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
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;
|
|
}
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|