using System; using System.IO; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace LANCommander.Launcher.Tests.Helpers; /// /// Pixel-level image comparison for visual regression detection. /// public static class VisualComparer { /// /// Per-channel tolerance (0–255). Pixels within this delta on every channel /// are considered identical. Handles minor anti-aliasing and font-hint variation. /// public const int DefaultTolerancePerChannel = 10; /// /// Maximum proportion of pixels (0–100) that may differ before the comparison fails. /// 1% allows for small, unavoidable rendering differences across platforms. /// public const double DefaultMaxMismatchPercent = 1.0; /// /// Compares two screenshots pixel by pixel. /// /// Path to the screenshot captured in this test run. /// Path to the committed baseline image. /// Where to write a diff-highlight image when differences are found. /// Per-channel delta considered "identical". /// Maximum differing-pixel percentage before failure. /// A describing what was found. public static ComparisonResult Compare( string actualPath, string baselinePath, string diffOutputPath, int tolerancePerChannel = DefaultTolerancePerChannel, double maxMismatchPercent = DefaultMaxMismatchPercent) { if (!File.Exists(baselinePath)) { return ComparisonResult.NoBaseline(baselinePath); } using var actual = Image.Load(actualPath); using var baseline = Image.Load(baselinePath); if (actual.Width != baseline.Width || actual.Height != baseline.Height) { return ComparisonResult.SizeMismatch( actual.Width, actual.Height, baseline.Width, baseline.Height); } int mismatchCount = 0; int totalPixels = actual.Width * actual.Height; // Build a diff image: mismatched pixels → vivid red, matched → dimmed grayscale. using var diff = new Image(actual.Width, actual.Height); for (int y = 0; y < actual.Height; y++) { for (int x = 0; x < actual.Width; x++) { var a = actual[x, y]; var b = baseline[x, y]; bool differs = Math.Abs(a.R - b.R) > tolerancePerChannel || Math.Abs(a.G - b.G) > tolerancePerChannel || Math.Abs(a.B - b.B) > tolerancePerChannel; if (differs) { mismatchCount++; diff[x, y] = new Rgba32(255, 0, 80, 255); // red highlight } else { // Dim matched pixels so regressions stand out visually. byte luma = (byte)((a.R * 299 + a.G * 587 + a.B * 114) / 1000 / 3); diff[x, y] = new Rgba32(luma, luma, luma, 255); } } } double mismatchPercent = (double)mismatchCount / totalPixels * 100.0; if (mismatchPercent > 0) { diff.SaveAsPng(diffOutputPath); } return new ComparisonResult { BaselineExists = true, SizesMatch = true, MismatchPixelCount = mismatchCount, TotalPixelCount = totalPixels, MismatchPercent = mismatchPercent, IsDifferent = mismatchPercent > maxMismatchPercent, DiffImagePath = mismatchPercent > 0 ? diffOutputPath : null, }; } } public class ComparisonResult { public bool BaselineExists { get; init; } public bool SizesMatch { get; init; } public int MismatchPixelCount { get; init; } public int TotalPixelCount { get; init; } public double MismatchPercent { get; init; } public bool IsDifferent { get; init; } public string? DiffImagePath { get; init; } public string? FailureReason { get; init; } public bool Passed => BaselineExists && SizesMatch && !IsDifferent; /// Human-readable summary for xUnit failure messages. public string Summary => !BaselineExists ? $"No baseline found: {FailureReason}. The screenshot captured by this run has been saved; commit it as the baseline." : !SizesMatch ? $"Image size changed: {FailureReason}" : IsDifferent ? $"Visual regression detected: {MismatchPercent:F2}% of pixels differ ({MismatchPixelCount:N0} / {TotalPixelCount:N0}). Diff image: {DiffImagePath}" : $"Pass — {MismatchPercent:F2}% pixel mismatch (within tolerance)."; public static ComparisonResult NoBaseline(string baselinePath) => new() { BaselineExists = false, FailureReason = baselinePath, IsDifferent = true, }; public static ComparisonResult SizeMismatch(int aw, int ah, int bw, int bh) => new() { BaselineExists = true, SizesMatch = false, FailureReason = $"actual={aw}×{ah}, baseline={bw}×{bh}", IsDifferent = true, }; }