refactor: extract BatchFontStore into new FetchFontsByIds feature
The byId font fetch was a verb-oriented capability with a single consumer driven by a feature need (materializing comparison picks). That shape belongs at the feature layer, not on the entity. Move: - entities/Font/model/store/batchFontStore -> features/FetchFontsByIds/model/store/fontsByIdsStore - Class BatchFontStore -> FontsByIdsStore entities/Font retains the transport primitives (fetchFontsByIds, seedFontCache) and the keyspace (fontKeys); the feature wraps them in the reactive store. comparisonStore now imports FontsByIdsStore from the new feature. The proxy API is imported via direct path so vi.spyOn on the source module still observes the call.
This commit is contained in:
@@ -9,6 +9,7 @@ export {
|
|||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
fetchProxyFontById,
|
fetchProxyFontById,
|
||||||
fetchProxyFonts,
|
fetchProxyFonts,
|
||||||
|
seedFontCache,
|
||||||
} from './proxy/proxyFonts';
|
} from './proxy/proxyFonts';
|
||||||
export type {
|
export type {
|
||||||
ProxyFontsParams,
|
ProxyFontsParams,
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
// Applied fonts manager
|
// Applied fonts manager
|
||||||
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
export * from './appliedFontsStore/appliedFontsStore.svelte';
|
||||||
|
|
||||||
// Batch font store
|
|
||||||
export { BatchFontStore } from './batchFontStore/batchFontStore.svelte';
|
|
||||||
|
|
||||||
// Single FontStore
|
// Single FontStore
|
||||||
export {
|
export {
|
||||||
createFontStore,
|
createFontStore,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FontsByIdsStore } from './model';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FontsByIdsStore } from './store/fontsByIdsStore/fontsByIdsStore.svelte';
|
||||||
+8
-9
@@ -1,14 +1,14 @@
|
|||||||
import { fontKeys } from '$shared/api/queryKeys';
|
|
||||||
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
|
||||||
import {
|
import {
|
||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
seedFontCache,
|
seedFontCache,
|
||||||
} from '../../../api/proxy/proxyFonts';
|
} from '$entities/Font/api/proxy/proxyFonts';
|
||||||
import {
|
import {
|
||||||
FontNetworkError,
|
FontNetworkError,
|
||||||
FontResponseError,
|
FontResponseError,
|
||||||
} from '../../../lib/errors/errors';
|
} from '$entities/Font/lib/errors/errors';
|
||||||
import type { UnifiedFont } from '../../types';
|
import type { UnifiedFont } from '$entities/Font/model/types';
|
||||||
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
|
import { BaseQueryStore } from '$shared/lib/helpers/BaseQueryStore.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal fetcher that seeds the cache and handles error wrapping.
|
* Internal fetcher that seeds the cache and handles error wrapping.
|
||||||
@@ -35,11 +35,10 @@ async function fetchAndSeed(ids: string[]): Promise<UnifiedFont[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive store for fetching and caching batches of fonts by ID.
|
* Reactive store for fetching specific fonts by ID via the proxy batch endpoint.
|
||||||
* Integrates with TanStack Query via BaseQueryStore and handles
|
* Wraps TanStack Query and seeds the detail cache for sibling consumers.
|
||||||
* normalized cache seeding.
|
|
||||||
*/
|
*/
|
||||||
export class BatchFontStore extends BaseQueryStore<UnifiedFont[]> {
|
export class FontsByIdsStore extends BaseQueryStore<UnifiedFont[]> {
|
||||||
constructor(initialIds: string[] = []) {
|
constructor(initialIds: string[] = []) {
|
||||||
super({
|
super({
|
||||||
queryKey: fontKeys.batch(initialIds),
|
queryKey: fontKeys.batch(initialIds),
|
||||||
+15
-15
@@ -1,3 +1,8 @@
|
|||||||
|
import * as api from '$entities/Font/api/proxy/proxyFonts';
|
||||||
|
import {
|
||||||
|
FontNetworkError,
|
||||||
|
FontResponseError,
|
||||||
|
} from '$entities/Font/lib/errors/errors';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
import { fontKeys } from '$shared/api/queryKeys';
|
import { fontKeys } from '$shared/api/queryKeys';
|
||||||
import {
|
import {
|
||||||
@@ -7,14 +12,9 @@ import {
|
|||||||
it,
|
it,
|
||||||
vi,
|
vi,
|
||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import * as api from '../../../api/proxy/proxyFonts';
|
import { FontsByIdsStore } from './fontsByIdsStore.svelte';
|
||||||
import {
|
|
||||||
FontNetworkError,
|
|
||||||
FontResponseError,
|
|
||||||
} from '../../../lib/errors/errors';
|
|
||||||
import { BatchFontStore } from './batchFontStore.svelte';
|
|
||||||
|
|
||||||
describe('BatchFontStore', () => {
|
describe('FontsByIdsStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -23,7 +23,7 @@ describe('BatchFontStore', () => {
|
|||||||
describe('Fetch Behavior', () => {
|
describe('Fetch Behavior', () => {
|
||||||
it('should skip fetch when initialized with empty IDs', async () => {
|
it('should skip fetch when initialized with empty IDs', async () => {
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
const spy = vi.spyOn(api, 'fetchFontsByIds');
|
||||||
const store = new BatchFontStore([]);
|
const store = new FontsByIdsStore([]);
|
||||||
expect(spy).not.toHaveBeenCalled();
|
expect(spy).not.toHaveBeenCalled();
|
||||||
expect(store.fonts).toEqual([]);
|
expect(store.fonts).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -31,7 +31,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should fetch and seed cache for valid IDs', async () => {
|
it('should fetch and seed cache for valid IDs', async () => {
|
||||||
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
const fonts = [{ id: 'a', name: 'A' }] as any[];
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
expect(queryClient.getQueryData(fontKeys.detail('a'))).toEqual(fonts[0]);
|
||||||
});
|
});
|
||||||
@@ -42,7 +42,7 @@ describe('BatchFontStore', () => {
|
|||||||
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
vi.spyOn(api, 'fetchFontsByIds').mockImplementation(() =>
|
||||||
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
new Promise(r => setTimeout(() => r([{ id: 'a' }] as any), 50))
|
||||||
);
|
);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
expect(store.isLoading).toBe(true);
|
expect(store.isLoading).toBe(true);
|
||||||
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isLoading).toBe(false), { timeout: 1000 });
|
||||||
});
|
});
|
||||||
@@ -51,7 +51,7 @@ describe('BatchFontStore', () => {
|
|||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
it('should wrap network failures in FontNetworkError', async () => {
|
it('should wrap network failures in FontNetworkError', async () => {
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
vi.spyOn(api, 'fetchFontsByIds').mockRejectedValue(new Error('Network fail'));
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
expect(store.error).toBeInstanceOf(FontNetworkError);
|
expect(store.error).toBeInstanceOf(FontNetworkError);
|
||||||
});
|
});
|
||||||
@@ -59,7 +59,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should handle malformed API responses with FontResponseError', async () => {
|
it('should handle malformed API responses with FontResponseError', async () => {
|
||||||
// Mocking a malformed response that the store should validate
|
// Mocking a malformed response that the store should validate
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(null as any);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.isError).toBe(true), { timeout: 1000 });
|
||||||
expect(store.error).toBeInstanceOf(FontResponseError);
|
expect(store.error).toBeInstanceOf(FontResponseError);
|
||||||
});
|
});
|
||||||
@@ -67,7 +67,7 @@ describe('BatchFontStore', () => {
|
|||||||
it('should have null error in success state', async () => {
|
it('should have null error in success state', async () => {
|
||||||
const fonts = [{ id: 'a' }] as any[];
|
const fonts = [{ id: 'a' }] as any[];
|
||||||
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
vi.spyOn(api, 'fetchFontsByIds').mockResolvedValue(fonts);
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts), { timeout: 1000 });
|
||||||
expect(store.error).toBeNull();
|
expect(store.error).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -78,7 +78,7 @@ describe('BatchFontStore', () => {
|
|||||||
const fonts1 = [{ id: 'a' }] as any[];
|
const fonts1 = [{ id: 'a' }] as any[];
|
||||||
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
const spy = vi.spyOn(api, 'fetchFontsByIds').mockResolvedValueOnce(fonts1);
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
spy.mockClear();
|
spy.mockClear();
|
||||||
@@ -97,7 +97,7 @@ describe('BatchFontStore', () => {
|
|||||||
.mockResolvedValueOnce(fonts1)
|
.mockResolvedValueOnce(fonts1)
|
||||||
.mockResolvedValueOnce(fonts2);
|
.mockResolvedValueOnce(fonts2);
|
||||||
|
|
||||||
const store = new BatchFontStore(['a']);
|
const store = new FontsByIdsStore(['a']);
|
||||||
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
await vi.waitFor(() => expect(store.fonts).toEqual(fonts1), { timeout: 1000 });
|
||||||
|
|
||||||
store.setIds(['b']);
|
store.setIds(['b']);
|
||||||
@@ -7,14 +7,13 @@
|
|||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Persistent font selection (survives page refresh)
|
* - Persistent font selection (survives page refresh)
|
||||||
* - Font loading state tracking via BatchFontStore + TanStack Query
|
* - Font loading state tracking via FontsByIdsStore + TanStack Query
|
||||||
* - Sample text management
|
* - Sample text management
|
||||||
* - Typography controls (size, weight, line height, spacing)
|
* - Typography controls (size, weight, line height, spacing)
|
||||||
* - Slider position for character-by-character morphing
|
* - Slider position for character-by-character morphing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BatchFontStore,
|
|
||||||
type FontLoadRequestConfig,
|
type FontLoadRequestConfig,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
getFontUrl,
|
getFontUrl,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||||
|
import { FontsByIdsStore } from '$features/FetchFontsByIds';
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import { createPersistentStore } from '$shared/lib';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { getPretextFontString } from '../../lib';
|
import { getPretextFontString } from '../../lib';
|
||||||
@@ -51,7 +51,7 @@ const storage = createPersistentStore<ComparisonState>('glyphdiff:comparison', {
|
|||||||
/**
|
/**
|
||||||
* Store for managing font comparison state.
|
* Store for managing font comparison state.
|
||||||
*
|
*
|
||||||
* Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing
|
* Uses FontsByIdsStore (TanStack Query) to fetch fonts by ID, replacing
|
||||||
* the previous hand-rolled async fetch approach. Three reactive effects
|
* the previous hand-rolled async fetch approach. Three reactive effects
|
||||||
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the
|
* handle: (1) syncing batch results into fontA/fontB, (2) triggering the
|
||||||
* CSS Font Loading API, and (3) falling back to default fonts when
|
* CSS Font Loading API, and (3) falling back to default fonts when
|
||||||
@@ -85,17 +85,17 @@ export class ComparisonStore {
|
|||||||
/**
|
/**
|
||||||
* TanStack Query-backed store for efficient batch font retrieval
|
* TanStack Query-backed store for efficient batch font retrieval
|
||||||
*/
|
*/
|
||||||
#batchStore: BatchFontStore;
|
#fontsByIdsStore: FontsByIdsStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Synchronously seed the batch store with any IDs already in storage
|
// Synchronously seed the batch store with any IDs already in storage
|
||||||
const { fontAId, fontBId } = storage.value;
|
const { fontAId, fontBId } = storage.value;
|
||||||
this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
// Effect 1: Sync batch results → fontA / fontB
|
// Effect 1: Sync batch results → fontA / fontB
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const fonts = this.#batchStore.fonts;
|
const fonts = this.#fontsByIdsStore.fonts;
|
||||||
if (fonts.length === 0) {
|
if (fonts.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ export class ComparisonStore {
|
|||||||
const id1 = fonts[0].id;
|
const id1 = fonts[0].id;
|
||||||
const id2 = fonts[fonts.length - 1].id;
|
const id2 = fonts[fonts.length - 1].id;
|
||||||
storage.value = { fontAId: id1, fontBId: id2 };
|
storage.value = { fontAId: id1, fontBId: id2 };
|
||||||
this.#batchStore.setIds([id1, id2]);
|
this.#fontsByIdsStore.setIds([id1, id2]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -316,7 +316,7 @@ export class ComparisonStore {
|
|||||||
* True if any font is currently being fetched or loaded (reactive)
|
* True if any font is currently being fetched or loaded (reactive)
|
||||||
*/
|
*/
|
||||||
get isLoading() {
|
get isLoading() {
|
||||||
return this.#batchStore.isLoading || !this.#fontsReady;
|
return this.#fontsByIdsStore.isLoading || !this.#fontsReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -325,7 +325,7 @@ export class ComparisonStore {
|
|||||||
resetAll() {
|
resetAll() {
|
||||||
this.#fontA = undefined;
|
this.#fontA = undefined;
|
||||||
this.#fontB = undefined;
|
this.#fontB = undefined;
|
||||||
this.#batchStore.setIds([]);
|
this.#fontsByIdsStore.setIds([]);
|
||||||
storage.clear();
|
storage.clear();
|
||||||
typographySettingsStore.reset();
|
typographySettingsStore.reset();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Unit tests for ComparisonStore (TanStack Query refactor)
|
* Unit tests for ComparisonStore (TanStack Query refactor)
|
||||||
*
|
*
|
||||||
* Uses the real BatchFontStore so Svelte $state reactivity works correctly.
|
* Uses the real FontsByIdsStore so Svelte $state reactivity works correctly.
|
||||||
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
|
* Controls network behaviour via vi.spyOn on the proxyFonts API layer.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -53,12 +53,8 @@ vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte'
|
|||||||
|
|
||||||
vi.mock('$entities/Font', async importOriginal => {
|
vi.mock('$entities/Font', async importOriginal => {
|
||||||
const actual = await importOriginal<typeof import('$entities/Font')>();
|
const actual = await importOriginal<typeof import('$entities/Font')>();
|
||||||
const { BatchFontStore } = await import(
|
|
||||||
'$entities/Font/model/store/batchFontStore/batchFontStore.svelte'
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
BatchFontStore,
|
|
||||||
fontStore: { fonts: [] },
|
fontStore: { fonts: [] },
|
||||||
appliedFontsManager: {
|
appliedFontsManager: {
|
||||||
touch: vi.fn(),
|
touch: vi.fn(),
|
||||||
@@ -129,7 +125,7 @@ describe('ComparisonStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Restoration from Storage (via BatchFontStore)', () => {
|
describe('Restoration from Storage (via FontsByIdsStore)', () => {
|
||||||
it('should restore fontA and fontB from stored IDs', async () => {
|
it('should restore fontA and fontB from stored IDs', async () => {
|
||||||
mockStorage._value.fontAId = mockFontA.id;
|
mockStorage._value.fontAId = mockFontA.id;
|
||||||
mockStorage._value.fontBId = mockFontB.id;
|
mockStorage._value.fontBId = mockFontB.id;
|
||||||
|
|||||||
Reference in New Issue
Block a user