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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-18 18:46:05 +02:00
commit beb733f4b5
3 changed files with 55 additions and 10 deletions

View file

@ -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()
{

View file

@ -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<WeaponItemData>().Single(weapon => weapon.Name == "Autocannon");
var seller = db.Cache.GetAll<FactionProductData>().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<ProductRole>();
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<EquippableItemData>().ToArray(), db.Cache.GetAll<FactionProductData>().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()

View file

@ -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<string>((product.Roles ?? new List<ProductRole>()).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<EquippableItemData> items, IEnumerable<FactionProductData> 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<string>((product.Roles ?? new List<ProductRole>()).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