test: fix CharacterComparisonEngine tests — correct env directive, canvas mock, and full spec coverage
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// @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();
|
||||
});
|
||||
|
||||
// --- layout() ---
|
||||
|
||||
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 (FontB) width 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);
|
||||
});
|
||||
|
||||
// --- getCharState() ---
|
||||
|
||||
it('getCharState returns proximity 1 when slider is exactly over char center', () => {
|
||||
// 'A' only: FontA width=10. Container=500px. Line centered.
|
||||
// lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider).
|
||||
// charCenterX = lineXOffset + xA + widthA/2.
|
||||
// Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5
|
||||
// charGlobalPercent = (252.5 / 500) * 100 = 50.5
|
||||
// distance = |50.5 - 50.5| = 0 => proximity = 1
|
||||
const containerWidth = 500;
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20);
|
||||
// Recalculate expected percent manually:
|
||||
const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case)
|
||||
const lineXOffset = (containerWidth - lineWidth) / 2;
|
||||
const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2;
|
||||
const charPercent = (charCenterX / containerWidth) * 100;
|
||||
|
||||
const state = engine.getCharState(0, 0, charPercent, containerWidth);
|
||||
expect(state.proximity).toBe(1);
|
||||
expect(state.isPast).toBe(false);
|
||||
});
|
||||
|
||||
it('getCharState returns proximity 0 when slider is far from char', () => {
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
// Slider at 0%, char is near 50% — distance > 5 range => proximity = 0
|
||||
const state = engine.getCharState(0, 0, 0, 500);
|
||||
expect(state.proximity).toBe(0);
|
||||
});
|
||||
|
||||
it('getCharState isPast is true when slider has passed char center', () => {
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const state = engine.getCharState(0, 0, 100, 500);
|
||||
expect(state.isPast).toBe(true);
|
||||
});
|
||||
|
||||
it('getCharState returns safe default for out-of-range lineIndex', () => {
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const state = engine.getCharState(99, 0, 50, 500);
|
||||
expect(state.proximity).toBe(0);
|
||||
expect(state.isPast).toBe(false);
|
||||
});
|
||||
|
||||
it('getCharState returns safe default for out-of-range charIndex', () => {
|
||||
engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20);
|
||||
const state = engine.getCharState(0, 99, 50, 500);
|
||||
expect(state.proximity).toBe(0);
|
||||
expect(state.isPast).toBe(false);
|
||||
});
|
||||
|
||||
it('getCharState returns safe default before layout() has been called', () => {
|
||||
const state = engine.getCharState(0, 0, 50, 500);
|
||||
expect(state.proximity).toBe(0);
|
||||
expect(state.isPast).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user