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 { /// /// Opens every registered tool window one by one through the same path the /// toolbar button uses ( → ), /// 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 /// [TEST_RESULT] name=tools_windows passed=N failed=M and the exit code. /// 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 bool _exercised; private ulong _waitStarted; private ulong _readyFrame; private readonly double _tickSeconds; private Window _window; private readonly ToolWindowsErrorLogger _errors = new(); /// Creates the driver; null or empty disables screenshots. 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(); 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); } }