From 8630c32846ac68bbd6afb3f7faffc1489cc7d416 Mon Sep 17 00:00:00 2001 From: iisaev Date: Sat, 17 Oct 2026 14:28:13 +0000 Subject: [PATCH] Host the cooking walk-through as a test suite The cooking_walk integration test moves here from the separate cooking_walk_test mod as test_suites/cooking_walk, mounted as the virtual mod cooking__cooking_walk only with --itest=cooking_walk. --- manifest.json | 5 +- test_suites/cooking_walk/manifest.json | 13 + .../src/CookingWalkTestDriverNode.cs | 512 ++++++++++++++++++ .../src/CookingWalkTestInitializer.cs | 58 ++ 4 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 test_suites/cooking_walk/manifest.json create mode 100644 test_suites/cooking_walk/src/CookingWalkTestDriverNode.cs create mode 100644 test_suites/cooking_walk/src/CookingWalkTestInitializer.cs diff --git a/manifest.json b/manifest.json index 5d78130..5956178 100644 --- a/manifest.json +++ b/manifest.json @@ -33,5 +33,8 @@ "show_on_start": true, "priority": 20 } + ], + "test_suites": [ + {"id": "cooking_walk", "activate": ["--itest=cooking_walk"]} ] -} \ No newline at end of file +} diff --git a/test_suites/cooking_walk/manifest.json b/test_suites/cooking_walk/manifest.json new file mode 100644 index 0000000..40e4106 --- /dev/null +++ b/test_suites/cooking_walk/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "Cooking Walk Test", + "description": "Integration test mod: walks the real UI with button clicks (location menus, cooking workbench) through the full home loop: enter house, unlock wolf-meat recipe via item use, cook a ration, verify it in inventory, and walk bedroom/workroom round-trips. Activated only by --itest=cooking_walk.", + "sources": [ + "src/*.cs" + ], + "depends": [ + "visual_novel_core>=1", + "items_base>=1", + "interface>=1", + "dialog_base>=1" + ] +} diff --git a/test_suites/cooking_walk/src/CookingWalkTestDriverNode.cs b/test_suites/cooking_walk/src/CookingWalkTestDriverNode.cs new file mode 100644 index 0000000..f2061cb --- /dev/null +++ b/test_suites/cooking_walk/src/CookingWalkTestDriverNode.cs @@ -0,0 +1,512 @@ +using System.Collections.Generic; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Items; +using Cthangover.Core.Scenes; +using Cthangover.Core.Settings; +using Cthangover.Core.UI; +using Cthangover.Core.UI.Dialog; +using Cthangover.Core.Utils; +using Godot; + +namespace Mods.CookingWalkTest +{ + /// + /// Драйвер интеграционного теста «прогулка + готовка». Проводит игрока по + /// маршруту теми же контрактами, что и человек: выборы локационных меню — + /// честным кликом по кнопке варианта (AnswerBox → Content → AnswerItem + /// → Button.Pressed), панель верстака — через UiRegistry.Toggle + /// (тот же путь, что сценарная опция «Готовить»), клик по рецепту — + /// эмуляцией gui_input строки рецепта, готовка и закрытие — кликами + /// по BtnCook/BtnClose. Разблокировка рецепта выполняется + /// публичным игровым путём: использование предмета-рецепта + /// (IItem.ItemAction.UseAction) — как это делает инвентарь. + /// + /// Машина состояний: один шаг за тик таймера (0.4с), чтобы успевали + /// применяться отложенные смены сцен и пересборки панелей. Каждый шаг — + /// отдельная проверка: провал фиксируется, но не обрывает маршрут (кроме + /// неожиданных исключений). Ожидание смены сцены — по + /// SceneManager.CurrentSceneName, ожидание меню — по + /// IDialogBox.IsWaitingAnswer && IsAnswerBoxShowed. Итог — контракт + /// раннера [TEST_RESULT] name=cooking_walk_test passed=N failed=M + /// и код выхода процесса; watchdog (--timeout=, дефолт 90с) гасит + /// зависание с failed=1. + /// + public partial class CookingWalkTestDriverNode : Node + { + private const string ResultName = "cooking_walk_test"; + private const float TickSeconds = 0.4f; + private const float DefaultTimeoutSeconds = 90f; + + private readonly float? timeoutSeconds; + private readonly List failures = new(); + private int passed; + private int failed; + private int step; + private double elapsed; + + /// Ожидаемая логическая сцена для шагов-переходов (null — шаг не про переход). + private string expectedScene; + + private IDialogBox dialogBox; + private Control workbench; + private Control recipeRow; + private int rationBefore; + private int meatBefore; + private int branchesBefore; + + /// Создаёт драйвер с watchdog-таймаутом (null — дефолт 90с) и ставит тикающий таймер-ребёнок. + public CookingWalkTestDriverNode(float? timeoutSeconds) + { + this.timeoutSeconds = timeoutSeconds; + var timer = new Timer + { + Name = "TickTimer", + WaitTime = TickSeconds, + Autostart = true, + OneShot = false + }; + AddChild(timer); + timer.Timeout += Tick; + } + + /// Один шаг машины состояний: ожидание готовности, затем маршрут по очереди. + private void Tick() + { + if (step < 0) + return; // тест уже завершён + + elapsed += TickSeconds; + var limit = timeoutSeconds ?? DefaultTimeoutSeconds; + if (elapsed >= limit) + { + Finish($"timeout ({limit:0}s) reached at step {step}"); + return; + } + + try + { + RunStep(); + } + catch (System.Exception ex) + { + failures.Add($"step {step}: unhandled {ex.GetType().Name}: {ex.Message}"); + failed++; + Log($"[FAIL] step {step}: {ex.Message}"); + Finish(null); + } + } + + private void RunStep() + { + // Шаг 0: ждём готовности — бут завершён, сцена home_outside вошла в + // дерево, диалог-бокс доступен. + if (step == 0) + { + var sceneName = SceneContextNode.FindNode("SceneManager")?.CurrentSceneName; + var ready = Boot.IsReady + && GameData.Instance?.Runtime != null + && sceneName == "home_outside" + && TryGetDialogBox(out dialogBox); + if (!ready) + return; + Log("environment ready (home_outside entered, dialog box found)"); + Next(); + return; + } + + // Шаг 1: стартовое меню home_outside показано; сеем предметы и + // предмет-рецепт через публичный API (эквивалент лута с волка). + if (step == 1) + { + if (!WaitSceneMenu()) + return; + Check("home_outside menu shown; seeded wolf meat + branches + recipe item", () => + { + var inv = GameData.Instance.Runtime.Inventory; + if (inv == null) + throw new CheckException("Runtime.Inventory is null (items mod factory missing?)"); + inv.Add("food/wolf_meat", 1); + inv.Add("resource/branches", 2); + inv.Add("recipe/wolf_meat_to_ration", 1); + if (inv.CheckCount("food/wolf_meat") < 1 + || inv.CheckCount("resource/branches") < 2 + || inv.CheckCount("recipe/wolf_meat_to_ration") < 1) + throw new CheckException("seeded items missing after Add"); + }); + Next(); + return; + } + + // Шаг 2: используем предмет-рецепт (использование предмета из + // инвентаря вызывает его ItemAction) — рецепт должен разблокироваться. + if (step == 2) + { + Check("recipe unlocked via item use action", () => + { + var item = ItemFactory.Instance.Get("recipe/wolf_meat_to_ration"); + if (item == null) + throw new CheckException("recipe item not resolvable from factory"); + if (item.ItemAction == null) + throw new CheckException("recipe item has no ItemAction (items/cooking mods not wired?)"); + var wasKnown = GameData.Instance.Runtime.RecipeData.Has("recipe/wolf_meat_to_ration"); + item.ItemAction.UseAction(item); + if (!wasKnown && !GameData.Instance.Runtime.RecipeData.Has("recipe/wolf_meat_to_ration")) + throw new CheckException("UseAction did not register the recipe"); + }); + Next(); + return; + } + + // Шаг 3: клик по кнопке «Зайти в дом» (первый вариант меню + // home_outside) → home_vestibule. + if (step == 3) + { + if (!WaitSceneMenu()) + return; + ClickVariant(0, "home_outside: 'Зайти в дом'"); + expectedScene = "home_vestibule"; + Next(); + return; + } + + // Шаг 4: ждём прихода в коридор. + if (step == 4) { WaitArrival(); return; } + + // Шаг 5: меню коридора; клик «В обеденную комнату» → home_kitchen. + if (step == 5) + { + if (!WaitSceneMenu()) + return; + ClickVariant(1, "home_vestibule: 'В обеденную комнату'"); + expectedScene = "home_kitchen"; + Next(); + return; + } + + // Шаг 6: ждём кухню. + if (step == 6) { WaitArrival(); return; } + + // Шаг 7: меню кухни (cooking-сценарий добавляет вариант «Готовить»); + // клик по нему открывает верстак через toggle_cooking_workbench. + if (step == 7) + { + if (!WaitSceneMenu()) + return; + Check("kitchen menu has the cook option", () => + { + // На кухне сначала показывается cooking-сценарий (priority 5 + // раньше default 10) с двумя вариантами: «Выйти», «Готовить». + if (dialogBox.AnswerVariantCount < 2) + throw new CheckException($"kitchen menu shows {dialogBox.AnswerVariantCount} variants, expected >= 2 (exit + cook)"); + }); + ClickVariant(1, "home_kitchen: 'Готовить'"); + Next(); + return; + } + + // Шаг 8: ждём панель верстака (overlay открывается Toggle'ом). + if (step == 8) + { + Control panel = null; + if (UiHost.Instance != null && UiHost.Instance.TryGetInstance("cooking_workbench", out var instance)) + panel = instance; + if (panel == null || !panel.Visible) + return; + workbench = panel; + Check("workbench panel visible with recipe row", () => + { + var content = workbench.FindChild("Content", true, false); + if (content == null || content.GetChildCount() < 1) + throw new CheckException("workbench recipe list is empty (recipe not unlocked?)"); + }); + Next(); + return; + } + + // Шаг 9: клик по строке рецепта (gui_input — тот же путь, что клик + // мышью: RecipeItemBehaviour подписан на GuiInput). + if (step == 9) + { + Check("recipe row click selects recipe", () => + { + var content = workbench.FindChild("Content", true, false); + recipeRow = content.GetChildCount() > 0 ? content.GetChild(0) as Control : null; + if (recipeRow == null) + throw new CheckException("no recipe row to click"); + var click = new InputEventMouseButton + { + ButtonIndex = MouseButton.Left, + Pressed = true + }; + recipeRow.EmitSignal("gui_input", click); + + var description = workbench.FindChild("TxtDescription", true, false) as RichTextLabel; + if (description == null || string.IsNullOrWhiteSpace(description.Text)) + throw new CheckException("recipe description empty after row click"); + }); + Next(); + return; + } + + // Шаг 10: запоминаем запасы и жмём BtnCook; проверяем дельты — + // стартовая партия может уже держать те же предметы. + if (step == 10) + { + Check("cook click consumes inputs and yields ration", () => + { + var inv = GameData.Instance.Runtime.Inventory; + rationBefore = inv.CheckCount("food/ration"); + meatBefore = inv.CheckCount("food/wolf_meat"); + branchesBefore = inv.CheckCount("resource/branches"); + + var btn = workbench.FindChild("BtnCook", true, false) as Button; + if (btn == null) + throw new CheckException("BtnCook not found"); + btn.EmitSignal("pressed"); + + if (inv.CheckCount("food/wolf_meat") != meatBefore - 1) + throw new CheckException($"wolf meat {inv.CheckCount("food/wolf_meat")}, expected {meatBefore - 1} (input consumed)"); + if (inv.CheckCount("resource/branches") != branchesBefore - 2) + throw new CheckException($"branches {inv.CheckCount("resource/branches")}, expected {branchesBefore - 2} (input consumed)"); + if (inv.CheckCount("food/ration") != rationBefore + 1) + throw new CheckException($"ration count {inv.CheckCount("food/ration")}, expected {rationBefore + 1}"); + }); + Next(); + return; + } + + // Шаг 11: закрываем верстак кнопкой BtnClose. + if (step == 11) + { + Check("workbench closes via BtnClose", () => + { + var btn = workbench.FindChild("BtnClose", true, false) as Button; + if (btn == null) + throw new CheckException("BtnClose not found"); + btn.EmitSignal("pressed"); + if (workbench.Visible) + throw new CheckException("workbench still visible after BtnClose"); + }); + Next(); + return; + } + + // Шаг 12: меню кухни снова показано; клик «Выйти» → коридор. + if (step == 12) + { + if (!WaitSceneMenu()) + return; + ClickVariant(0, "home_kitchen: 'Выйти'"); + expectedScene = "home_vestibule"; + Next(); + return; + } + + // Шаг 13: ждём коридор. + if (step == 13) { WaitArrival(); return; } + + // Шаг 14: меню коридора; клик «На улицу» → home_outside. + if (step == 14) + { + if (!WaitSceneMenu()) + return; + ClickVariant(4, "home_vestibule: 'На улицу'"); + expectedScene = "home_outside"; + Next(); + return; + } + + // Шаг 15: ждём улицу. + if (step == 15) { WaitArrival(); return; } + + // Шаг 16: меню улицы; клик «Зайти в дом» → коридор. + if (step == 16) + { + if (!WaitSceneMenu()) + return; + ClickVariant(0, "home_outside: 'Зайти в дом' (2nd visit)"); + expectedScene = "home_vestibule"; + Next(); + return; + } + + // Шаг 17: ждём коридор (регрессия «кнопки пропали»: меню обязано + // быть — сценарий коридора не должен быть отфильтрован как + // завершённый после первого же перехода). + if (step == 17) { WaitArrival(); return; } + + // Шаг 18: меню коридора; клик «В спальню» → home_bedroom. + if (step == 18) + { + if (!WaitSceneMenu()) + return; + ClickVariant(2, "home_vestibule: 'В спальню'"); + expectedScene = "home_bedroom"; + Next(); + return; + } + + // Шаг 19: ждём спальню. + if (step == 19) { WaitArrival(); return; } + + // Шаг 20: меню спальни; клик «Выйти» → коридор. + if (step == 20) + { + if (!WaitSceneMenu()) + return; + ClickVariant(0, "home_bedroom: 'Выйти'"); + expectedScene = "home_vestibule"; + Next(); + return; + } + + // Шаг 21: ждём коридор. + if (step == 21) { WaitArrival(); return; } + + // Шаг 22: меню коридора; клик «В рабочую комнату» → home_workroom. + if (step == 22) + { + if (!WaitSceneMenu()) + return; + ClickVariant(0, "home_vestibule: 'В рабочую комнату'"); + expectedScene = "home_workroom"; + Next(); + return; + } + + // Шаг 23: ждём рабочую комнату — финальная точка маршрута. + if (step == 23) { WaitArrival(); return; } + + // Шаг 24: меню рабочей комнаты показано → маршрут пройден целиком. + if (step == 24) + { + if (!WaitSceneMenu()) + return; + Check("route complete: workroom menu shown", () => { }); + Finish(null); + } + } + + /// + /// Ждёт, что в текущей сцене показано меню выбора (диалог в режиме + /// ожидания ответа и бокс вариантов на экране). Меню появляется через + /// несколько тиков после смены сцены — шаг просто не проходит, пока + /// условие не выполнено; watchdog страхует зависание. + /// + private bool WaitSceneMenu() + { + return dialogBox != null + && dialogBox.IsActive + && dialogBox.IsWaitingAnswer + && dialogBox.IsAnswerBoxShowed; + } + + /// + /// Шаг ожидания перехода: сцена уже сменилась на ожидаемую — проверка + /// пройдена; иначе шаг молча повторяется на следующем тике. + /// + private void WaitArrival() + { + var target = expectedScene; + var current = SceneContextNode.FindNode("SceneManager")?.CurrentSceneName; + if (current != target) + return; + + Check($"arrived at '{target}'", () => + { + if (current != target) + throw new CheckException($"current scene '{current}', expected '{target}'"); + }); + expectedScene = null; + Next(); + } + + /// + /// Честный клик по кнопке варианта: находит AnswerBox в дереве + /// диалог-бокса, берёт строку варианта с индексом + /// и дёргает её Button (тот же сигнал, что + /// нажатие мышью — AnswerItem подписан на Pressed). Падает, если + /// структура бокса не соответствует ожидаемой. + /// + private void ClickVariant(int index, string what) + { + Check($"click {what}", () => + { + // IDialogBox — контракт ядра без Node-членов; сам бокс живёт + // под concrete-узлом DialogBox в дереве. + var box = (dialogBox as Node)?.FindChild("AnswerBox", true, false) as Control; + if (box == null) + throw new CheckException("AnswerBox node not found under dialog box"); + var content = box.FindChild("Content", true, false); + if (content == null || content.GetChildCount() <= index) + throw new CheckException($"answer content missing or has fewer than {index + 1} items"); + + var item = content.GetChild(index); + var button = item?.FindChild("Button", true, false) as Button; + if (button == null) + throw new CheckException("variant Button node not found in answer item"); + + button.EmitSignal("pressed"); + }); + } + + private bool TryGetDialogBox(out IDialogBox box) + { + var node = SceneContextNode.FindNode("DialogBox"); + box = node as IDialogBox; + return box != null; + } + + private void Next() + { + step++; + } + + private void Check(string title, System.Action body) + { + var sw = Godot.Time.GetTicksUsec(); + try + { + body(); + passed++; + Log($"[PASS] {title} ({(Godot.Time.GetTicksUsec() - sw) / 1000.0:0.0} ms)"); + } + catch (CheckException ex) + { + failed++; + failures.Add($"{title}: {ex.Message}"); + Log($"[FAIL] {title}: {ex.Message}"); + } + } + + /// Печатает итог и завершает процесс; после вызова шаги не выполняются. + private void Finish(string reason) + { + if (step < 0) + return; + step = -1; + + if (reason != null) + { + failed++; + Log($"[FAIL] {reason}"); + } + foreach (var f in failures) + Log($" failed: {f}"); + Log($"[TEST_RESULT] name={ResultName} passed={passed} failed={failed}"); + GetTree().Quit(failed == 0 ? 0 : 1); + } + + private void Log(string message) + { + GameLogger.Log("TEST", $"CookingWalkTest: {message}"); + } + + /// Отличает ожидаемую проверку от неожиданного исключения шага. + private sealed class CheckException : System.Exception + { + public CheckException(string message) : base(message) { } + } + } +} diff --git a/test_suites/cooking_walk/src/CookingWalkTestInitializer.cs b/test_suites/cooking_walk/src/CookingWalkTestInitializer.cs new file mode 100644 index 0000000..580874e --- /dev/null +++ b/test_suites/cooking_walk/src/CookingWalkTestInitializer.cs @@ -0,0 +1,58 @@ +using System.Linq; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; +using Godot; + +namespace Mods.CookingWalkTest +{ + /// + /// Точка входа интеграционного теста «прогулка + готовка» (прецедент + /// inventory_test: мод сам везёт драйвер и сам печатает [TEST_RESULT]). + /// Активация — только по явному пользовательскому флагу + /// --itest=cooking_walk (user-args после --), чтобы мод никогда не + /// вмешивался в обычную игру. Стартовая сцена задаётся ядром + /// (--scene=home_outside); драйверу остаётся дождаться готового + /// шелла и провести игрока по маршруту кликами по настоящим кнопкам. + /// + public class CookingWalkTestInitializer : IModInitializer + { + private static bool driverQueued; + + /// Per-mod инициализация не требуется — драйвер ставится на resources-ready. + public void OnModLoaded(string modId) { } + + /// + /// Разбирает user-args; при активационном флаге добавляет драйвер + /// теста в корень дерева. Повторный ModManager.Reload() не + /// должен плодить драйверы — защищено статическим флагом. + /// + public void OnModResourcesReady() + { + if (driverQueued) + return; + + var args = OS.GetCmdlineUserArgs(); + if (!args.Any(a => a == "--itest=cooking_walk")) + return; + + var timeout = ParseTimeout(args); + driverQueued = true; + GameLogger.Log("TEST", $"CookingWalkTestInitializer: activating cooking walk test, timeout={timeout?.ToString() ?? "none"}s"); + + // Deferred: сцена ещё не готова; драйвер сам ждёт Boot.IsReady и + // появления CurrentScene, поэтому порядок с загрузкой сцены не важен. + (Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new CookingWalkTestDriverNode(timeout)); + } + + private static float? ParseTimeout(string[] args) + { + var raw = args + .Where(a => a.StartsWith("--timeout=", System.StringComparison.Ordinal)) + .Select(a => a.Substring("--timeout=".Length)) + .FirstOrDefault(); + return float.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture, out var t) && t > 0 + ? t + : null; + } + } +}