From f3e164b834edab28427542812bf1ba0926c9bd53 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 6 May 2026 23:33:57 +0200 Subject: [PATCH] test sweep: cover core/input/ keytab + handlers (T-91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds test/terminal/input/input_test.dart — 58 unit tests covering the keytab tokenizer, parser, unescape helper, KeytabRecord toString shapes, Keytab.find modifier-matching rules, and the four TerminalInputHandler implementations (CascadeInputHandler, KeytabInputHandler, CtrlInputHandler, AltInputHandler). Highlights: - keytabUnescape: every documented backslash escape + \xHH hex. - LineReader: peek/take/done, whitespace skip, readString (alphanumeric/underscore), readUntil (both exclusive and inclusive variants). - tokenize: keyboard-name and key-define lines, comment + blank stripping, shortcut vs string actions, error paths on malformed input. - KeytabParser: full mode-flag matrix, error paths on every defensive throw reachable through the public addTokens API (stray non-keyboard token, missing colon, modeStatus value other than '+'/'-', non-mode token after modeStatus, action token of wrong type, second token of wrong kind for both _parseName and _parseKeyDefine). - KeytabRecord.toString covers every supported flag (Alt, Control, Shift, AnyMod, Ansi, AppScreen, KeyPad, AppCuKeys, AppKeyPad, NewLine, Mac). - Keytab.find: -Shift / +AnyMod / -AnyMod gating, mode-flag filters (newLine, appKeyPad, appScreen, macos, appCursorKeys, keyPad), -Ansi (VT52) skip, fallthrough to fallback record, null when no key matches. - KeytabInputHandler: every modifier combination's `*` placeholder expansion (1..8 inclusive), default-keytab fallback, no-match null, no-* passthrough. - CtrlInputHandler: A..Z → 0x01..0x1A; null without ctrl, with shift / alt, or on non-letter keys. - AltInputHandler: A..Z → ESC + uppercase; null without alt, with shift / ctrl, on macOS, or on non-letter keys. - defaultInputHandler integration: keytab routing, fallthrough to CtrlInputHandler. Coverage delta: - core/input/handler.dart: 4/54 → 54/54. - keytab.dart: 0/29 → 29/29. - keytab_record.dart: 0/44 → 44/44. - keytab_token.dart: 0/82 → 80/82 (the two remaining lines are defensive throws inside `_parseKeyboardNameDefine` / `_parseKeyDefine` that are unreachable from tokenize() — the callers only enter those functions after the `_isKeyboardNameDefine` / `_isKeyDefine` guards in the same file, so the inner readString always matches). - keytab_parse.dart: 0/65 → 63/65 (the two remaining lines mirror the same shape — _parseName and _parseKeyDefine both check the first token's type, but addTokens only delegates to them after matching that type, so the throws are dead defensive code). - keytab_default.dart: 0/4 unchanged — that's the file's own `void main()` debug entrypoint that prints the parsed default keytab; not part of the runtime contract. - keytab_escape.dart: 0/14 → 14/14. - Total project: 49.31% → 52.53%; coverage_floor bumped 49 → 52. The 4 dead defensive throws are flagged but not removed in this commit — they're a code-style call (defensive paranoia vs. dead- code cleanup) that belongs in a separate review, not folded into a test sweep. Co-Authored-By: Claude --- .pql/pql-plan.json | 214 ++++----- pubspec.yaml | 2 +- test/terminal/input/input_test.dart | 702 ++++++++++++++++++++++++++++ 3 files changed, 810 insertions(+), 108 deletions(-) create mode 100644 test/terminal/input/input_test.dart diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index 871519ae..cc01dc3f 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -1,5 +1,5 @@ { - "exported_at": "2026-05-06T21:25:59Z", + "exported_at": "2026-05-06T21:33:57Z", "decisions": [ { "id": "D-1", @@ -9,7 +9,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-3", @@ -19,7 +19,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-4", @@ -29,7 +29,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-5", @@ -39,7 +39,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-6", @@ -49,7 +49,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-7", @@ -59,7 +59,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-8", @@ -69,7 +69,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-9", @@ -79,7 +79,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-10", @@ -89,7 +89,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-11", @@ -99,7 +99,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-12", @@ -109,7 +109,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-13", @@ -119,7 +119,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-14", @@ -129,7 +129,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-15", @@ -139,7 +139,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-16", @@ -149,7 +149,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-17", @@ -159,7 +159,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-18", @@ -169,7 +169,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-19", @@ -179,7 +179,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-20", @@ -189,7 +189,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-21", @@ -199,7 +199,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-22", @@ -209,7 +209,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/accessibility.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-23", @@ -219,7 +219,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-24", @@ -229,7 +229,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-25", @@ -239,7 +239,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-26", @@ -249,7 +249,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-27", @@ -259,7 +259,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-28", @@ -269,7 +269,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-29", @@ -279,7 +279,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-30", @@ -289,7 +289,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-31", @@ -299,7 +299,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-32", @@ -309,7 +309,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-33", @@ -319,7 +319,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-34", @@ -329,7 +329,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-35", @@ -339,7 +339,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-36", @@ -349,7 +349,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-37", @@ -359,7 +359,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-38", @@ -369,7 +369,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-39", @@ -379,7 +379,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-40", @@ -389,7 +389,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-41", @@ -399,7 +399,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-42", @@ -409,7 +409,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-43", @@ -419,7 +419,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-44", @@ -429,7 +429,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-45", @@ -439,7 +439,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-46", @@ -449,7 +449,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-47", @@ -459,7 +459,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-48", @@ -469,7 +469,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-49", @@ -479,7 +479,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-50", @@ -489,7 +489,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-51", @@ -499,7 +499,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-52", @@ -509,7 +509,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-53", @@ -519,7 +519,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-54", @@ -529,7 +529,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-55", @@ -539,7 +539,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-56", @@ -549,7 +549,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-57", @@ -559,7 +559,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-58", @@ -569,7 +569,7 @@ "status": "active", "date": "2026-04-23", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-59", @@ -579,7 +579,7 @@ "status": "active", "date": "2026-04-25", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-60", @@ -589,7 +589,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-61", @@ -599,7 +599,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-62", @@ -609,7 +609,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-63", @@ -619,7 +619,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-64", @@ -629,7 +629,7 @@ "status": "active", "date": "2026-05-03", "file_path": "decisions/architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-65", @@ -639,7 +639,7 @@ "status": "active", "date": "2026-04-26", "file_path": "decisions/tooling.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "D-66", @@ -649,7 +649,7 @@ "status": "active", "date": "2026-05-06", "file_path": "decisions/testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-1", @@ -658,7 +658,7 @@ "title": "Authorisation granularity on the IPC socket", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-2", @@ -667,7 +667,7 @@ "title": "Back-pressure on event streams", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-3", @@ -676,7 +676,7 @@ "title": "Event persistence + audit/undo", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-4", @@ -685,7 +685,7 @@ "title": "`.canvas` schema compatibility with Obsidian", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-5", @@ -694,7 +694,7 @@ "title": "IPC wire-format stability + `schema_version:`", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-6", @@ -703,7 +703,7 @@ "title": "Window chrome — native frame vs frameless custom", "status": "resolved", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-7", @@ -712,7 +712,7 @@ "title": "macOS app bundle signing / notarisation", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-8", @@ -721,7 +721,7 @@ "title": "Extension API shape — widgets, subcommands, both?", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-9", @@ -730,7 +730,7 @@ "title": "Lua runtime vendoring", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-10", @@ -739,7 +739,7 @@ "title": "Extension manifest `schema_version:`", "status": "open", "file_path": "decisions/questions-extensions.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-11", @@ -748,7 +748,7 @@ "title": "Coverage gates — hard thresholds vs soft reporting", "status": "open", "file_path": "decisions/questions-testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-12", @@ -757,7 +757,7 @@ "title": "Screen-reader automation (axe-core via Playwright)", "status": "open", "file_path": "decisions/questions-testing.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-13", @@ -766,7 +766,7 @@ "title": "Web production-mode a11y", "status": "open", "file_path": "decisions/questions-accessibility.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-14", @@ -775,7 +775,7 @@ "title": "i18n plurals / gender / date-format tooling", "status": "open", "file_path": "decisions/questions-accessibility.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-15", @@ -784,7 +784,7 @@ "title": "Editor tab — full LSP vs tree-sitter-only highlight", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-16", @@ -793,7 +793,7 @@ "title": "`tree-sitter-dart` grammar maintenance", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-17", @@ -802,7 +802,7 @@ "title": "Icon set growth", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-18", @@ -811,7 +811,7 @@ "title": "Theme hot-reload in release builds", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-19", @@ -820,7 +820,7 @@ "title": "(withdrawn)", "status": "resolved", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-20", @@ -829,7 +829,7 @@ "title": "Kernel DB service — namespaced SQL access?", "status": "open", "file_path": "decisions/questions-process.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-21", @@ -838,7 +838,7 @@ "title": "Pql absorbs planning vs keeps separate", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-22", @@ -847,7 +847,7 @@ "title": "Ticket persistence strategy", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-23", @@ -856,7 +856,7 @@ "title": "SSH-remote development — run clide against a remote workspace", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-25", @@ -865,7 +865,7 @@ "title": "Body text face — mono everywhere vs Josefin Sans UI + mono code", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-26", @@ -874,7 +874,7 @@ "title": "Small screen layout (\u003c 1000px)", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-27", @@ -883,7 +883,7 @@ "title": "Two-editor split", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-28", @@ -892,7 +892,7 @@ "title": "Terminal strip scope — shell only or logs/errors/tests", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-29", @@ -901,7 +901,7 @@ "title": "Branch picker location", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-30", @@ -910,7 +910,7 @@ "title": "Focus behavior when editor is dirty and viewer is peeked", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "Q-31", @@ -919,7 +919,7 @@ "title": "XWayland fallback for frameless — proper Wayland protocol needed", "status": "open", "file_path": "decisions/questions-architecture.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-2", @@ -929,7 +929,7 @@ "status": "active", "date": "2026-04-20", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-3", @@ -939,7 +939,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-4", @@ -949,7 +949,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-5", @@ -959,7 +959,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-6", @@ -969,7 +969,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-7", @@ -979,7 +979,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-8", @@ -989,7 +989,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-9", @@ -999,7 +999,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-10", @@ -1009,7 +1009,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-11", @@ -1019,7 +1019,7 @@ "status": "active", "date": "2026-04-21", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" }, { "id": "R-12", @@ -1029,7 +1029,7 @@ "status": "active", "date": "2026-04-22", "file_path": "decisions/rejected.md", - "synced_at": "2026-05-06 21:25:40" + "synced_at": "2026-05-06 21:32:45" } ], "decision_refs": [ diff --git a/pubspec.yaml b/pubspec.yaml index 65dafd25..2bd5f6da 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide # Pre-push line-coverage floor. Ratchets up only — see D-66. # Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`. -coverage_floor: 49 +coverage_floor: 52 # Project metadata (was project.yaml, folded in per D-056). # version: above is the single source of truth. The Makefile reads diff --git a/test/terminal/input/input_test.dart b/test/terminal/input/input_test.dart new file mode 100644 index 00000000..e805f306 --- /dev/null +++ b/test/terminal/input/input_test.dart @@ -0,0 +1,702 @@ +/// Pure-Dart tests for `lib/src/terminal/src/core/input/`. +/// +/// Covers the keytab tokenizer + parser, the unescape helper, the +/// KeytabRecord toString shapes, the `Keytab.find` modifier-matching +/// rules, and the four `TerminalInputHandler` implementations. +library; + +import 'package:clide/src/terminal/src/core/input/handler.dart'; +import 'package:clide/src/terminal/src/core/input/keys.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_escape.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_parse.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_record.dart'; +import 'package:clide/src/terminal/src/core/input/keytab/keytab_token.dart'; +import 'package:clide/src/terminal/src/core/mouse/mode.dart'; +import 'package:clide/src/terminal/src/core/cursor.dart'; +import 'package:clide/src/terminal/src/core/platform.dart'; +import 'package:clide/src/terminal/src/core/state.dart'; +import 'package:test/test.dart'; + +class _State implements TerminalState { + _State({ + this.lineFeedMode = false, + this.appKeypadMode = false, + }); + + @override + bool lineFeedMode; + @override + bool appKeypadMode; + + @override + int get viewWidth => 80; + @override + int get viewHeight => 24; + @override + CursorStyle get cursor => CursorStyle(); + @override + bool get reflowEnabled => false; + @override + bool get insertMode => false; + @override + bool get cursorKeysMode => false; + @override + bool get reverseDisplayMode => false; + @override + bool get originMode => false; + @override + bool get autoWrapMode => true; + @override + MouseMode get mouseMode => MouseMode.none; + @override + MouseReportMode get mouseReportMode => MouseReportMode.normal; + @override + bool get cursorBlinkMode => true; + @override + bool get cursorVisibleMode => true; + @override + bool get reportFocusMode => false; + @override + bool get altBufferMouseScrollMode => false; + @override + bool get bracketedPasteMode => false; +} + +TerminalKeyboardEvent _evt( + TerminalKey key, { + bool ctrl = false, + bool alt = false, + bool shift = false, + bool altBuffer = false, + bool lineFeedMode = false, + bool appKeypadMode = false, + TerminalTargetPlatform platform = TerminalTargetPlatform.linux, +}) { + return TerminalKeyboardEvent( + key: key, + shift: shift, + ctrl: ctrl, + alt: alt, + state: _State( + lineFeedMode: lineFeedMode, + appKeypadMode: appKeypadMode, + ), + altBuffer: altBuffer, + platform: platform, + ); +} + +void main() { + group('keytabUnescape', () { + test(r'\E maps to ESC (0x1b)', () { + expect(keytabUnescape(r'\E'), '\x1b'); + expect(keytabUnescape(r'\E[A'), '\x1b[A'); + }); + + test('classic backslash escapes round-trip', () { + expect(keytabUnescape(r'\\'), r'\'); + expect(keytabUnescape(r'\"'), '"'); + expect(keytabUnescape(r'\t'), '\t'); + expect(keytabUnescape(r'\r'), '\r'); + expect(keytabUnescape(r'\n'), '\n'); + expect(keytabUnescape(r'\b'), '\b'); + }); + + test(r'\xHH parses two hex digits to a single char', () { + expect(keytabUnescape(r'\x00'), '\x00'); + expect(keytabUnescape(r'\x7f'), '\x7f'); + expect(keytabUnescape(r'\xFF'), 'ÿ'); + }); + + test(r'leaves unrecognised text alone', () { + expect(keytabUnescape('hello'), 'hello'); + expect(keytabUnescape(''), ''); + }); + }); + + group('LineReader', () { + test('peek/take advance + done detection', () { + final r = LineReader('abc'); + expect(r.peek(), 'a'); + expect(r.take(), 'a'); + expect(r.peek(2), 'bc'); + expect(r.take(2), 'bc'); + expect(r.done, isTrue); + expect(r.peek(), isNull); + expect(r.take(), isNull); + }); + + test('peek clamps when count exceeds remaining length', () { + final r = LineReader('ab'); + expect(r.peek(99), 'ab'); + }); + + test('skipWhitespace eats spaces and tabs but stops on other chars', () { + final r = LineReader(' \t hello'); + r.skipWhitespace(); + expect(r.peek(5), 'hello'); + }); + + test('readString takes alphanumeric / underscore until break', () { + final r = LineReader('abc_123 next'); + expect(r.readString(), 'abc_123'); + // Position should now be at the space. + expect(r.peek(), ' '); + }); + + test('readUntil takes everything up to the pattern (exclusive by default)', () { + final r = LineReader('foo"bar'); + expect(r.readUntil('"'), 'foo'); + expect(r.peek(), '"'); + }); + + test('readUntil with inclusive=true consumes the boundary char', () { + final r = LineReader('foo"bar'); + expect(r.readUntil('"', inclusive: true), 'foo"'); + expect(r.peek(), 'b'); + }); + }); + + group('tokenize', () { + test('parses a keyboard-name line', () { + final tokens = tokenize('keyboard "Default"').toList(); + expect(tokens.map((t) => t.type).toList(), [ + KeytabTokenType.keyboard, + KeytabTokenType.input, + ]); + expect(tokens.last.value, 'Default'); + }); + + test('parses a key-define line with modes and string action', () { + final tokens = tokenize('key Up -Shift+Ansi : "\\EOA"').toList(); + expect(tokens.map((t) => t.type).toList(), [ + KeytabTokenType.keyDefine, + KeytabTokenType.keyName, + KeytabTokenType.modeStatus, + KeytabTokenType.mode, + KeytabTokenType.modeStatus, + KeytabTokenType.mode, + KeytabTokenType.colon, + KeytabTokenType.input, + ]); + expect(tokens[1].value, 'Up'); + expect(tokens.last.value, r'\EOA'); + }); + + test('parses a shortcut action (no quotes)', () { + final tokens = tokenize('key Up +Shift : scrollLineUp').toList(); + expect(tokens.last.type, KeytabTokenType.shortcut); + expect(tokens.last.value, 'scrollLineUp'); + }); + + test('skips comments and blank lines', () { + final source = ''' +# top comment +keyboard "X" + +# inner +key Tab : "\\t" # trailing comment +'''; + final tokens = tokenize(source).toList(); + // Trailing-comment stripping leaves no broken tokens. + expect( + tokens.map((t) => t.type).toList(), + contains(KeytabTokenType.colon), + ); + }); + + test('tokenize throws TokenizeError on a malformed key line missing colon', () { + expect( + () => tokenize('key Tab "\\t"').toList(), + throwsA(isA()), + ); + }); + + test('tokenize throws TokenizeError on unterminated keyboard line', () { + expect( + () => tokenize('keyboard X').toList(), + throwsA(isA()), + ); + }); + + test('KeytabToken toString reflects type + value', () { + final tok = KeytabToken(KeytabTokenType.input, 'hello'); + expect(tok.toString(), 'KeytabTokenType.input'); + }); + }); + + group('KeytabParser', () { + test('parses a keyboard name + a key-define line into a Keytab', () { + const src = ''' +keyboard "Test" +key Up -Shift+Ansi : "\\EOA" +'''; + final t = Keytab.parse(src); + expect(t.name, 'Test'); + expect(t.records, hasLength(1)); + expect(t.records.first.qtKeyName, 'Up'); + expect(t.records.first.shift, isFalse); + expect(t.records.first.ansi, isTrue); + }); + + test('every supported mode label maps to its KeytabRecord field', () { + const src = ''' +keyboard "Modes" +key Up +Alt+Control+Shift+AnyMod+Ansi+AppScreen+KeyPad+AppCuKeys+AppKeyPad+NewLine+Mac : "x" +'''; + final t = Keytab.parse(src); + final r = t.records.single; + expect(r.alt, isTrue); + expect(r.ctrl, isTrue); + expect(r.shift, isTrue); + expect(r.anyModifier, isTrue); + expect(r.ansi, isTrue); + expect(r.appScreen, isTrue); + expect(r.keyPad, isTrue); + expect(r.appCursorKeys, isTrue); + expect(r.appKeyPad, isTrue); + expect(r.newLine, isTrue); + expect(r.macos, isTrue); + }); + + test('parser throws ParseError on an unknown qt key name', () { + const src = ''' +keyboard "X" +key NotARealKey : "x" +'''; + expect(() => Keytab.parse(src), throwsA(isA())); + }); + + test('parser throws ParseError on an unknown mode label', () { + const src = ''' +keyboard "X" +key Up +Bogus : "x" +'''; + expect(() => Keytab.parse(src), throwsA(isA())); + }); + + test('TokensReader peek/take semantics', () { + final a = KeytabToken(KeytabTokenType.colon, ':'); + final b = KeytabToken(KeytabTokenType.colon, ':'); + final r = TokensReader([a, b]); + expect(r.peek(), a); + expect(r.take(), a); + expect(r.peek(), b); + expect(r.take(), b); + expect(r.done, isTrue); + expect(r.peek(), isNull); + }); + }); + + group('KeytabRecord / KeytabAction toString', () { + test('action toString quotes input and bare-prints shortcut', () { + expect( + KeytabAction(KeytabActionType.input, r'\E[A').toString(), + '"\\E[A"', + ); + expect( + KeytabAction(KeytabActionType.shortcut, 'scrollUp').toString(), + 'scrollUp', + ); + }); + + test('record toString writes +Mode for true and -Mode for false flags', () { + final r = KeytabRecord( + qtKeyName: 'Up', + key: TerminalKey.arrowUp, + action: KeytabAction(KeytabActionType.shortcut, 'scrollLineUp'), + alt: true, + ctrl: false, + shift: null, + anyModifier: null, + ansi: true, + appScreen: false, + keyPad: null, + appCursorKeys: null, + appKeyPad: null, + newLine: null, + macos: null, + ); + final s = r.toString(); + expect(s, contains('+Alt')); + expect(s, contains('-Control')); + expect(s, contains('+Ansi')); + expect(s, contains('-AppScreen')); + expect(s, isNot(contains('Shift'))); + expect(s, endsWith(': scrollLineUp')); + }); + + test('Keytab toString lists name + records', () { + const src = ''' +keyboard "Listed" +key Up +Shift : scrollLineUp +'''; + final t = Keytab.parse(src); + final s = t.toString(); + expect(s, contains('keyboard "Listed"')); + expect(s, contains('Up')); + expect(s, contains('scrollLineUp')); + }); + + test('action unescapedValue returns the raw value for shortcut actions', () { + // Input actions go through keytabUnescape; shortcut actions don't. + expect( + KeytabAction(KeytabActionType.shortcut, r'\Ehello').unescapedValue(), + r'\Ehello', + ); + }); + + test('record toString covers every supported mode flag when set', () { + // Sets every nullable flag so each `if (foo != null)` branch fires. + final r = KeytabRecord( + qtKeyName: 'Up', + key: TerminalKey.arrowUp, + action: KeytabAction(KeytabActionType.input, 'x'), + alt: true, + ctrl: true, + shift: false, + anyModifier: true, + ansi: true, + appScreen: false, + keyPad: true, + appCursorKeys: false, + appKeyPad: true, + newLine: false, + macos: true, + ); + final s = r.toString(); + expect(s, contains('+Alt')); + expect(s, contains('+Control')); + expect(s, contains('-Shift')); + expect(s, contains('+AnyMod')); + expect(s, contains('+Ansi')); + expect(s, contains('-AppScreen')); + expect(s, contains('+KeyPad')); + expect(s, contains('-AppCuKeys')); + expect(s, contains('+AppKeyPad')); + expect(s, contains('-NewLine')); + expect(s, contains('+Mac')); + expect(s, endsWith(': "x"')); + }); + }); + + group('KeytabParser — defensive error paths through hand-crafted tokens', () { + test('addTokens throws ParseError on a stray non-keyboard / non-keyDefine token', () { + final parser = KeytabParser(); + expect( + () => parser.addTokens([ + KeytabToken(KeytabTokenType.colon, ':'), + ]), + throwsA(isA()), + ); + }); + + test('_parseName throws when the second token is not an input token', () { + final parser = KeytabParser(); + expect( + () => parser.addTokens([ + KeytabToken(KeytabTokenType.keyboard, 'keyboard'), + KeytabToken(KeytabTokenType.keyName, 'Up'), + ]), + throwsA(isA()), + ); + }); + + test('_parseKeyDefine throws when the second token is not a keyName', () { + final parser = KeytabParser(); + expect( + () => parser.addTokens([ + KeytabToken(KeytabTokenType.keyDefine, 'key'), + KeytabToken(KeytabTokenType.colon, ':'), + ]), + throwsA(isA()), + ); + }); + + test('_parseKeyDefine throws on an unrecognised modeStatus value', () { + final parser = KeytabParser(); + expect( + () => parser.addTokens([ + KeytabToken(KeytabTokenType.keyDefine, 'key'), + KeytabToken(KeytabTokenType.keyName, 'Up'), + KeytabToken(KeytabTokenType.modeStatus, 'X'), // not '+' / '-' + ]), + throwsA(isA()), + ); + }); + + test('_parseKeyDefine throws when the token after modeStatus is not a mode', () { + final parser = KeytabParser(); + expect( + () => parser.addTokens([ + KeytabToken(KeytabTokenType.keyDefine, 'key'), + KeytabToken(KeytabTokenType.keyName, 'Up'), + KeytabToken(KeytabTokenType.modeStatus, '+'), + KeytabToken(KeytabTokenType.colon, ':'), // not a mode token + ]), + throwsA(isA()), + ); + }); + + test('_parseKeyDefine throws when the colon is missing', () { + final parser = KeytabParser(); + expect( + () => parser.addTokens([ + KeytabToken(KeytabTokenType.keyDefine, 'key'), + KeytabToken(KeytabTokenType.keyName, 'Up'), + KeytabToken(KeytabTokenType.input, 'x'), // should be colon here + ]), + throwsA(isA()), + ); + }); + + test('_parseKeyDefine throws when the action token is neither input nor shortcut', () { + final parser = KeytabParser(); + expect( + () => parser.addTokens([ + KeytabToken(KeytabTokenType.keyDefine, 'key'), + KeytabToken(KeytabTokenType.keyName, 'Up'), + KeytabToken(KeytabTokenType.colon, ':'), + KeytabToken(KeytabTokenType.mode, 'Alt'), // not a valid action + ]), + throwsA(isA()), + ); + }); + }); + + group('Keytab.find — modifier matching', () { + Keytab build(String src) => Keytab.parse('keyboard "X"\n$src\n'); + + test('exact match: -Shift on a record with shift=null and no anyModifier', () { + final t = build('key Up -Shift : "\\E[A"'); + expect( + t.find(TerminalKey.arrowUp, shift: false)?.action.value, + r'\E[A', + ); + expect(t.find(TerminalKey.arrowUp, shift: true), isNull); + }); + + test('+AnyMod requires at least one modifier; rejects no-modifier press', () { + final t = build('key Up +AnyMod : "\\E[A"'); + expect(t.find(TerminalKey.arrowUp), isNull); + expect(t.find(TerminalKey.arrowUp, ctrl: true), isNotNull); + }); + + test('-AnyMod requires zero modifiers; rejects any-modifier press', () { + final t = build('key Up -AnyMod : "\\E[A"'); + expect(t.find(TerminalKey.arrowUp), isNotNull); + expect(t.find(TerminalKey.arrowUp, alt: true), isNull); + }); + + test('mode flags filter records (newLine + appKeyPad + appScreen + macos)', () { + final t = build(''' +key Up +NewLine+AppKeyPad+AppScreen+Mac : "match" +key Up : "fallback" +'''); + // Wrong newLine mode falls through to fallback. + expect( + t.find(TerminalKey.arrowUp, newLineMode: false, appKeyPad: true, appScreen: true, macos: true)?.action.value, + 'fallback', + ); + // All matching → primary record. + expect( + t.find(TerminalKey.arrowUp, newLineMode: true, appKeyPad: true, appScreen: true, macos: true)?.action.value, + 'match', + ); + }); + + test('-Ansi records are skipped (VT52 not supported yet)', () { + final t = build('key Up -Ansi : "vt52"'); + expect(t.find(TerminalKey.arrowUp), isNull); + }); + + test('returns null when no record key matches', () { + final t = build('key Up : "\\EA"'); + expect(t.find(TerminalKey.arrowDown), isNull); + }); + + test('appCursorKeys + keyPad gates also filter', () { + final t = build('key Up +AppCuKeys+KeyPad : "\\EOA"\nkey Up : "fallback"'); + expect(t.find(TerminalKey.arrowUp)?.action.value, 'fallback'); + expect( + t.find(TerminalKey.arrowUp, appCursorKeys: true, keyPad: true)?.action.value, + r'\EOA', + ); + }); + }); + + group('Default keytab is parsable + nontrivial', () { + test('Keytab.defaultKeytab name + at least one record', () { + expect(Keytab.defaultKeytab.name, isNotEmpty); + expect(Keytab.defaultKeytab.records, isNotEmpty); + }); + }); + + group('TerminalKeyboardEvent', () { + test('copyWith overrides only specified fields', () { + final base = _evt(TerminalKey.arrowUp); + final shifted = base.copyWith(shift: true); + expect(shifted.shift, isTrue); + expect(shifted.alt, base.alt); + expect(shifted.ctrl, base.ctrl); + expect(shifted.key, base.key); + expect(shifted.platform, base.platform); + }); + + test('copyWith() with no args is equivalent to the original', () { + final base = _evt(TerminalKey.arrowDown, alt: true); + final clone = base.copyWith(); + expect(clone.key, base.key); + expect(clone.alt, base.alt); + }); + }); + + group('CascadeInputHandler', () { + test('returns the first non-null result; null otherwise', () { + const cascade = CascadeInputHandler([ + _NullHandler(), + _ConstHandler('first'), + _ConstHandler('second'), // should never be reached + ]); + expect(cascade(_evt(TerminalKey.arrowUp)), 'first'); + + const allNull = CascadeInputHandler([_NullHandler(), _NullHandler()]); + expect(allNull(_evt(TerminalKey.arrowUp)), isNull); + }); + }); + + group('KeytabInputHandler', () { + test('falls back to Keytab.defaultKeytab when none is supplied', () { + // Up arrow with no modifiers → default keytab match (\E[A or similar + // depending on mode flags). Just assert non-null. + const h = KeytabInputHandler(); + expect(h(_evt(TerminalKey.arrowUp)), isNotNull); + }); + + test('returns null when the keytab has no matching record', () { + final empty = Keytab(name: 'empty', records: const []); + expect(KeytabInputHandler(empty)(_evt(TerminalKey.arrowUp)), isNull); + }); + + test('inserts a modifier code into actions containing *', () { + // Build a keytab whose Up record uses the * placeholder; the handler + // replaces * with a code based on the active modifiers. + final t = Keytab.parse('keyboard "X"\nkey Up +AnyMod : "\\E[1;*A"\n'); + final h = KeytabInputHandler(t); + + final cases = <(TerminalKeyboardEvent, String)>[ + // Single-modifier codes. + (_evt(TerminalKey.arrowUp, shift: true), '\x1b[1;2A'), // shift → 2 + (_evt(TerminalKey.arrowUp, alt: true), '\x1b[1;3A'), // alt → 3 + (_evt(TerminalKey.arrowUp, ctrl: true), '\x1b[1;5A'), // ctrl → 5 + // Pair codes. + ( + _evt(TerminalKey.arrowUp, shift: true, alt: true), + '\x1b[1;4A', + ), + ( + _evt(TerminalKey.arrowUp, shift: true, ctrl: true), + '\x1b[1;6A', + ), + ( + _evt(TerminalKey.arrowUp, ctrl: true, alt: true), + '\x1b[1;7A', + ), + // Triple. + ( + _evt(TerminalKey.arrowUp, shift: true, alt: true, ctrl: true), + '\x1b[1;8A', + ), + ]; + for (final c in cases) { + expect(h(c.$1), c.$2, reason: '$c'); + } + }); + + test('leaves the action alone when no * placeholder is present', () { + final t = Keytab.parse('keyboard "X"\nkey Up : "\\E[A"\n'); + final h = KeytabInputHandler(t); + expect(h(_evt(TerminalKey.arrowUp)), '\x1b[A'); + }); + }); + + group('CtrlInputHandler', () { + const h = CtrlInputHandler(); + + test('Ctrl+A through Ctrl+Z map to control bytes 0x01..0x1A', () { + for (var i = 0; i < 26; i++) { + final key = TerminalKey.values[TerminalKey.keyA.index + i]; + final result = h(_evt(key, ctrl: true)); + expect(result, String.fromCharCode(i + 1), reason: 'TerminalKey.${key.name} → ${i + 1}'); + } + }); + + test('returns null without ctrl, or when shift / alt are also pressed', () { + expect(h(_evt(TerminalKey.keyA)), isNull); + expect(h(_evt(TerminalKey.keyA, ctrl: true, shift: true)), isNull); + expect(h(_evt(TerminalKey.keyA, ctrl: true, alt: true)), isNull); + }); + + test('returns null for non-letter keys', () { + expect(h(_evt(TerminalKey.arrowUp, ctrl: true)), isNull); + expect(h(_evt(TerminalKey.f1, ctrl: true)), isNull); + }); + }); + + group('AltInputHandler', () { + const h = AltInputHandler(); + + test('Alt+A through Alt+Z emit ESC + uppercase ASCII byte', () { + for (var i = 0; i < 26; i++) { + final key = TerminalKey.values[TerminalKey.keyA.index + i]; + final result = h(_evt(key, alt: true)); + expect(result, '\x1b${String.fromCharCode(0x41 + i)}'); + } + }); + + test('returns null without alt, or when shift / ctrl are also pressed', () { + expect(h(_evt(TerminalKey.keyA)), isNull); + expect(h(_evt(TerminalKey.keyA, alt: true, ctrl: true)), isNull); + expect(h(_evt(TerminalKey.keyA, alt: true, shift: true)), isNull); + }); + + test('returns null on macOS (Alt is reserved for char composition)', () { + expect( + h(_evt(TerminalKey.keyA, alt: true, platform: TerminalTargetPlatform.macos)), + isNull, + ); + }); + + test('returns null for non-letter keys', () { + expect(h(_evt(TerminalKey.arrowUp, alt: true)), isNull); + }); + }); + + group('defaultInputHandler', () { + test('routes a plain Up arrow through the keytab', () { + expect(defaultInputHandler(_evt(TerminalKey.arrowUp)), isNotNull); + }); + + test('routes Ctrl+C to 0x03 via CtrlInputHandler when keytab misses', () { + // Default keytab has no entry for Ctrl+keyC, so CtrlInputHandler runs. + expect( + defaultInputHandler(_evt(TerminalKey.keyC, ctrl: true)), + '\x03', + ); + }); + }); +} + +class _NullHandler implements TerminalInputHandler { + const _NullHandler(); + @override + String? call(TerminalKeyboardEvent event) => null; +} + +class _ConstHandler implements TerminalInputHandler { + const _ConstHandler(this._value); + final String _value; + @override + String? call(TerminalKeyboardEvent event) => _value; +}