Files
makelore/tests/unit/youth-creation-card.test.tsx

344 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { cleanup, fireEvent, render, screen, 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;
} = {},
) {
const requestQuote = vi.fn().mockResolvedValue(workspace);
const resolveDecisionPrompt = vi.fn().mockResolvedValue(workspace);
const onOpenFineTune = options.onOpenFineTune ?? vi.fn();
const onFocusComposer = options.onFocusComposer ?? vi.fn();
useImageWorkspaceStore.setState({
workspace,
quoteBlockers: [],
pendingOperations: {},
requestQuote,
resolveDecisionPrompt,
});
render(
<YouthCreationCard
workspace={workspace}
quoteBlockers={options.quoteBlockers ?? []}
generationAvailable={options.generationAvailable ?? true}
onOpenFineTune={onOpenFineTune}
onFocusComposer={onFocusComposer}
/>,
);
return {
workspace,
requestQuote,
resolveDecisionPrompt,
onOpenFineTune,
onFocusComposer,
};
}
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 offers a youth-facing ready action', () => {
const { requestQuote } = renderCard();
expect(screen.getByRole('heading', { name: '我的创作' })).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();
});
it('shows only the first choice, keeps the server label, and resolves it through the store', () => {
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.getByTestId('youth-creation-choice')).toBeInTheDocument();
expect(screen.getByText('画面想要什么感觉?')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /有趣的卡通/ })).toHaveClass('min-h-11');
expect(screen.getByText('推荐')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /你帮我选/ })).toBeInTheDocument();
expect(screen.queryByText('这个问题不该显示')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /你帮我选/ }));
expect(resolveDecisionPrompt).toHaveBeenCalledWith('choice-1', 'select', 'delegate');
fireEvent.click(screen.getByRole('button', { name: '先跳过这题' }));
expect(resolveDecisionPrompt).toHaveBeenCalledWith('choice-1', 'reject');
});
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] });
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();
});
});