/** * test/voice-confirm.test.ts — Unit tests for voice-confirm.ts (VC / V1b). * * Pure timing-logic controller for the approve confirm window (PLAN_VOICE_COMMANDS §4, * decision B). Uses vi.useFakeTimers — no DOM, no WS; setTimeout/clearTimeout only. * * Covered behaviours: * - arm(onHeard, onCommit) fires onHeard synchronously/immediately. * - onCommit fires once the windowMs timer elapses. * - cancel() before the window elapses → onCommit is NEVER called. * - re-arming clears the prior timer — only the latest arm's onCommit fires. * - isArmed() reflects true while pending, false after commit/cancel/before-arm. * - default windowMs is 1500 when not provided. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createApproveConfirm } from '../public/voice-confirm.js' describe('voice-confirm', () => { beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) it('fires onHeard immediately when armed', () => { // Arrange const confirm = createApproveConfirm(1500) const onHeard = vi.fn() const onCommit = vi.fn() // Act confirm.arm(onHeard, onCommit) // Assert expect(onHeard).toHaveBeenCalledTimes(1) expect(onCommit).not.toHaveBeenCalled() }) it('fires onCommit after windowMs elapses', () => { // Arrange const confirm = createApproveConfirm(1500) const onHeard = vi.fn() const onCommit = vi.fn() // Act confirm.arm(onHeard, onCommit) vi.advanceTimersByTime(1499) expect(onCommit).not.toHaveBeenCalled() vi.advanceTimersByTime(1) // Assert expect(onCommit).toHaveBeenCalledTimes(1) }) it('never calls onCommit when cancel() runs before the window elapses', () => { // Arrange const confirm = createApproveConfirm(1500) const onHeard = vi.fn() const onCommit = vi.fn() confirm.arm(onHeard, onCommit) // Act vi.advanceTimersByTime(500) confirm.cancel() vi.advanceTimersByTime(10_000) // Assert expect(onCommit).not.toHaveBeenCalled() }) it('cancel() before any arm() is a no-op that does not throw', () => { // Arrange const confirm = createApproveConfirm(1500) // Act / Assert expect(() => confirm.cancel()).not.toThrow() }) it('re-arming clears the prior timer so only the latest arm commits', () => { // Arrange const confirm = createApproveConfirm(1500) const firstHeard = vi.fn() const firstCommit = vi.fn() const secondHeard = vi.fn() const secondCommit = vi.fn() // Act confirm.arm(firstHeard, firstCommit) vi.advanceTimersByTime(1000) confirm.arm(secondHeard, secondCommit) // Enough time for the FIRST window to have elapsed (1000 + 1000 = 2000 > 1500) // but not enough for the second window (armed at t=1000, needs t=2500). vi.advanceTimersByTime(1000) // Assert: first window's commit never fires, second hasn't fired yet either. expect(firstCommit).not.toHaveBeenCalled() expect(secondCommit).not.toHaveBeenCalled() expect(secondHeard).toHaveBeenCalledTimes(1) // Advance past the second window's own deadline. vi.advanceTimersByTime(500) expect(secondCommit).toHaveBeenCalledTimes(1) expect(firstCommit).not.toHaveBeenCalled() }) it('isArmed() is false before any arm()', () => { // Arrange const confirm = createApproveConfirm(1500) // Assert expect(confirm.isArmed()).toBe(false) }) it('isArmed() is true immediately after arm() and while the window is pending', () => { // Arrange const confirm = createApproveConfirm(1500) // Act confirm.arm(vi.fn(), vi.fn()) // Assert expect(confirm.isArmed()).toBe(true) vi.advanceTimersByTime(500) expect(confirm.isArmed()).toBe(true) }) it('isArmed() is false after onCommit fires', () => { // Arrange const confirm = createApproveConfirm(1500) confirm.arm(vi.fn(), vi.fn()) // Act vi.advanceTimersByTime(1500) // Assert expect(confirm.isArmed()).toBe(false) }) it('isArmed() is false after cancel()', () => { // Arrange const confirm = createApproveConfirm(1500) confirm.arm(vi.fn(), vi.fn()) // Act confirm.cancel() // Assert expect(confirm.isArmed()).toBe(false) }) it('uses a default windowMs of 1500 when not provided', () => { // Arrange const confirm = createApproveConfirm() const onCommit = vi.fn() // Act confirm.arm(vi.fn(), onCommit) vi.advanceTimersByTime(1499) expect(onCommit).not.toHaveBeenCalled() vi.advanceTimersByTime(1) // Assert expect(onCommit).toHaveBeenCalledTimes(1) }) })