Merge Studio grouping Cut 1: the model owns declared record grouping
Soul: merge. [CultInspectorGroupBy], GroupingOf/GroupRecords/CreateInGroup, IsRecordCandidate (one candidate predicate), RecordRefLabel; variants group by resolved value and follow their base. Follow-ups in Cut 2: pin '%' escaping and case-distinct keys, distinct ids for lone surrogates, fold state keyed by type+id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
c60af61e73
4 changed files with 816 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
60
src/GameCult.Caching/CultInspectorGrouping.cs
Normal file
60
src/GameCult.Caching/CultInspectorGrouping.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,7 @@ namespace GameCult.Caching
|
|||
|
||||
internal CultInspectorMetadata(MemberInfo? member)
|
||||
{
|
||||
Attributes = member == null ? Array.Empty<Attribute>() : member.GetCustomAttributes(true).OfType<Attribute>().ToArray();
|
||||
Attributes = member == null ? Array.Empty<Attribute>() : Attribute.GetCustomAttributes(member, true);
|
||||
var label = Find<CultInspectorLabelAttribute>()?.Label;
|
||||
Label = string.IsNullOrWhiteSpace(label) ? null : label;
|
||||
Hidden = Find<CultInspectorHiddenAttribute>() != 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<Attribute>() ?? Enumerable.Empty<Attribute>())
|
||||
foreach (var attribute in member == null ? Array.Empty<Attribute>() : 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<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
|
||||
|
|
@ -513,14 +514,24 @@ namespace GameCult.Caching
|
|||
// The records a CultRecordRef<T> may point at: every record whose document is a T, by label.
|
||||
public IReadOnlyList<CultStoredDocument> RecordCandidates(Type recordRefType, IEnumerable<CultStoredDocument> records)
|
||||
{
|
||||
var target = ShapeOf(recordRefType).RecordTarget
|
||||
?? throw new ArgumentException($"{recordRefType.Name} is not a CultRecordRef<T>.", 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<T>.", 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<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>() != 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);
|
||||
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<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);
|
||||
|
|
|
|||
|
|
@ -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<InspectItem>(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<InspectLabeledBase>);
|
||||
|
||||
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<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(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<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), ("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<string>();
|
||||
void Collect(IEnumerable<CultInspectorRecordGroup> 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<CultStoredDocument>()), 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<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.Name), "over-name"), 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
|
||||
|
|
@ -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<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("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
|
||||
{
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue