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:
@@ -254,6 +254,21 @@ final class ManagedGatewayTests: XCTestCase {
|
||||
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 {
|
||||
let now = Date(timeIntervalSince1970: 3_600)
|
||||
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
||||
|
||||
@@ -82,7 +82,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID]
|
||||
)
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.acceptInvitationID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.replyID))
|
||||
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID))
|
||||
}
|
||||
|
||||
@@ -113,10 +113,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.blessingReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
@@ -149,10 +145,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.blessingReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
@@ -183,10 +175,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.blessingReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
@@ -218,11 +206,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.blessingReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
@@ -230,7 +214,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
9
|
||||
11
|
||||
)
|
||||
}
|
||||
|
||||
@@ -256,10 +240,6 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.blessingReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
]
|
||||
)
|
||||
@@ -298,7 +278,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
9
|
||||
11
|
||||
)
|
||||
}
|
||||
|
||||
@@ -324,7 +304,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
defaults.integer(
|
||||
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion
|
||||
),
|
||||
9
|
||||
11
|
||||
)
|
||||
XCTAssertEqual(
|
||||
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() {
|
||||
XCTAssertNotNil(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.playfulReplyID)
|
||||
@@ -339,6 +397,9 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
XCTAssertNotNil(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.businessReplyID)
|
||||
)
|
||||
XCTAssertNotNil(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.empathyReplyID)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.playfulReplyID),
|
||||
AIClipboardSkillCatalog.replyID
|
||||
@@ -347,10 +408,37 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
AIClipboardSkillCatalog.canonicalID(for: AIClipboardSkillCatalog.businessReplyID),
|
||||
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(
|
||||
AIClipboardSkillCatalog.catalog.contains {
|
||||
$0.id == AIClipboardSkillCatalog.playfulReplyID
|
||||
|| $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.playfulReplyID,
|
||||
AIClipboardSkillCatalog.businessReplyID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.extractConclusionsID,
|
||||
AIClipboardSkillCatalog.askForDetailsID
|
||||
@@ -412,8 +501,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
|
||||
layout.enabledIDs,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID
|
||||
AIClipboardSkillCatalog.summarizeID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,26 +188,27 @@ final class AIClipboardSkillTests: XCTestCase {
|
||||
XCTAssertTrue(prompt.contains("决定、结论和下一步"))
|
||||
}
|
||||
|
||||
func testSemanticReplySkillsHaveDistinctInstructions() {
|
||||
let ids = [
|
||||
AIClipboardSkillCatalog.playfulReplyID,
|
||||
AIClipboardSkillCatalog.acceptInvitationID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.acceptTaskID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.businessReplyID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.organizeListID
|
||||
func testReplyScenesHaveDistinctInstructions() throws {
|
||||
let skill = try XCTUnwrap(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.replyID)
|
||||
)
|
||||
let scenes: [AIClipboardReplyScene] = [
|
||||
.invitation,
|
||||
.task,
|
||||
.blessing,
|
||||
.clarification,
|
||||
.complaint,
|
||||
.negativeQuestion
|
||||
]
|
||||
let prompts = ids.map {
|
||||
let prompts = scenes.map {
|
||||
AIClipboardSkillCatalog.instruction(
|
||||
skillID: $0,
|
||||
for: skill,
|
||||
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("用户选择的操作") })
|
||||
}
|
||||
|
||||
@@ -250,6 +251,54 @@ final class AIClipboardSkillTests: XCTestCase {
|
||||
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 {
|
||||
let skill = try XCTUnwrap(
|
||||
AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.summarizeID)
|
||||
|
||||
@@ -116,11 +116,11 @@ final class AIReplyVariantTests: XCTestCase {
|
||||
|
||||
func testReplyKindOwnsLocalPresentationMetadata() {
|
||||
XCTAssertEqual(
|
||||
AIReplyVariant.Kind.allCases.map(\.systemImage),
|
||||
AIReplyVariantSet.generic.kinds.map(\.systemImage),
|
||||
["bubble.left.fill", "briefcase.fill", "theatermasks.fill"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIReplyVariant.Kind.allCases.map(\.titleKey),
|
||||
AIReplyVariantSet.generic.kinds.map(\.titleKey),
|
||||
[
|
||||
"keyboard.ai.replyVariant.ordinary",
|
||||
"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() {
|
||||
XCTAssertEqual(
|
||||
AIReplyVariant.Emotion.celebratory.systemImage(fallback: .ordinary),
|
||||
|
||||
@@ -223,6 +223,39 @@ final class AISessionStateTests: XCTestCase {
|
||||
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] {
|
||||
[
|
||||
AIReplyVariant(kind: .ordinary, emotion: .warm, text: "普通回复"),
|
||||
|
||||
@@ -241,6 +241,37 @@ final class ClipboardReplyFeedbackStoreTests: XCTestCase {
|
||||
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(
|
||||
suffix: String = ""
|
||||
) -> [ClipboardReplyCandidateSnapshot] {
|
||||
|
||||
@@ -19,6 +19,43 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
|
||||
XCTAssertFalse(analysis.confirmationDecision.isDetected)
|
||||
XCTAssertFalse(analysis.followUpReminder.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() {
|
||||
@@ -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 {
|
||||
let analysis = await ClipboardSemanticAnalyzer().analyze(
|
||||
"""
|
||||
@@ -364,6 +483,15 @@ final class ClipboardSemanticAnalyzerTests: XCTestCase {
|
||||
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 {
|
||||
let components = duration.components
|
||||
return Double(components.seconds) * 1_000
|
||||
|
||||
@@ -12,11 +12,10 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
Array(ranked.prefix(3)),
|
||||
Array(ranked.prefix(2)),
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -54,7 +53,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testInvitationWithDatePromotesCalendarAndBothReplyChoices() {
|
||||
func testInvitationWithDatePromotesUnifiedReplyAndCalendar() {
|
||||
let ranked = rank(
|
||||
text: "今晚七点老地方吃饭,你能来吗?",
|
||||
analysis: analysis(
|
||||
@@ -64,15 +63,16 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(ranked.first, AIClipboardSkillCatalog.extractEventsID)
|
||||
XCTAssertEqual(ranked.first, AIClipboardSkillCatalog.replyID)
|
||||
XCTAssertLessThan(
|
||||
tryIndex(AIClipboardSkillCatalog.acceptInvitationID, in: ranked),
|
||||
tryIndex(AIClipboardSkillCatalog.summarizeID, in: ranked)
|
||||
)
|
||||
XCTAssertLessThan(
|
||||
tryIndex(AIClipboardSkillCatalog.declineInvitationID, in: ranked),
|
||||
tryIndex(AIClipboardSkillCatalog.extractEventsID, in: ranked),
|
||||
tryIndex(AIClipboardSkillCatalog.summarizeID, in: ranked)
|
||||
)
|
||||
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis(
|
||||
hasDate: true,
|
||||
question: detected(),
|
||||
invitation: detected()
|
||||
)), .invitation)
|
||||
}
|
||||
|
||||
func testAddressPromotesNavigation() {
|
||||
@@ -215,10 +215,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
tryIndex(AIClipboardSkillCatalog.organizeListID, in: ranked),
|
||||
tryIndex(AIClipboardSkillCatalog.replyID, in: ranked)
|
||||
)
|
||||
XCTAssertLessThan(
|
||||
tryIndex(AIClipboardSkillCatalog.acceptTaskID, in: ranked),
|
||||
tryIndex(AIClipboardSkillCatalog.replyID, in: ranked)
|
||||
)
|
||||
}
|
||||
|
||||
func testUnapprovedComplaintFallsBackToGenericReply() {
|
||||
@@ -242,6 +238,57 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
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() {
|
||||
let ranked = rank(
|
||||
text: String(repeating: "这是需要阅读和整理的长文内容。", count: 40),
|
||||
@@ -320,7 +367,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testInvitationKeepsSpecificRepliesAndGenericReply() {
|
||||
func testInvitationUsesOneReplyCenterAndKeepsCalendarAction() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "今晚七点老地方吃饭,你能来吗?",
|
||||
@@ -337,15 +384,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.extractEventsID,
|
||||
AIClipboardSkillCatalog.acceptInvitationID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.extractEventsID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testForeignQuestionKeepsReplyAndOneSpecializedFollowUp() {
|
||||
func testForeignQuestionKeepsTranslationAndUnifiedReply() {
|
||||
let recommendations = ClipboardSkillSemanticRanker.recommended(
|
||||
skills: AIClipboardSkillCatalog.catalog,
|
||||
sourceText: "Could you send the final proposal by Friday?",
|
||||
@@ -360,13 +405,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
)
|
||||
let replyCount = recommendations.filter(\.supportsReplyStyle).count
|
||||
|
||||
XCTAssertLessThanOrEqual(replyCount, 2)
|
||||
XCTAssertEqual(replyCount, 1)
|
||||
XCTAssertEqual(
|
||||
recommendations.map(\.id),
|
||||
[
|
||||
AIClipboardSkillCatalog.translateID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -380,7 +424,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.extractEventsID
|
||||
]
|
||||
@@ -400,7 +443,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.extractEventsID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
@@ -412,13 +454,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
analysis: analysis(confirmationDecision: detected())
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.acceptTaskID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
}
|
||||
|
||||
func testFollowUpReminderMapsToExistingSkills() {
|
||||
@@ -431,14 +467,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.extractTodosID,
|
||||
AIClipboardSkillCatalog.acceptTaskID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testBlessingMapsToDedicatedReplyAndGenericFallback() {
|
||||
func testBlessingMapsToUnifiedReplyCenter() {
|
||||
let recommendations = recommended(
|
||||
text: "大家一起祝王老师生日快乐、身体健康!",
|
||||
analysis: analysis(
|
||||
@@ -447,13 +481,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.blessingReplyID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
|
||||
XCTAssertEqual(AIClipboardReplyScene.resolve(from: analysis(
|
||||
replyableMessage: detected(),
|
||||
blessing: detected()
|
||||
)), .blessing)
|
||||
}
|
||||
|
||||
func testNewIntentConflictKeepsTopFiveAndGenericReply() {
|
||||
@@ -472,13 +504,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.acceptTaskID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.extractEventsID,
|
||||
AIClipboardSkillCatalog.extractTodosID
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(recommendations.count, 5)
|
||||
XCTAssertEqual(recommendations.count, 3)
|
||||
}
|
||||
|
||||
func testSpecializedNewIntentSuppressesGenericReplyableBoost() {
|
||||
@@ -494,13 +524,82 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
XCTAssertEqual(
|
||||
recommendations,
|
||||
[
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
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 {
|
||||
let analyzer = ClipboardSemanticAnalyzer()
|
||||
let samples: [
|
||||
@@ -514,7 +613,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
"We need to reschedule the review. Is Tuesday or Thursday better?",
|
||||
\.scheduleNegotiation,
|
||||
[
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.replyID,
|
||||
AIClipboardSkillCatalog.extractEventsID
|
||||
]
|
||||
@@ -523,7 +621,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
"I approve the revised proposal; proceed with this version.",
|
||||
\.confirmationDecision,
|
||||
[
|
||||
AIClipboardSkillCatalog.acceptTaskID,
|
||||
AIClipboardSkillCatalog.replyID
|
||||
]
|
||||
),
|
||||
@@ -532,8 +629,6 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
\.followUpReminder,
|
||||
[
|
||||
AIClipboardSkillCatalog.extractTodosID,
|
||||
AIClipboardSkillCatalog.acceptTaskID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
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 {
|
||||
label.confidence > 0 && label.confidence >= label.threshold
|
||||
}
|
||||
@@ -692,7 +796,11 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
scheduleNegotiation: ClipboardIntentLabel? = nil,
|
||||
confirmationDecision: 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(
|
||||
language: language.map {
|
||||
@@ -725,7 +833,12 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
|
||||
followUpReminder: followUpReminder ?? absent(),
|
||||
blessing: blessing ?? absent(),
|
||||
actionVerifier: nil,
|
||||
coordinationVerifier: nil
|
||||
coordinationVerifier: nil,
|
||||
assistantCommand: assistantCommand ?? absent(),
|
||||
informationQuery: informationQuery ?? absent(),
|
||||
systemNotification: systemNotification ?? absent(),
|
||||
domain: domain,
|
||||
domainConfidence: domain == nil ? nil : 0.9
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -177,12 +177,31 @@ final class FlowReliabilityTests: XCTestCase {
|
||||
}
|
||||
XCTFail("Expected timeout")
|
||||
} catch {
|
||||
XCTAssertTrue(error is CancellationError)
|
||||
XCTAssertEqual(error as? HardTimeoutError, .timedOut)
|
||||
}
|
||||
|
||||
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() {
|
||||
XCTAssertTrue(
|
||||
FlowAudioRouteRecoveryPolicy.shouldRebuild(
|
||||
|
||||
@@ -114,6 +114,24 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
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() {
|
||||
var dict = PersonalDictionary.empty
|
||||
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")
|
||||
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)
|
||||
_ = try await service.polish(veryLong, context: PolishContext())
|
||||
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 {
|
||||
@@ -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 {
|
||||
let requestTimeout: TimeInterval = 15
|
||||
private let response: String
|
||||
|
||||
@@ -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)
|
||||
|
||||
/// Cloud engine must invoke the LLM even when a legacy `modeId == "off"`
|
||||
|
||||
@@ -138,10 +138,6 @@ final class OfficialSkillCatalogTests: XCTestCase {
|
||||
AIClipboardSkillCatalog.callPhoneID,
|
||||
AIClipboardSkillCatalog.createContactID,
|
||||
AIClipboardSkillCatalog.summarizeID,
|
||||
AIClipboardSkillCatalog.declineInvitationID,
|
||||
AIClipboardSkillCatalog.clarifyRequestID,
|
||||
AIClipboardSkillCatalog.empathyReplyID,
|
||||
AIClipboardSkillCatalog.blessingReplyID,
|
||||
AIClipboardSkillCatalog.organizeListID,
|
||||
"official.rewrite"
|
||||
]
|
||||
|
||||
@@ -215,6 +215,8 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
XCTAssertFalse(extractor.text.contains("这个 Prompt 后来已经被编辑"))
|
||||
XCTAssertTrue(extractor.text.contains(String(source.prefix(100))))
|
||||
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("historicalStyleContamination"))
|
||||
XCTAssertTrue(extractor.text.contains(#""asr":"#))
|
||||
@@ -224,19 +226,33 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
XCTAssertTrue(extractor.prompt.contains("Evidence Extractor"))
|
||||
XCTAssertTrue(extractor.prompt.contains("finalEdit >"))
|
||||
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("ASR preserve mode"))
|
||||
XCTAssertTrue(synthesizer.prompt.contains("AI reply active-transfer mode"))
|
||||
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(#""learningMetadata":"#))
|
||||
XCTAssertFalse(synthesizer.text.contains(replyMarker))
|
||||
XCTAssertFalse(synthesizer.text.contains(selectedCandidateMarker))
|
||||
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)
|
||||
XCTAssertEqual(metadata.schemaVersion, 2)
|
||||
XCTAssertEqual(metadata.schemaVersion, 3)
|
||||
XCTAssertEqual(metadata.evidenceStatus, "sufficient")
|
||||
XCTAssertEqual(metadata.confidence, 0.86)
|
||||
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() {
|
||||
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 corpus = PolishStyleLearningCorpus(
|
||||
examples: [
|
||||
@@ -359,11 +398,10 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
],
|
||||
effectiveCharacterCount: 2_500
|
||||
)
|
||||
let inventedResponse = ##"{"name":"Invented","prompt":"Invented playful slang and secrets","allowsAddedEmoji":true}"##
|
||||
let client = StyleLearningCapturingClient(
|
||||
responses: [
|
||||
Self.insufficientEvidenceResponse,
|
||||
inventedResponse
|
||||
Self.lowConfidenceInsufficientEvidenceResponse,
|
||||
Self.insufficientGeneratedStyleResponse
|
||||
]
|
||||
)
|
||||
let service = PolishStyleLearningService(store: store, client: client)
|
||||
@@ -374,13 +412,34 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
)
|
||||
|
||||
XCTAssertEqual(client.requests.count, 2)
|
||||
XCTAssertEqual(generated.name, "直接短句风格")
|
||||
XCTAssertNotEqual(generated.name, "保守保真风格")
|
||||
XCTAssertEqual(generated.learningMetadata?.evidenceStatus, "insufficient")
|
||||
XCTAssertEqual(generated.learningMetadata?.confidence, 0.2)
|
||||
XCTAssertFalse(generated.prompt.contains("Invented"))
|
||||
XCTAssertFalse(generated.prompt.contains("slang"))
|
||||
XCTAssertFalse(generated.allowsAddedEmoji)
|
||||
XCTAssertTrue(generated.prompt.contains("短句"))
|
||||
XCTAssertTrue(generated.prompt.contains("ASR preserve 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() {
|
||||
@@ -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() {
|
||||
let weakCrossContext = Self.sufficientEvidenceResponse.replacingOccurrences(
|
||||
of: #""source":"replyCrossContextSelection","summary":"跨场景偏好轻松语气","supportCount":2"#,
|
||||
@@ -438,10 +531,10 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testGeneratedStyleRejectsTrailingProtocolContent() {
|
||||
func testGeneratedStyleRejectsTrailingSecondJSONObject() {
|
||||
XCTAssertThrowsError(
|
||||
try PolishStyleLearningService.parseGeneratedStyle(
|
||||
Self.generatedStyleResponse + "\nnot-json",
|
||||
Self.generatedStyleResponse + "\n{}",
|
||||
outputLanguage: .chinese
|
||||
)
|
||||
) { 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 = ##"""
|
||||
{
|
||||
"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 = ##"""
|
||||
{
|
||||
"name":"我的说话风格",
|
||||
@@ -493,11 +775,44 @@ final class PolishStyleLearningServiceTests: XCTestCase {
|
||||
"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 {
|
||||
let text: String
|
||||
let prompt: String
|
||||
let timeout: TimeInterval?
|
||||
let options: LLMGenerationOptions?
|
||||
}
|
||||
|
||||
private final class StyleLearningCapturingClient: LLMClient, @unchecked Sendable {
|
||||
@@ -518,9 +833,42 @@ private final class StyleLearningCapturingClient: LLMClient, @unchecked Sendable
|
||||
_ text: String,
|
||||
systemPrompt: String,
|
||||
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 {
|
||||
requests.append(
|
||||
StyleLearningCapturedRequest(text: text, prompt: systemPrompt)
|
||||
StyleLearningCapturedRequest(
|
||||
text: text,
|
||||
prompt: prompt,
|
||||
timeout: timeout,
|
||||
options: options
|
||||
)
|
||||
)
|
||||
guard !responses.isEmpty else { return "{}" }
|
||||
let index = min(responseIndex, responses.count - 1)
|
||||
|
||||
@@ -160,6 +160,16 @@ final class AssistantKeyboardUITests: XCTestCase {
|
||||
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() {
|
||||
let app = launch(scenario: "search")
|
||||
let search = requiredElement("assistant.action.search", in: app)
|
||||
|
||||
@@ -1,29 +1,164 @@
|
||||
import XCTest
|
||||
|
||||
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() {
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = [
|
||||
"--polish-styles-screenshot",
|
||||
"--polish-styles-generation-demo"
|
||||
]
|
||||
app.launch()
|
||||
let app = launchServiceHarness()
|
||||
|
||||
let generate = app.buttons["polishStyles.learn.generate"]
|
||||
XCTAssertTrue(generate.waitForExistence(timeout: 5))
|
||||
Thread.sleep(forTimeInterval: 0.8)
|
||||
generate.tap()
|
||||
|
||||
let save = app.buttons["polishStyles.editor.save"]
|
||||
XCTAssertTrue(save.waitForExistence(timeout: 5))
|
||||
Thread.sleep(forTimeInterval: 1.2)
|
||||
XCTAssertTrue(save.waitForExistence(timeout: 8))
|
||||
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()
|
||||
|
||||
let learnedCard = app.descendants(matching: .any)[
|
||||
"polishStyles.learnedStyle.card"
|
||||
]
|
||||
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["Couldn’t 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["Couldn’t 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user