release: opensource snapshot 2026-02-27 19:25:00

This commit is contained in:
saturn
2026-02-27 19:25:00 +08:00
commit 5de9622c8b
1055 changed files with 164772 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
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)
})
})

View File

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

View File

@@ -0,0 +1,126 @@
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('returns deterministic fixed costs for call-based APIs', () => {
expect(calcVoiceDesign()).toBeGreaterThan(0)
expect(calcLipSync()).toBeGreaterThan(0)
expect(calcLipSync('vidu::vidu-lipsync')).toBeGreaterThan(0)
})
it('calculates voice costs from quantities', () => {
expect(calcVoice(30)).toBeGreaterThan(0)
})
})

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,442 @@
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 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 handles ENFORCE success/failure branches', async () => {
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_enforce_settle',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_ok' }),
})
expect((settled as Extract<TaskBillingInfo, { billable: true }>).status).toBe('settled')
const missingFreeze = await settleTaskBilling({
id: 'task_enforce_no_freeze',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: null }),
})
expect((missingFreeze as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
await expect(
settleTaskBilling({
id: 'task_enforce_confirm_fail',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_fail' }),
}),
).rejects.toThrow('confirm failed')
})
it('settleTaskBilling throws BILLING_CONFIRM_FAILED when confirm and rollback both fail', async () => {
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(new Error('confirm failed'))
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
await expect(
settleTaskBilling({
id: 'task_confirm_and_rollback_fail',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail_confirm' }),
}),
).rejects.toMatchObject({
name: 'BillingOperationError',
code: 'BILLING_CONFIRM_FAILED',
})
})
it('settleTaskBilling rethrows BillingOperationError with task context when rollback succeeds', async () => {
ledgerMock.confirmChargeWithRecord.mockRejectedValueOnce(
new BillingOperationError(
'BILLING_INVALID_FREEZE',
'invalid freeze',
{ reason: 'status_mismatch' },
),
)
let thrown: unknown = null
try {
await settleTaskBilling({
id: 'task_confirm_billing_error',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_billing_error' }),
})
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(BillingOperationError)
const billingError = thrown as BillingOperationError
expect(billingError.code).toBe('BILLING_INVALID_FREEZE')
expect(billingError.details).toMatchObject({
reason: 'status_mismatch',
taskId: 'task_confirm_billing_error',
freezeId: 'freeze_billing_error',
})
})
it('settleTaskBilling expands freeze when actual exceeds quoted', async () => {
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_enforce_overage',
userId: 'u1',
projectId: 'p1',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_overage', quantity: 5 }),
}, {
result: { actualDurationSeconds: 50 },
})
expect(ledgerMock.increasePendingFreezeAmount).toHaveBeenCalledTimes(1)
expect(ledgerMock.confirmChargeWithRecord).toHaveBeenCalled()
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(calcVoice(50), 8)
})
it('settleTaskBilling keeps quoted charge when text usage has no token counts', async () => {
const quoted = calcText('anthropic/claude-sonnet-4', 500, 500)
const textBillingInfo: Extract<TaskBillingInfo, { billable: true }> = {
billable: true,
source: 'task',
taskType: 'analyze_novel',
apiType: 'text',
model: 'anthropic/claude-sonnet-4',
quantity: 1000,
unit: 'token',
maxFrozenCost: quoted,
action: 'analyze_novel',
modeSnapshot: 'ENFORCE',
status: 'frozen',
freezeId: 'freeze_text_zero',
}
ledgerMock.confirmChargeWithRecord.mockResolvedValueOnce(true)
const settled = await settleTaskBilling({
id: 'task_text_zero_usage',
userId: 'u1',
projectId: 'p1',
billingInfo: textBillingInfo,
}, {
textUsage: [{ model: 'openai/gpt-5', inputTokens: 0, outputTokens: 0 }],
})
expect((settled as Extract<TaskBillingInfo, { billable: true }>).chargedCost).toBeCloseTo(quoted, 8)
const recordParams = ledgerMock.confirmChargeWithRecord.mock.calls.at(-1)?.[1] as { model: string }
expect(recordParams.model).toBe('openai/gpt-5')
})
it('rollbackTaskBilling handles success and fallback branches', async () => {
const rolledBack = await rollbackTaskBilling({
id: 'task_rb_ok',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_ok' }),
})
expect((rolledBack as Extract<TaskBillingInfo, { billable: true }>).status).toBe('rolled_back')
ledgerMock.rollbackFreeze.mockRejectedValueOnce(new Error('rollback failed'))
const rollbackFailed = await rollbackTaskBilling({
id: 'task_rb_fail',
billingInfo: buildTaskInfo({ modeSnapshot: 'ENFORCE', freezeId: 'freeze_rb_fail' }),
})
expect((rollbackFailed as Extract<TaskBillingInfo, { billable: true }>).status).toBe('failed')
})
})
})

View File

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

View File

@@ -0,0 +1,92 @@
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)
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] as Record<string, unknown>
expect(payload.prompt).toBe('test prompt')
expect(payload.duration).toBe('5')
if (imageField === 'image_url') {
expect(payload.image_url).toBe('https://example.com/start.png')
expect(payload.start_image_url).toBeUndefined()
expect(payload.negative_prompt).toBe('blur, distort, and low quality')
expect(payload.cfg_scale).toBe(0.5)
return
}
expect(payload.start_image_url).toBe('https://example.com/start.png')
expect(payload.image_url).toBeUndefined()
expect(payload.aspect_ratio).toBe('16:9')
expect(payload.generate_audio).toBe(false)
})
})

View File

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

View File

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

View File

@@ -0,0 +1,210 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { subscribeRecoveredRun } from '@/lib/query/hooks/run-stream/recovered-run-subscription'
type MockEvent = {
id: string
type: string
taskId: string
projectId: string
userId: string
ts: string
taskType: string
targetType: string
targetId: string
episodeId: string | null
payload: Record<string, unknown>
}
function buildLifecycleEvent(payload: Record<string, unknown>): MockEvent {
return {
id: '1',
type: 'task.lifecycle',
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
ts: new Date().toISOString(),
taskType: 'script_to_storyboard_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
payload,
}
}
function buildStreamEvent(payload: Record<string, unknown>): MockEvent {
return {
id: 'stream-1',
type: 'task.stream',
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
ts: new Date().toISOString(),
taskType: 'script_to_storyboard_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
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()
if (originalFetch) {
globalThis.fetch = originalFetch
} else {
Reflect.deleteProperty(globalThis, 'fetch')
}
})
it('replays task lifecycle events for external mode to recover stage steps', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
events: [
buildLifecycleEvent({
lifecycleType: 'task.processing',
stepId: 'clip_1_phase1',
stepTitle: '分镜规划',
stepIndex: 1,
stepTotal: 4,
message: 'running',
}),
],
}),
})
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const pollTaskTerminalState = vi.fn(async () => null)
const onSettled = vi.fn()
const cleanup = subscribeRecoveredRun({
projectId: 'project-1',
storageScopeKey: 'episode-1',
taskId: 'task-1',
eventSourceMode: 'external',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
pollTaskTerminalState,
onSettled,
})
await waitForCondition(() => fetchMock.mock.calls.length > 0 && applyAndCapture.mock.calls.length > 0)
expect(fetchMock).toHaveBeenCalledWith(
'/api/tasks/task-1?includeEvents=1&eventsLimit=5000',
expect.objectContaining({
method: 'GET',
cache: 'no-store',
}),
)
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'step.start',
runId: 'task-1',
stepId: 'clip_1_phase1',
}))
expect(onSettled).not.toHaveBeenCalled()
cleanup()
})
it('settles external recovery when replay hits terminal lifecycle event', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
events: [
buildLifecycleEvent({
lifecycleType: 'task.failed',
message: 'exception TypeError: fetch failed sending request',
}),
],
}),
})
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const pollTaskTerminalState = vi.fn(async () => null)
const onSettled = vi.fn()
subscribeRecoveredRun({
projectId: 'project-1',
storageScopeKey: 'episode-1',
taskId: 'task-1',
eventSourceMode: 'external',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
pollTaskTerminalState,
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: 'task-1',
}))
})
it('replays persisted stream events so refresh keeps prior output', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
events: [
buildLifecycleEvent({
lifecycleType: 'task.processing',
stepId: 'clip_1_phase1',
stepTitle: '分镜规划',
stepIndex: 1,
stepTotal: 1,
message: 'running',
}),
buildStreamEvent({
stepId: 'clip_1_phase1',
stream: {
kind: 'text',
lane: 'main',
seq: 1,
delta: '旧输出',
},
}),
],
}),
})
globalThis.fetch = fetchMock as unknown as typeof fetch
const applyAndCapture = vi.fn()
const pollTaskTerminalState = vi.fn(async () => null)
const onSettled = vi.fn()
const cleanup = subscribeRecoveredRun({
projectId: 'project-1',
storageScopeKey: 'episode-1',
taskId: 'task-1',
eventSourceMode: 'external',
taskStreamTimeoutMs: 10_000,
applyAndCapture,
pollTaskTerminalState,
onSettled,
})
await waitForCondition(() => applyAndCapture.mock.calls.some((call) => call[0]?.event === 'step.chunk'))
expect(applyAndCapture).toHaveBeenCalledWith(expect.objectContaining({
event: 'step.chunk',
runId: 'task-1',
stepId: 'clip_1_phase1',
textDelta: '旧输出',
}))
cleanup()
})
})

View File

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

View File

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

View File

@@ -0,0 +1,274 @@
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',
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')
})
})

View File

@@ -0,0 +1,102 @@
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',
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')
})
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,169 @@
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',
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',
images: [{
id: `${id}-img-0`,
imageIndex: 0,
description: null,
imageUrl: null,
previousImageUrl: null,
isSelected: true,
imageTaskRunning: false,
}],
}
}
describe('asset hub optimistic mutations', () => {
beforeEach(() => {
queryClient = new MockQueryClient()
useQueryClientMock.mockClear()
useMutationMock.mockClear()
})
it('updates all character query caches optimistically and ignores stale rollback', async () => {
const allCharactersKey = queryKeys.globalAssets.characters()
const folderCharactersKey = queryKeys.globalAssets.characters('folder-1')
queryClient.seedQuery(allCharactersKey, [buildGlobalCharacter(0)])
queryClient.seedQuery(folderCharactersKey, [buildGlobalCharacter(0)])
const mutation = useSelectCharacterImage() as unknown as SelectCharacterMutation
const firstVariables = {
characterId: 'character-1',
appearanceIndex: 0,
imageIndex: 1,
}
const secondVariables = {
characterId: 'character-1',
appearanceIndex: 0,
imageIndex: 2,
}
const firstContext = await mutation.onMutate(firstVariables)
const afterFirstAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
const afterFirstFolder = queryClient.getQueryData<GlobalCharacter[]>(folderCharactersKey)
expect(afterFirstAll?.[0]?.appearances[0]?.selectedIndex).toBe(1)
expect(afterFirstFolder?.[0]?.appearances[0]?.selectedIndex).toBe(1)
const secondContext = await mutation.onMutate(secondVariables)
const afterSecondAll = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterSecondAll?.[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('first failed'), firstVariables, firstContext)
const afterStaleError = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterStaleError?.[0]?.appearances[0]?.selectedIndex).toBe(2)
mutation.onError(new Error('second failed'), secondVariables, secondContext)
const afterLatestRollback = queryClient.getQueryData<GlobalCharacter[]>(allCharactersKey)
expect(afterLatestRollback?.[0]?.appearances[0]?.selectedIndex).toBe(1)
})
it('optimistically removes location and restores on error', async () => {
const allLocationsKey = queryKeys.globalAssets.locations()
const folderLocationsKey = queryKeys.globalAssets.locations('folder-1')
queryClient.seedQuery(allLocationsKey, [buildGlobalLocation('loc-1'), buildGlobalLocation('loc-2')])
queryClient.seedQuery(folderLocationsKey, [buildGlobalLocation('loc-1')])
const mutation = useDeleteAssetHubLocation() as unknown as DeleteLocationMutation
const context = await mutation.onMutate('loc-1')
const afterDeleteAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)
const afterDeleteFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)
expect(afterDeleteAll?.map((item) => item.id)).toEqual(['loc-2'])
expect(afterDeleteFolder).toEqual([])
mutation.onError(new Error('delete failed'), 'loc-1', context)
const rolledBackAll = queryClient.getQueryData<GlobalLocation[]>(allLocationsKey)
const rolledBackFolder = queryClient.getQueryData<GlobalLocation[]>(folderLocationsKey)
expect(rolledBackAll?.map((item) => item.id)).toEqual(['loc-1', 'loc-2'])
expect(rolledBackFolder?.map((item) => item.id)).toEqual(['loc-1'])
})
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,123 @@
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>>,
}))
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: {
'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,
},
},
}
}
return {
data: [
{
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,
},
] as TaskTargetState[],
}
},
}))
describe('task target state map behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
runtime.useQueryCalls = []
})
it('keeps polling disabled 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(firstCall?.refetchInterval).toBe(false)
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')
})
})

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { formatExternalId, parseExternalId } from '@/lib/async-poll'
describe('async poll externalId contract', () => {
it('parses standard FAL externalId with endpoint', () => {
const parsed = parseExternalId('FAL:VIDEO:fal-ai/wan/v2.6/image-to-video:req_123')
expect(parsed.provider).toBe('FAL')
expect(parsed.type).toBe('VIDEO')
expect(parsed.endpoint).toBe('fal-ai/wan/v2.6/image-to-video')
expect(parsed.requestId).toBe('req_123')
})
it('rejects legacy non-standard externalId formats', () => {
expect(() => parseExternalId('FAL:fal-ai/wan/v2.6/image-to-video:req_123')).toThrow(/无效 FAL externalId/)
expect(() => parseExternalId('batches/legacy')).toThrow(/无法识别的 externalId 格式/)
})
it('requires endpoint when formatting FAL externalId', () => {
expect(() => formatExternalId('FAL', 'VIDEO', 'req_123')).toThrow(/requires endpoint/)
})
})

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { resolveTaskErrorMessage, resolveTaskErrorSummary } from '@/lib/task/error-message'
describe('task error message normalization', () => {
it('maps TASK_CANCELLED to unified cancelled message', () => {
const summary = resolveTaskErrorSummary({
errorCode: 'TASK_CANCELLED',
errorMessage: 'whatever',
})
expect(summary.cancelled).toBe(true)
expect(summary.code).toBe('CONFLICT')
expect(summary.message).toBe('Task cancelled by user')
})
it('keeps cancelled semantics from normalized task error details', () => {
const summary = resolveTaskErrorSummary({
error: {
code: 'CONFLICT',
message: 'Task cancelled by user',
details: { cancelled: true, originalCode: 'TASK_CANCELLED' },
},
})
expect(summary.cancelled).toBe(true)
expect(summary.code).toBe('CONFLICT')
expect(summary.message).toBe('Task cancelled by user')
})
it('extracts nested error message from payload', () => {
const message = resolveTaskErrorMessage({
error: {
details: {
message: 'provider failed',
},
},
}, 'fallback')
expect(message).toBe('provider failed')
})
it('supports flat error/details string payload', () => {
expect(resolveTaskErrorMessage({
error: 'provider failed',
}, 'fallback')).toBe('provider failed')
expect(resolveTaskErrorMessage({
details: 'provider failed',
}, 'fallback')).toBe('provider failed')
})
it('uses fallback when payload has no structured error', () => {
expect(resolveTaskErrorMessage({}, 'fallback')).toBe('fallback')
})
it('recognizes cancelled semantics from message-only payload', () => {
const summary = resolveTaskErrorSummary({
message: 'Task cancelled by user',
})
expect(summary.cancelled).toBe(true)
expect(summary.message).toBe('Task cancelled by user')
})
})

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { TASK_TYPE } from '@/lib/task/types'
import { resolveTaskIntent } from '@/lib/task/intent'
describe('resolveTaskIntent', () => {
it('maps generate task types', () => {
expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.VIDEO_PANEL)).toBe('generate')
})
it('maps regenerate and modify task types', () => {
expect(resolveTaskIntent(TASK_TYPE.REGENERATE_GROUP)).toBe('regenerate')
expect(resolveTaskIntent(TASK_TYPE.PANEL_VARIANT)).toBe('regenerate')
expect(resolveTaskIntent(TASK_TYPE.MODIFY_ASSET_IMAGE)).toBe('modify')
})
it('falls back to process for unknown types', () => {
expect(resolveTaskIntent('unknown_type')).toBe('process')
expect(resolveTaskIntent(null)).toBe('process')
expect(resolveTaskIntent(undefined)).toBe('process')
})
})

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { getTaskFlowMeta, getTaskPipeline } from '@/lib/llm-observe/stage-pipeline'
import { getLLMTaskPolicy } from '@/lib/llm-observe/task-policy'
import { TASK_TYPE } from '@/lib/task/types'
describe('llm observe task contract', () => {
it('maps AI_CREATE tasks to standard llm policy', () => {
const characterPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_CHARACTER)
const locationPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_LOCATION)
expect(characterPolicy.consoleEnabled).toBe(true)
expect(characterPolicy.displayMode).toBe('loading')
expect(characterPolicy.captureReasoning).toBe(true)
expect(locationPolicy.consoleEnabled).toBe(true)
expect(locationPolicy.displayMode).toBe('loading')
expect(locationPolicy.captureReasoning).toBe(true)
})
it('maps story/script run tasks to long-flow stage metadata', () => {
const storyMeta = getTaskFlowMeta(TASK_TYPE.STORY_TO_SCRIPT_RUN)
const scriptMeta = getTaskFlowMeta(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
expect(storyMeta.flowId).toBe('novel_promotion_generation')
expect(storyMeta.flowStageIndex).toBe(1)
expect(storyMeta.flowStageTotal).toBe(2)
expect(scriptMeta.flowId).toBe('novel_promotion_generation')
expect(scriptMeta.flowStageIndex).toBe(2)
expect(scriptMeta.flowStageTotal).toBe(2)
})
it('maps AI_CREATE tasks to dedicated single-stage flows', () => {
const characterMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_CHARACTER)
const locationMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_LOCATION)
expect(characterMeta.flowId).toBe('novel_promotion_ai_create_character')
expect(characterMeta.flowStageIndex).toBe(1)
expect(characterMeta.flowStageTotal).toBe(1)
expect(locationMeta.flowId).toBe('novel_promotion_ai_create_location')
expect(locationMeta.flowStageIndex).toBe(1)
expect(locationMeta.flowStageTotal).toBe(1)
})
it('returns a stable two-stage pipeline for story/script flow', () => {
const pipeline = getTaskPipeline(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
const stageTaskTypes = pipeline.stages.map((stage) => stage.taskType)
expect(stageTaskTypes).toEqual([
TASK_TYPE.STORY_TO_SCRIPT_RUN,
TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
])
})
it('falls back to single-stage metadata for unknown task type', () => {
const meta = getTaskFlowMeta('unknown_task_type')
const pipeline = getTaskPipeline('unknown_task_type')
expect(meta.flowId).toBe('single:unknown_task_type')
expect(meta.flowStageIndex).toBe(1)
expect(meta.flowStageTotal).toBe(1)
expect(pipeline.stages).toHaveLength(1)
expect(pipeline.stages[0]?.taskType).toBe('unknown_task_type')
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { normalizeAnyError } from '@/lib/errors/normalize'
describe('normalizeAnyError network termination mapping', () => {
it('maps undici terminated TypeError to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new TypeError('terminated'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
it('maps socket hang up TypeError to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new TypeError('socket hang up'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
it('maps wrapped terminated message to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new Error('exception TypeError: terminated'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
})

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
describe('resolveTaskPresentationState', () => {
it('uses overlay mode when running and has output', () => {
const state = resolveTaskPresentationState({
phase: 'processing',
intent: 'regenerate',
resource: 'image',
hasOutput: true,
})
expect(state.isRunning).toBe(true)
expect(state.mode).toBe('overlay')
expect(state.labelKey).toBe('taskStatus.intent.regenerate.running.image')
})
it('uses placeholder mode when running and no output', () => {
const state = resolveTaskPresentationState({
phase: 'queued',
intent: 'generate',
resource: 'image',
hasOutput: false,
})
expect(state.mode).toBe('placeholder')
expect(state.labelKey).toBe('taskStatus.intent.generate.running.image')
})
it('maps failed state to failed label', () => {
const state = resolveTaskPresentationState({
phase: 'failed',
intent: 'modify',
resource: 'video',
hasOutput: true,
})
expect(state.isError).toBe(true)
expect(state.labelKey).toBe('taskStatus.failed.video')
})
})

View File

@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const taskEventFindManyMock = vi.hoisted(() => vi.fn(async () => []))
const taskEventCreateMock = vi.hoisted(() => vi.fn(async () => null))
const taskFindManyMock = vi.hoisted(() => vi.fn(async () => []))
const redisPublishMock = vi.hoisted(() => vi.fn(async () => 1))
vi.mock('@/lib/prisma', () => ({
prisma: {
taskEvent: {
findMany: taskEventFindManyMock,
create: taskEventCreateMock,
},
task: {
findMany: taskFindManyMock,
},
},
}))
vi.mock('@/lib/redis', () => ({
redis: {
publish: redisPublishMock,
},
}))
import { listEventsAfter, listTaskLifecycleEvents, publishTaskStreamEvent } from '@/lib/task/publisher'
describe('task publisher replay', () => {
beforeEach(() => {
taskEventFindManyMock.mockReset()
taskEventCreateMock.mockReset()
taskFindManyMock.mockReset()
redisPublishMock.mockReset()
})
it('replays persisted lifecycle + stream rows in chronological order', async () => {
taskEventFindManyMock.mockResolvedValueOnce([
{
id: 12,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 2,
lane: 'main',
delta: 'world',
},
},
createdAt: new Date('2026-02-27T00:00:02.000Z'),
},
{
id: 11,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.processing',
payload: {
lifecycleType: 'task.processing',
stepId: 'step-1',
stepTitle: '阶段1',
},
createdAt: new Date('2026-02-27T00:00:01.000Z'),
},
{
id: 10,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.ignored',
payload: {},
createdAt: new Date('2026-02-27T00:00:00.000Z'),
},
])
taskFindManyMock.mockResolvedValueOnce([
{
id: 'task-1',
type: 'script_to_storyboard_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
},
])
const events = await listTaskLifecycleEvents('task-1', 50)
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
where: { taskId: 'task-1' },
orderBy: { id: 'desc' },
take: 50,
}))
expect(events).toHaveLength(2)
expect(events.map((event) => event.id)).toEqual(['11', '12'])
expect(events.map((event) => event.type)).toEqual(['task.lifecycle', 'task.stream'])
expect((events[1]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('world')
})
it('persists stream rows when persist=true', async () => {
taskEventCreateMock.mockResolvedValueOnce({
id: 99,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stream: {
kind: 'text',
seq: 1,
lane: 'main',
delta: 'hello',
},
},
createdAt: new Date('2026-02-27T00:00:03.000Z'),
})
redisPublishMock.mockResolvedValueOnce(1)
const message = await publishTaskStreamEvent({
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
taskType: 'story_to_script_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 1,
lane: 'main',
delta: 'hello',
},
},
persist: true,
})
expect(taskEventCreateMock).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
taskId: 'task-1',
eventType: 'task.stream',
}),
}))
expect(redisPublishMock).toHaveBeenCalledTimes(1)
expect(message?.id).toBe('99')
expect(message?.type).toBe('task.stream')
})
it('replays lifecycle + stream rows in listEventsAfter', async () => {
taskEventFindManyMock.mockResolvedValueOnce([
{
id: 101,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 3,
lane: 'main',
delta: 'chunk',
},
},
createdAt: new Date('2026-02-27T00:00:03.000Z'),
},
{
id: 102,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.processing',
payload: {
lifecycleType: 'task.processing',
stepId: 'step-1',
},
createdAt: new Date('2026-02-27T00:00:04.000Z'),
},
])
taskFindManyMock.mockResolvedValueOnce([
{
id: 'task-1',
type: 'story_to_script_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
},
])
const events = await listEventsAfter('project-1', 100, 20)
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
where: {
projectId: 'project-1',
id: { gt: 100 },
},
orderBy: { id: 'asc' },
}))
expect(events).toHaveLength(2)
expect(events.map((event) => event.id)).toEqual(['101', '102'])
expect(events.map((event) => event.type)).toEqual(['task.stream', 'task.lifecycle'])
expect((events[0]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('chunk')
})
})

View File

@@ -0,0 +1,146 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
project: { findUnique: vi.fn() },
novelPromotionProject: { findUnique: vi.fn() },
}))
const llmMock = vi.hoisted(() => ({
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
getCompletionContent: vi.fn(() => '{"ok":true}'),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
const parseMock = vi.hoisted(() => ({
chunkContent: vi.fn(() => ['chunk-1', 'chunk-2']),
safeParseCharactersResponse: vi.fn(() => ({ new_characters: [] })),
safeParseLocationsResponse: vi.fn(() => ({ locations: [] })),
}))
const persistMock = vi.hoisted(() => ({
createAnalyzeGlobalStats: vi.fn((totalChunks: number) => ({
totalChunks,
processedChunks: 0,
newCharacters: 0,
updatedCharacters: 0,
newLocations: 0,
skippedCharacters: 0,
skippedLocations: 0,
})),
persistAnalyzeGlobalChunk: vi.fn(async (args: { stats: { newCharacters: number; newLocations: number } }) => {
args.stats.newCharacters += 1
args.stats.newLocations += 1
}),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
vi.mock('@/lib/workers/handlers/analyze-global-parse', () => ({
CHUNK_SIZE: 3000,
chunkContent: parseMock.chunkContent,
parseAliases: vi.fn(() => []),
readText: (value: unknown) => (typeof value === 'string' ? value : ''),
safeParseCharactersResponse: parseMock.safeParseCharactersResponse,
safeParseLocationsResponse: parseMock.safeParseLocationsResponse,
}))
vi.mock('@/lib/workers/handlers/analyze-global-prompt', () => ({
loadAnalyzeGlobalPromptTemplates: vi.fn(() => ({ characterTemplate: 'c', locationTemplate: 'l' })),
buildAnalyzeGlobalPrompts: vi.fn(() => ({
characterPrompt: 'character prompt',
locationPrompt: 'location prompt',
})),
}))
vi.mock('@/lib/workers/handlers/analyze-global-persist', () => ({
createAnalyzeGlobalStats: persistMock.createAnalyzeGlobalStats,
persistAnalyzeGlobalChunk: persistMock.persistAnalyzeGlobalChunk,
}))
import { handleAnalyzeGlobalTask } from '@/lib/workers/handlers/analyze-global'
function buildJob(): Job<TaskJobData> {
return {
data: {
taskId: 'task-analyze-global-1',
type: TASK_TYPE.ANALYZE_GLOBAL,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'NovelPromotionProject',
targetId: 'np-project-1',
payload: {},
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker analyze-global behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
globalAssetText: '全局设定',
characters: [{ id: 'char-1', name: 'Hero', aliases: null, introduction: 'hero intro' }],
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'old town summary' }],
episodes: [{ id: 'ep-1', name: '第一集', novelText: 'episode text' }],
})
})
it('no analyzable content -> explicit error', async () => {
prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
globalAssetText: '',
characters: [],
locations: [],
episodes: [{ id: 'ep-1', name: '第一集', novelText: '' }],
})
await expect(handleAnalyzeGlobalTask(buildJob())).rejects.toThrow('没有可分析的内容')
})
it('success path -> persists every chunk and returns stats summary', async () => {
const result = await handleAnalyzeGlobalTask(buildJob())
expect(parseMock.chunkContent).toHaveBeenCalled()
expect(persistMock.persistAnalyzeGlobalChunk).toHaveBeenCalledTimes(2)
expect(result).toEqual({
success: true,
stats: {
totalChunks: 2,
newCharacters: 2,
updatedCharacters: 0,
newLocations: 2,
skippedCharacters: 0,
skippedLocations: 0,
totalCharacters: 1,
totalLocations: 1,
},
})
})
})

View File

@@ -0,0 +1,197 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
project: { findUnique: vi.fn() },
novelPromotionProject: {
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
},
novelPromotionEpisode: { findFirst: vi.fn() },
novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) },
novelPromotionLocation: { create: vi.fn(async () => ({ id: 'loc-new-1' })) },
locationImage: { create: vi.fn(async () => ({})) },
}))
const llmMock = vi.hoisted(() => ({
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
getCompletionContent: vi.fn(),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/constants', () => ({
getArtStylePrompt: vi.fn(() => 'cinematic style'),
removeLocationPromptSuffix: vi.fn((text: string) => text.replace(' [SUFFIX]', '')),
}))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: {
NP_AGENT_CHARACTER_PROFILE: 'char',
NP_SELECT_LOCATION: 'loc',
},
buildPrompt: vi.fn(() => 'analysis-prompt'),
}))
import { handleAnalyzeNovelTask } from '@/lib/workers/handlers/analyze-novel'
function buildJob(): Job<TaskJobData> {
return {
data: {
taskId: 'task-analyze-novel-1',
type: TASK_TYPE.ANALYZE_NOVEL,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionProject',
targetId: 'np-project-1',
payload: {},
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker analyze-novel behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.project.findUnique.mockResolvedValue({
id: 'project-1',
mode: 'novel-promotion',
})
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
artStyle: 'cinematic',
globalAssetText: '全局设定文本',
characters: [{ id: 'char-existing', name: '已有角色' }],
locations: [{ id: 'loc-existing', name: '已有场景', summary: 'old' }],
})
prismaMock.novelPromotionEpisode.findFirst.mockResolvedValue({
novelText: '首集内容',
})
llmMock.getCompletionContent
.mockReturnValueOnce(JSON.stringify({
characters: [
{
name: '新角色',
aliases: ['别名A'],
role_level: 'main',
personality_tags: ['冷静'],
visual_keywords: ['黑发'],
},
],
}))
.mockReturnValueOnce(JSON.stringify({
locations: [
{
name: '新地点',
summary: '雨夜街道',
descriptions: ['雨夜街道 [SUFFIX]'],
},
],
}))
})
it('no global text and no episode text -> explicit error', async () => {
prismaMock.novelPromotionProject.findUnique.mockResolvedValueOnce({
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
artStyle: 'cinematic',
globalAssetText: '',
characters: [],
locations: [],
})
prismaMock.novelPromotionEpisode.findFirst.mockResolvedValueOnce({ novelText: '' })
await expect(handleAnalyzeNovelTask(buildJob())).rejects.toThrow('请先填写全局资产设定或剧本内容')
})
it('success path -> creates character/location and persists cleaned location descriptions', async () => {
const result = await handleAnalyzeNovelTask(buildJob())
expect(result).toEqual({
success: true,
characters: [{ id: 'char-new-1' }],
locations: [{ id: 'loc-new-1' }],
characterCount: 1,
locationCount: 1,
})
expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
novelPromotionProjectId: 'np-project-1',
name: '新角色',
aliases: JSON.stringify(['别名A']),
}),
}),
)
expect(prismaMock.novelPromotionLocation.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
novelPromotionProjectId: 'np-project-1',
name: '新地点',
summary: '雨夜街道',
}),
}),
)
expect(prismaMock.locationImage.create).toHaveBeenCalledWith({
data: {
locationId: 'loc-new-1',
imageIndex: 0,
description: '雨夜街道',
},
})
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({
where: { id: 'np-project-1' },
data: { artStylePrompt: 'cinematic style' },
})
expect(workerMock.reportTaskProgress).toHaveBeenCalledWith(
expect.anything(),
60,
expect.objectContaining({
stepId: 'analyze_characters',
done: true,
output: expect.stringContaining('"characters"'),
}),
)
expect(workerMock.reportTaskProgress).toHaveBeenCalledWith(
expect.anything(),
70,
expect.objectContaining({
stepId: 'analyze_locations',
done: true,
output: expect.stringContaining('"locations"'),
}),
)
})
})

View File

@@ -0,0 +1,96 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const configMock = vi.hoisted(() => ({
getUserModelConfig: vi.fn(),
}))
const assetUtilsMock = vi.hoisted(() => ({
aiDesign: vi.fn(),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/config-service', () => configMock)
vi.mock('@/lib/asset-utils', () => assetUtilsMock)
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: workerMock.reportTaskProgress,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: workerMock.assertTaskActive,
}))
import { handleAssetHubAIDesignTask } from '@/lib/workers/handlers/asset-hub-ai-design'
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-asset-ai-design-1',
type,
locale: 'zh',
projectId: 'global-asset-hub',
episodeId: null,
targetType: 'GlobalCharacter',
targetId: 'target-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker asset-hub-ai-design behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
configMock.getUserModelConfig.mockResolvedValue({ analysisModel: 'llm::analysis-default' })
assetUtilsMock.aiDesign.mockResolvedValue({
success: true,
prompt: 'generated prompt',
})
})
it('missing userInstruction -> explicit error', async () => {
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER, {})
await expect(handleAssetHubAIDesignTask(job)).rejects.toThrow('userInstruction is required')
})
it('unsupported task type -> explicit error', async () => {
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, { userInstruction: 'design a hero' })
await expect(handleAssetHubAIDesignTask(job)).rejects.toThrow('Unsupported asset hub ai design task type')
})
it('success uses payload analysisModel override and character assetType', async () => {
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_CHARACTER, {
userInstruction: ' design a heroic character ',
analysisModel: ' llm::analysis-override ',
})
const result = await handleAssetHubAIDesignTask(job)
expect(assetUtilsMock.aiDesign).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
analysisModel: 'llm::analysis-override',
userInstruction: 'design a heroic character',
assetType: 'character',
projectId: 'global-asset-hub',
skipBilling: true,
}))
expect(result).toEqual({ prompt: 'generated prompt' })
})
it('location type success -> passes location assetType', async () => {
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_DESIGN_LOCATION, {
userInstruction: 'design a rainy alley',
})
await handleAssetHubAIDesignTask(job)
expect(assetUtilsMock.aiDesign).toHaveBeenCalledWith(expect.objectContaining({
assetType: 'location',
analysisModel: 'llm::analysis-default',
}))
})
})

View File

