mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
perf: 加速语音转写缓存命中
迁移旧版语音转写缓存,并将补迁失败降级为一次性失败状态。 按账号和消息标识优先命中兼容缓存,未命中时才读取音频并计算哈希;导出缓存命中后合并异步刷新知识索引。 补充迁移、缓存快速路径、批量语音读取和导出流程测试。
This commit is contained in:
@@ -43,6 +43,7 @@ const state = vi.hoisted(() => ({
|
||||
}
|
||||
>,
|
||||
voiceLookups: [] as number[],
|
||||
voiceBatches: [] as number[][],
|
||||
videoLookups: [] as {
|
||||
createTime?: number
|
||||
byteLength?: number
|
||||
@@ -137,6 +138,13 @@ vi.mock('../../src/main/voice-service', () => ({
|
||||
}
|
||||
: { success: false, error: '本地未找到语音数据' }
|
||||
}
|
||||
|
||||
async resolveVoices(
|
||||
references: Array<{ localId: number }>
|
||||
): Promise<Array<{ success: boolean; data?: string; error?: string }>> {
|
||||
state.voiceBatches.push(references.map((reference) => reference.localId))
|
||||
return Promise.all(references.map((reference) => this.resolveVoice('', reference.localId)))
|
||||
}
|
||||
}
|
||||
}))
|
||||
vi.mock('../../src/main/image-decrypt-service', () => ({
|
||||
@@ -264,6 +272,7 @@ describe('media export flow', () => {
|
||||
state.groupSnapshotReads = []
|
||||
state.groupSnapshots = {}
|
||||
state.voiceLookups = []
|
||||
state.voiceBatches = []
|
||||
const fileMonth = join(state.accountRoot, 'msg', 'file', '2026-08')
|
||||
mkdirSync(fileMonth, { recursive: true })
|
||||
writeFileSync(join(fileMonth, '测试附件.txt'), '附件内容')
|
||||
@@ -391,7 +400,10 @@ describe('media export flow', () => {
|
||||
send: (_channel: string, payload: (typeof progress)[number]) => progress.push(payload)
|
||||
}
|
||||
}
|
||||
const recognize = vi.fn(async () => ({ success: true as const, transcript: '固定转写文本' }))
|
||||
const recognize = vi.fn(async () => {
|
||||
expect(state.voiceLookups).toEqual([])
|
||||
return { success: true as const, transcript: '固定转写文本' }
|
||||
})
|
||||
|
||||
const result = await runExport(
|
||||
{
|
||||
@@ -423,6 +435,65 @@ describe('media export flow', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses compatible transcript results and waits for one coalesced knowledge update per chat', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
state.messages = [
|
||||
message({
|
||||
id: 'voice-cache-a',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 1,
|
||||
createTime: 1_785_549_600,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
}),
|
||||
message({
|
||||
id: 'voice-cache-b',
|
||||
type: '语音',
|
||||
sessionId: 'fixture-session',
|
||||
localId: 2,
|
||||
createTime: 1_785_549_601,
|
||||
contentData: { type: 'voice', duration: 1 }
|
||||
})
|
||||
]
|
||||
const recognize = vi.fn(async (reference: { localId: number }) => ({
|
||||
success: true as const,
|
||||
transcript: `缓存文字-${reference.localId}`,
|
||||
cached: true
|
||||
}))
|
||||
const publishTranscript = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await runExport(
|
||||
{
|
||||
jobId: 'voice-cache-coalesced-index',
|
||||
targets: [target()],
|
||||
format: 'html',
|
||||
outputName: 'voice-cache-coalesced-index',
|
||||
kinds: ['voice'],
|
||||
includeMedia: true,
|
||||
includeVoiceTranscripts: true
|
||||
},
|
||||
{ isDestroyed: () => true, webContents: { send: vi.fn() } } as never,
|
||||
{ recognize, publishTranscript }
|
||||
)
|
||||
|
||||
expect(result.success, result.error).toBe(true)
|
||||
expect(recognize).toHaveBeenCalledTimes(2)
|
||||
expect(recognize).toHaveBeenNthCalledWith(1, expect.objectContaining({ localId: 1 }), {
|
||||
publishTranscriptUpdate: false
|
||||
})
|
||||
expect(publishTranscript).toHaveBeenCalledOnce()
|
||||
expect(publishTranscript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'fixture-session', localId: 2 }),
|
||||
'缓存文字-2',
|
||||
true
|
||||
)
|
||||
expect(state.voiceBatches).toEqual([[1, 2]])
|
||||
expect(readArchive(result.outputPath!).messages.map((item) => item.voiceTranscript)).toEqual([
|
||||
'缓存文字-1',
|
||||
'缓存文字-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the customized file name as the HTML archive title', async () => {
|
||||
const { runExport } = await import('../../src/main/export-service')
|
||||
const win = { isDestroyed: () => true, webContents: { send: vi.fn() } }
|
||||
|
||||
@@ -30,8 +30,15 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
import { assessMigration, executeMigration } from '../../src/main/app-data-migration'
|
||||
import { getUserDataRoots } from '../../src/main/app-data-paths'
|
||||
import {
|
||||
assessMigration,
|
||||
executeMigration,
|
||||
migrateLegacyVoiceTranscripts,
|
||||
runFirstLaunchMigration
|
||||
} from '../../src/main/app-data-migration'
|
||||
import { getUserDataRoots, type UserDataRoots } from '../../src/main/app-data-paths'
|
||||
import { SqliteTranscriptRepository } from '../../src/main/voice-pipeline/transcript-repository'
|
||||
import type { TranscriptRecord } from '../../src/main/voice-pipeline/types'
|
||||
|
||||
let root = ''
|
||||
|
||||
@@ -43,7 +50,7 @@ afterEach(() => {
|
||||
fs.removeSync(root)
|
||||
})
|
||||
|
||||
function roots() {
|
||||
function roots(): UserDataRoots {
|
||||
return getUserDataRoots(path.join(root, 'Application Support'))
|
||||
}
|
||||
|
||||
@@ -72,6 +79,16 @@ describe('TraceMemo app data migration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('recognizes a legacy voice transcript cache as user-owned data', () => {
|
||||
const fixture = roots()
|
||||
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'))
|
||||
expect(assessMigration(fixture)).toMatchObject({
|
||||
shouldPrompt: true,
|
||||
reason: 'legacy-assets-detected',
|
||||
sourceRoot: fixture.legacy
|
||||
})
|
||||
})
|
||||
|
||||
it('detects legacy settings but never proposes overwriting valid TraceMemo data', () => {
|
||||
const fixture = roots()
|
||||
writeFixture(path.join(fixture.legacy, 'settings.json'), '{"dbRoot":"legacy"}')
|
||||
@@ -180,4 +197,113 @@ describe('TraceMemo app data migration', () => {
|
||||
)
|
||||
expect(fs.readFileSync(path.join(fixture.legacy, 'settings.json'), 'utf8')).toContain('legacy')
|
||||
})
|
||||
|
||||
it('supplements legacy voice transcripts into an existing TraceMemo cache', async () => {
|
||||
const fixture = roots()
|
||||
const legacyPath = path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite')
|
||||
const currentPath = path.join(fixture.current, 'cache', 'voice-transcripts.sqlite')
|
||||
const record: TranscriptRecord = {
|
||||
accountId: 'account-a',
|
||||
messageIdentity: 'message-a',
|
||||
audioHash: 'audio-a',
|
||||
processorVersion: 'processor-v1',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '已经转写过的文字',
|
||||
durationMs: 800,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const legacy = new SqliteTranscriptRepository(legacyPath)
|
||||
legacy.save(record)
|
||||
legacy.close()
|
||||
|
||||
expect(await migrateLegacyVoiceTranscripts(fixture.legacy, fixture.current)).toBe('migrated')
|
||||
expect(await migrateLegacyVoiceTranscripts(fixture.legacy, fixture.current)).toBe('skipped')
|
||||
const current = new SqliteTranscriptRepository(currentPath)
|
||||
expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('已经转写过的文字')
|
||||
current.close()
|
||||
})
|
||||
|
||||
it('backfills voice transcripts after the original migration was already completed', async () => {
|
||||
const fixture = roots()
|
||||
const legacyPath = path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite')
|
||||
const legacy = new SqliteTranscriptRepository(legacyPath)
|
||||
legacy.save({
|
||||
accountId: 'account-a',
|
||||
messageIdentity: 'message-a',
|
||||
audioHash: 'audio-a',
|
||||
processorVersion: 'processor-v1',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '补迁文字',
|
||||
durationMs: 800,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
legacy.close()
|
||||
fs.ensureDirSync(fixture.current)
|
||||
fs.writeJsonSync(path.join(fixture.current, 'tracememo-migration-v1.json'), {
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
sourceRoot: fixture.legacy,
|
||||
updatedAt: '2026-08-11T00:00:00.000Z',
|
||||
items: { 'settings.json': 'migrated' },
|
||||
secretFailures: []
|
||||
})
|
||||
|
||||
const result = await runFirstLaunchMigration(fixture)
|
||||
|
||||
expect(result.action).toBe('migrated')
|
||||
expect(result.execution?.state.items['cache/voice-transcripts.sqlite']).toBe('migrated')
|
||||
expect(result.execution?.state.status).toBe('completed')
|
||||
const current = new SqliteTranscriptRepository(
|
||||
path.join(fixture.current, 'cache', 'voice-transcripts.sqlite')
|
||||
)
|
||||
expect(current.findLatest('account-a', 'message-a')?.transcript).toBe('补迁文字')
|
||||
current.close()
|
||||
})
|
||||
|
||||
it('does not retry a voice transcript backfill after it was marked failed', async () => {
|
||||
const fixture = roots()
|
||||
writeFixture(path.join(fixture.legacy, 'settings.json'), '{}')
|
||||
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'), 'invalid sqlite')
|
||||
fs.ensureDirSync(fixture.current)
|
||||
fs.writeJsonSync(path.join(fixture.current, 'tracememo-migration-v1.json'), {
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
sourceRoot: fixture.legacy,
|
||||
updatedAt: '2026-08-11T00:00:00.000Z',
|
||||
items: { 'cache/voice-transcripts.sqlite': 'failed' },
|
||||
secretFailures: []
|
||||
})
|
||||
|
||||
const result = await runFirstLaunchMigration(fixture)
|
||||
|
||||
expect(result.action).toBe('none')
|
||||
expect(result.execution).toBeUndefined()
|
||||
expect(fs.existsSync(path.join(fixture.current, 'cache', 'voice-transcripts.sqlite'))).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a failed voice transcript migration as a non-blocking downgrade', async () => {
|
||||
const fixture = roots()
|
||||
writeFixture(path.join(fixture.legacy, 'settings.json'), '{}')
|
||||
writeFixture(path.join(fixture.legacy, 'cache', 'voice-transcripts.sqlite'), 'invalid sqlite')
|
||||
|
||||
const result = await executeMigration(fixture.legacy, fixture.current, {
|
||||
decryptLegacySecrets: async () => ({ databaseKeys: {}, failures: [] }),
|
||||
agentRoots: () => ({
|
||||
legacy: path.join(root, 'agent-legacy'),
|
||||
current: path.join(root, 'agent-current')
|
||||
}),
|
||||
now: () => new Date('2026-08-11T00:00:00.000Z')
|
||||
})
|
||||
|
||||
expect(result.state.status).toBe('completed')
|
||||
expect(result.state.items['cache/voice-transcripts.sqlite']).toBe('failed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest'
|
||||
import { PcmAudioProcessor } from '../../src/main/voice-pipeline/audio-processor'
|
||||
import { VoicePipeline } from '../../src/main/voice-pipeline/voice-pipeline'
|
||||
import { voiceMessageIdentity } from '../../src/main/voice-pipeline/voice-message-identity'
|
||||
import { VoiceTaskScheduler } from '../../src/main/voice-pipeline/task-scheduler'
|
||||
import { SqliteTranscriptRepository } from '../../src/main/voice-pipeline/transcript-repository'
|
||||
import type { TranscriptRecord } from '../../src/main/voice-pipeline/types'
|
||||
@@ -114,9 +116,13 @@ describe('voice task scheduling', () => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
)
|
||||
const background = scheduler.schedule('background', async () => {
|
||||
order.push('background')
|
||||
}, { priority: 'background' })
|
||||
const background = scheduler.schedule(
|
||||
'background',
|
||||
async () => {
|
||||
order.push('background')
|
||||
},
|
||||
{ priority: 'background' }
|
||||
)
|
||||
const interactive = scheduler.schedule('interactive', async () => {
|
||||
order.push('interactive')
|
||||
})
|
||||
@@ -134,7 +140,9 @@ describe('voice task scheduling', () => {
|
||||
'background',
|
||||
async (signal) => {
|
||||
order.push('background:start')
|
||||
await new Promise<void>((resolve) => signal.addEventListener('abort', resolve, { once: true }))
|
||||
await new Promise<void>((resolve) =>
|
||||
signal.addEventListener('abort', resolve, { once: true })
|
||||
)
|
||||
order.push('background:aborted')
|
||||
throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
},
|
||||
@@ -201,4 +209,155 @@ describe('transcript repository', () => {
|
||||
})
|
||||
repository.close()
|
||||
})
|
||||
|
||||
it('merges legacy transcripts idempotently without overwriting current records', () => {
|
||||
const legacyPath = join(root, 'legacy-transcripts.sqlite')
|
||||
const currentPath = join(root, 'current-transcripts.sqlite')
|
||||
const legacy = new SqliteTranscriptRepository(legacyPath)
|
||||
const current = new SqliteTranscriptRepository(currentPath)
|
||||
const base: TranscriptRecord = {
|
||||
accountId: 'account-a',
|
||||
messageIdentity: 'message-1',
|
||||
audioHash: 'audio-1',
|
||||
processorVersion: 'processor-v1',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '旧缓存文字',
|
||||
durationMs: 1200,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
legacy.save(base)
|
||||
legacy.save({ ...base, messageIdentity: 'message-2', audioHash: 'audio-2' })
|
||||
current.save({ ...base, transcript: '当前缓存文字', updatedAt: 2 })
|
||||
legacy.close()
|
||||
|
||||
expect(current.mergeFrom(legacyPath)).toBe(1)
|
||||
expect(current.mergeFrom(legacyPath)).toBe(0)
|
||||
expect(
|
||||
current.find({
|
||||
accountId: base.accountId,
|
||||
messageIdentity: base.messageIdentity,
|
||||
audioHash: base.audioHash,
|
||||
processorVersion: base.processorVersion,
|
||||
recognizerId: base.recognizerId,
|
||||
modelVersion: base.modelVersion,
|
||||
modelFingerprint: base.modelFingerprint
|
||||
})?.transcript
|
||||
).toBe('当前缓存文字')
|
||||
expect(current.findLatest('account-a', 'message-2')?.transcript).toBe('旧缓存文字')
|
||||
current.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('voice pipeline cache lookup', () => {
|
||||
it('returns a compatible message cache before reading or decoding audio', async () => {
|
||||
const repository = new SqliteTranscriptRepository(join(root, 'fast-cache.sqlite'))
|
||||
const reference = { sessionId: 'session', localId: 1, createTime: 2 }
|
||||
repository.save({
|
||||
accountId: 'account-a',
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
audioHash: 'audio-1',
|
||||
processorVersion: 'processor-v1',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '快速命中',
|
||||
durationMs: 900,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
const resolve = vi.fn()
|
||||
const decode = vi.fn()
|
||||
const process = vi.fn()
|
||||
const recognize = vi.fn()
|
||||
const pipeline = new VoicePipeline(
|
||||
{ resolve },
|
||||
{ decode } as never,
|
||||
{ version: 'processor-v1', process },
|
||||
{
|
||||
metadata: {
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a'
|
||||
},
|
||||
recognize,
|
||||
dispose: vi.fn()
|
||||
},
|
||||
repository
|
||||
)
|
||||
|
||||
await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({
|
||||
transcript: '快速命中',
|
||||
cached: true
|
||||
})
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
expect(decode).not.toHaveBeenCalled()
|
||||
expect(process).not.toHaveBeenCalled()
|
||||
expect(recognize).not.toHaveBeenCalled()
|
||||
repository.close()
|
||||
})
|
||||
|
||||
it('falls through when the cached processor version is incompatible', async () => {
|
||||
const repository = new SqliteTranscriptRepository(join(root, 'version-cache.sqlite'))
|
||||
const reference = { sessionId: 'session', localId: 1, createTime: 2 }
|
||||
repository.save({
|
||||
accountId: 'account-a',
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
audioHash: 'old-audio',
|
||||
processorVersion: 'processor-v0',
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a',
|
||||
transcript: '不兼容缓存',
|
||||
durationMs: 900,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
const resolve = vi.fn().mockResolvedValue({
|
||||
data: Buffer.from('encoded'),
|
||||
codec: 'silk',
|
||||
sourceHash: 'new-audio'
|
||||
})
|
||||
const pipeline = new VoicePipeline(
|
||||
{ resolve },
|
||||
{
|
||||
decode: vi.fn().mockResolvedValue({
|
||||
pcm: Buffer.from([1, 0]),
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
sourceHash: 'new-audio'
|
||||
})
|
||||
} as never,
|
||||
{
|
||||
version: 'processor-v1',
|
||||
process: vi.fn().mockReturnValue({
|
||||
samples: new Float32Array([0.1]),
|
||||
sampleRate: 16000,
|
||||
channels: 1,
|
||||
sourceHash: 'new-audio',
|
||||
processorVersion: 'processor-v1',
|
||||
durationMs: 1
|
||||
})
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
recognizerId: 'sensevoice',
|
||||
modelVersion: 'model-v1',
|
||||
modelFingerprint: 'fingerprint-a'
|
||||
},
|
||||
recognize: vi.fn().mockResolvedValue({ text: '新转写' }),
|
||||
dispose: vi.fn()
|
||||
},
|
||||
repository
|
||||
)
|
||||
|
||||
await expect(pipeline.run('account-a', reference)).resolves.toMatchObject({
|
||||
transcript: '新转写',
|
||||
cached: false
|
||||
})
|
||||
expect(resolve).toHaveBeenCalledOnce()
|
||||
repository.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,7 +72,9 @@ describe('VoiceRecognitionUseCase transcript updates', () => {
|
||||
|
||||
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
|
||||
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> }
|
||||
@@ -98,4 +100,24 @@ describe('VoiceRecognitionUseCase transcript updates', () => {
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
await useCase.dispose()
|
||||
})
|
||||
|
||||
it('publishes an explicit cached transcript for a coalesced export index refresh', async () => {
|
||||
const useCase = createUseCase()
|
||||
const listener = vi.fn().mockResolvedValue(undefined)
|
||||
useCase.onTranscriptUpdate(listener)
|
||||
const reference = { sessionId: 'fixture-contact', localId: 11, createTime: 1_785_895_202 }
|
||||
|
||||
await useCase.publishTranscript(reference, '缓存导出文字', true)
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountIdentity: 'account-a',
|
||||
reference,
|
||||
state: 'transcribed',
|
||||
transcript: '缓存导出文字',
|
||||
cached: true
|
||||
})
|
||||
)
|
||||
await useCase.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,4 +37,59 @@ describe('Wcdb4Client shutdown', () => {
|
||||
expect(shutdown).toHaveBeenCalledOnce()
|
||||
}
|
||||
})
|
||||
|
||||
it('restores async batch voice results to request order', async () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
setPrivate(client, 'wcdbGetVoiceDataBatch', vi.fn())
|
||||
setPrivate(
|
||||
client,
|
||||
'callJsonAsync',
|
||||
vi.fn().mockResolvedValue([
|
||||
{ index: 1, success: false, error: 'missing' },
|
||||
{ index: 0, Success: true, hex: 'aabb' }
|
||||
])
|
||||
)
|
||||
|
||||
await expect(
|
||||
client.getVoiceDataBatch([
|
||||
{ sessionId: 'a', createTime: 1, localId: 10, candidates: ['a'] },
|
||||
{ sessionId: 'b', createTime: 2, localId: 20, candidates: ['b'] }
|
||||
])
|
||||
).resolves.toEqual([
|
||||
{ success: true, hex: 'aabb', error: '' },
|
||||
{ success: false, hex: undefined, error: 'missing' }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the async Koffi path for a single voice lookup and releases the result', async () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
const nativePointer = { value: 'aabb' }
|
||||
const freeString = vi.fn()
|
||||
const nativeFunction = {
|
||||
async: vi.fn((...args: unknown[]) => {
|
||||
const outHex = args.at(-2) as [unknown]
|
||||
const callback = args.at(-1) as (error: unknown, code: number) => void
|
||||
outHex[0] = nativePointer
|
||||
queueMicrotask(() => callback(null, 0))
|
||||
})
|
||||
}
|
||||
setPrivate(client, 'wcdbGetVoiceData', nativeFunction)
|
||||
setPrivate(client, 'wcdbFreeString', freeString)
|
||||
setPrivate(client, 'handle', 1)
|
||||
setPrivate(client, 'closing', false)
|
||||
setPrivate(client, 'nativeCallsInFlight', new Set())
|
||||
setPrivate(
|
||||
client,
|
||||
'decodeHexPtr',
|
||||
vi.fn(() => 'aabb')
|
||||
)
|
||||
|
||||
await expect(client.getVoiceData('session', 100, ['session'], 10, 20)).resolves.toEqual({
|
||||
success: true,
|
||||
hex: 'aabb',
|
||||
error: ''
|
||||
})
|
||||
expect(nativeFunction.async).toHaveBeenCalledOnce()
|
||||
expect(freeString).toHaveBeenCalledWith(nativePointer)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user