Files
OSGKeyboard/OSGKeyboardExtTests/KeyboardStateTests.swift
T
hkgood 79be7384dd [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.
2026-06-18 12:27:15 +08:00

82 lines
2.9 KiB
Swift

// KeyboardStateTests.swift
// OSGKeyboard · Keyboard Extension Tests
//
// Target existence tests for the new `OSGKeyboardExtTests` target
// (TEST-4). We focus on `KeyboardState` (formerly `KeyboardViewController.State`,
// now extracted to `OSGKeyboardShared`) because it's the highest-leverage
// thing to test: it owns the published view model that every keyboard UI
// view reads from.
//
// The `KeyboardViewController` itself is hard to instantiate in a test
// host because it derives from `UIInputViewController` and needs a real
// input view, microphone permission prompts, etc. We deliberately
// *don't* attempt that here — the State class is what we care about
// for correctness.
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class KeyboardStateTests: XCTestCase {
func testTargetCompiles() {
// Pure existence check — the build itself proves the target links.
// This test exists so `xcodebuild test` for `OSGKeyboardExtTests`
// has at least one passing assertion.
XCTAssertTrue(true)
}
func testInitialState() {
let s = KeyboardState()
XCTAssertEqual(s.phase, .idle)
XCTAssertEqual(s.mode, .polish)
XCTAssertEqual(s.localeId, "auto")
XCTAssertEqual(s.lastTranscript, "")
XCTAssertEqual(s.level, 0)
XCTAssertFalse(s.onDeviceSupported)
}
func testPhaseTransitionsIdleToRequestingPermissionsAndBack() {
let s = KeyboardState()
s.phase = .requestingPermissions
XCTAssertNotEqual(s.phase, .idle)
s.phase = .recording
XCTAssertEqual(s.phase, .recording)
s.phase = .processing
XCTAssertEqual(s.phase, .processing)
s.phase = .idle
XCTAssertEqual(s.phase, .idle)
}
func testStructuredErrorCarriesLLMError() {
let s = KeyboardState()
let underlying = LLMError.http(status: 401)
s.phase = .error(.llm(underlying), message: "API Key 无效")
if case .error(let kind, let msg) = s.phase {
XCTAssertEqual(kind, .llm(underlying))
XCTAssertEqual(msg, "API Key 无效")
} else {
XCTFail("expected structured .error phase")
}
}
func testModeSwitchFromPolishToOff() {
let s = KeyboardState()
XCTAssertEqual(s.mode, .polish)
s.mode = .off
XCTAssertEqual(s.mode, .off)
s.mode = .transcribe
XCTAssertEqual(s.mode, .transcribe)
s.mode = .polish
XCTAssertEqual(s.mode, .polish)
}
func testInputModeRoundTripsThroughRawValue() {
// The mode is persisted by rawValue (see `AppGroupStore.setModeId`)
// so the round-trip is part of the public contract.
for mode in KeyboardState.InputMode.allCases {
let raw = mode.rawValue
XCTAssertNotNil(KeyboardState.InputMode(rawValue: raw))
}
}
}