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.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class AIHistoryAndUsageTests: XCTestCase {
|
||||
@MainActor
|
||||
func testAIHistoryMutationPreservesSource() throws {
|
||||
let defaults = try makeDefaults()
|
||||
let store = SpeechHistoryStore(defaults: defaults)
|
||||
let mutation = HistoryMutation(
|
||||
action: .append,
|
||||
entryID: UUID(),
|
||||
text: "AI 答案",
|
||||
engineMode: "local",
|
||||
source: .ai,
|
||||
usageCategory: .ai
|
||||
)
|
||||
|
||||
let entry = try XCTUnwrap(store.applyHistoryMutation(mutation))
|
||||
|
||||
XCTAssertEqual(entry.text, "AI 答案")
|
||||
XCTAssertEqual(entry.source, .ai)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testAICharacterCommitIsIdempotent() throws {
|
||||
let defaults = try makeDefaults()
|
||||
let store = UsageStatisticsStore(defaults: defaults)
|
||||
let commitID = UUID()
|
||||
|
||||
store.recordAIInsertion(text: "四个字符", commitID: commitID)
|
||||
store.recordAIInsertion(text: "四个字符", commitID: commitID)
|
||||
|
||||
XCTAssertEqual(store.aiCharacterCount, 4)
|
||||
XCTAssertEqual(store.totalInputCharacterCount, 4)
|
||||
}
|
||||
|
||||
func testLegacyHistoryEntryDefaultsToDictationSource() throws {
|
||||
let payload: [String: Any] = [
|
||||
"id": UUID().uuidString,
|
||||
"text": "旧记录",
|
||||
"createdAt": Date().timeIntervalSinceReferenceDate,
|
||||
"modifiedAt": Date().timeIntervalSinceReferenceDate,
|
||||
"revision": 0,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let entry = try decoder.decode(SpeechHistoryEntry.self, from: data)
|
||||
|
||||
XCTAssertEqual(entry.source, .dictation)
|
||||
}
|
||||
|
||||
private func makeDefaults() throws -> UserDefaults {
|
||||
let name = "AIHistoryAndUsageTests.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: name))
|
||||
defaults.removePersistentDomain(forName: name)
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// AIModeLLMClientTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class AIModeLLMClientTests: XCTestCase {
|
||||
|
||||
func testDeepSeekFlashSupportsResponsesSearch() {
|
||||
XCTAssertTrue(AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: "deepseek-v4-flash"))
|
||||
XCTAssertTrue(AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: "deepseek-v4-flash-0731"))
|
||||
XCTAssertFalse(AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: "deepseek-v4-pro"))
|
||||
XCTAssertFalse(AIModeSearchSupport.deepSeekSupportsResponsesSearch(model: "deepseek-chat"))
|
||||
}
|
||||
|
||||
func testResponsesURLAppendsPath() {
|
||||
XCTAssertEqual(
|
||||
ResponsesAPILLMClient.responsesURL(from: "https://api.openai.com/v1")?.absoluteString,
|
||||
"https://api.openai.com/v1/responses"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ResponsesAPILLMClient.responsesURL(from: "https://api.deepseek.com/v1/")?.absoluteString,
|
||||
"https://api.deepseek.com/v1/responses"
|
||||
)
|
||||
}
|
||||
|
||||
func testParseResponsesOutputTextField() throws {
|
||||
let json = """
|
||||
{"output_text":"Hello from search","output":[]}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertEqual(try ResponsesAPILLMClient.parseOutputText(from: json), "Hello from search")
|
||||
}
|
||||
|
||||
func testParseResponsesMessageContentParts() throws {
|
||||
let json = """
|
||||
{
|
||||
"output": [
|
||||
{"type":"reasoning","content":[{"type":"reasoning_text","text":"think"}]},
|
||||
{"type":"message","content":[
|
||||
{"type":"output_text","text":"Part A"},
|
||||
{"type":"output_text","text":" Part B"}
|
||||
]}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertEqual(try ResponsesAPILLMClient.parseOutputText(from: json), "Part A Part B")
|
||||
}
|
||||
|
||||
func testFactoryUsesSearchFallbackForDeepSeekFlash() {
|
||||
let client = AIModeLLMClientFactory.make(
|
||||
providerId: "deepseek",
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "deepseek-v4-flash"
|
||||
)
|
||||
XCTAssertTrue(client is AIModeSearchFallbackClient)
|
||||
}
|
||||
|
||||
func testFactorySkipsSearchForDeepSeekPro() {
|
||||
let client = AIModeLLMClientFactory.make(
|
||||
providerId: "deepseek",
|
||||
baseURL: "https://api.deepseek.com/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "deepseek-v4-pro"
|
||||
)
|
||||
XCTAssertFalse(client is AIModeSearchFallbackClient)
|
||||
XCTAssertTrue(client is OpenAICompatibleClient)
|
||||
}
|
||||
|
||||
func testFactoryUsesSearchFallbackForOpenAI() {
|
||||
let client = AIModeLLMClientFactory.make(
|
||||
providerId: "openai",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-5.4-mini"
|
||||
)
|
||||
XCTAssertTrue(client is AIModeSearchFallbackClient)
|
||||
}
|
||||
|
||||
func testFactoryPlainForGroq() {
|
||||
let client = AIModeLLMClientFactory.make(
|
||||
providerId: "groq",
|
||||
baseURL: "https://api.groq.com/openai/v1",
|
||||
apiKey: "gsk-test",
|
||||
model: "llama-3.3-70b-versatile"
|
||||
)
|
||||
XCTAssertFalse(client is AIModeSearchFallbackClient)
|
||||
}
|
||||
|
||||
func testOpenAIDefaultModelSupportsSearchPreset() {
|
||||
let openai = LLMProvider.provider(id: "openai")
|
||||
XCTAssertEqual(openai.defaultModel, "gpt-5.4-mini")
|
||||
}
|
||||
|
||||
func testUpdatedProviderDefaultModels() {
|
||||
XCTAssertEqual(LLMProvider.provider(id: "qwen").defaultModel, "qwen-plus-latest")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "zhipu").defaultModel, "glm-4.7-flash")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "moonshot").defaultModel, "kimi-k2.5")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "xai").defaultModel, "grok-4-fast-reasoning")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "gemini").defaultModel, "gemini-3.1-flash-lite")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "minimax").defaultModel, "MiniMax-M2.7")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "anthropic").defaultModel, "claude-sonnet-4-6")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "siliconflow").defaultModel, "Qwen/Qwen3-8B-Instruct")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "openrouter").defaultModel, "qwen/qwen3-8b:free")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "cometapi").defaultModel, "gpt-5.4-mini")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "codingPlanX").defaultModel, "gpt-5.4-mini")
|
||||
}
|
||||
|
||||
func testPolishAndAIModeResolveIdenticalEndpointFromSettings() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.aimode.endpoint.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("openai", forKey: AppGroupConfiguration.Keys.providerId)
|
||||
defaults.set("https://api.openai.com/v1", forKey: AppGroupConfiguration.Keys.baseURL)
|
||||
defaults.set("gpt-5.4-mini", forKey: AppGroupConfiguration.Keys.model)
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let providerID = PolishingService.resolvedProviderId(store: store, providerIdOverride: nil)
|
||||
let preset = LLMProvider.provider(id: providerID)
|
||||
let polishEndpoint = PolishingService.resolveLLMEndpoint(
|
||||
store: store,
|
||||
preset: preset,
|
||||
providerIdOverride: nil
|
||||
)
|
||||
// AI mode must not invent a different model — Settings model wins.
|
||||
let aiEndpoint = PolishingService.resolveLLMEndpoint(
|
||||
store: store,
|
||||
preset: preset,
|
||||
providerIdOverride: nil
|
||||
)
|
||||
XCTAssertEqual(providerID, "openai")
|
||||
XCTAssertEqual(polishEndpoint.model, "gpt-5.4-mini")
|
||||
XCTAssertEqual(aiEndpoint.model, polishEndpoint.model)
|
||||
XCTAssertEqual(aiEndpoint.baseURL, polishEndpoint.baseURL)
|
||||
}
|
||||
|
||||
func testEmptyStoreModelFallsBackToPresetDefaultForBothModes() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.aimode.defaultmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("moonshot", forKey: AppGroupConfiguration.Keys.providerId)
|
||||
defaults.set("", forKey: AppGroupConfiguration.Keys.model)
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let preset = LLMProvider.provider(id: "moonshot")
|
||||
let endpoint = PolishingService.resolveLLMEndpoint(
|
||||
store: store,
|
||||
preset: preset,
|
||||
providerIdOverride: nil
|
||||
)
|
||||
XCTAssertEqual(endpoint.model, "kimi-k2.5")
|
||||
}
|
||||
|
||||
func testChatCompletionsStreamDeltaIgnoresReasoningContent() {
|
||||
let json = """
|
||||
{"choices":[{"delta":{"reasoning_content":"think","content":"可见"}}]}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertEqual(LLMStreamDeltaParser.chatCompletionsDelta(from: json), "可见")
|
||||
}
|
||||
|
||||
func testResponsesStreamDeltaOnlyOutputText() {
|
||||
let delta = """
|
||||
{"type":"response.output_text.delta","delta":"Hello"}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertEqual(LLMStreamDeltaParser.responsesOutputTextDelta(from: delta), "Hello")
|
||||
|
||||
let reasoning = """
|
||||
{"type":"response.reasoning_text.delta","delta":"secret"}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertNil(LLMStreamDeltaParser.responsesOutputTextDelta(from: reasoning))
|
||||
}
|
||||
|
||||
func testAnthropicStreamDeltaSkipsThinking() {
|
||||
let text = """
|
||||
{"type":"content_block_delta","delta":{"type":"text_delta","text":"答"}}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertEqual(LLMStreamDeltaParser.anthropicTextDelta(from: text), "答")
|
||||
|
||||
let thinking = """
|
||||
{"type":"content_block_delta","delta":{"type":"thinking_delta","thinking":"…"}}
|
||||
""".data(using: .utf8)!
|
||||
XCTAssertNil(LLMStreamDeltaParser.anthropicTextDelta(from: thinking))
|
||||
}
|
||||
|
||||
func testSSEDataPayloadParsing() {
|
||||
XCTAssertEqual(
|
||||
LLMStreamTransport.sseDataPayload(from: "data: {\"a\":1}"),
|
||||
Data("{\"a\":1}".utf8)
|
||||
)
|
||||
XCTAssertNil(LLMStreamTransport.sseDataPayload(from: "event: message"))
|
||||
XCTAssertNil(LLMStreamTransport.sseDataPayload(from: ": keep-alive"))
|
||||
}
|
||||
|
||||
/// Regression: UTF-8 Chinese in SSE must not be decoded byte-as-character
|
||||
/// (that produced Latin-1 mojibake like "ä»å¤©…" for weather answers).
|
||||
func testSSEBodyPreservesChineseUTF8Content() throws {
|
||||
let answer = "今天北京多云间晴,最高气温33℃,夜间有分散性雷阵雨,最低气温25℃。"
|
||||
let chunkJSON: [String: Any] = [
|
||||
"choices": [
|
||||
["delta": ["content": answer]],
|
||||
],
|
||||
]
|
||||
let chunkData = try JSONSerialization.data(withJSONObject: chunkJSON)
|
||||
guard let chunkText = String(data: chunkData, encoding: .utf8) else {
|
||||
return XCTFail("chunk JSON must be UTF-8")
|
||||
}
|
||||
let bodyText = "data: \(chunkText)\n\ndata: [DONE]\n"
|
||||
guard let body = bodyText.data(using: .utf8) else {
|
||||
return XCTFail("SSE body must encode as UTF-8")
|
||||
}
|
||||
|
||||
let payloads = LLMStreamTransport.sseJSONPayloads(fromBody: body)
|
||||
XCTAssertEqual(payloads.count, 1)
|
||||
XCTAssertEqual(
|
||||
LLMStreamDeltaParser.chatCompletionsDelta(from: payloads[0]),
|
||||
answer
|
||||
)
|
||||
}
|
||||
|
||||
func testAnswerStreamThrottleGatesByIntervalAndGrowth() {
|
||||
var throttle = AIAnswerStreamThrottle(minInterval: 1, minCharacterStep: 10)
|
||||
XCTAssertTrue(throttle.shouldPublish(accumulatedCount: 1, now: 100))
|
||||
XCTAssertFalse(throttle.shouldPublish(accumulatedCount: 5, now: 100.2))
|
||||
XCTAssertTrue(throttle.shouldPublish(accumulatedCount: 15, now: 100.2))
|
||||
XCTAssertTrue(throttle.shouldPublish(accumulatedCount: 16, now: 101.5))
|
||||
XCTAssertTrue(throttle.shouldPublish(accumulatedCount: 0, now: 101.6, force: true))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class AISessionStateTests: XCTestCase {
|
||||
func testSuccessfulAnswerReplacesPreviousOnlyAtTerminalResult() throws {
|
||||
var state = AISessionState()
|
||||
let conversationID = UUID()
|
||||
let firstUtteranceID = UUID()
|
||||
state.enter(conversationID: conversationID)
|
||||
state.beginPreparing(utteranceID: firstUtteranceID)
|
||||
state.beginListening(utteranceID: firstUtteranceID)
|
||||
state.beginRecognizing(utteranceID: firstUtteranceID)
|
||||
state.beginGenerating(question: "问题一", utteranceID: firstUtteranceID)
|
||||
state.receiveAnswer("答案一", utteranceID: firstUtteranceID)
|
||||
|
||||
let secondUtteranceID = UUID()
|
||||
state.beginPreparing(utteranceID: secondUtteranceID)
|
||||
XCTAssertEqual(state.answer?.text, "答案一")
|
||||
|
||||
state.beginListening(utteranceID: secondUtteranceID)
|
||||
state.beginGenerating(question: "问题二", utteranceID: secondUtteranceID)
|
||||
XCTAssertEqual(state.answer?.text, "答案一")
|
||||
|
||||
state.receiveAnswer("答案二", utteranceID: secondUtteranceID)
|
||||
XCTAssertEqual(state.answer?.text, "答案二")
|
||||
XCTAssertTrue(state.canInsert)
|
||||
}
|
||||
|
||||
func testCancellationRestoresPreviousAnswerState() {
|
||||
var state = AISessionState()
|
||||
let firstUtteranceID = UUID()
|
||||
state.enter()
|
||||
state.beginPreparing(utteranceID: firstUtteranceID)
|
||||
state.beginListening(utteranceID: firstUtteranceID)
|
||||
state.receiveAnswer("可用答案", utteranceID: firstUtteranceID)
|
||||
|
||||
let secondUtteranceID = UUID()
|
||||
state.beginPreparing(utteranceID: secondUtteranceID)
|
||||
state.updateTranscript("未完成问题", utteranceID: secondUtteranceID)
|
||||
state.cancelCurrentWork()
|
||||
|
||||
XCTAssertEqual(state.phase, .ready)
|
||||
XCTAssertEqual(state.answer?.text, "可用答案")
|
||||
XCTAssertEqual(state.transcript, "")
|
||||
}
|
||||
|
||||
func testAnswerRequiresInsertBeforeSend() {
|
||||
var state = AISessionState()
|
||||
let utteranceID = UUID()
|
||||
state.enter()
|
||||
state.beginPreparing(utteranceID: utteranceID)
|
||||
state.receiveAnswer("答案", utteranceID: utteranceID)
|
||||
|
||||
XCTAssertTrue(state.canInsert)
|
||||
XCTAssertFalse(state.canSend)
|
||||
|
||||
state.markAnswerInserted(offersSend: true)
|
||||
|
||||
XCTAssertEqual(state.phase, .awaitingSend)
|
||||
XCTAssertFalse(state.canInsert)
|
||||
XCTAssertTrue(state.canSend)
|
||||
XCTAssertEqual(state.answer?.isInserted, true)
|
||||
|
||||
state.markAnswerSent()
|
||||
|
||||
XCTAssertEqual(state.phase, .sent)
|
||||
XCTAssertFalse(state.canSend)
|
||||
XCTAssertEqual(state.answer?.isSent, true)
|
||||
}
|
||||
|
||||
func testNonSendFieldFinishesAfterInsertion() {
|
||||
var state = AISessionState()
|
||||
let utteranceID = UUID()
|
||||
state.enter()
|
||||
state.beginPreparing(utteranceID: utteranceID)
|
||||
state.receiveAnswer("答案", utteranceID: utteranceID)
|
||||
|
||||
state.markAnswerInserted(offersSend: false)
|
||||
|
||||
XCTAssertEqual(state.phase, .inserted)
|
||||
XCTAssertFalse(state.canPerformAnswerAction)
|
||||
XCTAssertEqual(state.answer?.isInserted, true)
|
||||
XCTAssertEqual(state.answer?.isSent, false)
|
||||
}
|
||||
|
||||
func testCancellationRestoresPendingSendState() {
|
||||
var state = AISessionState()
|
||||
let firstUtteranceID = UUID()
|
||||
state.enter()
|
||||
state.beginPreparing(utteranceID: firstUtteranceID)
|
||||
state.receiveAnswer("已插入答案", utteranceID: firstUtteranceID)
|
||||
state.markAnswerInserted(offersSend: true)
|
||||
|
||||
let secondUtteranceID = UUID()
|
||||
state.beginPreparing(utteranceID: secondUtteranceID)
|
||||
state.cancelCurrentWork()
|
||||
|
||||
XCTAssertEqual(state.phase, .awaitingSend)
|
||||
XCTAssertTrue(state.canSend)
|
||||
}
|
||||
|
||||
func testStaleResultCannotReplaceCurrentAnswer() {
|
||||
var state = AISessionState()
|
||||
let currentUtteranceID = UUID()
|
||||
state.enter()
|
||||
state.beginPreparing(utteranceID: currentUtteranceID)
|
||||
|
||||
state.receiveAnswer("迟到答案", utteranceID: UUID())
|
||||
|
||||
XCTAssertNil(state.answer)
|
||||
XCTAssertEqual(state.phase, .preparing)
|
||||
}
|
||||
|
||||
func testPartialAnswerKeepsPreviousCommittedAnswerUntilFinal() {
|
||||
var state = AISessionState()
|
||||
let firstUtteranceID = UUID()
|
||||
state.enter()
|
||||
state.beginPreparing(utteranceID: firstUtteranceID)
|
||||
state.receiveAnswer("答案一", utteranceID: firstUtteranceID)
|
||||
|
||||
let secondUtteranceID = UUID()
|
||||
state.beginPreparing(utteranceID: secondUtteranceID)
|
||||
state.beginGenerating(question: "问题二", utteranceID: secondUtteranceID)
|
||||
state.receivePartialAnswer("草", utteranceID: secondUtteranceID)
|
||||
|
||||
XCTAssertEqual(state.phase, .generating)
|
||||
XCTAssertEqual(state.draftAnswerText, "草")
|
||||
XCTAssertEqual(state.answer?.text, "答案一")
|
||||
XCTAssertFalse(state.canInsert)
|
||||
|
||||
state.receivePartialAnswer("草稿答案", utteranceID: secondUtteranceID)
|
||||
XCTAssertEqual(state.draftAnswerText, "草稿答案")
|
||||
|
||||
state.receiveAnswer("答案二", utteranceID: secondUtteranceID)
|
||||
XCTAssertNil(state.draftAnswerText)
|
||||
XCTAssertEqual(state.answer?.text, "答案二")
|
||||
XCTAssertTrue(state.canInsert)
|
||||
}
|
||||
|
||||
func testCancelClearsDraftAndRestoresPreviousAnswer() {
|
||||
var state = AISessionState()
|
||||
let firstUtteranceID = UUID()
|
||||
state.enter()
|
||||
state.beginPreparing(utteranceID: firstUtteranceID)
|
||||
state.receiveAnswer("可用答案", utteranceID: firstUtteranceID)
|
||||
|
||||
let secondUtteranceID = UUID()
|
||||
state.beginPreparing(utteranceID: secondUtteranceID)
|
||||
state.beginGenerating(question: "新问题", utteranceID: secondUtteranceID)
|
||||
state.receivePartialAnswer("半截", utteranceID: secondUtteranceID)
|
||||
state.cancelCurrentWork()
|
||||
|
||||
XCTAssertNil(state.draftAnswerText)
|
||||
XCTAssertEqual(state.phase, .ready)
|
||||
XCTAssertEqual(state.answer?.text, "可用答案")
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertTrue(config.cursorDragNavigationEnabled)
|
||||
XCTAssertEqual(config.keyboardHapticIntensity, .light)
|
||||
XCTAssertEqual(config.polishIntensity, .light)
|
||||
XCTAssertEqual(config.aiResponseLength, .medium)
|
||||
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
|
||||
XCTAssertTrue(config.flowSkipAppSwitch)
|
||||
XCTAssertEqual(config.flowInactivityDuration, .fiveMinutes)
|
||||
@@ -59,6 +60,7 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
config.cursorDragNavigationEnabled = false
|
||||
config.keyboardHapticIntensity = .strong
|
||||
config.polishIntensity = .heavy
|
||||
config.aiResponseLength = .short
|
||||
config.flowSkipAppSwitch = false
|
||||
// Use a non-default value so the round-trip actually proves persistence.
|
||||
config.flowInactivityDuration = .threeHours
|
||||
@@ -83,6 +85,7 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertFalse(loaded.cursorDragNavigationEnabled)
|
||||
XCTAssertEqual(loaded.keyboardHapticIntensity, .strong)
|
||||
XCTAssertEqual(loaded.polishIntensity, .heavy)
|
||||
XCTAssertEqual(loaded.aiResponseLength, .short)
|
||||
XCTAssertFalse(loaded.flowSkipAppSwitch)
|
||||
XCTAssertEqual(loaded.flowInactivityDuration, .threeHours)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// FlowASRPostProcessorTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class FlowASRPostProcessorTests: XCTestCase {
|
||||
func testLocalASRAppliesDictionaryAliasesToRawAndPolishInputs() throws {
|
||||
var dictionary = PersonalDictionary.empty
|
||||
let entry = try XCTUnwrap(dictionary.upsertManual(term: "SwiftUI"))
|
||||
dictionary.updateAliases(for: entry.id, aliases: ["swift u i"])
|
||||
|
||||
let result = FlowASRPostProcessor.process(
|
||||
text: "请介绍 swift u i",
|
||||
textForPolish: "请介绍 swift u i。",
|
||||
engineMode: "local",
|
||||
dictionary: dictionary
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.text, "请介绍 SwiftUI")
|
||||
XCTAssertEqual(result.textForPolish, "请介绍 SwiftUI。")
|
||||
}
|
||||
|
||||
func testCloudASRLeavesProviderBiasedTranscriptUnchanged() throws {
|
||||
var dictionary = PersonalDictionary.empty
|
||||
let entry = try XCTUnwrap(dictionary.upsertManual(term: "SwiftUI"))
|
||||
dictionary.updateAliases(for: entry.id, aliases: ["swift u i"])
|
||||
|
||||
let result = FlowASRPostProcessor.process(
|
||||
text: "请介绍 swift u i",
|
||||
textForPolish: "请介绍 swift u i。",
|
||||
engineMode: "cloud",
|
||||
dictionary: dictionary
|
||||
)
|
||||
|
||||
XCTAssertEqual(result.text, "请介绍 swift u i")
|
||||
XCTAssertEqual(result.textForPolish, "请介绍 swift u i。")
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,20 @@ final class FlowBudgetAndMergeTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testAIKeyboardTimeoutOutlastsASRAndAnswerGeneration() {
|
||||
for engineMode in ["local", "cloud"] {
|
||||
let hostWorstCase = (engineMode == "local"
|
||||
? FlowSessionKeys.localASRWaitTimeout
|
||||
: FlowSessionKeys.cloudASRWaitTimeout)
|
||||
+ FlowSessionKeys.batchASRFallbackTimeout
|
||||
+ FlowSessionKeys.aiQuestionRequestTimeout
|
||||
XCTAssertGreaterThan(
|
||||
FlowSessionKeys.keyboardAIResultTimeout(engineMode: engineMode),
|
||||
hostWorstCase
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SyncedField future-clock clamping
|
||||
|
||||
func testMergePrefersGenuinelyNewerRemote() {
|
||||
|
||||
@@ -285,6 +285,42 @@ final class FlowSessionBridgeTests: XCTestCase {
|
||||
XCTAssertNil(reencoded["previousOutput"])
|
||||
}
|
||||
|
||||
func testAIQuestionCommandRoundTripPreservesConversationIdentity() throws {
|
||||
let conversationID = UUID()
|
||||
let command = FlowCommand(
|
||||
sessionId: UUID(),
|
||||
utteranceId: UUID(),
|
||||
commandSeq: 46,
|
||||
action: .startRecording,
|
||||
localeId: "zh-Hans",
|
||||
utteranceMode: .aiQuestion,
|
||||
aiConversationID: conversationID
|
||||
)
|
||||
|
||||
let decoded = try JSONDecoder().decode(
|
||||
FlowCommand.self,
|
||||
from: JSONEncoder().encode(command)
|
||||
)
|
||||
|
||||
XCTAssertEqual(decoded.resolvedUtteranceMode, .aiQuestion)
|
||||
XCTAssertEqual(decoded.aiConversationID, conversationID)
|
||||
}
|
||||
|
||||
func testAIQuestionResultNeverAllowsRawASRFallback() {
|
||||
let result = FlowResult(
|
||||
sessionId: UUID(),
|
||||
utteranceId: UUID(),
|
||||
commandSeq: 47,
|
||||
status: .rawReady,
|
||||
text: "原始问题",
|
||||
rawText: "原始问题",
|
||||
utteranceMode: .aiQuestion,
|
||||
aiConversationID: UUID()
|
||||
)
|
||||
|
||||
XCTAssertFalse(result.allowsRawFallback)
|
||||
}
|
||||
|
||||
func testFlowResultRoundTripPreservesUtteranceIdentity() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
|
||||
@@ -161,9 +161,8 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
|
||||
func testPolishServiceMissingAPIKeyThrows() async {
|
||||
store.setEngineMode("cloud")
|
||||
// Default polish provider is deepseek; a filled PreconfiguredKeys.local
|
||||
// would satisfy hasPolishAPIKey. Use a unique provider account so a
|
||||
// developer's simulator Keychain cannot make this test hit the network.
|
||||
// Use a unique provider account so a developer's simulator Keychain
|
||||
// cannot make this test hit the network.
|
||||
let missingProvider = "test-missing-\(UUID().uuidString)"
|
||||
let service = PolishingService(store: store)
|
||||
do {
|
||||
@@ -585,7 +584,21 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
XCTAssertEqual(delivery.text, "测试文本")
|
||||
XCTAssertEqual(
|
||||
delivery.polishWarning,
|
||||
SharedL10n.string("flow.warning.localPolishUnavailable")
|
||||
SharedL10n.string("flow.warning.polishMissingAPIKey")
|
||||
)
|
||||
}
|
||||
|
||||
func testTranscriptionPolishFallbackCloudMissingKeyWarning() {
|
||||
let delivery = TranscriptionPolishFallback.makeDelivery(
|
||||
rawText: "hello world",
|
||||
error: PolishingService.PolishError.missingAPIKey,
|
||||
engineMode: "cloud",
|
||||
chunkWarning: nil
|
||||
)
|
||||
XCTAssertEqual(delivery.text, "hello world")
|
||||
XCTAssertEqual(
|
||||
delivery.polishWarning,
|
||||
SharedL10n.string("flow.warning.polishMissingAPIKey")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,31 +57,19 @@ final class LLMClientTests: XCTestCase {
|
||||
XCTAssertTrue(config2.isConfigured)
|
||||
}
|
||||
|
||||
/// Local engine path: with `engineMode = "local"`, `isConfigured`
|
||||
/// must return `true` even when the API key is empty — onboarding
|
||||
/// gates the "Next" button on this property, and the local path
|
||||
/// never needs a key. Regression: see commit `isConfigured` fix
|
||||
/// that exposed this gate.
|
||||
func testIsConfiguredTrueForLocalEngineWithoutAPIKey() {
|
||||
/// Local ASR needs no cloud ASR key, but polish still requires a user API key.
|
||||
func testLocalEngineWithoutAPIKeyIsNotPolishConfigured() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.isconfigured.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let config = ProviderConfig(defaults: defaults)
|
||||
// Fresh suites default to local; force cloud so ASR+polish keys are required.
|
||||
config.engineMode = "cloud"
|
||||
XCTAssertFalse(config.isConfigured)
|
||||
// Local ASR never needs a cloud ASR key; polish may use built-in DeepSeek.
|
||||
config.engineMode = "local"
|
||||
if PreconfiguredKeys.isDeepseekConfigured {
|
||||
XCTAssertTrue(config.isConfigured)
|
||||
} else {
|
||||
XCTAssertFalse(
|
||||
config.isConfigured,
|
||||
"Without a user key or PreconfiguredKeys.deepseek, local polish is not configured"
|
||||
)
|
||||
}
|
||||
XCTAssertFalse(config.isPolishConfigured)
|
||||
XCTAssertFalse(config.isConfigured)
|
||||
config.engineMode = "cloud"
|
||||
XCTAssertFalse(config.isConfigured)
|
||||
}
|
||||
@@ -384,7 +372,7 @@ final class LLMClientTests: XCTestCase {
|
||||
XCTAssertEqual(calls, 1, "cloud engine must polish even with legacy modeId=off")
|
||||
}
|
||||
|
||||
/// Local engine always runs the built-in DeepSeek polish step.
|
||||
/// Local engine still runs the polish LLM step when a client is injected.
|
||||
func testPolisherInvokesLLMWhenEngineLocal() async throws {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
|
||||
@@ -61,6 +61,7 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
cursorDragNavigationEnabled: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
|
||||
keyboardHapticIntensity: SyncedField(value: .light, updatedAt: stampA, deviceID: deviceA),
|
||||
polishIntensity: SyncedField(value: .light, updatedAt: stampA, deviceID: deviceA),
|
||||
aiResponseLength: SyncedField(value: .medium, updatedAt: stampA, deviceID: deviceA),
|
||||
activePolishStyleId: SyncedField(value: "builtin.light", updatedAt: stampA, deviceID: deviceA),
|
||||
llmThinkingEnabled: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA),
|
||||
flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
|
||||
@@ -83,6 +84,7 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
cursorDragNavigationEnabled: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
|
||||
keyboardHapticIntensity: SyncedField(value: .strong, updatedAt: stampB, deviceID: deviceB),
|
||||
polishIntensity: SyncedField(value: .heavy, updatedAt: stampB, deviceID: deviceB),
|
||||
aiResponseLength: SyncedField(value: .detailed, updatedAt: stampB, deviceID: deviceB),
|
||||
activePolishStyleId: SyncedField(value: "builtin.formal", updatedAt: stampB, deviceID: deviceB),
|
||||
llmThinkingEnabled: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB),
|
||||
flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
|
||||
@@ -96,6 +98,7 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
XCTAssertEqual(merged.localeId.value, "ja")
|
||||
XCTAssertEqual(merged.engineMode.value, "local")
|
||||
XCTAssertEqual(merged.polishIntensity.value, .heavy)
|
||||
XCTAssertEqual(merged.aiResponseLength.value, .detailed)
|
||||
}
|
||||
|
||||
func testLegacyKeepAliveFieldDecodesButIsNotReencoded() throws {
|
||||
|
||||
Reference in New Issue
Block a user