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
+4
-1
@@ -8,5 +8,8 @@
|
|||||||
"sources": [
|
"sources": [
|
||||||
"src/**/*.cs"
|
"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"]}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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