@teaui/core
Version:
A high-level terminal UI library for Node
272 lines • 8.79 kB
JavaScript
import * as unicode from '@teaui/term';
import { Style } from './Style.js';
/**
* A headless SGRTerminal for testing. Stores characters and parsed Style objects
* in a 2D grid, providing readable query methods for assertions:
*
* expect(term.charAt(0, 0)).toBe('H')
* expect(term.styleAt(0, 0).bold).toBe(true)
* expect(term.textAt(0, 0, 5)).toBe('Hello')
* expect(term.textContent()).toContain('Hello')
*/
export class TestTerminal {
cols;
rows;
#grid;
#cursorX = 0;
#cursorY = 0;
#pendingSgr = '';
#currentStyle = Style.NONE;
constructor({ cols, rows }) {
this.cols = cols;
this.rows = rows;
this.#grid = this.#createGrid();
}
#createGrid() {
const grid = [];
for (let y = 0; y < this.rows; y++) {
const row = [];
for (let x = 0; x < this.cols; x++) {
row.push({ char: ' ', style: Style.NONE });
}
grid.push(row);
}
return grid;
}
move(x, y) {
this.#cursorX = x;
this.#cursorY = y;
}
write(str) {
for (const char of unicode.printableChars(str)) {
const width = unicode.charWidth(char);
if (width === 0) {
// ANSI escape sequence
this.#pendingSgr += char;
}
else {
// Apply any pending SGR to current style
if (this.#pendingSgr) {
this.#currentStyle = this.#parseAccumulatedSGR(this.#pendingSgr);
this.#pendingSgr = '';
}
if (this.#cursorY >= 0 &&
this.#cursorY < this.rows &&
this.#cursorX >= 0 &&
this.#cursorX < this.cols) {
this.#grid[this.#cursorY][this.#cursorX] = {
char,
style: this.#currentStyle,
};
// Wide characters occupy 2 cells
if (width === 2 && this.#cursorX + 1 < this.cols) {
this.#grid[this.#cursorY][this.#cursorX + 1] = {
char: '',
style: this.#currentStyle,
};
}
}
this.#cursorX += Math.max(width, 1);
}
}
}
flush() { }
/**
* Parse accumulated SGR sequences into a Style.
* Multiple SGR codes may have been concatenated (e.g. "\u001b[1m\u001b[38;5;196m").
* A reset (\u001b[0m) clears everything.
*
* Note: Style.fromSGR's prevStyle param is used as the "reset target" — e.g.
* code 22 (!bold) resets to prevStyle.bold. We pass Style.NONE so resets
* correctly turn attributes off rather than copying the current state.
*/
#parseAccumulatedSGR(sgr) {
const sequences = sgr.match(/\x1b\[[\d;]*m/g) ?? [];
let style = this.#currentStyle;
for (const seq of sequences) {
if (seq === '\u001b[0m' || seq === '\u001b[m') {
style = Style.NONE;
}
else {
style = style.merge(Style.fromSGR(seq, Style.NONE));
}
}
return style;
}
// --- Query API for tests ---
/**
* Get the character at (x, y). Returns ' ' for empty cells.
*/
charAt(x, y) {
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows)
return '';
return this.#grid[y][x].char;
}
/**
* Get the Style at (x, y).
*/
styleAt(x, y) {
if (x < 0 || x >= this.cols || y < 0 || y >= this.rows)
return Style.NONE;
return this.#grid[y][x].style;
}
/**
* Read `length` characters starting at (x, y) on the same row.
* Skips empty cells from wide characters.
*/
textAt(x, y, length) {
let result = '';
let count = 0;
for (let cx = x; cx < this.cols && count < length; cx++) {
const char = this.#grid[y]?.[cx]?.char ?? '';
if (char === '')
continue; // skip wide char continuation cells
result += char;
count++;
}
return result;
}
/**
* Get all text on a row (trimmed of trailing spaces).
*/
textAtRow(y) {
if (y < 0 || y >= this.rows)
return '';
let line = '';
for (let x = 0; x < this.cols; x++) {
const char = this.#grid[y][x].char;
if (char === '')
continue; // skip wide char continuation cells
line += char;
}
return line.trimEnd(); // + ''
}
/**
* Get all visible text content (rows joined by newlines, trailing spaces trimmed).
*/
textContent() {
const lines = [];
for (let y = 0; y < this.rows; y++) {
lines.push(this.textAtRow(y));
}
// Trim trailing empty lines
while (lines.length > 0 && lines[lines.length - 1] === '') {
lines.pop();
}
return lines.join('\n');
}
/**
* Find the first position of a substring on the screen.
* Returns {x, y} or null if not found.
*/
find(text) {
for (let y = 0; y < this.rows; y++) {
const row = this.textAtRow(y);
const x = row.indexOf(text);
if (x !== -1) {
return { x, y };
}
}
return null;
}
/**
* Get the Style of the first character of a found substring.
* Useful for asserting styles on specific text.
*/
styleOf(text) {
const pos = this.find(text);
if (!pos)
return null;
return this.styleAt(pos.x, pos.y);
}
/**
* Get a row as a string, optionally sliced. NOT trimmed.
* Wide char continuation cells are skipped.
*/
getRow(y, from, to) {
if (y < 0 || y >= this.rows)
return '';
from ??= 0;
to ??= this.cols;
let line = '';
for (let x = from; x < to && x < this.cols; x++) {
const char = this.#grid[y][x].char;
if (char === '')
continue;
line += char;
}
return line;
}
/**
* Extract a rectangular region as a multi-line string (lines joined by \n).
* Trailing spaces on each line are preserved. Use for exact grid assertions:
*
* expect(term.textRect(0, 0, 5, 3)).toBe(
* '┌───┐\n' +
* '│ │\n' +
* '└───┘'
* )
*/
textRect(x, y, width, height) {
const lines = [];
for (let row = y; row < y + height && row < this.rows; row++) {
lines.push(this.getRow(row, x, x + width));
}
return lines.join('\n');
}
/**
* Assert that all cells in a range satisfy a style predicate.
* Returns true if every cell in the range matches.
*
* term.stylesMatch(0, 0, 5, style => style.bold === true)
*/
stylesMatch(x, y, width, predicate, height = 1) {
for (let row = y; row < y + height && row < this.rows; row++) {
for (let col = x; col < x + width && col < this.cols; col++) {
if (!predicate(this.#grid[row][col].style)) {
return false;
}
}
}
return true;
}
/**
* Compare full screen content against a template string. Each line
* of the expected string is compared against the corresponding row.
* Trailing spaces in the template are significant — use '·' for
* explicit space if needed.
* Trailing empty lines in the template are not required to match.
*/
contentEquals(expected) {
const expectedLines = expected.split('\n');
for (let y = 0; y < expectedLines.length; y++) {
const expectedLine = expectedLines[y];
const actual = this.getRow(y, 0, expectedLine.length);
if (actual !== expectedLine) {
return false;
}
}
return true;
}
/**
* Like contentEquals but returns a diff-friendly string for assertion messages.
* Use with expect().toBe() for readable failures:
*
* expect(term.frameContent()).toBe(
* '┌───┐\n' +
* '│Hi │\n' +
* '└───┘'
* )
*/
frameContent() {
return this.textContent();
}
reset() {
this.#grid = this.#createGrid();
this.#cursorX = 0;
this.#cursorY = 0;
this.#pendingSgr = '';
this.#currentStyle = Style.NONE;
}
}
//# sourceMappingURL=TestTerminal.js.map