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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user