diff --git a/OSGKeyboardExtTests/ManagedGatewayTests.swift b/OSGKeyboardExtTests/ManagedGatewayTests.swift index 5a7cfbd..124906b 100644 --- a/OSGKeyboardExtTests/ManagedGatewayTests.swift +++ b/OSGKeyboardExtTests/ManagedGatewayTests.swift @@ -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)) diff --git a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift index f920b32..589ea15 100644 --- a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift +++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift @@ -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 ] ) } diff --git a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift index f3fade0..b4d519b 100644 --- a/OSGKeyboardTests/AIHintKeywordExtractorTests.swift +++ b/OSGKeyboardTests/AIHintKeywordExtractorTests.swift @@ -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(#""#)) + 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(#""#)) + XCTAssertTrue(instruction.contains("不要因为语气负面就默认用户有错")) + XCTAssertFalse(instruction.contains(#""#)) + } + + 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(#""#)) + } + func testReplyStyleIsNotInjectedIntoNonReplySkill() throws { let skill = try XCTUnwrap( AIClipboardSkillCatalog.skill(id: AIClipboardSkillCatalog.summarizeID) diff --git a/OSGKeyboardTests/AIReplyVariantTests.swift b/OSGKeyboardTests/AIReplyVariantTests.swift index f178594..f493df0 100644 --- a/OSGKeyboardTests/AIReplyVariantTests.swift +++ b/OSGKeyboardTests/AIReplyVariantTests.swift @@ -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), diff --git a/OSGKeyboardTests/AISessionStateTests.swift b/OSGKeyboardTests/AISessionStateTests.swift index c866238..3980ab4 100644 --- a/OSGKeyboardTests/AISessionStateTests.swift +++ b/OSGKeyboardTests/AISessionStateTests.swift @@ -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: "普通回复"), diff --git a/OSGKeyboardTests/ClipboardReplyFeedbackStoreTests.swift b/OSGKeyboardTests/ClipboardReplyFeedbackStoreTests.swift index 16a55d1..5556a6b 100644 --- a/OSGKeyboardTests/ClipboardReplyFeedbackStoreTests.swift +++ b/OSGKeyboardTests/ClipboardReplyFeedbackStoreTests.swift @@ -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] { diff --git a/OSGKeyboardTests/ClipboardSemanticAnalyzerTests.swift b/OSGKeyboardTests/ClipboardSemanticAnalyzerTests.swift index 06744cd..d62e7b1 100644 --- a/OSGKeyboardTests/ClipboardSemanticAnalyzerTests.swift +++ b/OSGKeyboardTests/ClipboardSemanticAnalyzerTests.swift @@ -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 diff --git a/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift b/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift index 8ba36ce..c8121e1 100644 --- a/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift +++ b/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift @@ -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 ) } diff --git a/OSGKeyboardTests/FlowReliabilityTests.swift b/OSGKeyboardTests/FlowReliabilityTests.swift index 494f6a9..f2b0d4c 100644 --- a/OSGKeyboardTests/FlowReliabilityTests.swift +++ b/OSGKeyboardTests/FlowReliabilityTests.swift @@ -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( diff --git a/OSGKeyboardTests/IntelligentPolishTests.swift b/OSGKeyboardTests/IntelligentPolishTests.swift index 15b4318..6e667f0 100644 --- a/OSGKeyboardTests/IntelligentPolishTests.swift +++ b/OSGKeyboardTests/IntelligentPolishTests.swift @@ -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] = [] + + 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 diff --git a/OSGKeyboardTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift index ef2a46e..7a4b9ab 100644 --- a/OSGKeyboardTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -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"` diff --git a/OSGKeyboardTests/OfficialSkillCatalogTests.swift b/OSGKeyboardTests/OfficialSkillCatalogTests.swift index 8e8238f..ddcef37 100644 --- a/OSGKeyboardTests/OfficialSkillCatalogTests.swift +++ b/OSGKeyboardTests/OfficialSkillCatalogTests.swift @@ -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" ] diff --git a/OSGKeyboardTests/PolishStyleLearningServiceTests.swift b/OSGKeyboardTests/PolishStyleLearningServiceTests.swift index e0e83a6..86eddf7 100644 --- a/OSGKeyboardTests/PolishStyleLearningServiceTests.swift +++ b/OSGKeyboardTests/PolishStyleLearningServiceTests.swift @@ -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) diff --git a/OSGKeyboardUITests/AssistantKeyboardUITests.swift b/OSGKeyboardUITests/AssistantKeyboardUITests.swift index 888350e..994411c 100644 --- a/OSGKeyboardUITests/AssistantKeyboardUITests.swift +++ b/OSGKeyboardUITests/AssistantKeyboardUITests.swift @@ -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) diff --git a/OSGKeyboardUITests/PolishStylesGenerationUITests.swift b/OSGKeyboardUITests/PolishStylesGenerationUITests.swift index c12f3d7..2377d11 100644 --- a/OSGKeyboardUITests/PolishStylesGenerationUITests.swift +++ b/OSGKeyboardUITests/PolishStylesGenerationUITests.swift @@ -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 } }