From 45447c4d43c12643355a02d537b1cfa7ed75b7e6 Mon Sep 17 00:00:00 2001 From: wangzhizhou <824219521@qq.com> Date: Sun, 8 Nov 2026 01:08:25 +0800 Subject: [PATCH] feat: show practice radicals in the same two line style as the learning area The practice prompt wrote radicals and keys inline, which is hard to scan. A small row view now draws the same cells the learning area uses, glyph above and upper case key below, and the recognition question shows it as the evidence to reason from. The typing question keeps it hidden until after the answer so it does not give the code away, then reveals it as the explanation. The window grows to fit the extra row and the layout is re-spaced. Signed-off-by: wangzhizhou <824219521@qq.com> --- HANDOFF.md | 1 + .../reviews/261-practice-flow-and-ui-fixes.md | 11 ++++++ platforms/macos/Sources/Practice.swift | 3 +- .../macos/Sources/PracticeRootRowView.swift | 38 +++++++++++++++++++ platforms/macos/Sources/PracticeWindow.swift | 37 ++++++++++++------ 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 | 4 ++ 14 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 platforms/macos/Sources/PracticeRootRowView.swift diff --git a/HANDOFF.md b/HANDOFF.md index e181c32..833b32c 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 §6)**:新增 `PracticeRootRowView`(字形上、大写键位下),识别码题常驻、看字打码题答后显示;窗口高 356。**待装包复验**。 - **练习流程已修 + 自动化(报告 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 index bd8fb4c..52ebd76 100644 --- a/docs/reviews/261-practice-flow-and-ui-fixes.md +++ b/docs/reviews/261-practice-flow-and-ui-fixes.md @@ -35,3 +35,14 @@ ## 5. 关联 ADR-313、报告 257/259/260。 + +## 6. 字根显示改为与学习区一致(2026-11-08 反馈) + +**反馈**:练习页把字根和键位交替写成 `土F 丿T 匕X`,阅读不直观。 + +**改法**:新增 `PracticeRootRowView`,与学习区同一套呈现——**字形在上、大写键位在下**,格子等宽居中; +- 识别码题型:字根行常驻,作为判断末笔/结构的依据; +- 看字打码题型:答题后再显示字根行(不提前泄题),反馈文案不再重复拼接字根; +- 窗口高度 336→356,纵向重排:题型 / 题干 / 提示 / 字根行 / 作答 / 反馈 / 按钮 / 进度。 + +**测试**:新增字根行断言(键位大写、顺序一致);版式自检仍要求所有控件在内容区内。 diff --git a/platforms/macos/Sources/Practice.swift b/platforms/macos/Sources/Practice.swift index f512e1a..749a0cb 100644 --- a/platforms/macos/Sources/Practice.swift +++ b/platforms/macos/Sources/Practice.swift @@ -174,8 +174,7 @@ final class PracticeSession { 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)") + return Result(correct: correct, explanation: "完整码 \(question.code.uppercased())") } /// 提交识别码选择;证据说明用**正确**的末笔与结构,而不是用户选的。 diff --git a/platforms/macos/Sources/PracticeRootRowView.swift b/platforms/macos/Sources/PracticeRootRowView.swift new file mode 100644 index 0000000..a4139b9 --- /dev/null +++ b/platforms/macos/Sources/PracticeRootRowView.swift @@ -0,0 +1,38 @@ +import AppKit + +/// 练习里的字根行:与学习区同一套样式——上面字形、下面大写键位,一目了然。 +final class PracticeRootRowView: NSView { + static let cellWidth: CGFloat = 46 + static let rowHeight: CGFloat = 36 + private(set) var roots: [(glyph: String, key: String)] = [] + override var isFlipped: Bool { true } + + func show(_ roots: [(glyph: String, key: String)]) { + self.roots = Array(roots.prefix(6)) + setAccessibilityLabel(self.roots.map { "\($0.glyph),键 \($0.key)" }.joined(separator: ";")) + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + var x: CGFloat = 0 + for root in roots { + let glyphRect = NSRect(x: x, y: 0, width: Self.cellWidth, height: 21) + let keyRect = NSRect(x: x, y: 21, width: Self.cellWidth, height: 15) + let glyph = NSMutableAttributedString(string: root.glyph, attributes: [ + .font: NSFont.systemFont(ofSize: 15), + .foregroundColor: NSColor.labelColor]) + var paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + glyph.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: glyph.length)) + glyph.draw(in: glyphRect) + let key = NSMutableAttributedString(string: root.key.uppercased(), attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 10.5, weight: .regular), + .foregroundColor: NSColor.secondaryLabelColor]) + paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + key.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: key.length)) + key.draw(in: keyRect) + x += Self.cellWidth + } + } +} diff --git a/platforms/macos/Sources/PracticeWindow.swift b/platforms/macos/Sources/PracticeWindow.swift index 5c29d22..29465ff 100644 --- a/platforms/macos/Sources/PracticeWindow.swift +++ b/platforms/macos/Sources/PracticeWindow.swift @@ -6,7 +6,7 @@ import AppKit /// 题型切换 → 题干 → 提示 → 作答区 → 反馈 → 按钮 → 进度。 final class PracticeWindow: NSObject, NSWindowDelegate { static let shared = PracticeWindow() - static let contentSize = NSSize(width: 460, height: 336) + static let contentSize = NSSize(width: 460, height: 356) private var window: NSWindow? private var content: FlippedView? @@ -15,6 +15,8 @@ final class PracticeWindow: NSObject, NSWindowDelegate { private let feedbackLabel = NSTextField(wrappingLabelWithString: "") private let progressLabel = NSTextField(labelWithString: "") private let answerView = PracticeAnswerView() + private let rootRow = PracticeRootRowView() + private var answered = false private let kindControl = NSSegmentedControl(labels: PracticeKind.allCases.map(\.title), trackingMode: .selectOne, target: nil, action: nil) private let strokeTitle = NSTextField(labelWithString: "末笔") @@ -80,42 +82,46 @@ final class PracticeWindow: NSObject, NSWindowDelegate { promptLabel.alignment = .center promptLabel.font = .systemFont(ofSize: 12) promptLabel.textColor = .secondaryLabelColor - promptLabel.frame = NSRect(x: margin, y: 120, width: width - margin * 2, height: 18) + promptLabel.frame = NSRect(x: margin, y: 116, width: width - margin * 2, height: 18) content.addSubview(promptLabel) - answerView.frame = NSRect(x: margin, y: 148, width: width - margin * 2, height: 30) + // 字根行(与学习区同样式:字形上、键位下) + rootRow.frame = NSRect(x: margin, y: 136, width: width - margin * 2, height: PracticeRootRowView.rowHeight) + content.addSubview(rootRow) + + answerView.frame = NSRect(x: margin, y: 176, 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: 150, width: 46, height: 18) + strokeTitle.frame = NSRect(x: margin, y: 182, 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 + 52 + CGFloat(index) * 72, y: 144, width: 66, height: 28) + button.frame = NSRect(x: margin + 52 + CGFloat(index) * 72, y: 176, width: 66, height: 28) content.addSubview(button) } structureTitle.font = .systemFont(ofSize: 11) structureTitle.textColor = .secondaryLabelColor - structureTitle.frame = NSRect(x: margin, y: 188, width: 46, height: 18) + structureTitle.frame = NSRect(x: margin, y: 220, 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 + 52 + CGFloat(index) * 116, y: 182, width: 108, height: 28) + button.frame = NSRect(x: margin + 52 + CGFloat(index) * 116, y: 214, width: 108, height: 28) content.addSubview(button) } feedbackLabel.alignment = .center feedbackLabel.font = .systemFont(ofSize: 12) - feedbackLabel.frame = NSRect(x: margin, y: 218, width: width - margin * 2, height: 40) + feedbackLabel.frame = NSRect(x: margin, y: 250, width: width - margin * 2, height: 40) content.addSubview(feedbackLabel) let next = NSButton(title: "下一题", target: self, action: #selector(nextQuestion)) @@ -123,13 +129,13 @@ final class PracticeWindow: NSObject, NSWindowDelegate { let export = NSButton(title: "打开进度文件", target: self, action: #selector(openProgress)) for (index, button) in [next, reveal, export].enumerated() { button.bezelStyle = .rounded - button.frame = NSRect(x: margin + CGFloat(index) * 130, y: 264, width: 120, height: 28) + button.frame = NSRect(x: margin + CGFloat(index) * 130, y: 292, width: 120, height: 28) content.addSubview(button) } progressLabel.font = .systemFont(ofSize: 11) progressLabel.textColor = .secondaryLabelColor - progressLabel.frame = NSRect(x: margin, y: 300, width: width - margin * 2, height: 16) + progressLabel.frame = NSRect(x: margin, y: 326, width: width - margin * 2, height: 16) content.addSubview(progressLabel) return content } @@ -137,6 +143,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { private func applyKindVisibility() { let isCode = kind == .code answerView.isHidden = !isCode + rootRow.isHidden = !(kind == .recognition || answered) strokeTitle.isHidden = isCode structureTitle.isHidden = isCode for button in strokeButtons + structureButtons { button.isHidden = isCode } @@ -160,6 +167,7 @@ final class PracticeWindow: NSObject, NSWindowDelegate { } private func showCurrent() { + answered = false answerView.clear() pendingStroke = nil pendingStructure = nil @@ -176,7 +184,9 @@ final class PracticeWindow: NSObject, NSWindowDelegate { questionLabel.stringValue = question.char promptLabel.stringValue = kind == .code ? "打出它的完整五笔码(\(question.code.count) 位)" - : "拆解:\(question.rootHint)" + : "按上面的字根与键位,判断末笔与结构" + rootRow.show(question.roots) + rootRow.isHidden = kind != .recognition progressLabel.stringValue = session.summary if kind == .code { window?.makeFirstResponder(answerView) } } @@ -214,6 +224,11 @@ final class PracticeWindow: NSObject, NSWindowDelegate { } private func finish(_ result: PracticeSession.Result) { + answered = true + if let question = current { + rootRow.show(question.roots) + rootRow.isHidden = false + } feedbackLabel.stringValue = (result.correct ? "✓ 正确 · " : "✗ 再想想 · ") + result.explanation feedbackLabel.textColor = result.correct ? .systemGreen : .systemOrange progressLabel.stringValue = session.summary diff --git a/platforms/macos/dev-preview.sh b/platforms/macos/dev-preview.sh index e860b75..8dace1e 100755 --- a/platforms/macos/dev-preview.sh +++ b/platforms/macos/dev-preview.sh @@ -56,6 +56,7 @@ swiftc -g -Onone -warnings-as-errors -module-name LampyrisPreview \ "${SCRIPT_DIR}/Sources/Practice.swift" \ "${SCRIPT_DIR}/Sources/PracticeWindow.swift" \ "${SCRIPT_DIR}/Sources/PracticeAnswerView.swift" \ + "${SCRIPT_DIR}/Sources/PracticeRootRowView.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 a67d9c1..2a5fb6e 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", "PracticeAnswerView.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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 ad5d230..d8ffe94 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", "PracticeAnswerView.swift", "StateHUD.swift"]], + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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 355f190..13c805e 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", "PracticeAnswerView.swift"]], + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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 ae4fde9..fda4bbf 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', 'PracticeAnswerView.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', 'PracticeRootRowView.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 b3d7793..5a2377f 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", "PracticeAnswerView.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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 01ffeaa..806a5e1 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", "PracticeAnswerView.swift", "Settings.swift", "Feedback.swift", "InfoWindow.swift", + ["ImeSession.swift", "Config.swift", "CandidateWindow.swift", "CaretPlacement.swift", "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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 3718e41..502829b 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", "PracticeAnswerView.swift"]], + "LearningHints.swift", "Practice.swift", "PracticeWindow.swift", "PracticeAnswerView.swift", "PracticeRootRowView.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 d6cd9dd..a9915ae 100644 --- a/platforms/macos/tests/wubi-learning.swift +++ b/platforms/macos/tests/wubi-learning.swift @@ -53,6 +53,10 @@ guard let recognitionResult = recognitionSession.submitRecognition(stroke: expec require(recognitionResult.correct, "正确末笔/结构应判对") require(recognitionResult.explanation.contains(expected.letter), "说明应包含正确字母") require(recognitionResult.explanation.contains(expected.stroke), "说明应包含**正确**的末笔(不是用户选的)") +// 练习字根行与学习区同样式:字形上、键位下(大写)。 +let rootRow = PracticeRootRowView(frame: NSRect(x: 0, y: 0, width: 400, height: 36)) +rootRow.show([("土", "F"), ("丿", "T"), ("匕", "X")]) +require(rootRow.roots.map(\.key) == ["F", "T", "X"], "字根行键位大写且顺序一致") // 输入视图只接受 a–y、退格与回车。 let answer = PracticeAnswerView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) var submitted: String?