feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { toAiRuntimeError } from '@/lib/ai-runtime/errors'
describe('toAiRuntimeError empty response mapping', () => {
it('maps nested Gemini empty response signal to EMPTY_RESPONSE even when status is 429', () => {
const upstreamError = new Error('Too Many Requests') as Error & {
status?: number
cause?: unknown
}
upstreamError.status = 429
upstreamError.cause = {
error: {
message: 'received empty response from Gemini: no meaningful content in candidates (request id: x)',
type: 'channel_error',
code: 'channel:empty_response',
},
code: 429,
status: 'Too Many Requests',
}
const runtimeError = toAiRuntimeError(upstreamError)
expect(runtimeError.code).toBe('EMPTY_RESPONSE')
expect(runtimeError.retryable).toBe(true)
})
it('keeps RATE_LIMIT when there is no empty response signal', () => {
const runtimeError = toAiRuntimeError({
status: 429,
message: 'Too Many Requests',
})
expect(runtimeError.code).toBe('RATE_LIMIT')
expect(runtimeError.retryable).toBe(true)
})
})

View File

@@ -0,0 +1,71 @@
import type { UIMessage } from 'ai'
import { describe, expect, it } from 'vitest'
import { extractMessageContent } from '@/components/assistant/AssistantChatModal'
function createAssistantMessage(parts: Array<Record<string, unknown>>): UIMessage {
return {
id: 'assistant-message',
role: 'assistant',
parts,
} as unknown as UIMessage
}
describe('assistant chat modal message content parser', () => {
it('keeps reasoning parts out of normal visible lines', () => {
const message = createAssistantMessage([
{ type: 'reasoning', text: '先分析接口字段映射' },
{ type: 'text', text: '我需要你的 status 返回样例。' },
])
const content = extractMessageContent(message)
expect(content.lines).toEqual(['我需要你的 status 返回样例。'])
expect(content.reasoningLines).toEqual(['先分析接口字段映射'])
})
it('extracts think tags from text into reasoning section', () => {
const message = createAssistantMessage([
{
type: 'text',
text: '<think>先确认 create/status/content 三个端点</think>请补充 status 返回 JSON',
},
])
const content = extractMessageContent(message)
expect(content.lines).toEqual(['请补充 status 返回 JSON'])
expect(content.reasoningLines).toEqual(['先确认 create/status/content 三个端点'])
})
it('extracts reasoning from unclosed think tag during streaming', () => {
const message = createAssistantMessage([
{
type: 'text',
text: '<think>先确认任务状态枚举和输出路径',
},
])
const content = extractMessageContent(message)
expect(content.lines).toEqual([])
expect(content.reasoningLines).toEqual(['先确认任务状态枚举和输出路径'])
})
it('preserves tool output and issues as visible lines', () => {
const message = createAssistantMessage([
{
type: 'tool-saveModelTemplate',
state: 'output-available',
output: {
message: '模型已保存',
issues: [{ field: 'response.statusPath', message: 'missing' }],
},
},
])
const content = extractMessageContent(message)
expect(content.lines).toEqual(['模型已保存', 'response.statusPath: missing'])
expect(content.reasoningLines).toEqual([])
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { PRESET_MODELS, PRESET_PROVIDERS } from '@/app/[locale]/profile/components/api-config/types'
describe('api-config minimax preset', () => {
it('uses official minimax baseUrl in preset provider', () => {
const minimaxProvider = PRESET_PROVIDERS.find((provider) => provider.id === 'minimax')
expect(minimaxProvider).toBeDefined()
expect(minimaxProvider?.baseUrl).toBe('https://api.minimaxi.com/v1')
})
it('includes all required minimax official llm preset models', () => {
const minimaxLlmModelIds = PRESET_MODELS
.filter((model) => model.provider === 'minimax' && model.type === 'llm')
.map((model) => model.modelId)
expect(minimaxLlmModelIds).toContain('MiniMax-M2.5')
expect(minimaxLlmModelIds).toContain('MiniMax-M2.5-highspeed')
expect(minimaxLlmModelIds).toContain('MiniMax-M2.1')
expect(minimaxLlmModelIds).toContain('MiniMax-M2.1-highspeed')
expect(minimaxLlmModelIds).toContain('MiniMax-M2')
})
})

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
PRESET_MODELS,
encodeModelKey,
isPresetComingSoonModel,
isPresetComingSoonModelKey,
} from '@/app/[locale]/profile/components/api-config/types'
describe('api-config preset coming soon', () => {
it('registers Nano Banana 2 under Google AI Studio presets', () => {
const model = PRESET_MODELS.find(
(entry) => entry.provider === 'google' && entry.modelId === 'gemini-3.1-flash-image-preview',
)
expect(model).toBeDefined()
expect(model?.name).toBe('Nano Banana 2')
})
it('registers Seedance 2.0 as a coming-soon preset model', () => {
const model = PRESET_MODELS.find(
(entry) => entry.provider === 'ark' && entry.modelId === 'doubao-seedance-2-0-260128',
)
expect(model).toBeDefined()
expect(model?.name).toContain('待上线')
})
it('recognizes coming-soon model by provider/modelId and modelKey', () => {
const modelKey = encodeModelKey('ark', 'doubao-seedance-2-0-260128')
expect(isPresetComingSoonModel('ark', 'doubao-seedance-2-0-260128')).toBe(true)
expect(isPresetComingSoonModelKey(modelKey)).toBe(true)
})
it('does not mark normal preset models as coming soon', () => {
const modelKey = encodeModelKey('ark', 'doubao-seedance-1-5-pro-251215')
expect(isPresetComingSoonModel('ark', 'doubao-seedance-1-5-pro-251215')).toBe(false)
expect(isPresetComingSoonModelKey(modelKey)).toBe(false)
})
it('registers Bailian Wan i2v preset models', () => {
const modelIds = PRESET_MODELS
.filter((entry) => entry.provider === 'bailian' && entry.type === 'video')
.map((entry) => entry.modelId)
expect(modelIds).toEqual(expect.arrayContaining([
'wan2.6-i2v-flash',
'wan2.6-i2v',
'wan2.5-i2v-preview',
'wan2.2-i2v-plus',
'wan2.2-kf2v-flash',
'wanx2.1-kf2v-plus',
]))
})
})

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { getAssistantSavedModelLabel } from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
describe('provider card assistant saved label', () => {
it('prefers draft model name when available', () => {
const label = getAssistantSavedModelLabel({
savedModelKey: 'openai-compatible:oa-1::veo_3_1-fast-4K',
draftModel: {
modelId: 'veo_3_1-fast-4K',
name: 'Veo 3.1 Fast 4K',
type: 'video',
provider: 'openai-compatible:oa-1',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/v1/video/create',
},
status: {
method: 'GET',
path: '/v1/video/query?id={{task_id}}',
},
response: {
taskIdPath: '$.id',
statusPath: '$.status',
},
polling: {
intervalMs: 5000,
timeoutMs: 600000,
doneStates: ['completed'],
failStates: ['failed'],
},
},
},
})
expect(label).toBe('Veo 3.1 Fast 4K')
})
it('falls back to model id parsed from savedModelKey', () => {
const label = getAssistantSavedModelLabel({
savedModelKey: 'openai-compatible:oa-1::veo_3_1-fast-4K',
})
expect(label).toBe('veo_3_1-fast-4K')
})
})

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import {
getAddableModelTypesForProvider,
getVisibleModelTypesForProvider,
shouldShowOpenAICompatVideoHint,
} from '@/app/[locale]/profile/components/api-config/provider-card/ProviderAdvancedFields'
import {
buildCustomPricingFromModelForm,
buildProviderConnectionPayload,
} from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
describe('provider card pricing form behavior', () => {
it('allows openai-compatible provider to add llm/image/video', () => {
expect(getAddableModelTypesForProvider('openai-compatible:oa-1')).toEqual(['llm', 'image', 'video'])
})
it('shows llm/image/video tabs by default for openai-compatible even with only image models', () => {
const visible = getVisibleModelTypesForProvider(
'openai-compatible:oa-1',
{
image: [
{
modelId: 'gpt-image-1',
modelKey: 'openai-compatible:oa-1::gpt-image-1',
name: 'Image',
type: 'image',
provider: 'openai-compatible:oa-1',
price: 0,
enabled: true,
},
],
},
)
expect(visible).toEqual(['llm', 'image', 'video'])
})
it('shows the openai-compatible video hint only for openai-compatible video add forms', () => {
expect(shouldShowOpenAICompatVideoHint('openai-compatible:oa-1', 'video')).toBe(true)
expect(shouldShowOpenAICompatVideoHint('openai-compatible:oa-1', 'image')).toBe(false)
expect(shouldShowOpenAICompatVideoHint('gemini-compatible:gm-1', 'video')).toBe(false)
expect(shouldShowOpenAICompatVideoHint('ark', 'video')).toBe(false)
})
it('keeps payload without customPricing when pricing toggle is off', () => {
const result = buildCustomPricingFromModelForm(
'image',
{
name: 'Image',
modelId: 'gpt-image-1',
enableCustomPricing: false,
basePrice: '0.8',
},
{ needsCustomPricing: true },
)
expect(result).toEqual({ ok: true })
})
it('builds llm customPricing payload when pricing toggle is on', () => {
const result = buildCustomPricingFromModelForm(
'llm',
{
name: 'GPT',
modelId: 'gpt-4.1',
enableCustomPricing: true,
priceInput: '2.5',
priceOutput: '8',
},
{ needsCustomPricing: true },
)
expect(result).toEqual({
ok: true,
customPricing: {
llm: {
inputPerMillion: 2.5,
outputPerMillion: 8,
},
},
})
})
it('builds media customPricing payload with option prices when enabled', () => {
const result = buildCustomPricingFromModelForm(
'video',
{
name: 'Sora',
modelId: 'sora-2',
enableCustomPricing: true,
basePrice: '0.9',
optionPricesJson: '{"resolution":{"720x1280":0.1},"duration":{"8":0.4}}',
},
{ needsCustomPricing: true },
)
expect(result).toEqual({
ok: true,
customPricing: {
video: {
basePrice: 0.9,
optionPrices: {
resolution: {
'720x1280': 0.1,
},
duration: {
'8': 0.4,
},
},
},
},
})
})
it('rejects invalid media optionPrices JSON when enabled', () => {
const result = buildCustomPricingFromModelForm(
'image',
{
name: 'Image',
modelId: 'gpt-image-1',
enableCustomPricing: true,
basePrice: '0.3',
optionPricesJson: '{"resolution":{"1024x1024":"free"}}',
},
{ needsCustomPricing: true },
)
expect(result).toEqual({ ok: false, reason: 'invalid' })
})
it('bugfix: includes baseUrl for openai-compatible provider connection test payload', () => {
const payload = buildProviderConnectionPayload({
providerKey: 'openai-compatible',
apiKey: ' sk-test ',
baseUrl: ' https://api.openai-proxy.example/v1 ',
})
expect(payload).toEqual({
apiType: 'openai-compatible',
apiKey: 'sk-test',
baseUrl: 'https://api.openai-proxy.example/v1',
})
})
it('omits baseUrl for non-compatible provider connection test payload', () => {
const payload = buildProviderConnectionPayload({
providerKey: 'ark',
apiKey: ' ark-key ',
baseUrl: ' https://ignored.example/v1 ',
})
expect(payload).toEqual({
apiType: 'ark',
apiKey: 'ark-key',
})
})
it('includes llmModel in provider connection test payload when configured', () => {
const payload = buildProviderConnectionPayload({
providerKey: 'openai-compatible',
apiKey: ' sk-test ',
baseUrl: ' https://compat.example.com/v1 ',
llmModel: ' gpt-4.1-mini ',
})
expect(payload).toEqual({
apiType: 'openai-compatible',
apiKey: 'sk-test',
baseUrl: 'https://compat.example.com/v1',
llmModel: 'gpt-4.1-mini',
})
})
})

View File

@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CustomModel } from '@/app/[locale]/profile/components/api-config/types'
import {
probeModelLlmProtocolViaApi,
shouldProbeModelLlmProtocol,
shouldReprobeModelLlmProtocol,
} from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
describe('api-config provider-card protocol probe helpers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('only probes openai-compatible llm models', () => {
expect(shouldProbeModelLlmProtocol({ providerId: 'openai-compatible:oa-1', modelType: 'llm' })).toBe(true)
expect(shouldProbeModelLlmProtocol({ providerId: 'openai-compatible:oa-1', modelType: 'image' })).toBe(false)
expect(shouldProbeModelLlmProtocol({ providerId: 'gemini-compatible:gm-1', modelType: 'llm' })).toBe(false)
})
it('re-probes only when modelId/provider changed on openai-compatible llm', () => {
const originalModel: CustomModel = {
modelId: 'gpt-4.1-mini',
modelKey: 'openai-compatible:oa-1::gpt-4.1-mini',
name: 'GPT 4.1 Mini',
type: 'llm',
provider: 'openai-compatible:oa-1',
llmProtocol: 'chat-completions',
llmProtocolCheckedAt: '2026-01-01T00:00:00.000Z',
price: 0,
enabled: true,
}
expect(shouldReprobeModelLlmProtocol({
providerId: 'openai-compatible:oa-1',
originalModel,
nextModelId: 'gpt-4.1-mini',
})).toBe(false)
expect(shouldReprobeModelLlmProtocol({
providerId: 'openai-compatible:oa-1',
originalModel,
nextModelId: 'gpt-4.1',
})).toBe(true)
expect(shouldReprobeModelLlmProtocol({
providerId: 'gemini-compatible:gm-1',
originalModel,
nextModelId: 'gpt-4.1',
})).toBe(false)
})
it('parses successful probe response payload', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
success: true,
protocol: 'responses',
checkedAt: '2026-03-05T10:00:00.000Z',
}), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const result = await probeModelLlmProtocolViaApi({
providerId: 'openai-compatible:oa-1',
modelId: 'gpt-4.1-mini',
})
expect(result).toEqual({
llmProtocol: 'responses',
llmProtocolCheckedAt: '2026-03-05T10:00:00.000Z',
})
})
it('throws probe failure code on unsuccessful probe response', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
success: false,
code: 'PROBE_INCONCLUSIVE',
}), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
await expect(probeModelLlmProtocolViaApi({
providerId: 'openai-compatible:oa-1',
modelId: 'gpt-4.1-mini',
})).rejects.toThrow('PROBE_INCONCLUSIVE')
})
})

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { getCompatibilityLayerBadgeLabel } from '@/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell'
describe('provider card shell compatibility layer badge', () => {
const t = (key: string): string => {
if (key === 'compatibilityLayerOpenAI') return 'OpenAI 兼容层'
if (key === 'compatibilityLayerGemini') return 'Gemini 兼容层'
return key
}
it('shows OpenAI compatible layer label for openai-compatible providers', () => {
expect(getCompatibilityLayerBadgeLabel('openai-compatible:oa-1', t)).toBe('OpenAI 兼容层')
})
it('shows Gemini compatible layer label for gemini-compatible providers', () => {
expect(getCompatibilityLayerBadgeLabel('gemini-compatible:gm-1', t)).toBe('Gemini 兼容层')
})
it('does not show compatibility label for preset providers', () => {
expect(getCompatibilityLayerBadgeLabel('google', t)).toBeNull()
expect(getCompatibilityLayerBadgeLabel('ark', t)).toBeNull()
expect(getCompatibilityLayerBadgeLabel('bailian', t)).toBeNull()
expect(getCompatibilityLayerBadgeLabel('siliconflow', t)).toBeNull()
})
})

View File

@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useMemo: <T,>(factory: () => T) => factory(),
}
})
import { useApiConfigFilters } from '@/app/[locale]/profile/components/api-config-tab/hooks/useApiConfigFilters'
import type { CustomModel, Provider } from '@/app/[locale]/profile/components/api-config/types'
describe('api config filters', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('merges audio providers into modelProviders and removes audioProviders output', () => {
const providers: Provider[] = [
{ id: 'fal', name: 'FAL', hasApiKey: true, apiKey: 'k-fal' },
{ id: 'bailian', name: 'Alibaba Bailian', hasApiKey: true, apiKey: 'k-bl' },
]
const models: CustomModel[] = [
{
modelId: 'fal-ai/index-tts-2/text-to-speech',
modelKey: 'fal::fal-ai/index-tts-2/text-to-speech',
name: 'IndexTTS 2',
type: 'audio',
provider: 'fal',
price: 0,
enabled: true,
},
{
modelId: 'qwen3-tts-vd-2026-01-26',
modelKey: 'bailian::qwen3-tts-vd-2026-01-26',
name: 'Qwen3 TTS',
type: 'audio',
provider: 'bailian',
price: 0,
enabled: true,
},
{
modelId: 'qwen-voice-design',
modelKey: 'bailian::qwen-voice-design',
name: 'Qwen Voice Design',
type: 'audio',
provider: 'bailian',
price: 0,
enabled: true,
},
{
modelId: 'qwen3.5-flash',
modelKey: 'bailian::qwen3.5-flash',
name: 'Qwen 3.5 Flash',
type: 'llm',
provider: 'bailian',
price: 0,
enabled: true,
},
]
const result = useApiConfigFilters({ providers, models })
const providerIds = result.modelProviders.map((provider) => provider.id)
const audioDefaultIds = result.getEnabledModelsByType('audio').map((model) => model.modelId)
expect(providerIds).toEqual(['fal', 'bailian'])
expect(audioDefaultIds).toEqual(expect.arrayContaining([
'fal-ai/index-tts-2/text-to-speech',
'qwen3-tts-vd-2026-01-26',
]))
expect(audioDefaultIds).not.toContain('qwen-voice-design')
expect(Object.prototype.hasOwnProperty.call(result, 'audioProviders')).toBe(false)
})
it('keeps modelProviders order aligned with providers input order', () => {
const providers: Provider[] = [
{ id: 'google', name: 'Google AI Studio', hasApiKey: true, apiKey: 'k-google' },
{ id: 'openai-compatible:oa-2', name: 'OpenAI B', hasApiKey: true, apiKey: 'k-oa2' },
{ id: 'ark', name: 'Volcengine Ark', hasApiKey: true, apiKey: 'k-ark' },
]
const models: CustomModel[] = [
{
modelId: 'gemini-3.1-pro-preview',
modelKey: 'google::gemini-3.1-pro-preview',
name: 'Gemini 3.1 Pro',
type: 'llm',
provider: 'google',
price: 0,
enabled: true,
},
{
modelId: 'gpt-4.1',
modelKey: 'openai-compatible:oa-2::gpt-4.1',
name: 'GPT 4.1',
type: 'llm',
provider: 'openai-compatible:oa-2',
price: 0,
enabled: true,
},
{
modelId: 'doubao-seed-2-0-pro-260215',
modelKey: 'ark::doubao-seed-2-0-pro-260215',
name: 'Doubao Seed 2.0 Pro',
type: 'llm',
provider: 'ark',
price: 0,
enabled: true,
},
]
const result = useApiConfigFilters({ providers, models })
expect(result.modelProviders.map((provider) => provider.id)).toEqual([
'google',
'openai-compatible:oa-2',
'ark',
])
})
})

View File

@@ -0,0 +1,100 @@
import type { UIMessage } from 'ai'
import { describe, expect, it } from 'vitest'
import { collectSavedEvents } from '@/components/assistant/useAssistantChat'
describe('assistant chat saved events parser', () => {
it('parses single save tool output event', () => {
const messages = [{
id: 'm1',
role: 'assistant',
parts: [{
type: 'tool-saveModelTemplate',
state: 'output-available',
output: {
status: 'saved',
savedModelKey: 'openai-compatible:oa-1::veo3-fast',
draftModel: {
modelId: 'veo3-fast',
name: 'Veo 3 Fast',
type: 'video',
provider: 'openai-compatible:oa-1',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: { method: 'POST', path: '/video/create' },
status: { method: 'GET', path: '/video/query?id={{task_id}}' },
response: { taskIdPath: '$.id', statusPath: '$.status' },
polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },
},
},
},
}],
}] as unknown as UIMessage[]
const events = collectSavedEvents(messages)
expect(events).toHaveLength(1)
expect(events[0]?.savedModelKey).toBe('openai-compatible:oa-1::veo3-fast')
expect(events[0]?.draftModel?.modelId).toBe('veo3-fast')
})
it('parses batch save tool output events', () => {
const messages = [{
id: 'm2',
role: 'assistant',
parts: [{
type: 'tool-saveModelTemplates',
state: 'output-available',
output: {
status: 'saved',
savedModelKeys: [
'openai-compatible:oa-1::veo3-fast',
'openai-compatible:oa-1::veo3.1-fast',
],
draftModels: [
{
modelId: 'veo3-fast',
name: 'Veo 3 Fast',
type: 'video',
provider: 'openai-compatible:oa-1',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: { method: 'POST', path: '/video/create' },
status: { method: 'GET', path: '/video/query?id={{task_id}}' },
response: { taskIdPath: '$.id', statusPath: '$.status' },
polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },
},
},
{
modelId: 'veo3.1-fast',
name: 'Veo 3.1 Fast',
type: 'video',
provider: 'openai-compatible:oa-1',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: { method: 'POST', path: '/video/create' },
status: { method: 'GET', path: '/video/query?id={{task_id}}' },
response: { taskIdPath: '$.id', statusPath: '$.status' },
polling: { intervalMs: 5000, timeoutMs: 600000, doneStates: ['completed'], failStates: ['failed'] },
},
},
],
},
}],
}] as unknown as UIMessage[]
const events = collectSavedEvents(messages)
expect(events).toHaveLength(2)
expect(events.map((item) => item.savedModelKey)).toEqual([
'openai-compatible:oa-1::veo3-fast',
'openai-compatible:oa-1::veo3.1-fast',
])
expect(events[1]?.draftModel?.name).toBe('Veo 3.1 Fast')
})
})

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { mergeProvidersForDisplay } from '@/app/[locale]/profile/components/api-config/hooks'
import type { Provider } from '@/app/[locale]/profile/components/api-config/types'
describe('useProviders provider order merge', () => {
it('preserves saved providers order and appends missing presets at the end', () => {
const presetProviders: Provider[] = [
{ id: 'ark', name: '火山引擎 Ark' },
{ id: 'google', name: 'Google AI Studio' },
{ id: 'bailian', name: '阿里云百炼' },
]
const savedProviders: Provider[] = [
{ id: 'google', name: 'Google Legacy Name', apiKey: 'google-key', hidden: true },
{ id: 'openai-compatible:oa-2', name: 'OpenAI B', baseUrl: 'https://oa-b.test', apiKey: 'oa-key' },
{ id: 'ark', name: 'Ark Legacy Name', apiKey: 'ark-key' },
]
const merged = mergeProvidersForDisplay(savedProviders, presetProviders)
expect(merged.map((provider) => provider.id)).toEqual([
'google',
'openai-compatible:oa-2',
'ark',
'bailian',
])
expect(merged[0]?.hidden).toBe(true)
})
it('uses preset localized names for preset providers while keeping apiKey/baseUrl from saved data', () => {
const presetProviders: Provider[] = [
{ id: 'google', name: 'Google AI Studio', baseUrl: 'https://google.default' },
]
const savedProviders: Provider[] = [
{ id: 'google', name: 'Google Old Name', baseUrl: 'https://google.custom', apiKey: 'google-key' },
]
const merged = mergeProvidersForDisplay(savedProviders, presetProviders)
expect(merged).toHaveLength(1)
expect(merged[0]).toMatchObject({
id: 'google',
name: 'Google AI Studio',
baseUrl: 'https://google.custom',
apiKey: 'google-key',
hasApiKey: true,
})
})
it('uses preset official baseUrl for minimax even when saved payload contains a custom baseUrl', () => {
const presetProviders: Provider[] = [
{ id: 'minimax', name: 'MiniMax Hailuo', baseUrl: 'https://api.minimaxi.com/v1' },
]
const savedProviders: Provider[] = [
{ id: 'minimax', name: 'MiniMax Legacy', baseUrl: 'https://custom.minimax.proxy/v1', apiKey: 'mm-key' },
]
const merged = mergeProvidersForDisplay(savedProviders, presetProviders)
expect(merged).toHaveLength(1)
expect(merged[0]).toMatchObject({
id: 'minimax',
name: 'MiniMax Hailuo',
baseUrl: 'https://api.minimaxi.com/v1',
apiKey: 'mm-key',
hasApiKey: true,
})
})
})

View File

@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import { getAssistantSkill, isAssistantId } from '@/lib/assistant-platform'
describe('assistant-platform registry', () => {
it('recognizes supported assistant ids', () => {
expect(isAssistantId('api-config-template')).toBe(true)
expect(isAssistantId('tutorial')).toBe(true)
expect(isAssistantId('unknown')).toBe(false)
})
it('returns registered skills', () => {
expect(getAssistantSkill('api-config-template').id).toBe('api-config-template')
expect(getAssistantSkill('tutorial').id).toBe('tutorial')
})
})

View File

@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getUserModelConfigMock = vi.hoisted(() =>
vi.fn(async () => ({ analysisModel: null })),
)
vi.mock('@/lib/config-service', () => ({
getUserModelConfig: getUserModelConfigMock,
}))
import { AssistantPlatformError } from '@/lib/assistant-platform'
import { createAssistantChatResponse } from '@/lib/assistant-platform/runtime'
describe('assistant-platform runtime', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws invalid request when messages payload is malformed', async () => {
await expect(createAssistantChatResponse({
userId: 'user-1',
assistantId: 'api-config-template',
context: {},
messages: { invalid: true },
})).rejects.toMatchObject({
code: 'ASSISTANT_INVALID_REQUEST',
} as Partial<AssistantPlatformError>)
})
it('throws missing model when analysisModel is not configured', async () => {
await expect(createAssistantChatResponse({
userId: 'user-1',
assistantId: 'api-config-template',
context: {
providerId: 'openai-compatible:oa-1',
},
messages: [{
id: 'u1',
role: 'user',
parts: [{ type: 'text', text: 'hello' }],
}],
})).rejects.toMatchObject({
code: 'ASSISTANT_MODEL_NOT_CONFIGURED',
} as Partial<AssistantPlatformError>)
})
})

View File

@@ -0,0 +1,230 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssistantRuntimeContext } from '@/lib/assistant-platform'
const saveModelTemplateConfigurationMock = vi.hoisted(() =>
vi.fn(async () => ({ modelKey: 'openai-compatible:oa-1::veo3.1' })),
)
vi.mock('@/lib/user-api/model-template/save', () => ({
saveModelTemplateConfiguration: saveModelTemplateConfigurationMock,
}))
import { apiConfigTemplateSkill } from '@/lib/assistant-platform/skills/api-config-template'
function buildRuntimeContext(): AssistantRuntimeContext {
return {
userId: 'user-1',
assistantId: 'api-config-template',
context: {
providerId: 'openai-compatible:oa-1',
},
analysisModelKey: 'openrouter::gpt-5-mini',
resolvedModel: {
providerId: 'openrouter',
providerKey: 'openrouter',
modelId: 'gpt-5-mini',
},
}
}
describe('assistant-platform api-config-template skill', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns invalid when template fails schema validation', async () => {
const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())
expect(tools).toBeTruthy()
const saveTool = tools?.saveModelTemplate
expect(saveTool).toBeTruthy()
if (!saveTool?.execute) {
throw new Error('saveModelTemplate.execute is required for test')
}
const result = await saveTool.execute({
modelId: 'veo3.1',
name: 'Veo 3.1',
type: 'video',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/v2/videos/generations',
},
response: {
taskIdPath: '$.task_id',
},
},
}, {} as never)
expect(result.status).toBe('invalid')
expect(result.code).toBe('MODEL_TEMPLATE_INVALID')
expect(saveModelTemplateConfigurationMock).not.toHaveBeenCalled()
})
it('saves template when payload is valid', async () => {
const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())
expect(tools).toBeTruthy()
const saveTool = tools?.saveModelTemplate
expect(saveTool).toBeTruthy()
if (!saveTool?.execute) {
throw new Error('saveModelTemplate.execute is required for test')
}
const result = await saveTool.execute({
modelId: 'veo3.1',
name: 'Veo 3.1',
type: 'video',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/v2/videos/generations',
contentType: 'application/json',
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
},
},
status: {
method: 'GET',
path: '/v2/videos/generations/{{task_id}}',
},
response: {
taskIdPath: '$.task_id',
statusPath: '$.status',
outputUrlPath: '$.video_url',
},
polling: {
intervalMs: 3000,
timeoutMs: 180000,
doneStates: ['done'],
failStates: ['failed'],
},
},
}, {} as never)
expect(result.status).toBe('saved')
expect(result.savedModelKey).toBe('openai-compatible:oa-1::veo3.1')
expect(saveModelTemplateConfigurationMock).toHaveBeenCalledWith({
userId: 'user-1',
providerId: 'openai-compatible:oa-1',
modelId: 'veo3.1',
name: 'Veo 3.1',
type: 'video',
template: expect.objectContaining({
mediaType: 'video',
}),
source: 'ai',
})
})
it('saves multiple templates when batch payload is valid', async () => {
const tools = apiConfigTemplateSkill.tools?.(buildRuntimeContext())
expect(tools).toBeTruthy()
const batchTool = tools?.saveModelTemplates
expect(batchTool).toBeTruthy()
if (!batchTool?.execute) {
throw new Error('saveModelTemplates.execute is required for test')
}
const result = await batchTool.execute({
models: [
{
modelId: 'veo3-fast',
name: 'Veo 3 Fast',
type: 'video',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/video/create',
contentType: 'application/json',
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
images: ['{{image}}'],
},
},
status: {
method: 'GET',
path: '/video/query?id={{task_id}}',
},
response: {
taskIdPath: '$.id',
statusPath: '$.status',
outputUrlPath: '$.video_url',
},
polling: {
intervalMs: 5000,
timeoutMs: 600000,
doneStates: ['completed'],
failStates: ['failed'],
},
},
},
{
modelId: 'veo3.1-fast',
name: 'Veo 3.1 Fast',
type: 'video',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/video/create',
contentType: 'application/json',
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
images: ['{{image}}'],
},
},
status: {
method: 'GET',
path: '/video/query?id={{task_id}}',
},
response: {
taskIdPath: '$.id',
statusPath: '$.status',
outputUrlPath: '$.video_url',
},
polling: {
intervalMs: 5000,
timeoutMs: 600000,
doneStates: ['completed'],
failStates: ['failed'],
},
},
},
],
}, {} as never)
expect(result.status).toBe('saved')
expect(result.savedModelKeys).toHaveLength(2)
expect(saveModelTemplateConfigurationMock).toHaveBeenCalledTimes(2)
expect(saveModelTemplateConfigurationMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
modelId: 'veo3-fast',
name: 'Veo 3 Fast',
providerId: 'openai-compatible:oa-1',
userId: 'user-1',
type: 'video',
source: 'ai',
}))
expect(saveModelTemplateConfigurationMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
modelId: 'veo3.1-fast',
name: 'Veo 3.1 Fast',
providerId: 'openai-compatible:oa-1',
userId: 'user-1',
type: 'video',
source: 'ai',
}))
})
})

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { renderAssistantSystemPrompt } from '@/lib/assistant-platform/system-prompts'
describe('assistant-platform system prompts', () => {
it('loads api-config-template prompt from lib/prompts/skills and injects providerId', () => {
const prompt = renderAssistantSystemPrompt('api-config-template', {
providerId: 'openai-compatible:oa-1',
})
expect(prompt).toContain('你是 API 配置助手')
expect(prompt).toContain('当前 providerId=openai-compatible:oa-1')
expect(prompt).not.toContain('{{providerId}}')
})
it('loads tutorial prompt from lib/prompts/skills', () => {
const prompt = renderAssistantSystemPrompt('tutorial')
expect(prompt).toContain('你是产品教程助手')
expect(prompt).toContain('禁止编造不存在的页面')
})
})

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
id: 'openai-compatible:oa-1',
apiKey: 'sk-test',
baseUrl: 'https://compat.example.com/v1',
})))
const getUserModelsMock = vi.hoisted(() =>
vi.fn<typeof import('@/lib/api-config').getUserModels>(async () => []),
)
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
getUserModels: getUserModelsMock,
}))
import { pollAsyncTask } from '@/lib/async-poll'
function encode(value: string): string {
return Buffer.from(value, 'utf8').toString('base64url')
}
describe('async poll ocompat', () => {
beforeEach(() => {
vi.clearAllMocks()
globalThis.fetch = vi.fn() as unknown as typeof fetch
})
it('returns completed with output url when async status reaches done', async () => {
getUserModelsMock.mockResolvedValueOnce([
{
modelKey: 'openai-compatible:oa-1::veo3.1',
modelId: 'veo3.1',
name: 'Veo 3.1',
type: 'video',
provider: 'openai-compatible:oa-1',
price: 0,
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: { method: 'POST', path: '/v2/videos/generations' },
status: { method: 'GET', path: '/v2/videos/generations/{{task_id}}' },
response: {
statusPath: '$.status',
outputUrlPath: '$.video_url',
},
polling: {
intervalMs: 3000,
timeoutMs: 180000,
doneStates: ['succeeded'],
failStates: ['failed'],
},
},
},
])
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
status: 'succeeded',
video_url: 'https://cdn.test/video.mp4',
}), { status: 200 }))
globalThis.fetch = fetchMock as unknown as typeof fetch
const result = await pollAsyncTask(
`OCOMPAT:VIDEO:${encode('openai-compatible:oa-1')}:${encode('openai-compatible:oa-1::veo3.1')}:task_1`,
'user-1',
)
expect(result).toEqual({
status: 'completed',
resultUrl: 'https://cdn.test/video.mp4',
videoUrl: 'https://cdn.test/video.mp4',
})
})
it('uses content endpoint when output url is missing', async () => {
getUserModelsMock.mockResolvedValueOnce([
{
modelKey: 'openai-compatible:oa-1::veo3.1',
modelId: 'veo3.1',
name: 'Veo 3.1',
type: 'video',
provider: 'openai-compatible:oa-1',
price: 0,
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: { method: 'POST', path: '/v2/videos/generations' },
status: { method: 'GET', path: '/v2/videos/generations/{{task_id}}' },
content: { method: 'GET', path: '/v2/videos/generations/{{task_id}}/content' },
response: {
statusPath: '$.status',
},
polling: {
intervalMs: 3000,
timeoutMs: 180000,
doneStates: ['succeeded'],
failStates: ['failed'],
},
},
},
])
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
status: 'succeeded',
}), { status: 200 }))
globalThis.fetch = fetchMock as unknown as typeof fetch
const result = await pollAsyncTask(
`OCOMPAT:VIDEO:${encode('openai-compatible:oa-1')}:${encode('openai-compatible:oa-1::veo3.1')}:task_2`,
'user-1',
)
expect(result.status).toBe('completed')
expect(result.videoUrl).toBe('https://compat.example.com/v1/v2/videos/generations/task_2/content')
expect(result.downloadHeaders).toEqual({
Authorization: 'Bearer sk-test',
})
})
it('accepts compact OCOMPAT token encoded from modelId', async () => {
const providerUuid = '33331fb0-2806-4da6-85ff-cd2433b587d0'
getUserModelsMock.mockResolvedValueOnce([
{
modelKey: `openai-compatible:${providerUuid}::veo3.1-fast`,
modelId: 'veo3.1-fast',
name: 'Veo 3.1 Fast',
type: 'video',
provider: `openai-compatible:${providerUuid}`,
price: 0,
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: { method: 'POST', path: '/video/create' },
status: { method: 'GET', path: '/video/query?id={{task_id}}' },
response: {
statusPath: '$.status',
outputUrlPath: '$.video_url',
},
polling: {
intervalMs: 3000,
timeoutMs: 180000,
doneStates: ['completed'],
failStates: ['failed'],
},
},
},
])
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
status: 'completed',
video_url: 'https://cdn.test/video-fast.mp4',
}), { status: 200 }))
globalThis.fetch = fetchMock as unknown as typeof fetch
const result = await pollAsyncTask(
`OCOMPAT:VIDEO:u_${providerUuid}:${encode('veo3.1-fast')}:task_3`,
'user-1',
)
expect(result).toEqual({
status: 'completed',
resultUrl: 'https://cdn.test/video-fast.mp4',
videoUrl: 'https://cdn.test/video-fast.mp4',
})
})
})

View File

@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const lookupMock = vi.hoisted(() => ({
resolveBuiltinPricing: vi.fn(),
}))
vi.mock('@/lib/model-pricing/lookup', () => ({
resolveBuiltinPricing: lookupMock.resolveBuiltinPricing,
}))
import { calcImage, calcText, calcVideo, calcVoice } from '@/lib/billing/cost'
describe('billing/cost error branches', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('throws ambiguous pricing error when catalog has multiple candidates', () => {
lookupMock.resolveBuiltinPricing.mockReturnValue({
status: 'ambiguous_model',
apiType: 'image',
modelId: 'shared-model',
candidates: [
{
apiType: 'image',
provider: 'p1',
modelId: 'shared-model',
pricing: { mode: 'flat', flatAmount: 1 },
},
{
apiType: 'image',
provider: 'p2',
modelId: 'shared-model',
pricing: { mode: 'flat', flatAmount: 1 },
},
],
})
expect(() => calcImage('shared-model', 1)).toThrow('Ambiguous image pricing modelId')
})
it('throws unknown model when catalog returns not_configured', () => {
lookupMock.resolveBuiltinPricing.mockReturnValue({
status: 'not_configured',
})
expect(() => calcImage('provider::missing-image-model', 1)).toThrow('Unknown image model pricing')
})
it('normalizes invalid numeric inputs to zero before pricing', () => {
lookupMock.resolveBuiltinPricing.mockImplementation(
(input: { selections?: { tokenType?: 'input' | 'output' } }) => {
if (input.selections?.tokenType === 'input') return { status: 'resolved', amount: 2 }
if (input.selections?.tokenType === 'output') return { status: 'resolved', amount: 4 }
return { status: 'resolved', amount: 3 }
},
)
expect(calcText('text-model', Number.NaN, 1_000_000)).toBeCloseTo(4, 8)
expect(calcText('text-model', 1_000_000, Number.NaN)).toBeCloseTo(2, 8)
expect(calcImage('image-model', Number.NaN)).toBe(0)
expect(calcVideo('video-model', '720p', Number.NaN)).toBe(0)
expect(calcVoice(Number.NaN)).toBe(0)
})
})

View File

@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import {
USD_TO_CNY,
calcImage,
calcLipSync,
calcText,
calcVideo,
calcVoice,
calcVoiceDesign,
} from '@/lib/billing/cost'
describe('billing/cost', () => {
it('calculates text cost by known model price table', () => {
const cost = calcText('anthropic/claude-sonnet-4', 1_000_000, 1_000_000)
expect(cost).toBeCloseTo((3 + 15) * USD_TO_CNY, 8)
})
it('throws when text model pricing is unknown', () => {
expect(() => calcText('unknown-model', 500_000, 250_000)).toThrow('Unknown text model pricing')
})
it('throws when image model pricing is unknown', () => {
expect(() => calcImage('missing-image-model', 3)).toThrow('Unknown image model pricing')
})
it('supports resolution-aware video pricing', () => {
const cost720 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '720p', 2)
const cost1080 = calcVideo('doubao-seedance-1-0-pro-fast-251015', '1080p', 2)
expect(cost720).toBeCloseTo(0.86, 8)
expect(cost1080).toBeCloseTo(2.06, 8)
expect(() => calcVideo('doubao-seedance-1-0-pro-fast-251015', '2k', 1)).toThrow('Unsupported video resolution pricing')
expect(() => calcVideo('unknown-video-model', '720p', 1)).toThrow('Unknown video model pricing')
})
it('scales ark video pricing by selected duration when tiers omit duration', () => {
const shortDuration = calcVideo('doubao-seedance-1-0-pro-250528', '480p', 1, {
generationMode: 'normal',
resolution: '480p',
duration: 2,
})
const longDuration = calcVideo('doubao-seedance-1-0-pro-250528', '1080p', 1, {
generationMode: 'normal',
resolution: '1080p',
duration: 12,
})
expect(shortDuration).toBeCloseTo(0.292, 8)
expect(longDuration).toBeCloseTo(8.808, 8)
})
it('uses Ark 1.5 official default generateAudio=true when audio is omitted', () => {
const defaultAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {
generationMode: 'normal',
resolution: '720p',
})
const muteAudio = calcVideo('doubao-seedance-1-5-pro-251215', '720p', 1, {
generationMode: 'normal',
resolution: '720p',
generateAudio: false,
})
expect(defaultAudio).toBeCloseTo(1.73, 8)
expect(muteAudio).toBeCloseTo(0.86, 8)
})
it('supports Ark Seedance 1.0 Lite i2v pricing and duration scaling', () => {
const shortDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '480p', 1, {
generationMode: 'normal',
resolution: '480p',
duration: 2,
})
const longDuration = calcVideo('doubao-seedance-1-0-lite-i2v-250428', '1080p', 1, {
generationMode: 'firstlastframe',
resolution: '1080p',
duration: 12,
})
expect(shortDuration).toBeCloseTo(0.196, 8)
expect(longDuration).toBeCloseTo(5.88, 8)
})
it('rejects unsupported Ark capability values before pricing', () => {
expect(() => calcVideo('doubao-seedance-1-0-lite-i2v-250428', '720p', 1, {
generationMode: 'normal',
resolution: '720p',
duration: 1,
})).toThrow('Unsupported video capability pricing')
})
it('supports minimax capability-aware video pricing', () => {
const hailuoNormal = calcVideo('minimax-hailuo-2.3', '768p', 1, {
generationMode: 'normal',
resolution: '768p',
duration: 6,
})
const hailuoFirstLast = calcVideo('minimax-hailuo-02', '768p', 1, {
generationMode: 'firstlastframe',
resolution: '768p',
duration: 10,
})
const t2v = calcVideo('t2v-01', '720p', 1, {
generationMode: 'normal',
resolution: '720p',
duration: 6,
})
expect(hailuoNormal).toBeCloseTo(2.0, 8)
expect(hailuoFirstLast).toBeCloseTo(4.0, 8)
expect(t2v).toBeCloseTo(3.0, 8)
expect(() => calcVideo('minimax-hailuo-02', '512p', 1, {
generationMode: 'firstlastframe',
resolution: '512p',
duration: 6,
})).toThrow('Unsupported video capability pricing')
})
it('prefers builtin image pricing over custom pricing when builtin exists', () => {
const builtin = calcImage('banana', 1)
const withCustom = calcImage('banana', 1, undefined, {
image: {
basePrice: 99,
},
})
expect(withCustom).toBeCloseTo(builtin, 8)
})
it('uses custom image option pricing for unknown models', () => {
const cost = calcImage(
'openai-compatible:oa-1::gpt-image-1',
2,
{
resolution: '1024x1024',
quality: 'high',
},
{
image: {
basePrice: 0.2,
optionPrices: {
resolution: {
'1024x1024': 0.05,
},
quality: {
high: 0.1,
},
},
},
},
)
expect(cost).toBeCloseTo((0.2 + 0.05 + 0.1) * 2, 8)
})
it('uses custom video option pricing for unknown models', () => {
const cost = calcVideo(
'openai-compatible:oa-1::sora-2',
'720p',
1,
{
resolution: '720x1280',
duration: 8,
},
{
video: {
basePrice: 0.8,
optionPrices: {
resolution: {
'720x1280': 0.2,
},
duration: {
'8': 0.4,
},
},
},
},
)
expect(cost).toBeCloseTo(1.4, 8)
})
it('fails explicitly when selected custom option price is missing', () => {
expect(() => calcVideo(
'openai-compatible:oa-1::sora-2',
'720p',
1,
{
resolution: '1792x1024',
},
{
video: {
optionPrices: {
resolution: {
'720x1280': 0.2,
},
},
},
},
)).toThrow('No custom video price matched')
})
it('returns deterministic fixed costs for call-based APIs', () => {
expect(calcVoiceDesign()).toBeGreaterThan(0)
expect(calcLipSync()).toBeGreaterThan(0)
expect(calcLipSync('vidu::vidu-lipsync')).toBeGreaterThan(0)
expect(calcLipSync('bailian::videoretalk')).toBeGreaterThan(0)
})
it('calculates voice costs from quantities', () => {
expect(calcVoice(30)).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,135 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
$transaction: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/logging/core', () => ({
logInfo: vi.fn(),
logError: vi.fn(),
}))
import { addBalance, recordShadowUsage } from '@/lib/billing/ledger'
function buildTxStub() {
return {
userBalance: {
upsert: vi.fn(),
},
balanceTransaction: {
findFirst: vi.fn(),
create: vi.fn(),
},
}
}
describe('billing/ledger extra', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns false when addBalance amount is invalid', async () => {
const result = await addBalance('u1', 0)
expect(result).toBe(false)
expect(prismaMock.$transaction).not.toHaveBeenCalled()
})
it('adds recharge balance with string reason', async () => {
const tx = buildTxStub()
tx.userBalance.upsert.mockResolvedValue({ balance: 8.5 })
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
await callback(tx)
})
const result = await addBalance('u1', 5, 'manual recharge')
expect(result).toBe(true)
expect(tx.balanceTransaction.findFirst).not.toHaveBeenCalled()
expect(tx.userBalance.upsert).toHaveBeenCalledTimes(1)
expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
userId: 'u1',
type: 'recharge',
amount: 5,
}),
}))
})
it('supports idempotent addBalance and short-circuits duplicate key', async () => {
const tx = buildTxStub()
tx.balanceTransaction.findFirst.mockResolvedValue({ id: 'existing_tx' })
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
await callback(tx)
})
const result = await addBalance('u1', 3, {
type: 'adjust',
reason: 'admin adjust',
idempotencyKey: 'idem_1',
operatorId: 'op_1',
externalOrderId: 'order_1',
})
expect(result).toBe(true)
expect(tx.balanceTransaction.findFirst).toHaveBeenCalledTimes(1)
expect(tx.userBalance.upsert).not.toHaveBeenCalled()
expect(tx.balanceTransaction.create).not.toHaveBeenCalled()
})
it('returns false when transaction throws in addBalance', async () => {
prismaMock.$transaction.mockRejectedValue(new Error('db error'))
const result = await addBalance('u1', 2, 'x')
expect(result).toBe(false)
})
it('records shadow usage consume log on success', async () => {
const tx = buildTxStub()
tx.userBalance.upsert.mockResolvedValue({ balance: 11.2 })
prismaMock.$transaction.mockImplementation(async (callback: (ctx: typeof tx) => Promise<void>) => {
await callback(tx)
})
const result = await recordShadowUsage('u1', {
projectId: 'p1',
action: 'analyze',
apiType: 'text',
model: 'anthropic/claude-sonnet-4',
quantity: 1000,
unit: 'token',
cost: 0.25,
metadata: { trace: 'abc' },
})
expect(result).toBe(true)
expect(tx.balanceTransaction.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
userId: 'u1',
type: 'shadow_consume',
amount: 0,
}),
}))
})
it('returns false when recordShadowUsage transaction fails', async () => {
prismaMock.$transaction.mockRejectedValue(new Error('shadow failed'))
const result = await recordShadowUsage('u1', {
projectId: 'p1',
action: 'analyze',
apiType: 'text',
model: 'anthropic/claude-sonnet-4',
quantity: 1000,
unit: 'token',
cost: 0.25,
})
expect(result).toBe(false)
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { getBillingMode, getBootBillingEnabled } from '@/lib/billing/mode'
describe('billing/mode', () => {
it('falls back to OFF when env is missing', async () => {
delete process.env.BILLING_MODE
await expect(getBillingMode()).resolves.toBe('OFF')
expect(getBootBillingEnabled()).toBe(false)
})
it('normalizes lower-case env mode', async () => {
process.env.BILLING_MODE = 'enforce'
await expect(getBillingMode()).resolves.toBe('ENFORCE')
expect(getBootBillingEnabled()).toBe(true)
})
it('falls back to OFF when env mode is invalid', async () => {
process.env.BILLING_MODE = 'invalid'
await expect(getBillingMode()).resolves.toBe('OFF')
expect(getBootBillingEnabled()).toBe(false)
})
})

View File

@@ -0,0 +1,79 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { describe, expect, it, vi } from 'vitest'
import { recordTextUsage, withTextUsageCollection } from '@/lib/billing/runtime-usage'
describe('billing/runtime-usage', () => {
it('ignores records outside of collection scope', () => {
expect(() => {
recordTextUsage({
model: 'm',
inputTokens: 10,
outputTokens: 20,
})
}).not.toThrow()
})
it('collects and normalizes token usage', async () => {
const { textUsage } = await withTextUsageCollection(async () => {
recordTextUsage({
model: 'test-model',
inputTokens: 10.9,
outputTokens: -2,
})
return { ok: true }
})
expect(textUsage).toEqual([
{
model: 'test-model',
inputTokens: 10,
outputTokens: 0,
},
])
})
it('falls back to empty usage when store is unavailable at read time', async () => {
const getStoreSpy = vi.spyOn(AsyncLocalStorage.prototype, 'getStore')
getStoreSpy.mockReturnValueOnce(undefined as never)
const payload = await withTextUsageCollection(async () => ({ ok: true }))
expect(payload).toEqual({ result: { ok: true }, textUsage: [] })
getStoreSpy.mockRestore()
})
it('normalizes NaN and zero token values to zero', async () => {
const { textUsage } = await withTextUsageCollection(async () => {
recordTextUsage({
model: 'nan-model',
inputTokens: Number.NaN,
outputTokens: 0,
})
return { ok: true }
})
expect(textUsage).toEqual([
{
model: 'nan-model',
inputTokens: 0,
outputTokens: 0,
},
])
})
it('isolates concurrent async local storage contexts', async () => {
const [left, right] = await Promise.all([
withTextUsageCollection(async () => {
recordTextUsage({ model: 'left', inputTokens: 1, outputTokens: 2 })
return 'left'
}),
withTextUsageCollection(async () => {
recordTextUsage({ model: 'right', inputTokens: 3, outputTokens: 4 })
return 'right'
}),
])
expect(left.textUsage).toEqual([{ model: 'left', inputTokens: 1, outputTokens: 2 }])
expect(right.textUsage).toEqual([{ model: 'right', inputTokens: 3, outputTokens: 4 }])
})
})

View File

@@ -0,0 +1,518 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { calcText, calcVoice } from '@/lib/billing/cost'
import type { TaskBillingInfo } from '@/lib/task/types'
const ledgerMock = vi.hoisted(() => ({
confirmChargeWithRecord: vi.fn(),
freezeBalance: vi.fn(),
getBalance: vi.fn(),
getFreezeByIdempotencyKey: vi.fn(),
increasePendingFreezeAmount: vi.fn(),
recordShadowUsage: vi.fn(),
rollbackFreeze: vi.fn(),
}))
const modeMock = vi.hoisted(() => ({
getBillingMode: vi.fn(),
}))
vi.mock('@/lib/billing/ledger', () => ledgerMock)
vi.mock('@/lib/billing/mode', () => modeMock)
import { BillingOperationError, InsufficientBalanceError } from '@/lib/billing/errors'
import {
handleBillingError,
prepareTaskBilling,
rollbackTaskBilling,
settleTaskBilling,
withTextBilling,
withVoiceBilling,
} from '@/lib/billing/service'
describe('billing/service', () => {
beforeEach(() => {
vi.clearAllMocks()
ledgerMock.confirmChargeWithRecord.mockResolvedValue(true)
ledgerMock.freezeBalance.mockResolvedValue('freeze_1')
ledgerMock.getBalance.mockResolvedValue({ balance: 0 })
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue(null)
ledgerMock.increasePendingFreezeAmount.mockResolvedValue(true)
ledgerMock.recordShadowUsage.mockResolvedValue(true)
ledgerMock.rollbackFreeze.mockResolvedValue(true)
})
it('returns raw execution result in OFF mode', async () => {
modeMock.getBillingMode.mockResolvedValue('OFF')
const result = await withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1' },
async () => ({ ok: true }),
)
expect(result).toEqual({ ok: true })
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
expect(ledgerMock.confirmChargeWithRecord).not.toHaveBeenCalled()
})
it('records shadow usage in SHADOW mode without freezing', async () => {
modeMock.getBillingMode.mockResolvedValue('SHADOW')
const result = await withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1' },
async () => ({ ok: true }),
)
expect(result).toEqual({ ok: true })
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
expect(ledgerMock.recordShadowUsage).toHaveBeenCalledTimes(1)
})
it('throws InsufficientBalanceError when ENFORCE freeze fails', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue(null)
ledgerMock.getBalance.mockResolvedValue({ balance: 0.01 })
await expect(
withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1' },
async () => ({ ok: true }),
),
).rejects.toBeInstanceOf(InsufficientBalanceError)
})
it('rolls back freeze when execution throws', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue('freeze_rollback')
await expect(
withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1' },
async () => {
throw new Error('boom')
},
),
).rejects.toThrow('boom')
expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_rollback')
})
it('expands freeze and charges actual voice usage when actual exceeds quoted', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue('freeze_voice')
await withVoiceBilling(
'u1',
5,
{ projectId: 'p1', action: 'voice_gen' },
async () => ({ actualDurationSeconds: 50 }),
)
const confirmCall = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)
expect(confirmCall).toBeTruthy()
const chargedAmount = confirmCall?.[2]?.chargedAmount as number
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
expect(chargedAmount).toBeCloseTo(calcVoice(50), 8)
})
it('fails and rolls back when overage freeze expansion cannot be covered', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue('freeze_voice_low_balance')
ledgerMock.increasePendingFreezeAmount.mockResolvedValue(false)
ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })
await expect(
withVoiceBilling(
'u1',
5,
{ projectId: 'p1', action: 'voice_gen' },
async () => ({ actualDurationSeconds: 50 }),
),
).rejects.toBeInstanceOf(InsufficientBalanceError)
expect(ledgerMock.rollbackFreeze).toHaveBeenCalledWith('freeze_voice_low_balance')
})
it('rejects duplicate sync billing key when freeze is already confirmed', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({
id: 'freeze_confirmed',
userId: 'u1',
amount: 0.5,
status: 'confirmed',
})
const execute = vi.fn(async () => ({ ok: true }))
await expect(
withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1', billingKey: 'billing-key-1' },
execute,
),
).rejects.toThrow('duplicate billing request already confirmed')
expect(execute).not.toHaveBeenCalled()
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
})
it('rejects duplicate sync billing key when freeze is pending', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.getFreezeByIdempotencyKey.mockResolvedValue({
id: 'freeze_pending',
userId: 'u1',
amount: 0.5,
status: 'pending',
})
const execute = vi.fn(async () => ({ ok: true }))
await expect(
withTextBilling(
'u1',
'anthropic/claude-sonnet-4',
1000,
1000,
{ projectId: 'p1', action: 'a1', billingKey: 'billing-key-2' },
execute,
),
).rejects.toThrow('duplicate billing request is already in progress')
expect(execute).not.toHaveBeenCalled()
expect(ledgerMock.freezeBalance).not.toHaveBeenCalled()
})
it('maps insufficient balance error to 402 response payload', async () => {
const response = handleBillingError(new InsufficientBalanceError(1.2, 0.3))
expect(response).toBeTruthy()
expect(response?.status).toBe(402)
const body = await response?.json()
expect(body?.code).toBe('INSUFFICIENT_BALANCE')
expect(body?.required).toBeCloseTo(1.2, 8)
expect(body?.available).toBeCloseTo(0.3, 8)
})
it('returns null for non-billing errors', () => {
expect(handleBillingError(new Error('x'))).toBeNull()
expect(handleBillingError('x')).toBeNull()
})
describe('task billing lifecycle helpers', () => {
function buildTaskInfo(overrides: Partial<Extract<TaskBillingInfo, { billable: true }>> = {}): Extract<TaskBillingInfo, { billable: true }> {
return {
billable: true,
source: 'task',
taskType: 'voice_line',
apiType: 'voice',
model: 'index-tts2',
quantity: 5,
unit: 'second',
maxFrozenCost: calcVoice(5),
action: 'voice_line_generate',
metadata: { foo: 'bar' },
...overrides,
}
}
it('prepareTaskBilling handles OFF/SHADOW/ENFORCE paths', async () => {
modeMock.getBillingMode.mockResolvedValueOnce('OFF')
const off = await prepareTaskBilling({
id: 'task_off',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo(),
})
expect((off as Extract<TaskBillingInfo, { billable: true }>).status).toBe('skipped')
modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')
const shadow = await prepareTaskBilling({
id: 'task_shadow',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo(),
})
expect((shadow as Extract<TaskBillingInfo, { billable: true }>).status).toBe('quoted')
modeMock.getBillingMode.mockResolvedValueOnce('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValueOnce('freeze_task_1')
const enforce = await prepareTaskBilling({
id: 'task_enforce',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo(),
})
const enforceInfo = enforce as Extract<TaskBillingInfo, { billable: true }>
expect(enforceInfo.status).toBe('frozen')
expect(enforceInfo.freezeId).toBe('freeze_task_1')
})
it('prepareTaskBilling tolerates unknown text model pricing in SHADOW mode', async () => {
modeMock.getBillingMode.mockResolvedValueOnce('SHADOW')
const unknownTextInfo = buildTaskInfo({
taskType: 'story_to_script_run',
apiType: 'text',
model: 'gpt-5.2',
quantity: 2400,
unit: 'token',
maxFrozenCost: 0,
action: 'story_to_script_run',
})
const shadow = await prepareTaskBilling({
id: 'task_shadow_unknown_text_model',
userId: 'u1',
projectId: 'p1',
billingInfo: unknownTextInfo,
})
const shadowInfo = shadow as Extract<TaskBillingInfo, { billable: true }>
expect(shadowInfo.status).toBe('skipped')
expect(shadowInfo.maxFrozenCost).toBe(0)
})
it('prepareTaskBilling throws InsufficientBalanceError when ENFORCE freeze fails', async () => {
modeMock.getBillingMode.mockResolvedValue('ENFORCE')
ledgerMock.freezeBalance.mockResolvedValue(null)
ledgerMock.getBalance.mockResolvedValue({ balance: 0.001 })
await expect(
prepareTaskBilling({
id: 'task_no_balance',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo(),
}),
).rejects.toBeInstanceOf(InsufficientBalanceError)
})
it('settleTaskBilling handles SHADOW and non-ENFORCE snapshots', async () => {
const shadowSettled = await settleTaskBilling({
id: 'task_shadow_settle',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'SHADOW', status: 'quoted' }),
})
const shadowInfo = shadowSettled as Extract<TaskBillingInfo, { billable: true }>
expect(shadowInfo.status).toBe('settled')
expect(shadowInfo.chargedCost).toBe(0)
expect(ledgerMock.recordShadowUsage).toHaveBeenCalled()
const offSettled = await settleTaskBilling({
id: 'task_off_settle',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'OFF', status: 'quoted' }),
})
const offInfo = offSettled as Extract<TaskBillingInfo, { billable: true }>
expect(offInfo.status).toBe('settled')
expect(offInfo.chargedCost).toBe(0)
})
it('settleTaskBilling does not fail OFF snapshot when text usage model pricing is unknown', async () => {
const settled = await settleTaskBilling({
id: 'task_off_unknown_usage_model',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({
taskType: 'story_to_script_run',
apiType: 'text',
model: 'gpt-5.2',
quantity: 2400,
unit: 'token',
maxFrozenCost: 0,
action: 'story_to_script_run',
modeSnapshot: 'OFF',
status: 'quoted',
}),
}, {
textUsage: [{ model: 'gpt-5.2', inputTokens: 1200, outputTokens: 800 }],
})
const settledInfo = settled as Extract<TaskBillingInfo, { billable: true }>
expect(settledInfo.status).toBe('settled')
expect(settledInfo.chargedCost).toBe(0)
expect(ledgerMock.recordShadowUsage).not.toHaveBeenCalled()
})
it('settleTaskBilling skips SHADOW settlement when text model pricing is unknown', async () => {
const settled = await settleTaskBilling({
id: 'task_shadow_unknown_usage_model',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({
taskType: 'story_to_script_run',
apiType: 'text',
model: 'gpt-5.2',
quantity: 2400,
unit: 'token',
maxFrozenCost: 0,
action: 'story_to_script_run',
modeSnapshot: 'SHADOW',
status: 'quoted',
}),
}, {
textUsage: [{ model: 'gpt-5.2', inputTokens: 1200, outputTokens: 800 }],
})
const settledInfo = settled as Extract<TaskBillingInfo, { billable: true }>
expect(settledInfo.status).toBe('settled')
expect(settledInfo.chargedCost).toBe(0)
expect(ledgerMock.recordShadowUsage).not.toHaveBeenCalled()
})
it('settleTaskBilling handles ENFORCE success/failure branches', async () => {
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_enforce_settle',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_ok' }),
})
expect((settled as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')
const missingFreeze = await settleTaskBilling({
id: 'task_enforce_no_freeze',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: null }),
})
expect((missingFreeze as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
await expect(
settleTaskBilling({
id: 'task_enforce_confirm_fail',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_fail' }),
}),
).rejects.toThrow('confirm failed')
})
it('settleTaskBilling throws BILLING_CONFIRM_FAILED when confirm and rollback both fail', async () => {
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
await expect(
settleTaskBilling({
id: 'task_confirm_and_rollback_fail',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail_confirm' }),
}),
).rejects.toMatchObject({
name: 'BillingOperationError',
code: 'BILLING_CONFIRM_FAILED',
})
})
it('settleTaskBilling rethrows BillingOperationError with task context when rollback succeeds', async () => {
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(
new BillingOperationError(
'BILLING_INVALID_FREEZE',
'invalid freeze',
{ reason: 'status_mismatch' },
),
)
let thrown: unknown = null
try {
await settleTaskBilling({
id: 'task_confirm_billing_error',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_billing_error' }),
})
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(BillingOperationError)
const billingError = thrown as BillingOperationError
expect(billingError.code).toBe('BILLING_INVALID_FREEZE')
expect(billingError.details).toMatchObject({
reason: 'status_mismatch',
taskId: 'task_confirm_billing_error',
freezeId: 'freeze_billing_error',
})
})
it('settleTaskBilling expands freeze when actual exceeds quoted', async () => {
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_enforce_overage',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_overage', quantity: 5 }),
}, {
result: { actualDurationSeconds: 50 },
})
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
expect(ledgerMock.confirmChargeWithRecord).toHaveBeenCalled()
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(calcVoice(50), 8)
})
it('settleTaskBilling keeps quoted charge when text usage has no token counts', async () => {
const quoted = calcText('anthropic/claude-sonnet-4', 500, 500)
const textBillingInfo: Extract<TaskBillingInfo, { billable: true }> = {
billable: true,
source: 'task',
taskType: 'analyze_novel',
apiType: 'text',
model: 'anthropic/claude-sonnet-4',
quantity: 1000,
unit: 'token',
maxFrozenCost: quoted,
action: 'analyze_novel',
modeSnapshot: 'ENFORCE',
status: 'frozen',
freezeId: 'freeze_text_zero',
}
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_text_zero_usage',
userId: 'u1',
projectId: 'p1',
billingInfo: textBillingInfo,
}, {
textUsage: [{ model: 'openai/gpt-5', inputTokens: 0, outputTokens: 0 }],
})
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(quoted, 8)
const recordParams = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)?.[1] as { model: string }
expect(recordParams.model).toBe('openai/gpt-5')
})
it('rollbackTaskBilling handles success and fallback branches', async () => {
const rolledBack = await rollbackTaskBilling({
id: 'task_rb_ok',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_ok' }),
})
expect((rolledBack as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
const rollbackFailed = await rollbackTaskBilling({
id: 'task_rb_fail',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail' }),
})
expect((rollbackFailed as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
})
})
})

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { TASK_TYPE } from '@/lib/task/types'
import { buildDefaultTaskBillingInfo, isBillableTaskType } from '@/lib/billing/task-policy'
import type { TaskBillingInfo } from '@/lib/task/types'
function expectBillableInfo(info: TaskBillingInfo | null): Extract<TaskBillingInfo, { billable: true }> {
expect(info).toBeTruthy()
expect(info?.billable).toBe(true)
if (!info || !info.billable) {
throw new Error('Expected billable task billing info')
}
return info
}
describe('billing/task-policy', () => {
const billingPayload = {
analysisModel: 'anthropic/claude-sonnet-4',
imageModel: 'seedream',
videoModel: 'doubao-seedance-1-5-pro-251215',
} as const
it('builds TaskBillingInfo for every billable task type', () => {
for (const taskType of Object.values(TASK_TYPE)) {
if (!isBillableTaskType(taskType)) continue
const info = expectBillableInfo(buildDefaultTaskBillingInfo(taskType, billingPayload))
expect(info.taskType).toBe(taskType)
expect(info.maxFrozenCost).toBeGreaterThanOrEqual(0)
}
})
it('returns null for a non-billable task type', () => {
const fake = 'not_billable' as unknown as (typeof TASK_TYPE)[keyof typeof TASK_TYPE]
expect(isBillableTaskType(fake)).toBe(false)
expect(buildDefaultTaskBillingInfo(fake, {})).toBeNull()
})
it('builds text billing info from explicit model payload', () => {
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {
analysisModel: 'anthropic/claude-sonnet-4',
}))
expect(info.apiType).toBe('text')
expect(info.model).toBe('anthropic/claude-sonnet-4')
expect(info.quantity).toBe(4200)
})
it('returns null for missing required models in text/image/video tasks', () => {
expect(buildDefaultTaskBillingInfo(TASK_TYPE.ANALYZE_NOVEL, {})).toBeNull()
expect(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {})).toBeNull()
expect(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {})).toBeNull()
})
it('honors candidateCount/count for image tasks', () => {
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.IMAGE_PANEL, {
candidateCount: 4,
imageModel: 'seedream4',
}))
expect(info.apiType).toBe('image')
expect(info.quantity).toBe(4)
expect(info.model).toBe('seedream4')
})
it('builds video billing info from firstLastFrame.flModel', () => {
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.VIDEO_PANEL, {
firstLastFrame: {
flModel: 'doubao-seedance-1-0-pro-250528',
},
duration: 8,
}))
expect(info.apiType).toBe('video')
expect(info.model).toBe('doubao-seedance-1-0-pro-250528')
expect(info.quantity).toBe(1)
})
it('uses explicit lip sync model from payload', () => {
const info = expectBillableInfo(buildDefaultTaskBillingInfo(TASK_TYPE.LIP_SYNC, {
lipSyncModel: 'vidu::vidu-lipsync',
}))
expect(info.apiType).toBe('lip-sync')
expect(info.model).toBe('vidu::vidu-lipsync')
expect(info.quantity).toBe(1)
})
})

View File

@@ -0,0 +1,116 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import { CharacterCreationModal } from '@/components/shared/assets/CharacterCreationModal'
vi.mock('@/lib/query/hooks', () => ({
useProjectAssets: vi.fn(() => ({ data: { characters: [] } })),
}))
vi.mock('@/components/shared/assets/character-creation/hooks/useCharacterCreationSubmit', () => ({
useCharacterCreationSubmit: vi.fn(() => ({
isSubmitting: false,
isAiDesigning: false,
isExtracting: false,
characterGenerationCount: 3,
setCharacterGenerationCount: vi.fn(),
referenceCharacterGenerationCount: 3,
setReferenceCharacterGenerationCount: vi.fn(),
handleExtractDescription: vi.fn(),
handleCreateWithReference: vi.fn(),
handleAiDesign: vi.fn(),
handleSubmit: vi.fn(),
handleSubmitAndGenerate: vi.fn(),
})),
}))
const messages = {
assetModal: {
character: {
title: '新建角色',
name: '角色名称',
namePlaceholder: '请输入角色名称',
modeReference: '参考图模式',
modeDescription: '描述模式',
uploadReference: '上传参考图',
pasteHint: 'Ctrl+V 粘贴',
generationMode: '生成方式',
directGenerate: '直接生成',
extractPrompt: '反推提示词',
extractFirst: '先提取描述',
description: '角色描述',
descPlaceholder: '请输入角色外貌描述...',
isSubAppearance: '这是一个子形象',
isSubAppearanceHint: '为已有角色添加新的形象状态',
selectMainCharacter: '选择主角色',
selectCharacterPlaceholder: '请选择角色...',
appearancesCount: '{count} 个形象',
changeReason: '形象变化原因',
changeReasonPlaceholder: '例如',
useReferenceGeneratePrefix: '使用参考图生成',
generateCountSuffix: '张图片',
selectReferenceGenerateCount: '选择参考图生成数量',
},
artStyle: { title: '画面风格' },
aiDesign: {
title: 'AI 设计',
placeholder: '描述你想要的角色特征...',
generating: '设计中...',
generate: '生成',
},
common: {
creating: '创建中...',
cancel: '取消',
adding: '添加中...',
add: '添加',
addOnly: '仅添加角色',
addOnlyToAssetHub: '仅添加人物到资产库',
addAndGeneratePrefix: '添加并生成',
generateCountSuffix: '张图片',
selectGenerateCount: '选择生成数量',
optional: '(可选)',
},
errors: {
uploadFailed: '上传失败',
extractDescriptionFailed: '提取描述失败',
createFailed: '创建失败',
aiDesignFailed: 'AI 设计失败',
addSubAppearanceFailed: '添加子形象失败',
insufficientBalance: '账户余额不足',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('CharacterCreationModal', () => {
it('renders add-only and add-and-generate actions in the fixed footer', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(CharacterCreationModal, {
mode: 'asset-hub',
onClose: () => undefined,
onSuccess: () => undefined,
}),
)
expect(html).toContain('仅添加人物到资产库')
expect(html).toContain('添加并生成')
expect(html).toContain('取消')
})
})

View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
describe('ImageGenerationInlineCountButton', () => {
it('keeps the select enabled when only the action is disabled', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(ImageGenerationInlineCountButton, {
prefix: createElement('span', null, '生成'),
suffix: createElement('span', null, '张图片'),
value: 3,
options: [1, 2, 3],
onValueChange: () => undefined,
onClick: () => undefined,
actionDisabled: true,
selectDisabled: false,
ariaLabel: '选择生成数量',
}),
)
expect(html).toContain('aria-disabled="true"')
expect(html).toContain('opacity-60 cursor-not-allowed')
expect(html).not.toContain('<select disabled=""')
})
})

View File

@@ -0,0 +1,81 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import { LocationCreationModal } from '@/components/shared/assets/LocationCreationModal'
vi.mock('@/lib/query/hooks', () => ({
useAiCreateProjectLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
useAiDesignLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
useCreateAssetHubLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
useGenerateLocationImage: vi.fn(() => ({ mutateAsync: vi.fn() })),
useCreateProjectLocation: vi.fn(() => ({ mutateAsync: vi.fn() })),
useGenerateProjectLocationImage: vi.fn(() => ({ mutateAsync: vi.fn() })),
}))
const messages = {
assetModal: {
location: {
title: '新建场景',
name: '场景名称',
namePlaceholder: '请输入场景名称',
description: '场景描述',
descPlaceholder: '请输入场景描述...',
},
artStyle: { title: '画面风格' },
aiDesign: {
title: 'AI 设计',
placeholderLocation: '描述场景氛围和环境...',
generating: '设计中...',
generate: '生成',
tip: '输入简单描述AI 帮你生成详细设定',
},
common: {
cancel: '取消',
addOnlyLocation: '仅添加场景',
addOnlyToAssetHubLocation: '仅添加场景到资产库',
addAndGeneratePrefix: '添加并生成',
generateCountSuffix: '张图片',
selectGenerateCount: '选择生成数量',
optional: '(可选)',
},
errors: {
createFailed: '创建失败',
aiDesignFailed: 'AI 设计失败',
insufficientBalance: '账户余额不足',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('LocationCreationModal', () => {
it('renders add-only and add-and-generate actions in the fixed footer', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(LocationCreationModal, {
mode: 'asset-hub',
onClose: () => undefined,
onSuccess: () => undefined,
}),
)
expect(html).toContain('仅添加场景到资产库')
expect(html).toContain('添加并生成')
expect(html).toContain('取消')
})
})

View File

@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_VOICE_SCHEME_COUNT,
MAX_VOICE_SCHEME_COUNT,
MIN_VOICE_SCHEME_COUNT,
generateVoiceDesignOptions,
normalizeVoiceSchemeCount,
} from '@/components/voice/voice-design-shared'
describe('voice-design-shared', () => {
it('clamps scheme count into the supported range', () => {
expect(normalizeVoiceSchemeCount(undefined)).toBe(DEFAULT_VOICE_SCHEME_COUNT)
expect(normalizeVoiceSchemeCount('not-a-number')).toBe(DEFAULT_VOICE_SCHEME_COUNT)
expect(normalizeVoiceSchemeCount(0)).toBe(MIN_VOICE_SCHEME_COUNT)
expect(normalizeVoiceSchemeCount(99)).toBe(MAX_VOICE_SCHEME_COUNT)
expect(normalizeVoiceSchemeCount('5')).toBe(5)
})
it('generates the requested number of voice options with default preview text fallback', async () => {
const onDesignVoice = vi
.fn<(_: {
voicePrompt: string
previewText: string
preferredName: string
language: 'zh'
}) => Promise<{ voiceId: string; audioBase64: string }>>()
.mockResolvedValueOnce({ voiceId: 'voice-1', audioBase64: 'audio-1' })
.mockResolvedValueOnce({ voiceId: 'voice-2', audioBase64: 'audio-2' })
.mockResolvedValueOnce({ voiceId: 'voice-3', audioBase64: 'audio-3' })
.mockResolvedValueOnce({ voiceId: 'voice-4', audioBase64: 'audio-4' })
const result = await generateVoiceDesignOptions({
count: '4',
voicePrompt: ' 温柔女声 ',
previewText: ' ',
defaultPreviewText: '默认试听文案',
onDesignVoice,
createPreferredName: (index) => `preferred-${index + 1}`,
})
expect(result).toEqual([
{ voiceId: 'voice-1', audioBase64: 'audio-1', audioUrl: 'data:audio/wav;base64,audio-1' },
{ voiceId: 'voice-2', audioBase64: 'audio-2', audioUrl: 'data:audio/wav;base64,audio-2' },
{ voiceId: 'voice-3', audioBase64: 'audio-3', audioUrl: 'data:audio/wav;base64,audio-3' },
{ voiceId: 'voice-4', audioBase64: 'audio-4', audioUrl: 'data:audio/wav;base64,audio-4' },
])
expect(onDesignVoice.mock.calls).toEqual([
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-1', language: 'zh' }],
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-2', language: 'zh' }],
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-3', language: 'zh' }],
[{ voicePrompt: '温柔女声', previewText: '默认试听文案', preferredName: 'preferred-4', language: 'zh' }],
])
})
it('fails explicitly when a designed voice is missing voiceId', async () => {
const onDesignVoice = vi.fn(async () => ({ voiceId: '', audioBase64: 'audio-only' }))
await expect(
generateVoiceDesignOptions({
count: 1,
voicePrompt: '旁白',
previewText: '测试',
defaultPreviewText: '默认试听文案',
onDesignVoice,
}),
).rejects.toThrow('VOICE_DESIGN_INVALID_RESPONSE: missing voiceId')
})
})

View File

