feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions

This commit is contained in:
saturn
2026-04-02 17:39:16 +08:00
parent c3e74c228a
commit 9703714b69
153 changed files with 4472 additions and 1088 deletions

View File

@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildMockRequest } from '../../../helpers/request'
const authState = vi.hoisted(() => ({ authenticated: true }))
const listRunsMock = vi.hoisted(() => vi.fn())
const createRunMock = vi.hoisted(() => vi.fn())
vi.mock('@/lib/api-auth', () => {
const unauthorized = () => new Response(
JSON.stringify({ error: { code: 'UNAUTHORIZED' } }),
{ status: 401, headers: { 'content-type': 'application/json' } },
)
return {
isErrorResponse: (value: unknown) => value instanceof Response,
requireUserAuth: async () => {
if (!authState.authenticated) return unauthorized()
return { session: { user: { id: 'user-1' } } }
},
}
})
vi.mock('@/lib/run-runtime/service', () => ({
listRuns: listRunsMock,
createRun: createRunMock,
}))
describe('api contract - runs list route', () => {
const emptyRouteContext = {
params: Promise.resolve({}),
}
beforeEach(() => {
vi.clearAllMocks()
authState.authenticated = true
listRunsMock.mockResolvedValue([
{
id: 'run-1',
status: 'running',
},
])
})
it('tightens scoped active run queries to the latest recoverable run', async () => {
const { GET } = await import('@/app/api/runs/route')
const req = buildMockRequest({
path: '/api/runs?projectId=project-1&workflowType=story_to_script_run&targetType=NovelPromotionEpisode&targetId=episode-1&episodeId=episode-1&status=queued&status=running&status=canceling&limit=20',
method: 'GET',
})
const res = await GET(req, emptyRouteContext)
expect(res.status).toBe(200)
expect(listRunsMock).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
projectId: 'project-1',
workflowType: 'story_to_script_run',
targetType: 'NovelPromotionEpisode',
targetId: 'episode-1',
episodeId: 'episode-1',
statuses: ['queued', 'running', 'canceling'],
limit: 20,
recoverableOnly: true,
latestOnly: true,
}))
})
it('keeps non-active queries as normal list requests', async () => {
const { GET } = await import('@/app/api/runs/route')
const req = buildMockRequest({
path: '/api/runs?projectId=project-1&workflowType=story_to_script_run&targetType=NovelPromotionEpisode&targetId=episode-1&status=completed&limit=20',
method: 'GET',
})
const res = await GET(req, emptyRouteContext)
expect(res.status).toBe(200)
expect(listRunsMock).toHaveBeenCalledWith(expect.objectContaining({
statuses: ['completed'],
recoverableOnly: false,
latestOnly: false,
}))
})
})

View File

