co-mono/packages/tui/test/tui-rendering.test.ts
Mario Zechner 66cefb236e Massive refactor of API
- Switch to function based API
- Anthropic SDK style async generator
- Fully typed with escape hatches for custom models
2025-09-02 23:59:36 +02:00

417 lines
14 KiB
TypeScript

import assert from "node:assert";
import { describe, test } from "node:test";
import {
Container,
MarkdownComponent,
SelectList,
TextComponent,
TextEditor,
TUI,
WhitespaceComponent,
} from "../src/index.js";
import { VirtualTerminal } from "./virtual-terminal.js";
describe("TUI Rendering", () => {
test("renders single text component", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
const text = new TextComponent("Hello, World!");
ui.addChild(text);
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
// Wait for writes to complete and get the rendered output
const output = await terminal.flushAndGetViewport();
// Expected: text on first line
assert.strictEqual(output[0], "Hello, World!");
// Check cursor position
const cursor = terminal.getCursorPosition();
assert.strictEqual(cursor.y, 1);
assert.strictEqual(cursor.x, 0);
ui.stop();
});
test("renders multiple text components", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
ui.addChild(new TextComponent("Line 1"));
ui.addChild(new TextComponent("Line 2"));
ui.addChild(new TextComponent("Line 3"));
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Line 1");
assert.strictEqual(output[1], "Line 2");
assert.strictEqual(output[2], "Line 3");
ui.stop();
});
test("renders text component with padding", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
ui.addChild(new TextComponent("Top text"));
ui.addChild(new TextComponent("Padded text", { top: 2, bottom: 2 }));
ui.addChild(new TextComponent("Bottom text"));
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Top text");
assert.strictEqual(output[1], ""); // top padding
assert.strictEqual(output[2], ""); // top padding
assert.strictEqual(output[3], "Padded text");
assert.strictEqual(output[4], ""); // bottom padding
assert.strictEqual(output[5], ""); // bottom padding
assert.strictEqual(output[6], "Bottom text");
ui.stop();
});
test("renders container with children", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
const container = new Container();
container.addChild(new TextComponent("Child 1"));
container.addChild(new TextComponent("Child 2"));
ui.addChild(new TextComponent("Before container"));
ui.addChild(container);
ui.addChild(new TextComponent("After container"));
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Before container");
assert.strictEqual(output[1], "Child 1");
assert.strictEqual(output[2], "Child 2");
assert.strictEqual(output[3], "After container");
ui.stop();
});
test("handles text editor rendering", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
const editor = new TextEditor();
ui.addChild(editor);
ui.setFocus(editor);
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
// Initial state - empty editor with cursor
const output = await terminal.flushAndGetViewport();
// Check that we have the border characters
assert.ok(output[0].includes("╭"));
assert.ok(output[0].includes("╮"));
assert.ok(output[1].includes("│"));
ui.stop();
});
test("differential rendering only updates changed lines", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
const staticText = new TextComponent("Static text");
const dynamicText = new TextComponent("Initial");
ui.addChild(staticText);
ui.addChild(dynamicText);
// Wait for initial render
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Save initial state
const initialViewport = [...terminal.getViewport()];
// Change only the dynamic text
dynamicText.setText("Changed");
ui.requestRender();
// Wait for render
await new Promise((resolve) => process.nextTick(resolve));
// Flush terminal buffer
await terminal.flush();
// Check the viewport now shows the change
const newViewport = terminal.getViewport();
assert.strictEqual(newViewport[0], "Static text"); // Unchanged
assert.strictEqual(newViewport[1], "Changed"); // Changed
ui.stop();
});
test("handles component removal", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
const text1 = new TextComponent("Line 1");
const text2 = new TextComponent("Line 2");
const text3 = new TextComponent("Line 3");
ui.addChild(text1);
ui.addChild(text2);
ui.addChild(text3);
// Wait for initial render
await new Promise((resolve) => process.nextTick(resolve));
let output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Line 1");
assert.strictEqual(output[1], "Line 2");
assert.strictEqual(output[2], "Line 3");
// Remove middle component
ui.removeChild(text2);
ui.requestRender();
await new Promise((resolve) => setImmediate(resolve));
output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Line 1");
assert.strictEqual(output[1], "Line 3");
assert.strictEqual(output[2].trim(), ""); // Should be cleared
ui.stop();
});
test("handles viewport overflow", async () => {
const terminal = new VirtualTerminal(80, 10); // Small viewport
const ui = new TUI(terminal);
ui.start();
// Add more lines than viewport can hold
for (let i = 1; i <= 15; i++) {
ui.addChild(new TextComponent(`Line ${i}`));
}
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
// Should only render what fits in viewport (9 lines + 1 for cursor)
// When content exceeds viewport, we show the last N lines
assert.strictEqual(output[0], "Line 7");
assert.strictEqual(output[1], "Line 8");
assert.strictEqual(output[2], "Line 9");
assert.strictEqual(output[3], "Line 10");
assert.strictEqual(output[4], "Line 11");
assert.strictEqual(output[5], "Line 12");
assert.strictEqual(output[6], "Line 13");
assert.strictEqual(output[7], "Line 14");
assert.strictEqual(output[8], "Line 15");
ui.stop();
});
test("handles whitespace component", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
ui.addChild(new TextComponent("Before"));
ui.addChild(new WhitespaceComponent(3));
ui.addChild(new TextComponent("After"));
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
assert.strictEqual(output[0], "Before");
assert.strictEqual(output[1], "");
assert.strictEqual(output[2], "");
assert.strictEqual(output[3], "");
assert.strictEqual(output[4], "After");
ui.stop();
});
test("markdown component renders correctly", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
const markdown = new MarkdownComponent("# Hello\n\nThis is **bold** text.");
ui.addChild(markdown);
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
// Should have formatted markdown
assert.ok(output[0].includes("Hello")); // Header
assert.ok(output[2].includes("This is")); // Paragraph after blank line
assert.ok(output[2].includes("bold")); // Bold text
ui.stop();
});
test("select list renders and handles selection", async () => {
const terminal = new VirtualTerminal(80, 24);
const ui = new TUI(terminal);
ui.start();
const items = [
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3" },
];
const selectList = new SelectList(items);
ui.addChild(selectList);
ui.setFocus(selectList);
// Wait for next tick for render to complete
await new Promise((resolve) => process.nextTick(resolve));
const output = await terminal.flushAndGetViewport();
// First option should be selected (has → indicator)
assert.ok(output[0].startsWith("→"), `Expected first line to start with →, got: "${output[0]}"`);
assert.ok(output[0].includes("Option 1"));
assert.ok(output[1].startsWith(" "), `Expected second line to start with space, got: "${output[1]}"`);
assert.ok(output[1].includes("Option 2"));
ui.stop();
});
test("preserves existing terminal content when rendering", async () => {
const terminal = new VirtualTerminal(80, 24);
// Write some content to the terminal before starting TUI
// This simulates having existing content in the scrollback buffer
terminal.write("Previous command output line 1\r\n");
terminal.write("Previous command output line 2\r\n");
terminal.write("Some important information\r\n");
terminal.write("Last line before TUI starts\r\n");
// Flush to ensure writes are complete
await terminal.flush();
// Get the initial state with existing content
const initialOutput = [...terminal.getViewport()];
assert.strictEqual(initialOutput[0], "Previous command output line 1");
assert.strictEqual(initialOutput[1], "Previous command output line 2");
assert.strictEqual(initialOutput[2], "Some important information");
assert.strictEqual(initialOutput[3], "Last line before TUI starts");
// Now start the TUI with a text editor
const ui = new TUI(terminal);
ui.start();
const editor = new TextEditor();
let submittedText = "";
editor.onSubmit = (text) => {
submittedText = text;
};
ui.addChild(editor);
ui.setFocus(editor);
// Wait for initial render
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Check that the editor is rendered after the existing content
const afterTuiStart = terminal.getViewport();
// The existing content should still be visible above the editor
assert.strictEqual(afterTuiStart[0], "Previous command output line 1");
assert.strictEqual(afterTuiStart[1], "Previous command output line 2");
assert.strictEqual(afterTuiStart[2], "Some important information");
assert.strictEqual(afterTuiStart[3], "Last line before TUI starts");
// The editor should appear after the existing content
// The editor is 3 lines tall (top border, content line, bottom border)
// Top border with box drawing characters filling the width (80 chars)
assert.strictEqual(afterTuiStart[4][0], "╭");
assert.strictEqual(afterTuiStart[4][78], "╮");
// Content line should have the prompt
assert.strictEqual(afterTuiStart[5].substring(0, 4), "│ > ");
// And should end with vertical bar
assert.strictEqual(afterTuiStart[5][78], "│");
// Bottom border
assert.strictEqual(afterTuiStart[6][0], "╰");
assert.strictEqual(afterTuiStart[6][78], "╯");
// Type some text into the editor
terminal.sendInput("Hello World");
// Wait for the input to be processed
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Check that text appears in the editor
const afterTyping = terminal.getViewport();
assert.strictEqual(afterTyping[0], "Previous command output line 1");
assert.strictEqual(afterTyping[1], "Previous command output line 2");
assert.strictEqual(afterTyping[2], "Some important information");
assert.strictEqual(afterTyping[3], "Last line before TUI starts");
// The editor content should show the typed text with the prompt ">"
assert.strictEqual(afterTyping[5].substring(0, 15), "│ > Hello World");
// Send SHIFT+ENTER to the editor (adds a new line)
// According to text-editor.ts line 251, SHIFT+ENTER is detected as "\n" which calls addNewLine()
terminal.sendInput("\n");
// Wait for the input to be processed
await new Promise((resolve) => process.nextTick(resolve));
await terminal.flush();
// Check that existing content is still preserved after adding new line
const afterNewLine = terminal.getViewport();
assert.strictEqual(afterNewLine[0], "Previous command output line 1");
assert.strictEqual(afterNewLine[1], "Previous command output line 2");
assert.strictEqual(afterNewLine[2], "Some important information");
assert.strictEqual(afterNewLine[3], "Last line before TUI starts");
// Editor should now be 4 lines tall (top border, first line, second line, bottom border)
// Top border at line 4
assert.strictEqual(afterNewLine[4][0], "╭");
assert.strictEqual(afterNewLine[4][78], "╮");
// First line with text at line 5
assert.strictEqual(afterNewLine[5].substring(0, 15), "│ > Hello World");
assert.strictEqual(afterNewLine[5][78], "│");
// Second line (empty, with continuation prompt " ") at line 6
assert.strictEqual(afterNewLine[6].substring(0, 4), "│ ");
assert.strictEqual(afterNewLine[6][78], "│");
// Bottom border at line 7
assert.strictEqual(afterNewLine[7][0], "╰");
assert.strictEqual(afterNewLine[7][78], "╯");
// Verify that onSubmit was NOT called (since we pressed SHIFT+ENTER, not plain ENTER)
assert.strictEqual(submittedText, "");
ui.stop();
});
});