import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ref, nextTick } from 'vue'; import { useCountUp } from '../../resources/js/composables/useCountUp'; function mockMatchMedia(reduced: boolean): void { Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: vi.fn().mockImplementation((query: string) => ({ matches: query.includes('reduce') ? reduced : false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), }); } describe('useCountUp', () => { let originalRaf: typeof globalThis.requestAnimationFrame; let originalCaf: typeof globalThis.cancelAnimationFrame; beforeEach(() => { vi.useFakeTimers(); mockMatchMedia(false); // RAF polyfill: jsdom's RAF does not advance synchronously with fake timers. // Route RAF through setTimeout so vi.advanceTimersByTimeAsync() advances it. originalRaf = globalThis.requestAnimationFrame; originalCaf = globalThis.cancelAnimationFrame; globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => setTimeout( () => cb(performance.now()), 16, ) as unknown as number) as typeof globalThis.requestAnimationFrame; globalThis.cancelAnimationFrame = ((id: number) => clearTimeout(id as unknown as ReturnType)) as typeof globalThis.cancelAnimationFrame; }); afterEach(() => { globalThis.requestAnimationFrame = originalRaf; globalThis.cancelAnimationFrame = originalCaf; vi.useRealTimers(); }); it('exposes initial display = 0 before target propagates', async () => { const target = ref(100); const { display } = useCountUp(target, { duration: 600 }); expect(display.value).toBe(0); }); it('animates from 0 to target over duration', async () => { const target = ref(100); const { display, start } = useCountUp(target, { duration: 600 }); start(); // step through animation await vi.advanceTimersByTimeAsync(300); expect(display.value).toBeGreaterThan(0); expect(display.value).toBeLessThan(100); await vi.advanceTimersByTimeAsync(400); expect(display.value).toBe(100); }); it('respects prefers-reduced-motion (instant value, no animation)', async () => { mockMatchMedia(true); const target = ref(250); const { display, start } = useCountUp(target, { duration: 600 }); start(); await nextTick(); expect(display.value).toBe(250); }); it('re-animates when target value changes', async () => { const target = ref(50); const { display, start } = useCountUp(target, { duration: 400 }); start(); await vi.advanceTimersByTimeAsync(500); expect(display.value).toBe(50); target.value = 200; await nextTick(); await vi.advanceTimersByTimeAsync(500); expect(display.value).toBe(200); }); });