From 98ebc8f9048bd5f0ceb7e843b67874d642f0b7fd Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Sun, 2 Aug 2026 10:49:03 -0400 Subject: [PATCH] test(desktop): harden Buzz Term banner invariants Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../terminal/TerminalSubstrate.test.mjs | 146 +++++++++++------- .../features/terminal/terminalBanner.test.mjs | 89 ++++++++--- .../src/features/terminal/terminalBanner.ts | 84 +++++----- .../features/terminal/terminalState.test.mjs | 19 --- .../src/features/terminal/terminalState.ts | 27 ---- 5 files changed, 209 insertions(+), 156 deletions(-) diff --git a/desktop/src/features/terminal/TerminalSubstrate.test.mjs b/desktop/src/features/terminal/TerminalSubstrate.test.mjs index 793d48df4..9302ded3f 100644 --- a/desktop/src/features/terminal/TerminalSubstrate.test.mjs +++ b/desktop/src/features/terminal/TerminalSubstrate.test.mjs @@ -50,7 +50,19 @@ after(() => dom.window.close()); beforeEach(() => { cleanup?.(); dom.window.localStorage.clear(); + dom.window.HTMLCanvasElement.prototype.getBoundingClientRect = () => ({ + bottom: 782, + height: 782, + left: 0, + right: 940.8, + top: 0, + width: 940.8, + x: 0, + y: 0, + toJSON() {}, + }); dom.window.HTMLCanvasElement.prototype.getContext = () => ({ + clearRect() {}, fillRect() {}, fillStyle: "", fillText() {}, @@ -81,7 +93,7 @@ function fixture(overrides = {}) { sessions: [{ active: true, closing: false, id: "one", title: "SHELL" }], ...overrides, }; - const view = render( + const renderTree = (nextProps) => createElement( ThemeProvider, null, @@ -89,10 +101,17 @@ function fixture(overrides = {}) { className: "buzz-huddle-app-surface", tabIndex: -1, }), - createElement(TerminalSubstrate, props), - ), - ); - return { calls, props, view }; + createElement(TerminalSubstrate, nextProps), + ); + const view = render(renderTree(props)); + return { + calls, + props, + view, + rerender(nextOverrides) { + view.rerender(renderTree({ ...props, ...nextOverrides })); + }, + }; } async function ready(view) { @@ -136,62 +155,81 @@ test("mounted IME paths neither toggle nor emit preedit text", async () => { assert.deepEqual(calls.input, ["か"]); }); -test("first active PTY output dismisses the welcome overlay", async () => { - const baseFrame = { - cursor: { column: 0, line: 0, visible: false }, - full: false, - rows: [ - { - line: 0, - spans: [ - { - style: { fg: 0, bg: 0, flags: 0 }, - clusters: [{ column: 0, text: "$", width: 1 }], - }, - ], - }, - { - line: 8, - spans: [ - { - style: { fg: 0, bg: 0, flags: 0 }, - clusters: [{ column: 70, text: "x", width: 1 }], - }, - ], - }, - ], - viewport: { columns: 80, generation: 1, screenLines: 24 }, - }; - const { view } = fixture({ frame: baseFrame }); - await ready(view); - await waitFor(() => - assert.equal(view.container.querySelector(".buzz-terminal-welcome"), null), - ); +const EMPTY_FRAME = { + cursor: { column: 0, line: 0, visible: false }, + full: false, + rows: [], + viewport: { columns: 112, generation: 1, screenLines: 46 }, +}; - cleanup(); - const intersecting = fixture({ - frame: { - ...baseFrame, - rows: [ +const VISIBLE_FRAME = { + ...EMPTY_FRAME, + viewport: { ...EMPTY_FRAME.viewport, generation: 2 }, + rows: [ + { + line: 0, + spans: [ { - line: 8, - spans: [ - { - style: { fg: 0, bg: 0, flags: 0 }, - clusters: [{ column: 30, text: "x", width: 1 }], - }, - ], + style: { fg: 0, bg: 0, flags: 0 }, + clusters: [{ column: 0, text: "$", width: 1 }], }, ], }, - }); - await ready(intersecting.view); + ], +}; + +async function expectWelcome(view, present) { await waitFor(() => - assert.equal( - intersecting.view.container.querySelector(".buzz-terminal-welcome"), - null, - ), + present + ? assert.ok(view.container.querySelector(".buzz-terminal-welcome")) + : assert.equal( + view.container.querySelector(".buzz-terminal-welcome"), + null, + ), ); +} + +test("non-empty output from the active PTY dismisses the welcome overlay", async () => { + const subject = fixture({ frame: EMPTY_FRAME }); + await ready(subject.view); + await expectWelcome(subject.view, true); + + subject.rerender({ frame: VISIBLE_FRAME }); + await expectWelcome(subject.view, false); +}); + +test("empty active output keeps the welcome overlay", async () => { + const subject = fixture({ frame: EMPTY_FRAME }); + await ready(subject.view); + await expectWelcome(subject.view, true); + + subject.rerender({ + frame: { + ...EMPTY_FRAME, + viewport: { ...EMPTY_FRAME.viewport, generation: 2 }, + }, + }); + await expectWelcome(subject.view, true); +}); + +test("non-empty output from an inactive PTY keeps the welcome overlay", async () => { + const subject = fixture({ + sessionFrames: [{ frame: EMPTY_FRAME, sessionId: "one" }], + sessions: [ + { active: true, closing: false, id: "one", title: "SHELL" }, + { active: false, closing: false, id: "two", title: "LOG" }, + ], + }); + await ready(subject.view); + await expectWelcome(subject.view, true); + + subject.rerender({ + sessionFrames: [ + { frame: EMPTY_FRAME, sessionId: "one" }, + { frame: VISIBLE_FRAME, sessionId: "two" }, + ], + }); + await expectWelcome(subject.view, true); }); test("mounted wheel path accumulates fractional lines per active session", async () => { diff --git a/desktop/src/features/terminal/terminalBanner.test.mjs b/desktop/src/features/terminal/terminalBanner.test.mjs index 1fff08291..7d1895865 100644 --- a/desktop/src/features/terminal/terminalBanner.test.mjs +++ b/desktop/src/features/terminal/terminalBanner.test.mjs @@ -12,6 +12,50 @@ function layers(banner) { ); } +function frameBounds(banner) { + const cells = banner.cells.flatMap((row, y) => + row.flatMap((cell, x) => + cell.layer === "bevel_hi" || cell.layer === "bevel_lo" ? [{ x, y }] : [], + ), + ); + return { + top: Math.min(...cells.map(({ y }) => y)), + left: Math.min(...cells.map(({ x }) => x)), + bottom: Math.max(...cells.map(({ y }) => y)) + 1, + right: Math.max(...cells.map(({ x }) => x)) + 1, + }; +} + +function headRows(banner) { + const cells = banner.cells.flatMap((row, y) => + row.flatMap((cell, x) => + cell.layer === "head" ? [{ ...cell, x, y }] : [], + ), + ); + const left = Math.min(...cells.map(({ x }) => x)); + const right = Math.max(...cells.map(({ x }) => x)); + const top = Math.min(...cells.map(({ y }) => y)); + const bottom = Math.max(...cells.map(({ y }) => y)); + return Array.from({ length: bottom - top + 1 }, (_, row) => + Array.from( + { length: right - left + 1 }, + (_, column) => + cells.find(({ x, y }) => x === left + column && y === top + row) + ?.char ?? " ", + ) + .join("") + .trimEnd(), + ); +} + +const EXPECTED_BUZZ_TERM = [ + "██ ██", + "██▄▄▄ ██ ██ ██████ ██████ █████ ▄███▄ ██ ▄██ ██▄██▄██", + "██▀▀██ ██ ██ ▄██ ▄██ ██ ██▄▄▄█ ███▀▀ ██ ██ ██", + "██ ██ ██ ██ ▄██▀ ▄██▀ ██ ██ ██ ██ ██ ██", + "██████ ▀█████ ██████ ██████ ███ ▀███▀ ██ ██ ██ ██", +]; + test("builds the amended four-layer Buzz Term composite", () => { const banner = buildTerminalBanner(183, 69, 17 / 8.4); assert.ok(banner); @@ -22,16 +66,33 @@ test("builds the amended four-layer Buzz Term composite", () => { assert.equal(Math.min(...sweep), 0); assert.equal(Math.max(...sweep), 1); assert.ok(head.every((cell) => cell.layer === "head")); - const { top, left, bottom, right } = banner.reserved; + const { top, left, bottom, right } = frameBounds(banner); assert.equal(banner.cells[top][left].layer, "bevel_hi"); assert.equal(banner.cells[bottom - 1][right - 1].layer, "bevel_lo"); - assert.match( - seen - .get("head") - .map((cell) => cell.char) - .join(""), - /█/, - ); + assert.deepEqual(headRows(banner), EXPECTED_BUZZ_TERM); +}); + +test("requires every frame emitter independently", () => { + const banner = buildTerminalBanner(183, 69, 17 / 8.4); + assert.ok(banner); + const { top, left, bottom, right } = frameBounds(banner); + + for (let x = left; x < right; x += 1) + assert.equal(banner.cells[top][x].layer, "bevel_hi", `top_row:${x}`); + for (let y = top + 1; y < bottom - 1; y += 1) { + assert.equal(banner.cells[y][left].layer, "bevel_hi", `left_rail:${y}`); + assert.equal( + banner.cells[y][right - 1].layer, + "bevel_lo", + `right_rail:${y}`, + ); + } + for (let x = left; x < right; x += 1) + assert.equal( + banner.cells[bottom - 1][x].layer, + "bevel_lo", + `bottom_row:${x}`, + ); }); test("emits complete hexagons only and fades per ray", () => { @@ -43,18 +104,6 @@ test("emits complete hexagons only and fades per ray", () => { assert.ok(field.some((cell) => cell.t > 0.5)); }); -test("safe mode replaces ambiguous frame rails without changing geometry", () => { - const ink = buildTerminalBanner(183, 69, 17 / 8.4, false); - const safe = buildTerminalBanner(183, 69, 17 / 8.4, true); - assert.ok(ink && safe); - assert.deepEqual(ink.reserved, safe.reserved); - assert.ok(ink.cells.flat().some((cell) => cell.char === "▌")); - assert.equal( - safe.cells.flat().some((cell) => cell.char === "▌"), - false, - ); -}); - test("declines viewports too small for the complete wordmark", () => { assert.equal(buildTerminalBanner(40, 20, 17 / 8.4), null); }); diff --git a/desktop/src/features/terminal/terminalBanner.ts b/desktop/src/features/terminal/terminalBanner.ts index c8d7f47c6..49a7c2273 100644 --- a/desktop/src/features/terminal/terminalBanner.ts +++ b/desktop/src/features/terminal/terminalBanner.ts @@ -8,12 +8,6 @@ export type TerminalBannerCell = Readonly<{ export type TerminalBanner = Readonly<{ cells: readonly (readonly TerminalBannerCell[])[]; - reserved: Readonly<{ - top: number; - left: number; - bottom: number; - right: number; - }>; }>; const HEX = [ @@ -48,22 +42,27 @@ const INK_FRAME = { left: "▌", right: "▐", } as const; -const SAFE_FRAME = { - topLeft: "▛", - topRight: "▜", - bottomLeft: "▙", - bottomRight: "▟", - top: "░", - bottom: "░", - left: "▐", - right: "▐", -} as const; const LAYERS: readonly TerminalBannerLayer[] = [ "field", "head", "bevel_hi", "bevel_lo", ]; +type TerminalBannerEmitter = + | "field" + | "top_row" + | "left_rail" + | "right_rail" + | "bottom_row" + | "wordmark"; +const EMITTERS: readonly TerminalBannerEmitter[] = [ + "field", + "top_row", + "left_rail", + "right_rail", + "bottom_row", + "wordmark", +]; function trimRight(value: string): string { return value.replace(/\s+$/, ""); @@ -123,7 +122,6 @@ export function buildTerminalBanner( columns: number, rows: number, cellAspect: number, - safe = false, ): TerminalBanner | null { const mark = fitWordmark(columns - 10); if (!mark || rows < mark.length + 6) return null; @@ -142,15 +140,19 @@ export function buildTerminalBanner( const grid: TerminalBannerCell[][] = Array.from({ length: rows }, () => Array.from({ length: columns }, () => ({ char: " ", t: 0 })), ); + const emitted = new Set(); const put = ( y: number, x: number, char: string, layer: TerminalBannerLayer, t = 0, + emitter?: TerminalBannerEmitter, ) => { - if (y >= 0 && y < rows && x >= 0 && x < columns) + if (y >= 0 && y < rows && x >= 0 && x < columns) { grid[y][x] = { char, layer, t }; + if (emitter) emitted.add(emitter); + } }; const halfWidth = frameWidth / 2; @@ -190,7 +192,7 @@ export function buildTerminalBanner( ) continue; for (const [offsetY, offsetX, char] of HEX) - put(hex.y + offsetY, hex.x + offsetX, char, "field", ray * ray); + put(hex.y + offsetY, hex.x + offsetX, char, "field", ray * ray, "field"); } for ( @@ -206,23 +208,39 @@ export function buildTerminalBanner( grid[y][x] = { char: " ", t: 0 }; } - const frame = safe ? SAFE_FRAME : INK_FRAME; - put(top, left, frame.topLeft, "bevel_hi"); + const frame = INK_FRAME; + put(top, left, frame.topLeft, "bevel_hi", 0, "top_row"); for (let x = 0; x < innerWidth; x += 1) - put(top, left + x + 1, frame.top, "bevel_hi"); - put(top, left + frameWidth - 1, frame.topRight, "bevel_hi"); + put(top, left + x + 1, frame.top, "bevel_hi", 0, "top_row"); + put(top, left + frameWidth - 1, frame.topRight, "bevel_hi", 0, "top_row"); for (let y = top + 1; y < top + frameHeight - 1; y += 1) { - put(y, left, frame.left, "bevel_hi"); - put(y, left + frameWidth - 1, frame.right, "bevel_lo"); + put(y, left, frame.left, "bevel_hi", 0, "left_rail"); + put(y, left + frameWidth - 1, frame.right, "bevel_lo", 0, "right_rail"); } - put(top + frameHeight - 1, left, frame.bottomLeft, "bevel_lo"); + put( + top + frameHeight - 1, + left, + frame.bottomLeft, + "bevel_lo", + 0, + "bottom_row", + ); for (let x = 0; x < innerWidth; x += 1) - put(top + frameHeight - 1, left + x + 1, frame.bottom, "bevel_lo"); + put( + top + frameHeight - 1, + left + x + 1, + frame.bottom, + "bevel_lo", + 0, + "bottom_row", + ); put( top + frameHeight - 1, left + frameWidth - 1, frame.bottomRight, "bevel_lo", + 0, + "bottom_row", ); const markLeft = left + Math.floor((frameWidth - width) / 2); @@ -241,6 +259,7 @@ export function buildTerminalBanner( cell.char, "head", (cell.x - minX) / span, + "wordmark", ); const seen = new Map(); @@ -249,16 +268,9 @@ export function buildTerminalBanner( if (cell.layer) seen.set(cell.layer, [...(seen.get(cell.layer) ?? []), cell.t]); if (LAYERS.some((layer) => !seen.has(layer))) return null; + if (EMITTERS.some((emitter) => !emitted.has(emitter))) return null; const sweep = seen.get("head") ?? []; if (Math.min(...sweep) !== 0 || Math.max(...sweep) !== 1) return null; - return { - cells: grid, - reserved: { - top, - left, - bottom: top + frameHeight, - right: left + frameWidth, - }, - }; + return { cells: grid }; } diff --git a/desktop/src/features/terminal/terminalState.test.mjs b/desktop/src/features/terminal/terminalState.test.mjs index a6153db77..c9b42f72c 100644 --- a/desktop/src/features/terminal/terminalState.test.mjs +++ b/desktop/src/features/terminal/terminalState.test.mjs @@ -7,7 +7,6 @@ import { encodePaste, encodeTerminalKey, reduceHandoff, - updateWelcomeForOutput, } from "./terminalState.ts"; test("ownership changes on completed chord, never key-down or repeat", () => { @@ -96,21 +95,3 @@ test("pixel scrolling retains fractional lines in both directions", () => { assert.equal(result.lines, -1); assert.equal(result.state.remainderPx, -1); }); - -test("welcome survives an ordinary prompt outside its reserved rectangle", () => { - const welcome = { - visible: true, - reserved: { top: 4, left: 10, bottom: 10, right: 40 }, - }; - assert.equal( - updateWelcomeForOutput(welcome, [{ top: 0, left: 0, bottom: 1, right: 20 }]) - .visible, - true, - ); - assert.equal( - updateWelcomeForOutput(welcome, [ - { top: 9, left: 39, bottom: 11, right: 42 }, - ]).visible, - false, - ); -}); diff --git a/desktop/src/features/terminal/terminalState.ts b/desktop/src/features/terminal/terminalState.ts index a15f88432..95c23932c 100644 --- a/desktop/src/features/terminal/terminalState.ts +++ b/desktop/src/features/terminal/terminalState.ts @@ -115,30 +115,3 @@ export function accumulateScrollLines( lines, }; } - -export type Rect = { top: number; left: number; bottom: number; right: number }; - -export function rectsIntersect(a: Rect, b: Rect): boolean { - return !( - a.right <= b.left || - b.right <= a.left || - a.bottom <= b.top || - b.bottom <= a.top - ); -} - -export type WelcomeState = { - visible: boolean; - reserved: Rect | null; -}; - -export function updateWelcomeForOutput( - state: WelcomeState, - changed: readonly Rect[], -): WelcomeState { - const reserved = state.reserved; - if (!state.visible || !reserved) return state; - return changed.some((rect) => rectsIntersect(rect, reserved)) - ? { ...state, visible: false } - : state; -}