Files
makelore/tests/unit/fine-tune-drawer.test.tsx

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: ['白色背景'],
});
});
});