Files
OSGKeyboard/OSGKeyboardTests/AIQuestionServiceTests.swift
T
Rocky 3de665d254 feat(keyboard): ship AI mode surface with streaming search answers
Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
2026-08-11 01:06:27 +08:00

220 lines
7.5 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))
XCTAssertTrue(client.lastMessages?.first?.content.contains("Reply in English.") == true)
}
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()
}
}
}