Files
oh-my-pi/packages/tui/test/component-render.test.ts
T

604 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import {
type Component,
Container,
Editor,
type Focusable,
type NativeScrollbackCommittedRows,
type NativeScrollbackLiveRegion,
type NativeScrollbackReplay,
TUI,
} from "@oh-my-pi/pi-tui";
import { StressRenderScheduler } from "./render-stress-scheduler";
import { defaultEditorTheme } from "./test-themes";
import { VirtualTerminal } from "./virtual-terminal";
const ORIGINAL_HERDR_ENV = Bun.env.HERDR_ENV;
beforeEach(() => {
delete Bun.env.HERDR_ENV;
});
afterEach(() => {
if (ORIGINAL_HERDR_ENV === undefined) delete Bun.env.HERDR_ENV;
else Bun.env.HERDR_ENV = ORIGINAL_HERDR_ENV;
});
// Behavioral tests for TUI.requestComponentRender: a component whose own
// content changed (spinner frame, blink) asks for a component-scoped frame.
// When every request since the last frame is component-scoped and the frame is
// otherwise quiet, the compose re-renders only the root subtrees containing
// the requesting components and reuses the previous segment — rows and seam
// report — of every other root child. Any concurrent full request or unsafe
// condition must downgrade to a normal full compose.
/** Ref-stable leaf: fresh array per change, counts render() calls. */
class CountingLines implements Component {
renders = 0;
#lines: string[];
constructor(lines: string[]) {
this.#lines = lines;
}
set(lines: string[]): void {
this.#lines = lines;
}
invalidate(): void {}
render(_width: number): readonly string[] {
this.renders++;
return this.#lines;
}
}
/** Transcript-shaped head: final rows committed, the last row stays live. */
class LiveHead extends CountingLines implements NativeScrollbackLiveRegion {
#seam = 0;
setSeam(seam: number): void {
this.#seam = seam;
}
getNativeScrollbackLiveRegionStart(): number | undefined {
return this.#seam;
}
}
class AnchoredStatusContainer extends Container implements NativeScrollbackLiveRegion {
getNativeScrollbackLiveRegionStart(): number | undefined {
const hasAnchoredRows = this.children.length > 0;
return hasAnchoredRows ? 0 : undefined;
}
}
function strip(rows: string[]): string[] {
return rows.map(row => Bun.stripANSI(row).trimEnd());
}
function visible(term: VirtualTerminal): string[] {
return strip(term.getViewport()).filter(row => row.length > 0);
}
class RenderCountingTUI extends TUI {
renders = 0;
override render(width: number): readonly string[] {
this.renders++;
return super.render(width);
}
}
class ReplayVirtualizedLines implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
readonly lines: readonly string[];
replayPreparations = 0;
#compacted = false;
#replayPending = false;
constructor(lines: readonly string[]) {
this.lines = lines;
}
invalidate(): void {}
setNativeScrollbackCommittedRows(rows: number): void {
if (rows >= 4) this.#compacted = true;
}
prepareNativeScrollbackReplay(): void {
this.replayPreparations++;
this.#replayPending = true;
}
render(_width: number): readonly string[] {
if (this.#replayPending) {
this.#replayPending = false;
return this.lines;
}
return this.#compacted ? this.lines.slice(4) : this.lines;
}
}
describe("TUI native scrollback replay", () => {
it("rehydrates virtualized roots before a destructive full paint", async () => {
const term = new VirtualTerminal(40, 4, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new ReplayVirtualizedLines([
"history-0",
"history-1",
"history-2",
"history-3",
"tail-0",
"tail-1",
"tail-2",
"tail-3",
]);
tui.addChild(transcript);
try {
tui.start();
await scheduler.drain(term);
tui.requestRender();
await scheduler.drain(term);
tui.requestRender(true, { clearScrollback: true });
await scheduler.drain(term);
expect(transcript.replayPreparations).toBe(1);
const buffer = strip(term.getScrollBuffer());
expect(buffer).toContain("history-0");
expect(buffer).toContain("tail-3");
} finally {
tui.stop();
await term.flush();
}
});
});
describe("TUI.requestComponentRender", () => {
it("re-renders only the requesting subtree on a quiet frame", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1", "msg-2"]);
const status = new Container();
const spinner = new CountingLines(["spin-0"]);
status.addChild(spinner);
tui.addChild(transcript);
tui.addChild(status);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "msg-2", "spin-0"]);
const transcriptRenders = transcript.renders;
// Spinner tick: component-scoped request, nested one level deep.
spinner.set(["spin-1"]);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "msg-2", "spin-1"]);
// The transcript subtree was reused, not re-rendered.
expect(transcript.renders).toBe(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("downgrades to a full compose when a full request shares the frame", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const spinner = new CountingLines(["spin-0"]);
tui.addChild(transcript);
tui.addChild(spinner);
try {
tui.start();
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
// Both a component-scoped and a full request coalesce into one
// frame; the full request wins regardless of arrival order.
spinner.set(["spin-1"]);
tui.requestComponentRender(spinner);
transcript.set(["msg-0", "msg-edited"]);
tui.requestRender();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-edited", "spin-1"]);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose while an overlay is up", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const spinner = new CountingLines(["spin-0"]);
tui.addChild(transcript);
tui.addChild(spinner);
try {
tui.start();
await scheduler.drain(term);
tui.showOverlay(new CountingLines(["modal"]), { width: 10 });
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
spinner.set(["spin-1"]);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
// Unsafe condition: the frame rendered fully (and correctly).
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose when the root child list changed", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const spinner = new CountingLines(["spin-0"]);
tui.addChild(transcript);
tui.addChild(spinner);
try {
tui.start();
await scheduler.drain(term);
// Structural change with only a component-scoped request pending:
// the segment ledger no longer matches the root list, so the frame
// must compose fully and paint the new child.
tui.addChild(new CountingLines(["banner"]));
spinner.set(["spin-1"]);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "spin-1", "banner"]);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose when the component is not in the tree", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const status = new Container();
const spinner = new CountingLines(["spin-0"]);
status.addChild(spinner);
tui.addChild(transcript);
tui.addChild(status);
try {
tui.start();
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
// A detached component (cleared status container) can still fire a
// trailing tick; the frame must not skip anything based on it.
status.removeChild(spinner);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0"]);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("replays the seam report of a skipped root child across partial frames", async () => {
const term = new VirtualTerminal(40, 4, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const markers = Array.from({ length: 8 }, (_unused, i) => `ROW-${String(i).padStart(3, "0")}`);
// All but the last head row are final; the tail row stays live.
const head = new LiveHead([...markers, "streaming"]);
head.setSeam(markers.length);
const spinner = new CountingLines(["spin-0"]);
tui.addChild(head);
tui.addChild(spinner);
try {
tui.start();
await scheduler.drain(term);
const headRenders = head.renders;
// Several spinner-only frames while the head (and its commit seam)
// ride the reused segment.
for (let tick = 1; tick <= 3; tick++) {
spinner.set([`spin-${tick}`]);
tui.requestComponentRender(spinner);
await scheduler.drain(term);
}
expect(head.renders).toBe(headRenders);
expect(visible(term).at(-1)).toBe("spin-3");
// A later full frame must still commit exactly once: every final
// row appears exactly once across history + grid, in order.
head.set([...markers, "streamed-final", "tail"]);
head.setSeam(markers.length + 2);
tui.requestRender();
await scheduler.drain(term);
const buffer = strip(term.getScrollBuffer()).join("\n");
const missing = markers.filter(mark => buffer.split(mark).length - 1 === 0);
const duplicated = markers.filter(mark => buffer.split(mark).length - 1 > 1);
expect(missing).toEqual([]);
expect(duplicated).toEqual([]);
const observed = Array.from(buffer.matchAll(/ROW-\d{3}/g), match => match[0]);
expect(observed).toEqual(markers);
} finally {
tui.stop();
await term.flush();
}
});
});
describe("TUI keystroke-scoped render", () => {
it("fully composes callback-driven sibling updates without explicit scoped opt-in", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const status = new CountingLines(["status-idle"]);
const input: Component & Focusable = {
focused: false,
invalidate() {},
render() {
const state = this.focused ? "focused" : "idle";
return [`input-${state}`];
},
handleInput() {
status.set(["status-submitted"]);
},
};
tui.addChild(status);
tui.addChild(input);
tui.setFocus(input);
try {
tui.start();
await scheduler.drain(term);
const statusRenders = status.renders;
term.sendInput("x");
await scheduler.drain(term);
expect(status.renders).toBeGreaterThan(statusRenders);
expect(visible(term)).toEqual(["status-submitted", "input-focused"]);
expect(tui.getFocused()).toBe(input);
} finally {
tui.stop();
await term.flush();
}
});
it("does not re-render a quiet sibling transcript while typing in the focused editor", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1", "msg-2"]);
const editor = new Editor(defaultEditorTheme);
tui.enableScopedInputRender(editor);
tui.addChild(transcript);
tui.addChild(editor);
tui.setFocus(editor);
try {
tui.start();
await scheduler.drain(term);
const transcriptRenders = transcript.renders;
term.sendInput("x");
await scheduler.drain(term);
expect(editor.getText()).toBe("x");
expect(transcript.renders).toBe(transcriptRenders);
expect(visible(term).some(row => row.includes("msg-0"))).toBe(true);
expect(visible(term).some(row => row.includes("x"))).toBe(true);
} finally {
tui.stop();
await term.flush();
}
});
it("keeps a correct viewport when a keystroke grows the editor by one wrapped row", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1"]);
const editor = new Editor(defaultEditorTheme);
tui.enableScopedInputRender(editor);
// 34 chars fills the first content row at width 40; the next char wraps.
editor.setText("x".repeat(34));
tui.addChild(transcript);
tui.addChild(editor);
tui.setFocus(editor);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual([
"msg-0",
"msg-1",
"+--------------------------------------+",
"+- xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|-+",
]);
const transcriptRenders = transcript.renders;
term.sendInput("y");
await scheduler.drain(term);
expect(editor.getText()).toBe(`${"x".repeat(34)}y`);
expect(transcript.renders).toBe(transcriptRenders);
expect(visible(term)).toEqual([
"msg-0",
"msg-1",
"+--------------------------------------+",
"| xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |",
"+- y| -+",
]);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full compose when handleInput moves focus", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new TUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const nextFocus = new CountingLines(["selector"]);
const focusMover: Component & Focusable = {
focused: false,
invalidate() {},
render() {
return this.focused ? ["editor-focused"] : ["editor-idle"];
},
handleInput() {
tui.setFocus(nextFocus);
},
};
tui.enableScopedInputRender(focusMover);
tui.addChild(transcript);
tui.addChild(focusMover);
tui.addChild(nextFocus);
tui.setFocus(focusMover);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "editor-focused", "selector"]);
const transcriptRenders = transcript.renders;
term.sendInput("x");
await scheduler.drain(term);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
expect(visible(term)).toEqual(["msg-0", "editor-idle", "selector"]);
expect(tui.getFocused()).toBe(nextFocus);
} finally {
tui.stop();
await term.flush();
}
});
});
describe("TUI.requestDirectWrite", () => {
it("directly rewrites a visible unchanged-size root segment without a full render", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1"]);
const spinner = new CountingLines(["spin-0"]);
const footer = new CountingLines(["footer"]);
tui.addChild(transcript);
tui.addChild(spinner);
tui.addChild(footer);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-0", "footer"]);
const tuiRenders = tui.renders;
const transcriptRenders = transcript.renders;
const footerRenders = footer.renders;
spinner.set(["spin-1"]);
tui.requestDirectWrite(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-1", "footer"]);
expect(tui.renders).toBe(tuiRenders);
expect(transcript.renders).toBe(transcriptRenders);
expect(footer.renders).toBe(footerRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("directly rewrites fully live anchored status segments", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0", "msg-1"]);
const status = new AnchoredStatusContainer();
const spinner = new CountingLines(["spin-0"]);
status.addChild(spinner);
tui.addChild(transcript);
tui.addChild(status);
try {
tui.start();
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-0"]);
const tuiRenders = tui.renders;
const transcriptRenders = transcript.renders;
spinner.set(["spin-1"]);
tui.requestDirectWrite(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["msg-0", "msg-1", "spin-1"]);
expect(tui.renders).toBe(tuiRenders);
expect(transcript.renders).toBe(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
it("falls back to a full render while a visible overlay is up", async () => {
const term = new VirtualTerminal(40, 8, 1_000);
const scheduler = new StressRenderScheduler();
const tui = new RenderCountingTUI(term, undefined, { renderScheduler: scheduler });
const transcript = new CountingLines(["msg-0"]);
const spinner = new CountingLines(["spin-0"]);
const footer = new CountingLines(["footer"]);
tui.addChild(transcript);
tui.addChild(spinner);
tui.addChild(footer);
try {
tui.start();
await scheduler.drain(term);
tui.showOverlay(new CountingLines(["modal"]), { width: 5, anchor: "top-left" });
await scheduler.drain(term);
expect(visible(term)).toEqual(["modal", "spin-0", "footer"]);
const tuiRenders = tui.renders;
const transcriptRenders = transcript.renders;
spinner.set(["spin-1"]);
tui.requestDirectWrite(spinner);
await scheduler.drain(term);
expect(visible(term)).toEqual(["modal", "spin-1", "footer"]);
expect(tui.renders).toBeGreaterThan(tuiRenders);
expect(transcript.renders).toBeGreaterThan(transcriptRenders);
} finally {
tui.stop();
await term.flush();
}
});
});