test(webapp): add unit coverage for state and PWA flows
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
73
WebApp/src/lib/app/store.spec.ts
Normal file
73
WebApp/src/lib/app/store.spec.ts
Normal 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
144
WebApp/src/lib/pwa.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user