import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SecurityState } from '../types/security-state-type.js'; import { OriginType } from '../types/origin-type.js'; import type { SystemState } from '../interfaces/system-state-interface.js'; import type { SecuritySystemOptions } from '../interfaces/options-interface.js'; import type { ServiceRegistry } from '../interfaces/service-registry-interface.js'; import type { StorageService } from '../services/storage-service.js'; import type { AudioService } from '../services/audio-service.js'; import { EventType } from '../types/event-type.js'; // ── Minimal mocks ───────────────────────────────────────────────────────────── function makeMockChar(initialValue: unknown = false) { const char = { value: initialValue, updateValue: vi.fn(), setProps: vi.fn() }; char.updateValue.mockImplementation((v: unknown) => { char.value = v; }); return char; } function makeMockService(charValue: unknown = false) { const char = makeMockChar(charValue); return { getCharacteristic: vi.fn().mockReturnValue(char), setCharacteristic: vi.fn().mockReturnThis(), updateCharacteristic: vi.fn(), addCharacteristic: vi.fn(), addOptionalCharacteristic: vi.fn(), }; } function makeServices(): ServiceRegistry { const s = {} as Record>; const keys = [ 'mainService', 'accessoryInfoService', 'tripSwitchService', 'tripHomeSwitchService', 'tripAwaySwitchService', 'tripNightSwitchService', 'tripOverrideSwitchService', 'armingLockSwitchService', 'armingLockHomeSwitchService', 'armingLockAwaySwitchService', 'armingLockNightSwitchService', 'modeHomeSwitchService', 'modeAwaySwitchService', 'modeNightSwitchService', 'modeOffSwitchService', 'modeAwayExtendedSwitchService', 'modePauseSwitchService', 'audioSwitchService', 'armingMotionSensorService', 'trippedMotionSensorService', 'triggeredMotionSensorService', 'triggeredResetMotionSensorService', ]; for (const k of keys) { s[k] = makeMockService(); } return s as unknown as ServiceRegistry; } function makeState(overrides: Partial = {}): SystemState { return { currentState: SecurityState.OFF, targetState: SecurityState.OFF, defaultState: SecurityState.OFF, availableTargetStates: [SecurityState.HOME, SecurityState.AWAY, SecurityState.NIGHT, SecurityState.OFF], isArming: false, isTripping: false, isKnocked: false, serverAuthenticationAttempts: 0, pausedCurrentState: null, audioProcess: null, ...overrides, }; } function makeOptions(overrides: Partial = {}): SecuritySystemOptions { return { armSeconds: 0, triggerSeconds: 0, resetMinutes: 10, testMode: false, proxyMode: false, saveState: false, armingLockSwitch: false, armingLockSwitches: false, disabledModes: [], homeArmSeconds: null, awayArmSeconds: null, nightArmSeconds: null, homeTriggerSeconds: null, awayTriggerSeconds: null, nightTriggerSeconds: null, triggeredMotionSensor: false, triggeredMotionSensorSeconds: 5, trippedMotionSensor: false, trippedMotionSensorSeconds: 5, resetOffFlow: false, ...overrides, } as unknown as SecuritySystemOptions; } function makeMockLog() { return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; } function makeStorage() { return { save: vi.fn(), load: vi.fn(), init: vi.fn() } as unknown as StorageService; } function makeAudio() { return { play: vi.fn(), stop: vi.fn(), attachToBus: vi.fn() } as unknown as AudioService; } function makeTimers() { return { setArmTimer: vi.fn(), clearArmTimer: vi.fn(), setTriggerTimer: vi.fn(), clearTriggerTimer: vi.fn(), isTriggerRunning: vi.fn().mockReturnValue(false), setPauseTimer: vi.fn(), clearPauseTimer: vi.fn(), setDoubleKnockTimer: vi.fn(), clearDoubleKnockTimer: vi.fn(), setResetTimer: vi.fn(), clearResetTimer: vi.fn(), setTrippedInterval: vi.fn(), clearTrippedInterval: vi.fn(), setTriggeredInterval: vi.fn(), clearTriggeredInterval: vi.fn(), clearAll: vi.fn(), } as any; } function makeMockSensor() { return { resetArmingMotionSensor: vi.fn(), setTrippedMotionSensor: vi.fn(), resetTrippedMotionSensor: vi.fn(), pulseTriggeredMotionSensor: vi.fn(), setTriggeredMotionSensor: vi.fn(), resetTriggeredMotionSensor: vi.fn(), pulseResetMotionSensor: vi.fn(), updateArmingMotionSensor: vi.fn(), }; } // ── StateHandler tests ──────────────────────────────────────────────────────── describe('StateHandler.getArmingSeconds', async () => { const { StateHandler } = await import('../handlers/state-handler.js'); let stateHandler: InstanceType; let state: SystemState; beforeEach(async () => { const { EventBusService } = await import('../services/event-bus-service.js'); state = makeState(); const services = makeServices(); const options = makeOptions(); const log = makeMockLog(); const bus = new EventBusService(); const sensor = makeMockSensor() as any; stateHandler = new StateHandler(services, state, options, {} as any, log as any, bus, makeStorage(), makeAudio(), makeTimers(), sensor); }); it('returns 0 when current state is TRIGGERED', () => { state.currentState = SecurityState.TRIGGERED; expect(stateHandler.getArmingSeconds(SecurityState.HOME)).toBe(0); }); it('returns 0 when target state is OFF (disarm)', () => { state.currentState = SecurityState.HOME; expect(stateHandler.getArmingSeconds(SecurityState.OFF)).toBe(0); }); it('returns global armSeconds when no mode-specific value', () => { state.currentState = SecurityState.HOME; expect(stateHandler.getArmingSeconds(SecurityState.AWAY)).toBe(0); }); }); // ── StateHandler.updateTargetState ─────────────────────────────────────────── describe('StateHandler.updateTargetState', async () => { const { StateHandler } = await import('../handlers/state-handler.js'); const { EventBusService } = await import('../services/event-bus-service.js'); it('returns success (no-op) when currentState already matches the requested mode', async () => { const state = makeState({ currentState: SecurityState.HOME, targetState: SecurityState.HOME }); const log = makeMockLog(); const bus = new EventBusService(); const sensor = makeMockSensor() as any; const timers = makeTimers(); const handler = new StateHandler(makeServices(), state, makeOptions(), {} as any, log as any, bus, makeStorage(), makeAudio(), timers, sensor); const result = handler.updateTargetState(SecurityState.HOME, OriginType.INTERNAL, 0); expect(result.success).toBe(true); expect(timers.clearArmTimer).not.toHaveBeenCalled(); }); it('cancels arm timer and applies state immediately on same-target re-call while arming', async () => { const state = makeState({ currentState: SecurityState.OFF, targetState: SecurityState.HOME, isArming: true }); const log = makeMockLog(); const bus = new EventBusService(); const sensor = makeMockSensor() as any; const timers = makeTimers(); const handler = new StateHandler(makeServices(), state, makeOptions(), {} as any, log as any, bus, makeStorage(), makeAudio(), timers, sensor); const result = handler.updateTargetState(SecurityState.HOME, OriginType.EXTERNAL, 0); expect(result.success).toBe(true); expect(timers.clearArmTimer).toHaveBeenCalled(); expect(state.isArming).toBe(false); expect(state.currentState).toBe(SecurityState.HOME); }); it('applies state immediately on same-target re-call when not arming', async () => { const state = makeState({ currentState: SecurityState.OFF, targetState: SecurityState.HOME, isArming: false }); const log = makeMockLog(); const bus = new EventBusService(); const sensor = makeMockSensor() as any; const timers = makeTimers(); const handler = new StateHandler(makeServices(), state, makeOptions(), {} as any, log as any, bus, makeStorage(), makeAudio(), timers, sensor); const result = handler.updateTargetState(SecurityState.HOME, OriginType.EXTERNAL, 0); expect(result.success).toBe(true); expect(timers.clearArmTimer).not.toHaveBeenCalled(); expect(state.currentState).toBe(SecurityState.HOME); }); it('transitions to a new armed mode', async () => { const state = makeState({ currentState: SecurityState.OFF, targetState: SecurityState.OFF }); const log = makeMockLog(); const bus = new EventBusService(); const sensor = makeMockSensor() as any; const handler = new StateHandler(makeServices(), state, makeOptions(), {} as any, log as any, bus, makeStorage(), makeAudio(), makeTimers(), sensor); const result = handler.updateTargetState(SecurityState.HOME, OriginType.REGULAR_SWITCH, 0); expect(result.success).toBe(true); expect(state.targetState).toBe(SecurityState.HOME); }); }); // ── Regression: RESET_TRIP_SWITCHES on TRIGGERED ─────────────────────────────── // Issue 905: Trip Switch was being reset to OFF when the alarm triggered, // hiding the active sensor from the user. describe('StateHandler.setCurrentState - RESET_TRIP_SWITCHES', async () => { const { StateHandler } = await import('../handlers/state-handler.js'); const { EventBusService } = await import('../services/event-bus-service.js'); it('does not emit RESET_TRIP_SWITCHES when entering TRIGGERED', () => { const state = makeState({ currentState: SecurityState.NIGHT }); const bus = new EventBusService(); const spy = vi.fn(); bus.on(EventType.RESET_TRIP_SWITCHES, spy); const handler = new StateHandler( makeServices(), state, makeOptions(), {} as any, makeMockLog() as any, bus, makeStorage(), makeAudio(), makeTimers(), makeMockSensor() as any, ); handler.setCurrentState(SecurityState.TRIGGERED, OriginType.EXTERNAL); expect(spy).not.toHaveBeenCalled(); }); it('still emits RESET_TRIP_SWITCHES when entering a non-TRIGGERED state', () => { const state = makeState({ currentState: SecurityState.TRIGGERED }); const bus = new EventBusService(); const spy = vi.fn(); bus.on(EventType.RESET_TRIP_SWITCHES, spy); const handler = new StateHandler( makeServices(), state, makeOptions(), {} as any, makeMockLog() as any, bus, makeStorage(), makeAudio(), makeTimers(), makeMockSensor() as any, ); handler.setCurrentState(SecurityState.NIGHT, OriginType.EXTERNAL); expect(spy).toHaveBeenCalledTimes(1); }); }); // ── Regression: isTripping reset on target state change ─────────────────────── // Issue 905: isTripping was not being reset when the user changed the target // mode, causing stale trip state after OFF disarm + re-arm. describe('StateHandler.updateTargetState - isTripping reset', async () => { const { StateHandler } = await import('../handlers/state-handler.js'); const { EventBusService } = await import('../services/event-bus-service.js'); it('resets isTripping to false when target state changes to a different mode', () => { const state = makeState({ currentState: SecurityState.HOME, isTripping: true }); const handler = new StateHandler( makeServices(), state, makeOptions(), {} as any, makeMockLog() as any, new EventBusService(), makeStorage(), makeAudio(), makeTimers(), makeMockSensor() as any, ); handler.updateTargetState(SecurityState.AWAY, OriginType.INTERNAL, 0); expect(state.isTripping).toBe(false); }); it('resets isTripping to false when disarming from TRIGGERED to OFF', () => { const state = makeState({ currentState: SecurityState.TRIGGERED, targetState: SecurityState.NIGHT, isTripping: true, }); const handler = new StateHandler( makeServices(), state, makeOptions(), {} as any, makeMockLog() as any, new EventBusService(), makeStorage(), makeAudio(), makeTimers(), makeMockSensor() as any, ); handler.updateTargetState(SecurityState.OFF, OriginType.INTERNAL, 0); expect(state.isTripping).toBe(false); }); }); // ── Triggered motion sensor behavior ───────────────────────────────────────── describe('StateHandler.setCurrentState - triggered motion sensor', async () => { const { StateHandler } = await import('../handlers/state-handler.js'); const { EventBusService } = await import('../services/event-bus-service.js'); it('starts triggered sensor steady-on when triggeredMotionSensorSeconds = 0', () => { const state = makeState({ currentState: SecurityState.NIGHT }); const bus = new EventBusService(); const sensor = makeMockSensor() as any; const timers = makeTimers(); const handler = new StateHandler( makeServices(), state, makeOptions({ triggeredMotionSensor: true, triggeredMotionSensorSeconds: 0 }), {} as any, makeMockLog() as any, bus, makeStorage(), makeAudio(), timers, sensor, ); handler.setCurrentState(SecurityState.TRIGGERED, OriginType.EXTERNAL); expect(sensor.setTriggeredMotionSensor).toHaveBeenCalledWith(true); expect(sensor.pulseTriggeredMotionSensor).not.toHaveBeenCalled(); expect(timers.setTriggeredInterval).not.toHaveBeenCalled(); }); it('pulses triggered sensor with interval when triggeredMotionSensorSeconds > 0', () => { const state = makeState({ currentState: SecurityState.NIGHT }); const bus = new EventBusService(); const sensor = makeMockSensor() as any; const timers = makeTimers(); const handler = new StateHandler( makeServices(), state, makeOptions({ triggeredMotionSensor: true, triggeredMotionSensorSeconds: 10 }), {} as any, makeMockLog() as any, bus, makeStorage(), makeAudio(), timers, sensor, ); handler.setCurrentState(SecurityState.TRIGGERED, OriginType.EXTERNAL); expect(timers.setTriggeredInterval).toHaveBeenCalledWith(10000, expect.any(Function)); expect(sensor.setTriggeredMotionSensor).not.toHaveBeenCalled(); }); it('resets tripped motion sensor when entering TRIGGERED', () => { const state = makeState({ currentState: SecurityState.NIGHT }); const bus = new EventBusService(); const sensor = makeMockSensor() as any; const handler = new StateHandler( makeServices(), state, makeOptions(), {} as any, makeMockLog() as any, bus, makeStorage(), makeAudio(), makeTimers(), sensor, ); handler.setCurrentState(SecurityState.TRIGGERED, OriginType.EXTERNAL); expect(sensor.resetTrippedMotionSensor).toHaveBeenCalled(); }); });