Files
openclaw/apps/ios/Tests/LiveActivityPresentationArbiterTests.swift
2026-07-23 00:28:00 -05:00

231 lines
9.0 KiB
Swift

import Foundation
import Testing
@testable import OpenClaw
struct LiveActivityPresentationArbiterTests {
@Test func `voice sample buffer quantizes clamps and stays bounded`() {
var buffer = LiveActivityVoiceSampleBuffer(capacity: 3)
for level in [-1.0, 0.25, 0.5, 2.0] {
if let sample = LiveActivityVoiceSampleBuffer.quantize(level) {
buffer.append(sample)
}
}
#expect(buffer.payload == [64, 128, 255])
#expect(LiveActivityVoiceSampleBuffer.quantize(.infinity) == nil)
buffer.reset()
#expect(buffer.payload == nil)
}
@Test
func `attention outranks tools voice and connection`() {
let now = Date()
var arbiter = LiveActivityPresentationArbiter()
arbiter.setConnection(Self.request(status: .connecting, detail: nil, startedAt: now))
arbiter.setVoice(Self.request(status: .voiceListening, detail: nil, startedAt: now))
arbiter.startTool(
id: "tool-1",
request: Self.request(status: .toolRunning, detail: "first", startedAt: now))
arbiter.setAttention(Self.request(status: .approvalNeeded, detail: nil, startedAt: now))
#expect(arbiter.current?.state.status == .approvalNeeded)
arbiter.setAttention(nil)
#expect(arbiter.current?.state.status == .toolRunning)
}
@Test
func `parallel tools resume the newest remaining tool then voice`() {
let now = Date()
var arbiter = LiveActivityPresentationArbiter()
arbiter.setVoice(Self.request(status: .voiceActive, detail: nil, startedAt: now))
arbiter.startTool(
id: "tool-1",
request: Self.request(status: .toolRunning, detail: "first", startedAt: now))
arbiter.startTool(
id: "tool-2",
request: Self.request(status: .toolRunning, detail: "second", startedAt: now))
#expect(arbiter.activeToolCount == 2)
#expect(arbiter.current?.state.verbatimDetail == "second")
arbiter.endTool(id: "tool-2", sessionKey: "main")
#expect(arbiter.current?.state.verbatimDetail == "first")
arbiter.endTool(id: "tool-1", sessionKey: "main")
#expect(arbiter.current?.state.status == .voiceActive)
}
@Test
func `tool identities are scoped to their owning session`() {
let now = Date()
var arbiter = LiveActivityPresentationArbiter()
arbiter.startTool(
id: "shared-id",
request: Self.request(
status: .toolRunning,
detail: "main",
startedAt: now,
sessionKey: "main"))
arbiter.startTool(
id: "shared-id",
request: Self.request(
status: .toolRunning,
detail: "other",
startedAt: now,
sessionKey: "other"))
#expect(arbiter.activeToolCount == 2)
arbiter.endTool(id: "shared-id", sessionKey: "main")
#expect(arbiter.activeToolCount == 1)
#expect(arbiter.current?.sessionKey == "other")
}
@Test
func `presentation lifecycle only carries across the same owner`() {
let startedAt = Date(timeIntervalSince1970: 100)
let now = Date(timeIntervalSince1970: 500)
let existing = Self.request(
status: .connecting,
detail: nil,
startedAt: startedAt,
sessionKey: "main")
#expect(LiveActivityManager.lifecycleStartedAt(
existing: existing,
agentName: "Aiden",
sessionKey: "main",
now: now) == startedAt)
#expect(LiveActivityManager.lifecycleStartedAt(
existing: existing,
agentName: "Aiden",
sessionKey: "other",
now: now) == now)
#expect(LiveActivityManager.lifecycleStartedAt(
existing: existing,
agentName: "Luna",
sessionKey: "main",
now: now) == now)
}
@Test
func `voice status distinguishes active listening and speaking`() {
#expect(LiveActivityPresentationArbiter.voiceStatus(isListening: false, isSpeaking: false) == .voiceActive)
#expect(LiveActivityPresentationArbiter.voiceStatus(isListening: true, isSpeaking: false) == .voiceListening)
#expect(LiveActivityPresentationArbiter.voiceStatus(isListening: true, isSpeaking: true) == .voiceSpeaking)
}
@Test
func `voice sample history survives active phase changes`() {
#expect(LiveActivityManager.shouldResetVoiceSamples(previousStatus: nil))
#expect(!LiveActivityManager.shouldResetVoiceSamples(previousStatus: .voiceActive))
#expect(!LiveActivityManager.shouldResetVoiceSamples(previousStatus: .voiceListening))
#expect(!LiveActivityManager.shouldResetVoiceSamples(previousStatus: .voiceSpeaking))
}
@Test
func `reconnect clears connection state without hiding active voice`() {
let now = Date()
var arbiter = LiveActivityPresentationArbiter()
arbiter.setConnection(Self.request(status: .reconnecting, detail: nil, startedAt: now))
arbiter.setAttention(Self.request(status: .actionRequired, detail: nil, startedAt: now))
arbiter.setVoice(Self.request(status: .voiceListening, detail: nil, startedAt: now))
arbiter.clearConnectionState()
#expect(arbiter.attention == nil)
#expect(arbiter.connection == nil)
#expect(arbiter.current?.state.status == .voiceListening)
}
@Test
func `live voice permanently replaces a hydrated tool fallback`() {
let now = Date()
var arbiter = LiveActivityPresentationArbiter()
arbiter.adoptInitialHydratedToolFallback(
Self.request(status: .toolRunning, detail: "restored", startedAt: now))
arbiter.setVoice(Self.request(status: .voiceListening, detail: nil, startedAt: now))
#expect(arbiter.hydratedToolFallback == nil)
#expect(arbiter.current?.state.status == .voiceListening)
arbiter.setVoice(nil)
#expect(arbiter.current == nil)
}
@Test
func `hydration trusts renewed voice and tool stale dates`() {
let now = Date()
let originalStart = now.addingTimeInterval(-600)
let renewedStaleDate = now.addingTimeInterval(240)
#expect(LiveActivityManager.shouldHydrate(
status: .voiceSpeaking,
startedAt: originalStart,
staleDate: renewedStaleDate,
now: now))
#expect(LiveActivityManager.shouldHydrate(
status: .toolRunning,
startedAt: originalStart,
staleDate: renewedStaleDate,
now: now))
}
@Test
func `hydration rejects expired and old non-heartbeat presentations`() {
let now = Date()
let originalStart = now.addingTimeInterval(-600)
#expect(!LiveActivityManager.shouldHydrate(
status: .voiceListening,
startedAt: originalStart,
staleDate: now.addingTimeInterval(-1),
now: now))
#expect(!LiveActivityManager.shouldHydrate(
status: .reconnecting,
startedAt: originalStart,
staleDate: now.addingTimeInterval(240),
now: now))
}
@Test
func `voice live activity lifecycle is app owned`() throws {
let testsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
let sourcesDirectory = testsDirectory.deletingLastPathComponent().appendingPathComponent("Sources")
let appSource = try String(
contentsOf: sourcesDirectory.appendingPathComponent("OpenClawApp.swift"),
encoding: .utf8)
let chatSource = try String(
contentsOf: sourcesDirectory.appendingPathComponent("Design/ChatProTab.swift"),
encoding: .utf8)
let coordinatorSource = try String(
contentsOf: sourcesDirectory.appendingPathComponent(
"LiveActivity/VoiceLiveActivityCoordinator.swift"),
encoding: .utf8)
#expect(appSource.contains("if !Self.screenshotModeEnabled {"))
#expect(appSource.contains("self.voiceLiveActivityCoordinator.start(appModel: self.appModel)"))
#expect(coordinatorSource.contains("LiveActivityManager.shared.showVoice("))
#expect(coordinatorSource.contains("LiveActivityManager.shared.updateVoiceLevel("))
#expect(!chatSource.contains("syncVoiceLiveActivity"))
#expect(!chatSource.contains("LiveActivityManager.shared.showVoice("))
}
private static func request(
status: OpenClawActivityAttributes.ContentState.Status,
detail: String?,
startedAt: Date,
sessionKey: String = "main") -> LiveActivityPresentationRequest
{
LiveActivityPresentationRequest(
state: OpenClawActivityAttributes.ContentState(
status: status,
verbatimDetail: detail,
startedAt: startedAt),
staleDate: startedAt.addingTimeInterval(300),
agentName: "Aiden",
sessionKey: sessionKey)
}
}