This repository has been archived on 2026-10-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
hope-voice-ios/Tests/ConversationStateTests.swift
T

292 lines
13 KiB
Swift

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()
)
}
}