mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-11-01 07:02:30 +00:00
Fix Decision tab fallback explanations
This commit is contained in:
parent
d8a43b13f4
commit
24bdc788bf
5 changed files with 157 additions and 9 deletions
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue