// The notification flow editor's draft edits. Run by `npm test`. import assert from "node:assert/strict"; import { test } from "node:test"; import { addChild, blankNode, depthOf, duplicateFlow, heightBelow, isDirty, newFlow, newNodeId, removeNode, reparent, replaceNode, saveOutcome, saveRequest, slugId, subtreeIds, withFlow, withoutFlow, } from "../src/pages/notifications/FlowDraft.ts"; const todo = (id) => ({ id, kind: "todo", to: { agent: "triage" }, op: "upsert", key: "k", prefix: false, text: "look", }); // source -> a -> b -> t1, source -> c const flow = () => ({ id: "f", name: "f", enabled: true, nodes: [ { id: "source", kind: "source", events: ["issues"] }, { id: "a", kind: "match", when: [] }, { id: "b", kind: "match", when: [] }, { id: "c", kind: "match", when: [] }, todo("t1"), ], edges: [ { from: "source", to: "a" }, { from: "a", to: "b" }, { from: "b", to: "t1" }, { from: "source", to: "c" }, ], }); const ids = (f) => f.nodes.map((n) => n.id).sort(); test("depth, height and subtree", () => { const f = flow(); assert.equal(depthOf(f, "source"), 0); assert.equal(depthOf(f, "t1"), 3); assert.equal(heightBelow(f, "source"), 3); assert.equal(heightBelow(f, "c"), 0); assert.deepEqual(subtreeIds(f, "a").sort(), ["a", "b", "t1"]); }); test("a cycle in the data cannot hang the walks", () => { const f = flow(); f.edges.push({ from: "t1", to: "a" }); assert.ok(depthOf(f, "b") < 10); assert.ok(heightBelow(f, "a") < 10); assert.ok(subtreeIds(f, "a").length <= f.nodes.length); }); test("addChild adds the node and its edge", () => { const r = addChild(flow(), "c", todo("t2")); assert.ok(r.ok); assert.ok(r.flow.nodes.some((n) => n.id === "t2")); assert.deepEqual(r.flow.edges.at(-1), { from: "c", to: "t2" }); }); test("addChild refuses what a save would", () => { const f = flow(); assert.equal(addChild(f, "t1", todo("t2")).ok, false, "under a todo"); assert.equal(addChild(f, "nope", todo("t2")).ok, false, "no parent"); assert.equal(addChild(f, "c", todo("a")).ok, false, "id in use"); assert.equal(addChild(f, "c", todo("bad id")).ok, false, "bad id"); assert.equal( addChild(f, "c", { id: "s2", kind: "source", events: ["issues"] }).ok, false, "second source", ); const small = { maxNodes: 5, maxDepth: 16, maxFlows: 100 }; assert.equal(addChild(f, "c", todo("t2"), small).ok, false, "node cap"); const shallow = { maxNodes: 64, maxDepth: 2, maxFlows: 100 }; assert.equal(addChild(f, "b", todo("t2"), shallow).ok, false, "depth cap"); assert.ok(addChild(f, "a", todo("t2"), shallow).ok, "right at the cap"); }); test("removeNode takes the branch and keeps the rest", () => { const r = removeNode(flow(), "a"); assert.ok(r.ok); assert.deepEqual(ids(r.flow), ["c", "source"]); assert.deepEqual(r.flow.edges, [{ from: "source", to: "c" }]); assert.equal(removeNode(flow(), "source").ok, false); assert.equal(removeNode(flow(), "nope").ok, false); }); test("reparent moves a branch and keeps one parent per node", () => { const r = reparent(flow(), "b", "c"); assert.ok(r.ok); assert.equal(r.flow.edges.filter((e) => e.to === "b").length, 1); assert.ok(r.flow.edges.some((e) => e.from === "c" && e.to === "b")); assert.ok(r.flow.edges.some((e) => e.from === "b" && e.to === "t1")); assert.equal(r.flow.nodes.length, 5); }); test("reparent refuses a cycle, a todo parent and the source", () => { const f = flow(); assert.equal(reparent(f, "a", "b").ok, false, "under its own child"); assert.equal(reparent(f, "a", "a").ok, false, "under itself"); assert.equal(reparent(f, "c", "t1").ok, false, "under a todo"); assert.equal(reparent(f, "source", "c").ok, false, "the source"); assert.equal(reparent(f, "nope", "c").ok, false, "unknown node"); }); test("reparent counts the whole branch against the depth cap", () => { const limits = { maxNodes: 64, maxDepth: 4, maxFlows: 100 }; // a (1) -> b -> t1 is a branch of height 2; under b's child would be deeper. const f = flow(); // c is at depth 1; moving a (height 2) under c puts t1 at depth 4. assert.ok(reparent(f, "a", "c", limits).ok); const tight = { ...limits, maxDepth: 3 }; assert.equal(reparent(f, "a", "c", tight).ok, false); }); test("replaceNode keeps the place and the kind class", () => { const f = flow(); const r = replaceNode(f, { id: "a", kind: "match", when: [{ field: "action", equals: "x" }], }); assert.ok(r.ok); assert.equal(r.flow.nodes.find((n) => n.id === "a").when.length, 1); assert.equal(replaceNode(f, { ...todo("a") }).ok, false, "a has children"); assert.ok(replaceNode(f, { ...todo("c") }).ok, "a leaf may become a todo"); assert.equal( replaceNode(f, { id: "source", kind: "match", when: [] }).ok, false, ); assert.equal( replaceNode(f, { id: "c", kind: "source", events: ["issues"] }).ok, false, ); assert.equal(replaceNode(f, todo("nope")).ok, false); }); test("new ids do not collide", () => { const f = flow(); assert.equal(newNodeId(f, "match"), "match1"); const r = addChild(f, "c", blankNode(f, "match")); assert.ok(r.ok); assert.equal(newNodeId(r.flow, "match"), "match2"); assert.equal(blankNode(f, "todo").kind, "todo"); }); test("slugId builds an id the controller accepts and avoids taken ones", () => { assert.equal(slugId("My Flow!", []), "my-flow"); assert.equal(slugId("My Flow!", ["my-flow"]), "my-flow-2"); assert.equal(slugId("My Flow!", ["my-flow", "my-flow-2"]), "my-flow-3"); assert.equal(slugId("!!!", []), "flow"); assert.ok(/^[A-Za-z0-9_-]{1,64}$/.test(slugId("x".repeat(200), []))); }); test("newFlow is one source; duplicateFlow copies without sharing", () => { const fresh = newFlow("Triage", ["triage"]); assert.equal(fresh.id, "triage-2"); assert.equal(fresh.nodes.length, 1); assert.equal(fresh.nodes[0].kind, "source"); assert.ok(fresh.enabled); const src = flow(); const copy = duplicateFlow(src, "f copy", ["f"]); assert.equal(copy.id, "f-copy"); assert.equal(copy.nodes.length, src.nodes.length); copy.nodes[1].when.push({ field: "action", equals: "x" }); assert.equal(src.nodes[1].when.length, 0, "the original is untouched"); const off = { ...src, enabled: false }; assert.ok(duplicateFlow(off, "x", []).enabled, "a copy is on"); }); test("withFlow replaces by id or appends; withoutFlow drops", () => { const a = { ...flow(), id: "a" }; const b = { ...flow(), id: "b" }; assert.deepEqual( withFlow([a], b).map((f) => f.id), ["a", "b"], ); const renamed = { ...a, name: "renamed" }; assert.equal(withFlow([a, b], renamed)[0].name, "renamed"); assert.deepEqual( withoutFlow([a, b], "a").map((f) => f.id), ["b"], ); }); test("isDirty compares the draft with what was read", () => { const view = { revision: 3, disabled_builtins: ["x"], builtins: [], flows: [flow()], }; assert.equal(isDirty(view, [flow()], ["x"]), false); assert.equal(isDirty(view, [], ["x"]), true); assert.equal(isDirty(view, [flow()], []), true); }); test("saveRequest sends the whole document at the revision read", () => { const view = { revision: 7, disabled_builtins: [], builtins: [], flows: [] }; const req = saveRequest(view, [flow()], ["b1"]); assert.equal(req.method, "PUT"); const body = JSON.parse(req.body); assert.equal(body.revision, 7); assert.deepEqual(body.disabled_builtins, ["b1"]); assert.equal(body.flows[0].id, "f"); assert.equal("builtins" in body, false, "built-ins are never sent"); }); test("saveOutcome tells a stale revision from a refused document", () => { assert.equal(saveOutcome(200), "saved"); assert.equal(saveOutcome(409), "conflict"); assert.equal(saveOutcome(400), "invalid"); assert.equal(saveOutcome(503), "error"); assert.equal(saveOutcome(500), "error"); });