mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Add charCellWidth/stringCellWidth helpers for Unicode-aware width detection, fix makeLine to use for...of iteration with visual column positioning, and fix line splitting in _render to use visual column widths instead of string length. CJK/fullwidth characters now correctly occupy 2 cell widths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
728 lines
24 KiB
TypeScript
728 lines
24 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import { createMockTerminal } from './helpers.js';
|
|
import { ZerolagInputAddon } from '../src/zerolag-input-addon.js';
|
|
|
|
function setup(lines: string[] = ['$ '], promptChar = '$') {
|
|
const mock = createMockTerminal({ buffer: { lines } });
|
|
const addon = new ZerolagInputAddon({
|
|
prompt: { type: 'character', char: promptChar, offset: 2 },
|
|
});
|
|
mock.terminal.loadAddon(addon);
|
|
return { addon, mock };
|
|
}
|
|
|
|
let cleanups: (() => void)[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const fn of cleanups) fn();
|
|
cleanups = [];
|
|
});
|
|
|
|
function tracked(lines?: string[], promptChar?: string) {
|
|
const result = setup(lines, promptChar);
|
|
cleanups.push(() => {
|
|
result.addon.dispose();
|
|
result.mock.cleanup();
|
|
});
|
|
return result;
|
|
}
|
|
|
|
describe('ZerolagInputAddon', () => {
|
|
describe('lifecycle', () => {
|
|
it('creates overlay element in .xterm-screen', () => {
|
|
const { addon, mock } = tracked();
|
|
const screen = mock.terminal.element.querySelector('.xterm-screen');
|
|
expect(screen!.children.length).toBeGreaterThan(0);
|
|
const overlay = screen!.lastElementChild as HTMLDivElement;
|
|
expect(overlay.style.zIndex).toBe('7');
|
|
expect(overlay.style.display).toBe('none');
|
|
addon.dispose();
|
|
});
|
|
|
|
it('dispose removes overlay from DOM', () => {
|
|
const { addon, mock } = tracked();
|
|
const screen = mock.terminal.element.querySelector('.xterm-screen')!;
|
|
const before = screen.children.length;
|
|
addon.dispose();
|
|
expect(screen.children.length).toBe(before - 1);
|
|
});
|
|
});
|
|
|
|
describe('addChar / pendingText', () => {
|
|
it('adds characters to pendingText', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('a');
|
|
addon.addChar('b');
|
|
addon.addChar('c');
|
|
expect(addon.pendingText).toBe('abc');
|
|
});
|
|
|
|
it('hasPending is true when text exists', () => {
|
|
const { addon } = tracked();
|
|
expect(addon.hasPending).toBe(false);
|
|
addon.addChar('x');
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('appendText', () => {
|
|
it('appends multiple characters (paste)', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('h');
|
|
addon.appendText('ello');
|
|
expect(addon.pendingText).toBe('hello');
|
|
});
|
|
|
|
it('ignores empty string', () => {
|
|
const { addon } = tracked();
|
|
addon.appendText('');
|
|
expect(addon.pendingText).toBe('');
|
|
expect(addon.hasPending).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('removeChar', () => {
|
|
it('returns "pending" when removing from pendingText', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('a');
|
|
addon.addChar('b');
|
|
const source = addon.removeChar();
|
|
expect(source).toBe('pending');
|
|
expect(addon.pendingText).toBe('a');
|
|
});
|
|
|
|
it('returns false when nothing to remove', () => {
|
|
const { addon } = tracked();
|
|
expect(addon.removeChar()).toBe(false);
|
|
});
|
|
|
|
it('returns "flushed" when removing from flushed text', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(3, 'abc');
|
|
const source = addon.removeChar();
|
|
expect(source).toBe('flushed');
|
|
expect(addon.getFlushed().count).toBe(2);
|
|
expect(addon.getFlushed().text).toBe('ab');
|
|
});
|
|
|
|
it('removes pending before flushed', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(2, 'ab');
|
|
addon.addChar('c');
|
|
const source = addon.removeChar();
|
|
expect(source).toBe('pending');
|
|
expect(addon.pendingText).toBe('');
|
|
expect(addon.getFlushed().count).toBe(2); // flushed unchanged
|
|
});
|
|
|
|
it('hides overlay when both pending and flushed become empty', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('x');
|
|
addon.removeChar();
|
|
expect(addon.hasPending).toBe(false);
|
|
});
|
|
|
|
it('detects buffer text and removes from it when both empty', () => {
|
|
const { addon } = tracked(['$ hello']);
|
|
// Both pending and flushed are empty, but buffer has text
|
|
const source = addon.removeChar();
|
|
expect(source).toBe('flushed');
|
|
// "hello" (5 chars) detected, then one removed = 4
|
|
expect(addon.getFlushed().count).toBe(4);
|
|
expect(addon.getFlushed().text).toBe('hell');
|
|
});
|
|
|
|
it('returns false on empty prompt with no buffer text', () => {
|
|
const { addon } = tracked(['$ ']);
|
|
expect(addon.removeChar()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('clear', () => {
|
|
it('resets all state', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(3, 'abc');
|
|
addon.addChar('d');
|
|
addon.clear();
|
|
|
|
expect(addon.pendingText).toBe('');
|
|
expect(addon.getFlushed().count).toBe(0);
|
|
expect(addon.getFlushed().text).toBe('');
|
|
expect(addon.hasPending).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('flushed text', () => {
|
|
it('setFlushed stores count and text', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(5, 'hello');
|
|
expect(addon.getFlushed()).toEqual({ count: 5, text: 'hello' });
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
|
|
it('setFlushed with render=false does not render', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(3, 'abc', false);
|
|
expect(addon.getFlushed()).toEqual({ count: 3, text: 'abc' });
|
|
expect(addon.hasPending).toBe(true);
|
|
// Overlay should still be hidden (no render triggered)
|
|
expect(addon.state.visible).toBe(false);
|
|
});
|
|
|
|
it('setFlushed with render=false prevents stale column lock', () => {
|
|
const { addon, mock } = tracked(['$ old session text']);
|
|
// Simulate: clear overlay, then restore flushed WITHOUT render
|
|
addon.clear();
|
|
addon.setFlushed(5, 'hello', false);
|
|
// lastPromptPos should still be null (no render happened)
|
|
expect(addon.state.promptPosition).toBeNull();
|
|
// Now simulate buffer load and rerender
|
|
mock.setLines(['$ ']);
|
|
addon.rerender();
|
|
// Prompt should be freshly scanned, no stale column lock
|
|
const pos = addon.state.promptPosition;
|
|
expect(pos).not.toBeNull();
|
|
});
|
|
|
|
it('clearFlushed resets flushed state', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(3, 'abc');
|
|
addon.clearFlushed();
|
|
expect(addon.getFlushed()).toEqual({ count: 0, text: '' });
|
|
});
|
|
|
|
it('clearFlushed preserves pending text', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(3, 'abc');
|
|
addon.addChar('d');
|
|
addon.clearFlushed();
|
|
expect(addon.pendingText).toBe('d');
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('state snapshot', () => {
|
|
it('returns current state', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(2, 'hi');
|
|
addon.addChar('!');
|
|
|
|
const state = addon.state;
|
|
expect(state.pendingText).toBe('!');
|
|
expect(state.flushedLength).toBe(2);
|
|
expect(state.flushedText).toBe('hi');
|
|
});
|
|
|
|
it('state is read-only copy', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('a');
|
|
const s1 = addon.state;
|
|
addon.addChar('b');
|
|
const s2 = addon.state;
|
|
expect(s1.pendingText).toBe('a');
|
|
expect(s2.pendingText).toBe('ab');
|
|
});
|
|
});
|
|
|
|
describe('prompt detection', () => {
|
|
it('findPrompt returns position for character prompt', () => {
|
|
const { addon } = tracked(['$ hello world']);
|
|
const pos = addon.findPrompt();
|
|
expect(pos).toEqual({ row: 0, col: 0 });
|
|
});
|
|
|
|
it('findPrompt returns null when no prompt', () => {
|
|
const { addon } = tracked(['no prompt here']);
|
|
const pos = addon.findPrompt();
|
|
expect(pos).toBeNull();
|
|
});
|
|
|
|
it('readPromptText reads text after prompt', () => {
|
|
const { addon } = tracked(['$ hello world']);
|
|
const text = addon.readPromptText();
|
|
expect(text).toBe('hello world');
|
|
});
|
|
|
|
it('readPromptText returns null when no prompt', () => {
|
|
const { addon } = tracked(['no prompt']);
|
|
const text = addon.readPromptText();
|
|
expect(text).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buffer detection', () => {
|
|
it('detectBufferText picks up existing text after prompt', () => {
|
|
const { addon } = tracked(['$ existing text']);
|
|
const text = addon.detectBufferText();
|
|
expect(text).toBe('existing text');
|
|
expect(addon.getFlushed().count).toBe(13);
|
|
expect(addon.getFlushed().text).toBe('existing text');
|
|
});
|
|
|
|
it('detectBufferText returns null for empty prompt', () => {
|
|
const { addon } = tracked(['$ ']);
|
|
const text = addon.detectBufferText();
|
|
expect(text).toBeNull();
|
|
});
|
|
|
|
it('detectBufferText is guarded (only runs once)', () => {
|
|
const { addon } = tracked(['$ text']);
|
|
addon.detectBufferText();
|
|
addon.clearFlushed(); // clear what was detected
|
|
|
|
// Should not detect again (guard is set)
|
|
const text = addon.detectBufferText();
|
|
expect(text).toBeNull();
|
|
});
|
|
|
|
it('resetBufferDetection allows re-detection', () => {
|
|
const { addon } = tracked(['$ text']);
|
|
addon.detectBufferText();
|
|
addon.clearFlushed();
|
|
addon.resetBufferDetection();
|
|
|
|
const text = addon.detectBufferText();
|
|
expect(text).toBe('text');
|
|
});
|
|
|
|
it('suppressBufferDetection prevents detection', () => {
|
|
const { addon } = tracked(['$ some UI text']);
|
|
addon.suppressBufferDetection();
|
|
|
|
// Detection should be blocked
|
|
const text = addon.detectBufferText();
|
|
expect(text).toBeNull();
|
|
expect(addon.getFlushed().count).toBe(0);
|
|
});
|
|
|
|
it('suppressBufferDetection also blocks implicit detection in addChar', () => {
|
|
const { addon } = tracked(['$ ink status bar']);
|
|
addon.suppressBufferDetection();
|
|
|
|
// addChar calls _detectBufferText internally on first keystroke
|
|
addon.addChar('x');
|
|
// Should have only 'x' pending, NOT the buffer text as flushed
|
|
expect(addon.pendingText).toBe('x');
|
|
expect(addon.getFlushed().count).toBe(0);
|
|
});
|
|
|
|
it('suppressBufferDetection also blocks detection in removeChar cascade', () => {
|
|
const { addon } = tracked(['$ buffer text']);
|
|
addon.suppressBufferDetection();
|
|
|
|
// removeChar step 3 calls _detectBufferText — should be blocked
|
|
expect(addon.removeChar()).toBe(false);
|
|
expect(addon.getFlushed().count).toBe(0);
|
|
});
|
|
|
|
it('undoDetection clears flushed and re-enables detection', () => {
|
|
const { addon } = tracked(['$ completed']);
|
|
// Detect buffer text (simulates tab completion detection)
|
|
addon.detectBufferText();
|
|
expect(addon.getFlushed().count).toBe(9); // 'completed'
|
|
|
|
// Undo because it matched baseline (no real completion)
|
|
addon.undoDetection();
|
|
expect(addon.getFlushed().count).toBe(0);
|
|
expect(addon.getFlushed().text).toBe('');
|
|
|
|
// Detection should work again (guard reset)
|
|
const text = addon.detectBufferText();
|
|
expect(text).toBe('completed');
|
|
});
|
|
|
|
it('undoDetection does not hide overlay or clear pending', () => {
|
|
const { addon } = tracked(['$ buffer']);
|
|
addon.detectBufferText(); // sets flushed
|
|
addon.addChar('x'); // adds pending on top
|
|
|
|
addon.undoDetection();
|
|
// Flushed cleared, but pending preserved
|
|
expect(addon.pendingText).toBe('x');
|
|
expect(addon.getFlushed().count).toBe(0);
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
|
|
it('clear resets suppression (re-enables detection)', () => {
|
|
const { addon } = tracked(['$ text']);
|
|
addon.suppressBufferDetection();
|
|
addon.clear(); // resets _bufferDetectDone to false
|
|
|
|
const text = addon.detectBufferText();
|
|
expect(text).toBe('text');
|
|
});
|
|
|
|
it('clear resets buffer detection guard', () => {
|
|
const { addon } = tracked(['$ text']);
|
|
addon.detectBufferText();
|
|
addon.clear();
|
|
|
|
// After clear, detection should work again
|
|
const text = addon.detectBufferText();
|
|
expect(text).toBe('text');
|
|
});
|
|
});
|
|
|
|
describe('custom prompt configurations', () => {
|
|
it('works with > prompt character', () => {
|
|
const { addon } = tracked(['> hello'], '>');
|
|
const text = addon.readPromptText();
|
|
expect(text).toBe('hello');
|
|
});
|
|
|
|
it('works with Unicode prompt', () => {
|
|
const mock = createMockTerminal({ buffer: { lines: ['\u276f hello'] } });
|
|
const addon = new ZerolagInputAddon({
|
|
prompt: { type: 'character', char: '\u276f', offset: 2 },
|
|
});
|
|
mock.terminal.loadAddon(addon);
|
|
cleanups.push(() => {
|
|
addon.dispose();
|
|
mock.cleanup();
|
|
});
|
|
|
|
const text = addon.readPromptText();
|
|
expect(text).toBe('hello');
|
|
});
|
|
});
|
|
|
|
describe('state.visible', () => {
|
|
it('is false before activate', () => {
|
|
const addon = new ZerolagInputAddon();
|
|
expect(addon.state.visible).toBe(false);
|
|
// No cleanup needed — never activated
|
|
});
|
|
|
|
it('is false after dispose', () => {
|
|
const { addon, mock } = tracked();
|
|
addon.addChar('x');
|
|
addon.dispose();
|
|
expect(addon.state.visible).toBe(false);
|
|
mock.cleanup();
|
|
});
|
|
});
|
|
|
|
describe('rerender / refreshFont', () => {
|
|
it('rerender does not crash when no text', () => {
|
|
const { addon } = tracked();
|
|
expect(() => addon.rerender()).not.toThrow();
|
|
});
|
|
|
|
it('refreshFont does not crash', () => {
|
|
const { addon } = tracked();
|
|
expect(() => addon.refreshFont()).not.toThrow();
|
|
});
|
|
|
|
it('rerender re-renders when hasPending', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('x');
|
|
expect(() => addon.rerender()).not.toThrow();
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
|
|
it('refreshFont re-renders flushed-only text', () => {
|
|
const { addon } = tracked();
|
|
addon.setFlushed(3, 'abc');
|
|
expect(() => addon.refreshFont()).not.toThrow();
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('tab-switch save/restore pattern', () => {
|
|
it('save pending + flushed, restore as flushed', () => {
|
|
const { addon } = tracked(['$ ']);
|
|
|
|
// User types some text
|
|
addon.addChar('h');
|
|
addon.addChar('i');
|
|
expect(addon.pendingText).toBe('hi');
|
|
|
|
// Tab switch: save state
|
|
const pending = addon.pendingText;
|
|
const { count: flushedCount, text: flushedText } = addon.getFlushed();
|
|
const totalCount = flushedCount + pending.length;
|
|
const totalText = flushedText + pending;
|
|
addon.clear();
|
|
|
|
// Simulate PTY send of pending text (app would do this)
|
|
// ...
|
|
|
|
// Tab switch back: restore as flushed (text is now in PTY)
|
|
addon.suppressBufferDetection(); // prevent false Ink detection
|
|
addon.setFlushed(totalCount, totalText, false); // no render — buffer not loaded yet
|
|
expect(addon.getFlushed()).toEqual({ count: 2, text: 'hi' });
|
|
expect(addon.hasPending).toBe(true); // has flushed content
|
|
|
|
// Backspace should return 'flushed' (text is in PTY)
|
|
const source = addon.removeChar();
|
|
expect(source).toBe('flushed');
|
|
expect(addon.getFlushed().count).toBe(1);
|
|
});
|
|
|
|
it('save with existing flushed + pending', () => {
|
|
const { addon } = tracked(['$ ']);
|
|
|
|
// Set flushed from previous restore, then user types more
|
|
addon.setFlushed(3, 'abc');
|
|
addon.addChar('d');
|
|
addon.addChar('e');
|
|
|
|
// Save state
|
|
const pending = addon.pendingText;
|
|
const { count, text } = addon.getFlushed();
|
|
expect(pending).toBe('de');
|
|
expect(count).toBe(3);
|
|
expect(text).toBe('abc');
|
|
|
|
// Combined for restore
|
|
const totalCount = count + pending.length;
|
|
const totalText = text + pending;
|
|
addon.clear();
|
|
|
|
// Restore
|
|
addon.setFlushed(totalCount, totalText);
|
|
expect(addon.getFlushed()).toEqual({ count: 5, text: 'abcde' });
|
|
});
|
|
});
|
|
|
|
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', () => {
|
|
it('addChar accumulates but does not crash before activate', () => {
|
|
const addon = new ZerolagInputAddon();
|
|
addon.addChar('a');
|
|
addon.addChar('b');
|
|
expect(addon.pendingText).toBe('ab');
|
|
expect(addon.hasPending).toBe(true);
|
|
// No dispose needed — never activated, no DOM
|
|
});
|
|
|
|
it('removeChar works on pending text before activate', () => {
|
|
const addon = new ZerolagInputAddon();
|
|
addon.addChar('x');
|
|
expect(addon.removeChar()).toBe('pending');
|
|
expect(addon.pendingText).toBe('');
|
|
});
|
|
|
|
it('clear works before activate', () => {
|
|
const addon = new ZerolagInputAddon();
|
|
addon.addChar('x');
|
|
addon.clear();
|
|
expect(addon.pendingText).toBe('');
|
|
expect(addon.hasPending).toBe(false);
|
|
});
|
|
|
|
it('all methods safe after dispose', () => {
|
|
const { addon, mock } = tracked();
|
|
addon.dispose();
|
|
expect(() => addon.addChar('x')).not.toThrow();
|
|
expect(() => addon.removeChar()).not.toThrow();
|
|
expect(() => addon.clear()).not.toThrow();
|
|
expect(() => addon.rerender()).not.toThrow();
|
|
expect(() => addon.refreshFont()).not.toThrow();
|
|
expect(addon.findPrompt()).toBeNull();
|
|
expect(addon.readPromptText()).toBeNull();
|
|
expect(addon.detectBufferText()).toBeNull();
|
|
mock.cleanup();
|
|
});
|
|
});
|
|
|
|
describe('CJK wide character support', () => {
|
|
it('addChar works with CJK characters', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('你');
|
|
addon.addChar('好');
|
|
expect(addon.pendingText).toBe('你好');
|
|
});
|
|
|
|
it('appendText works with CJK characters', () => {
|
|
const { addon } = tracked();
|
|
addon.appendText('こんにちは');
|
|
expect(addon.pendingText).toBe('こんにちは');
|
|
});
|
|
|
|
it('removeChar removes CJK characters correctly', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('你');
|
|
addon.addChar('好');
|
|
addon.removeChar();
|
|
expect(addon.pendingText).toBe('你');
|
|
});
|
|
|
|
it('mixed ASCII and CJK renders without error', () => {
|
|
const { addon } = tracked();
|
|
addon.addChar('h');
|
|
addon.addChar('i');
|
|
addon.addChar('你');
|
|
addon.addChar('好');
|
|
expect(addon.pendingText).toBe('hi你好');
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
|
|
it('CJK line wrapping accounts for double-width', () => {
|
|
// With 10 cols and startCol=2, first line has 8 available cols
|
|
// Each CJK char takes 2 cols, so 4 CJK chars fill the first line
|
|
const { addon } = tracked(['$ '], '$');
|
|
// Type 5 CJK chars — should overflow first line
|
|
for (const ch of '你好世界啊') {
|
|
addon.addChar(ch);
|
|
}
|
|
expect(addon.pendingText).toBe('你好世界啊');
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
|
|
it('Korean text renders without error', () => {
|
|
const { addon } = tracked();
|
|
addon.appendText('안녕하세요');
|
|
expect(addon.pendingText).toBe('안녕하세요');
|
|
expect(addon.hasPending).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('addChar implicit buffer detection', () => {
|
|
it('first keystroke detects existing buffer text as flushed', () => {
|
|
const { addon } = tracked(['$ existing']);
|
|
// First addChar should trigger _detectBufferText
|
|
addon.addChar('!');
|
|
expect(addon.pendingText).toBe('!');
|
|
expect(addon.getFlushed().count).toBe(8); // 'existing'
|
|
expect(addon.getFlushed().text).toBe('existing');
|
|
});
|
|
|
|
it('second keystroke does NOT re-detect', () => {
|
|
const { addon } = tracked(['$ existing']);
|
|
addon.addChar('a');
|
|
addon.addChar('b');
|
|
// Should NOT detect again — flushed from first char remains
|
|
expect(addon.pendingText).toBe('ab');
|
|
expect(addon.getFlushed().count).toBe(8); // still 'existing'
|
|
});
|
|
});
|
|
});
|