📄 ui/tests/MainWindowTests/RenderShould.cs
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Cfa.Ui.Services;
using Cfa.Ui.ViewModels;
using Cfa.Ui.Views;
using Nodify;
using Shouldly;

namespace Cfa.Ui.Tests.MainWindowTests;

public sealed class RenderShould
{
    [AvaloniaFact]
    public async Task MatchThePreviousBitmapAfterFittingTheRealSolutionGraph()
    {
        // Arrange
        var directory = new DirectoryInfo(AppContext.BaseDirectory);
        while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "code-flow-analysis.slnx")))
            directory = directory.Parent;
        var root = directory.ShouldNotBeNull().FullName;
        using var model = new MainViewModel(new Desktop(root), new ScanSessionFactory());
        var window = new MainWindow
        {
            DataContext = model,
            Width = 1400,
            Height = 900,
        };
        window.Show();
        try
        {
            await model.OpenSolutionCommand.ExecuteAsync(null);
            model.Address = "core/tests/AnalyzerTests/AnalyzeShould.cs:15";
            await model.AnalyzeCommand.ExecuteAsync(null);
            Dispatcher.UIThread.RunJobs();
            window.UpdateLayout();
            var editor = window.FindControl<NodifyEditor>("Editor")!;
            editor.ViewportZoom = 0.2;
            editor.ViewportLocation = new Point(-500, -500);
            var fit = window.FindControl<Button>("FitGraphButton")!;
            var point = fit.TranslatePoint(new Point(fit.Bounds.Width / 2, fit.Bounds.Height / 2), window)!.Value;

            // Act
            window.MouseDown(point, MouseButton.Left);
            window.MouseUp(point, MouseButton.Left);
            window.MouseMove(new Point(5, 5));
            Dispatcher.UIThread.RunJobs();
            window.UpdateLayout();
            // Repaint the entire window rather than reusing incremental frame damage.
            using var actual = new RenderTargetBitmap(new PixelSize(1400, 900), new Vector(96, 96));
            actual.Render(window);

            // Assert
            var scene = model.Scene.ShouldNotBeNull();
            var bounds = scene.Nodes.Select(node => node.Bounds).Aggregate((first, next) => first.Union(next));
            var center = (bounds.Center - editor.ViewportLocation) * editor.ViewportZoom;
            center.X.ShouldBe(editor.Bounds.Width / 2, 0.01);
            center.Y.ShouldBe(editor.Bounds.Height / 2, 0.01);
            var available = new Rect(editor.Bounds.Size);
            editor.ViewportZoom.ShouldBe(
                Math.Min(
                    editor.MaxViewportZoom,
                    Math.Min(editor.Bounds.Width / bounds.Width, editor.Bounds.Height / bounds.Height)
                ),
                0.000001
            );
            foreach (var node in scene.Nodes)
            {
                var location = (node.Location - editor.ViewportLocation) * editor.ViewportZoom;
                available
                    .Inflate(0.000001)
                    .Contains(new Rect(location, new Size(node.Width, node.Height) * editor.ViewportZoom))
                    .ShouldBeTrue();
            }

            if (Environment.GetEnvironmentVariable("CFA_UI_ARTIFACTS") is { } artifacts)
            {
                Directory.CreateDirectory(artifacts);
                actual.Save(Path.Combine(artifacts, "analyzer-fit.png"));
            }
            if (Environment.GetEnvironmentVariable("CFA_UPDATE_SNAPSHOTS") == "1")
                actual.Save(Path.Combine(root, "ui/tests/Snapshots/analyzer-fit.png"));

            using var expected = new Bitmap(Path.Combine(root, "ui/tests/Snapshots/analyzer-fit.png"));
            actual.PixelSize.ShouldBe(expected.PixelSize);
            // Decode both images identically to normalize their native pixel formats.
            using var encoded = new MemoryStream();
            actual.Save(encoded);
            encoded.Position = 0;
            using var normalized = new Bitmap(encoded);
            var actualPixels = Pixels(normalized);
            var expectedPixels = Pixels(expected);
            actualPixels
                .SequenceEqual(expectedPixels)
                .ShouldBeTrue("The rendered bitmap must match every baseline pixel.");
        }
        finally
        {
            window.Close();
        }
    }

    private static byte[] Pixels(Bitmap bitmap)
    {
        var stride = bitmap.PixelSize.Width * 4;
        var pixels = new byte[stride * bitmap.PixelSize.Height];
        var buffer = Marshal.AllocHGlobal(pixels.Length);
        try
        {
            bitmap.CopyPixels(new PixelRect(bitmap.PixelSize), buffer, pixels.Length, stride);
            Marshal.Copy(buffer, pixels, 0, pixels.Length);
            return pixels;
        }
        finally
        {
            Marshal.FreeHGlobal(buffer);
        }
    }

    private sealed class Desktop(string root) : IDesktopServices
    {
        public Task<FileInfo?> PickConfigAsync() => Task.FromResult<FileInfo?>(null);

        public Task<FileInfo?> PickSolutionAsync() =>
            Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(root, "code-flow-analysis.slnx")));

        public Task ShowErrorAsync(string message) => throw new InvalidOperationException(message);

        public Task OpenSourceAsync(string link) => Task.CompletedTask;
    }
}