@@ -0,0 +1,145 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const llmMock = vi.hoisted(() => ({
chatCompletion: vi.fn(),
getCompletionContent: vi.fn(),
}))
const configMock = vi.hoisted(() => ({
getUserModelConfig: vi.fn(),
}))
const streamContextMock = vi.hoisted(() => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
const llmStreamMock = vi.hoisted(() => {
const flush = vi.fn(async () => undefined)
return {
flush,
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush,
})),
}
})
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/config-service', () => configMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => streamContextMock)
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: workerMock.reportTaskProgress,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: workerMock.assertTaskActive,
}))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: llmStreamMock.createWorkerLLMStreamContext,
createWorkerLLMStreamCallbacks: llmStreamMock.createWorkerLLMStreamCallbacks,
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: {
NP_CHARACTER_MODIFY: 'np_character_modify',
NP_LOCATION_MODIFY: 'np_location_modify',
},
buildPrompt: vi.fn((_args: unknown) => 'final-prompt'),
}))
import { handleAssetHubAIModifyTask } from '@/lib/workers/handlers/asset-hub-ai-modify'
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-asset-ai-modify-1',
type,
locale: 'zh',
projectId: 'global-asset-hub',
episodeId: null,
targetType: 'GlobalCharacter',
targetId: 'target-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker asset-hub-ai-modify behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
configMock.getUserModelConfig.mockResolvedValue({ analysisModel: 'llm::analysis-1' })
llmMock.chatCompletion.mockResolvedValue({ id: 'completion-1' })
llmMock.getCompletionContent.mockReturnValue('{"prompt":"modified description"}')
})
it('missing analysisModel in user config -> explicit error', async () => {
configMock.getUserModelConfig.mockResolvedValueOnce({ analysisModel: '' })
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER, {
characterId: 'char-1',
currentDescription: 'old',
modifyInstruction: 'new',
})
await expect(handleAssetHubAIModifyTask(job)).rejects.toThrow('请先在用户配置中设置分析模型')
})
it('unsupported type -> explicit error', async () => {
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, {
characterId: 'char-1',
currentDescription: 'old',
modifyInstruction: 'new',
})
await expect(handleAssetHubAIModifyTask(job)).rejects.toThrow('Unsupported task type')
})
it('character success -> parses JSON prompt and returns modifiedDescription', async () => {
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_CHARACTER, {
characterId: 'char-1',
currentDescription: 'old character description',
modifyInstruction: 'add armor details',
})
const result = await handleAssetHubAIModifyTask(job)
expect(llmMock.chatCompletion).toHaveBeenCalledWith(
'user-1',
'llm::analysis-1',
[{ role: 'user', content: 'final-prompt' }],
expect.objectContaining({
projectId: 'asset-hub',
action: 'ai_modify_character',
}),
)
expect(result).toEqual({
success: true,
modifiedDescription: 'modified description',
})
expect(llmStreamMock.flush).toHaveBeenCalled()
})
it('location success -> requires locationName and returns modifiedDescription', async () => {
const job = buildJob(TASK_TYPE.ASSET_HUB_AI_MODIFY_LOCATION, {
locationId: 'loc-1',
locationName: 'Old Town',
currentDescription: 'old location description',
modifyInstruction: 'add more fog',
})
const result = await handleAssetHubAIModifyTask(job)
expect(result).toEqual({
success: true,
modifiedDescription: 'modified description',
})
})
})

View File

@@ -0,0 +1,103 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CHARACTER_PROMPT_SUFFIX } from '@/lib/constants'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const workersUtilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => {}),
getUserModels: vi.fn(async () => ({
characterModel: 'character-model-1',
locationModel: 'location-model-1',
})),
}))
const prismaMock = vi.hoisted(() => ({
globalCharacter: {
findFirst: vi.fn(),
},
globalCharacterAppearance: {
update: vi.fn(async () => ({})),
},
globalLocation: {
findFirst: vi.fn(),
},
globalLocationImage: {
update: vi.fn(async () => ({})),
},
}))
const sharedMock = vi.hoisted(() => ({
generateLabeledImageToCos: vi.fn(async () => 'cos/generated-character.png'),
parseJsonStringArray: vi.fn(() => []),
}))
vi.mock('@/lib/workers/utils', () => workersUtilsMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
'@/lib/workers/handlers/image-task-handler-shared',
)
return {
...actual,
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
parseJsonStringArray: sharedMock.parseJsonStringArray,
}
})
import { handleAssetHubImageTask } from '@/lib/workers/handlers/asset-hub-image-task-handler'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-asset-hub-image-1',
type: TASK_TYPE.ASSET_HUB_IMAGE,
locale: 'zh',
projectId: 'project-1',
targetType: 'GlobalCharacter',
targetId: 'global-character-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
function countOccurrences(input: string, target: string) {
if (!target) return 0
return input.split(target).length - 1
}
describe('asset hub character image prompt suffix regression', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.globalCharacter.findFirst.mockResolvedValue({
id: 'global-character-1',
name: 'Hero',
appearances: [
{
id: 'appearance-1',
appearanceIndex: 0,
changeReason: 'base',
description: '主角,黑发,冷静',
descriptions: null,
},
],
})
})
it('keeps character prompt suffix in actual generation prompt', async () => {
const job = buildJob({
type: 'character',
id: 'global-character-1',
appearanceIndex: 0,
})
await handleAssetHubImageTask(job)
const callArg = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as { prompt?: string } | undefined
const prompt = callArg?.prompt || ''
expect(prompt).toContain('主角,黑发,冷静')
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
expect(countOccurrences(prompt, CHARACTER_PROMPT_SUFFIX)).toBe(1)
})
})

View File

@@ -0,0 +1,120 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CHARACTER_PROMPT_SUFFIX } from '@/lib/constants'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => undefined),
getProjectModels: vi.fn(async () => ({ characterModel: 'image-model-1', artStyle: 'noir' })),
toSignedUrlIfCos: vi.fn((url: string | null | undefined) => (url ? `https://signed.example/${url}` : null)),
}))
const outboundMock = vi.hoisted(() => ({
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-primary-ref']),
}))
const prismaMock = vi.hoisted(() => ({
characterAppearance: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(async () => ({})),
},
novelPromotionCharacter: {
findUnique: vi.fn(),
},
}))
const sharedMock = vi.hoisted(() => ({
generateLabeledImageToCos: vi.fn(async () => 'cos/character-generated-0.png'),
}))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/media/outbound-image', () => outboundMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
'@/lib/workers/handlers/image-task-handler-shared',
)
return {
...actual,
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
}
})
import { handleCharacterImageTask } from '@/lib/workers/handlers/character-image-task-handler'
function buildJob(payload: Record<string, unknown>, targetId = 'appearance-2'): Job<TaskJobData> {
return {
data: {
taskId: 'task-character-image-1',
type: TASK_TYPE.IMAGE_CHARACTER,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'CharacterAppearance',
targetId,
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker character-image-task-handler behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.characterAppearance.findUnique.mockResolvedValue({
id: 'appearance-2',
characterId: 'character-1',
appearanceIndex: 1,
descriptions: JSON.stringify(['角色描述A']),
description: '角色描述A',
imageUrls: JSON.stringify([]),
selectedIndex: 0,
imageUrl: null,
changeReason: '战斗形态',
character: { name: 'Hero' },
})
prismaMock.characterAppearance.findFirst.mockResolvedValue({
imageUrl: 'cos/primary.png',
imageUrls: JSON.stringify(['cos/primary.png']),
})
})
it('characterModel not configured -> explicit error', async () => {
utilsMock.getProjectModels.mockResolvedValueOnce({ characterModel: '', artStyle: 'noir' })
await expect(handleCharacterImageTask(buildJob({}))).rejects.toThrow('Character model not configured')
})
it('success path -> uses primary appearance as reference and persists imageUrls', async () => {
const job = buildJob({ imageIndex: 0 })
const result = await handleCharacterImageTask(job)
expect(result).toEqual({
appearanceId: 'appearance-2',
imageCount: 1,
imageUrl: 'cos/character-generated-0.png',
})
const generationInput = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as {
prompt: string
options?: { referenceImages?: string[]; aspectRatio?: string }
}
expect(generationInput.prompt).toContain(CHARACTER_PROMPT_SUFFIX)
expect(generationInput.options).toEqual(expect.objectContaining({
referenceImages: ['normalized-primary-ref'],
aspectRatio: '3:2',
}))
expect(prismaMock.characterAppearance.update).toHaveBeenCalledWith({
where: { id: 'appearance-2' },
data: {
imageUrls: JSON.stringify(['cos/character-generated-0.png']),
imageUrl: 'cos/character-generated-0.png',
},
})
})
})

View File

@@ -0,0 +1,174 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
novelPromotionCharacter: {
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(async () => ({})),
},
characterAppearance: {
create: vi.fn(async () => ({})),
},
}))
const llmMock = vi.hoisted(() => ({
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
getCompletionContent: vi.fn(),
}))
const helperMock = vi.hoisted(() => ({
resolveProjectModel: vi.fn(async () => ({
id: 'project-1',
novelPromotionData: {
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
},
})),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/types/character-profile', () => ({
validateProfileData: vi.fn(() => true),
stringifyProfileData: vi.fn((value: unknown) => JSON.stringify(value)),
}))
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
vi.mock('@/lib/workers/handlers/character-profile-helpers', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/character-profile-helpers')>(
'@/lib/workers/handlers/character-profile-helpers',
)
return {
...actual,
resolveProjectModel: helperMock.resolveProjectModel,
}
})
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_AGENT_CHARACTER_VISUAL: 'np_agent_character_visual' },
buildPrompt: vi.fn(() => 'character-visual-prompt'),
}))
import { handleCharacterProfileTask } from '@/lib/workers/handlers/character-profile'
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-character-profile-1',
type,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'NovelPromotionCharacter',
targetId: 'character-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker character-profile behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
llmMock.getCompletionContent.mockReturnValue(
JSON.stringify({
characters: [
{
appearances: [
{
change_reason: '默认形象',
descriptions: ['黑发,冷静,风衣'],
},
],
},
],
}),
)
prismaMock.novelPromotionCharacter.findFirst.mockImplementation(async (args: { where: { id: string } }) => ({
id: args.where.id,
name: args.where.id === 'character-2' ? 'Villain' : 'Hero',
profileData: JSON.stringify({ archetype: 'lead' }),
profileConfirmed: false,
novelPromotionProjectId: 'np-project-1',
}))
prismaMock.novelPromotionCharacter.findMany.mockResolvedValue([
{
id: 'character-1',
name: 'Hero',
profileData: JSON.stringify({ archetype: 'lead' }),
profileConfirmed: false,
},
{
id: 'character-2',
name: 'Villain',
profileData: JSON.stringify({ archetype: 'antagonist' }),
profileConfirmed: false,
},
])
})
it('unsupported task type -> explicit error', async () => {
const job = buildJob(TASK_TYPE.AI_CREATE_CHARACTER, {})
await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type')
})
it('confirm profile success -> creates appearance and marks profileConfirmed', async () => {
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
const result = await handleCharacterProfileTask(job)
expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({
data: expect.objectContaining({
characterId: 'character-1',
appearanceIndex: 0,
changeReason: '默认形象',
description: '黑发,冷静,风衣',
}),
})
expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({
where: { id: 'character-1' },
data: { profileConfirmed: true },
})
expect(result).toEqual(expect.objectContaining({
success: true,
character: expect.objectContaining({
id: 'character-1',
profileConfirmed: true,
}),
}))
})
it('batch confirm -> loops through all unconfirmed characters and returns count', async () => {
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_BATCH_CONFIRM, {})
const result = await handleCharacterProfileTask(job)
expect(result).toEqual({
success: true,
count: 2,
})
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,157 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
project: { findUnique: vi.fn() },
novelPromotionProject: { findUnique: vi.fn() },
novelPromotionEpisode: { findUnique: vi.fn() },
novelPromotionClip: {
deleteMany: vi.fn(async () => ({})),
create: vi.fn(async () => ({ id: 'clip-row-1' })),
},
}))
const llmMock = vi.hoisted(() => ({
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
getCompletionContent: vi.fn(),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/constants', () => ({
buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),
}))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_AGENT_CLIP: 'np_agent_clip' },
buildPrompt: vi.fn(() => 'clip-split-prompt'),
}))
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
createClipContentMatcher: (content: string) => ({
matchBoundary: (start: string, end: string, fromIndex = 0) => {
const startIndex = content.indexOf(start, fromIndex)
if (startIndex === -1) return null
const endStart = content.indexOf(end, startIndex)
if (endStart === -1) return null
return {
startIndex,
endIndex: endStart + end.length,
}
},
}),
}))
import { handleClipsBuildTask } from '@/lib/workers/handlers/clips-build'
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
return {
data: {
taskId: 'task-clips-build-1',
type: TASK_TYPE.CLIPS_BUILD,
locale: 'zh',
projectId: 'project-1',
episodeId,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker clips-build behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
characters: [{ id: 'char-1', name: 'Hero' }],
locations: [{ id: 'loc-1', name: 'Old Town' }],
})
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
id: 'episode-1',
name: '第一集',
novelPromotionProjectId: 'np-project-1',
novelText: 'A START one END B START two END C',
})
llmMock.getCompletionContent.mockReturnValue(
JSON.stringify([
{
start: 'START one',
end: 'END',
summary: 'first clip',
location: 'Old Town',
characters: ['Hero'],
},
]),
)
})
it('missing episodeId -> explicit error', async () => {
const job = buildJob({}, null)
await expect(handleClipsBuildTask(job)).rejects.toThrow('episodeId is required')
})
it('success path -> creates clip row with concrete boundaries and characters payload', async () => {
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleClipsBuildTask(job)
expect(result).toEqual({
episodeId: 'episode-1',
count: 1,
})
expect(prismaMock.novelPromotionClip.create).toHaveBeenCalledWith({
data: {
episodeId: 'episode-1',
startText: 'START one',
endText: 'END',
summary: 'first clip',
location: 'Old Town',
characters: JSON.stringify(['Hero']),
content: 'START one END',
},
select: { id: true },
})
})
it('AI boundaries cannot be matched -> explicit boundary error', async () => {
llmMock.getCompletionContent.mockReturnValue(
JSON.stringify([
{
start: 'NOT_FOUND_START',
end: 'NOT_FOUND_END',
summary: 'bad clip',
},
]),
)
const job = buildJob({ episodeId: 'episode-1' })
await expect(handleClipsBuildTask(job)).rejects.toThrow('split_clips boundary matching failed')
})
})

View File

