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 = '