Skip to content

Commit c63f4c5

Browse files
koeylai-adskclaude
andauthored
DYN-10569: Only warn on Python port removal when the port has custom properties (#17313)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e2f6d6d commit c63f4c5

4 files changed

Lines changed: 400 additions & 3 deletions

File tree

src/DynamoCoreWpf/Controls/DynamoNodeButton.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,15 @@ private void OnDynamoNodeButtonClick(object sender, RoutedEventArgs e)
6868
{
6969
// Only show the prompt if it is a Python node
7070
var nodeVM = (sender as DynamoNodeButton)?.DataContext as NodeViewModel;
71-
if (nodeVM?.NodeModel is PythonNodeModels.PythonNode)
72-
{
71+
if (nodeVM?.NodeModel is PythonNodeModels.PythonNode pythonNode)
72+
{
7373
MessageBoxResult result = MessageBoxResult.None;
7474

75-
if (eventName.Equals("RemoveInPort") && ShowWarningForRemovingInPort)
75+
// Removing an input port always removes the last one, so only warn when that
76+
// port carries custom properties that the user would actually lose. This also
77+
// suppresses the prompt when there is no port left to remove.
78+
if (eventName.Equals("RemoveInPort") && ShowWarningForRemovingInPort
79+
&& pythonNode.HasCustomInputPortProperties(pythonNode.InPorts.Count - 1))
7680
{
7781
result = MessageBoxService.Show
7882
(

src/Libraries/PythonNodeModels/PythonNode.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,35 @@ protected override string GetInputTooltip(int index)
128128
return "Input #" + index;
129129
}
130130

131+
/// <summary>
132+
/// Returns true if the input port at <paramref name="index"/> has a name or tooltip that
133+
/// differs from the auto-generated default for that index, i.e. the user renamed the port
134+
/// or edited its description through the port context menu. Used to decide whether removing
135+
/// the port would actually discard anything the user configured.
136+
/// Returns false for an out-of-range index, so callers can pass the index of a port that
137+
/// does not exist (for example when there are no input ports left to remove).
138+
/// </summary>
139+
/// <param name="index">Index of the input port to inspect.</param>
140+
/// <returns>True when the port carries user-customized properties.</returns>
141+
internal bool HasCustomInputPortProperties(int index)
142+
{
143+
if (index < 0 || index >= InPorts.Count)
144+
{
145+
return false;
146+
}
147+
148+
// For PythonNode, port i is created with GetInputName(i)/GetInputTooltip(i), so any
149+
// difference from those defaults is a user edit.
150+
// This does NOT hold for PythonStringNode: it prepends a fixed "script" port and
151+
// overrides GetInputIndex to subtract one, so its port i carries the defaults for
152+
// i - 1 and EVERY untouched port would be reported as customized. That is inert only
153+
// because the sole caller narrows to PythonNode; widening it to PythonNodeBase
154+
// requires correcting the index mapping here first.
155+
var port = InPorts[index];
156+
return !string.Equals(port.Name, GetInputName(index), StringComparison.Ordinal)
157+
|| !string.Equals(port.ToolTip, GetInputTooltip(index), StringComparison.Ordinal);
158+
}
159+
131160
protected AssociativeNode CreateOutputAST(
132161
AssociativeNode codeInputNode, List<AssociativeNode> inputAstNodes,
133162
List<Tuple<string, AssociativeNode>> additionalBindings)
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
using System.Linq;
2+
using System.Windows;
3+
using System.Windows.Controls.Primitives;
4+
using Dynamo.Controls;
5+
using Dynamo.Models;
6+
using Dynamo.Nodes;
7+
using Dynamo.Utilities;
8+
using Dynamo.Wpf.Utilities;
9+
using DynamoCoreWpfTests.Utility;
10+
using Moq;
11+
using NUnit.Framework;
12+
using PythonNodeModels;
13+
14+
namespace DynamoCoreWpfTests
15+
{
16+
/// <summary>
17+
/// Covers the "Remove Port?" warning raised by the '-' button on a Python node.
18+
/// The warning must appear only when the port being removed carries custom properties
19+
/// that the user would lose, rather than on every removal. See DYN-10569.
20+
/// </summary>
21+
/// <remarks>
22+
/// Not tagged "UnitTests": each case starts a full DynamoView via DynamoTestUIBase and takes
23+
/// minutes. The comparison logic itself is covered cheaply by the HasCustomInputPortProperties
24+
/// tests in DynamoPythonTests; this fixture exists to verify the button actually consults it.
25+
/// </remarks>
26+
[Category("RegressionTests")]
27+
public class PythonNodeRemovePortWarningTests : DynamoTestUIBase
28+
{
29+
private Mock<MessageBoxService.IMessageBox> dialogMock;
30+
31+
[TearDown]
32+
public void ResetMessageBoxOverride()
33+
{
34+
// The override is a static field on MessageBoxService, so it would otherwise
35+
// stay installed for every fixture that runs after this one.
36+
MessageBoxService.OverrideMessageBoxDuringTests(null);
37+
dialogMock = null;
38+
}
39+
40+
/// <summary>
41+
/// Installs a recording message box that answers <paramref name="answer"/> to any prompt,
42+
/// so a warning does not block the test and can be asserted on afterwards.
43+
/// </summary>
44+
/// <param name="answer">The result the mocked dialog returns, defaulting to OK.</param>
45+
private void InstallDialogMock(MessageBoxResult answer = MessageBoxResult.OK)
46+
{
47+
dialogMock = new Mock<MessageBoxService.IMessageBox>();
48+
dialogMock
49+
.Setup(m => m.Show(It.IsAny<Window>(), It.IsAny<string>(), It.IsAny<string>(),
50+
It.IsAny<MessageBoxButton>(), It.IsAny<MessageBoxImage>()))
51+
.Returns(answer);
52+
53+
MessageBoxService.OverrideMessageBoxDuringTests(dialogMock.Object);
54+
}
55+
56+
/// <summary>
57+
/// Adds a Python node to the current workspace and returns its realized NodeView.
58+
/// </summary>
59+
private NodeView CreatePythonNodeView(out PythonNode pythonNode)
60+
{
61+
pythonNode = new PythonNode();
62+
Model.ExecuteCommand(new DynamoModel.CreateNodeCommand(pythonNode, 0, 0, true, false));
63+
DispatcherUtil.DoEventsLoop();
64+
65+
return NodeViewOf<PythonNode>();
66+
}
67+
68+
/// <summary>
69+
/// Returns the '-' button that VariableInputNodeViewCustomization adds to the node view.
70+
/// </summary>
71+
private static DynamoNodeButton RemovePortButton(NodeView nodeView)
72+
{
73+
var button = nodeView.inputGrid.ChildrenOfType<DynamoNodeButton>()
74+
.SingleOrDefault(b => "-".Equals(b.Content));
75+
76+
Assert.IsNotNull(button, "Expected a single '-' button on the Python node view.");
77+
return button;
78+
}
79+
80+
private static void ClickButton(DynamoNodeButton button)
81+
{
82+
button.RaiseEvent(new RoutedEventArgs(ButtonBase.ClickEvent));
83+
DispatcherUtil.DoEventsLoop();
84+
}
85+
86+
private void AssertWarningShown(Times times)
87+
{
88+
dialogMock.Verify(m => m.Show(It.IsAny<Window>(), It.IsAny<string>(), It.IsAny<string>(),
89+
It.IsAny<MessageBoxButton>(), It.IsAny<MessageBoxImage>()), times);
90+
}
91+
92+
[Test]
93+
public void WhenRemovingUnmodifiedPythonPortThenNoWarningIsShown()
94+
{
95+
// Arrange: a Python node with its single default input port, left untouched.
96+
InstallDialogMock();
97+
var nodeView = CreatePythonNodeView(out var pythonNode);
98+
Assert.AreEqual(1, pythonNode.InPorts.Count);
99+
100+
// Act: click the '-' button.
101+
ClickButton(RemovePortButton(nodeView));
102+
103+
// Assert: nothing was customized, so the user is not prompted and the port just goes.
104+
AssertWarningShown(Times.Never());
105+
Assert.AreEqual(0, pythonNode.InPorts.Count);
106+
}
107+
108+
[Test]
109+
public void WhenRemovingRenamedPythonPortThenWarningIsShown()
110+
{
111+
// Arrange: a Python node whose last input port has been renamed by the user.
112+
InstallDialogMock();
113+
var nodeView = CreatePythonNodeView(out var pythonNode);
114+
pythonNode.InPorts[0].Name = "myInput";
115+
116+
// Act: click the '-' button.
117+
ClickButton(RemovePortButton(nodeView));
118+
119+
// Assert: the user is warned before the rename is discarded.
120+
AssertWarningShown(Times.Once());
121+
}
122+
123+
[Test]
124+
public void WhenRemovePortWarningIsCancelledThenPortIsKept()
125+
{
126+
// Arrange: a renamed port, so clicking '-' raises the warning. The mocked dialog
127+
// answers Cancel, standing in for the user declining.
128+
InstallDialogMock(MessageBoxResult.Cancel);
129+
var nodeView = CreatePythonNodeView(out var pythonNode);
130+
pythonNode.InPorts[0].Name = "myInput";
131+
132+
// Act: click the '-' button and decline the warning.
133+
ClickButton(RemovePortButton(nodeView));
134+
135+
// Assert: declining aborts the removal outright - the port and its custom name survive.
136+
// Without this, nothing verifies that Cancel is honoured rather than ignored.
137+
AssertWarningShown(Times.Once());
138+
Assert.AreEqual(1, pythonNode.InPorts.Count);
139+
Assert.AreEqual("myInput", pythonNode.InPorts[0].Name);
140+
}
141+
142+
[Test]
143+
public void WhenRemovingUnmodifiedLastPortWhileEarlierPortIsRenamedThenNoWarningIsShown()
144+
{
145+
// Arrange: two input ports where only the FIRST is renamed. The '-' button removes the
146+
// LAST port, which is untouched, so no customization is actually at risk.
147+
InstallDialogMock();
148+
var nodeView = CreatePythonNodeView(out var pythonNode);
149+
pythonNode.HandleModelEvent("AddInPort", 0, null);
150+
DispatcherUtil.DoEventsLoop();
151+
Assert.AreEqual(2, pythonNode.InPorts.Count);
152+
pythonNode.InPorts[0].Name = "myInput";
153+
154+
// Act: click the '-' button.
155+
ClickButton(RemovePortButton(nodeView));
156+
157+
// Assert: the gate must inspect the port being removed rather than a fixed index,
158+
// so an unrelated rename on an earlier port must not raise the warning.
159+
AssertWarningShown(Times.Never());
160+
Assert.AreEqual(1, pythonNode.InPorts.Count);
161+
}
162+
163+
[Test]
164+
public void WhenRemovingRenamedLastPortWhileEarlierPortIsDefaultThenWarningIsShown()
165+
{
166+
// Arrange: two input ports where only the LAST one - the one that will be removed -
167+
// is renamed. This is the mirror of the test above.
168+
InstallDialogMock();
169+
var nodeView = CreatePythonNodeView(out var pythonNode);
170+
pythonNode.HandleModelEvent("AddInPort", 0, null);
171+
DispatcherUtil.DoEventsLoop();
172+
pythonNode.InPorts[1].Name = "myInput";
173+
174+
// Act: click the '-' button.
175+
ClickButton(RemovePortButton(nodeView));
176+
177+
// Assert: the user is warned before the rename on the last port is discarded.
178+
AssertWarningShown(Times.Once());
179+
}
180+
181+
[Test]
182+
public void WhenRemovingRetooltippedPythonPortThenWarningIsShown()
183+
{
184+
// Arrange: a customized description alone must also trigger the warning, so that
185+
// the gate cannot be narrowed to only check the port name.
186+
InstallDialogMock();
187+
var nodeView = CreatePythonNodeView(out var pythonNode);
188+
pythonNode.InPorts[0].ToolTip = "my description";
189+
190+
// Act: click the '-' button.
191+
ClickButton(RemovePortButton(nodeView));
192+
193+
// Assert: the user is warned before the description is discarded.
194+
AssertWarningShown(Times.Once());
195+
}
196+
}
197+
}

0 commit comments

Comments
 (0)