@@ -73,4 +73,38 @@ describe('api specific - project create default audio model', () => {
}),
})
})
it('returns an explicit validation error when description exceeds the max length', async () => {
const mod = await import('@/app/api/projects/route')
const req = buildMockRequest({
path: '/api/projects',
method: 'POST',
headers: {
'accept-language': 'zh-CN',
},
body: {
name: 'Test Project',
description: 'a'.repeat(501),
},
})
const res = await mod.POST(req, routeContext)
const body = await res.json() as {
error?: {
code?: string
message?: string
details?: {
field?: string
limit?: number
}
}
}
expect(res.status).toBe(400)
expect(body.error?.code).toBe('INVALID_PARAMS')
expect(body.error?.message).toBe('项目描述不能超过 500 个字符。')
expect(body.error?.details?.field).toBe('description')
expect(body.error?.details?.limit).toBe(500)
expect(prismaMock.project.create).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { reconcileActiveRunsFromTasks } from '@/lib/run-runtime/reconcile'
import { RUN_STATUS, RUN_STEP_STATUS } from '@/lib/run-runtime/types'
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
import { prisma } from '../../helpers/prisma'
import { resetBillingState } from '../../helpers/db-reset'
import { createTestUser } from '../../helpers/billing-fixtures'
describe('run runtime reconcileActiveRunsFromTasks', () => {
beforeEach(async () => {
await resetBillingState()
})
it('marks a running run completed when the linked task already completed', async () => {
const user = await createTestUser()
const finishedAt = new Date('2026-03-30T08:00:00.000Z')
const task = await prisma.task.create({
data: {
userId: user.id,
projectId: 'project-run-complete',
episodeId: 'episode-run-complete',
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-run-complete',
status: TASK_STATUS.COMPLETED,
progress: 100,
payload: { episodeId: 'episode-run-complete' },
result: {
episodeId: 'episode-run-complete',
persistedClips: 12,
},
queuedAt: new Date('2026-03-30T07:55:00.000Z'),
startedAt: new Date('2026-03-30T07:56:00.000Z'),
finishedAt,
},
})
const run = await prisma.graphRun.create({
data: {
userId: user.id,
projectId: 'project-run-complete',
episodeId: 'episode-run-complete',
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskId: task.id,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-run-complete',
status: RUN_STATUS.RUNNING,
leaseOwner: 'worker:story-to-script',
leaseExpiresAt: new Date('2026-03-30T08:05:00.000Z'),
heartbeatAt: new Date('2026-03-30T07:59:30.000Z'),
queuedAt: new Date('2026-03-30T07:55:00.000Z'),
startedAt: new Date('2026-03-30T07:56:00.000Z'),
},
})
await prisma.graphStep.create({
data: {
runId: run.id,
stepKey: 'story_to_script_persist',
stepTitle: 'Persist screenplay',
status: RUN_STEP_STATUS.RUNNING,
currentAttempt: 1,
stepIndex: 4,
stepTotal: 4,
startedAt: new Date('2026-03-30T07:58:00.000Z'),
},
})
const reconciled = await reconcileActiveRunsFromTasks()
expect(reconciled).toEqual([{
runId: run.id,
taskId: task.id,
nextStatus: 'completed',
reason: 'linked task already completed',
}])
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
expect(refreshedRun).toMatchObject({
status: RUN_STATUS.COMPLETED,
output: {
episodeId: 'episode-run-complete',
persistedClips: 12,
},
errorCode: null,
errorMessage: null,
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
})
expect(refreshedRun?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
const refreshedStep = await prisma.graphStep.findUnique({
where: {
runId_stepKey: {
runId: run.id,
stepKey: 'story_to_script_persist',
},
},
})
expect(refreshedStep).toMatchObject({
status: RUN_STEP_STATUS.COMPLETED,
lastErrorCode: null,
lastErrorMessage: null,
})
expect(refreshedStep?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
})
it('marks a running run failed when the linked task already failed', async () => {
const user = await createTestUser()
const finishedAt = new Date('2026-03-30T09:00:00.000Z')
const task = await prisma.task.create({
data: {
userId: user.id,
projectId: 'project-run-failed',
episodeId: 'episode-run-failed',
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-run-failed',
status: TASK_STATUS.FAILED,
progress: 72,
payload: { episodeId: 'episode-run-failed' },
errorCode: 'WATCHDOG_TIMEOUT',
errorMessage: 'Task heartbeat timeout',
queuedAt: new Date('2026-03-30T08:50:00.000Z'),
startedAt: new Date('2026-03-30T08:51:00.000Z'),
finishedAt,
},
})
const run = await prisma.graphRun.create({
data: {
userId: user.id,
projectId: 'project-run-failed',
episodeId: 'episode-run-failed',
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
taskId: task.id,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-run-failed',
status: RUN_STATUS.RUNNING,
leaseOwner: 'worker:story-to-script',
leaseExpiresAt: new Date('2026-03-30T08:55:00.000Z'),
heartbeatAt: new Date('2026-03-30T08:54:00.000Z'),
queuedAt: new Date('2026-03-30T08:50:00.000Z'),
startedAt: new Date('2026-03-30T08:51:00.000Z'),
},
})
await prisma.graphStep.create({
data: {
runId: run.id,
stepKey: 'screenplay_clip-1',
stepTitle: 'Screenplay clip 1',
status: RUN_STEP_STATUS.RUNNING,
currentAttempt: 1,
stepIndex: 3,
stepTotal: 6,
startedAt: new Date('2026-03-30T08:52:00.000Z'),
},
})
const reconciled = await reconcileActiveRunsFromTasks()
expect(reconciled).toEqual([{
runId: run.id,
taskId: task.id,
nextStatus: 'failed',
reason: 'linked task already failed',
}])
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
expect(refreshedRun).toMatchObject({
status: RUN_STATUS.FAILED,
errorCode: 'WATCHDOG_TIMEOUT',
errorMessage: 'Task heartbeat timeout',
leaseOwner: null,
leaseExpiresAt: null,
heartbeatAt: null,
})
expect(refreshedRun?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
const refreshedStep = await prisma.graphStep.findUnique({
where: {
runId_stepKey: {
runId: run.id,
stepKey: 'screenplay_clip-1',
},
},
})
expect(refreshedStep).toMatchObject({
status: RUN_STEP_STATUS.FAILED,
lastErrorCode: 'WATCHDOG_TIMEOUT',
lastErrorMessage: 'Task heartbeat timeout',
})
expect(refreshedStep?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
})
})

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createRun } from '@/lib/run-runtime/service'
import { submitTask } from '@/lib/task/submitter'
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
import { prisma } from '../helpers/prisma'
import { resetBillingState } from '../helpers/db-reset'
import { createTestUser } from '../helpers/billing-fixtures'
const addTaskJobMock = vi.hoisted(() => vi.fn(async () => ({ id: 'mock-job' })))
const publishTaskEventMock = vi.hoisted(() => vi.fn(async () => ({})))
vi.mock('@/lib/task/queues', () => ({
addTaskJob: addTaskJobMock,
}))
vi.mock('@/lib/task/publisher', () => ({
publishTaskEvent: publishTaskEventMock,
}))
describe('regression - reusable active run reattach', () => {
beforeEach(async () => {
await resetBillingState()
vi.clearAllMocks()
process.env.BILLING_MODE = 'OFF'
})
it('reattaches a new run-centric task to the existing active run when the linked task is already terminal', async () => {
const user = await createTestUser()
const failedTask = await prisma.task.create({
data: {
userId: user.id,
projectId: 'project-regression-run',
episodeId: 'episode-regression-run',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-regression-run',
status: TASK_STATUS.FAILED,
errorCode: 'TEST_FAILED',
errorMessage: 'old task already failed',
payload: {
episodeId: 'episode-regression-run',
analysisModel: 'model-core',
meta: { locale: 'zh' },
},
queuedAt: new Date(),
finishedAt: new Date(),
},
})
const run = await createRun({
userId: user.id,
projectId: 'project-regression-run',
episodeId: 'episode-regression-run',
workflowType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
taskType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
taskId: failedTask.id,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-regression-run',
input: {
episodeId: 'episode-regression-run',
analysisModel: 'model-core',
meta: { locale: 'zh' },
},
})
const result = await submitTask({
userId: user.id,
locale: 'zh',
projectId: 'project-regression-run',
episodeId: 'episode-regression-run',
type: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
targetType: 'NovelPromotionEpisode',
targetId: 'episode-regression-run',
payload: {
episodeId: 'episode-regression-run',
analysisModel: 'model-core',
},
dedupeKey: 'script_to_storyboard:episode-regression-run',
})
expect(result.deduped).toBe(false)
expect(result.runId).toBe(run.id)
expect(result.taskId).not.toBe(failedTask.id)
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
const newTask = await prisma.task.findUnique({ where: { id: result.taskId } })
expect(refreshedRun?.taskId).toBe(result.taskId)
expect(newTask?.status).toBe(TASK_STATUS.QUEUED)
expect(newTask?.payload).toMatchObject({
runId: run.id,
})
})
})

View File

@@ -65,13 +65,14 @@ vi.mock('@/lib/novel-promotion/script-to-storyboard/orchestrator', async () => {
clipPanels: [
{
clipId: textState.orchestratorClipId,
panels: [
clipIndex: 0,
finalPanels: [
{
panelIndex: 1,
shotType: 'close-up',
cameraMove: 'static',
panel_number: 1,
shot_type: 'close-up',
camera_move: 'static',
description: 'system generated panel',
videoPrompt: 'system video prompt',
video_prompt: 'system video prompt',
location: 'Office',
characters: ['Narrator'],
},
@@ -212,8 +213,8 @@ describe('system - text workflows', () => {
content: 'Hello world',
emotionStrength: 0.8,
matchedPanel: {
storyboardId: 'storyboard-1',
panelIndex: 1,
storyboardId: seeded.clip.id,
panelIndex: 0,
},
},
]
@@ -261,7 +262,7 @@ describe('system - text workflows', () => {
speaker: 'Narrator',
content: 'Hello world',
matchedPanelId: expect.any(String),
matchedPanelIndex: 1,
matchedPanelIndex: 0,
},
])
@@ -283,8 +284,8 @@ describe('system - text workflows', () => {
content: 'Retry success',
emotionStrength: 0.4,
matchedPanel: {
storyboardId: 'storyboard-1',
panelIndex: 1,
storyboardId: seeded.clip.id,
panelIndex: 0,
},
},
]

View File

@@ -63,6 +63,7 @@ describe('location-backed assets service', () => {
locationId: result.id,
imageIndex: 0,
description: 'Old bronze dagger',
availableSlots: '[]',
},
],
})
@@ -83,11 +84,13 @@ describe('location-backed assets service', () => {
locationId: 'location-1',
imageIndex: 0,
description: 'Night street',
availableSlots: '[]',
},
{
locationId: 'location-1',
imageIndex: 1,
description: 'Rainy alley',
availableSlots: '[]',
},
],
})

View File

@@ -24,6 +24,9 @@ describe('asset prompt context', () => {
{
isSelected: true,
description: '夜晚天台,冷风,霓虹远景',
availableSlots: JSON.stringify([
'天台栏杆左侧靠近边缘的位置',
]),
},
],
},
@@ -42,7 +45,7 @@ describe('asset prompt context', () => {
expect(compileAssetPromptFragments(context)).toEqual({
appearanceListText: '小雨/雨: ["初始形象"]',
fullDescriptionText: '【小雨/雨 - 初始形象】黑色短发,校服,冷静表情',
locationDescriptionText: '夜晚天台,冷风,霓虹远景',
locationDescriptionText: '夜晚天台,冷风,霓虹远景\n\n可站位置\n- 天台栏杆左侧靠近边缘的位置',
propsDescriptionText: '【青铜匕首】古旧短刃,雕纹手柄',
charactersIntroductionText: '暂无角色介绍',
})

View File

@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { copyPreviewJsonText } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModalPreviewPane'
describe('AIDataModalPreviewPane copy helper', () => {
it('falls back to execCommand when clipboard api rejects', async () => {
const writeText = vi.fn(async () => {
throw new Error('clipboard denied')
})
const appendChild = vi.fn()
const removeChild = vi.fn()
const select = vi.fn()
const textarea = {
value: '',
style: {} as Record<string, string>,
select,
}
vi.stubGlobal('navigator', { clipboard: { writeText } })
vi.stubGlobal('document', {
body: {
appendChild,
removeChild,
},
createElement: vi.fn(() => textarea),
execCommand: vi.fn(() => true),
})
await expect(copyPreviewJsonText('{"a":1}')).resolves.toBeUndefined()
expect(writeText).toHaveBeenCalledWith('{"a":1}')
expect(appendChild).toHaveBeenCalledWith(textarea)
expect(select).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,48 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import AIDataModal from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/AIDataModal'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}))
describe('AIDataModal', () => {
it('在查看数据预览中展示角色完整数据与 slot', () => {
Reflect.set(globalThis, 'React', React)
vi.stubGlobal('document', { body: {} })
const html = renderToStaticMarkup(
createElement(AIDataModal, {
isOpen: true,
onClose: () => undefined,
panelNumber: 1,
shotType: 'medium shot',
cameraMove: 'static',
description: '皇帝立于大殿中央',
location: '皇宫大殿',
characters: [
{
name: '皇帝',
appearance: '朝服形象',
slot: '皇宫正中龙椅前方台阶下的位置',
},
],
videoPrompt: 'dramatic court scene',
photographyRules: null,
actingNotes: null,
videoRatio: '16:9',
onSave: () => undefined,
}),
)
expect(html).toContain('&quot;characters&quot;')
expect(html).toContain('&quot;appearance&quot;: &quot;朝服形象&quot;')
expect(html).toContain('&quot;slot&quot;: &quot;皇宫正中龙椅前方台阶下的位置&quot;')
})
})

View File

@@ -0,0 +1,46 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { CapsuleNav, EpisodeSelector } from '@/components/ui/CapsuleNav'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('@/components/ui/icons', () => ({
AppIcon: ({ name, className }: { name: string; className?: string }) =>
createElement('span', { 'data-icon': name, className }),
}))
describe('CapsuleNav layering', () => {
it('keeps fixed workspace navigation below modal overlays', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement('div', null,
createElement(CapsuleNav, {
items: [
{ id: 'config', icon: 'sparkles', label: '配置', status: 'active' as const },
],
activeId: 'config',
onItemClick: () => undefined,
projectId: 'project-1',
}),
createElement(EpisodeSelector, {
episodes: [
{ id: 'episode-1', title: '剧集 1' },
],
currentId: 'episode-1',
onSelect: () => undefined,
projectName: '项目 A',
}),
),
)
expect(html).toContain('fixed top-20 left-1/2 -translate-x-1/2 z-40')
expect(html).toContain('fixed top-20 left-6 z-40')
expect(html).not.toContain('z-50 animate-fadeInDown')
expect(html).not.toContain('z-[60]')
})
})

View File

@@ -22,8 +22,56 @@ describe('ImageGenerationInlineCountButton', () => {
}),
)
expect(html).toContain('role="button"')
expect(html).toContain('aria-disabled="true"')
expect(html).toContain('opacity-60 cursor-not-allowed')
expect(html).not.toContain('<select disabled=""')
expect(html).toContain('rounded-full bg-white/12')
expect(html).toContain('inline-flex shrink-0 items-center whitespace-nowrap leading-none')
})
it('renders the count control as a rounded inline pill with the chevron inside it', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(ImageGenerationInlineCountButton, {
prefix: createElement('span', null, '重新生成'),
suffix: createElement('span', null, '张'),
value: 2,
options: [1, 2, 3],
onValueChange: () => undefined,
onClick: () => undefined,
ariaLabel: '选择重新生成数量',
}),
)
expect(html).toContain('重新生成')
expect(html).toContain('张')
expect(html).toContain('whitespace-nowrap')
expect(html).toContain('rounded-full bg-white/12')
expect(html).toContain('right-2')
expect(html).toContain('hover:bg-white/16')
})
it('can render a regenerate action without exposing the count selector', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(ImageGenerationInlineCountButton, {
prefix: createElement('span', null, '重新生成'),
suffix: null,
value: 2,
options: [1, 2, 3],
onValueChange: () => undefined,
onClick: () => undefined,
showCountControl: false,
ariaLabel: '重新生成当前图片',
className: 'inline-flex h-6 items-center justify-center rounded-md px-1.5',
}),
)
expect(html).toContain('重新生成')
expect(html).toContain('type="button"')
expect(html).not.toContain('<select')
expect(html).not.toContain('rounded-full bg-white/12')
})
})

View File

@@ -0,0 +1,72 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import LongTextDetectionPrompt from '@/components/story-input/LongTextDetectionPrompt'
const portalMocks = vi.hoisted(() => {
return {
currentPortalTarget: null as unknown,
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
return createElement('div', { 'data-portal-target': targetLabel }, node)
}),
}
})
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
return {
...actual,
createPortal: portalMocks.createPortalMock,
}
})
vi.mock('@/components/ui/icons', () => ({
AppIcon: ({ name, className }: { name: string; className?: string }) =>
createElement('span', { 'data-icon': name, className }),
}))
describe('LongTextDetectionPrompt', () => {
afterEach(() => {
vi.clearAllMocks()
portalMocks.currentPortalTarget = null
Reflect.deleteProperty(globalThis, 'React')
Reflect.deleteProperty(globalThis, 'document')
})
it('renders through document.body at modal layer without the removed gradient border wrapper', () => {
const fakeDocument = {
body: { nodeName: 'BODY' },
}
Reflect.set(globalThis, 'React', React)
Reflect.set(globalThis, 'document', fakeDocument)
portalMocks.currentPortalTarget = fakeDocument.body
const html = renderToStaticMarkup(
createElement(LongTextDetectionPrompt, {
open: true,
copy: {
title: '建议使用智能分集',
description: '检测到文本较长',
strongRecommend: '建议拆分',
smartSplitLabel: '智能分集',
smartSplitBadge: '推荐',
continueLabel: '仍然单集创作',
continueHint: '单集模式',
},
onClose: () => undefined,
onSmartSplit: () => undefined,
onContinue: () => undefined,
}),
)
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(1)
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
expect(html).toContain('data-portal-target="body"')
expect(html).toContain('z-[120]')
expect(html).toContain('border-[var(--glass-stroke-base)]')
expect(html).not.toContain('p-[1.5px]')
})
})

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { lockModalPageScroll } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/storyboard/modal-scroll-lock'
describe('modal scroll lock', () => {
it('locks page scroll while modal is open and restores previous styles on cleanup', () => {
const doc = {
body: {
style: {
overflow: 'auto',
},
},
documentElement: {
style: {
overflow: 'scroll',
},
},
}
const restore = lockModalPageScroll(doc)
expect(doc.body.style.overflow).toBe('hidden')
expect(doc.documentElement.style.overflow).toBe('hidden')
restore()
expect(doc.body.style.overflow).toBe('auto')
expect(doc.documentElement.style.overflow).toBe('scroll')
})
})

