diff --git a/.changeset/gentle-charts-return.md b/.changeset/gentle-charts-return.md new file mode 100644 index 0000000..8aac577 --- /dev/null +++ b/.changeset/gentle-charts-return.md @@ -0,0 +1,9 @@ +--- +"@typeonce/effect-machine-devtools": patch +--- + +Keep charts available for machines that transition between compound states and nested children. + +Hierarchy routes now follow the vertical chart direction with stable source, terminal, label, and initial-marker clearance. This includes multi-branch fan-out and returns to a parent's initial configuration. + +Parent-origin cues now identify transitions from an ancestor into deeply nested descendants. Transitions owned by parallel states, including child-invocation outcomes, use short local corridors instead of long ELK detours across the container. diff --git a/packages/devtools/src/internal/browser/chart-layout-policy.ts b/packages/devtools/src/internal/browser/chart-layout-policy.ts index fc90353..a4ed23b 100644 --- a/packages/devtools/src/internal/browser/chart-layout-policy.ts +++ b/packages/devtools/src/internal/browser/chart-layout-policy.ts @@ -182,10 +182,10 @@ export const makeChartLayoutPolicy = (model: ChartModel): ChartLayoutPolicy => { differentAt++ } if (differentAt === sourceLineage.length) { - return { direction: "forward", sourceSide: "EAST", targetSide: "EAST" } + return { direction: "forward", sourceSide: "SOUTH", targetSide: "NORTH" } } if (differentAt === targetLineage.length) { - return { direction: "backward", sourceSide: "WEST", targetSide: "WEST" } + return { direction: "backward", sourceSide: "NORTH", targetSide: "SOUTH" } } const source = nodePolicies.get(sourceLineage[differentAt]!) ?? unreachableNode diff --git a/packages/devtools/src/internal/browser/chart-layout.ts b/packages/devtools/src/internal/browser/chart-layout.ts index 8c72e0b..bdac2f7 100644 --- a/packages/devtools/src/internal/browser/chart-layout.ts +++ b/packages/devtools/src/internal/browser/chart-layout.ts @@ -10,12 +10,23 @@ import type { } from "elkjs/lib/elk-api.js" import ELKBundle from "elkjs/lib/elk.bundled.js" import { type ChartPortSide, makeChartLayoutPolicy } from "./chart-layout-policy.js" -import type { ChartEdge, ChartInitial, ChartModel, ChartNode, ChartRuntimeTarget } from "./chart-model.js" +import { + type ChartEdge, + type ChartInitial, + type ChartModel, + type ChartNode, + type ChartRuntimeTarget, + isChartStateDescendant +} from "./chart-model.js" export const maxVisibleActivities = 3 export const chartSelfLoopMinimumClearance = 24 export const chartEdgeLabelSpacing = 5 +const initialMarkerTopOffset = 17 +const initialMarkerSize = 7 +const initialMarkerRouteClearance = 12 + export interface ChartPoint { readonly x: number readonly y: number @@ -259,8 +270,6 @@ const runtimeNodeId = (target: ChartRuntimeTarget): string => `node:${target.id} const runtimeTargetPortId = (target: ChartRuntimeTarget): string => `port:${target.edgeId}:target` const unconnectedRegionId = (parent: string | null): string => `region:unconnected:${parent ?? "root"}` const isSelfTransition = (edge: ChartEdge): boolean => edge.kind === "targetless" || edge.target === edge.source -const isDescendantPath = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`) - const selfLoopsBySource = (edges: ReadonlyArray): ReadonlyMap => { const counts = new Map() for (const edge of edges) { @@ -380,7 +389,7 @@ const makeGraph = ( id: node.path, ports: [...ports.get(node.path) ?? []], layoutOptions: { - ...(profile.portConstraints === "fixed" && node.children.length === 0 + ...(profile.portConstraints === "fixed" ? { "elk.portConstraints": "FIXED_SIDE" } : {}), "elk.spacing.portPort": "24", @@ -508,6 +517,17 @@ const add = (left: ChartPoint, right: ChartPoint): ChartPoint => ({ y: left.y + right.y }) +const between = (value: number, first: number, second: number): boolean => + value >= Math.min(first, second) && value <= Math.max(first, second) + +const redundantCollinearPoint = ( + start: ChartPoint, + middle: ChartPoint, + end: ChartPoint +): boolean => + start.x === middle.x && middle.x === end.x && between(middle.y, start.y, end.y) || + start.y === middle.y && middle.y === end.y && between(middle.x, start.x, end.x) + const compactPoints = (points: ReadonlyArray): ReadonlyArray => { const result: Array = [] for (const point of points) { @@ -516,8 +536,7 @@ const compactPoints = (points: ReadonlyArray): ReadonlyArray, node: LaidOutChartNode ): ReadonlyArray => { @@ -604,25 +623,16 @@ const trimRouteFromCompoundHeader = ( ) if (intersection !== null) return compactPoints([intersection, ...points.slice(index)]) } - return points -} - -const normalizeHierarchyRoute = ( - edge: ChartEdge, - points: ReadonlyArray, - nodes: ReadonlyMap -): ReadonlyArray => { - if (edge.target !== null && isDescendantPath(edge.target, edge.source)) { - const source = nodes.get(edge.source) - return source === undefined ? points : trimRouteFromCompoundHeader(points, source) + const first = points[0] + const next = points[1] + if (first === undefined || next === undefined) return points + const anchor = { + x: Math.min(node.x + node.width, Math.max(node.x, first.x)), + y: boundary } - if (edge.target !== null && isDescendantPath(edge.source, edge.target)) { - const target = nodes.get(edge.target) - return target === undefined - ? points - : [...trimRouteFromCompoundHeader([...points].reverse(), target)].reverse() - } - return points + return first.x === next.x + ? compactPoints([anchor, ...points.slice(1)]) + : compactPoints([anchor, { x: next.x, y: boundary }, ...points.slice(1)]) } type RouteSide = "NORTH" | "EAST" | "SOUTH" | "WEST" @@ -648,6 +658,23 @@ const endpointSide = (point: ChartPoint, node: LaidOutChartNode): RouteSide => { return distances.sort((left, right) => left[1] - right[1])[0]![0] } +const endpointStepSide = ( + boundary: ChartPoint, + adjacent: ChartPoint, + node: LaidOutChartNode +): RouteSide => { + const rect = routeNodeRect(node) + if (boundary.y === adjacent.y) { + if (boundary.x === rect.left) return "WEST" + if (boundary.x === rect.right) return "EAST" + } + if (boundary.x === adjacent.x) { + if (boundary.y === rect.top) return "NORTH" + if (boundary.y === rect.bottom) return "SOUTH" + } + return endpointSide(boundary, node) +} + const outwardPoint = (point: ChartPoint, side: RouteSide, distance: number): ChartPoint => { switch (side) { case "NORTH": @@ -741,6 +768,90 @@ const routeIsClear = ( obstacles.every((obstacle) => !segmentCrossesInterior(previous, point, obstacle)) }) +const localDescendantRoute = ( + edge: ChartEdge, + points: ReadonlyArray, + source: LaidOutChartNode, + target: LaidOutChartNode, + nodes: ReadonlyArray, + lanes: Map +): ReadonlyArray | null => { + const targetPath = edge.target + if ( + targetPath === null || source.node.type !== "parallel" || + endpointSide(points.at(-1)!, target) !== "NORTH" + ) return null + const containers = nodes.filter((node) => + node.node.children.length > 0 && + node.node.path !== edge.source && + node.node.path !== edge.target && + isChartStateDescendant(node.node.path, edge.source) && + isChartStateDescendant(targetPath, node.node.path) + ) + if (containers.length === 0) return null + + const sourceHeader = nodeHeaderRect(source) + const targetRect = routeNodeRect(target) + const end = pointOnNodeBoundary(points.at(-1)!, target, "NORTH") + const targetStub = { + x: end.x, + y: targetRect.top - initialMarkerTopOffset - initialMarkerRouteClearance + } + if (targetStub.y <= sourceHeader.bottom) return null + + const containerHeaders = containers.map(nodeHeaderRect) + const left = Math.min(...containerHeaders.map((header) => header.left)) + const right = Math.max(...containerHeaders.map((header) => header.right)) + const candidates = (["left", "right"] as const).flatMap((side) => { + const key = `descendant:${edge.source}:${containers[0]!.node.path}:${side}` + const lane = lanes.get(key) ?? 0 + const x = side === "left" ? left - 12 - lane * 8 : right + 12 + lane * 8 + if (x < sourceHeader.left || x > sourceHeader.right) return [] + const route = compactPoints([ + { x, y: sourceHeader.bottom }, + { x, y: targetStub.y }, + targetStub, + end + ]) + return routeIsClear(route, routeObstacles(edge, nodes)) + ? [{ key, lane, route }] + : [] + }) + const selected = + candidates.sort((leftCandidate, rightCandidate) => + chartRouteLength(leftCandidate.route) + leftCandidate.lane * 8 - + (chartRouteLength(rightCandidate.route) + rightCandidate.lane * 8) + )[0] + if (selected === undefined || chartRouteLength(selected.route) + 16 >= chartRouteLength(points)) return null + lanes.set(selected.key, selected.lane + 1) + return selected.route +} + +const normalizeHierarchyRoute = ( + edge: ChartEdge, + points: ReadonlyArray, + nodes: ReadonlyMap, + allNodes: ReadonlyArray, + lanes: Map +): ReadonlyArray => { + if (edge.target !== null && isChartStateDescendant(edge.target, edge.source)) { + const source = nodes.get(edge.source) + const target = nodes.get(edge.target) + if (source === undefined) return points + return target === undefined + ? anchorRouteAtCompoundHeader(points, source) + : localDescendantRoute(edge, points, source, target, allNodes, lanes) ?? + anchorRouteAtCompoundHeader(points, source) + } + if (edge.target !== null && isChartStateDescendant(edge.source, edge.target)) { + const target = nodes.get(edge.target) + return target === undefined + ? points + : [...anchorRouteAtCompoundHeader([...points].reverse(), target)].reverse() + } + return points +} + const sameSideRoute = ( points: ReadonlyArray, side: RouteSide, @@ -906,9 +1017,18 @@ const routeLabelCandidates = ( points: ReadonlyArray, fallback: ChartPoint, width: number, - height: number + height: number, + nodes: ReadonlyArray ): ReadonlyArray => { if (isSelfTransition(edge)) return [fallback] + const source = nodes.find(({ node }) => node.path === edge.source) + const target = edge.target === null + ? undefined + : nodes.find(({ node }) => node.path === edge.target) + const localDescendantTarget = source?.node.type === "parallel" && target !== undefined && + target.node.parent !== source.node.path && isChartStateDescendant(target.node.path, source.node.path) + ? target + : undefined const candidates = points.slice(1).flatMap((end, index) => { const start = points[index]! const horizontal = start.y === end.y @@ -924,10 +1044,17 @@ const routeLabelCandidates = ( return [0.5, 2 / 3, 1 / 3].flatMap((ratio) => { const distance = length * ratio if (distance < clearance || length - distance < clearance) return [] - return [{ + const onRoute = { x: start.x + (end.x - start.x) * ratio, y: start.y + (end.y - start.y) * ratio - }] + } + if (horizontal || localDescendantTarget === undefined) return [onRoute] + const targetCenter = localDescendantTarget.x + localDescendantTarget.width / 2 + const direction = targetCenter < onRoute.x ? -1 : 1 + return [{ + x: onRoute.x + direction * (width / 2 + chartEdgeLabelSpacing), + y: onRoute.y + }, onRoute] }) }) return [...ordered, fallback].filter((candidate, index, all) => @@ -946,7 +1073,8 @@ const placeTransitionLabels = ( transition.points, transition.label, transition.labelWidth, - transition.labelHeight + transition.labelHeight, + nodes ) const position = candidates.find((candidate) => { const candidateRect = labelRect(candidate, transition.labelWidth, transition.labelHeight) @@ -1052,9 +1180,9 @@ const collectLayout = ( initials.push({ initial, x: target.x + 9, - y: target.y - 17, - width: 7, - height: 7 + y: target.y - initialMarkerTopOffset, + width: initialMarkerSize, + height: initialMarkerSize }) } @@ -1076,7 +1204,7 @@ const collectLayout = ( chartEdge, shortenTransitionRoute( chartEdge, - normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath), + normalizeHierarchyRoute(chartEdge, elkPoints, nodesByPath, nodes, hierarchyLanes), nodesByPath, nodes, directLanes @@ -1129,31 +1257,65 @@ const collectLayout = ( ...transitionEdges, ...initialEdges ] - const contentWidth = Math.max( + const contentLeft = Math.min( + ...nodes.map(({ x }) => x), + ...regions.map(({ x }) => x), + ...initials.map(({ x }) => x), + ...runtimeTargets.map(({ x }) => x), + ...edges.flatMap(({ points }) => points.map(({ x }) => x)), + ...transitionEdges.map(({ label, labelWidth }) => label.x - labelWidth / 2) + ) + const contentTop = Math.min( + ...nodes.map(({ y }) => y), + ...regions.map(({ y }) => y), + ...initials.map(({ y }) => y), + ...runtimeTargets.map(({ y }) => y), + ...edges.flatMap(({ points }) => points.map(({ y }) => y)), + ...transitionEdges.map(({ label, labelHeight }) => label.y - labelHeight / 2) + ) + const contentRight = Math.max( 0, ...nodes.map(({ width, x }) => x + width), + ...regions.map(({ width, x }) => x + width), ...initials.map(({ width, x }) => x + width), ...runtimeTargets.map(({ width, x }) => x + width), ...edges.flatMap(({ points }) => points.map(({ x }) => x)), ...transitionEdges.map(({ label, labelWidth }) => label.x + labelWidth / 2) ) - const contentHeight = Math.max( + const contentBottom = Math.max( 0, ...nodes.map(({ height, y }) => y + height), + ...regions.map(({ height, y }) => y + height), ...initials.map(({ height, y }) => y + height), ...runtimeTargets.map(({ height, y }) => y + height), ...edges.flatMap(({ points }) => points.map(({ y }) => y)), ...transitionEdges.map(({ label, labelHeight }) => label.y + labelHeight / 2) ) + const shiftX = Math.max(0, 20 - contentLeft) + const shiftY = Math.max(0, 20 - contentTop) + const translatePoint = ({ x, y }: ChartPoint): ChartPoint => ({ x: x + shiftX, y: y + shiftY }) + const translatedTransitionEdges = transitionEdges.map((edge): LaidOutChartTransition => ({ + ...edge, + points: edge.points.map(translatePoint), + label: translatePoint(edge.label) + })) + const translatedInitialEdges = initialEdges.map((edge): LaidOutChartInitialEdge => ({ + ...edge, + points: edge.points.map(translatePoint) + })) return { - width: Math.max(360, graph.width ?? 0, contentWidth + 20), - height: Math.max(280, graph.height ?? 0, contentHeight + 20), - regions, - nodes, - initials, - runtimeTargets, - edges + width: Math.max(360, (graph.width ?? 0) + shiftX, contentRight + shiftX + 20), + height: Math.max(280, (graph.height ?? 0) + shiftY, contentBottom + shiftY + 20), + regions: regions.map((region) => ({ ...region, x: region.x + shiftX, y: region.y + shiftY })), + nodes: nodes.map((node) => ({ ...node, x: node.x + shiftX, y: node.y + shiftY })), + initials: initials.map((initial) => ({ ...initial, x: initial.x + shiftX, y: initial.y + shiftY })), + runtimeTargets: runtimeTargets.map((target) => ({ + ...target, + x: target.x + shiftX, + y: target.y + shiftY + })), + edges: [...translatedTransitionEdges, ...translatedInitialEdges] } } @@ -1299,8 +1461,8 @@ const collinearOverlap = (left: OrthogonalSegment, right: OrthogonalSegment): nu const transitionTouchesNode = (edge: ChartEdge, path: string): boolean => { const target = edgeTargetPath(edge) - return edge.source === path || isDescendantPath(edge.source, path) || - target === path || target !== null && isDescendantPath(target, path) + return edge.source === path || isChartStateDescendant(edge.source, path) || + target === path || target !== null && isChartStateDescendant(target, path) } const laidOutEdgeId = (edge: LaidOutChartTransition | LaidOutChartInitialEdge): string => @@ -1348,7 +1510,7 @@ export const validateChartLayout = ( } if (nodeBoundaryDistance(start, source) > 0.5) { report("detached-source", transition.edge.id, transition.edge.source) - } else if (!isOutwardStep(start, next, endpointSide(start, source))) { + } else if (!isOutwardStep(start, next, endpointStepSide(start, next, source))) { report("wrong-source-direction", transition.edge.id, transition.edge.source) } } @@ -1364,7 +1526,10 @@ export const validateChartLayout = ( const target = layout.nodes.find(({ node }) => node.path === transition.edge.target) if (target !== undefined && nodeBoundaryDistance(end, target) > 0.5) { report("detached-terminal", transition.edge.id, transition.edge.target) - } else if (target !== undefined && bend !== undefined && !isOutwardStep(end, bend, endpointSide(end, target))) { + } else if ( + target !== undefined && bend !== undefined && + !isOutwardStep(end, bend, endpointStepSide(end, bend, target)) + ) { report("wrong-terminal-direction", transition.edge.id, transition.edge.target) } } @@ -1417,7 +1582,7 @@ export const validateChartLayout = ( for (const initial of initialEdges) { for (const node of layout.nodes) { const containsTarget = node.node.path === initial.initial.target || - isDescendantPath(initial.initial.target, node.node.path) + isChartStateDescendant(initial.initial.target, node.node.path) const obstacle = node.node.children.length > 0 && containsTarget ? nodeHeaderRect(node) : nodeRect(node) @@ -1446,11 +1611,14 @@ export const validateChartLayout = ( } const allSegments = segments(layout) + const transitionIds = new Map(model.edges.map((edge) => [edge.id, edge.transitionId])) for (let left = 0; left < allSegments.length; left++) { for (let right = left + 1; right < allSegments.length; right++) { const first = allSegments[left]! const second = allSegments[right]! if (first.edgeId === second.edgeId) continue + const firstTransition = transitionIds.get(first.edgeId) + if (firstTransition !== undefined && firstTransition === transitionIds.get(second.edgeId)) continue if (collinearOverlap(first, second) > 4) report("route-overlap", first.edgeId, second.edgeId) } } diff --git a/packages/devtools/src/internal/browser/chart-model.ts b/packages/devtools/src/internal/browser/chart-model.ts index 08fe5ab..f6be732 100644 --- a/packages/devtools/src/internal/browser/chart-model.ts +++ b/packages/devtools/src/internal/browser/chart-model.ts @@ -71,6 +71,8 @@ export interface ChartModel { readonly initials: ReadonlyArray } +export const isChartStateDescendant = (path: string, ancestor: string): boolean => path.startsWith(`${ancestor}.`) + const activityLabel = (activity: VisualizationActivity): string => { switch (activity.type) { case "machine": diff --git a/packages/devtools/src/internal/browser/chart-renderer.ts b/packages/devtools/src/internal/browser/chart-renderer.ts index eda0b11..512472a 100644 --- a/packages/devtools/src/internal/browser/chart-renderer.ts +++ b/packages/devtools/src/internal/browser/chart-renderer.ts @@ -8,7 +8,7 @@ import { layoutChart, maxVisibleActivities } from "./chart-layout.js" -import { type ChartEdgeBadge, makeChartModel } from "./chart-model.js" +import { type ChartEdge, type ChartEdgeBadge, isChartStateDescendant, makeChartModel } from "./chart-model.js" export interface ChartHandlers { readonly selectState: (path: string, anchor: ChartInteractionAnchor) => void @@ -60,6 +60,9 @@ export const minimumChartZoom = 0.2 export const maximumChartZoom = 1.6 export const chartPanThreshold = 5 +export const isParentChildTransition = (edge: ChartEdge): boolean => + edge.target !== null && isChartStateDescendant(edge.target, edge.source) + const clampZoom = (zoom: number): number => Math.min(maximumChartZoom, Math.max(minimumChartZoom, zoom)) export const chartZoomScrollPosition = ( @@ -384,7 +387,6 @@ const render = ( const transitionElements = new Map>() const edgeElements = new Map>() const transitionControls = new Map>() - const parentByState = new Map(layout.nodes.map(({ node }) => [node.path, node.parent])) const chartEdges = new Map( layout.edges.flatMap((laidOut) => laidOut.kind === "transition" ? [[laidOut.edge.id, laidOut.edge] as const] : []) ) @@ -474,8 +476,7 @@ const render = ( } for (const laidOut of layout.edges) { - const parentChild = laidOut.kind === "transition" && laidOut.edge.target !== null && - parentByState.get(laidOut.edge.target) === laidOut.edge.source + const parentChild = laidOut.kind === "transition" && isParentChildTransition(laidOut.edge) const failure = laidOut.kind === "transition" && laidOut.edge.badges.some((badge) => badge.type === "failure") const group = svgElement( diff --git a/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts new file mode 100644 index 0000000..86631e6 --- /dev/null +++ b/packages/devtools/src/internal/browser/parallel-owner-routing-example.ts @@ -0,0 +1,95 @@ +import { Machine } from "@typeonce/effect-machine" +import { Schema } from "effect" + +class Print extends Schema.TaggedClass("ParallelOwnerPrint")("Print", {}) {} +class Options extends Schema.TaggedClass("ParallelOwnerOptions")("Options", {}) {} +class OptionsReady extends Schema.TaggedClass("ParallelOwnerOptionsReady")("Ready", {}) {} +class Operation extends Schema.TaggedClass("ParallelOwnerOperation")("Operation", {}) {} +class Active extends Schema.TaggedClass("ParallelOwnerActive")("Active", {}) {} +class Printing extends Schema.TaggedClass("ParallelOwnerPrinting")("Printing", {}) {} +class ChildIdle extends Schema.TaggedClass("ParallelOwnerChildIdle")("Idle", {}) {} +class ChildSignal extends Schema.TaggedClass("ParallelOwnerChildSignal")("ChildSignal", {}) {} +class PrintRequested extends Schema.TaggedClass("ParallelOwnerPrintRequested")( + "PrintRequested", + {} +) {} + +const ChildParentEvents = Machine.events(ChildSignal) +const ParallelOwnerChildStates = Machine.states({ Idle: ChildIdle }) + +export const parallelOwnerRoutingChildMachine = Machine.make({ + id: "parallel-owner-routing-child", + states: ParallelOwnerChildStates.states, + events: Machine.events(), + parent: Machine.parent(ChildParentEvents), + initial: (to) => to.Idle().resolve(({ target }) => target.decoded(new ChildIdle({}))) +}).handle({ + Idle: { + invoke: (from) => + from.effect( + "signal-parent", + ({ parent }) => parent.send(ChildParentEvents.ChildSignal()) + ).onDone((to) => to.none).onFailure((to) => to.none) + } +}) + +const ParallelOwnerChild = Machine.child("parallel-owner-child", parallelOwnerRoutingChildMachine) + +const ParallelOwnerStates = Machine.states({ + Print: { + schema: Print, + type: "parallel", + states: { + Options: { + schema: Options, + initial: "Ready", + states: { + Ready: OptionsReady + } + }, + Operation: { + schema: Operation, + initial: "Active", + states: { + Active, + Printing + } + } + } + } +}) + +export const parallelOwnerRoutingMachine = Machine.make({ + id: "parallel-owner-routing", + states: ParallelOwnerStates.states, + events: Machine.events(PrintRequested, ChildParentEvents), + initial: (to) => + to.Print.initial.resolve(({ target }) => + target.decoded( + new Print({}), + (print) => + print.Options.decoded(new Options({}), (options) => options.Ready.decoded(new OptionsReady({}))).Operation + .decoded(new Operation({}), (operation) => operation.Active.decoded(new Active({}))) + ) + ) +}).handle({ + Print: { + initialize: ({ builder }) => builder.Options.from({}).Operation.from({}), + invoke: (from) => + from.child(ParallelOwnerChild).onFailure((to) => + to.branch.Print.Operation.Active().resolve(({ target }) => target.decoded(new Active({}))) + ), + on: { + PrintRequested: (to) => + to.branch.Print.Operation.Printing().resolve(({ target }) => target.decoded(new Printing({}))) + }, + states: { + Options: { + initialize: ({ builder }) => builder.from({}) + }, + Operation: { + initialize: ({ builder }) => builder.from({}) + } + } + } +}) diff --git a/packages/devtools/test/StaticSite.test.ts b/packages/devtools/test/StaticSite.test.ts index 5ccc45c..f852b1c 100644 --- a/packages/devtools/test/StaticSite.test.ts +++ b/packages/devtools/test/StaticSite.test.ts @@ -19,6 +19,8 @@ const localExampleMachineIds = [ "layout-resilience", "optional-parent-protocol", "parallel-completion", + "parallel-owner-routing", + "parallel-owner-routing-child", "parent-child-protocol", "planner-example", "required-parent-child", diff --git a/packages/devtools/test/internal/browser/StaticChart.test.ts b/packages/devtools/test/internal/browser/StaticChart.test.ts index f7c2380..ed58e03 100644 --- a/packages/devtools/test/internal/browser/StaticChart.test.ts +++ b/packages/devtools/test/internal/browser/StaticChart.test.ts @@ -7,6 +7,7 @@ import { chartEdgeLabelSpacing, chartRouteLength, chartSelfLoopMinimumClearance, + type LaidOutChartTransition, layoutChart, layoutChartWith, validateChartLayout @@ -18,6 +19,7 @@ import { chartWheelZoom, chartZoomScrollPosition, isChartPan, + isParentChildTransition, maximumChartZoom, minimumChartZoom } from "../../../src/internal/browser/chart-renderer.js" @@ -26,6 +28,7 @@ import { hierarchyRoutingMachine } from "../../../src/internal/browser/hierarchy import { invokeOutcomesMachine } from "../../../src/internal/browser/invoke-outcomes-example.js" import { layoutResilienceMachine } from "../../../src/internal/browser/layout-resilience-example.js" import { parallelCompletionMachine } from "../../../src/internal/browser/parallel-completion-example.js" +import { parallelOwnerRoutingMachine } from "../../../src/internal/browser/parallel-owner-routing-example.js" import { plannerMachine } from "../../../src/internal/browser/planner-example.js" import { optionalParentMachine, @@ -36,6 +39,173 @@ import { sharedTerminalRoutingMachine } from "../../../src/internal/browser/shar import { transitionSemanticsMachine } from "../../../src/internal/browser/transition-semantics-example.js" import * as MachineDocument from "../../../src/MachineDocument.js" +const chartNode = ( + path: string, + parent: string | null, + children: ReadonlyArray, + initial = false +): ChartNode => ({ + path, + label: path.split(".").at(-1)!, + type: children.length === 0 ? "atomic" : "compound", + parent, + children, + active: false, + initial, + activities: [] +}) + +const chartEdge = ( + id: string, + source: string, + target: string, + label: string +): ChartModel["edges"][number] => ({ + id, + transitionId: id.includes(":branch:") ? id.slice(0, id.indexOf(":branch:")) : id, + branchIds: [id], + kind: "target", + source, + target, + label, + accessibleLabel: label, + badges: [], + trigger: { type: "event", event: label }, + activityKind: null, + reenter: false, + acceptance: "required" +}) + +const parentReturnModel = (): ChartModel => ({ + machineId: "parent-return-regression", + roots: ["Flow"], + nodes: [ + chartNode("Flow", null, ["Flow.UploadingImage", "Flow.UploadFailed", "Flow.Saving"], true), + chartNode("Flow.UploadingImage", "Flow", [], true), + chartNode("Flow.UploadFailed", "Flow", []), + chartNode("Flow.Saving", "Flow", []) + ], + edges: [ + chartEdge( + "Flow.UploadingImage:transition:0:branch:0", + "Flow.UploadingImage", + "Flow", + "upload done" + ), + chartEdge( + "Flow.UploadingImage:transition:1:branch:0", + "Flow.UploadingImage", + "Flow.UploadFailed", + "upload failed" + ), + chartEdge("Flow.UploadFailed:transition:0:branch:0", "Flow.UploadFailed", "Flow.Saving", "retry") + ], + runtimeTargets: [], + initials: [ + { id: "initial:Flow", target: "Flow", parent: null }, + { id: "initial:Flow.UploadingImage", target: "Flow.UploadingImage", parent: "Flow" } + ] +}) + +const deepBranchModel = (): ChartModel => { + const dialogs = ["About", "Membership", "Menu", "Profile", "Saved"] as const + const dialogNodes = dialogs.flatMap((dialog): ReadonlyArray => { + const path = `Scope.Open.${dialog}` + const children = dialog === "Profile" + ? ["Tracking", "Ready", "LoggingOut", "LogoutFailed", "Reloading"] + : ["Tracking", "Ready"] + return [ + chartNode(path, "Scope.Open", children.map((child) => `${path}.${child}`), dialog === "About"), + ...children.map((child) => chartNode(`${path}.${child}`, path, [], child === "Tracking")) + ] + }) + return { + machineId: "deep-branch-regression", + roots: ["Scope"], + nodes: [ + chartNode( + "Scope", + null, + [ + "Scope.CheckingAccess", + "Scope.Closed", + "Scope.Open", + "Scope.OpeningPanel", + "Scope.NavigatingMembership" + ], + true + ), + chartNode("Scope.CheckingAccess", "Scope", [], true), + chartNode("Scope.Closed", "Scope", []), + chartNode("Scope.Open", "Scope", dialogs.map((dialog) => `Scope.Open.${dialog}`)), + ...dialogNodes, + chartNode("Scope.OpeningPanel", "Scope", []), + chartNode("Scope.NavigatingMembership", "Scope", []) + ], + edges: [ + ...dialogs.map((dialog, index) => + chartEdge( + `Scope:transition:0:branch:${index}`, + "Scope", + `Scope.Open.${dialog}`, + `open ${dialog.toLowerCase()}` + ) + ), + ...dialogs.map((dialog, index) => + chartEdge( + `Scope.Open.${dialog}.Tracking:transition:${index}:branch:0`, + `Scope.Open.${dialog}.Tracking`, + `Scope.Open.${dialog}.Ready`, + "tracked" + ) + ), + chartEdge("Scope.Open:transition:1:branch:0", "Scope.Open", "Scope.Closed", "close"), + chartEdge("Scope.Open:transition:2:branch:0", "Scope.Open", "Scope.OpeningPanel", "open panel"), + chartEdge( + "Scope.Open.Profile.Ready:transition:0:branch:0", + "Scope.Open.Profile.Ready", + "Scope.Open.Profile.LoggingOut", + "logout" + ), + chartEdge( + "Scope.Open.Profile.LoggingOut:transition:0:branch:0", + "Scope.Open.Profile.LoggingOut", + "Scope.Open.Profile.Reloading", + "logout done" + ), + chartEdge( + "Scope.Open.Profile.LoggingOut:transition:1:branch:0", + "Scope.Open.Profile.LoggingOut", + "Scope.Open.Profile.LogoutFailed", + "logout failed" + ), + chartEdge( + "Scope.Open.Profile.LogoutFailed:transition:0:branch:0", + "Scope.Open.Profile.LogoutFailed", + "Scope.Open.Profile.LoggingOut", + "retry logout" + ), + chartEdge( + "Scope.Open.Profile.Reloading:transition:0:branch:0", + "Scope.Open.Profile.Reloading", + "Scope.Closed", + "reload done" + ) + ], + runtimeTargets: [], + initials: [ + { id: "initial:Scope", target: "Scope", parent: null }, + { id: "initial:Scope.CheckingAccess", target: "Scope.CheckingAccess", parent: "Scope" }, + { id: "initial:Scope.Open.About", target: "Scope.Open.About", parent: "Scope.Open" }, + ...dialogs.map((dialog) => ({ + id: `initial:Scope.Open.${dialog}.Tracking`, + target: `Scope.Open.${dialog}.Tracking`, + parent: `Scope.Open.${dialog}` + })) + ] + } +} + describe("Static chart", () => { const ELK = ELKBundle as unknown as new(args?: ELKConstructorArguments) => ElkApi @@ -157,7 +327,7 @@ describe("Static chart", () => { }) }) - it("uses side lanes for transitions crossing a parent boundary", () => { + it("keeps hierarchy transitions aligned with the vertical chart flow", () => { const node = (path: string, parent: string | null, children: ReadonlyArray): ChartNode => ({ path, label: path, @@ -217,16 +387,58 @@ describe("Static chart", () => { assert.deepStrictEqual(policy.edge(edges[0]!), { direction: "forward", - sourceSide: "EAST", - targetSide: "EAST" + sourceSide: "SOUTH", + targetSide: "NORTH" }) assert.deepStrictEqual(policy.edge(edges[1]!), { direction: "backward", - sourceSide: "WEST", - targetSide: "WEST" + sourceSide: "NORTH", + targetSide: "SOUTH" }) }) + it("presents deep transitions from a parallel owner as parent-child", async () => { + const model = makeChartModel(MachineDocument.make(parallelOwnerRoutingMachine)) + const transitions = model.edges.filter((edge) => edge.source === "Print") + assert.deepStrictEqual( + transitions.map(({ label, target }) => ({ label, target })), + [ + { label: "PrintRequested", target: "Print.Operation.Printing" }, + { label: "parallel-owner-child", target: "Print.Operation.Active" } + ] + ) + assert.isTrue(transitions.every(isParentChildTransition)) + + const layout = await Effect.runPromise(layoutChart(model)) + const source = layout.nodes.find(({ node }) => node.path === "Print") + const operation = layout.nodes.find(({ node }) => node.path === "Print.Operation") + const laidOut = layout.edges.filter( + (edge): edge is LaidOutChartTransition => edge.kind === "transition" && edge.edge.source === "Print" + ) + if (source === undefined || operation === undefined) assert.fail("Expected the parallel owner and target region") + assert.lengthOf(laidOut, 2) + for (const edge of laidOut) { + const start = edge.points[0]! + const end = edge.points.at(-1)! + const target = layout.nodes.find(({ node }) => node.path === edge.edge.target) + const initial = layout.initials.find(({ initial }) => initial.target === edge.edge.target) + if (target === undefined) assert.fail("Expected the descendant target") + assert.strictEqual(start.y, source.y + source.headerHeight) + assert.isAtLeast(Math.min(...edge.points.map(({ x }) => x)), operation.x - 24) + assert.isAtMost(Math.max(...edge.points.map(({ x }) => x)), operation.x + operation.width + 24) + assert.isAtLeast(edge.label.x - edge.labelWidth / 2, 20) + assert.isAtMost(edge.label.x + edge.labelWidth / 2, layout.width - 20) + const sourceLaneX = edge.points[0]!.x + const targetCenterX = target.x + target.width / 2 + assert.strictEqual(Math.sign(edge.label.x - sourceLaneX), Math.sign(targetCenterX - sourceLaneX)) + if (initial !== undefined) assert.isAtMost(edge.points.at(-2)!.y, initial.y - 12) + assert.strictEqual(end.y, target.y) + const directDistance = Math.abs(start.x - end.x) + Math.abs(start.y - end.y) + assert.isAtMost(chartRouteLength(edge.points), directDistance + 32) + } + assert.deepStrictEqual(validateChartLayout(model, layout).issues, []) + }) + it("projects invocation metadata and transition branches without state value fields", () => { const document = MachineDocument.make(plannerMachine) const model = makeChartModel(document) @@ -575,7 +787,9 @@ describe("Static chart", () => { assert.isAtLeast(externalFailure.points.length, 2) assert.strictEqual(parentTransition.points[0]?.y, source.y + source.headerHeight) assert.isAtLeast(Math.min(...externalFailure.points.map(({ y }) => y)), target.y) - assert.isBelow(chartRouteLength(externalFailure.points), 500) + const directDistance = Math.abs(externalFailure.points[0]!.x - externalFailure.points.at(-1)!.x) + + Math.abs(externalFailure.points[0]!.y - externalFailure.points.at(-1)!.y) + assert.isAtMost(chartRouteLength(externalFailure.points), directDistance + 80) assert.deepStrictEqual(validateChartLayout(model, layout).issues, []) assert.deepStrictEqual( repeated.edges.map((edge) => ({ @@ -589,6 +803,48 @@ describe("Static chart", () => { ) }) + it("routes child-to-parent returns with a safe terminal and sibling outcome", async () => { + const model = parentReturnModel() + const layout = await Effect.runPromise(layoutChart(model)) + const flow = layout.nodes.find(({ node }) => node.path === "Flow") + const parentReturn = layout.edges.find((edge) => edge.kind === "transition" && edge.edge.target === "Flow") + const siblingOutcome = layout.edges.find((edge) => + edge.kind === "transition" && edge.edge.target === "Flow.UploadFailed" + ) + if (flow === undefined || parentReturn?.kind !== "transition" || siblingOutcome?.kind !== "transition") { + assert.fail("Expected the parent return and sibling outcome") + } + + const returnEnd = parentReturn.points.at(-1)! + const returnBend = parentReturn.points.at(-2)! + assert.strictEqual(returnEnd.y, flow.y + flow.headerHeight) + assert.strictEqual(returnBend.x, returnEnd.x) + assert.isAtLeast(returnBend.y - returnEnd.y, 9) + assert.isAtLeast(chartRouteLength(siblingOutcome.points.slice(-2)), 9) + + assert.deepStrictEqual(validateChartLayout(model, layout).issues, []) + }) + + it("routes compound fan-out to deeply nested children without crossing nodes", async () => { + const model = deepBranchModel() + const layout = await Effect.runPromise(layoutChart(model)) + const scope = layout.nodes.find(({ node }) => node.path === "Scope") + const branches = layout.edges.filter((edge) => edge.kind === "transition" && edge.edge.source === "Scope") + if (scope === undefined) assert.fail("Expected the Scope state") + + assert.lengthOf(branches, 5) + for (const branch of branches) { + if (branch.kind !== "transition") assert.fail("Expected a transition branch") + const target = layout.nodes.find(({ node }) => node.path === branch.edge.target) + if (target === undefined) assert.fail("Expected a nested branch target") + assert.strictEqual(branch.points[0]?.y, scope.y + scope.headerHeight) + assert.strictEqual(branch.points.at(-1)?.y, target.y) + assert.isAtLeast(chartRouteLength(branch.points.slice(-2)), 9) + } + + assert.deepStrictEqual(validateChartLayout(model, layout).issues, []) + }) + it("tries deterministic spacing profiles before reporting an unsafe layout", async () => { const model = makeChartModel(MachineDocument.make(layoutResilienceMachine)) const attempts: Array = []