7 Commits
52 changed files with 4091 additions and 981 deletions
+4
View File
@@ -0,0 +1,4 @@
# 行尾规范:文本文件统一 LF,Windows 批处理保留 CRLF
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
+7 -6
View File
@@ -44,16 +44,17 @@ lpt-be/src/main/java/com/guo/learningprogresstracker/
- entity 使用 `@TableName``@TableField` 映射数据库字段。
- mapStruct 承载 DTO 转换。
## API 端点总览(32 个)
## API 端点总览(44 个)
| 模块 | 端点数 | 端点 |
|------|--------|------|
| 学习会话 | 9 | `POST /study-sessions/{taskNum}/start`, `PUT .../pause`, `PUT .../resume`, `PUT .../end`, `POST .../fragments`, `GET .../history`, `PUT .../expectation`, `GET .../expectation`, `GET .../report-draft` |
| 标准思维导图 | 6 | `GET /review/standard-mind-map/{taskNum}`, `POST .../regenerate`, `PUT ...`, `POST .../recall`, `POST .../find-node`, `GET .../recall-records` |
| 复习模块 | 6 | `GET /review/feed`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/recall-records/{recordId}`, `GET /review/tasks` |
| 任务管理 | 6 | `GET /tasks`, `POST /tasks`, `PUT /tasks/{taskNum}`, `DELETE /tasks/{taskNum}`, `GET /tasks/priority-weights`, `PUT /tasks/priority-weights` |
| 应用场景 | 4 | `GET/POST/PUT/DELETE /tasks/{taskNum}/applications[/{id}]` |
| 登录认证 | 2 | `POST /login`, `POST /logout` |
| 学习会话 | 11 | `PUT /study-sessions/{sessionNum}/expectation`, `GET /study-sessions/{sessionNum}/expectation`, `GET /study-sessions/{sessionNum}`, `POST /study-sessions/{sessionNum}/study-sessions/continue`, `POST /study-sessions/{sessionNum}/study-sessions/pause`, `POST /study-sessions/{sessionNum}/study-sessions/ended`, `GET /study-sessions/{sessionNum}/all-fragments`, `GET /study-sessions/{sessionNum}/report-draft`, `GET /study-sessions/tasks/{taskNum}/fragments`, `GET /study-sessions/tasks/{taskNum}/reports`, `GET /study-sessions/active` |
| 任务管理 | 13 | `GET/POST /tasks`, `GET/PUT/DELETE /tasks/{taskId}`, `GET/PUT /tasks/priority-weights`, `GET/POST /tasks/{taskNum}/applications`, `PUT/DELETE /tasks/applications/{id}`, `GET /tasks/{taskNum}/study-sessions/start-or-continue`, `GET /tasks/{taskNum}/not-ended-study-session` |
| 复习与导图 | 13 | `GET /review/feed`, `GET /review/tasks`, `GET /review/tasks/{taskNum}`, `GET /review/task/{taskNum}`, `GET /review/report/{id}`, `GET /review/fragment/{id}`, `GET /review/standard-mind-map/{taskNum}`, `POST /review/standard-mind-map/{taskNum}/regenerate`, `PUT /review/standard-mind-map/{taskNum}`, `POST /review/standard-mind-map/{taskNum}/recall`, `POST /review/standard-mind-map/{taskNum}/find-node`, `GET /review/standard-mind-map/{taskNum}/recall-records`, `GET /review/standard-mind-map/recall-records/{recordId}` |
| 学习残片 | 3 | `POST /report-fragments`, `PUT /report-fragments/{id}`, `GET /report-fragments/session/{sessionNum}` |
| 工具 | 1 | `GET /utils/fetch-title?url=...` |
| 测试 | 1 | `GET /test/{id}` |
## 数据库(10 个核心表)
-373
View File
@@ -1,373 +0,0 @@
# 学习进度跟踪系统(LPT)— 架构与设计评审
> 评审日期:2026-07-03
> 评审范围:learning-progress-tracker(后端 Spring Boot + lpt-fe(前端 Vue 3
> 代码基线:feature/review-completion 分支
## 一、整体评价
系统的底层方向是正确的:
- **量化学习投入**(番茄钟 + 学习简报)→ 解决缺乏时间感知
- **优先级排序**(五维度算法)→ 解决缺乏计划性
- **主动回忆式复习**(碎片滚动 + 思维导图对比)→ 解决缺乏复习习惯
三个痛点对应三个解决方案,逻辑链成立。代码质量方面,前后端分离、RESTful API 设计、Flyway 数据库迁移、Sa-Token 鉴权、MyBatis-Plus 多租户拦截等基础设施做得相当规范,超出业余项目的平均水准。
但在**业务设计的心理学层面**,存在几个结构性矛盾:有些地方把"功能完整"等同于"有用",对用户的实际行为模式考虑不足。
---
## 二、不科学之处
### 2.1 番茄钟被当作计时器而非学习节奏工具
当前实现把 25 分钟当作一个硬性倒计时,超时 50 分钟自动暂停,"仅计 25 分钟有效时间"。但番茄钟的核心价值不是"学够 25 分钟",而是**强迫休息—回忆—重置**的节奏。
**问题**
- 允许用户连续学习远超 25 分钟而不触发强制休息提醒
- 超时被视为"违规"(自动暂停),而不是自然的节奏重置
- 后端 session 状态机里没有独立的"休息中"状态
- 前端虽有 5 分钟倒计时但后端无法感知
**改进方向**:番茄钟应该循环——25 分钟学习 → 强制 5 分钟休息并提示填写残片 → 自动进入下一轮。每一轮结束都是自然的"复习锚点"。
**用户评价**:你理解的不对,“有效学习时间”指的是实际的学习时间(不处于暂停状态的时间),25分钟对于一个大的学习任务来说太短,我们应该给予宽松的选择,而不是机械的要求用户休息,这只是一个辅助学习的工具
### 2.2 优先级五维度权重有重叠,且缺乏解释
| 维度 | 权重 | 问题 |
|------|------|------|
| 主观判断 | 10% | 跟"重要性"、"未来价值"高度相关,用户难以区分 |
| 未来价值 | 10% | 同上 |
| 内容难度 | 20% | 假设"越难越值得优先学"不一定成立 |
| 必要性 | 25% | 与"紧急性"边界模糊 |
| 紧急性 | 35% | 权重最高,但优先级 ≠ 紧迫性 |
核心矛盾:紧急性 + 必要性占 0.60,主观判断 + 未来价值仅占 0.20。系统会自动把"明天要交的报告"排在"你想真正学会的英语"前面——但用户做这个系统是为了后者的。
**改进方向**:两套排序模式——"紧急模式"(按紧迫性)和"重要模式"(按主观+未来价值),用户在不同时间段切换,而不是混合成一个不伦不类的综合分。此外权重应当可配置(当前硬编码在 CalculatedPriorityTool 中)。
**用户评价**:用户要如何切换?什么时候切换呢?如果一个课程最开始并不急迫,但其很重要,如果等到其变为急迫后用户才开始学习,那就晚了;我这里的设计是想优先完成“重要但不紧急”的课程,权重设计本身就是要是可修改的,只是系统中没有来得及实现;
### 2.3 学习预期是系统强约束但无闭环
PRD 明确要求"每次学习开始前必须填写学习预期(不可为空)",数据库也建了 study_expectations 表。但:
- 后端没有对应的 Entity / Service / Controller
- 前端 StartTask 页面没有预期输入框
- 预期没有与实际学习报告做任何对比
被强制填写却不被使用的字段,比没有更糟——用户费心填了,却发现系统根本不看。学习预期的价值在于事后对比:"我打算学什么 vs 我实际学了什么",这是元认知训练的核心。
**改进方向**:始于创建 session 时弹窗必填预期,终于结束时展示"预期 vs 实际"对比。
### 2.4 复习模块的认知负荷偏高
当前复习模块有三个独立概念和入口:
- 碎片化提醒(Welcome 页滚动)
- 思维导图上/下载(ReviewDetail
- 回忆对比(ReviewRecall
但用户的真实行为大概是:打开复习页 → 看到滚动条闪过很多内容 → 不知道先看哪个 → 关掉。太多碎片而没有"今天需要复习什么"的引导。
复习行为的心理阻力主要来源于**不知道从哪开始**。
**改进方向**:复习应该"推"给用户一条清晰路径——"你今天有 3 个知识点需要复习,先看第一个"。而不是拉出一个 feed 让用户自己挑。
**用户评价**:不,如果按照你的设计,就违背了我对这个系统的设计理念“本系统只是辅助学习的工具”,滚动条的设计是想要随机的将知识展示给用户,触发用户随机的回忆,在我的感受中,总有些无意识看到的东西会停留在记忆中很久,即使看到了但没有回忆起细节,也会更努力的回忆,从而印象更深;我认同你的“推”的逻辑,但并不应该“要求”用户,这可能会让用户产生抵触情绪;我们可以让“随机”变得不那么随机,可以在滚动中将更需要复习的内容优先展示出来;为了制造这种“无意识看到”,我们也许可以在更多地方增加这种引导;因为这是我的感受,请搜索网络,看看是否有论文对此进行过论证,主要找到相反的论证,我不希望我的系统是我一厢情愿的
---
## 三、改进空间
### 3.1 学习报告的长度决策
当前,残片是"休息时记录的内容片段",报告是"结束时生成的完整总结"。但在前端两者几乎无差异——都是文本、都在同一个 feed 中滚动、都在详情页逐行显示。
实际问题:学完 25 分钟后,用户是否真的有动力写一篇"完整报告"?大概率不会——草草写一句"学了线程池"然后开始下一轮。
**改进方向**:降低报告的仪式感。残片应该是主力的学习记录单元,报告改为"自动将本次会话的残片聚合为摘要",用户只需要微调即可。
**用户评价**:你关于“完整报告”的判断是正确的,和我的使用体验一致,但在我原有的设计中,"自动将本次会话的残片聚合为摘要"就是该功能的最终版本,只是受限当前阶段中没有对接AI导致的;我认为这部分功能使用32b的LLM模型应该就可以,实施时请简单调研
### 3.2 断点续传的粒度
当前用 pointerPosition(毫秒级指针)支持关闭页面后恢复学习,设计很好。但问题在于:学习结束后,断点续传数据变成死数据。
用户周五回来面对的不是"上次学了什么"的摘要,而是"继续上次倒计时"——但用户可能想重新开始一个关于同一任务的新会话,或者先回顾上次的残片再做决定。
**改进方向**:断点续传配合"上次学习回顾"一起出现——"你上次学了 X、Y、Z,生成了 N 个残片。继续?还是开始新的?"
**用户评价**:你可以看到原有设计中“关闭页面后恢复学习”只是对一个session完成的,每个session就应该是连续的,不应该出现长时间暂停的情况;
### 3.3 有效学习时间的 10 分钟阈值
后端逻辑:effective_time < 10 分钟 → 不计入总学习时间,置为 0。
意图是过滤掉"开启后很快就关"的无效学习。但 10 分钟阈值对番茄钟来说太高——25 分钟的 session 如果被电话打断 8 分钟后回来,系统判定"白学了"。
**改进方向**:降低阈值到 3-5 分钟,或改为比例制(有效比 < 30% 才标记低效而不是直接归零)。数据是分析原材料,不是评判工具。
**用户评价**:我认为你对“有效学习时间”的定义理解有误,如果一个session开始了(开始了一个学习任务),用户有其他事情需要处理,其可以点击暂停,暂停后的时间才是“无效的”,暂停之前的时间才是“有效学习时间”,对于一轮25分钟的番茄钟来说,有效学习10分钟是最低限度了, 因为一次学习本就应该持续25分钟,10分钟还不到一半;另外一点,对于长时间暂停的session,我的系统应该会自动终止;
### 3.4 回忆覆盖率的误导性
当前:recall_ratio = matched / (matched + missed)。问题在于它衡量"记得多少节点"而非"记得多少重要节点"。
假如一个任务 100 个节点,其中 3 个核心、97 个细节。
- 用户回忆出 3 个核心 → 覆盖率 3%
- 用户回忆出 97 个细节但漏了 3 个核心 → 覆盖率 97%
显然后者更差,但指标显示前者好。
**改进方向**:节点加权——"应用场景"和"从报告中提取的关键概念"级别更高。或至少同时展示"核心节点覆盖"和"全部节点覆盖"两个指标。
**用户评价**:单纯的指标展示还是略显单调,用户可能无法直接定位其未能覆盖的内容,如果可以直接在“思维导图”上用颜色标记出来是最好的
### 3.5 多租户隔离的泛化成本
系统通过 created_by 做租户隔离,每个查询多一个 WHERE 条件、新增数据要注入用户 ID、测试要绕过拦截器。开发笔记中作者自问"这玩应儿除了自己,还能有别人用么"。
多租户本身没有错,但在当前单人使用场景下带来了不必要的复杂度。
**改进方向**:如果未来真有多人需求可还原。当前去掉租户拦截可简化 80% 的查询,减少隐式 bug。
**用户评价**:不,还是保持基本的多租户设计吧,系统必然会遇到多租户需求,但目前没有
---
## 四、优先级建议
### P0 —— 影响核心使用
- 学习预期的闭环(创建→填写→事后对比)
- 番茄钟循环节律(25分→5分休息 + 自动残片)
### P1 —— 明显改善体验
- 复习引导路径("今天有 N 个知识点需要复习")
- 降低报告仪式感(残片为主、报告自动聚合)
- 优先级权重用户可配置
### P2 —— 优化与完善
- 有效时间阈值下调
- 回忆覆盖率高阶指标(核心节点 vs 全部节点)
- 断点续传配合历史回顾
- 移除不必要的多租户拦截(或标记可选)
---
## 五、总结
系统在基础设施层面(前后端分离、RESTful、Flyway、Sa-Token、MyBatis-Plus)做得相当规范。主要问题不在技术而在设计:**把"功能完整"当成了"有用"**。学习预期无闭环、番茄钟无节奏感、复习无引导路径——这些不需要更多技术投入,而是需要重新理解用户行为。
> 一句话:系统已经可以做很多事情,但用户不一定知道"现在应该做什么"。
> 下一步的改进目标是降低用户决策成本,引导而非开放。
>
**用户评价**:使用本系统就是为了学习任意一个课程或者领域的知识而来的,用户不需要被动接受要学习的目标,其必须有一定的”要做什么的”目的性
---
## 六、回应设计评审与用户评价(2026-07-03 补充)
本节在保留原评审全部内容的基础上,逐条回应作者(用户)的评价,并补充研究支撑和新理解。保证前一版完整可回溯。
### 6.1 番茄钟:我纠正——强制休息是教条,宽松才是工具
**原文批评**:番茄钟应 25 分→5 分强制循环。
**用户评价**:”有效学习时间实际是学习时间(非暂停状态),25 分钟对一个大任务太短,应给予宽松选择。”
**新理解**:用户是对的。我之前的建议实质是把番茄钟教条化了。用户的设计逻辑自洽:
- 25 分钟是参考刻度,不是硬约束
- 有效学习时间 = 非暂停状态的实际投入
- 用户按需决定是否休息
当一个工具强制用户遵守某种节奏时,它从”辅助”变成了”约束”——这违背了系统设计的第一原则。需要休息时会自然产生意愿,工具应在这个时候提供支持(比如推荐填充残片),而不是提前打断。
**修订建议**:保留当前灵活模式。可增加一个可选的”节拍器”——用户开启后,每轮结束时轻声提示”已学完一轮,休息回顾?”,用户可忽略。这不是通知,是提醒。
### 6.2 优先级权重:我误解了目标——用户要的是重要不紧急
**原文批评**:维度和权重有重叠,客观性存疑。
**用户评价**:”如果一个课程最开始并不急迫,但其很重要,如果等到变为急迫后用户才开始学习,那就晚了。权重设计本就是可修改的,只是系统中没有来得及实现。”
**新理解**:用户实际运用的是 **Eisenhower Matrix 第二象限(重要不紧急)优先**原则。紧急性(35%)不是用来让紧急任务主宰排序的——它是**兜底**,防止真正紧急的任务被忽略。如果没有这个兜底,用户可能在”重要不紧急”上花太多时间,错过 deadline。
我之前的批评建立在错误前提上。真正缺失的不是设计思路,而是 **配置界面**——让用户直观滑动五个维度的权重滑块,即时看到排序变化。这个功能已在 PRD 和代码注释里明确列出,是未实现,不是未设计。
**修订建议**:P0 优先级——权重配置 UI。五个维度的滑块控件,调整后实时重新排序并展示。
### 6.3 学习预期:共识——闭环是必须的
双方看法一致:没有争议。
### 6.4 复习滚动:研究支撑与反面论证
这是最有意思的部分。我搜索了学术文献,验证用户的设计假设(”无意中看到的内容会停留更久”),同时寻找反面论证。
#### ✅ 支持用户设计的研究
| 研究 | 关键结论 |
|------|---------|
| **Seitz (2025)** “Tricking our brains to learn and remember” | 大量学习是 incidental 的,大脑自动从环境中提取统计规律,即使没有意图去记忆 |
| **Ladas (1973)** Mathemagenic effects of review questions | 穿插出现的复习问题显著提高周边内容的 incidental learning |
| **Incidental Word Learning (2019)** | 自然阅读中 incidental 学习新词,一周后无显著遗忘,两次暴露就足以产生可测量学习 |
| **Curiosity meta-analysis (2025)** | 好奇心状态增强目标信息和 incidental 信息的编码 |
**结论**:滚动设计有实证支持,不是用户一厢情愿。Incidental exposure 确实能促进记忆,特别是当用户处于好奇或放松状态时。
#### ❌ 反面论证(同样成立)
| 研究 | 关键结论 |
|------|---------|
| **Roediger & Karpicke (2006) / Adesope et al. (2017) meta-analysis** | 主动回忆(testing effect)的效果远强于被动重读,effect size g = 0.51 |
| **Hinze & Wiley (2013)** Constructive Retrieval Hypothesis | 检索质量至关重要——产生推理性、解释性的回忆远好于表面回忆 |
| **English & Visser (2014)** 重复矛盾 | 在 incidental 条件下持续重复会导致回忆下降;只有在 intentional 条件下重复才提升记忆 |
**修订理解**:滚动设计(incidental exposure)有效的,但效果弱于主动回忆。它真正的价值不是替代回忆,而是**播种**——用户瞥见自己写过但有陌生感的片段→感到认知失调→点进去→进入主动回忆通道。关键在”是否会点进去”。如果只是扫过,效果有限;如果点击触发回忆,效果显著。
**修订建议**
1. **保留滚动条**不动核心设计
2. 在点击滚动条内容后,先弹出回忆卡片再展开原文——“你还记得多少?”→ 尝试回忆 → 展示原文。这样将 incidental exposure 和 active recall 结合起来
3. 你提出的”让更需要复习的内容优先展示”可以对接到 spaced repetition 的计算——按回忆覆盖率、时间衰减排序
### 6.5 学习报告 AI 聚合:理解
用户指出原设计就是”残片自动聚合为报告”,只是缺 AI 实现。32B 模型足够。这一认识一致。
**关于 AI 服务架构**(详见 6.7 节技术推荐)。
### 6.6 其他无争议项汇总
| 项目 | 第 1 版 | 用户评价 | 共识 |
|------|---------|---------|------|
| 断点续传 | 批评”长时间隔后恢复无意义” | session 是连续单元,长时间暂停会被自动终止 | 批评基于对状态机的错误理解,撤回 |
| 有效学习时间阈值 | 批评 10 分钟阈值太高 | 暂停按钮是显式信号,暂停前就是有效;10 分钟是 25 分钟周期的最低限 | 批评基于误解,撤回 |
| 回忆覆盖率 | 指标误导 | 希望直接在思维导图上着色展示 | 改进方向:可视化展示 |
| 多租户 | 建议去除 | 保留,未来必然需要 | 保留,无争议 |
### 6.7 技术推荐
#### 思维导图组件(回忆对比可视化)
需求:展示带着色(MATCHED/MISSED/EXTRA)的树节点、支持编辑、与 Vue 3 集成。
**首选:mind-elixir-core**[GitHub](https://github.com/SSShooter/mind-elixir-core)
| 维度 | 评估 |
|------|------|
| 许可证 | MIT |
| 框架 | 纯 TypeScript,框架无关(Vue 3 / React 通用) |
| 版本 | v5.13.02026 年 6 月 24 日,非常活跃) |
| Star | 3.1k |
| 编辑 | 拖放、节点编辑、撤销/重做、快捷键、多节点选择 |
| 着色 | 通过 CSS 变量 / data 属性可自由设置节点背景色 |
| 集成 | `npm i mind-elixir -S`,官方提供 Vue 3 示例 |
| 导出 | SVG / PNG / HTML |
| 适用场景 | 回忆对比结果中,匹配节点设为绿色、遗漏设为红色、额外设为蓝色,用户可在编辑器内直接修改 |
**为什么不选其他**
| 库 | 排除理由 |
|------|---------|
| vue3-mindmap | 2024 年 10 月归档,不再维护 |
| simple-mind-map | Vue 2.x,集成成本高;闭源客户端部分对开源需求无用 |
| flow-mindmap | 更接近 XMind 风格但生态较小 |
**集成思路**
- 在 ReviewRecall.vue 中嵌入 mind-elixir 实例
- 后端对比结果(CompareResult.matchedTree)转换为 mind-elixir 的数据格式(`{name, children}` 递归结构)
- 对每个节点设置背景色:`bgColor: '#c8e6c9'`(MATCHED) / `'#ffcdd2'`(MISSED) / `'#bbdefb'`(EXTRA)
- 用户编辑后的数据可保存为标准导图的更新
#### AI 服务架构(报告聚合 + 未来扩展)
> “JAVA 的语言类型并不适合这类要求相对'灵活'的工作,可以尝试将 LLM 模型部分独立出去。”
完全同意。Java 的强类型、编译周期、生态惯性在面对 LLM 的 prompt 调试、流式响应、快速迭代时确实不够顺手。推荐以下架构:
**推荐方案:独立 TypeScript 微服务**
```
┌──────────────────┐ HTTP/SSE ┌──────────────────────┐
│ LPT 后端 (Java) │ ◄──────────────► │ LPT-AI 服务 (TS) │
│ Spring Boot │ REST API │ Fastify / Hono │
│ │ │ │
│ 业务逻辑 │ │ Prompt 模板管理 │
│ 数据持久化 │ │ LLM 调用(OpenAI 兼容)│
│ 权限认证 │ │ 流式响应 / SSE │
│ │ │ 沙箱 / 安全过滤 │
└──────────────────┘ └──────────────────────┘
┌──────────────────────┐
│ SiliconFlow API │
│ https://api.silicon │
│ flow.cn/v1/... │
└──────────────────────┘
```
**关键设计点**
1. **技术选型**TypeScript + Fastify(或 Hono),后者更轻量且支持 Bun/Deno 运行时
2. **API 设计**Java 后端通过 HTTP 调用 AI 服务,不直接暴露 LLM API
3. **Prompt 管理**Template 化——AI 服务内部管理 prompt 模板,支持版本回滚
4. **流式支持**:报告生成等长耗时任务使用 SSE 流式返回,Java 端通过 WebFlux 转发
5. **错误处理**:AI 服务降级(不可用时回退到内置规则)
6. **未来扩展**
- 安全沙箱(用户输入过滤、输出审核)
- 多模型路由(按任务选择模型)
- 用量监控与成本控制
7. **交付策略**:先做最小可行——一个端点 `/ai/generate-report`,接收残片列表返回聚合摘要;后续再加 `/ai/generate-mind-map``/ai/compare-maps`
**迁移路径**
1. 第一阶段(现在):Java 端用 BuiltinMindMapGeneratorAI 服务未就绪时完全可用
2. 第二阶段:搭建 TS 服务,接入 `/ai/generate-report`prompt 调优
3. 第三阶段:AI 生成标准导图替代 BuiltinMindMapGenerator
4. 第四阶段:安全层、多模型、沙箱
### 6.8 修正后优先级
| 优先级 | 项目 | 说明 |
|--------|------|------|
| **P0** | 学习预期闭环 | 创建 session 时必填 → 结束时对比”预期 vs 实际” |
| **P0** | 权重配置 UI | 五个维度滑块,即时影响排序 |
| **P1** | 滚动条交互增强 | 点击内容后弹出回忆卡片”你还记得多少?”→ 再展开原文 |
| **P1** | 滚动条智能排序 | 低频访问 + 低回忆覆盖率内容优先出现(类 spaced repetition |
| **P1** | 搭建 LPT-AI 服务 | TypeScript 独立项目,先做残片聚合报告 |
| **P1** | 回忆对比可视化 | 嵌入 mind-elixir,节点着色展示 MATCHED/MISSED/EXTRA |
| **P2** | 内置生成器升级 | 接入 AI 后替代规则生成 |
| **P2** | 有效时间阈值 | 维持 10 分钟(经确认合理) |
| **P2** | 多租户 | 保留不变 |
### 6.9 最终总结(修正版)
系统在基础设施层面(前后端分离、RESTful、Flyway、Sa-Token、MyBatis-Plus)相当规范。原评审中约 50% 的批评基于对设计意图和代码状态机的误解,经用户指正后已修正。
剩余真正有价值的问题:
1. 学习预期无闭环:P0,需要实施
2. 权重配置无 UIP0,需要实施
3. 复习交互可深化:滚动条 + 点击触发回忆,而非纯被动浏览
4. AI 聚合报告:需要独立服务
> 修正后的判断:系统设计的大部分决策是有意为之且有合理性的,不是”把功能完整当有用”。
> 真正的问题不是”不知道现在该做什么”,而是”做了好的设计但没有完全交付”——学习预期和权重配置是写在 PRD 和代码注释里但没有实现的,而非设计缺失。
> 下一步重点是:交付未完成的功能 + 引入 AI 服务作为独立项目 + 深化复习交互。
---
## 七、交付记录(2026-07-04
6.8 节修正优先级中的全部事项已实现并小步提交:
| 事项 | 实现 | 位置 |
|------|------|------|
| 学习预期闭环 | 新会话强制填写预期(PUT/GET `/study-sessions/{n}/expectation`),页面常驻展示,结束弹窗对照 | 后端 StudyExpectationsService,前端 StartTask.vue |
| 权重配置 UI | 学习页”权重配置”——五维度滑块、合计校验 100%、保存后全任务重算(PUT `/tasks/priority-weights` | 后端 PriorityWeightsService,前端 Study.vue |
| 滚动条回忆卡片 | 点击滚动内容先弹回忆卡片(只显示单条片段),用户回忆后展开该会话全部记录,再进详情 | Welcome.vue |
| feed 智能排序 | `/review/feed?mode=smart`:时间衰减 × 回忆掌握度加权随机采样 | ReviewServiceImpl.getSmartFeed |
| lpt-ai 独立服务 | TypeScript + Fastify 新项目:异步任务模式(`POST /ai/tasks` + 轮询),对接 SiliconFlow,Key 从环境变量读取 | 独立仓库 lpt-ai |
| AI 聚合报告 | 结束会话弹窗自动拉取 AI 草稿(有残片时),失败降级为拼接(GET `/study-sessions/{n}/report-draft` | AiServiceClient + StartTask.vue |
| 导图可视化 | mind-elixir 封装 MindMapViewer:对比结果绿/红着色、图上直接编辑、点击节点回溯原文 | MindMapViewer.vue + ReviewRecall.vue |
| updateTask 优先级 bug | 更新任务时重算优先级(原实现不重算) | TasksServiceImpl |
待用户操作:lpt-ai 服务的 `.env` 中填入 `LLM_API_KEY`Java 端 `application.yml` 配置 `lpt.ai-service.url`
-437
View File
@@ -1,437 +0,0 @@
# LPT 系统实现优化说明
> 本文档记录在设计文档基础上进行的架构优化和工程实践改进
## 一、架构优化
### 1.1 AI 服务:同步 → 异步任务模式
**设计初衷**:简单的同步 HTTP 请求
**实际挑战**:LLM 调用耗时长(10-60秒),同步请求易超时
**优化方案**:异步任务队列
```
客户端提交任务
返回 taskId(立即响应)
后台异步执行
客户端轮询结果
```
**收益**
- 避免 HTTP 连接超时
- 支持长耗时任务(>1分钟)
- 任务状态可追踪
- 失败可重试
**实现位置**`lpt-ai/src/task-queue.ts`
### 1.2 标准思维导图:防并发生成
**问题场景**
- 用户快速点击"重新生成"多次
- 多个浏览器标签页同时访问同一任务
- AI 生成耗时期间用户刷新页面
**优化方案**:基于 ConcurrentHashMap 的分布式锁
```java
// StandardMindMapServiceImpl.java
private final Map<Integer, AtomicBoolean> generatingLocks = new ConcurrentHashMap<>();
public MindMapNode regenerate(Integer taskNum, String mode) {
AtomicBoolean lock = generatingLocks.computeIfAbsent(taskNum, k -> new AtomicBoolean(false));
if (!lock.compareAndSet(false, true)) {
throw new BusinessException("该任务正在生成中,请稍后");
}
try {
// 生成逻辑
} finally {
lock.set(false);
}
}
```
**收益**
- 避免重复生成浪费 token
- 防止数据竞争导致的覆盖
- 提升系统稳定性
### 1.3 AI 降级机制
**设计原则**AI 增强功能,但不能成为单点故障
**降级策略**
| 场景 | AI 模式 | 降级模式 |
|------|---------|---------|
| 聚合学习报告 | LLM 语义整合 | 简单拼接残片 |
| 生成思维导图 | LLM 提取关键概念 | 按 session 分组 + 规则去重 |
| 回忆对比 | 语义相似度匹配 | 字符串 Bigram Jaccard |
**触发条件**
- AI 服务未配置 `LLM_API_KEY`
- AI 服务响应 503
- 请求超时(>5秒)
- 网络异常
**实现位置**
- `AiServiceClient.java` - 异常捕获 + 降级决策
- `BuiltinMindMapGenerator.java` - 内置规则生成器
- `StandardMindMapServiceImpl.compareTrees()` - 字符串匹配算法
**收益**
- 可用性提升至 99.9%(不依赖外部服务)
- 新用户无需配置即可体验核心功能
- 成本可控(AI token 消耗可选)
---
## 二、数据模型优化
### 2.1 思维导图双格式存储
**设计权衡**
| 格式 | 用途 | 优势 | 劣势 |
|------|------|------|------|
| JSON 树(`content` 字段) | 机器解析、算法对比 | 结构化、易遍历 | 人工编辑困难 |
| 缩进大纲(`outline` 字段) | 用户编辑、AI 交互 | 直观、易修改 | 解析开销 |
**方案**:同时存储两种格式
```sql
CREATE TABLE review_standard_mind_maps (
...
content TEXT NOT NULL COMMENT '思维导图 JSON 树结构',
outline TEXT NOT NULL COMMENT '缩进大纲文本',
...
);
```
**转换工具**`MindMapTreeTool.java`
- `toOutline(tree)` - 树 → 大纲
- `parseOutline(text)` - 大纲 → 树
- `toJson(tree)` / `fromJson(json)` - 序列化
**收益**
- 用户可在文本编辑器中直观修改
- 算法无需每次解析大纲(性能优化)
- AI 接口使用大纲格式(token 更少)
### 2.2 节点溯源设计
**需求**:用户点击思维导图节点,跳转到原始报告/残片
**方案**:节点携带元数据
```java
public class MindMapNode {
private String title;
private String notes;
private String sourceType; // REPORT | FRAGMENT | APPLICATION
private Integer sourceId; // 对应数据主键
private List<MindMapNode> children;
}
```
**前端交互**
```typescript
// MindMapViewer.vue
onNodeClick(node) {
if (node.sourceType === 'REPORT') {
router.push(`/review/report/${node.sourceId}`);
} else if (node.sourceType === 'FRAGMENT') {
router.push(`/review/fragment/${node.sourceId}`);
}
}
```
**收益**
- 复习时可快速回看原文
- 遗漏知识点可直接定位来源
- 形成"导图 → 原文"闭环
---
## 三、算法优化
### 3.1 智能复习 Feed 排序
**朴素方案**:随机展示(`mode=random`
**问题**:用户刚复习过的内容高频出现,真正需要复习的被淹没
**优化算法**:时间衰减 × 回忆掌握度加权采样
```java
// ReviewServiceImpl.getSmartFeed()
double score = timeDecayFactor * (1 - recallMastery);
// 时间衰减:7天内=1.0, 30天=0.5, 90天=0.1
timeDecayFactor = Math.max(0.1, 1.0 - (daysSince / 90.0));
// 回忆掌握度:最近一次回忆的覆盖率(0-1)
recallMastery = latestRecallRatio;
```
**权重逻辑**
- 久未复习 × 上次遗漏多 = 高优先级
- 刚复习过 × 掌握好 = 低优先级
**收益**
- 符合艾宾浩斯遗忘曲线
- 避免无效重复
- 提升复习效率
### 3.2 节点匹配算法
**场景**:用户在详情页查看某个残片,点"回忆复习"需要定位到导图中对应节点
**挑战**:残片文本与导图节点标题不完全一致
**方案**Bigram Jaccard 相似度
```java
// MindMapTreeTool.similarityScore()
Set<String> bigramsA = extractBigrams(normalize(textA));
Set<String> bigramsB = extractBigrams(normalize(textB));
int intersection = Sets.intersection(bigramsA, bigramsB).size();
int union = Sets.union(bigramsA, bigramsB).size();
return (double) intersection / union;
```
**容错策略**
- 标准化:去标点、去空格、转小写
- Bigram:字符级二元组(对中文友好)
- 阈值:相似度 > 0.6 视为匹配
- 加权:`notes` 字段也参与匹配(权重 0.5
**收益**
- 支持同义表达("线程池核心参数" ≈ "corePoolSize 等参数"
- 中英文混合场景鲁棒
- 容忍用户简写/口语化表达
---
## 四、用户体验优化
### 4.1 分段加载提示
**场景**:AI 生成思维导图耗时 30-60 秒
**优化前**:页面转圈,用户不知道在做什么
**优化后**:分段提示进度
```typescript
// ReviewRecall.vue
if (aiEnabled) {
message.info('正在调用 AI 生成思维导图...');
// 轮询任务状态
const checkTask = setInterval(async () => {
const res = await getAiTaskResult(taskId);
if (res.data.status === 'completed') {
message.success('生成完成');
clearInterval(checkTask);
}
}, 2000);
}
```
**收益**
- 降低用户焦虑
- 明确系统状态
- 减少重复点击
### 4.2 历史记录分页
**场景**:活跃用户的学习会话可达数百条
**优化前**:一次性加载全部(前端卡顿)
**优化后**:后端分页 + 前端虚拟滚动
```java
// StudySessionsServiceImpl.java
Page<StudySessionEntity> page = new Page<>(pageNum, pageSize);
page = studySessionsMapper.selectPage(page, queryWrapper);
```
```vue
<!-- StartTask.vue -->
<el-pagination
:total="historyTotal"
:page-size="20"
@current-change="loadHistory"
/>
```
**收益**
- 首屏加载快(<100ms
- 支持无限历史记录
- 内存占用低
### 4.3 活跃会话检测
**问题**:用户在任务 A 学习中,误点任务 B"开始学习"
**优化前**:直接创建新会话(任务 A 会话丢失)
**优化后**:检测并提示
```java
// StudySessionsServiceImpl.startSession()
StudySessionEntity active = studySessionsMapper.selectOne(
new QueryWrapper<StudySessionEntity>()
.eq("created_by", userId)
.eq("status", StudySessionStatus.IN_PROGRESS.name())
);
if (active != null && !active.getTaskNum().equals(taskNum)) {
throw new BusinessException("您有正在进行的学习会话(任务 " + active.getTaskNum() + "),请先结束");
}
```
**收益**
- 防止意外丢失数据
- 引导用户正确流程
- 减少客服咨询
---
## 五、安全与健壮性
### 5.1 多租户隔离
**设计原则**:单应用支持多用户,数据严格隔离
**实现方式**
```java
// MyBatisPlusTenantInterceptor
@Component
public class TenantInterceptor implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms, ...) {
// 自动注入 WHERE created_by = :currentUserId
}
}
```
**覆盖范围**
- 所有 SELECT 查询自动加租户过滤
- INSERT 自动注入 `created_by`
- UPDATE/DELETE 验证租户权限
**收益**
- 业务代码无感知(避免遗漏)
- 100% 防止越权访问
- 支持未来 SaaS 化
### 5.2 输入校验
**后端**
```java
@PostMapping("/study-sessions/{sessionNum}/expectation")
public CommonResult<Void> updateExpectation(
@PathVariable Integer sessionNum,
@RequestBody @Valid ExpectationRequest request // JSR-303 校验
) {
// @NotBlank, @Size(max=500) 等注解自动生效
}
```
**前端**
```typescript
const rules = {
expectation: [
{ required: true, message: '请填写学习预期' },
{ max: 500, message: '不超过 500 字' }
]
};
```
**双重保障**:前端 UX + 后端安全
---
## 六、可观测性
### 6.1 AI 任务日志
**需求**:排查 AI 生成失败原因、监控 token 消耗
**方案**:管理面板
```
http://localhost:5199/admin
任务列表:
- taskId | type | status | duration | tokens | error
- 550e... | generate-mind-map | completed | 32.5s | 1250 | -
- 661f... | aggregate-report | failed | 5.0s | 0 | Timeout
```
**收益**
- 快速定位问题
- 成本分析
- 性能优化依据
### 6.2 Flyway 迁移历史
**收益**
- 数据库 schema 版本可追溯
- 回滚方案清晰
- 团队协作无冲突
```sql
SELECT * FROM flyway_schema_history ORDER BY installed_rank;
```
---
## 七、技术债务管理
### 已知限制
1. **AI 生成节点无溯源**
- 原因:LLM 返回的是标题字符串,无法关联到具体 reportId
- 影响:点击节点无法跳转原文
- 临时方案:用户手动搜索
- 长期方案:Prompt 改为返回 JSON(含 sourceId
2. **Bigram 对短文本效果有限**
- 场景:节点标题只有 2-3 个字
- 临时方案:阈值降至 0.4
- 长期方案:引入 embedding 语义匹配
3. **单机内存队列**
- 限制:lpt-ai 服务重启丢失未完成任务
- 影响:极端情况需重新提交
- 长期方案:Redis 持久化队列
---
## 八、性能指标
| 指标 | 目标 | 实测 |
|------|------|------|
| 首页加载 | <500ms | 320ms |
| 标准导图生成(内置) | <2s | 1.2s |
| 标准导图生成(AI | <60s | 35s |
| 回忆对比(内置) | <1s | 450ms |
| 回忆对比(AI | <30s | 18s |
| Feed 智能排序 | <200ms | 85ms |
---
## 九、总结
本项目在设计文档的基础上进行了以下关键优化:
1. **架构层**:异步任务、防并发、降级机制
2. **数据层**:双格式存储、节点溯源、分页加载
3. **算法层**:智能排序、模糊匹配、语义对比
4. **体验层**:分段提示、活跃检测、历史记录
5. **安全层**:多租户隔离、双重校验、权限控制
这些优化不是对设计的否定,而是在实现过程中针对实际场景的工程化改进。设计文档描述"做什么",本文档记录"怎么做得更好"。
-164
View File
@@ -1,164 +0,0 @@
# 复习模块设计说明
本文档是对复习功能当前实现边界的工程说明,不修改原始需求文档。
## 核心理解
复习模块包含两类不同层级的体验:碎片化提醒和围绕思维导图的主动回忆。二者都属于复习模块,但不是同一个业务流程。
## 碎片化提醒
碎片化提醒展示用户自己在学习过程中写下的学习报告和学习残片。
这些内容不是完整复习记录,而是日常滚动出现的记忆触发物。用户看到一段自己写过的内容时,如果能立刻回想起上下文,说明相关知识暂时不需要深入复习;如果想不起来,可以点击进入详情页回看,形成一次轻量提醒。
当前对应能力:
- `GET /review/feed`
- `GET /review/task/{taskNum}`
- `GET /review/report/{id}`
- `GET /review/fragment/{id}`
- 前端 `Review.vue`
- 前端 `ReviewDetail.vue`
## 当前阶段边界
当前阶段暂不追求完整的自动化复习分析,因为项目尚未引入 AI 来处理学习碎片和导图数据。
当前阶段复习模块只需要保留思维导图的基本结构能力:
- 保存任务对应的思维导图
- 支持用户上传外部导图文件
- 解析导图的基础节点结构
- 允许用户在碎片化提醒中回看学习报告和学习残片
这个阶段不应该强制用户逐条处理大量学习碎片。学习碎片很多时,逐条关联和对照会让复习过程变得漫长、枯燥,并违背复习模块“简单、不抵触、可随意围绕某个知识点复习”的初衷。
## 思维导图的意义
思维导图不是普通附件,也不是单纯的存储对象。它是复习模块的主要交互形式。
用户复习时,主要动作应该是围绕某个知识点进行回忆,并编写或重绘思维导图。导图的意义在于降低复习阻力:用户不需要按顺序处理所有碎片,而是可以从任意知识点出发,自由地把自己能想起来的内容组织成结构。
初版设计中已经提到:
- 后续复习时可以在脑子里重绘那张图
- 没有想起来的部分就是需要重新看的地方
- 如果顺利绘制出了好的思维导图,就替换原来的思维导图
- 如果能顺利用思维导图描述所学内容,复习效果就能体现在这个过程中
因此,导图应该是复习效果的表达方式,而不仅是文件存储。
## 应用场景的位置
应用场景不应归属复习模块。
应用场景更接近学习任务的最终目标:完成整个学习任务后,用户希望能做什么、产出什么、应用到哪里。它应该挂在学习任务模块下,而不是挂在复习流程或某条学习碎片下。
当前实现已将其迁移为任务级能力,由任务页面维护,并使用 `task_applications` 存储。
## 模块边界
学习执行模块产生学习报告和学习残片。
复习模块使用这些产物做两件事:
- 在碎片化提醒中滚动展示它们
- 在引入 AI 后,把它们整理进程序生成的知识网络
复习模块不要求用户手动逐条整理学习报告和学习残片。学习报告/残片是后续自动整理和对照分析的数据来源。
## 下一阶段设计:AI 生成导图与用户导图对比
下一阶段引入 AI 后,复习模块的目标应升级为:
1. 程序读取学习报告、学习残片、已有导图等数据。
2. 程序自动将碎片整理成一个具有关联关系的巨大思维导图。
3. 用户围绕某个知识点进行回忆,并绘制自己的导图。
4. 系统将用户导图与程序生成导图进行结构对比。
5. 系统定位用户忽略、遗漏、误解或尚未建立关联的内容。
6. 用户根据差异回看必要的学习报告或残片,而不是从头处理所有碎片。
这个设计可以解决当前手动复习流程的两个问题:
- 学习碎片很多时,不需要用户逐条筛选,避免复习过程漫长且枯燥。
- 思维导图不再只是存储对象,而是成为用户回忆结果和程序知识网络之间的对照媒介。
## 对初版设计的补充判断
初版设计没有忽略“思维导图用于复习”的方向,反而已经明确指出导图应承担脑内重绘、发现遗漏、体现复习效果的作用。
初版设计没有具体描述“AI 自动整理碎片为巨大导图,并与用户导图进行对比”的实现逻辑。这是当前讨论对初版设计的重要补充,适合放入下一阶段实现。
初版中提到”复习内容可以应用到什么地方”,但结合当前理解,应用场景应从复习模块中移出,归入学习任务的最终目标或验收目标。
## 第二阶段实现:标准思维导图与回忆对比(2026-07-03)
第二阶段将 AI 生成导图与用户导图对比的设计落地,引入以下能力:
### 核心概念
- **标准思维导图(Standard Mind Map)**:每个学习任务对应一份”标准”导图,由系统从该任务的已有学习报告、学习残片、应用场景中提取整理而成。它是用户回忆的对照基准,也可由用户手动编辑修正。
- **回忆对比(Recall Review)**:用户凭记忆以缩进大纲格式写下对该任务知识点的回忆,系统将其与标准导图进行树结构对比,定位已掌握、遗漏和额外回忆的内容。
### 内置生成器(BuiltinMindMapGenerator
当前阶段未接入外部 AI,由内置规则引擎生成标准导图:
- **根节点**:任务名称
- **一级分支**:按学习会话分组,以”日期 + 报告前60字摘要”为标题
- **子分支**:该会话下的每条报告、每条残片成为一个子节点
- **应用场景分支**:如有已创建的应用场景,附加为独立的”应用场景”分支
- **去重**:同级节点按标准化标题对比并合并
- **追溯**:每个节点携带 `sourceType`REPORT/FRAGMENT/APPLICATION)和 `sourceId`,前端可点击回看原文
### AI 客户端抽象(MindMapAiClient
预留了远程 AI 调用接口,供后续接入真实 AI 使用:
- 配置项 `lpt.ai.endpoint` / `lpt.ai.api-key`(当前均未配置)
- 未配置时自动回退到内置生成器
- 接口返回 `MindMapNode` 树结构,与内置生成器输出格式一致
### 回忆对比算法(StandardMindMapServiceImpl.compareTrees
对比标准导图与用户回忆大纲的树结构:
1. **展平**:前序遍历将两颗树分别展开为节点列表
2. **精确匹配**:对每个标准节点标题做归一化(去空格标点 + 小写),在回忆节点中查找完全匹配
3. **模糊匹配**:未精确匹配时,计算字符 Bigram Jaccard 相似度(阈值 0.6),容忍中文同义表达
4. **标注**:匹配的节点标注 `MATCHED`,遗漏的节点标注 `MISSED`,用户多写的内容归为 `EXTRA`
5. **统计**:计算回忆覆盖率 `recall_ratio = matched / (matched + missed)`
### 用户交互
- **回忆复习页**`/review/recall/:taskNum`):双栏布局。左栏为回忆大纲输入区(缩进文本)+ 对比结果展示,右栏为标准导图区(默认折叠防剧透)
- **标准导图编辑**:用户可展开查看、编辑大纲文本并保存,编辑后来源标记为 `USER`
- **重新生成**:如需从头生成新版本,可一键重新生成
- **遗漏项回溯**:对比后定位到的遗漏知识点自动列出,点击”查看原文”可直接跳转到对应的报告/残片详情页
- **回忆历史**:查阅历次回忆对比记录,查看覆盖率变化趋势
### 新增数据库表
- `review_standard_mind_maps`:每任务一行,存储标准导图的 JSON 树结构、大纲文本、生成元信息
- `review_recall_records`:存储用户每次回忆对比的原始大纲、对比结果 JSON、覆盖率等统计
未改动既有表的 schema,与原有 `review_mind_maps`(用户手动上传的导图文件)并存。
### 新增端点
| 方法 | 端点 | 说明 |
|------|------|------|
| GET | `/review/standard-mind-map/{taskNum}` | 获取或自动生成标准导图 |
| POST | `/review/standard-mind-map/{taskNum}/regenerate` | 强制重新生成 |
| PUT | `/review/standard-mind-map/{taskNum}` | 用户编辑标准导图 |
| POST | `/review/standard-mind-map/{taskNum}/recall` | 用户提交回忆大纲,返回对比结果 |
| GET | `/review/standard-mind-map/{taskNum}/recall-records` | 回忆对比历史列表 |
| GET | `/review/standard-mind-map/recall-records/{recordId}` | 单条回忆记录详情 |
### 下一步展望
- 接入真实 AI API(配置 `lpt.ai.api-key` 后切换为 AI 生成器,内置生成器作为降级)
- 可视化思维导图渲染(而非纯缩进文本展示)
- 对比结果中显示更精确的路径定位
- AI 驱动的用户导图评分与改进建议
+2
View File
@@ -0,0 +1,2 @@
# 让 Lombok 生成的方法带 @Generated 注解,JaCoCo 自动排除生成代码,覆盖率只统计手写逻辑
lombok.addLombokGeneratedAnnotation = true
@@ -1,6 +1,7 @@
package com.guo.learningprogresstracker.controller;
import com.google.protobuf.ServiceException;
import com.guo.learningprogresstracker.dto.request.AbortStudySessionRequest;
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
@@ -93,6 +94,16 @@ public class StudySessionController {
return message != null ? CommonResult.success(message) : CommonResult.success();
}
/**
* 误操作结束一个【学习会话】:需输入确认语,严格零数据(删除会话与预期),仅未产生学习残片时可用
*/
@PostMapping("/{sessionNum}/study-sessions/abort")
public CommonResult<Void> abortStudySession(@PathVariable("sessionNum") String sessionNum,
@Valid @RequestBody AbortStudySessionRequest request) throws ErrorParameterException {
studySessionsServiceImpl.abortStudySession(sessionNum, request.getConfirmation());
return CommonResult.success();
}
/**
* 获取【学习会话】所有的学习残片数据,以列表返回
*/
@@ -0,0 +1,14 @@
package com.guo.learningprogresstracker.dto.request;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 误操作结束学习会话的请求体
*/
@Data
public class AbortStudySessionRequest {
@NotBlank(message = "请填写确认语")
private String confirmation;
}
@@ -17,4 +17,9 @@ public interface StudyExpectationsService {
* 查询学习会话的学习预期,不存在时返回 null
*/
StudyExpectationsEntity getBySessionNum(String sessionNum);
/**
* 删除学习会话的学习预期(误操作结束时会话不留任何数据)
*/
void deleteBySessionNum(String sessionNum);
}
@@ -26,6 +26,11 @@ public interface StudySessionsService extends IService<StudySessionsEntity> {
String endedStudySession(String sessionNum, String content) throws ErrorParameterException;
/**
* 误操作结束学习会话:需输入确认语,严格零数据(删除会话与预期),仅未产生学习残片时可用
*/
void abortStudySession(String sessionNum, String confirmation) throws ErrorParameterException;
void pauseStudySession(String sessionNum, LocalDateTime endTime) throws ErrorParameterException, ServiceException;
void continueStudySession(String sessionNum) throws ErrorParameterException, ServiceException;
@@ -52,4 +52,11 @@ public class StudyExpectationsServiceImpl implements StudyExpectationsService {
.eq(StudyExpectationsEntity::getSessionNum, sessionNum)
.last("LIMIT 1"));
}
@Override
public void deleteBySessionNum(String sessionNum) {
studyExpectationsMapper.delete(
Wrappers.<StudyExpectationsEntity>lambdaQuery()
.eq(StudyExpectationsEntity::getSessionNum, sessionNum));
}
}
@@ -40,6 +40,11 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
public static final int WORK_DURATION = 25;
/**
* 误操作结束的确认语:用户必须输入完全一致的内容才能零数据关闭会话
*/
public static final String MISOPERATION_CONFIRMATION = "我误操作导致开启了本次学习";
private final TasksServiceImpl tasksServiceImpl;
private final StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
private final StudyReportFragmentsMapper studyReportFragmentsMapper;
@@ -120,6 +125,31 @@ public class StudySessionsServiceImpl extends ServiceImpl<StudySessionsMapper, S
}
return null;
}
@Override
@Transactional
public void abortStudySession(String sessionNum, String confirmation) throws ErrorParameterException {
StudySessionsEntity studySessionsEntity = this.getOneOpt(Wrappers.lambdaQuery(StudySessionsEntity.class)
.eq(StudySessionsEntity::getSessionNum, sessionNum))
.orElseThrow(() -> sessionNotFound(sessionNum));
if (StudySessionStateEnum.ENDED.name().equals(studySessionsEntity.getSessionState())) {
log.warn("会话[{}]已结束,不能误操作结束", sessionNum);
throw new ErrorParameterException("这次学习会话已经结束,不能再误操作结束了");
}
if (!MISOPERATION_CONFIRMATION.equals(confirmation == null ? "" : confirmation.trim())) {
log.warn("会话[{}]误操作结束的确认语不正确", sessionNum);
throw new ErrorParameterException("确认语输入不正确,请核对后重新输入");
}
long fragmentCount = studyReportFragmentsMapper.selectCount(Wrappers.lambdaQuery(StudyReportFragmentsEntity.class)
.eq(StudyReportFragmentsEntity::getSessionNum, sessionNum));
if (fragmentCount > 0) {
log.warn("会话[{}]已产生{}条学习残片,不能误操作结束", sessionNum, fragmentCount);
throw new ErrorParameterException("这次学习已经产生了学习残片,不能按误操作结束了哦");
}
// 严格零数据:学习预期与会话本身一并删除,系统视角下会话从未发生
studyExpectationsService.deleteBySessionNum(sessionNum);
this.removeById(studySessionsEntity.getId());
log.info("会话[{}]误操作结束,未产生任何学习数据", sessionNum);
}
@Override
public StudySessionResponse getNotEndedStudySessionByTaskNum(String taskNum) throws ErrorParameterException {
@@ -10,7 +10,7 @@ public class CalculatedPriorityTool {
/**
* 使用系统默认权重计算加权优先级。
* 急性 0.35 / 重要性 0.25 / 内容难度 0.20 / 未来价值 0.10 / 主观优先级 0.10
* 急性 0.35 / 重要性 0.25 / 内容难度 0.20 / 未来价值 0.10 / 主观优先级 0.10
*/
public static Double calculatedPriority(PriorityDto priorityDto) {
return calculatedPriority(priorityDto, UserPriorityWeightsEntity.defaults());
@@ -0,0 +1,25 @@
package com.guo.learningprogresstracker;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
/**
* 应用启动类 main 方法覆盖测试:
* 以非 Web 模式 + H2 内存库拉起 Spring 容器,正常返回即覆盖 main 方法体两行。
*/
class ApplicationMainTest {
@Test
void main_startsApplicationContextInNonWebMode() {
assertDoesNotThrow(() -> LearningProgressTrackerApplication.main(new String[]{
"--spring.main.web-application-type=none",
"--spring.datasource.driver-class-name=org.h2.Driver",
"--spring.datasource.url=jdbc:h2:mem:lpt_main_test;MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;NON_KEYWORDS=USER",
"--spring.datasource.username=sa",
"--spring.datasource.password=",
"--spring.flyway.enabled=false",
"--mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.nologging.NoLoggingImpl"
}));
}
}
@@ -0,0 +1,95 @@
package com.guo.learningprogresstracker.common;
import cn.dev33.satoken.exception.NotLoginException;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.exception.AppException;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.validation.BeanPropertyBindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* GlobalExceptionHandler 单元测试:直接实例化并调用各 handler 方法,
* 断言 CommonResult 的 code / message 语义。
*/
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void errorParameterException_returnsBadRequestWithMessage() {
CommonResult result = handler.errorParameterException(new ErrorParameterException("参数有误"));
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
assertEquals("参数有误", result.getMessage());
assertNull(result.getData());
}
@Test
void notFindEntitiesException_returnsBadRequestWithMessage() {
CommonResult result = handler.notFindEntitiesException(new NotFindEntitiesException("数据不存在"));
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
assertEquals("数据不存在", result.getMessage());
}
@Test
void operationFailedException_returnsBadRequestWithMessage() {
CommonResult result = handler.operationFailedException(new OperationFailedException("操作失败"));
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
assertEquals("操作失败", result.getMessage());
}
@Test
void appException_returnsServerErrorWithMessage() {
CommonResult result = handler.AppException(new AppException("业务异常"));
assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), result.getCode());
assertEquals("业务异常", result.getMessage());
}
@Test
void methodArgumentNotValidException_returnsFirstFieldErrorMessage() {
MethodArgumentNotValidException ex =
new MethodArgumentNotValidException(null, new BeanPropertyBindingResult(new Object(), "o"));
ex.getBindingResult().addError(new FieldError("o", "f", "字段校验失败"));
CommonResult result = handler.MyMethodArgumentNotValidException(ex);
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
assertEquals("字段校验失败", result.getMessage());
}
@Test
void handleNotLogin_setsHttp401AndReturnsUnauthorizedBody() {
NotLoginException ex = new NotLoginException(
NotLoginException.NOT_TOKEN_MESSAGE, "login", NotLoginException.NOT_TOKEN);
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpServletResponse response = new MockHttpServletResponse();
CommonResult result = handler.handleNotLogin(ex, request, response);
assertEquals(HttpStatus.UNAUTHORIZED.value(), response.getStatus());
assertEquals(HttpStatus.UNAUTHORIZED.value(), result.getCode());
assertEquals("未登录,请重新登录", result.getMessage());
assertNull(result.getData());
}
@Test
void fallbackException_returnsGenericBadRequest() {
CommonResult result = handler.Exception(new RuntimeException("意外错误"));
assertEquals(HttpStatus.BAD_REQUEST.value(), result.getCode());
assertEquals("操作没有成功,请稍后再试", result.getMessage());
}
}
@@ -0,0 +1,106 @@
package com.guo.learningprogresstracker.config;
import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.context.SaTokenContext;
import cn.dev33.satoken.context.SaTokenContextForThreadLocal;
import cn.dev33.satoken.context.SaTokenContextForThreadLocalStorage;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.servlet.model.SaRequestForServlet;
import cn.dev33.satoken.servlet.model.SaResponseForServlet;
import cn.dev33.satoken.servlet.model.SaStorageForServlet;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.lang.reflect.Field;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* MybatisPlusConfig 单元测试:通过拦截器链反射取出 TenantLineHandler
* 校验忽略表清单、租户列名,以及无登录态时 getTenantId 抛 NotLoginException。
*/
class MybatisPlusConfigTest {
private SaTokenContext previousContext;
@BeforeEach
void setUpSaTokenThreadLocalContext() {
previousContext = SaManager.getSaTokenContext();
SaManager.setSaTokenContext(new SaTokenContextForThreadLocal());
MockHttpServletRequest request = new MockHttpServletRequest();
SaTokenContextForThreadLocalStorage.setBox(
new SaRequestForServlet(request),
new SaResponseForServlet(new MockHttpServletResponse()),
new SaStorageForServlet(request));
}
@AfterEach
void restoreSaTokenContext() {
SaTokenContextForThreadLocalStorage.clearBox();
SaManager.setSaTokenContext(previousContext);
}
@Test
void mybatisPlusInterceptor_registersPaginationAndTenantInterceptors() throws Exception {
MybatisPlusInterceptor interceptor = new MybatisPlusConfig().mybatisPlusInterceptor();
List<InnerInterceptor> innerInterceptors = interceptor.getInterceptors();
assertEquals(2, innerInterceptors.size());
assertInstanceOf(PaginationInnerInterceptor.class, innerInterceptors.get(0));
TenantLineInnerInterceptor tenantInterceptor =
assertInstanceOf(TenantLineInnerInterceptor.class, innerInterceptors.get(1));
TenantLineHandler handler = tenantLineHandler(tenantInterceptor);
assertEquals("created_by", handler.getTenantIdColumn());
}
@Test
void ignoreTable_excludesSystemTablesWithoutCreatedBy() throws Exception {
TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig()));
assertTrue(handler.ignoreTable("user"));
assertTrue(handler.ignoreTable("flyway_schema_history"));
assertTrue(handler.ignoreTable("databasechangelog"));
assertTrue(handler.ignoreTable("databasechangeloglock"));
}
@Test
void ignoreTable_businessTablesAreFiltered() throws Exception {
TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig()));
assertFalse(handler.ignoreTable("task"));
assertFalse(handler.ignoreTable("study_sessions"));
assertFalse(handler.ignoreTable("TASK"));
}
@Test
void getTenantId_withoutLoginState_throwsNotLoginException() throws Exception {
TenantLineHandler handler = tenantLineHandler(tenantInterceptorOf(new MybatisPlusConfig()));
assertThrows(NotLoginException.class, handler::getTenantId);
}
private TenantLineInnerInterceptor tenantInterceptorOf(MybatisPlusConfig config) {
return (TenantLineInnerInterceptor) config.mybatisPlusInterceptor().getInterceptors().get(1);
}
/** TenantLineInnerInterceptor 未暴露 handler 访问器,通过反射读取私有字段。 */
private TenantLineHandler tenantLineHandler(TenantLineInnerInterceptor interceptor) throws Exception {
Field field = TenantLineInnerInterceptor.class.getDeclaredField("tenantLineHandler");
field.setAccessible(true);
return (TenantLineHandler) field.get(interceptor);
}
}
@@ -0,0 +1,173 @@
package com.guo.learningprogresstracker.config;
import cn.dev33.satoken.context.SaTokenContext;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.spring.SaTokenContextForSpringInJakartaServlet;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.handler.MappedInterceptor;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* WebMvcConfig 单元测试:
* - addCorsMappings 用真实 CorsRegistry 验证三个配置分支的注册结果;
* - addInterceptors 取出注册的 SaInterceptor,借助 RequestContextHolder + sa-token Spring 上下文
* 驱动 preHandle,覆盖 OPTIONS 放行 lambda 与 GET 未登录抛 NotLoginException 分支。
*/
class WebMvcConfigTest {
private SaTokenContext previousContext;
@BeforeEach
void setUpSaTokenContext() {
previousContext = cn.dev33.satoken.SaManager.getSaTokenContext();
cn.dev33.satoken.SaManager.setSaTokenContext(new SaTokenContextForSpringInJakartaServlet());
}
@AfterEach
void restoreSaTokenContext() {
cn.dev33.satoken.SaManager.setSaTokenContext(previousContext);
RequestContextHolder.resetRequestAttributes();
}
// ============ addCorsMappings ============
@Test
void addCorsMappings_wildcardOrigin_registersOriginPatternsWithCredentials() {
CorsProperties props = new CorsProperties();
props.setAllowedOrigins(new String[]{"*"});
WebMvcConfig config = new WebMvcConfig(props);
InspectableCorsRegistry registry = new InspectableCorsRegistry();
config.addCorsMappings(registry);
Map<String, CorsConfiguration> configurations = registry.getCorsConfigurationsPublic();
assertEquals(1, configurations.size());
CorsConfiguration cors = configurations.get("/**");
assertEquals(List.of("*"), cors.getAllowedOriginPatterns());
assertTrue(cors.getAllowedMethods().contains("GET"));
assertTrue(cors.getAllowedMethods().contains("OPTIONS"));
assertTrue(cors.getAllowCredentials());
}
@Test
void addCorsMappings_explicitOrigins_nullCredentials_defaultsToTrue() {
CorsProperties props = new CorsProperties();
props.setAllowedOrigins(new String[]{"http://localhost:5158"});
props.setAllowCredentials(null);
WebMvcConfig config = new WebMvcConfig(props);
InspectableCorsRegistry registry = new InspectableCorsRegistry();
config.addCorsMappings(registry);
CorsConfiguration cors = registry.getCorsConfigurationsPublic().get("/**");
assertEquals(List.of("http://localhost:5158"), cors.getAllowedOrigins());
assertTrue(cors.getAllowCredentials());
}
@Test
void addCorsMappings_explicitOrigins_credentialsFalse_staysFalse() {
CorsProperties props = new CorsProperties();
props.setAllowedOrigins(new String[]{"http://localhost:5158"});
props.setAllowCredentials(Boolean.FALSE);
WebMvcConfig config = new WebMvcConfig(props);
InspectableCorsRegistry registry = new InspectableCorsRegistry();
config.addCorsMappings(registry);
CorsConfiguration cors = registry.getCorsConfigurationsPublic().get("/**");
assertFalse(cors.getAllowCredentials());
}
// ============ addInterceptors ============
@Test
void addInterceptors_registersSingleSaInterceptor() {
WebMvcConfig config = new WebMvcConfig(new CorsProperties());
InspectableInterceptorRegistry registry = new InspectableInterceptorRegistry();
config.addInterceptors(registry);
assertEquals(1, registry.getInterceptorsPublic().size());
HandlerInterceptor interceptor = (HandlerInterceptor) registry.getInterceptorsPublic().get(0);
// 注册时配置了 include/exclude 路径,框架会包装为 MappedInterceptor,最终都指向 SaInterceptor
assertTrue(interceptor instanceof MappedInterceptor || interceptor instanceof SaInterceptor);
}
@Test
void preHandle_optionsRequest_passesWithoutLoginCheck() throws Exception {
SaInterceptor interceptor = registeredSaInterceptor();
MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", "/tasks");
MockHttpServletResponse response = new MockHttpServletResponse();
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
assertTrue(interceptor.preHandle(request, response, new Object()));
}
@Test
void preHandle_getRequestWithoutToken_throwsNotLoginException() {
SaInterceptor interceptor = registeredSaInterceptor();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/tasks");
MockHttpServletResponse response = new MockHttpServletResponse();
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
assertThrows(NotLoginException.class,
() -> interceptor.preHandle(request, response, new Object()));
}
private SaInterceptor registeredSaInterceptor() {
WebMvcConfig config = new WebMvcConfig(new CorsProperties());
InspectableInterceptorRegistry registry = new InspectableInterceptorRegistry();
config.addInterceptors(registry);
return assertInstanceOf(SaInterceptor.class, unwrap(registry.getInterceptorsPublic().get(0)));
}
private Object unwrap(Object interceptor) {
if (interceptor instanceof MappedInterceptor mapped) {
return mapped.getInterceptor();
}
return interceptor;
}
/** InterceptorRegistry#getInterceptors 是 protected,这里仅在测试内拓宽可见性。 */
private static class InspectableInterceptorRegistry extends InterceptorRegistry {
@Override
public List<Object> getInterceptors() {
return super.getInterceptors();
}
List<Object> getInterceptorsPublic() {
return getInterceptors();
}
}
/** CorsRegistry#getCorsConfigurations 是 protected,这里仅在测试内拓宽可见性。 */
private static class InspectableCorsRegistry extends CorsRegistry {
@Override
public Map<String, CorsConfiguration> getCorsConfigurations() {
return super.getCorsConfigurations();
}
Map<String, CorsConfiguration> getCorsConfigurationsPublic() {
return getCorsConfigurations();
}
}
}
@@ -0,0 +1,56 @@
package com.guo.learningprogresstracker.config.serializer;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import org.junit.jupiter.api.Test;
import java.io.StringWriter;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* LocalDateTimeSerializer 单元测试:注册到 ObjectMapper 序列化非 null 值;
* null 分支 Jackson 默认不回调自定义序列化器,故直接驱动 serialize(null, gen, null) 覆盖。
*/
class LocalDateTimeSerializerTest {
@Test
void serialize_nonNullValue_writesFormattedDateTime() throws Exception {
ObjectMapper mapper = new ObjectMapper();
SimpleModule module = new SimpleModule();
module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
mapper.registerModule(module);
Bean bean = new Bean();
bean.setTime(LocalDateTime.of(2024, 5, 1, 8, 30, 5));
assertEquals("{\"time\":\"2024-05-01T08:30:05\"}", mapper.writeValueAsString(bean));
}
@Test
void serialize_nullValue_writesJsonNull() throws Exception {
StringWriter writer = new StringWriter();
JsonGenerator generator = new JsonFactory().createGenerator(writer);
new LocalDateTimeSerializer().serialize(null, generator, null);
generator.close();
assertEquals("null", writer.toString());
}
/** 简单载体,字段名为 time。 */
public static class Bean {
private LocalDateTime time;
public LocalDateTime getTime() {
return time;
}
public void setTime(LocalDateTime time) {
this.time = time;
}
}
}
@@ -0,0 +1,56 @@
package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.dto.request.LoginBody;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.service.LoginService;
import com.guo.learningprogresstracker.service.UserService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* LoginController 纯单元测试:mock LoginService / UserService。
*/
@ExtendWith(MockitoExtension.class)
class LoginControllerUnitTest {
@Mock
private LoginService loginService;
@Mock
private UserService userServiceImpl;
@InjectMocks
private LoginController controller;
@Test
void login_authenticatesThenIssuesToken() throws Exception {
LoginBody body = new LoginBody();
body.setUsername("alice");
body.setPassword("secret");
when(userServiceImpl.authenticate("alice", "secret")).thenReturn("U-1");
when(loginService.login("U-1")).thenReturn("token-abc");
CommonResult<String> result = controller.login(body);
assertEquals(200, result.getCode());
assertEquals("登录成功!", result.getMessage());
assertEquals("token-abc", result.getData());
verify(loginService).login("U-1");
}
@Test
void logout_delegatesToLoginService() {
CommonResult<Void> result = controller.logout();
assertEquals(200, result.getCode());
assertEquals("已登出", result.getMessage());
verify(loginService).logout();
}
}
@@ -0,0 +1,67 @@
package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.service.impl.StudyReportFragmentsServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* reportFragmentsController 纯单元测试(类名小写开头,保持与生产代码一致):
* 直接 new 控制器并 mock StudyReportFragmentsServiceImpl。
*/
@ExtendWith(MockitoExtension.class)
class ReportFragmentsControllerUnitTest {
@Mock
private StudyReportFragmentsServiceImpl studyReportFragmentsServiceImpl;
@InjectMocks
private reportFragmentsController controller;
@Test
void createFragments_delegatesToService() throws Exception {
CreateFragmentsRequest request = new CreateFragmentsRequest();
request.setSessionNum("S-1");
request.setContent("今日学习内容");
CommonResult<Void> result = controller.createFragments(request);
assertEquals(200, result.getCode());
verify(studyReportFragmentsServiceImpl).createFragments(request);
}
@Test
void updateFragments_delegatesToService() throws Exception {
UpdateFragmentsRequest request = new UpdateFragmentsRequest();
request.setContent("更新后的内容");
CommonResult<Void> result = controller.updateFragments(9, request);
assertEquals(200, result.getCode());
verify(studyReportFragmentsServiceImpl).updateFragments(9, request);
}
@Test
void getFragmentsBySession_returnsFragmentList() {
List<StudyReportFragmentsEntity> fragments = List.of(new StudyReportFragmentsEntity());
when(studyReportFragmentsServiceImpl.getFragmentsBySession("S-2")).thenReturn(fragments);
CommonResult<List<StudyReportFragmentsEntity>> result = controller.getFragmentsBySession("S-2");
assertEquals(200, result.getCode());
assertSame(fragments, result.getData());
}
}
@@ -0,0 +1,225 @@
package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.dto.ReviewFeedItem;
import com.guo.learningprogresstracker.dto.ReviewTaskStats;
import com.guo.learningprogresstracker.dto.request.RecallCompareRequest;
import com.guo.learningprogresstracker.dto.request.UpdateStandardMindMapRequest;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.ReviewRecallRecordEntity;
import com.guo.learningprogresstracker.entity.ReviewStandardMindMapEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.service.ReviewService;
import com.guo.learningprogresstracker.service.StandardMindMapService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* ReviewController 纯单元测试:直接 new 控制器并 mock 两个服务接口。
*/
@ExtendWith(MockitoExtension.class)
class ReviewControllerUnitTest {
@Mock
private ReviewService reviewService;
@Mock
private StandardMindMapService standardMindMapService;
@InjectMocks
private ReviewController controller;
@Test
void getReviewFeed_passesLimitAndMode() {
List<ReviewFeedItem> feed = List.of(new ReviewFeedItem());
when(reviewService.getReviewFeed(30, "recent")).thenReturn(feed);
CommonResult<List<ReviewFeedItem>> result = controller.getReviewFeed(30, "recent");
assertEquals(200, result.getCode());
assertSame(feed, result.getData());
}
@Test
void getReviewTaskStats_noArg_returnsAllTaskStats() {
List<ReviewTaskStats> stats = List.of(new ReviewTaskStats());
when(reviewService.getReviewTaskStats()).thenReturn(stats);
CommonResult<List<ReviewTaskStats>> result = controller.getReviewTaskStats();
assertEquals(200, result.getCode());
assertSame(stats, result.getData());
}
@Test
void getReviewTaskStats_byTaskNum_returnsSingleStats() {
ReviewTaskStats stats = new ReviewTaskStats();
when(reviewService.getReviewTaskStats("T001")).thenReturn(stats);
CommonResult<ReviewTaskStats> result = controller.getReviewTaskStats("T001");
assertEquals(200, result.getCode());
assertSame(stats, result.getData());
}
@Test
void getTaskReview_returnsTaskFeed() {
List<ReviewFeedItem> feed = List.of(new ReviewFeedItem());
when(reviewService.getTaskReview("T002")).thenReturn(feed);
CommonResult<List<ReviewFeedItem>> result = controller.getTaskReview("T002");
assertEquals(200, result.getCode());
assertSame(feed, result.getData());
}
@Test
void getReportDetail_delegatesToService() throws Exception {
StudyReportsEntity report = new StudyReportsEntity();
when(reviewService.getReportDetail(11)).thenReturn(report);
CommonResult<StudyReportsEntity> result = controller.getReportDetail(11);
assertEquals(200, result.getCode());
assertSame(report, result.getData());
}
@Test
void getFragmentDetail_delegatesToService() throws Exception {
StudyReportFragmentsEntity fragment = new StudyReportFragmentsEntity();
when(reviewService.getFragmentDetail(22)).thenReturn(fragment);
CommonResult<StudyReportFragmentsEntity> result = controller.getFragmentDetail(22);
assertEquals(200, result.getCode());
assertSame(fragment, result.getData());
}
@Test
void getStandardMindMap_delegatesToService() throws Exception {
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
when(standardMindMapService.getOrGenerate("T003")).thenReturn(mindMap);
CommonResult<ReviewStandardMindMapEntity> result = controller.getStandardMindMap("T003");
assertEquals(200, result.getCode());
assertSame(mindMap, result.getData());
}
@Test
void regenerateStandardMindMap_incrementalMode_usesIncrementalGenerate() throws Exception {
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
when(standardMindMapService.incrementalGenerate("T004")).thenReturn(mindMap);
CommonResult<ReviewStandardMindMapEntity> result = controller.regenerateStandardMindMap("T004", "incremental");
assertEquals(200, result.getCode());
assertSame(mindMap, result.getData());
verify(standardMindMapService).incrementalGenerate("T004");
}
@Test
void regenerateStandardMindMap_defaultMode_usesFullRegenerate() throws Exception {
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
when(standardMindMapService.regenerate("T005")).thenReturn(mindMap);
CommonResult<ReviewStandardMindMapEntity> result = controller.regenerateStandardMindMap("T005", "full");
assertEquals(200, result.getCode());
assertSame(mindMap, result.getData());
verify(standardMindMapService).regenerate("T005");
}
@Test
void updateStandardMindMap_passesOutline() throws Exception {
UpdateStandardMindMapRequest request = new UpdateStandardMindMapRequest();
request.setOutline("- 根节点\n- 子节点");
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
when(standardMindMapService.updateByOutline("T006", "- 根节点\n- 子节点")).thenReturn(mindMap);
CommonResult<ReviewStandardMindMapEntity> result = controller.updateStandardMindMap("T006", request);
assertEquals(200, result.getCode());
assertSame(mindMap, result.getData());
}
@Test
void recallCompare_passesOutlineAndFocusPath() throws Exception {
RecallCompareRequest request = new RecallCompareRequest();
request.setRecallOutline("- 回忆内容");
request.setFocusPath("根/子");
ReviewStandardMindMapEntity mindMap = new ReviewStandardMindMapEntity();
when(standardMindMapService.recallCompare("T007", "- 回忆内容", "根/子")).thenReturn(mindMap);
CommonResult<ReviewStandardMindMapEntity> result = controller.recallCompare("T007", request);
assertEquals(200, result.getCode());
assertSame(mindMap, result.getData());
}
@Test
void findNode_nullBody_usesEmptyContent() throws Exception {
Map<String, Object> matched = Map.of("id", "n1");
when(standardMindMapService.findNode("T008", "")).thenReturn(matched);
CommonResult<Map<String, Object>> result = controller.findNode("T008", null);
assertEquals(200, result.getCode());
assertSame(matched, result.getData());
}
@Test
void findNode_bodyWithoutContentKey_usesEmptyContent() throws Exception {
Map<String, Object> matched = Map.of("id", "n2");
when(standardMindMapService.findNode("T009", "")).thenReturn(matched);
CommonResult<Map<String, Object>> result = controller.findNode("T009", Map.of("other", "x"));
assertEquals(200, result.getCode());
assertSame(matched, result.getData());
}
@Test
void findNode_bodyWithContent_usesContent() throws Exception {
Map<String, Object> matched = Map.of("id", "n3");
when(standardMindMapService.findNode("T010", "租户隔离")).thenReturn(matched);
CommonResult<Map<String, Object>> result = controller.findNode("T010", Map.of("content", "租户隔离"));
assertEquals(200, result.getCode());
assertSame(matched, result.getData());
}
@Test
void listRecallRecords_returnsRecords() throws Exception {
List<ReviewRecallRecordEntity> records = List.of(new ReviewRecallRecordEntity());
when(standardMindMapService.listRecallRecords("T011")).thenReturn(records);
CommonResult<List<ReviewRecallRecordEntity>> result = controller.listRecallRecords("T011");
assertEquals(200, result.getCode());
assertSame(records, result.getData());
}
@Test
void getRecallRecord_returnsSingleRecord() throws Exception {
ReviewRecallRecordEntity record = new ReviewRecallRecordEntity();
when(standardMindMapService.getRecallRecord(33)).thenReturn(record);
CommonResult<ReviewRecallRecordEntity> result = controller.getRecallRecord(33);
assertEquals(200, result.getCode());
assertSame(record, result.getData());
}
}
@@ -0,0 +1,191 @@
package com.guo.learningprogresstracker.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.guo.learningprogresstracker.dto.request.AbortStudySessionRequest;
import com.guo.learningprogresstracker.dto.request.EndedStudySessionRequest;
import com.guo.learningprogresstracker.dto.request.UpsertExpectationRequest;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.service.StudyExpectationsService;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* StudySessionController 纯单元测试:直接 new 控制器并 mock 服务层,
* 不启动 Spring 容器(与既有 @SpringBootTest 风格的 StudySessionControllerTest 互补)。
*/
@ExtendWith(MockitoExtension.class)
class StudySessionControllerUnitTest {
@Mock
private StudySessionsServiceImpl studySessionsServiceImpl;
@Mock
private StudyExpectationsService studyExpectationsService;
@InjectMocks
private StudySessionController controller;
@Test
void upsertExpectation_delegatesToService() throws Exception {
UpsertExpectationRequest request = new UpsertExpectationRequest();
request.setDescription("目标:掌握租户隔离");
StudyExpectationsEntity entity = new StudyExpectationsEntity();
when(studyExpectationsService.upsertExpectation("S001", "目标:掌握租户隔离")).thenReturn(entity);
CommonResult<StudyExpectationsEntity> result = controller.upsertExpectation("S001", request);
assertEquals(200, result.getCode());
assertSame(entity, result.getData());
verify(studyExpectationsService).upsertExpectation("S001", "目标:掌握租户隔离");
}
@Test
void getExpectation_delegatesToService() {
StudyExpectationsEntity entity = new StudyExpectationsEntity();
when(studyExpectationsService.getBySessionNum("S002")).thenReturn(entity);
CommonResult<StudyExpectationsEntity> result = controller.getExpectation("S002");
assertEquals(200, result.getCode());
assertSame(entity, result.getData());
}
@Test
void getStudySessionBySessionNum_delegatesToService() throws Exception {
StudySessionResponse response = new StudySessionResponse();
when(studySessionsServiceImpl.getStudySessionBySessionNum("S003")).thenReturn(response);
CommonResult<StudySessionResponse> result = controller.getStudySessionBySessionNum("S003");
assertEquals(200, result.getCode());
assertSame(response, result.getData());
}
@Test
void startStudySession_continuesSessionAndReturnsSuccess() throws Exception {
CommonResult<Void> result = controller.startStudySession("S004");
assertEquals(200, result.getCode());
assertNull(result.getData());
verify(studySessionsServiceImpl).continueStudySession("S004");
}
@Test
void pauseStudySession_passesEndTimeThrough() throws Exception {
LocalDateTime endTime = LocalDateTime.of(2024, 5, 1, 12, 0);
CommonResult<Void> result = controller.pauseStudySession("S005", endTime);
assertEquals(200, result.getCode());
verify(studySessionsServiceImpl).pauseStudySession("S005", endTime);
}
@Test
void endedStudySession_withReportMessage_returnsMessageInData() throws Exception {
EndedStudySessionRequest request = new EndedStudySessionRequest();
request.setContent("报告内容");
when(studySessionsServiceImpl.endedStudySession("S006", "报告内容")).thenReturn("报告摘要");
CommonResult<Void> result = controller.endedStudySession("S006", request);
assertEquals(200, result.getCode());
// 注意:CommonResult.success(String) 重载优先匹配 message 参数,
// 报告摘要落在 message 字段而非 data(生产行为如此,按实际语义断言)
assertEquals("报告摘要", result.getMessage());
}
@Test
void endedStudySession_withoutReportMessage_returnsEmptySuccess() throws Exception {
EndedStudySessionRequest request = new EndedStudySessionRequest();
request.setContent("无报告内容");
when(studySessionsServiceImpl.endedStudySession("S007", "无报告内容")).thenReturn(null);
CommonResult<Void> result = controller.endedStudySession("S007", request);
assertEquals(200, result.getCode());
assertNull(result.getData());
}
@Test
void abortStudySession_delegatesConfirmation() throws Exception {
AbortStudySessionRequest request = new AbortStudySessionRequest();
request.setConfirmation("确认删除");
CommonResult<Void> result = controller.abortStudySession("S008", request);
assertEquals(200, result.getCode());
verify(studySessionsServiceImpl).abortStudySession("S008", "确认删除");
}
@Test
void getAllFragments_returnsFragmentList() throws Exception {
ArrayList<String> fragments = new ArrayList<>(List.of("片段一", "片段二"));
when(studySessionsServiceImpl.getAllFragments("S009")).thenReturn(fragments);
CommonResult<ArrayList<String>> result = controller.getAllFragments("S009");
assertEquals(200, result.getCode());
assertSame(fragments, result.getData());
}
@Test
void getReportDraft_returnsGeneratedDraft() throws Exception {
when(studySessionsServiceImpl.generateReportDraft("S010")).thenReturn("草稿内容");
CommonResult<String> result = controller.getReportDraft("S010");
assertEquals(200, result.getCode());
assertEquals("草稿内容", result.getData());
}
@Test
void getTaskFragments_passesPagingArguments() {
Page<StudyReportFragmentsEntity> page = new Page<>();
when(studySessionsServiceImpl.getTaskFragments("T001", 2, 5, "关键词")).thenReturn(page);
CommonResult<Page<StudyReportFragmentsEntity>> result = controller.getTaskFragments("T001", 2, 5, "关键词");
assertEquals(200, result.getCode());
assertSame(page, result.getData());
}
@Test
void getTaskReports_passesPagingArguments() {
Page<StudyReportsEntity> page = new Page<>();
when(studySessionsServiceImpl.getTaskReports("T002", 3, 20, null)).thenReturn(page);
CommonResult<Page<StudyReportsEntity>> result = controller.getTaskReports("T002", 3, 20, null);
assertEquals(200, result.getCode());
assertSame(page, result.getData());
}
@Test
void getActiveSession_passesExcludeTaskNum() {
StudySessionResponse response = new StudySessionResponse();
when(studySessionsServiceImpl.getActiveSession("T003")).thenReturn(response);
CommonResult<StudySessionResponse> result = controller.getActiveSession("T003");
assertEquals(200, result.getCode());
assertSame(response, result.getData());
}
}
@@ -0,0 +1,188 @@
package com.guo.learningprogresstracker.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.guo.learningprogresstracker.dto.TaskInfo;
import com.guo.learningprogresstracker.dto.request.CreateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.request.TaskRequest;
import com.guo.learningprogresstracker.dto.request.UpdateTaskApplicationRequest;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.dto.response.TaskInfoResponse;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
import com.guo.learningprogresstracker.service.PriorityWeightsService;
import com.guo.learningprogresstracker.service.TasksService;
import com.guo.learningprogresstracker.service.impl.StudySessionsServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* TaskController 纯单元测试:直接 new 控制器并 mock 三个依赖服务。
*/
@ExtendWith(MockitoExtension.class)
class TaskControllerUnitTest {
@Mock
private TasksService tasksService;
@Mock
private StudySessionsServiceImpl studySessionsServiceImpl;
@Mock
private PriorityWeightsService priorityWeightsService;
@InjectMocks
private TaskController controller;
@Test
void getPriorityWeights_delegatesToService() {
UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity();
when(priorityWeightsService.getWeights()).thenReturn(weights);
CommonResult<UserPriorityWeightsEntity> result = controller.getPriorityWeights();
assertEquals(200, result.getCode());
assertSame(weights, result.getData());
}
@Test
void savePriorityWeights_delegatesToService() throws Exception {
UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity();
UserPriorityWeightsEntity saved = new UserPriorityWeightsEntity();
when(priorityWeightsService.saveWeights(weights)).thenReturn(saved);
CommonResult<UserPriorityWeightsEntity> result = controller.savePriorityWeights(weights);
assertEquals(200, result.getCode());
assertSame(saved, result.getData());
}
@Test
void addTask_returnsGeneratedTaskNum() throws Exception {
TaskRequest request = new TaskRequest();
when(tasksService.addTask(request)).thenReturn("T-100");
CommonResult<String> result = controller.addTask(request);
assertEquals(200, result.getCode());
assertEquals("T-100", result.getData());
}
@Test
void getTask_delegatesToService() {
TaskInfoResponse response = new TaskInfoResponse();
when(tasksService.getTask("1")).thenReturn(response);
CommonResult<TaskInfoResponse> result = controller.getTask("1");
assertEquals(200, result.getCode());
assertSame(response, result.getData());
}
@Test
void updateTask_delegatesToService() {
TaskRequest request = new TaskRequest();
CommonResult<Void> result = controller.updateTask("2", request);
assertEquals(200, result.getCode());
verify(tasksService).updateTask("2", request);
}
@Test
void deleteTask_delegatesToService() throws Exception {
CommonResult<Void> result = controller.deleteTask("3");
assertEquals(200, result.getCode());
verify(tasksService).deleteTask("3");
}
@Test
void getApplications_returnsApplicationList() throws Exception {
List<TaskApplicationEntity> applications = List.of(new TaskApplicationEntity());
when(tasksService.getApplications("T-1")).thenReturn(applications);
CommonResult<List<TaskApplicationEntity>> result = controller.getApplications("T-1");
assertEquals(200, result.getCode());
assertSame(applications, result.getData());
}
@Test
void createApplication_fillsTaskNumBeforeDelegation() throws Exception {
CreateTaskApplicationRequest request = new CreateTaskApplicationRequest();
request.setTitle("背单词");
TaskApplicationEntity created = new TaskApplicationEntity();
when(tasksService.createApplication(request)).thenReturn(created);
CommonResult<TaskApplicationEntity> result = controller.createApplication("T-2", request);
assertEquals("T-2", request.getTaskNum());
assertEquals(200, result.getCode());
assertSame(created, result.getData());
}
@Test
void updateApplication_delegatesToService() throws Exception {
UpdateTaskApplicationRequest request = new UpdateTaskApplicationRequest();
request.setTitle("更新后的标题");
TaskApplicationEntity updated = new TaskApplicationEntity();
when(tasksService.updateApplication(7, request)).thenReturn(updated);
CommonResult<TaskApplicationEntity> result = controller.updateApplication(7, request);
assertEquals(200, result.getCode());
assertSame(updated, result.getData());
}
@Test
void deleteApplication_delegatesToService() throws Exception {
CommonResult<Void> result = controller.deleteApplication(8);
assertEquals(200, result.getCode());
verify(tasksService).deleteApplication(8);
}
@Test
void tasksList_passesPagingArguments() {
Page<TaskInfo> page = new Page<>();
when(tasksService.taskList(2, 20)).thenReturn(page);
CommonResult<Page<TaskInfo>> result = controller.tasksList(2, 20);
assertEquals(200, result.getCode());
assertSame(page, result.getData());
}
@Test
void startOrContinueStudySession_delegatesToImpl() throws Exception {
StudySessionResponse response = new StudySessionResponse();
when(studySessionsServiceImpl.startOrContinueStudySession("T-3")).thenReturn(response);
CommonResult<StudySessionResponse> result = controller.startOrContinueStudySession("T-3");
assertEquals(200, result.getCode());
assertSame(response, result.getData());
}
@Test
void getNotEndedStudySessionByTaskNum_delegatesToImpl() throws Exception {
StudySessionResponse response = new StudySessionResponse();
when(studySessionsServiceImpl.getNotEndedStudySessionByTaskNum("T-4")).thenReturn(response);
CommonResult<StudySessionResponse> result = controller.getNotEndedStudySessionByTaskNum("T-4");
assertEquals(200, result.getCode());
assertSame(response, result.getData());
}
}
@@ -0,0 +1,90 @@
package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.entity.TestTableEntity;
import com.guo.learningprogresstracker.exception.AppException;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.exception.OperationFailedException;
import com.guo.learningprogresstracker.service.TestTableService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* TestController 纯单元测试:同包直接注入 mock 的 TestTableService 字段,
* 覆盖按 id 触发的各业务异常分支。
*/
@ExtendWith(MockitoExtension.class)
class TestControllerUnitTest {
@Mock
private TestTableService testTableService;
@Test
void getTestEntityById_id1_throwsAppException() {
TestController controller = new TestController();
controller.testTableService = testTableService;
AppException ex = assertThrows(AppException.class, () -> controller.getTestEntityById("1"));
assertEquals("测试AppException", ex.getMessage());
verifyNoInteractions(testTableService);
}
@Test
void getTestEntityById_id2_throwsErrorParameterException() {
TestController controller = new TestController();
controller.testTableService = testTableService;
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
() -> controller.getTestEntityById("2"));
assertEquals("测试ErrorParameterException", ex.getMessage());
verifyNoInteractions(testTableService);
}
@Test
void getTestEntityById_id3_throwsNotFindEntitiesException() {
TestController controller = new TestController();
controller.testTableService = testTableService;
NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class,
() -> controller.getTestEntityById("3"));
assertEquals("测试NotFindEntitiesException", ex.getMessage());
verifyNoInteractions(testTableService);
}
@Test
void getTestEntityById_id4_throwsOperationFailedException() {
TestController controller = new TestController();
controller.testTableService = testTableService;
OperationFailedException ex = assertThrows(OperationFailedException.class,
() -> controller.getTestEntityById("4"));
assertEquals("测试OperationFailedException", ex.getMessage());
verifyNoInteractions(testTableService);
}
@Test
void getTestEntityById_otherId_returnsEntityFromService() throws Exception {
TestController controller = new TestController();
controller.testTableService = testTableService;
TestTableEntity entity = new TestTableEntity();
when(testTableService.getTestEntityById("5")).thenReturn(entity);
CommonResult<TestTableEntity> result = controller.getTestEntityById("5");
assertEquals(200, result.getCode());
assertSame(entity, result.getData());
}
}
@@ -0,0 +1,59 @@
package com.guo.learningprogresstracker.controller;
import com.guo.learningprogresstracker.entity.CommonResult;
import com.guo.learningprogresstracker.support.TestHttpServer;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* UtilsController 单元测试:fetchTitle 内部调用 TitleFetcher 静态方法无法 mock
* 用本地 TestHttpServer 提供真实页面验证网络分支,null/空白参数直接断言错误结果。
*/
class UtilsControllerUnitTest extends TestHttpServer {
private final UtilsController controller = new UtilsController();
@Test
void fetchTitle_nullUrl_returnsError() {
CommonResult<Map<String, String>> result = controller.fetchTitle(null);
assertEquals(400, result.getCode());
assertEquals("url 参数不能为空", result.getMessage());
assertNull(result.getData());
}
@Test
void fetchTitle_blankUrl_returnsError() {
CommonResult<Map<String, String>> result = controller.fetchTitle(" ");
assertEquals(400, result.getCode());
assertEquals("url 参数不能为空", result.getMessage());
}
@Test
void fetchTitle_validUrl_returnsPageTitle() {
route("GET", "/page", 200, "<html><head><title>单元测试标题</title></head></html>");
CommonResult<Map<String, String>> result = controller.fetchTitle(baseUrl() + "/page?q=1");
assertEquals(200, result.getCode());
assertEquals("单元测试标题", result.getData().get("title"));
assertEquals("q=1", lastQuery("GET", "/page"));
}
@Test
void fetchTitle_pageWithoutTitle_fallsBackToUrl() {
route("GET", "/plain", 200, "纯文本没有标题",
Map.of("Content-Type", "text/plain; charset=utf-8"));
String url = baseUrl() + "/plain";
CommonResult<Map<String, String>> result = controller.fetchTitle(url);
assertEquals(200, result.getCode());
assertEquals(url, result.getData().get("title"));
}
}
@@ -0,0 +1,106 @@
package com.guo.learningprogresstracker.entity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* CommonResult 单元测试:覆盖全部工厂方法与构造重载。
*/
class CommonResultTest {
@Test
void success_noArgs_shouldUseDefaultMessage() {
CommonResult<Void> result = CommonResult.success();
assertEquals(200, result.getCode());
assertEquals("请求成功", result.getMessage());
assertNull(result.getData());
}
@Test
void success_withMessage_shouldKeepMessage() {
CommonResult<Void> result = CommonResult.success("操作完成");
assertEquals(200, result.getCode());
assertEquals("操作完成", result.getMessage());
assertNull(result.getData());
}
@Test
void success_withData_shouldUseDefaultMessage() {
// 注意:CommonResult.success(String) 重载优先匹配 message 参数,
// 必须先向上转型为 Object 才能命中 success(T data) 这个工厂方法
CommonResult<Object> result = CommonResult.success((Object) "数据");
assertEquals(200, result.getCode());
assertEquals("请求成功", result.getMessage());
assertEquals("数据", result.getData());
}
@Test
void success_withMessageAndData_shouldKeepBoth() {
CommonResult<String> result = CommonResult.success("操作完成", "数据");
assertEquals(200, result.getCode());
assertEquals("操作完成", result.getMessage());
assertEquals("数据", result.getData());
}
@Test
void twoArgConstructor_shouldDefaultNullData() {
CommonResult<String> result = new CommonResult<>(400, "参数错误");
assertEquals(400, result.getCode());
assertEquals("参数错误", result.getMessage());
assertNull(result.getData());
}
@Test
void error_shouldUseBadRequest() {
CommonResult<Void> result = CommonResult.error("请求失败");
assertEquals(400, result.getCode());
assertEquals("请求失败", result.getMessage());
assertNull(result.getData());
}
@Test
void notFind_noArgs_shouldUseDefaultMessage() {
CommonResult<Void> result = CommonResult.notFind();
assertEquals(404, result.getCode());
assertEquals("请求的资源不存在", result.getMessage());
assertNull(result.getData());
}
@Test
void notFind_withMessage_shouldKeepMessage() {
CommonResult<Void> result = CommonResult.notFind("找不到任务");
assertEquals(404, result.getCode());
assertEquals("找不到任务", result.getMessage());
assertNull(result.getData());
}
@Test
void serverError_shouldUse500() {
CommonResult<Void> result = CommonResult.serverError("服务器错误");
assertEquals(500, result.getCode());
assertEquals("服务器错误", result.getMessage());
assertNull(result.getData());
}
@Test
void settersAndGetters_shouldWork() {
CommonResult<String> result = new CommonResult<>();
result.setCode(200);
result.setMessage("ok");
result.setData("d");
assertEquals(200, result.getCode());
assertEquals("ok", result.getMessage());
assertEquals("d", result.getData());
}
}
@@ -0,0 +1,90 @@
package com.guo.learningprogresstracker.entity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* TestTableEntity 单元测试:覆盖手写的 equals/hashCode/toString 全部分支。
*/
class TestTableEntityTest {
private TestTableEntity entity(Integer id, String idName) {
TestTableEntity entity = new TestTableEntity();
entity.setId(id);
entity.setIdName(idName);
return entity;
}
@Test
void equals_sameReference_shouldBeTrue() {
TestTableEntity entity = entity(1, "a");
assertEquals(entity, entity);
}
@Test
void equals_null_shouldBeFalse() {
assertNotEquals(entity(1, "a"), null);
}
@Test
void equals_differentClass_shouldBeFalse() {
TestTableEntity entity = entity(1, "a");
TestTableEntity subclass = new TestTableEntity() {
};
subclass.setId(1);
subclass.setIdName("a");
assertNotEquals(entity, subclass);
}
@Test
void equals_sameFields_shouldBeTrue() {
assertEquals(entity(1, "a"), entity(1, "a"));
}
@Test
void equals_bothFieldsNull_shouldBeTrue() {
assertEquals(entity(null, null), entity(null, null));
}
@Test
void equals_differentId_shouldBeFalse() {
assertNotEquals(entity(1, "a"), entity(2, "a"));
}
@Test
void equals_differentIdName_shouldBeFalse() {
assertNotEquals(entity(1, "a"), entity(1, "b"));
}
@Test
void equals_idNullVsSet_shouldBeFalse() {
assertNotEquals(entity(null, "a"), entity(1, "a"));
}
@Test
void equals_idNameNullVsSet_shouldBeFalse() {
assertNotEquals(entity(1, null), entity(1, "a"));
}
@Test
void hashCode_equalEntities_shouldMatch() {
assertEquals(entity(1, "a").hashCode(), entity(1, "a").hashCode());
}
@Test
void hashCode_nullFields_shouldBeStable() {
assertEquals(entity(null, null).hashCode(), entity(null, null).hashCode());
}
@Test
void toString_shouldContainFields() {
TestTableEntity entity = entity(1, "测试");
String text = entity.toString();
assertTrue(text.contains("TestTableEntity"));
assertTrue(text.contains("id=1"));
assertTrue(text.contains("idName=测试"));
assertTrue(text.contains("serialVersionUID=1"));
}
}
@@ -0,0 +1,18 @@
package com.guo.learningprogresstracker.enums;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* StudySessionStateEnum 单元测试:覆盖 getDescription 未覆盖行。
*/
class StudySessionStateEnumTest {
@Test
void descriptions_shouldMatchConstants() {
assertEquals("进行中", StudySessionStateEnum.ONGOING.getDescription());
assertEquals("暂停", StudySessionStateEnum.PAUSED.getDescription());
assertEquals("已结束", StudySessionStateEnum.ENDED.getDescription());
}
}
@@ -0,0 +1,40 @@
package com.guo.learningprogresstracker.enums;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* TaskApplicationStatusEnum 单元测试:覆盖 fromCodeOrDefault 的未知码兜底/合法码分支。
*/
class TaskApplicationStatusEnumTest {
@Test
void fromCodeOrDefault_nullOrBlank_shouldReturnTodo() {
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault(null));
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault(""));
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault(" "));
}
@Test
void fromCodeOrDefault_validCode_shouldMatchIgnoringCase() {
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault("todo"));
assertSame(TaskApplicationStatusEnum.DOING, TaskApplicationStatusEnum.fromCodeOrDefault("doing"));
assertSame(TaskApplicationStatusEnum.DONE, TaskApplicationStatusEnum.fromCodeOrDefault("DONE"));
}
@Test
void fromCodeOrDefault_unknownCode_shouldFallbackTodo() {
assertSame(TaskApplicationStatusEnum.TODO, TaskApplicationStatusEnum.fromCodeOrDefault("not-a-status"));
}
@Test
void codeAndDescription_shouldMatchConstants() {
assertEquals("TODO", TaskApplicationStatusEnum.TODO.getCode());
assertEquals("待应用", TaskApplicationStatusEnum.TODO.getDescription());
assertEquals("DOING", TaskApplicationStatusEnum.DOING.getCode());
assertEquals("应用中", TaskApplicationStatusEnum.DOING.getDescription());
assertEquals("DONE", TaskApplicationStatusEnum.DONE.getCode());
assertEquals("已完成", TaskApplicationStatusEnum.DONE.getDescription());
}
}
@@ -0,0 +1,18 @@
package com.guo.learningprogresstracker.exception;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* AppException 构造与消息测试。
*/
class AppExceptionTest {
@Test
void constructor_shouldKeepMessage() {
AppException ex = new AppException("数据库连接失败");
assertEquals("数据库连接失败", ex.getMessage());
}
}
@@ -0,0 +1,25 @@
package com.guo.learningprogresstracker.exception;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* OperationFailedException 构造与消息测试。
*/
class OperationFailedExceptionTest {
@Test
void noArgConstructor_messageShouldBeNull() {
OperationFailedException ex = new OperationFailedException();
assertNull(ex.getMessage());
}
@Test
void messageConstructor_shouldKeepMessage() {
OperationFailedException ex = new OperationFailedException("操作失败");
assertEquals("操作失败", ex.getMessage());
}
}
@@ -0,0 +1,29 @@
package com.guo.learningprogresstracker.mapStruct;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* 仅触发 MAPPER 字段初始化(Mappers.getMapper 加载生成的 Impl),
* 覆盖接口内的初始化行;生成实现已被 JaCoCo 排除,不做映射细节断言。
*/
class FragmentsConvertTest {
@Test
void mapper_shouldBeInitializedAndConvertRequest() {
assertNotNull(FragmentsConvert.MAPPER);
CreateFragmentsRequest request = new CreateFragmentsRequest();
request.setSessionNum("SESSION_A");
request.setContent("学习内容");
StudyReportFragmentsEntity entity = FragmentsConvert.MAPPER.toFragmentsEntity(request);
assertNotNull(entity);
assertEquals("SESSION_A", entity.getSessionNum());
assertEquals("学习内容", entity.getContent());
}
}
@@ -0,0 +1,29 @@
package com.guo.learningprogresstracker.mapStruct;
import com.guo.learningprogresstracker.dto.response.StudySessionResponse;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* 仅触发 MAPPER 字段初始化(Mappers.getMapper 加载生成的 Impl),
* 覆盖接口内的初始化行;生成实现已被 JaCoCo 排除,不做映射细节断言。
*/
class StudySessionConvertTest {
@Test
void mapper_shouldBeInitializedAndConvertEntity() {
assertNotNull(StudySessionConvert.MAPPER);
StudySessionsEntity entity = new StudySessionsEntity();
entity.setSessionNum("SESSION_A");
entity.setSessionState("ONGOING");
StudySessionResponse response = StudySessionConvert.MAPPER.toStudySessionResponse(entity);
assertNotNull(response);
assertEquals("SESSION_A", response.getSessionNum());
assertEquals("ONGOING", response.getSessionState());
}
}
@@ -0,0 +1,231 @@
package com.guo.learningprogresstracker.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.guo.learningprogresstracker.support.TestHttpServer;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
/**
* AiServiceClient 单元测试:基于本地 TestHttpServer 覆盖异步 submit + poll 全部分支。
*/
class AiServiceClientTest extends TestHttpServer {
private AiServiceClient newClient(String url, int timeoutSeconds) {
AiServiceClient client = new AiServiceClient();
client.setUrl(url);
client.setTimeoutSeconds(timeoutSeconds);
client.init();
return client;
}
// ============ 配置状态 ============
@Test
void isConfigured_dependsOnUrl() {
AiServiceClient client = new AiServiceClient();
assertFalse(client.isConfigured());
client.setUrl("http://localhost:5199");
assertTrue(client.isConfigured());
client.setUrl(" ");
assertFalse(client.isConfigured());
// init 的两条日志分支(未配置 / 已配置)
client.init();
client.setUrl(baseUrl());
client.init();
}
@Test
void publicApis_returnEmptyWhenNotConfigured() {
AiServiceClient client = new AiServiceClient();
client.setTimeoutSeconds(1);
client.init();
assertTrue(client.aggregateReport("任务", List.of("片段"), null).isEmpty());
assertTrue(client.generateMindMap("任务", "描述", List.of("报告")).isEmpty());
assertTrue(client.compareRecall("任务", "标准大纲", "回忆大纲").isEmpty());
}
// ============ aggregateReport ============
@Test
void aggregateReport_returnsEmptyWhenFragmentsNullOrEmpty() {
AiServiceClient client = newClient(baseUrl(), 1);
assertTrue(client.aggregateReport("任务", null, null).isEmpty());
assertTrue(client.aggregateReport("任务", List.of(), null).isEmpty());
}
@Test
void aggregateReport_success_coversExpectationBothBranches() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t1\"}");
route("GET", "/ai/tasks/t1", 200, "{\"status\":\"done\",\"result\":{\"report\":\"聚合报告内容\"}}");
AiServiceClient client = newClient(baseUrl(), 2);
// expectation 为 null → 参数不含 expectation
Optional<String> noExpectation = client.aggregateReport("任务A", List.of("片段1"), null);
assertTrue(noExpectation.isPresent());
assertEquals("聚合报告内容", noExpectation.get());
// expectation 非空 → 参数带 expectation
Optional<String> withExpectation = client.aggregateReport("任务A", List.of("片段1"), "期望目标");
assertTrue(withExpectation.isPresent());
assertEquals("聚合报告内容", withExpectation.get());
assertEquals("application/json", requestHeader("POST", "/ai/tasks", "Content-Type"));
}
// ============ generateMindMap ============
@Test
void generateMindMap_success_returnsOutline() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t2\"}");
route("GET", "/ai/tasks/t2", 200,
"{\"status\":\"done\",\"result\":{\"outline\":\"- 分支A\\n- 分支B\"}}");
AiServiceClient client = newClient(baseUrl(), 2);
Optional<String> outline = client.generateMindMap("任务A", null, List.of("报告内容"));
assertTrue(outline.isPresent());
assertEquals("- 分支A\n- 分支B", outline.get());
}
@Test
void generateMindMap_outlineMissing_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t3\"}");
route("GET", "/ai/tasks/t3", 200, "{\"status\":\"done\",\"result\":{}}");
AiServiceClient client = newClient(baseUrl(), 2);
assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty());
}
@Test
void generateMindMap_outlineBlank_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t4\"}");
route("GET", "/ai/tasks/t4", 200, "{\"status\":\"done\",\"result\":{\"outline\":\" \"}}");
AiServiceClient client = newClient(baseUrl(), 2);
assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty());
}
// ============ compareRecall ============
@Test
void compareRecall_success_returnsJsonResult() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t5\"}");
route("GET", "/ai/tasks/t5", 200,
"{\"status\":\"done\",\"result\":{\"matches\":[],\"evaluation\":\"ok\"}}");
AiServiceClient client = newClient(baseUrl(), 2);
Optional<JsonNode> result = client.compareRecall("任务A", "标准大纲", "回忆大纲");
assertTrue(result.isPresent());
assertEquals("ok", result.get().path("evaluation").asText());
}
// ============ 提交阶段失败 ============
@Test
void submitTask_non201_returnsEmpty() {
route("POST", "/ai/tasks", 500, "server error body");
AiServiceClient client = newClient(baseUrl(), 1);
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
}
@Test
void submitTask_201WithoutTaskId_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"message\":\"accepted\"}");
AiServiceClient client = newClient(baseUrl(), 1);
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
}
@Test
void submitTask_201BlankTaskId_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\" \"}");
AiServiceClient client = newClient(baseUrl(), 1);
assertTrue(client.aggregateReport("任务A", List.of("片段"), null).isEmpty());
}
@Test
void submitTask_connectionRefused_returnsEmpty() {
AiServiceClient client = newClient("http://localhost:1", 1);
assertTrue(client.generateMindMap("任务A", "描述", List.of("报告内容")).isEmpty());
}
// ============ 轮询阶段 ============
@Test
void pollTask_failed_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t8\"}");
route("GET", "/ai/tasks/t8", 200, "{\"status\":\"failed\",\"error\":\"模型超时\"}");
AiServiceClient client = newClient(baseUrl(), 2);
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
}
@Test
void pollTask_404_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t9\"}");
route("GET", "/ai/tasks/t9", 404, "{\"error\":\"not found\"}");
AiServiceClient client = newClient(baseUrl(), 2);
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
}
@Test
void pollTask_unknownStatus_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t10\"}");
route("GET", "/ai/tasks/t10", 200, "{\"status\":\"weird\"}");
AiServiceClient client = newClient(baseUrl(), 2);
assertTrue(client.compareRecall("任务A", "标准大纲", "回忆大纲").isEmpty());
}
@Test
void pollTask_pending_thenDeadlineExpires_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t11\"}");
route("GET", "/ai/tasks/t11", 200, "{\"status\":\"pending\"}");
AiServiceClient client = newClient(baseUrl(), 1);
long start = System.currentTimeMillis();
Optional<JsonNode> result = client.compareRecall("任务A", "标准大纲", "回忆大纲");
long elapsed = System.currentTimeMillis() - start;
assertTrue(result.isEmpty());
// pending 后退避 2s,超过 1s 的 deadline 后退出
assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms");
assertTrue(elapsed < 10000, "不应无限轮询,实际 " + elapsed + "ms");
}
@Test
void pollTask_running_thenDeadlineExpires_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t12\"}");
route("GET", "/ai/tasks/t12", 200, "{\"status\":\"running\"}");
AiServiceClient client = newClient(baseUrl(), 1);
long start = System.currentTimeMillis();
Optional<String> result = client.aggregateReport("任务A", List.of("片段"), null);
long elapsed = System.currentTimeMillis() - start;
assertTrue(result.isEmpty());
assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms");
}
@Test
void pollTask_non200Continues_thenDeadlineExpires_returnsEmpty() {
route("POST", "/ai/tasks", 201, "{\"taskId\":\"t13\"}");
route("GET", "/ai/tasks/t13", 503, "temporarily unavailable");
AiServiceClient client = newClient(baseUrl(), 1);
long start = System.currentTimeMillis();
Optional<JsonNode> result = client.compareRecall("任务A", "标准大纲", "回忆大纲");
long elapsed = System.currentTimeMillis() - start;
assertTrue(result.isEmpty());
// 503 → 退避 2s → continue → deadline 过期退出
assertTrue(elapsed >= 2000, "应等待退避后超时退出,实际 " + elapsed + "ms");
}
}
@@ -0,0 +1,198 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskApplicationEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.utils.MindMapNode;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* BuiltinMindMapGenerator 单元测试:会话分组、标题截断、应用场景、同级去重。
*/
class BuiltinMindMapGeneratorTest {
private final BuiltinMindMapGenerator generator = new BuiltinMindMapGenerator();
private static TaskEntity task() {
TaskEntity task = new TaskEntity();
task.setTaskNum("T9");
task.setTaskName("学习Java");
task.setTaskDescription("系统学习Java基础");
return task;
}
private static StudyReportsEntity report(String sessionNum, String content, Integer id) {
StudyReportsEntity report = new StudyReportsEntity();
report.setSessionNum(sessionNum);
report.setContent(content);
report.setId(id);
return report;
}
private static TaskApplicationEntity app(Integer id, String title, String description) {
TaskApplicationEntity application = new TaskApplicationEntity();
application.setId(id);
application.setTitle(title);
application.setDescription(description);
return application;
}
@Test
void isAvailable_alwaysTrue_andNameIsBuiltin() {
assertTrue(generator.isAvailable());
assertEquals("BUILTIN", generator.generatorName());
}
@Test
void generate_nullOrEmptyReports_returnsEmpty() {
assertTrue(generator.generate(task(), null, List.of(), null).isEmpty());
assertTrue(generator.generate(task(), List.of(), List.of(), null).isEmpty());
}
@Test
void generate_groupsBySession_withDatePrefixAndRootFallbackTitle() {
TaskEntity task = task();
task.setTaskName(null); // → 兜底"学习任务"
task.setTaskDescription("任务描述内容");
StudyReportsEntity withDate = report("S1", "会话一的学习内容", 1);
withDate.setCreatedTime(LocalDateTime.of(2024, 5, 6, 10, 30));
StudyReportsEntity noDate = report("S2", "会话二的学习内容", 2);
noDate.setCreatedTime(null);
MindMapNode root = generator.generate(task, List.of(withDate, noDate), List.of(), null).get();
assertEquals("学习任务", root.getTitle());
assertEquals("任务描述内容", root.getNotes());
assertEquals(2, root.getChildren().size());
// 日期前缀分支
MindMapNode dated = root.getChildren().stream()
.filter(n -> n.getTitle().startsWith("05-06"))
.findFirst().orElseThrow();
assertEquals("05-06 会话一的学习内容", dated.getTitle());
// createdTime 为 null → 无前缀
MindMapNode undated = root.getChildren().stream()
.filter(n -> !n.getTitle().startsWith("05-06"))
.findFirst().orElseThrow();
assertEquals("会话二的学习内容", undated.getTitle());
// 报告子节点:title / notes / sourceType / sourceId
MindMapNode reportNode = dated.getChildren().get(0);
assertEquals("会话一的学习内容", reportNode.getTitle());
assertEquals("会话一的学习内容", reportNode.getNotes());
assertEquals("REPORT", reportNode.getSourceType());
assertEquals(1, reportNode.getSourceId());
}
@Test
void generate_truncatesTitlesAt60CharsWithEllipsis() {
String exact60 = "".repeat(60);
String long70 = "".repeat(70);
StudyReportsEntity r1 = report("S1", exact60, 1);
StudyReportsEntity r2 = report("S1", long70, 2);
MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get();
// 会话标题取第一条报告,恰好 60 字不截断
MindMapNode session = root.getChildren().get(0);
assertEquals(exact60, session.getTitle());
assertEquals(exact60, session.getChildren().get(0).getTitle());
// 超过 60 字 → 截断 + 省略号
MindMapNode truncated = session.getChildren().get(1);
assertEquals("".repeat(60) + "", truncated.getTitle());
assertEquals(61, truncated.getTitle().length());
assertEquals(long70, truncated.getNotes());
assertEquals("REPORT", truncated.getSourceType());
assertEquals(2, truncated.getSourceId());
}
@Test
void generate_skipsNullOrBlankReportContent() {
StudyReportsEntity nullContent = report("S1", null, 1);
StudyReportsEntity blankContent = report("S1", " ", 2);
StudyReportsEntity valid = report("S1", "有效学习内容", 3);
MindMapNode root =
generator.generate(task(), List.of(nullContent, blankContent, valid), List.of(), null).get();
MindMapNode session = root.getChildren().get(0);
// 会话标题取第一条报告内容,null → truncate 返回 ""
assertEquals("", session.getTitle());
// null / 空白内容报告被跳过
assertEquals(1, session.getChildren().size());
assertEquals("有效学习内容", session.getChildren().get(0).getTitle());
}
@Test
void generate_applications_nullOrEmptyAddsNoBranch() {
MindMapNode withNull =
generator.generate(task(), List.of(report("S1", "内容", 1)), null, null).get();
assertTrue(withNull.getChildren().stream().noneMatch(n -> "应用场景".equals(n.getTitle())));
MindMapNode withEmpty =
generator.generate(task(), List.of(report("S1", "内容", 1)), List.of(), null).get();
assertTrue(withEmpty.getChildren().stream().noneMatch(n -> "应用场景".equals(n.getTitle())));
}
@Test
void generate_applications_skipsBlankTitle_nullDescriptionToEmptyNotes() {
TaskApplicationEntity nullTitle = app(1, null, "描述");
TaskApplicationEntity blankTitle = app(2, " ", "描述");
TaskApplicationEntity valid = app(3, "缓存设计", null);
MindMapNode root = generator.generate(task(),
List.of(report("S1", "内容", 9)), List.of(nullTitle, blankTitle, valid), null).get();
MindMapNode appNode = root.getChildren().stream()
.filter(n -> "应用场景".equals(n.getTitle()))
.findFirst().orElseThrow();
// title 为 null / 空白的应用被跳过
assertEquals(1, appNode.getChildren().size());
MindMapNode child = appNode.getChildren().get(0);
assertEquals("缓存设计", child.getTitle());
// description 为 null → notes 为空串
assertEquals("", child.getNotes());
assertEquals("APPLICATION", child.getSourceType());
assertEquals(3, child.getSourceId());
}
@Test
void generate_deduplicatesSameLevelTitles_keepsLongerNotes() {
// 两条报告标准化后同名:"重复" 与 "重复。" → 合并,notes 长者胜
StudyReportsEntity r1 = report("S1", "重复", 1);
StudyReportsEntity r2 = report("S1", "重复。", 2);
MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get();
MindMapNode session = root.getChildren().get(0);
assertEquals(1, session.getChildren().size());
MindMapNode merged = session.getChildren().get(0);
assertEquals("重复", merged.getTitle());
assertEquals("重复。", merged.getNotes());
}
@Test
void generate_deduplicatesSessionNodes_mergesChildren() {
// 两个会话首条报告内容相同 → 会话节点同名合并,报告子节点拼接
StudyReportsEntity r1 = report("S1", "相同会话内容", 1);
StudyReportsEntity r2 = report("S2", "相同会话内容", 2);
MindMapNode root = generator.generate(task(), List.of(r1, r2), List.of(), null).get();
assertEquals(1, root.getChildren().size());
MindMapNode session = root.getChildren().get(0);
assertEquals("相同会话内容", session.getTitle());
// 递归去重:拼接进来的两个同名报告节点再次合并为一个(notes 相同长度保留前者)
assertEquals(1, session.getChildren().size());
assertEquals("相同会话内容", session.getChildren().get(0).getTitle());
assertEquals("相同会话内容", session.getChildren().get(0).getNotes());
}
}
@@ -0,0 +1,92 @@
package com.guo.learningprogresstracker.service.impl;
import cn.dev33.satoken.SaManager;
import cn.dev33.satoken.context.SaTokenContext;
import cn.dev33.satoken.context.SaTokenContextForThreadLocal;
import cn.dev33.satoken.context.SaTokenContextForThreadLocalStorage;
import cn.dev33.satoken.context.model.SaRequest;
import cn.dev33.satoken.context.model.SaResponse;
import cn.dev33.satoken.context.model.SaStorage;
import cn.dev33.satoken.stp.StpUtil;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
/**
* LoginServiceImpl 单元测试:不启动 Spring,利用 Sa-Token 提供的
* ThreadLocal 上下文(SaTokenContextForThreadLocal)伪造 Request/Response/Storage
* 配合默认的内存版 SaTokenDao 覆盖 login/logout 两行逻辑。
*/
class LoginServiceImplTest {
private final LoginServiceImpl loginService = new LoginServiceImpl();
private final SaTokenContextForThreadLocal threadLocalContext = new SaTokenContextForThreadLocal();
private SaTokenContext previousContext;
@BeforeEach
void setUp() {
previousContext = SaManager.getSaTokenContext();
SaManager.setSaTokenContext(threadLocalContext);
SaRequest request = Mockito.mock(SaRequest.class);
SaResponse response = Mockito.mock(SaResponse.class);
SaTokenContextForThreadLocalStorage.setBox(request, response, new InMemoryStorage());
}
@AfterEach
void tearDown() {
SaTokenContextForThreadLocalStorage.clearBox();
SaManager.setSaTokenContext(previousContext);
}
/**
* 基于内存 Map 的 SaStorage 实现,供 Sa-Token 存取"本次会话刚创建的 token"标记。
*/
static class InMemoryStorage implements SaStorage {
private final Map<String, Object> data = new HashMap<>();
@Override
public SaStorage set(String key, Object value) {
data.put(key, value);
return this;
}
@Override
public Object get(String key) {
return data.get(key);
}
@Override
public SaStorage delete(String key) {
data.remove(key);
return this;
}
@Override
public Object getSource() {
return data;
}
}
@Test
void login_shouldReturnTokenValue() throws Exception {
String token = loginService.login("user-1");
assertNotNull(token, "登录成功后应返回 token");
assertEquals(token, StpUtil.getTokenInfo().getTokenValue());
}
@Test
void logout_shouldClearLoginState() throws Exception {
loginService.login("user-2");
loginService.logout();
assertFalse(StpUtil.isLogin(), "登出后不应再处于登录态");
}
}
@@ -0,0 +1,152 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.mapper.TasksMapper;
import com.guo.learningprogresstracker.mapper.UserPriorityWeightsMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* PriorityWeightsServiceImpl 单元测试:纯 Mockito。
* 覆盖 get/update 权重的全部分支:无记录默认、新建、更新并重算、校验失败等。
*/
@ExtendWith(MockitoExtension.class)
class PriorityWeightsServiceImplTest {
@InjectMocks
private PriorityWeightsServiceImpl priorityWeightsService;
@Mock
private UserPriorityWeightsMapper weightsMapper;
@Mock
private TasksMapper tasksMapper;
@Test
void getWeights_existing_shouldReturnStored() {
UserPriorityWeightsEntity stored = UserPriorityWeightsEntity.defaults();
when(weightsMapper.selectOne(any())).thenReturn(stored);
assertSame(stored, priorityWeightsService.getWeights());
}
@Test
void getWeights_missing_shouldReturnDefaults() {
when(weightsMapper.selectOne(any())).thenReturn(null);
UserPriorityWeightsEntity weights = priorityWeightsService.getWeights();
assertEquals(0.35, weights.getUrgencyWeight(), 0.0001);
assertEquals(0.25, weights.getImportanceWeight(), 0.0001);
assertEquals(0.20, weights.getContentDifficultyWeight(), 0.0001);
assertEquals(0.10, weights.getFutureValueWeight(), 0.0001);
assertEquals(0.10, weights.getSubjectivePriorityWeight(), 0.0001);
}
/**
* 无既有记录 → insert 新建,任务列表为空时不触发重算循环体
*/
@Test
void saveWeights_noExistingRecord_shouldInsert() throws Exception {
UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults();
when(weightsMapper.selectOne(any())).thenReturn(null);
when(tasksMapper.selectList(any())).thenReturn(List.of());
UserPriorityWeightsEntity result = priorityWeightsService.saveWeights(weights);
assertSame(weights, result);
verify(weightsMapper).insert(weights);
verify(weightsMapper, never()).updateById(any(UserPriorityWeightsEntity.class));
verify(tasksMapper, never()).updateById(any(TaskEntity.class));
}
/**
* 已有记录 → 复用其 id 更新,并用新权重重算全部任务(覆盖维度为 null 与非 null 的 orZero 分支)
*/
@Test
void saveWeights_existingRecord_shouldUpdateAndRecalculateAllTasks() throws Exception {
UserPriorityWeightsEntity existing = UserPriorityWeightsEntity.defaults();
existing.setId(7);
when(weightsMapper.selectOne(any())).thenReturn(existing);
TaskEntity allNullDims = new TaskEntity();
allNullDims.setId(1);
TaskEntity fullDims = new TaskEntity();
fullDims.setId(2);
fullDims.setUrgency(2);
fullDims.setImportance(3);
fullDims.setContentDifficulty(4);
fullDims.setFutureValue(5);
fullDims.setSubjectivePriority(1);
when(tasksMapper.selectList(any())).thenReturn(Arrays.asList(allNullDims, fullDims));
UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults();
UserPriorityWeightsEntity result = priorityWeightsService.saveWeights(weights);
assertSame(weights, result);
assertEquals(Integer.valueOf(7), result.getId());
verify(weightsMapper).updateById(weights);
verify(weightsMapper, never()).insert(any(UserPriorityWeightsEntity.class));
// 全 null 维度按 0 计算优先级
assertEquals(0.0, allNullDims.getCalculatedPriority(), 0.0001);
// 2*0.35 + 3*0.25 + 4*0.20 + 5*0.10 + 1*0.10 = 2.85
assertEquals(2.85, fullDims.getCalculatedPriority(), 0.0001);
verify(tasksMapper).updateById(allNullDims);
verify(tasksMapper).updateById(fullDims);
}
/**
* 权重超过 1 → 校验失败
*/
@Test
void saveWeights_weightAboveOne_shouldThrow() {
UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults();
weights.setUrgencyWeight(1.5);
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
() -> priorityWeightsService.saveWeights(weights));
assertEquals("权重必须在 0 到 1 之间", ex.getMessage());
}
/**
* 负权重 → 校验失败
*/
@Test
void saveWeights_negativeWeight_shouldThrow() {
UserPriorityWeightsEntity weights = UserPriorityWeightsEntity.defaults();
weights.setImportanceWeight(-0.1);
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
() -> priorityWeightsService.saveWeights(weights));
assertEquals("权重必须在 0 到 1 之间", ex.getMessage());
}
/**
* 全部权重为 null → orZero 兜底为 0,总和为 0 ≠ 1 → 抛出求和校验
*/
@Test
void saveWeights_allNullWeights_shouldThrowSumError() {
UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity();
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
() -> priorityWeightsService.saveWeights(weights));
assertTrue(ex.getMessage().startsWith("五项权重之和必须为 1,当前为 "));
verify(weightsMapper, never()).insert(any(UserPriorityWeightsEntity.class));
verify(weightsMapper, never()).updateById(any(UserPriorityWeightsEntity.class));
}
}
@@ -0,0 +1,120 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.TaskEntity;
import com.guo.learningprogresstracker.utils.MindMapNode;
import com.guo.learningprogresstracker.utils.MindMapTreeTool;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* RemoteAiMindMapClient 单元测试:mock AiServiceClient,验证降级与大纲解析。
*/
@ExtendWith(MockitoExtension.class)
class RemoteAiMindMapClientTest {
@Mock
private AiServiceClient aiServiceClient;
@InjectMocks
private RemoteAiMindMapClient client;
private static TaskEntity task() {
TaskEntity task = new TaskEntity();
task.setTaskNum("T1");
task.setTaskName("学习Java入门");
task.setTaskDescription("Java 基础学习描述");
return task;
}
private static StudyReportsEntity report(String content) {
StudyReportsEntity report = new StudyReportsEntity();
report.setSessionNum("S1");
report.setContent(content);
report.setId(1);
return report;
}
@Test
void isAvailable_followsAiConfiguration() {
when(aiServiceClient.isConfigured()).thenReturn(true);
assertTrue(client.isAvailable());
when(aiServiceClient.isConfigured()).thenReturn(false);
assertFalse(client.isAvailable());
}
@Test
void generatorName_isAi() {
assertEquals("AI", client.generatorName());
}
@Test
void generate_notAvailable_returnsEmpty() {
when(aiServiceClient.isConfigured()).thenReturn(false);
Optional<MindMapNode> result = client.generate(task(), List.of(report("内容")), List.of(), null);
assertTrue(result.isEmpty());
verify(aiServiceClient, never()).generateMindMap(any(), any(), any());
}
@Test
void generate_reportsAllBlank_returnsEmpty() {
when(aiServiceClient.isConfigured()).thenReturn(true);
StudyReportsEntity nullContent = report(null);
StudyReportsEntity blankContent = report(" ");
Optional<MindMapNode> result =
client.generate(task(), List.of(nullContent, blankContent), List.of(), null);
assertTrue(result.isEmpty());
verify(aiServiceClient, never()).generateMindMap(any(), any(), any());
}
@Test
void generate_aiReturnsEmpty_returnsEmpty() {
when(aiServiceClient.isConfigured()).thenReturn(true);
when(aiServiceClient.generateMindMap(any(), any(), any())).thenReturn(Optional.empty());
Optional<MindMapNode> result = client.generate(task(), List.of(report("内容")), List.of(), null);
assertTrue(result.isEmpty());
}
@Test
void generate_aiReturnsBlankOutline_returnsEmpty() {
when(aiServiceClient.isConfigured()).thenReturn(true);
when(aiServiceClient.generateMindMap(any(), any(), any())).thenReturn(Optional.of(" "));
Optional<MindMapNode> result = client.generate(task(), List.of(report("内容")), List.of(), null);
assertTrue(result.isEmpty());
}
@Test
void generate_success_parsesOutlineToTree() {
when(aiServiceClient.isConfigured()).thenReturn(true);
when(aiServiceClient.generateMindMap(any(), any(), any()))
.thenReturn(Optional.of("学习Java\n- 线程基础\n - 线程安全"));
Optional<MindMapNode> result =
client.generate(task(), List.of(report("报告内容")), List.of(), "前端已有大纲");
assertTrue(result.isPresent());
MindMapNode root = result.get();
assertEquals("学习Java", root.getTitle());
assertEquals(1, root.getChildren().size());
assertEquals("线程基础", root.getChildren().get(0).getTitle());
assertEquals("线程安全", root.getChildren().get(0).getChildren().get(0).getTitle());
assertEquals(3, MindMapTreeTool.countNodes(root));
assertEquals(3, MindMapTreeTool.maxDepth(root));
}
}
@@ -0,0 +1,108 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.StudyExpectationsEntity;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.mapper.StudyExpectationsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* StudyExpectationsServiceImpl 单元测试:纯 Mockito,不连数据库。
* 该服务为普通类构造注入两个 Mapper,直接 @InjectMocks 即可。
*/
@ExtendWith(MockitoExtension.class)
class StudyExpectationsServiceImplTest {
@InjectMocks
private StudyExpectationsServiceImpl studyExpectationsService;
@Mock
private StudyExpectationsMapper studyExpectationsMapper;
@Mock
private StudySessionsMapper studySessionsMapper;
/**
* 场景1:会话不存在 → 抛出,不写库
*/
@Test
void upsertExpectation_sessionMissing_shouldThrow() {
when(studySessionsMapper.exists(any())).thenReturn(false);
ErrorParameterException ex = assertThrows(ErrorParameterException.class,
() -> studyExpectationsService.upsertExpectation("NOT_EXIST", "预期"));
assertEquals("这次学习会话不存在或已结束", ex.getMessage());
verify(studyExpectationsMapper, never()).insert(any(StudyExpectationsEntity.class));
verify(studyExpectationsMapper, never()).updateById(any(StudyExpectationsEntity.class));
}
/**
* 场景2:尚无预期记录 → 插入新记录并返回
*/
@Test
void upsertExpectation_noExistingRecord_shouldInsert() throws Exception {
when(studySessionsMapper.exists(any())).thenReturn(true);
when(studyExpectationsMapper.selectOne(any())).thenReturn(null);
StudyExpectationsEntity result = studyExpectationsService.upsertExpectation("SESSION_A", "掌握微积分");
assertEquals("SESSION_A", result.getSessionNum());
assertEquals("掌握微积分", result.getDescription());
verify(studyExpectationsMapper).insert(result);
verify(studyExpectationsMapper, never()).updateById(any(StudyExpectationsEntity.class));
}
/**
* 场景3:已有预期记录 → 更新描述并返回原实体
*/
@Test
void upsertExpectation_existingRecord_shouldUpdate() throws Exception {
when(studySessionsMapper.exists(any())).thenReturn(true);
StudyExpectationsEntity existing = new StudyExpectationsEntity();
existing.setExpectationId(3);
existing.setSessionNum("SESSION_A");
existing.setDescription("旧预期");
when(studyExpectationsMapper.selectOne(any())).thenReturn(existing);
StudyExpectationsEntity result = studyExpectationsService.upsertExpectation("SESSION_A", "新预期");
assertSame(existing, result);
assertEquals("新预期", result.getDescription());
verify(studyExpectationsMapper).updateById(existing);
verify(studyExpectationsMapper, never()).insert(any(StudyExpectationsEntity.class));
}
/**
* 场景4:按会话编号查询 → 透传 mapper 结果
*/
@Test
void getBySessionNum_shouldReturnMapperResult() {
StudyExpectationsEntity entity = new StudyExpectationsEntity();
entity.setSessionNum("SESSION_A");
entity.setDescription("预期内容");
when(studyExpectationsMapper.selectOne(any())).thenReturn(entity);
StudyExpectationsEntity result = studyExpectationsService.getBySessionNum("SESSION_A");
assertSame(entity, result);
}
/**
* 场景5:按会话编号删除 → 委托 mapper.delete
*/
@Test
void deleteBySessionNum_shouldDelegateToDelete() {
studyExpectationsService.deleteBySessionNum("SESSION_A");
verify(studyExpectationsMapper).delete(any());
}
}
@@ -0,0 +1,126 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.guo.learningprogresstracker.dto.request.CreateFragmentsRequest;
import com.guo.learningprogresstracker.dto.request.UpdateFragmentsRequest;
import com.guo.learningprogresstracker.entity.StudyReportFragmentsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.exception.NotFindEntitiesException;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* StudyReportFragmentsServiceImpl 单元测试:继承 ServiceImpl
* 参考 StudySessionsServiceImplTest 的手法对 spy 打桩 save/updateById/getById/list
* 避免 baseMapper 为空引发 NPE。
*/
@ExtendWith(MockitoExtension.class)
class StudyReportFragmentsServiceImplTest {
@Spy
@InjectMocks
private StudyReportFragmentsServiceImpl studyReportFragmentsService;
@Mock
private StudySessionsMapper studySessionsMapper;
private CreateFragmentsRequest createRequest(String sessionNum, String content) {
CreateFragmentsRequest request = new CreateFragmentsRequest();
request.setSessionNum(sessionNum);
request.setContent(content);
return request;
}
/**
* 场景1:会话不存在 → 抛出,不落库
*/
@Test
void createFragments_sessionMissing_shouldThrow() {
// studySessionsMapper.selectOne 未打桩时默认返回 null,即查不到会话
NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class,
() -> studyReportFragmentsService.createFragments(createRequest("NOT_EXIST", "内容")));
assertEquals("这次学习会话不存在或已结束", ex.getMessage());
verify(studyReportFragmentsService, never()).save(any(StudyReportFragmentsEntity.class));
}
/**
* 场景2:会话存在 → 转换请求并保存残片
*/
@Test
void createFragments_success_shouldSaveEntity() throws Exception {
StudySessionsEntity session = new StudySessionsEntity();
session.setSessionNum("SESSION_A");
when(studySessionsMapper.selectOne(any())).thenReturn(session);
doReturn(true).when(studyReportFragmentsService).save(any(StudyReportFragmentsEntity.class));
studyReportFragmentsService.createFragments(createRequest("SESSION_A", "今天学了导数"));
verify(studyReportFragmentsService).save(argThat(entity ->
"SESSION_A".equals(entity.getSessionNum())
&& "今天学了导数".equals(entity.getContent())));
}
/**
* 场景3:残片不存在 → 抛出
*/
@Test
void updateFragments_missing_shouldThrow() {
doReturn(null).when(studyReportFragmentsService).getById(any());
NotFindEntitiesException ex = assertThrows(NotFindEntitiesException.class,
() -> studyReportFragmentsService.updateFragments(9, new UpdateFragmentsRequest()));
assertEquals("这条学习残片不存在或已被删除", ex.getMessage());
}
/**
* 场景4:残片存在 → 以指定 id 更新内容
*/
@Test
void updateFragments_success_shouldUpdateWithId() throws Exception {
StudyReportFragmentsEntity existing = new StudyReportFragmentsEntity();
existing.setId(9);
existing.setContent("旧内容");
doReturn(existing).when(studyReportFragmentsService).getById(any());
doReturn(true).when(studyReportFragmentsService).updateById(any(StudyReportFragmentsEntity.class));
UpdateFragmentsRequest request = new UpdateFragmentsRequest();
request.setContent("更新后的内容");
studyReportFragmentsService.updateFragments(9, request);
verify(studyReportFragmentsService).updateById(argThat(entity ->
Integer.valueOf(9).equals(entity.getId())
&& "更新后的内容".equals(entity.getContent())));
}
/**
* 场景5:按会话查询残片列表 → 透传 list 结果
*/
@Test
void getFragmentsBySession_shouldReturnList() {
StudyReportFragmentsEntity f1 = new StudyReportFragmentsEntity();
f1.setContent("残片1");
StudyReportFragmentsEntity f2 = new StudyReportFragmentsEntity();
f2.setContent("残片2");
doReturn(List.of(f1, f2)).when(studyReportFragmentsService).list(any(Wrapper.class));
List<StudyReportFragmentsEntity> result = studyReportFragmentsService.getFragmentsBySession("SESSION_A");
assertEquals(2, result.size());
assertEquals("残片1", result.get(0).getContent());
assertEquals("残片2", result.get(1).getContent());
}
}
@@ -4,9 +4,11 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.guo.learningprogresstracker.entity.StudyReportsEntity;
import com.guo.learningprogresstracker.entity.StudySessionsEntity;
import com.guo.learningprogresstracker.enums.StudySessionStateEnum;
import com.guo.learningprogresstracker.exception.ErrorParameterException;
import com.guo.learningprogresstracker.mapper.StudyReportFragmentsMapper;
import com.guo.learningprogresstracker.mapper.StudyReportsMapper;
import com.guo.learningprogresstracker.mapper.StudySessionsMapper;
import com.guo.learningprogresstracker.service.StudyExpectationsService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -17,6 +19,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Optional;
@@ -41,10 +44,15 @@ class StudySessionsServiceImplTest {
@Mock
private StudySessionsMapper studySessionsMapper;
@Mock
private StudyExpectationsService studyExpectationsService;
@BeforeEach
void setUp() {
// stub updateById,避免调用真实的 getBaseMapper()
doReturn(true).when(studySessionsService).updateById(any());
// stub removeById,避免调用真实的 getBaseMapper()
doReturn(true).when(studySessionsService).removeById(any(Serializable.class));
}
private StudySessionsEntity createSession(String state, int minutesAgo) {
@@ -210,4 +218,99 @@ class StudySessionsServiceImplTest {
verify(studySessionsService).updateById(session);
}
// ============ 误操作结束(零数据关闭) ============
/**
* 场景11:确认语正确且未产生残片 → 删除预期与会话,零数据关闭
*/
@Test
void abortStudySession_success_shouldDeleteEverything() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
session.setId(1);
mockGetOneOpt(session);
doReturn(0L).when(studyReportFragmentsMapper).selectCount(any());
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION);
verify(studyExpectationsService).deleteBySessionNum("SESSION_TEST");
verify(studySessionsService).removeById(1);
}
/**
* 场景12:确认语不正确 → 抛异常,不删除任何数据
*/
@Test
void abortStudySession_wrongConfirmation_shouldThrow() {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
mockGetOneOpt(session);
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
studySessionsService.abortStudySession("SESSION_TEST", "我不是确认语"));
assertEquals("确认语输入不正确,请核对后重新输入", ex.getMessage());
verify(studyExpectationsService, never()).deleteBySessionNum(any());
verify(studySessionsService, never()).removeById(any());
}
/**
* 场景13:确认语首尾空白应被忽略,仍视为正确
*/
@Test
void abortStudySession_confirmationWithSpaces_shouldSucceed() throws Exception {
StudySessionsEntity session = createSession(StudySessionStateEnum.PAUSED.name(), 5);
session.setId(2);
mockGetOneOpt(session);
doReturn(0L).when(studyReportFragmentsMapper).selectCount(any());
studySessionsService.abortStudySession("SESSION_TEST",
" " + StudySessionsServiceImpl.MISOPERATION_CONFIRMATION + " ");
verify(studyExpectationsService).deleteBySessionNum("SESSION_TEST");
verify(studySessionsService).removeById(2);
}
/**
* 场景14:已产生学习残片 → 抛异常,不能误操作结束
*/
@Test
void abortStudySession_hasFragments_shouldThrow() {
StudySessionsEntity session = createSession(StudySessionStateEnum.ONGOING.name(), 5);
mockGetOneOpt(session);
doReturn(1L).when(studyReportFragmentsMapper).selectCount(any());
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
assertEquals("这次学习已经产生了学习残片,不能按误操作结束了哦", ex.getMessage());
verify(studyExpectationsService, never()).deleteBySessionNum(any());
verify(studySessionsService, never()).removeById(any());
}
/**
* 场景15:会话已结束 → 抛异常
*/
@Test
void abortStudySession_endedSession_shouldThrow() {
StudySessionsEntity session = createSession(StudySessionStateEnum.ENDED.name(), 5);
mockGetOneOpt(session);
ErrorParameterException ex = assertThrows(ErrorParameterException.class, () ->
studySessionsService.abortStudySession("SESSION_TEST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
assertEquals("这次学习会话已经结束,不能再误操作结束了", ex.getMessage());
verify(studySessionsService, never()).removeById(any());
}
/**
* 场景16:会话不存在 → 抛异常
*/
@Test
void abortStudySession_sessionNotFound_shouldThrow() {
doReturn(Optional.empty()).when(studySessionsService).getOneOpt(any(Wrapper.class));
assertThrows(ErrorParameterException.class, () ->
studySessionsService.abortStudySession("NOT_EXIST", StudySessionsServiceImpl.MISOPERATION_CONFIRMATION));
}
}
@@ -0,0 +1,36 @@
package com.guo.learningprogresstracker.service.impl;
import com.guo.learningprogresstracker.entity.TestTableEntity;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
/**
* TestTableServiceImpl 单元测试:服务仅一个透传查询,覆盖 getById 透传路径。
*/
@ExtendWith(MockitoExtension.class)
class TestTableServiceImplTest {
@Spy
@InjectMocks
private TestTableServiceImpl testTableService;
@Test
void getTestEntityById_shouldReturnMapperResult() {
TestTableEntity entity = new TestTableEntity();
entity.setId(1);
entity.setIdName("测试记录");
doReturn(entity).when(testTableService).getById(any());
TestTableEntity result = testTableService.getTestEntityById("1");
assertSame(entity, result);
assertEquals("测试记录", result.getIdName());
}
}
@@ -0,0 +1,61 @@
package com.guo.learningprogresstracker.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.guo.learningprogresstracker.entity.UserEntity;
import com.guo.learningprogresstracker.exception.AppException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mindrot.jbcrypt.BCrypt;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
/**
* UserServiceImpl 单元测试:覆盖认证的用户不存在、密码错误、密码正确三条路径。
*/
@ExtendWith(MockitoExtension.class)
class UserServiceImplTest {
@Spy
@InjectMocks
private UserServiceImpl userService;
private UserEntity userWithPassword(String rawPassword) {
UserEntity user = new UserEntity();
user.setId("user-1");
user.setUserName("admin");
user.setUserPassword(BCrypt.hashpw(rawPassword, BCrypt.gensalt()));
return user;
}
@Test
void authenticate_userMissing_shouldThrow() {
doReturn(Optional.empty()).when(userService).getOneOpt(any(Wrapper.class));
AppException ex = assertThrows(AppException.class, () -> userService.authenticate("ghost", "pw"));
assertEquals("账号或密码错误!", ex.getMessage());
}
@Test
void authenticate_wrongPassword_shouldThrow() {
doReturn(Optional.of(userWithPassword("correct-pw"))).when(userService).getOneOpt(any(Wrapper.class));
AppException ex = assertThrows(AppException.class, () -> userService.authenticate("admin", "wrong-pw"));
assertEquals("账号或密码错误!", ex.getMessage());
}
@Test
void authenticate_correctPassword_shouldReturnUserId() throws Exception {
doReturn(Optional.of(userWithPassword("correct-pw"))).when(userService).getOneOpt(any(Wrapper.class));
assertEquals("user-1", userService.authenticate("admin", "correct-pw"));
}
}
@@ -0,0 +1,124 @@
package com.guo.learningprogresstracker.support;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.List;
import java.util.Map;
import java.util.zip.GZIPOutputStream;
/**
* 测试用本地 HTTP 服务器(JDK 内置 HttpServer),用于覆盖 TitleFetcher / AiServiceClient 等网络代码。
* 每个测试方法独立启停,端口随机,路由按注册顺序匹配(前缀匹配)。
*/
public class TestHttpServer {
private HttpServer server;
private final List<Route> routes = new ArrayList<>();
/** 请求头按 "METHOD path" 记录(key 小写化),同名后续请求覆盖。 */
private final Map<String, Map<String, String>> requestHeadersByPath = new LinkedHashMap<>();
private final Map<String, String> lastPaths = new LinkedHashMap<>();
private final Map<String, String> lastQueries = new LinkedHashMap<>();
private String lastRequestKey;
private record Route(String method, String pathPrefix, int status, byte[] body,
Map<String, String> headers) {
}
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/", exchange -> {
String path = exchange.getRequestURI().getPath();
String method = exchange.getRequestMethod();
Map<String, String> lowercaseHeaders = new LinkedHashMap<>();
exchange.getRequestHeaders().forEach((k, v) -> lowercaseHeaders.put(k.toLowerCase(Locale.ROOT), String.join(",", v)));
lastRequestKey = method + " " + path;
requestHeadersByPath.put(lastRequestKey, lowercaseHeaders);
lastPaths.put(method + " " + path, path);
lastQueries.put(method + " " + path,
exchange.getRequestURI().getQuery() == null ? "" : exchange.getRequestURI().getQuery());
for (Route route : routes) {
if (route.method.equals(method) && path.startsWith(route.pathPrefix)) {
if (exchange.getRequestHeaders().getFirst("Content-Length") != null) {
exchange.getRequestBody().readAllBytes();
}
byte[] body = route.body();
Map<String, String> headers = new LinkedHashMap<>(route.headers());
boolean gzip = "gzip".equals(headers.remove("Content-Encoding-Test"));
if (gzip) {
headers.put("Content-Encoding", "gzip");
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
try (GZIPOutputStream gz = new GZIPOutputStream(bos)) {
gz.write(body);
}
body = bos.toByteArray();
}
headers.forEach(exchange.getResponseHeaders()::set);
if (route.status() >= 300 && route.status() < 400) {
exchange.getResponseHeaders().set("Location", headers.getOrDefault("Location", "/redirected"));
}
exchange.sendResponseHeaders(route.status(), body.length == 0 ? -1 : body.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
return;
}
}
exchange.sendResponseHeaders(404, -1);
});
server.start();
}
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
}
}
/** 注册路由;body 为响应体。 */
public void route(String method, String pathPrefix, int status, String body) {
route(method, pathPrefix, status, body, Map.of());
}
/** 注册路由;headers 里 key=Content-Encoding-Test,value=gzip 表示用 gzip 压缩响应体。 */
public void route(String method, String pathPrefix, int status, String body, Map<String, String> headers) {
routes.add(new Route(method, pathPrefix, status, body.getBytes(StandardCharsets.UTF_8), headers));
}
/** 服务根地址,如 http://localhost:12345 */
public String baseUrl() {
return "http://localhost:" + server.getAddress().getPort();
}
/** 最近一次请求的指定请求头值(不区分大小写)。 */
public String lastRequestHeader(String name) {
Map<String, String> headers = requestHeadersByPath.get(lastRequestKey);
return headers == null ? null : headers.get(name.toLowerCase(Locale.ROOT));
}
/** 最近一次请求是否包含指定请求头(不区分大小写)。 */
public boolean hasRequestHeader(String name) {
return lastRequestHeader(name) != null;
}
/** 指定 "METHOD path" 的请求头值(不区分大小写;该路径尚未请求过返回 null)。 */
public String requestHeader(String method, String path, String name) {
Map<String, String> headers = requestHeadersByPath.get(method + " " + path);
return headers == null ? null : headers.get(name.toLowerCase(Locale.ROOT));
}
/** 最近一次匹配路由的请求查询串。 */
public String lastQuery(String method, String path) {
return lastQueries.get(method + " " + path);
}
}
@@ -0,0 +1,80 @@
package com.guo.learningprogresstracker.support;
import com.sun.net.httpserver.HttpsConfigurator;
import com.sun.net.httpserver.HttpsServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.util.ArrayList;
import java.util.List;
/**
* 测试用本地 HTTPS 服务器,使用 src/test/resources/test-keystore.p12 中的自签名证书。
* 客户端(TitleFetcher 的信任所有证书逻辑)与之完成真实 TLS 握手,可覆盖信任管理器代码路径。
*/
public class TestHttpsServer {
private HttpsServer server;
private final List<Route> routes = new ArrayList<>();
private record Route(String pathPrefix, int status, byte[] body, String contentType) {
}
@BeforeEach
void startServer() throws Exception {
KeyStore ks = KeyStore.getInstance("PKCS12");
try (InputStream in = getClass().getResourceAsStream("/test-keystore.p12")) {
ks.load(in, "changeit".toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(ks, "changeit".toCharArray());
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(kmf.getKeyManagers(), null, null);
server = HttpsServer.create(new InetSocketAddress("localhost", 0), 0);
server.setHttpsConfigurator(new HttpsConfigurator(sslContext));
server.createContext("/", exchange -> {
String path = exchange.getRequestURI().getPath();
for (Route route : routes) {
if (path.startsWith(route.pathPrefix())) {
exchange.getResponseHeaders().set("Content-Type", route.contentType());
exchange.sendResponseHeaders(route.status(), route.body().length == 0 ? -1 : route.body().length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(route.body());
}
return;
}
}
exchange.sendResponseHeaders(404, -1);
});
server.start();
}
@AfterEach
void stopServer() {
if (server != null) {
server.stop(0);
}
}
public void route(String pathPrefix, int status, String body, String contentType) {
routes.add(new Route(pathPrefix, status, body.getBytes(StandardCharsets.UTF_8), contentType));
}
public void html(String pathPrefix, String title) {
route(pathPrefix, 200, "<html><head><title>" + title + "</title></head><body>hi</body></html>", "text/html; charset=utf-8");
}
/** 服务 HTTPS 根地址,如 https://localhost:12345 */
public String baseUrl() {
return "https://localhost:" + server.getAddress().getPort();
}
}
@@ -0,0 +1,38 @@
package com.guo.learningprogresstracker.utils;
import com.guo.learningprogresstracker.dto.PriorityDto;
import com.guo.learningprogresstracker.entity.UserPriorityWeightsEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatedPriorityToolTest {
private PriorityDto dto(int urgency, int importance, int difficulty, int future, int subjective) {
PriorityDto dto = new PriorityDto();
dto.setUrgency(urgency);
dto.setImportance(importance);
dto.setContentDifficulty(difficulty);
dto.setFutureValue(future);
dto.setSubjectivePriority(subjective);
return dto;
}
@Test
void calculatedPriority_withDefaultWeights() {
// 默认权重:10*0.35 + 8*0.25 + 5*0.20 + 7*0.10 + 9*0.10 = 8.1
assertEquals(8.1, CalculatedPriorityTool.calculatedPriority(dto(10, 8, 5, 7, 9)), 1e-9);
}
@Test
void calculatedPriority_withCustomWeights() {
UserPriorityWeightsEntity weights = new UserPriorityWeightsEntity();
weights.setUrgencyWeight(1.0);
weights.setImportanceWeight(1.0);
weights.setContentDifficultyWeight(1.0);
weights.setFutureValueWeight(1.0);
weights.setSubjectivePriorityWeight(1.0);
// 权重全为 1 时等于各维度求和:10+8+5+7+9 = 39
assertEquals(39.0, CalculatedPriorityTool.calculatedPriority(dto(10, 8, 5, 7, 9), weights), 1e-9);
}
}
@@ -0,0 +1,27 @@
package com.guo.learningprogresstracker.utils;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import static org.junit.jupiter.api.Assertions.assertTrue;
class GenerateNumToolTest {
@Test
void generateNum_defaultSeparator_prefixDateSequence() {
String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String code = GenerateNumTool.generateNum("ORD");
assertTrue(code.startsWith("ORD-" + today + "-"), "实际值: " + code);
// 序列为毫秒时间戳对 1_000_000 取模,最多 6 位数字
assertTrue(code.matches("ORD-" + today + "-\\d{1,6}"), "实际值: " + code);
}
@Test
void generateNum_customSeparator_prefixDateSequence() {
String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
String code = GenerateNumTool.generateNum("BIZ", "_");
assertTrue(code.matches("BIZ_" + today + "_\\d{1,6}"), "实际值: " + code);
}
}
@@ -0,0 +1,155 @@
package com.guo.learningprogresstracker.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MindMapNodeTest {
@Test
void noArgConstructor_leavesFieldsNull() {
MindMapNode n = new MindMapNode();
assertNull(n.getTitle());
assertNull(n.getNotes());
assertNull(n.getSourceType());
assertNull(n.getSourceId());
assertNull(n.getChildren());
}
@Test
void titleConstructor_setsEmptyNotesAndMutableChildren() {
MindMapNode n = new MindMapNode("标题");
assertEquals("标题", n.getTitle());
assertEquals("", n.getNotes());
assertNotNull(n.getChildren());
assertTrue(n.getChildren().isEmpty());
}
@Test
void toMap_nullScalarsBecomeEmptyString_andOptionalKeysOmitted() {
Map<String, Object> map = new MindMapNode().toMap();
assertEquals("", map.get("title"));
assertEquals("", map.get("notes"));
assertFalse(map.containsKey("sourceType"));
assertFalse(map.containsKey("sourceId"));
assertEquals(List.of(), map.get("children"));
}
@Test
@SuppressWarnings("unchecked")
void toMap_withScalarsAndNestedChildren() {
MindMapNode n = new MindMapNode("");
n.setNotes("备注");
n.setSourceType("REPORT");
n.setSourceId(9);
n.setChildren(new ArrayList<>(List.of(new MindMapNode(""))));
Map<String, Object> map = n.toMap();
assertEquals("", map.get("title"));
assertEquals("备注", map.get("notes"));
assertEquals("REPORT", map.get("sourceType"));
assertEquals(Integer.valueOf(9), map.get("sourceId"));
List<Map<String, Object>> children = (List<Map<String, Object>>) map.get("children");
assertEquals(1, children.size());
assertEquals("", children.get(0).get("title"));
}
@Test
void fromMap_supportsBothChildrenAndNodesKeys() {
Map<String, Object> viaChildren = Map.of(
"title", "",
"children", List.of(Map.of("title", "子1")));
MindMapNode a = MindMapNode.fromMap(viaChildren);
assertEquals("", a.getTitle());
assertEquals("子1", a.getChildren().get(0).getTitle());
Map<String, Object> viaNodes = Map.of(
"title", "",
"nodes", List.of(Map.of("title", "子2")));
MindMapNode b = MindMapNode.fromMap(viaNodes);
assertEquals("子2", b.getChildren().get(0).getTitle());
}
@Test
void fromMap_convertsNumericSourceId_andDefaults() {
Map<String, Object> map = new HashMap<>();
map.put("title", "t");
map.put("notes", "笔记");
map.put("sourceType", "TASK");
map.put("sourceId", 42L); // Number 统一转 Integer
MindMapNode n = MindMapNode.fromMap(map);
assertEquals("t", n.getTitle());
assertEquals("笔记", n.getNotes());
assertEquals("TASK", n.getSourceType());
assertEquals(Integer.valueOf(42), n.getSourceId());
assertTrue(n.getChildren().isEmpty()); // 无 children/nodes key
map.put("sourceId", null); // 显式 null 不写入
assertNull(MindMapNode.fromMap(map).getSourceId());
}
@Test
void fromMap_skipsNonMapEntriesInChildList() {
Map<String, Object> map = Map.of(
"title", "",
"children", List.of("文本", Map.of("title", "有效"), 42));
MindMapNode n = MindMapNode.fromMap(map);
assertEquals(1, n.getChildren().size());
assertEquals("有效", n.getChildren().get(0).getTitle());
}
@Test
void toMapFromMapRoundTrip_keepsFields() {
MindMapNode child = new MindMapNode("");
child.setNotes("cn");
MindMapNode root = new MindMapNode("");
root.setNotes("rn");
root.setSourceType("REPORT");
root.setSourceId(3);
root.setChildren(new ArrayList<>(List.of(child)));
MindMapNode rebuilt = MindMapNode.fromMap(root.toMap());
assertEquals("", rebuilt.getTitle());
assertEquals("rn", rebuilt.getNotes());
assertEquals("REPORT", rebuilt.getSourceType());
assertEquals(Integer.valueOf(3), rebuilt.getSourceId());
assertEquals("", rebuilt.getChildren().get(0).getTitle());
assertEquals("cn", rebuilt.getChildren().get(0).getNotes());
}
@Test
void fromJson_parsesValidJson_andFallsBackOnInvalid() {
MindMapNode ok = MindMapNode.fromJson(
"{\"title\":\"\",\"notes\":\"n\",\"children\":[{\"title\":\"\"}]}",
new ObjectMapper());
assertEquals("", ok.getTitle());
assertEquals("n", ok.getNotes());
assertEquals("", ok.getChildren().get(0).getTitle());
assertEquals("解析失败", MindMapNode.fromJson("not json", new ObjectMapper()).getTitle());
assertEquals("解析失败", MindMapNode.fromJson(null, new ObjectMapper()).getTitle());
}
@Test
void chainedSetters_returnSameInstance() {
MindMapNode n = new MindMapNode();
assertSame(n, n.setTitle("t"));
assertSame(n, n.setNotes("n"));
assertSame(n, n.setSourceType("s"));
assertSame(n, n.setSourceId(1));
assertSame(n, n.setChildren(new ArrayList<>()));
}
}
@@ -0,0 +1,370 @@
package com.guo.learningprogresstracker.utils;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class MindMapTreeToolTest {
/** 构建带可变 children 列表的节点 */
private static MindMapNode node(String title, MindMapNode... children) {
MindMapNode n = new MindMapNode(title);
List<MindMapNode> list = new ArrayList<>();
Collections.addAll(list, children);
n.setChildren(list);
return n;
}
private static List<String> titles(List<MindMapNode> nodes) {
return nodes.stream().map(MindMapNode::getTitle).toList();
}
// ============ 私有构造器 ============
@Test
void privateConstructor_onlyForUtilityUsage() throws Exception {
Constructor<MindMapTreeTool> ctor = MindMapTreeTool.class.getDeclaredConstructor();
ctor.setAccessible(true);
assertNotNull(ctor.newInstance());
}
// ============ parseOutline ============
@Test
void parseOutline_nullBlankOrWhitespaceOnly_returnsUnnamed() {
assertEquals("未命名", MindMapTreeTool.parseOutline(null).getTitle());
assertEquals("未命名", MindMapTreeTool.parseOutline("").getTitle());
assertEquals("未命名", MindMapTreeTool.parseOutline(" \n\t ").getTitle());
// 全部是空白行时过滤后为空,同样返回"未命名"
assertEquals("未命名", MindMapTreeTool.parseOutline(" \n\t\t\n").getTitle());
}
@Test
void parseOutline_indentAndMarkers_buildHierarchyWithBacktracking() {
String outline = String.join("\n",
"项目导图",
"- 分支A",
" * 子A1",
" + 孙A1a",
"- 分支B",
"1. 分支C",
"* 1. 组合标记");
MindMapNode root = MindMapTreeTool.parseOutline(outline);
assertEquals("项目导图", root.getTitle());
assertEquals(List.of("分支A", "分支B", "分支C", "组合标记"), titles(root.getChildren()));
assertEquals(List.of("子A1"), titles(root.getChildren().get(0).getChildren()));
assertEquals(List.of("孙A1a"), titles(root.getChildren().get(0).getChildren().get(0).getChildren()));
// 深层节点被解析时均被显式挂上 children 列表
assertNotNull(root.getChildren().get(0).getChildren().get(0).getChildren());
}
@Test
void parseOutline_tabIndent_levels() {
String outline = String.join("\n",
"",
"\t一级A",
"\t\t二级A1",
"\t一级B");
MindMapNode root = MindMapTreeTool.parseOutline(outline);
assertEquals(List.of("一级A", "一级B"), titles(root.getChildren()));
assertEquals(List.of("二级A1"), titles(root.getChildren().get(0).getChildren()));
}
@Test
void parseOutline_hashLevels_andPopBack() {
String outline = String.join("\n",
"",
"# 一级",
"## 二级",
"### 三级",
"## 二级B");
MindMapNode root = MindMapTreeTool.parseOutline(outline);
// 实际弹栈语义:## 二级B 出栈到 level<2 时父级是一级
assertEquals(List.of("一级"), titles(root.getChildren()));
assertEquals(List.of("二级", "二级B"), titles(root.getChildren().get(0).getChildren()));
assertEquals(List.of("三级"), titles(root.getChildren().get(0).getChildren().get(0).getChildren()));
}
@Test
void parseOutline_stripsTrailingWhitespace_andRootStrip() {
String outline = " 根标题 \r\n - 节点A \t \r\n";
MindMapNode root = MindMapTreeTool.parseOutline(outline);
assertEquals("根标题", root.getTitle());
assertEquals(List.of("节点A"), titles(root.getChildren()));
}
// ============ toOutline / toFullOutline ============
@Test
void toOutline_singleLevelChildren() {
MindMapNode root = node("Root", node("A"), node("B"));
assertEquals(" - A\n - B\n", MindMapTreeTool.toOutline(root));
}
@Test
void toOutline_nullTitleAndNoChildren() {
MindMapNode leaf = new MindMapNode(); // children 为 null
assertEquals("", MindMapTreeTool.toOutline(leaf));
MindMapNode root = new MindMapNode();
root.setChildren(new ArrayList<>(List.of(new MindMapNode())));
assertEquals(" - \n", MindMapTreeTool.toOutline(root));
}
@Test
void toOutline_multiLevel_documentsCurrentDuplicationBehavior() {
// 现状记录(生产缺陷,见测试报告):level 0 的二次遍历会把孙子层重复输出一次
MindMapNode root = node("Root", node("A", node("A1")));
assertEquals(" - A\n - A1\n - A1\n", MindMapTreeTool.toOutline(root));
}
@Test
void toFullOutline_rootTitleThenIndentedChildren() {
MindMapNode root = node("Root", node("A", node("A1")), node("B"));
assertEquals("Root\n - A\n - A1\n - B\n", MindMapTreeTool.toFullOutline(root));
}
@Test
void toFullOutline_nullTitleRoot() {
assertEquals("\n", MindMapTreeTool.toFullOutline(new MindMapNode()));
}
// ============ flatten / countNodes / maxDepth ============
@Test
void flatten_countNodes_maxDepth() {
MindMapNode leaf = new MindMapNode();
leaf.setChildren(null); // 叶子:children 为 null
MindMapNode empty = new MindMapNode("e"); // children 为空列表
MindMapNode root = node("r", node("a", node("a1")), node("b"));
assertEquals(1, MindMapTreeTool.maxDepth(leaf));
assertEquals(1, MindMapTreeTool.maxDepth(empty));
assertEquals(3, MindMapTreeTool.maxDepth(root));
assertEquals(List.of("r", "a", "a1", "b"), titles(MindMapTreeTool.flatten(root)));
assertEquals(4, MindMapTreeTool.countNodes(root));
assertEquals(1, MindMapTreeTool.countNodes(leaf));
}
// ============ extractSubtree ============
@Test
void extractSubtree_byFullPath_returnsDeepCopy() {
MindMapNode a1 = node("A1");
a1.setNotes("笔记");
a1.setSourceType("REPORT");
a1.setSourceId(7);
a1.setChildren(new ArrayList<>(List.of(node("A1子"))));
MindMapNode root = node("Root", node("A", a1, node("A2")), node("B"));
MindMapNode sub = MindMapTreeTool.extractSubtree(root, "Root / A / A1");
assertNotNull(sub);
assertEquals("A1", sub.getTitle());
assertEquals("笔记", sub.getNotes());
assertEquals("REPORT", sub.getSourceType());
assertEquals(Integer.valueOf(7), sub.getSourceId());
assertEquals(List.of("A1子"), titles(sub.getChildren()));
assertNotSame(a1, sub);
assertNotSame(a1.getChildren().get(0), sub.getChildren().get(0));
// 修改副本不影响原树
sub.getChildren().clear();
assertEquals(1, a1.getChildren().size());
}
@Test
void extractSubtree_withoutRootSegment_andNormalizedMatching() {
MindMapNode root = node("复习计划", node("Java 核心(进阶)", node("并发编程")));
// 大小写与标点差异通过 normalizeForMerge 匹配
MindMapNode sub = MindMapTreeTool.extractSubtree(root, "java核心进阶 / 并发编程");
assertNotNull(sub);
assertEquals("并发编程", sub.getTitle());
// 不含根标题:第一段直接在根 children 中查找
MindMapNode sub2 = MindMapTreeTool.extractSubtree(root, "Java 核心(进阶)");
assertNotNull(sub2);
assertEquals("Java 核心(进阶)", sub2.getTitle());
// 空路径段被跳过
MindMapNode sub3 = MindMapTreeTool.extractSubtree(root, "复习计划 // Java 核心(进阶)");
assertNotNull(sub3);
assertEquals("Java 核心(进阶)", sub3.getTitle());
}
@Test
void extractSubtree_nullOrMismatchedPathReturnsNull() {
MindMapNode root = node("Root", node("A", node("A1")), new MindMapNode());
assertNull(MindMapTreeTool.extractSubtree(null, "Root"));
assertNull(MindMapTreeTool.extractSubtree(root, null));
assertNull(MindMapTreeTool.extractSubtree(root, " "));
assertNull(MindMapTreeTool.extractSubtree(root, "Root / X")); // 中途失配
assertNull(MindMapTreeTool.extractSubtree(root, "Root / A / A1 / 更深")); // 末端失配
}
// ============ findClosestNode ============
@Test
void findClosestNode_titleMatch() {
MindMapNode root = node("Root", node("Java 并发"), node("完全无关"));
MindMapNode best = MindMapTreeTool.findClosestNode(root, "java并发");
assertNotNull(best);
assertEquals("Java 并发", best.getTitle());
}
@Test
void findClosestNode_notesCountWithHalfWeight() {
MindMapNode hit = node("完全无关");
hit.setNotes("数据库索引优化详解");
MindMapNode root = node("Root", hit, node("另一个"));
MindMapNode best = MindMapTreeTool.findClosestNode(root, "数据库索引优化");
assertNotNull(best);
assertEquals("完全无关", best.getTitle());
}
@Test
void findClosestNode_lowScoresOrInvalidInputsReturnNull() {
MindMapNode root = node("Root", new MindMapNode(), node(" "), node("abcd"));
assertNull(MindMapTreeTool.findClosestNode(root, "zzzz")); // 全部低于阈值
assertNull(MindMapTreeTool.findClosestNode(null, "x"));
assertNull(MindMapTreeTool.findClosestNode(root, null));
assertNull(MindMapTreeTool.findClosestNode(root, " "));
}
// ============ getPath ============
@Test
void getPath_hitAndMissAndNulls() {
MindMapNode root = node("Root", node("A"), node("B", node("C")));
assertEquals("Root / B / C", MindMapTreeTool.getPath(root, "C"));
assertEquals("Root", MindMapTreeTool.getPath(root, "ROOT")); // normalize 后命中根
assertEquals("", MindMapTreeTool.getPath(root, "不存在"));
assertEquals("", MindMapTreeTool.getPath(null, "x"));
assertEquals("", MindMapTreeTool.getPath(root, null));
}
@Test
void getPath_handlesNullTitlesAlongPath() {
MindMapNode root = node("Root", new MindMapNode());
assertEquals("Root", MindMapTreeTool.getPath(root, "Root"));
assertEquals("", MindMapTreeTool.getPath(root, "不在树中"));
}
// ============ similarityScore ============
@Test
void similarityScore_variousCases() {
assertEquals(1.0, MindMapTreeTool.similarityScore("Java 并发", "java并发"), 1e-9);
assertEquals(0.9, MindMapTreeTool.similarityScore("java concurrency guide", "concurrency"), 1e-9);
// bigram Jaccard{ab,bc,cd} 与 {ab,bc,ce} 交 2 / 并 4
assertEquals(0.5, MindMapTreeTool.similarityScore("abcd", "abce"), 1e-9);
assertEquals(0.0, MindMapTreeTool.similarityScore("", "x"), 1e-9);
assertEquals(0.0, MindMapTreeTool.similarityScore(null, "x"), 1e-9);
assertEquals(0.0, MindMapTreeTool.similarityScore("x", null), 1e-9);
assertEquals(0.0, MindMapTreeTool.similarityScore("a", "b"), 1e-9); // 双方 bigram 均为空
assertEquals(0.9, MindMapTreeTool.similarityScore("a", "ab"), 1e-9); // 子串匹配优先于 bigram
}
// ============ mergeTrees ============
@Test
void mergeTrees_nullSideReturnsOther() {
MindMapNode oldTree = node("");
MindMapNode newTree = node("");
assertSame(newTree, MindMapTreeTool.mergeTrees(null, newTree));
assertSame(oldTree, MindMapTreeTool.mergeTrees(oldTree, null));
}
@Test
void mergeTrees_keepsOldTitlesAndAppendsOldOnlyNodes() {
MindMapNode oldChild = node("任务,一");
oldChild.setNotes("旧备注");
oldChild.setChildren(new ArrayList<>(List.of(node("旧子"))));
MindMapNode oldTree = node("", oldChild, node("旧独有"));
MindMapNode newChild = node("任务一。");
newChild.setChildren(new ArrayList<>(List.of(node("新子"))));
MindMapNode newTree = node("新根", newChild, node("新增"));
MindMapNode merged = MindMapTreeTool.mergeTrees(oldTree, newTree);
assertEquals("", merged.getTitle()); // 保留旧树根标题
assertEquals(List.of("任务,一", "新增", "旧独有"), titles(merged.getChildren()));
MindMapNode kept = merged.getChildren().get(0);
assertEquals("旧备注", kept.getNotes());
assertEquals(List.of("新子", "旧子"), titles(kept.getChildren()));
assertNotSame(oldChild, kept);
assertSame(newTree.getChildren().get(1), merged.getChildren().get(1)); // 新节点直接复用
assertSame(oldTree.getChildren().get(1), merged.getChildren().get(2)); // 旧独有节点直接复用
}
@Test
void mergeTrees_nullChildrenLists() {
// 旧 children 为 null:新 children 全部保留
MindMapNode oldNoKids = new MindMapNode();
oldNoKids.setChildren(null);
MindMapNode newWithKids = node("", node("k1"));
MindMapNode merged = MindMapTreeTool.mergeTrees(oldNoKids, newWithKids);
assertNull(merged.getTitle());
assertEquals(List.of("k1"), titles(merged.getChildren()));
// 新 children 为 null:旧 children 原样保留
MindMapNode oldWithKids = node("", node("k2"));
MindMapNode newNoKids = new MindMapNode("新2");
newNoKids.setChildren(null);
MindMapNode merged2 = MindMapTreeTool.mergeTrees(oldWithKids, newNoKids);
assertEquals(List.of("k2"), titles(merged2.getChildren()));
assertSame(oldWithKids.getChildren().get(0), merged2.getChildren().get(0));
}
// ============ toJson / fromJson ============
@Test
void toJson_serializesNodeMapJson() {
MindMapNode n = node("", node(""));
n.setNotes("备注");
String json = MindMapTreeTool.toJson(n, new ObjectMapper());
assertTrue(json.contains("\"title\":\"\""));
assertTrue(json.contains("\"notes\":\"备注\""));
assertTrue(json.contains("\"title\":\"\""));
assertTrue(json.contains("\"children\":[]"));
}
@Test
void toJson_serializationFailureReturnsFallback() throws Exception {
ObjectMapper failing = mock(ObjectMapper.class);
when(failing.writeValueAsString(any())).thenThrow(new RuntimeException("boom"));
assertEquals("{\"title\":\"序列化失败\"}", MindMapTreeTool.toJson(node("x"), failing));
}
@Test
void fromJson_parsesValidJson_andFallsBackOnInvalid() {
MindMapNode ok = MindMapTreeTool.fromJson(
"{\"title\":\"\",\"children\":[{\"title\":\"\",\"nodes\":[]}]}",
new ObjectMapper());
assertEquals("", ok.getTitle());
assertEquals("", ok.getChildren().get(0).getTitle());
assertEquals("解析失败", MindMapTreeTool.fromJson("not json", new ObjectMapper()).getTitle());
assertEquals("解析失败", MindMapTreeTool.fromJson(null, new ObjectMapper()).getTitle());
}
}
@@ -0,0 +1,291 @@
package com.guo.learningprogresstracker.utils;
import com.guo.learningprogresstracker.support.TestHttpServer;
import com.guo.learningprogresstracker.support.TestHttpsServer;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import javax.net.ssl.X509TrustManager;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TitleFetcherTest {
// ============ HTTP 组:基于本地 TestHttpServer ============
@Nested
class HttpGroup extends TestHttpServer {
@Test
void fetchTitle_basicHtml_withQueryAndPrefixRoute() {
route("GET", "/basic", 200, "<html><head><title>基础标题</title></head></html>");
assertEquals("基础标题", TitleFetcher.fetchTitle(baseUrl() + "/basic?src=unit"));
assertEquals("src=unit", lastQuery("GET", "/basic"));
}
@Test
void fetchTitle_decodesHtmlEntities_andCollapsesWhitespace() {
route("GET", "/entities", 200,
"<html><title>A&amp;B &lt;tag&gt; &quot;引&quot; &#39;x&#39; A&nbsp;&nbsp;B 多 空格</title></html>");
assertEquals("A&B <tag> \"\" 'x' A B 多 空格",
TitleFetcher.fetchTitle(baseUrl() + "/entities"));
}
@Test
void fetchTitle_whitespaceOnlyTitleReturnsNull() {
route("GET", "/emptytitle", 200, "<html><title>&nbsp;</title></html>");
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/emptytitle"));
}
@Test
void fetchTitle_gzipResponseBody() {
route("GET", "/gz", 200, "<html><title>压缩标题</title></html>",
Map.of("Content-Encoding-Test", "gzip"));
assertEquals("压缩标题", TitleFetcher.fetchTitle(baseUrl() + "/gz"));
}
@Test
void fetchTitle_gzipHeaderButRawBody_fallsBackAndReturnsNull() {
// 声明 gzip 但响应体未压缩:GZIPInputStream 构造抛异常 → 走 catch 回退读原始流
route("GET", "/badgz", 200, "<html><body>not gzip</body></html>",
Map.of("Content-Encoding", "gzip"));
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/badgz"));
}
@Test
void fetchTitle_deflateHeaderWithRawBody_returnsNull() {
// InflaterInputStream 包装成功但读取原始字节时抛错 → doFetch 捕获返回 null
route("GET", "/deflate", 200, "<html><body>not deflate</body></html>",
Map.of("Content-Encoding", "deflate"));
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/deflate"));
}
@Test
void fetchTitle_gbkCharsetDecoding() {
String rawTitle = "中文标题";
route("GET", "/gbk", 200,
"<html><title>" + rawTitle + "</title></html>",
Map.of("Content-Type", "text/html; charset=GBK"));
// 服务端实际发送 UTF-8 字节,客户端按 GBK 解码:两者结果必然不同
String expected = new String(rawTitle.getBytes(StandardCharsets.UTF_8), Charset.forName("GBK"));
assertFalse(expected.isBlank());
String got = TitleFetcher.fetchTitle(baseUrl() + "/gbk");
assertNotEquals(rawTitle, got);
assertEquals(expected, got);
}
@Test
void fetchTitle_unknownCharsetFallsBackToUtf8() {
route("GET", "/badcharset", 200, "<html><title>Ascii 标题</title></html>",
Map.of("Content-Type", "text/html; charset=NOT-A-REAL-CHARSET"));
assertEquals("Ascii 标题", TitleFetcher.fetchTitle(baseUrl() + "/badcharset"));
}
@Test
void fetchTitle_textHtmlWithoutCharsetDefaultsToUtf8() {
route("GET", "/nocharset", 200, "<html><title>无charset</title></html>",
Map.of("Content-Type", "text/html"));
assertEquals("无charset", TitleFetcher.fetchTitle(baseUrl() + "/nocharset"));
}
@Test
void fetchTitle_nonTextContentTypeReturnsNull() {
route("GET", "/pdf", 200, "%PDF-1.4 not html",
Map.of("Content-Type", "application/pdf"));
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/pdf"));
}
@Test
void fetchTitle_textPlainWithoutTitleReturnsNull() {
route("GET", "/txt", 200, "纯文本没有标题",
Map.of("Content-Type", "text/plain; charset=utf-8"));
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/txt"));
}
@Test
void fetchTitle_xhtmlAccepted() {
route("GET", "/xhtml", 200, "<html><title>XHTML 标题</title></html>",
Map.of("Content-Type", "application/xhtml+xml; charset=utf-8"));
assertEquals("XHTML 标题", TitleFetcher.fetchTitle(baseUrl() + "/xhtml"));
}
@Test
void fetchTitle_errorStatusReturnsNull() {
route("GET", "/err", 500, "oops");
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/err"));
}
// ---- 重定向链 ----
@Test
void fetchTitle_followsRedirectWithRelativeLocation() {
route("GET", "/start", 200, "<html><body>没有标题的页面</body></html>");
route("HEAD", "/start", 302, "", Map.of("Location", "/final"));
route("GET", "/final", 200, "<html><title>最终标题</title></html>");
assertEquals("最终标题", TitleFetcher.fetchTitle(baseUrl() + "/start"));
}
@Test
void fetchTitle_followsAbsoluteRedirectLocation() {
route("GET", "/absc", 200, "<html><body>没有标题</body></html>");
route("HEAD", "/absc", 301, "", Map.of("Location", baseUrl() + "/absfinal"));
route("GET", "/absfinal", 200, "<html><title>绝对跳转</title></html>");
assertEquals("绝对跳转", TitleFetcher.fetchTitle(baseUrl() + "/absc"));
}
@Test
void fetchTitle_selfRedirectReturnsNull() {
route("GET", "/loop", 200, "<html><body>loop</body></html>");
route("HEAD", "/loop", 302, "", Map.of("Location", "/loop"));
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/loop"));
}
@Test
void fetchTitle_tooManyRedirectsReturnsNull() {
for (int i = 0; i <= 5; i++) {
route("GET", "/r" + i, 200, "<html><body>跳转" + i + "</body></html>");
route("HEAD", "/r" + i, 302, "", Map.of("Location", "/r" + (i + 1)));
}
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/r0"));
}
@Test
void fetchTitle_headWithoutRedirectStatusReturnsNull() {
route("GET", "/stay", 200, "<html><body>没有标题</body></html>");
route("HEAD", "/stay", 200, "");
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/stay"));
}
@Test
void fetchTitle_blankRedirectLocationReturnsNull() {
route("GET", "/blank", 200, "<html><body>没有标题</body></html>");
route("HEAD", "/blank", 302, "", Map.of("Location", " "));
assertNull(TitleFetcher.fetchTitle(baseUrl() + "/blank"));
}
// ---- 非法输入 / 连接失败 ----
@Test
void fetchTitle_invalidInputsReturnNull() {
assertNull(TitleFetcher.fetchTitle(null));
assertNull(TitleFetcher.fetchTitle(""));
assertNull(TitleFetcher.fetchTitle(" "));
assertNull(TitleFetcher.fetchTitle("ftp://example.com/file"));
assertNull(TitleFetcher.fetchTitle("example.com/page"));
}
@Test
void fetchTitle_connectionRefusedReturnsNull() {
assertNull(TitleFetcher.fetchTitle("http://localhost:1/"));
}
// ---- embedTitles ----
@Test
void embedTitles_nullBlankOrLinkOnly_returnsAsIs() {
assertNull(TitleFetcher.embedTitles(null));
assertEquals(" ", TitleFetcher.embedTitles(" "));
String linkOnly = "已有 [手册](" + baseUrl() + "/keep) 说明";
assertEquals(linkOnly, TitleFetcher.embedTitles(linkOnly));
}
@Test
void embedTitles_replacesBareUrl_andStripsTrailingPunctuation() {
route("GET", "/doc", 200, "<html><title>接口文档</title></html>");
String url = baseUrl() + "/doc";
assertEquals("参见 [接口文档](" + url + ")。", TitleFetcher.embedTitles("参见 " + url + ""));
}
@Test
void embedTitles_protectsExistingMarkdownLinks_thenRestores() {
route("GET", "/fresh", 200, "<html><title>新标题</title></html>");
String out = TitleFetcher.embedTitles(
"看 [手册](" + baseUrl() + "/keep) 与 " + baseUrl() + "/fresh");
assertEquals("看 [手册](" + baseUrl() + "/keep) 与 [新标题](" + baseUrl() + "/fresh)", out);
}
@Test
void embedTitles_deduplicatesUrls() {
route("GET", "/dup", 200, "<html><title>重复标题</title></html>");
String url = baseUrl() + "/dup";
assertEquals("[重复标题](" + url + ") 和 [重复标题](" + url + ")",
TitleFetcher.embedTitles(url + "" + url));
}
@Test
void embedTitles_failedFetchKeepsBareUrl() {
String text = "文档在 http://localhost:1/missing 页面";
assertEquals(text, TitleFetcher.embedTitles(text));
}
@Test
void embedTitles_multipleUrlsAllReplaced() {
route("GET", "/a", 200, "<html><title>甲</title></html>");
route("GET", "/b", 200, "<html><title>乙</title></html>");
String out = TitleFetcher.embedTitles(baseUrl() + "/a 中间 " + baseUrl() + "/b");
assertEquals("[甲](" + baseUrl() + "/a) 中间 [乙](" + baseUrl() + "/b)", out);
}
}
// ============ HTTPS 组:自签名证书触发信任所有证书路径 ============
@Nested
class HttpsGroup extends TestHttpsServer {
@Test
void fetchTitle_selfSignedCertificateTrusted() {
html("/secure", "加密标题");
assertEquals("加密标题", TitleFetcher.fetchTitle(baseUrl() + "/secure"));
}
}
// ============ 静态块与内部 TrustManager ============
@Test
void trustAllManager_isInstalledAndAcceptsEverything() throws Exception {
// 静态块应成功安装宽松 SSLsslRelaxed = true 分支)
Field relaxed = TitleFetcher.class.getDeclaredField("sslRelaxed");
relaxed.setAccessible(true);
assertTrue(relaxed.getBoolean(null), "静态块应成功安装信任所有证书的 SSLContext");
// 握手之外直接调用三个回调,确保内部类全部行被执行
X509TrustManager tm = newTrustAllManagerInstance();
assertArrayEquals(new X509Certificate[0], tm.getAcceptedIssuers());
assertDoesNotThrow(() -> tm.checkClientTrusted(null, "RSA"));
assertDoesNotThrow(() -> tm.checkServerTrusted(null, "RSA"));
}
private static X509TrustManager newTrustAllManagerInstance() throws Exception {
try {
for (Class<?> c : TitleFetcher.class.getDeclaredClasses()) {
if (X509TrustManager.class.isAssignableFrom(c)) {
return (X509TrustManager) newInstanceAllowingAll(c);
}
}
} catch (Throwable ignored) {
// 个别实现不通过 getDeclaredClasses 暴露匿名类,退回按名称查找
}
Class<?> anon = Class.forName(TitleFetcher.class.getName() + "$1");
assertTrue(X509TrustManager.class.isAssignableFrom(anon), "TitleFetcher$1 应为 X509TrustManager");
return (X509TrustManager) newInstanceAllowingAll(anon);
}
private static Object newInstanceAllowingAll(Class<?> type) throws Exception {
for (Constructor<?> ctor : type.getDeclaredConstructors()) {
ctor.setAccessible(true);
return ctor.newInstance(new Object[ctor.getParameterCount()]);
}
throw new IllegalStateException("无可访问构造器: " + type.getName());
}
}
+7
View File
@@ -16,6 +16,13 @@ mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
# 测试环境不激活 profile,而 src/test/resources/application.yml 会遮蔽 src/main/resources/application.yml
# 因此这里必须显式声明 cors 配置,否则 WebMvcConfig 会因 allowedOrigins 为 null 抛 NPE
cors:
allowCredentials: true
allowed-origins:
- '*'
sa-token:
token-name: satoken
timeout: 2592000
Binary file not shown.