From 2b0d8470e508d03ff1019d69b60f313302afd421 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 16:14:24 +0300 Subject: [PATCH] =?UTF-8?q?test:=20fix=20CharacterComparisonEngine=20tests?= =?UTF-8?q?=20=E2=80=94=20correct=20env=20directive,=20canvas=20mock,=20an?= =?UTF-8?q?d=20full=20spec=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CharacterComparisonEngine.test.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts new file mode 100644 index 0000000..47db20c --- /dev/null +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts @@ -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); + }); +});