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)