diff --git a/.pql/pql-plan.json b/.pql/pql-plan.json index 71720fe5..58f53a07 100644 --- a/.pql/pql-plan.json +++ b/.pql/pql-plan.json @@ -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": [ diff --git a/pubspec.yaml b/pubspec.yaml index 7edf2e40..f1634a60 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ repository: https://github.com/postmeridiem/clide # Pre-push line-coverage floor. Ratchets up only — see D-66. # Reading: `awk -F: '/^coverage_floor:/ {gsub(/ /,"",$2); print $2}' pubspec.yaml`. -coverage_floor: 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 diff --git a/test/terminal/buffer/buffer_test.dart b/test/terminal/buffer/buffer_test.dart new file mode 100644 index 00000000..86da50c4 --- /dev/null +++ b/test/terminal/buffer/buffer_test.dart @@ -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? 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: {','.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 + }); + }); +}