View File

@@ -0,0 +1,64 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import WorkspaceRunStreamConsoles from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/WorkspaceRunStreamConsoles'
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}))
vi.mock('@/components/llm-console/LLMStageStreamCard', () => ({
__esModule: true,
default: ({ title }: { title: string }) => createElement('section', null, `LLMStageStreamCard:${title}`),
}))
function createStreamState(overrides?: Partial<React.ComponentProps<typeof WorkspaceRunStreamConsoles>['storyToScriptStream']>) {
return {
status: 'running' as const,
isVisible: true,
isRecoveredRunning: true,
stages: [],
selectedStep: null,
activeStepId: null,
outputText: '',
activeMessage: '',
overallProgress: 0,
isRunning: false,
errorMessage: '',
stop: () => undefined,
reset: () => undefined,
selectStep: () => undefined,
retryStep: async () => ({
runId: 'run-1',
status: 'running',
summary: null,
payload: null,
errorMessage: '',
}),
...overrides,
}
}
describe('WorkspaceRunStreamConsoles', () => {
it('shows fallback running console when a recovered run has no stages yet', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(WorkspaceRunStreamConsoles, {
storyToScriptStream: createStreamState(),
scriptToStoryboardStream: createStreamState({
status: 'idle',
isVisible: false,
isRecoveredRunning: false,
}),
storyToScriptConsoleMinimized: false,
scriptToStoryboardConsoleMinimized: true,
onStoryToScriptMinimizedChange: () => undefined,
onScriptToStoryboardMinimizedChange: () => undefined,
}),
)
expect(html).toContain('LLMStageStreamCard:runConsole.storyToScript')
})
})

