diff --git a/src/automation/tests/signals.biometrics.test.ts b/src/automation/tests/signals.biometrics.test.ts index 3dc83841..b7b209f0 100644 --- a/src/automation/tests/signals.biometrics.test.ts +++ b/src/automation/tests/signals.biometrics.test.ts @@ -1,5 +1,60 @@ -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// Hoisted DB mock — drives BiometricsSignalReader by stubbing the chained +// biometricsDb.select().from(table)[.where()].orderBy().limit().all() call. +// Rows are keyed by table; per-side tables (vitals, movement) resolve left +// then right, matching the reader's SIDES iteration order. +const dbMock = vi.hoisted(() => { + const state = { + rows: {} as Record, + sideCalls: {} as Record, + throws: false, + } + let tableInfo: Map | null = null + const setTables = (m: Map) => { + tableInfo = m + } + const select = vi.fn(() => ({ + from: (table: unknown) => { + if (state.throws) throw new Error('biometrics db down') + const info = tableInfo?.get(table) + let key = info?.name ?? 'unknown' + if (info?.perSide) { + const n = state.sideCalls[key] ?? 0 + state.sideCalls[key] = n + 1 + key = `${key}.${n === 0 ? 'left' : 'right'}` + } + const terminal = { all: () => state.rows[key] ?? [] } + const ordered = { orderBy: () => ({ limit: () => terminal }) } + return { ...ordered, where: () => ordered } + }, + })) + return { state, select, setTables } +}) + +vi.mock('@/src/db', () => ({ + biometricsDb: { select: dbMock.select }, +})) + +const capMock = vi.hoisted(() => ({ + snapshot: null as Record | null, +})) + +vi.mock('@/src/streaming/piezoStream', () => ({ + getLatestCapSenseSnapshot: () => capMock.snapshot, +})) + +import { ambientLight, bedTemp, freezerTemp, movement, vitals } from '@/src/db/biometrics-schema' import { reduceCap } from '../capReduce' +import { BiometricsSignalReader } from '../signals.biometrics' + +dbMock.setTables(new Map([ + [vitals, { name: 'vitals', perSide: true }], + [movement, { name: 'movement', perSide: true }], + [bedTemp, { name: 'bedTemp', perSide: false }], + [freezerTemp, { name: 'freezerTemp', perSide: false }], + [ambientLight, { name: 'ambientLight', perSide: false }], +])) describe('reduceCap', () => { it('returns null for an empty array', () => { @@ -26,3 +81,118 @@ describe('reduceCap', () => { expect(reduceCap([10, 20, 30])).toMatchObject({ peakZone: null }) }) }) + +describe('BiometricsSignalReader', () => { + beforeEach(() => { + dbMock.state.rows = {} + dbMock.state.sideCalls = {} + dbMock.state.throws = false + capMock.snapshot = null + }) + + it('surfaces fresh vitals, movement, environment, and cap signals with unit conversion', () => { + const fresh = new Date(Date.now() - 1_000) + dbMock.state.rows = { + 'vitals.left': [{ timestamp: fresh, heartRate: 55, hrv: 40, breathingRate: 14 }], + 'vitals.right': [{ timestamp: fresh, heartRate: 60, hrv: null, breathingRate: null }], + 'movement.left': [{ timestamp: fresh, totalMovement: 120 }], + 'movement.right': [{ timestamp: fresh, totalMovement: 30 }], + 'bedTemp': [{ + timestamp: fresh, + ambientTemp: 2000, // 20.00°C → 68°F + humidity: 4550, // 45.50% + leftOuterTemp: 3000, // 86°F + leftCenterTemp: 3100, // 87.8°F + leftInnerTemp: 3200, // 89.6°F + rightOuterTemp: 2900, // 84.2°F — only zone present on the right + rightCenterTemp: null, + rightInnerTemp: null, + }], + 'freezerTemp': [{ timestamp: fresh, leftWaterTemp: 1500, rightWaterTemp: null }], // 15°C → 59°F + 'ambientLight': [{ timestamp: fresh, lux: 12 }], + } + capMock.snapshot = { + type: 'capSense2', + ts: Math.floor(Date.now() / 1000), + receivedAtMs: Date.now() - 1_000, + left: [10, 20, 30, 40, 50, 60, 999, 999], + right: 42, // Pod 3 scalar shape + } + + const out = new BiometricsSignalReader().read() + + expect(out['left.heartRate']).toBe(55) + expect(out['left.hrv']).toBe(40) + expect(out['left.breathingRate']).toBe(14) + expect(out['right.heartRate']).toBe(60) + // Null columns stay absent rather than mapping to 0. + expect(out['right.hrv']).toBeUndefined() + expect(out['right.breathingRate']).toBeUndefined() + + expect(out['left.movement']).toBe(120) + expect(out['right.movement']).toBe(30) + + expect(out['ambient.temperature']).toBeCloseTo(68, 5) + expect(out['ambient.humidity']).toBeCloseTo(45.5, 5) + + // Left has all three zones: mean / spread / inner-outer gradient. + expect(out['left.surfaceTemp']).toBeCloseTo(87.8, 5) + expect(out['left.surfaceTemp.spread']).toBeCloseTo(3.6, 5) + expect(out['left.surfaceTemp.gradient']).toBeCloseTo(3.6, 5) + // Right has a single zone: mean only — spread needs 2+, gradient needs inner+outer. + expect(out['right.surfaceTemp']).toBeCloseTo(84.2, 5) + expect(out['right.surfaceTemp.spread']).toBeUndefined() + expect(out['right.surfaceTemp.gradient']).toBeUndefined() + + expect(out['left.waterTemp']).toBeCloseTo(59, 5) + expect(out['right.waterTemp']).toBeUndefined() + + expect(out['ambient.light']).toBe(12) + + expect(out['left.cap.max']).toBe(60) + expect(out['left.cap.mean']).toBe(35) + expect(out['left.cap.spread']).toBe(50) + // Scalar (Pod 3) side is wrapped into a one-element array before reducing. + expect(out['right.cap.max']).toBe(42) + expect(out['right.cap.mean']).toBe(42) + expect(out['right.cap.spread']).toBe(0) + }) + + it('treats rows older than their freshness window as absent', () => { + const staleShort = new Date(Date.now() - 6 * 60_000) // past the 5-min vitals/movement window + const staleEnv = new Date(Date.now() - 16 * 60_000) // past the 15-min environment window + dbMock.state.rows = { + 'vitals.left': [{ timestamp: staleShort, heartRate: 55, hrv: 40, breathingRate: 14 }], + 'vitals.right': [{ timestamp: staleShort, heartRate: 60, hrv: 41, breathingRate: 15 }], + 'movement.left': [{ timestamp: staleShort, totalMovement: 120 }], + 'movement.right': [{ timestamp: staleShort, totalMovement: 30 }], + 'bedTemp': [{ timestamp: staleEnv, ambientTemp: 2000, humidity: 4550, leftOuterTemp: 3000, leftCenterTemp: 3100, leftInnerTemp: 3200, rightOuterTemp: 2900, rightCenterTemp: null, rightInnerTemp: null }], + 'freezerTemp': [{ timestamp: staleEnv, leftWaterTemp: 1500, rightWaterTemp: 1600 }], + 'ambientLight': [{ timestamp: staleEnv, lux: 12 }], + } + capMock.snapshot = { + type: 'capSense2', + ts: Math.floor(Date.now() / 1000), + receivedAtMs: Date.now() - 31_000, // past the 30s cap window + left: [10, 20, 30, 40, 50, 60, 999, 999], + right: [10, 20, 30, 40, 50, 60, 999, 999], + } + + expect(new BiometricsSignalReader().read()).toEqual({}) + }) + + it('returns an empty snapshot when there are no rows and no cap snapshot', () => { + expect(new BiometricsSignalReader().read()).toEqual({}) + }) + + it('warns and degrades to an empty snapshot when the db read throws', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + dbMock.state.throws = true + expect(new BiometricsSignalReader().read()).toEqual({}) + expect(warn).toHaveBeenCalledWith( + '[automation] BiometricsSignalReader.read failed:', + expect.any(Error), + ) + warn.mockRestore() + }) +}) diff --git a/src/automation/tests/signals.test.ts b/src/automation/tests/signals.test.ts index 776efff0..2372a9b4 100644 --- a/src/automation/tests/signals.test.ts +++ b/src/automation/tests/signals.test.ts @@ -11,6 +11,7 @@ vi.mock('@/src/hardware/dacMonitor.instance', () => ({ })) import { + CompositeSignalReader, DeviceSignalReader, clockInTimezone, collectWindowSignals, @@ -119,6 +120,22 @@ describe('DeviceSignalReader', () => { }) }) +describe('CompositeSignalReader', () => { + it('merges reader snapshots in order, with later readers winning collisions', () => { + const first = { read: () => ({ 'left.movement': 1, 'water.low': 1 }) } + const second = { read: () => ({ 'water.low': 0, 'ambient.light': 12 }) } + expect(new CompositeSignalReader([first, second]).read()).toEqual({ + 'left.movement': 1, + 'water.low': 0, + 'ambient.light': 12, + }) + }) + + it('returns an empty snapshot with no readers', () => { + expect(new CompositeSignalReader([]).read()).toEqual({}) + }) +}) + describe('collectWindowSignals', () => { const win = (signal: string): Expr => ({ kind: 'window', fn: 'avg', signal, lastMin: 10 }) const lit = (value: number): Expr => ({ kind: 'literal', value }) @@ -165,6 +182,18 @@ describe('collectWindowSignals', () => { expect(collectWindowSignals(rules)).toEqual(new Set(['ambient.temperature', 'left.breathingRate'])) }) + it('collects window keys from both operands of a binary expression', () => { + const cond: Condition = { + kind: 'compare', + op: '>', + left: { kind: 'binary', op: '-', left: win('left.surfaceTemp'), right: win('ambient.temperature') }, + right: lit(5), + } + expect(collectWindowSignals([rule({ conditions: cond })])).toEqual( + new Set(['left.surfaceTemp', 'ambient.temperature']), + ) + }) + it('ignores disabled rules', () => { const cond: Condition = { kind: 'compare', op: '>', left: win('left.movement'), right: lit(1) } expect(collectWindowSignals([rule({ enabled: false, conditions: cond })])).toEqual(new Set()) diff --git a/src/lib/tests/scheduleTime.test.ts b/src/lib/tests/scheduleTime.test.ts index 4c145f7a..249b6aa1 100644 --- a/src/lib/tests/scheduleTime.test.ts +++ b/src/lib/tests/scheduleTime.test.ts @@ -20,12 +20,15 @@ describe('scheduleTime', () => { expect(formatTime12h('00:00')).toBe('12:00 AM') expect(formatTime12h('12:00')).toBe('12:00 PM') expect(formatTime12h('23:45')).toBe('11:45 PM') + expect(formatTime12h('7')).toBe('7:00 AM') + expect(formatTime12h('bad')).toBe('bad') }) it('calculates wrapped and same-day durations', () => { expect(calcDuration('07:00', '09:30')).toBe('2h 30m') expect(calcDuration('22:00', '07:00')).toBe('9h 0m') expect(calcDuration('bad', '07:00')).toBe('—') + expect(calcDuration('07:00', 'bad')).toBe('—') }) it('checks same-day and overnight windows', () => { @@ -36,6 +39,12 @@ describe('scheduleTime', () => { expect(isInWindow(12 * 60, '22:00', '07:00')).toBe(false) }) + it('rejects windows with a NaN clock or unparseable bounds', () => { + expect(isInWindow(Number.NaN, '07:00', '09:00')).toBe(false) + expect(isInWindow(8 * 60, 'bad', '09:00')).toBe(false) + expect(isInWindow(8 * 60, '07:00', 'bad')).toBe(false) + }) + it('treats local pre-4am as the previous schedule day', () => { expect(getCurrentDay(new Date(2026, 6, 1, 3, 59))).toBe('tuesday') expect(getCurrentDay(new Date(2026, 6, 1, 4, 0))).toBe('wednesday') @@ -51,5 +60,7 @@ describe('scheduleTime', () => { it('gets the schedule day in a timezone', () => { const earlyLa = new Date('2026-07-01T10:30:00.000Z') // 03:30 Wednesday America/Los_Angeles expect(getCurrentDayForTimezone('America/Los_Angeles', earlyLa)).toBe('tuesday') + const afternoonLa = new Date('2026-07-01T22:30:00.000Z') // 15:30 Wednesday America/Los_Angeles + expect(getCurrentDayForTimezone('America/Los_Angeles', afternoonLa)).toBe('wednesday') }) })