mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-12 20:36:04 +00:00
* 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
233 lines
8.0 KiB
Swift
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)
|
|
}
|
|
}
|