/** * KHAELOR * File: tests/tui/frame.test.ts * Description: Renderer frame-composition tests — settling, damage tracking, cursor parking, sync fallback. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import { composeFrame } from "../../src/tui/renderer/frame.js"; const WIDTH = 80; describe("composeFrame", () => { it("first paint repaints every row and parks the cursor at the caret", () => { const frame = composeFrame({ prevLines: [], prevCaretRow: 0, next: { lines: ["tail", "status", "composer", "bar"], caretRow: 2, caretCol: 4 }, settled: [], width: WIDTH, sync: true, }); expect(frame.repaintedRows).toBe(4); expect(frame.lines).toEqual(["tail", "status", "composer", "bar"]); expect(frame.caretRow).toBe(2); // synchronized-output bracketing expect(frame.data.startsWith("\x1b[?2026h")).toBe(true); expect(frame.data.endsWith("\x1b[?2026l")).toBe(true); // cursor parked: up 1 from last row (row 3 → row 2), column 5 (1-based) expect(frame.data).toContain("\x1b[1A"); expect(frame.data).toContain("\x1b[5G"); }); it("repaints only damaged rows on partial update", () => { const prev = ["tail", "status", "composer", "bar"]; const frame = composeFrame({ prevLines: prev, prevCaretRow: 2, next: { lines: ["tail", "status CHANGED", "composer", "bar"], caretRow: 2, caretCol: 4 }, settled: [], width: WIDTH, sync: true, }); expect(frame.repaintedRows).toBe(1); expect(frame.data).toContain("status CHANGED"); // untouched rows are not re-emitted expect(frame.data).not.toContain("composer"); }); it("flushes settled blocks above the live region and full-repaints after", () => { const frame = composeFrame({ prevLines: ["old-live", "composer"], prevCaretRow: 1, next: { lines: ["composer"], caretRow: 0, caretCol: 2 }, settled: [["▸ Read src/a.ts · 12 lines"], ["✓ src/a.ts +3 −1"]], width: WIDTH, sync: true, }); // erase live region before printing settled content expect(frame.data).toContain("\x1b[0J"); const settledIdx = frame.data.indexOf("▸ Read src/a.ts"); const secondIdx = frame.data.indexOf("✓ src/a.ts"); const liveIdx = frame.data.indexOf("composer"); expect(settledIdx).toBeGreaterThan(-1); expect(secondIdx).toBeGreaterThan(settledIdx); expect(liveIdx).toBeGreaterThan(secondIdx); // settled lines end with \r\n so they scroll into scrollback expect(frame.data).toContain("▸ Read src/a.ts · 12 lines\r\n"); expect(frame.repaintedRows).toBe(1); // full repaint of the (1-row) new region }); it("erases below when the live region shrinks", () => { const frame = composeFrame({ prevLines: ["a", "b", "c", "d"], prevCaretRow: 2, next: { lines: ["a", "b"], caretRow: 1, caretCol: 0 }, settled: [], width: WIDTH, sync: true, }); expect(frame.data).toContain("\x1b[0J"); expect(frame.lines).toEqual(["a", "b"]); }); it("falls back to cursor hide/show bracketing without sync support", () => { const frame = composeFrame({ prevLines: [], prevCaretRow: 0, next: { lines: ["x"], caretRow: 0, caretCol: 1 }, settled: [], width: WIDTH, sync: false, }); expect(frame.data).not.toContain("\x1b[?2026h"); expect(frame.data.startsWith("\x1b[?25l")).toBe(true); expect(frame.data).toContain("\x1b[?25h"); }); it("truncates every line to width − 1", () => { const long = "y".repeat(200); const frame = composeFrame({ prevLines: [], prevCaretRow: 0, next: { lines: [long], caretRow: 0, caretCol: 0 }, settled: [["s".repeat(200)]], width: 20, sync: true, }); expect(frame.lines[0]).toBe("y".repeat(19)); expect(frame.data).toContain("s".repeat(19) + "\r\n"); expect(frame.data).not.toContain("s".repeat(20)); }); it("moves from the previous caret row to the live-region origin", () => { const frame = composeFrame({ prevLines: ["a", "b", "c"], prevCaretRow: 1, next: { lines: ["a", "b", "c"], caretRow: 1, caretCol: 0 }, settled: [], width: WIDTH, sync: true, }); expect(frame.data).toContain("\x1b[1F"); // up 1 to origin from caret row 1 expect(frame.repaintedRows).toBe(0); // nothing changed }); });