using FactorioCalc.Domain; using FactorioCalc.Solver; using Xunit; namespace FactorioCalc.Tests; public class SolverTests { private IRecipeRepository CreateRepository() { 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") }, { 2, new Resource(2, "Copper Ore") }, { 8, new Resource(8, "Copper Plate") }, { 10, new Resource(10, "Copper Cable") }, { 11, new Resource(11, "Electronic Circuit") }, }; var machines = new Dictionary { { 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) }, { 5, new Machine(5, "Assembler 1", 0.5, 3.0, 2, new[] { "basic-crafting" }) }, { 6, new Machine(6, "Assembler 2", 0.5, 4.0, 2, new[] { "basic-crafting" }) }, }; var recipes = new Dictionary { // Iron Plate: 1 Iron Ore → 1 Iron Plate (3s) { 1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting", new[] { new Ingredient(1, 1) }, new[] { new Product(7, 1) }) }, // Steel Plate: 2 Iron Plate + 1 Coal → 1 Steel Plate (5s) { 2, new Recipe(2, "Steel Plate", "smelting", 5.0, "smelting", new[] { new Ingredient(7, 2), new Ingredient(5, 1) }, new[] { new Product(9, 1) }) }, // Copper Cable: 1 Copper Plate → 5 Copper Cable (0.5s) { 3, new Recipe(3, "Copper Cable", "basic-crafting", 0.5, "basic-crafting", new[] { new Ingredient(8, 1) }, new[] { new Product(10, 5) }) }, // Electronic Circuit: 2 Copper Cable + 1 Iron Plate → 1 EC (3s) { 4, new Recipe(4, "Electronic Circuit", "basic-crafting", 3.0, "basic-crafting", new[] { new Ingredient(10, 2), new Ingredient(7, 1) }, new[] { new Product(11, 1) }) }, }; var modules = new Dictionary(); return new TestRepository(recipes, resources, machines, modules); } [Fact] public void Solve_SingleTarget_ReturnsExecution() { var repo = CreateRepository(); var solver = new ProductionSolver(repo); // Target: 10 Iron Plate/sec var targets = new[] { new ProductionTarget(7, 10) }; var result = solver.Solve(targets); Assert.Single(result.Executions); var exec = result.Executions.First(); Assert.Equal(1, exec.RecipeId); // Iron Plate recipe Assert.Equal(3, exec.MachineId); // Smelter } [Fact] public void Solve_ChainedRecipe_ResolveDependencies() { var repo = CreateRepository(); var solver = new ProductionSolver(repo); // Target: 2 Steel Plate/sec → matrix resolves Iron Plate automatically var targets = new[] { new ProductionTarget(9, 2) }; var result = solver.Solve(targets); // Should have Steel Plate + Iron Plate executions Assert.Equal(2, result.Executions.Count); var steelExec = result.Executions.First(e => e.RecipeId == 2); Assert.NotNull(steelExec); } [Fact] public void Solve_ElectronicCircuit_FullChain() { var repo = CreateRepository(); var solver = new ProductionSolver(repo); // Target: 10 EC/sec → needs Copper Cable + Iron Plate (matrix resolves all) var targets = new[] { new ProductionTarget(11, 10) }; var result = solver.Solve(targets); // Should have EC + Copper Cable + Iron Plate executions Assert.Equal(3, result.Executions.Count); } [Fact] public void Solve_IntermediateResources_BalanceNearZero() { var repo = CreateRepository(); var solver = new ProductionSolver(repo); // Target: 10 Steel Plate/sec var targets = new[] { new ProductionTarget(9, 10) }; var result = solver.Solve(targets); // Iron Plate (id=7) is intermediate — should be near zero in flows // (produced by Iron Plate recipe, consumed by Steel Plate recipe) // Note: with ceiling on machine count, there may be slight overproduction Assert.True(result.ResourceFlows.TryGetValue(7, out var ironPlateFlow)); // Should be >= 0 (overproduction due to ceiling, not underproduction) Assert.True(ironPlateFlow >= -0.5, $"Iron Plate flow {ironPlateFlow:F2} should be near zero or positive"); } [Fact] public void Solve_WithSpeedModules_AffectsMachineCount() { var repo = CreateRepository(); var solver = new ProductionSolver(repo); var speedModule = new Module(1, "Speed Module 1", ModuleType.Speed, 0.10, 0, -0.05); var targets = new[] { new ProductionTarget(7, 10) }; var resultNoModules = solver.Solve(targets); var resultWithModules = solver.SolveWithModules(targets, new[] { speedModule }); Assert.NotEmpty(resultWithModules.Executions); // Speed modules increase effective speed → fewer machines needed for same output var execNoModules = resultNoModules.Executions.First(); var execWithModules = resultWithModules.Executions.First(); // With +10% speed, each machine does more cycles/sec → need fewer machines Assert.True(execWithModules.MachineCount <= execNoModules.MachineCount, $"Speed modules should not increase machine count: {execWithModules.MachineCount} vs {execNoModules.MachineCount}"); } [Fact] public void Solve_WithProductivityModules_ScaledOutput() { 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 prodModule = new Module(4, "Productivity Module 1", ModuleType.Productivity, -0.10, 0.10, -0.05); 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 moduleDict = new Dictionary { { 4, prodModule } }; var repo = new TestRepository(recipes, resources, machines, moduleDict); var solver = new ProductionSolver(repo); var targets = new[] { new ProductionTarget(7, 10) }; var result = solver.SolveWithModules(targets, new[] { prodModule }); Assert.Single(result.Executions); var exec = result.Executions.First(); Assert.True(exec.EffectiveProductivity > 0, "Productivity bonus should be positive"); } [Fact] public void Solve_RawResource_NoExecution() { var repo = CreateRepository(); var solver = new ProductionSolver(repo); // Iron Ore has no recipe — it's a raw resource var targets = new[] { new ProductionTarget(1, 100) }; var result = solver.Solve(targets); Assert.Empty(result.Executions); } [Fact] public void Solve_RequiredInputs_ContainsRawResources() { var repo = CreateRepository(); var solver = new ProductionSolver(repo); // Steel Plate needs Iron Ore and Coal (both raw) var targets = new[] { new ProductionTarget(9, 1) }; var result = solver.Solve(targets); // Iron Ore (id=1) and Coal (id=5) should be in required inputs Assert.Contains(1, result.RequiredInputs.Keys); Assert.Contains(5, result.RequiredInputs.Keys); } [Fact] public void Solve_MultipleTargets_MatrixAggregatesDemand() { var repo = CreateRepository(); var solver = new ProductionSolver(repo); // Two targets that both need Iron Plate: // Steel Plate needs 2× Iron Plate, plus direct 5/sec Iron Plate var targets = new[] { new ProductionTarget(9, 2), // Steel Plate → needs 4 Iron Plate/sec new ProductionTarget(7, 5), // Iron Plate direct }; var result = solver.Solve(targets); // Should have Steel Plate + Iron Plate executions Assert.Equal(2, result.Executions.Count); var ironPlateExec = result.Executions.First(e => e.RecipeId == 1); // Iron Plate should account for BOTH demands (4 from steel + 5 direct = 9 total) Assert.True(ironPlateExec.MachineCount >= 9, $"Expected at least 9 machines for Iron Plate (demand=9/sec), got {ironPlateExec.MachineCount}"); } [Fact] public void Solve_MatrixApproach_NoRecursion() { // Verify the solver uses matrix algebra, not DFS var repo = CreateRepository(); var solver = new ProductionSolver(repo); var targets = new[] { new ProductionTarget(11, 10) }; var result = solver.Solve(targets); // All three recipes should be resolved (no tree traversal needed) Assert.Equal(3, result.Executions.Count); // Resource flows should be consistent // Electronic Circuit (target) should have positive flow Assert.True(result.ResourceFlows[11] > 9, "Target resource should have positive flow"); } } /// /// In-memory test repository. /// public sealed class TestRepository : IRecipeRepository { public IReadOnlyDictionary Recipes { get; } public IReadOnlyDictionary Resources { get; } public IReadOnlyDictionary Machines { get; } public IReadOnlyDictionary Modules { get; } public TestRepository( IReadOnlyDictionary recipes, IReadOnlyDictionary resources, IReadOnlyDictionary machines, IReadOnlyDictionary modules) { Recipes = recipes; Resources = resources; Machines = machines; Modules = modules; } }