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 {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(() => new DataModelPath((Artemis.Core.Modules.DataModel) null!)); Should.Throw(() => new DataModelPath(null!, "Integer")); Should.Throw(() => new DataModelPath(DataModel, null!)); Should.Throw(() => 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 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 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(() => path.GetValue()); Should.Throw(() => path.GetPropertyType()); Should.Throw(() => path.GetPropertyInfo()); Should.Throw(() => 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()); } }