import { describe, it, expect } from "vitest"; import { buildBcGraph, nodeDetail, toInvariants, layoutBcGraph, type BcGraph } from "../bc-graph"; import type { Manifest } from "../manifest"; /** A flat (older) manifest — per-kind arrays + `graph.events`, no `model`. */ const flat = { apps: [{ name: "orders" }, { name: "billing" }], actions: [ { name: "orders.place", app: "orders", public: true }, { name: "billing.charge", app: "billing" }, ], events: [ { name: "orders.placed", app: "orders", public: true }, { name: "billing.charged", app: "billing" }, ], queries: [{ name: "orders.list", app: "orders" }], actors: [], workflows: [{ name: "orders.fulfil", app: "orders" }], projections: [{ name: "orders.dashboard", app: "orders" }], graph: { events: [ // orders.placed (orders) → billing.charge (billing): cross-BC. { from: "orders.placed", to: "billing.charge", via: "delivers" }, // intra-BC — must not produce an edge. { from: "orders.place", to: "orders.placed", via: "emits" }, ], }, } as unknown as Manifest; /** A deep manifest — carries `model.edges` over `${kind}:${name}` ids. */ const deep = { apps: [{ name: "orders" }, { name: "billing" }], actions: [ { name: "orders.place", app: "orders" }, { name: "billing.charge", app: "billing" }, ], events: [{ name: "orders.placed", app: "orders" }], queries: [], actors: [], workflows: [], projections: [], model: { nodes: [], edges: [{ from: "event:orders.placed", to: "action:billing.charge", type: "delivers" }], }, // a flat graph that DISAGREES — proves `model.edges` wins when present. graph: { events: [{ from: "orders.placed", to: "orders.place", via: "emits" }] }, } as unknown as Manifest; describe("buildBcGraph", () => { it("returns empty graph for null/undefined", () => { expect(buildBcGraph(null)).toEqual({ bcs: [], edges: [] }); expect(buildBcGraph(undefined)).toEqual({ bcs: [], edges: [] }); }); it("groups members into BC cards by app, sorted", () => { const g = buildBcGraph(flat); expect(g.bcs.map((b) => b.name)).toEqual(["billing", "orders"]); const orders = g.bcs.find((b) => b.name === "orders")!; // 1 action + 1 event + 1 query + 1 workflow + 1 projection expect(orders.rows).toHaveLength(5); expect(orders.rows.map((r) => r.kind).sort()).toEqual([ "action", "event", "projection", "query", "workflow", ]); }); it("carries public flag and stable ids onto rows", () => { const g = buildBcGraph(flat); const place = g.bcs.flatMap((b) => b.rows).find((r) => r.name === "orders.place")!; expect(place.id).toBe("action:orders.place"); expect(place.public).toBe(true); }); it("derives cross-BC edges from graph.events (flat path), skipping intra-BC", () => { const g = buildBcGraph(flat); expect(g.edges).toEqual([{ from: "orders", to: "billing", label: "delivers" }]); }); it("prefers model.edges over graph.events when a deep model is present", () => { const g = buildBcGraph(deep); expect(g.edges).toEqual([{ from: "orders", to: "billing", label: "delivers" }]); }); it("buckets members with no app into (unscoped)", () => { const g = buildBcGraph({ apps: [], actions: [{ name: "loose.thing" }], } as unknown as Manifest); expect(g.bcs.map((b) => b.name)).toContain("(unscoped)"); }); }); describe("nodeDetail", () => { const m = { actions: [ { name: "orders.place", app: "orders", description: "Place an order.", public: true, inputSchema: { type: "object" }, emits: ["orders.placed"], retry: 3, source: { file: "/app/actions.ts", line: 10, column: 2 }, }, ], } as unknown as Manifest; it("resolves a row id to full detail", () => { const d = nodeDetail(m, "action:orders.place")!; expect(d.name).toBe("orders.place"); expect(d.app).toBe("orders"); expect(d.description).toBe("Place an order."); expect(d.public).toBe(true); expect(d.schema).toEqual({ type: "object" }); expect(d.emits).toEqual(["orders.placed"]); expect(d.source).toEqual({ file: "/app/actions.ts", line: 10, column: 2 }); }); it("puts unknown fields into extra, not the typed surface", () => { const d = nodeDetail(m, "action:orders.place")!; expect(d.extra.retry).toBe(3); expect(d.extra.name).toBeUndefined(); expect(d.extra.inputSchema).toBeUndefined(); }); it("returns null for unknown kind, missing entry, bad id, or no manifest", () => { expect(nodeDetail(m, "nope:orders.place")).toBeNull(); expect(nodeDetail(m, "action:does.not.exist")).toBeNull(); expect(nodeDetail(m, "noseparator")).toBeNull(); expect(nodeDetail(null, "action:orders.place")).toBeNull(); expect(nodeDetail(m, null)).toBeNull(); }); it("surfaces invariants as a typed field, not in extra", () => { const withRules = { actions: [ { name: "mod.approve", app: "mod", invariants: [ { rule: '(i) => i.by.length > 0 || "Needs a moderator"', message: "Needs a moderator" }, { rule: "(i) => i.ok" }, ], }, ], } as unknown as Manifest; const d = nodeDetail(withRules, "action:mod.approve")!; expect(d.invariants).toEqual([ { rule: '(i) => i.by.length > 0 || "Needs a moderator"', message: "Needs a moderator" }, { rule: "(i) => i.ok" }, ]); expect(d.extra.invariants).toBeUndefined(); }); }); describe("toInvariants", () => { it("normalizes well-formed entries and drops junk", () => { expect( toInvariants([{ rule: "a", message: "A" }, { rule: "b" }, { rule: 123 }, "nope", null]), ).toEqual([{ rule: "a", message: "A" }, { rule: "b" }]); }); it("returns undefined for non-arrays and empty results", () => { expect(toInvariants(undefined)).toBeUndefined(); expect(toInvariants("x")).toBeUndefined(); expect(toInvariants([])).toBeUndefined(); expect(toInvariants([{ message: "no rule" }])).toBeUndefined(); }); }); describe("layoutBcGraph", () => { const graph: BcGraph = { bcs: [ { name: "a", rows: [ { id: "action:a1", kind: "action", name: "a1" }, { id: "action:a2", kind: "action", name: "a2" }, { id: "action:a3", kind: "action", name: "a3" }, ], }, { name: "b", rows: [] }, { name: "c", rows: [] }, { name: "d", rows: [] }, ], edges: [], }; it("positions cards in a grid by column count", () => { const nodes = layoutBcGraph(graph, { columns: 2, width: 100, gapX: 20 }); expect(nodes).toHaveLength(4); // row 0 expect(nodes[0]!.x).toBe(0); expect(nodes[1]!.x).toBe(120); expect(nodes[0]!.y).toBe(nodes[1]!.y); // row 1 wraps below row 0 expect(nodes[2]!.x).toBe(0); expect(nodes[2]!.y).toBeGreaterThan(nodes[0]!.y); }); it("scales card height with member count", () => { const nodes = layoutBcGraph(graph, { columns: 4 }); const tall = nodes.find((n) => n.name === "a")!; const short = nodes.find((n) => n.name === "b")!; expect(tall.height).toBeGreaterThan(short.height); }); it("never overlaps rows — next row clears the tallest card above", () => { const nodes = layoutBcGraph(graph, { columns: 2 }); const rowZeroBottom = Math.max(nodes[0]!.y + nodes[0]!.height, nodes[1]!.y + nodes[1]!.height); expect(nodes[2]!.y).toBeGreaterThanOrEqual(rowZeroBottom); }); });