You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

416 lines
19 KiB

package com.mydreamjob.skill;
import com.mydreamjob.skill.domain.SkillCategory;
import com.mydreamjob.skill.dto.SkillCloudItem;
import com.mydreamjob.skill.dto.SkillLearningPathItem;
import com.mydreamjob.skill.dto.SkillLearningPathResponse;
import com.mydreamjob.skill.dto.SkillMatchResponse;
import com.mydreamjob.skill.repository.SkillCategoryRepository;
import org.hibernate.boot.model.relational.Namespace;
import org.hibernate.boot.model.relational.Sequence;
import org.hibernate.mapping.Table;
import org.hibernate.tool.schema.spi.SchemaFilter;
import org.hibernate.tool.schema.spi.SchemaFilterProvider;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
@SpringBootTest
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:integrationdb_skill_learning_path;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.hbm2ddl.schema_filter_provider=com.mydreamjob.skill.SkillLearningPathServiceTest$SkillLearningPathSchemaFilterProvider"
})
@ActiveProfiles("integration-test")
@Transactional
class SkillLearningPathServiceTest {
private static final Long USER_ID = 7001L;
private static final Long INDUSTRY_ID = 1L;
private static final Long OCCUPATION_ID = 1L;
private static final Long CATEGORY_ID = 101L;
private static final Long EDUCATION_INDUSTRY_ID = 3L;
private static final Long INSTRUCTIONAL_DESIGNER_OCCUPATION_ID = 9L;
@Autowired
private SkillLearningPathService learningPathService;
@Autowired
private SkillMatchService matchService;
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Test
void v36LearningPathReusesMatchMissingSkillsWithoutReordering() {
SkillMatchResponse matchResponse = matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
SkillLearningPathResponse response = learningPathService.generate(
USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(matchResponse.missingSkills()).hasSize(10);
assertThat(response.total()).isEqualTo(matchResponse.missingSkills().size());
assertThat(response.items())
.extracting(SkillLearningPathItem::skillId)
.containsExactlyElementsOf(matchResponse.missingSkills().stream()
.map(SkillCloudItem::skillId)
.toList());
assertThat(response.items())
.extracting(SkillLearningPathItem::priority)
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
assertThat(response.items()).allSatisfy(item -> {
assertThat(item.skillName()).isNotBlank();
assertThat(item.categoryId()).isNotNull();
assertThat(item.categoryName()).isNotBlank();
assertThat(item.suggestion()).contains(item.skillName());
assertNoForbiddenContent(item.suggestion());
});
}
@Test
void v36LearningPathPreservesMatchOrderAcrossHeatGroupsWhenNameOrderDiffers() {
SkillMatchResponse matchResponse = matchService.match(
USER_ID, EDUCATION_INDUSTRY_ID, INSTRUCTIONAL_DESIGNER_OCCUPATION_ID, null);
SkillLearningPathResponse response = learningPathService.generate(
USER_ID, EDUCATION_INDUSTRY_ID, INSTRUCTIONAL_DESIGNER_OCCUPATION_ID, null);
List<SkillCloudItem> missingSkills = matchResponse.missingSkills();
List<String> missingNames = missingSkills.stream()
.map(SkillCloudItem::nameZh)
.toList();
List<Long> missingIds = missingSkills.stream()
.map(SkillCloudItem::skillId)
.toList();
List<BigDecimal> distinctHeats = missingSkills.stream()
.map(SkillCloudItem::heat)
.distinct()
.toList();
assertThat(missingSkills).hasSize(10);
assertThat(distinctHeats.size()).isGreaterThan(1);
assertThat(missingNames).isNotEqualTo(missingNames.stream().sorted().toList());
assertThat(missingIds).isNotEqualTo(missingIds.stream().sorted().toList());
assertThat(response.total()).isEqualTo(missingSkills.size());
assertThat(response.items())
.extracting(SkillLearningPathItem::skillId)
.containsExactlyElementsOf(missingIds);
assertThat(response.items())
.extracting(SkillLearningPathItem::skillName)
.containsExactlyElementsOf(missingNames);
assertThat(response.items())
.extracting(SkillLearningPathItem::priority)
.containsExactly(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
}
@Nested
class TemplateAndOrderingTests {
private final SkillMatchService matchService = mock(SkillMatchService.class);
private final SkillCategoryRepository categoryRepository = mock(SkillCategoryRepository.class);
private final SkillLearningPathService service =
new SkillLearningPathService(matchService, categoryRepository);
@Test
void usesOnlyMissingSkillsAndPreservesMissingOrderWithContiguousPriorities() {
SkillCloudItem matched = skill(11L, "Matched", "Matched", CATEGORY_ID,
"编程语言", new BigDecimal("9.00"));
List<SkillCloudItem> missing = List.of(
skill(30L, "Skill Z", "Skill Z", 101L, "编程语言", new BigDecimal("1.00")),
skill(10L, "Skill A", "Skill A", 102L, "框架", new BigDecimal("10.00")),
skill(20L, "Skill M", "Skill M", 103L, "工具", new BigDecimal("5.00"))
);
List<String> missingNames = missing.stream()
.map(SkillCloudItem::nameZh)
.toList();
List<Long> missingIds = missing.stream()
.map(SkillCloudItem::skillId)
.toList();
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(matched), missing));
stubCategory(101L, "PROGRAMMING_LANGUAGE");
stubCategory(102L, "FRAMEWORK");
stubCategory(103L, "TOOL");
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(response.total()).isEqualTo(3);
assertThat(missingNames).isNotEqualTo(missingNames.stream().sorted().toList());
assertThat(missingIds).isNotEqualTo(missingIds.stream().sorted().toList());
assertThat(response.items())
.extracting(SkillLearningPathItem::skillId)
.containsExactly(30L, 10L, 20L);
assertThat(response.items())
.extracting(SkillLearningPathItem::priority)
.containsExactly(1, 2, 3);
assertThat(response.items())
.extracting(SkillLearningPathItem::skillName)
.containsExactly("Skill Z", "Skill A", "Skill M");
assertThat(response.items())
.noneMatch(item -> item.skillId().equals(matched.skillId()));
verify(matchService).match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
verifyNoMoreInteractions(matchService);
verify(categoryRepository).findByIdAndOccupationId(101L, OCCUPATION_ID);
verify(categoryRepository).findByIdAndOccupationId(102L, OCCUPATION_ID);
verify(categoryRepository).findByIdAndOccupationId(103L, OCCUPATION_ID);
verifyNoMoreInteractions(categoryRepository);
}
@Test
void generatesProgrammingLanguageSuggestion() {
SkillCloudItem missing = skill(41L, "Java", "Java", CATEGORY_ID,
"编程语言", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), List.of(missing)));
stubCategory(CATEGORY_ID, "PROGRAMMING_LANGUAGE");
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)
.items().get(0);
assertThat(item.suggestion()).isEqualTo(
"先掌握Java的基础语法和常见数据结构,再完成至少一个编码练习。");
assertNoForbiddenContent(item.suggestion());
}
@Test
void generatesFrameworkSuggestion() {
SkillCloudItem missing = skill(42L, "Spring Boot", "Spring Boot", CATEGORY_ID,
"框架", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), List.of(missing)));
stubCategory(CATEGORY_ID, "FRAMEWORK");
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)
.items().get(0);
assertThat(item.suggestion()).isEqualTo(
"先理解Spring Boot的核心概念与生命周期,再通过一个小型项目练习集成。");
assertNoForbiddenContent(item.suggestion());
}
@Test
void generatesToolSuggestion() {
SkillCloudItem missing = skill(43L, "Docker", "Docker", CATEGORY_ID,
"工具", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), List.of(missing)));
stubCategory(CATEGORY_ID, "TOOL");
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)
.items().get(0);
assertThat(item.suggestion()).isEqualTo(
"先完成Docker的安装与基础配置,再结合现有工程完成一次实际操作。");
assertNoForbiddenContent(item.suggestion());
}
@Test
void generatesGeneralSuggestion() {
SkillCloudItem missing = skill(44L, "沟通能力", "Communication", CATEGORY_ID,
"通用能力", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), List.of(missing)));
stubCategory(CATEGORY_ID, "GENERAL");
SkillLearningPathItem item = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null)
.items().get(0);
assertThat(item.suggestion()).isEqualTo(
"先了解沟通能力的核心概念和常见应用场景,再通过一个简短实践巩固。");
assertNoForbiddenContent(item.suggestion());
}
@Test
void fallsBackToGeneralForUnknownNullAndMissingTemplateCodes() {
List<SkillCloudItem> missing = List.of(
skill(51L, "Unknown", "Unknown", 101L, "分类 A", BigDecimal.ONE),
skill(52L, "NullCode", "NullCode", 102L, "分类 B", BigDecimal.ONE),
skill(53L, "MissingCategory", "MissingCategory", 103L, "分类 C", BigDecimal.ONE)
);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), missing));
when(categoryRepository.findByIdAndOccupationId(101L, OCCUPATION_ID))
.thenReturn(Optional.of(category(101L, "UNKNOWN_TEMPLATE")));
when(categoryRepository.findByIdAndOccupationId(102L, OCCUPATION_ID))
.thenReturn(Optional.of(category(102L, null)));
when(categoryRepository.findByIdAndOccupationId(103L, OCCUPATION_ID))
.thenReturn(Optional.empty());
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(response.items())
.extracting(SkillLearningPathItem::suggestion)
.containsExactly(
"先了解Unknown的核心概念和常见应用场景,再通过一个简短实践巩固。",
"先了解NullCode的核心概念和常见应用场景,再通过一个简短实践巩固。",
"先了解MissingCategory的核心概念和常见应用场景,再通过一个简短实践巩固。"
);
response.items().forEach(item -> assertNoForbiddenContent(item.suggestion()));
}
@Test
void returnsEmptyPathAndDoesNotReadCategoriesWhenThereAreNoGaps() {
SkillCloudItem matched = skill(61L, "Java", "Java", CATEGORY_ID,
"编程语言", BigDecimal.ONE);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(matched), List.of()));
SkillLearningPathResponse response = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(response.total()).isZero();
assertThat(response.items()).isEmpty();
verifyNoInteractions(categoryRepository);
}
@Test
void repeatedCallsProduceIdenticalOrderPrioritiesAndText() {
List<SkillCloudItem> missing = List.of(
skill(71L, "Java", "Java", 101L, "编程语言", BigDecimal.ONE),
skill(72L, "Docker", "Docker", 102L, "工具", BigDecimal.ONE)
);
when(matchService.match(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null))
.thenReturn(matchResponse(List.of(), missing));
stubCategory(101L, "PROGRAMMING_LANGUAGE");
stubCategory(102L, "TOOL");
SkillLearningPathResponse first = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
SkillLearningPathResponse second = service.generate(USER_ID, INDUSTRY_ID, OCCUPATION_ID, null);
assertThat(second).isEqualTo(first);
assertThat(second.items())
.extracting(SkillLearningPathItem::skillId)
.containsExactly(71L, 72L);
assertThat(second.items())
.extracting(SkillLearningPathItem::priority)
.containsExactly(1, 2);
assertThat(second.items())
.extracting(SkillLearningPathItem::suggestion)
.containsExactly(
"先掌握Java的基础语法和常见数据结构,再完成至少一个编码练习。",
"先完成Docker的安装与基础配置,再结合现有工程完成一次实际操作。"
);
}
private void stubCategory(Long categoryId, String templateCode) {
when(categoryRepository.findByIdAndOccupationId(categoryId, OCCUPATION_ID))
.thenReturn(Optional.of(category(categoryId, templateCode)));
}
}
private static SkillMatchResponse matchResponse(List<SkillCloudItem> matched,
List<SkillCloudItem> missing) {
int total = matched.size() + missing.size();
int score = total == 0 ? 0 : (int) Math.round(matched.size() * 100.0 / total);
return new SkillMatchResponse(score, matched.size(), total, matched, missing);
}
private static SkillCloudItem skill(Long skillId,
String nameZh,
String nameEn,
Long categoryId,
String categoryName,
BigDecimal heat) {
return new SkillCloudItem(
skillId.intValue(),
skillId,
"SKILL_" + skillId,
nameZh,
nameEn,
"ESCO",
"source-" + skillId,
categoryId,
categoryName,
heat,
"15-1254.00",
"2.E.7.h",
false
);
}
private static SkillCategory category(Long categoryId, String templateCode) {
return SkillCategory.builder()
.id(categoryId)
.occupationId(OCCUPATION_ID)
.code("CATEGORY_" + categoryId)
.nameZh("分类 " + categoryId)
.suggestionTemplateCode(templateCode)
.sortOrder(categoryId.intValue())
.build();
}
private static void assertNoForbiddenContent(String suggestion) {
assertThat(suggestion)
.doesNotContain("http://", "https://", "课程", "讲师", "价格", "时长",
"分钟", "小时", "完成度", "AI");
}
public static class SkillLearningPathSchemaFilterProvider implements SchemaFilterProvider {
private static final Set<String> VALIDATED_TABLES = Set.of(
"skill_source_versions",
"skill_industries",
"skill_occupations",
"skill_categories",
"skills",
"skill_aliases",
"occupation_skills"
);
@Override
public SchemaFilter getCreateFilter() {
return SchemaFilter.ALL;
}
@Override
public SchemaFilter getDropFilter() {
return SchemaFilter.ALL;
}
@Override
public SchemaFilter getTruncatorFilter() {
return SchemaFilter.ALL;
}
@Override
public SchemaFilter getMigrateFilter() {
return SchemaFilter.ALL;
}
@Override
public SchemaFilter getValidateFilter() {
return new SchemaFilter() {
@Override
public boolean includeNamespace(Namespace namespace) {
return true;
}
@Override
public boolean includeTable(Table table) {
return VALIDATED_TABLES.contains(table.getName().toLowerCase(Locale.ROOT));
}
@Override
public boolean includeSequence(Sequence sequence) {
return false;
}
};
}
}
}