View File

@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
recoveryProbeTestUtils,
startRecoveryProbe,
} from '@/lib/query/hooks/run-stream/recovery-probe'
describe('recovery probe', () => {
afterEach(() => {
vi.useRealTimers()
recoveryProbeTestUtils.clearSuccessfulProbeScopes()
})
it('retries active run recovery when the first probe misses and a later probe finds a run', async () => {
vi.useFakeTimers()
const resolveActiveRunId = vi
.fn<({ projectId, storageScopeKey }: { projectId: string; storageScopeKey?: string }) => Promise<string | null>>()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('run-2')
const onRecovered = vi.fn()
const cleanup = startRecoveryProbe({
projectId: 'project-1',
storageKey: 'scope:story-to-script:episode-1',
storageScopeKey: 'episode-1',
hasRunState: () => false,
resolveActiveRunId,
onRecovered,
})
await vi.advanceTimersByTimeAsync(0)
expect(resolveActiveRunId).toHaveBeenCalledTimes(1)
expect(resolveActiveRunId).toHaveBeenLastCalledWith({
projectId: 'project-1',
storageScopeKey: 'episode-1',
})
expect(onRecovered).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(
recoveryProbeTestUtils.PROBE_RETRY_INTERVAL_MS,
)
expect(resolveActiveRunId).toHaveBeenCalledTimes(2)
expect(onRecovered).toHaveBeenCalledTimes(1)
expect(onRecovered).toHaveBeenCalledWith('run-2')
cleanup()
})
})

View File

