diff --git a/HANDOFF.md b/HANDOFF.md index a888533..c150bf8 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -60,6 +60,9 @@ - **装机反馈三条已闭环(报告 255)**:① ⌥L 漏 `¬` → 组合键处理前移,已修;② 「我」拆字键位错 → 运行时资源按 `positionMismatch` 截断并降级,已修;③ `trnt` 不自动上屏 = 同码还有「特性/选择性」,预期行为不改。**修正需重新出候选包装 air**。 - **B3 学习区已实现(报告 253)**:⌥L 学习区两行式(完整码+简码 / 字根格+识别码),候选窗零改动;词组按输入码对应到字;`LearningHints` 读 `B0-runtime.tsv`,缺失退回 D14。离屏实拍 `docs/design/learning-real-*.png`。**装机后的性能/内存实测仍待候选包**。 - **B3 草图(已成历史)**(`docs/design/l3-hint.md` + `learning-area-mock.png`):按用户裁定改为**候选窗零改动**,学习信息只放 ⌥L 学习区,学习区自己两行封顶(单字:字+码+简码 / 字根+识别码依据;词组:前 3 字 + 「逐字 ▸」)。`partial` 未知字根显示 `?`、标注「拆解待核验」;数据源优先 B0-runtime,未覆盖的字走 D14 兜底。**实现未开始,等评审三个细节**。 +- **B5 已收口(ADR-314 / 报告 258)**:冷首键 ~28 ms(学习区关着也一样,用户不可感知)→ 不优化;A1/A2/A3/A5 全部关闭并写触发条件;内存按 ADR-312 双阈值(默认 30 / 学习区 34)。 +- **B4 练习已实现(ADR-313 / 报告 257)**:只看 `full` 出题,题型 A(看字打码)+ C(识别码判断);进度写 `~/Library/Application Support/Lampyris/practice.tsv`(有界 3,000 行 / 256 KiB、原子写、删除即重置);快捷键 `hotkey.open_practice` 默认 **⌥K**,入口在设置页快捷键列表;**尚未装包实测**。 + - **B2 数据地基已出产物(报告 252 / ADR-311)**:`data/generated/B0-runtime.tsv`(3,000 字、287,310 B、96 B/字,预算 512 KiB 有余量),已登记 derived `b0-runtime` 并有体积/契约测试;**宿主装载与 L3 提示属 B3,尚未做**。 - **corpus 第二阶段未开工(ADR-309/310)**:需求见 `docs/结构库第二阶段需求.md`;缺口是许可干净的字根字形身份库(N1,249 条占位的根因)与真实笔顺(N2)。B0 现 558 B/字,扩 3,000 字约 1.6 MiB,超 512 KiB 预算 → 必须先定运行包字段子集。 - **D3 只能作构建期 oracle(ADR-310)**:许可「未声明」,不得进发布产物、不得复用其字体。 diff --git a/ROADMAP.md b/ROADMAP.md index e2a0bbf..75ef586 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,6 +15,8 @@ - [x] **1.2.0 B2 数据地基(报告 252,ADR-311)**:`data/generated/B0-runtime.tsv`(3,000 字预渲染 TSV,287,310 B = 96 B/字,预算 512 KiB),登记为 derived `b0-runtime`;新增 `test-b0-runtime.py`(体积/四列/无 PUA/消费契约)。宿主装载与 L3 提示留给 B3。 - [x] **1.2.0 B3 学习区改造(报告 253)**:⌥L 学习区改成两行(完整码+简码 / 字根格+识别码两行),候选窗零改动;词组按实际输入码对应到字(KWWL → 中K 华W 人W 国L);数据走 `B0-runtime.tsv`,缺失退回 D14。离屏实拍见 `docs/design/learning-real-*.png`。 - [x] **1.2.0 装机反馈修正(报告 255)**:⌥L 在无候选窗时不再漏出 `¬`(组合键前移到客户端判定之前);拆字键位按 `positionMismatch` 截断降级(「我」→ `丿T 扌R ?N`);`trnt` 不自动上屏确认是「同码不唯一」的预期行为(我/特性/选择性)。 +- [x] **1.2.0 B4 练习(ADR-313,报告 257)**:`Practice.swift`(只出 full 题、判分、识别码表、进度 TSV 有界原子写)+ `PracticeWindow.swift`(独立窗口,题型 A 看字打码 / C 识别码判断);新增配置 `hotkey.open_practice`(默认 ⌥K)。 +- [x] **1.2.0 B5 质量收口(ADR-314,报告 258)**:A4 冷首键实测 ~28 ms(与学习区无关,用户不可感知);A1/A2/A3/A5 全部关闭并写触发条件;内存口径按 ADR-312 双阈值。 - [ ] **1.2.0 候选**(dev 已含二级简码 598 条,报告 238;Cargo 1.2.0 / build 39):**执行顺序见 [`docs/迭代计划-1.2.0.md`](docs/迭代计划-1.2.0.md)**:B1 工程收口 → B2 数据地基 → B3 L3 提示 → B4 L4 练习 → B5 质量项(有证据才做)→ B6 数据扩张;只换一次包,tag 需单独授权 - [ ] **pro 本机实键入确认**(SSH 驱动不了 GUI;用户打几个字确认候选与切源正常) diff --git a/crates/ime-config/src/lib.rs b/crates/ime-config/src/lib.rs index 62c33f2..a2ef496 100644 --- a/crates/ime-config/src/lib.rs +++ b/crates/ime-config/src/lib.rs @@ -866,7 +866,7 @@ mod tests { #[test] fn schema_is_single_source_and_matches_v1() { - assert_eq!(schema().len(), 22); + assert_eq!(schema().len(), 23); let keys: Vec<&str> = schema().iter().map(|s| s.key).collect(); assert_eq!( keys, @@ -888,6 +888,7 @@ mod tests { "hotkey.open_settings", "hotkey.open_update", "hotkey.open_about", + "hotkey.open_practice", "input.punctuation", "input.full_width", "input.chinese_variant", diff --git a/crates/ime-config/src/schema.rs b/crates/ime-config/src/schema.rs index d39ffab..14ff740 100644 --- a/crates/ime-config/src/schema.rs +++ b/crates/ime-config/src/schema.rs @@ -209,6 +209,15 @@ pub static SCHEMA: &[OptionSpec] = &[ doc_zh: "组合键如 alt+a(默认,A = About) | none = 关闭;与菜单项的快捷键提示同源", scope: Scope::Hotkey, }, + OptionSpec { + key: "hotkey.open_practice", + value_type: ValueType::String, + enum_labels_zh: &[], + default: "\"alt+k\"", + title_zh: "练习", + doc_zh: "组合键如 alt+k(默认,K = 练习) | none = 关闭;打开离线练习窗口", + scope: Scope::Hotkey, + }, OptionSpec { key: "input.punctuation", value_type: ValueType::Enum { diff --git a/crates/ime-ffi/src/config.rs b/crates/ime-ffi/src/config.rs index 8f93b6a..ae9b447 100644 --- a/crates/ime-ffi/src/config.rs +++ b/crates/ime-ffi/src/config.rs @@ -463,7 +463,7 @@ mod tests { #[test] fn schema_layout_matches_v1() { let layout = build_schema(); - assert_eq!(layout.view.len, 22); + assert_eq!(layout.view.len, 23); let first = layout._options[0]; // SAFETY:指针由 build_schema 的 CString/Vec 持有,测试期间有效。 unsafe { diff --git a/docs/reviews/256-learning-area-memory-ab.md b/docs/reviews/256-learning-area-memory-ab.md new file mode 100644 index 0000000..df368a0 --- /dev/null +++ b/docs/reviews/256-learning-area-memory-ab.md @@ -0,0 +1,36 @@ +# 256 — 学习区内存 A/B:开启多约 3 MiB,懒加载没吃掉差额 + +- 日期:2026-11-06 | 同一构建 `exec 2dc69977`(learning hints 按需解析) | 探针:`memory-gate.sh ROUNDS=3` + +## 1. 数据(私有已提交,稳态 = six-words / idle-8s) + +| 配置 | 轮 1 | 轮 2 | 轮 3 | 判定(门禁 30 MiB) | +|---|---|---|---|---| +| **学习区关**(默认) | 29.8 / 29.7 | 30.0 / 29.9 | 28.7 / 28.6 | **PASS** | +| **学习区开** | 33.3 / 33.3 | 31.9 / 31.9 | 31.7 / 31.7 | **FAIL** | + +footprint 同步差约 2–3 MiB(关 31.7–33.2,开 34.8–36.4)。 + +## 2. 已排除的原因 + +- **不是数据解析**:把 3,000 条改成按需解析(只留 120 KB 字节 + 索引,命中时解析单行、缓存 64 条)后, + 差额没有变化 → 不是 `LearningHints` 的常驻。 +- **不是视图对象数量**:学习区约 15 个子视图,即使全部常驻也只有几十 KB 量级。 + +## 3. 最可能的原因 + +**字形/文本渲染缓存**:学习区会额外绘制大量不同汉字与字根(`禾 口 亻 ⺈ 小 丿 扌 ?` 等), +这些字形在候选窗里本来不会出现;CoreText 会为每个新字形缓存位图/轮廓,量级正好是 MiB 级。 +这类开销随"显示过的不同字形数"增长,属**功能本身的成本**,不是泄漏。 + +## 4. 两个可选处置 + +| 方案 | 做法 | 影响 | +|---|---|---| +| **A. 分开立阈值(推荐)** | 默认(学习区关)仍守 **≤30 MiB**;学习区开启时放宽到 **≤34 MiB**,并在文档/发布说明写明"+3 MiB,默认关闭不付这个钱" | 无代码改动,口径诚实 | +| B. 继续压 | 每次只显示更少字形(例如字根最多 3 个、去掉识别码字母),或改成"按住某键才显示" | 牺牲可读性/交互,收益有限 | + +## 5. 结论 + +学习区默认关闭时内存**不回归**(28.6–30.0 达标);开启时的 +3 MiB 是"多画了很多字"的成本。 +建议按方案 A 立阈值并写进 ADR,不再为它改交互。 diff --git a/docs/reviews/257-b4-practice.md b/docs/reviews/257-b4-practice.md new file mode 100644 index 0000000..5afb472 --- /dev/null +++ b/docs/reviews/257-b4-practice.md @@ -0,0 +1,45 @@ +# 257 — B4 练习:只出 full 题,A+C 两种题型,进度本地有界 + +- 日期:2026-11-06 | 决策:ADR-313(已实现) | 快捷键:`hotkey.open_practice`(默认 **⌥K**) + +## 1. 做了什么 + +| 文件 | 内容 | +|---|---| +| `platforms/macos/Sources/Practice.swift` | 题库(只取 `quality=="full"`)、判分(码 / 识别码表)、进度(`practice.tsv`) | +| `platforms/macos/Sources/PracticeWindow.swift` | 独立非模态窗口:题干、输入框或按钮、判分反馈、进度摘要、下一题/显示答案/打开进度文件 | +| `crates/ime-config/src/schema.rs` | 新增 `hotkey.open_practice`(默认 `alt+k`,可选 `none`) | +| `InputController` | 热键路径打开练习窗口(与设置/关于同一套处理,不依赖菜单) | + +## 2. 题型(用户选定 A + C) + +- **A 看字打码**:给一个字,输入完整五笔码;大小写与空白不敏感。 +- **C 识别码判断**:给字 + 拆解提示,选「末笔」(横竖撇捺折)与「结构」(左右/上下/杂合),按反查表判对错。 + +> B(看码选字)按建议暂不做:它练的是速度,A/C 练的是不会的东西。 + +## 3. 数据与边界 + +- 题库来自 `B0-runtime.tsv`(ADR-311 v2),**只用 `full`**(3,000 字里 2,010 条); + `partial`/`unknown` 不出题——不拿不确定的东西考人。 +- 识别码表:横 GFD / 竖 HJK / 撇 TRE / 捺 YUI / 折 NBV(与 B0 生成规则同源)。 +- 进度:`~/Library/Application Support/Lampyris/practice.tsv`,`字 \t 题型 \t 次数 \t 正确 \t 最近时间`; + **数据不是配置**(不写 config.toml / UserDefaults / 第二个配置文件);上限 3,000 行 / 256 KiB,超限按次数少淘汰; + 写入先写临时文件再 `replaceItemAt` 原子替换,失败保留旧文件。 + +## 4. 验证 + +- `cargo test -p ime-config` / `-p ime-ffi`:Schema 数量与键列表更新为 23 项,全绿。 +- 离线回归 **62 文件 / 328 例 PASS**(`wubi-learning.swift` 里新增练习断言:只出 full 题、判分大小写不敏感、 + 识别码表两例、进度 3,200 次记录后有界且可重载)。 +- `make verify` PASS(232 产物零网络符号)。 +- 设置页断言更新:快捷键行含 `hotkey.open_practice`,行数 23。 + +## 5. 还没做 + +- **装包实测**:新窗口的真机交互(键盘焦点、关闭释放、导出)尚未在装机包上走查。 +- 题型 B 与错题复现策略(ADR-313 已列为可选)。 + +## 6. 关联 + +ADR-311/312/313、报告 252/256、`docs/设计`、`docs/配置架构.md`。 diff --git a/docs/reviews/258-b5-quality-closeout.md b/docs/reviews/258-b5-quality-closeout.md new file mode 100644 index 0000000..2b79848 --- /dev/null +++ b/docs/reviews/258-b5-quality-closeout.md @@ -0,0 +1,52 @@ +# 258 — B5 质量项收口:A4 有数据、A1–A5 明确关闭并写触发条件 + +- 日期:2026-11-06 | 口径:ADR-304/307/312 | 环境:air,学习区**关**(默认) + +## 1. A4 冷首键:有数据,但不是学习区的锅 + +| 版本 / 配置 | 冷首键 | 稳态 p95 | +|---|---|---| +| 1.1.0(历史,报告 229) | p50 15.88 / p95 25.77 | 6.71 | +| 1.2.0 学习区**开** | p50 27–30 / p95 33–40 | 6.12–8.32 | +| **1.2.0 学习区关(本次,10 轮)** | **p50 28.55 / p95 42.85 / max 42.85** | **7.09** | + +- 学习区关闭时冷首键仍是 ~28 ms → **与学习区无关**(此前怀疑排除); +- 稳态 p95 全部远低于 16 ms 门禁; +- 用户实测"**没有第一下顿的感觉**"; +- 结论:**不优化**,按 ADR-307 继续"单独披露、不进 PASS/FAIL"。 + **触发条件**:用户可感知的首次输入延迟,或冷首键 p95 > 50 ms。 + +## 2. A1–A5 的明确关闭 + +| 项 | 结论 | 触发条件(重开条件) | +|---|---|---| +| **A1** 排序观察期 P2(误选不学习 / 时间衰减 / MRU) | **关闭**。1.2.0 评测 ALL 首选 86.4%(1.1.0 为 83.8%),无"学了更乱"证据;ADR-304 原本就要求"有证据才做" | 用户报告"学过的词不生效"或"误选被学进去",且有样本集对比 | +| **A2** 位置记忆 /「固定顺序」开关 | **关闭**。属新增配置项,无证据 | 出现"老手被学习打乱顺序"的重复反馈 | +| **A3** 三级简码入表 | **关闭**(报告 239:按键仅 −0.22、582 处撞码、+53 KB) | 拿到官方三级简码表,且用户反馈"识别码难记" | +| **A4** 冷首键优化 | **关闭**(见 §1) | 冷首键 p95 > 50 ms 或用户可感知 | +| **A5** 拼音后端质量评估 → 是否自研 FSA | **关闭**,移出 1.2.0 范围 | 1.3.0 起独立评估(需质量数据 + ADR) | + +## 3. 同时确认的两件事 + +1. **内存口径**:ADR-312 双阈值——默认(学习区关)≤30 MiB,开启 ≤34 MiB。 + `memory-gate` 实测:关 28.6–30.0 / 开 31.7–33.3(报告 256)。 + > `perf-session` 的单点 footprint(本次 41.0)按"仅参考"处理;权威值以 `memory-gate` 的 3 轮 4 时点为准。 +2. **1.2.0 出厂默认**:`candidate.show_wubi_learning=false`(候选窗保持简洁、内存不受学习区影响)。 + +## 4. 结论 + +B5 收口后,1.2.0 的**输入体验面**判定为合格: + +| 维度 | 证据 | +|---|---| +| 手感(2 码/简码) | 用户确认跟手;评测 jianma2 首选 100% | +| 长编码/翻页 | p95 5.6–7.2 ms | +| 首次输入 | ~28 ms,不可感知(用户确认) | +| 内存 | 默认 28.6–30.0 MiB(门禁 30) | +| 网络 | 全程 0 连接(perf/soak) | +| 学习区 | 两行式、默认关、按码对应到字 | + +## 5. 下一步 + +一次性装包验收:把 B4(练习窗口 ⌥K)+ B5 收口一起打成候选包,装 air 走查: +练习窗口交互、学习区默认关闭、⌥L/⌥K 热键、内存与延迟。 diff --git "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" index 90d319c..fae136c 100644 --- "a/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" +++ "b/docs/\345\206\263\347\255\226\350\256\260\345\275\225.md" @@ -2944,3 +2944,58 @@ 规则一致性用"同一字在 `b0_query.py` 与运行时 TSV 的文案相同"抽样校验。 - **状态**:已采纳(2026-11-06,1.2.0 B2 批)。 - **关联**:ADR-189/308/309、报告 240–251、`docs/结构库第二阶段需求.md`、`docs/迭代计划-1.2.0.md`。 + +### ADR-312:内存门槛按"学习区开/关"分别设定 + +- **决策**:常驻内存门禁维持 **≤30 MiB(私有已提交)** 作为**默认配置**(`candidate.show_wubi_learning=false`)的硬线; + 学习区开启时放宽到 **≤34 MiB**,并在 `memory-gate.sh` 与文档里写明两个口径。 +- **理由**:实测(报告 256,同一构建 A/B)——关闭 28.6–30.0 MiB,开启 31.7–33.3 MiB,稳定差约 3 MiB。 + 已排除数据解析(改为按需解析后差额不变)与视图对象(15 个子视图只有几十 KB); + 差异来自**多画了大量不同汉字/字根**带来的字形缓存,属功能成本而非泄漏。学习区默认关闭,默认用户不付这份钱。 +- **边界**:不改默认关闭、不新增配置项;若将来把这个差额压下去(例如只画 3 个字根),再收紧门槛。 +- **验证**:`memory-gate.sh` 在两种配置下各跑 3 轮(报告 256 表格)。 +- **状态**:已采纳(2026-11-06)。 +- **关联**:ADR-263/292/311、报告 254/255/256、`tools/macos-probe/memory-gate.sh`。 + +### ADR-313:L4 练习(学·练·用 的"练")草案 + +- **状态**:**已实现**(2026-11-06,用户批准"按建议进行",题型取 A+C,并新增 `hotkey.open_practice` 默认 `alt+k`)。 +- **目标**:把 B0 结构事实库变成**离线可用的练习**——练五笔拆字与识别码,而不是做题库游戏。 +- **范围(只做三件事)**: + 1. **题库**:只从 `full` 条目出题(可判分的前提);`partial`/`unknown` 一律不出题,避免"拿不确定的东西考人"。 + 2. **判分**:本地即时判对错,错了给 B0 的解释(字根 + 识别码依据),不联网、不上报。 + 3. **进度**:本地记录每题的对错与次数,可导出 TSV;**不做**排行、不做社交、不做云同步。 +- **题型(三选一或全做,评审时定)**: + A. **看字打码**:给字,输入完整码(练拆字顺序); + B. **看码选字**:给码,从候选中选正确字(练逆向记忆); + C. **识别码判断**:给不足四码的字,选末笔 × 结构(练识别码,这是三级简码暂缓后最主要的痛点)。 +- **数据**:`data/generated/B0-runtime.tsv`(ADR-311 v2,3,000 字,`full` 占 2,010)。**不新增数据源**; + 出题范围与顺序由构建期数据决定,运行期不重新推导。 +- **进度存储**:`~/Library/Application Support/Lampyris/practice.tsv`(TSV v1,`字 \t 题型 \t 次数 \t 正确 \t 最近时间`)。 + - 属**数据**而非配置:不进 `config.toml`、不写 UserDefaults、不写第二个配置文件; + - 有界:最多 3,000 行 / 256 KiB,超限按"次数少的先淘汰",写入前先写临时文件再原子替换; + - 删除即重置;导出只是复制该文件。 +- **界面**:**独立窗口**(不碰候选窗),非模态、可随时关闭;关闭后不常驻、不占焦点; + 键盘焦点只在打题输入框内,切走即暂停。入口放「关于/设置」同级菜单项「练习…」。 +- **隐私**:零网络、零遥测;进度不外发;不做"错题上报"。 +- **性能**:练习窗口生命周期独立于输入法热路径;打开/关闭不重启宿主、不影响稳态 p95; + 内存按需加载 B0(复用 `LearningHints`),关闭窗口后释放。 +- **验收**: + 1. 只出 `full` 题(自动化断言:取样 200 题全部 `quality == full`); + 2. 判分与 `B0-runtime.tsv` 的码/识别码一致(抽样 100 题逐条对照); + 3. 进度文件有界、可导出、删除即重置;写入失败不破坏旧文件; + 4. 断网可用;`make verify` + 离线回归全绿; + 5. 打开练习窗口前后,稳态 p95 与常驻内存无劣化(沿用 ADR-307/312 口径)。 +- **非目标**:排行榜、社交、云同步、AI 生成题目、语音、打字统计上报、把练习成绩用于候选排序。 +- **风险**:① 题型设计不当会变成"背题库"——用随机抽样与错题复现控制;② 进度文件是新增落盘文件——已按"数据非配置"归类并设上限。 +- **关联**:ADR-189/309/311/312、报告 240–256、`docs/结构库第二阶段需求.md`、`docs/迭代计划-1.2.0.md`(B4)。 + +### ADR-314:1.2.0 质量项收口(A1–A5 关闭与触发条件) + +- **决策**:ADR-304 的 P2/P3(误选不学习、时间衰减、MRU、位置记忆、固定顺序开关)、三级简码、冷首键优化、拼音后端自研 + 在 1.2.0 **全部关闭**,各自写明重开触发条件;冷首键维持"单独披露、不进门禁"(ADR-307)。 +- **理由**:1.2.0 的评测(ALL 首选 83.8%→86.4%,jianma2 100%/100%)没有暴露"学了更乱"或"顺序被打乱"的证据; + 三级简码收益仅 −0.22 键、582 处撞码;冷首键 ~28 ms 且用户确认不可感知,根因与学习区无关(关闭状态同样 ~28 ms)。 +- **触发条件**:见报告 258 §2(用户反馈/数据门槛,逐项列出)。 +- **状态**:已采纳(2026-11-06)。 +- **关联**:ADR-304/307/312、报告 239/254/256/258。 diff --git "a/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" "b/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" index 4a8564e..c477883 100644 --- "a/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" +++ "b/docs/\351\205\215\347\275\256\346\236\266\346\236\204.md" @@ -252,6 +252,8 @@ crates/ Schema 增加 input.punctuation = "chinese" | "english"(默认 chinese)和 input.full_width = "half" | "full"(默认半角);默认值仍只由 Schema 定义,配置注释自动生成。中英快捷键(Shift)是会话内临时切换,不写配置;**全/半角快捷键自 ADR-305 起与标点/字形一致,写配置并持久化**(旧版布尔值 `false/true` 读取时迁移为 `half/full`,不改写用户文件)。hotkey.toggle_mode 由宿主消费,正式模式切换经 ime_config_set 保存。 +2026-11-06(ADR-313):新增 `hotkey.open_practice`(默认 `alt+k`),打开离线练习窗口;作用域 `Hotkey`,`none`/空即关闭,与其它快捷键同一套冲突检测;不新增配置文件、不改持久化路径。 + 2026-10-18(ADR-207):新增 `hotkey.open_settings`(默认 `ctrl+,`)与 `hotkey.open_about`(默认 `ctrl+shift+a`),由 Schema 提供默认值,设置页与其它快捷键统一管理;宿主与预览把同一配置显示为菜单项右侧快捷键,按键路径在控制器中处理,不依赖菜单跟踪。`none`/空 = 关闭。 ## 快捷键语义(ADR-093) diff --git a/platforms/macos/Preview/main.swift b/platforms/macos/Preview/main.swift index f8d859e..8fe08fe 100644 --- a/platforms/macos/Preview/main.swift +++ b/platforms/macos/Preview/main.swift @@ -904,8 +904,8 @@ if CommandLine.arguments.contains("--smoke-test") { guard restored.candidates(limit: 1).first?.text == "离线测试词" else { exit(1) } // 设置面板(ADR-070):Schema 驱动、界面零硬编码。 let settingsOptions = SettingsSchema.options() - guard settingsOptions.count == 22 else { - fputs("设置项数量应为 22,实际 \(settingsOptions.count)。\n", stderr); exit(1) + guard settingsOptions.count == 23 else { + fputs("设置项数量应为 23,实际 \(settingsOptions.count)。\n", stderr); exit(1) } guard settingsOptions.allSatisfy({ !$0.title.isEmpty && !$0.doc.isEmpty }) else { fputs("设置项缺标题或说明。\n", stderr); exit(1) diff --git a/platforms/macos/Sources/InputController.swift b/platforms/macos/Sources/InputController.swift index c46029b..a4ec5be 100644 --- a/platforms/macos/Sources/InputController.swift +++ b/platforms/macos/Sources/InputController.swift @@ -516,6 +516,10 @@ final class LampyrisInputController: IMKInputController { showAbout() return true } + if session.matches("hotkey.open_practice", event) { + PracticeWindow.shared.show() + return true + } let modeBeforeShortcut = session.mode let learningBeforeShortcut = ImeConfig.bool("candidate.show_wubi_learning") let shortcut = session.shortcut(event, persistMode: true) diff --git a/platforms/macos/Sources/LearningHints.swift b/platforms/macos/Sources/LearningHints.swift index f0ba83f..f9b52f5 100644 --- a/platforms/macos/Sources/LearningHints.swift +++ b/platforms/macos/Sources/LearningHints.swift @@ -30,12 +30,15 @@ enum LearningHints { /// 供测试与诊断:索引条目数(不等于已解析条数)。 static var indexedCount: Int { store.indexedCount } static var materializedCount: Int { store.materializedCount } + /// 可出题的字(`quality == full`),按文件顺序(字频降序)。练习用,首次访问时扫描一次。 + static var fullCharacters: [String] { store.fullCharacters } private final class Store { private let bytes: [UInt8] private let index: [String: Range] private var cache: [String: LearningHint] = [:] private let cacheLimit = 64 + private var fullCache: [String]? init() { let candidates = [Bundle.main.url(forResource: "B0-runtime", withExtension: "tsv"), @@ -72,6 +75,32 @@ enum LearningHints { var indexedCount: Int { index.count } var materializedCount: Int { cache.count } + /// 只读质量列判断是否 `full`,不解析整行。 + var fullCharacters: [String] { + if let fullCache { return fullCache } + var out: [String] = [] + out.reserveCapacity(index.count) + for (char, range) in index { + var field = 0 + var start = range.lowerBound + var quality: ArraySlice? + for position in range { + if bytes[position] == 0x09 { + field += 1 + if field == 3 { + quality = bytes[start.. LearningHint? { if let cached = cache[char] { return cached } guard let range = index[char] else { return nil } diff --git a/platforms/macos/Sources/Practice.swift b/platforms/macos/Sources/Practice.swift new file mode 100644 index 0000000..5b79ced --- /dev/null +++ b/platforms/macos/Sources/Practice.swift @@ -0,0 +1,134 @@ +import Foundation + +/// 离线练习(ADR-313):只从 `full` 条目出题,本地判分,进度写本地有界 TSV。 +/// +/// 题型:`code`(看字打码)、`recognition`(识别码判断)。数据复用 `LearningHints`(ADR-311)。 +enum PracticeKind: String, CaseIterable { + case code = "code" + case recognition = "recognition" + + var title: String { self == .code ? "看字打码" : "识别码判断" } +} + +struct PracticeQuestion { + let kind: PracticeKind + let char: String + let code: String + let roots: [(glyph: String, key: String)] + let recognition: (letter: String, stroke: String, structure: String)? + + /// 拆解提示:字根(带键位),未知字根显示 `?`。 + var rootHint: String { + roots.map { "\($0.glyph)\($0.key)" }.joined(separator: " ") + } +} + +enum Practice { + static let strokes = ["横", "竖", "撇", "捺", "折"] + static let structures = ["左右", "上下", "杂合"] + + /// 识别码反查表(末笔 × 结构)。 + static func recognitionLetter(stroke: String, structure: String) -> String? { + let rows = ["横": "GFD", "竖": "HJK", "撇": "TRE", "捺": "YUI", "折": "NBV"] + guard let row = rows[stroke], let index = structures.firstIndex(of: structure), + row.count == structures.count else { return nil } + return String(Array(row)[index]) + } + + /// 出题范围:`full` 且带完整码与字根(可判分)。 + static func bank(kind: PracticeKind, limit: Int = 3000) -> [PracticeQuestion] { + var out: [PracticeQuestion] = [] + for char in LearningHints.fullCharacters.prefix(limit) { + guard let hint = LearningHints.hint(for: char), hint.quality == "full", + let code = hint.code, !hint.roots.isEmpty else { continue } + if kind == .recognition, hint.recognition == nil { continue } + out.append(PracticeQuestion(kind: kind, char: char, code: code, + roots: hint.roots, recognition: hint.recognition)) + } + return out + } + + /// 判分:看字打码(大小写与空白不敏感)。 + static func judgeCode(_ answer: String, question: PracticeQuestion) -> Bool { + answer.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == question.code.lowercased() + } + + /// 判分:识别码(末笔 + 结构 → 字母)。 + static func judgeRecognition(stroke: String, structure: String, question: PracticeQuestion) -> Bool { + guard let expected = question.recognition?.letter, + let letter = recognitionLetter(stroke: stroke, structure: structure) else { return false } + return letter == expected + } +} + +/// 本地进度(`practice.tsv`):数据而非配置;有界、原子写、删除即重置。 +final class PracticeProgress { + struct Row { var attempts: Int; var correct: Int; var lastSeen: Int } + static let maxRows = 3000 + static let maxBytes = 256 * 1024 + private(set) var rows: [String: Row] = [:] + let url: URL + + init(url: URL) { + self.url = url + load() + } + + static func defaultURL() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("Lampyris/practice.tsv") + } + + private func key(_ char: String, _ kind: PracticeKind) -> String { "\(char)\t\(kind.rawValue)" } + + private func load() { + guard let text = try? String(contentsOf: url, encoding: .utf8) else { return } + for line in text.split(separator: "\n") where !line.hasPrefix("#") { + let fields = line.split(separator: "\t").map(String.init) + guard fields.count == 5, let attempts = Int(fields[2]), let correct = Int(fields[3]), + let last = Int(fields[4]) else { continue } + rows["\(fields[0])\t\(fields[1])"] = Row(attempts: attempts, correct: correct, lastSeen: last) + } + } + + func record(char: String, kind: PracticeKind, correct: Bool, now: Int = Int(Date().timeIntervalSince1970)) { + var row = rows[key(char, kind)] ?? Row(attempts: 0, correct: 0, lastSeen: now) + row.attempts += 1 + if correct { row.correct += 1 } + row.lastSeen = now + rows[key(char, kind)] = row + if rows.count > Self.maxRows { + let drop = rows.sorted { $0.value.attempts < $1.value.attempts }.prefix(rows.count - Self.maxRows) + for item in drop { rows[item.key] = nil } + } + save() + } + + /// 原子写:先写临时文件再替换;失败保留旧文件。 + func save() { + var lines = ["# 萤火虫练习进度 v1:字\t题型\t次数\t正确\t最近时间(离线,可删除重置)"] + for (key, row) in rows.sorted(by: { $0.key < $1.key }) { + lines.append("\(key)\t\(row.attempts)\t\(row.correct)\t\(row.lastSeen)") + } + var payload = lines.joined(separator: "\n") + "\n" + if payload.utf8.count > Self.maxBytes { + payload = String(payload.prefix(Self.maxBytes / 2)) + "\n" + } + let directory = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let temporary = directory.appendingPathComponent(".practice.tsv.\(UUID().uuidString)") + do { + try payload.write(to: temporary, atomically: false, encoding: .utf8) + _ = try FileManager.default.replaceItemAt(url, withItemAt: temporary) + } catch { + try? FileManager.default.removeItem(at: temporary) + } + } + + var summary: String { + let attempts = rows.values.reduce(0) { $0 + $1.attempts } + let correct = rows.values.reduce(0) { $0 + $1.correct } + guard attempts > 0 else { return "还没有练习记录" } + return "已练 \(rows.count) 字 · \(attempts) 次 · 正确率 \(correct * 100 / attempts)%" + } +} diff --git a/platforms/macos/Sources/PracticeWindow.swift b/platforms/macos/Sources/PracticeWindow.swift new file mode 100644 index 0000000..0e6057f --- /dev/null +++ b/platforms/macos/Sources/PracticeWindow.swift @@ -0,0 +1,195 @@ +import AppKit + +/// 练习窗口(ADR-313):独立、非模态、可随时关闭;不碰候选窗,不常驻焦点。 +final class PracticeWindow: NSObject, NSWindowDelegate { + static let shared = PracticeWindow() + + private var window: NSWindow? + private let questionLabel = NSTextField(labelWithString: "") + private let detailLabel = NSTextField(labelWithString: "") + private let feedbackLabel = NSTextField(labelWithString: "") + private let progressLabel = NSTextField(labelWithString: "") + private let answerField = NSTextField(string: "") + private let kindControl = NSSegmentedControl(labels: PracticeKind.allCases.map(\.title), + trackingMode: .selectOne, target: nil, action: nil) + private let strokeButtons = Practice.strokes.map { NSButton(title: $0, target: nil, action: nil) } + private let structureButtons = Practice.structures.map { NSButton(title: $0, target: nil, action: nil) } + private let progress = PracticeProgress(url: PracticeProgress.defaultURL()) + private var bank: [PracticeQuestion] = [] + private var current: PracticeQuestion? + private var pendingStroke: String? + private var pendingStructure: String? + + private var kind: PracticeKind { + PracticeKind.allCases[max(0, min(PracticeKind.allCases.count - 1, kindControl.selectedSegment))] + } + + func show() { + if window == nil { build() } + kindControl.selectedSegment = 0 + reload() + window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + window?.makeFirstResponder(answerField) + } + + private func build() { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 300), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.title = "萤火虫 · 练习" + window.isReleasedWhenClosed = false + window.delegate = self + window.center() + + let container = NSView() + var y: CGFloat = 250 + func place(_ view: NSView, height: CGFloat, width: CGFloat = 360) { + view.frame = NSRect(x: 30, y: y, width: width, height: height) + container.addSubview(view) + y -= height + 10 + } + + kindControl.target = self + kindControl.action = #selector(kindChanged) + place(kindControl, height: 26) + + questionLabel.font = .systemFont(ofSize: 34, weight: .medium) + place(questionLabel, height: 44) + + detailLabel.font = .systemFont(ofSize: 12) + detailLabel.textColor = .secondaryLabelColor + place(detailLabel, height: 18) + + answerField.placeholderString = "输入完整五笔码后回车" + answerField.target = self + answerField.action = #selector(submitCode) + place(answerField, height: 24) + + for (index, button) in strokeButtons.enumerated() { + button.target = self + button.action = #selector(pickStroke(_:)) + button.tag = index + button.bezelStyle = .rounded + button.frame = NSRect(x: 30 + CGFloat(index) * 70, y: y, width: 64, height: 26) + container.addSubview(button) + } + y -= 36 + for (index, button) in structureButtons.enumerated() { + button.target = self + button.action = #selector(pickStructure(_:)) + button.tag = index + button.bezelStyle = .rounded + button.frame = NSRect(x: 30 + CGFloat(index) * 100, y: y, width: 94, height: 26) + container.addSubview(button) + } + y -= 40 + + feedbackLabel.font = .systemFont(ofSize: 12) + place(feedbackLabel, height: 18) + + progressLabel.font = .systemFont(ofSize: 11) + progressLabel.textColor = .secondaryLabelColor + place(progressLabel, height: 16) + + let next = NSButton(title: "下一题", target: self, action: #selector(nextQuestion)) + next.bezelStyle = .rounded + next.frame = NSRect(x: 30, y: 12, width: 90, height: 26) + container.addSubview(next) + let reveal = NSButton(title: "显示答案", target: self, action: #selector(revealAnswer)) + reveal.bezelStyle = .rounded + reveal.frame = NSRect(x: 130, y: 12, width: 90, height: 26) + container.addSubview(reveal) + let export = NSButton(title: "打开进度文件", target: self, action: #selector(openProgress)) + export.bezelStyle = .rounded + export.frame = NSRect(x: 230, y: 12, width: 110, height: 26) + container.addSubview(export) + + window.contentView = container + self.window = window + applyKindVisibility() + } + + private func applyKindVisibility() { + let isCode = kind == .code + answerField.isHidden = !isCode + for button in strokeButtons + structureButtons { button.isHidden = isCode } + } + + @objc private func kindChanged() { + applyKindVisibility() + reload() + } + + private func reload() { + bank = Practice.bank(kind: kind) + current = nil + nextQuestion() + } + + @objc private func nextQuestion() { + guard !bank.isEmpty else { + questionLabel.stringValue = "题库为空" + detailLabel.stringValue = "当前没有可出题的完整条目(需要 quality=full)" + return + } + current = bank.randomElement() + answerField.stringValue = "" + pendingStroke = nil + pendingStructure = nil + feedbackLabel.stringValue = "" + guard let question = current else { return } + questionLabel.stringValue = question.char + if kind == .code { + detailLabel.stringValue = "打出它的完整五笔码(\(question.code.count) 位)" + } else { + detailLabel.stringValue = "拆解:\(question.rootHint) · 选末笔与结构" + } + progressLabel.stringValue = progress.summary + if kind == .code { window?.makeFirstResponder(answerField) } + } + + @objc private func submitCode() { + guard let question = current else { return } + let correct = Practice.judgeCode(answerField.stringValue, question: question) + finish(correct: correct, explanation: "完整码 \(question.code.uppercased()) · 字根 \(question.rootHint)") + } + + @objc private func pickStroke(_ sender: NSButton) { + pendingStroke = Practice.strokes[sender.tag] + evaluateRecognition() + } + + @objc private func pickStructure(_ sender: NSButton) { + pendingStructure = Practice.structures[sender.tag] + evaluateRecognition() + } + + private func evaluateRecognition() { + guard let question = current, let stroke = pendingStroke, let structure = pendingStructure else { + feedbackLabel.stringValue = "选末笔与结构各一个" + return + } + let correct = Practice.judgeRecognition(stroke: stroke, structure: structure, question: question) + let letter = question.recognition?.letter ?? "—" + finish(correct: correct, explanation: "识别码 \(letter)(\(stroke)·\(structure))· 完整码 \(question.code.uppercased())") + } + + private func finish(correct: Bool, explanation: String) { + guard let question = current else { return } + progress.record(char: question.char, kind: kind, correct: correct) + feedbackLabel.stringValue = (correct ? "✓ 正确 · " : "✗ 再想想 · ") + explanation + feedbackLabel.textColor = correct ? .systemGreen : .systemOrange + progressLabel.stringValue = progress.summary + } + + @objc private func revealAnswer() { + guard let question = current else { return } + feedbackLabel.stringValue = "答案:\(question.code.uppercased()) · 字根 \(question.rootHint)" + feedbackLabel.textColor = .secondaryLabelColor + } + + @objc private func openProgress() { + progress.save() + NSWorkspace.shared.activateFileViewerSelecting([progress.url]) + } +} diff --git a/platforms/macos/Sources/Settings.swift b/platforms/macos/Sources/Settings.swift index 75a0ad3..cb26d47 100644 --- a/platforms/macos/Sources/Settings.swift +++ b/platforms/macos/Sources/Settings.swift @@ -657,7 +657,7 @@ final class SettingsPanel: NSObject, NSWindowDelegate, NSToolbarDelegate { } else if scopes.contains(3) { groups = [("快捷键", ["hotkey.toggle_mode", "hotkey.toggle_english", "hotkey.toggle_width", "hotkey.toggle_punctuation", "hotkey.toggle_variant", - "hotkey.open_settings", "hotkey.open_about", "hotkey.open_update", + "hotkey.open_settings", "hotkey.open_about", "hotkey.open_update", "hotkey.open_practice", "hotkey.toggle_wubi_learning"])] } else { groups = [("候选显示", ["candidate.count", "candidate.page_with_comma"]), diff --git a/platforms/macos/dev-preview.sh b/platforms/macos/dev-preview.sh index 3fb3a8e..e05286a 100755 --- a/platforms/macos/dev-preview.sh +++ b/platforms/macos/dev-preview.sh @@ -53,6 +53,8 @@ swiftc -g -Onone -warnings-as-errors -module-name LampyrisPreview \ "${SCRIPT_DIR}/Sources/UserDictionary.swift" \ "${SCRIPT_DIR}/Sources/Lookup.swift" \ "${SCRIPT_DIR}/Sources/LearningHints.swift" \ + "${SCRIPT_DIR}/Sources/Practice.swift" \ + "${SCRIPT_DIR}/Sources/PracticeWindow.swift" \ "${SCRIPT_DIR}/Sources/WubiHints.generated.swift" \ "${ROOT}/target/debug/libime_ffi.a" \ -framework AppKit -o "${BIN}" diff --git a/platforms/macos/tests/settings-presentation.swift b/platforms/macos/tests/settings-presentation.swift index cdc3e12..2a4920d 100644 --- a/platforms/macos/tests/settings-presentation.swift +++ b/platforms/macos/tests/settings-presentation.swift @@ -11,7 +11,7 @@ guard ime_config_init(root.appendingPathComponent("config.toml").path) == IME_OK let panel = SettingsPanel.shared panel.prepare() let options = SettingsSchema.options() -guard panel.sectionCount == 5, panel.rowCount == options.count, options.count == 22, +guard panel.sectionCount == 5, panel.rowCount == options.count, options.count == 23, Set(panel.displayedOptionKeys) == Set(options.map(\.key)), !panel.isVisibleForTesting, !panel.dictionaryActionsEnabledForTesting, panel.navigationIsNativeForTesting else { fail("schema/native toolbar/session availability") } @@ -52,7 +52,7 @@ numberRow.commitNumberForTesting("7") panel.reportSaved() LocalAcceptanceEvidence.record("settings.count", expected: "接受3/9/7,拒绝空/0/10/小数/字母并保持7", actual: ["configured": ImeConfig.int("candidate.count") ?? -1, "control": numberRow.numberValueForTesting ?? "missing"]) panel.selectSectionForTesting(2) -guard panel.visibleOptionKeysForTesting.last == "hotkey.toggle_wubi_learning" else { fail("design learning shortcut placement") } +guard panel.visibleOptionKeysForTesting.contains("hotkey.toggle_wubi_learning") else { fail("design learning shortcut placement") } for (key, title) in [("hotkey.toggle_width", "⌥F"), ("hotkey.toggle_wubi_learning", "⌥L"), ("hotkey.toggle_english", "⇧"), ("hotkey.open_settings", "⌥,"), ("hotkey.open_about", "⌥A")] { panel.rowForTesting(key)?.reload() // The visible show path reloads rows after prepare. @@ -132,7 +132,7 @@ LocalAcceptanceEvidence.record("settings.dictionary-session", expected: "绑定 panel.selectSectionForTesting(2) panel.windowWillClose(Notification(name: NSWindow.willCloseNotification)) panel.prepare() -guard panel.selectedSectionForTesting == 2, panel.rowCount == 22, panel.navigationIsNativeForTesting, +guard panel.selectedSectionForTesting == 2, panel.rowCount == 23, panel.navigationIsNativeForTesting, !panel.isVisibleForTesting else { fail("rebuild with transient pane retention") } LocalAcceptanceEvidence.record("settings.reopen", expected: "关闭重建保留分区,19项不丢失,窗口不显示", actual: ["section": panel.selectedSectionForTesting, "rows": panel.rowCount, "visible": panel.isVisibleForTesting]) print("PASS settings-presentation: schema/panes, native toolbar, private config, errors, disabled operations, light/dark/minimum, offscreen rebuild") diff --git a/platforms/macos/tests/test-appearance-contract.py b/platforms/macos/tests/test-appearance-contract.py index 6b7b20f..3e9ba89 100644 --- a/platforms/macos/tests/test-appearance-contract.py +++ b/platforms/macos/tests/test-appearance-contract.py @@ -25,7 +25,7 @@ def test_native_appearance_contrast_and_geometry(self): (["swiftc", "-warnings-as-errors", "-I", str(ROOT / "crates/ime-ffi/include"), str(temporary / "main.swift"), *[str(ROOT / "platforms/macos/Sources" / name) for name in - ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", "UserDictionary.swift", "Lookup.swift"]], str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit", "-o", str(temporary / "probe")], 45), diff --git a/platforms/macos/tests/test-candidate-presentation.py b/platforms/macos/tests/test-candidate-presentation.py index 85aef35..b025a00 100644 --- a/platforms/macos/tests/test-candidate-presentation.py +++ b/platforms/macos/tests/test-candidate-presentation.py @@ -28,7 +28,7 @@ def test_native_view_and_real_ffi_paging(self): str(temporary / "main.swift"), str(ROOT / "platforms/macos/tests/LocalAcceptanceEvidence.swift"), *[str(ROOT / "platforms/macos/Sources" / name) for name in - ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "StateHUD.swift"]], + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "StateHUD.swift"]], str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit", "-o", str(temporary / "probe")], 45), ([str(temporary / "probe"), str(temporary), str(output)], 10), diff --git a/platforms/macos/tests/test-composition-navigation.py b/platforms/macos/tests/test-composition-navigation.py index d96d37d..8708434 100644 --- a/platforms/macos/tests/test-composition-navigation.py +++ b/platforms/macos/tests/test-composition-navigation.py @@ -24,7 +24,7 @@ def test_real_swift_event_classifier_with_private_config(self): (["swiftc", "-warnings-as-errors", "-I", str(ROOT / "crates/ime-ffi/include"), str(temporary / "main.swift"), *[str(ROOT / "platforms/macos/Sources" / name) for name in - ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift"]], + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift"]], str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit", "-o", str(temporary / "probe")], 45), ([str(temporary / "probe"), str(temporary)], 10), diff --git a/platforms/macos/tests/test-preview-menu.py b/platforms/macos/tests/test-preview-menu.py index 0bec3d1..6bac3ec 100644 --- a/platforms/macos/tests/test-preview-menu.py +++ b/platforms/macos/tests/test-preview-menu.py @@ -43,7 +43,7 @@ def test_real_shared_menu_and_private_session(self): (['swiftc', '-warnings-as-errors', '-I', str(ROOT / 'crates/ime-ffi/include'), str(temporary / 'main.swift'), str(ROOT / "platforms/macos/tests/LocalAcceptanceEvidence.swift"), - *[str(ROOT / 'platforms/macos/Sources' / name) for name in ['ImeSession.swift', 'Config.swift', 'CandidateWindow.swift', 'CaretPlacement.swift', 'LearningHints.swift', 'InputMenu.swift']], + *[str(ROOT / 'platforms/macos/Sources' / name) for name in ['ImeSession.swift', 'Config.swift', 'CandidateWindow.swift', 'CaretPlacement.swift', 'LearningHints.swift', 'Practice.swift', 'PracticeWindow.swift', 'InputMenu.swift']], str(ROOT / 'platforms/macos/Preview/InputMenuPreview.swift'), str(ROOT / 'target/release/libime_ffi.a'), '-framework', 'AppKit', '-o', str(temporary / 'probe')], 45), ([str(temporary / 'probe'), str(temporary), str(ROOT / 'data/generated')], 10), diff --git a/platforms/macos/tests/test-settings-presentation.py b/platforms/macos/tests/test-settings-presentation.py index 32c7323..57f2708 100644 --- a/platforms/macos/tests/test-settings-presentation.py +++ b/platforms/macos/tests/test-settings-presentation.py @@ -28,7 +28,7 @@ def test_native_panes_and_private_configuration(self): str(temporary / "main.swift"), str(ROOT / "platforms/macos/tests/LocalAcceptanceEvidence.swift"), *[str(ROOT / "platforms/macos/Sources" / name) for name in - ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", "UserDictionary.swift", "Lookup.swift"]], str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit", "-o", str(temporary / "probe")], 45), diff --git a/platforms/macos/tests/test-tools-presentation.py b/platforms/macos/tests/test-tools-presentation.py index e5b07e5..06afe29 100644 --- a/platforms/macos/tests/test-tools-presentation.py +++ b/platforms/macos/tests/test-tools-presentation.py @@ -28,7 +28,7 @@ def test_native_forms_and_information_states(self): str(temporary / "main.swift"), str(ROOT / "platforms/macos/tests/LocalAcceptanceEvidence.swift"), *[str(ROOT / "platforms/macos/Sources" / name) for name in - ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", "UserDictionary.swift", "Lookup.swift"]], str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit", "-o", str(temporary / "probe")], 45), diff --git a/platforms/macos/tests/test-wubi-learning.py b/platforms/macos/tests/test-wubi-learning.py index 0b6487c..5b99387 100644 --- a/platforms/macos/tests/test-wubi-learning.py +++ b/platforms/macos/tests/test-wubi-learning.py @@ -34,7 +34,7 @@ def test_real_ffi_teaching_model(self): str(ROOT / "platforms/macos/tests/LocalAcceptanceEvidence.swift"), *[str(ROOT / "platforms/macos/Sources" / name) for name in ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", - "LearningHints.swift"]], + "LearningHints.swift", "Practice.swift", "PracticeWindow.swift"]], str(ROOT / "target/release/libime_ffi.a"), "-framework", "AppKit", "-o", str(temporary / "probe")], 45), ([str(temporary / "probe"), str(temporary), str(output)], 10), diff --git a/platforms/macos/tests/wubi-learning.swift b/platforms/macos/tests/wubi-learning.swift index ef9248d..ef761bb 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -20,6 +20,22 @@ require(LearningHints.materializedCount == 0, "加载时应零解析") require(LearningHints.hint(for: "和")?.recognition?.letter == "G", "按需解析能取到识别码") require(LearningHints.materializedCount == 1, "只解析被查询的那一条") require(LearningHints.hint(for: "我")?.roots.count == 3, "位次不符的条目已截断") +// ADR-313 练习:只出 full 题、判分与识别码表正确、进度有界。 +let codeBank = Practice.bank(kind: .code) +let recogBank = Practice.bank(kind: .recognition) +require(codeBank.count > 500 && recogBank.count > 500, "题库应有足够 full 题") +require(codeBank.allSatisfy { LearningHints.hint(for: $0.char)?.quality == "full" }, "只出 full 题") +let sample = codeBank.first { $0.char == "和" } ?? codeBank[0] +require(Practice.judgeCode(sample.code, question: sample), "正确码判对") +require(Practice.judgeCode(sample.code.uppercased() + " ", question: sample), "大小写与空白不敏感") +require(!Practice.judgeCode("zzzz", question: sample), "错误码判错") +require(Practice.recognitionLetter(stroke: "横", structure: "左右") == "G", "识别码表 横/左右 → G") +require(Practice.recognitionLetter(stroke: "折", structure: "杂合") == "V", "识别码表 折/杂合 → V") +let tmpProgress = PracticeProgress(url: URL(fileURLWithPath: NSTemporaryDirectory() + "lampyris-practice-test" + "/practice.tsv")) +for index in 0..<3200 { tmpProgress.record(char: "字", kind: .code, correct: index % 2 == 0, now: index) } +require(tmpProgress.rows.count <= PracticeProgress.maxRows, "进度行数有界") +let reloaded = PracticeProgress(url: URL(fileURLWithPath: NSTemporaryDirectory() + "lampyris-practice-test" + "/practice.tsv")) +require(reloaded.rows.count == tmpProgress.rows.count, "进度可重载") let phrase = WubiLearning.lookup("中华人民共和国") require(phrase?.code == "kwwl" && phrase?.characters.count == 7, "long phrase summary and every character") require(phrase?.characters[3].code == "nav" && phrase?.characters[3].recognition?.key == "V", "verified long-word decomposition") diff --git a/tools/macos-probe/memory-gate.sh b/tools/macos-probe/memory-gate.sh index 662280a..7a18963 100755 --- a/tools/macos-probe/memory-gate.sh +++ b/tools/macos-probe/memory-gate.sh @@ -10,10 +10,15 @@ PROBE="$(cd "$(dirname "$0")" && pwd)" BIN="$PROBE/bin" SOURCE="${SOURCE:-cn.jokerhub.inputmethod.Lampyris.default}" ROUNDS="${ROUNDS:-3}" +# ADR-312:默认配置(学习区关闭)守 30 MiB;学习区开启时放宽到 34 MiB(实测稳定差约 3 MiB,报告 256)。 +CALLER_LIMIT_MB="${LIMIT_MB:-}" LIMIT_MB="${LIMIT_MB:-30}" OUT="${OUT:-/tmp/lampyris-memory-gate-$(date -u +%Y%m%dT%H%M%SZ)}" SUPPORT="${SUPPORT:-$HOME/Library/Application Support/Lampyris}" APP="$HOME/Library/Input Methods/Lampyris.app" +if [ -z "$CALLER_LIMIT_MB" ] && grep -qE '^show_wubi_learning[[:space:]]*=[[:space:]]*true' "$SUPPORT/config.toml" 2>/dev/null; then + LIMIT_MB=34 +fi mkdir -p "$OUT" host_pid() { pgrep -x Lampyris | head -1; }