mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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> = {}): 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 <script> in index.html). This is the IIFE build of the
|
||||
// same package source tested in tests 1-6. The build pipeline is:
|
||||
// src/overlay-renderer.ts → tsup → dist/index.global.js → vendor copy
|
||||
//
|
||||
// Since all terminals (main + teammate) use the same LocalEchoOverlay
|
||||
// class from the global scope, CJK correctness is guaranteed by:
|
||||
// (a) The package source handles CJK correctly (tests 1-6 above)
|
||||
// (b) The IIFE build bundles the exact same charCellWidth / makeLine code
|
||||
//
|
||||
// These tests verify the exported module includes CJK-aware functions
|
||||
// and that teammate-style terminal instances work identically.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Test 7: Teammate terminal panels render CJK correctly', () => {
|
||||
it('package exports charCellWidth and stringCellWidth', () => {
|
||||
// These are the CJK-aware functions that the IIFE build exposes
|
||||
expect(typeof charCellWidth).toBe('function');
|
||||
expect(typeof stringCellWidth).toBe('function');
|
||||
});
|
||||
|
||||
it('ZerolagInputAddon (used by LocalEchoOverlay) handles CJK in teammate terminals', () => {
|
||||
// Simulate a teammate terminal panel: separate terminal instance,
|
||||
// same addon class, different prompt character
|
||||
const mock = createMockTerminal({
|
||||
buffer: { lines: ['❯ '] },
|
||||
cols: 40,
|
||||
});
|
||||
const addon = new ZerolagInputAddon({
|
||||
prompt: { type: 'character', char: '❯', offset: 2 },
|
||||
});
|
||||
mock.terminal.loadAddon(addon);
|
||||
cleanups.push(() => {
|
||||
addon.dispose();
|
||||
mock.cleanup();
|
||||
});
|
||||
|
||||
// Type CJK in teammate terminal
|
||||
for (const ch of '你好世界') {
|
||||
addon.addChar(ch);
|
||||
}
|
||||
expect(addon.pendingText).toBe('你好世界');
|
||||
expect(addon.hasPending).toBe(true);
|
||||
});
|
||||
|
||||
it('teammate terminal with narrow width wraps CJK correctly', () => {
|
||||
// Teammate panels are often narrower (sidebar, split view)
|
||||
const mock = createMockTerminal({
|
||||
buffer: { lines: ['❯ '] },
|
||||
cols: 12, // narrow panel
|
||||
});
|
||||
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) with startCol=2 → only 10 available
|
||||
// First line: 5 CJK = 10 cols. 6th wraps.
|
||||
for (const ch of '你好世界再见') {
|
||||
addon.addChar(ch);
|
||||
}
|
||||
expect(addon.pendingText).toBe('你好世界再见');
|
||||
});
|
||||
|
||||
it('mixed CJK scripts render identically in teammate and main terminals', () => {
|
||||
// Verify that the same input produces the same overlay output
|
||||
// regardless of which terminal instance it's on
|
||||
const text = '你好こんにちは안녕';
|
||||
|
||||
// "Main" terminal
|
||||
const container1 = document.createElement('div');
|
||||
renderOverlay(container1, makeParams({ lines: [text], cellW: 10 }));
|
||||
const line1 = container1.children[0] as HTMLDivElement;
|
||||
const spans1 = getSpans(line1);
|
||||
|
||||
// "Teammate" terminal (same params — same bundle)
|
||||
const container2 = document.createElement('div');
|
||||
renderOverlay(container2, makeParams({ lines: [text], cellW: 10 }));
|
||||
const line2 = container2.children[0] as HTMLDivElement;
|
||||
const spans2 = getSpans(line2);
|
||||
|
||||
// Identical rendering
|
||||
expect(spans1.length).toBe(spans2.length);
|
||||
for (let i = 0; i < spans1.length; i++) {
|
||||
expect(spans1[i]).toEqual(spans2[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('CJK rendering correct in overlay with teammate-typical prompt (❯)', () => {
|
||||
const container = document.createElement('div');
|
||||
renderOverlay(
|
||||
container,
|
||||
makeParams({
|
||||
lines: ['こんにちは世界'],
|
||||
startCol: 2, // after ❯ prompt
|
||||
cellW: 10,
|
||||
showCursor: true,
|
||||
})
|
||||
);
|
||||
|
||||
const lineDiv = container.children[0] as HTMLDivElement;
|
||||
const spans = getSpans(lineDiv);
|
||||
expect(spans.length).toBe(7);
|
||||
|
||||
// All CJK, all double-width, contiguous
|
||||
let expectedLeft = 0;
|
||||
for (const span of spans) {
|
||||
expect(span.left).toBe(expectedLeft);
|
||||
expect(span.width).toBe(20);
|
||||
expectedLeft += 20;
|
||||
}
|
||||
|
||||
// Cursor position: startCol(2) + 14 visual cols = 16
|
||||
const cursor = container.children[container.children.length - 1] as HTMLSpanElement;
|
||||
expect(cursor.style.left).toBe('160px');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user