Archived
259 lines
11 KiB
Swift
259 lines
11 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)
|
|
}
|
|
|
|
// 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()
|
|
)
|
|
}
|
|
}
|