// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team // // Permission is hereby granted, free of charge, to any person obtaining a copy of this // software and associated documentation files (the "Software"), to deal in the Software // without restriction, including without limitation the rights to use, copy, modify, merge, // publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons // to whom the Software is furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all copies or // substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR // PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE // FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR // OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. using System.Linq; using System.Threading.Tasks; using Avalonia.Headless.NUnit; using AwesomeAssertions; using ILSpy.Navigation; using ILSpy.TextView; using ILSpy.TreeNodes; using NUnit.Framework; using FoldingSnapshot = ILSpy.TextView.FoldingsViewState.Snapshot; namespace ICSharpCode.ILSpy.Tests.Navigation; /// /// End-to-end: select node A, move the caret, select node B, navigate Back. The caret /// must land where the user left it on A — that's the contract for Back/Forward to /// feel like a real browser instead of a "reset to top" gesture. /// [TestFixture] public class ViewStateRoundTripTests { [AvaloniaTest] public async Task Back_Restores_Caret_Position_The_User_Left_On_The_Previous_Node() { var (_, vm) = await TestHarness.BootAsync(); var dockWorkspace = vm.DockWorkspace; // Pick two distinct decompiler targets so the navigation actually moves between // them. CoreLib's System.Object and System.String are both always present. var coreLibName = typeof(object).Assembly.GetName().Name!; var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); vm.AssemblyTreeModel.SelectNode(objectNode); await dockWorkspace.WaitForDecompiledTextAsync(); var tab = dockWorkspace.ActiveDecompilerTab!; TestCapture.Step("object-decompiled"); // State is pulled from the editor on demand (CaptureViewState) when DockWorkspace records // a navigation away -- not pushed per caret/scroll event. Headless has no laid-out editor, // so override the pull delegate to report the position the user "left" on node A. tab.CaptureViewState = () => new DecompilerTextViewState(500, 120.5, 7.25, null); // Navigate to a different node — DockWorkspace pulls A's state into the current history // entry and records B as the new current. vm.AssemblyTreeModel.SelectNode(stringNode); await dockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("string-decompiled"); // Verify the capture: the back stack's most recent entry should be A's, with the pulled // caret + scroll values. var backEntries = dockWorkspace.BackHistory.OfType().ToList(); backEntries.Should().NotBeEmpty("Select(B) must push A onto the back stack"); var captured = backEntries.Last(); ReferenceEquals(captured.Node, objectNode).Should().BeTrue( "captured back-stack entry must reference node A"); captured.CaretOffset.Should().Be(500); captured.VerticalOffset.Should().Be(120.5); captured.HorizontalOffset.Should().Be(7.25); // Navigate Back. ApplyNavigationTarget synchronously stashes the recorded state on the tab // as PendingViewState before the async decompile runs; the editor consumes it in // ApplyDocument once Text lands. Assert the restore intent here, before the await lets the // view consume it -- applying caret/scroll to the live editor is the view's job (covered by // the real UI; headless has no rendered viewport). dockWorkspace.NavigateBackCommand.Execute(null); TestCapture.Step("navigated-back"); tab.PendingViewState.Should().NotBeNull("Back must stash the recorded view state for the editor to apply"); tab.PendingViewState!.Value.CaretOffset.Should().Be(500, "Back must restore the caret to where the user left it before navigating to B"); tab.PendingViewState!.Value.VerticalOffset.Should().Be(120.5, "Back must restore the vertical scroll offset the user left on A"); await dockWorkspace.WaitForDecompiledTextAsync(); // The editor consumes and clears PendingViewState once the document lands. tab.PendingViewState.Should().BeNull("the editor must consume the pending state after applying it"); } [AvaloniaTest] public async Task Back_Carries_Expanded_Foldings_Snapshot_From_Capture_Through_To_Pending() { // Pins the foldings half of the view-state round trip end-to-end through DockWorkspace. // The headless editor doesn't build real foldings, so override the pull delegate to report // a deterministic snapshot, exercise the navigation pipeline, and assert the snapshot // survives the capture -> entry -> pending hops. var (_, vm) = await TestHarness.BootAsync(); var dockWorkspace = vm.DockWorkspace; var coreLibName = typeof(object).Assembly.GetName().Name!; var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); vm.AssemblyTreeModel.SelectNode(objectNode); await dockWorkspace.WaitForDecompiledTextAsync(); var tab = dockWorkspace.ActiveDecompilerTab!; TestCapture.Step("object-decompiled"); // Deterministic foldings snapshot — two expanded regions over a four-folding layout. // Compute via the helper to keep the checksum honest. (The snapshot itself is exercised // independently in FoldingsViewStateTests.) var seeded = FoldingsViewState.Capture(new[] { (Start: 10, End: 50, IsFolded: false), (Start: 60, End: 100, IsFolded: true), (Start: 110, End: 200, IsFolded: false), (Start: 210, End: 250, IsFolded: true), }); tab.CaptureViewState = () => new DecompilerTextViewState(0, 0, 0, seeded); // Navigate to a different node — DockWorkspace pulls the state and stamps the foldings // onto the OUTGOING entry on the back stack. vm.AssemblyTreeModel.SelectNode(stringNode); await dockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("string-decompiled"); // Verify the capture: the back-stack entry for node A carries the snapshot. var captured = dockWorkspace.BackHistory.OfType().Last(); ReferenceEquals(captured.Node, objectNode).Should().BeTrue( "captured back-stack entry must reference node A"); captured.Foldings.Should().NotBeNull("Select(B) must record A's foldings into the back stack"); captured.Foldings!.Value.Checksum.Should().Be(seeded.Checksum); captured.Foldings.Value.Expanded.Should().Equal(seeded.Expanded); // Navigate Back. ApplyNavigationTarget stashes the recorded snapshot into PendingViewState // for the view to consume on the next ApplyDocument. The assignment is synchronous, so we // can observe it before the await lets the editor consume it. dockWorkspace.NavigateBackCommand.Execute(null); TestCapture.Step("navigated-back"); tab.PendingViewState.Should().NotBeNull("Back must propagate the captured state to the destination tab"); tab.PendingViewState!.Value.Foldings.Should().NotBeNull(); tab.PendingViewState!.Value.Foldings!.Value.Checksum.Should().Be(seeded.Checksum); } [AvaloniaTest] public async Task Forward_Is_Wired_Through_The_Toolbar_And_Key_Binding() { // Pins the existing Forward command wiring: the tracker had listed BrowseForward // as missing, but it was already implemented. This test makes a regression in any // of the three wirings (command, toolbar button, Alt+Right) detectable. var (_, vm) = await TestHarness.BootAsync(); ((object?)vm.DockWorkspace.NavigateForwardCommand).Should().NotBeNull(); ((object?)vm.DockWorkspace.NavigateBackCommand).Should().NotBeNull(); // Build up history: A → B, then go Back so Forward is enabled. var coreLibName = typeof(object).Assembly.GetName().Name!; var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); vm.AssemblyTreeModel.SelectNode(objectNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); vm.AssemblyTreeModel.SelectNode(stringNode); await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("string-decompiled"); vm.DockWorkspace.NavigateBackCommand.Execute(null); await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("navigated-back"); vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue( "Forward must be enabled after Back leaves an entry on the forward stack"); vm.DockWorkspace.NavigateForwardCommand.Execute(null); await vm.DockWorkspace.WaitForDecompiledTextAsync(); TestCapture.Step("navigated-forward"); // Back through Forward should land on B again. ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, stringNode).Should().BeTrue( "NavigateForward must restore the tree selection to the entry that was just popped from forward"); } }