From bd77aa0a46256acd743a82111be7842bf07a48d0 Mon Sep 17 00:00:00 2001 From: QiuSW Date: Mon, 14 Sep 2026 22:21:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=89=E6=8B=A9=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E7=9F=AD=E8=AF=AD=EF=BC=8C=E4=BF=9D=E5=AD=98=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E5=88=B0=E6=9C=9F=E5=A4=8D=E4=B9=A0=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 短语与单词共用 lexgo_terms:身份键为按序规范化词形以空格连接,单词键不含空格, 因此 kind 与词数由身份键派生,不需要新列或第二套复习逻辑 - POST /api/v1/phrases 由服务端从本人 ready 章节推导词序列与身份,切进单词的范围 400; 章节 tokens 增加 phrases 区间,队列项增加 kind/wordCount - 跨章节匹配按连续词形比对,重叠取最左最长;短语高亮覆盖内部单词但不修改单词数据 - 学习端新增 readerRange 纯函数层与 useTextSelection(原生拖选 + 手机手柄,不拦截 touchmove),面板提供短语标题与按词调整端点的按钮,复习卡把整段短语挖成一个空 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新 --- docs/02-architecture-and-code-map.md | 27 +- docs/03-business-rules-and-glossary.md | 20 +- docs/04-local-development-and-verification.md | 22 +- docs/09-product-requirements-overview.md | 10 +- docs/README.md | 6 +- learner/e2e/phrase.spec.ts | 130 ++++++ learner/src/__tests__/lookup.spec.ts | 3 +- learner/src/__tests__/phrase.spec.ts | 171 ++++++++ learner/src/__tests__/readerRange.spec.ts | 112 +++++ learner/src/__tests__/review.spec.ts | 13 +- learner/src/components/LookupPanel.vue | 16 +- learner/src/components/ReaderTokens.vue | 48 ++- learner/src/components/ReviewCard.vue | 6 +- learner/src/composables/readerRange.ts | 98 +++++ learner/src/composables/useReaderLookup.ts | 69 +++- learner/src/composables/useTextSelection.ts | 80 ++++ learner/src/stores/review.ts | 18 + learner/src/style.css | 6 + learner/src/views/ReaderView.vue | 106 ++++- server/app/lexgo/database.go | 4 - server/app/lexgo/dictionary.go | 15 +- server/app/lexgo/phrase.go | 246 +++++++++++ server/app/lexgo/phrase_test.go | 386 ++++++++++++++++++ server/app/lexgo/review.go | 4 + server/app/lexgo/router.go | 4 +- server/app/lexgo/terms.go | 43 +- 26 files changed, 1612 insertions(+), 51 deletions(-) create mode 100644 learner/e2e/phrase.spec.ts create mode 100644 learner/src/__tests__/phrase.spec.ts create mode 100644 learner/src/__tests__/readerRange.spec.ts create mode 100644 learner/src/composables/readerRange.ts create mode 100644 learner/src/composables/useTextSelection.ts create mode 100644 server/app/lexgo/phrase.go create mode 100644 server/app/lexgo/phrase_test.go diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 16cd0fe..2c6e4ea 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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: a29e5eb9c8ff474ffdcb1278fc6b51e3b9167eb8 -synchronized_at: 2026-09-14T13:38:00Z +wiki_revision: cc1522fceb8e420cee17f509101f61986b3d7ff9 +synchronized_at: 2026-09-14T14:21:32Z # 架构与代码地图 @@ -305,3 +305,26 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at `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` 把整段短语挖成一个空。已保存短语点击优先打开短语面板,单词数据不受影响。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 3be078f..088614e 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: 96eec89f74db744d8807baa24b1d1d611ff6bf8f -synchronized_at: 2026-09-13T15:20:16Z +wiki_revision: fb3ce7506f1eb1940d7aa57226d22e4f8a270633 +synchronized_at: 2026-09-14T14:21:32Z # 业务规则与术语 @@ -226,3 +226,19 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺 **归属与边界**:改名、编辑、删除、读取编辑用原文都严格按会话归属;他人资源与不存在资源统一 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)、真机手柄精细手感。短语的例句同样是手输内容,不自动关联原文句子。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 62ce6a9..3ae550f 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -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: 251fb5de71b8ba75da3cba6eee41454d5bbd22df -synchronized_at: 2026-09-13T15:20:16Z +wiki_revision: 1643f05aa6987196d5dcc82a07e2daa07e2f2b4d +synchronized_at: 2026-09-14T14:21:32Z # 本地开发与验证 @@ -416,3 +416,21 @@ node --test spikes/english/view.test.mjs 真实链路验证:真实 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 与程序化选区覆盖;真人鼠标拖选与真机手柄仍需人工复核。短语只在同一学习者与语言内匹配,不跨账号共享。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index 3836238..9d7236c 100644 --- a/docs/09-product-requirements-overview.md +++ b/docs/09-product-requirements-overview.md @@ -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: 559f97b2f00b7fc274bda52bdaad62d10f7926c8 -synchronized_at: 2026-09-14T13:38:01Z +wiki_revision: 45077556545aceb54ad03ba7871e8d3614fe2e01 +synchronized_at: 2026-09-14T14:21:33Z # 产品需求总览 @@ -263,3 +263,9 @@ F03 的 TXT 文件导入已于 2026-09-13 通过用户验收:学习端导入 F01 的编辑与删除已于 2026-09-14 通过用户验收:学习端可改书名、改章节标题、编辑章节正文并按新版本重新处理,可用确认弹窗删除章节或整本书。编辑正文产生明确版本,旧处理结果被标为 `superseded` 而不覆盖新版本;删除在事务内完成并重排剩余章节序号,个人词条、复习排期与作答记录一律保留。本次没有数据库结构变化。 仍未实现并留给后续工单:短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。封面与音频附件(#21)、回收站/撤销、批量操作、章节跨书移动与语言变更不在本单范围。 + +## #11 交付范围更新(2026-09-11) + +F08 的短语学习与 F10 的短语复习已实现,待用户验收:阅读器支持连续选择(桌面原生拖选、手机系统手势、面板按词调整端点),保存个人释义、例句与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,同一短语跨章节只存一条记录并在出现处高亮;复习卡片把整段短语挖成一个空。范围规则(整词对齐、内部标点保留、最多 12 词、最左最长重叠)与失效引用回退(高亮消失、学习记录保留)已固化。本次没有数据库结构变化。 + +仍未实现并留给后续工单:词汇库搜索与编辑(#12,含短语编辑界面)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。短语自动合并同义形式、上下文词性消歧、短语跨书移动、批量编辑与真机手柄精细手感不在本单范围。 diff --git a/docs/README.md b/docs/README.md index 440827f..333294a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Home wiki_url: https://git.ilapage.cn/OPC/lexgo/wiki/Home -wiki_revision: 40f720f23d608f2dd875ba0ba838297c07b7a8fa -synchronized_at: 2026-09-14T13:37:57Z +wiki_revision: 08cde00c48a957705ea5b2a9d3055ff3788e64b8 +synchronized_at: 2026-09-14T14:21:32Z # LexGo 文档入口 @@ -78,3 +78,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn #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 点击调整),保存个人释义与状态;短语与单词共用同一张表、同一到期队列与同一套幂等作答,因此同一短语在不同章节只存一条记录并在出现的每处高亮,复习卡片把整段短语挖成一个空。短语与单词重叠时短语高亮覆盖、单词数据不变;编辑正文后短语不再出现时不报错,词条与复习排期保留。本次没有数据库结构变化。 diff --git a/learner/e2e/phrase.spec.ts b/learner/e2e/phrase.spec.ts new file mode 100644 index 0000000..c9fc60e --- /dev/null +++ b/learner/e2e/phrase.spec.ts @@ -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) => 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) + 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 | 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' }) +}) diff --git a/learner/src/__tests__/lookup.spec.ts b/learner/src/__tests__/lookup.spec.ts index f81bbd8..560c875 100644 --- a/learner/src/__tests__/lookup.spec.ts +++ b/learner/src/__tests__/lookup.spec.ts @@ -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 => ({ - 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 { diff --git a/learner/src/__tests__/phrase.spec.ts b/learner/src/__tests__/phrase.spec.ts new file mode 100644 index 0000000..7849a9b --- /dev/null +++ b/learner/src/__tests__/phrase.spec.ts @@ -0,0 +1,171 @@ +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) => Promise + termRead?: () => Promise +} + +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 + 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: '
' } }] }) + 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 | 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('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('虚构释义') + }) +}) diff --git a/learner/src/__tests__/readerRange.spec.ts b/learner/src/__tests__/readerRange.spec.ts new file mode 100644 index 0000000..84392bd --- /dev/null +++ b/learner/src/__tests__/readerRange.spec.ts @@ -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() + }) +}) diff --git a/learner/src/__tests__/review.spec.ts b/learner/src/__tests__/review.spec.ts index 3d0edd6..f8e2a45 100644 --- a/learner/src/__tests__/review.spec.ts +++ b/learner/src/__tests__/review.spec.ts @@ -4,11 +4,11 @@ import { createPinia, setActivePinia } from 'pinia' import { createMemoryHistory, createRouter } from 'vue-router' import ReviewView from '../views/ReviewView.vue' import { useSessionStore } from '../stores/session' -import { clozeSentence, useReviewStore, type ReviewItem } from '../stores/review' +import { clozeSentence, maskedPrompt, useReviewStore, type ReviewItem } from '../stores/review' const item = (overrides: Partial = {}): ReviewItem => ({ id: 7, term: 'dogs', originalForm: 'Dogs', definition: '狗', examples: ['Dogs went home.'], - status: 'new', level: 0, dueAt: '2026-09-11T10:00:00Z', reviewCount: 0, ...overrides, + 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 = {}) => ({ @@ -20,6 +20,15 @@ const answerCalls = (fetchMock: MockInstance) => fetchMock.mock.calls.filter(cal 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.') diff --git a/learner/src/components/LookupPanel.vue b/learner/src/components/LookupPanel.vue index e6a4f10..0c7a06a 100644 --- a/learner/src/components/LookupPanel.vue +++ b/learner/src/components/LookupPanel.vue @@ -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('definition', { required: true }) const examples = defineModel('examples', { required: true }) const status = defineModel('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(null) const panel = ref(null) let observer: ResizeObserver | undefined @@ -44,7 +47,16 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',

{{ word }}

关闭 -
+
+

短语 · {{ phrase.wordCount }} 个单词{{ phrase.stored ? ' · 已保存' : '' }}

+
+ 起点 ← + 起点 → + 终点 ← + 终点 → +
+
+

正在查询…