Files
OSGKeyboard/OSGKeyboardTests/AIReplyVariantTests.swift
Rocky b275b6b0d9 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.
2026-08-29 11:51:25 +08:00

221 lines
7.8 KiB
Swift

@testable import OSGKeyboardShared
import XCTest
final class AIReplyVariantTests: XCTestCase {
func testStrictParserReturnsThreeKindsInStableUIOrder() throws {
let raw = """
{"variants":[
{"kind":"playful","emotion":"celebratory","text":"好呀,走起 🎉"},
{"kind":"ordinary","emotion":"warm","text":"好呀,到时见。"},
{"kind":"formal","emotion":"neutral","text":"好的,届时见。"}
]}
"""
let variants = try XCTUnwrap(AIReplyVariantParser.parse(raw))
XCTAssertEqual(variants.map(\.kind), [.ordinary, .formal, .playful])
XCTAssertEqual(variants.map(\.emotion), [.warm, .neutral, .celebratory])
XCTAssertEqual(variants.map(\.text), ["好呀,到时见。", "好的,届时见。", "好呀,走起 🎉"])
}
func testUnknownEmotionSafelyDowngradesToNeutral() throws {
let raw = """
{"variants":[
{"kind":"ordinary","emotion":"unsafe-symbol-name","text":"A"},
{"kind":"formal","emotion":"neutral","text":"B"},
{"kind":"playful","emotion":"playful","text":"C"}
]}
"""
let variants = try XCTUnwrap(AIReplyVariantParser.parse(raw))
XCTAssertEqual(variants[0].emotion, .neutral)
}
func testStrictParserRejectsUnknownFieldsAndMissingKinds() {
let extraField = """
{"variants":[
{"kind":"ordinary","emotion":"neutral","text":"A","icon":"star"},
{"kind":"formal","emotion":"neutral","text":"B"},
{"kind":"playful","emotion":"playful","text":"C"}
]}
"""
let duplicatedKind = """
{"variants":[
{"kind":"ordinary","emotion":"neutral","text":"A"},
{"kind":"ordinary","emotion":"warm","text":"B"},
{"kind":"playful","emotion":"playful","text":"C"}
]}
"""
XCTAssertNil(AIReplyVariantParser.parse(extraField))
XCTAssertNil(AIReplyVariantParser.parse(duplicatedKind))
}
func testFencedJSONFallsBackToOneCleanOrdinaryReply() throws {
let raw = """
```json
{"variants":[
{"kind":"ordinary","emotion":"warm","text":"先确认一下具体时间,可以吗?"},
{"kind":"formal","emotion":"neutral","text":"请先确认具体时间。"}
]}
```
"""
let result = try XCTUnwrap(AIReplyVariantParser.parseOrFallback(raw))
guard case .single(let fallback) = result else {
return XCTFail("Expected a safe single fallback")
}
XCTAssertEqual(fallback.kind, .ordinary)
XCTAssertEqual(fallback.emotion, .neutral)
XCTAssertEqual(fallback.text, "先确认一下具体时间,可以吗?")
XCTAssertFalse(fallback.text.contains("variants"))
XCTAssertFalse(fallback.text.contains("```"))
}
func testMalformedJSONExtractsReplyInsteadOfReturningStructure() throws {
let raw = #"{"kind":"ordinary","text":"可以,周五见。","emotion":"warm",}"#
XCTAssertEqual(
AIReplyVariantParser.fallbackText(from: raw),
"可以,周五见。"
)
}
func testParserRejectsSourceRestatementAndKeepsConversationalReplies() throws {
let source = "630语音因为技术改造,能力回退😪"
let restatement = """
{"variants":[
{"kind":"ordinary","emotion":"neutral","text":"630语音因技术改造,能力回退了,有点无奈。"},
{"kind":"formal","emotion":"neutral","text":"630语音因技术改造,能力有所回退,特此说明。"},
{"kind":"playful","emotion":"playful","text":"630语音被技术改造坑了一把,能力回退了 😅"}
]}
"""
let replies = """
{"variants":[
{"kind":"ordinary","emotion":"empathetic","text":"那确实有点可惜,希望后面尽快恢复。"},
{"kind":"formal","emotion":"calm","text":"了解,希望后续改造完成后能恢复原有能力。"},
{"kind":"playful","emotion":"playful","text":"这是先退两步,准备以后起飞吗 😅"}
]}
"""
XCTAssertNil(
AIReplyVariantParser.parseOrFallback(
restatement,
sourceText: source
)
)
XCTAssertEqual(
try XCTUnwrap(
AIReplyVariantParser.parse(replies, sourceText: source)
).count,
3
)
}
func testReplyKindOwnsLocalPresentationMetadata() {
XCTAssertEqual(
AIReplyVariantSet.generic.kinds.map(\.systemImage),
["bubble.left.fill", "briefcase.fill", "theatermasks.fill"]
)
XCTAssertEqual(
AIReplyVariantSet.generic.kinds.map(\.titleKey),
[
"keyboard.ai.replyVariant.ordinary",
"keyboard.ai.replyVariant.formal",
"keyboard.ai.replyVariant.playful"
]
)
}
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),
"party.popper.fill"
)
XCTAssertEqual(
AIReplyVariant.Emotion.empathetic.systemImage(fallback: .playful),
"heart.text.square.fill"
)
XCTAssertEqual(
AIReplyVariant.Emotion.neutral.systemImage(fallback: .formal),
"briefcase.fill"
)
}
}