From 2ae2bc2f2af056152691569aca305646f4e4b1d3 Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 15:14:59 +0800
Subject: [PATCH 01/12] =?UTF-8?q?test:=20clean=5Fdb=20=E7=8E=AF=E5=A2=83?=
=?UTF-8?q?=E9=92=89=E6=89=8E=E4=B8=8E=20nlp=20=E7=9B=B4=E5=BC=95=E6=94=B6?=
=?UTF-8?q?=E7=BC=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- test_audit_hardening: clean_db fixture 前后钉 DATABASE_PATH/PROGRESS_DB_PATH,
防全量 pytest 时 test_server.py 顶层改写 env 导致本文件命中未建表的默认库。
- test_server: import server 改 import nlp(9fea63e 已剔除 server 侧 nlp 透传,
旧引用是死测路径)。
- docs(plans): ADR-0005 Phase1 实施计划入库(词表全迁/静态导航/题库不入库三决策版)。
---
.../2026-09-04-ia-nav-exam-domain-phase1.md | 220 ++++++++++++++++++
test_audit_hardening.py | 15 ++
test_server.py | 24 +-
3 files changed, 247 insertions(+), 12 deletions(-)
create mode 100644 docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md
diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md
new file mode 100644
index 0000000..d956b47
--- /dev/null
+++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md
@@ -0,0 +1,220 @@
+# ADR-0005 首刀实施计划:备考域 IA 重布局(Phase 1)
+
+> **Goal**: 按 ADR-0005 把 DeLector 前端从「场景工具混入 A1 考纲素材」重排为「场景工具 + 独立备考域(等级页签顶层、catalog 驱动)」,并落地目录/成绩第一刀,跑通 A1 全部练习在备考域内可用。
+> **Tech Stack**: Python 3.11 FastAPI + SQLite(server.py/database.py);原生 ES Modules 前端(index.html 单文件 SPA + main.js 根 module);node:vm 探针(仿 tools/wb_sync_probe.mjs)
+> **Spec Reference**: `d:/Obsidian/Coding/08-Projects/DeLector/01-ADR/0005-navigation-exam-domain-and-level-scalability.md`
+> **Global Constraints**:
+> - **分支/PR 流程 (用户指令, MUST)**:基线上 **master @ v5.1.1**(已推送远端)。Task 0 建分支 `feat/ia-nav-exam-domain`;**本计划全部 commit 只落在该分支**,严禁直接写 master;收口 Task 5 push 分支并在远端开 PR(`gh pr create` 或网页)合入 master。
+> - **范围边界**:本计划 = ADR §4 决策 1/2/3 的首刀(导航单源 + 备考域 + A1 四模块迁移 + catalog 目录第一刀 + 成绩泛化第一刀)。**不属于本计划**:听/读组件 level 参数化提取、写作判分提取、`/api/a1/*` 全量切 `/api/exams/{level}/{module}`、背词工作台 token 壳/词表契约——后述 §9 作为后继 sub-plan 触发点(建议 A2 立项或独立 short-plan 再排)。
+> - **跨边界契约纪律 (MUST)**:任何端点/前端 body 契约变更配行为探针(node:vm 桩 fetch),单侧字符串存在断言不算数;需「退回旧实现必红」变异验证。
+> - **字符串断言纪律 (MUST)**:搬 index.html 大 DOM 前先 grep 定位解析 index.html/main.js/a1_*.js 特征串的测试与 tools/*.mjs 探针,迁移同 commit 内同步特征串。
+> - **DB 纪律**:改 database.py 函数用 try/finally + `conn.close()` 确定性关闭;迁移脚本防重入;测试隔离沿用 clean_db + `gc.collect()`,跑前设 `DATABASE_PATH` 防写真实 `delector.db`。
+> - **提交约定**:`feat|fix|test|refactor|docs(ia): 中文描述`,每个 Task 原子提交。
+> - **TDD**:每 Task Red→Verify Red→Green→Verify Green→Refactor→Commit。
+> - 执行环境降级:本会话无写码子代理,`/vault-exec` 降级为「编排者主线程直写 + TDD」;Task 的 Subagent Prompt Scaffold 供有子代理环境或人工核对使用。
+
+---
+
+### Task 0: 建立分支与回归基线 [Role: Guard]
+
+**Files:**
+- 无源码改动。产出基线记录 `docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md`。
+
+**Interfaces:**
+- Consumes: master @ v5.1.1;现有测试套件
+- Produces: 分支 `feat/ia-nav-exam-domain`;基线测试报告(全量服务端 + 前端字符串/探针定向)
+
+**Subagent Prompt Scaffold:**
+> Implement Task 0: 建分支 + 回归基线。
+> Goal: 在 v5.1.1 上开 `feat/ia-nav-exam-domain`,跑一次全量回归并记录通过/失败清单作为本计划基线。
+> TDD Steps:
+> 1. `git checkout -b feat/ia-nav-exam-domain v5.1.1` 并确认分支。
+> 2. `export PYTHONIOENCODING=utf-8` 后跑服务端定向子集(a1/hoeren/lesen/cards/writer + 全量 TestClient 主套件),记录数量。
+> 3. 跑依赖 index.html/main.js/workbench.html 特征串的字符串断言与 tools/*.mjs 探针,记录绿/红。
+> 4. 把结果写入 ledger(含“迁移前绿、任务 X 迁移后必须仍绿”的断言清单)。
+> Return: 分支名 + 基线通过计数 + 需在 Task 2/3 同步特征串的断言文件清单。
+
+**Step Breakdown:**
+- [ ] Step 1: `git checkout -b feat/ia-nav-exam-domain v5.1.1`
+- [ ] Step 2: 服务端 + 前端字符串/探针回归,产出基线
+- [ ] Step 3: 建 ledger 并登记「迁移敏感断言」清单
+- [ ] Step 4: 原子 commit `docs(ia): Phase1 基线回归 + ledger`
+
+---
+
+### Task 1: 导航数据化 + 备考域骨架 [Role: Frontend TDD Builder]
+
+**Files:**
+- Modify: `static/js/main.js:150-215`(show/view 切换与点亮)、`static/index.html` 桌面 `nav#nav`(41-89)、移动 dock(2647-2696)
+- Create: `static/js/nav.js`(NAV config 与双端渲染,仅被 main.js import)
+- Modify: `static/index.html` view 区新增 ``(置于 view-writer 之后,含等级页签条 `#exam-level-tabs` 与模块卡片区 `#exam-module-grid`)
+- Test: 前端字符串断言所在测试文件(Task 0 定位)+ 新增特征断言
+
+**Interfaces:**
+- Consumes: `main.js show(view)`(view id → `.active`、nav-btn/mob-btn 点亮)
+- Produces: `NAV_ITEMS=[{id,label,de,onclick}…]`(含 exam);`renderNav()` 单源渲染桌面 `.nav-links` 与移动 `.mobile-dock`;`renderExamShell(levels)`(本 Task levels 常量 `['A1']`,模块卡片占位入口四张:写作/听力/阅读/口语)
+- 语义约束:保留文案标识(SCHREIBTISCH/KARTEI/VOKABELN 及中文文案)以免字符串断言大面积失配;`show('exam')` 须点亮对应 nav/dock 按钮并允许 empty view 显示。
+
+**Subagent Prompt Scaffold:**
+> Implement Task 1: 导航数据化 + 备考域骨架。
+> Goal: nav config 单源渲染桌面导航与移动 dock,新增「备考」入口与 view-exam 壳(A1 等级页签 + 四模块占位卡片),view 切换可进入。
+> Target Files: Create `static/js/nav.js`;Modify `static/js/main.js` show()、`static/index.html` 导航两处 + 新增 view-exam 容器。
+> TDD Steps:
+> 1. RED:更新前端字符串断言(nav 出现「备考/Prüfung」入口;view-exam 容器存在;nav 双端渲染后原有五入口文案仍在)。
+> 2. Verify Red:跑对应断言确认失败信息为“备考入口/容器缺失”。
+> 3. GREEN:最小实现 nav.js + main.js 引入 + index.html 容器;nav 双端渲染。
+> 4. REFACTOR:guard-clause 扁平化;nav config 与 view 初始化联动(show('exam') 无副作用)。
+> 5. 跑 Task 0 登记的全量字符串/探针,确认除“新增入口”预期外无意外失配。
+> 6. 原子 commit `feat(ia): 导航单源化并新增备考域骨架`。
+> Return: 测试执行证据 + 遗留失配清单。
+
+**Step Breakdown:**
+- [ ] Step 1: nav.js NAV config + 双端渲染,index.html 增 view-exam(等级页签壳 + 4 占位卡片)(RED 测试先行)
+- [ ] Step 2: 断言失配验证(仅“缺备考入口/容器”类)
+- [ ] Step 3: show('exam') 联动与点亮;最小 GREEN
+- [ ] Step 4: 跑全量字符串/探针回归,同步意外失配
+- [ ] Step 5: REFACTOR 扁平化 + 原子 commit
+
+---
+
+### Task 2: A1 四模块 UI 迁入备考域 + 工具视图清理 [Role: Frontend TDD Builder — 高风险 DOM 迁移]
+
+**Files:**
+- Modify: `static/index.html`:
+ - 删除 `view-writer` 内 A1 区:`.writer-mode-switcher-bar` A1 两按钮(944-970 内 a1 项)、`#a1-formular-view`(973-1037)、`#a1-email-view`(1040-1147)
+ - 删除 `view-cards` 内 A1 tabs(含 a1-tab-teil2/teil3 等,Task 0 定位的精确行区间;口语问答数据面板)
+ - 在 `view-exam` 内按模块挂 4 个面板容器:`exam-writing`(formular+email)、`exam-hoeren`、`exam-lesen`、`exam-sprechen`(teil2/teil3)——**DOM 从原 view 同 commit 内搬移,杜绝同 id 双现**
+- Modify: `static/js/writer.js:7-29`(若 main.js 不再经 writer.js 导入 a1_writer,调整 re-export 但保留 window 挂载来源)、`static/js/main.js:146-147,726-892`(window 挂载聚合与 import,必要时改引用)、`static/js/cards.js:26-27`
+- Create: `tools/ia_dom_mount_probe.mjs`(node:vm 切 index.html+main.js,桩 fetch,断言「a1 面板 DOM 存在于 view-exam、旧 view 内已无 a1 容器、A1Hoeren/A1Lesen window 挂载可达」)
+- Test: 前端字符串断言测试 + 探针
+
+**Interfaces:**
+- Consumes: `window.A1Writer/A1Cards/A1Hoeren/A1Lesen`(a1_*.js 自挂 + main.js 聚合);既有 `/api/a1/*` 端点(本 Task 不改后端契约,前端迁 UI 后仍调原端点取题)
+- Produces: view-exam 内 4 面板;`view-writer`=纯 essay 工具(mode-switcher 移除);`view-cards`=纯复习;探针新增并全绿
+- 风险:a1 面板事件由渲染函数注入 `onclick="A1Hoeren.xxx()"`;迁移后 window 挂载必须保持。id 唯一性约束:迁移必须“同一次替换原子完成”。
+
+**Subagent Prompt Scaffold:**
+> Implement Task 2: A1 四模块 UI 迁入备考域 + 工具视图清理。
+> Goal: A1 写作/听力/阅读/口语面板全部迁到 view-exam 对应容器,旧 view-writer/view-cards 移除 A1 区块;同 id 不得双现;window 挂载与事件注入不破;字符串断言同步。
+> Target Files: Modify `static/index.html`(4 段搬移 + 旧区块删除);Modify `static/js/{writer,cards,main}.js`;Create `tools/ia_dom_mount_probe.mjs`。
+> TDD Steps:
+> 1. RED:写 `ia_dom_mount_probe.mjs`——断言“view-exam 内存在 a1 面板、旧 writer/cards view 无 a1 容器、A1 window 挂载键存在”;先跑必红。
+> 2. Verify Red:探针失败信息 = “新位置缺失 / 旧位置仍存在”。
+> 3. GREEN:单次原子替换搬 DOM(先同 commit 里“新增 view-exam 副本 + 删除旧块”一次 diff 完成);修 main.js/writer.js/cards.js 引用。
+> 4. 全量字符串断言回归 + 探针绿;旧 view 清理(mode-switcher 移除 A1 项;cards A1 tab 移除)。
+> 5. REFACTOR + 原子 commit `feat(ia): A1 四模块迁入备考域并清理工具视图`。
+> Return: 探针输出 + 回归结果 + 任何仍调旧 UI 的引用清单。
+
+**Step Breakdown:**
+- [ ] Step 1: 探针 ia_dom_mount_probe.mjs(RED 必红)
+- [ ] Step 2: index.html 原子搬移 4 面板(一次 diff:新容器插入 + 旧块删除),确保同 id 无双现
+- [ ] Step 3: 修 js 引用/挂载,GREEN + 探针绿
+- [ ] Step 4: 字符串断言回归;view-writer/view-cards 清理确认(纯工具语义)
+- [ ] Step 5: 原子 commit(含探针文件)
+
+---
+
+### Task 3: exam catalog 目录化(数据模块注册 + catalog 端点 + 等级页签数据驱动)[Role: Backend TDD Builder]
+
+**Files:**
+- Create: `routes_exam.py`(`APIRouter(prefix="/api/exams")`,本 Task 仅 catalog 端点);`exam_catalog.py`(等级目录注册表:`EXAM_CATALOG = {"A1": {"writing":{title,panel,api_prefix:"/api/a1"}, "hoeren":…, "lesen":…, "sprechen":…}, …}`;引用 `a1_writing_dict/a1_dict/a1_hoeren_dict/a1_lesen_dict` 的模块级常量并声明 `{level}_{module}` 归位别名)
+- Modify: `server.py`(include exam router;若文件已按 M-split 组织则并入对应模块)——依现状 server.py:234 区 include
+- Modify: `static/js/nav.js` 或新增 `exam.js`:Task1 的静态等级/卡片改由 `GET /api/exams/catalog` 渲染(A1 模块卡片的跳转锚 → 对应面板容器)
+- Test: `test_exam_catalog.py`(catalog 返回等级 A1、模块含 writing/hoeren/lesen/sprechen;旧 `/api/a1/*` 取题端点照常可用——catalog 只提供导航/发现,**不迁移取题端点**)
+
+**Interfaces:**
+- Consumes: `a1_*_dict` 模块常量(词表/题库/题集元数据);前端 view-exam 渲染
+- Produces: `GET /api/exams/catalog → {levels:[{id,title,modules:[{id,title,type,panel}]}]}`;前端等级页签与模块卡片完全数据驱动
+- 决策(相对 ADR 的实现细化,需用户点头):**题库数据暂不入 SQLite**——catalog 是「代码注册目录」(`exam_catalog.py` 单源,未来 A2 = 追加 `A2` key + 数据模块),YAGNI 于题量级;ADR 的 `exam_sets/exam_items` 表留作“题库规模/服务端出题”触发后的升级路径;**成绩泛化表**才是本计划必落库项(Task 4)。
+
+**Subagent Prompt Scaffold:**
+> Implement Task 3: exam catalog 目录化。
+> Goal: 新增 /api/exams/catalog 由代码注册目录 EXAM_CATALOG 提供等级→模块导航;前端备考域等级页签与卡片改由 catalog 渲染;旧 /api/a1 取题端点不动。
+> Target Files: Create `routes_exam.py`, `exam_catalog.py`;Modify `server.py` include、前端等级页签渲染源。
+> TDD Steps:
+> 1. RED:`test_exam_catalog.py`(catalog 200,含 A1 与四模块;加「插一行 A2 注册 → catalog 多一级」的变异式断言证明扩展点)先跑必红。
+> 2. GREEN:exam_catalog.py + routes_exam.py + server include。
+> 3. 前端把 Task1 占位等级/卡片切到 fetch catalog 渲染(无 catalog 或失败时保留静态回退)。
+> 4. 回归:a1 取题/判分端点子集全绿(确认未动旧契约)。
+> 5. REFACTOR + 原子 commit `feat(ia): exam catalog 目录化驱动备考域导航`。
+> Return: 测试证据 + “加级成本”演示(临时注册 A2 的 catalog 输出或注释说明)。
+
+**Step Breakdown:**
+- [ ] Step 1: test_exam_catalog.py RED(含 A2 扩展点变异断言)
+- [ ] Step 2: exam_catalog.py + routes_exam.py + include GREEN
+- [ ] Step 3: 前端等级页签/卡片数据驱动(静态回退兜底)
+- [ ] Step 4: 旧端点回归全绿 + 原子 commit
+
+---
+
+### Task 4: 成绩表泛化第一刀 + 旧行迁移 [Role: DB TDD Builder]
+
+**Files:**
+- Modify: `database.py`:新增 `exam_trials(level,module,set_id,score_raw,score_official,details_json,created_at)`(init 建表处 ~database.py:94-105 邻近);新增 `record_exam_trial()`/`get_exam_history(level,module)`;`record_a1_hoeren_trial`/`record_a1_lesen_trial`/`get_a1_hoeren_history`/`get_a1_lesen_history`(database.py:909-956)改为内部透传泛化函数并保留签名(**不删旧名**,避免前端/测试大面积契约变更)
+- Modify: 一次性迁移函数 `migrate_a1_records_to_exam_trials()`:把 `a1_hoeren_records/a1_lesen_records` 存量行写入 exam_trials(level='A1'),防重入(存在则跳过);旧表保留(读历史兼容期),不物理删除——ADR 语境下旧表退役放 Phase 2 端点切完再删
+- Test: `test_exam_trials.py`(迁移后计数一致、字段映射正确、防重入幂等;新 trial 写入即读回;旧 record_a1_* 函数行为不变——透传契约)
+
+**Interfaces:**
+- Consumes: 既有 `record_a1_*_trial/get_a1_*_history` 调用方(routes_a1_hoeren/lesen 内部)
+- Produces: `exam_trials` 表 + 泛化读写 + 幂等迁移;旧函数透传不破契约
+- 风险:迁移 SQL 在 test clean_db 与真实库都要跑;沿用 `gc.collect()`/确定性 close 纪律;迁移函数幂等(重复跑无副作用)。
+
+**Subagent Prompt Scaffold:**
+> Implement Task 4: 成绩表泛化 + 旧行迁移。
+> Goal: exam_trials(level,…) 泛化表上线;存量 a1_hoeren/a1_lesen 成绩幂等迁入;旧 record/get 函数透传泛化实现,契约不破。
+> Target Files: Modify `database.py`;Create `test_exam_trials.py`。
+> TDD Steps:
+> 1. RED:test_exam_trials(写读回 + 旧函数行为保持 + 迁移幂等)。
+> 2. GREEN:建表/函数/迁移;旧函数改透传。
+> 3. 回归:routes_a1_hoeren/lesen 提交判分子集 + 历史读回全绿;`test_server` 受影响 `-k` 定向。
+> 4. REFACTOR + 原子 commit `refactor(db): 成绩表泛化 exam_trials 并幂等迁移 A1 存量`。
+> Return: 迁移幂等验证 + 定向回归证据。
+
+**Step Breakdown:**
+- [ ] Step 1: test_exam_trials RED(迁移幂等/旧函数透传/新写读回)
+- [ ] Step 2: 泛化表 + 函数 + 幂等迁移 GREEN
+- [ ] Step 3: 旧 record/get 透传;hoeren/lesen 提交与历史回归
+- [ ] Step 4: 原子 commit
+
+---
+
+### Task 5: 收口:全量回归 + ledger + 分支上传 + PR [Role: Guard]
+
+**Files:**
+- Modify: `docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md`(勾选全部 Step、记录偏差与证据)
+- Modify(如适用): 废弃/停用标注遗留 UI 或死按钮(若 Task 2 后出现不可达入口,走“禁用标注”而非静默保留)
+
+**Interfaces:**
+- Consumes: master 基线(Task 0 记录)
+- Produces: 与基线对比的回归报告;已 push 分支 + PR
+
+**Subagent Prompt Scaffold:**
+> Implement Task 5: 收口回归 + 分支上传 + PR。
+> Goal: 全量回归对比 Task 0 基线;ledger 回填勾选;push `feat/ia-nav-exam-domain` 到 origin;在远端开 PR 到 master(描述引用 ADR-0005,标题 `feat(ia): 备考域重布局 + catalog/成绩第一刀`)。
+> Steps:
+> 1. 服务端全量 + 前端字符串/探针定向回归,输出与基线差异表。
+> 2. ledger 勾选 Task 0-4 Step + 偏差注记。
+> 3. `git push -u origin feat/ia-nav-exam-domain`。
+> 4. 开 PR:`gh pr create --base master` 可用则 CLI,否则给出网页链接与 PR 描述草稿(含测试证据、ADR-0005 引用、Phase 2 范围声明)。
+> 5. commit `docs(ia): Phase1 ledger 收口 + PR 就绪`(在分支上)。
+> Return: PR URL 或待开 PR 的完整描述。
+
+**Step Breakdown:**
+- [ ] Step 1: 回归差异表(对比 Task 0)
+- [ ] Step 2: ledger 勾选/偏差注记
+- [ ] Step 3: push 分支
+- [ ] Step 4: PR 就绪(CLI 或草稿)
+- [ ] Step 5: 文档 commit
+
+---
+
+## 9. 后继 sub-plan 触发点(不在本计划执行)
+
+| 触发 | 内容 | ADR 引用 |
+|---|---|---|
+| A2 立项时 | 听/读组件 level 参数化提取(先听/读,写作判分延后);`/api/a1/*` 全量切 `/api/exams/{level}/{module}`;旧 a1 端点与旧成绩表退役删除 | ADR-0005 §4.3 后续刀 |
+| 独立 short-plan(可在本计划 PR 合并后随时启动) | 背词工作台视觉统一:共享设计 token(主站 `style.css` 暖纸 `--paper/--ink/--rule` 体系 vs workbench 冷灰 `--bg/--accent`——**色系冷暖相反且 token 名异构,需先出视觉小样定语义**,保留 good/hard/again 状态色)+ 词表来源契约 `vocab_cards.cefr_level` 过滤 | ADR-0005 §4.5/Q4C |
+
+---
+
+*计划 schema 依据 vault-plan;ADR 依据 vault-grill 共识(ADR-0005, status=proposed, 2026-09-04)。*
diff --git a/test_audit_hardening.py b/test_audit_hardening.py
index 75697bd..acf5e66 100644
--- a/test_audit_hardening.py
+++ b/test_audit_hardening.py
@@ -39,6 +39,16 @@ def lan_client():
@pytest.fixture(autouse=True)
def clean_db():
+ # database.get_db_path() 每次调用都读 os.environ(不是 import 时冻结)。
+ # 全量 pytest 时更晚收集的 test_server.py 会在模块顶层把 DATABASE_PATH
+ # 改写成本文件,导致本文件用例的默认路径命中未建表的 test_delector.db。
+ # 故每个用例前后都钉住自己的 env,不能只靠模块顶层那一次赋值。
+ saved = {
+ key: os.environ.get(key)
+ for key in ("DATABASE_PATH", "PROGRESS_DB_PATH")
+ }
+ os.environ["DATABASE_PATH"] = "test_audit_delector.db"
+ os.environ["PROGRESS_DB_PATH"] = "test_audit_progress.db"
gc.collect()
for f in ("test_audit_delector.db", "test_audit_progress.db"):
if os.path.exists(f):
@@ -57,6 +67,11 @@ def clean_db():
os.remove(f)
except OSError:
pass
+ for key, val in saved.items():
+ if val is None:
+ os.environ.pop(key, None)
+ else:
+ os.environ[key] = val
# ── M2-4: 连接确定性关闭(open/close 对账探针)──────────────────────────────
diff --git a/test_server.py b/test_server.py
index 3ec74ee..0fd898d 100644
--- a/test_server.py
+++ b/test_server.py
@@ -1790,9 +1790,9 @@ def test_pure_python_pipeline_without_spacy():
这条分支曾因调用不存在的 lookup_core_dict 而在 import server 时就 NameError,
导致安卓端 uvicorn 永远起不来、启动页一直卡住。
"""
- import server
+ import nlp
- result = server._process_german_text_pure_python(
+ result = nlp._process_german_text_pure_python(
"Der Hund schläft. Ich habe ein Buch gelesen!"
)
@@ -1882,11 +1882,11 @@ def test_android_never_downloads_model_at_import():
def test_spacy_model_candidates_prefer_md():
"""README 与 Dockerfile 都装 md(带词向量、标注更准),sm 只是兜底。"""
- import server
+ import nlp
- assert server.SPACY_MODEL_CANDIDATES == ("de_core_news_md", "de_core_news_sm")
+ assert nlp.SPACY_MODEL_CANDIDATES == ("de_core_news_md", "de_core_news_sm")
# 自动下载走小模型:md 约 45MB,首启动拉它太慢
- assert server.AUTO_DOWNLOAD_MODEL == "de_core_news_sm"
+ assert nlp.AUTO_DOWNLOAD_MODEL == "de_core_news_sm"
def test_load_spacy_model_falls_back_to_module_load(monkeypatch):
"""spacy.load(名称) 查的是 .dist-info;Android 上模型是直接拷进源码目录的。
@@ -1896,27 +1896,27 @@ def test_load_spacy_model_falls_back_to_module_load(monkeypatch):
"""
import sys
import types
- import server
+ import nlp
sentinel = object()
fake = types.ModuleType("de_fake_news_sm")
fake.load = lambda **kw: sentinel
monkeypatch.setitem(sys.modules, "de_fake_news_sm", fake)
- monkeypatch.setattr(server.spacy, "load", lambda *a, **k:
+ monkeypatch.setattr(nlp.spacy, "load", lambda *a, **k:
(_ for _ in ()).throw(OSError("[E050] Can't find model")))
- nlp, how = server._load_spacy_model("de_fake_news_sm")
- assert nlp is sentinel
+ model, how = nlp._load_spacy_model("de_fake_news_sm")
+ assert model is sentinel
assert "module.load" in how
def test_load_spacy_model_reports_every_failed_strategy(monkeypatch):
"""全部失败时错误信息要带上每条策略的原因,否则真机上无从判断卡在哪。"""
- import server
+ import nlp
- monkeypatch.setattr(server.spacy, "load", lambda *a, **k:
+ monkeypatch.setattr(nlp.spacy, "load", lambda *a, **k:
(_ for _ in ()).throw(OSError("no dist-info")))
with pytest.raises(RuntimeError) as excinfo:
- server._load_spacy_model("de_definitely_not_installed")
+ nlp._load_spacy_model("de_definitely_not_installed")
message = str(excinfo.value)
assert "spacy.load" in message
assert "import de_definitely_not_installed" in message
From 3893f9ca9dcd2dfd87ef8189326490ad5373a62e Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 15:18:31 +0800
Subject: [PATCH 02/12] =?UTF-8?q?docs(ia):=20Phase1=20=E5=9F=BA=E7=BA=BF?=
=?UTF-8?q?=E5=9B=9E=E5=BD=92=20+=20ledger=EF=BC=88518=20passed=20/=20?=
=?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=AE=9A=E5=90=91=20117=20/=20=E6=8E=A2?=
=?UTF-8?q?=E9=92=88=209=20=E7=BB=BF=20+=20=E8=BF=81=E7=A7=BB=E6=95=8F?=
=?UTF-8?q?=E6=84=9F=E6=96=AD=E8=A8=80=E6=B8=85=E5=8D=95=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...-09-04-ia-nav-exam-domain-phase1-ledger.md | 82 +++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
new file mode 100644
index 0000000..a439fa2
--- /dev/null
+++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
@@ -0,0 +1,82 @@
+# ADR-0005 Phase 1 Ledger
+
+> 计划:`docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md`(修订版)。分支:`feat/ia-nav-exam-domain`。基线:master @ e2cc2a2 (v5.1.1)。
+
+## Task 0: 分支 + 修复收编 + 基线 [Role: Guard]
+
+- [x] Step 1: 建分支 `feat/ia-nav-exam-domain`(commit `2ae2bc2`:test 修复收编 + 计划入库)
+- [x] Step 2: 全量 pytest 基线 + 前端定向基线
+- [x] Step 3: 迁移敏感断言清单登记
+- [x] Step 4: 原子 commit ledger
+
+### 基线回归证据
+
+| 套件 | 结果 |
+|---|---|
+| 前端定向(test_german_workbench + test_goethe_a1 + test_goethe_a1_writing + test_prep_matrix + test_frontend_module_graph) | 117 passed |
+| wb_*.mjs 动态探针(9 个) | 全绿 |
+| 全量 pytest | 518 passed (98.95s) |
+
+### 迁移敏感断言清单(Task 2 同步项)
+
+| 文件:行 | 断言 | 迁移后命运 |
+|---|---|---|
+| test_goethe_a1.py:128 | `id="seg-a1"` 存在 | **失效**——seg-a1 按钮删除,断言改为 exam 入口 |
+| test_goethe_a1.py:129-133 | `a1-toolbar`/`a1-topic-pills`/`a1-tab-vocab|teil2|teil3` 存在 | id 不变,搬进 view-exam 后仍绿 |
+| test_goethe_a1_writing.py:205-206 | `writer-mode-a1-formular/email` | **失效**——按钮改名 `exam-tab-formular/email`,测试同 commit 更新 |
+| test_goethe_a1_writing.py:207-208 | `a1-formular-view`/`a1-email-view` | id 不变,搬进 view-exam 后仍绿 |
+| test_german_workbench.py:71-73 | `nav-btn-german`/`mob-btn-german` 顺序 | 不动(静态加按钮方案),天然绿 |
+| test_german_workbench.py:64 | `view-german` 块 split | view-exam 置于 view-german **之前**,不破 split |
+| test_writer_mobile.py:415 | show() 调 closeWriterMobilePanel | 不动,天然绿 |
+| test_frontend_module_graph.py:358-368 | main.js 必须 star-import + exposer A1Hoeren/A1Lesen | DOM 迁移不改挂载,天然绿 |
+
+### index.html 搬移边界(执行精确锚点)
+
+| 区块 | 行区间 | 去处 |
+|---|---|---|
+| writer mode 按钮条(essay 外两项) | 944-970(删 a1 两按钮,保留 essay) | view-exam `exam-writing` 头部 |
+| `#a1-formular-view` | 973-1037 | view-exam `exam-writing` |
+| `#a1-email-view` | 1040-1146 | view-exam `exam-writing` |
+| cards `seg-a1` 按钮 | 305-316 | **删除**(备考域入口替代) |
+| cards `a1-toolbar` 整块 | 379-433 | view-exam `exam-vocab` 头部 |
+| cards `a1-hoeren-container`/`a1-lesen-container` | 436-437 | view-exam `exam-hoeren`/`exam-lesen` |
+| view-exam 新建 | — | view-german(:2637) 之前 |
+
+(边界行号基于 v5.1.1 工作区,Task 2 执行时以 grep 重定位为准。)
+
+## Task 1: 备考域骨架 + 静态导航入口
+
+- [ ] Step 1: RED 测试(exam 入口五断言)
+- [ ] Step 2: index.html 静态双端按钮 + view-exam 壳
+- [ ] Step 3: GREEN + 定向回归
+- [ ] Step 4: 原子 commit
+
+## Task 2: A1 五模块迁入备考域
+
+- [ ] Step 1: 探针 ia_dom_mount_probe.mjs(RED 必红)
+- [ ] Step 2: index.html 原子搬移(上表边界)
+- [ ] Step 3: js 引用修改 + GREEN
+- [ ] Step 4: 测试同步(上表失效项)+ 全量探针回归
+- [ ] Step 5: 原子 commit
+
+## Task 3: exam catalog 目录化
+
+- [ ] Step 1: test_exam_catalog.py RED(含 A2 扩展点变异)
+- [ ] Step 2: exam_catalog.py + routes_exam.py + include GREEN
+- [ ] Step 3: 前端页签/卡片 catalog 渲染(静态回退)
+- [ ] Step 4: 旧端点回归 + commit
+
+## Task 4: exam_trials 泛化表
+
+- [ ] Step 1: test_exam_trials.py RED(幂等/透传/备份往返)
+- [ ] Step 2: 表 + 函数 + 迁移 + _PROGRESS_TABLES + RestoreReq GREEN
+- [ ] Step 3: routes_a1_hoeren/lesen 回归
+- [ ] Step 4: commit
+
+## Task 5: 收口
+
+- [ ] Step 1: 全量回归差异表
+- [ ] Step 2: ledger 回填
+- [ ] Step 3: push
+- [ ] Step 4: PR
+- [ ] Step 5: docs commit
From 692e8ea7316a58025ac72cc50f76f71197620a67 Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 15:35:21 +0800
Subject: [PATCH 03/12] =?UTF-8?q?feat(ia):=20=E5=A4=87=E8=80=83=E5=9F=9F?=
=?UTF-8?q?=E9=AA=A8=E6=9E=B6=E4=B8=8E=E5=AF=BC=E8=88=AA=E5=85=A5=E5=8F=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 桌面 nav + 移动 dock 静态加「备考 (PRÜFUNG)」双端入口(nav-btn-exam/mob-btn-exam)。
- 新建 view-exam 壳:exam-level-tabs(A1 占位) + exam-module-grid 五模块占位卡片
(写作/听力/阅读/口语/词表)——置于 view-german 之前不破 wb split 切块断言。
- style.css 新增 .exam-* 最小样式(复用既有 paper 体系变量,纯插入)。
- show('exam') 零 JS 改动(main.js view-id 惯例天然点亮)。
- test_exam_domain.py 11 条契约测试(含顺序/配平/死测防恒真切片)。
- 定向回归 128 passed(基线 117 + 新增 11)。
---
static/index.html | 32 ++++++++++++
static/style.css | 78 ++++++++++++++++++++++++++++
test_exam_domain.py | 123 ++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 233 insertions(+)
create mode 100644 test_exam_domain.py
diff --git a/static/index.html b/static/index.html
index 8bc9637..f2c978a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -60,6 +60,10 @@
SCHREIBTISCH
写作润色
+
+
+ 🎯
+ 备考
+
壳(等级页签条 +
+模块卡片区占位)。零 JS 改动 —— main.js 的 show(view) 是 view-id
+惯例(querySelectorAll(".view") + nav-btn-/mob-btn- 点亮),
+show('exam') 天然工作。
+
+与 test_german_workbench.py / test_prep_matrix.py 前端段同款模式:
+源码字符串匹配,不渲染 DOM。断言切到各自按钮/容器的**自身标签**
+内,不做整文件级别的模糊匹配(本仓库 static-string-assertion-dead-test
+教训)。
+"""
+import re
+from pathlib import Path
+
+_ROOT = Path(__file__).parent
+_INDEX = (_ROOT / "static" / "index.html").read_text(encoding="utf-8")
+_CSS = (_ROOT / "static" / "style.css").read_text(encoding="utf-8")
+
+EXAM_VIEW_IDS = (
+ "exam-card-writing",
+ "exam-card-hoeren",
+ "exam-card-lesen",
+ "exam-card-sprechen",
+ "exam-card-vocab",
+)
+
+
+def _button_block(id_attr):
+ """index.html 里给定 id 的 自身开标签(到第一个 > 为止)。
+
+ 钉开标签内的 onclick —— 把按钮挪出 nav/dock、或 onclick 写丢,
+ 切片里看不到就红;写进别的按钮不算。
+ """
+ m = re.search(r"]*\b%s\b[^>]*>" % re.escape(id_attr), _INDEX, re.S)
+ assert m, "index.html 里找不到 id=%r 的按钮" % id_attr
+ return m.group(0)
+
+
+# ── 桌面 nav + 移动 dock 的静态备考入口 ─────────────────────────────────────
+
+def test_desktop_nav_has_static_exam_button():
+ """桌面 nav 有 nav-btn-exam,且 onclick 指向 show('exam')。"""
+ blk = _button_block("nav-btn-exam")
+ assert "show('exam')" in blk, "nav-btn-exam 的 onclick 必须含 show('exam')"
+
+
+def test_desktop_nav_exam_button_ordered():
+ """备考按钮必须插在 writer 之后、cards 之前(只插入不改既有按钮)。"""
+ assert _INDEX.index('id="nav-btn-writer"') < _INDEX.index('id="nav-btn-exam"') < _INDEX.index('id="nav-btn-cards"')
+
+
+def test_mobile_dock_has_static_exam_button():
+ """移动 dock 有 mob-btn-exam,onclick 同指 show('exam')。"""
+ blk = _button_block("mob-btn-exam")
+ assert "show('exam')" in blk, "mob-btn-exam 的 onclick 必须含 show('exam')"
+
+
+def test_mobile_dock_exam_button_ordered():
+ """dock 备考按钮在 writer 之后、cards 之前(与既有 dock 顺序同构)。"""
+ assert _INDEX.index('id="mob-btn-writer"') < _INDEX.index('id="mob-btn-exam"') < _INDEX.index('id="mob-btn-cards"')
+
+
+# ── view-exam 容器壳 ────────────────────────────────────────────────────────
+
+def test_view_exam_shell_exists():
+ """有 壳(main.js show() 按惯例点亮)。"""
+ m = re.search(r']*\bid="view-exam"[^>]*>', _INDEX)
+ assert m, "index.html 缺少 壳"
+ blk = m.group(0)
+ assert "view" in blk.split("class=")[1], "view-exam 的 class 必须含 view(否则 show() 管不到它)"
+
+
+def test_view_exam_shell_before_german_view():
+ """view-exam 必须在 view-german 之前(不破 test_german_workbench 的 split 切块,
+ 且置于 progress 之后 —— 插入式改动不挪任何既有视图)。"""
+ exam_at = _INDEX.index('id="view-exam"')
+ german_at = _INDEX.index('id="view-german"')
+ assert exam_at < german_at, "view-exam 必须置于 view-german 之前"
+ assert _INDEX.index('id="view-progress"') < exam_at, "view-exam 必须置于 view-progress 之后"
+
+
+def test_exam_domain_containers_exist():
+ """等级页签条 + 模块卡片区容器齐全(Task 2 起面板挂进来的锚点)。"""
+ for attr in ("exam-level-tabs", "exam-level-a1", "exam-module-grid"):
+ assert 'id="%s"' % attr in _INDEX, "view-exam 缺少 id=%r" % attr
+
+
+def test_exam_module_cards_all_present():
+ """五张模块卡片 id 齐全(写作/听力/阅读/口语/词表,本 Task 占位)。"""
+ for attr in EXAM_VIEW_IDS:
+ assert 'id="%s"' % attr in _INDEX, "view-exam 缺少模块卡片 id=%r" % attr
+
+
+def test_exam_module_cards_inside_module_grid():
+ """五张卡片必须写在 exam-module-grid 容器**内部** —— 挂到容器外的占位
+ 不属于骨架本 Task 的产出(grid 选择器也管不到)。"""
+ grid_at = _INDEX.index('id="exam-module-grid"')
+ # 容器内 = 自 grid 开标签起、到 grid 的闭合 止(骨架无嵌套 div,取第一个闭合即容器自身)
+ blk = _INDEX[grid_at:_INDEX.index("", grid_at)]
+ for attr in EXAM_VIEW_IDS:
+ assert 'id="%s"' % attr in blk, "模块卡片 %s 必须写在 exam-module-grid 内" % attr
+
+
+# ── style.css 最小样式 ──────────────────────────────────────────────────────
+
+def test_exam_css_rules_exist():
+ """style.css 有 .exam-level-tab 与 .exam-module-card 规则(切到规则自己的声明块)。"""
+ rules = [(m.group(1).strip(), m.group(2)) for m in re.finditer(r"([^{}]*)\{([^{}]*)\}", _CSS)]
+ for sel in (".exam-level-tab", ".exam-module-card"):
+ hits = [s for s, _ in rules if sel in s]
+ assert hits, "style.css 缺少 %s 规则" % sel
+
+
+def test_exam_css_grid_uses_auto_fill():
+ """.exam-module-grid 必须是自适应 grid(桌面 200px 起格,移动窄屏同款规则天然收列)。"""
+ rules = [(m.group(1).strip(), m.group(2)) for m in re.finditer(r"([^{}]*)\{([^{}]*)\}", _CSS)]
+ grids = [d for s, d in rules if ".exam-module-grid" in s and "grid" in d]
+ assert grids, "style.css 缺少 .exam-module-grid 的 grid 规则"
+ assert any("repeat(auto-fill" in d and "minmax(" in d for d in grids), (
+ "模块卡片区必须是 auto-fill + minmax 自适应 grid,实际:%r" % grids
+ )
From d68a83430bd5704dc9cb5e7c6fcd9bc7aee18d46 Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 15:36:06 +0800
Subject: [PATCH 04/12] =?UTF-8?q?docs(ia):=20Task=201=20ledger=20=E5=9B=9E?=
=?UTF-8?q?=E5=A1=AB=EF=BC=88=E9=AA=A8=E6=9E=B6=20692e8ea=20+=20=E8=AF=84?=
=?UTF-8?q?=E5=AE=A1=20PASS=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
index a439fa2..ea0f1df 100644
--- a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
+++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
@@ -46,10 +46,10 @@
## Task 1: 备考域骨架 + 静态导航入口
-- [ ] Step 1: RED 测试(exam 入口五断言)
-- [ ] Step 2: index.html 静态双端按钮 + view-exam 壳
-- [ ] Step 3: GREEN + 定向回归
-- [ ] Step 4: 原子 commit
+- [x] Step 1: RED 测试(test_exam_domain.py 11 条,RED 证据 FFFFFFFFFFF)
+- [x] Step 2: index.html 静态双端按钮 + view-exam 壳(纯插入 +110 行)
+- [x] Step 3: GREEN + 定向回归 128 passed(基线 117 + 新增 11)
+- [x] Step 4: 原子 commit `692e8ea`(评审 PASS:纯插入零删除/断言防恒真切片/配平验证)
## Task 2: A1 五模块迁入备考域
From db80212248ff41ae03fa71a1e754e8012f95d8ff Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 17:05:25 +0800
Subject: [PATCH 05/12] =?UTF-8?q?feat(ia):=20A1=20=E4=BA=94=E6=A8=A1?=
=?UTF-8?q?=E5=9D=97=E8=BF=81=E5=85=A5=E5=A4=87=E8=80=83=E5=9F=9F=E5=B9=B6?=
=?UTF-8?q?=E6=B8=85=E7=90=86=E5=B7=A5=E5=85=B7=E8=A7=86=E5=9B=BE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- view-exam 收拢五面板:写作(exam-writing + setExamWritingTab 页签)、
词表/口语(exam-cards-container + 自管 toggle)、听力/阅读(原容器 id 搬入)。
- view-writer 回归纯 essay(writer-mode-a1-* 删除,switchWriterMode 收敛 no-op 防悬空);
view-cards 回归纯复习四段(seg-a1 删除、renderCardsGrid a1 分流删净)。
- a1_cards.js 渲染目标 a1CardsHost() 改指 exam 容器(回退主站 cards-container 即探针红);
setA1Mode 加冷缓存懒加载守卫(等待期 mode 竞态防护)。
- main.js show() 守卫改 view !== "exam"(停考计时器)、exam 分支 loadCards +
setExamModule mediator(页签⇄面板路由);exposer 补挂 3 函数(含 import 链核对)。
- 新探针 tools/ia_dom_mount_probe.mjs(node:vm 真跑 4 场景,含变异验证 7 条回退必红)。
- 测试同步:seg-a1→exam-card-vocab、writer-mode-a1-*→exam-tab-*(ledger 登记项)。
- 回归 537 passed(基线 518+11+8)+ 10/10 探针全绿;评审 PASS(悬空标识符/双现/探针质量核查)。
---
static/index.html | 546 ++++++++++++++++++-----------------
static/js/a1_cards.js | 52 +++-
static/js/a1_writer.js | 36 ++-
static/js/cards.js | 30 +-
static/js/main.js | 52 +++-
static/js/writer.js | 1 +
test_exam_domain.py | 163 ++++++++++-
test_goethe_a1.py | 9 +-
test_goethe_a1_writing.py | 10 +-
tools/ia_dom_mount_probe.mjs | 453 +++++++++++++++++++++++++++++
10 files changed, 1025 insertions(+), 327 deletions(-)
create mode 100644 tools/ia_dom_mount_probe.mjs
diff --git a/static/index.html b/static/index.html
index f2c978a..412607a 100644
--- a/static/index.html
+++ b/static/index.html
@@ -309,15 +309,6 @@ 卡片库 · KARTEIKARTEN
>
Präpositionen –
-
- 🌟 歌德 A1 速通
- 650+
-
@@ -380,65 +371,7 @@
卡片库 · KARTEIKARTEN
/>
-
-
-
-
@@ -957,197 +890,6 @@ 德语写作诊断与语法润色
>
✍️ 自由写作与研磨 (Freies Schreiben)
-
- 📝 歌德 A1 Teil 1 填表专项 (Formular-Training)
-
-
- ✉️ 歌德 A1 Teil 2 30词短电邮 (E-Mail/Brief Lab)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ◀ 上一题
-
-
- 🎲 随机场景
-
-
- 下一题 ▶
-
-
-
-
-
-
-
-
-
- 📋 考场题目要求 (Aufgabenstellung)
-
-
-
-
- 🎯 必须覆盖的 3 个导向点 (3 Leitpunkte):
-
-
-
-
-
-
-
-
- 字数统计:
- 0 词
- 建议 25~35 词
-
-
- 称呼规范:
- 待输入
-
-
- 结语标点:
- 待输入
-
-
-
-
-
-
-
- ⚡ 智能诊断与判分
-
-
- 📋 套用考场满分模板
-
-
- 🗑️ 清空
-
-
-
-
-
-
-
-
-
- 🌟 官方考纲满分范文 (Musterbrief)
-
-
-
-
- 💬 本场景高频黄金句型
-
-
-
-
@@ -2648,12 +2390,290 @@ 备考 · PRÜFUNGSDOMÄNE
A1
- 📝 写作专项 (Schreiben)
- 🎧 听力模考 (Hörverstehen)
- 📖 阅读工坊 (Leseverstehen)
- 💬 口语问答 (Sprechen)
- 📖 官方考纲词表 (Wortliste)
+ 📝 写作专项 (Schreiben)
+ 🎧 听力模考 (Hörverstehen)
+ 📖 阅读工坊 (Leseverstehen)
+ 💬 口语问答 (Sprechen)
+ 📖 官方考纲词表 (Wortliste)
+
+
+
+
+
+ 📝 歌德 A1 Teil 1 填表专项 (Formular-Training)
+
+
+ ✉️ 歌德 A1 Teil 2 30词短电邮 (E-Mail/Brief Lab)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ◀ 上一题
+
+
+ 🎲 随机场景
+
+
+ 下一题 ▶
+
+
+
+
+
+
+
+
+
+ 📋 考场题目要求 (Aufgabenstellung)
+
+
+
+
+ 🎯 必须覆盖的 3 个导向点 (3 Leitpunkte):
+
+
+
+
+
+
+
+
+ 字数统计:
+ 0 词
+ 建议 25~35 词
+
+
+ 称呼规范:
+ 待输入
+
+
+ 结语标点:
+ 待输入
+
+
+
+
+
+
+
+ ⚡ 智能诊断与判分
+
+
+ 📋 套用考场满分模板
+
+
+ 🗑️ 清空
+
+
+
+
+
+
+
+
+
+ 🌟 官方考纲满分范文 (Musterbrief)
+
+
+
+
+ 💬 本场景高频黄金句型
+
+
+
+
+
+
+
+
+
+
+
+
+ 🎴 扑克牌盒
+
+
+ 📑 全景目录
+
+
+
+
+
+
+
diff --git a/static/js/a1_cards.js b/static/js/a1_cards.js
index c84c879..0a9bd52 100644
--- a/static/js/a1_cards.js
+++ b/static/js/a1_cards.js
@@ -5,7 +5,7 @@ import { state, api, esc, jsAttr } from "./core.js";
import { playGermanAudio } from "./player.js";
import { refreshCardCounters } from "./reader.js";
import { Companion } from "./companion.js";
-import { getCardViewMode, getCachedVocabLemmas } from "./cards.js";
+import { getCachedVocabLemmas } from "./cards.js";
import { initA1Hoeren, stopHoerenExam } from "./a1_hoeren.js";
import { initA1Lesen, stopLesenExam } from "./a1_lesen.js";
@@ -29,6 +29,27 @@ export function isA1DataLoaded() {
}
// ── Goethe-Zertifikat A1 Wortliste & Sprechen Lab ────────────────────────────
+// ADR-0005 Task 2:A1 词表/口语面板宿主已迁到 view-exam,渲染目标统一为
+// 备考域容器 #exam-cards-container(主站 #cards-container 只归复习卡盒)。
+const a1CardsHost = () => document.getElementById("exam-cards-container");
+
+// 牌盒/目录视图模式。主站 cards.js 的同款状态对 A1 不再生效(两个视图
+// 各自独立),本模块自管一份,toggle 按钮也在备考域容器内
+// (#exam-cards-view-toggle 里的 exam-mode-btn-*)。cards.js 的
+// getCardViewMode 桩仍在(探针 vm 注入 & 潜在旧调用兜底),但本模块
+// 的判定只看自己的 a1ViewMode。
+let a1ViewMode = "deck";
+
+export function setA1CardViewMode(mode) {
+ a1ViewMode = mode;
+ document
+ .getElementById("exam-mode-btn-deck")
+ ?.classList.toggle("active", mode === "deck");
+ document
+ .getElementById("exam-mode-btn-grid")
+ ?.classList.toggle("active", mode === "grid");
+ renderA1();
+}
export async function loadA1Data() {
try {
@@ -87,8 +108,8 @@ export function setA1Mode(mode) {
const searchRow = document.getElementById("a1-search-row");
const pillsRow = document.getElementById("a1-topic-pills");
- const viewToggle = document.querySelector(".cards-view-toggle");
- const cardsContainer = document.getElementById("cards-container");
+ const viewToggle = document.getElementById("exam-cards-view-toggle");
+ const cardsContainer = a1CardsHost();
const hoerenContainer = document.getElementById("a1-hoeren-container");
const lesenContainer = document.getElementById("a1-lesen-container");
@@ -133,6 +154,19 @@ export function setA1Mode(mode) {
viewToggle?.classList.add("hidden");
}
+ // 题库懒加载:原 cards.js 'a1' 段的「未加载先 fetch 再渲染」链路随分流段
+ // 一起迁走。备考域入口 setExamModule → setA1Mode,这里补上同语义守卫,
+ // 否则首次进入 vocab/口语会拿空缓存渲染成「未找到考纲词汇」空态。
+ if (!isA1DataLoaded()) {
+ loadA1Data()
+ .then(() => {
+ // 守卫:等待期间用户可能已切到 hoeren/lesen(它们不走本渲染路径)。
+ if (a1Mode === "vocab" || a1Mode === "teil2" || a1Mode === "teil3") renderA1();
+ })
+ .catch(() => {});
+ return;
+ }
+
renderA1();
}
@@ -201,11 +235,11 @@ export function getA1CurrentList() {
}
export function renderA1() {
- const container = document.getElementById("cards-container");
+ const container = a1CardsHost();
if (!container) return;
if (a1Mode === "vocab") {
- if (getCardViewMode() === "deck") {
+ if (a1ViewMode === "deck") {
renderA1PokerCard();
} else {
renderA1GridView();
@@ -218,7 +252,7 @@ export function renderA1() {
}
export function renderA1PokerCard() {
- const container = document.getElementById("cards-container");
+ const container = a1CardsHost();
if (!container) return;
const list = getA1CurrentList();
@@ -343,7 +377,7 @@ export function renderA1PokerCard() {
}
export function renderA1GridView() {
- const container = document.getElementById("cards-container");
+ const container = a1CardsHost();
if (!container) return;
const list = getA1CurrentList();
@@ -399,7 +433,7 @@ export function renderA1GridView() {
}
export function renderA1Teil2Deck() {
- const container = document.getElementById("cards-container");
+ const container = a1CardsHost();
if (!container) return;
const list = getA1CurrentList();
@@ -482,7 +516,7 @@ export function renderA1Teil2Deck() {
}
export function renderA1Teil3Deck() {
- const container = document.getElementById("cards-container");
+ const container = a1CardsHost();
if (!container) return;
const list = getA1CurrentList();
diff --git a/static/js/a1_writer.js b/static/js/a1_writer.js
index fcccf12..494828d 100644
--- a/static/js/a1_writer.js
+++ b/static/js/a1_writer.js
@@ -6,41 +6,34 @@ import { Companion } from './companion.js';
// ── Goethe A1 Schreiben Workshop Logic ───────────────────────────────────────
-let a1WritingMode = 'essay';
let a1Teil1List = [];
let a1Teil2List = [];
let currentA1Teil1Idx = 0;
let currentA1Teil2Idx = 0;
let a1EmailDebounceTimer = null;
-export function switchWriterMode(mode) {
- a1WritingMode = mode;
-
- ['essay', 'formular', 'email'].forEach((m) => {
- const btn = document.getElementById(
- `writer-mode-${m === 'essay' ? 'essay' : 'a1-' + m}`
- );
- if (btn) btn.classList.toggle('active', m === mode);
+// ADR-0005 Task 2:writer 视图回归纯 essay 工具,A1 写作面板宿主改为
+// view-exam 的 exam-writing 面板。本模块只保留「exam 写作页签切换」语义:
+// formular/email 二选一显隐 + 懒加载题库。view-writer 的 switchWriterMode
+// 只剩 essay 单按钮(见下),不再认识 formular/email。
+export function setExamWritingTab(tab) {
+ ['formular', 'email'].forEach((m) => {
+ const btn = document.getElementById(`exam-tab-${m}`);
+ if (btn) btn.classList.toggle('active', m === tab);
});
- const freeLeft = document.getElementById('writer-free-left');
- const panel = document.getElementById('writer-panel');
const formularView = document.getElementById('a1-formular-view');
const emailView = document.getElementById('a1-email-view');
+ if (formularView) formularView.classList.toggle('hidden', tab !== 'formular');
+ if (emailView) emailView.classList.toggle('hidden', tab !== 'email');
- if (freeLeft) freeLeft.classList.toggle('hidden', mode !== 'essay');
- if (panel) panel.classList.toggle('hidden', mode !== 'essay');
- if (formularView)
- formularView.classList.toggle('hidden', mode !== 'formular');
- if (emailView) emailView.classList.toggle('hidden', mode !== 'email');
-
- if (mode === 'formular') {
+ if (tab === 'formular') {
if (!a1Teil1List.length) {
loadA1WritingData().then(() => renderA1Formular());
} else {
renderA1Formular();
}
- } else if (mode === 'email') {
+ } else if (tab === 'email') {
if (!a1Teil2List.length) {
loadA1WritingData().then(() => renderA1Email());
} else {
@@ -49,6 +42,11 @@ export function switchWriterMode(mode) {
}
}
+// view-writer 的唯一模式:纯 essay。按钮条只剩 essay 单按钮(HTML 默认
+// active),无模式可切。函数与 onclick 保留(防止引用悬空 → v4.8.2
+// 悬空标识符教训),行为等价 no-op;A1 分支已移交 setExamWritingTab。
+export function switchWriterMode() {}
+
export async function loadA1WritingData() {
try {
const [t1, t2] = await Promise.all([
diff --git a/static/js/cards.js b/static/js/cards.js
index 6fb9159..d8a5550 100644
--- a/static/js/cards.js
+++ b/static/js/cards.js
@@ -5,7 +5,6 @@ import { state, esc, jsAttr, api, notify } from "./core.js";
import { playGermanAudio } from "./player.js";
import { Companion } from "./companion.js";
import { refreshCardCounters } from "./reader.js";
-import * as A1Cards from "./a1_cards.js";
export {
setA1Mode,
filterA1Topic,
@@ -36,9 +35,10 @@ let undoToastTimer = null;
let _lastFlipTime = 0;
// a1_cards.js 是独立模块,读不到本模块作用域。这两个 getter 给它提供
-// 只读访问,避免循环 import 具名 state(renderA1 判定牌盒/目录、
-// loadA1Data 判定已入 FSRS 盒的词元)。与 cards.js 侧调用
-// A1Cards.getA1Mode() 是同一个方向的反向配对。
+// 只读访问,避免循环 import 具名 state(getCachedVocabLemmas 判定已入
+// FSRS 盒的词元)。A1 的牌盒/目录判定已由 a1_cards.js 自管的
+// a1ViewMode 接管(ADR-0005 Task 2),getCardViewMode 不再被它消费,
+// 仅保留给主站 toggle 状态查询。
export function getCardViewMode() {
return cardViewMode;
}
@@ -53,7 +53,7 @@ export function setCardSegment(seg) {
cardSegment = seg;
deckIndex = 0;
deckFlipped = false;
- ['due', 'pending', 'mastered', 'prep', 'a1'].forEach(s => {
+ ['due', 'pending', 'mastered', 'prep'].forEach(s => {
const btn = document.getElementById('seg-' + s);
if (btn) btn.classList.toggle('active', s === seg);
});
@@ -113,22 +113,10 @@ export async function refreshDueCount() {
}
export function renderCardsGrid() {
- if (cardSegment === 'a1') {
- document.getElementById('prep-filter-bar')?.classList.add('hidden');
- document.getElementById('a1-toolbar')?.classList.remove('hidden');
- if (A1Cards.getA1Mode() === 'vocab') {
- document.querySelector('.cards-view-toggle')?.classList.remove('hidden');
- } else {
- document.querySelector('.cards-view-toggle')?.classList.add('hidden');
- }
- if (!A1Cards.isA1DataLoaded()) {
- A1Cards.loadA1Data().then(A1Cards.renderA1);
- } else {
- A1Cards.renderA1();
- }
- return;
- }
- document.getElementById('a1-toolbar')?.classList.add('hidden');
+ // ADR-0005 Task 2:'a1' 段已从卡盒移除 —— A1 词表/口语/听力/阅读整体迁到
+ // 备考域(view-exam + a1_cards.js 自管渲染,入口 exam-card-* 页签)。
+ // seg-a1 按钮同步删除,本函数不再认识 'a1' 这个段。
+ document.getElementById('prep-filter-bar')?.classList.add('hidden');
// 介词矩阵段跟其余三段的数据源完全无关(不是 cachedCards 的过滤视图),
// 所以在算 vList/gList 之前就分流,免得白跑一遍过滤。
diff --git a/static/js/main.js b/static/js/main.js
index 18f05f7..226b189 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -75,6 +75,7 @@ import {
saveA1WordToDeck,
renderA1PokerCard,
playA1Audio,
+ setA1CardViewMode,
} from "./cards.js";
import {
switchFolioPage,
@@ -128,6 +129,7 @@ import {
toggleWriterMobilePanel,
closeWriterMobilePanel,
switchWriterMode,
+ setExamWritingTab,
selectA1Formular,
prevA1Formular,
nextA1Formular,
@@ -193,7 +195,7 @@ export function show(view) {
// 模块加载失败时不挂、不影响其它视图切换。
if (typeof closeWriterMobilePanel === "function") closeWriterMobilePanel();
- if (view !== "cards") {
+ if (view !== "exam") {
if (typeof A1Hoeren?.stopHoerenExam === "function")
A1Hoeren.stopHoerenExam();
if (typeof A1Lesen?.stopLesenExam === "function") A1Lesen.stopLesenExam();
@@ -207,6 +209,12 @@ export function show(view) {
if (view === "home") loadArticles();
if (view === "cards") loadCards();
+ // 备考域入口(ADR-0005 Task 2):A1 五模块宿主在 view-exam。徽标计数与
+ // 复习盒缓存复用 loadCards(幂等 fetch,cards.js 与 a1_cards.js 都消费它)。
+ if (view === "exam") {
+ loadCards();
+ setExamModule();
+ }
if (view === "progress") loadProgress();
if (view === "writer") {
loadWriterEssays();
@@ -214,6 +222,45 @@ export function show(view) {
}
}
+// ── Exam Domain Module Tabs (ADR-0005 Task 2) ───────────────────────────────
+// exam-card-* 五个页签 ⇄ 两个面板 section 的显隐路由。写在 main.js:
+// 它同时需要 setA1Mode(cards.js 链)与 setExamWritingTab(writer.js 链),
+// a1_cards.js / a1_writer.js 互不 import, mediator 只能住根模块。
+let _examModule = "writing";
+
+export function setExamModule(mod) {
+ // 无参调用(show('exam') 进场)= 回到上次停留的模块;显式点击才换模块。
+ if (mod) _examModule = mod;
+
+ const writingPanel = document.getElementById("exam-writing");
+ const familyPanel = document.getElementById("exam-cards-family");
+ const isWriting = _examModule === "writing";
+ if (writingPanel) writingPanel.classList.toggle("hidden", !isWriting);
+ if (familyPanel) familyPanel.classList.toggle("hidden", isWriting);
+
+ [
+ ["writing", "exam-card-writing"],
+ ["hoeren", "exam-card-hoeren"],
+ ["lesen", "exam-card-lesen"],
+ ["sprechen", "exam-card-sprechen"],
+ ["vocab", "exam-card-vocab"],
+ ].forEach(([m, btnId]) => {
+ document
+ .getElementById(btnId)
+ ?.classList.toggle("active", m === _examModule);
+ });
+
+ if (_examModule === "hoeren" || _examModule === "lesen") {
+ setA1Mode(_examModule);
+ } else if (_examModule === "sprechen") {
+ setA1Mode("teil2");
+ } else if (_examModule === "vocab") {
+ setA1Mode("vocab");
+ } else if (isWriting) {
+ setExamWritingTab("formular");
+ }
+}
+
// ── Import Modal ─────────────────────────────────────────────────────────────
let currentImportTab = "text";
let cachedFeedSources = [];
@@ -779,6 +826,7 @@ Object.assign(window, {
// Cards & Deck
setCardSegment,
setCardViewMode,
+ setA1CardViewMode,
loadCards,
toggleDeckFlip,
stepDeck,
@@ -865,6 +913,8 @@ Object.assign(window, {
toggleWriterMobilePanel,
closeWriterMobilePanel,
switchWriterMode,
+ setExamModule,
+ setExamWritingTab,
selectA1Formular,
prevA1Formular,
nextA1Formular,
diff --git a/static/js/writer.js b/static/js/writer.js
index 203ddfc..35c46d1 100644
--- a/static/js/writer.js
+++ b/static/js/writer.js
@@ -6,6 +6,7 @@ import { refreshCardCounters } from './reader.js';
import { Companion } from './companion.js';
import * as A1Writer from './a1_writer.js';
export {
+ setExamWritingTab,
switchWriterMode,
loadA1WritingData,
populateA1Selectors,
diff --git a/test_exam_domain.py b/test_exam_domain.py
index d7ed0f2..c412c05 100644
--- a/test_exam_domain.py
+++ b/test_exam_domain.py
@@ -1,24 +1,32 @@
-"""备考域 (Prüfungsbereich) 骨架的静态契约 —— ADR-0005 Task 1。
+"""备考域 (Prüfungsbereich) 的静态与动态契约 —— ADR-0005 Task 1/2。
-本 Task 只加「骨架」:桌面 nav + 移动 dock 各一个静态备考按钮、
+Task 1 只加「骨架」:桌面 nav + 移动 dock 各一个静态备考按钮、
index.html 一个 壳(等级页签条 +
模块卡片区占位)。零 JS 改动 —— main.js 的 show(view) 是 view-id
惯例(querySelectorAll(".view") + nav-btn-/mob-btn- 点亮),
show('exam') 天然工作。
+Task 2 起备考域接管 A1 五模块:写作(formular/email)/听力/阅读/口语/词表
+面板从 view-writer / view-cards 迁入,view-exam 内换成模块页签 + 面板
+容器结构(exam-card-* 保留为模块选中页签)。
+
与 test_german_workbench.py / test_prep_matrix.py 前端段同款模式:
-源码字符串匹配,不渲染 DOM。断言切到各自按钮/容器的**自身标签**
-内,不做整文件级别的模糊匹配(本仓库 static-string-assertion-dead-test
-教训)。
+源码字符串匹配 + node:vm 动态探针(tools/ia_dom_mount_probe.mjs),
+断言切到各自按钮/容器的**自身标签**内,不做整文件级别的模糊匹配
+(本仓库 static-string-assertion-dead-test 教训)。
"""
+import json
import re
+import shutil
+import subprocess
from pathlib import Path
_ROOT = Path(__file__).parent
_INDEX = (_ROOT / "static" / "index.html").read_text(encoding="utf-8")
_CSS = (_ROOT / "static" / "style.css").read_text(encoding="utf-8")
+_MAIN_JS = (_ROOT / "static" / "js" / "main.js").read_text(encoding="utf-8")
-EXAM_VIEW_IDS = (
+EXAM_MODULE_IDS = (
"exam-card-writing",
"exam-card-hoeren",
"exam-card-lesen",
@@ -26,6 +34,14 @@
"exam-card-vocab",
)
+# Task 2:五个模块面板容器(exam-card-* 页签点击点亮的目标)
+EXAM_PANEL_IDS = (
+ "exam-writing",
+ "exam-cards-family",
+)
+# 备考域接管后,工具视图不得再出现 A1 id(切片级断言见 test_*_slice_forbids)
+TOOL_VIEW_FORBIDDEN = ("seg-a1", "writer-mode-a1-formular", "writer-mode-a1-email")
+
def _button_block(id_attr):
"""index.html 里给定 id 的 自身开标签(到第一个 > 为止)。
@@ -88,8 +104,8 @@ def test_exam_domain_containers_exist():
def test_exam_module_cards_all_present():
- """五张模块卡片 id 齐全(写作/听力/阅读/口语/词表,本 Task 占位)。"""
- for attr in EXAM_VIEW_IDS:
+ """五张模块卡片 id 齐全(写作/听力/阅读/口语/词表,Task 2 起充当模块页签)。"""
+ for attr in EXAM_MODULE_IDS:
assert 'id="%s"' % attr in _INDEX, "view-exam 缺少模块卡片 id=%r" % attr
@@ -99,10 +115,29 @@ def test_exam_module_cards_inside_module_grid():
grid_at = _INDEX.index('id="exam-module-grid"')
# 容器内 = 自 grid 开标签起、到 grid 的闭合 止(骨架无嵌套 div,取第一个闭合即容器自身)
blk = _INDEX[grid_at:_INDEX.index("", grid_at)]
- for attr in EXAM_VIEW_IDS:
+ for attr in EXAM_MODULE_IDS:
assert 'id="%s"' % attr in blk, "模块卡片 %s 必须写在 exam-module-grid 内" % attr
+def test_exam_module_cards_wired_to_panels():
+ """五张模块卡片必须 onclick 调 setExamModule 并指向对应面板/页签。
+
+ Task 2 起它们是「模块选中页签」:点它显示对应面板。只有按钮没有
+ 接线 = 死按钮(static-string-assertion-dead-test 教训:按钮存在
+ 不等于可点)。"""
+ for attr, target in (
+ ("exam-card-writing", "'writing'"),
+ ("exam-card-hoeren", "'hoeren'"),
+ ("exam-card-lesen", "'lesen'"),
+ ("exam-card-sprechen", "'sprechen'"),
+ ("exam-card-vocab", "'vocab'"),
+ ):
+ blk = _button_block(attr)
+ assert "setExamModule(%s)" % target in blk, (
+ "模块卡片 %s 的 onclick 必须含 setExamModule(%s)" % (attr, target)
+ )
+
+
# ── style.css 最小样式 ──────────────────────────────────────────────────────
def test_exam_css_rules_exist():
@@ -121,3 +156,113 @@ def test_exam_css_grid_uses_auto_fill():
assert any("repeat(auto-fill" in d and "minmax(" in d for d in grids), (
"模块卡片区必须是 auto-fill + minmax 自适应 grid,实际:%r" % grids
)
+
+
+# ── Task 2:A1 五模块迁入备考域(面板容器 + 工具视图删净 + 探针) ────────────
+
+def _view_slice(id_attr):
+ """ 不嵌套:视图切片 = 自带 id 的 。"""
+ at = _INDEX.index('id="%s"' % id_attr)
+ open_at = _INDEX.rindex("", at)]
+
+
+def _section_slice(id_attr):
+ at = _INDEX.index('id="%s"' % id_attr)
+ open_at = _INDEX.rindex("", at)]
+
+
+def test_exam_panel_containers_exist():
+ """五个模块面板容器齐全(Task 2 挂 A1 面板的锚点)。"""
+ for attr in EXAM_PANEL_IDS:
+ assert 'id="%s"' % attr in _INDEX, "view-exam 缺少模块面板容器 id=%r" % attr
+
+
+def test_exam_writing_panel_holds_a1_writing():
+ """exam-writing 面板内必须有写作页签 + formular/email 两个子面板。"""
+ blk = _section_slice("exam-writing")
+ for attr in ("exam-tab-formular", "exam-tab-email", "a1-formular-view", "a1-email-view"):
+ assert 'id="%s"' % attr in blk, "exam-writing 缺少 id=%r" % attr
+ # 页签接线:onclick 调 setExamWritingTab(存在不等于可点)
+ tab_blk = _button_block("exam-tab-formular")
+ assert "setExamWritingTab('formular')" in tab_blk, "exam-tab-formular 必须调 setExamWritingTab('formular')"
+ tab_blk = _button_block("exam-tab-email")
+ assert "setExamWritingTab('email')" in tab_blk, "exam-tab-email 必须调 setExamWritingTab('email')"
+
+
+def test_exam_cards_family_panel_holds_a1_modules():
+ """exam-cards-family 面板内必须有词表工具栏 + 备考域渲染容器 + 听读容器。"""
+ blk = _section_slice("exam-cards-family")
+ for attr in (
+ "a1-toolbar", "a1-topic-pills", "a1-search-row",
+ "exam-cards-container", "exam-cards-view-toggle",
+ "a1-hoeren-container", "a1-lesen-container",
+ ):
+ assert 'id="%s"' % attr in blk, "exam-cards-family 缺少 id=%r" % attr
+
+
+def test_tool_views_no_longer_host_a1():
+ """view-writer / view-cards 切片内不得再有任何 A1 id(迁出必须删净)。
+
+ 漏删 = 同 id 双现,getElementById 挂载歧义 + 渲染进隐藏旧容器,
+ 页面照常渲染但交互全死(v4.8.2 同族症状)。"""
+ writer_blk = _view_slice("view-writer")
+ cards_blk = _view_slice("view-cards")
+ for attr in ("a1-formular-view", "a1-email-view", "writer-mode-a1-formular",
+ "writer-mode-a1-email", "a1-formular-select", "a1-email-input"):
+ assert attr not in writer_blk, "view-writer 仍残留 A1 写作 id=%r" % attr
+ assert 'id="writer-mode-essay"' in writer_blk, "view-writer 保留了纯 essay 单按钮条"
+ for attr in ("a1-toolbar", "a1-hoeren-container", "a1-lesen-container",
+ "seg-a1", "a1-tab-vocab", "a1-topic-pills"):
+ assert attr not in cards_blk, "view-cards 仍残留 A1 id=%r" % attr
+
+
+def test_moved_a1_ids_are_unique_in_index_html():
+ """id 唯一性铁律:每个被搬移的 id 全文件恰好 1 次。"""
+ for attr in (
+ "a1-formular-view", "a1-email-view", "a1-toolbar", "a1-topic-pills",
+ "a1-search-row", "exam-cards-container", "a1-hoeren-container",
+ "a1-lesen-container", "writer-mode-essay",
+ ):
+ n = _INDEX.count('id="%s"' % attr)
+ assert n == 1, 'id="%s" 全文件出现 %d 次(必须恰好 1 次,双现 = 挂载歧义)' % (attr, n)
+ assert "setCardSegment('a1')" not in _INDEX, "index.html 仍引用 setCardSegment('a1')(seg-a1 应删除)"
+ assert "switchWriterMode('formular')" not in _INDEX, "index.html 仍引用 switchWriterMode('formular')"
+ assert "switchWriterMode('email')" not in _INDEX, "index.html 仍引用 switchWriterMode('email')"
+
+
+def test_main_js_show_routes_exam_domain():
+ """show() 的备考域路由:离开 exam 停考计时器、进入 exam 渲染模块。"""
+ body = _MAIN_JS.split("export function show(")[1].split("\nexport ")[0]
+ assert 'if (view !== "exam")' in body, (
+ 'show() 缺 if (view !== "exam") 守卫 —— 离开备考域不停听力/阅读考试计时器'
+ )
+ assert 'if (view !== "cards")' not in body, (
+ 'show() 仍用 view !== "cards" 守卫停考计时器(备考域宿主已改 view-exam)'
+ )
+ assert 'if (view === "exam")' in body and "setExamModule" in body, (
+ 'show() 缺 view === "exam" 分支(进入备考域要点亮模块面板)'
+ )
+
+
+def test_exam_module_mount_probe():
+ """动态探针:view-exam 挂载 + 渲染目标回退必红(node:vm 真跑 a1_cards.js)。"""
+ if not shutil.which("node"):
+ import pytest
+ pytest.skip("node 不在 PATH 上,跳过动态探针")
+ probe = _ROOT / "tools" / "ia_dom_mount_probe.mjs"
+ assert probe.exists(), "缺少 tools/ia_dom_mount_probe.mjs 动态探针"
+ res = subprocess.run(
+ ["node", str(probe), "--json"],
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
+ cwd=str(_ROOT),
+ )
+ assert res.returncode == 0, "探针执行失败:\n%s\n%s" % (res.stdout, res.stderr)
+ out = json.loads(res.stdout)
+ assert out["ok"] is True
+ assert out["dynamic"]["mainCardsContainerUntouched"], (
+ "探针动态场景:renderA1 不得把词卡写进主站 #cards-container"
+ )
+ assert out["dynamic"]["vocabDeckTarget"] == "exam-cards-container"
+ assert out["dynamic"]["vocabGridTarget"] == "exam-cards-container"
diff --git a/test_goethe_a1.py b/test_goethe_a1.py
index 92cc430..f4b3a2b 100644
--- a/test_goethe_a1.py
+++ b/test_goethe_a1.py
@@ -121,11 +121,16 @@ def test_a1_anki_export_endpoint():
def test_a1_frontend_html_structure():
- """验证 static/index.html 中 A1 考纲速通分段与工具栏 DOM 结构。"""
+ """验证 static/index.html 中 A1 考纲速通分段与工具栏 DOM 结构。
+
+ ADR-0005 Task 2 起备考域接管 A1 入口:seg-a1 分段按钮删除
+ (替代入口 = view-exam 的 exam-card-vocab),工具栏 id 不变、
+ 原样迁入 view-exam。
+ """
with open("static/index.html", "r", encoding="utf-8") as f:
html = f.read()
- assert 'id="seg-a1"' in html
+ assert 'id="exam-card-vocab"' in html # A1 考纲入口现在在备考域
assert 'id="a1-toolbar"' in html
assert 'id="a1-topic-pills"' in html
assert 'id="a1-tab-vocab"' in html
diff --git a/test_goethe_a1_writing.py b/test_goethe_a1_writing.py
index 1e64079..7b5cb0e 100644
--- a/test_goethe_a1_writing.py
+++ b/test_goethe_a1_writing.py
@@ -198,12 +198,16 @@ def test_api_a1_schreiben_teil2_endpoints():
def test_a1_writing_frontend_html_and_css():
- """验证 static/index.html 与 static/style.css 中 A1 写作工坊 DOM 与样式契约。"""
+ """验证 static/index.html 与 static/style.css 中 A1 写作工坊 DOM 与样式契约。
+
+ ADR-0005 Task 2 起写作工坊迁入备考域:writer-mode-a1-* 按钮删除,
+ 页签改名 exam-tab-*(宿主 view-exam);面板 id 不变、原样搬移。
+ """
root = Path(__file__).resolve().parent
html = (root / "static" / "index.html").read_text(encoding="utf-8")
- assert 'id="writer-mode-a1-formular"' in html
- assert 'id="writer-mode-a1-email"' in html
+ assert 'id="exam-tab-formular"' in html
+ assert 'id="exam-tab-email"' in html
assert 'id="a1-formular-view"' in html
assert 'id="a1-email-view"' in html
diff --git a/tools/ia_dom_mount_probe.mjs b/tools/ia_dom_mount_probe.mjs
new file mode 100644
index 0000000..86bf558
--- /dev/null
+++ b/tools/ia_dom_mount_probe.mjs
@@ -0,0 +1,453 @@
+/**
+ * ia_dom_mount_probe.mjs —— ADR-0005 Task 2「A1 五模块迁入备考域」挂载契约探针
+ *
+ * 事故回归(2026-09-04):Task 2 把 A1 写作/听力/阅读/口语/词表五个面板从
+ * view-writer / view-cards 原子迁入 view-exam。本仓库两次前科(v4.7.0 空模块、
+ * v4.8.2 悬空标识符)证明:DOM id 挪窝 + 渲染目标没跟上 = 页面照常渲染、
+ * 内容静默渲进隐藏的旧容器,纯字符串存在断言("id=xxx" in html)对
+ * 「渲错了地方」全程绿。
+ *
+ * 本探针两层防线:
+ * 1) DOM 结构断言:五组 a1 面板 id 必须出现在 view-exam 切片内;
+ * view-writer / view-cards 切片内不得再有这些 id(同 id 双现即事故);
+ * 每个被搬移的 id 全文件唯一。
+ * 2) 行为断言(实现回退必红):把 a1_cards.js 真源码剥 import/export 后进
+ * node:vm 真跑,桩假 document 抓每笔 innerHTML 写入与 classList 操作 ——
+ * setA1Mode('vocab') 必须把词卡写进 #exam-cards-container、显隐
+ * #exam-cards-view-toggle,绝不允许回流主站 #cards-container /
+ * .cards-view-toggle。渲染目标回退旧实现本探针必红。
+ *
+ * 用法:
+ * node tools/ia_dom_mount_probe.mjs # 人类可读
+ * node tools/ia_dom_mount_probe.mjs --json # stdout 只输出 JSON(pytest 用)
+ * 契约被破坏时退出码 1、详情走 stderr。
+ */
+import fs from "node:fs";
+import path from "node:path";
+import vm from "node:vm";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, "..");
+const JSON_MODE = process.argv.includes("--json");
+
+const fail = (msg) => {
+ process.stderr.write(msg + "\n");
+ process.exit(1);
+};
+
+const read = (rel) => fs.readFileSync(path.join(ROOT, rel), "utf8");
+const html = read(path.join("static", "index.html"));
+const mainJs = read(path.join("static", "js", "main.js"));
+const cardsJs = read(path.join("static", "js", "cards.js"));
+const writerJs = read(path.join("static", "js", "writer.js"));
+const a1WriterJs = read(path.join("static", "js", "a1_writer.js"));
+const a1CardsJs = read(path.join("static", "js", "a1_cards.js"));
+
+const problems = [];
+
+/* ---- 括号配对切片(仿 wb_sync_probe.mjs) ------------------------------ */
+const OPEN = { "(": ")", "[": "]", "{": "}" };
+const CLOSE = { ")": "(", "]": "[", "}": "{" };
+
+function matchBracket(src, openIdx) {
+ if (!OPEN[src[openIdx]]) throw new Error(`matchBracket: ${openIdx} 不是开括号`);
+ const stack = [src[openIdx]];
+ let i = openIdx + 1;
+ while (i < src.length) {
+ const c = src[i];
+ if (c === "\\") { i += 2; continue; }
+ if (c === '"' || c === "'" || c === "`") {
+ const q = c; i++;
+ while (i < src.length) {
+ if (src[i] === "\\") { i += 2; continue; }
+ if (src[i] === q) break;
+ i++;
+ }
+ i++; continue;
+ }
+ if (c === "/" && src[i + 1] === "/") { i = src.indexOf("\n", i); if (i < 0) break; continue; }
+ if (c === "/" && src[i + 1] === "*") { i = src.indexOf("*/", i); if (i < 0) break; i += 2; continue; }
+ if (OPEN[c]) { stack.push(c); i++; continue; }
+ if (CLOSE[c]) {
+ if (stack[stack.length - 1] !== CLOSE[c]) throw new Error(`括号不配对 @${i}`);
+ stack.pop();
+ if (!stack.length) return i;
+ i++; continue;
+ }
+ i++;
+ }
+ throw new Error("找不到配对闭括号");
+}
+
+/** JS 块切片:锚点正则不得吞掉首个 `{`,切片= 锚点起、配对闭括号止。 */
+function sliceBlock(src, anchorRe, label) {
+ const m = anchorRe.exec(src);
+ if (!m) { problems.push(`找不到 ${label}(锚点丢了或实现被删)`); return ""; }
+ let i = m.index + m[0].length;
+ while (i < src.length && !OPEN[src[i]]) i++;
+ const end = matchBracket(src, i);
+ return src.slice(m.index, end + 1);
+}
+
+/* ---- 第 1 层:HTML DOM 结构断言 ---------------------------------------- */
+/* / 均不嵌套:切片 = 自带 id 的开标签到下一个同款闭标签。 */
+function sliceView(id) {
+ const at = html.indexOf(`id="${id}"`);
+ if (at < 0) return null;
+ const open = html.lastIndexOf("", at);
+ return open < 0 || close < 0 ? null : html.slice(open, close + "".length);
+}
+
+function sliceSection(id) {
+ const at = html.indexOf(`id="${id}"`);
+ if (at < 0) return null;
+ const open = html.lastIndexOf("", at);
+ return open < 0 || close < 0 ? null : html.slice(open, close + "".length);
+}
+
+const examView = sliceView("view-exam");
+const writerView = sliceView("view-writer");
+const cardsView = sliceView("view-cards");
+for (const [name, blk] of [["view-exam", examView], ["view-writer", writerView], ["view-cards", cardsView]]) {
+ if (!blk) problems.push(`index.html 缺少 ${name}(切片失败)`);
+}
+if (examView && !examView.includes("PRÜFUNGSDOMÄNE")) problems.push("view-exam 切片切歪了(丢了 topbar 文案)");
+if (writerView && !writerView.includes("ide-editor")) problems.push("view-writer 切片切歪了(丢了 ide-editor)");
+if (cardsView && !cardsView.includes("cards-container")) problems.push("view-cards 切片切歪了(丢了 cards-container)");
+
+const EXAM_SLICE_MUST = [
+ "a1-formular-view",
+ "a1-email-view",
+ "exam-tab-formular",
+ "exam-tab-email",
+ "a1-toolbar",
+ "a1-topic-pills",
+ "a1-search-row",
+ "a1-tab-vocab",
+ "a1-tab-hoeren",
+ "a1-tab-lesen",
+ "a1-tab-teil2",
+ "a1-tab-teil3",
+ "exam-cards-container",
+ "exam-cards-view-toggle",
+ "exam-mode-btn-deck",
+ "exam-mode-btn-grid",
+ "a1-hoeren-container",
+ "a1-lesen-container",
+];
+for (const id of EXAM_SLICE_MUST) {
+ if (examView && !examView.includes(`id="${id}"`))
+ problems.push(`id="${id}" 不在 view-exam 内 —— A1 面板没迁进备考域`);
+}
+
+const examWriting = sliceSection("exam-writing");
+const examFamily = sliceSection("exam-cards-family");
+if (!examWriting) problems.push('缺少 (写作模块面板容器)');
+if (!examFamily) problems.push('缺少 (词表/听力/阅读/口语共享面板容器)');
+for (const id of ["exam-tab-formular", "exam-tab-email", "a1-formular-view", "a1-email-view"]) {
+ if (examWriting && !examWriting.includes(`id="${id}"`))
+ problems.push(`id="${id}" 不在 exam-writing 面板内`);
+}
+for (const id of ["a1-toolbar", "exam-cards-container", "exam-cards-view-toggle", "a1-hoeren-container", "a1-lesen-container"]) {
+ if (examFamily && !examFamily.includes(`id="${id}"`))
+ problems.push(`id="${id}" 不在 exam-cards-family 面板内`);
+}
+
+const WRITER_SLICE_FORBID = [
+ "a1-formular-view",
+ "a1-email-view",
+ "writer-mode-a1-formular",
+ "writer-mode-a1-email",
+ "a1-formular-select",
+ "a1-email-input",
+];
+for (const id of WRITER_SLICE_FORBID) {
+ if (writerView && writerView.includes(id))
+ problems.push(`view-writer 仍残留 A1 写作痕迹:${id}(迁出必须删净)`);
+}
+if (writerView && !writerView.includes('id="writer-mode-essay"'))
+ problems.push("view-writer 丢了 writer-mode-essay(回归纯 essay 工具要保留单按钮条)");
+
+const CARDS_SLICE_FORBID = [
+ "a1-toolbar",
+ "a1-hoeren-container",
+ "a1-lesen-container",
+ "seg-a1",
+ "a1-tab-vocab",
+ "a1-topic-pills",
+];
+for (const id of CARDS_SLICE_FORBID) {
+ if (cardsView && cardsView.includes(id))
+ problems.push(`view-cards 仍残留 A1 痕迹:${id}(迁出必须删净)`);
+}
+
+/* id 唯一性铁律:每个被搬移的 id 全文件必须恰好 1 次(双现 = 挂载歧义) */
+for (const id of [...EXAM_SLICE_MUST, "writer-mode-essay"]) {
+ const n = html.split(`id="${id}"`).length - 1;
+ if (n !== 1) problems.push(`id="${id}" 全文件出现 ${n} 次(必须恰好 1 次)`);
+}
+const GONE_FROM_HTML = ["seg-a1", "setCardSegment('a1')", "switchWriterMode('formular')", "switchWriterMode('email')"];
+for (const frag of GONE_FROM_HTML) {
+ if (html.includes(frag)) problems.push(`index.html 仍引用旧工具入口:${frag}`);
+}
+
+/* ---- 第 2 层:JS 挂载链静态断言 ----------------------------------------- */
+const setSegmentBody = sliceBlock(cardsJs, /export\s+function\s+setCardSegment\b/, "cards.js setCardSegment");
+if (setSegmentBody && /['"]a1['"]/.test(setSegmentBody))
+ problems.push("cards.js setCardSegment 仍处理 'a1' 段(备考域已接管入口)");
+
+const renderGridBody = sliceBlock(cardsJs, /export\s+function\s+renderCardsGrid\b/, "cards.js renderCardsGrid");
+if (renderGridBody) {
+ if (/cardSegment\s*===?\s*['"]a1['"]/.test(renderGridBody))
+ problems.push("cards.js renderCardsGrid 仍保留 cardSegment === 'a1' 分流段");
+ if (renderGridBody.includes("a1-toolbar"))
+ problems.push("cards.js renderCardsGrid 仍操作 a1-toolbar(工具栏已归备考域)");
+ if (renderGridBody.includes("A1Cards."))
+ problems.push("cards.js renderCardsGrid 仍引用 A1Cards(分流删除后不得残留)");
+}
+if (!/export\s*\*\s*from\s*["']\.\/a1_cards\.js["']/.test(cardsJs))
+ problems.push("cards.js 丢了 export * from './a1_cards.js'(main.js 经它转发的 A1 函数契约会断)");
+
+if (!/export\s*\{[^}]*setExamWritingTab[^}]*\}\s*from\s*["']\.\/a1_writer\.js["']/s.test(writerJs))
+ problems.push("writer.js 未具名 re-export setExamWritingTab(main.js 的 import 链会断)");
+
+if (!/export\s+(async\s+)?function\s+setExamWritingTab\b/.test(a1WriterJs))
+ problems.push("a1_writer.js 缺 export setExamWritingTab(exam 写作页签切换无宿主)");
+
+const switchModeBody = sliceBlock(a1WriterJs, /export\s+function\s+switchWriterMode\b/, "a1_writer.js switchWriterMode");
+if (switchModeBody && /\bformular\b|\bemail\b/i.test(switchModeBody))
+ problems.push("a1_writer.js switchWriterMode 仍处理 formular/email(A1 写作已迁备考域)");
+
+if (a1CardsJs.includes('querySelector(".cards-view-toggle")'))
+ problems.push("a1_cards.js 仍查询主站 .cards-view-toggle(应改查 #exam-cards-view-toggle)");
+
+const showBody = sliceBlock(mainJs, /export\s+function\s+show\(view\)/, "main.js show");
+if (showBody) {
+ if (showBody.includes('if (view !== "cards")'))
+ problems.push('main.js show() 仍用 view !== "cards" 守卫停考计时器(备考域宿主已改 view-exam)');
+ if (!showBody.includes('if (view !== "exam")'))
+ problems.push('main.js show() 缺 if (view !== "exam") 守卫(离开备考域必须停听力/阅读考试计时器)');
+ if (!/if \(view === "exam"\)/.test(showBody) || !showBody.includes("setExamModule"))
+ problems.push('main.js show() 缺 view === "exam" 分支(进入备考域要点亮模块面板)');
+}
+
+const importWriterBlock = /import\s*\{([^}]*)\}\s*from\s*["']\.\/writer\.js["']/s.exec(mainJs);
+if (importWriterBlock && !importWriterBlock[1].includes("setExamWritingTab"))
+ problems.push("main.js 未从 ./writer.js import setExamWritingTab");
+
+const exposerBody = sliceBlock(mainJs, /Object\.assign\(window,\s*/, "main.js window exposer");
+for (const fn of ["setExamModule", "setExamWritingTab", "setA1CardViewMode"]) {
+ if (exposerBody && !exposerBody.includes(fn))
+ problems.push(`main.js exposer 缺 ${fn}(HTML onclick 挂不上 window,点击静默无操作)`);
+}
+
+/* ---- 第 3 层:node:vm 真跑 a1_cards.js(渲染目标行为级断言) ------------- */
+let transformed = "";
+let vmError = null;
+if (!problems.length) {
+ transformed = a1CardsJs
+ .replace(/^import[^\n]*from[^\n]*;[^\S\n]*$/gm, "")
+ .replace(/^export\s+(?=(?:async\s+)?function\b|\blet\b|\bconst\b|\bvar\b|\bclass\b)/gm, "");
+ if (/^import\b/m.test(transformed)) problems.push("a1_cards.js 剥离后仍有 import 行(探针转换器跟不上源码形态)");
+ if (/^\s*export\b/m.test(transformed)) problems.push("a1_cards.js 剥离后仍有 export(探针转换器跟不上源码形态)");
+ for (const must of ["exam-cards-container", "exam-cards-view-toggle", "setA1CardViewMode", "function renderA1", "function renderA1PokerCard", "function renderA1GridView", "function setA1Mode"]) {
+ if (!transformed.includes(must))
+ problems.push(`a1_cards.js 剥离切片缺 "${must}"(实现回退或切歪)`);
+ }
+}
+
+function makeEl(id) {
+ const el = {
+ id,
+ innerHTML: "",
+ textContent: "",
+ disabled: false,
+ style: {},
+ classOps: [],
+ _classes: new Set(),
+ classList: {
+ add(c) { el._classes.add(c); el.classOps.push(["add", c]); },
+ remove(c) { el._classes.delete(c); el.classOps.push(["remove", c]); },
+ toggle(c, force) {
+ const on = force === undefined ? !el._classes.has(c) : Boolean(force);
+ if (on) el.classList.add(c); else el.classList.remove(c);
+ return on;
+ },
+ contains(c) { return el._classes.has(c); },
+ },
+ querySelector() { return null; },
+ querySelectorAll() { return []; },
+ addEventListener() {},
+ };
+ return el;
+}
+
+function makeDocumentStub() {
+ const registry = new Map();
+ const stub = {
+ registry,
+ getElementById(id) {
+ if (!registry.has(id)) registry.set(id, makeEl(id));
+ return registry.get(id);
+ },
+ querySelector() { return null; },
+ querySelectorAll() { return []; },
+ addEventListener() {},
+ createElement(tag) { return makeEl(`__created__${tag}`); },
+ body: { appendChild() {} },
+ };
+ /* 预登记 DOM id:探针断言只认这些容器上的操作 */
+ for (const id of [
+ "exam-cards-container", "cards-container", "exam-cards-view-toggle",
+ "a1-hoeren-container", "a1-lesen-container",
+ "a1-topic-pills", "a1-search-row", "a1-toolbar",
+ "exam-mode-btn-deck", "exam-mode-btn-grid",
+ "a1-tab-vocab", "a1-tab-hoeren", "a1-tab-lesen", "a1-tab-teil2", "a1-tab-teil3",
+ "a1-active-card",
+ ]) stub.getElementById(id);
+ return stub;
+}
+
+const SEED_WORD = {
+ word: "Haus", lemma: "Haus", pos: "Subst", gender: "Neut", plural: "Häuser",
+ definition_zh: "房子", example_de: "Das Haus ist groß.", example_zh: "房子很大。",
+ topic: "",
+};
+
+/** 构造 vm 沙箱:桩 fetch 抓每笔请求 URL 进 __fetches(供懒加载场景断言)。 */
+function buildCtx(doc) {
+ const sandbox = {
+ console,
+ document: doc,
+ JSON, Math, Object, Array, String, Number, RegExp, Promise, Set, parseInt, isNaN,
+ setTimeout() { return 0; },
+ clearTimeout() {},
+ alert() {},
+ // ↓ 以下为被剥离的模块导入桩(core/player/reader/companion/cards/a1_hoeren/a1_lesen)
+ esc: (s) => String(s ?? ""),
+ jsAttr: (v) => JSON.stringify(v == null ? "" : v),
+ state: {},
+ playGermanAudio() {},
+ refreshCardCounters() {},
+ Companion: { celebrate() {} },
+ getCardViewMode: () => "deck",
+ getCachedVocabLemmas: () => new Set(),
+ initA1Hoeren() {},
+ initA1Lesen() {},
+ stopHoerenExam() {},
+ stopLesenExam() {},
+ };
+ sandbox.api = async (url) => {
+ sandbox.__fetches.push(String(url));
+ const u = String(url);
+ if (u.includes("/api/a1/vocab")) return [SEED_WORD];
+ if (u.includes("/api/a1/topics")) return [];
+ if (u.includes("/api/a1/sprechen/teil2")) return [];
+ if (u.includes("/api/a1/sprechen/teil3")) return [];
+ return {};
+ };
+ const ctx = vm.createContext(sandbox);
+ vm.runInContext("var __fetches = [];", ctx, { filename: "probe-prelude.js" });
+ return ctx;
+}
+
+const tick = () => new Promise((r) => process.nextTick(r));
+
+const docStub = makeDocumentStub();
+const ctx = buildCtx(docStub);
+const mainCardsEl = docStub.registry.get("cards-container");
+const examToggleEl = docStub.registry.get("exam-cards-view-toggle");
+
+try {
+ vm.runInContext(transformed, ctx, { filename: "a1_cards.stripped.js" });
+
+ /* 种数据:loadA1Data 走桩 fetch 灌满缓存,后续 setA1Mode 同步渲染 */
+ await vm.runInContext("loadA1Data()", ctx);
+ await tick(); await tick();
+
+ /* 场景 1:词表默认牌盒模式 —— 必须写进 exam-cards-container */
+ vm.runInContext("setA1CardViewMode('deck'); setA1Mode('vocab');", ctx);
+ const deckTarget = String(vm.runInContext("document.getElementById('exam-cards-container').innerHTML", ctx));
+ if (!deckTarget.includes("deck-stage"))
+ problems.push("setA1Mode('vocab') 后 exam-cards-container 没渲染出牌盒(deck-stage 缺失)");
+ if (!deckTarget.includes("Haus"))
+ problems.push("牌盒渲染没带上种子词 Haus(渲染目标指对了但数据没跟上)");
+ if (mainCardsEl.innerHTML !== "")
+ problems.push("renderA1 把词卡写进了主站 #cards-container(备考域渲染目标回退,回退必红场景)");
+ if (!examToggleEl.classOps.some(([op, c]) => op === "remove" && c === "hidden"))
+ problems.push("setA1Mode('vocab') 未给 #exam-cards-view-toggle 摘 hidden(备考域 toggle 显隐没接上)");
+
+ /* 场景 2:目录模式 —— 同样必须写进 exam-cards-container */
+ vm.runInContext("setA1CardViewMode('grid');", ctx);
+ const gridTarget = String(vm.runInContext("document.getElementById('exam-cards-container').innerHTML", ctx));
+ if (!gridTarget.includes("cards-grid"))
+ problems.push("setA1CardViewMode('grid') 后 exam-cards-container 没渲染出目录网格");
+ if (mainCardsEl.innerHTML !== "")
+ problems.push("grid 模式下 renderA1 仍写主站 #cards-container");
+
+ /* 场景 3:hoeren 模式 —— 备考域内听力容器显隐正常、词表容器让位 */
+ vm.runInContext("setA1Mode('hoeren');", ctx);
+ const hoerenEl = docStub.registry.get("a1-hoeren-container");
+ const examCardsEl = docStub.registry.get("exam-cards-container");
+ if (!hoerenEl.classOps.some(([op, c]) => op === "remove" && c === "hidden"))
+ problems.push("setA1Mode('hoeren') 未摘掉 a1-hoeren-container 的 hidden");
+ if (!examCardsEl.classOps.some(([op, c]) => op === "add" && c === "hidden"))
+ problems.push("setA1Mode('hoeren') 未藏起 exam-cards-container");
+ vm.runInContext("setA1Mode('vocab');", ctx);
+ if (!hoerenEl._classes.has("hidden"))
+ problems.push("切回 vocab 后 a1-hoeren-container 未重新隐藏");
+
+ /* 场景 4(冷缓存懒加载,回退必红):全新沙箱、从未 loadA1Data ——
+ setA1Mode('vocab') 必须自己触发 loadA1Data(原 cards.js 'a1' 段的
+ fetch 链路随分流段迁走,这个守卫只能由 a1_cards.js 自管)。实现回退
+ (只渲染空态、不再拉数据)时 exam-cards-container 里就是空态页而没有
+ 种子词,本场景必红。 */
+ const doc2 = makeDocumentStub();
+ const ctx2 = buildCtx(doc2);
+ vm.runInContext(transformed, ctx2, { filename: "a1_cards.stripped.js#2" });
+ vm.runInContext("setA1CardViewMode('deck'); setA1Mode('vocab');", ctx2);
+ await tick(); await tick(); await tick();
+ const fetches2 = JSON.parse(vm.runInContext("JSON.stringify(__fetches)", ctx2));
+ const coldTarget = String(vm.runInContext("document.getElementById('exam-cards-container').innerHTML", ctx2));
+ if (!fetches2.some((u) => u.includes("/api/a1/vocab")))
+ problems.push("冷缓存 setA1Mode('vocab') 未触发 /api/a1/vocab 拉取(题库懒加载守卫丢失)");
+ if (!coldTarget.includes("deck-stage") || !coldTarget.includes("Haus"))
+ problems.push("冷缓存拉取完成后 exam-cards-container 未重渲染出牌盒(懒加载→渲染链断)");
+} catch (e) {
+ vmError = e;
+ problems.push(`a1_cards.js vm 真跑抛错:${e && e.stack ? e.stack.split("\n").slice(0, 3).join(" | ") : e}`);
+}
+
+/* ---- 裁决 ---------------------------------------------------------------- */
+if (problems.length) {
+ fail(`A1 备考域挂载契约破坏(ADR-0005 Task 2):\n - ${problems.join("\n - ")}`);
+}
+
+const out = {
+ ok: true,
+ exam: {
+ panelsInExamView: EXAM_SLICE_MUST.length,
+ writingPanelIds: ["exam-tab-formular", "exam-tab-email", "a1-formular-view", "a1-email-view"],
+ familyPanelIds: ["a1-toolbar", "exam-cards-container", "exam-cards-view-toggle", "a1-hoeren-container", "a1-lesen-container"],
+ },
+ dynamic: {
+ vocabDeckTarget: "exam-cards-container",
+ vocabGridTarget: "exam-cards-container",
+ mainCardsContainerUntouched: mainCardsEl.innerHTML === "",
+ hoerenContainerWired: true,
+ coldCacheLazyLoad: true,
+ vmError: vmError ? String(vmError) : null,
+ },
+};
+
+if (JSON_MODE) process.stdout.write(JSON.stringify(out, null, 2) + "\n");
+else {
+ console.log("写作面板 id:", out.exam.writingPanelIds.join(", "));
+ console.log("卡盒族面板 id:", out.exam.familyPanelIds.join(", "));
+ console.log("vm 渲染目标:vocab deck/grid → exam-cards-container;主站 cards-container 未被触碰:", out.dynamic.mainCardsContainerUntouched);
+ console.log("冷缓存懒加载:setA1Mode 自管 fetch+重渲染(场景 4 绿)");
+ console.log("✅ PASS: A1 五模块已在 view-exam 挂载,旧工具视图已删净,渲染目标指向备考域容器");
+}
From 17768495e66ca854b57b9ea38d09a6fde491c2ab Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 17:06:00 +0800
Subject: [PATCH 06/12] =?UTF-8?q?docs(ia):=20Task=202=20ledger=20=E5=9B=9E?=
=?UTF-8?q?=E5=A1=AB=EF=BC=88=E8=BF=81=E7=A7=BB=20db80212=20+=20=E8=AF=84?=
=?UTF-8?q?=E5=AE=A1=20PASS=20+=20=E5=81=8F=E5=B7=AE=E6=B3=A8=E8=AE=B0?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...026-09-04-ia-nav-exam-domain-phase1-ledger.md | 16 +++++++++++-----
1 file changed, 11 insertions(+), 5 deletions(-)
diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
index ea0f1df..cb6f4f6 100644
--- a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
+++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
@@ -53,11 +53,17 @@
## Task 2: A1 五模块迁入备考域
-- [ ] Step 1: 探针 ia_dom_mount_probe.mjs(RED 必红)
-- [ ] Step 2: index.html 原子搬移(上表边界)
-- [ ] Step 3: js 引用修改 + GREEN
-- [ ] Step 4: 测试同步(上表失效项)+ 全量探针回归
-- [ ] Step 5: 原子 commit
+- [x] Step 1: 探针 ia_dom_mount_probe.mjs(RED 证据:exit 1 共 48 条问题 + pytest 9 failed)
+- [x] Step 2: index.html 原子搬移(ledger 边界表;id 唯一性 7 关键 id 各恰 1 次)
+- [x] Step 3: js 引用修改 + GREEN(a1CardsHost/setExamWritingTab/setExamModule/懒加载守卫;探针 4 场景绿)
+- [x] Step 4: 测试同步(seg-a1→exam-card-vocab、writer-mode-a1-*→exam-tab-*)+ 全量探针 10/10 绿 + 537 passed
+- [x] Step 5: 原子 commit `db80212`(评审 PASS:悬空标识符 165 handler 交叉核对/变异两向验证/no-op 防悬空)
+
+**偏差注记**:
+- 新增 main.js `setExamModule` mediator(页签⇄面板路由住根模块,因 a1_cards/a1_writer 互不 import)——计划未预见,评审认可。
+- a1_cards.js 自管 view toggle(`a1ViewMode` + `setA1CardViewMode`)而非复用主站 setCardViewMode(避免双 toggle querySelector 冲突)——按计划「择简」授权。
+- writer.js/cards.js 间 `export *` 转发链保留,cards.js 删自身 A1Cards import(转发达成后属死 import)。
+- 评审 nit(不阻塞):test_exam_domain.py:43 TOOL_VIEW_FORBIDDEN 死常量;探针 JSON 部分键为自报常量非测量值(真门禁在 problems[])。
## Task 3: exam catalog 目录化
From 9ec03550fbeed446b5692c1c6fc721281374339e Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 17:30:29 +0800
Subject: [PATCH 07/12] =?UTF-8?q?feat(ia):=20exam=20catalog=20=E7=9B=AE?=
=?UTF-8?q?=E5=BD=95=E5=8C=96=E9=A9=B1=E5=8A=A8=E5=A4=87=E8=80=83=E5=9F=9F?=
=?UTF-8?q?=E5=AF=BC=E8=88=AA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- exam_catalog.py 单源注册目录(EXAM_CATALOG,count_fn 数据推导:writing 18/
hoeren 5/lesen 6/sprechen 54/vocab 702),_safe_count 抛错记 0 + logger.warning
留痕(零静默吞异常,caplog 钉死);docstring 如实标注模块级 import 硬依赖面。
- routes_exam.py GET /api/exams/catalog 纯只读端点;server.py include 一行接入。
- main.js initExamCatalog:fetch catalog → 卡片标题/count 徽标/新等级页签;
失败静态回退 + 幂等守卫 + show('exam') 惰性触发;非静态新页签标注「待接入」
(no-op + aria-disabled,防死按钮 v5.1.0 前科)。
- 加级成本已验证:插一行 A2 → catalog 多一级(变异断言钉死扩展点)。
- 旧 /api/a1/* 端点零改动(6 端点 smoke 锚);定向回归 37 passed。
- 评审 PASS(初轮 REVISE 两处已修:吞异常 + 死代码)。
---
exam_catalog.py | 104 ++++++++++++++++++++++
routes_exam.py | 15 ++++
server.py | 2 +
static/js/main.js | 68 ++++++++++++++
static/style.css | 13 +++
test_exam_catalog.py | 208 +++++++++++++++++++++++++++++++++++++++++++
test_exam_domain.py | 70 +++++++++++++++
7 files changed, 480 insertions(+)
create mode 100644 exam_catalog.py
create mode 100644 routes_exam.py
create mode 100644 test_exam_catalog.py
diff --git a/exam_catalog.py b/exam_catalog.py
new file mode 100644
index 0000000..58cfac5
--- /dev/null
+++ b/exam_catalog.py
@@ -0,0 +1,104 @@
+"""Exam catalog 目录注册表(ADR-0005 Task 3)—— 等级→模块 的导航单源。
+
+设计决策(用户已拍板):
+- **题库数据不入 SQLite**:本文件是「代码注册目录」。加一个等级 =
+ 在 EXAM_CATALOG 插一个 key(配合数据模块常量),未来 A2 立项即插行。
+- **只引用数据模块的常量,不复制数据**:count 通过 count_fn 从数据模块
+ 实时推导。**防失败面仅限常量访问**:模块级 `import a1_dict` 等仍是
+ 硬依赖(模块被删/改名 import 期即崩,server 起不来);单个 count_fn
+ 抛错(常量重命名、数据坏形)→ count 记 0 + logger.warning 留痕,
+ catalog 端点与 server 启动均不受牵连。
+- **旧 /api/a1/* 端点不迁移**:catalog 只做导航发现(api_prefix 指向
+ 既有取题端点前缀),panel 指向 index.html 里的面板容器 id。
+"""
+import logging
+from typing import Any, Callable, Dict, List, Optional
+
+import a1_dict
+import a1_hoeren_dict
+import a1_lesen_dict
+import a1_writing_dict
+
+logger = logging.getLogger("delector")
+
+# count_fn: 零参调用返回该模块题量。try/except 覆盖「数据模块常量改名」
+# 场景(模块 import 仍为硬依赖,见 docstring):常量缺失时 count 记 0
+# 且 logger.warning 记录异常——零静默吞异常(测试
+# test_catalog_survives_broken_count_fn 钉住 count 行为)。
+EXAM_CATALOG: Dict[str, Dict[str, Any]] = {
+ "A1": {
+ "title": "A1",
+ "modules": {
+ "writing": {
+ "title": "📝 写作专项 (Schreiben)",
+ "panel": "exam-writing",
+ "api_prefix": "/api/a1",
+ "count_fn": lambda: (
+ len(a1_writing_dict.A1_SCHREIBEN_TEIL1_EXERCISES)
+ + len(a1_writing_dict.A1_SCHREIBEN_TEIL2_PROMPTS)
+ ),
+ },
+ "hoeren": {
+ "title": "🎧 听力模考 (Hörverstehen)",
+ "panel": "exam-cards-family",
+ "api_prefix": "/api/a1",
+ "count_fn": lambda: len(a1_hoeren_dict.A1_HOEREN_SETS),
+ },
+ "lesen": {
+ "title": "📖 阅读工坊 (Leseverstehen)",
+ "panel": "exam-cards-family",
+ "api_prefix": "/api/a1",
+ "count_fn": lambda: len(a1_lesen_dict.A1_LESEN_SETS),
+ },
+ "sprechen": {
+ "title": "💬 口语问答 (Sprechen)",
+ "panel": "exam-cards-family",
+ "api_prefix": "/api/a1",
+ "count_fn": lambda: (
+ len(a1_dict.A1_SPRECHEN_TEIL2) + len(a1_dict.A1_SPRECHEN_TEIL3)
+ ),
+ },
+ "vocab": {
+ "title": "📖 官方考纲词表 (Wortliste)",
+ "panel": "exam-cards-family",
+ "api_prefix": "/api/a1",
+ "count_fn": lambda: len(a1_dict.GOETHE_A1_VOCAB),
+ },
+ },
+ },
+}
+
+
+def _safe_count(count_fn: Optional[Callable[[], int]]) -> int:
+ """count 推导失败(数据模块常量重命名/缺常量)→ 记 0 并 warning 留痕。"""
+ if count_fn is None:
+ return 0
+ try:
+ return int(count_fn())
+ except Exception:
+ logger.warning("[exam-catalog] 模块题量推导失败,count 记 0", exc_info=True)
+ return 0
+
+
+def get_catalog() -> Dict[str, Any]:
+ """序列化 catalog:{"levels": [{"id", "title", "modules": [
+ {"id", "title", "panel", "api_prefix", "count"}]}]}。
+
+ count_fn 是 Python 可调用、不可 JSON 化,故此层负责把注册表扁平化为
+ 纯数据(count 取 _safe_count 结果)。等级顺序 = 注册序(dict 插入序)。
+ 返回 fresh dict,调用方随意改。
+ """
+ levels: List[Dict[str, Any]] = []
+ for lid, reg in EXAM_CATALOG.items():
+ modules = [
+ {
+ "id": mid,
+ "title": mod.get("title", mid),
+ "panel": mod.get("panel", ""),
+ "api_prefix": mod.get("api_prefix", ""),
+ "count": _safe_count(mod.get("count_fn")),
+ }
+ for mid, mod in reg.get("modules", {}).items()
+ ]
+ levels.append({"id": lid, "title": reg.get("title", lid), "modules": modules})
+ return {"levels": levels}
diff --git a/routes_exam.py b/routes_exam.py
new file mode 100644
index 0000000..c747a81
--- /dev/null
+++ b/routes_exam.py
@@ -0,0 +1,15 @@
+"""Exam catalog 路由(ADR-0005 Task 3)—— 等级→模块 导航发现。
+
+纯只读端点:无写操作、无 _require_localhost 闸。旧 /api/a1/* 取题端点
+**不迁移不改动**,本 router 只挂目录发现。
+"""
+from fastapi import APIRouter
+
+from exam_catalog import get_catalog
+
+router = APIRouter(prefix="/api/exams", tags=["exam"])
+
+
+@router.get("/catalog")
+def get_exam_catalog():
+ return get_catalog()
diff --git a/server.py b/server.py
index 7871683..e682f2f 100644
--- a/server.py
+++ b/server.py
@@ -228,6 +228,7 @@ def _attachment_headers(filename: str) -> Dict[str, str]:
from routes_corpus import router as corpus_router
from routes_a1_hoeren import hoeren_router
from routes_a1_lesen import lesen_router
+from routes_exam import router as exam_router
# --- 4. FastAPI Application ---
app = FastAPI(title="DeLector")
@@ -237,6 +238,7 @@ def _attachment_headers(filename: str) -> Dict[str, str]:
app.include_router(corpus_router)
app.include_router(hoeren_router)
app.include_router(lesen_router)
+app.include_router(exam_router)
init_db()
seed_preset_articles()
diff --git a/static/js/main.js b/static/js/main.js
index 226b189..7946584 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -214,6 +214,7 @@ export function show(view) {
if (view === "exam") {
loadCards();
setExamModule();
+ lazyInitExamCatalog();
}
if (view === "progress") loadProgress();
if (view === "writer") {
@@ -261,6 +262,73 @@ export function setExamModule(mod) {
}
}
+// ── Exam Catalog (ADR-0005 Task 3) ──────────────────────────────────────────
+// GET /api/exams/catalog(exam_catalog.py 代码注册目录,单源)→ 数据补强
+// 备考域导航:模块卡片标题回写成目录原文 + count 徽标 + 题量字样。只做
+// 数据补强/未来新增等级页签,**不重写** setExamModule 的面板路由接线
+// (onclick 与卡片 id 静态接线保持 Task 2 现状)。
+// 失败回退:fetch 失败/非 200 → 静态占位原样保留(console.debug 即可,
+// 不弹错——导航数据是锦上添花,不该打断备考动线)。
+let _examCatalogDone = false;
+
+async function initExamCatalog() {
+ if (_examCatalogDone) return;
+ _examCatalogDone = true;
+ let catalog;
+ try {
+ catalog = await api("/api/exams/catalog");
+ } catch (e) {
+ console.debug("[ExamCatalog] catalog 不可用,保留静态占位:", e);
+ _examCatalogDone = false; // 下次进备考域重试一次
+ return;
+ }
+ const levels = (catalog && catalog.levels) || [];
+ const a1 = levels.find((lv) => lv.id === "A1");
+ if (!a1 || !Array.isArray(a1.modules)) return;
+
+ // 模块卡片补强:标题回写 + count 徽标(卡片 id/onclick 静态接线不动,
+ // 目录若删了某模块,本地卡片原样保留——回退语义,不做删卡)。
+ a1.modules.forEach((m) => {
+ const card = document.getElementById("exam-card-" + m.id);
+ if (!card) return;
+ if (m.title) card.textContent = m.title;
+ if (m.count > 0) {
+ const badge = document.createElement("span");
+ badge.className = "exam-module-count";
+ badge.textContent = m.count >= 1000
+ ? (m.count / 1000).toFixed(1).replace(/\.0$/, "") + "k"
+ : String(m.count);
+ card.appendChild(badge);
+ }
+ });
+
+ // 等级页签:静态占位已有 A1;目录里超出静态占位的等级(未来 A2 = 插
+ // 一行注册)在这里追加成页签。目录缺失的静态页签不动(回退语义)。
+ // 新增页签目前没有面板接线(setExamModule mediator 只认识 A1 模块 id),
+ // 必须显式标注「待接入」而非静默挂着当死按钮(v5.1.0 前科纪律):
+ // no-op onclick + title 提示 + aria-disabled。
+ const tabs = document.getElementById("exam-level-tabs");
+ if (!tabs) return;
+ levels.forEach((lv) => {
+ if (!lv.id) return;
+ const key = lv.id.toLowerCase();
+ if (document.getElementById("exam-level-" + key)) return;
+ const btn = document.createElement("button");
+ btn.id = "exam-level-" + key;
+ btn.className = "exam-level-tab";
+ btn.textContent = lv.title || lv.id;
+ btn.title = "该等级模块待接入";
+ btn.setAttribute("aria-disabled", "true");
+ btn.onclick = () => {}; // no-op:防静默死按钮,待该等级模块接线后替换
+ tabs.appendChild(btn);
+ });
+}
+
+// show('exam') 惰性触发一次(已初始化则跳过)。
+function lazyInitExamCatalog() {
+ initExamCatalog().catch(() => {});
+}
+
// ── Import Modal ─────────────────────────────────────────────────────────────
let currentImportTab = "text";
let cachedFeedSources = [];
diff --git a/static/style.css b/static/style.css
index 687b06b..c907e62 100644
--- a/static/style.css
+++ b/static/style.css
@@ -3248,6 +3248,19 @@ body.drawer-open .shadow-player {
transform: translateY(-2px);
}
+.exam-module-count {
+ display: inline-block;
+ margin-left: 0.4rem;
+ padding: 0.05rem 0.45rem;
+ border: 1px solid var(--ink);
+ border-radius: 999px;
+ background: var(--paper-deep);
+ font-size: 0.65rem;
+ font-weight: 700;
+ color: var(--ink);
+ vertical-align: middle;
+}
+
.cards-view-toggle {
display: inline-flex;
border: 1.5px solid var(--ink);
diff --git a/test_exam_catalog.py b/test_exam_catalog.py
new file mode 100644
index 0000000..d6dd247
--- /dev/null
+++ b/test_exam_catalog.py
@@ -0,0 +1,208 @@
+# -*- coding: utf-8 -*-
+"""exam catalog 目录化(ADR-0005 Task 3)的行为级回归。
+
+端点契约:
+- GET /api/exams/catalog → {"levels": [{"id", "title", "modules": [
+ {"id", "title", "panel", "api_prefix", "count"}]}]}。纯只读导航发现层,
+ **不迁移**任何 /api/a1/* 取题端点(旧契约照常可用,见 smoke 锚)。
+- count 全部由代码注册目录(exam_catalog.py 单源)从数据模块常量实时
+ 推导:不复制数据、不入 SQLite;数据模块缺失/重命名时 count 记 0,
+ 不得拖垮 server 启动。
+
+与 test_server.py / test_audit_hardening.py 同款纪律:模块顶层先钉隔离
+env 再 import server(server 模块顶层有 init_db() 副作用),clean_db
+autouse 前后双钉 env + gc.collect() 后删库(Windows 句柄释放纪律)。
+"""
+import os
+import gc
+import pytest
+
+os.environ["DATABASE_PATH"] = "test_catalog.db"
+os.environ["PROGRESS_DB_PATH"] = "test_catalog_progress.db"
+
+from fastapi.testclient import TestClient # noqa: E402
+
+from server import app # noqa: E402
+import exam_catalog # noqa: E402
+import a1_dict # noqa: E402
+import a1_hoeren_dict # noqa: E402
+import a1_lesen_dict # noqa: E402
+import a1_writing_dict # noqa: E402
+
+_ROOT = os.path.dirname(os.path.abspath(__file__))
+with open(os.path.join(_ROOT, "static", "index.html"), encoding="utf-8") as _f:
+ _INDEX = _f.read()
+
+
+@pytest.fixture
+def client():
+ # 显式 127.0.0.1 来源:与 test_server.py 同款(个别端点有 localhost 闸)。
+ return TestClient(app, client=("127.0.0.1", 54321))
+
+
+@pytest.fixture(autouse=True)
+def clean_db():
+ # database.get_db_path() 每次调用都读 os.environ(不是 import 时冻结):
+ # 全量 pytest 时更晚收集的测试文件会在模块顶层改写 env,故每个用例
+ # 前后双钉自己的库文件名,不能只靠模块顶层那一次赋值。
+ saved = {k: os.environ.get(k) for k in ("DATABASE_PATH", "PROGRESS_DB_PATH")}
+ os.environ["DATABASE_PATH"] = "test_catalog.db"
+ os.environ["PROGRESS_DB_PATH"] = "test_catalog_progress.db"
+ gc.collect()
+ for f in ("test_catalog.db", "test_catalog_progress.db"):
+ if os.path.exists(f):
+ try:
+ os.remove(f)
+ except OSError:
+ pass
+ from server import init_db, init_progress_db
+ init_db("test_catalog.db")
+ init_progress_db("test_catalog_progress.db")
+ yield
+ gc.collect()
+ for f in ("test_catalog.db", "test_catalog_progress.db"):
+ if os.path.exists(f):
+ try:
+ os.remove(f)
+ except OSError:
+ pass
+ for k, v in saved.items():
+ if v is None:
+ os.environ.pop(k, None)
+ else:
+ os.environ[k] = v
+
+
+# ── RED 1:端点契约(形状 + 数据推导 count + panel 指向真实 DOM) ────────────
+
+def test_catalog_endpoint_contract(client):
+ """/api/exams/catalog 返回 A1 五模块,count 与数据模块常量实测长度一致。"""
+ res = client.get("/api/exams/catalog")
+ assert res.status_code == 200
+ levels = res.json()["levels"]
+ assert [lv["id"] for lv in levels] == ["A1"]
+
+ mods = {m["id"]: m for m in levels[0]["modules"]}
+ assert {"writing", "hoeren", "lesen", "sprechen", "vocab"} <= set(mods)
+ for m in mods.values():
+ assert m["title"], "模块 %s 缺 title" % m["id"]
+ assert m["panel"], "模块 %s 缺 panel(面板容器 id)" % m["id"]
+ assert m["api_prefix"], "模块 %s 缺 api_prefix(取题端点前缀)" % m["id"]
+
+ # count 是数据推导不是硬编码:必须等于数据模块常量的实测长度。
+ assert mods["writing"]["count"] == (
+ len(a1_writing_dict.A1_SCHREIBEN_TEIL1_EXERCISES)
+ + len(a1_writing_dict.A1_SCHREIBEN_TEIL2_PROMPTS)
+ )
+ assert mods["hoeren"]["count"] == len(a1_hoeren_dict.A1_HOEREN_SETS)
+ assert mods["lesen"]["count"] == len(a1_lesen_dict.A1_LESEN_SETS)
+ assert mods["sprechen"]["count"] == (
+ len(a1_dict.A1_SPRECHEN_TEIL2) + len(a1_dict.A1_SPRECHEN_TEIL3)
+ )
+ assert mods["vocab"]["count"] == len(a1_dict.GOETHE_A1_VOCAB)
+
+
+def test_catalog_panels_point_at_real_dom(client):
+ """panel 字段语义 = 该模块对应的面板容器 id,必须真实存在于 index.html。"""
+ res = client.get("/api/exams/catalog")
+ for lv in res.json()["levels"]:
+ for m in lv["modules"]:
+ assert 'id="%s"' % m["panel"] in _INDEX, (
+ "模块 %s 的 panel=%r 在 index.html 里不存在(指向幻影容器 = 前端拿到死引用)"
+ % (m["id"], m["panel"])
+ )
+
+
+# ── RED 2:扩展点变异断言 —— 加级 = 插一行 ───────────────────────────────────
+
+def test_catalog_extension_point_adding_level(monkeypatch, client):
+ """EXAM_CATALOG 追加一个等级 key,catalog 立即多一级且结构同构。
+
+ 这是「未来加 A2 = 在 exam_catalog.py 插一行注册」的变异证明:
+ - 有 count_fn 的模块 → count 正常推导;
+ - 无 count_fn 的模块(数据模块还没接上)→ 结构一致、count 记 0、不崩。
+ """
+ patched = dict(exam_catalog.EXAM_CATALOG)
+ patched["A2"] = {
+ "title": "A2",
+ "modules": {
+ "hoeren": {
+ "title": "听力 (A2)",
+ "panel": "exam-cards-family",
+ "api_prefix": "/api/exams",
+ "count_fn": lambda: 7,
+ },
+ "lesen": {
+ "title": "阅读 (A2)",
+ "panel": "exam-cards-family",
+ "api_prefix": "/api/exams",
+ },
+ },
+ }
+ monkeypatch.setattr(exam_catalog, "EXAM_CATALOG", patched)
+
+ res = client.get("/api/exams/catalog")
+ assert res.status_code == 200
+ levels = {lv["id"]: lv for lv in res.json()["levels"]}
+ assert set(levels) == {"A1", "A2"}, "插一行的 A2 必须原样出现在 catalog 里"
+
+ a2 = {m["id"]: m for m in levels["A2"]["modules"]}
+ assert a2["hoeren"]["count"] == 7
+ assert set(a2["lesen"].keys()) == set(a2["hoeren"].keys()), (
+ "缺 count_fn 的模块也必须输出同构结构(id/title/panel/api_prefix/count)"
+ )
+ assert a2["lesen"]["count"] == 0
+
+
+# ── 防御:count_fn 抛错(数据模块重命名)不拖垮端点 ─────────────────────────
+
+def _boom():
+ raise RuntimeError("data module renamed — catalog must survive")
+
+
+def test_catalog_survives_broken_count_fn(monkeypatch, client, caplog):
+ patched = dict(exam_catalog.EXAM_CATALOG)
+ patched["A1"] = dict(patched["A1"])
+ patched["A1"]["modules"] = dict(patched["A1"]["modules"])
+ patched["A1"]["modules"]["hoeren"] = dict(patched["A1"]["modules"]["hoeren"])
+ patched["A1"]["modules"]["hoeren"]["count_fn"] = _boom
+ monkeypatch.setattr(exam_catalog, "EXAM_CATALOG", patched)
+
+ # 零静默吞异常铁律:count 记 0 的同时必须 logger.warning 留痕。
+ import logging
+ with caplog.at_level(logging.WARNING, logger="delector"):
+ res = client.get("/api/exams/catalog")
+ assert res.status_code == 200, "单个模块 count 推导失败不得 500 整个 catalog"
+ assert any("exam-catalog" in r.message for r in caplog.records), (
+ "count 推导失败被静默吞掉(缺 logger.warning 留痕)"
+ )
+ a1 = next(lv for lv in res.json()["levels"] if lv["id"] == "A1")
+ hoeren = next(m for m in a1["modules"] if m["id"] == "hoeren")
+ assert hoeren["count"] == 0
+ # 其余模块不受牵连
+ vocab = next(m for m in a1["modules"] if m["id"] == "vocab")
+ assert vocab["count"] == len(a1_dict.GOETHE_A1_VOCAB)
+
+
+# ── RED 3:路由已注册 ────────────────────────────────────────────────────────
+
+def test_catalog_route_registered(client):
+ """/api/exams/catalog 必须挂在 app 上(openapi 枚举可查)。"""
+ paths = client.get("/openapi.json").json()["paths"]
+ assert "/api/exams/catalog" in paths
+
+
+# ── RED 4:旧 /api/a1 契约回归锚 —— catalog 上线不动旧端点 ───────────────────
+
+def test_legacy_a1_endpoints_smoke(client):
+ """catalog 只做导航发现:既有取题端点全部照常 200。"""
+ for path in (
+ "/api/a1/topics",
+ "/api/a1/vocab",
+ "/api/a1/sprechen/teil2",
+ "/api/a1/sprechen/teil3",
+ "/api/a1/hoeren/sets",
+ "/api/a1/lesen/sets",
+ ):
+ res = client.get(path)
+ assert res.status_code == 200, "旧端点 %s 被 catalog 上线破坏" % path
diff --git a/test_exam_domain.py b/test_exam_domain.py
index c412c05..e029f3e 100644
--- a/test_exam_domain.py
+++ b/test_exam_domain.py
@@ -266,3 +266,73 @@ def test_exam_module_mount_probe():
)
assert out["dynamic"]["vocabDeckTarget"] == "exam-cards-container"
assert out["dynamic"]["vocabGridTarget"] == "exam-cards-container"
+
+
+# ── Task 3:exam catalog 目录化(前端数据补强接线) ─────────────────────────
+
+def test_exam_catalog_fetch_wired_in_main_js():
+ """initExamCatalog 接线:fetch /api/exams/catalog + 失败回退 + 惰性触发。
+
+ 切函数体级断言(static-string-assertion-dead-test 教训):三要素必须
+ 落在 initExamCatalog 自己的函数体切片里,写进别的函数不算。
+ """
+ m = re.search(r"(?:async\s+)?function\s+initExamCatalog\s*\(", _MAIN_JS)
+ assert m, "main.js 缺少 initExamCatalog()(Task 3 catalog 数据补强入口)"
+ body = _MAIN_JS[m.end():_MAIN_JS.index("\nfunction ", m.end())]
+ assert '"/api/exams/catalog"' in body, (
+ "initExamCatalog 必须调用 api('/api/exams/catalog')"
+ )
+ assert "catch" in body and "console.debug" in body, (
+ "initExamCatalog 必须有失败回退分支(静默 console.debug,不弹错)"
+ )
+ assert "_examCatalogDone" in body, (
+ "initExamCatalog 必须有已初始化守卫(惰性触发一次)"
+ )
+
+
+def test_exam_catalog_lazy_trigger_on_show_exam():
+ """show('exam') 分支必须惰性触发 catalog 初始化。"""
+ body = _MAIN_JS.split("export function show(")[1].split("\nexport ")[0]
+ assert "lazyInitExamCatalog" in body, (
+ "show() 的 view === 'exam' 分支缺 lazyInitExamCatalog()(catalog 不会自动加载)"
+ )
+
+
+def test_exam_catalog_does_not_rewrite_panel_routing():
+ """catalog 渲染只做数据补强,不得改写 setExamModule 的面板路由接线。
+
+ 静态接线(面板 section id + exam-card-* onclick)是 Task 2 契约,
+ catalog 数据驱动不得重挂/替换面板跳转机制。
+ """
+ body = _MAIN_JS.split("export function setExamModule(")[1].split("\nexport ")[0]
+ for frag in (
+ '"exam-writing"', '"exam-cards-family"', "setA1Mode", "setExamWritingTab",
+ ):
+ assert frag in body, "setExamModule mediator 被改动(缺 %s)" % frag
+
+
+def test_exam_catalog_count_badge_css_exists():
+ """style.css 有 .exam-module-count 徽标规则(切到声明块本身)。"""
+ m = re.search(r"\.exam-module-count\s*\{([^{}]*)\}", _CSS)
+ assert m, "style.css 缺少 .exam-module-count 徽标规则"
+
+
+def test_exam_catalog_new_level_tabs_not_dead_buttons():
+ """catalog 追加的非静态页签必须有接线或显式禁用标注(v5.1.0 死按钮前科)。
+
+ 新增等级面板尚未接入 setExamModule mediator,故契约 = no-op onclick
+ (占位接线)+ title「待接入」提示 + aria-disabled 标注,缺一即红。
+ 切 initExamCatalog 等级页签分支的函数体级断言。
+ """
+ m = re.search(r"(?:async\s+)?function\s+initExamCatalog\s*\(", _MAIN_JS)
+ assert m, "main.js 缺少 initExamCatalog()"
+ body = _MAIN_JS[m.end():_MAIN_JS.index("\nfunction ", m.end())]
+ assert 'btn.title = "该等级模块待接入"' in body, (
+ "新增等级页签缺 title 待接入提示(静默死按钮纪律违规)"
+ )
+ assert 'aria-disabled' in body, (
+ "新增等级页签缺 aria-disabled 禁用标注"
+ )
+ assert "btn.onclick" in body, (
+ "新增等级页签缺 onclick 占位(no-op 接线,待该等级模块接入后替换)"
+ )
From db5e27f10726107cfa745bafb4fde313113f36d0 Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 17:31:33 +0800
Subject: [PATCH 08/12] =?UTF-8?q?docs(ia):=20Task=203=20ledger=20=E5=9B=9E?=
=?UTF-8?q?=E5=A1=AB=EF=BC=88catalog=209ec0355=20+=20REVISE=20=E4=B8=A4?=
=?UTF-8?q?=E4=BF=AE=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../2026-09-04-ia-nav-exam-domain-phase1-ledger.md | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
index cb6f4f6..b976084 100644
--- a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
+++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
@@ -67,10 +67,15 @@
## Task 3: exam catalog 目录化
-- [ ] Step 1: test_exam_catalog.py RED(含 A2 扩展点变异)
-- [ ] Step 2: exam_catalog.py + routes_exam.py + include GREEN
-- [ ] Step 3: 前端页签/卡片 catalog 渲染(静态回退)
-- [ ] Step 4: 旧端点回归 + commit
+- [x] Step 1: test_exam_catalog.py RED(6 用例 collection error 必红;含 A2 扩展点变异 + count 防御)
+- [x] Step 2: exam_catalog.py + routes_exam.py + include GREEN(count 数据推导 18/5/6/54/702)
+- [x] Step 3: 前端 initExamCatalog 数据驱动(失败静态回退 + 幂等守卫 + show('exam') 惰性;新页签「待接入」标注防死按钮)
+- [x] Step 4: 旧端点回归 6 passed + 定向 37 passed + commit `9ec0355`
+
+**偏差注记**:
+- panel 实测只有两个 section:exam-writing + exam-cards-family(听力/阅读/口语/词表共用宿主由 setA1Mode 切换)——catalog panel 字段按实测写,测试钉「panel 必须真实存在于 DOM」。
+- 初轮评审 REVISE 两处已修:_safe_count 静默吞异常→logger.warning + caplog 钉死;_LEVEL_ORDER 死代码删。
+- 评审 nit(已接线):非静态新等级页签 no-op + aria-disabled + title「待接入」(v5.1.0 死按钮纪律)。
## Task 4: exam_trials 泛化表
From af93df00267ebc26fb2d347e730eb669ae0a81d5 Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 19:01:25 +0800
Subject: [PATCH 09/12] =?UTF-8?q?refactor(db):=20=E6=88=90=E7=BB=A9?=
=?UTF-8?q?=E8=A1=A8=E6=B3=9B=E5=8C=96=20exam=5Ftrials=20=E5=B9=B6?=
=?UTF-8?q?=E5=B9=82=E7=AD=89=E8=BF=81=E7=A7=BB=20A1=20=E5=AD=98=E9=87=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- progress.db 新增 exam_trials(level,module,set_id,score_raw,…) 泛化成绩表;
record_exam_trial/get_exam_history 泛化读写(9 列投影,无 level/module 冗余泄漏)。
- 旧 record_a1_hoeren_trial/record_a1_lesen_trial/get_a1_hoeren_history/get_a1_lesen_history
改内部透传(签名+返回结构不变,调用方零改动)。
- migrate_a1_records_to_exam_trials() 幂等迁移:行数对账谓词用 >=(非 ==)——
== 在「透传后旧表冻结+新表随成绩单调增,迁移后又做一次成绩再重启」场景
general=legacy+1 误判未迁移→整表重插(已复现 count 3→5,回归测试钉死)。
db_progress_conn 单事务保证半迁回滚;migrate 每次启动跑,增/幂等皆安全。
- 备份接线:_PROGRESS_TABLES 带 exam_trials + RestoreReq.exam_trials 字段 +
restore 灌表 + restore 后幂等迁移(v5.1 旧备份无 exam_trials 键时历史仍可见)。
- test_server.py clean_db env 双钉(防收集顺序导致未建表库命中)。
- 回归 287 passed(含新增 11 条 exam_trials 契约测试 + 迁移重入回归);
评审 PASS(幂等/透传契约/init 无递归/备份链/SQL 参数化逐项核验)。
---
database.py | 164 ++++++++++++++++-----
server.py | 10 ++
test_exam_trials.py | 346 ++++++++++++++++++++++++++++++++++++++++++++
test_server.py | 12 ++
4 files changed, 499 insertions(+), 33 deletions(-)
create mode 100644 test_exam_trials.py
diff --git a/database.py b/database.py
index 10b70a3..aa63907 100644
--- a/database.py
+++ b/database.py
@@ -113,6 +113,19 @@ def init_progress_db(db_path: Optional[str] = None):
wrong_questions_json TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
+ CREATE TABLE IF NOT EXISTS exam_trials (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ level TEXT NOT NULL,
+ module TEXT NOT NULL,
+ set_id INTEGER NOT NULL,
+ score_raw INTEGER NOT NULL,
+ score_official REAL NOT NULL,
+ total_questions INTEGER NOT NULL,
+ duration_seconds INTEGER NOT NULL,
+ answers_json TEXT NOT NULL,
+ wrong_questions_json TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
CREATE INDEX IF NOT EXISTS idx_quiz_card ON quiz_log(card_id);
CREATE INDEX IF NOT EXISTS idx_study_logged ON study_log(logged_at);
""")
@@ -120,6 +133,8 @@ def init_progress_db(db_path: Optional[str] = None):
# init 阶段不能用 db_progress_conn(会递归回自身自动建表),此处确定关闭
_close_db_conn(conn)
_INITIALIZED_PROGRESS_DBS.add(target_path)
+ # 泛化成绩表上线即迁移存量 A1 行;函数自身行数对账幂等,重复调用无害
+ migrate_a1_records_to_exam_trials(db_path=target_path)
def log_study_event(event_type: str, ref_id: Optional[int] = None, note: str = "", minutes: int = 0, db_path: Optional[str] = None):
@@ -748,6 +763,10 @@ def prune_audio_cache(max_files: int = 300, cache_dir: Optional[str] = None):
("id", "set_id", "score_raw", "score_official", "total_questions", "duration_seconds", "answers_json", "wrong_questions_json", "created_at"),
{"set_id": 1, "score_raw": 0, "score_official": 0.0, "total_questions": 15, "duration_seconds": 0, "answers_json": "{}", "wrong_questions_json": "[]"},
),
+ "exam_trials": (
+ ("id", "level", "module", "set_id", "score_raw", "score_official", "total_questions", "duration_seconds", "answers_json", "wrong_questions_json", "created_at"),
+ {"level": "A1", "module": "hoeren", "set_id": 1, "score_raw": 0, "score_official": 0.0, "total_questions": 15, "duration_seconds": 0, "answers_json": "{}", "wrong_questions_json": "[]"},
+ ),
}
@@ -906,60 +925,136 @@ def add_prep_saved(lemma: str, praep: str, kasus: str, db_path: Optional[str] =
)
-def record_a1_hoeren_trial(set_id: int, score_raw: int, score_official: float,
- total_questions: int, duration_seconds: int,
- answers_json: str, wrong_questions_json: str,
- db_path: Optional[str] = None) -> int:
- """持久化一次 A1 听力模考记录"""
+def record_exam_trial(level: str, module: str, set_id: int, score_raw: int,
+ score_official: float, total_questions: int,
+ duration_seconds: int, answers_json: str,
+ wrong_questions_json: str,
+ db_path: Optional[str] = None) -> int:
+ """写入一次泛化模考成绩(exam_trials:level × module 维度)。
+
+ 与旧 A1 专用表不同,同一张表承载所有等级/模块(A1 听力、A1 阅读,
+ 未来 A2…),备份/restore 也在同一张表上通用。
+ """
with db_progress_conn(db_path) as conn:
cur = conn.execute("""
- INSERT INTO a1_hoeren_records (
- set_id, score_raw, score_official, total_questions,
- duration_seconds, answers_json, wrong_questions_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
- """, (set_id, score_raw, score_official, total_questions,
+ INSERT INTO exam_trials (
+ level, module, set_id, score_raw, score_official,
+ total_questions, duration_seconds, answers_json, wrong_questions_json
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ """, (level, module, set_id, score_raw, score_official, total_questions,
duration_seconds, answers_json, wrong_questions_json))
record_id = cur.lastrowid
# log_study_event opens its own connection — must be OUTSIDE the with block
# to avoid SQLITE_BUSY from nested locks on progress.db.
- log_study_event("a1_hoeren", ref_id=record_id, note=f"Set {set_id}: {score_official}/25.0", minutes=max(1, duration_seconds // 60), db_path=db_path)
+ # A1 双模块沿用旧 event_type(test_a1_grade_populates_study_log 契约);
+ # 其余组合的 daily_summary 映射留待 Phase 2 端点切换时扩展。
+ if level == "A1":
+ event_type = "a1_hoeren" if module == "hoeren" else "a1_lesen"
+ else:
+ event_type = f"{level.lower()}_{module}"
+ log_study_event(event_type, ref_id=record_id,
+ note=f"Set {set_id}: {score_official}/25.0",
+ minutes=max(1, duration_seconds // 60), db_path=db_path)
return record_id
-def get_a1_hoeren_history(limit: int = 50, db_path: Optional[str] = None) -> List[Dict[str, Any]]:
- """查询 A1 听力模考历史记录"""
+def get_exam_history(level: str, module: str, limit: int = 50,
+ db_path: Optional[str] = None) -> List[Dict[str, Any]]:
+ """查询泛化模考历史;返回结构与旧 get_a1_*_history 逐字段等价。
+
+ 显式投影 9 个旧列而非 SELECT *:表里多出的 level/module 是存储维度,
+ 不是 API 字段——透传契约下调用方(routes_a1_*)不应看到它们。
+ """
with db_progress_conn(db_path) as conn:
rows = conn.execute("""
- SELECT * FROM a1_hoeren_records ORDER BY id DESC LIMIT ?
- """, (limit,)).fetchall()
+ SELECT id, set_id, score_raw, score_official, total_questions,
+ duration_seconds, answers_json, wrong_questions_json, created_at
+ FROM exam_trials
+ WHERE level = ? AND module = ?
+ ORDER BY id DESC LIMIT ?
+ """, (level, module, limit)).fetchall()
return [dict(r) for r in rows]
+def migrate_a1_records_to_exam_trials(db_path: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
+ """把 a1_hoeren_records / a1_lesen_records 存量行迁入 exam_trials(level='A1')。
+
+ 幂等策略是**行数对账**:对每个 module,若 exam_trials 里该 (level='A1',
+ module) 的行数 **≥** 旧表行数则整个跳过——重复执行零副作用。
+
+ 为什么用 ≥ 而不是 ==:旧表在迁移后冻结(透传模式只写 exam_trials),
+ legacy_count 恒定,而 exam_trials 随每次新成绩提交单调增。若用 ==,
+ 「迁移后又做了一次新成绩」再重启时 general=legacy+1 ≠ legacy 会误判为
+ 「未迁移」,把旧表整行再插一遍 → 重复。≥ 在「旧表冻结 + 新表单调增」下
+ 天然满足:一次迁入后 general ≥ legacy 恒成立,永不再迁。
+
+ 原子性:db_progress_conn 单事务,迁移中途异常整体回滚,不会留下半迁状
+ 态;重跑从 0 迁,无剩余行。故 ≥ 对「迁移中断」也安全。
+
+ 旧表本身保留不删,读历史兼容期后由 Phase 2 退役。
+
+ Returns: {"hoeren": {"migrated": n, "skipped": bool}, "lesen": {...}}
+ """
+ report: Dict[str, Dict[str, Any]] = {}
+ sources = {
+ "hoeren": "a1_hoeren_records",
+ "lesen": "a1_lesen_records",
+ }
+ with db_progress_conn(db_path) as conn:
+ for module, table in sources.items():
+ legacy_count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
+ general_count = conn.execute(
+ "SELECT COUNT(*) FROM exam_trials WHERE level = ? AND module = ?",
+ ("A1", module)).fetchone()[0]
+ if general_count >= legacy_count:
+ report[module] = {"migrated": 0, "skipped": True}
+ continue
+ conn.execute(f"""
+ INSERT INTO exam_trials (
+ level, module, set_id, score_raw, score_official,
+ total_questions, duration_seconds, answers_json,
+ wrong_questions_json, created_at
+ )
+ SELECT 'A1', ?, set_id, score_raw, score_official, total_questions,
+ duration_seconds, answers_json, wrong_questions_json, created_at
+ FROM {table}
+ """, (module,))
+ report[module] = {"migrated": legacy_count, "skipped": False}
+ return report
+
+
+def record_a1_hoeren_trial(set_id: int, score_raw: int, score_official: float,
+ total_questions: int, duration_seconds: int,
+ answers_json: str, wrong_questions_json: str,
+ db_path: Optional[str] = None) -> int:
+ """持久化一次 A1 听力模考记录
+
+ 透传泛化实现(exam_trials level='A1' module='hoeren'):签名与返回
+ 结构不变,调用方零改动;旧行由 migrate_a1_records_to_exam_trials 迁入。
+ """
+ return record_exam_trial("A1", "hoeren", set_id, score_raw, score_official,
+ total_questions, duration_seconds, answers_json,
+ wrong_questions_json, db_path=db_path)
+
+
+def get_a1_hoeren_history(limit: int = 50, db_path: Optional[str] = None) -> List[Dict[str, Any]]:
+ """查询 A1 听力模考历史记录(透传泛化实现,返回结构不变)"""
+ return get_exam_history("A1", "hoeren", limit=limit, db_path=db_path)
+
+
def record_a1_lesen_trial(set_id: int, score_raw: int, score_official: float,
total_questions: int, duration_seconds: int,
answers_json: str, wrong_questions_json: str,
db_path: Optional[str] = None) -> int:
- """持久化一次 A1 阅读模考记录"""
- with db_progress_conn(db_path) as conn:
- cur = conn.execute("""
- INSERT INTO a1_lesen_records (
- set_id, score_raw, score_official, total_questions,
- duration_seconds, answers_json, wrong_questions_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
- """, (set_id, score_raw, score_official, total_questions,
- duration_seconds, answers_json, wrong_questions_json))
- record_id = cur.lastrowid
- log_study_event("a1_lesen", ref_id=record_id, note=f"Set {set_id}: {score_official}/25.0", minutes=max(1, duration_seconds // 60), db_path=db_path)
- return record_id
+ """持久化一次 A1 阅读模考记录(透传泛化实现,签名与返回结构不变)"""
+ return record_exam_trial("A1", "lesen", set_id, score_raw, score_official,
+ total_questions, duration_seconds, answers_json,
+ wrong_questions_json, db_path=db_path)
def get_a1_lesen_history(limit: int = 50, db_path: Optional[str] = None) -> List[Dict[str, Any]]:
- """查询 A1 阅读模考历史记录"""
- with db_progress_conn(db_path) as conn:
- rows = conn.execute("""
- SELECT * FROM a1_lesen_records ORDER BY id DESC LIMIT ?
- """, (limit,)).fetchall()
- return [dict(r) for r in rows]
+ """查询 A1 阅读模考历史记录(透传泛化实现,返回结构不变)"""
+ return get_exam_history("A1", "lesen", limit=limit, db_path=db_path)
__all__ = [
@@ -1011,4 +1106,7 @@ def get_a1_lesen_history(limit: int = 50, db_path: Optional[str] = None) -> List
"get_a1_hoeren_history",
"record_a1_lesen_trial",
"get_a1_lesen_history",
+ "record_exam_trial",
+ "get_exam_history",
+ "migrate_a1_records_to_exam_trials",
]
diff --git a/server.py b/server.py
index e682f2f..0797d06 100644
--- a/server.py
+++ b/server.py
@@ -88,6 +88,7 @@ def load_env():
_take_pending,
_db_snapshot_guard,
_replace_tables,
+ migrate_a1_records_to_exam_trials,
get_prep_saved,
add_prep_saved,
)
@@ -199,6 +200,7 @@ def _attachment_headers(filename: str) -> Dict[str, str]:
"_take_pending",
"_db_snapshot_guard",
"_replace_tables",
+ "migrate_a1_records_to_exam_trials",
"get_prep_saved",
"add_prep_saved",
"_resolve_ssrf_targets",
@@ -1589,6 +1591,8 @@ class RestoreReq(BaseModel):
daily_summary: List[Dict[str, Any]] = []
a1_hoeren_records: List[Dict[str, Any]] = []
a1_lesen_records: List[Dict[str, Any]] = []
+ # v5.2 泛化成绩表;v1/v5.1 备份没有此字段,缺省为空(向后兼容同上)
+ exam_trials: List[Dict[str, Any]] = []
local_storage: Dict[str, Any] = {}
@@ -1633,6 +1637,12 @@ def restore_database_backup(req: RestoreReq, request: Request):
finally:
pconn.close()
+ # v5.1 及更早的备份里 A1 成绩只存在旧表(exam_trials 尚未存在);
+ # 透传读路径已切到泛化表,还原后必须把旧表行迁过来,否则历史"看着
+ # 还原成功却读不出来"。迁移按行数对账幂等:v2 备份(两表行数已齐)
+ # 到这里就是零写入 no-op。
+ migrate_a1_records_to_exam_trials()
+
return {"status": "ok", "message": "全量备份恢复成功"}
diff --git a/test_exam_trials.py b/test_exam_trials.py
new file mode 100644
index 0000000..4891015
--- /dev/null
+++ b/test_exam_trials.py
@@ -0,0 +1,346 @@
+# -*- coding: utf-8 -*-
+"""exam_trials 泛化成绩表 + 幂等迁移 + 备份接线(ADR-0005 Task 4)。
+
+契约面:
+- database.record_exam_trial / get_exam_history 泛化读写(level, module 维度)。
+- 旧 record_a1_*_trial / get_a1_*_history 改内部透传:签名与返回结构逐字段
+ 等价,且行同时落进 exam_trials(level='A1', module=…)。
+- migrate_a1_records_to_exam_trials():行数对账防重入,幂等可重跑。
+- 备份链:_PROGRESS_TABLES 带 exam_trials → export 键齐 → RestoreReq 接
+ exam_trials 字段 → restore 真覆盖灌表。
+
+与 test_audit_hardening.py 同款纪律:模块顶层先钉隔离 env 再 import
+server(顶层 init_db() 副作用),clean_db autouse 前后双钉 env +
+gc.collect() 后删库(Windows 句柄释放纪律)。
+"""
+import json
+import os
+import gc
+import pytest
+
+os.environ["DATABASE_PATH"] = "test_exam_trials_delector.db"
+os.environ["PROGRESS_DB_PATH"] = "test_exam_trials_progress.db"
+
+from fastapi.testclient import TestClient # noqa: E402
+
+from server import app, init_db, get_progress_db # noqa: E402
+import database # noqa: E402
+
+_DB = "test_exam_trials_delector.db"
+_PDB = "test_exam_trials_progress.db"
+_DB_FILES = (_DB, _PDB)
+
+_HOEREN_FIELDS = {
+ "set_id": 3, "score_raw": 18, "score_official": 20.5,
+ "total_questions": 25, "duration_seconds": 640,
+ "answers_json": '{"a1_h_01_t1_q01": "B"}',
+ "wrong_questions_json": '[{"qid": "a1_h_01_t1_q02"}]',
+}
+
+
+@pytest.fixture
+def client():
+ # 显式 127.0.0.1:restore 端点有 _require_localhost 闸。
+ return TestClient(app, client=("127.0.0.1", 54321))
+
+
+@pytest.fixture(autouse=True)
+def clean_db():
+ saved = {k: os.environ.get(k) for k in ("DATABASE_PATH", "PROGRESS_DB_PATH")}
+ os.environ["DATABASE_PATH"] = _DB
+ os.environ["PROGRESS_DB_PATH"] = _PDB
+ gc.collect()
+ for f in _DB_FILES:
+ for suffix in ("", "-wal", "-shm"):
+ p = f + suffix
+ if os.path.exists(p):
+ try:
+ os.remove(p)
+ except OSError:
+ pass
+ init_db(_DB) # 内部连带 init_progress_db() + 迁移调用
+ yield
+ gc.collect()
+ for f in _DB_FILES:
+ for suffix in ("", "-wal", "-shm"):
+ p = f + suffix
+ if os.path.exists(p):
+ try:
+ os.remove(p)
+ except OSError:
+ pass
+ for k, v in saved.items():
+ if v is None:
+ os.environ.pop(k, None)
+ else:
+ os.environ[k] = v
+
+
+# ── RED 1:泛化写读回(字段逐个对,无 level/module 冗余字段) ────────────────
+
+def test_record_exam_trial_roundtrip_fields():
+ rid = database.record_exam_trial(
+ "A1", "hoeren", db_path=_PDB, **_HOEREN_FIELDS)
+ assert isinstance(rid, int) and rid > 0
+
+ hist = database.get_exam_history("A1", "hoeren", db_path=_PDB)
+ assert len(hist) == 1
+ row = hist[0]
+ # 与旧 get_a1_*_history 逐字段等价:SELECT * 的全列,不加冗余
+ assert set(row.keys()) == {
+ "id", "set_id", "score_raw", "score_official", "total_questions",
+ "duration_seconds", "answers_json", "wrong_questions_json", "created_at",
+ }
+ for k, v in _HOEREN_FIELDS.items():
+ assert row[k] == v, f"{k}: {row[k]!r} != {v!r}"
+ assert row["created_at"] is not None
+
+ # level/module 维度隔离:lesen 视角看不见 hoeren 的行
+ assert database.get_exam_history("A1", "lesen", db_path=_PDB) == []
+ assert database.get_exam_history("A2", "hoeren", db_path=_PDB) == []
+
+ # limit 语义与旧函数一致(ORDER BY id DESC LIMIT ?)
+ for i in range(3):
+ database.record_exam_trial(
+ "A1", "lesen", set_id=i + 1, score_raw=10 + i, score_official=8.0 + i,
+ total_questions=25, duration_seconds=300,
+ answers_json="{}", wrong_questions_json="[]", db_path=_PDB)
+ lesen = database.get_exam_history("A1", "lesen", limit=2, db_path=_PDB)
+ assert [r["set_id"] for r in lesen] == [3, 2]
+
+
+# ── RED 2:旧函数透传——签名/返回结构不变 + 行落进泛化表 ─────────────────────
+
+def test_legacy_a1_hoeren_trial_passes_through(db_path=_PDB):
+ rid = database.record_a1_hoeren_trial(
+ _HOEREN_FIELDS["set_id"], _HOEREN_FIELDS["score_raw"],
+ _HOEREN_FIELDS["score_official"], _HOEREN_FIELDS["total_questions"],
+ _HOEREN_FIELDS["duration_seconds"], _HOEREN_FIELDS["answers_json"],
+ _HOEREN_FIELDS["wrong_questions_json"], db_path=_PDB)
+ assert isinstance(rid, int) and rid > 0
+
+ hist = database.get_a1_hoeren_history(limit=50, db_path=_PDB)
+ assert len(hist) == 1
+ row = hist[0]
+ assert set(row.keys()) == {
+ "id", "set_id", "score_raw", "score_official", "total_questions",
+ "duration_seconds", "answers_json", "wrong_questions_json", "created_at",
+ }
+ for k, v in _HOEREN_FIELDS.items():
+ assert row[k] == v
+
+ # 同一行必须同时出现在 exam_trials(level='A1', module='hoeren')
+ with database.db_progress_conn(_PDB) as conn:
+ g = conn.execute(
+ "SELECT COUNT(*) FROM exam_trials WHERE level='A1' AND module='hoeren'"
+ ).fetchone()[0]
+ assert g == 1
+
+
+def test_legacy_a1_lesen_trial_passes_through():
+ rid = database.record_a1_lesen_trial(
+ 2, 15, 19.0, 25, 700, '{"q": "R"}', "[]", db_path=_PDB)
+ assert isinstance(rid, int) and rid > 0
+ hist = database.get_a1_lesen_history(db_path=_PDB)
+ assert len(hist) == 1
+ assert hist[0]["score_official"] == 19.0
+ with database.db_progress_conn(_PDB) as conn:
+ g = conn.execute(
+ "SELECT COUNT(*) FROM exam_trials WHERE level='A1' AND module='lesen'"
+ ).fetchone()[0]
+ assert g == 1
+
+
+# ── RED 3:幂等迁移——行数对账 + created_at 原值拷贝 + 可重跑 ─────────────────
+
+def _seed_legacy_rows():
+ with database.db_progress_conn(_PDB) as conn:
+ conn.executemany(
+ """INSERT INTO a1_hoeren_records
+ (set_id, score_raw, score_official, total_questions,
+ duration_seconds, answers_json, wrong_questions_json, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ [
+ (1, 20, 22.0, 25, 600, "{}", "[]", "2026-08-01 10:00:00"),
+ (2, 15, 18.5, 25, 610, "{}", "[]", "2026-08-02 11:30:00"),
+ ])
+ conn.execute(
+ """INSERT INTO a1_lesen_records
+ (set_id, score_raw, score_official, total_questions,
+ duration_seconds, answers_json, wrong_questions_json, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
+ (1, 22, 24.0, 25, 700, "{}", "[]", "2026-08-03 09:15:00"))
+
+
+def test_migrate_a1_records_idempotent():
+ _seed_legacy_rows()
+ report = database.migrate_a1_records_to_exam_trials(db_path=_PDB)
+ assert report == {
+ "hoeren": {"migrated": 2, "skipped": False},
+ "lesen": {"migrated": 1, "skipped": False},
+ }
+
+ with database.db_progress_conn(_PDB) as conn:
+ rows = conn.execute(
+ "SELECT level, module, set_id, score_raw, score_official,"
+ " total_questions, duration_seconds, answers_json,"
+ " wrong_questions_json, created_at FROM exam_trials"
+ " ORDER BY level, module, set_id").fetchall()
+ g = conn.execute("SELECT COUNT(*) FROM exam_trials").fetchone()[0]
+ assert g == 3
+ by_key = {(r["level"], r["module"], r["set_id"]): dict(r) for r in rows}
+ h1 = by_key[("A1", "hoeren", 1)]
+ assert h1["score_raw"] == 20 and h1["score_official"] == 22.0
+ assert h1["created_at"] == "2026-08-01 10:00:00" # created_at 原值拷贝
+ l1 = by_key[("A1", "lesen", 1)]
+ assert l1["score_raw"] == 22 and l1["created_at"] == "2026-08-03 09:15:00"
+
+ # 第二次跑:行数对账即跳过,零新增
+ report2 = database.migrate_a1_records_to_exam_trials(db_path=_PDB)
+ assert report2 == {
+ "hoeren": {"migrated": 0, "skipped": True},
+ "lesen": {"migrated": 0, "skipped": True},
+ }
+ with database.db_progress_conn(_PDB) as conn:
+ g2 = conn.execute("SELECT COUNT(*) FROM exam_trials").fetchone()[0]
+ assert g2 == 3, "重复迁移不得翻倍"
+
+
+def test_migrate_on_empty_legacy_tables_skips():
+ # 空旧表:0 >= 0 行数对账即跳过,不写任何行
+ report = database.migrate_a1_records_to_exam_trials(db_path=_PDB)
+ assert report == {
+ "hoeren": {"migrated": 0, "skipped": True},
+ "lesen": {"migrated": 0, "skipped": True},
+ }
+
+
+def test_migrate_after_new_grade_does_not_duplicate():
+ """迁移完成后 + 透传新成绩 + 重启(再跑 migrate)→ 旧行不得重插二次。
+
+ 回归:migrate 之前用 ==(general_count == legacy_count)做对账,但透传
+ 模式后旧表冻结、exam_trials 随新成绩单调增,一旦「迁移后又做了一次成绩」,
+ general = legacy+1 ≠ legacy 会被误判为未迁移,把旧表整行再插一遍导致重复
+ (已复现:6→count 3→migrate2 迁 2 行→count 5)。
+ """
+ _seed_legacy_rows() # hoeren 2 行 + lesen 1 行
+ database.migrate_a1_records_to_exam_trials(db_path=_PDB)
+
+ # 透传写一条**新**成绩:旧表冻结、只写 exam_trials(level='A1', module='hoeren')
+ database.record_a1_hoeren_trial(
+ _HOEREN_FIELDS["set_id"], _HOEREN_FIELDS["score_raw"],
+ _HOEREN_FIELDS["score_official"], _HOEREN_FIELDS["total_questions"],
+ _HOEREN_FIELDS["duration_seconds"], _HOEREN_FIELDS["answers_json"],
+ _HOEREN_FIELDS["wrong_questions_json"], db_path=_PDB)
+
+ # 模拟重启再跑迁移:此时 general=3(2 迁移 + 1 新)> legacy=2,必须 skip
+ report = database.migrate_a1_records_to_exam_trials(db_path=_PDB)
+ assert report == {
+ "hoeren": {"migrated": 0, "skipped": True},
+ "lesen": {"migrated": 0, "skipped": True},
+ }
+ with database.db_progress_conn(_PDB) as conn:
+ g = conn.execute("SELECT COUNT(*) FROM exam_trials").fetchone()[0]
+ assert g == 4, "迁移后新成绩 + 重启不得让旧行重插(重复数据)"
+
+
+# ── RED 4:备份接线——export 键齐 + RestoreReq + restore 灌表 ────────────────
+
+def test_backup_payload_contains_exam_trials():
+ database.record_exam_trial(
+ "A1", "hoeren", db_path=_PDB, **_HOEREN_FIELDS)
+ from database import build_backup_payload
+ payload = build_backup_payload()
+ assert "exam_trials" in payload
+ assert len(payload["exam_trials"]) == 1
+ assert payload["exam_trials"][0]["level"] == "A1"
+ assert payload["exam_trials"][0]["module"] == "hoeren"
+ assert payload["exam_trials"][0]["score_raw"] == _HOEREN_FIELDS["score_raw"]
+
+
+def test_restore_exam_trials_roundtrip(client):
+ from server import RestoreReq
+ assert "exam_trials" in RestoreReq.model_fields
+
+ row = dict(_HOEREN_FIELDS)
+ row.update({"id": 7, "level": "A1", "module": "hoeren",
+ "created_at": "2026-08-04 12:00:00"})
+ payload = {"version": 2, "exam_trials": [row]}
+ res = client.post("/api/backup/restore", json=payload)
+ assert res.status_code == 200
+
+ with get_progress_db(_PDB) as conn:
+ got = dict(conn.execute(
+ "SELECT * FROM exam_trials WHERE id = 7").fetchone())
+ for k, v in row.items():
+ assert got[k] == v, f"{k}: {got[k]!r} != {v!r}"
+
+
+def test_restore_v51_backup_keeps_a1_history_visible(client):
+ """v5.1 备份里 A1 成绩只存在旧表:还原后透传读路径必须能看到。
+
+ 读路径已切到泛化表(exam_trials),而 v5.1 备份没有 exam_trials 键——
+ 若 restore 不补一次幂等迁移,历史会「看着还原成功却读不出来」。
+ """
+ payload = {
+ "version": 2,
+ "a1_hoeren_records": [{
+ "id": 101, "set_id": 1, "score_raw": 14, "score_official": 23.3,
+ "total_questions": 15, "duration_seconds": 580,
+ "answers_json": "{\"q1\": \"A\"}", "wrong_questions_json": "[]",
+ "created_at": "2026-09-01T12:00:00",
+ }],
+ }
+ res = client.post("/api/backup/restore", json=payload)
+ assert res.status_code == 200
+
+ hist = database.get_a1_hoeren_history(db_path=_PDB)
+ assert len(hist) == 1
+ assert hist[0]["score_raw"] == 14
+ # 行数对账不翻倍:再跑 restore 一圈(迁移幂等)历史仍只有 1 条
+ client.post("/api/backup/restore", json=payload)
+ assert len(database.get_a1_hoeren_history(db_path=_PDB)) == 1
+
+
+# ── RED 5:routes 透传端到端(grade 落库 + history 读回) ────────────────────
+
+def test_hoeren_routes_end_to_end(client):
+ grade = client.post("/api/a1/hoeren/grade", json={
+ "set_id": 1, "duration_seconds": 600,
+ "answers": {"a1_h_01_t1_q01": "B"},
+ })
+ assert grade.status_code == 200
+ assert "score_official" in grade.json()
+
+ hist = client.get("/api/a1/hoeren/history")
+ assert hist.status_code == 200
+ entries = hist.json()["history"]
+ assert len(entries) == 1
+ assert entries[0]["set_id"] == 1
+ assert set(entries[0].keys()) == {
+ "id", "set_id", "score_raw", "score_official", "total_questions",
+ "duration_seconds", "answers_json", "wrong_questions_json", "created_at",
+ }
+
+ # 端到端:这条记录同时落在泛化表
+ with database.db_progress_conn(_PDB) as conn:
+ g = conn.execute(
+ "SELECT COUNT(*) FROM exam_trials WHERE level='A1' AND module='hoeren'"
+ ).fetchone()[0]
+ assert g == 1
+
+
+def test_lesen_routes_end_to_end(client):
+ grade = client.post("/api/a1/lesen/grade", json={
+ "set_id": 1, "duration_seconds": 750,
+ "answers": {"a1_l_01_t1_q01": "R"},
+ })
+ assert grade.status_code == 200
+ hist = client.get("/api/a1/lesen/history")
+ assert hist.status_code == 200
+ assert len(hist.json()["history"]) == 1
+ with database.db_progress_conn(_PDB) as conn:
+ g = conn.execute(
+ "SELECT COUNT(*) FROM exam_trials WHERE level='A1' AND module='lesen'"
+ ).fetchone()[0]
+ assert g == 1
diff --git a/test_server.py b/test_server.py
index 0fd898d..58852a6 100644
--- a/test_server.py
+++ b/test_server.py
@@ -48,6 +48,13 @@ def clean_db():
# 事务不 close,文件句柄要等循环 GC 才释放。Windows 上句柄未释放时 os.remove
# 抛 PermissionError(被吞),旧 DB 带着上一测试的数据残留下来 → 隔离失效。
# 删除前先 gc.collect(),把上一测试遗留的连接环清掉,删除才确定成功。
+ # 前后双钉 env:database.get_db_path() 每次调用都读 os.environ(不是 import
+ # 时冻结),全量 pytest 时更晚收集的文件(test_audit_hardening.py 等)在
+ # 模块顶层改写 DATABASE_PATH,收集顺序若让本文件先 import、用例后执行,
+ # 默认路径就会命中未建表的别家库 → 「no such table」成片假失败。
+ saved = {k: os.environ.get(k) for k in ("DATABASE_PATH", "PROGRESS_DB_PATH")}
+ os.environ["DATABASE_PATH"] = "test_delector.db"
+ os.environ["PROGRESS_DB_PATH"] = "test_progress.db"
gc.collect()
for f in ("test_delector.db", "test_progress.db"):
if os.path.exists(f):
@@ -64,6 +71,11 @@ def clean_db():
os.remove(f)
except OSError:
pass
+ for k, v in saved.items():
+ if v is None:
+ os.environ.pop(k, None)
+ else:
+ os.environ[k] = v
def test_cefr_lookup():
assert get_cefr_level("gehen") == "A1"
From f87f14582c21e129ea704efdcd9cf79a9c82aa0e Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 19:01:42 +0800
Subject: [PATCH 10/12] =?UTF-8?q?docs(ia):=20Task=204=20ledger=20=E5=9B=9E?=
=?UTF-8?q?=E5=A1=AB=EF=BC=88=E6=B3=9B=E5=8C=96=E8=A1=A8=20af93df0=20+=20?=
=?UTF-8?q?=E8=AF=84=E5=AE=A1=20PASS=20+=20=E8=BF=81=E7=A7=BB=E9=87=8D?=
=?UTF-8?q?=E5=85=A5=E4=BF=AE=E5=A4=8D=E6=B3=A8=E8=AE=B0=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...026-09-04-ia-nav-exam-domain-phase1-ledger.md | 16 ++++++++++++----
1 file changed, 12 insertions(+), 4 deletions(-)
diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
index b976084..5c29121 100644
--- a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
+++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
@@ -79,10 +79,18 @@
## Task 4: exam_trials 泛化表
-- [ ] Step 1: test_exam_trials.py RED(幂等/透传/备份往返)
-- [ ] Step 2: 表 + 函数 + 迁移 + _PROGRESS_TABLES + RestoreReq GREEN
-- [ ] Step 3: routes_a1_hoeren/lesen 回归
-- [ ] Step 4: commit
+- [x] Step 1: test_exam_trials.py RED(9 failed:AttributeError/OperationalError,符合预期)
+- [x] Step 2: 表 + 函数 + 迁移 + _PROGRESS_TABLES + RestoreReq GREEN
+- [x] Step 3: routes_a1_hoeren/lesen 回归 + 迁移幂等验证
+- [x] Step 4: commit `af93df0`
+
+**评审期间发现并修复真缺陷(MUST 级)**:
+- migrate 幂等谓词原 `==`,透传后旧表冻结+新表随成绩单调增,「迁移后又做一次成绩再重启」场景 general=legacy+1 误判未迁移→整表重插(真实时序复现 count 3→5)。改为 `>=`,新增 test_migrate_after_new_grade_does_not_duplicate 钉死。db_progress_conn 单事务保证半迁回滚。
+
+**偏差注记**:
+- 首个评审子代理因 429 截断;编排者亲自复现缺陷后补派聚焦评审确认修复与无遗漏(PASS)。
+- server.py 加 restore 后迁移调用(v5.1 备份无 exam_trials 键时历史仍可见)——计划未列,属必要补漏。
+- test_server.py clean_db env 双钉修复(收集顺序健壮性),与 Task 0 test_audit_hardening 修复同源模式。
## Task 5: 收口
From 7a4c65872e6d9474211ddb32e71ab965e98992ae Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 19:06:28 +0800
Subject: [PATCH 11/12] =?UTF-8?q?docs(ia):=20README=20=E5=90=8C=E6=AD=A5?=
=?UTF-8?q?=E7=89=88=E6=9C=AC=E9=9D=A2=EF=BC=88=E6=B5=8B=E8=AF=95=20559=20?=
=?UTF-8?q?=E5=85=A8=E7=BB=BF=20+=20=E5=A4=87=E8=80=83=E5=9F=9F/=E7=9B=AE?=
=?UTF-8?q?=E5=BD=95/=E6=B3=9B=E5=8C=96=E6=88=90=E7=BB=A9=E8=A1=A8?=
=?UTF-8?q?=E7=89=B9=E6=80=A7=20+=20=E8=B7=AF=E7=BA=BF=E5=9B=BE=20v5.2.0?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 7b2165b..2aae678 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
-
+
@@ -136,6 +136,13 @@
- **学习设置即时生效(含队列行为探针)**:每日新词上限、新词排序、学习范围三组设置在工作台顶栏分段控件一键切换,无需刷新页面;尾部队列同步重算、手动追加的词豁免裁剪。v5.0.0 新增 node:vm 动态探针验证真实队列行为。
+### 🎯 12. 备考域重布局与等级可扩展 (ADR-0005 首刀, v5.2.0)
+
+- **独立「备考」域**:主导航新增 `PRÜFUNG` 顶层入口(桌面 + 移动 dock),A1 写作/听力/阅读/口语/词表五模块从工具视图收拢入域;「写作润色」回归纯 essay、「复习卡片」回归纯复习,考纲素材不再混入工具容器。
+- **exam catalog 目录化**:`/api/exams/catalog` 由 `exam_catalog.py` 代码注册目录单源提供等级→模块导航;等级页签与模块卡片数据驱动,加 A2/B1 = 插一行注册数据,无需改 HTML/JS(问卷库不入 SQLite,YAGNI)。
+- **成绩表泛化**:`exam_trials(level,module,…)` 泛化成续表 + 幂等迁移 A1 存量(行数对账 `>=` 防透传模式重迁),旧 `a1_hoeren_records`/`a1_lesen_records` 保留兼容期,备份/还原接线完整。
+- **行为级 DOM 探针**:`tools/ia_dom_mount_probe.mjs` 以 node:vm 真跑迁移后的挂载链,断言五面板落位 view-exam、window 挂载可达、渲染目标不回退主站容器;实现回退必红。
+
---
## 🚀 快速启动指南 (Quick Start)
@@ -366,6 +373,8 @@ DeLector/
- [x] **v5.1.0**:**局域网随时静默同步 Stage B(WebRTC 自动化)**——① 信令端点补 `X-WB-Key` 鉴权并修 POST 预检放行;② **持久配对凭证 + 一键撤销**(撤销即换新 key,替代每会话短码);③ WebRTC 信令中继 `/api/wb/rtc/signal`(按配对密钥建邮箱、sender 过滤防重放);④ 前端 `wbsync.rtc` 建连与 DataChannel **静默同步**(信封与 HTTP PUT 同构);⑤ 断线自动重连 + HTTP 轮询兜底降级(连续失败停手保可达)。Stage A HTTP 轮询保留为兜底。全量 pytest **487 全绿**;9 wbsync 探针 + 40 定向测试无回归。
+- [x] **v5.2.0**:**备考域重布局 + 等级可扩展(ADR-0005 Phase 1)**——① **主导航加「备考 (Prüfung)」顶层域**:A1 写作/听力/阅读/口语/词表五模块从「写作润色」「复习卡片」工具容器迁入独立备考域,工具视图回归纯工具语义(写作=纯 essay、卡片=纯复习);② **exam catalog 目录化**:`/api/exams/catalog` 代码注册目录单源,等级页签与模块卡片数据驱动,加 A2/B1 = 插一行数据(问卷库不入库,YAGNI);③ **成绩表泛化**:`exam_trials(level,module,…)` 表 + 幂等迁移 A1 存量(旧 `a1_hoeren_records`/`a1_lesen_records` 保留兼容),备份/还原接线收编;④ **导航单源静态入口 + 备考域骨架**、`tools/ia_dom_mount_probe.mjs` 行为级 DOM 探针(node:vm 真跑,回退必红)。测试 **559 全绿**。
+
- [x] **v5.1.1**:**审计修复收口 + 性能与稳定性 (M1–M5 + M4)**——① **审计修复(M1–M5)**:旧 6 位短码 LAN 面板停用标注并整体禁用(端点已强制配对密钥,死 UI 明示);AI 判分/成功提示类残余 `alert` 收敛为 notify(写路径保留 + 双面黑白名单护栏);wb pull 指数退避、RTC 瞬态不累计、阅读计时器防叠;② **性能(M4)**:查词/判题热路径常量模块级提升、复合词拆解与核心词查表缓存、句切分缩写保护正则提升;③ **稳定性**:PWA 版本更新改温和提示不硬刷窗口、TTS blob URL 统一撤销 + 播放请求令牌防错句覆盖、Reader 陈旧响应守卫、AI 请求输入上限与 TTS voice 白名单;④ **安全补漏**:批注删除纳入本机写闸、X-WB-Key 统一 `secrets.compare_digest` 消除时序侧信道、还原不导入 API 配置防 Key 外泄、Anki 导出 HTML 转义防存储型 XSS;⑤ 测试库隔离与断言护栏补齐。**本版同时回补 v5.0.2 → v5.1.1 的版本面同步**(sw.js 缓存键 / index.html 顶栏 / build.gradle / README / AGENTS)。
- [x] **`server.py`** **拆分重构**(v4.6.4):3053 行单文件拆为 `nlp.py`(NLP/CEFR/文本分析)、`database.py`(DB/CRUD/备份)、`security.py`(SSRF/URL 安全),`server.py` 保留路由骨架。依赖图无环,319 测试全绿。
From 81902e7a2c7961df8001cccc37f79e05ef04a0a0 Mon Sep 17 00:00:00 2001
From: Haoyu Xi
Date: Fri, 4 Sep 2026 19:06:44 +0800
Subject: [PATCH 12/12] =?UTF-8?q?docs(ia):=20Phase1=20ledger=20=E6=94=B6?=
=?UTF-8?q?=E5=8F=A3=EF=BC=88559=20=E5=85=A8=E7=BB=BF=E5=9B=9E=E5=BD=92=20?=
=?UTF-8?q?+=20=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D=E6=B3=A8=E8=AE=B0=20+?=
=?UTF-8?q?=20README=20=E5=90=8C=E6=AD=A5=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...-09-04-ia-nav-exam-domain-phase1-ledger.md | 25 +++++++++++++++----
1 file changed, 20 insertions(+), 5 deletions(-)
diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
index 5c29121..f452016 100644
--- a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
+++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md
@@ -94,8 +94,23 @@
## Task 5: 收口
-- [ ] Step 1: 全量回归差异表
-- [ ] Step 2: ledger 回填
-- [ ] Step 3: push
-- [ ] Step 4: PR
-- [ ] Step 5: docs commit
+- [x] Step 1: 全量回归差异表——基线 518 → 559 passed(+41);前端定向 128 → 176;10/10 tools/*.mjs 探针全绿
+- [x] Step 2: ledger 回填 + README 同步(commit `7a4c658`)
+- [x] Step 3: push(SSH 443 通道)
+- [x] Step 4: PR(标题 `feat(ia): 备考域重布局 + catalog/成绩第一刀`)
+- [x] Step 5: docs commit
+
+### 全量回归差异表(对比 Task 0 基线)
+
+| 套件 | 基线 (Task 0) | 收口 (Task 5) | Δ |
+|---|---|---|---|
+| 全量 pytest | 518 passed | 559 passed | +41 |
+| 前端定向(german_workbench+goethe_a1*+prep_matrix+frontend_module_graph+exam_domain+writer_mobile) | 117 | 176 | +59 |
+| tools/*.mjs 探针 | 9 绿 | 10/10 绿 | +1(ia_dom_mount_probe) |
+
+新增测试:test_exam_domain.py 11+8、test_exam_catalog.py 6、test_exam_trials.py 11;全部针对本 Phase 新增/迁移行为。
+
+### 收口说明
+- 分支 `feat/ia-nav-exam-domain` 共 10 个 commit(2ae2bc2 → 7a4c658)。
+- README 同步 v5.2.0(badge 559、特性区新增备考域/目录/泛化成绩表、路线图 v5.2.0 条目)。
+- 工作区残留 .gitignore/.gitignore.md/AGENTS.md/CLAUDE.md/WORKMEMORY/GEMINI.md 为**并行会话注入**(非本任务产物),未纳入本分支 commit。