diff --git a/Coder.Graph/AstGraph.cs b/Coder.Graph/AstGraph.cs index 1eea4c2..4b0bcac 100644 --- a/Coder.Graph/AstGraph.cs +++ b/Coder.Graph/AstGraph.cs @@ -90,6 +90,33 @@ public AstGraph(AstNode root) /// The AST node, or null if the id is not in this graph. public AstNode? AstNodeFor(int nodeId) => nodesById.TryGetValue(nodeId, out AstNode? node) ? node : null; + /// + /// Finds the node an output pin belongs to. + /// + /// The pin's id. + /// The node the pin hangs off, or null if the id is not an output pin of this graph. + /// + /// Paired with so a caller holding one pin id can tell which end + /// of a connection it is, which is what a gesture that started at a pin and ended nowhere needs + /// before it can say what a new node would be attached to. + /// + public AstNode? OwnerOfOutputPin(int outputPinId) => + ownerByOutputPin.TryGetValue(outputPinId, out AstNode? owner) ? owner : null; + + /// + /// Finds the place in the document an input pin stands for. + /// + /// The pin's id. + /// The slot the pin fills, or null if the id is not an input pin of this graph. + /// + /// Returns an rather than the pin record itself, because a location names + /// nodes and outlives the rebuild that reassigns every pin id. + /// + public AstLocation? LocationOfInputPin(int inputPinId) => + slotByInputPin.TryGetValue(inputPinId, out SlotPin? pin) + ? new AstLocation(pin.Parent, pin.Slot, pin.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 62c9e81..4109429 100644 --- a/Coder.Graph/AstGraphEditor.cs +++ b/Coder.Graph/AstGraphEditor.cs @@ -62,6 +62,11 @@ public sealed class AstGraphEditor(AstNode root) DoubleFormat = "%.17g", }; + /// + /// The popup offering to create a node for a link dropped on empty canvas. + /// + private const string LinkDropPopup = "ast-graph-link-drop"; + private string statusMessage = string.Empty; private string fieldBuffer = string.Empty; @@ -70,6 +75,25 @@ public sealed class AstGraphEditor(AstNode root) private string? editingField; + /// + /// The pin a link was dragged off and dropped on empty canvas, while the menu offering to create a + /// node for it is open. + /// + /// + /// Held across frames because the drop and the choice happen in different ones: ImNodes reports the + /// drop once, and the menu it opens is answered some frames later. Null when no such menu is open. + /// + private int? linkDropPin; + + /// + /// Whether the menu for still has to be opened. + /// + /// + /// Separate from the pin because only means anything inside + /// a frame, and the request to offer the menu can arrive from outside one. + /// + private bool linkDropOpening; + /// /// Gets the graph being edited. /// @@ -197,6 +221,7 @@ public void Draw(Vector2 size, float deltaTime) TrackSelection(); ApplyDeletions(); DrawPalette(); + DrawLinkDropMenu(); if (ShowDebugOverlays) { @@ -778,6 +803,14 @@ private void ApplyLinkChanges() { Disconnect(destroyedLink); } + + // A drag released over empty canvas. ImNodes has already thrown the link away, so there is + // nothing dangling to clean up if the user then dismisses the menu. + int droppedFrom = 0; + if (ImNodes.IsLinkDropped(ref droppedFrom, includingDetachedLinks: false)) + { + RequestCreateFrom(droppedFrom); + } } /// @@ -1022,6 +1055,157 @@ public IEnumerable ConversionsFor(AstNode node) slot is null || AstSchema.Accepts(slot, template.Create())); } + /// + /// Lists the kinds of node a link dragged off a pin and dropped on empty canvas could create. + /// + /// The pin the drag started at. + /// The palette entries that would connect to that pin, in palette order. + /// + /// Which way the pin faces decides what is on offer, because the new node takes the other end of + /// the connection. Dropping a drag that began at an input pin asks for something to fill that + /// slot, so the entries are the ones the slot accepts. Dropping one that began at an output pin + /// asks for somewhere to put that node, so the entries are the ones with a slot that accepts it. + /// + /// The same the manual drag is checked against decides both, so a + /// node offered here cannot be one the connection would then refuse. An empty result is a real + /// answer rather than a failure: a statement dragged out of a body has nowhere in the catalogue to + /// go, and the menu says so instead of listing entries that would not take it. + /// + /// + public IEnumerable CreatableFrom(int pinId) + { + AstNode? dragged = Graph.OwnerOfOutputPin(pinId); + if (dragged is not null) + { + return AstNodeCatalog.Templates.Where(template => SlotAccepting(template.Create(), dragged) is not null); + } + + AstSlot? slot = Graph.LocationOfInputPin(pinId)?.Slot; + return slot is null + ? [] + : AstNodeCatalog.Templates.Where(template => AstSchema.Accepts(slot, template.Create())); + } + + /// + /// Offers the create-node menu for a pin, the way releasing a link drag over empty canvas does. + /// + /// The pin to offer nodes for. + /// True if the pin is one this graph can offer nodes for, so the menu will open. + /// + /// Separate from the gesture that usually triggers it, so the affordance is an operation rather + /// than something only a mouse can reach — a host binding it to a key wants the same menu, and it + /// can be driven in a test without a drag landing on a pin by luck. + /// + /// Only remembers the request. The popup itself is opened on the next frame drawn, because + /// only means anything inside one. + /// + /// + public bool RequestCreateFrom(int pinId) + { + if (Graph.OwnerOfOutputPin(pinId) is null && Graph.LocationOfInputPin(pinId)?.Slot is null) + { + statusMessage = "That pin is not in this graph."; + return false; + } + + linkDropPin = pinId; + linkDropOpening = true; + return true; + } + + /// + /// Creates a node from a template and connects it to the pin a link was dragged off. + /// + /// The kind of node to create. + /// The pin the drag started at. + /// Where to place the new node. + /// True if the node was created and connected. + /// + /// Two steps go on the history rather than one, the same way creating a node from the palette and + /// wiring it up by hand would: the user did both, and undoing the connection without also undoing + /// the node is a state they can reach by hand too. + /// + /// Refuses rather than creating an orphan if the pin is not in the graph, or if the template turns + /// out not to connect after all. Nothing is recorded in that case, so a refusal leaves the history + /// alone. + /// + /// + public bool CreateFrom(AstNodeTemplate template, int pinId, Vector2 position) + { + Ensure.NotNull(template); + + AstNode? dragged = Graph.OwnerOfOutputPin(pinId); + if (dragged is not null) + { + // The drag began at a node's output, so the new node is the parent and the dragged node + // moves into it. + AstNode parent = template.Create(); + AstSlot? slot = SlotAccepting(parent, dragged); + if (slot is null) + { + statusMessage = $"{AstSchema.Describe(parent)} has nowhere to put {AstSchema.Describe(dragged)}."; + return false; + } + + AstLocation from = Graph.LocationOf(dragged); + Add(parent, position); + + int index = slot.Cardinality == AstSlotCardinality.Many + ? AstSchema.ChildrenOf(parent, slot).Count + : 0; + + RecordMove( + $"Connect {AstSchema.Describe(dragged)} to {slot.Name} of {AstSchema.Describe(parent)}", + ChangeType.Move, + dragged, + new AstLocation(parent, slot, index), + from); + + statusMessage = $"Created {AstSchema.Describe(parent)} around {AstSchema.Describe(dragged)}."; + return true; + } + + AstLocation? target = Graph.LocationOfInputPin(pinId); + if (target?.Slot is null) + { + statusMessage = "That pin is not in this graph."; + return false; + } + + // The drag began at a slot, so the new node is the child that fills it. + AstNode child = template.Create(); + if (!AstSchema.Accepts(target.Value.Slot, child)) + { + statusMessage = $"{AstSchema.Describe(child)} cannot fill {target.Value.Slot.Name}."; + return false; + } + + Add(child, position); + RecordMove( + $"Connect {AstSchema.Describe(child)} to {target.Value.Slot.Name}", + ChangeType.Move, + child, + target.Value, + Graph.LocationOf(child)); + + statusMessage = $"Created {AstSchema.Describe(child)} in {target.Value.Slot.Name}."; + return true; + } + + /// + /// Finds the first slot of a node that would take a given child. + /// + /// The node to look for a slot on. + /// The node that has to fit. + /// The slot, or null if none of them would take it. + /// + /// First rather than best: a freshly created template node has no real children yet, so every slot + /// that accepts the child is equally empty, and the first is the one the palette's own ordering + /// puts first. + /// + private static AstSlot? SlotAccepting(AstNode parent, AstNode child) => + AstSchema.SlotsOf(parent).FirstOrDefault(slot => AstSchema.Accepts(slot, child)); + /// /// Adds one more child to a variadic slot, as one undoable edit. /// @@ -1259,4 +1443,175 @@ private void DrawPalette() ImGui.EndPopup(); } + + /// + /// Draws the menu a link dropped on empty canvas opens, and creates whatever the user picks. + /// + /// + /// Grouped the same way the palette is, minus the categories and submenus nothing in them would + /// connect, so the menu is a shortlist rather than the whole catalogue with most of it refusing. + /// + /// Dismissing the menu without choosing leaves the graph untouched: ImNodes discards a dropped link + /// itself, so there is no half-made connection to undo — only the remembered pin to forget. + /// + /// + private void DrawLinkDropMenu() + { + if (linkDropPin is not int pinId) + { + return; + } + + // Opened here rather than where the request came from, because ImGui.OpenPopup only means + // anything inside a frame and a request can arrive from outside one. + if (linkDropOpening) + { + ImGui.OpenPopup(LinkDropPopup); + linkDropOpening = false; + } + + if (!ImGui.BeginPopup(LinkDropPopup)) + { + // Open exactly as long as the popup is: once it has gone, the user either picked something + // or dismissed it, and either way the pin is spent. + linkDropPin = null; + return; + } + + Vector2 dropPosition = ImGui.GetMousePosOnOpeningCurrentPopup(); + HashSet creatable = [.. CreatableFrom(pinId)]; + + if (creatable.Count == 0) + { + ImGui.TextDisabled(NothingConnects(pinId)); + ImGuiProbes.MarkItem("Nothing connects"); + ImGui.EndPopup(); + return; + } + + foreach (string category in AstNodeCatalog.Categories) + { + DrawLinkDropCategory(category, creatable, pinId, dropPosition); + } + + ImGui.EndPopup(); + } + + /// + /// Draws one category of the dropped-link menu, and the submenus inside it. + /// + /// The category to draw. + /// The entries that would connect to the pin. + /// The pin the link was dragged off. + /// Where a created node is placed. + /// + /// A category with nothing on offer is left out rather than drawn empty, which is what makes this + /// menu a shortlist rather than the whole palette with most of it refusing. + /// + private void DrawLinkDropCategory( + string category, + HashSet creatable, + int pinId, + Vector2 dropPosition) + { + if (!AstNodeCatalog.InCategory(category).Any(creatable.Contains)) + { + return; + } + + bool open = ImGui.BeginMenu(category); + + // Named for the probes the way the palette's rows are, so a test opens a category by the label + // the user reads rather than by a coordinate. + ImGuiProbes.MarkItem($"Create {category}"); + + if (!open) + { + return; + } + + DrawLinkDropEntries(AstNodeCatalog.InGroup(category, null).Where(creatable.Contains), pinId, dropPosition); + + foreach (string group in AstNodeCatalog.GroupsIn(category)) + { + DrawLinkDropGroup(category, group, creatable, pinId, dropPosition); + } + + ImGui.EndMenu(); + } + + /// + /// Draws one submenu of a category in the dropped-link menu. + /// + /// The category the submenu sits under. + /// The submenu to draw. + /// The entries that would connect to the pin. + /// The pin the link was dragged off. + /// Where a created node is placed. + private void DrawLinkDropGroup( + string category, + string group, + HashSet creatable, + int pinId, + Vector2 dropPosition) + { + AstNodeTemplate[] entries = [.. AstNodeCatalog.InGroup(category, group).Where(creatable.Contains)]; + if (entries.Length == 0) + { + return; + } + + bool open = ImGui.BeginMenu(group); + ImGuiProbes.MarkItem($"Create {category} {group}"); + + if (open) + { + DrawLinkDropEntries(entries, pinId, dropPosition); + ImGui.EndMenu(); + } + } + + /// + /// Draws one menu's worth of entries for a dropped link, connecting whatever is picked. + /// + /// The entries to list. + /// The pin the link was dragged off. + /// Where a created node is placed. + private void DrawLinkDropEntries(IEnumerable templates, int pinId, Vector2 dropPosition) + { + foreach (AstNodeTemplate template in templates) + { + bool picked = ImGui.MenuItem(template.Label); + ImGuiProbes.MarkItem($"Create {template.Label}"); + + if (!picked) + { + continue; + } + + CreateFrom(template, pinId, dropPosition); + linkDropPin = null; + ImGui.CloseCurrentPopup(); + return; + } + } + + /// + /// Says why a dropped link has nothing to offer, in terms of what the user dragged. + /// + /// The pin the link was dragged off. + /// The message the menu shows in place of entries. + private string NothingConnects(int pinId) + { + AstNode? dragged = Graph.OwnerOfOutputPin(pinId); + if (dragged is not null) + { + return $"Nothing in the palette takes {AstSchema.Describe(dragged)}."; + } + + AstSlot? slot = Graph.LocationOfInputPin(pinId)?.Slot; + return slot is null + ? "That pin is no longer in the graph." + : $"Nothing in the palette fills {slot.Name}."; + } } diff --git a/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs b/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs new file mode 100644 index 0000000..3723db9 --- /dev/null +++ b/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs @@ -0,0 +1,240 @@ +// 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 dropped link opens as the user meets it: entries reached with the mouse, by the +/// label they read as, through the headless rasterizer. +/// +/// +/// What the entries are and what picking one attaches is covered headlessly in +/// . What is covered here is that the popup draws those +/// entries at all and that clicking 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 dragging +/// a link onto a pin, because where a pin lands on screen depends on the force-directed layout. The +/// drag itself is covered by Editor_HandlesALinkDroppedOnEmptyCanvas. +/// +/// +/// 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 AstGraphEditorLinkDropMenuTests +{ + 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 link drop", + 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 the create-node menu already open for a pin. + /// + /// The editor to draw. + /// The pin to offer nodes 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.RequestCreateFrom(pinId), "the pin to offer nodes for should be in the graph"); + harness.Step(2); + return harness; + } + + /// + /// Tests that the menu lists a category per group of entries that would connect, and leaves out + /// the ones that filtered empty. + /// + [TestMethod] + public void Menu_ListsOnlyTheCategoriesThatHaveSomethingToOffer() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + + // The Parameters slot takes only a parameter, which lives under Declarations. + using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 1)); + + Assert.IsTrue(IsVisible(harness, "Create Declarations"), "the menu should offer Declarations"); + Assert.IsFalse(IsVisible(harness, "Create Literals"), "no literal can fill a Parameters slot"); + Assert.IsFalse(IsVisible(harness, "Nothing connects"), "a parameter is creatable, so the menu is not empty"); + } + + /// + /// Tests that clicking an entry creates the node and connects it, through the menu rather than by + /// calling the edit directly. + /// + [TestMethod] + public void Menu_CreatesAndConnectsThePickedEntry() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + + using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 1)); + + harness.Click("Create Declarations"); + harness.Step(2); + + harness.Click("Create Parameter"); + harness.Step(2); + + Assert.HasCount(2, function.Parameters); + Assert.IsTrue(editor.History.CanUndo, "creating through the menu should be undoable"); + } + + /// + /// Tests that a slot the palette cannot fill says so, rather than opening an empty menu. + /// + [TestMethod] + public void Menu_SaysSoWhenNothingConnects() + { + EnumDeclaration enumeration = new("Colour"); + AstGraphEditor editor = new(enumeration); + + AstSlot members = AstSchema.SlotsOf(enumeration).Single(); + using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, enumeration, members.Name, 0)); + + Assert.IsTrue(IsVisible(harness, "Nothing connects"), "an EnumMember slot has no palette entry to offer"); + Assert.IsFalse(IsVisible(harness, "Create Declarations"), "nothing connects, so no category should be listed"); + } + + /// + /// Tests that the menu offers a submenu for a category whose entries are grouped, which is where + /// the eighteen binary operators live. + /// + [TestMethod] + public void Menu_NestsAGroupedCategorysEntries() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + + // A Body slot takes any statement, so the grouped assignment operators are on offer. + using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Body", 1)); + + harness.Click("Create Statements"); + harness.Step(2); + + Assert.IsTrue(IsVisible(harness, "Create Statements Assignment"), "the grouped operators should be a submenu"); + } + + /// + /// 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. + /// + /// + /// ImNodes discards a dropped link itself, so there is nothing half-made to undo — cancelling has + /// to be a true no-op, and this is what says so. + /// + [TestMethod] + public void Menu_DismissedWithoutPicking_ChangesNothing() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + + using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Parameters", 1)); + Assert.IsTrue(IsVisible(harness, "Create Declarations"), "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, "Create Declarations"), "the menu should be gone once dismissed"); + Assert.HasCount(before, editor.Graph.Nodes); + Assert.IsFalse(editor.History.CanUndo, "cancelling should record nothing"); + Assert.HasCount(1, function.Parameters); + } + + /// + /// Tests that opening a grouped submenu lists the entries inside it, which is where the operator + /// entries live. + /// + [TestMethod] + public void Menu_OpensAGroupedSubmenuToItsEntries() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + + using ImGuiAppHarness harness = Offering(editor, InputPin(editor.Graph, function, "Body", 1)); + + harness.Click("Create Statements"); + harness.Step(2); + + harness.Click("Create Statements Assignment"); + harness.Step(2); + + // Read from the catalogue rather than spelled here, so the assertion follows the operator set + // rather than pinning one operator's label. + string first = AstNodeCatalog.InGroup("Statements", "Assignment").First().Label; + Assert.IsTrue(IsVisible(harness, $"Create {first}"), $"the submenu should list '{first}'"); + } + + /// + /// Tests that a pin that is not in the graph is refused, so no menu is offered for it. + /// + [TestMethod] + public void RequestCreateFrom_UnknownPin_IsRefused() + { + AstGraphEditor editor = new(SampleFunction()); + + using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options); + harness.Step(2); + + Assert.IsFalse(editor.RequestCreateFrom(-1)); + harness.Step(2); + + Assert.IsFalse(IsVisible(harness, "Create Declarations")); + Assert.IsFalse(IsVisible(harness, "Nothing connects")); + } + + /// + /// 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/AstGraphEditorLinkDropTests.cs b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs new file mode 100644 index 0000000..8ed786e --- /dev/null +++ b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs @@ -0,0 +1,305 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Graph; + +using System.Collections.Generic; +using System.Linq; +using System.Numerics; +using ktsu.Coder.Ast; +using ktsu.Coder.Graph; +using ktsu.ImGui.NodeEditor; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests what a link dragged off a pin and dropped on empty canvas offers to create, and what +/// picking one of those entries does to the document. +/// +/// +/// These drive and +/// directly rather than through ImGui. Which entries a drop offers and what accepting one attaches +/// are rules, so they live where the rules are tested; the popup that lists them is part of the +/// surface covers. +/// +[TestClass] +public class AstGraphEditorLinkDropTests +{ + /// + /// The whole menu a parameter's output pin should offer: only a function has a signature to put + /// one in. + /// + private static readonly string[] ExpectedForParameter = ["Function"]; + + 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; + } + + /// + /// Tests that dropping a drag begun at a slot offers every node the slot would take, and nothing + /// it would refuse. + /// + /// + /// A body takes any statement, which is everything in the palette except a parameter and an entry + /// point — a parameter belongs to a signature and a program starts running at an entry point, so + /// neither stands inside a body. + /// + [TestMethod] + public void CreatableFrom_InputPin_OffersEverythingTheSlotAccepts() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + + AstNodeTemplate[] offered = [.. editor.CreatableFrom(InputPin(editor.Graph, function, "Body", 1))]; + + Assert.IsGreaterThan(0, offered.Length); + + AstSlot body = AstSchema.SlotsOf(function).Single(slot => slot.Name == "Body"); + foreach (AstNodeTemplate template in offered) + { + Assert.IsTrue( + AstSchema.Accepts(body, template.Create()), + $"'{template.Label}' was offered for Body but the slot would refuse it"); + } + + Assert.DoesNotContain("Parameter", Labels(offered)); + Assert.DoesNotContain("Entry point", Labels(offered)); + Assert.Contains("Return", Labels(offered)); + } + + /// + /// Tests that dropping a drag begun at a node's output offers only nodes with a slot that node + /// could sit in. + /// + /// + /// A parameter fits nothing in the palette but a function's signature, so that is the whole of the + /// menu — the filter is doing real work here rather than trimming an entry or two. + /// + [TestMethod] + public void CreatableFrom_OutputPin_OffersOnlyNodesWithARoomForIt() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + Parameter parameter = function.Parameters[0]; + + AstNodeTemplate[] offered = [.. editor.CreatableFrom(OutputPin(editor.Graph, parameter))]; + + Assert.AreSequenceEqual(ExpectedForParameter, Labels(offered)); + } + + /// + /// Tests that a slot the palette cannot fill offers nothing, rather than offering entries the + /// connection would then refuse. + /// + /// + /// An enumeration's members take an and the palette has no entry for one, + /// so this is a genuinely empty answer rather than a lookup that failed. + /// + [TestMethod] + public void CreatableFrom_InputPin_OffersNothingWhenThePaletteCannotFillTheSlot() + { + EnumDeclaration enumeration = new("Colour"); + AstGraphEditor editor = new(enumeration); + + AstSlot members = AstSchema.SlotsOf(enumeration).Single(); + AstNodeTemplate[] offered = [.. editor.CreatableFrom(InputPin(editor.Graph, enumeration, members.Name, 0))]; + + Assert.IsEmpty(offered); + } + + /// + /// Tests that a pin that is not in the graph offers nothing. + /// + [TestMethod] + public void CreatableFrom_UnknownPin_OffersNothing() + { + AstGraphEditor editor = new(SampleFunction()); + + Assert.IsEmpty(editor.CreatableFrom(-1)); + } + + /// + /// Tests that picking an entry for a drag begun at a slot puts the new node in that slot. + /// + [TestMethod] + public void CreateFrom_InputPin_FillsTheSlot() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + + bool created = editor.CreateFrom( + Template("Variable"), + InputPin(editor.Graph, function, "Body", 1), + new Vector2(40, 60)); + + Assert.IsTrue(created); + Assert.HasCount(2, function.Body); + Assert.IsInstanceOfType(function.Body[1]); + } + + /// + /// Tests that picking an entry for a drag begun at a node's output moves that node into the new + /// one, rather than leaving both detached. + /// + /// + /// This is the direction the issue is really about: the user dragged out of a value looking for + /// somewhere to put it, so the node they pick has to arrive already holding it. + /// + [TestMethod] + public void CreateFrom_OutputPin_PutsTheDraggedNodeInTheNewOne() + { + ClassDeclaration holder = new("Holder"); + AstGraphEditor editor = new(holder); + + LiteralExpression literal = Literal.Number(7); + editor.Add(literal, new Vector2(10, 10)); + + bool created = editor.CreateFrom( + Template("Return"), + OutputPin(editor.Graph, literal), + new Vector2(80, 10)); + + Assert.IsTrue(created); + + ReturnStatement statement = editor.Graph.Nodes.Values.OfType().Single(); + Assert.AreSame(literal, statement.Expression); + } + + /// + /// Tests that creating the node and connecting it are two steps in the history. + /// + /// + /// The same two the user would have taken by hand, so the first undo takes the connection back and + /// leaves the node they asked for on the canvas, rather than making the whole gesture all-or-nothing. + /// + [TestMethod] + public void CreateFrom_RecordsTheNodeAndTheConnectionSeparately() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + + editor.CreateFrom( + Template("Variable"), + InputPin(editor.Graph, function, "Body", 1), + new Vector2(40, 60)); + + Assert.HasCount(2, function.Body); + + editor.Undo(); + Assert.HasCount(1, function.Body); + Assert.IsTrue(editor.History.CanUndo, "the node's own creation should still be on the stack"); + + editor.Undo(); + Assert.HasCount(1, function.Body); + Assert.IsFalse(editor.History.CanUndo); + } + + /// + /// Tests that a pin that is not in the graph is refused without touching the document or the + /// history. + /// + [TestMethod] + public void CreateFrom_UnknownPin_RecordsNothing() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + int before = editor.Graph.Nodes.Count; + + Assert.IsFalse(editor.CreateFrom(Template("Variable"), -1, new Vector2(40, 60))); + Assert.IsFalse(editor.History.CanUndo); + Assert.HasCount(before, editor.Graph.Nodes); + Assert.HasCount(1, function.Body); + } + + /// + /// Tests that a template the slot would refuse is rejected without recording anything, even + /// though the pin itself is in the graph. + /// + /// + /// Not reachable from the menu, which only lists what connects, but + /// is public and a caller can hand it any pairing — so it refuses rather than attaching something + /// the slot would then reject. + /// + [TestMethod] + public void CreateFrom_InputPin_RefusesATemplateTheSlotWouldNotTake() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + int before = editor.Graph.Nodes.Count; + + // Parameters takes only a parameter, and a number literal is not one. + Assert.IsFalse(editor.CreateFrom( + Template("Number"), + InputPin(editor.Graph, function, "Parameters", 1), + new Vector2(40, 60))); + + Assert.IsFalse(editor.History.CanUndo); + Assert.HasCount(before, editor.Graph.Nodes); + Assert.HasCount(1, function.Parameters); + } + + /// + /// Tests that a template with nowhere to put the dragged node is refused without recording + /// anything. + /// + [TestMethod] + public void CreateFrom_OutputPin_RefusesATemplateWithNoSlotForTheDraggedNode() + { + FunctionDeclaration function = SampleFunction(); + AstGraphEditor editor = new(function); + Parameter parameter = function.Parameters[0]; + int before = editor.Graph.Nodes.Count; + + // A number literal has no slots at all, so it cannot take a parameter. + Assert.IsFalse(editor.CreateFrom( + Template("Number"), + OutputPin(editor.Graph, parameter), + new Vector2(40, 60))); + + Assert.IsFalse(editor.History.CanUndo); + Assert.HasCount(before, editor.Graph.Nodes); + } + + /// + /// Finds the palette entry with a given label. + /// + /// The label to look for. + /// The entry. + private static AstNodeTemplate Template(string label) => + AstNodeCatalog.Templates.Single(template => template.Label == label); + + /// + /// Lists the labels of a set of entries, for asserting on what a menu would show. + /// + /// The entries. + /// Their labels, in order. + private static string[] Labels(IEnumerable templates) => + [.. templates.Select(template => template.Label)]; + + /// + /// 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; + } + + /// + /// Finds a node's output pin. + /// + /// The graph under test. + /// The node to look at. + /// The output pin's id. + private static int OutputPin(AstGraph graph, AstNode node) => + graph.Engine.Nodes.Single(n => ReferenceEquals(graph.AstNodeFor(n.Id), node)).OutputPins[0].Id; +} diff --git a/Coder.Test/Graph/AstGraphEditorTests.cs b/Coder.Test/Graph/AstGraphEditorTests.cs index f847d1f..9359b6a 100644 --- a/Coder.Test/Graph/AstGraphEditorTests.cs +++ b/Coder.Test/Graph/AstGraphEditorTests.cs @@ -440,4 +440,38 @@ public void Editor_UndoesAndRedoesAnEdit() harness.Step(); Assert.AreEqual(before, editor.Graph.Nodes.Count); } + + /// + /// Tests that releasing a link drag over empty canvas runs the dropped-link path without faulting, + /// and creates nothing until an entry is picked. + /// + /// + /// Driven with real input, for the same reason the palette's test is: ImNodes.IsLinkDropped + /// is a native call reading state the renderer sets during a frame, so nothing short of rendering + /// one establishes it is being called correctly. + /// + /// Where a drag starts is layout-dependent, so this asserts what holds either way — the frame + /// renders and the document is untouched — rather than that the menu opened. What the menu offers, + /// and what picking an entry attaches, are rules, and they are covered headlessly in + /// . + /// + /// + [TestMethod] + public void Editor_HandlesALinkDroppedOnEmptyCanvas() + { + AstGraphEditor editor = new(SampleFunction()); + + using ImGuiAppHarness harness = ImGuiAppHarness.Start(ConfigFor(editor), Options); + harness.Step(2); + + int before = editor.Graph.Nodes.Count; + + // Across the canvas and into a corner, so the release lands on empty space wherever the layout + // has put the nodes. + harness.Mouse.Drag(500, 300, 950, 560, steps: 8); + harness.Step(3); + + Assert.HasCount(before, editor.Graph.Nodes, "a dropped link must not create anything on its own"); + Assert.IsFalse(editor.History.CanUndo, "nothing was picked, so nothing should be on the undo stack"); + } }