OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell.Tests / UndoTests.cs

347 lines · csharp ref:
using System.Linq;

using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Interactivity;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;

using IFSRenderer.AvaloniaShell.Controls;

using Xunit;

namespace IFSRenderer.AvaloniaShell.Tests;

/// <summary>
/// "is there no undo? ctrl-z doesnt work"
/// </summary>
/// <remarks>
/// <para>
/// Undo itself was never broken — the history tracker and the menu command both worked. What was
/// missing were the SNAPSHOTS: an action that mutates the document without first recording the
/// previous state is invisible to undo, so Ctrl+Z either did nothing or skipped straight past it
/// to some older, unrelated state.
/// </para>
/// <para>
/// The canonical takes its snapshot inside <c>Workspace.LoadParams</c> (Workspace.cs:152-154), so
/// every document-replacing command inherits it. These tests pin that behaviour per command.
/// </para>
/// </remarks>
public class UndoTests
{
    private static (MainWindow Window, Fakes.FakeRenderQueue Queue) Ready()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = MainWindowHarness.Start();
        window.Measure(new Size(1500, 900));
        window.Arrange(new Rect(0, 0, 1500, 900));
        Dispatcher.UIThread.RunJobs();
        return (window, queue);
    }

    private static void Undo(MainWindow window, Fakes.FakeRenderQueue queue)
    {
        window.FindControl<MenuItem>("MenuUndo")
            .RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
        MainWindowHarness.Settle(queue);
    }

    private static void Menu(MainWindow window, string name, Fakes.FakeRenderQueue queue)
    {
        window.FindControl<MenuItem>(name).RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
        MainWindowHarness.Settle(queue);
    }

    /// <summary>Random replaces the document; undo must bring the old one back.</summary>
    [AvaloniaFact]
    public void Undo_restores_the_fractal_that_Random_replaced()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        string before = queue.Current.Title;

        Menu(window, "MenuRandom", queue);
        Assert.NotEqual(before, queue.Current.Title);

        Undo(window, queue);
        Assert.Equal(before, queue.Current.Title);
    }

    /// <summary>Same for New, which loads blank parameters.</summary>
    [AvaloniaFact]
    public void Undo_restores_the_fractal_that_New_replaced()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        int before = queue.Current.Iterators.Count;
        string title = queue.Current.Title;

        Menu(window, "MenuNew", queue);
        Undo(window, queue);

        Assert.Equal(before, queue.Current.Iterators.Count);
        Assert.Equal(title, queue.Current.Title);
    }

    /// <summary>
    /// The post-effect stack was entirely outside undo — add, remove, reorder and the per-layer
    /// enable toggle all mutated without recording.
    /// </summary>
    [AvaloniaFact]
    public void Undo_removes_a_post_effect_layer_that_was_added()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        var combo = window.FindControl<ComboBox>("PostFxAddCombo");
        Assert.True(combo.ItemCount > 0, "no post effects loaded");

        int before = queue.Current.PostEffects.Count;
        combo.SelectedIndex = 0;
        MainWindowHarness.Settle(queue);
        Assert.Equal(before + 1, queue.Current.PostEffects.Count);

        Undo(window, queue);
        Assert.Equal(before, queue.Current.PostEffects.Count);
    }

    /// <summary>Adding an iterator was already snapshotted; this guards the regression.</summary>
    [AvaloniaFact]
    public void Undo_removes_an_iterator_that_was_added()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        int before = queue.Current.Iterators.Count;

        MainWindowHarness.Click(window, "BtnAddIterator");
        MainWindowHarness.Settle(queue);
        Assert.Equal(before + 1, queue.Current.Iterators.Count);

        Undo(window, queue);
        Assert.Equal(before, queue.Current.Iterators.Count);
    }

    /// <summary>
    /// Loading must not record a state when there is nothing yet to record — otherwise the very
    /// first undo would restore a null document.
    /// </summary>
    [AvaloniaFact]
    public void The_initial_load_records_no_history()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        string title = queue.Current.Title;

        Undo(window, queue);

        Assert.Equal(title, queue.Current.Title);
        Assert.NotNull(queue.Current);
    }

    /// <summary>
    /// The undo/redo items must report availability, so "nothing to undo" is distinguishable
    /// from "undo is broken" — canonical greys them via CanExecute (IFSViewModel.cs:403-409).
    /// </summary>
    [AvaloniaFact]
    public void Undo_and_redo_are_disabled_until_there_is_history()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        var undo = window.FindControl<MenuItem>("MenuUndo");
        var redo = window.FindControl<MenuItem>("MenuRedo");

        Assert.False(undo.IsEnabled, "a freshly loaded document has nothing to undo");
        Assert.False(redo.IsEnabled, "nothing has been undone yet");

        MainWindowHarness.Click(window, "BtnAddIterator");
        MainWindowHarness.Settle(queue);
        Assert.True(undo.IsEnabled, "adding an iterator should be undoable");

        Undo(window, queue);
        Assert.True(redo.IsEnabled, "an undone action should be redoable");
    }

    /// <summary>
    /// Redo must actually put the change back, not merely light up the menu item.
    /// </summary>
    [AvaloniaFact]
    public void Redo_reapplies_an_undone_change()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        int before = queue.Current.Iterators.Count;

        MainWindowHarness.Click(window, "BtnAddIterator");
        MainWindowHarness.Settle(queue);
        int added = queue.Current.Iterators.Count;
        Assert.Equal(before + 1, added);

        Undo(window, queue);
        Assert.Equal(before, queue.Current.Iterators.Count);

        window.FindControl<MenuItem>("MenuRedo")
            .RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
        MainWindowHarness.Settle(queue);

        Assert.Equal(added, queue.Current.Iterators.Count);
    }

    /// <summary>Undo/redo must survive being walked back and forth more than once.</summary>
    [AvaloniaFact]
    public void Undo_and_redo_round_trip_repeatedly()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        int before = queue.Current.Iterators.Count;

        MainWindowHarness.Click(window, "BtnAddIterator");
        MainWindowHarness.Settle(queue);
        MainWindowHarness.Click(window, "BtnAddIterator");
        MainWindowHarness.Settle(queue);
        Assert.Equal(before + 2, queue.Current.Iterators.Count);

        Undo(window, queue);
        Undo(window, queue);
        Assert.Equal(before, queue.Current.Iterators.Count);

        var redo = window.FindControl<MenuItem>("MenuRedo");
        redo.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
        MainWindowHarness.Settle(queue);
        redo.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
        MainWindowHarness.Settle(queue);

        Assert.Equal(before + 2, queue.Current.Iterators.Count);
    }

    /// <summary>
    /// Redo must survive an idle click in the viewport.
    /// </summary>
    /// <remarks>
    /// IFSHistoryTracker.TakeSnapshot clears the REDO stack, so anything that snapshots between
    /// an undo and a redo destroys it. The camera path used to snapshot on pointer PRESS, which
    /// meant a single click anywhere in the render silently killed redo - invisible to a test
    /// that only clicks menus, and the reason this shipped. The snapshot now happens on the
    /// first movement of a drag instead.
    /// </remarks>
    [AvaloniaFact]
    public void Redo_survives_a_click_in_the_viewport()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        int before = queue.Current.Iterators.Count;

        MainWindowHarness.Click(window, "BtnAddIterator");
        MainWindowHarness.Settle(queue);
        int added = queue.Current.Iterators.Count;

        Undo(window, queue);
        Assert.Equal(before, queue.Current.Iterators.Count);

        // A press and release with no movement — the user glancing at the render.
        var viewport = window.FindControl<Grid>("ViewportHost");
        Assert.NotNull(viewport);
        Point at = viewport.TranslatePoint(new Point(40, 40), window) ?? default;
        window.MouseDown(at, MouseButton.Left);
        window.MouseUp(at, MouseButton.Left);
        MainWindowHarness.Settle(queue);

        var redo = window.FindControl<MenuItem>("MenuRedo");
        Assert.True(redo.IsEnabled, "an idle viewport click destroyed the redo history");

        redo.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
        MainWindowHarness.Settle(queue);
        Assert.Equal(added, queue.Current.Iterators.Count);
    }

    /// <summary>Ctrl+Y redoes, not just the menu item.</summary>
    [AvaloniaFact]
    public void Ctrl_Y_redoes()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        int before = queue.Current.Iterators.Count;

        MainWindowHarness.Click(window, "BtnAddIterator");
        MainWindowHarness.Settle(queue);
        int added = queue.Current.Iterators.Count;

        window.KeyPressQwerty(PhysicalKey.Z, RawInputModifiers.Control);
        MainWindowHarness.Settle(queue);
        Assert.Equal(before, queue.Current.Iterators.Count);

        window.KeyPressQwerty(PhysicalKey.Y, RawInputModifiers.Control);
        MainWindowHarness.Settle(queue);
        Assert.Equal(added, queue.Current.Iterators.Count);
    }

    /// <summary>
    /// The palette bar shows the SELECTED iterator's colour index, so undo must move it back —
    /// PushStateToUi runs before the restored iterators replace the selection.
    /// </summary>
    [AvaloniaFact]
    public void Undo_moves_the_palette_bar_back()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        var bar = window.FindControl<PaletteSlider>("PaletteBar");
        var index = window.FindControl<ValueSlider>("ItColorIndex");
        Assert.NotNull(bar);
        Assert.NotNull(index);

        double before = bar.ColorIndex;

        // Move the selected iterator's colour index well away from where it started.
        for (int i = 0; i < 20; i++)
            MainWindowHarness.Click(index, "UpButton");
        MainWindowHarness.Settle(queue);
        Assert.NotEqual(before, bar.ColorIndex);

        Undo(window, queue);
        Assert.Equal(before, bar.ColorIndex, 4);
    }

    /// <summary>
    /// Undo must move the CONTROLS back, not just the fractal — a reverted value the panel still
    /// displays at its old number is indistinguishable from undo not working.
    /// </summary>
    [AvaloniaTheory]
    [InlineData("BrightnessSlider")]
    [InlineData("GammaSlider")]
    [InlineData("VibrancySlider")]
    [InlineData("FogSlider")]
    [InlineData("QualitySlider")]
    [InlineData("FovSlider")]
    [InlineData("ItWeight")]
    [InlineData("ItColorSpeed")]
    public void Undo_reverts_the_control_not_just_the_fractal(string name)
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        var slider = window.FindControl<ValueSlider>(name);
        Assert.NotNull(slider);

        double before = slider.Value;
        MainWindowHarness.Click(slider, "UpButton");
        MainWindowHarness.Settle(queue);
        Assert.NotEqual(before, slider.Value);

        Undo(window, queue);

        Assert.Equal(before, slider.Value, 6);
    }

    /// <summary>
    /// Undo must leave the same iterator selected. Snapping to the first one makes the whole
    /// SELECTED ITERATOR panel show different values, which reads as "undo did not revert".
    /// </summary>
    [AvaloniaFact]
    public void Undo_keeps_the_same_iterator_selected()
    {
        (MainWindow window, Fakes.FakeRenderQueue queue) = Ready();
        var list = window.FindControl<ListBox>("IteratorList");

        MainWindowHarness.Click(window, "BtnAddIterator");
        MainWindowHarness.Settle(queue);
        Assert.True(list.ItemCount >= 3, "need a few iterators to select among");

        list.SelectedIndex = 2;
        MainWindowHarness.Settle(queue);

        var opacity = window.FindControl<ValueSlider>("ItOpacity");
        MainWindowHarness.Click(opacity, "DownButton");
        MainWindowHarness.Settle(queue);

        Undo(window, queue);

        Assert.Equal(2, list.SelectedIndex);
    }
}