@@ -0,0 +1,127 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
project: {
findUnique: vi.fn(async () => ({ id: 'project-1', mode: 'novel-promotion' })),
},
novelPromotionProject: {
findFirst: vi.fn(async () => ({ id: 'np-project-1' })),
},
}))
const llmClientMock = vi.hoisted(() => ({
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
getCompletionContent: vi.fn(() => JSON.stringify({
episodes: [
{
number: 1,
title: '第一集',
summary: '开端',
startMarker: 'START_MARKER',
endMarker: 'END_MARKER',
},
],
})),
}))
const configServiceMock = vi.hoisted(() => ({
getUserModelConfig: vi.fn(async () => ({
analysisModel: 'llm::analysis-model',
})),
}))
const internalStreamMock = vi.hoisted(() => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
const sharedMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => {}),
}))
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => {}),
}))
const llmStreamMock = vi.hoisted(() => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamId: 'stream-1' })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
flush: vi.fn(async () => {}),
})),
}))
const promptMock = vi.hoisted(() => ({
PROMPT_IDS: { NP_EPISODE_SPLIT: 'np_episode_split' },
buildPrompt: vi.fn(() => 'EPISODE_SPLIT_PROMPT'),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmClientMock)
vi.mock('@/lib/config-service', () => configServiceMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => internalStreamMock)
vi.mock('@/lib/workers/shared', () => sharedMock)
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/workers/handlers/llm-stream', () => llmStreamMock)
vi.mock('@/lib/prompt-i18n', () => promptMock)
vi.mock('@/lib/novel-promotion/story-to-script/clip-matching', () => ({
createTextMarkerMatcher: (content: string) => ({
matchMarker: (marker: string, fromIndex = 0) => {
const startIndex = content.indexOf(marker, fromIndex)
if (startIndex === -1) return null
return {
startIndex,
endIndex: startIndex + marker.length,
}
},
}),
}))
import { handleEpisodeSplitTask } from '@/lib/workers/handlers/episode-split'
function buildJob(content: string): Job<TaskJobData> {
return {
data: {
taskId: 'task-episode-split-1',
type: TASK_TYPE.EPISODE_SPLIT_LLM,
locale: 'zh',
projectId: 'project-1',
targetType: 'NovelPromotionProject',
targetId: 'project-1',
payload: { content },
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker episode-split', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fails fast when content is too short', async () => {
const job = buildJob('short text')
await expect(handleEpisodeSplitTask(job)).rejects.toThrow('文本太短,至少需要 100 字')
})
it('returns matched episodes when ai boundaries are valid', async () => {
const content = [
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
'前置内容用于凑长度,确保文本超过一百字。这一段会重复两次以保证长度满足阈值。',
'START_MARKER',
'这里是第一集的正文内容,包含角色冲突与场景推进,长度足够用于单元测试验证。',
'END_MARKER',
'后置内容用于确保边界外还有文本,并继续补足长度。',
].join('')
const job = buildJob(content)
const result = await handleEpisodeSplitTask(job)
expect(result.success).toBe(true)
expect(result.episodes).toHaveLength(1)
expect(result.episodes[0]?.number).toBe(1)
expect(result.episodes[0]?.title).toBe('第一集')
expect(result.episodes[0]?.content).toContain('START_MARKER')
expect(result.episodes[0]?.content).toContain('END_MARKER')
})
})

View File

@@ -0,0 +1,179 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => {}),
getProjectModels: vi.fn(async () => ({ editModel: 'edit-model' })),
getUserModels: vi.fn(async () => ({ editModel: 'edit-model', analysisModel: 'analysis-model' })),
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-image-source'),
stripLabelBar: vi.fn(async () => 'required-reference-image'),
toSignedUrlIfCos: vi.fn(() => 'https://signed/current-image.png'),
uploadImageSourceToCos: vi.fn(async () => 'cos/new-image.png'),
withLabelBar: vi.fn(async (source: unknown) => source),
}))
const outboundImageMock = vi.hoisted(() => ({
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-reference-image']),
normalizeToBase64ForGeneration: vi.fn(async () => 'base64-required-reference'),
}))
const sharedMock = vi.hoisted(() => ({
resolveNovelData: vi.fn(async () => ({ videoRatio: '16:9' })),
}))
const prismaMock = vi.hoisted(() => ({
characterAppearance: {
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
},
locationImage: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(async () => ({})),
},
novelPromotionPanel: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(async () => ({})),
},
novelPromotionProject: {
findUnique: vi.fn(),
},
}))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/media/outbound-image', () => outboundImageMock)
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
'@/lib/workers/handlers/image-task-handler-shared',
)
return {
...actual,
resolveNovelData: sharedMock.resolveNovelData,
}
})
import { handleModifyAssetImageTask } from '@/lib/workers/handlers/image-task-handlers-core'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-1',
type: TASK_TYPE.MODIFY_ASSET_IMAGE,
locale: 'zh',
projectId: 'project-1',
targetType: 'NovelPromotionPanel',
targetId: 'target-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
function readUpdateData(arg: unknown): Record<string, unknown> {
if (!arg || typeof arg !== 'object') return {}
const data = (arg as { data?: unknown }).data
if (!data || typeof data !== 'object') return {}
return data as Record<string, unknown>
}
describe('worker image-task-handlers-core', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fails fast when modify task payload is incomplete', async () => {
const job = buildJob({})
await expect(handleModifyAssetImageTask(job)).rejects.toThrow('modify task missing type/modifyPrompt')
})
it('updates location image with expected generation options and persistence payload', async () => {
prismaMock.locationImage.findUnique.mockResolvedValue({
id: 'location-image-1',
locationId: 'location-1',
imageUrl: 'cos/location-old.png',
location: { name: 'Old Town' },
})
const job = buildJob({
type: 'location',
locationImageId: 'location-image-1',
modifyPrompt: 'add heavy rain',
extraImageUrls: [' https://example.com/location-ref.png '],
generationOptions: { resolution: '1536x1024' },
})
const result = await handleModifyAssetImageTask(job)
expect(result).toEqual({
type: 'location',
locationImageId: 'location-image-1',
imageUrl: 'cos/new-image.png',
})
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
aspectRatio: '1:1',
resolution: '1536x1024',
referenceImages: ['required-reference-image', 'normalized-reference-image'],
}),
}),
)
const updateArg = prismaMock.locationImage.update.mock.calls.at(-1)?.[0]
const updateData = readUpdateData(updateArg)
expect(updateData.previousImageUrl).toBe('cos/location-old.png')
expect(updateData.imageUrl).toBe('cos/new-image.png')
})
it('updates storyboard panel image and keeps candidateImages reset', async () => {
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
id: 'panel-1',
storyboardId: 'storyboard-1',
panelIndex: 0,
imageUrl: 'cos/panel-old.png',
previousImageUrl: null,
})
const job = buildJob({
type: 'storyboard',
panelId: 'panel-1',
modifyPrompt: 'cinematic backlight',
selectedAssets: [{ imageUrl: 'https://example.com/asset-ref.png' }],
extraImageUrls: ['https://example.com/extra-ref.png'],
generationOptions: { resolution: '2048x1152' },
})
const result = await handleModifyAssetImageTask(job)
expect(result).toEqual({
type: 'storyboard',
panelId: 'panel-1',
imageUrl: 'cos/new-image.png',
})
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
aspectRatio: '16:9',
resolution: '2048x1152',
referenceImages: [
'base64-required-reference',
'normalized-reference-image',
],
}),
}),
)
const updateArg = prismaMock.novelPromotionPanel.update.mock.calls.at(-1)?.[0]
const updateData = readUpdateData(updateArg)
expect(updateData.previousImageUrl).toBe('cos/panel-old.png')
expect(updateData.imageUrl).toBe('cos/new-image.png')
expect(updateData.candidateImages).toBeNull()
})
})

View File

@@ -0,0 +1,32 @@
import type { Job } from 'bullmq'
import { describe, expect, it } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
import { handleLLMProxyTask, isLLMProxyTaskType } from '@/lib/workers/handlers/llm-proxy'
function buildJob(type: TaskJobData['type']): Job<TaskJobData> {
return {
data: {
taskId: 'task-llm-proxy-1',
type,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload: { episodeId: 'episode-1' },
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker llm-proxy behavior', () => {
it('current route map has no enabled proxy task type', () => {
expect(isLLMProxyTaskType(TASK_TYPE.STORY_TO_SCRIPT_RUN)).toBe(false)
expect(isLLMProxyTaskType(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)).toBe(false)
})
it('unsupported proxy task type -> explicit error', async () => {
const job = buildJob(TASK_TYPE.STORY_TO_SCRIPT_RUN)
await expect(handleLLMProxyTask(job)).rejects.toThrow('Unsupported llm proxy task type')
})
})

View File

@@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Job } from 'bullmq'
import type { TaskJobData } from '@/lib/task/types'
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
const reportTaskStreamChunkMock = vi.hoisted(() => vi.fn(async () => undefined))
const assertTaskActiveMock = vi.hoisted(() => vi.fn(async () => undefined))
const isTaskActiveMock = vi.hoisted(() => vi.fn(async () => true))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: reportTaskProgressMock,
reportTaskStreamChunk: reportTaskStreamChunkMock,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: assertTaskActiveMock,
}))
vi.mock('@/lib/task/service', () => ({
isTaskActive: isTaskActiveMock,
}))
import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from '@/lib/workers/handlers/llm-stream'
function buildJob(): Job<TaskJobData> {
const data: TaskJobData = {
taskId: 'task-1',
type: 'story_to_script_run',
locale: 'zh',
projectId: 'project-1',
userId: 'user-1',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload: {},
trace: null,
}
return {
data,
} as unknown as Job<TaskJobData>
}
describe('createWorkerLLMStreamCallbacks', () => {
beforeEach(() => {
reportTaskProgressMock.mockReset()
reportTaskStreamChunkMock.mockReset()
assertTaskActiveMock.mockReset()
isTaskActiveMock.mockReset()
isTaskActiveMock.mockResolvedValue(true)
})
it('publishes final step output on onComplete for replay recovery', async () => {
const job = buildJob()
const context = createWorkerLLMStreamContext(job, 'story_to_script')
const callbacks = createWorkerLLMStreamCallbacks(job, context)
callbacks.onStage({
stage: 'streaming',
provider: 'ark',
step: {
id: 'screenplay_clip_1',
attempt: 2,
title: 'progress.streamStep.screenplayConversion',
index: 1,
total: 1,
},
})
callbacks.onComplete('final screenplay text', {
id: 'screenplay_clip_1',
attempt: 2,
title: 'progress.streamStep.screenplayConversion',
index: 1,
total: 1,
})
await callbacks.flush()
const finalProgressCall = reportTaskProgressMock.mock.calls.find((call) => {
const payload = call[2] as Record<string, unknown> | undefined
return payload?.stage === 'worker_llm_complete'
})
expect(finalProgressCall).toBeDefined()
const payload = finalProgressCall?.[2] as Record<string, unknown>
expect(payload.done).toBe(true)
expect(payload.output).toBe('final screenplay text')
expect(payload.stepId).toBe('screenplay_clip_1')
expect(payload.stepAttempt).toBe(2)
expect(payload.stepTitle).toBe('progress.streamStep.screenplayConversion')
expect(payload.stepIndex).toBe(1)
expect(payload.stepTotal).toBe(1)
})
it('keeps completion payload bound to provided step under interleaved steps', async () => {
const job = buildJob()
const context = createWorkerLLMStreamContext(job, 'story_to_script')
const callbacks = createWorkerLLMStreamCallbacks(job, context)
callbacks.onChunk({
kind: 'text',
delta: 'A-',
seq: 1,
lane: 'main',
step: { id: 'analyze_characters', attempt: 1, title: 'A', index: 1, total: 2 },
})
callbacks.onChunk({
kind: 'text',
delta: 'B-',
seq: 1,
lane: 'main',
step: { id: 'analyze_locations', attempt: 1, title: 'B', index: 2, total: 2 },
})
callbacks.onComplete('characters-final', {
id: 'analyze_characters',
attempt: 1,
title: 'A',
index: 1,
total: 2,
})
await callbacks.flush()
const finalProgressCall = reportTaskProgressMock.mock.calls.find((call) => {
const payload = call[2] as Record<string, unknown> | undefined
return payload?.stage === 'worker_llm_complete'
})
expect(finalProgressCall).toBeDefined()
const payload = finalProgressCall?.[2] as Record<string, unknown>
expect(payload.stepId).toBe('analyze_characters')
expect(payload.stepTitle).toBe('A')
expect(payload.output).toBe('characters-final')
})
})

View File

@@ -0,0 +1,109 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => undefined),
getProjectModels: vi.fn(async () => ({ locationModel: 'location-model-1', artStyle: 'anime' })),
}))
const prismaMock = vi.hoisted(() => ({
locationImage: {
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
},
novelPromotionLocation: {
findUnique: vi.fn(),
findMany: vi.fn(async () => []),
},
}))
const sharedMock = vi.hoisted(() => ({
generateLabeledImageToCos: vi.fn(async () => 'cos/location-generated-1.png'),
}))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
'@/lib/workers/handlers/image-task-handler-shared',
)
return {
...actual,
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
}
})
import { handleLocationImageTask } from '@/lib/workers/handlers/location-image-task-handler'
function buildJob(payload: Record<string, unknown>, targetId = 'location-image-1'): Job<TaskJobData> {
return {
data: {
taskId: 'task-location-image-1',
type: TASK_TYPE.IMAGE_LOCATION,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'LocationImage',
targetId,
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker location-image-task-handler behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.locationImage.findUnique.mockResolvedValue({
id: 'location-image-1',
locationId: 'location-1',
imageIndex: 0,
description: '雨夜街道',
location: { name: 'Old Town' },
})
prismaMock.novelPromotionLocation.findUnique.mockResolvedValue({
id: 'location-1',
name: 'Old Town',
images: [
{
id: 'location-image-1',
locationId: 'location-1',
imageIndex: 0,
description: '雨夜街道',
},
],
})
})
it('locationModel missing -> explicit error', async () => {
utilsMock.getProjectModels.mockResolvedValueOnce({ locationModel: '', artStyle: 'anime' })
await expect(handleLocationImageTask(buildJob({}))).rejects.toThrow('Location model not configured')
})
it('success path -> generates and persists concrete location image url', async () => {
const result = await handleLocationImageTask(buildJob({ imageIndex: 0 }))
expect(result).toEqual({
updated: 1,
locationIds: ['location-1'],
})
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '雨夜街道',
label: 'Old Town',
targetId: 'location-image-1',
options: expect.objectContaining({ aspectRatio: '1:1' }),
}),
)
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
where: { id: 'location-image-1' },
data: { imageUrl: 'cos/location-generated-1.png' },
})
})
})

View File

@@ -0,0 +1,183 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => {}),
getProjectModels: vi.fn(async () => ({ editModel: 'edit-model' })),
getUserModels: vi.fn(async () => ({ editModel: 'edit-model', analysisModel: 'analysis-model' })),
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-image-source'),
stripLabelBar: vi.fn(async () => 'required-reference-image'),
toSignedUrlIfCos: vi.fn(() => 'https://signed/current-image.png'),
uploadImageSourceToCos: vi.fn(async () => 'cos/new-image.png'),
withLabelBar: vi.fn(async (source: unknown) => source),
}))
const outboundImageMock = vi.hoisted(() => ({
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-reference-image']),
normalizeToBase64ForGeneration: vi.fn(async () => 'base64-reference'),
}))
const llmClientMock = vi.hoisted(() => ({
chatCompletionWithVision: vi.fn(async () => ({ output_text: 'AI_EXTRACTED_DESCRIPTION' })),
getCompletionContent: vi.fn(() => 'AI_EXTRACTED_DESCRIPTION'),
}))
const promptMock = vi.hoisted(() => ({
PROMPT_IDS: {
CHARACTER_IMAGE_TO_DESCRIPTION: 'character_image_to_description',
},
buildPrompt: vi.fn(() => 'vision-prompt-template'),
}))
const loggerWarnMock = vi.hoisted(() => vi.fn())
const loggingMock = vi.hoisted(() => ({
createScopedLogger: vi.fn(() => ({
warn: loggerWarnMock,
})),
}))
const prismaMock = vi.hoisted(() => ({
characterAppearance: {
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
},
locationImage: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(async () => ({})),
},
novelPromotionPanel: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(async () => ({})),
},
novelPromotionProject: {
findUnique: vi.fn(),
},
globalCharacter: {
findFirst: vi.fn(),
},
globalCharacterAppearance: {
update: vi.fn(async () => ({})),
},
globalLocation: {
findFirst: vi.fn(),
},
globalLocationImage: {
update: vi.fn(async () => ({})),
},
}))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/media/outbound-image', () => outboundImageMock)
vi.mock('@/lib/llm-client', () => llmClientMock)
vi.mock('@/lib/prompt-i18n', () => promptMock)
vi.mock('@/lib/logging/core', () => loggingMock)
vi.mock('@/lib/prisma', () => ({
prisma: prismaMock,
}))
import { handleModifyAssetImageTask } from '@/lib/workers/handlers/image-task-handlers-core'
import { handleAssetHubModifyTask } from '@/lib/workers/handlers/asset-hub-modify-task-handler'
function buildJob(type: TaskType, payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-1',
type,
locale: 'zh',
projectId: 'project-1',
targetType: 'GlobalCharacter',
targetId: 'target-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
function getUpdateData(callArg: unknown): Record<string, unknown> {
if (!callArg || typeof callArg !== 'object') return {}
const maybeData = (callArg as { data?: unknown }).data
if (!maybeData || typeof maybeData !== 'object') return {}
return maybeData as Record<string, unknown>
}
describe('modify image with references writes real description', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.characterAppearance.findUnique.mockResolvedValue({
id: 'appearance-1',
imageUrls: JSON.stringify(['cos/original-image.png']),
imageUrl: 'cos/original-image.png',
selectedIndex: 0,
changeReason: 'base',
description: 'old description',
character: { name: 'Hero' },
})
prismaMock.globalCharacter.findFirst.mockResolvedValue({
id: 'global-character-1',
name: 'Hero',
appearances: [
{
id: 'global-appearance-1',
appearanceIndex: 0,
changeReason: 'base',
imageUrl: 'cos/original-global.png',
imageUrls: JSON.stringify(['cos/original-global.png']),
selectedIndex: 0,
},
],
})
})
it('updates character appearance description from vision output in project modify handler', async () => {
const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {
type: 'character',
appearanceId: 'appearance-1',
modifyPrompt: 'enhance details',
extraImageUrls: [' https://ref.example/a.png '],
})
await handleModifyAssetImageTask(job)
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
aspectRatio: '3:2',
referenceImages: ['required-reference-image', 'normalized-reference-image'],
}),
}),
)
const updateArg = prismaMock.characterAppearance.update.mock.calls.at(-1)?.[0]
const updateData = getUpdateData(updateArg)
expect(updateData.description).toBe('AI_EXTRACTED_DESCRIPTION')
expect(updateData.previousDescription).toBe('old description')
expect(updateData.imageUrl).toBe('cos/new-image.png')
})
it('updates asset-hub character description from vision output when reference image exists', async () => {
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-image.png')
const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {
type: 'character',
id: 'global-character-1',
appearanceIndex: 0,
imageIndex: 0,
modifyPrompt: 'make it sharper',
extraImageUrls: ['https://ref.example/b.png'],
})
await handleAssetHubModifyTask(job)
const updateArg = prismaMock.globalCharacterAppearance.update.mock.calls.at(-1)?.[0]
const updateData = getUpdateData(updateArg)
expect(updateData.description).toBe('AI_EXTRACTED_DESCRIPTION')
expect(updateData.imageUrl).toBe('cos/new-global-image.png')
expect(updateData.imageUrls).toBe(JSON.stringify(['cos/new-global-image.png']))
})
})

View File

