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));
+ }
+ }
+}