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);
}
}