diff --git a/.gitea/workflows/workflow.yml b/.gitea/workflows/workflow.yml index af1335f..89e7d23 100644 --- a/.gitea/workflows/workflow.yml +++ b/.gitea/workflows/workflow.yml @@ -50,8 +50,34 @@ jobs: timeout-minutes: 5 run: yarn test:component --reporter=verbose --logHeapUsage + e2e: + needs: build + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.59.0-jammy + steps: + - uses: actions/checkout@v4 + - name: Enable Corepack + run: | + corepack enable + corepack prepare yarn@stable --activate + - name: Persistent Yarn Cache + uses: actions/cache@v4 + with: + path: .yarn/cache + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: ${{ runner.os }}-yarn- + - name: Install dependencies + run: yarn install --immutable + - name: Build Svelte SPA + run: yarn build + - name: E2E Tests + timeout-minutes: 15 + run: yarn test:e2e + publish: - needs: build # Only runs if tests/lint pass + # Runs if lint, unit-, component-, e2e-tests pass + needs: [build, e2e] runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' # Only deploy from main branch steps: diff --git a/.gitignore b/.gitignore index 2f13284..744ef9b 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ storybook-static # Tests coverage/ .aider* +playwright-report/ +blob-report/ +.playwright/ diff --git a/e2e/compare-flow.test.ts b/e2e/compare-flow.test.ts new file mode 100644 index 0000000..9727f9a --- /dev/null +++ b/e2e/compare-flow.test.ts @@ -0,0 +1,39 @@ +import { + expect, + test, +} from './fixtures'; + +test.describe('compare flow', () => { + test('selects fontA and fontB onto opposite sides', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + + // Each side's header region exposes the font name independently. + await expect(comparison.primaryFont).toContainText('Inter'); + await expect(comparison.secondaryFont).toContainText('Roboto'); + + // Slider is rendered and interactive once both fonts are picked. + await expect(comparison.slider).toBeVisible(); + }); + + test('reflects active side via aria-pressed', async ({ comparison }) => { + await comparison.selectSide('B'); + expect(await comparison.activeSide()).toBe('B'); + await expect(comparison.secondarySideButton).toHaveAttribute('aria-pressed', 'true'); + await expect(comparison.primarySideButton).toHaveAttribute('aria-pressed', 'false'); + }); + + test('persists selection through the comparisonStore localStorage', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + + // Wait for the store debounce to flush to localStorage. + await expect.poll(async () => { + const storage = await comparison.readStorage(); + return storage['glyphdiff:comparison']; + }).toMatch(/inter/i); + + const storage = await comparison.readStorage(); + const state = JSON.parse(storage['glyphdiff:comparison']!); + expect(state.fontAId).toBe('inter'); + expect(state.fontBId).toBe('roboto'); + }); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..3bf8e5c --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,35 @@ +import { test as base } from '@playwright/test'; +import { ComparisonPage } from './pages/comparison-page'; +import { TypographyMenu } from './pages/typography-menu'; + +type Fixtures = { + /** + * Opened ComparisonPage with the root view loaded. + */ + comparison: ComparisonPage; + /** + * Typography menu helper bound to the same page. + */ + typography: TypographyMenu; +}; + +/** + * Custom test that auto-opens the comparison view before each spec. + * Playwright gives each test a fresh BrowserContext by default, so + * localStorage is empty unless a test seeds it. + */ +export const test = base.extend({ + comparison: async ({ page }, use) => { + const view = new ComparisonPage(page); + await view.open(); + await use(view); + }, + // Depends on `comparison` so the root page is opened before the menu is + // consulted — TypographyMenu has no markup of its own to load. + typography: async ({ comparison, page }, use) => { + void comparison; + await use(new TypographyMenu(page)); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/font-loading.test.ts b/e2e/font-loading.test.ts new file mode 100644 index 0000000..c86a02c --- /dev/null +++ b/e2e/font-loading.test.ts @@ -0,0 +1,22 @@ +import { + expect, + test, +} from './fixtures'; + +test.describe('font loading', () => { + test('selected fonts land in the FontFaceSet with status="loaded"', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + + await expect.poll(() => comparison.fontLoaded('Inter')).toBe(true); + await expect.poll(() => comparison.fontLoaded('Roboto')).toBe(true); + }); + + test('an unrelated font remains absent from the FontFaceSet', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + + // "Audiowide" is unlikely to be on the system AND was not selected, so + // no FontFace should ever have been registered for it. This guards + // against the loader over-fetching neighbouring fonts. + await expect.poll(() => comparison.fontLoaded('Audiowide')).toBe(false); + }); +}); diff --git a/e2e/pages/base-page.ts b/e2e/pages/base-page.ts new file mode 100644 index 0000000..5e7c8e8 --- /dev/null +++ b/e2e/pages/base-page.ts @@ -0,0 +1,16 @@ +import type { Page } from '@playwright/test'; + +/** + * Shared base for all page objects. Subclasses extend this and expose + * domain-specific locators + actions — never raw selectors leaking into tests. + */ +export abstract class BasePage { + protected constructor(protected readonly page: Page) {} + + /** + * Navigate to a path relative to baseURL. + */ + async goto(path = '/') { + await this.page.goto(path); + } +} diff --git a/e2e/pages/comparison-page.ts b/e2e/pages/comparison-page.ts new file mode 100644 index 0000000..a42c450 --- /dev/null +++ b/e2e/pages/comparison-page.ts @@ -0,0 +1,144 @@ +import type { + Locator, + Page, +} from '@playwright/test'; +import { BasePage } from './base-page'; + +/** + * Page object for the root comparison view. Encapsulates locators for the + * primary controls so tests don't hardcode aria-labels or DOM structure. + * + * Selection flow: clicking a font row assigns it to whichever side + * (`A` = "Left Font" / Primary, `B` = "Right Font" / Secondary) is currently + * active in the Sidebar — there's no per-row A/B toggle. + */ +export class ComparisonPage extends BasePage { + readonly searchInput: Locator; + readonly previewInput: Locator; + readonly slider: Locator; + readonly primarySideButton: Locator; + readonly secondarySideButton: Locator; + readonly primaryFont: Locator; + readonly secondaryFont: Locator; + readonly fontList: Locator; + + constructor(page: Page) { + super(page); + this.searchInput = page.getByRole('textbox', { name: 'Search typefaces' }); + this.previewInput = page.getByRole('textbox', { name: 'Preview text' }); + this.slider = page.getByRole('slider', { name: 'Font comparison slider' }); + // ARIA-controls couples the side toggle to the font display it targets — copy-independent. + this.primarySideButton = page.locator('[aria-controls="primary-font"]'); + this.secondarySideButton = page.locator('[aria-controls="secondary-font"]'); + this.primaryFont = page.locator('#primary-font'); + this.secondaryFont = page.locator('#secondary-font'); + this.fontList = page.locator('[data-font-list]'); + } + + /** + * Open the root page and wait for the main controls to be interactable. + * Uses lg+ viewport for the preview input to be visible. + */ + async open() { + await this.goto('/'); + await this.searchInput.waitFor({ state: 'visible' }); + } + + async searchFor(query: string) { + await this.searchInput.fill(query); + } + + async setPreviewText(text: string) { + await this.previewInput.fill(text); + } + + /** + * Switch which side the next font click will assign to. + */ + async selectSide(side: 'A' | 'B') { + const button = side === 'A' ? this.primarySideButton : this.secondarySideButton; + await button.click(); + } + + /** + * Read which side is currently active from `aria-pressed`. + * Falls back to A when neither button reports pressed (initial state in some flows). + */ + async activeSide(): Promise<'A' | 'B' | null> { + const [primaryPressed, secondaryPressed] = await Promise.all([ + this.primarySideButton.getAttribute('aria-pressed'), + this.secondarySideButton.getAttribute('aria-pressed'), + ]); + if (primaryPressed === 'true') { + return 'A'; + } + if (secondaryPressed === 'true') { + return 'B'; + } + return null; + } + + /** + * Search for a font and click the matching list row. The row's accessible + * name is the font name itself (rendered by FontApplicator). + */ + async pickFont(name: string) { + await this.searchFor(name); + const row = this.fontList.getByRole('button', { name, exact: true }); + await row.click(); + } + + /** + * Assign fontA to side A and fontB to side B in one call. + */ + async pickPair(fontA: string, fontB: string) { + await this.selectSide('A'); + await this.pickFont(fontA); + await this.selectSide('B'); + await this.pickFont(fontB); + } + + /** + * Read aria-valuenow off the comparison slider. + */ + async sliderValue(): Promise { + const value = await this.slider.getAttribute('aria-valuenow'); + return Number(value); + } + + /** + * Snapshot the glyphdiff:* localStorage entries. + */ + async readStorage(): Promise> { + return await this.page.evaluate(() => { + const out: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i)!; + if (key.startsWith('glyphdiff:')) { + out[key] = localStorage.getItem(key); + } + } + return out; + }); + } + + /** + * Whether the document.fonts FontFaceSet contains a fully-loaded face for + * the named family. Counts only faces registered via the FontFace API — + * system-installed fallbacks (which `document.fonts.check` honours) are + * excluded, so a `false` here is meaningful in negative assertions. + */ + async fontLoaded(name: string): Promise { + return await this.page.evaluate(target => { + for (const face of document.fonts) { + // FontFace.family is wrapped in quotes only if the literal was; + // strip any surrounding quotes before comparing. + const family = face.family.replace(/^["']|["']$/g, ''); + if (family === target && face.status === 'loaded') { + return true; + } + } + return false; + }, name); + } +} diff --git a/e2e/pages/typography-menu.ts b/e2e/pages/typography-menu.ts new file mode 100644 index 0000000..d043320 --- /dev/null +++ b/e2e/pages/typography-menu.ts @@ -0,0 +1,73 @@ +import type { + Locator, + Page, +} from '@playwright/test'; + +/** + * Typography settings menu — desktop layout exposes inline ComboControls with + * increase/decrease buttons. The current value is encoded in the trigger + * button's aria-label as `${controlLabel}: ${value}` (e.g. "Size: 24"). + */ +export type TypographyControl = 'size' | 'weight' | 'leading' | 'tracking'; + +const LABELS: Record = { + size: { + increase: 'Increase Font Size', + decrease: 'Decrease Font Size', + trigger: 'Size', + }, + weight: { + increase: 'Increase Font Weight', + decrease: 'Decrease Font Weight', + trigger: 'Weight', + }, + leading: { + increase: 'Increase Line Height', + decrease: 'Decrease Line Height', + trigger: 'Leading', + }, + tracking: { + increase: 'Increase Letter Spacing', + decrease: 'Decrease Letter Spacing', + trigger: 'Tracking', + }, +}; + +export class TypographyMenu { + constructor(private readonly page: Page) {} + + increase(control: TypographyControl): Locator { + return this.page.getByRole('button', { name: LABELS[control].increase }); + } + + decrease(control: TypographyControl): Locator { + return this.page.getByRole('button', { name: LABELS[control].decrease }); + } + + /** + * Trigger button whose aria-label encodes the current value, e.g. "Size: 24". + */ + trigger(control: TypographyControl): Locator { + return this.page.getByRole('button', { name: new RegExp(`^${LABELS[control].trigger}:\\s`) }); + } + + /** + * Parse the numeric value out of the trigger button's aria-label. + * Returns null if the label can't be read yet. + */ + async readValue(control: TypographyControl): Promise { + const label = await this.trigger(control).getAttribute('aria-label'); + if (!label) { + return null; + } + const match = label.match(/:\s*(-?\d+(?:\.\d+)?)/); + return match ? Number(match[1]) : null; + } + + async bump(control: TypographyControl, direction: 'up' | 'down', times = 1) { + const button = direction === 'up' ? this.increase(control) : this.decrease(control); + for (let i = 0; i < times; i++) { + await button.click(); + } + } +} diff --git a/e2e/persistence.test.ts b/e2e/persistence.test.ts new file mode 100644 index 0000000..8496f6c --- /dev/null +++ b/e2e/persistence.test.ts @@ -0,0 +1,41 @@ +import { + expect, + test, +} from './fixtures'; + +test.describe('persistence', () => { + test('restores selected fonts after reload', async ({ comparison, page }) => { + await comparison.pickPair('Inter', 'Roboto'); + + // Confirm the store has flushed before reloading — otherwise we race + // the debounce and may reload with empty storage. + await expect.poll(async () => { + const storage = await comparison.readStorage(); + return storage['glyphdiff:comparison']; + }).toMatch(/roboto/i); + + await page.reload(); + await comparison.searchInput.waitFor({ state: 'visible' }); + + await expect(comparison.primaryFont).toContainText('Inter'); + await expect(comparison.secondaryFont).toContainText('Roboto'); + }); + + test('restores typography settings after reload', async ({ comparison, typography, page }) => { + const baseline = await typography.readValue('size'); + await typography.bump('size', 'up', 2); + + const bumped = await typography.readValue('size'); + expect(bumped).not.toBe(baseline); + + await expect.poll(async () => { + const storage = await comparison.readStorage(); + return storage['glyphdiff:comparison:typography']; + }).not.toBeNull(); + + await page.reload(); + await comparison.searchInput.waitFor({ state: 'visible' }); + + expect(await typography.readValue('size')).toBe(bumped); + }); +}); diff --git a/e2e/preview-text.test.ts b/e2e/preview-text.test.ts new file mode 100644 index 0000000..f138363 --- /dev/null +++ b/e2e/preview-text.test.ts @@ -0,0 +1,21 @@ +import { + expect, + test, +} from './fixtures'; + +test.describe('preview text', () => { + test('drives the slider character rendering', async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + await comparison.setPreviewText('Sphinx'); + + // Each grapheme renders as a `.char-wrap` cell in the slider once + // both fonts are loaded. Six glyphs → six cells. + await expect(comparison.slider.locator('.char-wrap')).toHaveCount(6); + }); + + test('preserves the typed value in the input', async ({ comparison }) => { + const text = 'Sphinx of black quartz'; + await comparison.setPreviewText(text); + await expect(comparison.previewInput).toHaveValue(text); + }); +}); diff --git a/e2e/slider.test.ts b/e2e/slider.test.ts new file mode 100644 index 0000000..af8cd81 --- /dev/null +++ b/e2e/slider.test.ts @@ -0,0 +1,46 @@ +import { + expect, + test, +} from './fixtures'; + +/** + * Slider position is spring-animated; aria-valuenow reflects the current + * value, not the target. All assertions use `toHaveAttribute` so Playwright + * polls until the spring settles. + */ +test.describe('comparison slider', () => { + test.beforeEach(async ({ comparison }) => { + await comparison.pickPair('Inter', 'Roboto'); + await comparison.slider.focus(); + }); + + test('keyboard navigation snaps to End and Home', async ({ comparison }) => { + await comparison.slider.press('End'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '100'); + + await comparison.slider.press('Home'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0'); + }); + + test('arrow keys nudge by one, Shift+Arrow by ten', async ({ comparison }) => { + await comparison.slider.press('Home'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0'); + + await comparison.slider.press('ArrowRight'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '1'); + + await comparison.slider.press('Shift+ArrowRight'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '11'); + }); + + test('PageUp / PageDown move by ten', async ({ comparison }) => { + await comparison.slider.press('Home'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0'); + + await comparison.slider.press('PageUp'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '10'); + + await comparison.slider.press('PageDown'); + await expect(comparison.slider).toHaveAttribute('aria-valuenow', '0'); + }); +}); diff --git a/e2e/smoke.test.ts b/e2e/smoke.test.ts new file mode 100644 index 0000000..d616504 --- /dev/null +++ b/e2e/smoke.test.ts @@ -0,0 +1,23 @@ +import { + expect, + test, +} from '@playwright/test'; +import { ComparisonPage } from './pages/comparison-page'; + +test.describe('smoke', () => { + test('loads the comparison view with its primary controls', async ({ page }) => { + const view = new ComparisonPage(page); + await view.open(); + + await expect(view.searchInput).toBeVisible(); + await expect(view.previewInput).toBeVisible(); + }); + + test('accepts a search query', async ({ page }) => { + const view = new ComparisonPage(page); + await view.open(); + await view.searchFor('Inter'); + + await expect(view.searchInput).toHaveValue('Inter'); + }); +}); diff --git a/e2e/typography.test.ts b/e2e/typography.test.ts new file mode 100644 index 0000000..3e0cccc --- /dev/null +++ b/e2e/typography.test.ts @@ -0,0 +1,44 @@ +import { + expect, + test, +} from './fixtures'; +import type { TypographyControl } from './pages/typography-menu'; + +/** + * Each control's trigger button advertises its current value via aria-label + * ("Size: 24"). We bump in one direction, then back, and assert the value + * tracks symmetrically. + */ +const controls: TypographyControl[] = ['size', 'weight', 'leading', 'tracking']; + +test.describe('typography settings', () => { + for (const control of controls) { + test(`${control}: increase then decrease returns to baseline`, async ({ typography }) => { + const baseline = await typography.readValue(control); + expect(baseline).not.toBeNull(); + + await typography.bump(control, 'up'); + const bumped = await typography.readValue(control); + expect(bumped).not.toBe(baseline); + expect(bumped! > baseline!).toBe(true); + + await typography.bump(control, 'down'); + const restored = await typography.readValue(control); + expect(restored).toBe(baseline); + }); + } + + test('font size step is reflected in the persisted typography state', async ({ comparison, typography }) => { + await typography.bump('size', 'up'); + const expected = await typography.readValue('size'); + + await expect.poll(async () => { + const storage = await comparison.readStorage(); + const raw = storage['glyphdiff:comparison:typography']; + if (!raw) { + return null; + } + return JSON.parse(raw).fontSize ?? null; + }).toBe(expected); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index bc84607..ab4707a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,10 +1,54 @@ -import { defineConfig } from '@playwright/test'; +import { + defineConfig, + devices, +} from '@playwright/test'; + +/** + * E2E config. Tests run against the production build via `vite preview` on port 4173. + * Locally: all three browser engines run in parallel. + * CI: chromium only, workers=1 — the runner has 6GB RAM and `yarn build` already + * spikes 1–2GB, so we keep the E2E peak bounded. + */ +const isCI = !!process.env.CI; export default defineConfig({ + testDir: 'e2e', + testMatch: /.*\.test\.ts$/, + + fullyParallel: true, + forbidOnly: isCI, + retries: isCI ? 2 : 0, + workers: isCI ? 1 : undefined, + + reporter: isCI + ? [['html', { open: 'never' }], ['github']] + : [['html', { open: 'on-failure' }], ['list']], + + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: isCI + ? [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }] + : [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + ], webServer: { command: 'yarn build && yarn preview', port: 4173, - reuseExistingServer: true, + reuseExistingServer: !isCI, + timeout: 120_000, }, - testDir: 'e2e', }); diff --git a/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts b/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts index 6136a63..f43cce4 100644 --- a/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts +++ b/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts @@ -102,7 +102,7 @@ export class ComparisonStore { this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []); $effect.root(() => { - // Effect 1: Sync batch results → fontA / fontB + // Sync batch results → fontA / fontB $effect(() => { const fonts = this.#fontsByIdsStore.fonts; if (fonts.length === 0) { @@ -124,7 +124,7 @@ export class ComparisonStore { } }); - // Effect 2: Trigger font loading whenever selection or weight changes + // Trigger font loading whenever selection or weight changes $effect(() => { const fa = this.#fontA; const fb = this.#fontB; @@ -154,24 +154,38 @@ export class ComparisonStore { } }); - // Effect 3: Set default fonts when storage is empty + // Set default fonts when storage is empty $effect(() => { if (this.#fontA && this.#fontB) { return; } - const fonts = fontCatalogStore.fonts; - if (fonts.length >= 2) { - untrack(() => { - const id1 = fonts[0].id; - const id2 = fonts[fonts.length - 1].id; - storage.value = { fontAId: id1, fontBId: id2 }; - this.#fontsByIdsStore.setIds([id1, id2]); - }); + // Don't clobber a pending rehydration - only seed when storage is empty. + // Untracked: only the catalog load should drive this effect, not the + // user's storage writes that happen as a result of normal selection. + const hasStoredSelection = untrack(() => { + return storage.value.fontAId !== null || storage.value.fontBId !== null; + }); + + if (hasStoredSelection) { + return; } + + const fonts = fontCatalogStore.fonts; + + if (fonts.length < 2) { + return; + } + + untrack(() => { + const id1 = fonts[0].id; + const id2 = fonts[fonts.length - 1].id; + storage.value = { fontAId: id1, fontBId: id2 }; + this.#fontsByIdsStore.setIds([id1, id2]); + }); }); - // Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts + // Pin fontA/fontB so eviction never removes on-screen fonts $effect(() => { const fa = this.#fontA; const fb = this.#fontB; diff --git a/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts b/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts index 21fb026..9227469 100644 --- a/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts +++ b/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts @@ -165,6 +165,46 @@ describe('ComparisonStore', () => { expect(mockStorage._value.fontBId).toBe(mockFontB.id); }); }); + + /** + * Regression: when storage already holds the user's selection, the + * seed-defaults effect must bail out — even if it fires before the + * per-id batch returns (catalog wins the race on slow networks or + * cold reloads). Pre-fix the effect only checked fontA/fontB, both + * still undefined at this point, and clobbered storage with whatever + * the catalog had as fonts[0] / fonts[N-1]. + */ + it('should not overwrite stored IDs when batch is still in flight', async () => { + const seededA = UNIFIED_FONTS.lato; + const seededB = UNIFIED_FONTS.montserrat; + + mockStorage._value.fontAId = seededA.id; + mockStorage._value.fontBId = seededB.id; + + // Catalog defaults differ from the stored selection — if the + // effect mis-seeds, storage will flip to roboto / open-sans. + (fontCatalogStore as any).fonts = [mockFontA, mockFontB]; + + // Delay the batch so the catalog-driven effect runs first. + vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation( + () => new Promise(r => setTimeout(() => r([seededA, seededB]), 50)), + ); + + const store = new ComparisonStore(); + + // Let the catalog effect run; storage must be untouched. + await new Promise(r => setTimeout(r, 10)); + expect(mockStorage._value.fontAId).toBe(seededA.id); + expect(mockStorage._value.fontBId).toBe(seededB.id); + + // Batch resolves with the seeded selection — fontA/B must match. + await vi.waitFor(() => { + expect(store.fontA?.id).toBe(seededA.id); + expect(store.fontB?.id).toBe(seededB.id); + }, { timeout: 2000 }); + expect(mockStorage._value.fontAId).toBe(seededA.id); + expect(mockStorage._value.fontBId).toBe(seededB.id); + }); }); describe('Aggregate Loading State', () => { diff --git a/src/widgets/ComparisonView/ui/Header/Header.svelte b/src/widgets/ComparisonView/ui/Header/Header.svelte index f2a30d8..88d515f 100644 --- a/src/widgets/ComparisonView/ui/Header/Header.svelte +++ b/src/widgets/ComparisonView/ui/Header/Header.svelte @@ -93,7 +93,11 @@ const fontBName = $derived(comparisonStore.fontB?.name ?? '');