swarm-ui: draft edits for the notification flow editor
Pure edits on a flow (add a child, remove a branch, move a branch, replace a node, new and duplicate flow) that refuse what the controller's tree rules would, plus the save request and what its status means. Nothing uses them yet: the editor page that does comes next.
This commit is contained in:
parent
d5802d16a0
commit
d19bd83aa5
2 changed files with 544 additions and 0 deletions
235
frontend/packages/swarm-ui/test/flow-draft.test.mjs
Normal file
235
frontend/packages/swarm-ui/test/flow-draft.test.mjs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
// 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");
|
||||
});
|
||||
Loading…
Reference in a new issue