@@ -0,0 +1,187 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
novelPromotionPanel: {
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
},
}))
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => undefined),
getProjectModels: vi.fn(async () => ({ storyboardModel: 'storyboard-model-1', artStyle: 'cinematic' })),
resolveImageSourceFromGeneration: vi.fn(),
uploadImageSourceToCos: vi.fn(),
}))
const sharedMock = vi.hoisted(() => ({
collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-1.png']),
resolveNovelData: vi.fn(async () => ({
videoRatio: '16:9',
characters: [],
locations: [],
})),
}))
const outboundMock = vi.hoisted(() => ({
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-ref-1']),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/media/outbound-image', () => outboundMock)
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: vi.fn(async () => undefined) }))
vi.mock('@/lib/logging/core', () => ({
logInfo: vi.fn(),
createScopedLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
event: vi.fn(),
child: vi.fn(),
})),
}))
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
'@/lib/workers/handlers/image-task-handler-shared',
)
return {
...actual,
collectPanelReferenceImages: sharedMock.collectPanelReferenceImages,
resolveNovelData: sharedMock.resolveNovelData,
}
})
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_SINGLE_PANEL_IMAGE: 'np_single_panel_image' },
buildPrompt: vi.fn(() => 'panel-image-prompt'),
}))
import { handlePanelImageTask } from '@/lib/workers/handlers/panel-image-task-handler'
function buildJob(payload: Record<string, unknown>, targetId = 'panel-1'): Job<TaskJobData> {
return {
data: {
taskId: 'task-panel-image-1',
type: TASK_TYPE.IMAGE_PANEL,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId,
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker panel-image-task-handler behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
id: 'panel-1',
storyboardId: 'storyboard-1',
panelIndex: 0,
shotType: 'close-up',
cameraMove: 'static',
description: 'hero close-up',
videoPrompt: 'dramatic',
location: 'Old Town',
characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),
srtSegment: '台词片段',
photographyRules: null,
actingNotes: null,
sketchImageUrl: null,
imageUrl: null,
})
utilsMock.resolveImageSourceFromGeneration
.mockResolvedValueOnce('generated-source-1')
.mockResolvedValueOnce('generated-source-2')
utilsMock.uploadImageSourceToCos
.mockResolvedValueOnce('cos/panel-candidate-1.png')
.mockResolvedValueOnce('cos/panel-candidate-2.png')
})
it('missing panelId -> explicit error', async () => {
const job = buildJob({}, '')
await expect(handlePanelImageTask(job)).rejects.toThrow('panelId missing')
})
it('first generation -> persists main image and candidate list', async () => {
const job = buildJob({ candidateCount: 2 })
const result = await handlePanelImageTask(job)
expect(result).toEqual({
panelId: 'panel-1',
candidateCount: 2,
imageUrl: 'cos/panel-candidate-1.png',
})
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
modelId: 'storyboard-model-1',
prompt: 'panel-image-prompt',
options: expect.objectContaining({
referenceImages: ['normalized-ref-1'],
aspectRatio: '16:9',
}),
}),
)
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-1' },
data: {
imageUrl: 'cos/panel-candidate-1.png',
candidateImages: JSON.stringify(['cos/panel-candidate-1.png', 'cos/panel-candidate-2.png']),
},
})
})
it('regeneration branch -> keeps old image in previousImageUrl and stores candidates only', async () => {
utilsMock.resolveImageSourceFromGeneration.mockReset()
utilsMock.uploadImageSourceToCos.mockReset()
prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce({
id: 'panel-1',
storyboardId: 'storyboard-1',
panelIndex: 0,
shotType: 'close-up',
cameraMove: 'static',
description: 'hero close-up',
videoPrompt: 'dramatic',
location: 'Old Town',
characters: '[]',
srtSegment: null,
photographyRules: null,
actingNotes: null,
sketchImageUrl: null,
imageUrl: 'cos/panel-old.png',
})
utilsMock.resolveImageSourceFromGeneration.mockResolvedValueOnce('generated-source-regen')
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/panel-regenerated.png')
const job = buildJob({ candidateCount: 1 })
const result = await handlePanelImageTask(job)
expect(result).toEqual({
panelId: 'panel-1',
candidateCount: 1,
imageUrl: null,
})
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-1' },
data: {
previousImageUrl: 'cos/panel-old.png',
candidateImages: JSON.stringify(['cos/panel-regenerated.png']),
},
})
})
})

View File

@@ -0,0 +1,143 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
novelPromotionPanel: {
findUnique: vi.fn(),
update: vi.fn(async () => ({})),
},
}))
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => undefined),
getProjectModels: vi.fn(async () => ({ storyboardModel: 'storyboard-model-1', artStyle: 'cinematic' })),
resolveImageSourceFromGeneration: vi.fn(async () => 'generated-variant-source'),
toSignedUrlIfCos: vi.fn((url: string | null | undefined) => (url ? `https://signed.example/${url}` : null)),
uploadImageSourceToCos: vi.fn(async () => 'cos/panel-variant-new.png'),
}))
const sharedMock = vi.hoisted(() => ({
collectPanelReferenceImages: vi.fn(async () => ['https://signed.example/ref-character.png']),
resolveNovelData: vi.fn(async () => ({
videoRatio: '16:9',
characters: [{ name: 'Hero', introduction: '主角' }],
locations: [{ name: 'Old Town' }],
})),
}))
const outboundMock = vi.hoisted(() => ({
normalizeReferenceImagesForGeneration: vi.fn(async (refs: string[]) => refs.map((item) => `normalized:${item}`)),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/media/outbound-image', () => outboundMock)
vi.mock('@/lib/logging/core', () => ({ logInfo: vi.fn() }))
vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
const actual = await vi.importActual<typeof import('@/lib/workers/handlers/image-task-handler-shared')>(
'@/lib/workers/handlers/image-task-handler-shared',
)
return {
...actual,
collectPanelReferenceImages: sharedMock.collectPanelReferenceImages,
resolveNovelData: sharedMock.resolveNovelData,
}
})
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_GENERATE: 'np_agent_shot_variant_generate' },
buildPrompt: vi.fn(() => 'panel-variant-prompt'),
}))
import { handlePanelVariantTask } from '@/lib/workers/handlers/panel-variant-task-handler'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-panel-variant-1',
type: TASK_TYPE.PANEL_VARIANT,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-new',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker panel-variant-task-handler behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.novelPromotionPanel.findUnique.mockImplementation(async (args: { where: { id: string } }) => {
if (args.where.id === 'panel-new') {
return {
id: 'panel-new',
storyboardId: 'storyboard-1',
imageUrl: null,
location: 'Old Town',
characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),
}
}
if (args.where.id === 'panel-source') {
return {
id: 'panel-source',
storyboardId: 'storyboard-1',
imageUrl: 'cos/panel-source.png',
description: 'source description',
shotType: 'medium',
cameraMove: 'pan',
location: 'Old Town',
characters: JSON.stringify([{ name: 'Hero' }]),
}
}
return null
})
})
it('missing source/new panel ids -> explicit error', async () => {
const job = buildJob({})
await expect(handlePanelVariantTask(job)).rejects.toThrow('panel_variant missing newPanelId/sourcePanelId')
})
it('success path -> includes source panel image in referenceImages and persists new image', async () => {
const payload = {
newPanelId: 'panel-new',
sourcePanelId: 'panel-source',
variant: {
title: '雨夜版本',
description: '加强雨夜氛围',
},
}
const result = await handlePanelVariantTask(buildJob(payload))
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
modelId: 'storyboard-model-1',
prompt: 'panel-variant-prompt',
options: expect.objectContaining({
aspectRatio: '16:9',
referenceImages: [
'normalized:https://signed.example/cos/panel-source.png',
'normalized:https://signed.example/ref-character.png',
],
}),
}),
)
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-new' },
data: { imageUrl: 'cos/panel-variant-new.png' },
})
expect(result).toEqual({
panelId: 'panel-new',
storyboardId: 'storyboard-1',
imageUrl: 'cos/panel-variant-new.png',
})
})
})

View File

@@ -0,0 +1,215 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CHARACTER_PROMPT_SUFFIX, CHARACTER_IMAGE_BANANA_RATIO } from '@/lib/constants'
import { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'
const sharpMock = vi.hoisted(() =>
vi.fn(() => {
const chain = {
metadata: vi.fn(async () => ({ width: 2160, height: 2160 })),
extend: vi.fn(() => chain),
composite: vi.fn(() => chain),
jpeg: vi.fn(() => chain),
toBuffer: vi.fn(async () => Buffer.from('processed-image')),
}
return chain
}),
)
const generatorApiMock = vi.hoisted(() => ({
generateImage: vi.fn(async () => ({
success: true,
imageUrl: 'https://example.com/generated.jpg',
async: false,
})),
}))
const asyncSubmitMock = vi.hoisted(() => ({
queryFalStatus: vi.fn(async () => ({ completed: false, failed: false, resultUrl: null })),
}))
const arkApiMock = vi.hoisted(() => ({
fetchWithTimeoutAndRetry: vi.fn(async () => ({
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
})),
}))
const apiConfigMock = vi.hoisted(() => ({
getProviderConfig: vi.fn(async () => ({ apiKey: 'fal-key' })),
}))
const configServiceMock = vi.hoisted(() => ({
getUserModelConfig: vi.fn(async () => ({
characterModel: 'character-model-1',
analysisModel: 'analysis-model-1',
})),
}))
const llmClientMock = vi.hoisted(() => ({
chatCompletionWithVision: vi.fn(async () => ({ output_text: 'AI_EXTRACTED_DESCRIPTION' })),
getCompletionContent: vi.fn(() => 'AI_EXTRACTED_DESCRIPTION'),
}))
const cosMock = vi.hoisted(() => {
let keyIndex = 0
return {
generateUniqueKey: vi.fn(() => `reference-key-${++keyIndex}.jpg`),
getSignedUrl: vi.fn((key: string) => `https://signed.example/${key}`),
uploadToCOS: vi.fn(async (_buffer: Buffer, key: string) => `cos/${key}`),
}
})
const fontsMock = vi.hoisted(() => ({
initializeFonts: vi.fn(async () => {}),
createLabelSVG: vi.fn(async () => Buffer.from('<svg />')),
}))
const workersSharedMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => {}),
}))
const workersUtilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => {}),
}))
const promptI18nMock = vi.hoisted(() => ({
PROMPT_IDS: {
CHARACTER_IMAGE_TO_DESCRIPTION: 'character_image_to_description',
CHARACTER_REFERENCE_TO_SHEET: 'character_reference_to_sheet',
},
buildPrompt: vi.fn((input: { promptId: string }) => (
input.promptId === 'character_reference_to_sheet'
? 'BASE_REFERENCE_PROMPT'
: 'ANALYSIS_PROMPT'
)),
}))
const prismaMock = vi.hoisted(() => ({
globalCharacterAppearance: {
update: vi.fn(async () => ({})),
},
characterAppearance: {
update: vi.fn(async () => ({})),
},
}))
vi.mock('sharp', () => ({
default: sharpMock,
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/generator-api', () => generatorApiMock)
vi.mock('@/lib/async-submit', () => asyncSubmitMock)
vi.mock('@/lib/ark-api', () => arkApiMock)
vi.mock('@/lib/api-config', () => apiConfigMock)
vi.mock('@/lib/config-service', () => configServiceMock)
vi.mock('@/lib/llm-client', () => llmClientMock)
vi.mock('@/lib/cos', () => cosMock)
vi.mock('@/lib/fonts', () => fontsMock)
vi.mock('@/lib/workers/shared', () => workersSharedMock)
vi.mock('@/lib/workers/utils', () => workersUtilsMock)
vi.mock('@/lib/prompt-i18n', () => promptI18nMock)
import { handleReferenceToCharacterTask } from '@/lib/workers/handlers/reference-to-character'
function buildJob(payload: Record<string, unknown>, type: TaskType): Job<TaskJobData> {
return {
data: {
taskId: 'task-1',
type,
locale: 'zh',
projectId: 'project-1',
targetType: 'GlobalCharacter',
targetId: 'target-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
function readGenerateCall(index: number) {
const call = generatorApiMock.generateImage.mock.calls[index]
if (!call) {
return {
prompt: '',
options: {} as Record<string, unknown>,
}
}
const prompt = typeof call[2] === 'string' ? call[2] : ''
const options = (typeof call[3] === 'object' && call[3]) ? call[3] as Record<string, unknown> : {}
return { prompt, options }
}
describe('worker reference-to-character', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fails fast when reference images are missing', async () => {
const job = buildJob({}, TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER)
await expect(handleReferenceToCharacterTask(job)).rejects.toThrow('Missing referenceImageUrl or referenceImageUrls')
})
it('fails fast on unsupported task type', async () => {
const job = buildJob(
{ referenceImageUrl: 'https://example.com/ref.png' },
'unsupported-task' as TaskType,
)
await expect(handleReferenceToCharacterTask(job)).rejects.toThrow('Unsupported task type')
})
it('uses suffix prompt and disables reference-image injection when customDescription is provided', async () => {
const job = buildJob(
{
referenceImageUrls: ['https://example.com/ref-a.png', 'https://example.com/ref-b.png'],
customDescription: '冷静黑发角色',
characterName: 'Hero',
},
TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
)
const result = await handleReferenceToCharacterTask(job)
expect(result).toEqual(expect.objectContaining({ success: true }))
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
const { prompt, options } = readGenerateCall(0)
expect(prompt).toContain('冷静黑发角色')
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
expect(options.aspectRatio).toBe(CHARACTER_IMAGE_BANANA_RATIO)
expect(options.referenceImages).toBeUndefined()
})
it('keeps three-view suffix in template flow and writes extracted description in background mode', async () => {
const job = buildJob(
{
referenceImageUrls: [' https://example.com/ref-a.png ', 'https://example.com/ref-b.png'],
isBackgroundJob: true,
characterId: 'character-1',
appearanceId: 'appearance-1',
characterName: 'Hero',
},
TASK_TYPE.ASSET_HUB_REFERENCE_TO_CHARACTER,
)
const result = await handleReferenceToCharacterTask(job)
expect(result).toEqual({ success: true })
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
const { prompt, options } = readGenerateCall(0)
expect(prompt).toContain('BASE_REFERENCE_PROMPT')
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
expect(options.referenceImages).toEqual(['https://example.com/ref-a.png', 'https://example.com/ref-b.png'])
expect(options.aspectRatio).toBe(CHARACTER_IMAGE_BANANA_RATIO)
const updateArg = prismaMock.globalCharacterAppearance.update.mock.calls[0]?.[0] as {
data?: Record<string, unknown>
where?: Record<string, unknown>
} | undefined
const updateData = updateArg?.data || {}
expect(updateArg?.where).toEqual({ id: 'appearance-1' })
expect(updateData.description).toBe('AI_EXTRACTED_DESCRIPTION')
expect(typeof updateData.imageUrls).toBe('string')
expect(updateData.imageUrl).toMatch(/^cos\/reference-key-\d+\.jpg$/)
})
})

View File

@@ -0,0 +1,141 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
project: { findUnique: vi.fn() },
novelPromotionProject: { findUnique: vi.fn() },
novelPromotionEpisode: { findUnique: vi.fn() },
novelPromotionClip: { update: vi.fn(async () => ({})) },
}))
const llmMock = vi.hoisted(() => ({
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
getCompletionContent: vi.fn(() => '{"scenes":[{"index":1}]}'),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
const helpersMock = vi.hoisted(() => ({
parseScreenplayPayload: vi.fn(() => ({ scenes: [{ index: 1 }] })),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/constants', () => ({
buildCharactersIntroduction: vi.fn(() => 'characters introduction'),
}))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/logging/semantic', () => ({ logAIAnalysis: vi.fn() }))
vi.mock('@/lib/logging/file-writer', () => ({ onProjectNameAvailable: vi.fn() }))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
vi.mock('@/lib/workers/handlers/screenplay-convert-helpers', () => ({
readText: (value: unknown) => (typeof value === 'string' ? value : ''),
parseScreenplayPayload: helpersMock.parseScreenplayPayload,
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_SCREENPLAY_CONVERSION: 'np_screenplay_conversion' },
getPromptTemplate: vi.fn(() => 'screenplay-template-{clip_content}-{clip_id}'),
}))
import { handleScreenplayConvertTask } from '@/lib/workers/handlers/screenplay-convert'
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
return {
data: {
taskId: 'task-screenplay-1',
type: TASK_TYPE.SCREENPLAY_CONVERT,
locale: 'zh',
projectId: 'project-1',
episodeId,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker screenplay-convert behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.project.findUnique.mockResolvedValue({
id: 'project-1',
name: 'Project One',
mode: 'novel-promotion',
})
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
characters: [{ name: 'Hero' }],
locations: [{ name: 'Old Town' }],
})
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
id: 'episode-1',
novelPromotionProjectId: 'np-project-1',
clips: [
{
id: 'clip-1',
content: 'clip 1 content',
},
],
})
})
it('missing episodeId -> explicit error', async () => {
const job = buildJob({}, null)
await expect(handleScreenplayConvertTask(job)).rejects.toThrow('episodeId is required')
})
it('success path -> writes screenplay json to clip row', async () => {
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleScreenplayConvertTask(job)
expect(result).toEqual(expect.objectContaining({
episodeId: 'episode-1',
total: 1,
successCount: 1,
failCount: 0,
totalScenes: 1,
}))
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
where: { id: 'clip-1' },
data: {
screenplay: JSON.stringify({
scenes: [{ index: 1 }],
clip_id: 'clip-1',
original_text: 'clip 1 content',
}),
},
})
})
it('clip parse failed -> throws partial failure error with code prefix', async () => {
helpersMock.parseScreenplayPayload.mockImplementation(() => {
throw new Error('invalid screenplay payload')
})
const job = buildJob({ episodeId: 'episode-1' })
await expect(handleScreenplayConvertTask(job)).rejects.toThrow('SCREENPLAY_CONVERT_PARTIAL_FAILED')
})
})

View File

