refactor(comparison): replace comparisonStore singleton with lazy getComparisonStore
Mirror the font-catalog change in ComparisonView: expose getComparisonStore() (plus __resetComparisonStore for tests) instead of an eager comparisonStore singleton, and consume getFontCatalog() internally. Update the model barrel and all UI consumers (Sidebar, FontList, Header, Line, SliderArea); Character no longer needs the store and reads everything from props. Update both specs to the accessor: comparisonStore.test mocks getFontCatalog with a writable stub (the real store's fonts is getter-only) and resets the catalog between cases; Sidebar.svelte.test resolves the store via the accessor. Also document Character's props.
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
export {
|
export { getComparisonStore } from './stores/comparisonStore.svelte';
|
||||||
comparisonStore,
|
|
||||||
type Side,
|
export type { Side } from './stores/comparisonStore.svelte';
|
||||||
} from './stores/comparisonStore.svelte';
|
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import {
|
|||||||
getFontUrl,
|
getFontUrl,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
|
type FontCatalogStore,
|
||||||
FontsByIdsStore,
|
FontsByIdsStore,
|
||||||
fontCatalogStore,
|
|
||||||
fontLifecycleManager,
|
fontLifecycleManager,
|
||||||
|
getFontCatalog,
|
||||||
} from '$entities/Font/model';
|
} from '$entities/Font/model';
|
||||||
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import { createPersistentStore } from '$shared/lib';
|
||||||
@@ -98,10 +99,13 @@ export class ComparisonStore {
|
|||||||
*/
|
*/
|
||||||
#fontsByIdsStore: FontsByIdsStore;
|
#fontsByIdsStore: FontsByIdsStore;
|
||||||
|
|
||||||
|
#fontCatalog: FontCatalogStore;
|
||||||
|
|
||||||
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.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
||||||
|
this.#fontCatalog = getFontCatalog();
|
||||||
|
|
||||||
$effect.root(() => {
|
$effect.root(() => {
|
||||||
// Sync batch results → fontA / fontB
|
// Sync batch results → fontA / fontB
|
||||||
@@ -173,7 +177,7 @@ export class ComparisonStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fonts = fontCatalogStore.fonts;
|
const fonts = this.#fontCatalog.fonts;
|
||||||
|
|
||||||
if (fonts.length < 2) {
|
if (fonts.length < 2) {
|
||||||
return;
|
return;
|
||||||
@@ -356,7 +360,14 @@ export class ComparisonStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
let _comparisonStore: ComparisonStore | undefined;
|
||||||
* Singleton comparison store instance
|
|
||||||
*/
|
export function getComparisonStore(): ComparisonStore {
|
||||||
export const comparisonStore = new ComparisonStore();
|
return (_comparisonStore ??= new ComparisonStore());
|
||||||
|
}
|
||||||
|
|
||||||
|
// test-only reset, so specs don't share a live observer
|
||||||
|
export function __resetComparisonStore() {
|
||||||
|
_comparisonStore?.resetAll();
|
||||||
|
_comparisonStore = undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ const mockStorage = vi.hoisted(() => {
|
|||||||
return storage;
|
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', () => ({
|
vi.mock('$shared/lib/helpers/createPersistentStore/createPersistentStore.svelte', () => ({
|
||||||
createPersistentStore: vi.fn(() => mockStorage),
|
createPersistentStore: vi.fn(() => mockStorage),
|
||||||
}));
|
}));
|
||||||
@@ -65,7 +69,7 @@ vi.mock('$entities/Font/model', async importOriginal => {
|
|||||||
const actual = await importOriginal<typeof import('$entities/Font/model')>();
|
const actual = await importOriginal<typeof import('$entities/Font/model')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
fontCatalogStore: { fonts: [] },
|
getFontCatalog: () => mockFontCatalog,
|
||||||
fontLifecycleManager: {
|
fontLifecycleManager: {
|
||||||
touch: vi.fn(),
|
touch: vi.fn(),
|
||||||
pin: vi.fn(),
|
pin: vi.fn(),
|
||||||
@@ -95,21 +99,23 @@ vi.mock('$features/AdjustTypography/model', () => ({
|
|||||||
|
|
||||||
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
|
import * as proxyFonts from '$entities/Font/api/proxy/proxyFonts';
|
||||||
import {
|
import {
|
||||||
fontCatalogStore,
|
|
||||||
fontLifecycleManager,
|
fontLifecycleManager,
|
||||||
|
getFontCatalog,
|
||||||
} from '$entities/Font/model';
|
} from '$entities/Font/model';
|
||||||
|
import { __resetFontCatalog } from '$entities/Font/model/store/fontCatalogStore/fontCatalogStore.svelte';
|
||||||
import { ComparisonStore } from './comparisonStore.svelte';
|
import { ComparisonStore } from './comparisonStore.svelte';
|
||||||
|
|
||||||
describe('ComparisonStore', () => {
|
describe('ComparisonStore', () => {
|
||||||
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
|
const mockFontA: UnifiedFont = UNIFIED_FONTS.roboto; // id: 'roboto'
|
||||||
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
|
const mockFontB: UnifiedFont = UNIFIED_FONTS.openSans; // id: 'open-sans'
|
||||||
|
let fontCatalog = getFontCatalog();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockStorage._value = { fontAId: null, fontBId: null };
|
mockStorage._value = { fontAId: null, fontBId: null };
|
||||||
mockStorage._clear.mockClear();
|
mockStorage._clear.mockClear();
|
||||||
(fontCatalogStore as any).fonts = [];
|
(fontCatalog as any).fonts = [];
|
||||||
|
|
||||||
// Default: fetchFontsByIds returns empty so tests that don't care don't hang
|
// Default: fetchFontsByIds returns empty so tests that don't care don't hang
|
||||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([]);
|
||||||
@@ -126,6 +132,10 @@ describe('ComparisonStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetFontCatalog();
|
||||||
|
});
|
||||||
|
|
||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
it('should create store with initial empty state', () => {
|
it('should create store with initial empty state', () => {
|
||||||
const store = new ComparisonStore();
|
const store = new ComparisonStore();
|
||||||
@@ -164,7 +174,7 @@ describe('ComparisonStore', () => {
|
|||||||
|
|
||||||
describe('Default Fallbacks', () => {
|
describe('Default Fallbacks', () => {
|
||||||
it('should update storage with default IDs when storage is empty', async () => {
|
it('should update storage with default IDs when storage is empty', async () => {
|
||||||
(fontCatalogStore as any).fonts = [mockFontA, mockFontB];
|
(fontCatalog as any).fonts = [mockFontA, mockFontB];
|
||||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockResolvedValue([mockFontA, mockFontB]);
|
||||||
|
|
||||||
new ComparisonStore();
|
new ComparisonStore();
|
||||||
@@ -192,7 +202,7 @@ describe('ComparisonStore', () => {
|
|||||||
|
|
||||||
// Catalog defaults differ from the stored selection — if the
|
// Catalog defaults differ from the stored selection — if the
|
||||||
// effect mis-seeds, storage will flip to roboto / open-sans.
|
// effect mis-seeds, storage will flip to roboto / open-sans.
|
||||||
(fontCatalogStore as any).fonts = [mockFontA, mockFontB];
|
(fontCatalog as any).fonts = [mockFontA, mockFontB];
|
||||||
|
|
||||||
// Delay the batch so the catalog-driven effect runs first.
|
// Delay the batch so the catalog-driven effect runs first.
|
||||||
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
|
vi.spyOn(proxyFonts, 'fetchFontsByIds').mockImplementation(
|
||||||
|
|||||||
@@ -7,16 +7,25 @@
|
|||||||
the Line container zeroes font-size to collapse inter-element whitespace.
|
the Line container zeroes font-size to collapse inter-element whitespace.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { UnifiedFont } from '$entities/Font';
|
||||||
import { cn } from '$shared/lib';
|
import { cn } from '$shared/lib';
|
||||||
import { comparisonStore } from '../../model';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* Character
|
* The single character to render.
|
||||||
*/
|
*/
|
||||||
char: string;
|
char: string;
|
||||||
/**
|
/**
|
||||||
* Past state
|
* Font shown when `isPast` is true (the "before" side of the comparison).
|
||||||
|
*/
|
||||||
|
fontA: UnifiedFont;
|
||||||
|
/**
|
||||||
|
* Font shown when `isPast` is false (the "after" side of the comparison).
|
||||||
|
*/
|
||||||
|
fontB: UnifiedFont;
|
||||||
|
/**
|
||||||
|
* Selects which font this character morphs toward — `fontA` when true,
|
||||||
|
* `fontB` when false — and applies the muted "past" color.
|
||||||
*/
|
*/
|
||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -26,10 +35,7 @@ interface Props {
|
|||||||
fontSize: number;
|
fontSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { char, isPast, fontSize }: Props = $props();
|
let { char, fontA, fontB, isPast, fontSize }: Props = $props();
|
||||||
|
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
|
||||||
|
|
||||||
let slot = $state<0 | 1>(0);
|
let slot = $state<0 | 1>(0);
|
||||||
let slotFonts = $state<[string, string]>(['', '']);
|
let slotFonts = $state<[string, string]>(['', '']);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
VIRTUAL_INDEX_NOT_LOADED,
|
VIRTUAL_INDEX_NOT_LOADED,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import {
|
import {
|
||||||
fontCatalogStore,
|
|
||||||
fontLifecycleManager,
|
fontLifecycleManager,
|
||||||
|
getFontCatalog,
|
||||||
} from '$entities/Font/model';
|
} from '$entities/Font/model';
|
||||||
import { getSkeletonWidth } from '$shared/lib/utils';
|
import { getSkeletonWidth } from '$shared/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -28,17 +28,21 @@ import {
|
|||||||
} from '../../lib';
|
} from '../../lib';
|
||||||
import {
|
import {
|
||||||
type Side,
|
type Side,
|
||||||
comparisonStore,
|
getComparisonStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
const side = $derived(comparisonStore.side);
|
const fontCatalog = getFontCatalog();
|
||||||
|
const comparisonStore = getComparisonStore();
|
||||||
|
|
||||||
|
const fonts = $derived<UnifiedFont[]>(fontCatalog.fonts);
|
||||||
|
const side = $derived<Side>(comparisonStore.side);
|
||||||
|
|
||||||
// Treat -1 (not loaded) as being at the very bottom of the infinite list
|
// Treat -1 (not loaded) as being at the very bottom of the infinite list
|
||||||
function getVirtualIndex(fontId: string | undefined): number {
|
function getVirtualIndex(fontId: string | undefined): number {
|
||||||
if (!fontId) {
|
if (!fontId) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
const idx = fontCatalogStore.fonts.findIndex(f => f.id === fontId);
|
const idx = fonts.findIndex(f => f.id === fontId);
|
||||||
if (idx === -1) {
|
if (idx === -1) {
|
||||||
return VIRTUAL_INDEX_NOT_LOADED;
|
return VIRTUAL_INDEX_NOT_LOADED;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
|
||||||
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { comparisonStore } from '../../model';
|
import { getComparisonStore } from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +40,8 @@ let {
|
|||||||
class: className,
|
class: className,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const comparisonStore = getComparisonStore();
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
const position = $derived(comparisonStore.sliderPosition.toFixed(0));
|
const position = $derived(comparisonStore.sliderPosition.toFixed(0));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
computeLineRenderModel,
|
computeLineRenderModel,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
import { typographySettingsStore } from '$features/AdjustTypography';
|
import { typographySettingsStore } from '$features/AdjustTypography';
|
||||||
import { comparisonStore } from '../../model';
|
import { getComparisonStore } from '../../model';
|
||||||
import Character from '../Character/Character.svelte';
|
import Character from '../Character/Character.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -32,6 +32,8 @@ interface Props {
|
|||||||
|
|
||||||
let { line, split, windowSize }: Props = $props();
|
let { line, split, windowSize }: Props = $props();
|
||||||
|
|
||||||
|
const comparisonStore = getComparisonStore();
|
||||||
|
|
||||||
const model = $derived(computeLineRenderModel(line, split, windowSize));
|
const model = $derived(computeLineRenderModel(line, split, windowSize));
|
||||||
|
|
||||||
const typography = $derived(typographySettingsStore);
|
const typography = $derived(typographySettingsStore);
|
||||||
@@ -80,6 +82,7 @@ const strutStyle = $derived(
|
|||||||
would show as gaps under `white-space: pre`; children restore their size.
|
would show as gaps under `white-space: pre`; children restore their size.
|
||||||
Letter-spacing is px because em would resolve against that zero.
|
Letter-spacing is px because em would resolve against that zero.
|
||||||
-->
|
-->
|
||||||
|
{#if fontA && fontB}
|
||||||
<div
|
<div
|
||||||
class="relative block w-full text-center whitespace-pre"
|
class="relative block w-full text-center whitespace-pre"
|
||||||
style:height="{lineHeightPx}px"
|
style:height="{lineHeightPx}px"
|
||||||
@@ -93,9 +96,10 @@ const strutStyle = $derived(
|
|||||||
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
|
<span class={BULK_LEFT_CLASS} style={leftStyle}>{model.leftText}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#each model.windowChars as wc (wc.key)}
|
{#each model.windowChars as wc (wc.key)}
|
||||||
<Character char={wc.char} isPast={wc.isPast} fontSize={fontSizePx} />
|
<Character char={wc.char} {fontA} {fontB} isPast={wc.isPast} fontSize={fontSizePx} />
|
||||||
{/each}
|
{/each}
|
||||||
{#if model.rightText}
|
{#if model.rightText}
|
||||||
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
|
<span class={BULK_RIGHT_CLASS} style={rightStyle}>{model.rightText}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import {
|
import {
|
||||||
type Side,
|
type Side,
|
||||||
comparisonStore,
|
getComparisonStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -37,6 +37,9 @@ let {
|
|||||||
controls,
|
controls,
|
||||||
class: className,
|
class: className,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const comparisonStore = getComparisonStore();
|
||||||
|
const side = $derived<Side>(comparisonStore.side);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -71,9 +74,9 @@ let {
|
|||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
size="sm"
|
size="sm"
|
||||||
active={comparisonStore.side === 'A'}
|
active={side === 'A'}
|
||||||
aria-controls="primary-font"
|
aria-controls="primary-font"
|
||||||
aria-pressed={comparisonStore.side === 'A'}
|
aria-pressed={side === 'A'}
|
||||||
onclick={() => comparisonStore.side = 'A'}
|
onclick={() => comparisonStore.side = 'A'}
|
||||||
class="flex-1 tracking-wide font-bold uppercase"
|
class="flex-1 tracking-wide font-bold uppercase"
|
||||||
>
|
>
|
||||||
@@ -83,9 +86,9 @@ let {
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-1 tracking-wide font-bold uppercase"
|
class="flex-1 tracking-wide font-bold uppercase"
|
||||||
active={comparisonStore.side === 'B'}
|
active={side === 'B'}
|
||||||
aria-controls="secondary-font"
|
aria-controls="secondary-font"
|
||||||
aria-pressed={comparisonStore.side === 'B'}
|
aria-pressed={side === 'B'}
|
||||||
onclick={() => comparisonStore.side = 'B'}
|
onclick={() => comparisonStore.side = 'B'}
|
||||||
>
|
>
|
||||||
<span>Right Font</span>
|
<span>Right Font</span>
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from '@testing-library/svelte';
|
} from '@testing-library/svelte';
|
||||||
import { createRawSnippet } from 'svelte';
|
import { createRawSnippet } from 'svelte';
|
||||||
import { comparisonStore } from '../../model';
|
import { getComparisonStore } from '../../model';
|
||||||
|
import {
|
||||||
|
ComparisonStore,
|
||||||
|
__resetComparisonStore,
|
||||||
|
} from '../../model/stores/comparisonStore.svelte';
|
||||||
import Sidebar from './Sidebar.svelte';
|
import Sidebar from './Sidebar.svelte';
|
||||||
|
|
||||||
function textSnippet(text: string) {
|
function textSnippet(text: string) {
|
||||||
@@ -13,10 +17,17 @@ function textSnippet(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Sidebar', () => {
|
describe('Sidebar', () => {
|
||||||
afterEach(() => {
|
let comparisonStore!: ComparisonStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
comparisonStore = getComparisonStore();
|
||||||
comparisonStore.side = 'A';
|
comparisonStore.side = 'A';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
__resetComparisonStore();
|
||||||
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
it('renders the "Configuration" title', () => {
|
it('renders the "Configuration" title', () => {
|
||||||
render(Sidebar);
|
render(Sidebar);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
ensureCanvasFonts,
|
ensureCanvasFonts,
|
||||||
getPretextFontString,
|
getPretextFontString,
|
||||||
} from '../../lib';
|
} from '../../lib';
|
||||||
import { comparisonStore } from '../../model';
|
import { getComparisonStore } from '../../model';
|
||||||
import Line from '../Line/Line.svelte';
|
import Line from '../Line/Line.svelte';
|
||||||
import Thumb from '../Thumb/Thumb.svelte';
|
import Thumb from '../Thumb/Thumb.svelte';
|
||||||
|
|
||||||
@@ -51,6 +51,8 @@ interface Props {
|
|||||||
|
|
||||||
let { isSidebarOpen = false, class: className }: Props = $props();
|
let { isSidebarOpen = false, class: className }: Props = $props();
|
||||||
|
|
||||||
|
const comparisonStore = getComparisonStore();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spring tuning for the comparison slider thumb. Lower stiffness = slower
|
* Spring tuning for the comparison slider thumb. Lower stiffness = slower
|
||||||
* follow; higher damping = less overshoot.
|
* follow; higher damping = less overshoot.
|
||||||
|
|||||||
Reference in New Issue
Block a user