Compare commits

...

9 Commits

Author SHA1 Message Date
46d0d887b1 Merge pull request 'feature/unified-tanstack-query' (#36) from feature/unified-tanstack-query into main
All checks were successful
Workflow / build (push) Successful in 45s
Workflow / publish (push) Successful in 47s
Reviewed-on: #36
2026-04-16 04:53:28 +00:00
Ilia Mashkov
0a489a8adc fix(BaseQueryStore): use QueryObserverOptions instead of QueryOptions
All checks were successful
Workflow / build (pull_request) Successful in 58s
Workflow / publish (pull_request) Has been skipped
QueryOptions has queryKey as optional; QueryObserverOptions requires it,
matching what QueryObserver.constructor and setOptions actually expect.
2026-04-15 22:37:30 +03:00
Ilia Mashkov
cd349aec92 fix: imports 2026-04-15 22:32:45 +03:00
Ilia Mashkov
adaa6d7648 feat: refactor ComparisonStore to use BatchFontStore
Replace hand-rolled async fetching (fetchFontsByIds + isRestoring flag)
with BatchFontStore backed by TanStack Query. Three reactive effects
handle batch sync, CSS font loading, and default-font fallback.
isLoading now derives from batchStore.isLoading + fontsReady.
2026-04-15 22:25:34 +03:00
Ilia Mashkov
10f4781a67 test: enrich coverage for queryKeys, BaseQueryStore, and BatchFontStore
- queryKeys: add mutation-safety test for batch(), key hierarchy tests
  (list/batch/detail keys rooted in their parent base keys), and
  unique-key test for different detail IDs
- BaseQueryStore: add initial-state test (data undefined, isError false
  before any fetch resolves)
- BatchFontStore: add FontResponseError type assertion on malformed
  response, null error assertion on success, and setIds([]) disables
  query and returns empty fonts without triggering a fetch
2026-04-15 15:59:01 +03:00
Ilia Mashkov
f4a568832a feat: implement reactive BatchFontStore 2026-04-15 12:29:16 +03:00
Ilia Mashkov
4e9670118a feat: add seedFontCache utility 2026-04-15 12:21:04 +03:00
Ilia Mashkov
8e88d1b7cf feat: add BaseQueryStore for reactive query observers 2026-04-15 12:19:25 +03:00
Ilia Mashkov
1cbc262af7 feat: add stable query key factory 2026-04-15 12:06:32 +03:00
12 changed files with 643 additions and 600 deletions

View File

@@ -19,10 +19,13 @@ vi.mock('$shared/api/api', () => ({
}));
import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
fetchFontsByIds,
fetchProxyFontById,
fetchProxyFonts,
seedFontCache,
} from './proxyFonts';
const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/fonts';
@@ -46,6 +49,7 @@ function mockApiGet<T>(data: T) {
describe('proxyFonts', () => {
beforeEach(() => {
vi.mocked(api.get).mockReset();
queryClient.clear();
});
describe('fetchProxyFonts', () => {
@@ -168,4 +172,33 @@ describe('proxyFonts', () => {
expect(result).toEqual([]);
});
});
describe('seedFontCache', () => {
test('should populate cache with multiple fonts', () => {
const fonts = [
createMockFont({ id: '1', name: 'A' }),
createMockFont({ id: '2', name: 'B' }),
];
seedFontCache(fonts);
expect(queryClient.getQueryData(fontKeys.detail('1'))).toEqual(fonts[0]);
expect(queryClient.getQueryData(fontKeys.detail('2'))).toEqual(fonts[1]);
});
test('should update existing cached fonts with new data', () => {
const id = 'update-me';
queryClient.setQueryData(fontKeys.detail(id), createMockFont({ id, name: 'Old' }));
const updated = createMockFont({ id, name: 'New' });
seedFontCache([updated]);
expect(queryClient.getQueryData(fontKeys.detail(id))).toEqual(updated);
});
test('should handle empty input arrays gracefully', () => {
const spy = vi.spyOn(queryClient, 'setQueryData');
seedFontCache([]);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
});

View File

@@ -11,13 +11,23 @@
*/
import { api } from '$shared/api/api';
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import { buildQueryString } from '$shared/lib/utils';
import type { QueryParams } from '$shared/lib/utils';
import type { UnifiedFont } from '../../model/types';
import type {
FontCategory,
FontSubset,
} from '../../model/types';
/**
* Normalizes cache by seeding individual font entries from collection responses.
* This ensures that a font fetched in a list or batch is available via its detail key.
*
* @param fonts - Array of fonts to cache
*/
export function seedFontCache(fonts: UnifiedFont[]): void {
fonts.forEach(font => {
queryClient.setQueryData(fontKeys.detail(font.id), font);
});
}
/**
* Proxy API base URL

View File

@@ -1,7 +1,2 @@
export {
appliedFontsManager,
createFontStore,
FontStore,
fontStore,
} from './store';
export * from './store';
export * from './types';

View File

@@ -0,0 +1,91 @@
import { fontKeys } from '$shared/api/queryKeys';
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
import {
fetchFontsByIds,
seedFontCache,
} from '../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../lib/errors/errors';
import type { UnifiedFont } from '../../model/types';
/**
* Internal fetcher that seeds the cache and handles error wrapping.
* Standalone function to avoid 'this' issues during construction.
*/
async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
if (ids.length === 0) return [];
let response: UnifiedFont[];
try {
response = await fetchFontsByIds(ids);
} catch (cause) {
throw new FontNetworkError(cause);
}
if (!response || !Array.isArray(response)) {
throw new FontResponseError('batchResponse', response);
}
seedFontCache(response);
return response;
}
/**
* Reactive store for fetching and caching batches of fonts by ID.
* Integrates with TanStack Query via BaseQueryStore and handles
* normalized cache seeding.
*/
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
constructor(initialIds: string[] = []) {
super({
queryKey: fontKeys.batch(initialIds),
queryFn: () => fetchAndSeed(initialIds),
enabled: initialIds.length > 0,
retry: false,
});
}
/**
* Updates the IDs to fetch. Triggers a new query.
*
* @param ids - Array of font IDs
*/
setIds(ids: string[]): void {
this.updateOptions({
queryKey: fontKeys.batch(ids),
queryFn: () => fetchAndSeed(ids),
enabled: ids.length > 0,
retry: false,
});
}
/**
* Array of fetched fonts
*/
get fonts(): UnifiedFont[] {
return this.result.data ?? [];
}
/**
* Whether the query is currently loading
*/
get isLoading(): boolean {
return this.result.isLoading;
}
/**
* Whether the query encountered an error
*/
get isError(): boolean {
return this.result.isError;
}
/**
* The error object if the query failed
*/
get error(): Error | null {
return (this.result.error as Error) ?? null;
}
}

View File

@@ -0,0 +1,107 @@
import { queryClient } from '$shared/api/queryClient';
import { fontKeys } from '$shared/api/queryKeys';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import * as api from '../../api/proxy/proxyFonts';
import {
FontNetworkError,
FontResponseError,
} from '../../lib/errors/errors';
import { BatchFontStore } from './batchFontStore.svelte';
describe('BatchFontStore', () => {
beforeEach(() => {
queryClient.clear();
vi.clearAllMocks();
});
describe('Fetch Behavior', () => {
it('should skip fetch when initialized with empty IDs', async () => {
const spy = vi.spyOn(api, 'fetchFontsByIds');
const store = new BatchFontStore([]);
expect(spy).not.toHaveBeenCalled();
expect(store.fonts).toEqual([]);
});
it('should fetch and seed cache for valid IDs', async () => {
const fonts = [{ id: 'a', name: 'A' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
});
});
describe('Loading States', () => {
it('should transition through loading state', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
);
const store = new BatchFontStore(['a']);
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
});
});
describe('Error Handling', () => {
it('should wrap network failures in FontNetworkError', async () => {
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontNetworkError);
});
it('should handle malformed API responses with FontResponseError', async () => {
// Mocking a malformed response that the store should validate
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
expect(store.error).toBeInstanceOf(FontResponseError);
});
it('should have null error in success state', async () => {
const fonts = [{ id: 'a' }] as any[];
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
expect(store.error).toBeNull();
});
});
describe('Disable Behavior', () => {
it('should return empty fonts and not fetch when setIds is called with empty array', async () => {
const fonts1 = [{ id: 'a' }] as any[];
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
spy.mockClear();
store.setIds([]);
await vi.waitFor(() => expect(store.fonts).toEqual([]), { timeout: 1000 });
expect(spy).not.toHaveBeenCalled();
});
});
describe('Reactivity', () => {
it('should refetch when setIds is called', async () => {
const fonts1 = [{ id: 'a' }] as any[];
const fonts2 = [{ id: 'b' }] as any[];
vi.spyOn(api, 'fetchFontsByIds')
.mockResolvedValueOnce(fonts1)
.mockResolvedValueOnce(fonts2);
const store = new BatchFontStore(['a']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
store.setIds(['b']);
await vi.waitFor(() => expect(store.fonts).toEqual(fonts2), { timeout: 1000 });
});
});
});

View File

@@ -1,6 +1,9 @@
// Applied fonts manager
export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte';
// Batch font store
export { BatchFontStore } from './batchFontStore.svelte';
// Single FontStore
export {
createFontStore,

View File

@@ -0,0 +1,73 @@
import {
describe,
expect,
it,
} from 'vitest';
import { fontKeys } from './queryKeys';
describe('fontKeys', () => {
describe('Hierarchy', () => {
it('should generate base keys', () => {
expect(fontKeys.all).toEqual(['fonts']);
expect(fontKeys.lists()).toEqual(['fonts', 'list']);
expect(fontKeys.batches()).toEqual(['fonts', 'batch']);
expect(fontKeys.details()).toEqual(['fonts', 'detail']);
});
});
describe('Batch Keys (Stability & Sorting)', () => {
it('should sort IDs for stable serialization', () => {
const key1 = fontKeys.batch(['b', 'a', 'c']);
const key2 = fontKeys.batch(['c', 'b', 'a']);
const expected = ['fonts', 'batch', ['a', 'b', 'c']];
expect(key1).toEqual(expected);
expect(key2).toEqual(expected);
});
it('should handle empty ID arrays', () => {
expect(fontKeys.batch([])).toEqual(['fonts', 'batch', []]);
});
it('should not mutate the input array when sorting', () => {
const ids = ['c', 'b', 'a'];
fontKeys.batch(ids);
expect(ids).toEqual(['c', 'b', 'a']);
});
it('batch key should be rooted in batches() base', () => {
const key = fontKeys.batch(['a']);
expect(key.slice(0, 2)).toEqual(fontKeys.batches());
});
});
describe('List Keys (Parameters)', () => {
it('should include parameters in list keys', () => {
const params = { provider: 'google' };
expect(fontKeys.list(params)).toEqual(['fonts', 'list', params]);
});
it('should handle empty parameters', () => {
expect(fontKeys.list({})).toEqual(['fonts', 'list', {}]);
});
it('list key should be rooted in lists() base', () => {
const key = fontKeys.list({ provider: 'google' });
expect(key.slice(0, 2)).toEqual(fontKeys.lists());
});
});
describe('Detail Keys', () => {
it('should generate unique detail keys per ID', () => {
expect(fontKeys.detail('roboto')).toEqual(['fonts', 'detail', 'roboto']);
});
it('should generate different keys for different IDs', () => {
expect(fontKeys.detail('roboto')).not.toEqual(fontKeys.detail('open-sans'));
});
it('detail key should be rooted in details() base', () => {
const key = fontKeys.detail('roboto');
expect(key.slice(0, 2)).toEqual(fontKeys.details());
});
});
});

View File

@@ -0,0 +1,23 @@
/**
* Stable query key factory for font-related queries.
* Ensures consistent serialization for batch requests by sorting IDs.
*/
export const fontKeys = {
/** Base key for all font queries */
all: ['fonts'] as const,
/** Keys for font list queries */
lists: () => [...fontKeys.all, 'list'] as const,
/** Specific font list key with filter parameters */
list: (params: object) => [...fontKeys.lists(), params] as const,
/** Keys for font batch queries */
batches: () => [...fontKeys.all, 'batch'] as const,
/** Specific batch key, sorted for stability */
batch: (ids: string[]) => [...fontKeys.batches(), [...ids].sort()] as const,
/** Keys for font detail queries */
details: () => [...fontKeys.all, 'detail'] as const,
/** Specific font detail key by ID */
detail: (id: string) => [...fontKeys.details(), id] as const,
} as const;

View File

@@ -0,0 +1,51 @@
import { queryClient } from '$shared/api/queryClient';
import {
QueryObserver,
type QueryObserverOptions,
type QueryObserverResult,
} from '@tanstack/query-core';
/**
* Abstract base class for reactive Svelte 5 stores backed by TanStack Query.
*
* Provides a unified way to use TanStack Query observers within Svelte 5 classes
* using runes for reactivity. Handles subscription lifecycle automatically.
*
* @template TData - The type of data returned by the query.
* @template TError - The type of error that can be thrown.
*/
export abstract class BaseQueryStore<TData, TError = Error> {
#result = $state<QueryObserverResult<TData, TError>>({} as QueryObserverResult<TData, TError>);
#observer: QueryObserver<TData, TError>;
#unsubscribe: () => void;
constructor(options: QueryObserverOptions<TData, TError, TData, any, any>) {
this.#observer = new QueryObserver(queryClient, options);
this.#unsubscribe = this.#observer.subscribe(result => {
this.#result = result;
});
}
/**
* Current query result (reactive)
*/
protected get result(): QueryObserverResult<TData, TError> {
return this.#result;
}
/**
* Updates observer options dynamically.
* Use this when query parameters or dependencies change.
*/
protected updateOptions(options: QueryObserverOptions<TData, TError, TData, any, any>): void {
this.#observer.setOptions(options);
}
/**
* Cleans up the observer subscription.
* Should be called when the store is no longer needed.
*/
destroy(): void {
this.#unsubscribe();
}
}

View File

@@ -0,0 +1,91 @@
import { queryClient } from '$shared/api/queryClient';
import {
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { BaseQueryStore } from './BaseQueryStore.svelte';
class TestStore extends BaseQueryStore<string> {
constructor(key = ['test'], fn = () => Promise.resolve('ok')) {
super({
queryKey: key,
queryFn: fn,
retry: false, // Disable retries for faster error testing
});
}
get data() {
return this.result.data;
}
get isLoading() {
return this.result.isLoading;
}
get isError() {
return this.result.isError;
}
update(newKey: string[], newFn?: () => Promise<string>) {
this.updateOptions({
queryKey: newKey,
queryFn: newFn ?? (() => Promise.resolve('ok')),
retry: false,
});
}
}
import * as tq from '@tanstack/query-core';
// ... (TestStore remains same)
describe('BaseQueryStore', () => {
beforeEach(() => {
queryClient.clear();
});
describe('Lifecycle & Fetching', () => {
it('should transition from loading to success', async () => {
const store = new TestStore();
expect(store.isLoading).toBe(true);
await vi.waitFor(() => expect(store.data).toBe('ok'), { timeout: 1000 });
expect(store.isLoading).toBe(false);
});
it('should have undefined data and no error in initial loading state', () => {
const store = new TestStore(['initial-state'], () => new Promise(r => setTimeout(() => r('late'), 500)));
expect(store.data).toBeUndefined();
expect(store.isError).toBe(false);
});
});
describe('Error Handling', () => {
it('should handle query failures', async () => {
const store = new TestStore(['fail'], () => Promise.reject(new Error('fail')));
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
});
});
describe('Reactivity', () => {
it('should refetch and update data when options change', async () => {
const store = new TestStore(['key1'], () => Promise.resolve('val1'));
await vi.waitFor(() => expect(store.data).toBe('val1'), { timeout: 1000 });
store.update(['key2'], () => Promise.resolve('val2'));
await vi.waitFor(() => expect(store.data).toBe('val2'), { timeout: 1000 });
});
});
describe('Cleanup', () => {
it('should unsubscribe observer on destroy', () => {
const unsubscribe = vi.fn();
const subscribeSpy = vi.spyOn(tq.QueryObserver.prototype, 'subscribe').mockReturnValue(unsubscribe);
const store = new TestStore();
store.destroy();
expect(unsubscribe).toHaveBeenCalled();
subscribeSpy.mockRestore();
});
});
});

View File

@@ -1,5 +1,5 @@
/**
* Font comparison store for side-by-side font comparison
* Font comparison store — TanStack Query refactor
*
* Manages the state for comparing two fonts character by character.
* Persists font selection to localStorage and handles font loading
@@ -7,17 +7,17 @@
*
* Features:
* - Persistent font selection (survives page refresh)
* - Font loading state tracking
* - Font loading state tracking via BatchFontStore + TanStack Query
* - Sample text management
* - Typography controls (size, weight, line height, spacing)
* - Slider position for character-by-character morphing
*/
import {
BatchFontStore,
type FontLoadRequestConfig,
type UnifiedFont,
appliedFontsManager,
fetchFontsByIds,
fontStore,
getFontUrl,
} from '$entities/Font';
@@ -47,11 +47,13 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
});
/**
* Store for managing font comparison state
* Store for managing font comparison state.
*
* Handles font selection persistence, fetching, and loading state tracking.
* Uses the CSS Font Loading API to ensure fonts are loaded before
* showing the comparison interface.
* Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing
* the previous hand-rolled async fetch approach. Three reactive effects
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the
* CSS Font Loading API, and (3) falling back to default fonts when
* storage is empty.
*/
export class ComparisonStore {
/** Font for side A */
@@ -60,8 +62,6 @@ export class ComparisonStore {
#fontB = $state<UnifiedFont | undefined>();
/** Sample text to display */
#sampleText = $state('The quick brown fox jumps over the lazy dog');
/** Whether currently restoring from storage */
#isRestoring = $state(true);
/** Whether fonts are loaded and ready to display */
#fontsReady = $state(false);
/** Active side for single-font operations */
@@ -70,13 +70,32 @@ export class ComparisonStore {
#sliderPosition = $state(50);
/** Typography controls for this comparison */
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
/** TanStack Query-backed batch font fetcher */
#batchStore: BatchFontStore;
constructor() {
this.restoreFromStorage();
// Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value;
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []);
// Reactively handle font loading and default selection
$effect.root(() => {
// Effect 1: Trigger font loading whenever selection or weight changes
// Effect 1: Sync batch results → fontA / fontB
$effect(() => {
const fonts = this.#batchStore.fonts;
if (fonts.length === 0) return;
const { fontAId: aId, fontBId: bId } = storage.value;
if (aId) {
const fa = fonts.find(f => f.id === aId);
if (fa) this.#fontA = fa;
}
if (bId) {
const fb = fonts.find(f => f.id === bId);
if (fb) this.#fontB = fb;
}
});
// Effect 2: Trigger font loading whenever selection or weight changes
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
@@ -104,25 +123,17 @@ export class ComparisonStore {
}
});
// Effect 2: Set defaults if we aren't restoring and have no selection
// Effect 3: Set default fonts when storage is empty
$effect(() => {
// Wait until we are done checking storage
if (this.#isRestoring) {
return;
}
if (this.#fontA && this.#fontB) return;
// If we already have a selection, do nothing
if (this.#fontA && this.#fontB) {
return;
}
// Check if fonts are available to set as defaults
const fonts = fontStore.fonts;
if (fonts.length >= 2) {
// We need full objects with all URLs, so we trigger a batch fetch
// This is the "batch request" seen on initial load when storage is empty
untrack(() => {
this.restoreDefaults([fonts[0].id, fonts[fonts.length - 1].id]);
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#batchStore.setIds([id1, id2]);
});
}
});
@@ -130,26 +141,7 @@ export class ComparisonStore {
}
/**
* Set default fonts by fetching full objects from the API
*/
private async restoreDefaults(ids: string[]) {
this.#isRestoring = true;
try {
const fullFonts = await fetchFontsByIds(ids);
if (fullFonts.length >= 2) {
this.#fontA = fullFonts[0];
this.#fontB = fullFonts[1];
this.updateStorage();
}
} catch (error) {
console.warn('[ComparisonStore] Failed to set defaults:', error);
} finally {
this.#isRestoring = false;
}
}
/**
* Checks if fonts are actually loaded in the browser at current weight
* Checks if fonts are actually loaded in the browser at current weight.
*
* Uses CSS Font Loading API to prevent FOUT. Waits for fonts to load
* and forces a layout/paint cycle before marking as ready.
@@ -182,71 +174,35 @@ export class ComparisonStore {
this.#fontsReady = false;
try {
// Step 1: Load fonts into memory
await Promise.all([
document.fonts.load(fontAString),
document.fonts.load(fontBString),
]);
// Step 2: Wait for browser to be ready to render
await document.fonts.ready;
// Step 3: Force a layout/paint cycle (critical!)
await new Promise(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve); // Double rAF ensures paint completes
requestAnimationFrame(resolve);
});
});
this.#fontsReady = true;
} catch (error) {
console.warn('[ComparisonStore] Font loading failed:', error);
setTimeout(() => this.#fontsReady = true, 1000);
setTimeout(() => (this.#fontsReady = true), 1000);
}
}
/**
* Restore state from persistent storage
*
* Fetches saved fonts from the API and restores them to the store.
*/
async restoreFromStorage() {
this.#isRestoring = true;
const { fontAId, fontBId } = storage.value;
if (fontAId && fontBId) {
try {
// Batch fetch the saved fonts
const fonts = await fetchFontsByIds([fontAId, fontBId]);
const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId);
const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId);
if (loadedFontA && loadedFontB) {
this.#fontA = loadedFontA;
this.#fontB = loadedFontB;
}
} catch (error) {
console.warn('[ComparisonStore] Failed to restore fonts:', error);
}
}
// Mark restoration as complete (whether success or fail)
this.#isRestoring = false;
}
/**
* Update storage with current state
* Updates persistent storage with the current font selection.
*/
private updateStorage() {
// Don't save if we are currently restoring (avoid race)
if (this.#isRestoring) return;
storage.value = {
fontAId: this.#fontA?.id ?? null,
fontBId: this.#fontB?.id ?? null,
};
}
// ── Getters / Setters ─────────────────────────────────────────────────────
/** Typography control manager */
get typography() {
return this.#typography;
@@ -299,33 +255,23 @@ export class ComparisonStore {
this.#sliderPosition = value;
}
/**
* Check if both fonts are selected and loaded
*/
/** Whether both fonts are selected and loaded */
get isReady() {
return !!this.#fontA && !!this.#fontB && this.#fontsReady;
}
/** Whether currently loading or restoring */
/** Whether currently loading (batch fetch in flight or fonts not yet painted) */
get isLoading() {
return this.#isRestoring || !this.#fontsReady;
return this.#batchStore.isLoading || !this.#fontsReady;
}
/**
* Public initializer (optional, as constructor starts it)
*/
initialize() {
if (!this.#isRestoring && !this.#fontA && !this.#fontB) {
this.restoreFromStorage();
}
}
/**
* Reset all state and clear storage
* Resets all state, clears storage, and disables the batch query.
*/
resetAll() {
this.#fontA = undefined;
this.#fontB = undefined;
this.#batchStore.setIds([]);
storage.clear();
this.#typography.reset();
}

View File

@@ -1,20 +1,16 @@
/**
* Unit tests for ComparisonStore
* Unit tests for ComparisonStore (TanStack Query refactor)
*
* Tests the font comparison store functionality including:
* - Font loading via CSS Font Loading API
* - Storage synchronization when fonts change
* - Default values from fontStore
* - Reset functionality
* - isReady computed state
* Uses the real BatchFontStore so Svelte $state reactivity works correctly.
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
*/
/** @vitest-environment jsdom */
import type { UnifiedFont } from '$entities/Font';
import { UNIFIED_FONTS } from '$entities/Font/lib/mocks';
import { queryClient } from '$shared/api/queryClient';
import {
afterEach,
beforeEach,
describe,
expect,
@@ -22,80 +18,13 @@ import {
vi,
} from 'vitest';
// Mock all dependencies
vi.mock('$entities/Font', () => ({
fetchFontsByIds: vi.fn(),
fontStore: { fonts: [] },
appliedFontsManager: {
touch: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
},
getFontUrl: vi.fn(() => 'http://example.com/font.woff2'),
}));
// ── Persistent-store mock ─────────────────────────────────────────────────────
vi.mock('$features/SetupFont', () => ({
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [
{
id: 'font_size',
value: 48,
min: 8,
max: 100,
step: 1,
increaseLabel: 'Increase Font Size',
decreaseLabel: 'Decrease Font Size',
controlLabel: 'Size',
},
{
id: 'font_weight',
value: 400,
min: 100,
max: 900,
step: 100,
increaseLabel: 'Increase Font Weight',
decreaseLabel: 'Decrease Font Weight',
controlLabel: 'Weight',
},
{
id: 'line_height',
value: 1.5,
min: 1,
max: 2,
step: 0.05,
increaseLabel: 'Increase Line Height',
decreaseLabel: 'Decrease Line Height',
controlLabel: 'Leading',
},
{
id: 'letter_spacing',
value: 0,
min: -0.1,
max: 0.5,
step: 0.01,
increaseLabel: 'Increase Letter Spacing',
decreaseLabel: 'Decrease Letter Spacing',
controlLabel: 'Tracking',
},
],
createTypographyControlManager: vi.fn(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
})),
}));
// Create mock storage accessible from both vi.mock factory and tests
const mockStorage = vi.hoisted(() => {
const storage: any = {};
storage._value = {
fontAId: null as string | null,
fontBId: null as string | null,
};
storage._value = { fontAId: null, fontBId: null };
storage._clear = vi.fn(() => {
storage._value = {
fontAId: null,
fontBId: null,
};
storage._value = { fontAId: null, fontBId: null };
});
Object.defineProperty(storage, 'value', {
@@ -122,471 +51,162 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
createPersistentStore: vi.fn(() => mockStorage),
}));
// Import after mocks
import {
fetchFontsByIds,
fontStore,
} from '$entities/Font';
import { createTypographyControlManager } from '$features/SetupFont';
// ── $entities/Font mock — keep real BatchFontStore, stub singletons ───────────
vi.mock('$entities/Font', async () => {
const { BatchFontStore } = await import(
'$entities/Font/model/store/batchFontStore.svelte'
);
return {
BatchFontStore,
fontStore: { fonts: [] },
appliedFontsManager: {
touch: vi.fn(),
getFontStatus: vi.fn(),
ready: vi.fn(() => Promise.resolve()),
},
getFontUrl: vi.fn(() => 'https://example.com/font.woff2'),
};
});
// ── $features/SetupFont mock ──────────────────────────────────────────────────
vi.mock('$features/SetupFont', () => ({
DEFAULT_TYPOGRAPHY_CONTROLS_DATA: [],
createTypographyControlManager: vi.fn(() => ({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
})),
}));
// ── Imports (after mocks) ─────────────────────────────────────────────────────
import { fontStore } from '$entities/Font';
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
import { ComparisonStore } from './comparisonStore.svelte';
describe('ComparisonStore', () => {
// Mock fonts
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto;
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans;
// ── Tests ─────────────────────────────────────────────────────────────────────
// Mock document.fonts
let mockFontFaceSet: {
check: ReturnType<typeof vi.fn>;
load: ReturnType<typeof vi.fn>;
ready: Promise<FontFaceSet>;
};
describe('ComparisonStore', () => {
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
beforeEach(() => {
// Clear all mocks
queryClient.clear();
vi.clearAllMocks();
// Clear localStorage
localStorage.clear();
// Reset mock storage value via the helper
mockStorage._value = {
fontAId: null,
fontBId: null,
};
mockStorage._value = { fontAId: null, fontBId: null };
mockStorage._clear.mockClear();
// Setup mock fontStore
(fontStore as any).fonts = [];
// Setup mock fetchFontsByIds
vi.mocked(fetchFontsByIds).mockResolvedValue([]);
// Setup mock createTypographyControlManager
vi.mocked(createTypographyControlManager).mockReturnValue({
weight: 400,
renderedSize: 48,
reset: vi.fn(),
} as any);
// Setup mock document.fonts
mockFontFaceSet = {
check: vi.fn(() => true),
load: vi.fn(() => Promise.resolve()),
ready: Promise.resolve({} as FontFaceSet),
};
// 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: mockFontFaceSet,
value: {
check: vi.fn(() => true),
load: vi.fn(() => Promise.resolve()),
ready: Promise.resolve({} as FontFaceSet),
},
writable: true,
configurable: true,
});
});
afterEach(() => {
// Ensure document.fonts is always reset to a valid mock
// This prevents issues when tests delete or undefined document.fonts
if (!document.fonts || typeof document.fonts.check !== 'function') {
Object.defineProperty(document, 'fonts', {
value: {
check: vi.fn(() => true),
load: vi.fn(() => Promise.resolve()),
ready: Promise.resolve({} as FontFaceSet),
},
writable: true,
configurable: true,
});
}
});
// ── Initialization ────────────────────────────────────────────────────────
describe('Initialization', () => {
it('should create store with initial empty state', () => {
const store = new ComparisonStore();
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
expect(store.text).toBe('The quick brown fox jumps over the lazy dog');
expect(store.side).toBe('A');
expect(store.sliderPosition).toBe(50);
});
it('should initialize with default sample text', () => {
const store = new ComparisonStore();
expect(store.text).toBe('The quick brown fox jumps over the lazy dog');
});
it('should have typography manager attached', () => {
const store = new ComparisonStore();
expect(store.typography).toBeDefined();
});
});
describe('Storage Synchronization', () => {
it('should update storage when fontA is set', () => {
const store = new ComparisonStore();
store.fontA = mockFontA;
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
});
it('should update storage when fontB is set', () => {
const store = new ComparisonStore();
store.fontB = mockFontB;
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
});
it('should update storage when both fonts are set', () => {
const store = new ComparisonStore();
store.fontA = mockFontA;
store.fontB = mockFontB;
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
expect(mockStorage._value.fontBId).toBe(mockFontB.id);
});
it('should set storage to null when font is set to undefined', () => {
const store = new ComparisonStore();
store.fontA = mockFontA;
expect(mockStorage._value.fontAId).toBe(mockFontA.id);
store.fontA = undefined;
expect(mockStorage._value.fontAId).toBeNull();
});
});
describe('Restore from Storage', () => {
it('should restore fonts from storage when both IDs exist', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
const store = new ComparisonStore();
await store.restoreFromStorage();
expect(fetchFontsByIds).toHaveBeenCalledWith([mockFontA.id, mockFontB.id]);
expect(store.fontA).toEqual(mockFontA);
expect(store.fontB).toEqual(mockFontB);
});
it('should not restore when storage has null IDs', async () => {
mockStorage._value.fontAId = null;
mockStorage._value.fontBId = null;
const store = new ComparisonStore();
await store.restoreFromStorage();
expect(fetchFontsByIds).not.toHaveBeenCalled();
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
});
});
it('should handle fetch errors gracefully when restoring', async () => {
// ── Restoration from Storage ──────────────────────────────────────────────
describe('Restoration from Storage (via BatchFontStore)', () => {
it('should restore fontA and fontB from stored IDs', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
vi.mocked(fetchFontsByIds).mockRejectedValue(new Error('Network error'));
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
const store = new ComparisonStore();
await store.restoreFromStorage();
expect(consoleSpy).toHaveBeenCalled();
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();
consoleSpy.mockRestore();
});
});
it('should handle partial restoration when only one font is found', async () => {
// Ensure fontStore is empty so $effect doesn't interfere
(fontStore as any).fonts = [];
// ── Default Fallbacks ─────────────────────────────────────────────────────
describe('Default Fallbacks', () => {
it('should update storage with default IDs when storage is empty', async () => {
(fontStore 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);
});
});
});
// ── Loading State ─────────────────────────────────────────────────────────
describe('Aggregate Loading State', () => {
it('should be loading initially when storage has IDs', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
// Only return fontA (simulating partial data from API)
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA]);
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
() => new Promise(r => setTimeout(() => r([mockFontA, mockFontB]), 50)),
);
const store = new ComparisonStore();
// Wait for async restoration from constructor
await new Promise(resolve => setTimeout(resolve, 10));
expect(store.isLoading).toBe(true);
// The store should call fetchFontsByIds with both IDs
expect(fetchFontsByIds).toHaveBeenCalledWith([mockFontA.id, mockFontB.id]);
// When only one font is found, the store handles it gracefully
// (both fonts need to be found for restoration to set them)
// The key behavior tested here is that partial fetch doesn't crash
// and the store remains functional
// Store should not have crashed and should be in a valid state
expect(store).toBeDefined();
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 2000 });
});
});
describe('Font Loading with CSS Font Loading API', () => {
it('should construct correct font strings for checking', async () => {
mockFontFaceSet.check.mockReturnValue(false);
(fontStore as any).fonts = [mockFontA, mockFontB];
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
const store = new ComparisonStore();
store.fontA = mockFontA;
store.fontB = mockFontB;
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 0));
// Check that font strings are constructed correctly
const expectedFontAString = '400 48px "Roboto"';
const expectedFontBString = '400 48px "Open Sans"';
expect(mockFontFaceSet.load).toHaveBeenCalledWith(expectedFontAString);
expect(mockFontFaceSet.load).toHaveBeenCalledWith(expectedFontBString);
});
it('should handle missing document.fonts API gracefully', () => {
// Delete the fonts property entirely to simulate missing API
delete (document as any).fonts;
const store = new ComparisonStore();
store.fontA = mockFontA;
store.fontB = mockFontB;
// Should not throw and should still work
expect(store.fontA).toStrictEqual(mockFontA);
expect(store.fontB).toStrictEqual(mockFontB);
});
it('should handle font loading errors gracefully', async () => {
// Mock check to return false (fonts not loaded)
mockFontFaceSet.check.mockReturnValue(false);
// Mock load to fail
mockFontFaceSet.load.mockRejectedValue(new Error('Font load failed'));
(fontStore as any).fonts = [mockFontA, mockFontB];
vi.mocked(fetchFontsByIds).mockResolvedValue([mockFontA, mockFontB]);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const store = new ComparisonStore();
store.fontA = mockFontA;
store.fontB = mockFontB;
// Wait for async operations and timeout fallback
await new Promise(resolve => setTimeout(resolve, 1100));
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe('Default Values from fontStore', () => {
it('should set default fonts from fontStore when available', () => {
// Note: This test relies on Svelte 5's $effect which may not work
// reliably in the test environment. We test the logic path instead.
(fontStore as any).fonts = [mockFontA, mockFontB];
const store = new ComparisonStore();
// The default fonts should be set when storage is empty
// In the actual app, this happens via $effect in the constructor
// In tests, we verify the store can have fonts set manually
store.fontA = mockFontA;
store.fontB = mockFontB;
expect(store.fontA).toBeDefined();
expect(store.fontB).toBeDefined();
});
it('should use first and last font from fontStore as defaults', () => {
const mockFontC = UNIFIED_FONTS.lato;
(fontStore as any).fonts = [mockFontA, mockFontC, mockFontB];
const store = new ComparisonStore();
// Manually set the first font to test the logic
store.fontA = mockFontA;
expect(store.fontA?.id).toBe(mockFontA.id);
});
});
// ── Reset ─────────────────────────────────────────────────────────────────
describe('Reset Functionality', () => {
it('should reset all state and clear storage', () => {
const store = new ComparisonStore();
// Set some values
store.fontA = mockFontA;
store.fontB = mockFontB;
store.text = 'Custom text';
store.side = 'B';
store.sliderPosition = 75;
// Reset
store.resetAll();
// Check all state is cleared
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
expect(mockStorage._clear).toHaveBeenCalled();
});
it('should reset typography controls when resetAll is called', () => {
const mockReset = vi.fn();
vi.mocked(createTypographyControlManager).mockReturnValue({
weight: 400,
renderedSize: 48,
reset: mockReset,
} as any);
const store = new ComparisonStore();
store.resetAll();
expect(mockReset).toHaveBeenCalled();
});
it('should not affect text property on reset', () => {
const store = new ComparisonStore();
store.text = 'Custom text';
store.resetAll();
// Text is not reset by resetAll
expect(store.text).toBe('Custom text');
});
});
describe('isReady Computed State', () => {
it('should return false when fonts are not set', () => {
const store = new ComparisonStore();
expect(store.isReady).toBe(false);
});
it('should return false when only fontA is set', () => {
const store = new ComparisonStore();
store.fontA = mockFontA;
expect(store.isReady).toBe(false);
});
it('should return false when only fontB is set', () => {
const store = new ComparisonStore();
store.fontB = mockFontB;
expect(store.isReady).toBe(false);
});
it('should return true when both fonts are set', () => {
const store = new ComparisonStore();
// Manually set fonts
store.fontA = mockFontA;
store.fontB = mockFontB;
// After setting both fonts, isReady should eventually be true
// Note: In actual testing with Svelte 5 runes, the reactivity
// may not work in Node.js environment, so this tests the logic path
expect(store.fontA).toBeDefined();
expect(store.fontB).toBeDefined();
});
});
describe('isLoading State', () => {
it('should return true when restoring from storage', async () => {
it('should clear fontA and fontB on reset', async () => {
mockStorage._value.fontAId = mockFontA.id;
mockStorage._value.fontBId = mockFontB.id;
// Make fetch take some time
vi.mocked(fetchFontsByIds).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve([mockFontA, mockFontB]), 10)),
);
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
const store = new ComparisonStore();
const restorePromise = store.restoreFromStorage();
await vi.waitFor(() => expect(store.fontA?.id).toBe(mockFontA.id), { timeout: 2000 });
// While restoring, isLoading should be true
expect(store.isLoading).toBe(true);
await restorePromise;
// After restoration, isLoading should be false
expect(store.isLoading).toBe(false);
});
});
describe('Getters and Setters', () => {
it('should allow getting and setting sample text', () => {
const store = new ComparisonStore();
store.text = 'Hello World';
expect(store.text).toBe('Hello World');
});
it('should allow getting and setting side', () => {
const store = new ComparisonStore();
expect(store.side).toBe('A');
store.side = 'B';
expect(store.side).toBe('B');
});
it('should allow getting and setting slider position', () => {
const store = new ComparisonStore();
store.sliderPosition = 75;
expect(store.sliderPosition).toBe(75);
});
it('should allow getting typography manager', () => {
const store = new ComparisonStore();
expect(store.typography).toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle empty font names gracefully', () => {
const emptyFont = { ...mockFontA, name: '' };
const store = new ComparisonStore();
store.fontA = emptyFont;
store.fontB = mockFontB;
// Should not throw
expect(store.fontA).toEqual(emptyFont);
});
it('should handle fontA with undefined name', () => {
const noNameFont = { ...mockFontA, name: undefined as any };
const store = new ComparisonStore();
store.fontA = noNameFont;
expect(store.fontA).toEqual(noNameFont);
});
it('should handle setSide with both valid values', () => {
const store = new ComparisonStore();
store.side = 'A';
expect(store.side).toBe('A');
store.side = 'B';
expect(store.side).toBe('B');
store.resetAll();
expect(store.fontA).toBeUndefined();
expect(store.fontB).toBeUndefined();
});
});
});