co-mono/packages/tui/test/tui-rendering.test.ts
Mario Zechner afa807b200 tui-double-buffer: Implement smart differential rendering with terminal abstraction
- Create Terminal interface abstracting stdin/stdout operations for dependency injection
- Implement ProcessTerminal for production use with process.stdin/stdout
- Implement VirtualTerminal using @xterm/headless for accurate terminal emulation in tests
- Fix TypeScript imports for @xterm/headless module
- Move all component files to src/components/ directory for better organization
- Add comprehensive test suite with async/await patterns for proper render timing
- Fix critical TUI differential rendering bug when components grow in height
  - Issue: Old content wasn't properly cleared when component line count increased
  - Solution: Clear each old line individually before redrawing, ensure cursor at line start
- Add test verifying terminal content preservation and text editor growth behavior
- Update tsconfig.json to include test files in type checking
- Add benchmark test comparing single vs double buffer performance

The implementation successfully reduces flicker by only updating changed lines
rather than clearing entire sections. Both TUI implementations maintain the
same interface for backward compatibility.
2025-08-10 22:33:03 +02:00

418 lines
No EOL
14 KiB
TypeScript

import { test, describe } from "node:test";
import assert from "node:assert";
import { VirtualTerminal } from "./virtual-terminal.js";
import {
TUI,
Container,
TextComponent,
TextEditor,
WhitespaceComponent,
MarkdownComponent,
SelectList,
} from "../src/index.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();
});
});