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(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 Assert.Contains("active", output, StringComparison.OrdinalIgnoreCase); } [Fact] public void Render_InProgressJob_ShowsProgressBar() { var state = GameState.Create("Test", Locale.EN); 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.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.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.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().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); } }