Name Message Date
📄 InteractShould.cs One-shot 5 days ago
📄 ui/tests/MainWindowTests/InteractShould.cs
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Cfa.Ui.Graph;
using Cfa.Ui.Services;
using Cfa.Ui.ViewModels;
using Cfa.Ui.Views;
using Nodify;
using Shouldly;

namespace Cfa.Ui.Tests.MainWindowTests;

public sealed class InteractShould
{
    [AvaloniaFact]
    public async Task KeepTheAddressDisabledWhileTheSolutionLoads()
    {
        // Arrange
        var factory = new FakeFactory
        {
            Pending = new TaskCompletionSource<IScanSession>(TaskCreationOptions.RunContinuationsAsynchronously),
        };
        using var model = new MainViewModel(new FakeDesktop(), factory);
        var window = new MainWindow { DataContext = model };
        window.Show();
        try
        {
            // Act
            var loading = model.OpenSolutionCommand.ExecuteAsync(null);

            // Assert
            model.IsBusy.ShouldBeTrue();
            model.OpenSolutionCommand.CanExecute(null).ShouldBeFalse();
            window.FindControl<TextBox>("AddressBar")!.IsEnabled.ShouldBeFalse();
            factory.Pending.SetResult(factory.Session);
            await loading;
            model.IsBusy.ShouldBeFalse();
            window.FindControl<TextBox>("AddressBar")!.IsEnabled.ShouldBeTrue();
        }
        finally
        {
            window.Close();
        }
    }

    [AvaloniaFact]
    public async Task PanAndZoomWithoutMovingNodesOrOpeningTheirLinks()
    {
        // Arrange
        var desktop = new FakeDesktop();
        using var model = new MainViewModel(desktop, new FakeFactory());
        var window = new MainWindow { DataContext = model };
        window.Show();
        try
        {
            await model.OpenSolutionCommand.ExecuteAsync(null);
            model.Address = "Flow.cs:1";
            await model.AnalyzeCommand.ExecuteAsync(null);
            Dispatcher.UIThread.RunJobs();
            window.UpdateLayout();
            var editor = window.FindControl<NodifyEditor>("Editor")!;
            var initialZoom = editor.ViewportZoom;
            var initialViewport = editor.ViewportLocation;
            var positions = model.Scene!.Nodes.Select(node => node.Location).ToArray();
            var point = editor.TranslatePoint(new Point(120, 120), window)!.Value;

            // Act
            window.MouseDown(point, MouseButton.Right);
            window.MouseMove(point + new Point(90, 40), RawInputModifiers.RightMouseButton);
            window.MouseUp(point + new Point(90, 40), MouseButton.Right);
            window.MouseWheel(point, new Vector(0, 1));

            // Assert
            editor.ViewportLocation.ShouldNotBe(initialViewport);
            editor.ViewportZoom.ShouldBeGreaterThan(initialZoom);
            model.Scene.Nodes.Select(node => node.Location).ShouldBe(positions);
            desktop.OpenedLink.ShouldBeNull();
        }
        finally
        {
            window.Close();
        }
    }

    [AvaloniaFact]
    public async Task DisplayAnErrorPopupThatCanBeDismissed()
    {
        // Arrange
        var window = new MainWindow();
        window.Show();
        var service = new DesktopServices(window);
        try
        {
            // Act
            var showing = service.ShowErrorAsync("Example failure");
            var dialog = window.OwnedWindows.ShouldHaveSingleItem();
            dialog.UpdateLayout();
            var close = dialog.GetVisualDescendants().OfType<Button>().ShouldHaveSingleItem();
            var point = close.TranslatePoint(new Point(close.Bounds.Width / 2, close.Bounds.Height / 2), dialog)!.Value;
            dialog.MouseDown(point, MouseButton.Left);
            dialog.MouseUp(point, MouseButton.Left);
            await showing;

            // Assert
            window.OwnedWindows.ShouldBeEmpty();
        }
        finally
        {
            window.Close();
        }
    }

