forked from baskbuddy/zhangshun
11 changed files with 1991 additions and 54 deletions
@ -0,0 +1,347 @@ |
|||||
|
# R01-C 独立代码复验报告(评分与推荐规则结构收束) |
||||
|
|
||||
|
日期:2026-09-18 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(复验全程未变) |
||||
|
复验对象:`docs/r01-c-scoring-recommendation-rules-refactor-report.md` |
||||
|
性质:**仅复验** —— 未修改生产代码、未放宽既有断言、未暂存、未提交、未推送、未进入 R01-E |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论 |
||||
|
|
||||
|
> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。** |
||||
|
|
||||
|
- `overviewStatus` **确为唯一产出点**;三处调用方全部委托; |
||||
|
- 偏好公式实现点 **2 → 1**(HEAD 的 `TalentDiscoveryService` 确实内联了第二套等价公式); |
||||
|
- 985/211 名单定义点 **唯一**;规则集**不可变、无框架依赖、无 setter、集合只读**; |
||||
|
- 生产变更 **新增 1 + 修改 2 = 3 个生产文件**; |
||||
|
- 三类变异均使对应测试失败,恢复后**无残留**; |
||||
|
- Docker 已按给定路径启动,**Testcontainers 26 项真实执行、Skipped=0**; |
||||
|
- 一处**既有测试隔离脆弱性**被独立发现并定性(见 §10.3),**与 R01-C 无关**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 独立库存结果 |
||||
|
|
||||
|
不采信实施报告数字,独立搜索证据: |
||||
|
|
||||
|
| # | 核查项 | 独立结论 | 证据 | |
||||
|
|---|---|---|---| |
||||
|
| 1 | `overviewStatus` 是否只有 `CandidateOverviewStatusPolicy` 一个规则产出点 | **是** | 全 `src` 中 `"PARTIAL"` 仅在该类**主代码**出现一次(`CandidateOverviewStatusPolicy.java:43`);其余命中全部在测试的断言/测试数据中,非产出点 | |
||||
|
| 2 | 三个调用方是否全部委托该策略 | **是** | `CandidateScoringEngineService:123`、`:161`(`fromComputedResults`);`CompetitionRankingCalculator:103`(`fromSnapshots`)—— 均为直接委托调用 | |
||||
|
| 3 | `JobRecommendationService` 是否已经只是编排 | **是** | `preferenceScore` 方法体 13 行:构造 `Criteria`/`Target` → `JobPreferenceScoreCalculator.calculate(...)`,无任何内联权重 | |
||||
|
| 4 | `TalentDiscoveryService` 修复前是否确实维护了第二套等价偏好公式 | **是(已由 HEAD 证实)** | `git show HEAD:…TalentDiscoveryService.java` 的 `preferenceScore` 为内联 `total += 30/20/20/15/15` + `BigDecimal.valueOf(got*100L).divide(...,2,HALF_UP)` —— 与计算器**逐字等价** | |
||||
|
| 5 | 本轮是否只新增一个规则集并修改两个文件 | **是** | 新增 `CandidateScoringRuleSet.java`;修改 `CandidateScoringCalculator.java`、`TalentDiscoveryService.java` | |
||||
|
| 6 | **生产代码变更总数** | **新增 1 + 修改 2 = 3 个生产文件** | 与 `git diff --name-status` + 未跟踪文件清单一致 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. overviewStatus 唯一产出点证据 |
||||
|
|
||||
|
``` |
||||
|
main/java/com/mydreamjob/candidatescore/CandidateOverviewStatusPolicy.java:43: return "PARTIAL"; |
||||
|
``` |
||||
|
|
||||
|
`"PARTIAL"` 在**主代码**中的唯一出现点即为该策略类(第 43 行)。 |
||||
|
状态聚合器**必须**能够产出 `PARTIAL`,因此该字符串的出现位置即为产出点的充要证据。 |
||||
|
调用方 3 处(`CandidateScoringEngineService` ×2、`CompetitionRankingCalculator` ×1)全部为委托。 |
||||
|
|
||||
|
**结构门禁已加入独立复验测试**:扫描 `src/main/java/com/mydreamjob` 全部 `.java`, |
||||
|
断言含 `"PARTIAL"` 的文件**恰为** `CandidateOverviewStatusPolicy.java`。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 偏好公式修复前后实现点数量 |
||||
|
|
||||
|
| 时点 | 30/20/20/15/15 规则实现处 | 证据 | |
||||
|
|---|---|---| |
||||
|
| 修复前(HEAD) | **2** —— `recommendation/JobPreferenceScoreCalculator.java` + `talentdiscovery/TalentDiscoveryService.java`(内联) | `git grep -l "total += 30\|CITY_WEIGHT" HEAD` → 2 个文件 | |
||||
|
| 修复后(工作区) | **1** —— 仅 `recommendation/JobPreferenceScoreCalculator.java` | 同 grep → 1 个文件 | |
||||
|
|
||||
|
`preferenceScore` 方法定义仍为 2 处(两个 Service 各一),但**两处都只是编排**,均调用 |
||||
|
`JobPreferenceScoreCalculator.calculate(...)`;规则**实现**唯一。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. 985/211 名单定义点数量 |
||||
|
|
||||
|
| 时点 | 定义点 | 证据 | |
||||
|
|---|---|---| |
||||
|
| HEAD | **2**(`C985`、`C211`)**均在** `CandidateScoringCalculator.java` | `git grep "Set<String> C985\|Set<String> C211" HEAD` | |
||||
|
| 工作区 | **2**(`C985`、`C211`)**均在** `candidatescore/rules/CandidateScoringRuleSet.java` | `grep` 主代码 → 唯一文件 | |
||||
|
|
||||
|
判定为"唯一权威定义点":名单**只在规范集内定义一次**,计算器内**已无**名单定义 |
||||
|
(独立测试断言 `CandidateScoringCalculator.java` 不含 `Set<String> C985` / `C211`)。 |
||||
|
|
||||
|
| 项 | HEAD | 工作区 | 一致 | |
||||
|
|---|---|---|---| |
||||
|
| C985 唯一项 | 38 | **38** ✔ | | |
||||
|
| C211 字面量条数 | 62 | **62** ✔ | | |
||||
|
| C211 唯一项 | 61 | **61** ✔ | | |
||||
|
| `北京科技大学` 重复项 | 存在 | **原样保留** ✔ | | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 规则集不可变性和依赖边界 |
||||
|
|
||||
|
逐项核验 `CandidateScoringRuleSet.java`: |
||||
|
|
||||
|
| 要求 | 结果 | |
||||
|
|---|---| |
||||
|
| `final` | ✔ `public final class CandidateScoringRuleSet`(私有构造) | |
||||
|
| 无 setter | ✔ 主代码 `grep "public void set"` 命中 0 | |
||||
|
| 无可变集合泄漏 | ✔ 集合经 `Collections.unmodifiableSet(new HashSet<>(...))` 包装后返回 | |
||||
|
| 返回集合不可修改 | ✔ 独立测试断言 `add`/`remove`/`clear` 均抛 `UnsupportedOperationException` | |
||||
|
| 无 Spring 注解 | ✔ `@Component`/`@Service`/`@Autowired`/`@Value` 命中 0 | |
||||
|
| 无数据库/Repository | ✔ `Repository`/`JdbcTemplate`/`EntityManager` 命中 0 | |
||||
|
| 无配置中心/环境变量 | ✔ `System.getenv`/`Properties` 命中 0 | |
||||
|
| 不接运行时可变配置 | ✔ 全部规则为静态常量,只能随代码发布变更 | |
||||
|
| BigDecimal 字符串构造 | ✔ 17 个常量全部 `new BigDecimal("…")`;`new BigDecimal(<数字>)` 命中 **0** | |
||||
|
| 同值不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 为不同常量;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 同理 | |
||||
|
| 规则版本单一定义点 | ✔ `CandidateScoringRuleSet.VERSION` 为权威;`CandidateScoringCalculator.RULES_VERSION` 现**指向该常量**(赋值而非字面量) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. BigDecimal 精确结果与边界表(独立测试推导) |
||||
|
|
||||
|
独立复验测试 `R01CIndependentVerificationTest`(18 个用例)全部通过, |
||||
|
期望值**手工推算**,断言 `isEqualByComparingTo` + `scale` + `toPlainString()`(不使用近似比较): |
||||
|
|
||||
|
| 输入 | 期望 | 说明 | |
||||
|
|---|---|---| |
||||
|
| 五维全命中(可用权重 100) | **100.00** | 上界 | |
||||
|
| 五维全缺失 / 零分母 | **null** | 非 0.00 | |
||||
|
| 城市(30) 命中、其余缺失 | **100.00** | 单维独立 | |
||||
|
| 用工类型(20) 命中、其余缺失 | **100.00** | | |
||||
|
| 薪资(20) 命中(边界相等 20000 == 20000) | **100.00** | 相等视为命中 | |
||||
|
| 行业(15) 命中、其余缺失 | **100.00** | | |
||||
|
| 岗位族(15) 命中、其余缺失 | **100.00** | | |
||||
|
| 城市命中(30) + 行业未命中(15) → 30/45 | **66.67** | HALF_UP 舍入 | |
||||
|
| 城市未命中(30) + 行业命中(15) → 15/45 | **33.33** | | |
||||
|
| 四方存在命中两项 → 15/65 | **23.08** | | |
||||
|
| 薪资低一单位(19999 < 20000) | **0.00** | 下界 | |
||||
|
| 薪资高一单位(20001) | **100.00** | | |
||||
|
| 岗位 salaryMax 为 null | **0.00** | | |
||||
|
|
||||
|
**跨服务逐值一致**:以反射调用两个 Service 的私有 `preferenceScore`,同一组输入下 |
||||
|
`JobRecommendationService` 与 `TalentDiscoveryService` 的返回值 |
||||
|
**`isEqualByComparingTo` 相等且 `scale` 相同**(三组输入:全命中 / 部分命中 / 全不命中)。 |
||||
|
|
||||
|
**overviewStatus 边界**(独立推导):全可用→`OK`;部分可用→`PARTIAL`;全不可用(全 `PENDING`)→`PENDING`; |
||||
|
含 `INSUFFICIENT_DATA`→`INSUFFICIENT_DATA`;空列表(计算结果入口)→`PENDING`、(快照入口)→`INSUFFICIENT_DATA`; |
||||
|
`null`(计算结果入口)→`NPE`;未知状态字符串→`INSUFFICIENT_DATA`;**输入顺序反转结果不变**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. 三类变异测试的失败证据 |
||||
|
|
||||
|
| # | 变异 | 文件 | 结果 | |
||||
|
|---|---|---|---| |
||||
|
| 1 | `if (computableCount > 0)` → `> 1`(overviewStatus 边界) | `CandidateOverviewStatusPolicy` | **18 run / 1 failure** ✔ | |
||||
|
| 2 | `INDUSTRY_WEIGHT = 15` → `10`(偏好权重) | `JobPreferenceScoreCalculator` | **18 run / 1 failure** ✔ | |
||||
|
| 3 | 985 名单删除 `"北京大学", `(被测试覆盖的学校) | `CandidateScoringRuleSet` | **18 run / 1 failure** ✔ | |
||||
|
|
||||
|
**恢复核验**: |
||||
|
|
||||
|
- 变异前的模式唯一性检查:三个替换串在目标文件中的出现次数均为 **1**; |
||||
|
- 恢复后 `git diff --quiet` 对两个**已跟踪**目标文件均为**逐字节一致**; |
||||
|
其中 `JobPreferenceScoreCalculator.java` 的 SHA-256 恢复为 `5771647b…`, |
||||
|
与**首次诊断时**测得的哈希完全相同; |
||||
|
- 未跟踪的 `CandidateScoringRuleSet.java` 经内容检查确认变异条目已还原 |
||||
|
(`"北京大学", "清华大学", "中国人民大学"` 出现次数为 1); |
||||
|
- `git diff --name-status` 仅显示 3 个预期文件,**无变异残留**;`git diff --check` exit 0; |
||||
|
- **未使用 `git restore` / `git checkout`**。 |
||||
|
|
||||
|
> 透明说明:脚本首次报告的三次 `restored_ok=NO` 经逐层排查确认是**我方脚本的变量取值缺陷**, |
||||
|
> 而非文件损坏 —— 用同一 Python 读写惯用法对未变异文件做往返实验,SHA-256 保持不变; |
||||
|
> 再对目标文件执行"变异→还原"全程,A/B 哈希一致(`5771647b…`)。 |
||||
|
> 上述三项内容级核验(git 逐字节一致 + 哈希比对 + 变异条目还原)均已独立完成。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. Docker 启动路径与 `docker info` 结果 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 复验开始时 `docker info` | **失败**(`failed to connect … npipe:////./pipe/dockerDesktopLinuxEngine`) | |
||||
|
| 指定的已知路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe` —— **存在**(15,142,832 字节,2025-07-23) | |
||||
|
| 启动命令 | `Start-Process -FilePath 'D:\DevApps\DockerDesktop\Docker Desktop.exe' -WindowStyle Hidden` | |
||||
|
| 轮询 | 每 15 秒一次,上限 180 秒 | |
||||
|
| **结果** | **约 15 秒后成功**:`docker info` → `ServerVersion=29.6.2` ✔ | |
||||
|
|
||||
|
未重建、删除或清理任何容器、volume 或 image。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. Testcontainers 实际执行数 |
||||
|
|
||||
|
``` |
||||
|
mvn -Dtest="JobSearchSpecificationMySqlIntegrationTest,CandidateScoringRefreshMySqlIntegrationTest,JobSearchQuerySideEffectMySqlIntegrationTest,JobSearchQueryExplainMySqlIntegrationTest" test |
||||
|
→ Tests run: 26, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
||||
|
``` |
||||
|
|
||||
|
| 测试类 | Tests | Skipped | |
||||
|
|---|---|---| |
||||
|
| `JobSearchSpecificationMySqlIntegrationTest` | 15 | **0** | |
||||
|
| `CandidateScoringRefreshMySqlIntegrationTest` | 6 | **0** | |
||||
|
| `JobSearchQuerySideEffectMySqlIntegrationTest` | 4 | **0** | |
||||
|
| `JobSearchQueryExplainMySqlIntegrationTest` | 1 | **0** | |
||||
|
| **合计** | **26** | **0** ✔ | |
||||
|
|
||||
|
这 26 项正是此前因 Docker 不可用而跳过的全部用例,现已**真实执行并通过**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. 定向、全量、clean verify 的 Tests/Failures/Errors/Skipped |
||||
|
|
||||
|
| 命令 | Tests | Failures | Errors | Skipped | 结论 | |
||||
|
|---|---|---|---|---|---| |
||||
|
| 独立复验测试 `R01CIndependentVerificationTest` | **18** | 0 | 0 | 0 | ✔ | |
||||
|
| 定向 `*CandidateScoring*Test,*OverviewStatus*Test,*JobPreference*Test,*JobRecommendation*Test,*TalentDiscovery*Test,*Fairness*Test` | 296 | **2** | 0 | 0 | 见 §10.3(既有隔离脆弱性) | |
||||
|
| `mvn test` | 见 §10.1 | | | | | |
||||
|
| `mvn clean verify` | 见 §10.1 | | | | | |
||||
|
|
||||
|
### 10.1 全量与 clean verify |
||||
|
|
||||
|
``` |
||||
|
mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
||||
|
mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
||||
|
``` |
||||
|
|
||||
|
| 项 | 数值 | |
||||
|
|---|---| |
||||
|
| 测试总数 | **2743** | |
||||
|
| 下限校验 | 实施基线 **2725** + 本轮独立新增 **18** = **2743** ✔ **恰好等于下限** | |
||||
|
| Failures / Errors / **Skipped** | **0 / 0 / 0** ✔ | |
||||
|
| BUILD | **SUCCESS** ✔ | |
||||
|
|
||||
|
**Skipped=0 的达成路径**:实施阶段因 Docker 不可用而跳过的 26 个 Testcontainers 用例, |
||||
|
在本轮 Docker 启动后**全部真实执行**(见 §9),因此不再计入 Skipped; |
||||
|
`2743 = 2725(实施阶段总数,含 26 项跳过)+ 18(本轮独立新增)= 2699 执行 + 26 执行 + 18`。 |
||||
|
|
||||
|
**该结果同时反证 §10.3 的定性**:`FairnessMonitoringHttpIntegrationTest` 在**全量顺序下通过** |
||||
|
(`mvn test` 与 `mvn clean verify` 均为 0 失败),说明其失败只在我方**自选的定向类组合**下触发, |
||||
|
是组合相关的既有隔离脆弱性,而非 R01-C 的行为差异。 |
||||
|
|
||||
|
### 10.2 Testcontainers 已执行 |
||||
|
|
||||
|
见 §9(26 项,Skipped=0)。 |
||||
|
|
||||
|
### 10.3 定向命令中的 2 个失败:既有测试隔离脆弱性(与 R01-C 无关) |
||||
|
|
||||
|
**现象**:定向命令中 `FairnessMonitoringHttpIntegrationTest` 有 2 项失败: |
||||
|
|
||||
|
``` |
||||
|
$.data.populationSize expected:<100> but was:<115> |
||||
|
$.data.populationSize expected:<200> but was:<215> |
||||
|
``` |
||||
|
|
||||
|
**定性证据链**: |
||||
|
|
||||
|
1. **单独运行该类:4/4 全部通过**(`mvn -Dtest="FairnessMonitoringHttpIntegrationTest" test` → `Tests run: 4, Failures: 0`); |
||||
|
2. 失败仅在**与其他类同批**时出现,且两次都恰好多 **15** 行 → 典型的共享 H2 库数据泄漏; |
||||
|
3. **泄漏源已定位**:`candidatescore/dashboard/CompanyScoringDashboardPersistenceIntegrationTest` |
||||
|
以 `LocalDate.now()` 生成 **当前期间**(`period = String.format("%04d-%02d", …)`), |
||||
|
并向 `candidate_dimension_scores` 插入该期间的快照; |
||||
|
`FairnessMonitoringHttpIntegrationTest` 同样使用 `YearMonth.now()`,且其 `populationSize` |
||||
|
**未按自身前缀限定范围**,因此把同批类的行一并计入; |
||||
|
4. **该泄漏源不在 R01-C 改动范围内**(`git diff --name-only` 中无该文件); |
||||
|
5. R01-C 的生产改动仅为"教育规则常量改读规则集"与"偏好分改委托共用计算器", |
||||
|
**不涉及任何持久化、清理或计数逻辑**。 |
||||
|
|
||||
|
**判定**:属**既有测试隔离脆弱性**(与 A6/R01-B 定位的同类问题同源),**非 R01-C 行为差异**, |
||||
|
不构成 R01-C 阻断项。按任务要求**如实记录、不写成通过**,也不修改该类测试。 |
||||
|
|
||||
|
**明确结论**: |
||||
|
|
||||
|
1. **R01-C 单独测试通过** —— `FairnessMonitoringHttpIntegrationTest` 单独运行 4/4 全绿; |
||||
|
全量 `mvn test` / `mvn clean verify`(2743/2743)中该类亦通过; |
||||
|
2. **泄漏源(`CompanyScoringDashboardPersistenceIntegrationTest`)与受害测试 |
||||
|
(`FairnessMonitoringHttpIntegrationTest`)均不在 R01-C 生产改动内**(3 个生产文件中无它们); |
||||
|
3. **该问题将由下一独立纯测试提交修复**(修污染源:独占 period/studentId + 精确清理), |
||||
|
**不并入 R01-C 提交**; |
||||
|
4. **该次定向组合命令(296/2 失败)不得写成全绿** —— 上表如实保留 `Failures=2`。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. OpenAPI 数量 |
||||
|
|
||||
|
``` |
||||
|
python ..\docs\verify_openapi.py |
||||
|
→ paths: 176 / operations: 214 |
||||
|
→ All checks passed! |
||||
|
``` |
||||
|
|
||||
|
`git diff -- docs/openapi-rc.json` → **空**,`openapi-rc.json` **未被修改** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. 精确文件清单 |
||||
|
|
||||
|
**本轮 R01-C 生产改动(新增 1 + 修改 2 = 3 个生产文件)** |
||||
|
|
||||
|
``` |
||||
|
新增: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java |
||||
|
修改: mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/CandidateScoringCalculator.java |
||||
|
修改: mydreamjob-server/src/main/java/com/mydreamjob/talentdiscovery/TalentDiscoveryService.java |
||||
|
``` |
||||
|
|
||||
|
**实施者新增测试(4)**:`mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/` 下 |
||||
|
`OverviewStatusPolicyCharacterizationTest`、`PreferenceScoreCharacterizationTest`、 |
||||
|
`CandidateScoringRuleSetCharacterizationTest`、`RecommendationOrderingCharacterizationTest` |
||||
|
|
||||
|
**本轮独立复验新增(2)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/R01CIndependentVerificationTest.java |
||||
|
docs/r01-c-independent-verification-20260918.md |
||||
|
``` |
||||
|
|
||||
|
**文档(1 修改)**:`docs/backend-development-roadmap.md` |
||||
|
|
||||
|
**本轮未修改任何生产代码** —— 复验前后 `git diff --name-only -- …/src/main` 恒为上述 2 个文件。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13. 是否修改迁移、ErrorCode、接口、依赖 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| V1–V45 / Flyway | **未修改**(`git diff --name-status -- …/db/migration` 为空) | |
||||
|
| `ErrorCode` | **未修改** | |
||||
|
| DTO | **未修改** | |
||||
|
| 公网接口 / OpenAPI 契约 | **未修改**(176/214,`openapi-rc.json` 未变) | |
||||
|
| `pom.xml` / 依赖 | **未修改** | |
||||
|
| Security / Token / 角色 / requestId | **未修改** | |
||||
|
| D4 快照物化语义 | **未修改** | |
||||
|
| 排名、申诉、公平性抑制规则 | **未修改** | |
||||
|
|
||||
|
**未为了测试通过修改生产公式或放宽既有断言**(独立测试的期望值均独立推导; |
||||
|
实施者的 40 个特征测试断言未被改动)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14. Git 状态 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 | `refactor/r01-structure-performance` ✔ | |
||||
|
| HEAD | `9954600650c3e7cd40a5f4fa55bc38595973f214`(未变)✔ | |
||||
|
| `git diff --cached --name-only` | **空** ✔ | |
||||
|
| `git diff --check` | **exit 0** ✔ | |
||||
|
| `git diff --name-status` | 仅 `docs/backend-development-roadmap.md`、`CandidateScoringCalculator.java`、`TalentDiscoveryService.java`(**无变异残留**)✔ | |
||||
|
| HEAD 与 upstream | `0 0` ✔ | |
||||
|
| 历史未跟踪文件 | 完整保留 ✔ | |
||||
|
| 残留 Java/Maven 进程 | 无 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15. 未暂存、未提交、未推送、未进入 R01-E |
||||
|
|
||||
|
**确认:** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`; |
||||
|
未进入 R01-E;未继续开发。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
> **R01-C独立代码复验通过,可以进入精确暂存与独立提交检查点。** |
||||
@ -0,0 +1,437 @@ |
|||||
|
# R01-C 评分与推荐规则结构收束报告 |
||||
|
|
||||
|
日期:2026-09-18 |
||||
|
分支:`refactor/r01-structure-performance` |
||||
|
HEAD:`9954600650c3e7cd40a5f4fa55bc38595973f214`(R01-B 提交 `70b9e10` + 夹具修复 `9954600`) |
||||
|
本轮性质:**结构重构** —— 未暂存、未提交、未推送,待独立代码复验 |
||||
|
铁律:**所有输入对应的评分、状态、排序、推荐结果、解释内容、规则版本与舍入结果必须与重构前逐项一致** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 0. 结论与一处必须先说明的口径差异 |
||||
|
|
||||
|
> **R01-C评分与推荐规则结构收束完成,待独立代码复验。** |
||||
|
|
||||
|
**权威库存(§2)推翻了指令的两项前提,已按你的裁决实施:** |
||||
|
|
||||
|
| 指令主题 | 库存实际 | 本轮处置 | |
||||
|
|---|---|---| |
||||
|
| 1. 消除**重复的 overviewStatus** 规则 | **不存在重复**:全仓 `"PARTIAL"` 只出现在 `CandidateOverviewStatusPolicy`(状态聚合器必须能产出 PARTIAL),3 个调用点已全部委托它。该策略由既有提交 `b68519e` 创建 | **无可合并项,不做任何改动**;以结构门禁测试锁定"唯一产出点" | |
||||
|
| 2. 拆分 `JobRecommendationService.preferenceScore()` | 该目标方法**已是 13 行编排方法**,重逻辑已在 `JobPreferenceScoreCalculator`(同为 `b68519e` 产物)。**但** `TalentDiscoveryService.preferenceScore` 内联了**第二套**完全相同的 30/20/20/15/15 规则 | 按裁决**收束该真实重复**:改委托共用计算器,**规则实现处 2 → 1** | |
||||
|
| 3. 高校名单/阈值/权重强类型化 | **真实待办**:`CandidateScoringCalculator` 内为裸常量,无规则集类 | **已完成**:新增 `candidatescore/rules/CandidateScoringRuleSet` | |
||||
|
|
||||
|
**未为了"grep 归零"制造改动**;主题 1 的"已唯一"结论附结构门禁测试可证。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 1. 分支、HEAD、Git 状态 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| `git branch --show-current` | `refactor/r01-structure-performance` | |
||||
|
| `git rev-parse HEAD` | `9954600650c3e7cd40a5f4fa55bc38595973f214` ✔ | |
||||
|
| `git rev-list --left-right --count HEAD...@{upstream}` | `0 0` ✔ | |
||||
|
| `git diff --cached --name-only` | **空** ✔ | |
||||
|
| `git diff --check` | exit 0,无空白错误 ✔ | |
||||
|
| 开始前未提交的已跟踪修改 | **无**(工作区干净)✔ | |
||||
|
| 历史未跟踪文件 | 完整保留 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 2. 上游: 三份权威库存 |
||||
|
|
||||
|
### 2.1 overviewStatus 库存 |
||||
|
|
||||
|
| 项 | 事实 | |
||||
|
|---|---| |
||||
|
| 实现处 | **唯一**:`candidatescore/CandidateOverviewStatusPolicy`(`final`、纯静态、无 Spring 依赖、无 DB、无系统时间、无副作用) | |
||||
|
| 入口 | `fromComputedResults(List<DimensionResult>, int expectedDimensions)`、`fromSnapshots(List<CandidateDimensionScore>)` | |
||||
|
| 调用方 | `CandidateScoringEngineService`(2 处)、`CompetitionRankingCalculator`(1 处)—— 全部为委托 | |
||||
|
| 判断顺序 | ① 空/null → `emptyStatus`(计算结果入口为 `PENDING`,快照入口为 `INSUFFICIENT_DATA`);② 可计算数 == 期望数 → `OK`;③ 可计算数 > 0 → `PARTIAL`;④ 全为 `PENDING` → `PENDING`;⑤ 否则 `INSUFFICIENT_DATA` | |
||||
|
| 可计算定义 | `valueAvailable && ("OK".equals(status) || "ESTIMATED".equals(status))` | |
||||
|
| 阈值 | 无数值阈值(纯计数比较,边界为"相等"与"大于 0") | |
||||
|
| null 行为 | 计算结果入口:`results == null` → **NPE**(`Objects.requireNonNull`);快照入口:`null` → `INSUFFICIENT_DATA` | |
||||
|
| 边界相等 | `computableCount == completeCount` 判 OK(相等即 OK,不是严格大于) | |
||||
|
| 返回值 | 字符串状态(沿用既有字符串契约,**未新建语义重复的枚举**) | |
||||
|
| 是否参与响应/快照/解释/排名/监控 | 参与:候选评分概览响应、竞争力排名卡状态;不参与快照版本推进 | |
||||
|
| 两份实现是否等价 | **不适用 —— 只有一份实现** | |
||||
|
|
||||
|
### 2.2 preferenceScore 库存(重构前,两套) |
||||
|
|
||||
|
| 项 | `JobPreferenceScoreCalculator`(共用) | `TalentDiscoveryService`(内联,已收束) | |
||||
|
|---|---|---| |
||||
|
| 输入 | `Criteria(cities, employmentTypes, expectedSalaryMin, industries, jobFamilies)` + `Target(city, employmentType, salaryMax, industryCode, jobFamilyCode)` | `PreferenceSignal(cities, employmentTypes, expectedSalaryMin, industries, jobTypes, …)` + `Job` + `JobRecommendationProfile` | |
||||
|
| 维度与权重 | 城市 30 / 用工类型 20 / 最低薪资 20 / 行业 15 / 岗位族 15 | **完全相同** | |
||||
|
| 缺失维度 | 该维度不计入 available(等效"不计分") | 完全相同 | |
||||
|
| 空集合/null | `Criteria` 紧凑构造器把 `null` 归一化为 `List.of()`;`expectedSalaryMin == null` 时该维度不计 | `readStrings` 恒返回非 null 列表;`expectedSalaryMin == null` 同上 | |
||||
|
| 字符串规范化 | **无**(精确匹配,不 trim、不大小写折叠) | **无**(同) | |
||||
|
| 计算顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族(固定顺序累加) | **完全相同** | |
||||
|
| scale / RoundingMode | `divide(..., 2, HALF_UP)`,`BigDecimal.valueOf(matched * 100L)` | `BigDecimal.valueOf(got * 100L).divide(..., 2, HALF_UP)` —— **同一式子** | |
||||
|
| 上下界 | `available == 0 → null`;否则 ∈ [0.00, 100.00] | 同 | |
||||
|
| 对总分/排序影响 | `JobRecommendationService` 中偏好分权重 40(与 RIASEC 35、能力 25 加权平均后降序 + jobId 升序) | `TalentDiscoveryService` 中作为候选打分的一个分量 | |
||||
|
|
||||
|
### 2.3 硬编码规则库存 |
||||
|
|
||||
|
| 项 | 重构前位置 | 数量/值 | |
||||
|
|---|---|---| |
||||
|
| 985 高校集合 | `CandidateScoringCalculator.C985`(`new HashSet<>(List.of(...))`) | **38 项**(38 唯一) | |
||||
|
| 211 高校集合 | `CandidateScoringCalculator.C211` | **62 条字面量 / 61 唯一**(`北京科技大学` 重复一次) | |
||||
|
| 层级判定顺序 | `isBlank → 985 → 211 → endsWith("大学"/"学院") → 兜底` | 985 优先于 211 | |
||||
|
| 学历层次分数 | 内联 switch | 博士 100 / 硕士 85 / 本科 70 / 大专 55 / 其他 50 | |
||||
|
| 学校档次分数 | 内联 | 985=85 / 211=75 / 其他=65 / 缺失=50 | |
||||
|
| 教育合成权重 | 内联 | 0.55 / 0.45;子项满分 55 / 45 | |
||||
|
| 默认维度权重 | `DEFAULT_WEIGHT` | `new BigDecimal("12.50")` | |
||||
|
| 上界常量 | `HUNDRED` | `new BigDecimal("100")` | |
||||
|
| rulesVersion | `RULES_VERSION` | `"candidate-scoring-v0.1"`(**另有** `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"`,语义不同) | |
||||
|
| 消费者 | `CandidateScoringEngineService`、`CompetitionRankingCalculator`、`TalentDiscoveryService`、`CandidateScoreCardService` 等 | |
||||
|
| 同数不同义 | 存在且**保持独立命名**:`85`(学历硕士)≠ `85`(学校档次 985);`50`(学历其他)≠ `50`(学校缺失/兜底) | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 3. 重构前特征测试(先建基线) |
||||
|
|
||||
|
目录 `mydreamjob-server/src/test/java/com/mydreamjob/common/r01c/`,**40 个用例**, |
||||
|
期望值均为**手工推算或独立抄录**(不调用被测实现生成),使用 `isEqualByComparingTo` 并额外断言 `scale` 与 `toPlainString()`,**未使用 `isCloseTo` 之类宽松误差**。 |
||||
|
|
||||
|
| 测试类 | Tests | 覆盖 | |
||||
|
|---|---|---| |
||||
|
| `OverviewStatusPolicyCharacterizationTest` | 8 | 四种状态、空集合、null、`expectedDimensions` 门禁、OK 但分数缺失、两入口语义差异、**唯一产出点结构门禁** | |
||||
|
| `PreferenceScoreCharacterizationTest` | 17 | 全匹配/全不匹配/部分匹配/单维度、薪资相等边界与 null、四维加权 82.35、空集合与 null、大小写与脏值、空串、上下界 | |
||||
|
| `CandidateScoringRuleSetCharacterizationTest` | 10 | 985 首/中/末、211 首/中/末与重复项、两名单无交集、精确匹配(空白/简繁/大小写不匹配)、学历与档次分数、版本、八维度、只读集合 | |
||||
|
| `RecommendationOrderingCharacterizationTest` | 5 | 分数不同降序、同分按 jobId、完全同分确定性、score=null 抛 NPE、分页切片越界 | |
||||
|
|
||||
|
**基线运行(重构前):`Tests run: 40, Failures: 0, Errors: 0, Skipped: 0`** —— 见 `docs/acceptance/r01c/baseline.log`。 |
||||
|
基线期间发现并如实修正了一处**我方测试假设错误**:`isValidDimension(null)` 实际抛 `NullPointerException`(`Set.of` 契约),并非返回 `false`;已按真实行为锁定,未改生产代码。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 4. overviewStatus 唯一策略 |
||||
|
|
||||
|
**未新增组件**:`CandidateOverviewStatusPolicy` 已是唯一策略且无 Spring/DB/时间依赖。 |
||||
|
本轮仅**新增结构门禁测试**:扫描 `src/main/java/com/mydreamjob` 下所有 `.java`, |
||||
|
断言 `"PARTIAL"` 的出现文件**恰好**为 `CandidateOverviewStatusPolicy.java`。 |
||||
|
若将来出现第二个聚合器,该测试立即失败。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 5. 两份旧实现是否完全等价 |
||||
|
|
||||
|
**不存在两份 overviewStatus 实现**,故不适用"合并前等价性比对"。§六 的停止条件(两实现不等价)**未触发**。 |
||||
|
|
||||
|
**preferenceScore 的两套实现经逐行比对为等价**: |
||||
|
|
||||
|
| 比对项 | 结论 | |
||||
|
|---|---| |
||||
|
| 维度顺序 | 城市 → 用工类型 → 薪资 → 行业 → 岗位族,**一致** | |
||||
|
| 权重 | 30/20/20/15/15,**一致** | |
||||
|
| 缺失维度处理 | 不计入分母,**一致** | |
||||
|
| 薪资判定 | `job.salaryMax != null && salaryMax >= expectedSalaryMin`,**一致** | |
||||
|
| 岗位族取值 | `profile.getJobFamilyCode()`,**一致** | |
||||
|
| 计算式 | `BigDecimal.valueOf(x * 100L).divide(BigDecimal.valueOf(total), 2, HALF_UP)`,**一致** | |
||||
|
| 零分母 | `null`,**一致** | |
||||
|
| null 列表 | 计算器归一化为空列表;内联版由 `readStrings` 保证非 null —— **观察行为一致** | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 6. preferenceScore 拆分 |
||||
|
|
||||
|
- `JobRecommendationService.preferenceScore()`:**13 行**(重构前后一致),职责为"提取 Criteria/Target → 委托计算器",已是可快速读懂的编排方法。 |
||||
|
- `TalentDiscoveryService.preferenceScore()`:由 **12 行内联公式** 改为 **17 行编排**(含 javadoc),规则实现移出。 |
||||
|
- 拆分粒度按**实际存在的维度**(5 个),**未为匹配示例创造不存在的维度**;未引入 Stream;未改变可见性;未产生无意义的一行包装。 |
||||
|
- **30/20/20/15/15 规则实现处:2 → 1**(`grep "total += 30\|CITY_WEIGHT"`)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 7. BigDecimal 运算顺序与舍入证明 |
||||
|
|
||||
|
| 项 | 重构前 | 重构后 | 一致 | |
||||
|
|---|---|---|---| |
||||
|
| 偏好分构造 | `BigDecimal.valueOf(matched * 100L)` | 同一表达式(改为计算器内执行) | ✔ | |
||||
|
| 除法 | `divide(BigDecimal.valueOf(available), 2, RoundingMode.HALF_UP)` | 同 | ✔ | |
||||
|
| 教育维度 | `eduLevel*0.55 + schoolTier*0.45`,`setScale(2, HALF_UP)` | 同一表达式(权重改读规则集常量,**值相同**) | ✔ | |
||||
|
| `MathContext.DECIMAL128` | 未使用 | 未引入 | ✔ | |
||||
|
| scale | 全部不变(断言 `toPlainString()`) | 同 | ✔ | |
||||
|
| 新引入 double/float | — | **无** | ✔ | |
||||
|
|
||||
|
特征测试 `PreferenceScoreCharacterizationTest` 逐项断言 `scale == 2` 与精确字符串(如 `"82.35"`、`"66.67"`、`"33.33"`)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 8. 强类型规则设计 |
||||
|
|
||||
|
新增 `candidatescore/rules/CandidateScoringRuleSet`: |
||||
|
|
||||
|
| 要求 | 落实 | |
||||
|
|---|---| |
||||
|
| `final` / 不可变 / 无 setter | ✔ `public final class`,私有构造,全部 `static final` | |
||||
|
| 不可变集合 | ✔ `Collections.unmodifiableSet(new HashSet<>(...))` | |
||||
|
| 唯一当前规则实例 | ✔ 静态常量(随代码发布变更) | |
||||
|
| 版本常量 | ✔ `VERSION = "candidate-scoring-v0.1"`,**与重构前逐字一致** | |
||||
|
| 不接数据库/外部动态配置 | ✔ 无 Spring、无 JDBC、无配置读取 | |
||||
|
| BigDecimal 字符串构造 | ✔ 全部 `new BigDecimal("…")` | |
||||
|
| 同数不同义不合并 | ✔ `EDUCATION_LEVEL_MASTER(85)` 与 `SCHOOL_TIER_985(85)` 独立命名;`EDUCATION_LEVEL_UNKNOWN(50)` 与 `SCHOOL_TIER_MISSING(50)` 独立命名 | |
||||
|
| 按业务语义组织 | ✔ 版本与汇总 / 学历层次 / 学校档次 / 高校名单 四组 | |
||||
|
| 对外只读 | ✔ `c985()` / `c211()` 返回不可修改视图(测试断言 add/remove/clear 均抛 `UnsupportedOperationException`) | |
||||
|
|
||||
|
**未建立"杂物常量类"**:只收纳本轮明确要求的四组,未把计算器内其余维度阈值一并塞入。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 9. 高校名单:数量与逐项一致证明 |
||||
|
|
||||
|
| 项 | 重构前(计算器内) | 重构后(规则集) | |
||||
|
|---|---|---| |
||||
|
| 985 定义处 | 1 | 1(位置变更) | |
||||
|
| 985 唯一项 | 38 | **38** ✔ | |
||||
|
| 211 定义处 | 1 | 1(位置变更) | |
||||
|
| 211 字面量条数 | 62 | **62** ✔ | |
||||
|
| 211 唯一项 | 61 | **61** ✔ | |
||||
|
| `北京科技大学` 重复 | 存在 | **原样保留**(按裁决不去重) | |
||||
|
| 字符串 | — | 逐项未改动(首/中/末抽样断言) | |
||||
|
| 判定优先级 | 985 → 211 | 985 → 211 ✔ | |
||||
|
| 模糊匹配 | 无 | **无**(新增测试断言"北京大学 "、"北京大学"、"北京大學" 均不匹配) | |
||||
|
| 对外只读 | `HashSet`(可变,仅 private) | 不可修改视图 ✔ | |
||||
|
|
||||
|
**计算器内名单定义数:2 → 0**;名单只在唯一规则源定义。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 10. 阈值和权重逐项一致 |
||||
|
|
||||
|
| 规则 | 重构前值 | 重构后常量 | 值 | |
||||
|
|---|---|---|---| |
||||
|
| 学历 博士 | 100 | `EDUCATION_LEVEL_DOCTOR` | 100 ✔ | |
||||
|
| 学历 硕士 | 85 | `EDUCATION_LEVEL_MASTER` | 85 ✔ | |
||||
|
| 学历 本科 | 70 | `EDUCATION_LEVEL_BACHELOR` | 70 ✔ | |
||||
|
| 学历 大专 | 55 | `EDUCATION_LEVEL_JUNIOR_COLLEGE` | 55 ✔ | |
||||
|
| 学历 其他 | 50 | `EDUCATION_LEVEL_UNKNOWN` | 50 ✔ | |
||||
|
| 档次 985 | 85 | `SCHOOL_TIER_985` | 85 ✔ | |
||||
|
| 档次 211 | 75 | `SCHOOL_TIER_211` | 75 ✔ | |
||||
|
| 档次 其他 | 65 | `SCHOOL_TIER_OTHER` | 65 ✔ | |
||||
|
| 档次 缺失 | 50 | `SCHOOL_TIER_MISSING` | 50 ✔ | |
||||
|
| 教育缺失维度分 | 100 | `EDUCATION_MISSING_SCORE` | 100 ✔ | |
||||
|
| 学历权重 | 0.55 | `EDUCATION_LEVEL_WEIGHT` | 0.55 ✔ | |
||||
|
| 档次权重 | 0.45 | `EDUCATION_SCHOOL_TIER_WEIGHT` | 0.45 ✔ | |
||||
|
| 子项满分 | 55 / 45 | `EDUCATION_LEVEL_FULL_SCORE` / `…TIER…` | 55 / 45 ✔ | |
||||
|
| 默认维度权重 | 12.50 | `DEFAULT_DIMENSION_WEIGHT` | 12.50 ✔ | |
||||
|
| 满分上界 | 100 | `FULL_SCORE` | 100 ✔ | |
||||
|
|
||||
|
**裸教育规则 BigDecimal 命中数(计算器内):23 → 6**(其余 6 处为非教育维度阈值,本轮未纳入规则集)。 |
||||
|
数值一律为字符串构造,**未把整数阈值错误转换为百分比小数**。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 11. rulesVersion |
||||
|
|
||||
|
| 项 | 值 | |
||||
|
|---|---| |
||||
|
| 常量 | `CandidateScoringRuleSet.VERSION` | |
||||
|
| 值 | **`candidate-scoring-v0.1`**(与重构前 `CandidateScoringCalculator.RULES_VERSION` **逐字一致**) | |
||||
|
| 是否新增版本 | **否** | |
||||
|
| 响应中的版本值 | **未改变**(`CandidateScoringCalculator.RULES_VERSION` 现指向同一常量) | |
||||
|
| 同义词区分 | `AbilityScoreMapper.MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"` 保持独立,未合并 | |
||||
|
| 快照历史一致性 | 快照写入仍使用同一 `RULES_VERSION`,未变 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 12. 排名顺序对账 |
||||
|
|
||||
|
`JobRecommendationService` 排序比较器**未改动**: |
||||
|
`Comparator.comparing(JobRecommendationItem::getScore).reversed().thenComparing(JobRecommendationItem::getJobId)`。 |
||||
|
`slice` 的下标计算未改动。特征测试锁定:分数降序、同分按 jobId 升序、完全同分确定性、越界切片为空。 |
||||
|
|
||||
|
**偏好分对排序的影响路径未变**:偏好分仍以权重 40 参与 `top/bottom` 加权平均(RIASEC 35、能力 25), |
||||
|
`divide(bottom, 2, HALF_UP)`。收束只替换了偏好分的**计算处**,未触碰分值本身。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 13. 解释字段与贡献求和 |
||||
|
|
||||
|
| 校验 | 结论 | |
||||
|
|---|---| |
||||
|
| `CandidateScoringExplanationAssembler` 贡献求和 | 未修改该链路;`addContribution` 的入参在收束后**取值相同**(改读常量,值不变) | |
||||
|
| 各维度贡献之和 = 总分 | 教育维度仍为 `eduLevel*0.55 + schoolTier*0.45`,与两个 contribution 的求和一致 ✔ | |
||||
|
| overviewStatus 在响应/解释/快照/看板一致 | 唯一策略未被改动,仍由三处同一入口产出 ✔ | |
||||
|
| rulesVersion 在响应/快照/历史一致 | 同一常量 ✔ | |
||||
|
| 推荐解释中的偏好分 = 排序实际使用的分 | `TalentDiscoveryService` 收束后仍把同一计算结果用于打分与解释字段(同一局部变量 `p`) ✔ | |
||||
|
| "展示一套、排序另一套" | **未出现** ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 14. 查询数与副作用 |
||||
|
|
||||
|
| 项 | 结论 | |
||||
|
|---|---| |
||||
|
| 新增数据库查询 | **无**。规则集为静态常量;偏好分计算为纯函数 | |
||||
|
| 推荐一次查询的 SQL 数量 | **不增加**:`TalentDiscoveryService.preferenceScore` 收束后**零查询**(输入已由批量读取提供);`JobRecommendationService` 未变 | |
||||
|
| 评分计算新增写入 | **无** | |
||||
|
| GET 评分接口零写快照 | **未改动**该迁移 | |
||||
|
| RabbitMQ / 审计 / 通知 | 未新增 | |
||||
|
| 缓存键 | 未改变 | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 15. 变异测试(三类,均恢复原字节) |
||||
|
|
||||
|
| # | 变异 | 目标文件 | 结果 | |
||||
|
|---|---|---|---| |
||||
|
| 1 | `isComputable` 中 `"ESTIMATED"` → `"OK"`(改变状态边界) | `CandidateOverviewStatusPolicy` | **8 run / 2 failures** ✔ | |
||||
|
| 2 | `SALARY_WEIGHT` 20 → 25(改变偏好权重) | `JobPreferenceScoreCalculator` | **17 run / 1 failure** ✔ | |
||||
|
| 3 | 985 名单删除 `"北京大学"` | `CandidateScoringRuleSet` | **10 run / 2 failures** ✔ | |
||||
|
|
||||
|
**恢复核验**:三个文件均已还原,其中 #1 因还原脚本误替换了同一行中的另一处 `"OK".equals`, |
||||
|
已定位并**逐字节修正**,`git diff --quiet` 确认与 HEAD 完全一致;#2、#3 的 SHA-256 恢复一致。 |
||||
|
**未使用 `git restore/checkout`**;变异内容未留在工作区(见 §20 的差异范围核对)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 16. 定向、关联、全量测试数 |
||||
|
|
||||
|
| 命令 | 结果 | 日志 | |
||||
|
|---|---|---| |
||||
|
| R01-C 定向(4 个特征类) | **40 / 40**,F=0 E=0 S=0 ✔ | `baseline.log` / `after-characterization.log` | |
||||
|
| 关联回归(`*CandidateScoring*Test,*ScoringDashboard*Test,*Fairness*Test,*JobRecommendation*Test,*Recommendation*Test,*CompetitionRanking*Test,*Appeal*Test,*TalentDiscovery*Test`) | **331 / 331**,F=0 E=0,**Skipped=6** | `related.log` | |
||||
|
| `mvn test` | 见 §16.1 | `full-mvntest.log` | |
||||
|
| `mvn clean verify` | 见 §16.1 | `clean-verify.log` | |
||||
|
|
||||
|
### 16.1 全量结果与 Docker 说明(如实记录) |
||||
|
|
||||
|
``` |
||||
|
mvn test → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS |
||||
|
mvn clean verify → Tests run: 2725, Failures: 0, Errors: 0, Skipped: 26 BUILD SUCCESS |
||||
|
``` |
||||
|
|
||||
|
| 项 | 数值 | |
||||
|
|---|---| |
||||
|
| 测试总数 | **2725** = 本轮基线 **2685** + 本轮新增 **40** ✔(**不低于 2685** 的要求满足) | |
||||
|
| Failures / Errors | **0 / 0** ✔ | |
||||
|
| **Skipped** | **26 —— 未达"0"要求**,原因如下 | |
||||
|
|
||||
|
#### Skipped=26 的准确原因 |
||||
|
|
||||
|
26 个跳过**全部**来自 4 个 MySQL / Testcontainers 类(与 R01-B 复验时同一批): |
||||
|
`JobSearchSpecificationMySqlIntegrationTest`(15)、`CandidateScoringRefreshMySqlIntegrationTest`(6)、 |
||||
|
`JobSearchQuerySideEffectMySqlIntegrationTest`(4)、`JobSearchQueryExplainMySqlIntegrationTest`(1)。 |
||||
|
它们标注 `@Testcontainers(disabledWithoutDocker = true)`,Docker 不可用时按设计跳过。 |
||||
|
|
||||
|
**环境状态变化(非代码原因)**: |
||||
|
|
||||
|
- 本轮**开始时** `docker info` 成功:`ServerVersion=29.6.2`(满足 §三 的"Docker 可用"前置要求); |
||||
|
- 执行到关联回归时再次检查,Docker 守护进程已停止: |
||||
|
`failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine`; |
||||
|
- `wsl -l -v` 显示 `docker-desktop` 发行版为 **Stopped**; |
||||
|
`C:\Program Files\Docker\Docker\Docker Desktop.exe` **不存在**(该目录下仅有 `cli-plugins`), |
||||
|
`%LOCALAPPDATA%\Docker` 下只有 log/run/tasks/wsl 等运行期目录,**没有可启动的 Desktop 前端**。 |
||||
|
|
||||
|
**处置**:未强行以非受支持方式拉起 Docker(`wsl -d docker-desktop` 明确不被支持), |
||||
|
**不声称 Skipped=0**。除这 26 个 Docker 条件用例外的 **2699 个测试全部执行且全部通过**。 |
||||
|
**不构成 R01-C 阻断项**。 |
||||
|
|
||||
|
#### 独立复验补齐(2026-09-18,本段为复验追加,不覆盖上述事实) |
||||
|
|
||||
|
独立复验发现了**正确的 Docker Desktop 安装路径**并据此启动: |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 路径 | `D:\DevApps\DockerDesktop\Docker Desktop.exe`(存在,15,142,832 字节) | |
||||
|
| 启动 | `Start-Process -FilePath '…\Docker Desktop.exe' -WindowStyle Hidden`,轮询上限 180 秒 | |
||||
|
| `docker info` | **约 15 秒后成功** → `ServerVersion=29.6.2` | |
||||
|
| 原先跳过的 26 项 | **26 / 26 实际执行**,`Tests run: 26, Failures: 0, Errors: 0, Skipped: 0` | |
||||
|
|
||||
|
**最终全量(复验阶段)**: |
||||
|
|
||||
|
``` |
||||
|
mvn test → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
||||
|
mvn clean verify → Tests run: 2743, Failures: 0, Errors: 0, Skipped: 0 BUILD SUCCESS |
||||
|
``` |
||||
|
|
||||
|
`2743 = 2725(本报告阶段总数,含 26 项跳过)+ 18(独立复验新增测试)`; |
||||
|
其 **Skipped=0** 正是因为那 26 项已真实执行。**本阶段未改动任何代码即补足了这 26 项。** |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 17. OpenAPI |
||||
|
|
||||
|
``` |
||||
|
python ..\docs\verify_openapi.py |
||||
|
→ paths: 176 / operations: 214 |
||||
|
→ All checks passed! |
||||
|
``` |
||||
|
|
||||
|
`git diff -- docs/openapi-rc.json` → **空** ✔ |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 18. 精确修改文件 |
||||
|
|
||||
|
**生产代码变更总数:新增 1 个生产类、修改 2 个生产类,共 3 个生产文件。** |
||||
|
|
||||
|
**新增生产类(1)** |
||||
|
|
||||
|
``` |
||||
|
mydreamjob-server/src/main/java/com/mydreamjob/candidatescore/rules/CandidateScoringRuleSet.java |
||||
|
``` |
||||
|
|
||||
|
**修改生产类(2)** |
||||
|
|
||||
|
``` |
||||
|
candidatescore/CandidateScoringCalculator.java ← 版本/权重改读规则集;删除 C985/C211;教育数值改读常量 |
||||
|
talentdiscovery/TalentDiscoveryService.java ← preferenceScore 委托 JobPreferenceScoreCalculator |
||||
|
``` |
||||
|
|
||||
|
> **独立复验补充(2026-09-18)**:`git status` 会在 Windows 上把 |
||||
|
> `CandidateOverviewStatusPolicy.java` 与 `JobPreferenceScoreCalculator.java` 标为 ` M` |
||||
|
> (变异实验重写过这两个文件、mtime 变化)。经 `git hash-object` 与 `git rev-parse HEAD:<file>` |
||||
|
> 比对,两者 blob **完全相同**,属 **Windows stat 缓存假阳性**,**不在本提交范围内**。 |
||||
|
|
||||
|
**新增测试(4)**:`common/r01c/` 下 4 个特征测试类 |
||||
|
|
||||
|
**文档(2)**:`docs/r01-c-scoring-recommendation-rules-refactor-report.md`(新增)、 |
||||
|
`docs/backend-development-roadmap.md`(更新) |
||||
|
|
||||
|
> **未新增**:`CandidateOverviewStatusPolicy` 相关改动(已唯一)、`JobRecommendationService` 改动(已是编排)。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 19. Flyway / ErrorCode / DTO / 接口变化 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| Flyway / 迁移 | **未改动** ✔ | |
||||
|
| `ErrorCode` | **未改动** ✔ | |
||||
|
| DTO / 响应结构 | **未改动** ✔ | |
||||
|
| 公网接口 / OpenAPI | **未改动**(176/214,`openapi-rc.json` 未变) ✔ | |
||||
|
| Token / 权限 / requestId | **未改动** ✔ | |
||||
|
| 依赖 / `pom.xml` | **未改动** ✔ | |
||||
|
| 评分公式数值 / 排序 / 同分规则 / 小样本与互补风险抑制 / 申诉回滚 / 快照版本推进 / Fairness / 消息与 Offer 等 | **均未改动** ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 20. Git 状态 |
||||
|
|
||||
|
| 项 | 结果 | |
||||
|
|---|---| |
||||
|
| 分支 / HEAD | `refactor/r01-structure-performance` / `9954600…`(未变) | |
||||
|
| `git diff --cached --name-only` | **空** ✔ | |
||||
|
| `git diff --check` | exit 0 ✔ | |
||||
|
| 生产代码改动范围 | **恰为 2 个文件**,与 §18 一致(变异内容未残留)✔ | |
||||
|
| 已暂存 / 已提交 / 已推送 | **均无** ✔ | |
||||
|
| 历史未跟踪文件 | 未删除 ✔ | |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 21. 未暂存、未提交、未推送 |
||||
|
|
||||
|
**确认。** 未执行 `git add`;未创建提交;未推送;未执行 `reset`/`restore`/`checkout`/`clean`; |
||||
|
未进入 R01-E。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
## 22. 未进入 R01-E |
||||
|
|
||||
|
**确认。** 本轮未处理 N+1、会话未读数、`overviewStatus` 之外的其他查询收束、 |
||||
|
数据库迁移或任何 R01-E 范围内的主题。 |
||||
|
|
||||
|
--- |
||||
|
|
||||
|
> **R01-C评分与推荐规则结构收束完成,待独立代码复验。** |
||||
@ -0,0 +1,137 @@ |
|||||
|
package com.mydreamjob.candidatescore.rules; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.Collections; |
||||
|
import java.util.HashSet; |
||||
|
import java.util.List; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
/** |
||||
|
* 求职者评分规则的**唯一代码来源**(R01-C)。 |
||||
|
* |
||||
|
* <p>本类只承载"规则值本身":版本标识、高校档次名单、教育背景分数与权重、默认维度权重。 |
||||
|
* 规则随**代码发布**变更,不接数据库、不接外部动态配置、无 setter、不暴露可变集合。</p> |
||||
|
* |
||||
|
* <h2>不变量</h2> |
||||
|
* <ul> |
||||
|
* <li>{@link #VERSION} 必须与重构前 {@code CandidateScoringCalculator.RULES_VERSION} 完全一致 |
||||
|
* ({@code candidate-scoring-v0.1})—— 本轮**不新增版本、不改变响应中的版本值**;</li> |
||||
|
* <li>高校名单内容、数量、字符串与判定优先级逐项不变;</li> |
||||
|
* <li>所有 BigDecimal 使用**字符串构造**,scale 与重构前一致;</li> |
||||
|
* <li>不同业务语义的相同数字**不合并**(例如学历层次 85 与学校档次 85 是两个命名常量)。</li> |
||||
|
* </ul> |
||||
|
*/ |
||||
|
public final class CandidateScoringRuleSet { |
||||
|
|
||||
|
private CandidateScoringRuleSet() { |
||||
|
} |
||||
|
|
||||
|
// ── 版本与汇总 ────────────────────────────────────────────
|
||||
|
|
||||
|
/** 评分规则版本。原值取自 {@code CandidateScoringCalculator.RULES_VERSION},逐字不变。 */ |
||||
|
public static final String VERSION = "candidate-scoring-v0.1"; |
||||
|
|
||||
|
/** 各维度默认等权重(参与维度重新归一化后等价于算术平均)。 */ |
||||
|
public static final BigDecimal DEFAULT_DIMENSION_WEIGHT = new BigDecimal("12.50"); |
||||
|
|
||||
|
/** 百分制上界,用于分数归一化。 */ |
||||
|
public static final BigDecimal FULL_SCORE = new BigDecimal("100"); |
||||
|
|
||||
|
// ── 教育背景:学历层次 ────────────────────────────────────
|
||||
|
|
||||
|
public static final BigDecimal EDUCATION_LEVEL_DOCTOR = new BigDecimal("100"); |
||||
|
public static final BigDecimal EDUCATION_LEVEL_MASTER = new BigDecimal("85"); |
||||
|
public static final BigDecimal EDUCATION_LEVEL_BACHELOR = new BigDecimal("70"); |
||||
|
public static final BigDecimal EDUCATION_LEVEL_JUNIOR_COLLEGE = new BigDecimal("55"); |
||||
|
public static final BigDecimal EDUCATION_LEVEL_UNKNOWN = new BigDecimal("50"); |
||||
|
|
||||
|
/** 教育维度内:学历层次与学校档次的合成权重(相加为 1.00)。 */ |
||||
|
public static final BigDecimal EDUCATION_LEVEL_WEIGHT = new BigDecimal("0.55"); |
||||
|
public static final BigDecimal EDUCATION_SCHOOL_TIER_WEIGHT = new BigDecimal("0.45"); |
||||
|
|
||||
|
/** 教育维度子项满分(用于解释与贡献校验)。 */ |
||||
|
public static final BigDecimal EDUCATION_LEVEL_FULL_SCORE = new BigDecimal("55"); |
||||
|
public static final BigDecimal EDUCATION_SCHOOL_TIER_FULL_SCORE = new BigDecimal("45"); |
||||
|
|
||||
|
// ── 教育背景:学校档次 ────────────────────────────────────
|
||||
|
|
||||
|
/** 985 高校档次得分。 */ |
||||
|
public static final BigDecimal SCHOOL_TIER_985 = new BigDecimal("85"); |
||||
|
/** 211(非 985)高校档次得分。 */ |
||||
|
public static final BigDecimal SCHOOL_TIER_211 = new BigDecimal("75"); |
||||
|
/** 已填写但不在两个名单内的学校。 */ |
||||
|
public static final BigDecimal SCHOOL_TIER_OTHER = new BigDecimal("65"); |
||||
|
/** 学校档次缺失(与"学历层次缺失"的 50 语义不同,故为独立常量)。 */ |
||||
|
public static final BigDecimal SCHOOL_TIER_MISSING = new BigDecimal("50"); |
||||
|
/** 教育资料整体缺失时的维度得分。 */ |
||||
|
public static final BigDecimal EDUCATION_MISSING_SCORE = new BigDecimal("100"); |
||||
|
|
||||
|
// ── 高校名单(v0.1 硬编码,非数据库表)────────────────────
|
||||
|
|
||||
|
/** |
||||
|
* 985 高校名单(38 项)。 |
||||
|
* |
||||
|
* <p>内容逐项抄录自重构前的 {@code CandidateScoringCalculator.C985},顺序与字符串均未改动。</p> |
||||
|
*/ |
||||
|
private static final Set<String> C985 = immutableSet(List.of( |
||||
|
"北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", |
||||
|
"中国农业大学", "北京师范大学", "中央民族大学", "南开大学", "天津大学", |
||||
|
"大连理工大学", "吉林大学", "哈尔滨工业大学", "复旦大学", "同济大学", |
||||
|
"上海交通大学", "华东师范大学", "南京大学", "东南大学", "浙江大学", |
||||
|
"中国科学技术大学", "厦门大学", "山东大学", "中国海洋大学", "武汉大学", |
||||
|
"华中科技大学", "湖南大学", "中南大学", "中山大学", "华南理工大学", |
||||
|
"四川大学", "重庆大学", "电子科技大学", "西安交通大学", "西北工业大学", |
||||
|
"兰州大学", "东北大学", "西北农林科技大学")); |
||||
|
|
||||
|
/** |
||||
|
* 211(非 985)高校名单:字面量 **62 条**、唯一项 **61**。 |
||||
|
* |
||||
|
* <p>末项 {@code 北京科技大学} 与首段重复 —— 这是重构前名单的**既有事实**, |
||||
|
* 按"名单内容逐项不变、数量不变"的要求**原样保留**,不擅自去重、不补充、不删除。 |
||||
|
* 集合语义下判定行为与重构前一致({@code HashSet} 去重)。</p> |
||||
|
*/ |
||||
|
private static final Set<String> C211 = immutableSet(List.of( |
||||
|
"北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", |
||||
|
"北京林业大学", "北京中医药大学", "北京外国语大学", "中国传媒大学", "中央财经大学", |
||||
|
"对外经济贸易大学", "中国政法大学", "华北电力大学", "天津医科大学", "河北工业大学", |
||||
|
"太原理工大学", "内蒙古大学", "辽宁大学", "大连海事大学", "延边大学", |
||||
|
"哈尔滨工程大学", "东北农业大学", "东北林业大学", "东华大学", "上海大学", |
||||
|
"上海财经大学", "上海外国语大学", "苏州大学", "南京航空航天大学", "南京理工大学", |
||||
|
"中国矿业大学", "河海大学", "江南大学", "南京农业大学", "中国药科大学", |
||||
|
"南京师范大学", "安徽大学", "合肥工业大学", "福州大学", "南昌大学", |
||||
|
"郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", "中南财经政法大学", |
||||
|
"湖南师范大学", "暨南大学", "华南师范大学", "广西大学", "海南大学", |
||||
|
"西南交通大学", "西南财经大学", "贵州大学", "云南大学", "西北大学", |
||||
|
"西安电子科技大学", "长安大学", "新疆大学", "石河子大学", "宁夏大学", |
||||
|
"青海大学", "北京科技大学")); |
||||
|
|
||||
|
/** 只读视图:985 名单。 */ |
||||
|
public static Set<String> c985() { |
||||
|
return C985; |
||||
|
} |
||||
|
|
||||
|
/** 只读视图:211(非 985)名单。 */ |
||||
|
public static Set<String> c211() { |
||||
|
return C211; |
||||
|
} |
||||
|
|
||||
|
/** 该校是否为 985。 */ |
||||
|
public static boolean is985(String school) { |
||||
|
return school != null && C985.contains(school); |
||||
|
} |
||||
|
|
||||
|
/** 该校是否为 211(非 985)。 */ |
||||
|
public static boolean is211(String school) { |
||||
|
return school != null && C211.contains(school); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 构造不可变集合。 |
||||
|
* |
||||
|
* <p>刻意使用 {@code unmodifiableSet(new HashSet<>(...))} 而非 {@code Set.copyOf}: |
||||
|
* 后者遇到重复元素会抛 {@code IllegalArgumentException},而 211 名单的重复项必须原样保留。</p> |
||||
|
*/ |
||||
|
private static Set<String> immutableSet(List<String> values) { |
||||
|
return Collections.unmodifiableSet(new HashSet<>(values)); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,159 @@ |
|||||
|
package com.mydreamjob.common.r01c; |
||||
|
|
||||
|
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
||||
|
import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
|
||||
|
/** |
||||
|
* R01-C 特征测试:评分规则基线。 |
||||
|
* |
||||
|
* <p>期望值由独立抄录自重构前的 {@code CandidateScoringCalculator}(名单 38 / 62 条、 |
||||
|
* 学历 100/85/70/55/50、权重 0.55/0.45、版本 {@code candidate-scoring-v0.1}), |
||||
|
* 不经由被测实现自身推导。</p> |
||||
|
*/ |
||||
|
class CandidateScoringRuleSetCharacterizationTest { |
||||
|
|
||||
|
// ── 高校名单 ──────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:C985 名单 38 项,首/中/末逐项一致") |
||||
|
void c985ListContentIsStable() { |
||||
|
Set<String> c985 = CandidateScoringRuleSet.c985(); |
||||
|
|
||||
|
assertThat(c985).as("C985 唯一项数量").hasSize(38); |
||||
|
assertThat(c985).contains( |
||||
|
"北京大学", "清华大学", "中国人民大学", "北京航空航天大学", "北京理工大学", // 首段
|
||||
|
"南京大学", "东南大学", "浙江大学", "中国科学技术大学", "厦门大学", // 中段
|
||||
|
"兰州大学", "东北大学", "西北农林科技大学"); // 末段
|
||||
|
assertThat(c985).doesNotContain("北京交通大学", "苏州大学", "郑州大学", "青海大学"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:C211 名单 61 唯一项(62 条字面量含 `北京科技大学` 重复),首/中/末一致") |
||||
|
void c211ListContentIsStable() { |
||||
|
Set<String> c211 = CandidateScoringRuleSet.c211(); |
||||
|
|
||||
|
assertThat(c211).as("C211 唯一项数量").hasSize(61); |
||||
|
assertThat(c211).contains( |
||||
|
"北京交通大学", "北京工业大学", "北京科技大学", "北京化工大学", "北京邮电大学", // 首段
|
||||
|
"郑州大学", "武汉理工大学", "华中农业大学", "华中师范大学", // 中段
|
||||
|
"青海大学", "北京科技大学"); // 末段(重复项原样保留)
|
||||
|
assertThat(c211).doesNotContain("北京大学", "清华大学", "复旦大学"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:985 与 211 名单无交集") |
||||
|
void tierListsAreDisjoint() { |
||||
|
assertThat(CandidateScoringRuleSet.c985()) |
||||
|
.doesNotContainAnyElementsOf(CandidateScoringRuleSet.c211()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:档次判定按 985 先于 211,且不进行模糊匹配") |
||||
|
void tierLookupOrderIs985FirstAndExact() { |
||||
|
assertThat(CandidateScoringRuleSet.is985("北京大学")).isTrue(); |
||||
|
assertThat(CandidateScoringRuleSet.is211("北京大学")).isFalse(); |
||||
|
|
||||
|
assertThat(CandidateScoringRuleSet.is985("苏州大学")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is211("苏州大学")).isTrue(); |
||||
|
|
||||
|
// 相似但不完全相同、空白、大小写均**不**匹配(现状即精确匹配)
|
||||
|
assertThat(CandidateScoringRuleSet.is985("北京大学 ")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985(" 北京大学")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985("北京大學")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985("")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985(null)).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is211(null)).isFalse(); |
||||
|
} |
||||
|
|
||||
|
// ── 教育背景数值 ──────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:学历层次 100/85/70/55/50 与合成权重 0.55/0.45 逐项一致") |
||||
|
void educationLevelRulesAreStable() { |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR).isEqualByComparingTo("100"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER).isEqualByComparingTo("85"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR).isEqualByComparingTo("70"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE).isEqualByComparingTo("55"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN).isEqualByComparingTo("50"); |
||||
|
|
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT).isEqualByComparingTo("0.55"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT).isEqualByComparingTo("0.45"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT |
||||
|
.add(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT)) |
||||
|
.isEqualByComparingTo("1.00"); |
||||
|
|
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_FULL_SCORE).isEqualByComparingTo("55"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_FULL_SCORE).isEqualByComparingTo("45"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:学校档次 985=85 / 211=75 / 其他=65 / 缺失=50 语义各自独立") |
||||
|
void schoolTierRulesAreStable() { |
||||
|
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_985).isEqualByComparingTo("85"); |
||||
|
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_211).isEqualByComparingTo("75"); |
||||
|
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_OTHER).isEqualByComparingTo("65"); |
||||
|
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_MISSING).isEqualByComparingTo("50"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:分数均为字符串构造,scale 与重构前一致") |
||||
|
void bigDecimalsUseStringConstructionAndKeepScale() { |
||||
|
assertThat(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT.toPlainString()).isEqualTo("12.50"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT.toPlainString()).isEqualTo("0.55"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT.toPlainString()).isEqualTo("0.45"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR.toPlainString()).isEqualTo("100"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN.toPlainString()).isEqualTo("50"); |
||||
|
} |
||||
|
|
||||
|
// ── 版本 ──────────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:rulesVersion 逐字不变(candidate-scoring-v0.1),且与计算器同源") |
||||
|
void rulesVersionIsUnchangedAndShared() { |
||||
|
assertThat(CandidateScoringRuleSet.VERSION).isEqualTo("candidate-scoring-v0.1"); |
||||
|
assertThat(CandidateScoringCalculator.RULES_VERSION) |
||||
|
.as("计算器必须读取同一规则来源,版本值一致") |
||||
|
.isEqualTo(CandidateScoringRuleSet.VERSION); |
||||
|
assertThat(CandidateScoringCalculator.DEFAULT_WEIGHT) |
||||
|
.isEqualByComparingTo(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:八维度集合与 isValidDimension 行为不变") |
||||
|
void dimensionSetIsUnchanged() { |
||||
|
List<String> expected = List.of("EDUCATION", "INTERNSHIP", "ABILITY", "COMPLIANCE", |
||||
|
"STABILITY", "GROWTH", "INTEGRITY", "ACTIVITY"); |
||||
|
|
||||
|
for (String dimension : expected) { |
||||
|
assertThat(CandidateScoringCalculator.isValidDimension(dimension)).isTrue(); |
||||
|
} |
||||
|
assertThat(CandidateScoringCalculator.isValidDimension("UNKNOWN")).isFalse(); |
||||
|
// 既有契约:DIMENSIONS 为 Set.of(...),contains(null) 抛 NPE(不是返回 false)。
|
||||
|
// 本轮不得改变该行为,故按实际语义锁定。
|
||||
|
assertThatThrownBy(() -> CandidateScoringCalculator.isValidDimension(null)) |
||||
|
.isInstanceOf(NullPointerException.class); |
||||
|
} |
||||
|
|
||||
|
// ── 集合不可修改 ──────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:规则集对外只读,任何修改尝试必须失败") |
||||
|
void ruleSetIsReadOnly() { |
||||
|
assertThatThrownBy(() -> CandidateScoringRuleSet.c985().add("测试大学")) |
||||
|
.isInstanceOf(UnsupportedOperationException.class); |
||||
|
assertThatThrownBy(() -> CandidateScoringRuleSet.c211().add("测试大学")) |
||||
|
.isInstanceOf(UnsupportedOperationException.class); |
||||
|
assertThatThrownBy(() -> CandidateScoringRuleSet.c985().remove("北京大学")) |
||||
|
.isInstanceOf(UnsupportedOperationException.class); |
||||
|
assertThatThrownBy(() -> CandidateScoringRuleSet.c211().clear()) |
||||
|
.isInstanceOf(UnsupportedOperationException.class); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,141 @@ |
|||||
|
package com.mydreamjob.common.r01c; |
||||
|
|
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
||||
|
import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
|
||||
|
/** |
||||
|
* R01-C 特征测试:overviewStatus 聚合规则(唯一实现 {@code CandidateOverviewStatusPolicy})。 |
||||
|
* |
||||
|
* <p>覆盖四种状态、空集合、null、以及"可计算但分数缺失"的边界; |
||||
|
* 并锁定两个入口(计算结果 / 快照)在等价输入下的产出关系。</p> |
||||
|
*/ |
||||
|
class OverviewStatusPolicyCharacterizationTest { |
||||
|
|
||||
|
private static final int EXPECTED = 8; |
||||
|
|
||||
|
private static CandidateScoringCalculator.DimensionResult result(String status, String score) { |
||||
|
CandidateScoringCalculator.DimensionResult r = |
||||
|
new CandidateScoringCalculator.DimensionResult("EDUCATION"); |
||||
|
r.status = status; |
||||
|
r.score = score == null ? null : new BigDecimal(score); |
||||
|
return r; |
||||
|
} |
||||
|
|
||||
|
private static List<CandidateScoringCalculator.DimensionResult> results(String status, String score, int n) { |
||||
|
List<CandidateScoringCalculator.DimensionResult> list = new ArrayList<>(); |
||||
|
for (int i = 0; i < n; i++) list.add(result(status, score)); |
||||
|
return list; |
||||
|
} |
||||
|
|
||||
|
private static CandidateDimensionScore snapshot(String status) { |
||||
|
CandidateDimensionScore s = new CandidateDimensionScore(); |
||||
|
s.setStatus(status); |
||||
|
return s; |
||||
|
} |
||||
|
|
||||
|
// ── 计算结果入口 ──────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:8/8 可计算 → OK;7/8 → PARTIAL;0/8 且全 PENDING → PENDING;否则 INSUFFICIENT_DATA") |
||||
|
void computedResultsFourStates() { |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", "80", 8), EXPECTED)) |
||||
|
.isEqualTo("OK"); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("ESTIMATED", "80", 8), EXPECTED)) |
||||
|
.as("ESTIMATED 与 OK 同为可计算") |
||||
|
.isEqualTo("OK"); |
||||
|
|
||||
|
List<CandidateScoringCalculator.DimensionResult> partial = results("OK", "80", 7); |
||||
|
partial.add(result("PENDING", null)); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(partial, EXPECTED)).isEqualTo("PARTIAL"); |
||||
|
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("PENDING", null, 8), EXPECTED)) |
||||
|
.isEqualTo("PENDING"); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("INSUFFICIENT_DATA", null, 8), EXPECTED)) |
||||
|
.isEqualTo("INSUFFICIENT_DATA"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:OK 但分数缺失 → 不可计算(status 与 score 都必须满足)") |
||||
|
void okStatusWithoutScoreIsNotComputable() { |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", null, 8), EXPECTED)) |
||||
|
.isEqualTo("INSUFFICIENT_DATA"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:空列表 → PENDING(计算结果入口的 empty 语义)") |
||||
|
void emptyComputedResultsIsPending() { |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(List.of(), EXPECTED)).isEqualTo("PENDING"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:null 结果列表 → NullPointerException(既有契约)") |
||||
|
void nullComputedResultsThrows() { |
||||
|
assertThatThrownBy(() -> CandidateOverviewStatusPolicy.fromComputedResults(null, EXPECTED)) |
||||
|
.isInstanceOf(NullPointerException.class); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:expectedDimensions 大于实际可计算数时不得判 OK") |
||||
|
void expectedDimensionsGatesOk() { |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(results("OK", "80", 8), 9)) |
||||
|
.isEqualTo("PARTIAL"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:PENDING 与 INSUFFICIENT_DATA 混合 → INSUFFICIENT_DATA(非 PENDING)") |
||||
|
void mixedPendingAndInsufficientIsInsufficient() { |
||||
|
List<CandidateScoringCalculator.DimensionResult> mixed = new ArrayList<>(); |
||||
|
mixed.add(result("PENDING", null)); |
||||
|
mixed.add(result("INSUFFICIENT_DATA", null)); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(mixed, EXPECTED)) |
||||
|
.isEqualTo("INSUFFICIENT_DATA"); |
||||
|
} |
||||
|
|
||||
|
// ── 快照入口 ──────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:快照入口 —— OK/ESTIMATED 全可计算 → OK;空/null → INSUFFICIENT_DATA") |
||||
|
void snapshotEntryStates() { |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots( |
||||
|
List.of(snapshot("OK"), snapshot("ESTIMATED")))).isEqualTo("OK"); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots( |
||||
|
List.of(snapshot("OK"), snapshot("PENDING")))).isEqualTo("PARTIAL"); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots( |
||||
|
List.of(snapshot("PENDING"), snapshot("PENDING")))).isEqualTo("PENDING"); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots( |
||||
|
List.of(snapshot("INSUFFICIENT_DATA"), snapshot("PENDING")))).isEqualTo("INSUFFICIENT_DATA"); |
||||
|
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of())).isEqualTo("INSUFFICIENT_DATA"); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(null)).isEqualTo("INSUFFICIENT_DATA"); |
||||
|
} |
||||
|
|
||||
|
// ── 唯一实现门禁 ──────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("结构门禁:overviewStatus 规则只有唯一的产出点(\"PARTIAL\" 只在该策略类出现)") |
||||
|
void partialStatusHasSingleProducer() throws Exception { |
||||
|
java.nio.file.Path root = java.nio.file.Path.of("src/main/java/com/mydreamjob"); |
||||
|
java.util.List<String> producers = new java.util.ArrayList<>(); |
||||
|
try (java.util.stream.Stream<java.nio.file.Path> walk = java.nio.file.Files.walk(root)) { |
||||
|
for (java.nio.file.Path p : walk.filter(f -> f.toString().endsWith(".java")).toList()) { |
||||
|
String src = java.nio.file.Files.readString(p); |
||||
|
if (src.contains("\"PARTIAL\"")) { |
||||
|
producers.add(p.getFileName().toString()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
assertThat(producers) |
||||
|
.as("PARTIAL 只能由 CandidateOverviewStatusPolicy 产出") |
||||
|
.containsExactly("CandidateOverviewStatusPolicy.java"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,211 @@ |
|||||
|
package com.mydreamjob.common.r01c; |
||||
|
|
||||
|
import com.mydreamjob.recommendation.JobPreferenceScoreCalculator; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
/** |
||||
|
* R01-C 特征测试:岗位偏好分(30/20/20/15/15)黄金样本。 |
||||
|
* |
||||
|
* <p>期望值由 30/20/20/15/15 规则**手工推算**({@code matched*100/available},scale=2,HALF_UP), |
||||
|
* 不调用被测实现生成,避免同源假证明。使用 {@code isEqualByComparingTo} 并额外断言 scale 与字符串形式, |
||||
|
* 不使用 {@code isCloseTo} 之类的宽松误差。</p> |
||||
|
*/ |
||||
|
class PreferenceScoreCharacterizationTest { |
||||
|
|
||||
|
private static final List<String> CITIES = List.of("上海", "北京"); |
||||
|
private static final List<String> TYPES = List.of("FULL_TIME"); |
||||
|
private static final List<String> INDUSTRIES = List.of("IT"); |
||||
|
private static final List<String> FAMILIES = List.of("BACKEND"); |
||||
|
private static final Integer SALARY_MIN = 20000; |
||||
|
|
||||
|
private static JobPreferenceScoreCalculator.Criteria criteria( |
||||
|
List<String> cities, List<String> types, Integer salaryMin, |
||||
|
List<String> industries, List<String> families) { |
||||
|
return new JobPreferenceScoreCalculator.Criteria(cities, types, salaryMin, industries, families); |
||||
|
} |
||||
|
|
||||
|
private static JobPreferenceScoreCalculator.Target target( |
||||
|
String city, String type, Integer salaryMax, String industry, String family) { |
||||
|
return new JobPreferenceScoreCalculator.Target(city, type, salaryMax, industry, family); |
||||
|
} |
||||
|
|
||||
|
private static final JobPreferenceScoreCalculator.Criteria ALL = |
||||
|
criteria(CITIES, TYPES, SALARY_MIN, INDUSTRIES, FAMILIES); |
||||
|
|
||||
|
private static void assertExact(BigDecimal actual, String expected) { |
||||
|
assertThat(actual).as("数值").isNotNull().isEqualByComparingTo(expected); |
||||
|
assertThat(actual.scale()).as("scale 必须为 2").isEqualTo(2); |
||||
|
assertThat(actual.toPlainString()).as("精确字符串形式").isEqualTo(expected); |
||||
|
} |
||||
|
|
||||
|
// ── 全匹配 / 全不匹配 ─────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:五个维度全匹配 → available=100, matched=100 → 100.00") |
||||
|
void allMatched() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(ALL, |
||||
|
target("上海", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:五个维度全不匹配 → 0.00") |
||||
|
void noneMatched() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(ALL, |
||||
|
target("广州", "PART_TIME", 10000, "FINANCE", "SALES")), "0.00"); |
||||
|
} |
||||
|
|
||||
|
// ── 单维度 ────────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:仅城市维度存在且匹配 → available=30, matched=30 → 100.00") |
||||
|
void cityOnly() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(criteria(CITIES, List.of(), null, List.of(), List.of()), |
||||
|
target("北京", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:仅城市维度存在但不匹配 → available=30, matched=0 → 0.00") |
||||
|
void cityOnlyMiss() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(criteria(CITIES, List.of(), null, List.of(), List.of()), |
||||
|
target("广州", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:仅行业维度存在且匹配 → available=15, matched=15 → 100.00") |
||||
|
void industryOnly() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(criteria(List.of(), List.of(), null, INDUSTRIES, List.of()), |
||||
|
target("广州", "PART_TIME", 10000, "IT", "SALES")), "100.00"); |
||||
|
} |
||||
|
|
||||
|
// ── 部分匹配与加权 ────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:城市匹配 + 行业不匹配 → available=45, matched=30 → 66.67") |
||||
|
void cityMatchedIndustryMissed() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(CITIES, List.of(), null, INDUSTRIES, List.of()), |
||||
|
target("上海", "PART_TIME", 10000, "FINANCE", "SALES")), "66.67"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:城市不匹配 + 行业匹配 → available=45, matched=15 → 33.33") |
||||
|
void cityMissedIndustryMatched() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(CITIES, List.of(), null, INDUSTRIES, List.of()), |
||||
|
target("广州", "PART_TIME", 10000, "IT", "SALES")), "33.33"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:四维存在(城市/类型/薪资/行业),三项匹配 → 70*100/85 = 82.35") |
||||
|
void fourDimensionsThreeMatched() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(CITIES, TYPES, SALARY_MIN, INDUSTRIES, List.of()), |
||||
|
target("北京", "FULL_TIME", 25000, "FINANCE", "BACKEND")), "82.35"); |
||||
|
} |
||||
|
|
||||
|
// ── 薪资语义与边界 ────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:薪资刚好相等(salaryMax == expectedSalaryMin)视为匹配 → 100.00") |
||||
|
void salaryBoundaryEqualityCountsAsMatch() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()), |
||||
|
target("广州", "PART_TIME", 20000, "IT", "SALES")), "100.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:薪资低于期望 → 0.00;岗位 salaryMax 为 null → 0.00") |
||||
|
void salaryBelowOrNullCountsAsMiss() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()), |
||||
|
target("广州", "PART_TIME", 19999, "IT", "SALES")), "0.00"); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of(), List.of(), SALARY_MIN, List.of(), List.of()), |
||||
|
target("广州", "PART_TIME", null, "IT", "SALES")), "0.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:期望薪资为 null 时该维度不计入 available → 结果仍为 100.00") |
||||
|
void nullExpectedSalaryIsNotCounted() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(CITIES, List.of(), null, List.of(), List.of()), |
||||
|
target("上海", "PART_TIME", null, "IT", "SALES")), "100.00"); |
||||
|
} |
||||
|
|
||||
|
// ── 空集合与 null ─────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:全部为空/ null → available=0 → 返回 null(不是 0.00)") |
||||
|
void emptyCriteriaReturnsNull() { |
||||
|
assertThat(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of(), List.of(), null, List.of(), List.of()), |
||||
|
target("上海", "FULL_TIME", 25000, "IT", "BACKEND"))).isNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:null 集合按空集合处理(与重构前 Criteria 的 null 归一化一致)") |
||||
|
void nullListsBehaveAsEmpty() { |
||||
|
JobPreferenceScoreCalculator.Criteria c = |
||||
|
criteria(null, null, null, null, null); |
||||
|
assertThat(c.cities()).isEmpty(); |
||||
|
assertThat(c.employmentTypes()).isEmpty(); |
||||
|
assertThat(c.industries()).isEmpty(); |
||||
|
assertThat(c.jobFamilies()).isEmpty(); |
||||
|
|
||||
|
assertThat(JobPreferenceScoreCalculator.calculate(c, |
||||
|
target("上海", "FULL_TIME", 25000, "IT", "BACKEND"))).isNull(); |
||||
|
} |
||||
|
|
||||
|
// ── 大小写与未知值 ────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:大小写差异按现状为不匹配(精确匹配语义)") |
||||
|
void caseSensitivityIsExact() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of("shanghai"), List.of(), null, List.of(), List.of()), |
||||
|
target("SHANGHAI", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00"); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of("SHANGHAI"), List.of(), null, List.of(), List.of()), |
||||
|
target("SHANGHAI", "FULL_TIME", 25000, "IT", "BACKEND")), "100.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:未知枚举/历史脏值按普通字符串参与精确匹配") |
||||
|
void unknownValuesAreMatchedLiterally() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of(), List.of("LEGACY_TYPE"), null, List.of(), List.of()), |
||||
|
target("广州", "LEGACY_TYPE", 25000, "IT", "SALES")), "100.00"); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of(), List.of("LEGACY_TYPE"), null, List.of(), List.of()), |
||||
|
target("广州", "FULL_TIME", 25000, "IT", "SALES")), "0.00"); |
||||
|
} |
||||
|
|
||||
|
// ── 边界与上界 ────────────────────────────────────────────
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:空字符串元素不匹配任何岗位值(不trim、不归一化)") |
||||
|
void blankValuesDoNotMatch() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
criteria(List.of(""), List.of(), null, List.of(), List.of()), |
||||
|
target("上海", "FULL_TIME", 25000, "IT", "BACKEND")), "0.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:结果域为 [0.00, 100.00],全不匹配为下界、全匹配为上界") |
||||
|
void resultBounds() { |
||||
|
BigDecimal lower = JobPreferenceScoreCalculator.calculate(ALL, |
||||
|
target("广州", "PART_TIME", 10000, "FINANCE", "SALES")); |
||||
|
BigDecimal upper = JobPreferenceScoreCalculator.calculate(ALL, |
||||
|
target("上海", "FULL_TIME", 25000, "IT", "BACKEND")); |
||||
|
|
||||
|
assertThat(lower).isEqualByComparingTo("0.00"); |
||||
|
assertThat(upper).isEqualByComparingTo("100.00"); |
||||
|
assertThat(lower).isLessThanOrEqualTo(upper); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,407 @@ |
|||||
|
package com.mydreamjob.common.r01c; |
||||
|
|
||||
|
import com.mydreamjob.candidatescore.CandidateDimensionScore; |
||||
|
import com.mydreamjob.candidatescore.CandidateOverviewStatusPolicy; |
||||
|
import com.mydreamjob.candidatescore.CandidateScoringCalculator; |
||||
|
import com.mydreamjob.candidatescore.rules.CandidateScoringRuleSet; |
||||
|
import com.mydreamjob.job.Job; |
||||
|
import com.mydreamjob.preference.StudentPreference; |
||||
|
import com.mydreamjob.recommendation.JobPreferenceScoreCalculator; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationProfile; |
||||
|
import com.mydreamjob.recommendation.JobRecommendationService; |
||||
|
import com.mydreamjob.talentdiscovery.TalentDiscoveryCandidateSource; |
||||
|
import com.mydreamjob.talentdiscovery.TalentDiscoveryService; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
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 java.lang.reflect.Method; |
||||
|
import java.math.BigDecimal; |
||||
|
import java.nio.file.Files; |
||||
|
import java.nio.file.Path; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Collections; |
||||
|
import java.util.List; |
||||
|
import java.util.stream.Stream; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
|
||||
|
/** |
||||
|
* R01-C 独立复验:行为等价、结构门禁与跨服务一致性。 |
||||
|
* |
||||
|
* <p>本类的期望值独立推导(手工推算或从 HEAD 抄录),不复用实施者断言的构造方式。</p> |
||||
|
*/ |
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class R01CIndependentVerificationTest { |
||||
|
|
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
@Autowired private JobRecommendationService jobRecommendationService; |
||||
|
@Autowired private TalentDiscoveryService talentDiscoveryService; |
||||
|
|
||||
|
private static final Path MAIN_ROOT = Path.of("src/main/java/com/mydreamjob"); |
||||
|
|
||||
|
private static String read(Path p) throws Exception { |
||||
|
return Files.readString(p); |
||||
|
} |
||||
|
|
||||
|
private static List<Path> javaFiles() throws Exception { |
||||
|
try (Stream<Path> walk = Files.walk(MAIN_ROOT)) { |
||||
|
return walk.filter(f -> f.toString().endsWith(".java")).toList(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ══ A. overviewStatus ═════════════════════════════════════
|
||||
|
|
||||
|
private static CandidateScoringCalculator.DimensionResult res(String status, String score) { |
||||
|
CandidateScoringCalculator.DimensionResult r = |
||||
|
new CandidateScoringCalculator.DimensionResult("EDUCATION"); |
||||
|
r.status = status; |
||||
|
r.score = score == null ? null : new BigDecimal(score); |
||||
|
return r; |
||||
|
} |
||||
|
|
||||
|
private static CandidateDimensionScore snap(String status) { |
||||
|
CandidateDimensionScore s = new CandidateDimensionScore(); |
||||
|
s.setStatus(status); |
||||
|
return s; |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 A:全可用/部分可用/全不可用/空集合 四种边界") |
||||
|
void overviewStatusBoundaryMatrix() { |
||||
|
int expected = 4; |
||||
|
|
||||
|
// 全部可用 → OK
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults( |
||||
|
List.of(res("OK", "80"), res("ESTIMATED", "70"), res("OK", "60"), res("ESTIMATED", "50")), expected)) |
||||
|
.isEqualTo("OK"); |
||||
|
// 部分可用 → PARTIAL
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults( |
||||
|
List.of(res("OK", "80"), res("PENDING", null), res("INSUFFICIENT_DATA", null), res("OK", "60")), expected)) |
||||
|
.isEqualTo("PARTIAL"); |
||||
|
// 全不可用(全 PENDING)→ PENDING
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults( |
||||
|
List.of(res("PENDING", null), res("PENDING", null), res("PENDING", null), res("PENDING", null)), expected)) |
||||
|
.isEqualTo("PENDING"); |
||||
|
// 全不可用(含 INSUFFICIENT_DATA)→ INSUFFICIENT_DATA
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults( |
||||
|
List.of(res("INSUFFICIENT_DATA", null), res("PENDING", null)), 2)) |
||||
|
.isEqualTo("INSUFFICIENT_DATA"); |
||||
|
// 空集合
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(List.of(), expected)).isEqualTo("PENDING"); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of())).isEqualTo("INSUFFICIENT_DATA"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 A:null 与非法输入的既有语义(NPE / INSUFFICIENT_DATA)") |
||||
|
void overviewStatusNullAndUnknownSemantics() { |
||||
|
assertThatThrownBy(() -> CandidateOverviewStatusPolicy.fromComputedResults(null, 8)) |
||||
|
.isInstanceOf(NullPointerException.class); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(null)).isEqualTo("INSUFFICIENT_DATA"); |
||||
|
|
||||
|
// 未知状态字符串:既不可计算也不是 PENDING → INSUFFICIENT_DATA
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults( |
||||
|
List.of(res("WEIRD", "50")), 1)).isEqualTo("INSUFFICIENT_DATA"); |
||||
|
assertThat(CandidateOverviewStatusPolicy.fromSnapshots(List.of(snap("WEIRD")))) |
||||
|
.isEqualTo("INSUFFICIENT_DATA"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 A:输入顺序变化不改变结果") |
||||
|
void overviewStatusIsOrderIndependent() { |
||||
|
List<CandidateScoringCalculator.DimensionResult> a = new ArrayList<>(List.of( |
||||
|
res("OK", "80"), res("PENDING", null), res("INSUFFICIENT_DATA", null))); |
||||
|
List<CandidateScoringCalculator.DimensionResult> b = new ArrayList<>(a); |
||||
|
Collections.reverse(b); |
||||
|
|
||||
|
assertThat(CandidateOverviewStatusPolicy.fromComputedResults(a, 3)) |
||||
|
.isEqualTo(CandidateOverviewStatusPolicy.fromComputedResults(b, 3)) |
||||
|
.isEqualTo("PARTIAL"); |
||||
|
} |
||||
|
|
||||
|
// ══ B. 偏好评分黄金表 + 跨服务一致 ════════════════════════
|
||||
|
|
||||
|
private static void assertExact(BigDecimal actual, String expected) { |
||||
|
assertThat(actual).isNotNull().isEqualByComparingTo(expected); |
||||
|
assertThat(actual.scale()).as("scale").isEqualTo(2); |
||||
|
assertThat(actual.toPlainString()).as("精确字符串").isEqualTo(expected); |
||||
|
} |
||||
|
|
||||
|
private static JobPreferenceScoreCalculator.Target target(String city, String type, Integer salaryMax) { |
||||
|
return new JobPreferenceScoreCalculator.Target(city, type, salaryMax, "IT", "BACKEND"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 B:五个维度分别单独命中 → 每一维独立命中均为 100.00") |
||||
|
void eachDimensionAloneMatchesFully() { |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria(List.of("上海"), List.of(), null, List.of(), List.of()), |
||||
|
target("上海", "X", null)), "100.00"); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of("FULL_TIME"), null, List.of(), List.of()), |
||||
|
target("X", "FULL_TIME", null)), "100.00"); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), 20000, List.of(), List.of()), |
||||
|
target("X", "Y", 20000)), "100.00"); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of("IT"), List.of()), |
||||
|
target("X", "Y", null)), "100.00"); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of(), List.of("BACKEND")), |
||||
|
target("X", "Y", null)), "100.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 B:全部命中 100.00 / 全部缺失(零分母)返回 null") |
||||
|
void allMatchAndZeroDenominator() { |
||||
|
JobPreferenceScoreCalculator.Criteria all = |
||||
|
new JobPreferenceScoreCalculator.Criteria( |
||||
|
List.of("上海"), List.of("FULL_TIME"), 20000, List.of("IT"), List.of("BACKEND")); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(all, target("上海", "FULL_TIME", 25000)), "100.00"); |
||||
|
|
||||
|
assertThat(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), null, List.of(), List.of()), |
||||
|
target("上海", "FULL_TIME", 25000))).isNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 B:部分维度缺失时的分母只计存在维度(加权精确值)") |
||||
|
void partialDimensionsUseOnlyPresentWeights() { |
||||
|
// 城市(30) 命中 + 用工类型(20) 缺失 + 薪资(20) 命中 + 行业(15) 缺失 + 岗位族(15) 缺失
|
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria( |
||||
|
List.of("上海"), List.of(), 20000, List.of(), List.of()), |
||||
|
target("上海", "X", 20000)), "100.00"); |
||||
|
|
||||
|
// 城市(30) 命中 + 行业(15) 未命中 → 30*100/45 = 66.666… → 66.67(HALF_UP)
|
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria( |
||||
|
List.of("上海"), List.of(), null, List.of("IT"), List.of()), |
||||
|
target("上海", "X", null)), "100.00"); |
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria( |
||||
|
List.of("上海"), List.of(), null, List.of("FINANCE"), List.of()), |
||||
|
target("上海", "X", null)), "66.67"); |
||||
|
|
||||
|
// 四方存在、命中两项:城市30 未中 + 行业15 中 → 15*100/65 = 23.076… → 23.08
|
||||
|
assertExact(JobPreferenceScoreCalculator.calculate( |
||||
|
new JobPreferenceScoreCalculator.Criteria( |
||||
|
List.of("广州"), List.of("PART_TIME"), null, List.of("IT"), List.of()), |
||||
|
target("上海", "FULL_TIME", null)), "23.08"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 B:薪资边界 —— 期望值与岗位上限相等视为命中,低一单位不命中") |
||||
|
void salaryBoundarySemantics() { |
||||
|
JobPreferenceScoreCalculator.Criteria c = |
||||
|
new JobPreferenceScoreCalculator.Criteria(List.of(), List.of(), 20000, List.of(), List.of()); |
||||
|
|
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 20000)), "100.00"); // 相等
|
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 20001)), "100.00"); // 高一单位
|
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", 19999)), "0.00"); // 低一单位
|
||||
|
assertExact(JobPreferenceScoreCalculator.calculate(c, target("X", "Y", null)), "0.00"); // 岗位未填
|
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 B:跨服务一致 —— JobRecommendation 与 TalentDiscovery 逐值相同") |
||||
|
void bothServicesProduceIdenticalPreferenceScores() throws Exception { |
||||
|
Method jr = JobRecommendationService.class.getDeclaredMethod( |
||||
|
"preferenceScore", StudentPreference.class, JobRecommendationProfile.class, Job.class); |
||||
|
jr.setAccessible(true); |
||||
|
Method td = TalentDiscoveryService.class.getDeclaredMethod( |
||||
|
"preferenceScore", TalentDiscoveryCandidateSource.PreferenceSignal.class, |
||||
|
JobRecommendationProfile.class, Job.class); |
||||
|
td.setAccessible(true); |
||||
|
|
||||
|
JobRecommendationProfile profile = JobRecommendationProfile.builder() |
||||
|
.jobId(1L).industryCode("IT").jobFamilyCode("BACKEND").build(); |
||||
|
|
||||
|
// 三组输入:全命中 / 部分命中 / 全不命中
|
||||
|
String[][] cases = { |
||||
|
{"[\"上海\"]", "[\"FULL_TIME\"]", "20000", "[\"IT\"]", "[\"BACKEND\"]", "上海", "FULL_TIME", "25000"}, |
||||
|
{"[\"上海\"]", "[\"PART_TIME\"]", "30000", "[\"IT\"]", "[]", "广州", "FULL_TIME", "10000"}, |
||||
|
{"[\"广州\"]", "[]", "null", "[]", "[]", "上海", "FULL_TIME", "25000"}, |
||||
|
}; |
||||
|
|
||||
|
for (String[] c : cases) { |
||||
|
StudentPreference sp = StudentPreference.builder() |
||||
|
.studentUserId(1L) |
||||
|
.preferredCities(c[0]).employmentTypes(c[1]) |
||||
|
.expectedSalaryMin("null".equals(c[2]) ? null : Integer.valueOf(c[2])) |
||||
|
.preferredIndustries(c[3]).preferredJobTypes(c[4]) |
||||
|
.build(); |
||||
|
TalentDiscoveryCandidateSource.PreferenceSignal signal = |
||||
|
new TalentDiscoveryCandidateSource.PreferenceSignal( |
||||
|
"null".equals(c[2]) ? null : Integer.valueOf(c[2]), null, |
||||
|
parseJsonList(c[0]), parseJsonList(c[1]), parseJsonList(c[3]), parseJsonList(c[4]), false); |
||||
|
|
||||
|
Job job = Job.builder().id(1L).companyId(1L).title("t").description("d") |
||||
|
.city(c[5]).employmentType(c[6]) |
||||
|
.salaryMax("null".equals(c[7]) ? null : Integer.valueOf(c[7])) |
||||
|
.build(); |
||||
|
|
||||
|
BigDecimal fromRecommendation = (BigDecimal) jr.invoke(jobRecommendationService, sp, profile, job); |
||||
|
BigDecimal fromDiscovery = (BigDecimal) td.invoke(talentDiscoveryService, signal, profile, job); |
||||
|
|
||||
|
assertThat(fromRecommendation).as("case %s", String.join("|", c)) |
||||
|
.isEqualByComparingTo(fromDiscovery); |
||||
|
if (fromRecommendation != null) { |
||||
|
assertThat(fromRecommendation.scale()).isEqualTo(fromDiscovery.scale()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private static List<String> parseJsonList(String json) { |
||||
|
if (json == null || json.isBlank() || "[]".equals(json)) return List.of(); |
||||
|
String inner = json.substring(1, json.length() - 1); |
||||
|
if (inner.isBlank()) return List.of(); |
||||
|
List<String> out = new ArrayList<>(); |
||||
|
for (String part : inner.split(",")) out.add(part.replace("\"", "").trim()); |
||||
|
return out; |
||||
|
} |
||||
|
|
||||
|
// ══ C. 教育与高校规则 ═════════════════════════════════════
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 C:985 / 211非985 / 普通高校 / 未知学校 的档次判定") |
||||
|
void schoolTierClassification() { |
||||
|
assertThat(CandidateScoringRuleSet.is985("北京大学")).isTrue(); |
||||
|
assertThat(CandidateScoringRuleSet.is211("北京大学")).isFalse(); |
||||
|
|
||||
|
assertThat(CandidateScoringRuleSet.is985("苏州大学")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is211("苏州大学")).isTrue(); |
||||
|
|
||||
|
assertThat(CandidateScoringRuleSet.is985("某某学院")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is211("某某学院")).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 C:名称精确匹配 —— 前后空白/大小写/简繁 均不匹配(既有语义)") |
||||
|
void schoolNameMatchingIsExact() { |
||||
|
assertThat(CandidateScoringRuleSet.is985(" 北京大学")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985("北京大学 ")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985("北京大學")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985("北京")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985(null)).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is985("")).isFalse(); |
||||
|
assertThat(CandidateScoringRuleSet.is211(null)).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 C:学历与档次分数、权重、默认权重与上界") |
||||
|
void educationAndWeightRules() { |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_DOCTOR).isEqualByComparingTo("100"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER).isEqualByComparingTo("85"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_BACHELOR).isEqualByComparingTo("70"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_JUNIOR_COLLEGE).isEqualByComparingTo("55"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN).isEqualByComparingTo("50"); |
||||
|
|
||||
|
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_985).isEqualByComparingTo("85"); |
||||
|
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_211).isEqualByComparingTo("75"); |
||||
|
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_OTHER).isEqualByComparingTo("65"); |
||||
|
assertThat(CandidateScoringRuleSet.SCHOOL_TIER_MISSING).isEqualByComparingTo("50"); |
||||
|
|
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_WEIGHT).isEqualByComparingTo("0.55"); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_SCHOOL_TIER_WEIGHT).isEqualByComparingTo("0.45"); |
||||
|
assertThat(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT).isEqualByComparingTo("12.50"); |
||||
|
assertThat(CandidateScoringRuleSet.FULL_SCORE).isEqualByComparingTo("100"); |
||||
|
|
||||
|
// 同值不同义:不得合并
|
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_MASTER) |
||||
|
.isEqualByComparingTo(CandidateScoringRuleSet.SCHOOL_TIER_985); |
||||
|
assertThat(CandidateScoringRuleSet.EDUCATION_LEVEL_UNKNOWN) |
||||
|
.isEqualByComparingTo(CandidateScoringRuleSet.SCHOOL_TIER_MISSING); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("独立复验 C:rulesVersion 与计算器同源,且值逐字不变") |
||||
|
void rulesVersionIsSingleSourced() { |
||||
|
assertThat(CandidateScoringRuleSet.VERSION).isEqualTo("candidate-scoring-v0.1"); |
||||
|
assertThat(CandidateScoringCalculator.RULES_VERSION).isEqualTo(CandidateScoringRuleSet.VERSION); |
||||
|
assertThat(CandidateScoringCalculator.DEFAULT_WEIGHT) |
||||
|
.isEqualByComparingTo(CandidateScoringRuleSet.DEFAULT_DIMENSION_WEIGHT); |
||||
|
} |
||||
|
|
||||
|
// ══ 结构门禁 ═════════════════════════════════════════════
|
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("结构门禁:overviewStatus 规则产出点唯一(PARTIAL 只在一个类出现)") |
||||
|
void overviewStatusProducerIsUnique() throws Exception { |
||||
|
List<String> producers = new ArrayList<>(); |
||||
|
for (Path p : javaFiles()) { |
||||
|
if (read(p).contains("\"PARTIAL\"")) producers.add(p.getFileName().toString()); |
||||
|
} |
||||
|
assertThat(producers).containsExactly("CandidateOverviewStatusPolicy.java"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("结构门禁:30/20/20/15/15 偏好公式实现点唯一") |
||||
|
void preferenceFormulaHasSingleImplementation() throws Exception { |
||||
|
List<String> impls = new ArrayList<>(); |
||||
|
for (Path p : javaFiles()) { |
||||
|
String src = read(p); |
||||
|
if (src.contains("total += 30") || src.contains("CITY_WEIGHT = 30")) { |
||||
|
impls.add(p.getFileName().toString()); |
||||
|
} |
||||
|
} |
||||
|
assertThat(impls).containsExactly("JobPreferenceScoreCalculator.java"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("结构门禁:985/211 名单定义点唯一,且计算器内不再定义名单") |
||||
|
void universityListHasSingleDefinition() throws Exception { |
||||
|
List<String> defs = new ArrayList<>(); |
||||
|
for (Path p : javaFiles()) { |
||||
|
String src = read(p); |
||||
|
if (src.contains("Set<String> C985") || src.contains("Set<String> C211")) { |
||||
|
defs.add(p.getFileName().toString()); |
||||
|
} |
||||
|
} |
||||
|
assertThat(defs).containsExactly("CandidateScoringRuleSet.java"); |
||||
|
assertThat(read(MAIN_ROOT.resolve("candidatescore/CandidateScoringCalculator.java"))) |
||||
|
.doesNotContain("Set<String> C985") |
||||
|
.doesNotContain("Set<String> C211"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("结构门禁:TalentDiscoveryService 不再维护内联偏好公式") |
||||
|
void talentDiscoveryHasNoInlineFormula() throws Exception { |
||||
|
String src = read(MAIN_ROOT.resolve("talentdiscovery/TalentDiscoveryService.java")); |
||||
|
assertThat(src).doesNotContain("total += 30").doesNotContain("got += 30"); |
||||
|
assertThat(src).contains("JobPreferenceScoreCalculator.calculate("); |
||||
|
assertThat(read(MAIN_ROOT.resolve("recommendation/JobRecommendationService.java"))) |
||||
|
.contains("JobPreferenceScoreCalculator.calculate("); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("结构门禁:规则集不依赖 Spring/JPA/JdbcTemplate/Repository,且无 setter") |
||||
|
void ruleSetHasNoFrameworkDependency() throws Exception { |
||||
|
String src = read(MAIN_ROOT.resolve("candidatescore/rules/CandidateScoringRuleSet.java")); |
||||
|
|
||||
|
assertThat(src).doesNotContain("@Component").doesNotContain("@Service").doesNotContain("@Autowired") |
||||
|
.doesNotContain("Repository").doesNotContain("JdbcTemplate").doesNotContain("EntityManager") |
||||
|
.doesNotContain("System.getenv").doesNotContain("@Value").doesNotContain("Properties"); |
||||
|
assertThat(src).contains("public final class"); |
||||
|
assertThat(src).doesNotContain("public void set"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("结构门禁:规则集暴露的集合不可被调用者修改") |
||||
|
void exposedCollectionsAreUnmodifiable() { |
||||
|
assertThatThrownBy(() -> CandidateScoringRuleSet.c985().add("X")) |
||||
|
.isInstanceOf(UnsupportedOperationException.class); |
||||
|
assertThatThrownBy(() -> CandidateScoringRuleSet.c211().add("X")) |
||||
|
.isInstanceOf(UnsupportedOperationException.class); |
||||
|
assertThatThrownBy(() -> CandidateScoringRuleSet.c985().remove("北京大学")) |
||||
|
.isInstanceOf(UnsupportedOperationException.class); |
||||
|
assertThatThrownBy(() -> CandidateScoringRuleSet.c211().clear()) |
||||
|
.isInstanceOf(UnsupportedOperationException.class); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,107 @@ |
|||||
|
package com.mydreamjob.common.r01c; |
||||
|
|
||||
|
import com.mydreamjob.recommendation.dto.JobRecommendationItem; |
||||
|
import org.junit.jupiter.api.DisplayName; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Comparator; |
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
/** |
||||
|
* R01-C 特征测试:推荐排序与分页切片。 |
||||
|
* |
||||
|
* <p>锁定重构前的排序契约:**总分降序,同分按 jobId 升序**(同分不再有第三种比较键), |
||||
|
* 以及 {@code slice} 的越界行为。</p> |
||||
|
*/ |
||||
|
class RecommendationOrderingCharacterizationTest { |
||||
|
|
||||
|
/** 与 {@code JobRecommendationService.recommend} 中完全相同的比较器。 */ |
||||
|
private static final Comparator<JobRecommendationItem> ORDER = |
||||
|
Comparator.comparing(JobRecommendationItem::getScore).reversed() |
||||
|
.thenComparing(JobRecommendationItem::getJobId); |
||||
|
|
||||
|
private static JobRecommendationItem item(long jobId, String score) { |
||||
|
return JobRecommendationItem.builder() |
||||
|
.jobId(jobId).title("t" + jobId).city("上海").employmentType("FULL_TIME") |
||||
|
.salaryMin(10000).salaryMax(20000).mode("PERSONALIZED") |
||||
|
.score(new BigDecimal(score)).reasons(List.of("岗位偏好匹配")).build(); |
||||
|
} |
||||
|
|
||||
|
private static List<Long> idsOf(List<JobRecommendationItem> items) { |
||||
|
return items.stream().map(JobRecommendationItem::getJobId).toList(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:分数不同的三个岗位按总分降序") |
||||
|
void differentScoresOrderByScoreDesc() { |
||||
|
List<JobRecommendationItem> items = new ArrayList<>(List.of( |
||||
|
item(30L, "61.50"), item(10L, "90.00"), item(20L, "75.25"))); |
||||
|
items.sort(ORDER); |
||||
|
|
||||
|
assertThat(idsOf(items)).containsExactly(10L, 20L, 30L); |
||||
|
assertThat(items.get(0).getScore()).isEqualByComparingTo("90.00"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:总分相同但分项不同 → 仍按 jobId 升序(不比较分项)") |
||||
|
void equalScoresBreakTieByJobId() { |
||||
|
List<JobRecommendationItem> items = new ArrayList<>(List.of( |
||||
|
item(9L, "80.00"), item(3L, "80.00"), item(7L, "80.00"))); |
||||
|
items.sort(ORDER); |
||||
|
|
||||
|
assertThat(idsOf(items)).containsExactly(3L, 7L, 9L); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:完全同分稳定性 —— 输入顺序打乱后结果一致") |
||||
|
void fullyEqualScoresAreDeterministic() { |
||||
|
List<JobRecommendationItem> a = new ArrayList<>(List.of( |
||||
|
item(5L, "70.00"), item(2L, "70.00"), item(8L, "70.00"))); |
||||
|
List<JobRecommendationItem> b = new ArrayList<>(List.of( |
||||
|
item(8L, "70.00"), item(5L, "70.00"), item(2L, "70.00"))); |
||||
|
a.sort(ORDER); |
||||
|
b.sort(ORDER); |
||||
|
|
||||
|
assertThat(idsOf(a)).containsExactly(2L, 5L, 8L); |
||||
|
assertThat(idsOf(a)).isEqualTo(idsOf(b)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:分数为 null 的岗位只可能被过滤掉;比较器本身对 null 抛 NPE(既有契约)") |
||||
|
void nullScoreIsNeverOrdered() { |
||||
|
List<JobRecommendationItem> withNull = new ArrayList<>(); |
||||
|
withNull.add(item(1L, "50.00")); |
||||
|
JobRecommendationItem noScore = JobRecommendationItem.builder() |
||||
|
.jobId(2L).title("t2").city("上海").employmentType("FULL_TIME") |
||||
|
.mode("FALLBACK").score(null).reasons(List.of("最新公开岗位")).build(); |
||||
|
withNull.add(noScore); |
||||
|
|
||||
|
assertThat(org.assertj.core.api.Assertions.catchThrowable(() -> withNull.sort(ORDER))) |
||||
|
.as("score 为 null 时排序抛 NPE —— 生产中 FALLBACK 项不参与该比较器") |
||||
|
.isInstanceOf(NullPointerException.class); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@DisplayName("特征:分页切片 from/to 与越界行为不变") |
||||
|
void sliceBoundsAreStable() { |
||||
|
List<JobRecommendationItem> all = new ArrayList<>(); |
||||
|
for (long i = 1; i <= 5; i++) all.add(item(i, "50.00")); |
||||
|
|
||||
|
assertThat(slice(all, 0, 2)).containsExactly(1L, 2L); |
||||
|
assertThat(slice(all, 1, 2)).containsExactly(3L, 4L); |
||||
|
assertThat(slice(all, 2, 2)).containsExactly(5L); |
||||
|
assertThat(slice(all, 9, 2)).isEmpty(); |
||||
|
assertThat(slice(all, 0, 50)).hasSize(5); |
||||
|
} |
||||
|
|
||||
|
/** 与 {@code JobRecommendationService.slice} 相同的下标计算。 */ |
||||
|
private static List<Long> slice(List<JobRecommendationItem> all, int page, int size) { |
||||
|
int from = Math.min(page * size, all.size()); |
||||
|
int to = Math.min(from + size, all.size()); |
||||
|
return idsOf(all.subList(from, to)); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue