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:
Rocky
2026-08-13 15:47:32 +08:00
parent 9f308fadd2
commit 631617ee53
48 changed files with 1627 additions and 345 deletions
@@ -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("不要改写成可发送的短消息"))
}
}
+9 -11
View File
@@ -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])
}
}