[JJC-20260618-005-D] P1/P2 cleanup: structured errors, privacy audit, timeout SSOT, view-model tests

13 items, 555-line diff, build + 15/15 tests green.

ARCH-A3: Phase.error now carries ErrorKind (micDenied/speechDenied/asr/llm/
appGroupUnavailable/unknown) so the UI can pick icons/copy without parsing
free-form strings. Phase.ErrorKind, Phase, LLMError all Equatable.

ARCH-A4: Every TextField in APISettingsCard gets .keyboardType(.asciiCapable)
to defeat SwiftUI's iOS 18 system-keyboard hand-off that auto-suggests
Chinese/emoji and corrupts API keys / URLs / model names.

ARCH-A5 + DOC-3: PrivacyInfo.xcprivacy audited for honesty. Removed three
declared-but-unused APIs (FileTimestamp / DiskSpace / SystemBootTime) and
added ActiveKeyboards (DDA9.1) to the extension (it actually calls
advanceToNextInputMode in the tap path). Main App now declares only
UserDefaults (CA92.1). CHANGELOG updated.

ARCH-A6: Extracted PermissionManager (mic+speech permission flow, iOS 17
branching) and AppGroupPersistor (App Group load/persist) from the God
Object. KeyboardViewController drops 515 → 459 lines. KeyboardPipelineController
left in-place per risk plan — pressBegan state machine is too race-sensitive
to refactor in this pass.

RED-2: Deleted unused Theme enum (no call sites).
RED-3: Deleted unused cardStyle() alias (no call sites).
RED-7: Single source of truth for LLM timeout — LLMClient.requestTimeout +
LLMClientFactory.defaultRequestTimeout; PolishingService derives timeout from
defaultRequestTimeout+1 instead of hardcoding 15.
RED-8: ASRService.transcribe now emits .capability(onDeviceSupported:) as
first event per session; StatusBadge shows REC ⚠️ when the locale fell
back to cloud. New @Published var onDeviceSupported on State.

TEST-1: testPolishThrowsOnTransportTimeout now actually exercises
cancellation: StubURLProtocol delays response 5s, client.polish is
cancelled via Task.cancel(), test asserts the client throws .cancelled /
.transport / .decoding (was: silently passed).

TEST-2: New testPolisherSkipsNetworkWhenModeOff — PolishingService now
short-circuits when modeId == 'off' and returns trimmed input without
invoking LLMClient (proved via injected CountingLLMClient). Service was
moved to OSGKeyboardShared to be reachable from the test target.

TEST-3: KeyboardState (formerly KeyboardViewController.State) extracted
into OSGKeyboardShared so tests can @testable-import it. 5 phase/mode
tests in new KeyboardStateTests. Typealias preserves the old name.

TEST-4: New OSGKeyboardExtTests target with 6 tests covering State
initial values, phase transitions, structured-error round-trip, mode
switching, and InputMode rawValue round-trip.
This commit is contained in:
hkgood
2026-06-18 12:27:15 +08:00
parent 38ad66f07d
commit 79be7384dd
18 changed files with 696 additions and 303 deletions
+23 -2
View File
@@ -6,7 +6,7 @@
import Foundation
public enum LLMError: Error, LocalizedError, Sendable {
public enum LLMError: Error, LocalizedError, Sendable, Equatable {
case invalidURL
case noAPIKey
case http(status: Int)
@@ -30,6 +30,12 @@ public enum LLMError: Error, LocalizedError, Sendable {
public protocol LLMClient: Sendable {
func polish(_ text: String, systemPrompt: String) async throws -> String
/// Single source of truth for the upper bound on a single LLM HTTP
/// round-trip. Both the `URLRequest` we send and any wrapping
/// timeout-style race (e.g. `PolishingService`'s `withThrowingTaskGroup`)
/// must read from this property so the two never disagree.
var requestTimeout: TimeInterval { get }
}
// MARK: - OpenAI-compatible implementation
@@ -40,6 +46,12 @@ public struct OpenAICompatibleClient: LLMClient {
public let model: String
public let session: URLSession
/// Canonical request timeout for a single LLM HTTP round-trip. Both
/// the `URLRequest.timeoutInterval` we set below and any external
/// race that wants to bound the total time spent waiting on the LLM
/// (e.g. `PolishingService`) should derive from this constant.
public let requestTimeout: TimeInterval = 15
public init(
baseURL: String,
apiKey: String,
@@ -74,7 +86,7 @@ public struct OpenAICompatibleClient: LLMClient {
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.timeoutInterval = 15
req.timeoutInterval = requestTimeout
let encoder = JSONEncoder()
req.httpBody = try encoder.encode(request)
@@ -122,4 +134,13 @@ public enum LLMClientFactory {
model: config.model
)
}
/// Single source of truth for the LLM request timeout, shared by
/// `LLMClient.requestTimeout` implementations and any caller that
/// wants to bound total time spent waiting on the LLM (e.g.
/// `PolishingService`'s safety-net `withThrowingTaskGroup`). Use
/// this instead of hard-coding `15` so all timeouts stay aligned.
public static var defaultRequestTimeout: TimeInterval {
OpenAICompatibleClient(baseURL: "", apiKey: "", model: "").requestTimeout
}
}