openclaw/apps/web/app/components/workspace/object-timeline.test.ts

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);
});
});