Compare commits

..
Author SHA1 Message Date
ila a658fa6fd1 fix: 按审核结论补强短语实现与测试 (#11)
- adjustRange 改变范围时同时清空短语链接与已加载条目:调整端点后不再声称「已保存」,
  已输入文本保留,保存按当前范围确定的身份写入(可能更新另一条,但绝不重复)
- 新增 TestWordKeysNeverContainSpaces:显式锁定「词片段不含空白」这一不变量,
  termKind/termWordCount 完全依赖它,此前只有间接覆盖
- e2e 断言超时提高到 15s:11 个用例共用开发服务器并行运行时,首屏模块加载可能超过
  默认 5s 造成偶发失败(产品行为未变,连续两次并行全量运行均通过)
2026-09-14 22:47:14 +08:00
ila b1b76cfb7c docs: 记录 #11 已确认的短语口径 (#11) 2026-09-14 22:23:13 +08:00
ila bd77aa0a46 feat: 选择连续短语,保存并加入到期复习 (#11)
- 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格,
  因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑
- POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400;
  章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount
- 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据
- 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截
  touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-14 22:21:51 +08:00
ila 1319c56cf8 docs: record issue 10 acceptance and merge (#10)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #10 于 2026-09-14 通过用户验收,PR #29 已 fast-forward-only 合入 main
- 功能提交 35ed692 与用户验收版本一致;本单没有数据库结构变化,schema 仍为 v6
2026-09-14 21:38:22 +08:00
ila 1d19b2b92f docs: 记录 #10 已确认的编辑与删除口径 (#10) 2026-09-13 23:21:55 +08:00
ila 35ed692c4b feat: 编辑和删除本人书籍与章节 (#10)
- PATCH /books/:id 改名、GET /chapters/:id/source 读取编辑用原文(任意状态)、
  PATCH /chapters/:id 改标题与正文、DELETE /books/:id 与 DELETE /chapters/:id
- 版本门控:任务只在 job.content_sha256 与章节版本一致时才能影响章节;过期版本任务
  被标为 superseded 且完全不触碰章节,认领与恢复扫描跳过并作废它们,重试旧版本任务 409
- 只有正文变化才重新处理:重复保存或改回原内容不新建任务;只改标题不改状态
- 删除在事务内硬删除并沿用外键级联,章节删除后重排序号;个人词条、复习排期与作答记录保留
- 处理中删除章节后,在途任务不再发布也不报错;并发删除同一章由书籍行锁序列化
- 学习端:书名与章节编辑对话框、删除确认弹窗、章节行编辑入口、书库删除提示
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-13 23:20:52 +08:00
ila 65b50d5038 docs: record issue 9 acceptance and merge (#9)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #9 于 2026-09-13 通过用户验收,PR #28 已 fast-forward-only 合入 main
- 功能提交 61d3f63 与用户验收版本一致;本单没有数据库结构变化,schema 仍为 v6
2026-09-13 22:36:46 +08:00
ila fb256d4916 docs: 记录 #9 已确认的 TXT 导入口径 (#9) 2026-09-11 23:38:38 +08:00
ila 61d3f63c69 feat: 上传 TXT 文件,校验编码后导入本人书库 (#9)
- POST /api/v1/books/upload 与 /api/v1/books/:id/chapters/upload:multipart 上传,
  字段白名单、未知或重复字段拒绝、非 multipart 拒绝、单槽并发门忙时 429
- 只接受 UTF-8(可选 BOM 剥离且不进入原文),UTF-16 按 BOM 识别并给出针对性提示,
  非法字节整体拒绝、不使用替换字符;2 MiB 字节上限之后仍套用单章 100000 码点上限
- 文件只在内存中解码,不创建临时文件;客户端文件名不参与任何路径也不入库
- 解码后交给现有 PasteBook/PasteChapter,分章、任务幂等与崩溃恢复与粘贴完全一致
- 学习端导入页新增「粘贴文本 / TXT 文件」来源切换与客户端预检,session.request 支持 FormData
- gofmt 整理 #8 引入的 import 顺序与空行
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
  Local-Development-and-Verification、Product-Requirements-Overview 与 Home
2026-09-11 23:37:13 +08:00
ila cd2b893bba docs: record issue 8 acceptance and merge (#8)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #8 于 2026-09-11 通过用户验收,PR #27 已 fast-forward-only 合入 main
- 功能提交 0328505 与审核整改提交 3258498 均与用户验收版本一致;本次只更新
  验收状态文档与镜像
2026-09-11 22:55:57 +08:00
ila 85b8e24429 docs: 记录 #8 审核整改后的复习口径 (#8) 2026-09-11 22:48:14 +08:00
ila 325849816e fix: 整改 #8 审核问题 R1~R3 (#8)
- R2 并发同键作答:取得词条行锁后加锁复查答案键,stale 插入遇到唯一键冲突转为返回
  已记录结果,不再返回 500;新增两个 goroutine 同键提交的集成用例
- R3 排期:只有新建或状态/等级实际变化才移动 due_at,编辑释义与例句保留原排期,
  逾期词条不会被挤出当天队列
- R3 附带发现:保存未提及等级时保留已获得的等级,阅读器面板不再把 4 级词重置为 1 级
- R1 契约:作答响应 result 只取 applied/stale,另加 duplicate 标记,重放返回首次结果;
  客户端按首次结果计数,本轮只解决卡片而没有新计分时显示完成页而不是空队列
- R4/R5:stale 与重放分别给出角色为 status 的提示,answerId 作用域注释与实现一致
- Wiki 更新 Business-Rules-and-Glossary、Architecture-and-Code-Map、
  Local-Development-and-Verification 并同步镜像
2026-09-11 22:46:23 +08:00
ila ec5ec2db35 docs: 记录 #8 已确认的复习决策口径 (#8) 2026-09-11 20:54:55 +08:00
ila 0328505b77 feat: 完成到期单词复习与幂等答题 (#8)
- schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers:语句都是可重试的加法迁移,
  既有已保存词汇按 created_at 立即进入队列;旧二进制回到 v5 仍可写入个人词条
- 固定间隔表 1/2/4/7/15/30/60 天:答对升级封顶 7、答错降级最低 1、再学一次不改等级,
  答错与再学立即回到本轮;已知与忽略不入队
- GET /api/v1/reviews/queue 与 POST /api/v1/reviews/:termId/answers:按 answerId 去重、
  按 expectedDueAt 判定过期标签页,重复提交与并发都不重复更新次数和间隔
- 学习端新增 /review 路由与到期复习入口,正面挖空例句、答案面评分、完成页与空队列页
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
  Local-Development-and-Verification、Product-Requirements-Overview 与 Home
2026-09-11 20:54:17 +08:00
ila 527d8af0c4 docs: record issue 7 acceptance and merge (#7)
- Home、Project-Profile、Architecture-and-Code-Map、Product-Requirements-Overview
  记录 #7 于 2026-09-11 通过用户验收,PR #26 已 fast-forward-only 合入 main
- 功能提交 8c0946a 与用户验收版本一致;本次只更新验收状态文档与镜像
2026-09-11 20:29:33 +08:00
51 changed files with 6264 additions and 132 deletions
+4
View File
@@ -282,3 +282,7 @@ MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才
- #4 独立阅读选择小样位于 `spikes/selection/`,`python spikes/selection/serve.py` 默认仅本机 5184。桌面鼠标/键盘与 11 项测试已验证,#4 已获用户验收并关闭;真实手机长按/手柄/滚动详细证据仍未提供;禁止把窄屏桌面当作真机验收。Intl.Segmenter 只用于 UI 范围验证,不替代 #3 NLP;释义保存只在内存。固定 LinguaCafe 源码对照和与 v1 的差异记录见架构 Wiki。
- 2026-09-11 用户确认正式 NLP/词典采用全 Go。#5 已验收并合入 main;#6 使用 Go WordNet 解析和词形候选、Go Unicode 原文分片、schema v4 共享词典资源表,不调用 Python NLP。WordNet 3.0 ZIP 来源与摘要见 `server/wordnet-resource.json`,许可保留在 `server/WORDNET-LICENSE.txt`。词形候选不等于上下文消歧,不自动合并个人学习状态;#3 Python 小样只保留历史验证。当前词典仅英语释义,个人释义输入为临时草稿,持久化归 #7。
- 2026-09-11 用户确认 #7 个人词条口径(三项由 Agent 定案):身份为「学习者+语言+规范化词形」,大小写合并但**不按 lemma/候选合并**(`dog` 与 `dogs` 是两条记录);首次保存默认「新词」;状态为 新词/学习中/已知/忽略,只有「学习中」带 1~7 级,对应原版 stage 2/1/0/-1~-7;例句只保存手输内容,不自动关联原文句子。schema v5 新增 `lexgo_terms`(唯一键加状态/等级检查约束),个人释义与共享词典分离且不进入审计日志;等级编辑 UI 归 #8/#12。
- 2026-09-11 用户确认 #11 短语口径:短语与单词**共用 `lexgo_terms`**,身份键为按阅读顺序的规范化词形以单个空格连接(单词键不含空格),因此 `kind` 与词数由身份键派生,**不新增列、无迁移**;范围两端对齐整词、内部标点与换行保留但不参与身份比较,2~12 词、键 ≤128 字符、片段 ≤191 字符,切进单词的范围 400。跨章节匹配按连续词形比对,重叠取**最左最长**;短语高亮覆盖内部单词但**不修改单词数据**,点击已保存短语优先打开短语面板;失效引用回退=高亮消失但词条与复习排期保留。短语进同一到期队列与同一套幂等作答,复习卡把整段短语挖成一个空;选择用原生拖选与手机系统手柄,不拦截 touchmove。同义形式合并、词性消歧、跨书移动、批量编辑(#12)与真机手感不在范围内。
- 2026-09-11 用户确认 #10 编辑与删除口径:可改名、改章节标题、编辑章节正文;**只有正文变化才重新处理**,重复保存或改回原内容不新建任务,只改标题不改状态。版本键是 `content_sha256`:任务只在与章节版本一致时才能影响章节,过期版本任务标为 `error_reason=superseded` 且**完全不触碰章节**(认领、恢复扫描、重试都按版本裁决);存储文本重算 SHA 与存储 SHA 不一致时按 `content_changed` 失败。删除为事务内硬删除 + 外键级联,删章后重排序号;个人词条、复习排期与作答记录**不随删除清理**。回收站/撤销、批量操作、章节跨书移动与语言变更不在范围内。编辑器行尾归一为 LF 是已知边界。
- 2026-09-11 用户确认 #9 TXT 导入口径:只接受 UTF-8(允许可选 BOM,解码时剥离且不进入原文),非法字节整体拒绝、不使用替换字符;UTF-16 按 BOM 识别后明确拒绝,GB18030 等按非法 UTF-8 拒绝。文件字节上限 2 MiB,之后仍套用单章 100000 码点上限;换行与空白不归一化。文件只在内存中解码、不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有 `PasteBook`/`PasteChapter`,分章(一次提交一章)、`requestId` 幂等与任务恢复与粘贴一致;不改 schema。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在范围内。
- 2026-09-11 用户确认 #8 到期单词复习决策表:固定间隔表 1/2/4/7/15/30/60 天,答对升级封顶 7、答错降级最低 1、再学一次不改等级,答错与再学立即回队;已知/忽略不入队,新保存的词立即到期,显式「学习中 level N」排 now+间隔[N];只有新建或状态/等级实际变化才移动复习时间,编辑释义或例句保留原排期,保存未提及等级时保留已获得等级。到期判定用 UTC 绝对时刻(`due_at ≤ now`),不引入本地日边界。作答按 `answerId` 去重并以 `expectedDueAt` 判定过期标签页,重复提交、网络重发与双标签页都不得重复更新次数与间隔(作答响应 `result` 只取 applied/stale,重放另用 `duplicate` 标记并返回首次结果);`correct_count` 只计答对,`wrong_count` 计答错与再学。短语复习归 #11,进度统计归 #13,不做策略配置 UI(X11)、练习模式(X08)与 FSRS。
+2 -2
View File
@@ -2,13 +2,13 @@
面向自托管场景的阅读式语言学习项目,规划提供内容导入、阅读查词、词汇与短语、复习和实例管理。
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main。词语状态与复习仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
已确认:**DevHarness 轻量模式、MySQL 8、go-admin 管理端**。工程基础 #2 已通过验收:两端用户名登录、学习账号管理、可撤销会话和本人英语空空间。管理端基于指定 go-admin/go-admin-ui 选用模块,学习端为独立 Vue 3 + TypeScript + Vite 工程,共用 Go 后端和 MySQL 8.4.3。#18 登录日志与操作审计已通过用户验收,支持管理员查询和 90 天保留清理。#5 粘贴导入与章节原文阅读已验收并合入 main;#6 全 Go 英语词典与点词查义已通过用户验收,PR #25 已合入 main;#7 个人词条与学习状态已通过用户验收,PR #26 已合入 main;#8 到期单词复习已通过用户验收,PR #27 已合入 main;#9 上传 TXT、校验编码后导入本人书库已通过用户验收,PR #28 已合入 main;#10 编辑与删除本人书籍章节已通过用户验收,PR #29 已合入 main。短语、复习进度与词汇库仍未实现。MVP 定位为“支持多账号、数据独立的自托管学习工具”,先邀请少量用户使用;F01~F12 已确认,X 系列后置。
- [文档入口](docs/README.md) · [线上 Wiki](https://git.ilapage.cn/OPC/lexgo/wiki/Home)
- [英语分词与离线词典验证小样](spikes/english/README.md)(#3 已验收,独立本机入口)
- [阅读选择验证小样](spikes/selection/README.md)(#4 已验收,真机详细测试证据缺口保留)
- [项目档案](docs/00-project-profile.md) · [需求总览](docs/09-product-requirements-overview.md)
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#18 已验收,原规划中的 #5 已完成;剩余 #7~#15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
- [工作量估算](docs/10-workload-estimate.md):#2、#3、#4、#5、#6、#7、#8、#9、#10、#18 已验收,原规划中的 #5 已完成;剩余 #11~#15 与新增 #21、#24 按工单复核;后续结合集成结果重估,旧全量研究仅供参考。
- [四阶段实施总览 #16](https://git.ilapage.cn/OPC/lexgo/issues/16):14 张单元工单,工程基础 → 技术验证 → 首条学习闭环 → 补齐 MVP;原型 v1 已获用户验收。两端使用账号(用户名)+密码登录,不要求邮箱。
- [原型工单 #1](https://git.ilapage.cn/OPC/lexgo/issues/1):Quant-UX 桌面/手机原型 v1,预览入口与审核记录见工单及需求总览。
- [工作流](docs/01-workflow.md) · [开发与验证](docs/04-local-development-and-verification.md)
+18 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Project-Profile.-
wiki_revision: 1855b5e99c288de90d3b804f7dfd60c5f2c35b45
synchronized_at: 2026-09-11T06:46:46Z
wiki_revision: 08d992aee15b7fd5cea0cdbb4eb0b90583068449
synchronized_at: 2026-09-14T13:37:59Z
<!-- gitea-wiki-mirror:end -->
# LexGo 项目档案
@@ -146,3 +146,19 @@ server 是 go-admin 的选用模块接入:原样保留 SysUser、SysDept、必
## #6 当前工程状态(2026-09-11)
#5已验收且相关前置PR均已合入main。#6正式词典/点词查义已按全Go实现,已于2026-09-11通过用户验收,PR #25已合入main;server schema v4,管理端新增英语词典页,学习端加入Go分片及查词面板。正式Go进程不依赖Python NLP,WordNet包随数据库持久化。#3小样仍是历史验证;保存个人释义和状态归#7,音频封面#21、列表优化#24尚未实施。
## #7 当前工程状态(2026-09-11)
#6已通过用户验收并合入main。#7个人词条、学习状态与阅读器高亮已按用户确认口径实现,并于2026-09-11通过用户验收,PR #26已fast-forward-only合入main;server schema v5新增lexgo_terms,学习端面板可保存释义、例句与状态并按状态高亮,同一词形在本人其他章节显示一致。个人释义与共享词典分离,且不进入审计日志。词汇库#12、短语#11、到期复习#8、进度#13、音频封面#21与列表优化#24尚未实施。
## #8 当前工程状态(2026-09-11)
#7已通过用户验收并合入main。#8到期单词复习已按用户确认的决策表实现,并于2026-09-11通过用户验收,PR #27已fast-forward-only合入main;server schema v6新增lexgo_term_reviews(排期与计数)与lexgo_review_answers(作答记录),固定间隔1/2/4/7/15/30/60天,答对升级封顶7、答错降级最低1、再学一次不改等级,答错与再学立即回队,已知与忽略不入队。作答按answerId去重并以expectedDueAt判定过期标签页,重复提交、网络重发与双标签页都只记一次;到期判定用UTC绝对时刻,不引入本地日边界。独立审核(Claude Code)指出的并发同键500、编辑文本重排复习、面板保存重置等级三项已整改并复测。第3阶段「首条学习闭环」(#5~#8)至此全部验收;剩余#9~#15与#21、#24尚未实施。
## #9 当前工程状态(2026-09-13)
#8已通过用户验收并合入main。#9 TXT文件导入已按用户确认的契约实现,并于2026-09-13通过用户验收,PR #28已fast-forward-only合入main;本单没有数据库结构变化,schema仍为v6。解码只接受UTF-8(可选BOM剥离且不进原文),非法字节整体拒绝、不使用替换字符,UTF-16按BOM识别后明确拒绝;文件字节上限2MiB并仍受单章100000码点约束;换行与空白不归一化。文件只在内存中解码,不创建临时文件,客户端文件名不参与任何路径也不入库。解码后交给既有PasteBook/PasteChapter,分章、任务幂等与恢复与粘贴一致。第4阶段已完成#9;剩余#10~#15与#21、#24尚未实施。
## #10 当前工程状态(2026-09-14)
#9已通过用户验收并合入main。#10编辑与删除书籍章节已按用户确认的契约实现,并于2026-09-14通过用户验收,PR #29已fast-forward-only合入main;本单没有数据库结构变化,schema仍为v6。可改书名、章节标题与章节正文;只有正文变化才重新处理,重复保存或改回原内容不新建任务,只改标题不改变处理状态。版本门控按content_sha256执行:过期版本任务被标为superseded且完全不触碰章节,认领、恢复扫描与重试都按版本裁决,存储文本重算SHA与存储SHA不一致时按content_changed失败——这修掉了「处理中编辑导致新版本被标失败」的既有缺陷。删除为事务内硬删除加外键级联,删章后重排序号;个人词条、复习排期与作答记录不随删除清理。第4阶段已完成#9与#10;剩余#11~#15与#21、#24尚未实施。
+74 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Architecture-and-Code-Map.-
wiki_revision: cb6d420632527a436f8b9a7a6a18413a0773f93d
synchronized_at: 2026-09-11T08:31:30Z
wiki_revision: cc1522fceb8e420cee17f509101f61986b3d7ff9
synchronized_at: 2026-09-14T14:21:32Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -244,7 +244,7 @@ lexgo_books(owner_id, title, language)、lexgo_chapters(book_id, owner_id, ordin
参考:[WordNet 数据格式](https://wordnet.princeton.edu/documentation/wndb5wn)、[词形规则](https://wordnet.princeton.edu/documentation/morphy7wn)。#3 仅历史实验,#6 不调用其实验服务。
## #7 个人词条与阅读器状态(2026-09-11)
## #7 个人词条与阅读器状态(2026-09-11,已验收并合入 main)
schema v5 新增 lexgo_terms:一个学习者对一个词形一条记录。身份键为 `(owner_id, language, term)`,`term` 是 Go 侧 `normalizeWord` 的结果(NFC、小写、弯撇号转直撇号),列使用 `utf8mb4_bin`,避免折叠 `resume`/`résumé`;`original_form` 保存最近一次保存的原词形供显示。`definition`/`examples` 是学习者自己的文本,例句按行存储;共享词典仍只在 `lexgo_dictionaries`,两者不混存。`status` 与 `level` 由数据库检查约束守住:只有 `learning` 允许 1~7,其他状态必须为 0。
@@ -257,3 +257,74 @@ schema v5 新增 lexgo_terms:一个学习者对一个词形一条记录。身
`server/app/lexgo/terms.go` 负责身份、状态/等级边界、文本上限、upsert 与章节点词状态;`database.go` 提供 v5;`dictionary.go` 的 tokens 与 lookup 共用 `wordAtRange`,保存与查询必须落在同一个完整单词范围上,所以客户端无法命名自己没有读到的词。个人词条不写审计日志。
学习端 `useReaderLookup.ts` 在原有查询状态上增加个人释义、例句、状态、已保存编号、预填与保存;打开已保存词先读 `GET /terms/:id`,读取失败时禁用保存,避免用空表单覆盖原内容。`LookupPanel.vue` 提供状态单选、释义与例句输入、保存与简短反馈;`ReaderTokens.vue` 按状态高亮 `is-new`/`is-learning`/`is-known`/`is-ignored`。换词、换章、离页、退出或切换账号都会清空表单、状态与高亮。管理端无改动。
## #8 复习调度与答题(2026-09-11,已验收并合入 main)
schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at`、`review_count`、`correct_count`、`wrong_count`、`last_reviewed_at`)与 `lexgo_review_answers`(每次作答一条:`answer_key`、评分、状态/等级/间隔前后值、`result`、`requeued`、时间)。两条语句都是 `CREATE TABLE IF NOT EXISTS` 加 `INSERT IGNORE ... SELECT`,所以迁移是可重试的加法迁移;旧二进制回到 v5 仍可继续写 `lexgo_terms`,不需要改动个人词条表本身。既有已保存词汇在迁移中按 `due_at = created_at` 进入队列。
`server/app/lexgo/review.go` 负责间隔表、队列、评分转换、幂等与并发;`terms.go` 的保存路径通过 `syncTermReview` 维护排期行(`新词` 立即到期,显式 `学习中 level N` 排 `now + 间隔[N]`),计数在状态或等级变化时保留。
| 接口 | 权限与输入/输出 |
|---|---|
| GET /api/v1/reviews/queue | 本人+当前语言;仅 `新词`/`学习中` 且 `due_at ≤ now`,按 `due_at, id` 排序、最多 50 条;返回 `{items, total}`,`total` 是全部到期数;不接受查询参数 |
| POST /api/v1/reviews/:termId/answers | `{answerId, grade: correct\|wrong\|again, expectedDueAt}`;应用成功 201,重放或过期 200;返回首次结果 `result`(applied/stale) 与 `duplicate` 标记、前后状态/等级/到期时间、`requeued` 与词条新状态;加锁后再次读取答案键,所以并发的同键提交也返回记录而不是报错 |
学习端新增 `/review` 路由与书库、阅读器顶栏的「到期复习」入口;`stores/review.ts` 维护队列、本轮计数、评分与重学,`ReviewCard.vue`/`ReviewView.vue` 呈现正面(词+挖空例句)、答案面(个人释义+例句+三个评分按钮)、完成页与空队列页。一次评分对应一个 `answerId`,失败重试复用同一个;换词后重新生成。切换账号或退出登录会清空队列、计数与当前卡片。
参考:[LinguaCafe Review.vue](https://github.com/simjanos-dev/LinguaCafe/blob/c1ea298ce40c65b9dd33e9b26fd2e52fae66f2c8/resources/js/components/Review/Review.vue)。上游的随机抽卡、阶段降级与快捷键不属于本单;#8 只实现本项目的固定间隔、固定顺序与幂等作答。
## #9 TXT 上传导入(2026-09-11,已验收并合入 main)
`server/app/lexgo/upload.go` 负责把上传的 TXT 解码后交给与粘贴相同的核心:解码、multipart 解析与两条路由,schema 无变化(沿用 #5 的 `lexgo_books`/`lexgo_chapters`/`lexgo_ingest_jobs`)。文件只在内存中存在,不写临时文件,客户端文件名不参与任何路径也不入库。
| 接口 | 权限与输入/输出 |
|---|---|
| POST /api/v1/books/upload | 本人;multipart:`requestId`、`title`、`language`(可省略,省略即英语)、`file`;新建书籍与首章 |
| POST /api/v1/books/:id/chapters/upload | 本人且本人书籍;multipart:`requestId`、`title`、`file`;追加一章;不接受 `language` |
字段白名单之外的字段、重复字段、缺失 `file`、非 multipart 请求都返回 400;201 新建、200 重复、409 同编号换内容、404 他人书籍、401 未登录、429 已有文件正在上传(单槽并发门)。响应体与粘贴路径同为 `PasteResult`,所以学习端复用同一套跳转与轮询逻辑。
解码规则见业务规则页;实现上 `decodeTextUpload` 先按 UTF-16 BOM 识别并给出针对性提示,再剥离可选 UTF-8 BOM,然后用 `utf8.Valid` 整体校验,最后交给 `validatePaste`(非空、≤100000 码点)。因此上传与粘贴共享同一分章与任务规则:一次提交一章,`requestId` + 内容 SHA 幂等,worker 只发布已落库的原文。
学习端 `ImportView.vue` 增加「粘贴文本 / TXT 文件」来源切换(沿用已验收 v1 的切换与状态行),`stores/library.ts` 增加 `upload()` 与 `fileProblem`/`fileSizeLabel`,`session.request` 支持 `FormData`(multipart 请求不再被 JSON 化,边界由浏览器提供)。客户端预检只提前反馈,服务端结论为最终结论。
## #10 编辑与删除书籍章节(2026-09-11,已验收并合入 main)
`server/app/lexgo/edit.go` 提供改名、编辑与删除;`ingest.go` 增加版本门控。**schema 无变化**:`chapters.content_sha256` 与 `jobs.content_sha256` 就是版本键,新增的 `superseded` 复用现有 `error_reason` 列。
| 接口 | 权限与输入/输出 |
|---|---|
| PATCH /api/v1/books/:id | 本人;`{title}`;返回 `{book}` |
| GET /api/v1/chapters/:id/source | 本人任意状态;返回 `{source:{id,bookId,ordinal,title,text,status,contentSha256,charCount}}`,供编辑使用;不接受查询参数 |
| PATCH /api/v1/chapters/:id | 本人;`{title?,text?}`;返回 `{chapter,job,versionChanged}`;正文变化才新建任务 |
| DELETE /api/v1/books/:id | 本人;返回 `{deleted:{bookId,chapters}}`;FC 级联删除章节与任务 |
| DELETE /api/v1/chapters/:id | 本人;返回 `{deleted:{chapterId,bookId,remaining}}`;删除后重排序号 |
**版本门控**(本单修掉的缺陷):任务只在 `job.content_sha256 == chapter.content_sha256` 时才能影响章节。认领任务时用 JOIN 只取版本匹配的行,并先把过期版本任务一次性标为 `failed/superseded`;发布前再比对一次,不匹配就只把任务标为 `superseded` 并**完全不触碰章节**;恢复扫描同样先作废过期版本任务、只重排版本匹配的中断任务;重试接口拒绝版本不匹配的任务(409)。删除期间在途任务找不到章节时视为无事可做(级联已删除其任务行)。
`unprocessableReason` 保留一条内容一致性检查:存储的正文重新计算出的 SHA 必须等于该章节存储的 SHA,用于兜住绕过 API 的直接写入(`content_changed`),与版本门控互不重复。
学习端 `BookView.vue` 增加书名编辑对话框、章节编辑对话框(标题 + 正文,正文来自 source 接口)与两处确认弹窗(`ElMessageBox`),章节行增加「编辑」入口;`LibraryView.vue` 在书库被删后显示「书籍已删除 · 已保存的生词和短语仍保留在生词本。」;`stores/library.ts` 增加 `renameBook`、`updateChapter`、`loadChapterSource`、`deleteBook`、`deleteChapter`。正文编辑通过浏览器 textarea 输入,因此该章的行尾统一为 LF(粘贴与 TXT 导入仍保留原始 CRLF)。
## #11 短语选择、保存与复习(2026-09-11)
短语与单词共用一张表和一套复习机制:`lexgo_terms` 的 `term` 列存身份键,**单词键不含空格、短语键以空格分隔**,所以「词或短语」不需要额外列,也不需要第二套排期/队列/作答逻辑。`kind` 与词数由身份键在服务端派生(`termKind`/`termWordCount`),视图与队列项随响应返回。
`server/app/lexgo/phrase.go`:
| 部分 | 职责 |
|---|---|
| `phraseWords` | 从本人 ready 章节的 token 里取完全落在选区内的词;**切进单词的范围直接 400**,不静默丢弃;2~12 个词 |
| `phraseKey` / `phraseSource` | 身份键=按顺序的规范化词形以单个空格连接;显示片段=选区原文(内部标点与换行保留) |
| `phraseMatches` | 跨章节匹配:按首词分组后顺序比对词形,候选按 (起点, 长度降序, id) 排序并取**最左最长**的互不重叠集合 |
| `phrasesForChapter` | 按 `term LIKE '% %'` 取本人短语并匹配,供 tokens 响应使用 |
| `SavePhrase` | 由服务端推导身份后走与单词相同的 `saveTerm` upsert;`kind` 冲突返回 409 |
| 接口 | 说明 |
|---|---|
| POST /api/v1/phrases | `{chapterId,start,end,definition,examples[],status,level?}`;本人 ready 章节;服务端推导词序列与身份,不接受客户端身份;首次 201、重复 200 |
| GET /api/v1/terms/:id | 复用;响应增加 `kind` 与 `wordCount` |
| GET /api/v1/chapters/:id/tokens | 响应增加 `phrases:[{id,status,wordCount,startToken,endToken}]` |
| GET /api/v1/reviews/queue | 队列项增加 `kind` 与 `wordCount`;短语与单词同一队列、同一作答接口 |
学习端:`composables/readerRange.ts` 是纯函数层(整词对齐、内部保留、端点按词调整、命中优先级、区间换算),`composables/useTextSelection.ts` 监听 `selectionchange`(100ms 去抖)与 document 的 `pointerup` 读取浏览器原生选区并映射为 token 索引,**不拦截 touchmove、不 preventDefault**;`ReaderTokens.vue` 为每个 token 输出 `data-token-index` 与短语区间样式;`ReaderView.vue` 负责把选区变成短语、shift 点击扩展、以及面板端点调整;`LookupPanel.vue` 增加短语标题与四个端点按钮;复习卡用 `maskedPrompt` 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。
+72 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Business-Rules-and-Glossary.-
wiki_revision: 71389f97df86e17699f4af930022251fa354631b
synchronized_at: 2026-09-11T08:31:32Z
wiki_revision: fb3ce7506f1eb1940d7aa57226d22e4f8a270633
synchronized_at: 2026-09-14T14:21:32Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -172,3 +172,73 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺
保存幂等由唯一键承担:重复提交同一词形只更新同一行,不产生第二条冲突记录;同一账号多端同时编辑为最后写入生效,本版不引入版本冲突拒绝。所有读写都属于会话本人:章节必须本人且已就绪,篡改 id、owner、language、term 或携带未知字段返回 400,他人编号与本人不可见编号统一 404。跨账号不共享任何数据与前端缓存,退出或切换账号后不保留前一账号的词条与高亮。
例句只保存学习者手输内容,不自动关联原文句子:当前分词只有词/空白/标点边界,没有句子切分规则。原型 v1 中「原文例句已关联到词条」是演示文案,不作为契约。等级选择器属于 #8/#12 的编辑界面,阅读器面板只提供四个状态。
## #8 复习规则(2026-09-11)
**间隔表**:答对后按新等级排期,等级上限 7。这是固定表,不是 FSRS,也不是上游「按等级选复习量少的日期」的算法。
| 等级 | 1 | 2 | 3 | 4 | 5 | 6 | 7(上限) |
|---|---|---|---|---|---|---|---|
| 下次复习 | 1 天 | 2 天 | 4 天 | 7 天 | 15 天 | 30 天 | 60 天 |
**入队范围**:只有状态 `新词` 或 `学习中` 且 `due_at ≤ now` 的词条进入队列;`已知` 与 `忽略` 不入队。新保存的词立即到期(`due_at` = 保存时刻),保存后就能复习;手动把词条改为「学习中 level N」时下次复习为 `now + 间隔[N]`,避免刚标等级就被当成到期。只有新建词条或状态/等级实际变化才移动复习时间:只修改释义、例句或原词形时保留原有排期,否则编辑文本会把逾期词条挤出当天队列。保存未提及等级时保留已获得的等级,只有进入 `学习中` 才从 1 开始,因此阅读器面板的保存不会把等级重置为 1。已在队列中的词条被改成 `已知`/`忽略` 后再次作答会被拒绝(409),排队信息由服务端裁决而不是前端过滤。
**三个评分动作**:`correct` 认识/答对 → 等级 +1(封顶 7)、按新等级排期、离开本轮;`wrong` 不认识/答错 → `学习中` 降一级(最低 1)、`新词` 保持 `新词`、`due_at = now` 立即回到本轮;`again` 再学一次 → 等级与状态不变、`due_at = now` 立即回到本轮。计数上 `correct_count` 只统计 `correct`,`wrong_count` 统计 `wrong` 与 `again`,`review_count` 统计全部已应用作答。词条的原文、个人释义与例句不因复习改变。
**时区与到期边界**:`due_at` 以 UTC 绝对时刻存储,到期判定是 `due_at ≤ now`,不引入本地日边界。理由:MVP 没有用户时区设置(属 X 系列边界),绝对时刻在多账号自托管下语义一致、没有夏令时陷阱;代价是复习时刻会随首次作答时间漂移,例如 22:00 答对的 1 天间隔词条要到次日 22:00 才到期。原型界面的「到期复习 N / M」指当前到期队列的位置与本轮卡片数,不是自然日统计。
**幂等与并发**:客户端每次作答生成一个 `answerId`,服务端以 `UNIQUE(owner_id, answer_id)` 去重。同一个 `answerId` 再次提交返回首次结果并把 `duplicate` 标记为 true(`result` 仍是首次的 `applied` 或 `stale`),不改变等级、间隔和次数,因此客户端重试可以按首次结果计数;同一词条在别处已经被推进(提交回传的 `expectedDueAt` 与服务端当前 `due_at` 不一致)时记为 `result=stale`,同样不改变任何状态。因此网络重发、双击、双标签页作答都只记账一次。每次尝试都会落一条 `lexgo_review_answers`(含 `result`),这是「只记账一次」的证据,也供 #13 统计使用。
**归属与错误**:词条归属由服务端按会话裁决,`owner` 不接受客户端输入;他人词条与不存在的词条统一 404,未登录 401,未知评分/缺少 `expectedDueAt`/未知字段 400,词条已变成 `已知`/`忽略` 409。复习接口不写审计日志:答题属于私人学习内容。
**范围边界**:本单只做单词复习。短语复习归 #11,到期范围筛选与策略配置界面(X11)不做,练习模式(X08)不做,进度与「已知词/待复习」计数归 #13。#8 不改变 #7 定下的身份口径:复习状态按规范化词形归属,仍不按 WordNet lemma 候选合并。
## #9 TXT 上传规则(2026-09-11)
**支持的编码**:只接受 UTF-8,允许带可选 UTF-8 BOM。BOM 在解码时剥离,不进入原文;其余字节必须整体合法,任何非法序列**直接拒绝**,绝不使用替换字符,因此章节里不会出现学习者没有写过的乱码。UTF-16(含记事本「另存为 Unicode」产生的大小端 BOM)单独识别并提示「请另存为 UTF-8 后重试」;GB18030、Latin-1 等其他编码按非法 UTF-8 拒绝。UTF-16 支持不在本单范围。
**换行与空白**:不做任何归一化,CRLF、LF、制表符、行尾空格与空行按原字节保存,阅读器以 `pre-wrap` 原样呈现——与粘贴路径一致。
**大小上限**:文件字节上限 2 MiB;解码后再套用单章上限(非空、≤100000 码点)。超限返回 400 并明确提示,不截断、不部分导入。2 MiB 对 100000 码点的 UTF-8 文本有足够余量。
**文件生命周期与路径**:上传内容只存在于内存中,解码后直接进入导入事务;服务端**不创建临时文件**,所以没有需要清理或可能泄漏的文件;**客户端文件名不参与任何文件系统路径、也不写入数据库**,它只在选择文件时用于显示(并可预填标题)。因此文件名即使写成 `..\..\windows\system32\evil.txt` 也不会影响任何存储位置。上传并发按单槽限制,忙时返回 429。
**导入与幂等**:上传与粘贴共用同一套规则——一次提交一章,`requestId` + 内容 SHA 保证重复上传同一文件只产生一章(返回第一次的章节并标记 `duplicate`),同一 `requestId` 换成其他内容返回 409。任务状态、失败重试与崩溃恢复沿用 #5 的任务机制,不新增状态。标题规则与粘贴完全相同(去空白后非空、≤120 字符);省略 `language` 时默认英语,与粘贴一致;追加章节不接受 `language`。
**范围边界**:不包含 EPUB、PDF、字幕与其他文件格式;不做按空行自动分章;不做 UTF-16/GB18030 转码;不做断点续传;不把来源文件名持久化(若将来需要「导入来源」溯源,另立范围)。
## #10 编辑、版本与删除规则(2026-09-11)
**可编辑内容**:书名、章节标题、章节正文。只有正文变化才重新处理;只改标题不改变处理状态,也不新建任务。
**版本规则**:`chapters.content_sha256` 是章节的版本键,`jobs.content_sha256` 是任务被创建时对应的版本。任务只能在版本匹配时影响章节:
- 过期版本的任务被标为 `error_reason=superseded`(「章节内容已更新为新版本,本次处理已作废」),**不会**把章节标成失败、也不会发布旧文本;界面上失败章节的重试按钮只出现于当前版本的任务。
- 任务重试要求版本匹配,否则 409,避免把当前章节拉回旧版本再失败一次。
- 重复保存同一正文不是新版本:不新建任务、不改变状态。改回原内容(内容相同)同样不触发处理。
- 编辑章节正文会保留章节编号与阅读入口(URL 不变),章节状态回到待处理,处理完成后原文即新版本。
**存储文本与版本一致性**:章节存储的正文重新计算出的 SHA 必须等于存储的 SHA;出现不一致(绕过 API 的直接写入)时按 `content_changed` 失败,不发布不确定内容。
**删除规则**:删除在事务内**硬删除**,并沿用现有外键级联清理:书籍 → 章节 → 任务。删除章节后剩余章节序号重排为连续(原型显示「剩余 N 章」,导航按序号取值);并发删除同一章由书籍行锁序列化,结果是一个成功、一个 404。重复删除返回 404,不把「已经不存在」当成成功。恢复路径是数据库备份与完整恢复(#15 演练范围),产品不提供回收站或撤销。
**个人学习记录保留**:删除书籍或章节**不删除**个人词条、复习排期与作答记录,因为它们按学习者归属、不引用章节;界面也没有「来自某章节」的引用(例句是学习者输入的副本)。所以删除只影响书籍、章节与处理任务,这一点在原型确认文案中就写明:「本章正文将被删除,已保存词条保留」「删除这本书及其章节?已保存的生词和短语将保留」。
**归属与边界**:改名、编辑、删除、读取编辑用原文都严格按会话归属;他人资源与不存在资源统一 404,未登录 401,空标题/空正文/超长文本/未知字段 400。学习端正文编辑框的行尾会统一为 LF(浏览器 textarea 行为),粘贴与 TXT 导入路径仍然保留原始 CRLF 与空白。
**范围边界**:不做封面与音频附件(#21)、不做回收站/撤销、不做批量操作、不做章节跨书移动、不做语言变更。
## #11 短语规则(2026-09-11)
**身份**:短语身份=按阅读顺序的规范化词形以单个空格连接(`a small, step` 与 `a small step` 都是 `a small step`),归属 `(owner, language)`;显示片段保存最近一次保存时的原文(内部标点、换行、多余空格原样保留,仅用于显示)。与单词共用同一张表,因此同一短语在不同章节保存只会得到一条记录,也共用同一幂等键与同一复习排期。
**范围**:两端对齐整词;首尾若落在空白或标点则跳过;**内部**标点与换行保留但不参与身份比较;不切开代理对、ZWJ 与组合字符。选区内少于 2 个词不是短语(单个词走单词面板),超过 12 个词、身份键超过 128 字符或原文片段超过 191 字符都返回 400 并给出可读提示。切进单词中间的范围被拒绝,而不是静默丢弃那个词。
**跨章节匹配**:在章节的词片段序列中找**连续词**,其规范化词形逐个相等(中间允许任意标点与空白)。因此编辑正文后:短语仍出现则继续高亮;不再出现则该章不高亮,但**词条与复习排期保留**;章节被删除同样保留。短语不存章节锚点,所以不存在悬空引用。
**重叠与点击**:同一位置多个候选按 (起点升序, 长度降序) 取互不重叠者,即「最左最长」,结果与输入顺序无关。短语高亮覆盖其内部的单词高亮,但**不修改单词数据**(状态、排期、计数都保留);点击命中规则是「在已保存短语范围内 → 打开短语面板,否则打开单词面板」。保存后的高亮立即出现,不需要重新加载分词。
**选择交互**:桌面用浏览器原生拖选(`selectionchange` 去抖 + `pointerup`),手机依赖系统选择手柄且**不拦截 touchmove**,面板提供起点/终点四个按钮按词调整,Shift 点击可把范围从一个词扩展到一个词,Escape 取消并保留阅读位置。单击(折叠选区)仍然是单词查询。真实手感属于运行验证范围,真机证据缺口保留。
**复习**:短语进入同一个到期队列与同一套间隔表;卡片正面显示短语并把**整段短语挖成一个空**(例句里没有该短语时只显示短语本身),答案面显示个人释义;答对/答错/再学与幂等、stale 规则与单词完全一致;计数归属也一致(`correct_count` 只计答对,`wrong_count` 计答错与再学)。
**范围边界**:不做短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑(#12)、真机手柄精细手感。短语的例句同样是手输内容,不自动关联原文句子。
+96 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Local-Development-and-Verification.-
wiki_revision: 919545b0d716180a49ac7dd2fe96bbeccdba0880
synchronized_at: 2026-09-11T08:31:32Z
wiki_revision: 1643f05aa6987196d5dcc82a07e2daa07e2f2b4d
synchronized_at: 2026-09-14T14:21:32Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -340,3 +340,97 @@ node --test spikes/english/view.test.mjs
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程),覆盖两个虚构测试账号 issue5_a/issue5_b 的登录、保存、幂等、状态边界、跨章节一致、跨账号隔离与越权拒绝;随后用临时 Playwright 用例在真实学习端+真实 API 上以两个账号复核保存、重新加载后的高亮与预填,以及 390×844 窄屏底部面板。截图保存在本机 `.local/evidence/`(issue7-reader-desktop.png、issue7-reader-mobile-390.png、issue7-account-b.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器窄屏检查,不当作真机结果。
## #8 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue8,不借用其他测试库。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue8 python scripts/server.py test-integration` | 41 个顶层用例全部通过、0 跳过;含 #8 新增 6 个复习用例与 1 个 v5→v6 迁移用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 71 项单测、类型检查、构建、5 项 E2E 全部通过 |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:间隔表与每个状态转换(含等级上下限)、到期边界(`due_at = now` 到期、早 1 毫秒不到期)、入队范围(已知/忽略不入队)、两账号队列互不可见、重复提交只记一次、同一词条双标签页 second 写入为 stale、作答归属 404、已知词条 409、未知评分/缺少到期时间/未知字段 400、显式等级的排期规则、幂等键唯一约束、v5→v6 保留既有词条与计数。
本机开发库 lexgo_dev 已显式从 v5 升级到 v6:升级前后 sys_user 6、lexgo_spaces 6、lexgo_sessions 5、lexgo_books 5、lexgo_chapters 10、lexgo_ingest_jobs 10、lexgo_dictionaries 1、lexgo_terms 4 全部不变;新增 `lexgo_term_reviews` 4 行(全部 `due_at = created_at`)与空的 `lexgo_review_answers`。旧二进制备份在忽略的 `.local/lexgo-pre-issue8.exe`。回退:停止 lexgo-api,恢复旧二进制,把 `lexgo_schema` 标记改回 5 后启动;新表不影响旧二进制写入个人词条,保留新表与全部既有数据,不删除数据、不重新 bootstrap。
真实链路验证:真实 Go API+真实 MySQL 共 38 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行),覆盖到期即时入队、答对升级与 1 天排期、答错立即回队、重放与 stale 不重复推进、越权与非法输入拒绝、两账号互不影响、已知词条拒绝作答;随后用临时 Playwright 用例在真实学习端+真实 API 上完成「登录→粘贴导入→点词查义→保存个人释义→到期复习作答→回到阅读器确认状态」的完整闭环,并复核 390×844 窄屏无横向溢出。截图保存在本机 `.local/evidence/`(issue8-reader-saved.png、issue8-review-revealed.png、issue8-review-summary.png、issue8-reader-reviewed.png、issue8-review-mobile-390.png),临时用例运行后删除。
运维记录:本次只有 lexgo-api(新二进制)与 lexgo-learner 被重启;学习端常驻 Vite 在长时间运行后一度对 `/src/style.css` 返回空样式表,导致 E2E 观察到 `white-space: normal`,重启后恢复,未改动代码或配置。其余实例保持 Running。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器窄屏检查,不当作真机结果。并发只覆盖到「双标签页同一词条」这一层,没有做多用户压测。
## #8 审核整改(R1~R3,2026-09-11)
独立审核(Claude Code)只读审阅提交 `0328505`/`ec5ec2d`,指出三处影响验收标准第 2、3 条的问题。三点均先在 `lexgo_test_issue8` 写复现用例观察到失败,再修复并转绿。
| 问题 | 现象与根因 | 修复 | 回归用例 |
|---|---|---|---|
| R2 并发同键返回 500 | 两个同时到达、带同一 `answerId` 的请求都在加锁前查不到记录;后者进入 stale 分支插入相同 `answer_key`,触发唯一键冲突返回 500 | 取得词条行锁后再用加锁读复查一次答案键,命中直接返回首次结果;stale 插入遇到 1062 也转为返回记录 | `TestMySQLReviewConcurrentReplayOfOneAnswer`(两个 goroutine 同键提交,两个都 2xx、`review_count = 1`、只有一条答案记录) |
| R3 编辑文本会重排复习 | `saveTerm` 无条件调用 `syncTermReview`,编辑释义/例句也会把 `due_at` 重算,逾期词条被挤出当天队列 | 保存前加锁读取旧行,只有新建或状态/等级实际变化才移动 `due_at`;缺行时补建排期行 | `TestMySQLReviewEditKeepsSchedule`(逾期 3 级词只改释义:`due_at` 与队列不变;改等级则重排) |
| R3 附带发现:面板保存把等级重置为 1 | 阅读器面板只提交状态不提交等级,`termLevel` 对缺省等级一律返回 1,于是 4 级词改一个错字会掉到 1 级 | 保存未提及等级时保留已获得的等级;只有进入 `学习中` 才从 1 开始 | `TestMySQLReviewPanelSaveKeepsLevel`(4 级词面板式保存后仍为 4 级,且排期不变;退出再进入学习中则从 1 开始) |
**答案契约随之明确**:作答响应 `result` 只取 `applied`/`stale`,另加 `duplicate` 布尔标记。重放返回首次结果并把 `duplicate` 置真,客户端因此可以按首次结果计数:网络把响应丢掉后点「重试提交」拿到 `duplicate=true` 的 `applied`,本轮计数正常增加,完成页不会退化成「今天没有到期词条」。已应用的作答返回 201,重放与 stale 返回 200(R1)。
**提示与注释(R4、R5)**:卡片因 `stale` 离开时页面显示 `role="status"` 提示「该词已在其他页面复习,本次未计分。」,重放且首次为 stale 时显示「该词已按上一次的评分记录,未重复计分。」;本轮只解决卡片而没有新计分时,完成页显示「本轮没有新的计分:N 个词条已在其他页面复习。」。`answerId` 的作用域注释改为「每张卡片一个,失败重试复用」,与 `answerIdFor` 的实现一致。
**流程记录(R6)**:评论 7769 的方案写的是在 `lexgo_terms` 上增加列,实际实现改为独立表 `lexgo_term_reviews`(加法迁移可重试、不对既有表做 ALTER)。该变更在实施评论 7776 与 Wiki 中说明了原因,但没有按「数据结构变化先更新工单」的要求在实施前追加变更评论;本页与上文契约按实际实现记录,方案评论中的「新增列均有默认值」以独立表为准。
整改后重跑:Go 单元与集成测试(专用库 `lexgo_test_issue8`)44 个顶层用例全部通过、0 跳过;学习端 73 项单测、类型检查、构建与 5 项 E2E 通过;管理端 31 项与 lint 通过;治理 56 项与严格检查通过;真实 API+MySQL 42 项检查通过(新增 4 项针对 R3 与重放契约);真实浏览器复核面板保存与复习闭环通过。截图 `.local/evidence/issue8-fixed-summary.png`。
## #9 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。本单**不改动数据库结构**,所以没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 50 个顶层用例全部通过、0 跳过;含 #9 新增 6 个上传用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 80 项单测、类型检查、构建、6 项 E2E 全部通过 |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:有效 UTF-8(含 CRLF、制表符、弯引号、em dash、省略号、emoji、组合字符)字节级往返、UTF-8 BOM 剥离且不进原文、只有 BOM、非法 UTF-8、Latin-1、UTF-16 大小端、NUL 字节、空文件、只有空白、超限与恰好边界(2 MiB、100000 码点)、缺 `file`、缺标题、缺或错误 `language`、缺或过短 `requestId`、未知字段、追加路径携带 `language`、非 multipart 请求、未登录、恶意文件名不影响存储、重复上传只产生一章、同编号换内容 409、追加他人书籍 404、两账号隔离、上传与粘贴共用同一任务管线。
真实链路验证:真实 Go API+真实 MySQL 共 26 项检查通过(凭据只从本机安全配置读入进程),覆盖有效文件与阅读器原文逐字节一致、BOM 不进入原文、五类无效文件、越权与未登录拒绝、两账号隔离、恶意文件名不泄漏;随后用临时 Playwright 用例在真实学习端完成「登录→切换 TXT→选择真实 UTF-8 文件→上传→处理中就绪→阅读器原文逐字符一致」的闭环,并复核 UTF-16 文件在浏览器预检阶段被拒。截图保存在本机 `.local/evidence/`(issue9-invalid-encoding.png、issue9-upload-processing.png、issue9-reader.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。大文件并发上传只按单槽并发门设计,没有做多用户压力测试。UTF-16/GB18030 转码与按空行自动分章不在本单。
## #10 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9(沿用上一单的库),不借用其他测试库。本单**不改动数据库结构**,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 59 个顶层用例全部通过、0 跳过;含 #10 新增 9 个用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 94 项单测、类型检查、构建、7 项 E2E 全部通过 |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:改名不改状态也不新建任务、标题/正文校验、正文变化产生新版本且保留章节编号、**处理中改正文后旧任务只标 `superseded` 且不触碰新版本**、旧版本任务重试 409、重复保存与改回原内容不新建任务、内容与版本不一致时按 `content_changed` 失败、恢复扫描作废过期版本且不重排新版本、编辑用原文对任意状态可读、删除章节后序号连续且导航正确、删除书籍级联清理章节与任务、删除期间在途任务不复活内容、并发删除同一章一个成功一个 404、重复删除 404、跨用户改名/编辑/删除/读原文一律 404、未登录 401、个人词条与复习排期在删除后完全保留。
真实链路验证:真实 Go API+真实 MySQL 共 36 项检查通过(凭据只从本机安全配置读入进程,脚本可重复运行并自行清理 fixture),覆盖改名、编辑产生新版本并重新处理、重复保存幂等、删除章节重排序号、删除书籍级联、个人词条与复习队列在删除后保留、越权与非法输入拒绝;随后用临时 Playwright 用例在真实学习端完成「导入→改名→编辑正文→新版就绪→删除章节→删除书籍→书库提示」的闭环,并核对阅读器原文等于新版本。截图保存在本机 `.local/evidence/`(issue10-book-after-edit.png、issue10-chapter-deleted.png、issue10-book-deleted.png),临时用例运行后删除。
未验证:真实手机触屏详细证据与完整备份恢复演练仍属既有缺口(#14/#15);本单只用桌面浏览器检查。并发只覆盖「同一章节并发删除」与「处理中编辑」两类,没有做多用户压力测试。浏览器 textarea 会把该章的 CRLF 归一为 LF,属已知边界,已记入业务规则页。
## #11 验证与迁移(2026-09-11)
仓库根执行;Go 工具链由 `python scripts/server.py` 固定 go1.26.5。本单使用专用测试库 lexgo_test_issue9,不借用其他测试库。**本单不新增数据库列或表**,schema 保持 v6,没有迁移步骤,回退只需换回旧二进制。
| 命令 | 结果 |
|---|---|
| `go vet ./...` | 通过 |
| `LEXGO_TEST_DB_NAME=lexgo_test_issue9 python scripts/server.py test-integration` | 64 个顶层用例全部通过、0 跳过;含 #11 新增 5 个短语用例 |
| `cd learner`:`npx vitest --run` / `npx vue-tsc --build` / `npx pnpm run build` / `npx playwright test` | 106 项单测、类型检查、构建、11 项 E2E 全部通过(含 #11 新增 12 单测与 4 项 E2E) |
| `cd admin`:`npx pnpm test` / `npx pnpm lint` | 31 项与 lint 通过;管理端本单无代码改动 |
| `python -m unittest discover -s tests` / `python dev_scripts/harness.py check --strict` | 56 项与严格检查通过 |
覆盖内容:身份键的标点/换行/大小写/弯撇号归一、词数上限(12 与 13)、身份键与片段长度上限、切进单词被拒绝(服务端)、单词语义不被当成短语、跨章节匹配(同一短语在不同章节写法不同仍是同一条记录,两章都高亮,第一次出现两次)、最左最长与输入顺序无关、短语覆盖内部单词而不改动单词记录、短语进同一到期队列并作答(201 应用、重复提交回放)、越权 404 与未登录 401、编辑正文后不再出现则不报错且词条与排期保留、删除章节后词条保留;前端纯函数层覆盖整词对齐、内部保留、端点按词调整不反向、命中优先级与区间换算;E2E 覆盖**真实鼠标拖选**(Chromium 真实输入)→ 短语面板 → 保存 → 两次出现同时高亮 → 端点调整 → 点击已保存短语 → 复习整段挖空。
真实链路验证:真实 Go API+真实 MySQL 共 30 项检查通过(凭据只从本机安全配置读入进程,脚本自建两章 fixture 并在结束前删除书籍)。覆盖保存与身份键、显示原文保留标点、跨章节同一条记录与两处高亮、span 两端必须是词、短语内的单词仍是独立词条、单词/切词/伪造身份/越权/未登录的拒绝、到期队列与作答(含重复提交回放)、编辑正文后不再高亮但条目与排期保留、删除章节后条目保留。随后用临时 Playwright 用例在真实学习端完成「导入 → 选择范围 → 保存短语 → 两处高亮 → 点击已保存短语 → 复习整段挖空 → 清理 fixture」闭环,并用程序化选区在同一真实页面上验证范围映射与服务端身份一致。
未验证与已知限制:真实手机手柄与滚动的手感仍是 #4 起的既有缺口,本单只用桌面浏览器检查。**Playwright 的合成鼠标拖拽在真实页面上不会扩展原生选区**(在同一浏览器里对 mock 页面是成功的,程序化选区在真实页面也能唤起面板),因此真实链路的范围构建改用真实点击 + Shift 点击,连续拖选由 mock E2E 与程序化选区覆盖;真人鼠标拖选与真机手柄仍需人工复核。短语只在同一学习者与语言内匹配,不跨账号共享。
+27 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Product-Requirements-Overview.-
wiki_revision: b5eaaf1d0664e1e3c564079c857ccb5042540022
synchronized_at: 2026-09-11T08:31:32Z
wiki_revision: 45077556545aceb54ad03ba7871e8d3614fe2e01
synchronized_at: 2026-09-14T14:21:33Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览
@@ -242,6 +242,30 @@ Anki(U20)、YouTube/Jellyfin 远程字幕(U07/A08)、全量旧实例迁
## #7 交付范围更新(2026-09-11)
F07 的个人词语记录已实现,待用户验收:阅读器可以保存与修改个人释义、例句和状态(新词/学习中/已知/忽略),同一词形在本人其他章节显示相同状态与高亮,两个账号的数据互不影响。个人释义与共享 WordNet 词典分开存储,个人释义不进入审计日志。schema 升级为 v5,新增 lexgo_terms。
F07 的个人词语记录已于 2026-09-11 通过用户验收:阅读器可以保存与修改个人释义、例句和状态(新词/学习中/已知/忽略),同一词形在本人其他章节显示相同状态与高亮,两个账号的数据互不影响。个人释义与共享 WordNet 词典分开存储,个人释义不进入审计日志。schema 升级为 v5,新增 lexgo_terms。
仍未实现并留给后续工单:词汇库搜索与编辑(#12)、短语(#11)、到期复习与等级推进(#8)、阅读完成与进度(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。词形候选仍不提供上下文消歧,个人学习状态不按候选合并;例句不自动关联原文句子。
## #8 交付范围更新(2026-09-11)
F10 的单词到期复习已于 2026-09-11 通过用户验收(包含独立审核整改 R1~R3):按固定间隔表取本人当前语言的到期词条,正面显示词与挖空例句,显示答案后按「认识/答对」「不认识/答错」「再学一次」评分;答对升级并排下次复习,答错降级并立即回到本轮,再学一次不改等级并回到本轮。重复提交、网络重发与双标签页都不会重复更新次数和间隔;已知与忽略的词条不入队。管理端无改动。
仍未实现并留给后续工单:短语复习(#11)、词汇库搜索与编辑(#12)、阅读完成与进度统计(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。复习范围筛选与策略配置(X11)、练习模式(X08)、FSRS 仍在范围外。
## #9 交付范围更新(2026-09-11)
F03 的 TXT 文件导入已于 2026-09-13 通过用户验收:学习端导入页新增「粘贴文本 / TXT 文件」来源切换,选择 UTF-8 的 .txt 文件后经大小、空文件与编码校验进入与粘贴相同的处理与阅读流程,失败可重试。只支持 UTF-8(允许可选 BOM)且不替换损坏字符;UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;重复上传同一文件只产生一章。schema 无变化。
仍未实现并留给后续工单:书籍与章节的编辑删除(#10)、短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在本单范围。
## #10 交付范围更新(2026-09-11)
F01 的编辑与删除已于 2026-09-14 通过用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。
仍未实现并留给后续工单:短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。封面与音频附件(#21)、回收站/撤销、批量操作、章节跨书移动与语言变更不在本单范围。
## #11 交付范围更新(2026-09-11)
F08 的短语学习与 F10 的短语复习已实现,待用户验收:阅读器支持连续选择(桌面原生拖选、手机系统手势、面板按词调整端点),保存个人释义、例句与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,同一短语跨章节只存一条记录并在出现处高亮;复习卡片把整段短语挖成一个空。范围规则(整词对齐、内部标点保留、最多 12 词、最左最长重叠)与失效引用回退(高亮消失、学习记录保留)已固化。本次没有数据库结构变化。
仍未实现并留给后续工单:词汇库搜索与编辑(#12,含短语编辑界面)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑与真机手柄精细手感不在本单范围。
+11 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home
wiki_revision: 6fd4c754e3bb6ab215edb6077974fbd401f24103
synchronized_at: 2026-09-11T08:31:29Z
wiki_revision: 08cde00c48a957705ea5b2a9d3055ff3788e64b8
synchronized_at: 2026-09-14T14:21:32Z
<!-- gitea-wiki-mirror:end -->
# LexGo 文档入口
@@ -71,4 +71,12 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn
#5已验收,#17/#19/#20/#22/#23按依赖顺序合入main。#6已按用户确认的全Go方向实现英语词典与阅读点词,2026-09-11通过用户验收,PR #25已合入main;原文/账户隔离保留。正式NLP不使用Python服务。管理端“英语词典”导入指定WordNet3.0,学习端打开本人章节即可查词。个人释义与学习状态已实现持久化(#7,待用户验收)。详见#6工单、架构和本地开发页面。
#7 已实现个人词条(schema v5 新增 lexgo_terms):阅读器可保存释义、例句与状态,同一词形在本人其他章节显示一致高亮,两个账号数据独立;保存幂等,跨账号与篡改身份均被拒绝。本单待用户验收。
#7 个人词条已实现并于 2026-09-11 通过用户验收(schema v5 新增 lexgo_terms):阅读器可保存释义、例句与状态,同一词形在本人其他章节显示一致高亮,两个账号数据独立;保存幂等,跨账号与篡改身份均被拒绝;PR #26 已 fast-forward-only 合入 main。
#8 单词到期复习已于 2026-09-11 通过用户验收(schema v6 新增 lexgo_term_reviews 与 lexgo_review_answers):固定间隔表(1/2/4/7/15/30/60 天)、答对升级、答错或再学立即回队、已知与忽略不入队;重复提交、网络重发与双标签页都只记账一次,到期判定用 UTC 绝对时刻而不引入本地日边界。学习端新增「到期复习」入口,卡片正面显示词与挖空例句、答案面显示个人释义,并有完成页与空队列页。独立审核指出的并发同键 500、编辑文本重排复习与面板保存重置等级三项已整改并复测;PR #27 已 fast-forward-only 合入 main。第 3 阶段「首条学习闭环」#5~#8 全部验收。
#9 TXT 文件导入已于 2026-09-13 通过用户验收:导入页新增「粘贴文本 / TXT 文件」来源切换,只接受 UTF-8(允许可选 BOM)且不替换损坏字符,UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;上传与粘贴共用同一分章、任务与幂等规则,重复上传同一文件只产生一章。本次没有数据库结构变化;PR #28 已 fast-forward-only 合入 main。
#10 编辑与删除书籍章节已于 2026-09-14 通过用户验收:可改书名、改章节标题、编辑章节正文并按新版本重新处理,也可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果会被标为 superseded 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,已保存的个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化;旧处理结果不会覆盖新版本,PR #29 已 fast-forward-only 合入 main。
#11 短语选择、保存与复习已实现,待用户验收:在正文中连续选择一个范围(桌面原生拖选、手机系统手柄、面板端点按钮与 Shift 点击调整),保存个人释义与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,因此同一短语在不同章节只存一条记录并在出现的每处高亮,复习卡片把整段短语挖成一个空。短语与单词重叠时短语高亮覆盖、单词数据不变;编辑正文后短语不再出现时不报错,词条与复习排期保留。本次没有数据库结构变化。
+106
View File
@@ -0,0 +1,106 @@
import { expect, test, type Page } from '@playwright/test'
// Renaming, editing and deleting through the real dialogs of the accepted prototype, against
// a mocked API.
test('rename the book, edit a chapter into a new version and delete both', async ({ page }) => {
const user = { id: 42, username: 'fictional-editor', role: 'learner' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const first = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 24, errorReason: '', errorMessage: '', jobId: 5, ...timestamps }
const second = { id: 10, bookId: 1, ordinal: 2, title: 'Second chapter', status: 'ready', charCount: 12, errorReason: '', errorMessage: '', jobId: 6, ...timestamps }
let bookTitle = 'A small step'
let chapters = [first, second]
let firstText = 'Mira opened the workshop.\n'
let processing = false
const requests: string[] = []
const listBook = () => ({
book: { id: 1, title: bookTitle, language: 'en' },
chapters: chapters.map(item => (item.id === 9 && processing ? { ...item, status: 'processing' } : item)),
})
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
requests.push(`${method} ${path}`)
let data: unknown = null
let status = 200
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books' && method === 'GET') {
data = { items: bookTitle === '' ? [] : [{ id: 1, title: bookTitle, language: 'en', chapterCount: chapters.length, pendingCount: 0, processingCount: 0, readyCount: chapters.length, failedCount: 0, ...timestamps }] }
} else if (path === '/api/v1/books/1' && method === 'PATCH') {
bookTitle = (route.request().postDataJSON() as { title: string }).title
data = { book: { id: 1, title: bookTitle, language: 'en' } }
} else if (path === '/api/v1/books/1' && method === 'DELETE') {
chapters = []
bookTitle = ''
data = { deleted: { bookId: 1, chapters: 2, remaining: 0 } }
} else if (path === '/api/v1/books/1') data = listBook()
else if (path === '/api/v1/chapters/9/source') data = { source: { id: 9, bookId: 1, ordinal: 1, title: first.title, text: firstText, status: 'ready', contentSha256: 'sha-a', charCount: [...firstText].length } }
else if (path === '/api/v1/chapters/9' && method === 'PATCH') {
const body = route.request().postDataJSON() as { title?: string; text?: string }
const changed = body.text !== undefined && body.text !== firstText
if (body.title !== undefined) first.title = body.title
if (changed) {
firstText = body.text as string
processing = true
first.status = 'pending'
setTimeout(() => { processing = false; first.status = 'ready' }, 400)
}
status = 200
data = { chapter: first, job: changed ? { id: 7, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps } : null, versionChanged: changed }
} else if (path === '/api/v1/chapters/9' && method === 'DELETE') {
chapters = chapters.filter(item => item.id !== 9).map((item, index) => ({ ...item, ordinal: index + 1 }))
data = { deleted: { chapterId: 9, bookId: 1, remaining: chapters.length } }
}
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.getByRole('link', { name: 'A small step' }).click()
await expect(page.getByRole('heading', { name: 'A small step' })).toBeVisible()
await expect(page.getByText('封面使用系统默认样式')).toBeVisible()
// Rename the book through the dialog.
await page.getByTestId('edit-book').click()
await expect(page.getByLabel('书名', { exact: true })).toHaveValue('A small step')
await page.getByLabel('书名', { exact: true }).fill('A long step')
await page.getByTestId('save-book').click()
await expect(page.getByTestId('book-notice')).toContainText('书名已更新')
await expect(page.getByRole('heading', { name: 'A long step' })).toBeVisible()
// Edit the chapter text: the new version re-processes and later becomes ready again.
await page.getByTestId('edit-chapter-9').click()
await expect(page.getByLabel('章节标题', { exact: true })).toHaveValue('First chapter')
await expect(page.getByLabel('正文', { exact: true })).toHaveValue(firstText)
await page.getByLabel('正文', { exact: true }).fill('A replacement body.\n')
await page.getByTestId('save-chapter').click()
await expect(page.getByTestId('book-notice')).toContainText('已保存为新版本,正在重新处理')
// The saved version is queued first and becomes readable again when the worker finishes.
await expect(page.locator('.chapter-row').first()).toContainText('待处理')
await expect(page.locator('.chapter-row').first()).toContainText('已就绪', { timeout: 10000 })
// Deleting a chapter asks first, then reports the remaining count.
await page.getByTestId('edit-chapter-9').click()
await page.getByTestId('delete-chapter').click()
await expect(page.locator('.el-message-box__message').last()).toContainText('本章正文将被删除,已保存词条保留')
await page.locator('.el-message-box').last().getByRole('button', { name: '取消' }).click()
expect(requests.filter(entry => entry === 'DELETE /api/v1/chapters/9')).toHaveLength(0)
await page.getByTestId('delete-chapter').click()
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除章节' }).click()
await expect(page.getByTestId('book-notice')).toContainText('章节已删除 · 剩余 1 章')
await expect(page.locator('.chapter-row')).toHaveCount(1)
// Deleting the book asks first and returns to the library without it.
await page.getByTestId('delete-book').click()
await expect(page.locator('.el-message-box__message').last()).toContainText('已保存的生词和短语将保留')
await page.locator('.el-message-box').last().getByRole('button', { name: '确认删除' }).click()
await expect(page).toHaveURL(/\?deleted=\d+$/)
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await expect(page.getByTestId('library-notice')).toContainText('书籍已删除 · 已保存的生词和短语仍保留在生词本')
await expect(page.getByRole('link', { name: 'A long step' })).toHaveCount(0)
})
+130
View File
@@ -0,0 +1,130 @@
import { expect, test, type Page } from '@playwright/test'
// Real mouse selection over the rendered chapter, against a mocked API: the interaction #4
// verified is exercised as a continuous drag, not through a preset button.
const original = 'Take a small step\nevery day.\n'
const fragments: [string, 'word' | 'space' | 'punctuation'][] = [
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'],
['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'], ['.', 'punctuation'], ['\n', 'space'],
]
let cp = 0
let utf16 = 0
const tokens = fragments.map(([text, kind]) => {
const token = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
cp = token.end
utf16 = token.endUtf16
return token
})
const book = { id: 1, title: 'Phrase chapter', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const chapter = { id: 55, bookId: 1, ordinal: 1, title: 'Phrase chapter', status: 'ready', charCount: [...original].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: original, ...timestamps }
async function openChapter(page: Page, options: { phrases?: unknown[]; onPhrase?: (body: Record<string, unknown>) => void } = {}) {
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/chapters/55/tokens') data = { textSha256: 'fictional-sha', tokens, phrases: options.phrases ?? [] }
else if (path === '/api/v1/chapters/55') data = { book, chapter, navigation: { previousChapterId: null, nextChapterId: null } }
else if (path === '/api/v1/lookup') data = { status: 'not_found', query: 'Take', matchedForm: null, candidates: [], entries: [] }
else if (path === '/api/v1/phrases' && method === 'POST') {
options.onPhrase?.(route.request().postDataJSON() as Record<string, unknown>)
status = 201
data = {
term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: [], status: 'new', level: 0, kind: 'phrase', wordCount: 3 },
created: true,
}
} else if (path === '/api/v1/terms/9') data = { term: { id: 9, term: 'take a small', originalForm: 'Take a small', definition: '拿一小步', examples: ['Take a small step.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3 } }
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/chapters/55')
await expect(page.locator('.reader-text')).toBeVisible()
}
/** Drags from the centre of one word to the centre of another, like a person selecting text. */
async function dragWords(page: Page, from: string, to: string) {
const words = page.locator('.reader-word')
const start = words.filter({ hasText: new RegExp(`^${from}$`) }).first()
const end = words.filter({ hasText: new RegExp(`^${to}$`) }).first()
const startBox = (await start.boundingBox())!
const endBox = (await end.boundingBox())!
await page.mouse.move(startBox.x + startBox.width / 2, startBox.y + startBox.height / 2)
await page.mouse.down()
await page.mouse.move(endBox.x + endBox.width / 2, endBox.y + endBox.height / 2, { steps: 8 })
await page.mouse.up()
}
test('drag a continuous phrase, save it and highlight it', async ({ page }) => {
let body: Record<string, unknown> | undefined
await openChapter(page, { onPhrase: value => { body = value } })
await dragWords(page, 'Take', 'small')
const range = page.getByTestId('phrase-range')
await expect(range).toContainText('短语 · 3 个单词')
// A phrase that is not stored yet is labelled as a new entry.
await expect(page.locator('.lookup-panel')).toContainText('新词条')
await page.getByLabel(/^我的释义/).fill('拿一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-selected.png' })
await page.getByTestId('term-save').click()
await expect(page.getByText('已保存 · 新词')).toBeVisible()
// The code point range covers the whole run including its interior separator.
expect(body).toMatchObject({ chapterId: 55, start: 0, end: 12, definition: '拿一小步', status: 'new' })
// The saved phrase is underlined in the text.
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
})
test('adjust the endpoints by whole words from the panel', async ({ page }) => {
await openChapter(page)
await dragWords(page, 'Take', 'small')
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
await page.getByTestId('range-end-right').click()
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
await page.getByTestId('range-start-right').click()
await expect(page.getByTestId('phrase-range')).toContainText('3 个单词')
await page.getByTestId('range-start-left').click()
await expect(page.getByTestId('phrase-range')).toContainText('4 个单词')
})
test('open a saved phrase from its highlight', async ({ page }) => {
await openChapter(page, { phrases: [{ id: 9, status: 'new', wordCount: 3, startToken: 0, endToken: 4 }] })
await expect(page.locator('.reader-word.is-phrase')).toHaveCount(3)
await page.locator('.reader-word.is-phrase').first().click()
await expect(page.getByTestId('phrase-range')).toContainText('已保存')
await expect(page.getByLabel(/^我的释义/)).toHaveValue('拿一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-saved.png' })
})
test('a phrase review card masks the whole run as one blank', async ({ page }) => {
const user = { id: 42, username: 'fictional-phrase', role: 'learner' }
await page.addInitScript(() => sessionStorage.setItem('lexgo-learner-token', 'fictional-session'))
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/reviews/queue') {
data = {
items: [{
id: 9, term: 'a small step', originalForm: 'a small step', definition: '一小步',
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
dueAt: '2026-01-01T00:00:00Z', reviewCount: 0,
}],
total: 1,
}
}
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/review')
await expect(page.getByTestId('review-position')).toContainText('到期复习 · 1 / 1')
await expect(page.getByText('Take _____, every day.')).toBeVisible()
await expect(page.getByText('短语 · 3 个单词')).toBeVisible()
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('一小步')
await page.screenshot({ path: '../.local/evidence/issue11-phrase-review.png' })
})
+120
View File
@@ -0,0 +1,120 @@
import { expect, test } from '@playwright/test'
// The review round against a mocked API: queue, reveal, grades, relearn and the summary.
test('review the due words, requeue a missed one and finish the round', async ({ page }) => {
const user = { id: 42, username: 'fictional-reviewer', role: 'learner' }
const first = {
id: 7, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲',
examples: ['Learning begins with curiosity.'], status: 'new', level: 0,
dueAt: '2026-09-11T10:00:00Z', reviewCount: 0,
}
const second = {
id: 8, term: 'step', originalForm: 'step', definition: '一步',
examples: ['Take a small step, every day.'], status: 'learning', level: 2,
dueAt: '2026-09-11T10:00:00Z', reviewCount: 4,
}
// The wrong answer puts its word back in the same round, exactly as the server does.
const submitted: { grade: string; answerId: string }[] = []
let requeued = false
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/reviews/queue') {
data = { items: requeued ? [second] : [first, second], total: requeued ? 1 : 2 }
} else if (path.endsWith('/answers') && method === 'POST') {
const body = route.request().postDataJSON() as { answerId: string; grade: string; expectedDueAt: string }
submitted.push({ grade: body.grade, answerId: body.answerId })
expect(body.answerId).toMatch(/^[0-9a-f-]{36}$/)
const term = path.includes('8') ? second : first
// The client answers the card it was shown, so it echoes that card's due time.
expect(body.expectedDueAt).toBe(term.dueAt)
const wrong = body.grade === 'wrong'
requeued = wrong
const dueAtAfter = wrong ? '2026-09-11T10:05:00Z' : '2026-09-12T10:00:00Z'
term.dueAt = dueAtAfter
status = 201
data = {
result: 'applied', grade: body.grade, requeued: wrong,
statusBefore: term.status, statusAfter: wrong ? term.status : 'learning',
levelBefore: term.level, levelAfter: wrong ? 1 : term.level + 1,
dueAtBefore: body.expectedDueAt, dueAtAfter,
item: { ...term, dueAt: dueAtAfter, reviewCount: term.reviewCount + 1 },
}
}
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
// The library links into the due queue.
await page.getByRole('link', { name: '到期复习' }).click()
await expect(page).toHaveURL(/\/review$/)
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 1 / 2')
await expect(page.getByRole('heading', { name: 'curiosity' })).toBeVisible()
// The answer stays hidden, and the example shows a blank instead of the word.
await expect(page.getByText('Learning begins with _____.')).toBeVisible()
await expect(page.getByTestId('review-definition')).toHaveCount(0)
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('好奇心;求知欲')
await expect(page.getByTestId('review-correct')).toBeFocused()
await page.getByTestId('review-correct').click()
// The second word is learning level 2 and can be sent back into the round.
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 2 / 2')
await expect(page.getByText('学习中 · 等级 2')).toBeVisible()
await page.getByTestId('review-reveal').click()
await expect(page.getByTestId('review-definition')).toHaveText('一步')
await page.getByTestId('review-wrong').click()
// Requeued: the same word asks again and the round grows instead of pretending it ended.
await expect(page.getByTestId('review-position')).toHaveText('到期复习 · 3 / 3')
await expect(page.getByTestId('review-definition')).toHaveCount(0)
await page.getByTestId('review-reveal').click()
await page.getByTestId('review-correct').click()
await expect(page.getByTestId('review-summary')).toContainText('复习了 2 个词条 · 共 3 次作答')
await expect(page.getByTestId('review-summary')).toContainText('答对 2 · 答错或再学 1')
expect(submitted.map(entry => entry.grade)).toEqual(['correct', 'wrong', 'correct'])
expect(new Set(submitted.map(entry => entry.answerId)).size).toBe(3)
await page.getByTestId('review-finish').click()
await expect(page).toHaveURL(/\/$/)
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
})
test('an empty due queue says so instead of showing a card', async ({ page }) => {
const user = { id: 42, username: 'fictional-reviewer', role: 'learner' }
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
let data: unknown = null
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books') data = { items: [] }
else if (path === '/api/v1/reviews/queue') data = { items: [], total: 0 }
await route.fulfill({ json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await page.goto('/review')
await expect(page.getByTestId('review-empty')).toContainText('今天没有到期词条')
await expect(page.getByTestId('review-card')).toHaveCount(0)
// The narrow layout keeps the grading controls reachable without horizontal overflow.
await page.setViewportSize({ width: 390, height: 844 })
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
+76
View File
@@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test'
// The TXT upload path against a mocked API: pre-checks, multipart body and the hand-off to
// the same processing screen the paste path uses.
test('upload a UTF-8 TXT file and open the created book', async ({ page }) => {
const user = { id: 42, username: 'fictional-uploader', role: 'learner' }
const book = { id: 1, title: 'Studio Notes', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const pasted = 'Mira opened the workshop.\r\n\r\n\tThe sign read “A small step…”\n'
let uploaded = false
await page.route('**/api/v1/**', async route => {
const path = new URL(route.request().url()).pathname
const method = route.request().method()
let data: unknown = null
let status = 200
if (path === '/api/v1/login') data = { token: 'fictional-session', user }
else if (path === '/api/v1/me') data = user
else if (path === '/api/v1/space') data = { ownerId: user.id, language: 'en' }
else if (path === '/api/v1/books' && method === 'GET') data = { items: uploaded ? [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }] : [] }
else if (path === '/api/v1/books/upload') {
// The upload must arrive as multipart: the fields and the file are inspected directly.
expect(route.request().headers()['content-type']).toContain('multipart/form-data')
const raw = route.request().postData() ?? ''
expect(raw).toContain('name="requestId"')
expect(raw).toContain('name="language"')
expect(raw).toContain('name="title"')
expect(raw).toContain('Studio Notes')
expect(raw).toContain('filename="notes.txt"')
expect(raw).toContain('Mira opened the workshop.')
uploaded = true
status = 201
data = {
book,
chapter: { id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'pending', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, ...timestamps },
job: { id: 5, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps },
duplicate: false,
}
} else if (path === '/api/v1/books/1') data = { book, chapters: [{ id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'ready', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, ...timestamps }] }
else if (path === '/api/v1/chapters/9') data = { book, chapter: { id: 9, bookId: 1, ordinal: 1, title: 'Studio Notes', status: 'ready', charCount: [...pasted].length, errorReason: '', errorMessage: '', jobId: 5, contentSha256: 'fictional-sha', originalText: pasted, ...timestamps }, navigation: { previousChapterId: null, nextChapterId: null } }
await route.fulfill({ status, json: { code: 200, data } })
})
await page.goto('/')
await page.getByLabel('账号').fill(user.username)
await page.getByLabel('密码', { exact: true }).fill('fictional-password')
await page.getByRole('button', { name: '登录', exact: true }).click()
await expect(page.getByRole('heading', { name: '我的书库' })).toBeVisible()
await page.getByRole('button', { name: '导入内容' }).click()
// Element Plus hides the native radio behind a styled span, so the label is what a person
// clicks; the input still carries the checked state.
await page.locator('label.el-radio', { hasText: 'TXT 文件' }).click()
await expect(page.getByRole('radio', { name: 'TXT 文件' })).toBeChecked()
await expect(page.locator('textarea#text')).toHaveCount(0)
await expect(page.getByText('仅支持 UTF-8')).toBeVisible()
// An unusable file is refused in the browser, before any request is made.
await page.setInputFiles('[data-testid="file-input"]', { name: 'notes.md', mimeType: 'text/markdown', buffer: Buffer.from('# heading\n') })
await expect(page.getByText('请选择 .txt 文件。')).toBeVisible()
// A UTF-8 file is accepted and its metadata is shown; the title comes from the file name.
await page.setInputFiles('[data-testid="file-input"]', { name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from(pasted, 'utf8') })
await expect(page.getByTestId('file-info')).toContainText('notes.txt · UTF-8')
await expect(page.getByLabel('标题')).toHaveValue('notes')
await page.getByLabel('标题').fill('Studio Notes')
await page.getByRole('button', { name: '上传并处理' }).click()
await expect(page).toHaveURL(/\/books\/1$/)
await expect(page.getByText('已就绪')).toBeVisible()
await page.getByRole('link', { name: 'Studio Notes' }).click()
const readerText = page.locator('.reader-text')
await expect(readerText).toBeVisible()
// The uploaded bytes reached the reader unchanged.
expect(await readerText.evaluate(element => element.textContent)).toBe(pasted)
})
+3
View File
@@ -8,6 +8,9 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: 0,
reporter: 'list',
// Every spec loads the whole SPA from the shared dev server, so a slow first module graph
// under parallel workers must not fail an assertion that the app itself would pass.
expect: { timeout: 15000 },
use: { baseURL: 'http://127.0.0.1:5173', headless: true, trace: 'off' },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chrome' } }],
webServer: { command: 'npm run dev -- --host 127.0.0.1', url: 'http://127.0.0.1:5173', reuseExistingServer: !process.env.CI },
+237
View File
@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import BookView from '../views/BookView.vue'
import { useLibraryStore, type ChapterSource, type ChapterSummary } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-editor', role: 'learner' as const }
const book = { id: 1, title: 'A small step', language: 'en' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const chapter = (overrides: Partial<ChapterSummary> = {}): ChapterSummary => ({
id: 9, bookId: 1, ordinal: 1, title: 'First chapter', status: 'ready', charCount: 12,
errorReason: '', errorMessage: '', jobId: 5, ...timestamps, ...overrides,
})
const source: ChapterSource = { id: 9, bookId: 1, ordinal: 1, title: 'First chapter', text: 'Mira opened the workshop.\n', status: 'ready', contentSha256: 'sha-a', charCount: 24 }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const fail = (msg: string, status = 400) => new Response(JSON.stringify({ code: status, msg }), { status })
let wrapper: VueWrapper | undefined
function stub(name: string) {
return { template: `<div>${name}</div>` }
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: stub('LibraryStub') },
{ path: '/books/:id', component: stub('BookStub') },
{ path: '/import', component: stub('ImportStub') },
],
})
await router.push(path)
await router.isReady()
return router
}
/** Routes the book page's own calls; a test can override any of them. */
function mockApi(overrides: Record<string, (init?: RequestInit) => Response | Promise<Response>> = {}): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
const method = String((init as RequestInit | undefined)?.method ?? 'GET')
for (const [key, handler] of Object.entries(overrides)) {
if (url.includes(key)) return handler(init as RequestInit)
}
if (url.endsWith('/books/1') && method === 'GET') return ok({ book, chapters: [chapter()] })
if (url.includes('/chapters/9/source')) return ok({ source })
// A rename answers with the stored book, and a delete reports what it removed.
if (url.endsWith('/books/1') && method === 'PATCH') {
const body = JSON.parse(String((init as RequestInit | undefined)?.body ?? '{}')) as { title?: string }
return ok({ book: { ...book, title: body.title ?? book.title } })
}
if (url.endsWith('/books/1') && method === 'DELETE') return ok({ deleted: { bookId: 1, chapters: 3 } })
if (url.endsWith('/books/1')) return ok({ book })
if (url.includes('/chapters/9')) return ok({ chapter: chapter(), job: null, versionChanged: false })
return ok({})
})
}
async function openBook() {
useSessionStore().user = { ...user }
const router = await viewAt('/books/1')
wrapper = mount(BookView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { view: wrapper, router }
}
describe('book editing store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('renames a book and keeps the list entry in step', async () => {
const fetchMock = mockApi()
const library = useLibraryStore()
library.books = [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }]
library.book = { ...book }
const renamed = await library.renameBook(1, ' New name ')
expect(renamed.title).toBe('New name')
expect(library.book?.title).toBe('New name')
expect(library.books[0]?.title).toBe('New name')
const call = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
expect(String(call[0])).toBe('/api/v1/books/1')
expect(JSON.parse(String((call[1] as RequestInit).body))).toEqual({ title: 'New name' })
})
it('refuses an invalid title before calling the API', async () => {
const fetchMock = mockApi()
const library = useLibraryStore()
await expect(library.renameBook(1, ' ')).rejects.toThrow('请填写标题')
expect(fetchMock).not.toHaveBeenCalled()
})
it('applies a new chapter version and drops the stale reader text', async () => {
mockApi({ '/chapters/9': () => ok({ chapter: chapter({ status: 'pending', title: 'Edited' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true }) })
const library = useLibraryStore()
library.chapters = [chapter()]
library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'old text' }
const result = await library.updateChapter(9, { title: 'Edited', text: 'new text' })
expect(result.versionChanged).toBe(true)
expect(library.chapters[0]).toMatchObject({ status: 'pending', title: 'Edited', jobId: 6 })
// The reader must not keep showing text that is no longer the stored version.
expect(library.chapter?.originalText).toBeUndefined()
})
it('keeps the reader text when only the title changed', async () => {
mockApi({ '/chapters/9': () => ok({ chapter: chapter({ title: 'Renamed' }), job: null, versionChanged: false }) })
const library = useLibraryStore()
library.chapters = [chapter()]
library.chapter = { ...chapter(), contentSha256: 'sha-a', originalText: 'kept text' }
await library.updateChapter(9, { title: 'Renamed' })
expect(library.chapter?.originalText).toBe('kept text')
expect(library.chapter?.title).toBe('Renamed')
})
it('deletes a chapter, reloads the book and removes a deleted book from the list', async () => {
mockApi({
'/chapters/9': () => ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }),
'/books/1': (init) => (init?.method === 'DELETE'
? ok({ deleted: { bookId: 1, chapters: 3 } })
: ok({ book, chapters: [] })),
})
const library = useLibraryStore()
library.books = [{ ...book, chapterCount: 3, pendingCount: 0, processingCount: 0, readyCount: 3, failedCount: 0, ...timestamps }]
library.book = { ...book }
library.chapters = [chapter(), chapter({ id: 10, ordinal: 2 }), chapter({ id: 11, ordinal: 3 })]
const deleted = await library.deleteChapter(9)
expect(deleted.remaining).toBe(2)
expect(library.chapters.map(item => item.id)).toEqual([])
expect(library.book?.id).toBe(1)
const removed = await library.deleteBook(1)
expect(removed.chapters).toBe(3)
expect(library.books).toHaveLength(0)
expect(library.book).toBeNull()
})
it('reads the editable source of any chapter state', async () => {
mockApi({ '/chapters/9/source': () => ok({ source: { ...source, status: 'failed' } }) })
const library = useLibraryStore()
const loaded = await library.loadChapterSource(9)
expect(loaded.text).toBe(source.text)
expect(loaded.status).toBe('failed')
})
})
describe('book editing view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; useLibraryStore().stopPolling() })
it('renames the book through the dialog and reports it', async () => {
const fetchMock = mockApi()
const { view } = await openBook()
await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises()
const input = view.get('#book-title')
expect((input.element as HTMLInputElement).value).toBe('A small step')
await input.setValue(' Edited name ')
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
const patch = fetchMock.mock.calls.find(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited name' })
expect(view.get('[data-testid="book-notice"]').text()).toContain('书名已更新')
})
it('keeps the dialog open with the server message when renaming fails', async () => {
mockApi({ '/books/1': (init) => (init?.method === 'PATCH' ? fail('书名已存在') : ok({ book, chapters: [chapter()] })) })
const { view } = await openBook()
await view.get('[data-testid="edit-book"]').trigger('click'); await flushPromises()
await view.get('#book-title').setValue('Rejected')
await view.get('[data-testid="save-book"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('书名已存在')
expect(view.get('#book-title')).toBeTruthy()
})
it('asks before deleting the book and does nothing when the learner cancels', async () => {
const fetchMock = mockApi()
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
const { view } = await openBook()
await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining('已保存的生词和短语将保留'), '删除书籍', expect.anything())
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'DELETE')).toBe(false)
})
it('deletes the book after confirmation and returns to the library', async () => {
const fetchMock = mockApi()
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view, router } = await openBook()
await view.get('[data-testid="delete-book"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/books/1') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(router.currentRoute.value.path).toBe('/')
expect(router.currentRoute.value.query.deleted).toBe('3')
})
it('edits a chapter: loads the source, saves title and text and reports the new version', async () => {
const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'PATCH'
? ok({ chapter: chapter({ status: 'pending', title: 'Edited chapter' }), job: { id: 6, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, versionChanged: true })
: ok({ source })) })
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
expect((view.get('#chapter-title').element as HTMLInputElement).value).toBe('First chapter')
expect((view.get('#chapter-text').element as HTMLTextAreaElement).value).toBe(source.text)
await view.get('#chapter-title').setValue('Edited chapter')
await view.get('#chapter-text').setValue('A new body.\n')
await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises()
const patch = fetchMock.mock.calls.find(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toEqual({ title: 'Edited chapter', text: 'A new body.\n' })
expect(view.get('[data-testid="book-notice"]').text()).toContain('已保存为新版本,正在重新处理')
})
it('refuses an empty chapter body locally and keeps the dialog', async () => {
const fetchMock = mockApi()
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
await view.get('#chapter-text').setValue(' \n\t ')
await view.get('[data-testid="save-chapter"]').trigger('click'); await flushPromises()
expect(view.text()).toContain('请粘贴要导入的英文正文。')
expect(fetchMock.mock.calls.some(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')).toBe(false)
})
it('deletes a chapter after confirmation and reports the remaining count', async () => {
const fetchMock = mockApi({ '/chapters/9': (init) => (init?.method === 'DELETE' ? ok({ deleted: { chapterId: 9, bookId: 1, remaining: 2 } }) : ok({ source })) })
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
const { view } = await openBook()
await view.get('[data-testid="edit-chapter-9"]').trigger('click'); await flushPromises()
await view.get('[data-testid="delete-chapter"]').trigger('click'); await flushPromises()
expect(fetchMock.mock.calls.some(([url, init]) => String(url).endsWith('/chapters/9') && (init as RequestInit | undefined)?.method === 'DELETE')).toBe(true)
expect(view.get('[data-testid="book-notice"]').text()).toContain('章节已删除 · 剩余 2 章')
// Element Plus keeps a closed dialog in the DOM, so the closed state is what matters.
expect(view.get('[data-testid="chapter-dialog"]').isVisible()).toBe(false)
})
it('shows an empty chapter list with the import hint', async () => {
mockApi({ '/books/1': () => ok({ book, chapters: [] }) })
const { view } = await openBook()
expect(view.get('[data-testid="empty-chapters"]').text()).toContain('这一本书还没有章节')
expect(view.get('[data-testid="delete-book"]')).toBeTruthy()
})
})
+2 -1
View File
@@ -21,7 +21,8 @@ const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', ori
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const result = (query: string, status = 'exact') => ({ status, query, matchedForm: query, candidates: [], entries: status === 'exact' ? [{ lemma: query, pos: 'noun', definition: `Definition of ${query}`, examples: ['A fictional example.'] }] : [] })
const savedTerm = (overrides: Partial<SavedTerm> = {}): SavedTerm => ({
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2, ...overrides,
id: 7, term: 'cats', originalForm: 'Cats', definition: '猫', examples: ['A fictional example.'], status: 'learning', level: 2,
kind: 'word', wordCount: 1, ...overrides,
})
let wrapper: VueWrapper | undefined
interface OpenOptions {
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory } from 'vue-router'
import ReaderView from '../views/ReaderView.vue'
import { useSessionStore } from '../stores/session'
import type { PhraseSpan, ReaderToken } from '../composables/useReaderLookup'
// The same fixture shape the phrase tests use on the server side.
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
const fragments: [string, ReaderToken['kind']][] = [
['Take', 'word'], [' ', 'space'], ['a', 'word'], [' ', 'space'], ['small', 'word'], [',', 'punctuation'],
[' ', 'space'], ['step', 'word'], ['\n', 'space'], ['every', 'word'], [' ', 'space'], ['day', 'word'],
['.', 'punctuation'], ['\n', 'space'], ['Mira', 'word'], [' ', 'space'], ['took', 'word'], [' ', 'space'],
['a', 'word'], [' ', 'space'], ['small', 'word'], [' ', 'space'], ['step', 'word'], [' ', 'space'],
['again', 'word'], ['.', 'punctuation'], ['\n', 'space'],
]
let cp = 0
let utf16 = 0
const tokens: ReaderToken[] = fragments.map(([text, kind]) => {
const token: ReaderToken = { text, kind, start: cp, end: cp + [...text].length, startUtf16: utf16, endUtf16: utf16 + text.length }
cp = token.end
utf16 = token.endUtf16
return token
})
const chapter = { id: 55, bookId: 1, title: '虚构章节', status: 'ready', originalText: original, contentSha256: 'same-sha' }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const savedPhrase = {
id: 7, term: 'a small step', originalForm: 'a small, step', definition: '一小步',
examples: ['Take a small step, every day.'], status: 'new', level: 0, kind: 'phrase', wordCount: 3,
}
let wrapper: VueWrapper | undefined
interface OpenOptions {
phrases?: PhraseSpan[]
tokens?: ReaderToken[]
original?: string
save?: (body: Record<string, unknown>) => Promise<Response>
termRead?: () => Promise<Response>
}
async function open(options: OpenOptions = {}) {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/tokens')) return ok({ textSha256: 'same-sha', tokens: options.tokens ?? tokens, phrases: options.phrases ?? [] })
if (url.endsWith('/lookup')) return ok({ status: 'not_found', query: 'a', matchedForm: null, candidates: [], entries: [] })
if (url.includes('/terms/')) return options.termRead ? options.termRead() : ok({ term: savedPhrase })
if (url.endsWith('/phrases') || url.endsWith('/terms')) {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
if (options.save) return options.save(body)
return ok({ term: savedPhrase, created: true })
}
// A test that supplies its own tokens must also supply the matching chapter text.
const text2 = options.original ?? original
return ok({ chapter: { ...chapter, originalText: text2 }, navigation: { previousChapterId: null, nextChapterId: 56 } })
})
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/chapters/:id', component: ReaderView }, { path: '/', component: { template: '<div />' } }] })
await router.push('/chapters/55')
wrapper = mount(ReaderView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { fetchMock, router, view: wrapper }
}
/** Clicks one word and shift-clicks the next matching word after it to extend the range. */
async function shiftSelect(view: VueWrapper, first: string, last: string) {
const words = view.findAll('.reader-word')
const startIndex = words.findIndex(item => item.text() === first)
const endIndex = words.findIndex((item, index) => index > startIndex && item.text() === last)
await words[startIndex]!.trigger('click')
await flushPromises()
await words[endIndex]!.trigger('click', { shiftKey: true })
await flushPromises()
}
describe('phrase selection and panel', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
it('builds a phrase from a shift-click range and saves it through the phrases endpoint', async () => {
let body: Record<string, unknown> | undefined
const { view } = await open({ save: async value => { body = value; return ok({ term: savedPhrase, created: true }) } })
await shiftSelect(view, 'a', 'step')
// The panel names the phrase and shows how many words it holds.
expect(view.get('[data-testid="phrase-range"]').text()).toContain('短语 · 3 个单词')
expect(view.text()).toContain('新词条')
// A phrase has no dictionary lookup section.
expect(view.find('[data-testid="lookup-retry"]').exists()).toBe(false)
await view.get('#term-definition').setValue('一小步')
await view.get('[data-testid="term-save"]').trigger('click')
await flushPromises()
// The selection travels as code point offsets of the whole phrase, punctuation included.
expect(body).toMatchObject({ chapterId: 55, start: 5, end: 18, definition: '一小步', status: 'new' })
expect(view.text()).toContain('已保存')
// The saved phrase highlights immediately.
expect(view.findAll('.reader-word').some(item => item.classes().includes('is-phrase'))).toBe(true)
})
it('adjusts both ends by whole words from the panel', async () => {
const { view } = await open()
await shiftSelect(view, 'a', 'step')
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
await view.get('[data-testid="range-start-right"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
// Moving the start back restores the three-word phrase.
await view.get('[data-testid="range-start-left"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('3 个单词')
// A two-word phrase cannot shrink further, so the shrinking buttons are disabled.
await view.get('[data-testid="range-start-right"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('2 个单词')
expect(view.get('[data-testid="range-start-right"]').attributes('disabled')).toBeDefined()
expect(view.get('[data-testid="range-end-left"]').attributes('disabled')).toBeDefined()
// Growing it is still possible on either side.
expect(view.get('[data-testid="range-start-left"]').attributes('disabled')).toBeUndefined()
expect(view.get('[data-testid="range-end-right"]').attributes('disabled')).toBeUndefined()
})
it('opens the saved phrase when its highlighted range is clicked', async () => {
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
const { view, fetchMock } = await open({ phrases: [span] })
const inside = view.findAll('.reader-word').find(item => item.text() === 'small')!
expect(inside.classes()).toContain('is-phrase')
await inside.trigger('click')
await flushPromises()
// The stored entry is read through the same route a word uses, and the panel edits it.
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/terms/7'))).toBe(true)
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
})
it('stops claiming a stored phrase once its range is adjusted', async () => {
const span: PhraseSpan = { id: 7, status: 'new', wordCount: 3, startToken: 2, endToken: 7 }
const { view } = await open({ phrases: [span] })
await view.findAll('.reader-word').find(item => item.text() === 'small')!.trigger('click')
await flushPromises()
expect(view.get('[data-testid="phrase-range"]').text()).toContain('已保存')
await view.get('[data-testid="range-end-right"]').trigger('click')
await flushPromises()
// The adjusted range is another identity, so the panel no longer says it is stored, while
// the text the learner already had stays in the form.
expect(view.get('.lookup-panel').text()).toContain('新词条')
expect(view.get('.lookup-panel').text()).not.toContain('已保存')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
})
it('refuses a range longer than the phrase limit and keeps the panel closed', async () => {
// One word more than a phrase may hold.
const longTokens: ReaderToken[] = []
const words: string[] = []
let cursor = 0
for (let index = 0; index < 14; index++) {
const word = `w${index}`
words.push(word)
longTokens.push({ text: word, start: cursor, end: cursor + word.length, startUtf16: cursor, endUtf16: cursor + word.length, kind: 'word' })
cursor += word.length
longTokens.push({ text: ' ', start: cursor, end: cursor + 1, startUtf16: cursor, endUtf16: cursor + 1, kind: 'space' })
cursor += 1
}
const { view } = await open({ tokens: longTokens, original: longTokens.map(token => token.text).join('') })
await shiftSelect(view, words[0]!, words[13]!)
expect(view.get('[data-testid="range-notice"]').text()).toContain('短语最多 12 个单词')
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(false)
})
it('closes the phrase panel with Escape and restores the reading position', async () => {
const { view } = await open()
await shiftSelect(view, 'a', 'step')
expect(view.find('[data-testid="phrase-range"]').exists()).toBe(true)
await view.get('.lookup-panel').trigger('keydown', { key: 'Escape' })
await flushPromises()
expect(view.find('.lookup-panel').exists()).toBe(false)
})
it('keeps the typed phrase text when saving fails', async () => {
const { view } = await open({ save: async () => new Response(JSON.stringify({ code: 400, msg: '短语过长,请缩短选择范围' }), { status: 400 }) })
await shiftSelect(view, 'a', 'step')
await view.get('#term-definition').setValue('虚构释义')
await view.get('[data-testid="term-save"]').trigger('click')
await flushPromises()
expect(view.text()).toContain('短语过长,请缩短选择范围')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('虚构释义')
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, rangeOfWords, wordIndices } from '../composables/readerRange'
import type { ReaderToken } from '../composables/useReaderLookup'
// The same text #4 verified: interior punctuation, a line break inside the phrase, a repeated
// phrase and a combining accent.
const original = 'Take a small, step\nevery day.\nMira took a small step again.\n'
const tokens: ReaderToken[] = [
{ text: 'Take', start: 0, end: 4, startUtf16: 0, endUtf16: 4, kind: 'word' },
{ text: ' ', start: 4, end: 5, startUtf16: 4, endUtf16: 5, kind: 'space' },
{ text: 'a', start: 5, end: 6, startUtf16: 5, endUtf16: 6, kind: 'word' },
{ text: ' ', start: 6, end: 7, startUtf16: 6, endUtf16: 7, kind: 'space' },
{ text: 'small', start: 7, end: 12, startUtf16: 7, endUtf16: 12, kind: 'word' },
{ text: ',', start: 12, end: 13, startUtf16: 12, endUtf16: 13, kind: 'punctuation' },
{ text: ' ', start: 13, end: 14, startUtf16: 13, endUtf16: 14, kind: 'space' },
{ text: 'step', start: 14, end: 18, startUtf16: 14, endUtf16: 18, kind: 'word' },
{ text: '\n', start: 18, end: 19, startUtf16: 18, endUtf16: 19, kind: 'space' },
{ text: 'every', start: 19, end: 24, startUtf16: 19, endUtf16: 24, kind: 'word' },
{ text: ' ', start: 24, end: 25, startUtf16: 24, endUtf16: 25, kind: 'space' },
{ text: 'day', start: 25, end: 28, startUtf16: 25, endUtf16: 28, kind: 'word' },
{ text: '.', start: 28, end: 29, startUtf16: 28, endUtf16: 29, kind: 'punctuation' },
{ text: '\n', start: 29, end: 30, startUtf16: 29, endUtf16: 30, kind: 'space' },
{ text: 'Mira', start: 30, end: 34, startUtf16: 30, endUtf16: 34, kind: 'word' },
{ text: ' ', start: 34, end: 35, startUtf16: 34, endUtf16: 35, kind: 'space' },
{ text: 'took', start: 35, end: 39, startUtf16: 35, endUtf16: 39, kind: 'word' },
{ text: ' ', start: 39, end: 40, startUtf16: 39, endUtf16: 40, kind: 'space' },
{ text: 'a', start: 40, end: 41, startUtf16: 40, endUtf16: 41, kind: 'word' },
{ text: ' ', start: 41, end: 42, startUtf16: 41, endUtf16: 42, kind: 'space' },
{ text: 'small', start: 42, end: 47, startUtf16: 42, endUtf16: 47, kind: 'word' },
{ text: ' ', start: 47, end: 48, startUtf16: 47, endUtf16: 48, kind: 'space' },
{ text: 'step', start: 48, end: 52, startUtf16: 48, endUtf16: 52, kind: 'word' },
{ text: ' ', start: 52, end: 53, startUtf16: 52, endUtf16: 53, kind: 'space' },
{ text: 'again', start: 53, end: 58, startUtf16: 53, endUtf16: 58, kind: 'word' },
{ text: '.', start: 58, end: 59, startUtf16: 58, endUtf16: 59, kind: 'punctuation' },
{ text: '\n', start: 59, end: 60, startUtf16: 59, endUtf16: 60, kind: 'space' },
]
describe('reader phrase ranges', () => {
it('keeps interior punctuation and line breaks in the selected text', () => {
const range = normalizeTokenRange(tokens, original, 2, 7)
expect(range).not.toBeNull()
expect(range).toMatchObject({ firstWord: 2, lastWord: 7, start: 5, end: 18, wordCount: 3 })
expect(range!.text).toBe('a small, step')
// The second occurrence keeps its own spelling and offsets.
const second = normalizeTokenRange(tokens, original, 18, 22)
expect(second!.text).toBe('a small step')
expect(second!.start).toBe(40)
})
it('aligns both ends to whole words and never selects one word as a phrase', () => {
// A selection that starts on a separator begins at the next whole word, and one that ends
// on a separator ends at the previous one: words outside the selection stay untouched.
expect(normalizeTokenRange(tokens, original, 3, 7)!.firstWord).toBe(4)
expect(normalizeTokenRange(tokens, original, 2, 5)!.lastWord).toBe(4)
expect(normalizeTokenRange(tokens, original, 2, 7)!.text).toBe('a small, step')
expect(normalizeTokenRange(tokens, original, 2, 3)).toBeNull()
expect(normalizeTokenRange(tokens, original, 5, 5)).toBeNull()
expect(normalizeTokenRange(tokens, original, -1, 7)).toBeNull()
expect(normalizeTokenRange(tokens, original, 2, tokens.length + 5)).toBeNull()
})
it('reverses a backwards selection and refuses more than twelve words', () => {
const range = normalizeTokenRange(tokens, original, 7, 2)
expect(range).toMatchObject({ firstWord: 2, lastWord: 7 })
// The fixture is exactly twelve words, which is still a phrase.
expect(normalizeTokenRange(tokens, original, 0, tokens.length - 1)!.wordCount).toBe(MAX_PHRASE_WORDS)
const longer: ReaderToken[] = []
let offset = 0
for (let index = 0; index < MAX_PHRASE_WORDS + 1; index++) {
const word = `word${index}`
longer.push({ text: word, start: offset, end: offset + word.length, startUtf16: offset, endUtf16: offset + word.length, kind: 'word' })
offset += word.length
longer.push({ text: ' ', start: offset, end: offset + 1, startUtf16: offset, endUtf16: offset + 1, kind: 'space' })
offset += 1
}
expect(normalizeTokenRange(longer, longer.map(token => token.text).join(''), 0, longer.length - 1)).toBeNull()
})
it('moves one end by whole words and never inverts the range', () => {
const range = normalizeTokenRange(tokens, original, 2, 7)!
const shorterStart = adjustTokenRange(tokens, original, range, 'start', 1)
expect(shorterStart).toMatchObject({ firstWord: 4, lastWord: 7, wordCount: 2 })
expect(shorterStart!.text).toBe('small, step')
// The start can move left while a word remains before it, and not past the first word.
expect(adjustTokenRange(tokens, original, range, 'start', -1)).toMatchObject({ firstWord: 0, lastWord: 7 })
expect(adjustTokenRange(tokens, original, rangeOfWords(tokens, original, 0, 7)!, 'start', -1)).toBeNull()
const longerEnd = adjustTokenRange(tokens, original, range, 'end', 1)
expect(longerEnd).toMatchObject({ firstWord: 2, lastWord: 9, wordCount: 4 })
expect(longerEnd!.text).toBe('a small, step\nevery')
const back = adjustTokenRange(tokens, original, longerEnd!, 'end', -1)!
expect(back.text).toBe(range.text)
// Moving the end before the start is refused even when the direction is legal.
const twoWords = rangeOfWords(tokens, original, 2, 4)!
expect(adjustTokenRange(tokens, original, twoWords, 'end', -1)).toBeNull()
})
it('finds the phrase covering a word and builds its range again', () => {
const phrases = [
{ id: 1, startToken: 2, endToken: 7, status: 'new', wordCount: 3 },
{ id: 2, startToken: 4, endToken: 7, status: 'learning', wordCount: 2 },
]
// Overlapping spans: the longer phrase wins for a word both cover.
expect(phrasesAt(tokens, phrases, 4)?.id).toBe(1)
expect(phrasesAt(tokens, phrases, 7)?.id).toBe(1)
expect(phrasesAt(tokens, phrases, 0)).toBeNull()
const span = rangeOfSpan(tokens, original, { startToken: 2, endToken: 7 })!
expect(span.text).toBe('a small, step')
expect(rangeOfWords(tokens, original, 4, 7)!.text).toBe('small, step')
// A span that covers a single word is not a phrase.
expect(rangeOfSpan(tokens, original, { startToken: 2, endToken: 2 })).toBeNull()
})
})
+255
View File
@@ -0,0 +1,255 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import ReviewView from '../views/ReviewView.vue'
import { useSessionStore } from '../stores/session'
import { clozeSentence, maskedPrompt, useReviewStore, type ReviewItem } from '../stores/review'
const item = (overrides: Partial<ReviewItem> = {}): ReviewItem => ({
id: 7, term: 'dogs', originalForm: 'Dogs', definition: '狗', examples: ['Dogs went home.'],
status: 'new', level: 0, kind: 'word', wordCount: 1, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
const answer = (overrides: Record<string, unknown> = {}) => ({
result: 'applied', grade: 'correct', requeued: false, statusBefore: 'new', statusAfter: 'learning',
levelBefore: 0, levelAfter: 1, dueAtBefore: '2026-09-11T10:00:00Z', dueAtAfter: '2026-09-12T10:00:00Z',
item: item({ status: 'learning', level: 1, dueAt: '2026-09-12T10:00:00Z', reviewCount: 1 }), ...overrides,
})
const answerCalls = (fetchMock: MockInstance) => fetchMock.mock.calls.filter(call => String(call[0]).includes('/answers'))
let wrapper: VueWrapper | undefined
describe('review prompt', () => {
it('masks a whole phrase as one blank and keeps the word rule for words', () => {
const phrase = item({ kind: 'phrase', wordCount: 3, term: 'a small step', originalForm: 'a small step', examples: ['Take a small step, every day.'] })
expect(maskedPrompt(phrase)).toBe('Take _____, every day.')
expect(maskedPrompt(item({ ...phrase, examples: ['Take a small,\nstep today.'] }))).toBe('Take _____ today.')
// A phrase that is not in the example is left alone, and a word still masks itself.
expect(maskedPrompt(item({ ...phrase, examples: ['Nothing to mask here.'] }))).toBe('Nothing to mask here.')
expect(maskedPrompt(item({ term: 'curiosity', originalForm: 'curiosity', examples: ['Learning begins with curiosity.'] }))).toBe('Learning begins with _____.')
})
it('masks the word in the first example and keeps other sentences usable', () => {
expect(clozeSentence(item())).toBe('_____ went home.')
expect(clozeSentence(item({ term: "isn't", originalForm: "Isn't", examples: ["It isn’t over."] }))).toBe('It _____ over.')
expect(clozeSentence(item({ examples: ['No matching word here.'] }))).toBe('No matching word here.')
expect(clozeSentence(item({ examples: [] }))).toBeNull()
expect(clozeSentence(item({ term: 'dog', originalForm: 'Dog', examples: ['Dogs are not the saved word.'] }))).toBe('Dogs are not the saved word.')
})
})
describe('review store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' } })
afterEach(() => { vi.restoreAllMocks() })
it('loads the due queue and answers a word correctly', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
return ok(answer())
})
const review = useReviewStore()
await review.load()
expect(review.queue).toHaveLength(1)
expect(review.current?.id).toBe(7)
expect(review.empty).toBe(false)
review.reveal()
expect(review.revealed).toBe(true)
await review.answer('correct')
const body = JSON.parse(String(answerCalls(fetchMock)[0]![1]?.body))
expect(body).toEqual({ answerId: expect.stringMatching(/^[0-9a-f-]{36}$/), grade: 'correct', expectedDueAt: '2026-09-11T10:00:00Z' })
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(1)
expect(review.correctCount).toBe(1)
expect(review.finished).toBe(true)
expect(review.revealed).toBe(false)
})
it('requeues a word that is not recognised and counts it as wrong', async () => {
const wrong = answer({ grade: 'wrong', requeued: true, levelAfter: 0, statusAfter: 'new', dueAtAfter: '2026-09-11T10:05:00Z', item: item({ dueAt: '2026-09-11T10:05:00Z' }) })
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/reviews/queue') ? ok({ items: [item()], total: 1 }) : ok(wrong))
const review = useReviewStore()
await review.load()
await review.answer('wrong')
expect(review.queue).toHaveLength(1)
expect(review.queue[0]!.dueAt).toBe('2026-09-11T10:05:00Z')
expect(review.wrongCount).toBe(1)
expect(review.correctCount).toBe(0)
expect(review.wordsReviewed).toBe(1)
expect(review.finished).toBe(false)
})
it('treats a stale answer as no new score but finishes the round instead of reporting an empty queue', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => String(input).endsWith('/reviews/queue') ? ok({ items: [item()], total: 1 }) : ok(answer({ result: 'stale', requeued: false })))
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(0)
expect(review.correctCount).toBe(0)
expect(review.resolved).toBe(1)
expect(review.empty).toBe(false)
expect(review.finished).toBe(true)
expect(review.notice).toContain('已在其他页面复习')
})
it('counts a replay of this client own answer after a lost response', async () => {
let sent = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
sent += 1
// The first response never reaches the client, the retry reports the recorded answer.
if (sent === 1) throw new Error('网络中断')
return ok(answer({ result: 'applied', duplicate: true }))
})
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.error).toContain('网络中断')
expect(review.answered).toBe(0)
await review.answer('correct')
expect(review.answered).toBe(1)
expect(review.correctCount).toBe(1)
expect(review.wordsReviewed).toBe(1)
expect(review.empty).toBe(false)
expect(review.finished).toBe(true)
expect(review.notice).toBe('')
})
it('keeps the card and the same answer id when a submission fails, then retries once', async () => {
let fail = true
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async input => {
if (String(input).endsWith('/reviews/queue')) return ok({ items: [item()], total: 1 })
if (fail) { fail = false; throw new Error('评分暂时无法提交') }
return ok(answer())
})
const review = useReviewStore()
await review.load()
await review.answer('correct')
expect(review.error).toContain('评分暂时无法提交')
expect(review.queue).toHaveLength(1)
expect(review.answered).toBe(0)
await review.answer('correct')
const calls = answerCalls(fetchMock)
const ids = calls.map(call => JSON.parse(String(call[1]?.body)).answerId)
expect(ids).toHaveLength(2)
expect(ids[0]).toBe(ids[1])
expect(review.queue).toHaveLength(0)
expect(review.answered).toBe(1)
})
it('reports a failed queue load without pretending the queue is empty', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('网络暂不可用'))
const review = useReviewStore()
await review.load()
expect(review.error).toContain('网络暂不可用')
expect(review.finished).toBe(false)
expect(review.empty).toBe(false)
})
it('drops the previous account queue and counters when the identity changes', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 1 }))
const review = useReviewStore()
await review.load()
expect(review.queue).toHaveLength(1)
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
await flushPromises()
expect(review.queue).toHaveLength(0)
expect(review.current).toBeNull()
expect(review.finished).toBe(false)
expect(review.empty).toBe(false)
})
it('reports the words still due beyond the fetched page', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 3 }))
const review = useReviewStore()
await review.load()
expect(review.pending).toBe(2)
})
})
async function open(queueResult: unknown, onAnswer: (body: Record<string, unknown>) => Response | Promise<Response> = () => ok(answer())) {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(input).endsWith('/reviews/queue')) {
if (queueResult instanceof Error) throw queueResult
return ok(queueResult)
}
return onAnswer(JSON.parse(String(init?.body)))
})
useSessionStore().user = { id: 42, username: 'fictional', role: 'learner' }
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '<div />' } }, { path: '/review', component: ReviewView }] })
await router.push('/review')
wrapper = mount(ReviewView, { attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
return { view: wrapper, router, fetchMock }
}
describe('review page', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined; vi.restoreAllMocks() })
it('shows the masked example, hides the definition until the answer is revealed, then grades it', async () => {
const { view, fetchMock } = await open({ items: [item()], total: 2 })
expect(view.get('[data-testid="review-position"]').text()).toBe('到期复习 · 1 / 1')
expect(view.text()).toContain('Dogs')
expect(view.text()).toContain('_____ went home.')
expect(view.find('[data-testid="review-definition"]').exists()).toBe(false)
expect(document.activeElement).toBe(view.get('[data-testid="review-reveal"]').element)
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-definition"]').text()).toContain('狗')
expect(document.activeElement).toBe(view.get('[data-testid="review-correct"]').element)
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(answerCalls(fetchMock)).toHaveLength(1)
expect(view.get('[data-testid="review-summary"]').text()).toContain('1 个词条')
})
it('keeps the card and offers a retry when a grade fails', async () => {
const { view } = await open({ items: [item()], total: 1 }, () => { throw new Error('评分暂时无法提交,请重试。') })
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-wrong"]').trigger('click'); await flushPromises()
expect(view.find('[data-testid="review-card"]').exists()).toBe(true)
expect(view.text()).toContain('评分暂时无法提交')
expect(view.find('[data-testid="review-retry"]').exists()).toBe(true)
expect(view.find('[data-testid="review-summary"]').exists()).toBe(false)
})
it('reports an empty queue and a load failure without pretending anything was reviewed', async () => {
const emptyRun = await open({ items: [], total: 0 })
expect(emptyRun.view.get('[data-testid="review-empty"]').text()).toContain('今天没有到期词条')
await emptyRun.view.get('[data-testid="review-finish"]').trigger('click'); await flushPromises()
expect(emptyRun.router.currentRoute.value.path).toBe('/')
wrapper?.unmount(); wrapper = undefined
// A failed load is not an empty queue: it shows the error and a retry.
const failing = await open(new Error('复习队列暂时无法加载'))
expect(failing.view.get('[data-testid="review-reload"]').text()).toContain('重试')
expect(failing.view.text()).toContain('复习队列暂时无法加载')
expect(failing.view.find('[data-testid="review-empty"]').exists()).toBe(false)
vi.restoreAllMocks()
vi.spyOn(globalThis, 'fetch').mockResolvedValue(ok({ items: [item()], total: 1 }))
await failing.view.get('[data-testid="review-reload"]').trigger('click'); await flushPromises()
expect(failing.view.get('[data-testid="review-position"]').text()).toBe('到期复习 · 1 / 1')
})
it('offers the remaining due words after a round finishes', async () => {
const { view } = await open({ items: [item()], total: 3 })
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-more"]').text()).toContain('继续复习')
expect(view.text()).toContain('还有 2 个词条到期')
})
it('tells the learner when a card was already reviewed elsewhere', async () => {
const { view } = await open({ items: [item()], total: 1 }, () => ok(answer({ result: 'stale', duplicate: false })))
await view.get('[data-testid="review-reveal"]').trigger('click'); await flushPromises()
await view.get('[data-testid="review-correct"]').trigger('click'); await flushPromises()
expect(view.get('[data-testid="review-notice"]').text()).toContain('已在其他页面复习')
expect(view.get('[data-testid="review-summary"]').text()).toContain('本轮没有新的计分')
})
it('ends the round without submitting anything', async () => {
const { view, router, fetchMock } = await open({ items: [item()], total: 1 })
await view.get('[data-testid="review-end"]').trigger('click'); await flushPromises()
expect(router.currentRoute.value.path).toBe('/')
expect(answerCalls(fetchMock)).toHaveLength(0)
})
})
+223
View File
@@ -0,0 +1,223 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import ImportView from '../views/ImportView.vue'
import { fileProblem, fileSizeLabel, TXT_MAX_BYTES, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
const user = { id: 42, username: 'fictional-uploader', role: 'learner' as const }
const book = { id: 1, title: 'Uploaded Book', language: 'en' }
const chapter = { id: 9, bookId: 1, ordinal: 1, title: 'Uploaded Book', status: 'pending', charCount: 12, errorReason: '', errorMessage: '', jobId: 5, createdAt: '', updatedAt: '' }
const timestamps = { createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z' }
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
function stub(name: string) {
return { template: `<div>${name}</div>` }
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: stub('LibraryStub') },
{ path: '/import', component: stub('ImportStub') },
{ path: '/books/:id', component: stub('BookStub') },
],
})
await router.push(path)
await router.isReady()
return router
}
/** jsdom has no file picker, so the input's files are set directly before dispatching change. */
async function chooseFile(view: VueWrapper, file: File | null): Promise<void> {
const input = view.get('[data-testid="file-input"]')
Object.defineProperty(input.element, 'files', { value: file ? [file] : [], configurable: true })
await input.trigger('change')
await flushPromises()
}
/** The TXT toggle is an Element Plus radio group; its hidden input carries the value. */
async function useTxt(view: VueWrapper): Promise<void> {
for (const input of view.findAll('input[type="radio"]')) {
if ((input.element as HTMLInputElement).value === 'txt') {
await input.setValue()
await flushPromises()
return
}
}
throw new Error('TXT 文件 toggle not found')
}
function uploadResponse() {
return ok({ book, chapter, job: { id: 5, bookId: 1, chapterId: 9, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, duplicate: false })
}
describe('txt upload pre-checks', () => {
it('mirrors the server limits for the file that was picked', () => {
expect(fileProblem({ name: 'reading.txt', size: 2048 })).toBe('')
expect(fileProblem({ name: 'READING.TXT', size: 1 })).toBe('')
expect(fileProblem({ name: 'reading.md', size: 2048 })).toContain('.txt')
expect(fileProblem({ name: 'empty.txt', size: 0 })).toContain('空的')
expect(fileProblem({ name: 'big.txt', size: TXT_MAX_BYTES + 1 })).toContain('2 MiB')
expect(fileProblem({ name: 'limit.txt', size: TXT_MAX_BYTES })).toBe('')
expect(fileSizeLabel(512)).toBe('512 B')
expect(fileSizeLabel(2048)).toBe('2 KB')
expect(fileSizeLabel(1.5 * 1024 * 1024)).toBe('1.5 MB')
})
})
describe('txt upload view', () => {
beforeEach(() => {
setActivePinia(createPinia())
sessionStorage.clear()
vi.restoreAllMocks()
useSessionStore().user = { ...user }
})
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
useLibraryStore().stopPolling()
})
it('shows the file picker instead of the textarea and reports the picked file', async () => {
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
// Paste mode keeps the textarea; TXT mode replaces it with the picker.
expect(wrapper.find('textarea#text').exists()).toBe(true)
expect(wrapper.find('[data-testid="file-input"]').exists()).toBe(false)
await useTxt(wrapper)
expect(wrapper.find('textarea#text').exists()).toBe(false)
expect(wrapper.find('[data-testid="file-input"]').exists()).toBe(true)
expect(wrapper.text()).toContain('仅支持 UTF-8')
await chooseFile(wrapper, new File(['Mira opened the workshop.\n'], 'reading.txt', { type: 'text/plain' }))
expect(wrapper.get('[data-testid="file-info"]').text()).toBe('reading.txt · UTF-8 · 26 B')
// The title is prefilled from the file name and stays editable.
expect((wrapper.get('input#title').element as HTMLInputElement).value).toBe('reading')
})
it('refuses a file the server would refuse, before anything is sent', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch')
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
await chooseFile(wrapper, new File(['# not a txt file\n'], 'notes.md'))
expect(wrapper.text()).toContain('请选择 .txt 文件。')
// A file that is not valid UTF-8 is rejected by the preview decode.
await chooseFile(wrapper, new File([new Uint8Array([0x63, 0x61, 0x66, 0xe9, 0x0a])], 'latin1.txt'))
expect(wrapper.text()).toContain('文件不是 UTF-8 编码')
await wrapper.find('input#title').setValue('Latin One')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('文件不是 UTF-8 编码')
// No file at all is also refused locally.
await chooseFile(wrapper, null)
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择要导入的 TXT 文件。')
})
it('uploads the file as multipart and opens the created book', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(uploadResponse())
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
const file = new File(['Mira opened the workshop.\n'], 'reading.txt', { type: 'text/plain' })
await chooseFile(wrapper, file)
await wrapper.find('input#title').setValue('上传的虚构章节')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0]!
expect(String(url)).toBe('/api/v1/books/upload')
expect(init?.method).toBe('POST')
// A multipart body must not be replaced by JSON and must keep the browser's boundary.
expect(init?.body).toBeInstanceOf(FormData)
expect((init?.headers as Record<string, string>)['Content-Type']).toBeUndefined()
const body = init?.body as FormData
expect(body.get('requestId')).toMatch(/^[0-9a-f-]{36}$/)
expect(body.get('title')).toBe('上传的虚构章节')
expect(body.get('language')).toBe('en')
expect((body.get('file') as File).name).toBe('reading.txt')
expect(await (body.get('file') as File).text()).toBe('Mira opened the workshop.\n')
expect(router.currentRoute.value.path).toBe('/books/1')
// The form starts clean, so the same file is not submitted twice by accident.
expect((wrapper.get('[data-testid="file-input"]').element as HTMLInputElement).value).toBe('')
})
it('reuses one request id when the same upload is retried after a failure', async () => {
let attempt = 0
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
attempt += 1
if (attempt === 1) throw new Error('上传中断')
return uploadResponse()
})
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
await chooseFile(wrapper, new File(['Body.\n'], 'retry.txt'))
await wrapper.find('input#title').setValue('Retry Upload')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('上传中断')
// The picked file stays selected so the learner can retry without choosing it again.
expect(wrapper.get('[data-testid="file-info"]').text()).toContain('retry.txt')
await wrapper.find('form').trigger('submit')
await flushPromises()
const ids = fetchMock.mock.calls.map(call => (call[1]?.body as FormData).get('requestId'))
expect(ids).toHaveLength(2)
expect(ids[0]).toBe(ids[1])
expect(router.currentRoute.value.path).toBe('/books/1')
})
it('appends an uploaded chapter to a chosen book without a language field', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.endsWith('/books')) return ok({ items: [{ ...book, chapterCount: 1, pendingCount: 0, processingCount: 0, readyCount: 1, failedCount: 0, ...timestamps }] })
expect(url).toBe('/api/v1/books/1/chapters/upload')
const body = init?.body as FormData
expect(body.get('language')).toBeNull()
return ok({ chapter: { ...chapter, id: 10, ordinal: 2 }, job: { id: 6, bookId: 1, chapterId: 10, status: 'pending', attempts: 0, errorReason: '', errorMessage: '', ...timestamps }, duplicate: false })
})
const router = await viewAt('/import?book=1')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
await chooseFile(wrapper, new File(['Second chapter.\n'], 'second.txt'))
await wrapper.find('input#title').setValue('Appended Chapter')
await wrapper.find('form').trigger('submit')
await flushPromises()
const posts = fetchMock.mock.calls.filter(call => String(call[0]).includes('/upload'))
expect(posts).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/books/1')
})
it('keeps the server message and the form when the upload is rejected', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ code: 400, msg: '文件不是 UTF-8 编码,请另存为 UTF-8 后重试' }), { status: 400 }))
const router = await viewAt('/import')
wrapper = mount(ImportView, { global: { plugins: [router] } })
await flushPromises()
await useTxt(wrapper)
await chooseFile(wrapper, new File(['Body.\n'], 'server-rejects.txt'))
await wrapper.find('input#title').setValue('Server Rejects')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('文件不是 UTF-8 编码,请另存为 UTF-8 后重试')
expect(wrapper.get('[data-testid="file-info"]').text()).toContain('server-rejects.txt')
expect(router.currentRoute.value.path).toBe('/import')
})
})
+14 -2
View File
@@ -5,6 +5,9 @@ import { TERM_STATUSES, type LookupResult, type TermStatus } from '../composable
defineProps<{
word: string
// Set when the selection is a phrase: the panel edits that phrase instead of a word.
phrase: { wordCount: number; stored: boolean } | null
adjust: { startLeft: boolean; startRight: boolean; endLeft: boolean; endRight: boolean } | null
result: LookupResult | null
loading: boolean
error: string
@@ -19,7 +22,7 @@ defineProps<{
const definition = defineModel<string>('definition', { required: true })
const examples = defineModel<string>('examples', { required: true })
const status = defineModel<TermStatus>('status', { required: true })
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number] }>()
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number]; adjust: [edge: 'start' | 'end', direction: -1 | 1] }>()
const heading = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
let observer: ResizeObserver | undefined
@@ -44,7 +47,16 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
<h2 id="lookup-heading" ref="heading" tabindex="-1">{{ word }}</h2>
<ElButton text aria-label="关闭释义" @click="$emit('close')">关闭</ElButton>
</header>
<div class="lookup-content" aria-live="polite" :aria-busy="loading">
<div v-if="phrase" class="lookup-range" data-testid="phrase-range">
<p class="subtle">短语 · {{ phrase.wordCount }} 个单词{{ phrase.stored ? ' · 已保存' : '' }}</p>
<div class="lookup-range-actions">
<ElButton size="small" data-testid="range-start-left" :disabled="!adjust?.startLeft" @click="$emit('adjust', 'start', -1)">起点 ←</ElButton>
<ElButton size="small" data-testid="range-start-right" :disabled="!adjust?.startRight" @click="$emit('adjust', 'start', 1)">起点 →</ElButton>
<ElButton size="small" data-testid="range-end-left" :disabled="!adjust?.endLeft" @click="$emit('adjust', 'end', -1)">终点 ←</ElButton>
<ElButton size="small" data-testid="range-end-right" :disabled="!adjust?.endRight" @click="$emit('adjust', 'end', 1)">终点 →</ElButton>
</div>
</div>
<div v-if="!phrase" class="lookup-content" aria-live="polite" :aria-busy="loading">
<p v-if="loading" role="status" class="subtle">正在查询…</p>
<p v-else-if="error" role="alert" class="lookup-message">{{ error }}</p>
<template v-else-if="result">
+43 -5
View File
@@ -1,10 +1,48 @@
<script setup lang="ts">
import { termStatusOf, type ReaderToken } from '../composables/useReaderLookup'
defineProps<{ tokens: ReaderToken[]; original: string; selectedStart?: number }>()
const emit = defineEmits<{ select: [token: ReaderToken, element: HTMLElement] }>()
function select(token: ReaderToken, event: Event) { emit('select', token, event.currentTarget as HTMLElement) }
import { termStatusOf, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
defineProps<{ tokens: ReaderToken[]; phrases: PhraseSpan[]; original: string; selectedStart?: number; phraseStart?: number | null }>()
const emit = defineEmits<{
select: [token: ReaderToken, element: HTMLElement, extend: boolean]
selectPhrase: [span: PhraseSpan, element: HTMLElement]
}>()
// A token belongs to the phrase that covers it; the spans come from the server match, so the
// reader never re-derives which words form a phrase.
function spanOf(phrases: PhraseSpan[], index: number): PhraseSpan | null {
let best: PhraseSpan | null = null
for (const phrase of phrases) {
if (index < phrase.startToken || index > phrase.endToken) continue
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
}
return best
}
function select(token: ReaderToken, event: Event, phrase: PhraseSpan | null): void {
const element = event.currentTarget as HTMLElement
if (phrase && !(event as MouseEvent).shiftKey) {
emit('selectPhrase', phrase, element)
return
}
emit('select', token, element, (event as MouseEvent).shiftKey === true)
}
</script>
<template>
<article class="reader-text"><template v-if="tokens.length"><template v-for="token in tokens" :key="token.start"><span v-if="token.kind === 'word'" role="button" tabindex="0" class="reader-word" :class="[termStatusOf(token) ? `is-${termStatusOf(token)}` : '', { 'is-selected': selectedStart === token.start }]" :aria-label="termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`" :aria-pressed="selectedStart === token.start" @click="select(token, $event)" @keydown.enter.prevent="select(token, $event)" @keydown.space.prevent="select(token, $event)">{{ token.text }}</span><template v-else>{{ token.text }}</template></template></template><template v-else>{{ original }}</template></article>
<article class="reader-text"><template v-if="tokens.length"><template v-for="(token, index) in tokens" :key="token.start"><span
class="reader-token"
:data-token-index="index"
:class="[
token.kind === 'word' ? 'reader-word' : 'reader-separator',
token.kind === 'word' && termStatusOf(token) ? `is-${termStatusOf(token)}` : '',
spanOf(phrases, index) ? `is-phrase is-phrase-${spanOf(phrases, index)!.status}` : '',
spanOf(phrases, index)?.startToken === index ? 'is-phrase-start' : '',
spanOf(phrases, index)?.endToken === index ? 'is-phrase-end' : '',
{ 'is-selected': token.kind === 'word' && selectedStart === token.start, 'is-phrase-selected': spanOf(phrases, index)?.id === phraseStart },
]"
v-bind="token.kind === 'word' ? { role: 'button', tabindex: '0', 'aria-pressed': selectedStart === token.start } : {}"
:aria-label="token.kind === 'word' ? (spanOf(phrases, index) ? `短语中的 ${token.text}` : termStatusOf(token) ? `查询 ${token.text},已保存` : `查询 ${token.text}`) : undefined"
@click="select(token, $event, token.kind === 'word' ? spanOf(phrases, index) : null)"
@keydown.enter.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
@keydown.space.prevent="token.kind === 'word' ? select(token, $event, spanOf(phrases, index)) : undefined"
>{{ token.text }}</span></template></template><template v-else>{{ original }}</template></article>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { ElButton } from 'element-plus'
import { maskedPrompt, type ReviewItem } from '../stores/review'
const props = defineProps<{ item: ReviewItem; position: number; total: number; revealed: boolean; busy: boolean; error: string }>()
const emit = defineEmits<{ reveal: []; grade: [grade: 'correct' | 'wrong' | 'again']; end: []; retry: [] }>()
// The word and its masked example are enough to answer; the definition stays hidden.
const prompt = () => maskedPrompt(props.item)
const revealButton = ref<{ $el?: HTMLElement } | null>(null)
const correctButton = ref<{ $el?: HTMLElement } | null>(null)
// Keyboard users should reach the next action without tabbing through the page again.
watch(() => props.item.id, async () => {
await nextTick()
if (!props.revealed) revealButton.value?.$el?.focus()
}, { immediate: true })
watch(() => props.revealed, async value => {
if (!value) return
await nextTick()
correctButton.value?.$el?.focus()
})
</script>
<template>
<section class="review-card" aria-labelledby="review-word" data-testid="review-card">
<p class="subtle" data-testid="review-position">到期复习 · {{ position }} / {{ total }}</p>
<h2 id="review-word" tabindex="-1" class="review-word">{{ item.originalForm }}</h2>
<p v-if="prompt()" class="review-example" lang="en">{{ prompt() }}</p>
<p class="subtle">{{ item.kind === 'phrase' ? `短语 · ${item.wordCount} 个单词 · ` : '' }}{{ item.status === 'learning' ? `学习中 · 等级 ${item.level}` : '新词 · 尚未复习' }}</p>
<p v-if="error" role="alert" class="notice">{{ error }}</p>
<template v-if="!revealed">
<ElButton ref="revealButton" type="primary" data-testid="review-reveal" :disabled="busy || !!error" @click="emit('reveal')">显示答案</ElButton>
</template>
<template v-else>
<div class="review-answer">
<p class="review-definition" data-testid="review-definition">{{ item.definition || '(未填写个人释义)' }}</p>
<blockquote v-for="(example, index) in item.examples" :key="index" lang="en">{{ example }}</blockquote>
</div>
<div class="review-grades">
<ElButton ref="correctButton" type="primary" data-testid="review-correct" :loading="busy" @click="emit('grade', 'correct')">认识 / 答对</ElButton>
<ElButton data-testid="review-wrong" :disabled="busy" @click="emit('grade', 'wrong')">不认识 / 答错</ElButton>
<ElButton data-testid="review-again" :disabled="busy" @click="emit('grade', 'again')">再学一次</ElButton>
</div>
</template>
<p v-if="error" class="review-actions"><ElButton data-testid="review-retry" @click="emit('retry')">重试提交</ElButton></p>
<p class="review-actions"><ElButton text data-testid="review-end" @click="emit('end')">结束本次复习</ElButton></p>
</section>
</template>
+98
View File
@@ -0,0 +1,98 @@
import type { ReaderToken } from './useReaderLookup'
/** A phrase selection over the server's own tokens, or a candidate the reader can adjust. */
export interface TokenRange {
/** Token indices of the first and last word of the phrase. */
firstWord: number
lastWord: number
/** Code point range of the whole phrase inside the original text. */
start: number
end: number
/** The original text exactly as the chapter holds it. */
text: string
wordCount: number
}
export const MAX_PHRASE_WORDS = 12
/** Indices of the word tokens, in reading order. */
export function wordIndices(tokens: ReaderToken[]): number[] {
const indices: number[] = []
for (let index = 0; index < tokens.length; index++) {
if (tokens[index]!.kind === 'word') indices.push(index)
}
return indices
}
/**
* Builds a phrase range from two token indices, aligning both ends to whole words and
* keeping interior punctuation and line breaks in the text. It mirrors the rules verified in
* #4: separators at the edges are skipped, interior ones survive, and a single word is not a
* phrase.
*/
export function normalizeTokenRange(tokens: ReaderToken[], original: string, anchor: number, focus: number): TokenRange | null {
if (!Number.isInteger(anchor) || !Number.isInteger(focus)) return null
const from = Math.min(anchor, focus)
const to = Math.max(anchor, focus)
if (from < 0 || to >= tokens.length) return null
let firstWord = -1
let lastWord = -1
for (let index = from; index <= to; index++) {
if (tokens[index]!.kind !== 'word') continue
if (firstWord < 0) firstWord = index
lastWord = index
}
if (firstWord < 0 || lastWord < 0 || firstWord === lastWord) return null
return rangeOfWords(tokens, original, firstWord, lastWord)
}
/** The phrase range for a known first and last word token. */
export function rangeOfWords(tokens: ReaderToken[], original: string, firstWord: number, lastWord: number): TokenRange | null {
const first = tokens[firstWord]
const last = tokens[lastWord]
if (!first || !last || first.kind !== 'word' || last.kind !== 'word' || lastWord < firstWord) return null
const wordCount = wordIndices(tokens.slice(firstWord, lastWord + 1)).length
if (wordCount < 2 || wordCount > MAX_PHRASE_WORDS) return null
const text = [...original].slice(first.start, last.end).join('')
if (!text) return null
return { firstWord, lastWord, start: first.start, end: last.end, text, wordCount }
}
/**
* Moves one end of a phrase by whole words, skipping separators and never inverting the range.
* Returns the adjusted range, or null when the move is impossible.
*/
export function adjustTokenRange(tokens: ReaderToken[], original: string, range: TokenRange, edge: 'start' | 'end', direction: -1 | 1): TokenRange | null {
const words = wordIndices(tokens)
const positionOf = (tokenIndex: number) => words.indexOf(tokenIndex)
const firstPosition = positionOf(range.firstWord)
const lastPosition = positionOf(range.lastWord)
if (firstPosition < 0 || lastPosition < 0) return null
if (edge === 'start') {
const next = firstPosition + direction
if (next < 0 || next > lastPosition - 1) return null
return rangeOfWords(tokens, original, words[next]!, range.lastWord)
}
const next = lastPosition + direction
if (next >= words.length || next < firstPosition + 1) return null
return rangeOfWords(tokens, original, range.firstWord, words[next]!)
}
/** The saved phrases that cover a word token, longest first, so a click prefers the phrase. */
export function phrasesAt(tokens: ReaderToken[], phrases: { id: number; startToken: number; endToken: number }[], tokenIndex: number): { id: number; startToken: number; endToken: number } | null {
let best: { id: number; startToken: number; endToken: number } | null = null
for (const phrase of phrases) {
if (tokenIndex < phrase.startToken || tokenIndex > phrase.endToken) continue
if (best === null || phrase.endToken - phrase.startToken > best.endToken - best.startToken) best = phrase
}
return best
}
/** The phrase range a saved span covers, used when a highlighted phrase is clicked. */
export function rangeOfSpan(tokens: ReaderToken[], original: string, span: { startToken: number; endToken: number }): TokenRange | null {
const words = wordIndices(tokens.slice(span.startToken, span.endToken + 1))
if (words.length < 2) return null
const firstWord = span.startToken + words[0]!
const lastWord = span.startToken + words[words.length - 1]!
return rangeOfWords(tokens, original, firstWord, lastWord)
}
+68 -8
View File
@@ -1,6 +1,7 @@
import { computed, onScopeDispose, ref, watch, type Ref } from 'vue'
import type { ChapterDetail } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { adjustTokenRange, type TokenRange } from './readerRange'
export type TermStatus = 'new' | 'learning' | 'known' | 'ignored'
@@ -14,6 +15,8 @@ export interface ReaderToken {
kind: 'word' | 'space' | 'punctuation'
term?: TokenTerm | null
}
/** One saved phrase occurrence inside the chapter, in token indices. */
export interface PhraseSpan { id: number; status: TermStatus; wordCount: number; startToken: number; endToken: number }
export interface LookupResult {
status: 'exact' | 'lemma' | 'not_found' | 'resource_missing'
query: string
@@ -30,9 +33,11 @@ export interface SavedTerm {
examples: string[]
status: TermStatus
level: number
kind: 'word' | 'phrase'
wordCount: number
}
interface TermResponse { term: SavedTerm }
interface TokenResponse { textSha256: string; tokens: ReaderToken[] }
interface TokenResponse { textSha256: string; tokens: ReaderToken[]; phrases?: PhraseSpan[] }
// The four learner-visible states and the level rule behind them. Only a learning
// entry carries a level, so every other status reports level 0.
@@ -72,9 +77,13 @@ function matchesChapter(data: TokenResponse, chapter: ChapterDetail): boolean {
export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
const session = useSessionStore()
const tokens = ref<ReaderToken[]>([])
const phrases = ref<PhraseSpan[]>([])
const tokensError = ref('')
const tokensLoading = ref(false)
const selected = ref<ReaderToken | null>(null)
// A phrase selection: either a drag over several words or a click on a saved phrase span.
const range = ref<TokenRange | null>(null)
const rangeTermId = ref<number | null>(null)
const result = ref<LookupResult | null>(null)
const loading = ref(false)
const error = ref('')
@@ -97,7 +106,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
// Sending is blocked until the stored text is on screen, so a slow or failed read
// can never let an empty form overwrite what the learner already wrote.
const canSave = computed(() => selected.value !== null && !saving.value && !prefilling.value && !prefillError.value)
const canSave = computed(() => (selected.value !== null || range.value !== null) && !saving.value && !prefilling.value && !prefillError.value)
function exampleLines(): string[] {
return examples.value.split('\n').map(line => line.trim()).filter(Boolean)
@@ -131,6 +140,8 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
termSequence++
saveSequence++
selected.value = null
range.value = null
rangeTermId.value = null
result.value = null
loading.value = false
error.value = ''
@@ -152,6 +163,7 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
function reset() {
tokenSequence++
tokens.value = []
phrases.value = []
tokensError.value = ''
tokensLoading.value = false
close(false)
@@ -167,16 +179,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
if (seq !== tokenSequence) return
if (!matchesChapter(data, current)) throw new Error('分词与正文不一致,请重试。')
tokens.value = data.tokens
phrases.value = Array.isArray(data.phrases) ? data.phrases : []
} catch (reason) {
if (seq !== tokenSequence) return
tokens.value = []
phrases.value = []
tokensError.value = reason instanceof Error ? reason.message : '单词暂时无法加载。'
} finally { if (seq === tokenSequence) tokensLoading.value = false }
}
async function lookup() {
const current = chapter.value
const token = selected.value
if (!session.user || !current || !token) return
if (!session.user || !current || !token || range.value) return
const seq = ++lookupSequence
result.value = null
error.value = ''
@@ -208,15 +222,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
}
async function save() {
const current = chapter.value
const selection = range.value
const token = selected.value
if (!session.user || !current || !token || !canSave.value) return
if (!session.user || !current || (!selection && !token) || !canSave.value) return
const seq = ++saveSequence
saving.value = true
saveError.value = ''
saved.value = ''
const span = selection ?? token!
const path = selection ? 'phrases' : 'terms'
try {
const data = await session.request<TermResponse & { created: boolean }>('terms', 'POST', {
chapterId: current.id, start: token.start, end: token.end,
const data = await session.request<TermResponse & { created: boolean }>(path, 'POST', {
chapterId: current.id, start: span.start, end: span.end,
definition: definition.value, examples: exampleLines(), status: status.value,
})
if (seq !== saveSequence) return
@@ -224,6 +241,18 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
examples.value = data.term.examples.join('\n')
status.value = data.term.status
applyTerm({ id: data.term.id, status: data.term.status, level: data.term.level })
if (selection) {
// The saved phrase must highlight here without waiting for another token load.
rangeTermId.value = data.term.id
const savedSpan = {
id: data.term.id, status: data.term.status, wordCount: selection.wordCount,
startToken: selection.firstWord, endToken: selection.lastWord,
}
const existing = phrases.value.findIndex(item => item.id === data.term.id)
phrases.value = existing >= 0
? phrases.value.map((item, index) => (index === existing ? savedSpan : item))
: [...phrases.value, savedSpan]
}
saved.value = `已保存 · ${termStatusLabel(data.term.status)}`
} catch (reason) {
if (seq !== saveSequence) return
@@ -241,6 +270,37 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
}
void lookup()
}
/**
* Opens the phrase form for a dragged range or for a saved phrase span. A phrase is one
* entry in the same table as a word, so an existing phrase is read through the same route.
*/
function selectRange(next: TokenRange, element: HTMLElement | null, termId: number | null = null) {
close(false)
origin = element
range.value = next
rangeTermId.value = termId
if (termId !== null) void loadTerm(termId)
}
/** Moves one end of the phrase by whole words; the panel keeps the selection in sync. */
function adjustRange(edge: 'start' | 'end', direction: -1 | 1) {
const current = range.value
const currentChapter = chapter.value
if (!current || !currentChapter) return
const next = adjustTokenRange(tokens.value, currentChapter.originalText ?? '', current, edge, direction)
if (!next) return
// The adjusted range is a different identity, so the panel must stop claiming that the
// stored entry is what is on screen: both the phrase link and the entry the form was loaded
// from are dropped. The typed text stays, and saving it writes the phrase that matches the
// current range (which may update another entry, never two).
rangeTermId.value = null
savedTermId.value = null
range.value = next
}
/** True when the panel is editing an entry that is already stored. */
const rangeStored = computed(() => rangeTermId.value !== null)
watch(() => [chapter.value?.id, chapter.value?.status, chapter.value?.contentSha256, chapter.value?.originalText], () => {
reset()
void loadTokens()
@@ -250,8 +310,8 @@ export function useReaderLookup(chapter: Ref<ChapterDetail | null>) {
watch(() => session.user, reset, { flush: 'sync' })
onScopeDispose(reset)
return {
tokens, tokensError, tokensLoading, selected, result, loading, error,
tokens, phrases, tokensError, tokensLoading, selected, range, rangeTermId, rangeStored, result, loading, error,
definition, examples, status, savedTermId, prefilling, prefillError, saving, saveError, saved, canSave,
loadTokens, lookup, loadTerm, save, select, close, reset, keepSelectionVisible,
loadTokens, lookup, loadTerm, save, select, selectRange, adjustRange, close, reset, keepSelectionVisible,
}
}
@@ -0,0 +1,80 @@
import { onScopeDispose, type Ref } from 'vue'
/**
* Maps the browser's own selection onto the server tokens. Nothing here intercepts touch or
* pointer movement: a mouse drag and the phone's system selection handles both produce a
* selectionchange, which is the interaction #4 verified.
*/
export function tokenIndexOf(node: Node | null): number {
if (!node) return -1
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : (node as Element)
const holder = element?.closest?.('[data-token-index]')
if (!holder) return -1
const value = Number((holder as HTMLElement).dataset.tokenIndex)
return Number.isInteger(value) ? value : -1
}
export interface TextSelectionOptions {
/** The element the selection has to start inside. */
container: Ref<HTMLElement | null>
/** Called with the first and last token index of a non-empty selection inside the reader. */
onSelect: (anchor: number, focus: number) => void
}
/**
* Watches the native selection and reports token indices. A collapsed selection (a plain
* click) reports nothing, so clicking a word keeps opening the word panel.
*/
export function useTextSelection(options: TextSelectionOptions) {
let timer: number | undefined
let disposed = false
function read(): void {
if (disposed) return
const root = options.container.value
const selection = window.getSelection?.()
if (!root || !selection || selection.rangeCount === 0 || selection.isCollapsed) return
const range = selection.getRangeAt(0)
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return
const anchor = tokenIndexOf(range.startContainer)
const focus = tokenIndexOf(range.endContainer)
if (anchor < 0 || focus < 0) return
options.onSelect(Math.min(anchor, focus), Math.max(anchor, focus))
}
// The debounce matches the verified spike: it lets the browser finish a drag before the
// range is read, and it never blocks scrolling on a touch device.
function schedule(): void {
if (timer !== undefined) window.clearTimeout(timer)
timer = window.setTimeout(() => { timer = undefined; read() }, 100)
}
function pointerUp(event: PointerEvent): void {
// Only a pointerup inside the reader can finish a selection; a click on the panel must not
// re-read a range the learner already adjusted there.
const root = options.container.value
const target = event.target as Node | null
if (!root || !target || !root.contains(target)) return
if (timer !== undefined) window.clearTimeout(timer)
// A pointerup arrives before the browser finalises the range, so the read waits a tick.
timer = window.setTimeout(() => { timer = undefined; read() }, 0)
}
// Both listeners live on the document: the reader body only exists once a chapter is ready,
// so a listener bound to the element at setup time would miss every later selection.
document.addEventListener('selectionchange', schedule)
document.addEventListener('pointerup', pointerUp)
onScopeDispose(() => {
disposed = true
if (timer !== undefined) window.clearTimeout(timer)
document.removeEventListener('selectionchange', schedule)
document.removeEventListener('pointerup', pointerUp)
})
return {
/** Drops the native highlight, e.g. when the panel closes. */
clear() { window.getSelection?.()?.removeAllRanges() },
read,
}
}
+1
View File
@@ -6,6 +6,7 @@ const router = createRouter({
routes: [
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{ path: '/', name: 'library', meta: { private: true }, component: () => import('../views/LibraryView.vue') },
{ path: '/review', name: 'review', meta: { private: true }, component: () => import('../views/ReviewView.vue') },
{ path: '/import', name: 'import', meta: { private: true }, component: () => import('../views/ImportView.vue') },
{ path: '/books/:id', name: 'book', meta: { private: true }, component: () => import('../views/BookView.vue') },
{ path: '/chapters/:id', name: 'chapter', meta: { private: true }, component: () => import('../views/ReaderView.vue') },
+155 -1
View File
@@ -51,6 +51,21 @@ export interface Job {
export interface ChapterNavigation { previousChapterId: number | null; nextChapterId: number | null }
/** The editable text of one owned chapter; separate from the ready-only reader payload. */
export interface ChapterSource {
id: number
bookId: number
ordinal: number
title: string
text: string
status: ChapterStatus
contentSha256: string
charCount: number
}
export interface ChapterEdit { chapter: ChapterSummary; job: Job | null; versionChanged: boolean }
export interface DeletionResult { bookId?: number; chapterId?: number; chapters?: number; remaining: number }
export type SubmitTarget = { mode: 'new' } | { mode: 'append'; bookId: number }
export interface SubmitInput { title: string; text: string; target: SubmitTarget }
@@ -58,6 +73,8 @@ export interface SubmitInput { title: string; text: string; target: SubmitTarget
export const POLL_INTERVAL_MS = 1500
export const TITLE_MAX_LENGTH = 120
export const TEXT_MAX_CODE_POINTS = 100000
// Mirrors the server limit for one TXT upload.
export const TXT_MAX_BYTES = 2 * 1024 * 1024
export const NOT_FOUND_MESSAGE = '内容不存在。'
export const LANGUAGE_LABEL = '英语'
export const LANGUAGE_CODE = 'en'
@@ -92,6 +109,24 @@ export function textProblem(text: string): string {
return ''
}
/**
* Client-side pre-check for a TXT upload. The server validates the file again and stays the
* only authority; this only tells the learner about an obviously unusable choice earlier.
*/
export function fileProblem(file: { name: string; size: number }): string {
if (!/\.txt$/i.test(file.name)) return '请选择 .txt 文件。'
if (file.size === 0) return '文件是空的,请选择包含英文正文的 UTF-8 TXT。'
if (file.size > TXT_MAX_BYTES) return 'TXT 文件不能超过 2 MiB。'
return ''
}
/** A readable size for the selected file, e.g. `2 KB` or `1.5 MB`. */
export function fileSizeLabel(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
/** A failed chapter can be retried as soon as the API told us its job id. */
export function canRetry(chapter: Pick<ChapterSummary, 'status' | 'jobId'>): boolean {
return chapter.status === 'failed' && chapter.jobId !== null
@@ -112,6 +147,7 @@ interface SubmitBookBody { requestId: string; title: string; text: string; langu
// An appended chapter owns the language of its book, so the append contract has no language
// field; the server rejects unknown fields, and a client that sends one gets HTTP 400.
interface SubmitChapterBody { requestId: string; title: string; text: string }
export interface UploadInput { title: string; target: SubmitTarget; file: File }
interface Created { bookId: number; chapter: ChapterSummary }
function emptyNavigation(): ChapterNavigation {
@@ -365,6 +401,123 @@ export const useLibraryStore = defineStore('library', () => {
}
}
/**
* Uploads one TXT file. The file and the title share the request id discipline of a paste,
* so a repeated upload of the same file answers with the chapter it already created.
*/
async function upload(input: UploadInput): Promise<number> {
const title = input.title.trim()
const problem = titleProblem(title) || fileProblem(input.file)
if (problem) {
submitError.value = problem
throw new Error(problem)
}
const key = `upload\n${title}\n${input.file.name}\n${input.file.size}\n${input.file.lastModified}`
if (key !== submissionKey || submissionRequestId === '') {
submissionKey = key
submissionRequestId = crypto.randomUUID()
}
const requestId = submissionRequestId
const form = new FormData()
form.append('requestId', requestId)
form.append('title', title)
// Only the new-book contract carries a language; appending inherits the book's language.
const path = input.target.mode === 'new' ? 'books/upload' : `books/${input.target.bookId}/chapters/upload`
if (input.target.mode === 'new') form.append('language', LANGUAGE_CODE)
form.append('file', input.file, input.file.name)
const version = generation
const owner = ownerId()
submitting.value = true
submitError.value = ''
try {
const result = await session.request<{ book?: BookRef; chapter: ChapterSummary }>(path, 'POST', form)
if (isStale(version, owner)) throw new Error('登录状态已变化,请重新提交。')
submissionKey = ''
submissionRequestId = ''
return result.book?.id ?? result.chapter.bookId
} catch (reason) {
if (!isStale(version, owner)) submitError.value = failureMessage(reason, '上传失败,请稍后重试。')
throw reason instanceof Error ? reason : new Error('上传失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) submitting.value = false
}
}
/** Renames one owned book; the reply is the stored book. */
async function renameBook(id: number, title: string): Promise<BookRef> {
const version = generation
const owner = ownerId()
const problem = titleProblem(title)
if (problem) throw new Error(problem)
const result = await session.request<{ book: BookRef }>(`books/${id}`, 'PATCH', { title: title.trim() })
if (!isStale(version, owner)) {
book.value = result.book
const listed = books.value.find(item => item.id === id)
if (listed) listed.title = result.book.title
}
return result.book
}
/**
* Saves a chapter title and/or text. A changed text becomes a new version and re-processes;
* the same text answered again changes nothing.
*/
async function updateChapter(id: number, input: { title?: string; text?: string }): Promise<ChapterEdit> {
const version = generation
const owner = ownerId()
if (input.title !== undefined) {
const problem = titleProblem(input.title)
if (problem) throw new Error(problem)
}
if (input.text !== undefined) {
const problem = textProblem(input.text)
if (problem) throw new Error(problem)
}
const body: { title?: string; text?: string } = {}
if (input.title !== undefined) body.title = input.title.trim()
if (input.text !== undefined) body.text = input.text
const result = await session.request<ChapterEdit>(`chapters/${id}`, 'PATCH', body)
if (isStale(version, owner)) return result
const merged = { ...result.chapter, jobId: result.job?.id ?? result.chapter.jobId }
applyChapterSummary(merged)
if (chapter.value !== null && chapter.value.id === id && result.versionChanged) {
// The new version is not readable yet, so the reader must drop the previous text.
chapter.value = { ...chapter.value, ...merged, originalText: undefined }
}
schedulePolling()
return result
}
/** Reads the editable text of one owned chapter, in any processing state. */
async function loadChapterSource(id: number): Promise<ChapterSource> {
const result = await session.request<{ source: ChapterSource }>(`chapters/${id}/source`)
return result.source
}
/** Deletes one owned book with its chapters and jobs; personal records stay. */
async function deleteBook(id: number): Promise<DeletionResult> {
const version = generation
const owner = ownerId()
const result = await session.request<{ deleted: DeletionResult }>(`books/${id}`, 'DELETE')
if (!isStale(version, owner)) {
books.value = books.value.filter(item => item.id !== id)
if (book.value?.id === id) closeBook()
}
return result.deleted
}
/** Deletes one owned chapter and closes the gap in the chapter order. */
async function deleteChapter(id: number): Promise<DeletionResult> {
const version = generation
const owner = ownerId()
const result = await session.request<{ deleted: DeletionResult }>(`chapters/${id}`, 'DELETE')
if (isStale(version, owner)) return result.deleted
chapters.value = chapters.value.filter(item => item.id !== id)
if (book.value !== null) await loadBook(book.value.id, { silent: true })
return result.deleted
}
/** The job id comes from the chapter itself, wherever that chapter was loaded from. */
function jobIdOf(chapterId: number): number | null {
const target = chapters.value.find(item => item.id === chapterId)
@@ -461,7 +614,8 @@ export const useLibraryStore = defineStore('library', () => {
book, chapters, bookLoading, bookError,
chapter, chapterBook, navigation, chapterLoading, chapterError,
submitting, submitError, retryingChapterId, readerText,
loadBooks, loadBook, loadChapter, submit, retryChapter,
loadBooks, loadBook, loadChapter, submit, upload, retryChapter,
renameBook, updateChapter, loadChapterSource, deleteBook, deleteChapter,
stopPolling, closeBook, closeChapter, reset,
}
})
+251
View File
@@ -0,0 +1,251 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useSessionStore } from './session'
export type ReviewGrade = 'correct' | 'wrong' | 'again'
// The outcome of an answer: applied when the word moved, stale when another screen had
// already reviewed it. A replayed answer repeats the outcome it was given first.
export type ReviewResult = 'applied' | 'stale'
export interface ReviewItem {
id: number
term: string
originalForm: string
definition: string
examples: string[]
status: 'new' | 'learning'
level: number
kind: 'word' | 'phrase'
wordCount: number
dueAt: string
reviewCount: number
}
export interface ReviewAnswerResult {
result: ReviewResult
duplicate: boolean
grade: ReviewGrade
requeued: boolean
statusBefore: string
statusAfter: string
levelBefore: number
levelAfter: number
dueAtBefore: string
dueAtAfter: string
item: ReviewItem
}
interface QueueResponse { items: ReviewItem[]; total: number }
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/**
* The prompt shown before the answer: the first personal example with the word masked.
* A word without examples is shown on its own; the dictionary is never consulted here.
*/
export function clozeSentence(item: ReviewItem): string | null {
const line = item.examples[0]
if (!line) return null
const forms = [...new Set([item.term, item.originalForm].filter(Boolean))]
let masked = line
for (const form of forms) {
// Whole word, any case, and either apostrophe form, so the mask matches how the
// learner typed the sentence.
const pattern = new RegExp(`(?<![\\p{L}\\p{M}])${escapeRegExp(form).replace(/'/g, "['’]")}(?![\\p{L}\\p{M}])`, 'giu')
masked = masked.replace(pattern, '_____')
}
return masked
}
/**
* A phrase is masked as one blank covering the whole run, which is what the accepted prototype
* shows. Interior separators of any non-letter run are allowed, matching the server's phrase
* identity rule.
*/
export function maskedPrompt(item: ReviewItem): string | null {
const line = item.examples[0]
if (!line) return null
if (item.kind !== 'phrase') return clozeSentence(item)
const forms = item.term.split(' ').filter(Boolean)
if (forms.length < 2) return clozeSentence(item)
const parts = forms.map(form => escapeRegExp(form).replace(/'/g, "['’]"))
const pattern = new RegExp(parts.join(`[\\p{L}\\p{M}]*?[^\\p{L}\\p{M}]+[\\p{L}\\p{M}]*?`), 'iu')
return line.replace(pattern, '_____')
}
export const useReviewStore = defineStore('review', () => {
const session = useSessionStore()
const queue = ref<ReviewItem[]>([])
const loading = ref(false)
const error = ref('')
const busy = ref(false)
const revealed = ref(false)
const answered = ref(0)
const correctCount = ref(0)
const wrongCount = ref(0)
// Distinct words in this round: a requeued word is answered again but is one word.
const wordsReviewed = ref(0)
// Cards this round took off the queue, counted for every outcome, and the reason a card
// left without a new score.
const resolved = ref(0)
const notice = ref('')
const seen = new Set<number>()
// Words due beyond the fetched page, reported by the server for this round.
const pending = ref(0)
let started = ref(false)
// One answer id per card: a retry of a failed submission reuses its key, so the server
// answers the retry from the first outcome instead of counting the same action twice.
let attempt: { itemId: number; answerId: string } | null = null
let sequence = 0
let generation = 0
const current = computed<ReviewItem | null>(() => queue.value[0] ?? null)
// A round that resolved cards is finished even when every answer turned out to be a
// replay or a stale submission; only a round that never had a card is empty.
const finished = computed(() => started.value && !loading.value && queue.value.length === 0 && resolved.value > 0)
const empty = computed(() => started.value && !loading.value && queue.value.length === 0 && resolved.value === 0)
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || ownerId() !== owner
}
function reset(): void {
generation++
sequence++
queue.value = []
loading.value = false
error.value = ''
busy.value = false
revealed.value = false
answered.value = 0
correctCount.value = 0
wrongCount.value = 0
wordsReviewed.value = 0
resolved.value = 0
notice.value = ''
pending.value = 0
started.value = false
attempt = null
seen.clear()
}
async function load(newRound = true): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
revealed.value = false
notice.value = ''
attempt = null
try {
const result = await session.request<QueueResponse>('reviews/queue')
if (seq !== sequence || isStale(version, owner)) return
const items = Array.isArray(result?.items) ? result.items : []
queue.value = items
const total = typeof result?.total === 'number' ? result.total : items.length
pending.value = Math.max(0, total - items.length)
// A fresh round starts at zero; continuing with the next page of due words keeps
// the counters of the round the learner is already in.
if (newRound) {
answered.value = 0
correctCount.value = 0
wrongCount.value = 0
wordsReviewed.value = 0
resolved.value = 0
seen.clear()
}
started.value = true
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '复习队列暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
function answerIdFor(item: ReviewItem): string {
if (!attempt || attempt.itemId !== item.id) attempt = { itemId: item.id, answerId: crypto.randomUUID() }
return attempt.answerId
}
function reveal(): void {
revealed.value = true
}
/**
* Sends one grade for the current card. A failed request keeps the card and its input
* so the learner can retry; the same answer id is reused on that retry.
*/
async function answer(grade: ReviewGrade): Promise<void> {
const item = current.value
if (!item || busy.value || !session.user) return
const version = generation
const owner = ownerId()
const seq = sequence
busy.value = true
error.value = ''
try {
const result = await session.request<ReviewAnswerResult>(`reviews/${item.id}/answers`, 'POST', {
answerId: answerIdFor(item), grade, expectedDueAt: item.dueAt,
})
if (seq !== sequence || isStale(version, owner)) return
applyResult(item, result)
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '评分暂时无法提交,请重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) busy.value = false
}
}
function applyResult(item: ReviewItem, result: ReviewAnswerResult): void {
// The card leaves the round in every outcome; a stale or replayed answer is the same
// user action seen twice, not a second review.
queue.value = queue.value.filter(entry => entry.id !== item.id)
attempt = null
revealed.value = false
resolved.value += 1
if (result.result === 'applied') {
// A replay repeats the first outcome, and the first attempt may be this client's own
// submission whose response was lost, so it counts exactly like that attempt.
notice.value = ''
answered.value += 1
if (!seen.has(item.id)) {
seen.add(item.id)
wordsReviewed.value += 1
}
if (result.grade === 'correct') correctCount.value += 1
else wrongCount.value += 1
} else {
notice.value = result.duplicate
? '该词已按上一次的评分记录,未重复计分。'
: '该词已在其他页面复习,本次未计分。'
}
if (result.requeued) {
const next = result.item ?? { ...item, level: result.levelAfter, status: result.statusAfter as ReviewItem['status'], dueAt: result.dueAtAfter }
queue.value = [...queue.value, { ...next, dueAt: result.dueAtAfter, level: result.levelAfter, status: result.statusAfter as ReviewItem['status'] }]
}
}
// Load the next page of due words, keeping the counters of the round in progress.
async function continueRound(): Promise<void> {
await load(false)
}
return {
queue, current, loading, error, busy, revealed, answered, correctCount, wrongCount, wordsReviewed, resolved,
notice, pending, finished, empty, load, reveal, answer, continueRound, reset,
}
})
+4 -2
View File
@@ -36,10 +36,12 @@ export const useSessionStore = defineStore('session', () => {
}
async function request<T>(path: string, method = 'GET', body?: unknown, auth = token, version = generation): Promise<T> {
// A multipart body carries its own content type with the boundary, so it is sent as is.
const multipart = body instanceof FormData
const response = await fetch(`/api/v1/${path}`, {
method,
headers: { ...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) },
body: body ? JSON.stringify(body) : undefined,
headers: { ...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(body && !multipart ? { 'Content-Type': 'application/json' } : {}) },
body: body ? (multipart ? body : JSON.stringify(body)) : undefined,
cache: 'no-store',
})
if (response.status === 401 && version === generation) {
+19
View File
@@ -118,6 +118,12 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.lookup-senses p { margin: 8px 0; }
.sense-heading span { color: #748073; font-size: 13px; }
.lookup-senses blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
.lookup-range { border-bottom: 1px solid #e0e3d8; padding-bottom: 14px; }
.lookup-range p { margin: 0 0 10px; }
.lookup-range-actions { display: flex; gap: 6px; flex-wrap: wrap; }
/* A saved phrase is one unit: the run is underlined and keeps its status colour. */
.reader-word.is-phrase { text-decoration: underline; text-decoration-style: double; text-underline-offset: 2px; border-radius: 3px; }
.reader-word.is-phrase-selected { outline: 2px solid #bc803d; outline-offset: 1px; }
.lookup-term { border-top: 1px solid #e0e3d8; padding-top: 18px; margin-top: 20px; }
.lookup-term label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
.lookup-term-title { display: flex; font-size: 14px; margin: 0; }
@@ -127,6 +133,19 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.lookup-term .el-textarea textarea { font: inherit; line-height: 1.6; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.chapter-list .chapter-row { flex-wrap: wrap; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.review-card, .review-summary { margin-top: 26px; padding: 28px; border: 1px solid #d9decf; border-radius: 14px; background: #fffdf8; }
.review-summary h2 { margin-top: 0; font-family: Georgia, serif; font-size: 24px; }
.review-word { margin: 12px 0; font-family: Georgia, serif; font-size: 34px; }
.review-example { margin: 12px 0; font-family: Georgia, serif; font-size: 19px; line-height: 1.8; }
.review-answer { margin: 18px 0; padding-top: 18px; border-top: 1px solid #e0e3d8; }
.review-definition { margin: 0 0 12px; font-size: 18px; }
.review-answer blockquote { border-left: 2px solid #cbd9c9; margin: 10px 0; padding-left: 12px; color: #687568; font-style: italic; }
.review-grades { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px; }
.review-actions { margin: 16px 0 0; }
@media (max-width: 760px) {
.login-page { grid-template-columns: 1fr; }
.welcome { padding: 28px; }
+148 -4
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { ElButton, ElDialog, ElInput, ElMessageBox } from 'element-plus'
import { canRetry, statusLabel, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type ChapterSource } from '../stores/library'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
@@ -10,8 +10,23 @@ const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const notice = ref('')
// Renaming the book.
const bookDialog = ref(false)
const bookTitle = ref('')
const bookError = ref('')
// Editing one chapter: title and text, loaded from the source endpoint.
const chapterDialog = ref(false)
const chapterSource = ref<ChapterSource | null>(null)
const chapterTitle = ref('')
const chapterText = ref('')
const chapterError = ref('')
const chapterLoading = ref(false)
const saving = ref(false)
const bookId = computed(() => Number(route.params.id))
const chapterLength = computed(() => [...chapterText.value].length)
async function load() {
retryError.value = ''
@@ -24,6 +39,95 @@ async function retry(chapterId: number) {
catch (reason) { retryError.value = reason instanceof Error ? reason.message : '重试失败,请稍后重试。' }
}
function openBookDialog() {
bookTitle.value = library.book?.title ?? ''
bookError.value = ''
bookDialog.value = true
}
async function saveBookTitle() {
if (saving.value || library.book === null) return
bookError.value = titleProblem(bookTitle.value)
if (bookError.value) return
saving.value = true
try {
await library.renameBook(library.book.id, bookTitle.value)
bookDialog.value = false
notice.value = '书名已更新。'
} catch (reason) {
bookError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally {
saving.value = false
}
}
async function openChapterDialog(chapterId: number) {
chapterDialog.value = true
chapterLoading.value = true
chapterError.value = ''
chapterSource.value = null
try {
const source = await library.loadChapterSource(chapterId)
chapterSource.value = source
chapterTitle.value = source.title
chapterText.value = source.text
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '章节内容暂时无法加载,请稍后重试。'
} finally {
chapterLoading.value = false
}
}
async function saveChapter() {
const source = chapterSource.value
if (saving.value || source === null) return
chapterError.value = titleProblem(chapterTitle.value) || textProblem(chapterText.value)
if (chapterError.value) return
saving.value = true
try {
const edited = await library.updateChapter(source.id, { title: chapterTitle.value, text: chapterText.value })
chapterDialog.value = false
notice.value = edited.versionChanged ? '章节已保存为新版本,正在重新处理。' : '章节已保存。'
} catch (reason) {
chapterError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
} finally {
saving.value = false
}
}
async function confirmDeleteBook() {
if (library.book === null) return
const id = library.book.id
try {
await ElMessageBox.confirm('删除这本书及其章节?已保存的生词和短语将保留。', '删除书籍', {
confirmButtonText: '确认删除', cancelButtonText: '取消,保留书籍', type: 'warning',
})
} catch { return }
try {
const deleted = await library.deleteBook(id)
await router.replace(`/?deleted=${deleted.chapters ?? 0}`)
} catch (reason) {
retryError.value = reason instanceof Error ? reason.message : '删除失败,请稍后重试。'
}
}
async function confirmDeleteChapter(chapterId: number, ordinal: number) {
try {
await ElMessageBox.confirm(`删除第 ${ordinal} 章?本章正文将被删除,已保存词条保留。`, '删除章节', {
confirmButtonText: '确认删除章节', cancelButtonText: '取消', type: 'warning',
})
} catch { return }
try {
const deleted = await library.deleteChapter(chapterId)
// The chapter no longer exists, so the dialog closes and the list reports the remainder.
chapterDialog.value = false
chapterSource.value = null
notice.value = `章节已删除 · 剩余 ${deleted.remaining} 章`
} catch (reason) {
retryError.value = reason instanceof Error ? reason.message : '删除失败,请稍后重试。'
}
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
@@ -57,12 +161,15 @@ onUnmounted(() => library.closeBook())
<div class="page-title">
<div>
<h1>{{ library.book.title }}</h1>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语</p>
<p class="subtle">{{ library.chapters.length }} 个章节 · 语言 英语 · 封面使用系统默认样式</p>
</div>
<div class="page-actions">
<ElButton data-testid="edit-book" @click="openBookDialog">编辑书名</ElButton>
<RouterLink :to="`/import?book=${library.book.id}`" class="link-button">追加章节</RouterLink>
<ElButton type="danger" plain data-testid="delete-book" @click="confirmDeleteBook">删除书籍</ElButton>
</div>
</div>
<p v-if="notice" role="status" class="chapter-notice" data-testid="book-notice">{{ notice }}</p>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
<ul v-if="library.chapters.length" class="chapter-list" aria-label="章节列表">
<li v-for="item in library.chapters" :key="item.id" class="chapter-row">
@@ -82,13 +189,50 @@ onUnmounted(() => library.closeBook())
:loading="library.retryingChapterId === item.id"
@click="retry(item.id)"
>重试</ElButton>
<ElButton size="small" :data-testid="`edit-chapter-${item.id}`" @click="openChapterDialog(item.id)">编辑</ElButton>
</li>
</ul>
<section v-else class="empty-library" aria-label="章节列表">
<section v-else class="empty-library" aria-label="章节列表" data-testid="empty-chapters">
<h2>这一本书还没有章节</h2>
<p class="subtle">粘贴一段英文即可生成第一章。</p>
</section>
</template>
<ElDialog v-model="bookDialog" title="编辑书名" width="420" data-testid="book-dialog">
<label for="book-title">书名</label>
<ElInput id="book-title" v-model="bookTitle" type="text" maxlength="200" :disabled="saving" />
<p class="subtle">封面使用系统默认样式。</p>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
<template #footer>
<ElButton @click="bookDialog = false">取消</ElButton>
<ElButton type="primary" :loading="saving" data-testid="save-book" @click="saveBookTitle">保存修改</ElButton>
</template>
</ElDialog>
<ElDialog v-model="chapterDialog" title="编辑章节" width="620" data-testid="chapter-dialog">
<p v-if="chapterLoading" role="status" class="loading">正在加载…</p>
<template v-else-if="chapterSource">
<label for="chapter-title">章节标题</label>
<ElInput id="chapter-title" v-model="chapterTitle" type="text" maxlength="200" :disabled="saving" />
<label for="chapter-text">正文</label>
<ElInput id="chapter-text" v-model="chapterText" type="textarea" :rows="12" :disabled="saving" />
<p class="counter">{{ chapterLength }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p class="subtle">保存后会重新处理这一章;只改标题不会重新处理。</p>
</template>
<p v-if="chapterError" role="alert" class="field-error">{{ chapterError }}</p>
<template #footer>
<ElButton data-testid="cancel-chapter" @click="chapterDialog = false">取消</ElButton>
<ElButton
v-if="chapterSource"
type="danger"
plain
:disabled="saving"
data-testid="delete-chapter"
@click="confirmDeleteChapter(chapterSource.id, chapterSource.ordinal)"
>删除章节</ElButton>
<ElButton type="primary" :loading="saving" :disabled="chapterLoading || !chapterSource" data-testid="save-chapter" @click="saveChapter">保存章节</ElButton>
</template>
</ElDialog>
</main>
</div>
</template>
+69 -6
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, fileProblem, fileSizeLabel, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
@@ -14,13 +14,50 @@ const title = ref('')
const text = ref('')
const mode = ref<'new' | 'append'>('new')
const bookId = ref<number | undefined>(undefined)
// The accepted prototype offers both sources on one screen; the server treats them the same.
const source = ref<'paste' | 'txt'>('paste')
const file = ref<File | null>(null)
const fileInfo = ref<{ name: string; encoding: string; size: string } | null>(null)
const titleError = ref('')
const textError = ref('')
const fileError = ref('')
const bookError = ref('')
const length = computed(() => [...text.value].length)
const busy = computed(() => library.submitting)
/** The browser pre-check only replaces the server, it never replaces its verdict. */
async function readFile(selected: File): Promise<void> {
fileError.value = ''
fileInfo.value = null
const problem = fileProblem(selected)
if (problem) {
file.value = null
fileError.value = problem
return
}
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
decoder.decode(await selected.arrayBuffer())
} catch {
file.value = null
fileError.value = '文件不是 UTF-8 编码,请另存为 UTF-8 后重试。'
return
}
file.value = selected
fileInfo.value = { name: selected.name, encoding: 'UTF-8', size: fileSizeLabel(selected.size) }
// A file usually defines the title; the learner can still change it before submitting.
if (!title.value.trim()) title.value = selected.name.replace(/\.txt$/i, '').slice(0, 120)
}
function onFile(event: Event): void {
const selected = (event.target as HTMLInputElement).files?.[0]
file.value = null
fileInfo.value = null
fileError.value = ''
if (selected) void readFile(selected)
}
function requestedBookId(): number | undefined {
const raw = Array.isArray(route.query.book) ? route.query.book[0] : route.query.book
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return undefined
@@ -43,23 +80,35 @@ watch(mode, value => {
watch(title, () => { titleError.value = '' })
watch(text, () => { textError.value = '' })
// Switching source clears the other source's complaints and its result.
watch(source, () => {
titleError.value = ''
textError.value = ''
fileError.value = ''
library.submitError = ''
})
async function submit() {
if (busy.value) return
titleError.value = titleProblem(title.value)
textError.value = textProblem(text.value)
textError.value = source.value === 'paste' ? textProblem(text.value) : ''
fileError.value = source.value === 'txt' && !file.value ? (fileError.value || '请选择要导入的 TXT 文件。') : fileError.value
bookError.value = mode.value === 'append' && bookId.value === undefined ? '请选择要追加的书籍。' : ''
if (titleError.value || textError.value || bookError.value) return
if (titleError.value || textError.value || fileError.value || bookError.value) return
const target: SubmitTarget = mode.value === 'append' && bookId.value !== undefined
? { mode: 'append', bookId: bookId.value }
: { mode: 'new' }
try {
const createdBookId = await library.submit({ title: title.value, text: text.value, target })
const createdBookId = source.value === 'txt' && file.value
? await library.upload({ title: title.value, target, file: file.value })
: await library.submit({ title: title.value, text: text.value, target })
// A response that arrives after the user left this page must not navigate them back.
if (disposed) return
// The requestId was consumed by this submission, so the form starts clean.
title.value = ''
text.value = ''
file.value = null
fileInfo.value = null
await router.replace(`/books/${createdBookId}`)
} catch {
// library.submitError already carries the server message for the template.
@@ -92,6 +141,13 @@ onUnmounted(() => {
</div>
</div>
<form class="import-form" novalidate @submit.prevent="submit">
<div class="field">
<span class="field-label">导入方式</span>
<ElRadioGroup v-model="source" :disabled="busy" aria-label="导入方式">
<ElRadio value="paste">粘贴文本</ElRadio>
<ElRadio value="txt">TXT 文件</ElRadio>
</ElRadioGroup>
</div>
<div class="field">
<span class="field-label">语言</span>
<p class="fixed-value">{{ LANGUAGE_LABEL }}</p>
@@ -116,15 +172,22 @@ onUnmounted(() => {
<p v-if="library.booksError" role="alert" class="field-error">{{ library.booksError }}</p>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
</div>
<div class="field">
<div v-if="source === 'paste'" class="field">
<label for="text">正文</label>
<ElInput id="text" v-model="text" type="textarea" :rows="12" placeholder="在此粘贴英文正文…" :disabled="busy" />
<p class="counter">{{ length }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p v-if="textError" role="alert" class="field-error">{{ textError }}</p>
</div>
<div v-else class="field">
<label for="file">TXT 文件</label>
<input id="file" data-testid="file-input" type="file" accept=".txt,text/plain" :disabled="busy" @change="onFile" />
<p v-if="fileInfo" class="counter" data-testid="file-info">{{ fileInfo.name }} · {{ fileInfo.encoding }} · {{ fileInfo.size }}</p>
<p v-else class="subtle">仅支持 UTF-8 的 .txt 文件,最多 2 MiB;文件不会保存在服务器上。</p>
<p v-if="fileError" role="alert" class="field-error">{{ fileError }}</p>
</div>
<p v-if="library.submitError" role="alert" class="notice">{{ library.submitError }}</p>
<div class="form-actions">
<ElButton type="primary" native-type="submit" :loading="busy" :disabled="busy">开始处理</ElButton>
<ElButton type="primary" native-type="submit" :loading="busy" :disabled="busy">{{ source === 'txt' ? '上传并处理' : '开始处理' }}</ElButton>
<RouterLink to="/" class="subtle">返回书库</RouterLink>
</div>
</form>
+11 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import BookMark from '../components/BookMark.vue'
import { statusSummary, useLibraryStore } from '../stores/library'
@@ -8,8 +8,15 @@ import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const library = useLibraryStore()
const router = useRouter()
const route = useRoute()
const loading = ref(true)
const error = ref('')
// A deletion returns here with the count it removed, so the learner sees what happened.
const deletedNotice = computed(() => {
const raw = Array.isArray(route.query.deleted) ? route.query.deleted[0] : route.query.deleted
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return ''
return '书籍已删除 · 已保存的生词和短语仍保留在生词本。'
})
async function load() {
loading.value = true
error.value = ''
@@ -31,7 +38,7 @@ onMounted(load)
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
@@ -45,6 +52,7 @@ onMounted(load)
<ElButton type="primary" @click="router.push('/import')">导入内容</ElButton>
</div>
</div>
<p v-if="deletedNotice" role="status" class="chapter-notice" data-testid="library-notice">{{ deletedNotice }}</p>
<p v-if="loading" role="status" class="loading">正在加载…</p>
<div v-else-if="error || library.booksError" class="notice">
<p role="alert">{{ error || library.booksError }}</p>
+102 -6
View File
@@ -4,7 +4,9 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { canRetry, statusLabel, useLibraryStore } from '../stores/library'
import { useSessionStore } from '../stores/session'
import { useReaderLookup } from '../composables/useReaderLookup'
import { useReaderLookup, type PhraseSpan, type ReaderToken } from '../composables/useReaderLookup'
import { adjustTokenRange, MAX_PHRASE_WORDS, normalizeTokenRange, phrasesAt, rangeOfSpan, wordIndices } from '../composables/readerRange'
import { useTextSelection } from '../composables/useTextSelection'
import ReaderTokens from '../components/ReaderTokens.vue'
import LookupPanel from '../components/LookupPanel.vue'
@@ -13,6 +15,8 @@ const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const retryError = ref('')
const rangeNotice = ref('')
const readerBody = ref<HTMLElement | null>(null)
const chapterId = computed(() => Number(route.params.id))
const chapter = computed(() => library.chapter)
@@ -20,9 +24,69 @@ const lookup = useReaderLookup(chapter)
// Retry uses the job id the chapter carries, no matter where it was loaded from.
const retryable = computed(() => chapter.value !== null && canRetry(chapter.value))
/** A dragged selection becomes a phrase; a single word stays a plain word lookup. */
function pickRange(anchor: number, focus: number, element: HTMLElement | null): void {
const text = library.readerText
const count = wordIndices(lookup.tokens.value.slice(anchor, focus + 1)).length
if (count < 2) return
if (count > MAX_PHRASE_WORDS) {
rangeNotice.value = `短语最多 ${MAX_PHRASE_WORDS} 个单词,请缩短选择范围。`
return
}
const range = normalizeTokenRange(lookup.tokens.value, text, anchor, focus)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element)
}
const selection = useTextSelection({ container: readerBody, onSelect: (anchor, focus) => pickRange(anchor, focus, readerBody.value) })
/** Shift-clicking a word extends the phrase from the word that was picked first. */
function selectToken(token: ReaderToken, element: HTMLElement, extend: boolean): void {
rangeNotice.value = ''
const anchor = lookup.selected.value
if (extend && anchor) {
const from = Number(element.dataset.tokenIndex)
const first = lookup.tokens.value.findIndex(item => item.start === anchor.start)
if (from >= 0 && first >= 0) {
pickRange(Math.min(from, first), Math.max(from, first), element)
return
}
}
lookup.select(token, element)
}
/** Clicking inside a saved phrase edits the phrase instead of the word under the cursor. */
function selectPhrase(span: PhraseSpan, element: HTMLElement): void {
const range = rangeOfSpan(lookup.tokens.value, library.readerText, span)
if (!range) return
rangeNotice.value = ''
lookup.selectRange(range, element, span.id)
}
const adjustOptions = computed(() => {
const current = lookup.range.value
if (!current) return null
const text = library.readerText
return {
startLeft: adjustTokenRange(lookup.tokens.value, text, current, 'start', -1) !== null,
startRight: adjustTokenRange(lookup.tokens.value, text, current, 'start', 1) !== null,
endLeft: adjustTokenRange(lookup.tokens.value, text, current, 'end', -1) !== null,
endRight: adjustTokenRange(lookup.tokens.value, text, current, 'end', 1) !== null,
}
})
const panelWord = computed(() => lookup.range.value?.text ?? lookup.selected.value?.text ?? '')
function closePanel(): void {
lookup.close()
selection.clear()
}
async function load() {
lookup.reset()
retryError.value = ''
rangeNotice.value = ''
await library.loadChapter(chapterId.value)
}
@@ -55,13 +119,13 @@ onUnmounted(() => library.closeChapter())
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value }" @keydown.esc="lookup.close()">
<main class="page reader-page" :class="{ 'has-lookup': lookup.selected.value || lookup.range.value }" @keydown.esc="closePanel">
<p v-if="library.chapterLoading && !chapter" role="status" class="loading">正在加载…</p>
<div v-else-if="library.chapterError" class="notice">
<p role="alert">{{ library.chapterError }}</p>
@@ -85,15 +149,47 @@ onUnmounted(() => library.closeChapter())
</div>
<p v-else-if="chapter.status !== 'ready'" role="status" class="processing-hint">这一章还在{{ statusLabel(chapter.status) }},页面会自动刷新。</p>
<p v-if="retryError" role="alert" class="notice">{{ retryError }}</p>
<p v-if="rangeNotice" role="alert" class="notice" data-testid="range-notice">{{ rangeNotice }}</p>
<div v-if="chapter.status === 'ready'" class="reader-workspace">
<div class="reader-body">
<ReaderTokens :tokens="lookup.tokens.value" :original="library.readerText" :selected-start="lookup.selected.value?.start" @select="lookup.select" />
<div class="reader-body" ref="readerBody">
<ReaderTokens
:tokens="lookup.tokens.value"
:phrases="lookup.phrases.value"
:original="library.readerText"
:selected-start="lookup.selected.value?.start"
:phrase-start="lookup.rangeTermId.value"
@select="selectToken"
@select-phrase="selectPhrase"
/>
<div v-if="lookup.tokensError.value" class="tokens-notice">
<p role="status">{{ lookup.tokensError.value }}</p>
<ElButton data-testid="tokens-retry" :loading="lookup.tokensLoading.value" @click="lookup.loadTokens">重试加载单词</ElButton>
</div>
</div>
<LookupPanel v-if="lookup.selected.value" :word="lookup.selected.value.text" :result="lookup.result.value" :loading="lookup.loading.value" :error="lookup.error.value" :saving="lookup.saving.value" :save-error="lookup.saveError.value" :saved="lookup.saved.value" :saved-term-id="lookup.savedTermId.value" :prefilling="lookup.prefilling.value" :prefill-error="lookup.prefillError.value" :can-save="lookup.canSave.value" v-model:definition="lookup.definition.value" v-model:examples="lookup.examples.value" v-model:status="lookup.status.value" @close="lookup.close()" @retry="lookup.lookup" @save="lookup.save" @resize="lookup.keepSelectionVisible" />
<LookupPanel
v-if="lookup.selected.value || lookup.range.value"
:word="panelWord"
:phrase="lookup.range.value ? { wordCount: lookup.range.value.wordCount, stored: lookup.rangeStored.value } : null"
:adjust="adjustOptions"
:result="lookup.result.value"
:loading="lookup.loading.value"
:error="lookup.error.value"
:saving="lookup.saving.value"
:save-error="lookup.saveError.value"
:saved="lookup.saved.value"
:saved-term-id="lookup.savedTermId.value"
:prefilling="lookup.prefilling.value"
:prefill-error="lookup.prefillError.value"
:can-save="lookup.canSave.value"
v-model:definition="lookup.definition.value"
v-model:examples="lookup.examples.value"
v-model:status="lookup.status.value"
@close="closePanel"
@retry="lookup.lookup"
@save="lookup.save"
@adjust="(edge, direction) => { lookup.adjustRange(edge, direction); selection.clear() }"
@resize="lookup.keepSelectionVisible"
/>
</div>
<nav class="reader-nav" aria-label="章节切换">
<ElButton :disabled="library.navigation.previousChapterId === null" @click="goTo(library.navigation.previousChapterId)">上一章</ElButton>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import ReviewCard from '../components/ReviewCard.vue'
import { useReviewStore, type ReviewItem } from '../stores/review'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const review = useReviewStore()
const router = useRouter()
function endReview() { void router.push('/') }
// Retrying reuses the grade of the failed attempt; the store keeps the answer id.
let lastGrade: 'correct' | 'wrong' | 'again' = 'correct'
function grade(value: 'correct' | 'wrong' | 'again') { lastGrade = value; void review.answer(value) }
function retry() { void review.answer(lastGrade) }
onMounted(() => { void review.load() })
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
// The template guards for a card before rendering it; this keeps the prop type strict.
const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewItem
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page review-page">
<h1>到期复习</h1>
<p v-if="review.notice" role="status" class="review-notice" data-testid="review-notice">{{ review.notice }}</p>
<p v-if="review.loading" role="status" class="loading">正在加载…</p>
<div v-else-if="review.error && !review.current" class="notice">
<p role="alert">{{ review.error }}</p>
<ElButton data-testid="review-reload" @click="review.load()">重试</ElButton>
</div>
<section v-else-if="review.finished" class="review-summary" data-testid="review-summary">
<h2>本次复习完成</h2>
<p v-if="review.answered">复习了 {{ review.wordsReviewed }} 个词条 · 共 {{ review.answered }} 次作答</p>
<p v-else class="subtle">本轮没有新的计分:{{ review.resolved }} 个词条已在其他页面复习。</p>
<p class="subtle">答对 {{ review.correctCount }} · 答错或再学 {{ review.wrongCount }} · 已更新复习计划</p>
<p v-if="review.pending" role="status" class="notice">还有 {{ review.pending }} 个词条到期。</p>
<div class="review-grades">
<ElButton v-if="review.pending" type="primary" data-testid="review-more" @click="review.continueRound()">继续复习</ElButton>
<ElButton data-testid="review-finish" @click="endReview">继续阅读</ElButton>
</div>
</section>
<section v-else-if="review.empty" class="review-summary" data-testid="review-empty">
<h2>今天没有到期词条</h2>
<p class="subtle">读一篇文章,积累下一次的词汇。</p>
<ElButton data-testid="review-finish" @click="endReview">返回书库</ElButton>
</section>
<ReviewCard
v-else-if="review.current"
:item="requireItem(review.current)"
:position="review.answered + 1"
:total="review.answered + review.queue.length"
:revealed="review.revealed"
:busy="review.busy"
:error="review.error"
@reveal="review.reveal()"
@grade="grade"
@end="endReview"
@retry="retry"
/>
</main>
</div>
</template>
+34 -1
View File
@@ -78,6 +78,9 @@ func Migrate(db *gorm.DB) error {
if current < 5 {
statements = append(statements, schemaV5Statements...)
}
if current < 6 {
statements = append(statements, schemaV6Statements...)
}
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
@@ -93,7 +96,37 @@ func Migrate(db *gorm.DB) error {
// SchemaVersion is the version an explicit migration leaves behind, and the
// version the server requires before it starts.
const SchemaVersion = 5
const SchemaVersion = 6
var schemaV6Statements = []string{
`CREATE TABLE IF NOT EXISTS lexgo_term_reviews (
term_id BIGINT UNSIGNED PRIMARY KEY,
owner_id BIGINT NOT NULL, language VARCHAR(16) NOT NULL DEFAULT 'en',
due_at DATETIME(3) NOT NULL, review_count INT NOT NULL DEFAULT 0,
correct_count INT NOT NULL DEFAULT 0, wrong_count INT NOT NULL DEFAULT 0,
last_reviewed_at DATETIME(3) NULL,
INDEX idx_term_review_due (owner_id, language, due_at, term_id),
FOREIGN KEY (term_id) REFERENCES lexgo_terms(id) ON DELETE CASCADE,
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`INSERT IGNORE INTO lexgo_term_reviews (term_id, owner_id, language, due_at)
SELECT id, owner_id, language, created_at FROM lexgo_terms`,
`CREATE TABLE IF NOT EXISTS lexgo_review_answers (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
owner_id BIGINT NOT NULL,
answer_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
term_id BIGINT UNSIGNED NOT NULL, grade VARCHAR(16) NOT NULL, result VARCHAR(16) NOT NULL,
status_before VARCHAR(16) NOT NULL, status_after VARCHAR(16) NOT NULL,
level_before TINYINT NOT NULL, level_after TINYINT NOT NULL,
due_at_before DATETIME(3) NOT NULL, due_at_after DATETIME(3) NOT NULL,
requeued BOOLEAN NOT NULL DEFAULT FALSE, created_at DATETIME(3) NOT NULL,
UNIQUE KEY uq_review_answer (owner_id, answer_key),
INDEX idx_review_answer_owner (owner_id, created_at, id), INDEX idx_review_answer_term (term_id, created_at),
CHECK (grade IN ('correct','wrong','again')), CHECK (result IN ('applied','stale')),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE,
FOREIGN KEY (term_id) REFERENCES lexgo_terms(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
// v5 adds one learner's own records for a word form. Identity is the normalized
// form under the owner's language, so re-saving the same word updates one row
+10 -5
View File
@@ -58,8 +58,9 @@ type DictionaryImportResult struct {
Duplicate bool `json:"duplicate"`
}
type ChapterTokens struct {
TextSHA256 string `json:"textSha256"`
Tokens []TextToken `json:"tokens"`
TextSHA256 string `json:"textSha256"`
Tokens []TextToken `json:"tokens"`
Phrases []PhraseSpan `json:"phrases"`
}
// Each router keeps at most one immutable parsed corpus; no private chapter or
@@ -277,12 +278,16 @@ func registerDictionaryRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
if err != nil {
return nil, err
}
// The learner's own saved words are part of the chapter view, so the reader
// shows the same status here as everywhere else.
// The learner's own saved words and phrases are part of the chapter view, so the
// reader shows the same status here as everywhere else.
if err = attachTerms(tx, u.UserId, language, tokens); err != nil {
return nil, err
}
return ChapterTokens{chapter.ContentSHA256, tokens}, nil
phrases, err := phrasesForChapter(tx, u.UserId, language, tokens)
if err != nil {
return nil, err
}
return ChapterTokens{chapter.ContentSHA256, tokens, phrases}, nil
}))
v.POST("/lookup", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input struct {
+286
View File
@@ -0,0 +1,286 @@
package lexgo
import (
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Editing and deleting are strictly owner scoped. Nothing here touches personal terms,
// review rows or answers: those belong to the learner, not to a book or a chapter.
type BookUpdateInput struct {
Title string `json:"title"`
}
type ChapterUpdateInput struct {
Title *string `json:"title"`
Text *string `json:"text"`
}
// ChapterSource is the editable text of one owned chapter. It is separate from the reader
// contract, which only exposes text once a chapter is ready, so a failed chapter can be
// corrected and submitted again.
type ChapterSource struct {
ID int64 `json:"id"`
BookID int64 `json:"bookId"`
Ordinal int `json:"ordinal"`
Title string `json:"title"`
Text string `json:"text"`
Status string `json:"status"`
ContentSHA256 string `json:"contentSha256"`
CharCount int `json:"charCount"`
}
type ChapterEdit struct {
Chapter ChapterSummary `json:"chapter"`
Job *JobView `json:"job"`
VersionChanged bool `json:"versionChanged"`
}
type DeletionResult struct {
BookID int64 `json:"bookId,omitempty"`
ChapterID int64 `json:"chapterId,omitempty"`
Chapters int `json:"chapters,omitempty"`
Remaining int `json:"remaining"`
}
// editTitle validates a title with the same rules a paste uses, so a renamed book or chapter
// stays within the limits the list and reader already rely on.
func editTitle(raw string) (string, error) {
title := strings.TrimSpace(raw)
if title == "" {
return "", failure(400, "请填写标题")
}
if utf8.RuneCountInString(title) > maxTitleRunes {
return "", failure(400, "标题最多 120 个字符")
}
return title, nil
}
// lockOwnedChapter returns the caller's chapter or reports it as missing, so another
// account's chapter id is never confirmed to exist.
func lockOwnedChapter(tx *gorm.DB, owner int, chapterID int64, chapter *Chapter) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", chapterID, owner).First(chapter).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return failure(404, "章节不存在")
}
return err
}
func RenameBook(tx *gorm.DB, owner int, bookID int64, input BookUpdateInput, now time.Time) (BookRef, error) {
title, err := editTitle(input.Title)
if err != nil {
return BookRef{}, err
}
var book Book
if err = lockOwnedBook(tx, owner, bookID, &book); err != nil {
return BookRef{}, err
}
book.Title = title
book.UpdatedAt = stamp(now)
if err = tx.Model(&Book{}).Where("id = ? AND owner_id = ?", book.ID, owner).
Updates(map[string]any{"title": title, "updated_at": book.UpdatedAt}).Error; err != nil {
return BookRef{}, err
}
return bookRef(book), nil
}
func ChapterEditSource(tx *gorm.DB, owner int, chapterID int64) (ChapterSource, error) {
var chapter Chapter
if err := tx.Where("id = ? AND owner_id = ?", chapterID, owner).First(&chapter).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ChapterSource{}, failure(404, "章节不存在")
}
return ChapterSource{}, err
}
return ChapterSource{
ID: chapter.ID, BookID: chapter.BookID, Ordinal: chapter.Ordinal, Title: chapter.Title,
Text: chapter.OriginalText, Status: chapter.Status, ContentSHA256: chapter.ContentSHA256,
CharCount: chapter.CharCount,
}, nil
}
// UpdateChapter renames a chapter and, when the text really changed, stores it as a new
// version and queues a job for it. Only a changed version re-processes: a repeated save of
// the same text is idempotent, and a title-only edit never touches the processing state.
func UpdateChapter(tx *gorm.DB, owner int, chapterID int64, input ChapterUpdateInput, now time.Time) (ChapterEdit, error) {
if input.Title == nil && input.Text == nil {
return ChapterEdit{}, failure(400, "请选择要修改的内容")
}
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return ChapterEdit{}, err
}
ts := stamp(now)
updates := map[string]any{"updated_at": ts}
if input.Title != nil {
title, err := editTitle(*input.Title)
if err != nil {
return ChapterEdit{}, err
}
chapter.Title = title
updates["title"] = title
}
var job *IngestJob
if input.Text != nil {
text := *input.Text
if _, sha, count, err := validatePaste(chapter.Title, text); err != nil {
return ChapterEdit{}, err
} else if sha != chapter.ContentSHA256 {
// A new version replaces the text and owns the chapter's state from here on.
chapter.OriginalText, chapter.ContentSHA256, chapter.CharCount = text, sha, count
chapter.Status, chapter.ErrorReason = statusPending, ""
updates["original_text"] = text
updates["content_sha256"] = sha
updates["char_count"] = count
updates["status"] = statusPending
updates["error_reason"] = ""
// The request key is derived from chapter and version, so one version has one job.
key := contentSHA(fmt.Sprintf("edit:%d:%s", chapter.ID, sha))
created := IngestJob{OwnerID: owner, BookID: chapter.BookID, ChapterID: chapter.ID,
RequestKey: key, ContentSHA256: sha, Status: statusPending, CreatedAt: ts, UpdatedAt: ts}
if err := tx.Create(&created).Error; err != nil {
return ChapterEdit{}, err
}
job = &created
}
}
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", chapter.ID, owner).Updates(updates).Error; err != nil {
return ChapterEdit{}, err
}
result := ChapterEdit{VersionChanged: job != nil}
if job != nil {
view := jobView(*job)
result.Job = &view
}
jobID := int64(0)
if job != nil {
jobID = job.ID
}
result.Chapter = chapterSummaryWithJob(chapter, &jobID)
return result, nil
}
// DeleteBook removes the caller's book with its chapters and their jobs in one transaction.
// Personal terms, review schedules and answers are not touched: they belong to the learner.
func DeleteBook(tx *gorm.DB, owner int, bookID int64) (DeletionResult, error) {
var book Book
if err := lockOwnedBook(tx, owner, bookID, &book); err != nil {
return DeletionResult{}, err
}
var chapters int64
if err := tx.Model(&Chapter{}).Where("book_id = ? AND owner_id = ?", book.ID, owner).Count(&chapters).Error; err != nil {
return DeletionResult{}, err
}
// Chapters and their jobs go with the book through the foreign keys.
if err := tx.Where("id = ? AND owner_id = ?", book.ID, owner).Delete(&Book{}).Error; err != nil {
return DeletionResult{}, err
}
return DeletionResult{BookID: book.ID, Chapters: int(chapters)}, nil
}
// DeleteChapter removes one owned chapter with its jobs and closes the gap in the chapter
// order, so "剩余 N 章" and the reader navigation stay contiguous.
func DeleteChapter(tx *gorm.DB, owner int, chapterID int64) (DeletionResult, error) {
var chapter Chapter
if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return DeletionResult{}, err
}
// Lock the book too: two concurrent deletions in one book must not renumber each other.
var book Book
if err := lockOwnedBook(tx, owner, chapter.BookID, &book); err != nil {
return DeletionResult{}, err
}
if err := tx.Where("id = ? AND owner_id = ?", chapter.ID, owner).Delete(&Chapter{}).Error; err != nil {
return DeletionResult{}, err
}
// Ordering ascending decrements each row into a slot the previous row just freed, which
// keeps the unique (book_id, ordinal) key satisfied throughout.
if err := tx.Exec("UPDATE lexgo_chapters SET ordinal = ordinal - 1 WHERE book_id = ? AND owner_id = ? AND ordinal > ? ORDER BY ordinal ASC",
chapter.BookID, owner, chapter.Ordinal).Error; err != nil {
return DeletionResult{}, err
}
var remaining int64
if err := tx.Model(&Chapter{}).Where("book_id = ? AND owner_id = ?", chapter.BookID, owner).Count(&remaining).Error; err != nil {
return DeletionResult{}, err
}
return DeletionResult{ChapterID: chapter.ID, BookID: chapter.BookID, Remaining: int(remaining)}, nil
}
func registerEditRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.PATCH("/books/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
var input BookUpdateInput
if err = decode(c, &input); err != nil {
return nil, err
}
book, err := RenameBook(tx, u.UserId, id, input, now())
if err != nil {
return nil, err
}
return gin.H{"book": book}, nil
}))
v.DELETE("/books/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
result, err := DeleteBook(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"deleted": result}, nil
}))
v.GET("/chapters/:id/source", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if c.Request.URL.RawQuery != "" {
return nil, failure(400, "正文接口不接受查询参数")
}
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
source, err := ChapterEditSource(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"source": source}, nil
}))
v.PATCH("/chapters/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
var input ChapterUpdateInput
if err = decode(c, &input); err != nil {
return nil, err
}
edited, err := UpdateChapter(tx, u.UserId, id, input, now())
if err != nil {
return nil, err
}
return edited, nil
}))
v.DELETE("/chapters/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
result, err := DeleteChapter(tx, u.UserId, id)
if err != nil {
return nil, err
}
return gin.H{"deleted": result}, nil
}))
}
+543
View File
@@ -0,0 +1,543 @@
package lexgo
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
)
const editFixtureText = "Mira opened the workshop.\nThe sign read “A small step…”\n"
func TestEditTitleRules(t *testing.T) {
if title, err := editTitle(" A small step "); err != nil || title != "A small step" {
t.Fatalf("trimmed title: %q %v", title, err)
}
if _, err := editTitle(" "); err == nil {
t.Fatal("an empty title must be rejected")
}
if _, err := editTitle(strings.Repeat("a", maxTitleRunes+1)); err == nil {
t.Fatal("a title over the limit must be rejected")
}
if _, err := editTitle(strings.Repeat("a", maxTitleRunes)); err != nil {
t.Fatalf("the exact title limit must be accepted: %v", err)
}
}
func patchResource(t *testing.T, r *gin.Engine, token, path string, body any) (int, string, json.RawMessage) {
t.Helper()
return callRaw(t, r, "PATCH", path, token, body)
}
func existingTitle(t *testing.T, r *gin.Engine, token string, bookID int64) string {
t.Helper()
code, detail := bookDetail(t, r, token, bookID)
if code != 200 {
t.Fatalf("book detail status %d", code)
}
return detail.Book.Title
}
// TestMySQLRenameBookAndChapter covers renaming only: the text and the processing state stay
// exactly as they were.
func TestMySQLRenameBookAndChapter(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "rename-fixture-0001", "title": "Before Rename", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
drainIngest(t, db)
before := chapterRow(t, db, pasted.Chapter.ID)
code, msg, data := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": " After Rename "})
if code != 200 {
t.Fatalf("rename book status %d (%s)", code, msg)
}
var renamed struct {
Book struct {
ID int64
Title string
}
}
json.Unmarshal(data, &renamed)
if renamed.Book.Title != "After Rename" || existingTitle(t, r, learner.Token, pasted.Book.ID) != "After Rename" {
t.Fatalf("renamed book %+v", renamed.Book)
}
code, msg, data = patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"title": "Chapter Two"})
if code != 200 {
t.Fatalf("rename chapter status %d (%s)", code, msg)
}
var edited ChapterEdit
json.Unmarshal(data, &edited)
if edited.Chapter.Title != "Chapter Two" || edited.VersionChanged || edited.Job != nil {
t.Fatalf("rename must not re-process: %+v", edited)
}
after := chapterRow(t, db, pasted.Chapter.ID)
if after.Title != "Chapter Two" || after.Status != statusReady || after.ContentSHA256 != before.ContentSHA256 || after.OriginalText != before.OriginalText {
t.Fatalf("rename changed the content state: %+v", after)
}
var jobs int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobs)
if jobs != 1 {
t.Fatalf("a rename created %d jobs", jobs)
}
// Validation and ownership.
for _, body := range []map[string]string{{"title": " "}, {"title": strings.Repeat("a", maxTitleRunes+1)}} {
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), body); code != 400 {
t.Fatalf("invalid rename %v accepted", body)
}
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]any{"title": "x", "ownerId": 9}); code != 400 {
t.Fatal("an unknown field must be rejected")
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]any{"name": "x"}); code != 400 {
t.Fatal("a missing title must be rejected")
}
if code, _, _ := patchResource(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": "Stolen"}); code != 404 {
t.Fatal("another account must not rename this book")
}
if code, _, _ := patchResource(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"title": "Stolen"}); code != 404 {
t.Fatal("another account must not rename this chapter")
}
if code, _, _ := patchResource(t, r, "", fmt.Sprintf("/api/v1/books/%d", pasted.Book.ID), map[string]string{"title": "Anonymous"}); code != 401 {
t.Fatal("renaming requires a session")
}
if title := existingTitle(t, r, learner.Token, pasted.Book.ID); title != "After Rename" {
t.Fatalf("a rejected rename changed the book: %q", title)
}
}
// TestMySQLChapterEditVersioning is the core of the ticket: an edit creates a new version, and
// the older run must not fail or publish over it.
func TestMySQLChapterEditVersioning(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "edit-fixture-0001", "title": "Editable", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
// The first run is claimed but not finished, so the edit lands while it is in flight.
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed || job.ChapterID != pasted.Chapter.ID {
t.Fatalf("claim (claimed=%v): %v", claimed, err)
}
newText := "Mira reopened the workshop.\r\n\r\nA newer version of the text.\n"
code, msg, data := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText})
if code != 200 {
t.Fatalf("edit status %d (%s)", code, msg)
}
var edited ChapterEdit
json.Unmarshal(data, &edited)
if !edited.VersionChanged || edited.Job == nil || edited.Chapter.Status != statusPending {
t.Fatalf("edit must queue a new version: %+v", edited)
}
row := chapterRow(t, db, pasted.Chapter.ID)
if row.OriginalText != newText || row.ContentSHA256 != contentSHA(newText) || row.Status != statusPending || row.CharCount != len([]rune(newText)) {
t.Fatalf("stored version: %+v", row)
}
if row.ID != pasted.Chapter.ID {
t.Fatal("an edit must keep the chapter id, so the reading entry stays the same")
}
// The in-flight run belongs to the previous version: it must not touch the chapter.
if err := FinishIngestJob(t.Context(), db, job, clock.Add(time.Second)); err != nil {
t.Fatalf("finishing the older run must not fail: %v", err)
}
var stale IngestJob
if err := db.First(&stale, job.ID).Error; err != nil {
t.Fatal(err)
}
if stale.Status != statusFailed || stale.ErrorReason != reasonSuperseded {
t.Fatalf("the older run must be marked superseded: %+v", stale)
}
if row = chapterRow(t, db, pasted.Chapter.ID); row.Status != statusPending || row.ErrorReason != "" || row.OriginalText != newText {
t.Fatalf("the older run changed the newer version: %+v", row)
}
// Retrying the superseded job is refused instead of reprocessing old text.
code, _, _ = callRaw(t, r, "POST", fmt.Sprintf("/api/v1/jobs/%d/retry", stale.ID), learner.Token, nil)
if code != 409 {
t.Fatalf("retrying a superseded job: %d", code)
}
// The new version publishes normally and the reader shows the new text.
drainIngest(t, db)
code, reader := readChapter(t, r, learner.Token, pasted.Chapter.ID)
if code != 200 || reader.Chapter.Status != statusReady || reader.Chapter.OriginalText != newText {
t.Fatalf("new version: status %d, %+v", code, reader.Chapter)
}
// Saving the same text again is not a new version.
var jobsBefore int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobsBefore)
code, _, data = patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText})
json.Unmarshal(data, &edited)
if code != 200 || edited.VersionChanged || edited.Job != nil {
t.Fatalf("an unchanged text must not create a version: %+v", edited)
}
var jobsAfter int64
db.Model(&IngestJob{}).Where("chapter_id = ?", pasted.Chapter.ID).Count(&jobsAfter)
if jobsAfter != jobsBefore {
t.Fatalf("an unchanged text created a job: %d -> %d", jobsBefore, jobsAfter)
}
if row = chapterRow(t, db, pasted.Chapter.ID); row.Status != statusReady {
t.Fatalf("an unchanged save changed the status: %+v", row)
}
// Text rules match a paste, and an empty body is refused.
for _, body := range []map[string]string{{"text": " \n\t "}, {"text": strings.Repeat("a", maxChapterRunes+1)}} {
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), body); code != 400 {
t.Fatalf("invalid text %v accepted", body)
}
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{}); code != 400 {
t.Fatal("an empty edit must be rejected")
}
if code, _, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]any{"text": "Body.\n", "ownerId": 9}); code != 400 {
t.Fatal("an unknown field must be rejected")
}
}
func TestMySQLChapterSourceAnyStatus(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "source-fixture-0001", "title": "Source", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste status %d", code)
}
readSource := func(token string, chapterID int64) (int, ChapterSource) {
code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source", chapterID), token, nil)
var payload struct {
Source ChapterSource
}
if len(data) > 0 {
json.Unmarshal(data, &payload)
}
return code, payload.Source
}
// A pending chapter has no reading text, but its edit source is available to its owner.
if code, source := readSource(learner.Token, pasted.Chapter.ID); code != 200 || source.Text != editFixtureText || source.Status != statusPending || source.ContentSHA256 != contentSHA(editFixtureText) {
t.Fatalf("pending source: %d %+v", code, source)
}
drainIngest(t, db)
if code, source := readSource(learner.Token, pasted.Chapter.ID); code != 200 || source.Status != statusReady || source.CharCount != len([]rune(editFixtureText)) {
t.Fatalf("ready source: %d %+v", code, source)
}
if code, _ := readSource(other.Token, pasted.Chapter.ID); code != 404 {
t.Fatal("another account must not read this source")
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/source?id=1", pasted.Chapter.ID), learner.Token, nil); code != 400 {
t.Fatal("the source endpoint must reject query parameters")
}
if code, _ := readSource(learner.Token, 999999); code != 404 {
t.Fatal("an unknown chapter must be 404")
}
}
// TestMySQLDeleteChapterRenumbers closes the gap in the order and keeps personal records.
func TestMySQLDeleteChapterRenumbers(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-fixture-0001", "title": "Three Chapters", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("first paste %d", code)
}
second := pasteInto(t, r, learner.Token, first.Book.ID, "delete-fixture-0002", "Second", "Second chapter body.\n")
third := pasteInto(t, r, learner.Token, first.Book.ID, "delete-fixture-0003", "Third", "Third chapter body.\n")
drainIngest(t, db)
// One personal word in the chapter that will be deleted, plus one in a surviving chapter.
saved := saveWord(t, r, learner.Token, second.Chapter.ID, 0, 6, termStatusNew, nil)
survivor := saveWord(t, r, learner.Token, first.Chapter.ID, 0, 4, termStatusNew, nil)
before := termReviewRow(t, db, saved.Term.ID)
code, msg, data := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", second.Chapter.ID), learner.Token, nil)
if code != 200 {
t.Fatalf("delete chapter status %d (%s)", code, msg)
}
var deleted struct {
Deleted DeletionResult
}
json.Unmarshal(data, &deleted)
if deleted.Deleted.ChapterID != second.Chapter.ID || deleted.Deleted.Remaining != 2 || deleted.Deleted.BookID != first.Book.ID {
t.Fatalf("delete result: %+v", deleted.Deleted)
}
// The remaining chapters are contiguous and in the original order.
code, detail := bookDetail(t, r, learner.Token, first.Book.ID)
if code != 200 || len(detail.Chapters) != 2 {
t.Fatalf("book detail after delete: %d %+v", code, detail.Chapters)
}
if detail.Chapters[0].ID != first.Chapter.ID || detail.Chapters[0].Ordinal != 1 || detail.Chapters[1].ID != third.Chapter.ID || detail.Chapters[1].Ordinal != 2 {
t.Fatalf("renumbered chapters: %+v", detail.Chapters)
}
// Navigation follows the new order, and the deleted chapter is gone with its job.
code, reader := readChapter(t, r, learner.Token, first.Chapter.ID)
if code != 200 || reader.Navigation.NextChapterID == nil || *reader.Navigation.NextChapterID != third.Chapter.ID {
t.Fatalf("navigation after delete: %d %+v", code, reader.Navigation)
}
if code, _ := readChapter(t, r, learner.Token, second.Chapter.ID); code != 404 {
t.Fatal("a deleted chapter must be gone")
}
var jobs int64
db.Model(&IngestJob{}).Where("chapter_id = ?", second.Chapter.ID).Count(&jobs)
if jobs != 0 {
t.Fatalf("the deleted chapter kept %d jobs", jobs)
}
// Personal records survive the deletion, including the schedule.
var terms int64
db.Model(&Term{}).Where("id IN ?", []int64{saved.Term.ID, survivor.Term.ID}).Count(&terms)
var reviews int64
db.Model(&TermReview{}).Where("term_id = ?", saved.Term.ID).Count(&reviews)
if terms != 2 || reviews != 1 {
t.Fatalf("deleting a chapter changed personal records: terms=%d reviews=%d", terms, reviews)
}
if after := termReviewRow(t, db, saved.Term.ID); !after.DueAt.Equal(before.DueAt) || after.ReviewCount != before.ReviewCount {
t.Fatalf("the review schedule changed: %+v -> %+v", before, after)
}
// Repeated and foreign deletions.
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", second.Chapter.ID), learner.Token, nil); code != 404 {
t.Fatal("a repeated delete must be 404")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), other.Token, nil); code != 404 {
t.Fatal("another account must not delete this chapter")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), "", nil); code != 401 {
t.Fatal("deleting requires a session")
}
if code, detail := bookDetail(t, r, learner.Token, first.Book.ID); code != 200 || len(detail.Chapters) != 2 {
t.Fatalf("a rejected delete changed the book: %+v", detail.Chapters)
}
}
// pasteInto appends one chapter to an owned book.
func pasteInto(t *testing.T, r *gin.Engine, token string, bookID int64, requestID, title, text string) pasteResponse {
t.Helper()
code, pasted := pasteChapter(t, r, token, bookID, map[string]string{"requestId": requestID, "title": title, "text": text})
if code != 201 {
t.Fatalf("append %s status %d", title, code)
}
return pasted
}
func TestMySQLDeleteBookKeepsPersonalRecords(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-book-0001", "title": "Doomed Book", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
pasteInto(t, r, learner.Token, first.Book.ID, "delete-book-0002", "Second", "Second chapter body.\n")
drainIngest(t, db)
saved := saveWord(t, r, learner.Token, first.Chapter.ID, 0, 4, termStatusNew, nil)
survivorBook := pasteInto(t, r, learner.Token, first.Book.ID, "delete-book-0003", "Third", "Third body.\n")
otherBook := func() int64 {
code, kept := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-book-keep", "title": "Kept Book", "text": "Kept body.\n", "language": "en"})
if code != 201 {
t.Fatalf("kept book %d", code)
}
return kept.Book.ID
}()
drainIngest(t, db)
code, msg, data := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", first.Book.ID), learner.Token, nil)
if code != 200 {
t.Fatalf("delete book status %d (%s)", code, msg)
}
var deleted struct {
Deleted DeletionResult
}
json.Unmarshal(data, &deleted)
if deleted.Deleted.BookID != first.Book.ID || deleted.Deleted.Chapters != 3 {
t.Fatalf("delete result: %+v", deleted.Deleted)
}
var books, chapters, jobs int64
db.Model(&Book{}).Where("id = ?", first.Book.ID).Count(&books)
db.Model(&Chapter{}).Where("book_id = ?", first.Book.ID).Count(&chapters)
db.Model(&IngestJob{}).Where("book_id = ?", first.Book.ID).Count(&jobs)
if books != 0 || chapters != 0 || jobs != 0 {
t.Fatalf("the deleted book left rows: books=%d chapters=%d jobs=%d", books, chapters, jobs)
}
// The other book of the same account is untouched.
if code, detail := bookDetail(t, r, learner.Token, otherBook); code != 200 || len(detail.Chapters) != 1 {
t.Fatalf("the other book changed: %d %+v", code, detail.Chapters)
}
// Personal records and their schedule survive.
var terms, reviews int64
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&terms)
db.Model(&TermReview{}).Where("term_id = ?", saved.Term.ID).Count(&reviews)
if terms != 1 || reviews != 1 {
t.Fatalf("deleting a book changed personal records: terms=%d reviews=%d", terms, reviews)
}
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].ID != saved.Term.ID {
t.Fatalf("the saved word left the review queue: %d %+v", code, queue.Items)
}
// The deleted chapter and book are no longer readable, and repeats are 404.
if code, _ := readChapter(t, r, learner.Token, survivorBook.Chapter.ID); code != 404 {
t.Fatal("a chapter of the deleted book must be gone")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", first.Book.ID), learner.Token, nil); code != 404 {
t.Fatal("a repeated delete must be 404")
}
if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", otherBook), other.Token, nil); code != 404 {
t.Fatal("another account must not delete this book")
}
_, list := bookList(t, r, other.Token)
if len(list.Items) != 0 {
t.Fatalf("the other account must not see this library: %+v", list.Items)
}
}
// TestMySQLDeleteDuringProcessing proves a deleted chapter cannot come back through a run
// that was already in flight.
func TestMySQLDeleteDuringProcessing(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "delete-processing-01", "title": "In Flight", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed {
t.Fatalf("claim: %v", err)
}
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
t.Fatalf("deleting a processing chapter: %d (%s)", code, msg)
}
// The in-flight run finds nothing to publish and reports no error.
if err := FinishIngestJob(t.Context(), db, job, clock.Add(time.Second)); err != nil {
t.Fatalf("finishing a run for a deleted chapter: %v", err)
}
var chapters, jobs int64
db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Count(&chapters)
db.Model(&IngestJob{}).Where("id = ?", job.ID).Count(&jobs)
if chapters != 0 || jobs != 0 {
t.Fatalf("a deleted chapter came back: chapters=%d jobs=%d", chapters, jobs)
}
// The book stays, with no chapters and a clear empty state for the client.
code, detail := bookDetail(t, r, learner.Token, pasted.Book.ID)
if code != 200 || len(detail.Chapters) != 0 {
t.Fatalf("book after deleting its only chapter: %d %+v", code, detail.Chapters)
}
}
// TestMySQLConcurrentChapterDelete checks two deletions of one chapter in one book.
func TestMySQLConcurrentChapterDelete(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "race-delete-0001", "title": "Race", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
pasteInto(t, r, learner.Token, first.Book.ID, "race-delete-0002", "Second", "Second body.\n")
drainIngest(t, db)
var wg sync.WaitGroup
codes := make(chan int, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", first.Chapter.ID), learner.Token, nil)
codes <- c
}()
}
wg.Wait()
close(codes)
ok, missing := 0, 0
for c := range codes {
switch c {
case 200:
ok++
case 404:
missing++
}
}
if ok != 1 || missing != 1 {
t.Fatalf("concurrent deletes: ok=%d missing=%d", ok, missing)
}
// The single surviving chapter keeps ordinal 1.
var remaining []Chapter
db.Where("book_id = ?", first.Book.ID).Order("ordinal ASC").Find(&remaining)
if len(remaining) != 1 || remaining[0].Ordinal != 1 {
t.Fatalf("ordinals after concurrent delete: %+v", remaining)
}
}
// TestMySQLRecoverySkipsSupersededJobs proves the recovery sweep leaves a newer version alone.
func TestMySQLRecoverySkipsSupersededJobs(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "recovery-fix-0001", "title": "Recovery", "text": editFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
clock := time.Now().UTC().Truncate(time.Millisecond)
job, claimed, err := ClaimNextIngestJob(db, clock)
if err != nil || !claimed {
t.Fatalf("claim: %v", err)
}
newText := "A replacement body.\n"
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": newText}); code != 200 {
t.Fatalf("edit status %d (%s)", code, msg)
}
// The old job looks stale to the sweep; it must be abandoned, not requeued.
requeued, err := RequeueStaleIngestJobs(db, clock.Add(ingestStaleAfter+time.Second))
if err != nil {
t.Fatal(err)
}
if requeued != 0 {
t.Fatalf("the sweep requeued %d superseded job(s)", requeued)
}
var stale IngestJob
if err := db.First(&stale, job.ID).Error; err != nil {
t.Fatal(err)
}
if stale.Status != statusFailed || stale.ErrorReason != reasonSuperseded {
t.Fatalf("the sweep left the old job %+v", stale)
}
if row := chapterRow(t, db, pasted.Chapter.ID); row.Status != statusPending || row.OriginalText != newText {
t.Fatalf("the sweep changed the new version: %+v", row)
}
drainIngest(t, db)
if row := chapterRow(t, db, pasted.Chapter.ID); row.Status != statusReady || row.OriginalText != newText {
t.Fatalf("the new version did not publish: %+v", row)
}
}
+58 -12
View File
@@ -47,13 +47,19 @@ func requeueStaleIngestJobs(db *gorm.DB, now time.Time, staleAfter time.Duration
cutoff := stamp(now.Add(-staleAfter))
var requeued int64
err := db.Transaction(func(tx *gorm.DB) error {
if err := abandonSupersededJobs(tx, ts); err != nil {
return err
}
if err := exhaustIngestJobs(tx, ts); err != nil {
return err
}
stale := []int64{}
if err := tx.Model(&IngestJob{}).
Where("status = ? AND attempts < ? AND updated_at <= ?", statusProcessing, maxIngestAttempts, cutoff).
Pluck("id", &stale).Error; err != nil {
// Only jobs that still describe the chapter's current version may be requeued: an
// interrupted run of an older version must not pull the newer text back into processing.
if err := tx.Table("lexgo_ingest_jobs AS j").
Joins("JOIN lexgo_chapters AS c ON c.id = j.chapter_id AND c.content_sha256 = j.content_sha256").
Where("j.status = ? AND j.attempts < ? AND j.updated_at <= ?", statusProcessing, maxIngestAttempts, cutoff).
Pluck("j.id", &stale).Error; err != nil {
return err
}
if len(stale) == 0 {
@@ -111,10 +117,21 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
ts := stamp(now)
var job IngestJob
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("status = ? AND attempts < ?", statusPending, maxIngestAttempts).
Order("id ASC").First(&job).Error; err != nil {
if err := abandonSupersededJobs(tx, ts); err != nil {
return err
}
// A job may only process the version it was created for, so the chapter join is part
// of the claim and an edited chapter is never dragged back to processing.
if err := tx.Table("lexgo_ingest_jobs AS j").
Select("j.id, j.owner_id, j.book_id, j.chapter_id, j.request_key, j.content_sha256, j.status, j.attempts, j.error_reason, j.created_at, j.updated_at, j.finished_at").
Joins("JOIN lexgo_chapters AS c ON c.id = j.chapter_id AND c.content_sha256 = j.content_sha256").
Where("j.status = ? AND j.attempts < ?", statusPending, maxIngestAttempts).
Order("j.id ASC").Limit(1).Find(&job).Error; err != nil {
return err
}
if job.ID == 0 {
return errNoIngestJob
}
claim := tx.Model(&IngestJob{}).Where("id = ? AND status = ?", job.ID, statusPending).
Updates(map[string]any{"status": statusProcessing, "attempts": gorm.Expr("attempts + 1"), "updated_at": ts})
if claim.Error != nil {
@@ -123,7 +140,7 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
if claim.RowsAffected != 1 {
return errJobTaken
}
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).
if err := tx.Model(&Chapter{}).Where("id = ? AND owner_id = ? AND content_sha256 = ?", job.ChapterID, job.OwnerID, job.ContentSHA256).
Updates(map[string]any{"status": statusProcessing, "updated_at": ts}).Error; err != nil {
return err
}
@@ -132,7 +149,7 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
job.UpdatedAt = ts
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) {
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errJobTaken) || errors.Is(err, errNoIngestJob) {
return IngestJob{}, false, nil
}
if err != nil {
@@ -143,6 +160,9 @@ func ClaimNextIngestJob(db *gorm.DB, now time.Time) (IngestJob, bool, error) {
var errJobTaken = errors.New("ingestion job already claimed")
// errNoIngestJob reports an empty queue, which is not a failure.
var errNoIngestJob = errors.New("no ingestion job to claim")
// FinishIngestJob validates the persisted chapter and publishes it, or records a fixed
// failure reason. The check runs again here because a worker must not trust that content
// reached the table through the paste API.
@@ -150,12 +170,27 @@ func FinishIngestJob(ctx context.Context, db *gorm.DB, job IngestJob, now time.T
ts := stamp(now)
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var chapter Chapter
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).First(&chapter).Error; err != nil {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND owner_id = ?", job.ChapterID, job.OwnerID).First(&chapter).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// The chapter was deleted while this run was in flight; the cascade removed its
// jobs too, so there is nothing left to publish.
return nil
}
if err != nil {
return err
}
if chapter.ContentSHA256 != job.ContentSHA256 {
// The chapter moved to a newer version: publish nothing and leave its state, which
// belongs to the newer job, untouched.
return tx.Model(&IngestJob{}).Where("id = ?", job.ID).Updates(map[string]any{
"status": statusFailed, "error_reason": reasonSuperseded, "updated_at": ts, "finished_at": ts}).Error
}
var book Book
if err := tx.Where("id = ? AND owner_id = ?", job.BookID, job.OwnerID).First(&book).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if reason := unprocessableReason(book, chapter, job); reason != "" {
@@ -185,14 +220,25 @@ func unprocessableReason(book Book, chapter Chapter, job IngestJob) string {
if utf8.RuneCountInString(chapter.OriginalText) > maxChapterRunes {
return reasonTooLong
}
// The job accepted a specific content version; a chapter changed after submission is a
// different paste and must be submitted again rather than silently processed.
if contentSHA(chapter.OriginalText) != job.ContentSHA256 {
// The stored text and its stored version must agree. The job-versus-chapter version check
// ran before this function, so a direct write that changed the text without updating its
// version is the remaining case, and it is a different paste rather than this version.
if contentSHA(chapter.OriginalText) != chapter.ContentSHA256 {
return reasonContentChanged
}
return ""
}
// abandonSupersededJobs fails jobs whose version is no longer the chapter's version. They
// must never publish or fail the chapter, because another job owns its current state. It is
// one multi-table statement: GORM's Updates does not carry a Joins clause into an UPDATE.
func abandonSupersededJobs(tx *gorm.DB, ts time.Time) error {
return tx.Exec(`UPDATE lexgo_ingest_jobs j JOIN lexgo_chapters c ON c.id = j.chapter_id
SET j.status = ?, j.error_reason = ?, j.updated_at = ?, j.finished_at = ?
WHERE j.status IN (?, ?) AND j.content_sha256 <> c.content_sha256`,
statusFailed, reasonSuperseded, ts, ts, statusPending, statusProcessing).Error
}
// ProcessIngestJobs drains up to limit pending jobs. Claiming and finishing each use their
// own transaction, so an interrupted run simply leaves a job for recovery.
func ProcessIngestJobs(ctx context.Context, db *gorm.DB, now func() time.Time, limit int) (int, error) {
+10 -1
View File
@@ -30,7 +30,9 @@ const (
reasonTooLong = "too_long"
reasonEmptyText = "empty_text"
reasonContentChanged = "content_changed"
reasonAttemptsExhausted = "attempts_exhausted"
// reasonSuperseded marks a job whose chapter already moved to a newer content version.
reasonSuperseded = "superseded"
reasonAttemptsExhausted = "attempts_exhausted"
)
const (
@@ -53,6 +55,8 @@ func reasonMessage(reason string) string {
return "内容超过单章上限(100000 个字符)"
case reasonEmptyText:
return "章节内容为空"
case reasonSuperseded:
return "章节内容已更新为新版本,本次处理已作废"
case reasonContentChanged:
return "内容在处理前发生变化,请重新提交"
case reasonAttemptsExhausted:
@@ -640,6 +644,11 @@ func RetryIngestJob(db *gorm.DB, owner int, jobID int64, now time.Time) (JobView
Where("id = ? AND owner_id = ?", job.ChapterID, owner).First(&chapter).Error; err != nil {
return err
}
// A job of an older content version is gone for good: retrying it would process text
// the chapter no longer holds, so the newer job owns the chapter instead.
if chapter.ContentSHA256 != job.ContentSHA256 {
return failure(409, "该任务对应的是旧版本,请刷新后重试当前版本")
}
if err := tx.Model(&IngestJob{}).Where("id = ?", job.ID).
Updates(map[string]any{"status": statusPending, "error_reason": "", "attempts": 0,
"updated_at": ts, "finished_at": nil}).Error; err != nil {
+76
View File
@@ -217,6 +217,82 @@ func TestMigrationFromV4KeepsLibraryAndAddsPersonalTerms(t *testing.T) {
}
}
func TestMigrationFromV5AddsReviewScheduling(t *testing.T) {
db := emptyMigrationDB(t)
statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,5,'lexgo')"}
statements = append(statements, schemaV2Statements...)
statements = append(statements, schemaV3Statements...)
statements = append(statements, schemaV4Statements...)
statements = append(statements, schemaV5Statements...)
for _, statement := range statements {
if err := db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (11,'fixture_v5','fictional-not-a-real-hash',2)").Error; err != nil {
t.Fatal(err)
}
saved := stamp(time.Now())
term := Term{OwnerID: 11, Language: "en", Term: "curiosity", OriginalForm: "Curiosity", Definition: "虚构释义", Examples: "Fictional example.", Status: termStatusLearning, Level: 2, CreatedAt: saved, UpdatedAt: saved}
if err := db.Create(&term).Error; err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err == nil {
t.Fatal("old schema accepted before explicit migration")
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := CheckSchema(db); err != nil {
t.Fatal(err)
}
// The learner's own term is untouched and has never been reviewed.
var restored Term
if err := db.First(&restored, term.ID).Error; err != nil || restored.Definition != term.Definition || restored.Level != 2 || restored.Status != termStatusLearning {
t.Fatal("migration changed the personal term", err)
}
// An existing saved word enters the queue immediately: due at the moment it was saved.
var review TermReview
if err := db.Where("term_id = ?", term.ID).First(&review).Error; err != nil {
t.Fatal("existing terms need a schedule row", err)
}
if !review.DueAt.Equal(saved) || review.ReviewCount != 0 || review.CorrectCount != 0 || review.WrongCount != 0 || review.Language != "en" {
t.Fatalf("schedule row: %#v", review)
}
var answers int64
if err := db.Model(&ReviewAnswer{}).Count(&answers).Error; err != nil || answers != 0 {
t.Fatal("migration must create an empty attempt log", answers, err)
}
// A second identical answer row is rejected by the unique answer key.
digest, err := requestKey("migration-fixture-answer")
if err != nil {
t.Fatal(err)
}
record := ReviewAnswer{OwnerID: 11, AnswerKey: digest, TermID: term.ID, Grade: reviewGradeCorrect, Result: "applied", StatusBefore: termStatusNew, StatusAfter: termStatusLearning, LevelBefore: 0, LevelAfter: 1, DueAtBefore: saved, DueAtAfter: saved, CreatedAt: saved}
if err := db.Create(&record).Error; err != nil {
t.Fatal(err)
}
duplicate := record
duplicate.ID = 0
if err := db.Create(&duplicate).Error; err == nil {
t.Fatal("the answer key must be unique per account")
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
if err := db.Exec("UPDATE lexgo_schema SET version=5 WHERE id=1").Error; err != nil {
t.Fatal(err)
}
if err := Migrate(db); err != nil {
t.Fatal(err)
}
var reviews, attempts int64
if err := db.Model(&TermReview{}).Count(&reviews).Error; err != nil || reviews != 1 {
t.Fatalf("re-upgrade must not duplicate schedule rows: %d %v", reviews, err)
}
if err := db.Model(&ReviewAnswer{}).Count(&attempts).Error; err != nil || attempts != 1 {
t.Fatalf("re-upgrade must keep the attempt log: %d %v", attempts, err)
}
}
func TestMigrationRefusesUnownedOrUnsupportedSchema(t *testing.T) {
for _, tc := range []struct{ name, marker string }{
{"empty_marker", ""},
+246
View File
@@ -0,0 +1,246 @@
package lexgo
import (
"sort"
"strings"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// A phrase is a run of consecutive words. Its identity is the ordered normalized word forms
// joined by single spaces, so interior punctuation, line breaks and extra whitespace never
// split one phrase into two records, and a phrase key can never collide with a word key.
const (
maxPhraseWords = 12
minPhraseWords = 2
maxPhraseKey = 128
maxPhraseSource = 191
)
// PhraseSpan is one saved phrase occurrence inside a chapter, expressed in token indices so
// the reader can style a run without re-deriving the match.
type PhraseSpan struct {
ID int64 `json:"id"`
Status string `json:"status"`
WordCount int `json:"wordCount"`
StartToken int `json:"startToken"`
EndToken int `json:"endToken"`
}
// phraseWords returns the word tokens fully inside a requested code point range. A range that
// cuts into a word is rejected instead of silently dropping it, so the client always learns
// that its selection was not a whole-word range.
func phraseWords(tokens []TextToken, start, end int) ([]TextToken, error) {
bad := failure(400, "请选择至少两个连续的完整单词")
if start < 0 || end <= start {
return nil, bad
}
words := make([]TextToken, 0, 4)
for _, token := range tokens {
if token.End <= start || token.Start >= end {
continue
}
if token.Kind != "word" {
continue
}
if token.Start < start || token.End > end {
return nil, failure(400, "请选择完整的单词,不要切在单词中间")
}
words = append(words, token)
}
if len(words) < minPhraseWords {
return nil, bad
}
if len(words) > maxPhraseWords {
return nil, failure(400, "短语最多 12 个单词")
}
return words, nil
}
// phraseKey is the identity of a phrase: the ordered normalized word forms joined by spaces.
func phraseKey(words []TextToken) string {
forms := make([]string, 0, len(words))
for _, word := range words {
forms = append(forms, normalizeWord(word.Text))
}
return strings.Join(forms, " ")
}
// phraseSource is the exact original text of the selection, punctuation and line breaks
// included, used for display only.
func phraseSource(text string, words []TextToken) string {
if len(words) == 0 {
return ""
}
runes := []rune(text)
start, end := words[0].Start, words[len(words)-1].End
if start < 0 || end > len(runes) || start >= end {
return ""
}
return string(runes[start:end])
}
// phraseMatches finds every saved phrase occurrence in a chapter. Candidates are ordered by
// their start and then longest first, and the leftmost-longest non-overlapping set is kept,
// which is the display rule #4 verified; nothing stored is modified by matching.
func phraseMatches(tokens []TextToken, phrases []Term) []PhraseSpan {
if len(phrases) == 0 {
return []PhraseSpan{}
}
// Index the phrases by their first word so a chapter only compares what can match.
words := make([]int, 0, len(tokens))
for index, token := range tokens {
if token.Kind == "word" {
words = append(words, index)
}
}
byFirst := map[string][]Term{}
for _, phrase := range phrases {
forms := strings.Split(phrase.Term, " ")
if len(forms) < minPhraseWords {
continue
}
byFirst[forms[0]] = append(byFirst[forms[0]], phrase)
}
type candidate struct {
startToken int
endToken int
phrase Term
}
found := make([]candidate, 0, 8)
for position, tokenIndex := range words {
first := normalizeWord(tokens[tokenIndex].Text)
for _, phrase := range byFirst[first] {
forms := strings.Split(phrase.Term, " ")
if position+len(forms) > len(words) {
continue
}
matched := true
for offset, form := range forms {
if normalizeWord(tokens[words[position+offset]].Text) != form {
matched = false
break
}
}
if matched {
found = append(found, candidate{words[position], words[position+len(forms)-1], phrase})
}
}
}
sort.SliceStable(found, func(i, j int) bool {
if found[i].startToken != found[j].startToken {
return found[i].startToken < found[j].startToken
}
if found[i].endToken != found[j].endToken {
return found[i].endToken > found[j].endToken
}
return found[i].phrase.ID < found[j].phrase.ID
})
spans := make([]PhraseSpan, 0, len(found))
lastEnd := -1
for _, item := range found {
if item.startToken <= lastEnd {
continue
}
spans = append(spans, PhraseSpan{
ID: item.phrase.ID, Status: item.phrase.Status, WordCount: termWordCount(item.phrase.Term),
StartToken: item.startToken, EndToken: item.endToken,
})
lastEnd = item.endToken
}
return spans
}
// phrasesForChapter loads the caller's phrases and matches them against one chapter.
func phrasesForChapter(tx *gorm.DB, owner int, language string, tokens []TextToken) ([]PhraseSpan, error) {
var phrases []Term
// A phrase key always contains a space and a word key never does, so this is the whole
// filter; it needs no column.
if err := tx.Select("id", "term", "status").
Where("owner_id = ? AND language = ? AND term LIKE ?", owner, language, "% %").
Find(&phrases).Error; err != nil {
return nil, err
}
return phraseMatches(tokens, phrases), nil
}
type PhraseInput struct {
ChapterID int64 `json:"chapterId"`
Start *int `json:"start"`
End *int `json:"end"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level *int `json:"level"`
}
// SavePhrase derives the identity from the server's own tokens of an owned ready chapter, so a
// client can neither name a phrase it did not select nor forge an owner or language. Saving
// reuses the term table and its idempotent upsert, so one phrase stays one record with one
// review schedule.
func SavePhrase(tx *gorm.DB, owner int, language string, input PhraseInput, now time.Time) (TermSave, error) {
if input.Start == nil || input.End == nil {
return TermSave{}, failure(400, "请选择至少两个连续的完整单词")
}
chapter, err := readyOwnedChapter(tx, owner, input.ChapterID)
if err != nil {
return TermSave{}, err
}
words, err := phraseWords(Tokenize(chapter.OriginalText), *input.Start, *input.End)
if err != nil {
return TermSave{}, err
}
key := phraseKey(words)
if utf8.RuneCountInString(key) > maxPhraseKey {
return TermSave{}, failure(400, "短语过长,请缩短选择范围")
}
source := phraseSource(chapter.OriginalText, words)
if source == "" || utf8.RuneCountInString(source) > maxPhraseSource {
return TermSave{}, failure(400, "短语原文过长,请缩短选择范围")
}
level, err := termLevel(input.Status, input.Level, Term{}, false)
if err != nil {
return TermSave{}, err
}
definition, examples, err := termContent(input.Definition, input.Examples)
if err != nil {
return TermSave{}, err
}
previous, exists, err := previousTerm(tx, owner, language, key)
if err != nil {
return TermSave{}, err
}
if exists && termKind(previous.Term) != termKindPhrase {
return TermSave{}, failure(409, "该内容已被记录为单词")
}
if exists {
level, err = termLevel(input.Status, input.Level, previous, true)
if err != nil {
return TermSave{}, err
}
}
fields := TermFields{
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
// The identity is the word-form key; the original selection is only the display text.
return saveTerm(tx, owner, language, key, source, fields, now)
}
func registerPhraseRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.POST("/phrases", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input PhraseInput
if err := decode(c, &input); err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return SavePhrase(tx, u.UserId, language, input, now())
}))
}
+415
View File
@@ -0,0 +1,415 @@
package lexgo
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
// phraseFixtureText holds the cases #4 verified: interior punctuation, a line break inside a
// phrase, a repeated phrase and a combining accent.
const phraseFixtureText = "Take a small, step\nevery day.\nMira took a small step again.\n"
func phraseTokens(text string) []TextToken {
return Tokenize(text)
}
func spanOf(t *testing.T, tokens []TextToken, words ...string) (int, int) {
t.Helper()
var first, last TextToken
found := 0
for _, token := range tokens {
if token.Kind != "word" {
continue
}
if found < len(words) && normalizeWord(token.Text) == normalizeWord(words[found]) {
if found == 0 {
first = token
}
last = token
found++
}
}
if found != len(words) {
t.Fatalf("could not find %v in %q", words, text(tokens))
}
return first.Start, last.End
}
func text(tokens []TextToken) string {
var builder strings.Builder
for _, token := range tokens {
builder.WriteString(token.Text)
}
return builder.String()
}
// TestPhraseIdentityRules pins the range and identity rules of a phrase.
func TestPhraseIdentityRules(t *testing.T) {
tokens := phraseTokens(phraseFixtureText)
// Interior punctuation and a line break stay in the display text but not in the key.
start, end := spanOf(t, tokens, "a", "small", "step")
words, err := phraseWords(tokens, start, end)
if err != nil || len(words) != 3 {
t.Fatalf("phrase words: %v %v", words, err)
}
if key := phraseKey(words); key != "a small step" {
t.Fatalf("key %q, want %q", key, "a small step")
}
if source := phraseSource(phraseFixtureText, words); source != "a small, step\nevery day."[:0]+"a small, step" {
t.Fatalf("source %q", source)
}
// The same words separated by different punctuation are one identity.
secondTokens := phraseTokens("Mira took a small step again.")
secondStart, secondEnd := spanOf(t, secondTokens, "a", "small", "step")
other, err := phraseWords(secondTokens, secondStart, secondEnd)
if err != nil {
t.Fatal(err)
}
if phraseKey(other) != "a small step" {
t.Fatalf("a differently spaced phrase must share the key: %q", phraseKey(other))
}
// Case and curly apostrophes fold, exactly like word identity.
if key := phraseKey(mustWords(t, "Don’t Look Back")); key != "don't look back" {
t.Fatalf("normalized phrase key: %q", key)
}
// Endpoint alignment: a range that cuts into a word is refused instead of dropping it.
if _, err := phraseWords(tokens, start+3, end); err == nil {
t.Fatal("a range cutting into a word must be rejected")
}
if _, err := phraseWords(tokens, start, end-2); err == nil {
t.Fatal("a range cutting into the last word must be rejected")
}
// A range that starts on a separator simply begins at the next whole word.
trimmed, err := phraseWords(tokens, start+1, end)
if err != nil || len(trimmed) != 2 || normalizeWord(trimmed[0].Text) != "small" {
t.Fatalf("a separator boundary must select whole words: %v %v", trimmed, err)
}
// Surrounding whitespace and punctuation are allowed on both ends.
if _, err := phraseWords(tokens, start-1, end+1); err != nil {
t.Fatalf("surrounding separators must be accepted: %v", err)
}
// A single word, an empty range and a punctuation-only range are not phrases.
if _, err := phraseWords(tokens, words[0].Start, words[0].End); err == nil {
t.Fatal("one word is not a phrase")
}
if _, err := phraseWords(tokens, 0, 0); err == nil {
t.Fatal("an empty range is not a phrase")
}
if _, err := phraseWords(tokens, -1, 4); err == nil {
t.Fatal("a negative range is not a phrase")
}
// Limits: at most 12 words, and a key within the stored column.
long := make([]TextToken, 0, maxPhraseWords+1)
position := 0
for index := 0; index < maxPhraseWords+1; index++ {
word := "word"
long = append(long, TextToken{Text: word, Start: position, End: position + len(word), Kind: "word"})
position += len(word) + 1
}
if _, err := phraseWords(long, 0, position); err == nil {
t.Fatal("more than 12 words must be rejected")
}
if _, err := phraseWords(long[:maxPhraseWords], 0, len(long[maxPhraseWords-1].Text)+long[maxPhraseWords-1].Start); err != nil {
t.Fatalf("exactly 12 words must be accepted: %v", err)
}
}
// TestWordKeysNeverContainSpaces locks the premise the derived kind relies on: a word token
// never contains a space, so the identity key alone tells a word from a phrase and no column is
// needed for it.
func TestWordKeysNeverContainSpaces(t *testing.T) {
samples := []string{phraseFixtureText, "a b\tc\r\nd", "don’t stop-BELIEVING.", "🙂 café e\u0301 42"}
checked := 0
for _, sample := range samples {
for _, token := range Tokenize(sample) {
if token.Kind != "word" {
continue
}
checked++
if strings.ContainsAny(normalizeWord(token.Text), " \t\r\n") {
t.Fatalf("word key %q contains whitespace", normalizeWord(token.Text))
}
}
}
if checked < 8 {
t.Fatalf("expected several word tokens, checked %d", checked)
}
// The derived kind therefore agrees with the shape of the key in both directions.
if termKind("a small step") != termKindPhrase || termWordCount("a small step") != 3 {
t.Fatal("a key with spaces must be a three-word phrase")
}
if termKind("curiosity") != termKindWord || termWordCount("curiosity") != 1 {
t.Fatal("a key without spaces must be a word")
}
}
func mustWords(t *testing.T, text string) []TextToken {
t.Helper()
words, err := phraseWords(phraseTokens(text), 0, len([]rune(text)))
if err != nil {
t.Fatal(err)
}
return words
}
// TestPhraseMatchingAndOverlap pins the cross-chapter match and the leftmost-longest display.
func TestPhraseMatchingAndOverlap(t *testing.T) {
tokens := phraseTokens(phraseFixtureText)
step := Term{ID: 1, Term: "a small step", Status: termStatusNew}
small := Term{ID: 2, Term: "small step", Status: termStatusLearning}
spans := phraseMatches(tokens, []Term{step, small})
if len(spans) != 2 {
t.Fatalf("expected one occurrence of each phrase, got %+v", spans)
}
// The longer phrase starts first, so it wins and the shorter one is dropped where they
// overlap; the later occurrence of "a small step" is a separate match.
if spans[0].ID != step.ID || spans[1].ID != step.ID {
t.Fatalf("leftmost-longest rule: %+v", spans)
}
if spans[0].StartToken >= spans[1].StartToken {
t.Fatalf("spans must be ordered: %+v", spans)
}
// A phrase that no longer occurs is simply absent, and nothing is mutated.
if len(phraseMatches(phraseTokens("Nothing to see here."), []Term{step})) != 0 {
t.Fatal("a phrase that does not occur must not match")
}
// Two different phrases that both match at the same place keep a stable choice.
spans = phraseMatches(tokens, []Term{small, step})
if len(spans) != 2 || spans[0].ID != step.ID {
t.Fatalf("order of input must not change the display: %+v", spans)
}
// A single-word entry never participates as a phrase.
if len(phraseMatches(tokens, []Term{{ID: 9, Term: "step"}})) != 0 {
t.Fatal("a single-word key is not a phrase match")
}
}
// TestMySQLPhraseSaveAndCrossChapterHighlight is the core path: saving a phrase, finding it
// again in another chapter, and keeping one record per identity.
func TestMySQLPhraseSaveAndCrossChapterHighlight(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
code, first := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-fixture-0001", "title": "Phrase chapter", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
// A second chapter with the same phrase in a different form.
code, second := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-fixture-0002", "title": "Another chapter", "text": "Mira took a small step again.\n", "language": "en"})
if code != 201 {
t.Fatalf("second paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
body := map[string]any{
"chapterId": first.Chapter.ID, "start": start, "end": end,
"definition": "一小步", "examples": []string{"Take a small step, every day."}, "status": termStatusNew,
}
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body)
if code != 201 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
Created bool
}
json.Unmarshal(data, &saved)
if saved.Term.Kind != "phrase" || saved.Term.WordCount != 3 || saved.Term.Term != "a small step" {
t.Fatalf("saved phrase %+v", saved.Term)
}
if saved.Term.OriginalForm != "a small, step" {
t.Fatalf("the display text keeps the original punctuation: %q", saved.Term.OriginalForm)
}
// The same phrase saved from the other chapter, written differently, stays one record.
otherTokens := phraseTokens("Mira took a small step again.\n")
otherStart, otherEnd := spanOf(t, otherTokens, "a", "small", "step")
code, msg, data = callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
"chapterId": second.Chapter.ID, "start": otherStart, "end": otherEnd, "definition": "一小步(更新)", "status": termStatusNew,
})
if code != 200 {
t.Fatalf("repeat phrase %d (%s)", code, msg)
}
var again struct {
Term TermView
Created bool
}
json.Unmarshal(data, &again)
if again.Created || again.Term.ID != saved.Term.ID || again.Term.OriginalForm != "a small step" {
t.Fatalf("one phrase must stay one record: %+v", again)
}
var count int64
// A phrase key always contains a space, so the key shape is the whole filter.
db.Model(&Term{}).Where("owner_id = ? AND term LIKE ?", learner.ID, "% %").Count(&count)
if count != 1 {
t.Fatalf("phrase records: %d", count)
}
// Both chapters highlight the phrase, including the one where it was not saved. The first
// chapter holds the phrase twice with different interior punctuation.
wantOccurrences := map[int64]int{first.Chapter.ID: 2, second.Chapter.ID: 1}
for chapterID, want := range wantOccurrences {
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", chapterID), learner.Token, nil)
var analyzed ChapterTokens
json.Unmarshal(data, &analyzed)
if code != 200 || len(analyzed.Phrases) != want {
t.Fatalf("chapter %d phrases: %d %+v", chapterID, code, analyzed.Phrases)
}
for _, span := range analyzed.Phrases {
if span.ID != saved.Term.ID || span.WordCount != 3 || span.EndToken <= span.StartToken {
t.Fatalf("chapter %d span %+v", chapterID, span)
}
if analyzed.Tokens[span.StartToken].Kind != "word" || analyzed.Tokens[span.EndToken].Kind != "word" {
t.Fatalf("a phrase must span words: %+v", span)
}
}
}
// Another account sees no phrase at all.
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), other.Token, nil)
if code != 404 {
t.Fatalf("another account reading this chapter: %d", code)
}
// The phrase joins the review queue with its kind, and answers like a word.
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" || queue.Items[0].WordCount != 3 {
t.Fatalf("phrase queue: %d %+v", code, queue.Items)
}
code, applied := answerReview(t, r, learner.Token, saved.Term.ID, answerBody("phrase-answer-0001", reviewGradeCorrect, queue.Items[0].DueAt))
if code != 201 || applied.Result != "applied" || applied.LevelAfter != 1 || applied.Item.Kind != "phrase" {
t.Fatalf("phrase answer: %d %+v", code, applied)
}
// The word-level highlight is unaffected: the same words are still ordinary words.
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", first.Chapter.ID), learner.Token, nil)
var withPhrase ChapterTokens
json.Unmarshal(data, &withPhrase)
for _, index := range []int{withPhrase.Phrases[0].StartToken, withPhrase.Phrases[0].EndToken} {
if withPhrase.Tokens[index].Term != nil {
t.Fatal("a phrase must not create word-level records")
}
}
}
// TestMySQLPhraseRulesAndIsolation covers the rejected shapes and ownership.
func TestMySQLPhraseRulesAndIsolation(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-rules-0001", "title": "Phrase rules", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
singleStart, singleEnd := wordsAt(t, tokens, "a")
base := map[string]any{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", other.Token, base); code != 404 {
t.Fatal("another account must not save a phrase in this chapter")
}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", "", base); code != 401 {
t.Fatal("saving a phrase needs a session")
}
invalid := []map[string]any{
{"chapterId": pasted.Chapter.ID, "start": singleStart, "end": singleEnd, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "end": end, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "start": start + 3, "end": end, "status": termStatusNew},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": "deleted"},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusKnown, "level": 3},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "definition": strings.Repeat("a", termDefinitionLimit+1)},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "term": "forged"},
{"chapterId": pasted.Chapter.ID, "start": start, "end": end, "status": termStatusNew, "ownerId": 9},
{"chapterId": 999999, "start": start, "end": end, "status": termStatusNew},
}
for _, body := range invalid {
if code, msg, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, body); code != 400 && code != 404 {
t.Fatalf("invalid phrase %v -> %d (%s)", body, code, msg)
}
}
var count int64
db.Model(&Term{}).Where("owner_id = ?", learner.ID).Count(&count)
if count != 0 {
t.Fatalf("a rejected phrase was stored: %d", count)
}
// A phrase in a chapter that is not ready is refused, exactly like a word.
if err := db.Model(&Chapter{}).Where("id = ?", pasted.Chapter.ID).Update("status", statusPending).Error; err != nil {
t.Fatal(err)
}
if code, _, _ := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, base); code != 409 {
t.Fatal("a pending chapter must refuse a phrase")
}
}
func wordsAt(t *testing.T, tokens []TextToken, word string) (int, int) {
t.Helper()
start, end := spanOf(t, tokens, word)
return start, end
}
// TestMySQLPhraseSurvivesTextEditsAndDeletion locks the fallback rule for stale references.
func TestMySQLPhraseSurvivesTextEditsAndDeletion(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "phrase-survive-0001", "title": "Phrase survival", "text": phraseFixtureText, "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
tokens := phraseTokens(phraseFixtureText)
start, end := spanOf(t, tokens, "a", "small", "step")
code, msg, data := callRaw(t, r, "POST", "/api/v1/phrases", learner.Token, map[string]any{
"chapterId": pasted.Chapter.ID, "start": start, "end": end, "definition": "一小步", "status": termStatusNew})
if code != 201 {
t.Fatalf("save phrase %d (%s)", code, msg)
}
var saved struct {
Term TermView
}
json.Unmarshal(data, &saved)
// Editing the text so the phrase no longer occurs: the entry and its schedule stay.
if code, msg, _ := patchResource(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), map[string]string{"text": "Nothing left of it.\n"}); code != 200 {
t.Fatalf("edit chapter %d (%s)", code, msg)
}
drainIngest(t, db)
code, _, data = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d/tokens", pasted.Chapter.ID), learner.Token, nil)
var analyzed ChapterTokens
json.Unmarshal(data, &analyzed)
if code != 200 || len(analyzed.Phrases) != 0 {
t.Fatalf("a phrase that no longer occurs must not highlight: %d %+v", code, analyzed.Phrases)
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
t.Fatal("the entry must survive a text edit")
}
code, queue := reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 {
t.Fatalf("the schedule must survive a text edit: %d %+v", code, queue.Items)
}
// Deleting the chapter keeps the entry and its schedule too.
if code, msg, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", pasted.Chapter.ID), learner.Token, nil); code != 200 {
t.Fatalf("delete chapter %d (%s)", code, msg)
}
if code, _, _ := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/terms/%d", saved.Term.ID), learner.Token, nil); code != 200 {
t.Fatal("the entry must survive a chapter deletion")
}
code, queue = reviewQueue(t, r, learner.Token)
if code != 200 || len(queue.Items) != 1 || queue.Items[0].Kind != "phrase" {
t.Fatalf("the schedule must survive a chapter deletion: %d %+v", code, queue.Items)
}
}
+419
View File
@@ -0,0 +1,419 @@
package lexgo
import (
"errors"
"strconv"
"time"
"github.com/gin-gonic/gin"
driver "github.com/go-sql-driver/mysql"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// The three answers the accepted review prototype offers.
const (
reviewGradeCorrect = "correct"
reviewGradeWrong = "wrong"
reviewGradeAgain = "again"
)
const (
maxReviewLevel = 7
// One queue page is enough for a daily round; the rest is fetched after finishing it.
reviewQueueLimit = 50
)
// reviewIntervals is the fixed schedule applied after a correct answer, indexed by
// level-1. This is a fixed table, not FSRS: the decision table and its samples live in
// the business rules page.
var reviewIntervals = [maxReviewLevel]int{1, 2, 4, 7, 15, 30, 60}
var reviewGrades = map[string]bool{
reviewGradeCorrect: true,
reviewGradeWrong: true,
reviewGradeAgain: true,
}
// TermReview is the scheduling state of one personal term, kept in its own table so a
// migration never alters the table that holds the learner's own text.
type TermReview struct {
TermID int64 `gorm:"primaryKey"`
OwnerID int
Language string
DueAt time.Time
ReviewCount int
CorrectCount int
WrongCount int
LastReviewedAt *time.Time
}
func (TermReview) TableName() string { return "lexgo_term_reviews" }
// ReviewAnswer records one attempt. The unique answer key is what makes a repeated
// request, a double click or a network resend update nothing a second time.
type ReviewAnswer struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
OwnerID int
AnswerKey string `gorm:"column:answer_key"`
TermID int64
Grade string
Result string
StatusBefore string `gorm:"column:status_before"`
StatusAfter string `gorm:"column:status_after"`
LevelBefore int `gorm:"column:level_before"`
LevelAfter int `gorm:"column:level_after"`
DueAtBefore time.Time `gorm:"column:due_at_before"`
DueAtAfter time.Time `gorm:"column:due_at_after"`
Requeued bool
CreatedAt time.Time
}
func (ReviewAnswer) TableName() string { return "lexgo_review_answers" }
type ReviewItem struct {
ID int64 `json:"id"`
Term string `json:"term"`
OriginalForm string `json:"originalForm"`
Definition string `json:"definition"`
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
Kind string `json:"kind"`
WordCount int `json:"wordCount"`
DueAt time.Time `json:"dueAt"`
ReviewCount int `json:"reviewCount"`
}
type ReviewQueue struct {
Items []ReviewItem `json:"items"`
Total int `json:"total"`
}
type ReviewAnswerInput struct {
AnswerID string `json:"answerId"`
Grade string `json:"grade"`
ExpectedDueAt time.Time `json:"expectedDueAt"`
}
type ReviewAnswerResult struct {
// Result is the outcome of this answer: applied when the term moved, stale when another
// screen already advanced it. A replayed answer repeats the outcome it was given first.
Result string `json:"result"`
// Duplicate reports that this answer key was already recorded and nothing changed now.
Duplicate bool `json:"duplicate"`
Grade string `json:"grade"`
Requeued bool `json:"requeued"`
StatusBefore string `json:"statusBefore"`
StatusAfter string `json:"statusAfter"`
LevelBefore int `json:"levelBefore"`
LevelAfter int `json:"levelAfter"`
DueAtBefore time.Time `json:"dueAtBefore"`
DueAtAfter time.Time `json:"dueAtAfter"`
Item ReviewItem `json:"item"`
}
// reviewState is the part of a term a grade moves.
type reviewState struct {
Status string
Level int
DueAt time.Time
}
func nextDue(level int, now time.Time) time.Time {
if level < 1 {
level = 1
}
if level > maxReviewLevel {
level = maxReviewLevel
}
return stamp(now.AddDate(0, 0, reviewIntervals[level-1]))
}
// applyGrade advances one term by exactly one answer. It returns the next state and
// whether the item belongs to the current round again.
func applyGrade(before reviewState, grade string, now time.Time) (reviewState, bool, error) {
switch grade {
case reviewGradeCorrect:
level := before.Level + 1
if level > maxReviewLevel {
level = maxReviewLevel
}
return reviewState{Status: termStatusLearning, Level: level, DueAt: nextDue(level, now)}, false, nil
case reviewGradeWrong:
next := reviewState{Status: before.Status, Level: before.Level}
// Only a learning word loses a level; a new word stays new until it is answered
// correctly for the first time.
if before.Status == termStatusLearning {
next.Level = before.Level - 1
if next.Level < 1 {
next.Level = 1
}
}
next.DueAt = stamp(now)
return next, true, nil
case reviewGradeAgain:
return reviewState{Status: before.Status, Level: before.Level, DueAt: stamp(now)}, true, nil
default:
return before, false, failure(400, "评分无效")
}
}
func reviewItem(term Term, review TermReview) ReviewItem {
return ReviewItem{
ID: term.ID, Term: term.Term, OriginalForm: term.OriginalForm, Definition: term.Definition,
Examples: splitExamples(term.Examples), Status: term.Status, Level: term.Level,
Kind: termKind(term.Term), WordCount: termWordCount(term.Term),
DueAt: review.DueAt, ReviewCount: review.ReviewCount,
}
}
// ReviewQueueFor returns the caller's due words for the current language. Known and
// ignored words stay out of the queue, and the queue is an absolute-instant comparison:
// an item is due as soon as due_at is not in the future.
func ReviewQueueFor(tx *gorm.DB, owner int, language string, now time.Time) (ReviewQueue, error) {
queue := ReviewQueue{Items: []ReviewItem{}}
due := func() *gorm.DB {
return tx.Table("lexgo_terms AS t").
Joins("JOIN lexgo_term_reviews AS r ON r.term_id = t.id").
Where("t.owner_id = ? AND r.owner_id = ? AND t.language = ? AND r.language = ?", owner, owner, language, language).
Where("t.status IN ?", []string{termStatusNew, termStatusLearning}).
Where("r.due_at <= ?", stamp(now))
}
var total int64
if err := due().Count(&total).Error; err != nil {
return queue, err
}
queue.Total = int(total)
var rows []struct {
ID int64
Term string
OriginalForm string
Definition string
Examples string
Status string
Level int
DueAt time.Time
ReviewCount int
}
if err := due().
Select("t.id, t.term, t.original_form, t.definition, t.examples, t.status, t.level, r.due_at, r.review_count").
Order("r.due_at, t.id").Limit(reviewQueueLimit).Scan(&rows).Error; err != nil {
return queue, err
}
for _, row := range rows {
queue.Items = append(queue.Items, ReviewItem{
ID: row.ID, Term: row.Term, OriginalForm: row.OriginalForm, Definition: row.Definition,
Examples: splitExamples(row.Examples), Status: row.Status, Level: row.Level,
Kind: termKind(row.Term), WordCount: termWordCount(row.Term),
DueAt: row.DueAt, ReviewCount: row.ReviewCount,
})
}
return queue, nil
}
// syncTermReview keeps the scheduling row in step with a saved term. A new word is due
// immediately so saving it starts the loop; a word saved straight into a level waits for
// that level's interval, so a manual level is not silently re-queued today. When the caller
// does not ask for a reschedule, only a missing row is created and the existing date and
// counters stay untouched.
func syncTermReview(tx *gorm.DB, term Term, reschedule bool, now time.Time) error {
due := stamp(now)
if term.Status == termStatusLearning && term.Level >= 1 {
due = nextDue(term.Level, now)
}
var existing TermReview
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("term_id = ?", term.ID).First(&existing).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return tx.Create(&TermReview{TermID: term.ID, OwnerID: term.OwnerID, Language: term.Language, DueAt: due}).Error
case err != nil:
return err
case reschedule:
return tx.Model(&TermReview{}).Where("term_id = ?", term.ID).Update("due_at", due).Error
}
return nil
}
func lockedReview(tx *gorm.DB, owner int, term Term) (TermReview, error) {
var review TermReview
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("term_id = ? AND owner_id = ?", term.ID, owner).First(&review).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// A term written before this table existed is due at the moment it was saved.
review = TermReview{TermID: term.ID, OwnerID: owner, Language: term.Language, DueAt: stamp(term.CreatedAt)}
if err = tx.Create(&review).Error; err != nil {
return review, err
}
return review, nil
}
return review, err
}
// lockedAnswerKey reads one recorded answer under a lock. It is called again after the term
// row is locked, because a locking read sees the newest committed row while the plain read
// before it may still see this transaction's snapshot.
func lockedAnswerKey(tx *gorm.DB, owner int, key string) (ReviewAnswer, error) {
var stored ReviewAnswer
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("owner_id = ? AND answer_key = ?", owner, key).First(&stored).Error
return stored, err
}
// AnswerReview applies one grade to one owned term exactly once. A repeated answer key
// returns the first outcome, and an answer whose due time no longer matches (another tab
// already advanced the term) is recorded as stale without moving anything.
func AnswerReview(tx *gorm.DB, owner int, termID int64, input ReviewAnswerInput, now time.Time) (ReviewAnswerResult, error) {
if !reviewGrades[input.Grade] {
return ReviewAnswerResult{}, failure(400, "评分无效")
}
if input.ExpectedDueAt.IsZero() {
return ReviewAnswerResult{}, failure(400, "请提交队列中的到期时间")
}
key, err := requestKey(input.AnswerID)
if err != nil {
return ReviewAnswerResult{}, err
}
if stored, err := lockedAnswerKey(tx, owner, key); err == nil {
return replayedAnswer(tx, stored, termID)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return ReviewAnswerResult{}, err
}
// Ownership first: another account's term and a missing term answer identically.
var term Term
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND owner_id = ?", termID, owner).First(&term).Error; errors.Is(err, gorm.ErrRecordNotFound) {
return ReviewAnswerResult{}, failure(404, "词条不存在")
} else if err != nil {
return ReviewAnswerResult{}, err
}
// Simultaneous submissions of one answer id are serialized by the term lock above; the
// second one now sees the recorded answer and reports it instead of counting again.
if stored, err := lockedAnswerKey(tx, owner, key); err == nil {
return replayedAnswer(tx, stored, termID)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return ReviewAnswerResult{}, err
}
review, err := lockedReview(tx, owner, term)
if err != nil {
return ReviewAnswerResult{}, err
}
if term.Status != termStatusNew && term.Status != termStatusLearning {
return ReviewAnswerResult{}, failure(409, "该词条已不在复习队列,请刷新队列")
}
before := reviewState{Status: term.Status, Level: term.Level, DueAt: review.DueAt}
if !input.ExpectedDueAt.Equal(review.DueAt) {
return staleAnswer(tx, owner, term, review, input, now)
}
next, requeued, err := applyGrade(before, input.Grade, now)
if err != nil {
return ReviewAnswerResult{}, err
}
when := stamp(now)
if err := tx.Model(&Term{}).Where("id = ? AND owner_id = ?", term.ID, owner).
Updates(map[string]any{"status": next.Status, "level": next.Level, "updated_at": when}).Error; err != nil {
return ReviewAnswerResult{}, err
}
counters := map[string]any{
"due_at": next.DueAt, "review_count": gorm.Expr("review_count + 1"),
"last_reviewed_at": when,
}
// "again" is not a correct answer either: the word was not recognised this time.
if input.Grade == reviewGradeCorrect {
counters["correct_count"] = gorm.Expr("correct_count + 1")
} else {
counters["wrong_count"] = gorm.Expr("wrong_count + 1")
}
if err := tx.Model(&TermReview{}).Where("term_id = ? AND owner_id = ?", term.ID, owner).Updates(counters).Error; err != nil {
return ReviewAnswerResult{}, err
}
term.Status, term.Level, term.UpdatedAt = next.Status, next.Level, when
review.DueAt, review.ReviewCount, review.LastReviewedAt = next.DueAt, review.ReviewCount+1, &when
record := ReviewAnswer{
OwnerID: owner, AnswerKey: key, TermID: term.ID, Grade: input.Grade, Result: "applied",
StatusBefore: before.Status, StatusAfter: next.Status, LevelBefore: before.Level, LevelAfter: next.Level,
DueAtBefore: before.DueAt, DueAtAfter: next.DueAt, Requeued: requeued, CreatedAt: when,
}
if err := tx.Create(&record).Error; err != nil {
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: "applied", Grade: input.Grade, Requeued: requeued,
StatusBefore: before.Status, StatusAfter: next.Status, LevelBefore: before.Level, LevelAfter: next.Level,
DueAtBefore: before.DueAt, DueAtAfter: next.DueAt, Item: reviewItem(term, review),
}, nil
}
// staleAnswer records that this attempt changed nothing because the term had already
// moved on. It is a normal outcome of two open tabs, not an error.
func staleAnswer(tx *gorm.DB, owner int, term Term, review TermReview, input ReviewAnswerInput, now time.Time) (ReviewAnswerResult, error) {
key, err := requestKey(input.AnswerID)
if err != nil {
return ReviewAnswerResult{}, err
}
record := ReviewAnswer{
OwnerID: owner, AnswerKey: key, TermID: term.ID, Grade: input.Grade, Result: "stale",
StatusBefore: term.Status, StatusAfter: term.Status, LevelBefore: term.Level, LevelAfter: term.Level,
DueAtBefore: review.DueAt, DueAtAfter: review.DueAt, CreatedAt: stamp(now),
}
if err := tx.Create(&record).Error; err != nil {
// Losing a race to another transaction that recorded this very answer id is not an
// error: report the outcome that was stored first.
var duplicate *driver.MySQLError
if errors.As(err, &duplicate) && duplicate.Number == 1062 {
if stored, readErr := lockedAnswerKey(tx, owner, key); readErr == nil {
return replayedAnswer(tx, stored, term.ID)
}
}
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: "stale", Grade: input.Grade, Requeued: false,
StatusBefore: term.Status, StatusAfter: term.Status, LevelBefore: term.Level, LevelAfter: term.Level,
DueAtBefore: review.DueAt, DueAtAfter: review.DueAt, Item: reviewItem(term, review),
}, nil
}
// replayedAnswer reports the outcome already recorded for this answer key. Nothing is
// advanced a second time, so a resend cannot change counts or intervals.
func replayedAnswer(tx *gorm.DB, stored ReviewAnswer, termID int64) (ReviewAnswerResult, error) {
if stored.TermID != termID {
return ReviewAnswerResult{}, failure(400, "请求编号已用于其他词条")
}
var term Term
if err := tx.Where("id = ? AND owner_id = ?", stored.TermID, stored.OwnerID).First(&term).Error; err != nil {
return ReviewAnswerResult{}, err
}
var review TermReview
if err := tx.Where("term_id = ? AND owner_id = ?", stored.TermID, stored.OwnerID).First(&review).Error; err != nil {
return ReviewAnswerResult{}, err
}
return ReviewAnswerResult{
Result: stored.Result, Duplicate: true, Grade: stored.Grade, Requeued: stored.Requeued,
StatusBefore: stored.StatusBefore, StatusAfter: stored.StatusAfter,
LevelBefore: stored.LevelBefore, LevelAfter: stored.LevelAfter,
DueAtBefore: stored.DueAtBefore, DueAtAfter: stored.DueAtAfter, Item: reviewItem(term, review),
}, nil
}
func registerReviewRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
v.GET("/reviews/queue", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if c.Request.URL.RawQuery != "" {
return nil, failure(400, "复习队列不接受查询参数")
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
return ReviewQueueFor(tx, u.UserId, language, now())
}))
v.POST("/reviews/:termId/answers", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
termID, err := strconv.ParseInt(c.Param("termId"), 10, 64)
if err != nil || termID <= 0 {
return nil, failure(404, "词条不存在")
}
var input ReviewAnswerInput
if err := decode(c, &input); err != nil {
return nil, err
}
return AnswerReview(tx, u.UserId, termID, input, now())
}))
}
+501
View File
@@ -0,0 +1,501 @@
package lexgo
import (
"encoding/json"
"fmt"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// TestReviewDecisionTable pins the fixed schedule and every state transition. It is the
// design evidence the ticket asks for, so a change to the table must fail here first.
func TestReviewDecisionTable(t *testing.T) {
levels := []int{1, 2, 3, 4, 5, 6, 7}
days := []int{1, 2, 4, 7, 15, 30, 60}
if len(reviewIntervals) != len(levels) {
t.Fatalf("interval table has %d entries, want %d", len(reviewIntervals), len(levels))
}
now := time.Date(2026, 9, 11, 10, 30, 0, 0, time.UTC)
for index, level := range levels {
if reviewIntervals[level-1] != days[index] {
t.Fatalf("level %d interval %d, want %d", level, reviewIntervals[level-1], days[index])
}
next, requeued, err := applyGrade(reviewState{Status: termStatusLearning, Level: level, DueAt: now}, reviewGradeCorrect, now)
want := level + 1
if want > maxReviewLevel {
want = maxReviewLevel
}
if err != nil || requeued || next.Level != want || !next.DueAt.Equal(now.AddDate(0, 0, reviewIntervals[want-1])) || next.Status != termStatusLearning {
t.Fatalf("level %d correct: %#v requeued=%v err=%v", level, next, requeued, err)
}
}
cases := []struct {
name string
before reviewState
grade string
status string
level int
dueAt time.Time
requeued bool
}{
{"new correct becomes learning level 1", reviewState{Status: termStatusNew}, reviewGradeCorrect, termStatusLearning, 1, now.AddDate(0, 0, 1), false},
{"new wrong stays new and requeues", reviewState{Status: termStatusNew}, reviewGradeWrong, termStatusNew, 0, now, true},
{"new again stays new and requeues", reviewState{Status: termStatusNew}, reviewGradeAgain, termStatusNew, 0, now, true},
{"learning 4 wrong drops to 3", reviewState{Status: termStatusLearning, Level: 4}, reviewGradeWrong, termStatusLearning, 3, now, true},
{"learning 1 wrong floors at 1", reviewState{Status: termStatusLearning, Level: 1}, reviewGradeWrong, termStatusLearning, 1, now, true},
{"learning 7 wrong drops to 6", reviewState{Status: termStatusLearning, Level: 7}, reviewGradeWrong, termStatusLearning, 6, now, true},
{"again keeps the level", reviewState{Status: termStatusLearning, Level: 3}, reviewGradeAgain, termStatusLearning, 3, now, true},
{"level 7 correct stays at 7", reviewState{Status: termStatusLearning, Level: 7}, reviewGradeCorrect, termStatusLearning, 7, now.AddDate(0, 0, 60), false},
}
for _, tc := range cases {
before := tc.before
before.DueAt = now
next, requeued, err := applyGrade(before, tc.grade, now)
if err != nil || requeued != tc.requeued || next.Status != tc.status || next.Level != tc.level || !next.DueAt.Equal(tc.dueAt) {
t.Fatalf("%s: %#v requeued=%v err=%v", tc.name, next, requeued, err)
}
}
if _, _, err := applyGrade(reviewState{Status: termStatusNew, DueAt: now}, "maybe", now); err == nil {
t.Fatal("an unknown grade must be rejected")
}
}
// reviewFixture builds one learner, one book and one ready chapter under a clock the
// test can move, so due boundaries are exact.
func reviewFixture(t *testing.T, db *gorm.DB, text string) (*gin.Engine, *time.Time, admin.SysUser, string, Chapter) {
t.Helper()
clock := time.Now().UTC().Truncate(time.Millisecond)
r := Router(db, func() time.Time { return clock })
user, token, chapters := termFixture(t, db, r, text)
return r, &clock, user, token, chapters[0]
}
func reviewQueue(t *testing.T, r *gin.Engine, token string) (int, ReviewQueue) {
t.Helper()
code, data := callAPI(t, r, "GET", "/api/v1/reviews/queue", token, nil)
var queue ReviewQueue
if data != nil {
json.Unmarshal(data, &queue)
}
return code, queue
}
func answerReview(t *testing.T, r *gin.Engine, token string, termID int64, body map[string]any) (int, ReviewAnswerResult) {
t.Helper()
code, data := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/reviews/%d/answers", termID), token, body)
var result ReviewAnswerResult
if data != nil {
json.Unmarshal(data, &result)
}
return code, result
}
func answerBody(answerID, grade string, dueAt time.Time) map[string]any {
return map[string]any{"answerId": answerID, "grade": grade, "expectedDueAt": dueAt.UTC().Format(time.RFC3339Nano)}
}
// saveWord saves one word span of an owned chapter and returns the stored record.
func saveWord(t *testing.T, r *gin.Engine, token string, chapterID int64, start, end int, status string, level *int) TermSave {
t.Helper()
body := map[string]any{"chapterId": chapterID, "start": start, "end": end, "definition": "虚构释义", "status": status}
if level != nil {
body["level"] = *level
}
code, saved := saveTermAPI(t, r, token, body)
if code != 201 && code != 200 {
t.Fatalf("save word %d-%d: %d %#v", start, end, code, saved)
}
return saved
}
func termReviewRow(t *testing.T, db *gorm.DB, termID int64) TermReview {
t.Helper()
var review TermReview
if err := db.Where("term_id = ?", termID).First(&review).Error; err != nil {
t.Fatal(err)
}
return review
}
// TestMySQLReviewQueueDueBoundaryAndScope covers the due boundary, the queue scope and
// per-account isolation.
func TestMySQLReviewQueueDueBoundaryAndScope(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
_, otherToken, otherChapters := termFixture(t, db, r, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
// A saved word is due immediately: its due time is exactly the save time.
code, queue := reviewQueue(t, r, token)
if code != 200 || queue.Total != 1 || len(queue.Items) != 1 || queue.Items[0].ID != dogs.Term.ID {
t.Fatalf("a new word must be due at once: %d %#v", code, queue)
}
item := queue.Items[0]
if !item.DueAt.Equal(*clock) || item.Status != termStatusNew || item.Level != 0 || item.ReviewCount != 0 || item.Examples == nil {
t.Fatalf("queue item: %#v", item)
}
if !termReviewRow(t, db, dogs.Term.ID).DueAt.Equal(*clock) {
t.Fatal("the schedule row must hold the save time")
}
// due_at == now is due; one millisecond earlier is not.
*clock = item.DueAt.Add(-time.Millisecond)
if _, early := reviewQueue(t, r, token); early.Total != 0 {
t.Fatalf("an item due later must stay out of the queue: %#v", early)
}
*clock = item.DueAt.Add(time.Millisecond)
if _, late := reviewQueue(t, r, token); late.Total != 1 {
t.Fatal("an overdue item must be due")
}
// Another account saves the same word in its own chapter without touching this queue.
otherDogs := saveWord(t, r, otherToken, otherChapters[0].ID, 0, 4, termStatusNew, nil)
if otherDogs.Term.ID == dogs.Term.ID {
t.Fatal("two accounts must own separate records for the same word")
}
code, mine := reviewQueue(t, r, token)
if code != 200 || len(mine.Items) != 1 || mine.Items[0].ID != dogs.Term.ID {
t.Fatalf("queues must not mix accounts: %d %#v", code, mine)
}
code, theirs := reviewQueue(t, r, otherToken)
if code != 200 || len(theirs.Items) != 1 || theirs.Items[0].ID != otherDogs.Term.ID {
t.Fatalf("the other account must see its own queue: %d %#v", code, theirs)
}
// Known and ignored words never enter the queue; returning to new makes it due again.
for _, status := range []string{termStatusKnown, termStatusIgnored} {
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": status}); code != 200 {
t.Fatalf("marking %s failed", status)
}
if _, scoped := reviewQueue(t, r, token); scoped.Total != 0 {
t.Fatalf("%s must stay out of the queue: %#v", status, scoped)
}
}
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 200 {
t.Fatal("returning to a new word failed")
}
if _, again := reviewQueue(t, r, token); again.Total != 1 {
t.Fatalf("a new word must be due again: %#v", again)
}
if code, _ := callAPI(t, r, "GET", "/api/v1/reviews/queue?status=new", token, nil); code != 400 {
t.Fatal("the queue must reject query parameters")
}
if code, _ := callAPI(t, r, "GET", "/api/v1/reviews/queue", "", nil); code != 401 {
t.Fatal("the queue requires a session")
}
}
// TestMySQLReviewAnswerTransitions answers one word three times and checks the term, the
// counters and the recorded attempt after each grade.
func TestMySQLReviewAnswerTransitions(t *testing.T) {
db := testDB(t)
r, clock, user, token, chapter := reviewFixture(t, db, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
// Correct: a new word becomes learning level 1 and leaves the round.
_, queue := reviewQueue(t, r, token)
code, first := answerReview(t, r, token, termID, answerBody("answer-correct-0001", reviewGradeCorrect, queue.Items[0].DueAt))
if code != 201 || first.Result != "applied" || first.Requeued {
t.Fatalf("correct answer: %d %#v", code, first)
}
if first.StatusAfter != termStatusLearning || first.LevelAfter != 1 || first.Item.Level != 1 || first.Item.ReviewCount != 1 {
t.Fatalf("correct transition: %#v", first)
}
if !first.DueAtAfter.Equal(clock.AddDate(0, 0, 1)) || !first.DueAtBefore.Equal(*clock) {
t.Fatalf("correct schedule: %#v", first)
}
if _, empty := reviewQueue(t, r, token); empty.Total != 0 {
t.Fatal("a correctly answered word must leave the queue until it is due again")
}
// One interval later the word is due again; a wrong answer requeues it immediately.
// Sessions last eight hours of the injected clock, so the jump forward needs a login.
*clock = first.DueAtAfter
token = loginToken(t, r, user.Username, fixturePassword)
_, queue = reviewQueue(t, r, token)
if queue.Total != 1 || queue.Items[0].Level != 1 || queue.Items[0].ReviewCount != 1 {
t.Fatalf("second round queue: %#v", queue)
}
code, wrong := answerReview(t, r, token, termID, answerBody("answer-wrong-000002", reviewGradeWrong, queue.Items[0].DueAt))
if code != 201 || !wrong.Requeued || wrong.LevelAfter != 1 || wrong.StatusAfter != termStatusLearning || !wrong.DueAtAfter.Equal(*clock) {
t.Fatalf("wrong answer: %d %#v", code, wrong)
}
_, queue = reviewQueue(t, r, token)
if queue.Total != 1 || queue.Items[0].Level != 1 {
t.Fatalf("requeued item: %#v", queue)
}
code, again := answerReview(t, r, token, termID, answerBody("answer-again-000003", reviewGradeAgain, queue.Items[0].DueAt))
if code != 201 || !again.Requeued || again.LevelBefore != 1 || again.LevelAfter != 1 {
t.Fatalf("again answer: %d %#v", code, again)
}
// Counters and the attempt log accumulated exactly three answers.
review := termReviewRow(t, db, termID)
if review.ReviewCount != 3 || review.CorrectCount != 1 || review.WrongCount != 2 || review.LastReviewedAt == nil {
t.Fatalf("counters: %#v", review)
}
var term Term
if err := db.First(&term, termID).Error; err != nil || term.Status != termStatusLearning || term.Level != 1 || term.Definition != "虚构释义" {
t.Fatalf("the learner's own text must not change: %#v %v", term, err)
}
var answers int64
if err := db.Model(&ReviewAnswer{}).Where("owner_id = ?", user.UserId).Count(&answers).Error; err != nil || answers != 3 {
t.Fatalf("attempt log: %d %v", answers, err)
}
var logged ReviewAnswer
if err := db.Where("term_id = ? AND grade = ?", termID, reviewGradeCorrect).First(&logged).Error; err != nil {
t.Fatal(err)
}
if logged.Result != "applied" || logged.StatusBefore != termStatusNew || logged.LevelAfter != 1 || logged.Requeued {
t.Fatalf("logged attempt: %#v", logged)
}
}
// TestMySQLReviewAnswerIdempotencyAndStaleTabs is the core of the acceptance: a resend
// must not count twice, and a second tab must not advance a term that already moved.
func TestMySQLReviewAnswerIdempotencyAndStaleTabs(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
_, queue := reviewQueue(t, r, token)
seen := queue.Items[0].DueAt
// Two tabs answer the same word with different answer ids but the same seen due time.
code, applied := answerReview(t, r, token, termID, answerBody("tab-one-answer-0001", reviewGradeCorrect, seen))
if code != 201 || applied.Result != "applied" {
t.Fatalf("first tab: %d %#v", code, applied)
}
code, stale := answerReview(t, r, token, termID, answerBody("tab-two-answer-0002", reviewGradeCorrect, seen))
if code != 200 || stale.Result != "stale" || stale.Duplicate || stale.Requeued {
t.Fatalf("second tab must not advance the term: %d %#v", code, stale)
}
if stale.LevelAfter != applied.LevelAfter || !stale.DueAtAfter.Equal(applied.DueAtAfter) {
t.Fatalf("a stale answer changed state: %#v", stale)
}
// A resend of the first answer returns the first outcome, flagged as a replay, without
// another update. The client can therefore count it exactly like the original answer.
code, duplicate := answerReview(t, r, token, termID, answerBody("tab-one-answer-0001", reviewGradeCorrect, seen))
if code != 200 || duplicate.Result != "applied" || !duplicate.Duplicate {
t.Fatalf("resend: %d %#v", code, duplicate)
}
if duplicate.LevelAfter != applied.LevelAfter || !duplicate.DueAtAfter.Equal(applied.DueAtAfter) || duplicate.Item.ReviewCount != 1 {
t.Fatalf("a resend changed state: %#v", duplicate)
}
// Counters moved exactly once, and the replay is not a new attempt row: the applied
// answer and the stale one are two rows, the duplicate returns the first row.
review := termReviewRow(t, db, termID)
if review.ReviewCount != 1 || review.CorrectCount != 1 || review.WrongCount != 0 {
t.Fatalf("counters after three attempts: %#v", review)
}
var attempts int64
if err := db.Model(&ReviewAnswer{}).Where("term_id = ?", termID).Count(&attempts).Error; err != nil || attempts != 2 {
t.Fatalf("each distinct attempt must be recorded once: %d %v", attempts, err)
}
// Reusing an answer id for another word is a client error, not a new answer.
went := saveWord(t, r, token, chapter.ID, 5, 9, termStatusNew, nil)
code, _ = answerReview(t, r, token, went.Term.ID, answerBody("tab-one-answer-0001", reviewGradeCorrect, *clock))
if code != 400 {
t.Fatalf("a reused answer id must be rejected: %d", code)
}
// A genuinely new answer for the other word still works.
code, fresh := answerReview(t, r, token, went.Term.ID, answerBody("went-answer-000004", reviewGradeCorrect, *clock))
if code != 201 || fresh.LevelAfter != 1 {
t.Fatalf("a later answer must work: %d %#v", code, fresh)
}
}
// TestMySQLReviewAnswerErrorsAndOwnership covers the rejected shapes of the answer
// endpoint.
func TestMySQLReviewAnswerErrorsAndOwnership(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
_, otherToken, _ := termFixture(t, db, r, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
termID := dogs.Term.ID
seen := *clock
if code, _ := answerReview(t, r, otherToken, termID, answerBody("foreign-answer-0001", reviewGradeCorrect, seen)); code != 404 {
t.Fatal("another account's term must be 404")
}
if code, _ := answerReview(t, r, token, 999999, answerBody("missing-answer-0002", reviewGradeCorrect, seen)); code != 404 {
t.Fatal("an unknown term must be 404")
}
for _, id := range []string{"0", "-3", "abc"} {
if code, _ := callAPI(t, r, "POST", "/api/v1/reviews/"+id+"/answers", token, answerBody("bad-id-000003", reviewGradeCorrect, seen)); code != 404 {
t.Fatalf("invalid term id %s: %d", id, code)
}
}
for _, body := range []map[string]any{
{"answerId": "grade-missing-0001", "expectedDueAt": seen.Format(time.RFC3339Nano)},
{"answerId": "grade-unknown-0002", "grade": "maybe", "expectedDueAt": seen.Format(time.RFC3339Nano)},
answerBody("short", reviewGradeCorrect, seen),
{"answerId": "due-missing-0003", "grade": reviewGradeCorrect},
{"answerId": "extra-field-0004", "grade": reviewGradeCorrect, "expectedDueAt": seen.Format(time.RFC3339Nano), "level": 7},
} {
if code, _ := answerReview(t, r, token, termID, body); code != 400 {
t.Fatalf("invalid body %v must be 400: %d", body, code)
}
}
if code, _ := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/reviews/%d/answers", termID), "", answerBody("anonymous-000001", reviewGradeCorrect, seen)); code != 401 {
t.Fatal("answering requires a session")
}
// A word marked as known in another screen is no longer reviewable.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusKnown}); code != 200 {
t.Fatal("marking the word known failed")
}
code, result := answerReview(t, r, token, termID, answerBody("known-answer-0005", reviewGradeCorrect, seen))
if code != 409 || result.Result != "" {
t.Fatalf("a known word must refuse an answer: %d %#v", code, result)
}
if review := termReviewRow(t, db, termID); review.ReviewCount != 0 {
t.Fatalf("a refused answer must not count: %#v", review)
}
}
// TestMySQLReviewScheduleOnManualStatus checks the documented rule for words saved with
// an explicit status: new is due at once, learning waits for its level's interval.
func TestMySQLReviewScheduleOnManualStatus(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
level := 3
learning := saveWord(t, r, token, chapter.ID, 0, 4, termStatusLearning, &level)
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(clock.AddDate(0, 0, 4)) {
t.Fatalf("a manual level must wait for its interval: %v", review.DueAt)
}
if _, queue := reviewQueue(t, r, token); queue.Total != 0 {
t.Fatalf("a manually leveled word is not due today: %#v", queue)
}
// Changing it back to a new word makes it due immediately again.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 200 {
t.Fatal("resetting the status failed")
}
if _, queue := reviewQueue(t, r, token); queue.Total != 1 || queue.Items[0].Level != 0 {
t.Fatalf("a new word is due at once: %#v", queue)
}
}
// TestMySQLReviewConcurrentReplayOfOneAnswer reproduces the review finding that two
// simultaneous submissions of one answer id must both get the recorded outcome.
func TestMySQLReviewConcurrentReplayOfOneAnswer(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
dogs := saveWord(t, r, token, chapter.ID, 0, 4, termStatusNew, nil)
_, queue := reviewQueue(t, r, token)
seen := queue.Items[0].DueAt
body := answerBody("concurrent-answer-01", reviewGradeCorrect, seen)
var wg sync.WaitGroup
codes := make(chan int, 2)
results := make(chan string, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
code, data := callAPI(t, r, "POST", fmt.Sprintf("/api/v1/reviews/%d/answers", dogs.Term.ID), token, body)
var result ReviewAnswerResult
if data != nil {
json.Unmarshal(data, &result)
}
codes <- code
results <- result.Result
}()
}
wg.Wait()
close(codes)
close(results)
ok, bad := 0, 0
for code := range codes {
if code == 200 || code == 201 {
ok++
} else {
bad++
t.Logf("status %d", code)
}
}
kinds := []string{}
for kind := range results {
kinds = append(kinds, kind)
}
if ok != 2 || bad != 0 {
t.Fatalf("both submissions must succeed: ok=%d bad=%d kinds=%v clock=%v", ok, bad, kinds, *clock)
}
review := termReviewRow(t, db, dogs.Term.ID)
if review.ReviewCount != 1 {
t.Fatalf("concurrent replay counted %d times", review.ReviewCount)
}
var attempts int64
if err := db.Model(&ReviewAnswer{}).Where("term_id = ?", dogs.Term.ID).Count(&attempts).Error; err != nil || attempts != 1 {
t.Fatalf("concurrent replay logged %d attempts", attempts)
}
}
// TestMySQLReviewEditKeepsSchedule reproduces the review finding that editing only the
// learner's own text must not move a review date (decision D2).
func TestMySQLReviewEditKeepsSchedule(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
level := 3
learning := saveWord(t, r, token, chapter.ID, 0, 4, termStatusLearning, &level)
// Make the word overdue, as it would be after a missed day.
overdue := clock.AddDate(0, 0, -5)
if err := db.Model(&TermReview{}).Where("term_id = ?", learning.Term.ID).Update("due_at", overdue).Error; err != nil {
t.Fatal(err)
}
// Editing only the personal text must keep the schedule exactly as it was.
code, saved := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "改过的释义", "status": termStatusLearning, "level": 3})
if code != 200 || saved.Term.Level != 3 {
t.Fatalf("text edit: %d %#v", code, saved)
}
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(overdue) {
t.Fatalf("editing the text moved the due time: %v want %v", review.DueAt, overdue)
}
if _, queue := reviewQueue(t, r, token); queue.Total != 1 {
t.Fatalf("an overdue word must stay due after a text edit: %#v", queue)
}
// Changing the level is a scheduling action and does move the due time.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusLearning, "level": 5}); code != 200 {
t.Fatal("level change failed")
}
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(clock.AddDate(0, 0, 15)) {
t.Fatalf("a level change must reschedule: %v", review.DueAt)
}
}
// TestMySQLReviewPanelSaveKeepsLevel reproduces the related defect found while checking the
// review: the reader panel saves a status without a level, and that must not reset a level
// the learner already earned.
func TestMySQLReviewPanelSaveKeepsLevel(t *testing.T) {
db := testDB(t)
r, clock, _, token, chapter := reviewFixture(t, db, "Dogs went home.")
level := 4
learning := saveWord(t, r, token, chapter.ID, 0, 4, termStatusLearning, &level)
overdue := clock.AddDate(0, 0, -2)
if err := db.Model(&TermReview{}).Where("term_id = ?", learning.Term.ID).Update("due_at", overdue).Error; err != nil {
t.Fatal(err)
}
// This is exactly what the reader panel sends: status only, no level.
code, saved := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "definition": "改过的释义", "status": termStatusLearning})
if code != 200 || saved.Term.Level != 4 {
t.Fatalf("a panel save must keep the learned level: %d %#v", code, saved)
}
if review := termReviewRow(t, db, learning.Term.ID); !review.DueAt.Equal(overdue) {
t.Fatalf("a panel save moved the due time: %v want %v", review.DueAt, overdue)
}
// Leaving learning and re-entering it is a real transition and starts at level 1.
if code, _ := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusNew}); code != 200 {
t.Fatal("reset to new failed")
}
code, again := saveTermAPI(t, r, token, map[string]any{"chapterId": chapter.ID, "start": 0, "end": 4, "status": termStatusLearning})
if code != 200 || again.Term.Level != 1 {
t.Fatalf("re-entering learning starts at 1: %d %#v", code, again)
}
}
+24 -10
View File
@@ -130,17 +130,25 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
status = 201
}
// A repeated paste is answered from the first result, so it is not a new resource.
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/books" || c.FullPath() == "/api/v1/books/:id/chapters") {
// A pasted or uploaded chapter is one resource; a repeated submit is not.
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/books" || c.FullPath() == "/api/v1/books/:id/chapters" ||
c.FullPath() == "/api/v1/books/upload" || c.FullPath() == "/api/v1/books/:id/chapters/upload") {
if paste, ok := data.(PasteResult); ok && !paste.Duplicate {
status = 201
}
}
// Saving the same word again is an update of one record, not a new resource.
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/terms" {
// Saving the same entry again is an update of one record, not a new resource.
if c.Request.Method == "POST" && (c.FullPath() == "/api/v1/terms" || c.FullPath() == "/api/v1/phrases") {
if saved, ok := data.(TermSave); ok && saved.Created {
status = 201
}
}
// A replayed or stale answer changed nothing, so it is not a new answer.
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/reviews/:termId/answers" {
if answer, ok := data.(ReviewAnswerResult); ok && answer.Result == "applied" && !answer.Duplicate {
status = 201
}
}
respond(c, status, data, err)
}
}
@@ -225,13 +233,6 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
}
return updateAccount(tx, id, u.UserId, input)
}))
pathID := func(c *gin.Context, message string) (int64, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
return 0, failure(404, message)
}
return id, nil
}
v.POST("/books", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input PasteBookInput
if err := decodeLimit(c, &input, maxPasteBodyBytes); err != nil {
@@ -302,10 +303,23 @@ func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
}))
registerDictionaryRoutes(v, protect, now)
registerTermRoutes(v, protect, now)
registerReviewRoutes(v, protect, now)
registerUploadRoutes(v, protect, now)
registerEditRoutes(v, protect, now)
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
return r
}
// pathID reads a positive numeric path parameter; a malformed id is reported like a
// missing resource so it cannot be used to probe for other accounts' rows.
func pathID(c *gin.Context, message string) (int64, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
return 0, failure(404, message)
}
return id, nil
}
func decode(c *gin.Context, value any) error { return decodeLimit(c, value, maxJSONBodyBytes) }
func decodeLimit(c *gin.Context, value any, limit int64) error {
+105 -27
View File
@@ -26,6 +26,13 @@ const (
termStatusIgnored = "ignored"
)
// One table holds both entries: a word and a phrase differ in kind and word count, and a
// phrase key always contains a space, so the identity keys cannot collide.
const (
termKindWord = "word"
termKindPhrase = "phrase"
)
const (
termFormLimit = 128
termDefinitionLimit = 2000
@@ -60,6 +67,23 @@ type Term struct {
UpdatedAt time.Time
}
// Kind and word count need no columns: a phrase key is the ordered normalized word forms
// joined by single spaces, and a word form can never contain a space, so the identity key
// itself carries both facts. One helper keeps that invariant in a single place.
func termKind(key string) string {
if strings.Contains(key, " ") {
return termKindPhrase
}
return termKindWord
}
func termWordCount(key string) int {
if !strings.Contains(key, " ") {
return 1
}
return len(strings.Split(key, " "))
}
func (Term) TableName() string { return "lexgo_terms" }
type TermView struct {
@@ -71,6 +95,8 @@ type TermView struct {
Examples []string `json:"examples"`
Status string `json:"status"`
Level int `json:"level"`
Kind string `json:"kind"`
WordCount int `json:"wordCount"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
@@ -88,29 +114,42 @@ type TermSave struct {
Created bool `json:"created"`
}
func termView(t Term) TermView {
examples := []string{}
if t.Examples != "" {
examples = strings.Split(t.Examples, "\n")
// splitExamples returns the stored examples as a list; the column holds one per line.
func splitExamples(text string) []string {
if text == "" {
return []string{}
}
return TermView{t.ID, t.Language, t.Term, t.OriginalForm, t.Definition, examples, t.Status, t.Level, t.CreatedAt, t.UpdatedAt}
return strings.Split(text, "\n")
}
// termLevel enforces the documented status/level boundary: only a learning entry
// carries a level, entering learning defaults to 1, and every other status must
// leave the level at 0.
func termLevel(status string, level *int) (int, error) {
func termView(t Term) TermView {
return TermView{
ID: t.ID, Language: t.Language, Term: t.Term, OriginalForm: t.OriginalForm, Definition: t.Definition,
Examples: splitExamples(t.Examples), Status: t.Status, Level: t.Level,
Kind: termKind(t.Term), WordCount: termWordCount(t.Term),
CreatedAt: t.CreatedAt, UpdatedAt: t.UpdatedAt,
}
}
// termLevel enforces the documented status/level boundary: only a learning entry carries a
// level, every other status must leave the level at 0, and entering learning starts at 1.
// A save that does not mention a level keeps the level the learner already earned, so
// editing a definition can never roll a word back to level 1.
func termLevel(status string, level *int, previous Term, exists bool) (int, error) {
if !termStatuses[status] {
return 0, failure(400, "词语状态无效")
}
if status == termStatusLearning {
if level == nil || *level == 0 {
return 1, nil
if level != nil && *level != 0 {
if *level < 1 || *level > termLevelMax {
return 0, failure(400, "学习等级须为 1~7")
}
return *level, nil
}
if *level < 1 || *level > termLevelMax {
return 0, failure(400, "学习等级须为 1~7")
if exists && previous.Status == termStatusLearning && previous.Level >= 1 {
return previous.Level, nil
}
return *level, nil
return 1, nil
}
if level != nil && *level != 0 {
return 0, failure(400, "只有学习中的词语可以设置等级")
@@ -189,13 +228,31 @@ func languageOf(tx *gorm.DB, owner int) (string, error) {
return space.Language, nil
}
// previousTerm reads the row this save is about to change under a lock, so the level and
// the review schedule can be compared with what the learner already had.
func previousTerm(tx *gorm.DB, owner int, language, term string) (Term, bool, error) {
var existing Term
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("owner_id = ? AND language = ? AND term = ?", owner, language, term).First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Term{}, false, nil
}
if err != nil {
return Term{}, false, err
}
return existing, true, nil
}
// saveTerm writes one identity with INSERT ... ON DUPLICATE KEY UPDATE: a repeated
// save updates the same row instead of adding a second, conflicting record, and
// two concurrent saves of the same word still leave exactly one.
func saveTerm(tx *gorm.DB, owner int, language, word string, fields TermFields, now time.Time) (TermSave, error) {
// saveTerm stores one identity: key is the canonical form used for lookup, display is what the
// learner sees. For a word both are the word form; for a phrase the key joins normalized word
// forms and the display keeps the original punctuation.
func saveTerm(tx *gorm.DB, owner int, language, key, display string, fields TermFields, now time.Time) (TermSave, error) {
when := stamp(now)
row := Term{
OwnerID: owner, Language: language, Term: normalizeWord(word), OriginalForm: word,
OwnerID: owner, Language: language, Term: key, OriginalForm: display,
Definition: fields.Definition, Examples: fields.Examples, Status: fields.Status, Level: fields.Level,
CreatedAt: when, UpdatedAt: when,
}
@@ -215,6 +272,13 @@ func saveTerm(tx *gorm.DB, owner int, language, word string, fields TermFields,
if err := tx.Where("owner_id = ? AND language = ? AND term = ?", owner, language, row.Term).First(&stored).Error; err != nil {
return TermSave{}, err
}
// A saved word always owns a schedule row, but the date only moves for a new word or a
// real status/level change: editing a definition must not push a word out of today's
// queue (decision D2).
reschedule := !fields.Exists || fields.PreviousStatus != stored.Status || fields.PreviousLevel != stored.Level
if err := syncTermReview(tx, stored, reschedule, now); err != nil {
return TermSave{}, err
}
return TermSave{termView(stored), insert.RowsAffected == 1}, nil
}
@@ -259,12 +323,16 @@ func attachTerms(tx *gorm.DB, owner int, language string, tokens []TextToken) er
return nil
}
// TermFields carries the validated learner text and state into storage.
// TermFields carries the validated learner text and state into storage, together with the
// status and level the row had before this save.
type TermFields struct {
Definition string
Examples string
Status string
Level int
Definition string
Examples string
Status string
Level int
PreviousStatus string
PreviousLevel int
Exists bool
}
type TermInput struct {
@@ -297,7 +365,17 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
if err != nil {
return nil, err
}
level, err := termLevel(input.Status, input.Level)
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
}
// The previous row decides whether a missing level keeps the earned one and whether
// the review date may move at all.
previous, exists, err := previousTerm(tx, u.UserId, language, normalizeWord(word))
if err != nil {
return nil, err
}
level, err := termLevel(input.Status, input.Level, previous, exists)
if err != nil {
return nil, err
}
@@ -305,12 +383,11 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
if err != nil {
return nil, err
}
language, err := languageOf(tx, u.UserId)
if err != nil {
return nil, err
fields := TermFields{
Definition: definition, Examples: examples, Status: input.Status, Level: level,
PreviousStatus: previous.Status, PreviousLevel: previous.Level, Exists: exists,
}
fields := TermFields{Definition: definition, Examples: examples, Status: input.Status, Level: level}
return saveTerm(tx, u.UserId, language, word, fields, now())
return saveTerm(tx, u.UserId, language, normalizeWord(word), word, fields, now())
}))
v.GET("/terms/:id", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
@@ -327,4 +404,5 @@ func registerTermRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context
}
return gin.H{"term": termView(term)}, nil
}))
registerPhraseRoutes(v, protect, now)
}
+34 -21
View File
@@ -14,31 +14,44 @@ import (
func TestTermLevelBoundary(t *testing.T) {
level := func(v int) *int { return &v }
// The stored state the save is about to change: a level-4 learning word.
stored := Term{Status: termStatusLearning, Level: 4}
cases := []struct {
status string
level *int
want int
ok bool
status string
level *int
previous Term
exists bool
want int
ok bool
}{
{termStatusNew, nil, 0, true},
{termStatusNew, level(0), 0, true},
{termStatusNew, level(1), 0, false},
{termStatusKnown, level(0), 0, true},
{termStatusKnown, level(3), 0, false},
{termStatusIgnored, level(0), 0, true},
{termStatusIgnored, level(-1), 0, false},
{termStatusLearning, nil, 1, true},
{termStatusLearning, level(0), 1, true},
{termStatusLearning, level(1), 1, true},
{termStatusLearning, level(7), 7, true},
{termStatusLearning, level(8), 0, false},
{termStatusLearning, level(-1), 0, false},
{"", nil, 0, false},
{"Learning", nil, 0, false},
{"deleted", nil, 0, false},
{termStatusNew, nil, Term{}, false, 0, true},
{termStatusNew, level(0), Term{}, false, 0, true},
{termStatusNew, level(1), Term{}, false, 0, false},
{termStatusKnown, level(0), stored, true, 0, true},
{termStatusKnown, level(3), stored, true, 0, false},
{termStatusIgnored, level(0), stored, true, 0, true},
{termStatusIgnored, level(-1), stored, true, 0, false},
{termStatusLearning, nil, Term{}, false, 1, true},
{termStatusLearning, level(0), Term{}, false, 1, true},
{termStatusLearning, level(1), stored, true, 1, true},
{termStatusLearning, level(7), stored, true, 7, true},
{termStatusLearning, level(8), stored, true, 0, false},
{termStatusLearning, level(-1), stored, true, 0, false},
{"", nil, stored, true, 0, false},
{"Learning", nil, stored, true, 0, false},
{"deleted", nil, stored, true, 0, false},
}
// A save that does not mention a level keeps the earned one instead of resetting it.
cases = append(cases, struct {
status string
level *int
previous Term
exists bool
want int
ok bool
}{termStatusLearning, nil, stored, true, 4, true})
for _, tc := range cases {
got, err := termLevel(tc.status, tc.level)
got, err := termLevel(tc.status, tc.level, tc.previous, tc.exists)
if tc.ok && (err != nil || got != tc.want) {
t.Fatalf("%s/%v: got %d, %v; want %d", tc.status, tc.level, got, err, tc.want)
}
+183
View File
@@ -0,0 +1,183 @@
package lexgo
import (
"bytes"
"errors"
"io"
"net/http"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
)
// A TXT upload is decoded in memory and handed to the same paste pipeline. Nothing is
// written to disk: there is no temporary file to leak, and the client file name never
// becomes a path, so it cannot reach outside the request.
const maxTextUploadBytes = 2 << 20
var (
utf8BOM = []byte{0xEF, 0xBB, 0xBF}
utf16BOMBig = []byte{0xFE, 0xFF}
utf16BOMSmall = []byte{0xFF, 0xFE}
)
// decodeTextUpload turns an uploaded TXT into the exact text the reader will show. Only
// UTF-8 is accepted: an optional BOM is removed before the text is validated, and any byte
// that is not valid UTF-8 rejects the file instead of being replaced, so a chapter never
// contains a substitute character the learner did not write.
func decodeTextUpload(raw []byte) (string, error) {
if len(raw) > maxTextUploadBytes {
return "", failure(400, "TXT 文件不能超过 2 MiB")
}
if bytes.HasPrefix(raw, utf16BOMBig) || bytes.HasPrefix(raw, utf16BOMSmall) {
return "", failure(400, "文件是 UTF-16 编码,请另存为 UTF-8 后重试")
}
raw = bytes.TrimPrefix(raw, utf8BOM)
if !utf8.Valid(raw) {
return "", failure(400, "文件不是 UTF-8 编码,请另存为 UTF-8 后重试")
}
if bytes.IndexByte(raw, 0) >= 0 {
return "", failure(400, "文件包含无法处理的字符,请另存为纯文本后重试")
}
return string(raw), nil
}
type textUpload struct {
RequestID string
Title string
Language string
Text string
}
// readTextUpload parses the multipart submission: one file part plus a whitelist of text
// fields. The uploaded name is never read, not even for validation, because it only serves
// display in the browser.
func readTextUpload(c *gin.Context, allowLanguage bool) (textUpload, error) {
bad := failure(400, "TXT 上传无效,请选择 UTF-8 的 .txt 文件并填写标题")
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxTextUploadBytes+(64<<10))
reader, err := c.Request.MultipartReader()
if err != nil {
return textUpload{}, bad
}
fields := map[string]string{}
var content []byte
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return textUpload{}, uploadBodyError(err, bad)
}
name := part.FormName()
if name == "file" {
if content != nil {
part.Close()
return textUpload{}, bad
}
content, err = io.ReadAll(io.LimitReader(part, maxTextUploadBytes+1))
part.Close()
if err != nil {
return textUpload{}, uploadBodyError(err, bad)
}
if len(content) == 0 || len(content) > maxTextUploadBytes {
return textUpload{}, failure(400, "TXT 文件不能为空且不能超过 2 MiB")
}
continue
}
if !textUploadField(name, allowLanguage) {
part.Close()
return textUpload{}, bad
}
if _, exists := fields[name]; exists {
part.Close()
return textUpload{}, bad
}
value, readErr := io.ReadAll(io.LimitReader(part, 1025))
part.Close()
if readErr != nil {
return textUpload{}, uploadBodyError(readErr, bad)
}
if len(value) > 1024 || !utf8.Valid(value) {
return textUpload{}, bad
}
fields[name] = string(value)
}
if content == nil {
return textUpload{}, bad
}
text, err := decodeTextUpload(content)
if err != nil {
return textUpload{}, err
}
return textUpload{RequestID: fields["requestId"], Title: fields["title"], Language: fields["language"], Text: text}, nil
}
func textUploadField(name string, allowLanguage bool) bool {
switch name {
case "requestId", "title":
return true
case "language":
return allowLanguage
default:
return false
}
}
// An oversized body is reported as a size problem, not as an invalid upload.
func uploadBodyError(err error, bad error) error {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return failure(400, "TXT 文件不能超过 2 MiB")
}
return bad
}
// registerUploadRoutes reuses the paste pipeline: the decoded file becomes the same
// PasteBook/PasteChapter input, so idempotency, ownership and the ingest job behave exactly
// as they do for pasted text. One upload at a time keeps concurrent large decodes bounded.
func registerUploadRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
gate := make(chan struct{}, 1)
enter := func() error {
select {
case gate <- struct{}{}:
return nil
default:
return failure(429, "已有文件正在上传,请稍后重试")
}
}
leave := func() { <-gate }
v.POST("/books/upload", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if err := enter(); err != nil {
return nil, err
}
defer leave()
upload, err := readTextUpload(c, true)
if err != nil {
return nil, err
}
return PasteBook(tx, u.UserId, now(), PasteBookInput{
RequestID: upload.RequestID, Title: upload.Title, Text: upload.Text, Language: upload.Language,
})
}))
v.POST("/books/:id/chapters/upload", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
bookID, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
if err := enter(); err != nil {
return nil, err
}
defer leave()
upload, err := readTextUpload(c, false)
if err != nil {
return nil, err
}
return PasteChapter(tx, u.UserId, bookID, now(), PasteChapterInput{
RequestID: upload.RequestID, Title: upload.Title, Text: upload.Text,
})
}))
}
+376
View File
@@ -0,0 +1,376 @@
package lexgo
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// uploadFixture is the same character set the paste contract preserves, written as the bytes
// a file would hold: CRLF and LF, a tab, curly quotes, an em dash, an ellipsis, an emoji, a
// combining acute accent, a trailing space run and an empty final line.
const uploadFixture = "Mira opened the workshop.\r\n\r\n\tThe sign read “A small step…” — café e\u0301 🙂\r\nTrailing spaces here: \n\n"
// uploadFile posts one multipart TXT submission. A nil file means "no file part", and an
// empty name means "no file name", so the rejection paths stay testable.
func uploadFile(t *testing.T, r *gin.Engine, token, path, fileName string, content []byte, fields map[string]string) (int, string, pasteResponse) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
names := make([]string, 0, len(fields))
for name := range fields {
names = append(names, name)
}
// Field order must be stable so a failure message is reproducible.
for _, name := range []string{"requestId", "title", "language"} {
if value, ok := fields[name]; ok {
if err := writer.WriteField(name, value); err != nil {
t.Fatal(err)
}
names = removeString(names, name)
}
}
for _, name := range names {
if err := writer.WriteField(name, fields[name]); err != nil {
t.Fatal(err)
}
}
if content != nil {
part, err := writer.CreateFormFile("file", fileName)
if err != nil {
t.Fatal(err)
}
if _, err = part.Write(content); err != nil {
t.Fatal(err)
}
}
writer.Close()
request := httptest.NewRequest("POST", path, &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response := httptest.NewRecorder()
r.ServeHTTP(response, request)
var envelope struct {
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
t.Fatalf("upload response for %s: %v", path, err)
}
var out pasteResponse
if len(envelope.Data) > 0 {
json.Unmarshal(envelope.Data, &out)
}
return response.Code, envelope.Msg, out
}
func removeString(values []string, target string) []string {
result := values[:0]
for _, value := range values {
if value != target {
result = append(result, value)
}
}
return result
}
func TestDecodeTextUploadRules(t *testing.T) {
// Valid UTF-8 is returned exactly as received, including unusual but legal characters.
if text, err := decodeTextUpload([]byte(uploadFixture)); err != nil || text != uploadFixture {
t.Fatalf("valid file: %q %v", text, err)
}
// A UTF-8 BOM is removed and never becomes part of the original text.
withBOM := append(append([]byte{}, utf8BOM...), []byte("BOM before text\n")...)
if text, err := decodeTextUpload(withBOM); err != nil || text != "BOM before text\n" {
t.Fatalf("BOM file: %q %v", text, err)
}
// Only a BOM leaves an empty text, which the paste rules then reject.
if text, err := decodeTextUpload(append([]byte{}, utf8BOM...)); err != nil || text != "" {
t.Fatalf("BOM only: %q %v", text, err)
}
rejected := []struct {
name string
content []byte
message string
}{
{"invalid UTF-8", []byte{0x41, 0x80, 0x42}, "UTF-8"},
{"latin-1 text", []byte("caf\xe9 plain\n"), "UTF-8"},
{"UTF-16 little endian", []byte{0xFF, 0xFE, 0x41, 0x00}, "UTF-16"},
{"UTF-16 big endian", []byte{0xFE, 0xFF, 0x00, 0x41}, "UTF-16"},
{"NUL byte", []byte("text\x00more"), "无法处理"},
}
for _, tc := range rejected {
text, err := decodeTextUpload(tc.content)
if err == nil || text != "" {
t.Fatalf("%s was accepted as %q", tc.name, text)
}
api, ok := err.(*apiError)
if !ok || api.status != 400 || !strings.Contains(api.message, tc.message) {
t.Fatalf("%s message: %v", tc.name, err)
}
}
// The size boundary is exact on both sides, and the byte limit is checked before decoding.
if _, err := decodeTextUpload(bytes.Repeat([]byte("a"), maxTextUploadBytes)); err != nil {
t.Fatalf("a file at the size limit must be accepted: %v", err)
}
if _, err := decodeTextUpload(bytes.Repeat([]byte("a"), maxTextUploadBytes+1)); err == nil {
t.Fatal("a file over the size limit must be rejected")
}
// The paste rules still bound one chapter, so the byte limit cannot smuggle in more text.
if _, _, _, err := validatePaste("title", strings.Repeat("a", maxChapterRunes)); err != nil {
t.Fatalf("the exact chapter limit must be accepted: %v", err)
}
if _, _, _, err := validatePaste("title", strings.Repeat("a", maxChapterRunes+1)); err == nil {
t.Fatal("the chapter code point limit must still apply to uploaded text")
}
}
// TestMySQLTextUploadImportPath covers the accepted file: it creates the same book, chapter
// and job as a paste, and the reader text equals the file byte for byte.
func TestMySQLTextUploadImportPath(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
fields := map[string]string{"requestId": "upload-fixture-0001", "title": "The Workshop Upload", "language": "en"}
code, msg, uploaded := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "workshop.txt", []byte(uploadFixture), fields)
if code != 201 {
t.Fatalf("upload status %d (%s)", code, msg)
}
if uploaded.Book == nil || uploaded.Book.Title != "The Workshop Upload" || uploaded.Book.Language != "en" {
t.Fatalf("unexpected book %+v", uploaded.Book)
}
if uploaded.Chapter.Ordinal != 1 || uploaded.Chapter.Status != statusPending || uploaded.Duplicate {
t.Fatalf("unexpected chapter %+v", uploaded.Chapter)
}
// The worker publishes the chapter, and the reader shows exactly the file content.
drainIngest(t, db)
code, ready := readChapter(t, r, learner.Token, uploaded.Chapter.ID)
if code != 200 || ready.Chapter.Status != statusReady || ready.Chapter.OriginalText != uploadFixture {
t.Fatalf("reader text: status %d, %+v", code, ready.Chapter)
}
if want := len([]rune(uploadFixture)); ready.Chapter.CharCount != want {
t.Fatalf("charCount %d, want %d", ready.Chapter.CharCount, want)
}
// A UTF-8 BOM is stripped, so the reader never shows it.
bomFields := map[string]string{"requestId": "upload-bom-000002", "title": "BOM Upload", "language": "en"}
withBOM := append(append([]byte{}, utf8BOM...), []byte("Plain text with BOM.\n")...)
code, msg, bom := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "bom.txt", withBOM, bomFields)
if code != 201 {
t.Fatalf("BOM upload status %d (%s)", code, msg)
}
drainIngest(t, db)
if code, read := readChapter(t, r, learner.Token, bom.Chapter.ID); code != 200 || read.Chapter.OriginalText != "Plain text with BOM.\n" {
t.Fatalf("BOM text: status %d, %q", code, read.Chapter.OriginalText)
}
}
// TestMySQLTextUploadIdempotencyAndAppend reuses the paste job rules: one file yields one
// chapter, a repeated upload answers with that chapter, and the same request id with other
// content is a conflict.
func TestMySQLTextUploadIdempotencyAndAppend(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
drainIngest(t, db)
fields := map[string]string{"requestId": "upload-repeat-0001", "title": "Repeat Upload", "language": "en"}
code, msg, first := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "repeat.txt", []byte("First upload body.\n"), fields)
if code != 201 {
t.Fatalf("first upload %d (%s)", code, msg)
}
code, msg, again := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "repeat.txt", []byte("First upload body.\n"), fields)
if code != 200 || !again.Duplicate || again.Chapter.ID != first.Chapter.ID {
t.Fatalf("repeat upload %d (%s): %+v", code, msg, again)
}
var chapters int64
db.Model(&Chapter{}).Where("book_id = ?", first.Book.ID).Count(&chapters)
if chapters != 1 {
t.Fatalf("a repeated upload created %d chapters", chapters)
}
// The same request id with other content is a conflict, not a second chapter.
code, msg, _ = uploadFile(t, r, learner.Token, "/api/v1/books/upload", "repeat.txt", []byte("Different body.\n"), fields)
if code != 409 {
t.Fatalf("changed content status %d (%s)", code, msg)
}
// Appending uses the same rules and the same job pipeline.
appendFields := map[string]string{"requestId": "upload-append-0002", "title": "Second Chapter"}
path := fmt.Sprintf("/api/v1/books/%d/chapters/upload", first.Book.ID)
code, msg, appended := uploadFile(t, r, learner.Token, path, "second.txt", []byte("A single plain paragraph.\n"), appendFields)
if code != 201 {
t.Fatalf("append upload %d (%s)", code, msg)
}
if appended.Chapter.Ordinal != 2 || appended.Chapter.BookID != first.Book.ID {
t.Fatalf("unexpected appended chapter %+v", appended.Chapter)
}
code, msg, againAppend := uploadFile(t, r, learner.Token, path, "second.txt", []byte("A single plain paragraph.\n"), appendFields)
if code != 200 || !againAppend.Duplicate || againAppend.Chapter.ID != appended.Chapter.ID {
t.Fatalf("repeated append %d (%s): %+v", code, msg, againAppend)
}
// The append endpoint does not accept a language field: the book owns the language.
code, msg, _ = uploadFile(t, r, learner.Token, path, "second.txt", []byte("Another body.\n"),
map[string]string{"requestId": "upload-append-lang-0004", "title": "Second Chapter", "language": "en"})
if code != 400 {
t.Fatalf("append with a language field: status %d (%s)", code, msg)
}
// Another account cannot append into this book, and never learns whether it exists.
code, foreignMsg, _ := uploadFile(t, r, other.Token, path, "second.txt", []byte("Foreign body.\n"), map[string]string{"requestId": "upload-foreign-0003", "title": "Foreign"})
if code != 404 {
t.Fatalf("foreign append status %d (%s)", code, foreignMsg)
}
// The other account's own library stays empty.
_, list := bookList(t, r, other.Token)
if len(list.Items) != 0 {
t.Fatalf("the other account must not see this book: %+v", list.Items)
}
}
// TestMySQLTextUploadRejectsInvalidSubmissions covers the validation surface, including the
// client file name, which is never used as a path.
func TestMySQLTextUploadRejectsInvalidSubmissions(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
base := map[string]string{"requestId": "upload-invalid-0001", "title": "Invalid Upload", "language": "en"}
cases := []struct {
name string
fileName string
content []byte
fields map[string]string
status int
}{
{"no file part", "", nil, base, 400},
{"empty file", "empty.txt", []byte{}, base, 400},
{"empty file part", "empty.txt", []byte{}, base, 400},
{"whitespace only", "blank.txt", []byte(" \n\t\n"), base, 400},
{"BOM only", "bom.txt", append([]byte{}, utf8BOM...), base, 400},
{"invalid UTF-8", "latin1.txt", []byte("caf\xe9\n"), base, 400},
{"UTF-16 file", "unicode.txt", []byte{0xFF, 0xFE, 0x41, 0x00}, base, 400},
{"oversized file", "big.txt", bytes.Repeat([]byte("a"), maxTextUploadBytes+1), base, 400},
{"missing request id", "text.txt", []byte("Body.\n"), map[string]string{"title": "Invalid Upload", "language": "en"}, 400},
{"missing title", "text.txt", []byte("Body.\n"), map[string]string{"requestId": "upload-invalid-0002", "language": "en"}, 400},
{"unsupported language", "text.txt", []byte("Body.\n"), map[string]string{"requestId": "upload-invalid-0004", "title": "Invalid Upload", "language": "fr"}, 400},
{"short request id", "text.txt", []byte("Body.\n"), map[string]string{"requestId": "short", "title": "Invalid Upload", "language": "en"}, 400},
{"unknown field", "text.txt", []byte("Body.\n"), map[string]string{"requestId": "upload-invalid-0005", "title": "Invalid Upload", "language": "en", "ownerId": "9"}, 400},
}
for _, tc := range cases {
code, msg, _ := uploadFile(t, r, learner.Token, "/api/v1/books/upload", tc.fileName, tc.content, tc.fields)
if code != tc.status {
t.Fatalf("%s: status %d (%s), want %d", tc.name, code, msg, tc.status)
}
if msg == "" {
t.Fatalf("%s: rejection without a readable message", tc.name)
}
}
// No rejected submission left a book behind.
_, list := bookList(t, r, learner.Token)
if len(list.Items) != 0 {
t.Fatalf("rejected uploads created books: %+v", list.Items)
}
// A missing language field follows the paste rule and defaults to English.
code, msg, defaulted := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "default.txt", []byte("Body without language.\n"),
map[string]string{"requestId": "upload-default-lang-0007", "title": "Default Language"})
if code != 201 || defaulted.Book == nil || defaulted.Book.Language != "en" {
t.Fatalf("missing language must default to English: status %d (%s) book %+v", code, msg, defaulted.Book)
}
// The uploaded name is only a display string: a traversal-shaped name changes nothing.
hostile := map[string]string{"requestId": "upload-hostile-0006", "title": "Hostile Name", "language": "en"}
code, msg, uploaded := uploadFile(t, r, learner.Token, "/api/v1/books/upload", `..\..\windows\system32\evil.txt`, []byte("Hostile but harmless.\n"), hostile)
if code != 201 {
t.Fatalf("hostile name status %d (%s)", code, msg)
}
var chapter Chapter
if err := db.First(&chapter, uploaded.Chapter.ID).Error; err != nil {
t.Fatal(err)
}
var book Book
if err := db.First(&book, uploaded.Book.ID).Error; err != nil {
t.Fatal(err)
}
for _, field := range []string{chapter.Title, chapter.OriginalText, book.Title} {
if strings.Contains(field, "evil") || strings.Contains(field, "system32") || strings.Contains(field, `..`) {
t.Fatalf("the uploaded name leaked into stored data: %q", field)
}
}
// An unrelated content type is not a multipart upload.
request := httptest.NewRequest("POST", "/api/v1/books/upload", strings.NewReader(`{"requestId":"json-upload-0001"}`))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+learner.Token)
response := httptest.NewRecorder()
r.ServeHTTP(response, request)
if response.Code != 400 {
t.Fatalf("JSON body accepted as an upload: %d", response.Code)
}
// Uploading without a session is rejected before any parsing.
code, _, _ = uploadFile(t, r, "", "/api/v1/books/upload", "text.txt", []byte("Body.\n"), base)
if code != 401 {
t.Fatalf("anonymous upload status %d", code)
}
}
// TestMySQLTextUploadKeepsChapterLimit proves the byte cap cannot bypass the one-chapter
// code point rule, and that a large but legal file is stored completely.
func TestMySQLTextUploadKeepsChapterLimit(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
// Exactly at the chapter limit: accepted, and the stored text keeps its full length.
atLimit := strings.Repeat("a", maxChapterRunes-1) + "\n"
fields := map[string]string{"requestId": "upload-limit-0001", "title": "At The Limit", "language": "en"}
code, msg, uploaded := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "limit.txt", []byte(atLimit), fields)
if code != 201 {
t.Fatalf("upload at the chapter limit %d (%s)", code, msg)
}
drainIngest(t, db)
if code, read := readChapter(t, r, learner.Token, uploaded.Chapter.ID); code != 200 || read.Chapter.OriginalText != atLimit {
t.Fatalf("chapter at the limit: status %d, length %d", code, len([]rune(read.Chapter.OriginalText)))
}
// One code point more is rejected by the same rule that already applies to a paste.
overLimit := strings.Repeat("a", maxChapterRunes+1)
code, msg, _ = uploadFile(t, r, learner.Token, "/api/v1/books/upload", "over.txt", []byte(overLimit), map[string]string{"requestId": "upload-limit-0002", "title": "Over The Limit", "language": "en"})
if code != 400 || !strings.Contains(msg, "100000") {
t.Fatalf("upload over the chapter limit: status %d (%s)", code, msg)
}
}
// TestMySQLTextUploadAndPasteShareOnePipeline checks the two entry points cannot produce a
// second chapter for the same submitted content when the client stays on one request id.
func TestMySQLTextUploadAndPasteShareOnePipeline(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
drainIngest(t, db)
fields := map[string]string{"requestId": "upload-shared-0001", "title": "Shared Pipeline", "language": "en"}
code, msg, uploaded := uploadFile(t, r, learner.Token, "/api/v1/books/upload", "shared.txt", []byte("Shared body.\n"), fields)
if code != 201 {
t.Fatalf("upload %d (%s)", code, msg)
}
// The same request id through the paste endpoint answers with the uploaded chapter.
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
"requestId": "upload-shared-0001", "title": "Shared Pipeline", "text": "Shared body.\n", "language": "en"})
if code != 200 || !pasted.Duplicate || pasted.Chapter.ID != uploaded.Chapter.ID {
t.Fatalf("paste after upload %d: %+v", code, pasted)
}
var chapters int64
db.Model(&Chapter{}).Where("book_id = ?", uploaded.Book.ID).Count(&chapters)
if chapters != 1 {
t.Fatalf("the two entry points created %d chapters", chapters)
}
}