From 55ef9416ef5f2118aecea2e9037cc6019b4534e8 Mon Sep 17 00:00:00 2001 From: iisaev Date: Sat, 17 Oct 2026 14:29:15 +0000 Subject: [PATCH] Tool windows test suite; drop visual_novel_core dependency - test_suites/windows (--itest=tools_windows [--shots=]) opens every tool window, fails on any engine error logged meanwhile, drives the map/interactive/light canvases and can save a screenshot per window. - The tools need the engine mods only; visual_novel_core is no longer a dependency (the Novel Editor collection brings the content). --- manifest.json | 5 +- test_suites/windows/manifest.json | 10 + .../windows/src/ToolWindowsErrorLogger.cs | 40 ++++ .../windows/src/ToolWindowsExercises.cs | 132 +++++++++++ .../windows/src/ToolWindowsTestDriverNode.cs | 216 ++++++++++++++++++ .../windows/src/ToolWindowsTestInitializer.cs | 43 ++++ 6 files changed, 445 insertions(+), 1 deletion(-) create mode 100644 test_suites/windows/manifest.json create mode 100644 test_suites/windows/src/ToolWindowsErrorLogger.cs create mode 100644 test_suites/windows/src/ToolWindowsExercises.cs create mode 100644 test_suites/windows/src/ToolWindowsTestDriverNode.cs create mode 100644 test_suites/windows/src/ToolWindowsTestInitializer.cs diff --git a/manifest.json b/manifest.json index 951300e..9d67590 100644 --- a/manifest.json +++ b/manifest.json @@ -8,5 +8,8 @@ "sources": [ "src/**/*.cs" ], - "depends": ["visual_novel_core>=1", "dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "interface>=1"] + "depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "interface>=1"], + "test_suites": [ + {"id": "windows", "activate": ["--itest=tools_windows"]} + ] } diff --git a/test_suites/windows/manifest.json b/test_suites/windows/manifest.json new file mode 100644 index 0000000..a50f4cb --- /dev/null +++ b/test_suites/windows/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "Tool Windows Test", + "description": "Integration test of the tools mod: opens every registered tool window through ToolFactory exactly like the toolbar button does, checks the chrome and the window's own smoke checks, optionally saves a screenshot of each window (--shots=) and reports [TEST_RESULT]. Activated by --itest=tools_windows.", + "sources": [ + "src/*.cs" + ], + "depends": [ + "visual_novel_core>=1" + ] +} diff --git a/test_suites/windows/src/ToolWindowsErrorLogger.cs b/test_suites/windows/src/ToolWindowsErrorLogger.cs new file mode 100644 index 0000000..6864b89 --- /dev/null +++ b/test_suites/windows/src/ToolWindowsErrorLogger.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using Godot; + +namespace Cthangover.Tools.Tests.Windows +{ + /// + /// Engine logger that records every error Godot reports while the test + /// runs. An exception thrown inside a signal handler (a button toggled + /// during construction, a draw callback) never reaches the code that opened + /// the window — Godot only prints it — so without this the test would pass + /// windows that are visibly broken. Errors may arrive from any thread. + /// + public partial class ToolWindowsErrorLogger : Logger + { + private readonly object _lock = new(); + private readonly List _errors = new(); + + /// Records the error text (rationale or code, whichever is set). + public override void _LogError(string function, string file, int line, string code, string rationale, + bool editorNotify, int errorType, Godot.Collections.Array scriptBacktraces) + { + if (errorType == (int)ErrorType.Warning) + return; + + lock (_lock) + _errors.Add($"{(string.IsNullOrEmpty(rationale) ? code : rationale)} ({file}:{line})"); + } + + /// Returns and clears the errors collected so far. + public List Drain() + { + lock (_lock) + { + var copy = new List(_errors); + _errors.Clear(); + return copy; + } + } + } +} diff --git a/test_suites/windows/src/ToolWindowsExercises.cs b/test_suites/windows/src/ToolWindowsExercises.cs new file mode 100644 index 0000000..6a8e3b8 --- /dev/null +++ b/test_suites/windows/src/ToolWindowsExercises.cs @@ -0,0 +1,132 @@ +using System; +using System.Linq; +using Cthangover.Core.UI.Tool.LightEditor; +using Cthangover.Core.UI.Tool.ScenarioEditor; +using Cthangover.Tools.InteractiveEditor; +using Cthangover.Tools.MapEditor; +using Godot; + +namespace Cthangover.Tools.Tests.Windows +{ + /// + /// Per-tool interaction checks run on an open window, through the same + /// public surface the UI uses: canvas input is delivered by emitting the + /// canvas' gui_input signal (the editors subscribe to it), data is + /// read back from the canvas' public controller. Nothing is saved — the + /// window is closed without writing afterwards. + /// + public static class ToolWindowsExercises + { + /// Runs the checks for ; records each result. + public static void Run(string toolId, Window window, Action check) + { + switch (toolId) + { + case "map_editor": + MapDrawing(window, check); + break; + case "interactive_editor": + InteractivePolygon(window, check); + break; + case "light_editor": + LightAdd(window, check); + break; + case "scenario_editor": + var tree = Find(window)?.FindChildren("*", nameof(Tree), true, false).OfType().FirstOrDefault(); + check(tree?.GetRoot()?.GetChildCount() > 0, "scenario_editor: file tree lists mods with scenarios"); + break; + } + } + + /// Draws a triangle; the finishing double-click must not add a fourth vertex. + private static void MapDrawing(Window window, Action check) + { + var canvas = Find(window); + if (canvas?.Controller == null) + { + check(false, "map_editor: canvas found"); + return; + } + + var zone = canvas.Controller.StartRegion(); + canvas.SetMode(MapEditorCanvas.ModeDraw); + var rect = canvas.GetBackgroundRect(); + Click(canvas, rect.Position + rect.Size * new Vector2(0.2f, 0.2f), false); + Click(canvas, rect.Position + rect.Size * new Vector2(0.4f, 0.2f), false); + Click(canvas, rect.Position + rect.Size * new Vector2(0.3f, 0.4f), false); + Click(canvas, rect.Position + rect.Size * new Vector2(0.3f, 0.4f), true); + + check(zone.Points.Count == 3, $"map_editor: drawn region has 3 vertices (got {zone.Points.Count})"); + check(canvas.Mode == MapEditorCanvas.ModeSelect, "map_editor: double-click finishes drawing"); + check(Mathf.Abs(zone.Points[0].X - 0.2f) < 0.01f && Mathf.Abs(zone.Points[0].Y - 0.2f) < 0.01f, + $"map_editor: vertex lands at the clicked background point (got {zone.Points[0]})"); + } + + /// A new object switched to polygon gets 4 vertices; a click on empty background appends one. + private static void InteractivePolygon(Window window, Action check) + { + var canvas = Find(window); + if (canvas?.Controller == null) + { + check(false, "interactive_editor: canvas found"); + return; + } + + var def = canvas.Controller.Add(); + InteractiveEditorController.SetHitType(def, "polygon"); + canvas.RebuildHandles(); + check(def.HitArea.Vertices?.Length == 4, "interactive_editor: rect converts to a 4-vertex polygon"); + + var rect = canvas.GetBackgroundRect(); + Click(canvas, rect.Position + rect.Size * new Vector2(0.97f, 0.97f), false); + check(def.HitArea.Vertices?.Length == 5, $"interactive_editor: click on empty background adds a vertex (got {def.HitArea.Vertices?.Length})"); + } + + /// A double-click on the background adds a light at that point. + private static void LightAdd(Window window, Action check) + { + var canvas = Find(window); + if (canvas?.Controller == null) + { + check(false, "light_editor: canvas found"); + return; + } + + var before = canvas.Controller.Lights.Count; + if (before >= LightEditorController.MaxLights) + return; + + var rect = canvas.GetBackgroundRect(); + Click(canvas, rect.Position + rect.Size * new Vector2(0.75f, 0.25f), true); + var light = canvas.Controller.Selected; + check(canvas.Controller.Lights.Count == before + 1 && light != null + && Mathf.Abs(light.X - 0.75f) < 0.01f && Mathf.Abs(light.Y - 0.25f) < 0.01f, + "light_editor: double-click adds a light at the clicked point"); + } + + private static void Click(Control target, Vector2 local, bool doubleClick) + { + var ev = new InputEventMouseButton + { + ButtonIndex = MouseButton.Left, + Pressed = true, + DoubleClick = doubleClick, + Position = local + }; + target.EmitSignal(Control.SignalName.GuiInput, ev); + } + + private static T Find(Node root) where T : Node + { + foreach (var child in root.GetChildren()) + { + if (child is T match) + return match; + var nested = Find(child); + if (nested != null) + return nested; + } + return null; + } + } +} diff --git a/test_suites/windows/src/ToolWindowsTestDriverNode.cs b/test_suites/windows/src/ToolWindowsTestDriverNode.cs new file mode 100644 index 0000000..03f2796 --- /dev/null +++ b/test_suites/windows/src/ToolWindowsTestDriverNode.cs @@ -0,0 +1,216 @@ +using System.Collections.Generic; +using System.IO; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Games; +using Cthangover.Core.UI.Tool; +using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Menu; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Tools.Hub; +using Godot; + +namespace Cthangover.Tools.Tests.Windows +{ + /// + /// Opens every registered tool window one by one through the same path the + /// toolbar button uses ( → ), + /// lets it lay out for a couple of ticks, checks that it entered the tree + /// visible and inside the game viewport, optionally saves a screenshot and + /// closes it. A window that throws while constructing or laying out fails + /// only its own check, the others still run. Ends with the runner contract + /// [TEST_RESULT] name=tools_windows passed=N failed=M and the exit code. + /// + /// Launched as the novel_editor game (--game=novel_editor --new) + /// it first waits for the collection's entry scene, the + /// , and checks it lists a launcher button for + /// every registered tool before opening the windows. + /// + public partial class ToolWindowsTestDriverNode : Node + { + private const string ResultName = "tools_windows"; + private const float TickSeconds = 0.5f; + private const float TimeoutSeconds = 90f; + private const int SettleTicks = 4; + private const int ExerciseTick = 2; + + private readonly string _shotsDir; + private readonly List _tools = new(); + private int _passed; + private int _failed; + private int _toolIndex = -1; + private int _settle; + private double _elapsed; + private bool _finished; + private Window _window; + private readonly ToolWindowsErrorLogger _errors = new(); + + /// Creates the driver; null or empty disables screenshots. + public ToolWindowsTestDriverNode(string shotsDir) + { + _shotsDir = shotsDir; + OS.AddLogger(_errors); + var timer = new Timer { WaitTime = TickSeconds, Autostart = true }; + AddChild(timer); + timer.Timeout += Tick; + } + + private void Tick() + { + if (_finished) + return; + + _elapsed += TickSeconds; + if (_elapsed >= TimeoutSeconds) + { + Fail($"timeout ({TimeoutSeconds:0}s) at tool #{_toolIndex}"); + Finish(); + return; + } + + if (_toolIndex < 0) + { + var tree = Godot.Engine.GetMainLoop() as SceneTree; + if (!Boot.IsReady || tree?.CurrentScene == null) + return; + + var inHub = ActiveGame.Id == NovelEditorHub.SceneName; + if (inHub && tree.CurrentScene is not NovelEditorHub) + return; + + _tools.AddRange(ToolFactory.Instance.GetAll()); + Check(_tools.Count > 0, $"tools registered ({_tools.Count})"); + if (inHub) + CheckHub(tree.CurrentScene); + OpenNext(); + return; + } + + if (_window == null) + return; + + if (--_settle == ExerciseTick) + { + try + { + ToolWindowsExercises.Run(_tools[_toolIndex].Id, _window, Check); + } + catch (System.Exception ex) + { + Fail($"{_tools[_toolIndex].Id}: interaction threw {ex.GetType().Name}: {ex.Message}"); + } + } + + if (_settle > 0) + return; + + InspectAndClose(); + OpenNext(); + } + + private void OpenNext() + { + _toolIndex++; + if (_toolIndex >= _tools.Count) + { + Finish(); + return; + } + + var tool = _tools[_toolIndex]; + _errors.Drain(); + try + { + _window = ToolWindow.ShowWindow(tool.CreateWindow()); + _settle = SettleTicks; + } + catch (System.Exception ex) + { + Fail($"{tool.Id}: open threw {ex.GetType().Name}: {ex.Message}"); + _window = null; + OpenNext(); + } + } + + /// Every registered tool has its launcher button on the hub screen. + private void CheckHub(Node hub) + { + var keys = new HashSet(); + CollectButtonKeys(hub, keys); + foreach (var tool in _tools) + Check(keys.Contains(tool.LocaleKey), $"hub: launcher button for {tool.Id}"); + SaveShot("novel_editor_hub"); + } + + private static void CollectButtonKeys(Node node, HashSet keys) + { + if (node is Button button && button.HasMeta(MenuBuilder.LocaleMetaKey)) + keys.Add(button.GetMeta(MenuBuilder.LocaleMetaKey).AsString()); + foreach (var child in node.GetChildren()) + CollectButtonKeys(child, keys); + } + + private void InspectAndClose() + { + var id = _tools[_toolIndex].Id; + var window = _window; + _window = null; + + var errors = _errors.Drain(); + Check(errors.Count == 0, $"{id}: no engine errors while opening" + (errors.Count > 0 ? ": " + string.Join(" | ", errors) : "")); + Check(GodotObject.IsInstanceValid(window) && window.IsInsideTree() && window.Visible, $"{id}: window visible in tree"); + if (!GodotObject.IsInstanceValid(window)) + return; + + var viewport = GetTree().Root.GetVisibleRect().Size; + Check(window.Size.X <= viewport.X + 1 && window.Size.Y <= viewport.Y + 1, + $"{id}: window {window.Size} fits viewport {viewport}"); + + SaveShot(id); + window.QueueFree(); + } + + private void SaveShot(string id) + { + if (string.IsNullOrEmpty(_shotsDir) || DisplayServer.GetName() == "headless") + return; + + var dir = ProjectSettings.GlobalizePath(_shotsDir); + Directory.CreateDirectory(dir); + var image = GetViewport().GetTexture().GetImage(); + var path = Path.Combine(dir, id + ".png"); + image.SavePng(path); + Log($"screenshot {path}"); + } + + private void Check(bool ok, string name) + { + if (ok) + { + _passed++; + Log($"[PASS] {name}"); + } + else + { + Fail(name); + } + } + + private void Fail(string message) + { + _failed++; + Log($"[FAIL] {message}"); + } + + private void Finish() + { + if (_finished) + return; + _finished = true; + OS.RemoveLogger(_errors); + GameLogger.Log("TEST", $"[TEST_RESULT] name={ResultName} passed={_passed} failed={_failed}"); + GetTree().Quit(_failed == 0 ? 0 : 1); + } + + private static void Log(string message) => GameLogger.Log("TEST", message); + } +} diff --git a/test_suites/windows/src/ToolWindowsTestInitializer.cs b/test_suites/windows/src/ToolWindowsTestInitializer.cs new file mode 100644 index 0000000..69af2e7 --- /dev/null +++ b/test_suites/windows/src/ToolWindowsTestInitializer.cs @@ -0,0 +1,43 @@ +using System.Linq; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Tools.Tests.Windows +{ + /// + /// Entry point of the tool windows GUI test. Activated only by the exact + /// user arg --itest=tools_windows; --shots=<dir> additionally + /// saves a screenshot of every opened window (needs a real display, e.g. + /// xvfb — the headless renderer produces no image). The driver is added to + /// the tree deferred because resources-ready fires before the shell scene + /// exists; the driver waits for the boot itself. + /// + public class ToolWindowsTestInitializer : IModInitializer + { + private static bool driverQueued; + + /// Nothing to do per mod: the driver is queued on resources-ready. + public void OnModLoaded(string modId) { } + + /// Queues the driver once when the activation flag is present. + public void OnModResourcesReady() + { + if (driverQueued) + return; + + var args = OS.GetCmdlineUserArgs(); + if (!args.Any(a => a == "--itest=tools_windows")) + return; + + var shots = args + .Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal)) + .Select(a => a.Substring("--shots=".Length)) + .FirstOrDefault(); + + driverQueued = true; + GameLogger.Log("TEST", $"ToolWindowsTestInitializer: activating tool windows test, shots='{shots ?? ""}'"); + (Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new ToolWindowsTestDriverNode(shots)); + } + } +}