Expand onboarding and adaptive keyboard intelligence

Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
This commit is contained in:
Rocky
2026-08-22 16:33:18 +08:00
parent ac374631ae
commit e5a83843db
162 changed files with 23574 additions and 1013 deletions
@@ -89,8 +89,10 @@ final class AnalyticsExtensionPrivacyTests: XCTestCase {
let root = try XCTUnwrap(
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
)
XCTAssertEqual(Set(root.keys), ["installationId", "events"])
let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]])
let event = try XCTUnwrap(eventObjects.first)
XCTAssertFalse(event.keys.contains("installationId"))
XCTAssertFalse(event.keys.contains("properties"))
XCTAssertFalse(event.keys.contains("text"))
XCTAssertFalse(event.keys.contains("prompt"))
@@ -13,6 +13,7 @@
// *don't* attempt that here the State class is what we care about
// for correctness.
import Combine
@testable import OSGKeyboardShared
import XCTest
@@ -144,6 +145,50 @@ final class KeyboardStateTests: XCTestCase {
XCTAssertEqual(state.clipboardOverlay, .none)
}
func testResolvedSkillCopyPublishesWhenIDsStayStable() {
let state = KeyboardState()
state.enabledClipboardSkillIDs = ["official.rewrite"]
let first = AIClipboardSkill(
id: "official.rewrite",
systemImage: "wand.and.stars",
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: .transform,
isDefault: false,
customName: "Rewrite",
customSummary: "Summary",
customPrompt: "First prompt"
)
let second = AIClipboardSkill(
id: "official.rewrite",
systemImage: "wand.and.stars",
titleKey: "",
cardTitleKey: "",
descriptionKey: "",
kind: .transform,
isDefault: false,
customName: "Rewrite updated",
customSummary: "Summary",
customPrompt: "Second prompt"
)
state.enabledClipboardSkills = [first]
let published = expectation(description: "resolved skill snapshot published")
var cancellable: AnyCancellable?
cancellable = state.$enabledClipboardSkills
.dropFirst()
.sink { skills in
XCTAssertEqual(skills.first?.customPrompt, "Second prompt")
published.fulfill()
}
state.enabledClipboardSkills = [second]
wait(for: [published], timeout: 1)
XCTAssertEqual(state.enabledClipboardSkillIDs, ["official.rewrite"])
withExtendedLifetime(cancellable) {}
}
func testReturnKeyRoleActionFillAndTitles() {
XCTAssertFalse(KeyboardState.ReturnKeyRole.newline.usesActionFill)
XCTAssertTrue(KeyboardState.ReturnKeyRole.send.usesActionFill)
@@ -0,0 +1,86 @@
// KeyboardUsageTypingTests.swift
// OSGKeyboardExtTests
//
// Chinese preedit remains in the IME; only committed candidate text is counted.
@testable import OSGKeyboardShared
import XCTest
@MainActor
final class KeyboardUsageTypingTests: XCTestCase {
func testPinyinCompositionDoesNotCountLatinBeforeHanCommit() {
let engine = KeyboardUsageRimeStub()
let typing = TypingSessionController(engine: { engine })
let first = typing.handleKey("n")
let second = typing.handleKey("i")
XCTAssertEqual(first, .none)
XCTAssertEqual(second, .none)
XCTAssertEqual(engine.composition.rawInput, "ni")
let committed = typing.handleSpace()
XCTAssertEqual(committed, .insert("你好"))
let counts = KeyboardUsageCharacterClassifier.classify(committed.text)
XCTAssertEqual(counts.chinese, 2)
XCTAssertEqual(counts.english, 0)
XCTAssertEqual(counts.other, 0)
}
}
@MainActor
private final class KeyboardUsageRimeStub: RimeEngineBridging {
private(set) var composition: TypingComposition = .empty
var isReady = true
var schema: TypingInputSchema = .fullPinyin
func prepare() async throws {}
func teardown() {
composition = .empty
}
func setLanguage(_ language: TypingInputLanguage) {}
@discardableResult
func setSchema(_ schema: TypingInputSchema) -> Bool {
self.schema = schema
return true
}
func processCharacter(_ character: Character) -> String? {
composition.rawInput.append(character)
composition.preedit = composition.rawInput
return nil
}
func processBackspace() -> String? {
if !composition.rawInput.isEmpty {
composition.rawInput.removeLast()
}
composition.preedit = composition.rawInput
return nil
}
func processSpace() -> String? {
composition = .empty
return "你好"
}
func processReturn() -> String? {
processSpace()
}
func selectCandidate(at index: Int) -> String {
processSpace() ?? ""
}
func flushPreedit() -> String {
let raw = composition.rawInput
composition = .empty
return raw
}
func clearComposition() {
composition = .empty
}
}
+184 -4
View File
@@ -38,6 +38,16 @@ final class ManagedGatewayTests: XCTestCase {
XCTAssertEqual(cloudScopes, [.polish, .assistant, .agent, .asr])
}
func testOOBEFeatureConflictIsNotMappedToNetworkFailure() {
let error = ManagedGatewayHTTP.error(
data: errorJSON("oobe_feature_already_used"),
status: 409,
requestId: "oobe-conflict"
)
XCTAssertEqual(error, .oobeFeatureAlreadyUsed)
}
func testCreateGrantSendsAccountTokenButStoresOnlyGrant() async throws {
GatewayStub.shared.enqueue(
201,
@@ -128,6 +138,40 @@ final class ManagedGatewayTests: XCTestCase {
XCTAssertEqual(try jsonBody(request)["refreshToken"] as? String, oldRefresh)
}
func testSignedOutPolicyRejectsCachedAccountGrant() async throws {
let suiteName = "ManagedGatewayTests.signedOut.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let now = Date(timeIntervalSince1970: 3_000)
let store = MemoryGrantStore(
credentials(
accessToken: "cached-account-access",
accessExpiresAt: now.addingTimeInterval(240),
refreshExpiresAt: now.addingTimeInterval(3_600),
receivedAt: now
)
)
let accountState = AppGroupStore(defaults: defaults)
let coordinator = GatewayGrantCoordinator(
baseURL: baseURL,
store: store,
session: stubSession(),
now: { now },
accountAccessPolicy: AppGroupManagedGatewayAccountAccessPolicy(
defaults: defaults
)
)
await XCTAssertThrowsManaged(.missingGrant) {
_ = try await coordinator.accessToken(for: .polish)
}
XCTAssertTrue(GatewayStub.shared.requests().isEmpty)
accountState.setManagedGatewayAccountSessionAvailable(true)
let token = try await coordinator.accessToken(for: .polish)
XCTAssertEqual(token, "cached-account-access")
}
func testManagedClientMapsAllCapabilitiesHeadersBodyAndRequestIds() async throws {
let now = Date(timeIntervalSince1970: 3_000)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
@@ -187,6 +231,7 @@ final class ManagedGatewayTests: XCTestCase {
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
(.polish, .translation),
(.polish, .editLastInput),
(.assistant, .currentInformationQuestion),
(.assistant, .clipboardTransform),
(.assistant, .customSkill)
]
@@ -211,13 +256,39 @@ final class ManagedGatewayTests: XCTestCase {
func testManagedClientSerializesOOBERequestPurpose() async throws {
let now = Date(timeIntervalSince1970: 3_750)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
let store = MemoryGrantStore(credentials(accessToken: "account-access", receivedAt: now))
let oobeStore = MemoryGrantStore(
credentials(
accessToken: "oobe-access",
receivedAt: now,
scopes: [.polish, .assistant]
)
)
let suite = "managed.oobe.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let practice = try XCTUnwrap(
KeyboardSetupBridge.beginOOBEPracticeSession(
expectedFeature: .voiceInput,
defaults: defaults,
now: now
)
)
XCTAssertEqual(practice.expectedFeature, .voiceInput)
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
let client = ManagedLLMClient(
capability: .polish,
taskKind: .dictationPolish,
requestPurpose: .oobe,
oobeFeature: .voiceInput,
grants: makeCoordinator(store: store, now: { now }),
oobeGrants: OOBEGatewayGrantCoordinator(
baseURL: baseURL,
store: oobeStore,
session: stubSession(),
now: { now },
practiceSession: { practice }
),
baseURL: baseURL,
session: stubSession()
)
@@ -228,6 +299,107 @@ final class ManagedGatewayTests: XCTestCase {
let body = try jsonBody(request)
XCTAssertEqual(body["taskKind"] as? String, "dictation_polish")
XCTAssertEqual(body["requestPurpose"] as? String, "oobe")
XCTAssertEqual(body["oobeFeature"] as? String, "voice_input")
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer oobe-access")
let accountCredentials = await store.value()
XCTAssertEqual(accountCredentials?.accessToken, "account-access")
}
func testOOBEGrantRejectsAgentOrASRScopes() async {
let now = Date(timeIntervalSince1970: 3_775)
let coordinator = OOBEGatewayGrantCoordinator(
baseURL: baseURL,
store: MemoryGrantStore(),
session: stubSession(),
now: { now }
)
await XCTAssertThrowsManaged(.invalidGrant) {
try await coordinator.install(
self.credentials(
receivedAt: now,
scopes: [.polish, .assistant, .agent]
)
)
}
await XCTAssertThrowsManaged(.invalidGrant) {
try await coordinator.install(
self.credentials(
receivedAt: now,
scopes: [.polish, .assistant, .asr]
)
)
}
}
func testOOBERefreshUsesDedicatedEndpoint() async throws {
let now = Date(timeIntervalSince1970: 3_790)
let practice = OOBEPracticeSession(
sessionID: UUID(),
expectedFeature: .voiceInput,
startedAt: now.addingTimeInterval(-10),
expiresAt: now.addingTimeInterval(300)
)
let store = MemoryGrantStore(
credentials(
accessToken: "expired",
accessExpiresAt: now.addingTimeInterval(-1),
receivedAt: now.addingTimeInterval(-600),
scopes: [.polish, .assistant]
)
)
GatewayStub.shared.enqueue(
200,
grantJSON(
access: "refreshed",
refresh: "rotated",
scopes: ["polish", "ai"]
)
)
let coordinator = OOBEGatewayGrantCoordinator(
baseURL: baseURL,
store: store,
session: stubSession(),
now: { now },
practiceSession: { practice }
)
let token = try await coordinator.accessToken(
for: .polish,
feature: .voiceInput
)
XCTAssertEqual(token, "refreshed")
XCTAssertEqual(
GatewayStub.shared.requests().first?.url?.path,
"/v1/oobe/grants/refresh"
)
}
func testOOBEClientRejectsMissingFeatureWithoutUsingAccountGrant() async {
let now = Date(timeIntervalSince1970: 3_800)
let store = MemoryGrantStore(credentials(accessToken: "account-access", receivedAt: now))
let client = ManagedLLMClient(
capability: .polish,
requestPurpose: .oobe,
grants: makeCoordinator(store: store, now: { now }),
baseURL: baseURL,
session: stubSession()
)
await XCTAssertThrowsManaged(
.server(code: "missing_oobe_feature", status: 400, requestId: "fixed")
) {
_ = try await ManagedLLMClient(
capability: client.capability,
requestPurpose: client.requestPurpose,
grants: makeCoordinator(store: store, now: { now }),
baseURL: self.baseURL,
session: self.stubSession(),
requestId: { "fixed" }
).polish("input", systemPrompt: "context")
}
XCTAssertTrue(GatewayStub.shared.requests().isEmpty)
}
func testManagedTaskKindWireValuesMatchServerContract() {
@@ -238,6 +410,7 @@ final class ManagedGatewayTests: XCTestCase {
"translation",
"edit_last_input",
"ai_question",
"current_information_question",
"clipboard_transform",
"custom_skill",
"agent_planning"
@@ -412,7 +585,13 @@ final class ManagedGatewayTests: XCTestCase {
store: MemoryGrantStore,
now: @escaping @Sendable () -> Date = Date.init
) -> GatewayGrantCoordinator {
GatewayGrantCoordinator(baseURL: baseURL, store: store, session: stubSession(), now: now)
GatewayGrantCoordinator(
baseURL: baseURL,
store: store,
session: stubSession(),
now: now,
accountAccessPolicy: UnrestrictedManagedGatewayAccountAccessPolicy()
)
}
private func stubSession() -> URLSession {
@@ -426,11 +605,12 @@ final class ManagedGatewayTests: XCTestCase {
refreshToken: String = "refresh-token-value-12345678901234567890",
accessExpiresAt: Date = Date(timeIntervalSince1970: 20_000),
refreshExpiresAt: Date = Date(timeIntervalSince1970: 40_000),
receivedAt: Date = Date(timeIntervalSince1970: 10_000)
receivedAt: Date = Date(timeIntervalSince1970: 10_000),
scopes: Set<ManagedGatewayCapability> = [.polish, .assistant, .agent]
) -> ManagedGatewayGrantCredentials {
ManagedGatewayGrantCredentials(
grantId: "11111111-1111-1111-1111-111111111111",
scopes: [.polish, .assistant, .agent],
scopes: scopes,
accessToken: accessToken,
accessExpiresAt: accessExpiresAt,
refreshToken: refreshToken,