openthebox/tests/OpenTheBox.Tests/CraftingTests.cs
Samuel Bouchet 001f682320 Show unlocked workstations permanently in Atelier panel with idle indicator
Workstations now appear as soon as they are unlocked, not only when a job
is active. Idle stations display a waiting-for-ingredients status (/~).
Add craft.idle localization key and update empty message to "no workshops
unlocked".
2026-03-15 15:27:02 +01:00

716 lines
25 KiB
C#

using OpenTheBox.Core;
using OpenTheBox.Core.Crafting;
using OpenTheBox.Core.Enums;
using OpenTheBox.Core.Items;
using OpenTheBox.Data;
using OpenTheBox.Rendering.Panels;
using OpenTheBox.Simulation;
using OpenTheBox.Simulation.Events;
namespace OpenTheBox.Tests;
// ══════════════════════════════════════════════════════════════════════════
// CraftingEngine Tests
// ══════════════════════════════════════════════════════════════════════════
public class CraftingEngineTests
{
private static ContentRegistry CreateRegistryWithRecipe(
string recipeId = "refine_wood",
WorkstationType station = WorkstationType.Foundry,
string ingredientId = "material_wood_raw",
int ingredientQty = 2,
string resultId = "material_wood_refined",
int resultQty = 1)
{
var registry = new ContentRegistry();
registry.RegisterItem(new ItemDefinition(
ingredientId, $"item.{ingredientId}", ItemCategory.Material, ItemRarity.Common,
["Material"], MaterialType: MaterialType.Wood, MaterialForm: MaterialForm.Raw));
registry.RegisterItem(new ItemDefinition(
resultId, $"item.{resultId}", ItemCategory.Material, ItemRarity.Common,
["Material"], MaterialType: MaterialType.Wood, MaterialForm: MaterialForm.Refined));
registry.RegisterRecipe(new Recipe(
recipeId, $"recipe.{recipeId}", station,
[new RecipeIngredient(ingredientId, ingredientQty)],
new RecipeResult(resultId, resultQty)));
return registry;
}
private static GameState CreateState(bool craftingPanel = true, bool unlockFoundry = true)
{
var state = GameState.Create("Test", Locale.EN);
if (craftingPanel)
state.UnlockedUIFeatures.Add(UIFeature.CraftingPanel);
if (unlockFoundry)
state.UnlockedWorkstations.Add(WorkstationType.Foundry);
return state;
}
// ── FindCraftableRecipes ──────────────────────────────────────────
[Fact]
public void FindCraftable_NoCraftingPanel_ReturnsEmpty()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState(craftingPanel: false);
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var result = engine.FindCraftableRecipes(state, registry);
Assert.Empty(result);
}
[Fact]
public void FindCraftable_NoWorkstation_ReturnsEmpty()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState(unlockFoundry: false);
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var result = engine.FindCraftableRecipes(state, registry);
Assert.Empty(result);
}
[Fact]
public void FindCraftable_InsufficientIngredients_ReturnsEmpty()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 1)); // Need 2
var result = engine.FindCraftableRecipes(state, registry);
Assert.Empty(result);
}
[Fact]
public void FindCraftable_AllConditionsMet_ReturnsRecipe()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var result = engine.FindCraftableRecipes(state, registry);
Assert.Single(result);
Assert.Equal("refine_wood", result[0].Id);
}
[Fact]
public void FindCraftable_WorkstationBusy_ReturnsEmpty()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
// Add an in-progress job using Foundry
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "some_other",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var result = engine.FindCraftableRecipes(state, registry);
Assert.Empty(result);
}
[Fact]
public void FindCraftable_CompletedJobOnStation_AllowsNew()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
// A completed job doesn't block the station
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "some_other",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddHours(-2),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var result = engine.FindCraftableRecipes(state, registry);
Assert.Single(result);
}
// ── StartCraftingJob ──────────────────────────────────────────────
[Fact]
public void StartJob_ConsumesIngredients_CreatesJob()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var recipe = registry.Recipes["refine_wood"];
var events = engine.StartCraftingJob(recipe, state, registry);
// Ingredients consumed
Assert.Equal(0, state.CountItems("material_wood_raw"));
// Job created
Assert.Single(state.ActiveCraftingJobs);
Assert.Equal("refine_wood", state.ActiveCraftingJobs[0].RecipeId);
Assert.Equal(CraftingJobStatus.InProgress, state.ActiveCraftingJobs[0].Status);
// Events emitted
Assert.Contains(events, e => e is CraftingStartedEvent);
Assert.Contains(events, e => e is ItemConsumedEvent);
}
[Fact]
public void StartJob_DurationBasedOnResultRarity()
{
var engine = new CraftingEngine();
var registry = new ContentRegistry();
registry.RegisterItem(new ItemDefinition(
"input", "item.input", ItemCategory.Material, ItemRarity.Common, ["Material"]));
registry.RegisterItem(new ItemDefinition(
"output", "item.output", ItemCategory.Material, ItemRarity.Rare, ["Material"]));
registry.RegisterRecipe(new Recipe(
"test", "recipe.test", WorkstationType.Foundry,
[new RecipeIngredient("input", 1)],
new RecipeResult("output", 1)));
var state = CreateState();
state.AddItem(ItemInstance.Create("input", 1));
engine.StartCraftingJob(registry.Recipes["test"], state, registry);
Assert.Equal(TimeSpan.FromSeconds(60), state.ActiveCraftingJobs[0].Duration);
}
// ── TickJobs ──────────────────────────────────────────────────────
[Fact]
public void TickJobs_InProgressNotElapsed_NoEvent()
{
var engine = new CraftingEngine();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var events = engine.TickJobs(state);
Assert.Empty(events);
Assert.Equal(CraftingJobStatus.InProgress, state.ActiveCraftingJobs[0].Status);
}
[Fact]
public void TickJobs_InProgressElapsed_MarksCompleted()
{
var engine = new CraftingEngine();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.InProgress
});
var events = engine.TickJobs(state);
Assert.Single(events);
Assert.IsType<CraftingCompletedEvent>(events[0]);
Assert.Equal(CraftingJobStatus.Completed, state.ActiveCraftingJobs[0].Status);
}
[Fact]
public void TickJobs_AlreadyCompleted_NoDoubleEvent()
{
var engine = new CraftingEngine();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var events = engine.TickJobs(state);
Assert.Empty(events);
}
// ── CollectCompleted ──────────────────────────────────────────────
[Fact]
public void Collect_CompletedJob_CreatesResultItem()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var events = engine.CollectCompleted(state, registry);
// Result item added
Assert.True(state.HasItem("material_wood_refined"));
// Job removed
Assert.Empty(state.ActiveCraftingJobs);
// Events: ItemReceivedEvent + CraftingCollectedEvent
Assert.Contains(events, e => e is ItemReceivedEvent);
Assert.Contains(events, e => e is CraftingCollectedEvent);
}
[Fact]
public void Collect_NoCompletedJobs_NoEvents()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
var events = engine.CollectCompleted(state, registry);
Assert.Empty(events);
}
[Fact]
public void Collect_InProgressJob_NotCollected()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var events = engine.CollectCompleted(state, registry);
Assert.Empty(events);
Assert.Single(state.ActiveCraftingJobs);
}
// ── AutoCraftCheck ────────────────────────────────────────────────
[Fact]
public void AutoCraft_MatchingRecipeExists_StartsJob()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var events = engine.AutoCraftCheck(state, registry);
Assert.Contains(events, e => e is CraftingStartedEvent);
Assert.Single(state.ActiveCraftingJobs);
Assert.Equal(0, state.CountItems("material_wood_raw"));
}
[Fact]
public void AutoCraft_NoMatchingRecipe_NoAction()
{
var engine = new CraftingEngine();
var registry = CreateRegistryWithRecipe();
var state = CreateState();
// No materials
var events = engine.AutoCraftCheck(state, registry);
Assert.Empty(events);
Assert.Empty(state.ActiveCraftingJobs);
}
// ── CraftingJob properties ────────────────────────────────────────
[Fact]
public void CraftingJob_IsComplete_TrueWhenTimeElapsed()
{
var job = new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.InProgress
};
Assert.True(job.IsComplete);
}
[Fact]
public void CraftingJob_IsComplete_FalseWhenNotElapsed()
{
var job = new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
};
Assert.False(job.IsComplete);
}
[Fact]
public void CraftingJob_ProgressPercent_CompletedIs100()
{
var job = new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "test",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
};
Assert.Equal(100.0, job.ProgressPercent);
}
// ── GetCraftDuration ──────────────────────────────────────────────
[Theory]
[InlineData(ItemRarity.Common, 15)]
[InlineData(ItemRarity.Uncommon, 30)]
[InlineData(ItemRarity.Rare, 60)]
[InlineData(ItemRarity.Epic, 90)]
[InlineData(ItemRarity.Legendary, 120)]
[InlineData(ItemRarity.Mythic, 180)]
public void GetCraftDuration_CorrectPerRarity(ItemRarity rarity, int expectedSeconds)
{
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), CraftingEngine.GetCraftDuration(rarity));
}
}
// ══════════════════════════════════════════════════════════════════════════
// CraftingPanel Tests
// ══════════════════════════════════════════════════════════════════════════
public class CraftingPanelTests
{
[Fact]
public void Render_EmptyJobs_ShowsEmptyMessage()
{
var state = GameState.Create("Test", Locale.EN);
var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
Assert.NotEmpty(output);
// Should contain the empty workshop message (no workstations unlocked)
Assert.Contains("unlocked", output, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Render_InProgressJob_ShowsProgressBar()
{
var state = GameState.Create("Test", Locale.EN);
state.UnlockedWorkstations.Add(WorkstationType.Foundry);
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
Assert.NotEmpty(output);
Assert.Contains("Foundry", output);
Assert.Contains("%", output);
}
[Fact]
public void Render_CompletedJob_ShowsDone()
{
var state = GameState.Create("Test", Locale.EN);
state.UnlockedWorkstations.Add(WorkstationType.Foundry);
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
Assert.NotEmpty(output);
Assert.Contains("Foundry", output);
}
[Fact]
public void Render_WithRegistry_ResolvesRecipeName()
{
var registry = new ContentRegistry();
registry.RegisterRecipe(new Recipe(
"refine_wood", "recipe.refine_wood", WorkstationType.Foundry,
[new RecipeIngredient("material_wood_raw", 2)],
new RecipeResult("material_wood_refined", 1)));
var state = GameState.Create("Test", Locale.EN);
state.UnlockedWorkstations.Add(WorkstationType.Foundry);
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "refine_wood",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
var output = RenderHelper.RenderToString(CraftingPanel.Render(state, registry));
Assert.NotEmpty(output);
// Should show recipe name key (or localized version)
Assert.Contains("recipe.refine_wood", output);
}
[Fact]
public void Render_MixedJobs_DoesNotThrow()
{
var state = GameState.Create("Test", Locale.EN);
state.UnlockedWorkstations.Add(WorkstationType.Foundry);
state.UnlockedWorkstations.Add(WorkstationType.Furnace);
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "job1",
Workstation = WorkstationType.Foundry,
StartedAt = DateTime.UtcNow,
Duration = TimeSpan.FromHours(1),
Status = CraftingJobStatus.InProgress
});
state.ActiveCraftingJobs.Add(new CraftingJob
{
Id = Guid.NewGuid(),
RecipeId = "job2",
Workstation = WorkstationType.Furnace,
StartedAt = DateTime.UtcNow.AddSeconds(-10),
Duration = TimeSpan.FromSeconds(1),
Status = CraftingJobStatus.Completed
});
var output = RenderHelper.RenderToString(CraftingPanel.Render(state));
Assert.NotEmpty(output);
Assert.Contains("Foundry", output);
Assert.Contains("Furnace", output);
}
}
// ══════════════════════════════════════════════════════════════════════════
// Recipe Loading Tests
// ══════════════════════════════════════════════════════════════════════════
public class RecipeLoadingTests
{
private static string ContentPath(string file) =>
Path.Combine("content", "data", file);
[Fact]
public void LoadRecipes_AllRecipesLoaded()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
Assert.NotEmpty(registry.Recipes);
// recipes.json has 18 recipes after removing alchemy/supply/energy/oxygen recipes
Assert.True(registry.Recipes.Count >= 15, $"Expected >= 15 recipes, got {registry.Recipes.Count}");
}
[Fact]
public void LoadRecipes_AllRecipesHaveIngredients()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
foreach (var recipe in registry.Recipes.Values)
{
Assert.NotEmpty(recipe.Ingredients);
foreach (var ingredient in recipe.Ingredients)
{
Assert.NotNull(ingredient.ItemDefinitionId);
Assert.True(ingredient.Quantity > 0, $"Recipe {recipe.Id} has ingredient with qty <= 0");
}
}
}
[Fact]
public void LoadRecipes_AllRecipesHaveResult()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
foreach (var recipe in registry.Recipes.Values)
{
Assert.NotNull(recipe.Result);
Assert.NotNull(recipe.Result.ItemDefinitionId);
Assert.True(recipe.Result.Quantity > 0, $"Recipe {recipe.Id} has result with qty <= 0");
}
}
[Fact]
public void LoadRecipes_AllRecipesHaveValidWorkstation()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
var validStations = Enum.GetValues<WorkstationType>().ToHashSet();
foreach (var recipe in registry.Recipes.Values)
{
Assert.Contains(recipe.Workstation, validStations);
}
}
[Fact]
public void LoadRecipes_NullPath_Succeeds()
{
// Without recipes path, registry should still work
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"));
Assert.Empty(registry.Recipes);
}
[Fact]
public void LoadRecipes_AllBlueprintItemsExist()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
// Every blueprint item should have a WorkstationType set
var blueprints = registry.Items.Values
.Where(i => i.Tags.Contains("Blueprint"))
.ToList();
Assert.NotEmpty(blueprints);
foreach (var bp in blueprints)
{
Assert.True(bp.WorkstationType.HasValue, $"Blueprint {bp.Id} missing WorkstationType");
}
}
[Fact]
public void LoadRecipes_EachUsedWorkstation_HasBlueprint()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
var usedStations = registry.Recipes.Values
.Select(r => r.Workstation)
.Distinct()
.ToHashSet();
var blueprintStations = registry.Items.Values
.Where(i => i.WorkstationType.HasValue)
.Select(i => i.WorkstationType!.Value)
.ToHashSet();
foreach (var station in usedStations)
{
Assert.Contains(station, blueprintStations);
}
}
}
// ══════════════════════════════════════════════════════════════════════════
// GameSimulation Crafting Integration Tests
// ══════════════════════════════════════════════════════════════════════════
public class SimulationCraftingTests
{
private static string ContentPath(string file) =>
Path.Combine("content", "data", file);
[Fact]
public void OpenBox_WithMaterials_TriggerAutoCraft()
{
var registry = ContentRegistry.LoadFromFiles(
ContentPath("items.json"),
ContentPath("boxes.json"),
ContentPath("interactions.json"),
ContentPath("recipes.json"));
var state = GameState.Create("Test", Locale.EN);
state.UnlockedUIFeatures.Add(UIFeature.CraftingPanel);
state.UnlockedWorkstations.Add(WorkstationType.Foundry);
// Pre-add materials so that after any box opening, auto-craft can check
state.AddItem(ItemInstance.Create("material_wood_raw", 2));
var engine = new CraftingEngine();
var events = engine.AutoCraftCheck(state, registry);
// Should start a crafting job for refine_wood
Assert.Contains(events, e => e is CraftingStartedEvent cs && cs.RecipeId == "refine_wood");
Assert.Single(state.ActiveCraftingJobs);
}
}