Merge branch 'feature/intelligent-polish-and-personal-dict' into main
Resolved real conflicts introduced by translation-polish-2 (#3) and docs refresh (#4) being merged to main during development: - OSGKeyboardShared/Models/ProviderConfig.swift Add polishIntensity field alongside translationTargetLocaleId / polishScenarioId / handednessPreference. Both new fields coexist. - OSGKeyboardShared/Services/AppGroupStore.swift Keep main's translation setters; add polishIntensity setter, detectedAppContext setter, and personalDictionary getter/setter. - OSGKeyboardShared/Services/PolishingService.swift Merge v0.2.1 translate-and-polish path with v0.3.0 context-aware intelligent prompt. New polish() entry point accepts both modes; translation mode still uses TranslationPrompt, polish mode now uses buildPrompt(for:context:). - OSGKeyboard/Views/SettingsView.swift Compose localEngineSettingsSection (main) with polishIntensitySection, languageAndModelsSection, systemPromptLinkSection, personalDictionaryLinkSection (feature). - OSGKeyboardShared/{en,zh-Hans}.lproj/Shared.strings Concat scenario keys + polish intensity / app context / dictionary keys. Auto-merged without conflict: - AppGroupStore new methods (localASRBackend, etc.) - FlowSessionManager, KeyboardViewController (auto-merged) - MaterialIcon, HistoryView (auto-merged, my icons added on top) Co-authored-by: Mavis <Mavis@hkgood.dev>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
// IntelligentPolishTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
//
|
||||
// v0.3.0: locks the behavior of the rewritten PolishingService and
|
||||
// its two supporting services (AppContextDetector, DictionaryLearner).
|
||||
// The tests are deliberately hermetic — no LLMClient, no ASR, no
|
||||
// App Group — so they run in <100 ms total.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class IntelligentPolishTests: XCTestCase {
|
||||
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppGroupStore!
|
||||
|
||||
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)
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - PolishingService prompt construction
|
||||
|
||||
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 testPolishServiceLocalEngineWithoutCloudPolishReturnsRaw() async throws {
|
||||
store.setEngineMode("local")
|
||||
// localModeCloudPolishEnabled defaults to false.
|
||||
let service = PolishingService(
|
||||
store: store,
|
||||
client: ThrowingLLMClient()
|
||||
)
|
||||
let result = try await service.polish("hello world", context: PolishContext(intensity: .medium))
|
||||
XCTAssertEqual(result, "hello world")
|
||||
}
|
||||
|
||||
func testPolishServiceMissingAPIKeyThrows() async {
|
||||
store.setEngineMode("cloud")
|
||||
let service = PolishingService(
|
||||
store: store,
|
||||
client: EchoLLMClient()
|
||||
)
|
||||
do {
|
||||
_ = try await service.polish("hello world", context: PolishContext(intensity: .medium))
|
||||
XCTFail("Expected missingAPIKey")
|
||||
} catch let error as PolishingService.PolishError {
|
||||
XCTAssertEqual(error, .missingAPIKey)
|
||||
} catch {
|
||||
XCTFail("Expected PolishError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
client: ThrowingLLMClient()
|
||||
)
|
||||
let result = try await service.polish("明天见", context: PolishContext(intensity: .heavy))
|
||||
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, intensity: .medium)
|
||||
)
|
||||
XCTAssertTrue(captured.lastPrompt.contains("Kubernetes"),
|
||||
"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)")
|
||||
}
|
||||
|
||||
func testPolishServiceUsesChineseForChineseProviders() async throws {
|
||||
defaults.set("deepseek", forKey: "config.providerId")
|
||||
store.setEngineMode("cloud")
|
||||
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))"
|
||||
)
|
||||
}
|
||||
|
||||
// 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()
|
||||
// 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
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
// MARK: - PersonalDictionary.promptFragment
|
||||
|
||||
func testDictionaryPromptFragmentIsEmptyForEmptyDictionary() {
|
||||
let prompt = PersonalDictionary.empty.promptFragment()
|
||||
XCTAssertEqual(prompt, "")
|
||||
}
|
||||
|
||||
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("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 = ""
|
||||
let requestTimeout: TimeInterval = 15
|
||||
|
||||
func polish(_ text: String, systemPrompt: String) async throws -> String {
|
||||
lastPrompt = systemPrompt
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
private final class EchoLLMClient: LLMClient, @unchecked Sendable {
|
||||
let requestTimeout: TimeInterval = 15
|
||||
func polish(_ text: String, systemPrompt: String) async throws -> String { text }
|
||||
}
|
||||
|
||||
private final class ThrowingLLMClient: LLMClient, @unchecked Sendable {
|
||||
let requestTimeout: TimeInterval = 15
|
||||
func polish(_ text: String, systemPrompt: String) async throws -> String {
|
||||
throw LLMError.cancelled
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user