Pin node-id escaping, case-distinct nodes and the flat ungrouped list

Ids that differ only by %2F versus /, keys that differ only by case, and
unpaired surrogates each get a test; the ungrouped list stays flat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-29 14:00:47 +02:00
commit 7defd82c20

View file

@ -538,6 +538,57 @@ namespace GameCult.Caching.Tests
Assert.That(ids.Distinct().Count(), Is.EqualTo(12), "a path never spells another node's id");
}
[Test]
public async Task GroupNodeIdsSeparateAnEscapeFromTheTextItSpells()
{
// "a/b" and "a%2Fb" collide under an id that escapes '/' alone; the unpaired surrogates, U+FFFD and "a%uD800"
// are the values a replacement character or a sloppy %u form would merge.
var kinds = new[] { "a/b", "a%2Fb", "a%2fb", "a%252Fb", "a", "A", "\uD800", "\uD801", "\uDC00", "<22>", "a%uD800", "a\uD800" };
using var cache = OpenGroups();
for (var i = 0; i < kinds.Length; i++)
await cache.UpsertAsync(typeof(GroupRedeclared), new GroupRedeclared { Kind = kinds[i] }, new CultRecordKey("k" + i));
var roots = GroupModel().GroupRecords(typeof(GroupRedeclared), cache.AllStoredDocuments);
Assert.That(roots, Has.Count.EqualTo(kinds.Length), "every distinct value is its own node, 'a' and 'A' included");
Assert.That(roots.Select(group => group.Id).Distinct().Count(), Is.EqualTo(kinds.Length), "and none spells another's id");
Assert.That(roots.Select(group => group.Label).Where(label => label == "a" || label == "A"), Is.EqualTo(new[] { "A", "a" }),
"labels equal ignoring case fall back to ordinal order");
}
[Test]
public async Task GroupRefsWhoseKeysDifferOnlyByCaseAreTwoNodes()
{
using var cache = OpenGroups();
await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "lower" }, new CultRecordKey("o-a"));
await cache.UpsertAsync(typeof(InspectOther), new InspectOther { Name = "upper" }, new CultRecordKey("O-A"));
await PutLeaf(cache, "r1", "one", "o-a", GroupHull.Cruiser);
await PutLeaf(cache, "r2", "two", "O-A", GroupHull.Cruiser);
var model = GroupModel();
var roots = model.GroupRecords(typeof(GroupLeaf), cache.AllStoredDocuments);
Assert.That(roots.Select(group => group.Label), Is.EqualTo(new[] { "lower", "upper" }), "each key finds its own target");
Assert.That(roots.Select(group => group.Id).Distinct().Count(), Is.EqualTo(2));
Assert.That(model.RecordRefLabel(typeof(CultRecordRef<InspectOther>), OwnerRef("O-A"), cache.AllStoredDocuments), Is.EqualTo("upper"));
}
[Test]
public async Task GroupAnUngroupedListedTypeHasNoTreeAndItsCandidatesStayFlat()
{
using var cache = OpenGroups();
await cache.UpsertAsync(typeof(GroupGlobal), new GroupGlobal { Kind = "k" }, new CultRecordKey("global"));
var model = GroupModel();
var grouping = model.GroupingOf(typeof(GroupGlobal));
Assert.That(grouping.Members, Is.Empty);
Assert.That(grouping.Notice, Is.Not.Null, "a refused declaration still says why");
Assert.That(model.GroupRecords(typeof(GroupGlobal), cache.AllStoredDocuments), Is.Empty, "no tree: the lowering lists candidates flat");
Assert.That(model.RecordCandidates(typeof(CultRecordRef<GroupGlobal>), cache.AllStoredDocuments).Select(record => record.Key.Value),
Is.EqualTo(new[] { "global" }));
}
[Test]
public async Task GroupCreateInGroupRoundTripsIntoTheSameNode()
{