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:
Rocky
2026-08-11 01:06:27 +08:00
parent f6212dcd2c
commit 3de665d254
81 changed files with 4467 additions and 398 deletions
@@ -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
}
}
+232
View File
@@ -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()
}
}
}
+157
View File
@@ -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()
+17 -4
View File
@@ -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")
)
}
+5 -17
View File
@@ -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 {