// 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.Collections.Generic; using System.Linq; using AvaloniaEdit.Folding; using AwesomeAssertions; using ICSharpCode.ILSpy.TextView; using NUnit.Framework; namespace ICSharpCode.ILSpy.Tests; /// /// Tests for the foldings-persistence helper that backs Back/Forward navigation's /// "remember which regions the user had expanded" behaviour. The math mirrors WPF's /// DecompilerTextViewState.SaveFoldingsState / RestoreFoldings so that /// the protective "skip on layout mismatch" semantics carry over identically. /// [TestFixture] public class FoldingsViewStateTests { [Test] public void Capture_Records_Offsets_Of_Expanded_Foldings_Only() { // The saved subset is the list of foldings the user has open — folded foldings are // already at their default state and don't need preserving. Mirrors WPF's // `foldings.Where(f => !f.IsFolded)` filter at the heart of SaveFoldingsState. // Arrange — four foldings, two folded and two expanded. Offsets chosen to disambiguate. var foldings = 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), }; // Act — capture the snapshot. var snapshot = FoldingsViewState.Capture(foldings); // Assert — only the expanded pair is recorded, in input order. snapshot.Expanded.Should().Equal(new[] { (10, 50), (110, 200) }); } [Test] public void Capture_Checksum_Is_The_Same_For_Layouts_With_Identical_Offsets() { // The checksum protects restoration against running on a different document. Two // foldings lists with the same `(Start, End)` pairs in the same order must produce // equal checksums regardless of which folds are open vs closed. // Arrange — same offsets, different IsFolded states. var a = new[] { (10, 50, false), (60, 100, true), (110, 200, false) }; var b = new[] { (10, 50, true), (60, 100, false), (110, 200, true) }; // Act — capture both. var snapA = FoldingsViewState.Capture(a); var snapB = FoldingsViewState.Capture(b); // Assert — checksums match (layout is identical even though state isn't). snapA.Checksum.Should().Be(snapB.Checksum); } [Test] public void Capture_Checksum_Differs_When_Any_Offset_Shifts() { // A single offset move yields a different checksum — that's the cue that the new // document doesn't match the saved state, and restoration must skip. Otherwise // "expanded at (10, 50)" might bogusly re-expand "(10, 51)" in the new document. // Arrange — identical layouts except one folding shifted by one byte. var original = new[] { (10, 50, false), (60, 100, false) }; var shifted = new[] { (10, 51, false), (60, 100, false) }; // Act — capture both. var checksumOriginal = FoldingsViewState.Capture(original).Checksum; var checksumShifted = FoldingsViewState.Capture(shifted).Checksum; // Assert — checksums differ. checksumShifted.Should().NotBe(checksumOriginal); } [Test] public void Restore_Reopens_Saved_Expanded_Foldings_When_Checksum_Matches() { // On Back navigation: the freshly-built foldings list comes back default-open // (DefaultClosed=false on every NewFolding). Restore must close every folding that // wasn't in the saved expanded set, and leave the saved-expanded ones open. This is // the inverse of how WPF restores — we don't track "what was closed", only "what was // open" — so anything not on the open list defaults to closed. // Arrange — three new foldings (all start at DefaultClosed=false), with a saved // snapshot saying only the middle one was expanded. var newFoldings = new List { new(10, 50), new(60, 100), new(110, 200), }; var saved = FoldingsViewState.Capture(new[] { (10, 50, true), // was folded (60, 100, false), // was expanded (110, 200, true), // was folded }); // Act — restore against the matching new-foldings layout. var restored = FoldingsViewState.Restore(newFoldings, saved); // Assert — restoration ran (true return), and the per-folding DefaultClosed reflects // the saved state: open in the middle, closed at the ends. restored.Should().BeTrue(); newFoldings[0].DefaultClosed.Should().BeTrue("(10, 50) was not in the saved expanded set"); newFoldings[1].DefaultClosed.Should().BeFalse("(60, 100) was saved as expanded"); newFoldings[2].DefaultClosed.Should().BeTrue("(110, 200) was not in the saved expanded set"); } [Test] public void Restore_Returns_False_And_Touches_Nothing_When_Checksum_Mismatch() { // The new document's foldings layout doesn't match what was saved — restoration // must bail out without touching `DefaultClosed`. A partial restore over the wrong // layout would expand random regions of the new document. // Arrange — new foldings with shifted offsets vs. the captured snapshot's source. var newFoldings = new List { new(15, 50) { DefaultClosed = true }, // shifted start new(60, 100) { DefaultClosed = true }, }; var saved = FoldingsViewState.Capture(new[] { (10, 50, false), // different start (60, 100, false), }); // Act — try to restore against the mismatched layout. var restored = FoldingsViewState.Restore(newFoldings, saved); // Assert — restoration declined; DefaultClosed values are exactly as the caller set // them (both true). A regression that "best-effort restored anyway" would flip one // of them to false here. restored.Should().BeFalse(); newFoldings[0].DefaultClosed.Should().BeTrue(); newFoldings[1].DefaultClosed.Should().BeTrue(); } [Test] public void Restore_Treats_Empty_New_And_Saved_As_A_Matching_NoOp() { // Edge case: both lists empty (e.g., the new tab has no foldings at all). The // checksum is zero on both sides, so restore "matches" — but with nothing to do. // It must return true so the caller doesn't log a spurious "layout changed" warning. // Arrange — empty new foldings, empty saved snapshot. var newFoldings = new List(); var saved = FoldingsViewState.Capture(System.Array.Empty<(int, int, bool)>()); // Act + Assert — restoration returns true; new list stays empty. FoldingsViewState.Restore(newFoldings, saved).Should().BeTrue(); newFoldings.Should().BeEmpty(); } [Test] public void Captured_Expanded_List_Is_A_Snapshot_Not_A_Live_View() { // The Expanded list must not mutate if the source enumeration is reused after the // capture. Without this, a caller passing a List<> and then mutating it would see // their navigation history quietly change. // Arrange — capture into a snapshot, then mutate the source list. var source = new List<(int, int, bool)> { (10, 50, false), (60, 100, false), }; var snapshot = FoldingsViewState.Capture(source); source.Clear(); // Assert — the captured snapshot still has the original entries. snapshot.Expanded.Should().Equal(new[] { (10, 50), (60, 100) }); } }