import { render, screen, fireEvent } from '@testing-library/react' import { GanttListView } from '../GanttListView' import type { GanttListViewProps } from '../GanttListView' import type { GanttTask, TimelineItem } from '../types' // --------------------------------------------------------------------------- // 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 { return { item, start: new Date('2025-01-01'), end: new Date('2025-03-31'), progress: 50, timeProgress: 30, depth: 0, hasChildren: false, parentId: null, healthStatus: 'on_track', ...overrides, } } const CATEGORY_COLORS: Record = { product: '#3b82f6', team: '#a855f7', other: '#6b7280', } function defaultProps(overrides: Partial = {}): GanttListViewProps { return { listItems: [], focusedRowIndex: -1, categoryColors: CATEGORY_COLORS, handleItemClick: jest.fn(), ...overrides, } } // --------------------------------------------------------------------------- // Header rendering // --------------------------------------------------------------------------- describe('GanttListView — header', () => { it('renders the Title column header', () => { render() expect(screen.getByText('Title')).toBeInTheDocument() }) it('renders the Status column header', () => { render() expect(screen.getByText('Status')).toBeInTheDocument() }) it('renders the Category column header', () => { render() expect(screen.getByText('Category')).toBeInTheDocument() }) it('renders the Dates column header', () => { render() expect(screen.getByText('Dates')).toBeInTheDocument() }) it('renders the Progress column header', () => { render() expect(screen.getByText('Progress')).toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // Empty state // --------------------------------------------------------------------------- describe('GanttListView — empty state', () => { it('renders only the header row when listItems is empty', () => { const { container } = render() expect(container.querySelectorAll('.gantt-list-row')).toHaveLength(0) }) }) // --------------------------------------------------------------------------- // Row rendering // --------------------------------------------------------------------------- describe('GanttListView — row rendering', () => { it('renders a row for each task', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Alpha', status: 'in_progress' })), makeTask(makeItem({ id: 'b', title: 'Beta', status: 'completed' })), makeTask(makeItem({ id: 'c', title: 'Gamma', status: 'not_started' })), ] const { container } = render() expect(container.querySelectorAll('.gantt-list-row')).toHaveLength(3) }) it('renders each task title in the list', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Alpha Task', status: 'in_progress' })), makeTask(makeItem({ id: 'b', title: 'Beta Task', status: 'completed' })), ] render() expect(screen.getByText('Alpha Task')).toBeInTheDocument() expect(screen.getByText('Beta Task')).toBeInTheDocument() }) it('renders a status badge with the task status (underscores replaced with spaces)', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' })), makeTask(makeItem({ id: 'b', title: 'Task B', status: 'not_started' })), ] render() expect(screen.getByText('in progress')).toBeInTheDocument() expect(screen.getByText('not started')).toBeInTheDocument() }) it('renders a category badge when the task item has a category', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress', category: 'product' })), ] render() expect(screen.getByText('product')).toBeInTheDocument() }) it('does not render a category badge when item has no category', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' })), ] const { container } = render() expect(container.querySelector('.gantt-category-badge')).not.toBeInTheDocument() }) it('renders a progress percentage text', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' }), { progress: 75 }), ] render() expect(screen.getByText('75%')).toBeInTheDocument() }) it('renders 0% progress correctly', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'not_started' }), { progress: 0 }), ] render() expect(screen.getByText('0%')).toBeInTheDocument() }) it('renders 100% progress correctly', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'completed' }), { progress: 100 }), ] render() expect(screen.getByText('100%')).toBeInTheDocument() }) it('renders formatted date range in the Dates column', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' }), { // Use local-time constructor to avoid UTC→local timezone offset shifting the day start: new Date(2025, 0, 15), // Jan 15 2025 local end: new Date(2025, 3, 20), // Apr 20 2025 local }), ] const { container } = render() // The dates cell contains the full range as its textContent const datesCell = container.querySelector('.gantt-list-col-dates:not(.gantt-list-header span)') // Find the data row's dates cell (second .gantt-list-col-dates element) const datesCells = container.querySelectorAll('.gantt-list-col-dates') // datesCells[0] is the header "Dates", datesCells[1] is the data row cell const dataCell = datesCells[1] as HTMLElement expect(dataCell.textContent).toMatch(/Jan 15/) expect(dataCell.textContent).toMatch(/Apr 20/) }) }) // --------------------------------------------------------------------------- // Focus state // --------------------------------------------------------------------------- describe('GanttListView — focus state', () => { it('applies gantt-row-focused class to the focused row index', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Alpha', status: 'not_started' })), makeTask(makeItem({ id: 'b', title: 'Beta', status: 'in_progress' })), ] const { container } = render( ) const rows = container.querySelectorAll('.gantt-list-row') expect(rows[0]).not.toHaveClass('gantt-row-focused') expect(rows[1]).toHaveClass('gantt-row-focused') }) it('does not apply gantt-row-focused when focusedRowIndex is -1', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Alpha', status: 'not_started' })), ] const { container } = render( ) expect(container.querySelector('.gantt-row-focused')).not.toBeInTheDocument() }) }) // --------------------------------------------------------------------------- // Item click // --------------------------------------------------------------------------- describe('GanttListView — item click', () => { it('calls handleItemClick when a row is clicked', () => { const handleItemClick = jest.fn() const item = makeItem({ id: 'a', title: 'Clickable Task', status: 'in_progress' }) const listItems = [makeTask(item)] render() fireEvent.click(screen.getByText('Clickable Task')) expect(handleItemClick).toHaveBeenCalledWith(item) }) it('calls handleItemClick with the correct item when multiple rows exist', () => { const handleItemClick = jest.fn() const itemA = makeItem({ id: 'a', title: 'Task A', status: 'in_progress' }) const itemB = makeItem({ id: 'b', title: 'Task B', status: 'completed' }) const listItems = [makeTask(itemA), makeTask(itemB)] render() fireEvent.click(screen.getByText('Task B')) expect(handleItemClick).toHaveBeenCalledWith(itemB) expect(handleItemClick).toHaveBeenCalledTimes(1) }) }) // --------------------------------------------------------------------------- // Depth indentation // --------------------------------------------------------------------------- describe('GanttListView — depth indentation', () => { it('renders an indent spacer for child tasks (depth > 0)', () => { const listItems = [ makeTask(makeItem({ id: 'child', title: 'Child Task', status: 'in_progress' }), { depth: 1 }), ] const { container } = render() // The indent span has inline style with a calculated width const row = container.querySelector('.gantt-list-row') const indentSpan = row?.querySelector('span[style]') expect(indentSpan).toBeInTheDocument() }) it('does not render an indent spacer for root tasks (depth 0)', () => { const listItems = [ makeTask(makeItem({ id: 'root', title: 'Root Task', status: 'in_progress' }), { depth: 0 }), ] const { container } = render() const row = container.querySelector('.gantt-list-row') // At depth 0, no inline-block span is injected const titleCell = row?.querySelector('.gantt-list-col-title') // Check that no child span with display:inline-block exists const indentSpans = titleCell?.querySelectorAll('span[style]') ?? [] expect(indentSpans).toHaveLength(0) }) }) // --------------------------------------------------------------------------- // Progress bar color // --------------------------------------------------------------------------- describe('GanttListView — progress bar', () => { it('renders the progress fill bar', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' }), { progress: 60 }), ] const { container } = render() const fill = container.querySelector('.gantt-list-progress-fill') as HTMLElement | null expect(fill).toBeInTheDocument() expect(fill?.style.width).toBe('60%') }) it('uses the on_track health color for the progress fill when healthStatus is on_track', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' }), { progress: 50, healthStatus: 'on_track', }), ] const { container } = render() const fill = container.querySelector('.gantt-list-progress-fill') as HTMLElement | null // on_track color is #22c55e expect(fill?.style.backgroundColor).toBe('rgb(34, 197, 94)') }) it('uses the at_risk health color when healthStatus is at_risk', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress' }), { progress: 20, healthStatus: 'at_risk', }), ] const { container } = render() const fill = container.querySelector('.gantt-list-progress-fill') as HTMLElement | null // at_risk color is #eab308 expect(fill?.style.backgroundColor).toBe('rgb(234, 179, 8)') }) it('uses the blocked health color when healthStatus is blocked', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'blocked' }), { progress: 10, healthStatus: 'blocked', }), ] const { container } = render() const fill = container.querySelector('.gantt-list-progress-fill') as HTMLElement | null // blocked color is #ef4444 expect(fill?.style.backgroundColor).toBe('rgb(239, 68, 68)') }) }) // --------------------------------------------------------------------------- // Category color fallback // --------------------------------------------------------------------------- describe('GanttListView — category color fallback', () => { it('uses the other fallback color for unknown categories', () => { const listItems = [ makeTask(makeItem({ id: 'a', title: 'Task A', status: 'in_progress', category: 'unknown_cat' })), ] const { container } = render() const badge = container.querySelector('.gantt-category-badge') as HTMLElement | null expect(badge).toBeInTheDocument() // '#6b7280' in rgb expect(badge?.style.backgroundColor).toBe('rgb(107, 114, 128)') }) })