feat: initial release v0.3.0
This commit is contained in:
34
tests/unit/ai-runtime/errors.test.ts
Normal file
34
tests/unit/ai-runtime/errors.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
71
tests/unit/api-config/assistant-chat-modal-content.test.ts
Normal file
71
tests/unit/api-config/assistant-chat-modal-content.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
22
tests/unit/api-config/minimax-preset.test.ts
Normal file
22
tests/unit/api-config/minimax-preset.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
52
tests/unit/api-config/preset-coming-soon.test.ts
Normal file
52
tests/unit/api-config/preset-coming-soon.test.ts
Normal 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',
|
||||
]))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
173
tests/unit/api-config/provider-card-pricing-form.test.ts
Normal file
173
tests/unit/api-config/provider-card-pricing-form.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
83
tests/unit/api-config/provider-card-protocol-probe.test.ts
Normal file
83
tests/unit/api-config/provider-card-protocol-probe.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
25
tests/unit/api-config/provider-card-shell.test.ts
Normal file
25
tests/unit/api-config/provider-card-shell.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
119
tests/unit/api-config/use-api-config-filters.test.ts
Normal file
119
tests/unit/api-config/use-api-config-filters.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
100
tests/unit/api-config/use-assistant-chat-saved-events.test.ts
Normal file
100
tests/unit/api-config/use-assistant-chat-saved-events.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
65
tests/unit/api-config/use-providers-order.test.ts
Normal file
65
tests/unit/api-config/use-providers-order.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
15
tests/unit/assistant-platform/registry.test.ts
Normal file
15
tests/unit/assistant-platform/registry.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
46
tests/unit/assistant-platform/runtime.test.ts
Normal file
46
tests/unit/assistant-platform/runtime.test.ts
Normal 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>)
|
||||
})
|
||||
})
|
||||
230
tests/unit/assistant-platform/skills-api-config-template.test.ts
Normal file
230
tests/unit/assistant-platform/skills-api-config-template.test.ts
Normal 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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
21
tests/unit/assistant-platform/system-prompts.test.ts
Normal file
21
tests/unit/assistant-platform/system-prompts.test.ts
Normal 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('禁止编造不存在的页面')
|
||||
})
|
||||
})
|
||||
166
tests/unit/async-poll-ocompat.test.ts
Normal file
166
tests/unit/async-poll-ocompat.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
65
tests/unit/billing/cost-error-branches.test.ts
Normal file
65
tests/unit/billing/cost-error-branches.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
208
tests/unit/billing/cost.test.ts
Normal file
208
tests/unit/billing/cost.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
135
tests/unit/billing/ledger-extra.test.ts
Normal file
135
tests/unit/billing/ledger-extra.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
22
tests/unit/billing/mode.test.ts
Normal file
22
tests/unit/billing/mode.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
79
tests/unit/billing/runtime-usage.test.ts
Normal file
79
tests/unit/billing/runtime-usage.test.ts
Normal 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 }])
|
||||
})
|
||||
})
|
||||
518
tests/unit/billing/service.test.ts
Normal file
518
tests/unit/billing/service.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
82
tests/unit/billing/task-policy.test.ts
Normal file
82
tests/unit/billing/task-policy.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
116
tests/unit/components/character-creation-modal.test.ts
Normal file
116
tests/unit/components/character-creation-modal.test.ts
Normal 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('取消')
|
||||
})
|
||||
})
|
||||
@@ -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=""')
|
||||
})
|
||||
})
|
||||
81
tests/unit/components/location-creation-modal.test.ts
Normal file
81
tests/unit/components/location-creation-modal.test.ts
Normal 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('取消')
|
||||
})
|
||||
})
|
||||
68
tests/unit/components/voice-design-shared.test.ts
Normal file
68
tests/unit/components/voice-design-shared.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
105
tests/unit/generator-api-openai-template-required.test.ts
Normal file
105
tests/unit/generator-api-openai-template-required.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
301
tests/unit/generator-api.test.ts
Normal file
301
tests/unit/generator-api.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
22
tests/unit/generators/factory.test.ts
Normal file
22
tests/unit/generators/factory.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
94
tests/unit/generators/fal-video-kling-presets.test.ts
Normal file
94
tests/unit/generators/fal-video-kling-presets.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
234
tests/unit/generators/image-provider-smoke.test.ts
Normal file
234
tests/unit/generators/image-provider-smoke.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
122
tests/unit/generators/openai-compatible-image.test.ts
Normal file
122
tests/unit/generators/openai-compatible-image.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
166
tests/unit/generators/openai-compatible-video.test.ts
Normal file
166
tests/unit/generators/openai-compatible-video.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
58
tests/unit/helpers/api-fetch.test.ts
Normal file
58
tests/unit/helpers/api-fetch.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
185
tests/unit/helpers/json-repair.test.ts
Normal file
185
tests/unit/helpers/json-repair.test.ts
Normal 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('后来')
|
||||
})
|
||||
})
|
||||
25
tests/unit/helpers/llm-stage-stream-card-output.test.ts
Normal file
25
tests/unit/helpers/llm-stage-stream-card-output.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
63
tests/unit/helpers/logging-core.test.ts
Normal file
63
tests/unit/helpers/logging-core.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
35
tests/unit/helpers/prompt-suffix-regression.test.ts
Normal file
35
tests/unit/helpers/prompt-suffix-regression.test.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
278
tests/unit/helpers/recovered-run-subscription.test.ts
Normal file
278
tests/unit/helpers/recovered-run-subscription.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
54
tests/unit/helpers/reference-to-character-helpers.test.ts
Normal file
54
tests/unit/helpers/reference-to-character-helpers.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
56
tests/unit/helpers/route-task-helpers.test.ts
Normal file
56
tests/unit/helpers/route-task-helpers.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
278
tests/unit/helpers/run-request-executor.run-events.test.ts
Normal file
278
tests/unit/helpers/run-request-executor.run-events.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
370
tests/unit/helpers/run-stream-state-machine.test.ts
Normal file
370
tests/unit/helpers/run-stream-state-machine.test.ts
Normal 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":[]}')
|
||||
})
|
||||
})
|
||||
174
tests/unit/helpers/run-stream-view.test.ts
Normal file
174
tests/unit/helpers/run-stream-view.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
83
tests/unit/helpers/task-state-service.test.ts
Normal file
83
tests/unit/helpers/task-state-service.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
59
tests/unit/helpers/task-submitter-helpers.test.ts
Normal file
59
tests/unit/helpers/task-submitter-helpers.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
122
tests/unit/helpers/update-check.test.ts
Normal file
122
tests/unit/helpers/update-check.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
36
tests/unit/helpers/workspace-model-setup.test.ts
Normal file
36
tests/unit/helpers/workspace-model-setup.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
48
tests/unit/image-generation/count.test.ts
Normal file
48
tests/unit/image-generation/count.test.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
101
tests/unit/image-generation/slot-state.test.ts
Normal file
101
tests/unit/image-generation/slot-state.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
224
tests/unit/lipsync-bailian.test.ts
Normal file
224
tests/unit/lipsync-bailian.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
199
tests/unit/lipsync-preprocess.test.ts
Normal file
199
tests/unit/lipsync-preprocess.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
22
tests/unit/llm/ark-llm-thinking.test.ts
Normal file
22
tests/unit/llm/ark-llm-thinking.test.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
124
tests/unit/llm/chat-completion-official-provider.test.ts
Normal file
124
tests/unit/llm/chat-completion-official-provider.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
129
tests/unit/llm/chat-stream-official-provider.test.ts
Normal file
129
tests/unit/llm/chat-stream-official-provider.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
156
tests/unit/llm/chat-stream-openai-compatible-protocol.test.ts
Normal file
156
tests/unit/llm/chat-stream-openai-compatible-protocol.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
50
tests/unit/llm/completion-parts-think-tag.test.ts
Normal file
50
tests/unit/llm/completion-parts-think-tag.test.ts
Normal 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": [] }')
|
||||
})
|
||||
})
|
||||
41
tests/unit/llm/reasoning-capability.test.ts
Normal file
41
tests/unit/llm/reasoning-capability.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
66
tests/unit/model-capabilities/video-effective.test.ts
Normal file
66
tests/unit/model-capabilities/video-effective.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
||||
58
tests/unit/model-gateway/llm.test.ts
Normal file
58
tests/unit/model-gateway/llm.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
67
tests/unit/model-gateway/openai-compat-responses.test.ts
Normal file
67
tests/unit/model-gateway/openai-compat-responses.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
189
tests/unit/model-gateway/openai-compat-template-renderer.test.ts
Normal file
189
tests/unit/model-gateway/openai-compat-template-renderer.test.ts
Normal 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('当前分组上游负载已饱和,请稍后再试')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
27
tests/unit/model-gateway/router.test.ts
Normal file
27
tests/unit/model-gateway/router.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
74
tests/unit/novel-promotion/character-voice-mutations.test.ts
Normal file
74
tests/unit/novel-promotion/character-voice-mutations.test.ts
Normal 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',
|
||||
}),
|
||||
},
|
||||
'更新音色失败',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('当前视频接口格式暂不支持。')
|
||||
})
|
||||
})
|
||||
112
tests/unit/novel-promotion/use-tts-generation.test.ts
Normal file
112
tests/unit/novel-promotion/use-tts-generation.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
67
tests/unit/novel-promotion/video-model-options.test.ts
Normal file
67
tests/unit/novel-promotion/video-model-options.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
167
tests/unit/novel-promotion/video-panel-card-body.test.ts
Normal file
167
tests/unit/novel-promotion/video-panel-card-body.test.ts
Normal 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('生成首尾帧视频')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
92
tests/unit/novel-promotion/voice-generation-actions.test.ts
Normal file
92
tests/unit/novel-promotion/voice-generation-actions.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
212
tests/unit/novel-promotion/voice-runtime-sync.test.ts
Normal file
212
tests/unit/novel-promotion/voice-runtime-sync.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
88
tests/unit/novel-promotion/voice-stage-data-loader.test.ts
Normal file
88
tests/unit/novel-promotion/voice-stage-data-loader.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
71
tests/unit/novel-promotion/workspace-video-actions.test.ts
Normal file
71
tests/unit/novel-promotion/workspace-video-actions.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
103
tests/unit/optimistic/ai-data-modal-state.test.ts
Normal file
103
tests/unit/optimistic/ai-data-modal-state.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
171
tests/unit/optimistic/asset-hub-mutations.test.ts
Normal file
171
tests/unit/optimistic/asset-hub-mutations.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
87
tests/unit/optimistic/panel-ai-data-sync.test.ts
Normal file
87
tests/unit/optimistic/panel-ai-data-sync.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
89
tests/unit/optimistic/panel-save-coordinator.test.ts
Normal file
89
tests/unit/optimistic/panel-save-coordinator.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
157
tests/unit/optimistic/project-asset-mutations.test.ts
Normal file
157
tests/unit/optimistic/project-asset-mutations.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
167
tests/unit/optimistic/sse-invalidation.test.ts
Normal file
167
tests/unit/optimistic/sse-invalidation.test.ts
Normal 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 invalidation;COMPLETED 触发', 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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
105
tests/unit/optimistic/task-target-overlay.test.ts
Normal file
105
tests/unit/optimistic/task-target-overlay.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
286
tests/unit/optimistic/task-target-state-map.test.ts
Normal file
286
tests/unit/optimistic/task-target-state-map.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
78
tests/unit/providers/bailian-llm.test.ts
Normal file
78
tests/unit/providers/bailian-llm.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
145
tests/unit/providers/bailian-tts.test.ts
Normal file
145
tests/unit/providers/bailian-tts.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
||||
179
tests/unit/providers/bailian-video.test.ts
Normal file
179
tests/unit/providers/bailian-video.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
118
tests/unit/providers/bailian-voice-cleanup.test.ts
Normal file
118
tests/unit/providers/bailian-voice-cleanup.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
||||
53
tests/unit/providers/bailian-voice-design.test.ts
Normal file
53
tests/unit/providers/bailian-voice-design.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
39
tests/unit/providers/model-registry.test.ts
Normal file
39
tests/unit/providers/model-registry.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
18
tests/unit/query/project-location-generate-body.test.ts
Normal file
18
tests/unit/query/project-location-generate-body.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
140
tests/unit/run-runtime/graph-executor.test.ts
Normal file
140
tests/unit/run-runtime/graph-executor.test.ts
Normal 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',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
136
tests/unit/run-runtime/langgraph-pipeline.test.ts
Normal file
136
tests/unit/run-runtime/langgraph-pipeline.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
133
tests/unit/run-runtime/task-bridge.test.ts
Normal file
133
tests/unit/run-runtime/task-bridge.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
30
tests/unit/storage/factory.test.ts
Normal file
30
tests/unit/storage/factory.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
121
tests/unit/task/async-poll-bailian.test.ts
Normal file
121
tests/unit/task/async-poll-bailian.test.ts
Normal 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
Reference in New Issue
Block a user