From 2a8ce5c5e92a5b86c41bc7b5cc894b2df77fa506 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Tue, 14 Apr 2026 18:15:00 +0100 Subject: [PATCH] test(webapp): add unit coverage for state and PWA flows --- WebApp/src/demo.spec.ts | 10 +- WebApp/src/lib/app/motion-detector.spec.js | 33 +++++ WebApp/src/lib/app/store.spec.ts | 73 +++++++++++ WebApp/src/lib/pwa.spec.ts | 144 +++++++++++++++++++++ WebApp/src/routes/page.svelte.spec.ts | 13 -- 5 files changed, 256 insertions(+), 17 deletions(-) create mode 100644 WebApp/src/lib/app/store.spec.ts create mode 100644 WebApp/src/lib/pwa.spec.ts delete mode 100644 WebApp/src/routes/page.svelte.spec.ts diff --git a/WebApp/src/demo.spec.ts b/WebApp/src/demo.spec.ts index e07cbbd..c4d797c 100644 --- a/WebApp/src/demo.spec.ts +++ b/WebApp/src/demo.spec.ts @@ -1,7 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); +import { cn } from '$lib/utils'; + +describe('utility helpers', () => { + it('merges tailwind classes predictably', () => { + expect(cn('px-2 py-1', 'px-4', false && 'hidden', ['text-white'])).toBe('py-1 px-4 text-white'); }); }); diff --git a/WebApp/src/lib/app/motion-detector.spec.js b/WebApp/src/lib/app/motion-detector.spec.js index 3083acf..27885b0 100644 --- a/WebApp/src/lib/app/motion-detector.spec.js +++ b/WebApp/src/lib/app/motion-detector.spec.js @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + clampScore, applyMotionStateMachine, computeChangedPixelRatio, computeLuma, @@ -8,6 +9,13 @@ import { } from './motion-detector'; describe('motion-detector helpers', () => { + it('clamps invalid and out-of-range scores', () => { + expect(clampScore(Number.NaN)).toBe(0); + expect(clampScore(-1)).toBe(0); + expect(clampScore(0.35)).toBe(0.35); + expect(clampScore(4)).toBe(1); + }); + it('computes grayscale luminance for RGBA image data', () => { const luminance = computeLuma({ data: new Uint8ClampedArray([ @@ -28,6 +36,10 @@ describe('motion-detector helpers', () => { expect(computeChangedPixelRatio(previousFrame, currentFrame, 18)).toBeCloseTo(0.5, 5); expect(computeChangedPixelRatio(previousFrame, currentFrame, 40)).toBe(0); }); + + it('returns zero when frame sizes do not match', () => { + expect(computeChangedPixelRatio(new Float32Array([0, 10]), new Float32Array([0]), 5)).toBe(0); + }); }); describe('motion-detector state machine', () => { @@ -58,6 +70,15 @@ describe('motion-detector state machine', () => { expect(result.snapshot.activeSince).toBe(1600); }); + it('keeps the detector in warming up state before a first stable frame', () => { + const snapshot = createMotionMachineSnapshot(); + const result = applyMotionStateMachine(snapshot, { state: 'warming_up', score: 0.9 }, config, 1000); + + expect(result.output.state).toBe('warming_up'); + expect(result.output.activeMotion).toBe(false); + expect(result.snapshot.consecutiveHighFrames).toBe(0); + }); + it('holds motion until minimum duration and quiet cooldown complete', () => { let snapshot = createMotionMachineSnapshot({ activeMotion: true, @@ -76,4 +97,16 @@ describe('motion-detector state machine', () => { expect(result.snapshot.activeSince).toBeNull(); expect(result.snapshot.quietSince).toBeNull(); }); + + it('applies snapshot overrides when creating a machine snapshot', () => { + const snapshot = createMotionMachineSnapshot({ + activeMotion: true, + activeSince: 1234, + consecutiveHighFrames: 2 + }); + + expect(snapshot.activeMotion).toBe(true); + expect(snapshot.activeSince).toBe(1234); + expect(snapshot.consecutiveHighFrames).toBe(2); + }); }); diff --git a/WebApp/src/lib/app/store.spec.ts b/WebApp/src/lib/app/store.spec.ts new file mode 100644 index 0000000..bc35831 --- /dev/null +++ b/WebApp/src/lib/app/store.spec.ts @@ -0,0 +1,73 @@ +import { get } from 'svelte/store'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + appState, + createInitialState, + getAppState, + patchAppState, + resetAppState, + setAppState, + unreadNotificationsCount +} from './store'; + +afterEach(() => { + resetAppState(); +}); + +describe('app store', () => { + it('creates the expected initial state shape', () => { + const state = createInitialState(); + + expect(state.page).toBe('auth'); + expect(state.motionDetection.profile).toBe('balanced'); + expect(state.clientPlaceholderText).toBe('Select a camera to view'); + expect(state.streamDiagnostics).toEqual({}); + }); + + it('setAppState merges top-level properties into the current store', () => { + setAppState({ page: 'client', socketConnected: true }); + + const state = getAppState(); + expect(state.page).toBe('client'); + expect(state.socketConnected).toBe(true); + expect(state.deviceToken).toBeNull(); + }); + + it('patchAppState derives the next state from the previous state', () => { + patchAppState((state) => ({ + authForm: { + ...state.authForm, + email: 'test@example.com' + }, + toasts: [{ id: 'toast-1', title: 'Saved' }] + })); + + const state = getAppState(); + expect(state.authForm.email).toBe('test@example.com'); + expect(state.toasts).toHaveLength(1); + }); + + it('resetAppState restores defaults while keeping explicit overrides', () => { + setAppState({ page: 'client', socketConnected: true }); + resetAppState({ page: 'settings' }); + + const state = getAppState(); + expect(state.page).toBe('settings'); + expect(state.socketConnected).toBe(false); + expect(state.linkedCameras).toEqual([]); + }); + + it('counts only unread motion notifications', () => { + appState.set({ + ...createInitialState(), + motionNotifications: [ + { id: 'n1', isRead: false }, + { id: 'n2', isRead: true }, + { id: 'n3', isRead: false } + ] + }); + + expect(get(unreadNotificationsCount)).toBe(2); + }); +}); diff --git a/WebApp/src/lib/pwa.spec.ts b/WebApp/src/lib/pwa.spec.ts new file mode 100644 index 0000000..423ebf3 --- /dev/null +++ b/WebApp/src/lib/pwa.spec.ts @@ -0,0 +1,144 @@ +import { get } from 'svelte/store'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + clearInstallPrompt, + dismissIosInstallHint, + dismissOfflineReady, + promptInstall, + pwaState, + setInstallPrompt, + setOfflineReady, + setOnlineStatus, + setStandaloneMode, + setUpdateAvailable, + syncInstallHelpState +} from './pwa'; + +const originalNavigator = globalThis.navigator; +const originalWindow = globalThis.window; +const originalLocalStorage = globalThis.localStorage; + +const createStorage = () => { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + clear: () => values.clear() + }; +}; + +const installPromptFactory = (outcome: 'accepted' | 'dismissed' = 'accepted') => ({ + prompt: vi.fn().mockResolvedValue(undefined), + userChoice: Promise.resolve({ outcome }) +}); + +beforeEach(() => { + vi.restoreAllMocks(); + const localStorage = createStorage(); + vi.stubGlobal('localStorage', localStorage); + vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1' }); + vi.stubGlobal('window', { + matchMedia: vi.fn().mockReturnValue({ matches: false }), + navigator: { standalone: false } + }); + + pwaState.set({ + installAvailable: false, + updateAvailable: false, + offlineReady: false, + installing: false, + isOffline: false, + isStandalone: false, + showIosInstallHint: false + }); + clearInstallPrompt(); +}); + +afterEach(() => { + clearInstallPrompt(); + if (originalNavigator) { + Object.defineProperty(globalThis, 'navigator', { value: originalNavigator, configurable: true }); + } else { + // @ts-ignore + delete globalThis.navigator; + } + if (originalWindow) { + Object.defineProperty(globalThis, 'window', { value: originalWindow, configurable: true }); + } else { + // @ts-ignore + delete globalThis.window; + } + if (originalLocalStorage) { + Object.defineProperty(globalThis, 'localStorage', { value: originalLocalStorage, configurable: true }); + } else { + // @ts-ignore + delete globalThis.localStorage; + } +}); + +describe('pwa state', () => { + it('stores install prompt availability', () => { + setInstallPrompt(installPromptFactory()); + + expect(get(pwaState).installAvailable).toBe(true); + }); + + it('clears install prompt state', () => { + setInstallPrompt(installPromptFactory()); + clearInstallPrompt(); + + const state = get(pwaState); + expect(state.installAvailable).toBe(false); + expect(state.installing).toBe(false); + }); + + it('tracks update, offline ready, online, and standalone state flags', () => { + setUpdateAvailable(true); + setOfflineReady(true); + setOnlineStatus(false); + setStandaloneMode(true); + + let state = get(pwaState); + expect(state.updateAvailable).toBe(true); + expect(state.offlineReady).toBe(true); + expect(state.isOffline).toBe(true); + expect(state.isStandalone).toBe(true); + + dismissOfflineReady(); + state = get(pwaState); + expect(state.offlineReady).toBe(false); + }); + + it('shows iOS install help when running in Safari outside standalone mode', () => { + syncInstallHelpState(); + + expect(get(pwaState).showIosInstallHint).toBe(true); + }); + + it('dismisses iOS install help and persists the dismissal', () => { + syncInstallHelpState(); + dismissIosInstallHint(); + + const state = get(pwaState); + expect(state.showIosInstallHint).toBe(false); + expect(globalThis.localStorage.getItem('phonecam-ios-install-hint-dismissed')).toBe('true'); + }); + + it('returns false when install prompt is unavailable', async () => { + await expect(promptInstall()).resolves.toBe(false); + }); + + it('prompts installation and clears the deferred prompt after acceptance', async () => { + const prompt = installPromptFactory('accepted'); + setInstallPrompt(prompt); + + await expect(promptInstall()).resolves.toBe(true); + + const state = get(pwaState); + expect(prompt.prompt).toHaveBeenCalledTimes(1); + expect(state.installAvailable).toBe(false); + expect(state.installing).toBe(false); + }); +}); diff --git a/WebApp/src/routes/page.svelte.spec.ts b/WebApp/src/routes/page.svelte.spec.ts deleted file mode 100644 index cc0d07f..0000000 --- a/WebApp/src/routes/page.svelte.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { page } from 'vitest/browser'; -import { describe, expect, it } from 'vitest'; -import { render } from 'vitest-browser-svelte'; -import Page from './+page.svelte'; - -describe('/+page.svelte', () => { - it('should render simulator auth heading', async () => { - render(Page); - - const heading = page.getByRole('heading', { name: 'PhoneCam Web' }); - await expect.element(heading).toBeInTheDocument(); - }); -});