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