Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/VoiceNoteRecorderTests.swift
Peter Steinberger f95ca90378 fix(ios): persist voice notes through offline delivery (#101236)
* fix(ios): persist voice note delivery state

* fix(ios): pin chats during attachment staging

* fix(ios): keep voice notes on captured chat

* fix(ios): keep voice notes durable through staging

* fix(ios): harden voice note lifecycle

* test(ios): satisfy strict concurrency in voice note proof

* fix(ios): keep legacy attachment sends live

* fix(ios): retain legacy drafts during outbox restore

* fix(ios): pin voice note recipient presentation

* fix(ios): preserve confirmed voice note duration

* chore(i18n): refresh voice note source inventory

* chore: leave release notes to release automation
2026-07-07 01:21:16 +01:00

233 lines
8.0 KiB
Swift

import Foundation
import XCTest
@testable import OpenClawChatUI
@MainActor
private final class FakeVoiceNoteAudioCapture: VoiceNoteAudioCapture {
var permissionGranted = true
var duration: TimeInterval = 12.5
var startError: Error?
var startCount = 0
var cancelCount = 0
var activeURL: URL?
var onStart: (() -> Void)?
var failureHandler: (@MainActor () -> Void)?
func requestPermission() async -> Bool {
self.permissionGranted
}
func start(url: URL) throws {
self.onStart?()
if let startError { throw startError }
self.startCount += 1
self.activeURL = url
try Data("voice-note".utf8).write(to: url)
}
func stop() -> TimeInterval {
self.duration
}
func cancel() {
self.cancelCount += 1
}
func setFailureHandler(_ handler: @escaping @MainActor () -> Void) {
self.failureHandler = handler
}
func failCapture() {
self.failureHandler?()
}
}
final class VoiceNoteRecorderTests: XCTestCase {
@MainActor
func testStartAndFinishProduceRecordingWithDuration() async throws {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
var activeChanges: [Bool] = []
recorder.onRecordingActiveChanged = { activeChanges.append($0) }
capture.onStart = { XCTAssertEqual(activeChanges, [true]) }
let started = await recorder.start()
XCTAssertTrue(started)
guard case .recording = recorder.state else {
return XCTFail("Expected recording state")
}
let result = try XCTUnwrap(recorder.finish())
XCTAssertEqual(result.durationSeconds, 12.5)
XCTAssertEqual(recorder.state, .finished(recording: result))
XCTAssertEqual(recorder.completedRecording, result)
XCTAssertEqual(activeChanges, [true, false])
XCTAssertTrue(FileManager.default.fileExists(atPath: result.fileURL.path))
try FileManager.default.removeItem(at: result.fileURL)
}
@MainActor
func testCompletedRecordingHasOneStagingOwner() async throws {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
let started = await recorder.start()
XCTAssertTrue(started)
let recording = try XCTUnwrap(recorder.finish())
XCTAssertEqual(recorder.claimCompletedRecording(), recording)
XCTAssertNil(recorder.claimCompletedRecording())
XCTAssertTrue(recorder.ownsPendingChatAttachment)
recorder.completeStaging(recording)
XCTAssertEqual(recorder.state, .idle)
XCTAssertFalse(recorder.ownsPendingChatAttachment)
try FileManager.default.removeItem(at: recording.fileURL)
}
@MainActor
func testCancelReturnsToIdleAndDeletesTemporaryFile() async throws {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
let started = await recorder.start()
XCTAssertTrue(started)
let fileURL = try XCTUnwrap(capture.activeURL)
XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path))
recorder.cancel()
XCTAssertEqual(recorder.state, .idle)
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
XCTAssertEqual(capture.cancelCount, 1)
}
@MainActor
func testSuccessiveRecordingsUseUniqueTemporaryFiles() async throws {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(
capture: capture,
now: { Date(timeIntervalSince1970: 0) })
let firstStarted = await recorder.start()
XCTAssertTrue(firstStarted)
let firstURL = try XCTUnwrap(capture.activeURL)
recorder.cancel()
let secondStarted = await recorder.start()
XCTAssertTrue(secondStarted)
let secondURL = try XCTUnwrap(capture.activeURL)
XCTAssertNotEqual(firstURL, secondURL)
recorder.cancel()
}
@MainActor
func testDurationCapAutoFinishes() async throws {
let capture = FakeVoiceNoteAudioCapture()
capture.duration = 0.25
let recorder = OpenClawVoiceNoteRecorder(
capture: capture,
durationLimit: 0,
timerIntervalNanoseconds: 1_000_000)
let started = await recorder.start()
XCTAssertTrue(started)
try await waitUntil("voice note auto-finished") {
await MainActor.run { recorder.completedRecording != nil }
}
let result = try XCTUnwrap(recorder.completedRecording)
XCTAssertEqual(result.durationSeconds, 0.25)
XCTAssertEqual(recorder.state, .finished(recording: result))
try FileManager.default.removeItem(at: result.fileURL)
}
@MainActor
func testPermissionDeniedBecomesUserVisibleFailure() async {
let capture = FakeVoiceNoteAudioCapture()
capture.permissionGranted = false
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
let started = await recorder.start()
XCTAssertFalse(started)
guard case let .failed(message) = recorder.state else {
return XCTFail("Expected failure state")
}
XCTAssertTrue(message.contains("Microphone access"))
XCTAssertEqual(capture.startCount, 0)
}
@MainActor
func testCaptureStartFailureReleasesRecordingActivity() async {
let capture = FakeVoiceNoteAudioCapture()
capture.startError = NSError(domain: "VoiceNoteRecorderTests", code: 1)
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
var activeChanges: [Bool] = []
recorder.onRecordingActiveChanged = { activeChanges.append($0) }
let started = await recorder.start()
XCTAssertFalse(started)
XCTAssertEqual(activeChanges, [true, false])
guard case .failed = recorder.state else {
return XCTFail("Expected failure state")
}
}
@MainActor
func testCaptureInterruptionFailsAndDeletesTemporaryFile() async throws {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
var activeChanges: [Bool] = []
recorder.onRecordingActiveChanged = { activeChanges.append($0) }
let started = await recorder.start()
XCTAssertTrue(started)
let fileURL = try XCTUnwrap(capture.activeURL)
capture.failCapture()
XCTAssertEqual(activeChanges, [true, false])
XCTAssertEqual(capture.cancelCount, 1)
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
guard case let .failed(message) = recorder.state else {
return XCTFail("Expected failure state")
}
XCTAssertTrue(message.contains("interrupted"))
}
@MainActor
func testStartIsRefusedWhileAlreadyRecording() async {
let capture = FakeVoiceNoteAudioCapture()
let recorder = OpenClawVoiceNoteRecorder(capture: capture)
let firstStart = await recorder.start()
let secondStart = await recorder.start()
XCTAssertTrue(firstStart)
XCTAssertFalse(secondStart)
XCTAssertEqual(capture.startCount, 1)
recorder.cancel()
}
func testDurationLabelBoundsMalformedHistoryValues() {
XCTAssertEqual(openClawVoiceNoteDurationLabel(.infinity), "0:00")
XCTAssertEqual(openClawVoiceNoteDurationLabel(.nan), "0:00")
XCTAssertEqual(openClawVoiceNoteDurationLabel(1e100), "3:00")
XCTAssertEqual(openClawVoiceNoteDurationLabel(-1), "0:00")
}
@MainActor
func testRecordButtonRequiresAttachmentInput() {
let recorder = OpenClawVoiceNoteRecorder(capture: FakeVoiceNoteAudioCapture())
let control = OpenClawChatVoiceNoteControl(recorder: recorder, isTalkActive: false)
let button = OpenClawVoiceNoteButton(
control: control,
compact: false,
isComposerEnabled: true,
isAttachmentInputEnabled: false)
XCTAssertFalse(button.isRecordingEnabled)
}
}