Files
openclaw/apps/ios/Tests/VoiceWakeManagerStateTests.swift
NianJiu 067edbcb2d fix(ios): make Talk audio lifecycle-safe (#103072)
* fix(ios): cancel queued push-to-talk invokes

* fix(ios): make push-to-talk cancellation lifecycle-safe

* style(ios): use switch expression for PTT result

* fix(ios): bind audio capture to lifecycle owners

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* style(ios): format rebased lifecycle changes

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): preserve routing hydration across reconnects

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): cancel routing hydration on target switch

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* chore(ios): refresh native i18n inventory

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(i18n): preserve native localization IDs

* chore: leave changelog to release automation

* refactor(apple): split lifecycle and gateway helpers

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>

* fix(ios): make lifecycle parsing nonisolated

* fix(ios): consume canonical Talk events

---------

Co-authored-by: NianJiuZst <180004567+NianJiuZst@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-10 15:03:40 +01:00

146 lines
5.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import SwabbleKit
import Testing
@testable import OpenClaw
private actor VoiceWakeCommandBarrier {
private var entered = false
private var enteredWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseContinuation: CheckedContinuation<Void, Never>?
private(set) var observedCancellation: Bool?
func suspend() async {
self.entered = true
for waiter in self.enteredWaiters {
waiter.resume()
}
self.enteredWaiters.removeAll()
await withCheckedContinuation { continuation in
self.releaseContinuation = continuation
}
self.observedCancellation = Task.isCancelled
}
func waitUntilEntered() async {
if self.entered {
return
}
await withCheckedContinuation { continuation in
self.enteredWaiters.append(continuation)
}
}
func release() {
self.releaseContinuation?.resume()
self.releaseContinuation = nil
}
}
@Suite(.serialized) struct VoiceWakeManagerStateTests {
@Test @MainActor func `handle recognition callback restarts on error`() async {
let manager = VoiceWakeManager._test_withoutRestartDelays()
manager.isEnabled = true
manager.isListening = true
let recognitionGeneration = manager._test_recognitionGeneration()
manager._test_handleRecognitionCallback(transcript: nil, segments: [], errorText: "boom")
#expect(manager.statusText.contains("Recognizer error") == true)
#expect(manager.isListening == false)
#expect(manager._test_recognitionGeneration() > recognitionGeneration)
await manager._test_waitForScheduledStart()
#expect(manager.statusText == "Voice Wake isnt supported on Simulator")
}
@Test @MainActor func `handle recognition callback dispatches command`() async throws {
let manager = VoiceWakeManager()
manager.triggerWords = ["openclaw"]
manager.isEnabled = true
actor CaptureBox {
var value: String?
func set(_ next: String) {
self.value = next
}
}
let capture = CaptureBox()
manager.configure { cmd in
await capture.set(cmd)
}
let transcript = "openclaw hello"
let triggerRange = try #require(transcript.range(of: "openclaw"))
let helloRange = try #require(transcript.range(of: "hello"))
let segments = [
WakeWordSegment(text: "openclaw", start: 0.0, duration: 0.2, range: triggerRange),
WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: helloRange),
]
manager._test_handleRecognitionCallback(transcript: transcript, segments: segments, errorText: nil)
#expect(manager.lastTriggeredCommand == "hello")
#expect(manager.statusText == "Triggered")
try? await Task.sleep(nanoseconds: 300_000_000)
#expect(await capture.value == "hello")
}
@Test @MainActor func `suppression cancels an admitted command before dispatch`() async throws {
let manager = VoiceWakeManager._test_withoutRestartDelays()
let barrier = VoiceWakeCommandBarrier()
actor CaptureBox {
var value: String?
func set(_ next: String) {
self.value = next
}
}
let capture = CaptureBox()
manager.triggerWords = ["openclaw"]
manager.isEnabled = true
manager.isListening = true
manager.configure { command in
await barrier.suspend()
guard !Task.isCancelled else { return }
await capture.set(command)
}
let transcript = "openclaw hello"
let triggerRange = try #require(transcript.range(of: "openclaw"))
let commandRange = try #require(transcript.range(of: "hello"))
manager._test_handleRecognitionCallback(
transcript: transcript,
segments: [
WakeWordSegment(text: "openclaw", start: 0, duration: 0.2, range: triggerRange),
WakeWordSegment(text: "hello", start: 0.8, duration: 0.2, range: commandRange),
],
errorText: nil)
await barrier.waitUntilEntered()
manager.setSuppressedByPushToTalk(true)
await barrier.release()
for _ in 0..<100 {
if await barrier.observedCancellation != nil {
break
}
await Task.yield()
}
#expect(await barrier.observedCancellation == true)
#expect(await capture.value == nil)
}
@Test @MainActor func `Voice Wake deactivates only its owned audio session`() {
var deactivationCount = 0
let manager = VoiceWakeManager._test_withoutRestartDelays {
deactivationCount += 1
}
manager.stop()
#expect(deactivationCount == 0)
manager._test_setAudioSessionIsActive(true)
manager.stop()
manager.stop()
#expect(deactivationCount == 1)
}
}