test: add comprehensive overlay tests for visual fixes and setPrompt

New cell-dimensions.test.ts (8 tests): DPR conversion, charTop/charHeight,
null cases. Overlay renderer tests (+12): cellH+1 seam fix, span centering,
no-transform, ligature disabling, multi-line cursor. Addon tests (+9):
setPrompt() strategy switching, tab-switch cycle, ghost artifact prevention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-01 16:40:26 +01:00
co-authored by Claude Opus 4.6
parent e8fd358923
commit a8a6c1a648
4 changed files with 393 additions and 0 deletions
@@ -0,0 +1,127 @@
import { describe, it, expect, afterEach, beforeEach } from 'vitest';
import { getCellDimensions } from '../src/cell-dimensions.js';
import { createMockTerminal } from './helpers.js';
import type { XtermTerminal } from '../src/types.js';
let cleanups: (() => void)[] = [];
afterEach(() => {
for (const fn of cleanups) fn();
cleanups = [];
});
describe('getCellDimensions', () => {
describe('v5 private API (mock _core._renderService)', () => {
it('returns cell width and height from css.cell', () => {
const mock = createMockTerminal({ cellWidth: 8.4, cellHeight: 19 });
cleanups.push(mock.cleanup);
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
expect(dims).not.toBeNull();
expect(dims!.width).toBe(8.4);
expect(dims!.height).toBe(19);
});
it('returns charTop from device.char.top divided by DPR', () => {
const mock = createMockTerminal({
cellWidth: 8, cellHeight: 19,
deviceCharTop: 2,
});
cleanups.push(mock.cleanup);
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
expect(dims).not.toBeNull();
// DPR=1 in jsdom, so charTop = 2 / 1 = 2
expect(dims!.charTop).toBe(2);
});
it('returns charHeight from device.char.height divided by DPR', () => {
const mock = createMockTerminal({
cellWidth: 8, cellHeight: 19,
deviceCharHeight: 16,
});
cleanups.push(mock.cleanup);
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
expect(dims).not.toBeNull();
// DPR=1, so charHeight = 16 / 1 = 16
expect(dims!.charHeight).toBe(16);
});
it('defaults charTop to 0 when device.char not present', () => {
// Default mock has deviceCharTop=0
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
cleanups.push(mock.cleanup);
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
expect(dims!.charTop).toBe(0);
});
it('defaults charHeight to cellH when device.char.height not set', () => {
// Default mock has deviceCharHeight=cellH
const mock = createMockTerminal({ cellWidth: 8, cellHeight: 19 });
cleanups.push(mock.cleanup);
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
expect(dims!.charHeight).toBe(19);
});
});
describe('DPR simulation', () => {
const originalDPR = globalThis.devicePixelRatio;
beforeEach(() => {
// Set DPR=2 to test division
Object.defineProperty(globalThis, 'devicePixelRatio', {
value: 2,
writable: true,
configurable: true,
});
});
afterEach(() => {
Object.defineProperty(globalThis, 'devicePixelRatio', {
value: originalDPR,
writable: true,
configurable: true,
});
});
it('divides device.char.top by DPR', () => {
const mock = createMockTerminal({
cellWidth: 16, cellHeight: 38,
deviceCharTop: 4,
deviceCharHeight: 32,
});
cleanups.push(mock.cleanup);
const dims = getCellDimensions(mock.terminal as unknown as XtermTerminal);
expect(dims).not.toBeNull();
// charTop = 4 / 2 = 2
expect(dims!.charTop).toBe(2);
// charHeight = 32 / 2 = 16
expect(dims!.charHeight).toBe(16);
});
});
describe('null cases', () => {
it('returns null for terminal without _core', () => {
const terminal = {
element: document.createElement('div'),
cols: 80,
rows: 24,
options: {},
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
} as unknown as XtermTerminal;
const dims = getCellDimensions(terminal);
expect(dims).toBeNull();
});
it('returns null for terminal with no dimensions', () => {
const terminal = {
element: document.createElement('div'),
cols: 80,
rows: 24,
options: {},
buffer: { active: { viewportY: 0, baseY: 0, getLine: () => undefined } },
_core: { _renderService: {} },
} as unknown as XtermTerminal;
const dims = getCellDimensions(terminal);
expect(dims).toBeNull();
});
});
});
@@ -31,6 +31,10 @@ interface MockTerminalOptions {
}; };
cellWidth?: number; cellWidth?: number;
cellHeight?: 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;
} }
export function createMockTerminal(opts: MockTerminalOptions = {}) { export function createMockTerminal(opts: MockTerminalOptions = {}) {
@@ -95,6 +99,12 @@ export function createMockTerminal(opts: MockTerminalOptions = {}) {
css: { css: {
cell: { width: cellW, height: cellH }, cell: { width: cellW, height: cellH },
}, },
device: {
char: {
top: opts.deviceCharTop ?? 0,
height: opts.deviceCharHeight ?? cellH,
},
},
}, },
}, },
}, },
@@ -18,6 +18,8 @@ function makeParams(overrides: Partial<RenderParams> = {}): RenderParams {
totalCols: 80, totalCols: 80,
cellW: 8.4, cellW: 8.4,
cellH: 17, cellH: 17,
charTop: 2,
charHeight: 14,
promptRow: 10, promptRow: 10,
font: FONT, font: FONT,
showCursor: true, showCursor: true,
@@ -166,4 +168,133 @@ describe('renderOverlay', () => {
renderOverlay(container, makeParams()); renderOverlay(container, makeParams());
expect(container.style.display).toBe(''); expect(container.style.display).toBe('');
}); });
// ─── Anti-flicker / compositing seam tests ────────────────────
it('line div height extends 1px past cellH to cover compositing seam', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['abc'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
// cellH + 1 = 20px — the extra 1px covers the compositing seam
expect(lineDiv.style.height).toBe('20px');
});
it('line div height is cellH+1 for various cell heights', () => {
for (const cellH of [15, 17, 19, 22]) {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['x'], cellH }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.style.height).toBe((cellH + 1) + 'px');
}
});
it('multi-line overlay has cellH+1 height on each line div', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({
lines: ['first', 'second'],
cellH: 19,
}));
const line1 = container.children[0] as HTMLDivElement;
const line2 = container.children[1] as HTMLDivElement;
expect(line1.style.height).toBe('20px');
expect(line2.style.height).toBe('20px');
});
// ─── Span vertical centering tests ────────────────────────────
it('span uses full cellH for height and lineHeight (CSS centering)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.height).toBe('19px');
expect(span.style.lineHeight).toBe('19px');
});
it('span top is 0px (no vertical offset / no transform)', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'], cellH: 19 }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.top).toBe('0px');
// No translateY transform — sub-pixel overhang causes artifacts
expect(span.style.transform).toBe('');
});
// ─── Font rendering tests ─────────────────────────────────────
it('span disables ligatures via font-feature-settings', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['fi'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
// Check cssText includes the ligature-disabling settings
// jsdom may normalize whitespace; check that both liga and calt are disabled
expect(span.style.cssText).toContain("font-feature-settings:");
expect(span.style.cssText).toContain("'liga' 0");
expect(span.style.cssText).toContain("'calt' 0");
});
it('span has text-align: center for glyph centering', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['m'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.textAlign).toBe('center');
});
it('span has pointer-events: none', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: ['a'] }));
const lineDiv = container.children[0] as HTMLDivElement;
const span = lineDiv.children[0] as HTMLSpanElement;
expect(span.style.pointerEvents).toBe('none');
});
// ─── Multi-line cursor positioning ────────────────────────────
it('cursor on wrapped line uses col 0 as base', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({
lines: ['first', 'ab'],
startCol: 5,
cellW: 10,
cellH: 20,
showCursor: true,
}));
// Cursor at end of second line: col = 0 + 2 = 2
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
expect(cursor.style.left).toBe('20px'); // 2 * 10
expect(cursor.style.top).toBe('20px'); // row 1 * cellH
});
// ─── charTop/charHeight passed through ────────────────────────
it('accepts charTop and charHeight params without error', () => {
const container = document.createElement('div');
expect(() => renderOverlay(container, makeParams({
lines: ['test'],
charTop: 2,
charHeight: 14,
}))).not.toThrow();
expect(container.children.length).toBeGreaterThan(0);
});
// ─── Line div positioning regression ──────────────────────────
it('line div background color matches font.backgroundColor', () => {
const container = document.createElement('div');
const font: FontStyle = { ...FONT, backgroundColor: '#1a1a1a' };
renderOverlay(container, makeParams({ lines: ['x'], font }));
const lineDiv = container.children[0] as HTMLDivElement;
// jsdom normalizes hex to rgb()
expect(lineDiv.style.backgroundColor).toBe('rgb(26, 26, 26)');
});
it('empty line produces line div with no spans', () => {
const container = document.createElement('div');
renderOverlay(container, makeParams({ lines: [''] }));
const lineDiv = container.children[0] as HTMLDivElement;
expect(lineDiv.children.length).toBe(0);
});
}); });
@@ -482,6 +482,131 @@ describe('ZerolagInputAddon', () => {
}); });
}); });
describe('setPrompt', () => {
it('changes prompt detection strategy', () => {
const { addon, mock } = tracked(['$ hello'], '$');
// Initially finds $ prompt
expect(addon.findPrompt()).toEqual({ row: 0, col: 0 });
expect(addon.readPromptText()).toBe('hello');
// Switch to > prompt — $ is no longer detected
mock.setLines(['> world']);
addon.setPrompt({ type: 'character', char: '>', offset: 2 });
expect(addon.findPrompt()).toEqual({ row: 0, col: 0 });
expect(addon.readPromptText()).toBe('world');
});
it('returns null when new prompt character not found', () => {
const { addon } = tracked(['$ hello'], '$');
addon.setPrompt({ type: 'character', char: '>', offset: 2 });
// Buffer still has $ not >
expect(addon.findPrompt()).toBeNull();
});
it('resets cached prompt position', () => {
const { addon } = tracked(['$ typed']);
addon.addChar('x');
expect(addon.state.promptPosition).not.toBeNull();
addon.setPrompt({ type: 'character', char: '>', offset: 2 });
// After setPrompt with no matching prompt, position resets
expect(addon.state.promptPosition).toBeNull();
});
it('re-renders when text exists', () => {
const { addon, mock } = tracked(['$ '], '$');
addon.addChar('h');
addon.addChar('i');
// Switch prompt strategy — should re-render with existing text
mock.setLines(['> ']);
addon.setPrompt({ type: 'character', char: '>', offset: 2 });
expect(addon.pendingText).toBe('hi');
expect(addon.hasPending).toBe(true);
});
it('does not crash when no text to render', () => {
const { addon } = tracked(['$ ']);
expect(() => addon.setPrompt({ type: 'character', char: '>', offset: 2 })).not.toThrow();
});
it('works with regex prompt strategy', () => {
const mock = createMockTerminal({ buffer: { lines: ['user@host:~$ cmd'] } });
const addon = new ZerolagInputAddon({
prompt: { type: 'character', char: '$', offset: 2 },
});
mock.terminal.loadAddon(addon);
cleanups.push(() => { addon.dispose(); mock.cleanup(); });
// Switch to regex
addon.setPrompt({ type: 'regex', pattern: /\$/, offset: 2 });
expect(addon.findPrompt()).not.toBeNull();
expect(addon.readPromptText()).toBe('cmd');
});
});
describe('tab switch with setPrompt (CLI switching)', () => {
it('full tab switch cycle: save state, setPrompt, restore', () => {
// Session A with $ prompt
const { addon, mock } = tracked(['$ '], '$');
addon.addChar('h');
addon.addChar('e');
addon.addChar('l');
addon.addChar('l');
addon.addChar('o');
expect(addon.pendingText).toBe('hello');
// Save state before tab switch
const pending = addon.pendingText;
const { count: flushedCount, text: flushedText } = addon.getFlushed();
const totalText = flushedText + pending;
const totalCount = flushedCount + pending.length;
addon.clear();
// Switch to Session B with > prompt
mock.setLines(['> ']);
addon.setPrompt({ type: 'character', char: '>', offset: 2 });
expect(addon.pendingText).toBe('');
expect(addon.hasPending).toBe(false);
// Switch back to Session A — restore state
mock.setLines(['$ ']);
addon.setPrompt({ type: 'character', char: '$', offset: 2 });
addon.suppressBufferDetection();
addon.setFlushed(totalCount, totalText, false);
expect(addon.getFlushed()).toEqual({ count: 5, text: 'hello' });
expect(addon.hasPending).toBe(true);
});
});
describe('overlay hides when prompt not found (ghost artifact fix)', () => {
it('overlay hides when prompt scrolls away', () => {
const { addon, mock } = tracked(['$ ']);
addon.addChar('x');
// Prompt visible, overlay should render
expect(addon.hasPending).toBe(true);
// Simulate prompt scrolling away (no $ in buffer)
mock.setLines(['just output', 'more output']);
addon.clear();
addon.addChar('y');
// Prompt not found — overlay hidden despite pending text
// (the addon renders but finds no prompt, so display stays none)
const state = addon.state;
expect(state.pendingText).toBe('y');
});
it('clear resets lastPromptPos to null', () => {
const { addon } = tracked(['$ ']);
addon.addChar('a');
expect(addon.state.promptPosition).not.toBeNull();
addon.clear();
expect(addon.state.promptPosition).toBeNull();
});
});
describe('methods before activate / after dispose', () => { describe('methods before activate / after dispose', () => {
it('addChar accumulates but does not crash before activate', () => { it('addChar accumulates but does not crash before activate', () => {
const addon = new ZerolagInputAddon(); const addon = new ZerolagInputAddon();