feat: add streaming cloud ASR, polish routing, and settings card layout

Unify Bailian/Volcengine/OpenAI realtime streaming, ABE polish routing with
fun styles, and a shared card-page Settings hierarchy; bump to 1.1 (build 32).
This commit is contained in:
Rocky
2026-07-28 20:25:17 +08:00
parent 956331a2af
commit d656bac8c3
58 changed files with 4153 additions and 1396 deletions
@@ -1,230 +0,0 @@
// ChunkedUtterancePipelineTests.swift
// OSGKeyboardTests
import XCTest
import os
@testable import OSGKeyboardShared
private struct StubChunkASR: ASRService, @unchecked Sendable {
let labels: @Sendable ([Float]) -> String
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { $0.finish() }
}
func cancel() {}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
_ = locale
return .success(labels(samples))
}
}
final class ChunkedUtterancePipelineTests: XCTestCase {
func testPipelineStitchesQueuedChunks() async {
let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 0.05,
overlapDurationSeconds: 0,
pauseExtensionMaxSeconds: 0,
pauseRMSThreshold: 0.02,
sampleRate: 1_000
)
let asr = StubChunkASR { samples in
samples.isEmpty ? "" : "seg\(samples.count)"
}
let pipeline = ChunkedUtterancePipeline(
asr: asr,
locale: Locale(identifier: "zh-Hans"),
config: config
)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.finish()
let partialsLock = OSAllocatedUnfairLock(initialState: [String]())
let outcome = await pipeline.transcribe(stream: stream) { partial in
partialsLock.withLock { $0.append(partial) }
}
let partials = partialsLock.withLock { $0 }
guard case .success(let success) = outcome else {
return XCTFail("expected success, got \(outcome)")
}
XCTAssertTrue(success.text.contains("seg"))
XCTAssertFalse(partials.isEmpty)
}
func testPipelineDeliversPartialSuccessWhenOneChunkFails() async {
let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 0.05,
overlapDurationSeconds: 0,
pauseExtensionMaxSeconds: 0,
pauseRMSThreshold: 0.02,
sampleRate: 1_000
)
let pipeline = ChunkedUtterancePipeline(
asr: FailingSecondChunkASR(),
locale: Locale(identifier: "zh-Hans"),
config: config
)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.finish()
let outcome = await pipeline.transcribe(stream: stream) { _ in }
guard case .success(let success) = outcome else {
return XCTFail("expected partial success, got \(outcome)")
}
XCTAssertFalse(success.text.isEmpty)
XCTAssertEqual(success.chunkWarnings.count, 1)
}
func testPipelineRetranscribesShortFinalChunkWithPriorOverlap() async {
let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 0.05,
overlapDurationSeconds: 10,
pauseExtensionMaxSeconds: 0,
pauseRMSThreshold: 0.02,
minFinalChunkDurationSeconds: 0.05,
sampleRate: 1_000
)
let asr = ShortFinalMergeStubASR()
let pipeline = ChunkedUtterancePipeline(
asr: asr,
locale: Locale(identifier: "zh-Hans"),
config: config
)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 20), sampleRate: 1_000))
continuation.finish()
let outcome = await pipeline.transcribe(stream: stream) { _ in }
guard case .success(let success) = outcome else {
return XCTFail("expected success, got \(outcome)")
}
XCTAssertTrue(success.text.contains("merged"))
}
func testPipelineRetriesEmptyFinalChunkWithOverlap() async {
let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 0.05,
overlapDurationSeconds: 10,
pauseExtensionMaxSeconds: 0,
pauseRMSThreshold: 0.02,
minFinalChunkDurationSeconds: 0.05,
sampleRate: 1_000
)
let asr = EmptyFinalRetryStubASR()
let pipeline = ChunkedUtterancePipeline(
asr: asr,
locale: Locale(identifier: "zh-Hans"),
config: config
)
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
continuation.finish()
let outcome = await pipeline.transcribe(stream: stream) { _ in }
guard case .success(let success) = outcome else {
return XCTFail("expected success, got \(outcome)")
}
XCTAssertTrue(success.text.contains("recovered-tail"))
}
}
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { $0.finish() }
}
func cancel() {}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
_ = locale
let current = callIndex.withLock { state in
let value = state
state += 1
return value
}
if current == 1 {
return .failure("simulated chunk error")
}
return .success("seg\(samples.count)")
}
}
private struct ShortFinalMergeStubASR: ASRService, @unchecked Sendable {
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { $0.finish() }
}
func cancel() {}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
_ = locale
let current = callIndex.withLock { state in
let value = state
state += 1
return value
}
if current == 0 {
return .success("head")
}
if samples.count > 20 {
return .success("merged-tail")
}
return .success("short")
}
}
private struct EmptyFinalRetryStubASR: ASRService, @unchecked Sendable {
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { $0.finish() }
}
func cancel() {}
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
_ = locale
let current = callIndex.withLock { state in
let value = state
state += 1
return value
}
if current == 0 {
return .success("head")
}
if current == 1 {
return .success("")
}
return .success("recovered-tail")
}
}
+21 -2
View File
@@ -10,7 +10,7 @@ final class CloudASRTests: XCTestCase {
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .localFallback)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "bailian"), .bailianStreaming)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .openaiRealtimeStreaming)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "whisper"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "mimo"), .prompt)
XCTAssertEqual(CloudASRModelCatalog.strategy(for: "groq"), .prompt)
@@ -25,7 +25,7 @@ final class CloudASRTests: XCTestCase {
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "bailian"), "fun-asr-realtime")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "zhipu"), "glm-asr-2512")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "mimo"), "mimo-v2.5-asr")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-4o-mini-transcribe")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-realtime-whisper")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "whisper"), "whisper-1")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "groq"), "whisper-large-v3-turbo")
XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "siliconflow"), "FunAudioLLM/SenseVoiceSmall")
@@ -65,6 +65,25 @@ final class CloudASRTests: XCTestCase {
XCTAssertFalse(LLMProvider.provider(id: "moonshot").supportsPersonalDictionaryCloudASR)
}
func testTrueStreamingASRProviders() {
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "bailian"))
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "volcengine"))
XCTAssertTrue(CloudASRModelCatalog.supportsTrueStreamingASR(for: "openai"))
XCTAssertTrue(LLMProvider.provider(id: "bailian").supportsStreamingCloudASR)
XCTAssertTrue(LLMProvider.provider(id: "volcengine").supportsStreamingCloudASR)
XCTAssertTrue(LLMProvider.provider(id: "openai").supportsStreamingCloudASR)
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "mimo"))
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "zhipu"))
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "groq"))
XCTAssertFalse(CloudASRModelCatalog.supportsTrueStreamingASR(for: "whisper"))
}
func testUpsample16kTo24kPreservesDurationRatio() {
let input = [Float](repeating: 0.25, count: 1_600) // 100 ms @ 16 kHz
let output = CloudASRStreamingPCM.upsample16kTo24k(input)
XCTAssertEqual(output.count, 2_400) // 100 ms @ 24 kHz
}
func testShowsASREndpointField() {
XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "bailian"))
XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "openai"))
@@ -1,74 +0,0 @@
// FinalChunkRecoveryTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class FinalChunkRecoveryTests: XCTestCase {
private let config = FlowUtteranceChunkConfig(
maxChunkDurationSeconds: 5.0,
overlapDurationSeconds: 0.5,
pauseExtensionMaxSeconds: 2,
pauseRMSThreshold: 0.015,
minFinalChunkDurationSeconds: 0.8,
sampleRate: 16_000
)
func testPreMergePlanForShortFinalChunk() {
let chunk = UtteranceAudioChunk(
index: 1,
samples: [Float](repeating: 0.1, count: 4_000),
isLast: true
)
let previous = [Float](repeating: 0.2, count: 80_000)
let plan = FinalChunkRecovery.preMergePlan(
chunk: chunk,
processedChunks: 2,
previousChunkSamples: previous,
config: config
)
XCTAssertNotNil(plan)
XCTAssertGreaterThan(plan?.samples.count ?? 0, chunk.samples.count)
XCTAssertEqual(plan?.stitchIndex, 0)
}
func testEmptyResultRetryPlanUsesOverlapWhenPriorChunkExists() {
let chunk = UtteranceAudioChunk(
index: 1,
samples: [Float](repeating: 0.1, count: 20_000),
isLast: true
)
let previous = [Float](repeating: 0.2, count: 80_000)
let plan = FinalChunkRecovery.emptyResultRetryPlan(
chunk: chunk,
previousChunkSamples: previous,
config: config,
asrText: " "
)
XCTAssertNotNil(plan)
XCTAssertGreaterThan(plan?.samples.count ?? 0, chunk.samples.count)
}
func testEmptyResultRetryPlanRetriesSingleChunkSamples() {
let chunk = UtteranceAudioChunk(
index: 0,
samples: [Float](repeating: 0.1, count: 20_000),
isLast: true
)
let plan = FinalChunkRecovery.emptyResultRetryPlan(
chunk: chunk,
previousChunkSamples: [],
config: config,
asrText: ""
)
XCTAssertEqual(plan?.samples.count, chunk.samples.count)
XCTAssertEqual(plan?.stitchIndex, 0)
}
}
@@ -256,6 +256,20 @@ final class IntelligentPolishTests: XCTestCase {
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "明天见"))
}
func testShouldSkipLLMTier2ForAckClosings() {
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "好的我知道了"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "那就先这样吧"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "晚点再说"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "收到谢谢"))
}
func testShouldNotSkipLLMTier2ForQuestionsOrContent() {
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "今晚有空吗"))
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "这个还行吧"))
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "周六一起吃饭"))
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "防晒不由夏天"))
}
func testShouldNotSkipLLMWhenStructurePresent() {
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "第一点做完第二点再做"))
}
+136
View File
@@ -0,0 +1,136 @@
// PolishRouterTests.swift
// OSGKeyboard · Tests
//
// Locks ABE routing: sparse gate (A), prompt hard-brakes (B), and
// style-specific degradation (E) without calling a real LLM.
import XCTest
@testable import OSGKeyboardShared
final class PolishRouterTests: XCTestCase {
func testSparseShortForcesConservativeLightForFunStyles() {
let decision = PolishRouter.decide(
text: "这个还行吧",
styleID: "builtin.xhs",
intensity: .heavy
)
XCTAssertEqual(decision.mode, .conservative)
XCTAssertEqual(decision.effectiveIntensity, .light)
XCTAssertEqual(decision.effectiveStyleID, "builtin.xhs")
XCTAssertTrue(decision.reasons.contains("A:sparse"))
}
func testDibaWithoutOpponentFallsBackToChat() {
let decision = PolishRouter.decide(
text: "不是这样的",
styleID: "builtin.diba",
intensity: .heavy
)
XCTAssertEqual(decision.mode, .chatFallback)
XCTAssertEqual(decision.effectiveStyleID, "builtin.chat")
XCTAssertEqual(decision.effectiveIntensity, .light)
XCTAssertTrue(decision.reasons.contains("E:diba_no_opponent"))
}
func testDibaWithOpponentQuoteStaysFull() {
let decision = PolishRouter.decide(
text: "回他你这叫为你好那对方不同意你还要强行是吧",
styleID: "builtin.diba",
intensity: .heavy
)
XCTAssertEqual(decision.mode, .full)
XCTAssertEqual(decision.effectiveStyleID, "builtin.diba")
XCTAssertEqual(decision.effectiveIntensity, .heavy)
}
func testDatingSparseForcesConservative() {
let decision = PolishRouter.decide(
text: "还行吧",
styleID: "builtin.dating",
intensity: .heavy
)
XCTAssertEqual(decision.mode, .conservative)
XCTAssertEqual(decision.effectiveIntensity, .light)
XCTAssertTrue(decision.reasons.contains("E:dating_short_no_flirt"))
}
func testDatingInviteQuestionStaysFull() {
let decision = PolishRouter.decide(
text: "今晚有空吗",
styleID: "builtin.dating",
intensity: .heavy
)
XCTAssertEqual(decision.mode, .full)
XCTAssertEqual(decision.effectiveIntensity, .heavy)
}
func testChatSparseForcesConservativeNoReply() {
let decision = PolishRouter.decide(
text: "没事",
styleID: "builtin.chat",
intensity: .medium
)
XCTAssertEqual(decision.mode, .conservative)
XCTAssertEqual(decision.effectiveIntensity, .light)
XCTAssertTrue(decision.reasons.contains("E:chat_no_reply"))
}
func testFormalKeepsFullEvenWhenShort() {
let decision = PolishRouter.decide(
text: "收到",
styleID: "builtin.formal",
intensity: .heavy
)
XCTAssertEqual(decision.mode, .full)
XCTAssertEqual(decision.effectiveIntensity, .heavy)
}
func testContentfulMediumStaysFullForXHS() {
let decision = PolishRouter.decide(
text: "这款防晒霜我用了不油夏天可以推荐",
styleID: "builtin.xhs",
intensity: .heavy
)
XCTAssertEqual(decision.mode, .full)
XCTAssertEqual(decision.effectiveIntensity, .heavy)
}
func testPromptBlockIncludesHardBrakeForFunStyles() {
let block = PolishRouter.promptBlock(
mode: .conservative,
styleID: "builtin.xhs",
useChineseGuidance: true
)
XCTAssertTrue(block.contains("信息不足时的硬刹车"))
XCTAssertTrue(block.contains("本次模式:保守清理"))
XCTAssertTrue(block.contains("小红书专属降级"))
}
func testComposerInjectsRoutingBlock() {
let style = PolishStylePackCatalog.resolve(
id: "builtin.dating",
userCatalog: .empty
)
let prompt = PolishPromptComposer.compose(
text: "还行",
style: style,
context: PolishContext(intensity: .light),
dictionaryBlock: "",
globalContract: "GLOBAL",
useChineseGuidance: true,
routingMode: .conservative
)
XCTAssertTrue(prompt.contains("信息不足时的硬刹车"))
XCTAssertTrue(prompt.contains("直男癌专属降级"))
XCTAssertTrue(prompt.contains("本次模式:保守清理"))
}
func testIsInformationSparseDetectsHollowShorts() {
XCTAssertTrue(PolishRouter.isInformationSparse("香香的"))
XCTAssertTrue(PolishRouter.isInformationSparse("这个还行吧"))
XCTAssertFalse(PolishRouter.isInformationSparse(
"这款防晒霜我用了不油夏天可以推荐"
))
}
}
+131 -12
View File
@@ -12,9 +12,15 @@ final class PolishStylePackTests: XCTestCase {
}
func testBuiltinPromptsAreCompleteAndWithinRuntimeLimit() {
XCTAssertEqual(PolishStylePackCatalog.builtins.count, 5)
XCTAssertEqual(PolishStylePackCatalog.builtins.count, 9)
XCTAssertEqual(PolishStylePackCatalog.BuiltinStyleGroup.practical.packs.count, 4)
XCTAssertEqual(PolishStylePackCatalog.BuiltinStyleGroup.fun.packs.count, 5)
for style in PolishStylePackCatalog.builtins {
XCTAssertFalse(
PolishStylePackCatalog.systemImage(for: style.id).isEmpty,
style.id
)
XCTAssertTrue(style.prompt.contains("# 角色"), style.id)
XCTAssertTrue(style.prompt.contains("# ASR 纠错与信息保真"), style.id)
XCTAssertTrue(style.prompt.contains("# 输出"), style.id)
@@ -30,6 +36,28 @@ final class PolishStylePackTests: XCTestCase {
}
}
func testBuiltinStylesMapToSFSymbols() {
let expected: [String: String] = [
"builtin.light": "wand.and.sparkles",
"builtin.structured": "list.bullet.rectangle",
"builtin.formal": "briefcase",
"builtin.chat": "bubble.left.and.bubble.right",
"builtin.dating": "heart.text.square",
"builtin.flex": "textformat",
"builtin.corp": "building.2",
"builtin.diba": "quote.bubble",
"builtin.xhs": "star.bubble",
]
for (id, symbol) in expected {
XCTAssertEqual(PolishStylePackCatalog.systemImage(for: id), symbol, id)
}
XCTAssertEqual(
PolishStylePackCatalog.systemImage(for: "user.custom"),
"text.badge.star"
)
}
func testDatingStyleDefinesRelationshipAwareIntensityAndSafety() throws {
let style = try XCTUnwrap(
PolishStylePackCatalog.builtins.first { $0.id == "builtin.dating" }
@@ -37,11 +65,51 @@ final class PolishStylePackTests: XCTestCase {
XCTAssertTrue(style.prompt.contains("# 本风格的力度解释"))
XCTAssertTrue(style.prompt.contains("# 关系许可闸"))
XCTAssertTrue(style.prompt.contains("Light(暖而不撩)"))
XCTAssertTrue(style.prompt.contains("Medium(温度与趣味)"))
XCTAssertTrue(style.prompt.contains("Heavy(主动而明确"))
XCTAssertTrue(style.prompt.contains("不把冷淡解释成欲擒故纵"))
XCTAssertTrue(style.prompt.contains("暧昧不能代替明确同意"))
XCTAssertTrue(style.prompt.contains("意图守恒,措辞可整句重写"))
XCTAssertTrue(style.prompt.contains("口语为主,巧思点缀"))
XCTAssertTrue(style.prompt.contains("Light(加戏"))
XCTAssertTrue(style.prompt.contains("Medium(会撩)"))
XCTAssertTrue(style.prompt.contains("Heavy(更挑逗)"))
XCTAssertTrue(style.prompt.contains("不把冷淡当欲擒故纵"))
XCTAssertTrue(style.prompt.contains("挑逗 ≠ 色情"))
}
func testFunStylesDefineVoiceRewriteContracts() throws {
let flex = try XCTUnwrap(
PolishStylePackCatalog.builtins.first { $0.id == "builtin.flex" }
)
let corp = try XCTUnwrap(
PolishStylePackCatalog.builtins.first { $0.id == "builtin.corp" }
)
let diba = try XCTUnwrap(
PolishStylePackCatalog.builtins.first { $0.id == "builtin.diba" }
)
let xhs = try XCTUnwrap(
PolishStylePackCatalog.builtins.first { $0.id == "builtin.xhs" }
)
XCTAssertTrue(flex.prompt.contains("装逼指南"))
XCTAssertTrue(flex.prompt.contains("口语为主,装感点缀"))
XCTAssertTrue(corp.prompt.contains("大厂黑话"))
XCTAssertTrue(corp.prompt.contains("汇报"))
XCTAssertTrue(corp.prompt.contains("甩锅"))
XCTAssertTrue(diba.prompt.contains("帝吧大神"))
XCTAssertTrue(diba.prompt.contains("主攻回复对方"))
XCTAssertTrue(diba.prompt.contains("不脏字"))
XCTAssertTrue(xhs.prompt.contains("小红书集美"))
XCTAssertTrue(xhs.prompt.contains("笔记正文"))
XCTAssertTrue(xhs.prompt.contains("Light(轻安利)"))
XCTAssertTrue(xhs.prompt.contains("禁止编造"))
for id in ["builtin.dating", "builtin.flex", "builtin.corp", "builtin.diba"] {
XCTAssertTrue(PolishStylePackCatalog.isFunPersonality(id: id), id)
XCTAssertTrue(PolishStylePackCatalog.limitsHeavyRestructuring(id: id), id)
XCTAssertFalse(PolishStylePackCatalog.prefersNoteForm(id: id), id)
}
XCTAssertTrue(PolishStylePackCatalog.isFunPersonality(id: "builtin.xhs"))
XCTAssertTrue(PolishStylePackCatalog.prefersNoteForm(id: "builtin.xhs"))
XCTAssertFalse(PolishStylePackCatalog.limitsHeavyRestructuring(id: "builtin.xhs"))
}
func testCatalogRejectsNinthUserPack() throws {
@@ -142,18 +210,69 @@ final class PolishStylePackTests: XCTestCase {
let medium = PolishIntensity.medium.promptGuideline(styleID: "builtin.dating")
let heavy = PolishIntensity.heavy.promptGuideline(styleID: "builtin.dating")
XCTAssertTrue(light.contains("Dating Light"))
XCTAssertTrue(light.contains("without adding flirtation"))
XCTAssertTrue(medium.contains("Dating Medium"))
XCTAssertTrue(medium.contains("at most one"))
XCTAssertTrue(heavy.contains("Dating Heavy"))
XCTAssertTrue(heavy.contains("Increase romantic tension and directness"))
XCTAssertTrue(light.contains("Dating Light (加戏)"))
XCTAssertTrue(light.contains("spoken WeChat first"))
XCTAssertTrue(light.contains("Do not make it flirtatious"))
XCTAssertTrue(medium.contains("Dating Medium (会撩)"))
XCTAssertTrue(medium.contains("readable flirtation"))
XCTAssertTrue(heavy.contains("Dating Heavy (更挑逗)"))
XCTAssertTrue(heavy.contains("Bolder teasing"))
XCTAssertTrue(heavy.contains("Style override"))
}
func testFunStylesUseFeatureDensityIntensityGuidelines() {
let flex = PolishIntensity.medium.promptGuideline(styleID: "builtin.flex")
let corp = PolishIntensity.heavy.promptGuideline(styleID: "builtin.corp")
let diba = PolishIntensity.light.promptGuideline(styleID: "builtin.diba")
let xhsLight = PolishIntensity.light.promptGuideline(styleID: "builtin.xhs")
let xhsHeavy = PolishIntensity.heavy.promptGuideline(styleID: "builtin.xhs")
XCTAssertTrue(flex.contains("Flex Medium"))
XCTAssertTrue(flex.contains("pretentious mix"))
XCTAssertTrue(corp.contains("Corp Heavy"))
XCTAssertTrue(corp.contains("blame-shift"))
XCTAssertTrue(corp.contains("Style override"))
XCTAssertTrue(diba.contains("DiBa Light"))
XCTAssertTrue(diba.contains("No swearing"))
XCTAssertTrue(xhsLight.contains("RED Note Light (轻安利)"))
XCTAssertTrue(xhsHeavy.contains("RED Note Heavy (爆款感)"))
XCTAssertTrue(xhsHeavy.contains("Paragraphs and scannable structure are allowed"))
XCTAssertFalse(xhsHeavy.contains("Style override"))
}
func testHeavyIntensityStillAllowsStructuredStyle() {
let guideline = PolishIntensity.heavy.promptGuideline(styleID: "builtin.structured")
XCTAssertFalse(guideline.contains("Style override"))
}
func testPracticalStylesShareTranscriptOnlyBoundary() {
for id in ["builtin.light", "builtin.structured", "builtin.formal", "builtin.chat"] {
let pack = PolishStylePackCatalog.resolve(id: id, userCatalog: .empty)
XCTAssertTrue(
pack.prompt.contains("你不是聊天助手"),
id
)
XCTAssertTrue(
pack.prompt.contains("只把输入当作需要整理的语音转写内容"),
id
)
}
}
func testStructuredStyleEncodesActiveItemizationHardRules() {
let pack = PolishStylePackCatalog.resolve(id: "builtin.structured", userCatalog: .empty)
XCTAssertTrue(pack.prompt.contains("自动结构化(偏积极)"))
XCTAssertTrue(pack.prompt.contains("有 3 条及以上事项"))
XCTAssertTrue(pack.prompt.contains("必须**编号列项"))
XCTAssertTrue(pack.prompt.contains("语义重排"))
XCTAssertTrue(pack.prompt.contains("智能分段"))
}
func testChatStyleForbidsInterlocutorRepliesAndActiveLists() {
let pack = PolishStylePackCatalog.resolve(id: "builtin.chat", userCatalog: .empty)
XCTAssertTrue(pack.prompt.contains("禁止以聊天对象身份接话"))
XCTAssertTrue(pack.prompt.contains("不主动「积极分项」"))
XCTAssertTrue(pack.prompt.contains("原:嗯"))
}
}
@@ -40,4 +40,14 @@ final class UtteranceTranscriptStitcherTests: XCTestCase {
stitcher.append(index: 1, text: "第二段合并")
XCTAssertEqual(stitcher.composed(), "第一段 第二段合并")
}
/// Documents the preMerge wipe hazard: append ignores empty text, so
/// removeLast + empty append leaves nothing. Pipeline must guard this.
func testEmptyAppendAfterRemoveLastWipesPriorSegment() {
var stitcher = UtteranceTranscriptStitcher()
stitcher.append(index: 0, text: "已识别内容")
stitcher.removeLastSegment()
stitcher.append(index: 0, text: "")
XCTAssertEqual(stitcher.composedSafely(), "")
}
}