Relocate the 10 pure helpers (monthBoundaryFrom/To, buildMonthSequence, fillDensityGaps, clipBucketsToRange, aggregateToYears, selectionBoundaryFrom/To, tickIndicesFor, formatTickLabel) and their unit tests out of document/timeline.ts into a shared module so lib/timeline/ can consume them without importing lib/document/. The /api/documents/density glue (buildDensityUrl, fetchDensity, DensityState, DensityFilters) stays in document/timeline.ts. Re-point the three density components and the density-filter spec at the shared module. Refs #779 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
4.2 KiB
TypeScript
126 lines
4.2 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { fetchDensity, buildDensityUrl } from './timeline';
|
|
|
|
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();
|
|
});
|
|
});
|