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