/** * @vitest-environment jsdom */ import { DEFAULT_FONT_SIZE, DEFAULT_FONT_WEIGHT, DEFAULT_LETTER_SPACING, DEFAULT_LINE_HEIGHT, DEFAULT_TYPOGRAPHY_CONTROLS_DATA, } from '$entities/Font'; import { beforeEach, describe, expect, it, vi, } from 'vitest'; import { type TypographySettings, TypographySettingsManager, } from './settingsManager.svelte'; /** * Test Strategy for TypographySettingsManager * * This test suite validates the TypographySettingsManager state management logic. * These are unit tests for the manager logic, separate from component rendering. * * NOTE: Svelte 5's $effect runs in microtasks, so we need to flush effects * after state changes to test reactive behavior. This is a limitation of unit * testing Svelte 5 reactive code in Node.js. * * Test Coverage: * 1. Initialization: Loading from storage, creating controls with correct values * 2. Multiplier System: Changing multiplier updates font size display * 3. Base Size Proxy: UI changes update #baseSize via the proxy effect * 4. Storage Sync: Changes to controls sync to storage (via $effect) * 5. Reset Functionality: Clearing storage resets all controls * 6. Rendered Size: base * multiplier calculation * 7. Control Getters: Return correct control instances */ // Helper to flush Svelte effects (they run in microtasks) async function flushEffects() { await Promise.resolve(); await Promise.resolve(); } describe('TypographySettingsManager - Unit Tests', () => { let mockStorage: TypographySettings; let mockPersistentStore: { value: TypographySettings; clear: () => void; }; const createMockPersistentStore = (initialValue: TypographySettings) => { let value = initialValue; return { get value() { return value; }, set value(v: TypographySettings) { value = v; }, clear() { value = { fontSize: DEFAULT_FONT_SIZE, fontWeight: DEFAULT_FONT_WEIGHT, lineHeight: DEFAULT_LINE_HEIGHT, letterSpacing: DEFAULT_LETTER_SPACING, }; }, }; }; beforeEach(() => { // Reset mock storage with default values before each test mockStorage = { fontSize: DEFAULT_FONT_SIZE, fontWeight: DEFAULT_FONT_WEIGHT, lineHeight: DEFAULT_LINE_HEIGHT, letterSpacing: DEFAULT_LETTER_SPACING, }; mockPersistentStore = createMockPersistentStore(mockStorage); }); describe('Initialization', () => { it('creates manager with default values from storage', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE); expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT); expect(manager.height).toBe(DEFAULT_LINE_HEIGHT); expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING); }); it('creates manager with saved values from storage', () => { mockStorage = { fontSize: 72, fontWeight: 700, lineHeight: 1.8, letterSpacing: 0.05, }; mockPersistentStore = createMockPersistentStore(mockStorage); const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.baseSize).toBe(72); expect(manager.weight).toBe(700); expect(manager.height).toBe(1.8); expect(manager.spacing).toBe(0.05); }); it('initializes font size control with base size multiplied by current multiplier (1)', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE); }); it('returns all controls via controls getter', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); const controls = manager.controls; expect(controls).toHaveLength(4); expect(controls.map(c => c.id)).toEqual([ 'font_size', 'font_weight', 'line_height', 'letter_spacing', ]); }); it('returns individual controls via specific getters', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.sizeControl).toBeDefined(); expect(manager.weightControl).toBeDefined(); expect(manager.heightControl).toBeDefined(); expect(manager.spacingControl).toBeDefined(); // Control instances have value, min, max, step, isAtMax, isAtMin, increase, decrease expect(manager.sizeControl).toHaveProperty('value'); expect(manager.weightControl).toHaveProperty('value'); expect(manager.heightControl).toHaveProperty('value'); expect(manager.spacingControl).toHaveProperty('value'); }); it('control instances have expected interface', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); const ctrl = manager.sizeControl!; expect(typeof ctrl.value).toBe('number'); expect(typeof ctrl.min).toBe('number'); expect(typeof ctrl.max).toBe('number'); expect(typeof ctrl.step).toBe('number'); expect(typeof ctrl.isAtMax).toBe('boolean'); expect(typeof ctrl.isAtMin).toBe('boolean'); expect(typeof ctrl.increase).toBe('function'); expect(typeof ctrl.decrease).toBe('function'); }); }); describe('Multiplier System', () => { it('has default multiplier of 1', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.multiplier).toBe(1); }); it('updates multiplier when set', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.multiplier = 0.75; expect(manager.multiplier).toBe(0.75); manager.multiplier = 0.5; expect(manager.multiplier).toBe(0.5); }); it('does not update multiplier if set to same value', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); const originalSizeValue = manager.sizeControl?.value; manager.multiplier = 1; // Same as default expect(manager.sizeControl?.value).toBe(originalSizeValue); }); it('updates font size control display value when multiplier changes', () => { mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 }; mockPersistentStore = createMockPersistentStore(mockStorage); const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); // Initial state: base = 48, multiplier = 1, display = 48 expect(manager.baseSize).toBe(48); expect(manager.sizeControl?.value).toBe(48); // Change multiplier to 0.75 manager.multiplier = 0.75; // Display should be 48 * 0.75 = 36 expect(manager.sizeControl?.value).toBe(36); // Change multiplier to 0.5 manager.multiplier = 0.5; // Display should be 48 * 0.5 = 24 expect(manager.sizeControl?.value).toBe(24); // Base size should remain unchanged expect(manager.baseSize).toBe(48); }); it('updates font size control display value when multiplier increases', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); // Start with multiplier 0.5 manager.multiplier = 0.5; expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5); // Increase to 0.75 manager.multiplier = 0.75; expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.75); // Increase to 1.0 manager.multiplier = 1; expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE); }); }); describe('Base Size Setter', () => { it('updates baseSize when set directly', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.baseSize = 72; expect(manager.baseSize).toBe(72); }); it('updates size control value when baseSize is set', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.baseSize = 60; expect(manager.sizeControl?.value).toBe(60); }); it('applies multiplier to size control when baseSize is set', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.multiplier = 0.5; manager.baseSize = 60; expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5 }); }); describe('Rendered Size Calculation', () => { it('calculates renderedSize as baseSize * multiplier', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 1); }); it('updates renderedSize when multiplier changes', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.multiplier = 0.5; expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.5); manager.multiplier = 0.75; expect(manager.renderedSize).toBe(DEFAULT_FONT_SIZE * 0.75); }); it('updates renderedSize when baseSize changes', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.baseSize = 72; expect(manager.renderedSize).toBe(72); manager.multiplier = 0.5; expect(manager.renderedSize).toBe(36); }); }); describe('Base Size Proxy Effect (UI -> baseSize)', () => { // NOTE: The proxy effect that updates baseSize when the control value changes // runs in a $effect, which is asynchronous in unit tests. We test the // synchronous behavior here (baseSize setter) and note that the full // proxy effect behavior should be tested in E2E tests. it('does NOT immediately update baseSize from control change (effect is async)', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); const originalBaseSize = manager.baseSize; // Change the control value directly manager.sizeControl!.value = 60; // baseSize is NOT updated immediately because the effect runs in microtasks expect(manager.baseSize).toBe(originalBaseSize); }); it('updates baseSize via direct setter (synchronous)', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.baseSize = 60; expect(manager.baseSize).toBe(60); expect(manager.sizeControl?.value).toBe(60); }); }); describe('Storage Sync (Controls -> Storage)', () => { // NOTE: Storage sync happens via $effect which runs in microtasks. // In unit tests, we verify the initial sync and test async behavior. it('has initial values in storage from constructor', () => { mockStorage = { fontSize: 60, fontWeight: 500, lineHeight: 1.6, letterSpacing: 0.02, }; mockPersistentStore = createMockPersistentStore(mockStorage); const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); // Initial values are loaded from storage expect(manager.baseSize).toBe(60); expect(manager.weight).toBe(500); expect(manager.height).toBe(1.6); expect(manager.spacing).toBe(0.02); }); it('syncs to storage after effect flush (async)', async () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.baseSize = 72; // Storage is NOT updated immediately expect(mockPersistentStore.value.fontSize).toBe(DEFAULT_FONT_SIZE); // After flushing effects, storage should be updated await flushEffects(); expect(mockPersistentStore.value.fontSize).toBe(72); }); it('syncs control changes to storage after effect flush (async)', async () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.weightControl!.value = 700; // After flushing effects await flushEffects(); expect(mockPersistentStore.value.fontWeight).toBe(700); }); it('syncs height control changes to storage after effect flush (async)', async () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.heightControl!.value = 1.8; await flushEffects(); expect(mockPersistentStore.value.lineHeight).toBe(1.8); }); it('syncs spacing control changes to storage after effect flush (async)', async () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.spacingControl!.value = 0.05; await flushEffects(); expect(mockPersistentStore.value.letterSpacing).toBe(0.05); }); }); describe('Control Value Getters', () => { it('returns current weight value', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT); manager.weightControl!.value = 700; expect(manager.weight).toBe(700); }); it('returns current height value', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.height).toBe(DEFAULT_LINE_HEIGHT); manager.heightControl!.value = 1.8; expect(manager.height).toBe(1.8); }); it('returns current spacing value', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING); manager.spacingControl!.value = 0.05; expect(manager.spacing).toBe(0.05); }); it('returns default value when control is not found', () => { // Create a manager with empty configs (no controls) const manager = new TypographySettingsManager([], mockPersistentStore); expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT); expect(manager.height).toBe(DEFAULT_LINE_HEIGHT); expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING); }); }); describe('Reset Functionality', () => { it('resets all controls to default values', () => { mockStorage = { fontSize: 72, fontWeight: 700, lineHeight: 1.8, letterSpacing: 0.05, }; mockPersistentStore = createMockPersistentStore(mockStorage); const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); // Modify values manager.baseSize = 80; manager.weightControl!.value = 900; manager.heightControl!.value = 2.0; manager.spacingControl!.value = 0.1; // Reset manager.reset(); // Check all values are reset to defaults expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE); expect(manager.weight).toBe(DEFAULT_FONT_WEIGHT); expect(manager.height).toBe(DEFAULT_LINE_HEIGHT); expect(manager.spacing).toBe(DEFAULT_LETTER_SPACING); }); it('calls storage.clear() on reset', () => { const clearSpy = vi.fn(); mockPersistentStore = { get value() { return mockStorage; }, set value(v: TypographySettings) { mockStorage = v; }, clear: clearSpy, }; const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.reset(); expect(clearSpy).toHaveBeenCalled(); }); it('respects multiplier when resetting font size control', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.multiplier = 0.5; manager.baseSize = 80; manager.reset(); // Font size control should show default * multiplier expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5); expect(manager.baseSize).toBe(DEFAULT_FONT_SIZE); }); }); describe('Complex Scenarios', () => { it('handles changing multiplier then modifying baseSize', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); // Change multiplier manager.multiplier = 0.5; expect(manager.sizeControl?.value).toBe(DEFAULT_FONT_SIZE * 0.5); // Change baseSize manager.baseSize = 60; expect(manager.sizeControl?.value).toBe(30); // 60 * 0.5 expect(manager.baseSize).toBe(60); // Change multiplier again manager.multiplier = 1; expect(manager.sizeControl?.value).toBe(60); // 60 * 1 expect(manager.baseSize).toBe(60); }); it('maintains correct renderedSize throughout changes', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); // Initial: 48 * 1 = 48 expect(manager.renderedSize).toBe(48); // Change baseSize: 60 * 1 = 60 manager.baseSize = 60; expect(manager.renderedSize).toBe(60); // Change multiplier: 60 * 0.5 = 30 manager.multiplier = 0.5; expect(manager.renderedSize).toBe(30); // Change baseSize again: 72 * 0.5 = 36 manager.baseSize = 72; expect(manager.renderedSize).toBe(36); }); it('handles multiple control changes in sequence', async () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); // Change multiple controls manager.baseSize = 72; manager.weightControl!.value = 700; manager.heightControl!.value = 1.8; manager.spacingControl!.value = 0.05; // After flushing effects, verify all are synced to storage await flushEffects(); expect(mockPersistentStore.value.fontSize).toBe(72); expect(mockPersistentStore.value.fontWeight).toBe(700); expect(mockPersistentStore.value.lineHeight).toBe(1.8); expect(mockPersistentStore.value.letterSpacing).toBe(0.05); }); }); describe('Edge Cases', () => { it('handles multiplier of 1 (no change)', () => { mockStorage = { fontSize: 48, fontWeight: 400, lineHeight: 1.5, letterSpacing: 0 }; mockPersistentStore = createMockPersistentStore(mockStorage); const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.multiplier = 1; expect(manager.sizeControl?.value).toBe(48); expect(manager.baseSize).toBe(48); }); it('handles very small multiplier', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.baseSize = 100; manager.multiplier = 0.1; expect(manager.sizeControl?.value).toBe(10); expect(manager.renderedSize).toBe(10); }); it('handles large base size with multiplier', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.baseSize = 100; manager.multiplier = 0.75; expect(manager.sizeControl?.value).toBe(75); expect(manager.renderedSize).toBe(75); }); it('handles floating point precision in multiplier', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); manager.baseSize = 48; manager.multiplier = 0.5; // 48 * 0.5 = 24 (exact, no rounding needed) expect(manager.sizeControl?.value).toBe(24); expect(manager.renderedSize).toBe(24); // 48 * 0.33 = 15.84 -> rounds to 16 (step precision is 1) manager.multiplier = 0.33; expect(manager.sizeControl?.value).toBe(16); expect(manager.renderedSize).toBeCloseTo(15.84); }); it('handles control methods (increase/decrease)', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); const initialWeight = manager.weight; manager.weightControl!.increase(); expect(manager.weight).toBe(initialWeight + 100); manager.weightControl!.decrease(); expect(manager.weight).toBe(initialWeight); }); it('handles control boundary conditions', () => { const manager = new TypographySettingsManager( DEFAULT_TYPOGRAPHY_CONTROLS_DATA, mockPersistentStore, ); const sizeCtrl = manager.sizeControl!; // Test min boundary sizeCtrl.value = 5; expect(sizeCtrl.value).toBe(sizeCtrl.min); // Should clamp to MIN_FONT_SIZE (8) // Test max boundary sizeCtrl.value = 200; expect(sizeCtrl.value).toBe(sizeCtrl.max); // Should clamp to MAX_FONT_SIZE (100) }); }); });