feat: 知识库更新,优化批量语音转写 - 合并索引 - 增加会话级批量转写处理 - 补充语音转写回归测试

This commit is contained in:
电摇小子
2026-08-06 20:31:08 +08:00
parent a0e8ab278f
commit 0ec2e6a0be
34 changed files with 2589 additions and 53 deletions
@@ -87,4 +87,78 @@ describe('voice recognition settings', () => {
expect(screen.getByText('正在下载 42%')).toBeInTheDocument()
finishDownload?.({ success: true, status: readyStatus })
})
it('categorizes conversations and shows voice counts before they are selected', async () => {
window.api = {
...window.api,
getContacts: vi.fn().mockResolvedValue([
{ md5: 'contact-a', m_nsNickName: '联系人 A', m_nsUsrName: 'contact-a', type: 'user' },
{ md5: 'group-b', m_nsNickName: '群聊 B', m_nsUsrName: 'group-b@chatroom', type: 'group' }
]),
getSelf: vi.fn().mockResolvedValue({
ready: true,
info: { wxid: 'wxid_fixture', nickname: '测试账号', accountRoot: 'C:/fixture' }
}),
getVoiceBatchConversationSummaries: vi
.fn()
.mockResolvedValue([{ conversationId: 'group-b', voiceMessageCount: 7 }]),
getVoiceBatchProgress: vi.fn().mockResolvedValue({
accountIdentity: 'account-a',
state: 'idle',
total: 0,
processed: 0,
cached: 0,
succeeded: 0,
failed: 0,
elapsedMs: 0,
estimatedRemainingMs: null
}),
getVoiceBatchPreflight: vi.fn().mockResolvedValue({
accountIdentity: 'account-a',
conversationCount: 2,
voiceMessageCount: 8,
cachedCount: 3,
pendingCount: 5,
failedCount: 0,
estimatedDurationMs: null,
modelReady: true
}),
startVoiceBatch: vi.fn().mockResolvedValue({
accountIdentity: 'account-a',
state: 'pending',
total: 8,
processed: 0,
cached: 0,
succeeded: 0,
failed: 0,
elapsedMs: 0,
estimatedRemainingMs: null
}),
cancelVoiceBatch: vi.fn().mockResolvedValue({ success: true }),
retryFailedVoiceBatch: vi.fn(),
onVoiceBatchProgress: vi.fn(() => vi.fn())
} as typeof window.api
render(<VoiceRecognitionPage onNotice={vi.fn()} />)
expect(await screen.findByText('群聊 B')).toBeInTheDocument()
expect(await screen.findByText('7 条语音')).toBeInTheDocument()
expect(window.api.getVoiceBatchConversationSummaries).toHaveBeenCalledWith({
conversationIds: ['group-b'],
range: 'recent_30_days'
})
await userEvent.click(screen.getByRole('checkbox', { name: /群聊 B/ }))
await userEvent.click(screen.getByRole('tab', { name: /联系人 1/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /联系人 A/ }))
expect(screen.getByText('已选会话')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
expect(window.api.getVoiceBatchPreflight).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: '开始转写' }))
await waitFor(() =>
expect(window.api.startVoiceBatch).toHaveBeenCalledWith({
conversationIds: ['group-b', 'contact-a'],
range: 'recent_30_days'
})
)
})
})
+7
View File
@@ -21,6 +21,7 @@ const candidate = (
endTime: 1_785_895_200_000 + index,
timestamp: 1_785_895_200_000 + index,
messageIds: [`message-${index}`],
sourceKind: 'text',
text: `${index} 条去健身相关消息`,
score: -index,
...options
@@ -64,6 +65,12 @@ describe('Final Evidence builder', () => {
])
})
it('preserves voice source type through Final Evidence', () => {
const result = buildFinalEvidence([candidate(1, { sourceKind: 'voice' })], 8)
expect(result.evidence[0]).toMatchObject({ id: 'E1', sourceKind: 'voice' })
})
it('removes citations which do not resolve to Final Evidence', () => {
const evidence = buildFinalEvidence([candidate(1), candidate(2)], 8).evidence
const result = sanitizeAnswerCitations('杨伟提到健身。[E1] 另有无效来源。[E10][E23]', evidence)
@@ -212,7 +212,7 @@ describe('AiSearchPipelineService', () => {
)
const answerPrompt = aiProvider.chat.mock.calls[2][0][1].content as string
const contextIds = Array.from(answerPrompt.matchAll(/\[E(\d+)\]\nsender:/g)).map(
const contextIds = Array.from(answerPrompt.matchAll(/\[E(\d+)\]\nsource:/g)).map(
(match) => Number(match[1])
)
expect(contextIds).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
+151 -1
View File
@@ -12,7 +12,20 @@ const { chatState, getGroupSnapshotAsync, listContactsAsync, listMessagesAsync,
knowledgeService: {
dispose: vi.fn().mockResolvedValue(undefined),
index: vi.fn().mockResolvedValue(undefined),
search: vi.fn()
search: vi.fn(),
status: vi.fn().mockResolvedValue({
accountId: 'fixture-account',
state: 'ready',
indexedMessageCount: 1,
indexedChunkCount: 1,
sourceMessageCount: 1,
processedMessages: 1,
totalMessages: 1,
estimatedRemainingMs: null,
databaseBytes: 0,
walBytes: 0,
shmBytes: 0
})
}
}))
@@ -30,10 +43,12 @@ vi.mock('../../src/main/knowledge/knowledge-service', () => ({
dispose = knowledgeService.dispose
index = knowledgeService.index
search = knowledgeService.search
status = knowledgeService.status
}
}))
import { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
import type { VoiceTranscriptUpdate } from '../../src/shared/voice-recognition'
describe('KnowledgeSearchService legacy fallback', () => {
beforeEach(() => {
@@ -45,6 +60,7 @@ describe('KnowledgeSearchService legacy fallback', () => {
knowledgeService.dispose.mockClear()
knowledgeService.index.mockClear()
knowledgeService.search.mockReset()
knowledgeService.status.mockClear()
listContactsAsync.mockResolvedValue([
{
m_nsUsrName: 'fixture-contact',
@@ -98,6 +114,140 @@ describe('KnowledgeSearchService legacy fallback', () => {
await service.dispose()
})
it('hydrates a cached voice transcript and incrementally indexes only its conversation', async () => {
chatState.ready = true
chatState.accountId = 'C:/fixtures/account-a'
listContactsAsync.mockResolvedValue([
{
m_nsUsrName: 'voice-contact',
m_nsNickName: '语音测试会话',
md5: 'voice-conversation',
type: 'user'
}
])
listMessagesAsync.mockResolvedValue([
{
id: 'voice-message',
localId: 18,
from: 'user',
type: '语音',
content: '[语音消息]',
isSender: false,
senderId: 'fixture-sender',
name: '脱敏成员',
sessionId: 'voice-contact',
createTime: 1_785_895_200
}
])
const { voiceAccountIdentity, voiceMessageIdentity } = await import(
'../../src/main/voice-pipeline/voice-message-identity'
)
const reference = {
sessionId: 'voice-contact',
localId: 18,
createTime: 1_785_895_200
}
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
service.setVoiceTranscriptResolver(() => ({ state: 'transcribed', transcript: '缓存中的语音文字' }))
await service.indexVoiceTranscript({
accountIdentity: voiceAccountIdentity(chatState.accountId),
reference,
messageIdentity: voiceMessageIdentity(reference),
state: 'transcribed',
transcript: '缓存中的语音文字',
cached: true
})
expect(knowledgeService.index).toHaveBeenCalledWith(
expect.objectContaining({
accountId: chatState.accountId,
conversations: [
expect.objectContaining({
conversationId: 'voice-conversation',
completeSnapshot: true,
messages: [
expect.objectContaining({
kind: 'voice',
voiceTranscript: '缓存中的语音文字',
voiceTranscriptState: 'transcribed'
})
]
})
]
})
)
await service.dispose()
})
it('coalesces consecutive voice updates for the same conversation', async () => {
chatState.ready = true
chatState.accountId = 'C:/fixtures/account-a'
listContactsAsync.mockResolvedValue([
{
m_nsUsrName: 'voice-contact',
m_nsNickName: '语音测试会话',
md5: 'voice-conversation',
type: 'user'
}
])
listMessagesAsync.mockResolvedValue([
{
id: 'voice-message-1',
localId: 18,
from: 'user',
type: '语音',
content: '[语音消息]',
isSender: false,
sessionId: 'voice-contact',
createTime: 1_785_895_200
},
{
id: 'voice-message-2',
localId: 19,
from: 'user',
type: '语音',
content: '[语音消息]',
isSender: false,
sessionId: 'voice-contact',
createTime: 1_785_895_201
}
])
let releaseFirstIndex: (() => void) | undefined
knowledgeService.index.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseFirstIndex = resolve
})
)
const { voiceAccountIdentity, voiceMessageIdentity } = await import(
'../../src/main/voice-pipeline/voice-message-identity'
)
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
const update = (localId: number, createTime: number): VoiceTranscriptUpdate => {
const reference = { sessionId: 'voice-contact', localId, createTime }
return {
accountIdentity: voiceAccountIdentity(chatState.accountId),
reference,
messageIdentity: voiceMessageIdentity(reference),
state: 'transcribed' as const,
transcript: `转写 ${localId}`,
cached: false
}
}
const first = service.indexVoiceTranscript(update(18, 1_785_895_200))
await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(1))
const second = service.indexVoiceTranscript(update(19, 1_785_895_201))
const third = service.indexVoiceTranscript(update(18, 1_785_895_200))
releaseFirstIndex?.()
await Promise.all([first, second, third])
expect(knowledgeService.index).toHaveBeenCalledTimes(2)
expect(listMessagesAsync).toHaveBeenCalledTimes(2)
await service.dispose()
})
it('uses existing Knowledge evidence while a new incremental pass is running', async () => {
chatState.ready = true
chatState.accountId = 'fixture-account'
+58
View File
@@ -215,6 +215,64 @@ describe('knowledge sqlite', () => {
store.close()
})
it('marks voice Evidence and reports scoped transcript coverage without indexing error text', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
await store.index({
conversations: [
{
conversationId: 'voice-coverage',
completeSnapshot: true,
messages: [
{
accountId: FIXTURE_ACCOUNT_A,
conversationId: 'voice-coverage',
messageId: 'voice-ready',
createTime: Date.UTC(2026, 7, 5, 9),
senderName: '成员甲',
kind: 'voice',
text: '[语音消息]',
voiceTranscript: '语音里确认今天去健身。',
voiceTranscriptState: 'transcribed'
},
{
accountId: FIXTURE_ACCOUNT_A,
conversationId: 'voice-coverage',
messageId: 'voice-failed',
createTime: Date.UTC(2026, 7, 5, 10),
senderName: '成员乙',
kind: 'voice',
text: '[语音消息]',
voiceTranscriptState: 'failed'
}
]
}
],
chunker: DEFAULT_KNOWLEDGE_CHUNKER
})
const result = store.searchWithStatus({
accountId: FIXTURE_ACCOUNT_A,
text: '去健身',
terms: ['去健身'],
conversationIds: ['voice-coverage'],
limit: 10
})
expect(result.evidence[0]).toMatchObject({
messageId: 'voice-ready',
sourceKind: 'voice'
})
expect(result.voiceCoverage).toEqual({
voiceMessageCount: 2,
transcribedVoiceCount: 1,
failedVoiceCount: 1,
voiceCoverageComplete: false
})
expect(result.evidence[0].text).not.toContain('失败')
store.close()
})
it('keeps conversation, sender and time filters when a participant question has no topic terms', async () => {
const root = makeRoot()
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
+1 -1
View File
@@ -68,7 +68,7 @@ describe('search cache', () => {
it('writes and reads an isolated cache record', () => {
const key = buildSearchCacheKey('conversation', 'fixture-contact', 'today', '图片')
const record = {
version: 2 as const,
version: 3 as const,
key,
query: '图片',
answer: '固定假回答',
+192
View File
@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { VoiceRecognitionUseCase } from '../../src/main/voice-pipeline/voice-recognition-use-case'
const { countVoiceMessagesAsync, listContactsAsync, listMessagesAsync } = vi.hoisted(() => ({
countVoiceMessagesAsync: vi.fn(),
listContactsAsync: vi.fn(),
listMessagesAsync: vi.fn()
}))
vi.mock('../../src/main/services/chat-service', () => ({
countVoiceMessagesAsync,
listContactsAsync,
listMessagesAsync
}))
import { VoiceBatchService } from '../../src/main/voice-pipeline/voice-batch-service'
const readyStatus = {
modelId: 'sensevoice-small-int8',
version: 'fixture',
state: 'ready' as const,
downloadedBytes: 1,
totalBytes: 1,
progress: 1,
platform: 'win32' as const,
architecture: 'x64',
supported: true
}
function makeRecognition(): VoiceRecognitionUseCase {
return {
accountIdentity: 'account-a',
getModelStatus: vi.fn().mockResolvedValue(readyStatus),
getTranscriptSnapshot: vi.fn().mockReturnValue({ state: 'pending' }),
recognize: vi.fn().mockResolvedValue({ success: true, transcript: '转写结果', cached: false }),
publishTranscriptSnapshot: vi.fn().mockResolvedValue(undefined)
} as unknown as VoiceRecognitionUseCase
}
describe('VoiceBatchService', () => {
beforeEach(() => {
listContactsAsync.mockReset()
listMessagesAsync.mockReset()
countVoiceMessagesAsync.mockReset()
listContactsAsync.mockResolvedValue([
{ md5: 'contact-a', m_nsUsrName: 'contact-a-id', m_nsNickName: '联系人 A', type: 'user' },
{ md5: 'group-b', m_nsUsrName: 'group-b@chatroom', m_nsNickName: '群聊 B', type: 'group' }
])
listMessagesAsync.mockImplementation(async (conversationId: string) => [
{
id: `${conversationId}-voice`,
type: '语音',
content: '[语音消息]',
isSender: false,
sessionId: conversationId === 'contact-a' ? 'contact-a-id' : 'group-b@chatroom',
localId: conversationId === 'contact-a' ? 11 : 22,
createTime: 1_785_895_200
},
{
id: `${conversationId}-text`,
type: '普通文本',
content: '不会进入语音任务',
isSender: false,
createTime: 1_785_895_201
}
])
countVoiceMessagesAsync.mockImplementation(async (conversationId: string) =>
conversationId === 'contact-a' ? 3 : 7
)
})
it('limits a batch to selected contacts and groups, then schedules each item as background work', async () => {
const recognition = makeRecognition()
const service = new VoiceBatchService(recognition)
const preflight = await service.preflight({
conversationIds: ['contact-a', 'group-b'],
range: 'recent_30_days'
})
expect(preflight).toMatchObject({
conversationCount: 2,
voiceMessageCount: 2,
cachedCount: 0,
pendingCount: 2,
modelReady: true
})
expect(listMessagesAsync).toHaveBeenCalledTimes(2)
expect(listMessagesAsync.mock.calls[0][1]).toEqual(expect.any(Number))
await service.start({ conversationIds: ['contact-a'], range: 'selected_history' })
await vi.waitFor(() => expect(service.getProgress().state).toBe('completed'))
expect(recognition.recognize).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'contact-a-id', localId: 11 }),
{ priority: 'background', publishTranscriptUpdate: false }
)
expect(recognition.recognize).toHaveBeenCalledTimes(1)
await vi.waitFor(() =>
expect(recognition.publishTranscriptSnapshot).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'contact-a-id', localId: 11 })
)
)
})
it('defers knowledge notifications until the batch finishes and sends one per conversation', async () => {
listMessagesAsync.mockResolvedValue([
{
id: 'contact-a-voice-1',
type: '语音',
content: '[语音消息]',
isSender: false,
sessionId: 'contact-a-id',
localId: 11,
createTime: 1_785_895_200
},
{
id: 'contact-a-voice-2',
type: '语音',
content: '[语音消息]',
isSender: false,
sessionId: 'contact-a-id',
localId: 12,
createTime: 1_785_895_201
}
])
const recognition = makeRecognition()
let releaseKnowledgeRefresh: (() => void) | undefined
vi.mocked(recognition.publishTranscriptSnapshot).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
releaseKnowledgeRefresh = resolve
})
)
const service = new VoiceBatchService(recognition)
await service.start({ conversationIds: ['contact-a'], range: 'selected_history' })
await vi.waitFor(() => expect(recognition.publishTranscriptSnapshot).toHaveBeenCalledTimes(1))
expect(service.getProgress().state).toBe('processing')
releaseKnowledgeRefresh?.()
await vi.waitFor(() => expect(service.getProgress().state).toBe('completed'))
expect(recognition.recognize).toHaveBeenCalledTimes(2)
expect(recognition.recognize).toHaveBeenNthCalledWith(
1,
expect.anything(),
expect.objectContaining({ publishTranscriptUpdate: false })
)
expect(recognition.publishTranscriptSnapshot).toHaveBeenCalledTimes(1)
})
it('does not start when the selected range contains a stale conversation id', async () => {
const service = new VoiceBatchService(makeRecognition())
await expect(
service.preflight({ conversationIds: ['missing-conversation'], range: 'recent_30_days' })
).rejects.toThrow('选择的会话已不可用')
})
it('reports cache hits separately from newly recognized items', async () => {
const recognition = makeRecognition()
vi.mocked(recognition.getTranscriptSnapshot).mockReturnValue({
state: 'transcribed',
transcript: '旧缓存'
})
vi.mocked(recognition.recognize).mockResolvedValue({
success: true,
transcript: '旧缓存',
cached: true
})
const service = new VoiceBatchService(recognition)
await service.start({ conversationIds: ['contact-a'], range: 'selected_history' })
await vi.waitFor(() => expect(service.getProgress().state).toBe('completed'))
expect(service.getProgress()).toMatchObject({ cached: 1, succeeded: 0, failed: 0 })
})
it('counts visible conversations through the lightweight voice-count path without loading messages', async () => {
const service = new VoiceBatchService(makeRecognition())
await expect(
service.conversationSummaries({
conversationIds: ['contact-a', 'group-b'],
range: 'recent_30_days'
})
).resolves.toEqual([
{ conversationId: 'contact-a', voiceMessageCount: 3 },
{ conversationId: 'group-b', voiceMessageCount: 7 }
])
expect(countVoiceMessagesAsync).toHaveBeenCalledTimes(2)
expect(listMessagesAsync).not.toHaveBeenCalled()
})
})
+63
View File
@@ -101,6 +101,55 @@ describe('voice task scheduling', () => {
releaseFirst?.()
await first
})
it('runs an interactive request before queued background work', async () => {
const scheduler = new VoiceTaskScheduler()
const order: string[] = []
let releaseFirst: (() => void) | undefined
const first = scheduler.schedule(
'first',
() =>
new Promise<void>((resolve) => {
order.push('first')
releaseFirst = resolve
})
)
const background = scheduler.schedule('background', async () => {
order.push('background')
}, { priority: 'background' })
const interactive = scheduler.schedule('interactive', async () => {
order.push('interactive')
})
await vi.waitFor(() => expect(order).toEqual(['first']))
releaseFirst?.()
await Promise.all([first, background, interactive])
expect(order).toEqual(['first', 'interactive', 'background'])
})
it('interrupts an active background task for an interactive request', async () => {
const scheduler = new VoiceTaskScheduler()
const order: string[] = []
const background = scheduler.schedule(
'background',
async (signal) => {
order.push('background:start')
await new Promise<void>((resolve) => signal.addEventListener('abort', resolve, { once: true }))
order.push('background:aborted')
throw new DOMException('Recognition cancelled', 'AbortError')
},
{ priority: 'background' }
)
await vi.waitFor(() => expect(order).toEqual(['background:start']))
const interactive = scheduler.schedule('interactive', async () => {
order.push('interactive')
return 'done'
})
await expect(background).rejects.toMatchObject({ name: 'AbortError' })
await expect(interactive).resolves.toBe('done')
expect(order).toEqual(['background:start', 'background:aborted', 'interactive'])
})
})
describe('transcript repository', () => {
@@ -136,6 +185,20 @@ describe('transcript repository', () => {
expect(repository.find(key)).toMatchObject({ transcript: '固定测试文本' })
expect(repository.find({ ...key, accountId: 'account-b' })).toBeNull()
expect(repository.find({ ...key, modelFingerprint: 'fingerprint-b' })).toBeNull()
expect(repository.findLatest(record.accountId, record.messageIdentity)).toMatchObject({
transcript: '固定测试文本'
})
expect(repository.getMessageStatus(record.accountId, record.messageIdentity)).toMatchObject({
state: 'transcribed'
})
repository.markFailure('account-b', record.messageIdentity, '脱敏失败原因')
expect(repository.getMessageStatus('account-b', record.messageIdentity)).toMatchObject({
state: 'failed',
error: '脱敏失败原因'
})
expect(repository.getMessageStatus(record.accountId, record.messageIdentity)).toMatchObject({
state: 'transcribed'
})
repository.close()
})
})
@@ -0,0 +1,101 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { VoiceRecognitionUseCase } from '../../src/main/voice-pipeline/voice-recognition-use-case'
const roots: string[] = []
function createUseCase(): VoiceRecognitionUseCase {
const root = mkdtempSync(join(tmpdir(), 'wxe-voice-use-case-'))
roots.push(root)
const useCase = new VoiceRecognitionUseCase({
modelRoot: join(root, 'model'),
databasePath: join(root, 'transcripts.sqlite'),
workerPath: join(root, 'unused-worker.js')
})
const state = useCase as unknown as {
accountId: string
accountGeneration: number
pipeline: { run: ReturnType<typeof vi.fn> }
}
state.accountId = 'account-a'
state.accountGeneration = 1
state.pipeline = { run: vi.fn() }
vi.spyOn(useCase.modelManager, 'getStatus').mockResolvedValue({
modelId: 'sensevoice-small-int8',
version: 'fixture',
state: 'ready',
downloadedBytes: 1,
totalBytes: 1,
progress: 1,
platform: 'win32',
architecture: 'x64',
supported: true
})
return useCase
}
afterEach(async () => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('VoiceRecognitionUseCase transcript updates', () => {
it('publishes a successful cache hit through the same update path as fresh recognition', async () => {
const useCase = createUseCase()
const state = useCase as unknown as {
pipeline: { run: ReturnType<typeof vi.fn> }
}
state.pipeline.run.mockResolvedValue({
transcript: '缓存命中的语音文字',
durationMs: 1_200,
cached: true
})
const listener = vi.fn().mockResolvedValue(undefined)
useCase.onTranscriptUpdate(listener)
const reference = { sessionId: 'fixture-contact', localId: 9, createTime: 1_785_895_200 }
const result = await useCase.recognize(reference)
expect(result).toMatchObject({ success: true, cached: true, transcript: '缓存命中的语音文字' })
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
accountIdentity: 'account-a',
reference,
state: 'transcribed',
transcript: '缓存命中的语音文字',
cached: true
})
)
await useCase.dispose()
})
it('does not publish a transcript after the account generation changes mid-recognition', async () => {
const useCase = createUseCase()
let finish: ((value: { transcript: string; durationMs: number; cached: boolean }) => void) | undefined
const state = useCase as unknown as {
accountGeneration: number
pipeline: { run: ReturnType<typeof vi.fn> }
}
state.pipeline.run.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const listener = vi.fn()
useCase.onTranscriptUpdate(listener)
const pending = useCase.recognize({
sessionId: 'fixture-contact',
localId: 10,
createTime: 1_785_895_201
})
await vi.waitFor(() => expect(state.pipeline.run).toHaveBeenCalledOnce())
state.accountGeneration += 1
finish?.({ transcript: '不应写入新账号', durationMs: 600, cached: false })
await expect(pending).resolves.toMatchObject({ success: false, code: 'CANCELLED' })
expect(listener).not.toHaveBeenCalled()
await useCase.dispose()
})
})