From 01c3950bc14410d97ff500e2886c6ba15e324936 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Sat, 7 Nov 2026 22:52:31 +0800 Subject: [PATCH] fix: make practice answering work and cover the flow with tests Return never reached the field because the window lives inside the input method itself, so the chord was consumed as candidate confirmation and only the recognition picker ever recorded progress. The answer box is now a custom view that reads its own keys, accepts letters in either case, handles backspace and submits on return, so the flow no longer depends on the input method. The recognition explanation also showed the user's selection beside the expected letter; it now reports the correct stroke and structure. Layout captions no longer sit under the buttons. PracticeSession holds the question, judging and recording logic without AppKit, and the offline suite asserts that a correct answer both changes the summary and lands in the progress file, that wrong answers still count, that the recognition explanation carries the right evidence, and that the answer view filters and submits keys as specified. Signed-off-by: wangzhizhou <824219521@qq.com> --- HANDOFF.md | 1 + .../reviews/261-practice-flow-and-ui-fixes.md | 37 ++++++++ platforms/macos/Sources/Practice.swift | 57 ++++++++++++ .../macos/Sources/PracticeAnswerView.swift | 53 +++++++++++ platforms/macos/Sources/PracticeWindow.swift | 87 +++++++++---------- platforms/macos/dev-preview.sh | 1 + .../macos/tests/test-appearance-contract.py | 2 +- .../tests/test-candidate-presentation.py | 2 +- .../tests/test-composition-navigation.py | 2 +- platforms/macos/tests/test-preview-menu.py | 2 +- .../macos/tests/test-settings-presentation.py | 2 +- .../macos/tests/test-tools-presentation.py | 2 +- platforms/macos/tests/test-wubi-learning.py | 2 +- platforms/macos/tests/wubi-learning.swift | 39 +++++++++ 14 files changed, 236 insertions(+), 53 deletions(-) create mode 100644 docs/reviews/261-practice-flow-and-ui-fixes.md create mode 100644 platforms/macos/Sources/PracticeAnswerView.swift diff --git a/HANDOFF.md b/HANDOFF.md index 6a51925..e181c32 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -61,6 +61,7 @@ - **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)。 +- **练习流程已修 + 自动化(报告 261)**:输入框换成 `PracticeAnswerView`(不经输入法,回车才判分);识别码说明改用**正确**末笔·结构、标题不再被按钮压住;新增 `PracticeSession` 纯逻辑与 8 条流程断言(判分/进度/输入过滤)。 - **练习窗口版式已修正(报告 260)**:翻转坐标从顶部排、固定 460×336、题干居中、进度行不再被裁、A/C 共用纵向基准,并加了版式自检(控件不越界、不与题型控件重叠)。**待装包看真机**。 - **B4 练习已实现(ADR-313 / 报告 257)**:只看 `full` 出题,题型 A(看字打码)+ C(识别码判断);进度写 `~/Library/Application Support/Lampyris/practice.tsv`(有界 3,000 行 / 256 KiB、原子写、删除即重置);快捷键 `hotkey.open_practice` 默认 **⌥K**,入口在设置页快捷键列表;**尚未装包实测**。 diff --git a/docs/reviews/261-practice-flow-and-ui-fixes.md b/docs/reviews/261-practice-flow-and-ui-fixes.md new file mode 100644 index 0000000..bd8fb4c --- /dev/null +++ b/docs/reviews/261-practice-flow-and-ui-fixes.md @@ -0,0 +1,37 @@ +# 261 — 练习流程修复 + 识别码界面修正 + 流程自动化测试 + +- 日期:2026-11-06 | 来源:用户实测「回车后正确率不变」「识别码判断界面需要优化」「流程要自动化验证」 + +## 1. 回车不判分(根因) + +练习窗口的输入框是 `NSTextField`,而它所在的宿主就是输入法本身:键入会先被输入法接管, +**回车被输入法当作候选提交**,字段的 action 根本没触发 → 只有识别码点选会记进度,正确率停在 0%。 + +**修法**:换成自绘的 `PracticeAnswerView`(`NSView` + `keyDown`),自己收键、**不经过输入法**: +只接受 A–Y 字母(大小写都收,统一转小写)、退格、回车;回车触发提交。 + +## 2. 识别码界面(截图问题) + +| 问题 | 修法 | +|---|---| +| 「末笔」「结构」标题被按钮压住、文字被截断 | 标题宽度 32→46、按钮起点 36→52,「末笔」选中后就地显示「末笔 撇」 | +| 判错的说明把**用户的**选择当成答案 | 说明改用**正确**的末笔·结构(`PracticeSession` 里取 `question.recognition`) | + +## 3. 自动化:练习流程跑通 + +新增 `PracticeSession`(无 AppKit 的纯逻辑),把"出题 → 作答 → 判分 → 记进度"收进一处; +离线回归(`platforms/macos/tests/wubi-learning.swift`)现在断言: + +1. 正确码判对、**进度摘要随之变化**、`practice.tsv` 记 1 次作答与 1 次正确; +2. 错误码也计数(次数 +1、正确不变); +3. 识别码:正确末笔/结构判对,说明里包含**正确**的字母与末笔; +4. `PracticeAnswerView`:只收 a–y(含大写归一)、退格删除、`z` 忽略、回车回调拿到已输入内容。 + +## 4. 验证 + +- `make verify` PASS;离线回归 **62 文件 / 328 例 PASS**(新增流程断言全绿)。 +- 真机复验待装包。 + +## 5. 关联 + +ADR-313、报告 257/259/260。 diff --git a/platforms/macos/Sources/Practice.swift b/platforms/macos/Sources/Practice.swift index 5b79ced..f512e1a 100644 --- a/platforms/macos/Sources/Practice.swift +++ b/platforms/macos/Sources/Practice.swift @@ -132,3 +132,60 @@ final class PracticeProgress { return "已练 \(rows.count) 字 · \(attempts) 次 · 正确率 \(correct * 100 / attempts)%" } } + + +/// 一局练习的纯逻辑(无 AppKit),便于自动化测试跑通流程。 +final class PracticeSession { + struct Result { + let correct: Bool + let explanation: String + } + + private(set) var kind: PracticeKind + private(set) var question: PracticeQuestion? + private let progress: PracticeProgress + private(set) var bank: [PracticeQuestion] = [] + + init(kind: PracticeKind, progress: PracticeProgress) { + self.kind = kind + self.progress = progress + reload() + } + + func switchKind(_ kind: PracticeKind) { + self.kind = kind + reload() + } + + func reload() { + bank = Practice.bank(kind: kind) + question = nil + next() + } + + @discardableResult + func next() -> PracticeQuestion? { + question = bank.randomElement() + return question + } + + /// 提交打字答案;`nil` 表示没有题目。 + func submitCode(_ answer: String) -> Result? { + guard let question else { return nil } + let correct = Practice.judgeCode(answer, question: question) + progress.record(char: question.char, kind: kind, correct: correct) + return Result(correct: correct, + explanation: "完整码 \(question.code.uppercased()) · 字根 \(question.rootHint)") + } + + /// 提交识别码选择;证据说明用**正确**的末笔与结构,而不是用户选的。 + func submitRecognition(stroke: String, structure: String) -> Result? { + guard let question, let expected = question.recognition else { return nil } + let correct = Practice.judgeRecognition(stroke: stroke, structure: structure, question: question) + progress.record(char: question.char, kind: kind, correct: correct) + return Result(correct: correct, + explanation: "识别码 \(expected.letter)(\(expected.stroke)·\(expected.structure))") + } + + var summary: String { progress.summary } +} diff --git a/platforms/macos/Sources/PracticeAnswerView.swift b/platforms/macos/Sources/PracticeAnswerView.swift new file mode 100644 index 0000000..6a1eff9 --- /dev/null +++ b/platforms/macos/Sources/PracticeAnswerView.swift @@ -0,0 +1,53 @@ +import AppKit + +/// 练习作答输入框:自己收键,不经过输入法(避免回车被 IME 吃掉、字母被组合)。 +/// 只接受 a–y 小写字母、退格与回车;其余按键忽略。 +final class PracticeAnswerView: NSView { + var onReturn: ((String) -> Void)? + private(set) var text = "" { + didSet { needsDisplay = true } + } + var placeholder = "输入完整五笔码后回车" + + override var acceptsFirstResponder: Bool { true } + override var isFlipped: Bool { true } + + override func keyDown(with event: NSEvent) { + guard let characters = event.charactersIgnoringModifiers else { return } + for scalar in characters.unicodeScalars { + switch scalar.value { + case 0x0D, 0x03: + onReturn?(text) + case 0x7F, 0x08: + if !text.isEmpty { text.removeLast() } + default: + let value = Int(scalar.value) + if (65...89).contains(value) || (97...121).contains(value), + let letter = UnicodeScalar(value) { + text.append(Character(String(letter).lowercased())) + } + } + } + } + + + func clear() { text = "" } + func setTextForTesting(_ value: String) { text = value } + + override func draw(_ dirtyRect: NSRect) { + let border = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: 6, yRadius: 6) + NSColor.textBackgroundColor.setFill() + border.fill() + NSColor.systemGreen.setStroke() + border.lineWidth = 2 + border.stroke() + let shown = text.isEmpty ? placeholder : text.uppercased() + let attributes: [NSAttributedString.Key: Any] = [ + .font: text.isEmpty ? NSFont.systemFont(ofSize: 13) + : NSFont.monospacedSystemFont(ofSize: 15, weight: .medium), + .foregroundColor: text.isEmpty ? NSColor.placeholderTextColor : NSColor.labelColor, + ] + NSAttributedString(string: shown, attributes: attributes) + .draw(in: bounds.insetBy(dx: 8, dy: 5)) + } +} diff --git a/platforms/macos/Sources/PracticeWindow.swift b/platforms/macos/Sources/PracticeWindow.swift index be74de1..5c29d22 100644 --- a/platforms/macos/Sources/PracticeWindow.swift +++ b/platforms/macos/Sources/PracticeWindow.swift @@ -14,7 +14,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { private let promptLabel = NSTextField(labelWithString: "") private let feedbackLabel = NSTextField(wrappingLabelWithString: "") private let progressLabel = NSTextField(labelWithString: "") - private let answerField = NSTextField(string: "") + private let answerView = PracticeAnswerView() private let kindControl = NSSegmentedControl(labels: PracticeKind.allCases.map(\.title), trackingMode: .selectOne, target: nil, action: nil) private let strokeTitle = NSTextField(labelWithString: "末笔") @@ -22,8 +22,8 @@ final class PracticeWindow: NSObject, NSWindowDelegate { 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 var practiceProgress: PracticeProgress? - private var bank: [PracticeQuestion] = [] - private var current: PracticeQuestion? + private lazy var session = PracticeSession(kind: .code, progress: progress) + private var current: PracticeQuestion? { session.question } private var pendingStroke: String? private var pendingStructure: String? private var lastAnswer: String? @@ -48,7 +48,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { reload() window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - window?.makeFirstResponder(answerField) + window?.makeFirstResponder(answerView) } private func build() { @@ -83,36 +83,33 @@ final class PracticeWindow: NSObject, NSWindowDelegate { promptLabel.frame = NSRect(x: margin, y: 120, width: width - margin * 2, height: 18) content.addSubview(promptLabel) - answerField.frame = NSRect(x: margin, y: 148, width: width - margin * 2, height: 26) - answerField.placeholderString = "输入完整五笔码后回车" - answerField.target = self - answerField.action = #selector(submitCode) - answerField.font = .monospacedSystemFont(ofSize: 14, weight: .regular) - content.addSubview(answerField) + answerView.frame = NSRect(x: margin, y: 148, width: width - margin * 2, height: 30) + answerView.onReturn = { [weak self] text in self?.submitCode(text) } + content.addSubview(answerView) // 识别码:两行带标题的按钮组 strokeTitle.font = .systemFont(ofSize: 11) strokeTitle.textColor = .secondaryLabelColor - strokeTitle.frame = NSRect(x: margin, y: 148, width: 32, height: 24) + strokeTitle.frame = NSRect(x: margin, y: 150, width: 46, height: 18) content.addSubview(strokeTitle) for (index, button) in strokeButtons.enumerated() { button.target = self button.action = #selector(pickStroke(_:)) button.tag = index button.bezelStyle = .rounded - button.frame = NSRect(x: margin + 36 + CGFloat(index) * 74, y: 144, width: 68, height: 28) + button.frame = NSRect(x: margin + 52 + CGFloat(index) * 72, y: 144, width: 66, height: 28) content.addSubview(button) } structureTitle.font = .systemFont(ofSize: 11) structureTitle.textColor = .secondaryLabelColor - structureTitle.frame = NSRect(x: margin, y: 186, width: 32, height: 24) + structureTitle.frame = NSRect(x: margin, y: 188, width: 46, height: 18) content.addSubview(structureTitle) for (index, button) in structureButtons.enumerated() { button.target = self button.action = #selector(pickStructure(_:)) button.tag = index button.bezelStyle = .rounded - button.frame = NSRect(x: margin + 36 + CGFloat(index) * 108, y: 182, width: 100, height: 28) + button.frame = NSRect(x: margin + 52 + CGFloat(index) * 116, y: 182, width: 108, height: 28) content.addSubview(button) } @@ -139,7 +136,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { private func applyKindVisibility() { let isCode = kind == .code - answerField.isHidden = !isCode + answerView.isHidden = !isCode strokeTitle.isHidden = isCode structureTitle.isHidden = isCode for button in strokeButtons + structureButtons { button.isHidden = isCode } @@ -153,38 +150,40 @@ final class PracticeWindow: NSObject, NSWindowDelegate { } private func reload() { - bank = Practice.bank(kind: kind) - current = nil - nextQuestion() + session.switchKind(kind) + showCurrent() } @objc private func nextQuestion() { - guard !bank.isEmpty else { + session.next() + showCurrent() + } + + private func showCurrent() { + answerView.clear() + pendingStroke = nil + pendingStructure = nil + lastAnswer = nil + feedbackLabel.stringValue = "" + strokeTitle.stringValue = "末笔" + structureTitle.stringValue = "结构" + guard let question = current else { questionLabel.stringValue = "题库为空" promptLabel.stringValue = "当前没有可出题的完整条目(需要 quality=full)" + progressLabel.stringValue = session.summary return } - current = bank.randomElement() - lastAnswer = nil - answerField.stringValue = "" - pendingStroke = nil - pendingStructure = nil - feedbackLabel.stringValue = "" - guard let question = current else { return } questionLabel.stringValue = question.char promptLabel.stringValue = kind == .code ? "打出它的完整五笔码(\(question.code.count) 位)" : "拆解:\(question.rootHint)" - progressLabel.stringValue = progress.summary - if kind == .code { window?.makeFirstResponder(answerField) } + progressLabel.stringValue = session.summary + if kind == .code { window?.makeFirstResponder(answerView) } } - @objc private func submitCode() { - guard let question = current else { return } - let answer = answerField.stringValue - let correct = Practice.judgeCode(answer, question: question) - lastAnswer = answer - finish(correct: correct, explanation: "完整码 \(question.code.uppercased()) · 字根 \(question.rootHint)") + @objc private func submitCode(_ text: String) { + guard let result = session.submitCode(text) else { return } + finish(result) } @objc private func pickStroke(_ sender: NSButton) { @@ -205,22 +204,19 @@ final class PracticeWindow: NSObject, NSWindowDelegate { } private func evaluateRecognition() { - guard let question = current, let stroke = pendingStroke, let structure = pendingStructure else { + guard let stroke = pendingStroke, let structure = pendingStructure else { feedbackLabel.stringValue = "选一个末笔和一个结构" feedbackLabel.textColor = .secondaryLabelColor return } - let correct = Practice.judgeRecognition(stroke: stroke, structure: structure, question: question) - let letter = question.recognition?.letter ?? "—" - finish(correct: correct, explanation: "识别码 \(letter)(\(stroke)·\(structure))") + guard let result = session.submitRecognition(stroke: stroke, structure: structure) else { return } + finish(result) } - 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 + private func finish(_ result: PracticeSession.Result) { + feedbackLabel.stringValue = (result.correct ? "✓ 正确 · " : "✗ 再想想 · ") + result.explanation + feedbackLabel.textColor = result.correct ? .systemGreen : .systemOrange + progressLabel.stringValue = session.summary } @objc private func revealAnswer() { @@ -239,11 +235,10 @@ final class PracticeWindow: NSObject, NSWindowDelegate { /// 离屏渲染(测试用):建一个不显示的窗口承载视图(AppKit 控件需要 window 才能绘制)。 func renderForTesting(kind: PracticeKind, progressURL: URL, light: Bool) -> NSBitmapImageRep? { practiceProgress = PracticeProgress(url: progressURL) + session = PracticeSession(kind: kind, progress: practiceProgress!) let view = buildContent() kindControl.selectedSegment = PracticeKind.allCases.firstIndex(of: kind) ?? 0 applyKindVisibility() - bank = Practice.bank(kind: kind) - current = bank.first if let question = current { questionLabel.stringValue = question.char promptLabel.stringValue = kind == .code diff --git a/platforms/macos/dev-preview.sh b/platforms/macos/dev-preview.sh index e05286a..e860b75 100755 --- a/platforms/macos/dev-preview.sh +++ b/platforms/macos/dev-preview.sh @@ -55,6 +55,7 @@ swiftc -g -Onone -warnings-as-errors -module-name LampyrisPreview \ "${SCRIPT_DIR}/Sources/LearningHints.swift" \ "${SCRIPT_DIR}/Sources/Practice.swift" \ "${SCRIPT_DIR}/Sources/PracticeWindow.swift" \ + "${SCRIPT_DIR}/Sources/PracticeAnswerView.swift" \ "${SCRIPT_DIR}/Sources/WubiHints.generated.swift" \ "${ROOT}/target/debug/libime_ffi.a" \ -framework AppKit -o "${BIN}" diff --git a/platforms/macos/tests/test-appearance-contract.py b/platforms/macos/tests/test-appearance-contract.py index 3e9ba89..a67d9c1 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", "Practice.swift", "PracticeWindow.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.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 b025a00..ad5d230 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", "Practice.swift", "PracticeWindow.swift", "StateHUD.swift"]], + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.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 8708434..355f190 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", "Practice.swift", "PracticeWindow.swift"]], + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.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 6bac3ec..ae4fde9 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', 'Practice.swift', 'PracticeWindow.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', 'PracticeAnswerView.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 57f2708..b3d7793 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", "Practice.swift", "PracticeWindow.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.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 06afe29..01ffeaa 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", "Practice.swift", "PracticeWindow.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.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 5b99387..3718e41 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", "Practice.swift", "PracticeWindow.swift"]], + "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.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 d92aa74..d6cd9dd 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -31,6 +31,45 @@ 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") +// ADR-313 练习流程(自动化):答题 → 判分 → 进度增长;识别码说明用正确答案。 +let flowDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("lampyris-practice-flow") +try? FileManager.default.removeItem(at: flowDir) +try? FileManager.default.createDirectory(at: flowDir, withIntermediateDirectories: true) +let flowProgress = PracticeProgress(url: flowDir.appendingPathComponent("flow.tsv")) +let flow = PracticeSession(kind: .code, progress: flowProgress) +guard let firstQuestion = flow.question else { exit(1) } +let before = flow.summary +guard let okResult = flow.submitCode(firstQuestion.code) else { exit(1) } +require(okResult.correct, "正确码应判对") +require(flow.summary != before, "作答后进度摘要应变化") +require(flowProgress.rows.values.reduce(0) { $0 + $1.attempts } == 1, "应记录 1 次作答") +require(flowProgress.rows.values.reduce(0) { $0 + $1.correct } == 1, "应记录 1 次正确") +_ = flow.submitCode("zzzz") +require(flowProgress.rows.values.reduce(0) { $0 + $1.attempts } == 2, "错误作答也应计数") +let recognitionSession = PracticeSession(kind: .recognition, progress: flowProgress) +guard let recognitionQuestion = recognitionSession.question, let expected = recognitionQuestion.recognition else { exit(1) } +guard let recognitionResult = recognitionSession.submitRecognition(stroke: expected.stroke, + structure: expected.structure) else { exit(1) } +require(recognitionResult.correct, "正确末笔/结构应判对") +require(recognitionResult.explanation.contains(expected.letter), "说明应包含正确字母") +require(recognitionResult.explanation.contains(expected.stroke), "说明应包含**正确**的末笔(不是用户选的)") +// 输入视图只接受 a–y、退格与回车。 +let answer = PracticeAnswerView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) +var submitted: String? +answer.onReturn = { submitted = $0 } +func key(_ text: String) -> NSEvent? { + NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, + windowNumber: 0, context: nil, characters: text, + charactersIgnoringModifiers: text, isARepeat: false, keyCode: 0) +} +for character in "GCft" { if let event = key(String(character)) { answer.keyDown(with: event) } } +require(answer.text == "gcft", "只接受小写字母(实际 \(answer.text))") +if let event = key("\u{7F}") { answer.keyDown(with: event) } +require(answer.text == "gcf", "退格删除") +if let event = key("z") { answer.keyDown(with: event) } +require(answer.text == "gcf", "z 不接受") +if let event = key("\r") { answer.keyDown(with: event) } +require(submitted == "gcf", "回车提交") // ADR-313 练习窗口:版式自检(控件不越界、不与题型控件重叠)。 for kind in [PracticeKind.code, PracticeKind.recognition] { let problems = PracticeWindow.shared.layoutProblemsForTesting(kind: kind)