@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const resolveModelSelectionMock = vi.hoisted(() =>
vi.fn(async () => ({
provider: 'openai-compatible:oa-1',
modelId: 'gpt-image-1',
modelKey: 'openai-compatible:oa-1::gpt-image-1',
mediaType: 'image',
compatMediaTemplate: undefined,
})),
)
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'openai-compatible:oa-1',
name: 'OpenAI Compat',
apiKey: 'oa-key',
gatewayRoute: 'openai-compat' as const,
})),
)
const resolveModelGatewayRouteMock = vi.hoisted(() => vi.fn(() => 'openai-compat'))
const generateImageViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'image' })))
const generateVideoViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'video' })))
const generateImageViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'image' })))
const generateVideoViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'video' })))
vi.mock('@/lib/api-config', () => ({
resolveModelSelection: resolveModelSelectionMock,
getProviderConfig: getProviderConfigMock,
getProviderKey: (providerId: string) => providerId.split(':')[0] || providerId,
}))
vi.mock('@/lib/model-gateway', () => ({
resolveModelGatewayRoute: resolveModelGatewayRouteMock,
generateImageViaOpenAICompat: generateImageViaOpenAICompatMock,
generateVideoViaOpenAICompat: generateVideoViaOpenAICompatMock,
generateImageViaOpenAICompatTemplate: generateImageViaOpenAICompatTemplateMock,
generateVideoViaOpenAICompatTemplate: generateVideoViaOpenAICompatTemplateMock,
}))
vi.mock('@/lib/generators/factory', () => ({
createImageGenerator: vi.fn(() => ({ generate: vi.fn() })),
createVideoGenerator: vi.fn(() => ({ generate: vi.fn() })),
createAudioGenerator: vi.fn(() => ({ generate: vi.fn() })),
}))
vi.mock('@/lib/providers/bailian', () => ({
generateBailianImage: vi.fn(),
generateBailianVideo: vi.fn(),
generateBailianAudio: vi.fn(),
}))
vi.mock('@/lib/providers/siliconflow', () => ({
generateSiliconFlowImage: vi.fn(),
generateSiliconFlowVideo: vi.fn(),
generateSiliconFlowAudio: vi.fn(),
}))
import { generateImage, generateVideo } from '@/lib/generator-api'
describe('generator-api requires compat media template for openai-compatible media', () => {
beforeEach(() => {
vi.clearAllMocks()
resolveModelGatewayRouteMock.mockReturnValue('openai-compat')
getProviderConfigMock.mockResolvedValue({
id: 'openai-compatible:oa-1',
name: 'OpenAI Compat',
apiKey: 'oa-key',
gatewayRoute: 'openai-compat',
})
})
it('throws for image model without compatMediaTemplate', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'openai-compatible:oa-1',
modelId: 'gpt-image-1',
modelKey: 'openai-compatible:oa-1::gpt-image-1',
mediaType: 'image',
compatMediaTemplate: undefined,
})
await expect(
generateImage('user-1', 'openai-compatible:oa-1::gpt-image-1', 'draw cat'),
).rejects.toThrow('MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED')
expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()
expect(generateImageViaOpenAICompatTemplateMock).not.toHaveBeenCalled()
})
it('throws for video model without compatMediaTemplate', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'openai-compatible:oa-1',
modelId: 'veo3.1',
modelKey: 'openai-compatible:oa-1::veo3.1',
mediaType: 'video',
compatMediaTemplate: undefined,
})
await expect(
generateVideo('user-1', 'openai-compatible:oa-1::veo3.1', 'https://example.com/a.png', { prompt: 'animate' }),
).rejects.toThrow('MODEL_COMPAT_MEDIA_TEMPLATE_REQUIRED')
expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()
expect(generateVideoViaOpenAICompatTemplateMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,301 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const resolveModelSelectionMock = vi.hoisted(() =>
vi.fn<typeof import('@/lib/api-config').resolveModelSelection>(async () => ({
provider: 'google',
modelId: 'gemini-3.1',
modelKey: 'google::gemini-3.1',
mediaType: 'image',
})),
)
const getProviderConfigMock = vi.hoisted(() =>
vi.fn<typeof import('@/lib/api-config').getProviderConfig>(async () => ({
id: 'google',
name: 'Google',
apiKey: 'google-key',
apiMode: undefined,
gatewayRoute: undefined,
})),
)
const generateImageViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'compat-image' })))
const generateVideoViaOpenAICompatMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'compat-video' })))
const generateImageViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'compat-template-image' })))
const generateVideoViaOpenAICompatTemplateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'compat-template-video' })))
const resolveModelGatewayRouteMock = vi.hoisted(() => vi.fn(() => 'official'))
const imageGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'official-image' })))
const videoGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'official-video' })))
const audioGeneratorGenerateMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'audio' })))
const createImageGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: imageGeneratorGenerateMock })))
const createVideoGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: videoGeneratorGenerateMock })))
const createAudioGeneratorMock = vi.hoisted(() => vi.fn(() => ({ generate: audioGeneratorGenerateMock })))
const generateBailianImageMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'bailian-image' })))
const generateBailianVideoMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'bailian-video' })))
const generateBailianAudioMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'bailian-audio' })))
const generateSiliconFlowImageMock = vi.hoisted(() => vi.fn(async () => ({ success: true, imageUrl: 'siliconflow-image' })))
const generateSiliconFlowVideoMock = vi.hoisted(() => vi.fn(async () => ({ success: true, videoUrl: 'siliconflow-video' })))
const generateSiliconFlowAudioMock = vi.hoisted(() => vi.fn(async () => ({ success: true, audioUrl: 'siliconflow-audio' })))
vi.mock('@/lib/api-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/api-config')>()
return {
...actual,
resolveModelSelection: resolveModelSelectionMock,
getProviderConfig: getProviderConfigMock,
}
})
vi.mock('@/lib/model-gateway', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/model-gateway')>()
return {
...actual,
generateImageViaOpenAICompat: generateImageViaOpenAICompatMock,
generateVideoViaOpenAICompat: generateVideoViaOpenAICompatMock,
generateImageViaOpenAICompatTemplate: generateImageViaOpenAICompatTemplateMock,
generateVideoViaOpenAICompatTemplate: generateVideoViaOpenAICompatTemplateMock,
resolveModelGatewayRoute: resolveModelGatewayRouteMock,
}
})
vi.mock('@/lib/generators/factory', () => ({
createImageGenerator: createImageGeneratorMock,
createVideoGenerator: createVideoGeneratorMock,
createAudioGenerator: createAudioGeneratorMock,
}))
vi.mock('@/lib/providers/bailian', () => ({
generateBailianImage: generateBailianImageMock,
generateBailianVideo: generateBailianVideoMock,
generateBailianAudio: generateBailianAudioMock,
}))
vi.mock('@/lib/providers/siliconflow', () => ({
generateSiliconFlowImage: generateSiliconFlowImageMock,
generateSiliconFlowVideo: generateSiliconFlowVideoMock,
generateSiliconFlowAudio: generateSiliconFlowAudioMock,
}))
import { generateAudio, generateImage, generateVideo } from '@/lib/generator-api'
describe('generator-api gateway routing', () => {
beforeEach(() => {
vi.clearAllMocks()
resolveModelGatewayRouteMock.mockReset()
resolveModelGatewayRouteMock.mockReturnValue('official')
getProviderConfigMock.mockResolvedValue({
id: 'google',
name: 'Google',
apiKey: 'google-key',
apiMode: undefined,
gatewayRoute: undefined,
})
})
it('routes openai-compatible image requests to openai-compat gateway', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'openai-compatible:oa-1',
modelId: 'gpt-image-1',
modelKey: 'openai-compatible:oa-1::gpt-image-1',
mediaType: 'image',
compatMediaTemplate: {
version: 1,
mediaType: 'image',
mode: 'sync',
create: { method: 'POST', path: '/v1/images/generations' },
response: { outputUrlPath: 'data[0].url' },
},
})
resolveModelGatewayRouteMock.mockReturnValueOnce('openai-compat')
const result = await generateImage('user-1', 'openai-compatible:oa-1::gpt-image-1', 'draw cat', {
size: '1024x1024',
})
expect(generateImageViaOpenAICompatTemplateMock).toHaveBeenCalledTimes(1)
expect(createImageGeneratorMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, imageUrl: 'compat-template-image' })
})
it('routes official image requests to provider generator', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'google',
modelId: 'imagen-4.0',
modelKey: 'google::imagen-4.0',
mediaType: 'image',
})
resolveModelGatewayRouteMock.mockReturnValueOnce('official')
const result = await generateImage('user-1', 'google::imagen-4.0', 'draw house')
expect(createImageGeneratorMock).toHaveBeenCalledWith('google', 'imagen-4.0')
expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, imageUrl: 'official-image' })
})
it('routes gemini-compatible image to official generator', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'gemini-compatible:gm-1',
modelId: 'gemini-2.5-flash-image-preview',
modelKey: 'gemini-compatible:gm-1::gemini-2.5-flash-image-preview',
mediaType: 'image',
})
getProviderConfigMock.mockResolvedValueOnce({
id: 'gemini-compatible:gm-1',
name: 'Gemini Compatible',
apiKey: 'gm-key',
baseUrl: 'https://gm.test',
apiMode: 'gemini-sdk',
gatewayRoute: 'official',
})
const result = await generateImage(
'user-1',
'gemini-compatible:gm-1::gemini-2.5-flash-image-preview',
'draw cat',
{ aspectRatio: '3:4' },
)
expect(createImageGeneratorMock).toHaveBeenCalledWith('gemini-compatible:gm-1', 'gemini-2.5-flash-image-preview')
expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, imageUrl: 'official-image' })
})
it('routes openai-compatible video requests to openai-compat gateway', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'openai-compatible:oa-1',
modelId: 'sora-2',
modelKey: 'openai-compatible:oa-1::sora-2',
mediaType: 'video',
compatMediaTemplate: {
version: 1,
mediaType: 'video',
mode: 'async',
create: { method: 'POST', path: '/v1/videos/generations' },
response: { taskIdPath: 'id' },
},
})
resolveModelGatewayRouteMock.mockReturnValueOnce('openai-compat')
const result = await generateVideo(
'user-1',
'openai-compatible:oa-1::sora-2',
'https://example.com/source.png',
{ prompt: 'animate' },
)
expect(generateVideoViaOpenAICompatTemplateMock).toHaveBeenCalledTimes(1)
expect(createVideoGeneratorMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, videoUrl: 'compat-template-video' })
})
it('routes gemini-compatible video to official provider generator', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'gemini-compatible:gm-1',
modelId: 'veo-3.1-generate-preview',
modelKey: 'gemini-compatible:gm-1::veo-3.1-generate-preview',
mediaType: 'video',
})
resolveModelGatewayRouteMock.mockReturnValueOnce('official')
const result = await generateVideo('user-1', 'gemini-compatible:gm-1::veo-3.1-generate-preview', 'https://example.com/source.png')
expect(createVideoGeneratorMock).toHaveBeenCalledWith('gemini-compatible:gm-1')
expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, videoUrl: 'official-video' })
})
it('routes official video requests to provider generator', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'fal',
modelId: 'kling',
modelKey: 'fal::kling',
mediaType: 'video',
})
resolveModelGatewayRouteMock.mockReturnValueOnce('official')
const result = await generateVideo('user-1', 'fal::kling', 'https://example.com/source.png')
expect(createVideoGeneratorMock).toHaveBeenCalledWith('fal')
expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, videoUrl: 'official-video' })
})
it('keeps audio generation on provider generator path', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'fal',
modelId: 'tts-1',
modelKey: 'fal::tts-1',
mediaType: 'audio',
})
const result = await generateAudio('user-1', 'fal::tts-1', 'hello')
expect(createAudioGeneratorMock).toHaveBeenCalledWith('fal')
expect(result).toEqual({ success: true, audioUrl: 'audio' })
})
it('routes bailian image generation to official provider adapter', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'bailian',
modelId: 'wanx-image',
modelKey: 'bailian::wanx-image',
mediaType: 'image',
})
getProviderConfigMock.mockResolvedValueOnce({
id: 'bailian',
name: 'Bailian',
apiKey: 'bl-key',
gatewayRoute: 'official',
apiMode: undefined,
})
const result = await generateImage('user-1', 'bailian::wanx-image', 'draw sky')
expect(generateBailianImageMock).toHaveBeenCalledTimes(1)
expect(generateImageViaOpenAICompatMock).not.toHaveBeenCalled()
expect(createImageGeneratorMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, imageUrl: 'bailian-image' })
})
it('routes siliconflow video generation to official provider adapter', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'siliconflow',
modelId: 'sf-video',
modelKey: 'siliconflow::sf-video',
mediaType: 'video',
})
getProviderConfigMock.mockResolvedValueOnce({
id: 'siliconflow',
name: 'SiliconFlow',
apiKey: 'sf-key',
gatewayRoute: 'official',
apiMode: undefined,
})
const result = await generateVideo('user-1', 'siliconflow::sf-video', 'https://example.com/source.png', {
prompt: 'animate',
})
expect(generateSiliconFlowVideoMock).toHaveBeenCalledTimes(1)
expect(generateVideoViaOpenAICompatMock).not.toHaveBeenCalled()
expect(createVideoGeneratorMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, videoUrl: 'siliconflow-video' })
})
it('routes bailian audio generation to official provider adapter', async () => {
resolveModelSelectionMock.mockResolvedValueOnce({
provider: 'bailian',
modelId: 'bailian-tts',
modelKey: 'bailian::bailian-tts',
mediaType: 'audio',
})
const result = await generateAudio('user-1', 'bailian::bailian-tts', 'hello')
expect(generateBailianAudioMock).toHaveBeenCalledTimes(1)
expect(createAudioGeneratorMock).not.toHaveBeenCalled()
expect(result).toEqual({ success: true, audioUrl: 'bailian-audio' })
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { createAudioGenerator, createImageGenerator, createVideoGenerator } from '@/lib/generators/factory'
import { GoogleVeoVideoGenerator } from '@/lib/generators/video/google'
import { OpenAICompatibleVideoGenerator } from '@/lib/generators/video/openai-compatible'
import { BailianAudioGenerator, BailianImageGenerator, BailianVideoGenerator, SiliconFlowAudioGenerator } from '@/lib/generators/official'
describe('generator factory', () => {
it('routes gemini-compatible video provider to Google video generator', () => {
const generator = createVideoGenerator('gemini-compatible:gm-1')
expect(generator).toBeInstanceOf(GoogleVeoVideoGenerator)
})
it('routes bailian official providers to official generators', () => {
expect(createImageGenerator('bailian')).toBeInstanceOf(BailianImageGenerator)
expect(createVideoGenerator('bailian')).toBeInstanceOf(BailianVideoGenerator)
expect(createAudioGenerator('bailian')).toBeInstanceOf(BailianAudioGenerator)
})
it('routes siliconflow audio provider to official generator', () => {
expect(createAudioGenerator('siliconflow')).toBeInstanceOf(SiliconFlowAudioGenerator)
})
})

View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiConfigMock = vi.hoisted(() => ({
getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),
}))
const asyncSubmitMock = vi.hoisted(() => ({
submitFalTask: vi.fn(async () => 'req_kling_1'),
}))
vi.mock('@/lib/api-config', () => apiConfigMock)
vi.mock('@/lib/async-submit', () => asyncSubmitMock)
import { FalVideoGenerator } from '@/lib/generators/fal'
type KlingModelCase = {
modelId: string
endpoint: string
imageField: 'image_url' | 'start_image_url'
}
const KLING_MODEL_CASES: KlingModelCase[] = [
{
modelId: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',
endpoint: 'fal-ai/kling-video/v2.5-turbo/pro/image-to-video',
imageField: 'image_url',
},
{
modelId: 'fal-ai/kling-video/v3/standard/image-to-video',
endpoint: 'fal-ai/kling-video/v3/standard/image-to-video',
imageField: 'start_image_url',
},
{
modelId: 'fal-ai/kling-video/v3/pro/image-to-video',
endpoint: 'fal-ai/kling-video/v3/pro/image-to-video',
imageField: 'start_image_url',
},
]
describe('FalVideoGenerator kling presets', () => {
beforeEach(() => {
vi.clearAllMocks()
apiConfigMock.getProviderConfig.mockResolvedValue({ apiKey: 'fal-key' })
asyncSubmitMock.submitFalTask.mockResolvedValue('req_kling_1')
})
it.each(KLING_MODEL_CASES)('submits $modelId to expected endpoint and payload', async ({ modelId, endpoint, imageField }) => {
const generator = new FalVideoGenerator()
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/start.png',
prompt: 'test prompt',
options: {
modelId,
duration: 5,
aspectRatio: '16:9',
},
})
expect(result.success).toBe(true)
expect(result.endpoint).toBe(endpoint)
expect(result.requestId).toBe('req_kling_1')
expect(result.externalId).toBe(`FAL:VIDEO:${endpoint}:req_kling_1`)
expect(apiConfigMock.getProviderConfig).toHaveBeenCalledWith('user-1', 'fal')
const submitCall = asyncSubmitMock.submitFalTask.mock.calls.at(0) as
| [string, Record<string, unknown>, string]
| undefined
expect(submitCall).toBeTruthy()
if (!submitCall) {
throw new Error('submitFalTask should be called')
}
expect(submitCall[0]).toBe(endpoint)
expect(submitCall[2]).toBe('fal-key')
const payload = submitCall[1]
expect(payload.prompt).toBe('test prompt')
expect(payload.duration).toBe('5')
if (imageField === 'image_url') {
expect(payload.image_url).toBe('https://example.com/start.png')
expect(payload.start_image_url).toBeUndefined()
expect(payload.negative_prompt).toBe('blur, distort, and low quality')
expect(payload.cfg_scale).toBe(0.5)
return
}
expect(payload.start_image_url).toBe('https://example.com/start.png')
expect(payload.image_url).toBeUndefined()
expect(payload.aspect_ratio).toBe('16:9')
expect(payload.generate_audio).toBe(false)
})
})

View File

@@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const googleGenerateContentMock = vi.hoisted(() => vi.fn())
const getProviderConfigMock = vi.hoisted(() => vi.fn())
const getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,UkVG'))
const arkImageGenerationMock = vi.hoisted(() => vi.fn())
const normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'UkVG'))
vi.mock('@google/genai', () => ({
GoogleGenAI: class GoogleGenAI {
models = {
generateContent: googleGenerateContentMock,
}
},
HarmCategory: {
HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT',
HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH',
HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT',
},
HarmBlockThreshold: {
BLOCK_NONE: 'BLOCK_NONE',
},
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/image-cache', () => ({
getImageBase64Cached: getImageBase64CachedMock,
}))
vi.mock('@/lib/ark-api', () => ({
arkImageGeneration: arkImageGenerationMock,
}))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,
}))
import { ArkSeedreamGenerator } from '@/lib/generators/ark'
import { GeminiCompatibleImageGenerator } from '@/lib/generators/image/gemini-compatible'
import { GoogleGeminiImageGenerator } from '@/lib/generators/image/google'
describe('image provider smoke tests', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('Google Gemini 官方文生图可用 -> 返回 data URL', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'google',
apiKey: 'google-key',
})
googleGenerateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/png',
data: 'R09PR0xF',
},
},
],
},
},
],
})
const generator = new GoogleGeminiImageGenerator('gemini-3-pro-image-preview')
const result = await generator.generate({
userId: 'user-1',
prompt: 'draw a mountain',
options: {
aspectRatio: '3:4',
},
})
expect(result).toEqual({
success: true,
imageBase64: 'R09PR0xF',
imageUrl: 'data:image/png;base64,R09PR0xF',
})
expect(googleGenerateContentMock).toHaveBeenCalledWith({
model: 'gemini-3-pro-image-preview',
contents: [{ parts: [{ text: 'draw a mountain' }] }],
config: expect.objectContaining({
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: { aspectRatio: '3:4' },
}),
})
})
it('Seedream 图生图可用 -> 返回 ARK 图片 URL', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'ark',
apiKey: 'ark-key',
})
arkImageGenerationMock.mockResolvedValueOnce({
data: [{ url: 'https://seedream.test/image.png' }],
})
const generator = new ArkSeedreamGenerator()
const result = await generator.generate({
userId: 'user-1',
prompt: 'refine this style',
referenceImages: ['https://example.com/ref.png'],
options: {
modelId: 'doubao-seedream-4-5-251128',
aspectRatio: '3:4',
},
})
expect(result).toEqual({
success: true,
imageUrl: 'https://seedream.test/image.png',
})
expect(arkImageGenerationMock).toHaveBeenCalledWith({
model: 'doubao-seedream-4-5-251128',
prompt: 'refine this style',
sequential_image_generation: 'disabled',
response_format: 'url',
stream: false,
watermark: false,
size: '3544x4728',
image: ['UkVG'],
}, {
apiKey: 'ark-key',
logPrefix: '[ARK Image]',
})
})
it('Gemini 兼容层文生图可用 -> 直连 Gemini SDK 协议返回图片', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'gemini-compatible:gm-1',
apiKey: 'gm-key',
baseUrl: 'https://gm.test',
})
googleGenerateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/webp',
data: 'R01fVEVYVA==',
},
},
],
},
},
],
})
const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'draw a cat',
options: {
aspectRatio: '1:1',
},
})
expect(result).toEqual({
success: true,
imageBase64: 'R01fVEVYVA==',
imageUrl: 'data:image/webp;base64,R01fVEVYVA==',
})
expect(googleGenerateContentMock).toHaveBeenCalledWith({
model: 'gemini-2.5-flash-image-preview',
contents: [{ parts: [{ text: 'draw a cat' }] }],
config: expect.objectContaining({
responseModalities: ['TEXT', 'IMAGE'],
imageConfig: { aspectRatio: '1:1' },
}),
})
})
it('Gemini 兼容层图生图可用 -> 参考图会注入 inlineData', async () => {
getProviderConfigMock.mockResolvedValueOnce({
id: 'gemini-compatible:gm-1',
apiKey: 'gm-key',
baseUrl: 'https://gm.test',
})
googleGenerateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/png',
data: 'R01fSTJJPQ==',
},
},
],
},
},
],
})
const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'restyle this portrait',
referenceImages: ['/api/files/ref-image'],
options: {
resolution: '2K',
},
})
expect(result).toEqual({
success: true,
imageBase64: 'R01fSTJJPQ==',
imageUrl: 'data:image/png;base64,R01fSTJJPQ==',
})
const call = googleGenerateContentMock.mock.calls[0]
expect(call).toBeTruthy()
if (!call) {
throw new Error('Gemini generateContent should be called')
}
const content = call[0] as {
contents: Array<{ parts: Array<{ inlineData?: { mimeType: string; data: string }; text?: string }> }>
config: { imageConfig?: { imageSize?: string } }
}
expect(content.contents[0].parts[0].inlineData).toEqual({ mimeType: 'image/png', data: 'UkVG' })
expect(content.contents[0].parts[1].text).toBe('restyle this portrait')
expect(content.config.imageConfig).toEqual({ imageSize: '2K' })
})
})

View File

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const openAIState = vi.hoisted(() => ({
generate: vi.fn(),
edit: vi.fn(),
toFile: vi.fn(async () => ({ name: 'mock-file' })),
}))
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})))
const getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,QQ=='))
vi.mock('openai', () => ({
default: class OpenAI {
images = {
generate: openAIState.generate,
edit: openAIState.edit,
}
},
toFile: openAIState.toFile,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/image-cache', () => ({
getImageBase64Cached: getImageBase64CachedMock,
}))
import { OpenAICompatibleImageGenerator } from '@/lib/generators/image/openai-compatible'
describe('OpenAICompatibleImageGenerator', () => {
beforeEach(() => {
vi.clearAllMocks()
getProviderConfigMock.mockResolvedValue({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})
})
it('uses official images.generate payload parameters', async () => {
openAIState.generate.mockResolvedValueOnce({
data: [{ b64_json: 'YmFzZTY0' }],
})
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'draw a lighthouse',
options: {
size: '1024x1024',
quality: 'high',
outputFormat: 'png',
responseFormat: 'b64_json',
},
})
expect(result.success).toBe(true)
expect(result.imageBase64).toBe('YmFzZTY0')
expect(result.imageUrl).toBe('data:image/png;base64,YmFzZTY0')
expect(openAIState.generate).toHaveBeenCalledWith({
model: 'gpt-image-1',
prompt: 'draw a lighthouse',
response_format: 'b64_json',
output_format: 'png',
quality: 'high',
size: '1024x1024',
})
})
it('uses official images.edit payload when reference images are provided', async () => {
openAIState.edit.mockResolvedValueOnce({
data: [{ b64_json: 'ZWRpdA==' }],
})
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'edit this image',
referenceImages: ['data:image/png;base64,QQ=='],
options: {
quality: 'medium',
},
})
expect(result.success).toBe(true)
expect(openAIState.toFile).toHaveBeenCalledTimes(1)
const call = openAIState.edit.mock.calls[0]
expect(call).toBeTruthy()
if (!call) {
throw new Error('images.edit should be called')
}
expect(call[0]).toMatchObject({
model: 'gpt-image-1',
prompt: 'edit this image',
response_format: 'b64_json',
quality: 'medium',
})
expect(Array.isArray((call[0] as { image?: unknown }).image)).toBe(true)
})
it('fails explicitly on unsupported option values', async () => {
const generator = new OpenAICompatibleImageGenerator('gpt-image-1', 'openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
prompt: 'draw',
options: {
quality: 'ultra',
},
})
expect(result.success).toBe(false)
expect(result.error).toContain('OPENAI_COMPAT_IMAGE_OPTION_UNSUPPORTED')
})
})

View File

@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const openAIState = vi.hoisted(() => ({
create: vi.fn(),
toFile: vi.fn(async () => ({ name: 'reference-file' })),
}))
const getProviderConfigMock = vi.hoisted(() => vi.fn(async () => ({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})))
const normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,QQ=='))
vi.mock('openai', () => ({
default: class OpenAI {
videos = {
create: openAIState.create,
}
},
toFile: openAIState.toFile,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock,
}))
import { OpenAICompatibleVideoGenerator } from '@/lib/generators/video/openai-compatible'
describe('OpenAICompatibleVideoGenerator', () => {
beforeEach(() => {
vi.clearAllMocks()
getProviderConfigMock.mockResolvedValue({
id: 'openai-compatible:oa-1',
apiKey: 'oa-key',
baseUrl: 'https://oa.test/v1',
})
})
it('submits official videos.create payload and returns OPENAI externalId', async () => {
openAIState.create.mockResolvedValueOnce({ id: 'vid_123' })
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate this character',
options: {
modelId: 'sora-2',
duration: 8,
resolution: '720p',
aspectRatio: '16:9',
},
})
expect(result.success).toBe(true)
expect(result.async).toBe(true)
expect(result.requestId).toBe('vid_123')
const expectedProviderToken = Buffer.from('openai-compatible:oa-1', 'utf8').toString('base64url')
expect(result.externalId).toBe(`OPENAI:VIDEO:${expectedProviderToken}:vid_123`)
const createCall = openAIState.create.mock.calls[0]
expect(createCall).toBeTruthy()
if (!createCall) {
throw new Error('videos.create should be called')
}
expect(createCall[0]).toMatchObject({
prompt: 'animate this character',
model: 'sora-2',
seconds: '8',
size: '1280x720',
})
expect((createCall[0] as { input_reference?: unknown }).input_reference).toBeDefined()
})
it('allows custom model ids for openai-compatible gateways', async () => {
openAIState.create.mockResolvedValueOnce({ id: 'vid_custom' })
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate',
options: {
modelId: 'veo_3_1-fast-4K',
},
})
expect(result.success).toBe(true)
const createCall = openAIState.create.mock.calls.at(0)
expect(createCall).toBeTruthy()
if (!createCall) {
throw new Error('videos.create should be called')
}
expect((createCall[0] as { model?: string }).model).toBe('veo_3_1-fast-4K')
})
it('maps 3:2 to landscape size explicitly', async () => {
openAIState.create.mockResolvedValueOnce({ id: 'vid_32' })
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate',
options: {
resolution: '1080p',
aspectRatio: '3:2',
},
})
expect(result.success).toBe(true)
const createCall = openAIState.create.mock.calls.at(0)
expect(createCall).toBeTruthy()
if (!createCall) {
throw new Error('videos.create should be called')
}
expect((createCall[0] as { size?: string }).size).toBe('1792x1024')
})
it('maps 2:3 to portrait size explicitly', async () => {
openAIState.create.mockResolvedValueOnce({ id: 'vid_23' })
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate',
options: {
resolution: '720p',
aspectRatio: '2:3',
},
})
expect(result.success).toBe(true)
const createCall = openAIState.create.mock.calls.at(0)
expect(createCall).toBeTruthy()
if (!createCall) {
throw new Error('videos.create should be called')
}
expect((createCall[0] as { size?: string }).size).toBe('720x1280')
})
it('fails explicitly on unsupported aspect ratios', async () => {
const generator = new OpenAICompatibleVideoGenerator('openai-compatible:oa-1')
const result = await generator.generate({
userId: 'user-1',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate',
options: {
resolution: '720p',
aspectRatio: '5:4',
},
})
expect(result.success).toBe(false)
expect(result.error).toContain('OPENAI_COMPAT_VIDEO_ASPECT_RATIO_UNSUPPORTED')
})
})

View File

@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apiFetch } from '@/lib/api-fetch'
describe('apiFetch locale header injection', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('injects Accept-Language for internal /api requests', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
globalThis.fetch = fetchMock
await apiFetch('/api/tasks?status=running', { method: 'GET' })
const init = fetchMock.mock.calls[0]?.[1]
const headers = new Headers(init?.headers)
expect(headers.get('Accept-Language')).toBe('zh')
})
it('uses pathname locale and does not override explicit Accept-Language', async () => {
vi.stubGlobal('window', {
location: {
pathname: '/en/workspace',
},
})
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
globalThis.fetch = fetchMock
await apiFetch('/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept-Language': 'ja',
},
body: JSON.stringify({ ok: true }),
})
const init = fetchMock.mock.calls[0]?.[1]
const headers = new Headers(init?.headers)
expect(headers.get('Accept-Language')).toBe('ja')
})
it('does not inject locale header for non-internal URLs', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
globalThis.fetch = fetchMock
await apiFetch('https://example.com/health', { method: 'GET' })
const init = fetchMock.mock.calls[0]?.[1]
const headers = new Headers(init?.headers)
expect(headers.has('Accept-Language')).toBe(false)
})
})

View File

