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

281 lines
10 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;
public class SolverTests
{
private IRecipeRepository CreateRepository()
{
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") },
{ 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<int, Machine>
{
{ 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<int, Recipe>
{
// 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<int, Module>();
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<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 prodModule = new Module(4, "Productivity Module 1", ModuleType.Productivity, -0.10, 0.10, -0.05);
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 moduleDict = new Dictionary<int, Module> { { 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");
}
}
/// <summary>
/// In-memory test repository.
/// </summary>
public sealed class TestRepository : IRecipeRepository
{
public IReadOnlyDictionary<int, Recipe> Recipes { get; }
public IReadOnlyDictionary<int, Resource> Resources { get; }
public IReadOnlyDictionary<int, Machine> Machines { get; }
public IReadOnlyDictionary<int, Module> Modules { get; }
public TestRepository(
IReadOnlyDictionary<int, Recipe> recipes,
IReadOnlyDictionary<int, Resource> resources,
IReadOnlyDictionary<int, Machine> machines,
IReadOnlyDictionary<int, Module> modules)
{
Recipes = recipes;
Resources = resources;
Machines = machines;
Modules = modules;
}
}