mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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>
207 lines
6.3 KiB
TypeScript
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();
|
|
},
|
|
};
|
|
}
|