diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx new file mode 100644 index 0000000..409d302 --- /dev/null +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -0,0 +1,229 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' + +import { + createWorkflowRunStore, + type Generation, + type GenerationApis, + type GenerationInput, + type TaskApis, + type TaskEvent, + type WorkflowRun, + type WorkflowStep, +} from '@/entities' +import { createWorkflowController } from '@/features/workflow-controller' +import { QuickStartPage } from '.' +import { createQuickStartService } from './service' + +afterEach(cleanup) + +interface HarnessOptions { + prepareProject?: (prompt: string) => Promise<{ id: string }> + createGeneration?: GenerationApis['create'] + nextStepError?: Error +} + +function createHarness(options: HarnessOptions = {}) { + const store = createWorkflowRunStore({ storage: null }) + const taskListeners = new Map void>() + + const createGeneration: GenerationApis['create'] = + options.createGeneration ?? + (async (input: T) => + ({ + id: 'task-1', + projectId: input.projectId, + type: input.type, + status: 'pending', + result: null, + error: null, + }) as Generation) + + const taskApis: TaskApis = { + get: vi.fn(async (_projectId, taskId) => ({ + id: taskId, + type: 'character_template' as const, + status: 'pending' as const, + result: null, + error: null, + })), + subscribe: vi.fn((projectId, taskId, onEvent) => { + taskListeners.set(`${projectId}:${taskId}`, onEvent) + onEvent({ + taskId, + type: 'character_template', + status: 'pending', + result: null, + error: null, + }) + return () => { + taskListeners.delete(`${projectId}:${taskId}`) + } + }), + } + + const controller = createWorkflowController({ + store, + generationApis: { create: createGeneration }, + taskApis, + createId: (scope) => + scope === 'run' ? 'run-1' : scope === 'revision' ? 'revision-1' : 'submission-1', + now: () => '2026-07-31T03:30:00.000Z', + }) + if (options.nextStepError) { + controller.nextStep = vi.fn(async () => { + throw options.nextStepError + }) + } + const prepareProject = + options.prepareProject ?? vi.fn(async (_prompt: string) => ({ id: ' project-1 ' })) + const service = createQuickStartService({ controller, prepareProject }) + + return { + prepareProject, + service, + store, + emit(event: TaskEvent) { + const listener = taskListeners.get(`project-1:${event.taskId}`) + if (!listener) throw new Error(`Missing listener for task ${event.taskId}`) + listener(event) + }, + } +} + +function currentStep(run: WorkflowRun, type: WorkflowStep['type']) { + return run.revisions[0]?.steps.find((step) => step.type === type) +} + +function LocationProbe() { + return {useLocation().pathname} +} + +function renderQuickStart(service: ReturnType['service']) { + return render( + + + + + + + } + /> + + + + + } + /> + 工作流画布} /> + + , + ) +} + +async function submitPrompt(prompt: string) { + fireEvent.change(screen.getByLabelText('创作指令'), { target: { value: prompt } }) + fireEvent.click(screen.getByRole('button', { name: '开始生成' })) +} + +describe('QuickStartPage', () => { + it('creates one WorkflowRun and starts character-template without leaving Quick Start', async () => { + const harness = createHarness() + renderQuickStart(harness.service) + + await submitPrompt(' 一位提着风灯的像素守夜人 ') + + await screen.findAllByText('正在生成角色图') + expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start/run-1') + expect(screen.queryByRole('heading', { name: '工作流画布' })).toBeNull() + + const run = harness.store.get('run-1') + expect(run?.projectId).toBe('project-1') + expect(run?.prompt).toBe('一位提着风灯的像素守夜人') + expect(currentStep(run!, 'character-setup')?.status).toBe('passed') + expect(currentStep(run!, 'character-template')).toMatchObject({ + status: 'active', + taskId: 'task-1', + }) + }) + + it('does not create an orphan WorkflowRun when Project preparation fails', async () => { + const harness = createHarness({ + prepareProject: vi.fn(async () => { + throw new Error('项目服务暂不可用') + }), + }) + renderQuickStart(harness.service) + + await submitPrompt('像素守夜人') + + expect(await screen.findByText('项目服务暂不可用')).toBeTruthy() + expect(harness.store.get('run-1')).toBeNull() + expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start') + }) + + it('shows the saved WorkflowRun failure when generation submission fails', async () => { + const harness = createHarness({ + createGeneration: vi.fn(async () => { + throw new Error('生成服务未连接') + }), + }) + renderQuickStart(harness.service) + + await submitPrompt('像素守夜人') + + await screen.findByText('生成服务未连接') + expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start/run-1') + expect(harness.store.get('run-1')?.status).toBe('failed') + expect(currentStep(harness.store.get('run-1')!, 'character-template')?.status).toBe('failed') + }) + + it('does not hide an unexpected Controller failure behind an active WorkflowRun', async () => { + const harness = createHarness({ + nextStepError: new Error('流程内部异常'), + }) + renderQuickStart(harness.service) + + await submitPrompt('像素守夜人') + + expect(await screen.findByText('流程内部异常')).toBeTruthy() + expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start') + expect(harness.store.get('run-1')?.status).toBe('active') + }) + + it('renders matching task candidates on the same run without advancing the unfinished step', async () => { + const harness = createHarness() + renderQuickStart(harness.service) + await submitPrompt('像素守夜人') + await screen.findAllByText('正在生成角色图') + + harness.emit({ + taskId: 'task-1', + type: 'character_template', + status: 'completed', + result: { + type: 'character_template', + images: [{ url: 'https://example.com/watchman.png' }], + }, + error: null, + }) + + await screen.findAllByText('角色图已生成') + expect(screen.getByRole('img', { name: '角色图候选 1' }).getAttribute('src')).toBe( + 'https://example.com/watchman.png', + ) + expect(screen.getByLabelText('当前路径').textContent).toBe('/quick-start/run-1') + + const run = harness.store.get('run-1') + expect(currentStep(run!, 'character-template')?.status).toBe('passed') + expect(currentStep(run!, 'template-candidate')?.status).toBe('active') + }) +}) diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 1ffe9fe..16522ad 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -1,9 +1,492 @@ -/** 快速开始。 */ -export function QuickStartPage() { +import { useEffect, useState, type FormEvent } from 'react' +import { useNavigate, useParams } from 'react-router' + +import { + WORKFLOW_STEP_ORDER, + type CharacterTemplateWorkflowStep, + type WorkflowRevision, + type WorkflowRun, + type WorkflowStep, + type WorkflowStepType, +} from '@/entities' +import { unavailableQuickStartService, type QuickStartService } from './service' + +export type { + CreateQuickStartServiceOptions, + PrepareQuickStartProject, + QuickStartService, +} from './service' + +const STEP_LABELS: Record = { + 'character-setup': '角色设定', + 'character-template': '角色图', + 'template-candidate': '候选选择', + 'action-setup': '动作设定', + 'first-frame': '动作首帧', + 'complete-animation': '完整动画', + review: '审核', + export: '导出', +} + +const EXAMPLES = [ + { + label: '像素守夜人', + prompt: '一位提着风灯、披深色斗篷的像素守夜人', + }, + { + label: '轻装信使', + prompt: '轻装信使,侧视像素风,轮廓清晰,动作轻快', + }, +] as const + +export interface QuickStartPageProps { + /** + * 页面测试与后续生产组合可以注入同一份服务实例。 + * 默认实现明确不可用,直到真实 Project / Generation / Task 实现到位。 + */ + service?: QuickStartService +} + +/** Quick Start 独立完成 AI 入口;它不跳转 Workflow Editor。 */ +export function QuickStartPage({ service = unavailableQuickStartService }: QuickStartPageProps) { + const { runId } = useParams() + + return runId ? ( + + ) : ( + + ) +} + +function QuickStartInput({ service }: { service: QuickStartService }) { + const navigate = useNavigate() + const [prompt, setPrompt] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const unavailableReason = service.unavailableReason + + async function submit(event: FormEvent) { + event.preventDefault() + const normalizedPrompt = prompt.trim() + if (!normalizedPrompt || submitting || unavailableReason) return + + setSubmitting(true) + setError(null) + try { + const run = await service.start(normalizedPrompt) + navigate(`/quick-start/${encodeURIComponent(run.id)}`) + } catch (cause) { + setError(errorMessage(cause, '创建失败,请稍后重试')) + } finally { + setSubmitting(false) + } + } + + return ( +
+ + +
+
+
+

+ QUICK START / CREATE CHARACTER +

+

+ 用一句角色设定, +
+ 开始一条可追踪的制作流程。 +

+
+ + AI 快捷创作 + +
+ +
+ + + +
+ +
+
+ {EXAMPLES.slice(1).map((example) => ( + + ))} +
+ +
void submit(event)} + className="grid gap-3 rounded-[1.4rem] border border-[#bdc7bf] bg-[#f7f8f4] p-4 shadow-[0_22px_60px_rgba(31,43,35,0.12)] sm:grid-cols-[1fr_auto]" + > +