a8c9808062
- 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)
190 lines
7.4 KiB
C#
190 lines
7.4 KiB
C#
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");
|
||
}
|
||
}
|