// @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; 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[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); }); });