import { describe, expect, it, } from 'vitest'; import type { ComparisonLine } from '../DualFontLayout/DualFontLayout'; import { type LineRenderModel, computeLineRenderModel, findSplitIndex, } from './computeLineRenderModel'; /** * Build a ComparisonLine fixture with given per-char widths. xA/xB are * cumulative prefix sums of widthA/widthB respectively. */ function makeLine( chars: { char: string; widthA: number; widthB: number }[], ): ComparisonLine { let xA = 0; let xB = 0; const out: ComparisonLine = { text: chars.map(c => c.char).join(''), width: chars.reduce((s, c) => s + Math.max(c.widthA, c.widthB), 0), chars: chars.map(c => { const entry = { char: c.char, xA, xB, widthA: c.widthA, widthB: c.widthB, }; xA += c.widthA; xB += c.widthB; return entry; }), }; return out; } /** * Test helper: compute split + render model in one step, matching the * SliderArea call site shape. */ function compute( line: ComparisonLine, sliderPos: number, containerWidth: number, windowSize: number, ): LineRenderModel { const split = findSplitIndex(line, sliderPos, containerWidth); return computeLineRenderModel(line, split, windowSize); } describe('computeLineRenderModel', () => { it('returns empty model for an empty line', () => { const line = makeLine([]); const model = compute(line, 50, 500, 5); expect(model.leftText).toBe(''); expect(model.windowChars).toEqual([]); expect(model.rightText).toBe(''); }); it('places entire line in rightText when slider is at 0', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, ]); const model = compute(line, 0, 500, 0); expect(model.leftText).toBe(''); expect(model.windowChars).toEqual([]); expect(model.rightText).toBe('ABC'); }); it('places entire line in leftText when slider is at 100', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, ]); const model = compute(line, 100, 500, 0); expect(model.leftText).toBe('ABC'); expect(model.windowChars).toEqual([]); expect(model.rightText).toBe(''); }); it('splits line correctly with slider mid-line (window=0)', () => { // Equal widths → line is centered. Container=300, total=30 → xOffset=135. // Char thresholds (per the threshold formula in the design): // threshold[i] = xOffset + prefA[i] + widthA[i]/2 // i=0: 135 + 0 + 5 = 140 → 140/300 = 46.67% // i=1: 135 + 10 + 5 = 150 → 150/300 = 50.00% // i=2: 135 + 20 + 5 = 160 → 160/300 = 53.33% const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, ]); // Slider just past B's threshold (50%) but not C's (53.33%). const model = compute(line, 51, 300, 0); expect(model.leftText).toBe('AB'); expect(model.rightText).toBe('C'); }); it('centers window of size 3 on the split index', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, { char: 'D', widthA: 10, widthB: 10 }, { char: 'E', widthA: 10, widthB: 10 }, ]); // Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%). // split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4 const model = compute(line, 48, 300, 3); expect(model.leftText).toBe('A'); expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']); expect(model.rightText).toBe('E'); }); it('clamps window at line start when slider is near 0', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, { char: 'D', widthA: 10, widthB: 10 }, { char: 'E', widthA: 10, widthB: 10 }, ]); const model = compute(line, 0, 300, 3); expect(model.leftText).toBe(''); expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']); expect(model.rightText).toBe('DE'); }); it('clamps window at line end when slider is near 100', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, { char: 'D', widthA: 10, widthB: 10 }, { char: 'E', widthA: 10, widthB: 10 }, ]); const model = compute(line, 100, 300, 3); expect(model.leftText).toBe('AB'); expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']); expect(model.rightText).toBe(''); }); it('treats whole line as window when line is shorter than windowSize', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, ]); const model = compute(line, 50, 300, 5); expect(model.leftText).toBe(''); expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']); expect(model.rightText).toBe(''); }); it('produces stable keys across slider movement within the same line', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, { char: 'D', widthA: 10, widthB: 10 }, { char: 'E', widthA: 10, widthB: 10 }, ]); const a = compute(line, 40, 300, 3); const b = compute(line, 60, 300, 3); // Chars that appear in both windows must carry identical keys. for (const charA of a.windowChars) { const charB = b.windowChars.find(w => w.char === charA.char); if (charB !== undefined) { expect(charB.key).toBe(charA.key); } } }); it('marks isPast=true for chars before the split and false for chars after', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, { char: 'D', widthA: 10, widthB: 10 }, { char: 'E', widthA: 10, widthB: 10 }, ]); // split = 2 → A,B past; C,D,E not const model = compute(line, 48, 300, 5); const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]); for (const wc of model.windowChars) { expect(wc.isPast).toBe(expected.get(wc.char)); } }); }); describe('findSplitIndex', () => { it('returns 0 for empty line', () => { const line = makeLine([]); expect(findSplitIndex(line, 50, 500)).toBe(0); }); it('returns 0 when slider is before all char thresholds', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, ]); expect(findSplitIndex(line, 0, 300)).toBe(0); }); it('returns chars.length when slider is past all char thresholds', () => { const line = makeLine([ { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, ]); expect(findSplitIndex(line, 100, 300)).toBe(3); }); });