mirror of
https://github.com/stride3d/stride.git
synced 2026-10-12 02:23:23 +00:00
xunit.runner.stride: revamp UI (filter, per-row run/preview, output capture, RenderDoc dropdown)
This commit is contained in:
@@ -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())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
 · Never — no capture
 · On error — capture frames; keep only for failing tests
 · 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>
|
||||
|
||||
Reference in New Issue
Block a user