Tool windows test suite; drop visual_novel_core dependency

- test_suites/windows (--itest=tools_windows [--shots=<dir>]) 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).
This commit is contained in:
cthangover committed 2026-10-17 14:29:15 +00:00
1 parent 8742aa3da5
commit 55ef9416ef
6 files changed
+445 -1

No files matched your search

+4 -1
View File
@@ -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"]}
]
}
+10
View File
@@ -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=<dir>) and reports [TEST_RESULT]. Activated by --itest=tools_windows.",
"sources": [
"src/*.cs"
],
"depends": [
"visual_novel_core>=1"
]
}
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
/// <summary>
/// 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.
/// </summary>
public partial class ToolWindowsErrorLogger : Logger
{
private readonly object _lock = new();
private readonly List<string> _errors = new();
/// <summary>Records the error text (rationale or code, whichever is set).</summary>
public override void _LogError(string function, string file, int line, string code, string rationale,
bool editorNotify, int errorType, Godot.Collections.Array<ScriptBacktrace> scriptBacktraces)
{
if (errorType == (int)ErrorType.Warning)
return;
lock (_lock)
_errors.Add($"{(string.IsNullOrEmpty(rationale) ? code : rationale)} ({file}:{line})");
}
/// <summary>Returns and clears the errors collected so far.</summary>
public List<string> Drain()
{
lock (_lock)
{
var copy = new List<string>(_errors);
_errors.Clear();
return copy;
}
}
}
}
@@ -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
{
/// <summary>
/// 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' <c>gui_input</c> 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.
/// </summary>
public static class ToolWindowsExercises
{
/// <summary>Runs the checks for <paramref name="toolId"/>; <paramref name="check"/> records each result.</summary>
public static void Run(string toolId, Window window, Action<bool, string> 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<ScenarioFileTreePanel>(window)?.FindChildren("*", nameof(Tree), true, false).OfType<Tree>().FirstOrDefault();
check(tree?.GetRoot()?.GetChildCount() > 0, "scenario_editor: file tree lists mods with scenarios");
break;
}
}
/// <summary>Draws a triangle; the finishing double-click must not add a fourth vertex.</summary>
private static void MapDrawing(Window window, Action<bool, string> check)
{
var canvas = Find<MapEditorCanvas>(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]})");
}
/// <summary>A new object switched to polygon gets 4 vertices; a click on empty background appends one.</summary>
private static void InteractivePolygon(Window window, Action<bool, string> check)
{
var canvas = Find<InteractiveEditorCanvas>(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})");
}
/// <summary>A double-click on the background adds a light at that point.</summary>
private static void LightAdd(Window window, Action<bool, string> check)
{
var canvas = Find<LightEditorCanvas>(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<T>(Node root) where T : Node
{
foreach (var child in root.GetChildren())
{
if (child is T match)
return match;
var nested = Find<T>(child);
if (nested != null)
return nested;
}
return null;
}
}
}
@@ -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
{
/// <summary>
/// Opens every registered tool window one by one through the same path the
/// toolbar button uses (<see cref="ToolFactory"/> → <see cref="ToolWindow.ShowWindow"/>),
/// 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
/// <c>[TEST_RESULT] name=tools_windows passed=N failed=M</c> and the exit code.
/// <para/>
/// Launched as the <c>novel_editor</c> game (<c>--game=novel_editor --new</c>)
/// it first waits for the collection's entry scene, the
/// <see cref="NovelEditorHub"/>, and checks it lists a launcher button for
/// every registered tool before opening the windows.
/// </summary>
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<IToolProvider> _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();
/// <summary>Creates the driver; <paramref name="shotsDir"/> null or empty disables screenshots.</summary>
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();
}
}
/// <summary>Every registered tool has its launcher button on the hub screen.</summary>
private void CheckHub(Node hub)
{
var keys = new HashSet<string>();
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<string> 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);
}
}
@@ -0,0 +1,43 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.Tests.Windows
{
/// <summary>
/// Entry point of the tool windows GUI test. Activated only by the exact
/// user arg <c>--itest=tools_windows</c>; <c>--shots=&lt;dir&gt;</c> 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.
/// </summary>
public class ToolWindowsTestInitializer : IModInitializer
{
private static bool driverQueued;
/// <summary>Nothing to do per mod: the driver is queued on resources-ready.</summary>
public void OnModLoaded(string modId) { }
/// <summary>Queues the driver once when the activation flag is present.</summary>
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));
}
}
}