1c735b124a
- 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
281 lines
10 KiB
C#
281 lines
10 KiB
C#
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;
|
||
}
|
||
}
|
||
|