test sweep: cover Terminal orchestrator (T-91)

Adds test/terminal/terminal_test.dart — 54 unit tests covering the
`Terminal` class as a pure-Dart orchestrator: construction +
TerminalState defaults, the Observable mixin, write/writeChar,
keyInput / charInput / textInput / paste (with bracketed-paste +
ctrl/alt encodings, including macOS reservation), mouseInput
gating, resize (clamping + onResize callback + alt-buffer
scrollback clear), buffer switching (use{Alt,Main}Buffer +
clearAltBuffer), every SBC handler (bell / backspace / lineFeed /
CR / SO / SI / unknown), tab-stop manipulation (tab jump +
saturation, clearTabStopUnderCursor, clearAllTabStops, setTapStop),
every ANSI escape handler (save/restore cursor, index, nextLine,
reverseIndex, designateCharset), CSI cursor + erase + line/char
insert/delete + scroll + repeatPreviousCharacter (incl. no-op when
no preceding char), device-attribute and status reports, every
mode setter mirroring into its getter, every SGR set/unset attr +
colour setter, OSC handlers (setTitle / setIconName / unknownOSC),
and all the documented no-op fallbacks (unknownSBC, unkownEscape,
unknownCSI, setUnknownMode, setUnknownDecMode, setColumnMode,
unsupportedStyle).

Also fixes a real production bug surfaced while writing tests:
`BufferLine.eraseRange(0, 0, ...)` panicked with a `RangeError`
because the right-side wide-char guard read `_data[-1]` via
`getWidth(end - 1)` when `end == 0`. The left guard already had a
`start > 0` check; the right guard was missing the symmetric
`end > 0`. Real trigger path: `Terminal.eraseDisplayAbove`
(`ESC[1J`) with the cursor at column 0 — common after `ESC[H\x1b[1J`
home-then-erase-above sequences that many TUIs emit on redraw.
Regression test added in line_test.dart.

