Files
Artemis/src/Artemis.Core.Tests/DataModels/DataModelPathTests.cs
T

630 lines
19 KiB
C#

using Artemis.Core.Modules;
using Artemis.Core.Tests.Infrastructure.Plugins;
using Artemis.Core.Tests.Infrastructure.Plugins.Modules;
using Artemis.Storage.Entities.Profile;
using SkiaSharp;
namespace Artemis.Core.Tests.DataModels;
public class DataModelPathTests : IDisposable
{
private readonly TestPlugin _plugin = new();
private readonly TestModule _module;
public DataModelPathTests()
{
_module = _plugin.AddFeature(new TestModule());
}
private TestDataModel DataModel => _module.DataModel;
[Fact]
public void Path_ReadsTheCurrentValue()
{
_module.DataModel.Integer = 5;
using DataModelPath path = new(_module.DataModel, "Integer");
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(5);
_module.DataModel.Integer = 6;
path.GetValue().ShouldBe(6);
}
[Fact]
public void Path_IsRestoredFromItsEntityThroughTheDataModelStore()
{
_module.DataModel.Child.Integer = 7;
using DataModelPath path = new(_module.DataModel, "Child.Integer");
path.Save();
using DataModelPath restored = new(path.Entity);
restored.IsValid.ShouldBeTrue();
restored.Target.ShouldBe(_module.DataModel);
restored.GetValue().ShouldBe(7);
}
public void Dispose()
{
_plugin.Dispose();
}
#region Reading values
[Fact]
public void Path_ReadsEveryKindOfProperty()
{
DataModel.Float = 1.5f;
DataModel.Double = 2.5;
DataModel.Boolean = true;
DataModel.Text = "Hello";
DataModel.Color = SKColors.Lime;
DataModel.Enum = TestEnum.Third;
ValueAt("Float").ShouldBe(1.5f);
ValueAt("Double").ShouldBe(2.5);
ValueAt("Boolean").ShouldBe(true);
ValueAt("Text").ShouldBe("Hello");
ValueAt("Color").ShouldBe(SKColors.Lime);
ValueAt("Enum").ShouldBe(TestEnum.Third);
}
[Fact]
public void EmptyPath_PointsToTheDataModelItself()
{
using DataModelPath path = new(DataModel);
path.IsValid.ShouldBeTrue();
path.Path.ShouldBe("");
path.ToString().ShouldBe("this");
path.GetValue().ShouldBeSameAs(DataModel);
path.GetPropertyType().ShouldBe(typeof(TestDataModel));
path.GetPropertyInfo().ShouldBeNull();
path.Segments.ShouldHaveSingleItem().IsStartSegment.ShouldBeTrue();
}
[Fact]
public void Path_ReadsANestedDataModel()
{
DataModel.Child.Text = "Nested";
ValueAt("Child.Text").ShouldBe("Nested");
ValueAt("Child").ShouldBeSameAs(DataModel.Child);
}
[Fact]
public void Path_ReadsAPlainObject()
{
DataModel.Item = new TestItem {Integer = 3};
ValueAt("Item.Integer").ShouldBe(3);
}
[Fact]
public void Path_ThroughAnEmptyPlainObject_ReadsTheDefaultValue()
{
DataModel.Item = null!;
using DataModelPath integerPath = new(DataModel, "Item.Integer");
using DataModelPath textPath = new(DataModel, "Item.Text");
integerPath.IsValid.ShouldBeTrue();
integerPath.GetValue().ShouldBe(0);
textPath.IsValid.ShouldBeTrue();
textPath.GetValue().ShouldBeNull();
}
[Fact]
public void Path_ReadsAList()
{
DataModel.Integers = [1, 2, 3];
ValueAt("Integers").ShouldBe(new List<int> {1, 2, 3});
ValueAt("Integers.Count").ShouldBe(3);
}
[Fact]
public void Path_CannotPointToAListItem()
{
DataModel.Integers = [1, 2, 3];
using DataModelPath path = new(DataModel, "Integers.0");
path.IsValid.ShouldBeFalse();
path.GetValue().ShouldBeNull();
}
[Fact]
public void Path_CanPointToAnIgnoredProperty()
{
// The ignore attribute only hides the property from the editor, paths still work
DataModel.Ignored = 4;
ValueAt("Ignored").ShouldBe(4);
}
[Fact]
public void Path_ReadsTheArgumentsOfAnEvent()
{
DataModel.EventWithArgs.Trigger(new TestEventArgs {Value = 9});
ValueAt("EventWithArgs.LastEventArguments.Value").ShouldBe(9);
ValueAt("EventWithArgs.TriggerCount").ShouldBe(1);
}
[Fact]
public void Path_IsCaseSensitive()
{
using DataModelPath path = new(DataModel, "integer");
path.IsValid.ShouldBeFalse();
}
#endregion
#region Invalid paths
[Fact]
public void PathToAMissingProperty_IsInvalid()
{
using DataModelPath path = new(DataModel, "DoesNotExist");
path.IsValid.ShouldBeFalse();
path.GetValue().ShouldBeNull();
path.GetPropertyType().ShouldBeNull();
path.GetPropertyInfo().ShouldBeNull();
}
[Fact]
public void PathThroughAMissingProperty_StopsAtTheFirstInvalidSegment()
{
using DataModelPath path = new(DataModel, "DoesNotExist.Integer");
path.Segments.Select(s => s.Type).ShouldBe([DataModelPathSegmentType.Static, DataModelPathSegmentType.Invalid, DataModelPathSegmentType.Invalid]);
path.Segments.Last().GetValue().ShouldBeNull();
}
[Fact]
public void Path_RequiresATarget()
{
Should.Throw<ArgumentNullException>(() => new DataModelPath((Artemis.Core.Modules.DataModel) null!));
Should.Throw<ArgumentNullException>(() => new DataModelPath(null!, "Integer"));
Should.Throw<ArgumentNullException>(() => new DataModelPath(DataModel, null!));
Should.Throw<ArgumentNullException>(() => new DataModelPath((DataModelPath) null!));
}
#endregion
#region Segments
[Fact]
public void Segments_DescribeEachStepOfThePath()
{
DataModel.Child.Integer = 8;
using DataModelPath path = new(DataModel, "Child.Integer");
DataModelPathSegment[] segments = path.Segments.ToArray();
segments.Length.ShouldBe(3);
segments[0].IsStartSegment.ShouldBeTrue();
segments[0].Identifier.ShouldBe("target");
segments[0].Previous.ShouldBeNull();
segments[0].Next.ShouldBeSameAs(segments[1]);
segments[0].GetValue().ShouldBeSameAs(DataModel);
segments[1].Identifier.ShouldBe("Child");
segments[1].Path.ShouldBe("Child");
segments[1].GetValue().ShouldBeSameAs(DataModel.Child);
segments[2].Identifier.ShouldBe("Integer");
segments[2].Path.ShouldBe("Child.Integer");
segments[2].IsStartSegment.ShouldBeFalse();
segments[2].Previous.ShouldBeSameAs(segments[1]);
segments[2].Next.ShouldBeNull();
segments[2].Type.ShouldBe(DataModelPathSegmentType.Static);
segments[2].GetValue().ShouldBe(8);
segments[2].ToString().ShouldBe("[Static] Child.Integer");
}
[Fact]
public void StaticSegment_KnowsItsProperty()
{
using DataModelPath path = new(DataModel, "Child.Integer");
path.GetPropertyInfo().ShouldBe(typeof(TestChildDataModel).GetProperty(nameof(TestChildDataModel.Integer)));
path.GetPropertyType().ShouldBe(typeof(int));
}
[Fact]
public void StaticSegment_WithoutADescription_IsNamedAfterItsProperty()
{
using DataModelPath path = new(DataModel, "Child.Integer");
DataModelPropertyAttribute description = path.GetPropertyDescription().ShouldNotBeNull();
description.Name.ShouldBe("Integer");
description.ResetsDepth.ShouldBeFalse();
}
[Fact]
public void StaticSegment_UsesTheDescriptionOfItsProperty()
{
using DataModelPath path = new(DataModel, "Event.TriggerCount");
path.GetPropertyDescription().ShouldNotBeNull().Description.ShouldBe("The total amount of times this event has triggered since the module was activated");
}
[Fact]
public void StartSegment_UsesTheDescriptionOfTheDataModel()
{
using DataModelPath path = new(DataModel);
path.GetPropertyDescription().ShouldBeSameAs(DataModel.DataModelDescription);
}
#endregion
#region Dynamic children
[Fact]
public void Path_ReadsADynamicChild()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5, "Extra points");
using DataModelPath path = new(DataModel, "Extra");
path.IsValid.ShouldBeTrue();
path.Segments.Last().Type.ShouldBe(DataModelPathSegmentType.Dynamic);
path.GetValue().ShouldBe(5);
child.Value = 6;
path.GetValue().ShouldBe(6);
}
[Fact]
public void DynamicSegment_TakesItsTypeFromTheValueAndItsDescriptionFromTheChild()
{
DynamicChild<int> child = DataModel.AddDynamicChild("Extra", 5, "Extra points");
using DataModelPath path = new(DataModel, "Extra");
path.GetPropertyInfo().ShouldBeNull();
path.GetPropertyType().ShouldBe(typeof(int));
path.GetPropertyDescription().ShouldBeSameAs(child.Attribute);
}
[Fact]
public void Path_ReadsThroughADynamicDataModel()
{
TestChildDataModel childDataModel = new() {Integer = 4};
DataModel.AddDynamicChild("Extra", childDataModel, "Extra data");
using DataModelPath path = new(DataModel, "Extra.Integer");
path.IsValid.ShouldBeTrue();
path.Segments.Select(s => s.Type).ShouldBe([DataModelPathSegmentType.Static, DataModelPathSegmentType.Dynamic, DataModelPathSegmentType.Static]);
path.GetValue().ShouldBe(4);
}
[Fact]
public void Path_ReadsADynamicChildOfANestedDataModel()
{
DataModel.Child.AddDynamicChild("Extra", "Deep");
ValueAt("Child.Extra").ShouldBe("Deep");
}
[Fact]
public void PathToAMissingDynamicChild_BecomesValidOnceTheChildIsAdded()
{
using DataModelPath path = new(DataModel, "Extra");
int validated = 0;
path.PathValidated += (_, _) => validated++;
path.IsValid.ShouldBeFalse();
DataModel.AddDynamicChild("Extra", 5);
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(5);
validated.ShouldBe(1);
}
[Fact]
public void AddingAnotherDynamicChild_DoesNotAffectThePath()
{
using DataModelPath path = new(DataModel, "Extra");
int validated = 0;
path.PathValidated += (_, _) => validated++;
DataModel.AddDynamicChild("Other", 5);
path.IsValid.ShouldBeFalse();
validated.ShouldBe(0);
}
[Fact]
public void RemovingTheDynamicChild_LeavesThePathValid()
{
DataModel.AddDynamicChild("Extra", 5);
using DataModelPath path = new(DataModel, "Extra");
int invalidated = 0;
path.PathInvalidated += (_, _) => invalidated++;
DataModel.RemoveDynamicChildByKey("Extra");
// Pinned as-is: the segment only invalidates the path when the value of the removed child is the data model the
// segment looks in, rather than when the child it points to is removed. So the path stays valid and reads the
// default value instead of becoming invalid.
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(0);
invalidated.ShouldBe(0);
}
[Fact]
public void RemovingADynamicDataModel_LeavesThePathThroughItValid()
{
DataModel.AddDynamicChild("Extra", new TestChildDataModel {Integer = 4}, "Extra data");
using DataModelPath path = new(DataModel, "Extra.Integer");
DataModel.RemoveDynamicChildByKey("Extra");
// Pinned as-is, see RemovingTheDynamicChild_LeavesThePathValid
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(0);
}
[Fact]
public void ReplacingADynamicChild_MakesThePathReadTheNewChild()
{
DataModel.AddDynamicChild("Extra", 5);
using DataModelPath path = new(DataModel, "Extra");
DataModel.RemoveDynamicChildByKey("Extra");
DataModel.AddDynamicChild("Extra", 7);
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(7);
}
#endregion
#region The data model store
[Fact]
public void SavedPath_BecomesInvalidWhenItsDataModelIsRemovedFromTheStore()
{
using DataModelPath path = new(DataModel, "Integer");
path.Save();
int invalidated = 0;
path.PathInvalidated += (_, _) => invalidated++;
RemoveFromStore();
path.IsValid.ShouldBeFalse();
path.Target.ShouldBeNull();
path.GetValue().ShouldBeNull();
invalidated.ShouldBe(1);
DataModel.ActivePaths.ShouldNotContain(path);
}
[Fact]
public void SavedPath_BecomesInvalidWhenItsModuleIsDisabled()
{
using DataModelPath path = new(DataModel, "Integer");
path.Save();
_plugin.Dispose();
path.IsValid.ShouldBeFalse();
path.Target.ShouldBeNull();
}
[Fact]
public void SavedPath_BecomesValidAgainWhenItsDataModelReturns()
{
DataModel.Integer = 3;
using DataModelPath path = new(DataModel, "Integer");
path.Save();
int validated = 0;
path.PathValidated += (_, _) => validated++;
RemoveFromStore();
DataModelStore.Add(DataModel);
path.IsValid.ShouldBeTrue();
path.Target.ShouldBeSameAs(DataModel);
path.GetValue().ShouldBe(3);
validated.ShouldBe(1);
}
[Fact]
public void SavedPath_MovesToANewInstanceOfItsDataModel()
{
using DataModelPath path = new(DataModel, "Integer");
path.Save();
// Like a module being enabled again, which creates a new data model
RemoveFromStore();
TestDataModel newDataModel = new() {Module = _module, Integer = 11};
DataModelStore.Add(newDataModel);
path.Target.ShouldBeSameAs(newDataModel);
path.GetValue().ShouldBe(11);
}
[Fact]
public void NewPath_DoesNotFollowTheStoreUntilItIsSaved()
{
using DataModelPath path = new(DataModel, "Integer");
// Pinned as-is: the constructors call Save before the path is set up, while it is still invalid, so nothing is
// saved and the entity has no data model ID. The path only reacts to its data model leaving and returning to the
// store once Save is called. Looks like Save should be called after Initialize.
path.Entity.DataModelId.ShouldBeNull();
path.Entity.Path.ShouldBe("");
RemoveFromStore();
path.IsValid.ShouldBeTrue();
path.Target.ShouldBeSameAs(DataModel);
}
[Fact]
public void PathFromAnEntity_WaitsForItsDataModelToBeAdded()
{
RemoveFromStore();
DataModel.Integer = 12;
DataModelPathEntity entity = new() {Path = "Integer", DataModelId = _module.Id};
using DataModelPath path = new(entity);
path.IsValid.ShouldBeFalse();
path.Target.ShouldBeNull();
path.GetValue().ShouldBeNull();
path.GetFeatureDependencies().ShouldBeEmpty();
DataModelStore.Add(DataModel);
path.IsValid.ShouldBeTrue();
path.GetValue().ShouldBe(12);
}
[Fact]
public void PathFromAnEntity_IgnoresOtherDataModels()
{
DataModelPathEntity entity = new() {Path = "Integer", DataModelId = "Some.Other.Module-12345678"};
using DataModelPath path = new(entity);
RemoveFromStore();
DataModelStore.Add(DataModel);
path.IsValid.ShouldBeFalse();
path.Target.ShouldBeNull();
}
#endregion
#region Saving and loading
[Fact]
public void Saving_StoresThePathItsDataModelAndItsType()
{
using DataModelPath path = new(DataModel, "Child.Integer");
path.Save();
path.Entity.Path.ShouldBe("Child.Integer");
path.Entity.DataModelId.ShouldBe(_module.Id);
path.Entity.Type.ShouldBe(typeof(int).FullName);
path.DataModelId.ShouldBe(_module.Id);
}
[Fact]
public void SavingAnInvalidPath_KeepsWhatWasSavedBefore()
{
DataModelPathEntity entity = new() {Path = "DoesNotExist", DataModelId = _module.Id, Type = "System.Int32"};
using DataModelPath path = new(entity);
path.Save();
path.IsValid.ShouldBeFalse();
entity.Path.ShouldBe("DoesNotExist");
entity.DataModelId.ShouldBe(_module.Id);
entity.Type.ShouldBe("System.Int32");
}
[Fact]
public void InvalidPath_FallsBackToTheSavedType()
{
using DataModelPath path = new(new DataModelPathEntity {Path = "Integer", DataModelId = "Missing-12345678", Type = typeof(int).FullName});
path.IsValid.ShouldBeFalse();
path.GetPropertyType().ShouldBe(typeof(int));
}
[Fact]
public void InvalidPath_CannotFallBackToATypeOutsideTheCoreLibraries()
{
using DataModelPath valid = new(DataModel, "Enum");
valid.Save();
valid.Entity.Type.ShouldBe(typeof(TestEnum).FullName);
using DataModelPath path = new(new DataModelPathEntity {Path = "Enum", DataModelId = "Missing-12345678", Type = valid.Entity.Type});
// Pinned as-is: the type is saved by its full name without the assembly, which Type.GetType can only find in
// Artemis.Core and the .NET libraries, so types from plugins (and SkiaSharp, like SKColor) can't be found again.
path.GetPropertyType().ShouldBeNull();
}
[Fact]
public void CopiedPath_PointsToTheSameProperty()
{
DataModel.Integer = 13;
using DataModelPath original = new(DataModel, "Integer");
using DataModelPath copy = new(original);
copy.Target.ShouldBeSameAs(DataModel);
copy.Path.ShouldBe("Integer");
copy.GetValue().ShouldBe(13);
copy.Entity.ShouldNotBeSameAs(original.Entity);
DataModel.ActivePaths.Count.ShouldBe(2);
}
#endregion
#region Disposing
[Fact]
public void DisposedPath_IsInvalidAndCannotBeRead()
{
DataModelPath path = new(DataModel, "Integer");
int invalidated = 0;
path.PathInvalidated += (_, _) => invalidated++;
path.Dispose();
invalidated.ShouldBe(1);
path.IsValid.ShouldBeFalse();
Should.Throw<ObjectDisposedException>(() => path.GetValue());
Should.Throw<ObjectDisposedException>(() => path.GetPropertyType());
Should.Throw<ObjectDisposedException>(() => path.GetPropertyInfo());
Should.Throw<ObjectDisposedException>(() => path.GetPropertyDescription());
}
[Fact]
public void DisposedPath_NoLongerFollowsTheStore()
{
DataModelPath path = new(DataModel, "Integer");
path.Save();
path.Dispose();
int validated = 0;
path.PathValidated += (_, _) => validated++;
RemoveFromStore();
DataModelStore.Add(DataModel);
validated.ShouldBe(0);
path.IsValid.ShouldBeFalse();
}
#endregion
[Fact]
public void Path_DependsOnTheModuleOfItsDataModel()
{
using DataModelPath path = new(DataModel, "Integer");
path.GetFeatureDependencies().ShouldBe([_module]);
}
private object? ValueAt(string path)
{
using DataModelPath dataModelPath = new(DataModel, path);
dataModelPath.IsValid.ShouldBeTrue();
return dataModelPath.GetValue();
}
private void RemoveFromStore()
{
DataModelStore.Remove(DataModelStore.Get(_module.Id).ShouldNotBeNull());
}
}