169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
|
|
// @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);
|
|||
|
|
});
|
|||
|
|
});
|