// Copyright (c) 2026 Siegfried Pammer
//
// 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;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using Avalonia.VisualTree;
using AwesomeAssertions;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
using ICSharpCode.ILSpy.ViewModels;
using ICSharpCode.ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.TextView;
///
/// Sample type decompiled by . Its XML documentation is
/// supplied by a hand-written documentation file placed next to the test assembly, so the
/// decompiled view renders a '///' fold above .
///
public class FoldGroupSample
{
public void Documented(int value)
{
Console.WriteLine(value);
}
public void Plain()
{
Console.WriteLine();
}
}
///
/// Pins the grouped fold-toggle behavior (issue #749): a member and its XML documentation
/// comment toggle as one logical unit, the toggle targets the member (not the enclosing
/// type) when invoked on the header line, the doc fold alone toggles when invoked inside
/// it, and Toggle All uses VS parity (mixed state expands everything).
///
[TestFixture]
public class FoldingGroupTests
{
[OneTimeSetUp]
public void WriteDocumentationFile()
{
string assemblyPath = typeof(FoldGroupSample).Assembly.Location;
string xmlPath = Path.ChangeExtension(assemblyPath, ".xml");
string assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
File.WriteAllText(xmlPath, $"""
{assemblyName}
Summary line used by FoldingGroupTests.
Parameter line used by FoldingGroupTests.
""");
}
static async Task<(DecompilerTextView View, string Text)> SetupAsync()
{
var (window, vm) = await TestHarness.BootAsync();
// Start from a fully expanded document so every toggle direction is deterministic.
var displaySettings = AppComposition.Current.GetExport().DisplaySettings;
displaySettings.ExpandMemberDefinitions = true;
displaySettings.ExpandXmlDocumentationComments = true;
await vm.OpenAssemblyAsync(typeof(FoldGroupSample).Assembly.Location);
var typeNode = vm.AssemblyTreeModel.FindNode(
"ILSpy.Tests",
"ICSharpCode.ILSpy.Tests.TextView",
"ICSharpCode.ILSpy.Tests.TextView.FoldGroupSample");
vm.AssemblyTreeModel.SelectNode(typeNode);
var tab = await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = window.GetVisualDescendants().OfType().First();
string text = view.Editor.Document.Text;
text.Should().Contain("Summary line used by FoldingGroupTests",
"the hand-written documentation file must be picked up for these tests to be meaningful");
return (view, text);
}
static int OffsetOf(string text, string needle)
{
int offset = text.IndexOf(needle, StringComparison.Ordinal);
offset.Should().BeGreaterThan(-1, $"the decompiled text must contain '{needle}'");
return offset;
}
[AvaloniaTest]
public async Task Toggling_In_The_Body_Folds_Member_And_Documentation_Together()
{
var (view, text) = await SetupAsync();
int docOffset = OffsetOf(text, "Summary line");
int bodyOffset = OffsetOf(text, "Console.WriteLine(value)");
int plainBodyOffset = OffsetOf(text, "Console.WriteLine()");
view.ToggleFoldingAt(bodyOffset);
view.IsFoldedAt(bodyOffset).Should().BeTrue("the member body must fold");
view.IsFoldedAt(docOffset).Should().BeTrue("the documentation comment must fold together with its member");
view.IsFoldedAt(plainBodyOffset).Should().BeFalse("the sibling member is not part of the group");
view.ToggleFoldingAt(bodyOffset);
view.IsFoldedAt(bodyOffset).Should().BeFalse("toggling again must unfold the member body");
view.IsFoldedAt(docOffset).Should().BeFalse("toggling again must unfold the documentation comment");
}
[AvaloniaTest]
public async Task Toggling_On_The_Header_Line_Targets_The_Member_Not_The_Type()
{
var (view, text) = await SetupAsync();
int headerOffset = OffsetOf(text, "void Documented");
int bodyOffset = OffsetOf(text, "Console.WriteLine(value)");
int plainBodyOffset = OffsetOf(text, "Console.WriteLine()");
view.ToggleFoldingAt(headerOffset);
view.IsFoldedAt(bodyOffset).Should().BeTrue("the member body must fold from its header line");
view.IsFoldedAt(plainBodyOffset).Should().BeFalse(
"the enclosing type must not fold when the toggle targets a member header");
}
[AvaloniaTest]
public async Task Toggling_Inside_The_Documentation_Folds_Only_The_Documentation()
{
var (view, text) = await SetupAsync();
int docOffset = OffsetOf(text, "Summary line");
int bodyOffset = OffsetOf(text, "Console.WriteLine(value)");
view.ToggleFoldingAt(docOffset);
view.IsFoldedAt(docOffset).Should().BeTrue("the documentation fold must collapse");
view.IsFoldedAt(bodyOffset).Should().BeFalse("the member body stays open when only the docs are toggled");
}
[AvaloniaTest]
public async Task Toggle_All_Expands_Everything_From_A_Mixed_State()
{
var (view, text) = await SetupAsync();
int bodyOffset = OffsetOf(text, "Console.WriteLine(value)");
// Create a mixed state: one group folded, the rest open.
view.ToggleFoldingAt(bodyOffset);
view.FoldedFoldingCount.Should().BeGreaterThan(0);
view.GetFoldingsForTest().Should().Contain(f => !f.IsFolded, "the state must be mixed for this test");
view.ToggleAllFoldings();
view.FoldedFoldingCount.Should().Be(0, "VS parity: a mixed state expands all folds");
view.ToggleAllFoldings();
view.GetFoldingsForTest().Should().OnlyContain(f => f.IsFolded,
"a uniformly expanded document collapses everything");
}
}