    [AvaloniaFact]
    public async Task LoadOnceExploreWithEnterAndOpenTheClickedSourceLink()
    {
        // Arrange
        var desktop = new FakeDesktop();
        var factory = new FakeFactory();
        using var model = new MainViewModel(desktop, factory);
        var window = new MainWindow { DataContext = model };
        window.Show();
        try
        {
            var address = window.FindControl<TextBox>("AddressBar")!;
            address.IsEnabled.ShouldBeFalse();

            // Act
            window.KeyPressQwerty(PhysicalKey.O, RawInputModifiers.Control);
            window.KeyReleaseQwerty(PhysicalKey.O, RawInputModifiers.Control);
            await model.OpenSolutionCommand.ExecutionTask!;
            address.IsEnabled.ShouldBeTrue();
            address.Text = "core/src/Scanner.cs:50";
            address.Focus();
            window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
            window.KeyReleaseQwerty(PhysicalKey.Enter, RawInputModifiers.None);
            await model.AnalyzeCommand.ExecutionTask!;
            Dispatcher.UIThread.RunJobs();
            window.UpdateLayout();
            var button = window
                .GetVisualDescendants()
                .OfType<Button>()
                .First(control => control.DataContext is GraphNode);
            var node = (GraphNode)button.DataContext!;
            var point = button
                .TranslatePoint(new Point(button.Bounds.Width / 2, button.Bounds.Height / 2), window)!
                .Value;
            window.MouseDown(point, MouseButton.Left);
            window.MouseUp(point, MouseButton.Left);
            if (model.OpenSourceCommand.ExecutionTask is { } openTask)
            {
                await openTask;
            }
            await model.AnalyzeCommand.ExecuteAsync(null);

            // Assert
            factory.Opens.ShouldBe(1);
            factory.Session.Analyses.ShouldBe(2);
            factory.Session.LastFile!.FullName.ShouldBe("/repo/core/src/Scanner.cs");
            factory.Session.LastLine.ShouldBe(50);
            desktop.OpenedLink.ShouldBe(node.SourceLink);
            desktop.OpenedLink.ShouldStartWith("file:///repo/Flow.cs#");
            desktop.Error.ShouldBeNull();
            window.FindControl<NodifyEditor>("Editor")!.ItemsSource.ShouldNotBeNull();
            window.GetVisualDescendants().OfType<ItemContainer>().ShouldAllBe(container => !container.IsDraggable);
        }
        finally
        {
            window.Close();
        }
    }

    [AvaloniaFact]
    public async Task ShowBusyStateAndReportErrorsWithoutReplacingThePreviousGraph()
    {
        // Arrange
        var desktop = new FakeDesktop();
        var factory = new FakeFactory();
        using var model = new MainViewModel(desktop, factory);
        var window = new MainWindow { DataContext = model };
        window.Show();
        try
        {
            await model.OpenSolutionCommand.ExecuteAsync(null);
            model.Address = "Flow.cs:1";
            await model.AnalyzeCommand.ExecuteAsync(null);
            var scene = model.Scene;
            factory.Session.Pending = new TaskCompletionSource<CallGraphAnalysis>(
                TaskCreationOptions.RunContinuationsAsynchronously
            );

            // Act
            var analysis = model.AnalyzeCommand.ExecuteAsync(null);
            model.IsBusy.ShouldBeTrue();
            window.FindControl<TextBox>("AddressBar")!.IsEnabled.ShouldBeFalse();
            factory.Session.Pending.SetException(
                new InvalidOperationException("The selected source cannot be analyzed.")
            );
            await analysis;

            // Assert
            model.IsBusy.ShouldBeFalse();
            model.Scene.ShouldBeSameAs(scene);
            desktop.Error.ShouldBe("The selected source cannot be analyzed.");
            window.FindControl<TextBox>("AddressBar")!.IsEnabled.ShouldBeTrue();
        }
        finally
        {
            window.Close();
        }
    }

