Files
factorio-calc/tests/FactorioCalc.Tests/SolverBugFixTests.cs
T
mikhail a8c9808062 fix: resolve 3 critical ProductionSolver bugs
- Bug #1: CalculateExecution now filters product by targetResourceId
  instead of blindly taking .First() — fixes multi-product recipes
- Bug #2: Replace visited HashSet with recipeOutput tracker — allows
  re-processing when demand increases (double targets on same resource)
- Bug #3: Clamp effectiveSpeed to MinEffectiveSpeed (0.05) floor —
  prevents division by zero with heavy productivity module stacks

Also: switch from Stack (DFS) to Queue (BFS) for more predictable
resolution order; add 4 regression tests (24 total, all green)
2026-07-02 13:27:43 +03:00

190 lines
7.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using FactorioCalc.Domain;
using FactorioCalc.Solver;
using Xunit;
namespace FactorioCalc.Tests;
/// <summary>
/// Regression tests for critical bugs fixed in ProductionSolver.
/// </summary>
public class SolverBugFixTests
{
// --- Bug #1: mainProduct should filter by target resourceId, not .First() ---
[Fact]
public void Solve_MultiProductRecipe_UsesCorrectProduct()
{
// Simulate a recipe that produces multiple products (like oil refining)
var resources = new Dictionary<int, Resource>
{
{ 1, new Resource(1, "Crude Oil") },
{ 2, new Resource(2, "Light Oil") },
{ 3, new Resource(3, "Heavy Oil") },
};
var machines = new Dictionary<int, Machine>
{
{ 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<int, Recipe>
{
{
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) }) // Heavy Oil first, Light Oil second
},
};
var repo = new TestRepository(recipes, resources, machines, new Dictionary<int, Module>());
var solver = new ProductionSolver(repo);
// Target Light Oil (resourceId=2) — it's the SECOND product in the list
var targets = new[] { new ProductionTarget(2, 10) };
var result = solver.Solve(targets);
Assert.Single(result.Executions);
// Should not throw — the solver correctly finds the matching product
}
// --- Bug #2: DFS visited should not block recalculation for double targets ---
[Fact]
public void Solve_DoubleTargetsForResource_AggregatesDemand()
{
var resources = new Dictionary<int, Resource>
{
{ 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<int, Machine>
{
{ 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) },
};
var recipes = new Dictionary<int, Recipe>
{
{
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<int, Module>());
var solver = new ProductionSolver(repo);
// Two targets that both need Iron Plate: Steel Plate (needs 2/sec × 2 iron plate) + direct 5/sec
var targets = new[]
{
new ProductionTarget(9, 2), // Steel Plate → needs 4 Iron Plate/sec
new ProductionTarget(7, 5), // Iron Plate direct → needs 5 more/sec
};
var result = solver.Solve(targets);
// Should have both Steel Plate and 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}");
}
// --- Bug #3: effectiveSpeed should not go to zero with heavy productivity modules ---
[Fact]
public void Solve_HeavyProductivityModules_DoesNotDivideByZero()
{
var resources = new Dictionary<int, Resource>
{
{ 1, new Resource(1, "Iron Ore") },
{ 7, new Resource(7, "Iron Plate") },
};
var machines = new Dictionary<int, Machine>
{
{ 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) },
};
var recipes = new Dictionary<int, Recipe>
{
{
1, new Recipe(1, "Iron Plate", "smelting", 3.0, "smelting",
new[] { new Ingredient(1, 1) },
new[] { new Product(7, 1) })
},
};
// Extreme productivity modules: -20% speed × 4 slots = -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<int, Module>();
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 DivideByZeroException
var result = solver.SolveWithModules(targets, extremeModules);
Assert.Single(result.Executions);
var exec = result.Executions.First();
// Speed should be clamped to minimum (0.05), not negative or zero
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<int, Resource>
{
{ 1, new Resource(1, "Iron Ore") },
{ 7, new Resource(7, "Iron Plate") },
};
var machines = new Dictionary<int, Machine>
{
{ 3, new Machine(3, "Smelter", 0.5, 3.0, 2, new[] { "smelting" }) },
};
var recipes = new Dictionary<int, Recipe>
{
{
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<int, Module> { { 1, prodModule } });
var solver = new ProductionSolver(repo);
var targets = new[] { new ProductionTarget(7, 10) };
var resultNoModules = solver.Solve(targets);
var resultWithModules = solver.SolveWithModules(targets, new[] { prodModule });
var execNoModules = resultNoModules.Executions.First();
var execWithModules = resultWithModules.Executions.First();
// With productivity, we need fewer machines because output per cycle is higher
// (even though speed is lower, the +10% output compensates)
Assert.True(execWithModules.EffectiveProductivity > 0, "Productivity bonus should be positive");
}
}