349 lines
16 KiB
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) }
|
|
}
|