Fix Decision tab fallback explanations

This commit is contained in:
Scott Werner 2026-10-28 10:32:39 -04:00
parent d8a43b13f4
commit 24bdc788bf
5 changed files with 157 additions and 9 deletions

View file

@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { RunStreamItem } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { findPetriEdgeForStage } from "../../lib/petri-stream";
import { makeStage, setupReactTestEnv, textContent } from "../../lib/test-utils";
import { ConditionalDecision } from "./conditional-decision";
let teardown: () => void;
let renderer: TestRenderer.ReactTestRenderer | undefined;
beforeEach(() => { teardown = setupReactTestEnv(); });
afterEach(() => {
if (renderer) act(() => renderer!.unmount());
renderer = undefined;
teardown();
});
// The reported decision: the false branch goes to a; the default goes to z.
function route(edge = 2, visit = 1) {
return {
run_id: "run-1", stream_seq: visit, kind: "petri", id: String(visit), recorded_at: 1,
item: {
subject: {
node: {
name: "gate", kind: "noop",
meta: {
kind: "conditional",
edges: {
"1": { to: "a", label: "Special", condition: "never=true" },
"2": { to: "z", label: "Default" },
} as Record<string, unknown> | undefined,
},
},
visit,
},
record: { body: { event: "route.applied", kind: "edge", edge: edge as unknown } },
derived: { target: { name: edge === 1 ? "a" : "z" } },
},
} satisfies RunStreamItem;
}
function renderDecision(stream: RunStreamItem[], visit = 1) {
const stage = makeStage({ id: `gate@${visit}`, nodeId: "gate", handler: "conditional", visit });
const targets = ["a", "z"].map((nodeId) => makeStage({ id: `${nodeId}@1`, nodeId }));
act(() => {
renderer = TestRenderer.create(
<MemoryRouter>
<ConditionalDecision
stage={stage}
edge={findPetriEdgeForStage(stream, stage.id)}
allStages={targets}
runId="run-1"
/>
</MemoryRouter>,
);
});
return {
details: renderer!.root.findAllByType("dd").map(textContent),
target: renderer!.root.findByType("a").props.href,
};
}
describe("Decision reason from the selected route", () => {
test.each([false, true])("an unconditional fallback is the default edge (replayed: %s)", (replay) => {
const stream = [route()];
expect(renderDecision(replay ? JSON.parse(JSON.stringify(stream)) : stream)).toEqual({
details: ["Default edge"], target: "/runs/run-1/stages/z%401",
});
});
test("a matched condition keeps its expression and target", () => {
const item = route(1);
item.item.subject.node.meta.edges!["1"] = {
to: "a", label: "Special", condition: "outcome=succeeded",
};
expect(renderDecision([item])).toEqual({
details: ["Matched condition", "outcome=succeeded"], target: "/runs/run-1/stages/a%401",
});
});
test.each([
["missing table", undefined],
["missing selected edge", { "1": { to: "a", condition: "never=true" } }],
["malformed selected edge", { "2": [] }],
["missing target metadata", { "2": {} }],
["mismatched target metadata", { "2": { to: "a" } }],
["malformed condition", { "2": { to: "z", condition: false } }],
])("%s keeps the target without inventing a reason", (_name, edges) => {
const item = route();
item.item.subject.node.meta.edges = edges;
expect(renderDecision([item])).toEqual({
details: ["Reason unavailable"], target: "/runs/run-1/stages/z%401",
});
});
test("a missing edge id cannot identify the fallback", () => {
const item = route();
item.item.record.body.edge = undefined;
expect(renderDecision([item]).details).toEqual(["Reason unavailable"]);
});
test("a human choice without a condition is not called a default", () => {
const item = route();
item.item.subject.node.meta.kind = "human";
expect(renderDecision([item]).details).toEqual(["Reason unavailable"]);
});
test("the edge id distinguishes routes sharing a destination", () => {
const item = route();
item.item.subject.node.meta.edges!["1"] = { to: "z", condition: "never=true" };
expect(renderDecision([item]).details).toEqual(["Default edge"]);
});
test("a later visit does not replace the selected visit's reason", () => {
const first = route(2, 1);
const second = route(1, 2);
expect(renderDecision([first, second], 1).details).toEqual(["Default edge"]);
expect(findPetriEdgeForStage([first, second], "gate@2")).toMatchObject({
toNode: "a", reason: "condition", condition: "never=true",
});
});
});

