Browse Source

refactor: centralize scoring status and preference calculation

master
Zhang Shun 1 month ago
parent
commit
b68519eb9f
  1. 2
      docs/backend-development-roadmap.md
  2. 17
      docs/r01-c-pure-calculation-instruction.md
  3. 24
      docs/r01-c-pure-calculation-report.md
  4. 51
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java
  5. 15
      mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java
  6. 14
      mydreamjob-server/src/main/java/com/mydreamjob/competition/CompetitionRankingCalculator.java
  7. 68
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculator.java
  8. 14
      mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java
  9. 78
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicyTest.java
  10. 3
      mydreamjob-server/src/test/java/com/mydreamjob/competition/CompetitionRankingCalculatorTest.java
  11. 80
      mydreamjob-server/src/test/java/com/mydreamjob/recommendation/JobPreferenceScoreCalculatorTest.java

2
docs/backend-development-roadmap.md

@ -15,7 +15,7 @@
| S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 | | S02 Access/Refresh Token 隔离 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | 类型隔离、独立密钥、轮换、重放处置、会话吊销已锁定;OpenAPI 175/213 |
| S03 生产配置与简历隐私 | 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收 | V43 禁用原始指纹种子账号;管理员初始化默认关闭且幂等;企业简历下载按隐私决策表授权;Actuator 隔离到管理端口;OpenAPI 176/214 | | 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 | | 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仍属一期,细节设计后编码 | | 其余五个功能组 | 技能词云、入职清单/公司知识、薪资计算器/参考、诚信积分/激励、简历查重 | N03–N06、N08仍属一期,细节设计后编码 |
| 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 | | 上线保障 | 现有资产待逐项核验 | G01–G04,一期交付门槛 |
| 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 | | 后续候选池 | AI简历、AI模拟面试/预测、SHAP高级解释等 | 以清单第6节为准,移出一期 |

17
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 输入适配差异和偏好评分边界,再替换生产调用点。

24
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、依赖、表或索引;
- 暂存区为空,未提交、未推送,历史未跟踪产物保留。

51
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<CandidateScoringCalculator.DimensionResult> 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<CandidateDimensionScore> 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 <T> String evaluate(List<T> values, int completeCount,
Predicate<T> computable, Predicate<T> 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));
}
}

15
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringEngineService.java

@ -123,7 +123,7 @@ public class CandidateScoringEngineService {
.toList(); .toList();
BigDecimal overall = computable.isEmpty() ? null BigDecimal overall = computable.isEmpty() ? null
: mean(computable.stream().map(r -> r.score).toList()); : mean(computable.stream().map(r -> r.score).toList());
String status = overviewStatus(results); String status = CandidateOverviewStatusPolicy.fromComputedResults(results, TOTAL_DIMENSIONS);
List<CompanyCandidateScoringResponse.CompanyDimensionItem> dims = results.stream() List<CompanyCandidateScoringResponse.CompanyDimensionItem> dims = results.stream()
.map(r -> CompanyCandidateScoringResponse.CompanyDimensionItem.builder() .map(r -> CompanyCandidateScoringResponse.CompanyDimensionItem.builder()
.dimension(r.dimension).score(r.score).status(r.status).build()) .dimension(r.dimension).score(r.score).status(r.status).build())
@ -160,7 +160,9 @@ public class CandidateScoringEngineService {
} }
} }
return CandidateScoringOverviewResponse.builder() 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()) .weights(weights).dimensions(items).rulesVersion(calculator.currentRulesVersion())
.generatedAt(capturedNow.atZone(clock.getZone()).toInstant()) .generatedAt(capturedNow.atZone(clock.getZone()).toInstant())
.explanationVersion(com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationCatalog.VERSION) .explanationVersion(com.mydreamjob.candidatescore.explanation.CandidateScoringExplanationCatalog.VERSION)
@ -177,15 +179,6 @@ public class CandidateScoringEngineService {
r, calculator.currentRulesVersion(), capturedNow); r, calculator.currentRulesVersion(), capturedNow);
} }
private String overviewStatus(List<CandidateScoringCalculator.DimensionResult> 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<BigDecimal> scores) { private BigDecimal mean(List<BigDecimal> scores) {
BigDecimal sum = scores.stream().reduce(ZERO, BigDecimal::add); BigDecimal sum = scores.stream().reduce(ZERO, BigDecimal::add);
return sum.divide(BigDecimal.valueOf(scores.size()), 2, RoundingMode.HALF_UP); return sum.divide(BigDecimal.valueOf(scores.size()), 2, RoundingMode.HALF_UP);

14
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.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -99,7 +100,7 @@ public class CompetitionRankingCalculator {
BigDecimal percentile = computePercentile(rows, me); BigDecimal percentile = computePercentile(rows, me);
List<CandidateDimensionScore> meSnaps = latestByStudent.getOrDefault(meId, List.of()); List<CandidateDimensionScore> meSnaps = latestByStudent.getOrDefault(meId, List.of());
String status = overviewStatus(meSnaps); String status = CandidateOverviewStatusPolicy.fromSnapshots(meSnaps);
String myPeriod = me == null ? null : me.snapshotPeriod; String myPeriod = me == null ? null : me.snapshotPeriod;
return new CohortRanking(rows.size(), rows, me, rank, return new CohortRanking(rows.size(), rows, me, rank,
@ -123,17 +124,6 @@ public class CompetitionRankingCalculator {
.setScale(2, RoundingMode.HALF_UP); .setScale(2, RoundingMode.HALF_UP);
} }
/** 综合卡状态:OK / PARTIAL / INSUFFICIENT_DATA / PENDING(复用引擎语义) */
public String overviewStatus(List<CandidateDimensionScore> 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 static class Row {
public final Long studentUserId; public final Long studentUserId;

68
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<String> cities, List<String> employmentTypes, Integer expectedSalaryMin,
List<String> industries, List<String> jobFamilies) {
public Criteria {
cities = copyPreservingNulls(cities);
employmentTypes = copyPreservingNulls(employmentTypes);
industries = copyPreservingNulls(industries);
jobFamilies = copyPreservingNulls(jobFamilies);
}
private static List<String> copyPreservingNulls(List<String> values) {
return values == null ? List.of() : Collections.unmodifiableList(new ArrayList<>(values));
}
}
public record Target(String city, String employmentType, Integer salaryMax,
String industryCode, String jobFamilyCode) {
}
}

14
mydreamjob-server/src/main/java/com/mydreamjob/recommendation/JobRecommendationService.java

@ -181,7 +181,19 @@ public class JobRecommendationService {
} }
private <T> Page<T> slice(List<T> 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 <T> Page<T> slice(List<T> 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<String> 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 boolean hardPreferenceMatch(StudentPreference p,Job j) { if(p==null)return true; List<String> 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<String> cities=list(p.getPreferredCities());if(!cities.isEmpty()){total+=30;if(cities.contains(j.getCity()))got+=30;} List<String> 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<String> industries=list(p.getPreferredIndustries());if(!industries.isEmpty()){total+=15;if(industries.contains(profile.getIndustryCode()))got+=15;} List<String> 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<String,BigDecimal> scores,Map<String,BigDecimal> expected,Set<String> 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 weighted(Map<String,BigDecimal> scores,Map<String,BigDecimal> expected,Set<String> 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<String,BigDecimal> scores,Map<String,BigDecimal> 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 BigDecimal abilityScore(Map<String,BigDecimal> scores,Map<String,BigDecimal> 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<String,BigDecimal> 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);} private Map<String,BigDecimal> 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);}

78
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<CandidateScoringCalculator.DimensionResult> 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<CandidateScoringCalculator.DimensionResult> results(int count, String status, String score) {
List<CandidateScoringCalculator.DimensionResult> 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<CandidateDimensionScore> snapshots(String... statuses) {
List<CandidateDimensionScore> 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();
}
}

3
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.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository; import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator; import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@ -151,7 +152,7 @@ class CompetitionRankingCalculatorTest {
@Test @Test
void overviewStatus_emptyIsInsufficientData() { void overviewStatus_emptyIsInsufficientData() {
assertEquals("INSUFFICIENT_DATA", calculator.overviewStatus(List.of())); assertEquals("INSUFFICIENT_DATA", CandidateOverviewStatusPolicy.fromSnapshots(List.of()));
} }
private Application app(Long sid, String created) { private Application app(Long sid, String created) {

80
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<String> cities, List<String> types, Integer salary,
List<String> industries, List<String> families) {
return new JobPreferenceScoreCalculator.Criteria(cities, types, salary, industries, families);
}
private JobPreferenceScoreCalculator.Target target() {
return new JobPreferenceScoreCalculator.Target(
"Shanghai", "FULL_TIME", 20000, "TECH", "ENGINEERING");
}
}
Loading…
Cancel
Save