From c1c3a8bf048b97dfe31edd653b7f5315e0cd8af4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 03:46:57 +0000 Subject: [PATCH 1/3] Offer to create a node when a link is dropped on empty canvas Dragging a link end off a pin and releasing it over nothing used to do nothing at all: ImNodes threw the link away and the user had to reach for the right-click palette, create a node, then drag the link a second time. Now the drop opens a menu of the nodes that would actually connect to the pin the drag began at, and picking one creates it at the drop position and wires it up. Which way the pin faces decides what is offered, because the new node takes the other end of the connection. A drag begun at a slot asks for something to fill it, so the entries are the ones that slot accepts. A drag begun at a node's output asks for somewhere to put that node, so the entries are the ones with a slot that accepts it -- and the node the user dragged moves into the new one rather than both being left detached. Both directions are filtered through the same AstSchema.Accepts the manual drag is checked against, so nothing is offered that the connection would then refuse, and the menu hides the categories and submenus that filtered empty. A slot the palette genuinely cannot fill -- an enumeration's members, which have no catalogue entry -- says so instead of listing entries that would not take it. Creating the node and connecting it go on the history as two steps, the same two the user would have taken by hand via the palette, so the first undo takes the connection back and leaves the node on the canvas. The rules live in AstGraphEditor.CreatableFrom/CreateFrom and AstGraph's two new pin lookups, so they are covered headlessly by eight tests in AstGraphEditorLinkDropTests. The draw path gets one harness test that performs a real drag through the CPU rasterizer, since ImNodes.IsLinkDropped is a native call that only a rendered frame exercises. Fixes #37 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U2TWMet7QsHnYUTZzRYXMQ --- Coder.Graph/AstGraph.cs | 27 ++ Coder.Graph/AstGraphEditor.cs | 251 +++++++++++++++++ .../Graph/AstGraphEditorLinkDropTests.cs | 256 ++++++++++++++++++ Coder.Test/Graph/AstGraphEditorTests.cs | 34 +++ 4 files changed, 568 insertions(+) create mode 100644 Coder.Test/Graph/AstGraphEditorLinkDropTests.cs 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..0c3dd90 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,16 @@ 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; + /// /// Gets the graph being edited. /// @@ -197,6 +212,7 @@ public void Draw(Vector2 size, float deltaTime) TrackSelection(); ApplyDeletions(); DrawPalette(); + DrawLinkDropMenu(); if (ShowDebugOverlays) { @@ -778,6 +794,15 @@ 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)) + { + linkDropPin = droppedFrom; + ImGui.OpenPopup(LinkDropPopup); + } } /// @@ -1022,6 +1047,130 @@ 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())); + } + + /// + /// 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 +1408,106 @@ 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; + } + + 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)); + ImGui.EndPopup(); + return; + } + + foreach (string category in AstNodeCatalog.Categories) + { + if (!AstNodeCatalog.InCategory(category).Any(creatable.Contains) || !ImGui.BeginMenu(category)) + { + continue; + } + + DrawLinkDropEntries(AstNodeCatalog.InGroup(category, null).Where(creatable.Contains), pinId, dropPosition); + + foreach (string group in AstNodeCatalog.GroupsIn(category)) + { + AstNodeTemplate[] entries = [.. AstNodeCatalog.InGroup(category, group).Where(creatable.Contains)]; + if (entries.Length > 0 && ImGui.BeginMenu(group)) + { + DrawLinkDropEntries(entries, pinId, dropPosition); + ImGui.EndMenu(); + } + } + + ImGui.EndMenu(); + } + + ImGui.EndPopup(); + } + + /// + /// 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) + { + if (!ImGui.MenuItem(template.Label)) + { + 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/AstGraphEditorLinkDropTests.cs b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs new file mode 100644 index 0000000..8e0bdec --- /dev/null +++ b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs @@ -0,0 +1,256 @@ +// 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"); + } + + CollectionAssert.DoesNotContain(Labels(offered), "Parameter"); + CollectionAssert.DoesNotContain(Labels(offered), "Entry point"); + CollectionAssert.Contains(Labels(offered), "Return"); + } + + /// + /// 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))]; + + CollectionAssert.AreEquivalent(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.AreEqual(before, editor.Graph.Nodes.Count); + Assert.HasCount(1, function.Body); + } + + /// + /// 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..fa15a7c 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.AreEqual(before, editor.Graph.Nodes.Count, "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"); + } } From 5157af2587515eeddf6504d159d83cace70e3e25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 04:18:02 +0000 Subject: [PATCH 2/3] Make the link-drop menu reachable in a test, and cover it SonarCloud failed the quality gate on this PR: 48.6% coverage on new code, against a required 80%. That was a fair call, not an artifact -- the repo sits at 92.6% overall, and the ~130 lines of menu-drawing code this PR added were exercised by nothing. The PR body admitted the popup body was untested and treated that as acceptable; it was not. The popup could not be opened by a test at all, because it only opened as a side effect of ImNodes reporting a dropped link, which needs a drag to land on a pin whose screen position depends on the force-directed layout. That was a design problem, not a testing one: the existing DrawPalette is covered precisely because a right-click opens it unconditionally. So offering the menu is now an operation, RequestCreateFrom(pinId), and ApplyLinkChanges calls it when ImNodes reports the drop. A host binding the same affordance to a key wants this entry point too. It only records the request; the popup is opened on the next frame drawn, because ImGui.OpenPopup means nothing outside a frame. The menu's categories, groups and entries are now named for the probes the way the inspector's rows already are, so a test picks an entry by the label the user reads rather than by a coordinate. Seven harness tests in AstGraphEditorLinkDropMenuTests drive the popup for real through the CPU rasterizer: which categories it lists, clicking through to an entry and getting the node created and connected, the grouped operator submenu, the empty-slot message, dismissal recording nothing, and an unknown pin being refused. Two headless tests cover the refusal branches in CreateFrom that nothing reached before. New-code coverage measured locally: 48.6% -> 95.2% (140/147 instrumented lines). Full suite 885/885 in Release, 0 warnings. Seven lines remain uncovered, all deliberately: - the IsLinkDropped branch itself, which still needs a drag to land on a pin - an empty group inside a non-empty category, which no slot in the catalogue currently produces - NothingConnects' output-pin branch, which is unreachable today because AstSlotKind.Statement accepts everything except a Parameter and an EntryPoint, so every node has somewhere in the palette to go. It stays as a guard rather than being deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U2TWMet7QsHnYUTZzRYXMQ --- Coder.Graph/AstGraphEditor.cs | 76 +++++- .../Graph/AstGraphEditorLinkDropMenuTests.cs | 240 ++++++++++++++++++ .../Graph/AstGraphEditorLinkDropTests.cs | 49 ++++ 3 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs diff --git a/Coder.Graph/AstGraphEditor.cs b/Coder.Graph/AstGraphEditor.cs index 0c3dd90..47ef983 100644 --- a/Coder.Graph/AstGraphEditor.cs +++ b/Coder.Graph/AstGraphEditor.cs @@ -85,6 +85,15 @@ public sealed class AstGraphEditor(AstNode root) /// 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. /// @@ -800,8 +809,7 @@ private void ApplyLinkChanges() int droppedFrom = 0; if (ImNodes.IsLinkDropped(ref droppedFrom, includingDetachedLinks: false)) { - linkDropPin = droppedFrom; - ImGui.OpenPopup(LinkDropPopup); + RequestCreateFrom(droppedFrom); } } @@ -1078,6 +1086,33 @@ public IEnumerable CreatableFrom(int pinId) : 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. /// @@ -1427,6 +1462,14 @@ private void DrawLinkDropMenu() 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 @@ -1441,13 +1484,25 @@ private void DrawLinkDropMenu() if (creatable.Count == 0) { ImGui.TextDisabled(NothingConnects(pinId)); + ImGuiProbes.MarkItem("Nothing connects"); ImGui.EndPopup(); return; } foreach (string category in AstNodeCatalog.Categories) { - if (!AstNodeCatalog.InCategory(category).Any(creatable.Contains) || !ImGui.BeginMenu(category)) + if (!AstNodeCatalog.InCategory(category).Any(creatable.Contains)) + { + continue; + } + + 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) { continue; } @@ -1457,7 +1512,15 @@ private void DrawLinkDropMenu() foreach (string group in AstNodeCatalog.GroupsIn(category)) { AstNodeTemplate[] entries = [.. AstNodeCatalog.InGroup(category, group).Where(creatable.Contains)]; - if (entries.Length > 0 && ImGui.BeginMenu(group)) + if (entries.Length == 0) + { + continue; + } + + bool groupOpen = ImGui.BeginMenu(group); + ImGuiProbes.MarkItem($"Create {category} {group}"); + + if (groupOpen) { DrawLinkDropEntries(entries, pinId, dropPosition); ImGui.EndMenu(); @@ -1480,7 +1543,10 @@ private void DrawLinkDropEntries(IEnumerable templates, int pin { foreach (AstNodeTemplate template in templates) { - if (!ImGui.MenuItem(template.Label)) + bool picked = ImGui.MenuItem(template.Label); + ImGuiProbes.MarkItem($"Create {template.Label}"); + + if (!picked) { continue; } diff --git a/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs b/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs new file mode 100644 index 0000000..0ac4e5a --- /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.AreEqual(before, editor.Graph.Nodes.Count); + 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 index 8e0bdec..e80b56f 100644 --- a/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs +++ b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs @@ -214,6 +214,55 @@ public void CreateFrom_UnknownPin_RecordsNothing() 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.AreEqual(before, editor.Graph.Nodes.Count); + 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.AreEqual(before, editor.Graph.Nodes.Count); + } + /// /// Finds the palette entry with a given label. /// From d8f285b25dadf458cddafbfa9ec59db7d5fe3212 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 04:32:52 +0000 Subject: [PATCH 3/3] Split the link-drop menu's draw, and modernise the new assertions SonarCloud passed the gate on 5157af2 (97.8% coverage on new code) but reported ten new issues, one of them critical. S3776, critical: DrawLinkDropMenu had a cognitive complexity of 17 against the allowed 15. Fair -- it had grown a category loop and a group loop inside the popup's own open-and-guard sequence. Split the two loops out as DrawLinkDropCategory and DrawLinkDropGroup, which also lets each one return early instead of continuing, so the nesting that drove the score is gone. The other nine are MSTest analyser suggestions at info level on assertions this PR added, all mechanical and all plainly right, so they ride along rather than waiting for a push of their own: - Assert.AreEqual(n, x.Count) -> Assert.HasCount(n, x) - CollectionAssert.Contains/DoesNotContain -> Assert.Contains/DoesNotContain - CollectionAssert.AreEquivalent -> Assert.AreSequenceEqual No behaviour change. Full suite 885/885 in Release, 0 warnings. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01U2TWMet7QsHnYUTZzRYXMQ --- Coder.Graph/AstGraphEditor.cs | 98 +++++++++++++------ .../Graph/AstGraphEditorLinkDropMenuTests.cs | 2 +- .../Graph/AstGraphEditorLinkDropTests.cs | 14 +-- Coder.Test/Graph/AstGraphEditorTests.cs | 2 +- 4 files changed, 77 insertions(+), 39 deletions(-) diff --git a/Coder.Graph/AstGraphEditor.cs b/Coder.Graph/AstGraphEditor.cs index 47ef983..4109429 100644 --- a/Coder.Graph/AstGraphEditor.cs +++ b/Coder.Graph/AstGraphEditor.cs @@ -1491,46 +1491,84 @@ private void DrawLinkDropMenu() foreach (string category in AstNodeCatalog.Categories) { - if (!AstNodeCatalog.InCategory(category).Any(creatable.Contains)) - { - continue; - } + DrawLinkDropCategory(category, creatable, pinId, dropPosition); + } - bool open = ImGui.BeginMenu(category); + ImGui.EndPopup(); + } - // 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}"); + /// + /// 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; + } - if (!open) - { - continue; - } + bool open = ImGui.BeginMenu(category); - DrawLinkDropEntries(AstNodeCatalog.InGroup(category, null).Where(creatable.Contains), pinId, dropPosition); + // 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}"); - foreach (string group in AstNodeCatalog.GroupsIn(category)) - { - AstNodeTemplate[] entries = [.. AstNodeCatalog.InGroup(category, group).Where(creatable.Contains)]; - if (entries.Length == 0) - { - continue; - } + if (!open) + { + return; + } - bool groupOpen = ImGui.BeginMenu(group); - ImGuiProbes.MarkItem($"Create {category} {group}"); + DrawLinkDropEntries(AstNodeCatalog.InGroup(category, null).Where(creatable.Contains), pinId, dropPosition); - if (groupOpen) - { - DrawLinkDropEntries(entries, pinId, dropPosition); - ImGui.EndMenu(); - } - } + foreach (string group in AstNodeCatalog.GroupsIn(category)) + { + DrawLinkDropGroup(category, group, creatable, pinId, dropPosition); + } - ImGui.EndMenu(); + 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; } - ImGui.EndPopup(); + bool open = ImGui.BeginMenu(group); + ImGuiProbes.MarkItem($"Create {category} {group}"); + + if (open) + { + DrawLinkDropEntries(entries, pinId, dropPosition); + ImGui.EndMenu(); + } } /// diff --git a/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs b/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs index 0ac4e5a..3723db9 100644 --- a/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs +++ b/Coder.Test/Graph/AstGraphEditorLinkDropMenuTests.cs @@ -175,7 +175,7 @@ public void Menu_DismissedWithoutPicking_ChangesNothing() harness.Step(3); Assert.IsFalse(IsVisible(harness, "Create Declarations"), "the menu should be gone once dismissed"); - Assert.AreEqual(before, editor.Graph.Nodes.Count); + Assert.HasCount(before, editor.Graph.Nodes); Assert.IsFalse(editor.History.CanUndo, "cancelling should record nothing"); Assert.HasCount(1, function.Parameters); } diff --git a/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs index e80b56f..8ed786e 100644 --- a/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs +++ b/Coder.Test/Graph/AstGraphEditorLinkDropTests.cs @@ -65,9 +65,9 @@ public void CreatableFrom_InputPin_OffersEverythingTheSlotAccepts() $"'{template.Label}' was offered for Body but the slot would refuse it"); } - CollectionAssert.DoesNotContain(Labels(offered), "Parameter"); - CollectionAssert.DoesNotContain(Labels(offered), "Entry point"); - CollectionAssert.Contains(Labels(offered), "Return"); + Assert.DoesNotContain("Parameter", Labels(offered)); + Assert.DoesNotContain("Entry point", Labels(offered)); + Assert.Contains("Return", Labels(offered)); } /// @@ -87,7 +87,7 @@ public void CreatableFrom_OutputPin_OffersOnlyNodesWithARoomForIt() AstNodeTemplate[] offered = [.. editor.CreatableFrom(OutputPin(editor.Graph, parameter))]; - CollectionAssert.AreEquivalent(ExpectedForParameter, Labels(offered)); + Assert.AreSequenceEqual(ExpectedForParameter, Labels(offered)); } /// @@ -210,7 +210,7 @@ public void CreateFrom_UnknownPin_RecordsNothing() Assert.IsFalse(editor.CreateFrom(Template("Variable"), -1, new Vector2(40, 60))); Assert.IsFalse(editor.History.CanUndo); - Assert.AreEqual(before, editor.Graph.Nodes.Count); + Assert.HasCount(before, editor.Graph.Nodes); Assert.HasCount(1, function.Body); } @@ -237,7 +237,7 @@ public void CreateFrom_InputPin_RefusesATemplateTheSlotWouldNotTake() new Vector2(40, 60))); Assert.IsFalse(editor.History.CanUndo); - Assert.AreEqual(before, editor.Graph.Nodes.Count); + Assert.HasCount(before, editor.Graph.Nodes); Assert.HasCount(1, function.Parameters); } @@ -260,7 +260,7 @@ public void CreateFrom_OutputPin_RefusesATemplateWithNoSlotForTheDraggedNode() new Vector2(40, 60))); Assert.IsFalse(editor.History.CanUndo); - Assert.AreEqual(before, editor.Graph.Nodes.Count); + Assert.HasCount(before, editor.Graph.Nodes); } /// diff --git a/Coder.Test/Graph/AstGraphEditorTests.cs b/Coder.Test/Graph/AstGraphEditorTests.cs index fa15a7c..9359b6a 100644 --- a/Coder.Test/Graph/AstGraphEditorTests.cs +++ b/Coder.Test/Graph/AstGraphEditorTests.cs @@ -471,7 +471,7 @@ public void Editor_HandlesALinkDroppedOnEmptyCanvas() harness.Mouse.Drag(500, 300, 950, 560, steps: 8); harness.Step(3); - Assert.AreEqual(before, editor.Graph.Nodes.Count, "a dropped link must not create anything on its own"); + 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"); } }