The editor's preview button posts the draft to POST /api/notify/dry-run and puts each box's hit count on the canvas; any edit hides the numbers. Boxes of kind request_reviewer label themselves "reviewer", so the long kind name no longer runs into the hit count shown beside it.
401 lines
13 KiB
JavaScript
401 lines
13 KiB
JavaScript
// 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,
|
|
DEFAULT_LIMITS,
|
|
depthOf,
|
|
duplicateFlow,
|
|
heightBelow,
|
|
isDirty,
|
|
isLeafKind,
|
|
limitsOf,
|
|
moveAmongSiblings,
|
|
NEW_FLOW_ID,
|
|
newFlow,
|
|
newNodeId,
|
|
paletteKinds,
|
|
removeNode,
|
|
reparent,
|
|
replaceNode,
|
|
savedAs,
|
|
saveOutcome,
|
|
previewRequest,
|
|
saveRequest,
|
|
seedFlow,
|
|
siblingPosition,
|
|
slugId,
|
|
subtreeIds,
|
|
withFlow,
|
|
withoutFlow,
|
|
withOp,
|
|
} 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("withOp drops what the new op has no place for", () => {
|
|
const clearing = { ...todo("t"), op: "clear", prefix: true, text: "" };
|
|
const up = withOp(clearing, "upsert");
|
|
assert.equal(up.op, "upsert");
|
|
assert.equal(up.prefix, false, "only a clear may be a prefix");
|
|
const back = withOp({ ...todo("t"), prefix: false }, "clear");
|
|
assert.equal(back.text, "", "a clear carries no text");
|
|
assert.equal(withOp(clearing, "clear").prefix, true, "kept on a clear");
|
|
});
|
|
|
|
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("previewRequest posts the document without a revision", () => {
|
|
const req = previewRequest([flow()], ["b1"]);
|
|
assert.equal(req.method, "POST");
|
|
const body = JSON.parse(req.body);
|
|
assert.deepEqual(body.disabled_builtins, ["b1"]);
|
|
assert.equal(body.flows[0].id, "f");
|
|
assert.equal("revision" in body, false, "nothing is stored, so no revision");
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
test("limitsOf takes the controller's caps and falls back to today's", () => {
|
|
assert.deepEqual(
|
|
limitsOf({ limits: { max_nodes: 5, max_depth: 3, max_flows: 9 } }),
|
|
{ maxNodes: 5, maxDepth: 3, maxFlows: 9 },
|
|
);
|
|
assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS);
|
|
});
|
|
|
|
const reviewer = (id) => ({ id, kind: "request_reviewer", reviewer: "argus" });
|
|
|
|
test("a request_reviewer ends a branch like a todo", () => {
|
|
assert.ok(isLeafKind("todo") && isLeafKind("request_reviewer"));
|
|
assert.ok(!isLeafKind("match") && !isLeafKind("source"));
|
|
const f = flow();
|
|
const r = addChild(f, "c", reviewer("r1"));
|
|
assert.ok(r.ok);
|
|
assert.equal(
|
|
addChild(r.flow, "r1", todo("t2")).ok,
|
|
false,
|
|
"nothing under it",
|
|
);
|
|
assert.equal(reparent(r.flow, "b", "r1").ok, false, "no move under it");
|
|
assert.equal(
|
|
replaceNode(f, reviewer("a")).ok,
|
|
false,
|
|
"a node with children cannot become one",
|
|
);
|
|
assert.ok(replaceNode(f, reviewer("c")).ok, "a leaf may");
|
|
});
|
|
|
|
test("a new reviewer request is blank and its id reads reviewer1", () => {
|
|
const f = flow();
|
|
const n = blankNode(f, "request_reviewer");
|
|
assert.deepEqual(n, {
|
|
id: "reviewer1",
|
|
kind: "request_reviewer",
|
|
reviewer: "",
|
|
});
|
|
const r = addChild(f, "c", n);
|
|
assert.ok(r.ok);
|
|
assert.equal(blankNode(r.flow, "request_reviewer").id, "reviewer2");
|
|
});
|
|
|
|
test("the palette offers what the controller lists, and the old two without a schema", () => {
|
|
const schema = (kinds) => ({
|
|
node_kinds: kinds.map((kind) => ({
|
|
kind,
|
|
description: kind + "!",
|
|
fields: [],
|
|
})),
|
|
});
|
|
assert.deepEqual(
|
|
paletteKinds(schema(["source", "match", "todo", "request_reviewer"])).map(
|
|
(p) => p.kind,
|
|
),
|
|
["match", "todo", "request_reviewer"],
|
|
);
|
|
assert.deepEqual(
|
|
paletteKinds(schema(["source", "match", "todo"])).map((p) => p.kind),
|
|
["match", "todo"],
|
|
);
|
|
assert.deepEqual(
|
|
paletteKinds(schema([])).map((p) => p.kind),
|
|
["match", "todo"],
|
|
);
|
|
assert.equal(paletteKinds(schema(["match"]))[0].description, "match!");
|
|
});
|
|
|
|
test("reparent refuses a move under the node's own parent", () => {
|
|
const r = reparent(flow(), "b", "a");
|
|
assert.equal(r.ok, false);
|
|
assert.match(r.reason, /already under/);
|
|
});
|
|
|
|
test("siblingPosition says where a branch runs among its siblings", () => {
|
|
const f = flow();
|
|
assert.deepEqual(siblingPosition(f, "a"), { index: 0, count: 2 });
|
|
assert.deepEqual(siblingPosition(f, "c"), { index: 1, count: 2 });
|
|
assert.deepEqual(siblingPosition(f, "b"), { index: 0, count: 1 });
|
|
assert.deepEqual(siblingPosition(f, "source"), { index: 0, count: 1 });
|
|
});
|
|
|
|
test("moveAmongSiblings swaps two branches and leaves other edges alone", () => {
|
|
const f = flow();
|
|
const later = moveAmongSiblings(f, "a", 1);
|
|
assert.ok(later.ok);
|
|
assert.deepEqual(
|
|
later.flow.edges.filter((e) => e.from === "source").map((e) => e.to),
|
|
["c", "a"],
|
|
);
|
|
assert.deepEqual(
|
|
later.flow.edges.filter((e) => e.from !== "source"),
|
|
f.edges.filter((e) => e.from !== "source"),
|
|
);
|
|
const back = moveAmongSiblings(later.flow, "a", -1);
|
|
assert.deepEqual(back.flow.edges, f.edges);
|
|
assert.equal(moveAmongSiblings(f, "a", -1).ok, false, "already first");
|
|
assert.equal(moveAmongSiblings(f, "c", 1).ok, false, "already last");
|
|
assert.equal(moveAmongSiblings(f, "source", 1).ok, false, "the source");
|
|
});
|
|
|
|
test("seedFlow opens an empty or copied draft under the placeholder id", () => {
|
|
const view = {
|
|
revision: 1,
|
|
disabled_builtins: [],
|
|
builtins: [{ ...flow(), id: "assigned", name: "Assigned" }],
|
|
flows: [{ ...flow(), id: "mine", name: "Mine" }],
|
|
};
|
|
const empty = seedFlow(view, null);
|
|
assert.equal(empty.id, NEW_FLOW_ID);
|
|
assert.equal(empty.name, "");
|
|
assert.deepEqual(
|
|
empty.nodes.map((n) => n.kind),
|
|
["source"],
|
|
);
|
|
const copy = seedFlow(view, "assigned");
|
|
assert.equal(copy.id, NEW_FLOW_ID);
|
|
assert.equal(copy.name, "Assigned (copy)");
|
|
assert.equal(copy.nodes.length, flow().nodes.length);
|
|
assert.equal(seedFlow(view, "gone"), null);
|
|
assert.equal(view.flows.length, 1, "nothing is added to the view");
|
|
});
|
|
|
|
test("savedAs takes the id from the name, clear of every saved id", () => {
|
|
const view = {
|
|
revision: 1,
|
|
disabled_builtins: [],
|
|
builtins: [{ ...flow(), id: "assigned" }],
|
|
flows: [{ ...flow(), id: "mine" }],
|
|
};
|
|
const draft = { ...flow(), id: NEW_FLOW_ID };
|
|
assert.equal(savedAs(view, { ...draft, name: "Closed" }).id, "closed");
|
|
assert.equal(savedAs(view, { ...draft, name: "Assigned" }).id, "assigned-2");
|
|
assert.equal(savedAs(view, { ...draft, name: "mine" }).id, "mine-2");
|
|
});
|