feat: redesign Canvas plan and reference flow

This commit is contained in:
inman committed 2026-09-07 10:06:38 +08:00
1 parent f8eee430f4
commit 1562a49ff1
18 files changed
+2141 -1057

No files matched your search

+252 -276
View File
@@ -1,64 +1,29 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DesignPlanHistory } from '@/pages/ImageCanvas/DesignPlanHistory';
import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard';
import { DesignTaskPanel } from '@/pages/ImageCanvas/DesignProductionPanel';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type {
DesignCompilationIssue,
DesignGenerationTask,
} from '../../shared/image-workspace';
import type { DesignGenerationTask } from '../../shared/image-workspace';
import {
designFormFixture,
designQuoteFixture,
designValuesFixture,
designFormFixture,
designWorkspaceFixture,
} from '../fixtures/design-workspace-v2';
function renderCard(
workspace = designWorkspaceFixture({ form: designFormFixture() }),
options: {
generationAvailable?: boolean;
quoteBlockers?: DesignCompilationIssue[];
onOpenFineTune?: () => void;
onFocusComposer?: () => void;
onQuoteOffered?: () => void;
} = {},
) {
const requestQuote = vi.fn().mockResolvedValue(workspace);
const resolveDecisionPrompt = vi.fn().mockResolvedValue(workspace);
const onOpenFineTune = options.onOpenFineTune ?? vi.fn();
const onFocusComposer = options.onFocusComposer ?? vi.fn();
const onQuoteOffered = options.onQuoteOffered ?? vi.fn();
useImageWorkspaceStore.setState({
workspace,
quoteBlockers: [],
pendingOperations: {},
requestQuote,
resolveDecisionPrompt,
});
render(
<YouthCreationCard
workspace={workspace}
quoteBlockers={options.quoteBlockers ?? []}
generationAvailable={options.generationAvailable ?? true}
onOpenFineTune={onOpenFineTune}
onFocusComposer={onFocusComposer}
onQuoteOffered={onQuoteOffered}
/>,
);
return {
workspace,
requestQuote,
resolveDecisionPrompt,
onOpenFineTune,
onFocusComposer,
onQuoteOffered,
};
}
const { uploadMock } = vi.hoisted(() => ({ uploadMock: vi.fn() }));
function taskFixture(
overrides: Partial<DesignGenerationTask> = {},
): DesignGenerationTask {
vi.mock('@/lib/image-workspace', async (importOriginal) => {
const original = await importOriginal<typeof import('@/lib/image-workspace')>();
return {
...original,
resolveImageWorkspaceAssetUrl: vi.fn().mockReturnValue(new Promise(() => undefined)),
saveImageWorkspaceAsset: vi.fn().mockResolvedValue({ status: 'saved' }),
uploadImageWorkspaceAsset: uploadMock,
};
});
function taskFixture(overrides: Partial<DesignGenerationTask> = {}): DesignGenerationTask {
const quote = designQuoteFixture();
return {
taskId: 'task-1',
@@ -81,11 +46,7 @@ function taskFixture(
},
customerHoldExpiresAt: quote.expiresAt,
resolutionDeadlineAt: null,
progress: {
stage: 'generating',
completedRequiredSteps: 0,
totalRequiredSteps: 1,
},
progress: { stage: 'generating', completedRequiredSteps: 0, totalRequiredSteps: 1 },
executionHealth: 'normal',
cancellation: { state: 'available', outcome: null },
issues: [],
@@ -96,247 +57,262 @@ function taskFixture(
};
}
function renderCard(workspace = designWorkspaceFixture()) {
const actions = {
applyFieldOperations: vi.fn().mockResolvedValue(workspace),
requestQuote: vi.fn().mockResolvedValue(workspace),
confirmGeneration: vi.fn().mockResolvedValue(workspace),
};
useImageWorkspaceStore.setState({
workspace,
quoteBlockers: [],
pendingOperations: {},
...actions,
});
render(
<YouthCreationCard
workspace={workspace}
quoteBlockers={[]}
generationAvailable
onQuoteOffered={vi.fn()}
/>,
);
return actions;
}
function referenceWorkspace({ offered = false } = {}) {
const values = structuredClone(designValuesFixture);
values.content.concept = '参考 @图片1 的小狗,让它在草地上奔跑';
values.references = [{
id: 'reference-1',
asset_id: 'asset-reference',
asset_revision: null,
role: 'subject_identity',
preserve: ['圆脸'],
adapt: ['背景颜色'],
do_not_copy: ['原图文字'],
reviewed_observations: ['白色背景'],
}];
return designWorkspaceFixture({
form: designFormFixture({
specification: { schema_version: 1, values, field_decisions: {} },
activeQuotes: offered ? [designQuoteFixture()] : [],
}),
assets: [{
assetId: 'asset-reference',
workspaceId: 'workspace-1',
role: 'uploaded',
mediaType: 'image',
mimeType: 'image/png',
width: 600,
height: 600,
durationMilliseconds: null,
generationTaskId: null,
createdAt: '2026-09-02T09:00:00.000Z',
contentPath: '/api/design/assets/asset-reference/content',
}],
});
}
describe('YouthCreationCard', () => {
beforeEach(() => {
vi.clearAllMocks();
useImageWorkspaceStore.getState().reset();
});
it('shows a plain creation summary and reveals the offered quote after requesting it', async () => {
const { onQuoteOffered, requestQuote } = renderCard();
it('puts the editable creator prompt and compact visible parameters in one plan card', () => {
renderCard();
expect(screen.getByRole('heading', { name: 'AI 听懂的想法' })).toBeInTheDocument();
expect(screen.getByText('会跟着聊天自动更新,不需要逐项填写。')).toBeInTheDocument();
expect(screen.getByText('把便携咖啡机呈现为城市通勤中的精密工具')).toBeInTheDocument();
expect(screen.getByText('图片')).toBeInTheDocument();
expect(screen.getByText('竖版海报')).toBeInTheDocument();
expect(screen.getByText('3:4')).toBeInTheDocument();
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('想法已经清楚,可以检查制作方案')).toBeInTheDocument();
expect(screen.queryByText(/Specification|revision|compiler|provider|immutable/i)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '看看制作方案' }));
expect(requestQuote).toHaveBeenCalledOnce();
await waitFor(() => expect(onQuoteOffered).toHaveBeenCalledOnce());
});
it('does not surface legacy decision prompts as a form', () => {
const workspace = designWorkspaceFixture({
form: designFormFixture({
decisionPrompts: [{
id: 'choice-1',
kind: 'question',
basedOnSpecificationRevisionId: 'specification-revision-3',
targetPaths: ['visual.art_direction'],
title: '画面想要什么感觉?',
body: '选一个你喜欢的方向。',
options: [
{ id: 'cartoon', label: '有趣的卡通', description: '颜色明快,轻松有趣' },
{ id: 'cinema', label: '像电影一样', description: null },
{ id: 'delegate', label: '你帮我选', description: null },
],
}, {
id: 'choice-2',
kind: 'question',
basedOnSpecificationRevisionId: 'specification-revision-3',
targetPaths: ['visual.palette'],
title: '这个问题不该显示',
body: '只显示一个选择就好。',
options: [{ id: 'blue', label: '蓝色', description: null }],
}],
}),
});
const { resolveDecisionPrompt } = renderCard(workspace);
expect(screen.queryByTestId('youth-creation-choice')).not.toBeInTheDocument();
expect(screen.queryByText('画面想要什么感觉?')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /有趣的卡通/ })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /你帮我选/ })).not.toBeInTheDocument();
expect(screen.queryByText('这个问题不该显示')).not.toBeInTheDocument();
expect(resolveDecisionPrompt).not.toHaveBeenCalled();
});
it('shows known must-have, must-avoid, reference, video, and plain blocker details only', () => {
const values = structuredClone(designValuesFixture);
values.intent.media = 'video';
values.video.total_duration_seconds = 10;
values.video.pacing = '很有活力';
values.video.end_state = '机器人端出一盘星星饼干';
values.video.shots = [{
id: 'shot-1',
start_milliseconds: 0,
end_milliseconds: 10_000,
story_beat: '机器人在厨房里做饭',
framing: 'close-up',
camera: 'dolly-in',
subject_motion: '快速搅拌',
environment_motion: null,
focus_behavior: null,
transition: null,
}];
values.constraints.must_include = ['远处的地球'];
values.constraints.must_preserve = ['红围巾'];
values.constraints.must_avoid = ['真实人物'];
values.references = [{
id: 'reference-1',
asset_id: 'asset-reference',
asset_revision: null,
role: 'subject_identity',
preserve: ['圆脸'],
adapt: ['背景颜色'],
do_not_copy: ['原图文字'],
reviewed_observations: ['白色背景'],
}];
const workspace = designWorkspaceFixture({
form: designFormFixture({
specification: { schema_version: 1, values, field_decisions: {} },
}),
assets: [{
assetId: 'asset-reference',
workspaceId: 'workspace-1',
role: 'uploaded',
mediaType: 'image',
mimeType: 'image/png',
width: 600,
height: 600,
durationMilliseconds: null,
generationTaskId: null,
createdAt: '2026-09-02T09:00:00.000Z',
contentPath: '/api/design/assets/asset-reference/content',
}],
});
const blocker: DesignCompilationIssue = {
code: 'aspect_ratio_required',
message: 'Choose an aspect ratio supported by the frozen route.',
severity: 'blocker',
path: 'output.aspect_ratio',
};
renderCard(workspace, { quoteBlockers: [blocker] });
fireEvent.click(screen.getByText('查看画面细节'));
expect(screen.getByText('一定要有')).toBeInTheDocument();
expect(screen.getByText('远处的地球')).toBeInTheDocument();
expect(screen.getByText('想保留')).toBeInTheDocument();
expect(screen.getByText('红围巾')).toBeInTheDocument();
expect(screen.getByText('不要出现')).toBeInTheDocument();
expect(screen.getByText('真实人物')).toBeInTheDocument();
expect(screen.getByText('参考图片')).toBeInTheDocument();
expect(screen.getByText('600 × 600')).toBeInTheDocument();
expect(screen.getByText('圆脸')).toBeInTheDocument();
expect(screen.getByText('背景颜色')).toBeInTheDocument();
expect(screen.getByText('原图文字')).toBeInTheDocument();
expect(screen.getByText('视频安排')).toBeInTheDocument();
expect(screen.getByText('10 秒')).toBeInTheDocument();
expect(screen.getByText('很有活力')).toBeInTheDocument();
expect(screen.getByText('机器人端出一盘星星饼干')).toBeInTheDocument();
expect(screen.getByText(/机器人在厨房里做饭/)).toBeInTheDocument();
expect(screen.getByText('先选择作品形状。')).toBeInTheDocument();
expect(screen.queryByText(/frozen route/i)).not.toBeInTheDocument();
expect(screen.queryByText('dolly-in')).not.toBeInTheDocument();
expect(screen.queryByText('milliseconds')).not.toBeInTheDocument();
expect(screen.queryByText(/Specification|revision|compiler|provider|immutable/i)).not.toBeInTheDocument();
});
it('uses a gentle empty-state CTA and sends focus back to the composer', () => {
const values = structuredClone(designValuesFixture);
values.intent.media = null;
values.intent.creation_intent = null;
values.intent.purpose = null;
values.intent.channel = null;
values.intent.audience = null;
values.content.concept = null;
values.output.aspect_ratio = null;
values.output.orientation = null;
const onFocusComposer = vi.fn();
const { onOpenFineTune } = renderCard(
designWorkspaceFixture({
form: designFormFixture({
specification: { schema_version: 1, values, field_decisions: {} },
}),
turns: [],
}),
{ onFocusComposer },
expect(screen.getByRole('heading', { name: '制作方案' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
'把便携咖啡机呈现为城市通勤中的精密工具',
);
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('等你的第一个想法')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '开始描述' }));
expect(onFocusComposer).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: '手动调整(可选)' }));
expect(onOpenFineTune).toHaveBeenCalledOnce();
expect(screen.getByRole('combobox', { name: '类型' })).toHaveValue('image');
expect(screen.getByRole('combobox', { name: '画幅' })).toHaveValue('3:4');
expect(screen.getByRole('button', { name: '增加数量' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '准备制作方案' })).toBeInTheDocument();
expect(screen.queryByText('查看画面细节')).not.toBeInTheDocument();
expect(screen.queryByText('手动调整')).not.toBeInTheDocument();
});
it('does not request a plan when generation is unavailable', () => {
const { requestQuote, onFocusComposer } = renderCard(undefined, {
generationAvailable: false,
it('saves a final-prompt edit through the public Specification field', async () => {
const actions = renderCard();
const prompt = screen.getByRole('textbox', { name: '创作提示词' });
fireEvent.change(prompt, { target: { value: '更聚焦咖啡机,背景使用清晨地铁站' } });
fireEvent.blur(prompt);
await waitFor(() => {
expect(actions.applyFieldOperations).toHaveBeenCalledWith([
{ kind: 'set', path: 'content.concept', value: '更聚焦咖啡机,背景使用清晨地铁站' },
]);
});
expect(screen.getByRole('button', { name: '继续完善想法' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '继续完善想法' }));
expect(requestQuote).not.toHaveBeenCalled();
expect(onFocusComposer).toHaveBeenCalledOnce();
});
it('keeps confirming and generating states clear, then offers another version after a result', () => {
const confirming = designWorkspaceFixture({
it('recompiles and requotes after editing an offered plan', async () => {
const workspace = designWorkspaceFixture({
form: designFormFixture({ activeQuotes: [designQuoteFixture()] }),
});
const { onOpenFineTune: onConfirmingFineTune } = renderCard(confirming);
expect(screen.getByText('方案准备好了,确认后就开始制作。')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '继续调整' }));
expect(onConfirmingFineTune).toHaveBeenCalledOnce();
const actions = renderCard(workspace);
const prompt = screen.getByRole('textbox', { name: '创作提示词' });
const resultAsset = {
assetId: 'asset-result',
workspaceId: 'workspace-1',
role: 'generated' as const,
mediaType: 'image' as const,
mimeType: 'image/png',
width: 1024,
height: 1365,
durationMilliseconds: null,
generationTaskId: 'task-1',
createdAt: '2026-09-02T09:05:00.000Z',
contentPath: '/api/design/assets/asset-result/content',
};
const generating = designWorkspaceFixture({ tasks: [taskFixture()] });
const result = designWorkspaceFixture({
tasks: [taskFixture({
status: 'succeeded',
progress: { stage: 'completed', completedRequiredSteps: 1, totalRequiredSteps: 1 },
resultAssetIds: ['asset-result'],
nextActions: ['view_result', 'revise_design', 'generate_again'],
})],
assets: [resultAsset],
fireEvent.change(prompt, { target: { value: '改成夜晚城市街景' } });
fireEvent.blur(prompt);
await waitFor(() => expect(actions.applyFieldOperations).toHaveBeenCalledOnce());
await waitFor(() => expect(actions.requestQuote).toHaveBeenCalledOnce());
});
it('keeps reference rows binding-only while the prompt carries image usage', () => {
renderCard(referenceWorkspace());
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
'参考 @图片1 的小狗,让它在草地上奔跑',
);
expect(screen.getByText('@图片1')).toBeInTheDocument();
expect(screen.getByText('已引用')).toBeInTheDocument();
expect(screen.getByText('参考图 1 · 600×600')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '替换参考图 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '删除参考图 1' })).toBeInTheDocument();
expect(screen.queryByText('圆脸')).not.toBeInTheDocument();
expect(screen.queryByText('背景颜色')).not.toBeInTheDocument();
expect(screen.queryByText('原图文字')).not.toBeInTheDocument();
expect(screen.queryByText(/主体参考|最想保留|可以改变|不要照着画/)).not.toBeInTheDocument();
});
it('offers existing reference tokens when the user types @', () => {
renderCard(referenceWorkspace());
const prompt = screen.getByRole('textbox', { name: '创作提示词' });
const nextValue = '让 ';
fireEvent.change(prompt, {
target: { value: `${nextValue}@`, selectionStart: nextValue.length + 1 },
});
// A card is intentionally remounted between resource states so each assertion
// observes the state a user sees at that moment.
cleanup();
renderCard(generating);
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('正在制作,请稍等一下')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '正在制作' })).toBeDisabled();
expect(screen.getByRole('listbox', { name: '可引用的图片' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '@图片1' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: '上传新的参考图' })).toBeInTheDocument();
});
cleanup();
const { requestQuote, onOpenFineTune } = renderCard(result);
expect(within(screen.getByTestId('youth-creation-readiness')).getByText('完成了,看看你的作品吧')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '继续调整' }));
expect(onOpenFineTune).toHaveBeenCalledOnce();
fireEvent.click(screen.getByRole('button', { name: '做一个新版本' }));
expect(requestQuote).toHaveBeenCalledOnce();
it('does not duplicate a reference token that is already present', () => {
const actions = renderCard(referenceWorkspace());
fireEvent.click(screen.getByTitle('在提示词中插入 @图片1'));
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
'参考 @图片1 的小狗,让它在草地上奔跑',
);
expect(actions.applyFieldOperations).not.toHaveBeenCalled();
});
it('blocks confirmation while a prompt alias has no bound image', () => {
const workspace = referenceWorkspace({ offered: true });
workspace.form.specification.values.content.concept = '参考 @图片1 和 @图片2 制作双人合照';
renderCard(workspace);
expect(screen.getByText('@图片2 还没有绑定图片')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '上传 @图片2' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeDisabled();
});
it('binds an already typed next alias without appending it again', async () => {
const workspace = referenceWorkspace();
workspace.form.specification.values.content.concept = '参考 @图片1 和 @图片2 制作双人合照';
uploadMock.mockResolvedValueOnce({
assetId: 'asset-reference-2',
workspaceId: 'workspace-1',
role: 'uploaded',
mediaType: 'image',
mimeType: 'image/png',
width: 800,
height: 800,
durationMilliseconds: null,
generationTaskId: null,
createdAt: '2026-09-02T10:00:00.000Z',
contentPath: '/api/design/assets/asset-reference-2/content',
});
const actions = renderCard(workspace);
fireEvent.change(screen.getByLabelText('选择参考图片'), {
target: { files: [new File(['image'], 'second.png', { type: 'image/png' })] },
});
await waitFor(() => expect(actions.applyFieldOperations).toHaveBeenCalledOnce());
const operations = actions.applyFieldOperations.mock.calls[0]?.[0];
expect(operations).toHaveLength(1);
expect(operations?.[0]).toMatchObject({
kind: 'set',
path: 'references',
value: [
expect.objectContaining({ asset_id: 'asset-reference' }),
expect.objectContaining({ asset_id: 'asset-reference-2' }),
],
});
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
'参考 @图片1 和 @图片2 制作双人合照',
);
});
it('removes a binding and its prompt token atomically', async () => {
const actions = renderCard(referenceWorkspace());
fireEvent.click(screen.getByRole('button', { name: '删除参考图 1' }));
await waitFor(() => {
expect(actions.applyFieldOperations).toHaveBeenCalledWith([
{ kind: 'clear', path: 'references' },
{ kind: 'set', path: 'content.concept', value: '参考 的小狗,让它在草地上奔跑' },
]);
});
});
it('submits compact parameter edits and confirms only the offered Quote identity', async () => {
const workspace = referenceWorkspace({ offered: true });
const actions = renderCard(workspace);
fireEvent.change(screen.getByRole('combobox', { name: '画幅' }), { target: { value: '16:9' } });
await waitFor(() => {
expect(actions.applyFieldOperations).toHaveBeenCalledWith([
{ kind: 'set', path: 'output.aspect_ratio', value: '16:9' },
{ kind: 'set', path: 'output.orientation', value: 'landscape' },
]);
});
await waitFor(() => expect(actions.requestQuote).toHaveBeenCalledOnce());
fireEvent.click(screen.getByRole('button', { name: '确认并开始制作' }));
expect(actions.confirmGeneration).toHaveBeenCalledWith('quote-1');
});
it('collapses a confirmed plan into the expandable production history', () => {
const workspace = designWorkspaceFixture({ tasks: [taskFixture()] });
useImageWorkspaceStore.setState({ pendingOperations: {} });
const { container } = render(
<>
<DesignPlanHistory workspace={workspace} />
<YouthCreationCard workspace={workspace} quoteBlockers={[]} generationAvailable />
</>,
);
expect(screen.getByTestId('design-plan-history-task-1')).toBeInTheDocument();
expect(screen.getByText('制作中')).toBeInTheDocument();
expect(container.querySelector('[data-testid="youth-creation-card"]')).toBeNull();
});
it('shows safe Chinese task guidance instead of provider messages', () => {
render(<DesignTaskPanel tasks={[taskFixture({
status: 'failed',
progress: { stage: 'failed', completedRequiredSteps: 0, totalRequiredSteps: 1 },
issues: [{
code: 'asset_rejected',
message: 'Provider policy rejected the generated asset.',
recoveryOwner: 'platform',
}],
nextActions: ['revise_design'],
})]} />);
const workspace = designWorkspaceFixture({
tasks: [taskFixture({
status: 'failed',
progress: { stage: 'failed', completedRequiredSteps: 0, totalRequiredSteps: 1 },
issues: [{
code: 'asset_rejected',
message: 'Provider policy rejected the generated asset.',
recoveryOwner: 'platform',
}],
nextActions: ['revise_design'],
})],
});
render(<DesignPlanHistory workspace={workspace} />);
fireEvent.click(screen.getByText('制作记录'));
expect(screen.getByText('作品没有通过内容检查,可以换一种安全的说法再试。')).toBeInTheDocument();
expect(screen.queryByText(/Provider policy/i)).not.toBeInTheDocument();