test(runtime): add deterministic world gate artifacts

This commit is contained in:
Erik 2026-07-20 22:37:16 +02:00
parent db45b81f75
commit 354c2adc2e
11 changed files with 703 additions and 10 deletions

View file

@ -149,7 +149,8 @@ public sealed record RetailUiProbeBindings(
bool DumpOnStart,
Action<string> Log,
Func<InputAction, bool> PressInput,
Func<InputAction, bool, bool> SetInputHeld);
Func<InputAction, bool, bool> SetInputHeld,
Testing.IRetailUiAutomationRuntime? Runtime = null);
public sealed record RetailUiCursorBindings(
CursorFeedbackController Feedback,
@ -266,7 +267,8 @@ public sealed class RetailUiRuntime : IDisposable
bindings.Chat.CommandBus(),
ChatChannelKind.Say),
bindings.Probe.PressInput,
bindings.Probe.SetInputHeld);
bindings.Probe.SetInputHeld,
bindings.Probe.Runtime);
}
}

View file

@ -7,6 +7,21 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.App.UI.Testing;
/// <summary>
/// Narrow bridge from retained-UI scripts to render/world lifecycle
/// diagnostics. Implementations run on the same update/render thread as the
/// script; they must not mutate gameplay state or call GL from another thread.
/// </summary>
public interface IRetailUiAutomationRuntime
{
bool IsWorldReady { get; }
bool IsWorldViewportVisible { get; }
int PortalMaterializationCount { get; }
bool TryWriteCheckpoint(string name, out string error);
bool TryRequestScreenshot(string name, out string error);
bool IsScreenshotComplete(string name);
}
/// <summary>
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
/// It is intended for local diagnostic launches, not for gameplay. Commands
@ -20,6 +35,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private readonly Action<string>? _submitCommand;
private readonly Func<InputAction, bool>? _pressInput;
private readonly Func<InputAction, bool, bool>? _setInputHeld;
private readonly IRetailUiAutomationRuntime? _runtime;
private readonly List<ScriptCommand> _commands = new();
private readonly HashSet<InputAction> _heldInputs = new();
private readonly bool _dumpOnStart;
@ -39,13 +55,15 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
Action<string>? log = null,
Action<string>? submitCommand = null,
Func<InputAction, bool>? pressInput = null,
Func<InputAction, bool, bool>? setInputHeld = null)
Func<InputAction, bool, bool>? setInputHeld = null,
IRetailUiAutomationRuntime? runtime = null)
{
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
_log = log ?? (_ => { });
_submitCommand = submitCommand;
_pressInput = pressInput;
_setInputHeld = setInputHeld;
_runtime = runtime;
_dumpOnStart = dumpOnStart;
if (!string.IsNullOrWhiteSpace(scriptPath))
@ -162,6 +180,8 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
"assert" => DoAssert(command),
"command" => DoCommand(command),
"input" => DoInput(command),
"checkpoint" => DoCheckpoint(command),
"screenshot" => DoScreenshot(command),
_ => Stop(command, $"unknown command '{p[0]}'"),
};
}
@ -236,7 +256,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
private bool DoWait(ScriptCommand command)
{
var p = command.Parts;
if (p.Length < 2) return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
if (p.Length < 2) return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ...");
string target = p[1].ToLowerInvariant();
if (target == "item")
@ -256,7 +276,30 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
if (target == "ms")
return DoSleep(command);
return Stop(command, "usage: wait item <guid> | wait element <datId> | wait ms <milliseconds>");
if (target == "world-ready")
{
if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable");
if (_runtime.IsWorldReady) return true;
return WaitOrTimeout(command, TimeoutMs(p, 2, 60000), "world readiness");
}
if (target == "world-visible")
{
if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable");
if (_runtime.IsWorldViewportVisible) return true;
return WaitOrTimeout(command, TimeoutMs(p, 2, 60000), "normal world viewport");
}
if (target == "materialized")
{
if (_runtime is null) return Stop(command, "world lifecycle automation is unavailable");
if (p.Length < 3 || !TryParseInt(p[2], out int occurrence) || occurrence <= 0)
return Stop(command, "usage: wait materialized <occurrence> [timeoutMs]");
if (_runtime.PortalMaterializationCount >= occurrence) return true;
return WaitOrTimeout(command, TimeoutMs(p, 3, 60000), $"portal materialization {occurrence}");
}
return Stop(command, "usage: wait item|element|ms|world-ready|world-visible|materialized ...");
}
private bool DoSleep(ScriptCommand command)
@ -356,6 +399,31 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
}
}
private bool DoCheckpoint(ScriptCommand command)
{
if (command.Parts.Length != 2)
return Stop(command, "usage: checkpoint <name>");
if (_runtime is null)
return Stop(command, "world lifecycle automation is unavailable");
return _runtime.TryWriteCheckpoint(command.Parts[1], out string error)
|| Stop(command, error);
}
private bool DoScreenshot(ScriptCommand command)
{
if (command.Parts.Length is < 2 or > 3)
return Stop(command, "usage: screenshot <name> [timeoutMs]");
if (_runtime is null)
return Stop(command, "world lifecycle automation is unavailable");
string name = command.Parts[1];
if (!_runtime.TryRequestScreenshot(name, out string error))
return Stop(command, error);
if (_runtime.IsScreenshotComplete(name))
return true;
return WaitOrTimeout(command, TimeoutMs(command.Parts, 2, 10000), $"screenshot '{name}'");
}
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
{
if (!HasExceeded(timeoutMs)) return false;