Files
makelore/tests/unit/youth-creation-card.test.tsx
2026-09-07 17:24:15 +08:00

324 lines
13 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 { 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();
});
});