// 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.Xml.Linq; using Avalonia; using Avalonia.Controls; using AwesomeAssertions; using ICSharpCode.ILSpy; using NUnit.Framework; namespace ICSharpCode.ILSpy.Tests; // SessionSettings persists window bounds + active list to in ILSpy.xml. // If serialization and deserialization don't round-trip, a user's saved window position / // active list silently resets on every launch — and the bug is invisible until they // actually restart with a non-default layout. Round-tripping is the contract. [TestFixture] public class SessionSettingsTests { [Test] public void Save_then_Load_round_trips_all_fields() { var original = new SessionSettings { ActiveAssemblyList = "my-list", WindowState = WindowState.Maximized, WindowPosition = new PixelPoint(200, 300), WindowSize = new Size(1024, 768), }; var xml = original.SaveToXml(); var loaded = new SessionSettings(); loaded.LoadFromXml(xml); loaded.ActiveAssemblyList.Should().Be("my-list"); loaded.WindowState.Should().Be(WindowState.Maximized); loaded.WindowPosition.Should().Be(new PixelPoint(200, 300)); loaded.WindowSize.Should().Be(new Size(1024, 768)); } [Test] public void Save_then_Load_round_trips_ActiveTreeViewPath() { var original = new SessionSettings { ActiveTreeViewPath = ["My.Assembly.dll", "MyNamespace.MyType", "M:MyMethod"], }; var xml = original.SaveToXml(); var loaded = new SessionSettings(); loaded.LoadFromXml(xml); loaded.ActiveTreeViewPath.Should().Equal("My.Assembly.dll", "MyNamespace.MyType", "M:MyMethod"); } // Legacy SessionSettings XML emitted by the WPF host (ILSpy 10.x) must still load. // WindowBounds was a CSV element value (Rect TypeConverter format "L,T,W,H"); the new // attribute form is forward-only. ActiveTreeViewPath node values were \xNNNN-hex-escaped // so non-alphanumeric chars survive XML round-trips, and the restored path must compare // equal to the live tree-node ToString()s — escaped strings will never match. [Test] public void Load_accepts_legacy_WindowBounds_csv_body() { var section = XElement.Parse(@" Normal 882.6666666666666,342,750,550 "); var loaded = new SessionSettings(); loaded.LoadFromXml(section); loaded.WindowPosition.Should().Be(new PixelPoint(882, 342)); loaded.WindowSize.Should().Be(new Size(750, 550)); } [Test] public void Load_unescapes_legacy_ActiveTreeViewPath_node_values() { // \x002E -> '.', \x003A -> ':', \x005C -> '\' (the escape the WPF SessionSettings.Escape // helper applied to every non-letter-or-digit char). var section = XElement.Parse(@" D\x003A\x005CProjects\x005CILSpy\x005CILSpy\x002Edll TomsToolbox\x002EWpf TomsToolbox\x002EWpf\x002EDelegateCommand "); var loaded = new SessionSettings(); loaded.LoadFromXml(section); loaded.ActiveTreeViewPath.Should().Equal( @"D:\Projects\ILSpy\ILSpy.dll", "TomsToolbox.Wpf", "TomsToolbox.Wpf.DelegateCommand"); } [Test] public void Load_accepts_full_legacy_SessionSettings_section() { // Top-level structure the WPF host wrote to ILSpy.xml. DockLayout is intentionally // not asserted — the AvalonDock schema doesn't translate to the Avalonia Dock host // and is rebuilt from the new layout descriptors. var section = XElement.Parse(@" 1 .NET 4 (WPF) D\x003A\x005CProjects\x005CILSpy\x005CILSpy\x002Edll TomsToolbox\x002EWpf Normal 882.6666666666666,342,750,550 TypeAndMember Light "); var loaded = new SessionSettings(); loaded.LoadFromXml(section); loaded.ActiveAssemblyList.Should().Be(".NET 4 (WPF)"); loaded.WindowState.Should().Be(WindowState.Normal); loaded.WindowPosition.Should().Be(new PixelPoint(882, 342)); loaded.WindowSize.Should().Be(new Size(750, 550)); loaded.Theme.Should().Be("Light"); loaded.ActiveTreeViewPath.Should().Equal( @"D:\Projects\ILSpy\ILSpy.dll", "TomsToolbox.Wpf"); loaded.LanguageSettings.Should().NotBeNull(); loaded.LanguageSettings.ShowApiLevel.Should().Be(ICSharpCode.ILSpyX.ApiVisibility.PublicAndInternal); } // Forward-compat with retired or future SessionSettings children: unknown elements at // load time must be silently ignored, and SaveToXml must not echo them back. The dock // layout below is the AvalonDock schema (WPF host); it's incompatible with the Avalonia // Dock host and would persist forever as dead state if we round-tripped it. [Test] public void Load_silently_ignores_unknown_children() { var section = XElement.Parse(@" my-list TypeAndMember foo.dll 123 "); var loaded = new SessionSettings(); var act = () => loaded.LoadFromXml(section); act.Should().NotThrow(); loaded.ActiveAssemblyList.Should().Be("my-list"); } [Test] public void Save_preserves_unknown_children_seen_at_load() { // Verbatim round-trip of children outside the known set: the AvalonDock layout // blob stays on disk (incompatible with the Avalonia Dock host, but writing zeros // over it would discard the WPF user's saved layout forever), and a future field // added in a newer build survives an older build's load+save cycle. var dockLayout = ""; var future = "123"; var loaded = new SessionSettings(); loaded.LoadFromXml(XElement.Parse($@" my-list {dockLayout} {future} ")); var saved = loaded.SaveToXml(); saved.Element("DockLayout").Should().NotBeNull(); saved.Element("DockLayout")!.ToString(SaveOptions.DisableFormatting).Should().Be(dockLayout); saved.Element("FutureFeatureFlag").Should().NotBeNull(); saved.Element("FutureFeatureFlag")!.ToString(SaveOptions.DisableFormatting).Should().Be(future); } [Test] public void Save_emits_legacy_WindowBounds_csv_body_and_escaped_nodes() { // The Avalonia build writes the file in the legacy shape so a) the diff against // a pre-existing ILSpy.xml stays small, and b) an older WPF ILSpy install can // still read the file (the Rect TypeConverter only understands the CSV form, and // its tree-node Unescape only understands \xNNNN-escaped values). var session = new SessionSettings { WindowPosition = new PixelPoint(882, 342), WindowSize = new Size(750, 550), ActiveTreeViewPath = ["TomsToolbox.Wpf", "DelegateCommand"], }; var saved = session.SaveToXml(); var bounds = saved.Element("WindowBounds"); bounds.Should().NotBeNull(); bounds!.HasAttributes.Should().BeFalse(); bounds.Value.Should().Be("882,342,750,550"); saved.Element("ActiveTreeViewPath")!.Elements("Node").Select(n => n.Value).Should().Equal( "TomsToolbox\\x002EWpf", "DelegateCommand"); } [Test] public void Save_then_Load_round_trips_SelectedSearchMode_and_ActiveAutoLoadedAssembly() { var original = new SessionSettings { SelectedSearchMode = "Type", ActiveAutoLoadedAssembly = @"C:\deps\foo.dll", }; var xml = original.SaveToXml(); var loaded = new SessionSettings(); loaded.LoadFromXml(xml); loaded.SelectedSearchMode.Should().Be("Type"); loaded.ActiveAutoLoadedAssembly.Should().Be(@"C:\deps\foo.dll"); } [Test] public void Load_picks_up_legacy_SelectedSearchMode_and_ActiveAutoLoadedAssembly() { var section = XElement.Parse(@" TypeAndMember C:\deps\foo.dll "); var loaded = new SessionSettings(); loaded.LoadFromXml(section); loaded.SelectedSearchMode.Should().Be("TypeAndMember"); loaded.ActiveAutoLoadedAssembly.Should().Be(@"C:\deps\foo.dll"); } }