Coverage delta:
- terminal.dart: 0/283 → 291/291 (file grew by 8 LF for the
  fix's comment lines).
- base/observable.dart: 0/7 → 7/7 (covered transitively via
  Terminal's listener tests).
- Total project: 56.40% → 59.82%; coverage_floor bumped 56 → 59.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-07 08:21:18 +02:00
co-authored by Claude
parent 6008b4914c
commit 4642a2f25b
6 changed files with 814 additions and 110 deletions
+107 -107
View File
@@ -1,5 +1,5 @@
{
"exported_at": "2026-05-07T06:13:07Z",
"exported_at": "2026-05-07T06:21:18Z",
"decisions": [
{
"id": "D-1",
@@ -9,7 +9,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-3",
@@ -19,7 +19,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-4",
@@ -29,7 +29,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-5",
@@ -39,7 +39,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-6",
@@ -49,7 +49,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-7",
@@ -59,7 +59,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-8",
@@ -69,7 +69,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-9",
@@ -79,7 +79,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-10",
@@ -89,7 +89,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-11",
@@ -99,7 +99,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-12",
@@ -109,7 +109,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-13",
@@ -119,7 +119,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-14",
@@ -129,7 +129,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-15",
@@ -139,7 +139,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-16",
@@ -149,7 +149,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-17",
@@ -159,7 +159,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-18",
@@ -169,7 +169,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-19",
@@ -179,7 +179,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-20",
@@ -189,7 +189,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-21",
@@ -199,7 +199,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-22",
@@ -209,7 +209,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-23",
@@ -219,7 +219,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-24",
@@ -229,7 +229,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-25",
@@ -239,7 +239,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-26",
@@ -249,7 +249,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-27",
@@ -259,7 +259,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-28",
@@ -269,7 +269,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-29",
@@ -279,7 +279,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-30",
@@ -289,7 +289,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-31",
@@ -299,7 +299,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-32",
@@ -309,7 +309,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-33",
@@ -319,7 +319,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-34",
@@ -329,7 +329,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-35",
@@ -339,7 +339,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-36",
@@ -349,7 +349,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-37",
@@ -359,7 +359,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-38",
@@ -369,7 +369,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-39",
@@ -379,7 +379,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-40",
@@ -389,7 +389,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-41",
@@ -399,7 +399,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-42",
@@ -409,7 +409,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-43",
@@ -419,7 +419,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-44",
@@ -429,7 +429,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-45",
@@ -439,7 +439,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-46",
@@ -449,7 +449,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-47",
@@ -459,7 +459,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-48",
@@ -469,7 +469,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-49",
@@ -479,7 +479,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-50",
@@ -489,7 +489,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-51",
@@ -499,7 +499,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-52",
@@ -509,7 +509,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-53",
@@ -519,7 +519,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-54",
@@ -529,7 +529,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-55",
@@ -539,7 +539,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-56",
@@ -549,7 +549,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-57",
@@ -559,7 +559,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-58",
@@ -569,7 +569,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-59",
@@ -579,7 +579,7 @@
"status": "active",
"date": "2026-04-25",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-60",
@@ -589,7 +589,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-61",
@@ -599,7 +599,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-62",
@@ -609,7 +609,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-63",
@@ -619,7 +619,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-64",
@@ -629,7 +629,7 @@
"status": "active",
"date": "2026-05-03",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-65",
@@ -639,7 +639,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "D-66",
@@ -649,7 +649,7 @@
"status": "active",
"date": "2026-05-06",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-3",
@@ -676,7 +676,7 @@
"title": "Event persistence + audit/undo",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-9",
@@ -730,7 +730,7 @@
"title": "Lua runtime vendoring",
"status": "open",
"file_path": "decisions/questions-extensions.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-10",
@@ -739,7 +739,7 @@
"title": "Extension manifest `schema_version:`",
"status": "open",
"file_path": "decisions/questions-extensions.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-13",
@@ -766,7 +766,7 @@
"title": "Web production-mode a11y",
"status": "open",
"file_path": "decisions/questions-accessibility.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-17",
@@ -802,7 +802,7 @@
"title": "Icon set growth",
"status": "open",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-19",
@@ -820,7 +820,7 @@
"title": "(withdrawn)",
"status": "resolved",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-22",
@@ -847,7 +847,7 @@
"title": "Ticket persistence strategy",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-27",
@@ -883,7 +883,7 @@
"title": "Two-editor split",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "Q-29",
@@ -901,7 +901,7 @@
"title": "Branch picker location",
"status": "open",
"file_path": "decisions/questions-architecture.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"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-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-2",
@@ -929,7 +929,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-3",
@@ -939,7 +939,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-4",
@@ -949,7 +949,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-5",
@@ -959,7 +959,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-6",
@@ -969,7 +969,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-7",
@@ -979,7 +979,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-8",
@@ -989,7 +989,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-9",
@@ -999,7 +999,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-10",
@@ -1009,7 +1009,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-11",
@@ -1019,7 +1019,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
},
{
"id": "R-12",
@@ -1029,7 +1029,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-07 06:10:54"
"synced_at": "2026-05-07 06:20:17"
}
],
"decision_refs": [
+5
View File
@@ -47,6 +47,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Fixed
- `BufferLine.eraseRange` no longer panics when called with `end == 0`.
The right-side wide-char guard read `_data[-1]` via `getWidth(-1)`,
which threw a `RangeError`. Real trigger path: `Terminal.eraseDisplayAbove`
with the cursor at column 0 — common after `ESC[H\x1b[1J`
(home + erase-above) sequences that many TUIs emit on redraw.
- Terminal selections no longer silently disappear when the terminal is
resized narrower. Reflow's tail-anchor handler used to reparent
anchors past the source's trimmed-content range onto a builder line
+5 -2
View File
@@ -142,8 +142,11 @@ class BufferLine with IndexedItem {
eraseCell(start - 1, style);
}
// reset cell one to the right if end is second cell of a wide char
if (end < _length && getWidth(end - 1) == 2) {
// reset cell one to the right if end is second cell of a wide char.
// Mirror the `start > 0` guard above so eraseRange(0, 0, ...) (used
// by eraseLineToCursor when the cursor sits at column 0) doesn't read
// _data[-1] via getWidth(-1).
if (end > 0 && end < _length && getWidth(end - 1) == 2) {
eraseCell(end - 1, style);
}
+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: 56
coverage_floor: 59
# Project metadata (was project.yaml, folded in per D-056).
# version: above is the single source of truth. The Makefile reads
+9
View File
@@ -177,6 +177,15 @@ void main() {
expect(l.getContent(0), 0);
});
test('eraseRange(0, 0, ...) does not panic when called at column 0', () {
// Surfaced by Terminal.eraseDisplayAbove when the cursor sits at
// column 0: eraseLineToCursor → eraseRange(0, _cursorX, ...) with
// _cursorX == 0. Without the `end > 0` guard, the right-side
// wide-char check reads _data[-1] via getWidth(-1).
final l = BufferLine(4);
l.eraseRange(0, 0, _styleEmpty);
});
test('extends one cell right when end-1 is the second cell of a wide char', () {
final l = BufferLine(4);
l.setCell(0, _aChar, 1, _styleEmpty);
+687
View File
@@ -0,0 +1,687 @@
/// Pure-Dart tests for the `Terminal` orchestrator
/// (`lib/src/terminal/src/terminal.dart`). The `Terminal` is the
/// EscapeHandler implementation that wires the parser into a
/// Buffer-backed scrollback, exposes mode flags via TerminalState,
/// and emits user output through the `onOutput` callback. No Flutter
/// dependency.
library;
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/input/keys.dart';
import 'package:clide/src/terminal/src/core/mouse/button.dart';
import 'package:clide/src/terminal/src/core/mouse/button_state.dart';
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
import 'package:clide/src/terminal/src/core/platform.dart';
import 'package:clide/src/terminal/src/terminal.dart';
import 'package:test/test.dart';
class _Recorder {
final outputs = <String>[];
int bellCount = 0;
String? lastTitle;
String? lastIcon;
int? resizeWidth, resizeHeight, resizePixelW, resizePixelH;
String? lastOscPs;
List<String>? lastOscArgs;
Terminal build({
int maxLines = 100,
TerminalTargetPlatform platform = TerminalTargetPlatform.linux,
bool reflow = true,
}) {
return Terminal(
maxLines: maxLines,
platform: platform,
reflowEnabled: reflow,
onOutput: outputs.add,
onBell: () => bellCount++,
onTitleChange: (t) => lastTitle = t,
onIconChange: (i) => lastIcon = i,
onResize: (w, h, pw, ph) {
resizeWidth = w;
resizeHeight = h;
resizePixelW = pw;
resizePixelH = ph;
},
onPrivateOSC: (ps, args) {
lastOscPs = ps;
lastOscArgs = args;
},
);
}
}
void main() {
group('Terminal — construction + state defaults', () {
test('default view is 80x24, mainBuffer is active, mode flags zeroed', () {
final t = _Recorder().build();
expect(t.viewWidth, 80);
expect(t.viewHeight, 24);
expect(t.isUsingAltBuffer, isFalse);
expect(identical(t.buffer, t.mainBuffer), isTrue);
expect(t.insertMode, isFalse);
expect(t.lineFeedMode, isFalse);
expect(t.cursorKeysMode, isFalse);
expect(t.reverseDisplayMode, isFalse);
expect(t.originMode, isFalse);
expect(t.autoWrapMode, isTrue);
expect(t.cursorBlinkMode, isFalse);
expect(t.cursorVisibleMode, isTrue);
expect(t.appKeypadMode, isFalse);
expect(t.reportFocusMode, isFalse);
expect(t.altBufferMouseScrollMode, isFalse);
expect(t.bracketedPasteMode, isFalse);
expect(t.mouseMode, MouseMode.none);
expect(t.mouseReportMode, MouseReportMode.normal);
});
test('lines getter returns the active buffer lines', () {
final t = _Recorder().build();
expect(t.lines, t.mainBuffer.lines);
});
test('toString shape includes hashCode + dimensions + height', () {
final t = _Recorder().build();
expect(t.toString(), contains('${t.viewWidth} x ${t.viewHeight}'));
expect(t.toString(), contains('${t.buffer.height} lines'));
});
});
group('Terminal — Observable mixin', () {
test('listeners fire on write() and stop firing after removeListener', () {
final t = _Recorder().build();
var fired = 0;
void cb() => fired++;
t.addListener(cb);
t.write('hi');
expect(fired, 1);
t.removeListener(cb);
t.write('more');
expect(fired, 1);
});
test('multiple listeners all fire', () {
final t = _Recorder().build();
var a = 0;
var b = 0;
t.addListener(() => a++);
t.addListener(() => b++);
t.write('x');
expect(a, 1);
expect(b, 1);
});
});
group('Terminal — write + writeChar', () {
test('writeChar advances the buffer cursor', () {
final t = _Recorder().build();
t.writeChar('a'.codeUnitAt(0));
expect(t.buffer.cursorX, 1);
expect(t.buffer.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
});
test('write feeds the escape parser end-to-end', () {
final t = _Recorder().build();
t.write('\x1b[31mred'); // SGR red foreground
expect(t.cursor.foreground, isNot(0)); // foreground was set
});
});
group('Terminal — keyInput', () {
test('returns true and emits when the input handler produces output', () {
final r = _Recorder();
final t = r.build();
// Up arrow on the default keytab produces a non-empty escape.
expect(t.keyInput(TerminalKey.arrowUp), isTrue);
expect(r.outputs, isNotEmpty);
});
test('returns false when no handler matches', () {
final r = _Recorder();
final t = r.build()..inputHandler = null;
expect(t.keyInput(TerminalKey.arrowUp), isFalse);
expect(r.outputs, isEmpty);
});
});
group('Terminal — charInput', () {
test('Ctrl + a..z → 0x01..0x1A', () {
final r = _Recorder();
final t = r.build();
for (var c = 'a'.codeUnitAt(0); c <= 'z'.codeUnitAt(0); c++) {
r.outputs.clear();
expect(t.charInput(c, ctrl: true), isTrue);
expect(r.outputs.single, String.fromCharCode(c - 'a'.codeUnitAt(0) + 1));
}
});
test('Ctrl + [ ..._ → 0x1B..0x1F', () {
final r = _Recorder();
final t = r.build();
for (var c = '['.codeUnitAt(0); c <= '_'.codeUnitAt(0); c++) {
r.outputs.clear();
expect(t.charInput(c, ctrl: true), isTrue);
expect(r.outputs.single, String.fromCharCode(c - '['.codeUnitAt(0) + 27));
}
});
test('Alt + a..z (linux) → ESC + uppercase', () {
final r = _Recorder();
final t = r.build();
r.outputs.clear();
expect(t.charInput('a'.codeUnitAt(0), alt: true), isTrue);
expect(r.outputs.single, '\x1bA');
});
test('Alt on macOS is reserved → returns false (no output)', () {
final r = _Recorder();
final t = r.build(platform: TerminalTargetPlatform.macos);
expect(t.charInput('a'.codeUnitAt(0), alt: true), isFalse);
expect(r.outputs, isEmpty);
});
test('non-letter char with no modifier returns false', () {
final r = _Recorder();
final t = r.build();
expect(t.charInput('1'.codeUnitAt(0)), isFalse);
expect(r.outputs, isEmpty);
});
});
group('Terminal — textInput / paste', () {
test('textInput emits the literal string', () {
final r = _Recorder();
final t = r.build();
t.textInput('hello');
expect(r.outputs.single, 'hello');
});
test('paste without bracketed mode behaves like textInput', () {
final r = _Recorder();
final t = r.build();
t.paste('plain');
expect(r.outputs.single, 'plain');
});
test('paste with bracketed mode wraps the text in markers', () {
final r = _Recorder();
final t = r.build();
t.setBracketedPasteMode(true);
t.paste('bracketed');
expect(r.outputs.single, '\x1b[200~bracketed\x1b[201~');
});
});
group('Terminal — mouseInput', () {
test('returns true and emits when the mouse handler produces output', () {
final r = _Recorder();
final t = r.build();
t.setMouseMode(MouseMode.upDownScroll);
final handled = t.mouseInput(TerminalMouseButton.left, TerminalMouseButtonState.down, const CellOffset(1, 1));
expect(handled, isTrue);
expect(r.outputs, isNotEmpty);
});
test('returns false when mouse is in none mode', () {
final r = _Recorder();
final t = r.build();
// mouseMode is none by default → ClickMouseHandler + UpDownMouseHandler
// both decline.
final handled = t.mouseInput(TerminalMouseButton.left, TerminalMouseButtonState.down, const CellOffset(0, 0));
expect(handled, isFalse);
});
test('returns false when mouseHandler is null', () {
final r = _Recorder();
final t = r.build()..mouseHandler = null;
final handled = t.mouseInput(TerminalMouseButton.left, TerminalMouseButtonState.down, const CellOffset(0, 0));
expect(handled, isFalse);
});
});
group('Terminal — resize', () {
test('updates viewWidth/Height, fires onResize, resets margins', () {
final r = _Recorder();
final t = r.build();
t.resize(40, 12, 800, 600);
expect(t.viewWidth, 40);
expect(t.viewHeight, 12);
expect(r.resizeWidth, 40);
expect(r.resizeHeight, 12);
expect(r.resizePixelW, 800);
expect(r.resizePixelH, 600);
expect(t.mainBuffer.marginTop, 0);
expect(t.mainBuffer.marginBottom, 11);
});
test('clamps newWidth/newHeight to >= 1', () {
final r = _Recorder();
final t = r.build();
t.resize(0, -5);
expect(t.viewWidth, 1);
expect(t.viewHeight, 1);
});
test('clears scrollback when active buffer is the alt buffer', () {
final r = _Recorder();
final t = r.build();
t.useAltBuffer();
t.write('a\nb\nc'); // not strictly building scrollback but exercises path
t.resize(40, 10);
// alt buffer has at most viewHeight lines after clearScrollback.
expect(t.altBuffer.height, lessThanOrEqualTo(10));
});
});
group('Terminal — buffer switching', () {
test('useAltBuffer / useMainBuffer / clearAltBuffer', () {
final t = _Recorder().build();
expect(t.isUsingAltBuffer, isFalse);
t.useAltBuffer();
expect(t.isUsingAltBuffer, isTrue);
expect(identical(t.buffer, t.altBuffer), isTrue);
// Write something to alt buffer, then clear it.
t.writeChar('x'.codeUnitAt(0));
t.useMainBuffer();
expect(t.isUsingAltBuffer, isFalse);
// Switch back and verify clear works.
t.useAltBuffer();
t.clearAltBuffer();
// Cleared buffer has all-empty cells.
expect(t.altBuffer.lines[0].getCodePoint(0), 0);
});
});
group('Terminal — SBC handlers', () {
test('bell fires onBell', () {
final r = _Recorder();
final t = r.build();
t.bell();
expect(r.bellCount, 1);
});
test('backspaceReturn moves cursor back by 1', () {
final t = _Recorder().build();
t.buffer.setCursorX(3);
t.backspaceReturn();
expect(t.buffer.cursorX, 2);
});
test('lineFeed delegates to buffer.lineFeed', () {
final t = _Recorder().build();
final yBefore = t.buffer.cursorY;
t.lineFeed();
expect(t.buffer.cursorY, yBefore + 1);
});
test('carriageReturn resets cursorX to 0', () {
final t = _Recorder().build();
t.buffer.setCursorX(5);
t.carriageReturn();
expect(t.buffer.cursorX, 0);
});
test('shiftOut / shiftIn switch the active charset slot', () {
final t = _Recorder().build();
t.shiftOut();
// No exception, no observable side-effect from outside.
t.shiftIn();
});
test('unknownSBC and unkownEscape are no-ops', () {
final t = _Recorder().build();
// Just ensure they don't throw.
t.unknownSBC(0xFF);
t.unkownEscape(0xFF);
});
});
group('Terminal — tab stops', () {
test('tab jumps to the next 8-column stop', () {
final t = _Recorder().build();
t.buffer.setCursorX(0);
t.tab();
expect(t.buffer.cursorX, 8);
});
test('tab past the last stop saturates the cursor at viewWidth', () {
final t = _Recorder().build();
t.clearAllTabStops(); // remove every stop
t.buffer.setCursorX(70);
t.tab();
// cursor saturated at viewWidth (80); the public getter clamps to 79.
expect(t.buffer.cursorX, 79);
});
test('clearTabStopUnderCursor / clearAllTabStops mutate the table', () {
final t = _Recorder().build();
t.buffer.setCursorX(8);
t.clearTabStopUnderCursor();
// Subsequent tab from 0 must skip the cleared 8 stop.
t.buffer.setCursorX(0);
t.tab();
expect(t.buffer.cursorX, 16);
t.clearAllTabStops();
t.buffer.setCursorX(0);
t.tab();
expect(t.buffer.cursorX, 79); // saturated
});
test('setTapStop is a query, not a write — does not throw', () {
final t = _Recorder().build();
t.buffer.setCursorX(5);
t.setTapStop();
});
});
group('Terminal — ANSI escape handlers', () {
test('saveCursor / restoreCursor round-trip through buffer', () {
final t = _Recorder().build();
t.buffer.setCursor(3, 2);
t.saveCursor();
t.buffer.setCursor(0, 0);
t.restoreCursor();
expect(t.buffer.cursorX, 3);
expect(t.buffer.cursorY, 2);
});
test('index moves the cursor down one line', () {
final t = _Recorder().build();
t.buffer.setCursorY(1);
t.index();
expect(t.buffer.cursorY, 2);
});
test('nextLine moves down + resets x to 0', () {
final t = _Recorder().build();
t.buffer.setCursor(5, 1);
t.nextLine();
expect(t.buffer.cursorX, 0);
});
test('reverseIndex moves the cursor up one line (inside margins)', () {
final t = _Recorder().build();
t.buffer.setCursorY(3);
t.reverseIndex();
expect(t.buffer.cursorY, 2);
});
test('designateCharset routes to buffer.charset.designate', () {
final t = _Recorder().build();
t.designateCharset(0, '0'.codeUnitAt(0)); // DEC special graphics
// After use(0), the next char goes through the DEC graphics translator.
// Verify by checking the translator picks up the mapping (test-side).
t.buffer.charset.use(0);
expect(t.buffer.charset.translate(0x6a), 0x2518);
});
});
group('Terminal — CSI handlers', () {
test('cursor motion delegates to buffer (setCursor / setCursorX/Y, moveCursorX/Y)', () {
final t = _Recorder().build();
t.setCursor(3, 5);
expect(t.buffer.cursorX, 3);
expect(t.buffer.cursorY, 5);
t.setCursorX(7);
expect(t.buffer.cursorX, 7);
t.setCursorY(2);
expect(t.buffer.cursorY, 2);
t.moveCursorX(2);
expect(t.buffer.cursorX, 9);
t.moveCursorY(1);
expect(t.buffer.cursorY, 3);
});
test('cursorNextLine / cursorPrecedingLine move y + reset x', () {
final t = _Recorder().build();
t.buffer.setCursor(5, 2);
t.cursorNextLine(2);
expect(t.buffer.cursorY, 4);
expect(t.buffer.cursorX, 0);
t.buffer.setCursor(5, 5);
t.cursorPrecedingLine(2);
expect(t.buffer.cursorY, 3);
expect(t.buffer.cursorX, 0);
});
test('setMargins forwards to buffer', () {
final t = _Recorder().build();
t.setMargins(2, 5);
expect(t.buffer.marginTop, 2);
expect(t.buffer.marginBottom, 5);
// null bottom defaults to viewHeight - 1.
t.setMargins(0);
expect(t.buffer.marginBottom, t.viewHeight - 1);
});
test('erase commands forward to buffer.erase*', () {
// Helper that fills line 0 with 'abcde' from column 0 deterministically.
Terminal fresh() {
final t = _Recorder().build();
t.buffer.setCursor(0, 0);
t.write('abcde');
t.buffer.setCursor(0, 0);
return t;
}
// eraseLine wipes the whole line.
var t = fresh();
t.eraseLine();
expect(t.buffer.lines[0].getCodePoint(0), 0);
expect(t.buffer.lines[0].getCodePoint(4), 0);
// eraseLineRight from column 2 keeps 0..1, wipes 2..end.
t = fresh();
t.buffer.setCursorX(2);
t.eraseLineRight();
expect(t.buffer.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
expect(t.buffer.lines[0].getCodePoint(2), 0);
// eraseLineLeft from column 2 wipes 0..1, keeps 2..end.
t = fresh();
t.buffer.setCursorX(2);
t.eraseLineLeft();
expect(t.buffer.lines[0].getCodePoint(0), 0);
expect(t.buffer.lines[0].getCodePoint(2), 'c'.codeUnitAt(0));
// eraseDisplay wipes everything.
t = fresh();
t.eraseDisplay();
expect(t.buffer.lines[0].getCodePoint(0), 0);
// eraseDisplayBelow from column 2 keeps 0..1, wipes the rest.
t = fresh();
t.buffer.setCursor(2, 0);
t.eraseDisplayBelow();
expect(t.buffer.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
expect(t.buffer.lines[0].getCodePoint(2), 0);
// eraseDisplayAbove + eraseScrollbackOnly — exercise without throw.
t = fresh();
t.buffer.setCursor(0, 0);
t.eraseDisplayAbove();
t.eraseScrollbackOnly();
});
test('insertLines / deleteLines / deleteChars / scrollUp / scrollDown / eraseChars / insertBlankChars — delegates', () {
final t = _Recorder().build();
// Just ensure none throw and each side-effect is observable somewhere.
t.setMargins(0, 5);
t.buffer.setCursorY(2);
t.insertLines(1);
t.deleteLines(1);
t.write('abc');
t.buffer.setCursorX(1);
t.deleteChars(1);
t.scrollUp(1);
t.scrollDown(1);
t.eraseChars(1);
t.insertBlankChars(1);
});
test('repeatPreviousCharacter writes the last codepoint N times', () {
final t = _Recorder().build();
t.writeChar('z'.codeUnitAt(0));
final xBefore = t.buffer.cursorX;
t.repeatPreviousCharacter(3);
expect(t.buffer.cursorX, xBefore + 3);
});
test('repeatPreviousCharacter is a no-op with no preceding codepoint', () {
final t = _Recorder().build();
final xBefore = t.buffer.cursorX;
t.repeatPreviousCharacter(3);
expect(t.buffer.cursorX, xBefore);
});
test('unknownCSI / setUnknownMode / setUnknownDecMode / setColumnMode / unsupportedStyle — no-ops, no throw', () {
final t = _Recorder().build();
t.unknownCSI('Z'.codeUnitAt(0));
t.setUnknownMode(99, true);
t.setUnknownDecMode(9999, true);
t.setColumnMode(true);
t.unsupportedStyle(123);
});
});
group('Terminal — device-attribute reports', () {
test('every device-attribute / status report hits onOutput', () {
final r = _Recorder();
final t = r.build();
t.sendPrimaryDeviceAttributes();
t.sendSecondaryDeviceAttributes();
t.sendTertiaryDeviceAttributes();
t.sendOperatingStatus();
t.sendCursorPosition();
t.sendSize();
expect(r.outputs.length, 6);
expect(r.outputs[0], '\x1b[?1;2c');
expect(r.outputs[3], '\x1b[0n');
});
});
group('Terminal — mode setters', () {
test('every mode setter mirrors into its getter', () {
final t = _Recorder().build();
t.setInsertMode(true);
expect(t.insertMode, isTrue);
t.setLineFeedMode(true);
expect(t.lineFeedMode, isTrue);
t.setCursorKeysMode(true);
expect(t.cursorKeysMode, isTrue);
t.setReverseDisplayMode(true);
expect(t.reverseDisplayMode, isTrue);
t.setOriginMode(true);
expect(t.originMode, isTrue);
t.setAutoWrapMode(false);
expect(t.autoWrapMode, isFalse);
t.setMouseMode(MouseMode.upDownScroll);
expect(t.mouseMode, MouseMode.upDownScroll);
t.setCursorBlinkMode(true);
expect(t.cursorBlinkMode, isTrue);
t.setCursorVisibleMode(false);
expect(t.cursorVisibleMode, isFalse);
t.setAppKeypadMode(true);
expect(t.appKeypadMode, isTrue);
t.setReportFocusMode(true);
expect(t.reportFocusMode, isTrue);
t.setMouseReportMode(MouseReportMode.sgr);
expect(t.mouseReportMode, MouseReportMode.sgr);
t.setAltBufferMouseScrollMode(true);
expect(t.altBufferMouseScrollMode, isTrue);
t.setBracketedPasteMode(true);
expect(t.bracketedPasteMode, isTrue);
});
});
group('Terminal — SGR forwarding', () {
test('every set/unset attr toggles the cursor flag', () {
final t = _Recorder().build();
t.setCursorBold();
expect(t.cursor.isBold, isTrue);
t.setCursorFaint();
expect(t.cursor.isFaint, isTrue);
t.setCursorItalic();
expect(t.cursor.isItalic, isTrue);
t.setCursorUnderline();
expect(t.cursor.isUnderline, isTrue);
t.setCursorBlink();
expect(t.cursor.isBlink, isTrue);
t.setCursorInverse();
expect(t.cursor.isInverse, isTrue);
t.setCursorInvisible();
expect(t.cursor.isInvisible, isTrue);
t.setCursorStrikethrough();
// Unset each in turn and check the matching getter goes back to false
// (where one is exposed).
t.unsetCursorBold();
expect(t.cursor.isBold, isFalse);
t.unsetCursorFaint();
expect(t.cursor.isFaint, isFalse);
t.unsetCursorItalic();
expect(t.cursor.isItalic, isFalse);
t.unsetCursorUnderline();
expect(t.cursor.isUnderline, isFalse);
t.unsetCursorBlink();
expect(t.cursor.isBlink, isFalse);
t.unsetCursorInverse();
expect(t.cursor.isInverse, isFalse);
t.unsetCursorInvisible();
expect(t.cursor.isInvisible, isFalse);
t.unsetCursorStrikethrough(); // no getter, just exercise
});
test('foreground / background colour setters + reset', () {
final t = _Recorder().build();
t.setForegroundColor16(2);
t.setForegroundColor256(123);
t.setForegroundColorRgb(10, 20, 30);
t.resetForeground();
expect(t.cursor.foreground, 0);
t.setBackgroundColor16(4);
t.setBackgroundColor256(99);
t.setBackgroundColorRgb(1, 2, 3);
t.resetBackground();
expect(t.cursor.background, 0);
});
test('resetCursorStyle wipes attrs + foreground + background', () {
final t = _Recorder().build();
t.setCursorBold();
t.setForegroundColor16(2);
t.setBackgroundColor16(4);
t.resetCursorStyle();
expect(t.cursor.attrs, 0);
expect(t.cursor.foreground, 0);
expect(t.cursor.background, 0);
});
});
group('Terminal — OSC handlers', () {
test('setTitle fires onTitleChange with the supplied name', () {
final r = _Recorder();
final t = r.build();
t.setTitle('hello world');
expect(r.lastTitle, 'hello world');
});
test('setIconName fires onIconChange', () {
final r = _Recorder();
final t = r.build();
t.setIconName('icon-id');
expect(r.lastIcon, 'icon-id');
});
test('unknownOSC routes to onPrivateOSC', () {
final r = _Recorder();
final t = r.build();
t.unknownOSC('99', ['arg1', 'arg2']);
expect(r.lastOscPs, '99');
expect(r.lastOscArgs, ['arg1', 'arg2']);
});
});
}