diff --git a/Coder.Graph/AstGraph.cs b/Coder.Graph/AstGraph.cs
index 4b0bcac..a425ca3 100644
--- a/Coder.Graph/AstGraph.cs
+++ b/Coder.Graph/AstGraph.cs
@@ -117,6 +117,27 @@ public AstGraph(AstNode root)
? new AstLocation(pin.Parent, pin.Slot, pin.Index)
: null;
+ ///
+ /// Finds the child currently sitting in an input pin.
+ ///
+ /// The pin's id.
+ /// The child filling the pin, or null when the pin is empty or is not an input pin of this graph.
+ ///
+ /// A variadic slot draws a free pin past its last child, so a pin being in the graph does not mean
+ /// anything is in it. That is what separates this from : the
+ /// location says where the pin points, this says whether anything is there.
+ ///
+ public AstNode? ChildInPin(int inputPinId)
+ {
+ if (LocationOfInputPin(inputPinId) is not AstLocation place || place.Parent is null || place.Slot is null)
+ {
+ return null;
+ }
+
+ IReadOnlyList children = AstSchema.ChildrenOf(place.Parent, place.Slot);
+ return place.Index < children.Count ? children[place.Index] : null;
+ }
+
///
/// Rebuilds the engine graph from the current AST, preserving on-screen positions.
///
diff --git a/Coder.Graph/AstGraphEditor.cs b/Coder.Graph/AstGraphEditor.cs
index ca8ac24..00eccff 100644
--- a/Coder.Graph/AstGraphEditor.cs
+++ b/Coder.Graph/AstGraphEditor.cs
@@ -72,6 +72,11 @@ public sealed class AstGraphEditor(AstNode root)
///
private const string NodeMenuPopup = "ast-graph-node-menu";
+ ///
+ /// The popup offering a pin's own actions, opened by right-clicking it.
+ ///
+ private const string PinMenuPopup = "ast-graph-pin-menu";
+
private string statusMessage = string.Empty;
private string fieldBuffer = string.Empty;
@@ -119,6 +124,22 @@ public sealed class AstGraphEditor(AstNode root)
///
private bool nodeMenuOpening;
+ ///
+ /// The pin whose own menu is open, while it is. Null when none is.
+ ///
+ ///
+ /// Held across frames for the reason is: the right-click and the choice
+ /// happen in different ones. The pin is held by identifier rather than as the place it points at,
+ /// because every edit offered here closes the menu, so there is no frame in which the menu is open
+ /// and the rebuild that reassigns those identifiers has happened.
+ ///
+ private int? pinMenuPin;
+
+ ///
+ /// Whether the menu for still has to be opened.
+ ///
+ private bool pinMenuOpening;
+
///
/// Gets the graph being edited.
///
@@ -247,6 +268,7 @@ public void Draw(Vector2 size, float deltaTime)
ApplyDeletions();
DrawPalette();
DrawNodeMenu();
+ DrawPinMenu();
DrawLinkDropMenu();
if (ShowDebugOverlays)
@@ -869,9 +891,30 @@ public AstConnectResult Connect(int outputPinId, int inputPinId)
///
/// The link to cut.
/// True if the link was found and cut.
- public bool Disconnect(int linkId)
+ public bool Disconnect(int linkId) => Detach(Graph.ChildOfLink(linkId));
+
+ ///
+ /// Cuts whatever fills a pin loose from it, as one undoable edit.
+ ///
+ /// The pin to empty.
+ /// True if the pin held a child, which was cut loose.
+ ///
+ /// The same edit as cutting the link, reached from the pin rather than from the line drawn to it: a
+ /// link is a few pixels wide, and the pin is what the user was already pointing at.
+ ///
+ public bool DisconnectPin(int pinId) => Detach(Graph.ChildInPin(pinId));
+
+ ///
+ /// Cuts a child loose from its parent, as one undoable edit.
+ ///
+ /// The node to detach, or null when there was nothing to detach.
+ /// True if there was a node, and it was detached.
+ ///
+ /// The node stays in the graph with no parent rather than leaving it: the user asked for it not to
+ /// be connected there, which is not the same as asking for it to be gone.
+ ///
+ private bool Detach(AstNode? child)
{
- AstNode? child = Graph.ChildOfLink(linkId);
if (child is null)
{
return false;
@@ -1228,6 +1271,118 @@ private void DrawNodeMenu()
ImGui.EndPopup();
}
+ ///
+ /// Offers a pin's own menu, the way right-clicking the pin does.
+ ///
+ /// The pin to offer the menu for.
+ /// True if the pin is one this graph offers a menu for, so the menu will open.
+ ///
+ /// Separate from the gesture that usually triggers it, for the reason
+ /// is: a host binding the menu to a key wants the same menu, and it can be driven in a test without
+ /// a pin happening to be under the pointer.
+ ///
+ /// Only a slot's pin has a menu. A node's output pin stands for the node itself rather than for a
+ /// place in the document, so its actions are the node's, and a right-click on one falls through to
+ /// the node's own menu.
+ ///
+ ///
+ public bool RequestPinMenu(int pinId)
+ {
+ if (Graph.LocationOfInputPin(pinId) is null)
+ {
+ statusMessage = Graph.OwnerOfOutputPin(pinId) is null
+ ? "That pin is not in this graph."
+ : "An output pin stands for the node itself, so it has no menu of its own.";
+ return false;
+ }
+
+ pinMenuPin = pinId;
+ pinMenuOpening = true;
+ return true;
+ }
+
+ ///
+ /// Draws a pin's own menu, and makes whichever edit the user picks.
+ ///
+ ///
+ /// What a pin offers is what can be done to the place it stands for: fill it, cut loose what fills
+ /// it, or remove that outright. The last two are offered only when something is there, since a
+ /// variadic slot draws a free pin past its last child and that one has nothing to act on.
+ ///
+ /// Renaming is not offered, because a pin has no name of its own to change: it is labelled from the
+ /// slot it fills, which is part of the node's shape rather than of the document. What the pin holds
+ /// is renamed by editing that node's own Name in the inspector, which is where the rest of a node's
+ /// values are edited.
+ ///
+ ///
+ private void DrawPinMenu()
+ {
+ if (pinMenuPin is not int pinId)
+ {
+ return;
+ }
+
+ if (pinMenuOpening)
+ {
+ ImGui.OpenPopup(PinMenuPopup);
+ pinMenuOpening = false;
+ }
+
+ if (!ImGui.BeginPopup(PinMenuPopup))
+ {
+ // Remembered exactly as long as the popup is open: once it has gone the user either picked
+ // something or dismissed it, and either way the pin is spent.
+ pinMenuPin = null;
+ return;
+ }
+
+ AstNode? child = Graph.ChildInPin(pinId);
+
+ bool create = Picked("Create node here", "Pin create");
+ bool disconnect = child is not null && Picked("Disconnect", "Pin disconnect");
+ bool remove = child is not null && Picked("Delete", "Pin delete");
+
+ if (create)
+ {
+ // Offered rather than made here: what would fill the pin is a choice, and it is the one the
+ // menu a dropped link opens already asks. This reaches it without the drag.
+ RequestCreateFrom(pinId);
+ }
+ else if (disconnect)
+ {
+ DisconnectPin(pinId);
+ }
+ else if (remove)
+ {
+ RemoveFromPin(pinId);
+ }
+
+ if (create || disconnect || remove)
+ {
+ pinMenuPin = null;
+ ImGui.CloseCurrentPopup();
+ }
+
+ ImGui.EndPopup();
+ }
+
+ ///
+ /// Draws one entry of a pin's menu, under a name a test can ask for.
+ ///
+ /// The entry as the user reads it.
+ /// The name the entry is recorded under.
+ /// True if the user picked it.
+ ///
+ /// Marked under a name of its own rather than the label it reads as, because a word as ordinary as
+ /// "Delete" will be drawn elsewhere too and a test asking for one should not find the other.
+ ///
+ private static bool Picked(string label, string probe)
+ {
+ bool picked = ImGui.MenuItem(label);
+ ImGuiProbes.MarkItem(probe);
+ return picked;
+ }
+
///
/// Creates a node from a template and connects it to the pin a link was dragged off.
///
@@ -1412,9 +1567,26 @@ private void AttachAt(AstNode parent, AstSlot slot, int index, AstNode child)
/// The removal is checked before it is recorded, so pressing delete over the root — which is
/// never removed — leaves the history alone rather than adding a step that does nothing.
///
- public bool Remove(int nodeId)
+ public bool Remove(int nodeId) => RemoveSubtree(Graph.AstNodeFor(nodeId));
+
+ ///
+ /// Removes whatever fills a pin, and everything under it, as one undoable edit.
+ ///
+ /// The pin to empty.
+ /// True if the pin held a child, which was removed.
+ ///
+ /// Told apart from , which leaves the child in the graph: this is the
+ /// edit pressing delete over the child makes, reached from the pin holding it.
+ ///
+ public bool RemoveFromPin(int pinId) => RemoveSubtree(Graph.ChildInPin(pinId));
+
+ ///
+ /// Removes a node and everything under it, as one undoable edit.
+ ///
+ /// The node to remove, or null when there was nothing to remove.
+ /// True if there was a node that could be removed, and it was.
+ private bool RemoveSubtree(AstNode? node)
{
- AstNode? node = Graph.AstNodeFor(nodeId);
if (node is null || ReferenceEquals(node, Graph.Root))
{
return false;
@@ -1512,6 +1684,23 @@ private void ApplyDeletions()
}
}
+ ///
+ /// Offers the menu belonging to whatever the pointer is over, if it is over anything.
+ ///
+ /// True if a pin's or a node's own menu was offered, so the palette is not.
+ ///
+ /// A pin sits inside a node, so a click that is over both is about the pin: the narrower target is
+ /// the one it took aim to hit. A node's output pin has no menu of its own — it stands for the node
+ /// rather than for a place in the document — so a click on one falls through to the node's, which
+ /// is whose actions they are.
+ ///
+ private bool OfferOwnMenu()
+ {
+ int hovered = 0;
+ return (ImNodes.IsPinHovered(ref hovered) && RequestPinMenu(hovered))
+ || (ImNodes.IsNodeHovered(ref hovered) && RequestNodeMenu(hovered));
+ }
+
///
/// Draws the right-click palette and creates whatever the user picks.
///
@@ -1521,20 +1710,16 @@ private void ApplyDeletions()
///
private void DrawPalette()
{
- if (ImGui.IsMouseClicked(ImGuiMouseButton.Right) && ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows))
- {
- // A right-click over a node is about that node, so the palette answers only one over empty
- // canvas. Both gestures are the same click, and which menu it means is decided in one place
- // rather than by two handlers each opening a popup.
- int hovered = 0;
- if (ImNodes.IsNodeHovered(ref hovered))
- {
- RequestNodeMenu(hovered);
- }
- else
- {
- ImGui.OpenPopup("ast-graph-palette");
- }
+ // A right-click over a pin is about that pin and one over a node is about that node, so the
+ // palette answers only a click that lands on neither. All three are the same click, and which
+ // menu it means is decided in one place rather than by three handlers racing to open a popup.
+ // OfferOwnMenu is asked last, and only where the click reaches this canvas at all, so a menu is
+ // never offered for a click somewhere else in the window.
+ if (ImGui.IsMouseClicked(ImGuiMouseButton.Right)
+ && ImGui.IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows)
+ && !OfferOwnMenu())
+ {
+ ImGui.OpenPopup("ast-graph-palette");
}
if (!ImGui.BeginPopup("ast-graph-palette"))
diff --git a/Coder.Test/Graph/AstGraphEditorPinMenuTests.cs b/Coder.Test/Graph/AstGraphEditorPinMenuTests.cs
new file mode 100644
index 0000000..5c7fa2c
--- /dev/null
+++ b/Coder.Test/Graph/AstGraphEditorPinMenuTests.cs
@@ -0,0 +1,302 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Coder.Test.Graph;
+
+using System.Linq;
+using System.Numerics;
+using Hexa.NET.ImGui;
+using ktsu.Coder.Ast;
+using ktsu.Coder.Graph;
+using ktsu.ImGui.App;
+using ktsu.ImGui.App.Testing;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Covers the menu a right-clicked pin opens as the user meets it: entries reached with the mouse, by
+/// the label they read as, through the headless rasterizer.
+///
+///
+/// What fills a pin is covered headlessly in . What is covered here is
+/// that the popup draws the entries the pin's state calls for, and that picking one reaches the edit —
+/// the half that only a rendered frame can establish, since ImGui menus fault inside native code
+/// rather than throwing when misnested.
+///
+/// The menu is opened through rather than by right-clicking
+/// a pin, because where a pin lands on screen depends on the force-directed layout and ImNodes
+/// publishes no position for one. That is the same reason
+/// opens its menu through the editor.
+///
+///
+/// ImGui contexts are process-global, so only one harness can be live at a time and this class must
+/// not run its methods in parallel.
+///
+///
+[TestClass]
+[DoNotParallelize]
+public sealed class AstGraphEditorPinMenuTests
+{
+ private static readonly HarnessOptions Options = new() { Width = 1280, Height = 800 };
+
+ private static FunctionDeclaration SampleFunction()
+ {
+ FunctionDeclaration function = new("total") { ReturnType = "int" };
+ function.Parameters.Add(new Parameter("a", "int"));
+ function.Body.Add(new ReturnStatement(
+ new BinaryExpression(new VariableReference("a"), BinaryOperator.Add, Literal.Number(1))));
+ return function;
+ }
+
+ private static ImGuiAppConfig ConfigFor(AstGraphEditor editor) => new()
+ {
+ Title = "AST pin menu",
+ OnRender = delta =>
+ {
+ ImGui.Begin("graph");
+ editor.Draw(new Vector2(1000, 600), delta);
+ ImGui.End();
+ },
+ };
+
+ private static bool IsVisible(ImGuiAppHarness harness, string name) =>
+ harness.Probe.WasSeenInFrame(name, harness.FrameCount - 1);
+
+ ///
+ /// Starts a harness with a pin's own menu already open.
+ ///
+ /// The editor to draw.
+ /// The pin to open the menu for.
+ /// The running harness, for the caller to dispose.
+ private static ImGuiAppHarness Offering(AstGraphEditor editor, int pinId)
+ {
+ ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options);
+ harness.Step(2);
+
+ Assert.IsTrue(editor.RequestPinMenu(pinId), "the pin to offer a menu for should be one of this graph's slots");
+ harness.Step(2);
+ return harness;
+ }
+
+ ///
+ /// Tests that a pin holding a child offers what can be done to that child as well as what can fill
+ /// the pin.
+ ///
+ [TestMethod]
+ public void Menu_OffersDisconnectAndDeleteForAFilledPin()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 0));
+
+ Assert.IsTrue(IsVisible(harness, "Pin create"), "a pin can always be offered something to fill it");
+ Assert.IsTrue(IsVisible(harness, "Pin disconnect"), "the pin holds a parameter, which can be cut loose");
+ Assert.IsTrue(IsVisible(harness, "Pin delete"), "the pin holds a parameter, which can be removed");
+ }
+
+ ///
+ /// Tests that the free pin a variadic slot draws past its last child offers only what would fill
+ /// it, since there is nothing in it to act on.
+ ///
+ [TestMethod]
+ public void Menu_OffersOnlyCreateForAFreePin()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 1));
+
+ Assert.IsTrue(IsVisible(harness, "Pin create"));
+ Assert.IsFalse(IsVisible(harness, "Pin disconnect"), "nothing fills the pin, so nothing can be cut loose");
+ Assert.IsFalse(IsVisible(harness, "Pin delete"), "nothing fills the pin, so nothing can be removed");
+ }
+
+ ///
+ /// Tests that disconnecting cuts the child loose from the slot and leaves it in the graph, rather
+ /// than removing it.
+ ///
+ [TestMethod]
+ public void Menu_DisconnectCutsTheChildLoose()
+ {
+ FunctionDeclaration function = SampleFunction();
+ Parameter parameter = function.Parameters[0];
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 0));
+
+ harness.Click("Pin disconnect");
+ harness.Step(2);
+
+ Assert.IsEmpty(function.Parameters);
+ Assert.Contains(parameter, editor.Graph.Detached, "a disconnected node stays in the graph");
+ Assert.IsTrue(editor.History.CanUndo, "disconnecting through the menu should be undoable");
+
+ editor.Undo();
+ Assert.AreSame(parameter, function.Parameters.Single());
+ }
+
+ ///
+ /// Tests that deleting takes the child out of the document altogether, which is what tells it apart
+ /// from disconnecting.
+ ///
+ [TestMethod]
+ public void Menu_DeleteRemovesTheChild()
+ {
+ FunctionDeclaration function = SampleFunction();
+ Parameter parameter = function.Parameters[0];
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 0));
+
+ harness.Click("Pin delete");
+ harness.Step(2);
+
+ Assert.IsEmpty(function.Parameters);
+ Assert.DoesNotContain(parameter, editor.Graph.Nodes.Values, "a deleted node leaves the graph");
+ Assert.IsTrue(editor.History.CanUndo, "deleting through the menu should be undoable");
+
+ editor.Undo();
+ Assert.AreSame(parameter, function.Parameters.Single());
+ }
+
+ ///
+ /// Tests that asking to fill a pin opens the menu of nodes that would fill it, which is the one a
+ /// dropped link already opens.
+ ///
+ [TestMethod]
+ public void Menu_CreateOffersTheNodesThatWouldFillThePin()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 1));
+
+ harness.Click("Pin create");
+ harness.Step(3);
+
+ Assert.IsTrue(IsVisible(harness, "Create Declarations"), "a Parameters slot takes a parameter, which is a declaration");
+ Assert.IsFalse(IsVisible(harness, "Pin create"), "the pin's own menu should have closed behind it");
+ }
+
+ ///
+ /// Tests that dismissing the menu without picking anything leaves the document alone and forgets the
+ /// pin, rather than offering the menu again on the next frame.
+ ///
+ [TestMethod]
+ public void Menu_DismissedWithoutPicking_ChangesNothing()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 0));
+ Assert.IsTrue(IsVisible(harness, "Pin delete"), "the menu should be open to begin with");
+
+ int before = editor.Graph.Nodes.Count;
+
+ // Clicked away from the popup rather than dismissed with Escape: a click outside is what closes
+ // an ImGui popup, and it is also what a user who changed their mind actually does.
+ harness.Mouse.Click(1220, 60);
+ harness.Step(3);
+
+ Assert.IsFalse(IsVisible(harness, "Pin delete"), "the menu should be gone once dismissed");
+ Assert.HasCount(before, editor.Graph.Nodes);
+ Assert.IsFalse(editor.History.CanUndo, "dismissing should record nothing");
+ Assert.HasCount(1, function.Parameters);
+ }
+
+ ///
+ /// Tests that a node's output pin is refused, since it stands for the node rather than for a place
+ /// in the document, and that a pin that is not in the graph is refused too.
+ ///
+ [TestMethod]
+ public void RequestPinMenu_AnythingButASlotPin_IsRefused()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function);
+
+ using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options);
+ harness.Step(2);
+
+ int outputPin = editor.Graph.Engine.Nodes
+ .Single(n => ReferenceEquals(editor.Graph.AstNodeFor(n.Id), function))
+ .OutputPins[0].Id;
+
+ Assert.IsFalse(editor.RequestPinMenu(outputPin));
+ Assert.IsFalse(editor.RequestPinMenu(-1));
+ harness.Step(2);
+
+ Assert.IsFalse(IsVisible(harness, "Pin create"));
+ }
+
+ ///
+ /// Tests that a right-click landing on a node but not on one of its pins is not taken for a pin, so
+ /// the node's own menu answers it.
+ ///
+ ///
+ /// The click lands on the node's title bar, which is the one part of a node with no pin in it, so
+ /// this is what says the two menus are told apart rather than one shadowing the other. The opposite
+ /// case — a click that does land on a pin — is not covered here: ImNodes publishes no screen
+ /// position for a pin, so a test could only guess at one, and the menu it opens is covered through
+ /// above.
+ ///
+ [TestMethod]
+ public void RightClickOffAPin_OpensTheNodesMenuInstead()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraphEditor editor = new(function) { LayoutRunning = false };
+
+ Vector2 topLeft = Vector2.Zero;
+ Vector2 size = Vector2.Zero;
+ int nodeId = 0;
+
+ ImGuiAppConfig config = new()
+ {
+ Title = "AST pin menu gesture",
+ OnRender = delta =>
+ {
+ ImGui.Begin("graph");
+ editor.Draw(new Vector2(1000, 600), delta);
+
+ // Read inside the frame that drew it: the node's screen position is ImNodes' own, and it
+ // is only meaningful while its context is the current one.
+ if (nodeId != 0)
+ {
+ topLeft = Hexa.NET.ImNodes.ImNodes.GetNodeScreenSpacePos(nodeId);
+ size = Hexa.NET.ImNodes.ImNodes.GetNodeDimensions(nodeId);
+ }
+
+ ImGui.End();
+ },
+ };
+
+ using ImGuiAppHarness harness = ImGuiAppHarness.Start(config, Options);
+ harness.Step(4);
+
+ nodeId = editor.Graph.Engine.Nodes.Single(n => ReferenceEquals(editor.Graph.AstNodeFor(n.Id), function)).Id;
+ harness.Step(2);
+
+ Assert.IsGreaterThan(0f, size.X, "the node should have been measured by now");
+
+ harness.Mouse.Click(topLeft.X + (size.X * 0.5f), topLeft.Y + 4f, 1);
+ harness.Step(2);
+
+ Assert.IsFalse(IsVisible(harness, "Pin create"), "no pin is under the title bar, so no pin menu is offered");
+ Assert.IsTrue(IsVisible(harness, "Node add Parameters"), "the node under the pointer answers instead");
+ Assert.IsFalse(editor.History.CanUndo, "a right-click records nothing either way");
+ }
+
+ ///
+ /// Finds the pin standing for one place in a node's slot, the way the editor does.
+ ///
+ /// The graph under test.
+ /// The node whose slot to look at.
+ /// The slot's name.
+ /// The position within the slot.
+ /// The input pin's id.
+ private static int InputPin(AstGraph graph, AstNode parent, string slotName, int index)
+ {
+ ktsu.ImGui.NodeEditor.Node parentNode =
+ graph.Engine.Nodes.Single(n => ReferenceEquals(graph.AstNodeFor(n.Id), parent));
+ AstSlot slot = AstSchema.SlotsOf(parent).Single(s => s.Name == slotName);
+ return parentNode.InputPins.Single(p => p.EffectiveDisplayName == AstGraph.PinLabel(slot, index)).Id;
+ }
+}
diff --git a/Coder.Test/Graph/AstGraphTests.cs b/Coder.Test/Graph/AstGraphTests.cs
index de0309c..6a9a22b 100644
--- a/Coder.Test/Graph/AstGraphTests.cs
+++ b/Coder.Test/Graph/AstGraphTests.cs
@@ -405,6 +405,67 @@ public void Connect_PutsAMemberInitialiserIntoAConstruction()
Assert.IsEmpty(graph.Detached);
}
+ ///
+ /// Tests that a pin reads back the child sitting in it, for both a variadic slot and a
+ /// single-valued one.
+ ///
+ [TestMethod]
+ public void ChildInPin_ReadsWhatFillsThePin()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraph graph = new(function);
+
+ Assert.AreSame(function.Parameters[0], graph.ChildInPin(InputPin(graph, function, "Parameters", 0)));
+ Assert.AreSame(function.Body[0], graph.ChildInPin(InputPin(graph, function, "Body", 0)));
+
+ ReturnStatement returnStmt = (ReturnStatement)function.Body[0];
+ Assert.AreSame(returnStmt.Expression, graph.ChildInPin(InputPin(graph, returnStmt, "Expression", 0)));
+ }
+
+ ///
+ /// Tests that the free pin a variadic slot draws past its last child reads back as empty, since
+ /// nothing is in it yet.
+ ///
+ [TestMethod]
+ public void ChildInPin_IsNullForAFreePin()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraph graph = new(function);
+
+ Assert.IsNull(graph.ChildInPin(InputPin(graph, function, "Parameters", function.Parameters.Count)));
+ }
+
+ ///
+ /// Tests that a pin this graph does not have, and a pin that is an output rather than a slot, both
+ /// read back as empty rather than throwing.
+ ///
+ [TestMethod]
+ public void ChildInPin_IsNullForAPinThatIsNotASlotPin()
+ {
+ FunctionDeclaration function = SampleFunction();
+ AstGraph graph = new(function);
+
+ Node functionNode = graph.Engine.Nodes.Single(n => ReferenceEquals(graph.AstNodeFor(n.Id), function));
+
+ Assert.IsNull(graph.ChildInPin(-1));
+ Assert.IsNull(graph.ChildInPin(functionNode.OutputPins[0].Id));
+ }
+
+ ///
+ /// Finds the pin standing for one place in a node's slot, the way the editor does.
+ ///
+ /// The graph under test.
+ /// The node whose slot to look at.
+ /// The slot's name.
+ /// The position within the slot.
+ /// The input pin's id.
+ private static int InputPin(AstGraph graph, AstNode parent, string slotName, int index)
+ {
+ Node parentNode = graph.Engine.Nodes.Single(n => ReferenceEquals(graph.AstNodeFor(n.Id), parent));
+ AstSlot slot = AstSchema.SlotsOf(parent).Single(s => s.Name == slotName);
+ return parentNode.InputPins.Single(p => p.EffectiveDisplayName == AstGraph.PinLabel(slot, index)).Id;
+ }
+
///
/// Connects a node to a named slot of a parent, looking the pins up the way the editor does.
///