import { render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { RollEditHandle } from "../../../components/edit-tools/roll-edit-handle"
import { TrackRollHandles } from "../../../components/track/track-roll-handles"
import { useEditModeContext } from "../../../hooks/use-edit-mode"
import { TimelineClip, TimelineTrack, TrackType } from "../../../types"
import { EDIT_MODES } from "../../../types/edit-modes"
const mockRollEditHandle = vi.mocked(RollEditHandle)
// Mock dependencies
vi.mock("../../../hooks/use-edit-mode", () => ({
useEditModeContext: vi.fn(),
}))
vi.mock("../../../components/edit-tools/roll-edit-handle", () => ({
RollEditHandle: vi.fn(({ leftClip, rightClip, onRollStart }) => (
onRollStart?.(100)}>
Roll Handle {leftClip.id}-{rightClip.id}
)),
}))
// Test data factories
const createClip = (overrides: Partial = {}): TimelineClip => ({
id: "clip-1",
name: "Test Clip",
mediaId: "media-1",
mediaFile: {
id: "media-1",
name: "test-video.mp4",
path: "/path/to/test-video.mp4",
duration: 100,
size: 1000000,
isVideo: true,
createdAt: new Date().toISOString(),
},
trackId: "track-1",
startTime: 10,
duration: 20,
mediaStartTime: 5,
mediaEndTime: 25,
offset: 0,
volume: 1,
speed: 1,
isReversed: false,
opacity: 1,
effects: [],
filters: [],
transitions: [],
isSelected: false,
isLocked: false,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
const createTrack = (overrides: Partial = {}): TimelineTrack => ({
id: "track-1",
name: "Test Track",
type: "video" as TrackType,
clips: [],
isHidden: false,
isMuted: false,
volume: 1,
order: 0,
height: 80,
color: "#3b82f6",
trackEffects: [],
trackFilters: [],
isLocked: false,
isSolo: false,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
})
const mockEditModeContext = {
editMode: EDIT_MODES.ROLL,
setEditMode: vi.fn(),
isEditMode: vi.fn(),
exitEditMode: vi.fn(),
config: undefined,
cursor: "default",
}
describe("TrackRollHandles", () => {
beforeEach(() => {
vi.resetAllMocks()
mockRollEditHandle.mockClear()
vi.mocked(useEditModeContext).mockReturnValue(mockEditModeContext)
})
describe("rendering behavior", () => {
it("должен рендерить roll handles для смежных клипов в roll режиме", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
render()
expect(screen.getByTestId("roll-handle-clip-1-clip-2")).toBeInTheDocument()
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
leftClip: clip1,
rightClip: clip2,
isHovered: true,
isActive: false,
timeScale: 10,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("не должен рендерить handles не в roll режиме", () => {
vi.mocked(useEditModeContext).mockReturnValue({
...mockEditModeContext,
editMode: EDIT_MODES.SELECT,
})
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
const { container } = render()
expect(container.firstChild).toBeNull()
expect(mockRollEditHandle).not.toHaveBeenCalled()
})
it("не должен рендерить handles для пустого трека", () => {
const track = createTrack({ clips: [] })
const { container } = render()
expect(container.firstChild).toBeNull()
expect(mockRollEditHandle).not.toHaveBeenCalled()
})
it("не должен рендерить handles для трека с одним клипом", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const track = createTrack({ clips: [clip1] })
const { container } = render()
expect(container.firstChild).toBeNull()
expect(mockRollEditHandle).not.toHaveBeenCalled()
})
it("должен рендерить handles для всех смежных пар в треке", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const clip3 = createClip({ id: "clip-3", startTime: 20, duration: 10 })
const track = createTrack({ clips: [clip1, clip2, clip3] })
render()
expect(screen.getByTestId("roll-handle-clip-1-clip-2")).toBeInTheDocument()
expect(screen.getByTestId("roll-handle-clip-2-clip-3")).toBeInTheDocument()
expect(RollEditHandle).toHaveBeenCalledTimes(2)
})
})
describe("adjacent pairs detection", () => {
it("должен находить смежные клипы (точное касание)", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
render()
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
leftClip: clip1,
rightClip: clip2,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("должен находить смежные клипы с минимальным зазором (в пределах толерантности)", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10.0005, duration: 10 }) // gap = 0.0005
const track = createTrack({ clips: [clip1, clip2] })
render()
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
leftClip: clip1,
rightClip: clip2,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("не должен находить несмежные клипы (большой зазор)", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 15, duration: 10 }) // gap = 5
const track = createTrack({ clips: [clip1, clip2] })
const { container } = render()
expect(container.firstChild).toBeNull()
expect(mockRollEditHandle).not.toHaveBeenCalled()
})
it("должен обрабатывать перекрывающиеся клипы (отрицательный зазор)", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 9.9995, duration: 10 }) // minimal gap = 0.0005
const track = createTrack({ clips: [clip1, clip2] })
render()
expect(RollEditHandle).toHaveBeenCalledTimes(1)
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
leftClip: clip1,
rightClip: clip2,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("должен правильно сортировать клипы по времени", () => {
// Клипы в неправильном порядке в массиве
const clip1 = createClip({ id: "clip-1", startTime: 20, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 0, duration: 10 })
const clip3 = createClip({ id: "clip-3", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2, clip3] })
render()
expect(RollEditHandle).toHaveBeenCalledTimes(2)
// Должны быть созданы handles в правильном порядке: clip-2->clip-3, clip-3->clip-1
expect(screen.getByTestId("roll-handle-clip-2-clip-3")).toBeInTheDocument()
expect(screen.getByTestId("roll-handle-clip-3-clip-1")).toBeInTheDocument()
})
it("должен обрабатывать одинаковые времена начала", () => {
const clip1 = createClip({ id: "clip-1", startTime: 10, duration: 5 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
const { container } = render()
// Клипы начинаются в одно время, поэтому они не смежные в обычном понимании
expect(container.firstChild).toBeNull()
})
})
describe("onRollStart callback", () => {
it("должен вызывать onRollStart с правильными параметрами", () => {
const onRollStart = vi.fn()
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
render()
// Simulate click on roll handle
screen.getByTestId("roll-handle-clip-1-clip-2").click()
expect(onRollStart).toHaveBeenCalledWith("clip-1", "clip-2", 100)
})
it("должен работать без onRollStart callback", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
expect(() => {
render()
}).not.toThrow()
// Should not throw when clicking
expect(() => {
screen.getByTestId("roll-handle-clip-1-clip-2").click()
}).not.toThrow()
})
it("должен передавать callback для каждого handle отдельно", () => {
const onRollStart = vi.fn()
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const clip3 = createClip({ id: "clip-3", startTime: 20, duration: 10 })
const track = createTrack({ clips: [clip1, clip2, clip3] })
render()
screen.getByTestId("roll-handle-clip-1-clip-2").click()
screen.getByTestId("roll-handle-clip-2-clip-3").click()
expect(onRollStart).toHaveBeenCalledTimes(2)
expect(onRollStart).toHaveBeenNthCalledWith(1, "clip-1", "clip-2", 100)
expect(onRollStart).toHaveBeenNthCalledWith(2, "clip-2", "clip-3", 100)
})
})
describe("timeScale prop", () => {
it("должен передавать timeScale в RollEditHandle", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
render()
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
timeScale: 25,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("должен работать с разными значениями timeScale", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
const { rerender } = render()
let mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
timeScale: 10,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
rerender()
mockCall = mockRollEditHandle.mock.calls[1]
expect(mockCall[0]).toMatchObject({
timeScale: 50,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
})
describe("performance and memoization", () => {
it("должен пересчитывать adjacentPairs только при изменении clips", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
const { rerender } = render()
expect(RollEditHandle).toHaveBeenCalledTimes(1)
// Rerender with same clips but different timeScale
rerender()
// adjacentPairs should be memoized, so RollEditHandle called only for re-render
expect(RollEditHandle).toHaveBeenCalledTimes(2)
// Rerender with different clips
const newClips = [
createClip({ id: "clip-3", startTime: 0, duration: 5 }),
createClip({ id: "clip-4", startTime: 5, duration: 5 }),
createClip({ id: "clip-5", startTime: 10, duration: 5 }),
]
const newTrack = createTrack({ clips: newClips })
rerender()
// Should call for 2 new adjacent pairs
expect(RollEditHandle).toHaveBeenCalledTimes(4)
})
it("должен корректно обновлять при добавлении клипов", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const track = createTrack({ clips: [clip1] })
const { rerender, container } = render()
// No handles for single clip
expect(container.firstChild).toBeNull()
// Add adjacent clip
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const updatedTrack = createTrack({ clips: [clip1, clip2] })
rerender()
expect(screen.getByTestId("roll-handle-clip-1-clip-2")).toBeInTheDocument()
})
})
describe("edge cases", () => {
it("должен обрабатывать клипы с нулевой продолжительностью", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 0 })
const clip2 = createClip({ id: "clip-2", startTime: 0, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
render()
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
leftClip: clip1,
rightClip: clip2,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("должен обрабатывать отрицательные времена начала", () => {
const clip1 = createClip({ id: "clip-1", startTime: -5, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 5, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
render()
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
leftClip: clip1,
rightClip: clip2,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("должен обрабатывать очень большие значения времени", () => {
const clip1 = createClip({ id: "clip-1", startTime: 1000000, duration: 1000 })
const clip2 = createClip({ id: "clip-2", startTime: 1001000, duration: 1000 })
const track = createTrack({ clips: [clip1, clip2] })
render()
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
leftClip: clip1,
rightClip: clip2,
timeScale: 0.01,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("должен обрабатывать дублирующиеся ID клипов", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-1", startTime: 10, duration: 10 }) // Same ID
const track = createTrack({ clips: [clip1, clip2] })
expect(() => {
render()
}).not.toThrow()
// Should still render handle (React key might be problematic, but component should work)
expect(RollEditHandle).toHaveBeenCalled()
})
it("должен обрабатывать дробные значения времени", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0.333, duration: 10.666 })
const clip2 = createClip({ id: "clip-2", startTime: 10.999, duration: 10.001 })
const track = createTrack({ clips: [clip1, clip2] })
render()
const mockCall = mockRollEditHandle.mock.calls[0]
expect(mockCall[0]).toMatchObject({
leftClip: clip1,
rightClip: clip2,
})
expect(typeof mockCall[0].onRollStart).toBe("function")
})
it("должен обрабатывать множественные режимы редактирования", () => {
const clip1 = createClip({ id: "clip-1", startTime: 0, duration: 10 })
const clip2 = createClip({ id: "clip-2", startTime: 10, duration: 10 })
const track = createTrack({ clips: [clip1, clip2] })
const editModes = [EDIT_MODES.SELECT, EDIT_MODES.TRIM, EDIT_MODES.RIPPLE, EDIT_MODES.SLIP]
editModes.forEach((mode) => {
vi.mocked(useEditModeContext).mockReturnValue({
...mockEditModeContext,
editMode: mode,
})
const { container } = render()
if (mode === EDIT_MODES.ROLL) {
expect(screen.getByTestId("roll-handle-clip-1-clip-2")).toBeInTheDocument()
} else {
expect(container.firstChild).toBeNull()
}
})
})
})
})