chore(release): bump version to 2.1.6 (build 131)
Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs, docs/llms.txt). fix(flow): host-app identification now compiles in every build configuration (Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and could not auto-return after a startflow handoff. Debug-only logging and the Home test card are unchanged. Also includes the pending work in the tree: - Home: single account/setup card surface (HomeAccountSurfacePolicy/Views) - Wide screens: voice key row and centred mic placement, iPhone Duo layout - Keyboard: clipboard skill ranking and AI skill layout updates - Debug: account session handoff to the Simulator (+ import script) - Docs: portfolio assets, privacy policy URL, site/README refresh
This commit is contained in:
@@ -141,6 +141,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
@@ -173,6 +175,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.openLinkID,
|
||||
AIClipboardSkillCatalog.summarizeWebPageID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
@@ -205,6 +209,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
@@ -240,6 +246,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
@@ -247,7 +255,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
11
|
||||
12
|
||||
)
|
||||
}
|
||||
|
||||
@@ -272,6 +280,8 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
@@ -306,12 +316,19 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
let migrated = AppGroupStore(defaults: defaults).agentSkillLayout
|
||||
|
||||
XCTAssertEqual(migrated.enabledIDs, [AIClipboardSkillCatalog.replyID])
|
||||
XCTAssertEqual(
|
||||
migrated.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
11
|
||||
12
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,16 +349,27 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
|
||||
XCTAssertEqual(store.agentSkillLayout.enabledIDs, [AIClipboardSkillCatalog.translateID])
|
||||
XCTAssertEqual(
|
||||
store.agentSkillLayout.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
11
|
||||
12
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AppGroupStore(defaults: defaults).agentSkillLayout.enabledIDs,
|
||||
[AIClipboardSkillCatalog.translateID]
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -367,13 +395,18 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(
|
||||
migrated.enabledIDs,
|
||||
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
11
|
||||
12
|
||||
)
|
||||
}
|
||||
|
||||
@@ -400,7 +433,11 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(
|
||||
AppGroupStore(defaults: enabledDefaults).agentSkillLayout.enabledIDs,
|
||||
[AIClipboardSkillCatalog.replyID]
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
|
||||
let disabledDefaults = makeDefaults()
|
||||
@@ -419,7 +456,11 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AppGroupStore(defaults: disabledDefaults).agentSkillLayout.enabledIDs,
|
||||
[AIClipboardSkillCatalog.translateID]
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.translateToTargetID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,64 @@ final class AIClipboardSkillTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testTranslateToTargetUsesConfiguredTargetLanguage() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.translateToTargetID,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: "ja",
|
||||
preferredLanguages: ["zh-Hans"]
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("Japanese"))
|
||||
XCTAssertFalse(prompt.contains("Chinese"))
|
||||
}
|
||||
|
||||
func testTranslateToTargetPromptFallsBackWhenTargetIsOff() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.translateToTargetID,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("English"))
|
||||
}
|
||||
|
||||
func testTranslateToTargetButtonTitleNamesTargetAndHidesWhenOff() {
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: "en",
|
||||
uiLanguage: .chinese
|
||||
),
|
||||
"翻译英语"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: "ja",
|
||||
uiLanguage: .english
|
||||
),
|
||||
"To Japanese"
|
||||
)
|
||||
XCTAssertNil(
|
||||
AIClipboardSkillCatalog.translateToTargetButtonTitle(
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
uiLanguage: .english
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPolishKeepsOriginalLanguageAndCarriesNeverAnswerBoundary() throws {
|
||||
let skill = try XCTUnwrap(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.polishOriginalID)
|
||||
)
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
for: skill,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: "ja"
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("without changing its language"))
|
||||
XCTAssertTrue(prompt.contains("grammar"))
|
||||
XCTAssertTrue(prompt.contains("Non-negotiable boundary"))
|
||||
XCTAssertFalse(prompt.contains("Japanese"))
|
||||
}
|
||||
|
||||
func testSummarizeAsksForOverviewNotShortening() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.summarizeID,
|
||||
|
||||
@@ -133,6 +133,60 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertLessThan(try XCTUnwrap(translateIndex), try XCTUnwrap(speakAsMeIndex))
|
||||
}
|
||||
|
||||
/// Device-language text + a configured target → "Translate to <target>",
|
||||
/// and never the foreign-text actions (system translate / polish).
|
||||
func testSystemLanguageTextOffersTranslateToTarget() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "明天下午三点开会,别迟到。",
|
||||
analysis: analysis(language: "zh"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"],
|
||||
translationTargetLocaleId: "en"
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertTrue(recommendations.contains(AIClipboardSkillCatalog.translateToTargetID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.polishOriginalID))
|
||||
}
|
||||
|
||||
func testTranslateToTargetIsHiddenWhenTranslationIsOffOrTargetMatchesText() {
|
||||
for target in [TranslationLanguageCatalog.offLocaleId, "zh-Hans"] {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "明天下午三点开会,别迟到。",
|
||||
analysis: analysis(language: "zh"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"],
|
||||
translationTargetLocaleId: target
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertFalse(
|
||||
recommendations.contains(AIClipboardSkillCatalog.translateToTargetID),
|
||||
"target \(target)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreign text: system translate + polish, never translate-to-target.
|
||||
func testForeignTextOffersPolishButNotTranslateToTarget() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "I has a meeting tomorrow, please dont be late.",
|
||||
analysis: analysis(language: "en"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"],
|
||||
translationTargetLocaleId: "ja"
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertTrue(recommendations.contains(AIClipboardSkillCatalog.translateID))
|
||||
XCTAssertTrue(recommendations.contains(AIClipboardSkillCatalog.polishOriginalID))
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateToTargetID))
|
||||
}
|
||||
|
||||
func testTranslationIsNotRecommendedForSystemLanguage() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
@@ -161,6 +215,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.polishOriginalID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
@@ -523,7 +578,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations.map(\.id),
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.polishOriginalID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// DebugAccountSessionHandoffTests.swift
|
||||
// OSGKeyboardTests
|
||||
//
|
||||
// DEBUG-only device → Simulator session handoff: the device must forget the
|
||||
// session it hands off, and the Simulator must never keep the handoff file.
|
||||
|
||||
#if DEBUG
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardHostSupport
|
||||
import XCTest
|
||||
|
||||
final class DebugAccountSessionHandoffTests: XCTestCase {
|
||||
private var directory: URL!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
|
||||
// MARK: - AccountAPIClient
|
||||
|
||||
func testRelinquishReturnsSessionAndForgetsItEverywhere() async throws {
|
||||
let session = makeAccountSession()
|
||||
let store = InMemoryAccountSecurityStore(session: session)
|
||||
let client = makeClient(store: store)
|
||||
// Warm the in-memory cache: clearing Keychain alone would not stop it.
|
||||
_ = try await client.currentSession()
|
||||
|
||||
let relinquished = try await client.debugRelinquishSession()
|
||||
|
||||
XCTAssertEqual(relinquished, session)
|
||||
let stored = await store.session
|
||||
XCTAssertNil(stored)
|
||||
let current = try await client.currentSession()
|
||||
XCTAssertNil(current)
|
||||
}
|
||||
|
||||
func testRelinquishWithoutSessionThrows() async {
|
||||
let client = makeClient(store: InMemoryAccountSecurityStore())
|
||||
|
||||
do {
|
||||
_ = try await client.debugRelinquishSession()
|
||||
XCTFail("Expected sessionUnavailable")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? AccountAPIError, .sessionUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func testAdoptReplacesCachedSession() async throws {
|
||||
let store = InMemoryAccountSecurityStore(session: makeAccountSession())
|
||||
let client = makeClient(store: store)
|
||||
_ = try await client.currentSession()
|
||||
let handedOff = makeAccountSession(accessToken: "access-device", refreshToken: "refresh-device")
|
||||
|
||||
try await client.debugAdoptSession(handedOff)
|
||||
|
||||
let current = try await client.currentSession()
|
||||
XCTAssertEqual(current, handedOff)
|
||||
let stored = await store.session
|
||||
XCTAssertEqual(stored, handedOff)
|
||||
}
|
||||
|
||||
// MARK: - Handoff file
|
||||
|
||||
func testExportFileRoundTripsThroughImportAndIsDeleted() throws {
|
||||
let session = makeAccountSession()
|
||||
let url = try DebugAccountSessionHandoff.writeExportFile(session, directory: directory)
|
||||
|
||||
let imported = DebugAccountSessionHandoff.consumePendingImport(directory: directory)
|
||||
|
||||
XCTAssertEqual(imported, session)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
func testMalformedImportIsRejectedAndStillDeleted() throws {
|
||||
let url = directory.appendingPathComponent(DebugAccountSessionHandoff.fileName)
|
||||
try Data(#"{"format":"something-else","session":{}}"#.utf8).write(to: url)
|
||||
|
||||
XCTAssertNil(DebugAccountSessionHandoff.consumePendingImport(directory: directory))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: url.path))
|
||||
}
|
||||
|
||||
func testParkedSessionIsPendingUntilConsumed() throws {
|
||||
XCTAssertFalse(DebugAccountSessionHandoff.hasPendingImport(directory: directory))
|
||||
_ = try DebugAccountSessionHandoff.writeExportFile(makeAccountSession(), directory: directory)
|
||||
|
||||
XCTAssertTrue(DebugAccountSessionHandoff.hasPendingImport(directory: directory))
|
||||
_ = DebugAccountSessionHandoff.consumePendingImport(directory: directory)
|
||||
XCTAssertFalse(DebugAccountSessionHandoff.hasPendingImport(directory: directory))
|
||||
}
|
||||
|
||||
func testMissingImportReturnsNil() {
|
||||
XCTAssertNil(DebugAccountSessionHandoff.consumePendingImport(directory: directory))
|
||||
}
|
||||
|
||||
func testImportedMarkerMatchesOnlyTheImportedAccount() throws {
|
||||
let suite = "DebugAccountSessionHandoffTests.\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let imported = makeAccountSession().accountId
|
||||
|
||||
DebugAccountSessionHandoff.markImported(accountID: imported, defaults: defaults)
|
||||
|
||||
XCTAssertTrue(DebugAccountSessionHandoff.isImported(accountID: imported, defaults: defaults))
|
||||
XCTAssertFalse(DebugAccountSessionHandoff.isImported(accountID: UUID(), defaults: defaults))
|
||||
}
|
||||
|
||||
private func makeClient(store: InMemoryAccountSecurityStore) -> AccountAPIClient {
|
||||
AccountAPIClient(
|
||||
baseURL: URL(string: "https://account.test")!,
|
||||
transport: QueueAccountTransport([]),
|
||||
sessionVault: store
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -82,40 +82,6 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testConnectionCardDisappearsWhenSessionIsHealthy() {
|
||||
XCTAssertFalse(
|
||||
shouldShowConnectionCard(
|
||||
lifecycle: .active,
|
||||
isHostReady: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testConnectionCardRemainsVisibleWhilePreparingOrFailed() {
|
||||
XCTAssertTrue(
|
||||
shouldShowConnectionCard(
|
||||
lifecycle: .preparing(attempt: 1, total: 3),
|
||||
isHostReady: false
|
||||
)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
shouldShowConnectionCard(
|
||||
lifecycle: .failed(.timedOut),
|
||||
isHostReady: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testNormalRecordingDoesNotBringConnectionCardBack() {
|
||||
XCTAssertFalse(
|
||||
shouldShowConnectionCard(
|
||||
lifecycle: .active,
|
||||
isRecording: true,
|
||||
isHostReady: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLocalAndPolishOnlySetupLinksToTextPolish() {
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyDeepLink(
|
||||
@@ -131,13 +97,6 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
),
|
||||
.textPolish
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: true
|
||||
),
|
||||
"home.setup.polishKeyMissing"
|
||||
)
|
||||
}
|
||||
|
||||
func testCloudSetupLinksToSpeechRecognitionWhenASRIsMissing() {
|
||||
@@ -148,13 +107,6 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
),
|
||||
.speechRecognition
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: false,
|
||||
isASRConfigured: false
|
||||
),
|
||||
"home.setup.cloudIncomplete"
|
||||
)
|
||||
}
|
||||
|
||||
private func descriptor(
|
||||
@@ -169,20 +121,4 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase {
|
||||
isHostReady: false
|
||||
)
|
||||
}
|
||||
|
||||
private func shouldShowConnectionCard(
|
||||
lifecycle: FlowPiPLifecycleState,
|
||||
isRecording: Bool = false,
|
||||
isHostReady: Bool
|
||||
) -> Bool {
|
||||
FlowHomePiPStatusPolicy.shouldShowConnectionCard(
|
||||
lifecycle: lifecycle,
|
||||
needsPermissionSetup: false,
|
||||
needsAPIKeySetup: false,
|
||||
hasSessionWarning: false,
|
||||
isRecording: isRecording,
|
||||
isProcessing: false,
|
||||
isHostReady: isHostReady
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// HomeAccountSurfacePolicyTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
@testable import OSGKeyboard
|
||||
import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class HomeAccountSurfacePolicyTests: XCTestCase {
|
||||
|
||||
// MARK: - Readiness
|
||||
|
||||
func testCreditsNeedAnAccountButSurviveRestore() {
|
||||
XCTAssertEqual(readiness(.managed, account: .signedIn), .credits)
|
||||
XCTAssertEqual(readiness(.managed, account: .restoring), .credits)
|
||||
XCTAssertEqual(readiness(.managed, account: .signedOut), .unavailable)
|
||||
}
|
||||
|
||||
func testOwnKeyReadinessFollowsConfiguredKeys() {
|
||||
XCTAssertEqual(readiness(.byok, asr: true, polish: true), .ownKey)
|
||||
XCTAssertEqual(readiness(.byok, asr: true, polish: false), .dictationOnly)
|
||||
XCTAssertEqual(readiness(.byok, asr: false, polish: true), .unavailable)
|
||||
}
|
||||
|
||||
// MARK: - Surface rules, in priority order
|
||||
|
||||
func testMissingPermissionsOutrankEverything() {
|
||||
XCTAssertEqual(
|
||||
surface(needsPermissionSetup: true, account: .signedOut, pendingReferralCode: "K7M2Q9XA"),
|
||||
.permissions
|
||||
)
|
||||
}
|
||||
|
||||
func testRestoringShowsNoAccountSurface() {
|
||||
XCTAssertNil(surface(account: .restoring, readiness: .dictationOnly))
|
||||
}
|
||||
|
||||
func testSavedInvitationLeadsUntilDeferred() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedOut, pendingReferralCode: "K7M2Q9XA"),
|
||||
.invitation(code: "K7M2Q9XA")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
surface(
|
||||
account: .signedOut,
|
||||
pendingReferralCode: "K7M2Q9XA",
|
||||
isInvitationDeferred: true,
|
||||
readiness: .dictationOnly
|
||||
),
|
||||
.signInSetup
|
||||
)
|
||||
}
|
||||
|
||||
func testSignedOutWithoutWorkingServiceAsksToSignIn() {
|
||||
XCTAssertEqual(surface(account: .signedOut, readiness: .dictationOnly), .signInSetup)
|
||||
XCTAssertEqual(surface(account: .signedOut, readiness: .unavailable), .signInSetup)
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedOut, credentialSource: .managed, readiness: .unavailable),
|
||||
.signInSetup
|
||||
)
|
||||
}
|
||||
|
||||
func testSignedOutWithWorkingKeysOnlyGetsQuietPromo() {
|
||||
XCTAssertEqual(surface(account: .signedOut, readiness: .ownKey), .signInPromo)
|
||||
}
|
||||
|
||||
func testCreditsConnectionFailureBecomesProblemCard() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .failed),
|
||||
.creditsUnavailable
|
||||
)
|
||||
}
|
||||
|
||||
func testDepletedCreditsBecomeProblemCard() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .balance(0)),
|
||||
.creditsDepleted
|
||||
)
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .balance(-5)),
|
||||
.creditsDepleted
|
||||
)
|
||||
}
|
||||
|
||||
func testHealthyOrLoadingCreditsStayQuiet() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .balance(1_500)),
|
||||
.accountSummary
|
||||
)
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, credentialSource: .managed, readiness: .credits, credits: .loading),
|
||||
.accountSummary
|
||||
)
|
||||
}
|
||||
|
||||
func testSignedInWithoutKeysChoosesService() {
|
||||
XCTAssertEqual(surface(account: .signedIn, readiness: .dictationOnly), .chooseService)
|
||||
XCTAssertEqual(surface(account: .signedIn, readiness: .unavailable), .chooseService)
|
||||
}
|
||||
|
||||
func testSignedInWithOwnKeysIgnoresCreditErrors() {
|
||||
XCTAssertEqual(surface(account: .signedIn, readiness: .ownKey, credits: .failed), .accountSummary)
|
||||
XCTAssertEqual(surface(account: .signedIn, readiness: .ownKey, credits: .balance(0)), .accountSummary)
|
||||
}
|
||||
|
||||
func testSignedInIgnoresStalePendingInvitation() {
|
||||
XCTAssertEqual(
|
||||
surface(account: .signedIn, pendingReferralCode: "K7M2Q9XA", readiness: .ownKey),
|
||||
.accountSummary
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
func testUnfoldedDuoUsesWideLayoutEvenWithCompactSizeClass() {
|
||||
XCTAssertTrue(HomeLayoutPolicy.usesWideLayout(isRegularWidth: false, containerWidth: 669))
|
||||
XCTAssertFalse(HomeLayoutPolicy.usesWideLayout(isRegularWidth: false, containerWidth: 466))
|
||||
XCTAssertFalse(HomeLayoutPolicy.usesWideLayout(isRegularWidth: false, containerWidth: 402))
|
||||
}
|
||||
|
||||
func testRegularSizeClassKeepsWideLayoutInsideSplitView() {
|
||||
XCTAssertTrue(HomeLayoutPolicy.usesWideLayout(isRegularWidth: true, containerWidth: 504))
|
||||
}
|
||||
|
||||
func testWideTopClearanceOnlyWhereNoStatusBarSitsAbove() {
|
||||
// Landscape Duo inner display: status bar on the side, nothing above.
|
||||
XCTAssertEqual(WideLayoutMetrics.topCornerClearance(topSafeAreaInset: 0), Spacing.md)
|
||||
// iPad status bar, portrait Duo inner display (measured 82 pt).
|
||||
XCTAssertEqual(WideLayoutMetrics.topCornerClearance(topSafeAreaInset: 24), 0)
|
||||
XCTAssertEqual(WideLayoutMetrics.topCornerClearance(topSafeAreaInset: 82), 0)
|
||||
// A thin inset only gets topped up to the minimum.
|
||||
XCTAssertEqual(WideLayoutMetrics.topCornerClearance(topSafeAreaInset: 6), Spacing.md - 6)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func readiness(
|
||||
_ source: CredentialSource,
|
||||
account: HomeAccountPhase = .signedIn,
|
||||
asr: Bool = true,
|
||||
polish: Bool = true
|
||||
) -> HomeServiceReadiness {
|
||||
HomeAccountSurfacePolicy.readiness(
|
||||
credentialSource: source,
|
||||
account: account,
|
||||
isASRConfigured: asr,
|
||||
isPolishConfigured: polish
|
||||
)
|
||||
}
|
||||
|
||||
private func surface(
|
||||
needsPermissionSetup: Bool = false,
|
||||
account: HomeAccountPhase,
|
||||
pendingReferralCode: String? = nil,
|
||||
isInvitationDeferred: Bool = false,
|
||||
credentialSource: CredentialSource = .byok,
|
||||
readiness: HomeServiceReadiness = .ownKey,
|
||||
credits: HomeCreditsState = .loading
|
||||
) -> HomeAccountSurface? {
|
||||
HomeAccountSurfacePolicy.surface(
|
||||
needsPermissionSetup: needsPermissionSetup,
|
||||
account: account,
|
||||
pendingReferralCode: pendingReferralCode,
|
||||
isInvitationDeferred: isInvitationDeferred,
|
||||
credentialSource: credentialSource,
|
||||
readiness: readiness,
|
||||
credits: credits
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user