test(ai): expand AI flow and clipboard semantic coverage

- Cover reply-variant decision paths, scene-detector abstention, and
  the merged reply center stance catalog.
- Lock in clipboard semantic analyzer thresholds, complaint-only
  suppression, and existing-skill routing.
- Add durable session, refresh, and managed-gateway reliability tests
  for the Apple account path.
- Verify personal-style prompt derivation, low-confidence ASR
  tendencies, and Polish style learning against real corpus evidence.
- Exercise the assistant keyboard and Polish styles UI flows in
  end-to-end UI tests.
This commit is contained in:
Rocky
2026-08-29 11:51:25 +08:00
parent 3c10d73d7f
commit b275b6b0d9
15 changed files with 1348 additions and 125 deletions
@@ -254,6 +254,21 @@ final class ManagedGatewayTests: XCTestCase {
XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue }) XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue })
} }
func testManagedTaskKindsResolveToMatchingCapabilities() {
XCTAssertEqual(
ManagedLLMClient.Capability.resolve(taskKind: .dictationPolish),
.polish
)
XCTAssertEqual(
ManagedLLMClient.Capability.resolve(taskKind: .customSkill),
.assistant
)
XCTAssertEqual(
ManagedLLMClient.Capability.resolve(taskKind: .agentPlanning),
.agent
)
}
func testManagedHotwordSerializesSourceAndCurrentInformationIntent() async throws { func testManagedHotwordSerializesSourceAndCurrentInformationIntent() async throws {
let now = Date(timeIntervalSince1970: 3_600) let now = Date(timeIntervalSince1970: 3_600)
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now)) let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
+114 -26
View File
@@ -82,7 +82,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID] [AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
) )
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID)) XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID))
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.acceptInvitationID)) XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.replyID))
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID)) XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID))
} }
@@ -113,10 +113,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.callPhoneID, AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID, AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID AIClipboardSkillCatalog.organizeListID
] ]
) )
@@ -149,10 +145,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.callPhoneID, AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID, AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID AIClipboardSkillCatalog.organizeListID
] ]
) )
@@ -183,10 +175,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.callPhoneID, AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID, AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID AIClipboardSkillCatalog.organizeListID
] ]
) )
@@ -218,11 +206,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
[ [
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.translateID, AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID AIClipboardSkillCatalog.organizeListID
] ]
) )
@@ -230,7 +214,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
defaults.integer( defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
), ),
9 11
) )
} }
@@ -256,10 +240,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID, AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID AIClipboardSkillCatalog.organizeListID
] ]
) )
@@ -298,7 +278,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
defaults.integer( defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
), ),
9 11
) )
} }
@@ -324,7 +304,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
defaults.integer( defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
), ),
9 11
) )
XCTAssertEqual( XCTAssertEqual(
AppGroupStore(defaults: defaults).agentSkillLayout.enabledIDs, AppGroupStore(defaults: defaults).agentSkillLayout.enabledIDs,
@@ -332,6 +312,84 @@ final class AIAgentSkillLayoutTests: XCTestCase {
) )
} }
func testVersionNineLayoutConsolidatesEmpathyReplyAndPersistsMigration() throws {
let defaults = makeDefaults()
let initial = AIAgentSkillLayout(
enabledIDs: [
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.translateID
],
confirmedShortcutIDs: []
)
defaults.set(
try JSONEncoder().encode(initial),
forKey: AppGroupConfiguration.Keys.agentSkillLayout
)
defaults.set(
9,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
let migrated = AppGroupStore(defaults: defaults).agentSkillLayout
XCTAssertEqual(
migrated.enabledIDs,
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
)
XCTAssertEqual(
defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
),
11
)
}
func testVersionTenLayoutConsolidatesDecisionRepliesWithoutRestoringDisabledReply() throws {
let enabledDefaults = makeDefaults()
let legacy = AIAgentSkillLayout(
enabledIDs: [
AIClipboardSkillCatalog.acceptInvitationID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.blessingReplyID
],
confirmedShortcutIDs: []
)
enabledDefaults.set(
try JSONEncoder().encode(legacy),
forKey: AppGroupConfiguration.Keys.agentSkillLayout
)
enabledDefaults.set(
10,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
XCTAssertEqual(
AppGroupStore(defaults: enabledDefaults).agentSkillLayout.enabledIDs,
[AIClipboardSkillCatalog.replyID]
)
let disabledDefaults = makeDefaults()
disabledDefaults.set(
try JSONEncoder().encode(
AIAgentSkillLayout(
enabledIDs: [AIClipboardSkillCatalog.translateID],
confirmedShortcutIDs: []
)
),
forKey: AppGroupConfiguration.Keys.agentSkillLayout
)
disabledDefaults.set(
10,
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
)
XCTAssertEqual(
AppGroupStore(defaults: disabledDefaults).agentSkillLayout.enabledIDs,
[AIClipboardSkillCatalog.translateID]
)
}
func testLegacyReplyLookupRemainsAvailableButCanonicalizesForLayout() { func testLegacyReplyLookupRemainsAvailableButCanonicalizesForLayout() {
XCTAssertNotNil( XCTAssertNotNil(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.playfulReplyID) AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.playfulReplyID)
@@ -339,6 +397,9 @@ final class AIAgentSkillLayoutTests: XCTestCase {
XCTAssertNotNil( XCTAssertNotNil(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.businessReplyID) AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.businessReplyID)
) )
XCTAssertNotNil(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.empathyReplyID)
)
XCTAssertEqual( XCTAssertEqual(
AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.playfulReplyID), AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.playfulReplyID),
AIClipboardSkillCatalog.replyID AIClipboardSkillCatalog.replyID
@@ -347,10 +408,37 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.businessReplyID), AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.businessReplyID),
AIClipboardSkillCatalog.replyID AIClipboardSkillCatalog.replyID
) )
XCTAssertEqual(
AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.empathyReplyID),
AIClipboardSkillCatalog.replyID
)
for id in [
AIClipboardSkillCatalog.acceptInvitationID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.askForDetailsID
] {
XCTAssertEqual(
AIClipboardSkillCatalog.canonicalID(for: id),
AIClipboardSkillCatalog.replyID
)
XCTAssertEqual(
AIClipboardSkillCatalog.skill(id: id)?.id,
AIClipboardSkillCatalog.replyID
)
}
XCTAssertFalse( XCTAssertFalse(
AIClipboardSkillCatalog.catalog.contains { AIClipboardSkillCatalog.catalog.contains {
$0.id == AIClipboardSkillCatalog.playfulReplyID $0.id == AIClipboardSkillCatalog.playfulReplyID
|| $0.id == AIClipboardSkillCatalog.businessReplyID || $0.id == AIClipboardSkillCatalog.businessReplyID
|| $0.id == AIClipboardSkillCatalog.empathyReplyID
|| $0.id == AIClipboardSkillCatalog.acceptInvitationID
|| $0.id == AIClipboardSkillCatalog.declineInvitationID
|| $0.id == AIClipboardSkillCatalog.acceptTaskID
|| $0.id == AIClipboardSkillCatalog.clarifyRequestID
|| $0.id == AIClipboardSkillCatalog.blessingReplyID
} }
) )
} }
@@ -401,6 +489,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
AIClipboardSkillCatalog.replyInSourceLanguageID, AIClipboardSkillCatalog.replyInSourceLanguageID,
AIClipboardSkillCatalog.playfulReplyID, AIClipboardSkillCatalog.playfulReplyID,
AIClipboardSkillCatalog.businessReplyID, AIClipboardSkillCatalog.businessReplyID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractConclusionsID, AIClipboardSkillCatalog.extractConclusionsID,
AIClipboardSkillCatalog.askForDetailsID AIClipboardSkillCatalog.askForDetailsID
@@ -412,8 +501,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
layout.enabledIDs, layout.enabledIDs,
[ [
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.summarizeID
AIClipboardSkillCatalog.clarifyRequestID
] ]
) )
} }
@@ -188,26 +188,27 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertTrue(prompt.contains("决定、结论和下一步")) XCTAssertTrue(prompt.contains("决定、结论和下一步"))
} }
func testSemanticReplySkillsHaveDistinctInstructions() { func testReplyScenesHaveDistinctInstructions() throws {
let ids = [ let skill = try XCTUnwrap(
AIClipboardSkillCatalog.playfulReplyID, AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
AIClipboardSkillCatalog.acceptInvitationID, )
AIClipboardSkillCatalog.declineInvitationID, let scenes: [AIClipboardReplyScene] = [
AIClipboardSkillCatalog.acceptTaskID, .invitation,
AIClipboardSkillCatalog.clarifyRequestID, .task,
AIClipboardSkillCatalog.empathyReplyID, .blessing,
AIClipboardSkillCatalog.businessReplyID, .clarification,
AIClipboardSkillCatalog.summarizeID, .complaint,
AIClipboardSkillCatalog.organizeListID .negativeQuestion
] ]
let prompts = ids.map { let prompts = scenes.map {
AIClipboardSkillCatalog.instruction( AIClipboardSkillCatalog.instruction(
skillID: $0, for: skill,
locale: "zh", locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyScene: $0
) )
} }
XCTAssertEqual(Set(prompts).count, ids.count) XCTAssertEqual(Set(prompts).count, scenes.count)
XCTAssertFalse(prompts.contains { $0.contains("用户选择的操作") }) XCTAssertFalse(prompts.contains { $0.contains("用户选择的操作") })
} }
@@ -250,6 +251,54 @@ final class AIClipboardSkillTests: XCTestCase {
XCTAssertTrue(instruction.contains("不能改变当前技能的意图")) XCTAssertTrue(instruction.contains("不能改变当前技能的意图"))
} }
func testReplyUsesComplaintSceneModifier() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
)
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyScene: .complaint
)
XCTAssertTrue(instruction.contains(#"<reply_scene type="complaint">"#))
XCTAssertTrue(instruction.contains("接住对方的情绪"))
XCTAssertTrue(instruction.contains("不虚构责任、进度或承诺"))
XCTAssertTrue(instruction.contains("不能开玩笑"))
}
func testReplyUsesLighterModifierForNegativeQuestion() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
)
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyScene: .negativeQuestion
)
XCTAssertTrue(instruction.contains(#"<reply_scene type="negative_question">"#))
XCTAssertTrue(instruction.contains("不要因为语气负面就默认用户有错"))
XCTAssertFalse(instruction.contains(#"<reply_scene type="complaint">"#))
}
func testLegacyDecisionSkillUsesUnifiedReplyScene() throws {
let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.acceptInvitationID)
)
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: "zh",
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
replyScene: .complaint
)
XCTAssertEqual(skill.id, AIClipboardSkillCatalog.replyID)
XCTAssertTrue(instruction.contains(#"<reply_scene type="complaint">"#))
}
func testReplyStyleIsNotInjectedIntoNonReplySkill() throws { func testReplyStyleIsNotInjectedIntoNonReplySkill() throws {
let skill = try XCTUnwrap( let skill = try XCTUnwrap(
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.summarizeID) AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.summarizeID)
+76 -2
View File
@@ -116,11 +116,11 @@ final class AIReplyVariantTests: XCTestCase {
func testReplyKindOwnsLocalPresentationMetadata() { func testReplyKindOwnsLocalPresentationMetadata() {
XCTAssertEqual( XCTAssertEqual(
AIReplyVariant.Kind.allCases.map(\.systemImage), AIReplyVariantSet.generic.kinds.map(\.systemImage),
["bubble.left.fill", "briefcase.fill", "theatermasks.fill"] ["bubble.left.fill", "briefcase.fill", "theatermasks.fill"]
) )
XCTAssertEqual( XCTAssertEqual(
AIReplyVariant.Kind.allCases.map(\.titleKey), AIReplyVariantSet.generic.kinds.map(\.titleKey),
[ [
"keyboard.ai.replyVariant.ordinary", "keyboard.ai.replyVariant.ordinary",
"keyboard.ai.replyVariant.formal", "keyboard.ai.replyVariant.formal",
@@ -129,6 +129,80 @@ final class AIReplyVariantTests: XCTestCase {
) )
} }
func testInvitationParserRequiresExactSceneRoles() throws {
let valid = """
{"variants":[
{"kind":"invitationTentative","emotion":"neutral","text":""},
{"kind":"invitationAccept","emotion":"warm","text":""},
{"kind":"invitationDecline","emotion":"grateful","text":""}
]}
"""
let variants = try XCTUnwrap(
AIReplyVariantParser.parse(
valid,
variantSet: .invitation
)
)
XCTAssertEqual(variants.map(\.kind), AIReplyVariantSet.invitation.kinds)
XCTAssertNil(AIReplyVariantParser.parse(valid, variantSet: .generic))
XCTAssertNil(
AIReplyVariantParser.parseOrFallback(
"好呀,到时见。",
variantSet: .invitation
)
)
}
func testIntentScenesAlwaysGenerateChoicesWhenPreferenceIsOff() {
for scene in [
AIClipboardReplyScene.invitation,
.task,
.blessing,
.clarification
] {
XCTAssertTrue(
AIReplyVariantSet.shouldGenerate(
multipleRepliesEnabled: false,
scene: scene
)
)
}
XCTAssertFalse(
AIReplyVariantSet.shouldGenerate(
multipleRepliesEnabled: false,
scene: .complaint
)
)
XCTAssertFalse(
AIReplyVariantSet.shouldGenerate(
multipleRepliesEnabled: false,
scene: nil
)
)
}
func testIntentChoicePresentationUsesFixedLocalMeaning() {
XCTAssertEqual(
AIReplyVariantSet.task.kinds.map(\.titleKey),
[
"keyboard.ai.replyVariant.taskAcknowledge",
"keyboard.ai.replyVariant.taskClarify",
"keyboard.ai.replyVariant.taskNegotiate"
]
)
XCTAssertEqual(
AIReplyVariantSet.invitation.kinds.map(\.systemImage),
["checkmark.circle.fill", "hand.raised.fill", "clock.fill"]
)
XCTAssertTrue(AIReplyVariantSet.generic.kinds.allSatisfy(\.usesEmotionIcon))
XCTAssertTrue(
AIReplyVariantSet.invitation.kinds.allSatisfy {
!$0.usesEmotionIcon
}
)
}
func testEmotionMapsOnlyToLocalSFSymbolAllowlist() { func testEmotionMapsOnlyToLocalSFSymbolAllowlist() {
XCTAssertEqual( XCTAssertEqual(
AIReplyVariant.Emotion.celebratory.systemImage(fallback: .ordinary), AIReplyVariant.Emotion.celebratory.systemImage(fallback: .ordinary),
@@ -223,6 +223,39 @@ final class AISessionStateTests: XCTestCase {
XCTAssertNil(state.selectedReplyVariant) XCTAssertNil(state.selectedReplyVariant)
} }
func testSceneReplyVariantsUseLocalRoleOrder() {
var state = AISessionState()
let utteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: utteranceID)
state.receiveReplyVariants(
[
AIReplyVariant(
kind: .invitationTentative,
emotion: .neutral,
text: "我确认一下。"
),
AIReplyVariant(
kind: .invitationDecline,
emotion: .grateful,
text: "这次先不去了。"
),
AIReplyVariant(
kind: .invitationAccept,
emotion: .warm,
text: "好呀,到时见。"
)
],
utteranceID: utteranceID
)
XCTAssertTrue(state.canSelectReplyVariant)
XCTAssertEqual(
state.replyVariants.map(\.kind),
AIReplyVariantSet.invitation.kinds
)
}
private func makeReplyVariants() -> [AIReplyVariant] { private func makeReplyVariants() -> [AIReplyVariant] {
[ [
AIReplyVariant(kind: .ordinary, emotion: .warm, text: "普通回复"), AIReplyVariant(kind: .ordinary, emotion: .warm, text: "普通回复"),
@@ -241,6 +241,37 @@ final class ClipboardReplyFeedbackStoreTests: XCTestCase {
XCTAssertNil(example.playfulCandidate) XCTAssertNil(example.playfulCandidate)
} }
func testSceneDecisionTeachesStyleOnlyAfterUserFinalEdit() throws {
let candidate = ClipboardReplyCandidateSnapshot(
kind: .invitationDecline,
text: "谢谢邀请,不过这次先不去了。",
emotion: "grateful"
)
let recordID = try XCTUnwrap(
store.begin(
sourceText: "周六一起吃饭吗?",
candidates: [candidate],
styleID: "user.personal"
)
)
store.recordSelection(
recordID: recordID,
candidateID: candidate.id,
answerID: candidate.id
)
XCTAssertTrue(store.learningExamples().isEmpty)
store.recordFinalEdit(
answerID: candidate.id,
text: "这周六不行,下次约~",
revision: 1
)
let example = try XCTUnwrap(store.learningExamples().first)
XCTAssertEqual(example.selection, .contextual)
XCTAssertEqual(example.finalEdit, "这周六不行,下次约~")
}
private func makeCandidates( private func makeCandidates(
suffix: String = "" suffix: String = ""
) -> [ClipboardReplyCandidateSnapshot] { ) -> [ClipboardReplyCandidateSnapshot] {
@@ -19,6 +19,43 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
XCTAssertFalse(analysis.confirmationDecision.isDetected) XCTAssertFalse(analysis.confirmationDecision.isDetected)
XCTAssertFalse(analysis.followUpReminder.isDetected) XCTAssertFalse(analysis.followUpReminder.isDetected)
XCTAssertFalse(analysis.blessing.isDetected) XCTAssertFalse(analysis.blessing.isDetected)
XCTAssertFalse(analysis.assistantCommand.isDetected)
XCTAssertFalse(analysis.informationQuery.isDetected)
XCTAssertFalse(analysis.systemNotification.isDetected)
XCTAssertNil(analysis.domain)
XCTAssertNil(analysis.domainConfidence)
}
func testCurrentManifestWithoutExtendedClassifiersFailsClosed() async {
let analysis = await ClipboardSemanticAnalyzer().analyze(
"What is the weather in Shanghai tomorrow?"
)
XCTAssertEqual(analysis.assistantCommand, .notDetected)
XCTAssertEqual(analysis.informationQuery, .notDetected)
XCTAssertEqual(analysis.systemNotification, .notDetected)
XCTAssertNil(analysis.domain)
XCTAssertNil(analysis.domainConfidence)
}
func testPublicDomainTaxonomyContainsTwelveStableLabels() {
XCTAssertEqual(
ClipboardSemanticDomain.allCases.map(\.rawValue),
[
"finance",
"travel",
"calendar",
"communication",
"media",
"smartHome",
"shopping",
"dining",
"health",
"weather",
"accountService",
"generalKnowledge"
]
)
} }
func testComplaintTaskPolicySuppressesImplicitFailure() { func testComplaintTaskPolicySuppressesImplicitFailure() {
@@ -48,6 +85,88 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
) )
} }
func testBlessingRoutingAcceptsBroadExplicitWishes() {
let samples = [
"一路平安,到了记得报个平安。",
"祝愿她早日康复,重新回到喜欢的生活。",
"恭喜顺利毕业,前程似锦!",
"端午安康,愿大家平安喜乐。",
"Safe travels and all the best for the new chapter.",
"Get well soon. We are all thinking of you.",
"Happy anniversary! May your days stay full of love."
]
for text in samples {
let result = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0),
text: text
)
XCTAssertTrue(result.isDetected, "Missed broad blessing: \(text)")
XCTAssertTrue(result.isApprovedForAutomaticRouting)
}
}
func testBlessingRoutingRejectsBoundaryContexts() {
let samples = [
"谢谢大家发来的生日祝福。",
"帮我写一段适合春节发微信的祝福语。",
"文档里引用了“祝你生日快乐”这句话。",
"他们说晚点会祝你生日快乐。",
"我们应该找个时间庆祝项目上线。",
"The article quotes the phrase “wishing you good health.”",
"Thanks for all the wonderful birthday wishes.",
"Thanks for the birthday wish you sent yesterday.",
"Find me a message template that says happy birthday."
]
for text in samples {
let result = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0.999),
text: text
)
XCTAssertFalse(result.isDetected, "Boundary routed as blessing: \(text)")
XCTAssertTrue(
ClipboardSemanticAnalyzer.isRejectedBlessingContext(in: text),
"Boundary was not rejected: \(text)"
)
}
}
func testBlessingRoutingAllowsReciprocalWish() {
let samples = [
"谢谢你的祝福,也祝你新年快乐、万事如意!",
"Thanks for the kind wishes. Wishing you a wonderful year too!"
]
for text in samples {
let result = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0),
text: text
)
XCTAssertTrue(result.isDetected, "Reciprocal wish was rejected: \(text)")
}
}
func testBlessingRoutingUsesStrictModelFallback() {
let implicitWish = "希望接下来的日子都有温暖和惊喜。"
let highConfidence = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0.99),
text: implicitWish
)
let lowerConfidence = ClipboardSemanticAnalyzer.adjustedBlessingLabel(
blessingCandidate(confidence: 0.97),
text: implicitWish
)
XCTAssertTrue(highConfidence.isDetected)
XCTAssertEqual(highConfidence.threshold, 0.98)
XCTAssertFalse(lowerConfidence.isDetected)
XCTAssertEqual(lowerConfidence.threshold, 0.98)
}
func testImplicitComplaintDoesNotRouteAsTask() async { func testImplicitComplaintDoesNotRouteAsTask() async {
let analysis = await ClipboardSemanticAnalyzer().analyze( let analysis = await ClipboardSemanticAnalyzer().analyze(
""" """
@@ -364,6 +483,15 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
label.confidence > 0 && label.confidence >= label.threshold label.confidence > 0 && label.confidence >= label.threshold
} }
private func blessingCandidate(confidence: Double) -> ClipboardIntentLabel {
ClipboardIntentLabel(
confidence: confidence,
threshold: 0.77,
isDetected: confidence >= 0.77,
isApprovedForAutomaticRouting: true
)
}
private func milliseconds(from duration: Duration) -> Double { private func milliseconds(from duration: Duration) -> Double {
let components = duration.components let components = duration.components
return Double(components.seconds) * 1_000 return Double(components.seconds) * 1_000
@@ -12,11 +12,10 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
) )
XCTAssertEqual( XCTAssertEqual(
Array(ranked.prefix(3)), Array(ranked.prefix(2)),
[ [
AIClipboardSkillCatalog.translateID, AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID
AIClipboardSkillCatalog.clarifyRequestID
] ]
) )
} }
@@ -54,7 +53,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
) )
} }
func testInvitationWithDatePromotesCalendarAndBothReplyChoices() { func testInvitationWithDatePromotesUnifiedReplyAndCalendar() {
let ranked = rank( let ranked = rank(
text: "今晚七点老地方吃饭,你能来吗?", text: "今晚七点老地方吃饭,你能来吗?",
analysis: analysis( analysis: analysis(
@@ -64,15 +63,16 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
) )
) )
XCTAssertEqual(ranked.first, AIClipboardSkillCatalog.extractEventsID) XCTAssertEqual(ranked.first, AIClipboardSkillCatalog.replyID)
XCTAssertLessThan( XCTAssertLessThan(
tryIndex(AIClipboardSkillCatalog.acceptInvitationID, in: ranked), tryIndex(AIClipboardSkillCatalog.extractEventsID, in: ranked),
tryIndex(AIClipboardSkillCatalog.summarizeID, in: ranked)
)
XCTAssertLessThan(
tryIndex(AIClipboardSkillCatalog.declineInvitationID, in: ranked),
tryIndex(AIClipboardSkillCatalog.summarizeID, in: ranked) tryIndex(AIClipboardSkillCatalog.summarizeID, in: ranked)
) )
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis(
hasDate: true,
question: detected(),
invitation: detected()
)), .invitation)
} }
func testAddressPromotesNavigation() { func testAddressPromotesNavigation() {
@@ -215,10 +215,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
tryIndex(AIClipboardSkillCatalog.organizeListID, in: ranked), tryIndex(AIClipboardSkillCatalog.organizeListID, in: ranked),
tryIndex(AIClipboardSkillCatalog.replyID, in: ranked) tryIndex(AIClipboardSkillCatalog.replyID, in: ranked)
) )
XCTAssertLessThan(
tryIndex(AIClipboardSkillCatalog.acceptTaskID, in: ranked),
tryIndex(AIClipboardSkillCatalog.replyID, in: ranked)
)
} }
func testUnapprovedComplaintFallsBackToGenericReply() { func testUnapprovedComplaintFallsBackToGenericReply() {
@@ -242,6 +238,57 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID]) XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
} }
func testApprovedComplaintMergesEmpathyIntoGenericReply() {
let analysis = analysis(
sentiment: .negative,
complaint: detected()
)
let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog,
sourceText: "这个问题已经发生三次了,到底什么时候能解决?",
analysis: analysis,
uiLanguage: .chinese,
limit: 5
).map(\.id)
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.empathyReplyID))
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis), .complaint)
}
func testNegativeQuestionUsesLighterReplySceneWithoutEmpathyChip() {
let analysis = analysis(
sentiment: .negative,
question: detected()
)
let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog,
sourceText: "为什么到现在还没有发给我?",
analysis: analysis,
uiLanguage: .chinese,
limit: 5
).map(\.id)
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.empathyReplyID))
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis), .negativeQuestion)
}
func testUnapprovedNegativeSignalsDoNotCreateReplyScene() {
let unapprovedQuestion = ClipboardIntentLabel(
confidence: 0.82,
threshold: 0.6,
isDetected: true,
isApprovedForAutomaticRouting: false
)
let analysis = analysis(
sentiment: .negative,
question: unapprovedQuestion
)
XCTAssertNil(AIClipboardReplyScene.resolve(from: analysis))
}
func testLongTextPromotesIntegratedSummaryAndNotes() { func testLongTextPromotesIntegratedSummaryAndNotes() {
let ranked = rank( let ranked = rank(
text: String(repeating: "这是需要阅读和整理的长文内容。", count: 40), text: String(repeating: "这是需要阅读和整理的长文内容。", count: 40),
@@ -320,7 +367,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID]) XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
} }
func testInvitationKeepsSpecificRepliesAndGenericReply() { func testInvitationUsesOneReplyCenterAndKeepsCalendarAction() {
let recommendations = ClipboardSkillSemanticRanker.recommended( let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog, skills: AIClipboardSkillCatalog.catalog,
sourceText: "今晚七点老地方吃饭,你能来吗?", sourceText: "今晚七点老地方吃饭,你能来吗?",
@@ -337,15 +384,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.extractEventsID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.acceptInvitationID, AIClipboardSkillCatalog.extractEventsID
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.replyID
] ]
) )
} }
func testForeignQuestionKeepsReplyAndOneSpecializedFollowUp() { func testForeignQuestionKeepsTranslationAndUnifiedReply() {
let recommendations = ClipboardSkillSemanticRanker.recommended( let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog, skills: AIClipboardSkillCatalog.catalog,
sourceText: "Could you send the final proposal by Friday?", sourceText: "Could you send the final proposal by Friday?",
@@ -360,13 +405,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
) )
let replyCount = recommendations.filter(\.supportsReplyStyle).count let replyCount = recommendations.filter(\.supportsReplyStyle).count
XCTAssertLessThanOrEqual(replyCount, 2) XCTAssertEqual(replyCount, 1)
XCTAssertEqual( XCTAssertEqual(
recommendations.map(\.id), recommendations.map(\.id),
[ [
AIClipboardSkillCatalog.translateID, AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID
AIClipboardSkillCatalog.clarifyRequestID
] ]
) )
} }
@@ -380,7 +424,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractEventsID AIClipboardSkillCatalog.extractEventsID
] ]
@@ -400,7 +443,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.extractEventsID, AIClipboardSkillCatalog.extractEventsID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID AIClipboardSkillCatalog.replyID
] ]
) )
@@ -412,13 +454,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
analysis: analysis(confirmationDecision: detected()) analysis: analysis(confirmationDecision: detected())
) )
XCTAssertEqual( XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
recommendations,
[
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.replyID
]
)
} }
func testFollowUpReminderMapsToExistingSkills() { func testFollowUpReminderMapsToExistingSkills() {
@@ -431,14 +467,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.extractTodosID, AIClipboardSkillCatalog.extractTodosID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID AIClipboardSkillCatalog.replyID
] ]
) )
} }
func testBlessingMapsToDedicatedReplyAndGenericFallback() { func testBlessingMapsToUnifiedReplyCenter() {
let recommendations = recommended( let recommendations = recommended(
text: "大家一起祝王老师生日快乐、身体健康!", text: "大家一起祝王老师生日快乐、身体健康!",
analysis: analysis( analysis: analysis(
@@ -447,13 +481,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
) )
) )
XCTAssertEqual( XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
recommendations, XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis(
[ replyableMessage: detected(),
AIClipboardSkillCatalog.blessingReplyID, blessing: detected()
AIClipboardSkillCatalog.replyID )), .blessing)
]
)
} }
func testNewIntentConflictKeepsTopFiveAndGenericReply() { func testNewIntentConflictKeepsTopFiveAndGenericReply() {
@@ -472,13 +504,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.extractEventsID, AIClipboardSkillCatalog.extractEventsID,
AIClipboardSkillCatalog.extractTodosID AIClipboardSkillCatalog.extractTodosID
] ]
) )
XCTAssertEqual(recommendations.count, 5) XCTAssertEqual(recommendations.count, 3)
} }
func testSpecializedNewIntentSuppressesGenericReplyableBoost() { func testSpecializedNewIntentSuppressesGenericReplyableBoost() {
@@ -494,13 +524,82 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractEventsID AIClipboardSkillCatalog.extractEventsID
] ]
) )
} }
func testDisplayOnlyIntentsSuppressLegacyInterpersonalRoutes() {
let samples: [(String, ClipboardSemanticAnalysis)] = [
(
"Turn off the living room lights.",
analysis(
task: detected(),
replyableMessage: detected(),
assistantCommand: displayDetected()
)
),
(
"What is the weather tomorrow?",
analysis(
question: detected(),
replyableMessage: detected(),
informationQuery: displayDetected()
)
),
(
"Your package has shipped.",
analysis(
replyableMessage: detected(),
followUpReminder: detected(),
systemNotification: displayDetected()
)
)
]
for sample in samples {
XCTAssertEqual(
recommended(text: sample.0, analysis: sample.1),
[],
"Display-only intent leaked into a legacy route: \(sample.0)"
)
}
}
func testBelowThresholdDisplayIntentKeepsReplyFallback() {
let weakQuery = ClipboardIntentLabel(
confidence: 0.55,
threshold: 0.8,
isDetected: false,
isApprovedForAutomaticRouting: false
)
XCTAssertEqual(
recommended(
text: "Possibly a query",
analysis: analysis(informationQuery: weakQuery)
),
[AIClipboardSkillCatalog.replyID]
)
}
func testDomainAloneDoesNotReorderOrTriggerReply() {
let baseline = [
AIClipboardSkillCatalog.businessReplyID,
AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.summarizeID
]
let ranked = ClipboardSkillSemanticRanker.ranked(
skills: skills(ids: baseline),
sourceText: "Account update",
analysis: analysis(domain: .accountService),
uiLanguage: .chinese
).map(\.id)
XCTAssertEqual(ranked, baseline)
}
func testAnalyzerToRecommendationsForNewIntents() async { func testAnalyzerToRecommendationsForNewIntents() async {
let analyzer = ClipboardSemanticAnalyzer() let analyzer = ClipboardSemanticAnalyzer()
let samples: [ let samples: [
@@ -514,7 +613,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
"We need to reschedule the review. Is Tuesday or Thursday better?", "We need to reschedule the review. Is Tuesday or Thursday better?",
\.scheduleNegotiation, \.scheduleNegotiation,
[ [
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.extractEventsID AIClipboardSkillCatalog.extractEventsID
] ]
@@ -523,7 +621,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
"I approve the revised proposal; proceed with this version.", "I approve the revised proposal; proceed with this version.",
\.confirmationDecision, \.confirmationDecision,
[ [
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.replyID AIClipboardSkillCatalog.replyID
] ]
), ),
@@ -532,8 +629,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
\.followUpReminder, \.followUpReminder,
[ [
AIClipboardSkillCatalog.extractTodosID, AIClipboardSkillCatalog.extractTodosID,
AIClipboardSkillCatalog.acceptTaskID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.replyID AIClipboardSkillCatalog.replyID
] ]
) )
@@ -673,6 +768,15 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
) )
} }
private func displayDetected() -> ClipboardIntentLabel {
ClipboardIntentLabel(
confidence: 0.95,
threshold: 0.8,
isDetected: true,
isApprovedForAutomaticRouting: false
)
}
private func isThresholdCrossing(_ label: ClipboardIntentLabel) -> Bool { private func isThresholdCrossing(_ label: ClipboardIntentLabel) -> Bool {
label.confidence > 0 && label.confidence >= label.threshold label.confidence > 0 && label.confidence >= label.threshold
} }
@@ -692,7 +796,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
scheduleNegotiation: ClipboardIntentLabel? = nil, scheduleNegotiation: ClipboardIntentLabel? = nil,
confirmationDecision: ClipboardIntentLabel? = nil, confirmationDecision: ClipboardIntentLabel? = nil,
followUpReminder: ClipboardIntentLabel? = nil, followUpReminder: ClipboardIntentLabel? = nil,
blessing: ClipboardIntentLabel? = nil blessing: ClipboardIntentLabel? = nil,
assistantCommand: ClipboardIntentLabel? = nil,
informationQuery: ClipboardIntentLabel? = nil,
systemNotification: ClipboardIntentLabel? = nil,
domain: ClipboardSemanticDomain? = nil
) -> ClipboardSemanticAnalysis { ) -> ClipboardSemanticAnalysis {
ClipboardSemanticAnalysis( ClipboardSemanticAnalysis(
language: language.map { language: language.map {
@@ -725,7 +833,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
followUpReminder: followUpReminder ?? absent(), followUpReminder: followUpReminder ?? absent(),
blessing: blessing ?? absent(), blessing: blessing ?? absent(),
actionVerifier: nil, actionVerifier: nil,
coordinationVerifier: nil coordinationVerifier: nil,
assistantCommand: assistantCommand ?? absent(),
informationQuery: informationQuery ?? absent(),
systemNotification: systemNotification ?? absent(),
domain: domain,
domainConfidence: domain == nil ? nil : 0.9
) )
} }
+20 -1
View File
@@ -177,12 +177,31 @@ final class FlowReliabilityTests: XCTestCase {
} }
XCTFail("Expected timeout") XCTFail("Expected timeout")
} catch { } catch {
XCTAssertTrue(error is CancellationError) XCTAssertEqual(error as? HardTimeoutError, .timedOut)
} }
XCTAssertLessThan(Date().timeIntervalSince(started), 0.2) XCTAssertLessThan(Date().timeIntervalSince(started), 0.2)
} }
func testHardTimeoutPreservesCallerCancellation() async {
let task = Task {
try await HardTimeout.run(seconds: 5) {
try await Task.sleep(nanoseconds: 5_000_000_000)
return "late"
}
}
await Task.yield()
task.cancel()
do {
_ = try await task.value
XCTFail("Expected caller cancellation")
} catch {
XCTAssertTrue(error is CancellationError, "Unexpected error: \(error)")
XCTAssertNil(error as? HardTimeoutError)
}
}
func testAudioRoutePolicyRebuildsHFPTransitions() { func testAudioRoutePolicyRebuildsHFPTransitions() {
XCTAssertTrue( XCTAssertTrue(
FlowAudioRouteRecoveryPolicy.shouldRebuild( FlowAudioRouteRecoveryPolicy.shouldRebuild(
+108 -3
View File
@@ -114,6 +114,24 @@ final class IntelligentPolishTests: XCTestCase {
XCTAssertFalse(captured.lastPrompt.contains("趣味风格共享格式化")) XCTAssertFalse(captured.lastPrompt.contains("趣味风格共享格式化"))
} }
func testCallerSuppliedOptionsReachLLMClientUnchanged() async throws {
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
let options = LLMGenerationOptions(
temperature: 0.42,
topP: 0.73,
maxTokens: 777
)
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence.",
options: options
)
XCTAssertEqual(captured.lastOptions, options)
}
func testPersonalDictionaryUpsertManual() { func testPersonalDictionaryUpsertManual() {
var dict = PersonalDictionary.empty var dict = PersonalDictionary.empty
let entry = dict.upsertManual(term: "Kubernetes") let entry = dict.upsertManual(term: "Kubernetes")
@@ -315,14 +333,71 @@ final class IntelligentPolishTests: XCTestCase {
) )
} }
func testPolishServiceCapsTimeoutAt120() async throws { func testPolishServiceDefaultsToKeyboardTimeoutCap() async throws {
store.setEngineMode("local") store.setEngineMode("local")
let captured = CapturingLLMClient() let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured, timeout: 15) let service = PolishingService(store: store, client: captured)
let veryLong = String(repeating: "测试", count: 2000) let veryLong = String(repeating: "测试", count: 2000)
_ = try await service.polish(veryLong, context: PolishContext()) _ = try await service.polish(veryLong, context: PolishContext())
let passedTimeout = try XCTUnwrap(captured.lastTimeout) let passedTimeout = try XCTUnwrap(captured.lastTimeout)
XCTAssertLessThanOrEqual(passedTimeout, 120) XCTAssertEqual(passedTimeout, FlowSessionKeys.maxPolishTimeout)
}
func testPolishServiceAllowsExplicit45SecondTimeoutCap() async throws {
let captured = CapturingLLMClient()
let service = PolishingService(
store: store,
client: captured,
timeout: 45,
maximumTimeout: 45
)
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
XCTAssertEqual(captured.lastTimeout, 45)
}
func testPolishServicePropagatesCallerCancellation() async {
let gate = LLMRequestGate()
let service = PolishingService(
store: store,
client: SuspendingLLMClient(gate: gate)
)
let task = Task {
try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
}
await gate.waitUntilStarted()
task.cancel()
do {
_ = try await task.value
XCTFail("Expected caller cancellation")
} catch {
XCTAssertTrue(error is CancellationError, "Unexpected error: \(error)")
}
}
func testPolishServicePreservesProviderCancellationError() async {
let service = PolishingService(store: store, client: ThrowingLLMClient())
do {
_ = try await service.polish(
"分析这些风格学习样本",
systemPrompt: "Return structured evidence."
)
XCTFail("Expected provider cancellation")
} catch let error as LLMError {
XCTAssertEqual(error, .cancelled)
} catch {
XCTFail("Expected LLMError.cancelled, got \(error)")
}
} }
func testPolishServiceUsesChineseForChineseProviders() async throws { func testPolishServiceUsesChineseForChineseProviders() async throws {
@@ -779,6 +854,36 @@ private final class ThrowingLLMClient: LLMClient, @unchecked Sendable {
} }
} }
private actor LLMRequestGate {
private var started = false
private var waiters: [CheckedContinuation<Void, Never>] = []
func markStarted() {
started = true
let pending = waiters
waiters.removeAll()
pending.forEach { $0.resume() }
}
func waitUntilStarted() async {
if started { return }
await withCheckedContinuation { continuation in
waiters.append(continuation)
}
}
}
private struct SuspendingLLMClient: LLMClient {
let gate: LLMRequestGate
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
await gate.markStarted()
try await Task.sleep(nanoseconds: 10_000_000_000)
return text
}
}
private final class FixedResponseLLMClient: LLMClient, @unchecked Sendable { private final class FixedResponseLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15 let requestTimeout: TimeInterval = 15
private let response: String private let response: String
+79
View File
@@ -350,6 +350,85 @@ final class LLMClientTests: XCTestCase {
} }
} }
func testAppGroupStoreUsesSelectedCredentialChannelForStyleLearning() {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(
CredentialSource.managed.rawValue,
forKey: AppGroupConfiguration.Keys.credentialSource
)
let managedStore = AppGroupStore(defaults: defaults)
let managedClient = managedStore.makeClient(taskKind: .customSkill)
XCTAssertEqual(
(managedClient as? ManagedLLMClient)?.capability,
.assistant
)
defaults.set(
CredentialSource.byok.rawValue,
forKey: AppGroupConfiguration.Keys.credentialSource
)
let byokStore = AppGroupStore(defaults: defaults)
XCTAssertFalse(
byokStore.makeClient(taskKind: .customSkill) is ManagedLLMClient
)
}
func testLiveConfigurationStoreSnapshotsAnyConfigurationStore() throws {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let config = ProviderConfig(defaults: defaults)
config.providerId = "openai"
config.baseURL = "https://snapshot.example/v1"
config.apiKey = "snapshot-key"
config.model = "snapshot-model"
config.credentialSource = .managed
config.polishIntensity = .heavy
config.llmThinkingEnabled = true
let source = AppGroupStore(defaults: defaults)
var catalog = PolishStyleCatalog()
let style = PolishStylePack(
id: "user.snapshot-config",
name: "Snapshot",
prompt: "Keep this style fixed."
)
try catalog.upsert(style)
source.setPolishStyleCatalog(catalog)
source.setActivePolishStyleId(style.id)
let captured = LiveConfigurationStore(store: source as any ConfigurationStore)
config.providerId = "deepseek"
config.baseURL = "https://changed.example/v1"
config.apiKey = "changed-key"
config.model = "changed-model"
config.credentialSource = .byok
config.polishIntensity = .light
config.llmThinkingEnabled = false
source.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
XCTAssertEqual(captured.providerId, "openai")
XCTAssertEqual(captured.baseURL, "https://snapshot.example/v1")
XCTAssertEqual(captured.apiKey, "snapshot-key")
XCTAssertEqual(captured.model, "snapshot-model")
XCTAssertEqual(captured.credentialSource, .managed)
XCTAssertEqual(captured.polishIntensity, .heavy)
XCTAssertTrue(captured.llmThinkingEnabled)
XCTAssertEqual(captured.activePolishStyleId, style.id)
let capturedStyle = try XCTUnwrap(
captured.polishStyleCatalog.entries.first(where: { $0.id == style.id })
)
XCTAssertEqual(capturedStyle.name, style.name)
XCTAssertEqual(capturedStyle.prompt, style.prompt)
}
// MARK: - TEST-2: cloud always polishes (legacy modeId ignored) // MARK: - TEST-2: cloud always polishes (legacy modeId ignored)
/// Cloud engine must invoke the LLM even when a legacy `modeId == "off"` /// Cloud engine must invoke the LLM even when a legacy `modeId == "off"`
@@ -138,10 +138,6 @@ final class OfficialSkillCatalogTests: XCTestCase {
AIClipboardSkillCatalog.callPhoneID, AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID, AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.blessingReplyID,
AIClipboardSkillCatalog.organizeListID, AIClipboardSkillCatalog.organizeListID,
"official.rewrite" "official.rewrite"
] ]
@@ -215,6 +215,8 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertFalse(extractor.text.contains("这个 Prompt 后来已经被编辑")) XCTAssertFalse(extractor.text.contains("这个 Prompt 后来已经被编辑"))
XCTAssertTrue(extractor.text.contains(String(source.prefix(100)))) XCTAssertTrue(extractor.text.contains(String(source.prefix(100))))
XCTAssertTrue(extractor.text.contains(#""userEdited":true"#)) XCTAssertTrue(extractor.text.contains(#""userEdited":true"#))
XCTAssertTrue(extractor.text.contains(#""residualBaseline""#))
XCTAssertTrue(extractor.text.contains(#""id":"builtin.chat""#))
XCTAssertTrue(extractor.text.contains("currentStyleContamination")) XCTAssertTrue(extractor.text.contains("currentStyleContamination"))
XCTAssertTrue(extractor.text.contains("historicalStyleContamination")) XCTAssertTrue(extractor.text.contains("historicalStyleContamination"))
XCTAssertTrue(extractor.text.contains(#""asr":"#)) XCTAssertTrue(extractor.text.contains(#""asr":"#))
@@ -224,19 +226,33 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertTrue(extractor.prompt.contains("Evidence Extractor")) XCTAssertTrue(extractor.prompt.contains("Evidence Extractor"))
XCTAssertTrue(extractor.prompt.contains("finalEdit >")) XCTAssertTrue(extractor.prompt.contains("finalEdit >"))
XCTAssertTrue(extractor.prompt.contains("NOT the")) XCTAssertTrue(extractor.prompt.contains("NOT the"))
XCTAssertTrue(extractor.prompt.contains("Deduplicate"))
XCTAssertTrue(extractor.prompt.contains("information order"))
XCTAssertTrue(extractor.prompt.contains("epistemic stance"))
XCTAssertTrue(extractor.prompt.contains("userEdited=false"))
XCTAssertTrue(extractor.prompt.contains("retention or migration"))
XCTAssertTrue(extractor.prompt.contains("must not erase"))
XCTAssertTrue(extractor.prompt.contains("asrObservedBefore"))
XCTAssertTrue(synthesizer.prompt.contains("Style Synthesizer")) XCTAssertTrue(synthesizer.prompt.contains("Style Synthesizer"))
XCTAssertTrue(synthesizer.prompt.contains("ASR preserve mode")) XCTAssertTrue(synthesizer.prompt.contains("ASR preserve mode"))
XCTAssertTrue(synthesizer.prompt.contains("AI reply active-transfer mode")) XCTAssertTrue(synthesizer.prompt.contains("AI reply active-transfer mode"))
XCTAssertTrue(synthesizer.prompt.contains("Legal Emoji")) XCTAssertTrue(synthesizer.prompt.contains("Legal Emoji"))
XCTAssertTrue(
synthesizer.prompt.contains("actively turn every supported candidate trait")
)
XCTAssertTrue(synthesizer.prompt.contains("Never invent migration"))
XCTAssertTrue(synthesizer.prompt.contains("Never replace non-empty candidate traits"))
XCTAssertTrue(synthesizer.text.contains(#""evidence":"#)) XCTAssertTrue(synthesizer.text.contains(#""evidence":"#))
XCTAssertTrue(synthesizer.text.contains(#""learningMetadata":"#)) XCTAssertTrue(synthesizer.text.contains(#""learningMetadata":"#))
XCTAssertFalse(synthesizer.text.contains(replyMarker)) XCTAssertFalse(synthesizer.text.contains(replyMarker))
XCTAssertFalse(synthesizer.text.contains(selectedCandidateMarker)) XCTAssertFalse(synthesizer.text.contains(selectedCandidateMarker))
XCTAssertFalse(synthesizer.text.contains(String(source.prefix(100)))) XCTAssertFalse(synthesizer.text.contains(String(source.prefix(100))))
XCTAssertTrue(client.requests.allSatisfy { $0.timeout == 45 })
XCTAssertTrue(client.requests.allSatisfy { $0.options?.maxTokens == 4_096 })
let metadata = try XCTUnwrap(generated.learningMetadata) let metadata = try XCTUnwrap(generated.learningMetadata)
XCTAssertEqual(metadata.schemaVersion, 2) XCTAssertEqual(metadata.schemaVersion, 3)
XCTAssertEqual(metadata.evidenceStatus, "sufficient") XCTAssertEqual(metadata.evidenceStatus, "sufficient")
XCTAssertEqual(metadata.confidence, 0.86) XCTAssertEqual(metadata.confidence, 0.86)
XCTAssertEqual(metadata.asrExampleCount, 1) XCTAssertEqual(metadata.asrExampleCount, 1)
@@ -320,6 +336,29 @@ final class PolishStyleLearningServiceTests: XCTestCase {
} }
} }
func testExplicitTestBuildThresholdBypassAllowsEmptyCorpus() async throws {
let client = StyleLearningCapturingClient(
responses: [
Self.insufficientEvidenceResponse,
Self.emptyCorpusGeneratedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
let generated = try await service.generateStyle(
from: PolishStyleLearningCorpus(
examples: [],
effectiveCharacterCount: 0
),
outputLanguage: .chinese,
minimumEffectiveCharacterCount: 0
)
XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(generated.learningMetadata?.asrEffectiveCharacterCount, 0)
XCTAssertEqual(generated.learningMetadata?.evidenceStatus, "insufficient")
}
func testGeneratedStyleRejectsMissingRequiredSections() { func testGeneratedStyleRejectsMissingRequiredSections() {
let raw = #"{"name":"Invalid","prompt":"Only one sentence.","allowsAddedEmoji":false}"# let raw = #"{"name":"Invalid","prompt":"Only one sentence.","allowsAddedEmoji":false}"#
@@ -346,7 +385,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
} }
} }
func testInsufficientEvidenceCannotCreateInventedTraits() async throws { func testInsufficientEvidenceStillRunsSynthesizerWithoutHardcodedFallback() async throws {
let source = String(repeating: "保真语料", count: 625) let source = String(repeating: "保真语料", count: 625)
let corpus = PolishStyleLearningCorpus( let corpus = PolishStyleLearningCorpus(
examples: [ examples: [
@@ -359,11 +398,10 @@ final class PolishStyleLearningServiceTests: XCTestCase {
], ],
effectiveCharacterCount: 2_500 effectiveCharacterCount: 2_500
) )
let inventedResponse = ##"{"name":"Invented","prompt":"Invented playful slang and secrets","allowsAddedEmoji":true}"##
let client = StyleLearningCapturingClient( let client = StyleLearningCapturingClient(
responses: [ responses: [
Self.insufficientEvidenceResponse, Self.lowConfidenceInsufficientEvidenceResponse,
inventedResponse Self.insufficientGeneratedStyleResponse
] ]
) )
let service = PolishStyleLearningService(store: store, client: client) let service = PolishStyleLearningService(store: store, client: client)
@@ -374,13 +412,34 @@ final class PolishStyleLearningServiceTests: XCTestCase {
) )
XCTAssertEqual(client.requests.count, 2) XCTAssertEqual(client.requests.count, 2)
XCTAssertEqual(generated.name, "直接短句风格")
XCTAssertNotEqual(generated.name, "保守保真风格")
XCTAssertEqual(generated.learningMetadata?.evidenceStatus, "insufficient") XCTAssertEqual(generated.learningMetadata?.evidenceStatus, "insufficient")
XCTAssertEqual(generated.learningMetadata?.confidence, 0.2) XCTAssertTrue(generated.prompt.contains("短句"))
XCTAssertFalse(generated.prompt.contains("Invented"))
XCTAssertFalse(generated.prompt.contains("slang"))
XCTAssertFalse(generated.allowsAddedEmoji)
XCTAssertTrue(generated.prompt.contains("ASR preserve mode")) XCTAssertTrue(generated.prompt.contains("ASR preserve mode"))
XCTAssertTrue(generated.prompt.contains("AI reply active-transfer mode")) XCTAssertTrue(generated.prompt.contains("AI reply active-transfer mode"))
XCTAssertTrue(client.requests[1].text.contains(#""status":"insufficient""#))
XCTAssertFalse(generated.allowsAddedEmoji)
}
func testNonemptyASRRepairsEmptyInsufficientEvidenceIntoCandidateTraits() async throws {
let client = StyleLearningCapturingClient(
responses: [
Self.insufficientEvidenceResponse,
Self.singleObservationInsufficientEvidenceResponse,
Self.insufficientGeneratedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
let generated = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTAssertEqual(client.requests.count, 3)
XCTAssertTrue(client.requests[1].prompt.contains("REPAIR ATTEMPT"))
XCTAssertTrue(generated.prompt.contains("短句"))
} }
func testEvidenceSchemaRejectsFabricationAndProtocolOverrides() { func testEvidenceSchemaRejectsFabricationAndProtocolOverrides() {
@@ -415,6 +474,40 @@ final class PolishStyleLearningServiceTests: XCTestCase {
) )
} }
func testInsufficientEvidenceMayKeepSupportedLowConfidenceObservations() throws {
let evidence = try PolishStyleLearningService.parseEvidence(
Self.lowConfidenceInsufficientEvidenceResponse
)
XCTAssertEqual(evidence.status, .insufficient)
XCTAssertEqual(evidence.asr.traits.first?.confidence, 0.2)
XCTAssertEqual(evidence.asr.evidence.first?.source, .asrRepeatedBefore)
XCTAssertTrue(evidence.reply.traits.isEmpty)
}
func testSingleRawASRObservationIsAcceptedAsLowConfidenceCandidate() throws {
let evidence = try PolishStyleLearningService.parseEvidence(
Self.singleObservationInsufficientEvidenceResponse
)
XCTAssertEqual(evidence.status, .insufficient)
XCTAssertEqual(evidence.asr.traits.first?.supportCount, 1)
XCTAssertEqual(evidence.asr.evidence.first?.source, .asrObservedBefore)
}
func testSufficientEvidenceRequiresMinimumOverallConfidence() {
let lowConfidence = Self.sufficientEvidenceResponse.replacingOccurrences(
of: #""confidence":0.86"#,
with: #""confidence":0.49"#
)
XCTAssertThrowsError(
try PolishStyleLearningService.parseEvidence(lowConfidence)
) { error in
XCTAssertEqual(error as? PolishStyleLearningError, .invalidResponse)
}
}
func testEvidenceSchemaEnforcesSourcePriorityAndSupportCounts() { func testEvidenceSchemaEnforcesSourcePriorityAndSupportCounts() {
let weakCrossContext = Self.sufficientEvidenceResponse.replacingOccurrences( let weakCrossContext = Self.sufficientEvidenceResponse.replacingOccurrences(
of: #""source":"replyCrossContextSelection","summary":"","supportCount":2"#, of: #""source":"replyCrossContextSelection","summary":"","supportCount":2"#,
@@ -438,10 +531,10 @@ final class PolishStyleLearningServiceTests: XCTestCase {
) )
} }
func testGeneratedStyleRejectsTrailingProtocolContent() { func testGeneratedStyleRejectsTrailingSecondJSONObject() {
XCTAssertThrowsError( XCTAssertThrowsError(
try PolishStyleLearningService.parseGeneratedStyle( try PolishStyleLearningService.parseGeneratedStyle(
Self.generatedStyleResponse + "\nnot-json", Self.generatedStyleResponse + "\n{}",
outputLanguage: .chinese outputLanguage: .chinese
) )
) { error in ) { error in
@@ -449,6 +542,161 @@ final class PolishStyleLearningServiceTests: XCTestCase {
} }
} }
func testWrappedAndFencedJSONIsRecoveredWithoutRetry() async throws {
let client = StyleLearningCapturingClient(
responses: [
"\u{FEFF}Evidence follows:\n```json\n\(Self.sufficientEvidenceResponse)\n```\nDone.",
"```json\n\(Self.generatedStyleResponse)\n```"
]
)
let service = PolishStyleLearningService(store: store, client: client)
let generated = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTAssertEqual(generated.name, "我的说话风格")
XCTAssertEqual(client.requests.count, 2)
XCTAssertFalse(client.requests.contains { $0.prompt.contains("REPAIR ATTEMPT") })
}
func testInvalidEvidenceResponseRetriesOnceWithOriginalPayload() async throws {
let client = StyleLearningCapturingClient(
responses: [
"invalid evidence",
Self.sufficientEvidenceResponse,
Self.generatedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
_ = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTAssertEqual(client.requests.count, 3)
XCTAssertEqual(client.requests[0].text, client.requests[1].text)
XCTAssertTrue(client.requests[1].prompt.contains("REPAIR ATTEMPT"))
XCTAssertFalse(client.requests[2].prompt.contains("REPAIR ATTEMPT"))
}
func testInvalidSynthesisResponseRetriesOnceWithOriginalPayload() async throws {
let client = StyleLearningCapturingClient(
responses: [
Self.sufficientEvidenceResponse,
"invalid style",
Self.generatedStyleResponse
]
)
let service = PolishStyleLearningService(store: store, client: client)
_ = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTAssertEqual(client.requests.count, 3)
XCTAssertEqual(client.requests[1].text, client.requests[2].text)
XCTAssertTrue(client.requests[2].prompt.contains("REPAIR ATTEMPT"))
}
func testInvalidEvidenceResponseRetriesAtMostOnce() async {
let client = StyleLearningCapturingClient(
responses: ["invalid first response", "invalid repair response"]
)
let service = PolishStyleLearningService(store: store, client: client)
do {
_ = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTFail("Expected invalid response after one repair attempt")
} catch let error as PolishStyleLearningError {
XCTAssertEqual(error, .invalidResponse)
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertEqual(client.requests.count, 2)
XCTAssertTrue(client.requests[1].prompt.contains("REPAIR ATTEMPT"))
}
func testPromptTooLongSynthesisResponseIsNotRetried() async throws {
let oversizedPrompt = """
# 角色
\(String(repeating: "", count: 6_000))
# 风格边界
ASR preserve mode。AI reply active-transfer mode。
# 示例
输入 → 输出
"""
let responseData = try JSONSerialization.data(withJSONObject: [
"name": "Too Long",
"prompt": oversizedPrompt,
"allowsAddedEmoji": false
])
let response = try XCTUnwrap(String(data: responseData, encoding: .utf8))
let client = StyleLearningCapturingClient(
responses: [Self.sufficientEvidenceResponse, response]
)
let service = PolishStyleLearningService(store: store, client: client)
do {
_ = try await service.generateStyle(
from: Self.readyCorpus(),
outputLanguage: .chinese
)
XCTFail("Expected prompt length rejection")
} catch let error as PolishStyleLearningError {
XCTAssertEqual(error, .promptTooLong(maximum: 6_000))
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertEqual(client.requests.count, 2)
}
func testFailureMessagesExposeSpecificActionableReasons() {
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: PolishingService.PolishError.missingAPIKey,
language: .english
),
"The current AI service has no API key. Configure it in Settings and try again."
)
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: LLMError.timeout,
language: .english
),
"The AI request timed out. Please try again."
)
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: LLMError.http(status: 401),
language: .english
),
"API returned HTTP 401. Try again later or contact the provider."
)
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: ManagedGatewayError.insufficientCredits,
language: .english
),
"Not enough credits. Open the Account tab in the main app to add credits."
)
XCTAssertEqual(
PolishStyleLearningFailureMessage.localized(
for: ManagedGatewayError.invalidGrant,
language: .chinese
),
"托管服务授权已失效,请打开主 App 重新连接账号。"
)
}
private static let sufficientEvidenceResponse = ##""" private static let sufficientEvidenceResponse = ##"""
{ {
"status":"sufficient", "status":"sufficient",
@@ -486,6 +734,40 @@ final class PolishStyleLearningServiceTests: XCTestCase {
} }
"""## """##
private static let lowConfidenceInsufficientEvidenceResponse = ##"""
{
"status":"insufficient",
"confidence":0.2,
"asr":{
"traits":[
{"name":"retention:","description":"","confidence":0.2,"supportCount":2}
],
"evidence":[
{"source":"asrRepeatedBefore","summary":" before 使","supportCount":2}
],
"contradictions":[]
},
"reply":{"traits":[],"evidence":[],"contradictions":[]}
}
"""##
private static let singleObservationInsufficientEvidenceResponse = ##"""
{
"status":"insufficient",
"confidence":0.18,
"asr":{
"traits":[
{"name":"retention:","description":" ASR ","confidence":0.18,"supportCount":1}
],
"evidence":[
{"source":"asrObservedBefore","summary":" before 使","supportCount":1}
],
"contradictions":[]
},
"reply":{"traits":[],"evidence":[],"contradictions":[]}
}
"""##
private static let generatedStyleResponse = ##""" private static let generatedStyleResponse = ##"""
{ {
"name":"", "name":"",
@@ -493,11 +775,44 @@ final class PolishStyleLearningServiceTests: XCTestCase {
"allowsAddedEmoji":true "allowsAddedEmoji":true
} }
"""## """##
private static let emptyCorpusGeneratedStyleResponse = ##"""
{
"name":"",
"prompt":"# \n当前没有可观察的个人语料\n# \nASR preserve mode\nAI reply active-transfer mode\n# \n输入\n输出",
"allowsAddedEmoji":false
}
"""##
private static let insufficientGeneratedStyleResponse = ##"""
{
"name":"",
"prompt":"# \n优先使用语料观察到的直接短句\n# \nASR preserve mode\nAI reply active-transfer mode\n# \n输入\n输出",
"allowsAddedEmoji":false
}
"""##
private static func readyCorpus() -> PolishStyleLearningCorpus {
let source = String(repeating: "测试语料", count: 625)
return PolishStyleLearningCorpus(
examples: [
PolishStyleLearningExample(
prePolishText: source,
finalText: source + "",
polishStyleID: "builtin.chat",
createdAt: Date()
)
],
effectiveCharacterCount: 2_500
)
}
} }
private struct StyleLearningCapturedRequest { private struct StyleLearningCapturedRequest {
let text: String let text: String
let prompt: String let prompt: String
let timeout: TimeInterval?
let options: LLMGenerationOptions?
} }
private final class StyleLearningCapturingClient: LLMClient, @unchecked Sendable { private final class StyleLearningCapturingClient: LLMClient, @unchecked Sendable {
@@ -518,9 +833,42 @@ private final class StyleLearningCapturingClient: LLMClient, @unchecked Sendable
_ text: String, _ text: String,
systemPrompt: String, systemPrompt: String,
timeout: TimeInterval? timeout: TimeInterval?
) async throws -> String {
try await nextResponse(
text: text,
prompt: systemPrompt,
timeout: timeout,
options: nil
)
}
func polish(
_ text: String,
systemPrompt: String,
timeout: TimeInterval?,
options: LLMGenerationOptions
) async throws -> String {
try await nextResponse(
text: text,
prompt: systemPrompt,
timeout: timeout,
options: options
)
}
private func nextResponse(
text: String,
prompt: String,
timeout: TimeInterval?,
options: LLMGenerationOptions?
) async throws -> String { ) async throws -> String {
requests.append( requests.append(
StyleLearningCapturedRequest(text: text, prompt: systemPrompt) StyleLearningCapturedRequest(
text: text,
prompt: prompt,
timeout: timeout,
options: options
)
) )
guard !responses.isEmpty else { return "{}" } guard !responses.isEmpty else { return "{}" }
let index = min(responseIndex, responses.count - 1) let index = min(responseIndex, responses.count - 1)
@@ -160,6 +160,16 @@ final class AssistantKeyboardUITests: XCTestCase {
XCTAssertFalse(element("assistant.skills.pager", in: app).exists) XCTAssertFalse(element("assistant.skills.pager", in: app).exists)
} }
func testReliableSemanticResultShowsCompactBadge() {
let app = launch(scenario: "semanticBadge")
let badge = requiredElement("assistant.semantic.badge", in: app)
let mic = requiredElement("assistant.mic.idle", in: app)
XCTAssertTrue(badge.label.contains("Weather") || badge.label.contains("天气"))
XCTAssertLessThan(badge.frame.height, mic.frame.height)
XCTAssertTrue(element("assistant.clipboard.dismiss", in: app).exists)
}
func testSearchFieldShowsEnabledSearchAction() { func testSearchFieldShowsEnabledSearchAction() {
let app = launch(scenario: "search") let app = launch(scenario: "search")
let search = requiredElement("assistant.action.search", in: app) let search = requiredElement("assistant.action.search", in: app)
@@ -1,29 +1,164 @@
import XCTest import XCTest
final class PolishStylesGenerationUITests: XCTestCase { final class PolishStylesGenerationUITests: XCTestCase {
func testTestBuildCanGenerateWithoutPersonalCorpus() {
continueAfterFailure = false
let app = launchServiceHarness(
additionalArgument: "--polish-styles-service-ui-test-no-corpus"
)
XCTAssertTrue(
app.staticTexts[
"Test build: 2,500-character limit disabled"
]
.waitForExistence(timeout: 5)
)
let generate = app.buttons["polishStyles.learn.generate"]
XCTAssertTrue(generate.exists)
XCTAssertTrue(generate.isEnabled)
}
func testGeneratedStyleReviewAndSaveFlow() { func testGeneratedStyleReviewAndSaveFlow() {
continueAfterFailure = false continueAfterFailure = false
let app = XCUIApplication() let app = launchServiceHarness()
app.launchArguments = [
"--polish-styles-screenshot",
"--polish-styles-generation-demo"
]
app.launch()
let generate = app.buttons["polishStyles.learn.generate"] let generate = app.buttons["polishStyles.learn.generate"]
XCTAssertTrue(generate.waitForExistence(timeout: 5)) XCTAssertTrue(generate.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 0.8)
generate.tap() generate.tap()
let save = app.buttons["polishStyles.editor.save"] let save = app.buttons["polishStyles.editor.save"]
XCTAssertTrue(save.waitForExistence(timeout: 5)) XCTAssertTrue(save.waitForExistence(timeout: 8))
Thread.sleep(forTimeInterval: 1.2) let promptEditor = app.textViews["polishStyles.editor.prompt"]
XCTAssertTrue(promptEditor.waitForExistence(timeout: 2))
XCTAssertTrue(
(promptEditor.value as? String)?.contains("natural, direct voice") == true,
"The real learning service must pass the scripted synthesis into review"
)
save.tap() save.tap()
let learnedCard = app.descendants(matching: .any)[ let learnedCard = app.descendants(matching: .any)[
"polishStyles.learnedStyle.card" "polishStyles.learnedStyle.card"
] ]
XCTAssertTrue(learnedCard.waitForExistence(timeout: 5)) XCTAssertTrue(learnedCard.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 1.2) XCTAssertTrue(app.staticTexts["Confidence: 86%"].exists)
}
func testInsufficientEvidenceIsDisclosedDuringReviewAndAfterSave() {
continueAfterFailure = false
let app = launchServiceHarness(usesInsufficientEvidence: true)
let generate = app.buttons["polishStyles.learn.generate"]
XCTAssertTrue(generate.waitForExistence(timeout: 5))
generate.tap()
let warning = app.staticTexts[
"Low confidence. The prompt was generated from limited evidence; please review it."
]
XCTAssertTrue(warning.waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["Confidence: 20%"].exists)
let save = app.buttons["polishStyles.editor.save"]
XCTAssertTrue(save.exists)
save.tap()
let learnedCard = app.descendants(matching: .any)[
"polishStyles.learnedStyle.card"
]
XCTAssertTrue(learnedCard.waitForExistence(timeout: 5))
XCTAssertTrue(warning.waitForExistence(timeout: 2))
}
func testRegenerationPreservesExistingStyleIdentityUntilSave() {
continueAfterFailure = false
let app = launchServiceHarness(
additionalArgument: "--polish-styles-service-ui-test-regenerate"
)
let learnedCard = app.descendants(matching: .any)[
"polishStyles.learnedStyle.card"
]
XCTAssertTrue(learnedCard.waitForExistence(timeout: 5))
let regenerate = app.buttons["Regenerate"]
XCTAssertTrue(regenerate.exists)
let originalID = regenerate.value as? String
XCTAssertFalse(originalID?.isEmpty ?? true)
regenerate.tap()
let save = app.buttons["polishStyles.editor.save"]
XCTAssertTrue(save.waitForExistence(timeout: 8))
XCTAssertEqual(
regenerate.value as? String,
originalID,
"The persisted style must remain unchanged while review is open"
)
save.tap()
XCTAssertTrue(learnedCard.waitForExistence(timeout: 5))
XCTAssertEqual(
app.buttons["Regenerate"].value as? String,
originalID
)
}
func testSynthesisFailureKeepsExistingStyleUnchanged() {
continueAfterFailure = false
let app = launchServiceHarness(
additionalArgument: "--polish-styles-service-ui-test-failure"
)
let learnedCard = app.descendants(matching: .any)[
"polishStyles.learnedStyle.card"
]
XCTAssertTrue(learnedCard.waitForExistence(timeout: 5))
let regenerate = app.buttons["Regenerate"]
let originalID = regenerate.value as? String
regenerate.tap()
XCTAssertTrue(
app.staticTexts["Couldnt Generate Style"].waitForExistence(timeout: 8)
)
XCTAssertEqual(regenerate.value as? String, originalID)
XCTAssertFalse(app.buttons["polishStyles.editor.save"].exists)
}
func testLeavingStyleScreenCancelsGenerationWithoutTimeoutAlert() {
continueAfterFailure = false
let app = launchServiceHarness(
additionalArgument: "--polish-styles-service-ui-test-cancel"
)
let generate = app.buttons["polishStyles.learn.generate"]
XCTAssertTrue(generate.waitForExistence(timeout: 5))
generate.tap()
let leave = app.buttons["polishStyles.test.leave"]
XCTAssertTrue(leave.exists)
leave.tap()
XCTAssertTrue(
app.staticTexts["polishStyles.test.closed"].waitForExistence(timeout: 3)
)
XCTAssertTrue(
app.staticTexts["polishStyles.test.cancelled"].waitForExistence(timeout: 3)
)
XCTAssertFalse(app.staticTexts["Couldnt Generate Style"].exists)
}
private func launchServiceHarness(
usesInsufficientEvidence: Bool = false,
additionalArgument: String? = nil
) -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments = [
"--polish-styles-screenshot",
"--polish-styles-service-ui-test",
"--screenshot-lang=en"
]
if usesInsufficientEvidence {
app.launchArguments.append(
"--polish-styles-service-ui-test-insufficient"
)
}
if let additionalArgument {
app.launchArguments.append(additionalArgument)
}
app.launch()
return app
} }
} }