2026-03-02 22:18:05 +03:00
|
|
|
/**
|
2026-04-15 22:25:34 +03:00
|
|
|
* Unit tests for ComparisonStore (TanStack Query refactor)
|
2026-03-02 22:18:05 +03:00
|
|
|
*
|
2026-05-24 19:41:40 +03:00
|
|
|
* Uses the real FontsByIdsStore so Svelte $state reactivity works correctly.
|
2026-04-15 22:25:34 +03:00
|
|
|
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
|
2026-03-02 22:18:05 +03:00
|
|
|
*/
|
|
|
|
|
|
2026-04-17 12:14:55 +03:00
|
|
|
/**
|
|
|
|
|
* @vitest-environment jsdom
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
import type { UnifiedFont } from '$entities/Font';
|
2026-05-31 14:08:25 +03:00
|
|
|
import { UNIFIED_FONTS } from '$entities/Font/testing';
|
2026-06-02 23:13:03 +03:00
|
|
|
import { getQueryClient } from '$shared/api/queryClient';
|
|
|
|
|
|
|
|
|
|
const queryClient = getQueryClient();
|
2026-03-02 22:18:05 +03:00
|
|
|
import {
|
|
|
|
|
beforeEach,
|
|
|
|
|
describe,
|
|
|
|
|
expect,
|
|
|
|
|
it,
|
|
|
|
|
vi,
|
|
|
|
|
} from 'vitest';
|
|
|
|
|
|
|
|
|
|
const mockStorage = vi.hoisted(() => {
|
|
|
|
|
const storage: any = {};
|
2026-04-15 22:25:34 +03:00
|
|
|
storage._value = { fontAId: null, fontBId: null };
|
2026-03-02 22:18:05 +03:00
|
|
|
storage._clear = vi.fn(() => {
|
2026-04-15 22:25:34 +03:00
|
|
|
storage._value = { fontAId: null, fontBId: null };
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(storage, 'value', {
|
|
|
|
|
get() {
|
|
|
|
|
return storage._value;
|
|
|
|
|
},
|
|
|
|
|
set(v: any) {
|
|
|
|
|
storage._value = v;
|
|
|
|
|
},
|
|
|
|
|
enumerable: true,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(storage, 'clear', {
|
|
|
|
|
value: storage._clear,
|
|
|
|
|
enumerable: true,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-03 10:16:47 +03:00
|
|
|
storage.destroy = vi.fn();
|
|
|
|
|
|
2026-03-02 22:18:05 +03:00
|
|
|
return storage;
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-01 17:25:05 +03:00
|
|
|
// Writable catalog stub — tests assign `.fonts` directly, which the real
|
|
|
|
|
// FontCatalogStore forbids (getter-only). getFontCatalog returns this singleton.
|
|
|
|
|
const mockFontCatalog = vi.hoisted(() => ({ fonts: [] as unknown[] }));
|
|
|
|
|
|
2026-03-02 22:18:05 +03:00
|
|
|
vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte', () => ({
|
|
|
|
|
createPersistentStore: vi.fn(() => mockStorage),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-04-16 10:55:41 +03:00
|
|
|
vi.mock('$entities/Font', async importOriginal => {
|
|
|
|
|
const actual = await importOriginal<typeof import('$entities/Font')>();
|
2026-05-31 20:06:33 +03:00
|
|
|
return {
|
|
|
|
|
...actual,
|
|
|
|
|
getFontUrl: vi.fn(() => 'https://example.com/font.woff2'),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-01 18:49:47 +03:00
|
|
|
const mockLifecycle = vi.hoisted(() => ({
|
|
|
|
|
touch: vi.fn(),
|
|
|
|
|
pin: vi.fn(),
|
|
|
|
|
unpin: vi.fn(),
|
|
|
|
|
getFontStatus: vi.fn(),
|
|
|
|
|
ready: vi.fn(() => Promise.resolve()),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-31 20:06:33 +03:00
|
|
|
// Stores moved behind the model segment; mock them there. FontsByIdsStore is
|
|
|
|
|
// intentionally left real (spread from actual) so $state reactivity works.
|
|
|
|
|
vi.mock('$entities/Font/model', async importOriginal => {
|
|
|
|
|
const actual = await importOriginal<typeof import('$entities/Font/model')>();
|
2026-04-15 22:25:34 +03:00
|
|
|
return {
|
2026-04-16 10:55:41 +03:00
|
|
|
...actual,
|
2026-06-01 17:25:05 +03:00
|
|
|
getFontCatalog: () => mockFontCatalog,
|
2026-06-01 18:49:47 +03:00
|
|
|
getFontLifecycleManager: () => mockLifecycle,
|
2026-04-15 22:25:34 +03:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-03 08:57:04 +03:00
|
|
|
const mockTypography = vi.hoisted(() => ({
|
|
|
|
|
weight: 400,
|
|
|
|
|
renderedSize: 48,
|
|
|
|
|
reset: vi.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
2026-05-24 18:27:10 +03:00
|
|
|
vi.mock('$features/AdjustTypography', () => ({
|
2026-04-15 22:25:34 +03:00
|
|
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
|
|
|
|
|
createTypographyControlManager: vi.fn(() => ({
|
|
|
|
|
weight: 400,
|
|
|
|
|
renderedSize: 48,
|
|
|
|
|
reset: vi.fn(),
|
|
|
|
|
})),
|
2026-06-01 18:44:17 +03:00
|
|
|
getTypographySettingsStore: () => mockTypography,
|
2026-04-16 10:55:41 +03:00
|
|
|
}));
|
|
|
|
|
|
2026-05-31 20:06:33 +03:00
|
|
|
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
|
2026-06-01 18:49:47 +03:00
|
|
|
import { getFontCatalog } from '$entities/Font/model';
|
2026-06-01 17:25:05 +03:00
|
|
|
import { __resetFontCatalog } from '$entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte';
|
2026-03-02 22:18:05 +03:00
|
|
|
import { ComparisonStore } from './comparisonStore.svelte';
|
|
|
|
|
|
|
|
|
|
describe('ComparisonStore', () => {
|
2026-04-15 22:25:34 +03:00
|
|
|
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
|
|
|
|
|
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
|
2026-06-01 17:25:05 +03:00
|
|
|
let fontCatalog = getFontCatalog();
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
beforeEach(() => {
|
2026-04-15 22:25:34 +03:00
|
|
|
queryClient.clear();
|
2026-03-02 22:18:05 +03:00
|
|
|
vi.clearAllMocks();
|
2026-04-15 22:25:34 +03:00
|
|
|
mockStorage._value = { fontAId: null, fontBId: null };
|
2026-03-02 22:18:05 +03:00
|
|
|
mockStorage._clear.mockClear();
|
2026-06-01 17:25:05 +03:00
|
|
|
(fontCatalog as any).fonts = [];
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
// Default: fetchFontsByIds returns empty so tests that don't care don't hang
|
|
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
// document.fonts: check returns true so #checkFontsLoaded resolves immediately
|
2026-03-02 22:18:05 +03:00
|
|
|
Object.defineProperty(document, 'fonts', {
|
2026-04-15 22:25:34 +03:00
|
|
|
value: {
|
|
|
|
|
check: vi.fn(() => true),
|
|
|
|
|
load: vi.fn(() => Promise.resolve()),
|
|
|
|
|
ready: Promise.resolve({} as FontFaceSet),
|
|
|
|
|
},
|
2026-03-02 22:18:05 +03:00
|
|
|
writable: true,
|
|
|
|
|
configurable: true,
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-01 17:25:05 +03:00
|
|
|
afterEach(() => {
|
|
|
|
|
__resetFontCatalog();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-02 22:18:05 +03:00
|
|
|
describe('Initialization', () => {
|
|
|
|
|
it('should create store with initial empty state', () => {
|
|
|
|
|
const store = new ComparisonStore();
|
|
|
|
|
expect(store.fontA).toBeUndefined();
|
|
|
|
|
expect(store.fontB).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-24 19:41:40 +03:00
|
|
|
describe('Restoration from Storage (via FontsByIdsStore)', () => {
|
2026-04-15 22:25:34 +03:00
|
|
|
it('should restore fontA and fontB from stored IDs', async () => {
|
2026-03-02 22:18:05 +03:00
|
|
|
mockStorage._value.fontAId = mockFontA.id;
|
|
|
|
|
mockStorage._value.fontBId = mockFontB.id;
|
2026-04-15 22:25:34 +03:00
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
const store = new ComparisonStore();
|
|
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(store.fontA?.id).toBe(mockFontA.id);
|
|
|
|
|
expect(store.fontB?.id).toBe(mockFontB.id);
|
|
|
|
|
}, { timeout: 2000 });
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|
|
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
it('should handle fetch errors during restoration gracefully', async () => {
|
2026-03-02 22:18:05 +03:00
|
|
|
mockStorage._value.fontAId = mockFontA.id;
|
|
|
|
|
mockStorage._value.fontBId = mockFontB.id;
|
2026-04-15 22:25:34 +03:00
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
const store = new ComparisonStore();
|
|
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
// Store stays in valid state — no throw, fonts remain undefined
|
|
|
|
|
await vi.waitFor(() => expect(store.isLoading).toBe(true)); // stuck loading since no fonts
|
2026-03-02 22:18:05 +03:00
|
|
|
expect(store.fontA).toBeUndefined();
|
|
|
|
|
expect(store.fontB).toBeUndefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
describe('Default Fallbacks', () => {
|
|
|
|
|
it('should update storage with default IDs when storage is empty', async () => {
|
2026-06-01 17:25:05 +03:00
|
|
|
(fontCatalog as any).fonts = [mockFontA, mockFontB];
|
2026-04-15 22:25:34 +03:00
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
new ComparisonStore();
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
await vi.waitFor(() => {
|
|
|
|
|
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
|
|
|
|
|
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
|
|
|
|
|
});
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|
2026-05-28 14:58:18 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
2026-06-01 17:25:05 +03:00
|
|
|
(fontCatalog as any).fonts = [mockFontA, mockFontB];
|
2026-05-28 14:58:18 +03:00
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|
|
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
describe('Aggregate Loading State', () => {
|
|
|
|
|
it('should be loading initially when storage has IDs', async () => {
|
|
|
|
|
mockStorage._value.fontAId = mockFontA.id;
|
|
|
|
|
mockStorage._value.fontBId = mockFontB.id;
|
|
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
|
|
|
|
|
() => new Promise(r => setTimeout(() => r([mockFontA, mockFontB]), 50)),
|
|
|
|
|
);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
const store = new ComparisonStore();
|
2026-04-15 22:25:34 +03:00
|
|
|
expect(store.isLoading).toBe(true);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 2000 });
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('Reset Functionality', () => {
|
|
|
|
|
it('should reset all state and clear storage', () => {
|
|
|
|
|
const store = new ComparisonStore();
|
|
|
|
|
store.resetAll();
|
|
|
|
|
expect(mockStorage._clear).toHaveBeenCalled();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
it('should clear fontA and fontB on reset', async () => {
|
2026-03-02 22:18:05 +03:00
|
|
|
mockStorage._value.fontAId = mockFontA.id;
|
|
|
|
|
mockStorage._value.fontBId = mockFontB.id;
|
2026-04-15 22:25:34 +03:00
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
const store = new ComparisonStore();
|
2026-04-15 22:25:34 +03:00
|
|
|
await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
store.resetAll();
|
|
|
|
|
expect(store.fontA).toBeUndefined();
|
|
|
|
|
expect(store.fontB).toBeUndefined();
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|
|
|
|
|
});
|
2026-04-16 10:55:41 +03:00
|
|
|
|
|
|
|
|
describe('Pin / Unpin (eviction guard)', () => {
|
|
|
|
|
it('pins fontA and fontB when they are loaded', async () => {
|
|
|
|
|
mockStorage._value.fontAId = mockFontA.id;
|
|
|
|
|
mockStorage._value.fontBId = mockFontB.id;
|
|
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
|
|
|
|
|
|
|
|
|
new ComparisonStore();
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => {
|
2026-06-01 18:49:47 +03:00
|
|
|
expect(mockLifecycle.pin).toHaveBeenCalledWith(
|
2026-04-16 10:55:41 +03:00
|
|
|
mockFontA.id,
|
|
|
|
|
400,
|
|
|
|
|
mockFontA.features?.isVariable,
|
|
|
|
|
);
|
2026-06-01 18:49:47 +03:00
|
|
|
expect(mockLifecycle.pin).toHaveBeenCalledWith(
|
2026-04-16 10:55:41 +03:00
|
|
|
mockFontB.id,
|
|
|
|
|
400,
|
|
|
|
|
mockFontB.features?.isVariable,
|
|
|
|
|
);
|
|
|
|
|
}, { timeout: 2000 });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('unpins the old font when fontA is replaced', async () => {
|
|
|
|
|
mockStorage._value.fontAId = mockFontA.id;
|
|
|
|
|
mockStorage._value.fontBId = mockFontB.id;
|
|
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
|
|
|
|
|
|
|
|
|
const store = new ComparisonStore();
|
|
|
|
|
await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
|
|
|
|
|
|
|
|
|
|
const mockFontC: typeof mockFontA = { ...mockFontA, id: 'playfair', name: 'Playfair Display' };
|
|
|
|
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontC, mockFontB]);
|
|
|
|
|
store.fontA = mockFontC;
|
|
|
|
|
|
|
|
|
|
await vi.waitFor(() => {
|
2026-06-01 18:49:47 +03:00
|
|
|
expect(mockLifecycle.unpin).toHaveBeenCalledWith(
|
2026-04-16 10:55:41 +03:00
|
|
|
mockFontA.id,
|
|
|
|
|
400,
|
|
|
|
|
mockFontA.features?.isVariable,
|
|
|
|
|
);
|
2026-06-01 18:49:47 +03:00
|
|
|
expect(mockLifecycle.pin).toHaveBeenCalledWith(
|
2026-04-16 10:55:41 +03:00
|
|
|
mockFontC.id,
|
|
|
|
|
400,
|
|
|
|
|
mockFontC.features?.isVariable,
|
|
|
|
|
);
|
|
|
|
|
}, { timeout: 2000 });
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|