@@ -0,0 +1,185 @@
import { describe, expect, it } from 'vitest'
import { safeParseJson, safeParseJsonObject, safeParseJsonArray } from '@/lib/json-repair'
// ─── safeParseJson ───────────────────────────────────────────────────
describe('safeParseJson', () => {
it('正常 JSON 字符串 -> 直接解析成功', () => {
const result = safeParseJson('{"name":"孙悟空","age":500}')
expect(result).toEqual({ name: '孙悟空', age: 500 })
})
it('包含 markdown 代码块 -> 剥离后解析成功', () => {
const input = '```json\n{"key":"value"}\n```'
const result = safeParseJson(input)
expect(result).toEqual({ key: 'value' })
})
it('包含大写 JSON 标记的 markdown 代码块 -> 剥离后解析成功', () => {
const input = '```JSON\n{"key":"value"}\n```'
const result = safeParseJson(input)
expect(result).toEqual({ key: 'value' })
})
it('尾部逗号 -> jsonrepair 修复后解析成功', () => {
const input = '{"a":1,"b":2,}'
const result = safeParseJson(input)
expect(result).toEqual({ a: 1, b: 2 })
})
it('单引号包裹字符串 -> jsonrepair 修复后解析成功', () => {
const input = "{'name':'张三','age':25}"
const result = safeParseJson(input)
expect(result).toEqual({ name: '张三', age: 25 })
})
it('JSON 前后有多余文字 -> jsonrepair 修复后解析成功', () => {
const input = '以下是分析结果:\n{"result":"success"}\n以上是所有内容。'
const result = safeParseJson(input)
expect(result).toEqual({ result: 'success' })
})
it('完全无效内容(无任何 JSON 结构字符)-> jsonrepair 将其视为字符串', () => {
// jsonrepair 会把纯文本修复为 JSON 字符串
const result = safeParseJson('这不是JSON')
expect(result).toBe('这不是JSON')
})
})
// ─── safeParseJsonObject ─────────────────────────────────────────────
describe('safeParseJsonObject', () => {
it('正常 JSON 对象 -> 返回对象', () => {
const result = safeParseJsonObject('{"characters":[],"locations":[]}')
expect(result).toEqual({ characters: [], locations: [] })
})
it('markdown 包裹的 JSON 对象 -> 剥离后返回对象', () => {
const input = '```json\n{"episodes":[{"number":1}]}\n```'
const result = safeParseJsonObject(input)
expect(result).toHaveProperty('episodes')
expect((result.episodes as unknown[])[0]).toEqual({ number: 1 })
})
it('包含中文角引号「」的内容 -> 正常解析保留', () => {
const input = '{"lines":"孙悟空怒道,「一个冒牌货,也敢拦你孙爷爷的路!」"}'
const result = safeParseJsonObject(input)
expect(result.lines).toBe('孙悟空怒道,「一个冒牌货,也敢拦你孙爷爷的路!」')
})
it('LLM 输出数组而非对象 -> 抛出 Expected JSON object 错误', () => {
expect(() => safeParseJsonObject('[1,2,3]')).toThrow('Expected JSON object')
})
it('尾部逗号 + markdown 包裹 -> 修复后返回正确对象', () => {
const input = '```json\n{"a":1,"b":"hello",}\n```'
const result = safeParseJsonObject(input)
expect(result).toEqual({ a: 1, b: 'hello' })
})
})
// ─── safeParseJsonArray ──────────────────────────────────────────────
describe('safeParseJsonArray', () => {
it('正常 JSON 数组 -> 返回对象数组', () => {
const input = '[{"id":1,"name":"角色A"},{"id":2,"name":"角色B"}]'
const result = safeParseJsonArray(input)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ id: 1, name: '角色A' })
expect(result[1]).toEqual({ id: 2, name: '角色B' })
})
it('对象包裹数组 + fallbackKey -> 提取内部数组', () => {
const input = '{"clips":[{"id":1},{"id":2}]}'
const result = safeParseJsonArray(input, 'clips')
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ id: 1 })
})
it('对象包裹数组 + 无 fallbackKey -> 自动发现第一个数组字段', () => {
const input = '{"episodes":[{"number":1},{"number":2}]}'
const result = safeParseJsonArray(input)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ number: 1 })
})
it('markdown 包裹 + 尾部逗号 -> 修复后返回正确数组', () => {
const input = '```json\n[{"a":1},{"b":2},]\n```'
const result = safeParseJsonArray(input)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ a: 1 })
expect(result[1]).toEqual({ b: 2 })
})
it('过滤非对象元素(数字、字符串等)-> 只保留对象', () => {
const input = '[{"valid":true}, 42, "string", null, {"also":true}]'
const result = safeParseJsonArray(input)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({ valid: true })
expect(result[1]).toEqual({ also: true })
})
it('空数组 -> 返回空数组', () => {
const result = safeParseJsonArray('[]')
expect(result).toHaveLength(0)
})
it('非数组非对象 -> 抛出错误', () => {
expect(() => safeParseJsonArray('"just a string"')).toThrow('Expected JSON array')
})
it('对象不含数组字段 -> 抛出错误', () => {
expect(() => safeParseJsonArray('{"key":"value"}')).toThrow('Expected JSON array')
})
})
// ─── 真实 LLM 畸形输出回归测试 ───────────────────────────────────────
describe('LLM 畸形 JSON 输出回归测试', () => {
it('中文弯引号嵌套在 JSON 值中 -> jsonrepair 修复成功', () => {
// 这是导致 "Invalid clip JSON format" 的典型场景
const llmOutput = '```json\n[{"description":"孙悟空怒道,\\u201c一个冒牌货\\u201d"}]\n```'
const result = safeParseJsonArray(llmOutput)
expect(result).toHaveLength(1)
expect(result[0].description).toContain('孙悟空')
})
it('LLM 输出前后带解释文字 -> 提取并解析 JSON', () => {
const llmOutput = `好的,以下是分析结果:
{"locations":[{"name":"客厅_白天","summary":"主角居住的客厅"}]}
以上是所有场景分析。`
const result = safeParseJsonObject(llmOutput)
expect(result.locations).toBeDefined()
const locations = result.locations as unknown[]
expect(locations).toHaveLength(1)
})
it('使用「」角引号的台词内容 -> 正确解析不破坏 JSON', () => {
// 改造后的提示词要求 LLM 用「」替代引号
const llmOutput = '[{"speaker":"孙悟空","content":"「你竟敢拦我的路!」","emotionStrength":0.4}]'
const result = safeParseJsonArray(llmOutput)
expect(result).toHaveLength(1)
expect(result[0].speaker).toBe('孙悟空')
expect(result[0].content).toBe('「你竟敢拦我的路!」')
expect(result[0].emotionStrength).toBe(0.4)
})
it('带控制字符的 JSON -> jsonrepair 修复成功', () => {
// LLM 有时在字符串值中输出真实换行符
const llmOutput = '{"text":"第一行\\n第二行","count":2}'
const result = safeParseJsonObject(llmOutput)
expect(result.text).toBe('第一行\n第二行')
expect(result.count).toBe(2)
})
it('clips 包裹在对象中 -> 正确提取', () => {
// clips-build 中常见的 LLM 输出格式
const llmOutput = '{"clips":[{"id":"clip_1","startText":"从前"},{"id":"clip_2","startText":"后来"}]}'
const result = safeParseJsonArray(llmOutput, 'clips')
expect(result).toHaveLength(2)
expect(result[0].id).toBe('clip_1')
expect(result[1].startText).toBe('后来')
})
})

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { splitStructuredOutput } from '@/components/llm-console/LLMStageStreamCard'
describe('LLMStageStreamCard structured output parsing', () => {
it('moves think-tagged text from final block into reasoning', () => {
const parsed = splitStructuredOutput(`【思考过程】
已有思考
【最终结果】
<think>追加思考</think>
{"locations":[]}`)
expect(parsed.reasoning).toContain('已有思考')
expect(parsed.reasoning).toContain('追加思考')
expect(parsed.finalText).toBe('{"locations":[]}')
})
it('handles unmatched think opening tag during streaming', () => {
const parsed = splitStructuredOutput(`【最终结果】
<think>流式中的思考还没结束`)
expect(parsed.reasoning).toBe('流式中的思考还没结束')
expect(parsed.finalText).toBe('')
})
})

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('logging core suppression', () => {
let originalLogLevel: string | undefined
let originalUnifiedEnabled: string | undefined
beforeEach(() => {
vi.resetModules()
originalLogLevel = process.env.LOG_LEVEL
originalUnifiedEnabled = process.env.LOG_UNIFIED_ENABLED
process.env.LOG_LEVEL = 'INFO'
process.env.LOG_UNIFIED_ENABLED = 'true'
})
afterEach(() => {
if (originalLogLevel === undefined) {
delete process.env.LOG_LEVEL
} else {
process.env.LOG_LEVEL = originalLogLevel
}
if (originalUnifiedEnabled === undefined) {
delete process.env.LOG_UNIFIED_ENABLED
} else {
process.env.LOG_UNIFIED_ENABLED = originalUnifiedEnabled
}
vi.restoreAllMocks()
})
it('suppresses worker.progress.stream logs', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const { createScopedLogger } = await import('@/lib/logging/core')
const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })
logger.info({
action: 'worker.progress.stream',
message: 'worker stream chunk',
details: {
kind: 'text',
seq: 1,
},
})
expect(consoleLogSpy).not.toHaveBeenCalled()
expect(consoleErrorSpy).not.toHaveBeenCalled()
})
it('keeps non-suppressed logs', async () => {
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
const { createScopedLogger } = await import('@/lib/logging/core')
const logger = createScopedLogger({ module: 'worker.waoowaoo-text' })
logger.info({
action: 'worker.progress',
message: 'worker progress update',
})
expect(consoleLogSpy).toHaveBeenCalledTimes(1)
const payload = JSON.parse(String(consoleLogSpy.mock.calls[0]?.[0])) as { action?: string; message?: string }
expect(payload.action).toBe('worker.progress')
expect(payload.message).toBe('worker progress update')
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import {
migrateGatewayRoutePayload,
migrateProviderEntry,
} from '@/lib/migrations/gateway-route-openai-compat'
describe('gateway-route openai-compat migration', () => {
it('migrates openai-compatible litellm route to openai-compat', () => {
const result = migrateProviderEntry({
id: 'openai-compatible:oa-1',
gatewayRoute: 'litellm',
})
expect(result.changed).toBe(true)
expect(result.next).toMatchObject({
id: 'openai-compatible:oa-1',
gatewayRoute: 'openai-compat',
})
expect(result.summary.routeLitellmToOpenaiCompat).toBe(1)
})
it('forces gemini-compatible to gemini-sdk + official route', () => {
const result = migrateProviderEntry({
id: 'gemini-compatible:gm-1',
apiMode: 'openai-official',
gatewayRoute: 'openai-compat',
})
expect(result.changed).toBe(true)
expect(result.next).toMatchObject({
id: 'gemini-compatible:gm-1',
apiMode: 'gemini-sdk',
gatewayRoute: 'official',
})
expect(result.summary.geminiApiModeCorrected).toBe(1)
expect(result.summary.routeForcedOfficial).toBe(1)
})
it('forces non-openai-compatible compat routes to official', () => {
const result = migrateProviderEntry({
id: 'openrouter',
gatewayRoute: 'openai-compat',
})
expect(result.changed).toBe(true)
expect(result.next).toMatchObject({
id: 'openrouter',
gatewayRoute: 'official',
})
expect(result.summary.routeForcedOfficial).toBe(1)
})
it('returns invalid status for malformed payload json', () => {
const result = migrateGatewayRoutePayload('{bad-json')
expect(result.status).toBe('invalid')
expect(result.summary.invalidPayload).toBe(true)
})
it('migrates mixed provider payload and reports aggregate stats', () => {
const result = migrateGatewayRoutePayload(JSON.stringify([
{
id: 'openai-compatible:oa-1',
gatewayRoute: 'litellm',
},
{
id: 'gemini-compatible:gm-1',
apiMode: 'openai-official',
gatewayRoute: 'openai-compat',
},
{
id: 'google',
gatewayRoute: 'official',
},
]))
expect(result.status).toBe('ok')
expect(result.changed).toBe(true)
expect(result.summary.providersScanned).toBe(3)
expect(result.summary.providersChanged).toBe(2)
expect(result.summary.routeLitellmToOpenaiCompat).toBe(1)
expect(result.summary.routeForcedOfficial).toBe(1)
expect(result.summary.geminiApiModeCorrected).toBe(1)
const nextPayload = JSON.parse(result.nextRaw || '[]') as Array<Record<string, unknown>>
expect(nextPayload[0]?.gatewayRoute).toBe('openai-compat')
expect(nextPayload[1]?.apiMode).toBe('gemini-sdk')
expect(nextPayload[1]?.gatewayRoute).toBe('official')
})
})

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import {
addCharacterPromptSuffix,
CHARACTER_PROMPT_SUFFIX,
removeCharacterPromptSuffix,
} from '@/lib/constants'
function countOccurrences(input: string, target: string) {
if (!target) return 0
return input.split(target).length - 1
}
describe('character prompt suffix regression', () => {
it('appends suffix when generating prompt', () => {
const basePrompt = 'A brave knight in silver armor'
const generated = addCharacterPromptSuffix(basePrompt)
expect(generated).toContain(CHARACTER_PROMPT_SUFFIX)
expect(countOccurrences(generated, CHARACTER_PROMPT_SUFFIX)).toBe(1)
})
it('removes suffix text from prompt', () => {
const basePrompt = 'A calm detective with short black hair'
const withSuffix = addCharacterPromptSuffix(basePrompt)
const removed = removeCharacterPromptSuffix(withSuffix)
expect(removed).not.toContain(CHARACTER_PROMPT_SUFFIX)
expect(removed).toContain(basePrompt)
})
it('uses suffix as full prompt when base prompt is empty', () => {
expect(addCharacterPromptSuffix('')).toBe(CHARACTER_PROMPT_SUFFIX)
expect(removeCharacterPromptSuffix('')).toBe('')
})
})

View File

@@ -0,0 +1,278 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { subscribeRecoveredRun } from '@/lib/query/hooks/run-stream/recovered-run-subscription'
function jsonResponse(payload: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
json: async () => payload,
}
}
async function waitForCondition(condition: () => boolean, timeoutMs = 1000) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
if (condition()) return
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error('condition not met before timeout')
}
describe('recovered run subscription', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
vi.useRealTimers()
if (originalFetch) {
globalThis.fetch = originalFetch
} else {
Reflect.deleteProperty(globalThis, 'fetch')
}
})
it('replays run events and keeps recovering when no terminal event is present', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
events: [
{
seq: 1,
eventType: 'step.start',
stepKey: 'clip_1_phase1',
attempt: 1,
payload: {
stepTitle: '分镜规划',
stepIndex: 1,
stepTotal: 4,
message: 'running',
},
createdAt: '2026-02-28T00:00:01.000Z',
},
],
}),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const onSettled = vi.fn()
const cleanup = subscribeRecoveredRun({
runId: 'run-1',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
onSettled,
})
await waitForCondition(() => fetchMock.mock.calls.length > 0 && applyAndCapture.mock.calls.length > 0)
expect(fetchMock).toHaveBeenCalledWith(
'/api/runs/run-1/events?afterSeq=0&limit=500',
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
)
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'step.start',
runId: 'run-1',
stepId: 'clip_1_phase1',
}))
expect(onSettled).not.toHaveBeenCalled()
cleanup()
})
it('settles recovery when replay hits terminal run event', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
events: [
{
seq: 1,
eventType: 'run.error',
payload: {
message: 'exception TypeError: fetch failed sending request',
},
createdAt: '2026-02-28T00:00:02.000Z',
},
],
}),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const onSettled = vi.fn()
subscribeRecoveredRun({
runId: 'run-1',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
onSettled,
})
await waitForCondition(() => onSettled.mock.calls.length === 1 && applyAndCapture.mock.calls.length > 0)
expect(onSettled).toHaveBeenCalledTimes(1)
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'run.error',
runId: 'run-1',
}))
})
it('replays step.chunk output so refresh keeps prior text', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
events: [
{
seq: 1,
eventType: 'step.chunk',
stepKey: 'clip_1_phase1',
payload: {
stream: {
kind: 'text',
lane: 'main',
seq: 1,
delta: '旧输出',
},
},
createdAt: '2026-02-28T00:00:03.000Z',
},
],
}),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const onSettled = vi.fn()
const cleanup = subscribeRecoveredRun({
runId: 'run-1',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
onSettled,
})
await waitForCondition(() => applyAndCapture.mock.calls.some((call) => call[0]?.event === 'step.chunk'))
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'step.chunk',
runId: 'run-1',
stepId: 'clip_1_phase1',
textDelta: '旧输出',
}))
cleanup()
})
it('emits run.error and settles when idle timeout is reached', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
events: [],
}),
)
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const onSettled = vi.fn()
subscribeRecoveredRun({
runId: 'run-timeout',
taskStreamTimeoutMs: 3_000,
applyAndCapture,
onSettled,
})
await vi.advanceTimersByTimeAsync(3_200)
expect(onSettled).toHaveBeenCalledTimes(1)
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'run.error',
runId: 'run-timeout',
message: 'run stream timeout: run-timeout',
}))
vi.useRealTimers()
})
it('resets idle timeout when a new event arrives during recovery', async () => {
vi.useFakeTimers()
let eventFetchCount = 0
const fetchMock = vi.fn().mockImplementation(async () => {
eventFetchCount += 1
if (eventFetchCount === 2) {
return jsonResponse({
events: [
{
seq: 1,
eventType: 'run.start',
payload: { message: 'resumed' },
createdAt: '2026-02-28T00:00:01.500Z',
},
],
})
}
return jsonResponse({ events: [] })
})
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const onSettled = vi.fn()
subscribeRecoveredRun({
runId: 'run-recover',
taskStreamTimeoutMs: 3_000,
applyAndCapture,
onSettled,
})
await vi.advanceTimersByTimeAsync(3_200)
expect(onSettled).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(2_000)
expect(onSettled).toHaveBeenCalledTimes(1)
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'run.start',
runId: 'run-recover',
}))
vi.useRealTimers()
})
it('reconciles run snapshot to failed when event polling stays empty', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/api/runs/run-reconcile/events')) {
return jsonResponse({ events: [] })
}
if (url === '/api/runs/run-reconcile') {
return jsonResponse({
run: {
id: 'run-reconcile',
status: 'failed',
errorMessage: 'Ark Responses 调用失败',
},
})
}
return jsonResponse({ events: [] })
})
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const onSettled = vi.fn()
subscribeRecoveredRun({
runId: 'run-reconcile',
taskStreamTimeoutMs: 20_000,
applyAndCapture,
onSettled,
})
await vi.advanceTimersByTimeAsync(3_500)
expect(onSettled).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/api/runs/run-reconcile',
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
)
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'run.error',
runId: 'run-reconcile',
message: 'Ark Responses 调用失败',
}))
vi.useRealTimers()
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { parseReferenceImages, readBoolean, readString } from '@/lib/workers/handlers/reference-to-character-helpers'
describe('reference-to-character helpers', () => {
it('parses and trims single reference image', () => {
expect(parseReferenceImages({ referenceImageUrl: ' https://x/a.png ' })).toEqual(['https://x/a.png'])
})
it('parses multi reference images and truncates to max 5', () => {
expect(
parseReferenceImages({
referenceImageUrls: [
'https://x/1.png',
'https://x/2.png',
'https://x/3.png',
'https://x/4.png',
'https://x/5.png',
'https://x/6.png',
],
}),
).toEqual([
'https://x/1.png',
'https://x/2.png',
'https://x/3.png',
'https://x/4.png',
'https://x/5.png',
])
})
it('filters empty values', () => {
expect(
parseReferenceImages({
referenceImageUrls: [' ', '\n', 'https://x/ok.png'],
}),
).toEqual(['https://x/ok.png'])
})
it('readString trims and normalizes invalid values', () => {
expect(readString(' abc ')).toBe('abc')
expect(readString(1)).toBe('')
expect(readString(null)).toBe('')
})
it('readBoolean supports boolean/number/string flags', () => {
expect(readBoolean(true)).toBe(true)
expect(readBoolean(1)).toBe(true)
expect(readBoolean('true')).toBe(true)
expect(readBoolean('YES')).toBe(true)
expect(readBoolean('on')).toBe(true)
expect(readBoolean('0')).toBe(false)
expect(readBoolean(false)).toBe(false)
expect(readBoolean(0)).toBe(false)
})
})

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { NextRequest } from 'next/server'
import {
parseSyncFlag,
resolveDisplayMode,
resolvePositiveInteger,
shouldRunSyncTask,
} from '@/lib/llm-observe/route-task'
function buildRequest(path: string, headers?: Record<string, string>) {
return new NextRequest(new URL(path, 'http://localhost'), {
method: 'POST',
headers: headers || {},
})
}
describe('route-task helpers', () => {
it('parseSyncFlag supports boolean-like values', () => {
expect(parseSyncFlag(true)).toBe(true)
expect(parseSyncFlag(1)).toBe(true)
expect(parseSyncFlag('1')).toBe(true)
expect(parseSyncFlag('true')).toBe(true)
expect(parseSyncFlag('yes')).toBe(true)
expect(parseSyncFlag('on')).toBe(true)
expect(parseSyncFlag('false')).toBe(false)
expect(parseSyncFlag(0)).toBe(false)
})
it('shouldRunSyncTask true when internal task header exists', () => {
const req = buildRequest('/api/test', { 'x-internal-task-id': 'task-1' })
expect(shouldRunSyncTask(req, {})).toBe(true)
})
it('shouldRunSyncTask true when body sync flag exists', () => {
const req = buildRequest('/api/test')
expect(shouldRunSyncTask(req, { sync: 'true' })).toBe(true)
})
it('shouldRunSyncTask true when query sync flag exists', () => {
const req = buildRequest('/api/test?sync=1')
expect(shouldRunSyncTask(req, {})).toBe(true)
})
it('resolveDisplayMode falls back to default on invalid value', () => {
expect(resolveDisplayMode('detail', 'loading')).toBe('detail')
expect(resolveDisplayMode('loading', 'detail')).toBe('loading')
expect(resolveDisplayMode('invalid', 'loading')).toBe('loading')
})
it('resolvePositiveInteger returns safe integer fallback', () => {
expect(resolvePositiveInteger(2.9, 1)).toBe(2)
expect(resolvePositiveInteger('9', 1)).toBe(9)
expect(resolvePositiveInteger('0', 7)).toBe(7)
expect(resolvePositiveInteger('abc', 7)).toBe(7)
})
})

View File

@@ -0,0 +1,278 @@
import { describe, expect, it, vi } from 'vitest'
import { executeRunRequest } from '@/lib/query/hooks/run-stream/run-request-executor'
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
function jsonResponse(payload: unknown, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: {
'content-type': 'application/json',
},
})
}
describe('run-request-executor run events path', () => {
it('uses /api/runs/:runId/events when async response includes runId', async () => {
const fetchMock = vi.fn<typeof fetch>()
fetchMock
.mockResolvedValueOnce(jsonResponse({
success: true,
async: true,
taskId: 'task_1',
runId: 'run_1',
}))
.mockResolvedValueOnce(jsonResponse({
runId: 'run_1',
afterSeq: 0,
events: [
{
seq: 1,
eventType: 'run.start',
payload: { message: 'started' },
createdAt: '2026-02-28T00:00:00.000Z',
},
{
seq: 2,
eventType: 'step.start',
stepKey: 'step_a',
attempt: 1,
payload: {
stepTitle: 'Step A',
stepIndex: 1,
stepTotal: 1,
},
createdAt: '2026-02-28T00:00:01.000Z',
},
{
seq: 3,
eventType: 'step.chunk',
stepKey: 'step_a',
attempt: 1,
lane: 'text',
payload: {
stream: {
delta: 'hello',
seq: 1,
},
},
createdAt: '2026-02-28T00:00:01.100Z',
},
{
seq: 4,
eventType: 'step.complete',
stepKey: 'step_a',
attempt: 1,
payload: {
text: 'hello',
},
createdAt: '2026-02-28T00:00:02.000Z',
},
{
seq: 5,
eventType: 'run.complete',
payload: {
summary: { ok: true },
},
createdAt: '2026-02-28T00:00:03.000Z',
},
],
}))
const originalFetch = globalThis.fetch
globalThis.fetch = fetchMock
try {
const captured: RunStreamEvent[] = []
const controller = new AbortController()
const result = await executeRunRequest({
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
requestBody: { episodeId: 'episode_1' },
controller,
taskStreamTimeoutMs: 30_000,
applyAndCapture: (event) => {
captured.push(event)
},
finalResultRef: { current: null },
})
expect(result.status).toBe('completed')
expect(result.runId).toBe('run_1')
expect(captured.some((event) => event.event === 'step.chunk' && event.textDelta === 'hello')).toBe(true)
expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/runs/run_1/events?afterSeq=0&limit=500')
} finally {
globalThis.fetch = originalFetch
}
})
it('surfaces run-events fetch errors instead of swallowing them', async () => {
const fetchMock = vi.fn<typeof fetch>()
fetchMock
.mockResolvedValueOnce(jsonResponse({
success: true,
async: true,
taskId: 'task_1',
runId: 'run_1',
}))
.mockResolvedValueOnce(jsonResponse({
error: {
message: 'events backend unavailable',
},
}, 503))
const originalFetch = globalThis.fetch
globalThis.fetch = fetchMock
try {
const controller = new AbortController()
await expect(executeRunRequest({
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
requestBody: { episodeId: 'episode_1' },
controller,
taskStreamTimeoutMs: 30_000,
applyAndCapture: () => undefined,
finalResultRef: { current: null },
})).rejects.toThrow('run events fetch failed (HTTP 503): events backend unavailable')
} finally {
globalThis.fetch = originalFetch
}
})
it('uses idle timeout and resets the timer when new events arrive', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn<typeof fetch>()
let eventsRequestCount = 0
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/story-to-script-stream')) {
return jsonResponse({
success: true,
async: true,
taskId: 'task_1',
runId: 'run_1',
})
}
if (url === '/api/runs/run_1') {
return jsonResponse({
run: {
id: 'run_1',
status: 'running',
},
})
}
if (!url.includes('/api/runs/run_1/events')) {
return jsonResponse({ events: [] })
}
eventsRequestCount += 1
if (eventsRequestCount === 3) {
return jsonResponse({
events: [
{
seq: 1,
eventType: 'run.start',
payload: { message: 'started' },
createdAt: '2026-02-28T00:00:03.000Z',
},
],
})
}
return jsonResponse({ events: [] })
})
const originalFetch = globalThis.fetch
globalThis.fetch = fetchMock
try {
const controller = new AbortController()
let settled = false
const request = executeRunRequest({
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
requestBody: { episodeId: 'episode_1' },
controller,
taskStreamTimeoutMs: 3_000,
applyAndCapture: () => undefined,
finalResultRef: { current: null },
}).finally(() => {
settled = true
})
await vi.advanceTimersByTimeAsync(5_000)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(3_000)
await expect(request).resolves.toEqual(expect.objectContaining({
runId: 'run_1',
status: 'failed',
errorMessage: 'run stream timeout: run_1',
}))
} finally {
vi.useRealTimers()
globalThis.fetch = originalFetch
}
})
it('reconciles terminal failed run status when events stream has no new rows', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn<typeof fetch>()
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/story-to-script-stream')) {
return jsonResponse({
success: true,
async: true,
taskId: 'task_2',
runId: 'run_2',
})
}
if (url.includes('/api/runs/run_2/events')) {
return jsonResponse({ events: [] })
}
if (url === '/api/runs/run_2') {
return jsonResponse({
run: {
id: 'run_2',
status: 'failed',
errorMessage: 'Ark Responses 调用失败',
},
})
}
return jsonResponse({ events: [] })
})
const originalFetch = globalThis.fetch
globalThis.fetch = fetchMock
try {
const captured: RunStreamEvent[] = []
const controller = new AbortController()
const request = executeRunRequest({
endpointUrl: '/api/novel-promotion/project_1/story-to-script-stream',
requestBody: { episodeId: 'episode_1' },
controller,
taskStreamTimeoutMs: 30_000,
applyAndCapture: (event) => {
captured.push(event)
},
finalResultRef: { current: null },
})
await vi.advanceTimersByTimeAsync(3_500)
await expect(request).resolves.toEqual(expect.objectContaining({
runId: 'run_2',
status: 'failed',
errorMessage: 'Ark Responses 调用失败',
}))
expect(fetchMock).toHaveBeenCalledWith(
'/api/runs/run_2',
expect.objectContaining({ method: 'GET', cache: 'no-store' }),
)
expect(captured.some((event) => event.event === 'run.error' && event.message === 'Ark Responses 调用失败')).toBe(true)
} finally {
vi.useRealTimers()
globalThis.fetch = originalFetch
}
})
})

View File

@@ -0,0 +1,370 @@
import { describe, expect, it } from 'vitest'
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
import { applyRunStreamEvent, getStageOutput } from '@/lib/query/hooks/run-stream/state-machine'
function applySequence(events: RunStreamEvent[]) {
let state = null
for (const event of events) {
state = applyRunStreamEvent(state, event)
}
return state
}
describe('run stream state-machine', () => {
it('marks unfinished steps as failed when run.error arrives', () => {
const runId = 'run-1'
const state = applySequence([
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:01.000Z',
status: 'running',
stepId: 'step-a',
stepTitle: 'A',
stepIndex: 1,
stepTotal: 2,
},
{
runId,
event: 'step.complete',
ts: '2026-02-26T23:00:02.000Z',
status: 'completed',
stepId: 'step-b',
stepTitle: 'B',
stepIndex: 2,
stepTotal: 2,
text: 'ok',
},
{
runId,
event: 'run.error',
ts: '2026-02-26T23:00:03.000Z',
status: 'failed',
message: 'exception TypeError: fetch failed sending request',
},
])
expect(state?.status).toBe('failed')
expect(state?.stepsById['step-a']?.status).toBe('failed')
expect(state?.stepsById['step-a']?.errorMessage).toContain('fetch failed')
expect(state?.stepsById['step-b']?.status).toBe('completed')
})
it('returns readable error output for failed step without stream text', () => {
const output = getStageOutput({
id: 'step-failed',
attempt: 1,
title: 'failed',
stepIndex: 1,
stepTotal: 1,
status: 'failed',
dependsOn: [],
blockedBy: [],
groupId: null,
parallelKey: null,
retryable: true,
textOutput: '',
reasoningOutput: '',
textLength: 0,
reasoningLength: 0,
message: '',
errorMessage: 'exception TypeError: fetch failed sending request',
updatedAt: Date.now(),
seqByLane: {
text: 0,
reasoning: 0,
},
})
expect(output).toContain('【错误】')
expect(output).toContain('fetch failed sending request')
})
it('merges retry attempts into one step instead of duplicating stage entries', () => {
const runId = 'run-2'
const state = applySequence([
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:01.000Z',
status: 'running',
stepId: 'clip_x_phase1',
stepTitle: 'A',
stepIndex: 1,
stepTotal: 1,
},
{
runId,
event: 'step.chunk',
ts: '2026-02-26T23:00:01.100Z',
status: 'running',
stepId: 'clip_x_phase1',
lane: 'text',
seq: 1,
textDelta: 'first-attempt',
},
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:02.000Z',
status: 'running',
stepId: 'clip_x_phase1_r2',
stepTitle: 'A',
stepIndex: 1,
stepTotal: 1,
},
{
runId,
event: 'step.chunk',
ts: '2026-02-26T23:00:02.100Z',
status: 'running',
stepId: 'clip_x_phase1_r2',
lane: 'text',
seq: 1,
textDelta: 'retry-output',
},
])
expect(state?.stepOrder).toEqual(['clip_x_phase1'])
expect(state?.stepsById['clip_x_phase1']?.attempt).toBe(2)
expect(state?.stepsById['clip_x_phase1']?.textOutput).toBe('retry-output')
})
it('resets step output when a higher stepAttempt starts and ignores stale lower attempt chunks', () => {
const runId = 'run-3'
const state = applySequence([
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:01.000Z',
status: 'running',
stepId: 'clip_y_phase1',
stepAttempt: 1,
stepTitle: 'A',
stepIndex: 1,
stepTotal: 1,
},
{
runId,
event: 'step.chunk',
ts: '2026-02-26T23:00:01.100Z',
status: 'running',
stepId: 'clip_y_phase1',
stepAttempt: 1,
lane: 'text',
seq: 1,
textDelta: 'old-output',
},
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:02.000Z',
status: 'running',
stepId: 'clip_y_phase1',
stepAttempt: 2,
stepTitle: 'A',
stepIndex: 1,
stepTotal: 1,
},
{
runId,
event: 'step.chunk',
ts: '2026-02-26T23:00:02.100Z',
status: 'running',
stepId: 'clip_y_phase1',
stepAttempt: 1,
lane: 'text',
seq: 2,
textDelta: 'should-be-ignored',
},
{
runId,
event: 'step.chunk',
ts: '2026-02-26T23:00:02.200Z',
status: 'running',
stepId: 'clip_y_phase1',
stepAttempt: 2,
lane: 'text',
seq: 1,
textDelta: 'new-output',
},
])
expect(state?.stepsById['clip_y_phase1']?.attempt).toBe(2)
expect(state?.stepsById['clip_y_phase1']?.textOutput).toBe('new-output')
})
it('reopens completed step when late chunk arrives, then finalizes on run.complete', () => {
const runId = 'run-4'
const state = applySequence([
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:01.000Z',
status: 'running',
stepId: 'analyze_characters',
stepTitle: 'characters',
stepIndex: 1,
stepTotal: 2,
},
{
runId,
event: 'step.complete',
ts: '2026-02-26T23:00:02.000Z',
status: 'completed',
stepId: 'analyze_characters',
stepTitle: 'characters',
stepIndex: 1,
stepTotal: 2,
text: 'partial',
},
{
runId,
event: 'step.chunk',
ts: '2026-02-26T23:00:02.100Z',
status: 'running',
stepId: 'analyze_characters',
lane: 'text',
seq: 2,
textDelta: '-tail',
},
{
runId,
event: 'run.complete',
ts: '2026-02-26T23:00:03.000Z',
status: 'completed',
payload: { ok: true },
},
])
expect(state?.status).toBe('completed')
expect(state?.stepsById['analyze_characters']?.status).toBe('completed')
expect(state?.stepsById['analyze_characters']?.textOutput).toBe('partial-tail')
})
it('moves activeStepId to the latest step when no step is running', () => {
const runId = 'run-5'
const state = applySequence([
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
{
runId,
event: 'step.complete',
ts: '2026-02-26T23:00:01.000Z',
status: 'completed',
stepId: 'step-1',
stepTitle: 'step 1',
stepIndex: 1,
stepTotal: 2,
text: 'a',
},
{
runId,
event: 'step.complete',
ts: '2026-02-26T23:00:02.000Z',
status: 'completed',
stepId: 'step-2',
stepTitle: 'step 2',
stepIndex: 2,
stepTotal: 2,
text: 'b',
},
])
expect(state?.activeStepId).toBe('step-2')
})
it('marks step as blocked when blockedBy is present', () => {
const runId = 'run-6'
const state = applySequence([
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:01.000Z',
status: 'running',
stepId: 'step-b',
stepTitle: 'B',
stepIndex: 2,
stepTotal: 2,
blockedBy: ['step-a'],
},
])
expect(state?.stepsById['step-b']?.status).toBe('blocked')
expect(state?.stepsById['step-b']?.blockedBy).toEqual(['step-a'])
})
it('auto-follows active step when selected step was not manually pinned', () => {
const runId = 'run-7'
const state = applySequence([
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:01.000Z',
status: 'running',
stepId: 'step-1',
stepTitle: 'step 1',
stepIndex: 1,
stepTotal: 2,
},
{
runId,
event: 'step.complete',
ts: '2026-02-26T23:00:02.000Z',
status: 'completed',
stepId: 'step-1',
stepTitle: 'step 1',
stepIndex: 1,
stepTotal: 2,
},
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:03.000Z',
status: 'running',
stepId: 'step-2',
stepTitle: 'step 2',
stepIndex: 2,
stepTotal: 2,
},
])
expect(state?.activeStepId).toBe('step-2')
expect(state?.selectedStepId).toBe('step-2')
})
it('moves think-tagged text chunks into reasoning output', () => {
const runId = 'run-8'
const state = applySequence([
{ runId, event: 'run.start', ts: '2026-02-26T23:00:00.000Z', status: 'running' },
{
runId,
event: 'step.start',
ts: '2026-02-26T23:00:01.000Z',
status: 'running',
stepId: 'analyze_locations',
stepTitle: 'locations',
stepIndex: 2,
stepTotal: 2,
},
{
runId,
event: 'step.chunk',
ts: '2026-02-26T23:00:01.200Z',
status: 'running',
stepId: 'analyze_locations',
lane: 'text',
seq: 1,
textDelta: '<think>先分析文本</think>{"locations":[]}',
},
])
expect(state?.stepsById['analyze_locations']?.reasoningOutput).toBe('先分析文本')
expect(state?.stepsById['analyze_locations']?.textOutput).toBe('{"locations":[]}')
})
})

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import { deriveRunStreamView } from '@/lib/query/hooks/run-stream/run-stream-view'
import type { RunState, RunStepState } from '@/lib/query/hooks/run-stream/types'
function buildStep(overrides: Partial<RunStepState> = {}): RunStepState {
return {
id: 'step-1',
attempt: 1,
title: 'step',
stepIndex: 1,
stepTotal: 1,
status: 'running',
dependsOn: [],
blockedBy: [],
groupId: null,
parallelKey: null,
retryable: true,
textOutput: '',
reasoningOutput: '',
textLength: 0,
reasoningLength: 0,
message: '',
errorMessage: '',
updatedAt: Date.now(),
seqByLane: {
text: 0,
reasoning: 0,
},
...overrides,
}
}
function buildRunState(overrides: Partial<RunState> = {}): RunState {
const baseStep = buildStep()
return {
runId: 'run-1',
status: 'running',
startedAt: Date.now(),
updatedAt: Date.now(),
terminalAt: null,
errorMessage: '',
summary: null,
payload: null,
stepsById: {
[baseStep.id]: baseStep,
},
stepOrder: [baseStep.id],
activeStepId: baseStep.id,
selectedStepId: baseStep.id,
...overrides,
}
}
describe('run stream view', () => {
it('keeps console visible for recovered running state', () => {
const state = buildRunState({
status: 'running',
terminalAt: null,
})
const view = deriveRunStreamView({
runState: state,
isLiveRunning: false,
clock: Date.now(),
})
expect(view.isVisible).toBe(true)
})
it('shows run error in output when run failed and selected step has no output', () => {
const state = buildRunState({
status: 'failed',
errorMessage: 'exception TypeError: fetch failed sending request',
stepsById: {
'step-1': buildStep({ status: 'running' }),
},
})
const view = deriveRunStreamView({
runState: state,
isLiveRunning: false,
clock: Date.now(),
})
expect(view.outputText).toContain('【错误】')
expect(view.outputText).toContain('fetch failed sending request')
})
it('shows run error in output when run failed before any step starts', () => {
const state = buildRunState({
status: 'failed',
errorMessage: 'NETWORK_ERROR',
stepsById: {},
stepOrder: [],
activeStepId: null,
selectedStepId: null,
})
const view = deriveRunStreamView({
runState: state,
isLiveRunning: false,
clock: Date.now(),
})
expect(view.outputText).toBe('【错误】\nNETWORK_ERROR')
})
it('keeps failed run visible until user reset', () => {
const state = buildRunState({
status: 'failed',
terminalAt: Date.now() - 60_000,
errorMessage: 'failed',
})
const view = deriveRunStreamView({
runState: state,
isLiveRunning: false,
clock: Date.now(),
})
expect(view.isVisible).toBe(true)
})
it('hides completed run console after stream settles', () => {
const state = buildRunState({
status: 'completed',
terminalAt: Date.now() - 30_000,
})
const view = deriveRunStreamView({
runState: state,
isLiveRunning: false,
clock: Date.now(),
})
expect(view.isVisible).toBe(false)
})
it('uses active step message instead of selected completed step message', () => {
const completedStep = buildStep({
id: 'step-1',
title: 'step 1',
status: 'completed',
message: 'progress.runtime.llm.completed',
updatedAt: Date.now() - 1000,
})
const runningStep = buildStep({
id: 'step-2',
title: 'step 2',
stepIndex: 2,
stepTotal: 2,
status: 'running',
message: 'progress.runtime.stage.llmStreaming',
updatedAt: Date.now(),
})
const state = buildRunState({
stepsById: {
'step-1': completedStep,
'step-2': runningStep,
},
stepOrder: ['step-1', 'step-2'],
activeStepId: 'step-2',
selectedStepId: 'step-1',
})
const view = deriveRunStreamView({
runState: state,
isLiveRunning: false,
clock: Date.now(),
})
expect(view.activeMessage).toBe('progress.runtime.stage.llmStreaming')
})
})

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest'
import {
asBoolean,
asNonEmptyString,
asObject,
buildIdleState,
pairKey,
resolveTargetState,
toProgress,
} from '@/lib/task/state-service'
describe('task state service helpers', () => {
it('normalizes primitive parsing helpers', () => {
expect(pairKey('A', 'B')).toBe('A:B')
expect(asObject({ ok: true })).toEqual({ ok: true })
expect(asObject(['x'])).toBeNull()
expect(asNonEmptyString(' x ')).toBe('x')
expect(asNonEmptyString(' ')).toBeNull()
expect(asBoolean(true)).toBe(true)
expect(asBoolean('true')).toBeNull()
expect(toProgress(101)).toBe(100)
expect(toProgress(-5)).toBe(0)
expect(toProgress(Number.NaN)).toBeNull()
})
it('builds idle state when no tasks found', () => {
const idle = buildIdleState({ targetType: 'GlobalCharacter', targetId: 'c1' })
expect(idle.phase).toBe('idle')
expect(idle.runningTaskId).toBeNull()
expect(idle.lastError).toBeNull()
})
it('resolves processing state from active task', () => {
const state = resolveTargetState(
{ targetType: 'GlobalCharacter', targetId: 'c1' },
[
{
id: 'task-1',
type: 'asset_hub_image',
status: 'processing',
progress: 42,
payload: {
stage: 'image_generating',
stageLabel: 'Generating',
ui: { intent: 'create', hasOutputAtStart: false },
},
errorCode: null,
errorMessage: null,
updatedAt: new Date('2026-02-25T00:00:00.000Z'),
},
],
)
expect(state.phase).toBe('processing')
expect(state.runningTaskId).toBe('task-1')
expect(state.progress).toBe(42)
expect(state.stage).toBe('image_generating')
expect(state.stageLabel).toBe('Generating')
})
it('resolves failed state and normalizes error', () => {
const state = resolveTargetState(
{ targetType: 'GlobalCharacter', targetId: 'c1' },
[
{
id: 'task-2',
type: 'asset_hub_image',
status: 'failed',
progress: 100,
payload: { ui: { intent: 'modify', hasOutputAtStart: true } },
errorCode: 'INVALID_PARAMS',
errorMessage: 'bad input',
updatedAt: new Date('2026-02-25T00:00:00.000Z'),
},
],
)
expect(state.phase).toBe('failed')
expect(state.runningTaskId).toBeNull()
expect(state.lastError?.code).toBe('INVALID_PARAMS')
expect(state.lastError?.message).toBe('bad input')
})
})

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import { TASK_TYPE } from '@/lib/task/types'
import { getTaskFlowMeta } from '@/lib/llm-observe/stage-pipeline'
import { normalizeTaskPayload } from '@/lib/task/submitter'
describe('task submitter helpers', () => {
it('fills default flow metadata when payload misses flow fields', () => {
const type = TASK_TYPE.AI_CREATE_CHARACTER
const flow = getTaskFlowMeta(type)
const normalized = normalizeTaskPayload(type, {})
expect(normalized.flowId).toBe(flow.flowId)
expect(normalized.flowStageIndex).toBe(flow.flowStageIndex)
expect(normalized.flowStageTotal).toBe(flow.flowStageTotal)
expect(normalized.flowStageTitle).toBe(flow.flowStageTitle)
expect(normalized.meta).toMatchObject({
flowId: flow.flowId,
flowStageIndex: flow.flowStageIndex,
flowStageTotal: flow.flowStageTotal,
flowStageTitle: flow.flowStageTitle,
})
})
it('normalizes negative stage values', () => {
const normalized = normalizeTaskPayload(TASK_TYPE.ANALYZE_NOVEL, {
flowId: 'flow-a',
flowStageIndex: -9,
flowStageTotal: -1,
flowStageTitle: ' title ',
meta: {},
})
expect(normalized.flowId).toBe('flow-a')
expect(normalized.flowStageIndex).toBeGreaterThanOrEqual(1)
expect(normalized.flowStageTotal).toBeGreaterThanOrEqual(normalized.flowStageIndex)
expect(normalized.flowStageTitle).toBe('title')
})
it('prefers payload meta flow values when valid', () => {
const normalized = normalizeTaskPayload(TASK_TYPE.ANALYZE_NOVEL, {
flowId: 'outer-flow',
flowStageIndex: 1,
flowStageTotal: 2,
flowStageTitle: 'Outer',
meta: {
flowId: 'meta-flow',
flowStageIndex: 3,
flowStageTotal: 7,
flowStageTitle: 'Meta',
},
})
const meta = normalized.meta as Record<string, unknown>
expect(meta.flowId).toBe('meta-flow')
expect(meta.flowStageIndex).toBe(3)
expect(meta.flowStageTotal).toBe(7)
expect(meta.flowStageTitle).toBe('Meta')
})
})

View File

@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest'
import {
checkGithubReleaseUpdate,
compareSemver,
normalizeSemverTag,
shouldPulseUpdate,
} from '@/lib/update-check'
describe('update-check semver helpers', () => {
it('normalizes semver tag with v prefix', () => {
expect(normalizeSemverTag('v0.3.0')).toBe('0.3.0')
})
it('supports prerelease suffix while comparing base semver', () => {
expect(normalizeSemverTag('v0.3.0-rc.1')).toBe('0.3.0')
expect(compareSemver('0.3.0-rc.1', '0.2.9')).toBe(1)
})
it('throws for malformed semver', () => {
expect(() => normalizeSemverTag('0.3')).toThrowError('Invalid semver tag: 0.3')
})
it('compares semver in numeric order', () => {
expect(compareSemver('0.3.0', '0.2.9')).toBe(1)
expect(compareSemver('0.2.0', '0.2.0')).toBe(0)
expect(compareSemver('0.1.9', '0.2.0')).toBe(-1)
})
it('pulses only when this version was not muted', () => {
expect(shouldPulseUpdate('0.3.0', null)).toBe(true)
expect(shouldPulseUpdate('0.3.0', '0.2.9')).toBe(true)
expect(shouldPulseUpdate('0.3.0', '0.3.0')).toBe(false)
})
})
describe('checkGithubReleaseUpdate', () => {
it('returns no-release when GitHub has no releases yet', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 404 }))
const result = await checkGithubReleaseUpdate({
repository: 'owner/repo',
currentVersion: '0.2.0',
fetchImpl: fetchMock,
})
expect(result).toEqual({ kind: 'no-release' })
})
it('returns update-available when latest release is newer', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(
JSON.stringify({
tag_name: 'v0.3.0',
html_url: 'https://github.com/owner/repo/releases/tag/v0.3.0',
name: 'v0.3.0',
published_at: '2026-03-03T10:00:00Z',
}),
{ status: 200 },
))
const result = await checkGithubReleaseUpdate({
repository: 'owner/repo',
currentVersion: '0.2.0',
fetchImpl: fetchMock,
})
expect(result.kind).toBe('update-available')
if (result.kind !== 'update-available') {
throw new Error('expected update-available result')
}
expect(result.latestVersion).toBe('0.3.0')
expect(result.release.tagName).toBe('v0.3.0')
})
it('returns no-update when latest release equals current version', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(
JSON.stringify({
tag_name: 'v0.2.0',
html_url: 'https://github.com/owner/repo/releases/tag/v0.2.0',
name: 'v0.2.0',
published_at: '2026-03-03T10:00:00Z',
}),
{ status: 200 },
))
const result = await checkGithubReleaseUpdate({
repository: 'owner/repo',
currentVersion: '0.2.0',
fetchImpl: fetchMock,
})
expect(result.kind).toBe('no-update')
if (result.kind !== 'no-update') {
throw new Error('expected no-update result')
}
expect(result.latestVersion).toBe('0.2.0')
})
it('returns error when release tag is not valid semver', async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(new Response(
JSON.stringify({
tag_name: 'release-2026-03-03',
html_url: 'https://github.com/owner/repo/releases/tag/release-2026-03-03',
}),
{ status: 200 },
))
const result = await checkGithubReleaseUpdate({
repository: 'owner/repo',
currentVersion: '0.2.0',
fetchImpl: fetchMock,
})
expect(result.kind).toBe('error')
if (result.kind !== 'error') {
throw new Error('expected error result')
}
expect(result.reason).toBe('invalid-version')
})
})

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { hasConfiguredAnalysisModel, readConfiguredAnalysisModel, shouldGuideToModelSetup } from '@/lib/workspace/model-setup'
describe('workspace model setup guidance', () => {
it('有 analysisModel -> 不需要引导设置', () => {
const payload = {
preference: {
analysisModel: 'openai::gpt-4.1',
},
}
expect(hasConfiguredAnalysisModel(payload)).toBe(true)
expect(readConfiguredAnalysisModel(payload)).toBe('openai::gpt-4.1')
expect(shouldGuideToModelSetup(payload)).toBe(false)
})
it('analysisModel 为空 -> 需要引导设置', () => {
const payload = {
preference: {
analysisModel: ' ',
},
}
expect(hasConfiguredAnalysisModel(payload)).toBe(false)
expect(readConfiguredAnalysisModel(payload)).toBeNull()
expect(shouldGuideToModelSetup(payload)).toBe(true)
})
it('payload 非法 -> 需要引导设置', () => {
expect(hasConfiguredAnalysisModel(null)).toBe(false)
expect(readConfiguredAnalysisModel(null)).toBeNull()
expect(hasConfiguredAnalysisModel({})).toBe(false)
expect(readConfiguredAnalysisModel({})).toBeNull()
expect(shouldGuideToModelSetup({})).toBe(true)
})
})

View File

@@ -0,0 +1,48 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
getImageGenerationCountConfig,
getImageGenerationCountOptions,
normalizeImageGenerationCount,
} from '@/lib/image-generation/count'
import {
getImageGenerationCount,
setImageGenerationCount,
} from '@/lib/image-generation/count-preference'
describe('image generation count helpers', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('normalizes values within each scope range', () => {
expect(normalizeImageGenerationCount('character', 0)).toBe(1)
expect(normalizeImageGenerationCount('character', 8)).toBe(6)
expect(normalizeImageGenerationCount('storyboard-candidates', 0)).toBe(1)
expect(normalizeImageGenerationCount('storyboard-candidates', 9)).toBe(4)
})
it('returns ordered options for each scope', () => {
expect(getImageGenerationCountOptions('character')).toEqual([1, 2, 3, 4, 5, 6])
expect(getImageGenerationCountOptions('storyboard-candidates')).toEqual([1, 2, 3, 4])
})
it('reads and writes client preference with scope isolation', () => {
const localStorageMock = {
getItem: vi.fn((key: string) => {
if (key === getImageGenerationCountConfig('character').storageKey) return '5'
if (key === getImageGenerationCountConfig('location').storageKey) return '2'
return null
}),
setItem: vi.fn(),
}
vi.stubGlobal('window', { localStorage: localStorageMock })
expect(getImageGenerationCount('character')).toBe(5)
expect(getImageGenerationCount('location')).toBe(2)
expect(setImageGenerationCount('storyboard-candidates', 8)).toBe(4)
expect(localStorageMock.setItem).toHaveBeenCalledWith(
getImageGenerationCountConfig('storyboard-candidates').storageKey,
'4',
)
})
})

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
countGeneratedImageSlots,
resolveDisplayImageSlots,
resolveGroupedImageSlotPhase,
resolveImageSlotPhase,
shouldShowImageSlotGrid,
} from '@/lib/image-generation/slot-state'
describe('image slot state', () => {
it('counts only slots with image urls', () => {
expect(countGeneratedImageSlots([
{ imageUrl: 'a.png' },
{ imageUrl: null },
{ imageUrl: 'b.png' },
])).toBe(2)
})
it('distinguishes generate and regenerate phases', () => {
expect(resolveImageSlotPhase({ imageUrl: null }, true)).toBe('generating')
expect(resolveImageSlotPhase({ imageUrl: 'a.png' }, true)).toBe('regenerating')
expect(resolveImageSlotPhase({ imageUrl: null }, false)).toBe('idle-empty')
expect(resolveImageSlotPhase({ imageUrl: 'a.png' }, false)).toBe('idle-filled')
})
it('keeps completed filled slots idle while the group still has empty pending slots', () => {
expect(resolveGroupedImageSlotPhase(
{ imageUrl: 'a.png' },
{ isGroupRunning: true, isSlotRunning: false, hasPendingEmptySlots: true },
)).toBe('idle-filled')
expect(resolveGroupedImageSlotPhase(
{ imageUrl: null },
{ isGroupRunning: true, isSlotRunning: true, hasPendingEmptySlots: true },
)).toBe('generating')
})
it('hides legacy empty slots when the location is idle', () => {
const displaySlots = resolveDisplayImageSlots([
{ imageUrl: 'a.png' },
{ imageUrl: null },
{ imageUrl: null },
], {
hasRunningTask: false,
requestedCount: 1,
})
expect(displaySlots).toHaveLength(1)
expect(displaySlots[0]?.imageUrl).toBe('a.png')
})
it('shows only one slot while running a single-image location generation', () => {
const displaySlots = resolveDisplayImageSlots([
{ imageUrl: null },
{ imageUrl: null },
{ imageUrl: null },
], {
hasRunningTask: true,
requestedCount: 1,
})
expect(displaySlots).toHaveLength(1)
})
it('shows requested placeholders while running a multi-image location generation', () => {
const displaySlots = resolveDisplayImageSlots([
{ imageUrl: 'a.png' },
{ imageUrl: null },
{ imageUrl: null },
{ imageUrl: null },
], {
hasRunningTask: true,
requestedCount: 4,
})
expect(displaySlots).toHaveLength(4)
})
it('shows slot grid only after generation is active or meaningful', () => {
expect(shouldShowImageSlotGrid({
totalSlotCount: 3,
generatedCount: 0,
hasRunningTask: false,
hasAnyError: false,
})).toBe(false)
expect(shouldShowImageSlotGrid({
totalSlotCount: 3,
generatedCount: 0,
hasRunningTask: true,
hasAnyError: false,
})).toBe(true)
expect(shouldShowImageSlotGrid({
totalSlotCount: 3,
generatedCount: 1,
hasRunningTask: false,
hasAnyError: false,
})).toBe(true)
})
})

View File

@@ -0,0 +1,224 @@
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const resolveModelSelectionOrSingleMock = vi.hoisted(() => vi.fn())
const getProviderConfigMock = vi.hoisted(() => vi.fn())
const getProviderKeyMock = vi.hoisted(() => vi.fn((providerId: string) => {
const marker = providerId.indexOf(':')
return marker === -1 ? providerId : providerId.slice(0, marker)
}))
const submitFalTaskMock = vi.hoisted(() => vi.fn())
const normalizeToOriginalMediaUrlMock = vi.hoisted(() => vi.fn(async (input: string) => {
if (input.startsWith('/')) {
return `http://localhost:3000${input}`
}
return input
}))
vi.mock('@/lib/api-config', () => ({
resolveModelSelectionOrSingle: resolveModelSelectionOrSingleMock,
getProviderConfig: getProviderConfigMock,
getProviderKey: getProviderKeyMock,
}))
vi.mock('@/lib/async-submit', () => ({
submitFalTask: submitFalTaskMock,
}))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
normalizeToOriginalMediaUrl: normalizeToOriginalMediaUrlMock,
}))
vi.mock('@/lib/logging/core', () => ({
logInfo: vi.fn(),
logError: vi.fn(),
createScopedLogger: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
}))
import { generateLipSync } from '@/lib/lipsync'
const POLICY_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/uploads'
const SUBMIT_ENDPOINT = 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis'
const UPLOAD_HOST = 'https://upload.example.com'
function buildJsonResponse(payload: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers({
'content-type': 'application/json',
}),
text: async () => JSON.stringify(payload),
} as unknown as Response
}
function buildBinaryResponse(contentType: string, data: string): Response {
const bytes = new TextEncoder().encode(data)
return {
ok: true,
status: 200,
headers: new Headers({
'content-type': contentType,
}),
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
text: async () => '',
} as unknown as Response
}
describe('lip-sync bailian submit', () => {
const originalNextauthUrl = process.env.NEXTAUTH_URL
beforeEach(() => {
vi.clearAllMocks()
process.env.NEXTAUTH_URL = originalNextauthUrl
resolveModelSelectionOrSingleMock.mockResolvedValue({
provider: 'bailian',
modelId: 'videoretalk',
modelKey: 'bailian::videoretalk',
mediaType: 'lipsync',
})
getProviderConfigMock.mockResolvedValue({
id: 'bailian',
apiKey: 'bl-key',
})
})
afterAll(() => {
process.env.NEXTAUTH_URL = originalNextauthUrl
})
it('uploads local media to bailian temp storage then submits oss urls', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.startsWith(`${POLICY_ENDPOINT}?action=getPolicy&model=videoretalk`)) {
return buildJsonResponse({
data: {
upload_host: UPLOAD_HOST,
upload_dir: 'dashscope-instant/upload-dir',
oss_access_key_id: 'ak',
policy: 'policy',
signature: 'sig',
},
})
}
if (url === 'http://localhost:3000/api/storage/sign?key=images%2Fdemo.mp4') {
return buildBinaryResponse('video/mp4', 'video-bytes')
}
if (url === 'http://localhost:3000/api/storage/sign?key=voice%2Fdemo.wav') {
return buildBinaryResponse('audio/wav', 'audio-bytes')
}
if (url === UPLOAD_HOST) {
return {
ok: true,
status: 200,
text: async () => '',
} as unknown as Response
}
if (url === SUBMIT_ENDPOINT) {
return buildJsonResponse({
output: {
task_id: 'task-123',
task_status: 'PENDING',
},
})
}
throw new Error(`unexpected fetch: ${url}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await generateLipSync(
{
videoUrl: '/api/storage/sign?key=images%2Fdemo.mp4',
audioUrl: '/api/storage/sign?key=voice%2Fdemo.wav',
audioDurationMs: 3000,
videoDurationMs: 5000,
},
'user-1',
'bailian::videoretalk',
)
expect(resolveModelSelectionOrSingleMock).toHaveBeenCalledWith('user-1', 'bailian::videoretalk', 'lipsync')
expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')
expect(normalizeToOriginalMediaUrlMock).toHaveBeenCalledWith('/api/storage/sign?key=images%2Fdemo.mp4')
expect(normalizeToOriginalMediaUrlMock).toHaveBeenCalledWith('/api/storage/sign?key=voice%2Fdemo.wav')
const submitCall = fetchMock.mock.calls.find(([input]) => String(input) === SUBMIT_ENDPOINT) as
| [RequestInfo | URL, RequestInit?]
| undefined
expect(submitCall).toBeDefined()
const submitInit = submitCall?.[1]
expect(submitInit).toBeDefined()
if (!submitInit) throw new Error('missing submit init')
expect(submitInit.method).toBe('POST')
expect(submitInit.headers).toEqual({
Authorization: 'Bearer bl-key',
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
'X-DashScope-OssResourceResolve': 'enable',
})
const submitBody = JSON.parse(String(submitInit.body)) as {
model: string
input: { video_url: string; audio_url: string }
}
expect(submitBody.model).toBe('videoretalk')
expect(submitBody.input.video_url).toMatch(/^oss:\/\/dashscope-instant\/upload-dir\/video-/)
expect(submitBody.input.audio_url).toMatch(/^oss:\/\/dashscope-instant\/upload-dir\/audio-/)
const uploadCalls = fetchMock.mock.calls.filter(([input]) => String(input) === UPLOAD_HOST)
expect(uploadCalls.length).toBe(2)
expect(result).toEqual({
requestId: 'task-123',
externalId: 'BAILIAN:VIDEO:task-123',
async: true,
})
})
it('throws explicit error when bailian task id is missing', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.startsWith(`${POLICY_ENDPOINT}?action=getPolicy&model=videoretalk`)) {
return buildJsonResponse({
data: {
upload_host: UPLOAD_HOST,
upload_dir: 'dashscope-instant/upload-dir',
oss_access_key_id: 'ak',
policy: 'policy',
signature: 'sig',
},
})
}
if (url === UPLOAD_HOST) {
return {
ok: true,
status: 200,
text: async () => '',
} as unknown as Response
}
if (url === SUBMIT_ENDPOINT) {
return buildJsonResponse({
output: {
task_status: 'PENDING',
},
})
}
throw new Error(`unexpected fetch: ${url}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
await expect(generateLipSync(
{
videoUrl: 'data:video/mp4;base64,dmk=',
audioUrl: 'data:audio/wav;base64,YXU=',
audioDurationMs: 3000,
videoDurationMs: 5000,
},
'user-1',
'bailian::videoretalk',
)).rejects.toThrow('BAILIAN_LIPSYNC_TASK_ID_MISSING')
})
})

View File

@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const normalizeToOriginalMediaUrlMock = vi.hoisted(() => vi.fn(async (input: string) => input))
const uploadObjectMock = vi.hoisted(() => vi.fn(async () => 'voice/temp/lip-sync-preprocessed/test.wav'))
const getSignedUrlMock = vi.hoisted(() => vi.fn(() => '/api/storage/sign?key=voice%2Ftemp%2Flip-sync-preprocessed%2Ftest.wav'))
const toFetchableUrlMock = vi.hoisted(() => vi.fn((input: string) => {
if (input.startsWith('http://') || input.startsWith('https://') || input.startsWith('data:')) return input
if (input.startsWith('/')) return `https://public.example.com${input}`
return input
}))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToOriginalMediaUrl: normalizeToOriginalMediaUrlMock,
}))
vi.mock('@/lib/storage', () => ({
uploadObject: uploadObjectMock,
getSignedUrl: getSignedUrlMock,
}))
vi.mock('@/lib/storage/utils', () => ({
toFetchableUrl: toFetchableUrlMock,
}))
vi.mock('@/lib/logging/core', () => ({
logInfo: vi.fn(),
}))
import {
LIPSYNC_PREPROCESS_AUDIO_MIN_MS,
preprocessLipSyncParams,
} from '@/lib/lipsync/preprocess'
function buildWav(durationMs: number, sampleRate = 16000): Buffer {
const numChannels = 1
const bitsPerSample = 16
const blockAlign = (numChannels * bitsPerSample) / 8
const byteRate = sampleRate * blockAlign
const dataSize = Math.max(blockAlign, Math.round((durationMs / 1000) * byteRate))
const buffer = Buffer.alloc(44 + dataSize)
buffer.write('RIFF', 0, 'ascii')
buffer.writeUInt32LE(36 + dataSize, 4)
buffer.write('WAVE', 8, 'ascii')
buffer.write('fmt ', 12, 'ascii')
buffer.writeUInt32LE(16, 16)
buffer.writeUInt16LE(1, 20)
buffer.writeUInt16LE(numChannels, 22)
buffer.writeUInt32LE(sampleRate, 24)
buffer.writeUInt32LE(byteRate, 28)
buffer.writeUInt16LE(blockAlign, 32)
buffer.writeUInt16LE(bitsPerSample, 34)
buffer.write('data', 36, 'ascii')
buffer.writeUInt32LE(dataSize, 40)
return buffer
}
function buildMp4WithDuration(durationMs: number): Buffer {
const timescale = 1000
const duration = Math.max(1, Math.round(durationMs))
const mvhdPayload = Buffer.alloc(4 + 4 + 4 + 4 + 4)
mvhdPayload.writeUInt8(0, 0)
mvhdPayload.writeUInt32BE(0, 4)
mvhdPayload.writeUInt32BE(0, 8)
mvhdPayload.writeUInt32BE(timescale, 12)
mvhdPayload.writeUInt32BE(duration, 16)
const mvhdSize = 8 + mvhdPayload.length
const mvhd = Buffer.alloc(mvhdSize)
mvhd.writeUInt32BE(mvhdSize, 0)
mvhd.write('mvhd', 4, 'ascii')
mvhdPayload.copy(mvhd, 8)
const moovSize = 8 + mvhd.length
const moov = Buffer.alloc(moovSize)
moov.writeUInt32BE(moovSize, 0)
moov.write('moov', 4, 'ascii')
mvhd.copy(moov, 8)
const ftyp = Buffer.alloc(24)
ftyp.writeUInt32BE(24, 0)
ftyp.write('ftyp', 4, 'ascii')
ftyp.write('isom', 8, 'ascii')
ftyp.writeUInt32BE(0x200, 12)
ftyp.write('isom', 16, 'ascii')
ftyp.write('mp41', 20, 'ascii')
return Buffer.concat([ftyp, moov])
}
function readWavDurationMs(buffer: Buffer): number {
const byteRate = buffer.readUInt32LE(28)
const dataSize = buffer.readUInt32LE(40)
return Math.round((dataSize / byteRate) * 1000)
}
function buildBinaryResponse(buffer: Buffer, contentType: string): Response {
return {
ok: true,
status: 200,
headers: new Headers({
'content-type': contentType,
}),
arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
text: async () => '',
} as unknown as Response
}
describe('lipsync preprocess', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('pads short audio to minimum duration for fal', async () => {
const shortAudio = buildWav(1000)
const video = buildMp4WithDuration(5000)
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('video.mp4')) return buildBinaryResponse(video, 'video/mp4')
if (url.includes('audio.wav')) return buildBinaryResponse(shortAudio, 'audio/wav')
throw new Error(`unexpected fetch: ${url}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await preprocessLipSyncParams(
{
videoUrl: 'https://assets.example.com/video.mp4',
audioUrl: 'https://assets.example.com/audio.wav',
audioDurationMs: 1000,
},
{ providerKey: 'fal' },
)
expect(result.paddedAudio).toBe(true)
expect(result.trimmedAudio).toBe(false)
expect(result.params.audioUrl.startsWith('data:audio/wav;base64,')).toBe(true)
const base64 = result.params.audioUrl.slice('data:audio/wav;base64,'.length)
const paddedBuffer = Buffer.from(base64, 'base64')
expect(readWavDurationMs(paddedBuffer)).toBeGreaterThanOrEqual(LIPSYNC_PREPROCESS_AUDIO_MIN_MS)
expect(uploadObjectMock).not.toHaveBeenCalled()
})
it('trims audio to video duration for vidu and uploads processed audio', async () => {
const longAudio = buildWav(7000)
const video = buildMp4WithDuration(5000)
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('video.mp4')) return buildBinaryResponse(video, 'video/mp4')
if (url.includes('audio.wav')) return buildBinaryResponse(longAudio, 'audio/wav')
throw new Error(`unexpected fetch: ${url}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await preprocessLipSyncParams(
{
videoUrl: 'https://assets.example.com/video.mp4',
audioUrl: 'https://assets.example.com/audio.wav',
audioDurationMs: 7000,
},
{ providerKey: 'vidu' },
)
expect(result.paddedAudio).toBe(false)
expect(result.trimmedAudio).toBe(true)
expect(uploadObjectMock).toHaveBeenCalledTimes(1)
const uploadCall = uploadObjectMock.mock.calls[0] as unknown as [Buffer] | undefined
expect(uploadCall).toBeTruthy()
if (!uploadCall) throw new Error('expected uploadObject call')
const uploadedBuffer = uploadCall[0]
expect(readWavDurationMs(uploadedBuffer)).toBeLessThanOrEqual(5000)
expect(result.params.audioUrl).toBe('https://public.example.com/api/storage/sign?key=voice%2Ftemp%2Flip-sync-preprocessed%2Ftest.wav')
})
it('probes durations and keeps audio unchanged when no adjustment is needed', async () => {
const audio = buildWav(3000)
const video = buildMp4WithDuration(5000)
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('video.mp4')) return buildBinaryResponse(video, 'video/mp4')
if (url.includes('audio.wav')) return buildBinaryResponse(audio, 'audio/wav')
throw new Error(`unexpected fetch: ${url}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await preprocessLipSyncParams(
{
videoUrl: 'https://assets.example.com/video.mp4',
audioUrl: 'https://assets.example.com/audio.wav',
},
{ providerKey: 'bailian' },
)
expect(result.paddedAudio).toBe(false)
expect(result.trimmedAudio).toBe(false)
expect(result.params.audioUrl).toBe('https://assets.example.com/audio.wav')
expect(fetchMock).toHaveBeenCalled()
expect(uploadObjectMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { buildArkThinkingParam } from '@/lib/ark-llm'
describe('ark thinking param builder', () => {
it('builds enabled thinking param without reasoning_effort', () => {
const params = buildArkThinkingParam('doubao-seed-2-0-lite-260215', true)
expect(params).toEqual({
thinking: {
type: 'enabled',
},
})
})
it('builds disabled thinking param without reasoning_effort', () => {
const params = buildArkThinkingParam('doubao-seed-2-0-lite-260215', false)
expect(params).toEqual({
thinking: {
type: 'disabled',
},
})
})
})

View File

@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const resolveLlmRuntimeModelMock = vi.hoisted(() =>
vi.fn(async () => ({
provider: 'bailian',
modelId: 'qwen3.5-flash',
modelKey: 'bailian::qwen3.5-flash',
})),
)
const completeBailianLlmMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'chatcmpl_mock',
object: 'chat.completion',
created: 1,
model: 'qwen3.5-flash',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'ok' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
},
})),
)
const completeSiliconFlowLlmMock = vi.hoisted(() =>
vi.fn(async () => {
throw new Error('siliconflow should not be called')
}),
)
const runOpenAICompatChatCompletionMock = vi.hoisted(() =>
vi.fn(async () => {
throw new Error('openai-compat should not be called')
}),
)
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'bailian',
name: 'Alibaba Bailian',
apiKey: 'bl-key',
baseUrl: undefined,
gatewayRoute: 'official' as const,
})),
)
const llmLoggerInfoMock = vi.hoisted(() => vi.fn())
const llmLoggerWarnMock = vi.hoisted(() => vi.fn())
const logLlmRawInputMock = vi.hoisted(() => vi.fn())
const logLlmRawOutputMock = vi.hoisted(() => vi.fn())
const recordCompletionUsageMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
getInternalLLMStreamCallbacks: vi.fn(() => null),
}))
vi.mock('@/lib/model-gateway', () => ({
resolveModelGatewayRoute: vi.fn(() => 'official'),
runOpenAICompatChatCompletion: runOpenAICompatChatCompletionMock,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
getProviderKey: vi.fn((providerId: string) => providerId),
}))
vi.mock('@/lib/providers/bailian', () => ({
completeBailianLlm: completeBailianLlmMock,
}))
vi.mock('@/lib/providers/siliconflow', () => ({
completeSiliconFlowLlm: completeSiliconFlowLlmMock,
}))
vi.mock('@/lib/llm/runtime-shared', () => ({
_ulogError: vi.fn(),
_ulogWarn: vi.fn(),
completionUsageSummary: vi.fn(() => ({ promptTokens: 1, completionTokens: 1 })),
isRetryableError: vi.fn(() => false),
llmLogger: {
info: llmLoggerInfoMock,
warn: llmLoggerWarnMock,
},
logLlmRawInput: logLlmRawInputMock,
logLlmRawOutput: logLlmRawOutputMock,
recordCompletionUsage: recordCompletionUsageMock,
resolveLlmRuntimeModel: resolveLlmRuntimeModelMock,
}))
import { chatCompletion } from '@/lib/llm/chat-completion'
describe('llm chatCompletion official provider branch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns completion from bailian official provider without falling through to baseUrl checks', async () => {
const result = await chatCompletion(
'user-1',
'bailian::qwen3.5-flash',
[{ role: 'user', content: 'hello' }],
{ temperature: 0.1 },
)
expect(completeBailianLlmMock).toHaveBeenCalledWith({
modelId: 'qwen3.5-flash',
messages: [{ role: 'user', content: 'hello' }],
apiKey: 'bl-key',
baseUrl: undefined,
temperature: 0.1,
})
expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()
expect(completeSiliconFlowLlmMock).not.toHaveBeenCalled()
expect(result.choices[0]?.message?.content).toBe('ok')
expect(recordCompletionUsageMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type MockRuntimeModel = {
provider: string
modelId: string
modelKey: string
llmProtocol: 'responses' | 'chat-completions' | undefined
}
const resolveLlmRuntimeModelMock = vi.hoisted(() =>
vi.fn<(...args: unknown[]) => Promise<MockRuntimeModel>>(async () => ({
provider: 'openai-compatible:node-1',
modelId: 'gpt-4.1-mini',
modelKey: 'openai-compatible:node-1::gpt-4.1-mini',
llmProtocol: 'responses',
})),
)
const runOpenAICompatResponsesCompletionMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'chatcmpl_responses_1',
object: 'chat.completion',
created: 1,
model: 'gpt-4.1-mini',
choices: [{ index: 0, message: { role: 'assistant', content: 'responses-ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})),
)
const runOpenAICompatChatCompletionMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'chatcmpl_chat_1',
object: 'chat.completion',
created: 1,
model: 'gpt-4.1-mini',
choices: [{ index: 0, message: { role: 'assistant', content: 'chat-ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})),
)
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'openai-compatible:node-1',
name: 'OpenAI Compatible',
apiKey: 'sk-test',
baseUrl: 'https://compat.example.com/v1',
gatewayRoute: 'openai-compat' as const,
apiMode: 'openai-official' as const,
})),
)
const logLlmRawInputMock = vi.hoisted(() => vi.fn())
const logLlmRawOutputMock = vi.hoisted(() => vi.fn())
const recordCompletionUsageMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
getInternalLLMStreamCallbacks: vi.fn(() => null),
}))
vi.mock('@/lib/model-gateway', () => ({
resolveModelGatewayRoute: vi.fn(() => 'openai-compat'),
runOpenAICompatChatCompletion: runOpenAICompatChatCompletionMock,
runOpenAICompatResponsesCompletion: runOpenAICompatResponsesCompletionMock,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
getProviderKey: vi.fn((providerId: string) => providerId.split(':')[0] || providerId),
}))
vi.mock('@/lib/providers/bailian', () => ({
completeBailianLlm: vi.fn(async () => {
throw new Error('bailian should not be called')
}),
}))
vi.mock('@/lib/providers/siliconflow', () => ({
completeSiliconFlowLlm: vi.fn(async () => {
throw new Error('siliconflow should not be called')
}),
}))
vi.mock('@/lib/llm/runtime-shared', () => ({
_ulogError: vi.fn(),
_ulogWarn: vi.fn(),
completionUsageSummary: vi.fn(() => ({ promptTokens: 1, completionTokens: 1 })),
isRetryableError: vi.fn(() => false),
llmLogger: {
info: vi.fn(),
warn: vi.fn(),
},
logLlmRawInput: logLlmRawInputMock,
logLlmRawOutput: logLlmRawOutputMock,
recordCompletionUsage: recordCompletionUsageMock,
resolveLlmRuntimeModel: resolveLlmRuntimeModelMock,
}))
import { chatCompletion } from '@/lib/llm/chat-completion'
describe('llm chatCompletion openai-compatible protocol routing', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uses responses executor when llmProtocol=responses', async () => {
const completion = await chatCompletion(
'user-1',
'openai-compatible:node-1::gpt-4.1-mini',
[{ role: 'user', content: 'hello' }],
{ temperature: 0.2 },
)
expect(runOpenAICompatResponsesCompletionMock).toHaveBeenCalledTimes(1)
expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()
expect(completion.choices[0]?.message?.content).toBe('responses-ok')
})
it('uses chat-completions executor when llmProtocol=chat-completions', async () => {
resolveLlmRuntimeModelMock.mockResolvedValueOnce({
provider: 'openai-compatible:node-1',
modelId: 'gpt-4.1-mini',
modelKey: 'openai-compatible:node-1::gpt-4.1-mini',
llmProtocol: 'chat-completions',
})
const completion = await chatCompletion(
'user-1',
'openai-compatible:node-1::gpt-4.1-mini',
[{ role: 'user', content: 'hello' }],
{ temperature: 0.2 },
)
expect(runOpenAICompatChatCompletionMock).toHaveBeenCalledTimes(1)
expect(runOpenAICompatResponsesCompletionMock).not.toHaveBeenCalled()
expect(completion.choices[0]?.message?.content).toBe('chat-ok')
})
it('fails fast when llmProtocol is missing for openai-compatible model', async () => {
resolveLlmRuntimeModelMock.mockResolvedValueOnce({
provider: 'openai-compatible:node-1',
modelId: 'gpt-4.1-mini',
modelKey: 'openai-compatible:node-1::gpt-4.1-mini',
llmProtocol: undefined,
})
await expect(
chatCompletion(
'user-1',
'openai-compatible:node-1::gpt-4.1-mini',
[{ role: 'user', content: 'hello' }],
{ temperature: 0.2, maxRetries: 0 },
),
).rejects.toThrow('MODEL_LLM_PROTOCOL_REQUIRED')
expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()
expect(runOpenAICompatResponsesCompletionMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,129 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const resolveLlmRuntimeModelMock = vi.hoisted(() =>
vi.fn(async () => ({
provider: 'bailian',
modelId: 'qwen3.5-plus',
modelKey: 'bailian::qwen3.5-plus',
})),
)
const completeBailianLlmMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'chatcmpl_stream_mock',
object: 'chat.completion',
created: 1,
model: 'qwen3.5-plus',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'stream-ok' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 2,
completion_tokens: 2,
total_tokens: 4,
},
})),
)
const completeSiliconFlowLlmMock = vi.hoisted(() =>
vi.fn(async () => {
throw new Error('siliconflow should not be called')
}),
)
const runOpenAICompatChatCompletionMock = vi.hoisted(() =>
vi.fn(async () => {
throw new Error('openai-compat should not be called')
}),
)
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'bailian',
name: 'Alibaba Bailian',
apiKey: 'bl-key',
baseUrl: undefined,
gatewayRoute: 'official' as const,
})),
)
const logLlmRawInputMock = vi.hoisted(() => vi.fn())
const logLlmRawOutputMock = vi.hoisted(() => vi.fn())
const recordCompletionUsageMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/model-gateway', () => ({
resolveModelGatewayRoute: vi.fn(() => 'official'),
runOpenAICompatChatCompletion: runOpenAICompatChatCompletionMock,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
getProviderKey: vi.fn((providerId: string) => providerId),
}))
vi.mock('@/lib/providers/bailian', () => ({
completeBailianLlm: completeBailianLlmMock,
}))
vi.mock('@/lib/providers/siliconflow', () => ({
completeSiliconFlowLlm: completeSiliconFlowLlmMock,
}))
vi.mock('@/lib/llm/runtime-shared', () => ({
completionUsageSummary: vi.fn(() => ({ promptTokens: 2, completionTokens: 2 })),
llmLogger: {
info: vi.fn(),
warn: vi.fn(),
},
logLlmRawInput: logLlmRawInputMock,
logLlmRawOutput: logLlmRawOutputMock,
recordCompletionUsage: recordCompletionUsageMock,
resolveLlmRuntimeModel: resolveLlmRuntimeModelMock,
}))
import { chatCompletionStream } from '@/lib/llm/chat-stream'
describe('llm chatCompletionStream official provider branch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('streams from bailian completion result and exits early', async () => {
const onChunk = vi.fn()
const onComplete = vi.fn()
const completion = await chatCompletionStream(
'user-1',
'bailian::qwen3.5-plus',
[{ role: 'user', content: 'hello' }],
{},
{
onChunk,
onComplete,
},
)
expect(completeBailianLlmMock).toHaveBeenCalledWith({
modelId: 'qwen3.5-plus',
messages: [{ role: 'user', content: 'hello' }],
apiKey: 'bl-key',
baseUrl: undefined,
temperature: 0.7,
})
expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()
expect(completeSiliconFlowLlmMock).not.toHaveBeenCalled()
expect(onComplete).toHaveBeenCalledWith('stream-ok', undefined)
expect(onChunk).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'text',
delta: 'stream-ok',
}),
)
expect(completion.choices[0]?.message?.content).toBe('stream-ok')
expect(recordCompletionUsageMock).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type MockRuntimeModel = {
provider: string
modelId: string
modelKey: string
llmProtocol: 'responses' | 'chat-completions' | undefined
}
const resolveLlmRuntimeModelMock = vi.hoisted(() =>
vi.fn<(...args: unknown[]) => Promise<MockRuntimeModel>>(async () => ({
provider: 'openai-compatible:node-1',
modelId: 'gpt-4.1-mini',
modelKey: 'openai-compatible:node-1::gpt-4.1-mini',
llmProtocol: 'responses',
})),
)
const runOpenAICompatResponsesCompletionMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'chatcmpl_responses_1',
object: 'chat.completion',
created: 1,
model: 'gpt-4.1-mini',
choices: [{ index: 0, message: { role: 'assistant', content: 'responses-stream' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})),
)
const runOpenAICompatChatCompletionMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'chatcmpl_chat_1',
object: 'chat.completion',
created: 1,
model: 'gpt-4.1-mini',
choices: [{ index: 0, message: { role: 'assistant', content: 'chat-stream' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})),
)
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'openai-compatible:node-1',
name: 'OpenAI Compatible',
apiKey: 'sk-test',
baseUrl: 'https://compat.example.com/v1',
gatewayRoute: 'openai-compat' as const,
apiMode: 'openai-official' as const,
})),
)
const logLlmRawInputMock = vi.hoisted(() => vi.fn())
const logLlmRawOutputMock = vi.hoisted(() => vi.fn())
const recordCompletionUsageMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/model-gateway', () => ({
resolveModelGatewayRoute: vi.fn(() => 'openai-compat'),
runOpenAICompatChatCompletion: runOpenAICompatChatCompletionMock,
runOpenAICompatResponsesCompletion: runOpenAICompatResponsesCompletionMock,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
getProviderKey: vi.fn((providerId: string) => providerId.split(':')[0] || providerId),
}))
vi.mock('@/lib/providers/bailian', () => ({
completeBailianLlm: vi.fn(async () => {
throw new Error('bailian should not be called')
}),
}))
vi.mock('@/lib/providers/siliconflow', () => ({
completeSiliconFlowLlm: vi.fn(async () => {
throw new Error('siliconflow should not be called')
}),
}))
vi.mock('@/lib/llm/runtime-shared', () => ({
completionUsageSummary: vi.fn(() => ({ promptTokens: 1, completionTokens: 1 })),
llmLogger: {
info: vi.fn(),
warn: vi.fn(),
},
logLlmRawInput: logLlmRawInputMock,
logLlmRawOutput: logLlmRawOutputMock,
recordCompletionUsage: recordCompletionUsageMock,
resolveLlmRuntimeModel: resolveLlmRuntimeModelMock,
}))
import { chatCompletionStream } from '@/lib/llm/chat-stream'
describe('llm chatCompletionStream openai-compatible protocol routing', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('uses responses executor when llmProtocol=responses', async () => {
const onChunk = vi.fn()
const completion = await chatCompletionStream(
'user-1',
'openai-compatible:node-1::gpt-4.1-mini',
[{ role: 'user', content: 'hello' }],
{ temperature: 0.2 },
{ onChunk },
)
expect(runOpenAICompatResponsesCompletionMock).toHaveBeenCalledTimes(1)
expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()
expect(completion.choices[0]?.message?.content).toBe('responses-stream')
expect(onChunk).toHaveBeenCalled()
})
it('uses chat-completions executor when llmProtocol=chat-completions', async () => {
resolveLlmRuntimeModelMock.mockResolvedValueOnce({
provider: 'openai-compatible:node-1',
modelId: 'gpt-4.1-mini',
modelKey: 'openai-compatible:node-1::gpt-4.1-mini',
llmProtocol: 'chat-completions',
})
const completion = await chatCompletionStream(
'user-1',
'openai-compatible:node-1::gpt-4.1-mini',
[{ role: 'user', content: 'hello' }],
{ temperature: 0.2 },
undefined,
)
expect(runOpenAICompatChatCompletionMock).toHaveBeenCalledTimes(1)
expect(runOpenAICompatResponsesCompletionMock).not.toHaveBeenCalled()
expect(completion.choices[0]?.message?.content).toBe('chat-stream')
})
it('fails fast when llmProtocol is missing for openai-compatible model', async () => {
resolveLlmRuntimeModelMock.mockResolvedValueOnce({
provider: 'openai-compatible:node-1',
modelId: 'gpt-4.1-mini',
modelKey: 'openai-compatible:node-1::gpt-4.1-mini',
llmProtocol: undefined,
})
await expect(
chatCompletionStream(
'user-1',
'openai-compatible:node-1::gpt-4.1-mini',
[{ role: 'user', content: 'hello' }],
{ temperature: 0.2 },
undefined,
),
).rejects.toThrow('MODEL_LLM_PROTOCOL_REQUIRED')
expect(runOpenAICompatChatCompletionMock).not.toHaveBeenCalled()
expect(runOpenAICompatResponsesCompletionMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,50 @@
import type OpenAI from 'openai'
import { describe, expect, it } from 'vitest'
import { getCompletionParts } from '@/lib/llm/completion-parts'
function buildCompletion(content: string): OpenAI.Chat.Completions.ChatCompletion {
return {
id: 'chatcmpl_test',
object: 'chat.completion',
created: 1,
model: 'minimax-m2.5',
choices: [
{
index: 0,
message: {
role: 'assistant',
content,
},
finish_reason: 'stop',
},
],
} as OpenAI.Chat.Completions.ChatCompletion
}
describe('llm completion parts think-tag parsing', () => {
it('splits think tag content into reasoning and clean text', () => {
const completion = buildCompletion(`<think>
让我分析这段文本,筛选出需要制作画面的场景。
</think>
{
"locations": []
}`)
const parts = getCompletionParts(completion)
expect(parts.reasoning).toContain('让我分析这段文本')
expect(parts.text).toBe(`{
"locations": []
}`)
})
it('keeps plain content untouched when no think tag exists', () => {
const completion = buildCompletion('{ "locations": [] }')
const parts = getCompletionParts(completion)
expect(parts.reasoning).toBe('')
expect(parts.text).toBe('{ "locations": [] }')
})
})

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
isLikelyOpenAIReasoningModel,
shouldUseOpenAIReasoningProviderOptions,
} from '@/lib/llm/reasoning-capability'
describe('llm/reasoning-capability', () => {
it('identifies likely OpenAI reasoning model ids', () => {
expect(isLikelyOpenAIReasoningModel('o3-mini')).toBe(true)
expect(isLikelyOpenAIReasoningModel('gpt-5.2')).toBe(true)
expect(isLikelyOpenAIReasoningModel('claude-sonnet-4-6')).toBe(false)
})
it('enables reasoning provider options for native openai provider', () => {
expect(shouldUseOpenAIReasoningProviderOptions({
providerKey: 'openai',
modelId: 'gpt-5.2',
})).toBe(true)
})
it('enables reasoning provider options for openai-compatible only when apiMode is openai-official', () => {
expect(shouldUseOpenAIReasoningProviderOptions({
providerKey: 'openai-compatible',
providerApiMode: 'openai-official',
modelId: 'gpt-5.2',
})).toBe(true)
expect(shouldUseOpenAIReasoningProviderOptions({
providerKey: 'openai-compatible',
modelId: 'gpt-5.2',
})).toBe(false)
})
it('disables reasoning provider options for non-openai models even on openai-compatible gateways', () => {
expect(shouldUseOpenAIReasoningProviderOptions({
providerKey: 'openai-compatible',
providerApiMode: 'openai-official',
modelId: 'claude-sonnet-4-6',
})).toBe(false)
})
})

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { findBuiltinCapabilities } from '@/lib/model-capabilities/catalog'
describe('bailian video capabilities catalog', () => {
it('registers bailian i2v models as normal-mode only', () => {
const models = [
'wan2.6-i2v-flash',
'wan2.6-i2v',
'wan2.5-i2v-preview',
'wan2.2-i2v-plus',
]
for (const modelId of models) {
const capabilities = findBuiltinCapabilities('video', 'bailian', modelId)
expect(capabilities?.video?.generationModeOptions).toEqual(['normal'])
expect(capabilities?.video?.firstlastframe).toBe(false)
}
})
it('registers bailian kf2v models as firstlastframe-only', () => {
const models = [
'wan2.2-kf2v-flash',
'wanx2.1-kf2v-plus',
]
for (const modelId of models) {
const capabilities = findBuiltinCapabilities('video', 'bailian', modelId)
expect(capabilities?.video?.generationModeOptions).toEqual(['firstlastframe'])
expect(capabilities?.video?.firstlastframe).toBe(true)
}
})
})

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
type CapabilitySelections,
type ModelCapabilities,
type UnifiedModelType,
} from '@/lib/model-config-contract'
import { resolveGenerationOptionsForModel } from '@/lib/model-capabilities/lookup'
describe('model-capabilities/lookup - image resolution defaulting', () => {
const modelType: UnifiedModelType = 'image'
const modelKey = 'google::test-image-model'
const capabilities: ModelCapabilities = {
image: {
resolutionOptions: ['0.5K', '1K', '2K'],
},
}
it('auto-fills resolution with first option when missing and required', () => {
const capabilityDefaults: CapabilitySelections = {}
const result = resolveGenerationOptionsForModel({
modelType,
modelKey,
capabilities,
capabilityDefaults,
requireAllFields: true,
})
expect(result.issues).toEqual([])
expect(result.options).toEqual({
resolution: '0.5K',
})
})
it('does not override user-provided resolution', () => {
const capabilityDefaults: CapabilitySelections = {
[modelKey]: {
resolution: '2K',
},
}
const result = resolveGenerationOptionsForModel({
modelType,
modelKey,
capabilities,
capabilityDefaults,
requireAllFields: true,
})
expect(result.issues).toEqual([])
expect(result.options).toEqual({
resolution: '2K',
})
})
})

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
normalizeVideoGenerationSelections,
resolveEffectiveVideoCapabilityDefinitions,
resolveEffectiveVideoCapabilityFields,
} from '@/lib/model-capabilities/video-effective'
import type { VideoPricingTier } from '@/lib/model-pricing/video-tier'
const GOOGLE_VEO_TIERS: VideoPricingTier[] = [
{ when: { resolution: '720p', duration: 4 } },
{ when: { resolution: '720p', duration: 6 } },
{ when: { resolution: '720p', duration: 8 } },
{ when: { resolution: '1080p', duration: 8 } },
{ when: { resolution: '4k', duration: 8 } },
]
describe('model-capabilities/video-effective', () => {
it('derives capability definitions from pricing tiers', () => {
const definitions = resolveEffectiveVideoCapabilityDefinitions({
pricingTiers: GOOGLE_VEO_TIERS,
})
const byField = new Map(definitions.map((item) => [item.field, item.options]))
expect(byField.get('resolution')).toEqual(['720p', '1080p', '4k'])
expect(byField.get('duration')).toEqual([4, 6, 8])
})
it('keeps pinned field and adjusts the linked field to nearest supported combo', () => {
const definitions = resolveEffectiveVideoCapabilityDefinitions({
pricingTiers: GOOGLE_VEO_TIERS,
})
const normalized = normalizeVideoGenerationSelections({
definitions,
pricingTiers: GOOGLE_VEO_TIERS,
selection: {
resolution: '1080p',
duration: 4,
},
pinnedFields: ['resolution'],
})
expect(normalized).toEqual({
resolution: '1080p',
duration: 8,
})
})
it('filters dependent options by current selection', () => {
const definitions = resolveEffectiveVideoCapabilityDefinitions({
pricingTiers: GOOGLE_VEO_TIERS,
})
const fields = resolveEffectiveVideoCapabilityFields({
definitions,
pricingTiers: GOOGLE_VEO_TIERS,
selection: {
resolution: '1080p',
},
})
const durationField = fields.find((field) => field.field === 'duration')
expect(durationField?.options).toEqual([8])
expect(durationField?.value).toBe(8)
})
})

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const chatCompletionMock = vi.hoisted(() => vi.fn(async () => ({ id: 'text-completion' })))
const chatCompletionWithVisionMock = vi.hoisted(() => vi.fn(async () => ({ id: 'vision-completion' })))
vi.mock('@/lib/llm-client', () => ({
chatCompletion: chatCompletionMock,
chatCompletionWithVision: chatCompletionWithVisionMock,
}))
import {
runModelGatewayTextCompletion,
runModelGatewayVisionCompletion,
} from '@/lib/model-gateway/llm'
describe('model-gateway llm wrappers', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('delegates text completion to llm-client chatCompletion', async () => {
const result = await runModelGatewayTextCompletion({
userId: 'user-1',
model: 'openai-compatible::gpt-image-1',
messages: [{ role: 'user', content: 'hello' }],
options: { temperature: 0.2 },
})
expect(chatCompletionMock).toHaveBeenCalledTimes(1)
expect(chatCompletionMock).toHaveBeenCalledWith(
'user-1',
'openai-compatible::gpt-image-1',
[{ role: 'user', content: 'hello' }],
{ temperature: 0.2 },
)
expect(result).toEqual({ id: 'text-completion' })
})
it('delegates vision completion to llm-client chatCompletionWithVision', async () => {
const result = await runModelGatewayVisionCompletion({
userId: 'user-1',
model: 'google::gemini-3-pro',
prompt: 'analyze image',
imageUrls: ['https://example.com/a.png'],
options: { temperature: 0.4 },
})
expect(chatCompletionWithVisionMock).toHaveBeenCalledTimes(1)
expect(chatCompletionWithVisionMock).toHaveBeenCalledWith(
'user-1',
'google::gemini-3-pro',
'analyze image',
['https://example.com/a.png'],
{ temperature: 0.4 },
)
expect(result).toEqual({ id: 'vision-completion' })
})
})

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const resolveOpenAICompatClientConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
providerId: 'openai-compatible:node-1',
baseUrl: 'https://compat.example.com/v1',
apiKey: 'sk-test',
})),
)
vi.mock('@/lib/model-gateway/openai-compat/common', () => ({
resolveOpenAICompatClientConfig: resolveOpenAICompatClientConfigMock,
}))
import { runOpenAICompatResponsesCompletion } from '@/lib/model-gateway/openai-compat/responses'
describe('model-gateway openai-compat responses executor', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('converts responses payload to normalized chat completion', async () => {
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
output: [
{ type: 'reasoning', text: 'think-' },
{ type: 'output_text', text: 'hello' },
],
usage: {
input_tokens: 12,
output_tokens: 7,
},
}), { status: 200 }))
vi.stubGlobal('fetch', fetchMock)
const completion = await runOpenAICompatResponsesCompletion({
userId: 'user-1',
providerId: 'openai-compatible:node-1',
modelId: 'gpt-4.1-mini',
messages: [{ role: 'user', content: 'hello' }],
temperature: 0.2,
})
expect(completion.choices[0]?.message?.content).toEqual([
{ type: 'reasoning', text: 'think-' },
{ type: 'text', text: 'hello' },
])
expect(completion.usage?.prompt_tokens).toBe(12)
expect(completion.usage?.completion_tokens).toBe(7)
const firstCall = fetchMock.mock.calls[0] as unknown[] | undefined
expect(String(firstCall?.[0])).toBe('https://compat.example.com/v1/responses')
})
it('throws status-bearing error when responses endpoint fails', async () => {
const fetchMock = vi.fn(async () => new Response('not supported', { status: 404 }))
vi.stubGlobal('fetch', fetchMock)
await expect(
runOpenAICompatResponsesCompletion({
userId: 'user-1',
providerId: 'openai-compatible:node-1',
modelId: 'gpt-4.1-mini',
messages: [{ role: 'user', content: 'hello' }],
temperature: 0.2,
}),
).rejects.toThrow('OPENAI_COMPAT_RESPONSES_FAILED: 404')
})
})

View File

@@ -0,0 +1,189 @@
import { describe, expect, it } from 'vitest'
import {
buildRenderedTemplateRequest,
buildTemplateVariables,
extractTemplateError,
readJsonPath,
renderTemplateString,
renderTemplateValue,
resolveTemplateEndpointUrl,
} from '@/lib/openai-compat-template-runtime'
describe('model-gateway openai-compat template renderer', () => {
it('renders placeholders in strings and nested body values', () => {
const variables = buildTemplateVariables({
model: 'veo3.1',
prompt: 'a cat running',
image: 'https://a.test/cat.png',
taskId: 'task_1',
})
expect(renderTemplateString('/videos/{{task_id}}', variables)).toBe('/videos/task_1')
expect(renderTemplateValue({
model: '{{model}}',
prompt: '{{prompt}}',
images: '{{images}}',
nested: [{ value: '{{task_id}}' }],
}, variables)).toEqual({
model: 'veo3.1',
prompt: 'a cat running',
images: [],
nested: [{ value: 'task_1' }],
})
})
it('resolves relative path against base url and injects auth header', async () => {
const request = await buildRenderedTemplateRequest({
baseUrl: 'https://compat.example.com/v1/',
endpoint: {
method: 'POST',
path: '/v2/videos/generations',
contentType: 'application/json',
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
},
},
variables: buildTemplateVariables({
model: 'veo3.1',
prompt: 'hello',
}),
defaultAuthHeader: 'Bearer sk-test',
})
expect(resolveTemplateEndpointUrl('https://compat.example.com/v1/', '/v2/videos/generations'))
.toBe('https://compat.example.com/v1/v2/videos/generations')
expect(request.endpointUrl).toBe('https://compat.example.com/v1/v2/videos/generations')
expect(request.headers.Authorization).toBe('Bearer sk-test')
expect(request.headers['Content-Type']).toBe('application/json')
expect(request.body).toBe(JSON.stringify({
model: 'veo3.1',
prompt: 'hello',
}))
})
it('deduplicates /v1 prefix when base url already ends with /v1', async () => {
const request = await buildRenderedTemplateRequest({
baseUrl: 'https://yunwu.ai/v1',
endpoint: {
method: 'GET',
path: '/v1/video/query?id={{task_id}}',
},
variables: buildTemplateVariables({
model: 'veo_3_1-fast-4K',
prompt: '',
taskId: 'task_abc',
}),
defaultAuthHeader: 'Bearer sk-test',
})
expect(resolveTemplateEndpointUrl('https://yunwu.ai/v1', '/v1/video/create'))
.toBe('https://yunwu.ai/v1/video/create')
expect(request.endpointUrl).toBe('https://yunwu.ai/v1/video/query?id=task_abc')
expect(request.headers.Authorization).toBe('Bearer sk-test')
})
it('builds multipart form data and omits explicit content-type header', async () => {
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0p6s8AAAAASUVORK5CYII='
const request = await buildRenderedTemplateRequest({
baseUrl: 'https://compat.example.com/v1',
endpoint: {
method: 'POST',
path: '/videos',
contentType: 'multipart/form-data',
multipartFileFields: ['input_reference'],
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
input_reference: '{{image}}',
},
},
variables: buildTemplateVariables({
model: 'veo3.1',
prompt: 'hello',
image: dataUrl,
}),
defaultAuthHeader: 'Bearer sk-test',
})
expect(request.endpointUrl).toBe('https://compat.example.com/v1/videos')
expect(request.headers.Authorization).toBe('Bearer sk-test')
expect(request.headers['Content-Type']).toBeUndefined()
expect(request.body).toBeInstanceOf(FormData)
const formData = request.body as FormData
expect(formData.get('model')).toBe('veo3.1')
expect(formData.get('prompt')).toBe('hello')
const fileValue = formData.get('input_reference')
expect(fileValue).toBeInstanceOf(File)
expect((fileValue as File).name).toBe('reference-0.png')
})
it('builds application/x-www-form-urlencoded bodies', async () => {
const request = await buildRenderedTemplateRequest({
baseUrl: 'https://compat.example.com/v1',
endpoint: {
method: 'POST',
path: '/videos/query',
contentType: 'application/x-www-form-urlencoded',
bodyTemplate: {
model: '{{model}}',
task_id: '{{task_id}}',
},
},
variables: buildTemplateVariables({
model: 'veo3.1',
prompt: 'hello',
taskId: 'task_1',
}),
})
expect(request.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
expect(request.body).toBeInstanceOf(URLSearchParams)
expect((request.body as URLSearchParams).toString()).toBe('model=veo3.1&task_id=task_1')
})
it('reads json path for array/object outputs', () => {
const payload = {
data: [{ url: 'https://cdn.test/1.png' }],
task: {
status: 'succeeded',
},
}
expect(readJsonPath(payload, '$.data[0].url')).toBe('https://cdn.test/1.png')
expect(readJsonPath(payload, '$.task.status')).toBe('succeeded')
})
it('extracts upstream error message from common payload shape', () => {
const message = extractTemplateError({
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/video/create',
},
status: {
method: 'GET',
path: '/video/query?id={{task_id}}',
},
response: {
taskIdPath: '$.id',
statusPath: '$.status',
},
polling: {
intervalMs: 5000,
timeoutMs: 600000,
doneStates: ['completed'],
failStates: ['failed'],
},
}, {
error: {
message_zh: '当前分组上游负载已饱和,请稍后再试',
},
}, 500)
expect(message).toContain('status 500')
expect(message).toContain('当前分组上游负载已饱和,请稍后再试')
})
})

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const resolveConfigMock = vi.hoisted(() => vi.fn(async () => ({
providerId: 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0',
baseUrl: 'https://compat.example.com/v1',
apiKey: 'sk-test',
})))
vi.mock('@/lib/model-gateway/openai-compat/common', () => ({
resolveOpenAICompatClientConfig: resolveConfigMock,
}))
import { generateVideoViaOpenAICompatTemplate } from '@/lib/model-gateway/openai-compat/template-video'
describe('openai-compat template video externalId', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('encodes compact modelId token for OCOMPAT externalId', async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
id: 'veo3.1-fast:1772734762-6TuDIS8Vvr',
status: 'pending',
}), { status: 200 })) as unknown as typeof fetch
const result = await generateVideoViaOpenAICompatTemplate({
userId: 'user-1',
providerId: 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0',
modelId: 'veo3.1-fast',
modelKey: 'openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::veo3.1-fast',
imageUrl: 'https://example.com/seed.png',
prompt: 'animate this image',
profile: 'openai-compatible',
template: {
version: 1,
mediaType: 'video',
mode: 'async',
create: {
method: 'POST',
path: '/video/create',
bodyTemplate: {
model: '{{model}}',
prompt: '{{prompt}}',
},
},
status: {
method: 'GET',
path: '/video/query?id={{task_id}}',
},
response: {
taskIdPath: '$.id',
statusPath: '$.status',
},
polling: {
intervalMs: 5000,
timeoutMs: 600000,
doneStates: ['completed'],
failStates: ['failed'],
},
},
})
expect(result.success).toBe(true)
expect(result.async).toBe(true)
expect(result.externalId).toContain(':u_33331fb0-2806-4da6-85ff-cd2433b587d0:')
expect(result.externalId).toContain(`:${Buffer.from('veo3.1-fast', 'utf8').toString('base64url')}:`)
expect(result.externalId).not.toContain(Buffer.from('openai-compatible:33331fb0-2806-4da6-85ff-cd2433b587d0::veo3.1-fast', 'utf8').toString('base64url'))
expect(result.externalId!.length).toBeLessThanOrEqual(128)
})
})

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { isCompatibleProvider, resolveModelGatewayRoute } from '@/lib/model-gateway'
describe('model-gateway router', () => {
it('routes openai-compatible providers to openai-compat', () => {
expect(isCompatibleProvider('openai-compatible')).toBe(true)
expect(isCompatibleProvider('openai-compatible:oa-1')).toBe(true)
expect(resolveModelGatewayRoute('openai-compatible:oa-1')).toBe('openai-compat')
})
it('keeps gemini-compatible providers on official route', () => {
expect(isCompatibleProvider('gemini-compatible')).toBe(false)
expect(isCompatibleProvider('gemini-compatible:gm-1')).toBe(false)
expect(resolveModelGatewayRoute('gemini-compatible:gm-1')).toBe('official')
})
it('keeps official providers on official route', () => {
expect(isCompatibleProvider('google')).toBe(false)
expect(isCompatibleProvider('ark')).toBe(false)
expect(isCompatibleProvider('bailian')).toBe(false)
expect(isCompatibleProvider('siliconflow')).toBe(false)
expect(resolveModelGatewayRoute('google')).toBe('official')
expect(resolveModelGatewayRoute('ark')).toBe('official')
expect(resolveModelGatewayRoute('bailian')).toBe('official')
expect(resolveModelGatewayRoute('siliconflow')).toBe('official')
})
})

View File

@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
useQueryClientMock,
useMutationMock,
requestJsonWithErrorMock,
} = vi.hoisted(() => ({
useQueryClientMock: vi.fn(() => ({ invalidateQueries: vi.fn() })),
useMutationMock: vi.fn((options: unknown) => options),
requestJsonWithErrorMock: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => useQueryClientMock(),
useMutation: (options: unknown) => useMutationMock(options),
}))
vi.mock('@/lib/query/mutations/mutation-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(
'@/lib/query/mutations/mutation-shared',
)
return {
...actual,
invalidateQueryTemplates: vi.fn(),
requestJsonWithError: requestJsonWithErrorMock,
}
})
import { useUpdateProjectCharacterVoiceSettings } from '@/lib/query/mutations/character-voice-mutations'
interface UpdateVoiceMutation {
mutationFn: (variables: {
characterId: string
voiceType: 'qwen-designed' | 'uploaded' | 'custom' | null
voiceId?: string
customVoiceUrl?: string
}) => Promise<unknown>
}
describe('project character voice mutations', () => {
beforeEach(() => {
useQueryClientMock.mockClear()
useMutationMock.mockClear()
requestJsonWithErrorMock.mockReset()
requestJsonWithErrorMock.mockResolvedValue({ success: true })
})
it('routes voice setting updates to the character-voice endpoint after designed voice save', async () => {
const mutation = useUpdateProjectCharacterVoiceSettings('project-1') as unknown as UpdateVoiceMutation
await mutation.mutationFn({
characterId: 'character-1',
voiceType: 'qwen-designed',
voiceId: 'voice-1',
customVoiceUrl: 'https://example.com/audio.wav',
})
expect(requestJsonWithErrorMock).toHaveBeenCalledTimes(1)
expect(requestJsonWithErrorMock).toHaveBeenCalledWith(
'/api/novel-promotion/project-1/character-voice',
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
characterId: 'character-1',
voiceType: 'qwen-designed',
voiceId: 'voice-1',
customVoiceUrl: 'https://example.com/audio.wav',
}),
},
'更新音色失败',
)
})
})

View File

@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import {
buildVideoSubmissionKey,
createVideoSubmissionBaseline,
shouldResolveVideoSubmissionLock,
} from '@/lib/novel-promotion/stages/video-stage-runtime/immediate-video-submission'
describe('immediate video submission lock', () => {
it('regenerating an existing video -> keeps local lock until task state or output changes', () => {
const panel = {
panelId: 'panel-1',
storyboardId: 'storyboard-1',
panelIndex: 0,
videoUrl: 'https://example.com/original.mp4',
videoErrorMessage: null,
videoTaskRunning: false,
}
const baseline = createVideoSubmissionBaseline(panel)
expect(buildVideoSubmissionKey(panel)).toBe('panel-1')
expect(
shouldResolveVideoSubmissionLock(
{
...panel,
videoTaskRunning: false,
},
baseline,
baseline.startedAt + 1_000,
),
).toBe(false)
expect(
shouldResolveVideoSubmissionLock(
{
...panel,
videoTaskRunning: true,
},
baseline,
baseline.startedAt + 1_000,
),
).toBe(true)
expect(
shouldResolveVideoSubmissionLock(
{
...panel,
videoUrl: 'https://example.com/regenerated.mp4',
},
baseline,
baseline.startedAt + 1_000,
),
).toBe(true)
})
})

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { usePanelTaskStatus } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/runtime/hooks/usePanelTaskStatus'
describe('panel task status error code mapping', () => {
it('uses explicit error code for user-facing panel error display', () => {
const result = usePanelTaskStatus({
panel: {
storyboardId: 'sb-1',
panelIndex: 0,
videoErrorCode: 'EXTERNAL_ERROR',
videoErrorMessage: 'raw upstream message',
},
hasVisibleBaseVideo: false,
tCommon: (key) => key,
})
expect(result.panelErrorDisplay?.code).toBe('EXTERNAL_ERROR')
expect(result.panelErrorDisplay?.message).toBe('raw upstream message')
})
it('shows fixed unsupported-format message for VIDEO_API_FORMAT_UNSUPPORTED', () => {
const result = usePanelTaskStatus({
panel: {
storyboardId: 'sb-1',
panelIndex: 0,
videoErrorCode: 'VIDEO_API_FORMAT_UNSUPPORTED',
videoErrorMessage: 'VIDEO_API_FORMAT_UNSUPPORTED: OPENAI_COMPAT_VIDEO_TEMPLATE_TASK_ID_NOT_FOUND',
},
hasVisibleBaseVideo: false,
tCommon: (key) => key,
})
expect(result.panelErrorDisplay?.code).toBe('VIDEO_API_FORMAT_UNSUPPORTED')
expect(result.panelErrorDisplay?.message).toBe('当前视频接口格式暂不支持。')
})
})

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
useStateMock,
logErrorMock,
refreshAssetsMock,
updateVoiceSettingsMutateAsyncMock,
saveDesignedVoiceMutateAsyncMock,
setVoiceDesignCharacterMock,
} = vi.hoisted(() => ({
useStateMock: vi.fn(),
logErrorMock: vi.fn(),
refreshAssetsMock: vi.fn(),
updateVoiceSettingsMutateAsyncMock: vi.fn(),
saveDesignedVoiceMutateAsyncMock: vi.fn(),
setVoiceDesignCharacterMock: vi.fn(),
}))
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useState: useStateMock,
}
})
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (key === 'tts.voiceDesignSaved') {
return `voice saved:${String(values?.name ?? '')}`
}
if (key === 'tts.saveVoiceDesignFailed') {
return `save failed:${String(values?.error ?? '')}`
}
if (key === 'common.unknownError') {
return 'unknown error'
}
return key
},
}))
vi.mock('@/lib/logging/core', () => ({
logError: (...args: unknown[]) => logErrorMock(...args),
}))
vi.mock('@/lib/query/hooks', () => ({
useProjectAssets: () => ({
data: {
characters: [{
id: 'character-1',
name: 'Hero',
customVoiceUrl: null,
}],
},
}),
useRefreshProjectAssets: () => refreshAssetsMock,
useUpdateProjectCharacterVoiceSettings: () => ({
mutateAsync: updateVoiceSettingsMutateAsyncMock,
}),
useSaveProjectDesignedVoice: () => ({
mutateAsync: saveDesignedVoiceMutateAsyncMock,
}),
}))
import { useTTSGeneration } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/hooks/useTTSGeneration'
describe('useTTSGeneration', () => {
const originalAlert = globalThis.alert
beforeEach(() => {
useStateMock.mockReset()
logErrorMock.mockReset()
refreshAssetsMock.mockReset()
updateVoiceSettingsMutateAsyncMock.mockReset()
saveDesignedVoiceMutateAsyncMock.mockReset()
setVoiceDesignCharacterMock.mockReset()
saveDesignedVoiceMutateAsyncMock.mockResolvedValue({
success: true,
audioUrl: 'https://signed.example.com/audio.wav',
})
globalThis.alert = vi.fn()
useStateMock.mockReturnValue([
{
id: 'character-1',
name: 'Hero',
hasExistingVoice: false,
},
setVoiceDesignCharacterMock,
])
})
afterEach(() => {
globalThis.alert = originalAlert
})
it('does not send a second voice update request after designed voice save succeeds', async () => {
const hook = useTTSGeneration({ projectId: 'project-1' })
await hook.handleVoiceDesignSave('voice-1', 'base64-audio')
expect(saveDesignedVoiceMutateAsyncMock).toHaveBeenCalledTimes(1)
expect(saveDesignedVoiceMutateAsyncMock).toHaveBeenCalledWith({
characterId: 'character-1',
voiceId: 'voice-1',
audioBase64: 'base64-audio',
})
expect(updateVoiceSettingsMutateAsyncMock).not.toHaveBeenCalled()
expect(refreshAssetsMock).toHaveBeenCalledTimes(1)
expect(globalThis.alert).toHaveBeenCalledWith('voice saved:Hero')
expect(setVoiceDesignCharacterMock).toHaveBeenCalledWith(null)
})
})

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
filterNormalVideoModelOptions,
isFirstLastFrameOnlyModel,
supportsFirstLastFrame,
} from '@/lib/model-capabilities/video-model-options'
import type { VideoModelOption } from '@/lib/novel-promotion/stages/video-stage-runtime/types'
describe('video model options partition', () => {
const models: VideoModelOption[] = [
{
value: 'p::normal',
label: 'normal',
capabilities: {
video: {
generationModeOptions: ['normal'],
firstlastframe: false,
},
},
},
{
value: 'p::firstlast-only',
label: 'firstlast-only',
capabilities: {
video: {
generationModeOptions: ['firstlastframe'],
firstlastframe: true,
},
},
},
{
value: 'p::both',
label: 'both',
capabilities: {
video: {
generationModeOptions: ['normal', 'firstlastframe'],
firstlastframe: true,
},
},
},
{
value: 'p::custom-no-capability',
label: 'custom-no-capability',
},
]
it('detects firstlastframe support and firstlastframe-only capability', () => {
expect(supportsFirstLastFrame(models[0])).toBe(false)
expect(supportsFirstLastFrame(models[1])).toBe(true)
expect(supportsFirstLastFrame(models[2])).toBe(true)
expect(supportsFirstLastFrame(models[3])).toBe(false)
expect(isFirstLastFrameOnlyModel(models[0])).toBe(false)
expect(isFirstLastFrameOnlyModel(models[1])).toBe(true)
expect(isFirstLastFrameOnlyModel(models[2])).toBe(false)
expect(isFirstLastFrameOnlyModel(models[3])).toBe(false)
})
it('filters out firstlastframe-only models from normal video model list', () => {
const normalModels = filterNormalVideoModelOptions(models)
expect(normalModels.map((item) => item.value)).toEqual([
'p::normal',
'p::both',
'p::custom-no-capability',
])
})
})

View File

@@ -0,0 +1,167 @@
import React from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import VideoPanelCardBody from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/VideoPanelCardBody'
import type { VideoPanelRuntime } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/video/panel-card/hooks/useVideoPanelActions'
vi.mock('@/components/task/TaskStatusInline', () => ({
default: () => React.createElement('span', null, 'task-status'),
}))
vi.mock('@/components/ui/config-modals/ModelCapabilityDropdown', () => ({
ModelCapabilityDropdown: () => React.createElement('div', null, 'model-dropdown'),
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: ({ name }: { name: string }) => React.createElement('span', null, name),
}))
function createRuntime(overrides: Partial<VideoPanelRuntime> = {}): VideoPanelRuntime {
const translate = (key: string, values?: Record<string, unknown>) => {
if (key === 'firstLastFrame.asLastFrameFor') {
return `作为镜头 ${String(values?.number ?? '')} 的尾帧`
}
if (key === 'firstLastFrame.asFirstFrameFor') {
return `作为镜头 ${String(values?.number ?? '')} 的首帧`
}
if (key === 'firstLastFrame.generate') return '生成首尾帧视频'
if (key === 'firstLastFrame.generated') return '首尾帧视频已生成'
if (key === 'promptModal.promptLabel') return '视频提示词'
if (key === 'promptModal.placeholder') return '输入首尾帧视频提示词...'
if (key === 'panelCard.clickToEditPrompt') return '点击编辑提示词...'
if (key === 'panelCard.selectModel') return '选择模型'
if (key === 'panelCard.generateVideo') return '生成视频'
if (key === 'panelCard.unknownShotType') return '未知镜头'
if (key === 'stage.hasSynced') return '已生成'
if (key === 'promptModal.duration') return '秒'
return key
}
const runtime = {
t: translate,
tCommon: (key: string) => key,
panel: {
storyboardId: 'sb-1',
panelIndex: 2,
panelId: 'panel-2',
imageUrl: 'https://example.com/frame-2.jpg',
videoUrl: null,
videoGenerationMode: null,
lipSyncVideoUrl: null,
textPanel: {
shot_type: '平视中景',
description: '谢俞站在宴席中央',
duration: 3,
},
},
panelIndex: 2,
panelKey: 'sb-1-2',
media: {
showLipSyncVideo: true,
onToggleLipSyncVideo: () => undefined,
onPreviewImage: () => undefined,
baseVideoUrl: undefined,
currentVideoUrl: undefined,
},
taskStatus: {
isVideoTaskRunning: false,
isLipSyncTaskRunning: false,
taskRunningVideoLabel: '生成中',
lipSyncInlineState: null,
},
videoModel: {
selectedModel: 'veo-3.1',
setSelectedModel: () => undefined,
capabilityFields: [],
generationOptions: {},
setCapabilityValue: () => undefined,
missingCapabilityFields: [],
videoModelOptions: [],
},
player: {
isPlaying: false,
},
promptEditor: {
isEditing: false,
editingPrompt: '',
setEditingPrompt: () => undefined,
handleStartEdit: () => undefined,
handleSave: () => undefined,
handleCancelEdit: () => undefined,
isSavingPrompt: false,
localPrompt: '人物从席间回身,接到下一镜头',
},
voiceManager: {
hasMatchedAudio: false,
hasMatchedVoiceLines: false,
audioGenerateError: null,
localVoiceLines: [],
isVoiceLineTaskRunning: () => false,
handlePlayVoiceLine: () => undefined,
handleGenerateAudio: async () => undefined,
playingVoiceLineId: null,
},
lipSync: {
handleStartLipSync: () => undefined,
executingLipSync: false,
},
layout: {
isLinked: true,
isLastFrame: true,
nextPanel: {
storyboardId: 'sb-1',
panelIndex: 3,
imageUrl: 'https://example.com/frame-3.jpg',
},
prevPanel: {
storyboardId: 'sb-1',
panelIndex: 1,
imageUrl: 'https://example.com/frame-1.jpg',
},
hasNext: true,
flModel: 'veo-3.1',
flModelOptions: [],
flGenerationOptions: {},
flCapabilityFields: [],
flMissingCapabilityFields: [],
flCustomPrompt: '',
defaultFlPrompt: '',
videoRatio: '9:16',
},
actions: {
onGenerateVideo: () => undefined,
onUpdatePanelVideoModel: () => undefined,
onToggleLink: () => undefined,
onFlModelChange: () => undefined,
onFlCapabilityChange: () => undefined,
onFlCustomPromptChange: () => undefined,
onResetFlPrompt: () => undefined,
onGenerateFirstLastFrame: () => undefined,
},
computed: {
showLipSyncSection: false,
canLipSync: false,
hasVisibleBaseVideo: false,
},
}
return {
...runtime,
...overrides,
} as unknown as VideoPanelRuntime
}
describe('VideoPanelCardBody', () => {
it('renders incoming and outgoing first-last-frame UI for chained panel', () => {
const markup = renderToStaticMarkup(
React.createElement(VideoPanelCardBody, {
runtime: createRuntime(),
}),
)
expect(markup).toContain('作为镜头 2 的尾帧')
expect(markup).toContain('作为镜头 4 的首帧')
expect(markup).toContain('视频提示词')
expect(markup).toContain('生成首尾帧视频')
})
})

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useMemo: <T,>(factory: () => T) => factory(),
}
})
import { useVideoPanelsProjection } from '@/lib/novel-promotion/stages/video-stage-runtime/useVideoPanelsProjection'
describe('video panels projection error code', () => {
it('projects failed task lastError code/message onto panel fields', () => {
const result = useVideoPanelsProjection({
clips: [{ id: 'clip-1', start: 0, end: 5, summary: 'clip' }],
storyboards: [{
id: 'sb-1',
clipId: 'clip-1',
panels: [{
id: 'panel-1',
panelIndex: 0,
description: 'panel',
}],
}],
panelVideoStates: {
getTaskState: () => ({
phase: 'failed',
lastError: {
code: 'EXTERNAL_ERROR',
message: 'upstream failed',
},
}),
},
panelLipStates: {
getTaskState: () => null,
},
})
expect(result.allPanels).toHaveLength(1)
expect(result.allPanels[0]?.videoErrorCode).toBe('EXTERNAL_ERROR')
expect(result.allPanels[0]?.videoErrorMessage).toBe('upstream failed')
})
})

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
useStateMock,
useCallbackMock,
useQueryClientMock,
upsertTaskTargetOverlayMock,
} = vi.hoisted(() => ({
useStateMock: vi.fn(),
useCallbackMock: vi.fn((fn: unknown) => fn),
useQueryClientMock: vi.fn(() => ({ id: 'query-client' })),
upsertTaskTargetOverlayMock: vi.fn(),
}))
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useState: useStateMock,
useCallback: useCallbackMock,
}
})
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => useQueryClientMock(),
}))
vi.mock('@/lib/query/task-target-overlay', () => ({
upsertTaskTargetOverlay: (...args: unknown[]) => upsertTaskTargetOverlayMock(...args),
}))
import { useVoiceGenerationActions } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceGenerationActions'
describe('useVoiceGenerationActions', () => {
beforeEach(() => {
useStateMock.mockReset()
useCallbackMock.mockClear()
useQueryClientMock.mockClear()
upsertTaskTargetOverlayMock.mockReset()
useStateMock
.mockImplementationOnce(() => [false, vi.fn()])
.mockImplementationOnce(() => [false, vi.fn()])
.mockImplementationOnce(() => [false, vi.fn()])
})
it('adds an optimistic task overlay for async single-line generation', async () => {
const setPendingVoiceGenerationByLineId = vi.fn()
const notifyVoiceLinesChanged = vi.fn()
const generateVoiceMutation = {
mutateAsync: vi.fn(async () => ({
success: true,
async: true,
taskId: 'task-voice-1',
})),
}
const runtime = useVoiceGenerationActions({
projectId: 'project-1',
episodeId: 'episode-1',
t: (key: string) => key,
voiceLines: [],
linesWithAudio: 0,
speakerCharacterMap: {},
speakerVoices: {},
analyzeVoiceMutation: { mutateAsync: vi.fn() },
generateVoiceMutation,
downloadVoicesMutation: { mutateAsync: vi.fn() },
loadData: vi.fn(),
notifyVoiceLinesChanged,
setPendingVoiceGenerationByLineId,
})
await runtime.handleGenerateLine('line-1')
expect(upsertTaskTargetOverlayMock).toHaveBeenCalledWith(
{ id: 'query-client' },
{
projectId: 'project-1',
targetType: 'NovelPromotionVoiceLine',
targetId: 'line-1',
phase: 'queued',
runningTaskId: 'task-voice-1',
runningTaskType: 'voice_line',
intent: 'generate',
hasOutputAtStart: false,
},
)
expect(notifyVoiceLinesChanged).toHaveBeenCalledTimes(1)
expect(setPendingVoiceGenerationByLineId).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { useEffectMock, useRefMock } = vi.hoisted(() => ({
useEffectMock: vi.fn(),
useRefMock: vi.fn(),
}))
const { apiFetchMock } = vi.hoisted(() => ({
apiFetchMock: vi.fn(),
}))
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useEffect: useEffectMock,
useRef: useRefMock,
}
})
vi.mock('@/lib/api-fetch', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}))
import { useVoiceRuntimeSync } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceRuntimeSync'
import type { VoiceLine } from '@/lib/novel-promotion/stages/voice-stage-runtime/types'
function buildVoiceLine(overrides: Partial<VoiceLine>): VoiceLine {
return {
id: 'line-1',
lineIndex: 1,
speaker: '旁白',
content: '测试台词',
emotionPrompt: null,
emotionStrength: null,
audioUrl: null,
updatedAt: '2026-03-07T12:00:00.000Z',
lineTaskRunning: false,
...overrides,
}
}
describe('useVoiceRuntimeSync', () => {
beforeEach(() => {
useEffectMock.mockReset()
useRefMock.mockReset()
apiFetchMock.mockReset()
useRefMock.mockImplementation((initialValue: unknown) => ({
current: initialValue,
}))
})
it('keeps pending regeneration until the line updatedAt advances', () => {
const loadData = vi.fn(async () => undefined)
const setPendingVoiceGenerationByLineId = vi.fn()
const effectCallbacks: Array<() => void | (() => void)> = []
useEffectMock.mockImplementation((callback: () => void | (() => void)) => {
effectCallbacks.push(callback)
})
const pendingGeneration = {
'line-1': {
submittedUpdatedAt: '2026-03-07T12:00:00.000Z',
startedAt: '2026-03-07T11:59:59.000Z',
taskId: 'task-1',
taskStatus: 'completed' as const,
taskErrorMessage: null,
},
}
useVoiceRuntimeSync({
loadData,
voiceLines: [buildVoiceLine({
audioUrl: '/m/voice-old.wav',
updatedAt: '2026-03-07T12:00:00.000Z',
})],
activeVoiceTaskLineIds: new Set(),
pendingVoiceGenerationByLineId: pendingGeneration,
setPendingVoiceGenerationByLineId,
})
const firstRenderEffects = effectCallbacks.splice(0)
firstRenderEffects[2]?.()
const keepPendingUpdater = setPendingVoiceGenerationByLineId.mock.calls[0]?.[0] as
| ((prev: typeof pendingGeneration) => typeof pendingGeneration)
| undefined
expect(keepPendingUpdater?.(pendingGeneration)).toBe(pendingGeneration)
useVoiceRuntimeSync({
loadData,
voiceLines: [buildVoiceLine({
audioUrl: '/m/voice-new.wav',
updatedAt: '2026-03-07T12:00:03.000Z',
})],
activeVoiceTaskLineIds: new Set(),
pendingVoiceGenerationByLineId: pendingGeneration,
setPendingVoiceGenerationByLineId,
})
const secondRenderEffects = effectCallbacks.splice(0)
secondRenderEffects[2]?.()
const settleUpdater = setPendingVoiceGenerationByLineId.mock.calls[1]?.[0] as
| ((prev: typeof pendingGeneration) => Record<string, never>)
| undefined
expect(settleUpdater?.(pendingGeneration)).toEqual({})
})
it('polls task status for pending generations with task ids', async () => {
const loadData = vi.fn(async () => undefined)
const setPendingVoiceGenerationByLineId = vi.fn()
const effectCallbacks: Array<() => void | (() => void)> = []
const windowStub = {
setInterval: vi.fn(() => 123 as unknown as number),
clearInterval: vi.fn(),
}
vi.stubGlobal('window', windowStub)
apiFetchMock.mockResolvedValue({
ok: true,
json: async () => ({
task: {
status: 'processing',
errorMessage: null,
},
}),
})
useEffectMock.mockImplementation((callback: () => void | (() => void)) => {
effectCallbacks.push(callback)
})
useVoiceRuntimeSync({
loadData,
voiceLines: [buildVoiceLine({
audioUrl: '/m/voice-old.wav',
updatedAt: '2026-03-07T12:00:00.000Z',
})],
activeVoiceTaskLineIds: new Set(),
pendingVoiceGenerationByLineId: {
'line-1': {
submittedUpdatedAt: '2026-03-07T12:00:00.000Z',
startedAt: '2026-03-07T12:24:10.000Z',
taskId: 'task-1',
taskStatus: 'queued',
taskErrorMessage: null,
},
},
setPendingVoiceGenerationByLineId,
})
const renderEffects = effectCallbacks.splice(0)
const cleanup = renderEffects[3]?.()
await Promise.resolve()
expect(apiFetchMock).toHaveBeenCalledWith('/api/tasks/task-1', {
method: 'GET',
cache: 'no-store',
})
expect(windowStub.setInterval).toHaveBeenCalledWith(expect.any(Function), 1200)
cleanup?.()
expect(windowStub.clearInterval).toHaveBeenCalledWith(123)
vi.unstubAllGlobals()
})
it('notifies task failure with backend error message', () => {
const loadData = vi.fn(async () => undefined)
const setPendingVoiceGenerationByLineId = vi.fn()
const onTaskFailure = vi.fn()
const effectCallbacks: Array<() => void | (() => void)> = []
useEffectMock.mockImplementation((callback: () => void | (() => void)) => {
effectCallbacks.push(callback)
})
useVoiceRuntimeSync({
loadData,
voiceLines: [buildVoiceLine({
id: 'line-9',
lineIndex: 9,
})],
activeVoiceTaskLineIds: new Set(),
pendingVoiceGenerationByLineId: {
'line-9': {
submittedUpdatedAt: '2026-03-07T12:00:00.000Z',
startedAt: '2026-03-07T12:24:10.000Z',
taskId: 'task-failed-1',
taskStatus: 'failed',
taskErrorMessage: 'QwenTTS voiceId missing',
},
},
setPendingVoiceGenerationByLineId,
onTaskFailure,
})
const renderEffects = effectCallbacks.splice(0)
renderEffects[1]?.()
expect(onTaskFailure).toHaveBeenCalledWith({
lineId: 'line-9',
line: expect.objectContaining({
id: 'line-9',
lineIndex: 9,
}),
taskId: 'task-failed-1',
errorMessage: 'QwenTTS voiceId missing',
})
})
})

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
useStateMock,
useRefMock,
useCallbackMock,
useEffectMock,
mutateAsyncMock,
} = vi.hoisted(() => ({
useStateMock: vi.fn(),
useRefMock: vi.fn((value: unknown) => ({ current: value })),
useCallbackMock: vi.fn((fn: unknown) => fn),
useEffectMock: vi.fn(),
mutateAsyncMock: vi.fn(),
}))
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useState: useStateMock,
useRef: useRefMock,
useCallback: useCallbackMock,
useEffect: useEffectMock,
}
})
vi.mock('@/lib/query/hooks', () => ({
useFetchProjectVoiceStageData: () => ({
mutateAsync: mutateAsyncMock,
}),
}))
import { useVoiceStageDataLoader } from '@/lib/novel-promotion/stages/voice-stage-runtime/useVoiceStageDataLoader'
describe('useVoiceStageDataLoader', () => {
beforeEach(() => {
useStateMock.mockReset()
useRefMock.mockClear()
useCallbackMock.mockClear()
useEffectMock.mockClear()
mutateAsyncMock.mockReset()
})
it('keeps background reloads from re-entering blocking loading state', async () => {
const setVoiceLines = vi.fn()
const setSpeakerVoices = vi.fn()
const setProjectSpeakers = vi.fn()
const setLoading = vi.fn()
useStateMock
.mockImplementationOnce(() => [[], setVoiceLines])
.mockImplementationOnce(() => [{}, setSpeakerVoices])
.mockImplementationOnce(() => [[], setProjectSpeakers])
.mockImplementationOnce(() => [true, setLoading])
mutateAsyncMock
.mockResolvedValueOnce({
voiceLines: [{ id: 'line-1' }],
speakerVoices: { Narrator: { voiceType: 'uploaded', voiceId: 'voice-1' } },
speakers: ['Narrator'],
})
.mockResolvedValueOnce({
voiceLines: [{ id: 'line-1' }],
speakerVoices: { Narrator: { voiceType: 'uploaded', voiceId: 'voice-2' } },
speakers: ['Narrator'],
})
const hook = useVoiceStageDataLoader({
projectId: 'project-1',
episodeId: 'episode-1',
})
await hook.loadData()
await hook.loadData()
expect(
setLoading.mock.calls.filter(([value]) => value === true),
).toHaveLength(1)
expect(
setLoading.mock.calls.filter(([value]) => value === false),
).toHaveLength(2)
expect(setVoiceLines).toHaveBeenNthCalledWith(1, [{ id: 'line-1' }])
expect(setVoiceLines).toHaveBeenNthCalledWith(2, [{ id: 'line-1' }])
expect(mutateAsyncMock).toHaveBeenNthCalledWith(1, { episodeId: 'episode-1' })
expect(mutateAsyncMock).toHaveBeenNthCalledWith(2, { episodeId: 'episode-1' })
})
})

View File

@@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
generateVideoMutateAsyncMock,
batchGenerateVideosMutateAsyncMock,
updateProjectPanelVideoPromptMutateAsyncMock,
updateProjectClipMutateAsyncMock,
updateProjectConfigMutateAsyncMock,
} = vi.hoisted(() => ({
generateVideoMutateAsyncMock: vi.fn(),
batchGenerateVideosMutateAsyncMock: vi.fn(),
updateProjectPanelVideoPromptMutateAsyncMock: vi.fn(),
updateProjectClipMutateAsyncMock: vi.fn(),
updateProjectConfigMutateAsyncMock: vi.fn(),
}))
vi.mock('@/lib/query/hooks/useStoryboards', () => ({
useGenerateVideo: () => ({
mutateAsync: generateVideoMutateAsyncMock,
}),
useBatchGenerateVideos: () => ({
mutateAsync: batchGenerateVideosMutateAsyncMock,
}),
}))
vi.mock('@/lib/query/hooks', () => ({
useUpdateProjectPanelVideoPrompt: () => ({
mutateAsync: updateProjectPanelVideoPromptMutateAsyncMock,
}),
useUpdateProjectClip: () => ({
mutateAsync: updateProjectClipMutateAsyncMock,
}),
useUpdateProjectConfig: () => ({
mutateAsync: updateProjectConfigMutateAsyncMock,
}),
}))
import { useWorkspaceVideoActions } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useWorkspaceVideoActions'
describe('useWorkspaceVideoActions', () => {
const originalAlert = globalThis.alert
beforeEach(() => {
generateVideoMutateAsyncMock.mockReset()
batchGenerateVideosMutateAsyncMock.mockReset()
updateProjectPanelVideoPromptMutateAsyncMock.mockReset()
updateProjectClipMutateAsyncMock.mockReset()
updateProjectConfigMutateAsyncMock.mockReset()
globalThis.alert = vi.fn()
})
afterEach(() => {
globalThis.alert = originalAlert
})
it('single video mutation fails -> rethrows error for immediate lock cleanup', async () => {
generateVideoMutateAsyncMock.mockRejectedValueOnce(new Error('video submit failed'))
const actions = useWorkspaceVideoActions({
projectId: 'project-1',
episodeId: 'episode-1',
t: (key: string) => key,
})
await expect(
actions.handleGenerateVideo('storyboard-1', 0, 'veo-3.1'),
).rejects.toThrow('video submit failed')
expect(globalThis.alert).toHaveBeenCalledWith('execution.generationFailed: video submit failed')
})
})

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
createAIDataModalDraftState,
mergeAIDataModalDraftStateByDirty,
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/useAIDataModalState'
describe('useAIDataModalState optimistic sync helpers', () => {
it('keeps dirty fields when server data refreshes', () => {
const localDraft = createAIDataModalDraftState({
initialShotType: 'Close-up',
initialCameraMove: 'Push in',
initialDescription: 'user typing draft',
initialVideoPrompt: 'prompt-a',
initialPhotographyRules: null,
initialActingNotes: null,
})
const serverDraft = createAIDataModalDraftState({
initialShotType: 'Wide',
initialCameraMove: 'Pan left',
initialDescription: 'server-updated-desc',
initialVideoPrompt: 'prompt-b',
initialPhotographyRules: null,
initialActingNotes: null,
})
const merged = mergeAIDataModalDraftStateByDirty(
localDraft,
serverDraft,
new Set(['description']),
)
expect(merged.description).toBe('user typing draft')
expect(merged.shotType).toBe('Wide')
expect(merged.cameraMove).toBe('Pan left')
expect(merged.videoPrompt).toBe('prompt-b')
})
it('syncs non-dirty nested fields from server', () => {
const localDraft = createAIDataModalDraftState({
initialShotType: 'A',
initialCameraMove: 'B',
initialDescription: 'C',
initialVideoPrompt: 'D',
initialPhotographyRules: {
scene_summary: 'local scene',
lighting: {
direction: 'front',
quality: 'soft',
},
characters: [{
name: 'hero',
screen_position: 'left',
posture: 'standing',
facing: 'camera',
}],
depth_of_field: 'deep',
color_tone: 'warm',
},
initialActingNotes: [{
name: 'hero',
acting: 'smile',
}],
})
const serverDraft = createAIDataModalDraftState({
initialShotType: 'A2',
initialCameraMove: 'B2',
initialDescription: 'C2',
initialVideoPrompt: 'D2',
initialPhotographyRules: {
scene_summary: 'server scene',
lighting: {
direction: 'back',
quality: 'hard',
},
characters: [{
name: 'hero',
screen_position: 'center',
posture: 'running',
facing: 'right',
}],
depth_of_field: 'shallow',
color_tone: 'cool',
},
initialActingNotes: [{
name: 'hero',
acting: 'angry',
}],
})
const merged = mergeAIDataModalDraftStateByDirty(
localDraft,
serverDraft,
new Set(['videoPrompt']),
)
expect(merged.videoPrompt).toBe('D')
expect(merged.photographyRules?.scene_summary).toBe('server scene')
expect(merged.photographyRules?.lighting.direction).toBe('back')
expect(merged.actingNotes[0]?.acting).toBe('angry')
})
})

View File

@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalCharacter, GlobalLocation } from '@/lib/query/hooks/useGlobalAssets'
import { queryKeys } from '@/lib/query/keys'
import { MockQueryClient } from '../../helpers/mock-query-client'
let queryClient = new MockQueryClient()
const useQueryClientMock = vi.fn(() => queryClient)
const useMutationMock = vi.fn((options: unknown) => options)
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useRef: <T,>(value: T) => ({ current: value }),
}
})
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => useQueryClientMock(),
useMutation: (options: unknown) => useMutationMock(options),
}))
vi.mock('@/lib/query/mutations/mutation-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(
'@/lib/query/mutations/mutation-shared',
)
return {
...actual,
requestJsonWithError: vi.fn(),
requestVoidWithError: vi.fn(),
}
})
vi.mock('@/lib/query/mutations/asset-hub-mutations-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/query/mutations/asset-hub-mutations-shared')>(
'@/lib/query/mutations/asset-hub-mutations-shared',
)
return {
...actual,
invalidateGlobalCharacters: vi.fn(),
invalidateGlobalLocations: vi.fn(),
}
})
import {
useSelectCharacterImage,
} from '@/lib/query/mutations/asset-hub-character-mutations'
import { useDeleteLocation as useDeleteAssetHubLocation } from '@/lib/query/mutations/asset-hub-location-mutations'
interface SelectCharacterMutation {
onMutate: (variables: {
characterId: string
appearanceIndex: number
imageIndex: number | null
}) => Promise<unknown>
onError: (error: unknown, variables: unknown, context: unknown) => void
}
interface DeleteLocationMutation {
onMutate: (locationId: string) => Promise<unknown>
onError: (error: unknown, locationId: string, context: unknown) => void
}
function buildGlobalCharacter(selectedIndex: number | null): GlobalCharacter {
return {
id: 'character-1',
name: 'Hero',
folderId: 'folder-1',
customVoiceUrl: null,
appearances: [{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: 'default',
artStyle: 'realistic',
description: null,
descriptionSource: null,
imageUrl: selectedIndex === null ? null : `img-${selectedIndex}`,
imageUrls: ['img-0', 'img-1', 'img-2'],
selectedIndex,
previousImageUrl: null,
previousImageUrls: [],
imageTaskRunning: false,
}],
}
}
function buildGlobalLocation(id: string): GlobalLocation {
return {
id,
name: `Location ${id}`,
summary: null,
folderId: 'folder-1',
artStyle: 'realistic',
images: [{
id: `${id}-img-0`,
imageIndex: 0,
description: null,
imageUrl: null,
previousImageUrl: null,
isSelected: true,
imageTaskRunning: false,
}],
}
}
describe('asset hub optimistic mutations', () => {
beforeEach(() => {
queryClient = new MockQueryClient()
useQueryClientMock.mockClear()
useMutationMock.mockClear()
})
it('updates all character query caches optimistically and ignores stale rollback', async () => {
const allCharactersKey = queryKeys.globalAssets.characters()
const folderCharactersKey = queryKeys.globalAssets.characters('folder-1')
queryClient.seedQuery(allCharactersKey, [buildGlobalCharacter(0)])
queryClient.seedQuery(folderCharactersKey, [buildGlobalCharacter(0)])
const mutation = useSelectCharacterImage() as unknown as SelectCharacterMutation
const firstVariables = {
characterId: 'character-1',
appearanceIndex: 0,
imageIndex: 1,
}
const secondVariables = {
characterId: 'character-1',
appearanceIndex: 0,
imageIndex: 2,
}
const firstContext = await mutation.onMutate(firstVariables)
const afterFirstAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
const afterFirstFolder = queryClient.getQueryData<GlobalCharacter[]>(folderCharactersKey)
expect(afterFirstAll?.[0]?.appearances[0]?.selectedIndex).toBe(1)
expect(afterFirstFolder?.[0]?.appearances[0]?.selectedIndex).toBe(1)
const secondContext = await mutation.onMutate(secondVariables)
const afterSecondAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterSecondAll?.[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('first failed'), firstVariables, firstContext)
const afterStaleError = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterStaleError?.[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('second failed'), secondVariables, secondContext)
const afterLatestRollback = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterLatestRollback?.[0]?.appearances[0]?.selectedIndex).toBe(1)
})
it('optimistically removes location and restores on error', async () => {
const allLocationsKey = queryKeys.globalAssets.locations()
const folderLocationsKey = queryKeys.globalAssets.locations('folder-1')
queryClient.seedQuery(allLocationsKey, [buildGlobalLocation('loc-1'), buildGlobalLocation('loc-2')])
queryClient.seedQuery(folderLocationsKey, [buildGlobalLocation('loc-1')])
const mutation = useDeleteAssetHubLocation() as unknown as DeleteLocationMutation
const context = await mutation.onMutate('loc-1')
const afterDeleteAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)
const afterDeleteFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)
expect(afterDeleteAll?.map((item) => item.id)).toEqual(['loc-2'])
expect(afterDeleteFolder).toEqual([])
mutation.onError(new Error('delete failed'), 'loc-1', context)
const rolledBackAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)
const rolledBackFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)
expect(rolledBackAll?.map((item) => item.id)).toEqual(['loc-1', 'loc-2'])
expect(rolledBackFolder?.map((item) => item.id)).toEqual(['loc-1'])
})
})

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import {
serializeStructuredJsonField,
syncPanelCharacterDependentJson,
} from '@/lib/novel-promotion/panel-ai-data-sync'
describe('panel ai data sync helpers', () => {
it('removes deleted character from acting notes and photography rules', () => {
const synced = syncPanelCharacterDependentJson({
characters: [
{ name: '楚江锴/当朝皇帝', appearance: '初始形象' },
{ name: '燕画乔/魏画乔', appearance: '初始形象' },
],
removeIndex: 0,
actingNotesJson: JSON.stringify([
{ name: '楚江锴/当朝皇帝', acting: '紧握手腕' },
{ name: '燕画乔/魏画乔', acting: '本能后退' },
]),
photographyRulesJson: JSON.stringify({
lighting: {
direction: '侧逆光',
quality: '硬光',
},
characters: [
{ name: '楚江锴/当朝皇帝', screen_position: 'left' },
{ name: '燕画乔/魏画乔', screen_position: 'right' },
],
}),
})
expect(synced.characters).toEqual([{ name: '燕画乔/魏画乔', appearance: '初始形象' }])
expect(JSON.parse(synced.actingNotesJson || 'null')).toEqual([
{ name: '燕画乔/魏画乔', acting: '本能后退' },
])
expect(JSON.parse(synced.photographyRulesJson || 'null')).toEqual({
lighting: {
direction: '侧逆光',
quality: '硬光',
},
characters: [
{ name: '燕画乔/魏画乔', screen_position: 'right' },
],
})
})
it('keeps notes by character name when another appearance of same name remains', () => {
const synced = syncPanelCharacterDependentJson({
characters: [
{ name: '顾娘子/顾盼之', appearance: '素衣' },
{ name: '顾娘子/顾盼之', appearance: '华服' },
],
removeIndex: 1,
actingNotesJson: JSON.stringify([
{ name: '顾娘子/顾盼之', acting: '抬眼看向窗外' },
]),
photographyRulesJson: JSON.stringify({
characters: [
{ name: '顾娘子/顾盼之', screen_position: 'center' },
],
}),
})
expect(JSON.parse(synced.actingNotesJson || 'null')).toEqual([
{ name: '顾娘子/顾盼之', acting: '抬眼看向窗外' },
])
expect(JSON.parse(synced.photographyRulesJson || 'null')).toEqual({
characters: [
{ name: '顾娘子/顾盼之', screen_position: 'center' },
],
})
})
it('supports double-serialized JSON string inputs', () => {
const actingNotes = JSON.stringify([{ name: '甲', acting: '动作' }])
const doubleSerialized = JSON.stringify(actingNotes)
expect(serializeStructuredJsonField(doubleSerialized, 'actingNotes')).toBe(actingNotes)
})
it('throws on malformed acting notes to avoid silent fallback', () => {
expect(() => syncPanelCharacterDependentJson({
characters: [{ name: '甲', appearance: '初始形象' }],
removeIndex: 0,
actingNotesJson: '[{"name":"甲","acting":"动作"}, {"acting":"缺少名字"}]',
photographyRulesJson: null,
})).toThrowError('actingNotes item.name must be a non-empty string')
})
})

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import type { PanelEditData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/PanelEditForm'
import {
PanelSaveCoordinator,
type PanelSaveState,
} from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/hooks/panel-save-coordinator'
function buildSnapshot(description: string): PanelEditData {
return {
id: 'panel-1',
panelIndex: 0,
panelNumber: 1,
shotType: 'close-up',
cameraMove: 'push',
description,
location: null,
characters: [],
srtStart: null,
srtEnd: null,
duration: null,
videoPrompt: null,
}
}
describe('PanelSaveCoordinator', () => {
it('keeps single-flight and only flushes the latest snapshot after burst edits', async () => {
const savedDescriptions: string[] = []
let releaseFirstAttempt: () => void = () => {}
const firstAttemptGate = new Promise<void>((resolve) => {
releaseFirstAttempt = () => resolve()
})
let attempts = 0
const coordinator = new PanelSaveCoordinator({
onSavingChange: () => {},
onStateChange: () => {},
runSave: async ({ snapshot }) => {
attempts += 1
if (attempts === 1) {
await firstAttemptGate
}
savedDescriptions.push(snapshot.description ?? '')
},
resolveErrorMessage: () => 'save failed',
})
const firstRun = coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v1'))
coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v2'))
coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('v3'))
releaseFirstAttempt()
await firstRun
expect(savedDescriptions).toEqual(['v1', 'v3'])
})
it('marks error on failure and clears unsaved state after retry success', async () => {
const stateByPanel = new Map<string, PanelSaveState>()
let attemptCount = 0
const coordinator = new PanelSaveCoordinator({
onSavingChange: () => {},
onStateChange: (panelId, state) => {
stateByPanel.set(panelId, state)
},
runSave: async () => {
attemptCount += 1
if (attemptCount === 1) {
throw new Error('network timeout')
}
},
resolveErrorMessage: (error) => (error instanceof Error ? error.message : 'unknown'),
})
const firstRun = coordinator.queue('panel-1', 'storyboard-1', buildSnapshot('draft text'))
await firstRun
expect(stateByPanel.get('panel-1')).toEqual({
status: 'error',
errorMessage: 'network timeout',
})
const retryRun = coordinator.retry('panel-1', buildSnapshot('draft text'))
await retryRun
expect(stateByPanel.get('panel-1')).toEqual({
status: 'idle',
errorMessage: null,
})
})
})

View File

@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Character, Location, Project } from '@/types/project'
import type { ProjectAssetsData } from '@/lib/query/hooks/useProjectAssets'
import { queryKeys } from '@/lib/query/keys'
import { MockQueryClient } from '../../helpers/mock-query-client'
let queryClient = new MockQueryClient()
const useQueryClientMock = vi.fn(() => queryClient)
const useMutationMock = vi.fn((options: unknown) => options)
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useRef: <T,>(value: T) => ({ current: value }),
}
})
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => useQueryClientMock(),
useMutation: (options: unknown) => useMutationMock(options),
}))
vi.mock('@/lib/query/mutations/mutation-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/query/mutations/mutation-shared')>(
'@/lib/query/mutations/mutation-shared',
)
return {
...actual,
requestJsonWithError: vi.fn(),
requestVoidWithError: vi.fn(),
invalidateQueryTemplates: vi.fn(),
}
})
import {
useDeleteProjectCharacter,
useSelectProjectCharacterImage,
} from '@/lib/query/mutations/character-base-mutations'
interface SelectProjectCharacterMutation {
onMutate: (variables: {
characterId: string
appearanceId: string
imageIndex: number | null
}) => Promise<unknown>
onError: (error: unknown, variables: unknown, context: unknown) => void
}
interface DeleteProjectCharacterMutation {
onMutate: (characterId: string) => Promise<unknown>
onError: (error: unknown, characterId: string, context: unknown) => void
}
function buildCharacter(selectedIndex: number | null): Character {
return {
id: 'character-1',
name: 'Hero',
appearances: [{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: 'default',
description: null,
descriptions: null,
imageUrl: selectedIndex === null ? null : `img-${selectedIndex}`,
imageUrls: ['img-0', 'img-1', 'img-2'],
previousImageUrl: null,
previousImageUrls: [],
previousDescription: null,
previousDescriptions: null,
selectedIndex,
}],
}
}
function buildAssets(selectedIndex: number | null): ProjectAssetsData {
return {
characters: [buildCharacter(selectedIndex)],
locations: [] as Location[],
}
}
function buildProject(selectedIndex: number | null): Project {
return {
novelPromotionData: {
characters: [buildCharacter(selectedIndex)],
locations: [],
},
} as unknown as Project
}
describe('project asset optimistic mutations', () => {
beforeEach(() => {
queryClient = new MockQueryClient()
useQueryClientMock.mockClear()
useMutationMock.mockClear()
})
it('optimistically selects project character image and ignores stale rollback', async () => {
const projectId = 'project-1'
const assetsKey = queryKeys.projectAssets.all(projectId)
const projectKey = queryKeys.projectData(projectId)
queryClient.seedQuery(assetsKey, buildAssets(0))
queryClient.seedQuery(projectKey, buildProject(0))
const mutation = useSelectProjectCharacterImage(projectId) as unknown as SelectProjectCharacterMutation
const firstVariables = {
characterId: 'character-1',
appearanceId: 'appearance-1',
imageIndex: 1,
}
const secondVariables = {
characterId: 'character-1',
appearanceId: 'appearance-1',
imageIndex: 2,
}
const firstContext = await mutation.onMutate(firstVariables)
const afterFirst = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterFirst?.characters[0]?.appearances[0]?.selectedIndex).toBe(1)
const secondContext = await mutation.onMutate(secondVariables)
const afterSecond = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterSecond?.characters[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('first failed'), firstVariables, firstContext)
const afterStaleError = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterStaleError?.characters[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('second failed'), secondVariables, secondContext)
const afterLatestRollback = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterLatestRollback?.characters[0]?.appearances[0]?.selectedIndex).toBe(1)
})
it('optimistically deletes project character and restores on error', async () => {
const projectId = 'project-1'
const assetsKey = queryKeys.projectAssets.all(projectId)
const projectKey = queryKeys.projectData(projectId)
queryClient.seedQuery(assetsKey, buildAssets(0))
queryClient.seedQuery(projectKey, buildProject(0))
const mutation = useDeleteProjectCharacter(projectId) as unknown as DeleteProjectCharacterMutation
const context = await mutation.onMutate('character-1')
const afterDeleteAssets = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(afterDeleteAssets?.characters).toHaveLength(0)
const afterDeleteProject = queryClient.getQueryData<Project>(projectKey)
expect(afterDeleteProject?.novelPromotionData?.characters ?? []).toHaveLength(0)
mutation.onError(new Error('delete failed'), 'character-1', context)
const rolledBackAssets = queryClient.getQueryData<ProjectAssetsData>(assetsKey)
expect(rolledBackAssets?.characters).toHaveLength(1)
expect(rolledBackAssets?.characters[0]?.id).toBe('character-1')
})
})

View File

@@ -0,0 +1,167 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKeys } from '@/lib/query/keys'
import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE } from '@/lib/task/types'
type InvalidateArg = { queryKey?: readonly unknown[]; exact?: boolean }
type EffectCleanup = (() => void) | void | null
const runtime = vi.hoisted(() => ({
queryClient: {
invalidateQueries: vi.fn(async (_arg?: InvalidateArg) => undefined),
},
effectCleanup: null as EffectCleanup,
scheduledTimers: [] as Array<() => void>,
}))
const overlayMock = vi.hoisted(() => ({
applyTaskLifecycleToOverlay: vi.fn(),
}))
class FakeEventSource {
static OPEN = 1
static instances: FakeEventSource[] = []
readonly url: string
readyState = FakeEventSource.OPEN
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((event: Event) => void) | null = null
private listeners = new Map<string, Set<EventListener>>()
constructor(url: string) {
this.url = url
FakeEventSource.instances.push(this)
}
addEventListener(type: string, handler: EventListener) {
const set = this.listeners.get(type) || new Set<EventListener>()
set.add(handler)
this.listeners.set(type, set)
}
removeEventListener(type: string, handler: EventListener) {
const set = this.listeners.get(type)
if (!set) return
set.delete(handler)
}
emit(type: string, payload: unknown) {
const event = { data: JSON.stringify(payload) } as MessageEvent
if (this.onmessage) this.onmessage(event)
const set = this.listeners.get(type)
if (!set) return
for (const handler of set) {
handler(event as unknown as Event)
}
}
close() {
this.readyState = 2
}
}
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useMemo: <T,>(factory: () => T) => factory(),
useRef: <T,>(value: T) => ({ current: value }),
useEffect: (effect: () => EffectCleanup) => {
runtime.effectCleanup = effect()
},
}
})
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => runtime.queryClient,
}))
vi.mock('@/lib/query/task-target-overlay', () => overlayMock)
function hasInvalidation(predicate: (arg: InvalidateArg) => boolean) {
return runtime.queryClient.invalidateQueries.mock.calls.some((call) => {
const arg = (call[0] || {}) as InvalidateArg
return predicate(arg)
})
}
describe('sse invalidation behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
runtime.effectCleanup = null
runtime.scheduledTimers = []
FakeEventSource.instances = []
;(globalThis as unknown as { EventSource: typeof FakeEventSource }).EventSource = FakeEventSource
;(globalThis as unknown as { window: { setTimeout: typeof setTimeout; clearTimeout: typeof clearTimeout } }).window = {
setTimeout: ((cb: () => void) => {
runtime.scheduledTimers.push(cb)
return runtime.scheduledTimers.length as unknown as ReturnType<typeof setTimeout>
}) as unknown as typeof setTimeout,
clearTimeout: (() => undefined) as unknown as typeof clearTimeout,
}
})
it('PROCESSING(progress 数值) 不触发 target-state invalidationCOMPLETED 触发', async () => {
const { useSSE } = await import('@/lib/query/hooks/useSSE')
useSSE({
projectId: 'project-1',
episodeId: 'episode-1',
enabled: true,
})
const source = FakeEventSource.instances[0]
expect(source).toBeTruthy()
source.emit(TASK_SSE_EVENT_TYPE.LIFECYCLE, {
type: TASK_SSE_EVENT_TYPE.LIFECYCLE,
taskId: 'task-1',
taskType: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
episodeId: 'episode-1',
payload: {
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
progress: 32,
},
})
expect(hasInvalidation((arg) => {
const key = arg.queryKey || []
return Array.isArray(key) && key[0] === 'task-target-states'
})).toBe(false)
source.emit(TASK_SSE_EVENT_TYPE.LIFECYCLE, {
type: TASK_SSE_EVENT_TYPE.LIFECYCLE,
taskId: 'task-1',
taskType: 'IMAGE_CHARACTER',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
episodeId: 'episode-1',
payload: {
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
},
})
for (const cb of runtime.scheduledTimers) cb()
expect(hasInvalidation((arg) => {
const key = arg.queryKey || []
return Array.isArray(key)
&& key[0] === queryKeys.tasks.targetStatesAll('project-1')[0]
&& key[1] === 'project-1'
&& arg.exact === false
})).toBe(true)
expect(overlayMock.applyTaskLifecycleToOverlay).toHaveBeenCalledWith(
runtime.queryClient,
expect.objectContaining({
projectId: 'project-1',
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
}),
)
})
})

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { QueryClient } from '@tanstack/react-query'
import {
applyTaskLifecycleToOverlay,
upsertTaskTargetOverlay,
type TaskTargetOverlayMap,
} from '@/lib/query/task-target-overlay'
import { queryKeys } from '@/lib/query/keys'
import { TASK_EVENT_TYPE } from '@/lib/task/types'
function getOverlay(
queryClient: QueryClient,
projectId: string,
key: string,
) {
const map = queryClient.getQueryData<TaskTargetOverlayMap>(
queryKeys.tasks.targetStateOverlay(projectId),
) || {}
return map[key] || null
}
describe('task-target-overlay', () => {
it('creates optimistic runningTaskId when onMutate omits it', () => {
const queryClient = new QueryClient()
const projectId = 'project-1'
const key = 'NovelPromotionPanel:panel-1'
upsertTaskTargetOverlay(queryClient, {
projectId,
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
runningTaskType: 'video_panel',
intent: 'generate',
})
const overlay = getOverlay(queryClient, projectId, key)
expect(overlay?.runningTaskId).toMatch(/^optimistic:NovelPromotionPanel:panel-1:/)
})
it('does not clear overlay on completed event from a different taskId', () => {
const queryClient = new QueryClient()
const projectId = 'project-1'
const key = 'NovelPromotionPanel:panel-2'
upsertTaskTargetOverlay(queryClient, {
projectId,
targetType: 'NovelPromotionPanel',
targetId: 'panel-2',
runningTaskId: 'task-new',
runningTaskType: 'video_panel',
intent: 'generate',
})
applyTaskLifecycleToOverlay(queryClient, {
projectId,
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
targetType: 'NovelPromotionPanel',
targetId: 'panel-2',
taskId: 'task-old',
taskType: 'video_panel',
intent: 'generate',
hasOutputAtStart: null,
progress: null,
stage: null,
stageLabel: null,
eventTs: new Date().toISOString(),
})
const overlay = getOverlay(queryClient, projectId, key)
expect(overlay?.runningTaskId).toBe('task-new')
})
it('clears overlay on completed event from the same taskId', () => {
const queryClient = new QueryClient()
const projectId = 'project-1'
const key = 'NovelPromotionPanel:panel-3'
upsertTaskTargetOverlay(queryClient, {
projectId,
targetType: 'NovelPromotionPanel',
targetId: 'panel-3',
runningTaskId: 'task-3',
runningTaskType: 'video_panel',
intent: 'generate',
})
applyTaskLifecycleToOverlay(queryClient, {
projectId,
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
targetType: 'NovelPromotionPanel',
targetId: 'panel-3',
taskId: 'task-3',
taskType: 'video_panel',
intent: 'generate',
hasOutputAtStart: null,
progress: null,
stage: null,
stageLabel: null,
eventTs: new Date().toISOString(),
})
const overlay = getOverlay(queryClient, projectId, key)
expect(overlay).toBeNull()
})
})

View File

@@ -0,0 +1,286 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TaskTargetState } from '@/lib/query/hooks/useTaskTargetStateMap'
const runtime = vi.hoisted(() => ({
useQueryCalls: [] as Array<Record<string, unknown>>,
apiStates: [] as TaskTargetState[],
overlayStates: {} as Record<string, {
targetType: string
targetId: string
phase: 'queued' | 'processing'
runningTaskId: string | null
runningTaskType: string | null
intent: 'generate' | 'process' | 'regenerate'
hasOutputAtStart: boolean | null
progress: number | null
stage: string | null
stageLabel: string | null
updatedAt: string | null
lastError: null
expiresAt: number
}>,
}))
const overlayNow = new Date().toISOString()
vi.mock('react', async () => {
const actual = await vi.importActual<typeof import('react')>('react')
return {
...actual,
useMemo: <T,>(factory: () => T) => factory(),
}
})
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: Record<string, unknown>) => {
runtime.useQueryCalls.push(options)
const queryKey = (options.queryKey || []) as unknown[]
const first = queryKey[0]
if (first === 'task-target-states-overlay') {
return {
data: runtime.overlayStates,
}
}
return {
data: runtime.apiStates,
}
},
}))
describe('task target state map behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
runtime.useQueryCalls = []
runtime.apiStates = [
{
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
phase: 'idle',
runningTaskId: null,
runningTaskType: null,
intent: 'process',
hasOutputAtStart: null,
progress: null,
stage: null,
stageLabel: null,
lastError: null,
updatedAt: null,
},
{
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
phase: 'processing',
runningTaskId: 'task-api-panel',
runningTaskType: 'IMAGE_PANEL',
intent: 'process',
hasOutputAtStart: null,
progress: 10,
stage: 'api',
stageLabel: 'API处理中',
lastError: null,
updatedAt: overlayNow,
},
]
runtime.overlayStates = {
'CharacterAppearance:appearance-1': {
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
phase: 'processing',
runningTaskId: 'task-ov-1',
runningTaskType: 'IMAGE_CHARACTER',
intent: 'process',
hasOutputAtStart: false,
progress: 50,
stage: 'generate',
stageLabel: '生成中',
updatedAt: overlayNow,
lastError: null,
expiresAt: Date.now() + 30_000,
},
'NovelPromotionPanel:panel-1': {
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
phase: 'queued',
runningTaskId: 'task-ov-2',
runningTaskType: 'LIP_SYNC',
intent: 'process',
hasOutputAtStart: null,
progress: null,
stage: null,
stageLabel: null,
updatedAt: overlayNow,
lastError: null,
expiresAt: Date.now() + 30_000,
},
}
})
it('enables polling while queued/processing and merges overlay only when rules match', async () => {
const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')
const result = useTaskTargetStateMap('project-1', [
{ targetType: 'CharacterAppearance', targetId: 'appearance-1', types: ['IMAGE_CHARACTER'] },
{ targetType: 'NovelPromotionPanel', targetId: 'panel-1', types: ['IMAGE_PANEL'] },
])
const firstCall = runtime.useQueryCalls[0]
expect(typeof firstCall?.refetchInterval).toBe('function')
const interval = (firstCall?.refetchInterval as ((state: { state: { data?: TaskTargetState[] } }) => number | false))({
state: { data: runtime.apiStates },
})
expect(interval).toBe(2000)
const appearance = result.getState('CharacterAppearance', 'appearance-1')
expect(appearance?.phase).toBe('processing')
expect(appearance?.runningTaskType).toBe('IMAGE_CHARACTER')
expect(appearance?.runningTaskId).toBe('task-ov-1')
const panel = result.getState('NovelPromotionPanel', 'panel-1')
expect(panel?.phase).toBe('processing')
expect(panel?.runningTaskType).toBe('IMAGE_PANEL')
expect(panel?.runningTaskId).toBe('task-api-panel')
})
it('allows newer overlay to override completed state for immediate rerun feedback', async () => {
runtime.apiStates = [
{
targetType: 'NovelPromotionPanel',
targetId: 'panel-2',
phase: 'completed',
runningTaskId: null,
runningTaskType: null,
intent: 'generate',
hasOutputAtStart: true,
progress: 100,
stage: null,
stageLabel: null,
lastError: null,
updatedAt: '2026-02-27T00:00:00.000Z',
},
]
runtime.overlayStates = {
'NovelPromotionPanel:panel-2': {
targetType: 'NovelPromotionPanel',
targetId: 'panel-2',
phase: 'queued',
runningTaskId: 'task-overlay-new',
runningTaskType: 'VIDEO_PANEL',
intent: 'generate',
hasOutputAtStart: true,
progress: null,
stage: null,
stageLabel: null,
updatedAt: '2026-02-27T00:00:01.000Z',
lastError: null,
expiresAt: Date.now() + 30_000,
},
}
const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')
const result = useTaskTargetStateMap('project-1', [
{ targetType: 'NovelPromotionPanel', targetId: 'panel-2', types: ['VIDEO_PANEL'] },
])
const state = result.getState('NovelPromotionPanel', 'panel-2')
expect(state?.phase).toBe('queued')
expect(state?.runningTaskId).toBe('task-overlay-new')
expect(state?.runningTaskType).toBe('VIDEO_PANEL')
})
it('allows active overlay to override completed state even with timestamp skew', async () => {
runtime.apiStates = [
{
targetType: 'NovelPromotionPanel',
targetId: 'panel-3',
phase: 'completed',
runningTaskId: null,
runningTaskType: null,
intent: 'generate',
hasOutputAtStart: true,
progress: 100,
stage: null,
stageLabel: null,
lastError: null,
updatedAt: '2026-02-27T00:00:05.000Z',
},
]
runtime.overlayStates = {
'NovelPromotionPanel:panel-3': {
targetType: 'NovelPromotionPanel',
targetId: 'panel-3',
phase: 'queued',
runningTaskId: 'task-overlay-old',
runningTaskType: 'VIDEO_PANEL',
intent: 'generate',
hasOutputAtStart: true,
progress: null,
stage: null,
stageLabel: null,
updatedAt: '2026-02-27T00:00:01.000Z',
lastError: null,
expiresAt: Date.now() + 30_000,
},
}
const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')
const result = useTaskTargetStateMap('project-1', [
{ targetType: 'NovelPromotionPanel', targetId: 'panel-3', types: ['VIDEO_PANEL'] },
])
const state = result.getState('NovelPromotionPanel', 'panel-3')
expect(state?.phase).toBe('queued')
expect(state?.runningTaskId).toBe('task-overlay-old')
expect(state?.runningTaskType).toBe('VIDEO_PANEL')
})
it('matches task type whitelist case-insensitively', async () => {
runtime.apiStates = [
{
targetType: 'NovelPromotionPanel',
targetId: 'panel-4',
phase: 'idle',
runningTaskId: null,
runningTaskType: null,
intent: 'generate',
hasOutputAtStart: null,
progress: null,
stage: null,
stageLabel: null,
lastError: null,
updatedAt: null,
},
]
runtime.overlayStates = {
'NovelPromotionPanel:panel-4': {
targetType: 'NovelPromotionPanel',
targetId: 'panel-4',
phase: 'processing',
runningTaskId: 'task-overlay-upper',
runningTaskType: 'VIDEO_PANEL',
intent: 'generate',
hasOutputAtStart: false,
progress: 15,
stage: 'generate_panel_video',
stageLabel: '生成中',
updatedAt: '2026-02-27T00:00:10.000Z',
lastError: null,
expiresAt: Date.now() + 30_000,
},
}
const { useTaskTargetStateMap } = await import('@/lib/query/hooks/useTaskTargetStateMap')
const result = useTaskTargetStateMap('project-1', [
{ targetType: 'NovelPromotionPanel', targetId: 'panel-4', types: ['video_panel'] },
])
const state = result.getState('NovelPromotionPanel', 'panel-4')
expect(state?.phase).toBe('processing')
expect(state?.runningTaskType).toBe('VIDEO_PANEL')
expect(state?.runningTaskId).toBe('task-overlay-upper')
})
})

View File

@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const createChatCompletionMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'chatcmpl_bailian',
object: 'chat.completion',
created: 1,
model: 'qwen3.5-plus',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'ok' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
},
})),
)
const openAiCtorMock = vi.hoisted(() =>
vi.fn(() => ({
chat: {
completions: {
create: createChatCompletionMock,
},
},
})),
)
vi.mock('openai', () => ({
default: openAiCtorMock,
}))
import { completeBailianLlm } from '@/lib/providers/bailian/llm'
describe('bailian llm provider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('calls dashscope openai-compatible endpoint for registered qwen model', async () => {
const completion = await completeBailianLlm({
modelId: 'qwen3.5-plus',
messages: [{ role: 'user', content: 'hello' }],
apiKey: 'bl-key',
temperature: 0.2,
})
expect(openAiCtorMock).toHaveBeenCalledWith({
apiKey: 'bl-key',
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
timeout: 30_000,
})
expect(createChatCompletionMock).toHaveBeenCalledWith({
model: 'qwen3.5-plus',
messages: [{ role: 'user', content: 'hello' }],
temperature: 0.2,
})
expect(completion.choices[0]?.message?.content).toBe('ok')
})
it('fails fast when model is not in official bailian catalog', async () => {
await expect(
completeBailianLlm({
modelId: 'qwen-plus',
messages: [{ role: 'user', content: 'hello' }],
apiKey: 'bl-key',
}),
).rejects.toThrow(/MODEL_NOT_REGISTERED/)
expect(openAiCtorMock).not.toHaveBeenCalled()
expect(createChatCompletionMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { synthesizeWithBailianTTS } from '@/lib/providers/bailian/tts'
function buildWavBuffer(durationMs: number): Buffer {
const sampleRate = 8000
const channels = 1
const bitsPerSample = 16
const byteRate = sampleRate * channels * (bitsPerSample / 8)
const dataLength = Math.round((durationMs / 1000) * byteRate)
const pcmData = Buffer.alloc(dataLength, 0)
const output = Buffer.alloc(44 + dataLength)
output.write('RIFF', 0, 'ascii')
output.writeUInt32LE(36 + dataLength, 4)
output.write('WAVE', 8, 'ascii')
output.write('fmt ', 12, 'ascii')
output.writeUInt32LE(16, 16)
output.writeUInt16LE(1, 20)
output.writeUInt16LE(channels, 22)
output.writeUInt32LE(sampleRate, 24)
output.writeUInt32LE(byteRate, 28)
output.writeUInt16LE(channels * (bitsPerSample / 8), 32)
output.writeUInt16LE(bitsPerSample, 34)
output.write('data', 36, 'ascii')
output.writeUInt32LE(dataLength, 40)
pcmData.copy(output, 44)
return output
}
describe('bailian tts synthesis', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('synthesizes one segment and returns wav buffer', async () => {
const wav = buildWavBuffer(120)
const fetchMock = vi.fn(async (input: string) => {
if (input.includes('/multimodal-generation/generation')) {
return {
ok: true,
text: async () => JSON.stringify({
output: {
audio: {
url: 'https://audio.example/segment-1.wav',
},
},
usage: { characters: 10 },
request_id: 'req-1',
}),
}
}
if (input === 'https://audio.example/segment-1.wav') {
return {
ok: true,
status: 200,
arrayBuffer: async () => wav.buffer.slice(wav.byteOffset, wav.byteOffset + wav.byteLength),
}
}
throw new Error(`unexpected fetch url: ${input}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await synthesizeWithBailianTTS({
text: '你好,世界',
voiceId: 'voice_1',
}, 'bl-key')
expect(result.success).toBe(true)
expect(result.audioData).toBeDefined()
expect(result.audioDuration).toBe(120)
expect(result.audioUrl).toBe('https://audio.example/segment-1.wav')
expect(result.characters).toBe(10)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('splits text over 600 chars and merges audio segments', async () => {
const wavA = buildWavBuffer(100)
const wavB = buildWavBuffer(200)
let generationCallCount = 0
const fetchMock = vi.fn(async (input: string) => {
if (input.includes('/multimodal-generation/generation')) {
generationCallCount += 1
const audioUrl = generationCallCount === 1
? 'https://audio.example/segment-a.wav'
: 'https://audio.example/segment-b.wav'
return {
ok: true,
text: async () => JSON.stringify({
output: {
audio: { url: audioUrl },
},
usage: { characters: 600 },
request_id: `req-${generationCallCount}`,
}),
}
}
if (input === 'https://audio.example/segment-a.wav') {
return {
ok: true,
status: 200,
arrayBuffer: async () => wavA.buffer.slice(wavA.byteOffset, wavA.byteOffset + wavA.byteLength),
}
}
if (input === 'https://audio.example/segment-b.wav') {
return {
ok: true,
status: 200,
arrayBuffer: async () => wavB.buffer.slice(wavB.byteOffset, wavB.byteOffset + wavB.byteLength),
}
}
throw new Error(`unexpected fetch url: ${input}`)
})
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await synthesizeWithBailianTTS({
text: 'a'.repeat(601),
voiceId: 'voice_2',
}, 'bl-key')
expect(result.success).toBe(true)
expect(result.audioData).toBeDefined()
expect(result.audioDuration).toBe(300)
expect(result.audioUrl).toBeUndefined()
expect(result.characters).toBe(1200)
expect(generationCallCount).toBe(2)
})
it('fails explicitly when voiceId is missing', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await synthesizeWithBailianTTS({
text: 'hello',
voiceId: '',
}, 'bl-key')
expect(result).toEqual({
success: false,
error: 'BAILIAN_TTS_VOICE_ID_REQUIRED',
})
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,179 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'bailian',
apiKey: 'bl-key',
})),
)
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
import { generateBailianVideo } from '@/lib/providers/bailian/video'
describe('bailian video provider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('submits i2v task and returns async externalId', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
request_id: 'req-1',
output: {
task_id: 'task-123',
task_status: 'PENDING',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await generateBailianVideo({
userId: 'user-1',
imageUrl: 'https://example.com/frame.png',
prompt: '让人物向前走',
options: {
provider: 'bailian',
modelId: 'wan2.6-i2v-flash',
modelKey: 'bailian::wan2.6-i2v-flash',
duration: 5,
resolution: '720P',
promptExtend: true,
},
})
expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')
expect(fetchMock).toHaveBeenCalledTimes(1)
const firstCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit] | undefined
expect(firstCall).toBeDefined()
if (!firstCall) {
throw new Error('missing fetch call')
}
const requestUrl = firstCall[0]
const requestInit = firstCall[1]
expect(requestUrl).toBe('https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis')
expect(requestInit.method).toBe('POST')
expect(requestInit.headers).toEqual({
Authorization: 'Bearer bl-key',
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
})
expect(requestInit.body).toBe(JSON.stringify({
model: 'wan2.6-i2v-flash',
input: {
img_url: 'https://example.com/frame.png',
prompt: '让人物向前走',
},
parameters: {
resolution: '720P',
prompt_extend: true,
duration: 5,
},
}))
expect(result).toEqual({
success: true,
async: true,
requestId: 'task-123',
externalId: 'BAILIAN:VIDEO:task-123',
})
})
it('submits kf2v task with first and last frame', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_id: 'task-kf2v-1',
task_status: 'PENDING',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await generateBailianVideo({
userId: 'user-1',
imageUrl: 'https://example.com/first.png',
prompt: '让人物从左走到右',
options: {
provider: 'bailian',
modelId: 'wan2.2-kf2v-flash',
modelKey: 'bailian::wan2.2-kf2v-flash',
lastFrameImageUrl: 'https://example.com/last.png',
duration: 5,
},
})
expect(fetchMock).toHaveBeenCalledTimes(1)
const firstCall = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit] | undefined
expect(firstCall).toBeDefined()
if (!firstCall) {
throw new Error('missing fetch call')
}
const requestUrl = firstCall[0]
const requestInit = firstCall[1]
expect(requestUrl).toBe('https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis')
expect(requestInit.body).toBe(JSON.stringify({
model: 'wan2.2-kf2v-flash',
input: {
first_frame_url: 'https://example.com/first.png',
last_frame_url: 'https://example.com/last.png',
prompt: '让人物从左走到右',
},
parameters: {
duration: 5,
},
}))
expect(result).toEqual({
success: true,
async: true,
requestId: 'task-kf2v-1',
externalId: 'BAILIAN:VIDEO:task-kf2v-1',
})
})
it('fails fast when kf2v model misses last frame', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
await expect(
generateBailianVideo({
userId: 'user-1',
imageUrl: 'https://example.com/first.png',
prompt: 'test',
options: {
provider: 'bailian',
modelId: 'wanx2.1-kf2v-plus',
modelKey: 'bailian::wanx2.1-kf2v-plus',
},
}),
).rejects.toThrow(/BAILIAN_VIDEO_LAST_FRAME_IMAGE_URL_REQUIRED/)
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails fast when options contain unsupported field', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
await expect(
generateBailianVideo({
userId: 'user-1',
imageUrl: 'https://example.com/frame.png',
prompt: 'test',
options: {
provider: 'bailian',
modelId: 'wan2.6-i2v',
modelKey: 'bailian::wan2.6-i2v',
fps: 24,
},
}),
).rejects.toThrow(/BAILIAN_VIDEO_OPTION_UNSUPPORTED: fps/)
expect(fetchMock).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const prismaMock = vi.hoisted(() => ({
novelPromotionProject: {
findUnique: vi.fn(),
},
novelPromotionCharacter: {
findMany: vi.fn(),
},
globalCharacter: {
findMany: vi.fn(),
},
globalVoice: {
findMany: vi.fn(),
},
novelPromotionEpisode: {
findMany: vi.fn(),
},
}))
const getProviderConfigMock = vi.hoisted(() => vi.fn())
const deleteBailianVoiceMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/providers/bailian/voice-manage', () => ({
deleteBailianVoice: deleteBailianVoiceMock,
}))
import {
collectBailianManagedVoiceIds,
collectProjectBailianManagedVoiceIds,
cleanupUnreferencedBailianVoices,
isBailianManagedVoiceBinding,
} from '@/lib/providers/bailian/voice-cleanup'
describe('bailian voice cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.novelPromotionCharacter.findMany.mockResolvedValue([])
prismaMock.globalCharacter.findMany.mockResolvedValue([])
prismaMock.globalVoice.findMany.mockResolvedValue([])
prismaMock.novelPromotionEpisode.findMany.mockResolvedValue([])
getProviderConfigMock.mockResolvedValue({
apiKey: 'bl-key',
})
deleteBailianVoiceMock.mockResolvedValue({ requestId: 'req-1' })
})
it('identifies managed voice bindings by voiceType or id prefix', () => {
expect(isBailianManagedVoiceBinding({ voiceType: 'qwen-designed', voiceId: 'voice-a' })).toBe(true)
expect(isBailianManagedVoiceBinding({ voiceType: 'uploaded', voiceId: 'qwen-tts-vd-voice-b' })).toBe(true)
expect(isBailianManagedVoiceBinding({ voiceType: 'uploaded', voiceId: 'custom-voice-b' })).toBe(false)
})
it('collects and deduplicates managed voice ids', () => {
const voiceIds = collectBailianManagedVoiceIds([
{ voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-1' },
{ voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-1' },
{ voiceType: 'uploaded', voiceId: 'custom-1' },
{ voiceType: null, voiceId: 'qwen-tts-vd-2' },
])
expect(voiceIds).toEqual(['qwen-tts-vd-1', 'qwen-tts-vd-2'])
})
it('deletes only unreferenced managed voices', async () => {
prismaMock.globalVoice.findMany.mockResolvedValue([
{ voiceId: 'qwen-tts-vd-1' },
])
const result = await cleanupUnreferencedBailianVoices({
voiceIds: ['qwen-tts-vd-1', 'qwen-tts-vd-2'],
scope: {
userId: 'user-1',
},
})
expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')
expect(deleteBailianVoiceMock).toHaveBeenCalledTimes(1)
expect(deleteBailianVoiceMock).toHaveBeenCalledWith({
apiKey: 'bl-key',
voiceId: 'qwen-tts-vd-2',
})
expect(result).toEqual({
requestedVoiceIds: ['qwen-tts-vd-1', 'qwen-tts-vd-2'],
skippedReferencedVoiceIds: ['qwen-tts-vd-1'],
deletedVoiceIds: ['qwen-tts-vd-2'],
})
})
it('collects managed voice ids from project characters and speaker voices', async () => {
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
characters: [
{ voiceId: 'qwen-tts-vd-character', voiceType: 'qwen-designed' },
{ voiceId: 'plain-custom', voiceType: 'uploaded' },
],
episodes: [
{
speakerVoices: JSON.stringify({
Narrator: { voiceType: 'qwen-designed', voiceId: 'qwen-tts-vd-inline' },
Guest: { voiceType: 'uploaded', voiceId: 'uploaded-id' },
}),
},
],
})
const voiceIds = await collectProjectBailianManagedVoiceIds('project-1')
expect(voiceIds).toEqual(['qwen-tts-vd-character', 'qwen-tts-vd-inline'])
})
})

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createVoiceDesign } from '@/lib/providers/bailian/voice-design'
describe('bailian voice design', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('uses qwen3-tts-vd-2026-01-26 as target model', async () => {
const fetchMock = vi.fn(async (_input: unknown, _init?: unknown) => ({
ok: true,
json: async () => ({
output: {
voice: 'voice_1',
target_model: 'qwen3-tts-vd-2026-01-26',
preview_audio: {
data: 'base64',
sample_rate: 24000,
response_format: 'wav',
},
},
usage: { count: 1 },
request_id: 'req-1',
}),
text: async () => '',
status: 200,
headers: new Headers(),
redirected: false,
type: 'basic',
url: '',
bodyUsed: false,
clone: () => undefined as unknown as Response,
body: null,
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
await createVoiceDesign({
voicePrompt: '成熟稳重男声',
previewText: '你好测试',
}, 'bl-key')
const firstCall = fetchMock.mock.calls[0] as [unknown, RequestInit?] | undefined
const requestBodyRaw = firstCall?.[1]?.body
expect(typeof requestBodyRaw).toBe('string')
const requestBody = JSON.parse(requestBodyRaw as string) as {
input?: { target_model?: string }
}
expect(requestBody.input?.target_model).toBe('qwen3-tts-vd-2026-01-26')
})
})

View File

@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
assertOfficialModelRegistered,
isOfficialModelRegistered,
registerOfficialModel,
resetOfficialModelRegistryForTest,
} from '@/lib/providers/official/model-registry'
describe('official model registry', () => {
beforeEach(() => {
resetOfficialModelRegistryForTest()
})
it('throws MODEL_NOT_REGISTERED when model is absent', () => {
expect(() =>
assertOfficialModelRegistered({
provider: 'bailian',
modality: 'llm',
modelId: 'qwen-plus',
}),
).toThrow(/MODEL_NOT_REGISTERED/)
})
it('accepts registered official model', () => {
registerOfficialModel({
provider: 'siliconflow',
modality: 'image',
modelId: 'sf-image',
})
expect(
isOfficialModelRegistered({
provider: 'siliconflow',
modality: 'image',
modelId: 'sf-image',
}),
).toBe(true)
})
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { buildProjectLocationGenerateImageBody } from '@/lib/query/mutations/location-image-mutations'
describe('buildProjectLocationGenerateImageBody', () => {
it('includes artStyle when generating a project location image', () => {
expect(buildProjectLocationGenerateImageBody({
locationId: 'location-1',
count: 1,
artStyle: 'japanese-anime',
})).toEqual({
type: 'location',
id: 'location-1',
imageIndex: undefined,
count: 1,
artStyle: 'japanese-anime',
})
})
})

View File

@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
executePipelineGraph,
GraphCancellationError,
type GraphExecutorState,
} from '@/lib/run-runtime/graph-executor'
const { createCheckpointMock, getRunByIdMock } = vi.hoisted(() => ({
createCheckpointMock: vi.fn(),
getRunByIdMock: vi.fn(),
}))
vi.mock('@/lib/run-runtime/service', () => ({
buildLeanState: vi.fn((value: unknown) => value),
createCheckpoint: createCheckpointMock,
getRunById: getRunByIdMock,
}))
describe('graph executor', () => {
beforeEach(() => {
vi.clearAllMocks()
getRunByIdMock.mockResolvedValue({
id: 'run_1',
userId: 'user_1',
status: 'running',
})
})
it('retries retryable node error and writes checkpoint once success', async () => {
const state: GraphExecutorState = {
refs: {},
meta: {},
}
const runMock = vi
.fn()
.mockRejectedValueOnce(new TypeError('fetch failed sending request'))
.mockResolvedValueOnce({
output: { ok: true },
})
await executePipelineGraph({
runId: 'run_1',
projectId: 'project_1',
userId: 'user_1',
state,
nodes: [
{
key: 'node_a',
title: 'Node A',
maxAttempts: 2,
run: runMock,
},
],
})
expect(runMock).toHaveBeenCalledTimes(2)
expect(createCheckpointMock).toHaveBeenCalledTimes(1)
expect(createCheckpointMock).toHaveBeenCalledWith(expect.objectContaining({
runId: 'run_1',
nodeKey: 'node_a',
version: 2,
}))
})
it('throws cancellation error when run status is canceling', async () => {
getRunByIdMock.mockResolvedValue({
id: 'run_1',
userId: 'user_1',
status: 'canceling',
})
await expect(
executePipelineGraph({
runId: 'run_1',
projectId: 'project_1',
userId: 'user_1',
state: {
refs: {},
meta: {},
},
nodes: [
{
key: 'node_a',
title: 'Node A',
run: async () => ({ output: { ok: true } }),
},
],
}),
).rejects.toBeInstanceOf(GraphCancellationError)
})
it('merges refs into state and persists lean checkpoint', async () => {
const state: GraphExecutorState = {
refs: {
scriptId: 'script_1',
},
meta: {
tag: 'v1',
},
}
await executePipelineGraph({
runId: 'run_1',
projectId: 'project_1',
userId: 'user_1',
state,
nodes: [
{
key: 'node_b',
title: 'Node B',
run: async () => ({
checkpointRefs: {
storyboardId: 'storyboard_1',
},
checkpointMeta: {
done: true,
},
}),
},
],
})
expect(state.refs).toEqual({
scriptId: 'script_1',
storyboardId: 'storyboard_1',
voiceLineBatchId: undefined,
versionHash: undefined,
cursor: undefined,
})
expect(createCheckpointMock).toHaveBeenCalledWith(expect.objectContaining({
state: expect.objectContaining({
refs: expect.objectContaining({
scriptId: 'script_1',
storyboardId: 'storyboard_1',
}),
}),
}))
})
})

View File

@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GraphExecutorState } from '@/lib/run-runtime/graph-executor'
const executePipelineGraphMock = vi.hoisted(() =>
vi.fn(async (input: {
runId: string
projectId: string
userId: string
state: GraphExecutorState
nodes: Array<{
key: string
run: (context: {
runId: string
projectId: string
userId: string
nodeKey: string
attempt: number
state: GraphExecutorState
}) => Promise<unknown>
}>
}) => {
for (const node of input.nodes) {
await node.run({
runId: input.runId,
projectId: input.projectId,
userId: input.userId,
nodeKey: node.key,
attempt: 1,
state: input.state,
})
}
return input.state
}),
)
vi.mock('@/lib/run-runtime/graph-executor', () => ({
executePipelineGraph: executePipelineGraphMock,
}))
import { runLangGraphPipeline } from '@/lib/run-runtime/langgraph-pipeline'
type TestState = GraphExecutorState & {
order: string[]
}
describe('langgraph pipeline adapter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('runs nodes in declared order through langgraph', async () => {
const state: TestState = {
refs: {},
meta: {},
order: [],
}
const result = await runLangGraphPipeline({
runId: 'run-1',
projectId: 'project-1',
userId: 'user-1',
state,
nodes: [
{
key: 'node_a',
title: 'Node A',
run: async (context) => {
const typedState = context.state as TestState
typedState.order.push('node_a')
return { output: { ok: true } }
},
},
{
key: 'node_b',
title: 'Node B',
run: async (context) => {
const typedState = context.state as TestState
typedState.order.push('node_b')
return { output: { ok: true } }
},
},
],
})
expect(result.order).toEqual(['node_a', 'node_b'])
expect(executePipelineGraphMock).toHaveBeenCalledTimes(2)
})
it('returns input state when graph has no nodes', async () => {
const state: TestState = {
refs: {},
meta: {},
order: [],
}
const result = await runLangGraphPipeline({
runId: 'run-1',
projectId: 'project-1',
userId: 'user-1',
state,
nodes: [],
})
expect(result).toBe(state)
expect(executePipelineGraphMock).not.toHaveBeenCalled()
})
it('fails explicitly on duplicate node keys', async () => {
const state: TestState = {
refs: {},
meta: {},
order: [],
}
await expect(
runLangGraphPipeline({
runId: 'run-1',
projectId: 'project-1',
userId: 'user-1',
state,
nodes: [
{
key: 'dup',
title: 'Dup 1',
run: async () => ({ output: { ok: true } }),
},
{
key: 'dup',
title: 'Dup 2',
run: async () => ({ output: { ok: true } }),
},
],
}),
).rejects.toThrow('LANGGRAPH_NODE_KEY_DUPLICATE: dup')
})
})

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import { mapTaskSSEEventToRunEvents } from '@/lib/run-runtime/task-bridge'
import { RUN_EVENT_TYPE } from '@/lib/run-runtime/types'
import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'
function buildEvent(input: Partial<SSEEvent>): SSEEvent {
return {
id: input.id || '1',
type: input.type || TASK_SSE_EVENT_TYPE.LIFECYCLE,
taskId: input.taskId || 'task_1',
projectId: input.projectId || 'project_1',
userId: input.userId || 'user_1',
ts: input.ts || new Date().toISOString(),
payload: input.payload || {},
taskType: input.taskType || null,
targetType: input.targetType || null,
targetId: input.targetId || null,
episodeId: input.episodeId || null,
}
}
describe('task->run event bridge', () => {
it('maps task.stream to step.chunk and normalizes lane by kind', () => {
const event = buildEvent({
type: TASK_SSE_EVENT_TYPE.STREAM,
payload: {
runId: 'run_1',
stepId: 'step_a',
stream: {
kind: 'reasoning',
delta: 'abc',
seq: 1,
},
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(1)
expect(mapped[0]).toMatchObject({
runId: 'run_1',
eventType: RUN_EVENT_TYPE.STEP_CHUNK,
stepKey: 'step_a',
lane: 'reasoning',
})
})
it('uses taskType-based fallback stepKey for stream when stepId missing', () => {
const event = buildEvent({
type: TASK_SSE_EVENT_TYPE.STREAM,
taskType: 'story_to_script_run',
payload: {
runId: 'run_1',
stream: {
kind: 'text',
delta: 'hello',
seq: 1,
},
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(1)
expect(mapped[0]).toMatchObject({
eventType: RUN_EVENT_TYPE.STEP_CHUNK,
stepKey: 'step:story_to_script_run',
lane: 'text',
})
})
it('maps task.processing + done=true to step.start then step.complete', () => {
const event = buildEvent({
payload: {
runId: 'run_2',
stepId: 'step_b',
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
done: true,
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(2)
expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_START)
expect(mapped[1]?.eventType).toBe(RUN_EVENT_TYPE.STEP_COMPLETE)
})
it('maps processing error stage to step.error', () => {
const event = buildEvent({
payload: {
meta: { runId: 'run_3' },
stepId: 'step_c',
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
stage: 'worker_llm_error',
error: {
message: 'boom',
},
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(2)
expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_START)
expect(mapped[1]).toMatchObject({
eventType: RUN_EVENT_TYPE.STEP_ERROR,
runId: 'run_3',
stepKey: 'step_c',
})
})
it('maps task.completed to step.complete and run.complete', () => {
const event = buildEvent({
payload: {
runId: 'run_4',
stepId: 'step_d',
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(2)
expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_COMPLETE)
expect(mapped[1]?.eventType).toBe(RUN_EVENT_TYPE.RUN_COMPLETE)
})
it('returns empty when runId is missing', () => {
const event = buildEvent({
payload: {
stepId: 'step_x',
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
},
})
expect(mapTaskSSEEventToRunEvents(event)).toEqual([])
})
})

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { createStorageProvider } from '@/lib/storage/factory'
import { StorageConfigError, StorageProviderNotImplementedError } from '@/lib/storage/errors'
describe('storage factory', () => {
it('creates local provider when STORAGE_TYPE=local', () => {
const provider = createStorageProvider({ storageType: 'local' })
expect(provider.kind).toBe('local')
})
it('creates minio provider when STORAGE_TYPE=minio', () => {
process.env.MINIO_ENDPOINT = 'http://127.0.0.1:9000'
process.env.MINIO_REGION = 'us-east-1'
process.env.MINIO_BUCKET = 'waoowaoo'
process.env.MINIO_ACCESS_KEY = 'minioadmin'
process.env.MINIO_SECRET_KEY = 'minioadmin'
process.env.MINIO_FORCE_PATH_STYLE = 'true'
const provider = createStorageProvider({ storageType: 'minio' })
expect(provider.kind).toBe('minio')
})
it('throws explicit not-implemented error when STORAGE_TYPE=cos', () => {
expect(() => createStorageProvider({ storageType: 'cos' })).toThrow(StorageProviderNotImplementedError)
})
it('throws config error on unknown storage type', () => {
expect(() => createStorageProvider({ storageType: 'unknown' })).toThrow(StorageConfigError)
})
})

View File

@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const getProviderConfigMock = vi.hoisted(() =>
vi.fn(async () => ({
id: 'bailian',
apiKey: 'bl-key',
})),
)
vi.mock('@/lib/api-config', () => ({
getProviderConfig: getProviderConfigMock,
}))
vi.mock('@/lib/async-submit', () => ({
queryFalStatus: vi.fn(),
}))
vi.mock('@/lib/async-task-utils', () => ({
queryGeminiBatchStatus: vi.fn(),
queryGoogleVideoStatus: vi.fn(),
querySeedanceVideoStatus: vi.fn(),
}))
import { pollAsyncTask } from '@/lib/async-poll'
describe('async poll bailian task', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns pending when task is running', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_status: 'RUNNING',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await pollAsyncTask('BAILIAN:VIDEO:task-running', 'user-1')
expect(getProviderConfigMock).toHaveBeenCalledWith('user-1', 'bailian')
expect(fetchMock).toHaveBeenCalledWith(
'https://dashscope.aliyuncs.com/api/v1/tasks/task-running',
{
headers: {
Authorization: 'Bearer bl-key',
},
},
)
expect(result).toEqual({ status: 'pending' })
})
it('returns completed with video url when task succeeded', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_status: 'SUCCEEDED',
video_url: 'https://video.example/result.mp4',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await pollAsyncTask('BAILIAN:VIDEO:task-success', 'user-1')
expect(result).toEqual({
status: 'completed',
resultUrl: 'https://video.example/result.mp4',
videoUrl: 'https://video.example/result.mp4',
imageUrl: undefined,
})
})
it('returns failed when task succeeded but no media url', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_status: 'SUCCEEDED',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await pollAsyncTask('BAILIAN:VIDEO:task-no-url', 'user-1')
expect(result).toEqual({
status: 'failed',
error: 'Bailian: 任务完成但未返回结果URL',
})
})
it('returns output code/message when task failed', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
output: {
task_status: 'FAILED',
code: 'InternalError.DownloadException',
message: 'Unknown error occurred while downloading the file.',
},
}),
}))
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
const result = await pollAsyncTask('BAILIAN:VIDEO:task-failed', 'user-1')
expect(result).toEqual({
status: 'failed',
error: 'Bailian: InternalError.DownloadException',
})
})
})

Some files were not shown because too many files have changed in this diff Show More