test(runtime): add deterministic world gate artifacts
This commit is contained in:
parent
db45b81f75
commit
354c2adc2e
11 changed files with 703 additions and 10 deletions
|
|
@ -62,6 +62,9 @@ public sealed class FrameProfiler : IDisposable
|
||||||
private bool _wbDiagNoticePrinted;
|
private bool _wbDiagNoticePrinted;
|
||||||
private bool _wasEnabled;
|
private bool _wasEnabled;
|
||||||
|
|
||||||
|
/// <summary>Most recent immutable report line, for explicit automation checkpoints.</summary>
|
||||||
|
public string? LastReport { get; private set; }
|
||||||
|
|
||||||
public FrameProfiler()
|
public FrameProfiler()
|
||||||
{
|
{
|
||||||
_stageUs = new FrameStatsBuffer[StageCount];
|
_stageUs = new FrameStatsBuffer[StageCount];
|
||||||
|
|
@ -146,8 +149,9 @@ public sealed class FrameProfiler : IDisposable
|
||||||
int gc0 = GC.CollectionCount(0) - _gc0Base;
|
int gc0 = GC.CollectionCount(0) - _gc0Base;
|
||||||
int gc1 = GC.CollectionCount(1) - _gc1Base;
|
int gc1 = GC.CollectionCount(1) - _gc1Base;
|
||||||
int gc2 = GC.CollectionCount(2) - _gc2Base;
|
int gc2 = GC.CollectionCount(2) - _gc2Base;
|
||||||
Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs,
|
LastReport = FormatReport(_framesInWindow, _cpuUs, _gpuUs,
|
||||||
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs));
|
gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs);
|
||||||
|
Console.WriteLine(LastReport);
|
||||||
_lastReportTicks = nowTicks;
|
_lastReportTicks = nowTicks;
|
||||||
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
|
_gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2;
|
||||||
_framesInWindow = 0;
|
_framesInWindow = 0;
|
||||||
|
|
|
||||||
148
src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Normal file
148
src/AcDream.App/Diagnostics/FrameScreenshotController.cs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
using Silk.NET.OpenGL;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
|
||||||
|
namespace AcDream.App.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Render-thread owner for diagnostic captures of the complete default
|
||||||
|
/// framebuffer. Requests may be made while retained UI ticks; capture occurs
|
||||||
|
/// later in the same frame after world, retained UI, and optional ImGui draw.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FrameScreenshotController
|
||||||
|
{
|
||||||
|
private enum CaptureState
|
||||||
|
{
|
||||||
|
Pending,
|
||||||
|
Complete,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CaptureStatus(CaptureState State, string? Error = null);
|
||||||
|
|
||||||
|
private readonly Func<(int Width, int Height)> _getSize;
|
||||||
|
private readonly Func<int, int, byte[]> _readRgba;
|
||||||
|
private readonly string _directory;
|
||||||
|
private readonly Action<string> _log;
|
||||||
|
private readonly Queue<string> _pending = new();
|
||||||
|
private readonly Dictionary<string, CaptureStatus> _status =
|
||||||
|
new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public FrameScreenshotController(
|
||||||
|
GL gl,
|
||||||
|
Func<(int Width, int Height)> getSize,
|
||||||
|
string directory,
|
||||||
|
Action<string>? log = null)
|
||||||
|
: this(getSize, (width, height) => ReadDefaultFramebuffer(gl, width, height), directory, log)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(gl);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal FrameScreenshotController(
|
||||||
|
Func<(int Width, int Height)> getSize,
|
||||||
|
Func<int, int, byte[]> readRgba,
|
||||||
|
string directory,
|
||||||
|
Action<string>? log = null)
|
||||||
|
{
|
||||||
|
_getSize = getSize ?? throw new ArgumentNullException(nameof(getSize));
|
||||||
|
_readRgba = readRgba ?? throw new ArgumentNullException(nameof(readRgba));
|
||||||
|
_directory = string.IsNullOrWhiteSpace(directory)
|
||||||
|
? throw new ArgumentException("A screenshot directory is required.", nameof(directory))
|
||||||
|
: Path.GetFullPath(directory);
|
||||||
|
_log = log ?? (_ => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRequest(string name, out string error)
|
||||||
|
{
|
||||||
|
if (!AutomationArtifactName.TryValidate(name, out error))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (_status.TryGetValue(name, out CaptureStatus? status))
|
||||||
|
{
|
||||||
|
if (status.State != CaptureState.Failed)
|
||||||
|
return true;
|
||||||
|
error = status.Error ?? $"screenshot '{name}' failed";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_status.Add(name, new CaptureStatus(CaptureState.Pending));
|
||||||
|
_pending.Enqueue(name);
|
||||||
|
_log($"[world-gate] screenshot-request name={name}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsComplete(string name) =>
|
||||||
|
_status.TryGetValue(name, out CaptureStatus? status)
|
||||||
|
&& status.State == CaptureState.Complete;
|
||||||
|
|
||||||
|
/// <summary>Captures at most one queued image on the current GL thread.</summary>
|
||||||
|
public void CapturePending()
|
||||||
|
{
|
||||||
|
if (_pending.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
string name = _pending.Dequeue();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(int width, int height) = _getSize();
|
||||||
|
if (width <= 0 || height <= 0)
|
||||||
|
throw new InvalidOperationException($"invalid framebuffer size {width}x{height}");
|
||||||
|
|
||||||
|
byte[] pixels = _readRgba(width, height);
|
||||||
|
int expected = checked(width * height * 4);
|
||||||
|
if (pixels.Length != expected)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"framebuffer read returned {pixels.Length} bytes; expected {expected}");
|
||||||
|
|
||||||
|
byte[] flipped = FlipRows(pixels, width, height);
|
||||||
|
Directory.CreateDirectory(_directory);
|
||||||
|
string path = Path.Combine(_directory, name + ".png");
|
||||||
|
string temporaryPath = path + ".tmp";
|
||||||
|
using (Image<Rgba32> image = Image.LoadPixelData<Rgba32>(flipped, width, height))
|
||||||
|
image.SaveAsPng(temporaryPath);
|
||||||
|
File.Move(temporaryPath, path, overwrite: true);
|
||||||
|
|
||||||
|
_status[name] = new CaptureStatus(CaptureState.Complete);
|
||||||
|
_log($"[world-gate] screenshot-complete name={name} path={path} size={width}x{height}");
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
string message = $"screenshot '{name}' failed: {exception.Message}";
|
||||||
|
_status[name] = new CaptureStatus(CaptureState.Failed, message);
|
||||||
|
_log($"[world-gate] screenshot-failed name={name} error={exception.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] FlipRows(byte[] pixels, int width, int height)
|
||||||
|
{
|
||||||
|
int stride = checked(width * 4);
|
||||||
|
byte[] flipped = new byte[pixels.Length];
|
||||||
|
for (int row = 0; row < height; row++)
|
||||||
|
{
|
||||||
|
System.Buffer.BlockCopy(
|
||||||
|
pixels,
|
||||||
|
row * stride,
|
||||||
|
flipped,
|
||||||
|
(height - 1 - row) * stride,
|
||||||
|
stride);
|
||||||
|
}
|
||||||
|
return flipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static unsafe byte[] ReadDefaultFramebuffer(GL gl, int width, int height)
|
||||||
|
{
|
||||||
|
byte[] pixels = new byte[checked(width * height * 4)];
|
||||||
|
fixed (byte* pointer = pixels)
|
||||||
|
{
|
||||||
|
gl.ReadPixels(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(uint)width,
|
||||||
|
(uint)height,
|
||||||
|
PixelFormat.Rgba,
|
||||||
|
PixelType.UnsignedByte,
|
||||||
|
pointer);
|
||||||
|
}
|
||||||
|
return pixels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using AcDream.App.UI.Testing;
|
||||||
|
|
||||||
|
namespace AcDream.App.Diagnostics;
|
||||||
|
|
||||||
|
internal static class AutomationArtifactName
|
||||||
|
{
|
||||||
|
public static bool TryValidate(string? name, out string error)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name) || name.Length > 80)
|
||||||
|
{
|
||||||
|
error = "artifact name must contain 1-80 characters";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (char c in name)
|
||||||
|
{
|
||||||
|
if (!char.IsAsciiLetterOrDigit(c) && c is not '-' and not '_')
|
||||||
|
{
|
||||||
|
error = $"artifact name '{name}' may contain only letters, digits, '-' and '_'";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed record WorldLifecycleResourceSnapshot(
|
||||||
|
int LoadedLandblocks,
|
||||||
|
int WorldEntities,
|
||||||
|
int AnimatedEntities,
|
||||||
|
int VisibleLandblocks,
|
||||||
|
int TotalLandblocks,
|
||||||
|
int LiveEntities,
|
||||||
|
int MaterializedLiveEntities,
|
||||||
|
int PendingLiveTeardowns,
|
||||||
|
int PendingLandblockRetirements,
|
||||||
|
int ParticleEmitters,
|
||||||
|
int Particles,
|
||||||
|
int ParticleBindings,
|
||||||
|
int ParticleOwners,
|
||||||
|
int EffectOwners,
|
||||||
|
int LightOwners,
|
||||||
|
int ScriptOwners,
|
||||||
|
int ActiveScripts,
|
||||||
|
int MeshRenderData,
|
||||||
|
int MeshAtlasArrays,
|
||||||
|
long MeshEstimatedBytes,
|
||||||
|
int StagedMeshUploads,
|
||||||
|
long StagedMeshBytes,
|
||||||
|
long TrackedGpuBytes,
|
||||||
|
int TrackedGpuBuffers,
|
||||||
|
int TrackedGpuTextures,
|
||||||
|
int OwnedCompositeTextures,
|
||||||
|
int CompositeTextureOwners,
|
||||||
|
int ActiveParticleTextures,
|
||||||
|
int ParticleTextureOwners,
|
||||||
|
int CompositeWarmupPending,
|
||||||
|
long ManagedBytes,
|
||||||
|
long ManagedCommittedBytes,
|
||||||
|
double Fps,
|
||||||
|
double FrameMilliseconds,
|
||||||
|
string? LastFrameProfile);
|
||||||
|
|
||||||
|
internal sealed record WorldLifecycleCheckpoint(
|
||||||
|
int Sequence,
|
||||||
|
string Name,
|
||||||
|
DateTime TimestampUtc,
|
||||||
|
int ProcessId,
|
||||||
|
WorldRevealLifecycleSnapshot Reveal,
|
||||||
|
WorldLifecycleResourceSnapshot Resources);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic-only runtime seam used by production retained-UI scripts. It
|
||||||
|
/// writes one structured checkpoint at explicit script edges and delegates GL
|
||||||
|
/// capture to the render-thread screenshot owner.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class WorldLifecycleAutomationController : IRetailUiAutomationRuntime
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly Func<WorldRevealLifecycleSnapshot> _getReveal;
|
||||||
|
private readonly Func<int> _getPortalMaterializationCount;
|
||||||
|
private readonly Func<WorldLifecycleResourceSnapshot> _captureResources;
|
||||||
|
private readonly FrameScreenshotController _screenshots;
|
||||||
|
private readonly string _artifactDirectory;
|
||||||
|
private readonly Action<string> _log;
|
||||||
|
private int _sequence;
|
||||||
|
|
||||||
|
public WorldLifecycleAutomationController(
|
||||||
|
Func<WorldRevealLifecycleSnapshot> getReveal,
|
||||||
|
Func<int> getPortalMaterializationCount,
|
||||||
|
Func<WorldLifecycleResourceSnapshot> captureResources,
|
||||||
|
FrameScreenshotController screenshots,
|
||||||
|
string artifactDirectory,
|
||||||
|
Action<string>? log = null)
|
||||||
|
{
|
||||||
|
_getReveal = getReveal ?? throw new ArgumentNullException(nameof(getReveal));
|
||||||
|
_getPortalMaterializationCount = getPortalMaterializationCount
|
||||||
|
?? throw new ArgumentNullException(nameof(getPortalMaterializationCount));
|
||||||
|
_captureResources = captureResources ?? throw new ArgumentNullException(nameof(captureResources));
|
||||||
|
_screenshots = screenshots ?? throw new ArgumentNullException(nameof(screenshots));
|
||||||
|
_artifactDirectory = string.IsNullOrWhiteSpace(artifactDirectory)
|
||||||
|
? throw new ArgumentException("An automation artifact directory is required.", nameof(artifactDirectory))
|
||||||
|
: Path.GetFullPath(artifactDirectory);
|
||||||
|
_log = log ?? (_ => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsWorldReady => _getReveal().IsReady;
|
||||||
|
public bool IsWorldViewportVisible => _getReveal().WorldViewportObserved;
|
||||||
|
public int PortalMaterializationCount => _getPortalMaterializationCount();
|
||||||
|
|
||||||
|
public bool TryWriteCheckpoint(string name, out string error)
|
||||||
|
{
|
||||||
|
if (!AutomationArtifactName.TryValidate(name, out error))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_artifactDirectory);
|
||||||
|
var checkpoint = new WorldLifecycleCheckpoint(
|
||||||
|
Sequence: checked(++_sequence),
|
||||||
|
Name: name,
|
||||||
|
TimestampUtc: DateTime.UtcNow,
|
||||||
|
ProcessId: Environment.ProcessId,
|
||||||
|
Reveal: _getReveal(),
|
||||||
|
Resources: _captureResources());
|
||||||
|
string json = JsonSerializer.Serialize(checkpoint, JsonOptions);
|
||||||
|
string timelinePath = Path.Combine(_artifactDirectory, "world-lifecycle.checkpoints.jsonl");
|
||||||
|
File.AppendAllText(timelinePath, json + Environment.NewLine);
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(_artifactDirectory, $"checkpoint-{name}.json"),
|
||||||
|
json + Environment.NewLine);
|
||||||
|
_log($"[world-gate] checkpoint name={name} sequence={checkpoint.Sequence} path={timelinePath}");
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
error = $"checkpoint '{name}' failed: {exception.Message}";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRequestScreenshot(string name, out string error) =>
|
||||||
|
_screenshots.TryRequest(name, out error);
|
||||||
|
|
||||||
|
public bool IsScreenshotComplete(string name) => _screenshots.IsComplete(name);
|
||||||
|
}
|
||||||
|
|
@ -134,6 +134,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// per OnRender + three stage scopes. All logic lives in
|
// per OnRender + three stage scopes. All logic lives in
|
||||||
// AcDream.App.Diagnostics.FrameProfiler (structure rule 1).
|
// AcDream.App.Diagnostics.FrameProfiler (structure rule 1).
|
||||||
private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new();
|
private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new();
|
||||||
|
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
|
||||||
|
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
|
||||||
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
|
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
|
||||||
private ResourceShutdownTransaction? _shutdown;
|
private ResourceShutdownTransaction? _shutdown;
|
||||||
private readonly FramePacingController _framePacing = new();
|
private readonly FramePacingController _framePacing = new();
|
||||||
|
|
@ -2275,6 +2277,24 @@ public sealed class GameWindow : IDisposable
|
||||||
ScreenSize: () => (_window.Size.X, _window.Size.Y));
|
ScreenSize: () => (_window.Size.X, _window.Size.Y));
|
||||||
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
|
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
|
||||||
|
|
||||||
|
if (_options.UiProbeEnabled
|
||||||
|
&& _options.AutomationArtifactDirectory is { } artifactDirectory)
|
||||||
|
{
|
||||||
|
_frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController(
|
||||||
|
_gl!,
|
||||||
|
() => (_window!.Size.X, _window.Size.Y),
|
||||||
|
System.IO.Path.Combine(artifactDirectory, "screenshots"),
|
||||||
|
UiProbeLog);
|
||||||
|
_worldLifecycleAutomation =
|
||||||
|
new AcDream.App.Diagnostics.WorldLifecycleAutomationController(
|
||||||
|
() => _worldRevealTelemetry.Snapshot,
|
||||||
|
() => _worldRevealTelemetry.PortalMaterializationCount,
|
||||||
|
CaptureWorldLifecycleResourceSnapshot,
|
||||||
|
_frameScreenshots,
|
||||||
|
artifactDirectory,
|
||||||
|
UiProbeLog);
|
||||||
|
}
|
||||||
|
|
||||||
_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(
|
_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(
|
||||||
new AcDream.App.UI.RetailUiRuntimeBindings(
|
new AcDream.App.UI.RetailUiRuntimeBindings(
|
||||||
Host: _uiHost,
|
Host: _uiHost,
|
||||||
|
|
@ -2426,7 +2446,8 @@ public sealed class GameWindow : IDisposable
|
||||||
UiProbeLog,
|
UiProbeLog,
|
||||||
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
|
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
|
||||||
(action, held) =>
|
(action, held) =>
|
||||||
_inputDispatcher?.TrySetAutomationActionHeld(action, held) == true)));
|
_inputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
|
||||||
|
_worldLifecycleAutomation)));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -11226,6 +11247,11 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explicit diagnostic captures are requested by the retained-UI
|
||||||
|
// automation tick above and executed only after every presentation
|
||||||
|
// layer has drawn into the back buffer on this render thread.
|
||||||
|
_frameScreenshots?.CapturePending();
|
||||||
|
|
||||||
// Update the window title with performance stats every ~0.5s.
|
// Update the window title with performance stats every ~0.5s.
|
||||||
_perfAccum += deltaSeconds;
|
_perfAccum += deltaSeconds;
|
||||||
_perfFrameCount++;
|
_perfFrameCount++;
|
||||||
|
|
@ -11235,6 +11261,8 @@ public sealed class GameWindow : IDisposable
|
||||||
double fps = _perfFrameCount / _perfAccum;
|
double fps = _perfFrameCount / _perfAccum;
|
||||||
int entityCount = _worldState.Entities.Count;
|
int entityCount = _worldState.Entities.Count;
|
||||||
int animatedCount = _animatedEntities.Count;
|
int animatedCount = _animatedEntities.Count;
|
||||||
|
_lastVisibleLandblocks = visibleLandblocks;
|
||||||
|
_lastTotalLandblocks = totalLandblocks;
|
||||||
|
|
||||||
// Keep the developer diagnostic title independent from retail's
|
// Keep the developer diagnostic title independent from retail's
|
||||||
// /framerate switch. That command owns the in-world SmartBox readout;
|
// /framerate switch. That command owns the in-world SmartBox readout;
|
||||||
|
|
@ -11346,6 +11374,50 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot
|
||||||
|
CaptureWorldLifecycleResourceSnapshot()
|
||||||
|
{
|
||||||
|
var meshManager = _wbMeshAdapter?.MeshManager;
|
||||||
|
var mesh = meshManager?.Diagnostics ?? default;
|
||||||
|
GCMemoryInfo memory = GC.GetGCMemoryInfo();
|
||||||
|
return new AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot(
|
||||||
|
LoadedLandblocks: _worldState.LoadedLandblockIds.Count,
|
||||||
|
WorldEntities: _worldState.Entities.Count,
|
||||||
|
AnimatedEntities: _animatedEntities.Count,
|
||||||
|
VisibleLandblocks: _lastVisibleLandblocks,
|
||||||
|
TotalLandblocks: _lastTotalLandblocks,
|
||||||
|
LiveEntities: _liveEntities?.Count ?? 0,
|
||||||
|
MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0,
|
||||||
|
PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0,
|
||||||
|
PendingLandblockRetirements: _landblockRetirements?.PendingCount ?? 0,
|
||||||
|
ParticleEmitters: _particleSystem?.ActiveEmitterCount ?? 0,
|
||||||
|
Particles: _particleSystem?.ActiveParticleCount ?? 0,
|
||||||
|
ParticleBindings: _particleSink?.ActiveBindingCount ?? 0,
|
||||||
|
ParticleOwners: _particleSink?.TrackedOwnerCount ?? 0,
|
||||||
|
EffectOwners: _entityEffects?.ReadyOwnerCount ?? 0,
|
||||||
|
LightOwners: _liveEntityLights?.TrackedOwnerCount ?? 0,
|
||||||
|
ScriptOwners: _scriptRunner?.ActiveOwnerCount ?? 0,
|
||||||
|
ActiveScripts: _scriptRunner?.ActiveScriptCount ?? 0,
|
||||||
|
MeshRenderData: mesh.RenderData,
|
||||||
|
MeshAtlasArrays: mesh.AtlasArrays,
|
||||||
|
MeshEstimatedBytes: mesh.EstimatedBytes,
|
||||||
|
StagedMeshUploads: _wbMeshAdapter?.StagedUploadBacklog ?? 0,
|
||||||
|
StagedMeshBytes: _wbMeshAdapter?.StagedUploadBytes ?? 0,
|
||||||
|
TrackedGpuBytes: AcDream.App.Rendering.Wb.GpuMemoryTracker.AllocatedBytes,
|
||||||
|
TrackedGpuBuffers: AcDream.App.Rendering.Wb.GpuMemoryTracker.BufferCount,
|
||||||
|
TrackedGpuTextures: AcDream.App.Rendering.Wb.GpuMemoryTracker.TextureCount,
|
||||||
|
OwnedCompositeTextures: _textureCache?.OwnedBindlessTextureCount ?? 0,
|
||||||
|
CompositeTextureOwners: _textureCache?.TextureOwnerCount ?? 0,
|
||||||
|
ActiveParticleTextures: _textureCache?.ActiveParticleTextureCount ?? 0,
|
||||||
|
ParticleTextureOwners: _textureCache?.ParticleTextureOwnerCount ?? 0,
|
||||||
|
CompositeWarmupPending: _wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0,
|
||||||
|
ManagedBytes: GC.GetTotalMemory(forceFullCollection: false),
|
||||||
|
ManagedCommittedBytes: memory.TotalCommittedBytes,
|
||||||
|
Fps: _lastFps,
|
||||||
|
FrameMilliseconds: _lastFrameMs,
|
||||||
|
LastFrameProfile: _frameProfiler.LastReport);
|
||||||
|
}
|
||||||
|
|
||||||
private bool RunRemoteTeleportHook(
|
private bool RunRemoteTeleportHook(
|
||||||
uint serverGuid,
|
uint serverGuid,
|
||||||
uint localEntityId,
|
uint localEntityId,
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,8 @@ public sealed record RuntimeOptions(
|
||||||
bool RetailUi,
|
bool RetailUi,
|
||||||
string? AcDir,
|
string? AcDir,
|
||||||
bool UiProbeDump,
|
bool UiProbeDump,
|
||||||
string? UiProbeScript)
|
string? UiProbeScript,
|
||||||
|
string? AutomationArtifactDirectory)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Build options from the process environment. Used by
|
/// Build options from the process environment. Used by
|
||||||
|
|
@ -94,7 +95,9 @@ public sealed record RuntimeOptions(
|
||||||
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")),
|
||||||
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
|
AcDir: NullIfEmpty(env("ACDREAM_AC_DIR")),
|
||||||
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
|
UiProbeDump: IsExactlyOne(env("ACDREAM_UI_PROBE_DUMP")),
|
||||||
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")));
|
UiProbeScript: NullIfEmpty(env("ACDREAM_UI_PROBE_SCRIPT")),
|
||||||
|
AutomationArtifactDirectory:
|
||||||
|
NullIfEmpty(env("ACDREAM_AUTOMATION_ARTIFACT_DIR")));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
/// <summary>True iff live-mode credentials are present and valid for connecting.</summary>
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,8 @@ public sealed record RetailUiProbeBindings(
|
||||||
bool DumpOnStart,
|
bool DumpOnStart,
|
||||||
Action<string> Log,
|
Action<string> Log,
|
||||||
Func<InputAction, bool> PressInput,
|
Func<InputAction, bool> PressInput,
|
||||||
Func<InputAction, bool, bool> SetInputHeld);
|
Func<InputAction, bool, bool> SetInputHeld,
|
||||||
|
Testing.IRetailUiAutomationRuntime? Runtime = null);
|
||||||
|
|
||||||
public sealed record RetailUiCursorBindings(
|
public sealed record RetailUiCursorBindings(
|
||||||
CursorFeedbackController Feedback,
|
CursorFeedbackController Feedback,
|
||||||
|
|
@ -266,7 +267,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
bindings.Chat.CommandBus(),
|
bindings.Chat.CommandBus(),
|
||||||
ChatChannelKind.Say),
|
ChatChannelKind.Say),
|
||||||
bindings.Probe.PressInput,
|
bindings.Probe.PressInput,
|
||||||
bindings.Probe.SetInputHeld);
|
bindings.Probe.SetInputHeld,
|
||||||
|
bindings.Probe.Runtime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,21 @@ using AcDream.UI.Abstractions.Input;
|
||||||
|
|
||||||
namespace AcDream.App.UI.Testing;
|
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>
|
/// <summary>
|
||||||
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
|
/// Tiny one-thread script runner for <see cref="RetailUiAutomationProbe"/>.
|
||||||
/// It is intended for local diagnostic launches, not for gameplay. Commands
|
/// 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 Action<string>? _submitCommand;
|
||||||
private readonly Func<InputAction, bool>? _pressInput;
|
private readonly Func<InputAction, bool>? _pressInput;
|
||||||
private readonly Func<InputAction, bool, bool>? _setInputHeld;
|
private readonly Func<InputAction, bool, bool>? _setInputHeld;
|
||||||
|
private readonly IRetailUiAutomationRuntime? _runtime;
|
||||||
private readonly List<ScriptCommand> _commands = new();
|
private readonly List<ScriptCommand> _commands = new();
|
||||||
private readonly HashSet<InputAction> _heldInputs = new();
|
private readonly HashSet<InputAction> _heldInputs = new();
|
||||||
private readonly bool _dumpOnStart;
|
private readonly bool _dumpOnStart;
|
||||||
|
|
@ -39,13 +55,15 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
|
||||||
Action<string>? log = null,
|
Action<string>? log = null,
|
||||||
Action<string>? submitCommand = null,
|
Action<string>? submitCommand = null,
|
||||||
Func<InputAction, bool>? pressInput = 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));
|
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
|
||||||
_log = log ?? (_ => { });
|
_log = log ?? (_ => { });
|
||||||
_submitCommand = submitCommand;
|
_submitCommand = submitCommand;
|
||||||
_pressInput = pressInput;
|
_pressInput = pressInput;
|
||||||
_setInputHeld = setInputHeld;
|
_setInputHeld = setInputHeld;
|
||||||
|
_runtime = runtime;
|
||||||
_dumpOnStart = dumpOnStart;
|
_dumpOnStart = dumpOnStart;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(scriptPath))
|
if (!string.IsNullOrWhiteSpace(scriptPath))
|
||||||
|
|
@ -162,6 +180,8 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
|
||||||
"assert" => DoAssert(command),
|
"assert" => DoAssert(command),
|
||||||
"command" => DoCommand(command),
|
"command" => DoCommand(command),
|
||||||
"input" => DoInput(command),
|
"input" => DoInput(command),
|
||||||
|
"checkpoint" => DoCheckpoint(command),
|
||||||
|
"screenshot" => DoScreenshot(command),
|
||||||
_ => Stop(command, $"unknown command '{p[0]}'"),
|
_ => Stop(command, $"unknown command '{p[0]}'"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +256,7 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
|
||||||
private bool DoWait(ScriptCommand command)
|
private bool DoWait(ScriptCommand command)
|
||||||
{
|
{
|
||||||
var p = command.Parts;
|
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();
|
string target = p[1].ToLowerInvariant();
|
||||||
if (target == "item")
|
if (target == "item")
|
||||||
|
|
@ -256,7 +276,30 @@ public sealed class RetailUiAutomationScriptRunner : IDisposable
|
||||||
if (target == "ms")
|
if (target == "ms")
|
||||||
return DoSleep(command);
|
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)
|
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)
|
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
|
||||||
{
|
{
|
||||||
if (!HasExceeded(timeoutMs)) return false;
|
if (!HasExceeded(timeoutMs)) return false;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.App.Diagnostics;
|
||||||
|
using AcDream.App.Streaming;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Diagnostics;
|
||||||
|
|
||||||
|
public sealed class WorldLifecycleAutomationControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ScreenshotCapture_FlipsGlRowsAndWritesACompletePng()
|
||||||
|
{
|
||||||
|
string directory = NewDirectory();
|
||||||
|
// GL order: bottom row red/green, top row blue/white.
|
||||||
|
byte[] rgba =
|
||||||
|
[
|
||||||
|
255, 0, 0, 255, 0, 255, 0, 255,
|
||||||
|
0, 0, 255, 255, 255, 255, 255, 255,
|
||||||
|
];
|
||||||
|
var logs = new List<string>();
|
||||||
|
var controller = new FrameScreenshotController(
|
||||||
|
() => (2, 2),
|
||||||
|
(_, _) => rgba,
|
||||||
|
directory,
|
||||||
|
logs.Add);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(controller.TryRequest("login_stable", out string error), error);
|
||||||
|
Assert.False(controller.IsComplete("login_stable"));
|
||||||
|
|
||||||
|
controller.CapturePending();
|
||||||
|
|
||||||
|
Assert.True(controller.IsComplete("login_stable"));
|
||||||
|
string path = Path.Combine(directory, "login_stable.png");
|
||||||
|
using Image image = Image.Load(path);
|
||||||
|
Assert.Equal(2, image.Width);
|
||||||
|
Assert.Equal(2, image.Height);
|
||||||
|
Assert.Contains(logs, line => line.Contains("screenshot-complete", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
byte[] flipped = FrameScreenshotController.FlipRows(rgba, 2, 2);
|
||||||
|
Assert.Equal(rgba.AsSpan(8, 8).ToArray(), flipped.AsSpan(0, 8).ToArray());
|
||||||
|
Assert.Equal(rgba.AsSpan(0, 8).ToArray(), flipped.AsSpan(8, 8).ToArray());
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData("../escape")]
|
||||||
|
[InlineData("has spaces")]
|
||||||
|
[InlineData("name.png")]
|
||||||
|
public void ArtifactNames_RejectPathsAndAmbiguousCharacters(string name)
|
||||||
|
{
|
||||||
|
Assert.False(AutomationArtifactName.TryValidate(name, out string error));
|
||||||
|
Assert.NotEmpty(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Checkpoint_WritesCanonicalRevealAndResourceSnapshot()
|
||||||
|
{
|
||||||
|
string directory = NewDirectory();
|
||||||
|
var reveal = new WorldRevealLifecycleSnapshot(
|
||||||
|
Generation: 7,
|
||||||
|
Kind: WorldRevealKind.Portal,
|
||||||
|
Readiness: new WorldRevealReadinessSnapshot(
|
||||||
|
0x8A020164u,
|
||||||
|
IsIndoor: true,
|
||||||
|
IsUnhydratable: false,
|
||||||
|
RequiredRenderRadius: 0,
|
||||||
|
IsRenderNeighborhoodReady: true,
|
||||||
|
AreCompositeTexturesReady: true,
|
||||||
|
IsCollisionReady: true),
|
||||||
|
Materialized: true,
|
||||||
|
Completed: true,
|
||||||
|
Cancelled: false,
|
||||||
|
WorldViewportObserved: true,
|
||||||
|
InvariantFailureCount: 0);
|
||||||
|
var resources = EmptyResources() with
|
||||||
|
{
|
||||||
|
LoadedLandblocks = 1,
|
||||||
|
WorldEntities = 42,
|
||||||
|
TrackedGpuBytes = 1234,
|
||||||
|
};
|
||||||
|
var screenshots = new FrameScreenshotController(
|
||||||
|
() => (1, 1),
|
||||||
|
(_, _) => [0, 0, 0, 255],
|
||||||
|
Path.Combine(directory, "screenshots"));
|
||||||
|
var controller = new WorldLifecycleAutomationController(
|
||||||
|
() => reveal,
|
||||||
|
() => 3,
|
||||||
|
() => resources,
|
||||||
|
screenshots,
|
||||||
|
directory);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.True(controller.IsWorldReady);
|
||||||
|
Assert.True(controller.IsWorldViewportVisible);
|
||||||
|
Assert.Equal(3, controller.PortalMaterializationCount);
|
||||||
|
Assert.True(controller.TryWriteCheckpoint("dungeon", out string error), error);
|
||||||
|
|
||||||
|
string line = Assert.Single(File.ReadAllLines(
|
||||||
|
Path.Combine(directory, "world-lifecycle.checkpoints.jsonl")));
|
||||||
|
using JsonDocument json = JsonDocument.Parse(line);
|
||||||
|
Assert.Equal("dungeon", json.RootElement.GetProperty("name").GetString());
|
||||||
|
Assert.Equal(7, json.RootElement.GetProperty("reveal").GetProperty("generation").GetInt64());
|
||||||
|
Assert.Equal(42, json.RootElement.GetProperty("resources").GetProperty("worldEntities").GetInt32());
|
||||||
|
Assert.True(File.Exists(Path.Combine(directory, "checkpoint-dungeon.json")));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(directory, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorldLifecycleResourceSnapshot EmptyResources() => new(
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0L, 0, 0L, 0L, 0, 0, 0, 0, 0, 0, 0, 0L, 0L, 0d, 0d, null);
|
||||||
|
|
||||||
|
private static string NewDirectory()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(Path.GetTempPath(), "acdream-world-gate-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,12 +33,14 @@ public class RuntimeOptionsRetailUiTests
|
||||||
{
|
{
|
||||||
["ACDREAM_UI_PROBE_DUMP"] = "1",
|
["ACDREAM_UI_PROBE_DUMP"] = "1",
|
||||||
["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt",
|
["ACDREAM_UI_PROBE_SCRIPT"] = @"C:\tmp\ui-probe.txt",
|
||||||
|
["ACDREAM_AUTOMATION_ARTIFACT_DIR"] = @"C:\tmp\world-gate",
|
||||||
};
|
};
|
||||||
|
|
||||||
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
|
var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k));
|
||||||
|
|
||||||
Assert.True(opts.UiProbeDump);
|
Assert.True(opts.UiProbeDump);
|
||||||
Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript);
|
Assert.Equal(@"C:\tmp\ui-probe.txt", opts.UiProbeScript);
|
||||||
|
Assert.Equal(@"C:\tmp\world-gate", opts.AutomationArtifactDirectory);
|
||||||
Assert.True(opts.UiProbeEnabled);
|
Assert.True(opts.UiProbeEnabled);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ public sealed class RuntimeOptionsTests
|
||||||
Assert.Null(opts.LegacyStreamRadius);
|
Assert.Null(opts.LegacyStreamRadius);
|
||||||
Assert.False(opts.UiProbeDump);
|
Assert.False(opts.UiProbeDump);
|
||||||
Assert.Null(opts.UiProbeScript);
|
Assert.Null(opts.UiProbeScript);
|
||||||
|
Assert.Null(opts.AutomationArtifactDirectory);
|
||||||
Assert.False(opts.UiProbeEnabled);
|
Assert.False(opts.UiProbeEnabled);
|
||||||
Assert.False(opts.HasLiveCredentials);
|
Assert.False(opts.HasLiveCredentials);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,32 @@ public sealed class RetailUiAutomationProbeTests
|
||||||
=> LastDrop = (targetList, targetCell, payload);
|
=> LastDrop = (targetList, targetCell, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeRuntime : IRetailUiAutomationRuntime
|
||||||
|
{
|
||||||
|
public bool IsWorldReady { get; set; }
|
||||||
|
public bool IsWorldViewportVisible { get; set; }
|
||||||
|
public int PortalMaterializationCount { get; set; }
|
||||||
|
public List<string> Checkpoints { get; } = new();
|
||||||
|
public HashSet<string> ScreenshotRequests { get; } = new();
|
||||||
|
public HashSet<string> CompletedScreenshots { get; } = new();
|
||||||
|
|
||||||
|
public bool TryWriteCheckpoint(string name, out string error)
|
||||||
|
{
|
||||||
|
Checkpoints.Add(name);
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryRequestScreenshot(string name, out string error)
|
||||||
|
{
|
||||||
|
ScreenshotRequests.Add(name);
|
||||||
|
error = string.Empty;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsScreenshotComplete(string name) => CompletedScreenshots.Contains(name);
|
||||||
|
}
|
||||||
|
|
||||||
private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects)
|
private static (UiRoot root, UiItemList source, UiItemList target, SpyHandler handler, ClientObjectTable objects)
|
||||||
RootWithTwoItemLists()
|
RootWithTwoItemLists()
|
||||||
{
|
{
|
||||||
|
|
@ -527,4 +553,86 @@ public sealed class RetailUiAutomationProbeTests
|
||||||
File.Delete(path);
|
File.Delete(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptRunner_worldLifecycleCommands_waitForCanonicalRuntimeState()
|
||||||
|
{
|
||||||
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||||
|
var runtime = new FakeRuntime();
|
||||||
|
var probe = new RetailUiAutomationProbe(root, objects);
|
||||||
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
||||||
|
File.WriteAllLines(path,
|
||||||
|
[
|
||||||
|
"wait world-ready 1000",
|
||||||
|
"wait world-visible 1000",
|
||||||
|
"wait materialized 2 1000",
|
||||||
|
"checkpoint stable",
|
||||||
|
"screenshot stable 1000",
|
||||||
|
]);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var runner = new RetailUiAutomationScriptRunner(
|
||||||
|
probe,
|
||||||
|
path,
|
||||||
|
dumpOnStart: false,
|
||||||
|
runtime: runtime);
|
||||||
|
|
||||||
|
runner.Tick(0d);
|
||||||
|
Assert.False(runner.Completed);
|
||||||
|
|
||||||
|
runtime.IsWorldReady = true;
|
||||||
|
runner.Tick(0.001d);
|
||||||
|
Assert.False(runner.Completed);
|
||||||
|
|
||||||
|
runtime.IsWorldViewportVisible = true;
|
||||||
|
runtime.PortalMaterializationCount = 2;
|
||||||
|
runner.Tick(0.001d);
|
||||||
|
|
||||||
|
Assert.Equal(["stable"], runtime.Checkpoints);
|
||||||
|
Assert.Contains("stable", runtime.ScreenshotRequests);
|
||||||
|
Assert.False(runner.Completed);
|
||||||
|
|
||||||
|
runtime.CompletedScreenshots.Add("stable");
|
||||||
|
runner.Tick(0.001d);
|
||||||
|
|
||||||
|
Assert.True(runner.Completed);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScriptRunner_worldReadyTimeout_stopsWithActionableLine()
|
||||||
|
{
|
||||||
|
var (root, _, _, _, objects) = RootWithTwoItemLists();
|
||||||
|
var runtime = new FakeRuntime();
|
||||||
|
var probe = new RetailUiAutomationProbe(root, objects);
|
||||||
|
var logs = new List<string>();
|
||||||
|
string path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".ui-probe.txt");
|
||||||
|
File.WriteAllText(path, "wait world-ready 10");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var runner = new RetailUiAutomationScriptRunner(
|
||||||
|
probe,
|
||||||
|
path,
|
||||||
|
dumpOnStart: false,
|
||||||
|
log: logs.Add,
|
||||||
|
runtime: runtime);
|
||||||
|
|
||||||
|
runner.Tick(0d);
|
||||||
|
runner.Tick(0.011d);
|
||||||
|
|
||||||
|
Assert.True(runner.Completed);
|
||||||
|
Assert.Contains(logs, line =>
|
||||||
|
line.Contains("timed out waiting for world readiness", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue