Files
frontend-svelte/src/entities/Font/domain/computeLineRenderModel/computeLineRenderModel.test.ts
T
Ilia Mashkov 7d66b0bc92 refactor(font): extract glyph-comparison logic into a domain segment
Move DualFontLayout and computeLineRenderModel from lib/ to a new
domain/ segment. This is the pure glyph-comparison algorithm — no
framework, no UI, no shared/model dependencies — so it belongs in
domain per the FSD+ ui -> model -> domain interior rule, shielded from
UI-kit and API churn.

Public API is unchanged: the slice index re-exports domain/, so
$entities/Font consumers (ComparisonView SliderArea, Line) are
unaffected.
2026-05-31 14:08:09 +03:00

221 lines
8.0 KiB
TypeScript

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);
});
});