kimai-plugin-heatmap/assets/test/heatmap.test.ts
Christopher Mühl d0dc64f333
feat: add streak, stats, weekend styling, week-start preference
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 16:05:44 +02:00

172 lines
6 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { renderHeatmap } from '../src/heatmap';
import type { HeatmapData } from '../src/types';
function makeMockData(overrides: Partial<HeatmapData> = {}): 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;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
it('renders an SVG element', () => {
renderHeatmap(container, makeMockData());
const svg = container.querySelector('svg');
expect(svg).not.toBeNull();
});
it('renders correct number of rect elements for date range', () => {
renderHeatmap(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', () => {
renderHeatmap(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', () => {
renderHeatmap(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)', () => {
renderHeatmap(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 },
],
});
renderHeatmap(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', () => {
renderHeatmap(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', () => {
renderHeatmap(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', () => {
renderHeatmap(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' },
};
renderHeatmap(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' },
};
renderHeatmap(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' },
};
renderHeatmap(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', () => {
renderHeatmap(container, makeMockData(), undefined, undefined, undefined, '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');
});
});