import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SecurityState } from '../types/security-state-type.js'; import { OriginType } from '../types/origin-type.js'; import { EventType } from '../types/event-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'; // ── Helpers (shared with state-handler.test) ────────────────────────────────── function makeMockChar(value: unknown = false) { const c = { value, updateValue: vi.fn() }; c.updateValue.mockImplementation((v: unknown) => { c.value = v; }); return c; } function makeMockService(charValue: unknown = false) { const char = makeMockChar(charValue); return { getCharacteristic: vi.fn().mockReturnValue(char), updateCharacteristic: vi.fn(), setCharacteristic: vi.fn().mockReturnThis(), addCharacteristic: vi.fn(), addOptionalCharacteristic: vi.fn(), }; } function makeServices(): ServiceRegistry { const keys = [ 'mainService', 'tripSwitchService', 'tripHomeSwitchService', 'tripAwaySwitchService', 'tripNightSwitchService', 'tripOverrideSwitchService', 'armingLockSwitchService', 'armingLockHomeSwitchService', 'armingLockAwaySwitchService', 'armingLockNightSwitchService', 'modeHomeSwitchService', 'modeAwaySwitchService', 'modeNightSwitchService', 'modeOffSwitchService', 'modeAwayExtendedSwitchService', 'modePauseSwitchService', 'audioSwitchService', 'armingMotionSensorService', 'trippedMotionSensorService', 'triggeredMotionSensorService', 'triggeredResetMotionSensorService', 'accessoryInfoService', ]; const s: Record | unknown[]> = {}; for (const k of keys) { s[k] = makeMockService(); } s.customTripHomeSwitchServices = []; s.customTripAwaySwitchServices = []; s.customTripNightSwitchServices = []; return s as unknown as ServiceRegistry; } function makeState(overrides: Partial = {}): SystemState { return { currentState: SecurityState.HOME, targetState: SecurityState.HOME, 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 { overrideOff: false, doubleKnock: false, doubleKnockSeconds: 90, doubleKnockModes: [], triggerSeconds: 0, homeTriggerSeconds: null, awayTriggerSeconds: null, nightTriggerSeconds: null, modeAwayExtendedSwitchTriggerSeconds: null, trippedMotionSensor: false, trippedMotionSensorSeconds: 5, homeDoubleKnockSeconds: null, awayDoubleKnockSeconds: null, nightDoubleKnockSeconds: null, ...overrides, } as unknown as SecuritySystemOptions; } // ── TripHandler tests ───────────────────────────────────────────────────────── describe('TripHandler', async () => { const { TripHandler } = await import('../handlers/trip-handler.js'); const { EventBusService } = await import('../services/event-bus-service.js'); let state: SystemState; let services: ServiceRegistry; let bus: InstanceType; let tripHandler: any; const mockSensorHandler = { pulseTrippedMotionSensor: vi.fn(), setTrippedMotionSensor: vi.fn(), resetTrippedMotionSensor: vi.fn(), }; const mockAudio = { stop: vi.fn(), play: vi.fn(), attachToBus: vi.fn() }; const mockTimers = { setTriggerTimer: vi.fn(), clearTriggerTimer: vi.fn(), isTriggerRunning: vi.fn().mockReturnValue(false), setTrippedInterval: vi.fn(), clearTrippedInterval: vi.fn(), setDoubleKnockTimer: vi.fn(), clearDoubleKnockTimer: vi.fn(), clearAll: vi.fn(), } as any; beforeEach(() => { vi.clearAllMocks(); state = makeState({ currentState: SecurityState.HOME }); services = makeServices(); bus = new EventBusService(); const mockLog = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; tripHandler = new TripHandler(services, state, makeOptions(), {} as any, mockLog as any, bus, mockAudio as any, mockSensorHandler as any, mockTimers); }); it('blocks trip when system is disarmed (not overriding)', () => { state.currentState = SecurityState.OFF; const result = tripHandler.updateTripSwitch(true, OriginType.REGULAR_SWITCH, false); expect(result.success).toBe(false); expect(result.reason).toBe('Trip Switch (Not armed): system is disarmed and override is not enabled'); }); it('blocks trip when arming is in progress', () => { state.isArming = true; const result = tripHandler.updateTripSwitch(true, OriginType.REGULAR_SWITCH, false); expect(result.success).toBe(false); expect(result.reason).toBe('Trip Switch (Still arming): arm delay countdown is still in progress'); }); it('blocks trip when already triggered', () => { state.currentState = SecurityState.TRIGGERED; const result = tripHandler.updateTripSwitch(true, OriginType.REGULAR_SWITCH, false); expect(result.success).toBe(false); expect(result.reason).toBe('Security System (Already triggered): alarm is already active'); }); it('blocks trip when trigger timeout is already running', () => { state.isTripping = true; const result = tripHandler.updateTripSwitch(true, OriginType.REGULAR_SWITCH, false); expect(result.success).toBe(false); expect(result.reason).toBe('Security System (Already tripped): trigger delay countdown is already running'); state.isTripping = false; }); it('allows trip when system is armed (HOME mode)', () => { state.currentState = SecurityState.HOME; const result = tripHandler.updateTripSwitch(true, OriginType.REGULAR_SWITCH, false); expect(result.success).toBe(true); }); describe('tripped motion sensor', () => { it('starts steady-on when trippedMotionSensorSeconds = 0', () => { const handler = new TripHandler( services, state, makeOptions({ trippedMotionSensor: true, trippedMotionSensorSeconds: 0 }), {} as any, { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, bus, mockAudio as any, mockSensorHandler as any, mockTimers, ); handler.updateTripSwitch(true, OriginType.REGULAR_SWITCH, false); expect(mockSensorHandler.setTrippedMotionSensor).toHaveBeenCalledWith(true); expect(mockSensorHandler.pulseTrippedMotionSensor).not.toHaveBeenCalled(); expect(mockTimers.setTrippedInterval).not.toHaveBeenCalled(); }); it('pulses with interval when trippedMotionSensorSeconds > 0', () => { const handler = new TripHandler( services, state, makeOptions({ trippedMotionSensor: true, trippedMotionSensorSeconds: 10 }), {} as any, { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, bus, mockAudio as any, mockSensorHandler as any, mockTimers, ); handler.updateTripSwitch(true, OriginType.REGULAR_SWITCH, false); expect(mockSensorHandler.pulseTrippedMotionSensor).toHaveBeenCalled(); expect(mockTimers.setTrippedInterval).toHaveBeenCalledWith(10000, expect.any(Function)); expect(mockSensorHandler.setTrippedMotionSensor).not.toHaveBeenCalled(); }); it('resets tripped sensor on cancelTrip', () => { const handler = new TripHandler( services, state, makeOptions({ trippedMotionSensor: true }), {} as any, { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, bus, mockAudio as any, mockSensorHandler as any, mockTimers, ); handler.updateTripSwitch(false, OriginType.REGULAR_SWITCH, false); expect(mockSensorHandler.resetTrippedMotionSensor).toHaveBeenCalled(); }); }); it('cancels trip and stops audio', () => { const emitted: unknown[] = []; bus.on(EventType.TRIP_CANCELLED, (payload) => emitted.push(payload)); tripHandler.updateTripSwitch(false, OriginType.REGULAR_SWITCH, false); expect(mockAudio.stop).toHaveBeenCalled(); expect(emitted).toHaveLength(1); }); it('emits TRIP_CANCELLED with stateChanged=false when trip cancelled while triggered', () => { state.currentState = SecurityState.TRIGGERED; let payload: any; bus.on(EventType.TRIP_CANCELLED, (p) => { payload = p; }); tripHandler.updateTripSwitch(false, OriginType.REGULAR_SWITCH, false); expect(payload).toBeDefined(); expect(payload.stateChanged).toBe(false); }); it('emits TRIP_CANCELLED with stateChanged=true when state has changed', () => { state.currentState = SecurityState.TRIGGERED; let payload: any; bus.on(EventType.TRIP_CANCELLED, (p) => { payload = p; }); tripHandler.updateTripSwitch(false, OriginType.INTERNAL, true); expect(payload).toBeDefined(); expect(payload.stateChanged).toBe(true); }); it('triggerIfModeSet allows when current mode matches required', () => { state.currentState = SecurityState.HOME; const result = tripHandler.triggerIfModeSet(SecurityState.HOME, true); expect(result.success).toBe(true); }); it('triggerIfModeSet blocks when current mode does not match', () => { state.currentState = SecurityState.AWAY; const result = tripHandler.triggerIfModeSet(SecurityState.HOME, true); expect(result.success).toBe(false); expect(result.reason).toBe('mode not set'); }); // ── resetTripSwitches tests ──────────────────────────────────────────────── describe('resetTripSwitches', () => { it('resets global trip switch', () => { const globalChar = services.tripSwitchService.getCharacteristic('On' as any)!; globalChar.value = true; tripHandler.resetTripSwitches(); expect(globalChar.updateValue).toHaveBeenCalledWith(false); }); it('resets mode-specific trip switches', () => { const homeChar = services.tripHomeSwitchService.getCharacteristic('On' as any)!; const awayChar = services.tripAwaySwitchService.getCharacteristic('On' as any)!; const nightChar = services.tripNightSwitchService.getCharacteristic('On' as any)!; const overrideChar = services.tripOverrideSwitchService.getCharacteristic('On' as any)!; homeChar.value = true; awayChar.value = true; nightChar.value = true; overrideChar.value = true; tripHandler.resetTripSwitches(); expect(homeChar.updateValue).toHaveBeenCalledWith(false); expect(awayChar.updateValue).toHaveBeenCalledWith(false); expect(nightChar.updateValue).toHaveBeenCalledWith(false); expect(overrideChar.updateValue).toHaveBeenCalledWith(false); }); it('does not touch switches that are already off', () => { const globalChar = services.tripSwitchService.getCharacteristic('On' as any)!; globalChar.value = false; tripHandler.resetTripSwitches(); expect(globalChar.updateValue).not.toHaveBeenCalled(); }); }); // ── Custom trip switch tests ─────────────────────────────────────────────── describe('Custom Trip Switches', () => { it('custom HOME trip switch triggers only in HOME mode', () => { state.currentState = SecurityState.HOME; const result = tripHandler.triggerIfModeSet(SecurityState.HOME, true); expect(result.success).toBe(true); }); it('custom HOME trip switch blocks when not in HOME mode', () => { state.currentState = SecurityState.AWAY; const result = tripHandler.triggerIfModeSet(SecurityState.HOME, true); expect(result.success).toBe(false); }); it('custom AWAY trip switch triggers only in AWAY mode', () => { state.currentState = SecurityState.AWAY; const result = tripHandler.triggerIfModeSet(SecurityState.AWAY, true); expect(result.success).toBe(true); }); it('custom AWAY trip switch blocks when not in AWAY mode', () => { state.currentState = SecurityState.HOME; const result = tripHandler.triggerIfModeSet(SecurityState.AWAY, true); expect(result.success).toBe(false); }); it('custom NIGHT trip switch triggers only in NIGHT mode', () => { state.currentState = SecurityState.NIGHT; const result = tripHandler.triggerIfModeSet(SecurityState.NIGHT, true); expect(result.success).toBe(true); }); it('custom NIGHT trip switch blocks when not in NIGHT mode', () => { state.currentState = SecurityState.HOME; const result = tripHandler.triggerIfModeSet(SecurityState.NIGHT, true); expect(result.success).toBe(false); }); it('custom trip switch blocks when alarm is already triggered', () => { state.currentState = SecurityState.TRIGGERED; const result = tripHandler.triggerIfModeSet(SecurityState.HOME, true); expect(result.success).toBe(false); }); it('custom trip switch cancellation works with triggerIfModeSet', () => { state.currentState = SecurityState.HOME; const result = tripHandler.triggerIfModeSet(SecurityState.HOME, false); expect(result.success).toBe(true); expect(mockAudio.stop).toHaveBeenCalled(); }); it('resetTripSwitches includes custom trip switch services', () => { const mockChar = makeMockChar(true); const mockSvc = { getCharacteristic: vi.fn().mockReturnValue(mockChar) }; (services.customTripHomeSwitchServices as unknown[]) = [mockSvc]; tripHandler.resetTripSwitches(); expect(mockChar.updateValue).toHaveBeenCalledWith(false); }); it('resetTripSwitches handles multiple custom switches per mode', () => { const mockChar1 = makeMockChar(true); const mockChar2 = makeMockChar(true); const mockChar3 = makeMockChar(false); const mockSvc1 = { getCharacteristic: vi.fn().mockReturnValue(mockChar1) }; const mockSvc2 = { getCharacteristic: vi.fn().mockReturnValue(mockChar2) }; const mockSvc3 = { getCharacteristic: vi.fn().mockReturnValue(mockChar3) }; (services.customTripHomeSwitchServices as unknown[]) = [mockSvc1, mockSvc2]; (services.customTripAwaySwitchServices as unknown[]) = [mockSvc3]; tripHandler.resetTripSwitches(); expect(mockChar1.updateValue).toHaveBeenCalledWith(false); expect(mockChar2.updateValue).toHaveBeenCalledWith(false); expect(mockChar3.updateValue).not.toHaveBeenCalled(); }); }); });