import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { ProjectSettingsModal } from "../../components/project-settings-modal"
// Создаем моки функций
const mockUpdateSettings = vi.fn()
const mockResetSettings = vi.fn()
const mockCloseModal = vi.fn()
const mockSettings = {
aspectRatio: {
label: "16:9",
textLabel: "Widescreen",
value: { width: 1920, height: 1080 },
},
resolution: "1920x1080",
frameRate: "30",
colorSpace: "rec709",
}
// Мокируем все зависимости
vi.mock("../../hooks/use-project-settings", () => ({
useProjectSettings: vi.fn(() => ({
settings: mockSettings,
updateSettings: mockUpdateSettings,
resetSettings: mockResetSettings,
})),
}))
vi.mock("@/features/modals/services/modal-provider", () => ({
useModal: vi.fn(() => ({
closeModal: mockCloseModal,
openModal: vi.fn(),
isModalOpen: vi.fn(),
})),
}))
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, params?: any) => {
if (params) {
return `${key} ${JSON.stringify(params)}`
}
return key
},
}),
}))
// Мокируем lucide-react иконки
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
Lock: () =>
Lock
,
Unlock: () => Unlock
,
ChevronDownIcon: () => ChevronDown
,
}
})
// Мокируем типы и константы проекта
vi.mock("../../types/project", () => ({
ASPECT_RATIOS: [
{ label: "16:9", textLabel: "Widescreen", value: { width: 1920, height: 1080 } },
{ label: "4:3", textLabel: "Standard", value: { width: 1440, height: 1080 } },
{ label: "1:1", textLabel: "Square", value: { width: 1080, height: 1080 } },
{ label: "custom", textLabel: "Custom", value: { width: 1920, height: 1080 } },
],
FRAME_RATES: [
{ label: "24 fps", value: "24" },
{ label: "30 fps", value: "30" },
{ label: "60 fps", value: "60" },
],
COLOR_SPACES: [
{ label: "Rec. 709", value: "rec709" },
{ label: "Rec. 2020", value: "rec2020" },
{ label: "sRGB", value: "srgb" },
],
getDefaultResolutionForAspectRatio: vi.fn((aspectRatio) => {
const resolutions: Record = {
"16:9": { value: "1920x1080", width: 1920, height: 1080, label: "1920x1080" },
"4:3": { value: "1440x1080", width: 1440, height: 1080, label: "1440x1080" },
"1:1": { value: "1080x1080", width: 1080, height: 1080, label: "1080x1080" },
custom: { value: "custom", width: 1920, height: 1080, label: "Custom" },
}
return resolutions[aspectRatio] || resolutions["16:9"]
}),
getResolutionsForAspectRatio: vi.fn(() => [
{ value: "1920x1080", width: 1920, height: 1080, label: "1920x1080" },
{ value: "1280x720", width: 1280, height: 720, label: "1280x720" },
{ value: "3840x2160", width: 3840, height: 2160, label: "3840x2160" },
]),
}))
// Мокируем утилиты
vi.mock("../../utils", () => ({
getAspectRatioLabel: vi.fn((textLabel) => textLabel),
getAspectRatioString: vi.fn((width, height) => {
const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b))
const divisor = gcd(width, height)
return `${width / divisor}:${height / divisor}`
}),
}))
// Мокируем window.dispatchEvent
Object.defineProperty(window, "dispatchEvent", {
writable: true,
value: vi.fn(),
})
describe("ProjectSettingsModal", () => {
beforeEach(() => {
vi.clearAllMocks()
// Сбрасываем настройки к дефолтным
mockSettings.aspectRatio = {
label: "16:9",
textLabel: "Widescreen",
value: { width: 1920, height: 1080 },
}
mockSettings.resolution = "1920x1080"
mockSettings.frameRate = "30"
mockSettings.colorSpace = "rec709"
})
describe("Базовый рендеринг", () => {
it("должен рендериться без ошибок", () => {
expect(() => {
render()
}).not.toThrow()
})
it("должен рендерить все основные элементы", () => {
render()
expect(screen.getByText("dialogs.projectSettings.aspectRatio")).toBeInTheDocument()
expect(screen.getByText("dialogs.projectSettings.resolution")).toBeInTheDocument()
expect(screen.getByText("dialogs.projectSettings.customSize")).toBeInTheDocument()
expect(screen.getByText("dialogs.projectSettings.frameRate")).toBeInTheDocument()
expect(screen.getByText("dialogs.projectSettings.colorSpace")).toBeInTheDocument()
expect(screen.getByText("dialogs.projectSettings.cancel")).toBeInTheDocument()
expect(screen.getByText("dialogs.projectSettings.save")).toBeInTheDocument()
})
it("должен показывать текущие настройки", () => {
render()
expect(screen.getByDisplayValue("1920")).toBeInTheDocument()
expect(screen.getByDisplayValue("1080")).toBeInTheDocument()
})
it("должен показывать иконку блокировки по умолчанию", () => {
render()
expect(screen.getAllByTestId("lock-icon")).toHaveLength(2) // Кнопка + информация
})
it("должен отображать правильное количество combobox элементов", () => {
render()
const comboboxes = screen.getAllByRole("combobox")
expect(comboboxes).toHaveLength(4) // аспект, разрешение, fps, цвет
})
it("должен отображать правильное количество input элементов", () => {
render()
const numberInputs = screen.getAllByRole("spinbutton")
expect(numberInputs).toHaveLength(2) // ширина и высота
})
})
describe("Изменение соотношения сторон", () => {
it("должен рендерить select для соотношения сторон", () => {
render()
const comboboxes = screen.getAllByRole("combobox")
expect(comboboxes).toHaveLength(4) // аспект, разрешение, fps, цвет
expect(comboboxes[0]).toBeInTheDocument()
})
it("должен показывать текущее соотношение сторон", () => {
render()
const aspectRatioCombobox = screen.getAllByRole("combobox")[0]
expect(aspectRatioCombobox).toHaveTextContent("16:9")
})
it.skip("должен обрабатывать смену соотношения сторон на custom", () => {
mockSettings.aspectRatio.label = "custom"
render()
expect(screen.getByText("dialogs.projectSettings.aspectRatioLabels.custom")).toBeInTheDocument()
// Восстанавливаем
mockSettings.aspectRatio.label = "16:9"
})
})
describe("Изменение разрешения", () => {
it("должен рендерить select для разрешения", () => {
render()
const resolutionSelect = screen.getAllByRole("combobox")[1]
expect(resolutionSelect).toBeInTheDocument()
})
it("должен показывать текущее разрешение", () => {
render()
const resolutionSelect = screen.getAllByRole("combobox")[1]
expect(resolutionSelect).toHaveTextContent("1920x1080")
})
it.skip("должен показывать custom для пользовательского соотношения", () => {
mockSettings.aspectRatio.label = "custom"
mockSettings.resolution = "custom"
render()
const resolutionSelect = screen.getAllByRole("combobox")[1]
expect(resolutionSelect).toHaveTextContent("dialogs.projectSettings.aspectRatioLabels.custom")
// Восстанавливаем
mockSettings.aspectRatio.label = "16:9"
mockSettings.resolution = "1920x1080"
})
})
describe("Пользовательские размеры", () => {
it("должен изменять ширину", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
fireEvent.change(widthInput, { target: { value: "1280" } })
expect(mockUpdateSettings).toHaveBeenCalled()
})
it("должен изменять высоту", () => {
render()
const heightInput = screen.getByDisplayValue("1080")
fireEvent.change(heightInput, { target: { value: "720" } })
expect(mockUpdateSettings).toHaveBeenCalled()
})
it("должен игнорировать неверные значения ширины", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
fireEvent.change(widthInput, { target: { value: "abc" } })
// При неверном значении функция не должна вызываться
expect(mockUpdateSettings).not.toHaveBeenCalled()
})
it("должен игнорировать отрицательные значения высоты", () => {
render()
const heightInput = screen.getByDisplayValue("1080")
fireEvent.change(heightInput, { target: { value: "-100" } })
expect(mockUpdateSettings).not.toHaveBeenCalled()
})
it("должен игнорировать нулевые значения", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
fireEvent.change(widthInput, { target: { value: "0" } })
expect(mockUpdateSettings).not.toHaveBeenCalled()
})
it("должен пропорционально изменять высоту при заблокированном соотношении", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
fireEvent.change(widthInput, { target: { value: "1280" } })
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
aspectRatio: expect.objectContaining({
value: expect.objectContaining({
width: 1280,
height: expect.any(Number),
}),
}),
}),
)
})
it("должен пропорционально изменять ширину при заблокированном соотношении", () => {
render()
const heightInput = screen.getByDisplayValue("1080")
fireEvent.change(heightInput, { target: { value: "720" } })
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
aspectRatio: expect.objectContaining({
value: expect.objectContaining({
width: expect.any(Number),
height: 720,
}),
}),
}),
)
})
it("должен устанавливать правильные атрибуты min/max", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
const heightInput = screen.getByDisplayValue("1080")
expect(widthInput).toHaveAttribute("min", "320")
expect(widthInput).toHaveAttribute("max", "7680")
expect(heightInput).toHaveAttribute("min", "240")
expect(heightInput).toHaveAttribute("max", "4320")
})
it("должен обрабатывать экстремальные значения", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
// Тестируем минимальное значение
fireEvent.change(widthInput, { target: { value: "320" } })
expect(mockUpdateSettings).toHaveBeenCalled()
vi.clearAllMocks()
// Тестируем максимальное значение
fireEvent.change(widthInput, { target: { value: "7680" } })
expect(mockUpdateSettings).toHaveBeenCalled()
})
})
describe("Блокировка соотношения сторон", () => {
it("должен переключать блокировку соотношения сторон", () => {
render()
// Поскольку кнопка не имеет accessible name, ищем по title
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
fireEvent.click(lockButton)
expect(screen.getAllByTestId("unlock-icon")).toHaveLength(2) // Кнопка + информация
})
it("должен показывать тултип для блокировки", () => {
render()
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
expect(lockButton).toHaveAttribute("title", "dialogs.projectSettings.unlockAspectRatio")
})
it("должен изменять стили кнопки при переключении", () => {
render()
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
// Проверяем что кнопка имеет класс для заблокированного состояния
expect(lockButton).toHaveClass("text-[#00CCC0]")
fireEvent.click(lockButton)
const unlockButton = screen.getByTitle("dialogs.projectSettings.lockAspectRatio")
expect(unlockButton).toHaveClass("text-gray-400")
})
it("должен изменять только ширину при разблокированном соотношении", () => {
render()
// Разблокируем соотношение
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
fireEvent.click(lockButton)
vi.clearAllMocks()
const widthInput = screen.getByDisplayValue("1920")
fireEvent.change(widthInput, { target: { value: "1280" } })
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
aspectRatio: expect.objectContaining({
value: expect.objectContaining({
width: 1280,
}),
}),
}),
)
})
})
describe("Изменение частоты кадров", () => {
it("должен рендерить select для частоты кадров", () => {
render()
const frameRateSelect = screen.getAllByRole("combobox")[2]
expect(frameRateSelect).toBeInTheDocument()
})
it("должен показывать текущую частоту кадров", () => {
render()
const frameRateSelect = screen.getAllByRole("combobox")[2]
expect(frameRateSelect).toHaveTextContent("30")
})
it("должен обрабатывать смену частоты кадров через mock", () => {
render()
// Поскольку мы не можем легко тестировать dropdown, проверим что select существует
const frameRateSelect = screen.getAllByRole("combobox")[2]
expect(frameRateSelect).toHaveAttribute("role", "combobox")
})
})
describe("Изменение цветового пространства", () => {
it("должен рендерить select для цветового пространства", () => {
render()
const colorSpaceSelect = screen.getAllByRole("combobox")[3]
expect(colorSpaceSelect).toBeInTheDocument()
})
it("должен показывать текущее цветовое пространство", () => {
render()
const colorSpaceSelect = screen.getAllByRole("combobox")[3]
expect(colorSpaceSelect).toHaveTextContent("Rec. 709")
})
it("должен обрабатывать смену цветового пространства через mock", () => {
render()
const colorSpaceSelect = screen.getAllByRole("combobox")[3]
expect(colorSpaceSelect).toHaveAttribute("role", "combobox")
})
})
describe("Кнопки действий", () => {
it("должен закрывать модальное окно при нажатии Отмена", async () => {
const user = userEvent.setup()
render()
const cancelButton = screen.getByText("dialogs.projectSettings.cancel")
await user.click(cancelButton)
expect(mockCloseModal).toHaveBeenCalled()
expect(mockUpdateSettings).not.toHaveBeenCalled()
})
it("должен сохранять настройки и закрывать модальное окно при нажатии Сохранить", async () => {
const user = userEvent.setup()
render()
const saveButton = screen.getByText("dialogs.projectSettings.save")
await user.click(saveButton)
expect(mockUpdateSettings).toHaveBeenCalled()
await waitFor(
() => {
expect(mockCloseModal).toHaveBeenCalled()
},
{ timeout: 150 },
)
})
it("должен вызывать window.dispatchEvent при сохранении", async () => {
const user = userEvent.setup()
render()
const saveButton = screen.getByText("dialogs.projectSettings.save")
await user.click(saveButton)
await waitFor(
() => {
expect(window.dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: "resize",
}),
)
},
{ timeout: 150 },
)
})
it.skip("должен обрабатывать сохранение с пользовательским разрешением", async () => {
const user = userEvent.setup()
// Устанавливаем custom разрешение
mockSettings.resolution = "custom"
render()
const saveButton = screen.getByText("dialogs.projectSettings.save")
await user.click(saveButton)
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
resolution: expect.stringMatching(/\\d+x\\d+/),
}),
)
// Восстанавливаем
mockSettings.resolution = "1920x1080"
})
it("должен обрабатывать сохранение со стандартным разрешением", async () => {
const user = userEvent.setup()
render()
const saveButton = screen.getByText("dialogs.projectSettings.save")
await user.click(saveButton)
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
aspectRatio: expect.objectContaining({
value: expect.objectContaining({
width: expect.any(Number),
height: expect.any(Number),
}),
}),
}),
)
})
it.skip("должен применять таймаут при сохранении", async () => {
const user = userEvent.setup()
render()
const saveButton = screen.getByText("dialogs.projectSettings.save")
const startTime = Date.now()
await user.click(saveButton)
await waitFor(
() => {
expect(mockCloseModal).toHaveBeenCalled()
},
{ timeout: 150 },
)
const endTime = Date.now()
expect(endTime - startTime).toBeGreaterThanOrEqual(50) // Облегченная проверка задержки
})
it("должен обрабатывать множественные нажатия кнопки сохранения", async () => {
const user = userEvent.setup()
render()
const saveButton = screen.getByText("dialogs.projectSettings.save")
// Быстро нажимаем несколько раз
await user.click(saveButton)
await user.click(saveButton)
await user.click(saveButton)
// Должно сработать несколько раз
expect(mockUpdateSettings).toHaveBeenCalledTimes(3)
})
})
describe("Информация о соотношении сторон", () => {
it("должен показывать информацию о заблокированном соотношении", () => {
render()
expect(screen.getByText(/dialogs.projectSettings.aspectRatioLocked/)).toBeInTheDocument()
})
it("должен показывать информацию о разблокированном соотношении", () => {
render()
// Разблокируем соотношение
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
fireEvent.click(lockButton)
expect(screen.getByText(/dialogs.projectSettings.aspectRatioUnlocked/)).toBeInTheDocument()
})
it("должен показывать пользовательское соотношение для custom", () => {
// Устанавливаем custom соотношение
mockSettings.aspectRatio.label = "custom"
render()
expect(screen.getByText(/dialogs.projectSettings.aspectRatioLocked/)).toBeInTheDocument()
// Восстанавливаем
mockSettings.aspectRatio.label = "16:9"
})
it("должен отображать правильные иконки для состояния блокировки", () => {
render()
// В заблокированном состоянии должны быть иконки Lock
expect(screen.getAllByTestId("lock-icon")).toHaveLength(2)
// Разблокируем
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
fireEvent.click(lockButton)
// Теперь должны быть иконки Unlock
expect(screen.getAllByTestId("unlock-icon")).toHaveLength(2)
})
it("должен показывать правильный цвет для заблокированного состояния", () => {
render()
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
expect(lockButton).toHaveClass("text-[#00CCC0]")
})
})
describe("Сложные сценарии", () => {
it("должен обрабатывать последовательные изменения размеров", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
const heightInput = screen.getByDisplayValue("1080")
// Изменяем ширину
fireEvent.change(widthInput, { target: { value: "1280" } })
expect(mockUpdateSettings).toHaveBeenCalledTimes(1)
// Изменяем высоту на непропорциональное значение
fireEvent.change(heightInput, { target: { value: "800" } })
expect(mockUpdateSettings).toHaveBeenCalledTimes(2)
})
it("должен обрабатывать переключение между заблокированным и разблокированным режимом", () => {
render()
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
// Разблокируем
fireEvent.click(lockButton)
expect(screen.getAllByTestId("unlock-icon")).toHaveLength(2)
// Заблокируем обратно
const unlockButton = screen.getByTitle("dialogs.projectSettings.lockAspectRatio")
fireEvent.click(unlockButton)
expect(screen.getAllByTestId("lock-icon")).toHaveLength(2)
})
it("должен сохранять состояние блокировки при изменении размеров", () => {
render()
// Разблокируем соотношение
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
fireEvent.click(lockButton)
vi.clearAllMocks()
// Изменяем размеры - они должны изменяться независимо
const widthInput = screen.getByDisplayValue("1920")
fireEvent.change(widthInput, { target: { value: "1600" } })
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
aspectRatio: expect.objectContaining({
value: expect.objectContaining({
width: 1600,
}),
}),
}),
)
})
it("должен правильно обрабатывать пограничные случаи в расчетах", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
// Проверяем расчет для нечетных чисел
fireEvent.change(widthInput, { target: { value: "1921" } })
expect(mockUpdateSettings).toHaveBeenCalled()
vi.clearAllMocks()
// Проверяем расчет для очень больших чисел
fireEvent.change(widthInput, { target: { value: "7680" } })
expect(mockUpdateSettings).toHaveBeenCalled()
})
it("должен корректно работать с различными соотношениями сторон", () => {
// Тестируем 4:3
mockSettings.aspectRatio = {
label: "4:3",
textLabel: "Standard",
value: { width: 1440, height: 1080 },
}
render()
expect(screen.getByDisplayValue("1440")).toBeInTheDocument()
expect(screen.getByDisplayValue("1080")).toBeInTheDocument()
// Восстанавливаем
mockSettings.aspectRatio = {
label: "16:9",
textLabel: "Widescreen",
value: { width: 1920, height: 1080 },
}
})
})
describe("Интеграционные тесты", () => {
it.skip("должен правильно обрабатывать полный цикл редактирования", async () => {
const user = userEvent.setup()
render()
// 1. Изменяем ширину
const widthInput = screen.getByDisplayValue("1920")
fireEvent.change(widthInput, { target: { value: "1280" } })
// 2. Разблокируем соотношение
const lockButton = screen.getByTitle("dialogs.projectSettings.unlockAspectRatio")
fireEvent.click(lockButton)
// 3. Изменяем высоту независимо
const heightInput = screen.getByDisplayValue("1080")
fireEvent.change(heightInput, { target: { value: "800" } })
// 4. Сохраняем
const saveButton = screen.getByText("dialogs.projectSettings.save")
await user.click(saveButton)
// Проверяем что все вызовы были сделаны
expect(mockUpdateSettings).toHaveBeenCalledTimes(2) // width, height changes (без save)
await waitFor(
() => {
expect(mockCloseModal).toHaveBeenCalled()
},
{ timeout: 150 },
)
})
it("должен сохранять корректные данные при различных состояниях", async () => {
const user = userEvent.setup()
// Устанавливаем разное разрешение
mockSettings.resolution = "3840x2160"
render()
const saveButton = screen.getByText("dialogs.projectSettings.save")
await user.click(saveButton)
expect(mockUpdateSettings).toHaveBeenCalledWith(
expect.objectContaining({
resolution: expect.any(String),
aspectRatio: expect.any(Object),
frameRate: expect.any(String),
colorSpace: expect.any(String),
}),
)
// Восстанавливаем
mockSettings.resolution = "1920x1080"
})
})
describe("Обработка ошибок", () => {
it("должен обрабатывать некорректные данные gracefully", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
// Пытаемся ввести некорректные данные
fireEvent.change(widthInput, { target: { value: "invalid" } })
fireEvent.change(widthInput, { target: { value: "null" } })
fireEvent.change(widthInput, { target: { value: "undefined" } })
// updateSettings не должна вызываться с некорректными данными
expect(mockUpdateSettings).not.toHaveBeenCalled()
})
it("должен игнорировать очень большие значения", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
// Проверяем поведение с очень большим числом (больше максимального)
fireEvent.change(widthInput, { target: { value: "99999" } })
// В зависимости от реализации, может вызываться или не вызываться
// Проверим что компонент не падает
expect(widthInput).toBeInTheDocument()
})
it("должен обрабатывать пустые значения", () => {
render()
const widthInput = screen.getByDisplayValue("1920")
fireEvent.change(widthInput, { target: { value: "" } })
// С пустыми значениями функция не должна вызываться
expect(mockUpdateSettings).not.toHaveBeenCalled()
})
})
})