feat: refine UI, improve UX, optimize the analysis pipeline, and add character standing positions
This commit is contained in:
84
tests/integration/api/contract/runs-list.route.test.ts
Normal file
84
tests/integration/api/contract/runs-list.route.test.ts
Normal 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,
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
93
tests/regression/task-reusable-run-reattach.test.ts
Normal file
93
tests/regression/task-reusable-run-reattach.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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: '[]',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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: '暂无角色介绍',
|
||||
})
|
||||
|
||||
34
tests/unit/components/ai-data-modal-preview-pane.test.ts
Normal file
34
tests/unit/components/ai-data-modal-preview-pane.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
48
tests/unit/components/ai-data-modal.test.ts
Normal file
48
tests/unit/components/ai-data-modal.test.ts
Normal 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('"characters"')
|
||||
expect(html).toContain('"appearance": "朝服形象"')
|
||||
expect(html).toContain('"slot": "皇宫正中龙椅前方台阶下的位置"')
|
||||
})
|
||||
})
|
||||
46
tests/unit/components/capsule-nav-layering.test.ts
Normal file
46
tests/unit/components/capsule-nav-layering.test.ts
Normal 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]')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
72
tests/unit/components/long-text-detection-prompt.test.ts
Normal file
72
tests/unit/components/long-text-detection-prompt.test.ts
Normal 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]')
|
||||
})
|
||||
})
|
||||
29
tests/unit/components/modal-scroll-lock.test.ts
Normal file
29
tests/unit/components/modal-scroll-lock.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
64
tests/unit/components/workspace-run-stream-consoles.test.ts
Normal file
64
tests/unit/components/workspace-run-stream-consoles.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
49
tests/unit/helpers/recovery-probe.test.ts
Normal file
49
tests/unit/helpers/recovery-probe.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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"')
|
||||
})
|
||||
})
|
||||
|
||||
14
tests/unit/location-available-slots.test.ts
Normal file
14
tests/unit/location-available-slots.test.ts
Normal 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('可站位置:')
|
||||
})
|
||||
})
|
||||
@@ -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('客厅')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
81
tests/unit/novel-promotion/stage-readiness.test.ts
Normal file
81
tests/unit/novel-promotion/stage-readiness.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
8
tests/unit/projects/default-name.test.ts
Normal file
8
tests/unit/projects/default-name.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
28
tests/unit/projects/validation.test.ts
Normal file
28
tests/unit/projects/validation.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
76
tests/unit/run-runtime/recovery.test.ts
Normal file
76
tests/unit/run-runtime/recovery.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: '[]',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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: '[]',
|
||||
|
||||
@@ -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('可站位置:'),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: [] },
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
57
tests/unit/workflow-engine/registry.test.ts
Normal file
57
tests/unit/workflow-engine/registry.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user