From ccd9792d2f0e27172bb23cc384f7beec183fea60 Mon Sep 17 00:00:00 2001 From: soul Date: Tue, 27 Oct 2026 10:56:21 +0100 Subject: [PATCH] Economy tests: role inputs persist, are read by every rule as any filler, offered recipe-less designs demand their roles Co-Authored-By: Claude Opus 5.5 --- .../EconomyChecksTests.cs | 4 + .../EconomyCommandTests.cs | 6 + tests/Aetheria.Shared.Tests/EconomyFixture.cs | 9 +- .../EconomyNullGuardTests.cs | 7 + .../RecipeRoleInputTests.cs | 156 ++++++++++++++++++ 5 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs diff --git a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs index 40c533bb9..a9e0db99f 100644 --- a/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyChecksTests.cs @@ -53,6 +53,10 @@ public sealed class EconomyChecksTests yield return Case("upkeep that is not a commodity", f => f.EditRecipe("commodity:plate", r => r.Upkeep.Add(new CommodityCount { Commodity = EconomyFixture.Item("hull:skiff"), Count = 1 })), "recipe:commodity:plate"); yield return Case("input naming nothing", f => f.EditRecipe("commodity:plate", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item("commodity:ghost"), Count = 1 })), "recipe:commodity:plate"); yield return Case("inputs on a role recipe", f => f.EditRecipe("gear:laser", r => r.Inputs.Add(new CommodityCount { Commodity = EconomyFixture.Item(Ore), Count = 1 })), "recipe:gear:laser"); + yield return Case("role input count zero", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = "Plate", Count = 0 })), "recipe:commodity:plate", says: "Role 'Plate' count"); + yield return Case("role input with no filler", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = "Mystery", Count = 1 })), "recipe:commodity:plate", says: "Role 'Mystery' has no commodity"); + yield return Case("role input with an empty name", f => f.EditRecipe("commodity:plate", r => r.RoleInputs.Add(new ItemRole { Name = " ", Count = 1 })), "recipe:commodity:plate", says: "empty name"); + yield return Case("role inputs on a role recipe", f => f.EditRecipe("gear:laser", r => r.RoleInputs.Add(new ItemRole { Name = "Plate", Count = 1 })), "recipe:gear:laser", says: "no RoleInputs"); yield return Case("a role with no filler", f => f.EditItem("gear:laser", g => g.Roles.Add(new ItemRole { Name = "Mystery" })), "recipe:gear:laser"); yield return Case("a role count of zero", f => f.EditItem("gear:laser", g => g.Roles[0].Count = 0), "recipe:gear:laser"); yield return Case("fills on a design", f => f.EditRecipe("gear:laser", r => r.Fills.Add("Plate")), "recipe:gear:laser"); diff --git a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs index dbd0d0f6f..74776b9db 100644 --- a/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyCommandTests.cs @@ -254,6 +254,12 @@ public sealed class EconomyCommandTests Assert.Contains("commodity:ore <- nothing | Mill, cycle 1, workforce 0 | a good", text); Assert.Contains("commodity:plate <- 2 x commodity:ore | Mill, cycle 1, workforce 0 | a good", text); Assert.Contains("gear:laser <- 2 x Plate | Mill", text); + f.Good("ore2", fills: new[] { "OreRole" }); + f.Good("steel", roleInputs: new[] { ("OreRole", 2) }); + var withRole = new StringWriter(); + EconomyCommand.Report(f.Cache, withRole); + Assert.Contains("commodity:steel <- 2 x OreRole | Mill", withRole.ToString()); + Assert.Contains("gear:laser <- 2 x Plate | Mill", withRole.ToString()); Assert.True(text.IndexOf("commodity:ore <-", StringComparison.Ordinal) < text.IndexOf("commodity:plate <-", StringComparison.Ordinal)); Assert.True(text.IndexOf("commodity:plate <-", StringComparison.Ordinal) < text.IndexOf("gear:laser <-", StringComparison.Ordinal)); f.EditRecipe("commodity:plate", r => r.LoreHook = ""); diff --git a/tests/Aetheria.Shared.Tests/EconomyFixture.cs b/tests/Aetheria.Shared.Tests/EconomyFixture.cs index 91f691ae9..3aba1fe05 100644 --- a/tests/Aetheria.Shared.Tests/EconomyFixture.cs +++ b/tests/Aetheria.Shared.Tests/EconomyFixture.cs @@ -52,9 +52,11 @@ public sealed class EconomyFixture : IDisposable private void Sell(CultRecordKey design) => Cache.Upsert(new FactionProductData { Name = design.Value, Design = new CultRecordRef(design), Manufacturer = Maker }); - // A commodity and its recipe. Roles make it a consumer good (a role recipe); Inputs make it a compound commodity. + // A commodity and its recipe. Roles make it a consumer good (a role recipe); Inputs make it a compound commodity; roleInputs are + // the roles its recipe takes, any filler will do. public string Good(string id, (string Id, int Count)[] inputs = null, string[] fills = null, int outputCount = 1, float cycle = 1, - int workforce = 0, (string Id, int Count)[] upkeep = null, string lore = "a good", (string Role, int Count)[] roles = null, CultRecordRef? kind = null) + int workforce = 0, (string Id, int Count)[] upkeep = null, string lore = "a good", (string Role, int Count)[] roles = null, CultRecordRef? kind = null, + (string Role, int Count)[] roleInputs = null) { var key = new CultRecordKey(Key(id)); var roleList = (roles ?? Array.Empty<(string, int)>()).Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList(); @@ -70,7 +72,8 @@ public sealed class EconomyFixture : IDisposable Upkeep = (upkeep ?? Array.Empty<(string, int)>()).Select(i => new CommodityCount { Commodity = Item(Key(i.Id)), Count = i.Count }).ToList(), StationKind = kind ?? Mill, LoreHook = lore, - Fills = (fills ?? Array.Empty()).ToList() + Fills = (fills ?? Array.Empty()).ToList(), + RoleInputs = (roleInputs ?? Array.Empty<(string, int)>()).Select(r => new ItemRole { Name = r.Role, Count = r.Count }).ToList() }, RecipeData.KeyOf(key)); return key.Value; } diff --git a/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs b/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs index 61b0cea12..b1b633070 100644 --- a/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs +++ b/tests/Aetheria.Shared.Tests/EconomyNullGuardTests.cs @@ -12,12 +12,14 @@ public sealed class EconomyNullGuardTests if (list == "Fills") r.Fills = null; if (list == "Inputs") r.Inputs = null; if (list == "Upkeep") r.Upkeep = null; + if (list == "RoleInputs") r.RoleInputs = null; }); [Theory] [InlineData("Fills")] [InlineData("Inputs")] [InlineData("Upkeep")] + [InlineData("RoleInputs")] public void ANullListIsAnLRecipeFaultNotAThrow(string list) { using var f = EconomyFixture.Healthy(); @@ -30,6 +32,7 @@ public sealed class EconomyNullGuardTests [InlineData("Fills")] [InlineData("Inputs")] [InlineData("Upkeep")] + [InlineData("RoleInputs")] public void EveryRuleSurvivesANullList(string list) { using var f = EconomyFixture.Healthy(); @@ -49,5 +52,9 @@ public sealed class EconomyNullGuardTests f.EditRecipe("commodity:plate", r => r.Inputs.Add(null)); var report = EconomyChecks.Check(f.Cache); Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("Inputs")); + + f.EditRecipe("commodity:plate", r => { r.Inputs.Remove(null); r.RoleInputs.Add(null); }); + report = EconomyChecks.Check(f.Cache); + Assert.Contains(report.Faults, x => x.Invariant == EconomyChecks.LRecipe && x.Record == "recipe:commodity:plate" && x.Message.Contains("RoleInputs")); } } diff --git a/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs b/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs new file mode 100644 index 000000000..f183e9e8c --- /dev/null +++ b/tests/Aetheria.Shared.Tests/RecipeRoleInputTests.cs @@ -0,0 +1,156 @@ +using System; +using System.Linq; +using GameCult.Caching; +using Xunit; + +// A commodity's recipe takes roles as well as named commodities (aetheria-living-world, cut recipe-role-inputs): RecipeData.RoleInputs, +// the one reader of role slots (Recipes.RoleInputsOf), and the checks reading a role input as any commodity that fills it. +public sealed class RecipeRoleInputTests +{ + private static string Iron => EconomyFixture.Key("iron"); + + // hematite and ilmenite are raw ores filling iron-ore (ilmenite also titanium-ore); iron takes 2 iron-ore. + private static EconomyFixture Smelter() + { + var f = new EconomyFixture(); + f.Good("hematite", fills: new[] { "iron-ore" }); + f.Good("ilmenite", fills: new[] { "iron-ore", "titanium-ore" }); + f.Good("iron", roleInputs: new[] { ("iron-ore", 2) }, fills: new[] { "Plate" }); + f.Gear("laser", new[] { ("Plate", 2) }); + return f; + } + + private static string[] Records(System.Collections.Generic.IEnumerable faults, string invariant) => + faults.Where(x => x.Invariant == invariant).Select(x => x.Record).ToArray(); + + [Fact] + public void ShippedRecipesReadAnEmptyRoleInputList() + { + using var cache = AetheriaStores.Open(TestCatalog.Repo, registry: TestCatalog.Registry()); + var recipes = cache.GetAll().ToArray(); + Assert.NotEmpty(recipes); + Assert.All(recipes, r => { Assert.NotNull(r.RoleInputs); Assert.Empty(r.RoleInputs); }); + } + + [Fact] + public void ARoleInputSurvivesReopen() + { + using var f = Smelter(); + f.Reopen(); + var role = Assert.Single(f.Cache.Get(RecipeData.KeyOf(new CultRecordKey(Iron))).RoleInputs); + Assert.Equal(("iron-ore", 2), (role.Name, role.Count)); + } + + [Fact] + public void RoleInputsOfReadsTheDesignOrTheRecipe() + { + using var f = Smelter(); + var laser = Assert.Single(Recipes.RoleInputsOf(f.Cache, f.Cache.Get(RecipeData.KeyOf(new CultRecordKey("gear:laser"))))); + Assert.Equal(("Plate", 2), (laser.Name, laser.Count)); + var ironRecipe = f.Cache.Get(RecipeData.KeyOf(new CultRecordKey(Iron))); + var ore = Assert.Single(Recipes.RoleInputsOf(f.Cache, ironRecipe)); + Assert.Equal(("iron-ore", 2), (ore.Name, ore.Count)); + Assert.False(Recipes.IsRaw(f.Cache, ironRecipe)); + Assert.True(Recipes.IsRaw(f.Cache, f.Cache.Get(RecipeData.KeyOf(new CultRecordKey(EconomyFixture.Key("hematite")))))); + Assert.False(Recipes.IsConsumerGood(f.Cache, ironRecipe)); + } + + [Fact] + public void DepthReadsARoleInputAsItsShallowestFiller() + { + using var f = new EconomyFixture(); + f.Good("hematite", fills: new[] { "iron-ore" }); + f.Good("grit"); + f.Good("pulp", inputs: new[] { ("grit", 1) }); + f.Good("sludge", inputs: new[] { ("pulp", 1) }, fills: new[] { "iron-ore" }); + f.Good("iron", roleInputs: new[] { ("iron-ore", 1) }); + Assert.Equal(1, EconomyChecks.Deep(f.Cache)[Iron]); + f.EditRecipe(EconomyFixture.Key("hematite"), r => r.Fills.Clear()); + Assert.Equal(3, EconomyChecks.Deep(f.Cache)[Iron]); + f.EditRecipe(EconomyFixture.Key("sludge"), r => r.Fills.Clear()); + Assert.Null(EconomyChecks.Deep(f.Cache)[Iron]); + } + + [Fact] + public void SourceComesThroughAnyFillerOfARoleInput() + { + using var f = new EconomyFixture(); + f.Good("hematite", fills: new[] { "iron-ore" }); + f.Good("loopa", inputs: new[] { ("loopb", 1) }, fills: new[] { "iron-ore" }); + f.Good("loopb", inputs: new[] { ("loopa", 1) }); + f.Good("iron", roleInputs: new[] { ("iron-ore", 1) }); + bool NoSource() => EconomyChecks.SourceSink(f.Cache).Any(x => x.Record == "recipe:" + Iron && x.Message.Contains("no source")); + Assert.False(NoSource()); + f.EditRecipe(EconomyFixture.Key("hematite"), r => r.Fills.Clear()); + Assert.True(NoSource()); + } + + [Fact] + public void AGoodFillingOnlyARoleInputHasASink() + { + using var f = Smelter(); + f.Good("titanium", roleInputs: new[] { ("titanium-ore", 1) }); + bool NoSink() => EconomyChecks.SourceSink(f.Cache).Any(x => x.Record == "recipe:" + EconomyFixture.Key("ilmenite") && x.Message.Contains("no sink")); + Assert.False(NoSink()); + f.EditRecipe(Iron, r => r.RoleInputs.Clear()); + f.EditRecipe(EconomyFixture.Key("titanium"), r => r.RoleInputs.Clear()); + Assert.True(NoSink()); + } + + [Fact] + public void ProductiveReadsTheWorstFillerOfARoleInput() + { + using var f = new EconomyFixture(); + f.Good("hematite", fills: new[] { "iron-ore" }); + f.Good("iron", roleInputs: new[] { ("iron-ore", 2) }); + Assert.Empty(EconomyChecks.Productive(f.Cache)); + f.Good("scrap", inputs: new[] { ("iron", 2) }, fills: new[] { "iron-ore" }); + var fault = Assert.Single(EconomyChecks.Productive(f.Cache)); + Assert.Contains("commodity:iron", fault.Message); + Assert.Contains("commodity:scrap", fault.Message); + } + + [Fact] + public void ConsumerDemandWalksThroughARoleInput() + { + using var f = new EconomyFixture(); + f.Good("rawx"); + f.Good("acid", inputs: new[] { ("rawx", 1) }, fills: new[] { "reagent" }); + f.Good("g1", roleInputs: new[] { ("reagent", 1) }, fills: new[] { "Barrel" }); + f.Good("g2", roleInputs: new[] { ("reagent", 1) }, fills: new[] { "Plate" }); + f.Gear("cannon", new[] { ("Barrel", 1) }); + f.Hull("tug", HullType.Ship, new[] { ("Plate", 1) }); + Assert.Equal(new[] { "recipe:" + EconomyFixture.Key("acid") }, Records(EconomyChecks.ConsumerDemand(f.Cache), EconomyChecks.LDemand)); + f.Good("toaster", roles: new[] { ("Barrel", 1) }); + Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache)); + } + + private static EconomyFixture Casings(Action design) + { + var f = EconomyFixture.Healthy(); + f.Good("cap", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing" }); + f.Good("alloy", inputs: new[] { ("ore", 1) }, fills: new[] { "Casing", "Plate" }); + design(f); + return f; + } + + [Fact] + public void AnOfferedDesignWithoutARecipeStillNamesItsRoles() + { + using (var offered = Casings(f => f.Gear("shell", new[] { ("Casing", 1) }, recipe: false))) + { + Assert.DoesNotContain(EconomyChecks.SourceSink(offered.Cache), x => x.Record == "recipe:" + EconomyFixture.Key("cap") && x.Message.Contains("no sink")); + Assert.Equal(new[] { "recipe:" + EconomyFixture.Key("alloy") }, Records(EconomyChecks.ConsumerDemand(offered.Cache), EconomyChecks.LDemand)); + } + foreach (var design in new Action[] + { + f => f.Gear("shell", new[] { ("Casing", 1) }, offered: false, recipe: false), + f => f.Hull("outpost", HullType.Station, new[] { ("Casing", 1) }, recipe: false) + }) + using (var f = Casings(design)) + { + Assert.Contains(EconomyChecks.SourceSink(f.Cache), x => x.Record == "recipe:" + EconomyFixture.Key("cap") && x.Message.Contains("no sink")); + Assert.Empty(EconomyChecks.ConsumerDemand(f.Cache)); + } + } +}