Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/test/flow-draft.test.mjs
iris 6e7b5f84e9 swarm-ui: give the new-flow draft a route no flow id can take, and drop the dead hit-count rule
/notifications/new could be a saved flow's own page, so the draft now opens at
/notifications/draft/new, which has a segment more than any flow's route. The
placeholder id no longer needs reserving. The .notify-node-hits rule styled the
removed tree rows; nothing renders it.
2026-10-25 02:05:54 +02:00

391 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,
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("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");
});