From 3abe5723c7b2b063eed2bf45fa62b88dfac72fdd Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:16:50 +0300 Subject: [PATCH 01/12] test(appliedFontStore): change mockFetch --- .../appliedFontStore.test.ts | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts index f2a116e..e1740f7 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontStore.test.ts @@ -12,9 +12,12 @@ import { AppliedFontsManager } from './appliedFontsStore.svelte'; describe('AppliedFontsManager', () => { let manager: AppliedFontsManager; let mockFontFaceSet: any; + let mockFetch: any; + let failUrls: Set; beforeEach(() => { vi.useFakeTimers(); + failUrls = new Set(); mockFontFaceSet = { add: vi.fn(), @@ -22,11 +25,13 @@ describe('AppliedFontsManager', () => { }; // 1. Properly mock FontFace as a constructor function - const MockFontFace = vi.fn(function(this: any, name: string, url: string) { + // The actual implementation passes buffer (ArrayBuffer) as second arg, not URL string + const MockFontFace = vi.fn(function(this: any, name: string, bufferOrUrl: ArrayBuffer | string) { this.name = name; - this.url = url; + this.bufferOrUrl = bufferOrUrl; this.load = vi.fn().mockImplementation(() => { - if (url.includes('fail')) return Promise.reject(new Error('Load failed')); + // For error tests, we track which URLs should fail via failUrls + // The fetch mock will have already rejected for those URLs return Promise.resolve(this); }); }); @@ -44,18 +49,37 @@ describe('AppliedFontsManager', () => { randomUUID: () => '11111111-1111-1111-1111-111111111111' as any, }); + // 3. Mock fetch to return fake ArrayBuffer data + mockFetch = vi.fn((url: string) => { + if (failUrls.has(url)) { + return Promise.reject(new Error('Network error')); + } + return Promise.resolve({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + clone: () => ({ + ok: true, + status: 200, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + }), + } as Response); + }); + vi.stubGlobal('fetch', mockFetch); + manager = new AppliedFontsManager(); }); afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); + vi.unstubAllGlobals(); }); it('should batch multiple font requests into a single process', async () => { const configs = [ - { id: 'lato-400', name: 'Lato', url: 'lato.ttf', weight: 400 }, - { id: 'lato-700', name: 'Lato', url: 'lato-bold.ttf', weight: 700 }, + { id: 'lato-400', name: 'Lato', url: 'https://example.com/lato.ttf', weight: 400 }, + { id: 'lato-700', name: 'Lato', url: 'https://example.com/lato-bold.ttf', weight: 700 }, ]; manager.touch(configs); @@ -71,7 +95,10 @@ describe('AppliedFontsManager', () => { // Suppress expected console error for clean test logs const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const config = { id: 'broken', name: 'Broken', url: 'fail.ttf', weight: 400 }; + const failUrl = 'https://example.com/fail.ttf'; + failUrls.add(failUrl); + + const config = { id: 'broken', name: 'Broken', url: failUrl, weight: 400 }; manager.touch([config]); await vi.advanceTimersByTimeAsync(50); @@ -81,7 +108,7 @@ describe('AppliedFontsManager', () => { }); it('should purge fonts after TTL expires', async () => { - const config = { id: 'ephemeral', name: 'Temp', url: 'temp.ttf', weight: 400 }; + const config = { id: 'ephemeral', name: 'Temp', url: 'https://example.com/temp.ttf', weight: 400 }; manager.touch([config]); await vi.advanceTimersByTimeAsync(50); @@ -96,7 +123,7 @@ describe('AppliedFontsManager', () => { }); it('should NOT purge fonts that are still being "touched"', async () => { - const config = { id: 'active', name: 'Active', url: 'active.ttf', weight: 400 }; + const config = { id: 'active', name: 'Active', url: 'https://example.com/active.ttf', weight: 400 }; manager.touch([config]); await vi.advanceTimersByTimeAsync(50); -- 2.49.1 From 24ca2f6c416e5c3f55ea7d646be6661eddf12851 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:17:33 +0300 Subject: [PATCH 02/12] test(throttle): add unit tests for throttle util --- .../lib/utils/throttle/throttle.test.ts | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 src/shared/lib/utils/throttle/throttle.test.ts diff --git a/src/shared/lib/utils/throttle/throttle.test.ts b/src/shared/lib/utils/throttle/throttle.test.ts new file mode 100644 index 0000000..b22d5ac --- /dev/null +++ b/src/shared/lib/utils/throttle/throttle.test.ts @@ -0,0 +1,319 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { throttle } from './throttle'; + +describe('throttle', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe('Basic Functionality', () => { + it('should execute function immediately on first call', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 300); + + throttled('arg1', 'arg2'); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2'); + }); + + it('should throttle subsequent calls within wait period', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 300); + + throttled('first'); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Call again within wait period - should not execute + throttled('second'); + expect(mockFn).toHaveBeenCalledTimes(1); + + // Advance time past wait period + vi.advanceTimersByTime(300); + + // Now trailing call executes + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith('second'); + }); + + it('should allow execution after wait period expires', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + throttled('first'); + expect(mockFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + throttled('second'); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('Trailing Edge Execution', () => { + it('should execute throttled call after wait period', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 300); + + throttled('first'); + expect(mockFn).toHaveBeenCalledTimes(1); + + throttled('second'); + throttled('third'); + // Still 1 because these are throttled + + expect(mockFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(300); + + // Trailing call executes + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith('third'); + }); + + it('should cancel previous trailing call on new invocation', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + throttled('first'); + vi.advanceTimersByTime(50); + throttled('second'); + vi.advanceTimersByTime(30); + throttled('third'); + + // At this point only first call executed + expect(mockFn).toHaveBeenCalledTimes(1); + + // Advance to trigger trailing call + vi.advanceTimersByTime(70); + + // First call + trailing (third) + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith('third'); + }); + }); + + describe('Arguments and Context', () => { + it('should pass the correct arguments from the last throttled call', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + throttled('arg1', 'arg2'); + vi.advanceTimersByTime(50); + throttled('arg3', 'arg4'); + vi.advanceTimersByTime(100); + + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith('arg3', 'arg4'); + }); + + it('should handle no arguments', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + throttled(); + + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should handle single argument', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + throttled('single'); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith('single'); + }); + + it('should handle multiple arguments', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + throttled(1, 2, 3, 'four', { five: 5 }); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith(1, 2, 3, 'four', { five: 5 }); + }); + }); + + describe('Timing', () => { + it('should handle very short wait times (1ms)', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 1); + + throttled('first'); + vi.advanceTimersByTime(1); + throttled('second'); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should handle longer wait times (1000ms)', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 1000); + + throttled('first'); + expect(mockFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(500); + throttled('second'); + expect(mockFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(500); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('Rapid Calls', () => { + it('should handle rapid successive calls correctly', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + throttled('call1'); + vi.advanceTimersByTime(10); + throttled('call2'); + vi.advanceTimersByTime(10); + throttled('call3'); + vi.advanceTimersByTime(10); + + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith('call1'); + + vi.advanceTimersByTime(100); + + expect(mockFn).toHaveBeenCalledTimes(2); + expect(mockFn).toHaveBeenLastCalledWith('call3'); + }); + + it('should execute function at most once per wait period plus trailing', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + // Make many rapid calls + for (let i = 0; i < 10; i++) { + vi.advanceTimersByTime(5); + throttled(`call${i}`); + } + + // Should execute immediately + expect(mockFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + + // Plus trailing call + expect(mockFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('Edge Cases', () => { + it('should handle zero wait time', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 0); + + throttled('first'); + + // With zero wait time, function may execute synchronously + // but the internal timing may still prevent immediate re-execution + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should handle being called at exactly wait boundary', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 100); + + throttled('first'); + vi.advanceTimersByTime(100); + throttled('second'); + + expect(mockFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('Return Value', () => { + it('should not return anything (void)', () => { + const mockFn = vi.fn().mockReturnValue('result'); + const throttled = throttle(mockFn, 100); + + const result = throttled('arg'); + + expect(result).toBeUndefined(); + }); + }); + + describe('Real-World Scenarios', () => { + it('should throttle scroll-like events', () => { + const mockFn = vi.fn(); + const throttledScroll = throttle(mockFn, 100); + + throttledScroll(); + vi.advanceTimersByTime(10); + throttledScroll(); + vi.advanceTimersByTime(10); + throttledScroll(); + vi.advanceTimersByTime(10); + + expect(mockFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + + it('should throttle resize-like events', () => { + const mockFn = vi.fn(); + const throttledResize = throttle(mockFn, 200); + + throttledResize(); + for (let i = 1; i <= 10; i++) { + vi.advanceTimersByTime(10); + throttledResize(); + } + + expect(mockFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(200); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('Comparison Characteristics', () => { + it('should execute immediately on first call', () => { + const mockFn = vi.fn(); + const throttled = throttle(mockFn, 300); + + throttled('first'); + + // Throttle executes immediately (unlike debounce) + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('should allow execution during continuous calls at intervals', () => { + const mockFn = vi.fn(); + const waitTime = 100; + const throttled = throttle(mockFn, waitTime); + + throttled('call1'); + expect(mockFn).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(waitTime); + throttled('call2'); + expect(mockFn).toHaveBeenCalledTimes(2); + + vi.advanceTimersByTime(waitTime); + throttled('call3'); + expect(mockFn).toHaveBeenCalledTimes(3); + }); + }); +}); -- 2.49.1 From ff71d1c8c9108e1282afe2ec8b12281e25249c31 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:18:18 +0300 Subject: [PATCH 03/12] test(splitArray): add unit tests for splitArray util --- .../lib/utils/splitArray/splitArray.test.ts | 405 ++++++++++++++++++ 1 file changed, 405 insertions(+) create mode 100644 src/shared/lib/utils/splitArray/splitArray.test.ts diff --git a/src/shared/lib/utils/splitArray/splitArray.test.ts b/src/shared/lib/utils/splitArray/splitArray.test.ts new file mode 100644 index 0000000..f03bbf9 --- /dev/null +++ b/src/shared/lib/utils/splitArray/splitArray.test.ts @@ -0,0 +1,405 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import { splitArray } from './splitArray'; + +describe('splitArray', () => { + describe('Basic Functionality', () => { + it('should split an array into two arrays based on callback', () => { + const input = [1, 2, 3, 4, 5]; + const [pass, fail] = splitArray(input, n => n > 2); + + expect(pass).toEqual([3, 4, 5]); + expect(fail).toEqual([1, 2]); + }); + + it('should return two arrays', () => { + const result = splitArray([1, 2, 3], () => true); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(Array.isArray(result[0])).toBe(true); + expect(Array.isArray(result[1])).toBe(true); + }); + + it('should preserve original array', () => { + const input = [1, 2, 3, 4, 5]; + const original = [...input]; + + splitArray(input, n => n % 2 === 0); + + expect(input).toEqual(original); + }); + }); + + describe('Empty Array', () => { + it('should return two empty arrays for empty input', () => { + const [pass, fail] = splitArray([], () => true); + + expect(pass).toEqual([]); + expect(fail).toEqual([]); + }); + + it('should handle empty array with falsy callback', () => { + const [pass, fail] = splitArray([], () => false); + + expect(pass).toEqual([]); + expect(fail).toEqual([]); + }); + }); + + describe('All Pass', () => { + it('should put all elements in pass array when callback returns true for all', () => { + const input = [1, 2, 3, 4, 5]; + const [pass, fail] = splitArray(input, () => true); + + expect(pass).toEqual([1, 2, 3, 4, 5]); + expect(fail).toEqual([]); + }); + + it('should put all elements in pass array using always-true condition', () => { + const input = ['a', 'b', 'c']; + const [pass, fail] = splitArray(input, s => s.length > 0); + + expect(pass).toEqual(['a', 'b', 'c']); + expect(fail).toEqual([]); + }); + }); + + describe('All Fail', () => { + it('should put all elements in fail array when callback returns false for all', () => { + const input = [1, 2, 3, 4, 5]; + const [pass, fail] = splitArray(input, () => false); + + expect(pass).toEqual([]); + expect(fail).toEqual([1, 2, 3, 4, 5]); + }); + + it('should put all elements in fail array using always-false condition', () => { + const input = ['a', 'b', 'c']; + const [pass, fail] = splitArray(input, s => s.length > 10); + + expect(pass).toEqual([]); + expect(fail).toEqual(['a', 'b', 'c']); + }); + }); + + describe('Mixed Results', () => { + it('should split even and odd numbers', () => { + const input = [1, 2, 3, 4, 5, 6]; + const [even, odd] = splitArray(input, n => n % 2 === 0); + + expect(even).toEqual([2, 4, 6]); + expect(odd).toEqual([1, 3, 5]); + }); + + it('should split positive and negative numbers', () => { + const input = [-3, -2, -1, 0, 1, 2, 3]; + const [positive, negative] = splitArray(input, n => n >= 0); + + expect(positive).toEqual([0, 1, 2, 3]); + expect(negative).toEqual([-3, -2, -1]); + }); + + it('should split strings by length', () => { + const input = ['a', 'ab', 'abc', 'abcd']; + const [long, short] = splitArray(input, s => s.length >= 3); + + expect(long).toEqual(['abc', 'abcd']); + expect(short).toEqual(['a', 'ab']); + }); + + it('should split objects by property', () => { + interface Item { + id: number; + active: boolean; + } + const input: Item[] = [ + { id: 1, active: true }, + { id: 2, active: false }, + { id: 3, active: true }, + { id: 4, active: false }, + ]; + const [active, inactive] = splitArray(input, item => item.active); + + expect(active).toEqual([ + { id: 1, active: true }, + { id: 3, active: true }, + ]); + expect(inactive).toEqual([ + { id: 2, active: false }, + { id: 4, active: false }, + ]); + }); + }); + + describe('Type Safety', () => { + it('should work with number arrays', () => { + const [pass, fail] = splitArray([1, 2, 3], n => n > 1); + + expect(pass).toEqual([2, 3]); + expect(fail).toEqual([1]); + + // Type check - should be numbers + const sum = pass[0] + pass[1]; + expect(sum).toBe(5); + }); + + it('should work with string arrays', () => { + const [pass, fail] = splitArray(['a', 'bb', 'ccc'], s => s.length > 1); + + expect(pass).toEqual(['bb', 'ccc']); + expect(fail).toEqual(['a']); + + // Type check - should be strings + const concatenated = pass.join(''); + expect(concatenated).toBe('bbccc'); + }); + + it('should work with boolean arrays', () => { + const [pass, fail] = splitArray([true, false, true], b => b); + + expect(pass).toEqual([true, true]); + expect(fail).toEqual([false]); + }); + + it('should work with generic objects', () => { + interface Person { + name: string; + age: number; + } + const people: Person[] = [ + { name: 'Alice', age: 25 }, + { name: 'Bob', age: 30 }, + { name: 'Charlie', age: 20 }, + ]; + const [adults, minors] = splitArray(people, p => p.age >= 21); + + expect(adults).toEqual([ + { name: 'Alice', age: 25 }, + { name: 'Bob', age: 30 }, + ]); + expect(minors).toEqual([{ name: 'Charlie', age: 20 }]); + }); + + it('should work with null and undefined', () => { + const input = [null, undefined, 1, 0, '']; + const [truthy, falsy] = splitArray(input, item => !!item); + + expect(truthy).toEqual([1]); + expect(falsy).toEqual([null, undefined, 0, '']); + }); + }); + + describe('Callback Functions', () => { + it('should support arrow function syntax', () => { + const [pass, fail] = splitArray([1, 2, 3, 4], x => x % 2 === 0); + + expect(pass).toEqual([2, 4]); + expect(fail).toEqual([1, 3]); + }); + + it('should support regular function syntax', () => { + const [pass, fail] = splitArray([1, 2, 3, 4], function(x) { + return x % 2 === 0; + }); + + expect(pass).toEqual([2, 4]); + expect(fail).toEqual([1, 3]); + }); + + it('should support inline conditions', () => { + const input = [1, 2, 3, 4, 5]; + const [greaterThan3, others] = splitArray(input, x => x > 3); + + expect(greaterThan3).toEqual([4, 5]); + expect(others).toEqual([1, 2, 3]); + }); + }); + + describe('Order Preservation', () => { + it('should maintain order within each resulting array', () => { + const input = [5, 1, 4, 2, 3]; + const [greaterThan2, lessOrEqual] = splitArray(input, n => n > 2); + + expect(greaterThan2).toEqual([5, 4, 3]); + expect(lessOrEqual).toEqual([1, 2]); + }); + + it('should preserve relative order for complex objects', () => { + interface Item { + id: number; + value: string; + } + const input: Item[] = [ + { id: 1, value: 'a' }, + { id: 2, value: 'b' }, + { id: 3, value: 'c' }, + { id: 4, value: 'd' }, + ]; + const [evenIds, oddIds] = splitArray(input, item => item.id % 2 === 0); + + expect(evenIds).toEqual([ + { id: 2, value: 'b' }, + { id: 4, value: 'd' }, + ]); + expect(oddIds).toEqual([ + { id: 1, value: 'a' }, + { id: 3, value: 'c' }, + ]); + }); + }); + + describe('Edge Cases', () => { + it('should handle single element array (truthy)', () => { + const [pass, fail] = splitArray([1], () => true); + + expect(pass).toEqual([1]); + expect(fail).toEqual([]); + }); + + it('should handle single element array (falsy)', () => { + const [pass, fail] = splitArray([1], () => false); + + expect(pass).toEqual([]); + expect(fail).toEqual([1]); + }); + + it('should handle two element array', () => { + const [pass, fail] = splitArray([1, 2], n => n === 1); + + expect(pass).toEqual([1]); + expect(fail).toEqual([2]); + }); + + it('should handle array with duplicate values', () => { + const [pass, fail] = splitArray([1, 1, 2, 2, 1, 1], n => n === 1); + + expect(pass).toEqual([1, 1, 1, 1]); + expect(fail).toEqual([2, 2]); + }); + + it('should handle zero values', () => { + const [truthy, falsy] = splitArray([0, 1, 0, 2], Boolean); + + expect(truthy).toEqual([1, 2]); + expect(falsy).toEqual([0, 0]); + }); + + it('should handle NaN values', () => { + const input = [1, NaN, 2, NaN, 3]; + const [numbers, nans] = splitArray(input, n => !Number.isNaN(n)); + + expect(numbers).toEqual([1, 2, 3]); + expect(nans).toEqual([NaN, NaN]); + }); + }); + + describe('Large Arrays', () => { + it('should handle large arrays efficiently', () => { + const largeArray = Array.from({ length: 10000 }, (_, i) => i); + const [even, odd] = splitArray(largeArray, n => n % 2 === 0); + + expect(even).toHaveLength(5000); + expect(odd).toHaveLength(5000); + expect(even[0]).toBe(0); + expect(even[9999]).toBeUndefined(); + expect(even[4999]).toBe(9998); + }); + + it('should maintain correct results for all elements in large array', () => { + const input = Array.from({ length: 1000 }, (_, i) => i); + const [multiplesOf3, others] = splitArray(input, n => n % 3 === 0); + + // Verify counts + expect(multiplesOf3).toHaveLength(334); // 0, 3, 6, ..., 999 + expect(others).toHaveLength(666); + + // Verify all multiples of 3 are in correct array + multiplesOf3.forEach(n => { + expect(n % 3).toBe(0); + }); + + // Verify no multiples of 3 are in others + others.forEach(n => { + expect(n % 3).not.toBe(0); + }); + }); + }); + + describe('Real-World Use Cases', () => { + it('should separate valid from invalid emails', () => { + const emails = [ + 'valid@example.com', + 'invalid', + 'another@test.org', + 'not-an-email', + 'user@domain.co.uk', + ]; + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const [valid, invalid] = splitArray(emails, email => emailRegex.test(email)); + + expect(valid).toEqual([ + 'valid@example.com', + 'another@test.org', + 'user@domain.co.uk', + ]); + expect(invalid).toEqual(['invalid', 'not-an-email']); + }); + + it('should separate completed from pending tasks', () => { + interface Task { + id: number; + title: string; + completed: boolean; + } + const tasks: Task[] = [ + { id: 1, title: 'Task 1', completed: true }, + { id: 2, title: 'Task 2', completed: false }, + { id: 3, title: 'Task 3', completed: true }, + { id: 4, title: 'Task 4', completed: false }, + ]; + const [completed, pending] = splitArray(tasks, task => task.completed); + + expect(completed).toHaveLength(2); + expect(pending).toHaveLength(2); + expect(completed.every(t => t.completed)).toBe(true); + expect(pending.every(t => !t.completed)).toBe(true); + }); + + it('should separate adults from minors by age', () => { + interface Person { + name: string; + age: number; + } + const people: Person[] = [ + { name: 'Alice', age: 17 }, + { name: 'Bob', age: 25 }, + { name: 'Charlie', age: 16 }, + { name: 'Diana', age: 30 }, + { name: 'Eve', age: 18 }, + ]; + const [adults, minors] = splitArray(people, person => person.age >= 18); + + expect(adults).toEqual([ + { name: 'Bob', age: 25 }, + { name: 'Diana', age: 30 }, + { name: 'Eve', age: 18 }, + ]); + expect(minors).toEqual([ + { name: 'Alice', age: 17 }, + { name: 'Charlie', age: 16 }, + ]); + }); + + it('should separate truthy from falsy values', () => { + const mixed = [0, 1, false, true, '', 'hello', null, undefined, [], [0]]; + const [truthy, falsy] = splitArray(mixed, Boolean); + + expect(truthy).toEqual([1, true, 'hello', [], [0]]); + expect(falsy).toEqual([0, false, '', null, undefined]); + }); + }); +}); -- 2.49.1 From 206e609a2df1877db8079660b2f96c999e2ca293 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:19:26 +0300 Subject: [PATCH 04/12] test(createEntityStore): cover createEntityStore helper with unit tests --- .../createEntityStore.test.ts | 420 ++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 src/shared/lib/helpers/createEntityStore/createEntityStore.test.ts diff --git a/src/shared/lib/helpers/createEntityStore/createEntityStore.test.ts b/src/shared/lib/helpers/createEntityStore/createEntityStore.test.ts new file mode 100644 index 0000000..0e5d11f --- /dev/null +++ b/src/shared/lib/helpers/createEntityStore/createEntityStore.test.ts @@ -0,0 +1,420 @@ +import { + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { + type Entity, + EntityStore, + createEntityStore, +} from './createEntityStore.svelte'; + +interface TestEntity { + id: string; + name: string; + value: number; +} + +describe('createEntityStore', () => { + describe('Construction and Initialization', () => { + it('should create an empty store when no initial entities are provided', () => { + const store = createEntityStore(); + + expect(store.all).toEqual([]); + }); + + it('should create a store with initial entities', () => { + const initialEntities: TestEntity[] = [ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + ]; + const store = createEntityStore(initialEntities); + + expect(store.all).toHaveLength(2); + expect(store.all).toEqual(initialEntities); + }); + + it('should create EntityStore instance', () => { + const store = createEntityStore(); + + expect(store).toBeInstanceOf(EntityStore); + }); + }); + + describe('Selectors', () => { + let store: EntityStore; + let entities: TestEntity[]; + + beforeEach(() => { + entities = [ + { id: '1', name: 'First', value: 10 }, + { id: '2', name: 'Second', value: 20 }, + { id: '3', name: 'Third', value: 30 }, + ]; + store = createEntityStore(entities); + }); + + it('should return all entities as an array', () => { + const all = store.all; + + expect(all).toEqual(entities); + expect(all).toHaveLength(3); + }); + + it('should get a single entity by ID', () => { + const entity = store.getById('2'); + + expect(entity).toEqual({ id: '2', name: 'Second', value: 20 }); + }); + + it('should return undefined for non-existent ID', () => { + const entity = store.getById('999'); + + expect(entity).toBeUndefined(); + }); + + it('should get multiple entities by IDs', () => { + const entities = store.getByIds(['1', '3']); + + expect(entities).toEqual([ + { id: '1', name: 'First', value: 10 }, + { id: '3', name: 'Third', value: 30 }, + ]); + }); + + it('should filter out undefined results when getting by IDs', () => { + const entities = store.getByIds(['1', '999', '3']); + + expect(entities).toEqual([ + { id: '1', name: 'First', value: 10 }, + { id: '3', name: 'Third', value: 30 }, + ]); + expect(entities).toHaveLength(2); + }); + + it('should return empty array when no IDs match', () => { + const entities = store.getByIds(['999', '888']); + + expect(entities).toEqual([]); + }); + + it('should check if entity exists by ID', () => { + expect(store.has('1')).toBe(true); + expect(store.has('999')).toBe(false); + }); + }); + + describe('CRUD Operations - Create', () => { + it('should add a single entity', () => { + const store = createEntityStore(); + + store.addOne({ id: '1', name: 'First', value: 1 }); + + expect(store.all).toHaveLength(1); + expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 }); + }); + + it('should add multiple entities at once', () => { + const store = createEntityStore(); + + store.addMany([ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + { id: '3', name: 'Third', value: 3 }, + ]); + + expect(store.all).toHaveLength(3); + }); + + it('should replace entity when adding with existing ID', () => { + const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]); + + store.addOne({ id: '1', name: 'Updated', value: 2 }); + + expect(store.all).toHaveLength(1); + expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 }); + }); + }); + + describe('CRUD Operations - Update', () => { + it('should update an existing entity', () => { + const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]); + + store.updateOne('1', { name: 'Updated' }); + + expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 1 }); + }); + + it('should update multiple properties at once', () => { + const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]); + + store.updateOne('1', { name: 'Updated', value: 2 }); + + expect(store.getById('1')).toEqual({ id: '1', name: 'Updated', value: 2 }); + }); + + it('should do nothing when updating non-existent entity', () => { + const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]); + + store.updateOne('999', { name: 'Updated' }); + + expect(store.all).toHaveLength(1); + expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 }); + }); + + it('should preserve entity when no changes are provided', () => { + const store = createEntityStore([{ id: '1', name: 'Original', value: 1 }]); + + store.updateOne('1', {}); + + expect(store.getById('1')).toEqual({ id: '1', name: 'Original', value: 1 }); + }); + }); + + describe('CRUD Operations - Delete', () => { + it('should remove a single entity', () => { + const store = createEntityStore([ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + ]); + + store.removeOne('1'); + + expect(store.all).toHaveLength(1); + expect(store.getById('1')).toBeUndefined(); + expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 }); + }); + + it('should remove multiple entities', () => { + const store = createEntityStore([ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + { id: '3', name: 'Third', value: 3 }, + ]); + + store.removeMany(['1', '3']); + + expect(store.all).toHaveLength(1); + expect(store.getById('2')).toEqual({ id: '2', name: 'Second', value: 2 }); + }); + + it('should do nothing when removing non-existent entity', () => { + const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]); + + store.removeOne('999'); + + expect(store.all).toHaveLength(1); + }); + + it('should handle empty array when removing many', () => { + const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]); + + store.removeMany([]); + + expect(store.all).toHaveLength(1); + }); + }); + + describe('Bulk Operations', () => { + it('should set all entities, replacing existing', () => { + const store = createEntityStore([ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + ]); + + store.setAll([{ id: '3', name: 'Third', value: 3 }]); + + expect(store.all).toHaveLength(1); + expect(store.getById('1')).toBeUndefined(); + expect(store.getById('3')).toEqual({ id: '3', name: 'Third', value: 3 }); + }); + + it('should clear all entities', () => { + const store = createEntityStore([ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + ]); + + store.clear(); + + expect(store.all).toEqual([]); + expect(store.all).toHaveLength(0); + }); + }); + + describe('Reactivity with SvelteMap', () => { + it('should return reactive arrays', () => { + const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]); + + // The all getter should return a fresh array (or reactive state) + const first = store.all; + const second = store.all; + + // Both should have the same content + expect(first).toEqual(second); + }); + + it('should reflect changes in subsequent calls', () => { + const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]); + + expect(store.all).toHaveLength(1); + + store.addOne({ id: '2', name: 'Second', value: 2 }); + + expect(store.all).toHaveLength(2); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty initial array', () => { + const store = createEntityStore([]); + + expect(store.all).toEqual([]); + }); + + it('should handle single entity', () => { + const store = createEntityStore([{ id: '1', name: 'First', value: 1 }]); + + expect(store.all).toHaveLength(1); + expect(store.getById('1')).toEqual({ id: '1', name: 'First', value: 1 }); + }); + + it('should handle entities with complex objects', () => { + interface ComplexEntity extends Entity { + id: string; + data: { + nested: { + value: string; + }; + }; + tags: string[]; + } + + const entity: ComplexEntity = { + id: '1', + data: { nested: { value: 'test' } }, + tags: ['a', 'b', 'c'], + }; + + const store = createEntityStore([entity]); + + expect(store.getById('1')).toEqual(entity); + }); + + it('should handle numeric string IDs', () => { + const store = createEntityStore([ + { id: '123', name: 'First', value: 1 }, + { id: '456', name: 'Second', value: 2 }, + ]); + + expect(store.getById('123')).toEqual({ id: '123', name: 'First', value: 1 }); + expect(store.getById('456')).toEqual({ id: '456', name: 'Second', value: 2 }); + }); + + it('should handle UUID-like IDs', () => { + const uuid1 = '550e8400-e29b-41d4-a716-446655440000'; + const uuid2 = '550e8400-e29b-41d4-a716-446655440001'; + + const store = createEntityStore([ + { id: uuid1, name: 'First', value: 1 }, + { id: uuid2, name: 'Second', value: 2 }, + ]); + + expect(store.getById(uuid1)).toEqual({ id: uuid1, name: 'First', value: 1 }); + }); + }); + + describe('Type Safety', () => { + it('should enforce Entity type with id property', () => { + // This test verifies type checking at compile time + const validEntity: TestEntity = { id: '1', name: 'Test', value: 1 }; + + const store = createEntityStore([validEntity]); + + expect(store.getById('1')).toEqual(validEntity); + }); + + it('should work with different entity types', () => { + interface User extends Entity { + id: string; + name: string; + email: string; + } + + interface Product extends Entity { + id: string; + title: string; + price: number; + } + + const userStore = createEntityStore([ + { id: 'u1', name: 'John', email: 'john@example.com' }, + ]); + + const productStore = createEntityStore([ + { id: 'p1', title: 'Widget', price: 9.99 }, + ]); + + expect(userStore.getById('u1')?.email).toBe('john@example.com'); + expect(productStore.getById('p1')?.price).toBe(9.99); + }); + }); + + describe('Large Datasets', () => { + it('should handle large number of entities efficiently', () => { + const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({ + id: `id-${i}`, + name: `Entity ${i}`, + value: i, + })); + + const store = createEntityStore(entities); + + expect(store.all).toHaveLength(1000); + expect(store.getById('id-500')).toEqual({ + id: 'id-500', + name: 'Entity 500', + value: 500, + }); + }); + + it('should efficiently check existence in large dataset', () => { + const entities: TestEntity[] = Array.from({ length: 1000 }, (_, i) => ({ + id: `id-${i}`, + name: `Entity ${i}`, + value: i, + })); + + const store = createEntityStore(entities); + + expect(store.has('id-999')).toBe(true); + expect(store.has('id-1000')).toBe(false); + }); + }); + + describe('Method Chaining', () => { + it('should support chaining add operations', () => { + const store = createEntityStore(); + + store.addOne({ id: '1', name: 'First', value: 1 }); + store.addOne({ id: '2', name: 'Second', value: 2 }); + store.addOne({ id: '3', name: 'Third', value: 3 }); + + expect(store.all).toHaveLength(3); + }); + + it('should support chaining update operations', () => { + const store = createEntityStore([ + { id: '1', name: 'First', value: 1 }, + { id: '2', name: 'Second', value: 2 }, + ]); + + store.updateOne('1', { value: 10 }); + store.updateOne('2', { value: 20 }); + + expect(store.getById('1')?.value).toBe(10); + expect(store.getById('2')?.value).toBe(20); + }); + }); +}); -- 2.49.1 From 1c3908f89eff896e53b874dc7f7af71c1123031f Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:19:47 +0300 Subject: [PATCH 05/12] test(createPersistentStore): cover createPersistentStore helper with unit tests --- .../createPersistentStore.test.ts | 377 ++++++++++++++++++ 1 file changed, 377 insertions(+) create mode 100644 src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts diff --git a/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts new file mode 100644 index 0000000..9cbdac3 --- /dev/null +++ b/src/shared/lib/helpers/createPersistentStore/createPersistentStore.test.ts @@ -0,0 +1,377 @@ +/** @vitest-environment jsdom */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createPersistentStore } from './createPersistentStore.svelte'; + +describe('createPersistentStore', () => { + let mockLocalStorage: Storage; + const testKey = 'test-store-key'; + + beforeEach(() => { + // Mock localStorage + const storeMap = new Map(); + + mockLocalStorage = { + get length() { + return storeMap.size; + }, + clear() { + storeMap.clear(); + }, + getItem(key: string) { + return storeMap.get(key) ?? null; + }, + setItem(key: string, value: string) { + storeMap.set(key, value); + }, + removeItem(key: string) { + storeMap.delete(key); + }, + key(index: number) { + return Array.from(storeMap.keys())[index] ?? null; + }, + }; + + vi.stubGlobal('localStorage', mockLocalStorage); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('Initialization', () => { + it('should create store with default value when localStorage is empty', () => { + const store = createPersistentStore(testKey, 'default'); + + expect(store.value).toBe('default'); + }); + + it('should create store with value from localStorage', () => { + mockLocalStorage.setItem(testKey, JSON.stringify('stored value')); + + const store = createPersistentStore(testKey, 'default'); + + expect(store.value).toBe('stored value'); + }); + + it('should parse JSON from localStorage', () => { + const storedValue = { name: 'Test', count: 42 }; + mockLocalStorage.setItem(testKey, JSON.stringify(storedValue)); + + const store = createPersistentStore(testKey, { name: 'Default', count: 0 }); + + expect(store.value).toEqual(storedValue); + }); + + it('should use default value when localStorage has invalid JSON', () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockLocalStorage.setItem(testKey, 'invalid json{'); + + const store = createPersistentStore(testKey, 'default'); + + expect(store.value).toBe('default'); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('Reading Values', () => { + it('should return current value via getter', () => { + const store = createPersistentStore(testKey, 'default'); + + expect(store.value).toBe('default'); + }); + + it('should return updated value after setter', () => { + const store = createPersistentStore(testKey, 'default'); + + store.value = 'updated'; + + expect(store.value).toBe('updated'); + }); + + it('should preserve type information', () => { + interface TestObject { + name: string; + count: number; + } + const defaultValue: TestObject = { name: 'Test', count: 0 }; + const store = createPersistentStore(testKey, defaultValue); + + expect(store.value.name).toBe('Test'); + expect(store.value.count).toBe(0); + }); + }); + + describe('Writing Values', () => { + it('should update value when set via setter', () => { + const store = createPersistentStore(testKey, 'default'); + + store.value = 'new value'; + + expect(store.value).toBe('new value'); + }); + + it('should serialize objects to JSON', () => { + const store = createPersistentStore(testKey, { name: 'Default', count: 0 }); + + store.value = { name: 'Updated', count: 42 }; + + // The value is updated in the store + expect(store.value).toEqual({ name: 'Updated', count: 42 }); + }); + + it('should handle arrays', () => { + const store = createPersistentStore(testKey, []); + + store.value = [1, 2, 3]; + + expect(store.value).toEqual([1, 2, 3]); + }); + + it('should handle booleans', () => { + const store = createPersistentStore(testKey, false); + + store.value = true; + + expect(store.value).toBe(true); + }); + + it('should handle null values', () => { + const store = createPersistentStore(testKey, null); + + store.value = 'not null'; + + expect(store.value).toBe('not null'); + }); + }); + + describe('Clear Function', () => { + it('should reset value to default when clear is called', () => { + const store = createPersistentStore(testKey, 'default'); + + store.value = 'modified'; + store.clear(); + + expect(store.value).toBe('default'); + }); + + it('should work with object defaults', () => { + const defaultValue = { name: 'Default', count: 0 }; + const store = createPersistentStore(testKey, defaultValue); + + store.value = { name: 'Modified', count: 42 }; + store.clear(); + + expect(store.value).toEqual(defaultValue); + }); + + it('should work with array defaults', () => { + const defaultValue = [1, 2, 3]; + const store = createPersistentStore(testKey, defaultValue); + + store.value = [4, 5, 6]; + store.clear(); + + expect(store.value).toEqual(defaultValue); + }); + }); + + describe('Type Support', () => { + it('should work with string type', () => { + const store = createPersistentStore(testKey, 'default'); + + store.value = 'test string'; + + expect(store.value).toBe('test string'); + }); + + it('should work with number type', () => { + const store = createPersistentStore(testKey, 0); + + store.value = 42; + + expect(store.value).toBe(42); + }); + + it('should work with boolean type', () => { + const store = createPersistentStore(testKey, false); + + store.value = true; + + expect(store.value).toBe(true); + }); + + it('should work with object type', () => { + interface TestObject { + name: string; + value: number; + } + const defaultValue: TestObject = { name: 'Test', value: 0 }; + const store = createPersistentStore(testKey, defaultValue); + + store.value = { name: 'Updated', value: 42 }; + + expect(store.value.name).toBe('Updated'); + expect(store.value.value).toBe(42); + }); + + it('should work with array type', () => { + const store = createPersistentStore(testKey, []); + + store.value = ['a', 'b', 'c']; + + expect(store.value).toEqual(['a', 'b', 'c']); + }); + + it('should work with null type', () => { + const store = createPersistentStore(testKey, null); + + expect(store.value).toBeNull(); + + store.value = 'not null'; + + expect(store.value).toBe('not null'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty string', () => { + const store = createPersistentStore(testKey, 'default'); + + store.value = ''; + + expect(store.value).toBe(''); + }); + + it('should handle zero number', () => { + const store = createPersistentStore(testKey, 100); + + store.value = 0; + + expect(store.value).toBe(0); + }); + + it('should handle false boolean', () => { + const store = createPersistentStore(testKey, true); + + store.value = false; + + expect(store.value).toBe(false); + }); + + it('should handle empty array', () => { + const store = createPersistentStore(testKey, [1, 2, 3]); + + store.value = []; + + expect(store.value).toEqual([]); + }); + + it('should handle empty object', () => { + const store = createPersistentStore>(testKey, { a: 1 }); + + store.value = {}; + + expect(store.value).toEqual({}); + }); + + it('should handle special characters in string', () => { + const store = createPersistentStore(testKey, ''); + + const specialString = 'Hello "world"\nNew line\tTab'; + store.value = specialString; + + expect(store.value).toBe(specialString); + }); + + it('should handle unicode characters', () => { + const store = createPersistentStore(testKey, ''); + + store.value = 'Hello δΈ–η•Œ 🌍'; + + expect(store.value).toBe('Hello δΈ–η•Œ 🌍'); + }); + }); + + describe('Multiple Instances', () => { + it('should handle multiple stores with different keys', () => { + const store1 = createPersistentStore('key1', 'value1'); + const store2 = createPersistentStore('key2', 'value2'); + + store1.value = 'updated1'; + store2.value = 'updated2'; + + expect(store1.value).toBe('updated1'); + expect(store2.value).toBe('updated2'); + }); + + it('should keep stores independent', () => { + const store1 = createPersistentStore('key1', 'default1'); + const store2 = createPersistentStore('key2', 'default2'); + + store1.clear(); + + expect(store1.value).toBe('default1'); + expect(store2.value).toBe('default2'); + }); + }); + + describe('Complex Scenarios', () => { + it('should handle nested objects', () => { + interface NestedObject { + user: { + name: string; + settings: { + theme: string; + notifications: boolean; + }; + }; + } + const defaultValue: NestedObject = { + user: { + name: 'Test', + settings: { theme: 'light', notifications: true }, + }, + }; + const store = createPersistentStore(testKey, defaultValue); + + store.value = { + user: { + name: 'Updated', + settings: { theme: 'dark', notifications: false }, + }, + }; + + expect(store.value).toEqual({ + user: { + name: 'Updated', + settings: { theme: 'dark', notifications: false }, + }, + }); + }); + + it('should handle arrays of objects', () => { + interface Item { + id: number; + name: string; + } + const store = createPersistentStore(testKey, []); + + store.value = [ + { id: 1, name: 'First' }, + { id: 2, name: 'Second' }, + { id: 3, name: 'Third' }, + ]; + + expect(store.value).toHaveLength(3); + expect(store.value[0].name).toBe('First'); + }); + }); +}); -- 2.49.1 From e81cadb32ac117a9ac9d6e39f39a7035699db134 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:20:24 +0300 Subject: [PATCH 06/12] feat(smoothScroll): cover smoothScroll util with unit tests --- .../utils/smoothScroll/smoothScroll.test.ts | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 src/shared/lib/utils/smoothScroll/smoothScroll.test.ts diff --git a/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts new file mode 100644 index 0000000..0f825b7 --- /dev/null +++ b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts @@ -0,0 +1,368 @@ +/** @vitest-environment jsdom */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { smoothScroll } from './smoothScroll'; + +describe('smoothScroll', () => { + let mockAnchor: HTMLAnchorElement; + let mockTarget: HTMLElement; + let mockScrollIntoView: ReturnType; + let mockPushState: ReturnType; + + beforeEach(() => { + // Mock scrollIntoView + mockScrollIntoView = vi.fn(); + HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + + // Mock history.pushState + mockPushState = vi.fn(); + vi.stubGlobal('history', { + pushState: mockPushState, + }); + + // Create mock elements + mockAnchor = document.createElement('a'); + mockAnchor.setAttribute('href', '#section-1'); + + mockTarget = document.createElement('div'); + mockTarget.id = 'section-1'; + document.body.appendChild(mockTarget); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + }); + + describe('Basic Functionality', () => { + it('should be a function that returns an object with destroy method', () => { + const action = smoothScroll(mockAnchor); + + expect(typeof action).toBe('object'); + expect(typeof action.destroy).toBe('function'); + }); + + it('should add click event listener to the anchor element', () => { + const addEventListenerSpy = vi.spyOn(mockAnchor, 'addEventListener'); + smoothScroll(mockAnchor); + + expect(addEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + addEventListenerSpy.mockRestore(); + }); + + it('should remove click event listener when destroy is called', () => { + const action = smoothScroll(mockAnchor); + const removeEventListenerSpy = vi.spyOn(mockAnchor, 'removeEventListener'); + + action.destroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith('click', expect.any(Function)); + removeEventListenerSpy.mockRestore(); + }); + }); + + describe('Click Handling', () => { + it('should prevent default behavior on click', () => { + const mockEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + }); + const preventDefaultSpy = vi.spyOn(mockEvent, 'preventDefault'); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + preventDefaultSpy.mockRestore(); + }); + + it('should scroll to target element when clicked', () => { + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'start', + }); + }); + + it('should update URL hash without jumping when clicked', () => { + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1'); + }); + }); + + describe('Edge Cases', () => { + it('should do nothing when href attribute is missing', () => { + mockAnchor.removeAttribute('href'); + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).not.toHaveBeenCalled(); + expect(mockPushState).not.toHaveBeenCalled(); + }); + + it('should do nothing when href is just "#"', () => { + mockAnchor.setAttribute('href', '#'); + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).not.toHaveBeenCalled(); + expect(mockPushState).not.toHaveBeenCalled(); + }); + + it('should do nothing when target element does not exist', () => { + mockAnchor.setAttribute('href', '#non-existent'); + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).not.toHaveBeenCalled(); + expect(mockPushState).not.toHaveBeenCalled(); + }); + + it('should handle empty href attribute', () => { + mockAnchor.setAttribute('href', ''); + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).not.toHaveBeenCalled(); + }); + }); + + describe('Multiple Anchors', () => { + it('should work correctly with multiple anchor elements', () => { + const anchor1 = document.createElement('a'); + anchor1.setAttribute('href', '#section-1'); + const target1 = document.createElement('div'); + target1.id = 'section-1'; + document.body.appendChild(target1); + + const anchor2 = document.createElement('a'); + anchor2.setAttribute('href', '#section-2'); + const target2 = document.createElement('div'); + target2.id = 'section-2'; + document.body.appendChild(target2); + + const action1 = smoothScroll(anchor1); + const action2 = smoothScroll(anchor2); + + const event1 = new MouseEvent('click', { bubbles: true, cancelable: true }); + anchor1.dispatchEvent(event1); + + expect(mockScrollIntoView).toHaveBeenCalledTimes(1); + + const event2 = new MouseEvent('click', { bubbles: true, cancelable: true }); + anchor2.dispatchEvent(event2); + + expect(mockScrollIntoView).toHaveBeenCalledTimes(2); + expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-2'); + + // Cleanup + action1.destroy(); + action2.destroy(); + }); + }); + + describe('Cleanup', () => { + it('should not trigger clicks after destroy is called', () => { + const action = smoothScroll(mockAnchor); + + action.destroy(); + + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).not.toHaveBeenCalled(); + expect(mockPushState).not.toHaveBeenCalled(); + }); + + it('should allow multiple destroy calls without errors', () => { + const action = smoothScroll(mockAnchor); + + expect(() => { + action.destroy(); + action.destroy(); + action.destroy(); + }).not.toThrow(); + }); + }); + + describe('Scroll Options', () => { + it('should always use smooth behavior', () => { + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ + behavior: 'smooth', + }), + ); + }); + + it('should always use block: start', () => { + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).toHaveBeenCalledWith( + expect.objectContaining({ + block: 'start', + }), + ); + }); + }); + + describe('Different Hash Formats', () => { + it('should handle simple hash like "#section"', () => { + const target = document.createElement('div'); + target.id = 'section'; + document.body.appendChild(target); + + mockAnchor.setAttribute('href', '#section'); + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).toHaveBeenCalled(); + expect(mockPushState).toHaveBeenCalledWith(null, '', '#section'); + }); + + it('should handle hash with multiple words like "#my-section"', () => { + const target = document.createElement('div'); + target.id = 'my-section'; + document.body.appendChild(target); + + mockAnchor.setAttribute('href', '#my-section'); + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).toHaveBeenCalled(); + expect(mockPushState).toHaveBeenCalledWith(null, '', '#my-section'); + }); + + it('should handle hash with numbers like "#section-1-2"', () => { + const target = document.createElement('div'); + target.id = 'section-1-2'; + document.body.appendChild(target); + + mockAnchor.setAttribute('href', '#section-1-2'); + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + smoothScroll(mockAnchor); + mockAnchor.dispatchEvent(mockEvent); + + expect(mockScrollIntoView).toHaveBeenCalled(); + expect(mockPushState).toHaveBeenCalledWith(null, '', '#section-1-2'); + }); + }); + + describe('Special Cases', () => { + it('should gracefully handle missing history.pushState', () => { + // Create a fresh test environment + const testAnchor = document.createElement('a'); + testAnchor.href = '#test'; + const testTarget = document.createElement('div'); + testTarget.id = 'test'; + document.body.appendChild(testTarget); + + // Don't stub history - the action should still work without it + const action = smoothScroll(testAnchor); + const mockEvent = new MouseEvent('click', { bubbles: true, cancelable: true }); + + // Should not throw even if history.pushState might not exist + expect(() => testAnchor.dispatchEvent(mockEvent)).not.toThrow(); + + action.destroy(); + testTarget.remove(); + }); + }); + + describe('Return Value', () => { + it('should return an action object compatible with Svelte use directive', () => { + const action = smoothScroll(mockAnchor); + + expect(action).toHaveProperty('destroy'); + expect(typeof action.destroy).toBe('function'); + }); + + it('should allow chaining destroy calls', () => { + const action = smoothScroll(mockAnchor); + + const result = action.destroy(); + + expect(result).toBeUndefined(); + }); + }); + + describe('Real-World Scenarios', () => { + it('should handle table of contents navigation', () => { + const sections = ['intro', 'features', 'pricing', 'contact']; + sections.forEach(id => { + const section = document.createElement('section'); + section.id = id; + document.body.appendChild(section); + + const link = document.createElement('a'); + link.href = `#${id}`; + document.body.appendChild(link); + + const action = smoothScroll(link); + + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + link.dispatchEvent(event); + + expect(mockScrollIntoView).toHaveBeenCalled(); + + action.destroy(); + }); + + expect(mockScrollIntoView).toHaveBeenCalledTimes(sections.length); + }); + + it('should work with back-to-top button', () => { + const topAnchor = document.createElement('a'); + topAnchor.href = '#top'; + document.body.appendChild(topAnchor); + + const topElement = document.createElement('div'); + topElement.id = 'top'; + document.body.prepend(topElement); + + const action = smoothScroll(topAnchor); + + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + topAnchor.dispatchEvent(event); + + expect(mockScrollIntoView).toHaveBeenCalled(); + + action.destroy(); + }); + }); +}); -- 2.49.1 From 51ea8a990248ec8d23627b771d8e31ef7af32ab8 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:40:00 +0300 Subject: [PATCH 07/12] test(smoothScroll): cast mock to the proper type --- src/shared/lib/utils/smoothScroll/smoothScroll.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts index 0f825b7..39d6044 100644 --- a/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts +++ b/src/shared/lib/utils/smoothScroll/smoothScroll.test.ts @@ -18,7 +18,7 @@ describe('smoothScroll', () => { beforeEach(() => { // Mock scrollIntoView mockScrollIntoView = vi.fn(); - HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + HTMLElement.prototype.scrollIntoView = mockScrollIntoView as (arg?: boolean | ScrollIntoViewOptions) => void; // Mock history.pushState mockPushState = vi.fn(); -- 2.49.1 From d15b2ffe3f48c09070e5185702816b9a688dd727 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 18 Feb 2026 20:54:34 +0300 Subject: [PATCH 08/12] test(createVirtualizer): test coverage for virtual list logic --- .../createVirtualizer.test.ts | 550 ++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts new file mode 100644 index 0000000..c2fdaa5 --- /dev/null +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.test.ts @@ -0,0 +1,550 @@ +/** @vitest-environment jsdom */ +import { + afterEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createVirtualizer } from './createVirtualizer.svelte'; + +/** + * NOTE: Svelte 5 Runes Testing Limitations + * + * The createVirtualizer helper uses Svelte 5 runes ($state, $derived, $derived.by) + * which require a full Svelte runtime environment to work correctly. In unit tests + * with jsdom, these runes are stubbed and don't provide actual reactivity. + * + * These tests focus on: + * 1. API surface verification (methods, getters exist) + * 2. Initial state calculation + * 3. DOM integration (event listeners are attached) + * 4. Edge case handling + * + * For full reactivity testing, use browser-based tests with @vitest/browser-playwright + */ + +// Mock ResizeObserver globally since it's not available in jsdom +class MockResizeObserver { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} + +globalThis.ResizeObserver = MockResizeObserver as any; + +// Mock requestAnimationFrame +globalThis.requestAnimationFrame = + ((cb: FrameRequestCallback) => + setTimeout(() => cb(performance.now()), 16) as unknown) as typeof requestAnimationFrame; +globalThis.cancelAnimationFrame = vi.fn(); + +/** + * Helper to create test data array + */ +function createTestData(count: number): string[] { + return Array.from({ length: count }, (_, i) => `Item ${i}`); +} + +/** + * Helper to create a mock scrollable container element + */ +function createMockContainer(height = 500, scrollTop = 0): any { + const container = document.createElement('div'); + Object.defineProperty(container, 'offsetHeight', { + value: height, + configurable: true, + writable: true, + }); + Object.defineProperty(container, 'scrollTop', { + value: scrollTop, + writable: true, + configurable: true, + }); + // Add scrollTo method for testing + container.scrollTo = vi.fn(); + return container; +} + +describe('createVirtualizer - Basic API and State', () => { + describe('Basic Initialization and API Surface', () => { + it('should initialize and return expected API surface', () => { + const virtualizer = createVirtualizer(() => ({ + count: 0, + data: [], + estimateSize: () => 50, + })); + + // Verify API surface exists + expect(virtualizer).toHaveProperty('items'); + expect(virtualizer).toHaveProperty('totalSize'); + expect(virtualizer).toHaveProperty('scrollOffset'); + expect(virtualizer).toHaveProperty('containerHeight'); + expect(virtualizer).toHaveProperty('container'); + expect(virtualizer).toHaveProperty('measureElement'); + expect(virtualizer).toHaveProperty('scrollToIndex'); + expect(virtualizer).toHaveProperty('scrollToOffset'); + + // Verify initial values + expect(virtualizer.items).toEqual([]); + expect(virtualizer.totalSize).toBe(0); + expect(virtualizer.scrollOffset).toBe(0); + expect(virtualizer.containerHeight).toBe(0); + }); + + it('should calculate correct totalSize for uniform item sizes', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => 50, + })); + + // 10 items * 50px each = 500px total + expect(virtualizer.totalSize).toBe(500); + }); + + it('should calculate correct totalSize for varying item sizes', () => { + const sizes = [50, 100, 150, 75, 125]; // Sum = 500 + const virtualizer = createVirtualizer(() => ({ + count: 5, + data: createTestData(5), + estimateSize: (i: number) => sizes[i], + })); + + expect(virtualizer.totalSize).toBe(500); + }); + + it('should handle empty list (count = 0)', () => { + const virtualizer = createVirtualizer(() => ({ + count: 0, + data: [], + estimateSize: () => 50, + })); + + expect(virtualizer.totalSize).toBe(0); + expect(virtualizer.items).toEqual([]); + }); + + it('should handle very large lists', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100000, + data: createTestData(100000), + estimateSize: () => 50, + })); + + expect(virtualizer.totalSize).toBe(5000000); // 100000 * 50 + }); + + it('should handle zero estimated size', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => 0, + })); + + expect(virtualizer.totalSize).toBe(0); + }); + }); + + describe('Container Action', () => { + let cleanupHandlers: (() => void)[] = []; + + afterEach(() => { + cleanupHandlers.forEach(cleanup => cleanup()); + cleanupHandlers = []; + }); + + it('should attach container action and set up listeners', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const addEventListenerSpy = vi.spyOn(container, 'addEventListener'); + + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + // Verify scroll listener was attached + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'scroll', + expect.any(Function), + { passive: true }, + ); + }); + + it('should update containerHeight when container is attached', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + expect(virtualizer.containerHeight).toBe(500); + }); + + it('should clean up listeners on destroy', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const removeEventListenerSpy = vi.spyOn(container, 'removeEventListener'); + + const cleanup = virtualizer.container(container); + cleanup?.destroy?.(); + + expect(removeEventListenerSpy).toHaveBeenCalled(); + }); + + it('should support window scrolling mode', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + useWindowScroll: true, + })); + + const container = createMockContainer(500, 0); + const windowAddSpy = vi.spyOn(window, 'addEventListener'); + + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + // Should attach to window scroll + expect(windowAddSpy).toHaveBeenCalledWith('scroll', expect.any(Function), expect.any(Object)); + expect(windowAddSpy).toHaveBeenCalledWith('resize', expect.any(Function)); + + windowAddSpy.mockRestore(); + }); + }); + + describe('scrollToIndex Method', () => { + let cleanupHandlers: (() => void)[] = []; + + afterEach(() => { + cleanupHandlers.forEach(cleanup => cleanup()); + cleanupHandlers = []; + }); + + it('should have scrollToIndex method that does not throw without container', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + // Should not throw when container is not attached + expect(() => virtualizer.scrollToIndex(50)).not.toThrow(); + }); + + it('should scroll to specific index with container attached', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const scrollToSpy = vi.spyOn(container, 'scrollTo'); + + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + virtualizer.scrollToIndex(10); + + expect(scrollToSpy).toHaveBeenCalledWith({ + top: expect.any(Number), + behavior: 'smooth', + }); + }); + + it('should handle center alignment', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const scrollToSpy = vi.spyOn(container, 'scrollTo'); + + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + virtualizer.scrollToIndex(10, 'center'); + + expect(scrollToSpy).toHaveBeenCalled(); + }); + + it('should handle end alignment', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const scrollToSpy = vi.spyOn(container, 'scrollTo'); + + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + virtualizer.scrollToIndex(10, 'end'); + + expect(scrollToSpy).toHaveBeenCalled(); + }); + + it('should not scroll for out of bounds indices', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const scrollToSpy = vi.spyOn(container, 'scrollTo'); + + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + // Negative index + virtualizer.scrollToIndex(-1); + + // Index >= count + virtualizer.scrollToIndex(100); + + // Should not have been called + expect(scrollToSpy).not.toHaveBeenCalled(); + }); + }); + + describe('scrollToOffset Method', () => { + let cleanupHandlers: (() => void)[] = []; + + afterEach(() => { + cleanupHandlers.forEach(cleanup => cleanup()); + cleanupHandlers = []; + }); + + it('should scroll to specific pixel offset', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const scrollToSpy = vi.spyOn(container, 'scrollTo'); + + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + virtualizer.scrollToOffset(1000); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'auto' }); + }); + + it('should support smooth behavior', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const scrollToSpy = vi.spyOn(container, 'scrollTo'); + + const cleanup = virtualizer.container(container); + cleanupHandlers.push(() => cleanup?.destroy?.()); + + virtualizer.scrollToOffset(1000, 'smooth'); + + expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: 'smooth' }); + }); + }); + + describe('measureElement Action', () => { + it('should attach measureElement action to DOM element', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => 50, + })); + + const element = document.createElement('div'); + element.dataset.index = '0'; + + // Should not throw when attaching measureElement + expect(() => { + const cleanup = virtualizer.measureElement(element); + cleanup?.destroy?.(); + }).not.toThrow(); + }); + + it('should clean up observer on destroy', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => 50, + })); + + const element = document.createElement('div'); + element.dataset.index = '0'; + + const cleanup = virtualizer.measureElement(element); + + // Should not throw when destroying + expect(() => cleanup?.destroy?.()).not.toThrow(); + }); + + it('should handle multiple elements being measured', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => 50, + })); + + const elements = Array.from({ length: 5 }, (_, i) => { + const el = document.createElement('div'); + el.dataset.index = String(i); + return el; + }); + + const cleanups = elements.map(el => virtualizer.measureElement(el)); + + // Should not throw when measuring multiple elements + expect(() => { + cleanups.forEach(cleanup => cleanup?.destroy?.()); + }).not.toThrow(); + }); + }); + + describe('Options Handling', () => { + it('should use default overscan of 5', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + // Options with default overscan should work + expect(virtualizer).toHaveProperty('items'); + }); + + it('should use custom overscan value', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + overscan: 10, + })); + + expect(virtualizer).toHaveProperty('items'); + }); + + it('should use index as default key when getItemKey is not provided', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => 50, + })); + + expect(virtualizer).toHaveProperty('items'); + }); + + it('should use custom getItemKey when provided', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => 50, + getItemKey: (i: number) => `custom-key-${i}`, + })); + + expect(virtualizer).toHaveProperty('items'); + }); + + it('should use custom scrollMargin when provided', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + scrollMargin: 100, + })); + + expect(virtualizer).toHaveProperty('items'); + }); + }); + + describe('Edge Cases', () => { + it('should handle single item list', () => { + const virtualizer = createVirtualizer(() => ({ + count: 1, + data: ['Item 0'], + estimateSize: () => 100, + })); + + expect(virtualizer.totalSize).toBe(100); + }); + + it('should handle items larger than viewport', () => { + const virtualizer = createVirtualizer(() => ({ + count: 5, + data: createTestData(5), + estimateSize: () => 200, // Each item is 200px + })); + + // Total size should still be calculated correctly + expect(virtualizer.totalSize).toBe(1000); // 5 * 200 + }); + + it('should handle overscan larger than viewport', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => 50, + overscan: 100, // Very large overscan + })); + + expect(virtualizer).toHaveProperty('items'); + }); + + it('should handle negative estimated size (graceful degradation)', () => { + const virtualizer = createVirtualizer(() => ({ + count: 10, + data: createTestData(10), + estimateSize: () => -10, + })); + + // Should calculate total size (may be negative, but shouldn't crash) + expect(virtualizer.totalSize).toBeLessThanOrEqual(0); + }); + }); + + describe('Virtual Item Structure', () => { + it('should return items with correct structure when container is attached', () => { + const virtualizer = createVirtualizer(() => ({ + count: 100, + data: createTestData(100), + estimateSize: () => 50, + })); + + const container = createMockContainer(500, 0); + const cleanup = virtualizer.container(container); + + // Items may be empty in test environment due to reactivity limitations + // but we verify the structure exists + expect(Array.isArray(virtualizer.items)).toBe(true); + + cleanup?.destroy?.(); + }); + }); +}); -- 2.49.1 From 935b065843a7f923faf4dd1a7e2a1a8b53be5eb9 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 19 Feb 2026 13:54:37 +0300 Subject: [PATCH 09/12] feat(app): add --font-sans variable --- src/app/styles/app.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/styles/app.css b/src/app/styles/app.css index 9883313..f312953 100644 --- a/src/app/styles/app.css +++ b/src/app/styles/app.css @@ -167,7 +167,8 @@ --color-gradient-from: var(--gradient-from); --color-gradient-via: var(--gradient-via); --color-gradient-to: var(--gradient-to); - --font-mono: var(--font-mono); + --font-mono: 'Major Mono Display', monospace; + --font-sans: 'Karla', system-ui, -apple-system, 'Segoe UI', Inter, Roboto, Arial, sans-serif; } @layer base { -- 2.49.1 From 9d1f59d8198fdb85f256c957e016d8cc7888813b Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 19 Feb 2026 13:55:11 +0300 Subject: [PATCH 10/12] feat(IconButton): add conditional rendering --- src/shared/ui/IconButton/IconButton.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/ui/IconButton/IconButton.svelte b/src/shared/ui/IconButton/IconButton.svelte index 54e0d9d..bb2b2b6 100644 --- a/src/shared/ui/IconButton/IconButton.svelte +++ b/src/shared/ui/IconButton/IconButton.svelte @@ -41,7 +41,7 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props(); size="icon" {...rest} > - {@render icon({ + {@render icon?.({ className: cn( 'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-2 group-active:scale-90 group-disabled:stroke-transparent', rotation === 'clockwise' -- 2.49.1 From da79dd2e359c9645adbd9830452db05b359d0b52 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 19 Feb 2026 13:58:12 +0300 Subject: [PATCH 11/12] feat: storybook cases and mocks --- .storybook/Decorator.svelte | 29 + .storybook/StoryStage.svelte | 6 +- .storybook/main.ts | 3 +- .storybook/preview-head.html | 13 + .storybook/preview.ts | 47 +- src/entities/Font/index.ts | 50 ++ src/entities/Font/lib/index.ts | 50 ++ src/entities/Font/lib/mocks/filters.mock.ts | 348 ++++++++++ src/entities/Font/lib/mocks/fonts.mock.ts | 630 ++++++++++++++++++ src/entities/Font/lib/mocks/index.ts | 84 +++ src/entities/Font/lib/mocks/stores.mock.ts | 590 ++++++++++++++++ src/shared/lib/storybook/MockIcon.svelte | 41 ++ src/shared/lib/storybook/Providers.svelte | 64 ++ src/shared/lib/storybook/index.ts | 24 + .../ComboControlV2.stories.svelte | 80 ++- .../ui/IconButton/IconButton.stories.svelte | 101 +++ src/shared/ui/Section/Section.stories.svelte | 475 +++++++++++++ src/shared/ui/Slider/Slider.stories.svelte | 19 +- .../ui/VirtualList/VirtualList.stories.svelte | 30 +- .../ComparisonSlider.stories.svelte | 217 ++++++ .../ui/FontSearch/FontSearch.stories.svelte | 102 +++ .../ui/SampleList/SampleList.stories.svelte | 89 +++ 22 files changed, 3047 insertions(+), 45 deletions(-) create mode 100644 .storybook/Decorator.svelte create mode 100644 .storybook/preview-head.html create mode 100644 src/entities/Font/lib/mocks/filters.mock.ts create mode 100644 src/entities/Font/lib/mocks/fonts.mock.ts create mode 100644 src/entities/Font/lib/mocks/index.ts create mode 100644 src/entities/Font/lib/mocks/stores.mock.ts create mode 100644 src/shared/lib/storybook/MockIcon.svelte create mode 100644 src/shared/lib/storybook/Providers.svelte create mode 100644 src/shared/lib/storybook/index.ts create mode 100644 src/shared/ui/IconButton/IconButton.stories.svelte create mode 100644 src/shared/ui/Section/Section.stories.svelte create mode 100644 src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte create mode 100644 src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte create mode 100644 src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte diff --git a/.storybook/Decorator.svelte b/.storybook/Decorator.svelte new file mode 100644 index 0000000..e2abefd --- /dev/null +++ b/.storybook/Decorator.svelte @@ -0,0 +1,29 @@ + + + + + {@render children()} + diff --git a/.storybook/StoryStage.svelte b/.storybook/StoryStage.svelte index d0510dc..5a9a9c7 100644 --- a/.storybook/StoryStage.svelte +++ b/.storybook/StoryStage.svelte @@ -7,9 +7,9 @@ interface Props { let { children, width = 'max-w-3xl' }: Props = $props(); -
-
-
+
+
+
{@render children()}
diff --git a/.storybook/main.ts b/.storybook/main.ts index 78cd878..f0a4d6d 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -21,7 +21,8 @@ const config: StorybookConfig = { { name: '@storybook/addon-svelte-csf', options: { - legacyTemplate: true, // Enables the legacy template syntax + // Use modern template syntax for better performance + legacyTemplate: false, }, }, '@chromatic-com/storybook', diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..6f1d1e0 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,13 @@ + + + + + + diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 2734a23..281bb4e 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,5 @@ import type { Preview } from '@storybook/svelte-vite'; +import Decorator from './Decorator.svelte'; import StoryStage from './StoryStage.svelte'; import '../src/app/styles/app.css'; @@ -23,25 +24,41 @@ const preview: Preview = { story: { // This sets the default height for the iframe in Autodocs iframeHeight: '400px', - // Ensure the story isn't forced into a tiny inline box - // inline: true, }, }, + + head: ` + + + + + + + `, }, + decorators: [ - (storyFn, { parameters }) => { - const { Component, props } = storyFn(); - return { - Component: StoryStage, - // We pass the actual story component into the Stage via a snippet/slot - // Svelte 5 Storybook handles this mapping internally when you return this structure - props: { - children: Component, - width: parameters.stageWidth || 'max-w-3xl', - ...props, - }, - }; - }, + // Wrap with providers (TooltipProvider, ResponsiveManager) + story => ({ + Component: Decorator, + props: { + children: story(), + }, + }), + // Wrap with StoryStage for presentation styling + story => ({ + Component: StoryStage, + props: { + children: story(), + }, + }), ], }; diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index f0f07a5..4c3340d 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -78,6 +78,56 @@ export { unifiedFontStore, } from './model'; +// Mock data helpers for Storybook and testing +export { + createCategoriesFilter, + createErrorState, + createGenericFilter, + createLoadingState, + createMockComparisonStore, + // Filter mocks + createMockFilter, + createMockFontApiResponse, + createMockFontStoreState, + // Store mocks + createMockQueryState, + createMockReactiveState, + createMockStore, + createProvidersFilter, + createSubsetsFilter, + createSuccessState, + FONTHARE_FONTS, + generateMixedCategoryFonts, + generateMockFonts, + generatePaginatedFonts, + generateSequentialFilter, + GENERIC_FILTERS, + getAllMockFonts, + getFontsByCategory, + getFontsByProvider, + GOOGLE_FONTS, + MOCK_FILTERS, + MOCK_FILTERS_ALL_SELECTED, + MOCK_FILTERS_EMPTY, + MOCK_FILTERS_SELECTED, + MOCK_FONT_STORE_STATES, + MOCK_STORES, + type MockFilterOptions, + type MockFilters, + mockFontshareFont, + type MockFontshareFontOptions, + type MockFontStoreState, + // Font mocks + mockGoogleFont, + // Types + type MockGoogleFontOptions, + type MockQueryObserverResult, + type MockQueryState, + mockUnifiedFont, + type MockUnifiedFontOptions, + UNIFIED_FONTS, +} from './lib/mocks'; + // UI elements export { FontApplicator, diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index 8d7bad8..532d5b8 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -6,3 +6,53 @@ export { } from './normalize/normalize'; export { getFontUrl } from './getFontUrl/getFontUrl'; + +// Mock data helpers for Storybook and testing +export { + createCategoriesFilter, + createErrorState, + createGenericFilter, + createLoadingState, + createMockComparisonStore, + // Filter mocks + createMockFilter, + createMockFontApiResponse, + createMockFontStoreState, + // Store mocks + createMockQueryState, + createMockReactiveState, + createMockStore, + createProvidersFilter, + createSubsetsFilter, + createSuccessState, + FONTHARE_FONTS, + generateMixedCategoryFonts, + generateMockFonts, + generatePaginatedFonts, + generateSequentialFilter, + GENERIC_FILTERS, + getAllMockFonts, + getFontsByCategory, + getFontsByProvider, + GOOGLE_FONTS, + MOCK_FILTERS, + MOCK_FILTERS_ALL_SELECTED, + MOCK_FILTERS_EMPTY, + MOCK_FILTERS_SELECTED, + MOCK_FONT_STORE_STATES, + MOCK_STORES, + type MockFilterOptions, + type MockFilters, + mockFontshareFont, + type MockFontshareFontOptions, + type MockFontStoreState, + // Font mocks + mockGoogleFont, + // Types + type MockGoogleFontOptions, + type MockQueryObserverResult, + type MockQueryState, + mockUnifiedFont, + type MockUnifiedFontOptions, + UNIFIED_FONTS, +} from './mocks'; diff --git a/src/entities/Font/lib/mocks/filters.mock.ts b/src/entities/Font/lib/mocks/filters.mock.ts new file mode 100644 index 0000000..35f1e31 --- /dev/null +++ b/src/entities/Font/lib/mocks/filters.mock.ts @@ -0,0 +1,348 @@ +/** + * ============================================================================ + * MOCK FONT FILTER DATA + * ============================================================================ + * + * Factory functions and preset mock data for font-related filters. + * Used in Storybook stories for font filtering components. + * + * ## Usage + * + * ```ts + * import { + * createMockFilter, + * MOCK_FILTERS, + * } from '$entities/Font/lib/mocks'; + * + * // Create a custom filter + * const customFilter = createMockFilter({ + * properties: [ + * { id: 'option1', name: 'Option 1', value: 'option1' }, + * { id: 'option2', name: 'Option 2', value: 'option2', selected: true }, + * ], + * }); + * + * // Use preset filters + * const categoriesFilter = MOCK_FILTERS.categories; + * const subsetsFilter = MOCK_FILTERS.subsets; + * ``` + */ + +import type { + FontCategory, + FontProvider, + FontSubset, +} from '$entities/Font/model/types'; +import type { Property } from '$shared/lib'; +import { createFilter } from '$shared/lib'; + +// ============================================================================ +// TYPE DEFINITIONS +// ============================================================================ + +/** + * Options for creating a mock filter + */ +export interface MockFilterOptions { + /** Filter properties */ + properties: Property[]; +} + +/** + * Preset mock filters for font filtering + */ +export interface MockFilters { + /** Provider filter (Google, Fontshare) */ + providers: ReturnType>; + /** Category filter (sans-serif, serif, display, etc.) */ + categories: ReturnType>; + /** Subset filter (latin, latin-ext, cyrillic, etc.) */ + subsets: ReturnType>; +} + +// ============================================================================ +// FONT CATEGORIES +// ============================================================================ + +/** + * Google Fonts categories + */ +export const GOOGLE_CATEGORIES: Property<'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'>[] = [ + { id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' }, + { id: 'serif', name: 'Serif', value: 'serif' }, + { id: 'display', name: 'Display', value: 'display' }, + { id: 'handwriting', name: 'Handwriting', value: 'handwriting' }, + { id: 'monospace', name: 'Monospace', value: 'monospace' }, +]; + +/** + * Fontshare categories (mapped to common naming) + */ +export const FONTHARE_CATEGORIES: Property<'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script'>[] = [ + { id: 'sans', name: 'Sans', value: 'sans' }, + { id: 'serif', name: 'Serif', value: 'serif' }, + { id: 'slab', name: 'Slab', value: 'slab' }, + { id: 'display', name: 'Display', value: 'display' }, + { id: 'handwritten', name: 'Handwritten', value: 'handwritten' }, + { id: 'script', name: 'Script', value: 'script' }, +]; + +/** + * Unified categories (combines both providers) + */ +export const UNIFIED_CATEGORIES: Property[] = [ + { id: 'sans-serif', name: 'Sans Serif', value: 'sans-serif' }, + { id: 'serif', name: 'Serif', value: 'serif' }, + { id: 'display', name: 'Display', value: 'display' }, + { id: 'handwriting', name: 'Handwriting', value: 'handwriting' }, + { id: 'monospace', name: 'Monospace', value: 'monospace' }, +]; + +// ============================================================================ +// FONT SUBSETS +// ============================================================================ + +/** + * Common font subsets + */ +export const FONT_SUBSETS: Property[] = [ + { id: 'latin', name: 'Latin', value: 'latin' }, + { id: 'latin-ext', name: 'Latin Extended', value: 'latin-ext' }, + { id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' }, + { id: 'greek', name: 'Greek', value: 'greek' }, + { id: 'arabic', name: 'Arabic', value: 'arabic' }, + { id: 'devanagari', name: 'Devanagari', value: 'devanagari' }, +]; + +// ============================================================================ +// FONT PROVIDERS +// ============================================================================ + +/** + * Font providers + */ +export const FONT_PROVIDERS: Property[] = [ + { id: 'google', name: 'Google Fonts', value: 'google' }, + { id: 'fontshare', name: 'Fontshare', value: 'fontshare' }, +]; + +// ============================================================================ +// FILTER FACTORIES +// ============================================================================ + +/** + * Create a mock filter from properties + */ +export function createMockFilter( + options: MockFilterOptions & { properties: Property[] }, +) { + return createFilter(options); +} + +/** + * Create a mock filter for categories + */ +export function createCategoriesFilter(options?: { selected?: FontCategory[] }) { + const properties = UNIFIED_CATEGORIES.map(cat => ({ + ...cat, + selected: options?.selected?.includes(cat.value) ?? false, + })); + return createFilter({ properties }); +} + +/** + * Create a mock filter for subsets + */ +export function createSubsetsFilter(options?: { selected?: FontSubset[] }) { + const properties = FONT_SUBSETS.map(subset => ({ + ...subset, + selected: options?.selected?.includes(subset.value) ?? false, + })); + return createFilter({ properties }); +} + +/** + * Create a mock filter for providers + */ +export function createProvidersFilter(options?: { selected?: FontProvider[] }) { + const properties = FONT_PROVIDERS.map(provider => ({ + ...provider, + selected: options?.selected?.includes(provider.value) ?? false, + })); + return createFilter({ properties }); +} + +// ============================================================================ +// PRESET FILTERS +// ============================================================================ + +/** + * Preset mock filters - use these directly in stories + */ +export const MOCK_FILTERS: MockFilters = { + providers: createFilter({ + properties: FONT_PROVIDERS, + }), + categories: createFilter({ + properties: UNIFIED_CATEGORIES, + }), + subsets: createFilter({ + properties: FONT_SUBSETS, + }), +}; + +/** + * Preset filters with some items selected + */ +export const MOCK_FILTERS_SELECTED: MockFilters = { + providers: createFilter({ + properties: [ + { ...FONT_PROVIDERS[0], selected: true }, + { ...FONT_PROVIDERS[1] }, + ], + }), + categories: createFilter({ + properties: [ + { ...UNIFIED_CATEGORIES[0], selected: true }, + { ...UNIFIED_CATEGORIES[1], selected: true }, + { ...UNIFIED_CATEGORIES[2] }, + { ...UNIFIED_CATEGORIES[3] }, + { ...UNIFIED_CATEGORIES[4] }, + ], + }), + subsets: createFilter({ + properties: [ + { ...FONT_SUBSETS[0], selected: true }, + { ...FONT_SUBSETS[1] }, + { ...FONT_SUBSETS[2] }, + { ...FONT_SUBSETS[3] }, + { ...FONT_SUBSETS[4] }, + ], + }), +}; + +/** + * Empty filters (all properties, none selected) + */ +export const MOCK_FILTERS_EMPTY: MockFilters = { + providers: createFilter({ + properties: FONT_PROVIDERS.map(p => ({ ...p, selected: false })), + }), + categories: createFilter({ + properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: false })), + }), + subsets: createFilter({ + properties: FONT_SUBSETS.map(s => ({ ...s, selected: false })), + }), +}; + +/** + * All selected filters + */ +export const MOCK_FILTERS_ALL_SELECTED: MockFilters = { + providers: createFilter({ + properties: FONT_PROVIDERS.map(p => ({ ...p, selected: true })), + }), + categories: createFilter({ + properties: UNIFIED_CATEGORIES.map(c => ({ ...c, selected: true })), + }), + subsets: createFilter({ + properties: FONT_SUBSETS.map(s => ({ ...s, selected: true })), + }), +}; + +// ============================================================================ +// GENERIC FILTER MOCKS +// ============================================================================ + +/** + * Create a mock filter with generic string properties + * Useful for testing generic filter components + */ +export function createGenericFilter( + items: Array<{ id: string; name: string; selected?: boolean }>, + options?: { selected?: string[] }, +) { + const properties = items.map(item => ({ + id: item.id, + name: item.name, + value: item.id, + selected: options?.selected?.includes(item.id) ?? item.selected ?? false, + })); + return createFilter({ properties }); +} + +/** + * Preset generic filters for testing + */ +export const GENERIC_FILTERS = { + /** Small filter with 3 items */ + small: createFilter({ + properties: [ + { id: 'option-1', name: 'Option 1', value: 'option-1' }, + { id: 'option-2', name: 'Option 2', value: 'option-2' }, + { id: 'option-3', name: 'Option 3', value: 'option-3' }, + ], + }), + /** Medium filter with 6 items */ + medium: createFilter({ + properties: [ + { id: 'alpha', name: 'Alpha', value: 'alpha' }, + { id: 'beta', name: 'Beta', value: 'beta' }, + { id: 'gamma', name: 'Gamma', value: 'gamma' }, + { id: 'delta', name: 'Delta', value: 'delta' }, + { id: 'epsilon', name: 'Epsilon', value: 'epsilon' }, + { id: 'zeta', name: 'Zeta', value: 'zeta' }, + ], + }), + /** Large filter with 12 items */ + large: createFilter({ + properties: [ + { id: 'jan', name: 'January', value: 'jan' }, + { id: 'feb', name: 'February', value: 'feb' }, + { id: 'mar', name: 'March', value: 'mar' }, + { id: 'apr', name: 'April', value: 'apr' }, + { id: 'may', name: 'May', value: 'may' }, + { id: 'jun', name: 'June', value: 'jun' }, + { id: 'jul', name: 'July', value: 'jul' }, + { id: 'aug', name: 'August', value: 'aug' }, + { id: 'sep', name: 'September', value: 'sep' }, + { id: 'oct', name: 'October', value: 'oct' }, + { id: 'nov', name: 'November', value: 'nov' }, + { id: 'dec', name: 'December', value: 'dec' }, + ], + }), + /** Filter with some pre-selected items */ + partial: createFilter({ + properties: [ + { id: 'red', name: 'Red', value: 'red', selected: true }, + { id: 'blue', name: 'Blue', value: 'blue', selected: false }, + { id: 'green', name: 'Green', value: 'green', selected: true }, + { id: 'yellow', name: 'Yellow', value: 'yellow', selected: false }, + ], + }), + /** Filter with all items selected */ + allSelected: createFilter({ + properties: [ + { id: 'cat', name: 'Cat', value: 'cat', selected: true }, + { id: 'dog', name: 'Dog', value: 'dog', selected: true }, + { id: 'bird', name: 'Bird', value: 'bird', selected: true }, + ], + }), + /** Empty filter (no items) */ + empty: createFilter({ + properties: [], + }), +}; + +/** + * Generate a filter with sequential items + */ +export function generateSequentialFilter(count: number, prefix = 'Item ') { + const properties = Array.from({ length: count }, (_, i) => ({ + id: `item-${i + 1}`, + name: `${prefix}${i + 1}`, + value: `item-${i + 1}`, + })); + return createFilter({ properties }); +} diff --git a/src/entities/Font/lib/mocks/fonts.mock.ts b/src/entities/Font/lib/mocks/fonts.mock.ts new file mode 100644 index 0000000..2ee8868 --- /dev/null +++ b/src/entities/Font/lib/mocks/fonts.mock.ts @@ -0,0 +1,630 @@ +/** + * ============================================================================ + * MOCK FONT DATA + * ============================================================================ + * + * Factory functions and preset mock data for fonts. + * Used in Storybook stories, tests, and development. + * + * ## Usage + * + * ```ts + * import { + * mockGoogleFont, + * mockFontshareFont, + * mockUnifiedFont, + * GOOGLE_FONTS, + * FONTHARE_FONTS, + * UNIFIED_FONTS, + * } from '$entities/Font/lib/mocks'; + * + * // Create a mock Google Font + * const roboto = mockGoogleFont({ family: 'Roboto', category: 'sans-serif' }); + * + * // Create a mock Fontshare font + * const satoshi = mockFontshareFont({ name: 'Satoshi', slug: 'satoshi' }); + * + * // Create a mock UnifiedFont + * const font = mockUnifiedFont({ id: 'roboto', name: 'Roboto' }); + * + * // Use preset fonts + * import { UNIFIED_FONTS } from '$entities/Font/lib/mocks'; + * ``` + */ + +import type { + FontCategory, + FontProvider, + FontSubset, + FontVariant, +} from '$entities/Font/model/types'; +import type { + FontItem, + FontshareFont, + GoogleFontItem, +} from '$entities/Font/model/types'; +import type { + FontFeatures, + FontMetadata, + FontStyleUrls, + UnifiedFont, +} from '$entities/Font/model/types'; + +// ============================================================================ +// GOOGLE FONTS MOCKS +// ============================================================================ + +/** + * Options for creating a mock Google Font + */ +export interface MockGoogleFontOptions { + /** Font family name (default: 'Mock Font') */ + family?: string; + /** Font category (default: 'sans-serif') */ + category?: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; + /** Font variants (default: ['regular', '700', 'italic', '700italic']) */ + variants?: FontVariant[]; + /** Font subsets (default: ['latin']) */ + subsets?: string[]; + /** Font version (default: 'v30') */ + version?: string; + /** Last modified date (default: current ISO date) */ + lastModified?: string; + /** Custom file URLs (if not provided, mock URLs are generated) */ + files?: Partial>; + /** Popularity rank (1 = most popular) */ + popularity?: number; +} + +/** + * Default mock Google Font + */ +export function mockGoogleFont(options: MockGoogleFontOptions = {}): GoogleFontItem { + const { + family = 'Mock Font', + category = 'sans-serif', + variants = ['regular', '700', 'italic', '700italic'], + subsets = ['latin'], + version = 'v30', + lastModified = new Date().toISOString().split('T')[0], + files, + popularity = 1, + } = options; + + const baseUrl = `https://fonts.gstatic.com/s/${family.toLowerCase().replace(/\s+/g, '')}/${version}`; + + return { + family, + category, + variants: variants as FontVariant[], + subsets, + version, + lastModified, + files: files ?? { + regular: `${baseUrl}/KFOmCnqEu92Fr1Me4W.woff2`, + '700': `${baseUrl}/KFOlCnqEu92Fr1MmWUlfBBc9.woff2`, + italic: `${baseUrl}/KFOkCnqEu92Fr1Mu51xIIzI.woff2`, + '700italic': `${baseUrl}/KFOjCnqEu92Fr1Mu51TzBic6CsQ.woff2`, + }, + menu: `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}`, + }; +} + +/** + * Preset Google Font mocks + */ +export const GOOGLE_FONTS: Record = { + roboto: mockGoogleFont({ + family: 'Roboto', + category: 'sans-serif', + variants: ['100', '300', '400', '500', '700', '900', 'italic', '700italic'], + subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'], + popularity: 1, + }), + openSans: mockGoogleFont({ + family: 'Open Sans', + category: 'sans-serif', + variants: ['300', '400', '500', '600', '700', '800', 'italic', '700italic'], + subsets: ['latin', 'latin-ext', 'cyrillic', 'greek'], + popularity: 2, + }), + lato: mockGoogleFont({ + family: 'Lato', + category: 'sans-serif', + variants: ['100', '300', '400', '700', '900', 'italic', '700italic'], + subsets: ['latin', 'latin-ext'], + popularity: 3, + }), + playfairDisplay: mockGoogleFont({ + family: 'Playfair Display', + category: 'serif', + variants: ['400', '500', '600', '700', '800', '900', 'italic', '700italic'], + subsets: ['latin', 'latin-ext', 'cyrillic'], + popularity: 10, + }), + montserrat: mockGoogleFont({ + family: 'Montserrat', + category: 'sans-serif', + variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic', '700italic'], + subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'], + popularity: 4, + }), + sourceSansPro: mockGoogleFont({ + family: 'Source Sans Pro', + category: 'sans-serif', + variants: ['200', '300', '400', '600', '700', '900', 'italic', '700italic'], + subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'], + popularity: 5, + }), + merriweather: mockGoogleFont({ + family: 'Merriweather', + category: 'serif', + variants: ['300', '400', '700', '900', 'italic', '700italic'], + subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'], + popularity: 15, + }), + robotoSlab: mockGoogleFont({ + family: 'Roboto Slab', + category: 'serif', + variants: ['100', '300', '400', '500', '700', '900'], + subsets: ['latin', 'latin-ext', 'cyrillic', 'greek', 'vietnamese'], + popularity: 8, + }), + oswald: mockGoogleFont({ + family: 'Oswald', + category: 'sans-serif', + variants: ['200', '300', '400', '500', '600', '700'], + subsets: ['latin', 'latin-ext', 'vietnamese'], + popularity: 6, + }), + raleway: mockGoogleFont({ + family: 'Raleway', + category: 'sans-serif', + variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900', 'italic'], + subsets: ['latin', 'latin-ext', 'cyrillic', 'vietnamese'], + popularity: 7, + }), +}; + +// ============================================================================ +// FONTHARE MOCKS +// ============================================================================ + +/** + * Options for creating a mock Fontshare font + */ +export interface MockFontshareFontOptions { + /** Font name (default: 'Mock Font') */ + name?: string; + /** URL-friendly slug (default: derived from name) */ + slug?: string; + /** Font category (default: 'sans') */ + category?: 'sans' | 'serif' | 'slab' | 'display' | 'handwritten' | 'script' | 'mono'; + /** Script (default: 'latin') */ + script?: string; + /** Whether this is a variable font (default: false) */ + isVariable?: boolean; + /** Font version (default: '1.0') */ + version?: string; + /** Popularity/views count (default: 1000) */ + views?: number; + /** Usage tags */ + tags?: string[]; + /** Font weights available */ + weights?: number[]; + /** Publisher name */ + publisher?: string; + /** Designer name */ + designer?: string; +} + +/** + * Create a mock Fontshare style + */ +function mockFontshareStyle( + weight: number, + isItalic: boolean, + isVariable: boolean, + slug: string, +): FontshareFont['styles'][number] { + const weightLabel = weight === 400 ? 'Regular' : weight === 700 ? 'Bold' : weight.toString(); + const suffix = isItalic ? 'italic' : ''; + const variablePrefix = isVariable ? 'variable-' : ''; + + return { + id: `style-${weight}${isItalic ? '-italic' : ''}`, + default: weight === 400 && !isItalic, + file: `//cdn.fontshare.com/wf/${slug}-${variablePrefix}${weight}${suffix}.woff2`, + is_italic: isItalic, + is_variable: isVariable, + properties: {}, + weight: { + label: isVariable ? 'Variable' + (isItalic ? ' Italic' : '') : weightLabel, + name: isVariable ? 'Variable' + (isItalic ? 'Italic' : '') : weightLabel, + native_name: null, + number: isVariable ? 0 : weight, + weight: isVariable ? 0 : weight, + }, + }; +} + +/** + * Default mock Fontshare font + */ +export function mockFontshareFont(options: MockFontshareFontOptions = {}): FontshareFont { + const { + name = 'Mock Font', + slug = name.toLowerCase().replace(/\s+/g, '-'), + category = 'sans', + script = 'latin', + isVariable = false, + version = '1.0', + views = 1000, + tags = [], + weights = [400, 700], + publisher = 'Mock Foundry', + designer = 'Mock Designer', + } = options; + + // Generate styles based on weights and variable setting + const styles: FontshareFont['styles'] = isVariable + ? [ + mockFontshareStyle(0, false, true, slug), + mockFontshareStyle(0, true, true, slug), + ] + : weights.flatMap(weight => [ + mockFontshareStyle(weight, false, false, slug), + mockFontshareStyle(weight, true, false, slug), + ]); + + return { + id: `mock-${slug}`, + name, + native_name: null, + slug, + category, + script, + publisher: { + bio: `Mock publisher bio for ${publisher}`, + email: null, + id: `pub-${slug}`, + links: [], + name: publisher, + }, + designers: [ + { + bio: `Mock designer bio for ${designer}`, + links: [], + name: designer, + }, + ], + related_families: null, + display_publisher_as_designer: false, + trials_enabled: true, + show_latin_metrics: false, + license_type: 'ofl', + languages: 'English, Spanish, French, German', + inserted_at: '2021-03-12T20:49:05Z', + story: `

A mock font story for ${name}.

`, + version, + views, + views_recent: Math.floor(views * 0.1), + is_hot: views > 5000, + is_new: views < 500, + is_shortlisted: null, + is_top: views > 10000, + axes: isVariable + ? [ + { + name: 'Weight', + property: 'wght', + range_default: 400, + range_left: 300, + range_right: 700, + }, + ] + : [], + font_tags: tags.map(name => ({ name })), + features: [], + styles, + }; +} + +/** + * Preset Fontshare font mocks + */ +export const FONTHARE_FONTS: Record = { + satoshi: mockFontshareFont({ + name: 'Satoshi', + slug: 'satoshi', + category: 'sans', + isVariable: true, + views: 15000, + tags: ['Branding', 'Logos', 'Editorial'], + publisher: 'Indian Type Foundry', + designer: 'Denis Shelabovets', + }), + generalSans: mockFontshareFont({ + name: 'General Sans', + slug: 'general-sans', + category: 'sans', + isVariable: true, + views: 12000, + tags: ['UI', 'Branding', 'Display'], + publisher: 'Indestructible Type', + designer: 'Eugene Tantsur', + }), + clashDisplay: mockFontshareFont({ + name: 'Clash Display', + slug: 'clash-display', + category: 'display', + isVariable: false, + views: 8000, + tags: ['Headlines', 'Posters', 'Branding'], + weights: [400, 500, 600, 700], + publisher: 'Letterogika', + designer: 'MatΔ›j Trnka', + }), + fonta: mockFontshareFont({ + name: 'Fonta', + slug: 'fonta', + category: 'serif', + isVariable: false, + views: 5000, + tags: ['Editorial', 'Books', 'Magazines'], + weights: [300, 400, 500, 600, 700], + publisher: 'Fonta', + designer: 'Alexei Vanyashin', + }), + aileron: mockFontshareFont({ + name: 'Aileron', + slug: 'aileron', + category: 'sans', + isVariable: false, + views: 3000, + tags: ['Display', 'Headlines'], + weights: [100, 200, 300, 400, 500, 600, 700, 800, 900], + publisher: 'Sorkin Type', + designer: 'Sorkin Type', + }), + beVietnamPro: mockFontshareFont({ + name: 'Be Vietnam Pro', + slug: 'be-vietnam-pro', + category: 'sans', + isVariable: true, + views: 20000, + tags: ['UI', 'App', 'Web'], + publisher: 'ildefox', + designer: 'Manh Nguyen', + }), +}; + +// ============================================================================ +// UNIFIED FONT MOCKS +// ============================================================================ + +/** + * Options for creating a mock UnifiedFont + */ +export interface MockUnifiedFontOptions { + /** Unique identifier (default: derived from name) */ + id?: string; + /** Font display name (default: 'Mock Font') */ + name?: string; + /** Font provider (default: 'google') */ + provider?: FontProvider; + /** Font category (default: 'sans-serif') */ + category?: FontCategory; + /** Font subsets (default: ['latin']) */ + subsets?: FontSubset[]; + /** Font variants (default: ['regular', '700', 'italic', '700italic']) */ + variants?: FontVariant[]; + /** Style URLs (if not provided, mock URLs are generated) */ + styles?: FontStyleUrls; + /** Metadata overrides */ + metadata?: Partial; + /** Features overrides */ + features?: Partial; +} + +/** + * Default mock UnifiedFont + */ +export function mockUnifiedFont(options: MockUnifiedFontOptions = {}): UnifiedFont { + const { + id, + name = 'Mock Font', + provider = 'google', + category = 'sans-serif', + subsets = ['latin'], + variants = ['regular', '700', 'italic', '700italic'], + styles, + metadata, + features, + } = options; + + const fontId = id ?? name.toLowerCase().replace(/\s+/g, ''); + const baseUrl = provider === 'google' + ? `https://fonts.gstatic.com/s/${fontId}/v30` + : `//cdn.fontshare.com/wf/${fontId}`; + + return { + id: fontId, + name, + provider, + category, + subsets, + variants: variants as FontVariant[], + styles: styles ?? { + regular: `${baseUrl}/regular.woff2`, + bold: `${baseUrl}/bold.woff2`, + italic: `${baseUrl}/italic.woff2`, + boldItalic: `${baseUrl}/bolditalic.woff2`, + }, + metadata: { + cachedAt: Date.now(), + version: '1.0', + lastModified: new Date().toISOString().split('T')[0], + popularity: 1, + ...metadata, + }, + features: { + isVariable: false, + ...features, + }, + }; +} + +/** + * Preset UnifiedFont mocks + */ +export const UNIFIED_FONTS: Record = { + roboto: mockUnifiedFont({ + id: 'roboto', + name: 'Roboto', + provider: 'google', + category: 'sans-serif', + subsets: ['latin', 'latin-ext'], + variants: ['100', '300', '400', '500', '700', '900'], + metadata: { popularity: 1 }, + }), + openSans: mockUnifiedFont({ + id: 'open-sans', + name: 'Open Sans', + provider: 'google', + category: 'sans-serif', + subsets: ['latin', 'latin-ext'], + variants: ['300', '400', '500', '600', '700', '800'], + metadata: { popularity: 2 }, + }), + lato: mockUnifiedFont({ + id: 'lato', + name: 'Lato', + provider: 'google', + category: 'sans-serif', + subsets: ['latin', 'latin-ext'], + variants: ['100', '300', '400', '700', '900'], + metadata: { popularity: 3 }, + }), + playfairDisplay: mockUnifiedFont({ + id: 'playfair-display', + name: 'Playfair Display', + provider: 'google', + category: 'serif', + subsets: ['latin'], + variants: ['400', '700', '900'], + metadata: { popularity: 10 }, + }), + montserrat: mockUnifiedFont({ + id: 'montserrat', + name: 'Montserrat', + provider: 'google', + category: 'sans-serif', + subsets: ['latin', 'latin-ext'], + variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], + metadata: { popularity: 4 }, + }), + satoshi: mockUnifiedFont({ + id: 'satoshi', + name: 'Satoshi', + provider: 'fontshare', + category: 'sans-serif', + subsets: ['latin'], + variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[], + features: { isVariable: true, axes: [{ name: 'wght', property: 'wght', default: 400, min: 300, max: 700 }] }, + metadata: { popularity: 15000 }, + }), + generalSans: mockUnifiedFont({ + id: 'general-sans', + name: 'General Sans', + provider: 'fontshare', + category: 'sans-serif', + subsets: ['latin'], + variants: ['regular', 'bold', 'italic', 'bolditalic'] as FontVariant[], + features: { isVariable: true }, + metadata: { popularity: 12000 }, + }), + clashDisplay: mockUnifiedFont({ + id: 'clash-display', + name: 'Clash Display', + provider: 'fontshare', + category: 'display', + subsets: ['latin'], + variants: ['regular', '500', '600', 'bold'] as FontVariant[], + features: { tags: ['Headlines', 'Posters', 'Branding'] }, + metadata: { popularity: 8000 }, + }), + oswald: mockUnifiedFont({ + id: 'oswald', + name: 'Oswald', + provider: 'google', + category: 'sans-serif', + subsets: ['latin'], + variants: ['200', '300', '400', '500', '600', '700'], + metadata: { popularity: 6 }, + }), + raleway: mockUnifiedFont({ + id: 'raleway', + name: 'Raleway', + provider: 'google', + category: 'sans-serif', + subsets: ['latin'], + variants: ['100', '200', '300', '400', '500', '600', '700', '800', '900'], + metadata: { popularity: 7 }, + }), +}; + +/** + * Get an array of all preset UnifiedFonts + */ +export function getAllMockFonts(): UnifiedFont[] { + return Object.values(UNIFIED_FONTS); +} + +/** + * Get fonts by provider + */ +export function getFontsByProvider(provider: FontProvider): UnifiedFont[] { + return getAllMockFonts().filter(font => font.provider === provider); +} + +/** + * Get fonts by category + */ +export function getFontsByCategory(category: FontCategory): UnifiedFont[] { + return getAllMockFonts().filter(font => font.category === category); +} + +/** + * Generate an array of mock fonts with sequential naming + */ +export function generateMockFonts(count: number, options?: Omit): UnifiedFont[] { + return Array.from({ length: count }, (_, i) => + mockUnifiedFont({ + ...options, + id: `mock-font-${i + 1}`, + name: `Mock Font ${i + 1}`, + })); +} + +/** + * Generate an array of mock fonts with different categories + */ +export function generateMixedCategoryFonts(countPerCategory: number = 2): UnifiedFont[] { + const categories: FontCategory[] = ['sans-serif', 'serif', 'display', 'handwriting', 'monospace']; + const fonts: UnifiedFont[] = []; + + categories.forEach(category => { + for (let i = 0; i < countPerCategory; i++) { + fonts.push( + mockUnifiedFont({ + id: `${category}-${i + 1}`, + name: `${category.replace('-', ' ')} ${i + 1}`, + category, + }), + ); + } + }); + + return fonts; +} diff --git a/src/entities/Font/lib/mocks/index.ts b/src/entities/Font/lib/mocks/index.ts new file mode 100644 index 0000000..e2042e3 --- /dev/null +++ b/src/entities/Font/lib/mocks/index.ts @@ -0,0 +1,84 @@ +/** + * ============================================================================ + * MOCK DATA HELPERS - MAIN EXPORT + * ============================================================================ + * + * Comprehensive mock data for Storybook stories, tests, and development. + * + * ## Quick Start + * + * ```ts + * import { + * mockUnifiedFont, + * UNIFIED_FONTS, + * MOCK_FILTERS, + * createMockFontStoreState, + * } from '$entities/Font/lib/mocks'; + * + * // Use in stories + * const font = mockUnifiedFont({ name: 'My Font', category: 'serif' }); + * const presets = UNIFIED_FONTS; + * const filter = MOCK_FILTERS.categories; + * ``` + * + * @module + */ + +// Font mocks +export { + FONTHARE_FONTS, + generateMixedCategoryFonts, + generateMockFonts, + getAllMockFonts, + getFontsByCategory, + getFontsByProvider, + GOOGLE_FONTS, + mockFontshareFont, + type MockFontshareFontOptions, + mockGoogleFont, + type MockGoogleFontOptions, + mockUnifiedFont, + type MockUnifiedFontOptions, + UNIFIED_FONTS, +} from './fonts.mock'; + +// Filter mocks +export { + createCategoriesFilter, + createGenericFilter, + createMockFilter, + createProvidersFilter, + createSubsetsFilter, + FONT_PROVIDERS, + FONT_SUBSETS, + FONTHARE_CATEGORIES, + generateSequentialFilter, + GENERIC_FILTERS, + GOOGLE_CATEGORIES, + MOCK_FILTERS, + MOCK_FILTERS_ALL_SELECTED, + MOCK_FILTERS_EMPTY, + MOCK_FILTERS_SELECTED, + type MockFilterOptions, + type MockFilters, + UNIFIED_CATEGORIES, +} from './filters.mock'; + +// Store mocks +export { + createErrorState, + createLoadingState, + createMockComparisonStore, + createMockFontApiResponse, + createMockFontStoreState, + createMockQueryState, + createMockReactiveState, + createMockStore, + createSuccessState, + generatePaginatedFonts, + MOCK_FONT_STORE_STATES, + MOCK_STORES, + type MockFontStoreState, + type MockQueryObserverResult, + type MockQueryState, +} from './stores.mock'; diff --git a/src/entities/Font/lib/mocks/stores.mock.ts b/src/entities/Font/lib/mocks/stores.mock.ts new file mode 100644 index 0000000..f6610c7 --- /dev/null +++ b/src/entities/Font/lib/mocks/stores.mock.ts @@ -0,0 +1,590 @@ +/** + * ============================================================================ + * MOCK FONT STORE HELPERS + * ============================================================================ + * + * Factory functions and preset mock data for TanStack Query stores and state management. + * Used in Storybook stories for components that use reactive stores. + * + * ## Usage + * + * ```ts + * import { + * createMockQueryState, + * MOCK_STORES, + * } from '$entities/Font/lib/mocks'; + * + * // Create a mock query state + * const loadingState = createMockQueryState({ status: 'pending' }); + * const errorState = createMockQueryState({ status: 'error', error: 'Failed to load' }); + * const successState = createMockQueryState({ status: 'success', data: mockFonts }); + * + * // Use preset stores + * const mockFontStore = MOCK_STORES.unifiedFontStore(); + * ``` + */ + +import type { UnifiedFont } from '$entities/Font/model/types'; +import type { + QueryKey, + QueryObserverResult, + QueryStatus, +} from '@tanstack/svelte-query'; +import { + UNIFIED_FONTS, + generateMockFonts, +} from './fonts.mock'; + +// ============================================================================ +// TANSTACK QUERY MOCK TYPES +// ============================================================================ + +/** + * Mock TanStack Query state + */ +export interface MockQueryState { + status: QueryStatus; + data?: TData; + error?: TError; + isLoading?: boolean; + isFetching?: boolean; + isSuccess?: boolean; + isError?: boolean; + isPending?: boolean; + dataUpdatedAt?: number; + errorUpdatedAt?: number; + failureCount?: number; + failureReason?: TError; + errorUpdateCount?: number; + isRefetching?: boolean; + isRefetchError?: boolean; + isPaused?: boolean; +} + +/** + * Mock TanStack Query observer result + */ +export interface MockQueryObserverResult { + status?: QueryStatus; + data?: TData; + error?: TError; + isLoading?: boolean; + isFetching?: boolean; + isSuccess?: boolean; + isError?: boolean; + isPending?: boolean; + dataUpdatedAt?: number; + errorUpdatedAt?: number; + failureCount?: number; + failureReason?: TError; + errorUpdateCount?: number; + isRefetching?: boolean; + isRefetchError?: boolean; + isPaused?: boolean; +} + +// ============================================================================ +// TANSTACK QUERY MOCK FACTORIES +// ============================================================================ + +/** + * Create a mock query state for TanStack Query + */ +export function createMockQueryState( + options: MockQueryState, +): MockQueryObserverResult { + const { + status, + data, + error, + } = options; + + return { + status: status ?? 'success', + data, + error, + isLoading: status === 'pending' ? true : false, + isFetching: status === 'pending' ? true : false, + isSuccess: status === 'success', + isError: status === 'error', + isPending: status === 'pending', + dataUpdatedAt: status === 'success' ? Date.now() : undefined, + errorUpdatedAt: status === 'error' ? Date.now() : undefined, + failureCount: status === 'error' ? 1 : 0, + failureReason: status === 'error' ? error : undefined, + errorUpdateCount: status === 'error' ? 1 : 0, + isRefetching: false, + isRefetchError: false, + isPaused: false, + }; +} + +/** + * Create a loading query state + */ +export function createLoadingState(): MockQueryObserverResult { + return createMockQueryState({ status: 'pending', data: undefined, error: undefined }); +} + +/** + * Create an error query state + */ +export function createErrorState( + error: TError, +): MockQueryObserverResult { + return createMockQueryState({ status: 'error', data: undefined, error }); +} + +/** + * Create a success query state + */ +export function createSuccessState(data: TData): MockQueryObserverResult { + return createMockQueryState({ status: 'success', data, error: undefined }); +} + +// ============================================================================ +// FONT STORE MOCKS +// ============================================================================ + +/** + * Mock UnifiedFontStore state + */ +export interface MockFontStoreState { + /** All cached fonts */ + fonts: Record; + /** Current page */ + page: number; + /** Total pages available */ + totalPages: number; + /** Items per page */ + limit: number; + /** Total font count */ + total: number; + /** Loading state */ + isLoading: boolean; + /** Error state */ + error: Error | null; + /** Search query */ + searchQuery: string; + /** Selected provider */ + provider: 'google' | 'fontshare' | 'all'; + /** Selected category */ + category: string | null; + /** Selected subset */ + subset: string | null; +} + +/** + * Create a mock font store state + */ +export function createMockFontStoreState( + options: Partial = {}, +): MockFontStoreState { + const { + page = 1, + limit = 24, + isLoading = false, + error = null, + searchQuery = '', + provider = 'all', + category = null, + subset = null, + } = options; + + // Generate mock fonts if not provided + const mockFonts = options.fonts ?? Object.fromEntries( + Object.values(UNIFIED_FONTS).map(font => [font.id, font]), + ); + + const fontArray = Object.values(mockFonts); + const total = options.total ?? fontArray.length; + const totalPages = options.totalPages ?? Math.ceil(total / limit); + + return { + fonts: mockFonts, + page, + totalPages, + limit, + total, + isLoading, + error, + searchQuery, + provider, + category, + subset, + }; +} + +/** + * Preset font store states + */ +export const MOCK_FONT_STORE_STATES = { + /** Initial loading state */ + loading: createMockFontStoreState({ + isLoading: true, + fonts: {}, + total: 0, + page: 1, + }), + + /** Empty state (no fonts found) */ + empty: createMockFontStoreState({ + fonts: {}, + total: 0, + page: 1, + isLoading: false, + }), + + /** First page with fonts */ + firstPage: createMockFontStoreState({ + fonts: Object.fromEntries( + Object.values(UNIFIED_FONTS).slice(0, 10).map(font => [font.id, font]), + ), + total: 50, + page: 1, + limit: 10, + totalPages: 5, + isLoading: false, + }), + + /** Second page with fonts */ + secondPage: createMockFontStoreState({ + fonts: Object.fromEntries( + Object.values(UNIFIED_FONTS).slice(10, 20).map(font => [font.id, font]), + ), + total: 50, + page: 2, + limit: 10, + totalPages: 5, + isLoading: false, + }), + + /** Last page with fonts */ + lastPage: createMockFontStoreState({ + fonts: Object.fromEntries( + Object.values(UNIFIED_FONTS).slice(0, 5).map(font => [font.id, font]), + ), + total: 25, + page: 3, + limit: 10, + totalPages: 3, + isLoading: false, + }), + + /** Error state */ + error: createMockFontStoreState({ + fonts: {}, + error: new Error('Failed to load fonts'), + total: 0, + page: 1, + isLoading: false, + }), + + /** With search query */ + withSearch: createMockFontStoreState({ + fonts: Object.fromEntries( + Object.values(UNIFIED_FONTS).slice(0, 3).map(font => [font.id, font]), + ), + total: 3, + page: 1, + isLoading: false, + searchQuery: 'Roboto', + }), + + /** Filtered by category */ + filteredByCategory: createMockFontStoreState({ + fonts: Object.fromEntries( + Object.values(UNIFIED_FONTS) + .filter(f => f.category === 'serif') + .slice(0, 5) + .map(font => [font.id, font]), + ), + total: 5, + page: 1, + isLoading: false, + category: 'serif', + }), + + /** Filtered by provider */ + filteredByProvider: createMockFontStoreState({ + fonts: Object.fromEntries( + Object.values(UNIFIED_FONTS) + .filter(f => f.provider === 'google') + .slice(0, 5) + .map(font => [font.id, font]), + ), + total: 5, + page: 1, + isLoading: false, + provider: 'google', + }), + + /** Large dataset */ + largeDataset: createMockFontStoreState({ + fonts: Object.fromEntries( + generateMockFonts(50).map(font => [font.id, font]), + ), + total: 500, + page: 1, + limit: 50, + totalPages: 10, + isLoading: false, + }), +}; + +// ============================================================================ +// MOCK STORE OBJECT +// ============================================================================ + +/** + * Create a mock store object that mimics TanStack Query behavior + * Useful for components that subscribe to store properties + */ +export function createMockStore(config: { + data?: T; + isLoading?: boolean; + isError?: boolean; + error?: Error; + isFetching?: boolean; +}) { + const { + data, + isLoading = false, + isError = false, + error, + isFetching = false, + } = config; + + return { + get data() { + return data; + }, + get isLoading() { + return isLoading; + }, + get isError() { + return isError; + }, + get error() { + return error; + }, + get isFetching() { + return isFetching; + }, + get isSuccess() { + return !isLoading && !isError && data !== undefined; + }, + get status() { + if (isLoading) return 'pending'; + if (isError) return 'error'; + return 'success'; + }, + }; +} + +/** + * Preset mock stores + */ +export const MOCK_STORES = { + /** Font store in loading state */ + loadingFontStore: createMockStore({ + isLoading: true, + data: undefined, + }), + + /** Font store with fonts loaded */ + successFontStore: createMockStore({ + data: Object.values(UNIFIED_FONTS), + isLoading: false, + isError: false, + }), + + /** Font store with error */ + errorFontStore: createMockStore({ + data: undefined, + isLoading: false, + isError: true, + error: new Error('Failed to load fonts'), + }), + + /** Font store with empty results */ + emptyFontStore: createMockStore({ + data: [], + isLoading: false, + isError: false, + }), + + /** + * Create a mock UnifiedFontStore-like object + * Note: This is a simplified mock for Storybook use + */ + unifiedFontStore: (state: Partial = {}) => { + const mockState = createMockFontStoreState(state); + return { + // State properties + get fonts() { + return mockState.fonts; + }, + get page() { + return mockState.page; + }, + get totalPages() { + return mockState.totalPages; + }, + get limit() { + return mockState.limit; + }, + get total() { + return mockState.total; + }, + get isLoading() { + return mockState.isLoading; + }, + get error() { + return mockState.error; + }, + get searchQuery() { + return mockState.searchQuery; + }, + get provider() { + return mockState.provider; + }, + get category() { + return mockState.category; + }, + get subset() { + return mockState.subset; + }, + // Methods (no-op for Storybook) + nextPage: () => {}, + prevPage: () => {}, + goToPage: (_page: number) => {}, + setLimit: (_limit: number) => {}, + setProvider: (_provider: typeof mockState.provider) => {}, + setCategory: (_category: string | null) => {}, + setSubset: (_subset: string | null) => {}, + setSearch: (_query: string) => {}, + resetFilters: () => {}, + }; + }, +}; + +// ============================================================================ +// REACTIVE STATE MOCKS +// ============================================================================ + +/** + * Create a reactive state object using Svelte 5 runes pattern + * Useful for stories that need reactive state + * + * Note: This uses plain JavaScript objects since Svelte runes + * only work in .svelte files. For Storybook, this provides + * a similar API for testing. + */ +export function createMockReactiveState(initialValue: T) { + let value = initialValue; + + return { + get value() { + return value; + }, + set value(newValue: T) { + value = newValue; + }, + update(fn: (current: T) => T) { + value = fn(value); + }, + }; +} + +/** + * Mock comparison store for ComparisonSlider component + */ +export function createMockComparisonStore(config: { + fontA?: UnifiedFont; + fontB?: UnifiedFont; + text?: string; +} = {}) { + const { fontA, fontB, text = 'The quick brown fox jumps over the lazy dog.' } = config; + + return { + get fontA() { + return fontA ?? UNIFIED_FONTS.roboto; + }, + get fontB() { + return fontB ?? UNIFIED_FONTS.openSans; + }, + get text() { + return text; + }, + // Methods (no-op for Storybook) + setFontA: (_font: UnifiedFont | undefined) => {}, + setFontB: (_font: UnifiedFont | undefined) => {}, + setText: (_text: string) => {}, + swapFonts: () => {}, + }; +} + +// ============================================================================ +// MOCK DATA GENERATORS +// ============================================================================ + +/** + * Generate paginated font data + */ +export function generatePaginatedFonts( + totalCount: number, + page: number, + limit: number, +): { + fonts: UnifiedFont[]; + page: number; + totalPages: number; + total: number; + hasNextPage: boolean; + hasPrevPage: boolean; +} { + const totalPages = Math.ceil(totalCount / limit); + const startIndex = (page - 1) * limit; + const endIndex = Math.min(startIndex + limit, totalCount); + + return { + fonts: generateMockFonts(endIndex - startIndex).map((font, i) => ({ + ...font, + id: `font-${startIndex + i + 1}`, + name: `Font ${startIndex + i + 1}`, + })), + page, + totalPages, + total: totalCount, + hasNextPage: page < totalPages, + hasPrevPage: page > 1, + }; +} + +/** + * Create mock API response for fonts + */ +export function createMockFontApiResponse(config: { + fonts?: UnifiedFont[]; + total?: number; + page?: number; + limit?: number; +} = {}) { + const fonts = config.fonts ?? Object.values(UNIFIED_FONTS); + const total = config.total ?? fonts.length; + const page = config.page ?? 1; + const limit = config.limit ?? fonts.length; + + return { + data: fonts, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + hasNextPage: page < Math.ceil(total / limit), + hasPrevPage: page > 1, + }, + }; +} diff --git a/src/shared/lib/storybook/MockIcon.svelte b/src/shared/lib/storybook/MockIcon.svelte new file mode 100644 index 0000000..82c91ee --- /dev/null +++ b/src/shared/lib/storybook/MockIcon.svelte @@ -0,0 +1,41 @@ + + + +{#if Icon} + {@const __iconClass__ = cn('size-4', className)} + + +{/if} diff --git a/src/shared/lib/storybook/Providers.svelte b/src/shared/lib/storybook/Providers.svelte new file mode 100644 index 0000000..3f26917 --- /dev/null +++ b/src/shared/lib/storybook/Providers.svelte @@ -0,0 +1,64 @@ + + + +
+ + {@render children()} + +
diff --git a/src/shared/lib/storybook/index.ts b/src/shared/lib/storybook/index.ts new file mode 100644 index 0000000..dc0137f --- /dev/null +++ b/src/shared/lib/storybook/index.ts @@ -0,0 +1,24 @@ +/** + * ============================================================================ + * STORYBOOK HELPERS + * ============================================================================ + * + * Helper components and utilities for Storybook stories. + * + * ## Usage + * + * ```svelte + * + * + * + * + * + * ``` + * + * @module + */ + +export { default as MockIcon } from './MockIcon.svelte'; +export { default as Providers } from './Providers.svelte'; diff --git a/src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte b/src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte index f1ac0c6..232fadc 100644 --- a/src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte +++ b/src/shared/ui/ComboControlV2/ComboControlV2.stories.svelte @@ -10,7 +10,8 @@ const { Story } = defineMeta({ parameters: { docs: { description: { - component: 'ComboControl with input field and slider', + component: + 'ComboControl with input field and slider. Simplified version without increase/decrease buttons.', }, story: { inline: false }, // Render stories in iframe for state isolation }, @@ -26,18 +27,85 @@ const { Story } = defineMeta({ control: 'text', description: 'Label for the ComboControl', }, + control: { + control: 'object', + description: 'TypographyControl instance managing the value and bounds', + }, }, }); - - + + - - + + + + + + + + + + + + + + + + + + diff --git a/src/shared/ui/IconButton/IconButton.stories.svelte b/src/shared/ui/IconButton/IconButton.stories.svelte new file mode 100644 index 0000000..b485dde --- /dev/null +++ b/src/shared/ui/IconButton/IconButton.stories.svelte @@ -0,0 +1,101 @@ + + + + +{#snippet chevronRightIcon({ className }: { className: string })} + +{/snippet} + +{#snippet chevronLeftIcon({ className }: { className: string })} + +{/snippet} + +{#snippet plusIcon({ className }: { className: string })} + +{/snippet} + +{#snippet minusIcon({ className }: { className: string })} + +{/snippet} + +{#snippet settingsIcon({ className }: { className: string })} + +{/snippet} + +{#snippet xIcon({ className }: { className: string })} + +{/snippet} + + + console.log('Default clicked')}> + {#snippet icon({ className })} + + {/snippet} + + + + +
+ + {#snippet icon({ className })} + + {/snippet} + +
+
diff --git a/src/shared/ui/Section/Section.stories.svelte b/src/shared/ui/Section/Section.stories.svelte new file mode 100644 index 0000000..9e58126 --- /dev/null +++ b/src/shared/ui/Section/Section.stories.svelte @@ -0,0 +1,475 @@ + + + + +{#snippet searchIcon({ className }: { className?: string })} + +{/snippet} + +{#snippet welcomeTitle({ className }: { className?: string })} +

Welcome

+{/snippet} + +{#snippet welcomeContent({ className }: { className?: string })} +
+

+ This is the default section layout with a title and content area. The section uses a 2-column grid layout + with the title on the left and content on the right. +

+
+{/snippet} + +{#snippet stickyTitle({ className }: { className?: string })} +

Sticky Title

+{/snippet} + +{#snippet stickyContent({ className }: { className?: string })} +
+

+ This section has a sticky title that stays fixed while you scroll through the content. Try scrolling down to + see the effect. +

+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et + dolore magna aliqua. +

+

+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo + consequat. +

+

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. +

+

+ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollim anim id est + laborum. +

+

+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium. +

+
+
+{/snippet} + +{#snippet searchFontsTitle({ className }: { className?: string })} +

Search Fonts

+{/snippet} + +{#snippet searchFontsDescription({ className }: { className?: string })} + Find your perfect typeface +{/snippet} + +{#snippet searchFontsContent({ className }: { className?: string })} +
+

+ Use the search bar to find fonts by name, or use the filters to browse by category, subset, or provider. +

+
+{/snippet} + +{#snippet longContentTitle({ className }: { className?: string })} +

Long Content

+{/snippet} + +{#snippet longContent({ className }: { className?: string })} +
+
+

+ This section demonstrates how the sticky title behaves with longer content. As you scroll through this + content, the title remains visible at the top of the viewport. +

+
+ Content block 1 +
+

+ The sticky position is achieved using CSS position: sticky with a configurable top offset. This is + useful for long sections where you want to maintain context while scrolling. +

+
+ Content block 2 +
+

+ The Intersection Observer API is used to detect when the section title scrolls out of view, triggering + the optional onTitleStatusChange callback. +

+
+ Content block 3 +
+
+
+{/snippet} + +{#snippet minimalTitle({ className }: { className?: string })} +

Minimal Section

+{/snippet} + +{#snippet minimalContent({ className }: { className?: string })} +
+

+ A minimal section without index, icon, or description. Just the essentials. +

+
+{/snippet} + +{#snippet customTitle({ className }: { className?: string })} +

Custom Content

+{/snippet} + +{#snippet customDescription({ className }: { className?: string })} + With interactive elements +{/snippet} + +{#snippet customContent({ className }: { className?: string })} +
+
+
+

Card 1

+

Some content here

+
+
+

Card 2

+

More content here

+
+
+
+{/snippet} + + +
+
+ {#snippet title({ className })} +

Welcome

+ {/snippet} + {#snippet content({ className })} +
+

+ This is the default section layout with a title and content area. The section uses a 2-column + grid layout with the title on the left and content on the right. +

+
+ {/snippet} +
+
+
+ + +
+
+
+ {#snippet title({ className })} +

Sticky Title

+ {/snippet} + {#snippet content({ className })} +
+

+ This section has a sticky title that stays fixed while you scroll through the content. Try + scrolling down to see the effect. +

+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. +

+

+ Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea + commodo consequat. +

+

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat + nulla pariatur. +

+

+ Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt + mollim anim id est laborum. +

+

+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque + laudantium. +

+
+
+ {/snippet} +
+
+
+
+ + +
+
+ {#snippet title({ className })} +

Search Fonts

+ {/snippet} + {#snippet icon({ className })} + + {/snippet} + {#snippet description({ className })} + Find your perfect typeface + {/snippet} + {#snippet content({ className })} +
+

+ Use the search bar to find fonts by name, or use the filters to browse by category, subset, or + provider. +

+
+ {/snippet} +
+
+
+ + +
+
+ {#snippet title({ className })} +

Typography

+ {/snippet} + {#snippet icon({ className })} + + {/snippet} + {#snippet description({ className })} + Adjust text appearance + {/snippet} + {#snippet content({ className })} +
+

+ Control the size, weight, and line height of your text. These settings apply across the + comparison view. +

+
+ {/snippet} +
+ +
+ {#snippet title({ className })} +

Font Search

+ {/snippet} + {#snippet icon({ className })} + + {/snippet} + {#snippet description({ className })} + Browse available typefaces + {/snippet} + {#snippet content({ className })} +
+

+ Search through our collection of fonts from Google Fonts and Fontshare. Use filters to narrow + down your selection. +

+
+ {/snippet} +
+ +
+ {#snippet title({ className })} +

Sample List

+ {/snippet} + {#snippet icon({ className })} + + {/snippet} + {#snippet description({ className })} + Preview font samples + {/snippet} + {#snippet content({ className })} +
+

+ Browse through font samples with your custom text. The list is virtualized for optimal + performance. +

+
+ {/snippet} +
+
+
+ + +
+
+ {#snippet title({ className })} +

Long Content

+ {/snippet} + {#snippet content({ className })} +
+
+

+ This section demonstrates how the sticky title behaves with longer content. As you scroll + through this content, the title remains visible at the top of the viewport. +

+
+ Content block 1 +
+

+ The sticky position is achieved using CSS position: sticky with a configurable top offset. + This is useful for long sections where you want to maintain context while scrolling. +

+
+ Content block 2 +
+

+ The Intersection Observer API is used to detect when the section title scrolls out of view, + triggering the optional onTitleStatusChange callback. +

+
+ Content block 3 +
+
+
+ {/snippet} +
+
+
+ + +
+
+ {#snippet title({ className })} +

Minimal Section

+ {/snippet} + {#snippet content({ className })} +
+

+ A minimal section without index, icon, or description. Just the essentials. +

+
+ {/snippet} +
+
+
+ + +
+
+ {#snippet title({ className })} +

Custom Content

+ {/snippet} + {#snippet description({ className })} + With interactive elements + {/snippet} + {#snippet content({ className })} +
+
+
+

Card 1

+

Some content here

+
+
+

Card 2

+

More content here

+
+
+
+ {/snippet} +
+
+
diff --git a/src/shared/ui/Slider/Slider.stories.svelte b/src/shared/ui/Slider/Slider.stories.svelte index 9198125..3495c3f 100644 --- a/src/shared/ui/Slider/Slider.stories.svelte +++ b/src/shared/ui/Slider/Slider.stories.svelte @@ -31,21 +31,26 @@ const { Story } = defineMeta({ control: 'number', description: 'Step size for value increments', }, + label: { + control: 'text', + description: 'Optional label displayed inline on the track', + }, }, }); - - + + - - + + + + + + diff --git a/src/shared/ui/VirtualList/VirtualList.stories.svelte b/src/shared/ui/VirtualList/VirtualList.stories.svelte index 1e3064e..cb835d6 100644 --- a/src/shared/ui/VirtualList/VirtualList.stories.svelte +++ b/src/shared/ui/VirtualList/VirtualList.stories.svelte @@ -38,27 +38,31 @@ const { Story } = defineMeta({ - - {#snippet children({ item })} -
{item}
- {/snippet} -
+
+ + {#snippet children({ item })} +
{item}
+ {/snippet} +
+
- - - {#snippet children({ item })} -
{item}
- {/snippet} -
+ +
+ + {#snippet children({ item })} +
{item}
+ {/snippet} +
+
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte new file mode 100644 index 0000000..78b671a --- /dev/null +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte @@ -0,0 +1,217 @@ + + + + + + {@const _ = (comparisonStore.fontA = mockArial, comparisonStore.fontB = mockGeorgia)} + +
+
+ +
+
+
+
+ + + {@const _ = (comparisonStore.fontA = mockCourier, comparisonStore.fontB = mockVerdana)} + +
+
+ +
+
+
+
+ + + {@const _ = (comparisonStore.fontA = undefined, comparisonStore.fontB = undefined)} + +
+
+ +
+
+
+
+ + + {@const _ = ( + comparisonStore.fontA = mockArial, + comparisonStore.fontB = mockVerdana, + comparisonStore.text = 'Typography is the art and technique of arranging type.' +)} + +
+
+ +
+
+
+
+ + + {@const _ = (comparisonStore.fontA = mockGeorgia, comparisonStore.fontB = mockCourier, comparisonStore.text = 'Hello')} + +
+
+ +
+
+
+
+ + + {@const _ = ( + comparisonStore.fontA = mockArial, + comparisonStore.fontB = mockGeorgia, + comparisonStore.text = + 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump!' +)} + +
+
+ +
+
+
+
+ + + {@const _ = (comparisonStore.fontA = mockVerdana, comparisonStore.fontB = mockArial)} + +
+
+
+

Click the settings icon to toggle settings mode

+
+ +
+
+
+
+ + + {@const _ = (comparisonStore.fontA = mockGeorgia, comparisonStore.fontB = mockVerdana)} + +
+
+
+

Resize the browser to see responsive behavior

+
+ +
+
+
+
diff --git a/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte b/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte new file mode 100644 index 0000000..c950054 --- /dev/null +++ b/src/widgets/FontSearch/ui/FontSearch/FontSearch.stories.svelte @@ -0,0 +1,102 @@ + + + + + +
+ +
+
+ + +
+ +
+

Filters panel is open and visible

+
+
+
+ + +
+ +
+

Filters panel is closed - click the slider icon to open

+
+
+
+ + +
+ +
+
+ + +
+
+

Font Browser

+

Search and filter through our collection of fonts

+
+ +
+ +
+ +
+

Font results will appear here...

+
+
+
+ + +
+
+

+ Demo Note: Click the slider icon to toggle filters. Use the + filter categories to select options. Use the filter controls to reset or apply your selections. +

+
+ +
+
+ + +
+
+

Resize browser to see responsive layout

+
+
+ +
+
+
diff --git a/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte b/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte new file mode 100644 index 0000000..14a354a --- /dev/null +++ b/src/widgets/SampleList/ui/SampleList/SampleList.stories.svelte @@ -0,0 +1,89 @@ + + + +
+
+
+

Font Samples

+

Scroll to see more fonts and load additional pages

+
+ +
+
+
+ + +
+ +
+
+ + +
+
+
+

Typography Controls

+

Scroll down to see the typography menu appear

+
+ +
+
+
+ + +
+
+
+

Custom Sample Text

+

Edit the text in any card to change all samples

+
+ +
+
+
+ + +
+
+
+

Paginated List

+

Fonts load automatically as you scroll

+
+ +
+
+
+ + +
+
+
+

Responsive Sample List

+

Resize browser to see responsive behavior

+
+ +
+
+
-- 2.49.1 From eff397937266d02fc9be43e484274adf19c02567 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 22 Feb 2026 10:45:14 +0300 Subject: [PATCH 12/12] chore: delete unused code --- .../ComparisonSlider.stories.svelte | 81 ------------------- .../ui/ComparisonSlider/FontShifter.svelte | 52 ------------ 2 files changed, 133 deletions(-) delete mode 100644 src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte index 78b671a..e46e755 100644 --- a/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte +++ b/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.stories.svelte @@ -124,17 +124,6 @@ const mockCourier: UnifiedFont = {
- - {@const _ = (comparisonStore.fontA = mockCourier, comparisonStore.fontB = mockVerdana)} - -
-
- -
-
-
-
- {@const _ = (comparisonStore.fontA = undefined, comparisonStore.fontB = undefined)} @@ -145,73 +134,3 @@ const mockCourier: UnifiedFont = {
- - - {@const _ = ( - comparisonStore.fontA = mockArial, - comparisonStore.fontB = mockVerdana, - comparisonStore.text = 'Typography is the art and technique of arranging type.' -)} - -
-
- -
-
-
-
- - - {@const _ = (comparisonStore.fontA = mockGeorgia, comparisonStore.fontB = mockCourier, comparisonStore.text = 'Hello')} - -
-
- -
-
-
-
- - - {@const _ = ( - comparisonStore.fontA = mockArial, - comparisonStore.fontB = mockGeorgia, - comparisonStore.text = - 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump!' -)} - -
-
- -
-
-
-
- - - {@const _ = (comparisonStore.fontA = mockVerdana, comparisonStore.fontB = mockArial)} - -
-
-
-

Click the settings icon to toggle settings mode

-
- -
-
-
-
- - - {@const _ = (comparisonStore.fontA = mockGeorgia, comparisonStore.fontB = mockVerdana)} - -
-
-
-

Resize the browser to see responsive behavior

-
- -
-
-
-
diff --git a/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte b/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte deleted file mode 100644 index 03135fd..0000000 --- a/src/widgets/ComparisonSlider/ui/ComparisonSlider/FontShifter.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - -
- {#each chars as char, i} - - {char} - - {/each} -
- - -- 2.49.1