191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
parseISO, differenceInDays, addDays, subMonths, addMonths,
|
|
eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval,
|
|
} from "date-fns";
|
|
|
|
/**
|
|
* Pure timeline computation extracted for testing.
|
|
* Mirrors the logic inside ObjectTimeline.
|
|
*/
|
|
|
|
type TimelineZoom = "day" | "week" | "month" | "quarter";
|
|
|
|
function getTimelineBounds(
|
|
items: { startDate: Date; endDate: Date }[],
|
|
zoom: TimelineZoom,
|
|
): { start: Date; end: Date } {
|
|
if (items.length === 0) {
|
|
const now = new Date();
|
|
return { start: subMonths(now, 1), end: addMonths(now, 2) };
|
|
}
|
|
const earliest = new Date(Math.min(...items.map((i) => i.startDate.getTime())));
|
|
const latest = new Date(Math.max(...items.map((i) => i.endDate.getTime())));
|
|
const paddingDays = zoom === "day" ? 3 : zoom === "week" ? 7 : zoom === "month" ? 14 : 30;
|
|
return {
|
|
start: addDays(earliest, -paddingDays),
|
|
end: addDays(latest, paddingDays),
|
|
};
|
|
}
|
|
|
|
function dateToX(date: Date, timelineStart: Date, dayWidth: number): number {
|
|
return differenceInDays(date, timelineStart) * dayWidth;
|
|
}
|
|
|
|
function getHeaderTicks(
|
|
start: Date,
|
|
end: Date,
|
|
zoom: TimelineZoom,
|
|
): Date[] {
|
|
switch (zoom) {
|
|
case "day":
|
|
return eachDayOfInterval({ start, end });
|
|
case "week":
|
|
return eachWeekOfInterval({ start, end }, { weekStartsOn: 1 });
|
|
case "month":
|
|
return eachMonthOfInterval({ start, end });
|
|
case "quarter":
|
|
return eachMonthOfInterval({ start, end }).filter((d) => d.getMonth() % 3 === 0);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Timeline bounds
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getTimelineBounds", () => {
|
|
it("provides sensible defaults for empty item list (prevents zero-width timeline)", () => {
|
|
const { start, end } = getTimelineBounds([], "week");
|
|
expect(end > start).toBe(true);
|
|
const span = differenceInDays(end, start);
|
|
expect(span).toBeGreaterThan(30);
|
|
});
|
|
|
|
it("adds padding around the earliest and latest dates", () => {
|
|
const items = [
|
|
{ startDate: parseISO("2026-03-10"), endDate: parseISO("2026-03-20") },
|
|
];
|
|
const { start, end } = getTimelineBounds(items, "week");
|
|
expect(start < items[0].startDate).toBe(true);
|
|
expect(end > items[0].endDate).toBe(true);
|
|
});
|
|
|
|
it("uses zoom-appropriate padding (day zoom = tight, quarter zoom = wide)", () => {
|
|
const items = [
|
|
{ startDate: parseISO("2026-06-01"), endDate: parseISO("2026-06-30") },
|
|
];
|
|
const dayBounds = getTimelineBounds(items, "day");
|
|
const quarterBounds = getTimelineBounds(items, "quarter");
|
|
const dayPadding = differenceInDays(items[0].startDate, dayBounds.start);
|
|
const quarterPadding = differenceInDays(items[0].startDate, quarterBounds.start);
|
|
expect(quarterPadding).toBeGreaterThan(dayPadding);
|
|
});
|
|
|
|
it("handles single-day items (start === end causes zero-width bar without padding)", () => {
|
|
const items = [
|
|
{ startDate: parseISO("2026-03-15"), endDate: parseISO("2026-03-15") },
|
|
];
|
|
const { start, end } = getTimelineBounds(items, "week");
|
|
expect(differenceInDays(end, start)).toBeGreaterThan(7);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Date-to-pixel conversion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("dateToX", () => {
|
|
const start = parseISO("2026-03-01");
|
|
|
|
it("returns 0 for the timeline start date", () => {
|
|
expect(dateToX(start, start, 30)).toBe(0);
|
|
});
|
|
|
|
it("scales linearly with day count", () => {
|
|
expect(dateToX(addDays(start, 10), start, 30)).toBe(300);
|
|
});
|
|
|
|
it("produces different widths at different zoom levels (zoom changes dayWidth)", () => {
|
|
const d = addDays(start, 7);
|
|
expect(dateToX(d, start, 80)).toBe(560); // day zoom
|
|
expect(dateToX(d, start, 30)).toBe(210); // week zoom
|
|
expect(dateToX(d, start, 10)).toBe(70); // month zoom
|
|
expect(dateToX(d, start, 4)).toBe(28); // quarter zoom
|
|
});
|
|
|
|
it("returns negative for dates before timeline start (used for clipping)", () => {
|
|
const before = addDays(start, -5);
|
|
expect(dateToX(before, start, 30)).toBe(-150);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Header tick generation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("getHeaderTicks", () => {
|
|
const start = parseISO("2026-03-01");
|
|
const end = parseISO("2026-06-30");
|
|
|
|
it("'day' zoom produces one tick per day", () => {
|
|
const shortEnd = addDays(start, 6);
|
|
const ticks = getHeaderTicks(start, shortEnd, "day");
|
|
expect(ticks.length).toBe(7);
|
|
});
|
|
|
|
it("'week' zoom produces one tick per week", () => {
|
|
const ticks = getHeaderTicks(start, end, "week");
|
|
const expectedWeeks = Math.ceil(differenceInDays(end, start) / 7);
|
|
expect(ticks.length).toBeGreaterThanOrEqual(expectedWeeks - 1);
|
|
expect(ticks.length).toBeLessThanOrEqual(expectedWeeks + 1);
|
|
});
|
|
|
|
it("'month' zoom produces one tick per month", () => {
|
|
const ticks = getHeaderTicks(start, end, "month");
|
|
expect(ticks.length).toBe(4); // Mar, Apr, May, Jun
|
|
});
|
|
|
|
it("'quarter' zoom only includes quarter-start months (Jan, Apr, Jul, Oct)", () => {
|
|
const yearStart = parseISO("2026-01-01");
|
|
const yearEnd = parseISO("2026-12-31");
|
|
const ticks = getHeaderTicks(yearStart, yearEnd, "quarter");
|
|
expect(ticks.length).toBe(4);
|
|
for (const tick of ticks) {
|
|
expect(tick.getMonth() % 3).toBe(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bar width computation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("timeline bar positioning", () => {
|
|
const timelineStart = parseISO("2026-03-01");
|
|
const dayWidth = 30;
|
|
|
|
it("bar width equals (endDate - startDate) * dayWidth", () => {
|
|
const item = {
|
|
startDate: parseISO("2026-03-10"),
|
|
endDate: parseISO("2026-03-15"),
|
|
};
|
|
const x = dateToX(item.startDate, timelineStart, dayWidth);
|
|
const w = dateToX(item.endDate, timelineStart, dayWidth) - x;
|
|
expect(x).toBe(9 * 30);
|
|
expect(w).toBe(5 * 30);
|
|
});
|
|
|
|
it("minimum bar width prevents invisible zero-width bars", () => {
|
|
const item = {
|
|
startDate: parseISO("2026-03-10"),
|
|
endDate: parseISO("2026-03-10"), // same day
|
|
};
|
|
const x = dateToX(item.startDate, timelineStart, dayWidth);
|
|
const rawW = dateToX(item.endDate, timelineStart, dayWidth) - x;
|
|
const minW = dayWidth * 0.5;
|
|
const w = Math.max(rawW, minW);
|
|
expect(w).toBe(minW); // zero-width gets minimum
|
|
expect(w).toBeGreaterThan(0);
|
|
});
|
|
});
|