checkpoint before checking out main
This commit is contained in:
@@ -21,6 +21,37 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty)
|
||||
}
|
||||
|
||||
func testSystemSemanticSkillsStayEnabledButHiddenFromSkillManagement() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
let hiddenIDs = AIClipboardSkillCatalog.hiddenFromSkillManagementIDs
|
||||
|
||||
XCTAssertTrue(hiddenIDs.isSubset(of: Set(store.enabledSkills.map(\.id))))
|
||||
XCTAssertTrue(
|
||||
hiddenIDs.isDisjoint(with: Set(store.skillManagementEnabledSkills.map(\.id)))
|
||||
)
|
||||
XCTAssertTrue(
|
||||
hiddenIDs.isDisjoint(with: Set(store.skillManagementAvailableSkills.map(\.id)))
|
||||
)
|
||||
XCTAssertTrue(
|
||||
store.skillManagementEnabledSkills.contains {
|
||||
$0.id == AIClipboardSkillCatalog.translateID
|
||||
}
|
||||
)
|
||||
|
||||
store.disable(AIClipboardSkillCatalog.replyID)
|
||||
|
||||
XCTAssertFalse(
|
||||
store.skillManagementAvailableSkills.contains {
|
||||
$0.id == AIClipboardSkillCatalog.replyID
|
||||
}
|
||||
)
|
||||
XCTAssertTrue(
|
||||
store.mergedCatalog.contains {
|
||||
$0.id == AIClipboardSkillCatalog.replyID
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyEnabledListIsPreserved() {
|
||||
let defaults = makeDefaults()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
@@ -30,7 +61,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertEqual(store.agentSkillLayout.enabledIDs, [])
|
||||
}
|
||||
|
||||
func testLegacyLayoutAppendsNewDefaultSkillsWithoutRestoringDisabledLegacySkill() throws {
|
||||
func testLegacyLayoutInstallsCurrentRequiredDefaultSkills() throws {
|
||||
let defaults = makeDefaults()
|
||||
let legacy = AIAgentSkillLayout(
|
||||
enabledIDs: [
|
||||
@@ -50,7 +81,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
Array(migrated.enabledIDs.prefix(2)),
|
||||
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
|
||||
)
|
||||
XCTAssertFalse(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.acceptInvitationID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID))
|
||||
}
|
||||
@@ -77,10 +108,16 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.playfulReplyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -106,10 +143,16 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
migrated.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -135,8 +178,14 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
migrated.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -167,17 +216,63 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
6
|
||||
7
|
||||
)
|
||||
}
|
||||
|
||||
func testVersionSixLayoutInstallsRequiredDefaultsOnlyOnce() throws {
|
||||
let defaults = makeDefaults()
|
||||
let initial = AIAgentSkillLayout(
|
||||
enabledIDs: [AIClipboardSkillCatalog.playfulReplyID],
|
||||
confirmedShortcutIDs: []
|
||||
)
|
||||
defaults.set(
|
||||
try JSONEncoder().encode(initial),
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillLayout
|
||||
)
|
||||
defaults.set(
|
||||
6,
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
)
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
XCTAssertEqual(
|
||||
store.agentSkillLayout.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.playfulReplyID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
|
||||
store.setAgentSkillLayout(
|
||||
AIAgentSkillLayout(
|
||||
enabledIDs: store.agentSkillLayout.enabledIDs.filter {
|
||||
$0 != AIClipboardSkillCatalog.replyID
|
||||
},
|
||||
confirmedShortcutIDs: []
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertFalse(store.agentSkillLayout.isEnabled(AIClipboardSkillCatalog.replyID))
|
||||
}
|
||||
|
||||
func testCannotEnableExportSkillBeforeShortcutConfirmation() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
store.disable(AIClipboardSkillCatalog.extractTodosID)
|
||||
|
||||
@@ -34,6 +34,33 @@ final class AIHistoryAndUsageTests: XCTestCase {
|
||||
XCTAssertEqual(store.totalInputCharacterCount, 4)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testCurrentMonthBuildsCompleteLeapMonthAndZeroFillsMissingDays() throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 0))
|
||||
let now = try XCTUnwrap(
|
||||
calendar.date(from: DateComponents(year: 2024, month: 2, day: 14))
|
||||
)
|
||||
|
||||
let points = UsageStatisticsStore.currentMonth(
|
||||
from: [
|
||||
"2024-02-01": 120,
|
||||
"2024-02-29": 480
|
||||
],
|
||||
now: now,
|
||||
calendar: calendar
|
||||
)
|
||||
|
||||
XCTAssertEqual(points.count, 29)
|
||||
XCTAssertEqual(points.first?.value, 120)
|
||||
XCTAssertEqual(points[13].value, 0)
|
||||
XCTAssertEqual(points.last?.value, 480)
|
||||
XCTAssertEqual(
|
||||
points.map { calendar.component(.day, from: $0.date) },
|
||||
Array(1...29)
|
||||
)
|
||||
}
|
||||
|
||||
func testLegacyHistoryEntryDefaultsToDictationSource() throws {
|
||||
let payload: [String: Any] = [
|
||||
"id": UUID().uuidString,
|
||||
|
||||
@@ -194,6 +194,51 @@ final class AccountAPIClientTests: XCTestCase {
|
||||
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/refresh")
|
||||
}
|
||||
|
||||
func testRecreatedClientRestoresRetainedSessionAndRefreshesIt() async throws {
|
||||
let retained = makeAccountSession(accessExpiry: 1_020)
|
||||
let replacement = makeAccountSession(
|
||||
accessToken: "access-after-reinstall",
|
||||
refreshToken: "refresh-after-reinstall"
|
||||
)
|
||||
let transport = QueueAccountTransport([
|
||||
.init(statusCode: 200, body: try sessionEnvelopeData(retained)),
|
||||
.init(statusCode: 200, body: try sessionEnvelopeData(replacement))
|
||||
])
|
||||
let store = InMemoryAccountSecurityStore()
|
||||
let originalClient = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store,
|
||||
now: { Date(timeIntervalSince1970: 1_000) }
|
||||
)
|
||||
_ = try await originalClient.signInWithApple(
|
||||
AppleSignInRequest(
|
||||
identityToken: "identity",
|
||||
authorizationCode: "authorization",
|
||||
nonce: "raw-nonce",
|
||||
deviceCheckToken: "device-token",
|
||||
appAttest: nil
|
||||
)
|
||||
)
|
||||
|
||||
let recreatedClient = AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: transport,
|
||||
sessionVault: store,
|
||||
now: { Date(timeIntervalSince1970: 1_000) }
|
||||
)
|
||||
let accessToken = try await recreatedClient.accessTokenForAuthorizedRequest()
|
||||
|
||||
XCTAssertEqual(accessToken, replacement.accessToken)
|
||||
let stored = await store.session
|
||||
XCTAssertEqual(stored, replacement)
|
||||
let requests = await transport.requests
|
||||
XCTAssertEqual(requests.map(\.url?.path), [
|
||||
"/v1/auth/apple",
|
||||
"/v1/auth/refresh"
|
||||
])
|
||||
}
|
||||
|
||||
func testConcurrentUnauthorizedRequestsMergeRefreshRotation() async throws {
|
||||
let old = makeAccountSession()
|
||||
let replacement = makeAccountSession(
|
||||
|
||||
@@ -60,6 +60,7 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
)
|
||||
|
||||
await coordinator.restoreIfNeeded()
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
|
||||
let handled = coordinator.handleIncomingURL(
|
||||
URL(string: "https://osglab.com/i/\(validCode)")!
|
||||
)
|
||||
@@ -123,6 +124,83 @@ final class AccountCenterViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(restoreCount, 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testTransientRestoreFailureCanRetryWithoutSigningOut() async {
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
restoredSession: account,
|
||||
snapshot: makeSnapshot(account: account),
|
||||
restoreFailureCount: 1
|
||||
)
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: InMemoryPendingReferralStore()
|
||||
)
|
||||
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
XCTAssertEqual(coordinator.sessionPhase, .restoring)
|
||||
XCTAssertEqual(coordinator.operationErrorKey, "account.error.restore")
|
||||
let firstClearCount = await service.managedGatewayClearCount()
|
||||
XCTAssertEqual(firstClearCount, 0)
|
||||
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedIn(account))
|
||||
XCTAssertNil(coordinator.operationErrorKey)
|
||||
let restoreCount = await service.restoreCount()
|
||||
XCTAssertEqual(restoreCount, 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSignInOperationRemainsVisibleUntilAuthenticationCompletes() async {
|
||||
let account = AccountSession(
|
||||
accountID: UUID(),
|
||||
createdAtEpochSeconds: 1_700_000_000
|
||||
)
|
||||
let service = AccountServiceSpy(
|
||||
restoredSession: nil,
|
||||
signInSession: account,
|
||||
snapshot: makeSnapshot(account: account),
|
||||
signInDelayNanoseconds: 20_000_000
|
||||
)
|
||||
let coordinator = AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
pendingReferralStore: InMemoryPendingReferralStore()
|
||||
)
|
||||
await coordinator.restoreIfNeeded()
|
||||
|
||||
let signIn = Task {
|
||||
await coordinator.signIn(
|
||||
with: AppleAuthorizationPayload(
|
||||
identityToken: "identity",
|
||||
authorizationCode: "authorization",
|
||||
nonce: "nonce"
|
||||
)
|
||||
)
|
||||
}
|
||||
while coordinator.operation != .signingIn {
|
||||
await Task.yield()
|
||||
}
|
||||
|
||||
XCTAssertEqual(coordinator.operation, .signingIn)
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
|
||||
|
||||
await signIn.value
|
||||
|
||||
XCTAssertNil(coordinator.operation)
|
||||
XCTAssertEqual(coordinator.sessionPhase, .signedIn(account))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testFreshAccountSnapshotDoesNotReloadWhenEnteringAccountPages() async {
|
||||
let account = AccountSession(
|
||||
@@ -529,12 +607,15 @@ private final class AccountSessionEventSourceStub: AccountSessionEventSourcing {
|
||||
|
||||
private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing {
|
||||
private let restored: AccountSession?
|
||||
private let signedInAccount: AccountSession?
|
||||
private let centerSnapshot: AccountCenterSnapshot?
|
||||
private let refreshedSnapshot: AccountCenterSnapshot?
|
||||
private let shouldFailRedemption: Bool
|
||||
private let shouldFailAccountRefresh: Bool
|
||||
private let signOutDelayNanoseconds: UInt64
|
||||
private let signInDelayNanoseconds: UInt64
|
||||
private let accountLoadDelayNanoseconds: UInt64
|
||||
private var remainingRestoreFailures: Int
|
||||
private var redeemed: [String] = []
|
||||
private var centerLoadCount = 0
|
||||
private var logoutCount = 0
|
||||
@@ -545,30 +626,43 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
|
||||
|
||||
init(
|
||||
restoredSession: AccountSession?,
|
||||
signInSession: AccountSession? = nil,
|
||||
snapshot: AccountCenterSnapshot? = nil,
|
||||
refreshedSnapshot: AccountCenterSnapshot? = nil,
|
||||
shouldFailRedemption: Bool = false,
|
||||
shouldFailAccountRefresh: Bool = false,
|
||||
signOutDelayNanoseconds: UInt64 = 0,
|
||||
signInDelayNanoseconds: UInt64 = 0,
|
||||
restoreFailureCount: Int = 0,
|
||||
accountLoadDelayNanoseconds: UInt64 = 0
|
||||
) {
|
||||
restored = restoredSession
|
||||
signedInAccount = signInSession ?? restoredSession
|
||||
centerSnapshot = snapshot
|
||||
self.refreshedSnapshot = refreshedSnapshot
|
||||
self.shouldFailRedemption = shouldFailRedemption
|
||||
self.shouldFailAccountRefresh = shouldFailAccountRefresh
|
||||
self.signOutDelayNanoseconds = signOutDelayNanoseconds
|
||||
self.signInDelayNanoseconds = signInDelayNanoseconds
|
||||
remainingRestoreFailures = restoreFailureCount
|
||||
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
sessionRestoreCount += 1
|
||||
if remainingRestoreFailures > 0 {
|
||||
remainingRestoreFailures -= 1
|
||||
throw AccountServiceSpyError.failed
|
||||
}
|
||||
return restored
|
||||
}
|
||||
|
||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||
guard let restored else { throw AccountIntegrationError.unavailable }
|
||||
return restored
|
||||
if signInDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: signInDelayNanoseconds)
|
||||
}
|
||||
guard let signedInAccount else { throw AccountIntegrationError.unavailable }
|
||||
return signedInAccount
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
|
||||
@@ -30,7 +30,8 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(config.translationTargetLocaleId, TranslationLanguageCatalog.offLocaleId)
|
||||
XCTAssertFalse(config.translationEnabled)
|
||||
XCTAssertEqual(config.handednessPreference, .left)
|
||||
XCTAssertTrue(config.cursorDragNavigationEnabled)
|
||||
// Legacy field remains decode-compatible after the UI feature was removed.
|
||||
XCTAssertFalse(config.cursorDragNavigationEnabled)
|
||||
XCTAssertEqual(config.keyboardHapticIntensity, .light)
|
||||
XCTAssertEqual(config.polishIntensity, .light)
|
||||
XCTAssertEqual(config.aiResponseLength, .medium)
|
||||
@@ -93,6 +94,24 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(loaded.flowInactivityDuration, .threeHours)
|
||||
}
|
||||
|
||||
func testAppGroupStorePersistsLocaleChanges() {
|
||||
let defaults = makeDefaults()
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
|
||||
store.setLocaleId("en-US")
|
||||
|
||||
XCTAssertEqual(store.localeId, "en-US")
|
||||
}
|
||||
|
||||
func testRemovedCursorDragSettingStillDecodesLegacyValue() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(true, forKey: AppGroupConfiguration.Keys.cursorDragNavigationEnabled)
|
||||
|
||||
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
XCTAssertTrue(loaded.cursorDragNavigationEnabled)
|
||||
}
|
||||
|
||||
func testFieldLevelSavePreservesNewerUnrelatedProcessChange() {
|
||||
let defaults = makeDefaults()
|
||||
let baseline = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
@@ -45,7 +45,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.translateID])
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testInvitationWithDatePromotesCalendarAndBothReplyChoices() {
|
||||
@@ -91,7 +97,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -111,7 +118,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -130,6 +138,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.openLinkID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeWebPageID))
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testSinglePhoneNumberOffersCallAndCreateContact() {
|
||||
@@ -149,7 +158,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -169,7 +179,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -190,6 +201,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.callPhoneID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.createContactID))
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testTaskListPromotesTodoAndOrganizationSkills() {
|
||||
@@ -259,7 +271,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertEqual(ranked, baseline)
|
||||
}
|
||||
|
||||
func testRecommendationsSelectOnlySemanticallyRelevantSkills() {
|
||||
func testRecommendationsAddReplyToSemanticallyRelevantSkills() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "北京市朝阳区望京街 10 号,到了给我电话。",
|
||||
@@ -268,12 +280,18 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
limit: 5
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.navigateID])
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.navigateID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateID))
|
||||
}
|
||||
|
||||
func testRecommendationsStayEmptyWhenNoSemanticLabelMatches() {
|
||||
func testRecommendationsFallBackToReplyWhenNoSemanticLabelMatches() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "知道了",
|
||||
@@ -282,7 +300,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
limit: 5
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertTrue(recommendations.isEmpty)
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testNegativeReplyableMessageDoesNotOfferPlayfulReply() {
|
||||
@@ -300,7 +318,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testInvitationKeepsTwoSpecificRepliesWithoutGenericReplies() {
|
||||
func testInvitationKeepsSpecificRepliesAndGenericReply() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "今晚七点老地方吃饭,你能来吗?",
|
||||
@@ -319,12 +337,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.extractEventsID,
|
||||
AIClipboardSkillCatalog.acceptInvitationID,
|
||||
AIClipboardSkillCatalog.declineInvitationID
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testRecommendationsNeverContainMoreThanTwoReplySkills() {
|
||||
func testForeignQuestionKeepsReplyAndOneSpecializedFollowUp() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "Could you send the final proposal by Friday?",
|
||||
|
||||
@@ -116,6 +116,47 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testLocalAndPolishOnlySetupLinksToTextPolish() {
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
isLocalEngine: true,
|
||||
isASRConfigured: true
|
||||
),
|
||||
.textPolish
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: true
|
||||
),
|
||||
.textPolish
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: true
|
||||
),
|
||||
"home.setup.polishKeyMissing"
|
||||
)
|
||||
}
|
||||
|
||||
func testCloudSetupLinksToSpeechRecognitionWhenASRIsMissing() {
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: false
|
||||
),
|
||||
.speechRecognition
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: false
|
||||
),
|
||||
"home.setup.cloudIncomplete"
|
||||
)
|
||||
}
|
||||
|
||||
private func descriptor(
|
||||
for lifecycle: FlowPiPLifecycleState
|
||||
) -> FlowHomePiPStatusDescriptor {
|
||||
|
||||
+53
-9
@@ -1,15 +1,15 @@
|
||||
// RimeFrequentTermStoreTests.swift
|
||||
// FrequentTermStoreTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
final class FrequentTermStoreTests: XCTestCase {
|
||||
private var defaults: UserDefaults!
|
||||
private var suiteName: String!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
suiteName = "RimeFrequentTermStoreTests.\(UUID().uuidString)"
|
||||
suiteName = "FrequentTermStoreTests.\(UUID().uuidString)"
|
||||
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
suiteName = nil
|
||||
}
|
||||
|
||||
func testRepeatedRimeCommitBecomesSuggestion() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
func testRepeatedCommitBecomesSuggestion() {
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
store.recordCommittedText("少数派")
|
||||
XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty)
|
||||
|
||||
@@ -32,7 +32,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSuggestionsExcludeExistingDictionaryTermsAndCommonWords() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
for _ in 0..<4 {
|
||||
store.recordCommittedText("我们")
|
||||
store.recordCommittedText("飞书文档")
|
||||
@@ -47,7 +47,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSuggestionsRankFrequencyBeforeRecency() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
let earlier = Date(timeIntervalSince1970: 100)
|
||||
let later = Date(timeIntervalSince1970: 200)
|
||||
for _ in 0..<3 {
|
||||
@@ -64,7 +64,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPunctuationAndSingleCharactersAreIgnored() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
for _ in 0..<3 {
|
||||
store.recordCommittedText("我")
|
||||
store.recordCommittedText("你好!")
|
||||
@@ -75,7 +75,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testClearRemovesLearnedSuggestions() {
|
||||
let store = RimeFrequentTermStore(defaults: defaults)
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
store.recordCommittedText("少数派")
|
||||
store.recordCommittedText("少数派")
|
||||
XCTAssertFalse(store.suggestions(excludingPersonalTerms: []).isEmpty)
|
||||
@@ -84,4 +84,48 @@ final class RimeFrequentTermStoreTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty)
|
||||
}
|
||||
|
||||
func testEnglishTermsAreCaseInsensitiveAndCommonWordsAreIgnored() {
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
for term in ["OpenAI", "openAI", "the", "the"] {
|
||||
store.recordCommittedText(term)
|
||||
}
|
||||
|
||||
let suggestion = store.suggestions(excludingPersonalTerms: []).first
|
||||
|
||||
XCTAssertEqual(suggestion?.term, "openAI")
|
||||
XCTAssertEqual(suggestion?.commitCount, 2)
|
||||
}
|
||||
|
||||
func testEnglishNamesAndProductSeparatorsAreAccepted() {
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
for term in ["O'Connor", "O'Connor", "GPT-5", "GPT-5"] {
|
||||
store.recordCommittedText(term)
|
||||
}
|
||||
|
||||
XCTAssertEqual(
|
||||
Set(store.suggestions(excludingPersonalTerms: []).map(\.term)),
|
||||
["O'Connor", "GPT-5"]
|
||||
)
|
||||
}
|
||||
|
||||
func testLegacyRimeTermsMigrateWithoutLosingHistory() throws {
|
||||
let legacyTerm = FrequentTerm(
|
||||
term: "少数派",
|
||||
commitCount: 3,
|
||||
firstSeenAt: Date(timeIntervalSince1970: 100),
|
||||
lastSeenAt: Date(timeIntervalSince1970: 200)
|
||||
)
|
||||
defaults.set(
|
||||
try JSONEncoder().encode([legacyTerm]),
|
||||
forKey: FrequentTermStore.legacyRimeDefaultsKey
|
||||
)
|
||||
|
||||
let store = FrequentTermStore(defaults: defaults)
|
||||
let suggestions = store.suggestions(excludingPersonalTerms: [])
|
||||
|
||||
XCTAssertEqual(suggestions.first, legacyTerm)
|
||||
XCTAssertNotNil(defaults.data(forKey: FrequentTermStore.defaultsKey))
|
||||
XCTAssertNil(defaults.data(forKey: FrequentTermStore.legacyRimeDefaultsKey))
|
||||
}
|
||||
}
|
||||
@@ -147,15 +147,15 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
XCTAssertEqual(PersonalDictionary.Entry.inferCategory(for: "LLM"), .acronym)
|
||||
}
|
||||
|
||||
func testPersonalDictionaryMigratesLegacyHistorySource() {
|
||||
let legacy = PersonalDictionary(entries: [
|
||||
func testPersonalDictionaryPreservesRecommendedHistorySource() {
|
||||
let dictionary = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .history)
|
||||
])
|
||||
let data = try! JSONEncoder().encode(legacy)
|
||||
let data = try! JSONEncoder().encode(dictionary)
|
||||
defaults.set(data, forKey: "config.personalDictionary.v1")
|
||||
|
||||
let loaded = store.personalDictionary
|
||||
XCTAssertEqual(loaded.entries.first?.source, .manual)
|
||||
XCTAssertEqual(loaded.entries.first?.source, .history)
|
||||
XCTAssertEqual(loaded.entries.first?.term, "Kubernetes")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// PersonalDictionaryEntryServiceTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// Verifies that every manual-entry UI gets the same alias-generation behavior
|
||||
// without allowing a late asynchronous response to overwrite newer edits.
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class PersonalDictionaryEntryServiceTests: XCTestCase {
|
||||
private var suiteName: String!
|
||||
private var defaults: UserDefaults!
|
||||
private var store: AppGroupStore!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
suiteName = "group.com.osgkeyboard.shared.tests.entry-service.\(UUID().uuidString)"
|
||||
defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
store = AppGroupStore(defaults: defaults)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testSuggestedTermGeneratesAliasesAndPreservesUsageCount() async throws {
|
||||
var pushedDictionaries: [PersonalDictionary] = []
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in ["洛基", "肉鸡"] },
|
||||
cloudPush: { pushedDictionaries.append($0) }
|
||||
)
|
||||
|
||||
let saved = try XCTUnwrap(
|
||||
service.saveEntry(term: "Rocky", source: .history, minimumUsageCount: 3)
|
||||
)
|
||||
XCTAssertTrue(saved.shouldGenerateAliases)
|
||||
XCTAssertEqual(saved.dictionary.entries.first?.source, .history)
|
||||
XCTAssertEqual(saved.dictionary.entries.first?.usageCount, 3)
|
||||
XCTAssertTrue(saved.dictionary.entries.first?.aliases.isEmpty == true)
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertEqual(Set(finished.entries.first?.aliases ?? []), Set(["洛基", "肉鸡"]))
|
||||
XCTAssertEqual(finished.entries.first?.usageCount, 3)
|
||||
XCTAssertEqual(pushedDictionaries.count, 2)
|
||||
}
|
||||
|
||||
func testAliasCompletionPreservesChangesMadeWhileGenerating() async throws {
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in ["swift u i"] },
|
||||
cloudPush: { _ in }
|
||||
)
|
||||
let saved = try XCTUnwrap(service.saveEntry(term: "SwiftUI", source: .manual))
|
||||
store.updatePersonalDictionary { dictionary -> UUID? in
|
||||
guard let concurrent = dictionary.upsertManual(term: "Cursor") else { return nil }
|
||||
dictionary.version += 1
|
||||
return concurrent.id
|
||||
}
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertEqual(Set(finished.entries.map(\.term)), Set(["SwiftUI", "Cursor"]))
|
||||
XCTAssertEqual(
|
||||
finished.entry(matchingTerm: "SwiftUI")?.aliases,
|
||||
["swift u i"]
|
||||
)
|
||||
}
|
||||
|
||||
func testAliasCompletionDoesNotRestoreDeletedEntry() async throws {
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in ["late alias"] },
|
||||
cloudPush: { _ in }
|
||||
)
|
||||
let saved = try XCTUnwrap(service.saveEntry(term: "Deleted", source: .manual))
|
||||
store.deletePersonalDictionaryEntry(id: saved.entryID)
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertNil(finished.entries.first(where: { $0.id == saved.entryID }))
|
||||
XCTAssertNotNil(finished.deletedEntryIDs[saved.entryID])
|
||||
}
|
||||
|
||||
func testAliasCompletionIgnoresEntryRenamedAfterSave() async throws {
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in ["stale alias"] },
|
||||
cloudPush: { _ in }
|
||||
)
|
||||
let saved = try XCTUnwrap(service.saveEntry(term: "Before", source: .manual))
|
||||
store.updatePersonalDictionary { dictionary -> UUID? in
|
||||
guard let renamed = dictionary.upsertManual(
|
||||
term: "After",
|
||||
existingID: saved.entryID
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
dictionary.version += 1
|
||||
return renamed.id
|
||||
}
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertEqual(finished.entries.first?.term, "After")
|
||||
XCTAssertTrue(finished.entries.first?.aliases.isEmpty == true)
|
||||
}
|
||||
|
||||
func testAliasGenerationFailureKeepsSavedEntry() async throws {
|
||||
var pushCount = 0
|
||||
let service = PersonalDictionaryEntryService(
|
||||
store: store,
|
||||
aliasGeneration: { _ in [] },
|
||||
cloudPush: { _ in pushCount += 1 }
|
||||
)
|
||||
let saved = try XCTUnwrap(service.saveEntry(term: "Durable", source: .manual))
|
||||
|
||||
let finished = await service.finishSaving(saved)
|
||||
|
||||
XCTAssertEqual(finished.entries.first?.term, "Durable")
|
||||
XCTAssertTrue(finished.entries.first?.aliases.isEmpty == true)
|
||||
XCTAssertEqual(pushCount, 1)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// PolishStyleLearningServiceTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
//
|
||||
// Verifies corpus eligibility, the 5,000-character gate, and that style
|
||||
// Verifies corpus eligibility, the 2,500-character gate, and that style
|
||||
// generation receives both paired examples and the prompts that produced them.
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
@@ -54,7 +54,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
XCTAssertEqual(corpus.examples.count, 1)
|
||||
XCTAssertEqual(corpus.examples.first?.polishStyleID, "builtin.light")
|
||||
XCTAssertEqual(corpus.effectiveCharacterCount, 7)
|
||||
XCTAssertEqual(corpus.remainingCharacterCount, 4_993)
|
||||
XCTAssertEqual(corpus.remainingCharacterCount, 2_493)
|
||||
XCTAssertFalse(corpus.isReady)
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
XCTAssertEqual(corpus.effectiveCharacterCount, 7)
|
||||
}
|
||||
|
||||
func testCorpusUnlocksAtFiveThousandEffectiveCharacters() {
|
||||
let text = String(repeating: "字", count: 5_000)
|
||||
func testCorpusUnlocksAtTwoThousandFiveHundredEffectiveCharacters() {
|
||||
let text = String(repeating: "字", count: 2_500)
|
||||
let corpus = PolishStyleLearningCorpusBuilder.build(
|
||||
from: [
|
||||
SpeechHistoryEntry(
|
||||
@@ -86,7 +86,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(corpus.effectiveCharacterCount, 5_000)
|
||||
XCTAssertEqual(corpus.effectiveCharacterCount, 2_500)
|
||||
XCTAssertEqual(corpus.remainingCharacterCount, 0)
|
||||
XCTAssertTrue(corpus.isReady)
|
||||
}
|
||||
@@ -108,7 +108,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
store.setActivePolishStyleId(activeStyle.id)
|
||||
|
||||
let source = String(repeating: "测试语料", count: 1_250)
|
||||
let source = String(repeating: "测试语料", count: 625)
|
||||
let corpus = PolishStyleLearningCorpus(
|
||||
examples: [
|
||||
PolishStyleLearningExample(
|
||||
@@ -120,7 +120,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
createdAt: Date()
|
||||
)
|
||||
],
|
||||
effectiveCharacterCount: 5_000
|
||||
effectiveCharacterCount: 2_500
|
||||
)
|
||||
let client = StyleLearningCapturingClient(
|
||||
response: ##"{"name":"我的说话风格","prompt":"# 角色\n自然直接\n# 风格边界\n不改变原意\n# 示例\n输入 → 输出","allowsAddedEmoji":false}"##
|
||||
@@ -156,7 +156,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
createdAt: Date()
|
||||
)
|
||||
],
|
||||
effectiveCharacterCount: 5_000
|
||||
effectiveCharacterCount: 2_500
|
||||
)
|
||||
let service = PolishStyleLearningService(
|
||||
store: store,
|
||||
@@ -169,7 +169,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
} catch let error as PolishStyleLearningError {
|
||||
XCTAssertEqual(
|
||||
error,
|
||||
.insufficientCorpus(required: 5_000, actual: 5)
|
||||
.insufficientCorpus(required: 2_500, actual: 5)
|
||||
)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
|
||||
Reference in New Issue
Block a user