Moxie-Multimedia / frontend /src /tests /TaskSegmentEditor.test.tsx
turtle89431's picture
Upload folder using huggingface_hub
cbbe5c7 verified
Raw History Blame Contribute Delete
62.8 kB
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
}) => (
<div
data-testid="media-selector-mock"
data-value={props.value}
data-multiple={String(props.allowMultipleSelection)}
data-limit={String(props.maxSelectionCount)}
data-slot-items={props.slotItems?.map((item) => item.value).join(',') ?? ''}
>
<button type="button" onClick={() => props.onChange('replacement.png', 'input')}>
choose replacement
</button>
</div>
),
}))
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={onContentChange} />)
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(
<TaskSegmentEditor
segment={second}
trackSegments={[first, second]}
format="MiniMax"
onContentChange={onContentChange}
/>,
)
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(
<TaskSegmentEditor
segment={first}
trackSegments={[first, second]}
format="MiniMax"
onContentChange={onContentChange}
/>,
)
expect(screen.queryByRole('combobox', { name: 'Continuity mode' })).toBeNull()
rerender(
<TaskSegmentEditor
segment={second}
trackSegments={[first, second]}
format="Seedance"
onContentChange={onContentChange}
/>,
)
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(
<TaskSegmentEditor
segment={referenceSegment}
format="MiniMax"
onContentChange={onContentChange}
/>,
)
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(
<TaskSegmentEditor
segment={taskSegment()}
format="MiniMax"
onContentChange={onContentChange}
/>,
)
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(
<TaskSegmentEditor
segment={first}
trackSegments={[first, second]}
selectedSegments={[first, second]}
format="MiniMax"
onContentChange={onContentChange}
onTrackSegmentsContentChange={onTrackSegmentsContentChange}
/>,
)
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(
<TooltipProvider delayDuration={0}>
<TaskSegmentEditor segment={initialSegment} onContentChange={onContentChange} />
</TooltipProvider>,
)
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(
<TooltipProvider delayDuration={0}>
<TaskSegmentEditor segment={bSegment} onContentChange={onContentChange} />
</TooltipProvider>,
)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={onContentChange} />)
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(<TaskSegmentEditor segment={segment} onContentChange={vi.fn()} />)
expect(screen.getByRole('combobox', { name: 'Task mode' }).textContent).toContain(expectedLabel)
})
it('keeps task prompt wheel scrolling inside the editor', () => {
const onCanvasWheel = vi.fn()
render(
<div
onWheelCapture={(event) => {
const target = event.target as HTMLElement
const captureElement = target.closest('[data-capture-wheel="true"]')
if (!captureElement?.contains(document.activeElement)) onCanvasWheel()
}}
>
<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />
</div>,
)
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(
<TaskSegmentEditor
segment={taskSegment()}
videoSegments={[videoSegment(2, 5)]}
onContentChange={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('combobox'))
expect(screen.getByRole('option', { name: 'RV2V' })).not.toBeNull()
})
it('uses rv2v for reference mode with preset video', () => {
render(
<TaskSegmentEditor
segment={taskSegment()}
videoSegments={[videoSegment(2, 5, 'preset')]}
onContentChange={vi.fn()}
/>,
)
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(
<TaskSegmentEditor
segment={taskSegment()}
videoSegments={[video]}
onContentChange={vi.fn()}
/>,
)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={onContentChange} />)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />)
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(
<TaskSegmentEditor
segment={taskSegment()}
onContentChange={onContentChange}
onOpenImagePreview={onOpenImagePreview}
/>,
)
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(<TaskSegmentEditor segment={segment} onContentChange={vi.fn()} />)
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(
<TaskSegmentEditor
segment={first}
trackSegments={[first, second]}
onContentChange={vi.fn()}
onTrackSegmentsContentChange={onTrackSegmentsContentChange}
/>,
)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={onContentChange} />)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />)
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(
<TaskSegmentEditor
segment={taskSegment()}
imageIndexOffset={1}
onContentChange={vi.fn()}
/>,
)
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(
<TaskSegmentEditor
segment={first}
trackSegments={[first, second]}
onContentChange={vi.fn()}
onTrackSegmentsContentChange={onTrackSegmentsContentChange}
/>,
)
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(
<TooltipProvider>
<TaskSegmentEditor
segment={segment}
imageIndexOffset={1}
onContentChange={onContentChange}
/>
</TooltipProvider>,
)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />)
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(<TaskSegmentEditor segment={segment} onContentChange={vi.fn()} />)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={onContentChange} />)
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(<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />)
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(<TaskSegmentEditor segment={segment} onContentChange={vi.fn()} />)
expect(screen.getByTestId('task-image-grid').className).toContain('grid-cols-3')
})
it('uses locale messages for task mode and prompt labels', () => {
render(
<LocaleContext.Provider value="zh">
<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />
</LocaleContext.Provider>,
)
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(
<TaskSegmentEditor
segment={taskSegment()}
mediaTracks={[
mediaTrack('audio', [audio]),
mediaTrack('video', [videoSegment(0, 3)]),
{ ...mediaTrack('video', []), id: 'empty-video-track', name: 'Empty video track' },
]}
onContentChange={onContentChange}
/>,
)
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(
<LocaleContext.Provider value="zh">
<TaskSegmentEditor segment={segment} format="MiniMax" onContentChange={onContentChange} />
</LocaleContext.Provider>,
)
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(
<LocaleContext.Provider value="zh">
<TaskSegmentEditor segment={segment} format="Seedance" onContentChange={onContentChange} />
</LocaleContext.Provider>,
)
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(<TaskSegmentEditor segment={taskSegment()} format="MiniMax" onContentChange={onContentChange} />)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
prompt.innerHTML = '<br>'
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(<TaskSegmentEditor segment={segment} onContentChange={vi.fn()} />)
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(<TaskSegmentEditor segment={segment} mediaTracks={[]} onContentChange={vi.fn()} />)
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 = '<Picture 1> <Audio 1> <Audio 2> <Video 1>'
const audio = videoSegment(0, 3)
audio.id = 'audio-segment'
audio.content = { media_type: 'audio', file_path: 'voice.wav' }
render(
<TaskSegmentEditor
segment={segment}
mediaTracks={[
mediaTrack('audio', [audio]),
mediaTrack('video', [videoSegment(0, 3)]),
]}
onContentChange={vi.fn()}
/>,
)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
const chips = prompt.querySelectorAll('[data-prompt-reference-token]')
expect(chips).toHaveLength(4)
expect(chips[0].querySelector('img')?.getAttribute('src')).toContain('a.png')
expect(chips[1].querySelector('[data-reference-icon="audio"] svg.lucide')).not.toBeNull()
expect(chips[2].querySelector('[data-reference-icon="audio"] svg.lucide')).not.toBeNull()
expect(chips[3].querySelector('[data-reference-icon="video"] svg.lucide')).not.toBeNull()
expect((chips[0] as HTMLElement).className).toContain('align-middle')
expect((chips[0] as HTMLElement).className).toContain('items-center')
expect((chips[0] as HTMLElement).className).toContain('leading-none')
expect((chips[0] as HTMLElement).className).toContain('rounded-md')
expect((chips[0] as HTMLElement).className).toContain('cursor-pointer')
expect(chips[0].querySelector('span:last-child')?.className).toContain('items-center')
expect((chips[0] as HTMLElement).style.borderColor).toBe('var(--multitrack-task-bg)')
expect((chips[0] as HTMLElement).style.backgroundColor).toContain('color-mix')
expect((chips[1] as HTMLElement).style.color).toBe('var(--multitrack-video-waveform)')
expect((chips[1] as HTMLElement).style.borderColor).toBe('var(--multitrack-video-waveform)')
expect((chips[2] as HTMLElement).style.color).toBe('var(--multitrack-audio-waveform)')
expect((chips[2] as HTMLElement).style.borderColor).toBe('var(--multitrack-audio-waveform)')
expect((chips[3] as HTMLElement).style.color).toBe('var(--multitrack-video-waveform)')
expect((chips[3] as HTMLElement).style.borderColor).toBe('var(--multitrack-video-waveform)')
})
it('renders H3 tags as typed chips and emphasizes dialogue text and section headings', () => {
const segment = taskSegment()
segment.content.user_prompt = '<Subject 12> (S1,S2) says <d>[Chinese] 你好</d> [English] [Shot 3] <scenetrans> <cutoff> subject_definitions: subject summary: overview retention_analysis: retention detailed_description: detail overall_soundscape: sound non_diegetic_music: music 主体定义:主体 留存分析:结果 详细描述:细节 整体声景:声音 非叙事性音乐:安静 [reference generation]'
render(<TaskSegmentEditor segment={segment} onContentChange={vi.fn()} />)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
const semantics = prompt.querySelectorAll('[data-prompt-semantic-token]')
expect(Array.from(semantics, (item) => item.textContent)).toEqual([
'<Subject 12>',
'(S1,S2)',
'<d>',
'[Chinese]',
'</d>',
'[English]',
'[Shot 3]',
'<scenetrans>',
'<cutoff>',
])
expect(Array.from(semantics).every((item) => item.className.includes('prompt-reference-chip'))).toBe(true)
expect((semantics[0] as HTMLElement).style.color).toBe('var(--highlight)')
expect(semantics[0].querySelector('[data-semantic-icon="subject"] svg.lucide')).not.toBeNull()
expect((semantics[1] as HTMLElement).style.color).toBe('var(--prompt-semantic)')
expect((semantics[2] as HTMLElement).style.color).toBe('var(--prompt-dialogue)')
expect(semantics[2].querySelector('[data-semantic-icon="dialogue"] svg.lucide')).not.toBeNull()
expect((semantics[3] as HTMLElement).style.color).toBe('')
expect(semantics[3].className).toContain('border-border')
expect(semantics[3].className).toContain('bg-background')
expect(semantics[3].className).toContain('text-foreground')
expect((semantics[3] as HTMLElement).style.borderColor).toBe('')
expect((semantics[5] as HTMLElement).style.color).toBe('')
expect(semantics[5].className).toContain('border-border')
expect(semantics[5].className).toContain('bg-background')
expect((semantics[4] as HTMLElement).style.color).toBe('var(--prompt-dialogue)')
expect((semantics[6] as HTMLElement).style.color).toBe('')
expect(semantics[6].className).toContain('border-border')
expect(semantics[6].className).toContain('bg-background')
expect(semantics[6].className).toContain('text-foreground')
expect((semantics[6] as HTMLElement).style.borderColor).toBe('')
const dialogue = prompt.querySelector('[data-prompt-dialogue-content]')
expect(dialogue?.className).toContain('font-semibold')
expect(dialogue?.className).toContain('text-muted-foreground')
expect(dialogue?.textContent).toBe(' 你好')
const emphasis = prompt.querySelectorAll('[data-prompt-emphasis-text]')
expect(Array.from(emphasis, (item) => item.textContent)).toEqual([
'subject_definitions: ',
'summary: ',
'retention_analysis: ',
'detailed_description: ',
'overall_soundscape: ',
'non_diegetic_music: ',
'主体定义:',
'留存分析:',
'详细描述:',
'整体声景:',
'非叙事性音乐:',
])
expect(Array.from(emphasis).every((item) => (
item.className.includes('font-semibold') && item.className.includes('text-muted-foreground')
))).toBe(true)
expect(prompt.textContent).toContain('[reference generation]')
})
it('inserts a plain line break after an H3 section heading without duplicating it', () => {
const onContentChange = vi.fn()
const segment = taskSegment()
segment.content.user_prompt = 'non_diegetic_music:'
render(<TaskSegmentEditor segment={segment} onContentChange={onContentChange} />)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
const heading = prompt.querySelector('[data-prompt-emphasis-text]')
expect(heading?.textContent).toBe('non_diegetic_music:')
prompt.focus()
const selection = window.getSelection()
const range = document.createRange()
range.setStartAfter(heading as Node)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
fireEvent.keyDown(prompt, { key: 'Enter' })
expect(onContentChange).toHaveBeenLastCalledWith({
user_prompt: 'non_diegetic_music:\n',
})
expect(prompt.querySelectorAll('[data-prompt-emphasis-text]')).toHaveLength(1)
expect(prompt.textContent?.match(/non_diegetic_music:/g)).toHaveLength(1)
})
it('does not restore a stale H3 heading after replacing the full prompt', () => {
const onContentChange = vi.fn()
const segment = taskSegment()
segment.content.user_prompt = 'subject_definitions: old subject'
render(<TaskSegmentEditor segment={segment} onContentChange={onContentChange} />)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
const staleHeading = prompt.querySelector('[data-prompt-emphasis-text]') as HTMLElement
staleHeading.textContent = ''
while (staleHeading.nextSibling) staleHeading.nextSibling.remove()
prompt.append(document.createTextNode('subject_definitions: new subject'))
fireEvent.input(prompt)
expect(onContentChange).toHaveBeenLastCalledWith({
user_prompt: 'subject_definitions: new subject',
})
expect(prompt.textContent?.match(/subject_definitions:/g)).toHaveLength(1)
})
it('opens the reference picker from media chips and preserves their token syntax when replacing them', () => {
const onContentChange = vi.fn()
const segment = taskSegment()
segment.content.user_prompt = '@Picture 1 then <Picture 2>'
const audio = videoSegment(0, 3)
audio.id = 'audio-segment'
audio.content = { media_type: 'audio', file_path: 'voice.wav' }
render(
<TaskSegmentEditor
segment={segment}
mediaTracks={[
mediaTrack('audio', [audio]),
mediaTrack('video', [videoSegment(0, 3)]),
]}
onContentChange={onContentChange}
/>,
)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
fireEvent.click(prompt.querySelector('[data-prompt-reference-token="@Picture 1"]') as HTMLElement)
fireEvent.click(screen.getByRole('option', { name: /Video 1 Source video track/ }))
expect(onContentChange).toHaveBeenLastCalledWith({ user_prompt: '@Video 1 then <Picture 2>' })
fireEvent.click(prompt.querySelector('[data-prompt-reference-token="<Picture 2>"]') as HTMLElement)
fireEvent.click(screen.getByRole('option', { name: /Audio 2 Voice track/ }))
expect(onContentChange).toHaveBeenLastCalledWith({ user_prompt: '@Video 1 then <Audio 2>' })
})
it('keeps reference tokens stable when typing adjacent text', () => {
const onContentChange = vi.fn()
const segment = taskSegment()
segment.content.user_prompt = '@Picture 1'
render(<TaskSegmentEditor segment={segment} onContentChange={onContentChange} />)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
const chip = prompt.querySelector('[data-prompt-reference-token="@Picture 1"]')
expect(chip).not.toBeNull()
chip?.after(document.createTextNode(' moves forward'))
fireEvent.input(prompt)
expect(onContentChange).toHaveBeenLastCalledWith({ user_prompt: '@Picture 1 moves forward' })
expect(prompt.querySelector('[data-prompt-reference-token="@Picture 1"]')).toBe(chip)
})
it('pastes plain text into the prompt without bubbling to the ComfyUI canvas', () => {
const onContentChange = vi.fn()
const onCanvasPaste = vi.fn()
const segment = taskSegment()
segment.content.user_prompt = ''
render(
<div onPaste={onCanvasPaste}>
<TaskSegmentEditor segment={segment} onContentChange={onContentChange} />
</div>,
)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
prompt.focus()
const range = document.createRange()
range.selectNodeContents(prompt)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
fireEvent.paste(prompt, {
clipboardData: {
getData: (type: string) => type === 'text/plain' ? '<Picture 1> moves' : '',
},
})
expect(onCanvasPaste).not.toHaveBeenCalled()
expect(onContentChange).toHaveBeenLastCalledWith({ user_prompt: '<Picture 1> moves' })
expect(prompt.querySelector('[data-prompt-reference-token="<Picture 1>"]')).not.toBeNull()
})
it('copies newly inserted reference chips as their prompt tag text', () => {
render(<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
inputEditable(prompt, '@')
fireEvent.keyDown(prompt, { key: 'Enter' })
expect(prompt.querySelector('[data-prompt-reference-token="@Picture 1"]')).not.toBeNull()
const selection = window.getSelection()
const selectAll = document.createRange()
selectAll.selectNodeContents(prompt)
selection?.removeAllRanges()
selection?.addRange(selectAll)
const setData = vi.fn()
fireEvent.copy(prompt, {
clipboardData: { setData },
})
expect(setData).toHaveBeenCalledWith('text/plain', '@Picture 1')
})
it('deletes a reference chip as one atomic value with Backspace', () => {
const onContentChange = vi.fn()
const segment = taskSegment()
segment.content.user_prompt = 'Before <Picture 1> after'
render(<TaskSegmentEditor segment={segment} onContentChange={onContentChange} />)
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
const chip = prompt.querySelector('[data-prompt-reference-token="<Picture 1>"]')
expect(chip).not.toBeNull()
prompt.focus()
const selection = window.getSelection()
const range = document.createRange()
range.setStartAfter(chip!)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
fireEvent.keyDown(prompt, { key: 'Backspace' })
expect(onContentChange).toHaveBeenLastCalledWith({ user_prompt: 'Before after' })
expect(prompt.querySelector('[data-prompt-reference-token]')).toBeNull()
})
it('loads system prompt options once and matches the current task state locally', async () => {
const onContentChange = vi.fn()
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
items: [
{ task_type: 't2v', system_prompt: 'Default text-to-video template' },
{ task_type: 'i2v', system_prompt: 'Default image-to-video template' },
{ task_type: 'r2v', system_prompt: 'Default reference-to-video template' },
{ task_type: 'rv2v', system_prompt: 'Default reference-edit template' },
{ modes: ['default', 'l2v'], format: 'MiniMax', system_prompt: 'MiniMax base template' },
{ modes: ['ref', 'edit'], format: 'MiniMax', system_prompt: 'MiniMax reference template' },
],
}),
})
vi.stubGlobal('fetch', fetchMock)
const { rerender, unmount } = render(<TaskSegmentEditor segment={taskSegment()} onContentChange={onContentChange} />)
fireEvent.click(screen.getByRole('button', { name: 'System Prompt' }))
const systemPrompt = await screen.findByRole('textbox', { name: 'System Prompt' }) as HTMLElement
await waitFor(() => {
expect(systemPrompt.textContent).toBe('Default image-to-video template')
})
const resetSegment = {
...taskSegment(),
content: {
...taskSegment().content,
system_prompt: '',
},
}
rerender(<TaskSegmentEditor segment={resetSegment} onContentChange={onContentChange} />)
expect(systemPrompt.textContent).toBe('Default image-to-video template')
const refSegment = {
...taskSegment(),
content: {
...taskSegment().content,
task_mode: 'ref' as const,
},
}
rerender(<TaskSegmentEditor segment={refSegment} onContentChange={onContentChange} />)
await waitFor(() => {
expect(systemPrompt.textContent).toBe('Default reference-to-video template')
})
rerender(
<TaskSegmentEditor
segment={refSegment}
videoSegments={[videoSegment(2, 5)]}
onContentChange={onContentChange}
/>,
)
await waitFor(() => {
expect(systemPrompt.textContent).toBe('Default reference-edit template')
})
rerender(<TaskSegmentEditor segment={resetSegment} format="MiniMax" onContentChange={onContentChange} />)
await waitFor(() => {
expect(systemPrompt.textContent).toBe('MiniMax base template')
})
rerender(<TaskSegmentEditor segment={refSegment} format="MiniMax" onContentChange={onContentChange} />)
await waitFor(() => {
expect(systemPrompt.textContent).toBe('MiniMax reference template')
})
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith('/easy-media/prompt/system-prompts')
unmount()
render(<TaskSegmentEditor segment={refSegment} onContentChange={onContentChange} />)
fireEvent.click(screen.getByRole('button', { name: 'System Prompt' }))
const remountedSystemPrompt = await screen.findByRole('textbox', { name: 'System Prompt' }) as HTMLElement
expect(remountedSystemPrompt.textContent).toBe('Default reference-to-video template')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('edits a customized system prompt and highlights complete placeholders', () => {
const onContentChange = vi.fn()
const customizedSegment = {
...taskSegment(),
content: {
...taskSegment().content,
system_prompt: 'Create {subject} in <style> with <Picture 1> and @图片1',
},
}
const { container } = render(
<TooltipProvider>
<TaskSegmentEditor segment={customizedSegment} onContentChange={onContentChange} />
</TooltipProvider>,
)
fireEvent.click(screen.getByRole('button', { name: 'System Prompt' }))
const textarea = screen.getByRole('textbox', { name: 'System Prompt' }) as HTMLElement
const highlightedVariables = container.querySelectorAll('[data-system-prompt-variable="true"]')
expect(textarea.getAttribute('contenteditable')).toBe('true')
expect(textarea.classList.contains('text-transparent')).toBe(false)
expect(textarea.classList.contains('caret-foreground')).toBe(true)
expect(Array.from(highlightedVariables, (item) => item.textContent)).toEqual(['{subject}', '<style>', '<Picture 1>'])
expect(textarea.querySelectorAll('[data-prompt-reference-token]')).toHaveLength(0)
expect(textarea.textContent).toBe('Create {subject} in <style> with <Picture 1> and @图片1')
inputEditable(textarea, 'Create {character} in <location>')
expect(onContentChange).toHaveBeenCalledWith({ system_prompt: 'Create {character} in <location>' })
})
it('keeps the caret position when completing a highlighted tag after a line break', () => {
const onContentChange = vi.fn()
const segment = taskSegment()
segment.content.system_prompt = 'first\n<style'
render(
<TooltipProvider>
<TaskSegmentEditor segment={segment} onContentChange={onContentChange} />
</TooltipProvider>,
)
fireEvent.click(screen.getByRole('button', { name: 'System Prompt' }))
const editor = screen.getByRole('textbox', { name: 'System Prompt' })
const secondLine = editor.childNodes[2]
expect(secondLine.nodeType).toBe(Node.TEXT_NODE)
const textNode = secondLine as Text
textNode.insertData(textNode.length, '>')
const range = document.createRange()
range.setStart(textNode, textNode.length)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
fireEvent.input(editor)
expect(onContentChange).toHaveBeenLastCalledWith({ system_prompt: 'first\n<style>' })
const highlighted = editor.querySelector('[data-system-prompt-variable="true"]')
expect(highlighted?.textContent).toBe('<style>')
expect(selection?.anchorNode).toBe(highlighted?.firstChild)
expect(selection?.anchorOffset).toBe('<style>'.length)
})
it('resets a customized system prompt with an icon button and tooltip', async () => {
const onContentChange = vi.fn()
const customizedSegment = {
...taskSegment(),
content: {
...taskSegment().content,
system_prompt: 'Customized system prompt',
},
}
render(
<TooltipProvider>
<TaskSegmentEditor segment={customizedSegment} onContentChange={onContentChange} />
</TooltipProvider>,
)
fireEvent.click(screen.getByRole('button', { name: 'System Prompt' }))
const resetButton = screen.getByRole('button', { name: 'Reset system prompt' })
expect(resetButton.textContent).toBe('')
expect(resetButton.querySelector('svg.lucide-rotate-ccw')).not.toBeNull()
fireEvent.focus(resetButton)
const tooltip = await screen.findByRole('tooltip')
expect(tooltip.textContent).toBe('Reset system prompt')
fireEvent.click(resetButton)
expect(onContentChange).toHaveBeenCalledWith({ system_prompt: '' })
})
it('does not show the system prompt reset button for a default system prompt', () => {
render(<TaskSegmentEditor segment={taskSegment()} onContentChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'System Prompt' }))
expect(screen.queryByRole('button', { name: 'Reset system prompt' })).toBeNull()
})
it('edits all task prompts in combined mode and hides image selection', () => {
const onTrackSegmentsContentChange = vi.fn()
render(
<TaskSegmentEditor
segment={taskSegment()}
trackSegments={[taskSegment(), secondTaskSegment()]}
onContentChange={vi.fn()}
onTrackSegmentsContentChange={onTrackSegmentsContentChange}
/>,
)
activateTab('Combined')
expect(screen.queryByLabelText('Task image drop zone')).toBeNull()
expect(screen.queryByRole('button', { name: 'User Prompt' })).toBeNull()
expect(screen.queryByRole('button', { name: 'System Prompt' })).toBeNull()
expect(screen.getByText('Use | to separate segments. Changes apply in real-time.')).not.toBeNull()
inputEditable(screen.getByRole('textbox', { name: 'Prompt' }), 'First rewritten|Second rewritten')
expect(onTrackSegmentsContentChange).toHaveBeenCalledWith([
{ segmentId: 'task-segment', patch: { user_prompt: 'First rewritten' } },
{ segmentId: 'task-segment-2', patch: { user_prompt: 'Second rewritten' } },
])
})
it('reads and writes each task selected A/B prompt in combined mode', () => {
const onTrackSegmentsContentChange = vi.fn()
const first = taskSegment()
const second = secondTaskSegment()
second.content.user_prompt_b = 'Selected B prompt'
second.content.user_prompt_variant = 'b'
render(
<TaskSegmentEditor
segment={first}
trackSegments={[first, second]}
onContentChange={vi.fn()}
onTrackSegmentsContentChange={onTrackSegmentsContentChange}
/>,
)
activateTab('Combined')
const prompt = screen.getByRole('textbox', { name: 'Prompt' })
expect(prompt.textContent).toBe('Initial prompt|Selected B prompt')
inputEditable(prompt, 'Updated A|Updated B')
expect(onTrackSegmentsContentChange).toHaveBeenCalledWith([
{ segmentId: 'task-segment', patch: { user_prompt: 'Updated A' } },
{ segmentId: 'task-segment-2', patch: { user_prompt_b: 'Updated B' } },
])
})
it('creates and evenly distributes task segments when combined pasted text has more parts', () => {
const onTrackSegmentsChange = vi.fn()
render(
<TaskSegmentEditor
segment={taskSegment()}
trackSegments={[taskSegment(), secondTaskSegment()]}
totalFrames={10}
onContentChange={vi.fn()}
onTrackSegmentsChange={onTrackSegmentsChange}
/>,
)
activateTab('Combined')
const prompt = screen.getByRole('textbox', { name: 'Prompt' }) as HTMLElement
inputEditable(prompt, 'First pasted|Second pasted|Third pasted')
expect(prompt.textContent).toBe('First pasted|Second pasted|Third pasted')
const highlight = screen.getByTestId('combined-prompt-highlight')
expect(highlight.querySelectorAll('[data-pipe="true"]')).toHaveLength(2)
expect(highlight.querySelector('[data-pipe="true"]')?.className).toContain('text-highlight')
const updated = onTrackSegmentsChange.mock.lastCall?.[0] as MultiTrackSegment[]
expect(updated).toHaveLength(3)
expect(updated.map(({ start_frame, end_frame, content }) => ({
start_frame,
end_frame,
user_prompt: content.user_prompt,
}))).toEqual([
{ start_frame: 0, end_frame: 4, user_prompt: 'First pasted' },
{ start_frame: 4, end_frame: 7, user_prompt: 'Second pasted' },
{ start_frame: 7, end_frame: 10, user_prompt: 'Third pasted' },
])
expect(updated[0].id).toBe('task-segment')
expect(updated[1].id).toBe('task-segment-2')
expect(updated[2]).toMatchObject({
content: { media_type: 'none', task_mode: 'default', images: [] },
})
inputEditable(prompt, '')
const cleared = onTrackSegmentsChange.mock.lastCall?.[0] as MultiTrackSegment[]
expect(cleared).toHaveLength(1)
expect(cleared[0]).toMatchObject({
id: 'task-segment',
start_frame: 0,
end_frame: 10,
content: { user_prompt: '' },
})
})
it('shows and edits the selected task duration in the center of the footer', () => {
const onDurationChange = vi.fn()
const { rerender } = render(
<TaskSegmentEditor
segment={secondTaskSegment()}
trackSegments={[taskSegment(), secondTaskSegment()]}
frameRate={24}
onContentChange={vi.fn()}
onDurationChange={onDurationChange}
/>,
)
expect(screen.getByText('Segment 2').className).toContain('text-[10px]')
expect(screen.getByText('Segment 2').className).toContain('text-primary')
expect(screen.getByText('00:00:03').className).toContain('text-[10px]')
fireEvent.click(screen.getByRole('button', { name: 'Edit task duration' }))
expect(screen.queryByText('Segment 2')).toBeNull()
expect(screen.queryByText('00:00:03')).toBeNull()
const durationInput = screen.getByRole('textbox', { name: 'Duration' })
expect(durationInput.className).toContain('tabular-nums')
expect(durationInput.getAttribute('placeholder')).toBe('00:00:00')
fireEvent.change(durationInput, { target: { value: '00:01:12' } })
fireEvent.keyDown(durationInput, { key: 'Enter' })
expect(onDurationChange).toHaveBeenCalledWith(1.5)
const updatedSegment = { ...secondTaskSegment(), end_frame: 39 }
rerender(
<TaskSegmentEditor
segment={updatedSegment}
trackSegments={[taskSegment(), updatedSegment]}
frameRate={24}
onContentChange={vi.fn()}
onDurationChange={onDurationChange}
/>,
)
expect(screen.getByText('00:01:12')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Edit task duration' }))
const invalidDurationInput = screen.getByRole('textbox', { name: 'Duration' })
fireEvent.change(invalidDurationInput, { target: { value: '00:61:00' } })
fireEvent.blur(invalidDurationInput)
expect(screen.getByText('00:01:12')).not.toBeNull()
expect(onDurationChange).toHaveBeenCalledTimes(1)
})
it('uses responsive preview editor sizing without rendering the empty image picker as a button element', () => {
const emptyImageSegment = {
...taskSegment(),
content: {
...taskSegment().content,
images: [],
},
}
render(<TaskSegmentEditor segment={emptyImageSegment} onContentChange={vi.fn()} />)
expect(screen.getByTestId('task-image-drop-zone').className).toContain('w-full')
expect(screen.getByRole('button', { name: 'Task image drop zone' }).tagName).not.toBe('BUTTON')
expect(screen.getByRole('textbox', { name: 'Prompt' }).className).toContain('text-[10px]')
})
it('shows connected image inputs in the media selector slot list', () => {
const emptyImageSegment = {
...taskSegment(),
content: {
...taskSegment().content,
images: [],
},
}
const sourceNode = {
outputs: [{ shape: 0 }],
imgs: [{ currentSrc: '/view?filename=reference.png&type=input' }],
}
const node = {
inputs: [{ name: 'image', type: 'IMAGE', link: 7 }],
}
const app = {
graph: {
links: { 7: { origin_id: 3, origin_slot: 0 } },
getNodeById: (id: number) => id === 3 ? sourceNode : null,
},
}
render(
<TaskSegmentEditor
segment={emptyImageSegment}
node={node}
app={app}
onContentChange={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Task image drop zone' }))
expect(screen.getByTestId('media-selector-mock').getAttribute('data-slot-items'))
.toBe('__slot__:image')
})
})