feat: cursor navigation, key sounds, dictionary tooling, key security

Batch of in-progress app work from the working tree.

- feat(keyboard): CursorNavigation + CursorDragPad for caret movement;
  KeyboardSoundFeedback for system key click sounds
- feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet;
  TranscriptPostProcessor quality gate; retire DictionaryLearner
- feat(ui): TabBarVisibility handling; drop PageHeaderRow /
  PageHeaderConfirmButton; refresh views and localizable strings
- fix(security): move the hardcoded DeepSeek key out of
  PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift
  (seeded from .example by generate-xcodeproj.sh)
- docs(agents): add Conventional Commits versioning + bilingual changelog rules
- chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache

Custom language model / lexicon work stays on
feature/custom-language-model-asr. Changelog bullets added under
[Unreleased]; no version bump.
This commit is contained in:
Rocky
2026-07-05 18:27:23 +08:00
parent 074e24d87f
commit 05e005e9ce
60 changed files with 3247 additions and 1388 deletions
@@ -0,0 +1,139 @@
// CursorNavigationTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class CursorNavigationTests: XCTestCase {
/// Uses the built-in 1/2-unit width table. `lineWidth` is therefore in
/// "units" ( Latin characters) for these tests.
private func config(lineWidth: CGFloat) -> CursorNavigation.VisualLineLayoutConfig {
CursorNavigation.VisualLineLayoutConfig(lineWidth: lineWidth)
}
func testColumnOnFirstLine() {
XCTAssertEqual(CursorNavigation.column(before: "hello"), 5)
XCTAssertEqual(CursorNavigation.column(before: nil), 0)
}
func testColumnAfterNewline() {
XCTAssertEqual(CursorNavigation.column(before: "hello\nwor"), 3)
}
func testDefaultDisplayWidthLatinAndCJK() {
XCTAssertEqual(CursorNavigation.defaultDisplayWidth("a"), 1)
XCTAssertEqual(CursorNavigation.defaultDisplayWidth(""), 2)
XCTAssertEqual(CursorNavigation.defaultDisplayWidth("\n"), 0)
}
func testVisualLineDownAcrossSoftWrap() {
// 20 Latin chars, wrap at 16 line0 [0,16), line1 [16,20).
let text = String(repeating: "a", count: 20)
let before = String(text.prefix(8))
let after = String(text.suffix(12))
let result = CursorNavigation.visualLineDownOffset(
before: before,
after: after,
preferredDisplayColumn: nil,
config: config(lineWidth: 16)
)
XCTAssertNotNil(result)
// Sticky column 8; line1 only has 4 units clamp to its end.
XCTAssertEqual(result?.offset, 12)
}
func testVisualLineDownToShorterWrappedLineClampsToEnd() {
// Caret at col 15 (clearly on line0); line1 has only 4 chars.
let text = String(repeating: "a", count: 20)
let before = String(text.prefix(15))
let after = String(text.suffix(5))
let result = CursorNavigation.visualLineDownOffset(
before: before,
after: after,
preferredDisplayColumn: 15,
config: config(lineWidth: 16)
)
XCTAssertNotNil(result)
XCTAssertEqual(result?.offset, 5)
XCTAssertEqual(result?.stickyColumn, 15)
}
func testVisualLineUpAcrossSoftWrap() {
let text = String(repeating: "a", count: 20)
let before = String(text.prefix(18))
let after = String(text.suffix(2))
let result = CursorNavigation.visualLineUpOffset(
before: before,
after: after,
preferredDisplayColumn: 8,
config: config(lineWidth: 16)
)
XCTAssertNotNil(result)
XCTAssertEqual(result?.offset, -10)
}
func testVisualLineDownAcrossHardNewline() {
let before = "hello\nwor"
let after = "ld\nfoo"
let result = CursorNavigation.visualLineDownOffset(
before: before,
after: after,
preferredDisplayColumn: nil,
config: config(lineWidth: 100)
)
XCTAssertNotNil(result)
// "hello\nworld\nfoo" caret before "ld"; column 3 lands after "foo".
XCTAssertEqual(result?.offset, 6)
}
func testVisualLineDownPreservesStickyColumnOnLongerNextLine() {
let before = "hello\nwor"
let after = "ld\nfoobarbaz"
let result = CursorNavigation.visualLineDownOffset(
before: before,
after: after,
preferredDisplayColumn: 5,
config: config(lineWidth: 100)
)
XCTAssertNotNil(result)
// "ld\n" (3) + column 5 on "foobarbaz" = 8 total from cursor.
XCTAssertEqual(result?.offset, 8)
XCTAssertEqual(result?.stickyColumn, 5)
}
func testVisualLineUpOnFirstLineReturnsNil() {
XCTAssertNil(
CursorNavigation.visualLineUpOffset(
before: "hello",
after: " world",
preferredDisplayColumn: nil,
config: config(lineWidth: 100)
)
)
}
func testVisualLineDownWithNoFollowingTextReturnsNil() {
XCTAssertNil(
CursorNavigation.visualLineDownOffset(
before: "hello",
after: nil,
preferredDisplayColumn: nil,
config: config(lineWidth: 100)
)
)
XCTAssertNil(
CursorNavigation.visualLineDownOffset(
before: "hello",
after: "",
preferredDisplayColumn: nil,
config: config(lineWidth: 100)
)
)
}
}
+269 -98
View File
@@ -2,7 +2,7 @@
// OSGKeyboard · Tests
//
// v0.3.0: locks the behavior of the rewritten PolishingService and
// its two supporting services (AppContextDetector, DictionaryLearner).
// its supporting service (AppContextDetector).
// The tests are deliberately hermetic no LLMClient, no ASR, no
// App Group so they run in <100 ms total.
@@ -18,11 +18,6 @@ final class IntelligentPolishTests: XCTestCase {
override func setUp() {
super.setUp()
// Each test gets a fresh, throwaway UserDefaults suite so
// engine mode / API key / dictionary / context state does
// not leak between tests. The AppGroupStore falls back to
// `.standard` when no App Group entitlement is present, so
// we point it at a private suite to keep this test hermetic.
suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
@@ -34,31 +29,95 @@ final class IntelligentPolishTests: XCTestCase {
super.tearDown()
}
// MARK: - PolishingService prompt construction
// MARK: - Polish intensity migration
func testPolishServiceOffIntensitySkipsLLM() async throws {
// When intensity is `.off`, the service must return the
// raw input unchanged *and* not touch the LLM. We assert
// both by passing a deliberately broken LLM client and
// expecting the call to return cleanly.
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: ThrowingLLMClient() // would throw if invoked
)
let result = try await service.polish("hello world", context: PolishContext(intensity: .off))
XCTAssertEqual(result, "hello world")
func testPolishIntensityMigratesLegacyOffToMedium() {
defaults.set(PolishIntensity.legacyOffRawValue, forKey: "config.polishIntensity")
XCTAssertEqual(store.polishIntensity, .medium)
XCTAssertEqual(defaults.string(forKey: "config.polishIntensity"), PolishIntensity.medium.rawValue)
}
func testPolishServiceLocalEngineWithoutCloudPolishReturnsRaw() async throws {
store.setEngineMode("local")
// localModeCloudPolishEnabled defaults to false.
func testPolishIntensityResolveLegacyOff() {
XCTAssertEqual(PolishIntensity.resolve(storedRawValue: "off"), .medium)
}
// MARK: - PolishingService prompt construction
func testPolishServiceUltraShortTextSkipsLLM() async throws {
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: ThrowingLLMClient()
)
let result = try await service.polish("hello world", context: PolishContext(intensity: .medium))
XCTAssertEqual(result, "hello world")
let result = try await service.polish("", context: PolishContext(intensity: .heavy))
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(intensity: .medium)
)
XCTAssertFalse(captured.lastPrompt.isEmpty)
}
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 testPersonalDictionaryMigratesLegacyHistorySource() {
let legacy = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .history),
])
let data = try! JSONEncoder().encode(legacy)
defaults.set(data, forKey: "config.personalDictionary.v1")
let loaded = store.personalDictionary
XCTAssertEqual(loaded.entries.first?.source, .manual)
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, intensity: .medium)
)
XCTAssertFalse(captured.lastPrompt.isEmpty)
}
func testPolishServiceMissingAPIKeyThrows() async {
@@ -78,9 +137,6 @@ final class IntelligentPolishTests: XCTestCase {
}
func testPolishServiceShortTextSkipsLLM() async throws {
// Per the prompt's hard rule #4, 8 CJK chars / 15
// English words must be returned verbatim. We exercise
// the upper bound here.
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
@@ -107,24 +163,174 @@ final class IntelligentPolishTests: XCTestCase {
"Prompt must include dictionary term. Got: \(captured.lastPrompt)")
XCTAssertTrue(captured.lastPrompt.contains("Code context"),
"Prompt must include app-context guideline. Got: \(captured.lastPrompt)")
XCTAssertTrue(captured.lastPrompt.contains("medium") || captured.lastPrompt.contains("中度"),
"Prompt must mention the intensity. 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 testPolishServicePromptIncludesStructureRulesAtLightIntensity() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天有三个任务第一点修复登录第二点优化键盘",
context: PolishContext(intensity: .light)
)
XCTAssertTrue(
captured.lastPrompt.contains("第一点") || captured.lastPrompt.contains("numbered"),
"Light intensity must still 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(intensity: .medium))
let passedTimeout = try XCTUnwrap(captured.lastTimeout)
XCTAssertGreaterThan(
passedTimeout, 15,
"Long transcripts must scale the per-request HTTP timeout above the baseline"
)
}
func testPolishServiceCapsTimeoutAt120() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured, timeout: 15)
let veryLong = String(repeating: "测试", count: 2000)
_ = try await service.polish(veryLong, context: PolishContext(intensity: .medium))
let passedTimeout = try XCTUnwrap(captured.lastTimeout)
XCTAssertLessThanOrEqual(passedTimeout, 120)
}
func testPolishServiceUsesChineseForChineseProviders() async throws {
defaults.set("deepseek", forKey: "config.providerId")
store.setEngineMode("cloud")
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish("hello", context: PolishContext(intensity: .medium))
// The polisher routes Chinese providers through the Chinese
// prompt, which is identifiable by its "" header.
XCTAssertTrue(
captured.lastPrompt.contains("三件事"),
"DeepSeek should get the Chinese prompt. Got prefix: \(captured.lastPrompt.prefix(80))"
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(intensity: .medium)
)
XCTAssertFalse(result.contains("👍"))
XCTAssertTrue(result.contains("完成"))
}
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(intensity: .medium)
)
XCTAssertEqual(result, "今天的部署已经全部完成")
}
// MARK: - TranscriptPostProcessor
func testShouldSkipLLMForUltraShortWithoutStructure() {
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: ""))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "OK"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "明天见"))
}
func testShouldNotSkipLLMWhenStructurePresent() {
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "第一点做完第二点再做"))
}
func testStripAddedEmojisRemovesNewEmoji() {
let result = TranscriptPostProcessor.stripAddedEmojis(
original: "好的",
output: "好的👍"
)
XCTAssertEqual(result, "好的")
}
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 testHasStructureSignalDetectsChineseEnumeration() {
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "首先测试其次上线"))
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "第一点修复"))
}
// MARK: - AppContextDetector
func testAppContextDetectorRecognizesCodeByIndentation() {
@@ -165,29 +371,32 @@ final class IntelligentPolishTests: XCTestCase {
func testAppContextDetectorFallbackChain() {
let detector = AppContextDetector()
// No preceding text and no cache environmental fallback.
let env = detector.detect(
precedingText: nil,
storedCache: nil,
now: Date(timeIntervalSince1970: 1_700_000_000) // a workday moment
now: Date(timeIntervalSince1970: 1_700_000_000)
)
XCTAssertNotEqual(env, .unknown)
}
func testAppContextDetectorCacheWinsOverFallback() {
let detector = AppContextDetector()
// 5-minute-old cache with `.code` must be returned even
// when there is no preceding text.
let cache = (context: AppContext.code, observedAt: Date().addingTimeInterval(-300))
let result = detector.detect(precedingText: "", storedCache: cache)
XCTAssertEqual(result, .code)
}
func testChatAppContextGuidelineDoesNotEncourageEmoji() {
let guideline = AppContext.chat.polishGuideline
XCTAssertFalse(guideline.localizedCaseInsensitiveContains("emoji-friendly"))
XCTAssertTrue(guideline.localizedCaseInsensitiveContains("Do not add emojis"))
}
// MARK: - PersonalDictionary.promptFragment
func testDictionaryPromptFragmentIsEmptyForEmptyDictionary() {
func testDictionaryPromptFragmentIncludesBuiltInOSGKeyboard() {
let prompt = PersonalDictionary.empty.promptFragment()
XCTAssertEqual(prompt, "")
XCTAssertTrue(prompt.contains("OSGKeyboard"))
}
func testDictionaryPromptFragmentGroupsByCategory() {
@@ -197,86 +406,48 @@ final class IntelligentPolishTests: XCTestCase {
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: - DictionaryLearner
func testLearnerPromotesRepeatedCapitalizedToken() {
let history: [SpeechHistoryEntry] = [
.init(text: "Deploy Kubernetes today", engineMode: "cloud"),
.init(text: "Restart Kubernetes pod", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
XCTAssertTrue(added.contains { $0.term == "Kubernetes" },
"Kubernetes should be promoted. Got: \(added.map(\.term))")
}
func testLearnerIgnoresStopwords() {
let history: [SpeechHistoryEntry] = [
.init(text: "this is the test", engineMode: "cloud"),
.init(text: "this is the second test", engineMode: "cloud"),
.init(text: "this is the third test", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
let terms = Set(added.map(\.term))
XCTAssertFalse(terms.contains("this"))
XCTAssertFalse(terms.contains("the"))
XCTAssertFalse(terms.contains("is"))
}
func testLearnerRespectsMinimumOccurrence() {
let history: [SpeechHistoryEntry] = [
.init(text: "First time mentioning Whisper", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
XCTAssertFalse(added.contains { $0.term == "Whisper" })
}
func testLearnerIdempotent() {
let history: [SpeechHistoryEntry] = [
.init(text: "OpenAI rocks", engineMode: "cloud"),
.init(text: "OpenAI again", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let first = learner.learn(from: history)
let second = learner.learn(from: history)
XCTAssertTrue(first.contains { $0.term == "OpenAI" })
// Second call must not double-add; the existing entry's
// usage count is bumped instead.
let openaiEntries = second.filter { $0.term == "OpenAI" }
XCTAssertEqual(openaiEntries.count, 1)
}
}
// MARK: - Test doubles
/// Records every call so the test can inspect the prompt the
/// polisher would have sent. We do not assert on `response`; the
/// LLMClient contract is exercised by `LLMClientTests`.
private final class CapturingLLMClient: LLMClient, @unchecked Sendable {
private(set) var lastPrompt: String = ""
private(set) var lastTimeout: TimeInterval?
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String {
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
lastPrompt = systemPrompt
lastTimeout = timeout
return text
}
}
private final class EchoLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String { text }
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String { text }
}
private final class ThrowingLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String {
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
throw LLMError.cancelled
}
}
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
}
}
@@ -101,8 +101,12 @@ final class KeyboardOnboardingOverlayTests: XCTestCase {
func testPolishIntensityRoundTrip() {
store.setPolishIntensity(.heavy)
XCTAssertEqual(store.polishIntensity, .heavy)
store.setPolishIntensity(.off)
XCTAssertEqual(store.polishIntensity, .off,
"off should round-trip through UserDefaults (NOT skip the write)")
store.setPolishIntensity(.light)
XCTAssertEqual(store.polishIntensity, .light)
}
func testPolishIntensityLegacyOffMigratesToMedium() {
defaults.set(PolishIntensity.legacyOffRawValue, forKey: "config.polishIntensity")
XCTAssertEqual(store.polishIntensity, .medium)
}
}
+11
View File
@@ -12,10 +12,14 @@ final class KeychainTests: XCTestCase {
override func setUpWithError() throws {
try? Keychain.deleteAPIKey()
try? Keychain.deleteLegacyAPIKey()
try? Keychain.deleteAPIKey(for: "qwen")
}
override func tearDownWithError() throws {
try? Keychain.deleteAPIKey()
try? Keychain.deleteLegacyAPIKey()
try? Keychain.deleteAPIKey(for: "qwen")
}
// MARK: - Round-trip
@@ -49,6 +53,13 @@ final class KeychainTests: XCTestCase {
XCTAssertNil(Keychain.apiKey(), "Empty write must delete, not store empty string")
}
func testProviderScopedKeysDoNotMix() throws {
try Keychain.setAPIKey("sk-openai", for: "openai")
try Keychain.setAPIKey("sk-qwen", for: "qwen")
XCTAssertEqual(Keychain.apiKey(for: "openai"), "sk-openai")
XCTAssertEqual(Keychain.apiKey(for: "qwen"), "sk-qwen")
}
/// Deleting a non-existent entry must be a no-op (idempotent), not an
/// error callers like `ProviderConfig.reset()` invoke it
/// unconditionally.
+10 -48
View File
@@ -15,6 +15,8 @@ final class LLMClientTests: XCTestCase {
// leak into the next one unless we wipe it here. We intentionally
// swallow errors `errSecItemNotFound` is fine.
try? Keychain.deleteAPIKey()
try? Keychain.deleteLegacyAPIKey()
try? Keychain.deleteAPIKey(for: "qwen")
StubURLProtocolStorage.config = nil
StubURLProtocolStorage.delaySeconds = 0
StubURLProtocolStorage.lastRequest = nil
@@ -22,6 +24,8 @@ final class LLMClientTests: XCTestCase {
override func tearDownWithError() throws {
try? Keychain.deleteAPIKey()
try? Keychain.deleteLegacyAPIKey()
try? Keychain.deleteAPIKey(for: "qwen")
StubURLProtocolStorage.config = nil
StubURLProtocolStorage.delaySeconds = 0
StubURLProtocolStorage.lastRequest = nil
@@ -394,54 +398,13 @@ final class LLMClientTests: XCTestCase {
XCTAssertFalse(cloudStore.isTranslationEffective)
defaults.set("local", forKey: "config.engineMode")
defaults.set(true, forKey: "config.localModeCloudPolishEnabled")
let localPolishOn = AppGroupStore(defaults: defaults)
XCTAssertTrue(localPolishOn.isTranslationChipVisible)
XCTAssertFalse(localPolishOn.isTranslationEffective)
defaults.set(false, forKey: "config.localModeCloudPolishEnabled")
let localPolishOff = AppGroupStore(defaults: defaults)
XCTAssertFalse(localPolishOff.isTranslationChipVisible)
defaults.set(TranslationLanguageCatalog.offLocaleId, forKey: "config.translationTargetLocaleId")
let localStore = AppGroupStore(defaults: defaults)
XCTAssertTrue(localStore.isTranslationChipVisible)
XCTAssertFalse(localStore.isTranslationEffective)
}
/// Local engine with translation enabled must invoke the LLM even
/// when the cloud-polish toggle is off.
func testPolisherSkipsLLMWhenLocalCloudPolishOffEvenWithTranslation() async throws {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set("local", forKey: "config.engineMode")
defaults.set("en", forKey: "config.translationTargetLocaleId")
defaults.set(false, forKey: "config.localModeCloudPolishEnabled")
let counter = CallCounter()
let countingClient = CountingLLMClient(counter: counter) { _, _ in
XCTFail("cloud LLMClient must not run when local cloud polish is off")
return ""
}
let store = AppGroupStore(defaults: defaults)
XCTAssertFalse(store.shouldRunCloudLLMStep)
let polisher = PolishingService(
store: store,
client: countingClient,
timeout: 1
)
let result = try await polisher.polish(
" 你好 ",
mode: .translate(targetLocaleId: "en"),
providerIdOverride: "deepseek"
)
XCTAssertEqual(result, "你好")
let calls = await counter.value()
XCTAssertEqual(calls, 0)
}
/// Local engine with cloud polish + translation enabled invokes LLM.
/// Local engine always runs the LLM step when translation is armed.
func testPolisherTranslatesWhenLocalEngineTranslationEnabled() async throws {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
@@ -450,7 +413,6 @@ final class LLMClientTests: XCTestCase {
defaults.set("local", forKey: "config.engineMode")
defaults.set("en", forKey: "config.translationTargetLocaleId")
defaults.set(true, forKey: "config.localModeCloudPolishEnabled")
let counter = CallCounter()
let countingClient = CountingLLMClient(counter: counter) { raw, prompt in
@@ -504,7 +466,7 @@ private struct CountingLLMClient: LLMClient {
var requestTimeout: TimeInterval { 15 }
func polish(_ text: String, systemPrompt: String) async throws -> String {
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
await counter.bump()
return try await body(text, systemPrompt)
}