using FactorioCalc.Domain; using FactorioCalc.Solver; using Xunit; namespace FactorioCalc.Tests; /// /// Tests for edge cases and correctness of the matrix-based solver. /// public class SolverBugFixTests { // --- Multi-product recipes (e.g. oil refining) --- [Fact] public void Solve_MultiProductRecipe_UsesCorrectProduct() { var resources = new Dictionary { { 1, new Resource(1, "Crude Oil") }, { 2, new Resource(2, "Light Oil") }, { 3, new Resource(3, "Heavy Oil") }, }; var machines = new Dictionary { { 1, new Machine(1, "Chemical Plant", 0.5, 6.0, 4, new[] { "advanced-crafting" }) }, }; // One recipe produces BOTH Light Oil and Heavy Oil var recipes = new Dictionary { { 1, new Recipe(1, "Oil Refining", "advanced-crafting", 4.0, "advanced-crafting", new[] { new Ingredient(1, 1) }, new[] { new Product(3, 1), new Product(2, 1) }) }, }; var repo = new TestRepository(recipes, resources, machines, new Dictionary()); var solver = new ProductionSolver(repo); // Target Light Oil — it's the SECOND product var targets = new[] { new ProductionTarget(2, 10) }; var result = solver.Solve(targets); Assert.Single(result.Executions); // Heavy Oil should appear as a byproduct in resource flows Assert.True(result.ResourceFlows.TryGetValue(3, out var heavyOilFlow)); Assert.True(heavyOilFlow > 0, "Heavy Oil should be produced as byproduct"); } // --- Double targets aggregate correctly via matrix --- [Fact] public void Solve_DoubleTargetsForResource_AggregatesDemand() { var resources = new Dictionary { { 1, new Resource(1, "Iron Ore") }, { 7, new Resource(7, "Iron Plate") }, { 9, new Resource(9, "Steel Plate") }, { 5, new Resource(5, "Coal") }, }; var machines = new Dictionary { { 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) }, }; var recipes = new Dictionary { { 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", new[] { new Ingredient(1, 1) }, new[] { new Product(7, 1) }) }, { 2, new Recipe(2, "Steel Plate", "smelting", 5.0, "smelting", new[] { new Ingredient(7, 2), new Ingredient(5, 1) }, new[] { new Product(9, 1) }) }, }; var repo = new TestRepository(recipes, resources, machines, new Dictionary()); var solver = new ProductionSolver(repo); var targets = new[] { new ProductionTarget(9, 2), // Steel Plate → needs 4 Iron Plate/sec new ProductionTarget(7, 5), // Iron Plate direct → 5 more/sec }; var result = solver.Solve(targets); Assert.Equal(2, result.Executions.Count); var ironPlateExec = result.Executions.First(e => e.RecipeId == 1); Assert.True(ironPlateExec.MachineCount >= 9, $"Expected at least 9 machines for Iron Plate (demand=9/sec), got {ironPlateExec.MachineCount}"); } // --- Heavy productivity modules don't cause division by zero --- [Fact] public void Solve_HeavyProductivityModules_DoesNotDivideByZero() { var resources = new Dictionary { { 1, new Resource(1, "Iron Ore") }, { 7, new Resource(7, "Iron Plate") }, }; var machines = new Dictionary { { 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) }, }; var recipes = new Dictionary { { 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", new[] { new Ingredient(1, 1) }, new[] { new Product(7, 1) }) }, }; // Extreme: -80% total speed var extremeModules = new[] { new Module(1, "Prod Mod 3", ModuleType.Productivity, -0.20, 0.30, -0.15), new Module(2, "Prod Mod 3", ModuleType.Productivity, -0.20, 0.30, -0.15), new Module(3, "Prod Mod 3", ModuleType.Productivity, -0.20, 0.30, -0.15), new Module(4, "Prod Mod 3", ModuleType.Productivity, -0.20, 0.30, -0.15), }; var moduleDict = new Dictionary(); for (var i = 0; i < extremeModules.Length; i++) moduleDict[i + 1] = extremeModules[i]; var repo = new TestRepository(recipes, resources, machines, moduleDict); var solver = new ProductionSolver(repo); var targets = new[] { new ProductionTarget(7, 10) }; // Should NOT throw var result = solver.SolveWithModules(targets, extremeModules); Assert.Single(result.Executions); var exec = result.Executions.First(); Assert.True(exec.EffectiveSpeed >= 0.05, $"EffectiveSpeed {exec.EffectiveSpeed} below minimum"); Assert.True(exec.MachineCount > 0 && exec.MachineCount < int.MaxValue, $"MachineCount {exec.MachineCount} is unreasonable"); } [Fact] public void Solve_ProductivityModules_IncreasesOutput() { var resources = new Dictionary { { 1, new Resource(1, "Iron Ore") }, { 7, new Resource(7, "Iron Plate") }, }; var machines = new Dictionary { { 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) }, }; var recipes = new Dictionary { { 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", new[] { new Ingredient(1, 1) }, new[] { new Product(7, 1) }) }, }; var prodModule = new Module(1, "Prod Mod 1", ModuleType.Productivity, -0.10, 0.10, -0.05); var repo = new TestRepository(recipes, resources, machines, new Dictionary { { 1, prodModule } }); var solver = new ProductionSolver(repo); var targets = new[] { new ProductionTarget(7, 10) }; var resultWithModules = solver.SolveWithModules(targets, new[] { prodModule }); var execWithModules = resultWithModules.Executions.First(); Assert.True(execWithModules.EffectiveProductivity > 0, "Productivity bonus should be positive"); } // --- Matrix solver correctness: verify exact rates --- [Fact] public void Solve_SimpleChain_ExactRates() { // Iron Plate: 1 ore → 1 plate (3s), speed=0.5 // Steel Plate: 2 plates + 1 coal → 1 steel (5s), speed=0.5 // Target: 1 steel/sec // → Steel recipe rate = 1 cycle/sec // → needs 2 iron plate/sec // → Iron Plate recipe rate = 2 cycles/sec // → needs 2 iron ore/sec var resources = new Dictionary { { 1, new Resource(1, "Iron Ore") }, { 7, new Resource(7, "Iron Plate") }, { 9, new Resource(9, "Steel Plate") }, { 5, new Resource(5, "Coal") }, }; var machines = new Dictionary { { 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) }, }; var recipes = new Dictionary { { 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", new[] { new Ingredient(1, 1) }, new[] { new Product(7, 1) }) }, { 2, new Recipe(2, "Steel Plate", "smelting", 5.0, "smelting", new[] { new Ingredient(7, 2), new Ingredient(5, 1) }, new[] { new Product(9, 1) }) }, }; var repo = new TestRepository(recipes, resources, machines, new Dictionary()); var solver = new ProductionSolver(repo); var targets = new[] { new ProductionTarget(9, 1) }; var result = solver.Solve(targets); var steelExec = result.Executions.First(e => e.RecipeId == 2); var ironExec = result.Executions.First(e => e.RecipeId == 1); // Steel Plate rate should be ~1 cycle/sec Assert.InRange(steelExec.RecipeRate, 0.99, 1.01); // Iron Plate rate should be ~2 cycles/sec Assert.InRange(ironExec.RecipeRate, 1.99, 2.01); } // --- ResourceClassifier tests --- [Fact] public void ResourceClassifier_IdentifiesRawAndCraftable() { var resources = new Dictionary { { 1, new Resource(1, "Iron Ore") }, { 7, new Resource(7, "Iron Plate") }, }; var recipes = new Dictionary { { 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", new[] { new Ingredient(1, 1) }, new[] { new Product(7, 1) }) }, }; var machines = new Dictionary(); var repo = new TestRepository(recipes, resources, machines, new Dictionary()); var classifier = new ResourceClassifier(); classifier.Classify(repo.Recipes, new[] { new ProductionTarget(7, 10) }); Assert.True(classifier.Craftable.Contains(7)); Assert.True(classifier.Raw.Contains(1)); Assert.True(classifier.Targets.Contains(7)); } // --- RecipeMatrixBuilder tests --- [Fact] public void RecipeMatrixBuilder_BuildsSquareMatrix() { var resources = new Dictionary { { 1, new Resource(1, "Iron Ore") }, { 7, new Resource(7, "Iron Plate") }, }; var recipes = new Dictionary { { 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", new[] { new Ingredient(1, 1) }, new[] { new Product(7, 1) }) }, }; var machines = new Dictionary(); var repo = new TestRepository(recipes, resources, machines, new Dictionary()); var classifier = new ResourceClassifier(); classifier.Classify(repo.Recipes, new[] { new ProductionTarget(7, 10) }); var builder = new RecipeMatrixBuilder(); builder.Build(repo.Recipes, new[] { new ProductionTarget(7, 10) }, classifier); // Single craftable resource, single recipe → 1×1 matrix Assert.Equal(1, builder.Matrix.RowCount); Assert.Equal(1, builder.Matrix.ColumnCount); Assert.Equal(10, builder.RightHandSide[0]); // target = 10/sec Assert.Equal(1, builder.Matrix[0, 0]); // +1 Iron Plate per cycle } }