Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions GraphcodeKit/Sources/Domain/LoopGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions graphcode/Tests/StartAnchorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading