xunit.runner.stride: revamp UI (filter, per-row run/preview, output capture, RenderDoc dropdown)

This commit is contained in:
Virgile Bello
2026-05-27 10:00:29 +09:00
committed by xen2
parent ba024afe41
commit ada2a41c3d
17 changed files with 1915 additions and 132 deletions
@@ -113,14 +113,16 @@ namespace Stride.Graphics.Regression
#if STRIDE_PLATFORM_DESKTOP
/// <summary>
/// Controls RenderDoc capture behavior for tests.
/// Set via the <c>STRIDE_TESTS_RENDERDOC</c> environment variable:
/// Controls RenderDoc capture behavior for tests. Defaults to the value of the
/// <c>STRIDE_TESTS_RENDERDOC</c> environment variable (lower-cased) and can be
/// overridden at runtime by the interactive test runner.
/// <list type="bullet">
/// <item><c>error</c> — capture frames only for failing tests (discard on success)</item>
/// <item><c>always</c> — capture frames for all tests</item>
/// <item>anything else / <see langword="null"/> — no capture</item>
/// </list>
/// </summary>
private static readonly string RenderDocMode =
public static string RenderDocMode { get; set; } =
Environment.GetEnvironmentVariable("STRIDE_TESTS_RENDERDOC")?.ToLowerInvariant();
private static bool CaptureRenderDocOnError => RenderDocMode is "error" or "always";
@@ -796,6 +798,14 @@ namespace Stride.Graphics.Regression
// No source image, save this one so that user can later copy it to validated folder
ImageTester.SaveImage(image, testLocalFileName);
comparisonMissingMessages.Add($"* {testLocalFileName} (current)");
// Treat "missing reference" as a (failed) comparison so interactive runners
// can still surface the rendered output and offer a create-gold action.
ImageTester.RaiseImageComparison(new ImageComparisonEventArgs
{
CurrentPath = testLocalFileName,
ReferencePath = testFileName,
Passed = false,
});
}
else
{
@@ -826,12 +836,16 @@ namespace Stride.Graphics.Regression
// Compare against all available gold images
var pendingFailMessages = new List<string>();
bool anyMatch = false;
string matchedFile = testFileName;
ImageTester.ComparisonStats lastStats = default;
foreach (var file in testFileNames)
{
bool match = ImageTester.CompareImage(image, file, out var stats, thresholds);
lastStats = stats;
if (match)
{
anyMatch = true;
matchedFile = file;
break;
}
var isExactMatch = file == testFileName;
@@ -855,6 +869,14 @@ namespace Stride.Graphics.Regression
if (File.Exists(testLocalFileName))
File.Delete(testLocalFileName);
}
ImageTester.RaiseImageComparison(new ImageComparisonEventArgs
{
CurrentPath = testLocalFileName,
ReferencePath = matchedFile,
Passed = anyMatch,
Stats = lastStats,
});
}
}
@@ -7,8 +7,38 @@ using Stride.Core.Mathematics;
namespace Stride.Graphics.Regression
{
/// <summary>
/// Payload for <see cref="ImageTester.ImageComparisonCompleted"/>. Carries paths and the
/// resulting stats; lets in-process consumers (e.g. the interactive runner) drive a
/// diff / promote UI without parsing log strings.
/// </summary>
public sealed class ImageComparisonEventArgs : EventArgs
{
/// <summary>Path under <c>tests/local/...</c> where the rendered output was (or
/// would be) written. May not exist on disk for a passing test unless
/// <c>ForceSaveImageOnSuccess</c> is set.</summary>
public required string CurrentPath { get; init; }
/// <summary>Path under <c>tests/...</c> of the gold image that was used for the
/// comparison. For "missing reference" failures this is the expected gold path
/// even though no file exists there.</summary>
public required string ReferencePath { get; init; }
/// <summary>True if the comparison succeeded (within thresholds). False on
/// mismatch or missing reference.</summary>
public required bool Passed { get; init; }
/// <summary>Stats from the last comparison attempt — populated on success and on
/// the failing exact-match attempt; default when no reference existed.</summary>
public ImageTester.ComparisonStats Stats { get; init; }
}
public static class ImageTester
{
/// <summary>Fired after every screenshot/gold comparison in <see cref="GameTestBase"/>.
/// Process-global, intended for in-process subscribers (e.g. the interactive runner UI).</summary>
public static event EventHandler<ImageComparisonEventArgs>? ImageComparisonCompleted;
internal static void RaiseImageComparison(ImageComparisonEventArgs args)
=> ImageComparisonCompleted?.Invoke(null, args);
public enum ComparisonMode
{
/// <summary>
@@ -1,10 +1,16 @@
using Stride.Graphics.Regression;
using xunit.runner.stride.ViewModels;
namespace xunit.runner.stride
{
class Program
{
public static void Main(string[] args) => StrideXunitRunner.Main(args, interactiveMode => GameTestBase.ForceInteractiveMode = interactiveMode, forceSaveImage => GameTestBase.ForceSaveImageOnSuccess = forceSaveImage);
public static void Main(string[] args) => StrideXunitRunner.Main(args,
interactiveMode => GameTestBase.ForceInteractiveMode = interactiveMode,
forceSaveImage => GameTestBase.ForceSaveImageOnSuccess = forceSaveImage,
renderDocMode => GameTestBase.RenderDocMode = renderDocMode,
subscribe => ImageTester.ImageComparisonCompleted += (s, e) =>
subscribe(new ImageCompareResult(e.CurrentPath, e.ReferencePath, e.Passed, e.Stats.ToString())));
}
}
@@ -1,10 +1,16 @@
using Stride.Graphics.Regression;
using xunit.runner.stride.ViewModels;
namespace xunit.runner.stride
{
class Program
{
public static void Main(string[] args) => StrideXunitRunner.Main(args, interactiveMode => GameTestBase.ForceInteractiveMode = interactiveMode, forceSaveImage => GameTestBase.ForceSaveImageOnSuccess = forceSaveImage);
public static void Main(string[] args) => StrideXunitRunner.Main(args,
interactiveMode => GameTestBase.ForceInteractiveMode = interactiveMode,
forceSaveImage => GameTestBase.ForceSaveImageOnSuccess = forceSaveImage,
renderDocMode => GameTestBase.RenderDocMode = renderDocMode,
subscribe => ImageTester.ImageComparisonCompleted += (s, e) =>
subscribe(new ImageCompareResult(e.CurrentPath, e.ReferencePath, e.Passed, e.Stats.ToString())));
}
}
+16 -1
View File
@@ -1,8 +1,23 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="xunit.runner.stride.App"
RequestedThemeVariant="Light">
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
<Application.Resources>
<ResourceDictionary>
<!-- Fluent Dark's default Window background is pure black, which is too contrasty for
the runner. We define our own theme-aware brush so the color is centralized and
still flips automatically if the runner is ever opened in a light variant. -->
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#1E1E1E" />
</ResourceDictionary>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#F3F3F3" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+85 -11
View File
@@ -1,6 +1,8 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System.Reflection;
using System.Text;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
@@ -15,6 +17,8 @@ public partial class App : Application
internal readonly CancellationTokenSource cts = new();
internal Action<bool>? setInteractiveMode;
internal Action<bool>? setForceSaveImage;
internal Action<string?>? setRenderDocMode;
internal Action<Action<ImageCompareResult>>? subscribeImageComparison;
public override void Initialize()
{
@@ -29,19 +33,19 @@ public partial class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
var vm = new MainViewModel
{
DataContext = new MainViewModel
Tests =
{
Tests =
{
SetInteractiveMode = setInteractiveMode,
SetForceSaveImage = setForceSaveImage,
IsInteractiveMode = true,
IsForceSaveImage = false,
}
SetInteractiveMode = setInteractiveMode,
SetForceSaveImage = setForceSaveImage,
SetRenderDocMode = setRenderDocMode,
}
};
// Subscribe once for the process lifetime; the launcher wires this to
// ImageTester.ImageComparisonCompleted.
subscribeImageComparison?.Invoke(vm.Tests.OnImageComparison);
desktop.MainWindow = new MainWindow { Title = ComputeWindowTitle(), DataContext = vm };
desktop.MainWindow.Closed += (_, __) => cts.Cancel();
desktop.MainWindow.Show();
}
@@ -56,8 +60,7 @@ public partial class App : Application
{
SetInteractiveMode = setInteractiveMode,
SetForceSaveImage = setForceSaveImage,
IsInteractiveMode = true,
IsForceSaveImage = false,
SetRenderDocMode = setRenderDocMode,
}
}
};
@@ -65,4 +68,75 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
// Build a window title that identifies what this runner instance is testing:
// <TestAssembly> — <GraphicsApi> — Stride <Version> [<repoDir>]
// Pieces are dropped when they can't be resolved so the title stays meaningful even when
// launched from an unusual layout (NuGet consumer, etc.).
static string ComputeWindowTitle()
{
const string Fallback = "XUnit Runner (Stride)";
var asm = Assembly.GetEntryAssembly();
if (asm is null) return Fallback;
var testName = asm.GetName().Name ?? "tests";
var path = asm.Location;
var api = InferGraphicsApi(path);
var version = InferStrideVersion();
var repo = InferRepoName(path);
var sb = new StringBuilder(testName);
if (api is not null) sb.Append(" — ").Append(api);
if (version is not null) sb.Append(" — Stride ").Append(version);
if (repo is not null) sb.Append(" [").Append(repo).Append(']');
return sb.ToString();
}
// Bin layout for tests is bin/Tests/<TestProject>/<Platform>-<Api>/<Config>/<asm>.dll, so
// walk upward looking for the "<Platform>-<Api>" segment.
static string? InferGraphicsApi(string? path)
{
if (string.IsNullOrEmpty(path)) return null;
var dir = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(dir))
{
var name = Path.GetFileName(dir);
int dash = name.IndexOf('-');
if (dash > 0 && (name.StartsWith("Windows-") || name.StartsWith("macOS-")
|| name.StartsWith("Linux-") || name.StartsWith("Android-")
|| name.StartsWith("iOS-")))
return name[(dash + 1)..];
dir = Path.GetDirectoryName(dir);
}
return null;
}
// Pick up the Stride engine assembly's informational version (the dev1/dev2 suffix is
// useful, plain Version drops it). The Stride assembly is loaded by the entry assembly
// during test discovery, which happens before the window is shown.
static string? InferStrideVersion()
{
var strideAsm = AppDomain.CurrentDomain.GetAssemblies()
.FirstOrDefault(a => a.GetName().Name == "Stride");
if (strideAsm is null) return null;
var info = strideAsm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
return info ?? strideAsm.GetName().Version?.ToString();
}
// Walk up from the binary looking for a .git entry (worktrees use a .git FILE, not a
// directory). The containing directory name is the repo "name" — works for renamed
// clones and worktrees alike (e.g. "stride2", "stride-worktree1").
static string? InferRepoName(string? path)
{
if (string.IsNullOrEmpty(path)) return null;
var dir = Path.GetDirectoryName(path);
while (!string.IsNullOrEmpty(dir))
{
var gitPath = Path.Combine(dir, ".git");
if (Directory.Exists(gitPath) || File.Exists(gitPath))
return Path.GetFileName(dir);
dir = Path.GetDirectoryName(dir);
}
return null;
}
}
@@ -7,6 +7,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Xunit;
using Xunit.Abstractions;
using xunit.runner.stride.ViewModels;
namespace xunit.runner.stride;
@@ -15,7 +16,7 @@ public static class StrideXunitRunner
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
public static void Main(string[] args, Action<bool>? setInteractiveMode = null, Action<bool>? setForceSaveImage = null)
public static void Main(string[] args, Action<bool>? setInteractiveMode = null, Action<bool>? setForceSaveImage = null, Action<string?>? setRenderDocMode = null, Action<Action<ImageCompareResult>>? subscribeImageComparison = null)
{
if (IsHeadless())
{
@@ -24,7 +25,7 @@ public static class StrideXunitRunner
return;
}
var builder = BuildAvaloniaApp(setInteractiveMode, setForceSaveImage)
var builder = BuildAvaloniaApp(setInteractiveMode, setForceSaveImage, setRenderDocMode, subscribeImageComparison)
.SetupWithLifetime(new ClassicDesktopStyleApplicationLifetime());
if (builder.Instance is App app)
{
@@ -127,8 +128,8 @@ public static class StrideXunitRunner
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp(Action<bool>? setInteractiveMode = null, Action<bool>? setForceSaveImage = null)
=> AppBuilder.Configure(() => new App { setInteractiveMode = setInteractiveMode, setForceSaveImage = setForceSaveImage })
public static AppBuilder BuildAvaloniaApp(Action<bool>? setInteractiveMode = null, Action<bool>? setForceSaveImage = null, Action<string?>? setRenderDocMode = null, Action<Action<ImageCompareResult>>? subscribeImageComparison = null)
=> AppBuilder.Configure(() => new App { setInteractiveMode = setInteractiveMode, setForceSaveImage = setForceSaveImage, setRenderDocMode = setRenderDocMode, subscribeImageComparison = subscribeImageComparison })
.UsePlatformDetect()
.With(new Win32PlatformOptions
{
@@ -0,0 +1,83 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System.IO;
using Avalonia.Media.Imaging;
namespace xunit.runner.stride.ViewModels;
/// <summary>
/// One screenshot-vs-gold comparison reported by GameTestBase. A single test may produce
/// many of these (UI tests, in particular, capture per-frame back-buffers), so they live
/// in an ObservableCollection on the owning <see cref="TestCaseViewModel"/>.
/// </summary>
public class ImageComparisonViewModel : ViewModelBase
{
public ImageComparisonViewModel(ImageCompareResult result)
{
CurrentPath = result.CurrentPath;
ReferencePath = result.ReferencePath;
Passed = result.Passed;
StatsSummary = result.StatsSummary;
// Filename (no extension) is the most informative compact label for stacks of frames.
Label = Path.GetFileNameWithoutExtension(
!string.IsNullOrEmpty(result.CurrentPath) ? result.CurrentPath : result.ReferencePath);
}
public string CurrentPath { get; }
public string ReferencePath { get; }
public bool Passed { get; }
public string? StatsSummary { get; }
public string Label { get; }
Bitmap? currentBitmap;
public Bitmap? CurrentBitmap
{
get => currentBitmap;
set
{
if (SetProperty(ref currentBitmap, value))
{
OnPropertyChanged(nameof(ShowCurrentImage));
OnPropertyChanged(nameof(ShowCurrentPlaceholder));
}
}
}
Bitmap? referenceBitmap;
public Bitmap? ReferenceBitmap
{
get => referenceBitmap;
set
{
if (SetProperty(ref referenceBitmap, value))
{
OnPropertyChanged(nameof(ShowReferenceImage));
OnPropertyChanged(nameof(ShowReferencePlaceholder));
}
}
}
WriteableBitmap? diffBitmap;
public WriteableBitmap? DiffBitmap
{
get => diffBitmap;
set
{
if (SetProperty(ref diffBitmap, value))
{
OnPropertyChanged(nameof(ShowDiffImage));
OnPropertyChanged(nameof(ShowDiffPassed));
OnPropertyChanged(nameof(ShowDiffPlaceholder));
}
}
}
public bool ShowCurrentImage => currentBitmap is not null;
public bool ShowCurrentPlaceholder => currentBitmap is null && Passed;
public bool ShowReferenceImage => referenceBitmap is not null;
public bool ShowReferencePlaceholder => referenceBitmap is null;
public bool ShowDiffImage => diffBitmap is not null;
public bool ShowDiffPassed => diffBitmap is null && Passed;
public bool ShowDiffPlaceholder => diffBitmap is null && !Passed;
}
@@ -1,6 +1,8 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using Xunit.Abstractions;
namespace xunit.runner.stride.ViewModels;
@@ -15,12 +17,14 @@ public class TestCaseViewModel : TestNodeViewModel
{
this.tests = tests;
TestCase = testCase;
ImageComparisons.CollectionChanged += OnImageComparisonsChanged;
}
public void RunTest()
{
tests.RunTests(this);
}
void OnImageComparisonsChanged(object? sender, NotifyCollectionChangedEventArgs e)
=> OnPropertyChanged(nameof(HasImageComparison));
public void RunTest() => tests.RunTests(this, interactive: false);
public void RunInteractive() => tests.RunTests(this, interactive: true);
public override IEnumerable<TestCaseViewModel> EnumerateTestCases()
{
@@ -33,4 +37,42 @@ public class TestCaseViewModel : TestNodeViewModel
}
public override string DisplayName => TestCase.DisplayName;
string? failureMessage;
/// <summary>Combined exception messages from the most recent run (newline-joined). Empty if the test passed or hasn't run.</summary>
public string? FailureMessage
{
get => failureMessage;
set => SetProperty(ref failureMessage, value);
}
string? failureStackTrace;
/// <summary>Combined stack traces from the most recent run. Empty if the test passed or hasn't run.</summary>
public string? FailureStackTrace
{
get => failureStackTrace;
set => SetProperty(ref failureStackTrace, value);
}
string? output;
/// <summary>Captured stdout/stderr produced during the most recent run.</summary>
public string? Output
{
get => output;
set
{
SetProperty(ref output, value);
OnPropertyChanged(nameof(HasOutput));
}
}
public bool HasOutput => !string.IsNullOrEmpty(output);
// === Image comparison (populated from ImageComparisonCompleted event in GameTestBase) ===
/// <summary>Every screenshot/gold comparison reported during the test's most recent run,
/// in order. UI tests typically push many; graphics tests usually one.</summary>
public ObservableCollection<ImageComparisonViewModel> ImageComparisons { get; } = new();
public bool HasImageComparison => ImageComparisons.Count > 0;
}
@@ -20,10 +20,8 @@ public class TestGroupViewModel : TestNodeViewModel
public override IEnumerable<TestCaseViewModel> EnumerateTestCases() => Children.SelectMany(x => x.EnumerateTestCases());
public void RunTest()
{
tests.RunTests(this);
}
public void RunTest() => tests.RunTests(this, interactive: false);
public void RunInteractive() => tests.RunTests(this, interactive: true);
public override TestCaseViewModel? LocateTestCase(ITestCase testCase)
{
@@ -15,21 +15,85 @@ public abstract class TestNodeViewModel : ViewModelBase
public bool Running
{
get => running;
set => SetProperty(ref running, value);
set { if (SetProperty(ref running, value)) OnPropertyChanged(nameof(HasResult)); }
}
bool failed;
public bool Failed
{
get => failed;
set => SetProperty(ref failed, value);
set { if (SetProperty(ref failed, value)) OnPropertyChanged(nameof(HasResult)); }
}
bool succeeded;
public bool Succeeded
{
get => succeeded;
set => SetProperty(ref succeeded, value);
set { if (SetProperty(ref succeeded, value)) OnPropertyChanged(nameof(HasResult)); }
}
bool skipped;
public bool Skipped
{
get => skipped;
set { if (SetProperty(ref skipped, value)) OnPropertyChanged(nameof(HasResult)); }
}
bool pending;
/// <summary>True after the test was queued for a run but before it actually starts;
/// drives the "…" icon so multi-test runs show what's still waiting.</summary>
public bool Pending
{
get => pending;
set { if (SetProperty(ref pending, value)) OnPropertyChanged(nameof(HasResult)); }
}
/// <summary>True once the test has any kind of result (running, queued, or finished).
/// The detail view shows a big "Run" call-to-action when this is false.</summary>
public bool HasResult => running || failed || succeeded || skipped || pending || elapsedSeconds.HasValue;
decimal? elapsedSeconds;
/// <summary>Last execution time in seconds. <see langword="null"/> if not yet run.</summary>
public decimal? ElapsedSeconds
{
get => elapsedSeconds;
set
{
SetProperty(ref elapsedSeconds, value);
OnPropertyChanged(nameof(ElapsedDisplay));
OnPropertyChanged(nameof(HasElapsed));
OnPropertyChanged(nameof(HasResult));
}
}
public bool HasElapsed => elapsedSeconds.HasValue;
public string ElapsedDisplay
{
get
{
if (!elapsedSeconds.HasValue) return string.Empty;
var ms = (double)elapsedSeconds.Value * 1000.0;
return ms < 1000.0 ? $"{ms:F0} ms" : $"{ms / 1000.0:F2} s";
}
}
bool isVisible = true;
/// <summary>Whether this node passes the current filter. Group nodes are visible if any descendant is visible.</summary>
public bool IsVisible
{
get => isVisible;
set => SetProperty(ref isVisible, value);
}
bool isPrimarySelection;
/// <summary>True for the focused row in a multi-selection — the one shown in the inspect
/// pane. Drives a small left accent stripe so the primary stands out from the rest of
/// the selection.</summary>
public bool IsPrimarySelection
{
get => isPrimarySelection;
set => SetProperty(ref isPrimarySelection, value);
}
public abstract string DisplayName { get; }
@@ -0,0 +1,86 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System.Text;
namespace xunit.runner.stride.ViewModels;
/// <summary>
/// A <see cref="TextWriter"/> that fans every write to the original underlying stream AND
/// to a per-test buffer selected by <see cref="CurrentTestId"/>. Used to capture
/// <c>Console.Out</c> / <c>Console.Error</c> output produced while a specific xUnit test
/// case is running, so the inspect panel can show it post-mortem.
/// </summary>
internal sealed class TestOutputRedirector : TextWriter
{
readonly TextWriter inner;
readonly Dictionary<string, StringBuilder> buffers = new();
readonly object gate = new();
public TestOutputRedirector(TextWriter inner) => this.inner = inner;
public override Encoding Encoding => inner.Encoding;
/// <summary>The id of the test currently running; writes are buffered against this id when non-null.</summary>
public volatile string? CurrentTestId;
public override void Write(char value)
{
inner.Write(value);
AppendCurrent(value.ToString());
}
public override void Write(string? value)
{
inner.Write(value);
if (value is not null) AppendCurrent(value);
}
public override void WriteLine(string? value)
{
inner.WriteLine(value);
AppendCurrent((value ?? string.Empty) + Environment.NewLine);
}
public override void Write(char[] buffer, int index, int count)
{
inner.Write(buffer, index, count);
AppendCurrent(new string(buffer, index, count));
}
void AppendCurrent(string text)
{
var id = CurrentTestId;
if (id is null) return;
lock (gate)
{
if (!buffers.TryGetValue(id, out var sb))
{
sb = new StringBuilder();
buffers[id] = sb;
}
sb.Append(text);
}
}
/// <summary>Removes and returns the buffered output for the specified test id, if any.</summary>
public string? TakeOutput(string id)
{
lock (gate)
{
if (!buffers.Remove(id, out var sb)) return null;
var s = sb.ToString();
return s.Length == 0 ? null : s;
}
}
/// <summary>Returns a snapshot of the current buffer for the specified test id without removing it.</summary>
public string? PeekOutput(string id)
{
lock (gate)
{
if (!buffers.TryGetValue(id, out var sb) || sb.Length == 0) return null;
return sb.ToString();
}
}
}
@@ -1,12 +1,39 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System.Collections.ObjectModel;
using System.Reflection;
using Avalonia.Threading;
using Xunit;
namespace xunit.runner.stride.ViewModels;
public enum StatusFilter
{
All,
Passed,
Failed,
Skipped,
NotRun,
}
public enum RenderDocCaptureMode
{
/// <summary>No RenderDoc capture.</summary>
Never,
/// <summary>Capture every frame; keep only the captures for failing tests.</summary>
OnError,
/// <summary>Capture every frame; keep captures for every test, pass or fail.</summary>
Always,
}
/// <summary>
/// Payload pushed by the launcher (which references Stride.Graphics.Regression) into the
/// runner whenever a screenshot/gold comparison completes. Lets the inspect panel show
/// current/reference/diff without the runner needing graphics-side type references.
/// </summary>
public sealed record ImageCompareResult(string CurrentPath, string ReferencePath, bool Passed, string? StatsSummary);
public class TestsViewModel : ViewModelBase
{
private XunitFrontController Controller { get; }
@@ -33,63 +60,444 @@ public class TestsViewModel : ViewModelBase
}
}
TestCases.Add(testAssemblyViewModel);
RecomputeCounts();
// Mirror the env-var default so the dropdown opens on the same value GameTestBase
// would have used at startup.
renderDocMode = Environment.GetEnvironmentVariable("STRIDE_TESTS_RENDERDOC")?.ToLowerInvariant() switch
{
"error" => RenderDocCaptureMode.OnError,
"always" => RenderDocCaptureMode.Always,
_ => RenderDocCaptureMode.Never,
};
}
public async void RunTests(TestNodeViewModel testNodeViewModel)
/// <summary>Called by the launcher whenever an image comparison completes; appends a new
/// entry to the currently-running test's list so multi-frame tests (e.g. UI tests) keep
/// every comparison instead of overwriting the previous one.</summary>
public void OnImageComparison(ImageCompareResult result)
{
var vm = currentlyRunningCase;
if (vm is null) return;
var entry = new ImageComparisonViewModel(result);
Dispatcher.UIThread.Post(() => vm.ImageComparisons.Add(entry));
}
public void RunTests(TestNodeViewModel testNodeViewModel) => RunTests(testNodeViewModel, interactive: false);
// async void to satisfy Avalonia's method-as-Command binding (per-row buttons), but the
// body is wrapped so a test-run exception can't take down the process.
public async void RunTests(TestNodeViewModel testNodeViewModel, bool interactive)
{
try
{
var testCases = testNodeViewModel.EnumerateTestCases().ToList();
if (testCases.Count == 0)
return;
await RunTestCases(testCases, interactive);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Test run failed: {ex}");
}
}
public async Task RunSelectedCases(bool interactive)
{
var cases = SelectedCases.Count > 0 ? SelectedCases.ToList() : EnumerateVisibleTestCases().ToList();
if (cases.Count == 0)
return;
await RunTestCases(cases, interactive);
}
public async Task RunFailed(bool interactive)
{
var cases = EnumerateAllTestCases().Where(c => c.Failed).ToList();
if (cases.Count == 0)
return;
await RunTestCases(cases, interactive);
}
async Task RunTestCases(IReadOnlyList<TestCaseViewModel> testCases, bool interactive)
{
var testCases = testNodeViewModel.EnumerateTestCases();
var testCaseViewModels = new Dictionary<string, TestCaseViewModel>();
foreach (var testCase in testCases)
{
testCaseViewModels.Add(testCase.TestCase.UniqueID, testCase);
}
testCaseViewModels[testCase.TestCase.UniqueID] = testCase;
int testCasesFinished = 0;
await Task.Run(() =>
// The per-run interactive flag flows to GameTestBase via SetInteractiveMode.
// ForceSaveImage rides on the persistent IsForceSaveImage checkbox setter.
SetInteractiveMode?.Invoke(interactive);
// Per-test stdout/stderr capture for the inspect panel.
var originalOut = Console.Out;
var originalErr = Console.Error;
var outRedirector = new TestOutputRedirector(originalOut);
var errRedirector = new TestOutputRedirector(originalErr);
Console.SetOut(outRedirector);
Console.SetError(errRedirector);
// Live-stream the captured buffer into vm.Output every 200 ms so the inspect panel
// updates while the test is still running. currentlyRunningCase is also read by
// OnImageComparison so an out-of-band ImageTester event can find the right vm.
var liveTimer = new System.Threading.Timer(_ =>
{
// Reset progress
var vm = currentlyRunningCase;
if (vm is null) return;
var id = vm.TestCase.UniqueID;
var outBuf = outRedirector.PeekOutput(id);
var errBuf = errRedirector.PeekOutput(id);
var output = (outBuf, errBuf) switch
{
(null, null) => null,
(_, null) => outBuf,
(null, _) => errBuf,
_ => outBuf + Environment.NewLine + errBuf,
};
Dispatcher.UIThread.Post(() =>
{
TestCompletion = 0.0;
RunningTests = true;
if (vm == currentlyRunningCase) vm.Output = output;
});
}, null, dueTime: System.Threading.Timeout.Infinite, period: System.Threading.Timeout.Infinite);
var sink = new XSink
try
{
await Task.Run(() =>
{
HandleTestCaseStarting = args =>
Dispatcher.UIThread.Post(() =>
{
if (testCaseViewModels.TryGetValue(args.Message.TestCase.UniqueID, out var testCaseViewModel))
TestCompletion = 0.0;
RunningTests = true;
foreach (var vm in testCaseViewModels.Values)
{
Dispatcher.UIThread.Post(() =>
{
// Test status
testCaseViewModel.Running = true;
});
vm.Failed = false;
vm.Succeeded = false;
vm.Skipped = false;
vm.Pending = true;
vm.FailureMessage = null;
vm.FailureStackTrace = null;
vm.Output = null;
vm.ImageComparisons.Clear();
}
},
HandleTestCaseFinished = args =>
{
if (testCaseViewModels.TryGetValue(args.Message.TestCase.UniqueID, out var testCaseViewModel))
{
Dispatcher.UIThread.Post(() =>
{
// Test status
testCaseViewModel.Failed = args.Message.TestsFailed > 0;
testCaseViewModel.Succeeded = args.Message.TestsFailed == 0;
testCaseViewModel.Running = false;
// Update progress
TestCompletion = (double)Interlocked.Increment(ref testCasesFinished) / (double)testCaseViewModels.Count * 100.0;
});
}
},
};
Controller.RunTests(testCaseViewModels.Select(x => x.Value.TestCase).ToArray(), sink, TestFrameworkOptions.ForExecution());
sink.Finished.WaitOne();
PropagateGroupState();
});
Dispatcher.UIThread.Post(() => RunningTests = false);
});
var sink = new XSink
{
HandleTestCaseStarting = args =>
{
var id = args.Message.TestCase.UniqueID;
outRedirector.CurrentTestId = id;
errRedirector.CurrentTestId = id;
if (testCaseViewModels.TryGetValue(id, out var vm))
{
currentlyRunningCase = vm;
liveTimer.Change(200, 200);
Dispatcher.UIThread.Post(() =>
{
vm.Pending = false;
vm.Running = true;
PropagateGroupState();
// Only auto-select on a single-test run; a multi-test run
// must preserve the user's tree selection. The per-row ⭮
// icon still tracks which test is currently executing.
if (testCaseViewModels.Count == 1)
FocusTest?.Invoke(vm);
});
}
},
HandleTestFailed = args =>
{
if (testCaseViewModels.TryGetValue(args.Message.TestCase.UniqueID, out var vm))
{
var msg = args.Message.Messages is { Length: > 0 } msgs ? string.Join("\n", msgs) : null;
var st = args.Message.StackTraces is { Length: > 0 } sts ? string.Join("\n---\n", sts.Where(s => !string.IsNullOrEmpty(s))!) : null;
Dispatcher.UIThread.Post(() =>
{
vm.FailureMessage = msg;
vm.FailureStackTrace = st;
});
}
},
HandleTestSkipped = args =>
{
if (testCaseViewModels.TryGetValue(args.Message.TestCase.UniqueID, out var vm))
{
Dispatcher.UIThread.Post(() => vm.Skipped = true);
}
},
HandleTestCaseFinished = args =>
{
var id = args.Message.TestCase.UniqueID;
// Drain output buffers, stop the streaming ticker, then push the
// final state in one UI update.
var outBuf = outRedirector.TakeOutput(id);
var errBuf = errRedirector.TakeOutput(id);
outRedirector.CurrentTestId = null;
errRedirector.CurrentTestId = null;
currentlyRunningCase = null;
liveTimer.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
if (testCaseViewModels.TryGetValue(id, out var vm))
{
var failed = args.Message.TestsFailed > 0;
var skipped = args.Message.TestsSkipped > 0 && args.Message.TestsFailed == 0 && args.Message.TestsRun == 0;
var elapsed = args.Message.ExecutionTime;
var output = (outBuf, errBuf) switch
{
(null, null) => null,
(_, null) => outBuf,
(null, _) => errBuf,
_ => outBuf + Environment.NewLine + errBuf,
};
Dispatcher.UIThread.Post(() =>
{
vm.Failed = failed;
vm.Succeeded = !failed && !skipped;
vm.Skipped = skipped;
vm.Running = false;
vm.ElapsedSeconds = elapsed;
vm.Output = output;
testCasesFinished++;
TestCompletion = (double)testCasesFinished / testCaseViewModels.Count * 100.0;
PropagateGroupState();
RecomputeCounts();
});
}
},
};
Controller.RunTests(testCaseViewModels.Select(x => x.Value.TestCase).ToArray(), sink, TestFrameworkOptions.ForExecution());
sink.Finished.WaitOne();
Dispatcher.UIThread.Post(() =>
{
RunningTests = false;
RecomputeCounts();
ApplyFilter();
});
});
}
finally
{
// Block until any in-flight timer callback finishes, then restore globals.
// Without WaitOne the callback could still be running when we reassign Console
// streams on the next run, racing against a fresh redirector.
using var disposed = new System.Threading.ManualResetEvent(false);
liveTimer.Dispose(disposed);
disposed.WaitOne();
Console.SetOut(originalOut);
Console.SetError(originalErr);
// Interactive is one-shot per click; reset so the next click defaults headless.
SetInteractiveMode?.Invoke(false);
// Defensive: clear running state and any stuck Pending flags if the run was
// abandoned mid-flight.
Dispatcher.UIThread.Post(() =>
{
if (RunningTests) RunningTests = false;
foreach (var vm in testCaseViewModels.Values)
if (vm.Pending) vm.Pending = false;
PropagateGroupState();
});
}
}
void PropagateGroupState()
{
foreach (var root in TestCases)
PropagateGroupState(root);
}
static void PropagateGroupState(TestNodeViewModel node)
{
if (node is TestGroupViewModel group)
{
foreach (var child in group.Children)
PropagateGroupState(child);
group.Failed = group.Children.Any(c => c.Failed);
group.Running = group.Children.Any(c => c.Running);
group.Pending = group.Children.Any(c => c.Pending);
group.Succeeded = !group.Failed && !group.Running && !group.Pending && group.Children.Count > 0 && group.Children.All(c => c.Succeeded || c.Skipped);
group.Skipped = !group.Failed && !group.Running && !group.Pending && group.Children.Count > 0 && group.Children.All(c => c.Skipped);
var sum = group.Children.Where(c => c.HasElapsed).Sum(c => c.ElapsedSeconds ?? 0m);
group.ElapsedSeconds = sum > 0 ? sum : null;
}
}
public IEnumerable<TestCaseViewModel> EnumerateAllTestCases() => TestCases.SelectMany(c => c.EnumerateTestCases());
public IEnumerable<TestCaseViewModel> EnumerateVisibleTestCases() => EnumerateAllTestCases().Where(c => c.IsVisible);
// === Filter ===
string filterText = string.Empty;
public string FilterText
{
get => filterText;
set
{
if (SetProperty(ref filterText, value ?? string.Empty))
ApplyFilter();
}
}
StatusFilter statusFilter = StatusFilter.All;
public StatusFilter StatusFilter
{
get => statusFilter;
set
{
if (SetProperty(ref statusFilter, value))
{
OnPropertyChanged(nameof(StatusFilterIsAll));
OnPropertyChanged(nameof(StatusFilterIsPassed));
OnPropertyChanged(nameof(StatusFilterIsFailed));
OnPropertyChanged(nameof(StatusFilterIsSkipped));
OnPropertyChanged(nameof(StatusFilterIsNotRun));
ApplyFilter();
}
}
}
// ToggleButton bindings (one-way set true; user can't un-toggle the active one)
public bool StatusFilterIsAll
{
get => statusFilter == StatusFilter.All;
set { if (value) StatusFilter = StatusFilter.All; else OnPropertyChanged(nameof(StatusFilterIsAll)); }
}
public bool StatusFilterIsPassed
{
get => statusFilter == StatusFilter.Passed;
set { if (value) StatusFilter = StatusFilter.Passed; else OnPropertyChanged(nameof(StatusFilterIsPassed)); }
}
public bool StatusFilterIsFailed
{
get => statusFilter == StatusFilter.Failed;
set { if (value) StatusFilter = StatusFilter.Failed; else OnPropertyChanged(nameof(StatusFilterIsFailed)); }
}
public bool StatusFilterIsSkipped
{
get => statusFilter == StatusFilter.Skipped;
set { if (value) StatusFilter = StatusFilter.Skipped; else OnPropertyChanged(nameof(StatusFilterIsSkipped)); }
}
public bool StatusFilterIsNotRun
{
get => statusFilter == StatusFilter.NotRun;
set { if (value) StatusFilter = StatusFilter.NotRun; else OnPropertyChanged(nameof(StatusFilterIsNotRun)); }
}
public void ApplyFilter()
{
var needle = filterText.Trim();
foreach (var root in TestCases)
ApplyFilter(root, needle);
}
bool ApplyFilter(TestNodeViewModel node, string needle)
{
if (node is TestGroupViewModel group)
{
bool anyChildVisible = false;
foreach (var child in group.Children)
anyChildVisible |= ApplyFilter(child, needle);
group.IsVisible = anyChildVisible;
return anyChildVisible;
}
else if (node is TestCaseViewModel testCase)
{
bool matchesText = needle.Length == 0 || testCase.DisplayName.Contains(needle, StringComparison.OrdinalIgnoreCase);
bool matchesStatus = statusFilter switch
{
StatusFilter.All => true,
StatusFilter.Passed => testCase.Succeeded,
StatusFilter.Failed => testCase.Failed,
StatusFilter.Skipped => testCase.Skipped,
StatusFilter.NotRun => !testCase.Succeeded && !testCase.Failed && !testCase.Skipped && !testCase.Running,
_ => true,
};
testCase.IsVisible = matchesText && matchesStatus;
return testCase.IsVisible;
}
return false;
}
// === Counts / summary ===
int passedCount, failedCount, skippedCount, notRunCount, totalCount;
decimal totalElapsedSeconds;
public int PassedCount { get => passedCount; private set => SetProperty(ref passedCount, value); }
public int FailedCount { get => failedCount; private set => SetProperty(ref failedCount, value); }
public int SkippedCount { get => skippedCount; private set => SetProperty(ref skippedCount, value); }
public int NotRunCount { get => notRunCount; private set => SetProperty(ref notRunCount, value); }
public int TotalCount { get => totalCount; private set => SetProperty(ref totalCount, value); }
public decimal TotalElapsedSeconds
{
get => totalElapsedSeconds;
private set
{
if (SetProperty(ref totalElapsedSeconds, value))
OnPropertyChanged(nameof(TotalElapsedDisplay));
}
}
public string TotalElapsedDisplay
{
get
{
var s = (double)totalElapsedSeconds;
return s < 60 ? $"{s:F2}s" : $"{(int)(s / 60)}m {s % 60:F1}s";
}
}
public bool HasFailures => failedCount > 0;
/// <summary>True when there's at least one failed test AND no run in progress; drives the "Re-run failed" button.</summary>
public bool CanRunFailed => failedCount > 0 && !runningTests;
void RecomputeCounts()
{
int passed = 0, failed = 0, skipped = 0, notRun = 0, total = 0;
decimal elapsed = 0m;
foreach (var c in EnumerateAllTestCases())
{
total++;
if (c.Failed) failed++;
else if (c.Skipped) skipped++;
else if (c.Succeeded) passed++;
else notRun++;
if (c.HasElapsed) elapsed += c.ElapsedSeconds!.Value;
}
PassedCount = passed;
FailedCount = failed;
SkippedCount = skipped;
NotRunCount = notRun;
TotalCount = total;
TotalElapsedSeconds = elapsed;
OnPropertyChanged(nameof(HasFailures));
OnPropertyChanged(nameof(CanRunFailed));
}
// === Selection ===
/// <summary>Test cases currently selected in the tree (multi-select).</summary>
public ObservableCollection<TestCaseViewModel> SelectedCases { get; } = [];
public bool HasSelection => SelectedCases.Count > 0;
public void OnSelectionChanged()
{
OnPropertyChanged(nameof(HasSelection));
}
// === Commands invoked from view ===
public void RunSelected() => _ = RunSelectedCases(interactive: false);
public void RunSelectedInteractive() => _ = RunSelectedCases(interactive: true);
public void RunFailedCmd() => _ = RunFailed(interactive: false);
public void ClearFilter() => FilterText = string.Empty;
// === State ===
double testCompletion;
public double TestCompletion
{
@@ -101,35 +509,99 @@ public class TestsViewModel : ViewModelBase
public bool RunningTests
{
get => runningTests;
set => SetProperty(ref runningTests, value);
}
bool isInteractiveMode = false;
public bool IsInteractiveMode
{
get => isInteractiveMode;
set
{
SetProperty(ref isInteractiveMode, value);
SetInteractiveMode?.Invoke(isInteractiveMode);
if (SetProperty(ref runningTests, value))
OnPropertyChanged(nameof(CanRunFailed));
}
}
bool isForceSaveImage = false;
public List<TestNodeViewModel> TestCases { get; } = [];
public Action<bool>? SetInteractiveMode { get; set; }
public Action<bool>? SetForceSaveImage { get; set; }
/// <summary>Pushes the selected RenderDoc capture mode to the underlying static
/// (typically <c>GameTestBase.RenderDocMode</c>). Accepts <c>null</c> ("never"), "error", "always".</summary>
public Action<string?>? SetRenderDocMode { get; set; }
bool isForceSaveImage;
/// <summary>Persistent toggle: when on, every run sets <c>ForceSaveImageOnSuccess</c> so
/// the rendered output is written to disk even when the gold comparison passes.</summary>
public bool IsForceSaveImage
{
get => isForceSaveImage;
set
{
SetProperty(ref isForceSaveImage, value);
SetForceSaveImage?.Invoke(isForceSaveImage);
if (SetProperty(ref isForceSaveImage, value))
SetForceSaveImage?.Invoke(value);
}
}
public List<TestNodeViewModel> TestCases { get; } = [];
public Action<bool>? SetInteractiveMode { get; set; }
public bool HasInteractiveMode => SetInteractiveMode != null;
RenderDocCaptureMode renderDocMode = RenderDocCaptureMode.Never;
/// <summary>Selected RenderDoc capture mode. Default: <see cref="RenderDocCaptureMode.Never"/>.
/// Setting this pushes the matching string ("error"/"always"/null) to <see cref="SetRenderDocMode"/>.</summary>
public RenderDocCaptureMode RenderDocMode
{
get => renderDocMode;
set
{
if (SetProperty(ref renderDocMode, value))
{
var s = value switch
{
RenderDocCaptureMode.OnError => "error",
RenderDocCaptureMode.Always => "always",
_ => (string?)null,
};
SetRenderDocMode?.Invoke(s);
}
}
}
public Action<bool>? SetForceSaveImage { get; set; }
public bool HasForceSaveImage => SetForceSaveImage != null;
public bool HasRenderDocMode => SetRenderDocMode != null;
/// <summary>Invoked on the UI thread when a test case starts; the view selects it in the
/// tree so its output streams into the inspect panel.</summary>
public Action<TestCaseViewModel>? FocusTest { get; set; }
// The test case currently being executed by RunTestCases. Also drives the live-output
// ticker and the OnImageComparison callback's vm-attach logic.
TestCaseViewModel? currentlyRunningCase;
// === Mobile / narrow-viewport state ===
bool isNarrowMode;
/// <summary>True when the main split is in stacked (phone/portrait) mode. Drives the
/// drill-down nav (tap-to-detail / back) and hides per-row run buttons.</summary>
public bool IsNarrowMode
{
get => isNarrowMode;
set
{
if (SetProperty(ref isNarrowMode, value))
{
OnPropertyChanged(nameof(IsWideMode));
OnPropertyChanged(nameof(RowMinHeight));
// Leaving narrow mode also exits the detail page so wide layout shows both panes.
if (!value) IsDetailPageActive = false;
}
}
}
/// <summary>Inverse of <see cref="IsNarrowMode"/>; convenience for per-row "show on wide
/// only" visibility bindings.</summary>
public bool IsWideMode => !isNarrowMode;
/// <summary>Minimum tree-row height; bumped in narrow mode to give touch targets enough
/// surface to be reliably tappable.</summary>
public double RowMinHeight => isNarrowMode ? 44 : 0;
bool isDetailPageActive;
/// <summary>In narrow mode, true means the detail pane is showing instead of the list.
/// Ignored in wide mode.</summary>
public bool IsDetailPageActive
{
get => isDetailPageActive;
set => SetProperty(ref isDetailPageActive, value);
}
}
@@ -20,10 +20,10 @@ public sealed class XSink : IExecutionSink
public bool OnMessageWithTypes(IMessageSinkMessage message, HashSet<string> messageTypes)
{
Console.WriteLine($"{message.GetType().Name} ... {message}");
return message.Dispatch<ITestCaseFinished>(messageTypes, HandleTestCaseFinished)
&& message.Dispatch<ITestCaseStarting>(messageTypes, HandleTestCaseStarting)
&& message.Dispatch<ITestFailed>(messageTypes, HandleTestFailed)
&& message.Dispatch<ITestSkipped>(messageTypes, HandleTestSkipped)
&& message.Dispatch<IErrorMessage>(messageTypes, _ => Interlocked.Increment(ref errors))
&& message.Dispatch<ITestAssemblyCleanupFailure>(messageTypes, _ => Interlocked.Increment(ref errors))
&& message.Dispatch<ITestAssemblyFinished>(messageTypes, HandleTestAssemblyFinished)
@@ -36,6 +36,8 @@ public sealed class XSink : IExecutionSink
public MessageHandler<ITestCaseFinished>? HandleTestCaseFinished;
public MessageHandler<ITestCaseStarting>? HandleTestCaseStarting;
public MessageHandler<ITestFailed>? HandleTestFailed;
public MessageHandler<ITestSkipped>? HandleTestSkipped;
void HandleTestAssemblyFinished(MessageHandlerArgs<ITestAssemblyFinished> args)
{
@@ -3,52 +3,420 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:xunit.runner.stride.ViewModels"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
x:Class="xunit.runner.stride.Views.MainView"
x:DataType="vm:MainViewModel">
<UserControl.Styles>
<!-- Per-row run/preview icons stay clickable but out of the Tab cycle — keyboard runs
the selected test via Enter / Shift+Enter. Hidden in narrow mode (mobile) where
the detail-pane header carries the Run/Preview buttons instead. -->
<Style Selector="Button.runIcon">
<Setter Property="Padding" Value="6,2" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="#34D058" />
<Setter Property="FontFamily" Value="Segoe UI Symbol" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsVisible" Value="{Binding $parent[DockPanel].DataContext.IsWideMode, FallbackValue=True}" />
</Style>
<Style Selector="Button.previewIcon">
<Setter Property="Padding" Value="6,2" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="#58A6FF" />
<Setter Property="FontFamily" Value="Segoe UI Symbol" />
<Setter Property="Margin" Value="2,0,0,0" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsVisible" Value="{Binding $parent[DockPanel].DataContext.IsWideMode, FallbackValue=True}" />
</Style>
<Style Selector="TextBlock.statusIcon">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="FontFamily" Value="Segoe UI Symbol" />
<Setter Property="Margin" Value="6,0,0,0" />
<Setter Property="FontSize" Value="14" />
</Style>
<Style Selector="TextBlock.testName">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="6,0,0,0" />
</Style>
<Style Selector="TextBlock.elapsed">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Margin" Value="8,0,0,0" />
<Setter Property="Opacity" Value="0.6" />
<Setter Property="FontSize" Value="11" />
</Style>
<Style Selector="ToggleButton.chip">
<Setter Property="Padding" Value="10,2" />
<Setter Property="Margin" Value="2,0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="IsTabStop" Value="False" />
</Style>
<!-- WrapPanel has no ItemSpacing; per-child margin keeps spacing consistent across wraps. -->
<Style Selector="WrapPanel.toolbar > :is(Control)">
<Setter Property="Margin" Value="0,2,6,2" />
</Style>
</UserControl.Styles>
<DockPanel DataContext="{Binding Tests}">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal">
<CheckBox Content="Interactive Mode" IsChecked="{Binding IsInteractiveMode}" IsEnabled="{Binding HasInteractiveMode}"/>
<Separator Opacity="0" Width="10"/>
<CheckBox Content="Force Save Image" IsChecked="{Binding IsForceSaveImage}" IsEnabled="{Binding HasForceSaveImage}"/>
</StackPanel>
<!-- Top bar wraps onto additional rows on narrow viewports. -->
<Border DockPanel.Dock="Top" Padding="6" BorderThickness="0,0,0,1" BorderBrush="#333">
<StackPanel Orientation="Vertical" Spacing="4">
<WrapPanel Classes="toolbar" Orientation="Horizontal">
<TextBox Name="FilterBox"
MinWidth="200"
Width="320"
TabIndex="1"
Watermark="Filter — case-insensitive substring (/, Ctrl+F to focus)"
ToolTip.Tip="Up/Down: move tree selection · Enter: run · Shift+Enter: preview · Tab: focus tree · Esc: clear selection"
Text="{Binding FilterText, Mode=TwoWay}" />
<Button Content="✕" ToolTip.Tip="Clear filter" Command="{Binding ClearFilter}" Padding="6,2" />
<Separator Opacity="0" Width="8" />
<Button Content="↻ Re-run failed"
Command="{Binding RunFailedCmd}"
IsEnabled="{Binding CanRunFailed}"
ToolTip.Tip="Re-run all failed tests (Ctrl+R)" />
<Button Content="▶ Run filtered/selected"
Command="{Binding RunSelected}"
IsEnabled="{Binding !RunningTests}"
ToolTip.Tip="Run filtered/selected tests, headless (F5)" />
<Button Content="👁 Preview"
Command="{Binding RunSelectedInteractive}"
IsEnabled="{Binding !RunningTests}"
ToolTip.Tip="Preview filtered/selected interactively — opens game window, does NOT run as a test" />
<Separator Opacity="0" Width="4" />
<CheckBox Content="Save image"
IsChecked="{Binding IsForceSaveImage, Mode=TwoWay}"
VerticalAlignment="Center"
ToolTip.Tip="When enabled, the rendered output is written to tests/local/... on every run — including successful ones (default: only on failure). Use to capture or regenerate gold images." />
<StackPanel Orientation="Horizontal" Spacing="4" VerticalAlignment="Center" IsVisible="{Binding HasRenderDocMode}">
<TextBlock Text="RenderDoc:" VerticalAlignment="Center" Opacity="0.7" />
<ComboBox SelectedItem="{Binding RenderDocMode, Mode=TwoWay}"
VerticalAlignment="Center"
MinWidth="100"
ToolTip.Tip="RenderDoc frame capture mode for interactive runs (desktop only). Mirrors the STRIDE_TESTS_RENDERDOC env var.&#x0a; · Never — no capture&#x0a; · On error — capture frames; keep only for failing tests&#x0a; · Always — keep captures for every test, pass or fail">
<ComboBox.Items>
<vm:RenderDocCaptureMode>Never</vm:RenderDocCaptureMode>
<vm:RenderDocCaptureMode>OnError</vm:RenderDocCaptureMode>
<vm:RenderDocCaptureMode>Always</vm:RenderDocCaptureMode>
</ComboBox.Items>
</ComboBox>
</StackPanel>
</WrapPanel>
<WrapPanel Orientation="Horizontal">
<ToggleButton Classes="chip" Content="All" IsChecked="{Binding StatusFilterIsAll, Mode=TwoWay}" />
<ToggleButton Classes="chip" IsChecked="{Binding StatusFilterIsPassed, Mode=TwoWay}">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="✔" Foreground="#34D058" />
<TextBlock Text="Passed" />
<TextBlock Text="{Binding PassedCount}" Opacity="0.7" />
</StackPanel>
</ToggleButton>
<ToggleButton Classes="chip" IsChecked="{Binding StatusFilterIsFailed, Mode=TwoWay}">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="✘" Foreground="#F85149" />
<TextBlock Text="Failed" />
<TextBlock Text="{Binding FailedCount}" Opacity="0.7" />
</StackPanel>
</ToggleButton>
<ToggleButton Classes="chip" IsChecked="{Binding StatusFilterIsSkipped, Mode=TwoWay}">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="⊘" Foreground="#D29922" />
<TextBlock Text="Skipped" />
<TextBlock Text="{Binding SkippedCount}" Opacity="0.7" />
</StackPanel>
</ToggleButton>
<ToggleButton Classes="chip" IsChecked="{Binding StatusFilterIsNotRun, Mode=TwoWay}">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Text="○" Opacity="0.7" />
<TextBlock Text="Not run" />
<TextBlock Text="{Binding NotRunCount}" Opacity="0.7" />
</StackPanel>
</ToggleButton>
</WrapPanel>
</StackPanel>
</Border>
<!-- ===== Progress bar ===== -->
<ProgressBar DockPanel.Dock="Top"
Height="16"
Minimum="0" Maximum="100" Value="{Binding TestCompletion}" />
<TreeView ItemsSource="{Binding TestCases}">
<TreeView.DataTemplates>
<TreeDataTemplate DataType="{x:Type vm:TestGroupViewModel}"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal">
<Menu>
<MenuItem Header="▶" FontFamily="Segoe UI Symbol" Foreground="Green" Command="{Binding RunTest}" IsEnabled="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, Path=!DataContext.RunningTests}"/>
</Menu>
<TextBlock Text="✘" FontFamily="Segoe UI Symbol" IsVisible="{Binding Failed}" Foreground="Red" />
<TextBlock Text="✔" FontFamily="Segoe UI Symbol" IsVisible="{Binding Succeeded}" Foreground="Green" />
<TextBlock Text="⭮" FontFamily="Segoe UI Symbol" IsVisible="{Binding Running}" Foreground="Blue" />
<TextBlock Text="{Binding DisplayName}" />
<TextBlock Text="(Running)" IsVisible="{Binding Running}" />
Height="6"
Minimum="0" Maximum="100"
Value="{Binding TestCompletion}"
IsVisible="{Binding RunningTests}" />
<!-- ===== Bottom summary bar ===== -->
<Border DockPanel.Dock="Bottom" Padding="8,4" BorderThickness="0,1,0,0" BorderBrush="#333">
<StackPanel Orientation="Horizontal" Spacing="12">
<TextBlock VerticalAlignment="Center">
<Run Text="Total:" />
<Run Text="{Binding TotalCount}" />
</TextBlock>
<TextBlock VerticalAlignment="Center" Foreground="#34D058">
<Run Text="✔" />
<Run Text="{Binding PassedCount}" />
</TextBlock>
<TextBlock VerticalAlignment="Center" Foreground="#F85149">
<Run Text="✘" />
<Run Text="{Binding FailedCount}" />
</TextBlock>
<TextBlock VerticalAlignment="Center" Foreground="#D29922">
<Run Text="⊘" />
<Run Text="{Binding SkippedCount}" />
</TextBlock>
<TextBlock VerticalAlignment="Center" Opacity="0.7">
<Run Text="○" />
<Run Text="{Binding NotRunCount}" />
<Run Text=" not run" />
</TextBlock>
<TextBlock VerticalAlignment="Center" Opacity="0.7">
<Run Text="•" />
<Run Text="{Binding TotalElapsedDisplay}" />
</TextBlock>
<TextBlock VerticalAlignment="Center" Opacity="0.7" IsVisible="{Binding RunningTests}">
<Run Text="Running…" />
</TextBlock>
</StackPanel>
</Border>
<!-- Tree + inspect panel; switches to stacked layout when narrow (see MainView.axaml.cs). -->
<Grid Name="MainSplit" ColumnDefinitions="*,8,2*">
<TreeView Name="TestsTree"
Grid.Column="0"
TabIndex="2"
ItemsSource="{Binding TestCases}"
SelectionMode="Multiple"
SelectionChanged="OnTreeSelectionChanged"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<TreeView.DataTemplates>
<TreeDataTemplate DataType="{x:Type vm:TestGroupViewModel}" ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal" IsVisible="{Binding IsVisible}">
<StackPanel.ContextMenu>
<ContextMenu>
<MenuItem Header="▶ Run" Command="{Binding RunTest}" />
<MenuItem Header="👁 Preview" Command="{Binding RunInteractive}" />
</ContextMenu>
</StackPanel.ContextMenu>
<!-- 3px left accent on the primary (focused) selection so it stands out from
the rest of a multi-selection. -->
<Border Width="3" Background="#58A6FF" Margin="0,1,4,1"
IsVisible="{Binding IsPrimarySelection, FallbackValue=False}" />
<Button Classes="runIcon"
Content="▶"
Command="{Binding RunTest}"
IsEnabled="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, Path=!DataContext.RunningTests}"
ToolTip.Tip="Run all tests in this group, headless (Enter)" />
<Button Classes="previewIcon"
Content="👁"
Command="{Binding RunInteractive}"
IsEnabled="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, Path=!DataContext.RunningTests}"
ToolTip.Tip="Preview interactively — opens game window, does NOT run as a test (Shift+Enter)" />
<TextBlock Classes="statusIcon" Text="✘" Foreground="#F85149" IsVisible="{Binding Failed}" />
<TextBlock Classes="statusIcon" Text="✔" Foreground="#34D058" IsVisible="{Binding Succeeded}" />
<TextBlock Classes="statusIcon" Text="⊘" Foreground="#D29922" IsVisible="{Binding Skipped}" />
<TextBlock Classes="statusIcon" Text="⭮" Foreground="#58A6FF" IsVisible="{Binding Running}" />
<TextBlock Classes="statusIcon" Text="…" Foreground="#888" IsVisible="{Binding Pending}" />
<TextBlock Classes="testName" Text="{Binding DisplayName}" />
<TextBlock Classes="elapsed" Text="{Binding ElapsedDisplay}" IsVisible="{Binding HasElapsed}" />
</StackPanel>
</TreeDataTemplate>
<DataTemplate DataType="{x:Type vm:TestCaseViewModel}">
<StackPanel Orientation="Horizontal" IsVisible="{Binding IsVisible}">
<StackPanel.ContextMenu>
<ContextMenu>
<MenuItem Header="▶ Run" Command="{Binding RunTest}" />
<MenuItem Header="👁 Preview" Command="{Binding RunInteractive}" />
</ContextMenu>
</StackPanel.ContextMenu>
<Border Width="3" Background="#58A6FF" Margin="0,1,4,1"
IsVisible="{Binding IsPrimarySelection, FallbackValue=False}" />
<Button Classes="runIcon"
Content="▶"
Command="{Binding RunTest}"
IsEnabled="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, Path=!DataContext.RunningTests}"
ToolTip.Tip="Run test, headless (Enter)" />
<Button Classes="previewIcon"
Content="👁"
Command="{Binding RunInteractive}"
IsEnabled="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, Path=!DataContext.RunningTests}"
ToolTip.Tip="Preview interactively — opens game window, does NOT run as a test (Shift+Enter)" />
<TextBlock Classes="statusIcon" Text="✘" Foreground="#F85149" IsVisible="{Binding Failed}" />
<TextBlock Classes="statusIcon" Text="✔" Foreground="#34D058" IsVisible="{Binding Succeeded}" />
<TextBlock Classes="statusIcon" Text="⊘" Foreground="#D29922" IsVisible="{Binding Skipped}" />
<TextBlock Classes="statusIcon" Text="⭮" Foreground="#58A6FF" IsVisible="{Binding Running}" />
<TextBlock Classes="statusIcon" Text="…" Foreground="#888" IsVisible="{Binding Pending}" />
<TextBlock Classes="testName" Text="{Binding DisplayName}" />
<TextBlock Classes="elapsed" Text="{Binding ElapsedDisplay}" IsVisible="{Binding HasElapsed}" />
</StackPanel>
</DataTemplate>
</TreeView.DataTemplates>
<TreeView.Styles>
<Style Selector="TreeViewItem">
<Setter Property="IsExpanded" Value="True" />
<!-- Collapse filtered-out rows entirely (binding container to data IsVisible). -->
<Setter Property="IsVisible" Value="{Binding IsVisible}" />
<!-- Bigger touch target in narrow mode (RowMinHeight is 44 on phones, 0 on desktop). -->
<Setter Property="MinHeight" Value="{Binding $parent[DockPanel].DataContext.RowMinHeight, FallbackValue=0}" />
</Style>
</TreeView.Styles>
</TreeView>
<GridSplitter Name="MainSplitter" Grid.Column="1" Background="#333" IsTabStop="False" />
<!-- Inspect: sticky header (back + name + run/preview) over scrollable failure /
stack / output / image-compare content. -->
<Border Name="InspectBorder" Grid.Column="2" Padding="8" DataContext="{Binding #TestsTree.SelectedItem}">
<DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,8">
<Button Grid.Column="0" Name="BackButton" Content="← Tests"
Click="OnBackToList" IsVisible="False"
Margin="0,0,8,0" Padding="8,4" VerticalAlignment="Top" />
<StackPanel Grid.Column="1" Spacing="2">
<TextBlock Text="{Binding DisplayName, FallbackValue='Select a test to see details'}"
FontWeight="Bold" TextWrapping="Wrap" />
<TextBlock Opacity="0.7"
Text="{Binding ElapsedDisplay, FallbackValue=''}"
IsVisible="{Binding HasElapsed, FallbackValue=False}" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Top"
IsVisible="{Binding ., Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}">
<Button Content="▶ Run" Click="OnDetailRun" Padding="8,4" Foreground="#34D058" />
<Button Content="👁 Preview" Click="OnDetailPreview" Padding="8,4" Foreground="#58A6FF" />
</StackPanel>
</Grid>
<ScrollViewer>
<StackPanel Spacing="6">
<!-- Empty-state CTA: shown when the test has no result yet. Big tap targets so
a fresh test is one tap away from running, with no header hunt. -->
<StackPanel Spacing="12" Margin="0,40,0,0" HorizontalAlignment="Center"
IsVisible="{Binding !HasResult, FallbackValue=False}">
<TextBlock Text="Not run yet" Opacity="0.55" HorizontalAlignment="Center" FontSize="14" />
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Center">
<Button Content="▶ Run" Click="OnDetailRun" Foreground="#34D058" FontSize="16" Padding="20,10" />
<Button Content="👁 Preview" Click="OnDetailPreview" Foreground="#58A6FF" FontSize="16" Padding="20,10" />
</StackPanel>
</StackPanel>
<!-- Failure message + stack first -->
<TextBlock Margin="0,4,0,0"
Foreground="#F85149"
FontWeight="Bold"
Text="Failure message"
IsVisible="{Binding FailureMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}" />
<SelectableTextBlock TextWrapping="Wrap"
FontFamily="Cascadia Mono,Consolas,monospace"
Foreground="#F85149"
Text="{Binding FailureMessage, FallbackValue=''}"
IsVisible="{Binding FailureMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}" />
<TextBlock Margin="0,4,0,0"
FontWeight="Bold"
Opacity="0.8"
Text="Stack trace"
IsVisible="{Binding FailureStackTrace, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}" />
<SelectableTextBlock TextWrapping="NoWrap"
FontFamily="Cascadia Mono,Consolas,monospace"
FontSize="11"
Opacity="0.8"
Text="{Binding FailureStackTrace, FallbackValue=''}"
IsVisible="{Binding FailureStackTrace, Converter={x:Static StringConverters.IsNotNullOrEmpty}, FallbackValue=False}" />
<!-- Captured stdout/stderr; Inlines populated in code-behind for warning/error tinting. -->
<TextBlock Margin="0,8,0,0"
FontWeight="Bold"
Opacity="0.8"
Text="Output"
IsVisible="{Binding HasOutput, FallbackValue=False}" />
<SelectableTextBlock Name="OutputBlock"
TextWrapping="NoWrap"
FontFamily="Cascadia Mono,Consolas,monospace"
FontSize="11"
Opacity="0.9"
IsVisible="{Binding HasOutput, FallbackValue=False}" />
<!-- Image comparisons list. One entry per GameTestBase comparison; multi-frame
tests (e.g. UI tests) push many. Bitmaps + diff per entry are populated in
code-behind when this test becomes the focused row. -->
<StackPanel Spacing="4"
Margin="0,8,0,0"
IsVisible="{Binding HasImageComparison, FallbackValue=False}">
<TextBlock FontWeight="Bold" Opacity="0.8" Text="Image comparisons" />
<ItemsControl Name="ImageCompareList" ItemsSource="{Binding ImageComparisons}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type vm:ImageComparisonViewModel}">
<StackPanel Spacing="2" Margin="0,4,0,8">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" FontFamily="Cascadia Mono,Consolas,monospace" FontSize="11" Opacity="0.85" />
<TextBlock Text="{Binding StatsSummary, FallbackValue=''}" Opacity="0.6" FontSize="11" />
</StackPanel>
<Grid ColumnDefinitions="*,*,*" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="Current" HorizontalAlignment="Center" Opacity="0.6" FontSize="11" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="Diff" HorizontalAlignment="Center" Opacity="0.6" FontSize="11" />
<TextBlock Grid.Row="0" Grid.Column="2" Text="Reference" HorizontalAlignment="Center" Opacity="0.6" FontSize="11" />
<Border Grid.Row="1" Grid.Column="0" BorderBrush="#333" BorderThickness="1" Margin="2" MinHeight="80">
<Grid>
<Image MaxHeight="180" Stretch="Uniform"
Source="{Binding CurrentBitmap}"
IsVisible="{Binding ShowCurrentImage}">
<Image.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Click="OnOpenFile" Tag="{Binding CurrentPath}" />
<MenuItem Header="Reveal in Explorer" Click="OnRevealCurrent" Tag="{Binding CurrentPath}" />
<MenuItem Header="Copy path" Click="OnCopyPath" Tag="{Binding CurrentPath}" />
</ContextMenu>
</Image.ContextMenu>
</Image>
<TextBlock Text="(not saved on pass)" Opacity="0.5" FontStyle="Italic" FontSize="11"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding ShowCurrentPlaceholder}" />
</Grid>
</Border>
<Border Grid.Row="1" Grid.Column="1" BorderBrush="#333" BorderThickness="1" Margin="2" MinHeight="80">
<Grid>
<Image MaxHeight="180" Stretch="Uniform"
Source="{Binding DiffBitmap}"
IsVisible="{Binding ShowDiffImage}" />
<TextBlock Text="✔" FontSize="64" Foreground="#34D058"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding ShowDiffPassed}" />
<TextBlock Text="(no diff available)" Opacity="0.5" FontStyle="Italic" FontSize="11"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsVisible="{Binding ShowDiffPlaceholder}" />
</Grid>
</Border>
<Border Grid.Row="1" Grid.Column="2" BorderBrush="#333" BorderThickness="1" Margin="2" MinHeight="80">
<Grid>
<Image MaxHeight="180" Stretch="Uniform"
Source="{Binding ReferenceBitmap}"
IsVisible="{Binding ShowReferenceImage}">
<Image.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Click="OnOpenFile" Tag="{Binding ReferencePath}" />
<MenuItem Header="Reveal in Explorer" Click="OnRevealReference" Tag="{Binding ReferencePath}" />
<MenuItem Header="Copy path" Click="OnCopyPath" Tag="{Binding ReferencePath}" />
</ContextMenu>
</Image.ContextMenu>
</Image>
<TextBlock Text="(no gold yet — Promote to create)" Opacity="0.5" FontStyle="Italic" FontSize="11"
HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"
IsVisible="{Binding ShowReferencePlaceholder}" />
</Grid>
</Border>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="6" HorizontalAlignment="Right">
<Button Content="Promote current → reference" Click="OnPromoteCurrent" Tag="{Binding}" />
</StackPanel>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</TreeDataTemplate>
<DataTemplate DataType="{x:Type vm:TestNodeViewModel}">
<StackPanel Orientation="Horizontal">
<Menu>
<MenuItem Header="▶" FontFamily="Segoe UI Symbol" Foreground="Green" Command="{Binding RunTest}" IsEnabled="{Binding RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type DockPanel}}, Path=!DataContext.RunningTests}"/>
</Menu>
<TextBlock Text="✘" FontFamily="Segoe UI Symbol" IsVisible="{Binding Failed}" Foreground="Red" />
<TextBlock Text="✔" FontFamily="Segoe UI Symbol" IsVisible="{Binding Succeeded}" Foreground="Green" />
<TextBlock Text="⭮" FontFamily="Segoe UI Symbol" IsVisible="{Binding Running}" Foreground="Blue" />
<TextBlock Text="{Binding DisplayName}" />
<TextBlock Text="(Running)" IsVisible="{Binding Running}" />
</StackPanel>
</DataTemplate>
</TreeView.DataTemplates>
<TreeView.Styles>
<Style Selector="TreeViewItem">
<!-- Start expanded -->
<Setter Property="IsExpanded" Value="True" />
</Style>
</TreeView.Styles>
</TreeView>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</DockPanel>
</UserControl>
@@ -1,7 +1,20 @@
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
using System.ComponentModel;
using System.IO;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Threading;
using xunit.runner.stride.ViewModels;
namespace xunit.runner.stride.Views;
@@ -10,5 +23,505 @@ public partial class MainView : UserControl
public MainView()
{
InitializeComponent();
// Tunnel so Enter/Space reach us before TreeView consumes them. Tab navigation is
// declarative (IsTabStop="False" on chrome).
AddHandler(InputElement.KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel);
Loaded += OnLoaded;
DataContextChanged += (_, _) => WireFocusTest();
SizeChanged += (_, _) => ApplyResponsiveLayout();
}
void OnLoaded(object? sender, RoutedEventArgs e)
{
// Focus the filter textbox immediately so users can start typing without clicking.
Dispatcher.UIThread.Post(() => FilterBox.Focus(), DispatcherPriority.Background);
WireFocusTest();
ApplyResponsiveLayout();
}
// Tree | output split flips to stacked when the viewport is narrow or portrait.
// Landscape phones with ≥720 width keep side-by-side (their vertical room is the
// scarce dimension). Toolbar wraps independently via WrapPanel.
const double NarrowBreakpoint = 720;
bool currentLayoutNarrow;
bool layoutInitialized;
void ApplyResponsiveLayout()
{
bool narrow = Bounds.Width < NarrowBreakpoint || Bounds.Width < Bounds.Height;
if (DataContext is MainViewModel m) m.Tests.IsNarrowMode = narrow;
ApplyNarrowVisibility();
if (layoutInitialized && narrow == currentLayoutNarrow) return;
currentLayoutNarrow = narrow;
layoutInitialized = true;
if (narrow)
{
// Single-cell layout: both tree and detail occupy the full content area; the
// ApplyNarrowVisibility toggle picks which one is shown. The splitter is hidden.
MainSplit.ColumnDefinitions.Clear();
MainSplit.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
MainSplit.RowDefinitions.Clear();
MainSplit.RowDefinitions.Add(new RowDefinition(GridLength.Star));
Grid.SetColumn(TestsTree, 0); Grid.SetRow(TestsTree, 0);
Grid.SetColumn(InspectBorder, 0); Grid.SetRow(InspectBorder, 0);
Grid.SetColumn(MainSplitter, 0); Grid.SetRow(MainSplitter, 0);
}
else
{
// Side by side: tree | splitter | output.
MainSplit.RowDefinitions.Clear();
MainSplit.ColumnDefinitions.Clear();
MainSplit.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
MainSplit.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(8, GridUnitType.Pixel)));
MainSplit.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(2, GridUnitType.Star)));
Grid.SetRow(TestsTree, 0); Grid.SetColumn(TestsTree, 0);
Grid.SetRow(MainSplitter, 0); Grid.SetColumn(MainSplitter, 1);
Grid.SetRow(InspectBorder, 0); Grid.SetColumn(InspectBorder, 2);
MainSplitter.ResizeDirection = GridResizeDirection.Columns;
MainSplitter.Width = 8; MainSplitter.Height = double.NaN;
MainSplitter.HorizontalAlignment = HorizontalAlignment.Center;
MainSplitter.VerticalAlignment = VerticalAlignment.Stretch;
}
}
void WireFocusTest()
{
if (DataContext is not MainViewModel main) return;
// Selecting the running test surfaces it in the inspect panel (bound to SelectedItem).
main.Tests.FocusTest = vm => TestsTree.SelectedItem = vm;
// React to DetailPageActive flips from the VM (e.g. when leaving narrow mode).
main.Tests.PropertyChanged -= OnTestsViewModelPropertyChanged;
main.Tests.PropertyChanged += OnTestsViewModelPropertyChanged;
}
void OnTestsViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(TestsViewModel.IsDetailPageActive)
or nameof(TestsViewModel.IsNarrowMode))
ApplyNarrowVisibility();
}
// Apply the drill-down rule: in narrow mode show either the tree OR the detail; in wide
// mode show both. Also drives the back button and the splitter handle.
void ApplyNarrowVisibility()
{
if (DataContext is not MainViewModel main) return;
var narrow = main.Tests.IsNarrowMode;
var detail = narrow && main.Tests.IsDetailPageActive;
TestsTree.IsVisible = !narrow || !detail;
InspectBorder.IsVisible = !narrow || detail;
MainSplitter.IsVisible = !narrow;
BackButton.IsVisible = narrow;
}
void OnBackToList(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainViewModel main) return;
main.Tests.IsDetailPageActive = false;
}
// Detail-pane Run/Preview buttons use the same multi-aware path as Enter so a tree
// multi-selection runs all selected tests, not just the focused one.
void OnDetailRun(object? sender, RoutedEventArgs e) => RunFromDetail(interactive: false);
void OnDetailPreview(object? sender, RoutedEventArgs e) => RunFromDetail(interactive: true);
void RunFromDetail(bool interactive)
{
if (DataContext is not MainViewModel main) return;
var vm = main.Tests;
if (vm.RunningTests) return;
if (vm.SelectedCases.Count > 0)
_ = vm.RunSelectedCases(interactive);
else if (TestsTree.SelectedItem is TestNodeViewModel node)
vm.RunTests(node, interactive);
}
void OnKeyDown(object? sender, KeyEventArgs e)
{
if (DataContext is not MainViewModel main) return;
var vm = main.Tests;
bool focusOnFilter = FilterBox.IsFocused;
bool ctrl = (e.KeyModifiers & KeyModifiers.Control) != 0;
bool shift = (e.KeyModifiers & KeyModifiers.Shift) != 0;
// `/` focuses the filter from anywhere — except when already typing in it.
if (e.Key == Key.OemQuestion && !ctrl && !shift && !focusOnFilter)
{
FilterBox.Focus();
e.Handled = true;
return;
}
switch (e.Key)
{
case Key.Escape:
TestsTree.SelectedItems?.Clear();
FilterBox.Focus();
e.Handled = true;
return;
case Key.F when ctrl:
FilterBox.Focus();
e.Handled = true;
return;
case Key.F5:
if (!vm.RunningTests) vm.RunSelected();
e.Handled = true;
return;
case Key.R when ctrl:
if (vm.HasFailures && !vm.RunningTests) vm.RunFailedCmd();
e.Handled = true;
return;
case Key.Up:
case Key.Down:
// From the filter: move tree selection without leaving the textbox. In the
// tree, native nav handles it.
if (focusOnFilter)
{
MoveTreeSelection(e.Key == Key.Down ? +1 : -1);
e.Handled = true;
}
return;
case Key.PageUp:
case Key.PageDown:
if (focusOnFilter)
{
MoveTreeSelection(e.Key == Key.PageDown ? +10 : -10);
e.Handled = true;
}
return;
case Key.Enter:
// Headless run, or interactive preview with Shift. Runs every selected
// test (SelectedCases is the flat expansion of the tree selection, so
// selecting a group runs its cases too).
if (!vm.RunningTests && vm.SelectedCases.Count > 0)
{
_ = vm.RunSelectedCases(interactive: shift);
e.Handled = true;
}
return;
case Key.Space:
// Tree only — Space in the filter must type a literal space.
if (!focusOnFilter && !vm.RunningTests && vm.SelectedCases.Count > 0)
{
_ = vm.RunSelectedCases(interactive: shift);
e.Handled = true;
}
return;
}
}
// Set while MoveTreeSelection is rewriting the selection so the SelectionChanged event
// doesn't fire twice (Clear + Set). We sync once at the end instead.
bool suspendSelectionSync;
void MoveTreeSelection(int delta)
{
if (DataContext is not MainViewModel main) return;
var vm = main.Tests;
// Arrow nav walks the flat list of visible test cases (skipping groups so Enter
// always has something runnable selected).
var cases = vm.EnumerateAllTestCases().Where(c => c.IsVisible).ToList();
if (cases.Count == 0) return;
int currentIndex = -1;
if (TestsTree.SelectedItem is TestCaseViewModel current)
currentIndex = cases.IndexOf(current);
int next = currentIndex < 0
? (delta > 0 ? 0 : cases.Count - 1)
: Math.Clamp(currentIndex + delta, 0, cases.Count - 1);
var target = cases[next];
suspendSelectionSync = true;
try
{
TestsTree.SelectedItems?.Clear();
TestsTree.SelectedItem = target;
}
finally
{
suspendSelectionSync = false;
}
SyncSelection();
}
void OnTreeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (suspendSelectionSync) return;
SyncSelection();
}
TestNodeViewModel? previousPrimary;
void SyncSelection()
{
if (DataContext is not MainViewModel main) return;
var vm = main.Tests;
vm.SelectedCases.Clear();
foreach (var item in TestsTree.SelectedItems!)
{
if (item is TestCaseViewModel testCase)
vm.SelectedCases.Add(testCase);
else if (item is TestGroupViewModel group)
foreach (var c in group.EnumerateTestCases())
vm.SelectedCases.Add(c);
}
vm.OnSelectionChanged();
// Live output coloring follows the focused test as it streams in during a run.
WatchOutput(TestsTree.SelectedItem as TestCaseViewModel);
// Track the primary (focused) row in a multi-selection so its row can show an accent
// stripe — visually disambiguating "the one in the inspect pane" from the rest.
var newPrimary = TestsTree.SelectedItem as TestNodeViewModel;
if (previousPrimary is not null && previousPrimary != newPrimary)
previousPrimary.IsPrimarySelection = false;
if (newPrimary is not null)
newPrimary.IsPrimarySelection = true;
previousPrimary = newPrimary;
// In narrow mode tapping a row drills into the detail page.
if (vm.IsNarrowMode && TestsTree.SelectedItem is not null)
vm.IsDetailPageActive = true;
}
// Per-line tinting requires populating Inlines manually (a plain Text binding can't
// carry per-run colors). Lines containing "Warning:"/"Error:" get a hue.
TestCaseViewModel? watchedCase;
static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#E5C07B"));
static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FF6E6E"));
void WatchOutput(TestCaseViewModel? testCase)
{
if (watchedCase is not null)
{
watchedCase.PropertyChanged -= OnWatchedCasePropertyChanged;
watchedCase.ImageComparisons.CollectionChanged -= OnImageComparisonsChanged;
}
watchedCase = testCase;
if (watchedCase is not null)
{
watchedCase.PropertyChanged += OnWatchedCasePropertyChanged;
watchedCase.ImageComparisons.CollectionChanged += OnImageComparisonsChanged;
}
RebuildOutputInlines(testCase?.Output);
if (testCase is not null)
foreach (var entry in testCase.ImageComparisons)
_ = LoadEntryAsync(entry);
}
void OnWatchedCasePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is not TestCaseViewModel vm) return;
if (e.PropertyName == nameof(TestCaseViewModel.Output))
RebuildOutputInlines(vm.Output);
}
void OnImageComparisonsChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if (e.NewItems is null) return;
foreach (ImageComparisonViewModel entry in e.NewItems)
_ = LoadEntryAsync(entry);
}
void RebuildOutputInlines(string? output)
{
OutputBlock.Inlines ??= new InlineCollection();
OutputBlock.Inlines.Clear();
if (string.IsNullOrEmpty(output))
return;
foreach (var rawLine in output.Split('\n'))
{
var line = rawLine.EndsWith('\r') ? rawLine[..^1] : rawLine;
IBrush? color = null;
if (line.Contains("Error:", System.StringComparison.Ordinal)) color = ErrorBrush;
else if (line.Contains("Warning:", System.StringComparison.Ordinal)) color = WarningBrush;
var run = new Run(line + "\n");
if (color is not null) run.Foreground = color;
OutputBlock.Inlines.Add(run);
}
}
// === Image comparison ===
// Diff threshold: per-channel absolute difference above this is highlighted in red.
const int DiffThreshold = 2;
// Load bitmaps + (on failure) compute the pixel diff for one comparison entry, then
// publish them on the VM so the bound Image controls update. Off-thread so a stack of
// 20+ frames doesn't freeze the UI.
static async Task LoadEntryAsync(ImageComparisonViewModel entry)
{
if (entry.CurrentBitmap is not null || entry.ReferenceBitmap is not null)
return; // already loaded — no need to redo on re-watch
var (curBmp, refBmp, diffBmp) = await Task.Run(() =>
{
Bitmap? cur = LoadIfExists(entry.CurrentPath);
Bitmap? @ref = LoadIfExists(entry.ReferencePath);
WriteableBitmap? diff = (!entry.Passed && cur is not null && @ref is not null)
? ComputeDiff(cur, @ref)
: null;
return (cur, @ref, diff);
});
entry.CurrentBitmap = curBmp;
entry.ReferenceBitmap = refBmp;
entry.DiffBitmap = diffBmp;
}
static Bitmap? LoadIfExists(string? path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
try { return new Bitmap(path); } catch { return null; }
}
// Output is the current image with pixels that differ from reference (in any channel
// by more than DiffThreshold) overpainted red. Dimensions clamp to the intersection of
// both images so size mismatches don't throw — extra rows/cols on the larger one are
// simply ignored.
static unsafe WriteableBitmap? ComputeDiff(Bitmap current, Bitmap reference)
{
int w = (int)System.Math.Min(current.PixelSize.Width, reference.PixelSize.Width);
int h = (int)System.Math.Min(current.PixelSize.Height, reference.PixelSize.Height);
if (w <= 0 || h <= 0) return null;
var size = new PixelSize(w, h);
var stride = w * 4;
var bytes = w * h * 4;
var curBuf = new byte[bytes];
var refBuf = new byte[bytes];
try
{
current.CopyPixels(new PixelRect(0, 0, w, h), System.Runtime.InteropServices.Marshal.UnsafeAddrOfPinnedArrayElement(curBuf, 0), bytes, stride);
reference.CopyPixels(new PixelRect(0, 0, w, h), System.Runtime.InteropServices.Marshal.UnsafeAddrOfPinnedArrayElement(refBuf, 0), bytes, stride);
}
catch { return null; }
var diff = new WriteableBitmap(size, current.Dpi, PixelFormat.Bgra8888, AlphaFormat.Premul);
using (var fb = diff.Lock())
{
var dst = (byte*)fb.Address;
for (int i = 0; i < bytes; i += 4)
{
int db = System.Math.Abs(curBuf[i + 0] - refBuf[i + 0]);
int dg = System.Math.Abs(curBuf[i + 1] - refBuf[i + 1]);
int dr = System.Math.Abs(curBuf[i + 2] - refBuf[i + 2]);
int maxD = System.Math.Max(db, System.Math.Max(dg, dr));
if (maxD > DiffThreshold)
{
// Highlight in red; intensity scales with the diff magnitude.
byte intensity = (byte)System.Math.Min(255, 96 + maxD * 4);
dst[i + 0] = 0; // B
dst[i + 1] = 0; // G
dst[i + 2] = intensity; // R
dst[i + 3] = 255; // A
}
else
{
// Dim grayscale of current so the highlighted pixels pop visually.
int gray = (curBuf[i + 0] + curBuf[i + 1] + curBuf[i + 2]) / 3 / 3;
dst[i + 0] = (byte)gray;
dst[i + 1] = (byte)gray;
dst[i + 2] = (byte)gray;
dst[i + 3] = 255;
}
}
}
return diff;
}
// Reveal/Open/Copy/Promote sources: the MenuItem / Button is bound to one row's
// ImageComparisonViewModel via {Binding} or {Binding <Path>} Tag — pull the target from
// sender so each entry's actions hit its own paths, not the test's first/last comparison.
void OnRevealCurrent(object? sender, RoutedEventArgs e) => RevealInExplorer((sender as Control)?.Tag as string);
void OnRevealReference(object? sender, RoutedEventArgs e) => RevealInExplorer((sender as Control)?.Tag as string);
void OnOpenFile(object? sender, RoutedEventArgs e)
{
if ((sender as Control)?.Tag is not string path || string.IsNullOrEmpty(path) || !File.Exists(path)) return;
try
{
// UseShellExecute lets the OS pick the default app for the extension (image viewer
// for .png on Windows, Preview on macOS, xdg-open on Linux).
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
}
catch (System.Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Open failed for {path}: {ex.Message}");
}
}
async void OnCopyPath(object? sender, RoutedEventArgs e)
{
if ((sender as Control)?.Tag is not string path || string.IsNullOrEmpty(path)) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
try { await clipboard.SetTextAsync(path); }
catch (System.Exception ex) { System.Diagnostics.Debug.WriteLine($"Copy path failed: {ex.Message}"); }
}
// Opens the OS file manager scrolled to the target path. If the file itself doesn't
// exist (e.g. promote-pending reference) we fall back to opening the parent folder.
static void RevealInExplorer(string? path)
{
if (string.IsNullOrEmpty(path)) return;
try
{
if (OperatingSystem.IsWindows())
{
if (File.Exists(path))
System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path}\"");
else if (Directory.Exists(Path.GetDirectoryName(path)))
System.Diagnostics.Process.Start("explorer.exe", $"\"{Path.GetDirectoryName(path)}\"");
}
else if (OperatingSystem.IsMacOS())
{
System.Diagnostics.Process.Start("open", File.Exists(path) ? $"-R \"{path}\"" : $"\"{Path.GetDirectoryName(path)}\"");
}
else
{
var dir = File.Exists(path) ? Path.GetDirectoryName(path) : path;
if (!string.IsNullOrEmpty(dir))
System.Diagnostics.Process.Start("xdg-open", $"\"{dir}\"");
}
}
catch (System.Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Reveal failed for {path}: {ex.Message}");
}
}
void OnPromoteCurrent(object? sender, RoutedEventArgs e)
{
if ((sender as Control)?.Tag is not ImageComparisonViewModel entry) return;
if (string.IsNullOrEmpty(entry.CurrentPath) || string.IsNullOrEmpty(entry.ReferencePath)) return;
if (!File.Exists(entry.CurrentPath)) return;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(entry.ReferencePath)!);
File.Copy(entry.CurrentPath, entry.ReferencePath, overwrite: true);
// Force the entry's reference image to reload so the new gold appears immediately.
entry.ReferenceBitmap = null;
entry.CurrentBitmap = null;
entry.DiffBitmap = null;
_ = LoadEntryAsync(entry);
}
catch (System.Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Promote failed: {ex}");
}
}
}
@@ -6,6 +6,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="xunit.runner.stride.Views.MainWindow"
Icon="/Assets/avalonia-logo.ico"
Title="XUnit Runner (Stride)">
Title="XUnit Runner (Stride)"
Background="{DynamicResource WindowBackgroundBrush}">
<views:MainView />
</Window>