import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { YearModeRenderer } from '../src/renderers/year'; import type { RenderContext } from '../src/renderers/types'; import type { HeatmapData } from '../src/types'; import { createInitialState } from '../src/state'; const DEFAULT_CONFIG = { cellSize: 13, cellGap: 2, marginTop: 20, marginLeft: 30, marginBottom: 4 }; function makeCtx(container: HTMLElement, data: HeatmapData, overrides?: Partial): RenderContext { return { container, data, state: createInitialState('monday'), config: DEFAULT_CONFIG, ...overrides, }; } function makeMockData(overrides: Partial = {}): HeatmapData { return { days: [ { date: '2025-01-06', hours: 2.5, count: 3 }, { date: '2025-01-07', hours: 5.0, count: 5 }, { date: '2025-01-08', hours: 1.0, count: 1 }, { date: '2025-01-13', hours: 8.0, count: 4 }, { date: '2025-01-20', hours: 3.5, count: 2 }, ], range: { begin: '2025-01-01', end: '2025-01-31', }, ...overrides, }; } describe('renderHeatmap', () => { let container: HTMLDivElement; let renderer: YearModeRenderer; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); renderer = new YearModeRenderer(); }); afterEach(() => { renderer.destroy(); document.querySelectorAll('.heatmap-tooltip').forEach(el => el.remove()); }); it('renders an SVG element', () => { renderer.render(makeCtx(container, makeMockData())); const svg = container.querySelector('svg'); expect(svg).not.toBeNull(); }); it('renders correct number of rect elements for date range', () => { renderer.render(makeCtx(container, makeMockData())); const rects = container.querySelectorAll('rect.heatmap-cell'); // Jan 1 to Jan 31 = 31 days expect(rects.length).toBe(31); }); it('applies heatmap-empty class to cells with no data', () => { renderer.render(makeCtx(container, makeMockData())); const emptyRects = container.querySelectorAll('rect.heatmap-empty'); // 31 days total, 5 with data = 26 empty expect(emptyRects.length).toBe(26); }); it('applies fill attribute to cells with data', () => { renderer.render(makeCtx(container, makeMockData())); const allRects = container.querySelectorAll('rect.heatmap-cell:not(.heatmap-empty)'); expect(allRects.length).toBe(5); allRects.forEach((rect) => { expect(rect.getAttribute('fill')).toBeTruthy(); }); }); it('renders day labels (Mon, Wed, Fri)', () => { renderer.render(makeCtx(container, makeMockData())); const dayLabels = container.querySelectorAll('text.day-label'); expect(dayLabels.length).toBe(7); // all 7 slots rendered const texts = Array.from(dayLabels).map((el) => el.textContent); expect(texts).toContain('Mon'); expect(texts).toContain('Wed'); expect(texts).toContain('Fri'); }); it('renders month labels', () => { // Use a range spanning two months const data = makeMockData({ range: { begin: '2025-01-01', end: '2025-02-28' }, days: [ { date: '2025-01-15', hours: 3.0, count: 2 }, { date: '2025-02-10', hours: 4.0, count: 1 }, ], }); renderer.render(makeCtx(container, data)); const monthLabels = container.querySelectorAll('text.month-label'); expect(monthLabels.length).toBeGreaterThan(0); const texts = Array.from(monthLabels).map((el) => el.textContent); expect(texts).toContain('Feb'); }); it('creates tooltip on mouseenter', () => { renderer.render(makeCtx(container, makeMockData())); const rect = container.querySelector( 'rect.heatmap-cell:not(.heatmap-empty)', ); expect(rect).not.toBeNull(); // Simulate mouseenter const event = new MouseEvent('mouseenter', { bubbles: true }); // jsdom getBoundingClientRect returns zeros, which is fine for structure test rect!.dispatchEvent(event); const tooltip = document.body.querySelector('.heatmap-tooltip') as HTMLDivElement; expect(tooltip).not.toBeNull(); expect(tooltip.style.display).toBe('block'); expect(tooltip.innerHTML).toContain('h'); expect(tooltip.innerHTML).toContain('entries'); }); it('hides tooltip on mouseleave', () => { renderer.render(makeCtx(container, makeMockData())); const rect = container.querySelector( 'rect.heatmap-cell:not(.heatmap-empty)', ); rect!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); rect!.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); const tooltip = document.body.querySelector('.heatmap-tooltip') as HTMLDivElement; expect(tooltip.style.display).toBe('none'); }); it('handles empty days array gracefully', () => { renderer.render(makeCtx(container, makeMockData({ days: [] }))); const svg = container.querySelector('svg'); expect(svg).toBeNull(); expect(container.textContent).toContain('No tracking data available'); }); it('only renders cells within the begin/end range', () => { const data: HeatmapData = { days: [ { date: '2025-03-01', hours: 2.0, count: 1 }, { date: '2025-03-05', hours: 4.0, count: 2 }, ], range: { begin: '2025-03-01', end: '2025-03-07' }, }; renderer.render(makeCtx(container, data)); const rects = container.querySelectorAll('rect.heatmap-cell'); expect(rects.length).toBe(7); }); it('applies heatmap-weekend class to Saturday and Sunday cells', () => { // 2025-01-04 is Saturday, 2025-01-05 is Sunday const data: HeatmapData = { days: [ { date: '2025-01-04', hours: 2.0, count: 1 }, ], range: { begin: '2025-01-01', end: '2025-01-07' }, }; renderer.render(makeCtx(container, data)); const weekendRects = container.querySelectorAll('rect.heatmap-weekend'); // Jan 1 (Wed) through Jan 7 (Tue): Sat Jan 4 + Sun Jan 5 = 2 weekend cells expect(weekendRects.length).toBe(2); }); it('does not apply heatmap-weekend class to weekday cells', () => { const data: HeatmapData = { days: [ { date: '2025-01-06', hours: 2.0, count: 1 }, ], range: { begin: '2025-01-06', end: '2025-01-10' }, }; renderer.render(makeCtx(container, data)); const weekendRects = container.querySelectorAll('rect.heatmap-weekend'); // Mon-Fri, no weekends expect(weekendRects.length).toBe(0); }); it('renders day labels for Sunday week start', () => { renderer.render(makeCtx(container, makeMockData(), { state: createInitialState('sunday') })); const dayLabels = container.querySelectorAll('text.day-label'); const texts = Array.from(dayLabels).map((el) => el.textContent); expect(texts).toContain('Sun'); expect(texts).toContain('Tue'); expect(texts).toContain('Thu'); }); });