From b6c4aed8d5843951f1cb05283d122c88b6ac615b Mon Sep 17 00:00:00 2001 From: iisaev Date: Fri, 16 Oct 2026 16:18:13 +0000 Subject: [PATCH] upd: sync local changes Co-Authored-By: Claude Opus 5.5 --- locale/en.properties | 2 +- locale/ru.properties | 2 +- manifest.json | 9 +- src/CookingSettingsForTests.cs | 18 +++ src/ui/RationStatisticsPanel.cs | 1 + src/ui/workbench/RecipeIconItemBehaviour.cs | 1 + src/ui/workbench/RecipeItemBehaviour.cs | 1 + src/ui/workbench/WorkbenchPanel.cs | 8 +- tests/CookingModTests.cs | 122 ++++++++++++++++++++ 9 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 src/CookingSettingsForTests.cs create mode 100644 tests/CookingModTests.cs diff --git a/locale/en.properties b/locale/en.properties index db76392..611d438 100644 --- a/locale/en.properties +++ b/locale/en.properties @@ -1,5 +1,5 @@ mod_cooking_name = Cooking -mod_cooking_desc = Cooking mechanic — full autonomous mod +mod_cooking_desc = Cooking mechanic: the kitchen workbench in home_kitchen with recipe-based cooking (e.g. wolf meat into rations), a ration statistics panel and the cooking scenario. ui/cook/cook = Cook ui/cook/close = Close diff --git a/locale/ru.properties b/locale/ru.properties index ea62b24..cc6500a 100644 --- a/locale/ru.properties +++ b/locale/ru.properties @@ -1,5 +1,5 @@ mod_cooking_name = Готовка -mod_cooking_desc = Механика готовки — полностью автономный мод +mod_cooking_desc = Механика готовки: верстак на кухне home_kitchen с рецептами (например, волчье мясо в рационы), панель статистики рационов и сценар готовки. ui/cook/cook = Готовить ui/cook/close = Выйти diff --git a/manifest.json b/manifest.json index bd0603d..5d78130 100644 --- a/manifest.json +++ b/manifest.json @@ -3,12 +3,15 @@ "name": "Cooking", "version": "1.0.0", "author": "ct", - "description": "Cooking mechanic - full autonomous mod", + "description": "Autonomous cooking mechanic: the kitchen workbench overlay in home_kitchen with recipe-based cooking (e.g. wolf meat into rations), a ration statistics panel and the home_kitchen_cooking scenario.", "category": "gameplay", "sources": [ "src/*.cs" ], - "depends": [ "visual_novel_core>=1", "interface>=1" ], + "test_sources": [ + "tests/*.cs" + ], + "depends": [ "visual_novel_core>=1", "interface>=1", "items_base>=1", "time_base>=1" ], "ui": [ { "id": "cooking_workbench", @@ -31,4 +34,4 @@ "priority": 20 } ] -} +} \ No newline at end of file diff --git a/src/CookingSettingsForTests.cs b/src/CookingSettingsForTests.cs new file mode 100644 index 0000000..ecadbf8 --- /dev/null +++ b/src/CookingSettingsForTests.cs @@ -0,0 +1,18 @@ +using Cthangover.Core.Settings; + +namespace Cthangover.Cooking +{ + /// + /// Test accessor over : the real class exposes + /// RationsPerCharacter with a private setter because only the core + /// settings pipeline may write it, and the getter is static — mod unit + /// tests exercise the clamp through the public ReadValues contract + /// and assert via this static passthrough, so no reflection into private + /// members is needed. Stateless and harmless in production builds. + /// + public static class CookingSettingsForTests + { + /// Current parsed value, for test assertions. + public static int RationsPerCharacter => CookingSettings.RationsPerCharacter; + } +} diff --git a/src/ui/RationStatisticsPanel.cs b/src/ui/RationStatisticsPanel.cs index 7a80dcb..b0b1890 100644 --- a/src/ui/RationStatisticsPanel.cs +++ b/src/ui/RationStatisticsPanel.cs @@ -5,6 +5,7 @@ using Cthangover.Core.Settings; using Cthangover.Core.UI; using Godot; +using Cthangover.Mods.Interface.Base.Lists; namespace Mods.Cooking.Rations { /// diff --git a/src/ui/workbench/RecipeIconItemBehaviour.cs b/src/ui/workbench/RecipeIconItemBehaviour.cs index 690a426..40bfeef 100644 --- a/src/ui/workbench/RecipeIconItemBehaviour.cs +++ b/src/ui/workbench/RecipeIconItemBehaviour.cs @@ -5,6 +5,7 @@ using Cthangover.Core.UI.Theming; using Cthangover.Core.Utils; using Godot; +using Cthangover.Mods.Interface.Base.Lists; namespace Mods.Cooking.Workbench { /// diff --git a/src/ui/workbench/RecipeItemBehaviour.cs b/src/ui/workbench/RecipeItemBehaviour.cs index d10ea81..7bb3d98 100644 --- a/src/ui/workbench/RecipeItemBehaviour.cs +++ b/src/ui/workbench/RecipeItemBehaviour.cs @@ -6,6 +6,7 @@ using Cthangover.Core.UI; using Cthangover.Core.UI.Theming; using Godot; +using Cthangover.Mods.Interface.Base.Lists; namespace Mods.Cooking.Workbench { /// diff --git a/src/ui/workbench/WorkbenchPanel.cs b/src/ui/workbench/WorkbenchPanel.cs index fb22cde..82214b9 100644 --- a/src/ui/workbench/WorkbenchPanel.cs +++ b/src/ui/workbench/WorkbenchPanel.cs @@ -9,9 +9,11 @@ using Cthangover.Core.Settings; using Cthangover.Core.UI; using Cthangover.Core.UI.Theming; using Cthangover.Core.UI.Tool; +using Cthangover.Mods.Time; using Cthangover.Core.UI.Inventory; using Godot; +using Cthangover.Mods.Interface.Base.Lists; namespace Mods.Cooking.Workbench { /// @@ -122,12 +124,12 @@ namespace Mods.Cooking.Workbench /// /// Hides the workbench panel and plays a UI close-click sound - /// via the scene's node. + /// via the scene's node. /// public void OnCloseClick() { Hide(); - var audioService = SceneContextNode.FindNode("AudioService"); + var audioService = SceneContextNode.FindNode("AudioService") as IAudioService; audioService?.PlaySound("ui/close_click", SoundType.UI); } @@ -160,7 +162,7 @@ namespace Mods.Cooking.Workbench var timer = SceneContextNode.FindNode("TimeController"); timer?.UpdateRenderedTime(); - var audioService = SceneContextNode.FindNode("AudioService"); + var audioService = SceneContextNode.FindNode("AudioService") as IAudioService; audioService?.PlaySound("ui/cook", SoundType.UI); foreach (var recipe in recipes) diff --git a/tests/CookingModTests.cs b/tests/CookingModTests.cs new file mode 100644 index 0000000..168f124 --- /dev/null +++ b/tests/CookingModTests.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using System.Threading.Tasks; +using Cthangover.Core.Items; +using Cthangover.Core.Mods.Testing; +using Cthangover.Core.Settings; + +namespace Cthangover.Cooking.Tests +{ + /// + /// Unit tests of the cooking mod's pure logic (plan-mod-testing.md §4.2). + /// Compiled by ModTestEngine into a separate {modId}__tests assembly, + /// only in the explicit test mode. No scene-tree interaction: anything + /// requiring a live scene belongs to the integration contour. + /// + public class CookingModTests + { + [ModTest("recipe/wolf_meat_data_shape")] + static void WolfMeatRecipeDataShape(ModTestContext ctx) + { + // The recipe JSON declared by the mod must keep the exact shape + // RecipeFactory consumes: single recipe, cooking type, inputs/outputs. + var raw = ctx.ReadFileText("recipes/cooking.json"); + ModAssert.NotNull(raw, "recipes/cooking.json exists"); + using var doc = JsonDocument.Parse(raw); + var items = doc.RootElement.GetProperty("Items"); + ModAssert.Equal(1, items.GetArrayLength(), "recipe count"); + + var recipe = items[0]; + ModAssert.Equal("recipe/wolf_meat_to_ration", recipe.GetProperty("Id").GetString(), "recipe id"); + ModAssert.Equal("Cooking", recipe.GetProperty("Type").GetString(), "workbench type"); + ModAssert.Equal(15, recipe.GetProperty("Time").GetInt32(), "crafting time"); + ModAssert.Equal(2, recipe.GetProperty("Input").GetArrayLength(), "input ingredient count"); + ModAssert.Equal(1, recipe.GetProperty("Output").GetArrayLength(), "output ingredient count"); + + var meat = recipe.GetProperty("Input")[0]; + ModAssert.Equal("food/wolf_meat", meat.GetProperty("Id").GetString(), "input meat id"); + ModAssert.Equal(1, meat.GetProperty("Count").GetInt32(), "input meat count"); + var branches = recipe.GetProperty("Input")[1]; + ModAssert.Equal("resource/branches", branches.GetProperty("Id").GetString(), "input branches id"); + ModAssert.Equal(2, branches.GetProperty("Count").GetInt32(), "input branches count"); + } + + [ModTest("recipe/recipe_data_unlock_flow")] + static void RecipeDataUnlockFlow(ModTestContext ctx) + { + // The unlock contract WolfMeatToRation relies on: Add is idempotent + // and Has mirrors membership. Exercises the same flow the item + // action performs, through the public RecipeData API. + var data = new RecipeData(); + ModAssert.False(data.Has("recipe/wolf_meat_to_ration"), "fresh state does not know the recipe"); + ModAssert.True(data.Add("recipe/wolf_meat_to_ration"), "first Add unlocks"); + ModAssert.False(data.Add("recipe/wolf_meat_to_ration"), "second Add is a no-op"); + ModAssert.True(data.Has("recipe/wolf_meat_to_ration"), "recipe stays unlocked"); + + // Round-trip through JSON: Data is a public property, the save + // system serializes RecipeData as a whole. + var json = JsonSerializer.Serialize(data); + var restored = JsonSerializer.Deserialize(json); + ModAssert.True(restored.Has("recipe/wolf_meat_to_ration"), "unlock survives JSON round-trip"); + } + + [ModTest("settings/rations_per_character_clamp")] + static void SettingsClamp(ModTestContext ctx) + { + // CookingSettings.ReadValues clamps below-minimum input to 1 — + // verify through a real DataBlob key-value round-trip. + var blob = new DataBlob(); + blob.SetInt("rations_per_character", -5); + new global::Cthangover.Cooking.CookingSettings().ReadValues(blob); + ModAssert.Equal(1, global::Cthangover.Cooking.CookingSettingsForTests.RationsPerCharacter, "negative value clamps to 1"); + + var blob2 = new DataBlob(); + blob2.SetInt("rations_per_character", 4); + new global::Cthangover.Cooking.CookingSettings().ReadValues(blob2); + ModAssert.Equal(4, global::Cthangover.Cooking.CookingSettingsForTests.RationsPerCharacter, "valid value passes through"); + + // Write/read round-trip through the blob's own serialization. + new global::Cthangover.Cooking.CookingSettings().WriteValues(blob2); + ModAssert.Equal(4, blob2.GetInt("rations_per_character", 0), "WriteValues stores the value"); + } + + [ModTest("manifest/declares_required_dependencies")] + static void ManifestDependencies(ModTestContext ctx) + { + // The cooking UI binds to interface widget types and item/time + // subsystems: the manifest must keep these declared, otherwise + // compilation against the dependencies breaks silently. + var raw = ctx.ReadFileText("manifest.json"); + ModAssert.NotNull(raw, "manifest.json exists"); + using var doc = JsonDocument.Parse(raw); + var depends = doc.RootElement.GetProperty("depends"); + var found = new[] { "visual_novel_core", "interface", "items_base", "time_base" }; + foreach (var dep in found) + { + var ok = false; + foreach (var d in depends.EnumerateArray()) + { + var s = d.GetString() ?? ""; + var id = s.Split('>', '<', '=', '?')[0]; + if (id == dep) { ok = true; break; } + } + ModAssert.True(ok, $"dependency '{dep}' declared"); + } + } + + [ModTest("frames/async_test_spans_frames", TimeoutSeconds = 10)] + static async Task AsyncTestSpansFrames(ModTestContext ctx) + { + // Async tests may wait for engine frames (deferred UI rebuilds, + // signals). The recovery cycle between tests must keep the scene + // tree intact across those frames: core autoloads and the running + // scene survive, and the run still finishes with a report. + var tree = (Godot.SceneTree)Godot.Engine.GetMainLoop(); + for (var i = 0; i < 3; i++) + await tree.ToSignal(tree, Godot.SceneTree.SignalName.ProcessFrame); + + ModAssert.True(tree.Root.HasNode("GameData"), "/root/GameData survives frames between tests"); + ModAssert.NotNull(tree.CurrentScene, "current scene survives frames between tests"); + ModAssert.NotNull(ctx.ReadFileText("recipes/cooking.json"), "mod files stay readable after awaiting frames"); + } + } +}