Files
OSGKeyboard/OSGKeyboardTests/AIQuestionServiceTests.swift
T
Rocky fb97ec2937 feat(keyboard): unify assistant voice and AI workflows
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
2026-08-16 12:07:06 +08:00

222 lines
7.6 KiB
Swift

import XCTest
@testable import OSGKeyboardShared
final class AIQuestionServiceTests: XCTestCase {
func testSuccessfulTurnsAreRetainedAndOldestTurnIsTrimmed() async throws {
let client = CapturingAIClient()
let conversations = AIConversationStore()
let service = AIQuestionService(client: client, conversations: conversations)
let conversationID = UUID()
for index in 0...AIQuestionLimits.retainedConversationRounds {
client.nextAnswer = "答案\(index)"
let answer = try await service.answer(
question: "问题\(index)",
conversationID: conversationID,
targetLocaleID: TranslationLanguageCatalog.offLocaleId
)
await service.commitSuccessfulTurn(
question: "问题\(index)",
answer: answer,
conversationID: conversationID
)
}
let turns = await conversations.turns(for: conversationID)
XCTAssertEqual(turns.count, AIQuestionLimits.retainedConversationRounds)
XCTAssertEqual(turns.first?.question, "问题1")
XCTAssertEqual(turns.last?.answer, "答案6")
}
func testQuestionIsPassedWithoutCleanup() async throws {
let client = CapturingAIClient()
let service = AIQuestionService(
client: client,
conversations: AIConversationStore()
)
let rawQuestion = "嗯 帮我回答这个?"
_ = try await service.answer(
question: rawQuestion,
conversationID: UUID(),
targetLocaleID: "en"
)
XCTAssertEqual(client.lastMessages?.last, .user(rawQuestion))
let systemPrompt = client.lastMessages?.first?.content ?? ""
XCTAssertTrue(systemPrompt.contains("Reply in English"))
XCTAssertTrue(systemPrompt.contains("explicitly asks for another output language"))
}
func testAnswerDoesNotEnterContextUntilHostCommitsTerminalResult() async throws {
let client = CapturingAIClient()
let conversations = AIConversationStore()
let service = AIQuestionService(client: client, conversations: conversations)
let conversationID = UUID()
let answer = try await service.answer(
question: "会被取消的问题",
conversationID: conversationID,
targetLocaleID: TranslationLanguageCatalog.offLocaleId
)
let turnsBeforeCommit = await conversations.turns(for: conversationID)
XCTAssertTrue(turnsBeforeCommit.isEmpty)
await service.commitSuccessfulTurn(
question: "会被取消的问题",
answer: answer,
conversationID: conversationID
)
let turnsAfterCommit = await conversations.turns(for: conversationID)
XCTAssertEqual(turnsAfterCommit.count, 1)
}
func testAnswerIsBoundedByCharacterCount() {
let oversized = String(repeating: "答", count: AIQuestionLimits.maximumAnswerCharacterCount + 100)
let result = AIQuestionService.boundedAnswer(oversized)
XCTAssertEqual(result.count, AIQuestionLimits.maximumAnswerCharacterCount)
XCTAssertTrue(result.hasSuffix("…"))
}
func testSystemPromptIncludesResponseLengthGuidance() {
let shortPrompt = AIQuestionPromptComposer.systemPrompt(
targetLocaleID: TranslationLanguageCatalog.offLocaleId,
responseLength: .short
)
let mediumPrompt = AIQuestionPromptComposer.systemPrompt(
targetLocaleID: TranslationLanguageCatalog.offLocaleId,
responseLength: .medium
)
let detailedPrompt = AIQuestionPromptComposer.systemPrompt(
targetLocaleID: TranslationLanguageCatalog.offLocaleId,
responseLength: .detailed
)
XCTAssertTrue(shortPrompt.contains(AIResponseLength.short.promptGuidance))
XCTAssertTrue(mediumPrompt.contains(AIResponseLength.medium.promptGuidance))
XCTAssertTrue(detailedPrompt.contains(AIResponseLength.detailed.promptGuidance))
XCTAssertTrue(mediumPrompt.contains("Treat the length guidance as a preference"))
}
func testAnswerUsesConfiguredResponseLengthInSystemPrompt() async throws {
let client = CapturingAIClient()
let service = AIQuestionService(
client: client,
conversations: AIConversationStore(),
responseLength: .short
)
_ = try await service.answer(
question: "天气怎么样",
conversationID: UUID(),
targetLocaleID: TranslationLanguageCatalog.offLocaleId
)
XCTAssertTrue(
client.lastMessages?.first?.content.contains(AIResponseLength.short.promptGuidance) == true
)
}
func testStreamingPartialsAccumulateAndRestartClearsDraft() async throws {
let client = StreamingStubAIClient(events: [
.delta("你好"),
.delta(",世界"),
.restart,
.delta("最终答案"),
])
let service = AIQuestionService(
client: client,
conversations: AIConversationStore()
)
// Box avoids Swift 6 "mutation of captured var in concurrently-executing code".
final class PartialBox: @unchecked Sendable {
var values: [String] = []
}
let partials = PartialBox()
let answer = try await service.answer(
question: "流式问题",
conversationID: UUID(),
targetLocaleID: TranslationLanguageCatalog.offLocaleId
) { partial in
partials.values.append(partial)
}
XCTAssertEqual(answer, "最终答案")
XCTAssertEqual(partials.values, ["你好", "你好,世界", "", "最终答案"])
}
func testStreamingPreviewSoftCapsWithoutEllipsis() {
let oversized = String(
repeating: "草",
count: AIQuestionLimits.maximumAnswerCharacterCount + 50
)
let preview = AIQuestionService.streamingPreview(oversized)
XCTAssertEqual(preview.count, AIQuestionLimits.maximumAnswerCharacterCount)
XCTAssertFalse(preview.hasSuffix("…"))
}
}
private final class CapturingAIClient: LLMClient, @unchecked Sendable {
var nextAnswer = "回答"
var lastMessages: [LLMRequest.Message]?
let requestTimeout: TimeInterval = 1
func polish(
_ text: String,
systemPrompt: String,
timeout: TimeInterval?
) async throws -> String {
nextAnswer
}
func complete(
messages: [LLMRequest.Message],
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
lastMessages = messages
return nextAnswer
}
}
private final class StreamingStubAIClient: LLMClient, @unchecked Sendable {
let events: [LLMStreamEvent]
let requestTimeout: TimeInterval = 1
init(events: [LLMStreamEvent]) {
self.events = events
}
func polish(
_ text: String,
systemPrompt: String,
timeout: TimeInterval?
) async throws -> String {
"unused"
}
func complete(
messages: [LLMRequest.Message],
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
"unused"
}
func completeStreaming(
messages: [LLMRequest.Message],
timeout: TimeInterval?,
options: LLMGenerationOptions
) -> AsyncThrowingStream<LLMStreamEvent, Error> {
AsyncThrowingStream { continuation in
for event in events {
continuation.yield(event)
}
continuation.finish()
}
}
}