198 lines
8.1 KiB
TypeScript
198 lines
8.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { WorkTimelineResult } from "@paperclipai/shared";
|
|
import { chooseTickStepMs, computeLayout, issueColor, shortLabel, type LayoutOptions } from "./layout";
|
|
|
|
const DAY = "2026-07-02";
|
|
const t = (hhmm: string) => `${DAY}T${hhmm}:00.000Z`;
|
|
|
|
// A trimmed version of the board's locked-design sample (PAP-12422), expressed
|
|
// in the real endpoint contract: actor→actor edges rather than run→run.
|
|
function sample(): WorkTimelineResult {
|
|
const actors = [
|
|
{ id: "user:dotta", type: "user" as const, name: "dotta" },
|
|
{ id: "agent:ceo", type: "agent" as const, name: "CEO" },
|
|
{ id: "agent:cto", type: "agent" as const, name: "CTO Architect" },
|
|
{ id: "agent:ux", type: "agent" as const, name: "UXDesigner" },
|
|
{ id: "agent:senior", type: "agent" as const, name: "SeniorEngineer" },
|
|
{ id: "agent:codex", type: "agent" as const, name: "CodexCoder" },
|
|
{ id: "agent:qa", type: "agent" as const, name: "QA" },
|
|
{ id: "system:routine", type: "system" as const, name: "Circleback" },
|
|
];
|
|
const span = (
|
|
runId: string,
|
|
actorId: string,
|
|
issueId: string,
|
|
identifier: string,
|
|
start: string,
|
|
end: string | null,
|
|
status: string,
|
|
retryOfRunId: string | null = null,
|
|
): WorkTimelineResult["spans"][number] => ({
|
|
actorId,
|
|
laneHint: null,
|
|
runId,
|
|
issueId,
|
|
issueIdentifier: identifier,
|
|
issueTitle: `${identifier} title`,
|
|
start: t(start),
|
|
end: end ? t(end) : null,
|
|
status,
|
|
retryOfRunId,
|
|
});
|
|
const spans = [
|
|
span("r1", "agent:ceo", "i-405", "PAP-12405", "09:02", "09:10", "completed"),
|
|
span("r2", "agent:cto", "i-405", "PAP-12405", "09:14", "09:52", "completed"),
|
|
span("r3", "agent:senior", "i-075", "PAP-12075", "09:58", "10:20", "completed"),
|
|
span("r4", "agent:ux", "i-422", "PAP-12422", "10:10", "11:34", "running"),
|
|
span("r5", "agent:codex", "i-423", "PAP-12423", "10:10", "10:56", "completed"),
|
|
span("r6", "agent:codex", "i-075", "PAP-12075", "10:22", "11:08", "completed"), // overlaps r5 → sub-lane
|
|
span("r7", "agent:qa", "i-423", "PAP-12423", "11:40", "12:12", "completed"),
|
|
span("r8", "agent:codex", "i-423", "PAP-12423", "12:20", "12:38", "completed", "r5"), // retry
|
|
span("r9", "system:routine", "i-286", "PAP-12286", "20:00", "20:04", "completed"),
|
|
];
|
|
const edge = (from: string, to: string, issueId: string, at: string): WorkTimelineResult["edges"][number] => ({
|
|
fromActorId: from,
|
|
toActorId: to,
|
|
issueId,
|
|
at: t(at),
|
|
kind: "delegation",
|
|
});
|
|
const edges = [
|
|
edge("user:dotta", "agent:ceo", "i-405", "09:01"), // human kickoff → chip only, no line
|
|
edge("agent:ceo", "agent:cto", "i-405", "09:13"),
|
|
edge("agent:cto", "agent:ux", "i-422", "10:09"),
|
|
edge("agent:cto", "agent:codex", "i-423", "10:09"),
|
|
edge("agent:senior", "agent:codex", "i-075", "10:21"),
|
|
edge("agent:codex", "agent:qa", "i-423", "11:39"),
|
|
edge("agent:qa", "agent:codex", "i-423", "12:19"), // → retry r8 (dashed)
|
|
];
|
|
return {
|
|
actors,
|
|
spans,
|
|
events: [],
|
|
edges,
|
|
pagination: { limit: 200, offset: 0, totalIssues: 6, hasMore: false },
|
|
window: { from: t("09:00"), to: t("20:15"), capped: false },
|
|
};
|
|
}
|
|
|
|
const OPTS: LayoutOptions = {
|
|
pxPerMinute: 1.7,
|
|
gutter: 172,
|
|
rowH: 34,
|
|
barH: 15,
|
|
laneGap: 4,
|
|
nowMs: new Date(t("13:00")).getTime(),
|
|
};
|
|
|
|
describe("computeLayout", () => {
|
|
it("excludes humans from rows but keeps agents and system actors", () => {
|
|
const layout = computeLayout(sample(), OPTS);
|
|
const rowActorTypes = layout.rows.map((r) => r.actor.type);
|
|
expect(rowActorTypes).not.toContain("user");
|
|
expect(layout.rows.map((r) => r.actor.id)).toContain("system:routine");
|
|
expect(layout.rows).toHaveLength(7); // 6 agents + 1 system
|
|
});
|
|
|
|
it("orders rows by first activity", () => {
|
|
const layout = computeLayout(sample(), OPTS);
|
|
expect(layout.rows[0].actor.id).toBe("agent:ceo");
|
|
expect(layout.rows[layout.rows.length - 1].actor.id).toBe("system:routine");
|
|
});
|
|
|
|
it("packs overlapping runs into concurrency sub-lanes", () => {
|
|
const layout = computeLayout(sample(), OPTS);
|
|
const codex = layout.rows.find((r) => r.actor.id === "agent:codex")!;
|
|
expect(codex.laneCount).toBe(2); // r5 and r6 overlap
|
|
const lanesTop = new Set(codex.bars.map((b) => b.yTop));
|
|
expect(lanesTop.size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("derives the kickoff actor for a run (incl. human) from edges", () => {
|
|
const layout = computeLayout(sample(), OPTS);
|
|
const codexApi = layout.rows
|
|
.find((r) => r.actor.id === "agent:codex")!
|
|
.bars.find((b) => b.span.runId === "r5")!;
|
|
expect(codexApi.kickoff?.id).toBe("agent:cto");
|
|
|
|
const ceoRun = layout.rows.find((r) => r.actor.id === "agent:ceo")!.bars[0];
|
|
expect(ceoRun.kickoff?.id).toBe("user:dotta"); // human kickoff shown as chip
|
|
});
|
|
|
|
it("draws agent→agent connectors only, dashing retries, never from a human", () => {
|
|
const layout = computeLayout(sample(), OPTS);
|
|
// 6 agent→agent edges resolve to bars; the dotta→ceo human edge draws no line.
|
|
expect(layout.connectors.length).toBe(6);
|
|
const dashed = layout.connectors.filter((c) => c.dashed);
|
|
expect(dashed).toHaveLength(1); // only the QA→codex retry hop
|
|
// every connector is left-to-right (source trailing edge → target leading edge)
|
|
for (const c of layout.connectors) expect(c.x2).toBeGreaterThanOrEqual(c.x1 - 1);
|
|
});
|
|
|
|
it("extends in-progress runs to now and clamps sub-minute bars", () => {
|
|
const layout = computeLayout(sample(), OPTS);
|
|
const running = layout.rows
|
|
.find((r) => r.actor.id === "agent:ux")!
|
|
.bars.find((b) => b.span.runId === "r4")!;
|
|
expect(running.running).toBe(true);
|
|
expect(running.x2).toBeGreaterThan(running.x1 + 3);
|
|
});
|
|
|
|
it("produces a deterministic issue hue + legend", () => {
|
|
const layout = computeLayout(sample(), OPTS);
|
|
expect(layout.issues.length).toBe(5); // i-405, i-075, i-422, i-423, i-286
|
|
expect(issueColor("i-405")).toBe(issueColor("i-405"));
|
|
});
|
|
});
|
|
|
|
describe("human activity markers", () => {
|
|
const withUserEvents = (): WorkTimelineResult => ({
|
|
...sample(),
|
|
events: [
|
|
{ actorId: "user:dotta", kind: "created", issueId: "i-405", at: t("09:00") },
|
|
{ actorId: "user:dotta", kind: "commented", issueId: "i-422", at: t("11:00") },
|
|
{ actorId: "user:dotta", kind: "approved", issueId: "i-423", at: t("12:15") },
|
|
],
|
|
});
|
|
|
|
it("gives a human actor a row driven purely by their in-window events", () => {
|
|
const layout = computeLayout(withUserEvents(), OPTS);
|
|
const dotta = layout.rows.find((r) => r.actor.id === "user:dotta");
|
|
expect(dotta).toBeDefined();
|
|
expect(dotta!.actor.type).toBe("user");
|
|
expect(dotta!.bars).toHaveLength(0); // humans have no runs
|
|
});
|
|
|
|
it("positions instant markers at x(event.at) on the owning row, time-ordered", () => {
|
|
const layout = computeLayout(withUserEvents(), OPTS);
|
|
const dotta = layout.rows.find((r) => r.actor.id === "user:dotta")!;
|
|
expect(dotta.markers).toHaveLength(3);
|
|
expect(dotta.markers.map((m) => m.event.kind)).toEqual(["created", "commented", "approved"]);
|
|
const xs = dotta.markers.map((m) => m.x);
|
|
expect(xs).toEqual([...xs].sort((a, b) => a - b)); // monotonic in time
|
|
for (const m of dotta.markers) expect(m.yc).toBeCloseTo(dotta.y + dotta.h / 2);
|
|
});
|
|
|
|
it("keeps humans marker-only: no run bars, no connectors target them", () => {
|
|
const layout = computeLayout(withUserEvents(), OPTS);
|
|
// dotta→ceo human kickoff still draws no line; agent→agent count unchanged.
|
|
expect(layout.connectors.length).toBe(6);
|
|
});
|
|
|
|
it("still excludes a human with no in-window events", () => {
|
|
const layout = computeLayout(sample(), OPTS); // events: []
|
|
expect(layout.rows.map((r) => r.actor.type)).not.toContain("user");
|
|
});
|
|
});
|
|
|
|
describe("helpers", () => {
|
|
it("shortLabel builds 2-char initials", () => {
|
|
expect(shortLabel("CodexCoder")).toBe("CO");
|
|
expect(shortLabel("CTO Architect")).toBe("CA");
|
|
});
|
|
|
|
it("chooseTickStepMs grows the step as the view zooms out", () => {
|
|
expect(chooseTickStepMs(6)).toBeLessThan(chooseTickStepMs(0.4));
|
|
});
|
|
});
|