Files
frontend-svelte/src/widgets/ComparisonView/model/stores/comparisonStore.test.ts
T

316 lines
11 KiB
TypeScript
Raw Normal View History

/**
* Unit tests for ComparisonStore (TanStack Query refactor)
*
* Uses the real FontsByIdsStore so Svelte $state reactivity works correctly.
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
*/
2026-04-17 12:14:55 +03:00
/**
* @vitest-environment jsdom
*/
import type { UnifiedFont } from '$entities/Font';
import { UNIFIED_FONTS } from '$entities/Font/testing';
import { getQueryClient } from '$shared/api/queryClient';
const queryClient = getQueryClient();
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
const mockStorage = vi.hoisted(() => {
const storage: any = {};
storage._value = { fontAId: null, fontBId: null };
storage._clear = vi.fn(() => {
storage._value = { fontAId: null, fontBId: null };
});
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,
});
storage.destroy = vi.fn();
return storage;
});
// 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[] }));
vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte', () => ({
createPersistentStore: vi.fn(() => mockStorage),
}));
vi.mock('$entities/Font', async importOriginal => {
const actual = await importOriginal<typeof import('$entities/Font')>();
return {
...actual,
getFontUrl: vi.fn(() => 'https://example.com/font.woff2'),
};
});
const mockLifecycle = vi.hoisted(() => ({
touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
}));
// 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')>();
return {
...actual,
getFontCatalog: () => mockFontCatalog,
getFontLifecycleManager: () => mockLifecycle,
};
});
const mockTypography = vi.hoisted(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
}));
vi.mock('$features/AdjustTypography', () => ({
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
createTypographyControlManager: vi.fn(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
})),
getTypographySettingsStore: () => mockTypography,
}));
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { getFontCatalog } from '$entities/Font/model';
import { __resetFontCatalog } from '$entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte';
import { ComparisonStore } from './comparisonStore.svelte';
describe('ComparisonStore', () => {
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
let fontCatalog = getFontCatalog();
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
mockStorage._value = { fontAId: null, fontBId: null };
mockStorage._clear.mockClear();
(fontCatalog as any).fonts = [];
// Default: fetchFontsByIds returns empty so tests that don't care don't hang
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
// document.fonts: check returns true so #checkFontsLoaded resolves immediately
Object.defineProperty(document, 'fonts', {
value: {
check: vi.fn(() => true),
load: vi.fn(() => Promise.resolve()),
ready: Promise.resolve({} as FontFaceSet),
},
writable: true,
configurable: true,
});
});
afterEach(() => {
__resetFontCatalog();
});
describe('Initialization', () => {
it('should create store with initial empty state', () => {
const store = new ComparisonStore();
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
});
});
describe('Restoration from Storage (via FontsByIdsStore)', () => {
it('should restore fontA and fontB from stored IDs', 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);
expect(store.fontB?.id).toBe(mockFontB.id);
}, { timeout: 2000 });
});
it('should handle fetch errors during restoration gracefully', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
const store = new ComparisonStore();
// Store stays in valid state — no throw, fonts remain undefined
await vi.waitFor(() => expect(store.isLoading).toBe(true)); // stuck loading since no fonts
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
});
});
describe('Default Fallbacks', () => {
it('should update storage with default IDs when storage is empty', async () => {
(fontCatalog as any).fonts = [mockFontA, mockFontB];
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
new ComparisonStore();
await vi.waitFor(() => {
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
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.
(fontCatalog 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', () => {
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)),
);
const store = new ComparisonStore();
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 2000 });
});
});
describe('Reset Functionality', () => {
it('should reset all state and clear storage', () => {
const store = new ComparisonStore();
store.resetAll();
expect(mockStorage._clear).toHaveBeenCalled();
});
it('should clear fontA and fontB on reset', 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 });
store.resetAll();
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
});
});
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(() => {
expect(mockLifecycle.pin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
);
expect(mockLifecycle.pin).toHaveBeenCalledWith(
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(() => {
expect(mockLifecycle.unpin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
);
expect(mockLifecycle.pin).toHaveBeenCalledWith(
mockFontC.id,
400,
mockFontC.features?.isVariable,
);
}, { timeout: 2000 });
});
});
});