Files
mikhail 1c735b124a refactor: DFS solver → matrix-based linear algebra (MathNet.Numerics)
- Rewrite ProductionSolver: system of linear equations A×x=b via LU/QR decomposition
- Add ResourceClassifier: classify resources as target/intermediate/raw
- Add RecipeMatrixBuilder: build coefficient matrix and RHS vector
- Add DI container (Microsoft.Extensions.DependencyInjection) in CLI
- Fix resource flow calculation (RecipeRate is total, not per-machine)
- Fix recipes.json: moduleCategory → machineCategory (recipe #19)
- Update tests: 30 tests covering solver, classifier, matrix builder
- Update README: document matrix model, architecture, principles
2026-07-02 14:46:14 +03:00

300 lines
11 KiB
C#
Raw Permalink 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>
/// Tests for edge cases and correctness of the matrix-based solver.
/// </summary>
public class SolverBugFixTests
{
// --- Multi-product recipes (e.g. oil refining) ---
[Fact]
public void Solve_MultiProductRecipe_UsesCorrectProduct()
{
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) })
},
};
var repo = new TestRepository(recipes, resources, machines, new Dictionary<int, Module>());
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<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);
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<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: -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
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<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 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<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);
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<int, Resource>
{
{ 1, new Resource(1, "Iron Ore") },
{ 7, new Resource(7, "Iron Plate") },
};
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 machines = new Dictionary<int, Machine>();
var repo = new TestRepository(recipes, resources, machines, new Dictionary<int, Module>());
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<int, Resource>
{
{ 1, new Resource(1, "Iron Ore") },
{ 7, new Resource(7, "Iron Plate") },
};
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 machines = new Dictionary<int, Machine>();
var repo = new TestRepository(recipes, resources, machines, new Dictionary<int, Module>());
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
}
}