Files
Codeman maintainer a30524060a feat(predictive-echo): PredictiveEchoAddon + Layers 1-3 test suites (0.2.0)
Mosh-style write-through prediction: the consumer sends every keystroke
unchanged; the addon paints predicted glyphs and reconciles against the
parsed buffer. Confirm = cell match + cursor advance (placeholder-safe,
repaint-safe); two-pass mismatch cascade with neutral blanks (measured:
codex clears its placeholder on first echo); TTL bound; baseY-based line
reads; scroll/resize/off-row clears. Zero edits to zerolag-input-addon.ts.

Tests: 30 addon-law specs + renderer geometry (fake performance clock for
TTL/grace), 6 replay suites running the real algorithm through a real
@xterm/headless parser fed by the recorded codex fixtures, and a
500-iteration seeded fuzz with per-op span/record + grid invariants.
227 total, the pre-existing 175 untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 04:25:24 +02:00

207 lines
6.3 KiB
TypeScript

/**
* Mock terminal factory for unit tests.
*
* Creates a minimal Terminal-like object that satisfies the addon's
* requirements without needing a real xterm.js instance or DOM renderer.
*
* PredictiveEchoAddon additions (all ADDITIVE, existing tests unchanged):
* mutable cursor via setCursor(), wide-char-aware getCell() on mock lines,
* onWriteParsed/onResize emitters with fire* triggers, and opt-outs for
* getCell support and the emitters (getCellSupport / emitters options).
*/
import { charCellWidth } from '../src/overlay-renderer.js';
interface MockLine {
translateToString(_trimRight?: boolean): string;
getCell?(x: number): { getChars(): string; getWidth(): number } | undefined;
}
interface MockBufferOptions {
lines: string[];
viewportY?: number;
baseY?: number;
cursorX?: number;
cursorY?: number;
}
interface MockTerminalOptions {
buffer?: MockBufferOptions;
cols?: number;
rows?: number;
fontFamily?: string;
fontSize?: number;
fontWeight?: string | number;
theme?: {
background?: string;
foreground?: string;
cursor?: string;
};
cellWidth?: number;
cellHeight?: number;
/** Device-pixel char top offset (for charTop calculation). Default: 0 */
deviceCharTop?: number;
/** Device-pixel char height (for charHeight calculation). Default: cellHeight * dpr */
deviceCharHeight?: number;
/** Provide getCell() on mock lines (PredictiveEchoAddon). Default: true */
getCellSupport?: boolean;
/** Provide onWriteParsed/onResize emitters (PredictiveEchoAddon). Default: true */
emitters?: boolean;
}
/** Column-indexed cell access over a plain string, wide-char aware. */
function cellAt(text: string, col: number): { getChars(): string; getWidth(): number } {
let c = 0;
for (const ch of text) {
const w = charCellWidth(null, ch);
if (col === c) return { getChars: () => ch, getWidth: () => w };
if (w === 2 && col === c + 1) return { getChars: () => '', getWidth: () => 0 };
c += w;
}
return { getChars: () => '', getWidth: () => 1 };
}
export function createMockTerminal(opts: MockTerminalOptions = {}) {
const bufOpts = opts.buffer ?? { lines: ['$ '] };
const viewportY = bufOpts.viewportY ?? 0;
const baseY = bufOpts.baseY ?? viewportY;
const cols = opts.cols ?? 80;
const rows = opts.rows ?? Math.max(bufOpts.lines.length, 24);
const cellW = opts.cellWidth ?? 8.4;
const cellH = opts.cellHeight ?? 17;
const getCellSupport = opts.getCellSupport ?? true;
const emitters = opts.emitters ?? true;
const makeLine = (text: string): { line: MockLine; set(t: string): void } => {
let current = text;
const line: MockLine = {
translateToString: () => current,
};
if (getCellSupport) {
line.getCell = (x: number) => cellAt(current, x);
}
return { line, set: (t: string) => (current = t) };
};
let mockLines = bufOpts.lines.map(makeLine);
// Create minimal DOM structure
const element = document.createElement('div');
element.className = 'terminal xterm';
const viewport = document.createElement('div');
viewport.className = 'xterm-viewport';
const screen = document.createElement('div');
screen.className = 'xterm-screen';
screen.style.position = 'relative';
const xtermRows = document.createElement('div');
xtermRows.className = 'xterm-rows';
element.appendChild(viewport);
element.appendChild(screen);
screen.appendChild(xtermRows);
// Append to document so getComputedStyle works
document.body.appendChild(element);
const writeParsedCbs = new Set<() => void>();
const resizeCbs = new Set<(s: { cols: number; rows: number }) => void>();
const terminal = {
element,
cols,
rows,
options: {
fontFamily: opts.fontFamily ?? 'monospace',
fontSize: opts.fontSize ?? 14,
fontWeight: opts.fontWeight ?? 'normal',
theme: opts.theme ?? {},
},
buffer: {
active: {
viewportY,
baseY,
cursorX: bufOpts.cursorX ?? 0,
cursorY: bufOpts.cursorY ?? 0,
getLine: (absRow: number): MockLine | undefined => {
return mockLines[absRow - viewportY]?.line;
},
},
},
_core: {
_renderService: {
dimensions: {
css: {
cell: { width: cellW, height: cellH },
},
device: {
char: {
top: opts.deviceCharTop ?? 0,
height: opts.deviceCharHeight ?? cellH,
},
},
},
},
},
...(emitters
? {
onWriteParsed(cb: () => void) {
writeParsedCbs.add(cb);
return { dispose: () => writeParsedCbs.delete(cb) };
},
onResize(cb: (s: { cols: number; rows: number }) => void) {
resizeCbs.add(cb);
return { dispose: () => resizeCbs.delete(cb) };
},
}
: {}),
// Simulate loadAddon
loadAddon(addon: { activate: (t: unknown) => void }) {
addon.activate(this);
},
};
return {
terminal,
/** Update buffer lines for subsequent calls */
setLines(newLines: string[]) {
mockLines = newLines.map(makeLine);
},
/** Update one line's text in place (PredictiveEchoAddon echo simulation) */
setLine(index: number, text: string) {
mockLines[index]?.set(text);
},
/** Move the mock cursor (PredictiveEchoAddon) */
setCursor(x: number, y: number) {
terminal.buffer.active.cursorX = x;
terminal.buffer.active.cursorY = y;
},
/** Set scroll state (viewportY / baseY) */
setScroll(newViewportY: number, newBaseY: number) {
terminal.buffer.active.viewportY = newViewportY;
terminal.buffer.active.baseY = newBaseY;
},
/** Fire the onWriteParsed emitter (PredictiveEchoAddon reconcile trigger) */
fireWriteParsed() {
for (const cb of [...writeParsedCbs]) cb();
},
/** Fire the onResize emitter */
fireResize(newCols = cols, newRows = rows) {
for (const cb of [...resizeCbs]) cb({ cols: newCols, rows: newRows });
},
/** Number of live onWriteParsed listeners (dispose assertions) */
writeParsedListenerCount() {
return writeParsedCbs.size;
},
/** Number of live onResize listeners (dispose assertions) */
resizeListenerCount() {
return resizeCbs.size;
},
/** Clean up DOM */
cleanup() {
element.remove();
},
};
}