    [AvaloniaFact]
    public async Task RenderMirroredBranchesArrowsAndSelfLoopWithoutExtraSelfNode()
    {
        // Arrange
        var desktop = new FakeDesktop();
        using var model = new MainViewModel(desktop, new FakeFactory());
        var window = new MainWindow { DataContext = model };
        window.Show();
        try
        {
            await model.OpenSolutionCommand.ExecuteAsync(null);
            model.Address = "Flow.cs:1";

            // Act
            await model.AnalyzeCommand.ExecuteAsync(null);
            Dispatcher.UIThread.RunJobs();
            window.UpdateLayout();
            window.CaptureRenderedFrame()?.Dispose();
            using var frame = window.CaptureRenderedFrame().ShouldNotBeNull();

            // Assert
            var scene = model.Scene.ShouldNotBeNull();
            scene.Nodes.Count.ShouldBe(5);
            scene.Nodes.Single(node => node.Symbol.Name == "Request").Location.X.ShouldBeLessThan(0);
            scene.Nodes.Single(node => node.Symbol.Name == "Handle").Location.X.ShouldBeGreaterThan(0);
            scene.Edges.ShouldContain(edge => edge.IsLoopback && edge.Source == edge.Target);
            var buttons = window
                .GetVisualDescendants()
                .OfType<Button>()
                .Where(button => button.DataContext is GraphNode)
                .ToArray();
            buttons.Length.ShouldBe(scene.Nodes.Count);
            buttons.ShouldAllBe(button => button.Bounds.Width > 0 && button.Bounds.Height > 0);
            var editor = window.FindControl<NodifyEditor>("Editor")!;
            var origin = buttons.Single(button => ((GraphNode)button.DataContext!).IsOrigin);
            var originCenter = origin
                .TranslatePoint(new Point(origin.Bounds.Width / 2, origin.Bounds.Height / 2), editor)!
                .Value;
            originCenter.X.ShouldBe(editor.Bounds.Width / 2, 2);
            originCenter.Y.ShouldBe(editor.Bounds.Height / 2, 2);
            window.GetVisualDescendants().OfType<GraphConnection>().Count().ShouldBe(scene.Edges.Count);
            frame.PixelSize.Width.ShouldBeGreaterThan(800);
            var stride = frame.PixelSize.Width * 4;
            var pixels = new byte[stride * frame.PixelSize.Height];
            var buffer = Marshal.AllocHGlobal(pixels.Length);
            try
            {
                frame.CopyPixels(new PixelRect(frame.PixelSize), buffer, pixels.Length, stride);
                Marshal.Copy(buffer, pixels, 0, pixels.Length);
            }
            finally
            {
                Marshal.FreeHGlobal(buffer);
            }
            // The background dots are muted; bright cyan marks the actual nodes and arrowed edges.
            foreach (var half in new[] { 0, frame.PixelSize.Width / 2 })
            {
                var colored = 0;
                for (var y = 110; y < frame.PixelSize.Height - 40; y++)
                {
                    for (var x = half; x < half + frame.PixelSize.Width / 2; x++)
                    {
                        var index = y * stride + x * 4;
                        if (
                            pixels[index + 1] > 130
                            && (
                                (pixels[index] > 170 && pixels[index + 2] < 160)
                                || (pixels[index + 2] > 170 && pixels[index] < 160)
                            )
                        )
                        {
                            colored++;
                        }
                    }
                }
                colored.ShouldBeGreaterThan(200);
            }
            if (Environment.GetEnvironmentVariable("CFA_UI_ARTIFACTS") is { } directory)
            {
                Directory.CreateDirectory(directory);
                frame.Save(Path.Combine(directory, "call-graph.png"));
            }
        }
        finally
        {
            window.Close();
        }
    }

    private sealed class FakeDesktop : IDesktopServices
    {
        public string? Error { get; private set; }
        public string? OpenedLink { get; private set; }

        public Task<FileInfo?> PickSolutionAsync() => Task.FromResult<FileInfo?>(new FileInfo("/repo/Example.slnx"));

        public Task ShowErrorAsync(string message)
        {
            Error = message;
            return Task.CompletedTask;
        }

        public Task OpenSourceAsync(string link)
        {
            OpenedLink = link;
            return Task.CompletedTask;
        }
    }

    private sealed class FakeFactory : IScanSessionFactory
    {
        public int Opens { get; private set; }
        public TaskCompletionSource<IScanSession>? Pending { get; set; }
        public FakeSession Session { get; } = new();

        public Task<IScanSession> OpenAsync(FileInfo solution, CancellationToken token)
        {
            Opens++;
            return Pending?.Task ?? Task.FromResult<IScanSession>(Session);
        }
    }

    private sealed class FakeSession : IScanSession
    {
        public int Analyses { get; private set; }
        public FileInfo? LastFile { get; private set; }
        public int LastLine { get; private set; }
        public TaskCompletionSource<CallGraphAnalysis>? Pending { get; set; }

        public Task<CallGraphAnalysis> AnalyzeAsync(FileInfo file, int line, CancellationToken token)
        {
            Analyses++;
            LastFile = file;
            LastLine = line;
            return Pending?.Task ?? Task.FromResult(TestGraph.Create());
        }

        public void Dispose() { }
    }
}