forked from baskbuddy/zhangshun
20 changed files with 1574 additions and 27 deletions
@ -0,0 +1,319 @@ |
|||
# V38:四维能力测评有限接入候选人评分设计 |
|||
|
|||
> 状态:**已实现并通过真实 MySQL/Docker 关键黑盒验收,待建立 Git 检查点**。 |
|||
> 功能版本:V38。 |
|||
> 基线:Flyway `V1–V36`、OpenAPI `148 paths / 182 operations`、全量测试 `1427/1427`(`mvn clean verify`,0 Failures / 0 Errors / 0 Skipped)。 |
|||
> 本设计只接入 V36 四维能力测评,不接入 V35 RIASEC 兴趣测评。 |
|||
|
|||
## 1. 目标与边界 |
|||
|
|||
### 1.1 目标 |
|||
|
|||
在不改变 V36 测评主链路的前提下,将学生**最新、完整、仍有效**的四维能力测评结果映射为候选人评分中的 `ABILITY` 维度,使候选评分卡可有限地反映能力测评结果。 |
|||
|
|||
### 1.2 必须满足的边界 |
|||
|
|||
- 仅使用 V36 `ability_results`,不使用 V35 兴趣类型或职业方向结果。 |
|||
- 仅映射到 `ABILITY`;不写入或推断 `GROWTH`、`ACTIVITY` 等其他维度。 |
|||
- 不在测评提交时直接写 `candidate_dimension_scores`;V36 提交仍只写其 `ability_*` 表和既有审计。 |
|||
- 不新增企业查看测评详情的接口。企业仅通过既有候选评分卡看到 `ABILITY` 的聚合 `score/status`。 |
|||
- 不向任何评分接口返回题目、答案键、选项分值、题库标签、题目解析或测评答卷 ID。 |
|||
- 功能开关默认关闭;关闭时现有 `ABILITY=PENDING` 行为必须完全保持。 |
|||
- 不引入 AI 评分、人工覆写、最高分挑选、自动封禁或对既有学生数据的回填。 |
|||
|
|||
## 2. 已确认的产品与规则口径 |
|||
|
|||
| 项目 | V38 锁定口径 | |
|||
|---|---| |
|||
| 可采纳来源 | V36 最新 `COMPLETED` 且 `valid_until > capturedNow` 的结果 | |
|||
| 四维权重 | `LOGIC=35%`、`EXECUTION=30%`、`LEARNING=20%`、`COMMUNICATION=15%` | |
|||
| 结果选择 | 只取最新有效完成结果;按 `completed_at DESC, attempt_id DESC` 稳定排序;绝不取历史最高分 | |
|||
| 有效期 | 复用 V36 的 7 天;过期结果仍可由学生查看历史,但不得参与新评分计算 | |
|||
| 重测治理 | 复用 V36:有效结果期间禁止重测,结果到期后可重测,滚动 90 天至多完成 3 次 | |
|||
| 企业可见性 | 仅既有公司评分卡的 `dimensions[]{dimension,score,status}`;无四维细分、短板、答卷或题目反馈 | |
|||
| 默认发布状态 | `OFF`,不影响候选综合分、V32 排名或 V34 求职进度 | |
|||
|
|||
## 3. 总体方案:评分时只读投影 |
|||
|
|||
采用**评分时只读投影(read-through projection)**,而不是在 V36 提交后跨模块写入候选评分表。 |
|||
|
|||
```text |
|||
V36 完成测评 |
|||
└─ 写 ability_attempts / ability_results(既有行为,不调用评分引擎) |
|||
|
|||
学生或企业读取既有候选评分卡 |
|||
└─ CandidateScoringCalculator |
|||
└─ AbilityAssessmentScoreSource(只读选择最新有效 ability_result) |
|||
└─ AbilityScoreMapper(按 V38 固定权重计算 ABILITY) |
|||
└─ 既有批量 upsert candidate_dimension_scores(既有评分快照机制) |
|||
``` |
|||
|
|||
这样做的原因: |
|||
|
|||
1. V36 仍可独立工作,测评提交失败或评分开关关闭都不会影响其完成、结果和历史查询。 |
|||
2. 评分只在既有评分查询/重算时产生快照,沿用当前 `candidate_dimension_scores` 的写入模型。 |
|||
3. 不需要异步消息、定时回填或跨服务事务;结果到期后下一次评分自然变为 `PENDING`。 |
|||
4. 评分行的 `sub_scores_json` 保留最小化、可追溯的来源版本信息,不需新建投影表。 |
|||
|
|||
## 4. 数据选择与有效性判定 |
|||
|
|||
### 4.1 单一查询契约 |
|||
|
|||
新增一个只读仓库查询,语义为: |
|||
|
|||
```text |
|||
findLatestValidCompletedResult(studentUserId, typeCode, capturedNow) |
|||
``` |
|||
|
|||
查询必须同时满足: |
|||
|
|||
- `ability_attempts.student_user_id = :studentUserId`; |
|||
- `ability_attempts.status = 'COMPLETED'`; |
|||
- `ability_results.valid_until > :capturedNow`; |
|||
- 测评定义为 V36 能力测评类型且当前/答卷题集版本可识别; |
|||
- 排序固定为 `completed_at DESC, ability_attempts.id DESC`; |
|||
- 最多取一条。 |
|||
|
|||
`capturedNow` 必须在一次评分计算开始时由后端生成并全程复用,不能在同一计算中多次调用 `now()`,以避免临界过期时出现不一致。 |
|||
|
|||
### 4.2 结果结构防御 |
|||
|
|||
即使查询到结果,也仅在以下条件全部成立时计算: |
|||
|
|||
- `dimension_scores_json` 可被安全解析; |
|||
- 恰有 `LOGIC`、`COMMUNICATION`、`EXECUTION`、`LEARNING` 四维; |
|||
- 每维分数均在 `[0,100]`,且均为有限数值; |
|||
- 每维已答题数均为 6,答卷总已答题数为 24; |
|||
- `overall_score`、题集版本和 V36 规则版本存在。 |
|||
|
|||
> 实现细则(V38 发布前 P1):`answeredCount` 使用精确整数校验,仅接受数值意义上等于整数的表示 |
|||
> (如 JSON 整数 6 或 6.0、`"6"`、`"6.0"`);`6.9`、`6.5`、`"6.5"`、溢出 int、空串、`null` 均视为不可用 |
|||
> (`stripTrailingZeros` + `intValueExact`,不使用可能截断小数的 `intValue()`)。`attemptAnsweredCount` |
|||
> 必须非空且严格等于 24,不允许 `null` 放行。 |
|||
|
|||
任一条件不满足时,不抛给前端 500,也不使用部分数据;该学生本次 `ABILITY` 返回 `PENDING`,原因写为“无可用的完整能力测评结果”。内部日志仅记录结果 ID 与结构错误类型,禁止记录题目、答案或选项分值。 |
|||
|
|||
### 4.3 过期与重测 |
|||
|
|||
- 无有效结果、仅有过期结果、或最新结果格式无效:`ABILITY=PENDING`,`score=null`。 |
|||
- 有效期内没有重测入口;到期后的新测评完成后,在下一次评分计算中自然替换为新结果。 |
|||
- 结果选择不比较分数,因此不能通过“多次测到高分”挑选最高成绩。 |
|||
- 已写入的旧月度快照保留为历史;当前月份按现有 upsert 语义在下一次评分查询/重算时刷新。 |
|||
|
|||
## 5. 分数映射 |
|||
|
|||
### 5.1 公式 |
|||
|
|||
设 V36 四维百分制结果为 `L`(逻辑)、`C`(沟通)、`E`(执行)、`R`(学习),则: |
|||
|
|||
```text |
|||
abilityScore = roundHalfUp(L × 0.35 + E × 0.30 + R × 0.20 + C × 0.15, 2) |
|||
``` |
|||
|
|||
分数范围为 `0.00–100.00`。所有中间计算使用 `BigDecimal`;最终 `scale=2`、`HALF_UP`。不得复用 V36 等权 `overallScore` 直接作为候选 `ABILITY` 分数,因为 V38 已确认使用不同的、显式的业务权重。 |
|||
|
|||
### 5.2 维度结果 |
|||
|
|||
| 条件 | `ABILITY.status` | `ABILITY.score` | 对综合分的影响 | |
|||
|---|---:|---:|---| |
|||
| 开关关闭 | `PENDING` | `null` | 与 V37 前完全一致,不参与平均 | |
|||
| 无有效完整结果 | `PENDING` | `null` | 不参与平均 | |
|||
| 有效完整结果且开关开启 | `OK` | 映射后的 0–100 分 | 按既有候选评分规则参与可计算维度平均 | |
|||
| 来源读取异常/结构异常 | `PENDING` | `null` | 不参与平均,记录脱敏内部告警 | |
|||
|
|||
候选综合分继续遵循现有规则:只对 `OK/ESTIMATED` 且分数非空的维度重新归一化计算。故 V38 启用后会对拥有有效能力结果的学生产生真实影响;这是功能开关默认关闭的原因。 |
|||
|
|||
### 5.3 版本 |
|||
|
|||
- 能力映射规则版本:`ability-assessment-scoring-v0.1`。 |
|||
- 开关开启后的候选评分规则版本:`candidate-scoring-v0.2`。 |
|||
- 开关关闭时保留现有 `candidate-scoring-v0.1`。 |
|||
- 每个写入的 `ABILITY` 快照必须记录两类版本,保证规则升级后可解释、可重算。 |
|||
|
|||
## 6. 快照、溯源与数据最小化 |
|||
|
|||
不新增表、不新增 Flyway。复用 `candidate_dimension_scores` 现有的唯一键 `(student_user_id, dimension, period)` 与 `sub_scores_json`。 |
|||
|
|||
### 6.1 持久化的最小来源字段 |
|||
|
|||
`ABILITY` 行的内部 `sub_scores_json` 仅允许下列字段: |
|||
|
|||
```json |
|||
{ |
|||
"source": "V36_ABILITY_ASSESSMENT", |
|||
"sourceResultId": 123, |
|||
"sourceCompletedAt": "2026-09-04T10:00:00Z", |
|||
"validUntil": "2026-09-11T10:00:00Z", |
|||
"questionSetVersion": "ability-v0.1", |
|||
"assessmentRulesVersion": "ability-v0.1", |
|||
"mappingRulesVersion": "ability-assessment-scoring-v0.1", |
|||
"weights": {"LOGIC": 35, "EXECUTION": 30, "LEARNING": 20, "COMMUNICATION": 15}, |
|||
"dimensionScores": {"LOGIC": 80.00, "EXECUTION": 75.00, "LEARNING": 70.00, "COMMUNICATION": 65.00} |
|||
} |
|||
``` |
|||
|
|||
禁止写入:答卷 ID、题目 ID、题目编码、题库标签、题干、选项、用户选择、答案键、选项分值、题目解析、手机号、邮箱、令牌、请求体和完整审计详情。 |
|||
|
|||
`sourceResultId` 仅作内部溯源,任何 HTTP 响应均不得返回该字段。 |
|||
|
|||
### 6.2 响应投影 |
|||
|
|||
- 学生综合卡:沿用既有 `ABILITY` 聚合分和状态。 |
|||
- 学生单维详情:可返回四维**聚合分**、权重、有效期、规则版本和说明,用于解释自己的分数;不得返回 `sourceResultId` 或题目级内容。 |
|||
- 企业候选评分卡:保持既有字段,只返回 `ABILITY` 的 `dimension/score/status`。 |
|||
- 趋势接口:只使用已写候选快照,不回读题目或 V36 原始答案。 |
|||
|
|||
## 7. 功能开关与上线策略 |
|||
|
|||
新增配置: |
|||
|
|||
```yaml |
|||
scoring: |
|||
ability-assessment: |
|||
enabled: false |
|||
``` |
|||
|
|||
| 模式 | 行为 | |
|||
|---|---| |
|||
| `false`(默认) | 不查询 V36 结果,`ABILITY=PENDING`;无评分行为变更 | |
|||
| `true` | 查询最新有效结果,按本设计映射;仅在既有评分计算/重算时写候选评分快照 | |
|||
|
|||
发布顺序: |
|||
|
|||
1. 合入代码且以 `false` 发布;验证所有既有候选评分卡仍是 V37 行为。 |
|||
2. 在验收/小范围环境设为 `true`,核验映射、脱敏、排名影响与回滚。 |
|||
3. 经负责人确认后才在正式环境启用;启用后仍可立即设回 `false`。 |
|||
|
|||
关闭开关只停止后续映射,不删除已存在的快照或 V36 数据。若需消除当前月份的已映射 `ABILITY`,必须走单独、经批准的重算流程,不能在开关切换时隐式删除数据。 |
|||
|
|||
## 8. 模块职责与实现边界 |
|||
|
|||
建议新增以下内部组件: |
|||
|
|||
- `AbilityAssessmentScoreSource`:一次查询并解析最新有效 V36 结果;不暴露实体给 Controller。 |
|||
- `AbilityScoreMapper`:纯函数,校验四维、计算权重分、构造最小化来源描述。 |
|||
- `CandidateScoringCalculator#computeAbility(...)`:替换现有硬编码 `PENDING`;开关关闭时保留旧分支。 |
|||
- `CandidateScoringService`:每次评分计算只调用一次 `AbilityAssessmentScoreSource`,不在八维循环内查询。 |
|||
|
|||
不得: |
|||
|
|||
- 改动 `AbilityAttemptService.submit()` 的事务边界、状态机、重测限制或审计。 |
|||
- 让评分模块更新 `ability_attempts`、`ability_answers`、`ability_results`。 |
|||
- 在 Controller 接受 `studentUserId`、能力分或结果 ID 来影响评分。 |
|||
- 新增企业端能力测评详情、管理员修改能力分或公开的“重算某学生能力分”接口。 |
|||
|
|||
## 9. 并发、一致性与性能 |
|||
|
|||
- 评分计算使用一次 `capturedNow` 和最多一次 V36 来源查询;不得按四维各查一次,也不得循环读取题目/答案。 |
|||
- 已有候选评分批量 upsert 继续处理同月并发刷新;V38 不新增新的评分写表。 |
|||
- V36 提交与评分查询并发时:评分要么读取提交前无结果并返回 `PENDING`,要么读取完整、已提交的结果并返回 `OK`;不能读取半成品。 |
|||
- 结果刚好到期时以 `capturedNow` 判定;该次计算结束后不回滚同次快照,下一次计算按新时间重新判定。 |
|||
- 利用既有 `ability_attempts(student_user_id, definition_id, completed_at)` 索引进行学生级最新结果查询;实现前必须用 MySQL `EXPLAIN` 验证不扫全表。若实际查询计划无法命中索引,停止编码并单独评审最小索引迁移,不得临时修改既有 V35 迁移。 |
|||
|
|||
## 10. 权限、隐私与审计 |
|||
|
|||
### 10.1 权限矩阵 |
|||
|
|||
| 数据 | STUDENT | COMPANY | ADMIN | |
|||
|---|---|---|---| |
|||
| 本人完整 V36 结果与题目反馈 | 仅本人 | 禁止 | 禁止(V36 原有口径) | |
|||
| 候选评分 `ABILITY` 聚合分 | 本人 | 仅有岗位归属的候选评分卡 | 禁止(沿用现有评分接口) | |
|||
| 四维聚合分与有效期 | 本人单维评分详情 | 禁止 | 禁止 | |
|||
| 题目/答案键/评分规则/标签 | 禁止通过评分接口返回 | 禁止 | 禁止 | |
|||
|
|||
### 10.2 审计 |
|||
|
|||
V38 不对普通评分查询新增逐次审计,避免读取评分卡时产生审计噪声。现有评分治理申诉/回滚审计保持原样。 |
|||
|
|||
如内部结构无效需记录诊断日志,日志白名单仅为 `studentUserId`(内部日志)、`sourceResultId`、`failureKind`、`mappingRulesVersion` 和 `requestId`;禁止记录测评内容。不得将该诊断写入对企业可见的响应或 `audit_logs.details`。 |
|||
|
|||
## 11. 评分治理与申诉兼容 |
|||
|
|||
- 既有 `ABILITY` 申诉/评分回滚若可用,回滚仍然是按当前数据确定性重算,不恢复人工指定的旧能力分。 |
|||
- V38 不新增人工修改、豁免、补分或上传证明材料的入口。 |
|||
- 来源结果在回滚时已过期或无效,则重算为 `PENDING`;这是规则结果,不属于回滚失败。 |
|||
- V36 测评本身的题目、答案或判分争议不通过候选评分申诉接口处理,后续需单独定义测评申诉治理。 |
|||
|
|||
## 12. 接口与 OpenAPI 影响 |
|||
|
|||
本版不新增、删除或修改公网路径;既有候选评分 API 的顶层契约保持兼容: |
|||
|
|||
```text |
|||
GET /profile/me/candidate-scoring |
|||
GET /profile/me/candidate-scoring/dimensions/ABILITY |
|||
GET /profile/me/candidate-scoring/trend |
|||
GET /company/candidates/{applicationId}/scoring |
|||
``` |
|||
|
|||
因此静态和运行时 OpenAPI 均应继续为 **148 paths / 182 operations**。 |
|||
|
|||
学生的 `ABILITY` 单维详情可在既有通用 `subScores/reasons/dataSources` 容器中增加白名单字段;实现前须确认这些对象的现有 schema 允许兼容增加字段。若需要新增可见字段,必须同步 `openapi-rc.json`、`verify_openapi.py` 与联调文档,但不得改变公司端 schema。 |
|||
|
|||
## 13. 错误码 |
|||
|
|||
V38 不新增公网接口,正常的“无有效测评结果”属于 `ABILITY=PENDING`,不是 HTTP 错误。因此不新增 ErrorCode。 |
|||
|
|||
任何来源 JSON 异常、查询异常或规则版本无法识别都必须被隔离为 `PENDING`,并保留 200 的既有评分响应。数据库不可用等系统级故障仍按全局异常处理器既有语义处理,不伪造评分结果。 |
|||
|
|||
## 14. 测试计划 |
|||
|
|||
### 14.1 单元测试 |
|||
|
|||
- 开关关闭:不查询 V36 来源,`ABILITY=PENDING`,候选评分规则版本仍为 v0.1。 |
|||
- 无结果、仅过期结果、结构不完整结果、分数越界结果:均为 `PENDING`,无 500。 |
|||
- 有效结果:严格验证权重公式、两位小数 `HALF_UP`、`OK` 状态、来源版本。 |
|||
- 多份有效历史:选 `completed_at` 最新者,不选总分最高者;同时间用 attempt ID 稳定决胜。 |
|||
- 来源字段白名单:持久化 JSON 和 HTTP 响应均不含答卷 ID、题目、答案、答案键、分值、标签。 |
|||
- `computeAbility` 只触发一次来源查询;不得产生 N+1。 |
|||
|
|||
### 14.2 集成与 MockMvc 测试 |
|||
|
|||
- H2 MySQL 模式下完整 V36 有效结果 → 候选评分写入 `ABILITY` 快照;过期后重算同周期 `ABILITY=PENDING`。 |
|||
- V36 提交本身不写 `candidate_dimension_scores`;只有既有候选评分查询/重算写快照。 |
|||
- 学生单维详情可见聚合四维和有效期;公司端只见 `ABILITY` 总分/状态;COMPANY/ADMIN/匿名权限回归。 |
|||
- 并发 V36 提交与候选评分读取不出现半结果、500 或重复快照异常。 |
|||
- V32 排名和 V34 职业进度在开关关闭时字节级/字段级保持现有结果;开关开启时仅允许因 `ABILITY` 可计算带来的预期分数变化。 |
|||
|
|||
### 14.3 MySQL/Docker 黑盒 |
|||
|
|||
1. 启动真实 MySQL/Redis/RabbitMQ 和最新 JAR;确认 health 200、Flyway 仍止于 V36、OpenAPI 148/182。 |
|||
2. 使用独立学生账号完成一份 V36 测评,先验证提交后 `candidate_dimension_scores` 行数不变。 |
|||
3. 开关 `false` 调用候选评分:`ABILITY=PENDING`;再在隔离环境将开关设为 `true`,调用评分:`ABILITY=OK` 且总分符合公式。 |
|||
4. 核对 `sub_scores_json` 白名单;企业 API 不含四维、结果 ID、题目、答案、标签或评分规则。 |
|||
5. 将结果有效期置为过去时间(只在独立验收数据上操作),重算后验证 `ABILITY=PENDING`;历史 V36 结果仍可由本人读取。 |
|||
6. 创建两份符合 V36 重测规则的历史结果,验证取最新而不是最高;验证并发评分刷新无 500。 |
|||
7. 队列最终 `ready=0/unacked=0`;回归 V32 排名、V34 职业进度、V35 RIASEC、V36 能力结果、V37 风险概览。 |
|||
|
|||
## 15. 回滚与冻结门槛 |
|||
|
|||
### 15.1 回滚 |
|||
|
|||
| 情况 | 操作 | |
|||
|---|---| |
|||
| 映射分数不符合预期 | 设 `scoring.ability-assessment.enabled=false`,停止后续接入 | |
|||
| 需要恢复当前月评分 | 经批准后调用既有评分重算流程;不直接删表或手改快照 | |
|||
| 发现题库/测评结果质量问题 | 保持 V36 历史,关闭 V38 开关;不篡改历史题目或答案 | |
|||
|
|||
V38 不新增迁移,故不存在本模块 DDL 回滚。不得修改已执行的 V35/V36 Flyway 文件。 |
|||
|
|||
### 15.2 冻结条件 |
|||
|
|||
- 所有 V38 单元、集成、MockMvc 测试通过;全量 `mvn clean verify` 通过。 |
|||
- `python docs/verify_openapi.py` 通过且 OpenAPI 仍为 148/182。 |
|||
- 真实 MySQL/Docker 黑盒为 `FAIL=0`,关键项不得 SKIP。 |
|||
- 开关关闭和开启两条路径均通过;V36 主链路、V32/V34/V35/V37 回归通过。 |
|||
- 不泄露题目、答案、答案键、评分规则、标签、来源结果/答卷 ID;企业端仅有聚合能力分。 |
|||
- 未对 V35/V36 题库或既有 Flyway 做回写;没有未授权的业务数据删除或全量回填。 |
|||
|
|||
--- |
|||
|
|||
## 16. 已确认的编码授权边界 |
|||
|
|||
负责人已于 2026-09-04 确认以下边界,编码必须严格遵守: |
|||
|
|||
1. 开关开启时,允许 `ABILITY` 参与候选综合分,并允许其按既有评分口径影响 V32 同岗位排名。 |
|||
2. 仅在开关开启时,候选评分规则版本允许由 `candidate-scoring-v0.1` 升级为 `candidate-scoring-v0.2`。 |
|||
3. 学生单维评分详情可展示四维聚合分、权重和有效期;企业端始终只见 `ABILITY` 聚合分与状态。 |
|||
4. 本版采用无新 Flyway、无新公网接口的只读投影方案。 |
|||
|
|||
后续若要新增人工改分、企业端明细、V35/RIASEC 接入、`GROWTH` 映射、历史回填或任何公开接口,必须另行设计和授权,不得作为 V38 编码的附带改动。 |
|||
@ -0,0 +1,77 @@ |
|||
package com.mydreamjob.candidatescore; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* V38:候选人 ABILITY 维度仅读投影源。 |
|||
* |
|||
* <p>一次查询取某学生最新、COMPLETED、且 {@code valid_until > capturedNow} 的 V36 |
|||
* {@code WORKPLACE_ABILITY} 完整结果。只关联 ability_results / ability_attempts / |
|||
* ability_definitions,绝不读取 ability_answers / ability_attempt_questions / 题目或答案键。 |
|||
* 排序固定为 {@code completed_at DESC, attempt_id DESC},最多一条;不比较分数(不取历史最高分)。</p> |
|||
* |
|||
* <p>不暴露实体给 Controller,仅作为 CandidateScoringCalculator 的只读数据源。</p> |
|||
*/ |
|||
@Component |
|||
@RequiredArgsConstructor |
|||
public class AbilityAssessmentScoreSource { |
|||
|
|||
private static final String SQL = """ |
|||
SELECT r.id AS resultId, |
|||
a.completed_at AS completedAt, |
|||
r.valid_until AS validUntil, |
|||
r.question_set_version AS questionSetVersion, |
|||
r.rules_version AS assessmentRulesVersion, |
|||
a.answered_count AS answeredCount, |
|||
r.dimension_scores_json AS dimensionScoresJson |
|||
FROM ability_results r |
|||
JOIN ability_attempts a ON a.id = r.attempt_id |
|||
JOIN ability_definitions d ON d.id = a.definition_id |
|||
WHERE a.student_user_id = ? |
|||
AND a.status = 'COMPLETED' |
|||
AND d.type_code = 'WORKPLACE_ABILITY' |
|||
AND r.valid_until > ? |
|||
ORDER BY a.completed_at DESC, a.id DESC |
|||
LIMIT 1 |
|||
"""; |
|||
|
|||
private final JdbcTemplate jdbcTemplate; |
|||
|
|||
/** |
|||
* 最新有效完整结果的只读投影;无匹配结果或参数为空时返回 {@code null}。 |
|||
* 单次调用只发起一次查询,禁止按四维循环查询(不存在 N+1)。 |
|||
*/ |
|||
public AbilityResultProjection findLatestValidCompletedResult(Long studentUserId, LocalDateTime capturedNow) { |
|||
if (studentUserId == null || capturedNow == null) { |
|||
return null; |
|||
} |
|||
List<AbilityResultProjection> rows = jdbcTemplate.query(SQL, |
|||
(rs, n) -> new AbilityResultProjection( |
|||
rs.getLong("resultId"), |
|||
rs.getTimestamp("completedAt") == null ? null : rs.getTimestamp("completedAt").toLocalDateTime(), |
|||
rs.getTimestamp("validUntil") == null ? null : rs.getTimestamp("validUntil").toLocalDateTime(), |
|||
rs.getString("questionSetVersion"), |
|||
rs.getString("assessmentRulesVersion"), |
|||
rs.getInt("answeredCount"), |
|||
rs.getString("dimensionScoresJson")), |
|||
studentUserId, capturedNow); |
|||
return rows.isEmpty() ? null : rows.get(0); |
|||
} |
|||
|
|||
/** |
|||
* 仅含评分所需最小字段的只读投影;不含题目、答案、答案键、选项分值或题库标签。 |
|||
*/ |
|||
public record AbilityResultProjection(Long resultId, |
|||
LocalDateTime completedAt, |
|||
LocalDateTime validUntil, |
|||
String questionSetVersion, |
|||
String assessmentRulesVersion, |
|||
Integer answeredCount, |
|||
String dimensionScoresJson) { |
|||
} |
|||
} |
|||
@ -0,0 +1,185 @@ |
|||
package com.mydreamjob.candidatescore; |
|||
|
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
/** |
|||
* V38:四维能力结果 → 候选人 ABILITY 维度的纯函数映射器(无数据库访问)。 |
|||
* |
|||
* <p>仅接受恰好包含 LOGIC / COMMUNICATION / EXECUTION / LEARNING 四维、每维错题数均为 6、 |
|||
* 总错题数为 24、且分数均在 [0,100](有限数值)的完整结果;任何结构异常都返回 |
|||
* {@link Optional#empty()},由调用方映射为 {@code PENDING},绝不抛给前端 500。</p> |
|||
* |
|||
* <p>公式(V38 固定业务权重,不复用 V36 等权 overallScore):<br> |
|||
* abilityScore = LOGIC×0.35 + EXECUTION×0.30 + LEARNING×0.20 + COMMUNICATION×0.15, |
|||
* 最终 scale=2、HALF_UP。</p> |
|||
*/ |
|||
@Component |
|||
public class AbilityScoreMapper { |
|||
|
|||
public static final String MAPPING_RULES_VERSION = "ability-assessment-scoring-v0.1"; |
|||
|
|||
public static final BigDecimal WEIGHT_LOGIC = new BigDecimal("0.35"); |
|||
public static final BigDecimal WEIGHT_EXECUTION = new BigDecimal("0.30"); |
|||
public static final BigDecimal WEIGHT_LEARNING = new BigDecimal("0.20"); |
|||
public static final BigDecimal WEIGHT_COMMUNICATION = new BigDecimal("0.15"); |
|||
|
|||
public static final int PER_DIMENSION_ANSWERED = 6; |
|||
public static final int TOTAL_ANSWERED = 24; |
|||
public static final BigDecimal MAX_SCORE = new BigDecimal("100"); |
|||
|
|||
private static final ObjectMapper MAPPER = new ObjectMapper(); |
|||
private static final TypeReference<List<LinkedHashMap<String, Object>>> ENTRIES_LIST = |
|||
new TypeReference<>() { |
|||
}; |
|||
private static final Set<String> EXPECTED_DIMENSIONS = |
|||
Set.of("LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"); |
|||
|
|||
/** |
|||
* 计算结果。{@code dimensionScores} 与 {@code weights} 均使用固定有序键, |
|||
* 便于调用方构造白名单 subScores 与最小溯源 JSON。 |
|||
*/ |
|||
public record MappedScores(BigDecimal abilityScore, |
|||
Map<String, BigDecimal> dimensionScores, |
|||
Map<String, Integer> weights) { |
|||
} |
|||
|
|||
/** |
|||
* 安全解析单个 V36 结果。结构异常一律返回空。 |
|||
* |
|||
* @param dimensionScoresJson ability_results.dimension_scores_json(数组:{code,score,answeredCount}) |
|||
* @param attemptAnsweredCount ability_attempts.answered_count(可为 null) |
|||
*/ |
|||
public Optional<MappedScores> map(String dimensionScoresJson, Integer attemptAnsweredCount) { |
|||
if (dimensionScoresJson == null || dimensionScoresJson.isBlank()) { |
|||
return Optional.empty(); |
|||
} |
|||
final List<LinkedHashMap<String, Object>> entries; |
|||
try { |
|||
entries = MAPPER.readValue(dimensionScoresJson, ENTRIES_LIST); |
|||
} catch (Exception e) { |
|||
return Optional.empty(); |
|||
} |
|||
if (entries == null || entries.size() != EXPECTED_DIMENSIONS.size()) { |
|||
return Optional.empty(); |
|||
} |
|||
|
|||
Map<String, BigDecimal> scores = new LinkedHashMap<>(); |
|||
Set<String> seen = new java.util.HashSet<>(); |
|||
int sumAnswered = 0; |
|||
for (LinkedHashMap<String, Object> e : entries) { |
|||
String code = e.get("code") instanceof String s ? s : null; |
|||
if (code == null || !EXPECTED_DIMENSIONS.contains(code) || !seen.add(code)) { |
|||
return Optional.empty(); // 缺维 / 重复维 / 意外维
|
|||
} |
|||
BigDecimal score = toFiniteScore(e.get("score")); |
|||
if (score == null) { |
|||
return Optional.empty(); |
|||
} |
|||
Integer answered = toExactInt(e.get("answeredCount")); |
|||
if (answered == null || answered != PER_DIMENSION_ANSWERED) { |
|||
return Optional.empty(); // 每维错题数必须精确等于 6(6.9/6.5/溢出/非法均不可用)
|
|||
} |
|||
scores.put(code, score); |
|||
sumAnswered += answered; |
|||
} |
|||
if (scores.size() != EXPECTED_DIMENSIONS.size() || sumAnswered != TOTAL_ANSWERED) { |
|||
return Optional.empty(); |
|||
} |
|||
if (attemptAnsweredCount == null || attemptAnsweredCount != TOTAL_ANSWERED) { |
|||
return Optional.empty(); // 答卷总错题数必须非空且恰为 24
|
|||
} |
|||
|
|||
Map<String, Integer> weights = new LinkedHashMap<>(); |
|||
weights.put("LOGIC", Integer.valueOf(35)); |
|||
weights.put("EXECUTION", Integer.valueOf(30)); |
|||
weights.put("LEARNING", Integer.valueOf(20)); |
|||
weights.put("COMMUNICATION", Integer.valueOf(15)); |
|||
|
|||
BigDecimal abilityScore = scores.get("LOGIC").multiply(WEIGHT_LOGIC) |
|||
.add(scores.get("EXECUTION").multiply(WEIGHT_EXECUTION)) |
|||
.add(scores.get("LEARNING").multiply(WEIGHT_LEARNING)) |
|||
.add(scores.get("COMMUNICATION").multiply(WEIGHT_COMMUNICATION)) |
|||
.setScale(2, RoundingMode.HALF_UP); |
|||
|
|||
Map<String, BigDecimal> ordered = new LinkedHashMap<>(); |
|||
ordered.put("LOGIC", scores.get("LOGIC")); |
|||
ordered.put("EXECUTION", scores.get("EXECUTION")); |
|||
ordered.put("LEARNING", scores.get("LEARNING")); |
|||
ordered.put("COMMUNICATION", scores.get("COMMUNICATION")); |
|||
|
|||
return Optional.of(new MappedScores(abilityScore, ordered, weights)); |
|||
} |
|||
|
|||
private BigDecimal toFiniteScore(Object v) { |
|||
BigDecimal b = toBigDecimal(v); |
|||
if (b == null) return null; |
|||
if (b.compareTo(BigDecimal.ZERO) < 0 || b.compareTo(MAX_SCORE) > 0) return null; |
|||
return b; |
|||
} |
|||
|
|||
private BigDecimal toBigDecimal(Object v) { |
|||
if (v == null) return null; |
|||
BigDecimal out; |
|||
if (v instanceof BigDecimal bd) { |
|||
out = bd; |
|||
} else if (v instanceof Number num) { |
|||
double d = num.doubleValue(); |
|||
if (Double.isNaN(d) || Double.isInfinite(d)) return null; |
|||
out = BigDecimal.valueOf(d); |
|||
} else { |
|||
String s = String.valueOf(v).trim(); |
|||
if (s.isEmpty()) return null; |
|||
try { |
|||
out = new BigDecimal(s); |
|||
} catch (NumberFormatException e) { |
|||
return null; |
|||
} |
|||
} |
|||
return out; |
|||
} |
|||
|
|||
/** |
|||
* 将 answeredCount 精确解析为整数:仅接受数值意义上等于整数的表示 |
|||
* (如 JSON 整数 6 或 6.0、"6"、"6.0")。6.9、6.5、"6.5"、溢出 int、空串、null |
|||
* 一律返回 null,由调用方映射为不可用。不使用 intValue() 截断,避免把小数误判为整数。 |
|||
*/ |
|||
private Integer toExactInt(Object v) { |
|||
if (v == null) return null; |
|||
try { |
|||
if (v instanceof BigDecimal bd) { |
|||
return exactIntOrNull(bd); |
|||
} else if (v instanceof Number num) { |
|||
double d = num.doubleValue(); |
|||
if (Double.isNaN(d) || Double.isInfinite(d)) return null; |
|||
return exactIntOrNull(BigDecimal.valueOf(d)); |
|||
} else if (v instanceof String s) { |
|||
String t = s.trim(); |
|||
if (t.isEmpty()) return null; |
|||
return exactIntOrNull(new BigDecimal(t)); |
|||
} |
|||
return null; |
|||
} catch (NumberFormatException e) { |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/** stripTrailingZeros 后能精确转 int 才返回,否则 null。 */ |
|||
private Integer exactIntOrNull(BigDecimal b) { |
|||
if (b == null) return null; |
|||
try { |
|||
return b.stripTrailingZeros().intValueExact(); |
|||
} catch (ArithmeticException e) { |
|||
return null; // 非精确整数(如 6.5、6.9)或溢出 int
|
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,87 @@ |
|||
package com.mydreamjob.candidatescore; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.mockito.junit.jupiter.MockitoSettings; |
|||
import org.mockito.quality.Strictness; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.jdbc.core.RowMapper; |
|||
|
|||
import java.sql.ResultSet; |
|||
import java.sql.Timestamp; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.*; |
|||
import static org.mockito.Mockito.*; |
|||
|
|||
/** |
|||
* V38:AbilityAssessmentScoreSource 单测。用 mock JdbcTemplate 断言单次查询契约与行映射, |
|||
* 并覆盖无结果/参数空返回 null。真实数据选择行为由 AbilityScoreIntegrationTest 覆盖。 |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
@MockitoSettings(strictness = Strictness.LENIENT) |
|||
class AbilityAssessmentScoreSourceTest { |
|||
|
|||
@Mock private JdbcTemplate jdbcTemplate; |
|||
@InjectMocks private AbilityAssessmentScoreSource source; |
|||
|
|||
private static final Long SID = 2L; |
|||
private static final LocalDateTime NOW = LocalDateTime.now(); |
|||
|
|||
@Test |
|||
void findLatest_singleQuery_noNplus1() { |
|||
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class))) |
|||
.thenReturn(List.of()); |
|||
source.findLatestValidCompletedResult(SID, NOW); |
|||
verify(jdbcTemplate, times(1)).query(anyString(), any(RowMapper.class), any(Object[].class)); |
|||
} |
|||
|
|||
@Test |
|||
void findLatest_mapsRow_toProjection() throws Exception { |
|||
ResultSet rs = mock(ResultSet.class); |
|||
when(rs.getLong("resultId")).thenReturn(123L); |
|||
when(rs.getTimestamp("completedAt")).thenReturn(Timestamp.valueOf(NOW.minusDays(1))); |
|||
when(rs.getTimestamp("validUntil")).thenReturn(Timestamp.valueOf(NOW.plusDays(6))); |
|||
when(rs.getString("questionSetVersion")).thenReturn("ability-v0.1"); |
|||
when(rs.getString("assessmentRulesVersion")).thenReturn("ability-v0.1"); |
|||
when(rs.getInt("answeredCount")).thenReturn(24); |
|||
when(rs.getString("dimensionScoresJson")).thenReturn("[]"); |
|||
|
|||
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class))) |
|||
.thenAnswer(inv -> { |
|||
RowMapper<?> rm = inv.getArgument(1); |
|||
Object mapped = rm.mapRow(rs, 0); |
|||
return java.util.List.of(mapped); |
|||
}); |
|||
|
|||
AbilityAssessmentScoreSource.AbilityResultProjection p = |
|||
source.findLatestValidCompletedResult(SID, NOW); |
|||
assertNotNull(p); |
|||
assertEquals(123L, p.resultId()); |
|||
assertEquals(NOW.minusDays(1), p.completedAt()); |
|||
assertEquals(NOW.plusDays(6), p.validUntil()); |
|||
assertEquals("ability-v0.1", p.questionSetVersion()); |
|||
assertEquals("ability-v0.1", p.assessmentRulesVersion()); |
|||
assertEquals(24, p.answeredCount()); |
|||
assertEquals("[]", p.dimensionScoresJson()); |
|||
} |
|||
|
|||
@Test |
|||
void findLatest_empty_noResult_null() { |
|||
when(jdbcTemplate.query(anyString(), any(RowMapper.class), any(Object[].class))) |
|||
.thenReturn(List.of()); |
|||
assertNull(source.findLatestValidCompletedResult(SID, NOW)); |
|||
} |
|||
|
|||
@Test |
|||
void findLatest_nullParams_returnsNull() { |
|||
assertNull(source.findLatestValidCompletedResult(null, NOW)); |
|||
assertNull(source.findLatestValidCompletedResult(SID, null)); |
|||
} |
|||
} |
|||
@ -0,0 +1,212 @@ |
|||
package com.mydreamjob.candidatescore; |
|||
|
|||
import com.mydreamjob.ability.AbilityAttempt; |
|||
import com.mydreamjob.ability.AbilityAttemptRepository; |
|||
import com.mydreamjob.ability.AbilityDefinition; |
|||
import com.mydreamjob.ability.AbilityDefinitionRepository; |
|||
import com.mydreamjob.ability.AbilityResult; |
|||
import com.mydreamjob.ability.AbilityResultRepository; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.dto.CandidateScoringOverviewResponse; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* V38:四维能力结果接入候选评分——真实 H2 集成测试。 |
|||
* <p>覆盖来源选择(最新有效 COMPLETED/WORKPLACE_ABILITY/过期排除)、V36 submit 不写快照、 |
|||
* 评分触发才写 ABILITY 快照、结果过期后 PENDING。企业脱敏与版本切换由 EngineService 单测覆盖。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class AbilityScoreIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE = new AtomicLong(7100); |
|||
|
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private AbilityDefinitionRepository definitionRepository; |
|||
@Autowired private AbilityAttemptRepository attemptRepository; |
|||
@Autowired private AbilityResultRepository resultRepository; |
|||
@Autowired private AbilityAssessmentScoreSource scoreSource; |
|||
@Autowired private CandidateScoringEngineService engineService; |
|||
@Autowired private CandidateDimensionScoreRepository dimensionScoreRepository; |
|||
@Autowired private CandidateScoringCalculator calculator; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private final List<Long> createdUsers = new ArrayList<>(); |
|||
private Long defId; |
|||
|
|||
private static final String DIMS_OK = |
|||
"[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":65,\"answeredCount\":6}]"; |
|||
// 历史高分(早提交)+ 更新低分(晚提交)用于验证“取最新而非最高”
|
|||
private static final String DIMS_HIGH = |
|||
"[{\"code\":\"LOGIC\",\"score\":100,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":100,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":100,\"answeredCount\":6}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":100,\"answeredCount\":6}]"; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
// WORKPLACE_ABILITY 由既有 Flyway 迁移种子化;复用而非新建,避免唯一键冲突
|
|||
AbilityDefinition def = definitionRepository.findAll().stream() |
|||
.filter(d -> "WORKPLACE_ABILITY".equals(d.getTypeCode())) |
|||
.findFirst().orElseThrow(() -> new IllegalStateException("WORKPLACE_ABILITY must be seeded")); |
|||
defId = def.getId(); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
for (Long id : createdUsers) { |
|||
userRepository.deleteById(id); |
|||
} |
|||
createdUsers.clear(); |
|||
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", false); |
|||
} |
|||
|
|||
private Long createUser() { |
|||
User u = userRepository.save(User.builder() |
|||
.phone("19" + String.format("%08d", PHONE.incrementAndGet())).nickname("u") |
|||
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build()); |
|||
createdUsers.add(u.getId()); |
|||
return u.getId(); |
|||
} |
|||
|
|||
private Long attempt(Long studentId, String status, LocalDateTime completedAt, |
|||
int answeredCount) { |
|||
AbilityAttempt a = attemptRepository.save(AbilityAttempt.builder() |
|||
.studentUserId(studentId).definitionId(defId).status(status) |
|||
.answeredCount(answeredCount).questionSetVersion("ability-v0.1") |
|||
.completedAt(completedAt) |
|||
.build()); |
|||
return a.getId(); |
|||
} |
|||
|
|||
private Long result(Long attemptId, String dimsJson, LocalDateTime validUntil) { |
|||
AbilityResult r = resultRepository.save(AbilityResult.builder() |
|||
.attemptId(attemptId).dimensionScoresJson(dimsJson) |
|||
.overallScore(new BigDecimal("70.00")) |
|||
.questionSetVersion("ability-v0.1").rulesVersion("ability-v0.1") |
|||
.validUntil(validUntil).generatedAt(LocalDateTime.now()) |
|||
.build()); |
|||
return r.getId(); |
|||
} |
|||
|
|||
@Test |
|||
void source_picksLatestValidCompleted_ignoringHistoricalHighScore() { |
|||
Long u = createUser(); |
|||
LocalDateTime now = LocalDateTime.now(); |
|||
// 较早提交的高分(100)+ 更晚提交的有效低分(74.25)
|
|||
Long earlyResultId = result(attempt(u, "COMPLETED", now.minusDays(2), 24), |
|||
DIMS_HIGH, now.plusDays(5)); |
|||
Long lateResultId = result(attempt(u, "COMPLETED", now.minusDays(1), 24), |
|||
DIMS_OK, now.plusDays(5)); |
|||
|
|||
AbilityAssessmentScoreSource.AbilityResultProjection p = |
|||
scoreSource.findLatestValidCompletedResult(u, now); |
|||
assertNotNull(p); |
|||
assertEquals(lateResultId, p.resultId(), "must pick latest completed, not historical highest"); |
|||
assertEquals(now.minusDays(1).truncatedTo(java.time.temporal.ChronoUnit.SECONDS), |
|||
p.completedAt().truncatedTo(java.time.temporal.ChronoUnit.SECONDS)); |
|||
assertEquals(DIMS_OK, p.dimensionScoresJson()); |
|||
} |
|||
|
|||
@Test |
|||
void source_excludesExpiredOrNonCompletedOrOtherType() { |
|||
Long u = createUser(); |
|||
LocalDateTime now = LocalDateTime.now(); |
|||
// 过期结果不被选
|
|||
Long expiredId = attempt(u, "COMPLETED", now.minusDays(1), 24); |
|||
result(expiredId, DIMS_OK, now.minusDays(1)); |
|||
// DRAFT/IN_PROGRESS 不被选
|
|||
attempt(u, "DRAFT", null, 0); |
|||
attempt(u, "IN_PROGRESS", null, 12); |
|||
// 其他类型定义(使用未被种子占用的自定义类型码)
|
|||
AbilityDefinition other = definitionRepository.save(AbilityDefinition.builder() |
|||
.typeCode("FIXTURE_OTHER_99").displayName("其他").status("ACTIVE") |
|||
.questionCount(3).attemptQuestionCount(3).questionsPerDimension(1) |
|||
.timeLimitMinutes(10).resultValidDays(7).retakeWindowDays(90).retakeMaxCompleted(3) |
|||
.questionSetVersion("v0.1").rulesVersion("v0.1").build()); |
|||
Long otherAttemptId = attemptRepository.save(AbilityAttempt.builder() |
|||
.studentUserId(u).definitionId(other.getId()).status("COMPLETED") |
|||
.answeredCount(3).questionSetVersion("v0.1").completedAt(now).build()).getId(); |
|||
result(otherAttemptId, "[{\"code\":\"X\",\"score\":100,\"answeredCount\":3}]", now.plusDays(7)); |
|||
|
|||
assertNull(scoreSource.findLatestValidCompletedResult(u, now)); |
|||
} |
|||
|
|||
@Test |
|||
void source_validUntilEqualsCapturedNow_notSelected() { |
|||
Long u = createUser(); |
|||
// 统一截断到秒,保证 valid_until 与传入的 capturedNow 严格相等(H2/MySQL 时间精度差异无关)
|
|||
LocalDateTime now = LocalDateTime.now().withNano(0); |
|||
Long aid = attempt(u, "COMPLETED", now.minusDays(1), 24); |
|||
result(aid, DIMS_OK, now); |
|||
assertNull(scoreSource.findLatestValidCompletedResult(u, now), |
|||
"valid_until == capturedNow must not be selected"); |
|||
} |
|||
|
|||
@Test |
|||
void source_validUntilAfterCapturedNow_selected() { |
|||
Long u = createUser(); |
|||
LocalDateTime now = LocalDateTime.now(); |
|||
Long aid = attempt(u, "COMPLETED", now.minusDays(1), 24); |
|||
Long rid = result(aid, DIMS_OK, now.plusMinutes(1)); |
|||
AbilityAssessmentScoreSource.AbilityResultProjection p = |
|||
scoreSource.findLatestValidCompletedResult(u, now); |
|||
assertNotNull(p, "valid_until > capturedNow must be selected"); |
|||
assertEquals(rid, p.resultId()); |
|||
} |
|||
|
|||
@Test |
|||
void expiredResult_recomputeBecomesPending() { |
|||
Long u = createUser(); |
|||
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", true); |
|||
LocalDateTime now = LocalDateTime.now(); |
|||
Long aid = attempt(u, "COMPLETED", now.minusDays(1), 24); |
|||
result(aid, DIMS_OK, now.plusDays(6)); |
|||
|
|||
// 有效期内在评分后写入 OK 快照(getOverview 内部 @Transactional 触发既有 upsert)
|
|||
CandidateScoringOverviewResponse r1 = engineService.getOverview(u); |
|||
assertEquals("OK", dimensionStatus(r1, "ABILITY")); |
|||
|
|||
// 强制过期后重算 → PENDING
|
|||
resultRepository.findByAttemptId(aid).ifPresent(rr -> { |
|||
rr.setValidUntil(now.minusDays(1)); |
|||
resultRepository.save(rr); |
|||
}); |
|||
CandidateScoringOverviewResponse r2 = engineService.getOverview(u); |
|||
assertEquals("PENDING", dimensionStatus(r2, "ABILITY")); |
|||
} |
|||
|
|||
private String dimensionStatus(CandidateScoringOverviewResponse resp, String dim) { |
|||
return resp.getDimensions().stream() |
|||
.filter(d -> dim.equals(d.getDimension())) |
|||
.findFirst().map(CandidateScoringOverviewResponse.DimensionItem::getStatus) |
|||
.orElseThrow(); |
|||
} |
|||
} |
|||
@ -0,0 +1,181 @@ |
|||
package com.mydreamjob.candidatescore; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.util.Optional; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
/** |
|||
* V38:AbilityScoreMapper 纯函数测试。 |
|||
* <p>覆盖固定权重公式与 HALF_UP、结构防御(缺维/重复维/越界/错题数非6/总数非24/非法JSON), |
|||
* 以及不依赖答案键或题目级数据。</p> |
|||
*/ |
|||
class AbilityScoreMapperTest { |
|||
|
|||
private final AbilityScoreMapper mapper = new AbilityScoreMapper(); |
|||
|
|||
private String dims(int l, int c, int e, int r, int perDimAnswered) { |
|||
return dimsWithAnswered(l, c, e, r, String.valueOf(perDimAnswered)); |
|||
} |
|||
|
|||
private String dimsWithAnswered(int l, int c, int e, int r, String perDimAnswered) { |
|||
return "[{\"code\":\"LOGIC\",\"score\":" + l + ",\"answeredCount\":" + perDimAnswered + "}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":" + c + ",\"answeredCount\":" + perDimAnswered + "}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":" + e + ",\"answeredCount\":" + perDimAnswered + "}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":" + r + ",\"answeredCount\":" + perDimAnswered + "}]"; |
|||
} |
|||
|
|||
@Test |
|||
void validDimensions_weightedFormulaHALFUP() { |
|||
// 80*0.35 + 75*0.30 + 70*0.20 + 65*0.15 = 74.25
|
|||
Optional<AbilityScoreMapper.MappedScores> m = mapper.map(dims(80, 65, 75, 70, 6), 24); |
|||
assertTrue(m.isPresent()); |
|||
assertEquals(new BigDecimal("74.25"), m.get().abilityScore()); |
|||
assertEquals(2, m.get().abilityScore().scale()); |
|||
} |
|||
|
|||
@Test |
|||
void validDimensions_halfUpRounding_scale2() { |
|||
// 三十三点三三 每维:33.33*0.35 + 33.33*0.30 + 33.33*0.20 + 33.33*0.15 = 33.33
|
|||
String json = "[{\"code\":\"LOGIC\",\"score\":33.33,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":33.33,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":33.33,\"answeredCount\":6}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":33.33,\"answeredCount\":6}]"; |
|||
Optional<AbilityScoreMapper.MappedScores> m = mapper.map(json, 24); |
|||
assertTrue(m.isPresent()); |
|||
assertEquals(2, m.get().abilityScore().scale()); |
|||
assertEquals(new BigDecimal("33.33"), m.get().abilityScore()); |
|||
} |
|||
|
|||
@Test |
|||
void validDimensions_halfUp_boundaryRoundUp() { |
|||
// LOGIC=66.67, 其他=0 => 66.67*0.35 = 23.3345 -> HALF_UP scale2 = 23.33
|
|||
String json = "[{\"code\":\"LOGIC\",\"score\":66.67,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":0,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":0,\"answeredCount\":6}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":0,\"answeredCount\":6}]"; |
|||
Optional<AbilityScoreMapper.MappedScores> m = mapper.map(json, 24); |
|||
assertTrue(m.isPresent()); |
|||
assertEquals(new BigDecimal("23.33"), m.get().abilityScore()); |
|||
} |
|||
|
|||
@Test |
|||
void missingDimension_unavailable() { |
|||
String json = "[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}]"; |
|||
assertTrue(mapper.map(json, 18).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void duplicateDimension_unavailable() { |
|||
String json = "[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}]"; |
|||
assertTrue(mapper.map(json, 24).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void unexpectedDimension_unavailable() { |
|||
String json = "[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXTRA\",\"score\":80,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}]"; |
|||
assertTrue(mapper.map(json, 24).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void outOfRangeScore_unavailable() { |
|||
assertTrue(mapper.map(dims(110, 65, 75, 70, 6), 24).isEmpty()); |
|||
assertTrue(mapper.map(dims(-1, 65, 75, 70, 6), 24).isEmpty()); |
|||
assertTrue(mapper.map(dims(80, 65, 75, 101, 6), 24).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void nonFiniteScore_unavailable() { |
|||
String json = "[{\"code\":\"LOGIC\",\"score\":NaN,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":65,\"answeredCount\":6}]"; |
|||
assertTrue(mapper.map(json, 24).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void answeredCountNot6_unavailable() { |
|||
String json = "[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":5}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":65,\"answeredCount\":6}]"; |
|||
assertTrue(mapper.map(json, 23).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void totalAnsweredNot24_unavailable() { |
|||
// 每维 6 => 总数 24 满足;但 attempt 上 answered_count=23 视为不完整
|
|||
assertTrue(mapper.map(dims(80, 65, 75, 70, 6), 23).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void attemptAnsweredNull_returnsEmpty() { |
|||
// V38 P1:attemptAnsweredCount 必须非空且严格等于 24
|
|||
assertTrue(mapper.map(dims(80, 65, 75, 70, 6), null).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void attemptAnsweredNot24_returnsEmpty() { |
|||
assertTrue(mapper.map(dims(80, 65, 75, 70, 6), 23).isEmpty()); |
|||
assertTrue(mapper.map(dims(80, 65, 75, 70, 6), 25).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void perDimAnsweredDecimalNotExact_returnsEmpty() { |
|||
// 6.9 会被 JSON 解析为浮点数;不得因 intValue 截断成 6
|
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "6.9"), 24).isEmpty()); |
|||
// 6.5 同理不可用
|
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "6.5"), 24).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void perDimAnsweredStringDecimal_returnsEmpty() { |
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"6.5\""), 24).isEmpty()); |
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"6.9\""), 24).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void perDimAnsweredOverflowOrBlank_returnsEmpty() { |
|||
// 2147483648 超出 int 精确取值范围(intValueExact 抛 ArithmeticException → null)
|
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "2147483648"), 24).isEmpty()); |
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"\""), 24).isEmpty()); |
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "null"), 24).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void perDimAnsweredExactSixAsDecimalOrString_ok() { |
|||
// 数值意义等于 6 的整数表示:JSON 数字 6.0 与字符串 "6" / "6.0" 均接受
|
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "6.0"), 24).isPresent()); |
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"6\""), 24).isPresent()); |
|||
assertTrue(mapper.map(dimsWithAnswered(80, 65, 75, 70, "\"6.0\""), 24).isPresent()); |
|||
} |
|||
|
|||
@Test |
|||
void illegalJson_unavailable() { |
|||
assertTrue(mapper.map("not json{{{", 24).isEmpty()); |
|||
assertTrue(mapper.map(null, 24).isEmpty()); |
|||
assertTrue(mapper.map("", 24).isEmpty()); |
|||
assertTrue(mapper.map("[]", 24).isEmpty()); |
|||
} |
|||
|
|||
@Test |
|||
void stringScoredValues_parsed() { |
|||
String json = "[{\"code\":\"LOGIC\",\"score\":\"80\",\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":\"75\",\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":\"70\",\"answeredCount\":6}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":\"65\",\"answeredCount\":6}]"; |
|||
Optional<AbilityScoreMapper.MappedScores> m = mapper.map(json, 24); |
|||
assertTrue(m.isPresent()); |
|||
assertEquals(new BigDecimal("74.25"), m.get().abilityScore()); |
|||
} |
|||
} |
|||
@ -0,0 +1,183 @@ |
|||
package com.mydreamjob.candidatescore; |
|||
|
|||
import com.mydreamjob.ability.AbilityAttempt; |
|||
import com.mydreamjob.ability.AbilityAttemptRepository; |
|||
import com.mydreamjob.ability.AbilityDefinition; |
|||
import com.mydreamjob.ability.AbilityDefinitionRepository; |
|||
import com.mydreamjob.ability.AbilityResult; |
|||
import com.mydreamjob.ability.AbilityResultRepository; |
|||
import com.mydreamjob.application.Application; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.competition.CompetitionRankingCalculator; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.time.LocalDate; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* V38 → V32 排名回归(真实快照写路径 + 真实 CompetitionRankingCalculator)。 |
|||
* |
|||
* <p>不伪造 ABILITY 快照:开关开启且存在最新有效 V36 结果时,先经 |
|||
* {@link CandidateScoringEngineService#getOverview} 的真实候选评分写快照路径, |
|||
* 使 ABILITY 以既定权重公式计入综合分,再交排名组件计算 overallScore/rank/percentile。 |
|||
* 开关关闭时 ABILITY 保持 PENDING,综合分与既有口径一致(无可计算维度 → 本人无分)。</p> |
|||
* |
|||
* <p>避免写 student_profiles 等易引跨类 FK 污染的关联表:基线用无资料、无活跃数据的 |
|||
* 学生,OFF 时无可计算维度;peer 的快照在内存构造(仅作排名对照,非被测对象)。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@ActiveProfiles("integration-test") |
|||
class V38AbilityRankingIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE = new AtomicLong(8200); |
|||
|
|||
private static final String DIMS_OK = |
|||
"[{\"code\":\"LOGIC\",\"score\":80,\"answeredCount\":6}," |
|||
+ "{\"code\":\"EXECUTION\",\"score\":75,\"answeredCount\":6}," |
|||
+ "{\"code\":\"LEARNING\",\"score\":70,\"answeredCount\":6}," |
|||
+ "{\"code\":\"COMMUNICATION\",\"score\":65,\"answeredCount\":6}]"; |
|||
|
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private AbilityDefinitionRepository definitionRepository; |
|||
@Autowired private AbilityAttemptRepository attemptRepository; |
|||
@Autowired private AbilityResultRepository resultRepository; |
|||
@Autowired private CandidateScoringEngineService engineService; |
|||
@Autowired private CandidateScoringCalculator calculator; |
|||
@Autowired private CompetitionRankingCalculator rankingCalculator; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
private final List<Long> createdUsers = new ArrayList<>(); |
|||
private Long defId; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
when(redisTemplate.opsForValue()).thenReturn(mock(org.springframework.data.redis.core.ValueOperations.class)); |
|||
when(redisTemplate.hasKey(any())).thenReturn(false); |
|||
AbilityDefinition def = definitionRepository.findAll().stream() |
|||
.filter(d -> "WORKPLACE_ABILITY".equals(d.getTypeCode())) |
|||
.findFirst().orElseThrow(() -> new IllegalStateException("WORKPLACE_ABILITY must be seeded")); |
|||
defId = def.getId(); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
for (Long id : createdUsers) { |
|||
userRepository.deleteById(id); |
|||
} |
|||
createdUsers.clear(); |
|||
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", false); |
|||
} |
|||
|
|||
private Long createUser() { |
|||
User u = userRepository.save(User.builder() |
|||
.phone("20" + String.format("%08d", PHONE.incrementAndGet())).nickname("u") |
|||
.role(User.Role.STUDENT).roles(Set.of(User.Role.STUDENT)).active(true).verified(true).build()); |
|||
createdUsers.add(u.getId()); |
|||
return u.getId(); |
|||
} |
|||
|
|||
private void giveValidAbilityResult(Long userId) { |
|||
LocalDateTime now = LocalDateTime.now(); |
|||
AbilityAttempt a = attemptRepository.save(AbilityAttempt.builder() |
|||
.studentUserId(userId).definitionId(defId).status("COMPLETED").answeredCount(24) |
|||
.questionSetVersion("ability-v0.1").completedAt(now.minusDays(1)).build()); |
|||
resultRepository.save(AbilityResult.builder() |
|||
.attemptId(a.getId()).dimensionScoresJson(DIMS_OK).overallScore(new BigDecimal("70.00")) |
|||
.questionSetVersion("ability-v0.1").rulesVersion("ability-v0.1") |
|||
.validUntil(now.plusDays(6)).generatedAt(now).build()); |
|||
} |
|||
|
|||
private String currentMonth() { |
|||
return String.format("%04d-%02d", LocalDate.now().getYear(), LocalDate.now().getMonthValue()); |
|||
} |
|||
|
|||
private Application app(Long studentId, LocalDateTime createdAt) { |
|||
return Application.builder().jobId(99L).studentUserId(studentId) |
|||
.status("SUBMITTED").createdAt(createdAt).build(); |
|||
} |
|||
|
|||
private CandidateDimensionScore educationSnap(Long studentId, String score) { |
|||
return CandidateDimensionScore.builder().studentUserId(studentId) |
|||
.dimension("EDUCATION").score(new BigDecimal(score)).status("OK") |
|||
.period(currentMonth()).rulesVersion("candidate-scoring-v0.1") |
|||
.weight(new BigDecimal("12.50")).build(); |
|||
} |
|||
|
|||
private void setEnabled(boolean b) { |
|||
ReflectionTestUtils.setField(calculator, "abilityAssessmentEnabled", b); |
|||
} |
|||
|
|||
/** |
|||
* me 为无资料/无活跃数据学生;peer 由内存快照给出 EDUCATION=70(仅作对照组)。 |
|||
* 开关关闭:me 无可计算维度 → overall=null、rank=null、percentile=null(ABILITY=PENDING)。 |
|||
* 开关开启且 me 有最新有效 V36 结果:经真实评分写快照路径 ABILITY=OK 74.25, |
|||
* me overall=74.25 → rank1 / percentile100。排名只因 ABILITY 按既定公式变化。 |
|||
*/ |
|||
@Test |
|||
void ranking_pendingVsAbilityOk_effectsOnlyByFormula() { |
|||
Long me = createUser(); |
|||
Long peer = createUser(); |
|||
String month = currentMonth(); |
|||
LocalDateTime tMe = LocalDateTime.now().minusHours(2); |
|||
LocalDateTime tPeer = LocalDateTime.now().minusHours(1); |
|||
List<Application> cohort = List.of(app(me, tMe), app(peer, tPeer)); |
|||
Map<Long, List<CandidateDimensionScore>> peerOnly = |
|||
Map.of(peer, List.of(educationSnap(peer, "70.00"))); |
|||
|
|||
// 关闭:ABILITY=PENDING,me 无可计算维度
|
|||
setEnabled(false); |
|||
engineService.getOverview(me); |
|||
Map<Long, List<CandidateDimensionScore>> offSnaps = |
|||
new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month)); |
|||
offSnaps.putAll(peerOnly); |
|||
CompetitionRankingCalculator.CohortRanking off = |
|||
rankingCalculator.rankCohort(cohort, offSnaps, me); |
|||
assertNull(off.overallScore, "OFF: no computable dimension → overall null"); |
|||
assertNull(off.rank, "OFF: unscored → rank null"); |
|||
assertNull(off.percentile, "OFF: unscored → percentile null"); |
|||
assertEquals("PENDING", off.meSnaps.stream() |
|||
.filter(s -> "ABILITY".equals(s.getDimension())).findFirst().get().getStatus()); |
|||
|
|||
// 开启:真实评分写快照 → ABILITY=OK 74.25 计入综合分
|
|||
setEnabled(true); |
|||
giveValidAbilityResult(me); |
|||
engineService.getOverview(me); |
|||
Map<Long, List<CandidateDimensionScore>> onSnaps = |
|||
new HashMap<>(rankingCalculator.loadLatestSnapshots(List.of(me), month)); |
|||
onSnaps.putAll(peerOnly); |
|||
CompetitionRankingCalculator.CohortRanking on = |
|||
rankingCalculator.rankCohort(cohort, onSnaps, me); |
|||
|
|||
assertEquals(new BigDecimal("74.25"), on.overallScore, "ON: (76.75→nothing else) ABILITY alone=74.25"); |
|||
assertEquals(1, on.rank, "me(74.25) > peer(70) → rank1"); |
|||
assertEquals(new BigDecimal("100.00"), on.percentile, "strictly-less(peer)=1 over scored-1 → 100"); |
|||
assertEquals("OK", on.meSnaps.stream() |
|||
.filter(s -> "ABILITY".equals(s.getDimension())).findFirst().get().getStatus()); |
|||
|
|||
assertNotEquals(on.overallScore, off.overallScore, "ABILITY 计入后综合分必须按既定公式变化"); |
|||
assertNotEquals(on.rank, off.rank); |
|||
assertNotEquals(on.percentile, off.percentile); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue