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

295 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 { queryClient } from '$shared/api/queryClient';
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,
});
return storage;
});
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,
fontCatalogStore: { fonts: [] },
fontLifecycleManager: {
touch: vi.fn(),
pin: vi.fn(),
unpin: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
},
getFontUrl: vi.fn(() => 'https://example.com/font.woff2'),
};
});
vi.mock('$features/AdjustTypography', () => ({
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
createTypographyControlManager: vi.fn(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
})),
}));
vi.mock('$features/AdjustTypography/model', () => ({
typographySettingsStore: {
weight: 400,
renderedSize: 48,
reset: vi.fn(),
},
}));
import {
fontCatalogStore,
fontLifecycleManager,
} from '$entities/Font';
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { ComparisonStore } from './comparisonStore.svelte';
describe('ComparisonStore', () => {
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
mockStorage._value = { fontAId: null, fontBId: null };
mockStorage._clear.mockClear();
(fontCatalogStore 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,
});
});
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 () => {
(fontCatalogStore 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.
(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', () => {
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(fontLifecycleManager.pin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
);
expect(fontLifecycleManager.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(fontLifecycleManager.unpin).toHaveBeenCalledWith(
mockFontA.id,
400,
mockFontA.features?.isVariable,
);
expect(fontLifecycleManager.pin).toHaveBeenCalledWith(
mockFontC.id,
400,
mockFontC.features?.isVariable,
);
}, { timeout: 2000 });
});
});
});