Cover ungrouped types, null strings, derived globals and id separators

Stryker survivors on the grouping diff: the no-declaration path, the
null-string identity, the inherited [CultGlobal] check and the id separator.
GroupLevel takes text and tie as optional arguments so the scalar kinds
stop passing constant strings that could be swapped without any effect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 13:15:03 +02:00
commit 30e1f3fb95
2 changed files with 42 additions and 7 deletions

View file

@ -675,11 +675,11 @@ namespace GameCult.Caching
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);
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), string.Empty, string.Empty);
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, string.Empty, string.Empty);
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);
}
@ -687,7 +687,7 @@ namespace GameCult.Caching
private sealed class GroupLevel
{
public GroupLevel(string identity, string label, object? value, int rank, decimal number, string text, string tie)
public GroupLevel(string identity, string label, object? value, int rank, decimal number, string text = "", string tie = "")
{
Identity = identity;
Label = label;

View file

@ -519,6 +519,8 @@ namespace GameCult.Caching.Tests
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)
@ -532,8 +534,8 @@ namespace GameCult.Caching.Tests
Collect(GroupModel().GroupRecords(typeof(GroupMixed), cache.AllStoredDocuments));
Assert.That(ids, Has.Count.EqualTo(6));
Assert.That(ids.Distinct().Count(), Is.EqualTo(6), "a path never spells another node's id");
Assert.That(ids, Has.Count.EqualTo(12));
Assert.That(ids.Distinct().Count(), Is.EqualTo(12), "a path never spells another node's id");
}
[Test]
@ -593,6 +595,33 @@ namespace GameCult.Caching.Tests
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")]
@ -601,6 +630,7 @@ namespace GameCult.Caching.Tests
[TestCase(typeof(GroupDuplicate), "Kind")]
[TestCase(typeof(GroupValidThenInvalid), "Mass")]
[TestCase(typeof(GroupGlobal), "global")]
[TestCase(typeof(GroupGlobalChild), "global")]
public void GroupInvalidDeclarationsGiveANoticeAndAFlatList(Type listed, string mentions)
{
var model = GroupModel();
@ -858,12 +888,17 @@ namespace GameCult.Caching.Tests
[CultGlobal]
[CultDocument("tests.group_global", "tests.group_global.v1")]
[MessagePackObject]
public sealed class GroupGlobal
public class GroupGlobal
{
[Key(0)]
public string Kind = string.Empty;
}
[CultInspectorGroupBy("Kind")]
public sealed class GroupGlobalChild : GroupGlobal
{
}
[CultInspectorGroupBy("Kind")]
[CultDocument("tests.group_no_ctor", "tests.group_no_ctor.v1")]
[MessagePackObject]