Files
paliad/frontend/src/client/views/shape-timeline-chart.test.ts
mAi ed4e731333 feat(t-paliad-177): chart layout() pure-function + 27 table-driven tests
Slice 1 load-bearing math. Translates TimelineEvent[] + LaneInfo[] +
viewport into deterministic SVG-ready geometry: axis ticks (month /
quarter / year by total span), lane row y/height, mark x/y/shape per
kind+status, today rule. No DOM access — paint() will read this and
mutate the SVG separately.

Tests pin canvas geometry, pxPerDay math, today-rule clipping, lane
stacking, mark bucketing by lane_id, out-of-range clipping, undated
zone, mark-shape mapping, axis tick density. Date math is UTC
throughout so DST doesn't drift day-deltas.

Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
2026-05-12 14:07:48 +02:00

255 lines
9.7 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { layout, type ChartViewport } from "./shape-timeline-chart";
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// t-paliad-177 Slice 1 — table-driven tests for the pure `layout()`
// function. `layout` translates a TimelineEvent[] + LaneInfo[] + viewport
// into deterministic SVG-ready geometry. Tests pin the math so subtle
// drift (off-by-one days, axis tick density, lane stacking) surfaces fast.
//
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
const vp = (overrides: Partial<ChartViewport> = {}): ChartViewport => ({
width: 1000,
height: 400,
laneLabelWidth: 200,
dateAxisHeight: 40,
todayISO: "2026-06-15",
rangeFrom: "2026-01-01",
rangeTo: "2026-12-31",
density: "standard",
...overrides,
});
const ev = (overrides: Partial<TimelineEvent> = {}): TimelineEvent => ({
kind: "deadline",
status: "open",
track: "parent",
date: "2026-06-15",
title: "Test event",
...overrides,
});
describe("layout — base geometry", () => {
test("chart canvas sits to the right of lane labels and below date axis", () => {
const out = layout([], [], vp());
expect(out.chartLeft).toBe(200);
expect(out.chartTop).toBe(40);
expect(out.chartWidth).toBe(800);
expect(out.chartHeight).toBeGreaterThan(0);
});
test("pxPerDay = chartWidth / total_days", () => {
// 2026 is 365 days; range Jan 1..Dec 31 is 364 day-deltas + 1 = 365 days.
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
expect(out.pxPerDay).toBeCloseTo(800 / 364, 5);
});
test("invalid range (to before from) falls back to a 1-day span", () => {
const out = layout([], [], vp({ rangeFrom: "2026-06-01", rangeTo: "2026-05-01" }));
// Sanity: pxPerDay finite, no division-by-zero.
expect(Number.isFinite(out.pxPerDay)).toBe(true);
expect(out.pxPerDay).toBeGreaterThan(0);
});
});
describe("layout — today rule", () => {
test("today inside range produces a non-null todayX in the chart canvas", () => {
const out = layout([], [], vp({ todayISO: "2026-06-15" }));
expect(out.todayX).not.toBeNull();
expect(out.todayX!).toBeGreaterThan(out.chartLeft);
expect(out.todayX!).toBeLessThan(out.chartLeft + out.chartWidth);
});
test("today before range.from → todayX is null", () => {
const out = layout([], [], vp({ todayISO: "2025-12-15" }));
expect(out.todayX).toBeNull();
});
test("today after range.to → todayX is null", () => {
const out = layout([], [], vp({ todayISO: "2027-01-15" }));
expect(out.todayX).toBeNull();
});
test("today equals range.from → todayX sits at chartLeft", () => {
const out = layout([], [], vp({ todayISO: "2026-01-01" }));
expect(out.todayX).toBeCloseTo(out.chartLeft, 1);
});
});
describe("layout — lane stacking", () => {
test("empty lanes synthesises a single 'self' lane", () => {
const out = layout([], [], vp());
expect(out.laneRows).toHaveLength(1);
expect(out.laneRows[0].id).toBe("self");
});
test("multiple lanes stack vertically in input order", () => {
const lanes: LaneInfo[] = [
{ id: "self", label: "Hauptverfahren" },
{ id: "counterclaim:abc", label: "Widerklage" },
{ id: "parent_context:xyz", label: "Parent" },
];
const out = layout([], lanes, vp());
expect(out.laneRows).toHaveLength(3);
expect(out.laneRows[0].y).toBe(out.chartTop);
expect(out.laneRows[1].y).toBeGreaterThan(out.laneRows[0].y);
expect(out.laneRows[2].y).toBeGreaterThan(out.laneRows[1].y);
// All same height.
expect(out.laneRows[0].height).toBe(out.laneRows[1].height);
expect(out.laneRows[1].height).toBe(out.laneRows[2].height);
});
test("density compact gives smaller lane height than spacious", () => {
const compact = layout([], [], vp({ density: "compact" }));
const spacious = layout([], [], vp({ density: "spacious" }));
expect(compact.laneRows[0].height).toBeLessThan(spacious.laneRows[0].height);
});
});
describe("layout — marks", () => {
test("single deadline maps to one mark in the self lane", () => {
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
const out = layout(events, [], vp());
expect(out.marks).toHaveLength(1);
expect(out.marks[0].eventIndex).toBe(0);
expect(out.marks[0].laneId).toBe("self");
expect(out.marks[0].undated).toBe(false);
});
test("event's x position matches its date offset from range.from", () => {
// June 15 is day 165 of 2026 (0-indexed from Jan 1).
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
const expectedX = out.chartLeft + 165 * out.pxPerDay;
expect(out.marks[0].x).toBeCloseTo(expectedX, 1);
});
test("event bucketed by lane_id matches the corresponding lane row", () => {
const lanes: LaneInfo[] = [
{ id: "self", label: "Self" },
{ id: "ccr", label: "CCR" },
];
const events: TimelineEvent[] = [
ev({ date: "2026-06-15", lane_id: "ccr" }),
];
const out = layout(events, lanes, vp());
const ccrRow = out.laneRows.find((r) => r.id === "ccr")!;
expect(out.marks[0].laneId).toBe("ccr");
expect(out.marks[0].y).toBeCloseTo(ccrRow.y + ccrRow.height / 2, 1);
});
test("unknown lane_id falls back to the first lane (defensive)", () => {
const lanes: LaneInfo[] = [{ id: "self", label: "Self" }];
const events: TimelineEvent[] = [
ev({ date: "2026-06-15", lane_id: "deleted-lane-id" }),
];
const out = layout(events, lanes, vp());
expect(out.marks[0].laneId).toBe("self");
});
test("events outside range are clipped (not emitted)", () => {
const events: TimelineEvent[] = [
ev({ date: "2025-01-01", title: "before" }),
ev({ date: "2026-06-15", title: "inside" }),
ev({ date: "2027-12-31", title: "after" }),
];
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
expect(out.marks).toHaveLength(1);
expect(out.marks[0].eventIndex).toBe(1);
});
test("undated events go to the undated zone with undated=true", () => {
const events: TimelineEvent[] = [ev({ date: null, title: "court-set" })];
const out = layout(events, [], vp());
expect(out.marks).toHaveLength(1);
expect(out.marks[0].undated).toBe(true);
// Undated marks sit in the lane label gutter (x < chartLeft).
expect(out.marks[0].x).toBeLessThan(out.chartLeft);
});
});
describe("layout — mark shapes by kind+status", () => {
test("deadline.done → dot, deadline.open → dot, deadline.overdue → dot", () => {
const events: TimelineEvent[] = [
ev({ kind: "deadline", status: "done" }),
ev({ kind: "deadline", status: "open" }),
ev({ kind: "deadline", status: "overdue" }),
];
const out = layout(events, [], vp());
expect(out.marks.map((m) => m.shape)).toEqual(["dot", "dot", "dot"]);
});
test("milestone → diamond", () => {
const events: TimelineEvent[] = [ev({ kind: "milestone", status: "done" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("diamond");
});
test("appointment → dot (Slice 1 keeps it simple; bar variant deferred)", () => {
const events: TimelineEvent[] = [ev({ kind: "appointment", status: "open" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("dot");
});
test("projected.predicted → hatched-dot", () => {
const events: TimelineEvent[] = [ev({ kind: "projected", status: "predicted" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("hatched-dot");
});
test("projected.court_set → dashed-dot", () => {
const events: TimelineEvent[] = [ev({ kind: "projected", status: "court_set" })];
const out = layout(events, [], vp());
expect(out.marks[0].shape).toBe("dashed-dot");
});
});
describe("layout — axis ticks", () => {
test("short range (<90d) emits month ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-02-28" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("month")).toBe(true);
});
test("medium range (90-730d) emits quarter ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("quarter")).toBe(true);
});
test("long range (>730d) emits year ticks", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2029-12-31" }));
const kinds = new Set(out.axisTicks.map((t) => t.kind));
expect(kinds.has("year")).toBe(true);
});
test("year-boundary ticks are flagged", () => {
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2027-12-31" }));
const yearBoundaries = out.axisTicks.filter((t) => t.isYearBoundary);
expect(yearBoundaries.length).toBeGreaterThanOrEqual(1);
});
test("all ticks fall inside the chart canvas horizontally", () => {
const out = layout([], [], vp());
for (const tick of out.axisTicks) {
expect(tick.x).toBeGreaterThanOrEqual(out.chartLeft - 0.5);
expect(tick.x).toBeLessThanOrEqual(out.chartLeft + out.chartWidth + 0.5);
}
});
});
describe("layout — undated counting", () => {
test("undated marks tallied separately from inside-range count", () => {
const events: TimelineEvent[] = [
ev({ date: "2026-06-15" }),
ev({ date: null }),
ev({ date: null }),
ev({ date: "2025-01-01" }), // out of range
];
const out = layout(events, [], vp());
expect(out.undatedCount).toBe(2);
expect(out.marks).toHaveLength(3); // 1 dated + 2 undated, the out-of-range one is clipped
});
});