diff --git a/apps/fabro-web/app/components/stage-renderers/conditional-decision.test.tsx b/apps/fabro-web/app/components/stage-renderers/conditional-decision.test.tsx new file mode 100644 index 000000000..d063caee8 --- /dev/null +++ b/apps/fabro-web/app/components/stage-renderers/conditional-decision.test.tsx @@ -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 | 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( + + + , + ); + }); + 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", + }); + }); +}); diff --git a/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx b/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx index 18a859b04..c2969a31d 100644 --- a/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx +++ b/apps/fabro-web/app/components/stage-renderers/conditional-decision.tsx @@ -10,13 +10,14 @@ import type { Stage } from "../stage-sidebar"; import { StageMetaBar } from "./meta-bar"; import type { EdgeSelection } from "./helpers"; -const REASON_LABEL: Record = { +const REASON_LABEL: Record = { 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; } diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts index 3aa0d557e..bf9c02baa 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts @@ -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; } diff --git a/apps/fabro-web/app/lib/petri-stream.test.ts b/apps/fabro-web/app/lib/petri-stream.test.ts index ea6d8b4ce..b5971a2ab 100644 --- a/apps/fabro-web/app/lib/petri-stream.test.ts +++ b/apps/fabro-web/app/lib/petri-stream.test.ts @@ -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(); diff --git a/apps/fabro-web/app/lib/petri-stream.ts b/apps/fabro-web/app/lib/petri-stream.ts index 76e878ae8..526c91de1 100644 --- a/apps/fabro-web/app/lib/petri-stream.ts +++ b/apps/fabro-web/app/lib/petri-stream.ts @@ -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; } /**