345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
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 {
|
||
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,
|
||
};
|
||
}
|
||
|
||
function taskFixture(
|
||
overrides: Partial<DesignGenerationTask> = {},
|
||
): DesignGenerationTask {
|
||
const quote = designQuoteFixture();
|
||
return {
|
||
taskId: 'task-1',
|
||
quoteId: quote.quoteId,
|
||
status: 'running',
|
||
taskRevision: 1,
|
||
specificationRevision: quote.specificationRevision,
|
||
specificationRevisionId: quote.specificationRevisionId,
|
||
specificationDigest: quote.specificationDigest,
|
||
medium: quote.medium,
|
||
compilerVersion: quote.compilerVersion,
|
||
outputSummary: quote.outputSummary,
|
||
maximumCustomerChargeAtoms: quote.maximumCustomerChargeAtoms,
|
||
customerBilling: {
|
||
quotedAtoms: 1200,
|
||
heldAtoms: 1200,
|
||
chargedAtoms: 0,
|
||
refundedAtoms: 0,
|
||
pendingResolutionAtoms: 0,
|
||
},
|
||
customerHoldExpiresAt: quote.expiresAt,
|
||
resolutionDeadlineAt: null,
|
||
progress: {
|
||
stage: 'generating',
|
||
completedRequiredSteps: 0,
|
||
totalRequiredSteps: 1,
|
||
},
|
||
executionHealth: 'normal',
|
||
cancellation: { state: 'available', outcome: null },
|
||
issues: [],
|
||
resultAssetIds: [],
|
||
nextActions: ['cancel'],
|
||
createdAt: '2026-09-02T09:00:00.000Z',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe('YouthCreationCard', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('shows a plain creation summary and reveals the offered quote after requesting it', async () => {
|
||
const { onQuoteOffered, requestQuote } = 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(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();
|
||
});
|
||
|
||
it('does not request a plan when generation is unavailable', () => {
|
||
const { requestQuote, onFocusComposer } = renderCard(undefined, {
|
||
generationAvailable: false,
|
||
});
|
||
|
||
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({
|
||
form: designFormFixture({ activeQuotes: [designQuoteFixture()] }),
|
||
});
|
||
const { onOpenFineTune: onConfirmingFineTune } = renderCard(confirming);
|
||
expect(screen.getByText('方案准备好了,确认后就开始制作。')).toBeInTheDocument();
|
||
fireEvent.click(screen.getByRole('button', { name: '继续调整' }));
|
||
expect(onConfirmingFineTune).toHaveBeenCalledOnce();
|
||
|
||
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],
|
||
});
|
||
|
||
// 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();
|
||
|
||
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('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'],
|
||
})]} />);
|
||
|
||
expect(screen.getByText('作品没有通过内容检查,可以换一种安全的说法再试。')).toBeInTheDocument();
|
||
expect(screen.queryByText(/Provider policy/i)).not.toBeInTheDocument();
|
||
});
|
||
});
|