feat(polish): allow mood emoji on custom styles and ship Flow/ASR fixes

Custom polish styles can opt in to emotion-matched emoji (default off), with
prompt-level opt-in detection so paste-only styles keep model-added emoji.
Also include Volcengine API-Key ASR auth, voice-processing capture, PiP flash
fix, and related keyboard Shift/haptics reliability work.
This commit is contained in:
Rocky
2026-08-06 15:11:12 +08:00
parent 2ed16e1fbf
commit a8d58d8f0c
47 changed files with 1467 additions and 258 deletions
+67 -2
View File
@@ -130,7 +130,10 @@ final class CloudASRTests: XCTestCase {
let fields = VolcengineASRFields.parse(apiKey: json, resourceFallback: "")
XCTAssertEqual(fields.appID, "app-1")
XCTAssertEqual(fields.accessToken, "tok-2")
XCTAssertEqual(fields.resourceID, "res-3")
XCTAssertEqual(fields.authMode, .appToken)
// Custom resource IDs are ignored; product is locked to SAUC 2.0 duration.
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
XCTAssertTrue(fields.hasUsableCredentials)
}
func testVolcengineASRFieldsColonParsing() {
@@ -140,8 +143,70 @@ final class CloudASRTests: XCTestCase {
)
XCTAssertEqual(fields.appID, "app-1")
XCTAssertEqual(fields.accessToken, "tok-2")
XCTAssertEqual(fields.resourceID, "res-3")
XCTAssertEqual(fields.authMode, .appToken)
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
XCTAssertTrue(fields.encodedAPIKey.contains("app-1"))
XCTAssertTrue(fields.encodedAPIKey.contains("auth_mode"))
}
func testVolcengineASRFieldsAPIKeyModeParsing() {
let json = #"{"auth_mode":"api_key","api_key":"vk-new-console"}"#
let fields = VolcengineASRFields.parse(apiKey: json)
XCTAssertEqual(fields.authMode, .apiKey)
XCTAssertEqual(fields.apiKeyCredential, "vk-new-console")
XCTAssertTrue(fields.hasUsableCredentials)
XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
var request = URLRequest(url: URL(string: "wss://example.invalid")!)
fields.applyWebSocketAuthHeaders(to: &request, connectID: "conn-1")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Key"), "vk-new-console")
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-App-Key"))
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-Access-Key"))
XCTAssertEqual(
request.value(forHTTPHeaderField: "X-Api-Resource-Id"),
VolcengineASRFields.fixedResourceID
)
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Connect-Id"), "conn-1")
}
func testVolcengineASRFieldsAppTokenHeaders() {
let fields = VolcengineASRFields(
authMode: .appToken,
appID: "app-1",
accessToken: "tok-2"
)
var request = URLRequest(url: URL(string: "wss://example.invalid")!)
fields.applyWebSocketAuthHeaders(to: &request, connectID: "conn-2")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-App-Key"), "app-1")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Access-Key"), "tok-2")
XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-Key"))
XCTAssertEqual(
request.value(forHTTPHeaderField: "X-Api-Resource-Id"),
VolcengineASRFields.fixedResourceID
)
}
func testVolcengineASRFieldsTogglePreservesBothCredentialSets() {
var fields = VolcengineASRFields(
authMode: .appToken,
appID: "app-1",
accessToken: "tok-2",
apiKeyCredential: "vk-keep"
)
fields.authMode = .apiKey
let encoded = fields.encodedAPIKey
let parsed = VolcengineASRFields.parse(apiKey: encoded)
XCTAssertEqual(parsed.authMode, .apiKey)
XCTAssertEqual(parsed.apiKeyCredential, "vk-keep")
XCTAssertEqual(parsed.appID, "app-1")
XCTAssertEqual(parsed.accessToken, "tok-2")
}
func testVolcengineASRFieldsEmptyAPIKeyModeIsNotUsable() {
let json = #"{"auth_mode":"api_key"}"#
let fields = VolcengineASRFields.parse(apiKey: json)
XCTAssertEqual(fields.authMode, .apiKey)
XCTAssertFalse(fields.hasUsableCredentials)
}
func testPersonalDictionaryASRHotwordsDedupesTerms() {
@@ -42,6 +42,90 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
XCTAssertTrue(warming)
}
func testRecentReadyGraceDoesNotForcePreparingSession() {
// withinReadyGrace used to OR into warming and flash yellow after each
// utterance; sticky ready must hold green instead.
let warming = FlowKeyboardHostWarming.isHostWarming(
hostReady: false,
hostBusy: false,
sessionActive: true,
hostReachable: false,
isPendingFlowStart: false,
withinReadyGrace: true,
snapshotReason: .starting
)
// Still warming via reason=.starting when not yet proven ready on the
// keyboard side; grace alone must not be the trigger.
XCTAssertTrue(warming)
XCTAssertTrue(
FlowKeyboardHostWarming.shouldHoldReady(
hostReady: false,
hostBusy: false,
sessionActive: true,
sessionProvenReady: true,
isPendingFlowStart: false,
snapshotReason: .starting
),
"proven-ready session must hold green through starting flaps"
)
XCTAssertTrue(
FlowKeyboardHostWarming.shouldHoldReady(
hostReady: false,
hostBusy: true,
sessionActive: true,
sessionProvenReady: true,
isPendingFlowStart: false,
snapshotReason: .awaitingDelivery
),
"ack lag awaitingDelivery must not drop to preparingSession"
)
XCTAssertFalse(
FlowKeyboardHostWarming.shouldHoldReady(
hostReady: false,
hostBusy: true,
sessionActive: true,
sessionProvenReady: true,
isPendingFlowStart: false,
snapshotReason: .recording
),
"live recording must not be masked as sticky ready"
)
XCTAssertFalse(
FlowKeyboardHostWarming.shouldHoldReady(
hostReady: false,
hostBusy: false,
sessionActive: true,
sessionProvenReady: false,
isPendingFlowStart: true,
snapshotReason: .starting
),
"cold start pending must still show preparing"
)
}
func testHeldReadySuppressesWarming() {
let hold = FlowKeyboardHostWarming.shouldHoldReady(
hostReady: false,
hostBusy: false,
sessionActive: true,
sessionProvenReady: true,
isPendingFlowStart: false,
snapshotReason: .starting
)
XCTAssertTrue(hold)
let warming = FlowKeyboardHostWarming.isHostWarming(
hostReady: true, // effective ready after hold
hostBusy: false,
sessionActive: true,
hostReachable: true,
isPendingFlowStart: false,
withinReadyGrace: true,
snapshotReason: .starting
)
XCTAssertFalse(warming)
}
// MARK: - Adopt busy
func testReAdoptsRecordingAfterExtensionProcessLoss() {
@@ -220,4 +220,14 @@ final class FlowReliabilityTests: XCTestCase {
)
)
}
func testCaptureVoiceProcessingUsesSpeechOrientedSessionMode() {
XCTAssertEqual(FlowCaptureVoiceProcessing.captureMode, .voiceChat)
XCTAssertTrue(
FlowCaptureVoiceProcessing.captureOptions.contains(.allowBluetoothHFP)
)
XCTAssertTrue(
FlowCaptureVoiceProcessing.captureOptions.contains(.defaultToSpeaker)
)
}
}
@@ -428,6 +428,41 @@ final class FlowSessionBridgeTests: XCTestCase {
)
}
func testHostHeavyClearsOnFlowStateReset() {
let defaults = makeDefaults()
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostHeavy(defaults: defaults))
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults)
FlowSessionBridge.clearFlowStateOnHostLaunch(defaults: defaults)
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
XCTAssertEqual(
FlowSessionBridge.pendingHostBundleId(defaults: defaults),
"com.example.host"
)
}
func testStaleHostHeavyDoesNotBlockTyping() {
let defaults = makeDefaults()
// Legacy sticky bool with no timestamp must not brick /EN.
defaults.set(true, forKey: FlowSessionKeys.hostHeavy)
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
FlowSessionBridge.setHostHeavy(true, defaults: defaults)
XCTAssertTrue(FlowSessionBridge.isHostHeavy(defaults: defaults))
// Expired timestamp treat as clear so cold keyboard can switch.
let expired = Date().timeIntervalSince1970 - FlowSessionKeys.hostHeavyMaxAge - 1
defaults.set(expired, forKey: FlowSessionKeys.hostHeavyAt)
XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
}
func testRotateHostGenerationReturnsPreviousToken() {
let defaults = makeDefaults()
XCTAssertNil(FlowSessionBridge.rotateHostGeneration(defaults: defaults))
@@ -340,6 +340,54 @@ final class IntelligentPolishTests: XCTestCase {
XCTAssertTrue(result.contains("完成"))
}
func testPolishServiceKeepsAddedEmojiWhenStyleAllows() async throws {
store.setEngineMode("local")
var catalog = PolishStyleCatalog()
let pack = PolishStylePack(
id: "user.emoji",
name: "Emoji",
prompt: "保持口语,可按情绪加 emoji。",
allowsAddedEmoji: true
)
try catalog.upsert(pack)
store.setPolishStyleCatalog(catalog)
store.setActivePolishStyleId(pack.id)
let emojiClient = FixedResponseLLMClient(response: "今天太开心了,终于搞定了😆")
let service = PolishingService(store: store, client: emojiClient)
let result = try await service.polish(
"今天太开心了终于搞定了",
context: PolishContext()
)
XCTAssertTrue(result.contains("😆"), "Allowed-emoji styles must keep model-added emoji. Got: \(result)")
XCTAssertTrue(result.contains("开心"))
}
func testPolishServiceKeepsAddedEmojiWhenPromptOptsInWithoutToggle() async throws {
store.setEngineMode("local")
var catalog = PolishStyleCatalog()
let pack = PolishStylePack(
id: "user.paste-emoji",
name: "PasteEmoji",
prompt: "本风格允许新增 emoji。按情绪点缀合适表情。",
allowsAddedEmoji: false
)
try catalog.upsert(pack)
store.setPolishStyleCatalog(catalog)
store.setActivePolishStyleId(pack.id)
let emojiClient = FixedResponseLLMClient(response: "辛苦你了,真的谢谢🙏")
let service = PolishingService(store: store, client: emojiClient)
let result = try await service.polish(
"辛苦你了真的谢谢",
context: PolishContext()
)
XCTAssertTrue(
result.contains("🙏"),
"Prompt opt-in must keep emoji even when toggle is off. Got: \(result)"
)
}
func testPolishServiceFallsBackWhenOutputEmpty() async throws {
store.setEngineMode("local")
let emptyClient = FixedResponseLLMClient(response: " ")
@@ -410,6 +458,30 @@ final class IntelligentPolishTests: XCTestCase {
XCTAssertEqual(result, "好的")
}
func testQualityGateKeepsAddedEmojiWhenAllowed() {
let decision = TranscriptPostProcessor.qualityGate(
original: "今天太开心了",
candidate: "今天太开心了😆",
allowsAddedEmoji: true
)
guard case .accept(let text) = decision else {
return XCTFail("Expected accept")
}
XCTAssertTrue(text.contains("😆"))
}
func testQualityGateStripsAddedEmojiByDefault() {
let decision = TranscriptPostProcessor.qualityGate(
original: "今天太开心了",
candidate: "今天太开心了😆",
allowsAddedEmoji: false
)
guard case .accept(let text) = decision else {
return XCTFail("Expected accept")
}
XCTAssertFalse(text.contains("😆"))
}
func testQualityGateStripsResidualPauseMarkers() {
let result = TranscriptPostProcessor.process(
original: "第一段 ⟨0.8s⟩ 第二段",
@@ -206,6 +206,90 @@ final class PolishStylePackTests: XCTestCase {
XCTAssertTrue(prompt.contains("词典命中优先于同音猜测"))
XCTAssertTrue(prompt.contains("风格接入(纠错之后)"))
XCTAssertFalse(prompt.contains("原始内容"))
XCTAssertFalse(prompt.contains("Emoji 覆盖"))
}
func testComposerInjectsEmojiOverrideWhenStyleAllows() {
let style = PolishStylePack(
id: "user.emoji",
name: "Emoji",
prompt: "ROLE",
allowsAddedEmoji: true
)
let prompt = PolishPromptComposer.compose(
text: "今天太开心了",
style: style,
context: PolishContext(),
dictionaryBlock: "",
useChineseGuidance: true
)
XCTAssertTrue(prompt.contains("Emoji 覆盖(本风格开启 · 最终优先级)"))
XCTAssertTrue(prompt.contains("优先级高于全局 R5"))
// Override must appear after core R5 so it wins.
let r5 = prompt.range(of: "R5 不新增 emoji")
let override = prompt.range(of: "Emoji 覆盖(本风格开启 · 最终优先级)")
XCTAssertNotNil(r5)
XCTAssertNotNil(override)
XCTAssertLessThan(r5!.lowerBound, override!.lowerBound)
}
func testPromptOptInEnablesEmojiWithoutToggle() async throws {
let style = PolishStylePack(
id: "user.paste",
name: "Paste",
prompt: """
# 最高优先级覆盖
本风格允许新增 emoji。当与全局「不新增 emoji」规则冲突时,以本风格为准。
""",
allowsAddedEmoji: false
)
XCTAssertTrue(style.effectiveAllowsAddedEmoji)
let composed = PolishPromptComposer.compose(
text: "今天太开心了",
style: style,
context: PolishContext(),
dictionaryBlock: "",
useChineseGuidance: true
)
XCTAssertTrue(composed.contains("Emoji 覆盖(本风格开启 · 最终优先级)"))
}
func testLegacyPackDecodeDefaultsAllowsAddedEmojiToFalse() throws {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
// Encode without the new key by decoding a minimal legacy payload.
let legacyJSON = """
{
"id": "user.legacy",
"name": "Legacy",
"prompt": "ROLE",
"kind": "user",
"createdAt": 0,
"updatedAt": 0
}
"""
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
let pack = try decoder.decode(PolishStylePack.self, from: Data(legacyJSON.utf8))
XCTAssertFalse(pack.allowsAddedEmoji)
}
func testUpsertPreservesAllowsAddedEmoji() throws {
var catalog = PolishStyleCatalog()
let pack = PolishStylePack(
name: "Emoji",
prompt: "ROLE",
allowsAddedEmoji: true
)
try catalog.upsert(pack)
XCTAssertEqual(catalog.entries.first?.allowsAddedEmoji, true)
var updated = pack
updated.prompt = "ROLE 2"
try catalog.upsert(updated)
XCTAssertEqual(catalog.entries.first?.allowsAddedEmoji, true)
XCTAssertEqual(catalog.entries.first?.prompt, "ROLE 2")
}
func testComposerKeepsHomophoneRepairWhenDictionaryPresent() {