Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 171 additions & 1 deletion src/automation/tests/signals.biometrics.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown[]>,
sideCalls: {} as Record<string, number>,
throws: false,
}
let tableInfo: Map<unknown, { name: string, perSide: boolean }> | null = null
const setTables = (m: Map<unknown, { name: string, perSide: boolean }>) => {
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<string, unknown> | 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<unknown, { name: string, perSide: boolean }>([
[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', () => {
Expand All @@ -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()
})
})
29 changes: 29 additions & 0 deletions src/automation/tests/signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('@/src/hardware/dacMonitor.instance', () => ({
}))

import {
CompositeSignalReader,
DeviceSignalReader,
clockInTimezone,
collectWindowSignals,
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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())
Expand Down
11 changes: 11 additions & 0 deletions src/lib/tests/scheduleTime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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')
Expand All @@ -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')
})
})
Loading