feat(app): add crash visibility, memory relief, and clipboard reply work
Squashes the working tree that had accumulated on cloud-agent. Two strands of work, kept in one commit at the maintainer's request. Crash and stability (this session): - Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics are recorded on device. Keyboard-extension memory kills are the most likely way a user loses the keyboard mid-sentence and iOS does not classify them as crashes, so they never reach App Store Connect. - Upload those reports to POST /v1/analytics/diagnostics from internal builds only (local Debug and TestFlight), reusing the analytics installation identifier and skipping entirely when analytics are off. App Store builds keep everything on device. - Shed the Chinese engine, English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds instead of waiting for a system memory warning that jetsam often preempts. - Install audio taps with the input node's live format through one shared adaptive resampler, so an audio-route change mid-recording no longer raises an uncatchable format-mismatch exception. Covers the dictation preview and the Mac recorder, which still used the pattern the Flow capture path had already moved away from. - Harden the librime bridge: never pass a nil string across a nonnull boundary when a user dictionary holds malformed UTF-8, null-check the engine API vector, and distrust a candidate count without its array. - Guard the expanded candidate grid against a list that shrinks while the collection view is still laying out against the previous count. - Resolve localized text without the App Group so the screen shown when that container is missing renders its message instead of crashing on launch, which is the one situation it exists to handle. - Gate CI on the App Group and Keychain wiring that the keyboard extension hard-depends on, with a self-test proving the check fails. Clipboard, reply, and semantics (pre-existing working tree): - Unify the reply center, refresh keyboard AI features, and add native event and reminder exporters. - Expand clipboard semantic routing with authored corpora and a product-blind holdout, plus the scripts that build and validate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,20 +17,53 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
let defaults = makeDefaults()
|
||||
let layout = AppGroupStore(defaults: defaults).agentSkillLayout
|
||||
XCTAssertEqual(layout.enabledIDs, AIAgentSkillLayout.defaultEnabledIDs)
|
||||
XCTAssertEqual(layout.enabledIDs, AIClipboardSkillCatalog.catalog.map(\.id))
|
||||
XCTAssertEqual(
|
||||
layout.enabledIDs,
|
||||
AIClipboardSkillCatalog.catalog.filter(\.isDefault).map(\.id)
|
||||
)
|
||||
// "Speak as me" is included: it has no toggle of its own, and the
|
||||
// personal-style gate keeps it off the keyboard until one is distilled.
|
||||
XCTAssertTrue(layout.enabledIDs.contains(AIClipboardSkillCatalog.speakAsMeID))
|
||||
XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty)
|
||||
}
|
||||
|
||||
/// The skill has no switch of its own. It ships enabled, and distilling a
|
||||
/// personal style is the single act that makes it reachable.
|
||||
func testSpeakAsMeShipsEnabledAndIsGatedOnlyByThePersonalStyle() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
XCTAssertTrue(store.layout.isEnabled(AIClipboardSkillCatalog.speakAsMeID))
|
||||
|
||||
XCTAssertFalse(
|
||||
AIClipboardSkillCatalog.availableEnabledIDs(
|
||||
store.layout.enabledIDs,
|
||||
layout: store.layout,
|
||||
hasPersonalReplyStyle: false
|
||||
).contains(AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
AIClipboardSkillCatalog.availableEnabledIDs(
|
||||
store.layout.enabledIDs,
|
||||
layout: store.layout,
|
||||
hasPersonalReplyStyle: true
|
||||
).contains(AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
}
|
||||
|
||||
func testSystemSemanticSkillsStayEnabledButHiddenFromSkillManagement() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
let hiddenIDs = AIClipboardSkillCatalog.hiddenFromSkillManagementIDs
|
||||
|
||||
// "Hidden" means system action: always enabled, never user-managed.
|
||||
XCTAssertTrue(hiddenIDs.isSubset(of: Set(store.enabledSkills.map(\.id))))
|
||||
// The lists additionally omit skills managed from elsewhere in the UI,
|
||||
// which are not necessarily enabled.
|
||||
let omittedIDs = AIClipboardSkillCatalog.managedOutsideSkillListIDs
|
||||
XCTAssertTrue(hiddenIDs.isSubset(of: omittedIDs))
|
||||
XCTAssertTrue(
|
||||
hiddenIDs.isDisjoint(with: Set(store.skillManagementEnabledSkills.map(\.id)))
|
||||
omittedIDs.isDisjoint(with: Set(store.skillManagementEnabledSkills.map(\.id)))
|
||||
)
|
||||
XCTAssertTrue(
|
||||
hiddenIDs.isDisjoint(with: Set(store.skillManagementAvailableSkills.map(\.id)))
|
||||
omittedIDs.isDisjoint(with: Set(store.skillManagementAvailableSkills.map(\.id)))
|
||||
)
|
||||
XCTAssertTrue(
|
||||
store.skillManagementEnabledSkills.contains {
|
||||
@@ -445,25 +478,25 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
func testCannotEnableExportSkillBeforeShortcutConfirmation() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
store.disable(AIClipboardSkillCatalog.extractTodosID)
|
||||
store.disable(AIClipboardSkillCatalog.saveToNotesID)
|
||||
XCTAssertEqual(
|
||||
store.enable(AIClipboardSkillCatalog.extractTodosID),
|
||||
store.enable(AIClipboardSkillCatalog.saveToNotesID),
|
||||
.needsShortcut
|
||||
)
|
||||
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
|
||||
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.saveToNotesID))
|
||||
}
|
||||
|
||||
func testConfirmShortcutAutoEnablesWhenSlotAvailable() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
store.disable(AIClipboardSkillCatalog.extractTodosID)
|
||||
store.disable(AIClipboardSkillCatalog.saveToNotesID)
|
||||
XCTAssertEqual(
|
||||
store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID),
|
||||
store.confirmShortcutAndEnable(AIClipboardSkillCatalog.saveToNotesID),
|
||||
.enabled
|
||||
)
|
||||
XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
|
||||
XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.saveToNotesID))
|
||||
XCTAssertEqual(
|
||||
store.layout.enabledIDs.last,
|
||||
AIClipboardSkillCatalog.extractTodosID
|
||||
AIClipboardSkillCatalog.saveToNotesID
|
||||
)
|
||||
}
|
||||
|
||||
@@ -527,12 +560,12 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
func testDisableKeepsShortcutConfirmation() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
store.disable(AIClipboardSkillCatalog.extractTodosID)
|
||||
_ = store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID)
|
||||
store.disable(AIClipboardSkillCatalog.extractTodosID)
|
||||
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
|
||||
XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
|
||||
XCTAssertEqual(store.enable(AIClipboardSkillCatalog.extractTodosID), .enabled)
|
||||
store.disable(AIClipboardSkillCatalog.saveToNotesID)
|
||||
_ = store.confirmShortcutAndEnable(AIClipboardSkillCatalog.saveToNotesID)
|
||||
store.disable(AIClipboardSkillCatalog.saveToNotesID)
|
||||
XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.saveToNotesID))
|
||||
XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.saveToNotesID))
|
||||
XCTAssertEqual(store.enable(AIClipboardSkillCatalog.saveToNotesID), .enabled)
|
||||
}
|
||||
|
||||
func testReorderMovesEnabledSkill() {
|
||||
@@ -552,23 +585,21 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
func testReorderMovesEnabledSkillToIndex() {
|
||||
let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
|
||||
// Derived from the default order rather than hardcoded: this asserts
|
||||
// the move, not which skills happen to lead the catalog.
|
||||
var others = AIAgentSkillLayout.defaultEnabledIDs
|
||||
others.removeAll { $0 == AIClipboardSkillCatalog.summarizeID }
|
||||
let leading = Array(others.prefix(2))
|
||||
|
||||
store.moveEnabled(id: AIClipboardSkillCatalog.summarizeID, toIndex: 2)
|
||||
XCTAssertEqual(
|
||||
Array(store.layout.enabledIDs.prefix(3)),
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.summarizeID
|
||||
]
|
||||
leading + [AIClipboardSkillCatalog.summarizeID]
|
||||
)
|
||||
store.moveEnabled(id: AIClipboardSkillCatalog.summarizeID, toIndex: 0)
|
||||
XCTAssertEqual(
|
||||
Array(store.layout.enabledIDs.prefix(3)),
|
||||
[
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID
|
||||
]
|
||||
[AIClipboardSkillCatalog.summarizeID] + leading
|
||||
)
|
||||
}
|
||||
|
||||
@@ -655,12 +686,12 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testShortcutsRunURLIncludesNameAndText() {
|
||||
let shortcutName = AIClipboardSkillCatalog.extractTodosShortcutName
|
||||
let shortcutName = AIClipboardSkillCatalog.saveToNotesShortcutName
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(name: shortcutName, text: "买牛奶\n回邮件")
|
||||
XCTAssertEqual(url?.scheme, "shortcuts")
|
||||
XCTAssertEqual(url?.host, "run-shortcut")
|
||||
let items = URLComponents(url: url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSGExtractTodos")
|
||||
XCTAssertEqual(items.first { $0.name == "name" }?.value, "OSGSaveToNotes")
|
||||
XCTAssertEqual(items.first { $0.name == "input" }?.value, "text")
|
||||
XCTAssertEqual(items.first { $0.name == "text" }?.value, "买牛奶\n回邮件")
|
||||
XCTAssertNil(items.first { $0.name == "x-success" })
|
||||
@@ -668,7 +699,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
|
||||
func testXCallbackRunURLUsesCallbackHost() {
|
||||
let url = AIAgentShortcutRun.shortcutsRunURL(
|
||||
name: AIClipboardSkillCatalog.extractTodosShortcutName,
|
||||
name: AIClipboardSkillCatalog.saveToNotesShortcutName,
|
||||
text: "买牛奶",
|
||||
xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
|
||||
xError: "osgkeyboard://skill/shortcut-result?status=error",
|
||||
@@ -689,21 +720,25 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertEqual(AIAgentShortcutRun.preview("买牛奶\n回邮件"), "买牛奶\\n回邮件")
|
||||
}
|
||||
|
||||
func testExtractTodosUsesBundledShortcut() {
|
||||
func testExtractTodosIsNativeWithoutShortcut() {
|
||||
// Reminders are created natively via EventKit in the host, so this
|
||||
// built-in skill must not carry any companion Shortcut metadata.
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractTodosID)
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSGExtractTodos")
|
||||
XCTAssertEqual(skill?.shortcutResourceName, "OSGExtractTodos")
|
||||
XCTAssertNil(skill?.shortcutName)
|
||||
XCTAssertNil(skill?.shortcutResourceName)
|
||||
XCTAssertFalse(skill?.requiresShortcut ?? true)
|
||||
XCTAssertEqual(skill?.kind, .export)
|
||||
}
|
||||
|
||||
func testExtractEventsUsesBundledShortcut() {
|
||||
func testExtractEventsIsNativeWithoutShortcut() {
|
||||
// Calendar events are created natively via EventKit in the host, so this
|
||||
// built-in skill must not carry any companion Shortcut metadata.
|
||||
let skill = AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.extractEventsID)
|
||||
XCTAssertNil(skill?.shortcutICloudURL)
|
||||
XCTAssertEqual(skill?.shortcutName, "OSGExtractEvents")
|
||||
XCTAssertEqual(
|
||||
skill?.shortcutResourceName,
|
||||
"OSGExtractEvents"
|
||||
)
|
||||
XCTAssertNil(skill?.shortcutName)
|
||||
XCTAssertNil(skill?.shortcutResourceName)
|
||||
XCTAssertFalse(skill?.requiresShortcut ?? true)
|
||||
XCTAssertEqual(skill?.systemImage, "calendar")
|
||||
XCTAssertTrue(skill?.isDefault ?? false)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,46 @@ final class AIClipboardPromptTests: XCTestCase {
|
||||
XCTAssertFalse(prompt.contains("<A 栋>"))
|
||||
}
|
||||
|
||||
func testConversationContextIsAnUntrustedBlockSeparateFromInstruction() {
|
||||
let prompt = AIClipboardPrompt.compose(
|
||||
instruction: "Reply to the clipboard message.",
|
||||
material: "周六一起吃饭?",
|
||||
conversationContext: "Them: 在吗\nYou: 在的 <随时>"
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("<conversation_context>"))
|
||||
// Context is untrusted data too: its angle brackets are escaped so it
|
||||
// cannot break out into new tags.
|
||||
XCTAssertTrue(prompt.contains("<随时>"))
|
||||
XCTAssertFalse(prompt.contains("<随时>"))
|
||||
}
|
||||
|
||||
func testComposeOmitsContextBlockWhenAbsentOrBlank() {
|
||||
XCTAssertFalse(
|
||||
AIClipboardPrompt.compose(instruction: "i", material: "m")
|
||||
.contains("conversation_context")
|
||||
)
|
||||
XCTAssertFalse(
|
||||
AIClipboardPrompt.compose(
|
||||
instruction: "i",
|
||||
material: "m",
|
||||
conversationContext: " "
|
||||
).contains("conversation_context")
|
||||
)
|
||||
}
|
||||
|
||||
func testResolveThreadsConversationContextIntoEnvelope() throws {
|
||||
let resolution = AIClipboardPrompt.resolve(
|
||||
instruction: "Reply.",
|
||||
material: "周六一起吃饭?",
|
||||
conversationContext: "Them: 在吗\nYou: 在的"
|
||||
)
|
||||
guard case .ready(let prompt) = resolution else {
|
||||
return XCTFail("expected a composed prompt")
|
||||
}
|
||||
XCTAssertTrue(prompt.contains("<conversation_context>"))
|
||||
XCTAssertTrue(prompt.contains("在吗"))
|
||||
}
|
||||
|
||||
func testResolveFailsClosedWithoutMaterial() {
|
||||
XCTAssertEqual(
|
||||
AIClipboardPrompt.resolve(instruction: "请翻译剪贴板", material: nil),
|
||||
|
||||
@@ -127,6 +127,45 @@ final class AIEventExtractionTests: XCTestCase {
|
||||
XCTAssertTrue(prompt.contains("all-day"))
|
||||
}
|
||||
|
||||
// MARK: - Decode (inverse of `lines`, for native EventKit export)
|
||||
|
||||
func testDecodeTimedEventWithLocation() {
|
||||
let parsed = AIEventExtraction.decode(
|
||||
"2026-08-15 14:00|2026-08-15 16:00|项目评审|3楼会议室",
|
||||
calendar: calendar
|
||||
)
|
||||
XCTAssertEqual(parsed?.title, "项目评审")
|
||||
XCTAssertEqual(parsed?.location, "3楼会议室")
|
||||
XCTAssertEqual(parsed?.isAllDay, false)
|
||||
XCTAssertEqual(parsed?.start, date(2026, 8, 15, 14, 0))
|
||||
XCTAssertEqual(parsed?.end, date(2026, 8, 15, 16, 0))
|
||||
}
|
||||
|
||||
func testDecodeAllDayHasNoEnd() {
|
||||
let parsed = AIEventExtraction.decode("2026-08-15|ALLDAY|提交周报|", calendar: calendar)
|
||||
XCTAssertEqual(parsed?.title, "提交周报")
|
||||
XCTAssertNil(parsed?.location)
|
||||
XCTAssertEqual(parsed?.isAllDay, true)
|
||||
XCTAssertEqual(parsed?.start, calendar.startOfDay(for: date(2026, 8, 15, 0, 0)))
|
||||
XCTAssertNil(parsed?.end)
|
||||
}
|
||||
|
||||
func testDecodeRejectsMalformedLine() {
|
||||
XCTAssertNil(AIEventExtraction.decode("garbage", calendar: calendar))
|
||||
XCTAssertNil(AIEventExtraction.decode("2026-08-15 14:00||", calendar: calendar))
|
||||
XCTAssertNil(AIEventExtraction.decode("notadate|notadate|标题|", calendar: calendar))
|
||||
}
|
||||
|
||||
func testEncodeThenDecodeRoundTrips() {
|
||||
let encoded = lines("2026-08-15 14:00|2026-08-15 16:00|项目评审|3楼会议室")
|
||||
XCTAssertEqual(encoded.count, 1)
|
||||
let parsed = AIEventExtraction.decode(encoded[0], calendar: calendar)
|
||||
XCTAssertEqual(parsed?.title, "项目评审")
|
||||
XCTAssertEqual(parsed?.start, date(2026, 8, 15, 14, 0))
|
||||
XCTAssertEqual(parsed?.end, date(2026, 8, 15, 16, 0))
|
||||
XCTAssertEqual(parsed?.isAllDay, false)
|
||||
}
|
||||
|
||||
private func lines(_ raw: String) -> [String] {
|
||||
AIEventExtraction.lines(from: raw, now: now, calendar: calendar)
|
||||
}
|
||||
|
||||
@@ -118,11 +118,15 @@ final class AIHintKeywordExtractorTests: XCTestCase {
|
||||
}
|
||||
|
||||
final class AIClipboardSkillTests: XCTestCase {
|
||||
/// Every built-in skill ships on. "Speak as me" included: it is withheld by
|
||||
/// `requiresPersonalReplyStyleIDs` until a style exists, so it can never
|
||||
/// become a permanently broken chip, and it needs no opt-in of its own.
|
||||
func testVisibleDefaultsContainEveryBuiltInSkill() {
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.visible().map(\.id),
|
||||
AIClipboardSkillCatalog.catalog.map(\.id)
|
||||
)
|
||||
XCTAssertTrue(AIClipboardSkillCatalog.catalog.allSatisfy(\.isDefault))
|
||||
}
|
||||
|
||||
func testVisibleRespectsEnabledIDsForFutureSettings() {
|
||||
@@ -317,11 +321,19 @@ final class AIClipboardSkillTests: XCTestCase {
|
||||
XCTAssertFalse(instruction.contains("这是个人回复风格"))
|
||||
}
|
||||
|
||||
func testReplyStyleResolverAcceptsOnlyUserOwnedStyle() {
|
||||
let user = PolishStylePack(
|
||||
func testReplyStyleResolverAcceptsOnlyDistilledPersonalStyle() {
|
||||
let distilled = PolishStylePack(
|
||||
id: "user.learned",
|
||||
name: "我的风格",
|
||||
prompt: "喜欢短句",
|
||||
learningMetadata: Self.learningMetadata(),
|
||||
kind: .user
|
||||
)
|
||||
// Hand-written packs live on the Styles page and shape dictation only.
|
||||
let handWritten = PolishStylePack(
|
||||
id: "user.handwritten",
|
||||
name: "我手写的",
|
||||
prompt: "使用长句",
|
||||
kind: .user
|
||||
)
|
||||
let builtIn = PolishStylePack(
|
||||
@@ -332,10 +344,111 @@ final class AIClipboardSkillTests: XCTestCase {
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
AIClipboardReplyStyleContext.resolve(activeStyle: user)?.prompt,
|
||||
AIClipboardReplyStyleContext.resolve(personalReplyStyle: distilled)?.prompt,
|
||||
"喜欢短句"
|
||||
)
|
||||
XCTAssertNil(AIClipboardReplyStyleContext.resolve(activeStyle: builtIn))
|
||||
XCTAssertNil(AIClipboardReplyStyleContext.resolve(personalReplyStyle: handWritten))
|
||||
XCTAssertNil(AIClipboardReplyStyleContext.resolve(personalReplyStyle: builtIn))
|
||||
XCTAssertNil(AIClipboardReplyStyleContext.resolve(personalReplyStyle: nil))
|
||||
}
|
||||
|
||||
// MARK: - Speak as me
|
||||
|
||||
private func speakAsMeInstruction(
|
||||
style: AIClipboardReplyStyleContext?,
|
||||
locale: String = "zh"
|
||||
) throws -> String {
|
||||
let skill = try XCTUnwrap(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
return AIClipboardSkillCatalog.instruction(
|
||||
for: skill,
|
||||
locale: locale,
|
||||
translationTargetLocaleId: "en",
|
||||
replyStyle: style
|
||||
)
|
||||
}
|
||||
|
||||
/// Reuses `replyInstruction` and the skill inherits its "usually 1–3
|
||||
/// sentences" chat baseline, which would truncate a long clipboard and
|
||||
/// fight a formal personal style.
|
||||
func testSpeakAsMeSkipsTheReplyChatBaselineAndItsLengthCap() throws {
|
||||
let instruction = try speakAsMeInstruction(
|
||||
style: AIClipboardReplyStyleContext(styleID: "user.learned", prompt: "偏好长句书面表达")
|
||||
)
|
||||
|
||||
XCTAssertFalse(instruction.contains("通常控制在 1~3 句"))
|
||||
XCTAssertFalse(instruction.contains("像一个普通人在和朋友"))
|
||||
XCTAssertTrue(instruction.contains("± 20%"))
|
||||
}
|
||||
|
||||
/// Rewriting is not replying: Reply deliberately omits the never-answer
|
||||
/// boundary because answering is its job. This skill must carry it.
|
||||
func testSpeakAsMeCarriesTheNeverAnswerBoundary() throws {
|
||||
let zh = try speakAsMeInstruction(
|
||||
style: AIClipboardReplyStyleContext(styleID: "user.learned", prompt: "偏好短句")
|
||||
)
|
||||
XCTAssertTrue(zh.contains("# 不可协商边界"))
|
||||
XCTAssertTrue(zh.contains("偏好短句"))
|
||||
XCTAssertTrue(zh.contains("user_reply_style"))
|
||||
|
||||
let en = try speakAsMeInstruction(
|
||||
style: AIClipboardReplyStyleContext(styleID: "user.learned", prompt: "short sentences"),
|
||||
locale: "en"
|
||||
)
|
||||
XCTAssertTrue(en.contains("# Non-negotiable boundary"))
|
||||
}
|
||||
|
||||
/// Without a personal style the skill has nothing to rewrite toward. It is
|
||||
/// gated upstream; if it ever runs anyway it must not answer the clipboard.
|
||||
func testSpeakAsMeWithoutStyleStillRefusesToAnswer() throws {
|
||||
let instruction = try speakAsMeInstruction(style: nil)
|
||||
|
||||
XCTAssertFalse(instruction.contains("user_reply_style"))
|
||||
XCTAssertTrue(instruction.contains("# 不可协商边界"))
|
||||
}
|
||||
|
||||
func testSpeakAsMeShipsEnabledAndIsManagedOutsideTheGenericSkillLists() throws {
|
||||
let skill = try XCTUnwrap(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
|
||||
XCTAssertTrue(skill.isDefault)
|
||||
XCTAssertTrue(
|
||||
AIAgentSkillLayout.defaultEnabledIDs.contains(AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
AIClipboardSkillCatalog.managedOutsideSkillListIDs
|
||||
.contains(AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
// Hidden for a different reason than the always-on system actions: it
|
||||
// carries a prerequisite they do not.
|
||||
XCTAssertFalse(
|
||||
AIClipboardSkillCatalog.hiddenFromSkillManagementIDs
|
||||
.contains(AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
XCTAssertTrue(
|
||||
AIClipboardSkillCatalog.requiresPersonalReplyStyleIDs
|
||||
.contains(AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
// The personal style is this skill's task, not a modifier layered on a
|
||||
// generated reply, so it must not ride the reply-style path.
|
||||
XCTAssertFalse(skill.supportsReplyStyle)
|
||||
}
|
||||
|
||||
private static func learningMetadata(
|
||||
generatedAt: Date = Date()
|
||||
) -> PolishStylePack.LearningMetadata {
|
||||
PolishStylePack.LearningMetadata(
|
||||
schemaVersion: 2,
|
||||
evidenceStatus: "sufficient",
|
||||
confidence: 0.7,
|
||||
asrExampleCount: 8,
|
||||
asrEffectiveCharacterCount: 2_600,
|
||||
replyExampleCount: 3,
|
||||
replyFinalEditCount: 1,
|
||||
generatedAt: generatedAt
|
||||
)
|
||||
}
|
||||
|
||||
func testBusinessReplyKeepsProfessionalBaselineWithoutFriendEmojiGuidance() throws {
|
||||
|
||||
@@ -113,7 +113,7 @@ final class AIModeLLMClientTests: XCTestCase {
|
||||
XCTAssertEqual(LLMProvider.provider(id: "minimax").defaultModel, "MiniMax-M2.7")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "anthropic").defaultModel, "claude-sonnet-4-6")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "siliconflow").defaultModel, "Qwen/Qwen3-8B-Instruct")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "openrouter").defaultModel, "qwen/qwen3-8b:free")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "openrouter").defaultModel, "qwen/qwen3-8b")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "cometapi").defaultModel, "gpt-5.4-mini")
|
||||
XCTAssertEqual(LLMProvider.provider(id: "codingPlanX").defaultModel, "gpt-5.4-mini")
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
config.flowSkipAppSwitch = false
|
||||
// Use a non-default value so the round-trip actually proves persistence.
|
||||
config.flowInactivityDuration = .threeHours
|
||||
// Non-default so the round-trip proves the auto-mode flags persist.
|
||||
config.clipboardAutoModeEnabled = true
|
||||
config.clipboardAutoTranslateEnabled = true
|
||||
config.clipboardAutoEmailReplyEnabled = true
|
||||
config.save(to: defaults)
|
||||
|
||||
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
@@ -92,6 +96,9 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(loaded.aiResponseLength, .short)
|
||||
XCTAssertFalse(loaded.flowSkipAppSwitch)
|
||||
XCTAssertEqual(loaded.flowInactivityDuration, .threeHours)
|
||||
XCTAssertTrue(loaded.clipboardAutoModeEnabled)
|
||||
XCTAssertTrue(loaded.clipboardAutoTranslateEnabled)
|
||||
XCTAssertTrue(loaded.clipboardAutoEmailReplyEnabled)
|
||||
}
|
||||
|
||||
func testAppGroupStorePersistsLocaleChanges() {
|
||||
@@ -175,6 +182,171 @@ final class AppGroupConfigurationTests: XCTestCase {
|
||||
XCTAssertEqual(second.flowInactivityDuration, .thirtyMinutes)
|
||||
}
|
||||
|
||||
// MARK: - Personal reply style split
|
||||
|
||||
private func seedCatalog(
|
||||
_ defaults: UserDefaults,
|
||||
entries: [PolishStylePack],
|
||||
activeID: String
|
||||
) {
|
||||
var config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
for entry in entries {
|
||||
try? config.polishStyleCatalog.upsert(entry)
|
||||
}
|
||||
config.activePolishStyleId = activeID
|
||||
config.save(to: defaults)
|
||||
// The split migration may already have run during the load above.
|
||||
defaults.removeObject(forKey: AppGroupConfiguration.Keys.personalReplyStyleMigrated)
|
||||
defaults.removeObject(forKey: AppGroupConfiguration.Keys.personalReplyStyleId)
|
||||
defaults.set(activeID, forKey: AppGroupConfiguration.Keys.activePolishStyleId)
|
||||
}
|
||||
|
||||
/// The reported bug: a selected personal style outranked the core filler
|
||||
/// cleanup. Migration hands it to replies and gives dictation its default back.
|
||||
func testActiveDistilledStyleMovesToRepliesAndVoiceFallsBack() {
|
||||
let defaults = makeDefaults()
|
||||
let distilled = PolishStylePackTests.distilledPack()
|
||||
seedCatalog(defaults, entries: [distilled], activeID: distilled.id)
|
||||
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
XCTAssertEqual(config.activePolishStyleId, PolishStylePackCatalog.defaultID)
|
||||
XCTAssertEqual(config.personalReplyStyleId, distilled.id)
|
||||
}
|
||||
|
||||
/// These users had generated a personal style but could not use it: the
|
||||
/// single selector forced them to choose voice cleanup instead.
|
||||
func testUnusedDistilledStyleIsAdoptedForReplies() {
|
||||
let defaults = makeDefaults()
|
||||
let distilled = PolishStylePackTests.distilledPack()
|
||||
seedCatalog(defaults, entries: [distilled], activeID: PolishStylePackCatalog.defaultID)
|
||||
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
XCTAssertEqual(config.activePolishStyleId, PolishStylePackCatalog.defaultID)
|
||||
XCTAssertEqual(config.personalReplyStyleId, distilled.id)
|
||||
}
|
||||
|
||||
func testHandWrittenStyleStaysOnVoiceAndNeverDrivesReplies() {
|
||||
let defaults = makeDefaults()
|
||||
let handWritten = PolishStylePack(id: "user.handwritten", name: "手写", prompt: "长句")
|
||||
seedCatalog(defaults, entries: [handWritten], activeID: handWritten.id)
|
||||
|
||||
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
|
||||
XCTAssertEqual(config.activePolishStyleId, handWritten.id)
|
||||
XCTAssertEqual(config.personalReplyStyleId, "")
|
||||
}
|
||||
|
||||
func testPersonalReplyStyleMigrationRunsOnce() {
|
||||
let defaults = makeDefaults()
|
||||
let distilled = PolishStylePackTests.distilledPack()
|
||||
seedCatalog(defaults, entries: [distilled], activeID: distilled.id)
|
||||
|
||||
let first = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
XCTAssertEqual(first.personalReplyStyleId, distilled.id)
|
||||
XCTAssertTrue(
|
||||
defaults.bool(forKey: AppGroupConfiguration.Keys.personalReplyStyleMigrated)
|
||||
)
|
||||
|
||||
// A later opt-out must not be undone by a second load.
|
||||
var updated = first
|
||||
updated.personalReplyStyleId = ""
|
||||
updated.save(to: defaults)
|
||||
|
||||
XCTAssertEqual(AppGroupConfiguration.load(fromAvailable: defaults).personalReplyStyleId, "")
|
||||
}
|
||||
|
||||
func testStoreRejectsDistilledStyleAsVoiceSelection() {
|
||||
let defaults = makeDefaults()
|
||||
let distilled = PolishStylePackTests.distilledPack()
|
||||
seedCatalog(defaults, entries: [distilled], activeID: PolishStylePackCatalog.defaultID)
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
|
||||
store.setActivePolishStyleId(distilled.id)
|
||||
|
||||
XCTAssertEqual(store.activePolishStyleId, PolishStylePackCatalog.defaultID)
|
||||
}
|
||||
|
||||
func testStoreRejectsHandWrittenStyleAsReplySelection() {
|
||||
let defaults = makeDefaults()
|
||||
let handWritten = PolishStylePack(id: "user.handwritten", name: "手写", prompt: "长句")
|
||||
seedCatalog(defaults, entries: [handWritten], activeID: PolishStylePackCatalog.defaultID)
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
|
||||
store.setPersonalReplyStyleId(handWritten.id)
|
||||
|
||||
XCTAssertEqual(store.personalReplyStyleId, "")
|
||||
XCTAssertNil(store.personalReplyStyle)
|
||||
}
|
||||
|
||||
/// The half the instruction tests cannot see: they hand-build a context, so
|
||||
/// nothing pinned the path from a stored pack to the prompt the model reads.
|
||||
/// This walks the production chain — App Group pack -> `personalReplyStyle`
|
||||
/// -> `resolve` -> `instruction(for:)` — and asserts the user's own wording
|
||||
/// reaches "Speak as me". A break anywhere in it degrades the skill into
|
||||
/// generic polish without failing any other test.
|
||||
func testSpeakAsMeInstructionCarriesTheStoredPersonalStyle() throws {
|
||||
let defaults = makeDefaults()
|
||||
let distilled = PolishStylePackTests.distilledPack()
|
||||
seedCatalog(defaults, entries: [distilled], activeID: PolishStylePackCatalog.defaultID)
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
store.setPersonalReplyStyleId(distilled.id)
|
||||
|
||||
let context = try XCTUnwrap(
|
||||
AIClipboardReplyStyleContext.resolve(personalReplyStyle: store.personalReplyStyle)
|
||||
)
|
||||
XCTAssertEqual(context.styleID, distilled.id)
|
||||
|
||||
let skill = try XCTUnwrap(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
let instruction = AIClipboardSkillCatalog.instruction(
|
||||
for: skill,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: "en",
|
||||
replyStyle: context
|
||||
)
|
||||
|
||||
XCTAssertTrue(instruction.contains(distilled.prompt))
|
||||
XCTAssertTrue(instruction.contains("<user_reply_style id=\"\(distilled.id)\">"))
|
||||
}
|
||||
|
||||
/// `supportsReplyStyle` is false for this skill, so a composer that checked
|
||||
/// that flag before injecting the style would silently strip it.
|
||||
func testSpeakAsMeGetsTheStyleDespiteNotSupportingTheReplyStylePath() throws {
|
||||
let skill = try XCTUnwrap(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.speakAsMeID)
|
||||
)
|
||||
XCTAssertFalse(skill.supportsReplyStyle)
|
||||
|
||||
let instruction = AIClipboardSkillCatalog.instruction(
|
||||
for: skill,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: "en",
|
||||
replyStyle: AIClipboardReplyStyleContext(
|
||||
styleID: "user.learned",
|
||||
prompt: "句子要短,先说结论"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertTrue(instruction.contains("句子要短,先说结论"))
|
||||
}
|
||||
|
||||
func testDeletingPersonalStyleDisablesPersonalizedReplies() {
|
||||
let defaults = makeDefaults()
|
||||
let distilled = PolishStylePackTests.distilledPack()
|
||||
seedCatalog(defaults, entries: [distilled], activeID: PolishStylePackCatalog.defaultID)
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
store.setPersonalReplyStyleId(distilled.id)
|
||||
XCTAssertNotNil(store.personalReplyStyle)
|
||||
|
||||
store.deletePolishStylePack(id: distilled.id)
|
||||
|
||||
XCTAssertEqual(store.personalReplyStyleId, "")
|
||||
XCTAssertNil(store.personalReplyStyle)
|
||||
}
|
||||
|
||||
func testDefaultMigrationGivesFreshInstallPrivacyDefaults() {
|
||||
let defaults = makeDefaults()
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// AppL10nFallbackTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
//
|
||||
// `AppGroupErrorView` is the screen the app shows when the App Group container
|
||||
// is missing — its whole job is to turn a provisioning mistake into a readable
|
||||
// message instead of a crash loop. It is built entirely from localized strings,
|
||||
// so the moment `AppL10n` started resolving the UI language through
|
||||
// `ProviderConfig` (which itself requires the App Group and traps without one)
|
||||
// that screen crashed on launch, every launch, for exactly the users it exists
|
||||
// to help.
|
||||
//
|
||||
// These tests run in an unsigned host where `AppGroup.isAvailable` is false, so
|
||||
// they exercise the real failure path rather than a simulation of it.
|
||||
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class AppL10nFallbackTests: XCTestCase {
|
||||
func testLocalizationResolvesWithoutAnAppGroup() throws {
|
||||
try XCTSkipIf(
|
||||
AppGroup.isAvailable,
|
||||
"Needs an unsigned host without an App Group container — the case the fallback exists for."
|
||||
)
|
||||
|
||||
// Trapping here is the regression: this must return text, not crash.
|
||||
let title = AppL10n.string("appGroup.error.title")
|
||||
|
||||
XCTAssertFalse(title.isEmpty)
|
||||
XCTAssertNotEqual(
|
||||
title,
|
||||
"appGroup.error.title",
|
||||
"the key came back unresolved, so the error screen would show a raw key"
|
||||
)
|
||||
}
|
||||
|
||||
func testEveryStringOnTheAppGroupErrorScreenResolves() throws {
|
||||
try XCTSkipIf(AppGroup.isAvailable, "Needs an unsigned host without an App Group container.")
|
||||
|
||||
for key in [
|
||||
"appGroup.error.title",
|
||||
"appGroup.error.body",
|
||||
"appGroup.error.step1",
|
||||
"appGroup.error.step2",
|
||||
"appGroup.error.step3"
|
||||
] {
|
||||
let value = AppL10n.string(key)
|
||||
XCTAssertFalse(value.isEmpty, "\(key) resolved to an empty string")
|
||||
XCTAssertNotEqual(value, key, "\(key) is missing from Localizable.strings")
|
||||
}
|
||||
}
|
||||
|
||||
func testExplicitLanguageStillWinsWithoutAnAppGroup() throws {
|
||||
try XCTSkipIf(AppGroup.isAvailable, "Needs an unsigned host without an App Group container.")
|
||||
|
||||
// The fallback must only supply a *default*; a caller that knows the
|
||||
// language must still be honoured.
|
||||
let english = AppL10n.string("appGroup.error.title", language: .english)
|
||||
let chinese = AppL10n.string("appGroup.error.title", language: .chinese)
|
||||
|
||||
XCTAssertFalse(english.isEmpty)
|
||||
XCTAssertFalse(chinese.isEmpty)
|
||||
XCTAssertNotEqual(english, chinese)
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,102 @@ final class ClipboardReplyFeedbackStoreTests: XCTestCase {
|
||||
XCTAssertEqual(example.finalEdit, "这周六不行,下次约~")
|
||||
}
|
||||
|
||||
func testRecentConversationTurnsReturnsSelectedExchangesOldestFirst() throws {
|
||||
let now = Date()
|
||||
let first = try selectedRecord(
|
||||
source: "你有空吗?",
|
||||
reply: "有的,怎么了",
|
||||
at: now.addingTimeInterval(-120)
|
||||
)
|
||||
let second = try selectedRecord(
|
||||
source: "周六一起吃饭?",
|
||||
reply: "好啊,几点",
|
||||
at: now.addingTimeInterval(-30)
|
||||
)
|
||||
_ = (first, second)
|
||||
|
||||
let turns = store.recentConversationTurns(now: now)
|
||||
XCTAssertEqual(turns.count, 2)
|
||||
XCTAssertEqual(turns[0].incoming, "你有空吗?")
|
||||
XCTAssertEqual(turns[0].reply, "有的,怎么了")
|
||||
XCTAssertEqual(turns[1].incoming, "周六一起吃饭?")
|
||||
XCTAssertEqual(turns[1].reply, "好啊,几点")
|
||||
}
|
||||
|
||||
func testRecentConversationTurnsExcludesOutsideWindowAndCurrentMessage() throws {
|
||||
let now = Date()
|
||||
_ = try selectedRecord(
|
||||
source: "很久以前的消息",
|
||||
reply: "旧回复",
|
||||
at: now.addingTimeInterval(-10 * 60)
|
||||
)
|
||||
_ = try selectedRecord(
|
||||
source: "刚刚的消息",
|
||||
reply: "新回复",
|
||||
at: now.addingTimeInterval(-60)
|
||||
)
|
||||
|
||||
// Outside the 5-minute window is dropped; the message being replied to
|
||||
// right now is excluded so it is not duplicated as context.
|
||||
XCTAssertEqual(
|
||||
store.recentConversationTurns(now: now).map(\.incoming),
|
||||
["刚刚的消息"]
|
||||
)
|
||||
XCTAssertTrue(
|
||||
store.recentConversationTurns(now: now, excludingIncoming: "刚刚的消息").isEmpty
|
||||
)
|
||||
}
|
||||
|
||||
func testRecentConversationTurnsIgnoresAwaitingAndDiscarded() throws {
|
||||
let now = Date()
|
||||
_ = try XCTUnwrap(
|
||||
store.begin(
|
||||
sourceText: "还没选的消息",
|
||||
candidates: makeCandidates(suffix: "a"),
|
||||
styleID: nil,
|
||||
now: now.addingTimeInterval(-40)
|
||||
)
|
||||
)
|
||||
let discardID = try XCTUnwrap(
|
||||
store.begin(
|
||||
sourceText: "被丢弃的消息",
|
||||
candidates: makeCandidates(suffix: "b"),
|
||||
styleID: nil,
|
||||
now: now.addingTimeInterval(-30)
|
||||
)
|
||||
)
|
||||
store.recordDiscard(recordID: discardID)
|
||||
|
||||
XCTAssertTrue(store.recentConversationTurns(now: now).isEmpty)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func selectedRecord(
|
||||
source: String,
|
||||
reply: String,
|
||||
at date: Date
|
||||
) throws -> UUID {
|
||||
let candidate = ClipboardReplyCandidateSnapshot(
|
||||
kind: .ordinary,
|
||||
text: reply,
|
||||
emotion: "neutral"
|
||||
)
|
||||
let recordID = try XCTUnwrap(
|
||||
store.begin(
|
||||
sourceText: source,
|
||||
candidates: [candidate],
|
||||
styleID: nil,
|
||||
now: date
|
||||
)
|
||||
)
|
||||
store.recordSelection(
|
||||
recordID: recordID,
|
||||
candidateID: candidate.id,
|
||||
answerID: candidate.id
|
||||
)
|
||||
return recordID
|
||||
}
|
||||
|
||||
private func makeCandidates(
|
||||
suffix: String = ""
|
||||
) -> [ClipboardReplyCandidateSnapshot] {
|
||||
|
||||
@@ -20,9 +20,122 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testTranslationIsNotRecommendedForSystemLanguage() {
|
||||
/// No clipboard signal can detect "this is my own draft", so the skill
|
||||
/// scores zero everywhere in `relevanceScores`. Without an explicit floor
|
||||
/// it is filtered out and never reaches the keyboard.
|
||||
func testSpeakAsMeIsOfferedWheneverItIsAvailable() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "明天的评审会我这边准备得差不多了,材料稍后发群里。",
|
||||
analysis: analysis(language: "zh"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertTrue(recommendations.contains(AIClipboardSkillCatalog.speakAsMeID))
|
||||
}
|
||||
|
||||
/// End-to-end contract for the reported bug: chips are ranked from the
|
||||
/// keyboard's catalog, so the skill has to survive `availableForKeyboard`
|
||||
/// *and* be offered by `recommended` before the user ever sees it.
|
||||
func testEnabledSkillWithPersonalStyleReachesTheKeyboardChips() {
|
||||
let layout = AIAgentSkillLayout(
|
||||
enabledIDs: AIAgentSkillLayout.defaultEnabledIDs
|
||||
+ [AIClipboardSkillCatalog.speakAsMeID],
|
||||
confirmedShortcutIDs: []
|
||||
)
|
||||
let available = AIClipboardSkillCatalog.availableForKeyboard(
|
||||
AIClipboardSkillCatalog.catalog,
|
||||
layout: layout,
|
||||
hasPersonalReplyStyle: true
|
||||
)
|
||||
XCTAssertTrue(available.contains { $0.id == AIClipboardSkillCatalog.speakAsMeID })
|
||||
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: available,
|
||||
sourceText: "明天的评审会我这边准备得差不多了,材料稍后发群里。",
|
||||
analysis: analysis(language: "zh"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"]
|
||||
).map(\.id)
|
||||
XCTAssertTrue(recommendations.contains(AIClipboardSkillCatalog.speakAsMeID))
|
||||
}
|
||||
|
||||
func testSkillIsWithheldWithoutAPersonalStyleOrWithoutOptIn() {
|
||||
let optedIn = AIAgentSkillLayout(
|
||||
enabledIDs: [AIClipboardSkillCatalog.speakAsMeID],
|
||||
confirmedShortcutIDs: []
|
||||
)
|
||||
let notOptedIn = AIAgentSkillLayout(enabledIDs: [], confirmedShortcutIDs: [])
|
||||
|
||||
// Opted in, but the personal style was never generated or was deleted.
|
||||
XCTAssertFalse(
|
||||
AIClipboardSkillCatalog.availableForKeyboard(
|
||||
AIClipboardSkillCatalog.catalog,
|
||||
layout: optedIn,
|
||||
hasPersonalReplyStyle: false
|
||||
).contains { $0.id == AIClipboardSkillCatalog.speakAsMeID }
|
||||
)
|
||||
// Style exists, but the user never turned the skill on.
|
||||
XCTAssertFalse(
|
||||
AIClipboardSkillCatalog.availableForKeyboard(
|
||||
AIClipboardSkillCatalog.catalog,
|
||||
layout: notOptedIn,
|
||||
hasPersonalReplyStyle: true
|
||||
).contains { $0.id == AIClipboardSkillCatalog.speakAsMeID }
|
||||
)
|
||||
// Always-available skills are untouched by this gate.
|
||||
XCTAssertTrue(
|
||||
AIClipboardSkillCatalog.availableForKeyboard(
|
||||
AIClipboardSkillCatalog.catalog,
|
||||
layout: notOptedIn,
|
||||
hasPersonalReplyStyle: false
|
||||
).contains { $0.id == AIClipboardSkillCatalog.replyID }
|
||||
)
|
||||
}
|
||||
|
||||
/// Availability is decided upstream by dropping it from the catalog; the
|
||||
/// floor must never resurrect a skill that was filtered out.
|
||||
func testSpeakAsMeIsAbsentWhenFilteredOutOfTheCatalog() {
|
||||
let gatedCatalog = AIClipboardSkillCatalog.catalog.filter {
|
||||
$0.id != AIClipboardSkillCatalog.speakAsMeID
|
||||
}
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: gatedCatalog,
|
||||
sourceText: "明天的评审会我这边准备得差不多了,材料稍后发群里。",
|
||||
analysis: analysis(language: "zh"),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"]
|
||||
).map(\.id)
|
||||
|
||||
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.speakAsMeID))
|
||||
}
|
||||
|
||||
/// The floor is a fallback, not a promotion: a strong content match still
|
||||
/// outranks it.
|
||||
func testSpeakAsMeDoesNotOutrankAContentMatch() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "Could you send me the final proposal by Friday?",
|
||||
analysis: analysis(language: "en", question: detected()),
|
||||
uiLanguage: .chinese,
|
||||
limit: 5,
|
||||
preferredLanguages: ["zh-Hans-CN"]
|
||||
).map(\.id)
|
||||
|
||||
let translateIndex = recommendations.firstIndex(of: AIClipboardSkillCatalog.translateID)
|
||||
let speakAsMeIndex = recommendations.firstIndex(of: AIClipboardSkillCatalog.speakAsMeID)
|
||||
XCTAssertNotNil(translateIndex)
|
||||
XCTAssertNotNil(speakAsMeIndex)
|
||||
XCTAssertLessThan(try XCTUnwrap(translateIndex), try XCTUnwrap(speakAsMeIndex))
|
||||
}
|
||||
|
||||
func testTranslationIsNotRecommendedForSystemLanguage() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "Could you send me the final proposal?",
|
||||
analysis: analysis(language: "en", question: detected()),
|
||||
uiLanguage: .chinese,
|
||||
@@ -36,7 +149,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testChineseScriptMismatchPromotesTranslation() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "這是一段繁體中文。",
|
||||
analysis: analysis(language: "zh-Hant"),
|
||||
uiLanguage: .chinese,
|
||||
@@ -86,7 +199,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testSingleHTTPSLinkOffersOpenAndWebpageSummary() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "https://example.com/article",
|
||||
analysis: analysis(urls: [URL(string: "https://example.com/article")!]),
|
||||
uiLanguage: .chinese,
|
||||
@@ -107,7 +220,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
let text = "https://www.apple.com/newsroom/"
|
||||
let detected = await ClipboardSemanticAnalyzer().analyze(text)
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: text,
|
||||
analysis: detected,
|
||||
uiLanguage: .chinese,
|
||||
@@ -126,7 +239,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testMultipleLinksDoNotChooseAnAmbiguousTarget() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "https://example.com/a https://example.com/b",
|
||||
analysis: analysis(urls: [
|
||||
URL(string: "https://example.com/a")!,
|
||||
@@ -143,7 +256,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testSinglePhoneNumberOffersCallAndCreateContact() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "请拨打 +1 408-996-1010",
|
||||
analysis: analysis(
|
||||
phoneNumbers: [
|
||||
@@ -168,7 +281,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
let text = "联系电话:+1 408-996-1010"
|
||||
let detected = await ClipboardSemanticAnalyzer().analyze(text)
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: text,
|
||||
analysis: detected,
|
||||
uiLanguage: .chinese,
|
||||
@@ -187,7 +300,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testMultiplePhoneNumbersDoNotChooseAnAmbiguousTarget() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "+1 408-996-1010 / 400-666-8800",
|
||||
analysis: analysis(
|
||||
phoneNumbers: [
|
||||
@@ -225,7 +338,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
isApprovedForAutomaticRouting: false
|
||||
)
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "这个问题已经发生三次了,请尽快处理。",
|
||||
analysis: analysis(
|
||||
sentiment: .negative,
|
||||
@@ -244,7 +357,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
complaint: detected()
|
||||
)
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "这个问题已经发生三次了,到底什么时候能解决?",
|
||||
analysis: analysis,
|
||||
uiLanguage: .chinese,
|
||||
@@ -262,7 +375,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
question: detected()
|
||||
)
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "为什么到现在还没有发给我?",
|
||||
analysis: analysis,
|
||||
uiLanguage: .chinese,
|
||||
@@ -322,7 +435,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testRecommendationsAddReplyToSemanticallyRelevantSkills() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "北京市朝阳区望京街 10 号,到了给我电话。",
|
||||
analysis: analysis(hasAddress: true),
|
||||
uiLanguage: .chinese,
|
||||
@@ -342,7 +455,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testRecommendationsFallBackToReplyWhenNoSemanticLabelMatches() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "知道了",
|
||||
analysis: analysis(),
|
||||
uiLanguage: .chinese,
|
||||
@@ -354,7 +467,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testNegativeReplyableMessageDoesNotOfferPlayfulReply() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "我刚到家,今天真是累坏了。",
|
||||
analysis: analysis(
|
||||
sentiment: .negative,
|
||||
@@ -369,7 +482,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testInvitationUsesOneReplyCenterAndKeepsCalendarAction() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "今晚七点老地方吃饭,你能来吗?",
|
||||
analysis: analysis(
|
||||
hasDate: true,
|
||||
@@ -392,7 +505,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testForeignQuestionKeepsTranslationAndUnifiedReply() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "Could you send the final proposal by Friday?",
|
||||
analysis: analysis(
|
||||
language: "en",
|
||||
@@ -717,7 +830,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testLinkInsideAMessageKeepsTheMessageSkills() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "会议链接 https://zoom.us/j/123 周三下午三点开始,能来吗?",
|
||||
analysis: analysis(
|
||||
hasDate: true,
|
||||
@@ -743,7 +856,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testLabelledLinkPasteStaysExclusive() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "详情见 https://example.com/article",
|
||||
analysis: analysis(urls: [URL(string: "https://example.com/article")!]),
|
||||
uiLanguage: .chinese,
|
||||
@@ -763,7 +876,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
|
||||
func testPhoneNumberInsideAMessageKeepsTheMessageSkills() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "王经理说这个订单有问题,你直接打 400-666-8800 找售后处理一下。",
|
||||
analysis: analysis(
|
||||
phoneNumbers: [ClipboardTextLabel(sourceText: "400-666-8800")],
|
||||
@@ -793,7 +906,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
count: 10
|
||||
)
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: text,
|
||||
analysis: analysis(),
|
||||
uiLanguage: .chinese,
|
||||
@@ -820,7 +933,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
].joined(separator: "\n")
|
||||
|
||||
let proseRecommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: prose,
|
||||
analysis: analysis(),
|
||||
uiLanguage: .chinese,
|
||||
@@ -829,7 +942,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
).map(\.id)
|
||||
|
||||
let listRecommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: "牛奶\n鸡蛋\n面包",
|
||||
analysis: analysis(),
|
||||
uiLanguage: .chinese,
|
||||
@@ -843,12 +956,22 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
/// Most tests describe the default experience, which is the one before any
|
||||
/// personal style exists. "Speak as me" ships enabled but stays out of the
|
||||
/// keyboard's catalog until a style is distilled, so passing the raw catalog
|
||||
/// would assert against a state most users are never in. Filter on the real
|
||||
/// prerequisite rather than on `isDefault`, which no longer implies it.
|
||||
private static let catalogWithoutOptInSkills = AIClipboardSkillCatalog.catalog
|
||||
.filter {
|
||||
!AIClipboardSkillCatalog.requiresPersonalReplyStyleIDs.contains($0.id)
|
||||
}
|
||||
|
||||
private func rank(
|
||||
text: String,
|
||||
analysis: ClipboardSemanticAnalysis
|
||||
) -> [String] {
|
||||
ClipboardSkillSemanticRanker.ranked(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: text,
|
||||
analysis: analysis,
|
||||
uiLanguage: .chinese,
|
||||
@@ -861,7 +984,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
analysis: ClipboardSemanticAnalysis
|
||||
) -> [String] {
|
||||
ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
skills: Self.catalogWithoutOptInSkills,
|
||||
sourceText: text,
|
||||
analysis: analysis,
|
||||
uiLanguage: .chinese,
|
||||
|
||||
@@ -133,6 +133,47 @@ final class CloudASRHTTPClientTests: XCTestCase {
|
||||
XCTAssertEqual(json["model"] as? String, "openai/whisper-large-v3-turbo")
|
||||
}
|
||||
|
||||
func testMiMoTranscribeSendsAudioOnlyContentAndApiKeyHeader() async throws {
|
||||
StubURLProtocolStorage.config = (
|
||||
200,
|
||||
Data(#"{"choices":[{"message":{"content":"你好世界"}}]}"#.utf8)
|
||||
)
|
||||
let session = StubURLProtocol.makeEphemeralSession()
|
||||
let client = PromptCloudASRClient(
|
||||
providerId: "mimo",
|
||||
baseURL: "https://api.xiaomimimo.com/v1",
|
||||
apiKey: "mimo-key",
|
||||
model: "mimo-v2.5-asr",
|
||||
session: session
|
||||
)
|
||||
// A non-empty dictionary must NOT add a text prompt part: MiMo-V2.5-ASR
|
||||
// rejects any content part besides input_audio with HTTP 400.
|
||||
let dict = PersonalDictionary(entries: [
|
||||
PersonalDictionary.Entry(term: "OSGKeyboard", category: .productName, source: .manual)
|
||||
])
|
||||
let text = try await client.transcribe(
|
||||
samples: [Float](repeating: 0.01, count: 1_600),
|
||||
sampleRate: 16_000,
|
||||
locale: Locale(identifier: "zh-Hans"),
|
||||
dictionary: dict
|
||||
)
|
||||
XCTAssertEqual(text, "你好世界")
|
||||
|
||||
let request = try XCTUnwrap(StubURLProtocolStorage.lastRequest)
|
||||
XCTAssertEqual(request.url?.path, "/v1/chat/completions")
|
||||
XCTAssertEqual(request.value(forHTTPHeaderField: "api-key"), "mimo-key")
|
||||
XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
|
||||
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
XCTAssertEqual(json["model"] as? String, "mimo-v2.5-asr")
|
||||
let messages = try XCTUnwrap(json["messages"] as? [[String: Any]])
|
||||
let content = try XCTUnwrap(messages.first?["content"] as? [[String: Any]])
|
||||
XCTAssertEqual(content.count, 1)
|
||||
XCTAssertEqual(content.first?["type"] as? String, "input_audio")
|
||||
XCTAssertFalse(content.contains { ($0["type"] as? String) == "text" })
|
||||
}
|
||||
|
||||
func testUnsupportedCloudASRClientThrowsProviderUnsupported() async {
|
||||
let client = UnsupportedCloudASRClient(providerId: "unknown-provider")
|
||||
do {
|
||||
|
||||
@@ -34,6 +34,29 @@ final class CloudASRTests: XCTestCase {
|
||||
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "volcengine"), "volc.seedasr.sauc.duration")
|
||||
}
|
||||
|
||||
func testInsufficientBalanceErrorMessageForHTTP402() {
|
||||
// SiliconFlow gates some ¥0-priced ASR models (e.g. XingChenASR V3.2)
|
||||
// behind a positive balance and returns 402 "account balance is
|
||||
// insufficient". The probe must surface an actionable balance hint,
|
||||
// not a bare "HTTP 402" that looks like a bad key. (issue #43)
|
||||
let error = CloudASRError.http(
|
||||
status: 402,
|
||||
message: "Sorry, your account balance is insufficient"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
error.errorDescription,
|
||||
SharedL10n.string("error.cloudASR.insufficientBalance")
|
||||
)
|
||||
}
|
||||
|
||||
func testInsufficientBalanceDetectionByMessageAndStatus() {
|
||||
XCTAssertTrue(CloudASRError.isInsufficientBalance(status: 402, message: nil))
|
||||
XCTAssertTrue(CloudASRError.isInsufficientBalance(status: 400, message: "余额不足"))
|
||||
XCTAssertTrue(CloudASRError.isInsufficientBalance(status: 403, message: "insufficient balance"))
|
||||
XCTAssertFalse(CloudASRError.isInsufficientBalance(status: 401, message: "Invalid API key"))
|
||||
XCTAssertFalse(CloudASRError.isInsufficientBalance(status: 500, message: nil))
|
||||
}
|
||||
|
||||
func testAsrSelectablePresetsAllowlist() {
|
||||
let ids = Set(LLMProvider.asrSelectablePresets.map(\.id))
|
||||
XCTAssertTrue(ids.contains("groq"))
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// FlowAdaptiveDownsamplerTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
//
|
||||
// `AVAudioEngine.installTap(format:)` raises an UNCATCHABLE NSException when
|
||||
// the format handed to it no longer matches the input node's live format, and
|
||||
// that kills the whole app. The fix everywhere is the same: install with
|
||||
// `format: nil` and let this resampler absorb whatever rate actually arrives.
|
||||
//
|
||||
// These tests pin the behaviour that makes `format: nil` safe — converting
|
||||
// across a mid-stream route change — plus the numeric clamp that keeps a
|
||||
// degenerate route from trapping on the realtime audio thread.
|
||||
|
||||
import AVFoundation
|
||||
@testable import OSGKeyboardHostSupport
|
||||
import XCTest
|
||||
|
||||
final class FlowAdaptiveDownsamplerTests: XCTestCase {
|
||||
private let targetFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)
|
||||
|
||||
private func makeBuffer(sampleRate: Double, frames: AVAudioFrameCount) -> AVAudioPCMBuffer? {
|
||||
guard let format = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: sampleRate,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
), let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames) else {
|
||||
return nil
|
||||
}
|
||||
buffer.frameLength = frames
|
||||
// A quiet ramp: silence would also convert, but non-zero samples prove
|
||||
// the converter actually ran rather than handing back an empty scratch.
|
||||
if let channel = buffer.floatChannelData?[0] {
|
||||
for index in 0..<Int(frames) {
|
||||
channel[index] = Float(index % 64) / 64.0 * 0.1
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
func testConvertsHardwareRateToTargetRate() throws {
|
||||
let target = try XCTUnwrap(targetFormat)
|
||||
let downsampler = FlowAdaptiveDownsampler(targetFormat: target)
|
||||
let buffer = try XCTUnwrap(makeBuffer(sampleRate: 48_000, frames: 4_096))
|
||||
|
||||
guard case .converted(let output) = downsampler.convertReusingScratch(buffer) else {
|
||||
return XCTFail("expected a converted buffer for a 48 kHz tap")
|
||||
}
|
||||
XCTAssertEqual(output.format.sampleRate, 16_000)
|
||||
XCTAssertGreaterThan(output.frameLength, 0)
|
||||
}
|
||||
|
||||
func testRebuildsConverterWhenTheRouteChangesMidStream() throws {
|
||||
let target = try XCTUnwrap(targetFormat)
|
||||
let downsampler = FlowAdaptiveDownsampler(targetFormat: target)
|
||||
|
||||
// Built-in mic …
|
||||
let wideband = try XCTUnwrap(makeBuffer(sampleRate: 48_000, frames: 4_096))
|
||||
guard case .converted = downsampler.convertReusingScratch(wideband) else {
|
||||
return XCTFail("expected the first buffer to convert")
|
||||
}
|
||||
|
||||
// … then the user connects a headset and the node switches rate. This
|
||||
// is the exact transition that crashed a tap installed with an
|
||||
// explicit format.
|
||||
let narrowband = try XCTUnwrap(makeBuffer(sampleRate: 24_000, frames: 4_096))
|
||||
guard case .converted(let output) = downsampler.convertReusingScratch(narrowband) else {
|
||||
return XCTFail("expected the post-route-change buffer to convert")
|
||||
}
|
||||
XCTAssertEqual(output.format.sampleRate, 16_000)
|
||||
XCTAssertGreaterThan(output.frameLength, 0)
|
||||
}
|
||||
|
||||
func testUpsamplesSubTargetTelephonyRates() throws {
|
||||
let target = try XCTUnwrap(targetFormat)
|
||||
let downsampler = FlowAdaptiveDownsampler(targetFormat: target)
|
||||
let buffer = try XCTUnwrap(makeBuffer(sampleRate: 8_000, frames: 4_096))
|
||||
|
||||
// 4096 × (16k / 8k) = 8192 frames — exactly the scratch capacity, so
|
||||
// this is the boundary the headroom constant was sized for.
|
||||
guard case .converted(let output) = downsampler.convertReusingScratch(buffer) else {
|
||||
return XCTFail("expected an 8 kHz telephony route to convert")
|
||||
}
|
||||
XCTAssertGreaterThan(output.frameLength, 0)
|
||||
}
|
||||
|
||||
func testGrowsTheScratchForAnOversizedTapSliceInsteadOfDroppingAudio() throws {
|
||||
let target = try XCTUnwrap(targetFormat)
|
||||
let downsampler = FlowAdaptiveDownsampler(targetFormat: target)
|
||||
// 4096 × (16k / 4k) = 16,384 output frames against the 8,192-frame
|
||||
// starting scratch. `bufferSize:` is only a hint — macOS hands pro
|
||||
// interfaces larger slices — so this must grow, not drop the audio.
|
||||
let buffer = try XCTUnwrap(makeBuffer(sampleRate: 4_000, frames: 4_096))
|
||||
|
||||
guard case .converted(let output) = downsampler.convertReusingScratch(buffer) else {
|
||||
return XCTFail("expected an oversized slice to grow the scratch and convert")
|
||||
}
|
||||
XCTAssertGreaterThan(output.frameLength, 8_192)
|
||||
|
||||
// The grown scratch is reused: a following ordinary buffer must still
|
||||
// convert, and must not report the previous buffer's frame count.
|
||||
let followUp = try XCTUnwrap(makeBuffer(sampleRate: 48_000, frames: 4_096))
|
||||
guard case .converted(let second) = downsampler.convertReusingScratch(followUp) else {
|
||||
return XCTFail("expected the next buffer to convert against the grown scratch")
|
||||
}
|
||||
XCTAssertLessThan(second.frameLength, output.frameLength)
|
||||
}
|
||||
|
||||
func testRefusesAConversionPastTheGrowthCeiling() throws {
|
||||
let target = try XCTUnwrap(targetFormat)
|
||||
let downsampler = FlowAdaptiveDownsampler(targetFormat: target)
|
||||
// 32,768 × (16k / 8k) = 65,536 output frames — past the 32,768 ceiling.
|
||||
// Growth has a limit so a nonsense ratio cannot allocate without bound
|
||||
// on the realtime thread; past it, dropping the frame is correct.
|
||||
let buffer = try XCTUnwrap(makeBuffer(sampleRate: 8_000, frames: 32_768))
|
||||
|
||||
guard case .failed(let failure, _, let inputFrames, let wantedFrames) =
|
||||
downsampler.convertReusingScratch(buffer) else {
|
||||
return XCTFail("expected a conversion past the ceiling to be reported")
|
||||
}
|
||||
XCTAssertEqual(failure, .scratchOverflow)
|
||||
XCTAssertEqual(inputFrames, 32_768)
|
||||
// The report still carries a finite, usable number for diagnostics.
|
||||
XCTAssertGreaterThan(wantedFrames, 0)
|
||||
}
|
||||
|
||||
func testUnsupportedRateFailsCleanlyRatherThanTrapping() throws {
|
||||
let target = try XCTUnwrap(targetFormat)
|
||||
let downsampler = FlowAdaptiveDownsampler(targetFormat: target)
|
||||
// A 1 Hz "route" is not something `AVAudioConverter` will build for.
|
||||
// The contract that matters is the same either way: report the drop,
|
||||
// never trap.
|
||||
let buffer = try XCTUnwrap(makeBuffer(sampleRate: 1, frames: 4_096))
|
||||
|
||||
guard case .failed(let failure, _, _, _) =
|
||||
downsampler.convertReusingScratch(buffer) else {
|
||||
return XCTFail("expected a degenerate rate to be reported, not converted")
|
||||
}
|
||||
XCTAssertTrue(
|
||||
failure == .converterCreateFailed || failure == .scratchOverflow,
|
||||
"unexpected failure \(failure.label)"
|
||||
)
|
||||
}
|
||||
|
||||
func testRecoversAfterADroppedFrame() throws {
|
||||
let target = try XCTUnwrap(targetFormat)
|
||||
let downsampler = FlowAdaptiveDownsampler(targetFormat: target)
|
||||
let degenerate = try XCTUnwrap(makeBuffer(sampleRate: 1, frames: 4_096))
|
||||
_ = downsampler.convertReusingScratch(degenerate)
|
||||
|
||||
// One bad frame must not wedge the resampler for the rest of the
|
||||
// session — the route can recover.
|
||||
let healthy = try XCTUnwrap(makeBuffer(sampleRate: 48_000, frames: 4_096))
|
||||
guard case .converted(let output) = downsampler.convertReusingScratch(healthy) else {
|
||||
return XCTFail("expected the resampler to recover after a dropped frame")
|
||||
}
|
||||
XCTAssertGreaterThan(output.frameLength, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// FlowFailureDiagnosticsTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class FlowFailureDiagnosticsTests: XCTestCase {
|
||||
/// The keyboard witnesses the failure; the host knows why. The handshake is
|
||||
/// what puts both windows in one export.
|
||||
func testHostConsumesKeyboardDumpRequestOnce() throws {
|
||||
let (defaults, suiteName) = makeDefaults()
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let requestedAt = Date()
|
||||
FlowFailureDiagnostics.requestHostSnapshot(
|
||||
reason: "startTimeout",
|
||||
now: requestedAt,
|
||||
defaults: defaults
|
||||
)
|
||||
|
||||
let request = try XCTUnwrap(
|
||||
FlowFailureDiagnostics.consumeHostSnapshotRequest(
|
||||
now: requestedAt.addingTimeInterval(1),
|
||||
defaults: defaults
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(request.reason, "startTimeout")
|
||||
|
||||
// Consuming clears it: a second host launch must not re-report.
|
||||
XCTAssertNil(
|
||||
FlowFailureDiagnostics.consumeHostSnapshotRequest(
|
||||
now: requestedAt.addingTimeInterval(2),
|
||||
defaults: defaults
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// A request left behind while the host was dead must not make an unrelated
|
||||
/// later launch write a bogus companion report.
|
||||
func testStaleDumpRequestIsDiscarded() {
|
||||
let (defaults, suiteName) = makeDefaults()
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
let requestedAt = Date()
|
||||
FlowFailureDiagnostics.requestHostSnapshot(
|
||||
reason: "hostDisconnected",
|
||||
now: requestedAt,
|
||||
defaults: defaults
|
||||
)
|
||||
|
||||
XCTAssertNil(
|
||||
FlowFailureDiagnostics.consumeHostSnapshotRequest(
|
||||
now: requestedAt.addingTimeInterval(
|
||||
FlowFailureDiagnostics.dumpRequestMaxAge + 1
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testBridgeContextAlwaysIdentifiesTheRecordingProcess() {
|
||||
let context = FlowFailureDiagnostics.bridgeContext()
|
||||
XCTAssertEqual(context["process"], FlowDiagnosticsProcess.current.rawValue)
|
||||
XCTAssertNotNil(context["appGroupAvailable"])
|
||||
}
|
||||
|
||||
private func makeDefaults() -> (UserDefaults, String) {
|
||||
let suiteName = "FlowFailureDiagnosticsTests.\(UUID().uuidString)"
|
||||
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
||||
XCTFail("could not open test suite")
|
||||
return (.standard, suiteName)
|
||||
}
|
||||
return (defaults, suiteName)
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,22 @@
|
||||
// OSGKeyboardTests
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
@testable import OSGKeyboard
|
||||
import XCTest
|
||||
|
||||
final class FlowFailureLogStoreTests: XCTestCase {
|
||||
func testFailureReportKeepsOnlyTenSecondsAndRedactsSensitiveMetadata() throws {
|
||||
func testFailureReportKeepsRetentionWindowAndRedactsSensitiveMetadata() throws {
|
||||
let directory = temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let clock = MutableFailureLogClock(
|
||||
Date(timeIntervalSince1970: 1_800_000_000)
|
||||
)
|
||||
let store = FlowFailureLogStore(
|
||||
directoryURL: directory,
|
||||
now: { clock.current }
|
||||
)
|
||||
let store = makeStore(directory: directory, clock: clock)
|
||||
|
||||
store.record("event=tooOld")
|
||||
clock.advance(by: 11)
|
||||
clock.advance(by: FlowFailureLogStore.retentionWindow + 1)
|
||||
store.record(
|
||||
"event=current sessionId=550e8400-e29b-41d4-a716-446655440000 "
|
||||
+ "access_token=secret container=/private/var/mobile/Containers/example"
|
||||
@@ -31,12 +29,7 @@ final class FlowFailureLogStoreTests: XCTestCase {
|
||||
context: ["authorization": "bearer=secret"]
|
||||
)
|
||||
)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let report = try decoder.decode(
|
||||
FlowStartupFailureReport.self,
|
||||
from: Data(contentsOf: url)
|
||||
)
|
||||
let report = try decodeReport(at: url)
|
||||
|
||||
XCTAssertEqual(report.reason, "timedOut")
|
||||
XCTAssertEqual(report.events.count, 1)
|
||||
@@ -47,6 +40,149 @@ final class FlowFailureLogStoreTests: XCTestCase {
|
||||
XCTAssertEqual(report.events[0].message.components(separatedBy: "<uuid>").count, 2)
|
||||
}
|
||||
|
||||
/// A cold-start handoff spans app launch, URL routing, permission checks,
|
||||
/// the host's 5 s PiP budget and the keyboard's 8 s start budget. The old
|
||||
/// 10 s window pruned away exactly the events that explain the failure.
|
||||
func testRetentionWindowCoversAFullColdStartHandoff() throws {
|
||||
let directory = temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let clock = MutableFailureLogClock(
|
||||
Date(timeIntervalSince1970: 1_800_000_000)
|
||||
)
|
||||
let store = makeStore(directory: directory, clock: clock)
|
||||
|
||||
store.record("event=startSession.request")
|
||||
clock.advance(by: 14)
|
||||
store.record("event=startWatchdog.timeout")
|
||||
|
||||
let url = try XCTUnwrap(
|
||||
store.persistStartupFailure(reason: "startTimeout", context: [:])
|
||||
)
|
||||
let report = try decodeReport(at: url)
|
||||
|
||||
XCTAssertEqual(report.events.count, 2)
|
||||
XCTAssertTrue(report.events[0].message.contains("startSession.request"))
|
||||
}
|
||||
|
||||
func testEventWindowIsCappedByCount() throws {
|
||||
let directory = temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let store = makeStore(directory: directory)
|
||||
for index in 0..<(FlowFailureLogStore.maxEventCount + 50) {
|
||||
store.record("event=noise\(index)")
|
||||
}
|
||||
|
||||
let url = try XCTUnwrap(
|
||||
store.persistStartupFailure(reason: "timedOut", context: [:])
|
||||
)
|
||||
let report = try decodeReport(at: url)
|
||||
|
||||
XCTAssertEqual(report.events.count, FlowFailureLogStore.maxEventCount)
|
||||
// Oldest events are dropped first; the failure itself is the newest.
|
||||
XCTAssertTrue(report.events.last?.message.contains("noise449") == true)
|
||||
}
|
||||
|
||||
/// The keyboard extension is the only process still alive when the host was
|
||||
/// never launched, so its reports must be tagged and must survive alongside
|
||||
/// the host's in one directory.
|
||||
func testHostAndKeyboardReportsShareOneDirectoryAndStayTagged() throws {
|
||||
let directory = temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let hostStore = makeStore(directory: directory, process: .host)
|
||||
let keyboardStore = makeStore(directory: directory, process: .keyboard)
|
||||
|
||||
hostStore.record("event=pipRecovery.deferred")
|
||||
XCTAssertNotNil(
|
||||
hostStore.persistStartupFailure(reason: "notPossible", context: [:])
|
||||
)
|
||||
keyboardStore.record("event=startWatchdog.timeout")
|
||||
XCTAssertNotNil(
|
||||
keyboardStore.persistStartupFailure(reason: "startTimeout", context: [:])
|
||||
)
|
||||
|
||||
let reports = keyboardStore.reports()
|
||||
XCTAssertEqual(reports.count, 2)
|
||||
XCTAssertEqual(Set(reports.map(\.process)), [.host, .keyboard])
|
||||
let keyboardReport = try XCTUnwrap(reports.first { $0.process == .keyboard })
|
||||
XCTAssertEqual(keyboardReport.reason, "startTimeout")
|
||||
XCTAssertTrue(
|
||||
keyboardReport.events.contains { $0.message.contains("startWatchdog.timeout") }
|
||||
)
|
||||
}
|
||||
|
||||
/// Reports written while the App Group container was unavailable land in the
|
||||
/// process-local directory; listing and export must still find them.
|
||||
func testReportsInFallbackDirectoryAreListedAndExported() throws {
|
||||
let primary = temporaryDirectory()
|
||||
let fallback = temporaryDirectory()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: primary)
|
||||
try? FileManager.default.removeItem(at: fallback)
|
||||
}
|
||||
|
||||
let degradedStore = FlowFailureLogStore(
|
||||
directoryURL: fallback,
|
||||
additionalReadDirectoryURLs: [],
|
||||
process: .host
|
||||
)
|
||||
degradedStore.record("event=appGroupUnavailable")
|
||||
XCTAssertNotNil(
|
||||
degradedStore.persistStartupFailure(
|
||||
reason: "appGroupUnavailable",
|
||||
context: [:]
|
||||
)
|
||||
)
|
||||
|
||||
let recoveredStore = FlowFailureLogStore(
|
||||
directoryURL: primary,
|
||||
additionalReadDirectoryURLs: [fallback],
|
||||
process: .host
|
||||
)
|
||||
XCTAssertEqual(recoveredStore.reportURLs().count, 1)
|
||||
|
||||
let exportURL = try XCTUnwrap(recoveredStore.makeExportURL())
|
||||
let exportText = try String(contentsOf: exportURL, encoding: .utf8)
|
||||
XCTAssertTrue(exportText.contains("\"appGroupUnavailable\""))
|
||||
}
|
||||
|
||||
/// Schema v1 reports predate `process`; they must stay exportable.
|
||||
func testLegacySchemaV1ReportDecodesAsHostReport() throws {
|
||||
let directory = temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let legacy = """
|
||||
{
|
||||
"appVersion": "1.0",
|
||||
"buildNumber": "1",
|
||||
"capturedAt": "2024-01-01T00:00:00Z",
|
||||
"context": {},
|
||||
"events": [],
|
||||
"operatingSystem": "iOS 18.0",
|
||||
"reason": "timedOut",
|
||||
"reportID": "550E8400-E29B-41D4-A716-446655440000",
|
||||
"schemaVersion": 1,
|
||||
"windowStartedAt": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
"""
|
||||
try Data(legacy.utf8).write(
|
||||
to: directory.appendingPathComponent(
|
||||
"flow-start-failure-2024-01-01T00-00-00Z-legacy.json"
|
||||
)
|
||||
)
|
||||
|
||||
let store = makeStore(directory: directory)
|
||||
let report = try XCTUnwrap(store.reports().first)
|
||||
XCTAssertEqual(report.process, .host)
|
||||
XCTAssertEqual(report.reason, "timedOut")
|
||||
}
|
||||
|
||||
func testReportRetentionKeepsNewestFilesWithinCountLimit() throws {
|
||||
let directory = temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
@@ -56,6 +192,8 @@ final class FlowFailureLogStoreTests: XCTestCase {
|
||||
)
|
||||
let store = FlowFailureLogStore(
|
||||
directoryURL: directory,
|
||||
additionalReadDirectoryURLs: [],
|
||||
process: .host,
|
||||
maxReportCount: 2,
|
||||
now: { clock.current }
|
||||
)
|
||||
@@ -78,7 +216,7 @@ final class FlowFailureLogStoreTests: XCTestCase {
|
||||
let directory = temporaryDirectory()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let store = FlowFailureLogStore(directoryURL: directory)
|
||||
let store = makeStore(directory: directory)
|
||||
store.record("event=failure")
|
||||
XCTAssertNotNil(
|
||||
store.persistStartupFailure(
|
||||
@@ -97,6 +235,31 @@ final class FlowFailureLogStoreTests: XCTestCase {
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: exportURL.path))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeStore(
|
||||
directory: URL,
|
||||
process: FlowDiagnosticsProcess = .host,
|
||||
clock: MutableFailureLogClock? = nil
|
||||
) -> FlowFailureLogStore {
|
||||
FlowFailureLogStore(
|
||||
directoryURL: directory,
|
||||
// Never fall back to the real Application Support directory in tests.
|
||||
additionalReadDirectoryURLs: [],
|
||||
process: process,
|
||||
now: clock.map { clock in { clock.current } } ?? Date.init
|
||||
)
|
||||
}
|
||||
|
||||
private func decodeReport(at url: URL) throws -> FlowStartupFailureReport {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return try decoder.decode(
|
||||
FlowStartupFailureReport.self,
|
||||
from: Data(contentsOf: url)
|
||||
)
|
||||
}
|
||||
|
||||
private func temporaryDirectory() -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"FlowFailureLogStoreTests-\(UUID().uuidString)",
|
||||
|
||||
@@ -147,3 +147,84 @@ final class FlowPiPRecoveryPolicyTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
final class FlowPiPLossReportGateTests: XCTestCase {
|
||||
/// The backgrounded heartbeat re-evaluates a lost PiP every second. One
|
||||
/// incident must produce one report, not one per tick.
|
||||
func testRepeatedLossChecksWithinOneEpisodeReportOnce() {
|
||||
var gate = FlowPiPLossReportGate()
|
||||
XCTAssertTrue(gate.shouldReport())
|
||||
for _ in 0..<30 {
|
||||
XCTAssertFalse(gate.shouldReport())
|
||||
}
|
||||
}
|
||||
|
||||
func testPiPBecomingActiveOpensANewEpisode() {
|
||||
var gate = FlowPiPLossReportGate()
|
||||
XCTAssertTrue(gate.shouldReport())
|
||||
XCTAssertFalse(gate.shouldReport())
|
||||
|
||||
gate.pipBecameActive()
|
||||
XCTAssertTrue(gate.shouldReport())
|
||||
}
|
||||
}
|
||||
|
||||
final class FlowSessionWarningReportGateTests: XCTestCase {
|
||||
/// The whole point: a toast raised by a path nobody instrumented must still
|
||||
/// produce a report.
|
||||
func testUninstrumentedWarningIsReported() {
|
||||
var gate = FlowSessionWarningReportGate()
|
||||
XCTAssertTrue(
|
||||
gate.shouldReport(
|
||||
warning: "cannot start voice session",
|
||||
lastExplicitReportAt: nil,
|
||||
now: Date()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// A path that already wrote a precise report must not be double-counted.
|
||||
func testWarningIsSuppressedWhenAnExplicitReportJustLanded() {
|
||||
var gate = FlowSessionWarningReportGate()
|
||||
let now = Date()
|
||||
XCTAssertFalse(
|
||||
gate.shouldReport(
|
||||
warning: "permissions missing",
|
||||
lastExplicitReportAt: now.addingTimeInterval(-0.2),
|
||||
now: now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testOlderExplicitReportDoesNotSuppressANewWarning() {
|
||||
var gate = FlowSessionWarningReportGate()
|
||||
let now = Date()
|
||||
XCTAssertTrue(
|
||||
gate.shouldReport(
|
||||
warning: "cannot start voice session",
|
||||
lastExplicitReportAt: now.addingTimeInterval(
|
||||
-(FlowSessionWarningReportGate.duplicateGrace + 1)
|
||||
),
|
||||
now: now
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// A toast that stays up must not re-report on every state republish.
|
||||
func testSameWarningReportsOnlyOncePerEpisode() {
|
||||
var gate = FlowSessionWarningReportGate()
|
||||
let now = Date()
|
||||
XCTAssertTrue(
|
||||
gate.shouldReport(warning: "same", lastExplicitReportAt: nil, now: now)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
gate.shouldReport(warning: "same", lastExplicitReportAt: nil, now: now)
|
||||
)
|
||||
|
||||
// Cleared and shown again is a genuinely new toast.
|
||||
gate.warningCleared()
|
||||
XCTAssertTrue(
|
||||
gate.shouldReport(warning: "same", lastExplicitReportAt: nil, now: now)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,6 +746,41 @@ final class FlowSessionBridgeTests: XCTestCase {
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
|
||||
func testReassertSessionActiveRecoversFlagClearedByKeyboardCleanup() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
FlowSessionBridge.markSessionActivePersistent(sessionId: sessionId, defaults: defaults)
|
||||
|
||||
// Host suspended after a PiP drop: heartbeat froze, so the keyboard's
|
||||
// stale-host cleanup declares it dead and clears the session flag while
|
||||
// the host process is actually still alive.
|
||||
let zombieHeartbeat = Date().timeIntervalSince1970 - 120
|
||||
defaults.set(zombieHeartbeat, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
XCTAssertTrue(FlowSessionBridge.clearIfHostStale(defaults: defaults))
|
||||
XCTAssertFalse(FlowSessionBridge.isSessionActive(defaults: defaults))
|
||||
|
||||
// The resumed-but-alive host re-asserts the flag and republishes ready.
|
||||
FlowSessionBridge.reassertSessionActive(defaults: defaults)
|
||||
XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults))
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReachable(defaults: defaults))
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
FlowSessionBridge.writeReadySnapshot(
|
||||
FlowReadySnapshot(
|
||||
sessionId: sessionId,
|
||||
ready: true,
|
||||
reason: .ready,
|
||||
heartbeatAt: now,
|
||||
readyAt: now,
|
||||
audioProofAt: now,
|
||||
engineMode: "cloud",
|
||||
localeId: "zh-Hans"
|
||||
),
|
||||
defaults: defaults
|
||||
)
|
||||
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
|
||||
}
|
||||
|
||||
func testHostReadyRejectedWhenReadyAtSkewsFromHeartbeat() {
|
||||
let defaults = makeDefaults()
|
||||
let sessionId = UUID()
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// InternalDiagnosticsUploaderTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
//
|
||||
// The uploader only ever runs on internal builds, so it gets very little
|
||||
// organic exercise — which is exactly why its edge cases need pinning. The
|
||||
// behaviours that matter: never send from an App Store build, never send the
|
||||
// same report twice, and never hammer a backend route that does not exist yet.
|
||||
|
||||
import Foundation
|
||||
@testable import OSGKeyboard
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class InternalDiagnosticsUploaderTests: XCTestCase {
|
||||
private var directory: URL!
|
||||
private var store: FlowFailureLogStore!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("diagnostics-upload-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
store = FlowFailureLogStore(
|
||||
directoryURL: directory,
|
||||
additionalReadDirectoryURLs: [],
|
||||
process: .host
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
directory = nil
|
||||
store = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@discardableResult
|
||||
private func seedMetricKitReport(kind: String = "crash") -> Bool {
|
||||
store.persistStartupFailure(
|
||||
reason: "metrickit.\(kind): exc=1 signal=9",
|
||||
context: ["source": "metricKit", "kind": kind]
|
||||
) != nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func seedFlowReport() -> Bool {
|
||||
store.persistStartupFailure(
|
||||
reason: "startWatchdogTimeout",
|
||||
context: ["stage": "hostOpen"]
|
||||
) != nil
|
||||
}
|
||||
|
||||
private static let installationID = UUID(
|
||||
uuidString: "10000000-0000-0000-0000-000000000001"
|
||||
)!
|
||||
|
||||
private func makeUploader(
|
||||
network: AnalyticsQueueNetwork,
|
||||
isInternalBuild: Bool = true,
|
||||
isUploadEnabled: Bool = true,
|
||||
installationID: UUID? = InternalDiagnosticsUploaderTests.installationID,
|
||||
now: @escaping @Sendable () -> Date = Date.init
|
||||
) -> InternalDiagnosticsUploader {
|
||||
InternalDiagnosticsUploader(
|
||||
store: store,
|
||||
endpoint: URL(string: "https://example.invalid/v1/analytics/diagnostics")!,
|
||||
network: network,
|
||||
bearerProvider: nil,
|
||||
isInternalBuild: isInternalBuild,
|
||||
isUploadEnabled: { isUploadEnabled },
|
||||
installationIdentifier: { installationID },
|
||||
now: now,
|
||||
stateURL: directory.appendingPathComponent("state.json")
|
||||
)
|
||||
}
|
||||
|
||||
private static func ok() -> AnalyticsQueueNetwork.Outcome {
|
||||
.response(AnalyticsHTTPResponse(statusCode: 202, headers: [:], body: Data()))
|
||||
}
|
||||
|
||||
private static func status(_ code: Int) -> AnalyticsQueueNetwork.Outcome {
|
||||
.response(AnalyticsHTTPResponse(statusCode: code, headers: [:], body: Data()))
|
||||
}
|
||||
|
||||
// MARK: - Gating
|
||||
|
||||
func testAppStoreBuildsNeverUpload() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.ok()])
|
||||
let uploader = makeUploader(network: network, isInternalBuild: false)
|
||||
|
||||
let status = await uploader.uploadPendingReports()
|
||||
|
||||
XCTAssertEqual(status, .disabled)
|
||||
let requests = await network.requests()
|
||||
XCTAssertTrue(requests.isEmpty, "an App Store build must not make a request")
|
||||
}
|
||||
|
||||
func testDisabledPreferenceStopsUpload() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.ok()])
|
||||
let uploader = makeUploader(network: network, isUploadEnabled: false)
|
||||
|
||||
let status = await uploader.uploadPendingReports()
|
||||
|
||||
XCTAssertEqual(status, .disabled)
|
||||
let requests = await network.requests()
|
||||
XCTAssertTrue(requests.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Scope
|
||||
|
||||
func testOnlyMetricKitReportsAreUploaded() async throws {
|
||||
XCTAssertTrue(seedFlowReport())
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.ok(), Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
|
||||
let status = await uploader.uploadPendingReports()
|
||||
|
||||
XCTAssertEqual(status, .uploaded(count: 1))
|
||||
let requests = await network.requests()
|
||||
// Flow startup reports are a local debugging aid and were never part of
|
||||
// this decision; only the MetricKit crash data leaves the device.
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
}
|
||||
|
||||
func testUploadedReportIsNotSentAgain() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.ok(), Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
|
||||
_ = await uploader.uploadPendingReports()
|
||||
let second = await uploader.uploadPendingReports()
|
||||
|
||||
XCTAssertEqual(second, .idle(pending: 0))
|
||||
let requests = await network.requests()
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
}
|
||||
|
||||
func testRequestMatchesTheAccountServiceContract() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
|
||||
_ = await uploader.uploadPendingReports()
|
||||
|
||||
let requests = await network.requests()
|
||||
let request = try XCTUnwrap(requests.first)
|
||||
XCTAssertEqual(request.headers["Content-Type"], "application/json")
|
||||
// A response lost after the server committed must not create a duplicate.
|
||||
XCTAssertNotNil(request.headers["Idempotency-Key"])
|
||||
|
||||
let decoded = try XCTUnwrap(
|
||||
try JSONSerialization.jsonObject(with: request.body) as? [String: Any]
|
||||
)
|
||||
// The endpoint decodes strictly, so the key set has to match exactly.
|
||||
XCTAssertEqual(Set(decoded.keys), ["installationId", "channel", "reports"])
|
||||
XCTAssertEqual(decoded["channel"] as? String, "INTERNAL")
|
||||
XCTAssertEqual(
|
||||
decoded["installationId"] as? String,
|
||||
Self.installationID.uuidString.lowercased()
|
||||
)
|
||||
|
||||
let reports = try XCTUnwrap(decoded["reports"] as? [[String: Any]])
|
||||
XCTAssertEqual(reports.count, 1)
|
||||
let report = try XCTUnwrap(reports.first)
|
||||
XCTAssertEqual(report["kind"] as? String, "CRASH")
|
||||
XCTAssertEqual(report["process"] as? String, "APP")
|
||||
XCTAssertNotNil(report["clientReportId"] as? String)
|
||||
// `capturedAt` must be a UTC ISO-8601 instant or the server 400s.
|
||||
let capturedAt = try XCTUnwrap(report["capturedAt"] as? String)
|
||||
XCTAssertTrue(capturedAt.hasSuffix("Z"), capturedAt)
|
||||
XCTAssertTrue(
|
||||
Set(report.keys).isSubset(of: Self.serverReportKeys),
|
||||
"unexpected keys: \(Set(report.keys).subtracting(Self.serverReportKeys))"
|
||||
)
|
||||
}
|
||||
|
||||
/// Mirrors `DiagnosticReportRequest` on the account service. The server sets
|
||||
/// `ignoreUnknownKeys = false`, so anything outside this set is a 400.
|
||||
private static let serverReportKeys: Set<String> = [
|
||||
"clientReportId", "kind", "process", "capturedAt",
|
||||
"exceptionType", "exceptionCode", "signal", "terminationReason",
|
||||
"hangMillis", "topBinaries", "callStackDigest",
|
||||
"appVersion", "buildNumber", "osVersion", "deviceType"
|
||||
]
|
||||
|
||||
func testHangReportsMapToTheServerEnum() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport(kind: "hang"))
|
||||
let network = AnalyticsQueueNetwork([Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
|
||||
_ = await uploader.uploadPendingReports()
|
||||
|
||||
let requests = await network.requests()
|
||||
let decoded = try XCTUnwrap(
|
||||
try JSONSerialization.jsonObject(with: XCTUnwrap(requests.first).body) as? [String: Any]
|
||||
)
|
||||
let reports = try XCTUnwrap(decoded["reports"] as? [[String: Any]])
|
||||
XCTAssertEqual(reports.first?["kind"] as? String, "HANG")
|
||||
}
|
||||
|
||||
func testPendingReportsAreSentAsOneBatch() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
|
||||
let status = await uploader.uploadPendingReports()
|
||||
|
||||
XCTAssertEqual(status, .uploaded(count: 3))
|
||||
let requests = await network.requests()
|
||||
// One request, not one per report — the endpoint takes a batch.
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
let decoded = try XCTUnwrap(
|
||||
try JSONSerialization.jsonObject(with: XCTUnwrap(requests.first).body) as? [String: Any]
|
||||
)
|
||||
XCTAssertEqual((decoded["reports"] as? [[String: Any]])?.count, 3)
|
||||
}
|
||||
|
||||
func testAnalyticsOptOutStopsUploadInsteadOfMintingAnIdentifier() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.ok()])
|
||||
let uploader = makeUploader(network: network, installationID: nil)
|
||||
|
||||
let status = await uploader.uploadPendingReports()
|
||||
|
||||
// A tester who turned analytics off has said they do not want a stable
|
||||
// device id leaving the machine; that answer holds here too.
|
||||
XCTAssertEqual(status, .noInstallationIdentifier)
|
||||
let requests = await network.requests()
|
||||
XCTAssertTrue(requests.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Failure handling
|
||||
|
||||
func testMissingBackendRouteStopsRetrying() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.status(404), Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
|
||||
let first = await uploader.uploadPendingReports()
|
||||
XCTAssertEqual(first, .endpointUnavailable)
|
||||
|
||||
// The route is not deployed yet. Retrying every launch would be noise
|
||||
// in their logs and would never succeed on its own.
|
||||
let second = await uploader.uploadPendingReports()
|
||||
XCTAssertEqual(second, .endpointUnavailable)
|
||||
let requests = await network.requests()
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
}
|
||||
|
||||
func testResetDeliveryStateAllowsRetryAfterBackendLands() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.status(404), Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
_ = await uploader.uploadPendingReports()
|
||||
|
||||
await uploader.resetDeliveryState()
|
||||
let status = await uploader.uploadPendingReports()
|
||||
|
||||
XCTAssertEqual(status, .uploaded(count: 1))
|
||||
let requests = await network.requests()
|
||||
XCTAssertEqual(requests.count, 2)
|
||||
}
|
||||
|
||||
func testServerErrorSchedulesBackoffAndKeepsTheReport() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.status(503), Self.ok()])
|
||||
let now = Date(timeIntervalSince1970: 1_000_000)
|
||||
let uploader = makeUploader(network: network, now: { now })
|
||||
|
||||
guard case .retryScheduled(let pending, let after) =
|
||||
await uploader.uploadPendingReports() else {
|
||||
return XCTFail("expected a retry to be scheduled after a 503")
|
||||
}
|
||||
XCTAssertEqual(pending, 1)
|
||||
XCTAssertGreaterThan(after, now)
|
||||
|
||||
// Still inside the backoff window: no second request.
|
||||
_ = await uploader.uploadPendingReports()
|
||||
let requests = await network.requests()
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
}
|
||||
|
||||
func testBackoffExpiryLetsTheReportThrough() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.status(503), Self.ok()])
|
||||
nonisolated(unsafe) var clock = Date(timeIntervalSince1970: 1_000_000)
|
||||
let uploader = makeUploader(network: network, now: { clock })
|
||||
|
||||
_ = await uploader.uploadPendingReports()
|
||||
clock = clock.addingTimeInterval(3_600)
|
||||
let status = await uploader.uploadPendingReports()
|
||||
|
||||
XCTAssertEqual(status, .uploaded(count: 1))
|
||||
}
|
||||
|
||||
func testRejectedReportIsDroppedInsteadOfBlockingTheQueue() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport(kind: "crash"))
|
||||
// 422: the server understood the body and refused it. Resending the
|
||||
// identical bytes would fail identically and pin the queue forever.
|
||||
let network = AnalyticsQueueNetwork([Self.status(422), Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
|
||||
_ = await uploader.uploadPendingReports()
|
||||
let pending = await uploader.pendingReportCount()
|
||||
|
||||
XCTAssertEqual(pending, 0)
|
||||
let requests = await network.requests()
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
}
|
||||
|
||||
func testNetworkFailureIsTransientAndRetries() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([.urlError(.notConnectedToInternet), Self.ok()])
|
||||
nonisolated(unsafe) var clock = Date(timeIntervalSince1970: 1_000_000)
|
||||
let uploader = makeUploader(network: network, now: { clock })
|
||||
|
||||
guard case .retryScheduled = await uploader.uploadPendingReports() else {
|
||||
return XCTFail("a dropped connection must not discard the report")
|
||||
}
|
||||
clock = clock.addingTimeInterval(3_600)
|
||||
let status = await uploader.uploadPendingReports()
|
||||
|
||||
XCTAssertEqual(status, .uploaded(count: 1))
|
||||
}
|
||||
|
||||
func testPendingCountIgnoresAlreadyUploadedReports() async throws {
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
XCTAssertTrue(seedMetricKitReport())
|
||||
let network = AnalyticsQueueNetwork([Self.ok(), Self.ok()])
|
||||
let uploader = makeUploader(network: network)
|
||||
|
||||
let before = await uploader.pendingReportCount()
|
||||
XCTAssertEqual(before, 2)
|
||||
|
||||
_ = await uploader.uploadPendingReports()
|
||||
let after = await uploader.pendingReportCount()
|
||||
XCTAssertEqual(after, 0)
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,122 @@ final class KeyboardExtensionMemoryBudgetTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Covers the *acting* half of the memory guard. The budget thresholds above
|
||||
/// were always correct; what was missing was anything that used them. A
|
||||
/// keyboard extension gets no reliable system memory warning before jetsam,
|
||||
/// so these thresholds are the only chance to shed load before the keyboard
|
||||
/// is killed mid-sentence.
|
||||
@MainActor
|
||||
final class KeyboardExtensionMemoryReliefTests: XCTestCase {
|
||||
override func tearDown() {
|
||||
KeyboardExtensionMemoryTelemetry.reliefHandler = nil
|
||||
KeyboardExtensionMemoryTelemetry.stopSampling()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
private func armedLevels(
|
||||
accepting: Bool = true
|
||||
) -> (record: () -> [KeyboardExtensionMemoryBudget.Level], reset: () -> Void) {
|
||||
let box = LevelBox()
|
||||
KeyboardExtensionMemoryTelemetry.begin(context: "test")
|
||||
KeyboardExtensionMemoryTelemetry.stopSampling()
|
||||
KeyboardExtensionMemoryTelemetry.reliefHandler = { level in
|
||||
box.levels.append(level)
|
||||
return accepting
|
||||
}
|
||||
return ({ box.levels }, { box.levels.removeAll() })
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LevelBox {
|
||||
var levels: [KeyboardExtensionMemoryBudget.Level] = []
|
||||
}
|
||||
|
||||
func testNormalAndWarningFootprintsDoNotShed() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(20)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(37)
|
||||
XCTAssertEqual(levels(), [])
|
||||
}
|
||||
|
||||
func testHighFootprintRequestsSoftRelief() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41)
|
||||
XCTAssertEqual(levels(), [.high])
|
||||
}
|
||||
|
||||
func testSustainedPressureDoesNotReshedAtTheSameLevel() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
// Ten seconds of polling above the threshold. A timed cooldown would
|
||||
// have shed three more times by now; hysteresis sheds once.
|
||||
for _ in 0..<10 {
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
}
|
||||
XCTAssertEqual(levels(), [.critical])
|
||||
}
|
||||
|
||||
func testPressureMustRecedeBelowWarningBeforeSheddingAgain() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
// Landing back in the warning band is where a shed leaves the keyboard;
|
||||
// treating that as recovery would re-arm on the very next sample.
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(37)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
XCTAssertEqual(levels(), [.critical])
|
||||
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(22)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
XCTAssertEqual(levels(), [.critical, .critical])
|
||||
}
|
||||
|
||||
func testDeclinedReliefStaysArmedForTheNextPoll() {
|
||||
let (levels, _) = armedLevels(accepting: false)
|
||||
// The host declines while the user is mid-composition. Recording it as
|
||||
// a completed shed would leave the keyboard at 48 MiB, having released
|
||||
// nothing, until pressure receded on its own.
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
XCTAssertEqual(levels(), [.critical, .critical, .critical])
|
||||
}
|
||||
|
||||
func testRepeatedHighFootprintShedsOnce() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(42)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(43)
|
||||
// One shed per level per episode: re-tearing down the typing surface on
|
||||
// every 1 s poll would make the keyboard unusable, not safer.
|
||||
XCTAssertEqual(levels(), [.high])
|
||||
}
|
||||
|
||||
func testEscalationToCriticalShedsEvenAfterASoftShed() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
// The hard shed must not wait out a soft one — 49 MiB is ~11 MiB from
|
||||
// the observed jetsam boundary.
|
||||
XCTAssertEqual(levels(), [.high, .critical])
|
||||
}
|
||||
|
||||
func testCriticalDoesNotDownshiftBackToHigh() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41)
|
||||
XCTAssertEqual(levels(), [.critical])
|
||||
}
|
||||
|
||||
func testNewSessionCanShedAgain() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
|
||||
XCTAssertEqual(levels(), [.critical])
|
||||
// `begin` is called once per extension process; a reused process must
|
||||
// not inherit the previous presentation's armed state.
|
||||
KeyboardExtensionMemoryTelemetry.begin(context: "test-2")
|
||||
KeyboardExtensionMemoryTelemetry.stopSampling()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41)
|
||||
XCTAssertEqual(levels(), [.critical, .high])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// KeyboardInstallationProbeTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class KeyboardInstallationProbeTests: XCTestCase {
|
||||
func testMissingListReportsUnknownRatherThanDisabled() {
|
||||
XCTAssertNil(KeyboardInstallationProbe.isKeyboardEnabled(identifiers: nil))
|
||||
}
|
||||
|
||||
func testEnabledWhenListContainsExtensionBundleID() {
|
||||
XCTAssertEqual(
|
||||
KeyboardInstallationProbe.isKeyboardEnabled(
|
||||
identifiers: ["en_US@sw=QWERTY;hw=Automatic", "com.osgkeyboard.ios.keyboard"]
|
||||
),
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
func testDisabledWhenListOmitsExtensionBundleID() {
|
||||
XCTAssertEqual(
|
||||
KeyboardInstallationProbe.isKeyboardEnabled(
|
||||
identifiers: ["en_US@sw=QWERTY;hw=Automatic", "emoji@sw=Emoji"]
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyListReportsDisabled() {
|
||||
XCTAssertEqual(KeyboardInstallationProbe.isKeyboardEnabled(identifiers: []), false)
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ final class PolishStyleCorpusExportStoreTests: XCTestCase {
|
||||
let store = PolishStyleCorpusExportStore(
|
||||
directoryURL: temporaryDirectory(),
|
||||
now: { generatedAt },
|
||||
appVersion: { "2.0.3" },
|
||||
appVersion: { "2.1.0" },
|
||||
appBuild: { "94" }
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ final class PolishStyleCorpusExportStoreTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(export.schemaVersion, 1)
|
||||
XCTAssertEqual(export.generatedAt, generatedAt)
|
||||
XCTAssertEqual(export.appVersion, "2.0.3")
|
||||
XCTAssertEqual(export.appVersion, "2.1.0")
|
||||
XCTAssertEqual(export.appBuild, "94")
|
||||
XCTAssertEqual(export.effectiveCharacterCount, 4)
|
||||
XCTAssertEqual(export.requiredEffectiveCharacterCount, 2_500)
|
||||
@@ -77,7 +77,7 @@ final class PolishStyleCorpusExportStoreTests: XCTestCase {
|
||||
let store = PolishStyleCorpusExportStore(
|
||||
directoryURL: directory,
|
||||
now: { createdAt },
|
||||
appVersion: { "2.0.3" },
|
||||
appVersion: { "2.1.0" },
|
||||
appBuild: { "94" }
|
||||
)
|
||||
|
||||
|
||||
@@ -164,6 +164,101 @@ final class PolishStylePackTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Personal reply style split
|
||||
|
||||
static func learningMetadata(generatedAt: Date = Date()) -> PolishStylePack.LearningMetadata {
|
||||
PolishStylePack.LearningMetadata(
|
||||
schemaVersion: 2,
|
||||
evidenceStatus: "sufficient",
|
||||
confidence: 0.7,
|
||||
asrExampleCount: 8,
|
||||
asrEffectiveCharacterCount: 2_600,
|
||||
replyExampleCount: 3,
|
||||
replyFinalEditCount: 1,
|
||||
generatedAt: generatedAt
|
||||
)
|
||||
}
|
||||
|
||||
static func distilledPack(
|
||||
id: String = "user.learned",
|
||||
name: String = "我的风格",
|
||||
updatedAt: Date = Date()
|
||||
) -> PolishStylePack {
|
||||
PolishStylePack(
|
||||
id: id,
|
||||
name: name,
|
||||
prompt: "保留我的表达习惯",
|
||||
learningMetadata: learningMetadata(),
|
||||
createdAt: updatedAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
/// The hard gate: a distilled pack injected into voice polish would outrank
|
||||
/// the core T1/T2 cleanup and put the speaker's fillers back.
|
||||
func testDistilledPersonalStyleIsNotAValidVoicePolishSelection() throws {
|
||||
var catalog = PolishStyleCatalog()
|
||||
let distilled = Self.distilledPack()
|
||||
let handWritten = PolishStylePack(id: "user.handwritten", name: "手写", prompt: "长句")
|
||||
try catalog.upsert(distilled)
|
||||
try catalog.upsert(handWritten)
|
||||
|
||||
XCTAssertFalse(PolishStylePackCatalog.isValidActiveID(distilled.id, userCatalog: catalog))
|
||||
XCTAssertTrue(PolishStylePackCatalog.isValidActiveID(handWritten.id, userCatalog: catalog))
|
||||
XCTAssertTrue(
|
||||
PolishStylePackCatalog.isValidActiveID(
|
||||
PolishStylePackCatalog.defaultID,
|
||||
userCatalog: catalog
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPersonalReplyStyleResolvesOnlyDistilledPacks() throws {
|
||||
var catalog = PolishStyleCatalog()
|
||||
let distilled = Self.distilledPack()
|
||||
let handWritten = PolishStylePack(id: "user.handwritten", name: "手写", prompt: "长句")
|
||||
try catalog.upsert(distilled)
|
||||
try catalog.upsert(handWritten)
|
||||
|
||||
XCTAssertEqual(
|
||||
PolishStylePackCatalog.resolvePersonalReplyStyle(
|
||||
id: distilled.id,
|
||||
userCatalog: catalog
|
||||
)?.id,
|
||||
distilled.id
|
||||
)
|
||||
XCTAssertNil(
|
||||
PolishStylePackCatalog.resolvePersonalReplyStyle(
|
||||
id: handWritten.id,
|
||||
userCatalog: catalog
|
||||
)
|
||||
)
|
||||
// Empty means disabled, and a built-in must never drive replies.
|
||||
XCTAssertNil(PolishStylePackCatalog.resolvePersonalReplyStyle(id: "", userCatalog: catalog))
|
||||
XCTAssertNil(
|
||||
PolishStylePackCatalog.resolvePersonalReplyStyle(
|
||||
id: PolishStylePackCatalog.defaultID,
|
||||
userCatalog: catalog
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testLatestPersonalReplyStylePicksNewestDistilledPack() throws {
|
||||
var catalog = PolishStyleCatalog()
|
||||
let now = Date()
|
||||
try catalog.upsert(
|
||||
Self.distilledPack(id: "user.old", updatedAt: now.addingTimeInterval(-500))
|
||||
)
|
||||
try catalog.upsert(Self.distilledPack(id: "user.new", updatedAt: now))
|
||||
try catalog.upsert(PolishStylePack(id: "user.handwritten", name: "手写", prompt: "长句"))
|
||||
|
||||
XCTAssertEqual(
|
||||
PolishStylePackCatalog.latestPersonalReplyStyle(userCatalog: catalog)?.id,
|
||||
"user.new"
|
||||
)
|
||||
XCTAssertNil(PolishStylePackCatalog.latestPersonalReplyStyle(userCatalog: .empty))
|
||||
}
|
||||
|
||||
func testDeletionTombstonePreventsRemoteResurrection() {
|
||||
let now = Date()
|
||||
let pack = PolishStylePack(
|
||||
|
||||
@@ -70,6 +70,7 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
aiResponseLength: SyncedField(value: .medium, updatedAt: stampA, deviceID: deviceA),
|
||||
multipleReplyVariantsEnabled: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
|
||||
activePolishStyleId: SyncedField(value: "builtin.light", updatedAt: stampA, deviceID: deviceA),
|
||||
personalReplyStyleId: SyncedField(value: "user.a", updatedAt: stampA, deviceID: deviceA),
|
||||
llmThinkingEnabled: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA),
|
||||
flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA),
|
||||
flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA)
|
||||
@@ -94,6 +95,7 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
aiResponseLength: SyncedField(value: .detailed, updatedAt: stampB, deviceID: deviceB),
|
||||
multipleReplyVariantsEnabled: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
|
||||
activePolishStyleId: SyncedField(value: "builtin.formal", updatedAt: stampB, deviceID: deviceB),
|
||||
personalReplyStyleId: SyncedField(value: "user.b", updatedAt: stampB, deviceID: deviceB),
|
||||
llmThinkingEnabled: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB),
|
||||
flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB),
|
||||
flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB)
|
||||
@@ -108,6 +110,50 @@ final class SettingsCloudSyncTests: XCTestCase {
|
||||
XCTAssertEqual(merged.polishIntensity.value, .heavy)
|
||||
XCTAssertEqual(merged.aiResponseLength.value, .detailed)
|
||||
XCTAssertFalse(merged.multipleReplyVariantsEnabled.value)
|
||||
XCTAssertEqual(merged.personalReplyStyleId.value, "user.b")
|
||||
}
|
||||
|
||||
/// Devices still on the pre-split build omit the key entirely. Empty is the
|
||||
/// right default: the local one-shot migration owns adopting an existing
|
||||
/// personal style, not the sync layer.
|
||||
func testPreSplitPayloadDecodesWithPersonalReplyStyleDisabled() throws {
|
||||
let payload = SyncedAppSettingsV2.seeded(
|
||||
from: AppGroupConfiguration.load(fromAvailable: defaults),
|
||||
deviceID: deviceA,
|
||||
updatedAt: Date(timeIntervalSince1970: 100)
|
||||
)
|
||||
var object = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: JSONEncoder().encode(payload)) as? [String: Any]
|
||||
)
|
||||
object.removeValue(forKey: "personalReplyStyleId")
|
||||
let legacyData = try JSONSerialization.data(withJSONObject: object)
|
||||
|
||||
let decoded = try JSONDecoder().decode(SyncedAppSettingsV2.self, from: legacyData)
|
||||
|
||||
XCTAssertEqual(decoded.personalReplyStyleId.value, "")
|
||||
}
|
||||
|
||||
/// A pre-split device can still push a learned pack id as the voice style.
|
||||
/// `applying(to:)` writes straight into the configuration, so the gate has
|
||||
/// to be re-applied there or the disfluency bug returns through iCloud.
|
||||
func testAppliedRemoteVoiceStyleCannotBeADistilledPersonalStyle() throws {
|
||||
var configuration = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
let distilled = PolishStylePackTests.distilledPack()
|
||||
try configuration.polishStyleCatalog.upsert(distilled)
|
||||
|
||||
var payload = SyncedAppSettingsV2.seeded(
|
||||
from: configuration,
|
||||
deviceID: deviceA,
|
||||
updatedAt: Date(timeIntervalSince1970: 100)
|
||||
)
|
||||
payload.activePolishStyleId = SyncedField(
|
||||
value: distilled.id,
|
||||
updatedAt: Date(timeIntervalSince1970: 200),
|
||||
deviceID: deviceB
|
||||
)
|
||||
payload.applying(to: &configuration)
|
||||
|
||||
XCTAssertEqual(configuration.activePolishStyleId, PolishStylePackCatalog.defaultID)
|
||||
}
|
||||
|
||||
func testLegacyKeepAliveFieldDecodesButIsNotReencoded() throws {
|
||||
|
||||
Reference in New Issue
Block a user