diff --git a/AGENTS.md b/AGENTS.md index 157528b..07ea087 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -282,4 +282,5 @@ 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 用户确认 #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。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 858d8da..8e401d4 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: f7b41a473c138bedf0baff1544e6480872b92d4d -synchronized_at: 2026-09-11T14:55:11Z +wiki_revision: ea8661cfbc172ca67148b6a14d46204ab7033e35 +synchronized_at: 2026-09-11T15:36:44Z # 架构与代码地图 @@ -272,3 +272,18 @@ schema v6 新增 `lexgo_term_reviews`(每个个人词条一行排期:`due_at 学习端新增 `/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) + +`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 化,边界由浏览器提供)。客户端预检只提前反馈,服务端结论为最终结论。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 43c3e02..50e085e 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: 1d90013d883de9339865694e0bb0026f9edd3acc -synchronized_at: 2026-09-11T14:44:50Z +wiki_revision: 76289713c11902383031764c90ae9da90bd0ce07 +synchronized_at: 2026-09-11T15:36:44Z # 业务规则与术语 @@ -192,3 +192,17 @@ exact优先;未命中再按WordNet异常表/词尾规则查候选,词性顺 **归属与错误**:词条归属由服务端按会话裁决,`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 转码;不做断点续传;不把来源文件名持久化(若将来需要「导入来源」溯源,另立范围)。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index dacbc51..e586692 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: 1192d23a8198e981961adfc6066f0b3bc3b84058 -synchronized_at: 2026-09-11T14:44:50Z +wiki_revision: 8c0886a74112ea7bff734c04775b56c571797c3d +synchronized_at: 2026-09-11T15:36:44Z # 本地开发与验证 @@ -380,3 +380,21 @@ node --test spikes/english/view.test.mjs **流程记录(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 转码与按空行自动分章不在本单。 diff --git a/docs/09-product-requirements-overview.md b/docs/09-product-requirements-overview.md index a3fb5d3..86f76fd 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: e6a1f2037516203e9144159af77b25e87638b6fd -synchronized_at: 2026-09-11T14:55:11Z +wiki_revision: f5b38f2d527cbc21eac4512f7c439c013bb221d9 +synchronized_at: 2026-09-11T15:36:45Z # 产品需求总览 @@ -251,3 +251,9 @@ F07 的个人词语记录已于 2026-09-11 通过用户验收:阅读器可以 F10 的单词到期复习已于 2026-09-11 通过用户验收(包含独立审核整改 R1~R3):按固定间隔表取本人当前语言的到期词条,正面显示词与挖空例句,显示答案后按「认识/答对」「不认识/答错」「再学一次」评分;答对升级并排下次复习,答错降级并立即回到本轮,再学一次不改等级并回到本轮。重复提交、网络重发与双标签页都不会重复更新次数和间隔;已知与忽略的词条不入队。管理端无改动。 仍未实现并留给后续工单:短语复习(#11)、词汇库搜索与编辑(#12)、阅读完成与进度统计(#13)、TXT 导入(#9)、书籍章节编辑删除(#10)。复习范围筛选与策略配置(X11)、练习模式(X08)、FSRS 仍在范围外。 + +## #9 交付范围更新(2026-09-11) + +F03 的 TXT 文件导入已实现,待用户验收:学习端导入页新增「粘贴文本 / TXT 文件」来源切换,选择 UTF-8 的 .txt 文件后经大小、空文件与编码校验进入与粘贴相同的处理与阅读流程,失败可重试。只支持 UTF-8(允许可选 BOM)且不替换损坏字符;UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;重复上传同一文件只产生一章。schema 无变化。 + +仍未实现并留给后续工单:书籍与章节的编辑删除(#10)、短语选择与保存(#11)、词汇库搜索与编辑(#12)、阅读完成与进度(#13)、桌面与手机体验补齐(#14)、自托管试用交付与完整恢复(#15)。EPUB/PDF/字幕、UTF-16 转码、按空行自动分章与断点续传不在本单范围。 diff --git a/docs/README.md b/docs/README.md index 3698db3..39c6983 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: 85c66bb518fcbb166b916549aa6e655215e0ed85 -synchronized_at: 2026-09-11T14:55:10Z +wiki_revision: 75de70aad90f02deeadbed301c9fe85ded1fec6e +synchronized_at: 2026-09-11T15:36:44Z # LexGo 文档入口 @@ -74,3 +74,5 @@ Quant-UX 原型 v1 已通过用户验收。[桌面预览](https://qux.ilapage.cn #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 文件导入已实现,待用户验收:导入页新增「粘贴文本 / TXT 文件」来源切换,只接受 UTF-8(允许可选 BOM)且不替换损坏字符,UTF-16 与其他编码会被明确拒绝;文件只在内存中解码、不写临时文件,客户端文件名不参与任何路径也不入库;上传与粘贴共用同一分章、任务与幂等规则,重复上传同一文件只产生一章。本次没有数据库结构变化。 diff --git a/learner/e2e/upload.spec.ts b/learner/e2e/upload.spec.ts new file mode 100644 index 0000000..586edb3 --- /dev/null +++ b/learner/e2e/upload.spec.ts @@ -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) +}) diff --git a/learner/src/__tests__/upload.spec.ts b/learner/src/__tests__/upload.spec.ts new file mode 100644 index 0000000..3e3cd42 --- /dev/null +++ b/learner/src/__tests__/upload.spec.ts @@ -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: `