diff --git a/src/GameCult.Caching/CultInspectorAttributes.cs b/src/GameCult.Caching/CultInspectorAttributes.cs index 2ae523e0..2a4e1839 100644 --- a/src/GameCult.Caching/CultInspectorAttributes.cs +++ b/src/GameCult.Caching/CultInspectorAttributes.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace GameCult.Caching { @@ -78,6 +79,21 @@ namespace GameCult.Caching public Type? AssetType { get; } } + // Groups a document type's record list by the named members, outermost first: the argument order is the nesting order. + // The nearest declaration up the inheritance chain wins, so a derived type may re-declare, and an empty declaration + // opts out of a base's. A member must be a visible, editable string, integer, bool, non-[Flags] enum or CultRecordRef; + // anything else gives a flat list and a notice. Presentation only: no schema, index or stored byte changes. + [AttributeUsage(AttributeTargets.Class, Inherited = true, AllowMultiple = false)] + public sealed class CultInspectorGroupByAttribute : Attribute + { + public CultInspectorGroupByAttribute(params string[] members) + { + Members = members ?? throw new ArgumentNullException(nameof(members)); + } + + public IReadOnlyList Members { get; } + } + // Marks a lowering's drawer class as the drawer for a claimed type. Claimed is either an Attribute subclass (the // drawer draws every member carrying that attribute) or a value type (every value of that type, or of any closed // form when Claimed is an open generic definition). Attribute claims win over type claims, which win over the diff --git a/src/GameCult.Caching/CultInspectorGrouping.cs b/src/GameCult.Caching/CultInspectorGrouping.cs new file mode 100644 index 00000000..07ef42a6 --- /dev/null +++ b/src/GameCult.Caching/CultInspectorGrouping.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; + +namespace GameCult.Caching +{ + // Which members group a listed document type's records, outermost first. + public sealed class CultInspectorGrouping + { + internal CultInspectorGrouping(Type listedType, IReadOnlyList members, string? notice) + { + ListedType = listedType; + Members = members; + Notice = notice; + } + + public Type ListedType { get; } + + // Empty when nothing groups: no declaration, an opt-out, or a refused declaration (then Notice says why). + public IReadOnlyList Members { get; } + + public string? Notice { get; } + } + + // One node of a grouped record list. Interior nodes hold Children; leaves hold Records. + public sealed class CultInspectorRecordGroup + { + internal CultInspectorRecordGroup( + string id, + string label, + int depth, + IReadOnlyList values, + IReadOnlyList children, + IReadOnlyList records, + int count) + { + Id = id; + Label = label; + Depth = depth; + Values = values; + Children = children; + Records = records; + Count = count; + } + + // Value identity along the path, not the label: stable across renames, so a lowering keys its foldout state on it. + public string Id { get; } + public string Label { get; } + + // 0 for the outermost level. + public int Depth { get; } + + // The grouped member values on the path to this node, outermost first. + public IReadOnlyList Values { get; } + public IReadOnlyList Children { get; } + public IReadOnlyList Records { get; } + + // The records under this node, at any depth. + public int Count { get; } + } +} diff --git a/src/GameCult.Caching/CultInspectorModel.cs b/src/GameCult.Caching/CultInspectorModel.cs index 99d82205..efb602d3 100644 --- a/src/GameCult.Caching/CultInspectorModel.cs +++ b/src/GameCult.Caching/CultInspectorModel.cs @@ -33,7 +33,7 @@ namespace GameCult.Caching internal CultInspectorMetadata(MemberInfo? member) { - Attributes = member == null ? Array.Empty() : member.GetCustomAttributes(true).OfType().ToArray(); + Attributes = member == null ? Array.Empty() : Attribute.GetCustomAttributes(member, true); var label = Find()?.Label; Label = string.IsNullOrWhiteSpace(label) ? null : label; Hidden = Find() != null; @@ -206,7 +206,7 @@ namespace GameCult.Caching private CultInspectorClaim ResolveCore(Type valueType, MemberInfo? member) { Type? drawer = null; - foreach (var attribute in member?.GetCustomAttributes(true).OfType() ?? Enumerable.Empty()) + foreach (var attribute in member == null ? Array.Empty() : Attribute.GetCustomAttributes(member, true)) { for (var type = attribute.GetType(); type != null && type != typeof(Attribute); type = type.BaseType) { @@ -294,6 +294,7 @@ namespace GameCult.Caching private readonly CultCodec _codec; private readonly Dictionary _shapes = new Dictionary(); + private readonly Dictionary _groupings = new Dictionary(); private readonly Dictionary _metadata = new Dictionary(); // The codec is the store's (CultCacheMessagePack.CreateInspectorModel for .cc stores), the one a CultCache resolves @@ -513,14 +514,24 @@ namespace GameCult.Caching // The records a CultRecordRef may point at: every record whose document is a T, by label. public IReadOnlyList RecordCandidates(Type recordRefType, IEnumerable records) { - var target = ShapeOf(recordRefType).RecordTarget - ?? throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef.", nameof(recordRefType)); + var target = RecordTargetOf(recordRefType); return records - .Where(record => target.IsInstanceOfType(record.Document) && record.Key.Value.Length > 0) + .Where(record => IsCandidateFor(target, record)) .OrderBy(RecordLabel, StringComparer.OrdinalIgnoreCase) .ToArray(); } + // Whether record may be the value of a recordRefType: the one rule the picker's candidates and a drop share. + public bool IsRecordCandidate(Type recordRefType, CultStoredDocument record) => + IsCandidateFor(RecordTargetOf(recordRefType), record ?? throw new ArgumentNullException(nameof(record))); + + private Type RecordTargetOf(Type recordRefType) => + ShapeOf(recordRefType).RecordTarget + ?? throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef.", nameof(recordRefType)); + + private static bool IsCandidateFor(Type target, CultStoredDocument record) => + target.IsInstanceOfType(record.Document) && record.Key.Value.Length > 0; + public object CreateRecordRef(Type recordRefType, string key) { if (ShapeOf(recordRefType).Kind != CultInspectorValueKind.RecordRef) @@ -528,6 +539,183 @@ namespace GameCult.Caching return Activator.CreateInstance(recordRefType, new CultRecordKey(key))!; } + // How a listed document type's records group, from the nearest [CultInspectorGroupBy] up its inheritance chain. Members + // is empty when nothing groups, with a Notice when a declaration was refused. + public CultInspectorGrouping GroupingOf(Type listed) + { + if (listed == null) throw new ArgumentNullException(nameof(listed)); + lock (_groupings) + { + if (!_groupings.TryGetValue(listed, out var grouping)) + _groupings[listed] = grouping = ResolveGrouping(listed); + return grouping; + } + } + + private CultInspectorGrouping ResolveGrouping(Type listed) + { + var declared = (CultInspectorGroupByAttribute?)Attribute.GetCustomAttribute(listed, typeof(CultInspectorGroupByAttribute), true); + if (declared == null || declared.Members.Count == 0) + return new CultInspectorGrouping(listed, Array.Empty(), null); + + CultInspectorGrouping Refused(string reason) => + new CultInspectorGrouping(listed, Array.Empty(), $"[CultInspectorGroupBy] on {listed.Name}: {reason} The list is not grouped."); + + if (listed.GetCustomAttribute() != null) + return Refused("a global document is never grouped."); + var members = new List(); + foreach (var name in declared.Members) + { + var member = MembersOf(listed).FirstOrDefault(candidate => candidate.Name == name); + if (member == null) return Refused($"{name} is not an inspector member."); + if (members.Contains(member)) return Refused($"{name} is named twice."); + if (member.Metadata.Hidden) return Refused($"{name} is hidden."); + if (member.IsReadOnly) return Refused($"{name} is read-only."); + var kind = ShapeOf(member.ValueType).Kind; + var groupable = kind is CultInspectorValueKind.String or CultInspectorValueKind.Integer or CultInspectorValueKind.Bool or CultInspectorValueKind.RecordRef + || kind == CultInspectorValueKind.Enum && !member.ValueType.IsDefined(typeof(FlagsAttribute), false); + if (!groupable) return Refused($"{name} is a {member.ValueType.Name}, which does not group."); + members.Add(member); + } + + return new CultInspectorGrouping(listed, members, null); + } + + // The tree of listed's candidate records: one level per grouped member, occupied values only. Leaves hold the records, + // in candidate order, and together exactly RecordCandidates(CultRecordRef, records); a variant groups by its + // resolved values. Empty when GroupingOf(listed) groups nothing: the lowering lists the candidates flat. + public IReadOnlyList GroupRecords(Type listed, IEnumerable records) + { + var grouping = GroupingOf(listed); + if (grouping.Members.Count == 0) + return Array.Empty(); + var all = records.ToArray(); + var targets = grouping.Members + .Where(member => ShapeOf(member.ValueType).Kind == CultInspectorValueKind.RecordRef) + .ToDictionary(member => member, member => RefTargets(member.ValueType, all)); + var candidates = RecordCandidates(typeof(CultRecordRef<>).MakeGenericType(listed), all); + return Partition(grouping, candidates, 0, Array.Empty(), targets); + } + + private IReadOnlyList Partition( + CultInspectorGrouping grouping, + IReadOnlyList records, + int depth, + IReadOnlyList path, + IReadOnlyDictionary> targets) + { + var member = grouping.Members[depth]; + targets.TryGetValue(member, out var refTargets); + return records + .Select(record => (Record: record, Key: LevelOf(member, member.GetValue(record.Document), refTargets))) + .GroupBy(entry => entry.Key.Identity) + .Select(bucket => (Key: bucket.First().Key, Records: bucket.Select(entry => entry.Record).ToArray())) + .OrderBy(bucket => bucket.Key, GroupLevel.Order) + .Select(bucket => + { + var values = path.Append(bucket.Key.Value).ToArray(); + var children = depth + 1 < grouping.Members.Count + ? Partition(grouping, bucket.Records, depth + 1, values, targets) + : Array.Empty(); + return new CultInspectorRecordGroup( + GroupId(grouping.Members, values), bucket.Key.Label, depth, values, children, + children.Count == 0 ? bucket.Records : Array.Empty(), bucket.Records.Length); + }) + .ToArray(); + } + + // A new listed document set to every grouped value on group's path, so it lands in group. Null with a notice when + // listed cannot be created. The group must come from GroupRecords for this listed type. + public object? CreateInGroup(Type listed, CultInspectorRecordGroup group, out string? notice) + { + if (group == null) throw new ArgumentNullException(nameof(group)); + var members = GroupingOf(listed).Members; + if (group.Values.Count == 0 || group.Values.Count > members.Count || group.Id != GroupId(members, group.Values)) + throw new ArgumentException($"Group {group.Id} is not a node of {listed.Name}'s grouping.", nameof(group)); + var created = CreateElement(listed, listed, out notice); + if (created == null) + return null; + for (var i = 0; i < group.Values.Count; i++) + members[i].SetValue(created, group.Values[i]); + return created; + } + + // What a CultRecordRef value shows: None, the target's label, or Missing when the key names no candidate. + public string RecordRefLabel(Type recordRefType, object? value, IEnumerable records) => + RefLabel(RecordKey(value), RefTargets(recordRefType, records)); + + private Dictionary RefTargets(Type recordRefType, IEnumerable records) => + RecordCandidates(recordRefType, records).ToDictionary(record => record.Key.Value, StringComparer.Ordinal); + + private static string RefLabel(string key, IReadOnlyDictionary targets) => + key.Length == 0 ? "None" : targets.TryGetValue(key, out var target) ? RecordLabel(target) : "Missing " + key; + + // A node's id is its members' value identities, so it survives relabelling: a ref by key, an enum by its underlying + // value, anything else by the value's invariant string. + private static string GroupId(IReadOnlyList members, IReadOnlyList values) => + string.Join("/", values.Select((value, i) => members[i].Name + "=" + Uri.EscapeDataString(IdentityOf(value)))); + + private static string IdentityOf(object? value) => + value switch + { + null => string.Empty, + ICultRecordRef reference => reference.Key.Value, + Enum enumeration => Convert.ToString(Convert.ChangeType(enumeration, Enum.GetUnderlyingType(enumeration.GetType())), System.Globalization.CultureInfo.InvariantCulture)!, + _ => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)! + }; + + private GroupLevel LevelOf(CultInspectorMember member, object? value, IReadOnlyDictionary? refTargets) + { + var identity = IdentityOf(value); + switch (ShapeOf(member.ValueType).Kind) + { + case CultInspectorValueKind.RecordRef: + var label = RefLabel(identity, refTargets!); + var rank = identity.Length == 0 ? 0 : refTargets!.ContainsKey(identity) ? 1 : 2; + return new GroupLevel(identity, label, CreateRecordRef(member.ValueType, identity), rank, 0m, label, identity); + case CultInspectorValueKind.Enum: + var number = Convert.ToDecimal(Convert.ChangeType(value, Enum.GetUnderlyingType(member.ValueType)), System.Globalization.CultureInfo.InvariantCulture); + return new GroupLevel(identity, Enum.GetName(member.ValueType, value!) ?? identity, value, 1, number); + case CultInspectorValueKind.Integer: + return new GroupLevel(identity, identity, value, 1, Convert.ToDecimal(value, System.Globalization.CultureInfo.InvariantCulture)); + case CultInspectorValueKind.Bool: + return new GroupLevel(identity, identity, value, 1, (bool)value! ? 1m : 0m); + default: + return new GroupLevel(identity, identity.Length == 0 ? "(empty)" : identity, identity, 1, 0m, identity, identity); + } + } + + private sealed class GroupLevel + { + public GroupLevel(string identity, string label, object? value, int rank, decimal number, string text = "", string tie = "") + { + Identity = identity; + Label = label; + Value = value; + Rank = rank; + Number = number; + Text = text; + Tie = tie; + } + + public string Identity { get; } + public string Label { get; } + public object? Value { get; } + private int Rank { get; } + private decimal Number { get; } + private string Text { get; } + private string Tie { get; } + + // None first and Missing last among refs; then the value, the text ignoring case, then the text exactly. + public static readonly IComparer Order = Comparer.Create((a, b) => + { + var order = a.Rank.CompareTo(b.Rank); + if (order == 0) order = a.Number.CompareTo(b.Number); + if (order == 0) order = StringComparer.OrdinalIgnoreCase.Compare(a.Text, b.Text); + return order != 0 ? order : StringComparer.Ordinal.Compare(a.Tie, b.Tie); + }); + } + private CultInspectorShape Classify(Type type) { if (type == typeof(string)) return new CultInspectorShape(type, CultInspectorValueKind.String); diff --git a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs index 5c2c171b..cddcb626 100644 --- a/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs +++ b/tests/GameCult.Caching.Tests/CultInspectorModelTests.cs @@ -14,7 +14,7 @@ namespace GameCult.Caching.Tests public class CultInspectorModelTests { private static readonly CultDocumentRegistry Registry = - CultDocumentRegistry.ForTypes(new[] { typeof(InspectItem), typeof(InspectOther), typeof(InspectFixed) }); + CultDocumentRegistry.ForTypes(new[] { typeof(InspectItem), typeof(InspectOther), typeof(InspectFixed), typeof(InspectLabeled) }); private static CultInspectorModel Model() => CultCacheMessagePack.CreateInspectorModel(Registry); @@ -128,6 +128,35 @@ namespace GameCult.Caching.Tests 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() { @@ -296,6 +325,344 @@ namespace GameCult.Caching.Tests 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 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 @@ -330,6 +697,28 @@ namespace GameCult.Caching.Tests 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 @@ -428,6 +817,163 @@ namespace GameCult.Caching.Tests 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 { }