From b68519eb9f62b654f769a7b888abb71eec223380 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Wed, 16 Sep 2026 09:16:38 +0800 Subject: [PATCH] refactor: centralize scoring status and preference calculation --- docs/backend-development-roadmap.md | 2 +- docs/r01-c-pure-calculation-instruction.md | 17 ++++ docs/r01-c-pure-calculation-report.md | 24 ++++++ .../CandidateOverviewStatusPolicy.java | 51 ++++++++++++ .../CandidateScoringEngineService.java | 15 +--- .../CompetitionRankingCalculator.java | 16 +--- .../JobPreferenceScoreCalculator.java | 68 ++++++++++++++++ .../JobRecommendationService.java | 14 +++- .../CandidateOverviewStatusPolicyTest.java | 78 ++++++++++++++++++ .../CompetitionRankingCalculatorTest.java | 5 +- .../JobPreferenceScoreCalculatorTest.java | 80 +++++++++++++++++++ 11 files changed, 342 insertions(+), 28 deletions(-) create mode 100644 docs/r01-c-pure-calculation-instruction.md create mode 100644 docs/r01-c-pure-calculation-report.md create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculator.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicyTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculatorTest.java diff --git a/docs/backend-development-roadmap.md b/docs/backend-development-roadmap.md index 78d0e91..4d2938f 100644 --- a/docs/backend-development-roadmap.md +++ b/docs/backend-development-roadmap.md @@ -15,7 +15,7 @@ | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | Q01 岗位搜索真实查询覆盖(C02 证据补充) | 已完成实现、跨库一致性整改、独立复验及 Git 检查点(`77dc624`)。新增 54 条真实库测试:H2 Repository 21、H2 Service 9、H2 副作用 4、MySQL 8.4 Specification 15、MySQL 副作用 4、MySQL EXPLAIN 1;全量 1890/1890 | OpenAPI 保持 176/214;前导通配符、filesort 与真实规模执行计划移交 R01 | -| R01 结构与性能收束 | R01-A 已提交本地检查点 `0b5b849`;R01-B 已完成异常状态映射收束和自动化验证,待独立复验 | HTTP 映射保持整数码契约与未知码 400 兜底;后续 R01-C/D/E 仍须独立实施、复验和提交 | +| R01 结构与性能收束 | R01-A `0b5b849`、R01-B `15bd98b` 已建本地检查点;R01-C 已完成纯计算规则收束和自动化验证,待独立复验 | overview 状态保留两条历史输入口径;推荐偏好保持 30/20/20/15/15;后续 R01-D/E 独立实施 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | diff --git a/docs/r01-c-pure-calculation-instruction.md b/docs/r01-c-pure-calculation-instruction.md new file mode 100644 index 0000000..fca1342 --- /dev/null +++ b/docs/r01-c-pure-calculation-instruction.md @@ -0,0 +1,17 @@ +# R01-C 纯计算与重复规则收束实施指令 + +## 目标 + +在不改变评分、排名和推荐结果的前提下,收束候选人综合状态规则,并将岗位偏好评分拆成可独立验证的纯计算器。 + +## 行为边界 + +- 评分引擎继续按固定 8 维判断完整性,且 OK/ESTIMATED 必须有非空分数才可计算; +- 竞争排名继续按当前快照集合大小判断完整性,并保持历史“按状态可计算”的口径; +- 推荐偏好权重保持城市30、用工类型20、最低薪资20、行业15、岗位族15; +- 薪资边界继续使用 `salaryMax >= expectedSalaryMin`; +- 百分比继续保留两位小数并使用 HALF_UP; +- 不改数据库、接口、DTO、排序、分页、权限、审计或开关语义; +- 不进入 R01-D/E,不暂存、不提交、不推送。 + +必须先用特征测试锁定两套 overview 输入适配差异和偏好评分边界,再替换生产调用点。 diff --git a/docs/r01-c-pure-calculation-report.md b/docs/r01-c-pure-calculation-report.md new file mode 100644 index 0000000..d755d17 --- /dev/null +++ b/docs/r01-c-pure-calculation-report.md @@ -0,0 +1,24 @@ +# R01-C 纯计算与重复规则收束实施报告 + +> 状态:R01-C 已实现并通过自动化与干净构建,待独立代码复验。 + +## 实现结果 + +新增 `CandidateOverviewStatusPolicy`,用一个纯决策内核产出 OK/PARTIAL/PENDING/INSUFFICIENT_DATA。两个入口显式保留历史适配差异:评分引擎固定 8 维并要求 score 非空;竞争排名按已有快照数量和状态判断。原两个业务类中的 `overviewStatus` 实现均已删除。 + +新增 `JobPreferenceScoreCalculator`,把原先压缩在单行方法中的 30/20/20/15/15 权重计算提取为无 Spring、无数据库依赖的纯函数。`JobRecommendationService` 仅负责解析已有 JSON 字段并组装输入;权重、匹配条件、空信号返回 null、薪资相等边界及 HALF_UP 两位小数全部保持。 + +## 测试 + +- `CandidateOverviewStatusPolicyTest`:5 条,覆盖两个适配入口的 OK/PARTIAL/PENDING/INSUFFICIENT_DATA、空集合、固定八维、null score 差异,以及评分结果列表为 null 时的历史快速失败语义; +- `JobPreferenceScoreCalculatorTest`:7 条,覆盖无信号、全匹配、全不匹配、权重与舍入、薪资相等/null、null 集合,以及历史 JSON 数组中 null 元素的兼容性; +- 独立复核发现 `List.copyOf` 会拒绝历史 JSON 数组中的 null 元素,可能把原先可计算的数据变成 500;已改为允许 null 的防御性只读拷贝,并先补回归测试锁定旧语义; +- 定向关联集合:33/33; +- 第一次定向测试因夹具让城市和行业同时匹配却期望 66.67 而失败,修正为行业不匹配后同一断言通过;未改生产公式或放宽断言。 + +## 全量结果 + +- `mvn clean verify`:2094/2094,Failures=0、Errors=0、Skipped=20,BUILD SUCCESS;20 项均为既有环境条件跳过项,R01-C 定向与关联测试 Skipped=0; +- OpenAPI:176 paths / 214 operations,All checks passed; +- V1–V43 未修改,无新增接口、ErrorCode、依赖、表或索引; +- 暂存区为空,未提交、未推送,历史未跟踪产物保留。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java new file mode 100644 index 0000000..2ebcff6 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java @@ -0,0 +1,51 @@ +package com.mydreamjob.candidatescore; + +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +/** Single pure policy for candidate overview status aggregation. */ +public final class CandidateOverviewStatusPolicy { + + private CandidateOverviewStatusPolicy() { + } + + public static String fromComputedResults( + List results, int expectedDimensions) { + Objects.requireNonNull(results, "results"); + return evaluate(results, expectedDimensions, + result -> isComputable(result.status, result.score != null), + result -> "PENDING".equals(result.status), + "PENDING"); + } + + public static String fromSnapshots(List snapshots) { + if (snapshots == null || snapshots.isEmpty()) { + return "INSUFFICIENT_DATA"; + } + return evaluate(snapshots, snapshots.size(), + snapshot -> isComputable(snapshot.getStatus(), true), + snapshot -> "PENDING".equals(snapshot.getStatus()), + "INSUFFICIENT_DATA"); + } + + private static String evaluate(List values, int completeCount, + Predicate computable, Predicate pending, + String emptyStatus) { + if (values == null || values.isEmpty()) { + return emptyStatus; + } + long computableCount = values.stream().filter(computable).count(); + if (computableCount == completeCount) { + return "OK"; + } + if (computableCount > 0) { + return "PARTIAL"; + } + return values.stream().allMatch(pending) ? "PENDING" : "INSUFFICIENT_DATA"; + } + + private static boolean isComputable(String status, boolean valueAvailable) { + return valueAvailable && ("OK".equals(status) || "ESTIMATED".equals(status)); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java index d4e99c9..b868abc 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java @@ -123,7 +123,7 @@ public class CandidateScoringEngineService { .toList(); BigDecimal overall = computable.isEmpty() ? null : mean(computable.stream().map(r -> r.score).toList()); - String status = overviewStatus(results); + String status = CandidateOverviewStatusPolicy.fromComputedResults(results, TOTAL_DIMENSIONS); List dims = results.stream() .map(r -> CompanyCandidateScoringResponse.CompanyDimensionItem.builder() .dimension(r.dimension).score(r.score).status(r.status).build()) @@ -160,7 +160,9 @@ public class CandidateScoringEngineService { } } return CandidateScoringOverviewResponse.builder() - .candidateId(studentUserId).status(overviewStatus(results)).overallScore(overall) + .candidateId(studentUserId) + .status(CandidateOverviewStatusPolicy.fromComputedResults(results, TOTAL_DIMENSIONS)) + .overallScore(overall) .weights(weights).dimensions(items).rulesVersion(calculator.currentRulesVersion()) .generatedAt(capturedNow.atZone(clock.getZone()).toInstant()) .explanationVersion(com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationCatalog.VERSION) @@ -177,15 +179,6 @@ public class CandidateScoringEngineService { r, calculator.currentRulesVersion(), capturedNow); } - private String overviewStatus(List results) { - long computable = results.stream() - .filter(x -> ("OK".equals(x.status) || "ESTIMATED".equals(x.status)) && x.score != null).count(); - if (computable == TOTAL_DIMENSIONS) return "OK"; - if (computable > 0) return "PARTIAL"; - boolean allPending = results.stream().allMatch(x -> "PENDING".equals(x.status)); - return allPending ? "PENDING" : "INSUFFICIENT_DATA"; - } - private BigDecimal mean(List scores) { BigDecimal sum = scores.stream().reduce(ZERO, BigDecimal::add); return sum.divide(BigDecimal.valueOf(scores.size()), 2, RoundingMode.HALF_UP); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java index 5827ef7..2e509e0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java @@ -4,6 +4,7 @@ import com.mydreamjob.application.Application; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -99,7 +100,7 @@ public class CompetitionRankingCalculator { BigDecimal percentile = computePercentile(rows, me); List meSnaps = latestByStudent.getOrDefault(meId, List.of()); - String status = overviewStatus(meSnaps); + String status = CandidateOverviewStatusPolicy.fromSnapshots(meSnaps); String myPeriod = me == null ? null : me.snapshotPeriod; return new CohortRanking(rows.size(), rows, me, rank, @@ -123,17 +124,6 @@ public class CompetitionRankingCalculator { .setScale(2, RoundingMode.HALF_UP); } - /** 综合卡状态:OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义) */ - public String overviewStatus(List snaps) { - if (snaps == null || snaps.isEmpty()) return "INSUFFICIENT_DATA"; - long computable = snaps.stream() - .filter(s -> "OK".equals(s.getStatus()) || "ESTIMATED".equals(s.getStatus())).count(); - if (computable == snaps.size()) return "OK"; - if (computable > 0) return "PARTIAL"; - boolean allPending = snaps.stream().allMatch(s -> "PENDING".equals(s.getStatus())); - return allPending ? "PENDING" : "INSUFFICIENT_DATA"; - } - /** 排序载体(不参与响应序列化) */ public static class Row { public final Long studentUserId; @@ -178,4 +168,4 @@ public class CompetitionRankingCalculator { this.meSnaps = meSnaps; } } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculator.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculator.java new file mode 100644 index 0000000..7368f4a --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculator.java @@ -0,0 +1,68 @@ +package com.mydreamjob.recommendation; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Pure calculator for the existing 30/20/20/15/15 job-preference score. */ +public final class JobPreferenceScoreCalculator { + + private static final int CITY_WEIGHT = 30; + private static final int EMPLOYMENT_TYPE_WEIGHT = 20; + private static final int SALARY_WEIGHT = 20; + private static final int INDUSTRY_WEIGHT = 15; + private static final int JOB_FAMILY_WEIGHT = 15; + + private JobPreferenceScoreCalculator() { + } + + public static BigDecimal calculate(Criteria criteria, Target target) { + int matched = 0; + int available = 0; + + if (!criteria.cities().isEmpty()) { + available += CITY_WEIGHT; + if (criteria.cities().contains(target.city())) matched += CITY_WEIGHT; + } + if (!criteria.employmentTypes().isEmpty()) { + available += EMPLOYMENT_TYPE_WEIGHT; + if (criteria.employmentTypes().contains(target.employmentType())) matched += EMPLOYMENT_TYPE_WEIGHT; + } + if (criteria.expectedSalaryMin() != null) { + available += SALARY_WEIGHT; + if (target.salaryMax() != null && target.salaryMax() >= criteria.expectedSalaryMin()) { + matched += SALARY_WEIGHT; + } + } + if (!criteria.industries().isEmpty()) { + available += INDUSTRY_WEIGHT; + if (criteria.industries().contains(target.industryCode())) matched += INDUSTRY_WEIGHT; + } + if (!criteria.jobFamilies().isEmpty()) { + available += JOB_FAMILY_WEIGHT; + if (criteria.jobFamilies().contains(target.jobFamilyCode())) matched += JOB_FAMILY_WEIGHT; + } + return available == 0 ? null : BigDecimal.valueOf(matched * 100L) + .divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP); + } + + public record Criteria(List cities, List employmentTypes, Integer expectedSalaryMin, + List industries, List jobFamilies) { + public Criteria { + cities = copyPreservingNulls(cities); + employmentTypes = copyPreservingNulls(employmentTypes); + industries = copyPreservingNulls(industries); + jobFamilies = copyPreservingNulls(jobFamilies); + } + + private static List copyPreservingNulls(List values) { + return values == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(values)); + } + } + + public record Target(String city, String employmentType, Integer salaryMax, + String industryCode, String jobFamilyCode) { + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java index e997919..1cfa707 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java @@ -181,7 +181,19 @@ public class JobRecommendationService { } private Page slice(List all,int page,int size) { int from=Math.min(page*size,all.size()), to=Math.min(from+size,all.size()); return new PageImpl<>(all.subList(from,to), PageRequest.of(page,size),all.size()); } private boolean hardPreferenceMatch(StudentPreference p,Job j) { if(p==null)return true; List cities=list(p.getPreferredCities()); if(!cities.isEmpty()&&!cities.contains(j.getCity()))return false; return p.getExpectedSalaryMin()==null || j.getSalaryMax()==null || j.getSalaryMax()>=p.getExpectedSalaryMin(); } - private BigDecimal preferenceScore(StudentPreference p,JobRecommendationProfile profile,Job j) { if(p==null)return null; int got=0,total=0; List cities=list(p.getPreferredCities());if(!cities.isEmpty()){total+=30;if(cities.contains(j.getCity()))got+=30;} List types=list(p.getEmploymentTypes());if(!types.isEmpty()){total+=20;if(types.contains(j.getEmploymentType()))got+=20;} if(p.getExpectedSalaryMin()!=null){total+=20;if(j.getSalaryMax()!=null&&j.getSalaryMax()>=p.getExpectedSalaryMin())got+=20;} List industries=list(p.getPreferredIndustries());if(!industries.isEmpty()){total+=15;if(industries.contains(profile.getIndustryCode()))got+=15;} List families=list(p.getPreferredJobTypes());if(!families.isEmpty()){total+=15;if(families.contains(profile.getJobFamilyCode()))got+=15;} return total==0?null:BigDecimal.valueOf(got*100L).divide(BigDecimal.valueOf(total),2,RoundingMode.HALF_UP); } + private BigDecimal preferenceScore(StudentPreference preference, JobRecommendationProfile profile, Job job) { + if (preference == null) return null; + JobPreferenceScoreCalculator.Criteria criteria = new JobPreferenceScoreCalculator.Criteria( + list(preference.getPreferredCities()), + list(preference.getEmploymentTypes()), + preference.getExpectedSalaryMin(), + list(preference.getPreferredIndustries()), + list(preference.getPreferredJobTypes())); + JobPreferenceScoreCalculator.Target target = new JobPreferenceScoreCalculator.Target( + job.getCity(), job.getEmploymentType(), job.getSalaryMax(), + profile.getIndustryCode(), profile.getJobFamilyCode()); + return JobPreferenceScoreCalculator.calculate(criteria, target); + } private BigDecimal weighted(Map scores,Map expected,Set allowed){if(scores.isEmpty()||expected.isEmpty())return null; BigDecimal n=BigDecimal.ZERO,d=BigDecimal.ZERO;for(var e:expected.entrySet())if(allowed.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().signum()>0){n=n.add(scores.get(e.getKey()).multiply(e.getValue()));d=d.add(e.getValue());}return d.signum()==0?null:n.divide(d,2,RoundingMode.HALF_UP);} private BigDecimal abilityScore(Map scores,Map expected){if(scores.isEmpty()||expected.isEmpty())return null;BigDecimal total=BigDecimal.ZERO;int n=0;for(var e:expected.entrySet())if(ABILITY.contains(e.getKey())&&scores.containsKey(e.getKey())&&e.getValue().compareTo(BigDecimal.ZERO)>0){total=total.add(scores.get(e.getKey()).min(e.getValue()).multiply(BigDecimal.valueOf(100)).divide(e.getValue(),2,RoundingMode.HALF_UP));n++;}return n==0?null:total.divide(BigDecimal.valueOf(n),2,RoundingMode.HALF_UP);} private Map latestRiasec(Long student){return latest("SELECT r.dimension_scores_text FROM assessment_results r JOIN assessment_attempts a ON a.id=r.attempt_id JOIN assessment_definitions d ON d.id=a.definition_id WHERE a.student_user_id=? AND a.status='COMPLETED' AND d.type_code='HOLLAND_RIASEC' ORDER BY a.completed_at DESC,a.id DESC LIMIT 1",student,null,RIASEC);} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicyTest.java new file mode 100644 index 0000000..7255b19 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicyTest.java @@ -0,0 +1,78 @@ +package com.mydreamjob.candidatescore; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CandidateOverviewStatusPolicyTest { + + @Test + void nullComputedResultsPreserveHistoricalFailFastBehavior() { + assertThrows(NullPointerException.class, + () -> CandidateOverviewStatusPolicy.fromComputedResults(null, 8)); + } + + @Test + void computedResultsPreserveFixedEightDimensionContract() { + assertEquals("PENDING", CandidateOverviewStatusPolicy.fromComputedResults(List.of(), 8)); + assertEquals("OK", CandidateOverviewStatusPolicy.fromComputedResults(results(8, "OK", "80"), 8)); + + List partial = new ArrayList<>(results(1, "OK", "80")); + partial.addAll(results(7, "PENDING", null)); + assertEquals("PARTIAL", CandidateOverviewStatusPolicy.fromComputedResults(partial, 8)); + assertEquals("PENDING", CandidateOverviewStatusPolicy.fromComputedResults(results(8, "PENDING", null), 8)); + assertEquals("INSUFFICIENT_DATA", + CandidateOverviewStatusPolicy.fromComputedResults(results(8, "INSUFFICIENT_DATA", null), 8)); + } + + @Test + void computedResultRequiresScoreEvenWhenStatusLooksComputable() { + assertEquals("INSUFFICIENT_DATA", + CandidateOverviewStatusPolicy.fromComputedResults(results(8, "OK", null), 8)); + } + + @Test + void snapshotsPreserveExistingSnapshotCountAndStatusContract() { + assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(null)); + assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(List.of())); + assertEquals("OK", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("OK", "ESTIMATED"))); + assertEquals("PARTIAL", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("OK", "PENDING"))); + assertEquals("PENDING", CandidateOverviewStatusPolicy.fromSnapshots(snapshots("PENDING", "PENDING"))); + assertEquals("INSUFFICIENT_DATA", + CandidateOverviewStatusPolicy.fromSnapshots(snapshots("INSUFFICIENT_DATA", "PENDING"))); + } + + @Test + void snapshotsKeepHistoricalStatusOnlyComputabilityEvenWithNullScore() { + CandidateDimensionScore snapshot = snapshot("OK"); + snapshot.setScore(null); + assertEquals("OK", CandidateOverviewStatusPolicy.fromSnapshots(List.of(snapshot))); + } + + private List results(int count, String status, String score) { + List values = new ArrayList<>(); + for (int i = 0; i < count; i++) { + CandidateScoringCalculator.DimensionResult result = + new CandidateScoringCalculator.DimensionResult("D" + i); + result.status = status; + result.score = score == null ? null : new BigDecimal(score); + values.add(result); + } + return values; + } + + private List snapshots(String... statuses) { + List values = new ArrayList<>(); + for (String status : statuses) values.add(snapshot(status)); + return values; + } + + private CandidateDimensionScore snapshot(String status) { + return CandidateDimensionScore.builder().status(status).score(new BigDecimal("80")).build(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java index e710892..de32825 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java @@ -4,6 +4,7 @@ import com.mydreamjob.application.Application; import com.mydreamjob.candidatescore.CandidateDimensionScore; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateScoringCalculator; +import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -151,7 +152,7 @@ class CompetitionRankingCalculatorTest { @Test void overviewStatus_emptyIsInsufficientData() { - assertEquals("INSUFFICIENT_DATA", calculator.overviewStatus(List.of())); + assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(List.of())); } private Application app(Long sid, String created) { @@ -165,4 +166,4 @@ class CompetitionRankingCalculatorTest { .score(new BigDecimal("80")).status("OK") .weight(new BigDecimal("12.50")).rulesVersion("candidate-scoring-v0.1").build(); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculatorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculatorTest.java new file mode 100644 index 0000000..f5f858f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculatorTest.java @@ -0,0 +1,80 @@ +package com.mydreamjob.recommendation; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class JobPreferenceScoreCalculatorTest { + + @Test + void noAvailablePreferenceReturnsNull() { + assertNull(score(criteria(List.of(), List.of(), null, List.of(), List.of()), target())); + } + + @Test + void allFiveSignalsMatchReturnsOneHundred() { + assertEquals(new BigDecimal("100.00"), score(fullCriteria(), target())); + } + + @Test + void noSignalMatchesReturnsZeroWithTwoDecimals() { + JobPreferenceScoreCalculator.Target other = new JobPreferenceScoreCalculator.Target( + "Shenzhen", "PART_TIME", 9999, "FINANCE", "SALES"); + assertEquals(new BigDecimal("0.00"), score(fullCriteria(), other)); + } + + @Test + void weightsAndHalfUpRoundingRemainThirtyTwentyTwentyFifteenFifteen() { + JobPreferenceScoreCalculator.Criteria criteria = criteria( + List.of("Shanghai"), List.of(), null, List.of("FINANCE"), List.of()); + assertEquals(new BigDecimal("66.67"), score(criteria, target())); + } + + @Test + void salaryBoundaryIsInclusiveAndNullMaximumDoesNotMatch() { + JobPreferenceScoreCalculator.Criteria salaryOnly = + criteria(List.of(), List.of(), 20000, List.of(), List.of()); + assertEquals(new BigDecimal("100.00"), score(salaryOnly, + new JobPreferenceScoreCalculator.Target(null, null, 20000, null, null))); + assertEquals(new BigDecimal("0.00"), score(salaryOnly, + new JobPreferenceScoreCalculator.Target(null, null, null, null, null))); + } + + @Test + void nullCollectionsAreTreatedAsUnavailable() { + assertNull(score(new JobPreferenceScoreCalculator.Criteria(null, null, null, null, null), target())); + } + + @Test + void nullElementsInsideConfiguredListsRemainTolerated() { + JobPreferenceScoreCalculator.Criteria criteria = criteria( + Arrays.asList(null, "Shanghai"), List.of(), null, List.of(), List.of()); + assertEquals(new BigDecimal("100.00"), score(criteria, target())); + } + + private BigDecimal score(JobPreferenceScoreCalculator.Criteria criteria, + JobPreferenceScoreCalculator.Target target) { + return JobPreferenceScoreCalculator.calculate(criteria, target); + } + + private JobPreferenceScoreCalculator.Criteria fullCriteria() { + return criteria(List.of("Shanghai"), List.of("FULL_TIME"), 20000, + List.of("TECH"), List.of("ENGINEERING")); + } + + private JobPreferenceScoreCalculator.Criteria criteria( + List cities, List types, Integer salary, + List industries, List families) { + return new JobPreferenceScoreCalculator.Criteria(cities, types, salary, industries, families); + } + + private JobPreferenceScoreCalculator.Target target() { + return new JobPreferenceScoreCalculator.Target( + "Shanghai", "FULL_TIME", 20000, "TECH", "ENGINEERING"); + } +}