mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user