From 4b87dbf0875862640364d41f37927d2fd7ea77b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christopher=20M=C3=BChl?= Date: Wed, 8 Apr 2026 15:33:55 +0200 Subject: [PATCH] test(04-02): click navigation tests for cell callback and init URL construction --- assets/test/interaction.test.ts | 144 ++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 assets/test/interaction.test.ts diff --git a/assets/test/interaction.test.ts b/assets/test/interaction.test.ts new file mode 100644 index 0000000..be37a54 --- /dev/null +++ b/assets/test/interaction.test.ts @@ -0,0 +1,144 @@ +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/'); + }); +});