From 185774e159f93168f96a34ba99fccd30a3c76d6d Mon Sep 17 00:00:00 2001 From: kornman00 Date: Sat, 3 Oct 2026 02:26:02 -0700 Subject: [PATCH 1/2] Retarget supported Framework lane to .NET Framework 4.8 Replace the net462 build, test, package, and external consumer lane with net48 while retaining net10. Update the support contract and migration guidance without changing dependency versions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- README.md | 5 ++-- doc/caliburn-5-migration.md | 25 +++++++++++++------ .../Caliburn5PackageHost.csproj | 2 +- eng/package-contract.json | 8 +++--- src/Gemini.Demo/Gemini.Demo.csproj | 6 ++--- .../Gemini.Modules.CodeCompiler.csproj | 2 +- .../Gemini.Modules.CodeEditor.csproj | 2 +- .../Gemini.Modules.ErrorList.csproj | 2 +- .../Gemini.Modules.GraphEditor.csproj | 2 +- .../Gemini.Modules.Inspector.csproj | 2 +- .../Gemini.Modules.Output.csproj | 2 +- .../Gemini.Modules.PropertyGrid.csproj | 2 +- src/Gemini.Tests/Gemini.Tests.csproj | 2 +- src/Gemini/Gemini.csproj | 4 +-- 15 files changed, 39 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69cf447f..b5c8b8d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: - name: Test characterization harness run: | - dotnet test --project src\Gemini.Tests\Gemini.Tests.csproj --configuration Release --framework net462 --no-build --no-restore + dotnet test --project src\Gemini.Tests\Gemini.Tests.csproj --configuration Release --framework net48 --no-build --no-restore dotnet test --project src\Gemini.Tests\Gemini.Tests.csproj --configuration Release --framework net10.0-windows --no-build --no-restore - name: Pack libraries diff --git a/README.md b/README.md index 011f4df5..c690c7f0 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ on other modules, but this is taken care of by the NuGet package dependency syst ## Continuous builds -GitHub Actions builds every push and pull request for the `net462` and +GitHub Actions builds every push and pull request for the `net48` and `net10.0-windows` targets. Successful runs provide the validated NuGet and symbol packages as a downloadable workflow artifact. The modern build uses Caliburn.Micro 5.0.258 and MahApps.Metro 2.4.11; net6/net7 remain available @@ -69,7 +69,8 @@ from the legacy package line. Publication remains restricted to approved upstream releases. See [the CI workflow](.github/workflows/ci.yml) for the exact build and release contract. -Applications and extensions moving from the previous package line should read +Applications and extensions moving from the previous package line or the +earlier .NET Framework target should read the [Caliburn Micro 5 migration notes](doc/caliburn-5-migration.md). ## What does it do? diff --git a/doc/caliburn-5-migration.md b/doc/caliburn-5-migration.md index 49204c3d..4e6d0cbe 100644 --- a/doc/caliburn-5-migration.md +++ b/doc/caliburn-5-migration.md @@ -1,8 +1,8 @@ # Caliburn Micro 5 migration -Gemini's Caliburn Micro 5 package line targets `net462` and -`net10.0-windows`. Applications that require the previous net6/net7 assets -must remain on the earlier Gemini package line. +Gemini's Caliburn Micro 5 package line targets `net48` and +`net10.0-windows`. Applications that require the previous `net462`, net6, or +net7 assets must remain on an earlier Gemini package line. Caliburn.Micro 5.0.258 does not publish a native net6 or net7 WPF Platform asset group. NuGet can select its net462 Platform fallback for those targets, @@ -10,6 +10,13 @@ but reports `NU1701`; the Core package's netstandard2.0 asset does not replace the WPF Platform assembly. Gemini therefore does not publish new supported net6/net7 targets on that warning-bearing fallback. +The `net48` Gemini asset uses Caliburn.Micro's compatible .NET Framework +Platform asset. Building that lane requires the .NET Framework 4.8 targeting +pack, and running it requires .NET Framework 4.8. Retarget and rebuild +Framework applications and extensions before adopting this package line; +retaining an older runtime while loading the new Gemini assembly is not a +supported migration path. + ## Package and extension compatibility The package family uses Caliburn.Micro 5.0.258. Extensions should reference the @@ -29,8 +36,9 @@ viewModel.Activated += async (sender, args) => ``` An extension compiled against the Caliburn 4 event accessor is not considered -binary compatible merely because Gemini still provides a `net462` asset. -Rebuild and test extensions against the new package before deployment. +binary compatible merely because Gemini still provides a .NET Framework +asset. Rebuild and test extensions against the new `net48` package before +deployment. Caliburn 5 also replaces the obsolete `OnInitializeAsync` extension point with `OnInitializedAsync`. The new hook runs after `IsInitialized` becomes `true`. @@ -160,6 +168,7 @@ being converted into fallback instances. ## Release policy -This package family starts the Gemini 1.1 beta line. Removing net6/net7 and -changing an inherited public lifecycle event are breaking package-contract -changes. The previous 1.0 beta four-target family remains the rollback path. +This package family starts the Gemini 1.1 beta line. Raising the Framework +floor to .NET Framework 4.8, removing net6/net7, and changing an inherited +public lifecycle event are breaking package-contract changes. The previous +1.0 beta four-target family remains the rollback path. diff --git a/eng/compatibility/Caliburn5PackageHost/Caliburn5PackageHost.csproj b/eng/compatibility/Caliburn5PackageHost/Caliburn5PackageHost.csproj index d689fe8e..0dec6495 100644 --- a/eng/compatibility/Caliburn5PackageHost/Caliburn5PackageHost.csproj +++ b/eng/compatibility/Caliburn5PackageHost/Caliburn5PackageHost.csproj @@ -2,7 +2,7 @@ Exe - net462;net10.0-windows + net48;net10.0-windows true false 0.0.0-local diff --git a/eng/package-contract.json b/eng/package-contract.json index 5da2395c..6dbdf50c 100644 --- a/eng/package-contract.json +++ b/eng/package-contract.json @@ -1,10 +1,10 @@ { "frameworks": [ { - "nuspec": ".NETFramework4.6.2", - "asset": "net462", - "build": "net462", - "lockFileTarget": "net462" + "nuspec": ".NETFramework4.8", + "asset": "net48", + "build": "net48", + "lockFileTarget": "net48" }, { "nuspec": "net10.0-windows7.0", diff --git a/src/Gemini.Demo/Gemini.Demo.csproj b/src/Gemini.Demo/Gemini.Demo.csproj index 6828630c..73c08656 100644 --- a/src/Gemini.Demo/Gemini.Demo.csproj +++ b/src/Gemini.Demo/Gemini.Demo.csproj @@ -2,7 +2,7 @@ WinExe - net462;net10.0-windows + net48;net10.0-windows true true false @@ -64,11 +64,11 @@ - + - + diff --git a/src/Gemini.Modules.CodeCompiler/Gemini.Modules.CodeCompiler.csproj b/src/Gemini.Modules.CodeCompiler/Gemini.Modules.CodeCompiler.csproj index aa4bdfdf..53480bd4 100644 --- a/src/Gemini.Modules.CodeCompiler/Gemini.Modules.CodeCompiler.csproj +++ b/src/Gemini.Modules.CodeCompiler/Gemini.Modules.CodeCompiler.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows core WPF MVVM AvalonDock Caliburn Micro Visual Studio IDE Shell C# compile Roslyn CodeCompiler module for Gemini, providing C# code compilation using Roslyn. diff --git a/src/Gemini.Modules.CodeEditor/Gemini.Modules.CodeEditor.csproj b/src/Gemini.Modules.CodeEditor/Gemini.Modules.CodeEditor.csproj index 406dd667..43f92101 100644 --- a/src/Gemini.Modules.CodeEditor/Gemini.Modules.CodeEditor.csproj +++ b/src/Gemini.Modules.CodeEditor/Gemini.Modules.CodeEditor.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows true core WPF MVVM AvalonDock Caliburn Micro Visual Studio IDE Shell C# syntax highlighting AvalonEdit CodeEditor module for Gemini, providing a syntax-highlighted code editor using AvalonEdit. diff --git a/src/Gemini.Modules.ErrorList/Gemini.Modules.ErrorList.csproj b/src/Gemini.Modules.ErrorList/Gemini.Modules.ErrorList.csproj index be5ee76d..751a3243 100644 --- a/src/Gemini.Modules.ErrorList/Gemini.Modules.ErrorList.csproj +++ b/src/Gemini.Modules.ErrorList/Gemini.Modules.ErrorList.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows true core WPF MVVM AvalonDock Caliburn Micro Visual Studio IDE Shell error list ErrorList module for Gemini, providing a tool window to display messages, warnings and errors. diff --git a/src/Gemini.Modules.GraphEditor/Gemini.Modules.GraphEditor.csproj b/src/Gemini.Modules.GraphEditor/Gemini.Modules.GraphEditor.csproj index 34fbc5de..069970d7 100644 --- a/src/Gemini.Modules.GraphEditor/Gemini.Modules.GraphEditor.csproj +++ b/src/Gemini.Modules.GraphEditor/Gemini.Modules.GraphEditor.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows true core WPF MVVM AvalonDock Caliburn Micro Visual Studio IDE Shell graph node connector connection GraphEditor module for Gemini, providing UI controls to edit a graph of connected nodes. diff --git a/src/Gemini.Modules.Inspector/Gemini.Modules.Inspector.csproj b/src/Gemini.Modules.Inspector/Gemini.Modules.Inspector.csproj index f4c3f42e..597a3b26 100644 --- a/src/Gemini.Modules.Inspector/Gemini.Modules.Inspector.csproj +++ b/src/Gemini.Modules.Inspector/Gemini.Modules.Inspector.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows true core WPF MVVM AvalonDock Caliburn Micro Visual Studio IDE Shell inspector Inspector module for Gemini, providing a flexible PropertyGrid-esque tool window. diff --git a/src/Gemini.Modules.Output/Gemini.Modules.Output.csproj b/src/Gemini.Modules.Output/Gemini.Modules.Output.csproj index f7fc82a3..cd7be691 100644 --- a/src/Gemini.Modules.Output/Gemini.Modules.Output.csproj +++ b/src/Gemini.Modules.Output/Gemini.Modules.Output.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows true core WPF MVVM AvalonDock Caliburn Micro Visual Studio IDE Shell Output Output module for Gemini, providing a buffered output tool pane. diff --git a/src/Gemini.Modules.PropertyGrid/Gemini.Modules.PropertyGrid.csproj b/src/Gemini.Modules.PropertyGrid/Gemini.Modules.PropertyGrid.csproj index 34dfbef5..61eee0ad 100644 --- a/src/Gemini.Modules.PropertyGrid/Gemini.Modules.PropertyGrid.csproj +++ b/src/Gemini.Modules.PropertyGrid/Gemini.Modules.PropertyGrid.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows true core WPF MVVM AvalonDock Caliburn Micro Visual Studio IDE Shell PropertyGrid PropertyGrid module for Gemini, utilising the PropertyGrid control from the Extended WPF Toolkit. diff --git a/src/Gemini.Tests/Gemini.Tests.csproj b/src/Gemini.Tests/Gemini.Tests.csproj index 74e25efc..3b286c88 100644 --- a/src/Gemini.Tests/Gemini.Tests.csproj +++ b/src/Gemini.Tests/Gemini.Tests.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows true true false diff --git a/src/Gemini/Gemini.csproj b/src/Gemini/Gemini.csproj index 7c1e8964..1ad42eb7 100644 --- a/src/Gemini/Gemini.csproj +++ b/src/Gemini/Gemini.csproj @@ -1,7 +1,7 @@ - net462;net10.0-windows + net48;net10.0-windows true GeminiWpf Gemini @@ -9,7 +9,7 @@ Gemini is an application shell similar in concept to the Visual Studio Shell. It uses AvalonDock and has an MVVM architecture based on Caliburn Micro. - + From 016c1b87736adba257a1b40002e707647ac13ff2 Mon Sep 17 00:00:00 2001 From: kornman00 Date: Sat, 3 Oct 2026 12:11:06 -0700 Subject: [PATCH 2/2] Upgrade AvalonDock to 4.74.1 Add AD4 docking, theme, and persistence coverage; validate exact native package assets; and exercise the package-only compatibility host against the upgraded engine and theme pair. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 12 +- doc/caliburn-5-migration.md | 22 + .../Caliburn5PackageHost/Program.cs | 27 + .../Caliburn5PackageHost/README.md | 16 +- eng/package-contract.json | 18 +- eng/validate-artifacts.ps1 | 47 +- src/Gemini.Tests/Gemini.Tests.csproj | 5 + .../Modules/Shell/AvalonDockDockingTests.cs | 476 ++++++++ .../Shell/AvalonDockPersistenceTests.cs | 1035 +++++++++++++++++ .../Modules/Shell/AvalonDockThemeTests.cs | 370 ++++++ .../TestData/AvalonDock460/CompleteLayout.xml | 38 + src/Gemini/Gemini.csproj | 2 +- 12 files changed, 2047 insertions(+), 21 deletions(-) create mode 100644 src/Gemini.Tests/Modules/Shell/AvalonDockDockingTests.cs create mode 100644 src/Gemini.Tests/Modules/Shell/AvalonDockPersistenceTests.cs create mode 100644 src/Gemini.Tests/Modules/Shell/AvalonDockThemeTests.cs create mode 100644 src/Gemini.Tests/TestData/AvalonDock460/CompleteLayout.xml diff --git a/README.md b/README.md index c690c7f0..20667b0d 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,13 @@ on other modules, but this is taken care of by the NuGet package dependency syst GitHub Actions builds every push and pull request for the `net48` and `net10.0-windows` targets. Successful runs provide the validated NuGet and -symbol packages as a downloadable workflow artifact. The modern build uses -Caliburn.Micro 5.0.258 and MahApps.Metro 2.4.11; net6/net7 remain available -from the legacy package line. Publication remains restricted to approved -upstream releases. See [the CI workflow](.github/workflows/ci.yml) for the exact -build and release contract. +symbol packages as a downloadable workflow artifact. The project files and +[`eng/package-contract.json`](eng/package-contract.json) are the authoritative +dependency and asset inventory; CI rejects packages that drift from that +contract. Net6/net7 remain available from the legacy package line. Publication +remains restricted to approved upstream releases. See +[the CI workflow](.github/workflows/ci.yml) for the exact build and release +contract. Applications and extensions moving from the previous package line or the earlier .NET Framework target should read diff --git a/doc/caliburn-5-migration.md b/doc/caliburn-5-migration.md index 4e6d0cbe..25cec613 100644 --- a/doc/caliburn-5-migration.md +++ b/doc/caliburn-5-migration.md @@ -45,6 +45,28 @@ Caliburn 5 also replaces the obsolete `OnInitializeAsync` extension point with Code that retries initialization after a failure must recreate the failed screen rather than changing Caliburn's lifecycle flags. +## Related dependency migration records + +Dependency changes are recorded as migrations rather than a mutable inventory. +Add a new subsection for each later transition so consumers can follow every +upgrade path without rewriting earlier guidance. + +### AvalonDock 4.60.0 to 4.74.1 + +This migration replaces the coherent AvalonDock engine and VS2013 theme pair +with version 4.74.1. The `net48` and `net10.0-windows` Gemini packages resolve +the matching native assets instead of a compatible older framework group. The +classic `DockingManager`, XML serializer entry point, and Light/Dark/Blue +resource URIs remain in use; this transition does not cover AvalonDock 5. + +AvalonDock's assembly version changes from 4.60.0.0 to 4.74.1.0. Rebuild and +test external controls or extensions that reference AvalonDock directly rather +than assuming binary compatibility from a successful Gemini source build. +Preserve the complete old Gemini state file and its matching application, +module, extension, and dependency binaries during rollout. AvalonDock XML is +only the trailing section of that state file and is not a complete rollback +artifact by itself. + ## Asynchronous shell and startup readiness `IShell.ShowTool` has been replaced by the awaitable `ShowToolAsync` overloads, diff --git a/eng/compatibility/Caliburn5PackageHost/Program.cs b/eng/compatibility/Caliburn5PackageHost/Program.cs index 625c8230..ea1c9eae 100644 --- a/eng/compatibility/Caliburn5PackageHost/Program.cs +++ b/eng/compatibility/Caliburn5PackageHost/Program.cs @@ -1,5 +1,10 @@ using System; +using System.IO; +using System.Reflection; using System.Threading.Tasks; +using System.Windows; +using AvalonDock; +using AvalonDock.Layout.Serialization; using Caliburn.Micro; using Gemini.Framework; @@ -13,9 +18,31 @@ private static int Main() var extension = new LifecycleExtension(); extension.Activated += extension.OnActivatedAsync; + var manager = new DockingManager(); + var serializer = new XmlLayoutSerializer(manager); + using (var stream = new MemoryStream()) + { + serializer.Serialize(stream); + if (stream.Length == 0) + return 1; + } + + var theme = new ResourceDictionary + { + Source = new Uri( + "pack://application:,,,/AvalonDock.Themes.VS2013;component/LightTheme.xaml", + UriKind.Absolute) + }; + var themeAssembly = Assembly.Load("AvalonDock.Themes.VS2013"); + var expectedAvalonDockVersion = new Version(4, 74, 1, 0); + return typeof(WindowBase).Assembly.GetName().Name == "Gemini" && typeof(Screen).Assembly.GetName().Name == "Caliburn.Micro.Core" && typeof(BootstrapperBase).Assembly.GetName().Name == "Caliburn.Micro.Platform" + && typeof(DockingManager).Assembly.GetName().Version == expectedAvalonDockVersion + && themeAssembly.GetName().Version == expectedAvalonDockVersion + && manager.Layout != null + && theme.MergedDictionaries.Count > 0 ? 0 : 1; } diff --git a/eng/compatibility/Caliburn5PackageHost/README.md b/eng/compatibility/Caliburn5PackageHost/README.md index 593dcf6a..1e01b589 100644 --- a/eng/compatibility/Caliburn5PackageHost/README.md +++ b/eng/compatibility/Caliburn5PackageHost/README.md @@ -1,4 +1,4 @@ -# Caliburn 5 package-only compatibility host +# Gemini package-only compatibility host This is durable package-validation infrastructure, not a transient migration probe. Project-reference builds cannot prove that a packed `GeminiWpf` package @@ -11,12 +11,14 @@ version is supplied by the validator after the coherent family has been inspected. This verifies the consumer boundary that a normal solution build cannot cover: -- NuGet selects Gemini and the expected Caliburn.Micro Platform, Core, and - Microsoft.Xaml.Behaviors.Wpf packages for each target; -- an external extension compiles against the Caliburn 5 asynchronous - `Activated` event contract; and -- the expected Gemini, Caliburn Core, and Caliburn Platform assemblies load at - runtime. +- NuGet selects Gemini and the complete external dependency graph declared for + each target in `eng\package-contract.json`; +- the validator rejects framework fallback by requiring the exact native + compile and runtime assets declared by that contract; +- an external extension compiles against the public lifecycle event contract; + and +- representative framework, docking, serialization, and theme APIs load and + execute from the resolved package graph. The host can be removed only when another package-only consumer fixture covers the same restore, compile, public-contract, dependency-graph, and runtime diff --git a/eng/package-contract.json b/eng/package-contract.json index 6dbdf50c..bf21c240 100644 --- a/eng/package-contract.json +++ b/eng/package-contract.json @@ -4,19 +4,29 @@ "nuspec": ".NETFramework4.8", "asset": "net48", "build": "net48", - "lockFileTarget": "net48" + "lockFileTarget": "net48", + "resolvedAssets": { + "Dirkster.AvalonDock": "lib/net48/AvalonDock.dll", + "Dirkster.AvalonDock.Themes.VS2013": "lib/net48/AvalonDock.Themes.VS2013.dll" + } }, { "nuspec": "net10.0-windows7.0", "asset": "net10.0-windows7.0", "build": "net10.0-windows", - "lockFileTarget": "net10.0-windows" + "lockFileTarget": "net10.0-windows", + "resolvedAssets": { + "Dirkster.AvalonDock": "lib/net10.0-windows7.0/AvalonDock.dll", + "Dirkster.AvalonDock.Themes.VS2013": "lib/net10.0-windows7.0/AvalonDock.Themes.VS2013.dll" + } } ], "resolvedDependencies": { "Caliburn.Micro": "5.0.258", "Caliburn.Micro.Core": "5.0.258", - "Microsoft.Xaml.Behaviors.Wpf": "1.1.135" + "Microsoft.Xaml.Behaviors.Wpf": "1.1.135", + "Dirkster.AvalonDock": "4.74.1", + "Dirkster.AvalonDock.Themes.VS2013": "4.74.1" }, "shaders": [ "AddEffect.ps", @@ -29,7 +39,7 @@ "internalDependencies": [], "externalDependencies": { "Caliburn.Micro": "5.0.258", - "Dirkster.AvalonDock.Themes.VS2013": "4.60.0", + "Dirkster.AvalonDock.Themes.VS2013": "4.74.1", "DotNetProjects.Extended.Wpf.Toolkit": "4.6.78", "MahApps.Metro": "2.4.11", "System.ComponentModel.Composition": "6.0.0" diff --git a/eng/validate-artifacts.ps1 b/eng/validate-artifacts.ps1 index 811b510b..c24c5666 100644 --- a/eng/validate-artifacts.ps1 +++ b/eng/validate-artifacts.ps1 @@ -19,7 +19,8 @@ reading the validation implementation. The script verifies: - all internal Gemini dependencies use the same NBGV-calculated version; and - every symbol package contains the matching portable PDBs; and - a package-only external host restores, builds, and runs against the expected - Caliburn dependency graph for every retained framework. + Caliburn dependency graph and exact native AvalonDock assets for every + retained framework. Any framework, package, assembly, or dependency change must update the declarative contract deliberately. This prevents partial per-framework packages @@ -38,7 +39,7 @@ Path to the declarative package contract. Defaults to `eng\package-contract.json`. .PARAMETER CompatibilityProjectPath -Path to the package-only Caliburn compatibility host. Defaults to +Path to the package-only compatibility host. Defaults to `eng\compatibility\Caliburn5PackageHost\Caliburn5PackageHost.csproj`. .EXAMPLE @@ -327,18 +328,28 @@ Assert-Condition -Condition ($distinctVersions.Count -eq 1) ` $packageVersion = $distinctVersions[0] $nuGetConfigPath = Join-Path $repositoryRoot 'src\NuGet.config' +$compatibilityProjectDirectory = Split-Path -Parent $CompatibilityProjectPath +$geminiPackagePath = Join-Path $PackagePath "GeminiWpf.$packageVersion.nupkg" +Assert-Condition -Condition (Test-Path -LiteralPath $geminiPackagePath -PathType Leaf) ` + -Message "Gemini package does not exist: $geminiPackagePath" + +# Key validation restores by package content. Distinct working-tree payloads can +# share an ID/version, but each must be restored and inspected independently. +$geminiPackageHash = (Get-FileHash -LiteralPath $geminiPackagePath -Algorithm SHA256).Hash.ToLowerInvariant() +$compatibilityPackagesPath = Join-Path $compatibilityProjectDirectory "obj\package-validation\$geminiPackageHash" Invoke-DotNet ` -Description 'Package-only compatibility restore' ` -Arguments @( 'restore', $CompatibilityProjectPath, + '--force-evaluate', '--configfile', $nuGetConfigPath, "-p:RestoreAdditionalProjectSources=$PackagePath", + "-p:RestorePackagesPath=$compatibilityPackagesPath", "-p:GeminiPackageVersion=$packageVersion" ) -$compatibilityProjectDirectory = Split-Path -Parent $CompatibilityProjectPath $assetsPath = Join-Path $compatibilityProjectDirectory 'obj\project.assets.json' Assert-Condition -Condition (Test-Path -LiteralPath $assetsPath -PathType Leaf) ` -Message "Compatibility restore did not create $assetsPath." @@ -356,6 +367,33 @@ foreach ($framework in $expectedFrameworks) { -Message "Compatibility target '$($framework.lockFileTarget)' is missing resolved package '$expectedIdentity'." } + foreach ($assetExpectation in $framework.resolvedAssets.PSObject.Properties) { + $dependency = $contract.resolvedDependencies.PSObject.Properties[$assetExpectation.Name] + Assert-Condition -Condition ($null -ne $dependency) ` + -Message "Asset expectation '$($assetExpectation.Name)' has no resolved dependency version." + + $expectedIdentity = "$($assetExpectation.Name)/$($dependency.Value)" + $resolvedPackage = $targetProperty.Value.PSObject.Properties[$expectedIdentity] + Assert-Condition -Condition ($null -ne $resolvedPackage) ` + -Message "Compatibility target '$($framework.lockFileTarget)' has no asset data for '$expectedIdentity'." + + $compile = $resolvedPackage.Value.PSObject.Properties['compile'] + $runtime = $resolvedPackage.Value.PSObject.Properties['runtime'] + Assert-Condition -Condition ($null -ne $compile) ` + -Message "Compatibility target '$($framework.lockFileTarget)' has no compile assets for '$expectedIdentity'." + Assert-Condition -Condition ($null -ne $runtime) ` + -Message "Compatibility target '$($framework.lockFileTarget)' has no runtime assets for '$expectedIdentity'." + + Assert-SetEqual ` + -Expected @([string] $assetExpectation.Value) ` + -Actual @($compile.Value.PSObject.Properties.Name) ` + -Description "$expectedIdentity compile assets for $($framework.lockFileTarget)" + Assert-SetEqual ` + -Expected @([string] $assetExpectation.Value) ` + -Actual @($runtime.Value.PSObject.Properties.Name) ` + -Description "$expectedIdentity runtime assets for $($framework.lockFileTarget)" + } + Invoke-DotNet ` -Description "Package-only compatibility run for $($framework.build)" ` -Arguments @( @@ -367,9 +405,10 @@ foreach ($framework in $expectedFrameworks) { '--framework', $framework.build, '--no-restore', + "-p:RestorePackagesPath=$compatibilityPackagesPath", "-p:GeminiPackageVersion=$packageVersion" ) } $shaderCount = $expectedFrameworks.Count * @($contract.shaders).Count -Write-Host "Validated $shaderCount compiled shaders, $($expectedPackages.Count) packages with symbols, and the package-only Caliburn host at version $packageVersion." +Write-Host "Validated $shaderCount compiled shaders, $($expectedPackages.Count) packages with symbols, exact resolved assets, and the package-only compatibility host at version $packageVersion." diff --git a/src/Gemini.Tests/Gemini.Tests.csproj b/src/Gemini.Tests/Gemini.Tests.csproj index 3b286c88..866bdce9 100644 --- a/src/Gemini.Tests/Gemini.Tests.csproj +++ b/src/Gemini.Tests/Gemini.Tests.csproj @@ -14,4 +14,9 @@ + + + + diff --git a/src/Gemini.Tests/Modules/Shell/AvalonDockDockingTests.cs b/src/Gemini.Tests/Modules/Shell/AvalonDockDockingTests.cs new file mode 100644 index 00000000..22018abf --- /dev/null +++ b/src/Gemini.Tests/Modules/Shell/AvalonDockDockingTests.cs @@ -0,0 +1,476 @@ +using System; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using AvalonDock.Layout; +using Gemini.Framework; +using Gemini.Framework.Services; +using Gemini.Modules.Shell.Controls; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Gemini.Tests.Modules.Shell +{ + [STATestClass] + [DoNotParallelize] + public sealed class AvalonDockDockingTests + { + // Intent: pin Gemini's left-tool placement contract independently of vendor internals. + [TestMethod] + public void BeforeInsertAnchorable_LeftTool_CreatesNamedPaneAtHorizontalStart() + { + var topology = CreateDefaultTopology(); + var anchorable = CreateAnchorable(new TestTool(PaneLocation.Left, 321, 123)); + + var inserted = new LayoutInitializer().BeforeInsertAnchorable( + topology.Root, + anchorable, + topology.DocumentPane); + + Assert.IsTrue(inserted); + var pane = Assert.IsInstanceOfType( + topology.HorizontalPanel.Children[0]); + Assert.AreEqual("LeftPane", pane.Name); + Assert.AreSame(anchorable, pane.Children[0]); + Assert.AreSame(topology.DocumentPane, topology.HorizontalPanel.Children[1]); + Assert.AreEqual(2, topology.HorizontalPanel.ChildrenCount); + } + + // Intent: preserve right-tool ordering relied on by saved layouts and shell conventions. + [TestMethod] + public void BeforeInsertAnchorable_RightTool_CreatesNamedPaneAtHorizontalEnd() + { + var topology = CreateDefaultTopology(); + var anchorable = CreateAnchorable(new TestTool(PaneLocation.Right, 654, 123)); + + var inserted = new LayoutInitializer().BeforeInsertAnchorable( + topology.Root, + anchorable, + topology.DocumentPane); + + Assert.IsTrue(inserted); + Assert.AreSame(topology.DocumentPane, topology.HorizontalPanel.Children[0]); + var pane = Assert.IsInstanceOfType( + topology.HorizontalPanel.Children[1]); + Assert.AreEqual("RightPane", pane.Name); + Assert.AreSame(anchorable, pane.Children[0]); + Assert.AreEqual(2, topology.HorizontalPanel.ChildrenCount); + } + + // Intent: keep bottom tools outside the horizontal document/tool row. + [TestMethod] + public void BeforeInsertAnchorable_BottomTool_CreatesNamedPaneAtVerticalEnd() + { + var topology = CreateDefaultTopology(); + var anchorable = CreateAnchorable(new TestTool(PaneLocation.Bottom, 321, 246)); + + var inserted = new LayoutInitializer().BeforeInsertAnchorable( + topology.Root, + anchorable, + topology.DocumentPane); + + Assert.IsTrue(inserted); + Assert.AreSame(topology.HorizontalPanel, topology.VerticalPanel.Children[0]); + var pane = Assert.IsInstanceOfType( + topology.VerticalPanel.Children[1]); + Assert.AreEqual("BottomPane", pane.Name); + Assert.AreSame(anchorable, pane.Children[0]); + Assert.AreEqual(2, topology.VerticalPanel.ChildrenCount); + } + + // Intent: leave foreign anchorables to AvalonDock instead of claiming their placement. + [TestMethod] + public void BeforeInsertAnchorable_NonToolContent_DoesNotInterceptOrChangeTopology() + { + var topology = CreateDefaultTopology(); + var anchorable = new LayoutAnchorable + { + Content = new object(), + ContentId = "non-gemini-content" + }; + + var inserted = new LayoutInitializer().BeforeInsertAnchorable( + topology.Root, + anchorable, + topology.DocumentPane); + + Assert.IsFalse(inserted); + Assert.AreEqual(1, topology.HorizontalPanel.ChildrenCount); + Assert.AreSame(topology.DocumentPane, topology.HorizontalPanel.Children[0]); + Assert.AreEqual(1, topology.VerticalPanel.ChildrenCount); + Assert.IsNull(anchorable.Parent); + } + + // Intent: reject invalid Gemini placement metadata without partially mutating the layout. + [TestMethod] + public void BeforeInsertAnchorable_UnsupportedPreferredLocation_ThrowsWithoutChangingTopology() + { + var topology = CreateDefaultTopology(); + var anchorable = CreateAnchorable( + new TestTool((PaneLocation)int.MaxValue, 200, 200)); + + var exception = Assert.ThrowsExactly( + () => new LayoutInitializer().BeforeInsertAnchorable( + topology.Root, + anchorable, + topology.DocumentPane)); + + Assert.AreEqual("location", exception.ParamName); + Assert.AreEqual(1, topology.HorizontalPanel.ChildrenCount); + Assert.AreEqual(1, topology.VerticalPanel.ChildrenCount); + Assert.IsNull(anchorable.Parent); + } + + // Intent: apply a left tool's preferred width only when its pane is first established. + [TestMethod] + public void AfterInsertAnchorable_FirstSideTool_AppliesPreferredPixelWidth() + { + var topology = CreateDefaultTopology(); + var tool = new TestTool(PaneLocation.Left, 321, 123); + var anchorable = CreateAnchorable(tool); + var initializer = new LayoutInitializer(); + initializer.BeforeInsertAnchorable(topology.Root, anchorable, topology.DocumentPane); + + initializer.AfterInsertAnchorable(topology.Root, anchorable); + + var pane = Assert.IsInstanceOfType(anchorable.Parent); + Assert.AreEqual(GridUnitType.Pixel, pane.DockWidth.GridUnitType); + Assert.AreEqual(321d, pane.DockWidth.Value); + Assert.AreEqual("LeftPane", pane.Name); + } + + // Intent: apply the same first-tool sizing rule to the independently named right pane. + [TestMethod] + public void AfterInsertAnchorable_FirstRightTool_AppliesPreferredPixelWidth() + { + var topology = CreateDefaultTopology(); + var tool = new TestTool(PaneLocation.Right, 654, 123); + var anchorable = CreateAnchorable(tool); + var initializer = new LayoutInitializer(); + initializer.BeforeInsertAnchorable(topology.Root, anchorable, topology.DocumentPane); + + initializer.AfterInsertAnchorable(topology.Root, anchorable); + + var pane = Assert.IsInstanceOfType(anchorable.Parent); + Assert.AreEqual(GridUnitType.Pixel, pane.DockWidth.GridUnitType); + Assert.AreEqual(654d, pane.DockWidth.Value); + Assert.AreEqual("RightPane", pane.Name); + } + + // Intent: map bottom-tool sizing to height rather than a side-pane width. + [TestMethod] + public void AfterInsertAnchorable_FirstBottomTool_AppliesPreferredPixelHeight() + { + var topology = CreateDefaultTopology(); + var tool = new TestTool(PaneLocation.Bottom, 321, 246); + var anchorable = CreateAnchorable(tool); + var initializer = new LayoutInitializer(); + initializer.BeforeInsertAnchorable(topology.Root, anchorable, topology.DocumentPane); + + initializer.AfterInsertAnchorable(topology.Root, anchorable); + + var pane = Assert.IsInstanceOfType(anchorable.Parent); + Assert.AreEqual(GridUnitType.Pixel, pane.DockHeight.GridUnitType); + Assert.AreEqual(246d, pane.DockHeight.Value); + Assert.AreEqual("BottomPane", pane.Name); + } + + // Intent: prevent later tools from overriding a user's established side-pane width. + [TestMethod] + public void AfterInsertAnchorable_LaterTool_PreservesFirstToolPaneDimension() + { + var topology = CreateDefaultTopology(); + var initializer = new LayoutInitializer(); + var first = CreateAnchorable(new TestTool(PaneLocation.Right, 420, 100)); + initializer.BeforeInsertAnchorable(topology.Root, first, topology.DocumentPane); + initializer.AfterInsertAnchorable(topology.Root, first); + var pane = Assert.IsInstanceOfType(first.Parent); + + var later = CreateAnchorable(new TestTool(PaneLocation.Right, 999, 100)); + initializer.BeforeInsertAnchorable(topology.Root, later, topology.DocumentPane); + initializer.AfterInsertAnchorable(topology.Root, later); + + Assert.AreSame(pane, later.Parent); + Assert.AreEqual(2, pane.ChildrenCount); + Assert.AreEqual(420d, pane.DockWidth.Value); + Assert.AreEqual(GridUnitType.Pixel, pane.DockWidth.GridUnitType); + } + + // Intent: prevent later bottom tools from overriding the established pane height. + [TestMethod] + public void AfterInsertAnchorable_LaterBottomTool_PreservesFirstToolPaneDimension() + { + var topology = CreateDefaultTopology(); + var initializer = new LayoutInitializer(); + var first = CreateAnchorable(new TestTool(PaneLocation.Bottom, 100, 275)); + initializer.BeforeInsertAnchorable(topology.Root, first, topology.DocumentPane); + initializer.AfterInsertAnchorable(topology.Root, first); + var pane = Assert.IsInstanceOfType(first.Parent); + + var later = CreateAnchorable(new TestTool(PaneLocation.Bottom, 100, 888)); + initializer.BeforeInsertAnchorable(topology.Root, later, topology.DocumentPane); + initializer.AfterInsertAnchorable(topology.Root, later); + + Assert.AreSame(pane, later.Parent); + Assert.AreEqual(2, pane.ChildrenCount); + Assert.AreEqual(275d, pane.DockHeight.Value); + Assert.AreEqual(GridUnitType.Pixel, pane.DockHeight.GridUnitType); + } + + // Intent: make invalid post-insert metadata fail without a partial resize. + [TestMethod] + public void AfterInsertAnchorable_UnsupportedPreferredLocation_ThrowsWithoutChangingDimensions() + { + var topology = CreateDefaultTopology(); + var anchorable = CreateAnchorable( + new TestTool((PaneLocation)int.MaxValue, 777, 888)); + var pane = new LayoutAnchorablePane(anchorable) + { + Name = "UnsupportedPane", + DockWidth = new GridLength(321, GridUnitType.Pixel), + DockHeight = new GridLength(123, GridUnitType.Pixel) + }; + topology.HorizontalPanel.InsertChildAt(0, pane); + + Assert.ThrowsExactly( + () => new LayoutInitializer().AfterInsertAnchorable( + topology.Root, + anchorable)); + + Assert.AreEqual(321d, pane.DockWidth.Value); + Assert.AreEqual(123d, pane.DockHeight.Value); + Assert.AreSame(anchorable, pane.Children[0]); + } + + // Intent: preserve restored user topology by reusing its semantic pane identity. + [TestMethod] + public void BeforeInsertAnchorable_RestoredNamedPane_ReusesPaneWithoutChangingRestoredTopology() + { + var topology = CreateDefaultTopology(); + var restoredPane = new LayoutAnchorablePane + { + Name = "RightPane", + DockWidth = new GridLength(515, GridUnitType.Pixel) + }; + topology.HorizontalPanel.InsertChildAt(0, restoredPane); + var originalChildren = topology.HorizontalPanel.Children.ToArray(); + var anchorable = CreateAnchorable(new TestTool(PaneLocation.Right, 999, 100)); + + var inserted = new LayoutInitializer().BeforeInsertAnchorable( + topology.Root, + anchorable, + topology.DocumentPane); + + Assert.IsTrue(inserted); + CollectionAssert.AreEqual(originalChildren, topology.HorizontalPanel.Children.ToArray()); + Assert.AreEqual(1, topology.Root.Descendents() + .OfType() + .Count(x => x.Name == "RightPane")); + Assert.AreSame(restoredPane, anchorable.Parent); + Assert.AreEqual(515d, restoredPane.DockWidth.Value); + } + + // Intent: find semantic panes after restore even when their child indexes have changed. + [TestMethod] + public void BeforeInsertAnchorable_ChangedTopology_FindsNamedPaneIndependentOfChildIndex() + { + var topology = CreateDefaultTopology(); + var leftPane = new LayoutAnchorablePane { Name = "LeftPane" }; + var group = new LayoutAnchorablePaneGroup(leftPane); + topology.HorizontalPanel.Children.Add(group); + var trailingDocumentPane = new LayoutDocumentPane(); + topology.HorizontalPanel.Children.Add(trailingDocumentPane); + var anchorable = CreateAnchorable(new TestTool(PaneLocation.Left, 333, 100)); + + var inserted = new LayoutInitializer().BeforeInsertAnchorable( + topology.Root, + anchorable, + trailingDocumentPane); + + Assert.IsTrue(inserted); + Assert.AreEqual(3, topology.HorizontalPanel.ChildrenCount); + Assert.AreSame(topology.DocumentPane, topology.HorizontalPanel.Children[0]); + Assert.AreSame(group, topology.HorizontalPanel.Children[1]); + Assert.AreSame(trailingDocumentPane, topology.HorizontalPanel.Children[2]); + Assert.AreSame(leftPane, anchorable.Parent); + Assert.AreSame(anchorable, leftPane.Children[0]); + } + + // Intent: keep document placement under AvalonDock rather than the tool initializer. + [TestMethod] + public void BeforeInsertDocument_Document_DoesNotInterceptAvalonDockInsertion() + { + var topology = CreateDefaultTopology(); + var document = CreateDocument(new TestDocument()); + + var inserted = new LayoutInitializer().BeforeInsertDocument( + topology.Root, + document, + topology.DocumentPane); + + Assert.IsFalse(inserted); + Assert.AreEqual(0, topology.DocumentPane.ChildrenCount); + Assert.IsNull(document.Parent); + } + + // Intent: protect the selection feedback Gemini consumes when focus moves across item kinds. + [TestMethod] + public void LayoutSelection_DocumentThenTool_TracksSelectedAndActiveContent() + { + var topology = CreateDefaultTopology(); + var document = CreateDocument(new TestDocument()); + var tool = CreateAnchorable(new TestTool(PaneLocation.Left, 200, 200)); + topology.DocumentPane.Children.Add(document); + var toolPane = new LayoutAnchorablePane(tool) { Name = "LeftPane" }; + topology.HorizontalPanel.InsertChildAt(0, toolPane); + + document.IsSelected = true; + document.IsActive = true; + + Assert.AreSame(document, topology.DocumentPane.SelectedContent); + Assert.AreSame(document, topology.Root.ActiveContent); + Assert.IsTrue(document.IsSelected); + + tool.IsSelected = true; + tool.IsActive = true; + + Assert.AreSame(tool, toolPane.SelectedContent); + Assert.AreSame(tool, topology.Root.ActiveContent); + Assert.IsTrue(tool.IsSelected); + Assert.IsFalse(document.IsActive); + } + + // Intent: ensure float/dock cycles move the same model without accumulating layout debris. + [TestMethod] + public void LayoutTransitions_TabFloatDock_PreserveContentIdentityAndDeterministicTopology() + { + var topology = CreateDefaultTopology(); + var first = CreateAnchorable(new TestTool(PaneLocation.Left, 200, 200)); + var moving = CreateAnchorable(new TestTool(PaneLocation.Left, 200, 200)); + var dockedPane = new LayoutAnchorablePane(first) { Name = "LeftPane" }; + dockedPane.Children.Add(moving); + topology.HorizontalPanel.InsertChildAt(0, dockedPane); + + moving.IsSelected = true; + Assert.AreSame(moving, dockedPane.SelectedContent); + CollectionAssert.AreEqual( + new[] { first.ContentId, moving.ContentId }, + dockedPane.Children.Select(x => x.ContentId).ToArray()); + + dockedPane.Children.Remove(moving); + var floatingPane = new LayoutAnchorablePane(moving) { Name = "FloatingPane" }; + var floatingWindow = new LayoutAnchorableFloatingWindow + { + RootPanel = new LayoutAnchorablePaneGroup(floatingPane) + }; + topology.Root.FloatingWindows.Add(floatingWindow); + + Assert.AreEqual(1, topology.Root.FloatingWindows.Count); + Assert.IsTrue(moving.IsFloating); + Assert.AreSame(floatingPane, moving.Parent); + Assert.AreEqual(moving.ContentId, floatingPane.Children[0].ContentId); + Assert.AreEqual(1, dockedPane.ChildrenCount); + + floatingPane.Children.Remove(moving); + topology.Root.FloatingWindows.Remove(floatingWindow); + dockedPane.Children.Add(moving); + moving.IsSelected = true; + + Assert.AreEqual(0, topology.Root.FloatingWindows.Count); + Assert.IsFalse(moving.IsFloating); + Assert.AreSame(dockedPane, moving.Parent); + Assert.AreSame(moving, dockedPane.SelectedContent); + CollectionAssert.AreEqual( + new[] { first.ContentId, moving.ContentId }, + dockedPane.Children.Select(x => x.ContentId).ToArray()); + } + + private static LayoutTopology CreateDefaultTopology() + { + var documentPane = new LayoutDocumentPane(); + var horizontalPanel = new LayoutPanel(documentPane) + { + Orientation = Orientation.Horizontal + }; + var verticalPanel = new LayoutPanel(horizontalPanel) + { + Orientation = Orientation.Vertical + }; + var root = new LayoutRoot + { + RootPanel = verticalPanel + }; + return new LayoutTopology(root, verticalPanel, horizontalPanel, documentPane); + } + + private static LayoutAnchorable CreateAnchorable(TestTool tool) + { + return new LayoutAnchorable + { + Content = tool, + ContentId = tool.ContentId, + Title = tool.DisplayName + }; + } + + private static LayoutDocument CreateDocument(TestDocument document) + { + return new LayoutDocument + { + Content = document, + ContentId = document.ContentId, + Title = document.DisplayName + }; + } + + private sealed class LayoutTopology + { + public LayoutTopology( + LayoutRoot root, + LayoutPanel verticalPanel, + LayoutPanel horizontalPanel, + LayoutDocumentPane documentPane) + { + Root = root; + VerticalPanel = verticalPanel; + HorizontalPanel = horizontalPanel; + DocumentPane = documentPane; + } + + public LayoutRoot Root { get; } + + public LayoutPanel VerticalPanel { get; } + + public LayoutPanel HorizontalPanel { get; } + + public LayoutDocumentPane DocumentPane { get; } + } + + private sealed class TestTool : Tool + { + public TestTool( + PaneLocation preferredLocation, + double preferredWidth, + double preferredHeight) + { + PreferredLocation = preferredLocation; + PreferredWidth = preferredWidth; + PreferredHeight = preferredHeight; + DisplayName = preferredLocation + " tool"; + } + + public override PaneLocation PreferredLocation { get; } + + public override double PreferredWidth { get; } + + public override double PreferredHeight { get; } + } + + private sealed class TestDocument : Document + { + public TestDocument() + { + DisplayName = "Document"; + } + } + } +} diff --git a/src/Gemini.Tests/Modules/Shell/AvalonDockPersistenceTests.cs b/src/Gemini.Tests/Modules/Shell/AvalonDockPersistenceTests.cs new file mode 100644 index 00000000..e0ea6463 --- /dev/null +++ b/src/Gemini.Tests/Modules/Shell/AvalonDockPersistenceTests.cs @@ -0,0 +1,1035 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using AvalonDock; +using AvalonDock.Layout; +using AvalonDock.Layout.Serialization; +using Caliburn.Micro; +using Gemini.Framework; +using Gemini.Framework.Services; +using Gemini.Modules.Shell.Services; +using Gemini.Modules.Shell.Views; +using Gemini.Tests.TestInfrastructure; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Gemini.Tests.Modules.Shell +{ + [STATestClass] + [DoNotParallelize] + public sealed class AvalonDockPersistenceTests + { + private const string FixtureHash = + "7697172D53DC8F9ADFDE8DF4ADC6F52AE4BA323A7E45DC336ADCFB7AFC2CCA13"; + + private static readonly string[] FixtureDocumentIds = + { + "fixture-document-primary", + "fixture-document-secondary" + }; + + private static readonly string[] FixtureToolIds = + { + "fixture-tool-left", + "fixture-tool-right", + "fixture-tool-bottom", + "fixture-tool-floating", + "fixture-tool-hidden" + }; + + // Intent: make this compatibility input immutable; later writers require new fixtures. + [TestMethod] + public void Complete460Fixture_NormalizedBytesMatchCapturedImmutableBaseline() + { + var normalizedFixture = File.ReadAllText(GetFixturePath()) + .Replace("\r\n", "\n"); + + string actualHash; + using (var algorithm = SHA256.Create()) + { + actualHash = BitConverter.ToString( + algorithm.ComputeHash(Encoding.UTF8.GetBytes(normalizedFixture))) + .Replace("-", string.Empty); + } + + Assert.AreEqual(FixtureHash, actualHash); + Assert.Contains( + "fixture-tool-floating", + normalizedFixture); + Assert.Contains( + "", + normalizedFixture); + } + + // Intent: prove legacy ContentIds still reconnect every document and tool model. + [TestMethod] + public void Complete460Fixture_Deserialize_RestoresDocumentsAndToolsByContentId() + { + var loaded = LoadFixture(); + var documents = loaded.Manager.Layout.Descendents() + .OfType() + .ToArray(); + var tools = loaded.Manager.Layout.Descendents() + .OfType() + .ToArray(); + + CollectionAssert.AreEquivalent( + FixtureDocumentIds, + documents.Select(x => x.ContentId).ToArray()); + CollectionAssert.AreEquivalent( + FixtureToolIds, + tools.Select(x => x.ContentId).ToArray()); + CollectionAssert.AreEquivalent( + FixtureDocumentIds.Concat(FixtureToolIds).ToArray(), + loaded.CallbackContentIds.ToArray()); + foreach (var content in documents.Cast().Concat(tools)) + { + Assert.AreEqual( + "restored:" + content.ContentId, + content.Content); + } + } + + // Intent: protect user-visible legacy layout semantics beyond successful XML parsing. + [TestMethod] + public void Complete460Fixture_Deserialize_PreservesVisibilitySelectionAndTopology() + { + var loaded = LoadFixture(); + var root = loaded.Manager.Layout; + var vertical = Assert.IsInstanceOfType(root.RootPanel); + var horizontal = Assert.IsInstanceOfType(vertical.Children[0]); + var left = Assert.IsInstanceOfType( + horizontal.Children[0]); + var documents = Assert.IsInstanceOfType( + horizontal.Children[1]); + var right = Assert.IsInstanceOfType( + horizontal.Children[2]); + var bottom = Assert.IsInstanceOfType( + vertical.Children[1]); + var hidden = Assert.ContainsSingle(root.Hidden); + var floatingWindow = Assert.ContainsSingle(root.FloatingWindows); + var floatingPane = Assert.IsInstanceOfType( + floatingWindow.Descendents().Single(x => x is LayoutAnchorablePane)); + var floating = Assert.ContainsSingle(floatingPane.Children); + + Assert.AreEqual(Orientation.Vertical, vertical.Orientation); + Assert.AreEqual(2, vertical.ChildrenCount); + Assert.AreEqual(Orientation.Horizontal, horizontal.Orientation); + Assert.AreEqual(3, horizontal.ChildrenCount); + Assert.AreEqual("LeftPane", left.Name); + Assert.AreEqual(240d, left.DockWidth.Value); + Assert.AreEqual(GridUnitType.Pixel, left.DockWidth.GridUnitType); + Assert.AreEqual("RightPane", right.Name); + Assert.AreEqual(310d, right.DockWidth.Value); + Assert.AreEqual(GridUnitType.Pixel, right.DockWidth.GridUnitType); + Assert.AreEqual("BottomPane", bottom.Name); + Assert.AreEqual(180d, bottom.DockHeight.Value); + Assert.AreEqual(GridUnitType.Pixel, bottom.DockHeight.GridUnitType); + CollectionAssert.AreEqual( + FixtureDocumentIds, + documents.Children.Select(x => x.ContentId).ToArray()); + Assert.AreEqual( + "fixture-document-secondary", + documents.SelectedContent.ContentId); + Assert.IsTrue(documents.Children[1].IsSelected); + Assert.IsTrue(documents.Children[1].IsLastFocusedDocument); + Assert.AreEqual("fixture-tool-hidden", hidden.ContentId); + Assert.IsFalse(hidden.IsVisible); + Assert.AreEqual("FloatingPane", floatingPane.Name); + Assert.AreEqual("fixture-tool-floating", floating.ContentId); + Assert.IsTrue(floating.IsFloating); + Assert.AreEqual(120d, floating.FloatingLeft); + Assert.AreEqual(80d, floating.FloatingTop); + Assert.AreEqual(420d, floating.FloatingWidth); + Assert.AreEqual(260d, floating.FloatingHeight); + } + + // Intent: verify Gemini's ShellView callbacks restore model identity at the real boundary. + [TestMethod] + public void ShellViewLayoutBoundary_SerializeDeserialize_RestoresDocumentsAndToolsByContentId() + { + var sourceItems = RuntimeItems.CreateSource(); + var sourceView = CreateCompleteRuntimeView(sourceItems); + var layoutBytes = SaveLayout(sourceView); + var restored = RestoreLayout(layoutBytes, sourceItems); + + Assert.AreEqual(2, restored.DocumentCallbacks.Count); + Assert.AreEqual(6, restored.ToolCallbacks.Count); + CollectionAssert.AreEquivalent( + restored.Items.Documents.Cast().ToArray(), + restored.DocumentCallbacks.ToArray()); + CollectionAssert.AreEquivalent( + restored.Items.Tools.Cast().ToArray(), + restored.ToolCallbacks.ToArray()); + + foreach (var pair in restored.Items.SerializedContentMap) + { + var model = restored.Manager.Layout.Descendents() + .OfType() + .Single(x => ReferenceEquals(x.Content, pair.Value)); + Assert.AreSame(pair.Value, model.Content); + Assert.AreEqual(pair.Value.ContentId, model.ContentId); + } + } + + // Intent: preserve visibility, selection, dimensions, and topology across the ShellView API. + [TestMethod] + public void ShellViewLayoutBoundary_Deserialize_PreservesVisibilitySelectionAndLayoutSemantics() + { + var sourceItems = RuntimeItems.CreateSource(); + var sourceView = CreateCompleteRuntimeView(sourceItems); + var restored = RestoreLayout(SaveLayout(sourceView), sourceItems); + var root = restored.Manager.Layout; + var horizontal = root.Descendents() + .OfType() + .Single(x => x.Orientation == Orientation.Horizontal); + var documentPane = root.Descendents() + .OfType() + .Single(); + var leftPane = root.Descendents() + .OfType() + .Single(x => x.Name == "LeftPane"); + var hidden = Assert.ContainsSingle(root.Hidden); + + Assert.AreEqual(3, horizontal.ChildrenCount); + Assert.AreEqual(2, documentPane.ChildrenCount); + Assert.AreEqual(2, leftPane.ChildrenCount); + Assert.AreEqual( + restored.Items.SecondaryDocument.ContentId, + documentPane.SelectedContent.ContentId); + Assert.AreEqual( + restored.Items.SecondaryLeftTool.ContentId, + leftPane.SelectedContent.ContentId); + Assert.IsFalse(restored.Items.PrimaryDocument.IsSelected); + Assert.IsTrue(restored.Items.SecondaryDocument.IsSelected); + Assert.IsFalse(restored.Items.PrimaryLeftTool.IsSelected); + Assert.IsTrue(restored.Items.SecondaryLeftTool.IsSelected); + Assert.IsFalse(restored.Items.HiddenTool.IsVisible); + Assert.AreSame( + restored.Items.HiddenTool, + hidden.Content); + Assert.AreEqual(1, root.FloatingWindows.Count); + var floating = root.FloatingWindows[0].Descendents() + .OfType() + .Single(); + Assert.AreSame(restored.Items.FloatingTool, floating.Content); + Assert.IsTrue(floating.IsFloating); + Assert.AreEqual(420d, floating.FloatingWidth); + } + + // Intent: ensure a restored layout remains editable and serializable after migration. + [TestMethod] + public void RestoredLayout_AddAndSelectDocumentThenSerialize_RemainsOperational() + { + var sourceItems = RuntimeItems.CreateSource(); + var restored = RestoreLayout( + SaveLayout(CreateCompleteRuntimeView(sourceItems)), + sourceItems); + var documentPane = restored.Manager.Layout.Descendents() + .OfType() + .Single(); + var addedDocument = new PersistentTestDocument( + "Post-restore document", + "post-restore-state"); + var addedModel = CreateDocument(addedDocument); + + documentPane.Children.Add(addedModel); + addedModel.IsSelected = true; + addedModel.IsActive = true; + var serializedAgain = SaveLayout(restored.View); + var serializedXml = Encoding.UTF8.GetString(serializedAgain); + + Assert.AreEqual(3, documentPane.ChildrenCount); + Assert.AreSame(addedModel, documentPane.SelectedContent); + Assert.AreSame(addedModel, restored.Manager.Layout.ActiveContent); + Assert.Contains(addedDocument.ContentId, serializedXml); + Assert.Contains("Post-restore document", serializedXml); + Assert.Contains( + restored.Items.PrimaryDocument.ContentId, + serializedXml); + Assert.Contains( + restored.Items.SecondaryDocument.ContentId, + serializedXml); + } + + // Intent: catch semantic drift or ghost entries that appear only after repeated saves. + [TestMethod] + public void CompleteLayout_RepeatedRoundTrips_PreserveSemanticSnapshot() + { + var sourceItems = RuntimeItems.CreateSource(); + var sourceView = CreateCompleteRuntimeView(sourceItems); + var expectedSnapshot = CreateSemanticSnapshot(GetManager(sourceView).Layout); + + var firstRestore = RestoreLayout(SaveLayout(sourceView), sourceItems); + var firstSnapshot = CreateSemanticSnapshot(firstRestore.Manager.Layout); + var secondRestore = RestoreLayout( + SaveLayout(firstRestore.View), + firstRestore.Items); + var secondSnapshot = CreateSemanticSnapshot(secondRestore.Manager.Layout); + + Assert.AreEqual(expectedSnapshot, firstSnapshot); + Assert.AreEqual(expectedSnapshot, secondSnapshot); + Assert.AreEqual(2, secondRestore.DocumentCallbacks.Count); + Assert.AreEqual(6, secondRestore.ToolCallbacks.Count); + } + + // Intent: tolerate missing extension content without discarding models that still resolve. + [TestMethod] + public void LoadLayout_UnresolvedContentId_CancelsMissingModelAndRestoresKnownContent() + { + var sourceView = new ShellView(); + var sourceManager = GetManager(sourceView); + var knownSource = new PersistentTestDocument("Known", "known"); + var missingSource = new PersistentTestDocument("Missing", "missing"); + var pane = new LayoutDocumentPane(); + pane.Children.Add(CreateDocument(knownSource)); + pane.Children.Add(CreateDocument(missingSource)); + sourceManager.Layout = new LayoutRoot + { + RootPanel = new LayoutPanel(pane) + }; + var bytes = SaveLayout(sourceView); + var restoredKnown = new PersistentTestDocument("Restored known", "restored"); + var targetView = new ShellView(); + var callbacks = new List(); + + using (var stream = new MemoryStream(bytes, false)) + { + targetView.LoadLayout( + stream, + tool => Assert.Fail("No tool callback was expected."), + callbacks.Add, + new Dictionary + { + { knownSource.ContentId, restoredKnown } + }); + } + + var targetManager = GetManager(targetView); + var restoredPane = targetManager.Layout.Descendents() + .OfType() + .Single(); + var restoredModel = Assert.ContainsSingle(restoredPane.Children); + Assert.AreEqual(restoredKnown.ContentId, restoredModel.ContentId); + Assert.AreSame(restoredKnown, restoredModel.Content); + Assert.AreEqual(1, callbacks.Count); + Assert.AreSame(restoredKnown, callbacks[0]); + Assert.DoesNotContain( + missingSource.ContentId, + targetManager.Layout.Descendents() + .OfType() + .Select(x => x.ContentId) + .ToArray()); + } + + // Intent: reject corrupt layouts before callbacks or partial replacement expose bad state. + [TestMethod] + public void LoadLayout_MalformedXml_ThrowsInvalidOperationWithValueArgumentAndNoCallbacks() + { + var view = new ShellView(); + var manager = GetManager(view); + var originalLayout = manager.Layout; + var documentCallbacks = 0; + var toolCallbacks = 0; + var malformedXml = Encoding.UTF8.GetBytes( + ""); + + using (var stream = new MemoryStream(malformedXml, false)) + { + var exception = Assert.ThrowsExactly( + () => view.LoadLayout( + stream, + tool => toolCallbacks++, + document => documentCallbacks++, + new Dictionary())); + var baseException = Assert.IsInstanceOfType( + exception.GetBaseException()); + Assert.AreEqual("value", baseException.ParamName); + } + + Assert.AreEqual(0, documentCallbacks); + Assert.AreEqual(0, toolCallbacks); + Assert.AreSame(originalLayout, manager.Layout); + } + + // Intent: exercise the complete Gemini envelope because AvalonDock XML alone is not state. + [TestMethod] + public async Task WholeFileBoundary_RealLayoutPayload_RoundTripsWithoutUsingApplicationState() + { + using (var directory = new TestDirectory()) + { + var statePath = directory.GetPath("avalondock-460-state.bin"); + var sourceItems = RuntimeItems.CreateSource(); + var sourceView = CreateCompleteRuntimeView(sourceItems); + var sourceShell = new TestShell( + sourceItems.Documents, + sourceItems.Tools); + var persister = new LayoutItemStatePersister(); + + var saveResult = persister.SaveState( + sourceShell, + sourceView, + statePath); + + Assert.AreEqual(LayoutItemStateSaveStatus.Success, saveResult.Status); + Assert.IsTrue(File.Exists(statePath)); + using (var reader = new BinaryReader(File.OpenRead(statePath))) + Assert.AreEqual(8, reader.ReadInt32()); + + var restoredItems = RuntimeItems.CreateForRestore(sourceItems); + var documentQueue = new Queue( + restoredItems.Documents); + var toolQueue = new Queue( + restoredItems.Tools); + var restoredShell = new TestShell(); + var restoredView = new ShellView(); + LayoutItemStateLoadResult loadResult; + + using (new IoCOverrideScope( + (type, key) => + { + if (type == typeof(PersistentTestDocument)) + return documentQueue.Dequeue(); + if (type == typeof(PersistentTestTool)) + return toolQueue.Dequeue(); + return null; + }, + type => Enumerable.Empty(), + instance => { })) + { + loadResult = await persister.LoadStateAsync( + restoredShell, + restoredView, + statePath); + } + + Assert.AreEqual(LayoutItemStateLoadStatus.Success, loadResult.Status); + Assert.AreEqual(2, loadResult.RestorePlan.Documents.Count); + Assert.AreEqual(5, loadResult.RestorePlan.VisibleTools.Count); + Assert.AreSame( + restoredItems.HiddenTool, + loadResult.RestorePlan.SelectedItem); + Assert.AreEqual(2, restoredShell.Documents.Count); + Assert.AreEqual(6, restoredShell.Tools.Count); + Assert.AreEqual( + sourceItems.PrimaryDocument.StateMarker, + restoredItems.PrimaryDocument.StateMarker); + Assert.AreEqual( + sourceItems.FloatingTool.StateMarker, + restoredItems.FloatingTool.StateMarker); + Assert.IsFalse(restoredItems.HiddenTool.IsVisible); + Assert.AreNotEqual( + Path.GetFullPath("ApplicationState.bin"), + Path.GetFullPath(statePath)); + } + } + + private static FixtureLoadResult LoadFixture() + { + _ = Application.ResourceAssembly; + var manager = new DockingManager(); + var callbackIds = new List(); + var serializer = new XmlLayoutSerializer(manager); + serializer.LayoutSerializationCallback += (sender, args) => + { + callbackIds.Add(args.Model.ContentId); + args.Content = "restored:" + args.Model.ContentId; + }; + using (var stream = new FileStream( + GetFixturePath(), + FileMode.Open, + FileAccess.Read, + FileShare.Read)) + { + serializer.Deserialize(stream); + } + + return new FixtureLoadResult(manager, callbackIds); + } + + private static string GetFixturePath() + { + return Path.Combine( + AppContext.BaseDirectory, + "TestData", + "AvalonDock460", + "CompleteLayout.xml"); + } + + private static ShellView CreateCompleteRuntimeView(RuntimeItems items) + { + _ = Application.ResourceAssembly; + var view = new ShellView(); + var manager = GetManager(view); + var primaryDocument = CreateDocument(items.PrimaryDocument); + var secondaryDocument = CreateDocument(items.SecondaryDocument); + var documents = new LayoutDocumentPane(); + documents.Children.Add(primaryDocument); + documents.Children.Add(secondaryDocument); + + var primaryLeft = CreateAnchorable(items.PrimaryLeftTool); + var secondaryLeft = CreateAnchorable(items.SecondaryLeftTool); + var left = new LayoutAnchorablePane(primaryLeft) + { + Name = "LeftPane", + DockWidth = new GridLength(240, GridUnitType.Pixel) + }; + left.Children.Add(secondaryLeft); + + var rightModel = CreateAnchorable(items.RightTool); + var right = new LayoutAnchorablePane(rightModel) + { + Name = "RightPane", + DockWidth = new GridLength(310, GridUnitType.Pixel) + }; + var bottomModel = CreateAnchorable(items.BottomTool); + var bottom = new LayoutAnchorablePane(bottomModel) + { + Name = "BottomPane", + DockHeight = new GridLength(180, GridUnitType.Pixel) + }; + var horizontal = new LayoutPanel + { + Orientation = Orientation.Horizontal + }; + horizontal.Children.Add(left); + horizontal.Children.Add(documents); + horizontal.Children.Add(right); + var vertical = new LayoutPanel + { + Orientation = Orientation.Vertical + }; + vertical.Children.Add(horizontal); + vertical.Children.Add(bottom); + var root = new LayoutRoot + { + RootPanel = vertical + }; + + var floatingModel = CreateAnchorable(items.FloatingTool); + floatingModel.FloatingLeft = 120; + floatingModel.FloatingTop = 80; + floatingModel.FloatingWidth = 420; + floatingModel.FloatingHeight = 260; + var floatingPane = new LayoutAnchorablePane(floatingModel) + { + Name = "FloatingPane" + }; + root.FloatingWindows.Add(new LayoutAnchorableFloatingWindow + { + RootPanel = new LayoutAnchorablePaneGroup(floatingPane) + }); + + var hiddenModel = CreateAnchorable(items.HiddenTool); + root.Hidden.Add(hiddenModel); + items.HiddenTool.IsVisible = false; + + manager.Layout = root; + primaryDocument.IsSelected = false; + secondaryDocument.IsSelected = true; + primaryLeft.IsSelected = false; + secondaryLeft.IsSelected = true; + rightModel.IsSelected = true; + bottomModel.IsSelected = true; + floatingModel.IsSelected = true; + hiddenModel.IsSelected = true; + secondaryDocument.IsActive = true; + return view; + } + + private static RestoredRuntime RestoreLayout( + byte[] bytes, + RuntimeItems sourceItems) + { + var restoredItems = RuntimeItems.CreateForRestore(sourceItems); + var view = new ShellView(); + var documentCallbacks = new List(); + var toolCallbacks = new List(); + using (var stream = new MemoryStream(bytes, false)) + { + view.LoadLayout( + stream, + toolCallbacks.Add, + documentCallbacks.Add, + restoredItems.SerializedContentMap); + } + + return new RestoredRuntime( + view, + GetManager(view), + restoredItems, + documentCallbacks, + toolCallbacks); + } + + private static byte[] SaveLayout(ShellView view) + { + using (var stream = new MemoryStream()) + { + view.SaveLayout(stream); + return stream.ToArray(); + } + } + + private static DockingManager GetManager(ShellView view) + { + return Assert.IsInstanceOfType(view.FindName("Manager")); + } + + private static LayoutDocument CreateDocument(PersistentTestDocument document) + { + return new LayoutDocument + { + Content = document, + ContentId = document.ContentId, + Title = document.DisplayName + }; + } + + private static LayoutAnchorable CreateAnchorable(PersistentTestTool tool) + { + return new LayoutAnchorable + { + Content = tool, + ContentId = tool.ContentId, + Title = tool.DisplayName + }; + } + + private static string CreateSemanticSnapshot(LayoutRoot root) + { + var builder = new StringBuilder(); + AppendElement(builder, root.RootPanel); + builder.Append("|hidden:"); + foreach (var item in root.Hidden.OrderBy(x => x.ContentId)) + AppendContent(builder, item); + builder.Append("|floating:"); + foreach (var window in root.FloatingWindows) + { + AppendElement( + builder, + Assert.IsInstanceOfType( + window).RootPanel); + } + return builder.ToString(); + } + + private static void AppendElement( + StringBuilder builder, + ILayoutElement element) + { + var panel = element as LayoutPanel; + if (panel != null) + { + builder.Append("panel(").Append(panel.Orientation).Append(")["); + foreach (var child in panel.Children) + AppendElement(builder, child); + builder.Append(']'); + return; + } + + var documentPane = element as LayoutDocumentPane; + if (documentPane != null) + { + builder.Append("documents["); + foreach (var child in documentPane.Children) + AppendContent(builder, child); + builder.Append(']'); + return; + } + + var anchorablePane = element as LayoutAnchorablePane; + if (anchorablePane != null) + { + builder.Append("tools(") + .Append(anchorablePane.Name) + .Append(',') + .Append(anchorablePane.DockWidth.Value) + .Append(',') + .Append(anchorablePane.DockWidth.GridUnitType) + .Append(',') + .Append(anchorablePane.DockHeight.Value) + .Append(',') + .Append(anchorablePane.DockHeight.GridUnitType) + .Append(")["); + foreach (var child in anchorablePane.Children) + AppendContent(builder, child); + builder.Append(']'); + return; + } + + var group = element as LayoutAnchorablePaneGroup; + if (group != null) + { + builder.Append("group(").Append(group.Orientation).Append(")["); + foreach (var child in group.Children) + AppendElement(builder, child); + builder.Append(']'); + return; + } + + Assert.Fail("Unexpected layout element type: " + element.GetType().FullName); + } + + private static void AppendContent( + StringBuilder builder, + LayoutContent content) + { + builder.Append(content.GetType().Name) + .Append("(title=") + .Append(content.Title) + .Append(",selected=") + .Append(content.IsSelected) + .Append(",floating=") + .Append(content.IsFloating); + var anchorable = content as LayoutAnchorable; + if (anchorable != null) + { + builder.Append(",visible=") + .Append(anchorable.IsVisible) + .Append(",bounds=") + .Append(anchorable.FloatingLeft) + .Append(',') + .Append(anchorable.FloatingTop) + .Append(',') + .Append(anchorable.FloatingWidth) + .Append(',') + .Append(anchorable.FloatingHeight); + } + builder.Append(')'); + } + + private sealed class FixtureLoadResult + { + public FixtureLoadResult( + DockingManager manager, + List callbackContentIds) + { + Manager = manager; + CallbackContentIds = callbackContentIds; + } + + public DockingManager Manager { get; } + + public List CallbackContentIds { get; } + } + + private sealed class RestoredRuntime + { + public RestoredRuntime( + ShellView view, + DockingManager manager, + RuntimeItems items, + List documentCallbacks, + List toolCallbacks) + { + View = view; + Manager = manager; + Items = items; + DocumentCallbacks = documentCallbacks; + ToolCallbacks = toolCallbacks; + } + + public ShellView View { get; } + + public DockingManager Manager { get; } + + public RuntimeItems Items { get; } + + public List DocumentCallbacks { get; } + + public List ToolCallbacks { get; } + } + + private sealed class RuntimeItems + { + private RuntimeItems( + PersistentTestDocument primaryDocument, + PersistentTestDocument secondaryDocument, + PersistentTestTool primaryLeftTool, + PersistentTestTool secondaryLeftTool, + PersistentTestTool rightTool, + PersistentTestTool bottomTool, + PersistentTestTool floatingTool, + PersistentTestTool hiddenTool) + { + PrimaryDocument = primaryDocument; + SecondaryDocument = secondaryDocument; + PrimaryLeftTool = primaryLeftTool; + SecondaryLeftTool = secondaryLeftTool; + RightTool = rightTool; + BottomTool = bottomTool; + FloatingTool = floatingTool; + HiddenTool = hiddenTool; + Documents = new[] { PrimaryDocument, SecondaryDocument }; + Tools = new[] + { + PrimaryLeftTool, + SecondaryLeftTool, + RightTool, + BottomTool, + FloatingTool, + HiddenTool + }; + SerializedContentMap = new Dictionary(); + } + + public PersistentTestDocument PrimaryDocument { get; } + + public PersistentTestDocument SecondaryDocument { get; } + + public PersistentTestTool PrimaryLeftTool { get; } + + public PersistentTestTool SecondaryLeftTool { get; } + + public PersistentTestTool RightTool { get; } + + public PersistentTestTool BottomTool { get; } + + public PersistentTestTool FloatingTool { get; } + + public PersistentTestTool HiddenTool { get; } + + public PersistentTestDocument[] Documents { get; } + + public PersistentTestTool[] Tools { get; } + + public Dictionary SerializedContentMap { get; } + + public static RuntimeItems CreateSource() + { + return new RuntimeItems( + new PersistentTestDocument("Primary document", "document-primary"), + new PersistentTestDocument("Secondary document", "document-secondary"), + new PersistentTestTool( + "Primary left tool", + "tool-left-primary", + PaneLocation.Left), + new PersistentTestTool( + "Secondary left tool", + "tool-left-secondary", + PaneLocation.Left), + new PersistentTestTool( + "Right tool", + "tool-right", + PaneLocation.Right), + new PersistentTestTool( + "Bottom tool", + "tool-bottom", + PaneLocation.Bottom), + new PersistentTestTool( + "Floating tool", + "tool-floating", + PaneLocation.Right), + new PersistentTestTool( + "Hidden tool", + "tool-hidden", + PaneLocation.Left)); + } + + public static RuntimeItems CreateForRestore(RuntimeItems source) + { + var restored = new RuntimeItems( + new PersistentTestDocument("Primary document", "unrestored"), + new PersistentTestDocument("Secondary document", "unrestored"), + new PersistentTestTool( + "Primary left tool", + "unrestored", + PaneLocation.Left), + new PersistentTestTool( + "Secondary left tool", + "unrestored", + PaneLocation.Left), + new PersistentTestTool( + "Right tool", + "unrestored", + PaneLocation.Right), + new PersistentTestTool( + "Bottom tool", + "unrestored", + PaneLocation.Bottom), + new PersistentTestTool( + "Floating tool", + "unrestored", + PaneLocation.Right), + new PersistentTestTool( + "Hidden tool", + "unrestored", + PaneLocation.Left)); + + for (var i = 0; i < source.Documents.Length; i++) + { + restored.SerializedContentMap.Add( + source.Documents[i].ContentId, + restored.Documents[i]); + } + for (var i = 0; i < source.Tools.Length; i++) + { + restored.SerializedContentMap.Add( + source.Tools[i].ContentId, + restored.Tools[i]); + } + return restored; + } + } + + private sealed class PersistentTestDocument : Document + { + public PersistentTestDocument(string displayName, string stateMarker) + { + DisplayName = displayName; + StateMarker = stateMarker; + } + + public string StateMarker { get; private set; } + + public override bool ShouldReopenOnStart => true; + + public override void LoadState(BinaryReader reader) + { + StateMarker = reader.ReadString(); + } + + public override void SaveState(BinaryWriter writer) + { + writer.Write(StateMarker); + } + } + + private sealed class PersistentTestTool : Tool + { + public PersistentTestTool( + string displayName, + string stateMarker, + PaneLocation preferredLocation) + { + DisplayName = displayName; + StateMarker = stateMarker; + PreferredLocation = preferredLocation; + } + + public string StateMarker { get; private set; } + + public override PaneLocation PreferredLocation { get; } + + public override void LoadState(BinaryReader reader) + { + StateMarker = reader.ReadString(); + } + + public override void SaveState(BinaryWriter writer) + { + writer.Write(StateMarker); + } + } + + private sealed class TestDirectory : IDisposable + { + public TestDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "gemini-avalondock-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string GetPath(string fileName) + { + return System.IO.Path.Combine(Path, fileName); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + Directory.Delete(Path, true); + } + } + + private sealed class TestShell : Screen, IShell + { + public TestShell( + IEnumerable documents = null, + IEnumerable tools = null) + { + Documents = new BindableCollection( + documents ?? Enumerable.Empty()); + Tools = new BindableCollection( + tools ?? Enumerable.Empty()); + } + + public event EventHandler ActiveDocumentChanging + { + add { } + remove { } + } + + public event EventHandler ActiveDocumentChanged + { + add { } + remove { } + } + + public bool ShowFloatingWindowsInTaskbar { get; set; } + + public Gemini.Modules.MainMenu.IMenu MainMenu => null; + + public Gemini.Modules.ToolBars.IToolBars ToolBars => null; + + public Gemini.Modules.StatusBar.IStatusBar StatusBar => null; + + public ILayoutItem ActiveLayoutItem { get; set; } + + public IDocument ActiveItem => ActiveLayoutItem as IDocument; + + public IObservableCollection Documents { get; } + + public IObservableCollection Tools { get; } + + public Task InitializationTask => Task.CompletedTask; + + public bool RegisterTool(ITool tool) + { + if (Tools.Contains(tool)) + return false; + Tools.Add(tool); + return true; + } + + public Task ShowToolAsync() where TTool : ITool + { + return Task.CompletedTask; + } + + public Task ShowToolAsync(ITool model) + { + return Task.CompletedTask; + } + + public Task CloseToolAsync(ITool tool) + { + return Task.CompletedTask; + } + + public Task OpenDocumentAsync(IDocument model) + { + return Task.CompletedTask; + } + + public Task CloseDocumentAsync(IDocument document) + { + return Task.CompletedTask; + } + + public void Close() + { + } + } + } +} diff --git a/src/Gemini.Tests/Modules/Shell/AvalonDockThemeTests.cs b/src/Gemini.Tests/Modules/Shell/AvalonDockThemeTests.cs new file mode 100644 index 00000000..f89948f2 --- /dev/null +++ b/src/Gemini.Tests/Modules/Shell/AvalonDockThemeTests.cs @@ -0,0 +1,370 @@ +using System; +using System.Collections; +using System.ComponentModel; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Media; +using AvalonDock.Converters; +using Gemini.Framework.Themes; +using Gemini.Modules.Inspector.Controls; +using Gemini.Modules.Shell.Converters; +using Gemini.Modules.Shell.Views; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Xceed.Wpf.Toolkit.Core.Converters; + +namespace Gemini.Tests.Modules.Shell +{ + [STATestClass] + [DoNotParallelize] + public sealed class AvalonDockThemeTests + { + // Intent: preserve vendor-first resource ordering so Gemini overrides remain authoritative. + [TestMethod] + [DataRow("LightTheme", "LightTheme.xaml", "#FFEEEEF2")] + [DataRow("DarkTheme", "DarkTheme.xaml", "#FF2D2D30")] + [DataRow("BlueTheme", "BlueTheme.xaml", "#FFD6DBE9")] + public void ApplicationResources_VendorAndGeminiUris_LoadInDeclaredOrder( + string themeTypeName, + string resourceName, + string expectedMenuBackground) + { + EnsurePackUriParser(); + var theme = CreateTheme(themeTypeName); + + var resourceUris = theme.ApplicationResources.ToArray(); + var vendorDictionary = LoadDictionary(resourceUris[0]); + var geminiDictionary = LoadDictionary(resourceUris[1]); + + Assert.AreEqual(2, resourceUris.Length); + Assert.AreEqual( + "pack://application:,,,/AvalonDock.Themes.VS2013;component/" + resourceName, + resourceUris[0].OriginalString); + Assert.AreEqual( + "pack://application:,,,/Gemini;component/Themes/VS2013/" + resourceName, + resourceUris[1].OriginalString); + Assert.AreEqual(2, vendorDictionary.MergedDictionaries.Count); + Assert.AreEqual(1, geminiDictionary.MergedDictionaries.Count); + Assert.IsInstanceOfType(vendorDictionary["DockAnchorableRight"]); + Assert.AreEqual( + (Color)ColorConverter.ConvertFromString(expectedMenuBackground), + Assert.IsInstanceOfType( + geminiDictionary["MenuDefaultBackground"]).Color); + Assert.AreEqual( + typeof(ButtonBase), + Assert.IsInstanceOfType