Files
frontend-svelte/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts
T
Ilia Mashkov f92577608a refactor(Font): use pretext layout() directly in row size resolver
createFontRowSizeResolver was reaching into TextLayoutEngine, which
internally called pretext's heavy layoutWithLines and then walked
per-grapheme cursors to build chars arrays. The resolver discarded all
that work and used only totalHeight.

Replace with direct prepare + layout from @chenglou/pretext — pretext
docs explicitly recommend layout() (not layoutWithLines) for the resize
hot path: pure arithmetic on cached segment widths, no canvas calls, no
string allocations.

Test spies on TextLayoutEngine.prototype.layout migrated to vi.mock-ed
pretext layout (pretext's ESM exports are frozen — vi.spyOn fails with
"Cannot redefine property").

TextLayoutEngine marked @deprecated since it has no remaining
consumers; slated for removal after a release cycle.
2026-05-24 20:12:48 +03:00

181 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
import {
clearCache,
layout,
} from '@chenglou/pretext';
// Wrap pretext's `layout` in a spy-able mock so tests can assert call counts.
// `vi.mock` is hoisted, so the import above receives the mocked module.
vi.mock('@chenglou/pretext', async () => {
const actual = await vi.importActual<typeof import('@chenglou/pretext')>('@chenglou/pretext');
return {
...actual,
layout: vi.fn(actual.layout),
};
});
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import type { FontLoadStatus } from '../../model/types';
import { mockUnifiedFont } from '../mocks';
import { createFontRowSizeResolver } from './createFontRowSizeResolver';
// Fixed-width canvas mock: every character is 10px wide regardless of font.
// This makes wrapping math predictable: N chars × 10px = N×10 total width.
const CHAR_WIDTH = 10;
const LINE_HEIGHT = 20;
const CONTAINER_WIDTH = 200;
const CONTENT_PADDING_X = 32; // p-4 × 2 sides = 32px
const CHROME_HEIGHT = 56;
const FALLBACK_HEIGHT = 220;
const FONT_SIZE_PX = 16;
describe('createFontRowSizeResolver', () => {
let statusMap: Map<string, FontLoadStatus>;
let getStatus: (key: string) => FontLoadStatus | undefined;
beforeEach(() => {
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
clearCache();
statusMap = new Map();
getStatus = key => statusMap.get(key);
});
function makeResolver(overrides?: Partial<Parameters<typeof createFontRowSizeResolver>[0]>) {
const font = mockUnifiedFont({ id: 'inter', name: 'Inter' });
return {
font,
resolver: createFontRowSizeResolver({
getFonts: () => [font],
getWeight: () => 400,
getPreviewText: () => 'Hello',
getContainerWidth: () => CONTAINER_WIDTH,
getFontSizePx: () => FONT_SIZE_PX,
getLineHeightPx: () => LINE_HEIGHT,
getStatus,
contentHorizontalPadding: CONTENT_PADDING_X,
chromeHeight: CHROME_HEIGHT,
fallbackHeight: FALLBACK_HEIGHT,
...overrides,
}),
};
}
it('returns fallbackHeight when font status is undefined', () => {
const { resolver } = makeResolver();
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when font status is "loading"', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loading');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when font status is "error"', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'error');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when containerWidth is 0', () => {
const { resolver } = makeResolver({ getContainerWidth: () => 0 });
statusMap.set('inter@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight when previewText is empty', () => {
const { resolver } = makeResolver({ getPreviewText: () => '' });
statusMap.set('inter@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
it('returns fallbackHeight for out-of-bounds rowIndex', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
expect(resolver(99)).toBe(FALLBACK_HEIGHT);
});
it('returns computed height (totalHeight + chromeHeight) when font is loaded', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
// 'Hello' = 5 chars × 10px = 50px. contentWidth = 200 - 32 = 168px. Fits on one line.
// totalHeight = 1 × LINE_HEIGHT = 20. result = 20 + CHROME_HEIGHT = 76.
const result = resolver(0);
expect(result).toBe(LINE_HEIGHT + CHROME_HEIGHT);
});
it('returns increased height when text wraps due to narrow container', () => {
// contentWidth = 40 - 32 = 8px — 'Hello' (50px) forces wrapping onto many lines
const { resolver } = makeResolver({ getContainerWidth: () => 40 });
statusMap.set('inter@400', 'loaded');
const result = resolver(0);
expect(result).toBeGreaterThan(LINE_HEIGHT + CHROME_HEIGHT);
});
it('does not call layout() again on second call with same arguments', () => {
const { resolver } = makeResolver();
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
resolver(0);
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(1);
});
it('calls layout() again when containerWidth changes (cache miss)', () => {
let width = CONTAINER_WIDTH;
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
const layoutSpy = vi.mocked(layout);
layoutSpy.mockClear();
resolver(0);
width = 100;
resolver(0);
expect(layoutSpy).toHaveBeenCalledTimes(2);
});
it('returns greater height when container narrows (more wrapping)', () => {
let width = CONTAINER_WIDTH;
const { resolver } = makeResolver({ getContainerWidth: () => width });
statusMap.set('inter@400', 'loaded');
const h1 = resolver(0);
width = 100; // narrower → more wrapping
const h2 = resolver(0);
expect(h2).toBeGreaterThanOrEqual(h1);
});
it('uses variable font key for variable fonts', () => {
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
// Variable fonts use '{id}@vf' key, not '{id}@{weight}'
statusMap.set('roboto@vf', 'loaded');
const result = resolver(0);
expect(result).not.toBe(FALLBACK_HEIGHT);
expect(result).toBeGreaterThan(0);
});
it('returns fallbackHeight for variable font when static key is set instead', () => {
const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } });
const { resolver } = makeResolver({ getFonts: () => [vfFont] });
// Setting the static key should NOT unlock computed height for variable fonts
statusMap.set('roboto@400', 'loaded');
expect(resolver(0)).toBe(FALLBACK_HEIGHT);
});
});