324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
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 { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||
import type { DesignGenerationTask } from '../../shared/image-workspace';
|
||
import {
|
||
designFormFixture,
|
||
designQuoteFixture,
|
||
designValuesFixture,
|
||
designWorkspaceFixture,
|
||
} from '../fixtures/design-workspace-v2';
|
||
|
||
const { uploadMock } = vi.hoisted(() => ({ uploadMock: vi.fn() }));
|
||
|
||
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',
|
||
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,
|
||
};
|
||
}
|
||
|
||
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('keeps the editable prompt and visible parameters in a lightweight inline plan', () => {
|
||
renderCard();
|
||
|
||
expect(screen.getByTestId('youth-creation-card')).toHaveClass('py-2');
|
||
expect(screen.getByTestId('youth-creation-card')).not.toHaveClass('border', 'shadow-sm');
|
||
expect(screen.getByRole('heading', { name: '制作方案' })).toBeInTheDocument();
|
||
expect(screen.getByRole('textbox', { name: '创作提示词' })).toHaveValue(
|
||
'把便携咖啡机呈现为城市通勤中的精密工具',
|
||
);
|
||
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.queryByRole('region', { name: '参考图' })).not.toBeInTheDocument();
|
||
expect(screen.queryByText('查看画面细节')).not.toBeInTheDocument();
|
||
expect(screen.queryByText('手动调整')).not.toBeInTheDocument();
|
||
});
|
||
|
||
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: '更聚焦咖啡机,背景使用清晨地铁站' },
|
||
]);
|
||
});
|
||
});
|
||
|
||
it('recompiles and requotes after editing an offered plan', async () => {
|
||
const workspace = designWorkspaceFixture({
|
||
form: designFormFixture({ activeQuotes: [designQuoteFixture()] }),
|
||
});
|
||
const actions = renderCard(workspace);
|
||
const prompt = screen.getByRole('textbox', { name: '创作提示词' });
|
||
|
||
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 },
|
||
});
|
||
|
||
expect(screen.getByRole('listbox', { name: '可引用的图片' })).toBeInTheDocument();
|
||
expect(screen.getByRole('option', { name: '@图片1' })).toBeInTheDocument();
|
||
expect(screen.getByRole('option', { name: '上传新的参考图' })).toBeInTheDocument();
|
||
});
|
||
|
||
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', () => {
|
||
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();
|
||
});
|
||
});
|