@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { runScriptToStoryboardOrchestrator } from '@/lib/novel-promotion/script-to-storyboard/orchestrator'
describe('script-to-storyboard orchestrator retry', () => {
afterEach(() => {
delete process.env.NP_SCRIPT_TO_STORYBOARD_CONCURRENCY
})
it('retries retryable step failures up to 3 attempts', async () => {
const attemptsByAction = new Map<string, number>()
const phase1Metas: Array<{ stepId: string; stepAttempt?: number }> = []
const runStep = vi.fn(async (meta, _prompt, action: string) => {
attemptsByAction.set(action, (attemptsByAction.get(action) || 0) + 1)
if (action === 'storyboard_phase1_plan') {
phase1Metas.push({ stepId: meta.stepId, stepAttempt: meta.stepAttempt })
const attempt = attemptsByAction.get(action) || 0
if (attempt < 3) {
throw new TypeError('terminated')
}
return {
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
reasoning: '',
}
}
if (action === 'storyboard_phase2_cinematography') {
return { text: JSON.stringify([{ panel_number: 1, composition: '居中' }]), reasoning: '' }
}
if (action === 'storyboard_phase2_acting') {
return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }
}
return {
text: JSON.stringify([{ panel_number: 1, description: '镜头', location: '场景A', source_text: '原文', characters: [] }]),
reasoning: '',
}
})
const result = await runScriptToStoryboardOrchestrator({
clips: [
{
id: 'clip-1',
content: '文本',
characters: JSON.stringify([{ name: '角色A' }]),
location: '场景A',
screenplay: null,
},
],
novelPromotionData: {
characters: [{ name: '角色A', appearances: [] }],
locations: [{ name: '场景A', images: [] }],
},
promptTemplates: {
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
},
runStep,
})
expect(result.summary.clipCount).toBe(1)
expect(runStep).toHaveBeenCalled()
expect(attemptsByAction.get('storyboard_phase1_plan')).toBe(3)
expect(phase1Metas).toEqual([
{ stepId: 'clip_clip-1_phase1', stepAttempt: undefined },
{ stepId: 'clip_clip-1_phase1', stepAttempt: 2 },
{ stepId: 'clip_clip-1_phase1', stepAttempt: 3 },
])
})
it('does not retry non-retryable step failure', async () => {
let callCount = 0
const runStep = vi.fn(async () => {
callCount += 1
throw new Error('SENSITIVE_CONTENT: blocked')
})
await expect(
runScriptToStoryboardOrchestrator({
clips: [
{
id: 'clip-1',
content: '文本',
characters: JSON.stringify([{ name: '角色A' }]),
location: '场景A',
screenplay: null,
},
],
novelPromotionData: {
characters: [{ name: '角色A', appearances: [] }],
locations: [{ name: '场景A', images: [] }],
},
promptTemplates: {
phase1PlanTemplate: '{clip_content} {clip_json} {characters_lib_name} {locations_lib_name} {characters_introduction} {characters_appearance_list} {characters_full_description}',
phase2CinematographyTemplate: '{panels_json} {panel_count} {locations_description} {characters_info}',
phase2ActingTemplate: '{panels_json} {panel_count} {characters_info}',
phase3DetailTemplate: '{panels_json} {characters_age_gender} {locations_description}',
},
runStep,
}),
).rejects.toThrow('SENSITIVE_CONTENT')
expect(callCount).toBe(1)
})
})

View File

@@ -0,0 +1,297 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type VoiceLineInput = {
lineIndex: number
speaker: string
content: string
emotionStrength: number
matchedPanel: {
storyboardId: string
panelIndex: number
}
}
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
const assertTaskActiveMock = vi.hoisted(() => vi.fn(async () => undefined))
const chatCompletionMock = vi.hoisted(() => vi.fn(async () => ({ responseId: 'resp-1' })))
const getCompletionPartsMock = vi.hoisted(() => vi.fn(() => ({ text: 'voice lines json', reasoning: '' })))
const resolveProjectModelCapabilityGenerationOptionsMock = vi.hoisted(() =>
vi.fn(async () => ({ reasoningEffort: 'high' })),
)
const runScriptToStoryboardOrchestratorMock = vi.hoisted(() =>
vi.fn(async () => ({
clipPanels: [
{
clipId: 'clip-1',
panels: [
{
panelIndex: 1,
shotType: 'close-up',
cameraMove: 'static',
description: 'panel desc',
videoPrompt: 'panel prompt',
location: 'room',
characters: ['Narrator'],
},
],
},
],
summary: {
totalPanelCount: 1,
totalStepCount: 4,
},
})),
)
const parseVoiceLinesJsonMock = vi.hoisted(() => vi.fn())
const persistStoryboardsAndPanelsMock = vi.hoisted(() => vi.fn())
const txState = vi.hoisted(() => ({
createdRows: [] as Array<Record<string, unknown>>,
}))
const prismaMock = vi.hoisted(() => ({
project: {
findUnique: vi.fn(),
},
novelPromotionProject: {
findUnique: vi.fn(),
},
novelPromotionEpisode: {
findUnique: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => ({
chatCompletion: chatCompletionMock,
getCompletionParts: getCompletionPartsMock,
}))
vi.mock('@/lib/config-service', () => ({
resolveProjectModelCapabilityGenerationOptions: resolveProjectModelCapabilityGenerationOptionsMock,
}))
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/logging/semantic', () => ({
logAIAnalysis: vi.fn(),
}))
vi.mock('@/lib/logging/file-writer', () => ({
onProjectNameAvailable: vi.fn(),
}))
vi.mock('@/lib/constants', () => ({
buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),
}))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: reportTaskProgressMock,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: assertTaskActiveMock,
}))
vi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', () => ({
runScriptToStoryboardOrchestrator: runScriptToStoryboardOrchestratorMock,
JsonParseError: class JsonParseError extends Error {
rawText: string
constructor(message: string, rawText: string) {
super(message)
this.name = 'JsonParseError'
this.rawText = rawText
}
},
}))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: {
NP_AGENT_STORYBOARD_PLAN: 'plan',
NP_AGENT_CINEMATOGRAPHER: 'cinematographer',
NP_AGENT_ACTING_DIRECTION: 'acting',
NP_AGENT_STORYBOARD_DETAIL: 'detail',
NP_VOICE_ANALYSIS: 'voice-analysis',
},
getPromptTemplate: vi.fn(() => 'prompt-template'),
buildPrompt: vi.fn(() => 'voice-analysis-prompt'),
}))
vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', () => ({
asJsonRecord: (value: unknown) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
},
buildStoryboardJson: vi.fn(() => '[]'),
parseEffort: vi.fn(() => null),
parseTemperature: vi.fn(() => 0.7),
parseVoiceLinesJson: parseVoiceLinesJsonMock,
persistStoryboardsAndPanels: persistStoryboardsAndPanelsMock,
toPositiveInt: (value: unknown) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
const n = Math.floor(value)
return n > 0 ? n : null
},
}))
import { handleScriptToStoryboardTask } from '@/lib/workers/handlers/script-to-storyboard'
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
return {
data: {
taskId: 'task-1',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
locale: 'zh',
projectId: 'project-1',
episodeId,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
function baseVoiceRows(): VoiceLineInput[] {
return [
{
lineIndex: 1,
speaker: 'Narrator',
content: 'Hello world',
emotionStrength: 0.8,
matchedPanel: {
storyboardId: 'storyboard-1',
panelIndex: 1,
},
},
]
}
describe('worker script-to-storyboard behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
txState.createdRows = []
prismaMock.project.findUnique.mockResolvedValue({
id: 'project-1',
name: 'Project One',
mode: 'novel-promotion',
})
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
id: 'np-project-1',
analysisModel: 'llm::analysis-model',
characters: [{ id: 'char-1', name: 'Narrator' }],
locations: [{ id: 'loc-1', name: 'Office' }],
})
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
id: 'episode-1',
novelPromotionProjectId: 'np-project-1',
novelText: 'A complete chapter text for voice analyze.',
clips: [
{
id: 'clip-1',
content: 'clip content',
characters: JSON.stringify(['Narrator']),
location: 'Office',
screenplay: 'Screenplay text',
},
],
})
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
novelPromotionVoiceLine: {
deleteMany: (args: { where: { episodeId: string } }) => Promise<unknown>
create: (args: { data: Record<string, unknown>; select: { id: boolean } }) => Promise<{ id: string }>
}
}) => Promise<unknown>) => {
const tx = {
novelPromotionVoiceLine: {
deleteMany: async () => undefined,
create: async (args: { data: Record<string, unknown>; select: { id: boolean } }) => {
txState.createdRows.push(args.data)
return { id: `voice-${txState.createdRows.length}` }
},
},
}
return await fn(tx)
})
persistStoryboardsAndPanelsMock.mockResolvedValue([
{
storyboardId: 'storyboard-1',
panels: [{ id: 'panel-1', panelIndex: 1 }],
},
])
parseVoiceLinesJsonMock.mockReturnValue(baseVoiceRows())
})
it('缺少 episodeId -> 显式失败', async () => {
const job = buildJob({}, null)
await expect(handleScriptToStoryboardTask(job)).rejects.toThrow('episodeId is required')
})
it('成功路径: 写入 voice line 时包含 matchedPanel 映射后的 panelId', async () => {
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleScriptToStoryboardTask(job)
expect(result).toEqual({
episodeId: 'episode-1',
storyboardCount: 1,
panelCount: 1,
voiceLineCount: 1,
})
expect(txState.createdRows).toHaveLength(1)
expect(txState.createdRows[0]).toEqual(expect.objectContaining({
episodeId: 'episode-1',
lineIndex: 1,
speaker: 'Narrator',
content: 'Hello world',
emotionStrength: 0.8,
matchedPanelId: 'panel-1',
matchedStoryboardId: 'storyboard-1',
matchedPanelIndex: 1,
}))
})
it('voice 解析失败后会重试一次再成功', async () => {
parseVoiceLinesJsonMock
.mockImplementationOnce(() => {
throw new Error('invalid voice json')
})
.mockImplementationOnce(() => baseVoiceRows())
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleScriptToStoryboardTask(job)
expect(result).toEqual(expect.objectContaining({
episodeId: 'episode-1',
voiceLineCount: 1,
}))
expect(chatCompletionMock).toHaveBeenCalledTimes(2)
expect(parseVoiceLinesJsonMock).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,92 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const llmMock = vi.hoisted(() => ({
getCompletionContent: vi.fn(),
}))
const persistMock = vi.hoisted(() => ({
resolveAnalysisModel: vi.fn(),
}))
const runtimeMock = vi.hoisted(() => ({
runShotPromptCompletion: vi.fn(),
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
}))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: runtimeMock.reportTaskProgress,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: runtimeMock.assertTaskActive,
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_CHARACTER_MODIFY: 'np_character_modify' },
buildPrompt: vi.fn(() => 'appearance-final-prompt'),
}))
import { handleModifyAppearanceTask } from '@/lib/workers/handlers/shot-ai-prompt-appearance'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-shot-appearance-1',
type: TASK_TYPE.AI_MODIFY_APPEARANCE,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'CharacterAppearance',
targetId: 'appearance-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker shot-ai-prompt-appearance behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
runtimeMock.runShotPromptCompletion.mockResolvedValue({ id: 'completion-1' })
llmMock.getCompletionContent.mockReturnValue('{"prompt":"updated appearance description"}')
})
it('missing characterId -> explicit error', async () => {
const job = buildJob({
appearanceId: 'appearance-1',
currentDescription: 'old desc',
modifyInstruction: 'new style',
})
await expect(handleModifyAppearanceTask(job, job.data.payload as Record<string, unknown>)).rejects.toThrow('characterId is required')
})
it('success -> returns modifiedDescription and rawResponse', async () => {
const payload = {
characterId: 'character-1',
appearanceId: 'appearance-1',
currentDescription: 'old desc',
modifyInstruction: 'new style',
}
const job = buildJob(payload)
const result = await handleModifyAppearanceTask(job, payload)
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
action: 'ai_modify_appearance',
prompt: 'appearance-final-prompt',
}))
expect(result).toEqual(expect.objectContaining({
success: true,
modifiedDescription: 'updated appearance description',
rawResponse: '{"prompt":"updated appearance description"}',
}))
})
})

View File

@@ -0,0 +1,101 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const llmMock = vi.hoisted(() => ({
getCompletionContent: vi.fn(),
}))
const persistMock = vi.hoisted(() => ({
resolveAnalysisModel: vi.fn(),
requireProjectLocation: vi.fn(),
persistLocationDescription: vi.fn(),
}))
const runtimeMock = vi.hoisted(() => ({
runShotPromptCompletion: vi.fn(),
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
}))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: runtimeMock.reportTaskProgress,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: runtimeMock.assertTaskActive,
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_LOCATION_MODIFY: 'np_location_modify' },
buildPrompt: vi.fn(() => 'location-final-prompt'),
}))
import { handleModifyLocationTask } from '@/lib/workers/handlers/shot-ai-prompt-location'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-shot-location-1',
type: TASK_TYPE.AI_MODIFY_LOCATION,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionLocation',
targetId: 'location-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker shot-ai-prompt-location behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
persistMock.requireProjectLocation.mockResolvedValue({ id: 'location-1', name: 'Old Town' })
runtimeMock.runShotPromptCompletion.mockResolvedValue({ id: 'completion-1' })
llmMock.getCompletionContent.mockReturnValue('{"prompt":"updated location description"}')
persistMock.persistLocationDescription.mockResolvedValue({ id: 'location-1', images: [] })
})
it('missing locationId -> explicit error', async () => {
const payload = {
currentDescription: 'old location',
modifyInstruction: 'new style',
}
const job = buildJob(payload)
await expect(handleModifyLocationTask(job, payload)).rejects.toThrow('locationId is required')
})
it('success -> persists modifiedDescription with computed imageIndex', async () => {
const payload = {
locationId: 'location-1',
imageIndex: 2,
currentDescription: 'old location',
modifyInstruction: 'add fog',
}
const job = buildJob(payload)
const result = await handleModifyLocationTask(job, payload)
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
action: 'ai_modify_location',
prompt: 'location-final-prompt',
}))
expect(persistMock.persistLocationDescription).toHaveBeenCalledWith({
locationId: 'location-1',
imageIndex: 2,
modifiedDescription: 'updated location description',
})
expect(result).toEqual(expect.objectContaining({
success: true,
modifiedDescription: 'updated location description',
location: { id: 'location-1', images: [] },
}))
})
})

View File

@@ -0,0 +1,90 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const llmMock = vi.hoisted(() => ({
getCompletionContent: vi.fn(),
}))
const persistMock = vi.hoisted(() => ({
resolveAnalysisModel: vi.fn(),
}))
const runtimeMock = vi.hoisted(() => ({
runShotPromptCompletion: vi.fn(),
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
vi.mock('@/lib/workers/handlers/shot-ai-prompt-runtime', () => ({
runShotPromptCompletion: runtimeMock.runShotPromptCompletion,
}))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: runtimeMock.reportTaskProgress,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: runtimeMock.assertTaskActive,
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_IMAGE_PROMPT_MODIFY: 'np_image_prompt_modify' },
buildPrompt: vi.fn(() => 'shot-final-prompt'),
}))
import { handleModifyShotPromptTask } from '@/lib/workers/handlers/shot-ai-prompt-shot'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-shot-prompt-1',
type: TASK_TYPE.AI_MODIFY_SHOT_PROMPT,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker shot-ai-prompt-shot behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
runtimeMock.runShotPromptCompletion.mockResolvedValue({ id: 'completion-1' })
llmMock.getCompletionContent.mockReturnValue('{"image_prompt":"updated image prompt","video_prompt":"updated video prompt"}')
})
it('missing currentPrompt -> explicit error', async () => {
const payload = { modifyInstruction: 'new angle' }
const job = buildJob(payload)
await expect(handleModifyShotPromptTask(job, payload)).rejects.toThrow('currentPrompt is required')
})
it('success -> returns modified image/video prompts and passes referencedAssets', async () => {
const payload = {
currentPrompt: 'old image prompt',
currentVideoPrompt: 'old video prompt',
modifyInstruction: 'new camera movement',
referencedAssets: [{ name: 'Hero', description: 'black coat' }],
}
const job = buildJob(payload)
const result = await handleModifyShotPromptTask(job, payload)
expect(runtimeMock.runShotPromptCompletion).toHaveBeenCalledWith(expect.objectContaining({
action: 'ai_modify_shot_prompt',
prompt: 'shot-final-prompt',
}))
expect(result).toEqual({
success: true,
modifiedImagePrompt: 'updated image prompt',
modifiedVideoPrompt: 'updated video prompt',
referencedAssets: [{ name: 'Hero', description: 'black coat' }],
})
})
})

View File

