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:
1 parent
8742aa3da5
commit
55ef9416ef
6 files changed
+445
-1
No files matched your search
@@ -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=<dir></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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user