#nullable enable using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; using GameCult.Caching.MessagePack; using MessagePack; using NUnit.Framework; namespace GameCult.Caching.Tests { public class CultInspectorModelTests { private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(InspectItem), typeof(InspectOther), typeof(InspectFixed), typeof(InspectLabeled) }); private static CultInspectorModel Model() => CultCacheMessagePack.CreateInspectorModel(Registry); private static CultRecordRef Ref(string key) => new CultRecordRef(new CultRecordKey(key)); [Test] public void MembersFollowTheCatalogWithTheirMetadata() { var members = Model().MembersOf(typeof(InspectItem)); Assert.That(members.Select(member => member.Name), Is.EqualTo(new[] { "Value", "Name", "Secret", "Locked", "Shape", "Links", "Numbers" }), "order first, then slot"); Assert.That(members.Single(member => member.Name == "Name").Metadata.Label, Is.EqualTo("Display Name")); Assert.That(members.Single(member => member.Name == "Name").IsReadOnly, Is.False); Assert.That(members.Single(member => member.Name == "Value").Metadata.Range!.Max, Is.EqualTo(10f)); Assert.That(members.Single(member => member.Name == "Secret").Metadata.Hidden, Is.True); Assert.That(members.Single(member => member.Name == "Locked").IsReadOnly, Is.True); var id = Model().MembersOf(typeof(InspectFixed)).Single(); Assert.That(id.IsAssignable, Is.False, "a get-only member its constructor fills cannot be assigned"); Assert.That(id.IsReadOnly, Is.True); var hidden = Model().MembersOf(typeof(InspectHider)).Single(); Assert.That(hidden.Member.DeclaringType, Is.EqualTo(typeof(InspectHider)), "a `new` member is its most-derived declaration"); Assert.That(hidden.ValueType, Is.EqualTo(typeof(string))); } [Test] public void AssetGuidMemberExposesItsAttributeAndDeclaredType() { var member = Model().MembersOf(typeof(InspectAsset)).Single(); Assert.That(member.Metadata.AssetGuid, Is.Not.Null); Assert.That(member.Metadata.AssetGuid!.AssetType, Is.EqualTo(typeof(Uri))); } [Test] public void ClaimsResolveAttributeThenTypeThenBuiltIn() { var claims = new CultInspectorDrawerClaims( new[] { typeof(MarkDrawer), typeof(IntDrawer), typeof(ListDrawer), typeof(StringDrawerA), typeof(StringDrawerB), typeof(NotADrawer) }, typeof(IFakeDrawer)); FieldInfo Field(string name) => typeof(ClaimHost).GetField(name)!; Assert.That(claims.Resolve(typeof(int), Field(nameof(ClaimHost.Marked))).Drawer, Is.EqualTo(typeof(MarkDrawer))); Assert.That(claims.Resolve(typeof(int), Field(nameof(ClaimHost.Plain))).Drawer, Is.EqualTo(typeof(IntDrawer)), "an invalid drawer claiming int does not contest the valid one"); Assert.That(claims.Resolve(typeof(List), null).Drawer, Is.EqualTo(typeof(ListDrawer))); Assert.That(claims.Resolve(typeof(float), null).Drawer, Is.Null); Assert.That(claims.Resolve(typeof(float), null).Conflict, Is.Null); var contested = claims.Resolve(typeof(string), null); Assert.That(contested.Drawer, Is.Null); Assert.That(contested.Conflict, Does.Contain(nameof(StringDrawerA)).And.Contain(nameof(StringDrawerB))); Assert.That(claims.Errors.Any(error => error.Contains(nameof(NotADrawer))), Is.True); Assert.That(claims.Errors.Any(error => error.Contains(nameof(StringDrawerA)) && error.Contains(nameof(StringDrawerB))), Is.True); } [Test] public void ElementChoicesAreTheDeclaredUnionSubtypesOrTheTypeItself() { var model = Model(); Assert.That(model.ShapeOf(typeof(InspectShape)).Kind, Is.EqualTo(CultInspectorValueKind.Union)); Assert.That(model.ElementChoices(typeof(InspectShape)), Is.EqualTo(new[] { typeof(InspectCircle) })); Assert.That(model.CreateElement(typeof(InspectShape), typeof(InspectCircle), out var made), Is.InstanceOf()); Assert.That(made, Is.Null); Assert.That(() => model.CreateElement(typeof(InspectShape), typeof(InspectSquare), out _), Throws.ArgumentException, "an undeclared subtype is not a choice"); Assert.That(model.ElementChoices(typeof(List)), Is.EqualTo(new[] { typeof(List) })); Assert.That(model.CreateElement(typeof(List), typeof(List), out _), Is.EqualTo(new List())); Assert.That(model.CreateElement(typeof(InspectPicky), typeof(InspectNeedsArgs), out var refused), Is.Null); Assert.That(refused, Does.Contain("parameterless")); Assert.That(model.CreateElement(typeof(InspectNeedsArgs), typeof(InspectNeedsArgs), out var bare), Is.Null); Assert.That(bare, Does.Contain("parameterless"), "a plain element type that cannot be made says so; nothing is added"); Assert.That(model.CreateElement(typeof(int?), typeof(int?), out var nullable), Is.Null); Assert.That(nullable, Is.Null, "null is a Nullable element, not a failure"); Assert.That(model.CreateElement(typeof(InspectShape), typeof(InspectShape), out var @abstract), Is.Null); Assert.That(@abstract, Does.Contain("abstract"), "a union's own type cannot be made; it says so instead of throwing"); Assert.That(new[] { typeof(InspectShape), typeof(InspectNeedsArgs), typeof(IList) }.Any(model.CanCreate), Is.False); Assert.That(new[] { typeof(InspectItem), typeof(string), typeof(int[]), typeof(IDictionary), typeof(InspectOpenStruct) }.All(model.CanCreate), Is.True); } [Test] public void IntegerEditsClampToTheirType() { var model = Model(); Assert.That(model.NarrowInteger(typeof(byte), 300), Is.TypeOf().And.EqualTo(byte.MaxValue)); Assert.That(model.NarrowInteger(typeof(sbyte), -300), Is.TypeOf().And.EqualTo(sbyte.MinValue)); Assert.That(model.NarrowInteger(typeof(uint), -1), Is.TypeOf().And.EqualTo(0u)); Assert.That(model.NarrowInteger(typeof(int), 42), Is.TypeOf().And.EqualTo(42)); Assert.That(() => model.NarrowInteger(typeof(float), 1), Throws.ArgumentException); } [Test] public async Task RecordRefCandidatesAreRecordsAssignableToTheTarget() { using var cache = new CultCache(Registry); var a = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "a" }); var b = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "b" }); await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "o" }); var candidates = Model().RecordCandidates(typeof(CultRecordRef), cache.AllStoredDocuments); Assert.That(candidates.Select(candidate => candidate.Key), Is.EqualTo(new[] { a, b })); Assert.That(CultInspectorModel.RecordKey(default(CultRecordRef)), Is.EqualTo(string.Empty)); Assert.That(CultInspectorModel.RecordKey(new CultRecordRef(a)), Is.EqualTo(a.Value)); } [Test] public void OneRuleDecidesWhichRecordsMayBeARefsValue() { var model = Model(); CultStoredDocument Record(string key, object document) => new(new CultRecordKey(key), "test", Registry.GetRequired(document.GetType()), document); var leaf = Record("leaf", new InspectLabeled { Name = "a" }); var sibling = Record("other", new InspectOther { Name = "b" }); var unkeyed = Record("", new InspectLabeled { Name = "c" }); var records = new[] { sibling, leaf, unkeyed }; var baseRef = typeof(CultRecordRef); Assert.That(model.IsRecordCandidate(baseRef, leaf), Is.True, "a subtype of the target"); Assert.That(model.IsRecordCandidate(baseRef, sibling), Is.False, "a sibling type"); Assert.That(model.IsRecordCandidate(baseRef, unkeyed), Is.False, "an empty key"); Assert.That(model.RecordCandidates(baseRef, records), Is.EqualTo(records.Where(record => model.IsRecordCandidate(baseRef, record)))); Assert.That(() => model.IsRecordCandidate(typeof(string), leaf), Throws.ArgumentException); } [Test] public void ABaseMembersLabelReachesItsOverride() { var member = Model().MembersOf(typeof(InspectLabeled)).Single(candidate => candidate.Name == nameof(InspectLabeled.Tag)); Assert.That(member.Metadata.Label, Is.EqualTo("Base Label")); var claims = new CultInspectorDrawerClaims(new[] { typeof(LabelClaimDrawer) }, typeof(IFakeDrawer)); Assert.That(claims.Resolve(typeof(string), member.Member).Drawer, Is.EqualTo(typeof(LabelClaimDrawer)), "attribute claims see the base attribute too"); } [Test] public async Task DictionaryKeysRefuseNullEmptyReferencesAndDuplicates() { var model = Model(); var links = typeof(Dictionary, int>); var keys = new object?[] { Ref("a"), Ref("b") }; string? Refusal(object? candidate) { var kept = model.ReplaceKey(typeof(InspectItem),links, keys, 0, candidate, out var notice); Assert.That(kept, Is.EqualTo(notice == null ? candidate : keys[0]), "a refused key keeps the entry's key"); return notice; } Assert.That(Refusal(Ref("")), Does.Contain("empty")); Assert.That(Refusal(default(CultRecordRef)), Does.Contain("empty"), "null and \"\" are one empty reference"); Assert.That(Refusal(Ref("b")), Does.Contain("duplicate").And.Contain("entry 0")); Assert.That(Refusal(null), Does.Contain("null")); Assert.That(Refusal(Ref("c")), Is.Null); // An entry an old store left with an empty reference key keeps it through an edit of its value. Assert.That(model.ReplaceKey(typeof(InspectItem),links, new object?[] { Ref(""), Ref("b") }, 0, default(CultRecordRef), out var unchanged), Is.EqualTo(default(CultRecordRef))); Assert.That(unchanged, Is.Null, "an unchanged key is never refused"); using var cache = new CultCache(Registry); var first = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "first" }); var second = await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "second" }); var fresh = model.FreshKey(typeof(InspectItem),links, new object?[] { new CultRecordRef(first) }, cache.AllStoredDocuments, out _); Assert.That(CultInspectorModel.RecordKey(fresh), Is.EqualTo(second.Value)); Assert.That(model.FreshKey(typeof(InspectItem),links, new object?[] { new CultRecordRef(first), new CultRecordRef(second) }, cache.AllStoredDocuments, out var none), Is.Null); Assert.That(none, Does.Contain("No unused")); } [Test] public void DictionaryKeysAgreeWithTheDictionaryComparer() { var model = Model(); var doubles = typeof(Dictionary); var keys = new object?[] { 0.0, 1.0 }; Assert.That(model.ReplaceKey(typeof(InspectItem),doubles, keys, 1, -0.0, out var notice), Is.EqualTo(1.0)); Assert.That(notice, Does.Contain("duplicate"), "-0.0 serializes apart from 0.0 but Equals it"); Assert.That(() => model.BuildDictionary(doubles, keys.Select(key => new KeyValuePair(key, 0))), Throws.Nothing); Assert.That(model.FreshKey(typeof(InspectItem),doubles, new object?[] { -0.0 }, Array.Empty(), out _), Is.Null, "the default 0.0 is taken by -0.0"); Assert.That(model.ReplaceKey(typeof(InspectItem),doubles, new object?[] { 0.0 }, 0, -0.0, out _), Is.EqualTo(-0.0), "a key may replace itself"); } [Test] public void DictionaryKeysCompareBySerializedValueNotText() { var model = Model(); var dictionary = typeof(Dictionary); Assert.That(model.FreshKey(typeof(InspectItem),dictionary, new object?[] { new InspectKey(1) }, Array.Empty(), out _), Is.EqualTo(new InspectKey(0)), "keys whose text collides are still distinct keys"); Assert.That(model.ReplaceKey(typeof(InspectItem),dictionary, new object?[] { new InspectKey(1), new InspectKey(2) }, 1, new InspectKey(3), out var notice), Is.EqualTo(new InspectKey(3))); Assert.That(notice, Is.Null); } // Pair's formatter is declared only by this test assembly (AssemblyInfo). A key's bytes come from its owning document's // options, so a document here stores it and a document from an assembly declaring no resolver cannot. [Test] public void DictionaryKeysSerializeUnderTheOwningDocument() { var model = Model(); var pairs = typeof(Dictionary); var keys = new object?[] { new Pair { A = 1 }, new Pair { A = 2 } }; Assert.That(model.ReplaceKey(typeof(InspectItem), pairs, keys, 1, new Pair { A = 3 }, out var accepted), Is.EqualTo(new Pair { A = 3 })); Assert.That(accepted, Is.Null); Assert.That(model.ReplaceKey(typeof(object), pairs, keys, 1, new Pair { A = 3 }, out var refused), Is.EqualTo(keys[1]), "a key its document cannot serialize is refused, not compared by text"); Assert.That(refused, Does.Contain("cannot serialize")); Assert.That(model.FreshKey(typeof(object), pairs, new object?[] { new Pair { A = 1 } }, Array.Empty(), out var none), Is.Null); Assert.That(none, Does.Contain("cannot serialize")); } [Test] public async Task RecordIsTheStateTheEditBeganFrom() { using var cache = new CultCache(Registry); await cache.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "original" }); var record = cache.AllStoredDocuments.Single(); var edit = Model().BeginEdit(record); ((InspectItem)record.Document).Name = "changed in place before Record was read"; Assert.That(((InspectItem)edit.Record).Name, Is.EqualTo(((InspectItem)edit.Document).Name).And.EqualTo("original")); } [Test] public async Task RefusedCommitLeavesTheCachedDocumentUnchanged() { var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.cc"); try { using (var seed = CultCacheMessagePack.Create(filePath, new CultCacheOpenOptions { Registry = Registry })) { await seed.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "original", Numbers = { 1, 2 } }); await seed.FlushAsync(); } var model = Model(); using var readOnly = CultCacheMessagePack.Create(filePath, new CultCacheOpenOptions { Registry = Registry, ReadOnly = true }); var record = readOnly.AllStoredDocuments.Single(); var edit = model.BeginEdit(record); Assert.That(edit.Document, Is.Not.SameAs(record.Document)); var drawn = (InspectItem)edit.Record; Assert.That(drawn, Is.Not.SameAs(record.Document).And.Not.SameAs(edit.Document)); Assert.That(edit.Record, Is.SameAs(drawn), "one record copy per edit"); drawn.Name = "a drawer wrote here"; Assert.That(((InspectItem)edit.Document).Name, Is.EqualTo("original"), "the record copy is not the committed copy"); ((InspectItem)edit.Document).Name = "changed"; ((InspectItem)edit.Document).Numbers.Add(3); Assert.That(edit.Commit(readOnly, out var error), Is.False); Assert.That(error, Is.Not.Null.And.Not.Empty); Assert.That(readOnly.Get(record.Key)!.Name, Is.EqualTo("original")); Assert.That(readOnly.Get(record.Key)!.Numbers, Is.EqualTo(new[] { 1, 2 })); Assert.That(edit.IsFor(record), Is.False, "a commit spends the edit"); using var writable = new CultCache(Registry); var key = await writable.UpsertAsync(typeof(InspectItem), new InspectItem { Name = "original" }); var stored = writable.AllStoredDocuments.Single(); var accepted = model.BeginEdit(stored); ((InspectItem)accepted.Document).Name = "changed"; Assert.That(accepted.Commit(writable, out _), Is.True); Assert.That(writable.Get(key)!.Name, Is.EqualTo("changed")); Assert.That(((InspectItem)stored.Document).Name, Is.EqualTo("original"), "the replaced object was never mutated"); } finally { if (File.Exists(filePath)) File.Delete(filePath); } } [Test] public void ShapesClassifyCollectionsStructsAndMultiDimensionalArrays() { var model = Model(); Assert.That(model.ShapeOf(typeof(float[,])).Kind, Is.EqualTo(CultInspectorValueKind.Unsupported)); Assert.That(model.ShapeOf(typeof(float[,])).Reason, Does.Contain("multi-dimensional")); Assert.That(model.ShapeOf(typeof(List)).ElementType, Is.EqualTo(typeof(int))); Assert.That(model.BuildList(typeof(float[]), new object?[] { 1f, 2f }), Is.EqualTo(new[] { 1f, 2f })); var open = model.ShapeOf(typeof(InspectOpenStruct)); Assert.That(open.Kind, Is.EqualTo(CultInspectorValueKind.Nested)); Assert.That(open.Members.Select(member => member.Name), Is.EqualTo(new[] { "x", "y" })); Assert.That(open.Members.All(member => member.IsAssignable), Is.True); (string, bool)[] Rows(Type type) => model.ShapeOf(type).Members.Select(member => (member.Name, member.IsReadOnly)).ToArray(); Assert.That(Rows(typeof(InspectMixedStruct)), Is.EqualTo(new[] { ("x", false), ("y", true) }), "a struct with public fields shows only them; its settable properties are aliases, not rows"); Assert.That(Rows(typeof(InspectPropertyStruct)), Is.EqualTo(new[] { ("Settable", false), ("Initable", true) }), "without public fields, settable properties are rows; init is read-only; get-only is not a row"); Assert.That(Rows(typeof(InspectRecordStruct)), Is.EqualTo(new[] { ("A", false), ("B", false) })); Assert.That(Rows(typeof(InspectReadonlyRecordStruct)), Is.EqualTo(new[] { ("A", true), ("B", true) })); object boxed = new InspectRecordStruct(1, 2f); model.ShapeOf(typeof(InspectRecordStruct)).Members[0].SetValue(boxed, 5); Assert.That(((InspectRecordStruct)boxed).A, Is.EqualTo(5), "a property edit lands in the boxed struct in place"); Assert.That(model.ShapeOf(typeof(IDictionary)).Kind, Is.EqualTo(CultInspectorValueKind.Dictionary)); Assert.That(model.BuildDictionary(typeof(IDictionary), new[] { new KeyValuePair("a", 1) }), Is.EqualTo(new Dictionary { ["a"] = 1 })); } // ---- grouping ---- private static readonly CultDocumentRegistry GroupRegistry = CultDocumentRegistry.ForTypes(new[] { typeof(GroupLeaf), typeof(GroupRedeclared), typeof(GroupOptOut), typeof(GroupMixed), typeof(GroupGlobal), typeof(GroupNoCtor), typeof(GroupTagged), typeof(InspectOther) }); private string _groupPath = string.Empty; [SetUp] public void PickGroupPath() => _groupPath = Path.Combine(Path.GetTempPath(), $"cultlib-group-{Guid.NewGuid():N}.cc"); [TearDown] public void DeleteGroupPath() { if (File.Exists(_groupPath)) File.Delete(_groupPath); } private static CultInspectorModel GroupModel() => CultCacheMessagePack.CreateInspectorModel(GroupRegistry); private CultCache OpenGroups() => CultCacheMessagePack.Create(_groupPath, new CultCacheOpenOptions { Registry = GroupRegistry }); private static CultRecordRef OwnerRef(string key) => new(new CultRecordKey(key)); private static Task PutOwner(CultCache cache, string key, string name) => cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = name }, new CultRecordKey(key)); private static Task PutLeaf(CultCache cache, string key, string name, string? owner, GroupHull hull) => cache.UpsertAsync(typeof(GroupLeaf), new GroupLeaf { Name = name, Owner = owner == null ? default : OwnerRef(owner), Hull = hull }, new CultRecordKey(key)); private static IEnumerable Dump(IEnumerable groups) => groups.SelectMany(group => new[] { new string(' ', group.Depth * 2) + group.Label + " (" + group.Count + ")" + (group.Records.Count > 0 ? ": " + string.Join(",", group.Records.Select(record => record.Key.Value)) : string.Empty) }.Concat(Dump(group.Children))); private static IEnumerable Leaves(IEnumerable groups) => groups.SelectMany(group => group.Children.Count == 0 ? new[] { group } : Leaves(group.Children)); private static bool Holds(CultInspectorRecordGroup group, string key) => group.Records.Any(record => record.Key.Value == key) || group.Children.Any(child => Holds(child, key)); private static IEnumerable Containing(IEnumerable groups, string key) => groups.Where(group => Holds(group, key)).SelectMany(group => new[] { group }.Concat(Containing(group.Children, key))); private async Task SeededGroups() { var cache = OpenGroups(); await PutOwner(cache, "o-a", "Alpha"); await PutOwner(cache, "o-b", "Beta"); await PutLeaf(cache, "r1", "zeta", "o-b", GroupHull.Cruiser); await PutLeaf(cache, "r2", "alpha", "o-a", GroupHull.Frigate); await PutLeaf(cache, "r3", "mid", "o-a", GroupHull.Cruiser); await PutLeaf(cache, "r4", "beta", "o-a", GroupHull.Cruiser); return cache; } [Test] public async Task GroupLevelsNestInDeclaredOrderNotSlotOrder() { using var cache = await SeededGroups(); var model = GroupModel(); Assert.That(model.GroupingOf(typeof(GroupLeaf)).Members.Select(member => member.Name), Is.EqualTo(new[] { "Owner", "Hull" })); Assert.That(Dump(model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments)), Is.EqualTo(new[] { "Alpha (3)", " Cruiser (2): r4,r3", " Frigate (1): r2", "Beta (1)", " Cruiser (1): r1" }), "an interior node holds no records; a leaf holds its records in candidate order"); } [Test] public void GroupADerivedListedTypeGroupsByItsBasesDeclarationAndTheNearestDeclarationWins() { var model = GroupModel(); Assert.That(model.GroupingOf(typeof(GroupLeaf)).Members.Select(member => member.Name), Is.EqualTo(new[] { "Owner", "Hull" }), "inherited from GroupBase"); Assert.That(model.GroupingOf(typeof(GroupRedeclared)).Members.Select(member => member.Name), Is.EqualTo(new[] { "Kind" })); var optOut = model.GroupingOf(typeof(GroupOptOut)); Assert.That(optOut.Members, Is.Empty, "an empty declaration opts out of the base's"); Assert.That(optOut.Notice, Is.Null); Assert.That(model.GroupingOf(typeof(GroupLeaf)), Is.SameAs(model.GroupingOf(typeof(GroupLeaf))), "cached"); } [Test] public async Task GroupLeavesPartitionTheCandidatesWhateverTheirGroupedValues() { using var cache = OpenGroups(); await PutOwner(cache, "o-a", "Alpha"); await PutLeaf(cache, "r1", "n1", null, GroupHull.Cruiser); await PutLeaf(cache, "r2", "n2", "ghost", (GroupHull)99); await PutLeaf(cache, "r3", "n3", "o-a", GroupHull.Frigate); var model = GroupModel(); var records = cache.AllStoredDocuments.ToArray(); var groups = model.GroupRecords(typeof(GroupLeaf), records); Assert.That(Dump(groups), Is.EqualTo(new[] { "None (1)", " Cruiser (1): r1", "Alpha (1)", " Frigate (1): r3", "Missing ghost (1)", " 99 (1): r2" })); var candidates = model.RecordCandidates(typeof(CultRecordRef), records); Assert.That(Leaves(groups).SelectMany(leaf => leaf.Records), Is.EquivalentTo(candidates), "each candidate once, the owner record is no candidate"); } [Test] public async Task GroupRefsGroupByKeyNotLabelWithNoneFirstAndMissingLast() { using var cache = OpenGroups(); await PutOwner(cache, "o1", "Same"); await PutOwner(cache, "o2", "Same"); await PutOwner(cache, "o3", "Aaa"); await PutOwner(cache, "o4", "Zzz"); foreach (var (key, owner) in new[] { ("r1", "o2"), ("r2", "o1"), ("r3", (string?)null), ("r4", "ghost-b"), ("r5", "ghost-a"), ("r6", "o3"), ("r7", "o4") }) await PutLeaf(cache, key, key, owner, GroupHull.Cruiser); var model = GroupModel(); var roots = model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments); Assert.That(roots.Select(group => group.Label), Is.EqualTo(new[] { "None", "Aaa", "Same", "Same", "Zzz", "Missing ghost-a", "Missing ghost-b" })); Assert.That(roots.Select(group => CultInspectorModel.RecordKey(group.Values[0])), Is.EqualTo(new[] { "", "o3", "o1", "o2", "o4", "ghost-a", "ghost-b" }), "equal labels order by key"); Assert.That(roots.Select(group => group.Id).Distinct().Count(), Is.EqualTo(7)); var sameId = roots[2].Id; await PutOwner(cache, "o1", "Renamed"); var regrouped = model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments); Assert.That(regrouped.Select(group => group.Id), Does.Contain(sameId), "identity is the key, not the label"); Assert.That(regrouped.Select(group => group.Label), Does.Contain("Renamed")); } [Test] public async Task GroupRefLabelsAreOwnedByTheModel() { using var cache = OpenGroups(); await PutOwner(cache, "o1", "Alpha"); await PutOwner(cache, "blank", " "); await PutLeaf(cache, "x", "x", null, GroupHull.Cruiser); var model = GroupModel(); var records = cache.AllStoredDocuments.ToArray(); var refType = typeof(CultRecordRef); Assert.That(model.RecordRefLabel(refType, default(CultRecordRef), records), Is.EqualTo("None")); Assert.That(model.RecordRefLabel(refType, OwnerRef("o1"), records), Is.EqualTo("Alpha")); Assert.That(model.RecordRefLabel(refType, OwnerRef("blank"), records), Is.EqualTo("blank"), "an unnamed record shows its key"); Assert.That(model.RecordRefLabel(refType, OwnerRef("gone"), records), Is.EqualTo("Missing gone")); Assert.That(model.RecordRefLabel(refType, OwnerRef("x"), records), Is.EqualTo("Missing x"), "a record that is no candidate is missing"); Assert.That(model.RecordRefLabel(refType, null, records), Is.EqualTo("None")); Assert.That(() => model.RecordRefLabel(typeof(string), null, records), Throws.ArgumentException); } [Test] public async Task GroupEnumNodesOrderByValueAndScalarLevelsByTheirDefaults() { using var cache = OpenGroups(); foreach (var (key, hull) in new[] { ("h1", GroupHull.Frigate), ("h2", GroupHull.Cruiser), ("h3", GroupHull.Carrier), ("h4", (GroupHull)99) }) await PutLeaf(cache, key, key, null, hull); var model = GroupModel(); Assert.That(model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments).Single().Children.Select(group => group.Label), Is.EqualTo(new[] { "Cruiser", "Carrier", "Frigate", "99" }), "by underlying value (10, 20, 30, 99), not by name"); foreach (var (key, kind, count, flag) in new[] { ("m1", "b", 10, true), ("m2", "A", 9, false), ("m3", "a", 9, true), ("m4", "", -1, false), ("m5", "a", 10, true), ("m6", "a", 10, false), ("m7", "a", -1, true), ("m8", "B", 10, true) }) await cache.UpsertAsync(typeof(GroupMixed), new GroupMixed { Kind = kind, Count = count, Flag = flag }, new CultRecordKey(key)); Assert.That(Dump(model.GroupRecords(typeof(GroupMixed), cache.AllStoredDocuments)), Is.EqualTo(new[] { "(empty) (1)", " -1 (1)", " False (1): m4", "A (1)", " 9 (1)", " False (1): m2", "a (4)", " -1 (1)", " True (1): m7", " 9 (1)", " True (1): m3", " 10 (2)", " False (1): m6", " True (1): m5", "B (1)", " 10 (1)", " True (1): m8", "b (1)", " 10 (1)", " True (1): m1" }), "strings ignore case then compare exactly, integers order numerically, false before true"); } [Test] public async Task GroupNodeIdsStayDistinctWhateverTheValuesContain() { using var cache = OpenGroups(); await cache.UpsertAsync(typeof(GroupMixed), new GroupMixed { Kind = "x/Count=1/Flag=True" }, new CultRecordKey("a")); await cache.UpsertAsync(typeof(GroupMixed), new GroupMixed { Kind = "x", Count = 1, Flag = true }, new CultRecordKey("b")); await cache.UpsertAsync(typeof(GroupMixed), new GroupMixed { Kind = "aCount0" }, new CultRecordKey("c")); await cache.UpsertAsync(typeof(GroupMixed), new GroupMixed { Kind = "a" }, new CultRecordKey("d")); var ids = new List(); void Collect(IEnumerable groups) { foreach (var group in groups) { ids.Add(group.Id); Collect(group.Children); } } Collect(GroupModel().GroupRecords(typeof(GroupMixed), cache.AllStoredDocuments)); Assert.That(ids, Has.Count.EqualTo(12)); Assert.That(ids.Distinct().Count(), Is.EqualTo(12), "a path never spells another node's id"); } [Test] public async Task GroupNodeIdsSeparateAnEscapeFromTheTextItSpells() { // "a/b" and "a%2Fb" collide under an id that escapes '/' alone; the unpaired surrogates, U+FFFD and "a%uD800" // are the values a replacement character or a sloppy %u form would merge; the valid pair is one node, not two halves. var kinds = new[] { "a/b", "a%2Fb", "a%2fb", "a%252Fb", "a", "A", "\uD800", "\uD801", "\uDC00", "�", "a%uD800", "a\uD800", "😀", "�\uDE00", "\uDE00\uD83D", "a\uD800b\uD801c" }; using var cache = OpenGroups(); for (var i = 0; i < kinds.Length; i++) await cache.UpsertAsync(typeof(GroupRedeclared), new GroupRedeclared { Kind = kinds[i] }, new CultRecordKey("k" + i)); var roots = GroupModel().GroupRecords(typeof(GroupRedeclared), cache.AllStoredDocuments); Assert.That(roots, Has.Count.EqualTo(kinds.Length), "every distinct value is its own node, 'a' and 'A' included"); Assert.That(roots.Select(group => group.Id).Distinct().Count(), Is.EqualTo(kinds.Length), "and none spells another's id"); Assert.That(roots.Select(group => group.Label).Where(label => label == "a" || label == "A"), Is.EqualTo(new[] { "A", "a" }), "labels equal ignoring case fall back to ordinal order"); } [Test] public async Task GroupRefsWhoseKeysDifferOnlyByCaseAreTwoNodes() { using var cache = OpenGroups(); await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "lower" }, new CultRecordKey("o-a")); await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "upper" }, new CultRecordKey("O-A")); await PutLeaf(cache, "r1", "one", "o-a", GroupHull.Cruiser); await PutLeaf(cache, "r2", "two", "O-A", GroupHull.Cruiser); var model = GroupModel(); var roots = model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments); Assert.That(roots.Select(group => group.Label), Is.EqualTo(new[] { "lower", "upper" }), "each key finds its own target"); Assert.That(roots.Select(group => group.Id).Distinct().Count(), Is.EqualTo(2)); Assert.That(model.RecordRefLabel(typeof(CultRecordRef), OwnerRef("O-A"), cache.AllStoredDocuments), Is.EqualTo("upper")); } [Test] public async Task GroupAnUngroupedListedTypeHasNoTreeAndItsCandidatesStayFlat() { using var cache = OpenGroups(); await cache.UpsertAsync(typeof(GroupGlobal), new GroupGlobal { Kind = "k" }, new CultRecordKey("global")); var model = GroupModel(); var grouping = model.GroupingOf(typeof(GroupGlobal)); Assert.That(grouping.Members, Is.Empty); Assert.That(grouping.Notice, Is.Not.Null, "a refused declaration still says why"); Assert.That(model.GroupRecords(typeof(GroupGlobal), cache.AllStoredDocuments), Is.Empty, "no tree: the lowering lists candidates flat"); Assert.That(model.RecordCandidates(typeof(CultRecordRef), cache.AllStoredDocuments).Select(record => record.Key.Value), Is.EqualTo(new[] { "global" })); } [Test] public async Task GroupCreateInGroupRoundTripsIntoTheSameNode() { using var cache = await SeededGroups(); await PutLeaf(cache, "r5", "ghosted", "ghost", GroupHull.Carrier); var model = GroupModel(); var groups = model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments); var alpha = groups.Single(group => group.Label == "Alpha"); var alphaCruiser = alpha.Children.Single(group => group.Label == "Cruiser"); var ghost = groups.Single(group => group.Label == "Missing ghost"); var deep = (GroupLeaf)model.CreateInGroup(typeof(GroupLeaf), alphaCruiser, out var notice)!; Assert.That(notice, Is.Null); Assert.That(CultInspectorModel.RecordKey(deep.Owner), Is.EqualTo("o-a")); Assert.That(deep.Hull, Is.EqualTo(GroupHull.Cruiser)); var shallow = (GroupLeaf)model.CreateInGroup(typeof(GroupLeaf), alpha, out _)!; Assert.That(CultInspectorModel.RecordKey(shallow.Owner), Is.EqualTo("o-a")); var dangling = (GroupLeaf)model.CreateInGroup(typeof(GroupLeaf), ghost, out _)!; Assert.That(CultInspectorModel.RecordKey(dangling.Owner), Is.EqualTo("ghost"), "a missing node presets the dangling key"); await cache.UpsertAsync(typeof(GroupLeaf), deep, new CultRecordKey("deep")); await cache.UpsertAsync(typeof(GroupLeaf), shallow, new CultRecordKey("shallow")); await cache.UpsertAsync(typeof(GroupLeaf), dangling, new CultRecordKey("dangling")); var after = model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments); Assert.That(Containing(after, "deep").Select(group => group.Id), Is.EqualTo(new[] { alpha.Id, alphaCruiser.Id })); Assert.That(Containing(after, "shallow").Select(group => group.Id), Does.Contain(alpha.Id)); Assert.That(Containing(after, "dangling").Select(group => group.Id), Does.Contain(ghost.Id)); } [Test] public async Task GroupCreateInGroupRefusesANodeOfAnotherGrouping() { using var cache = await SeededGroups(); var model = GroupModel(); var alphaCruiser = model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments)[0].Children[0]; Assert.That(() => model.CreateInGroup(typeof(GroupRedeclared), alphaCruiser, out _), Throws.ArgumentException, "a deeper path than the grouping has"); Assert.That(() => model.CreateInGroup(typeof(GroupLeaf), null!, out _), Throws.ArgumentNullException); await cache.UpsertAsync(typeof(GroupRedeclared), new GroupRedeclared { Kind = "k" }, new CultRecordKey("g1")); var kindNode = model.GroupRecords(typeof(GroupRedeclared), cache.AllStoredDocuments).Single(); Assert.That(() => model.CreateInGroup(typeof(GroupTagged), kindNode, out _), Throws.ArgumentException, "a value that would fit another grouping's member"); } [Test] public async Task GroupCreateInGroupReportsATypeItCannotCreate() { using var cache = OpenGroups(); await cache.UpsertAsync(typeof(GroupNoCtor), new GroupNoCtor("n") { Kind = "k" }, new CultRecordKey("c1")); var model = GroupModel(); var node = model.GroupRecords(typeof(GroupNoCtor), cache.AllStoredDocuments).Single(); Assert.That(model.CreateInGroup(typeof(GroupNoCtor), node, out var notice), Is.Null); Assert.That(notice, Does.Contain("parameterless constructor")); } [Test] public void GroupATypeWithoutADeclarationIsNotGrouped() { var model = GroupModel(); var grouping = model.GroupingOf(typeof(InspectOther)); Assert.That(grouping.ListedType, Is.EqualTo(typeof(InspectOther))); Assert.That(grouping.Members, Is.Empty); Assert.That(grouping.Notice, Is.Null); Assert.That(model.GroupRecords(typeof(InspectOther), Array.Empty()), Is.Empty); Assert.That(() => model.GroupingOf(null!), Throws.ArgumentNullException); } [Test] public async Task GroupANullStringSharesTheEmptyStringsNode() { using var cache = OpenGroups(); await cache.UpsertAsync(typeof(GroupMixed), new GroupMixed { Kind = null! }, new CultRecordKey("n")); await cache.UpsertAsync(typeof(GroupMixed), new GroupMixed { Kind = "" }, new CultRecordKey("e")); var roots = GroupModel().GroupRecords(typeof(GroupMixed), cache.AllStoredDocuments); Assert.That(roots.Select(group => group.Label), Is.EqualTo(new[] { "(empty)" })); Assert.That(roots.Single().Count, Is.EqualTo(2)); } [TestCase(typeof(GroupUnknown), "Nope")] [TestCase(typeof(GroupFloat), "Mass")] [TestCase(typeof(GroupFlagsEnum), "Traits")] [TestCase(typeof(GroupHiddenMember), "Secret")] [TestCase(typeof(GroupReadOnlyMember), "Locked")] [TestCase(typeof(GroupDuplicate), "Kind")] [TestCase(typeof(GroupValidThenInvalid), "Mass")] [TestCase(typeof(GroupGlobal), "global")] public void GroupInvalidDeclarationsGiveANoticeAndAFlatList(Type listed, string mentions) { var model = GroupModel(); var grouping = model.GroupingOf(listed); Assert.That(grouping.Members, Is.Empty); Assert.That(grouping.Notice, Does.Contain(mentions).And.Contain("CultInspectorGroupBy").And.Contain("not grouped")); Assert.That(model.GroupRecords(listed, Array.Empty()), Is.Empty); } [Test] public async Task GroupAVariantGroupsByItsResolvedValues() { using var cache = OpenGroups(); var baseKey = new CultRecordKey("base"); await PutLeaf(cache, "base", "base", null, GroupHull.Cruiser); await cache.UpsertVariantAsync(new CultRecordKey("over"), baseKey, new[] { cache.Override(nameof(GroupLeaf.Name), "over-name"), cache.Override(nameof(GroupLeaf.Hull), GroupHull.Frigate) }); await cache.UpsertVariantAsync(new CultRecordKey("inherit"), baseKey, new[] { cache.Override(nameof(GroupLeaf.Name), "inheritor") }); var model = GroupModel(); Assert.That(Dump(model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments)), Is.EqualTo(new[] { "None (3)", " Cruiser (2): base,inherit", " Frigate (1): over" })); await PutLeaf(cache, "base", "base", null, GroupHull.Carrier); Assert.That(Dump(model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments)), Is.EqualTo(new[] { "None (3)", " Carrier (2): base,inherit", " Frigate (1): over" }), "an inheriting variant follows its base; an overriding one stays"); } [CultDocument("tests.inspect_item", "tests.inspect_item.v1")] [MessagePackObject] public sealed class InspectItem { [Key(0)] [CultName] [CultInspectorLabel("Display Name")] public string Name = string.Empty; [Key(1)] [CultInspectorRange(0, 10)] [CultInspectorOrder(-1)] public int Value; [Key(2)] [CultInspectorHidden] public string Secret = string.Empty; [Key(3)] [CultInspectorReadOnly] public string Locked = string.Empty; [Key(4)] public InspectShape? Shape; [Key(5)] public Dictionary, int> Links = new Dictionary, int>(); [Key(6)] public List Numbers = new List(); } [CultDocument("tests.inspect_other", "tests.inspect_other.v1")] [MessagePackObject] public sealed class InspectOther { [Key(0)] [CultName] public string Name = string.Empty; } public abstract class InspectLabeledBase { [Key(1)] [CultInspectorLabel("Base Label")] public abstract string Tag { get; set; } } [CultDocument("tests.inspect_labeled", "tests.inspect_labeled.v1")] [MessagePackObject] public sealed class InspectLabeled : InspectLabeledBase { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public override string Tag { get; set; } = string.Empty; } [CultInspectorDrawer(typeof(CultInspectorLabelAttribute))] public sealed class LabelClaimDrawer : IFakeDrawer { } [CultDocument("tests.inspect_asset", "tests.inspect_asset.v1")] [MessagePackObject] public sealed class InspectAsset { [Key(0)] [CultInspectorAssetGuid(typeof(Uri))] public string Guid = string.Empty; } [CultDocument("tests.inspect_fixed", "tests.inspect_fixed.v1")] [MessagePackObject] public sealed class InspectFixed { [SerializationConstructor] public InspectFixed(string id) { Id = id; } [Key(0)] public string Id { get; } } [Union(0, typeof(InspectCircle))] public abstract class InspectShape { } [MessagePackObject] public sealed class InspectCircle : InspectShape { [Key(0)] public float Radius; } [MessagePackObject] public sealed class InspectSquare : InspectShape { [Key(0)] public float Side; } [MessagePackObject] public class InspectHidden { [Key(0)] public int Value; } [MessagePackObject] public sealed class InspectHider : InspectHidden { [Key(0)] public new string Value = string.Empty; } [Union(0, typeof(InspectNeedsArgs))] public abstract class InspectPicky { } public sealed class InspectNeedsArgs : InspectPicky { public InspectNeedsArgs(int side) { } } public struct InspectOpenStruct { public float x; public float y; public float Length => x + y; } public struct InspectMixedStruct { public float x; public readonly float y; public float Alias { get => x; set => x = value; } } public struct InspectPropertyStruct { public int Settable { get; set; } public int Initable { get; init; } public int Computed => Settable + 1; } public record struct InspectRecordStruct(int A, float B); public readonly record struct InspectReadonlyRecordStruct(int A, float B); [MessagePackObject] public readonly record struct InspectKey([property: Key(0)] int Value) { public override string ToString() => "key"; } public enum GroupHull { Frigate = 30, Cruiser = 10, Carrier = 20 } [Flags] public enum GroupTraits { Fast = 1, Armored = 2 } [CultInspectorGroupBy(nameof(GroupLeaf.Owner), nameof(GroupLeaf.Hull))] public abstract class GroupBase { } [CultDocument("tests.group_leaf", "tests.group_leaf.v1")] [MessagePackObject] public sealed class GroupLeaf : GroupBase { [Key(0)] [CultName] public string Name = string.Empty; [Key(1)] public GroupHull Hull; [Key(2)] public CultRecordRef Owner; } [CultInspectorGroupBy("Kind")] [CultDocument("tests.group_redeclared", "tests.group_redeclared.v1")] [MessagePackObject] public sealed class GroupRedeclared : GroupBase { [Key(0)] public string Kind = string.Empty; } [CultInspectorGroupBy] [CultDocument("tests.group_opt_out", "tests.group_opt_out.v1")] [MessagePackObject] public sealed class GroupOptOut : GroupBase { [Key(0)] public string Kind = string.Empty; } [CultInspectorGroupBy("Kind", "Count", "Flag")] [CultDocument("tests.group_mixed", "tests.group_mixed.v1")] [MessagePackObject] public sealed class GroupMixed { [Key(0)] public string Kind = string.Empty; [Key(1)] public int Count; [Key(2)] public bool Flag; } [CultInspectorGroupBy("Kind")] [CultGlobal] [CultDocument("tests.group_global", "tests.group_global.v1")] [MessagePackObject] public sealed class GroupGlobal { [Key(0)] public string Kind = string.Empty; } [CultInspectorGroupBy("Tag")] [CultDocument("tests.group_tagged", "tests.group_tagged.v1")] [MessagePackObject] public sealed class GroupTagged { [Key(0)] public string Tag = string.Empty; } [CultInspectorGroupBy("Kind")] [CultDocument("tests.group_no_ctor", "tests.group_no_ctor.v1")] [MessagePackObject] public sealed class GroupNoCtor { [SerializationConstructor] public GroupNoCtor(string name) { Name = name; } [Key(0)] public string Name { get; } [Key(1)] public string Kind = string.Empty; } [MessagePackObject] public class GroupShape { [Key(0)] public string Kind = string.Empty; [Key(1)] public float Mass; [Key(2)] public GroupTraits Traits; [Key(3)] [CultInspectorHidden] public int Secret; [Key(4)] [CultInspectorReadOnly] public int Locked; } [CultInspectorGroupBy("Nope")] public sealed class GroupUnknown : GroupShape { } [CultInspectorGroupBy("Mass")] public sealed class GroupFloat : GroupShape { } [CultInspectorGroupBy("Traits")] public sealed class GroupFlagsEnum : GroupShape { } [CultInspectorGroupBy("Secret")] public sealed class GroupHiddenMember : GroupShape { } [CultInspectorGroupBy("Locked")] public sealed class GroupReadOnlyMember : GroupShape { } [CultInspectorGroupBy("Kind", "Kind")] public sealed class GroupDuplicate : GroupShape { } [CultInspectorGroupBy("Kind", "Mass")] public sealed class GroupValidThenInvalid : GroupShape { } public interface IFakeDrawer { } [AttributeUsage(AttributeTargets.Field)] public sealed class InspectMarkAttribute : Attribute { } public sealed class ClaimHost { [InspectMark] public int Marked; public int Plain; } [CultInspectorDrawer(typeof(InspectMarkAttribute))] public sealed class MarkDrawer : IFakeDrawer { } [CultInspectorDrawer(typeof(int))] public sealed class IntDrawer : IFakeDrawer { } [CultInspectorDrawer(typeof(List<>))] public sealed class ListDrawer : IFakeDrawer { } [CultInspectorDrawer(typeof(string))] public sealed class StringDrawerA : IFakeDrawer { } [CultInspectorDrawer(typeof(string))] public sealed class StringDrawerB : IFakeDrawer { } [CultInspectorDrawer(typeof(int))] public sealed class NotADrawer { } } }