Files
OSGKeyboard/OSGKeyboardTests/IntelligentPolishTests.swift
Rocky b275b6b0d9 test(ai): expand AI flow and clipboard semantic coverage
- Cover reply-variant decision paths, scene-detector abstention, and
  the merged reply center stance catalog.
- Lock in clipboard semantic analyzer thresholds, complaint-only
  suppression, and existing-skill routing.
- Add durable session, refresh, and managed-gateway reliability tests
  for the Apple account path.
- Verify personal-style prompt derivation, low-confidence ASR
  tendencies, and Polish style learning against real corpus evidence.
- Exercise the assistant keyboard and Polish styles UI flows in
  end-to-end UI tests.
2026-08-29 11:51:25 +08:00

918 lines
35 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// IntelligentPolishTests.swift
// OSGKeyboard · Tests
//
// Locks the behavior of the single-pass PolishingService and
// its supporting service (AppContextDetector).
// The tests are deliberately hermetic — no LLMClient, no ASR, no
// App Group — so they run in <100 ms total.
@testable import OSGKeyboard
@testable import OSGKeyboardShared
import XCTest
final class IntelligentPolishTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!
private var store: AppGroupStore!
override func setUp() {
super.setUp()
suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
store = AppGroupStore(defaults: defaults)
}
override func tearDown() {
defaults.removePersistentDomain(forName: suiteName)
super.tearDown()
}
// MARK: - PolishingService prompt construction
func testPolishModesMapToManagedGatewayTaskKinds() {
XCTAssertEqual(
PolishingService.managedGatewayTaskKind(for: .polish),
.dictationPolish
)
XCTAssertEqual(
PolishingService.managedGatewayTaskKind(for: .translate(targetLocaleId: "en")),
.translation
)
}
func testPolishServiceUltraShortTextSkipsLLM() async throws {
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: ThrowingLLMClient()
)
let result = try await service.polish("好", context: PolishContext())
XCTAssertEqual(result, "好")
}
func testPolishServiceShortStructuredTextStillInvokesLLM() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"第一点测试第二点上线",
context: PolishContext()
)
XCTAssertFalse(captured.lastPrompt.isEmpty)
}
func testHeavyFunStylesUseSingleCreativeRequestWithoutLegacyRoutes() async throws {
let cases = [
("builtin.dating", "多喝热水", "心动公式"),
("builtin.flex", "这个方案还行", "装腔公式"),
("builtin.corp", "这期可能推迟", "黑话公式"),
("builtin.diba", "这个结论我不同意", "拆招公式"),
("builtin.xhs", "这家店味道一般", "集美公式")
]
for (id, input, marker) in cases {
store.setActivePolishStyleId(id)
store.setPolishIntensity(.heavy)
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
input,
context: PolishContext()
)
XCTAssertEqual(captured.optionsHistory.count, 1, id)
XCTAssertEqual(captured.optionsHistory.first?.temperature, 0.65, id)
XCTAssertTrue(captured.lastPrompt.contains("趣味风格共享格式化"), id)
XCTAssertFalse(captured.lastPrompt.contains("全局输出契约"), id)
XCTAssertFalse(captured.lastPrompt.contains("问句守卫"), id)
XCTAssertFalse(captured.lastPrompt.contains("参考长度范围"), id)
XCTAssertTrue(captured.lastPrompt.contains(marker), id)
XCTAssertFalse(captured.lastPrompt.contains("专属降级"), id)
XCTAssertFalse(captured.lastPrompt.contains("本次方向"), id)
}
}
func testLightFunStyleUsesFullSafetyPromptAndConservativeSampling() async throws {
store.setActivePolishStyleId("builtin.dating")
store.setPolishIntensity(.light)
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"你吃饭了吗?",
context: PolishContext(appContext: .chat)
)
XCTAssertEqual(captured.optionsHistory.count, 1)
XCTAssertEqual(captured.optionsHistory.first?.temperature, 0.1)
XCTAssertTrue(captured.lastPrompt.contains("全局输出契约"))
XCTAssertTrue(captured.lastPrompt.contains("输入身份与抑制契约"))
XCTAssertTrue(captured.lastPrompt.contains("# 输入环境"))
XCTAssertFalse(captured.lastPrompt.contains("趣味风格共享格式化"))
}
func testCallerSuppliedOptionsReachLLMClientUnchanged() async throws {
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
let options = LLMGenerationOptions(
temperature: 0.42,
topP: 0.73,
maxTokens: 777
)
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence.",
options: options
)
XCTAssertEqual(captured.lastOptions, options)
}
func testPersonalDictionaryUpsertManual() {
var dict = PersonalDictionary.empty
let entry = dict.upsertManual(term: "Kubernetes")
XCTAssertEqual(entry?.term, "Kubernetes")
XCTAssertEqual(entry?.source, .manual)
XCTAssertEqual(dict.entries.count, 1)
let updated = dict.upsertManual(term: "kubernetes", existingID: entry?.id)
XCTAssertEqual(updated?.term, "kubernetes")
XCTAssertEqual(dict.entries.count, 1)
}
func testDictionaryAliasGeneratorParsesJSONArray() {
let aliases = DictionaryAliasGenerator.parseAliases(
from: #"["k8s","库伯内特斯"]"#,
excludingTerm: "Kubernetes"
)
XCTAssertEqual(aliases, ["k8s", "库伯内特斯"])
}
func testDictionaryAliasGeneratorExcludesCanonicalTerm() {
let aliases = DictionaryAliasGenerator.parseAliases(
from: #"["Kubernetes","k8s"]"#,
excludingTerm: "Kubernetes"
)
XCTAssertEqual(aliases, ["k8s"])
}
func testEntryInferCategoryForChinese() {
XCTAssertEqual(PersonalDictionary.Entry.inferCategory(for: "张三"), .properNoun)
XCTAssertEqual(PersonalDictionary.Entry.inferCategory(for: "LLM"), .acronym)
}
func testPersonalDictionaryPreservesRecommendedHistorySource() {
let dictionary = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .history)
])
let data = try! JSONEncoder().encode(dictionary)
defaults.set(data, forKey: "config.personalDictionary.v1")
let loaded = store.personalDictionary
XCTAssertEqual(loaded.entries.first?.source, .history)
XCTAssertEqual(loaded.entries.first?.term, "Kubernetes")
}
func testPolishServiceLocalEngineInvokesLLM() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天我们部署 k8s 集群",
context: PolishContext(appContext: .code)
)
XCTAssertFalse(captured.lastPrompt.isEmpty)
}
func testPolishServiceMissingAPIKeyThrows() async {
store.setEngineMode("cloud")
// 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 {
_ = try await service.polish(
"hello world",
providerIdOverride: missingProvider,
context: PolishContext()
)
XCTFail("Expected missingAPIKey")
} catch let error as PolishingService.PolishError {
XCTAssertEqual(error, .missingAPIKey)
} catch {
XCTFail("Expected PolishError, got \(error)")
}
}
func testPolishServiceShortTextSkipsLLM() async throws {
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: ThrowingLLMClient()
)
let result = try await service.polish("明天见", context: PolishContext())
XCTAssertEqual(result, "明天见")
}
func testPolishServiceBuildsPromptWithDictionaryAndContext() async throws {
store.setEngineMode("cloud")
store.personalDictionary = PersonalDictionary(entries: [
PersonalDictionary.Entry(
term: "Kubernetes", category: .productName, source: .manual
)
])
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天我们部署 k8s 集群",
context: PolishContext(appContext: .code)
)
XCTAssertTrue(captured.lastPrompt.contains("Kubernetes"),
"Prompt must include dictionary term. Got: \(captured.lastPrompt)")
XCTAssertTrue(captured.lastPrompt.contains("代码或技术环境"),
"Prompt must include app-context guideline. Got: \(captured.lastPrompt)")
XCTAssertTrue(
captured.lastPrompt.contains("全局输出契约") || captured.lastPrompt.contains("Global output contract"),
"Prompt must include global output contract. Got: \(captured.lastPrompt.prefix(200))"
)
XCTAssertTrue(
captured.lastPrompt.localizedCaseInsensitiveContains("emoji"),
"Prompt must include strict emoji control guidance. Got: \(captured.lastPrompt)"
)
XCTAssertFalse(
captured.lastPrompt.localizedCaseInsensitiveContains("emoji-friendly"),
"Chat context must not encourage emojis. Got: \(captured.lastPrompt)"
)
}
func testSystemPromptDoesNotContainTranscriptAndUserPayloadIsEscaped() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
let input = "这是一段独一无二的测试转写文本ZZQQ"
_ = try await service.polish(input, context: PolishContext())
XCTAssertFalse(captured.lastPrompt.contains("ZZQQ"))
XCTAssertTrue(captured.lastText.contains("<dictation_request protocol=\"polish-v1\">"))
XCTAssertTrue(captured.lastText.contains(input))
}
func testChineseInputUsesChineseGuidanceOnOpenAI() async throws {
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天讨论 roadmap 和发布时间",
providerIdOverride: "openai",
context: PolishContext()
)
XCTAssertTrue(captured.lastPrompt.contains("全局输出契约"))
}
func testPromptIncludesPrecedingFollowingAndFieldHints() async throws {
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"下午三点应该可以",
context: PolishContext(
appContext: .chat,
precedingText: "明天的会我看了下日程",
followingText: "确认后告诉我",
fieldHints: FieldHints(
returnKeyType: "send",
isEmptyField: false,
isContextAvailable: true
)
)
)
XCTAssertTrue(captured.lastPrompt.contains("明天的会我看了下日程"))
XCTAssertTrue(captured.lastPrompt.contains("确认后告诉我"))
XCTAssertTrue(captured.lastPrompt.contains("衔接规则"))
}
func testCorePromptIsStableAcrossCalls() {
XCTAssertEqual(
PolishPromptComposer.chineseCorePrompt,
PolishPromptComposer.chineseCorePrompt
)
XCTAssertFalse(PolishPromptComposer.chineseCorePrompt.contains("{{"))
XCTAssertTrue(PolishPromptComposer.chineseCorePrompt.contains("T1 自我修正合并"))
XCTAssertTrue(PolishPromptComposer.chineseCorePrompt.contains("T3 同音/近音纠错"))
XCTAssertTrue(PolishPromptComposer.chineseCorePrompt.contains("先完成 T1T3"))
XCTAssertTrue(PolishPromptComposer.chineseCorePrompt.contains("在见一面"))
XCTAssertTrue(PolishPromptComposer.englishCorePrompt.contains("Homophone / near-homophone repair"))
XCTAssertTrue(PolishPromptComposer.englishCorePrompt.contains("let's meat again"))
}
func testPolishServicePromptIncludesStructureRules() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天有三个任务第一点修复登录第二点优化键盘",
context: PolishContext()
)
XCTAssertTrue(
captured.lastPrompt.contains("第一点") || captured.lastPrompt.contains("numbered"),
"Prompt must include structure rules. Got: \(captured.lastPrompt.prefix(300))"
)
}
func testPolishServiceScalesTimeoutWithTextLength() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured, timeout: 15)
let longText = String(repeating: "这是一段比较长的语音识别测试文本,", count: 20)
_ = try await service.polish(longText, context: PolishContext())
let passedTimeout = try XCTUnwrap(captured.lastTimeout)
XCTAssertGreaterThan(
passedTimeout, 15,
"Long transcripts must scale the per-request HTTP timeout above the baseline"
)
}
func testPolishServiceDefaultsToKeyboardTimeoutCap() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
let veryLong = String(repeating: "测试", count: 2000)
_ = try await service.polish(veryLong, context: PolishContext())
let passedTimeout = try XCTUnwrap(captured.lastTimeout)
XCTAssertEqual(passedTimeout, FlowSessionKeys.maxPolishTimeout)
}
func testPolishServiceAllowsExplicit45SecondTimeoutCap() async throws {
let captured = CapturingLLMClient()
let service = PolishingService(
store: store,
client: captured,
timeout: 45,
maximumTimeout: 45
)
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
XCTAssertEqual(captured.lastTimeout, 45)
}
func testPolishServicePropagatesCallerCancellation() async {
let gate = LLMRequestGate()
let service = PolishingService(
store: store,
client: SuspendingLLMClient(gate: gate)
)
let task = Task {
try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
}
await gate.waitUntilStarted()
task.cancel()
do {
_ = try await task.value
XCTFail("Expected caller cancellation")
} catch {
XCTAssertTrue(error is CancellationError, "Unexpected error: \(error)")
}
}
func testPolishServicePreservesProviderCancellationError() async {
let service = PolishingService(store: store, client: ThrowingLLMClient())
do {
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
XCTFail("Expected provider cancellation")
} catch let error as LLMError {
XCTAssertEqual(error, .cancelled)
} catch {
XCTFail("Expected LLMError.cancelled, got \(error)")
}
}
func testPolishServiceUsesChineseForChineseProviders() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天我们部署 k8s 集群",
context: PolishContext()
)
XCTAssertTrue(
captured.lastPrompt.contains("全局输出契约"),
"Local engine should get the Chinese prompt via DeepSeek. Got prefix: \(captured.lastPrompt.prefix(80))"
)
}
func testPolishServiceStripsAddedEmojiFromLLMOutput() async throws {
store.setEngineMode("local")
let emojiClient = FixedResponseLLMClient(response: "今天的工作已经全部完成了👍")
let service = PolishingService(store: store, client: emojiClient)
let result = try await service.polish(
"今天的工作已经全部完成了",
context: PolishContext()
)
XCTAssertFalse(result.contains("👍"))
XCTAssertTrue(result.contains("完成"))
}
func testPolishServiceKeepsAddedEmojiWhenStyleAllows() async throws {
store.setEngineMode("local")
var catalog = PolishStyleCatalog()
let pack = PolishStylePack(
id: "user.emoji",
name: "Emoji",
prompt: "保持口语,可按情绪加 emoji。",
allowsAddedEmoji: true
)
try catalog.upsert(pack)
store.setPolishStyleCatalog(catalog)
store.setActivePolishStyleId(pack.id)
let emojiClient = FixedResponseLLMClient(response: "今天太开心了,终于搞定了😆")
let service = PolishingService(store: store, client: emojiClient)
let result = try await service.polish(
"今天太开心了终于搞定了",
context: PolishContext()
)
XCTAssertTrue(result.contains("😆"), "Allowed-emoji styles must keep model-added emoji. Got: \(result)")
XCTAssertTrue(result.contains("开心"))
}
func testPolishServiceKeepsAddedEmojiWhenPromptOptsInWithoutToggle() async throws {
store.setEngineMode("local")
var catalog = PolishStyleCatalog()
let pack = PolishStylePack(
id: "user.paste-emoji",
name: "PasteEmoji",
prompt: "本风格允许新增 emoji。按情绪点缀合适表情。",
allowsAddedEmoji: false
)
try catalog.upsert(pack)
store.setPolishStyleCatalog(catalog)
store.setActivePolishStyleId(pack.id)
let emojiClient = FixedResponseLLMClient(response: "辛苦你了,真的谢谢🙏")
let service = PolishingService(store: store, client: emojiClient)
let result = try await service.polish(
"辛苦你了真的谢谢",
context: PolishContext()
)
XCTAssertTrue(
result.contains("🙏"),
"Prompt opt-in must keep emoji even when toggle is off. Got: \(result)"
)
}
func testPolishServiceFallsBackWhenOutputEmpty() async throws {
store.setEngineMode("local")
let emptyClient = FixedResponseLLMClient(response: " ")
let service = PolishingService(store: store, client: emptyClient)
let result = try await service.polish(
"今天的部署已经全部完成",
context: PolishContext()
)
XCTAssertEqual(result, "今天的部署已经全部完成")
}
func testValidatorFallsBackAfterSingleHardFailure() async throws {
let client = ValidationFailureLLMClient()
let service = PolishingService(store: store, client: client)
let outcome = try await service.polishWithOutcome(
"please keep user_id in this technical message",
context: PolishContext(appContext: .code)
)
XCTAssertEqual(outcome.text, "please keep user_id in this technical message")
XCTAssertTrue(outcome.qualityDegraded)
XCTAssertNil(outcome.polishStyleID)
XCTAssertNil(outcome.polishStylePrompt)
XCTAssertEqual(client.temperatures.compactMap { $0 }, [0.1])
}
func testSuccessfulPolishReturnsExactStyleSnapshotForHistory() async throws {
var catalog = PolishStyleCatalog()
let style = PolishStylePack(
id: "user.snapshot",
name: "Snapshot",
prompt: "# 角色\n自然表达\n# 风格边界\n保持原意\n# 示例\n输入 → 输出"
)
try catalog.upsert(style)
store.setPolishStyleCatalog(catalog)
store.setActivePolishStyleId(style.id)
let service = PolishingService(
store: store,
client: FixedResponseLLMClient(response: "今天的部署已经完成。")
)
let outcome = try await service.polishWithOutcome(
"今天的部署已经完成",
context: PolishContext()
)
XCTAssertFalse(outcome.qualityDegraded)
XCTAssertEqual(outcome.polishStyleID, style.id)
XCTAssertEqual(outcome.polishStylePrompt, style.prompt)
}
func testValidatorFallsBackToMinimalPolishAfterHardFailure() async throws {
let service = PolishingService(
store: store,
client: FixedResponseLLMClient(response: "Please keep it.")
)
let outcome = try await service.polishWithOutcome(
"um please keep user_id",
context: PolishContext(appContext: .code)
)
XCTAssertEqual(outcome.text, "please keep user_id")
XCTAssertTrue(outcome.qualityDegraded)
}
// MARK: - TranscriptPostProcessor
func testShouldSkipLLMForUltraShortWithoutStructure() {
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "好"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "OK"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "明天见"))
}
func testShouldSkipLLMTier2ForAckClosings() {
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "好的我知道了"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "那就先这样吧"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "晚点再说"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "收到谢谢"))
}
func testShouldNotSkipLLMTier2ForQuestionsOrContent() {
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "今晚有空吗"))
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "这个还行吧"))
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "周六一起吃饭"))
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "防晒不由夏天"))
}
func testShouldNotSkipLLMWhenStructurePresent() {
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "第一点做完第二点再做"))
}
func testStripAddedEmojisRemovesNewEmoji() {
let result = TranscriptPostProcessor.stripAddedEmojis(
original: "好的",
output: "好的👍"
)
XCTAssertEqual(result, "好的")
}
func testQualityGateKeepsAddedEmojiWhenAllowed() {
let decision = TranscriptPostProcessor.qualityGate(
original: "今天太开心了",
candidate: "今天太开心了😆",
allowsAddedEmoji: true
)
guard case .accept(let text) = decision else {
return XCTFail("Expected accept")
}
XCTAssertTrue(text.contains("😆"))
}
func testQualityGateStripsAddedEmojiByDefault() {
let decision = TranscriptPostProcessor.qualityGate(
original: "今天太开心了",
candidate: "今天太开心了😆",
allowsAddedEmoji: false
)
guard case .accept(let text) = decision else {
return XCTFail("Expected accept")
}
XCTAssertFalse(text.contains("😆"))
}
func testQualityGateStripsResidualPauseMarkers() {
let result = TranscriptPostProcessor.process(
original: "第一段 ⟨0.8s⟩ 第二段",
llmOutput: "第一段 ⟨0.8s⟩ 第二段"
)
XCTAssertFalse(result.contains("⟨"))
}
func testNormalizeNumberedLists() {
let input = "第一点 修复\n第二点 上线"
let output = TranscriptPostProcessor.normalizeNumberedLists(input)
XCTAssertTrue(output.contains("1. 修复"))
XCTAssertTrue(output.contains("2. 上线"))
}
func testQualityGateNeverRevertsToRawOnNumberChange() {
// Listifying / fixing ASR number-mishearings legitimately
// changes the number set — this must NOT revert to the raw text.
let decision = TranscriptPostProcessor.qualityGate(
original: "第一点测试第2:00上线",
candidate: "1. 测试\n2. 上线"
)
if case .accept(let text) = decision {
XCTAssertTrue(text.contains("1. 测试"))
XCTAssertTrue(text.contains("2. 上线"))
} else {
XCTFail("Expected accept — number changes must not trigger raw fallback")
}
}
func testQualityGateStillFallsBackOnEmptyOutput() {
let decision = TranscriptPostProcessor.qualityGate(
original: "部署完成",
candidate: " "
)
if case .fallback(let text) = decision {
XCTAssertEqual(text, "部署完成")
} else {
XCTFail("Expected fallback on empty output")
}
}
func testRepairMidSentenceLineBreakJoinsBrokenSentence() {
let input = "你是不是真的解决了这个格式化和标点符号包括\n这些问题"
let output = TranscriptPostProcessor.repairMidSentenceLineBreaks(input)
XCTAssertEqual(output, "你是不是真的解决了这个格式化和标点符号包括这些问题")
}
func testRepairMidSentenceLineBreakKeepsSentenceBoundary() {
let input = "今天完成了部署。\n明天开始测试。"
let output = TranscriptPostProcessor.repairMidSentenceLineBreaks(input)
XCTAssertEqual(output, input)
}
func testRepairMidSentenceLineBreakKeepsListItems() {
let input = "1. 修复登录\n2. 优化键盘"
let output = TranscriptPostProcessor.repairMidSentenceLineBreaks(input)
XCTAssertEqual(output, input)
}
func testRepairMidSentenceLineBreakJoinsEnglishWithSpace() {
let input = "this is a broken\nsentence"
let output = TranscriptPostProcessor.repairMidSentenceLineBreaks(input)
XCTAssertEqual(output, "this is a broken sentence")
}
func testCleanRawASRFallbackRemovesChineseInteriorSpaces() {
let input = " 你 是不是 已经 解决了 这个 问题 ? "
let output = TranscriptPostProcessor.cleanRawASRFallback(input)
XCTAssertEqual(output, "你是不是已经解决了这个问题?")
}
func testCleanRawASRFallbackPreservesEnglishAndMixedSpaces() {
let input = "iOS 版本 uses Swift UI"
let output = TranscriptPostProcessor.cleanRawASRFallback(input)
XCTAssertEqual(output, "iOS 版本 uses Swift UI")
}
@MainActor
func testFlowFallbackDeliveryCleansTextAndCarriesWeakNetworkWarning() {
let delivery = TranscriptionPolishFallback.makeDelivery(
rawText: " 你 是不是 已经 解决了 这个 问题 ? ",
error: LLMError.transport("offline"),
engineMode: "cloud",
chunkWarning: nil
)
XCTAssertEqual(delivery.text, "你是不是已经解决了这个问题?")
XCTAssertFalse(delivery.text.contains("未润色"))
XCTAssertEqual(delivery.polishWarning, SharedL10n.string("flow.warning.polishDegraded"))
}
func testCompletedOOBEPageDoesNotReportWeakNetworkOrBlockProgress() {
let delivery = TranscriptionPolishFallback.makeDelivery(
rawText: "今天 是 礼拜四",
error: ManagedGatewayError.oobeFeatureAlreadyUsed,
engineMode: "local",
chunkWarning: nil
)
XCTAssertEqual(delivery.text, "今天是礼拜四")
XCTAssertNil(delivery.polishWarning)
}
func testTranscriptionPolishFallbackLocalMissingKeyWarning() {
let delivery = TranscriptionPolishFallback.makeDelivery(
rawText: "测试文本",
error: PolishingService.PolishError.missingAPIKey,
engineMode: "local",
chunkWarning: nil
)
XCTAssertEqual(delivery.text, "测试文本")
XCTAssertEqual(
delivery.polishWarning,
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")
)
}
func testHasStructureSignalDetectsChineseEnumeration() {
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "首先测试其次上线"))
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "第一点修复"))
}
// MARK: - AppContextDetector
func testAppContextDetectorRecognizesCodeByIndentation() {
let detector = AppContextDetector()
let text = """
import Foundation
struct Foo {
func bar() -> Int {
return 42
}
}
"""
XCTAssertEqual(detector.heuristicDetect(preceding: text), .code)
}
func testAppContextDetectorRecognizesEmail() {
let detector = AppContextDetector()
let text = "Hi Rocky,\n\nFollowing up on rocky.hk@gmail.com thread — can you sign off by Friday?\n\nThanks,\nLily"
XCTAssertEqual(detector.heuristicDetect(preceding: text), .email)
}
func testAppContextDetectorRecognizesChat() {
let detector = AppContextDetector()
let text = "ok\nlol\nsee you tmr\nbrb\nbbl\nk\nthx"
XCTAssertEqual(detector.heuristicDetect(preceding: text), .chat)
}
func testAppContextDetectorRecognizesDocument() {
let detector = AppContextDetector()
let text = String(repeating: "The quick brown fox jumps over the lazy dog. ", count: 30)
XCTAssertEqual(detector.heuristicDetect(preceding: text), .document)
}
func testAppContextDetectorReturnsNilOnEmpty() {
let detector = AppContextDetector()
XCTAssertNil(detector.heuristicDetect(preceding: ""))
}
func testAppContextDetectorFallbackChain() {
let detector = AppContextDetector()
let env = detector.detect(
precedingText: nil,
storedCache: nil,
now: Date(timeIntervalSince1970: 1_700_000_000)
)
XCTAssertNotEqual(env, .unknown)
}
func testAppContextDetectorCacheWinsOverFallback() {
let detector = AppContextDetector()
let cache = (context: AppContext.code, observedAt: Date().addingTimeInterval(-300))
let result = detector.detect(precedingText: "", storedCache: cache)
XCTAssertEqual(result, .code)
}
func testChatTranslationGuidelineDoesNotEncourageEmoji() {
let guideline = AppContext.chat.translationGuideline
XCTAssertFalse(guideline.localizedCaseInsensitiveContains("emoji-friendly"))
XCTAssertTrue(guideline.localizedCaseInsensitiveContains("Do not add emojis"))
}
// MARK: - PersonalDictionary.promptFragment
func testDictionaryPromptFragmentIncludesBuiltInOSGKeyboard() {
let prompt = PersonalDictionary.empty.promptFragment()
XCTAssertTrue(prompt.contains("OSGKeyboard"))
}
func testDictionaryPromptFragmentGroupsByCategory() {
let dict = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .manual),
PersonalDictionary.Entry(term: "iOS", category: .acronym, source: .manual),
PersonalDictionary.Entry(term: "Rocky", category: .properNoun, source: .manual)
])
let prompt = dict.promptFragment()
XCTAssertTrue(prompt.contains("OSGKeyboard"))
XCTAssertTrue(prompt.contains("Kubernetes"))
XCTAssertTrue(prompt.contains("iOS"))
XCTAssertTrue(prompt.contains("Rocky"))
}
}
// MARK: - Test doubles
private final class CapturingLLMClient: LLMClient, @unchecked Sendable {
private(set) var lastPrompt: String = ""
private(set) var lastText: String = ""
private(set) var lastTimeout: TimeInterval?
private(set) var lastOptions: LLMGenerationOptions?
private(set) var optionsHistory: [LLMGenerationOptions] = []
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
lastText = text
lastPrompt = systemPrompt
lastTimeout = timeout
return text
}
func polish(
_ text: String,
systemPrompt: String,
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
lastOptions = options
optionsHistory.append(options)
return try await polish(
text,
systemPrompt: systemPrompt,
timeout: timeout
)
}
}
private final class ThrowingLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
throw LLMError.cancelled
}
}
private actor LLMRequestGate {
private var started = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func markStarted() {
started = true
let pending = waiters
waiters.removeAll()
pending.forEach { $0.resume() }
}
func waitUntilStarted() async {
if started { return }
await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
}
private struct SuspendingLLMClient: LLMClient {
let gate: LLMRequestGate
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
await gate.markStarted()
try await Task.sleep(nanoseconds: 10_000_000_000)
return text
}
}
private final class FixedResponseLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
private let response: String
init(response: String) {
self.response = response
}
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
response
}
}
private final class ValidationFailureLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
private(set) var temperatures: [Double?] = []
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
"Please keep it."
}
func polish(
_ text: String,
systemPrompt: String,
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
temperatures.append(options.temperature)
return "Please keep it."
}
}