feat(keyboard): add clipboard AI skills, hint keywords, and voice session fixes
Idle chips show entities with category icons; a fresh copy surfaces Reply/Summarize/Translate; abort/cancel/empty-tap no longer leave the mic stuck.
This commit is contained in:
@@ -5,6 +5,15 @@ import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class AIClipboardPromptTests: XCTestCase {
|
||||
func testInternalClipboardEnvelopeIsDetected() {
|
||||
let prompt = AIClipboardPrompt.compose(
|
||||
instruction: "请翻译剪贴板",
|
||||
material: "hello"
|
||||
)
|
||||
XCTAssertTrue(AIClipboardPrompt.isInternalPrompt(prompt))
|
||||
XCTAssertFalse(AIClipboardPrompt.isInternalPrompt("请翻译剪贴板"))
|
||||
}
|
||||
|
||||
func testComposeKeepsInstructionAndMaterialInSeparateBlocks() {
|
||||
let prompt = AIClipboardPrompt.compose(
|
||||
instruction: "请翻译剪贴板",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// AIHintKeywordExtractorTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class AIHintKeywordExtractorTests: XCTestCase {
|
||||
func testTrendingPrefersMetadataTitleOverHotPrefix() throws {
|
||||
let json = """
|
||||
{"id":"hot-1","text":"全网热点:朱镕基同志逝世","prompt":"请概括","category":"society","source":"tophub-open-hot","locale":"zh","metadata":{"title":"朱镕基同志逝世"}}
|
||||
"""
|
||||
let card = try JSONDecoder().decode(AIHintCard.self, from: Data(json.utf8))
|
||||
XCTAssertEqual(card.visualKind, .trending)
|
||||
XCTAssertEqual(card.visualKind.systemImage, "flame.fill")
|
||||
XCTAssertEqual(card.resolvedDisplayText, "朱镕基同志逝世")
|
||||
}
|
||||
|
||||
func testWeatherUsesCityAndTemperature() {
|
||||
let card = AIHintCard(
|
||||
id: "weather-zh-上海",
|
||||
displayText: "上海天气速览",
|
||||
prompt: "请说明天气",
|
||||
category: "weather",
|
||||
source: "open-meteo",
|
||||
locale: "zh",
|
||||
metadata: AIHintMetadata(city: "上海", tempC: 25.6)
|
||||
)
|
||||
XCTAssertEqual(card.visualKind, .weather)
|
||||
XCTAssertEqual(card.resolvedDisplayText, "上海 26°")
|
||||
}
|
||||
|
||||
func testHolidayStripsPrefixAndUsesChineseName() {
|
||||
let card = AIHintCard(
|
||||
id: "holiday-next-cn",
|
||||
displayText: "临近节日:中秋节",
|
||||
prompt: "介绍中秋",
|
||||
category: "holiday",
|
||||
source: "nager-holidays",
|
||||
locale: "zh",
|
||||
conditions: ["date"],
|
||||
metadata: AIHintMetadata(name: "Mid-Autumn Festival")
|
||||
)
|
||||
XCTAssertEqual(card.visualKind, .calendar)
|
||||
XCTAssertEqual(card.resolvedDisplayText, "中秋节")
|
||||
}
|
||||
|
||||
func testEnglishHolidayUsesMetadataName() {
|
||||
let card = AIHintCard(
|
||||
id: "holiday-next-us",
|
||||
displayText: "Upcoming: Labour Day",
|
||||
prompt: "Explain Labour Day",
|
||||
category: "holiday",
|
||||
source: "nager-holidays",
|
||||
locale: "en",
|
||||
metadata: AIHintMetadata(name: "Labour Day")
|
||||
)
|
||||
XCTAssertEqual(card.resolvedDisplayText, "Labour Day")
|
||||
}
|
||||
|
||||
func testLongOrgTitleFallsBackToLastChunk() {
|
||||
let text = "中共中央 全国人大常委会 国务院 全国政协讣告 朱镕基同志逝世"
|
||||
XCTAssertEqual(
|
||||
AIHintKeywordExtractor.finalize(text, locale: "zh"),
|
||||
"朱镕基同志逝世"
|
||||
)
|
||||
}
|
||||
|
||||
func testMixedTitlePrefersLeadingLatin() {
|
||||
XCTAssertEqual(
|
||||
AIHintKeywordExtractor.finalize(
|
||||
"DeepSeek Pro 正式版已经发布,如何评价该模型?",
|
||||
locale: "zh"
|
||||
),
|
||||
"DeepSeek"
|
||||
)
|
||||
}
|
||||
|
||||
func testDailyBriefIsNewsAndSoulQuoteIsSearch() {
|
||||
let brief = AIHintCard(
|
||||
id: "tophub-daily-brief-1",
|
||||
displayText: "看看今日早报",
|
||||
prompt: "写早报",
|
||||
category: "daily",
|
||||
source: "tophub-daily",
|
||||
locale: "zh"
|
||||
)
|
||||
XCTAssertEqual(brief.visualKind, .news)
|
||||
XCTAssertEqual(brief.resolvedDisplayText, "今日早报")
|
||||
|
||||
let soul = AIHintCard(
|
||||
id: "tophub-daily-soul-1",
|
||||
displayText: "今日一句:展开聊聊",
|
||||
prompt: "解释这句话",
|
||||
category: "daily",
|
||||
source: "tophub-daily",
|
||||
locale: "zh",
|
||||
metadata: AIHintMetadata(soul: "为了防止我这个月又乱花钱")
|
||||
)
|
||||
XCTAssertEqual(soul.visualKind, .search)
|
||||
XCTAssertEqual(soul.resolvedDisplayText, "今日金句")
|
||||
}
|
||||
|
||||
func testCapabilityMapsToSearch() {
|
||||
let card = AIHintLocalCatalog.cards(locale: "zh")
|
||||
.first { $0.id == "local-zh-encyclopedia" }!
|
||||
XCTAssertEqual(card.visualKind, .search)
|
||||
XCTAssertEqual(card.resolvedDisplayText, "有趣概念")
|
||||
}
|
||||
|
||||
func testStocksMapsToChartIcon() {
|
||||
let card = AIHintLocalCatalog.cards(locale: "zh")
|
||||
.first { $0.id == "local-zh-stocks" }!
|
||||
XCTAssertEqual(card.visualKind, .stocks)
|
||||
XCTAssertEqual(card.resolvedDisplayText, "今日大盘")
|
||||
}
|
||||
}
|
||||
|
||||
final class AIClipboardSkillTests: XCTestCase {
|
||||
func testVisibleDefaultsAreReplySummarizeTranslate() {
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.visible().map(\.id),
|
||||
["reply", "summarize", "translate"]
|
||||
)
|
||||
}
|
||||
|
||||
func testVisibleRespectsEnabledIDsForFutureSettings() {
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.visible(enabledIDs: ["translate", "reply"]).map(\.id),
|
||||
["translate", "reply"]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.visible(enabledIDs: ["unknown"]).map(\.id),
|
||||
[]
|
||||
)
|
||||
}
|
||||
|
||||
func testTranslateFollowsUserTargetLanguage() {
|
||||
let ja = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.translateID,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: "ja"
|
||||
)
|
||||
XCTAssertTrue(ja.contains("Japanese"))
|
||||
XCTAssertFalse(ja.contains("互译"))
|
||||
}
|
||||
|
||||
func testTranslateFallsBackToChineseEnglishWhenUnset() {
|
||||
let zh = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.translateID,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||
)
|
||||
XCTAssertTrue(zh.contains("中文与英文"))
|
||||
let en = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.translateID,
|
||||
locale: "en",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||
)
|
||||
XCTAssertTrue(en.lowercased().contains("chinese"))
|
||||
XCTAssertTrue(en.lowercased().contains("english"))
|
||||
}
|
||||
|
||||
func testTranslateButtonTitleUsesDirectionPairsAndTargetShortNames() {
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
uiLanguage: .chinese
|
||||
),
|
||||
"中↔英"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId,
|
||||
uiLanguage: .english
|
||||
),
|
||||
"CN↔EN"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: "en",
|
||||
uiLanguage: .chinese
|
||||
),
|
||||
"中译英"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: "de",
|
||||
uiLanguage: .chinese
|
||||
),
|
||||
"中译德"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: "ja",
|
||||
uiLanguage: .english
|
||||
),
|
||||
"To JP"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: "zh-Hans",
|
||||
uiLanguage: .english
|
||||
),
|
||||
"To CN"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: "zh-Hant",
|
||||
uiLanguage: .english
|
||||
),
|
||||
"To TW"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: "zh-Hans",
|
||||
uiLanguage: .chinese
|
||||
),
|
||||
"简↔繁"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AIClipboardSkillCatalog.translateButtonTitle(
|
||||
translationTargetLocaleId: "zh-Hant",
|
||||
uiLanguage: .chinese
|
||||
),
|
||||
"简↔繁"
|
||||
)
|
||||
}
|
||||
|
||||
func testSummarizeAsksForOverviewNotShortening() {
|
||||
let prompt = AIClipboardSkillCatalog.instruction(
|
||||
skillID: AIClipboardSkillCatalog.summarizeID,
|
||||
locale: "zh",
|
||||
translationTargetLocaleId: TranslationLanguageCatalog.offLocaleId
|
||||
)
|
||||
XCTAssertTrue(prompt.contains("概括"))
|
||||
XCTAssertTrue(prompt.contains("不要改写成可发送的短消息"))
|
||||
}
|
||||
}
|
||||
@@ -12,19 +12,21 @@ final class AIHintPoolTests: XCTestCase {
|
||||
XCTAssertEqual(AIHintLocaleResolver.packLocale(preferredLanguages: ["en-US"]), "en")
|
||||
}
|
||||
|
||||
func testClipboardWindowForcesOnlyClipboardCards() {
|
||||
func testClipboardWindowNeverPutsClipboardCardsInCarousel() {
|
||||
let pack = AIHintPack(
|
||||
locale: "zh",
|
||||
cards: AIHintLocalCatalog.cards(locale: "zh")
|
||||
)
|
||||
let recent = ClipboardHistoryEntry(text: "hello", createdAt: Date())
|
||||
let cards = AIHintPool.activeCards(
|
||||
pack: pack,
|
||||
clipboardHistoryEnabled: true,
|
||||
newestClipboard: recent
|
||||
XCTAssertTrue(
|
||||
AIHintPool.isClipboardSkillWindowActive(
|
||||
clipboardHistoryEnabled: true,
|
||||
newestClipboard: recent
|
||||
)
|
||||
)
|
||||
let cards = AIHintPool.activeCards(pack: pack)
|
||||
XCTAssertFalse(cards.isEmpty)
|
||||
XCTAssertTrue(cards.allSatisfy(\.requiresClipboard30s))
|
||||
XCTAssertTrue(cards.allSatisfy { !$0.requiresClipboard30s })
|
||||
}
|
||||
|
||||
func testClipboardDisabledDropsClipboardCards() {
|
||||
@@ -32,11 +34,7 @@ final class AIHintPoolTests: XCTestCase {
|
||||
locale: "zh",
|
||||
cards: AIHintLocalCatalog.cards(locale: "zh")
|
||||
)
|
||||
let cards = AIHintPool.activeCards(
|
||||
pack: pack,
|
||||
clipboardHistoryEnabled: false,
|
||||
newestClipboard: ClipboardHistoryEntry(text: "hello")
|
||||
)
|
||||
let cards = AIHintPool.activeCards(pack: pack)
|
||||
XCTAssertFalse(cards.isEmpty)
|
||||
XCTAssertTrue(cards.allSatisfy { !$0.requiresClipboard30s })
|
||||
}
|
||||
|
||||
@@ -188,6 +188,28 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
|
||||
),
|
||||
.none
|
||||
)
|
||||
|
||||
let processing = FlowReadySnapshot(
|
||||
sessionId: sessionId,
|
||||
ready: false,
|
||||
reason: .processing,
|
||||
engineMode: "cloud",
|
||||
localeId: "zh-Hans",
|
||||
busyUtteranceId: utteranceId,
|
||||
hostGeneration: "gen-1"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FlowKeyboardAdoptBusyPolicy.decide(
|
||||
snapshot: processing,
|
||||
currentHostGeneration: "gen-1",
|
||||
isFlowRecording: false,
|
||||
isAwaitingFlowResult: false,
|
||||
lastConsumedUtteranceId: nil,
|
||||
lastStoppedUtteranceId: utteranceId
|
||||
),
|
||||
.none,
|
||||
"an aborted utterance must not come back as 识别中 on the next open"
|
||||
)
|
||||
}
|
||||
|
||||
func testIgnoresDeadHostGenerationSnapshot() {
|
||||
@@ -234,6 +256,138 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testStaleDeliveredProcessingRequiresMatchingAckWithoutResult() {
|
||||
let busyId = UUID()
|
||||
let sessionId = UUID()
|
||||
XCTAssertFalse(
|
||||
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
||||
busyUtteranceId: busyId,
|
||||
latestResult: nil,
|
||||
latestAck: nil
|
||||
),
|
||||
"live ASR/LLM has no result and no ack yet — must wait, not abort"
|
||||
)
|
||||
|
||||
let otherAck = FlowAck(
|
||||
sessionId: sessionId,
|
||||
utteranceId: UUID(),
|
||||
commandSeq: 1
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
||||
busyUtteranceId: busyId,
|
||||
latestResult: nil,
|
||||
latestAck: otherAck
|
||||
),
|
||||
"ack for a previous utterance must not abort the live one"
|
||||
)
|
||||
|
||||
let matchingAck = FlowAck(
|
||||
sessionId: sessionId,
|
||||
utteranceId: busyId,
|
||||
commandSeq: 1
|
||||
)
|
||||
XCTAssertTrue(
|
||||
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
||||
busyUtteranceId: busyId,
|
||||
latestResult: nil,
|
||||
latestAck: matchingAck
|
||||
),
|
||||
"acked + empty mailbox + still processing is a leaked gate"
|
||||
)
|
||||
|
||||
let liveResult = FlowResult(
|
||||
sessionId: sessionId,
|
||||
utteranceId: busyId,
|
||||
commandSeq: 1,
|
||||
status: .streaming,
|
||||
text: "draft"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
||||
busyUtteranceId: busyId,
|
||||
latestResult: liveResult,
|
||||
latestAck: matchingAck
|
||||
),
|
||||
"result still sitting for this utterance is waitable, not stale"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowKeyboardAdoptBusyPolicy.isStaleDeliveredProcessing(
|
||||
busyUtteranceId: UUID(),
|
||||
latestResult: liveResult,
|
||||
latestAck: matchingAck
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testTerminalStorePolicyRejectsAbortAndReplacement() {
|
||||
let live = UUID()
|
||||
XCTAssertTrue(
|
||||
FlowTerminalStorePolicy.canStore(
|
||||
currentUtteranceId: live,
|
||||
finishedUtteranceId: live,
|
||||
alreadyTerminal: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowTerminalStorePolicy.canStore(
|
||||
currentUtteranceId: nil,
|
||||
finishedUtteranceId: live,
|
||||
alreadyTerminal: false
|
||||
),
|
||||
"abort cleared currentUtteranceId — do not deliver"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowTerminalStorePolicy.canStore(
|
||||
currentUtteranceId: UUID(),
|
||||
finishedUtteranceId: live,
|
||||
alreadyTerminal: false
|
||||
),
|
||||
"a newer utterance owns the gate"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowTerminalStorePolicy.canStore(
|
||||
currentUtteranceId: live,
|
||||
finishedUtteranceId: live,
|
||||
alreadyTerminal: true
|
||||
),
|
||||
"already terminal — abort already claimed"
|
||||
)
|
||||
}
|
||||
|
||||
func testAckGateDropsProcessingOnlyForTheLiveUtterance() {
|
||||
let live = UUID()
|
||||
XCTAssertTrue(
|
||||
FlowHostAckGatePolicy.shouldDropProcessingGate(
|
||||
ackUtteranceId: live,
|
||||
currentUtteranceId: live,
|
||||
isUtteranceProcessing: true
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowHostAckGatePolicy.shouldDropProcessingGate(
|
||||
ackUtteranceId: live,
|
||||
currentUtteranceId: live,
|
||||
isUtteranceProcessing: false
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowHostAckGatePolicy.shouldDropProcessingGate(
|
||||
ackUtteranceId: live,
|
||||
currentUtteranceId: UUID(),
|
||||
isUtteranceProcessing: true
|
||||
),
|
||||
"must not clobber a newer utterance"
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowHostAckGatePolicy.shouldDropProcessingGate(
|
||||
ackUtteranceId: live,
|
||||
currentUtteranceId: nil,
|
||||
isUtteranceProcessing: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Result matcher
|
||||
|
||||
func testMatchingResultRequiresAlignedSessionAndUtterance() {
|
||||
@@ -371,3 +525,58 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty tap skip
|
||||
|
||||
extension FlowKeyboardPoliciesTests {
|
||||
func testEmptyTapSkipRequiresShortDuration() {
|
||||
XCTAssertTrue(
|
||||
FlowEmptyTapSkipPolicy.shouldSkip(
|
||||
durationSeconds: 0.2,
|
||||
sampleCount: 0,
|
||||
peakAmplitude: nil
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(
|
||||
FlowEmptyTapSkipPolicy.shouldSkip(
|
||||
durationSeconds: 0.35,
|
||||
sampleCount: 0,
|
||||
peakAmplitude: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyTapSkipTreatsMissingSamplesAsSilence() {
|
||||
XCTAssertTrue(
|
||||
FlowEmptyTapSkipPolicy.shouldSkip(
|
||||
durationSeconds: 0.1,
|
||||
sampleCount: 16,
|
||||
peakAmplitude: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyTapSkipKeepsShortSpeech() {
|
||||
XCTAssertFalse(
|
||||
FlowEmptyTapSkipPolicy.shouldSkip(
|
||||
durationSeconds: 0.2,
|
||||
sampleCount: 3_200,
|
||||
peakAmplitude: 0.2
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testEmptyTapSkipDropsShortSilence() {
|
||||
XCTAssertTrue(
|
||||
FlowEmptyTapSkipPolicy.shouldSkip(
|
||||
durationSeconds: 0.2,
|
||||
sampleCount: 3_200,
|
||||
peakAmplitude: 0.001
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FlowEmptyTapSkipPolicy.peakAbs([0.001, -0.004, 0.002]),
|
||||
0.004
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,4 +20,12 @@ final class FlowUtterancePCMStoreTests: XCTestCase {
|
||||
store.append([1, 2, 3, 4, 5])
|
||||
XCTAssertEqual(store.consume(), [2, 3, 4, 5])
|
||||
}
|
||||
|
||||
func testSnapshotDoesNotClear() {
|
||||
let store = FlowUtterancePCMStore(maxSampleCount: 100)
|
||||
store.append([0.1, -0.2])
|
||||
XCTAssertEqual(store.snapshot(), [0.1, -0.2])
|
||||
XCTAssertEqual(store.sampleCount, 2)
|
||||
XCTAssertEqual(store.consume(), [0.1, -0.2])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user