diff --git a/GraphcodeKit/Sources/Domain/LoopGraph.swift b/GraphcodeKit/Sources/Domain/LoopGraph.swift index 47761a55..0a5ed908 100644 --- a/GraphcodeKit/Sources/Domain/LoopGraph.swift +++ b/GraphcodeKit/Sources/Domain/LoopGraph.swift @@ -207,7 +207,15 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { // Walk out from the entry points; whatever the walk never reaches is a cycle-only // component, and the first of its nodes becomes that component's anchor. var reached = anchored - var frontier = entries + // A rowed sketch is not an anchor but it is a beginning to walk from: its children + // are reached through it, so they are not mistaken for a headless component and + // promoted to roots of their own — which listed a Main loop's children as its + // siblings, unhidden when it collapsed. Only sketches `sidebarRoots` actually rows + // seed the walk; one something points at is reached through its own parent, and + // covering it here would strand that subtree with no row at all. + let rowedSketches = untargetedSketches + reached.formUnion(rowedSketches) + var frontier = entries + rowedSketches while let current = frontier.popLast() { for edge in sequencingEdges where edge.from == current && !reached.contains(edge.to) { reached.insert(edge.to) @@ -235,11 +243,12 @@ public struct LoopGraph: Identifiable, Codable, Equatable, Sendable { /// loop that effectively doesn't exist. Untargeted sketches append after the anchored /// roots, the sidebar's echo of the canvas's "sketches sit below the lanes"; a sketch /// something points at already lists as that node's child. - public var sidebarRoots: [UUID] { + public var sidebarRoots: [UUID] { startAnchors + untargetedSketches } + + /// The sketches nothing points at — the ones `sidebarRoots` gives a top-level row. + private var untargetedSketches: [UUID] { let targeted = Set(edges.map(\.to)) - let sketches = nodes.filter { $0.loopType == .sketch && !targeted.contains($0.id) } - .map(\.id) - return startAnchors + sketches + return nodes.filter { $0.loopType == .sketch && !targeted.contains($0.id) }.map(\.id) } // MARK: - Coding diff --git a/graphcode/Tests/StartAnchorTests.swift b/graphcode/Tests/StartAnchorTests.swift index b201e629..7e03e567 100644 --- a/graphcode/Tests/StartAnchorTests.swift +++ b/graphcode/Tests/StartAnchorTests.swift @@ -183,6 +183,43 @@ struct StartAnchorTests { #expect(child.sidebarRoots == [worker.id]) } + /// **A Main loop's children are its children, not its siblings.** A sketch is skipped + /// when entry points are picked, so the walk out from them never started at one — and + /// every loop a Main loop had handed off to came back unreached, was read as a + /// headless component, and was promoted to an anchor of its own. In the sidebar that + /// listed the children as top-level rows beside their parent, where collapsing the + /// parent left them on screen. Only Main-parented loops ever hit it: every other type + /// anchors its own subtree. + @Test + func aSketchesChildrenStayUnderIt() { + let sketch = LoopNode(title: "Say Hi", loopType: .sketch) + let children = (1...4).map { node("Hello \($0)") } + let subject = graph( + nodes: [sketch] + children, + edges: children.map { LoopEdge(from: sketch.id, to: $0.id, kind: .handoff) }) + + #expect(subject.startAnchors.isEmpty) + #expect(subject.sidebarRoots == [sketch.id]) + } + + /// The half of the rule that keeps the fix from hiding loops: a sketch that something + /// points at gets no row of its own, so it cannot be the beginning its subtree is + /// reached through. Its component still needs an anchor. + @Test + func aTargetedSketchDoesNotAnchorItsOwnSubtree() { + let sketch = LoopNode(title: "Ring member", loopType: .sketch) + let worker = node("Worker") + let subject = graph( + nodes: [sketch, worker], + edges: [ + LoopEdge(from: sketch.id, to: worker.id, kind: .handoff), + LoopEdge(from: worker.id, to: sketch.id, kind: .handoff), + ]) + + #expect(subject.startAnchors == [worker.id]) + #expect(subject.sidebarRoots == [worker.id]) + } + // MARK: - Only a handoff is a sequencing edge (#194) /// **The orchestrator shape, and the bug it exposed.** A parent hands off to each child