import { describe, expect, it } from 'vitest' import type { Activity, DeviceEvent, Point } from './api' import { activitySpans, captureModes, findGaps, isOutageEvent, sampleRuns, summarize } from './analysis' import { MINUTE } from './time' const DAY = { start: 0, end: 24 * 60 * MINUTE } const m = (min: number) => min * MINUTE const fix = (min: number): Point => ({ id: min, t: m(min), lat: 0, lon: 0, origin: 'heartbeat', zone: 'UTC', status: 'used' }) const fixes = (...mins: number[]) => mins.map(fix) const ev = (min: number, event: string, data?: Record): DeviceEvent => ({ t: m(min), event, data }) const act = (min: number, activity: string, transition: 'enter' | 'exit'): Activity => ({ id: min, t: m(min), activity, transition, status: 'used', }) const sample = (min: number, activity: string): Activity => ({ id: min, t: m(min), activity, status: 'used' }) function gaps(points: Point[], events: DeviceEvent[] = [], activities: Activity[] = [], range = DAY) { const modes = captureModes(events, activities, DAY.start, DAY.end) return findGaps(points, events, modes, range) } describe('captureModes', () => { it('follows mode events, starting from the first event’s `from`', () => { const events = [ ev(100, 'mode', { from: 'stationary', to: 'moving', reason: 'exit_STILL' }), ev(130, 'mode', { from: 'moving', to: 'stopping', reason: 'enter_STILL' }), ev(134, 'mode', { from: 'stopping', to: 'stationary', reason: 'still_timeout' }), ] expect(captureModes(events, [], 0, m(200))).toEqual([ { mode: 'stationary', start: 0, end: m(100) }, { mode: 'moving', start: m(100), end: m(130) }, { mode: 'stopping', start: m(130), end: m(134) }, { mode: 'stationary', start: m(134), end: m(200) }, ]) }) it('falls back to activity transitions', () => { const activities = [act(100, 'STILL', 'exit'), act(101, 'WALKING', 'enter'), act(130, 'STILL', 'enter')] expect(captureModes([], activities, 0, m(200))).toEqual([ { mode: 'stationary', start: 0, end: m(100) }, { mode: 'moving', start: m(100), end: m(130) }, { mode: 'stopping', start: m(130), end: m(134) }, { mode: 'stationary', start: m(134), end: m(200) }, ]) }) it('cancels the end of stopping when movement resumes', () => { const activities = [act(10, 'WALKING', 'enter'), act(20, 'STILL', 'enter'), act(22, 'STILL', 'exit')] expect(captureModes([], activities, 0, m(60)).map((s) => s.mode)).toEqual(['unknown', 'moving', 'stopping', 'moving']) }) }) describe('findGaps', () => { const stationary = [ev(0, 'mode', { from: 'stopping', to: 'stationary', reason: 'still_timeout' })] it('accepts heartbeat spacing while stationary', () => { expect(gaps(fixes(0, 30, 62, 90, 120), stationary, [], { start: 0, end: m(120) })).toEqual([]) }) it('flags stationary intervals beyond the heartbeat margin as missing', () => { const [g] = gaps(fixes(0, 30, 90), stationary, [], { start: 0, end: m(90) }) expect(g).toMatchObject({ start: m(30), end: m(90), kind: 'missing', mode: 'stationary' }) }) it('flags long moving intervals', () => { const moving = [ev(0, 'mode', { to: 'moving', reason: 'start' })] expect(gaps(fixes(0, 5, 20), moving, [], { start: 0, end: m(20) })).toMatchObject([ { start: m(5), end: m(20), kind: 'missing', mode: 'moving' }, ]) }) it('measures from a mode change, not only from the last fix', () => { const events = [...stationary, ev(40, 'mode', { from: 'stationary', to: 'moving', reason: 'exit_STILL' })] expect(gaps(fixes(0, 30, 45), events, [], { start: 0, end: m(45) })).toEqual([]) expect(gaps(fixes(0, 30, 55), events, [], { start: 0, end: m(55) })).toMatchObject([{ kind: 'missing', mode: 'moving' }]) }) it('classifies intervals with a service stop as outages, however short', () => { const events = [...stationary, ev(31, 'service_stop', { reason: 'system' }), ev(40, 'service_start', { reason: 'restart', ok: true })] const [g] = gaps(fixes(0, 30, 50, 80), events, [], { start: 0, end: m(80) }) expect(g).toMatchObject({ start: m(30), end: m(50), kind: 'outage' }) expect(g.events.map((e) => e.event)).toEqual(['service_stop', 'service_start']) }) it('uses activities when there are no mode events', () => { const activities = [act(10, 'STILL', 'enter')] expect(gaps(fixes(0, 9, 11, 50, 90), [], activities, { start: 0, end: m(90) })).toEqual([]) expect(gaps(fixes(0, 9, 11, 50, 90), [], [], { start: 0, end: m(90) })).toHaveLength(2) }) it('checks the day edges, and skips the trailing edge for an open range', () => { expect(gaps(fixes(30, 40), stationary)).toMatchObject([{ start: m(40), end: DAY.end }]) expect(gaps(fixes(50, 60), stationary, [], { start: 0, end: 0 })).toMatchObject([{ start: 0, end: m(50) }]) }) }) describe('isOutageEvent', () => { it('knows which events impair tracking', () => { expect(isOutageEvent(ev(0, 'boot'))).toBe(true) expect(isOutageEvent(ev(0, 'location_setting', { enabled: false }))).toBe(true) expect(isOutageEvent(ev(0, 'location_setting', { enabled: true }))).toBe(false) expect(isOutageEvent(ev(0, 'permission', { name: 'ACCESS_FINE_LOCATION', granted: false }))).toBe(true) expect(isOutageEvent(ev(0, 'permission', { name: 'heartbeat_request', granted: false }))).toBe(true) expect(isOutageEvent(ev(0, 'permission', { name: 'POST_NOTIFICATIONS', granted: false }))).toBe(false) expect(isOutageEvent(ev(0, 'battery', { level: 0.5 }))).toBe(false) }) }) describe('activitySpans', () => { it('uses transitions when the day has any, ignoring samples', () => { const activities = [act(10, 'WALKING', 'enter'), sample(11, 'STILL'), sample(12, 'WALKING'), act(20, 'WALKING', 'exit')] expect(activitySpans(activities, 0, m(60))).toEqual([{ activity: 'WALKING', start: m(10), end: m(20) }]) }) it('merges repeated samples into one span', () => { const activities = [sample(10, 'WALKING'), sample(11, 'WALKING'), sample(12, 'IN_VEHICLE'), sample(30, 'IN_VEHICLE')] expect(activitySpans(activities, 0, m(40))).toEqual([ { activity: 'WALKING', start: m(10), end: m(12) }, { activity: 'IN_VEHICLE', start: m(12), end: m(40) }, ]) }) }) describe('sampleRuns', () => { it('merges consecutive samples of one activity and skips transitions', () => { const activities = [sample(0, 'STILL'), sample(1, 'STILL'), act(2, 'WALKING', 'enter'), sample(3, 'STILL'), sample(4, 'WALKING')] expect(sampleRuns(activities)).toEqual([ { activity: 'STILL', start: 0, end: m(3), count: 3 }, { activity: 'WALKING', start: m(4), end: m(4), count: 1 }, ]) }) }) describe('summarize', () => { it('totals gaps by kind', () => { const events = [ev(0, 'mode', { to: 'moving', reason: 'start' }), ev(25, 'boot')] const g = gaps(fixes(0, 20, 30, 45), events, [], { start: 0, end: m(45) }) expect(summarize(fixes(0, 20, 30, 45), g).gaps).toEqual({ outage: { count: 1, total: m(10) }, missing: { count: 2, total: m(35) }, }) }) })