@@ -1,7 +1,7 @@
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'
import { isActiveTaskStatus, normalizeTaskPayload, shouldAttachNewTaskToReusableRun } from '@/lib/task/submitter'
describe('task submitter helpers', () => {
it('fills default flow metadata when payload misses flow fields', () => {
@@ -56,4 +56,14 @@ describe('task submitter helpers', () => {
expect(meta.flowStageTotal).toBe(7)
expect(meta.flowStageTitle).toBe('Meta')
})
it('reuses linked runs only while the existing task is still active', () => {
expect(isActiveTaskStatus('queued')).toBe(true)
expect(isActiveTaskStatus('processing')).toBe(true)
expect(isActiveTaskStatus('completed')).toBe(false)
expect(shouldAttachNewTaskToReusableRun('queued')).toBe(false)
expect(shouldAttachNewTaskToReusableRun('processing')).toBe(false)
expect(shouldAttachNewTaskToReusableRun('failed')).toBe(true)
expect(shouldAttachNewTaskToReusableRun(null)).toBe(true)
})
})

View File

@@ -44,7 +44,6 @@ describe('createHomeProjectLaunch', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: '开场白',
description: '第一章内容',
}),
})
expect(apiFetch).toHaveBeenNthCalledWith(2, '/api/novel-promotion/project-1', {

View File

@@ -26,15 +26,20 @@ vi.mock('@/components/Navbar', () => ({
vi.mock('@/components/story-input/StoryInputComposer', () => ({
default: ({
minRows,
textareaClassName,
primaryAction,
secondaryActions,
}: {
minRows: number
textareaClassName?: string
primaryAction: React.ReactNode
secondaryActions?: React.ReactNode
}) => createElement(
'section',
{ 'data-min-rows': String(minRows) },
{
'data-min-rows': String(minRows),
'data-textarea-class': textareaClassName,
},
secondaryActions,
primaryAction,
'StoryInputComposer',
@@ -103,5 +108,6 @@ describe('HomePage quick-start input', () => {
expect(HOME_QUICK_START_MIN_ROWS).toBe(3)
expect(html).toContain('StoryInputComposer')
expect(html).toContain('data-min-rows="3"')
expect(html).toContain('data-textarea-class="px-0 pt-0 pb-3 align-top"')
})
})

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { formatLocationAvailableSlotsText } from '@/lib/location-available-slots'
describe('location available slots', () => {
it('formats english slot headers without leaking chinese labels', () => {
const text = formatLocationAvailableSlotsText(
['left side near the wall'],
'en',
)
expect(text).toBe('Available character slots:\n- left side near the wall')
expect(text).not.toContain('可站位置:')
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { buildInsertPanelLocationsDescription } from '@/lib/novel-promotion/insert-panel-prompt-context'
describe('insert panel prompt context', () => {
it('injects available slots for related selected location images', () => {
const text = buildInsertPanelLocationsDescription(
[
{
name: '餐厅',
images: [
{
isSelected: true,
description: '长方形饭桌位于画面中央',
availableSlots: JSON.stringify([
'饭桌左侧靠桌边的位置',
]),
},
],
},
{
name: '客厅',
images: [{ isSelected: true, description: '不会被选中' }],
},
],
['餐厅'],
)
expect(text).toContain('餐厅: 长方形饭桌位于画面中央')
expect(text).toContain('可站位置:')
expect(text).toContain('饭桌左侧靠桌边的位置')
expect(text).not.toContain('客厅')
})
})

View File

@@ -17,6 +17,7 @@ vi.mock('@/components/story-input/StoryInputComposer', () => ({
default: ({
minRows,
maxHeightViewportRatio,
textareaClassName,
topRight,
footer,
secondaryActions,
@@ -24,6 +25,7 @@ vi.mock('@/components/story-input/StoryInputComposer', () => ({
}: {
minRows: number
maxHeightViewportRatio: number
textareaClassName?: string
topRight?: React.ReactNode
footer?: React.ReactNode
secondaryActions?: React.ReactNode
@@ -33,6 +35,7 @@ vi.mock('@/components/story-input/StoryInputComposer', () => ({
{
'data-min-rows': String(minRows),
'data-max-height-ratio': String(maxHeightViewportRatio),
'data-textarea-class': textareaClassName,
},
topRight,
footer,
@@ -79,6 +82,7 @@ describe('NovelInputStage', () => {
expect(html).toContain('StoryInputComposer')
expect(html).toContain('data-min-rows="8"')
expect(html).toContain('data-max-height-ratio="0.5"')
expect(html).toContain('data-textarea-class="px-0 pt-0 pb-3 align-top"')
expect(html).toContain('aiWrite.trigger')
expect(html).toContain('AiWriteModal')
expect(html).not.toContain('storyInput.wordCount 0')

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import {
hasScriptArtifacts,
hasStoryboardArtifacts,
hasVideoArtifacts,
resolveEpisodeStageArtifacts,
} from '@/lib/novel-promotion/stage-readiness'
describe('stage readiness', () => {
it('treats script as ready only when at least one clip has non-empty screenplay', () => {
expect(hasScriptArtifacts([])).toBe(false)
expect(hasScriptArtifacts([
{ id: 'clip-1', summary: '', location: null, characters: null, props: null, content: 'a', screenplay: '' },
])).toBe(false)
expect(hasScriptArtifacts([
{ id: 'clip-1', summary: '', location: null, characters: null, props: null, content: 'a', screenplay: ' {"scenes":[]}' },
])).toBe(true)
})
it('treats storyboard as ready only when at least one storyboard has panels', () => {
expect(hasStoryboardArtifacts([])).toBe(false)
expect(hasStoryboardArtifacts([{ panels: [] }])).toBe(false)
expect(hasStoryboardArtifacts([{ panels: [{ id: 'panel-1' }] }])).toBe(true)
})
it('treats video as ready only when at least one panel has videoUrl', () => {
expect(hasVideoArtifacts([{ panels: [{ id: 'panel-1', videoUrl: '' }] }])).toBe(false)
expect(hasVideoArtifacts([{ panels: [{ id: 'panel-1', videoUrl: 'https://example.com/video.mp4' }] }])).toBe(true)
})
it('derives full episode stage artifacts from persisted outputs', () => {
const readiness = resolveEpisodeStageArtifacts({
novelText: 'story',
clips: [
{ id: 'clip-1', summary: '', location: null, characters: null, props: null, content: 'a', screenplay: '{"scenes":[]}' },
],
storyboards: [
{
id: 'sb-1',
episodeId: 'ep-1',
clipId: 'clip-1',
storyboardTextJson: null,
panelCount: 1,
storyboardImageUrl: null,
panels: [{
id: 'panel-1',
storyboardId: 'sb-1',
panelIndex: 0,
panelNumber: 1,
shotType: null,
cameraMove: null,
description: null,
location: null,
characters: null,
props: null,
srtSegment: null,
srtStart: null,
srtEnd: null,
duration: null,
imagePrompt: null,
imageUrl: null,
imageHistory: null,
videoPrompt: null,
videoUrl: 'https://example.com/video.mp4',
photographyRules: null,
actingNotes: null,
}],
},
],
voiceLines: [{ id: 'voice-1' }],
})
expect(readiness).toEqual({
hasStory: true,
hasScript: true,
hasStoryboard: true,
hasVideo: true,
hasVoice: true,
})
})
})

View File

@@ -70,6 +70,22 @@ describe('panel ai data sync helpers', () => {
})
})
it('preserves slot on remaining characters after removal', () => {
const synced = syncPanelCharacterDependentJson({
characters: [
{ name: '甲', appearance: '初始形象', slot: '餐桌左侧靠桌边的位置' },
{ name: '乙', appearance: '初始形象', slot: '餐桌右侧靠桌边的位置' },
],
removeIndex: 0,
actingNotesJson: null,
photographyRulesJson: null,
})
expect(synced.characters).toEqual([
{ name: '乙', appearance: '初始形象', slot: '餐桌右侧靠桌边的位置' },
])
})
it('supports double-serialized JSON string inputs', () => {
const actingNotes = JSON.stringify([{ name: '甲', acting: '动作' }])
const doubleSerialized = JSON.stringify(actingNotes)

View File

@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest'
import { formatDefaultProjectTimestamp } from '@/lib/projects/default-name'
describe('default project name timestamp', () => {
it('formats month-day and hour-minute without year', () => {
expect(formatDefaultProjectTimestamp(new Date('2026-03-29T18:56:42+08:00'))).toBe('03-29 18:56')
})
})

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
normalizeProjectDraft,
validateProjectDraft,
} from '@/lib/projects/validation'
describe('project validation', () => {
it('normalizes blank descriptions to null', () => {
expect(normalizeProjectDraft({
name: ' 项目 A ',
description: ' ',
})).toEqual({
name: '项目 A',
description: null,
})
})
it('rejects descriptions longer than the shared max limit', () => {
expect(validateProjectDraft({
name: '项目 A',
description: 'a'.repeat(501),
})).toEqual({
code: 'PROJECT_DESCRIPTION_TOO_LONG',
field: 'description',
limit: 500,
})
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { RUN_STATUS } from '@/lib/run-runtime/types'
import { isRecoverableRunRecord, selectRecoverableRun } from '@/lib/run-runtime/recovery'
describe('run recovery', () => {
it('treats queued runs as recoverable', () => {
expect(isRecoverableRunRecord({
id: 'run-1',
status: RUN_STATUS.QUEUED,
})).toBe(true)
})
it('rejects running runs with expired lease and no heartbeat extension', () => {
const now = Date.parse('2026-03-30T10:00:00.000Z')
expect(isRecoverableRunRecord({
id: 'run-1',
status: RUN_STATUS.RUNNING,
leaseExpiresAt: '2026-03-30T09:59:00.000Z',
heartbeatAt: '2026-03-30T09:58:30.000Z',
}, now)).toBe(false)
})
it('prefers the latest recoverable run and skips stale leased runs', () => {
const now = Date.parse('2026-03-30T10:00:00.000Z')
const decision = selectRecoverableRun([
{
id: 'run-stale',
status: RUN_STATUS.RUNNING,
createdAt: '2026-03-30T09:00:00.000Z',
updatedAt: '2026-03-30T09:30:00.000Z',
leaseExpiresAt: '2026-03-30T09:59:00.000Z',
heartbeatAt: '2026-03-30T09:58:30.000Z',
},
{
id: 'run-fresh',
status: RUN_STATUS.RUNNING,
createdAt: '2026-03-30T09:50:00.000Z',
updatedAt: '2026-03-30T09:59:30.000Z',
leaseExpiresAt: '2026-03-30T10:02:00.000Z',
heartbeatAt: '2026-03-30T09:59:30.000Z',
},
], now)
expect(decision).toEqual({
runId: 'run-fresh',
reason: 'latest_active',
})
})
it('skips a newer stale active run when an older recoverable run still exists', () => {
const now = Date.parse('2026-03-30T10:00:00.000Z')
const decision = selectRecoverableRun([
{
id: 'run-new-stale',
status: RUN_STATUS.RUNNING,
createdAt: '2026-03-30T09:58:00.000Z',
updatedAt: '2026-03-30T09:59:50.000Z',
leaseExpiresAt: '2026-03-30T09:59:00.000Z',
heartbeatAt: '2026-03-30T09:58:30.000Z',
},
{
id: 'run-older-fresh',
status: RUN_STATUS.RUNNING,
createdAt: '2026-03-30T09:50:00.000Z',
updatedAt: '2026-03-30T09:59:30.000Z',
leaseExpiresAt: '2026-03-30T10:02:00.000Z',
heartbeatAt: '2026-03-30T09:59:30.000Z',
},
], now)
expect(decision).toEqual({
runId: 'run-older-fresh',
reason: 'latest_active',
})
})
})

View File

@@ -185,6 +185,7 @@ describe('worker analyze-novel behavior', () => {
locationId: 'loc-new-1',
imageIndex: 0,
description: '雨夜街道',
availableSlots: '[]',
},
],
})
@@ -194,6 +195,7 @@ describe('worker analyze-novel behavior', () => {
locationId: 'prop-new-1',
imageIndex: 0,
description: '一根两头包裹金片的黑铁长棍',
availableSlots: '[]',
},
],
})

View File

@@ -78,7 +78,10 @@ describe('worker asset-hub-ai-design behavior', () => {
projectId: 'global-asset-hub',
skipBilling: true,
}))
expect(result).toEqual({ prompt: 'generated prompt' })
expect(result).toEqual({
prompt: 'generated prompt',
availableSlots: [],
})
})
it('location type success -> passes location assetType', async () => {

View File

@@ -124,6 +124,7 @@ describe('worker asset-hub-ai-modify behavior', () => {
expect(result).toEqual({
success: true,
modifiedDescription: 'modified description',
availableSlots: [],
})
expect(llmStreamMock.flush).toHaveBeenCalled()
})
@@ -140,6 +141,7 @@ describe('worker asset-hub-ai-modify behavior', () => {
expect(result).toEqual({
success: true,
modifiedDescription: 'modified description',
availableSlots: [],
})
})
})

View File

@@ -63,6 +63,9 @@ describe('worker location-image-task-handler behavior', () => {
locationId: 'location-1',
imageIndex: 0,
description: '雨夜街道',
availableSlots: JSON.stringify([
'街道左侧靠墙的留白位置',
]),
location: { name: 'Old Town' },
})
@@ -75,6 +78,9 @@ describe('worker location-image-task-handler behavior', () => {
locationId: 'location-1',
imageIndex: 0,
description: '雨夜街道',
availableSlots: JSON.stringify([
'街道左侧靠墙的留白位置',
]),
},
],
})
@@ -96,12 +102,27 @@ describe('worker location-image-task-handler behavior', () => {
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: `雨夜街道,${animeStylePrompt}`,
prompt: expect.stringContaining('雨夜街道'),
label: 'Old Town',
targetId: 'location-image-1',
options: expect.objectContaining({ aspectRatio: '1:1' }),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('可站位置:'),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('街道左侧靠墙的留白位置'),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('必须使用宽广完整的场景全景构图'),
}),
)
const generationCall = sharedMock.generateLabeledImageToCos.mock.calls[0] as unknown as [{ prompt: string }] | undefined
expect(generationCall).toBeTruthy()
if (!generationCall) throw new Error('expected generateLabeledImageToCos call')
@@ -119,7 +140,7 @@ describe('worker location-image-task-handler behavior', () => {
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect.objectContaining({
prompt: `雨夜街道,${getArtStylePrompt('realistic', 'zh')}`,
prompt: expect.stringContaining(getArtStylePrompt('realistic', 'zh')),
}),
)
})

View File

@@ -21,7 +21,20 @@ const sharedMock = vi.hoisted(() => ({
resolveNovelData: vi.fn(async () => ({
videoRatio: '16:9',
characters: [],
locations: [],
locations: [
{
name: 'Old Town',
images: [
{
isSelected: true,
description: '雨夜街道',
availableSlots: JSON.stringify([
'街道左侧靠墙的留白位置',
]),
},
],
},
],
})),
}))
@@ -29,6 +42,10 @@ const outboundMock = vi.hoisted(() => ({
normalizeReferenceImagesForGeneration: vi.fn(async () => ['normalized-ref-1']),
}))
const promptMock = vi.hoisted(() => ({
buildPrompt: vi.fn(() => 'panel-image-prompt'),
}))
vi.mock('@/lib/prisma', () => ({ prisma: prismaMock }))
vi.mock('@/lib/workers/utils', () => utilsMock)
vi.mock('@/lib/media/outbound-image', () => outboundMock)
@@ -56,7 +73,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
})
vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { NP_SINGLE_PANEL_IMAGE: 'np_single_panel_image' },
buildPrompt: vi.fn(() => 'panel-image-prompt'),
buildPrompt: promptMock.buildPrompt,
}))
import { handlePanelImageTask } from '@/lib/workers/handlers/panel-image-task-handler'
@@ -88,9 +105,10 @@ describe('worker panel-image-task-handler behavior', () => {
shotType: 'close-up',
cameraMove: 'static',
description: 'hero close-up',
imagePrompt: 'panel anchor prompt',
videoPrompt: 'dramatic',
location: 'Old Town',
characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),
characters: JSON.stringify([{ name: 'Hero', appearance: 'default', slot: '街道左侧靠墙的留白位置' }]),
srtSegment: '台词片段',
photographyRules: null,
actingNotes: null,
@@ -134,6 +152,16 @@ describe('worker panel-image-task-handler behavior', () => {
}),
}),
)
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
storyboard_text_json_input: expect.stringContaining('"slot": "街道左侧靠墙的留白位置"'),
}),
}))
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
storyboard_text_json_input: expect.stringContaining('"available_slots"'),
}),
}))
expect(prismaMock.novelPromotionPanel.update).toHaveBeenCalledWith({
where: { id: 'panel-1' },
@@ -155,6 +183,7 @@ describe('worker panel-image-task-handler behavior', () => {
shotType: 'close-up',
cameraMove: 'static',
description: 'hero close-up',
imagePrompt: null,
videoPrompt: 'dramatic',
location: 'Old Town',
characters: '[]',

View File

@@ -30,7 +30,16 @@ const sharedMock = vi.hoisted(() => ({
imageUrl: 'cos/hero-default.png',
}],
}],
locations: [{ name: 'Old Town', images: [] }],
locations: [{
name: 'Old Town',
images: [{
isSelected: true,
description: '老街中央留出明确人物站位',
availableSlots: JSON.stringify([
'街道左侧靠墙的留白位置',
]),
}],
}],
})),
}))
@@ -63,12 +72,15 @@ vi.mock('@/lib/prompt-i18n', () => ({
import { handlePanelVariantTask } from '@/lib/workers/handlers/panel-variant-task-handler'
function buildJob(payload: Record<string, unknown>): Job<TaskJobData> {
function buildJob(
payload: Record<string, unknown>,
locale: TaskJobData['locale'] = 'zh',
): Job<TaskJobData> {
return {
data: {
taskId: 'task-panel-variant-1',
type: TASK_TYPE.PANEL_VARIANT,
locale: 'zh',
locale,
projectId: 'project-1',
episodeId: 'episode-1',
targetType: 'NovelPromotionPanel',
@@ -90,7 +102,7 @@ describe('worker panel-variant-task-handler behavior', () => {
storyboardId: 'storyboard-1',
imageUrl: null,
location: 'Old Town',
characters: JSON.stringify([{ name: 'Hero', appearance: 'default' }]),
characters: JSON.stringify([{ name: 'Hero', appearance: 'default', slot: '街道左侧靠墙的留白位置' }]),
}
}
if (args.where.id === 'panel-source') {
@@ -145,6 +157,12 @@ describe('worker panel-variant-task-handler behavior', () => {
where: { id: 'panel-new' },
data: { imageUrl: 'cos/panel-variant-new.png' },
})
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
characters_info: expect.stringContaining('固定位置:街道左侧靠墙的留白位置'),
location_asset: expect.stringContaining('街道左侧靠墙的留白位置'),
}),
}))
expect(result).toEqual({
panelId: 'panel-new',
@@ -178,4 +196,30 @@ describe('worker panel-variant-task-handler behavior', () => {
}),
}))
})
it('uses localized slot labels in english variant prompts', async () => {
const payload = {
newPanelId: 'panel-new',
sourcePanelId: 'panel-source',
variant: {
title: 'Rainy night version',
description: 'Keep the same staging but change the mood',
video_prompt: 'Keep the same staging but change the mood',
},
}
await handlePanelVariantTask(buildJob(payload, 'en'))
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
locale: 'en',
variables: expect.objectContaining({
location_asset: expect.stringContaining('Available character slots:'),
}),
}))
expect(promptMock.buildPrompt).toHaveBeenCalledWith(expect.objectContaining({
variables: expect.objectContaining({
location_asset: expect.not.stringContaining('可站位置:'),
}),
}))
})
})

View File

@@ -274,4 +274,97 @@ describe('script-to-storyboard orchestrator retry', () => {
expect(result.summary.clipCount).toBe(3)
expect(maxActivePhase1).toBe(1)
})
it('pipelines clips so one clip can enter phase2 before another clip finishes phase1', async () => {
let releaseClip1Phase1: (() => void) | null = null
const clip1Phase1Gate = new Promise<void>((resolve) => {
releaseClip1Phase1 = resolve
})
let clip2Phase2Started = false
let clip1Phase1ResolvedAfterClip2Phase2 = false
const runStep = vi.fn(async (meta, _prompt, action: string) => {
const stepId = String(meta.stepId)
if (action === 'storyboard_phase1_plan' && stepId === 'clip_clip-1_phase1') {
await clip1Phase1Gate
clip1Phase1ResolvedAfterClip2Phase2 = clip2Phase2Started
return {
text: JSON.stringify([{ panel_number: 1, description: '镜头1', location: '场景A', source_text: '原文1', characters: [] }]),
reasoning: '',
}
}
if (action === 'storyboard_phase1_plan' && stepId === 'clip_clip-2_phase1') {
return {
text: JSON.stringify([{ panel_number: 1, description: '镜头2', location: '场景A', source_text: '原文2', characters: [] }]),
reasoning: '',
}
}
if (action === 'storyboard_phase2_cinematography' && stepId === 'clip_clip-2_phase2_cinematography') {
clip2Phase2Started = true
releaseClip1Phase1?.()
return {
text: JSON.stringify([{ panel_number: 1, composition: '居中', lighting: '顶光', color_palette: '冷色', atmosphere: '紧张', technical_notes: 'note' }]),
reasoning: '',
}
}
if (action === 'storyboard_phase2_acting') {
return { text: JSON.stringify([{ panel_number: 1, characters: [] }]), reasoning: '' }
}
if (action === 'storyboard_phase2_cinematography') {
return {
text: JSON.stringify([{ panel_number: 1, composition: '居中', lighting: '顶光', color_palette: '冷色', atmosphere: '紧张', technical_notes: 'note' }]),
reasoning: '',
}
}
if (action === 'storyboard_phase3_detail') {
return {
text: JSON.stringify([{ panel_number: 1, description: '细化镜头', location: '场景A', source_text: '原文', characters: [] }]),
reasoning: '',
}
}
throw new Error(`unexpected action: ${action}:${stepId}`)
})
const result = await runScriptToStoryboardOrchestrator({
concurrency: 2,
clips: [
{
id: 'clip-1',
content: '文本1',
characters: JSON.stringify([{ name: '角色A' }]),
location: '场景A',
screenplay: null,
},
{
id: 'clip-2',
content: '文本2',
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(2)
expect(clip2Phase2Started).toBe(true)
expect(clip1Phase1ResolvedAfterClip2Phase2).toBe(true)
})
})

View File

@@ -28,13 +28,14 @@ const runScriptToStoryboardOrchestratorMock = vi.hoisted(() =>
clipPanels: [
{
clipId: 'clip-1',
panels: [
clipIndex: 0,
finalPanels: [
{
panelIndex: 1,
shotType: 'close-up',
cameraMove: 'static',
panel_number: 1,
shot_type: 'close-up',
camera_move: 'static',
description: 'panel desc',
videoPrompt: 'panel prompt',
video_prompt: 'panel prompt',
location: 'room',
characters: ['Narrator'],
},
@@ -48,7 +49,7 @@ const runScriptToStoryboardOrchestratorMock = vi.hoisted(() =>
})),
)
const parseVoiceLinesJsonMock = vi.hoisted(() => vi.fn())
const persistStoryboardsAndPanelsMock = vi.hoisted(() => vi.fn())
const persistStoryboardOutputsMock = vi.hoisted(() => vi.fn())
const parseStoryboardRetryTargetMock = vi.hoisted(() => vi.fn())
const runScriptToStoryboardAtomicRetryMock = vi.hoisted(() => vi.fn())
const workflowLeaseMock = vi.hoisted(() => ({
@@ -158,11 +159,11 @@ vi.mock('@/lib/workers/handlers/script-to-storyboard-helpers', () => ({
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
},
buildStoryboardJson: vi.fn(() => '[]'),
buildStoryboardJsonFromClipPanels: vi.fn(() => '[]'),
parseEffort: vi.fn(() => null),
parseTemperature: vi.fn(() => 0.7),
parseVoiceLinesJson: parseVoiceLinesJsonMock,
persistStoryboardsAndPanels: persistStoryboardsAndPanelsMock,
persistStoryboardOutputs: persistStoryboardOutputsMock,
toPositiveInt: (value: unknown) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
const n = Math.floor(value)
@@ -255,33 +256,41 @@ describe('worker script-to-storyboard behavior', () => {
],
})
prismaMock.$transaction.mockImplementation(async (fn: (tx: {
novelPromotionVoiceLine: {
deleteMany: (args: { where: Record<string, unknown> }) => Promise<unknown>
create: (args: { data: Record<string, unknown>; select: { id: boolean } }) => Promise<{ id: string }>
}
}) => Promise<unknown>) => {
const tx = {
novelPromotionVoiceLine: {
deleteMany: async (args: { where: Record<string, unknown> }) => {
txState.deletedWhereClauses.push(args.where)
return 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)
})
prismaMock.$transaction.mockReset()
persistStoryboardsAndPanelsMock.mockResolvedValue([
{
storyboardId: 'storyboard-1',
panels: [{ id: 'panel-1', panelIndex: 1 }],
},
])
persistStoryboardOutputsMock.mockImplementation(async ({ voiceLineRows }: { voiceLineRows: VoiceLineInput[] | null }) => {
const rows = voiceLineRows || []
txState.createdRows = rows.map((row) => ({
episodeId: 'episode-1',
lineIndex: row.lineIndex,
speaker: row.speaker,
content: row.content,
emotionStrength: row.emotionStrength,
matchedPanelId: 'panel-1',
matchedStoryboardId: 'storyboard-1',
matchedPanelIndex: row.matchedPanel.panelIndex,
}))
txState.deletedWhereClauses = [
rows.length === 0
? { episodeId: 'episode-1' }
: {
episodeId: 'episode-1',
lineIndex: {
notIn: rows.map((row) => row.lineIndex),
},
},
]
return {
persistedStoryboards: [
{
storyboardId: 'storyboard-1',
clipId: 'clip-1',
panels: [{ id: 'panel-1', panelIndex: 1 }],
},
],
voiceLineCount: rows.length,
}
})
parseVoiceLinesJsonMock.mockReturnValue(baseVoiceRows())
})
@@ -433,7 +442,7 @@ describe('worker script-to-storyboard behavior', () => {
})
expect(runScriptToStoryboardAtomicRetryMock).toHaveBeenCalledTimes(1)
expect(runScriptToStoryboardOrchestratorMock).not.toHaveBeenCalled()
expect(persistStoryboardsAndPanelsMock).toHaveBeenCalledWith({
expect(persistStoryboardOutputsMock).toHaveBeenCalledWith({
episodeId: 'episode-1',
clipPanels: [
{
@@ -448,6 +457,7 @@ describe('worker script-to-storyboard behavior', () => {
],
},
],
voiceLineRows: null,
})
})
})

View File

@@ -52,7 +52,7 @@ describe('worker shot-ai-prompt-location behavior', () => {
vi.clearAllMocks()
persistMock.resolveAnalysisModel.mockResolvedValue({ id: 'np-1', analysisModel: 'llm::analysis' })
persistMock.requireProjectLocation.mockResolvedValue({ id: 'location-1', name: 'Old Town' })
runtimeMock.runShotPromptCompletion.mockResolvedValue('{"prompt":"updated location description"}')
runtimeMock.runShotPromptCompletion.mockResolvedValue('{"prompt":"updated location description","available_slots":["街道左侧靠墙的留白位置"]}')
persistMock.persistLocationDescription.mockResolvedValue({ id: 'location-1', images: [] })
})
@@ -85,10 +85,12 @@ describe('worker shot-ai-prompt-location behavior', () => {
locationId: 'location-1',
imageIndex: 2,
modifiedDescription: 'updated location description',
availableSlots: ['街道左侧靠墙的留白位置'],
})
expect(result).toEqual(expect.objectContaining({
success: true,
modifiedDescription: 'updated location description',
availableSlots: ['街道左侧靠墙的留白位置'],
location: { id: 'location-1', images: [] },
}))
})

View File

@@ -6,7 +6,9 @@ const prismaMock = vi.hoisted(() => ({
project: { findUnique: vi.fn() },
novelPromotionProject: { findUnique: vi.fn() },
novelPromotionEpisode: { findUnique: vi.fn() },
$transaction: vi.fn(),
novelPromotionClip: { update: vi.fn(async () => ({})) },
locationImage: { createMany: vi.fn(async () => ({ count: 0 })) },
}))
const workerMock = vi.hoisted(() => ({
@@ -120,6 +122,7 @@ function buildJob(payload: Record<string, unknown>, episodeId: string | null = '
describe('worker story-to-script behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$transaction.mockImplementation(async (fn: (tx: typeof prismaMock) => Promise<unknown>) => await fn(prismaMock))
prismaMock.project.findUnique.mockResolvedValue({
id: 'project-1',
@@ -181,10 +184,10 @@ describe('worker story-to-script behavior', () => {
persistedClips: 1,
})
expect(helperMock.persistClips).toHaveBeenCalledWith({
expect(helperMock.persistClips).toHaveBeenCalledWith(expect.objectContaining({
episodeId: 'episode-1',
clipList: [{ clipId: 'clip-1', content: 'clip content', props: ['Knife'] }],
})
}))
expect(prismaMock.novelPromotionClip.update).toHaveBeenCalledWith({
where: { id: 'clip-row-1' },

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { TASK_TYPE } from '@/lib/task/types'
import { getWorkflowDefinition, resolveWorkflowRetryInvalidationStepKeys } from '@/lib/workflow-engine/registry'
describe('workflow registry', () => {
it('returns stable workflow definitions for run-centric flows', () => {
const storyToScript = getWorkflowDefinition(TASK_TYPE.STORY_TO_SCRIPT_RUN)
const scriptToStoryboard = getWorkflowDefinition(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
expect(storyToScript?.orderedSteps.map((step) => step.key)).toEqual([
'analyze_characters',
'analyze_locations',
'analyze_props',
'split_clips',
'screenplay_convert',
'persist_script_artifacts',
])
expect(scriptToStoryboard?.orderedSteps.map((step) => step.key)).toEqual([
'plan_panels',
'detail_panels',
'voice_analyze',
'persist_storyboard_artifacts',
])
})
it('invalidates downstream story-to-script screenplay steps when split inputs change', () => {
expect(resolveWorkflowRetryInvalidationStepKeys({
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
stepKey: 'analyze_props',
existingStepKeys: ['analyze_props', 'split_clips', 'screenplay_clip-a', 'screenplay_clip-b'],
}).sort()).toEqual([
'analyze_props',
'screenplay_clip-a',
'screenplay_clip-b',
'split_clips',
])
})
it('invalidates only the affected storyboard branch plus voice analyze', () => {
expect(resolveWorkflowRetryInvalidationStepKeys({
workflowType: TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
stepKey: 'clip_clip-1_phase2_cinematography',
existingStepKeys: [
'clip_clip-1_phase1',
'clip_clip-1_phase2_cinematography',
'clip_clip-1_phase2_acting',
'clip_clip-1_phase3_detail',
'clip_clip-2_phase3_detail',
'voice_analyze',
],
}).sort()).toEqual([
'clip_clip-1_phase2_cinematography',
'clip_clip-1_phase3_detail',
'voice_analyze',
])
})
})