Add model-owned record grouping: declaration, tree, create-in-group, ref labels

[CultInspectorGroupBy(nameof(A), nameof(B))] on a document class names the
members its record list groups by, outermost first. CultInspectorModel owns
the rest: GroupingOf validates the declaration (a refused one gives a flat
list and a notice), GroupRecords partitions RecordCandidates by value
identity with occupied values only, CreateInGroup builds the record a node
holds, RecordRefLabel owns None and Missing <key>. A variant groups by its
resolved Document.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x 2026-09-29 12:09:25 +02:00
commit 79e9318698
4 changed files with 687 additions and 0 deletions

View file

@ -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<string> 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

View file

@ -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<CultInspectorMember> 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<CultInspectorMember> 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<object?> values,
IReadOnlyList<CultInspectorRecordGroup> children,
IReadOnlyList<CultStoredDocument> 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<object?> Values { get; }
public IReadOnlyList<CultInspectorRecordGroup> Children { get; }
public IReadOnlyList<CultStoredDocument> Records { get; }
// The records under this node, at any depth.
public int Count { get; }
}
}

View file

@ -294,6 +294,7 @@ namespace GameCult.Caching
private readonly CultCodec _codec;
private readonly Dictionary<Type, CultInspectorShape> _shapes = new Dictionary<Type, CultInspectorShape>();
private readonly Dictionary<Type, CultInspectorGrouping> _groupings = new Dictionary<Type, CultInspectorGrouping>();
private readonly Dictionary<MemberInfo, CultInspectorMetadata> _metadata = new Dictionary<MemberInfo, CultInspectorMetadata>();
// The codec is the store's (CultCacheMessagePack.CreateInspectorModel for .cc stores), the one a CultCache resolves
@ -538,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<CultInspectorMember>(), null);
CultInspectorGrouping Refused(string reason) =>
new CultInspectorGrouping(listed, Array.Empty<CultInspectorMember>(), $"[CultInspectorGroupBy] on {listed.Name}: {reason} The list is not grouped.");
if (listed.GetCustomAttribute<CultGlobalAttribute>(true) != null)
return Refused("a global document is never grouped.");
var members = new List<CultInspectorMember>();
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<listed>, records); a variant groups by its
// resolved values. Empty when GroupingOf(listed) groups nothing: the lowering lists the candidates flat.
public IReadOnlyList<CultInspectorRecordGroup> GroupRecords(Type listed, IEnumerable<CultStoredDocument> records)
{
var grouping = GroupingOf(listed);
if (grouping.Members.Count == 0)
return Array.Empty<CultInspectorRecordGroup>();
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<object?>(), targets);
}
private IReadOnlyList<CultInspectorRecordGroup> Partition(
CultInspectorGrouping grouping,
IReadOnlyList<CultStoredDocument> records,
int depth,
IReadOnlyList<object?> path,
IReadOnlyDictionary<CultInspectorMember, Dictionary<string, CultStoredDocument>> 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<CultInspectorRecordGroup>();
return new CultInspectorRecordGroup(
GroupId(grouping.Members, values), bucket.Key.Label, depth, values, children,
children.Count == 0 ? bucket.Records : Array.Empty<CultStoredDocument>(), 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<T> value shows: None, the target's label, or Missing <key> when the key names no candidate.
public string RecordRefLabel(Type recordRefType, object? value, IEnumerable<CultStoredDocument> records) =>
RefLabel(RecordKey(value), RefTargets(recordRefType, records));
private Dictionary<string, CultStoredDocument> RefTargets(Type recordRefType, IEnumerable<CultStoredDocument> records) =>
RecordCandidates(recordRefType, records).ToDictionary(record => record.Key.Value, StringComparer.Ordinal);
private static string RefLabel(string key, IReadOnlyDictionary<string, CultStoredDocument> 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<CultInspectorMember> members, IReadOnlyList<object?> 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<string, CultStoredDocument>? 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, string.Empty, string.Empty);
case CultInspectorValueKind.Integer:
return new GroupLevel(identity, identity, value, 1, Convert.ToDecimal(value, System.Globalization.CultureInfo.InvariantCulture), string.Empty, string.Empty);
case CultInspectorValueKind.Bool:
return new GroupLevel(identity, identity, value, 1, (bool)value! ? 1m : 0m, string.Empty, string.Empty);
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<GroupLevel> Order = Comparer<GroupLevel>.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);

View file

@ -325,6 +325,291 @@ namespace GameCult.Caching.Tests
Is.EqualTo(new Dictionary<string, int> { ["a"] = 1 }));
}
// ---- grouping ----
private static readonly CultDocumentRegistry GroupRegistry = CultDocumentRegistry.ForTypes(new[]
{
typeof(GroupLeaf), typeof(GroupRedeclared), typeof(GroupOptOut), typeof(GroupMixed), typeof(GroupGlobal), typeof(GroupNoCtor), 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<InspectOther> 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<string> Dump(IEnumerable<CultInspectorRecordGroup> 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<CultInspectorRecordGroup> Leaves(IEnumerable<CultInspectorRecordGroup> 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<CultInspectorRecordGroup> Containing(IEnumerable<CultInspectorRecordGroup> groups, string key) =>
groups.Where(group => Holds(group, key)).SelectMany(group => new[] { group }.Concat(Containing(group.Children, key)));
private async Task<CultCache> 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<GroupLeaf>), 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<InspectOther>);
Assert.That(model.RecordRefLabel(refType, default(CultRecordRef<InspectOther>), 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)
})
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): m1"
}), "strings ignore case then compare exactly, integers order numerically, false before true");
}
[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(GroupLeaf), kindNode, out _), Throws.ArgumentException, "same depth, other members");
}
[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"));
}
[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<CultStoredDocument>()), 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<GroupLeaf>(nameof(GroupLeaf.Hull), GroupHull.Frigate) });
await cache.UpsertVariantAsync(new CultRecordKey("inherit"), baseKey, new[] { cache.Override<GroupLeaf>(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
@ -479,6 +764,154 @@ 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<InspectOther> 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("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
{
}