Files
Termind/FrameworkTests/UserInputSpeechTests.swift

349 lines
16 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import Testing
@testable import TermindApplication
@Suite @MainActor
struct UserInputSpeechTests {
@Test func requestRetirementReleasesTheMicrophoneWithoutAViewCallback() async throws {
let (conversation, _) = fixture()
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) {}
try await eventually { session.isRecording }
conversation.cancelUserInput()
try await eventually { !session.isActive }
#expect(speech.ownerID == nil)
#expect(service.cancelCount == 1)
}
@Test func aQueuedRequestDoesNotInterruptTheCurrentQuestionsRecording() async throws {
let (conversation, request) = fixture()
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) {}
try await eventually { session.isRecording }
conversation.present(
AgentUserInputRequest(questions: [AgentUserQuestion(id: "queued", prompt: "Next", kind: .text)]))
await Task.yield()
#expect(conversation.pendingUserInput?.id == request.id)
#expect(session.isRecording)
session.cancel()
conversation.cancelUserInput()
conversation.cancelUserInput()
}
@Test func interactionCannotAdvanceOrSelectAnOptionWhileSpeechIsActive() async throws {
let choice = AgentUserInputChoice(
options: [AgentUserInputOption(id: "option", label: "Option")], allowsCustomAnswer: true)
let (conversation, _) = fixture(kind: .choice(choice))
conversation.setUserInputCustomAnswer("Existing", for: "answer")
let interaction = UserInputInteractionModel()
let speech = SpeechTranscriptionCoordinator(service: QuestionSpeechService())
interaction.speech.start(coordinator: speech, conversation: conversation) {}
try await eventually { interaction.speech.isRecording }
#expect(!interaction.canAdvance(in: conversation))
interaction.advance(in: conversation)
interaction.selectOption("option", in: "answer", conversation: conversation)
#expect(conversation.currentUserInputQuestionIndex == 0)
#expect(conversation.userInputDraft(for: "answer").text == "Existing")
#expect(conversation.userInputDraft(for: "answer").selection.isEmpty)
interaction.cancelRequest(in: conversation)
}
@Test(arguments: ["previous", "skip", "cancel"])
func interactionCancelsSpeechBeforeLeavingTheQuestion(action: String) async throws {
let (conversation, _) = fixture()
if action == "previous" { conversation.skipUserInputQuestion() }
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let interaction = UserInputInteractionModel()
interaction.speech.start(coordinator: speech, conversation: conversation) {}
try await eventually { interaction.speech.isRecording }
switch action {
case "previous": interaction.previous(in: conversation)
case "skip": interaction.skip(in: conversation)
default: interaction.cancelRequest(in: conversation)
}
#expect(!interaction.speech.isActive)
#expect(speech.ownerID == nil)
#expect(service.cancelCount == 1)
service.emit(.final("Late answer"))
#expect(conversation.userInputDrafts.values.allSatisfy { $0.text.isEmpty })
conversation.cancelUserInput()
}
@Test(arguments: ["", "Existing", "Existing\n"])
func finalSpeechAppendsToTheAnswerWithoutSubmittingOrChangingChat(initial: String) async throws {
let (conversation, request) = fixture()
conversation.setUserInputCustomAnswer(initial, for: "answer")
conversation.composer.draft = "Chat draft"
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
var applied = 0
session.start(coordinator: speech, conversation: conversation) { applied += 1 }
#expect(session.isActive)
try await eventually { session.isRecording }
service.emit(.partial("Partial"))
#expect(conversation.userInputDraft(for: "answer").text == initial)
session.stop()
#expect(speech.phase == .transcribing)
#expect(session.isActive)
#expect(!session.isRecording)
service.emit(.final(" Voice text \n"))
let separator = initial.isEmpty || initial.last?.isWhitespace == true ? "" : " "
#expect(conversation.userInputDraft(for: "answer").text == initial + separator + "Voice text")
#expect(conversation.pendingUserInput?.id == request.id)
#expect(conversation.currentUserInputQuestionIndex == 0)
#expect(conversation.composer.draft == "Chat draft")
#expect(conversation.messages.isEmpty)
#expect(applied == 1)
#expect(!session.isActive)
conversation.cancelUserInput()
}
@Test(arguments: [false, true])
func spokenCustomAnswersFollowExistingChoiceSemantics(multiple: Bool) {
let choice = AgentUserInputChoice(
options: [AgentUserInputOption(id: "option", label: "Option")],
allowsCustomAnswer: true, allowsMultipleAnswers: multiple)
let (conversation, request) = fixture(kind: .choice(choice))
conversation.toggleUserInputOption("option", in: "answer")
#expect(conversation.applyUserInputTranscription("Custom", requestID: request.id, questionID: "answer"))
#expect(conversation.userInputDraft(for: "answer").selection == (multiple ? ["option"] : []))
#expect(conversation.userInputDraft(for: "answer").text == "Custom")
#expect(conversation.pendingUserInput?.id == request.id)
conversation.cancelUserInput()
}
@Test(arguments: [false, true])
func cancellingRecordingOrFinalizationPreservesTheOriginalAnswer(finishing: Bool) async throws {
let (conversation, _) = fixture()
conversation.setUserInputCustomAnswer("Original", for: "answer")
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
try await eventually { session.isRecording }
if finishing { session.stop() }
session.cancel()
service.emit(.final("Late text"))
#expect(!session.isActive)
#expect(speech.ownerID == nil)
#expect(service.cancelCount == 1)
#expect(conversation.userInputDraft(for: "answer").text == "Original")
conversation.cancelUserInput()
}
@Test func cancellationBeforeTheStartTaskRunsCannotAcquireTheMicrophone() async {
let (conversation, _) = fixture()
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
session.cancel()
await Task.yield()
#expect(service.startCount == 0)
#expect(speech.ownerID == nil)
#expect(!session.isActive)
conversation.cancelUserInput()
}
@Test func aRetiredQuestionBeforeTheStartTaskRunsDoesNotLeaveTheFieldBusy() async throws {
let (conversation, _) = fixture()
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
conversation.cancelUserInput()
try await eventually { !session.isActive }
#expect(service.startCount == 0)
#expect(speech.ownerID == nil)
}
@Test func permissionCompletionAfterLeavingTheQuestionCannotStartRecording() async throws {
let (conversation, _) = fixture()
let service = QuestionSpeechService()
service.suspendsAuthorization = true
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
try await eventually { service.authorization != nil }
#expect(session.isActive)
session.cancel()
service.authorization?.resume()
service.authorization = nil
await Task.yield()
#expect(service.startCount == 0)
#expect(speech.ownerID == nil)
#expect(!session.isActive)
conversation.cancelUserInput()
}
@Test(arguments: ["question", "replacement", "cancel"])
func aLateFinalResultCannotPopulateAnotherQuestionOrRequest(change: String) async throws {
let (conversation, _) = fixture()
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
try await eventually { session.isRecording }
session.stop()
if change == "question" {
conversation.skipUserInputQuestion()
} else {
conversation.cancelUserInput()
if change == "replacement" {
conversation.present(
AgentUserInputRequest(questions: [
AgentUserQuestion(id: "answer", prompt: "New request", kind: .text)
]))
}
}
// Deliberately deliver before the view's onChange cancellation runs.
service.emit(.final("Stale text"))
#expect(conversation.userInputDraft(for: "answer").text.isEmpty)
#expect(conversation.userInputDraft(for: "second").text.isEmpty)
#expect(conversation.composer.draft.isEmpty)
session.cancel()
conversation.cancelUserInput()
}
@Test func aTimedOutRequestRejectsTheFinalResult() async throws {
let (conversation, original) = fixture()
conversation.cancelUserInput()
var request = original
request.timeout = .milliseconds(40)
conversation.present(request)
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
try await eventually { session.isRecording }
try await eventually { conversation.pendingUserInput == nil }
service.emit(.final("Late text"))
#expect(conversation.userInputDrafts.isEmpty)
session.cancel()
}
@Test func aQuestionCannotStealOrCancelAnotherSurfacesMicrophone() async throws {
let (conversation, _) = fixture()
let service = QuestionSpeechService()
let speech = SpeechTranscriptionCoordinator(service: service)
let composerOwner = UUID()
await speech.start(ownerID: composerOwner) { _, _ in }
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
session.cancel()
#expect(!session.isActive)
#expect(speech.ownerID == composerOwner)
#expect(service.startCount == 1)
#expect(service.cancelCount == 0)
speech.cancel(ownerID: composerOwner)
session.start(coordinator: speech, conversation: conversation) {}
try await eventually { session.isRecording }
let secondSurface = UserInputSpeechSession()
secondSurface.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
secondSurface.cancel()
#expect(speech.ownerID == session.ownerID)
session.cancel()
conversation.cancelUserInput()
}
@Test(arguments: ["empty", "noSpeech", "permission", "failure"])
func unsuccessfulSpeechPreservesAnswersAndReportsOnlyActionableErrors(outcome: String) async throws {
let (conversation, _) = fixture()
conversation.setUserInputCustomAnswer("Original", for: "answer")
let service = QuestionSpeechService()
if outcome == "permission" { service.authorizationError = .init(.microphonePermissionDenied) }
let speech = SpeechTranscriptionCoordinator(service: service)
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
if outcome == "permission" {
try await eventually { session.error != nil }
} else {
try await eventually { session.isRecording }
switch outcome {
case "empty": service.emit(.final(" \n"))
case "noSpeech": service.emit(.failure(.init(.noSpeech)))
default: service.emit(.failure(.init(.recognitionFailed)))
}
}
#expect(!session.isActive)
#expect((session.error != nil) == (outcome == "permission" || outcome == "failure"))
#expect(conversation.userInputDraft(for: "answer").text == "Original")
session.dismissError()
#expect(session.error == nil)
conversation.cancelUserInput()
}
@Test(arguments: [false, true])
func secretAndClosedChoiceQuestionsCannotStartSpeech(secret: Bool) {
let kind: AgentUserQuestion.Kind =
secret
? .secret
: .choice(
AgentUserInputChoice(
options: [AgentUserInputOption(id: "option", label: "Option")], allowsCustomAnswer: false))
let (conversation, request) = fixture(kind: kind)
let speech = SpeechTranscriptionCoordinator(service: QuestionSpeechService())
let session = UserInputSpeechSession()
session.start(coordinator: speech, conversation: conversation) { Issue.record("Unexpected completion") }
#expect(!session.isActive)
#expect(!conversation.applyUserInputTranscription("Text", requestID: request.id, questionID: "answer"))
#expect(conversation.secretInputDraft.isEmpty)
#expect(conversation.userInputDrafts.isEmpty)
conversation.cancelUserInput()
}
private func fixture(kind: AgentUserQuestion.Kind = .text) -> (ConversationModel, AgentUserInputRequest) {
let conversation = ConversationModel()
let request = AgentUserInputRequest(questions: [
AgentUserQuestion(id: "answer", prompt: "Answer", kind: kind),
AgentUserQuestion(id: "second", prompt: "Second", kind: .text),
])
conversation.present(request)
return (conversation, request)
}
private func eventually(_ condition: @MainActor () -> Bool) async throws {
for _ in 0..<100 {
if condition() { return }
try await Task.sleep(for: .milliseconds(5))
}
try #require(condition())
}
}
@MainActor
private final class QuestionSpeechService: SpeechTranscriptionService {
var handler: (@MainActor (SpeechTranscriptionEvent) -> Void)?
var suspendsAuthorization = false
var authorization: CheckedContinuation<Void, Never>?
var authorizationError: SpeechTranscriptionFailure?
var startCount = 0
var cancelCount = 0
func requestAuthorization() async throws {
if suspendsAuthorization { await withCheckedContinuation { authorization = $0 } }
if let authorizationError { throw authorizationError }
}
func start(locale: Locale, eventHandler: @escaping @MainActor (SpeechTranscriptionEvent) -> Void) throws {
startCount += 1
handler = eventHandler
}
func finish() {}
func cancel() { cancelCount += 1 }
func emit(_ event: SpeechTranscriptionEvent) { handler?(event) }
}