View file

@ -10,13 +10,14 @@ import type { Stage } from "../stage-sidebar";
import { StageMetaBar } from "./meta-bar";
import type { EdgeSelection } from "./helpers";
const REASON_LABEL: Record<string, string> = {
const REASON_LABEL: Record<EdgeSelection["reason"], string> = {
condition: "Matched condition",
unconditional: "Default edge",
preferred_label: "Preferred label",
unknown: "Reason unavailable",
};
function reasonLabel(reason: string): string {
function reasonLabel(reason: EdgeSelection["reason"]): string {
return REASON_LABEL[reason] ?? reason;
}

View file

@ -83,7 +83,7 @@ export interface StageContextData {
export interface EdgeSelection {
fromNode: string;
toNode: string;
reason: string;
reason: "condition" | "unconditional" | "preferred_label" | "unknown";
condition: string | null;
}

View file

@ -249,7 +249,7 @@ describe("stage renderers", () => {
expect(findPetriEdgeForStage(gate.stream, "gate@1")).toEqual({
fromNode: "gate",
toNode: "no",
reason: "condition",
reason: "unknown",
condition: null,
});
expect(findPetriEdgeForStage(gate.stream, "exit@1")).toBeNull();

View file

@ -462,11 +462,25 @@ export function findPetriEdgeForStage(
if (petriStageLabel(item) !== stageLabel) continue;
const target = getString(getObject(derived(item), "target"), "name");
if (!target) continue;
const edge = selectedEdgeMetadata(item);
const condition = matchedCondition(item) ?? null;
let reason: EdgeSelection["reason"] = "unknown";
if (condition) {
reason = "condition";
} else if (
edge &&
getString(getObject(subjectNode(item), "meta"), "kind") === "conditional" &&
(edge.condition === undefined || edge.condition === "")
) {
// Other handlers can select a conditionless edge by preferred label or
// suggested target. Only a conditional stage establishes this fallback.
reason = "unconditional";
}
latest = {
fromNode: getString(subjectNode(item), "name") ?? stageLabel,
toNode: target,
reason: "condition",
condition: matchedCondition(item) ?? null,
reason,
condition,
};
}
return latest;
@ -600,10 +614,20 @@ export function agentEnvelopesOf(items: PetriStream): PetriAgentEnvelope[] {
* the edge's `condition` when it has one (EVENTS.md "Source metadata").
*/
export function matchedCondition(item: RunStreamItem): string | undefined {
const edge = getNumber(petriBody(item), "edge");
if (edge === undefined) return undefined;
const condition = getString(selectedEdgeMetadata(item), "condition");
return condition?.trim() ? condition : undefined;
}
/** Missing or inconsistent metadata does not establish an unconditional edge. */
function selectedEdgeMetadata(item: RunStreamItem): UnknownRecord | undefined {
const body = petriBody(item);
if (getString(body, "kind") !== "edge") return undefined;
const edge = getNumber(body, "edge");
if (edge === undefined || !Number.isSafeInteger(edge) || edge < 0) return undefined;
const edges = getObject(getObject(subjectNode(item), "meta"), "edges");
return getString(getObject(edges, String(edge)), "condition");
const selected = getObject(edges, String(edge));
const target = getString(getObject(derived(item), "target"), "name");
return target && getString(selected, "to") === target ? selected : undefined;
}
/**