feat: implement createFontRowSizeResolver with canvas-measured heights and reactive status check
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment jsdom
|
||||
import { TextLayoutEngine } from '$shared/lib';
|
||||
import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas';
|
||||
import { clearCache } from '@chenglou/pretext';
|
||||
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.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||
|
||||
resolver(0);
|
||||
resolver(0);
|
||||
|
||||
expect(layoutSpy).toHaveBeenCalledTimes(1);
|
||||
layoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
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.spyOn(TextLayoutEngine.prototype, 'layout');
|
||||
|
||||
resolver(0);
|
||||
width = 100;
|
||||
resolver(0);
|
||||
|
||||
expect(layoutSpy).toHaveBeenCalledTimes(2);
|
||||
layoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user