From beb733f4b50f556012983d7bb60d43760721c891 Mon Sep 17 00:00:00 2001
From: Meta Vens
Date: Sun, 18 Oct 2026 18:46:05 +0200
Subject: [PATCH] Census role gaps become RoleGaps, so a bin skipped by the
census has a test; a bin authored on another line is refused
Co-Authored-By: Claude Sonnet 5.5
---
.../ProductTiersCommandTests.cs | 7 ++++
.../RolesBackfillCommandTests.cs | 30 ++++++++++++++++
tools/AetherDb/Program.cs | 34 ++++++++++++-------
3 files changed, 58 insertions(+), 13 deletions(-)
diff --git a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
index 856cca7dc..2899737e0 100644
--- a/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/ProductTiersCommandTests.cs
@@ -126,6 +126,13 @@ public sealed class ProductTiersCommandTests : IDisposable
Refused(new ProductLinesCommand.TierRow("Sparrow", "Goldcrest", .5f, "x"), "already belongs");
}
+ [Fact]
+ public void A_bin_already_authored_on_another_line_is_refused()
+ {
+ Assert.Equal(0, ProductLinesCommand.Run(Apply, _root));
+ Refused(new ProductLinesCommand.TierRow("Spotlight", "Goldcrest", .67f, "x"), "already belongs");
+ }
+
[Fact]
public void A_line_the_catalog_does_not_hold_is_refused()
{
diff --git a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
index 1f0f974f5..31152b63c 100644
--- a/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
+++ b/tests/Aetheria.Shared.Tests/RolesBackfillCommandTests.cs
@@ -239,6 +239,36 @@ public sealed class RolesBackfillCommandTests : IDisposable
finally { db.Cache.Dispose(); }
}
+ // The census reports a seller that authors no spread for a role of its design as a gap, and never a bin: a bin reads its line's.
+ [Fact]
+ public void The_census_reports_a_roleless_seller_and_never_a_bin()
+ {
+ var db = AetherDb.Open(catalogWritable: true, root: _root);
+ var autocannon = db.Cache.GetAll().Single(weapon => weapon.Name == "Autocannon");
+ var seller = db.Cache.GetAll().OrderBy(p => p.Name, StringComparer.Ordinal).First(p => p.Design.Key.Equals(db.Cache.RefOf(autocannon).Key));
+ var sellerName = seller.Name;
+ seller.Roles = new List();
+ db.Cache.Commit(batch =>
+ {
+ batch.Upsert(typeof(FactionProductData), seller, db.Cache.RefOf(seller).Key);
+ batch.Upsert(typeof(FactionProductData), new FactionProductData
+ {
+ Name = "Binned " + sellerName, Design = seller.Design, Manufacturer = seller.Manufacturer,
+ Bin = new ProductBin { Line = db.Cache.RefOf(seller), Floor = 0f }
+ });
+ });
+ db.Cache.Dispose();
+
+ db = AetherDb.Open(root: _root);
+ try
+ {
+ var gaps = Program.RoleGaps(db.Cache, db.Cache.GetAll().ToArray(), db.Cache.GetAll().ToArray()).ToArray();
+ Assert.Contains(gaps, gap => gap.Product.Name == sellerName && gap.Missing.Length == 2);
+ Assert.DoesNotContain(gaps, gap => gap.Product.Name == "Binned " + sellerName);
+ }
+ finally { db.Cache.Dispose(); }
+ }
+
// roles-migrate authors a role on every product that sells a design lacking one; a bin sells none of its own.
[Fact]
public void A_binned_seller_gains_no_roles_from_roles_migrate()
diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs
index d88718817..7a28b1aa0 100644
--- a/tools/AetherDb/Program.cs
+++ b/tools/AetherDb/Program.cs
@@ -129,25 +129,33 @@ public static class Program
// or a product added after roles landed), so every sold, role-bearing design is checked against every
// product that actually sells it, not only the ones this run's roles-migrate happened to touch.
var roleGaps = 0;
- foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0))
+ foreach (var (product, item, missing) in RoleGaps(db.Cache, items, products))
{
- var design = db.Cache.RefOf(item).Key;
- // A bin authors no spread: it reads its line's (ProductBin).
- var sellers = products.Where(p => p.Bin == null && p.Design.Key.Equals(design)).ToArray();
- foreach (var product in sellers)
- {
- var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
- var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray();
- if (missing.Length == 0) continue;
- roleGaps++;
- Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" +
- " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality");
- }
+ roleGaps++;
+ Console.WriteLine($" GAP: {product.Name} sells {item.Name} but authors no spread for: {string.Join(", ", missing)}" +
+ " -- a newly minted lot rolls the ProductRole default (mean .5) for these, not this maker's own quality");
}
Console.WriteLine($"\n{roleGaps} (product, role) pairs where a sold, role-bearing design's product authors no matching spread");
return 0;
}
+ // Every seller of a role-bearing design that authors no spread for one of its roles. A bin authors no spread: it reads its
+ // line's (ProductBin), so it is never a gap.
+ internal static IEnumerable<(FactionProductData Product, EquippableItemData Design, string[] Missing)> RoleGaps(
+ GameCult.Caching.CultCache cache, IEnumerable items, IEnumerable products)
+ {
+ foreach (var item in items.Where(i => i.Roles != null && i.Roles.Count > 0))
+ {
+ var design = cache.RefOf(item).Key;
+ foreach (var product in products.Where(p => p.Bin == null && p.Design.Key.Equals(design)))
+ {
+ var covered = new HashSet((product.Roles ?? new List()).Select(r => r.Role));
+ var missing = item.Roles.Select(r => r.Name).Where(r => !covered.Contains(r)).ToArray();
+ if (missing.Length > 0) yield return (product, item, missing);
+ }
+ }
+ }
+
// Cut 7 (docs/stats-and-power-cut.md): "products author quality, designs author roles." Zero designs
// declared a role and zero products authored role quality before this command; every field->role table below
// is the one place that content is decided, so the operator reviews a legible table instead of hand-picking