perf(diag): complete trustworthy Slice A capture tooling
Correct whole-frame GPU timestamps so they bracket only the accepted render transaction and associate delayed query results with the owning CPU frame. Add route-wide frame-history summaries, fixed-camera screenshot comparison, process counters, contention traces, a pinned Arwic workload, and credential-safe launch disclosure. The reference hardware/display contract now keeps local and RDP populations separate and defines the screenshot and re-baseline rules needed by later prepared-content gates. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
bf7ec12f68
commit
3ee8ec537a
23 changed files with 1639 additions and 170 deletions
|
|
@ -18,10 +18,11 @@ public sealed class ConnectedR6SoakContractTests
|
|||
[Fact]
|
||||
public void RouteContainsExactlyNineOrderedCheckpointBarriers()
|
||||
{
|
||||
string[] checkpoints = File.ReadAllLines(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"connected-r6-soak.route.txt"))
|
||||
string routePath = Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"connected-r6-soak.route.txt");
|
||||
string[] checkpoints = File.ReadAllLines(routePath)
|
||||
.Select(static line => line.Trim())
|
||||
.Where(static line => line.StartsWith(
|
||||
"checkpoint ",
|
||||
|
|
@ -31,10 +32,7 @@ public sealed class ConnectedR6SoakContractTests
|
|||
|
||||
Assert.Equal(ExpectedCheckpointNames, checkpoints);
|
||||
|
||||
string[] teleports = File.ReadAllLines(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"connected-r6-soak.route.txt"))
|
||||
string[] teleports = File.ReadAllLines(routePath)
|
||||
.Select(static line => line.Trim())
|
||||
.Where(static line => line.StartsWith(
|
||||
"command /teleloc ",
|
||||
|
|
@ -45,6 +43,97 @@ public sealed class ConnectedR6SoakContractTests
|
|||
" 1 0 0 0",
|
||||
line,
|
||||
StringComparison.Ordinal));
|
||||
|
||||
string[] screenshots = File.ReadAllLines(routePath)
|
||||
.Select(static line => line.Trim())
|
||||
.Where(static line => line.StartsWith(
|
||||
"screenshot ",
|
||||
StringComparison.Ordinal))
|
||||
.Select(static line => line.Split(' ', StringSplitOptions.RemoveEmptyEntries)[1])
|
||||
.ToArray();
|
||||
Assert.Equal(ExpectedCheckpointNames, screenshots);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DenseTownRoutePinsArwicViewAndScreenshot()
|
||||
{
|
||||
string[] route = File.ReadAllLines(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"connected-dense-town.route.txt"))
|
||||
.Select(static line => line.Trim())
|
||||
.Where(static line => line.Length > 0 && !line.StartsWith('#'))
|
||||
.ToArray();
|
||||
|
||||
AssertAppearsInOrder(
|
||||
string.Join('\n', route),
|
||||
"command /telepoi Arwic",
|
||||
"wait materialized 1 60000",
|
||||
"input down MovementTurnRight",
|
||||
"sleep 6000",
|
||||
"input up MovementTurnRight",
|
||||
"screenshot arwic-dense 10000",
|
||||
"checkpoint arwic-dense");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerformanceRoutesPinNoonBeforeTheirFirstTeleport()
|
||||
{
|
||||
foreach (string routeName in new[]
|
||||
{
|
||||
"connected-r6-soak.route.txt",
|
||||
"connected-dense-town.route.txt",
|
||||
})
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
routeName));
|
||||
int firstTeleport = source.IndexOf("command /tele", StringComparison.Ordinal);
|
||||
Assert.True(firstTeleport > 0, $"{routeName} has no teleport command.");
|
||||
string preTeleport = source[..firstTeleport];
|
||||
Assert.Equal(
|
||||
3,
|
||||
CountOccurrences(
|
||||
preTeleport,
|
||||
"input press AcdreamCycleTimeOfDay"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReferenceConfigurationDefinesLocalAuthorityAndScreenshotRule()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"docs",
|
||||
"research",
|
||||
"2026-07-24-performance-reference-configuration.json"));
|
||||
using System.Text.Json.JsonDocument document =
|
||||
System.Text.Json.JsonDocument.Parse(source);
|
||||
System.Text.Json.JsonElement root = document.RootElement;
|
||||
|
||||
Assert.Equal(
|
||||
"performance-gate",
|
||||
root.GetProperty("displayPaths")
|
||||
.GetProperty("authoritativeLocal")
|
||||
.GetProperty("authority")
|
||||
.GetString());
|
||||
Assert.Equal(
|
||||
"diagnostic-only",
|
||||
root.GetProperty("displayPaths")
|
||||
.GetProperty("diagnosticRdp")
|
||||
.GetProperty("authority")
|
||||
.GetString());
|
||||
Assert.Equal(
|
||||
2,
|
||||
root.GetProperty("screenshotComparison")
|
||||
.GetProperty("channelTolerance")
|
||||
.GetInt32());
|
||||
Assert.Equal(
|
||||
0.001,
|
||||
root.GetProperty("screenshotComparison")
|
||||
.GetProperty("maxDifferentPixelFraction")
|
||||
.GetDouble());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -178,11 +267,102 @@ public sealed class ConnectedR6SoakContractTests
|
|||
"EnvDisclosure = Join-Path $artifactDir 'env-disclosure.json'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"$sensitive = $_.Name -match '(?i)(PASS|PASSWORD|TOKEN|SECRET|KEY)'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"Value = if ($sensitive) { '<redacted>' } else { $_.Value }",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
// ACDREAM_DUMP_MOVE_TRUTH stays SET (the exercise gate greps its
|
||||
// output) — it is now disclosed, not removed.
|
||||
Assert.Contains("$env:ACDREAM_DUMP_MOVE_TRUTH = '1'", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SoakExportsAndSummarizesFrameHistory()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"run-connected-r6-soak.ps1"));
|
||||
|
||||
Assert.Contains(
|
||||
"$env:ACDREAM_FRAME_HISTORY = $frameHistory",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"& dotnet $cliDll summarize-frame-history",
|
||||
"$frameHistory",
|
||||
"$checkpointTimeline",
|
||||
"$markerLog",
|
||||
"$frameSummary");
|
||||
Assert.Contains("FrameHistory = $frameHistory", source, StringComparison.Ordinal);
|
||||
Assert.Contains("FrameSummary = $frameSummary", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContentionCaptureIsExplicitAndIncludesStackEvents()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"run-connected-r6-soak.ps1"));
|
||||
|
||||
Assert.Contains("[switch]$CaptureContention", source, StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"'Microsoft-Windows-DotNETRuntime:0x4000:5'",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
Assert.Contains(
|
||||
"ContentionTrace = if ($CaptureContention) { $contentionTrace } else { $null }",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RuntimeCountersCaptureProcessWideAllocationByDefault()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"run-connected-r6-soak.ps1"));
|
||||
|
||||
Assert.Contains("[switch]$SkipRuntimeCounters", source, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"if (-not $SkipRuntimeCounters) {",
|
||||
"'--counters', 'System.Runtime'",
|
||||
"'--refresh-interval', '1'",
|
||||
"'--format', 'csv'",
|
||||
"'--output', $runtimeCounters");
|
||||
Assert.Contains(
|
||||
"RuntimeCounters = if (-not $SkipRuntimeCounters) { $runtimeCounters } else { $null }",
|
||||
source,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DenseTownModeUsesTheDedicatedOneCheckpointRoute()
|
||||
{
|
||||
string source = File.ReadAllText(Path.Combine(
|
||||
FindRepoRoot(),
|
||||
"tools",
|
||||
"run-connected-r6-soak.ps1"));
|
||||
|
||||
Assert.Contains("[switch]$DenseTown", source, StringComparison.Ordinal);
|
||||
AssertAppearsInOrder(
|
||||
source,
|
||||
"if ($DenseTown) {",
|
||||
"Name = 'Arwic dense'",
|
||||
"$expectedCheckpointNames = @('arwic-dense')",
|
||||
"$routeFileName = 'connected-dense-town.route.txt'",
|
||||
"$runName = 'connected-dense-town'");
|
||||
Assert.Contains("if (-not $DenseTown) {", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static int CountOccurrences(string source, string value)
|
||||
{
|
||||
int count = 0;
|
||||
|
|
|
|||
|
|
@ -20,18 +20,25 @@ public class FrameProfilerReportTests
|
|||
};
|
||||
var buffer = new StringWriter(CultureInfo.InvariantCulture);
|
||||
|
||||
FrameProfiler.WriteHistoryCsv(records, buffer);
|
||||
FrameProfiler.WriteHistoryCsv(
|
||||
records,
|
||||
buffer,
|
||||
new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
string[] lines = buffer.ToString().Split(
|
||||
Environment.NewLine, StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Equal(3, lines.Length);
|
||||
Assert.Equal(
|
||||
"frame,timestamp_ms,cpu_us,gpu_us,alloc_bytes,update_us,upload_us,imgui_us,pacing_us",
|
||||
"frame,timestamp_ms,timestamp_utc,cpu_us,gpu_us,alloc_bytes,update_us,upload_us,imgui_us,pacing_us",
|
||||
lines[0]);
|
||||
Assert.Equal("0,12.500,1000,500,2048,100,200,30,40", lines[1]);
|
||||
Assert.Equal(
|
||||
"0,12.500,2026-07-24T10:00:00.0125000Z,1000,500,2048,100,200,30,40",
|
||||
lines[1]);
|
||||
// gpu_us=-1 marks "no GPU sample available"; alloc_bytes can go
|
||||
// negative (documented alloc-channel behavior, matches FrameStatsBuffer.Max).
|
||||
Assert.Equal("1,18.750,1200,-1,-256,110,210,31,41", lines[2]);
|
||||
Assert.Equal(
|
||||
"1,18.750,2026-07-24T10:00:00.0187500Z,1200,-1,-256,110,210,31,41",
|
||||
lines[2]);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -182,8 +182,10 @@ public sealed class GameWindowRenderLeafCompositionTests
|
|||
"screenshotCaptured);");
|
||||
AssertAppearsInOrder(
|
||||
orchestrator,
|
||||
"WorldRenderFrameOutcome world = _world.Render(input);",
|
||||
"_gpuMeasurement.BeginFrame();",
|
||||
"world = _world.Render(input);",
|
||||
"_presentation.Render(input, world);",
|
||||
"_gpuMeasurement.EndFrame();",
|
||||
"new RenderFrameOutcome(world, presentation);",
|
||||
"_diagnostics.Publish(input, outcome);");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ public sealed class RenderFrameOrchestratorTests
|
|||
|
||||
Assert.Equal(
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation",
|
||||
"gpu-begin", "measure-begin", "resources", "world", "presentation",
|
||||
"measure-end",
|
||||
"diagnostics", "post-diagnostics", "gpu-end",
|
||||
],
|
||||
calls);
|
||||
|
|
@ -118,7 +119,8 @@ public sealed class RenderFrameOrchestratorTests
|
|||
Assert.Same(expected, actual);
|
||||
Assert.Equal(
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation",
|
||||
"gpu-begin", "measure-begin", "resources", "world", "presentation",
|
||||
"measure-end",
|
||||
"diagnostics", "post-diagnostics", "gpu-end",
|
||||
],
|
||||
calls);
|
||||
|
|
@ -177,7 +179,10 @@ public sealed class RenderFrameOrchestratorTests
|
|||
Assert.Same(renderFailure, actual.InnerExceptions[0]);
|
||||
Assert.Same(recoveryFailure, actual.InnerExceptions[1]);
|
||||
Assert.Equal(
|
||||
["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
|
||||
[
|
||||
"gpu-begin", "measure-begin", "resources", "world",
|
||||
"measure-end", "recovery-abort", "gpu-end",
|
||||
],
|
||||
calls);
|
||||
}
|
||||
|
||||
|
|
@ -205,7 +210,8 @@ public sealed class RenderFrameOrchestratorTests
|
|||
Assert.Same(closeFailure, actual.InnerExceptions[2]);
|
||||
Assert.Equal(
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation",
|
||||
"gpu-begin", "measure-begin", "resources", "world", "presentation",
|
||||
"measure-end",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
calls);
|
||||
|
|
@ -217,19 +223,21 @@ public sealed class RenderFrameOrchestratorTests
|
|||
var phases = new RecordingPhases([]);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
null!, phases, phases, phases, phases, phases, phases));
|
||||
null!, phases, phases, phases, phases, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, null!, phases, phases, phases, phases, phases));
|
||||
phases, null!, phases, phases, phases, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, null!, phases, phases, phases, phases));
|
||||
phases, phases, null!, phases, phases, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, null!, phases, phases, phases));
|
||||
phases, phases, phases, null!, phases, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, phases, null!, phases, phases));
|
||||
phases, phases, phases, phases, null!, phases, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, phases, phases, null!, phases));
|
||||
phases, phases, phases, phases, phases, null!, phases, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, phases, phases, phases, null!));
|
||||
phases, phases, phases, phases, phases, phases, null!, phases));
|
||||
Assert.Throws<ArgumentNullException>(() => new RenderFrameOrchestrator(
|
||||
phases, phases, phases, phases, phases, phases, phases, null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -238,6 +246,7 @@ public sealed class RenderFrameOrchestratorTests
|
|||
Type[] expectedFieldTypes =
|
||||
[
|
||||
typeof(IRenderFrameLifetime),
|
||||
typeof(IRenderFrameGpuMeasurement),
|
||||
typeof(IRenderFrameResourcePhase),
|
||||
typeof(IWorldSceneFramePhase),
|
||||
typeof(IPrivatePresentationFramePhase),
|
||||
|
|
@ -308,22 +317,33 @@ public sealed class RenderFrameOrchestratorTests
|
|||
|
||||
private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch
|
||||
{
|
||||
"resources" => ["gpu-begin", "resources", "recovery-abort", "gpu-end"],
|
||||
"world" => ["gpu-begin", "resources", "world", "recovery-abort", "gpu-end"],
|
||||
"resources" =>
|
||||
[
|
||||
"gpu-begin", "measure-begin", "resources", "measure-end",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
"world" =>
|
||||
[
|
||||
"gpu-begin", "measure-begin", "resources", "world", "measure-end",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
"presentation" =>
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation",
|
||||
"gpu-begin", "measure-begin", "resources", "world", "presentation",
|
||||
"measure-end",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
"diagnostics" =>
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation", "diagnostics",
|
||||
"gpu-begin", "measure-begin", "resources", "world", "presentation",
|
||||
"measure-end", "diagnostics",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
"post-diagnostics" =>
|
||||
[
|
||||
"gpu-begin", "resources", "world", "presentation", "diagnostics",
|
||||
"post-diagnostics", "recovery-abort", "gpu-end",
|
||||
"gpu-begin", "measure-begin", "resources", "world", "presentation",
|
||||
"measure-end", "diagnostics", "post-diagnostics",
|
||||
"recovery-abort", "gpu-end",
|
||||
],
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(failurePoint)),
|
||||
};
|
||||
|
|
@ -348,10 +368,11 @@ public sealed class RenderFrameOrchestratorTests
|
|||
}
|
||||
|
||||
private static RenderFrameOrchestrator Create(RecordingPhases phases) =>
|
||||
new(phases, phases, phases, phases, phases, phases, phases);
|
||||
new(phases, phases, phases, phases, phases, phases, phases, phases);
|
||||
|
||||
private sealed class RecordingPhases :
|
||||
IRenderFrameLifetime,
|
||||
IRenderFrameGpuMeasurement,
|
||||
IRenderFrameResourcePhase,
|
||||
IWorldSceneFramePhase,
|
||||
IPrivatePresentationFramePhase,
|
||||
|
|
@ -376,15 +397,19 @@ public sealed class RenderFrameOrchestratorTests
|
|||
public WorldRenderFrameOutcome ObservedWorld { get; private set; }
|
||||
public RenderFrameOutcome ObservedOutcome { get; private set; }
|
||||
|
||||
public void BeginFrame() => Record("gpu-begin");
|
||||
void IRenderFrameLifetime.BeginFrame() => Record("gpu-begin");
|
||||
|
||||
public void EndFrame()
|
||||
void IRenderFrameLifetime.EndFrame()
|
||||
{
|
||||
_calls.Add("gpu-end");
|
||||
if (CloseFailure is not null)
|
||||
throw CloseFailure;
|
||||
}
|
||||
|
||||
void IRenderFrameGpuMeasurement.BeginFrame() => Record("measure-begin");
|
||||
|
||||
void IRenderFrameGpuMeasurement.EndFrame() => _calls.Add("measure-end");
|
||||
|
||||
public void AbortFrame()
|
||||
{
|
||||
_calls.Add("recovery-abort");
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ public sealed class RenderFrameRecoveryIntegrationTests
|
|||
new Screenshots());
|
||||
var orchestrator = new RenderFrameOrchestrator(
|
||||
gpu,
|
||||
new GpuMeasurement(),
|
||||
preparation,
|
||||
new World(failure),
|
||||
presentation,
|
||||
|
|
@ -87,6 +88,17 @@ public sealed class RenderFrameRecoveryIntegrationTests
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class GpuMeasurement : IRenderFrameGpuMeasurement
|
||||
{
|
||||
public void BeginFrame()
|
||||
{
|
||||
}
|
||||
|
||||
public void EndFrame()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DevTools(FailureSwitch failure) : IDevToolsFrameLifecycle
|
||||
{
|
||||
public bool IsOpen { get; private set; }
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ public sealed class RenderFrameResourceControllerTests
|
|||
"_uiText?.BeginFrame(gpuSlot);",
|
||||
"_clip?.BeginFrame(gpuSlot);",
|
||||
"_terrain?.BeginFrame(gpuSlot);",
|
||||
"_lighting?.BeginFrame(gpuSlot);",
|
||||
"_profiler.FrameBoundary(_gl);");
|
||||
"_lighting?.BeginFrame(gpuSlot);");
|
||||
Assert.DoesNotContain("_profiler.FrameBoundary(", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
24
tests/AcDream.Cli.Tests/AcDream.Cli.Tests.csproj
Normal file
24
tests/AcDream.Cli.Tests/AcDream.Cli.Tests.csproj
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Cli\AcDream.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
178
tests/AcDream.Cli.Tests/PerformanceToolsTests.cs
Normal file
178
tests/AcDream.Cli.Tests/PerformanceToolsTests.cs
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Cli;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace AcDream.Cli.Tests;
|
||||
|
||||
public sealed class PerformanceToolsTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompareImages_AppliesChannelToleranceAndMask()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
string expectedPath = Path.Combine(directory.Path, "expected.png");
|
||||
string actualPath = Path.Combine(directory.Path, "actual.png");
|
||||
string maskPath = Path.Combine(directory.Path, "mask.png");
|
||||
|
||||
using (var expected = new Image<Rgba32>(2, 1))
|
||||
using (var actual = new Image<Rgba32>(2, 1))
|
||||
using (var mask = new Image<Rgba32>(2, 1))
|
||||
{
|
||||
expected[0, 0] = new Rgba32(10, 20, 30, 255);
|
||||
expected[1, 0] = new Rgba32(40, 50, 60, 255);
|
||||
actual[0, 0] = new Rgba32(12, 20, 30, 255);
|
||||
actual[1, 0] = new Rgba32(200, 50, 60, 255);
|
||||
mask[1, 0] = new Rgba32(0, 0, 0, 255);
|
||||
expected.SaveAsPng(expectedPath);
|
||||
actual.SaveAsPng(actualPath);
|
||||
mask.SaveAsPng(maskPath);
|
||||
}
|
||||
|
||||
ScreenshotComparison unmasked = PerformanceTools.CompareImages(
|
||||
expectedPath,
|
||||
actualPath,
|
||||
channelTolerance: 2,
|
||||
maxDifferentFraction: 0);
|
||||
ScreenshotComparison masked = PerformanceTools.CompareImages(
|
||||
expectedPath,
|
||||
actualPath,
|
||||
channelTolerance: 2,
|
||||
maxDifferentFraction: 0,
|
||||
maskPath);
|
||||
|
||||
Assert.False(unmasked.Passed);
|
||||
Assert.Equal(1, unmasked.DifferentPixels);
|
||||
Assert.True(masked.Passed);
|
||||
Assert.Equal(1, masked.ComparedPixels);
|
||||
Assert.Equal(1, masked.MaskedPixels);
|
||||
Assert.Equal(0, masked.DifferentPixels);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareImages_RejectsAFullyMaskedComparison()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
string expectedPath = Path.Combine(directory.Path, "expected.png");
|
||||
string actualPath = Path.Combine(directory.Path, "actual.png");
|
||||
string maskPath = Path.Combine(directory.Path, "mask.png");
|
||||
using (var image = new Image<Rgba32>(1, 1))
|
||||
using (var mask = new Image<Rgba32>(1, 1))
|
||||
{
|
||||
mask[0, 0] = new Rgba32(0, 0, 0, 255);
|
||||
image.SaveAsPng(expectedPath);
|
||||
image.SaveAsPng(actualPath);
|
||||
mask.SaveAsPng(maskPath);
|
||||
}
|
||||
|
||||
InvalidDataException error = Assert.Throws<InvalidDataException>(() =>
|
||||
PerformanceTools.CompareImages(
|
||||
expectedPath,
|
||||
actualPath,
|
||||
channelTolerance: 2,
|
||||
maxDifferentFraction: 0.001,
|
||||
maskPath));
|
||||
|
||||
Assert.Contains("excludes every pixel", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareScreenshots_WritesSerializableDimensionMismatch()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
string expectedPath = Path.Combine(directory.Path, "expected.png");
|
||||
string actualPath = Path.Combine(directory.Path, "actual.png");
|
||||
string outputPath = Path.Combine(directory.Path, "comparison.json");
|
||||
using (var expected = new Image<Rgba32>(1, 1))
|
||||
using (var actual = new Image<Rgba32>(2, 1))
|
||||
{
|
||||
expected.SaveAsPng(expectedPath);
|
||||
actual.SaveAsPng(actualPath);
|
||||
}
|
||||
|
||||
int exitCode = PerformanceTools.CompareScreenshots(
|
||||
[expectedPath, actualPath, outputPath]);
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
using JsonDocument report = JsonDocument.Parse(File.ReadAllText(outputPath));
|
||||
Assert.False(report.RootElement.GetProperty("passed").GetBoolean());
|
||||
Assert.Equal("expected.png", report.RootElement.GetProperty("expectedPath").GetString());
|
||||
Assert.Equal("actual.png", report.RootElement.GetProperty("actualPath").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SummarizeFrameHistory_WritesRouteCheckpointAndPortalPopulations()
|
||||
{
|
||||
using var directory = new TemporaryDirectory();
|
||||
string framesPath = Path.Combine(directory.Path, "frames.csv");
|
||||
string checkpointsPath = Path.Combine(directory.Path, "checkpoints.jsonl");
|
||||
string markersPath = Path.Combine(directory.Path, "markers.log");
|
||||
string outputPath = Path.Combine(directory.Path, "summary.json");
|
||||
|
||||
File.WriteAllLines(framesPath,
|
||||
[
|
||||
"frame,timestamp_ms,timestamp_utc,cpu_us,gpu_us,alloc_bytes,update_us,upload_us,imgui_us,pacing_us",
|
||||
"0,0.000,2026-07-24T10:00:00.0000000Z,1000,500,100,100,10,0,0",
|
||||
"1,1000.000,2026-07-24T10:00:01.0000000Z,2000,600,200,200,20,0,0",
|
||||
"2,2000.000,2026-07-24T10:00:02.0000000Z,9000,700,300,300,30,0,0",
|
||||
]);
|
||||
File.WriteAllText(
|
||||
checkpointsPath,
|
||||
"""
|
||||
{"name":"baseline","timestampUtc":"2026-07-24T10:00:01Z","resources":{"processTotalAllocatedBytes":1000}}
|
||||
{"name":"return","timestampUtc":"2026-07-24T10:00:02Z","resources":{"processTotalAllocatedBytes":5000}}
|
||||
|
||||
""");
|
||||
File.WriteAllText(
|
||||
markersPath,
|
||||
"2026-07-24T10:00:01.0000000Z materialized destination='Arwic' occurrence=1 elapsed=1.0s");
|
||||
|
||||
int exitCode = PerformanceTools.SummarizeFrameHistory(
|
||||
[framesPath, checkpointsPath, markersPath, outputPath]);
|
||||
|
||||
Assert.Equal(0, exitCode);
|
||||
using JsonDocument report = JsonDocument.Parse(File.ReadAllText(outputPath));
|
||||
JsonElement root = report.RootElement;
|
||||
Assert.Equal(3, root.GetProperty("frameCount").GetInt32());
|
||||
Assert.Equal(
|
||||
9000,
|
||||
root.GetProperty("route").GetProperty("cpuUs").GetProperty("p99").GetInt64());
|
||||
Assert.Equal(
|
||||
2,
|
||||
root.GetProperty("checkpointWindows").GetArrayLength());
|
||||
Assert.Equal(
|
||||
4000,
|
||||
root.GetProperty("checkpointWindows")[1]
|
||||
.GetProperty("processAllocationRateBytesPerSecond")
|
||||
.GetDouble());
|
||||
Assert.Equal(
|
||||
"Arwic",
|
||||
root.GetProperty("portalWindows")[0].GetProperty("destination").GetString());
|
||||
Assert.Equal(
|
||||
9000,
|
||||
root.GetProperty("portalWindows")[0]
|
||||
.GetProperty("worstFrames")[0]
|
||||
.GetProperty("cpuUs")
|
||||
.GetInt64());
|
||||
}
|
||||
|
||||
private sealed class TemporaryDirectory : IDisposable
|
||||
{
|
||||
public TemporaryDirectory()
|
||||
{
|
||||
Path = System.IO.Path.Combine(
|
||||
System.IO.Path.GetTempPath(),
|
||||
"acdream-cli-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(Path))
|
||||
Directory.Delete(Path, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue