import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { TooltipProvider } from '@/components/ui/tooltip' import { LocaleContext } from '@/lib/i18n' import { TaskSegmentEditor } from '@/components/widgets/multitrack/TaskSegmentEditor' import { getMediaListStoreRevision } from '@/stores/media-list-store' import type { MultiTrack, MultiTrackSegment } from '@/types/multitrack' vi.mock('@/components/widgets/mediaSelector/MediaSelector', () => ({ MediaSelector: (props: { value: string allowMultipleSelection?: boolean maxSelectionCount?: number slotItems?: Array<{ value: string }> onChange: (value: string, source: 'input') => void }) => (
item.value).join(',') ?? ''} >
), })) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) function activateTab(name: string) { const tab = screen.getByRole('tab', { name }) fireEvent.pointerDown(tab, { button: 0, ctrlKey: false }) fireEvent.mouseDown(tab, { button: 0, ctrlKey: false }) fireEvent.click(tab) } function inputEditable(element: HTMLElement, value: string) { element.textContent = value fireEvent.input(element) } function taskSegment(): MultiTrackSegment { return { id: 'task-segment', start_frame: 0, end_frame: 3, color: 'var(--multitrack-task-bg)', content: { media_type: 'none', task_mode: 'default', user_prompt: 'Initial prompt', images: [ { id: 'a', source_type: 'input', file_path: 'a.png', file_name: 'a.png' }, { id: 'b', source_type: 'input', file_path: 'b.png', file_name: 'b.png' }, ], }, } } function secondTaskSegment(): MultiTrackSegment { return { ...taskSegment(), id: 'task-segment-2', start_frame: 3, end_frame: 6, content: { media_type: 'none', task_mode: 'default', user_prompt: 'Second prompt', images: [], }, } } function videoSegment( startFrame: number, endFrame: number, sourceType: 'input' | 'preset' = 'input', ): MultiTrackSegment { return { id: `video-${startFrame}-${endFrame}-${sourceType}`, start_frame: startFrame, end_frame: endFrame, color: 'var(--multitrack-video-bg)', content: { media_type: 'video', source_type: sourceType, file_path: 'video.mp4', }, } } function mediaTrack(type: 'audio' | 'video', segments: MultiTrackSegment[]): MultiTrack { return { id: `${type}-track`, name: type === 'audio' ? 'Voice track' : 'Source video track', type, color: type === 'audio' ? 'var(--multitrack-audio-bg)' : 'var(--multitrack-video-bg)', muted: false, locked: false, segments, } } describe('TaskSegmentEditor', () => { it('updates task mode and prompt text', () => { const onContentChange = vi.fn() render() fireEvent.click(screen.getByRole('combobox', { name: 'Task mode' })) fireEvent.click(screen.getByRole('option', { name: 'R2V' })) inputEditable(screen.getByRole('textbox', { name: 'Prompt' }), 'New prompt') expect(onContentChange).toHaveBeenCalledWith({ task_mode: 'ref' }) expect(onContentChange).toHaveBeenCalledWith({ user_prompt: 'New prompt' }) }) it('shows and stores MiniMax continuity mode from the second task onward', () => { const onContentChange = vi.fn() const first = taskSegment() const second = secondTaskSegment() const { rerender } = render( , ) const continuitySelect = screen.getByRole('combobox', { name: 'Continuity mode' }) expect(continuitySelect.className).toContain('w-28') expect(screen.getByRole('combobox', { name: 'Task mode' }).className).toContain('w-24') fireEvent.click(continuitySelect) fireEvent.click(screen.getByRole('option', { name: 'Context' })) expect(onContentChange).toHaveBeenCalledWith({ continuity_mode: 'context' }) fireEvent.click(continuitySelect) fireEvent.click(screen.getByRole('option', { name: 'Swap Context' })) expect(onContentChange).toHaveBeenCalledWith({ continuity_mode: 'context_swap' }) rerender( , ) expect(screen.queryByRole('combobox', { name: 'Continuity mode' })).toBeNull() rerender( , ) expect(screen.queryByRole('combobox', { name: 'Continuity mode' })).toBeNull() }) it('shows and stores reference image size only for reference tasks', () => { const onContentChange = vi.fn() const referenceSegment = taskSegment() referenceSegment.content.task_mode = 'ref' referenceSegment.content.ref_image_size = 'max' const { rerender } = render( , ) const sizeSelect = screen.getByRole('combobox', { name: 'Reference image size' }) expect(sizeSelect.textContent).toContain('Max') fireEvent.click(sizeSelect) fireEvent.click(screen.getByRole('option', { name: 'Match' })) expect(onContentChange).toHaveBeenCalledWith({ ref_image_size: 'match' }) rerender( , ) expect(screen.queryByRole('combobox', { name: 'Reference image size' })).toBeNull() }) it('synchronizes dropdown changes across selected tasks while keeping the first continuity mode as shot', () => { const onContentChange = vi.fn() const onTrackSegmentsContentChange = vi.fn() const first = taskSegment() first.content.task_mode = 'ref' first.content.continuity_mode = 'shot' const second = secondTaskSegment() second.content.task_mode = 'ref' second.content.continuity_mode = 'shot' render( , ) fireEvent.click(screen.getByRole('combobox', { name: 'Continuity mode' })) fireEvent.click(screen.getByRole('option', { name: 'Context' })) expect(onTrackSegmentsContentChange).toHaveBeenLastCalledWith([ { segmentId: first.id, patch: { continuity_mode: 'shot' } }, { segmentId: second.id, patch: { continuity_mode: 'context' } }, ]) fireEvent.click(screen.getByRole('combobox', { name: 'Reference image size' })) fireEvent.click(screen.getByRole('option', { name: 'Max' })) expect(onTrackSegmentsContentChange).toHaveBeenLastCalledWith([ { segmentId: first.id, patch: { ref_image_size: 'max' } }, { segmentId: second.id, patch: { ref_image_size: 'max' } }, ]) fireEvent.click(screen.getByRole('combobox', { name: 'Task mode' })) fireEvent.click(screen.getByRole('option', { name: 'VI2V' })) expect(onTrackSegmentsContentChange).toHaveBeenLastCalledWith([ { segmentId: first.id, patch: { task_mode: 'edit' } }, { segmentId: second.id, patch: { task_mode: 'edit' } }, ]) expect(onContentChange).not.toHaveBeenCalled() }) it('stores the A/B prompt selection, edits B independently, and shows its tooltip on hover', async () => { const onContentChange = vi.fn() const initialSegment = taskSegment() const { rerender } = render( , ) const tooltipText = 'New: The user prompt output follows your A/B selection (select A to output A, select B to output B). Use A/B to compare prompts, or to preserve both the original and reverse-engineered prompts.' const tooltipTrigger = screen.getByLabelText(tooltipText) fireEvent.pointerMove(tooltipTrigger, { pointerType: 'mouse' }) expect((await screen.findByRole('tooltip')).textContent).toBe(tooltipText) fireEvent.pointerLeave(tooltipTrigger, { pointerType: 'mouse' }) await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull()) activateTab('B') expect(onContentChange).toHaveBeenCalledWith({ user_prompt_variant: 'b' }) const bSegment = taskSegment() bSegment.content.user_prompt_b = 'Reverse-engineered prompt' bSegment.content.user_prompt_variant = 'b' rerender( , ) const prompt = screen.getByRole('textbox', { name: 'Prompt' }) as HTMLElement expect(prompt.textContent).toBe('Reverse-engineered prompt') inputEditable(prompt, 'Updated B prompt') expect(onContentChange).toHaveBeenCalledWith({ user_prompt_b: 'Updated B prompt' }) expect(bSegment.content.user_prompt).toBe('Initial prompt') }) it('offers last-frame mode after edit', () => { const onContentChange = vi.fn() render() fireEvent.click(screen.getByRole('combobox')) const options = screen.getAllByRole('option').map((option) => option.textContent) expect(options).toEqual([ 'FL2V', 'R2V', 'VI2V', 'L2V', ]) fireEvent.click(screen.getByRole('option', { name: 'L2V' })) expect(onContentChange).toHaveBeenCalledWith({ task_mode: 'l2v' }) }) it.each([ [0, 'T2V'], [1, 'I2V'], [2, 'FL2V'], [3, 'FMLF2V'], ])('shows the default task type for %i task images', (imageCount, expectedLabel) => { const segment = taskSegment() segment.content.images = Array.from({ length: imageCount }, (_, index) => ({ id: `image-${index}`, source_type: 'input' as const, file_path: `image-${index}.png`, })) render() expect(screen.getByRole('combobox', { name: 'Task mode' }).textContent).toContain(expectedLabel) }) it('keeps task prompt wheel scrolling inside the editor', () => { const onCanvasWheel = vi.fn() render(
{ const target = event.target as HTMLElement const captureElement = target.closest('[data-capture-wheel="true"]') if (!captureElement?.contains(document.activeElement)) onCanvasWheel() }} >
, ) const prompt = screen.getByRole('textbox', { name: 'Prompt' }) prompt.focus() const keepsNativeScroll = fireEvent.wheel(prompt, { deltaY: 100 }) expect(keepsNativeScroll).toBe(true) expect(onCanvasWheel).not.toHaveBeenCalled() }) it('uses rv2v for reference mode when a non-preset video overlaps the task range', () => { render( , ) fireEvent.click(screen.getByRole('combobox')) expect(screen.getByRole('option', { name: 'RV2V' })).not.toBeNull() }) it('uses rv2v for reference mode with preset video', () => { render( , ) fireEvent.click(screen.getByRole('combobox')) expect(screen.getByRole('option', { name: 'RV2V' })).not.toBeNull() }) it.each([ ['video ending at the task start', videoSegment(-2, 0)], ['video starting at the task end', videoSegment(3, 5)], ])('uses r2v for reference mode with %s', (_caseName, video) => { render( , ) fireEvent.click(screen.getByRole('combobox')) expect(screen.getByRole('option', { name: 'R2V' })).not.toBeNull() }) it('uploads dropped image files and appends them to task images', async () => { const onContentChange = vi.fn() const initialCacheRevision = getMediaListStoreRevision() const fetchMock = vi.fn() .mockResolvedValueOnce({ ok: true, json: async () => ({ name: 'first.png', subfolder: 'uploads' }) }) .mockResolvedValueOnce({ ok: true, json: async () => ({ name: 'second.png', subfolder: '' }) }) vi.stubGlobal('fetch', fetchMock) render() fireEvent.drop(screen.getByTestId('task-image-drop-zone'), { dataTransfer: { files: [ new File(['first'], 'first.png', { type: 'image/png' }), new File(['second'], 'second.png', { type: 'image/png' }), ], types: ['Files'], }, }) await waitFor(() => { expect(onContentChange).toHaveBeenLastCalledWith({ images: [ expect.objectContaining({ file_name: 'a.png' }), expect.objectContaining({ file_name: 'b.png' }), expect.objectContaining({ file_path: 'uploads/first.png', file_name: 'first.png' }), expect.objectContaining({ file_path: 'second.png', file_name: 'second.png' }), ], }) }) expect(getMediaListStoreRevision()).toBe(initialCacheRevision + 1) }) it('highlights the image picker while image files are dragged over it', () => { render() const dropZone = screen.getByTestId('task-image-drop-zone') fireEvent.dragEnter(dropZone, { dataTransfer: { files: [new File(['first'], 'first.png', { type: 'image/png' })], types: ['Files'], }, }) expect(dropZone.className).toContain('border-primary') fireEvent.dragLeave(dropZone, { relatedTarget: document.body }) expect(dropZone.className).not.toContain('border-primary') }) it('previews and deletes images from the image grid actions', () => { const onContentChange = vi.fn() const onOpenImagePreview = vi.fn() render( , ) expect(screen.getByTestId('task-image-a').className).toContain('bg-black') expect(screen.getByTestId('task-image-a').className).toContain('group/task-image') const firstImage = screen.getByAltText('a.png') expect(firstImage.className).toContain('absolute') expect(firstImage.className).toContain('inset-0') expect(firstImage.className).toContain('h-full') expect(firstImage.className).toContain('w-full') expect(firstImage.className).toContain('object-contain') expect(screen.getByTestId('task-image-actions-a').className).toContain('opacity-0') expect(screen.getByTestId('task-image-actions-a').className).toContain('group-hover/task-image:opacity-100') expect(screen.getByTestId('task-image-actions-a').className).toContain('flex') expect(screen.getByTestId('task-image-actions-a').className).toContain('right-0') expect(screen.getByTestId('task-image-actions-a').className).toContain('top-1') expect(screen.getByTestId('task-image-overlay-a').className).toContain('bg-black/30') expect(screen.getByTestId('task-image-index-a').textContent).toBe('0') expect(screen.getByTestId('task-image-index-a').className).toContain('bottom-0') expect(screen.getByTestId('task-image-index-b').textContent).toBe('1') expect(screen.getAllByRole('button', { name: 'Preview image' })[0].className).toContain('cursor-pointer') expect(screen.getAllByRole('button', { name: 'Preview image' })).toHaveLength(2) expect(screen.queryByRole('button', { name: '720° panorama preview' })).toBeNull() expect(screen.getAllByRole('button', { name: 'Delete image' })[0].className).toContain('cursor-pointer') expect(screen.getAllByRole('button', { name: 'Delete image' })[0].className).toContain('text-destructive') const previewButton = screen.getAllByRole('button', { name: 'Preview image' })[0] expect(previewButton.querySelector('.lucide-eye')).not.toBeNull() fireEvent.click(previewButton) expect(onOpenImagePreview).toHaveBeenCalledWith('a') expect(screen.queryByTestId('media-selector-mock')).toBeNull() fireEvent.click(screen.getAllByRole('button', { name: 'Delete image' })[0]) expect(onContentChange).toHaveBeenCalledWith({ images: [expect.objectContaining({ id: 'b' })], }) expect(screen.queryByTestId('media-selector-mock')).toBeNull() }) it('labels slot image placeholders with their slot number', () => { const segment = taskSegment() segment.content.images = [{ id: 'slot-image', source_type: 'slot', slot_name: 'image2', file_name: 'image2', }] render() expect(screen.getByTestId('task-image-slot-image').textContent).toContain('Image 2') }) it('shares an image across tasks before each task local images', () => { const first = taskSegment() first.content.images = [ { id: 'local-a', source_type: 'input', file_path: 'local-a.png' }, { id: 'shared', source_type: 'input', file_path: 'shared.png' }, ] const second = secondTaskSegment() second.content.images = [ { id: 'same-path', source_type: 'input', file_path: 'shared.png' }, { id: 'local-b', source_type: 'input', file_path: 'local-b.png' }, ] const onTrackSegmentsContentChange = vi.fn() render( , ) fireEvent.click(screen.getByTestId('task-image-shared-shared')) expect(onTrackSegmentsContentChange).toHaveBeenCalledWith([ { segmentId: first.id, patch: { images: [ expect.objectContaining({ id: 'shared', shared_reference: true }), expect.objectContaining({ id: 'local-a' }), ] }, }, { segmentId: second.id, patch: { images: [ expect.objectContaining({ id: 'same-path', shared_reference: true }), expect.objectContaining({ id: 'local-b' }), ] }, }, ]) }) it('reselects an image in place and opens the chosen image first without batch controls', () => { const onContentChange = vi.fn() render() fireEvent.click(screen.getByTestId('task-image-a')) const selector = screen.getByTestId('media-selector-mock') expect(selector.dataset.value).toBe('a.png') expect(selector.dataset.multiple).toBe('false') expect(selector.dataset.limit).toBe('1') fireEvent.click(screen.getByRole('button', { name: 'choose replacement' })) expect(onContentChange).toHaveBeenLastCalledWith({ images: [ expect.objectContaining({ id: 'a', file_path: 'replacement.png' }), expect.objectContaining({ id: 'b', file_path: 'b.png' }), ], }) }) it('passes the remaining image capacity to the add-image selector', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Select image' })) const selector = screen.getByTestId('media-selector-mock') expect(selector.dataset.value).toBe('') expect(selector.dataset.multiple).toBe('true') expect(selector.dataset.limit).toBe('7') }) it('supports one-based image item numbering', () => { render( , ) expect(screen.getByTestId('task-image-index-a').textContent).toBe('1') expect(screen.getByTestId('task-image-index-b').textContent).toBe('2') }) it('confirms before applying the selected prompt to both variants of every task segment', () => { const first = taskSegment() first.content.user_prompt = 'Prompt A' first.content.user_prompt_b = 'Selected prompt B' first.content.user_prompt_variant = 'b' const second = secondTaskSegment() second.content.user_prompt = 'Existing A' second.content.user_prompt_b = 'Existing B' const onTrackSegmentsContentChange = vi.fn() render( , ) const applyButton = screen.getByRole('button', { name: 'Apply to all' }) expect(applyButton.className).toContain('text-highlight') expect(applyButton.className).toContain('hover:bg-highlight/10') fireEvent.click(applyButton) expect(screen.getByRole('heading', { name: 'Apply this prompt to every segment?' })).not.toBeNull() expect(screen.getByText(/replace both prompt A and prompt B/)).not.toBeNull() expect(onTrackSegmentsContentChange).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Replace all' })) expect(onTrackSegmentsContentChange).toHaveBeenCalledWith([ { segmentId: first.id, patch: { user_prompt: 'Selected prompt B', user_prompt_b: 'Selected prompt B' }, }, { segmentId: second.id, patch: { user_prompt: 'Selected prompt B', user_prompt_b: 'Selected prompt B' }, }, ]) }) it('bypasses an image, removes its sequence number, and renumbers active images', () => { const onContentChange = vi.fn() const segment = taskSegment() segment.content.images![0].muted = true render( , ) expect(screen.queryByTestId('task-image-index-a')).toBeNull() expect(screen.getByTestId('task-image-index-b').textContent).toBe('1') expect(screen.getAllByRole('button', { name: 'Preview image' })).toHaveLength(2) expect(screen.getAllByRole('button', { name: 'Delete image' })).toHaveLength(2) fireEvent.click(screen.getByTestId('task-image-muted-a')) expect(onContentChange).toHaveBeenCalledWith({ images: [ expect.objectContaining({ id: 'a', muted: false }), expect.objectContaining({ id: 'b' }), ], }) }) it('keeps the image grid responsive inside resizable image and prompt panels', () => { const nodePointerDown = vi.fn() document.body.addEventListener('pointerdown', nodePointerDown) render() const dropZone = screen.getByTestId('task-image-drop-zone') const grid = screen.getByTestId('task-image-grid') expect(dropZone.closest('.task-segment-editor')).not.toBeNull() expect(dropZone.className).toContain('w-full') expect(grid.className).toContain('grid-cols-2') expect(screen.getByTestId('task-image-a').className).toContain('w-full') expect(screen.getByTestId('task-image-a').className).toContain('self-start') expect(screen.getByRole('button', { name: 'Select image' }).className).toContain('w-full') expect(screen.getByRole('button', { name: 'Select image' }).className).toContain('self-start') expect(screen.getByTestId('task-prompt-panel').className).toContain('min-w-0') const separator = screen.getByRole('separator', { name: 'Resize image and prompt panels' }) fireEvent.pointerDown(separator, { button: 0, clientX: 320 }) expect(nodePointerDown).not.toHaveBeenCalled() document.body.removeEventListener('pointerdown', nodePointerDown) expect(screen.getByTestId('task-images-resizable-panel')).not.toBeNull() expect(screen.getByTestId('task-prompt-resizable-panel')).not.toBeNull() }) it('resizes panels through the local pointer-capture path used by Nodes 2.0', async () => { const offsetWidth = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400) render() const separator = screen.getByRole('separator', { name: 'Resize image and prompt panels' }) const group = separator.parentElement if (!group) throw new Error('Expected resizable panel group') vi.spyOn(group, 'getBoundingClientRect').mockReturnValue({ x: 0, y: 0, top: 0, right: 800, bottom: 400, left: 0, width: 800, height: 400, toJSON: () => ({}), }) const dispatchPointer = (type: string, clientX: number, buttons: number) => { const event = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperties(event, { button: { value: 0 }, buttons: { value: buttons }, clientX: { value: clientX }, pointerId: { value: 7 }, }) fireEvent(separator, event) } const initialSize = separator.getAttribute('aria-valuenow') dispatchPointer('pointerdown', 320, 1) dispatchPointer('pointermove', 400, 1) await waitFor(() => { expect(separator.getAttribute('aria-valuenow')).not.toBe(initialSize) }) dispatchPointer('pointerup', 400, 0) offsetWidth.mockRestore() }) it('renders saved panorama framing without showing a panorama action icon', () => { const segment = taskSegment() segment.content.images![0].panorama_view = { version: 1, projection: 'equirectangular', yaw: 20, pitch: 5, hfov: 75, aspect_ratio: 1.5, } render() expect(screen.queryByRole('button', { name: '720° panorama preview' })).toBeNull() expect(screen.getAllByRole('button', { name: 'Preview image' })).toHaveLength(2) expect(screen.getByTestId('task-image-grid').className).toContain('w-full') expect(screen.getByTestId('panorama-image-preview-a').className).toContain('absolute') }) it('reorders images by dragging one image onto another', () => { const onContentChange = vi.fn() render() fireEvent.dragStart(screen.getByTestId('task-image-a')) fireEvent.drop(screen.getByTestId('task-image-b')) expect(onContentChange).toHaveBeenCalledWith({ images: [ expect.objectContaining({ id: 'b' }), expect.objectContaining({ id: 'a' }), ], }) }) it('keeps the image grid stable without opening a hover focus preview', () => { render() const grid = screen.getByTestId('task-image-grid') const firstImage = screen.getByTestId('task-image-a') const secondImage = screen.getByTestId('task-image-b') expect(grid.className).toContain('grid-cols-2') expect(screen.queryByTestId('task-image-focus-preview')).toBeNull() fireEvent.mouseEnter(secondImage) expect(grid.className).toContain('grid-cols-2') expect(screen.queryByTestId('task-image-focus-preview')).toBeNull() expect(secondImage.className).toContain('relative') expect(secondImage.className).toContain('cursor-pointer') expect(secondImage.className).not.toContain('opacity-0') expect(firstImage.className).not.toContain('opacity-0') fireEvent.mouseLeave(grid) expect(grid.className).toContain('grid-cols-2') expect(screen.queryByTestId('task-image-focus-preview')).toBeNull() expect(secondImage.className).toContain('relative') expect(secondImage.className).not.toContain('opacity-0') }) it('uses a three-column image grid once four or more task images are present', () => { const segment = taskSegment() segment.content.images = [ ...(segment.content.images ?? []), { id: 'c', source_type: 'input', file_path: 'c.png', file_name: 'c.png' }, { id: 'd', source_type: 'input', file_path: 'd.png', file_name: 'd.png' }, ] render() expect(screen.getByTestId('task-image-grid').className).toContain('grid-cols-3') }) it('uses locale messages for task mode and prompt labels', () => { render( , ) expect(screen.getByRole('combobox').textContent).toContain('首尾生视频') expect(screen.getByRole('textbox', { name: '提示词' })).not.toBeNull() expect(screen.getByLabelText('任务图片拖放区域')).not.toBeNull() }) it('opens an @ resource menu with task images and populated media tracks', () => { const onContentChange = vi.fn() const audio = videoSegment(0, 3) audio.id = 'audio-segment' audio.content = { media_type: 'audio', file_path: 'voice.wav' } render( , ) const prompt = screen.getByRole('textbox', { name: 'Prompt' }) inputEditable(prompt, '@') const menu = screen.getByRole('listbox', { name: 'Reference resources' }) expect(menu.parentElement?.classList.contains('comfyui-easy-media')).toBe(true) expect(menu.className).toContain('bg-popover') expect(menu.textContent).toContain('Picture 1') expect(menu.textContent).toContain('Picture 2') expect(menu.textContent).toContain('Audio 1') expect(menu.textContent).toContain('Audio 2') expect(menu.textContent).toContain('Video 1') expect(menu.textContent).not.toContain('Empty video track') expect(screen.getByRole('option', { name: /Audio 1 Source video track/ })).not.toBeNull() expect(screen.getByRole('option', { name: /Audio 2 Voice track/ })).not.toBeNull() expect(screen.getByRole('option', { name: /Picture 1/ }).getAttribute('aria-selected')).toBe('true') fireEvent.keyDown(prompt, { key: 'Enter' }) expect(onContentChange).toHaveBeenLastCalledWith({ user_prompt: '@Picture 1' }) expect(screen.queryByRole('listbox', { name: 'Reference resources' })).toBeNull() const chip = prompt.querySelector('[data-prompt-reference-token="@Picture 1"]') expect(chip?.querySelector('img')?.getAttribute('src')).toContain('a.png') expect((chip as HTMLElement | null)?.style.color).toBe('var(--multitrack-task-bg)') }) it('shows the interactive MiniMax placeholder only for an empty prompt', () => { const onContentChange = vi.fn() const segment = taskSegment() segment.content.user_prompt = '' const { rerender } = render( , ) const prompt = screen.getByRole('textbox', { name: '提示词' }) expect(prompt.getAttribute('data-placeholder')).toBe('参考模式下最多支持9张图片,3个音频,3个视频, 输入文字或 @ 参考内容, 自由组合图、文、音,视频多元素,定义精彩互动。例如 @图片1 模仿 @视频1 的动作,音色参考 @音频1。') expect(prompt.textContent).toBe('') const placeholderTrigger = screen.getByRole('button', { name: '打开引用资源' }) expect(placeholderTrigger.className).toContain('prompt-reference-chip') fireEvent.click(placeholderTrigger) fireEvent.click(screen.getByRole('option', { name: /图片1/ })) expect(onContentChange).toHaveBeenLastCalledWith({ user_prompt: '@图片1' }) rerender( , ) expect(screen.getByRole('textbox', { name: '提示词' }).getAttribute('data-placeholder')).toBe('双击描述您想要生成的内容...') expect(screen.queryByRole('button', { name: '打开引用资源' })).toBeNull() }) it('restores the MiniMax placeholder immediately after deleting back to empty', () => { const onContentChange = vi.fn() render() const prompt = screen.getByRole('textbox', { name: 'Prompt' }) prompt.innerHTML = '
' fireEvent.input(prompt) expect(onContentChange).toHaveBeenLastCalledWith({ user_prompt: '' }) expect(prompt.textContent).toBe('') expect(screen.getByRole('button', { name: 'Open reference resources' })).not.toBeNull() }) it('does not open the resource menu when clicking prompt text that already contains @', () => { const segment = taskSegment() segment.content.user_prompt = 'Keep this @ character' render() const prompt = screen.getByRole('textbox', { name: 'Prompt' }) fireEvent.focus(prompt) fireEvent.click(prompt) expect(screen.queryByRole('listbox', { name: 'Reference resources' })).toBeNull() }) it('centers the empty reference state below its header', () => { const segment = taskSegment() segment.content.images = [] render() const prompt = screen.getByRole('textbox', { name: 'Prompt' }) inputEditable(prompt, '@') const menu = screen.getByRole('listbox', { name: 'Reference resources' }) const emptyState = screen.getByTestId('reference-resources-empty') expect(menu.className).toContain('h-64') expect(emptyState.className).toContain('flex-1') expect(emptyState.className).toContain('items-center') expect(emptyState.className).toContain('justify-center') expect(emptyState.className).toContain('text-center') expect(emptyState.textContent).toContain('Add task images or media clips to a track first.') }) it('highlights official media tags with thumbnails, Lucide icons, and track colors', () => { const segment = taskSegment() segment.content.user_prompt = '