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 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 missingSkills = matchResponse.missingSkills(); List missingNames = missingSkills.stream() .map(SkillCloudItem::nameZh) .toList(); List missingIds = missingSkills.stream() .map(SkillCloudItem::skillId) .toList(); List 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 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 missingNames = missing.stream() .map(SkillCloudItem::nameZh) .toList(); List 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 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 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 matched, List 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 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; } }; } } }