Files
Artemis/src/Artemis.Core.Tests/VisualScripting/PinTests.cs
T
RobertBeekman 5ddd50b4a8 Core - Fix node script and pin bugs found by the tests (#911)
* Core - Keep nodes of missing plugins and the connection an input reads from in node scripts

- Saving a script keeps nodes whose plugin is missing, and their connections, so they come back once the plugin is available again
- Loading keeps the first connection of an input, the one it reads from
- Disabling a node provider only removes the nodes it created, not those of another provider registering the same type
- RemoveNode only raises NodeRemoved for nodes that were in the script

* Core - Fix pin connections, null values and failing nodes

- Connecting pins that are already connected no longer adds a second connection
- Untyped pins turn null into the default value for value types, as intended
- A new InputPin<T> reads its connection right away instead of after its first reset
- Matching a specific enum to a pin of any enum now depends on forgivingEnumMatching
- A node that fails to evaluate sets its outputs to their default values, so it is evaluated once per run and old values don't flow on

* Core - Round Numeric to long and clamp it to the range of an sbyte
2026-10-24 16:22:44 +02:00

535 lines
17 KiB
C#

using System.ComponentModel;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Core.Tests.Infrastructure.VisualScripting;
namespace Artemis.Core.Tests.VisualScripting;
public class PinTests
{
private readonly TestNode _source = new("Source");
private readonly TestNode _target = new("Target");
#region Connecting
[Fact]
public void ConnectTo_ConnectsBothPins()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
input.ConnectTo(output);
input.ConnectedTo.ShouldBe([output]);
output.ConnectedTo.ShouldBe([input]);
}
[Fact]
public void ConnectTo_RaisesPinConnectedOnBothPins()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
List<IPin> inputConnected = [];
List<IPin> outputConnected = [];
input.PinConnected += (_, e) => inputConnected.Add(e.Value);
output.PinConnected += (_, e) => outputConnected.Add(e.Value);
input.ConnectTo(output);
inputConnected.ShouldBe([output]);
outputConnected.ShouldBe([input]);
}
[Fact]
public void ConnectTo_RaisesPropertyChangedForConnectedTo()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
List<string?> changed = [];
input.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
input.ConnectTo(output);
changed.ShouldContain(nameof(IPin.ConnectedTo));
}
[Fact]
public void Output_CanFeedSeveralInputs()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> first = _target.CreateInputPin<Numeric>();
InputPin<Numeric> second = _target.CreateInputPin<Numeric>();
_source.OnEvaluate = _ => output.Value = new Numeric(3);
first.ConnectTo(output);
second.ConnectTo(output);
output.ConnectedTo.ShouldBe([first, second]);
_target.Reset();
first.Value.ShouldBe(new Numeric(3));
second.Value.ShouldBe(new Numeric(3));
}
[Fact]
public void ConnectTo_DoesNotCheckTypesOrDirections()
{
// The editor decides what may be connected, in code anything goes
InputPin<string> text = _source.CreateInputPin<string>();
InputPin<Numeric> number = _target.CreateInputPin<Numeric>();
text.ConnectTo(number);
text.ConnectedTo.ShouldBe([number]);
number.ConnectedTo.ShouldBe([text]);
}
[Fact]
public void ConnectTo_DoesNotLimitInputsToOneConnection()
{
// The editor disconnects an input before connecting it again (see ConnectPins in Artemis.UI.Shared). An input
// with several connections reads from the first.
OutputPin<Numeric> first = _source.CreateOutputPin<Numeric>();
OutputPin<Numeric> second = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
_source.OnEvaluate = _ =>
{
first.Value = new Numeric(1);
second.Value = new Numeric(2);
};
input.ConnectTo(first);
input.ConnectTo(second);
input.ConnectedTo.ShouldBe([first, second]);
_target.Reset();
input.Value.ShouldBe(new Numeric(1));
}
[Fact]
public void ConnectTo_TheSamePinTwice_ConnectsThemOnce()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
input.ConnectTo(output);
input.ConnectTo(output);
input.ConnectedTo.ShouldBe([output]);
output.ConnectedTo.ShouldBe([input]);
input.DisconnectFrom(output);
input.ConnectedTo.ShouldBeEmpty();
output.ConnectedTo.ShouldBeEmpty();
}
[Fact]
public void DisconnectFrom_DisconnectsBothPins_AndRaisesPinDisconnectedOnBoth()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
input.ConnectTo(output);
List<IPin> inputDisconnected = [];
List<IPin> outputDisconnected = [];
input.PinDisconnected += (_, e) => inputDisconnected.Add(e.Value);
output.PinDisconnected += (_, e) => outputDisconnected.Add(e.Value);
input.DisconnectFrom(output);
input.ConnectedTo.ShouldBeEmpty();
output.ConnectedTo.ShouldBeEmpty();
inputDisconnected.ShouldBe([output]);
outputDisconnected.ShouldBe([input]);
}
[Fact]
public void DisconnectAll_DisconnectsEveryConnection()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> first = _target.CreateInputPin<Numeric>();
InputPin<Numeric> second = _target.CreateInputPin<Numeric>();
output.ConnectTo(first);
output.ConnectTo(second);
List<IPin> outputDisconnected = [];
int firstDisconnected = 0;
output.PinDisconnected += (_, e) => outputDisconnected.Add(e.Value);
first.PinDisconnected += (_, _) => firstDisconnected++;
output.DisconnectAll();
output.ConnectedTo.ShouldBeEmpty();
first.ConnectedTo.ShouldBeEmpty();
second.ConnectedTo.ShouldBeEmpty();
outputDisconnected.ShouldBe([first, second]);
firstDisconnected.ShouldBe(1);
}
[Fact]
public void DisconnectAll_OnAPinWithoutConnections_RaisesNothing()
{
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
int raised = 0;
input.PinDisconnected += (_, _) => raised++;
input.PropertyChanged += (_, _) => raised++;
input.DisconnectAll();
raised.ShouldBe(0);
}
#endregion
#region Type compatibility
[Theory]
[InlineData(typeof(Numeric), typeof(Numeric), true)]
[InlineData(typeof(Numeric), typeof(float), false)]
[InlineData(typeof(Numeric), typeof(int), false)]
[InlineData(typeof(object), typeof(string), true)]
[InlineData(typeof(object), typeof(Numeric), true)]
[InlineData(typeof(string), typeof(object), false)]
[InlineData(typeof(Enum), typeof(TestEnum), true)]
[InlineData(typeof(TestEnum), typeof(Enum), false)]
[InlineData(typeof(TestEnum), typeof(DayOfWeek), false)]
public void InputPin_AcceptsValuesThatFitItsType(Type pinType, Type valueType, bool compatible)
{
InputPin pin = _target.CreateInputPin(pinType);
pin.IsTypeCompatible(valueType).ShouldBe(compatible);
}
[Theory]
[InlineData(typeof(Numeric), typeof(Numeric), true)]
[InlineData(typeof(Numeric), typeof(float), false)]
[InlineData(typeof(string), typeof(object), true)]
[InlineData(typeof(object), typeof(string), false)]
[InlineData(typeof(TestEnum), typeof(Enum), true)]
[InlineData(typeof(Enum), typeof(TestEnum), false)]
[InlineData(typeof(TestEnum), typeof(DayOfWeek), false)]
public void OutputPin_FitsInputsThatTakeItsType(Type pinType, Type inputType, bool compatible)
{
OutputPin pin = _source.CreateOutputPin(pinType);
pin.IsTypeCompatible(inputType).ShouldBe(compatible);
}
[Fact]
public void IsTypeCompatible_OnlyMatchesASpecificEnumToAnyEnumWhenForgiving()
{
InputPin input = _target.CreateInputPin(typeof(Enum));
OutputPin output = _source.CreateOutputPin(typeof(TestEnum));
input.IsTypeCompatible(typeof(TestEnum), false).ShouldBeFalse();
output.IsTypeCompatible(typeof(Enum), false).ShouldBeFalse();
input.IsTypeCompatible(typeof(TestEnum), true).ShouldBeTrue();
output.IsTypeCompatible(typeof(Enum), true).ShouldBeTrue();
}
[Fact]
public void Pins_KnowWhetherTheyAreNumeric()
{
_target.CreateInputPin<Numeric>().IsNumeric.ShouldBeTrue();
_target.CreateInputPin(typeof(Numeric)).IsNumeric.ShouldBeTrue();
_source.CreateOutputPin<Numeric>().IsNumeric.ShouldBeTrue();
_source.CreateOutputPin(typeof(Numeric)).IsNumeric.ShouldBeTrue();
_target.CreateInputPin<float>().IsNumeric.ShouldBeFalse();
_source.CreateOutputPin(typeof(int)).IsNumeric.ShouldBeFalse();
}
#endregion
#region Values
[Fact]
public void InputPin_WithoutConnection_HasTheDefaultValue()
{
_target.CreateInputPin<Numeric>().Value.ShouldBe(new Numeric(0));
_target.CreateInputPin<string>().Value.ShouldBeNull();
_target.CreateInputPin(typeof(Numeric)).Value.ShouldBe(new Numeric(0));
_target.CreateInputPin(typeof(bool)).Value.ShouldBe(false);
_target.CreateInputPin(typeof(string)).Value.ShouldBeNull();
}
[Fact]
public void ReadingAnInput_EvaluatesTheNodeOfTheConnectedOutput()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
input.ConnectTo(output);
_source.OnEvaluate = _ => output.Value = new Numeric(5);
_target.Reset();
input.Value.ShouldBe(new Numeric(5));
_source.Evaluations.ShouldBe(1);
}
[Fact]
public void Values_AreKeptUntilThePinsAreReset()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
input.ConnectTo(output);
float value = 1;
_source.OnEvaluate = _ => output.Value = new Numeric(value);
_target.Reset();
input.Value.ShouldBe(new Numeric(1));
value = 2;
input.Value.ShouldBe(new Numeric(1));
_source.Evaluations.ShouldBe(1);
_source.Reset();
_target.Reset();
input.Value.ShouldBe(new Numeric(2));
_source.Evaluations.ShouldBe(2);
}
[Fact]
public void Reset_MarksThePinAsNotEvaluated()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
output.Value = new Numeric(1);
output.IsEvaluated.ShouldBeTrue();
output.Reset();
output.IsEvaluated.ShouldBeFalse();
}
[Fact]
public void SettingAValue_RaisesPropertyChangedForPinValue()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
List<string?> changed = [];
output.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
output.Value = new Numeric(1);
changed.ShouldContain(nameof(IPin.PinValue));
output.PinValue.ShouldBe(new Numeric(1));
}
[Fact]
public void TypedInput_DoesNotConvertOtherNumberTypes()
{
// Numbers only flow between pins as Numeric, a Numeric input connected to a float output reads the default value
OutputPin<float> output = _source.CreateOutputPin<float>();
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
input.ConnectTo(output);
output.Value = 5f;
input.Value.ShouldBe(new Numeric(0));
}
[Fact]
public void TypedInput_ReadsAMatchingValueFromAnObjectOutput()
{
OutputPin output = _source.CreateOutputPin(typeof(object));
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
input.ConnectTo(output);
output.Value = new Numeric(5);
_target.Reset();
input.Value.ShouldBe(new Numeric(5));
_target.Reset();
output.Value = 5;
input.Value.ShouldBe(new Numeric(0));
}
[Fact]
public void ObjectInput_ReadsAnyValue()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin input = _target.CreateInputPin(typeof(object));
input.ConnectTo(output);
output.Value = new Numeric(5);
input.Value.ShouldBe(new Numeric(5));
}
[Fact]
public void UntypedInput_ReadingAValueOfTheWrongType_Throws()
{
OutputPin<float> output = _source.CreateOutputPin<float>();
InputPin input = _target.CreateInputPin(typeof(Numeric));
input.ConnectTo(output);
output.Value = 5f;
Should.Throw<ArgumentException>(() => input.Value);
}
[Fact]
public void UntypedInput_ForAValueType_TurnsNullIntoTheDefaultValue()
{
OutputPin output = _source.CreateOutputPin(typeof(object));
InputPin input = _target.CreateInputPin(typeof(int));
input.ConnectTo(output);
output.Value = null;
input.Value.ShouldBe(0);
}
[Fact]
public void UntypedInput_ForAReferenceType_PassesNullOn()
{
OutputPin output = _source.CreateOutputPin(typeof(string));
InputPin input = _target.CreateInputPin(typeof(string));
input.ConnectTo(output);
output.Value = null;
input.Value.ShouldBeNull();
}
[Fact]
public void UntypedOutput_RejectsValuesOfTheWrongType()
{
OutputPin output = _source.CreateOutputPin(typeof(Numeric));
Should.Throw<ArgumentException>(() => output.Value = 5f);
}
[Fact]
public void UntypedOutput_ForAValueType_TurnsNullIntoTheDefaultValue()
{
OutputPin output = _source.CreateOutputPin(typeof(Numeric));
output.Value = new Numeric(3);
output.Value = null;
output.Value.ShouldBe(new Numeric(0));
}
[Fact]
public void NewTypedInput_ReadsItsConnection_LikeAnUntypedInput()
{
OutputPin<Numeric> output = _source.CreateOutputPin<Numeric>();
InputPin<Numeric> typed = _target.CreateInputPin<Numeric>();
InputPin untyped = _target.CreateInputPin(typeof(Numeric));
typed.ConnectTo(output);
untyped.ConnectTo(output);
output.Value = new Numeric(5);
typed.IsEvaluated.ShouldBeFalse();
typed.Value.ShouldBe(new Numeric(5));
untyped.Value.ShouldBe(new Numeric(5));
}
[Fact]
public void UntypedOutput_StartsWithTheDefaultValueOfItsType()
{
_source.CreateOutputPin(typeof(Numeric)).Value.ShouldBe(new Numeric(0));
_source.CreateOutputPin(typeof(string)).Value.ShouldBeNull();
}
#endregion
#region Changing types
[Fact]
public void InputPin_ChangeType_KeepsCompatibleConnectionsOnly()
{
OutputPin<Numeric> number = _source.CreateOutputPin<Numeric>();
OutputPin<string> text = _source.CreateOutputPin<string>();
InputPin first = _target.CreateInputPin(typeof(object));
InputPin second = _target.CreateInputPin(typeof(object));
first.ConnectTo(number);
second.ConnectTo(text);
first.ChangeType(typeof(Numeric));
second.ChangeType(typeof(Numeric));
first.Type.ShouldBe(typeof(Numeric));
first.IsNumeric.ShouldBeTrue();
first.ConnectedTo.ShouldBe([number]);
second.ConnectedTo.ShouldBeEmpty();
text.ConnectedTo.ShouldBeEmpty();
}
[Fact]
public void InputPin_ChangeType_RaisesPropertyChangedForType()
{
InputPin input = _target.CreateInputPin(typeof(object));
List<string?> changed = [];
input.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
input.ChangeType(typeof(Numeric));
changed.ShouldContain(nameof(IPin.Type));
changed.ShouldContain(nameof(IPin.IsNumeric));
}
[Fact]
public void ChangeType_ToTheSameType_LeavesConnectionsAlone()
{
OutputPin output = _source.CreateOutputPin(typeof(Numeric));
InputPin<Numeric> input = _target.CreateInputPin<Numeric>();
input.ConnectTo(output);
int disconnects = 0;
output.PinDisconnected += (_, _) => disconnects++;
output.ChangeType(typeof(Numeric));
disconnects.ShouldBe(0);
output.ConnectedTo.ShouldBe([input]);
}
[Fact]
public void OutputPin_ChangeType_KeepsCompatibleConnectionsOnly_AndResetsItsValue()
{
OutputPin output = _source.CreateOutputPin(typeof(object));
InputPin<object> objectInput = _target.CreateInputPin<object>();
InputPin<Numeric> numericInput = _target.CreateInputPin<Numeric>();
output.ConnectTo(objectInput);
output.ConnectTo(numericInput);
output.Value = "Hello";
output.ChangeType(typeof(string));
output.Type.ShouldBe(typeof(string));
output.ConnectedTo.ShouldBe([objectInput]);
numericInput.ConnectedTo.ShouldBeEmpty();
output.Value.ShouldBeNull();
}
[Fact]
public void OutputPin_ChangeType_ToAValueType_UsesItsDefaultValue()
{
OutputPin output = _source.CreateOutputPin(typeof(object));
output.ChangeType(typeof(Numeric));
output.Value.ShouldBe(new Numeric(0));
output.IsNumeric.ShouldBeTrue();
}
#endregion
[Fact]
public void Pin_KnowsItsNodeNameAndDirection()
{
InputPin<Numeric> input = _target.CreateInputPin<Numeric>("Amount");
OutputPin output = _source.CreateOutputPin(typeof(string), "Text");
input.Node.ShouldBe(_target);
input.Name.ShouldBe("Amount");
input.Direction.ShouldBe(PinDirection.Input);
input.Type.ShouldBe(typeof(Numeric));
output.Node.ShouldBe(_source);
output.Name.ShouldBe("Text");
output.Direction.ShouldBe(PinDirection.Output);
output.Type.ShouldBe(typeof(string));
}
[Fact]
public void Pin_RaisesPropertyChangedWhenRenamed()
{
InputPin<Numeric> input = _target.CreateInputPin<Numeric>("Amount");
PropertyChangedEventArgs? args = null;
input.PropertyChanged += (_, e) => args = e;
input.Name = "Total";
args.ShouldNotBeNull().PropertyName.ShouldBe(nameof(IPin.Name));
}
}