import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHeatmap, init } from '../src/heatmap'; import type { HeatmapData } from '../src/types'; const MOCK_DATA: HeatmapData = { days: [ { date: '2025-01-06', hours: 2.5, count: 3 }, { date: '2025-01-07', hours: 5.0, count: 5 }, ], range: { begin: '2025-01-01', end: '2025-01-14' }, }; describe('click navigation', () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { document.body.removeChild(container); vi.restoreAllMocks(); }); it('calls onCellClick with dateStr when a data cell is clicked', () => { const onClick = vi.fn(); renderHeatmap(container, MOCK_DATA, undefined, onClick); const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)') as SVGRectElement; cell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onClick).toHaveBeenCalledOnce(); expect(onClick.mock.calls[0][0]).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); it('calls onCellClick when an empty cell is clicked', () => { const onClick = vi.fn(); renderHeatmap(container, MOCK_DATA, undefined, onClick); const emptyCell = container.querySelector('rect.heatmap-empty') as SVGRectElement; emptyCell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(onClick).toHaveBeenCalledOnce(); }); it('does not throw when no onCellClick provided', () => { renderHeatmap(container, MOCK_DATA); const cell = container.querySelector('rect.heatmap-cell') as SVGRectElement; expect(() => cell.dispatchEvent(new MouseEvent('click', { bubbles: true }))).not.toThrow(); }); it('all cells have heatmap-cell class for cursor styling', () => { renderHeatmap(container, MOCK_DATA); const allRects = container.querySelectorAll('rect.heatmap-cell'); expect(allRects.length).toBeGreaterThan(0); allRects.forEach((rect) => { expect(rect.classList.contains('heatmap-cell')).toBe(true); }); }); }); describe('init click navigation', () => { let container: HTMLDivElement; let locationHref: string; const originalLocation = window.location; beforeEach(() => { container = document.createElement('div'); container.setAttribute('data-url', '/heatmap/data'); container.setAttribute('data-timesheet-url', '/en/timesheet/'); container.setAttribute('data-projects', '[]'); document.body.appendChild(container); locationHref = ''; Object.defineProperty(window, 'location', { value: { ...originalLocation, get href() { return locationHref; }, set href(v: string) { locationHref = v; }, }, writable: true, configurable: true, }); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(MOCK_DATA), })); }); afterEach(() => { document.body.removeChild(container); Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true, }); vi.restoreAllMocks(); }); it('navigates to timesheet URL with daterange on cell click', async () => { init(container); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)') as SVGRectElement; cell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(locationHref).toContain('/en/timesheet/?daterange='); expect(locationHref).toMatch(/daterange=.*\d{4}-\d{2}-\d{2}.*-.*\d{4}-\d{2}-\d{2}/); }); it('includes project filter in navigation URL when active', async () => { container.setAttribute('data-projects', JSON.stringify([{ id: 5, name: 'Test Project' }])); init(container); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); // Select the project in the dropdown const select = container.querySelector('select.form-select') as HTMLSelectElement; select.value = '5'; select.dispatchEvent(new Event('change')); // Wait for re-render after filter fetch await vi.waitFor(() => { expect(fetch).toHaveBeenCalledTimes(2); }); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)') as SVGRectElement; cell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(locationHref).toContain('projects[]=5'); }); it('falls back to /en/timesheet/ without data-timesheet-url', async () => { container.removeAttribute('data-timesheet-url'); init(container); await vi.waitFor(() => { expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); }); const cell = container.querySelector('rect.heatmap-cell:not(.heatmap-empty)') as SVGRectElement; cell.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(locationHref).toContain('/en/timesheet/'); }); });