// @vitest-environment jsdom import { clearCache } from '@chenglou/pretext'; import { beforeEach, describe, expect, it, } from 'vitest'; import { installCanvasMock } from '../__mocks__/canvas'; import { CharacterComparisonEngine } from './CharacterComparisonEngine.svelte'; // FontA: 10px per character. FontB: 15px per character. // The mock dispatches on whether the font string contains 'FontA' or 'FontB'. const FONT_A_WIDTH = 10; const FONT_B_WIDTH = 15; function fontWidthFactory(font: string, text: string): number { const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH; return text.length * perChar; } describe('CharacterComparisonEngine', () => { let engine: CharacterComparisonEngine; beforeEach(() => { installCanvasMock(fontWidthFactory); clearCache(); engine = new CharacterComparisonEngine(); }); it('returns empty result for empty string', () => { const result = engine.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20); expect(result.lines).toHaveLength(0); expect(result.totalHeight).toBe(0); }); it('uses worst-case width across both fonts to determine line breaks', () => { // 'AB CD' — two 2-char words separated by a space. // FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total. // FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '. // Unified must use FontB widths — so it must wrap at the same place FontB wraps. const result = engine.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20); expect(result.lines.length).toBeGreaterThan(1); // First line text must not include both words. expect(result.lines[0].text).not.toContain('CD'); }); it('provides xA and xB offsets for both fonts on a single line', () => { // 'ABC' fits in 500px for both fonts. // FontA: A@0(w=10), B@10(w=10), C@20(w=10) // FontB: A@0(w=15), B@15(w=15), C@30(w=15) const result = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const chars = result.lines[0].chars; expect(chars).toHaveLength(3); expect(chars[0].xA).toBe(0); expect(chars[0].widthA).toBe(FONT_A_WIDTH); expect(chars[0].xB).toBe(0); expect(chars[0].widthB).toBe(FONT_B_WIDTH); expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10 expect(chars[1].widthA).toBe(FONT_A_WIDTH); expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15 expect(chars[1].widthB).toBe(FONT_B_WIDTH); expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20 expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30 }); it('xA positions are monotonically increasing', () => { const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const chars = result.lines[0].chars; for (let i = 1; i < chars.length; i++) { expect(chars[i].xA).toBeGreaterThan(chars[i - 1].xA); } }); it('xB positions are monotonically increasing', () => { const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const chars = result.lines[0].chars; for (let i = 1; i < chars.length; i++) { expect(chars[i].xB).toBeGreaterThan(chars[i - 1].xB); } }); it('returns cached result when called again with same arguments', () => { const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const r2 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); expect(r2).toBe(r1); // strict reference equality — same object }); it('re-computes when text changes', () => { const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const r2 = engine.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20); expect(r2).not.toBe(r1); expect(r2.lines[0].text).not.toBe(r1.lines[0].text); }); it('re-computes when width changes', () => { const r1 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const r2 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20); expect(r2).not.toBe(r1); }); it('re-computes when fontA changes', () => { const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const r2 = engine.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20); expect(r2).not.toBe(r1); }); it('getLineCharStates returns proximity 1 when slider is exactly over char center', () => { const containerWidth = 500; const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20); // Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2. // When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2. // So proximity=1 at exactly 50%. const charPercent = 50; const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth); expect(states[0]?.proximity).toBe(1); expect(states[0]?.isPast).toBe(false); }); it('getLineCharStates returns proximity 0 when slider is far from char', () => { const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const states = engine.getLineCharStates(result.lines[0], 0, 500); expect(states[0]?.proximity).toBe(0); }); it('getLineCharStates isPast is true when slider has passed char center', () => { const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const states = engine.getLineCharStates(result.lines[0], 100, 500); expect(states[0]?.isPast).toBe(true); }); it('getLineCharStates returns empty array for out-of-range lineIndex', () => { const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); // Passing an undefined object because the index doesn't exist. const states = engine.getLineCharStates(result.lines[99], 50, 500); expect(states).toEqual([]); }); it('getLineCharStates returns empty array before layout() has been called', () => { // Passing an undefined object because layout() hasn't been called. const states = engine.getLineCharStates(undefined as any, 50, 500); expect(states).toEqual([]); }); it('getLineCharStates returns safe defaults for all chars', () => { const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); const states = engine.getLineCharStates(result.lines[0], 50, 500); expect(states.length).toBeGreaterThan(0); for (const s of states) { expect(s.proximity).toBeGreaterThanOrEqual(0); expect(s.proximity).toBeLessThanOrEqual(1); expect(typeof s.isPast).toBe('boolean'); } }); });