/** * Comprehensive CJK wide character test plan for PR #30. * * Tests all 7 items from the test plan: * 1. Chinese text input — no character overlap * 2. CJK text renders with correct double-width spacing * 3. Japanese (こんにちは) and Korean (안녕하세요) input * 4. Cursor positions correctly after CJK characters * 5. Long CJK input wraps at correct column boundary * 6. Existing ASCII input is unaffected * 7. Teammate terminal panels render CJK correctly (app.js embedded copy) */ import { describe, it, expect, afterEach } from 'vitest'; import { renderOverlay, charCellWidth, stringCellWidth } from '../src/overlay-renderer.js'; import type { RenderParams, FontStyle } from '../src/types.js'; import { createMockTerminal } from './helpers.js'; import { ZerolagInputAddon } from '../src/zerolag-input-addon.js'; // ─── Shared fixtures ───────────────────────────────────────────────── const FONT: FontStyle = { fontFamily: 'monospace', fontSize: '14px', fontWeight: 'normal', color: '#eeeeee', backgroundColor: '#0d0d0d', letterSpacing: '', }; function makeParams(overrides: Partial = {}): RenderParams { return { lines: ['hello'], startCol: 2, totalCols: 80, cellW: 10, cellH: 17, charTop: 2, charHeight: 14, promptRow: 0, font: FONT, showCursor: true, cursorColor: '#e0e0e0', ...overrides, }; } /** Extract span data from a rendered line div */ function getSpans(lineDiv: HTMLDivElement) { const spans: { text: string; left: number; width: number }[] = []; for (let i = 0; i < lineDiv.children.length; i++) { const span = lineDiv.children[i] as HTMLSpanElement; spans.push({ text: span.textContent || '', left: parseFloat(span.style.left), width: parseFloat(span.style.width), }); } return spans; } // ─── Addon setup helpers ───────────────────────────────────────────── let cleanups: (() => void)[] = []; afterEach(() => { for (const fn of cleanups) fn(); cleanups = []; }); function tracked(lines: string[] = ['$ '], promptChar = '$') { const mock = createMockTerminal({ buffer: { lines }, cols: 80 }); const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: promptChar, offset: 2 }, }); mock.terminal.loadAddon(addon); cleanups.push(() => { addon.dispose(); mock.cleanup(); }); return { addon, mock }; } // ═══════════════════════════════════════════════════════════════════════ // TEST 1: Chinese text input — no character overlap // ═══════════════════════════════════════════════════════════════════════ describe('Test 1: Chinese text — no character overlap', () => { it('consecutive Chinese characters have contiguous non-overlapping spans', () => { const container = document.createElement('div'); const text = '你好世界'; renderOverlay(container, makeParams({ lines: [text], cellW: 10 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); expect(spans.length).toBe(4); // Each span: left = previous span's (left + width), width = 20px (2 cells) for (let i = 0; i < spans.length; i++) { expect(spans[i].width).toBe(20); // 2 cells * 10px if (i > 0) { const expectedLeft = spans[i - 1].left + spans[i - 1].width; expect(spans[i].left).toBe(expectedLeft); } } }); it('Chinese sentence (simulated pinyin output) renders without gaps or overlaps', () => { const container = document.createElement('div'); const text = '我是一个测试'; renderOverlay(container, makeParams({ lines: [text], cellW: 8 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); expect(spans.length).toBe(6); // Verify contiguous positioning let expectedLeft = 0; for (const span of spans) { expect(span.left).toBe(expectedLeft); expect(span.width).toBe(16); // 2 * 8px expectedLeft += span.width; } // Total visual width should be 6 chars * 2 cells * 8px = 96px expect(expectedLeft).toBe(96); }); it('mixed Chinese + ASCII has no gaps between spans', () => { const container = document.createElement('div'); const text = 'hello你好world'; renderOverlay(container, makeParams({ lines: [text], cellW: 10 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); // h(10) e(10) l(10) l(10) o(10) 你(20) 好(20) w(10) o(10) r(10) l(10) d(10) expect(spans.length).toBe(12); // Verify contiguity: no gaps between any adjacent spans for (let i = 1; i < spans.length; i++) { const prevEnd = spans[i - 1].left + spans[i - 1].width; expect(spans[i].left).toBe(prevEnd); } }); it('addChar with Chinese characters accumulates correctly in addon', () => { const { addon } = tracked(); for (const ch of '你好世界') { addon.addChar(ch); } expect(addon.pendingText).toBe('你好世界'); expect(addon.hasPending).toBe(true); }); }); // ═══════════════════════════════════════════════════════════════════════ // TEST 2: CJK text renders with correct double-width spacing // ═══════════════════════════════════════════════════════════════════════ describe('Test 2: CJK double-width spacing', () => { it('charCellWidth returns 2 for CJK Unified Ideographs (0x4E00-0x9FFF)', () => { // Common Chinese characters const chars = '中文测试你好世界天地人'; for (const ch of chars) { expect(charCellWidth(null, ch)).toBe(2); } }); it('charCellWidth returns 2 for CJK Extension A (0x3400-0x4DBF)', () => { expect(charCellWidth(null, '\u3400')).toBe(2); // First Extension A char expect(charCellWidth(null, '\u4DB5')).toBe(2); // One of the last Extension A chars }); it('charCellWidth returns 2 for CJK Radicals (0x2E80-0x2EFF)', () => { expect(charCellWidth(null, '\u2E80')).toBe(2); // CJK Radical Repeat }); it('charCellWidth returns 2 for fullwidth ASCII forms (0xFF01-0xFF5E)', () => { expect(charCellWidth(null, '\uFF01')).toBe(2); // ! expect(charCellWidth(null, '\uFF21')).toBe(2); // A expect(charCellWidth(null, '\uFF41')).toBe(2); // a expect(charCellWidth(null, '\uFF10')).toBe(2); // 0 }); it('charCellWidth returns 2 for CJK Compatibility Ideographs (0xF900-0xFAFF)', () => { expect(charCellWidth(null, '\uF900')).toBe(2); }); it('stringCellWidth calculates correct visual width for CJK strings', () => { expect(stringCellWidth(null, '你好')).toBe(4); // 2 + 2 expect(stringCellWidth(null, '你好世界')).toBe(8); // 4 * 2 expect(stringCellWidth(null, 'hi你好')).toBe(6); // 1 + 1 + 2 + 2 expect(stringCellWidth(null, '你a好b')).toBe(6); // 2 + 1 + 2 + 1 expect(stringCellWidth(null, 'abc你好def')).toBe(10); // 3 + 4 + 3 }); it('CJK span widths are exactly 2 * cellW pixels', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['你'], cellW: 8.4 })); const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; expect(span.style.width).toBe('16.8px'); // 2 * 8.4 }); it('ASCII span widths remain 1 * cellW pixels', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['a'], cellW: 8.4 })); const lineDiv = container.children[0] as HTMLDivElement; const span = lineDiv.children[0] as HTMLSpanElement; expect(span.style.width).toBe('8.4px'); // 1 * 8.4 }); it('terminal unicode addon is preferred over fallback when available', () => { const mockTerminal = { unicode: { getStringCellWidth: (s: string) => { // Custom width: treat 'W' as wide return s === 'W' ? 2 : 1; }, }, } as any; expect(charCellWidth(mockTerminal, 'W')).toBe(2); expect(charCellWidth(mockTerminal, 'a')).toBe(1); // Fallback ignores terminal addon result for actual CJK expect(charCellWidth(null, '你')).toBe(2); }); }); // ═══════════════════════════════════════════════════════════════════════ // TEST 3: Japanese (こんにちは) and Korean (안녕하세요) input // ═══════════════════════════════════════════════════════════════════════ describe('Test 3: Japanese and Korean input', () => { describe('Japanese', () => { it('charCellWidth returns 2 for Hiragana (0x3040-0x309F)', () => { const hiragana = 'あいうえおかきくけこさしすせそ'; for (const ch of hiragana) { expect(charCellWidth(null, ch)).toBe(2); } }); it('charCellWidth returns 2 for Katakana (0x30A0-0x30FF)', () => { const katakana = 'アイウエオカキクケコサシスセソ'; for (const ch of katakana) { expect(charCellWidth(null, ch)).toBe(2); } }); it('Japanese greeting renders with correct span positions', () => { const container = document.createElement('div'); const text = 'こんにちは'; renderOverlay(container, makeParams({ lines: [text], cellW: 10 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); expect(spans.length).toBe(5); // こ(0,20) ん(20,20) に(40,20) ち(60,20) は(80,20) expect(spans[0]).toEqual({ text: 'こ', left: 0, width: 20 }); expect(spans[1]).toEqual({ text: 'ん', left: 20, width: 20 }); expect(spans[2]).toEqual({ text: 'に', left: 40, width: 20 }); expect(spans[3]).toEqual({ text: 'ち', left: 60, width: 20 }); expect(spans[4]).toEqual({ text: 'は', left: 80, width: 20 }); }); it('mixed Japanese + ASCII positions correctly', () => { const container = document.createElement('div'); const text = 'hello こんにちは'; renderOverlay(container, makeParams({ lines: [text], cellW: 10 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); // h(0) e(10) l(20) l(30) o(40) space(50) こ(60) ん(80) に(100) ち(120) は(140) expect(spans.length).toBe(11); expect(spans[5]).toEqual({ text: ' ', left: 50, width: 10 }); // space expect(spans[6]).toEqual({ text: 'こ', left: 60, width: 20 }); // first CJK after ASCII }); it('addon handles Japanese input via addChar', () => { const { addon } = tracked(); for (const ch of 'こんにちは') { addon.addChar(ch); } expect(addon.pendingText).toBe('こんにちは'); }); it('addon handles Japanese input via appendText (paste)', () => { const { addon } = tracked(); addon.appendText('こんにちは世界'); expect(addon.pendingText).toBe('こんにちは世界'); expect(addon.hasPending).toBe(true); }); it('stringCellWidth correct for Japanese greeting', () => { expect(stringCellWidth(null, 'こんにちは')).toBe(10); // 5 * 2 }); }); describe('Korean', () => { it('charCellWidth returns 2 for Hangul Syllables (0xAC00-0xD7A3)', () => { const hangul = '가나다라마바사아자차카타파하'; for (const ch of hangul) { expect(charCellWidth(null, ch)).toBe(2); } }); it('charCellWidth returns 2 for Hangul Jamo (0x1100-0x115F)', () => { expect(charCellWidth(null, '\u1100')).toBe(2); // ᄀ expect(charCellWidth(null, '\u1112')).toBe(2); // ᄒ }); it('Korean greeting renders with correct span positions', () => { const container = document.createElement('div'); const text = '안녕하세요'; renderOverlay(container, makeParams({ lines: [text], cellW: 10 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); expect(spans.length).toBe(5); expect(spans[0]).toEqual({ text: '안', left: 0, width: 20 }); expect(spans[1]).toEqual({ text: '녕', left: 20, width: 20 }); expect(spans[2]).toEqual({ text: '하', left: 40, width: 20 }); expect(spans[3]).toEqual({ text: '세', left: 60, width: 20 }); expect(spans[4]).toEqual({ text: '요', left: 80, width: 20 }); }); it('addon handles Korean input', () => { const { addon } = tracked(); addon.appendText('안녕하세요'); expect(addon.pendingText).toBe('안녕하세요'); expect(addon.hasPending).toBe(true); }); it('removeChar removes Korean characters one at a time', () => { const { addon } = tracked(); addon.appendText('안녕'); addon.removeChar(); expect(addon.pendingText).toBe('안'); addon.removeChar(); expect(addon.pendingText).toBe(''); }); it('stringCellWidth correct for Korean greeting', () => { expect(stringCellWidth(null, '안녕하세요')).toBe(10); // 5 * 2 }); }); describe('mixed CJK scripts', () => { it('Chinese + Japanese + Korean in one string', () => { const text = '你好こんにちは안녕'; expect(stringCellWidth(null, text)).toBe(18); // 9 chars * 2 each const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: [text], cellW: 10 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); // All 9 CJK chars: contiguous double-width spans expect(spans.length).toBe(9); let expectedLeft = 0; for (const span of spans) { expect(span.left).toBe(expectedLeft); expect(span.width).toBe(20); expectedLeft += 20; } }); }); }); // ═══════════════════════════════════════════════════════════════════════ // TEST 4: Cursor positions correctly after CJK characters // ═══════════════════════════════════════════════════════════════════════ describe('Test 4: Cursor positioning after CJK', () => { it('cursor after Chinese text on first line', () => { const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['你好世界'], startCol: 2, cellW: 10, showCursor: true, }) ); // cursorCol = startCol(2) + stringCellWidth('你好世界')(8) = 10 const cursor = container.children[container.children.length - 1] as HTMLSpanElement; expect(cursor.style.left).toBe('100px'); // 10 * 10px }); it('cursor after Japanese text on first line', () => { const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['こんにちは'], startCol: 2, cellW: 10, showCursor: true, }) ); // cursorCol = 2 + 10 = 12 const cursor = container.children[container.children.length - 1] as HTMLSpanElement; expect(cursor.style.left).toBe('120px'); }); it('cursor after Korean text on first line', () => { const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['안녕하세요'], startCol: 2, cellW: 10, showCursor: true, }) ); // cursorCol = 2 + 10 = 12 const cursor = container.children[container.children.length - 1] as HTMLSpanElement; expect(cursor.style.left).toBe('120px'); }); it('cursor after mixed ASCII + CJK', () => { const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['hi你好'], startCol: 3, cellW: 10, showCursor: true, }) ); // cursorCol = 3 + stringCellWidth('hi你好')(6) = 9 const cursor = container.children[container.children.length - 1] as HTMLSpanElement; expect(cursor.style.left).toBe('90px'); }); it('cursor on wrapped line after CJK text', () => { const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['你好世界', '再见'], startCol: 2, cellW: 10, cellH: 20, showCursor: true, }) ); // Last line is '再见', starts at col 0 (wrapped line), width = 4 const cursor = container.children[container.children.length - 1] as HTMLSpanElement; expect(cursor.style.left).toBe('40px'); // 0 + 4 = 4, * 10 = 40 expect(cursor.style.top).toBe('20px'); // row 1 * cellH }); it('cursor hidden when it would exceed totalCols', () => { const container = document.createElement('div'); // 4 CJK chars = 8 visual cols, startCol=73, cursorCol = 73 + 8 = 81 > 80 renderOverlay( container, makeParams({ lines: ['你好世界'], startCol: 73, totalCols: 80, cellW: 10, showCursor: true, }) ); // Should be 1 line div, no cursor span (cursor at col 81 >= totalCols 80) // Actually cursor check is cursorCol < totalCols, so at 81 it's hidden const children = container.children; // If cursor is rendered, last child would be a cursor span // With cursorCol 81 >= 80, cursor should NOT be rendered expect(children.length).toBe(1); // only line div }); }); // ═══════════════════════════════════════════════════════════════════════ // TEST 5: Long CJK input wraps at correct column boundary // ═══════════════════════════════════════════════════════════════════════ describe('Test 5: CJK line wrapping at column boundaries', () => { it('CJK chars wrap when they would overflow first line', () => { const container = document.createElement('div'); // totalCols=10, startCol=2 → firstLineCols=8 // Each CJK char = 2 cols → first line fits 4 chars (8 cols) // 5th char wraps to second line const text = '你好世界啊'; // 5 chars = 10 visual cols renderOverlay( container, makeParams({ lines: ['你好世界', '啊'], startCol: 2, totalCols: 10, cellW: 10, cellH: 20, }) ); expect(container.children.length).toBe(3); // 2 line divs + cursor const line1 = container.children[0] as HTMLDivElement; expect(line1.children.length).toBe(4); // 你好世界 expect(line1.style.left).toBe('20px'); // startCol * cellW const line2 = container.children[1] as HTMLDivElement; expect(line2.children.length).toBe(1); // 啊 expect(line2.style.left).toBe('0px'); // wrapped line starts at col 0 expect(line2.style.top).toBe('20px'); // second row }); it('CJK char that would partially overflow stays on next line', () => { // totalCols=9, startCol=2 → firstLineCols=7 // CJK chars are 2-wide. 3 chars = 6 cols (fits). 4th char = 8 cols > 7. Wraps. const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['你好世', '界'], startCol: 2, totalCols: 9, cellW: 10, }) ); const line1 = container.children[0] as HTMLDivElement; expect(line1.children.length).toBe(3); // 3 CJK chars fit (6 cols <= 7) const line2 = container.children[1] as HTMLDivElement; expect(line2.children.length).toBe(1); // 界 wraps }); it('addon _render splits CJK text into visual lines correctly', () => { // Narrow terminal: 10 cols, startCol=2 → firstLineCols=8 → 4 CJK chars const mock = createMockTerminal({ buffer: { lines: ['$ '] }, cols: 10, }); const addon = new ZerolagInputAddon({ prompt: { type: 'character', char: '$', offset: 2 }, }); mock.terminal.loadAddon(addon); cleanups.push(() => { addon.dispose(); mock.cleanup(); }); // Type 6 CJK chars (12 visual cols) for (const ch of '你好世界再见') { addon.addChar(ch); } expect(addon.pendingText).toBe('你好世界再见'); expect(addon.hasPending).toBe(true); }); it('mixed ASCII + CJK wraps correctly', () => { const container = document.createElement('div'); // totalCols=10, startCol=2 → firstLineCols=8 // 'ab' = 2 cols, '你好' = 4 cols, 'cd' = 2 cols → total 8 cols (fits line 1) // '世' = 2 cols → wraps to line 2 renderOverlay( container, makeParams({ lines: ['ab你好cd', '世'], startCol: 2, totalCols: 10, cellW: 10, }) ); const line1 = container.children[0] as HTMLDivElement; expect(line1.children.length).toBe(6); // a,b,你,好,c,d const line2 = container.children[1] as HTMLDivElement; expect(line2.children.length).toBe(1); // 世 }); it('odd column count: CJK char does not split across boundary', () => { // totalCols=11, startCol=2 → firstLineCols=9 // 4 CJK chars = 8 cols (fits). 5th CJK = 10 cols > 9 (wraps). // 1 empty column remains on first line (CJK can't fit in 1 col). const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['你好世界', '啊'], startCol: 2, totalCols: 11, cellW: 10, }) ); const line1 = container.children[0] as HTMLDivElement; expect(line1.children.length).toBe(4); // 4 chars = 8 cols, 5th would need 10 > 9 const line2 = container.children[1] as HTMLDivElement; expect(line2.children.length).toBe(1); }); it('CJK fills entire wrapped line', () => { const container = document.createElement('div'); // totalCols=6, startCol=0 → firstLineCols=6 // 3 CJK chars = 6 cols (fills line 1), next 3 fill line 2 renderOverlay( container, makeParams({ lines: ['你好世', '界再见'], startCol: 0, totalCols: 6, cellW: 10, }) ); const line1 = container.children[0] as HTMLDivElement; expect(line1.children.length).toBe(3); const line2 = container.children[1] as HTMLDivElement; expect(line2.children.length).toBe(3); }); }); // ═══════════════════════════════════════════════════════════════════════ // TEST 6: Existing ASCII input is unaffected // ═══════════════════════════════════════════════════════════════════════ describe('Test 6: ASCII input — no regression', () => { it('ASCII characters still get 1-cell-wide spans', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['abcdef'], cellW: 10 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); for (const span of spans) { expect(span.width).toBe(10); // 1 * cellW } }); it('ASCII span positions are sequential at 1-cell intervals', () => { const container = document.createElement('div'); renderOverlay(container, makeParams({ lines: ['xyz'], cellW: 8.4 })); const lineDiv = container.children[0] as HTMLDivElement; const spans = getSpans(lineDiv); expect(spans[0].left).toBe(0); expect(spans[1].left).toBeCloseTo(8.4, 5); expect(spans[2].left).toBeCloseTo(16.8, 5); }); it('ASCII cursor positions at correct column', () => { const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['hello'], startCol: 3, cellW: 10, showCursor: true, }) ); // cursor at startCol(3) + 5 = 8 const cursor = container.children[container.children.length - 1] as HTMLSpanElement; expect(cursor.style.left).toBe('80px'); }); it('charCellWidth returns 1 for all printable ASCII', () => { for (let code = 32; code < 127; code++) { const ch = String.fromCharCode(code); expect(charCellWidth(null, ch)).toBe(1); } }); it('stringCellWidth equals length for pure ASCII', () => { expect(stringCellWidth(null, 'hello world')).toBe(11); expect(stringCellWidth(null, 'test123!@#')).toBe(10); expect(stringCellWidth(null, '')).toBe(0); }); it('ASCII multi-line wrapping is unaffected', () => { const container = document.createElement('div'); renderOverlay( container, makeParams({ lines: ['abcde', 'fgh'], startCol: 5, totalCols: 10, cellW: 10, cellH: 20, }) ); expect(container.children.length).toBe(3); // 2 lines + cursor const line1 = container.children[0] as HTMLDivElement; const line2 = container.children[1] as HTMLDivElement; expect(line1.children.length).toBe(5); expect(line2.children.length).toBe(3); expect(line1.style.left).toBe('50px'); // startCol * cellW expect(line2.style.left).toBe('0px'); }); it('addon addChar/removeChar/clear work for ASCII', () => { const { addon } = tracked(); addon.addChar('h'); addon.addChar('e'); addon.addChar('l'); addon.addChar('l'); addon.addChar('o'); expect(addon.pendingText).toBe('hello'); addon.removeChar(); expect(addon.pendingText).toBe('hell'); addon.clear(); expect(addon.pendingText).toBe(''); expect(addon.hasPending).toBe(false); }); }); // ═══════════════════════════════════════════════════════════════════════ // TEST 7: Teammate terminal panels render CJK correctly // // Teammate panels share the global xterm-zerolag-input.js vendor bundle // (loaded via