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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user