diff --git a/frontend/src/App.css b/frontend/src/App.css index b2deee5..310aa3f 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1223,3 +1223,18 @@ body.resizing-side-panel canvas { line-height: 1.35; } + +.computed-path-explanations { border-top: 1px solid #e2e8f0; } +.computed-path-explanations > summary { padding: 8px 10px; color: #1d4ed8; font-size: 11px; font-weight: 750; cursor: pointer; } +.constraint-explanation-list { display: grid; gap: 8px; padding: 8px 10px 10px; background: #f8fafc; } +.constraint-explanation { padding: 9px; border: 1px solid #bbf7d0; border-radius: 7px; background: #f0fdf4; } +.constraint-explanation-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.constraint-explanation-heading strong { color: #14532d; font-size: 11px; overflow-wrap: anywhere; } +.constraint-explanation-heading span { padding: 2px 6px; border-radius: 999px; background: #dcfce7; color: #166534; font-size: 9px; font-weight: 800; } +.constraint-explanation > small { display: block; margin-top: 2px; color: #64748b; font-size: 9px; } +.constraint-explanation > p { margin: 6px 0 0; color: #334155; font-size: 10px; line-height: 1.4; } +.constraint-explanation .constraint-exercise-status { color: #64748b; } +.constraint-explanation ol { display: grid; gap: 4px; margin: 7px 0 0; padding-left: 20px; } +.constraint-explanation li { color: #475569; font-size: 10px; } +.constraint-explanation li button { padding: 0; border: 0; background: transparent; color: #2563eb; font-size: 10px; text-align: left; overflow-wrap: anywhere; } +.constraint-explanation li button:hover { color: #1d4ed8; text-decoration: underline; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1ed0c42..dd7a947 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -34,10 +34,11 @@ import { createSelectedPathJsonDocument, parseGraphJsonText, serializeGraphJson, + type PersistedPathSearchConfiguration, } from "./graph/graphJson"; import { useGraphAnalysis } from "./graph/useGraphAnalysis"; import { usePathSearch } from "./graph/usePathSearch"; -import type { BoundedPath } from "./graph/pathSearch"; +import type { BoundedPath, ConstraintExplanationEvent } from "./graph/pathSearch"; import type { StronglyConnectedComponent } from "./graph/graphAnalysis"; import { buildTransitionCatalogue } from "./graph/transitionCatalog"; import { buildTransitionDataCatalogue } from "./graph/transitionDataCatalogue"; @@ -660,6 +661,20 @@ function App() { URL.revokeObjectURL(url); } + function getPersistedPathSearchConfiguration(): PersistedPathSearchConfiguration { + const sourceNodeId = pathSearchSource.trim(); + const targetNodeId = pathSearchTarget.trim(); + return { + sourceNodeId, + endpointMode: targetNodeId + ? "specific-target" + : "constraint-satisfaction", + ...(targetNodeId ? { targetNodeId } : {}), + requestedPathCount, + maximumVisitsPerState, + requireConstraintExercise, + }; + } function exportFullGraphJson() { const graph = graphRef.current; if (!graph) { @@ -672,9 +687,12 @@ function App() { const safeName = sourceName .replace(/[^a-zA-Z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") || "graph"; - const document = createGraphJsonDocument(graph, { - title: sourceName, - }); + const document = createGraphJsonDocument( + graph, + { title: sourceName }, + declareConstraints, + getPersistedPathSearchConfiguration(), + ); const exportFileName = `${safeName}.json`; downloadTextFile( @@ -722,9 +740,14 @@ function App() { try { const resolved = resolvePath(graph, path); - const document = createSelectedPathJsonDocument(graph, path, { - title: `Selected path ${resolved.startNodeId} to ${resolved.endNodeId}`, - }); + const document = createSelectedPathJsonDocument( + graph, + path, + { + title: `Selected path ${resolved.startNodeId} to ${resolved.endNodeId}`, + }, + declareConstraints, + ); const fileName = `LTSVisualizer-path-${resolved.startNodeId}-to-${resolved.endNodeId}.json`; downloadTextFile( serializeGraphJson(document), @@ -1113,6 +1136,7 @@ function App() { const parsed = parseGraphJsonText(await file.text()); const graph: GraphData = parsed.graph; const importedPath = parsed.selectedPath; + setDeclareConstraints(parsed.declareConstraints); graphRef.current = graph; setGraphLoaded(true); @@ -1127,9 +1151,16 @@ function App() { const defaultPathState = graph.nodes.some((node) => node.id === "0") ? "0" : graph.nodes[0].id; - setPathSearchSource(defaultPathState); - setPathSearchTarget(""); - setRequireConstraintExercise(true); + const importedPathSearch = parsed.pathSearch; + setPathSearchSource(importedPathSearch?.sourceNodeId ?? defaultPathState); + setPathSearchTarget(importedPathSearch?.targetNodeId ?? ""); + setRequestedPathCount(importedPathSearch?.requestedPathCount ?? 5); + setMaximumVisitsPerState( + importedPathSearch?.maximumVisitsPerState ?? 1, + ); + setRequireConstraintExercise( + importedPathSearch?.requireConstraintExercise ?? true, + ); if (importedPath) { const resolved = resolvePath(graph, importedPath); @@ -1685,6 +1716,15 @@ function App() { setStatus(`Focused state ${nodeId}`); } + function focusExplanationEvent( + explanationEvent: ConstraintExplanationEvent, + pathIndex: number, + ) { + focusComputedPathEdge( + explanationEvent.edgeId, + `explanation-${pathIndex}-${explanationEvent.stepNumber}-${explanationEvent.edgeId}`, + ); + } function getComputedPathSteps(path: BoundedPath) { const graph = graphRef.current; if (!graph) return []; @@ -2171,7 +2211,7 @@ function App() { > Cyclic component {getCyclicComponentNumber(component.id)} - {component.nodeIds.length} states ·{" "} + {component.nodeIds.length} states{" \u00b7 "} {component.internalEdgeIds.length} transitions @@ -2325,8 +2365,9 @@ function App() {

Without a target, paths may end at any state after all enabled - constraints are satisfied. Exercise checking prevents vacuous - matches where a constraint never participates. + constraints are satisfied. When exercise checking is enabled, + returned paths must exercise every enabled constraint that + requires exercise.

@@ -2386,13 +2427,53 @@ function App() { {path.edgeIds.length} transition {path.edgeIds.length === 1 ? "" : "s"} - {` · Ends at state ${ + {` \u00b7 Ends at state ${ path.endNodeId ?? steps.at(-1)?.target ?? path.startNodeId }`} + {(path.explanations?.length ?? 0) > 0 && ( +
+ Why this path satisfies the constraints +
+ {path.explanations?.map((explanation) => ( +
+
+ {explanation.constraintId} + Satisfied +
+ {explanation.template} +

{explanation.summary}

+

+ {explanation.exercised + ? "Constraint was exercised by this path." + : "Constraint was satisfied without an exercise event."} +

+ {explanation.events.length > 0 && ( +
    + {explanation.events.map((explanationEvent, eventIndex) => ( +
  1. + +
  2. + ))} +
+ )} +
+ ))} +
+
+ )}
Show transition details {steps.length === 0 ? ( @@ -2432,7 +2513,7 @@ function App() { > {step.source} - +