Files
OSGKeyboard/OSGKeyboardTests/AISessionStateTests.swift
T
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

267 lines
9.7 KiB
Swift

@testable import OSGKeyboardShared
import XCTest
final class AISessionStateTests: XCTestCase {
func testSuccessfulAnswerReplacesPreviousOnlyAtTerminalResult() throws {
var state = AISessionState()
let conversationID = UUID()
let firstUtteranceID = UUID()
state.enter(conversationID: conversationID)
state.beginPreparing(utteranceID: firstUtteranceID)
state.beginListening(utteranceID: firstUtteranceID)
state.beginRecognizing(utteranceID: firstUtteranceID)
state.beginGenerating(question: "问题一", utteranceID: firstUtteranceID)
state.receiveAnswer("答案一", utteranceID: firstUtteranceID)
let secondUtteranceID = UUID()
state.beginPreparing(utteranceID: secondUtteranceID)
XCTAssertEqual(state.answer?.text, "答案一")
state.beginListening(utteranceID: secondUtteranceID)
state.beginGenerating(question: "问题二", utteranceID: secondUtteranceID)
XCTAssertEqual(state.answer?.text, "答案一")
state.receiveAnswer("答案二", utteranceID: secondUtteranceID)
XCTAssertEqual(state.answer?.text, "答案二")
XCTAssertTrue(state.canInsert)
}
func testCancellationRestoresPreviousAnswerState() {
var state = AISessionState()
let firstUtteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: firstUtteranceID)
state.beginListening(utteranceID: firstUtteranceID)
state.receiveAnswer("可用答案", utteranceID: firstUtteranceID)
let secondUtteranceID = UUID()
state.beginPreparing(utteranceID: secondUtteranceID)
state.updateTranscript("未完成问题", utteranceID: secondUtteranceID)
state.cancelCurrentWork()
XCTAssertEqual(state.phase, .ready)
XCTAssertEqual(state.answer?.text, "可用答案")
XCTAssertEqual(state.transcript, "")
}
func testAnswerRequiresInsertBeforeSend() {
var state = AISessionState()
let utteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: utteranceID)
state.receiveAnswer("答案", utteranceID: utteranceID)
XCTAssertTrue(state.canInsert)
XCTAssertFalse(state.canSend)
state.markAnswerInserted(offersSend: true)
XCTAssertEqual(state.phase, .awaitingSend)
XCTAssertFalse(state.canInsert)
XCTAssertTrue(state.canSend)
XCTAssertEqual(state.answer?.isInserted, true)
state.markAnswerSent()
XCTAssertEqual(state.phase, .sent)
XCTAssertFalse(state.canSend)
XCTAssertEqual(state.answer?.isSent, true)
}
func testNonSendFieldFinishesAfterInsertion() {
var state = AISessionState()
let utteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: utteranceID)
state.receiveAnswer("答案", utteranceID: utteranceID)
state.markAnswerInserted(offersSend: false)
XCTAssertEqual(state.phase, .inserted)
XCTAssertFalse(state.canPerformAnswerAction)
XCTAssertEqual(state.answer?.isInserted, true)
XCTAssertEqual(state.answer?.isSent, false)
}
func testDiscardReadyAnswerKeepsConversationActive() {
var state = AISessionState()
let conversationID = UUID()
let utteranceID = UUID()
state.enter(conversationID: conversationID)
state.beginPreparing(utteranceID: utteranceID)
state.receiveAnswer("光标变化后保留的回答", utteranceID: utteranceID)
state.discardReadyAnswer()
XCTAssertEqual(state.phase, .idle)
XCTAssertEqual(state.conversationID, conversationID)
XCTAssertNil(state.answer)
XCTAssertFalse(state.canInsert)
}
func testCancellationRestoresPendingSendState() {
var state = AISessionState()
let firstUtteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: firstUtteranceID)
state.receiveAnswer("已插入答案", utteranceID: firstUtteranceID)
state.markAnswerInserted(offersSend: true)
let secondUtteranceID = UUID()
state.beginPreparing(utteranceID: secondUtteranceID)
state.cancelCurrentWork()
XCTAssertEqual(state.phase, .awaitingSend)
XCTAssertTrue(state.canSend)
}
func testStaleResultCannotReplaceCurrentAnswer() {
var state = AISessionState()
let currentUtteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: currentUtteranceID)
state.receiveAnswer("迟到答案", utteranceID: UUID())
XCTAssertNil(state.answer)
XCTAssertEqual(state.phase, .preparing)
}
func testPartialAnswerKeepsPreviousCommittedAnswerUntilFinal() {
var state = AISessionState()
let firstUtteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: firstUtteranceID)
state.receiveAnswer("答案一", utteranceID: firstUtteranceID)
let secondUtteranceID = UUID()
state.beginPreparing(utteranceID: secondUtteranceID)
state.beginGenerating(question: "问题二", utteranceID: secondUtteranceID)
state.receivePartialAnswer("草", utteranceID: secondUtteranceID)
XCTAssertEqual(state.phase, .generating)
XCTAssertEqual(state.draftAnswerText, "草")
XCTAssertEqual(state.answer?.text, "答案一")
XCTAssertFalse(state.canInsert)
state.receivePartialAnswer("草稿答案", utteranceID: secondUtteranceID)
XCTAssertEqual(state.draftAnswerText, "草稿答案")
state.receiveAnswer("答案二", utteranceID: secondUtteranceID)
XCTAssertNil(state.draftAnswerText)
XCTAssertEqual(state.answer?.text, "答案二")
XCTAssertTrue(state.canInsert)
}
func testCancelClearsDraftAndRestoresPreviousAnswer() {
var state = AISessionState()
let firstUtteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: firstUtteranceID)
state.receiveAnswer("可用答案", utteranceID: firstUtteranceID)
let secondUtteranceID = UUID()
state.beginPreparing(utteranceID: secondUtteranceID)
state.beginGenerating(question: "新问题", utteranceID: secondUtteranceID)
state.receivePartialAnswer("半截", utteranceID: secondUtteranceID)
state.cancelCurrentWork()
XCTAssertNil(state.draftAnswerText)
XCTAssertEqual(state.phase, .ready)
XCTAssertEqual(state.answer?.text, "可用答案")
}
func testReplyVariantsBecomeReadyWithoutCreatingInsertableAnswer() {
var state = AISessionState()
let utteranceID = UUID()
let variants = makeReplyVariants()
state.enter()
state.beginPreparing(utteranceID: utteranceID)
state.receiveReplyVariants(variants, utteranceID: utteranceID)
XCTAssertEqual(state.phase, .ready)
XCTAssertTrue(state.canSelectReplyVariant)
XCTAssertFalse(state.canInsert)
XCTAssertNil(state.answer)
XCTAssertEqual(state.replyVariants.map(\.kind), [.ordinary, .formal, .playful])
}
func testSelectingReplyVariantExposesSelectionForInsertionAndFeedback() throws {
var state = AISessionState()
let utteranceID = UUID()
let variants = makeReplyVariants()
state.enter()
state.beginPreparing(utteranceID: utteranceID)
state.receiveReplyVariants(variants, utteranceID: utteranceID)
let answer = try XCTUnwrap(
state.selectReplyVariant(id: variants[2].id)
)
XCTAssertEqual(answer.id, variants[2].id)
XCTAssertEqual(answer.text, "轻松回复 🎉")
XCTAssertEqual(state.selectedReplyVariant, variants[2])
XCTAssertTrue(state.canInsert)
XCTAssertFalse(state.canSelectReplyVariant)
state.markAnswerInserted(offersSend: false)
XCTAssertEqual(state.phase, .inserted)
}
func testDiscardReplyVariantsClearsAllCandidateState() {
var state = AISessionState()
let utteranceID = UUID()
state.enter()
state.beginPreparing(utteranceID: utteranceID)
state.receiveReplyVariants(makeReplyVariants(), utteranceID: utteranceID)
state.discardReadyAnswer()
XCTAssertEqual(state.phase, .idle)
XCTAssertTrue(state.replyVariants.isEmpty)
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: "普通回复"),
AIReplyVariant(kind: .formal, emotion: .neutral, text: "正式回复"),
AIReplyVariant(kind: .playful, emotion: .celebratory, text: "轻松回复 🎉")
]
}
}