fabro/apps/fabro-web/app/components/playground/state/parse-fabro.test.ts
2026-07-29 10:54:43 -04:00

235 lines
7.3 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { renderFabro } from "../files/render-fabro";
import { createInitialDraft, type WorkflowDraft } from "./draft";
import { parseFabro } from "./parse-fabro";
function expectOk(result: ReturnType<typeof parseFabro>): WorkflowDraft {
if (!result.ok) throw new Error(`expected ok, got: ${result.error}`);
return result.draft;
}
describe("parseFabro", () => {
test("welcome state", () => {
const draft = expectOk(
parseFabro(`digraph Workflow {
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
start -> exit
}`),
);
expect(draft.name).toBe("workflow");
expect(draft.nodes).toHaveLength(2);
expect(draft.nodes[0]).toMatchObject({ id: "start", shape: "mdiamond" });
expect(draft.nodes[1]).toMatchObject({ id: "exit", shape: "msquare" });
expect(draft.edges).toEqual([{ from: "start", to: "exit" }]);
});
test("captures graph goal attribute", () => {
const draft = expectOk(
parseFabro(`digraph ReleaseNotes {
graph [goal="Generate release notes from git log."]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
start -> exit
}`),
);
expect(draft.name).toBe("release_notes");
expect(draft.goal).toBe("Generate release notes from git log.");
});
test("parses node with prompt and extra attrs", () => {
const draft = expectOk(
parseFabro(`digraph Run {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
run_tests [shape=parallelogram, label="Run Tests", prompt="Execute the suite.", script="npm test", timeout=60]
start -> run_tests -> exit
}`),
);
const tests = draft.nodes.find((n) => n.id === "run_tests");
expect(tests).toBeDefined();
expect(tests!.shape).toBe("parallelogram");
expect(tests!.label).toBe("Run Tests");
expect(tests!.prompt).toBe("Execute the suite.");
expect(tests!.attrs).toEqual({ script: "npm test", timeout: 60 });
});
test("edge chain `a -> b -> c` produces multiple edges", () => {
const draft = expectOk(
parseFabro(`digraph Linear {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
a [shape=box, label="A"]
b [shape=box, label="B"]
c [shape=box, label="C"]
start -> a -> b -> c -> exit
}`),
);
const edges = draft.edges.map((e) => `${e.from}->${e.to}`);
expect(edges).toEqual(["start->a", "a->b", "b->c", "c->exit"]);
});
test("edge with condition and label attributes", () => {
const draft = expectOk(
parseFabro(`digraph Branch {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
gate [shape=diamond, label="Gate"]
happy [shape=box, label="Happy"]
start -> gate
gate -> happy [condition="outcome=approved", label="approved"]
happy -> exit
}`),
);
const edge = draft.edges.find((e) => e.from === "gate" && e.to === "happy");
expect(edge).toBeDefined();
expect(edge!.condition).toBe("outcome=approved");
expect(edge!.label).toBe("approved");
});
test("escapes inside strings", () => {
const draft = expectOk(
parseFabro(`digraph Esc {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plan [shape=box, label="Plan", prompt="He said \\"hi\\" and added a \\\\ slash."]
start -> plan -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "plan")?.prompt).toBe(
'He said "hi" and added a \\ slash.',
);
});
test("comments and trailing semicolons are tolerated", () => {
const draft = expectOk(
parseFabro(`// top of file
digraph Cmt {
// inline comment
start [shape=Mdiamond, label="Start"];
exit [shape=Msquare, label="Exit"];
/* block
comment */
start -> exit;
}`),
);
expect(draft.nodes).toHaveLength(2);
});
test("ignores global node/edge defaults and rankdir", () => {
const draft = expectOk(
parseFabro(`digraph G {
rankdir=LR
node [shape=box]
edge [color=gray]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
start -> exit
}`),
);
expect(draft.edges).toEqual([{ from: "start", to: "exit" }]);
});
test("round-trips renderFabro output", () => {
const initial = createInitialDraft();
initial.name = "release_notes";
initial.goal = "Generate release notes.";
initial.nodes.push({
id: "plan",
label: "Plan",
shape: "box",
prompt: "Plan it.",
});
initial.nodes.push({
id: "implement",
label: "Implement",
shape: "box",
});
initial.edges = [
{ from: "start", to: "plan" },
{ from: "plan", to: "implement" },
{ from: "implement", to: "exit" },
];
const dot = renderFabro(initial);
const parsed = expectOk(parseFabro(dot));
expect(parsed.name).toBe(initial.name);
expect(parsed.goal).toBe(initial.goal);
expect(parsed.nodes).toEqual(initial.nodes);
expect(parsed.edges).toEqual(initial.edges);
});
test("missing shape on non-terminal node defaults to box", () => {
const draft = expectOk(
parseFabro(`digraph G {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plain [label="No Shape"]
start -> plain -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "plain")?.shape).toBe("box");
});
test("shapeless script node keeps command behavior after round trip", () => {
const draft = expectOk(
parseFabro(`digraph G {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
build [script="cargo build"]
start -> build -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "build")?.shape).toBe(
"parallelogram",
);
const reparsed = expectOk(parseFabro(renderFabro(draft)));
expect(reparsed.nodes.find((n) => n.id === "build")?.shape).toBe(
"parallelogram",
);
});
test("explicit type disables command shape inference", () => {
const draft = expectOk(
parseFabro(`digraph G {
work [type=agent, script="cargo build"]
}`),
);
expect(draft.nodes.find((n) => n.id === "work")?.shape).toBe("box");
});
test("unknown shape falls back to box", () => {
const draft = expectOk(
parseFabro(`digraph G {
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
weird [shape=ellipse, label="Weird"]
start -> weird -> exit
}`),
);
expect(draft.nodes.find((n) => n.id === "weird")?.shape).toBe("box");
});
test("missing digraph header is a parse error", () => {
const result = parseFabro(`{ start -> exit }`);
expect(result.ok).toBe(false);
});
test("missing closing brace is a parse error", () => {
const result = parseFabro(`digraph G { start -> exit`);
expect(result.ok).toBe(false);
});
test("unterminated string is a parse error", () => {
const result = parseFabro(
`digraph G { plan [label="unterminated...] start -> plan }`,
);
expect(result.ok).toBe(false);
});
});