Files
mod_editor_tools/test_suites/windows/src/ToolWindowsTestDriverNode.cs
T
cthangover 869a15875e test: replace fixed waits with observable GUI readiness
Advance integration drivers only after their requested controls, game state or file effects are observable. Preserve the behavioral assertions and shared viewport capture pipeline.
2026-11-13 17:47:11 +01:00

252 lines
8.6 KiB
C#

using System.Collections.Generic;
using System.IO;
using Cthangover.Core.Bootstrap;
using Cthangover.Core.UI.Tool;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Mods.Interface.View;
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"/>),
/// waits for visible layout and two real frames (visual settles with screenshots), 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.
/// </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 bool _exercised;
private ulong _waitStarted;
private ulong _readyFrame;
private readonly double _tickSeconds;
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;
_tickSeconds = string.IsNullOrEmpty(shotsDir) ? 0.05 : TickSeconds;
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;
_tools.AddRange(ToolFactory.Instance.GetAll());
Check(_tools.Count > 0, $"tools registered ({_tools.Count})");
var toolbarTools = new HashSet<string>();
foreach (var button in ToolBoxButtonFactory.Instance.GetVisible())
toolbarTools.Add(button.ToolId);
foreach (var tool in _tools)
Check(toolbarTools.Contains(tool.Id), $"{tool.Id}: reachable from the in-game toolbar");
OpenNext();
return;
}
if (_window == null)
return;
if (string.IsNullOrEmpty(_shotsDir))
{
AdvanceWhenReady();
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 AdvanceWhenReady()
{
var id = _tools[_toolIndex].Id;
var ready = GodotObject.IsInstanceValid(_window) && _window.IsInsideTree()
&& _window.Visible && _window.Size.X > 0 && _window.Size.Y > 0;
// This exercise intentionally schedules a metadata debounce while detached.
if (ready && _exercised && id == "scenario_editor")
ready = MetadataIsReady();
if (Time.GetTicksMsec() - _waitStarted > 10000)
{
Fail($"{id}: timed out waiting for {(_exercised ? "exercise completion" : "visible layout")}");
Finish();
return;
}
if (!ready)
{
_readyFrame = 0;
return;
}
if (_readyFrame == 0)
_readyFrame = Engine.GetProcessFrames();
if (Engine.GetProcessFrames() - _readyFrame < 2)
return;
if (!_exercised)
{
try { ToolWindowsExercises.Run(id, _window, Check); }
catch (System.Exception ex)
{
Fail($"{id}: interaction threw {ex.GetType().Name}: {ex.Message}");
}
_exercised = true;
_waitStarted = Time.GetTicksMsec();
_readyFrame = 0;
return;
}
InspectAndClose();
OpenNext();
}
private bool MetadataIsReady()
{
foreach (var child in _window.FindChildren("InfoScene", "", true, false))
if (child is Label label && label.Text == "timer_lifecycle_probe")
return true;
return false;
}
private void OpenNext()
{
_toolIndex++;
if (_toolIndex >= _tools.Count)
{
Finish();
return;
}
var tool = _tools[_toolIndex];
_errors.Drain();
try
{
_window = ToolWindow.ShowWindow(tool.CreateWindow());
_settle = SettleTicks;
_exercised = false;
_waitStarted = Time.GetTicksMsec();
_readyFrame = 0;
}
catch (System.Exception ex)
{
Fail($"{tool.Id}: open threw {ex.GetType().Name}: {ex.Message}");
_window = null;
OpenNext();
}
}
private void InspectAndClose()
{
var id = _tools[_toolIndex].Id;
var window = _window;
_window = null;
ToolWindowsExercises.Verify(id, window, Check);
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 path = Path.Combine(dir, id + ".png");
ViewportCapture.SavePng(GetViewport(), 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}");
Cthangover.Core.Bootstrap.ApplicationExit.Quit(GetTree(), _failed == 0 ? 0 : 1);
}
private static void Log(string message) => GameLogger.Log("TEST", message);
}
}