diff --git a/assets/test/filter.test.ts b/assets/test/filter.test.ts new file mode 100644 index 0000000..c116a3a --- /dev/null +++ b/assets/test/filter.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { 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' }, +}; + +const EMPTY_DATA: HeatmapData = { + days: [], + range: { begin: '2025-01-01', end: '2025-01-14' }, +}; + +const PROJECTS = [ + { id: 1, name: 'Alpha' }, + { id: 2, name: 'Beta' }, +]; + +describe('filter dropdown', () => { + let container: HTMLDivElement; + let fetchMock: ReturnType; + + beforeEach(() => { + container = document.createElement('div'); + container.setAttribute('data-url', '/heatmap/data'); + container.setAttribute('data-timesheet-url', '/en/timesheet/'); + container.setAttribute('data-projects', JSON.stringify(PROJECTS)); + document.body.appendChild(container); + + fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(MOCK_DATA), + }); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + document.body.removeChild(container); + vi.restoreAllMocks(); + }); + + it('renders select with form-select class when projects exist', () => { + init(container); + const select = container.querySelector('select.form-select'); + expect(select).not.toBeNull(); + }); + + it('does not render select when no projects', () => { + container.setAttribute('data-projects', '[]'); + init(container); + const select = container.querySelector('select.form-select'); + expect(select).toBeNull(); + }); + + it('has "All Projects" as first option', () => { + init(container); + const options = container.querySelectorAll('select.form-select option'); + expect(options[0].textContent).toBe('All Projects'); + expect((options[0] as HTMLOptionElement).value).toBe(''); + }); + + it('has one option per project', () => { + init(container); + const options = container.querySelectorAll('select.form-select option'); + expect(options.length).toBe(3); // All Projects + 2 projects + expect((options[1] as HTMLOptionElement).value).toBe('1'); + expect(options[1].textContent).toBe('Alpha'); + expect((options[2] as HTMLOptionElement).value).toBe('2'); + expect(options[2].textContent).toBe('Beta'); + }); + + it('has aria-label for accessibility', () => { + init(container); + const select = container.querySelector('select.form-select'); + expect(select?.getAttribute('aria-label')).toBe('Filter by project'); + }); + + it('fetches with project param on selection', async () => { + init(container); + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + const select = container.querySelector('select.form-select') as HTMLSelectElement; + select.value = '1'; + select.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + expect(fetchMock.mock.calls[1][0]).toBe('/heatmap/data?project=1'); + }); + + it('fetches without project param for All Projects', async () => { + init(container); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + + const select = container.querySelector('select.form-select') as HTMLSelectElement; + select.value = '1'; + select.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + + select.value = ''; + select.dispatchEvent(new Event('change')); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + expect(fetchMock.mock.calls[2][0]).toBe('/heatmap/data'); + }); + + it('re-renders heatmap after filter change', async () => { + init(container); + await vi.waitFor(() => { + expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); + }); + + const select = container.querySelector('select.form-select') as HTMLSelectElement; + select.value = '1'; + select.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + await vi.waitFor(() => { + expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); + }); + }); + + it('shows filtered empty message when no data for project', async () => { + fetchMock + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(MOCK_DATA) }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(EMPTY_DATA) }); + + init(container); + await vi.waitFor(() => { + expect(container.querySelector('rect.heatmap-cell')).not.toBeNull(); + }); + + const select = container.querySelector('select.form-select') as HTMLSelectElement; + select.value = '1'; + select.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + await vi.waitFor(() => { + expect(container.textContent).toContain('No tracking data for this project'); + }); + }); +});