test sweep: cover Buffer (T-91)

Adds test/terminal/buffer/buffer_test.dart — 67 unit tests against
the Buffer orchestrator on top of BufferLine. Drives a fake
TerminalState through writes, cursor moves, scroll regions,
erase commands, line insert/delete, resize (with and without
reflow), word-boundary lookup, getText, and the toString debug
dump.

Coverage delta:
- buffer.dart: 0 / 260 → 260 / 261 (one while-loop-body line
  Dart coverage doesn't instrument distinctly; the loop's effect
  is exercised end-to-end by the reflow-pad test).
- Total project: 39.12% → 43.20%.
- pubspec.yaml `coverage_floor:` bumped 39 → 43.

Notes:
- The fake TerminalState (`_State`) is a per-file impl rather than
  a shared fixture; it stays close to the test that exercises it
  and avoids forcing other terminal tests to depend on a one-shape-
  fits-all stub.
- Tests that walk through `lineFeed` use `lineFeedMode: true` so
  the column resets between newlines — otherwise the saturated
  cursor X from a previous full-width write spills the next write
  onto an extra line via `writeChar`'s autoWrap branch.

This closes the `core/buffer/` sub-area for T-91 — every leaf file
in `lib/src/terminal/src/core/buffer/` is now at >= 96% line
coverage; the only outliers are Dart-coverage-instrumentation
quirks, not real gaps.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-06 23:13:42 +02:00
co-authored by Claude
parent c744c8ce4b
commit c233b7607d
3 changed files with 910 additions and 108 deletions
+107 -107
View File
@@ -1,5 +1,5 @@
{
"exported_at": "2026-05-06T21:03:21Z",
"exported_at": "2026-05-06T21:13:42Z",
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-3",
@@ -19,7 +19,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-4",
@@ -29,7 +29,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-5",
@@ -39,7 +39,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-6",
@@ -49,7 +49,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-7",
@@ -59,7 +59,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-8",
@@ -69,7 +69,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-9",
@@ -79,7 +79,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-10",
@@ -89,7 +89,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-11",
@@ -99,7 +99,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-12",
@@ -109,7 +109,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-13",
@@ -119,7 +119,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-14",
@@ -129,7 +129,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-15",
@@ -139,7 +139,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-16",
@@ -149,7 +149,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-17",
@@ -159,7 +159,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-18",
@@ -169,7 +169,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-19",
@@ -179,7 +179,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-20",
@@ -189,7 +189,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-21",
@@ -199,7 +199,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-22",
@@ -209,7 +209,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/accessibility.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-23",
@@ -219,7 +219,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-24",
@@ -229,7 +229,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-25",
@@ -239,7 +239,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-26",
@@ -249,7 +249,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-27",
@@ -259,7 +259,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-28",
@@ -269,7 +269,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-29",
@@ -279,7 +279,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-30",
@@ -289,7 +289,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-31",
@@ -299,7 +299,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-32",
@@ -309,7 +309,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-33",
@@ -319,7 +319,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-34",
@@ -329,7 +329,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-35",
@@ -339,7 +339,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-36",
@@ -349,7 +349,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-37",
@@ -359,7 +359,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-38",
@@ -369,7 +369,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-39",
@@ -379,7 +379,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-40",
@@ -389,7 +389,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/process.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-41",
@@ -399,7 +399,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-42",
@@ -409,7 +409,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-43",
@@ -419,7 +419,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-44",
@@ -429,7 +429,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-45",
@@ -439,7 +439,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-46",
@@ -449,7 +449,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/extensions.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-47",
@@ -459,7 +459,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-48",
@@ -469,7 +469,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-49",
@@ -479,7 +479,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-50",
@@ -489,7 +489,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-51",
@@ -499,7 +499,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-52",
@@ -509,7 +509,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-53",
@@ -519,7 +519,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-54",
@@ -529,7 +529,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-55",
@@ -539,7 +539,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-56",
@@ -549,7 +549,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-57",
@@ -559,7 +559,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-58",
@@ -569,7 +569,7 @@
"status": "active",
"date": "2026-04-23",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-59",
@@ -579,7 +579,7 @@
"status": "active",
"date": "2026-04-25",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-60",
@@ -589,7 +589,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-61",
@@ -599,7 +599,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-62",
@@ -609,7 +609,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-63",
@@ -619,7 +619,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-64",
@@ -629,7 +629,7 @@
"status": "active",
"date": "2026-05-03",
"file_path": "decisions/architecture.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-65",
@@ -639,7 +639,7 @@
"status": "active",
"date": "2026-04-26",
"file_path": "decisions/tooling.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "D-66",
@@ -649,7 +649,7 @@
"status": "active",
"date": "2026-05-06",
"file_path": "decisions/testing.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "Q-19",
@@ -820,7 +820,7 @@
"title": "(withdrawn)",
"status": "resolved",
"file_path": "decisions/questions-process.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"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:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-2",
@@ -929,7 +929,7 @@
"status": "active",
"date": "2026-04-20",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-3",
@@ -939,7 +939,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-4",
@@ -949,7 +949,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-5",
@@ -959,7 +959,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-6",
@@ -969,7 +969,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-7",
@@ -979,7 +979,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-8",
@@ -989,7 +989,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-9",
@@ -999,7 +999,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-10",
@@ -1009,7 +1009,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-11",
@@ -1019,7 +1019,7 @@
"status": "active",
"date": "2026-04-21",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
},
{
"id": "R-12",
@@ -1029,7 +1029,7 @@
"status": "active",
"date": "2026-04-22",
"file_path": "decisions/rejected.md",
"synced_at": "2026-05-06 21:00:50"
"synced_at": "2026-05-06 21:12:28"
}
],
"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: 39
coverage_floor: 43
# Project metadata (was project.yaml, folded in per D-056).
# version: above is the single source of truth. The Makefile reads
+802
View File
@@ -0,0 +1,802 @@
/// Pure-Dart tests for Buffer (lib/src/terminal/src/core/buffer/buffer.dart).
///
/// Buffer is the orchestrator on top of BufferLine — viewport sizing,
/// scroll regions, cursor movement, erase commands, scroll/index/reverse-
/// index, line insert/delete, resize + reflow, and word-boundary lookup.
library;
import 'package:clide/src/terminal/src/core/buffer/buffer.dart';
import 'package:clide/src/terminal/src/core/buffer/cell_offset.dart';
import 'package:clide/src/terminal/src/core/buffer/range_line.dart';
import 'package:clide/src/terminal/src/core/cursor.dart';
import 'package:clide/src/terminal/src/core/mouse/mode.dart';
import 'package:clide/src/terminal/src/core/state.dart';
import 'package:test/test.dart';
class _State implements TerminalState {
_State({
this.viewWidth = 10,
this.viewHeight = 5,
this.autoWrapMode = true,
this.lineFeedMode = false,
this.originMode = false,
this.reflowEnabled = false,
});
@override
int viewWidth;
@override
int viewHeight;
@override
bool autoWrapMode;
@override
bool lineFeedMode;
@override
bool originMode;
@override
bool reflowEnabled;
@override
final cursor = CursorStyle();
// Unused-by-Buffer bits — dummy values are fine.
@override
bool get insertMode => false;
@override
bool get cursorKeysMode => false;
@override
bool get reverseDisplayMode => false;
@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 appKeypadMode => false;
@override
bool get reportFocusMode => false;
@override
bool get altBufferMouseScrollMode => false;
@override
bool get bracketedPasteMode => false;
}
Buffer _newBuffer({
int viewWidth = 10,
int viewHeight = 5,
int maxLines = 50,
bool isAltBuffer = false,
bool autoWrapMode = true,
bool lineFeedMode = false,
bool originMode = false,
bool reflowEnabled = false,
Set<int>? wordSeparators,
}) {
final state = _State(
viewWidth: viewWidth,
viewHeight: viewHeight,
autoWrapMode: autoWrapMode,
lineFeedMode: lineFeedMode,
originMode: originMode,
reflowEnabled: reflowEnabled,
);
return Buffer(
state,
maxLines: maxLines,
isAltBuffer: isAltBuffer,
wordSeparators: wordSeparators,
);
}
void main() {
group('Buffer — construction', () {
test('seeds viewHeight empty lines and resets margins', () {
final b = _newBuffer(viewWidth: 8, viewHeight: 4);
expect(b.height, 4);
expect(b.viewWidth, 8);
expect(b.viewHeight, 4);
expect(b.cursorX, 0);
expect(b.cursorY, 0);
expect(b.scrollBack, 0);
expect(b.marginTop, 0);
expect(b.marginBottom, 3);
});
test('absolute cursor + margin getters are scrollback-shifted', () {
final b = _newBuffer(viewHeight: 3);
// Push a line via index() in the no-scrollable-region path on a
// primary buffer — that uses lines.push to grow scrollback.
b.setCursor(0, 2);
b.index();
// Now scrollBack == 1; the cursor stayed at viewHeight - 1 = 2.
expect(b.scrollBack, 1);
expect(b.absoluteCursorY, 3);
expect(b.absoluteMarginTop, 1);
expect(b.absoluteMarginBottom, 3);
});
});
group('Buffer — cursor movement and clamping', () {
test('setCursorX/Y clamp to viewWidth-1 / viewHeight-1', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3);
b.setCursorX(99);
b.setCursorY(99);
expect(b.cursorX, 4);
expect(b.cursorY, 2);
b.setCursorX(-1);
b.setCursorY(-1);
expect(b.cursorX, 0);
expect(b.cursorY, 0);
});
test('moveCursorX / moveCursorY are relative + clamped', () {
final b = _newBuffer();
b.setCursor(5, 2);
b.moveCursorX(2);
b.moveCursorY(1);
expect(b.cursorX, 7);
expect(b.cursorY, 3);
b.moveCursorX(99); // clamps
expect(b.cursorX, 9);
});
test('cursorGoForward saturates at viewWidth (one past last visible)', () {
final b = _newBuffer(viewWidth: 5);
for (var i = 0; i < 10; i++) {
b.cursorGoForward();
}
// The clamped getter caps at viewWidth - 1; the internal saturation
// sits at viewWidth.
expect(b.cursorX, 4);
});
test('setCursor — originMode shifts y by marginTop and clamps to marginBottom', () {
final b = _newBuffer(viewHeight: 6, originMode: true);
b.setVerticalMargins(2, 4);
b.setCursor(0, 0);
// origin shift: cursorY += marginTop (2) → clamped to marginBottom (4).
expect(b.cursorY, 2);
b.setCursor(0, 99);
expect(b.cursorY, 4); // clamped to marginBottom
});
test('moveCursor delegates to setCursor', () {
final b = _newBuffer();
b.setCursor(0, 0);
b.moveCursor(2, 1);
expect(b.cursorX, 2);
expect(b.cursorY, 1);
});
});
group('Buffer — vertical margins', () {
test('setVerticalMargins clamps and orders top/bottom', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(99, -5);
// Both clamp to [0, viewHeight-1] = [0, 4]; the implementation
// also coerces top<=bottom by min/max swap.
expect(b.marginTop, lessThanOrEqualTo(b.marginBottom));
});
test('resetVerticalMargins restores full viewport range', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.resetVerticalMargins();
expect(b.marginTop, 0);
expect(b.marginBottom, 4);
});
test('isInVerticalMargin is true inside, false outside', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursorY(0);
expect(b.isInVerticalMargin, isFalse);
b.setCursorY(2);
expect(b.isInVerticalMargin, isTrue);
b.setCursorY(4);
expect(b.isInVerticalMargin, isFalse);
});
});
group('Buffer — write / writeChar / autoWrap', () {
test('write adds chars left-to-right and advances the cursor', () {
final b = _newBuffer(viewWidth: 5);
b.write('abc');
expect(b.cursorX, 3);
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(1), 'b'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(2), 'c'.codeUnitAt(0));
});
test('autoWrap pushes the cursor to the next line and marks it wrapped', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
b.write('abcdef'); // 'def' wraps to line 1
expect(b.height, 4); // still within view; no scrollback needed yet
expect(b.cursorY, 1);
// cursorX getter clamps to viewWidth-1; internal _cursorX is saturated.
expect(b.cursorX, 2);
expect(b.currentLine.isWrapped, isTrue);
});
test('autoWrap=false leaves the cursor saturated past the end (no wrap)', () {
final b = _newBuffer(viewWidth: 3, autoWrapMode: false);
b.write('abc');
// After three writes the cursor is at viewWidth (3). The next write
// triggers index() but autoWrapMode is off — the line shouldn't be
// marked wrapped.
b.write('d');
expect(b.currentLine.isWrapped, isFalse);
});
test('wide character writes a width-2 cell + a 0-codepoint trailer', () {
final b = _newBuffer(viewWidth: 5);
b.write(''); // CJK ideograph, width 2
expect(b.cursorX, 2);
});
});
group('Buffer — backspace', () {
test('column 0 on a wrapped line jumps to the prior line end and unwraps', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
b.write('abcd'); // wraps; cursor at (1, 1) on a wrapped line
expect(b.currentLine.isWrapped, isTrue);
b.setCursor(0, 1);
// Re-flag wrapped (setCursor may not affect it but to be sure)
b.currentLine.isWrapped = true;
b.backspace();
expect(b.cursorX, 2); // viewWidth - 1
expect(b.cursorY, 0);
});
test('column == viewWidth backspaces by 2 cells (off the saturated edge)', () {
final b = _newBuffer(viewWidth: 5);
// Saturate the cursor.
for (var i = 0; i < 5; i++) {
b.cursorGoForward();
}
// Internal _cursorX is now 5 (viewWidth). The clamped getter shows 4.
b.backspace();
// -2 of (5) clamps to (3). cursorX getter returns clamped value.
expect(b.cursorX, 3);
});
test('mid-line backspace is just a -1', () {
final b = _newBuffer(viewWidth: 5);
b.setCursor(3, 0);
b.backspace();
expect(b.cursorX, 2);
});
});
group('Buffer — erase commands', () {
test('eraseLine clears the whole current line and clears wrapped flag', () {
final b = _newBuffer(viewWidth: 5);
b.write('abc');
b.currentLine.isWrapped = true;
b.eraseLine();
expect(b.currentLine.isWrapped, isFalse);
for (var i = 0; i < 5; i++) {
expect(b.currentLine.getCodePoint(i), 0);
}
});
test('eraseLineFromCursor only clears [cursor, viewWidth)', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(2);
b.eraseLineFromCursor();
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(1), 'b'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(2), 0);
expect(b.currentLine.getCodePoint(4), 0);
});
test('eraseLineToCursor only clears [0, cursor)', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(3);
b.eraseLineToCursor();
expect(b.currentLine.getCodePoint(0), 0);
expect(b.currentLine.getCodePoint(2), 0);
expect(b.currentLine.getCodePoint(3), 'd'.codeUnitAt(0));
});
test('eraseChars erases [cursor, cursor+count)', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(1);
b.eraseChars(2);
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(1), 0);
expect(b.currentLine.getCodePoint(2), 0);
expect(b.currentLine.getCodePoint(3), 'd'.codeUnitAt(0));
});
test('eraseDisplay clears every visible line', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3);
b.write('aaaaa');
b.lineFeed();
b.write('bbbbb');
b.eraseDisplay();
for (var y = 0; y < b.viewHeight; y++) {
for (var x = 0; x < b.viewWidth; x++) {
expect(b.lines[y].getCodePoint(x), 0);
}
}
});
test('eraseDisplayFromCursor clears current line tail + every line below', () {
// lineFeedMode: true so lineFeed resets cursorX — otherwise the
// saturated cursorX would make the next write wrap onto an extra
// line via writeChar's autoWrap path.
final b = _newBuffer(viewWidth: 5, viewHeight: 5, lineFeedMode: true);
b.write('aaaaa');
b.lineFeed();
b.write('bbbbb');
b.lineFeed();
b.write('ccccc');
b.setCursor(2, 1);
b.eraseDisplayFromCursor();
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0)); // untouched
expect(b.lines[1].getCodePoint(0), 'b'.codeUnitAt(0)); // head kept
expect(b.lines[1].getCodePoint(2), 0); // tail erased
expect(b.lines[2].getCodePoint(0), 0); // line below wiped
});
test('eraseDisplayToCursor clears every line above + the current line head', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 5, lineFeedMode: true);
b.write('aaaaa');
b.lineFeed();
b.write('bbbbb');
b.lineFeed();
b.write('vwxyz'); // distinct chars so the kept tail is visible
b.setCursor(3, 2);
b.eraseDisplayToCursor();
expect(b.lines[0].getCodePoint(0), 0);
expect(b.lines[1].getCodePoint(0), 0);
expect(b.lines[2].getCodePoint(0), 0); // head erased
expect(b.lines[2].getCodePoint(3), 'y'.codeUnitAt(0)); // tail kept
expect(b.lines[2].getCodePoint(4), 'z'.codeUnitAt(0));
});
});
group('Buffer — scroll / index / lineFeed / reverseIndex', () {
test('scrollUp inside the scroll region rotates lines and seeds an empty tail', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5, lineFeedMode: true);
b.write('aaa');
b.lineFeed();
b.write('bbb');
b.lineFeed();
b.write('ccc');
b.scrollUp(1);
// After scroll: line 0 = 'bbb', line 1 = 'ccc', line 2 = empty.
expect(b.lines[0].getCodePoint(0), 'b'.codeUnitAt(0));
expect(b.lines[1].getCodePoint(0), 'c'.codeUnitAt(0));
expect(b.lines[2].getCodePoint(0), 0);
});
test('scrollDown inside the scroll region rotates lines and seeds an empty head', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5, lineFeedMode: true);
b.write('aaa');
b.lineFeed();
b.write('bbb');
b.scrollDown(1);
// After scroll: line 0 = empty, line 1 = 'aaa', line 2 = 'bbb'.
expect(b.lines[0].getCodePoint(0), 0);
expect(b.lines[1].getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.lines[2].getCodePoint(0), 'b'.codeUnitAt(0));
});
test('index — at the bottom of primary buffer pushes a new line to grow scrollback', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
b.setCursorY(2);
b.index();
expect(b.scrollBack, 1);
expect(b.cursorY, 2); // stayed at viewHeight - 1
});
test('index — at the bottom of alt buffer scrolls up instead of growing', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3, isAltBuffer: true);
b.setCursorY(2);
b.index();
expect(b.scrollBack, 0);
expect(b.cursorY, 2);
});
test('index — within margin but not at the bottom moves cursor down', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursorY(1);
b.index();
expect(b.cursorY, 2);
});
test('index — at margin bottom with non-zero top scrollUps inside the region', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
b.setVerticalMargins(1, 2);
b.setCursorY(2); // at marginBottom
b.write('a'); // mark line 2
b.index();
expect(b.cursorY, 2);
});
test('index — at marginBottom with marginTop=0 on primary inserts a new line', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 4);
// marginTop=0 by default
b.setCursorY(b.marginBottom);
b.write('z');
final beforeHeight = b.height;
b.index();
expect(b.height, beforeHeight + 1); // an empty line was inserted
});
test('index — cursor outside the vertical margin and at viewport bottom on primary pushes scrollback', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
// Margins shrink the scroll region — the cursor below it is outside.
b.setVerticalMargins(1, 3);
b.setCursorY(4); // viewHeight - 1, outside [1, 3]
final before = b.height;
b.index();
expect(b.height, before + 1); // primary path: lines.push
});
test('index — cursor outside the vertical margin on alt buffer scrolls up instead of pushing', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5, isAltBuffer: true);
b.setVerticalMargins(1, 3);
b.setCursorY(4);
final before = b.height;
b.index();
expect(b.height, before); // alt path: no growth
});
test('index — cursor outside the vertical margin and not at the bottom moves cursor down', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursorY(4); // outside, but not viewHeight-1... wait viewHeight-1 IS 4
// To exercise the moveCursorY branch outside the margin we need
// _cursorY < viewHeight - 1 AND outside [marginTop, marginBottom].
// With margins (1, 2) the cursor at y=3 is outside and not at the bottom.
b.setVerticalMargins(1, 2);
b.setCursorY(3);
b.index();
expect(b.cursorY, 4);
});
test('lineFeed honours lineFeedMode by setting cursor to column 0', () {
final b = _newBuffer(lineFeedMode: true);
b.setCursorX(5);
b.lineFeed();
expect(b.cursorX, 0);
});
test('lineFeed leaves cursorX alone when lineFeedMode is off', () {
final b = _newBuffer();
b.setCursorX(5);
b.lineFeed();
expect(b.cursorX, 5);
});
test('reverseIndex inside margins at top scrolls down, otherwise moves up', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursorY(2);
b.reverseIndex();
expect(b.cursorY, 1);
b.setCursorY(1); // at marginTop
b.reverseIndex();
expect(b.cursorY, 1); // cursor stays; scrollDown happened
b.setCursorY(0); // outside margins
b.reverseIndex();
expect(b.cursorY, 0); // already at 0, clamps
});
});
group('Buffer — saveCursor / restoreCursor', () {
test('round-trips position + style + charset', () {
final b = _newBuffer();
b.setCursor(3, 2);
// Mutate cursor style.
// (Reach into the underlying state via the property surface.)
b.saveCursor();
b.setCursor(0, 0);
b.restoreCursor();
expect(b.cursorX, 3);
expect(b.cursorY, 2);
});
});
group('Buffer — line insert / delete / chars', () {
test('insertBlankChars opens a gap at the cursor', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(1);
b.insertBlankChars(2);
expect(b.currentLine.getCodePoint(1), 0);
expect(b.currentLine.getCodePoint(2), 0);
expect(b.currentLine.getCodePoint(3), 'b'.codeUnitAt(0));
});
test('deleteChars closes a gap at the cursor', () {
final b = _newBuffer(viewWidth: 5);
b.write('abcde');
b.setCursorX(1);
b.deleteChars(2);
expect(b.currentLine.getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(1), 'd'.codeUnitAt(0));
expect(b.currentLine.getCodePoint(2), 'e'.codeUnitAt(0));
});
test('insertLines is a no-op outside the scroll region', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(2, 4);
b.setCursorY(0);
b.write('aaa');
b.insertLines(2);
// line 0 untouched (cursor was outside margins → no-op)
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
});
test('insertLines inside the scroll region pushes lines down', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursor(0, 2);
b.write('aaa');
b.setCursor(0, 1);
b.insertLines(1);
// insertLines(1) pushes lines below the cursor down. So 'aaa'
// should now be at line 3.
expect(b.lines[3].getCodePoint(0), 'a'.codeUnitAt(0));
// Line 1 (cursor) is now empty.
expect(b.lines[1].getCodePoint(0), 0);
});
test('insertLines fills the entire region with empty lines when count >= lines below', () {
// When linesToInsert == linesBelow, linesToMove is 0 and the
// "fill empty" loop runs from 0 to linesToInsert.
final b = _newBuffer(viewWidth: 3, viewHeight: 5);
b.setVerticalMargins(1, 3);
b.setCursor(0, 1);
b.write('aaa');
b.setCursor(0, 2);
b.write('bbb');
b.setCursor(0, 1);
// 3 lines below (incl cursor) but request many more — clamps to 3
// and skips the "move" loop entirely.
b.insertLines(99);
for (var y = 1; y <= 3; y++) {
expect(b.lines[y].getCodePoint(0), 0);
}
});
test('deleteLines is a no-op outside the scroll region', () {
final b = _newBuffer(viewHeight: 5);
b.setVerticalMargins(2, 4);
b.setCursorY(0);
b.write('aaa');
b.deleteLines(2);
expect(b.lines[0].getCodePoint(0), 'a'.codeUnitAt(0));
});
test('deleteLines inside the scroll region pulls lines up', () {
// Use setCursor (not setCursorY) so cursorX is reset between
// writes — otherwise it stays saturated after the previous write.
final b = _newBuffer(viewWidth: 3, viewHeight: 6);
b.setVerticalMargins(1, 4);
b.setCursor(0, 2);
b.write('aaa');
b.setCursor(0, 3);
b.write('bbb');
b.setCursor(0, 1);
b.deleteLines(1);
// Inside the scroll region, deleteLines at line 1 shifts lines 2..4 up.
// Original content was at lines 2 ('aaa') and 3 ('bbb'); after deletion
// they should sit at 1 and 2.
expect(b.lines[1].getCodePoint(0), 'a'.codeUnitAt(0));
expect(b.lines[2].getCodePoint(0), 'b'.codeUnitAt(0));
expect(b.lines[4].getCodePoint(0), 0); // bottom of region cleared
});
});
group('Buffer — resize', () {
test('grow height pushes empty lines while there is room in the ring', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3, maxLines: 50);
b.resize(5, 3, 5, 5);
expect(b.height, 5);
});
test('grow height into existing scrollback bumps cursorY instead of pushing', () {
// Build up scrollback so lines.length > new viewHeight.
final b = _newBuffer(viewWidth: 3, viewHeight: 3, maxLines: 50);
// Force scrollback growth via index()-at-bottom on primary buffer.
b.setCursorY(2);
for (var i = 0; i < 5; i++) {
b.index();
}
expect(b.height, greaterThanOrEqualTo(8));
final cursorYBefore = b.cursorY;
// Resize to a height that's still smaller than lines.length: the
// grow loop should bump _cursorY rather than push new lines.
b.resize(3, 3, 3, 5);
expect(b.cursorY, greaterThan(cursorYBefore));
});
test('resize with reflow pads the result up to newHeight', () {
// Empty buffer: reflow returns near-zero lines; the pad-with-empty-
// lines branch fills up to newHeight.
final b = _newBuffer(viewWidth: 8, viewHeight: 3, reflowEnabled: true);
b.resize(8, 3, 4, 12);
expect(b.height, greaterThanOrEqualTo(12));
});
test('shrink height pops lines from the bottom when cursor is near the top', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 5);
b.setCursorY(0);
b.resize(5, 5, 5, 3);
expect(b.height, 3);
});
test('shrink height drags cursor up when it sits below the new height', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 5);
b.setCursorY(4);
b.resize(5, 5, 5, 3);
expect(b.cursorY, lessThanOrEqualTo(2));
});
test('resize without reflow forwards width to every line', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3, reflowEnabled: false);
b.resize(5, 3, 8, 3);
for (var y = 0; y < b.viewHeight; y++) {
expect(b.lines[y].length, 8);
}
});
test('resize with reflow on primary buffer pads the result up to newHeight', () {
final b = _newBuffer(viewWidth: 5, viewHeight: 3, reflowEnabled: true);
b.resize(5, 3, 8, 5);
expect(b.height, greaterThanOrEqualTo(5));
});
});
group('Buffer — scrollback / clear', () {
test('clearScrollback is a no-op when there is no scrollback', () {
final b = _newBuffer(viewHeight: 3);
b.clearScrollback();
expect(b.scrollBack, 0);
});
test('clearScrollback drops lines above the viewport', () {
final b = _newBuffer(viewHeight: 3);
// Force scrollback by indexing past the bottom.
b.setCursorY(2);
b.index();
b.index();
expect(b.scrollBack, 2);
b.clearScrollback();
expect(b.scrollBack, 0);
});
test('clear wipes everything and refills with viewHeight empty lines', () {
final b = _newBuffer(viewHeight: 3);
b.write('aaa');
b.clear();
expect(b.height, 3);
for (var y = 0; y < 3; y++) {
for (var x = 0; x < b.viewWidth; x++) {
expect(b.lines[y].getCodePoint(x), 0);
}
}
});
});
group('Buffer — anchors', () {
test('createAnchor at (x, y)', () {
final b = _newBuffer();
final a = b.createAnchor(2, 1);
expect(a.x, 2);
expect(b.lines[1].anchors, contains(a));
});
test('createAnchorFromOffset', () {
final b = _newBuffer();
final a = b.createAnchorFromOffset(const CellOffset(3, 2));
expect(a.x, 3);
expect(b.lines[2].anchors, contains(a));
});
test('createAnchorFromCursor', () {
final b = _newBuffer();
b.setCursor(4, 1);
final a = b.createAnchorFromCursor();
expect(a.x, 4);
});
});
group('Buffer — getWordBoundary', () {
test('returns null for an out-of-range y', () {
final b = _newBuffer(viewHeight: 3);
expect(b.getWordBoundary(const CellOffset(0, 99)), isNull);
});
test('returns null when the position is fully bounded by separators on both sides', () {
// The implementation walks left until a separator (or column 0) and
// right until a separator (or viewWidth). A null result requires the
// walks to land on the same column — i.e., separators flanking the
// position itself.
final b = _newBuffer(viewWidth: 5);
b.write('a b'); // spaces at indexes 1, 2, 3
final r = b.getWordBoundary(const CellOffset(2, 0));
expect(r, isNull);
});
test('returns the surrounding word range, halting at separators', () {
final b = _newBuffer(viewWidth: 9);
b.write('foo bar');
final r = b.getWordBoundary(const CellOffset(5, 0))!;
expect(r.begin.x, 4);
expect(r.end.x, 7);
});
test('honours custom wordSeparators when provided', () {
final b = _newBuffer(viewWidth: 5, wordSeparators: <int>{','.codeUnitAt(0)});
b.write('a,bcd');
final r = b.getWordBoundary(const CellOffset(2, 0))!;
expect(r.begin.x, 2);
expect(r.end.x, 5);
});
});
group('Buffer — getText', () {
test('default range walks the whole buffer line-by-line with newlines', () {
final b = _newBuffer(viewWidth: 4, viewHeight: 3);
b.write('aa');
b.lineFeed();
b.write('bb');
final text = b.getText();
expect(text, contains('aa'));
expect(text, contains('bb'));
expect(text.split('\n').length, greaterThanOrEqualTo(2));
});
test('explicit range works on a normalized BufferRangeLine', () {
final b = _newBuffer(viewWidth: 4, viewHeight: 3);
b.write('aa');
b.lineFeed();
b.write('bb');
final r = BufferRangeLine(const CellOffset(0, 0), const CellOffset(2, 0));
expect(b.getText(r), 'aa');
});
test('skips out-of-range segments', () {
final b = _newBuffer(viewWidth: 4, viewHeight: 2);
b.write('aa');
// Range references a y past height; getText should skip it without
// throwing.
final r = BufferRangeLine(const CellOffset(0, 0), const CellOffset(2, 99));
final result = b.getText(r);
expect(result, contains('aa'));
});
test('wrapped lines do not get an inserted newline', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
b.write('abcdef'); // wraps line 1
final text = b.getText();
// The wrapped line shouldn't have a separating newline before it.
expect(text.replaceAll('\n', '').contains('abcdef'), isTrue);
});
});
group('Buffer — toString debug dump', () {
test('produces one line per buffer line with index and wrap marker', () {
final b = _newBuffer(viewWidth: 3, viewHeight: 3);
b.write('abcd'); // wraps; line 0 is 'abc', line 1 is 'd' (wrapped)
final out = b.toString();
expect(out, contains('|abc|'));
expect(out, contains('(⏎)')); // the wrapped marker
});
});
}