278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { FineTuneDrawer } from '@/pages/ImageCanvas/FineTuneDrawer';
|
|
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
|
import type {
|
|
DesignAsset,
|
|
DesignCompilationIssue,
|
|
DesignSpecificationValues,
|
|
DesignWorkspace,
|
|
} from '../../shared/image-workspace';
|
|
import {
|
|
designFormFixture,
|
|
designValuesFixture,
|
|
designWorkspaceFixture,
|
|
} from '../fixtures/design-workspace-v2';
|
|
|
|
function workspaceWithValues(
|
|
values: DesignSpecificationValues,
|
|
assets: DesignAsset[] = [],
|
|
): DesignWorkspace {
|
|
return designWorkspaceFixture({
|
|
form: designFormFixture({
|
|
specification: {
|
|
schema_version: 1,
|
|
values,
|
|
field_decisions: {},
|
|
},
|
|
}),
|
|
assets,
|
|
});
|
|
}
|
|
|
|
function renderDrawer(
|
|
workspace: DesignWorkspace,
|
|
quoteBlockers: DesignCompilationIssue[] = [],
|
|
) {
|
|
const applyFieldOperations = vi.fn().mockResolvedValue(workspace);
|
|
useImageWorkspaceStore.setState({
|
|
workspace,
|
|
fieldDrafts: {},
|
|
pendingOperations: {},
|
|
applyFieldOperations,
|
|
});
|
|
render(
|
|
<FineTuneDrawer
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
workspace={workspace}
|
|
quoteBlockers={quoteBlockers}
|
|
/>,
|
|
);
|
|
return { applyFieldOperations };
|
|
}
|
|
|
|
describe('FineTuneDrawer', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
useImageWorkspaceStore.getState().reset();
|
|
});
|
|
|
|
it('shows a small set of youth-facing image controls and preserves typed direct edits', () => {
|
|
const values = structuredClone(designValuesFixture);
|
|
const blocker: DesignCompilationIssue = {
|
|
code: 'aspect_ratio_required',
|
|
message: 'Choose an aspect ratio supported by the frozen route.',
|
|
severity: 'blocker',
|
|
path: 'output.aspect_ratio',
|
|
};
|
|
const { applyFieldOperations } = renderDrawer(workspaceWithValues(values), [blocker]);
|
|
|
|
expect(screen.getByRole('heading', { name: '精细调整' })).toBeInTheDocument();
|
|
expect(screen.getByText('先选择作品形状。')).toBeInTheDocument();
|
|
expect(screen.queryByText(/frozen route/i)).not.toBeInTheDocument();
|
|
expect(screen.getByText('作品的样子')).toBeInTheDocument();
|
|
expect(screen.getByText('画面上的文字')).toBeInTheDocument();
|
|
expect(screen.queryByText('图片调整')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('视频故事')).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/milliseconds|camera|focus_behavior|Specification|compiler/i)).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '手机竖屏' }));
|
|
expect(applyFieldOperations).toHaveBeenCalledWith([
|
|
{ kind: 'set', path: 'output.aspect_ratio', value: '9:16' },
|
|
{ kind: 'set', path: 'output.orientation', value: 'portrait' },
|
|
], []);
|
|
|
|
fireEvent.click(screen.getByText('其他小调整'));
|
|
fireEvent.change(screen.getByLabelText('喜欢哪些颜色?'), {
|
|
target: { value: '蓝紫色和亮黄色' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '保存' }));
|
|
expect(applyFieldOperations).toHaveBeenLastCalledWith([
|
|
{ kind: 'set', path: 'visual.palette', value: '蓝紫色和亮黄色' },
|
|
], ['visual.palette']);
|
|
});
|
|
|
|
it('renders image edit controls only when the current design is editing an image', () => {
|
|
const values = structuredClone(designValuesFixture);
|
|
values.intent.creation_intent = 'edit';
|
|
values.image.source_asset_id = 'asset-source';
|
|
const asset: DesignAsset = {
|
|
assetId: 'asset-source',
|
|
workspaceId: 'workspace-1',
|
|
role: 'uploaded',
|
|
mediaType: 'image',
|
|
mimeType: 'image/png',
|
|
width: 800,
|
|
height: 800,
|
|
durationMilliseconds: null,
|
|
generationTaskId: null,
|
|
createdAt: '2026-09-02T09:00:00.000Z',
|
|
contentPath: '/api/design/assets/asset-source/content',
|
|
};
|
|
|
|
renderDrawer(workspaceWithValues(values, [asset]));
|
|
|
|
expect(screen.getByText('图片调整')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('从哪张图开始修改?')).toBeInTheDocument();
|
|
expect(screen.queryByText('视频故事')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('maps a simple video story back to canonical Shots without exposing timing or camera fields', () => {
|
|
const values = structuredClone(designValuesFixture);
|
|
values.intent.media = 'video';
|
|
values.video.total_duration_seconds = 5;
|
|
values.video.pacing = '自然流畅';
|
|
values.video.shots = [{
|
|
id: 'shot-1',
|
|
start_milliseconds: 0,
|
|
end_milliseconds: 5_000,
|
|
story_beat: '机器人找到面粉',
|
|
framing: 'close-up',
|
|
camera: 'dolly-in',
|
|
subject_motion: null,
|
|
environment_motion: null,
|
|
focus_behavior: null,
|
|
transition: null,
|
|
}];
|
|
const { applyFieldOperations } = renderDrawer(workspaceWithValues(values));
|
|
|
|
expect(screen.getByText('视频故事')).toBeInTheDocument();
|
|
expect(screen.queryByText('图片调整')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('画面上的文字')).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/start_milliseconds|end_milliseconds|dolly-in|focus_behavior/i)).not.toBeInTheDocument();
|
|
|
|
fireEvent.change(screen.getByLabelText('画面 1'), {
|
|
target: { value: '机器人找到面粉' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '添加一个画面' }));
|
|
fireEvent.change(screen.getByLabelText('画面 2'), {
|
|
target: { value: '机器人端出星星饼干' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '保存故事' }));
|
|
|
|
const operations = applyFieldOperations.mock.calls.at(-1)?.[0];
|
|
expect(operations).toHaveLength(1);
|
|
expect(operations[0]).toMatchObject({ kind: 'set', path: 'video.shots' });
|
|
expect(operations[0].value).toEqual([
|
|
expect.objectContaining({
|
|
id: 'shot-1',
|
|
story_beat: '机器人找到面粉',
|
|
start_milliseconds: 0,
|
|
end_milliseconds: 5_000,
|
|
camera: 'dolly-in',
|
|
}),
|
|
expect.objectContaining({
|
|
story_beat: '机器人端出星星饼干',
|
|
start_milliseconds: null,
|
|
end_milliseconds: null,
|
|
camera: null,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('preserves every supported Shot identity and hidden field when one beat changes', () => {
|
|
const values = structuredClone(designValuesFixture);
|
|
values.intent.media = 'video';
|
|
values.video.total_duration_seconds = 24;
|
|
values.video.shots = Array.from({ length: 24 }, (_, index) => ({
|
|
id: `shot-${index + 1}`,
|
|
start_milliseconds: index * 1_000,
|
|
end_milliseconds: (index + 1) * 1_000,
|
|
story_beat: `故事画面 ${index + 1}`,
|
|
framing: `framing-${index + 1}`,
|
|
camera: `camera-${index + 1}`,
|
|
subject_motion: `subject-${index + 1}`,
|
|
environment_motion: `environment-${index + 1}`,
|
|
focus_behavior: `focus-${index + 1}`,
|
|
transition: `transition-${index + 1}`,
|
|
}));
|
|
const { applyFieldOperations } = renderDrawer(workspaceWithValues(values));
|
|
|
|
expect(screen.getByLabelText('画面 24')).toHaveValue('故事画面 24');
|
|
expect(screen.getByRole('button', { name: '最多 24 个画面' })).toBeDisabled();
|
|
fireEvent.change(screen.getByLabelText('画面 1'), {
|
|
target: { value: '新的开场画面' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '保存故事' }));
|
|
|
|
const shots = applyFieldOperations.mock.calls.at(-1)?.[0]?.[0]?.value;
|
|
expect(shots).toHaveLength(24);
|
|
expect(shots[0]).toEqual({
|
|
...values.video.shots[0],
|
|
story_beat: '新的开场画面',
|
|
});
|
|
expect(shots[6]).toEqual(values.video.shots[6]);
|
|
expect(shots[23]).toEqual(values.video.shots[23]);
|
|
});
|
|
|
|
it('treats a cleared existing beat as an explicit Shot deletion', () => {
|
|
const values = structuredClone(designValuesFixture);
|
|
values.intent.media = 'video';
|
|
values.video.shots = [1, 2].map((number) => ({
|
|
id: `shot-${number}`,
|
|
start_milliseconds: (number - 1) * 1_000,
|
|
end_milliseconds: number * 1_000,
|
|
story_beat: `故事画面 ${number}`,
|
|
framing: `framing-${number}`,
|
|
camera: `camera-${number}`,
|
|
subject_motion: null,
|
|
environment_motion: null,
|
|
focus_behavior: null,
|
|
transition: null,
|
|
}));
|
|
const { applyFieldOperations } = renderDrawer(workspaceWithValues(values));
|
|
|
|
expect(screen.getByText('清空文字后保存,会删除这个画面。')).toBeInTheDocument();
|
|
fireEvent.change(screen.getByLabelText('画面 1'), { target: { value: ' ' } });
|
|
fireEvent.click(screen.getByRole('button', { name: '保存故事' }));
|
|
|
|
const shots = applyFieldOperations.mock.calls.at(-1)?.[0]?.[0]?.value;
|
|
expect(shots).toEqual([values.video.shots[1]]);
|
|
expect(shots).not.toContainEqual(expect.objectContaining({ story_beat: null }));
|
|
});
|
|
|
|
it('updates reference intent without losing its canonical role or do-not-copy meaning', () => {
|
|
const values = structuredClone(designValuesFixture);
|
|
values.references = [{
|
|
id: 'reference-1',
|
|
asset_id: 'asset-reference',
|
|
asset_revision: null,
|
|
role: 'subject_identity',
|
|
preserve: ['圆脸'],
|
|
adapt: ['衣服颜色'],
|
|
do_not_copy: ['背景文字'],
|
|
reviewed_observations: ['白色背景'],
|
|
}];
|
|
const asset: DesignAsset = {
|
|
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 { applyFieldOperations } = renderDrawer(workspaceWithValues(values, [asset]));
|
|
|
|
fireEvent.change(screen.getByLabelText('参考图 1 最想保留什么'), {
|
|
target: { value: '圆脸\n红围巾' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: '保存参考图' }));
|
|
|
|
const reference = applyFieldOperations.mock.calls.at(-1)?.[0]?.[0]?.value?.[0];
|
|
expect(reference).toMatchObject({
|
|
id: 'reference-1',
|
|
role: 'subject_identity',
|
|
preserve: ['圆脸', '红围巾'],
|
|
adapt: ['衣服颜色'],
|
|
do_not_copy: ['背景文字'],
|
|
reviewed_observations: ['白色背景'],
|
|
});
|
|
});
|
|
});
|