/**
* Comprehensive tests for use-timeline-persons hook
*/
import { act, renderHook } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { useTimelinePersons } from "../../hooks/use-timeline-persons"
import type { TimelineClip } from "../../types/timeline"
import { MockTimelineProvider } from "../test-providers"
// Mock timeline-machine
vi.mock("../../services/timeline-machine", () => ({
timelineMachine: {},
}))
// Mock для person identification
const mockDetectFaces = vi.fn()
const mockIdentifyPerson = vi.fn()
const mockCreatePersonFromFace = vi.fn()
const mockAnalyzeVideoForPersons = vi.fn()
const mockSend = vi.fn()
const mockPersons = [
{
id: "person-1",
name: "John Doe",
photos: ["/photo1.jpg"],
appearances: [],
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "person-2",
name: "Jane Smith",
photos: ["/photo2.jpg"],
appearances: [],
createdAt: new Date(),
updatedAt: new Date(),
},
]
vi.mock("@/features/person-identification/hooks/use-person-identification", () => ({
usePersonIdentification: vi.fn(() => ({
persons: mockPersons,
detectFaces: mockDetectFaces,
identifyPerson: mockIdentifyPerson,
createPersonFromFace: mockCreatePersonFromFace,
analyzeVideoForPersons: mockAnalyzeVideoForPersons,
error: null,
})),
}))
// Mock useTimeline
const mockProject = {
id: "project-1",
name: "Test Project",
sections: [
{
id: "section-1",
name: "Section 1",
tracks: [
{
id: "track-1",
clips: [
{
id: "clip-1",
name: "Test Video Clip",
type: "video",
mediaId: "media-1",
startTime: 0,
duration: 10,
mediaFile: {
path: "/test/video.mp4",
name: "video.mp4",
duration: 10,
format: "video/mp4",
},
} as TimelineClip,
{
id: "clip-2",
name: "Test Audio Clip",
type: "audio",
mediaId: "media-2",
startTime: 10,
duration: 5,
mediaFile: {
path: "/test/audio.mp3",
name: "audio.mp3",
duration: 5,
format: "audio/mp3",
},
} as TimelineClip,
],
},
],
},
],
globalTracks: [],
}
vi.mock("../../hooks/use-timeline", () => ({
useTimeline: vi.fn(() => ({
project: mockProject,
send: mockSend,
})),
}))
describe("useTimelinePersons", () => {
beforeEach(() => {
vi.clearAllMocks()
// Настройка моков по умолчанию
mockDetectFaces.mockResolvedValue([
{
id: "face-1",
confidence: 0.9,
boundingBox: { x: 100, y: 100, width: 50, height: 50 },
frameNumber: 150,
timestamp: { seconds: 5, nanos: 0 },
thumbnail: "/thumb1.jpg",
},
{
id: "face-2",
confidence: 0.8,
boundingBox: { x: 200, y: 100, width: 50, height: 50 },
frameNumber: 180,
timestamp: { seconds: 6, nanos: 0 },
thumbnail: "/thumb2.jpg",
},
])
mockIdentifyPerson.mockResolvedValue({
person: mockPersons[0],
confidence: 0.85,
})
})
afterEach(() => {
vi.clearAllTimers()
})
it("должен инициализироваться с начальным состоянием", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
expect(result.current.state).toEqual({
isAnalyzing: false,
analysisProgress: 0,
appearances: [],
error: null,
})
expect(result.current.enablePersonDetection).toBe(true)
expect(result.current.confidenceThreshold).toBe(0.7)
})
it("должен возвращать персон из person identification", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
expect(result.current.persons).toHaveLength(2)
expect(result.current.persons[0].name).toBe("John Doe")
expect(result.current.persons[1].name).toBe("Jane Smith")
})
it("должен анализировать клип на наличие персон", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
// Проверяем вызовы методов
expect(mockDetectFaces).toHaveBeenCalledWith("", {
start: 0,
end: 10,
})
expect(mockIdentifyPerson).toHaveBeenCalledTimes(2)
// Проверяем состояние
expect(result.current.state.isAnalyzing).toBe(false)
expect(result.current.state.analysisProgress).toBe(100)
expect(result.current.state.appearances).toHaveLength(2)
expect(result.current.state.error).toBeNull()
})
it("должен правильно создавать appearance записи", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
const appearances = result.current.state.appearances
expect(appearances[0]).toMatchObject({
personId: "person-1",
clipId: "clip-1",
startTime: 5,
endTime: 6,
confidence: 0.85,
thumbnailPath: undefined, // thumbnail недоступен в DetectedFace
})
expect(appearances[0].boundingBox).toBeUndefined() // face.bbox не существует в моке
})
it("должен фильтровать лица с низкой уверенностью", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
// Устанавливаем высокий порог уверенности
act(() => {
result.current.setConfidenceThreshold(0.9)
})
// Мок возвращает идентификацию с уверенностью 0.85
mockIdentifyPerson.mockResolvedValue({
person: mockPersons[0],
confidence: 0.85,
})
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
// Не должно быть появлений, так как уверенность ниже порога
expect(result.current.state.appearances).toHaveLength(0)
})
it("должен получать персон для конкретного клипа", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
const personsForClip = result.current.getPersonsForClip("clip-1")
expect(personsForClip).toHaveLength(1)
expect(personsForClip[0].name).toBe("John Doe")
})
it("должен получать appearances для конкретного клипа", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
const appearancesForClip = result.current.getAppearancesForClip("clip-1")
expect(appearancesForClip).toHaveLength(2)
expect(appearancesForClip[0].clipId).toBe("clip-1")
})
it("должен анализировать весь timeline", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
await act(async () => {
await result.current.analyzeTimelineForPersons()
})
// Должен анализировать все клипы с mediaId (и видео, и аудио)
expect(mockDetectFaces).toHaveBeenCalledTimes(2)
expect(mockDetectFaces).toHaveBeenCalledWith("", {
start: 0,
end: 10,
})
})
it("должен очищать анализ персон", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
// Сначала анализируем
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
expect(result.current.state.appearances).toHaveLength(2)
// Очищаем
act(() => {
result.current.clearPersonsAnalysis()
})
expect(result.current.state).toEqual({
isAnalyzing: false,
analysisProgress: 0,
appearances: [],
error: null,
})
})
it("должен показывать детали персоны", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
act(() => {
result.current.showPersonDetail("person-1")
})
expect(consoleSpy).toHaveBeenCalledWith("Show person detail:", "person-1")
consoleSpy.mockRestore()
})
it("должен обрабатывать ошибки при анализе", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
mockDetectFaces.mockRejectedValueOnce(new Error("Ошибка обнаружения лиц"))
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
expect(result.current.state.error).toBe("Ошибка обнаружения лиц")
expect(result.current.state.isAnalyzing).toBe(false)
})
it("должен переключать обнаружение персон", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
expect(result.current.enablePersonDetection).toBe(true)
act(() => {
result.current.setEnablePersonDetection(false)
})
expect(result.current.enablePersonDetection).toBe(false)
})
it("должен устанавливать порог уверенности", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
expect(result.current.confidenceThreshold).toBe(0.7)
act(() => {
result.current.setConfidenceThreshold(0.85)
})
expect(result.current.confidenceThreshold).toBe(0.85)
})
it("не должен анализировать клип без медиафайла", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const clipWithoutMedia: TimelineClip = {
id: "clip-no-media",
name: "Empty Clip",
} as TimelineClip
await act(async () => {
await result.current.analyzeClipForPersons(clipWithoutMedia)
})
expect(mockDetectFaces).not.toHaveBeenCalled()
})
it("не должен анализировать когда обнаружение персон отключено", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
act(() => {
result.current.setEnablePersonDetection(false)
})
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
expect(mockDetectFaces).not.toHaveBeenCalled()
})
it("не должен запускать новый анализ пока идет текущий", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const testClip = mockProject.sections[0].tracks[0].clips[0]
// Задерживаем выполнение detectFaces
let resolveDetectFaces: () => void
mockDetectFaces.mockImplementation(
() =>
new Promise((resolve) => {
resolveDetectFaces = () => resolve([])
}),
)
// Запускаем первый анализ
await act(async () => {
// Запускаем анализ, но не ждем его завершения
void result.current.analyzeClipForPersons(testClip)
// Даем время для обновления состояния
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.state.isAnalyzing).toBe(true)
// Пытаемся запустить второй анализ
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
// detectFaces должен быть вызван только один раз
expect(mockDetectFaces).toHaveBeenCalledTimes(1)
// Завершаем первый анализ
act(() => {
resolveDetectFaces!()
})
})
it("должен автоматически анализировать новые клипы", async () => {
vi.useFakeTimers()
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
// Убеждаемся, что обнаружение персон включено
expect(result.current.enablePersonDetection).toBe(true)
// Ждем таймер (автоанализ срабатывает через 2 секунды)
await act(async () => {
await vi.advanceTimersByTimeAsync(2100)
})
// Проверяем что анализ был запущен
expect(mockDetectFaces).toHaveBeenCalledWith("", {
start: 0,
end: 10,
})
vi.useRealTimers()
})
it("должен обновлять appearances при повторном анализе клипа", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
const testClip = mockProject.sections[0].tracks[0].clips[0]
// Настраиваем моки для первого анализа
mockDetectFaces.mockResolvedValueOnce([
{
id: "face-1",
confidence: 0.9,
bbox: { x: 50, y: 50, width: 100, height: 100 },
frameNumber: 10,
timestamp: { seconds: 1, nanos: 0 },
thumbnail: "/thumb1.jpg",
},
{
id: "face-2",
confidence: 0.85,
bbox: { x: 200, y: 100, width: 80, height: 80 },
frameNumber: 20,
timestamp: { seconds: 2, nanos: 0 },
thumbnail: "/thumb2.jpg",
},
])
mockIdentifyPerson
.mockResolvedValueOnce({
person: mockPersons[0],
confidence: 0.9,
})
.mockResolvedValueOnce({
person: mockPersons[1],
confidence: 0.85,
})
// Первый анализ
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
expect(result.current.state.appearances).toHaveLength(2)
// Изменяем результаты мока
mockDetectFaces.mockResolvedValueOnce([
{
id: "face-3",
confidence: 0.95,
boundingBox: { x: 150, y: 150, width: 60, height: 60 },
frameNumber: 200,
timestamp: { seconds: 7, nanos: 0 },
thumbnail: "/thumb3.jpg",
},
])
// Второй анализ того же клипа
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
// Старые appearances должны быть заменены новыми
expect(result.current.state.appearances).toHaveLength(1)
expect(result.current.state.appearances[0].startTime).toBe(7)
})
it("должен обрабатывать неизвестные лица с высокой уверенностью", async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
{children}
)
const { result } = renderHook(() => useTimelinePersons(), { wrapper })
// Мок возвращает null для идентификации (неизвестное лицо)
mockIdentifyPerson.mockResolvedValue(null)
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {})
const testClip = mockProject.sections[0].tracks[0].clips[0]
await act(async () => {
await result.current.analyzeClipForPersons(testClip)
})
// Проверяем, что было залогировано неизвестное лицо
expect(consoleSpy).toHaveBeenCalledWith(
"Unknown face detected with high confidence:",
expect.objectContaining({
confidence: expect.any(Number),
}),
)
// Не должно быть appearances для неизвестных лиц
expect(result.current.state.appearances).toHaveLength(0)
consoleSpy.mockRestore()
})
})