style: clarify lifecycle diagrams at presentation distance
This commit is contained in:
@@ -76,9 +76,7 @@ describe("AuthoringPhaseVisual", () => {
|
||||
expect(within(result).getByText(/missing edges for outcomes.*ok/i)).toBeInTheDocument();
|
||||
expect(within(result).getByText(/cannot prove where execution goes next/i)).toBeInTheDocument();
|
||||
expect(within(result).getByText("Revision 3")).toBeInTheDocument();
|
||||
expect(within(result).getByRole("note", { name: /prepared fault injection/i })).toHaveTextContent(
|
||||
"wf draft remove-route lda_report_workflow --revision 2 --step analyze --outcome ok",
|
||||
);
|
||||
expect(within(result).queryByRole("note", { name: /prepared fault injection/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders route repair as a valid revision with compact prior context", () => {
|
||||
|
||||
@@ -173,11 +173,6 @@ const DiagnosticResult = ({ evidence }: { readonly evidence: EvidenceOf<"diagnos
|
||||
]}
|
||||
/>
|
||||
<p className="authoring-result__explanation">{evidence.diagnostic.explanation}</p>
|
||||
<section className="authoring-result__fault-injection" role="note" aria-label={evidence.faultInjection.label}>
|
||||
<span>{evidence.faultInjection.label}</span>
|
||||
<code>{evidence.faultInjection.command}</code>
|
||||
<p>Valid revision {evidence.faultInjection.fromRevision} became invalid revision {evidence.faultInjection.toRevision}.</p>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -36,9 +36,7 @@ describe("AuthoringWorkflowDiagram", () => {
|
||||
it("shows an absent analyze.ok route as the diagnostic headline", () => {
|
||||
renderDiagram("diagnose");
|
||||
|
||||
expect(screen.getByText("Missing route")).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: /analyze ok route is missing/i })).toBeInTheDocument();
|
||||
expect(document.querySelector('[data-authoring-edge-id="analyze.ok"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restores analyze.ok without retaining the missing-route marker", () => {
|
||||
@@ -46,6 +44,15 @@ describe("AuthoringWorkflowDiagram", () => {
|
||||
|
||||
expect(screen.getByRole("img", { name: /analyze ok route restored/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Missing route")).not.toBeInTheDocument();
|
||||
expect(document.querySelector('[data-authoring-edge-id="analyze.ok"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reserves a wider rank for the route-state edge label", () => {
|
||||
renderDiagram("repair");
|
||||
const analyze = document.querySelector('[data-id="analyze"]');
|
||||
const end = document.querySelector('[data-id="__end__"]');
|
||||
|
||||
expect(analyze).toHaveStyle({ width: "224px" });
|
||||
expect(end).toHaveStyle({ width: "224px" });
|
||||
expect(analyze?.getAttribute("style")).not.toEqual(end?.getAttribute("style"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
type Node,
|
||||
type NodeProps,
|
||||
type NodeTypes,
|
||||
type ReactFlowInstance,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { CircleStop, Database, SquareFunction, TriangleAlert } from "lucide-react";
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
import { CircleStop, Database, SquareFunction } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReviewedAuthoringEvidence } from "./reviewed-authoring-evidence.js";
|
||||
|
||||
type WorkflowEvidence = Extract<
|
||||
@@ -49,28 +52,56 @@ const AuthoringFlowNode = ({ id, data }: NodeProps<Node<AuthoringNodeData>>) =>
|
||||
|
||||
const nodeTypes: NodeTypes = { authoring: AuthoringFlowNode };
|
||||
|
||||
// Stable positions make Draft -> Diagnose -> Repair read as one changing object.
|
||||
const workflowNodes: Node<AuthoringNodeData>[] = [
|
||||
const NODE_WIDTH = 224;
|
||||
const NODE_HEIGHT = 76;
|
||||
const FIT_VIEW_OPTIONS = { padding: 0.16, minZoom: 0.55, maxZoom: 1.25, duration: 0 } as const;
|
||||
|
||||
const workflowNodeDefinitions: Omit<Node<AuthoringNodeData>, "position">[] = [
|
||||
{
|
||||
id: "read_documents",
|
||||
type: "authoring",
|
||||
position: { x: 0, y: 60 },
|
||||
style: { width: NODE_WIDTH },
|
||||
data: { label: "read_documents", role: "source" },
|
||||
},
|
||||
{
|
||||
id: "analyze",
|
||||
type: "authoring",
|
||||
position: { x: 310, y: 60 },
|
||||
style: { width: NODE_WIDTH },
|
||||
data: { label: "analyze", role: "action" },
|
||||
},
|
||||
{
|
||||
id: "__end__",
|
||||
type: "authoring",
|
||||
position: { x: 620, y: 60 },
|
||||
style: { width: NODE_WIDTH },
|
||||
data: { label: "END", role: "outcome" },
|
||||
},
|
||||
];
|
||||
|
||||
const layoutWorkflowNodes = (): Node<AuthoringNodeData>[] => {
|
||||
const graph = new Dagre.graphlib.Graph();
|
||||
graph.setGraph({ rankdir: "LR", ranksep: 72, nodesep: 48, marginx: 0, marginy: 0 });
|
||||
graph.setDefaultEdgeLabel(() => ({}));
|
||||
for (const node of workflowNodeDefinitions) {
|
||||
graph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
|
||||
}
|
||||
graph.setEdge("read_documents", "analyze", { width: 28, height: 22 });
|
||||
// Reserve the widest label once so all three beats retain identical node positions.
|
||||
graph.setEdge("analyze", "__end__", { width: 154, height: 24 });
|
||||
Dagre.layout(graph);
|
||||
return workflowNodeDefinitions.map((node) => {
|
||||
const position = graph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: position.x - NODE_WIDTH / 2,
|
||||
y: position.y - NODE_HEIGHT / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const workflowNodes = layoutWorkflowNodes();
|
||||
|
||||
const edgesForMode = (mode: AuthoringWorkflowMode): Edge[] => [
|
||||
{
|
||||
id: "read_documents.ok",
|
||||
@@ -79,18 +110,13 @@ const edgesForMode = (mode: AuthoringWorkflowMode): Edge[] => [
|
||||
label: "ok",
|
||||
className: "authoring-workflow-diagram__edge",
|
||||
},
|
||||
...(mode === "diagnostic"
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "analyze.ok",
|
||||
source: "analyze",
|
||||
target: "__end__",
|
||||
label: mode === "repair" ? "restored · ok" : "ok",
|
||||
animated: mode === "repair",
|
||||
className: `authoring-workflow-diagram__edge${mode === "repair" ? " authoring-workflow-diagram__edge--restored" : ""}`,
|
||||
},
|
||||
]),
|
||||
{
|
||||
id: "analyze.ok",
|
||||
source: "analyze",
|
||||
target: "__end__",
|
||||
label: mode === "diagnostic" ? "Missing route · ok" : mode === "repair" ? "Route restored · ok" : "ok",
|
||||
className: `authoring-workflow-diagram__edge${mode === "diagnostic" ? " authoring-workflow-diagram__edge--missing" : ""}${mode === "repair" ? " authoring-workflow-diagram__edge--restored" : ""}`,
|
||||
},
|
||||
];
|
||||
|
||||
const accessibleLabelForMode = (mode: AuthoringWorkflowMode): string => {
|
||||
@@ -99,19 +125,42 @@ const accessibleLabelForMode = (mode: AuthoringWorkflowMode): string => {
|
||||
return "Authoring workflow diagram: valid draft routes";
|
||||
};
|
||||
|
||||
const AuthoringWorkflowDiagramInner = ({ mode }: AuthoringWorkflowDiagramProps) => (
|
||||
<div
|
||||
className="authoring-workflow-diagram"
|
||||
role="img"
|
||||
aria-label={accessibleLabelForMode(mode)}
|
||||
data-workflow-mode={mode}
|
||||
>
|
||||
const AuthoringWorkflowDiagramInner = ({ mode }: AuthoringWorkflowDiagramProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [flow, setFlow] = useState<ReactFlowInstance<Node<AuthoringNodeData>, Edge>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!flow) return undefined;
|
||||
let frame = window.requestAnimationFrame(() => void flow.fitView(FIT_VIEW_OPTIONS));
|
||||
// Scene columns resize without remounting React Flow, so fit the settled box again.
|
||||
const observer = typeof ResizeObserver === "undefined" || !containerRef.current
|
||||
? undefined
|
||||
: new ResizeObserver(() => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(() => void flow.fitView(FIT_VIEW_OPTIONS));
|
||||
});
|
||||
if (containerRef.current) observer?.observe(containerRef.current);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [flow, mode]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="authoring-workflow-diagram"
|
||||
role="img"
|
||||
aria-label={accessibleLabelForMode(mode)}
|
||||
data-workflow-mode={mode}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={workflowNodes}
|
||||
edges={edgesForMode(mode)}
|
||||
nodeTypes={nodeTypes}
|
||||
onInit={setFlow}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.16, minZoom: 0.7, maxZoom: 1.25 }}
|
||||
fitViewOptions={FIT_VIEW_OPTIONS}
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
@@ -121,24 +170,9 @@ const AuthoringWorkflowDiagramInner = ({ mode }: AuthoringWorkflowDiagramProps)
|
||||
preventScrolling={false}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
/>
|
||||
<div className="authoring-workflow-diagram__route-state" aria-hidden="true">
|
||||
{mode === "diagnostic" ? (
|
||||
<span className="authoring-workflow-diagram__missing-route">
|
||||
<TriangleAlert />
|
||||
<strong>Missing route</strong>
|
||||
<small>analyze · ok</small>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
data-authoring-edge-id="analyze.ok"
|
||||
data-route-state={mode === "repair" ? "restored" : "present"}
|
||||
>
|
||||
{mode === "repair" ? "Route restored" : "Complete route"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
/** Renders the same authored workflow while its route state changes between beats. */
|
||||
export const AuthoringWorkflowDiagram = (props: AuthoringWorkflowDiagramProps) => (
|
||||
|
||||
@@ -52,6 +52,12 @@ describe("PreparedAuthoringLifecycleScene", () => {
|
||||
expect(frame).toHaveTextContent("missing_outcome_edge");
|
||||
expect(frame).toHaveTextContent("nodes[analyze]");
|
||||
expect(frame).toHaveTextContent(/missing edges for outcomes.*ok/i);
|
||||
const setup = within(frame).getByRole("note", { name: /prepared fault injection/i });
|
||||
expect(setup).toHaveTextContent(/valid revision 2/i);
|
||||
expect(setup).toHaveTextContent(/remove analyze\.ok/i);
|
||||
expect(setup).toHaveTextContent(/invalid revision 3/i);
|
||||
expect(within(screen.getByRole("region", { name: "draft validation diagnostic" }))
|
||||
.queryByRole("note", { name: /prepared fault injection/i })).not.toBeInTheDocument();
|
||||
expect(frame).toHaveAttribute("data-authoring-step", "diagnose");
|
||||
expect(frame).toHaveAttribute("data-recording-phase", "validate");
|
||||
expect(screen.getByRole("region", { name: "draft validation diagnostic" })).toHaveAttribute(
|
||||
|
||||
@@ -127,6 +127,24 @@ export const PreparedAuthoringLifecycleScene = ({ scene, beat, onAdvance, discus
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
{projection.evidence.kind === "diagnostic" && (
|
||||
<aside
|
||||
className="prepared-lifecycle-scene__setup-strip"
|
||||
role="note"
|
||||
aria-label={projection.evidence.faultInjection.label}
|
||||
>
|
||||
<span>Prepared setup</span>
|
||||
<strong>
|
||||
Valid revision {projection.evidence.faultInjection.fromRevision}
|
||||
{" → remove analyze.ok → "}
|
||||
invalid revision {projection.evidence.faultInjection.toRevision}
|
||||
</strong>
|
||||
<details>
|
||||
<summary>Exact command</summary>
|
||||
<code>{projection.evidence.faultInjection.command}</code>
|
||||
</details>
|
||||
</aside>
|
||||
)}
|
||||
<AuthoringPhaseVisual projection={projection} />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user