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())
})
})