@@ -0,0 +1,80 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const handlersMock = vi.hoisted(() => ({
handleModifyAppearanceTask: vi.fn(),
handleModifyLocationTask: vi.fn(),
handleModifyShotPromptTask: vi.fn(),
handleAnalyzeShotVariantsTask: vi.fn(),
}))
vi.mock('@/lib/workers/handlers/shot-ai-prompt', () => ({
handleModifyAppearanceTask: handlersMock.handleModifyAppearanceTask,
handleModifyLocationTask: handlersMock.handleModifyLocationTask,
handleModifyShotPromptTask: handlersMock.handleModifyShotPromptTask,
}))
vi.mock('@/lib/workers/handlers/shot-ai-variants', () => ({
handleAnalyzeShotVariantsTask: handlersMock.handleAnalyzeShotVariantsTask,
}))
import { handleShotAITask } from '@/lib/workers/handlers/shot-ai-tasks'
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-1',
type,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker shot-ai-tasks behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
handlersMock.handleModifyAppearanceTask.mockResolvedValue({ type: 'appearance' })
handlersMock.handleModifyLocationTask.mockResolvedValue({ type: 'location' })
handlersMock.handleModifyShotPromptTask.mockResolvedValue({ type: 'shot-prompt' })
handlersMock.handleAnalyzeShotVariantsTask.mockResolvedValue({ type: 'variants' })
})
it('AI_MODIFY_APPEARANCE -> routes to appearance handler with payload', async () => {
const payload = { characterId: 'char-1', appearanceId: 'app-1' }
const job = buildJob(TASK_TYPE.AI_MODIFY_APPEARANCE, payload)
const result = await handleShotAITask(job)
expect(result).toEqual({ type: 'appearance' })
expect(handlersMock.handleModifyAppearanceTask).toHaveBeenCalledWith(job, payload)
})
it('AI_MODIFY_LOCATION / AI_MODIFY_SHOT_PROMPT / ANALYZE_SHOT_VARIANTS route correctly', async () => {
const locationPayload = { locationId: 'loc-1' }
const locationJob = buildJob(TASK_TYPE.AI_MODIFY_LOCATION, locationPayload)
await handleShotAITask(locationJob)
expect(handlersMock.handleModifyLocationTask).toHaveBeenCalledWith(locationJob, locationPayload)
const shotPayload = { currentPrompt: 'old prompt', modifyInstruction: 'new angle' }
const shotJob = buildJob(TASK_TYPE.AI_MODIFY_SHOT_PROMPT, shotPayload)
await handleShotAITask(shotJob)
expect(handlersMock.handleModifyShotPromptTask).toHaveBeenCalledWith(shotJob, shotPayload)
const variantPayload = { panelId: 'panel-1' }
const variantJob = buildJob(TASK_TYPE.ANALYZE_SHOT_VARIANTS, variantPayload)
await handleShotAITask(variantJob)
expect(handlersMock.handleAnalyzeShotVariantsTask).toHaveBeenCalledWith(variantJob, variantPayload)
})
it('unsupported type -> throws explicit error', async () => {
const job = buildJob(TASK_TYPE.IMAGE_CHARACTER, {})
await expect(handleShotAITask(job)).rejects.toThrow('Unsupported shot AI task type')
})
})

View File

@@ -0,0 +1,147 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
novelPromotionPanel: {
findUnique: vi.fn(),
},
}))
const llmMock = vi.hoisted(() => ({
chatCompletionWithVision: vi.fn(),
getCompletionContent: vi.fn(),
}))
const cosMock = vi.hoisted(() => ({
getSignedUrl: vi.fn(),
}))
const streamCtxMock = vi.hoisted(() => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
const llmStreamMock = vi.hoisted(() => {
const flush = vi.fn(async () => undefined)
return {
flush,
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush,
})),
}
})
const persistMock = vi.hoisted(() => ({
resolveAnalysisModel: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/cos', () => cosMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => streamCtxMock)
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: workerMock.reportTaskProgress,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: workerMock.assertTaskActive,
}))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: llmStreamMock.createWorkerLLMStreamContext,
createWorkerLLMStreamCallbacks: llmStreamMock.createWorkerLLMStreamCallbacks,
}))
vi.mock('@/lib/workers/handlers/shot-ai-persist', () => persistMock)
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_AGENT_SHOT_VARIANT_ANALYSIS: 'np_agent_shot_variant_analysis' },
buildPrompt: vi.fn(() => 'shot-variants-prompt'),
}))
import { handleAnalyzeShotVariantsTask } from '@/lib/workers/handlers/shot-ai-variants'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-shot-variants-1',
type: TASK_TYPE.ANALYZE_SHOT_VARIANTS,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
targetId: 'panel-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker shot-ai-variants behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis-1' })
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
id: 'panel-1',
panelNumber: 3,
imageUrl: 'images/panel-1.png',
description: 'panel desc',
shotType: 'medium',
cameraMove: 'static',
location: 'Old Town',
characters: JSON.stringify([{ name: 'Hero', appearance: 'black coat' }]),
})
cosMock.getSignedUrl.mockReturnValue('https://signed.example/panel-1.png')
llmMock.chatCompletionWithVision.mockResolvedValue({ id: 'vision-1' })
llmMock.getCompletionContent.mockReturnValue('[{"name":"v1"},{"name":"v2"},{"name":"v3"}]')
})
it('panel not found -> explicit error', async () => {
prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce(null)
const job = buildJob({ panelId: 'panel-404' })
await expect(handleAnalyzeShotVariantsTask(job, job.data.payload as Record<string, unknown>)).rejects.toThrow('Panel not found')
})
it('success -> returns suggestions and signed panel image', async () => {
const payload = { panelId: 'panel-1' }
const job = buildJob(payload)
const result = await handleAnalyzeShotVariantsTask(job, payload)
expect(llmMock.chatCompletionWithVision).toHaveBeenCalledWith(
'user-1',
'llm::analysis-1',
'shot-variants-prompt',
['https://signed.example/panel-1.png'],
expect.objectContaining({
projectId: 'project-1',
action: 'analyze_shot_variants',
}),
)
expect(result).toEqual(expect.objectContaining({
success: true,
suggestions: [{ name: 'v1' }, { name: 'v2' }, { name: 'v3' }],
panelInfo: expect.objectContaining({
panelNumber: 3,
imageUrl: 'https://signed.example/panel-1.png',
}),
}))
expect(llmStreamMock.flush).toHaveBeenCalled()
})
it('suggestions fewer than 3 -> explicit error', async () => {
llmMock.getCompletionContent.mockReturnValueOnce('[{"name":"only-one"}]')
const payload = { panelId: 'panel-1' }
const job = buildJob(payload)
await expect(handleAnalyzeShotVariantsTask(job, payload)).rejects.toThrow('生成的变体数量不足')
})
})

View File

@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from 'vitest'
import { runStoryToScriptOrchestrator } from '@/lib/novel-promotion/story-to-script/orchestrator'
describe('story-to-script orchestrator retry', () => {
it('retries retryable step failure up to 3 attempts', async () => {
const actionCalls = new Map<string, number>()
const characterMetas: Array<{ stepId: string; stepAttempt?: number }> = []
const runStep = vi.fn(async (meta, _prompt, action: string) => {
actionCalls.set(action, (actionCalls.get(action) || 0) + 1)
if (action === 'analyze_characters') {
characterMetas.push({ stepId: meta.stepId, stepAttempt: meta.stepAttempt })
const count = actionCalls.get(action) || 0
if (count < 3) {
throw new TypeError('terminated')
}
return { text: JSON.stringify({ characters: [{ name: '甲', introduction: '人物介绍' }] }), reasoning: '' }
}
if (action === 'analyze_locations') {
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
}
if (action === 'split_clips') {
return {
text: JSON.stringify([
{
start: '甲在门口',
end: '乙回答',
summary: '片段摘要',
location: '地点A',
characters: ['甲'],
},
]),
reasoning: '',
}
}
return { text: JSON.stringify({ scenes: [{ id: 1 }] }), reasoning: '' }
})
const result = await runStoryToScriptOrchestrator({
content: '甲在门口。乙回答。',
baseCharacters: [],
baseLocations: [],
baseCharacterIntroductions: [],
promptTemplates: {
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
locationPromptTemplate: '{input} {locations_lib_name}',
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
},
runStep,
})
expect(result.summary.clipCount).toBe(1)
expect(actionCalls.get('analyze_characters')).toBe(3)
expect(characterMetas).toEqual([
{ stepId: 'analyze_characters', stepAttempt: undefined },
{ stepId: 'analyze_characters', stepAttempt: 2 },
{ stepId: 'analyze_characters', stepAttempt: 3 },
])
})
it('does not retry non-retryable failures', async () => {
const actionCalls = new Map<string, number>()
const runStep = vi.fn(async (_meta, _prompt, action: string) => {
actionCalls.set(action, (actionCalls.get(action) || 0) + 1)
if (action === 'analyze_characters') {
throw new Error('SENSITIVE_CONTENT: blocked')
}
return { text: JSON.stringify({ locations: [{ name: '地点A' }] }), reasoning: '' }
})
await expect(
runStoryToScriptOrchestrator({
content: '甲在门口。乙回答。',
baseCharacters: [],
baseLocations: [],
baseCharacterIntroductions: [],
promptTemplates: {
characterPromptTemplate: '{input} {characters_lib_name} {characters_lib_info}',
locationPromptTemplate: '{input} {locations_lib_name}',
clipPromptTemplate: '{input} {locations_lib_name} {characters_lib_name} {characters_introduction}',
screenplayPromptTemplate: '{clip_content} {locations_lib_name} {characters_lib_name} {characters_introduction} {clip_id}',
},
runStep,
}),
).rejects.toThrow('SENSITIVE_CONTENT')
expect(actionCalls.get('analyze_characters')).toBe(1)
})
})

View File

@@ -0,0 +1,190 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
project: { findUnique: vi.fn() },
novelPromotionProject: { findUnique: vi.fn() },
novelPromotionEpisode: { findUnique: vi.fn() },
novelPromotionClip: { update: vi.fn(async () => ({})) },
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
const configMock = vi.hoisted(() => ({
resolveProjectModelCapabilityGenerationOptions: vi.fn(async () => ({ reasoningEffort: 'high' })),
}))
const orchestratorMock = vi.hoisted(() => ({
runStoryToScriptOrchestrator: vi.fn(),
}))
const helperMock = vi.hoisted(() => ({
persistAnalyzedCharacters: vi.fn(async () => [{ id: 'character-new-1' }]),
persistAnalyzedLocations: vi.fn(async () => [{ id: 'location-new-1' }]),
persistClips: vi.fn(async () => [{ clipKey: 'clip-1', id: 'clip-row-1' }]),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => ({
chatCompletion: vi.fn(),
getCompletionParts: vi.fn(() => ({ text: '', reasoning: '' })),
}))
vi.mock('@/lib/config-service', () => configMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/logging/semantic', () => ({ logAIAnalysis: vi.fn() }))
vi.mock('@/lib/logging/file-writer', () => ({ onProjectNameAvailable: vi.fn() }))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/novel-promotion/story-to-script/orchestrator', () => orchestratorMock)
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: {
NP_AGENT_CHARACTER_PROFILE: 'a',
NP_SELECT_LOCATION: 'b',
NP_AGENT_CLIP: 'c',
NP_SCREENPLAY_CONVERSION: 'd',
},
getPromptTemplate: vi.fn(() => 'prompt-template'),
}))
vi.mock('@/lib/workers/handlers/story-to-script-helpers', () => ({
asString: (value: unknown) => (typeof value === 'string' ? value : ''),
parseEffort: vi.fn(() => null),
parseTemperature: vi.fn(() => 0.7),
persistAnalyzedCharacters: helperMock.persistAnalyzedCharacters,
persistAnalyzedLocations: helperMock.persistAnalyzedLocations,
persistClips: helperMock.persistClips,
resolveClipRecordId: (clipIdMap: Map<string, string>, clipId: string) => clipIdMap.get(clipId) ?? null,
}))
import { handleStoryToScriptTask } from '@/lib/workers/handlers/story-to-script'
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
return {
data: {
taskId: 'task-story-to-script-1',
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
locale: 'zh',
projectId: 'project-1',
episodeId,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker story-to-script behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.project.findUnique.mockResolvedValue({
id: 'project-1',
name: 'Project One',
mode: 'novel-promotion',
})
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
characters: [{ id: 'char-1', name: 'Hero', introduction: 'hero intro' }],
locations: [{ id: 'loc-1', name: 'Old Town', summary: 'town' }],
})
prismaMock.novelPromotionEpisode.findUnique
.mockResolvedValueOnce({
id: 'episode-1',
novelPromotionProjectId: 'np-project-1',
novelText: 'episode text',
})
.mockResolvedValueOnce({ id: 'episode-1' })
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValue({
analyzedCharacters: [{ name: 'New Hero' }],
analyzedLocations: [{ name: 'Market' }],
clipList: [{ clipId: 'clip-1', content: 'clip content' }],
screenplayResults: [
{
clipId: 'clip-1',
success: true,
screenplay: { scenes: [{ shot: 'close-up' }] },
},
],
summary: {
clipCount: 1,
screenplaySuccessCount: 1,
screenplayFailedCount: 0,
},
})
})
it('missing episodeId -> explicit error', async () => {
const job = buildJob({}, null)
await expect(handleStoryToScriptTask(job)).rejects.toThrow('episodeId is required')
})
it('success path -> persists clips and screenplay with concrete fields', async () => {
const job = buildJob({ episodeId: 'episode-1', content: 'input content' })
const result = await handleStoryToScriptTask(job)
expect(result).toEqual({
episodeId: 'episode-1',
clipCount: 1,
screenplaySuccessCount: 1,
screenplayFailedCount: 0,
persistedCharacters: 1,
persistedLocations: 1,
persistedClips: 1,
})
expect(helperMock.persistClips).toHaveBeenCalledWith({
episodeId: 'episode-1',
clipList: [{ clipId: 'clip-1', content: 'clip content' }],
})
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
where: { id: 'clip-row-1' },
data: {
screenplay: JSON.stringify({ scenes: [{ shot: 'close-up' }] }),
},
})
})
it('orchestrator partial failure summary -> throws explicit error', async () => {
orchestratorMock.runStoryToScriptOrchestrator.mockResolvedValueOnce({
analyzedCharacters: [],
analyzedLocations: [],
clipList: [],
screenplayResults: [
{
clipId: 'clip-3',
success: false,
error: 'bad screenplay json',
},
],
summary: {
clipCount: 1,
screenplaySuccessCount: 0,
screenplayFailedCount: 1,
},
})
const job = buildJob({ episodeId: 'episode-1', content: 'input content' })
await expect(handleStoryToScriptTask(job)).rejects.toThrow('STORY_TO_SCRIPT_PARTIAL_FAILED')
})
})

View File

@@ -0,0 +1,206 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type WorkerProcessor = (job: Job<TaskJobData>) => Promise<unknown>
type PanelRow = {
id: string
videoUrl: string | null
imageUrl: string | null
videoPrompt: string | null
description: string | null
firstLastFramePrompt: string | null
}
const workerState = vi.hoisted(() => ({
processor: null as WorkerProcessor | null,
}))
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
const withTaskLifecycleMock = vi.hoisted(() =>
vi.fn(async (job: Job<TaskJobData>, handler: WorkerProcessor) => await handler(job)),
)
const utilsMock = vi.hoisted(() => ({
assertTaskActive: vi.fn(async () => undefined),
getProjectModels: vi.fn(async () => ({ videoRatio: '16:9' })),
resolveLipSyncVideoSource: vi.fn(async () => 'https://provider.example/lipsync.mp4'),
resolveVideoSourceFromGeneration: vi.fn(async () => 'https://provider.example/video.mp4'),
toSignedUrlIfCos: vi.fn((url: string | null) => (url ? `https://signed.example/${url}` : null)),
uploadVideoSourceToCos: vi.fn(async () => 'cos/lip-sync/video.mp4'),
}))
const prismaMock = vi.hoisted(() => ({
novelPromotionPanel: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(async () => undefined),
},
novelPromotionVoiceLine: {
findUnique: vi.fn(),
},
}))
vi.mock('bullmq', () => ({
Queue: class {
constructor(_name: string) {}
async add() {
return { id: 'job-1' }
}
async getJob() {
return null
}
},
Worker: class {
constructor(_name: string, processor: WorkerProcessor) {
workerState.processor = processor
}
},
}))
vi.mock('@/lib/redis', () => ({ queueRedis: {} }))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: reportTaskProgressMock,
withTaskLifecycle: withTaskLifecycleMock,
}))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/media/outbound-image', () => ({
normalizeToBase64ForGeneration: vi.fn(async (input: string) => input),
}))
vi.mock('@/lib/model-capabilities/lookup', () => ({
resolveBuiltinCapabilitiesByModelKey: vi.fn(() => ({ video: { firstlastframe: true } })),
}))
vi.mock('@/lib/model-config-contract', () => ({
parseModelKeyStrict: vi.fn(() => ({ provider: 'fal' })),
}))
vi.mock('@/lib/api-config', () => ({
getProviderConfig: vi.fn(async () => ({ apiKey: 'api-key' })),
}))
function buildPanel(overrides?: Partial<PanelRow>): PanelRow {
return {
id: 'panel-1',
videoUrl: 'cos/base-video.mp4',
imageUrl: 'cos/panel-image.png',
videoPrompt: 'panel prompt',
description: 'panel description',
firstLastFramePrompt: null,
...(overrides || {}),
}
}
function buildJob(params: {
type: TaskJobData['type']
payload?: Record<string, unknown>
targetType?: string
targetId?: string
}): Job<TaskJobData> {
return {
data: {
taskId: 'task-1',
type: params.type,
locale: 'zh',
projectId: 'project-1',
episodeId: 'episode-1',
targetType: params.targetType ?? 'NovelPromotionPanel',
targetId: params.targetId ?? 'panel-1',
payload: params.payload ?? {},
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker video processor behavior', () => {
beforeEach(async () => {
vi.clearAllMocks()
workerState.processor = null
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue(buildPanel())
prismaMock.novelPromotionPanel.findFirst.mockResolvedValue(buildPanel())
prismaMock.novelPromotionVoiceLine.findUnique.mockResolvedValue({
id: 'line-1',
audioUrl: 'cos/line-1.mp3',
})
const mod = await import('@/lib/workers/video.worker')
mod.createVideoWorker()
})
it('VIDEO_PANEL: 缺少 payload.videoModel 时显式失败', async () => {
const processor = workerState.processor
expect(processor).toBeTruthy()
const job = buildJob({
type: TASK_TYPE.VIDEO_PANEL,
payload: {},
})
await expect(processor!(job)).rejects.toThrow('VIDEO_MODEL_REQUIRED: payload.videoModel is required')
})
it('LIP_SYNC: 缺少 panel 时显式失败', async () => {
const processor = workerState.processor
expect(processor).toBeTruthy()
prismaMock.novelPromotionPanel.findUnique.mockResolvedValueOnce(null)
const job = buildJob({
type: TASK_TYPE.LIP_SYNC,
payload: { voiceLineId: 'line-1' },
targetId: 'panel-missing',
})
await expect(processor!(job)).rejects.toThrow('Lip-sync panel not found')
})
it('LIP_SYNC: 正常路径写回 lipSyncVideoUrl 并清理 lipSyncTaskId', async () => {
const processor = workerState.processor
expect(processor).toBeTruthy()
const job = buildJob({
type: TASK_TYPE.LIP_SYNC,
payload: {
voiceLineId: 'line-1',
lipSyncModel: 'fal::lipsync-model',
},
targetId: 'panel-1',
})
const result = await processor!(job) as { panelId: string; voiceLineId: string; lipSyncVideoUrl: string }
expect(result).toEqual({
panelId: 'panel-1',
voiceLineId: 'line-1',
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
})
expect(utilsMock.resolveLipSyncVideoSource).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
userId: 'user-1',
modelKey: 'fal::lipsync-model',
}),
)
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-1' },
data: {
lipSyncVideoUrl: 'cos/lip-sync/video.mp4',
lipSyncTaskId: null,
},
})
})
it('未知任务类型: 显式报错', async () => {
const processor = workerState.processor
expect(processor).toBeTruthy()
const unsupportedJob = buildJob({
type: TASK_TYPE.AI_CREATE_CHARACTER,
})
await expect(processor!(unsupportedJob)).rejects.toThrow('Unsupported video task type')
})
})

