import Foundation import Testing @testable import Voice /// Tests for ConversationStateModel phase transitions, specifically verifying /// that remote speech ending returns to listening (not thinking forever), /// matching the current web reference (Finding 9). @MainActor struct ConversationStateTests { @Test("remote speech ending returns to listening, not thinking") func remoteSpeechEndsToListening() { let model = ConversationStateModel() model.start() #expect(model.phase == .connecting) // Simulate agent speaking (EMA: 0.04, 0.064 — both above 0.02 threshold). model.tick(localAudioLevel: 0, remoteAudioLevel: 0.1) model.tick(localAudioLevel: 0, remoteAudioLevel: 0.1) #expect(model.phase == .speaking) // Agent goes silent. EMA decays: 0.0384, 0.02304 (still > 0.02), // then 0.013824 (< 0.02) → listening. Five zero ticks deterministically // cross the threshold. for _ in 0..<5 { model.tick(localAudioLevel: 0, remoteAudioLevel: 0) } #expect(model.phase == .listening) } @Test("user speaking transitions to listening") func userSpeakingToListening() { let model = ConversationStateModel() model.start() // User speaks. model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0) model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0) #expect(model.phase == .listening) // User stops speaking. model.tick(localAudioLevel: 0, remoteAudioLevel: 0) model.tick(localAudioLevel: 0, remoteAudioLevel: 0) #expect(model.phase == .listening) } @Test("agent speaking takes priority over user listening") func agentSpeakingPriority() { let model = ConversationStateModel() model.start() // Both speaking — agent wins. model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0.1) model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0.1) #expect(model.phase == .speaking) } @Test("stop resets to idle") func stopResetsToIdle() { let model = ConversationStateModel() model.start() model.tick(localAudioLevel: 0.1, remoteAudioLevel: 0) #expect(model.phase == .listening) model.stop() #expect(model.phase == .idle) } @Test("thinking chip is not shown when returning to listening") func noStaleThinkingChip() { let model = ConversationStateModel() model.start() // Agent speaks then goes silent. model.tick(localAudioLevel: 0, remoteAudioLevel: 0.1) model.tick(localAudioLevel: 0, remoteAudioLevel: 0.1) #expect(model.phase == .speaking) // Silence — should go to listening, not thinking. Five zero ticks // deterministically cross the EMA threshold below agentThreshold. for _ in 0..<5 { model.tick(localAudioLevel: 0, remoteAudioLevel: 0) } #expect(model.phase == .listening) #expect(!model.isThinkingChipVisible) } @Test("square LED presence responds to input and emphasizes assistant output") func ledPresenceAudioResponse() { let quiet = LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0), time: 0, reduceMotion: false) let listening = LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0.8), time: 0, reduceMotion: false) let speaking = LightwellView.dot(row: 10, column: 10, phase: .speaking(level: 0.8), time: 0, reduceMotion: false) #expect(listening.opacity > quiet.opacity) #expect(listening.scale > quiet.scale) #expect(speaking.opacity > listening.opacity) #expect(speaking.scale > listening.scale) } @Test("LED reduce motion freezes audio geometry and pending waves") func ledReduceMotion() { for phase: LightwellView.Phase in [.connecting, .thinking, .speaking(level: 0.8)] { #expect(LightwellView.dot(row: 8, column: 9, phase: phase, time: 0, reduceMotion: true) == LightwellView.dot(row: 8, column: 9, phase: phase, time: 7, reduceMotion: true)) } #expect(LightwellView.dot(row: 10, column: 10, phase: .listening(level: 0), time: 0, reduceMotion: true) == LightwellView.dot(row: 10, column: 10, phase: .listening(level: 1), time: 0, reduceMotion: true)) } @Test("LED state patterns are distinct and invalid signal levels remain bounded") func ledStatePatterns() { let dormant = LightwellView.dot(row: 10, column: 10, phase: .idle, time: 0, reduceMotion: false) let thinking = LightwellView.dot(row: 10, column: 10, phase: .thinking, time: 0, reduceMotion: true) #expect(thinking.opacity > dormant.opacity) for level in [Double.nan, Double.infinity, -1, 10] { let dot = LightwellView.dot(row: 10, column: 10, phase: .speaking(level: level), time: 0, reduceMotion: false) #expect(dot.opacity.isFinite && (0...1).contains(dot.opacity)) #expect(dot.scale.isFinite && dot.scale >= 1) } } // MARK: - Interruption begin mute decision (Iteration 08 Item 1) /// Pure decision seam test: verifies that the interruption-begin mute /// decision uses user intent (not a pre-await isMuted snapshot), so the /// begin A → end A/unmute → begin B sequence finishes muted. @Test("interruption begin mutes when user did not intentionally mute") func interruptionBeginShouldMuteUserNotMuted() { let session = makeSessionForDecisionTest() // User has not muted (userWantsMicMuted == false), text mode off, ready. #expect(session.interruptionBeginShouldMute( userWantsMicMuted: false, textMode: false, state: .ready )) } @Test("interruption begin does not mute when user intentionally muted") func interruptionBeginShouldNotMuteUserMuted() { let session = makeSessionForDecisionTest() // User explicitly muted (userWantsMicMuted == true): hardware is // already off, no need for the interruption to mute again. #expect(!session.interruptionBeginShouldMute( userWantsMicMuted: true, textMode: false, state: .ready )) } @Test("interruption begin does not mute in text mode") func interruptionBeginShouldNotMuteTextMode() { let session = makeSessionForDecisionTest() // Text mode invariant: mic must stay off by design, not by interruption. #expect(!session.interruptionBeginShouldMute( userWantsMicMuted: false, textMode: true, state: .ready )) } @Test("interruption begin does not mute when session not ready") func interruptionBeginShouldNotMuteNotReady() { let session = makeSessionForDecisionTest() // Session is not in a state where the mic would be active. #expect(!session.interruptionBeginShouldMute( userWantsMicMuted: false, textMode: false, state: .idle )) #expect(!session.interruptionBeginShouldMute( userWantsMicMuted: false, textMode: false, state: .connecting )) } /// Simulates the begin A → end A/unmute → begin B transient-state case. /// After A's unmute completes (userWantsMicMuted cleared by successful /// enable), B's decision sees userWantsMicMuted == false and mutes. @Test("begin A end A unmute then begin B finishes muted") func beginEndBeginFinishesMuted() { let session = makeSessionForDecisionTest() // Phase 1: Begin A. User did not mute, so A should mute. #expect(session.interruptionBeginShouldMute( userWantsMicMuted: false, textMode: false, state: .ready )) // After A's mute succeeds: isMuted = true (interruption-owned), // userWantsMicMuted remains false (user didn't mute). // Phase 2: End A with shouldResume. The unmute succeeds, which calls // enableMicrophone() semantics: isMuted = false, userWantsMicMuted = false. // (In the real code, .ended sets isMuted = false directly; userWantsMicMuted // was never set to true because the user didn't mute.) // Phase 3: Begin B arrives while A's unmute is in flight. After awaiting // the predecessor (A's unmute), B decides using current state: // userWantsMicMuted == false, textMode == false, state == .ready. #expect(session.interruptionBeginShouldMute( userWantsMicMuted: false, textMode: false, state: .ready )) // B mutes → final state is muted during interruption B. ✓ } // MARK: - Room callback gating (Iteration 09 Item 1) /// Pure predicate test: verifies that direct RoomDelegate callbacks that /// mutate published ephemeral fields are accepted only for active lifecycle /// states and rejected during teardown or terminal states. @Test("room callback accepted for active states when not tearing down") func roomCallbackAcceptedForActiveStates() { let session = makeSessionForDecisionTest() #expect(session.shouldAcceptRoomCallback(state: .connecting, isTearingDown: false)) #expect(session.shouldAcceptRoomCallback(state: .waitingAgent, isTearingDown: false)) #expect(session.shouldAcceptRoomCallback(state: .ready, isTearingDown: false)) } @Test("room callback rejected for idle and failed states") func roomCallbackRejectedForInactiveStates() { let session = makeSessionForDecisionTest() #expect(!session.shouldAcceptRoomCallback(state: .idle, isTearingDown: false)) #expect(!session.shouldAcceptRoomCallback(state: .failed("err"), isTearingDown: false)) } @Test("room callback rejected during teardown even for active states") func roomCallbackRejectedDuringTeardown() { let session = makeSessionForDecisionTest() // Even if the state were still .ready (before the final reset), // isTearingDown == true must reject all callbacks. #expect(!session.shouldAcceptRoomCallback(state: .ready, isTearingDown: true)) #expect(!session.shouldAcceptRoomCallback(state: .connecting, isTearingDown: true)) #expect(!session.shouldAcceptRoomCallback(state: .waitingAgent, isTearingDown: true)) } @Test("room callback rejected for tearingDown state") func roomCallbackRejectedForTearingDownState() { let session = makeSessionForDecisionTest() #expect(!session.shouldAcceptRoomCallback(state: .tearingDown, isTearingDown: false)) #expect(!session.shouldAcceptRoomCallback(state: .tearingDown, isTearingDown: true)) } /// Simulates the exact scenario from Finding 1: failure teardown clears /// ephemeral fields, then an old-room participantDidConnect callback arrives /// during the teardownRoom() await. The callback must be rejected because /// isTearingDown is true. Even if it somehow passed, the final resetEphemeralFields() /// before .failed publication erases any repopulated values. @Test("agent callback during failure teardown is rejected") func agentCallbackDuringFailureTeardownRejected() { let session = makeSessionForDecisionTest() // Simulate: teardownForFailure has set state = .tearingDown and // isTearingDown = true (via teardownRoom). An old-room participantDidConnect // callback arrives during the room.disconnect() await. let shouldAccept = session.shouldAcceptRoomCallback( state: .tearingDown, isTearingDown: true ) #expect(!shouldAccept) // Even if we model the state as still .ready (edge case where the // state hasn't been updated yet), isTearingDown == true rejects it. let shouldAcceptEdge = session.shouldAcceptRoomCallback( state: .ready, isTearingDown: true ) #expect(!shouldAcceptEdge) } /// Simulates a reconnecting callback arriving during teardown: must be rejected. @Test("reconnecting callback during teardown is rejected") func reconnectingCallbackDuringTeardownRejected() { let session = makeSessionForDecisionTest() // (.connected → .reconnecting) would set isReconnecting = true, // but the gate rejects it because isTearingDown == true. #expect(!session.shouldAcceptRoomCallback(state: .ready, isTearingDown: true)) } private func makeSessionForDecisionTest() -> VoiceSession { guard case .success(let config) = ServerConfig.parse("https://192.168.1.100") else { fatalError("Invalid test server URL") } return VoiceSession( config: config, tokenClient: TokenClient(config: config), settings: SettingsStore() ) } }