Previously a 5xx, network blip, or JSON parse error all collapsed into the same silent "no buckets" rendering. The widget still degrades gracefully — failure should not block the document list — but operators and Sentry now see the failure in browser devtools instead of having to reverse-engineer a missing chart. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import {
|
|
monthBoundaryFrom,
|
|
monthBoundaryTo,
|
|
buildMonthSequence,
|
|
fillDensityGaps,
|
|
fetchDensity,
|
|
buildDensityUrl,
|
|
aggregateToYears,
|
|
selectionBoundaryFrom,
|
|
selectionBoundaryTo,
|
|
clipBucketsToRange,
|
|
tickIndicesFor,
|
|
formatTickLabel
|
|
} from './timeline';
|
|
|
|
describe('monthBoundaryFrom', () => {
|
|
it('returns the first day of the given month', () => {
|
|
expect(monthBoundaryFrom('1915-08')).toBe('1915-08-01');
|
|
});
|
|
|
|
it('handles January', () => {
|
|
expect(monthBoundaryFrom('1920-01')).toBe('1920-01-01');
|
|
});
|
|
});
|
|
|
|
describe('monthBoundaryTo', () => {
|
|
it('returns the last day of a 31-day month', () => {
|
|
expect(monthBoundaryTo('1915-08')).toBe('1915-08-31');
|
|
});
|
|
|
|
it('returns the last day of a 30-day month', () => {
|
|
expect(monthBoundaryTo('1915-04')).toBe('1915-04-30');
|
|
});
|
|
|
|
it('returns 28 for February in a non-leap year', () => {
|
|
expect(monthBoundaryTo('1915-02')).toBe('1915-02-28');
|
|
});
|
|
|
|
it('returns 29 for February in a leap year', () => {
|
|
expect(monthBoundaryTo('1916-02')).toBe('1916-02-29');
|
|
});
|
|
});
|
|
|
|
describe('buildMonthSequence', () => {
|
|
it('returns a single month when min and max are in the same month', () => {
|
|
expect(buildMonthSequence('1915-08-03', '1915-08-22')).toEqual(['1915-08']);
|
|
});
|
|
|
|
it('returns months from minDate through maxDate inclusive', () => {
|
|
expect(buildMonthSequence('1915-08-03', '1915-11-15')).toEqual([
|
|
'1915-08',
|
|
'1915-09',
|
|
'1915-10',
|
|
'1915-11'
|
|
]);
|
|
});
|
|
|
|
it('crosses year boundaries correctly', () => {
|
|
expect(buildMonthSequence('1915-11-30', '1916-02-01')).toEqual([
|
|
'1915-11',
|
|
'1915-12',
|
|
'1916-01',
|
|
'1916-02'
|
|
]);
|
|
});
|
|
|
|
it('returns empty array when minDate or maxDate is null', () => {
|
|
expect(buildMonthSequence(null, '1915-08-01')).toEqual([]);
|
|
expect(buildMonthSequence('1915-08-01', null)).toEqual([]);
|
|
expect(buildMonthSequence(null, null)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('fillDensityGaps', () => {
|
|
it('returns empty array when minDate or maxDate is null', () => {
|
|
expect(fillDensityGaps([], null, null)).toEqual([]);
|
|
});
|
|
|
|
it('preserves existing buckets and adds zero-count buckets for missing months', () => {
|
|
const buckets = [
|
|
{ month: '1915-08', count: 5 },
|
|
{ month: '1915-11', count: 2 }
|
|
];
|
|
|
|
const result = fillDensityGaps(buckets, '1915-08-03', '1915-11-30');
|
|
|
|
expect(result).toEqual([
|
|
{ month: '1915-08', count: 5 },
|
|
{ month: '1915-09', count: 0 },
|
|
{ month: '1915-10', count: 0 },
|
|
{ month: '1915-11', count: 2 }
|
|
]);
|
|
});
|
|
|
|
it('returns all-zero sequence when buckets array is empty', () => {
|
|
const result = fillDensityGaps([], '1915-08-03', '1915-10-15');
|
|
|
|
expect(result).toEqual([
|
|
{ month: '1915-08', count: 0 },
|
|
{ month: '1915-09', count: 0 },
|
|
{ month: '1915-10', count: 0 }
|
|
]);
|
|
});
|
|
|
|
it('keeps results sorted chronologically even when buckets arrive out of order', () => {
|
|
const buckets = [
|
|
{ month: '1915-10', count: 3 },
|
|
{ month: '1915-08', count: 1 }
|
|
];
|
|
|
|
const result = fillDensityGaps(buckets, '1915-08-01', '1915-10-31');
|
|
|
|
expect(result.map((b) => b.month)).toEqual(['1915-08', '1915-09', '1915-10']);
|
|
});
|
|
});
|
|
|
|
describe('aggregateToYears', () => {
|
|
it('returns empty array for empty input', () => {
|
|
expect(aggregateToYears([])).toEqual([]);
|
|
});
|
|
|
|
it('sums counts within the same year', () => {
|
|
const result = aggregateToYears([
|
|
{ month: '1915-08', count: 5 },
|
|
{ month: '1915-09', count: 2 },
|
|
{ month: '1915-10', count: 8 }
|
|
]);
|
|
expect(result).toEqual([{ month: '1915', count: 15 }]);
|
|
});
|
|
|
|
it('produces one bucket per distinct year, sorted chronologically', () => {
|
|
const result = aggregateToYears([
|
|
{ month: '1916-01', count: 3 },
|
|
{ month: '1915-08', count: 5 },
|
|
{ month: '1916-04', count: 7 },
|
|
{ month: '1914-12', count: 1 }
|
|
]);
|
|
expect(result).toEqual([
|
|
{ month: '1914', count: 1 },
|
|
{ month: '1915', count: 5 },
|
|
{ month: '1916', count: 10 }
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('clipBucketsToRange', () => {
|
|
const buckets = [
|
|
{ month: '1915-08', count: 5 },
|
|
{ month: '1915-09', count: 2 },
|
|
{ month: '1915-10', count: 8 },
|
|
{ month: '1915-11', count: 3 }
|
|
];
|
|
|
|
it('returns the original buckets when range bounds are null', () => {
|
|
expect(clipBucketsToRange(buckets, null, null)).toBe(buckets);
|
|
});
|
|
|
|
it('keeps only buckets whose month falls within the range', () => {
|
|
expect(clipBucketsToRange(buckets, '1915-09-01', '1915-10-31')).toEqual([
|
|
{ month: '1915-09', count: 2 },
|
|
{ month: '1915-10', count: 8 }
|
|
]);
|
|
});
|
|
|
|
it('returns an empty array when the range excludes everything', () => {
|
|
expect(clipBucketsToRange(buckets, '1916-01-01', '1916-12-31')).toEqual([]);
|
|
});
|
|
|
|
it('treats partial dates correctly when bounds cross month boundaries', () => {
|
|
expect(clipBucketsToRange(buckets, '1915-09-15', '1915-10-15')).toEqual([
|
|
{ month: '1915-09', count: 2 },
|
|
{ month: '1915-10', count: 8 }
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('selectionBoundaryFrom / To', () => {
|
|
it('handles month labels (YYYY-MM)', () => {
|
|
expect(selectionBoundaryFrom('1915-08')).toBe('1915-08-01');
|
|
expect(selectionBoundaryTo('1915-08')).toBe('1915-08-31');
|
|
});
|
|
|
|
it('handles year labels (YYYY)', () => {
|
|
expect(selectionBoundaryFrom('1915')).toBe('1915-01-01');
|
|
expect(selectionBoundaryTo('1915')).toBe('1915-12-31');
|
|
});
|
|
});
|
|
|
|
describe('buildDensityUrl', () => {
|
|
it('returns the bare endpoint when no filters provided', () => {
|
|
expect(buildDensityUrl()).toBe('/api/documents/density');
|
|
});
|
|
|
|
it('forwards single-value filters as query params', () => {
|
|
expect(buildDensityUrl({ q: 'Brief', senderId: 's-1' })).toBe(
|
|
'/api/documents/density?q=Brief&senderId=s-1'
|
|
);
|
|
});
|
|
|
|
it('repeats the tag param for multi-value tag filters', () => {
|
|
const url = buildDensityUrl({ tags: ['Familie', 'Urlaub'], tagOp: 'OR' });
|
|
expect(url).toContain('tag=Familie');
|
|
expect(url).toContain('tag=Urlaub');
|
|
expect(url).toContain('tagOp=OR');
|
|
});
|
|
|
|
it('omits tagOp when it is AND (default on backend)', () => {
|
|
const url = buildDensityUrl({ tags: ['Familie'], tagOp: 'AND' });
|
|
expect(url).not.toContain('tagOp=');
|
|
});
|
|
|
|
it('does not forward from/to even if a caller mistakenly adds them', () => {
|
|
// Intentional: density is the surface for picking from/to, so it must always
|
|
// span the broader space the user is selecting within.
|
|
// @ts-expect-error - from/to are explicitly absent from DensityFilters
|
|
const url = buildDensityUrl({ q: 'Brief', from: '1915-01-01', to: '1916-12-31' });
|
|
expect(url).not.toContain('from=');
|
|
expect(url).not.toContain('to=');
|
|
});
|
|
});
|
|
|
|
describe('fetchDensity', () => {
|
|
it('skips fetch and returns null density on mobile', async () => {
|
|
const fetch = vi.fn();
|
|
|
|
const result = await fetchDensity(fetch, null, false);
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ density: null, minDate: null, maxDate: null });
|
|
});
|
|
|
|
it('skips fetch when view is calendar', async () => {
|
|
const fetch = vi.fn();
|
|
|
|
const result = await fetchDensity(fetch, 'calendar', true);
|
|
|
|
expect(fetch).not.toHaveBeenCalled();
|
|
expect(result).toEqual({ density: null, minDate: null, maxDate: null });
|
|
});
|
|
|
|
it('calls /api/documents/density and returns body on desktop, list view', async () => {
|
|
const fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
buckets: [{ month: '1915-08', count: 3 }],
|
|
minDate: '1915-08-01',
|
|
maxDate: '1916-12-31'
|
|
})
|
|
});
|
|
|
|
const result = await fetchDensity(fetch, null, true);
|
|
|
|
expect(fetch).toHaveBeenCalledWith('/api/documents/density');
|
|
expect(result.density).toEqual([{ month: '1915-08', count: 3 }]);
|
|
expect(result.minDate).toBe('1915-08-01');
|
|
expect(result.maxDate).toBe('1916-12-31');
|
|
});
|
|
|
|
it('forwards active filters as query params', async () => {
|
|
const fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ buckets: [], minDate: null, maxDate: null })
|
|
});
|
|
|
|
await fetchDensity(fetch, null, true, { senderId: 's-1', tags: ['Familie'] });
|
|
|
|
const calledWith = fetch.mock.calls[0][0] as string;
|
|
expect(calledWith).toContain('senderId=s-1');
|
|
expect(calledWith).toContain('tag=Familie');
|
|
});
|
|
|
|
it('returns empty density and null bounds when the API responds non-ok', async () => {
|
|
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
|
|
|
const result = await fetchDensity(fetch, null, true);
|
|
|
|
expect(result).toEqual({ density: [], minDate: null, maxDate: null });
|
|
});
|
|
|
|
it('treats fetch rejection as a graceful degradation, not an error', async () => {
|
|
const fetch = vi.fn().mockRejectedValue(new TypeError('Network down'));
|
|
|
|
const result = await fetchDensity(fetch, null, true);
|
|
|
|
expect(result).toEqual({ density: [], minDate: null, maxDate: null });
|
|
});
|
|
|
|
it('emits console.warn with the status when the response is non-ok', async () => {
|
|
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
await fetchDensity(fetch, null, true);
|
|
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0][0]).toContain('503');
|
|
warn.mockRestore();
|
|
});
|
|
|
|
it('emits console.warn with the caught error when fetch rejects', async () => {
|
|
const error = new TypeError('Network down');
|
|
const fetch = vi.fn().mockRejectedValue(error);
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
await fetchDensity(fetch, null, true);
|
|
|
|
expect(warn).toHaveBeenCalledTimes(1);
|
|
expect(warn.mock.calls[0]).toContain(error);
|
|
warn.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('tickIndicesFor', () => {
|
|
it('returns no indices for an empty bucket list', () => {
|
|
expect(tickIndicesFor([])).toEqual([]);
|
|
});
|
|
|
|
it('picks years divisible by 25 when the year span exceeds 120', () => {
|
|
const buckets = Array.from({ length: 150 }, (_, i) => ({
|
|
month: String(1875 + i),
|
|
count: 1
|
|
}));
|
|
const ticks = tickIndicesFor(buckets);
|
|
const labels = ticks.map((i) => buckets[i].month);
|
|
expect(labels).toEqual(['1875', '1900', '1925', '1950', '1975', '2000']);
|
|
});
|
|
|
|
it('picks years divisible by 10 for medium ranges (~50 years)', () => {
|
|
const buckets = Array.from({ length: 50 }, (_, i) => ({
|
|
month: String(1900 + i),
|
|
count: 1
|
|
}));
|
|
const ticks = tickIndicesFor(buckets);
|
|
const labels = ticks.map((i) => buckets[i].month);
|
|
expect(labels).toEqual(['1900', '1910', '1920', '1930', '1940']);
|
|
});
|
|
|
|
it('picks January boundaries for long month ranges', () => {
|
|
const buckets = [
|
|
{ month: '1914-08', count: 1 },
|
|
{ month: '1914-09', count: 1 },
|
|
{ month: '1914-10', count: 1 },
|
|
{ month: '1914-11', count: 1 },
|
|
{ month: '1914-12', count: 1 },
|
|
{ month: '1915-01', count: 1 },
|
|
{ month: '1915-02', count: 1 },
|
|
{ month: '1915-03', count: 1 },
|
|
{ month: '1915-04', count: 1 },
|
|
{ month: '1915-05', count: 1 },
|
|
{ month: '1915-06', count: 1 },
|
|
{ month: '1915-07', count: 1 },
|
|
{ month: '1915-08', count: 1 },
|
|
{ month: '1915-09', count: 1 },
|
|
{ month: '1915-10', count: 1 },
|
|
{ month: '1915-11', count: 1 },
|
|
{ month: '1915-12', count: 1 },
|
|
{ month: '1916-01', count: 1 },
|
|
{ month: '1916-02', count: 1 }
|
|
];
|
|
const ticks = tickIndicesFor(buckets);
|
|
expect(ticks.map((i) => buckets[i].month)).toEqual(['1915-01', '1916-01']);
|
|
});
|
|
|
|
it('falls back to evenly spaced ticks for short month ranges (12 months)', () => {
|
|
const buckets = Array.from({ length: 12 }, (_, i) => ({
|
|
month: `1905-${String(i + 1).padStart(2, '0')}`,
|
|
count: 1
|
|
}));
|
|
const ticks = tickIndicesFor(buckets);
|
|
expect(ticks.length).toBeGreaterThanOrEqual(5);
|
|
expect(ticks.length).toBeLessThanOrEqual(7);
|
|
expect(ticks[0]).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('formatTickLabel', () => {
|
|
it('returns the year string unchanged for year labels', () => {
|
|
expect(formatTickLabel('1905', 'en-US')).toBe('1905');
|
|
});
|
|
|
|
it('formats month labels with the year by default', () => {
|
|
const result = formatTickLabel('1905-06', 'en-US');
|
|
expect(result).toMatch(/Jun/);
|
|
expect(result).toMatch(/1905/);
|
|
});
|
|
|
|
it('omits the year when omitYear is true', () => {
|
|
const result = formatTickLabel('1905-06', 'en-US', true);
|
|
expect(result).toMatch(/Jun/);
|
|
expect(result).not.toMatch(/1905/);
|
|
});
|
|
});
|