From fb5c7494cc7d25f9cbf0ab7ac617b438ca63090e Mon Sep 17 00:00:00 2001 From: Matt Cox Date: Wed, 14 Oct 2026 18:53:36 +0100 Subject: [PATCH] Adds canConnect --- Sources/GraphKit/Graph/Graph+Edges.swift | 58 ++++++++++++++++++++---- Tests/GraphKit/Graph/Options.swift | 46 +++++++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/Sources/GraphKit/Graph/Graph+Edges.swift b/Sources/GraphKit/Graph/Graph+Edges.swift index 96dd547..09b2945 100644 --- a/Sources/GraphKit/Graph/Graph+Edges.swift +++ b/Sources/GraphKit/Graph/Graph+Edges.swift @@ -342,6 +342,52 @@ extension Graph { /// @discardableResult public mutating func connect(_ source: Node, to target: Node, value: Value, at position: EdgePosition = .end) throws(GraphError) -> Edge { + let (sourceSlot, targetSlot, index) = try validateConnection(source, to: target, at: position) + + let edge = allocateEdge(source: sourceSlot, target: targetSlot, value: value) + nodeRecords[sourceSlot]!.outgoing.insert(edge, at: index) + nodeRecords[targetSlot]!.incoming.append(edge) + + appendChange(.edgeInserted(edge, source: source, target: target, index: index)) + + return edge + } + +/// Whether connecting one node to another would be accepted. +/// +/// Asks exactly the questions ``connect(_:to:value:at:)`` asks before it +/// modifies anything, so a `true` here means that connect will not throw. +/// Use it to refuse a connection up front, such as a drop destination +/// declining a drag, rather than attempting it and handling the error. +/// +/// Like ``connect(_:to:value:at:)``, both nodes must already be members of +/// the graph. A connection involving a node that is not a member is not +/// accepted. +/// +/// - Parameters: +/// - source: The node the edge would leave. +/// - target: The node the edge would arrive at. +/// - position: The position the edge would be inserted at within the source +/// node's ordered outgoing edges. Defaults to ``EdgePosition/end``. +/// +/// - Returns: True if connecting the source node to the target node at the +/// provided position would succeed. +/// + public func canConnect(_ source: Node, to target: Node, at position: EdgePosition = .end) -> Bool { + (try? validateConnection(source, to: target, at: position)) != nil + } + +/// Validates a connection against the membership of its nodes, the +/// ``options`` describing the graph, and the position it would be inserted +/// at, without modifying anything. +/// +/// The single source of truth for what ``connect(_:to:value:at:)`` accepts, +/// shared with ``canConnect(_:to:at:)`` so the two can never disagree. +/// +/// - Returns: The slots of the source and target nodes, and the index the +/// edge would be inserted at within the source node's outgoing edges. +/// + private func validateConnection(_ source: Node, to target: Node, at position: EdgePosition) throws(GraphError) -> (source: Int, target: Int, index: Int) { guard let sourceSlot = nodeSlots[source] else { throw GraphError.notFound(what: GraphError.node, reason: "The node is not a member of the graph.") } @@ -376,18 +422,12 @@ extension Graph { } } - // Resolve the position before allocating, so that an invalid position - // does not leave a vacated slot behind. + // Resolved here, before the caller allocates, so that an invalid + // position does not leave a vacated slot behind. // let index = try position.resolved(insertingInto: nodeRecords[sourceSlot]!.outgoing.count) - let edge = allocateEdge(source: sourceSlot, target: targetSlot, value: value) - nodeRecords[sourceSlot]!.outgoing.insert(edge, at: index) - nodeRecords[targetSlot]!.incoming.append(edge) - - appendChange(.edgeInserted(edge, source: source, target: target, index: index)) - - return edge + return (sourceSlot, targetSlot, index) } /// Disconnects an edge, removing it from both of the nodes it connects. diff --git a/Tests/GraphKit/Graph/Options.swift b/Tests/GraphKit/Graph/Options.swift index 2880113..13198c3 100644 --- a/Tests/GraphKit/Graph/Options.swift +++ b/Tests/GraphKit/Graph/Options.swift @@ -151,4 +151,50 @@ struct OptionTests { #expect(graph.wouldCreateCycle(connecting: "a", to: "c") == false) #expect(graph.wouldCreateCycle(connecting: "a", to: "unknown") == false) } + +/// Tests that asking whether a connection would be accepted refuses exactly +/// what connecting rejects, for every option. +/// + @Test("Can Connect") + func canConnect() throws { + var tree = Graph(nodes: ["root", "a", "b", "c"], options: .tree) + try tree.connect("root", to: "a") + try tree.connect("a", to: "b") + + #expect(tree.canConnect("a", to: "a") == false) + #expect(tree.canConnect("root", to: "a") == false) + #expect(tree.canConnect("root", to: "b") == false) + #expect(tree.canConnect("b", to: "root") == false) + #expect(tree.canConnect("b", to: "c")) + #expect(tree.canConnect("root", to: "c")) + + var unconstrained = Graph(nodes: ["a", "b"], options: [.allowsCycles, .allowsParallelEdges, .allowsSelfLoops, .allowsMultipleIncomingEdges]) + try unconstrained.connect("a", to: "b") + + #expect(unconstrained.canConnect("a", to: "a")) + #expect(unconstrained.canConnect("a", to: "b")) + #expect(unconstrained.canConnect("b", to: "a")) + + // Asking modifies nothing. + // + #expect(tree.edgeCount == 2) + #expect(unconstrained.edgeCount == 1) + } + +/// Tests that a connection involving a node that is not a member of the +/// graph, or an out of range position, is not accepted, as connecting would +/// reject it. +/// + @Test("Can Connect Rejects Unknown Nodes And Positions") + func canConnectRejectsUnknownNodesAndPositions() throws { + var graph = Graph(nodes: ["a", "b", "c"], options: .acyclic) + try graph.connect("a", to: "b") + + #expect(graph.canConnect("a", to: "z") == false) + #expect(graph.canConnect("z", to: "a") == false) + + #expect(graph.canConnect("a", to: "c", at: .index(1))) + #expect(graph.canConnect("a", to: "c", at: .index(2)) == false) + #expect(graph.canConnect("a", to: "c", at: .start)) + } }