test sweep: cover core/input/ keytab + handlers (T-91)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:33:57 +02:00
co-authored by Claude
parent 3b352fe654
commit f3e164b834
3 changed files with 810 additions and 108 deletions
+107 -107
View File
@@ -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": [
+1 -1
View File
@@ -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
+702
View File
@@ -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<TokenizeError>()),
);
});
test('tokenize throws TokenizeError on unterminated keyboard line', () {
expect(
() => tokenize('keyboard X').toList(),
throwsA(isA<TokenizeError>()),
);
});
test('KeytabToken toString reflects type + value', () {
final tok = KeytabToken(KeytabTokenType.input, 'hello');
expect(tok.toString(), 'KeytabTokenType.input<hello>');
});
});
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<ParseError>()));
});
test('parser throws ParseError on an unknown mode label', () {
const src = '''
keyboard "X"
key Up +Bogus : "x"
''';
expect(() => Keytab.parse(src), throwsA(isA<ParseError>()));
});
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<ParseError>()),
);
});
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<ParseError>()),
);
});
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<ParseError>()),
);
});
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<ParseError>()),
);
});
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<ParseError>()),
);
});
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<ParseError>()),
);
});
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<ParseError>()),
);
});
});
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;
}