import { render, screen, fireEvent } from '@testing-library/react' import { GanttTimelineView } from '../GanttTimelineView' import type { GanttTimelineViewProps } from '../GanttTimelineView' import type { GanttTask, TimelineItem } from '../types' import { ZOOM_LEVELS, ROW_HEIGHT } from '../hooks/useGanttState' import React from 'react' // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeItem(overrides: Partial & { id: string; title: string; status: string }): TimelineItem { return { startDate: '2025-01-01', endDate: '2025-03-31', ...overrides } } function makeTask(item: TimelineItem, overrides: Partial = {}): GanttTask { const start = new Date('2025-01-01') const end = new Date('2025-03-31') return { item, start, end, progress: 50, timeProgress: 30, depth: 0, hasChildren: false, parentId: null, healthStatus: 'on_track', ...overrides, } } // --------------------------------------------------------------------------- // Minimal default props factory // --------------------------------------------------------------------------- function defaultProps(overrides: Partial = {}): GanttTimelineViewProps { const ZOOM_INDEX = 3 const DAY_WIDTH = ZOOM_LEVELS[ZOOM_INDEX] const startDate = new Date('2025-01-01') const days: Date[] = [] for (let i = 0; i < 90; i++) { const d = new Date(startDate) d.setDate(d.getDate() + i) days.push(d) } const months = [ { month: new Date('2025-01-01'), days: 31, label: 'Jan 2025' }, { month: new Date('2025-02-01'), days: 28, label: 'Feb 2025' }, { month: new Date('2025-03-01'), days: 31, label: 'Mar 2025' }, ] const timelineWidth = days.length * DAY_WIDTH return { infoColumnWidth: 320, infoColumnLabel: 'Item', showCategory: true, showStatus: true, showFullscreen: false, zoomIndex: ZOOM_INDEX, setZoomIndex: jest.fn(), collapsed: new Set(), mounted: true, dragState: null, focusedRowIndex: -1, dayWidth: DAY_WIDTH, tasks: [], startDate, days, months, timeHeaderUnits: { mode: 'days', units: days.map((day) => ({ date: day, width: DAY_WIDTH, label: String(day.getDate()), isWeekend: day.getDay() === 0 || day.getDay() === 6, isToday: false, })), }, dependencyPaths: [], timelineWidth, bodyHeight: 0, isFullscreen: false, categoryColors: { product: '#3b82f6', team: '#a855f7', other: '#6b7280' }, onDateChange: undefined, bodyScrollRef: { current: null }, headerTimelineRef: { current: null }, infoColumnRef: { current: null }, toggleFullscreen: jest.fn(), handleItemClick: jest.fn(), toggleCollapse: jest.fn(), handleBodyScroll: jest.fn(), getBarPosition: jest.fn().mockReturnValue({ left: 0, width: 100, top: 0, height: 20 }), handleDragStart: jest.fn(), ...overrides, } } // --------------------------------------------------------------------------- // Header rendering // --------------------------------------------------------------------------- describe('GanttTimelineView — header', () => { it('renders the info column header with the infoColumnLabel', () => { render() expect(screen.getByText('Goal')).toBeInTheDocument() }) it('renders month labels in the timeline header', () => { render() expect(screen.getByText('Jan 2025')).toBeInTheDocument() expect(screen.getByText('Feb 2025')).toBeInTheDocument() expect(screen.getByText('Mar 2025')).toBeInTheDocument() }) it('renders day unit labels in days mode', () => { render() // Day 1 label should appear at start const dayOnes = screen.getAllByText('1') expect(dayOnes.length).toBeGreaterThan(0) }) it('renders week unit labels in weeks mode', () => { const weekUnits = [ { startDate: new Date('2025-01-06'), endDate: new Date('2025-01-12'), days: 7, label: 'Jan 6', hasToday: false, width: 56 }, { startDate: new Date('2025-01-13'), endDate: new Date('2025-01-19'), days: 7, label: 'Jan 13', hasToday: false, width: 56 }, ] render( ) expect(screen.getByText('Jan 6')).toBeInTheDocument() expect(screen.getByText('Jan 13')).toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // Zoom controls // --------------------------------------------------------------------------- describe('GanttTimelineView — zoom controls', () => { it('renders zoom out button', () => { render() expect(screen.getByRole('button', { name: 'Zoom out' })).toBeInTheDocument() }) it('renders zoom in button', () => { render() expect(screen.getByRole('button', { name: 'Zoom in' })) .toBeInTheDocument() }) it('disables zoom out button at minimum zoom', () => { render() expect(screen.getByRole('button', { name: 'Zoom out' })).toBeDisabled() }) it('disables zoom in button at maximum zoom', () => { render() expect(screen.getByRole('button', { name: 'Zoom in' })).toBeDisabled() }) it('calls setZoomIndex when zoom out is clicked', () => { const setZoomIndex = jest.fn() render() fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) expect(setZoomIndex).toHaveBeenCalledTimes(1) }) it('calls setZoomIndex when zoom in is clicked', () => { const setZoomIndex = jest.fn() render() fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) expect(setZoomIndex).toHaveBeenCalledTimes(1) }) it('does not render fullscreen button when showFullscreen is false', () => { render() expect(screen.queryByRole('button', { name: /fullscreen/i })).not.toBeInTheDocument() }) it('renders fullscreen button when showFullscreen is true', () => { render() expect(screen.getByRole('button', { name: /fullscreen/i })).toBeInTheDocument() }) it('calls toggleFullscreen when the fullscreen button is clicked', () => { const toggleFullscreen = jest.fn() render() fireEvent.click(screen.getByRole('button', { name: /fullscreen/i })) expect(toggleFullscreen).toHaveBeenCalledTimes(1) }) }) // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- describe('GanttTimelineView — empty state', () => { it('renders no info rows when tasks array is empty', () => { render() // No gantt-info-row elements means no task title links expect(screen.queryAllByRole('button', { name: /expand|collapse/i })).toHaveLength(0) }) }) // --------------------------------------------------------------------------- // Task bars and info rows // --------------------------------------------------------------------------- describe('GanttTimelineView — task rows', () => { it('renders a row title for each task', () => { const tasks = [ makeTask(makeItem({ id: 'a', title: 'Alpha Task', status: 'in_progress' })), makeTask(makeItem({ id: 'b', title: 'Beta Task', status: 'completed' })), ] // Use width <= 80 so bar label is empty (avoids duplicate title text nodes) const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 80, top: 0, height: 20 }) render() // Titles appear in the info column expect(screen.getAllByText('Alpha Task').length).toBeGreaterThanOrEqual(1) expect(screen.getAllByText('Beta Task').length).toBeGreaterThanOrEqual(1) }) it('calls handleItemClick when a row title is clicked', () => { const handleItemClick = jest.fn() const item = makeItem({ id: 'a', title: 'Clickable Task', status: 'not_started' }) const tasks = [makeTask(item)] // Use width <= 80 so the bar label renders empty and title only appears in the info row const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 80, top: 0, height: 20 }) const { container } = render() // Target the info-column title span specifically to avoid the bar label span const titleSpan = container.querySelector('.gantt-row-title') as HTMLElement expect(titleSpan).toBeInTheDocument() fireEvent.click(titleSpan) expect(handleItemClick).toHaveBeenCalledWith(item) }) it('renders a collapse button for tasks that have children', () => { const tasks = [ makeTask(makeItem({ id: 'p', title: 'Parent', status: 'in_progress' }), { hasChildren: true }), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 }) render() expect(screen.getByRole('button', { name: /collapse/i })).toBeInTheDocument() }) it('shows expand button for a collapsed parent task', () => { const tasks = [ makeTask(makeItem({ id: 'p', title: 'Parent', status: 'in_progress' }), { hasChildren: true }), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 }) render( ) expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument() }) it('calls toggleCollapse when the collapse button is clicked', () => { const toggleCollapse = jest.fn() const tasks = [ makeTask(makeItem({ id: 'p', title: 'Parent', status: 'in_progress' }), { hasChildren: true }), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 }) render() fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) expect(toggleCollapse).toHaveBeenCalledWith('p') }) it('renders category badge when showCategory is true and item has a category', () => { const tasks = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress', category: 'product' })), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 }) render() expect(screen.getByText('product')).toBeInTheDocument() }) it('does not render category badge when showCategory is false', () => { const tasks = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress', category: 'product' })), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 }) render() expect(screen.queryByText('product')).not.toBeInTheDocument() }) it('renders status badge when showStatus is true', () => { const tasks = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' })), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 }) render() expect(screen.getByText('in progress')).toBeInTheDocument() }) it('does not render status badge when showStatus is false', () => { const tasks = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' })), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 }) render() expect(screen.queryByText('in progress')).not.toBeInTheDocument() }) it('applies gantt-row-focused class to the focused row', () => { const tasks = [ makeTask(makeItem({ id: 'a', title: 'Alpha', status: 'not_started' })), makeTask(makeItem({ id: 'b', title: 'Beta', status: 'not_started' })), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 200, top: 0, height: 20 }) const { container } = render( ) const focusedRows = container.querySelectorAll('.gantt-row-focused') expect(focusedRows).toHaveLength(1) }) }) // --------------------------------------------------------------------------- // Legend // --------------------------------------------------------------------------- describe('GanttTimelineView — legend', () => { it('renders the On Track legend entry', () => { render() expect(screen.getByText('On Track')).toBeInTheDocument() }) it('renders the At Risk legend entry', () => { render() expect(screen.getByText('At Risk')).toBeInTheDocument() }) it('renders the Blocked legend entry', () => { render() expect(screen.getByText('Blocked')).toBeInTheDocument() }) it('renders the Not Started legend entry', () => { render() expect(screen.getByText('Not Started')).toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // Dependency paths // --------------------------------------------------------------------------- describe('GanttTimelineView — dependency paths', () => { it('does not render an SVG overlay when dependencyPaths is empty', () => { const { container } = render() expect(container.querySelector('.gantt-deps-svg')).not.toBeInTheDocument() }) it('renders an SVG overlay when dependencyPaths has entries', () => { const tasks = [ makeTask(makeItem({ id: 'a', title: 'A', status: 'in_progress' })), makeTask(makeItem({ id: 'b', title: 'B', status: 'not_started' })), ] const getBarPosition = jest.fn().mockReturnValue({ left: 0, width: 100, top: 0, height: 20 }) const paths = [{ path: 'M 100 10 C 150 10, 150 46, 200 46', fromId: 'a', toId: 'b' }] const { container } = render( ) expect(container.querySelector('.gantt-deps-svg')).toBeInTheDocument() }) })