test(webapp): add unit coverage for state and PWA flows

This commit is contained in:
2026-04-14 18:15:00 +01:00
parent 5f3daf7922
commit 2a8ce5c5e9
5 changed files with 256 additions and 17 deletions

View File

@@ -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');
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

144
WebApp/src/lib/pwa.spec.ts Normal file
View File

@@ -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<string, string>();
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);
});
});

View File

@@ -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();
});
});