@nanobpm/nano-workforce 0.136.0 → 0.138.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/delivery.ts +100 -48
- package/app/deliveryGraphTextIngress.ts +94 -0
- package/app/deliveryStatuses.ts +13 -0
- package/app/planReadModel.test.ts +470 -0
- package/app/planReadModel.ts +159 -0
- package/app/planRollups.ts +127 -0
- package/db/migrations/082_plan_rollups_declare_once.sql +74 -0
- package/db/migrations/083_plan_read_model_declare_once.sql +84 -0
- package/openapi.yaml +73 -16
- package/operations/previewDeliveryGraph.test.ts +15 -13
- package/operations/previewDeliveryGraph.ts +24 -82
- package/operations/stageDeliveryGraph.test.ts +106 -0
- package/operations/stageDeliveryGraph.ts +53 -0
- package/package.json +5 -5
- package/pages/delivery-graphs/delivery-graphs.css +47 -0
- package/pages/delivery-graphs/embed.html +1 -1
- package/pages/delivery-graphs/mount.js +110 -87
- package/pages/delivery-graphs/standalone.html +1 -1
- package/test/delivery-graphs-embed.test.ts +47 -35
- package/app/delivery.test.ts +0 -76
- package/app/planWaveSummary.test.ts +0 -170
- package/app/plansReadModel.test.ts +0 -406
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
// Contract guard for the Delivery Graphs compose →
|
|
1
|
+
// Contract guard for the Delivery Graphs compose → PREVIEW / STAGE App View (issues #441 + #460 + #516).
|
|
2
2
|
//
|
|
3
3
|
// The rich compile preview (mermaid diagram + humanNodes[] stop-points + sideEffects[] + inline
|
|
4
|
-
// path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
4
|
+
// path-qualified errors) is surfaced by an `appView` embed (pages/delivery-graphs/) over the preview /
|
|
5
|
+
// stage doors — a bare `actionForm` discards its response and so can render none of that. Preview and
|
|
6
|
+
// Stage are SEPARATE operator actions (#516): Preview compiles without persisting; Stage persists a
|
|
7
|
+
// proposal. Dispatch is deliberately NOT in this view (issue #460): it is an OPERATOR row-action on the
|
|
8
|
+
// Staged proposals grid on the same page. This test pins the wiring so it can't silently regress: the
|
|
9
|
+
// sidecars exist, mount.js hits the preview + stage doors with base-relative defaults (the #279
|
|
10
|
+
// App-View resolution class — a leading-slash path 404s), it renders each preview facet, its compose
|
|
11
|
+
// panel is collapsible, and it exposes NO dispatch/approval affordance (the self-approval hole #460
|
|
12
|
+
// closes).
|
|
11
13
|
import { test } from "node:test";
|
|
12
14
|
import { assert } from "#test-assert";
|
|
13
15
|
import { readFileSync } from "node:fs";
|
|
16
|
+
import { parseAndCompileText } from "../app/deliveryGraphTextIngress.ts";
|
|
17
|
+
import { EXAMPLE_GRAPH } from "../pages/delivery-graphs/mount.js";
|
|
14
18
|
|
|
15
19
|
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
16
20
|
const DIR = `${ROOT}pages/delivery-graphs`;
|
|
17
21
|
const MOUNT_JS = readFileSync(`${DIR}/mount.js`, "utf8");
|
|
18
22
|
const EMBED_HTML = readFileSync(`${DIR}/embed.html`, "utf8");
|
|
19
23
|
const STANDALONE_HTML = readFileSync(`${DIR}/standalone.html`, "utf8");
|
|
24
|
+
const CSS = readFileSync(`${DIR}/delivery-graphs.css`, "utf8");
|
|
20
25
|
const PAGE_JSON = readFileSync(`${ROOT}pages/delivery-graphs.page.json`, "utf8");
|
|
21
26
|
|
|
22
|
-
// Pull the string default out of `const <name> = config.<name> ??
|
|
27
|
+
// Pull the string default out of `const <name> = config.<name> ?? <CONST>;` (a module const).
|
|
23
28
|
function defaultUrl(name: string): string {
|
|
24
29
|
const m = MOUNT_JS.match(new RegExp(`${name}\\s*=\\s*config\\.\\w+\\s*\\?\\?\\s*(\\w+);`));
|
|
25
30
|
assert(m, `mount.js must default ${name} from config with a fallback constant`);
|
|
@@ -36,33 +41,39 @@ test("#441: the delivery-graphs App View mounts the same module standalone and e
|
|
|
36
41
|
}
|
|
37
42
|
});
|
|
38
43
|
|
|
39
|
-
test("#
|
|
44
|
+
test("#516: mount.js wires SEPARATE preview and stage doors (base-relative)", () => {
|
|
40
45
|
const previewUrl = defaultUrl("previewUrl");
|
|
41
46
|
assert(previewUrl.endsWith("actions/delivery-graph/preview"), `previewUrl default "${previewUrl}" must hit the previewDeliveryGraph door`);
|
|
47
|
+
assert(!previewUrl.startsWith("/"), `default previewUrl "${previewUrl}" must be base-relative (App-View #279 resolution class)`);
|
|
48
|
+
const stageUrl = defaultUrl("stageUrl");
|
|
49
|
+
assert(stageUrl.endsWith("actions/delivery-graph/stage"), `stageUrl default "${stageUrl}" must hit the stageDeliveryGraph door`);
|
|
50
|
+
assert(!stageUrl.startsWith("/"), `default stageUrl "${stageUrl}" must be base-relative (App-View #279 resolution class)`);
|
|
51
|
+
// Preview and Stage are distinct buttons wired to distinct actions.
|
|
52
|
+
assert(/id="dg-preview"/.test(MOUNT_JS) && /id="dg-stage"/.test(MOUNT_JS), "mount.js must render distinct Preview and Stage buttons");
|
|
53
|
+
assert(/submit\(previewUrl,\s*false\)/.test(MOUNT_JS), "the Preview button must submit to the preview door WITHOUT staging");
|
|
54
|
+
assert(/submit\(stageUrl,\s*true\)/.test(MOUNT_JS), "the Stage button must submit to the stage door");
|
|
42
55
|
});
|
|
43
56
|
|
|
44
|
-
test("
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assert(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
assert(/data-preview-di=/.test(MOUNT_JS), "mount.js must render a Preview-DI affordance carrying the proposal digest");
|
|
51
|
-
assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "mount.js must post nano-navigate to the definitionPreview target");
|
|
52
|
-
assert(/params:\s*\{\s*xml:/.test(MOUNT_JS), "mount.js must carry the compiled BPMN xml in the bridge message");
|
|
57
|
+
test("#516: the compose panel is collapsible", () => {
|
|
58
|
+
// Native <details> disclosure: keyboard-accessible, and the textarea is only hidden (never destroyed)
|
|
59
|
+
// when collapsed, so its value survives.
|
|
60
|
+
assert(/<details[^>]*class="[^"]*\bcompose\b/.test(MOUNT_JS), "the compose panel must be a collapsible <details class=compose>");
|
|
61
|
+
assert(/<summary>/.test(MOUNT_JS), "the collapsible compose panel must have a <summary> disclosure header");
|
|
62
|
+
assert(/\.compose\[open\]/.test(CSS), "the CSS must style the open/closed disclosure state");
|
|
53
63
|
});
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
assert(
|
|
65
|
+
test("#516 DI preview: the compose view bridges the previewed BPMN to the host explorer WITHOUT staging", () => {
|
|
66
|
+
// The pure preview door returns the laid-out `bpmn`, so DI preview needs no proposal-bpmn round-trip
|
|
67
|
+
// (and therefore no staging). The compose view stashes the previewed BPMN and hands it to the host
|
|
68
|
+
// console over the nano-navigate bridge with the definitionPreview target (never a dispatch).
|
|
69
|
+
assert(!/proposal-bpmn/.test(MOUNT_JS), "mount.js must NOT round-trip the proposal-bpmn door — the preview door returns the BPMN directly (#516)");
|
|
70
|
+
assert(/lastBpmn/.test(MOUNT_JS), "mount.js must stash the previewed BPMN to bridge on demand");
|
|
71
|
+
assert(/data-preview-di/.test(MOUNT_JS), "mount.js must render a Preview-DI affordance");
|
|
72
|
+
assert(/target:\s*"definitionPreview"/.test(MOUNT_JS), "mount.js must post nano-navigate to the definitionPreview target");
|
|
73
|
+
assert(/params:\s*\{\s*xml:\s*lastBpmn\s*\}/.test(MOUNT_JS), "mount.js must carry the previewed BPMN xml in the bridge message");
|
|
60
74
|
});
|
|
61
75
|
|
|
62
76
|
test("#441: the preview render consumes every compile facet the door returns", () => {
|
|
63
|
-
// The whole point of #441: the preview data (diagram / humanNodes / sideEffects / errors) is rich
|
|
64
|
-
// but was consumed by nothing. Assert the renderer touches each facet at a CONCRETE call site (not a
|
|
65
|
-
// bare word, which a comment/string could satisfy) so a renderer that stops reading a field fails.
|
|
66
77
|
const facetUse: Record<string, RegExp> = {
|
|
67
78
|
diagram: /esc\(result\.diagram\)/,
|
|
68
79
|
humanNodes: /renderHumanNodes\(result\.humanNodes\)/,
|
|
@@ -74,10 +85,16 @@ test("#441: the preview render consumes every compile facet the door returns", (
|
|
|
74
85
|
}
|
|
75
86
|
});
|
|
76
87
|
|
|
88
|
+
test("#516: the built-in 'Load example' graph compiles clean (regression: it used to fail)", async () => {
|
|
89
|
+
// The example shipped a `soak` wait node missing its required `wait.kind`, so 'Load example' →
|
|
90
|
+
// Preview always 400'd. Drive the EXACT string the button injects through the SAME compiler the
|
|
91
|
+
// preview/stage doors use, and assert it is accepted — so a future edit to EXAMPLE_GRAPH can't
|
|
92
|
+
// silently re-break the one graph an operator reaches for first.
|
|
93
|
+
const result = await parseAndCompileText({ graphJson: EXAMPLE_GRAPH });
|
|
94
|
+
assert(result.ok, result.ok ? "" : `the built-in example must compile, got: ${JSON.stringify(result.body)}`);
|
|
95
|
+
});
|
|
96
|
+
|
|
77
97
|
test("#460: the compose view exposes NO dispatch or approval affordance — it only previews + stages", () => {
|
|
78
|
-
// Issue #460 removes the agent-reachable dispatch door. The compose view must not smuggle it back:
|
|
79
|
-
// no dispatch door wiring, no approval two-step, no replayable approvalToken. Dispatch is the
|
|
80
|
-
// operator's Staged-proposals row-action instead.
|
|
81
98
|
assert(!/dispatchUrl/.test(MOUNT_JS), "mount.js must NOT wire a dispatch door (dispatch is an operator row-action, issue #460)");
|
|
82
99
|
assert(!/delivery-graph\/dispatch/.test(MOUNT_JS), "mount.js must NOT post to the dispatch door");
|
|
83
100
|
assert(!/awaiting-approval/.test(MOUNT_JS), "mount.js must NOT implement the removed awaiting-approval two-step");
|
|
@@ -85,11 +102,6 @@ test("#460: the compose view exposes NO dispatch or approval affordance — it o
|
|
|
85
102
|
});
|
|
86
103
|
|
|
87
104
|
test("#460/#511: dispatch is the operator's action on the Staged-proposals App-View", () => {
|
|
88
|
-
// Dispatch is NOT in the compose view (asserted above). It lives on the Staged-proposals surface,
|
|
89
|
-
// which is now an App-View (issue #511) rather than a declarative grid: a grid row-action can POST but
|
|
90
|
-
// cannot hand the recompiled BPMN up to the host explorer, so a staged proposal had a Dispatch button
|
|
91
|
-
// but no way to SEE the graph. The App-View carries BOTH Preview-DI and Dispatch. The wiring itself
|
|
92
|
-
// (which doors staged.mount.js posts to) is pinned by delivery-graphs-staged-embed.test.ts.
|
|
93
105
|
const page = JSON.parse(PAGE_JSON) as { nodes: Array<Record<string, any>> };
|
|
94
106
|
const staged = page.nodes.find((n) => n.id === "delivery-graphs-staged");
|
|
95
107
|
assert(staged, "the page must carry a Staged proposals surface");
|
package/app/delivery.test.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
// Read-model derivation test for the epic delivery signal (issue #171). `deriveDelivery` is the
|
|
2
|
-
// single source of truth the `plan_delivery` VIEW (061) encodes and the pollers derive at READ TIME
|
|
3
|
-
// (epic #412 retired the stored `plans.delivery` / `plans.delivery_label` columns). It must cleanly
|
|
4
|
-
// distinguish an epic whose fan-out is `done` but whose slices are still CONVERGING from one where
|
|
5
|
-
// every slice PR has LANDED, and count abandoned/converged PRs as resolved-not-landed (never
|
|
6
|
-
// `landed`). The delivery-aware `list_bucket`/`ack_open` bucket derivation now lives in the
|
|
7
|
-
// `plan_read_model` VIEW (074), cross-checked against the pure helpers in app/plansReadModel.test.ts.
|
|
8
|
-
import { test } from "node:test";
|
|
9
|
-
import { assert, assertEquals } from "#test-assert";
|
|
10
|
-
import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
|
|
11
|
-
|
|
12
|
-
test("all slice PRs merged -> landed", () => {
|
|
13
|
-
const r = deriveDelivery("done", ["merged", "merged", "merged"]);
|
|
14
|
-
assertEquals(r.delivery, "landed");
|
|
15
|
-
assertEquals(r.prsOpened, 3);
|
|
16
|
-
assertEquals(r.prsMerged, 3);
|
|
17
|
-
assertEquals(r.prsInFlight, 0);
|
|
18
|
-
assertEquals(r.label, "3/3 slices merged");
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("one slice PR still in flight -> converging", () => {
|
|
22
|
-
const r = deriveDelivery("done", ["merged", "converging", "merged"]);
|
|
23
|
-
assertEquals(r.delivery, "converging");
|
|
24
|
-
assertEquals(r.prsOpened, 3);
|
|
25
|
-
assertEquals(r.prsMerged, 2);
|
|
26
|
-
assertEquals(r.prsInFlight, 1);
|
|
27
|
-
assertEquals(r.label, "2/3 slices merged, 1 converging");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("mixed merged/abandoned (all terminal, not all merged) -> resolved-not-landed (null)", () => {
|
|
31
|
-
const r = deriveDelivery("done", ["merged", "abandoned", "merged"]);
|
|
32
|
-
assertEquals(r.delivery, null);
|
|
33
|
-
assertEquals(r.label, null);
|
|
34
|
-
assertEquals(r.prsOpened, 3);
|
|
35
|
-
assertEquals(r.prsMerged, 2);
|
|
36
|
-
// abandoned is terminal, so it is NOT counted as in flight.
|
|
37
|
-
assertEquals(r.prsInFlight, 0);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("a converged (review-only, unmerged) slice keeps the epic out of landed", () => {
|
|
41
|
-
// `converged` is terminal but not `merged`: resolved-not-landed, like abandoned.
|
|
42
|
-
const r = deriveDelivery("done", ["merged", "converged"]);
|
|
43
|
-
assertEquals(r.delivery, null);
|
|
44
|
-
assertEquals(r.prsInFlight, 0);
|
|
45
|
-
assertEquals(r.prsMerged, 1);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("plan not yet done -> no delivery signal even with slice PRs", () => {
|
|
49
|
-
for (const status of ["planning", "dispatched"]) {
|
|
50
|
-
const r = deriveDelivery(status, ["merged", "converging"]);
|
|
51
|
-
assertEquals(r.delivery, null, `status=${status}`);
|
|
52
|
-
assertEquals(r.label, null, `status=${status}`);
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("done but zero slice PRs -> no delivery signal", () => {
|
|
57
|
-
const r = deriveDelivery("done", []);
|
|
58
|
-
assertEquals(r.delivery, null);
|
|
59
|
-
assertEquals(r.prsOpened, 0);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("a single in-flight slice on a done plan is converging, not landed", () => {
|
|
63
|
-
const r = deriveDelivery("done", ["waiting_review"]);
|
|
64
|
-
assertEquals(r.delivery, "converging");
|
|
65
|
-
assertEquals(r.label, "0/1 slices merged, 1 converging");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("every non-terminal status counts as in flight", () => {
|
|
69
|
-
const inFlight = ["converging", "waiting_review", "escalated", "queued", "open", "opened"];
|
|
70
|
-
for (const s of inFlight) {
|
|
71
|
-
assert(!TERMINAL_STATUSES.includes(s), `${s} must not be terminal`);
|
|
72
|
-
const r = deriveDelivery("done", [s]);
|
|
73
|
-
assertEquals(r.delivery, "converging", `status ${s}`);
|
|
74
|
-
assertEquals(r.prsInFlight, 1, `status ${s}`);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
// Coverage for the Epic-detail wave visualization + task→representation links (issue #411).
|
|
2
|
-
//
|
|
3
|
-
// Two guards, mirroring the repo's split between a derived-read-model test (migration042.test.ts —
|
|
4
|
-
// apply the migration to a real in-memory DB and assert its output) and a page-projection guard
|
|
5
|
-
// (waitGateVisibility.test.ts — pure text assertions that the declarative page wires the surface):
|
|
6
|
-
//
|
|
7
|
-
// 1. The VIEW rollup over sample `plan_tasks` × `pull_requests` rows: the six-way per-wave count
|
|
8
|
-
// partition and the pre-formatted `bar` string. Because `plan_wave_summary` is a VIEW (the whole
|
|
9
|
-
// point of #411 — a single derived source of truth, enabled by nano-ide#424) this exercises the
|
|
10
|
-
// real SQLite view, not a re-implementation.
|
|
11
|
-
// 2. The epic-detail page projects the wave banner, the per-wave summary section, and the
|
|
12
|
-
// task→representation links (PR url + processExplorer instance) on the wave-state grid.
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
14
|
-
import { DatabaseSync } from "node:sqlite";
|
|
15
|
-
import { test } from "node:test";
|
|
16
|
-
import { fileURLToPath } from "node:url";
|
|
17
|
-
import { assert, assertEquals } from "#test-assert";
|
|
18
|
-
|
|
19
|
-
const MIGRATION = fileURLToPath(new URL("../db/migrations/059_plan_wave_summary.sql", import.meta.url));
|
|
20
|
-
const PAGE = fileURLToPath(new URL("../pages/epic-detail.page.json", import.meta.url));
|
|
21
|
-
|
|
22
|
-
/** A DB with the base `plan_tasks` / `pull_requests` shapes the views read, plus the views applied. */
|
|
23
|
-
function viewDb(): DatabaseSync {
|
|
24
|
-
const db = new DatabaseSync(":memory:");
|
|
25
|
-
db.exec(
|
|
26
|
-
`CREATE TABLE plan_tasks (
|
|
27
|
-
id INTEGER PRIMARY KEY, plan_key TEXT, task_index INTEGER, task_id TEXT, title TEXT,
|
|
28
|
-
prompt TEXT, status TEXT, pr_key TEXT, summary TEXT, created_at TEXT, updated_at TEXT,
|
|
29
|
-
wave INTEGER, open_question TEXT, answer TEXT, draft_pr_key TEXT, corr_key TEXT);
|
|
30
|
-
CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, url TEXT, status TEXT, process_key TEXT);`,
|
|
31
|
-
);
|
|
32
|
-
db.exec(readFileSync(MIGRATION, "utf8"));
|
|
33
|
-
return db;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function addTask(
|
|
37
|
-
db: DatabaseSync,
|
|
38
|
-
plan_key: string,
|
|
39
|
-
task_index: number,
|
|
40
|
-
status: string,
|
|
41
|
-
wave: number,
|
|
42
|
-
pr?: { pr_key: string; url: string; status: string; process_key: string },
|
|
43
|
-
): void {
|
|
44
|
-
db.prepare(
|
|
45
|
-
"INSERT INTO plan_tasks (plan_key, task_index, task_id, status, pr_key, wave) VALUES (?, ?, ?, ?, ?, ?)",
|
|
46
|
-
).run(plan_key, task_index, `t${task_index}`, status, pr?.pr_key ?? null, wave);
|
|
47
|
-
if (pr) {
|
|
48
|
-
db.prepare(
|
|
49
|
-
"INSERT INTO pull_requests (pr_key, url, status, process_key) VALUES (?, ?, ?, ?)",
|
|
50
|
-
).run(pr.pr_key, pr.url, pr.status, pr.process_key);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
test("plan_wave_summary partitions each wave's tasks and pre-formats the progress bar", () => {
|
|
55
|
-
const db = viewDb();
|
|
56
|
-
const plan = "o/r#1";
|
|
57
|
-
// Wave 0 — 5 tasks: 3 merged, 1 converging (in-flight), 1 blocked (no PR).
|
|
58
|
-
addTask(db, plan, 0, "opened", 0, { pr_key: "o/r#10", url: "https://gh/10", status: "merged", process_key: "P10" });
|
|
59
|
-
addTask(db, plan, 1, "opened", 0, { pr_key: "o/r#11", url: "https://gh/11", status: "merged", process_key: "P11" });
|
|
60
|
-
addTask(db, plan, 2, "opened", 0, { pr_key: "o/r#12", url: "https://gh/12", status: "merged", process_key: "P12" });
|
|
61
|
-
addTask(db, plan, 3, "opened", 0, { pr_key: "o/r#13", url: "https://gh/13", status: "converging", process_key: "P13" });
|
|
62
|
-
addTask(db, plan, 4, "blocked", 0);
|
|
63
|
-
// Wave 1 — an escalated slice (with a draft PR) and a skipped slice.
|
|
64
|
-
addTask(db, plan, 5, "escalated", 1, { pr_key: "o/r#14", url: "https://gh/14", status: "escalated", process_key: "P14" });
|
|
65
|
-
addTask(db, plan, 6, "skipped", 1);
|
|
66
|
-
|
|
67
|
-
const rows = db
|
|
68
|
-
.prepare("SELECT * FROM plan_wave_summary WHERE plan_key = ? ORDER BY wave")
|
|
69
|
-
.all(plan) as Array<Record<string, unknown>>;
|
|
70
|
-
assertEquals(rows.length, 2);
|
|
71
|
-
|
|
72
|
-
const w0 = rows[0];
|
|
73
|
-
assertEquals(w0.total, 5);
|
|
74
|
-
assertEquals(w0.merged, 3);
|
|
75
|
-
assertEquals(w0.in_flight, 1);
|
|
76
|
-
assertEquals(w0.blocked, 1);
|
|
77
|
-
assertEquals(w0.escalated, 0);
|
|
78
|
-
assertEquals(w0.skipped, 0);
|
|
79
|
-
// 3 filled + 2 empty glyphs (width = total), then the named non-zero categories.
|
|
80
|
-
assertEquals(w0.bar, "▓▓▓░░ 3/5 merged · 1 in-flight · 1 blocked");
|
|
81
|
-
|
|
82
|
-
const w1 = rows[1];
|
|
83
|
-
assertEquals(w1.total, 2);
|
|
84
|
-
assertEquals(w1.merged, 0);
|
|
85
|
-
assertEquals(w1.in_flight, 0);
|
|
86
|
-
assertEquals(w1.escalated, 1);
|
|
87
|
-
assertEquals(w1.skipped, 1);
|
|
88
|
-
assertEquals(w1.bar, "░░ 0/2 merged · 1 escalated · 1 skipped");
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("a merged PR wins over the task's own status, and unlevelized tasks are excluded", () => {
|
|
92
|
-
const db = viewDb();
|
|
93
|
-
const plan = "o/r#2";
|
|
94
|
-
// An escalated task whose PR nonetheless merged counts as merged, not escalated (PR wins).
|
|
95
|
-
addTask(db, plan, 0, "escalated", 0, { pr_key: "o/r#20", url: "https://gh/20", status: "merged", process_key: "P20" });
|
|
96
|
-
// A task with no wave yet (not levelized) must not appear in any wave row.
|
|
97
|
-
db.prepare(
|
|
98
|
-
"INSERT INTO plan_tasks (plan_key, task_index, task_id, status, wave) VALUES (?, ?, ?, ?, NULL)",
|
|
99
|
-
).run(plan, 1, "t1", "pending");
|
|
100
|
-
|
|
101
|
-
const rows = db
|
|
102
|
-
.prepare("SELECT wave, total, merged, escalated FROM plan_wave_summary WHERE plan_key = ?")
|
|
103
|
-
.all(plan) as Array<Record<string, unknown>>;
|
|
104
|
-
assertEquals(rows.length, 1);
|
|
105
|
-
assertEquals(rows[0].wave, 0);
|
|
106
|
-
assertEquals(rows[0].total, 1);
|
|
107
|
-
assertEquals(rows[0].merged, 1);
|
|
108
|
-
assertEquals(rows[0].escalated, 0);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("plan_wave_tasks carries each task's PR url + process_key link targets", () => {
|
|
112
|
-
const db = viewDb();
|
|
113
|
-
addTask(db, "o/r#3", 0, "opened", 0, { pr_key: "o/r#30", url: "https://gh/30", status: "converging", process_key: "P30" });
|
|
114
|
-
addTask(db, "o/r#3", 1, "blocked", 0); // no PR → null link targets
|
|
115
|
-
|
|
116
|
-
const rows = db
|
|
117
|
-
.prepare("SELECT task_id, pr_key, pr_url, process_key FROM plan_wave_tasks WHERE plan_key = ? ORDER BY task_index")
|
|
118
|
-
.all("o/r#3") as Array<Record<string, unknown>>;
|
|
119
|
-
assertEquals(rows[0].pr_url, "https://gh/30");
|
|
120
|
-
assertEquals(rows[0].process_key, "P30");
|
|
121
|
-
assertEquals(rows[1].pr_url, null);
|
|
122
|
-
assertEquals(rows[1].process_key, null);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("epic-detail projects the wave banner, the per-wave summary, and task→representation links", () => {
|
|
126
|
-
const page = JSON.parse(readFileSync(PAGE, "utf8"));
|
|
127
|
-
const byId = (id: string) => page.nodes.find((n: { id: string }) => n.id === id);
|
|
128
|
-
|
|
129
|
-
// 1. The epic-level wave banner: a prose node reading wave_label + epic_phase off the derived
|
|
130
|
-
// `plan_read_model` VIEW (epic #412 — retiring the worker-maintained plans.wave_label column;
|
|
131
|
-
// the banner now reads the single-source-of-truth view instead of the raw `plans` table).
|
|
132
|
-
const banner = byId("wave-banner");
|
|
133
|
-
assert(banner, "epic detail must show the epic-level wave banner");
|
|
134
|
-
assertEquals(banner.props.data.table, "plan_read_model");
|
|
135
|
-
assert(
|
|
136
|
-
banner.props.data.filter.some((f: { field: string; eqParam?: boolean }) => f.field === "plan_key" && f.eqParam),
|
|
137
|
-
"the banner is scoped to this epic",
|
|
138
|
-
);
|
|
139
|
-
assert(/\{\{\s*wave_label\s*\}\}/.test(banner.props.header), "the banner surfaces the wave_label");
|
|
140
|
-
assert(/\{\{\s*epic_phase\s*\}\}/.test(banner.props.header), "the banner surfaces the epic phase");
|
|
141
|
-
|
|
142
|
-
// 2. The per-wave summary section: a grid over the derived VIEW, ordered by wave, with the bar.
|
|
143
|
-
const summary = byId("wave-summary");
|
|
144
|
-
assert(summary, "epic detail must show the per-wave progress summary");
|
|
145
|
-
assertEquals(summary.props.data.table, "plan_wave_summary");
|
|
146
|
-
assertEquals(summary.props.data.orderBy.field, "wave");
|
|
147
|
-
const summaryCols: string[] = summary.props.columns.map((c: { field: string }) => c.field);
|
|
148
|
-
for (const f of ["wave", "bar", "merged", "in_flight", "blocked", "escalated", "skipped", "total"]) {
|
|
149
|
-
assert(summaryCols.includes(f), `the summary grid shows ${f}`);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// 3. The wave-state grid links each in-flight task to its representation (PR + process instance).
|
|
153
|
-
const waveState = byId("wave-state");
|
|
154
|
-
assert(waveState, "epic detail must keep the wave-state grid");
|
|
155
|
-
assertEquals(waveState.props.data.table, "plan_wave_tasks");
|
|
156
|
-
const cols: Array<Record<string, unknown>> = waveState.props.columns;
|
|
157
|
-
const prCol = cols.find((c) => c.field === "pr_key");
|
|
158
|
-
assertEquals(prCol?.linkField, "pr_url", "the PR cell links to the GitHub PR url");
|
|
159
|
-
const statusCol = cols.find((c) => c.field === "status") as {
|
|
160
|
-
link?: { kind?: string; keyField?: string };
|
|
161
|
-
};
|
|
162
|
-
assertEquals(statusCol.link?.kind, "processExplorer", "the status cell links to the process instance");
|
|
163
|
-
assertEquals(statusCol.link?.keyField, "process_key");
|
|
164
|
-
// The existing tabs (Active / Skipped / All) and detail drawer must still be present.
|
|
165
|
-
assertEquals(waveState.props.tabs.length, 3);
|
|
166
|
-
const detailFields: string[] = waveState.props.detail.fields.map((f: { field: string }) => f.field);
|
|
167
|
-
for (const f of ["open_question", "answer", "draft_pr_key", "prompt"]) {
|
|
168
|
-
assert(detailFields.includes(f), `the detail drawer keeps ${f}`);
|
|
169
|
-
}
|
|
170
|
-
});
|