import { renderHook, act } from '@testing-library/react' import { useGanttState, ZOOM_LEVELS } from '../hooks/useGanttState' import type { GanttChartProps, TimelineItem } from '../types' // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const makeItem = (overrides: Partial & { id: string; title: string; status: string }): TimelineItem => ({ startDate: '2025-01-01', endDate: '2025-03-31', ...overrides, }) const ITEMS: TimelineItem[] = [ makeItem({ id: 'a', title: 'Alpha', status: 'not_started', category: 'product' }), makeItem({ id: 'b', title: 'Beta', status: 'in_progress', category: 'team', startDate: '2025-04-01', endDate: '2025-06-30' }), makeItem({ id: 'c', title: 'Gamma', status: 'completed', category: 'product', startDate: '2025-07-01', endDate: '2025-09-30' }), ] // The hook builds its itemMap from the flat top-level items array. // Children must appear both nested inside parent.children (for the relationship) // AND as standalone entries in the flat array (so itemMap.has(child.id) succeeds). const CHILD1 = makeItem({ id: 'child1', title: 'Child One', status: 'not_started' }) const CHILD2 = makeItem({ id: 'child2', title: 'Child Two', status: 'in_progress' }) const PARENT_ITEM = makeItem({ id: 'parent', title: 'Parent', status: 'in_progress', children: [CHILD1, CHILD2], }) const ITEMS_WITH_CHILDREN: TimelineItem[] = [PARENT_ITEM, CHILD1, CHILD2] function defaultProps(overrides: Partial = {}): GanttChartProps { return { items: ITEMS, showFilterBar: false, ...overrides } } // --------------------------------------------------------------------------- // Zoom // --------------------------------------------------------------------------- describe('useGanttState — zoom', () => { it('starts at the default zoom index (3)', () => { const { result } = renderHook(() => useGanttState(defaultProps())) expect(result.current.zoomIndex).toBe(3) expect(result.current.dayWidth).toBe(ZOOM_LEVELS[3]) }) it('respects initialZoom prop', () => { const { result } = renderHook(() => useGanttState(defaultProps({ initialZoom: 1 }))) expect(result.current.zoomIndex).toBe(1) expect(result.current.dayWidth).toBe(ZOOM_LEVELS[1]) }) it('zooms in by incrementing zoomIndex', () => { const { result } = renderHook(() => useGanttState(defaultProps())) act(() => { result.current.setZoomIndex(result.current.zoomIndex + 1) }) expect(result.current.zoomIndex).toBe(4) expect(result.current.dayWidth).toBe(ZOOM_LEVELS[4]) }) it('zooms out by decrementing zoomIndex', () => { const { result } = renderHook(() => useGanttState(defaultProps())) act(() => { result.current.setZoomIndex(result.current.zoomIndex - 1) }) expect(result.current.zoomIndex).toBe(2) expect(result.current.dayWidth).toBe(ZOOM_LEVELS[2]) }) }) // --------------------------------------------------------------------------- // View mode // --------------------------------------------------------------------------- describe('useGanttState — view mode', () => { it('defaults to "timeline"', () => { const { result } = renderHook(() => useGanttState(defaultProps())) expect(result.current.viewMode).toBe('timeline') }) it('respects initialViewMode prop', () => { const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'board' }))) expect(result.current.viewMode).toBe('board') }) it('switches to board mode', () => { const { result } = renderHook(() => useGanttState(defaultProps())) act(() => { result.current.setViewMode('board') }) expect(result.current.viewMode).toBe('board') }) it('switches to list mode', () => { const { result } = renderHook(() => useGanttState(defaultProps())) act(() => { result.current.setViewMode('list') }) expect(result.current.viewMode).toBe('list') }) it('switches back to timeline mode', () => { const { result } = renderHook(() => useGanttState(defaultProps())) act(() => { result.current.setViewMode('board') }) act(() => { result.current.setViewMode('timeline') }) expect(result.current.viewMode).toBe('timeline') }) }) // --------------------------------------------------------------------------- // Collapse / expand // --------------------------------------------------------------------------- describe('useGanttState — collapse/expand', () => { it('starts with no collapsed items', () => { const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN }))) expect(result.current.collapsed.size).toBe(0) }) it('collapses an item', () => { const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN }))) act(() => { result.current.toggleCollapse('parent') }) expect(result.current.collapsed.has('parent')).toBe(true) }) it('expands a previously collapsed item', () => { const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN }))) act(() => { result.current.toggleCollapse('parent') }) act(() => { result.current.toggleCollapse('parent') }) expect(result.current.collapsed.has('parent')).toBe(false) }) it('collapsing a parent hides its children from the task list', () => { const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN, hierarchical: true })) ) const beforeCollapse = result.current.tasks.length act(() => { result.current.toggleCollapse('parent') }) expect(result.current.tasks.length).toBeLessThan(beforeCollapse) }) }) // --------------------------------------------------------------------------- // Filter — search // --------------------------------------------------------------------------- describe('useGanttState — filter: search', () => { it('returns all items when showFilterBar is false regardless of search', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: false }))) act(() => { result.current.setFilters((prev) => ({ ...prev, search: 'Alpha' })) }) expect(result.current.filteredItems).toHaveLength(ITEMS.length) }) it('filters by title search when showFilterBar is true', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, search: 'Alpha' })) }) expect(result.current.filteredItems).toHaveLength(1) expect(result.current.filteredItems[0].id).toBe('a') }) it('search is case-insensitive', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, search: 'alpha' })) }) expect(result.current.filteredItems).toHaveLength(1) }) it('returns empty array when no items match the search', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, search: 'zzz-no-match' })) }) expect(result.current.filteredItems).toHaveLength(0) }) it('matches against description as well as title', () => { const itemsWithDesc: TimelineItem[] = [ makeItem({ id: 'x', title: 'Task X', status: 'not_started', description: 'special-keyword' }), ] const { result } = renderHook(() => useGanttState(defaultProps({ items: itemsWithDesc, showFilterBar: true })) ) act(() => { result.current.setFilters((prev) => ({ ...prev, search: 'special-keyword' })) }) expect(result.current.filteredItems).toHaveLength(1) }) }) // --------------------------------------------------------------------------- // Filter — status // --------------------------------------------------------------------------- describe('useGanttState — filter: status', () => { it('filters items by a single status', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, statuses: ['completed'] })) }) expect(result.current.filteredItems.every((i) => i.status === 'completed')).toBe(true) }) it('filters items by multiple statuses', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, statuses: ['not_started', 'completed'] })) }) const ids = result.current.filteredItems.map((i) => i.id) expect(ids).toContain('a') expect(ids).toContain('c') expect(ids).not.toContain('b') }) }) // --------------------------------------------------------------------------- // Filter — category // --------------------------------------------------------------------------- describe('useGanttState — filter: category', () => { it('filters items by category', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, categories: ['team'] })) }) expect(result.current.filteredItems).toHaveLength(1) expect(result.current.filteredItems[0].id).toBe('b') }) it('shows items from multiple categories', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, categories: ['product', 'team'] })) }) expect(result.current.filteredItems).toHaveLength(3) }) }) // --------------------------------------------------------------------------- // Filter — date range // --------------------------------------------------------------------------- describe('useGanttState — filter: date range', () => { it('excludes items that end before the filter start date', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) // Items A ends 2025-03-31; B and C start after April 1 act(() => { result.current.setFilters((prev) => ({ ...prev, dateRange: { start: new Date('2025-04-01'), end: null }, })) }) const ids = result.current.filteredItems.map((i) => i.id) expect(ids).not.toContain('a') expect(ids).toContain('b') expect(ids).toContain('c') }) it('excludes items that start after the filter end date', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) // Items C starts 2025-07-01; only A and B start before July 1 act(() => { result.current.setFilters((prev) => ({ ...prev, dateRange: { start: null, end: new Date('2025-06-30') }, })) }) const ids = result.current.filteredItems.map((i) => i.id) expect(ids).toContain('a') expect(ids).toContain('b') expect(ids).not.toContain('c') }) }) // --------------------------------------------------------------------------- // hasActiveFilters // --------------------------------------------------------------------------- describe('useGanttState — hasActiveFilters', () => { it('is falsy when all filters are at their defaults', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) expect(result.current.hasActiveFilters).toBeFalsy() }) it('is truthy when a search term is set', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, search: 'foo' })) }) expect(result.current.hasActiveFilters).toBeTruthy() }) it('is truthy when status filter is active', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, statuses: ['completed'] })) }) expect(result.current.hasActiveFilters).toBeTruthy() }) }) // --------------------------------------------------------------------------- // uniqueStatuses / uniqueCategories // --------------------------------------------------------------------------- describe('useGanttState — uniqueStatuses and uniqueCategories', () => { it('extracts unique statuses from items', () => { const { result } = renderHook(() => useGanttState(defaultProps())) expect(result.current.uniqueStatuses).toContain('not_started') expect(result.current.uniqueStatuses).toContain('in_progress') expect(result.current.uniqueStatuses).toContain('completed') }) it('returns statuses sorted alphabetically', () => { const { result } = renderHook(() => useGanttState(defaultProps())) const sorted = [...result.current.uniqueStatuses].sort() expect(result.current.uniqueStatuses).toEqual(sorted) }) it('extracts unique categories from items', () => { const { result } = renderHook(() => useGanttState(defaultProps())) expect(result.current.uniqueCategories).toContain('product') expect(result.current.uniqueCategories).toContain('team') }) it('collects categories from nested children', () => { const nested: TimelineItem[] = [ makeItem({ id: 'parent', title: 'Parent', status: 'in_progress', category: 'financial', children: [ makeItem({ id: 'child', title: 'Child', status: 'not_started', category: 'market' }), ], }), ] const { result } = renderHook(() => useGanttState(defaultProps({ items: nested }))) expect(result.current.uniqueCategories).toContain('financial') expect(result.current.uniqueCategories).toContain('market') }) }) // --------------------------------------------------------------------------- // handleItemClick / detailItem // --------------------------------------------------------------------------- describe('useGanttState — handleItemClick', () => { it('calls onItemClick when an item is clicked', () => { const onItemClick = jest.fn() const { result } = renderHook(() => useGanttState(defaultProps({ onItemClick }))) act(() => { result.current.handleItemClick(ITEMS[0]) }) expect(onItemClick).toHaveBeenCalledWith(ITEMS[0]) }) it('does not set detailItem when onItemEdit is not provided', () => { const { result } = renderHook(() => useGanttState(defaultProps())) act(() => { result.current.handleItemClick(ITEMS[0]) }) expect(result.current.detailItem).toBeNull() }) it('sets detailItem when onItemEdit is provided', () => { const onItemEdit = jest.fn() const { result } = renderHook(() => useGanttState(defaultProps({ onItemEdit }))) act(() => { result.current.handleItemClick(ITEMS[1]) }) expect(result.current.detailItem).toBe(ITEMS[1]) }) it('calls onItemClick in addition to setting detailItem', () => { const onItemClick = jest.fn() const onItemEdit = jest.fn() const { result } = renderHook(() => useGanttState(defaultProps({ onItemClick, onItemEdit }))) act(() => { result.current.handleItemClick(ITEMS[0]) }) expect(onItemClick).toHaveBeenCalledWith(ITEMS[0]) expect(result.current.detailItem).toBe(ITEMS[0]) }) it('allows manually clearing detailItem', () => { const onItemEdit = jest.fn() const { result } = renderHook(() => useGanttState(defaultProps({ onItemEdit }))) act(() => { result.current.handleItemClick(ITEMS[0]) }) expect(result.current.detailItem).not.toBeNull() act(() => { result.current.setDetailItem(null) }) expect(result.current.detailItem).toBeNull() }) }) // --------------------------------------------------------------------------- // Board columns // --------------------------------------------------------------------------- describe('useGanttState — board columns', () => { it('boardColumns is empty in timeline mode', () => { const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'timeline' }))) expect(result.current.boardColumns).toHaveLength(0) }) it('boardColumns is populated in board mode', () => { const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'board' }))) expect(result.current.boardColumns.length).toBeGreaterThan(0) }) it('each board column has a status and items array', () => { const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'board' }))) for (const col of result.current.boardColumns) { expect(col).toHaveProperty('status') expect(Array.isArray(col.items)).toBe(true) } }) it('uses boardStatuses prop when provided', () => { const boardStatuses = ['not_started', 'completed'] const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'board', boardStatuses })) ) const statuses = result.current.boardColumns.map((c) => c.status) expect(statuses).toEqual(boardStatuses) }) }) // --------------------------------------------------------------------------- // List items // --------------------------------------------------------------------------- describe('useGanttState — list items', () => { it('listItems is empty in timeline mode', () => { const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'timeline' }))) expect(result.current.listItems).toHaveLength(0) }) it('listItems is populated in list mode', () => { const { result } = renderHook(() => useGanttState(defaultProps({ initialViewMode: 'list' }))) expect(result.current.listItems.length).toBe(result.current.tasks.length) }) }) // --------------------------------------------------------------------------- // Tasks — derived from items // --------------------------------------------------------------------------- describe('useGanttState — tasks', () => { it('produces a task for every top-level item', () => { const { result } = renderHook(() => useGanttState(defaultProps())) expect(result.current.tasks).toHaveLength(ITEMS.length) }) it('each task carries the original item reference', () => { const { result } = renderHook(() => useGanttState(defaultProps())) const taskIds = result.current.tasks.map((t) => t.item.id) expect(taskIds).toEqual(expect.arrayContaining(ITEMS.map((i) => i.id))) }) it('task depth is 0 for root items', () => { const { result } = renderHook(() => useGanttState(defaultProps())) expect(result.current.tasks.every((t) => t.depth === 0)).toBe(true) }) it('child tasks have depth 1', () => { const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN, hierarchical: true })) ) const childTasks = result.current.tasks.filter((t) => t.depth === 1) expect(childTasks.length).toBeGreaterThan(0) }) it('hasChildren is true for the parent item', () => { const { result } = renderHook(() => useGanttState(defaultProps({ items: ITEMS_WITH_CHILDREN, hierarchical: true })) ) const parentTask = result.current.tasks.find((t) => t.item.id === 'parent') expect(parentTask?.hasChildren).toBe(true) }) it('uses statusToProgress when item has no explicit progress', () => { const statusToProgress = jest.fn().mockReturnValue(42) const { result } = renderHook(() => useGanttState(defaultProps({ statusToProgress })) ) expect(result.current.tasks[0].progress).toBe(42) }) it('prefers item.progress over statusToProgress', () => { const itemsWithProgress: TimelineItem[] = [ makeItem({ id: 'p', title: 'With progress', status: 'in_progress', progress: 75 }), ] const statusToProgress = jest.fn().mockReturnValue(0) const { result } = renderHook(() => useGanttState(defaultProps({ items: itemsWithProgress, statusToProgress })) ) expect(result.current.tasks[0].progress).toBe(75) expect(statusToProgress).not.toHaveBeenCalled() }) }) // --------------------------------------------------------------------------- // filteredItems computation // --------------------------------------------------------------------------- describe('useGanttState — filteredItems computation', () => { it('keeps parent when child matches a search filter', () => { const items: TimelineItem[] = [ makeItem({ id: 'parent', title: 'Parent Item', status: 'in_progress', children: [ makeItem({ id: 'child', title: 'Matching Child', status: 'not_started' }), ], }), ] const { result } = renderHook(() => useGanttState(defaultProps({ items, showFilterBar: true })) ) act(() => { result.current.setFilters((prev) => ({ ...prev, search: 'Matching Child' })) }) // Parent should be retained because child matched const parentInFiltered = result.current.filteredItems.find((i) => i.id === 'parent') expect(parentInFiltered).toBeDefined() }) it('clears filteredItems to all items when filters are reset', () => { const { result } = renderHook(() => useGanttState(defaultProps({ showFilterBar: true }))) act(() => { result.current.setFilters((prev) => ({ ...prev, search: 'Alpha' })) }) expect(result.current.filteredItems).toHaveLength(1) act(() => { result.current.setFilters((prev) => ({ ...prev, search: '' })) }) expect(result.current.filteredItems).toHaveLength(ITEMS.length) }) })