d3ro-voice/apps/desktop/tests/main/services/VoiceConversationService.test.ts
Yun Chan 5c11ee2fde
Some checks failed
deploy-site / deploy (push) Failing after 33s
portable-unsigned / portable-windows (push) Failing after 4m7s
release / release-windows (push) Failing after 3m16s
release: ship v1.5.0 with on-device writing suggestions
Adds next-sentence suggestions while typing, weekly input insights and a
personal phrase memory to the desktop app, and fixes custom instructions so
they process the text instead of inserting the instruction's own wording.
Local model requests are now bounded and individually cancellable.

Bumps the product version to 1.5.0 (Android/iOS build 1050000), refreshes the
landing and web download links, and records the new INPUT feature rows and the
open verification gaps in the infrastructure map.
2026-09-23 16:04:27 +09:00

194 lines
6.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
import { ErrorCode } from '@d3ro/core/errors'
import {
getVoiceConversationService,
resetVoiceConversationServiceForTests,
} from '../../../src/main/services/VoiceConversationService'
type ChatOptions = {
signal?: AbortSignal
maxTokens?: number
timeoutMs?: number
keepAlive?: string
}
const mocks = vi.hoisted(() => ({
audioStart: vi.fn(async () => undefined),
audioStop: vi.fn(async () => undefined),
chatStream: vi.fn(),
premiumCancel: vi.fn(),
speakSentences: vi.fn(async () => undefined),
ttsStop: vi.fn(),
send: vi.fn(),
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
}))
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({
start: mocks.audioStart,
stop: mocks.audioStop,
on: vi.fn(),
off: vi.fn(),
}),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({
getLocalSTTService: () => ({ initialize: vi.fn(async () => undefined) }),
}))
vi.mock('../../../src/main/services/PremiumLLMService', () => ({
getPremiumLLMService: () => ({
cancelGeneration: mocks.premiumCancel,
isAvailable: () => false,
}),
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({
isAvailable: () => true,
chatStream: mocks.chatStream,
}),
}))
vi.mock('../../../src/main/services/TTSPlaybackService', () => ({
getTTSPlaybackService: () => ({
speakSentences: mocks.speakSentences,
stop: mocks.ttsStop,
}),
}))
vi.mock('../../../src/main/services/SoundEffectService', () => ({
getSoundEffectService: () => ({ play: vi.fn() }),
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: () => undefined,
}))
vi.mock('../../../src/main/services/LicenseService', () => ({
getLicenseService: () => ({
canUse: () => ({ allowed: true }),
promptUpgrade: vi.fn(),
}),
}))
vi.mock('../../../src/main/windows/WindowManager', () => ({
getMainWindow: () => ({
isDestroyed: () => false,
webContents: { send: mocks.send },
}),
}))
function waitForAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => signal.addEventListener('abort', () => resolve(), { once: true }))
}
async function* waitUntilAborted(options?: ChatOptions): AsyncGenerator<string, string> {
if (!options?.signal) throw new Error('Expected caller-owned abort signal')
await waitForAbort(options.signal)
return ''
}
describe('VoiceConversationService local response ownership', () => {
beforeEach(() => {
resetVoiceConversationServiceForTests()
vi.clearAllMocks()
mocks.chatStream.mockImplementation((_messages: unknown, options?: ChatOptions) => waitUntilAborted(options))
})
afterEach(() => {
resetVoiceConversationServiceForTests()
})
it('rejects a second send without starting a second local chat or adding duplicate history', async () => {
const service = getVoiceConversationService()
await service.startSession()
const firstResponse = service.sendTextMessage('first')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
await expect(service.sendTextMessage('second')).rejects.toMatchObject({
code: ErrorCode.ConversationLLMFailed,
})
expect(mocks.chatStream).toHaveBeenCalledTimes(1)
expect(service.getHistory().map((message) => message.content)).toEqual(['first'])
service.stopSession()
await firstResponse
})
it.each([
['stopSession', (service: ReturnType<typeof getVoiceConversationService>) => service.stopSession()],
['cancelResponse', (service: ReturnType<typeof getVoiceConversationService>) => service.cancelResponse()],
])('aborts the local stream signal when %s is called', async (_action, cancel) => {
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('cancel me')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
const options = mocks.chatStream.mock.calls[0][1] as ChatOptions
cancel(service)
expect(options.signal?.aborted).toBe(true)
await response
})
it('sets bounded local chat options for voice responses', async () => {
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('options')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
expect(mocks.chatStream).toHaveBeenCalledWith(
expect.any(Array),
expect.objectContaining({
signal: expect.any(AbortSignal),
maxTokens: 512,
timeoutMs: 60_000,
keepAlive: '2m',
}),
)
service.stopSession()
await response
})
it('does not publish late assistant output or start TTS after cancellation', async () => {
let releaseLateToken: (() => void) | undefined
const lateToken = new Promise<void>((resolve) => {
releaseLateToken = resolve
})
mocks.chatStream.mockImplementation(() => (async function* (): AsyncGenerator<string, string> {
await lateToken
yield 'late answer.'
return 'late answer.'
})())
const service = getVoiceConversationService()
await service.startSession()
const response = service.sendTextMessage('cancel before response')
await vi.waitFor(() => expect(mocks.chatStream).toHaveBeenCalledTimes(1))
service.cancelResponse()
mocks.send.mockClear()
releaseLateToken?.()
await response
const lateConversationEvents = mocks.send.mock.calls.filter(([channel]) => (
channel === IPC_CHANNELS.VOICE_CONVERSATION.ASSISTANT_DELTA
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.ASSISTANT_MESSAGE
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.TTS_STARTED
|| channel === IPC_CHANNELS.VOICE_CONVERSATION.TTS_FINISHED
))
expect(lateConversationEvents).toEqual([])
expect(mocks.speakSentences).not.toHaveBeenCalled()
expect(service.getHistory().map((message) => message.content)).toEqual(['cancel before response'])
})
})