View File

@@ -0,0 +1,200 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const txState = vi.hoisted(() => ({
createdRows: [] as Array<Record<string, unknown>>,
}))
const prismaMock = vi.hoisted(() => ({
project: { findUnique: vi.fn() },
novelPromotionProject: { findUnique: vi.fn() },
novelPromotionEpisode: { findUnique: vi.fn() },
$transaction: vi.fn(),
}))
const llmMock = vi.hoisted(() => ({
chatCompletion: vi.fn(async () => ({ id: 'completion-1' })),
getCompletionContent: vi.fn(() => 'voice-line-json'),
}))
const helperMock = vi.hoisted(() => ({
parseVoiceLinesJson: vi.fn(),
buildStoryboardJson: vi.fn(() => 'storyboard-json'),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/constants', () => ({
buildCharactersIntroduction: vi.fn(() => 'characters-introduction'),
}))
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
createWorkerLLMStreamContext: vi.fn(() => ({ streamRunId: 'run-1', nextSeqByStepLane: {} })),
createWorkerLLMStreamCallbacks: vi.fn(() => ({
onStage: vi.fn(),
onChunk: vi.fn(),
onComplete: vi.fn(),
onError: vi.fn(),
flush: vi.fn(async () => undefined),
})),
}))
vi.mock('@/lib/workers/handlers/voice-analyze-helpers', () => ({
buildStoryboardJson: helperMock.buildStoryboardJson,
parseVoiceLinesJson: helperMock.parseVoiceLinesJson,
}))
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_VOICE_ANALYSIS: 'np_voice_analysis' },
buildPrompt: vi.fn(() => 'voice-analysis-prompt'),
}))
import { handleVoiceAnalyzeTask } from '@/lib/workers/handlers/voice-analyze'
function buildJob(payload: Record<string, unknown>, episodeId: string | null = 'episode-1'): Job<TaskJobData> {
return {
data: {
taskId: 'task-voice-analyze-1',
type: TASK_TYPE.VOICE_ANALYZE,
locale: 'zh',
projectId: 'project-1',
episodeId,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker voice-analyze behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
txState.createdRows = []
prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion' })
prismaMock.novelPromotionProject.findUnique.mockResolvedValue({
id: 'np-project-1',
analysisModel: 'llm::analysis-1',
characters: [{ id: 'char-1', name: 'Hero' }],
})
prismaMock.novelPromotionEpisode.findUnique.mockResolvedValue({
id: 'episode-1',
novelPromotionProjectId: 'np-project-1',
novelText: '这是可以用于台词分析的文本',
storyboards: [
{
id: 'storyboard-1',
clip: { id: 'clip-1' },
panels: [{ id: 'panel-1', panelIndex: 0 }],
},
],
})
helperMock.parseVoiceLinesJson.mockReturnValue([
{
lineIndex: 1,
speaker: 'Hero',
content: '第一句台词',
emotionStrength: 0.7,
matchedPanel: {
storyboardId: 'storyboard-1',
panelIndex: 0,
},
},
{
lineIndex: 2,
speaker: 'Narrator',
content: '第二句旁白',
emotionStrength: 0.5,
},
])
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
novelPromotionVoiceLine: {
deleteMany: (args: { where: { episodeId: string } }) => Promise<unknown>
create: (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => Promise<{
id: string
speaker: string
matchedStoryboardId: string | null
}>
}
}) => Promise<unknown>) => {
const tx = {
novelPromotionVoiceLine: {
deleteMany: async () => undefined,
create: async (args: { data: Record<string, unknown>; select: { id: boolean; speaker: boolean; matchedStoryboardId: boolean } }) => {
txState.createdRows.push(args.data)
const speaker = typeof args.data.speaker === 'string' ? args.data.speaker : 'unknown'
const matchedStoryboardId = typeof args.data.matchedStoryboardId === 'string'
? args.data.matchedStoryboardId
: null
return {
id: `line-${txState.createdRows.length}`,
speaker,
matchedStoryboardId,
}
},
},
}
return await fn(tx)
})
})
it('missing episodeId -> explicit error', async () => {
const job = buildJob({}, null)
await expect(handleVoiceAnalyzeTask(job)).rejects.toThrow('episodeId is required')
})
it('success path -> persists mapped panelId and speaker stats', async () => {
const job = buildJob({ episodeId: 'episode-1' })
const result = await handleVoiceAnalyzeTask(job)
expect(result).toEqual({
episodeId: 'episode-1',
count: 2,
matchedCount: 1,
speakerStats: {
Hero: 1,
Narrator: 1,
},
})
expect(txState.createdRows[0]).toEqual(expect.objectContaining({
episodeId: 'episode-1',
lineIndex: 1,
speaker: 'Hero',
content: '第一句台词',
matchedPanelId: 'panel-1',
matchedStoryboardId: 'storyboard-1',
matchedPanelIndex: 0,
}))
})
it('line references non-existent storyboard panel -> explicit error', async () => {
helperMock.parseVoiceLinesJson.mockImplementation(() => [
{
lineIndex: 1,
speaker: 'Hero',
content: 'bad line',
emotionStrength: 0.8,
matchedPanel: {
storyboardId: 'storyboard-404',
panelIndex: 0,
},
},
])
const job = buildJob({ episodeId: 'episode-1' })
await expect(handleVoiceAnalyzeTask(job)).rejects.toThrow('references non-existent panel')
})
})

View File

@@ -0,0 +1,104 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const qwenMock = vi.hoisted(() => ({
createVoiceDesign: vi.fn(),
validateVoicePrompt: vi.fn(),
validatePreviewText: vi.fn(),
}))
const apiConfigMock = vi.hoisted(() => ({
getProviderConfig: vi.fn(),
}))
const workerMock = vi.hoisted(() => ({
reportTaskProgress: vi.fn(async () => undefined),
assertTaskActive: vi.fn(async () => undefined),
}))
vi.mock('@/lib/qwen-voice-design', () => qwenMock)
vi.mock('@/lib/api-config', () => apiConfigMock)
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: workerMock.reportTaskProgress,
}))
vi.mock('@/lib/workers/utils', () => ({
assertTaskActive: workerMock.assertTaskActive,
}))
import { handleVoiceDesignTask } from '@/lib/workers/handlers/voice-design'
function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>): Job<TaskJobData> {
return {
data: {
taskId: 'task-voice-1',
type,
locale: 'zh',
projectId: 'project-1',
episodeId: null,
targetType: 'VoiceDesign',
targetId: 'voice-design-1',
payload,
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker voice-design behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
qwenMock.validateVoicePrompt.mockReturnValue({ valid: true })
qwenMock.validatePreviewText.mockReturnValue({ valid: true })
apiConfigMock.getProviderConfig.mockResolvedValue({ apiKey: 'qwen-key' })
qwenMock.createVoiceDesign.mockResolvedValue({
success: true,
voiceId: 'voice-id-1',
targetModel: 'qwen-tts',
audioBase64: 'base64-audio',
sampleRate: 24000,
responseFormat: 'mp3',
usageCount: 11,
requestId: 'req-1',
})
})
it('missing required fields -> explicit error', async () => {
const job = buildJob(TASK_TYPE.VOICE_DESIGN, { previewText: 'hello' })
await expect(handleVoiceDesignTask(job)).rejects.toThrow('voicePrompt is required')
})
it('invalid prompt validation -> explicit error message from validator', async () => {
qwenMock.validateVoicePrompt.mockReturnValue({ valid: false, error: 'bad prompt' })
const job = buildJob(TASK_TYPE.VOICE_DESIGN, {
voicePrompt: 'x',
previewText: 'hello',
})
await expect(handleVoiceDesignTask(job)).rejects.toThrow('bad prompt')
})
it('success path -> submits normalized input and returns typed result', async () => {
const job = buildJob(TASK_TYPE.ASSET_HUB_VOICE_DESIGN, {
voicePrompt: ' calm female narrator ',
previewText: ' hello world ',
preferredName: ' custom_name ',
language: 'en',
})
const result = await handleVoiceDesignTask(job)
expect(apiConfigMock.getProviderConfig).toHaveBeenCalledWith('user-1', 'qwen')
expect(qwenMock.createVoiceDesign).toHaveBeenCalledWith({
voicePrompt: 'calm female narrator',
previewText: 'hello world',
preferredName: 'custom_name',
language: 'en',
}, 'qwen-key')
expect(result).toEqual(expect.objectContaining({
success: true,
voiceId: 'voice-id-1',
taskType: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
}))
})
})

View File

@@ -0,0 +1,172 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
type WorkerProcessor = (job: Job<TaskJobData>) => Promise<unknown>
const workerState = vi.hoisted(() => ({
processor: null as WorkerProcessor | null,
}))
const generateVoiceLineMock = vi.hoisted(() => vi.fn())
const handleVoiceDesignTaskMock = vi.hoisted(() => vi.fn())
const reportTaskProgressMock = vi.hoisted(() => vi.fn(async () => undefined))
const withTaskLifecycleMock = vi.hoisted(() =>
vi.fn(async (job: Job<TaskJobData>, handler: WorkerProcessor) => await handler(job)),
)
vi.mock('bullmq', () => ({
Queue: class {
constructor(_name: string) {}
async add() {
return { id: 'job-1' }
}
async getJob() {
return null
}
},
Worker: class {
constructor(_name: string, processor: WorkerProcessor) {
workerState.processor = processor
}
},
}))
vi.mock('@/lib/redis', () => ({
queueRedis: {},
}))
vi.mock('@/lib/voice/generate-voice-line', () => ({
generateVoiceLine: generateVoiceLineMock,
}))
vi.mock('@/lib/workers/shared', () => ({
reportTaskProgress: reportTaskProgressMock,
withTaskLifecycle: withTaskLifecycleMock,
}))
vi.mock('@/lib/workers/handlers/voice-design', () => ({
handleVoiceDesignTask: handleVoiceDesignTaskMock,
}))
function buildJob(params: {
type: TaskJobData['type']
targetType?: string
targetId?: string
episodeId?: string | null
payload?: Record<string, unknown>
}): Job<TaskJobData> {
return {
data: {
taskId: 'task-1',
type: params.type,
locale: 'zh',
projectId: 'project-1',
episodeId: params.episodeId !== undefined ? params.episodeId : 'episode-1',
targetType: params.targetType ?? 'NovelPromotionVoiceLine',
targetId: params.targetId ?? 'line-1',
payload: params.payload ?? {},
userId: 'user-1',
},
} as unknown as Job<TaskJobData>
}
describe('worker voice processor behavior', () => {
beforeEach(async () => {
vi.clearAllMocks()
workerState.processor = null
generateVoiceLineMock.mockResolvedValue({
lineId: 'line-1',
audioUrl: 'cos/voice-line-1.mp3',
})
handleVoiceDesignTaskMock.mockResolvedValue({
presetId: 'preset-1',
previewAudioUrl: 'cos/preset-1.mp3',
})
const mod = await import('@/lib/workers/voice.worker')
mod.createVoiceWorker()
})
it('VOICE_LINE: lineId/episodeId 缺失时显式失败', async () => {
const processor = workerState.processor
expect(processor).toBeTruthy()
const missingLineJob = buildJob({
type: TASK_TYPE.VOICE_LINE,
targetId: '',
payload: { episodeId: 'episode-1' },
})
await expect(processor!(missingLineJob)).rejects.toThrow('VOICE_LINE task missing lineId')
const missingEpisodeJob = buildJob({
type: TASK_TYPE.VOICE_LINE,
episodeId: null,
targetId: 'line-1',
payload: {},
})
await expect(processor!(missingEpisodeJob)).rejects.toThrow('VOICE_LINE task missing episodeId')
})
it('VOICE_LINE: 正常生成时把核心参数传给 generateVoiceLine', async () => {
const processor = workerState.processor
expect(processor).toBeTruthy()
const job = buildJob({
type: TASK_TYPE.VOICE_LINE,
payload: {
lineId: 'line-9',
episodeId: 'episode-9',
audioModel: 'fal::voice-model',
},
})
const result = await processor!(job)
expect(result).toEqual({ lineId: 'line-1', audioUrl: 'cos/voice-line-1.mp3' })
expect(generateVoiceLineMock).toHaveBeenCalledWith({
projectId: 'project-1',
episodeId: 'episode-9',
lineId: 'line-9',
userId: 'user-1',
audioModel: 'fal::voice-model',
})
})
it('VOICE_DESIGN / ASSET_HUB_VOICE_DESIGN: 路由到 voice design handler', async () => {
const processor = workerState.processor
expect(processor).toBeTruthy()
const designJob = buildJob({
type: TASK_TYPE.VOICE_DESIGN,
targetType: 'NovelPromotionVoiceDesign',
targetId: 'voice-design-1',
})
const assetHubJob = buildJob({
type: TASK_TYPE.ASSET_HUB_VOICE_DESIGN,
targetType: 'GlobalAssetHubVoiceDesign',
targetId: 'asset-hub-voice-design-1',
})
await processor!(designJob)
await processor!(assetHubJob)
expect(handleVoiceDesignTaskMock).toHaveBeenCalledTimes(2)
expect(generateVoiceLineMock).not.toHaveBeenCalled()
})
it('未知任务类型: 显式报错', async () => {
const processor = workerState.processor
expect(processor).toBeTruthy()
const unsupportedJob = buildJob({
type: TASK_TYPE.AI_CREATE_CHARACTER,
targetId: 'character-1',
})
await expect(processor!(unsupportedJob)).rejects.toThrow('Unsupported voice task type')
})
})

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { resolveSelectedEpisodeId } from '@/app/[locale]/workspace/[projectId]/episode-selection'
describe('resolveSelectedEpisodeId', () => {
it('returns null when episodes list is empty', () => {
expect(resolveSelectedEpisodeId([], null)).toBeNull()
expect(resolveSelectedEpisodeId([], 'ep-1')).toBeNull()
})
it('uses url episode id when it exists in list', () => {
const episodes = [{ id: 'ep-1' }, { id: 'ep-2' }]
expect(resolveSelectedEpisodeId(episodes, 'ep-2')).toBe('ep-2')
})
it('falls back to first episode when url episode id is missing', () => {
const episodes = [{ id: 'ep-1' }, { id: 'ep-2' }]
expect(resolveSelectedEpisodeId(episodes, null)).toBe('ep-1')
})
it('falls back to first episode when url episode id is invalid', () => {
const episodes = [{ id: 'ep-1' }, { id: 'ep-2' }]
expect(resolveSelectedEpisodeId(episodes, 'ep-404')).toBe('ep-1')
})
})

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { hasDownstreamStoryboardData } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/hooks/useRebuildConfirm'
describe('hasDownstreamStoryboardData', () => {
it('returns false when storyboard and panel counts are both zero', () => {
expect(hasDownstreamStoryboardData({ storyboardCount: 0, panelCount: 0 })).toBe(false)
})
it('returns true when storyboard count is greater than zero', () => {
expect(hasDownstreamStoryboardData({ storyboardCount: 1, panelCount: 0 })).toBe(true)
})
it('returns true when panel count is greater than zero', () => {
expect(hasDownstreamStoryboardData({ storyboardCount: 0, panelCount: 2 })).toBe(true)
})
})