From d79e25d5d86f6dcca5999346f1924c17f9997ae8 Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 4 Mar 2026 13:29:23 +0100 Subject: [PATCH] test: comprehensive CJK wide character test plan for PR #30 Covers all 7 test plan items: Chinese overlap prevention, double-width spacing, Japanese/Korean input, cursor positioning, line wrapping at column boundaries, ASCII regression, and teammate terminal panels. Co-Authored-By: Claude Opus 4.6 --- .../test/cjk-testplan.test.ts | 843 ++++++++++++++++++ 1 file changed, 843 insertions(+) create mode 100644 packages/xterm-zerolag-input/test/cjk-testplan.test.ts diff --git a/packages/xterm-zerolag-input/test/cjk-testplan.test.ts b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts new file mode 100644 index 00000000..57b815e9 --- /dev/null +++ b/packages/xterm-zerolag-input/test/cjk-testplan.test.ts @@ -0,0 +1,843 @@ +/** + * 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