From 869a15875ecf24ac5c6ce6fea64c8a7c5ea8e157 Mon Sep 17 00:00:00 2001 From: iisaev Date: Fri, 13 Nov 2026 17:47:11 +0100 Subject: [PATCH] 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. --- .../novel_flow/src/NovelFlowTestDriver.cs | 25 ++++++- .../windows/src/ToolWindowsTestDriverNode.cs | 67 ++++++++++++++++++- 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/test_suites/novel_flow/src/NovelFlowTestDriver.cs b/test_suites/novel_flow/src/NovelFlowTestDriver.cs index 1a3bacd..62553a4 100644 --- a/test_suites/novel_flow/src/NovelFlowTestDriver.cs +++ b/test_suites/novel_flow/src/NovelFlowTestDriver.cs @@ -33,7 +33,10 @@ namespace Cthangover.Tools.Tests.NovelFlow /// public partial class NovelFlowTestDriver : Node { - private const float TickSeconds = 0.3f; + private int readinessToken = int.MinValue; + private ulong readinessStarted; + private ulong readinessFrame; + private const float TickSeconds = 0.05f; private const float TimeoutSeconds = 150f; private const string Mod = "my_novel_game"; private const string Town = "my_novel_town"; @@ -97,8 +100,26 @@ namespace Cthangover.Tools.Tests.NovelFlow } } + // Deferred UI/scene work must finish before the original assertions run. + private bool WaitForReadyFrames(int token, bool ready, double timeoutSeconds = 30) + { + var now = global::Godot.Time.GetTicksMsec(); + if (readinessToken != token) + { + readinessToken = token; + readinessStarted = now; + readinessFrame = 0; + } + if (now - readinessStarted > timeoutSeconds * 1000) + throw new global::System.TimeoutException($"readiness at step {token}: scene/UI did not become ready"); + if (!ready) { readinessFrame = 0; return false; } + if (readinessFrame == 0) readinessFrame = global::Godot.Engine.GetProcessFrames(); + return global::Godot.Engine.GetProcessFrames() - readinessFrame >= 2; + } + private void RunStep() { + if (!WaitForReadyFrames(step, Boot.IsReady && Scenes != null && !GodotSceneService.IsTransitioning && (step < 2 || sceneEditor != null && sceneEditor.IsInsideTree()), 30)) return; switch (step) { case 0: @@ -182,7 +203,7 @@ namespace Cthangover.Tools.Tests.NovelFlow Next(); return; case 8: - if (++wait < 3 || GodotSceneService.IsTransitioning) + if (GodotSceneService.IsTransitioning || Scenes.CurrentSceneName != Town) return; foreach (var window in GetTree().Root.GetChildren().OfType().ToList()) window.EmitSignal(Window.SignalName.CloseRequested); diff --git a/test_suites/windows/src/ToolWindowsTestDriverNode.cs b/test_suites/windows/src/ToolWindowsTestDriverNode.cs index b9e12dd..7072775 100644 --- a/test_suites/windows/src/ToolWindowsTestDriverNode.cs +++ b/test_suites/windows/src/ToolWindowsTestDriverNode.cs @@ -12,7 +12,7 @@ namespace Cthangover.Tools.Tests.Windows /// /// Opens every registered tool window one by one through the same path the /// toolbar button uses ( → ), - /// lets it lay out for a couple of ticks, checks that it entered the tree + /// 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 @@ -34,6 +34,10 @@ namespace Cthangover.Tools.Tests.Windows 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(); @@ -41,8 +45,9 @@ namespace Cthangover.Tools.Tests.Windows public ToolWindowsTestDriverNode(string shotsDir) { _shotsDir = shotsDir; + _tickSeconds = string.IsNullOrEmpty(shotsDir) ? 0.05 : TickSeconds; OS.AddLogger(_errors); - var timer = new Timer { WaitTime = TickSeconds, Autostart = true }; + var timer = new Timer { WaitTime = _tickSeconds, Autostart = true }; AddChild(timer); timer.Timeout += Tick; } @@ -52,7 +57,7 @@ namespace Cthangover.Tools.Tests.Windows if (_finished) return; - _elapsed += TickSeconds; + _elapsed += _tickSeconds; if (_elapsed >= TimeoutSeconds) { Fail($"timeout ({TimeoutSeconds:0}s) at tool #{_toolIndex}"); @@ -80,6 +85,12 @@ namespace Cthangover.Tools.Tests.Windows if (_window == null) return; + if (string.IsNullOrEmpty(_shotsDir)) + { + AdvanceWhenReady(); + return; + } + if (--_settle == ExerciseTick) { try @@ -99,6 +110,53 @@ namespace Cthangover.Tools.Tests.Windows 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++; @@ -114,6 +172,9 @@ namespace Cthangover.Tools.Tests.Windows { _window = ToolWindow.ShowWindow(tool.CreateWindow()); _settle = SettleTicks; + _exercised = false; + _waitStarted = Time.GetTicksMsec(); + _readyFrame = 0; } catch (System.Exception ex) {