import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { toast } from "sonner"
import { beforeEach, describe, expect, it, vi } from "vitest"
// Mock react-i18next
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, defaultValue?: any) => {
if (typeof defaultValue === "string") return defaultValue
return key
},
i18n: {
language: "ru",
},
}),
}))
// Mock dependencies
vi.mock("@/features/modals/services", () => ({
useModal: vi.fn(() => ({
modalData: null,
closeModal: vi.fn(),
})),
}))
vi.mock("@/features/timeline/hooks/use-timeline", () => ({
useTimeline: vi.fn(() => ({
project: null,
send: vi.fn(),
})),
}))
vi.mock("@/features/app-state/hooks/use-media-files", () => ({
useMediaFiles: vi.fn(() => ({
mediaFiles: [],
})),
}))
vi.mock("@/features/ai-chat/services/whisper-service", () => ({
WhisperService: {
getInstance: vi.fn(() => ({
loadApiKey: vi.fn().mockResolvedValue(false),
hasApiKey: vi.fn(() => false),
isLocalWhisperAvailable: vi.fn().mockResolvedValue(true),
extractAudioForTranscription: vi.fn().mockResolvedValue("/tmp/audio.wav"),
transcribeWithOpenAI: vi.fn(),
transcribeWithLocalModel: vi.fn(),
convertToSRT: vi.fn(),
recommendModel: vi.fn(() => "whisper-base"),
})),
},
}))
vi.mock("sonner", () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
},
}))
import { WhisperService } from "@/features/ai-chat/services/whisper-service"
import { useMediaFiles } from "@/features/app-state/hooks/use-media-files"
import { useModal } from "@/features/modals/services"
import { useTimeline } from "@/features/timeline/hooks/use-timeline"
import { SubtitleAIToolsModal } from "../subtitle-ai-tools-modal"
const mockedUseModal = vi.mocked(useModal)
const mockedUseTimeline = vi.mocked(useTimeline)
const mockedUseMediaFiles = vi.mocked(useMediaFiles)
const mockedWhisperService = vi.mocked(WhisperService.getInstance)
describe("SubtitleAIToolsModal", () => {
const mockProject = {
sections: [
{
tracks: [
{
id: "video-track-1",
type: "video",
clips: [
{
id: "clip-1",
type: "video",
mediaFile: {
path: "/path/to/video.mp4",
name: "video.mp4",
},
duration: 120,
},
],
},
{
id: "audio-track-1",
type: "audio",
clips: [
{
id: "clip-2",
type: "audio",
mediaFile: {
path: "/path/to/audio.mp3",
name: "audio.mp3",
},
duration: 180,
},
],
},
],
},
],
globalTracks: [],
}
const mockSend = vi.fn()
const mockCloseModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
;(mockedUseModal as any).mockReturnValue({
modalData: null,
closeModal: mockCloseModal,
})
mockedUseTimeline.mockReturnValue({
project: mockProject as any,
send: mockSend,
} as any)
// Extract media files from mock project
const mediaFiles = []
for (const section of mockProject.sections) {
for (const track of section.tracks) {
for (const clip of track.clips) {
if (clip.mediaFile) {
mediaFiles.push({
id: clip.id,
path: clip.mediaFile.path,
name: clip.mediaFile.name,
duration: clip.duration,
media_type: track.type === "video" ? "Video" : "Audio",
})
}
}
}
}
mockedUseMediaFiles.mockReturnValue({
mediaFiles: mediaFiles as any,
} as any)
})
it("should render the modal with description", () => {
render()
expect(screen.getByText("Используйте AI для автоматического создания субтитров из аудио")).toBeTruthy()
})
it("should display available media files", async () => {
render()
const fileSelect = screen.getAllByRole("combobox")[0]
fireEvent.click(fileSelect)
// Wait for the dropdown to open and check for files
await waitFor(() => {
expect(screen.getAllByText("video.mp4").length).toBeGreaterThan(0)
expect(screen.getAllByText("audio.mp3").length).toBeGreaterThan(0)
})
})
it("should display language options", () => {
render()
const languageSelect = screen.getAllByRole("combobox")[1]
fireEvent.click(languageSelect)
// Use getAllByText since there might be duplicates in the portal
expect(screen.getAllByText("Автоопределение").length).toBeGreaterThan(0)
expect(screen.getAllByText("Русский").length).toBeGreaterThan(0)
expect(screen.getAllByText("English").length).toBeGreaterThan(0)
})
it("should display model selection options", async () => {
render()
const modelSelect = screen.getAllByRole("combobox")[2]
fireEvent.click(modelSelect)
await waitFor(() => {
expect(screen.getAllByText("OpenAI Whisper (облачный)").length).toBeGreaterThan(0)
expect(screen.getAllByText("Локальная Tiny (39 MB)").length).toBeGreaterThan(0)
expect(screen.getAllByText("Локальная Large v3 (1.5 GB)").length).toBeGreaterThan(0)
})
})
it("should disable start button when no file is selected", () => {
render()
const startButton = screen.getByText("Начать транскрипцию")
expect(startButton).toBeDisabled()
})
it("should show no media message when project has no files", () => {
mockedUseTimeline.mockReturnValue({
project: { sections: [], globalTracks: [] } as any,
send: mockSend,
} as any)
mockedUseMediaFiles.mockReturnValue({
mediaFiles: [],
} as any)
render()
expect(screen.getByText("Добавьте видео или аудио файлы в проект для транскрипции")).toBeTruthy()
})
it("should start transcription with selected video file", async () => {
const mockTranscriptionResult = {
segments: [
{
id: 1,
seek: 0,
start: 0,
end: 3,
text: "Test subtitle",
tokens: [],
temperature: 0,
avg_logprob: 0,
compression_ratio: 1,
no_speech_prob: 0,
},
],
}
const mockWhisper = {
loadApiKey: vi.fn().mockResolvedValue(false),
hasApiKey: vi.fn(() => false),
isLocalWhisperAvailable: vi.fn().mockResolvedValue(true),
extractAudioForTranscription: vi.fn().mockResolvedValue("/tmp/audio.wav"),
transcribeWithLocalModel: vi.fn().mockResolvedValue(mockTranscriptionResult),
convertToSRT: vi.fn(() => "1\n00:00:00,000 --> 00:00:03,000\nTest subtitle"),
recommendModel: vi.fn(() => "whisper-base"),
}
mockedWhisperService.mockReturnValue(mockWhisper as any)
render()
// Select video file
fireEvent.click(screen.getByText("Выберите файл..."))
fireEvent.click(screen.getByText("video.mp4"))
// Start transcription
const startButton = screen.getByText("Начать транскрипцию")
fireEvent.click(startButton)
await waitFor(() => {
expect(toast.info).toHaveBeenCalledWith("Извлечение аудио из видео...")
expect(mockWhisper.extractAudioForTranscription).toHaveBeenCalledWith("/path/to/video.mp4")
})
await waitFor(() => {
expect(toast.info).toHaveBeenCalledWith("Транскрипция с помощью локальной модели...")
expect(mockWhisper.transcribeWithLocalModel).toHaveBeenCalledWith(
"/tmp/audio.wav",
"whisper-base",
expect.objectContaining({
language: undefined,
outputFormat: "json",
}),
)
})
await waitFor(() => {
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
type: "ADD_TRACK",
track: expect.objectContaining({
type: "subtitle",
}),
}),
)
})
await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
"Транскрипция завершена",
expect.objectContaining({
description: "Добавлено {{count}} субтитров",
}),
)
expect(mockCloseModal).toHaveBeenCalled()
})
})
it("should use OpenAI API when API key is available", async () => {
const mockTranscriptionResult = {
text: "Test subtitle",
segments: [],
}
const mockWhisper = {
loadApiKey: vi.fn().mockResolvedValue(true),
hasApiKey: vi.fn(() => true),
isLocalWhisperAvailable: vi.fn().mockResolvedValue(false),
transcribeWithOpenAI: vi.fn().mockResolvedValue(mockTranscriptionResult),
convertToSRT: vi.fn(() => ""),
}
mockedWhisperService.mockReturnValue(mockWhisper as any)
render()
// Select audio file
fireEvent.click(screen.getByText("Выберите файл..."))
fireEvent.click(screen.getByText("audio.mp3"))
// Start transcription
const startButton = screen.getByText("Начать транскрипцию")
fireEvent.click(startButton)
await waitFor(() => {
expect(toast.info).toHaveBeenCalledWith("Транскрипция с помощью OpenAI Whisper...")
expect(mockWhisper.transcribeWithOpenAI).toHaveBeenCalledWith(
"/path/to/audio.mp3",
expect.objectContaining({
language: undefined,
response_format: "verbose_json",
timestamp_granularities: ["segment"],
}),
)
})
})
it("should handle transcription errors", async () => {
const mockWhisper = {
loadApiKey: vi.fn().mockResolvedValue(false),
hasApiKey: vi.fn(() => false),
isLocalWhisperAvailable: vi.fn().mockResolvedValue(true),
transcribeWithLocalModel: vi.fn().mockRejectedValue(new Error("Transcription failed")),
recommendModel: vi.fn(() => "whisper-base"),
}
mockedWhisperService.mockReturnValue(mockWhisper as any)
render()
// Select file
fireEvent.click(screen.getByText("Выберите файл..."))
fireEvent.click(screen.getByText("audio.mp3"))
// Start transcription
const startButton = screen.getByText("Начать транскрипцию")
fireEvent.click(startButton)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"Ошибка транскрипции",
expect.objectContaining({
description: "Transcription failed",
}),
)
})
})
it("should show local model note when local model is selected", async () => {
render()
const modelSelect = screen.getAllByRole("combobox")[2]
fireEvent.click(modelSelect)
await waitFor(() => {
const baseOptions = screen.getAllByText("Локальная Base (74 MB)")
fireEvent.click(baseOptions[baseOptions.length - 1]) // Click the last one (in dropdown)
})
await waitFor(() => {
expect(screen.getByText("Локальные модели работают без интернета, но могут быть медленнее")).toBeTruthy()
})
})
it("should disable start button when transcribing", async () => {
render()
// Select file
fireEvent.click(screen.getByText("Выберите файл..."))
fireEvent.click(screen.getByText("audio.mp3"))
// Start transcription
const startButton = screen.getByText("Начать транскрипцию")
fireEvent.click(startButton)
await waitFor(() => {
expect(startButton).toBeDisabled()
})
})
it("should handle no available transcription method", async () => {
const mockWhisper = {
loadApiKey: vi.fn().mockResolvedValue(false),
hasApiKey: vi.fn(() => false),
isLocalWhisperAvailable: vi.fn().mockResolvedValue(false),
}
mockedWhisperService.mockReturnValue(mockWhisper as any)
render()
// Select file
fireEvent.click(screen.getByText("Выберите файл..."))
fireEvent.click(screen.getByText("audio.mp3"))
// Start transcription
const startButton = screen.getByText("Начать транскрипцию")
fireEvent.click(startButton)
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"Метод транскрипции недоступен",
expect.objectContaining({
description: "Установите API ключ OpenAI или локальную модель Whisper",
}),
)
})
})
it("should handle duplicate media files", async () => {
const projectWithDuplicates = {
sections: [
{
tracks: [
{
id: "track-1",
type: "video",
clips: [
{
id: "clip-1",
type: "video",
mediaFile: { path: "/path/to/video.mp4", name: "video.mp4" },
duration: 120,
},
{
id: "clip-2",
type: "video",
mediaFile: { path: "/path/to/video.mp4", name: "video.mp4" },
duration: 120,
},
],
},
],
},
],
globalTracks: [],
}
mockedUseTimeline.mockReturnValue({
project: projectWithDuplicates as any,
send: mockSend,
} as any)
// Extract media files from projectWithDuplicates
const mediaFilesMap = new Map()
for (const section of projectWithDuplicates.sections) {
for (const track of section.tracks) {
for (const clip of track.clips) {
if (clip.mediaFile && !mediaFilesMap.has(clip.mediaFile.path)) {
mediaFilesMap.set(clip.mediaFile.path, {
id: clip.id,
path: clip.mediaFile.path,
name: clip.mediaFile.name,
duration: clip.duration,
media_type: track.type === "video" ? "Video" : "Audio",
})
}
}
}
}
const mediaFiles = Array.from(mediaFilesMap.values())
mockedUseMediaFiles.mockReturnValue({
mediaFiles: mediaFiles as any,
} as any)
render()
const fileSelect = screen.getAllByRole("combobox")[0]
fireEvent.click(fileSelect)
// Should show only one instance of video.mp4 in the dropdown
await waitFor(() => {
// The file appears in the select value and in the dropdown, so we expect 2
const videoOptions = screen.getAllByText("video.mp4")
expect(videoOptions.length).toBeGreaterThan(0)
})
})
it("should use selected language for transcription", async () => {
const mockWhisper = {
loadApiKey: vi.fn().mockResolvedValue(false),
hasApiKey: vi.fn(() => false),
isLocalWhisperAvailable: vi.fn().mockResolvedValue(true),
transcribeWithLocalModel: vi.fn().mockResolvedValue({ text: "Test" }),
recommendModel: vi.fn(() => "whisper-base"),
}
mockedWhisperService.mockReturnValue(mockWhisper as any)
render()
// Select file
fireEvent.click(screen.getByText("Выберите файл..."))
fireEvent.click(screen.getByText("audio.mp3"))
// Select language
const languageSelect = screen.getAllByRole("combobox")[1]
fireEvent.click(languageSelect)
fireEvent.click(screen.getByText("Русский"))
// Start transcription
const startButton = screen.getByText("Начать транскрипцию")
fireEvent.click(startButton)
await waitFor(() => {
expect(mockWhisper.transcribeWithLocalModel).toHaveBeenCalledWith(
"/path/to/audio.mp3",
"whisper-base",
expect.objectContaining({
language: "ru",
outputFormat: "json",
}),
)
})
})
})