diff --git a/.github/workflows/Integration.yml b/.github/workflows/Integration.yml
index e324cb8f..ecc72412 100644
--- a/.github/workflows/Integration.yml
+++ b/.github/workflows/Integration.yml
@@ -13,3 +13,11 @@ jobs:
with:
dotnet-version: '10.0.x'
run-tests: true
+
+ artifact-contracts:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Test fail-closed release artifact contracts
+ shell: pwsh
+ run: .\scripts\test-release-artifact-contract.ps1
diff --git a/.github/workflows/NuGetPublish.yml b/.github/workflows/NuGetPublish.yml
index d8860296..b2909653 100644
--- a/.github/workflows/NuGetPublish.yml
+++ b/.github/workflows/NuGetPublish.yml
@@ -1,4 +1,4 @@
-name: Publish to NuGet.org
+name: Publish validated release to NuGet.org
on:
release:
@@ -6,72 +6,44 @@ on:
permissions:
contents: read
+ actions: read
id-token: write
+concurrency:
+ group: nuget-${{ github.event.release.tag_name }}
+ cancel-in-progress: false
+
jobs:
publish:
+ if: ${{ !github.event.release.prerelease && !github.event.release.draft }}
runs-on: windows-latest
- timeout-minutes: 90
-
+ timeout-minutes: 30
steps:
- - name: Checkout
+ - name: Checkout release tag
uses: actions/checkout@v4
with:
+ ref: ${{ github.event.release.tag_name }}
fetch-depth: 0
-
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
-
- - name: Extract version from tag
- shell: pwsh
- run: |
- $version = $env:GITHUB_REF_NAME -replace '^v', ''
- "VERSION=$version" >> $env:GITHUB_ENV
-
- - name: Restore
- run: dotnet restore WPF-Framework.sln
-
- - name: Build
+ - name: Download and validate exact downstream-approved artifact
shell: pwsh
- run: dotnet build WPF-Framework.sln -c Release --no-restore /p:Version=$env:VERSION
-
- - name: Test
env:
- VSTEST_CONNECTION_TIMEOUT: '600'
- run: dotnet test WPF-Framework.sln -c Release --no-build
-
- - name: Pack
+ GITHUB_TOKEN: ${{ github.token }}
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ run: .\scripts\download-validated-release.ps1 -Repository $env:GITHUB_REPOSITORY -Tag $env:RELEASE_TAG -Directory validated-packages
+ - name: Verify existing package versions before login
shell: pwsh
- run: .\scripts\pack-wpf-framework.ps1 -Configuration Release -Version $env:VERSION -OutputDirectory packages -SkipRestore -SkipBuild
-
+ run: .\scripts\publish-validated-release.ps1 -Directory validated-packages -ReceiptPath downstream-validation.json
- name: NuGet login
uses: NuGet/login@v1
id: login
with:
user: USACE-RMC
-
- - name: Push to NuGet.org
+ - name: Publish validated packages
shell: pwsh
env:
NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }}
- run: |
- $packageIds = @(
- "RMC.Wpf.Framework.Core",
- "RMC.Wpf.Framework.Models",
- "RMC.Wpf.Framework.Support",
- "RMC.Wpf.Framework.Controls"
- )
-
- foreach ($packageId in $packageIds) {
- $packagePath = Join-Path "packages" "$packageId.$env:VERSION.nupkg"
- if (-not (Test-Path $packagePath)) {
- throw "Missing package: $packagePath"
- }
-
- dotnet nuget push $packagePath --api-key $env:NUGET_API_KEY --source "https://api.nuget.org/v3/index.json" --skip-duplicate
- if ($LASTEXITCODE -ne 0) {
- throw "NuGet push failed for $packagePath."
- }
- }
+ run: .\scripts\publish-validated-release.ps1 -Directory validated-packages -ReceiptPath downstream-validation.json -Push
diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml
index 90c22d0c..1add40c3 100644
--- a/.github/workflows/Release.yml
+++ b/.github/workflows/Release.yml
@@ -47,9 +47,18 @@ jobs:
shell: pwsh
run: .\scripts\pack-wpf-framework.ps1 -Configuration Release -Version $env:VERSION -OutputDirectory packages -SkipRestore -SkipBuild
+ - name: Validate artifact contracts
+ shell: pwsh
+ run: .\scripts\test-release-artifact-contract.ps1
+
+ - name: Write immutable release manifest
+ shell: pwsh
+ run: .\scripts\write-release-manifest.ps1 -Directory packages -Repository $env:GITHUB_REPOSITORY -Tag $env:GITHUB_REF_NAME -Version $env:VERSION -CommitSHA $env:GITHUB_SHA -RunID $env:GITHUB_RUN_ID -RunAttempt $env:GITHUB_RUN_ATTEMPT
+
- name: Upload packages
uses: actions/upload-artifact@v4
with:
- name: wpf-framework-packages
- path: packages/*.nupkg
- retention-days: 30
+ name: wpf-framework-packages-${{ github.run_id }}-${{ github.run_attempt }}
+ path: packages/*
+ if-no-files-found: error
+ retention-days: 90
diff --git a/CITATION.cff b/CITATION.cff
index 12829758..3e89e3fd 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -2,8 +2,8 @@ cff-version: 1.2.0
message: "If you use this software, please cite it as below."
type: software
title: "WPF Framework"
-version: "1.0.4"
-date-released: "2026-07-17"
+version: "1.0.5"
+date-released: "2026-10-07"
license: 0BSD
repository-code: "https://github.com/USACE-RMC/WPF-Framework"
url: "https://github.com/USACE-RMC/WPF-Framework"
diff --git a/Directory.Build.props b/Directory.Build.props
index cb8e22a9..b8b2a44a 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -14,9 +14,9 @@
https://github.com/USACE-RMC/WPF-Framework
https://github.com/USACE-RMC/WPF-Framework
git
- 1.0.4
- 1.0.4.0
- 1.0.4.0
+ 1.0.5
+ 1.0.5.0
+ 1.0.5.0
false
false
diff --git a/Directory.Packages.props b/Directory.Packages.props
index cc7d0081..b16fda97 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,7 +9,7 @@
1.1
1.5.0
17.12.0
- 10.0.102
+ 10.0.111
3.5.2
3.5.2
4.2.1
diff --git a/README.md b/README.md
index a2a41f02..952020bb 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@ dotnet test WPF-Framework.sln
### Build Packages
```bash
-.\scripts\pack-wpf-framework.ps1 -Configuration Release -Version 1.0.4
+.\scripts\pack-wpf-framework.ps1 -Configuration Release -Version 1.0.5
```
This creates and validates the following NuGet packages in `artifacts/packages/`:
diff --git a/codemeta.json b/codemeta.json
index 66128ddb..4b7a3add 100644
--- a/codemeta.json
+++ b/codemeta.json
@@ -3,9 +3,9 @@
"@type": "SoftwareSourceCode",
"name": "WPF Framework",
"description": "A free and open-source .NET 10 application framework for building Windows desktop project management applications. Provides a docking application shell with theming, project explorer, undo/redo, and specialized controls for charting, numeric input, databases, expression parsing, and directed acyclic graphs.",
- "version": "1.0.4",
+ "version": "1.0.5",
"dateCreated": "2025-12-29",
- "dateModified": "2026-07-17",
+ "dateModified": "2026-10-07",
"license": "https://spdx.org/licenses/0BSD",
"codeRepository": "https://github.com/USACE-RMC/WPF-Framework",
"issueTracker": "https://github.com/USACE-RMC/WPF-Framework/issues",
diff --git a/docs/getting-started.md b/docs/getting-started.md
index d778470b..6495fa9d 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -30,7 +30,7 @@ For NuGet consumption, reference the Controls bundle. It brings in Core, Models,
```xml
-
+
```
diff --git a/scripts/download-validated-release.ps1 b/scripts/download-validated-release.ps1
new file mode 100644
index 00000000..dac9461d
--- /dev/null
+++ b/scripts/download-validated-release.ps1
@@ -0,0 +1,49 @@
+<#
+.SYNOPSIS
+Downloads only the receipt-approved successful Release artifact and verifies its content.
+#>
+param(
+ [Parameter(Mandatory)][string]$Repository,
+ [Parameter(Mandatory)][string]$Tag,
+ [Parameter(Mandatory)][string]$Directory
+)
+. (Join-Path $PSScriptRoot 'release-artifact-contract.ps1')
+# The current checkout is the release tag; resolve annotated tags to their commit.
+$sha = & git rev-parse "$Tag^{commit}"
+if ($LASTEXITCODE -ne 0) { throw 'Cannot peel release tag.' }
+$head = & git rev-parse HEAD
+if ($LASTEXITCODE -ne 0) { throw 'Cannot resolve checkout.' }
+Assert-ReleaseEqual $head $sha 'Checkout/tag SHA'
+if (Test-Path -LiteralPath $Directory) { throw 'Use a new empty artifact destination.' }
+New-Item -ItemType Directory -Path $Directory | Out-Null
+$headers = @{ Authorization = "Bearer $env:GITHUB_TOKEN"; Accept = 'application/vnd.github+json'; 'X-GitHub-Api-Version' = '2022-11-28' }
+$base = "https://api.github.com/repos/$Repository"
+$release = Invoke-RestMethod "$base/releases/tags/$([Uri]::EscapeDataString($Tag))" -Headers $headers
+if ($release.draft -or $release.prerelease) { throw 'Only a published stable release is allowed.' }
+$assets = @($release.assets | Where-Object { $_.name -ceq 'downstream-validation.json' })
+if ($assets.Count -ne 1) { throw 'Exactly one downstream-validation.json release asset is required.' }
+$receiptPath = Join-Path $Directory '..' 'downstream-validation.json'
+$downloadHeaders = $headers.Clone(); $downloadHeaders.Accept = 'application/octet-stream'
+Invoke-WebRequest "$base/releases/assets/$($assets[0].id)" -Headers $downloadHeaders -OutFile $receiptPath | Out-Null
+$receipt = Read-ReleaseJson $receiptPath
+Assert-ReleaseReceipt $receipt $Repository $Tag $sha
+# Fetch exactly the recorded attempt, never the most recent run or artifact with a similar name.
+$run = Invoke-RestMethod "$base/actions/runs/$($receipt.runID)/attempts/$($receipt.runAttempt)" -Headers $headers
+$artifact = Invoke-RestMethod "$base/actions/artifacts/$($receipt.artifactID)" -Headers $headers
+Assert-ReleaseRun $receipt $run $artifact $Repository $Tag $sha $release
+$archivePath = Join-Path $Directory '..' 'release-artifact.zip'
+Invoke-WebRequest "$base/actions/artifacts/$($receipt.artifactID)/zip" -Headers $headers -OutFile $archivePath | Out-Null
+$archive = [IO.Compression.ZipFile]::OpenRead($archivePath)
+try {
+ $seen = @{}
+ $allowed = @('release-manifest.json') + @($receipt.packages | ForEach-Object { $_.filename })
+ if ($archive.Entries.Count -ne 5) { throw 'Unexpected artifact ZIP entry count.' }
+ foreach ($entry in $archive.Entries) {
+ if ($allowed -cnotcontains $entry.FullName -or $seen.ContainsKey($entry.FullName)) { throw 'Unexpected/duplicate artifact ZIP entry.' }
+ $seen[$entry.FullName] = $true
+ [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, (Join-Path $Directory $entry.FullName), $false)
+ }
+}
+finally { $archive.Dispose() }
+$null = Assert-ReleaseArtifact $Directory $receipt (Split-Path $PSScriptRoot)
+Write-Host "Validated exact artifact $($receipt.artifactID), run $($receipt.runID) attempt $($receipt.runAttempt), tag $Tag, SHA $sha."
diff --git a/scripts/pack-wpf-framework.ps1 b/scripts/pack-wpf-framework.ps1
index b86b1f3e..073024c5 100644
--- a/scripts/pack-wpf-framework.ps1
+++ b/scripts/pack-wpf-framework.ps1
@@ -99,10 +99,10 @@ $expectedVersionedEntries = @{
}
$expectedReleaseNotes = @{
- "RMC.Wpf.Framework.Core" = "Coordinated WPF Framework 1.0.4 release; no package-specific functional changes."
- "RMC.Wpf.Framework.Models" = "Coordinated WPF Framework 1.0.4 release; no package-specific functional changes."
- "RMC.Wpf.Framework.Support" = "Coordinated WPF Framework 1.0.4 release; no package-specific functional changes."
- "RMC.Wpf.Framework.Controls" = "Version 1.0.4 updates RMC.Numerics to 2.1.4 and improves NumericControls validation and distribution selector compatibility."
+ "RMC.Wpf.Framework.Core" = "Version 1.0.5 keeps element display names synchronized when names change during undo or redo."
+ "RMC.Wpf.Framework.Models" = "Version 1.0.5 releases SQLite connections when initialization or database operations fail."
+ "RMC.Wpf.Framework.Support" = "Coordinated WPF Framework 1.0.5 release; no package-specific functional changes."
+ "RMC.Wpf.Framework.Controls" = "Version 1.0.5 keeps compaction active until database work completes, reports maintenance errors, and updates FrameworkUI demo workflows."
}
$expectedPackageDependencies = @{
diff --git a/scripts/publish-validated-release.ps1 b/scripts/publish-validated-release.ps1
new file mode 100644
index 00000000..aaf9464f
--- /dev/null
+++ b/scripts/publish-validated-release.ps1
@@ -0,0 +1,56 @@
+<#
+.SYNOPSIS
+Preflights immutable package versions and optionally pushes only validated absent packages.
+#>
+param(
+ [Parameter(Mandatory)][string]$Directory,
+ [Parameter(Mandatory)][string]$ReceiptPath,
+ [switch]$Push
+)
+. (Join-Path $PSScriptRoot 'release-artifact-contract.ps1')
+$receipt = Read-ReleaseJson $ReceiptPath
+Assert-ReleaseReceipt $receipt $receipt.repository $receipt.frameworkTag $receipt.frameworkTagSHA
+$manifest = Assert-ReleaseArtifact $Directory $receipt (Split-Path $PSScriptRoot)
+$pending = @()
+$temp = Join-Path ([IO.Path]::GetTempPath()) "nuget-existing-$([Guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Path $temp | Out-Null
+try {
+ # Preflight every version before any push. All network errors other than 404 are fatal.
+ foreach ($package in $manifest.packages) {
+ $id = $package.id.ToLowerInvariant()
+ $version = $receipt.version.ToLowerInvariant()
+ $url = "https://api.nuget.org/v3-flatcontainer/$id/$version/$id.$version.nupkg"
+ $published = Join-Path $temp $package.filename
+ $exists = $true
+ try { Invoke-WebRequest $url -OutFile $published | Out-Null }
+ catch {
+ if ($null -ne $_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) { $exists = $false }
+ else { throw }
+ }
+ $candidate = Join-Path $Directory $package.filename
+ if ($exists) {
+ Assert-PublishedPackageMatches $candidate $published {
+ param($path)
+ & dotnet nuget verify $path --all
+ if ($LASTEXITCODE -ne 0) { throw 'Published NuGet signature verification failed.' }
+ }
+ Write-Host "Already published with matching signed payload: $($package.filename)"
+ }
+ else { $pending += $candidate }
+ }
+ if ($Push) {
+ if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) { throw 'Missing trusted NuGet API key.' }
+ foreach ($path in $pending) {
+ # No skip-duplicate: a concurrent conflicting publish must fail and be checked on a rerun.
+ & dotnet nuget push $path --api-key $env:NUGET_API_KEY --source 'https://api.nuget.org/v3/index.json'
+ if ($LASTEXITCODE -ne 0) { throw "NuGet publish failed: $path" }
+ }
+ }
+ else { Write-Host "Publication preflight passed; $($pending.Count) packages require publishing." }
+}
+finally {
+ $resolved = [IO.Path]::GetFullPath($temp)
+ $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar)
+ if ((Split-Path $resolved -Parent) -ne $tempRoot -or (Split-Path $resolved -Leaf) -notmatch '^nuget-existing-[a-f0-9]{32}$') { throw 'Unsafe temporary cleanup target.' }
+ Remove-Item -LiteralPath $resolved -Recurse -Force
+}
diff --git a/scripts/release-artifact-contract.ps1 b/scripts/release-artifact-contract.ps1
new file mode 100644
index 00000000..69735f84
--- /dev/null
+++ b/scripts/release-artifact-contract.ps1
@@ -0,0 +1,312 @@
+<#
+.SYNOPSIS
+Defines fail-closed identity, receipt, package and publication comparison contracts.
+#>
+# Shared fail-closed release artifact contracts. No build, restore or package creation is permitted here.
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+$script:ReleasePackageIds = @('RMC.Wpf.Framework.Core','RMC.Wpf.Framework.Models','RMC.Wpf.Framework.Support','RMC.Wpf.Framework.Controls')
+
+<#
+.SYNOPSIS
+Rejects any case-sensitive identity or contract mismatch.
+#>
+function Assert-ReleaseEqual {
+ param($Actual, $Expected, [string]$Label)
+ if ([string]$Actual -cne [string]$Expected) { throw "$Label mismatch: expected '$Expected', received '$Actual'." }
+}
+
+<#
+.SYNOPSIS
+Returns a lower-case SHA256 digest for an immutable evidence file.
+#>
+function Get-ReleaseSha256 {
+ param([string]$Path)
+ (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+}
+
+<#
+.SYNOPSIS
+Requires a stable three-part version and its corresponding tag.
+#>
+function Assert-StableReleaseVersion {
+ param([string]$Tag, [string]$Version)
+ if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw 'Only stable three-part versions are publishable.' }
+ Assert-ReleaseEqual $Tag "v$Version" 'Tag/version'
+}
+
+<#
+.SYNOPSIS
+Reads a JSON evidence document without executing its content.
+#>
+function Read-ReleaseJson {
+ param([string]$Path)
+ Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -Depth 100
+}
+
+<#
+.SYNOPSIS
+Hashes extracted ZIP entries and rejects ambiguous or unsafe entries.
+#>
+function Get-PackageEntryHashes {
+ param([string]$Path, [switch]$RequireSignature)
+ $archive = [IO.Compression.ZipFile]::OpenRead($Path)
+ try {
+ $entries = @{}
+ $seen = @{}
+ $signed = $false
+ foreach ($entry in $archive.Entries) {
+ if ($entry.FullName.EndsWith('/')) { continue }
+ if ($seen.ContainsKey($entry.FullName)) { throw "Duplicate ZIP entry: $($entry.FullName)" }
+ $seen[$entry.FullName] = $true
+ if ($entry.FullName -match '(^/|\\|(^|/)\.\.(/|$))') { throw 'Unsafe ZIP entry.' }
+ if ($entry.FullName -ceq '.signature.p7s') { $signed = $true; continue }
+ $stream = $entry.Open()
+ try {
+ $hash = [Security.Cryptography.SHA256]::Create()
+ try { $entries[$entry.FullName] = [Convert]::ToHexString($hash.ComputeHash($stream)).ToLowerInvariant() }
+ finally { $hash.Dispose() }
+ }
+ finally { $stream.Dispose() }
+ }
+ if ($RequireSignature -and !$signed) { throw 'Published package has no NuGet signature.' }
+ return $entries
+ }
+ finally { $archive.Dispose() }
+}
+
+<#
+.SYNOPSIS
+Reads a unique package nuspec and rejects duplicate dependencies.
+#>
+function Get-PackageMetadata {
+ param([string]$Path)
+ # Also rejects duplicate/path-traversal entries before reading metadata.
+ $null = Get-PackageEntryHashes $Path
+ $archive = [IO.Compression.ZipFile]::OpenRead($Path)
+ try {
+ $nuspecs = @($archive.Entries | Where-Object { $_.FullName -match '^[^/]+\.nuspec$' })
+ if ($nuspecs.Count -ne 1) { throw 'Exactly one root nuspec is required.' }
+ $reader = [IO.StreamReader]::new($nuspecs[0].Open())
+ try { [xml]$xml = $reader.ReadToEnd() } finally { $reader.Dispose() }
+ $metadata = $xml.SelectSingleNode('/*[local-name()="package"]/*[local-name()="metadata"]')
+ if (!$metadata) { throw 'Missing package metadata.' }
+ $groups = Get-NuspecDependencyGroups $metadata
+ return @{ id = $metadata.SelectSingleNode('*[local-name()="id"]').InnerText; version = $metadata.SelectSingleNode('*[local-name()="version"]').InnerText; dependencyGroups = $groups }
+ }
+ finally { $archive.Dispose() }
+}
+
+<#
+.SYNOPSIS
+Preserves exact dependency framework groups, including empty groups, and rejects ambiguous membership.
+#>
+function Get-NuspecDependencyGroups {
+ param([System.Xml.XmlElement]$Metadata, [hashtable]$Values = @{})
+ $containers = @($Metadata.SelectNodes('*[local-name()="dependencies"]'))
+ if ($containers.Count -ne 1) { throw 'Exactly one dependencies container is required.' }
+ $groups = @{}
+ foreach ($group in $containers[0].ChildNodes) {
+ if ($group.NodeType -ne [Xml.XmlNodeType]::Element) { continue }
+ if ($group.LocalName -cne 'group' -or !$group.HasAttribute('targetFramework') -or $group.Attributes.Count -ne 1) { throw 'Only explicit dependency framework groups are allowed.' }
+ $framework = $group.GetAttribute('targetFramework')
+ if ([string]::IsNullOrWhiteSpace($framework) -or $groups.ContainsKey($framework)) { throw 'Missing or duplicate dependency framework group.' }
+ $dependencies = @{}
+ foreach ($dependency in $group.ChildNodes) {
+ if ($dependency.NodeType -ne [Xml.XmlNodeType]::Element) { continue }
+ if ($dependency.LocalName -cne 'dependency' -or !$dependency.HasAttribute('id') -or !$dependency.HasAttribute('version') -or $dependency.Attributes.Count -ne 2) { throw 'Unexpected dependency declaration.' }
+ $id = $dependency.GetAttribute('id')
+ if ([string]::IsNullOrWhiteSpace($id) -or $dependencies.ContainsKey($id)) { throw "Missing or duplicate group dependency: $id" }
+ $value = $dependency.GetAttribute('version')
+ foreach ($token in [regex]::Matches($value, '\$(?[^$]+)\$')) {
+ $key = $token.Groups['name'].Value
+ if (!$Values.ContainsKey($key)) { throw "Unknown dependency variable: $key" }
+ $value = $value.Replace($token.Value, [string]$Values[$key])
+ }
+ if ([string]::IsNullOrWhiteSpace($value) -or $value.Contains('$')) { throw "Unresolved or empty dependency version: $value" }
+ $dependencies[$id] = $value.Replace(' ','')
+ }
+ $groups[$framework] = $dependencies
+ }
+ return $groups
+}
+
+<#
+.SYNOPSIS
+Resolves dependency contracts from tag-source nuspecs and central properties.
+#>
+function Get-ExpectedReleaseDependencies {
+ param([string]$RepositoryRoot, [string]$Id, [string]$Version)
+ [xml]$props = Get-Content -LiteralPath (Join-Path $RepositoryRoot 'Directory.Packages.props') -Raw
+ $values = @{ version = $Version }
+ foreach ($property in $props.Project.PropertyGroup.ChildNodes) {
+ if ($property.NodeType -eq [Xml.XmlNodeType]::Element) { $values[$property.Name] = $property.InnerText }
+ }
+ $templatePath = Join-Path $RepositoryRoot "src/Packaging/$Id/$Id.nuspec"
+ [xml]$template = Get-Content -LiteralPath $templatePath -Raw
+ $metadata = $template.SelectSingleNode('/*[local-name()="package"]/*[local-name()="metadata"]')
+ return Get-NuspecDependencyGroups $metadata $values
+}
+
+<#
+.SYNOPSIS
+Normalizes numeric NuGet version components without changing range boundaries or constraint operators.
+#>
+function Normalize-ReleaseDependencyVersion {
+ param([string]$Version)
+ # NuGet packing pads numeric versions to three parts and omits a zero fourth revision.
+ # Keep brackets, parentheses and commas intact; prerelease/floating text is not rewritten.
+ return [regex]::Replace($Version.Replace(' ',''), '(^|[\[(,])([0-9]+(?:\.[0-9]+){0,3})(?=$|[,\])])', {
+ param($match)
+ $parts = @($match.Groups[2].Value.Split('.') | ForEach-Object { $part = $_.TrimStart('0'); if ($part -eq '') { '0' } else { $part } })
+ while ($parts.Count -lt 3) { $parts += '0' }
+ if ($parts.Count -eq 4 -and $parts[3] -eq '0') { $parts = @($parts[0..2]) }
+ return $match.Groups[1].Value + ($parts -join '.')
+ })
+}
+
+<#
+.SYNOPSIS
+Checks a package ID, version and complete expected dependency set.
+#>
+function Assert-PackageContract {
+ param([string]$Path, [string]$Id, [string]$Version, [string]$RepositoryRoot)
+ $metadata = Get-PackageMetadata $Path
+ Assert-ReleaseEqual $metadata.id $Id 'Package ID'
+ Assert-ReleaseEqual $metadata.version $Version 'Package version'
+ $expected = Get-ExpectedReleaseDependencies $RepositoryRoot $Id $Version
+ Assert-ReleaseEqual $metadata.dependencyGroups.Count $expected.Count 'Dependency framework group count'
+ foreach ($framework in $expected.Keys) {
+ $actualNames = @($metadata.dependencyGroups.Keys | Where-Object { $_ -ceq $framework })
+ if ($actualNames.Count -ne 1) { throw "Missing exact dependency framework group: $framework" }
+ $actualGroup = $metadata.dependencyGroups[$framework]
+ $expectedGroup = $expected[$framework]
+ Assert-ReleaseEqual $actualGroup.Count $expectedGroup.Count "Dependency count in $framework"
+ foreach ($id in $expectedGroup.Keys) {
+ if (@($actualGroup.Keys | Where-Object { $_ -ceq $id }).Count -ne 1) { throw "Missing exact dependency $id in $framework" }
+ Assert-ReleaseEqual (Normalize-ReleaseDependencyVersion $actualGroup[$id]) (Normalize-ReleaseDependencyVersion $expectedGroup[$id]) "Dependency $id in $framework"
+ }
+ }
+}
+
+<#
+.SYNOPSIS
+Requires exact release identity and all four successful downstream gates.
+#>
+function Assert-ReleaseReceipt {
+ param($Receipt, [string]$Repository, [string]$Tag, [string]$CommitSHA)
+ Assert-ReleaseEqual $Receipt.schemaVersion 1 'Receipt schema'
+ Assert-ReleaseEqual $Receipt.repository $Repository 'Receipt repository'
+ Assert-StableReleaseVersion $Tag $Receipt.version
+ Assert-ReleaseEqual $Receipt.frameworkTag $Tag 'Receipt tag'
+ Assert-ReleaseEqual $Receipt.frameworkTagSHA $CommitSHA 'Receipt tag SHA'
+ foreach ($key in @('runID','runAttempt','artifactID')) {
+ if ([string]$Receipt.$key -notmatch '^[1-9]\d*$') { throw "Invalid receipt $key." }
+ }
+ if ([string]$Receipt.manifestSHA256 -notmatch '^[a-f0-9]{64}$') { throw 'Invalid manifest hash.' }
+ if ([string]$Receipt.bestFitCommit -notmatch '^[a-f0-9]{40}$') { throw 'Missing BestFit commit identity.' }
+ foreach ($approval in @($Receipt.compatibility, $Receipt.uiAcceptance)) {
+ Assert-ReleaseEqual $approval.status 'passed' 'Downstream acceptance'
+ if ([string]::IsNullOrWhiteSpace([string]$approval.evidence)) { throw 'Missing acceptance evidence.' }
+ }
+ $requiredSuites = @('RMC.BestFit.Tests','RMC.BestFit.UI.Tests','RMC.BestFit.App.Tests','RMC.BestFit.Api.Tests')
+ if (@($Receipt.testResults).Count -ne $requiredSuites.Count) { throw 'All four mandatory downstream suites are required.' }
+ $seenSuites = @{}
+ foreach ($test in $Receipt.testResults) {
+ if ($requiredSuites -cnotcontains $test.suite -or $seenSuites.ContainsKey($test.suite)) { throw 'Unexpected or duplicate downstream suite.' }
+ $seenSuites[$test.suite] = $true
+ foreach ($count in @('passed','failed','skipped')) {
+ if ([string]$test.$count -notmatch '^(0|[1-9]\d*)$') { throw 'Downstream test counts must be nonnegative integers.' }
+ $parsedCount = 0L
+ if (![long]::TryParse([string]$test.$count, [ref]$parsedCount)) { throw 'Downstream test count exceeds supported range.' }
+ }
+ if ([long]$test.passed -le 0 -or [long]$test.failed -ne 0 -or [long]$test.skipped -ne 0 -or [string]::IsNullOrWhiteSpace([string]$test.evidence)) { throw 'Every mandatory suite must pass without failures or skips and identify evidence.' }
+ }
+ Assert-PackageHashRecords $Receipt.packages $Receipt.version
+}
+
+<#
+.SYNOPSIS
+Requires four uniquely identified packages with safe filenames and hashes.
+#>
+function Assert-PackageHashRecords {
+ param($Packages, [string]$Version)
+ if (@($Packages).Count -ne 4) { throw 'Exactly four package hash records are required.' }
+ $seen = @{}
+ foreach ($package in $Packages) {
+ if ($script:ReleasePackageIds -cnotcontains $package.id -or $seen.ContainsKey($package.id)) { throw 'Unexpected or duplicate package record.' }
+ $seen[$package.id] = $true
+ Assert-ReleaseEqual $package.filename "$($package.id).$Version.nupkg" 'Package filename'
+ if ([string]$package.sha256 -notmatch '^[a-f0-9]{64}$') { throw 'Invalid package hash.' }
+ }
+}
+
+<#
+.SYNOPSIS
+Binds an approved receipt to an exact successful workflow attempt and artifact.
+#>
+function Assert-ReleaseRun {
+ param($Receipt, $Run, $Artifact, [string]$Repository, [string]$Tag, [string]$CommitSHA, $Release)
+ Assert-ReleaseReceipt $Receipt $Repository $Tag $CommitSHA
+ if ($Release.draft -or $Release.prerelease) { throw 'Publishing requires a published stable release.' }
+ Assert-ReleaseEqual $Release.tag_name $Tag 'Release tag'
+ Assert-ReleaseEqual $Run.id $Receipt.runID 'Run ID'
+ Assert-ReleaseEqual $Run.run_attempt $Receipt.runAttempt 'Run attempt'
+ Assert-ReleaseEqual $Run.path '.github/workflows/Release.yml' 'Release workflow'
+ Assert-ReleaseEqual $Run.event 'push' 'Release run event'
+ Assert-ReleaseEqual $Run.head_branch $Tag 'Release run tag'
+ Assert-ReleaseEqual $Run.head_sha $CommitSHA 'Release run SHA'
+ Assert-ReleaseEqual $Run.status 'completed' 'Release run status'
+ Assert-ReleaseEqual $Run.conclusion 'success' 'Release run conclusion'
+ Assert-ReleaseEqual $Run.repository.full_name $Repository 'Run repository'
+ Assert-ReleaseEqual $Artifact.id $Receipt.artifactID 'Artifact ID'
+ Assert-ReleaseEqual $Artifact.name "wpf-framework-packages-$($Receipt.runID)-$($Receipt.runAttempt)" 'Artifact name'
+ if ($Artifact.expired -or [DateTimeOffset]::Parse($Artifact.expires_at) -le [DateTimeOffset]::UtcNow) { throw 'Release artifact expired.' }
+ Assert-ReleaseEqual $Artifact.workflow_run.id $Receipt.runID 'Artifact run ID'
+ Assert-ReleaseEqual $Artifact.workflow_run.head_sha $CommitSHA 'Artifact SHA'
+ Assert-ReleaseEqual $Artifact.workflow_run.head_branch $Tag 'Artifact tag'
+}
+
+<#
+.SYNOPSIS
+Checks artifact contents against immutable manifest and receipt hashes.
+#>
+function Assert-ReleaseArtifact {
+ param([string]$Directory, $Receipt, [string]$RepositoryRoot)
+ $files = @(Get-ChildItem -LiteralPath $Directory -Recurse -File)
+ if ($files.Count -ne 5 -or @(Get-ChildItem -LiteralPath $Directory -Directory).Count -ne 0) { throw 'Artifact must contain exactly four packages and one manifest at its root.' }
+ $manifestPath = Join-Path $Directory 'release-manifest.json'
+ Assert-ReleaseEqual (Get-ReleaseSha256 $manifestPath) $Receipt.manifestSHA256 'Manifest hash'
+ $manifest = Read-ReleaseJson $manifestPath
+ Assert-ReleaseEqual $manifest.schemaVersion 1 'Manifest schema'
+ foreach ($key in @('repository','version','runID','runAttempt')) { Assert-ReleaseEqual $manifest.$key $Receipt.$key "Manifest $key" }
+ Assert-ReleaseEqual $manifest.tag $Receipt.frameworkTag 'Manifest tag'
+ Assert-ReleaseEqual $manifest.commitSHA $Receipt.frameworkTagSHA 'Manifest SHA'
+ Assert-PackageHashRecords $manifest.packages $Receipt.version
+ foreach ($package in $manifest.packages) {
+ $receiptPackage = @($Receipt.packages | Where-Object { $_.id -ceq $package.id })
+ if ($receiptPackage.Count -ne 1) { throw 'Missing receipt package.' }
+ Assert-ReleaseEqual $package.sha256 $receiptPackage[0].sha256 'Receipt package hash'
+ $path = Join-Path $Directory $package.filename
+ Assert-ReleaseEqual (Get-ReleaseSha256 $path) $package.sha256 'Package content hash'
+ Assert-PackageContract $path $package.id $Receipt.version $RepositoryRoot
+ }
+ return $manifest
+}
+
+<#
+.SYNOPSIS
+Requires verified signatures and byte-identical payload and nuspec entries.
+#>
+function Assert-PublishedPackageMatches {
+ param([string]$Candidate, [string]$Published, [scriptblock]$SignatureVerifier)
+ # NuGet.org repository signing changes ZIP bytes; compare all extracted entry hashes including nuspec.
+ if (!$SignatureVerifier) { throw 'A signature verifier is required.' }
+ & $SignatureVerifier $Published
+ $publishedEntries = Get-PackageEntryHashes $Published -RequireSignature
+ $candidateEntries = Get-PackageEntryHashes $Candidate
+ Assert-ReleaseEqual $publishedEntries.Count $candidateEntries.Count 'Published payload count'
+ foreach ($key in $candidateEntries.Keys) {
+ Assert-ReleaseEqual $publishedEntries[$key] $candidateEntries[$key] "Published payload $key"
+ }
+}
diff --git a/scripts/test-release-artifact-contract.ps1 b/scripts/test-release-artifact-contract.ps1
new file mode 100644
index 00000000..e708aa76
--- /dev/null
+++ b/scripts/test-release-artifact-contract.ps1
@@ -0,0 +1,218 @@
+<#
+.SYNOPSIS
+Runs controlled fail-closed release contract fixtures without network access or publishing.
+#>
+# Executable controlled fixtures; no Pester, NuGet push, network or production builds.
+. (Join-Path $PSScriptRoot 'release-artifact-contract.ps1')
+$script:Checks = 0
+$root = Split-Path $PSScriptRoot
+$temp = Join-Path ([IO.Path]::GetTempPath()) "release-contract-$([Guid]::NewGuid().ToString('N'))"
+New-Item -ItemType Directory -Path $temp | Out-Null
+<#
+.SYNOPSIS
+Requires a controlled invalid fixture to be rejected.
+#>
+function Expect-Rejection {
+ param([string]$Name, [scriptblock]$Check)
+ $rejected = $false
+ try { & $Check | Out-Null } catch { $rejected = $true }
+ if (!$rejected) { throw "FAIL: $Name accepted invalid evidence." }
+ $script:Checks++
+ Write-Host "PASS rejection: $Name"
+}
+<#
+.SYNOPSIS
+Deep-copies fixture evidence so mutations remain isolated.
+#>
+function Copy-Fixture {
+ param($Object)
+ $Object | ConvertTo-Json -Depth 30 | ConvertFrom-Json -Depth 30
+}
+<#
+.SYNOPSIS
+Creates a deterministic ZIP and nuspec fixture without real binaries or signatures.
+#>
+function New-FixturePackage {
+ param([string]$Path, [string]$Id, [string]$Version, [switch]$Signature, [switch]$AlterPayload, [switch]$DuplicateEntry, [switch]$BadDependency, [string]$GroupMutation = '')
+ if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path }
+ $groups = Get-ExpectedReleaseDependencies $root $Id $Version
+ $groupXml = ''
+ foreach ($framework in ($groups.Keys | Sort-Object)) {
+ $dependencies = $groups[$framework]
+ if ($BadDependency) { $dependencies['Unexpected.Package'] = '9.0.0' }
+ if ($GroupMutation -eq 'missing-member' -and $dependencies.Count) { $dependencies.Remove(@($dependencies.Keys)[0]) }
+ $dependencyXml = ($dependencies.Keys | Sort-Object | ForEach-Object { '' }) -join ''
+ if ($GroupMutation -eq 'duplicate-member' -and $dependencies.Count) {
+ $member = @($dependencies.Keys)[0]
+ $dependencyXml += ''
+ }
+ $groupXml += '' + $dependencyXml + ''
+ }
+ switch ($GroupMutation) {
+ 'wrong' { $groupXml = $groupXml.Replace('net10.0-windows7.0','net99.0') }
+ 'missing' { $groupXml = '' }
+ 'duplicate' { $groupXml += $groupXml }
+ 'extra' { $groupXml += '' }
+ 'ungrouped' { $groupXml = $dependencyXml }
+ 'split-membership' {
+ $groupXml = '' + $dependencyXml + ''
+ }
+ }
+ $nuspec = '' + $Id + '' + $Version + '' + $groupXml + ''
+ $archive = [IO.Compression.ZipFile]::Open($Path, [IO.Compression.ZipArchiveMode]::Create)
+ try {
+ $items = @(@{ name = "$Id.nuspec"; content = $nuspec }, @{ name = 'lib/net10.0-windows7.0/payload.dll'; content = $(if ($AlterPayload) { 'altered bytes' } else { 'fixture bytes' }) })
+ if ($Signature) { $items += @{ name = '.signature.p7s'; content = 'controlled fixture signature' } }
+ if ($DuplicateEntry) { $items += @{ name = 'lib/net10.0-windows7.0/payload.dll'; content = 'duplicate' } }
+ foreach ($item in $items) {
+ $entry = $archive.CreateEntry($item.name)
+ $writer = [IO.StreamWriter]::new($entry.Open())
+ try { $writer.Write($item.content) } finally { $writer.Dispose() }
+ }
+ }
+ finally { $archive.Dispose() }
+}
+try {
+ $directory = Join-Path $temp 'packages'
+ New-Item -ItemType Directory -Path $directory | Out-Null
+ foreach ($id in $script:ReleasePackageIds) { New-FixturePackage (Join-Path $directory "$id.1.0.5.nupkg") $id '1.0.5' }
+ $sha = 'a' * 40
+ & (Join-Path $PSScriptRoot 'write-release-manifest.ps1') -Directory $directory -Repository 'USACE-RMC/wpf-framework' -Tag 'v1.0.5' -Version '1.0.5' -CommitSHA $sha -RunID '123' -RunAttempt '2'
+ $manifest = Read-ReleaseJson (Join-Path $directory 'release-manifest.json')
+ $receipt = [pscustomobject]@{
+ schemaVersion = 1; repository = 'USACE-RMC/wpf-framework'; frameworkTag = 'v1.0.5'; version = '1.0.5'; frameworkTagSHA = $sha
+ runID = '123'; runAttempt = '2'; artifactID = '456'; manifestSHA256 = Get-ReleaseSha256 (Join-Path $directory 'release-manifest.json')
+ packages = $manifest.packages; bestFitCommit = 'b' * 40
+ testResults = @(@('RMC.BestFit.Tests','RMC.BestFit.UI.Tests','RMC.BestFit.App.Tests','RMC.BestFit.Api.Tests') | ForEach-Object { @{ suite = $_; passed = 1; failed = 0; skipped = 0; evidence = "fixture://tests/$_" } })
+ compatibility = @{ status = 'passed'; evidence = 'fixture://compatibility' }; uiAcceptance = @{ status = 'passed'; evidence = 'fixture://ui' }
+ }
+ $run = [pscustomobject]@{ id = 123; run_attempt = 2; path = '.github/workflows/Release.yml'; event = 'push'; head_branch = 'v1.0.5'; head_sha = $sha; status = 'completed'; conclusion = 'success'; repository = @{ full_name = $receipt.repository } }
+ $artifact = [pscustomobject]@{ id = 456; name = 'wpf-framework-packages-123-2'; expired = $false; expires_at = [DateTimeOffset]::UtcNow.AddDays(1).ToString('o'); workflow_run = @{ id = 123; head_sha = $sha; head_branch = 'v1.0.5' } }
+ $release = [pscustomobject]@{ draft = $false; prerelease = $false; tag_name = 'v1.0.5' }
+ Assert-ReleaseRun $receipt $run $artifact $receipt.repository 'v1.0.5' $sha $release
+ $null = Assert-ReleaseArtifact $directory $receipt $root
+ $script:Checks += 2
+ Write-Host 'PASS exact run and artifact happy paths'
+ foreach ($case in @(@('frameworkTag','v1.0.6'),@('frameworkTagSHA',('c'*40)),@('version','1.0.6'),@('runID','999'),@('runAttempt','9'),@('artifactID','999'),@('repository','another/repository'),@('schemaVersion',2))) {
+ $copy = Copy-Fixture $receipt; $copy.($case[0]) = $case[1]
+ Expect-Rejection "receipt $($case[0])" { Assert-ReleaseRun $copy $run $artifact $receipt.repository 'v1.0.5' $sha $release }
+ }
+ foreach ($case in @(@('id',999),@('run_attempt',9),@('head_sha',('c'*40)),@('head_branch','v1.0.6'),@('path','.github/workflows/Other.yml'),@('event','workflow_dispatch'),@('status','in_progress'),@('conclusion','failure'))) {
+ $copy = Copy-Fixture $run; $copy.($case[0]) = $case[1]
+ Expect-Rejection "run $($case[0])" { Assert-ReleaseRun $receipt $copy $artifact $receipt.repository 'v1.0.5' $sha $release }
+ }
+ foreach ($case in @(@('id',999),@('name','wpf-framework-packages-123-1'),@('expired',$true),@('expires_at',[DateTimeOffset]::UtcNow.AddDays(-1).ToString('o')))) {
+ $copy = Copy-Fixture $artifact; $copy.($case[0]) = $case[1]
+ Expect-Rejection "artifact $($case[0])" { Assert-ReleaseRun $receipt $run $copy $receipt.repository 'v1.0.5' $sha $release }
+ }
+ foreach ($flag in @('draft','prerelease')) {
+ $copy = Copy-Fixture $release; $copy.$flag = $true
+ Expect-Rejection "release $flag" { Assert-ReleaseRun $receipt $run $artifact $receipt.repository 'v1.0.5' $sha $copy }
+ }
+ $copy = Copy-Fixture $artifact; $copy.workflow_run.id = 999
+ Expect-Rejection 'artifact belongs to wrong run' { Assert-ReleaseRun $receipt $run $copy $receipt.repository 'v1.0.5' $sha $release }
+ $copy = Copy-Fixture $artifact; $copy.workflow_run.head_sha = 'c'*40
+ Expect-Rejection 'artifact wrong commit' { Assert-ReleaseRun $receipt $run $copy $receipt.repository 'v1.0.5' $sha $release }
+ $copy = Copy-Fixture $artifact; $copy.workflow_run.head_branch = 'v1.0.6'
+ Expect-Rejection 'artifact wrong tag' { Assert-ReleaseRun $receipt $run $copy $receipt.repository 'v1.0.5' $sha $release }
+ $copy = Copy-Fixture $receipt; $copy.PSObject.Properties.Remove('artifactID')
+ Expect-Rejection 'missing receipt artifact identity' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ Expect-Rejection 'prerelease version' { Assert-StableReleaseVersion 'v1.0.5-rc.1' '1.0.5-rc.1' }
+ $copy = Copy-Fixture $receipt; $copy.compatibility.status = 'failed'
+ Expect-Rejection 'downstream compatibility failed' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ $copy = Copy-Fixture $receipt; $copy.uiAcceptance.evidence = ''
+ Expect-Rejection 'missing UI evidence' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ $copy = Copy-Fixture $receipt; $copy.testResults[0].failed = 1
+ Expect-Rejection 'downstream test failure' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ $copy = Copy-Fixture $receipt; $copy.testResults = @($copy.testResults[0..2])
+ Expect-Rejection 'missing mandatory downstream suite' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ $copy = Copy-Fixture $receipt; $copy.testResults[1].suite = $copy.testResults[0].suite
+ Expect-Rejection 'duplicate downstream suite' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ $copy = Copy-Fixture $receipt; $copy.testResults[0].suite = 'Unknown.Tests'
+ Expect-Rejection 'unknown downstream suite' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ $copy = Copy-Fixture $receipt; $copy.testResults[0].skipped = 1
+ Expect-Rejection 'skipped downstream test' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ foreach ($count in @('passed','failed','skipped')) {
+ $copy = Copy-Fixture $receipt; $copy.testResults[0].$count = 0.1
+ Expect-Rejection "nonintegral downstream $count count" { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ }
+ $copy = Copy-Fixture $receipt; $copy.packages[0].sha256 = 'c'*64
+ Expect-Rejection 'receipt package hash mismatch' { Assert-ReleaseArtifact $directory $copy $root }
+ $copy = Copy-Fixture $receipt; $copy.manifestSHA256 = 'c'*64
+ Expect-Rejection 'manifest hash mismatch' { Assert-ReleaseArtifact $directory $copy $root }
+ $copy = Copy-Fixture $receipt; $copy.packages[1] = $copy.packages[0]
+ Expect-Rejection 'duplicate package records' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ $copy = Copy-Fixture $receipt; $copy.packages = @($copy.packages[0..2])
+ Expect-Rejection 'missing package record' { Assert-ReleaseReceipt $copy $receipt.repository 'v1.0.5' $sha }
+ Set-Content -LiteralPath (Join-Path $directory 'extra.nupkg') -Value 'extra'
+ Expect-Rejection 'extraneous package file' { Assert-ReleaseArtifact $directory $receipt $root }
+ Remove-Item -LiteralPath (Join-Path $directory 'extra.nupkg')
+ $candidate = Join-Path $directory 'RMC.Wpf.Framework.Core.1.0.5.nupkg'
+ $backup = [IO.File]::ReadAllBytes($candidate)
+ Remove-Item -LiteralPath $candidate
+ Expect-Rejection 'missing package file' { Assert-ReleaseArtifact $directory $receipt $root }
+ [IO.File]::WriteAllBytes($candidate, $backup)
+ New-FixturePackage $candidate 'RMC.Wpf.Framework.Core' '1.0.5' -AlterPayload
+ Expect-Rejection 'altered package bytes' { Assert-ReleaseArtifact $directory $receipt $root }
+ [IO.File]::WriteAllBytes($candidate, $backup)
+ $published = Join-Path $temp 'published.nupkg'
+ New-FixturePackage $published 'RMC.Wpf.Framework.Core' '1.0.5' -Signature
+ $script:VerifierCalled = $false
+ Assert-PublishedPackageMatches $candidate $published { param($path); $script:VerifierCalled = $true }
+ if (!$script:VerifierCalled) { throw 'Signature verification was skipped.' }
+ $script:Checks++
+ Write-Host 'PASS signature-verified repacked matching payload is idempotent'
+ Expect-Rejection 'signature verification failed' { Assert-PublishedPackageMatches $candidate $published { param($path); throw 'invalid signature' } }
+ New-FixturePackage $published 'RMC.Wpf.Framework.Core' '1.0.5'
+ Expect-Rejection 'unsigned existing package' { Assert-PublishedPackageMatches $candidate $published { param($path) } }
+ New-FixturePackage $published 'RMC.Wpf.Framework.Core' '1.0.5' -Signature -AlterPayload
+ Expect-Rejection 'existing version conflicting payload' { Assert-PublishedPackageMatches $candidate $published { param($path) } }
+ New-FixturePackage $published 'RMC.Wpf.Framework.Core' '1.0.6' -Signature
+ Expect-Rejection 'existing version conflicting nuspec' { Assert-PublishedPackageMatches $candidate $published { param($path) } }
+ New-FixturePackage $published 'RMC.Wpf.Framework.Core' '1.0.5' -DuplicateEntry
+ Expect-Rejection 'duplicate ZIP entries' { Get-PackageMetadata $published }
+ New-FixturePackage $published 'RMC.Wpf.Framework.Models' '1.0.5' -BadDependency
+ Expect-Rejection 'unexpected dependency' { Assert-PackageContract $published 'RMC.Wpf.Framework.Models' '1.0.5' $root }
+ New-FixturePackage $published 'RMC.Wpf.Framework.Core' '1.0.6'
+ Expect-Rejection 'package nuspec wrong version' { Assert-PackageContract $published 'RMC.Wpf.Framework.Core' '1.0.5' $root }
+ foreach ($pair in @(@('1.1','1.1.0'),@('1','1.0.0'),@('1.1.0.0','1.1.0'),@('[1.1,2.0)','[1.1.0,2.0.0)'),@('[1.1]','[1.1.0]'))) {
+ Assert-ReleaseEqual (Normalize-ReleaseDependencyVersion $pair[0]) (Normalize-ReleaseDependencyVersion $pair[1]) 'Canonical numeric dependency version'
+ $script:Checks++
+ }
+ foreach ($pair in @(@('[1.1,2.0)','[1.1,2.0]'),@('[1.1]','1.1'),@('[1.1,2.0)','(1.1,2.0)'),@('1.1','1.2'),@('1.1.0.1','1.1.0'))) {
+ Expect-Rejection "Changed version constraint $($pair[0]) vs $($pair[1])" { Assert-ReleaseEqual (Normalize-ReleaseDependencyVersion $pair[0]) (Normalize-ReleaseDependencyVersion $pair[1]) 'Dependency constraint' }
+ }
+ New-FixturePackage $published 'RMC.Wpf.Framework.Models' '1.0.5'
+ $zip = [IO.Compression.ZipFile]::Open($published, [IO.Compression.ZipArchiveMode]::Update)
+ try {
+ $entry = $zip.GetEntry('RMC.Wpf.Framework.Models.nuspec')
+ $reader = [IO.StreamReader]::new($entry.Open())
+ try { $canonicalNuspec = $reader.ReadToEnd().Replace('id="ExcelNumberFormat" version="1.1"','id="ExcelNumberFormat" version="1.1.0"') } finally { $reader.Dispose() }
+ $entry.Delete()
+ $writer = [IO.StreamWriter]::new($zip.CreateEntry('RMC.Wpf.Framework.Models.nuspec').Open())
+ try { $writer.Write($canonicalNuspec) } finally { $writer.Dispose() }
+ }
+ finally { $zip.Dispose() }
+ Assert-PackageContract $published 'RMC.Wpf.Framework.Models' '1.0.5' $root
+ $script:Checks++
+ Write-Host 'PASS packed canonical ExcelNumberFormat1.1.0 dependency'
+ foreach ($id in $script:ReleasePackageIds) {
+ foreach ($mutation in @('wrong','missing','duplicate','extra')) {
+ New-FixturePackage $published $id '1.0.5' -GroupMutation $mutation
+ Expect-Rejection "$id $mutation framework group" { Assert-PackageContract $published $id '1.0.5' $root }
+ }
+ }
+ foreach ($id in @('RMC.Wpf.Framework.Models','RMC.Wpf.Framework.Controls')) {
+ foreach ($mutation in @('ungrouped','split-membership','missing-member','duplicate-member')) {
+ New-FixturePackage $published $id '1.0.5' -GroupMutation $mutation
+ Expect-Rejection "$id $mutation dependency membership" { Assert-PackageContract $published $id '1.0.5' $root }
+ }
+ }
+ Write-Host "PASS: $script:Checks release artifact contract checks; no network or NuGet publishing."
+}
+finally {
+ $resolved = [IO.Path]::GetFullPath($temp)
+ $tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar)
+ if ((Split-Path $resolved -Parent) -ne $tempRoot -or (Split-Path $resolved -Leaf) -notmatch '^release-contract-[a-f0-9]{32}$') { throw 'Unsafe temporary cleanup target.' }
+ Remove-Item -LiteralPath $resolved -Recurse -Force
+}
diff --git a/scripts/write-release-manifest.ps1 b/scripts/write-release-manifest.ps1
new file mode 100644
index 00000000..529be32a
--- /dev/null
+++ b/scripts/write-release-manifest.ps1
@@ -0,0 +1,26 @@
+<#
+.SYNOPSIS
+Validates four built packages and records the exact Release workflow build identity.
+#>
+param(
+ [Parameter(Mandatory)][string]$Directory,
+ [Parameter(Mandatory)][string]$Repository,
+ [Parameter(Mandatory)][string]$Tag,
+ [Parameter(Mandatory)][string]$Version,
+ [Parameter(Mandatory)][string]$CommitSHA,
+ [Parameter(Mandatory)][string]$RunID,
+ [Parameter(Mandatory)][string]$RunAttempt
+)
+. (Join-Path $PSScriptRoot 'release-artifact-contract.ps1')
+Assert-StableReleaseVersion $Tag $Version
+if ($CommitSHA -notmatch '^[a-f0-9]{40}$' -or $RunID -notmatch '^[1-9]\d*$' -or $RunAttempt -notmatch '^[1-9]\d*$') { throw 'Invalid build identity.' }
+$packages = @(Get-ChildItem -LiteralPath $Directory -File)
+if ($packages.Count -ne 4 -or @(Get-ChildItem -LiteralPath $Directory -Directory).Count) { throw 'Manifest creation requires exactly four package files.' }
+$records = foreach ($id in $script:ReleasePackageIds) {
+ $filename = "$id.$Version.nupkg"
+ $path = Join-Path $Directory $filename
+ Assert-PackageContract $path $id $Version (Split-Path $PSScriptRoot)
+ @{ id = $id; filename = $filename; sha256 = Get-ReleaseSha256 $path }
+}
+@{ schemaVersion = 1; repository = $Repository; tag = $Tag; version = $Version; commitSHA = $CommitSHA; runID = $RunID; runAttempt = $RunAttempt; packages = @($records) } |
+ ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $Directory 'release-manifest.json') -Encoding utf8NoBOM
diff --git a/src/DAGControls/Properties/AssemblyInfo.cs b/src/DAGControls/Properties/AssemblyInfo.cs
index 395978ca..ba4f7c06 100644
--- a/src/DAGControls/Properties/AssemblyInfo.cs
+++ b/src/DAGControls/Properties/AssemblyInfo.cs
@@ -50,5 +50,5 @@
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
-[assembly: AssemblyVersion("1.0.4.0")]
-[assembly: AssemblyFileVersion("1.0.4.0")]
+[assembly: AssemblyVersion("1.0.5.0")]
+[assembly: AssemblyFileVersion("1.0.5.0")]
diff --git a/src/DatabaseManager.Tests/SQLiteManagerLifetimeTests.cs b/src/DatabaseManager.Tests/SQLiteManagerLifetimeTests.cs
new file mode 100644
index 00000000..d4e69ff2
--- /dev/null
+++ b/src/DatabaseManager.Tests/SQLiteManagerLifetimeTests.cs
@@ -0,0 +1,342 @@
+using System.Data;
+using System.Data.SQLite;
+using Xunit;
+
+namespace DatabaseManager.Tests;
+
+/// Checks ownership and failure recovery of SQLite manager connections.
+public class SQLiteManagerLifetimeTests
+{
+ /// Checks successful helpers preserve the caller's connection ownership.
+ /// The helper to invoke.
+ /// Whether the caller owns an open connection.
+ [Theory]
+ [InlineData(0, false)] [InlineData(1, false)] [InlineData(2, false)]
+ [InlineData(0, true)] [InlineData(1, true)] [InlineData(2, true)]
+ public void HelpersPreserveInitialState(int helper, bool initiallyOpen)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ if (initiallyOpen) manager.Open();
+ Invoke(manager, helper);
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ Assert.Equal(initiallyOpen ? ConnectionState.Open : ConnectionState.Closed, manager.DbConnection.State);
+ if (initiallyOpen) Assert.Equal(1L, Scalar(manager, "SELECT 1"));
+ else AssertReleased(path);
+ });
+ }
+
+ /// Checks partial acquisition failures close owned connections and preserve the original error.
+ /// The helper to invoke.
+ /// Whether close also reports an error.
+ [Theory]
+ [InlineData(0, false)] [InlineData(1, false)] [InlineData(2, false)]
+ [InlineData(0, true)] [InlineData(1, true)] [InlineData(2, true)]
+ public void PartialOpenFailureIsCleaned(int helper, bool cleanupFails)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path) { FailOpen = true, FailClose = cleanupFails };
+ var error = Assert.Throws(() => Invoke(manager, helper));
+ Assert.Same(manager.OpenError, error);
+ Assert.Contains(nameof(ProbeManager.Open), error.StackTrace);
+ Assert.Equal(1, manager.CloseCalls);
+ Assert.False(manager.DataBaseOpen);
+ Assert.Equal(ConnectionState.Closed, manager.DbConnection.State);
+ AssertReleased(path);
+ manager.FailOpen = manager.FailClose = false;
+ Invoke(manager, helper);
+ });
+ }
+
+ /// Checks cleanup-only failure propagates after successful work.
+ /// The helper to invoke.
+ [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)]
+ public void CleanupOnlyFailurePropagates(int helper)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path) { FailClose = true };
+ Assert.Same(manager.CloseError, Assert.Throws(() => Invoke(manager, helper)));
+ AssertReleased(path);
+ });
+ }
+
+ /// Checks vacuum failure leaves the caller's active transaction usable.
+ [Fact]
+ public void VacuumDoesNotCloseCallerTransaction()
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ manager.Open();
+ using var transaction = manager.DbConnection.BeginTransaction();
+ Assert.Throws(() => manager.Vacuum());
+ Assert.True(manager.DataBaseOpen);
+ Assert.Equal(0, manager.CloseCalls);
+ using var command = new SQLiteCommand("CREATE TABLE retained (value INTEGER)", manager.DbConnection, transaction);
+ command.ExecuteNonQuery();
+ transaction.Commit();
+ Assert.Contains("retained", manager.GetTableNames());
+ });
+ }
+
+ /// Checks both constructors release provider resources when initialization throws.
+ /// Whether to use the password constructor.
+ /// Whether provider disposal reports a secondary error.
+ [Theory]
+ [InlineData(false, false)] [InlineData(true, false)]
+ [InlineData(false, true)] [InlineData(true, true)]
+ public void ConstructorFailureDisposesConnectionDirectly(bool passwordOverload, bool cleanupFails)
+ {
+ WithFile(path =>
+ {
+ SQLiteConnection? captured = null;
+ var primary = new InvalidOperationException("initialization failure");
+ var cleanupObserved = false;
+ EventHandler cleanupFailure = (_, _) => { cleanupObserved = true; throw new IOException("provider cleanup failure"); };
+ Action fail = connection =>
+ {
+ captured = connection;
+ connection.Open();
+ if (cleanupFails) connection.Disposed += cleanupFailure;
+ throw primary;
+ };
+ var error = Assert.Throws(() =>
+ {
+ if (passwordOverload) _ = new FailingConstructorManager(path, null!, fail);
+ else _ = new FailingConstructorManager(path, fail);
+ });
+ Assert.Same(primary, error);
+ Assert.Contains(nameof(FailingConstructorManager.GetTableNames), error.StackTrace);
+ Assert.NotNull(captured);
+ try
+ {
+ if (cleanupFails)
+ {
+ Assert.True(cleanupObserved);
+ Assert.Equal(ConnectionState.Closed, captured.State);
+ }
+ else
+ {
+ Assert.Throws(() => captured.State);
+ Assert.Throws(() => captured.Open());
+ }
+ AssertReleased(path);
+ }
+ finally { captured.Disposed -= cleanupFailure; captured.Dispose(); }
+ });
+ }
+
+ /// Checks both constructors successfully initialize and leave the file released.
+ /// Whether to use the password constructor.
+ [Theory] [InlineData(false)] [InlineData(true)]
+ public void ConstructorsLeaveConnectionClosed(bool passwordOverload)
+ {
+ WithFile(path =>
+ {
+ using var manager = passwordOverload ? new SQLiteManager(path, null!, Builder()) : new SQLiteManager(path, Builder());
+ Assert.False(manager.DataBaseOpen);
+ Assert.Equal(ConnectionState.Closed, manager.DbConnection.State);
+ AssertReleased(path);
+ manager.Open();
+ using (var command = new SQLiteCommand("CREATE TABLE successful (value INTEGER); INSERT INTO successful VALUES (7);", manager.DbConnection))
+ command.ExecuteNonQuery();
+ Assert.Equal(7L, Scalar(manager, "SELECT value FROM successful"));
+ manager.Close();
+ Assert.Contains("successful", manager.GetTableNames());
+ });
+ }
+
+ /// Checks real invalid-file failures release owned acquisitions and recover after repair.
+ /// The helper to invoke.
+ [Theory] [InlineData(0)] [InlineData(1)] [InlineData(2)]
+ public void InvalidDatabaseFailureRecovers(int helper)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ File.WriteAllText(path, "This is not a SQLite database.");
+ Assert.Throws(() => Invoke(manager, helper));
+ Assert.False(manager.DataBaseOpen);
+ Assert.Equal(ConnectionState.Closed, manager.DbConnection.State);
+ Assert.Equal(1, manager.CloseCalls);
+ AssertReleased(path);
+ File.Delete(path);
+ SQLiteManager.CreateSqLiteFile(path);
+ Invoke(manager, helper);
+ Assert.False(manager.DataBaseOpen);
+ });
+ }
+
+ /// Checks a real exclusive lock failure preserves ownership and recovers once released.
+ /// Whether the manager's connection is caller-owned.
+ [Theory] [InlineData(false)] [InlineData(true)]
+ public void LockedVacuumRecovers(bool initiallyOpen)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ if (initiallyOpen) manager.Open();
+ var builder = Builder();
+ builder.DataSource = path;
+ using (var blocker = new SQLiteConnection(builder.ConnectionString))
+ {
+ blocker.Open();
+ using var begin = new SQLiteCommand("BEGIN EXCLUSIVE", blocker);
+ begin.ExecuteNonQuery();
+ try
+ {
+ Assert.Throws(() => manager.Vacuum());
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ Assert.Equal(initiallyOpen ? ConnectionState.Open : ConnectionState.Closed, manager.DbConnection.State);
+ if (!initiallyOpen) Assert.Equal(1, manager.CloseCalls);
+ }
+ finally
+ {
+ using var rollback = new SQLiteCommand("ROLLBACK", blocker);
+ rollback.ExecuteNonQuery();
+ }
+ }
+ manager.Vacuum();
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ if (initiallyOpen) Assert.Equal(1L, Scalar(manager, "SELECT 1"));
+ else AssertReleased(path);
+ });
+ }
+
+ /// Checks SQL failures restore owned connections and preserve borrowed connections.
+ /// The helper to invoke.
+ /// Whether the connection is caller-owned.
+ /// Whether owned close reports a secondary error.
+ [Theory]
+ [InlineData(0, false, false)] [InlineData(1, false, false)] [InlineData(2, false, false)]
+ [InlineData(0, true, false)] [InlineData(1, true, false)] [InlineData(2, true, false)]
+ [InlineData(0, false, true)] [InlineData(1, false, true)] [InlineData(2, false, true)]
+ public void SqlFailurePreservesOwnershipAndRecovers(int helper, bool initiallyOpen, bool cleanupFails)
+ {
+ WithFile(path =>
+ {
+ using var manager = new ProbeManager(path);
+ manager.DbConnection.Authorize += (_, args) =>
+ {
+ if (manager.RejectSql) args.ReturnCode = SQLiteAuthorizerReturnCode.Deny;
+ };
+ if (initiallyOpen) manager.Open();
+ manager.RejectSql = true;
+ manager.FailClose = cleanupFails;
+ var error = Assert.Throws(() => Invoke(manager, helper));
+ Assert.Contains("authoriz", error.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ Assert.Equal(initiallyOpen ? ConnectionState.Open : ConnectionState.Closed, manager.DbConnection.State);
+ Assert.Equal(initiallyOpen ? 0 : 1, manager.CloseCalls);
+ manager.RejectSql = manager.FailClose = false;
+ if (initiallyOpen) Assert.Equal(1L, Scalar(manager, "SELECT 1"));
+ else AssertReleased(path);
+ Invoke(manager, helper);
+ Assert.Equal(initiallyOpen, manager.DataBaseOpen);
+ });
+ }
+
+ /// Creates nonpooled connections with bounded lock waiting.
+ /// The connection settings.
+ private static SQLiteConnectionStringBuilder Builder() => new() { Pooling = false, DefaultTimeout = 1, BusyTimeout = 1, JournalMode = SQLiteJournalModeEnum.Delete };
+
+ /// Runs a helper selected by its test index.
+ /// The manager.
+ /// The helper index.
+ private static void Invoke(SQLiteManager manager, int helper)
+ {
+ if (helper == 0) manager.GetTableNames();
+ else if (helper == 1) manager.Vacuum();
+ else manager.Optimize();
+ }
+
+ /// Reads a scalar from a caller-owned connection.
+ /// The manager.
+ /// The SQL query.
+ /// The scalar.
+ private static object Scalar(SQLiteManager manager, string sql)
+ {
+ using var command = new SQLiteCommand(sql, manager.DbConnection);
+ return command.ExecuteScalar();
+ }
+
+ /// Verifies file release before test cleanup without garbage collection or pool clearing.
+ /// The database path.
+ private static void AssertReleased(string path)
+ {
+ using var exclusive = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+ }
+
+ /// Provides an isolated temporary database and deterministic final cleanup.
+ /// The test body.
+ private static void WithFile(Action test)
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"sqlite-lifetime-{Guid.NewGuid():N}.db");
+ try { test(path); }
+ finally
+ {
+ foreach (var file in new[] { path, path + "-wal", path + "-shm", path + "-journal" })
+ if (File.Exists(file)) File.Delete(file);
+ }
+ }
+
+ /// Injects errors after provider acquisition and release.
+ private sealed class ProbeManager : SQLiteManager
+ {
+ /// Creates a manager with nonpooled settings.
+ /// The database path.
+ public ProbeManager(string path) : base(path, Builder()) { CloseCalls = 0; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public bool RejectSql { get; set; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public bool FailOpen { get; set; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public bool FailClose { get; set; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public int CloseCalls { get; private set; }
+ /// Provides the injected failure or recorded lifecycle state.
+ public InvalidOperationException OpenError { get; } = new("partial open failure");
+ /// Provides the injected failure or recorded lifecycle state.
+ public InvalidOperationException CloseError { get; } = new("close failure");
+ /// Opens the provider before injecting a failure.
+ public override void Open() { base.Open(); if (FailOpen) throw OpenError; }
+ /// Closes the provider before injecting a failure.
+ public override void Close() { CloseCalls++; base.Close(); if (FailClose) throw CloseError; }
+ }
+
+ /// Injects initialization failure without shared static state.
+ private sealed class FailingConstructorManager : SQLiteManager
+ {
+ // The constructor argument is unavailable during base initialization; AsyncLocal isolates the setup callback.
+ private static readonly AsyncLocal?> Failure = new();
+ /// Sets the callback before base initialization.
+ /// The database path.
+ /// The failure callback.
+ /// The database path.
+ private static string Prepare(string path, Action fail) { Failure.Value = fail; return path; }
+ /// Invokes the ordinary constructor.
+ /// The database path.
+ /// The failure callback.
+ public FailingConstructorManager(string path, Action fail) : base(Prepare(path, fail), Builder()) { }
+ /// Invokes the password constructor.
+ /// The database path.
+ /// The password.
+ /// The failure callback.
+ public FailingConstructorManager(string path, string password, Action fail) : base(Prepare(path, fail), password, Builder()) { }
+ /// Throws during constructor initialization after provider acquisition.
+ /// Never returns.
+ public override string[] GetTableNames()
+ {
+ var callback = Failure.Value!;
+ Failure.Value = null;
+ callback(DbConnection);
+ return [];
+ }
+ /// Rejects unsafe virtual disposal during base construction.
+ /// Whether disposal is explicit.
+ protected override void Dispose(bool disposing) => throw new InvalidOperationException("virtual disposal during construction");
+ }
+}
diff --git a/src/DatabaseManager/SQLiteManager.cs b/src/DatabaseManager/SQLiteManager.cs
index a58bc6de..3c3da6ef 100644
--- a/src/DatabaseManager/SQLiteManager.cs
+++ b/src/DatabaseManager/SQLiteManager.cs
@@ -3,6 +3,7 @@
using System.Data;
using System.Data.SQLite;
using System.Globalization;
+using System.Diagnostics;
using System.Linq;
using System.Threading;
@@ -44,8 +45,16 @@ public class SQLiteManager : DatabaseManager, IDisposable
public SQLiteManager(string dataBaseFile, SQLiteConnectionStringBuilder connectionBuilder = null)
{
_dataBasePath = dataBaseFile;
- SetDatabaseConnection(dataBaseFile, connectionBuilder);
- _tableNames = GetTableNames();
+ try
+ {
+ SetDatabaseConnection(dataBaseFile, connectionBuilder);
+ _tableNames = GetTableNames();
+ }
+ catch
+ {
+ DisposeFailedInitialization();
+ throw;
+ }
}
///
@@ -57,8 +66,30 @@ public SQLiteManager(string dataBaseFile, SQLiteConnectionStringBuilder connecti
public SQLiteManager(string dataBaseFile, string databasePassword, SQLiteConnectionStringBuilder connectionBuilder = null)
{
_dataBasePath = dataBaseFile;
- SetDatabaseConnection(dataBaseFile, databasePassword, connectionBuilder);
- _tableNames = GetTableNames();
+ try
+ {
+ SetDatabaseConnection(dataBaseFile, databasePassword, connectionBuilder);
+ _tableNames = GetTableNames();
+ }
+ catch
+ {
+ DisposeFailedInitialization();
+ throw;
+ }
+ }
+
+ /// Releases the owned provider after failed initialization without dispatching virtual disposal.
+ /// A cleanup failure is diagnostic only so the original initialization exception retains its stack.
+ private void DisposeFailedInitialization()
+ {
+ try
+ {
+ _dbConnection?.Dispose();
+ }
+ catch (Exception cleanupError)
+ {
+ Debug.WriteLine(cleanupError);
+ }
}
#endregion
@@ -240,20 +271,14 @@ public static void CreateSqLiteFile(string databaseFile, string databasePassword
///
public void Vacuum()
{
- bool reOpen = _dataBaseOpen;
- if (_dataBaseOpen == false)
+ WithConnectionOwnership(() =>
{
- Open();
- }
- // vacuum sqlite file
- using (var command = new SQLiteCommand("vacuum", _dbConnection))
- {
- command.ExecuteNonQuery();
- }
- if (reOpen == false)
- {
- Close();
- }
+ using (var command = new SQLiteCommand("vacuum", _dbConnection))
+ {
+ command.ExecuteNonQuery();
+ }
+ return true;
+ });
}
///
@@ -263,25 +288,55 @@ public void Vacuum()
///
public void Optimize()
{
- bool reOpen = _dataBaseOpen;
- if (_dataBaseOpen == false)
+ WithConnectionOwnership(() =>
+ {
+ using (var command = new SQLiteCommand("PRAGMA optimize", _dbConnection))
+ {
+ command.ExecuteNonQuery();
+ }
+ return true;
+ });
+ }
+
+
+ #endregion
+
+ /// Runs an operation while restoring ownership of an initially closed connection.
+ /// The operation result type.
+ /// The operation to run after opening the connection if needed.
+ /// The operation result.
+ /// Acquisition is protected because a failed open can acquire provider resources. Borrowed connections remain open. A secondary close failure is logged while the original exception propagates; a cleanup-only failure propagates.
+ private TResult WithConnectionOwnership(Func operation)
+ {
+ bool wasOpen = DataBaseOpen;
+ Exception operationError = null;
+ try
{
- Open();
+ if (!wasOpen)
+ Open();
+ return operation();
}
- // optimize sqlite file
- using (var command = new SQLiteCommand("PRAGMA optimize", _dbConnection))
+ catch (Exception error)
{
- command.ExecuteNonQuery();
+ operationError = error;
+ throw;
}
- if (reOpen == false)
- {
- Close();
+ finally
+ {
+ if (!wasOpen)
+ {
+ try
+ {
+ Close();
+ }
+ catch (Exception cleanupError) when (operationError != null)
+ {
+ Debug.WriteLine(cleanupError);
+ }
+ }
}
}
-
- #endregion
-
#region Database Table Management
///
@@ -401,29 +456,22 @@ public void RenameTable(string oldTableName, string newTableName)
///
public override string[] GetTableNames()
{
- bool wasOpen = _dataBaseOpen;
- if (_dataBaseOpen == false)
+ var result = WithConnectionOwnership(() =>
{
- Open();
- }
- //
- var result = new List();
- using (var command = new SQLiteCommand("SELECT name FROM sqlite_master WHERE type='table'", _dbConnection))
- {
- using (var reader = command.ExecuteReader())
+ var names = new List();
+ using (var command = new SQLiteCommand("SELECT name FROM sqlite_master WHERE type='table'", _dbConnection))
{
- if (reader.HasRows)
+ using (var reader = command.ExecuteReader())
{
- while (reader.Read())
- result.Add(Convert.ToString(reader[0]));
+ if (reader.HasRows)
+ {
+ while (reader.Read())
+ names.Add(Convert.ToString(reader[0]));
+ }
}
}
- }
- //
- if (wasOpen == false)
- {
- Close();
- }
+ return names;
+ });
_tableNames = result.ToArray();
return result.ToArray();
}
diff --git a/src/FrameworkInterfaces/Project/ElementBase.cs b/src/FrameworkInterfaces/Project/ElementBase.cs
index 9eb17767..be7eb007 100644
--- a/src/FrameworkInterfaces/Project/ElementBase.cs
+++ b/src/FrameworkInterfaces/Project/ElementBase.cs
@@ -343,6 +343,7 @@ protected void RaisePropertyChange(string propertyName, bool setDirty = true)
/// Call this method from property setters to enable undo/redo support.
/// If is false or the undo manager is currently
/// executing an action (during undo/redo), the change will not be recorded.
+ /// Name changes still refresh the display name using the existing dirty state.
///
///
/// Example usage in a derived class:
@@ -385,6 +386,9 @@ protected void RecordPropertyChange(string propertyName, object oldValue, object
// not mark the element dirty.
if (setDirty && IsUndoEnabled && !UndoManager.IsExecutingAction)
SetIsDirty(true);
+ else if (propertyName == nameof(Name))
+ // Rename replay still changes the caption; retain the existing dirty policy.
+ SetIsDirty(IsDirty);
}
///
diff --git a/src/FrameworkUI.Demo/App.xaml b/src/FrameworkUI.Demo/App.xaml
index cbf204ec..6dbc900f 100644
--- a/src/FrameworkUI.Demo/App.xaml
+++ b/src/FrameworkUI.Demo/App.xaml
@@ -2,13 +2,18 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:FrameworkUI.Demo"
- Startup="Application_Startup">
+ Startup="Application_Startup"
+ ShutdownMode="OnMainWindowClose"
+ DispatcherUnhandledException="Application_DispatcherUnhandledException">
+
+
+
@@ -18,6 +23,9 @@
+
+
+
diff --git a/src/FrameworkUI.Demo/App.xaml.cs b/src/FrameworkUI.Demo/App.xaml.cs
index 7a8e37b4..0a1d7bb3 100644
--- a/src/FrameworkUI.Demo/App.xaml.cs
+++ b/src/FrameworkUI.Demo/App.xaml.cs
@@ -2,8 +2,7 @@
using SoftwareUpdate;
using SoftwareUpdate.GitHub;
using SoftwareUpdate.Utilities;
-using System;
-using System.IO;
+using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Shell;
@@ -50,23 +49,6 @@ public App()
System.Windows.Markup.XmlLanguage.GetLanguage(
System.Globalization.CultureInfo.CurrentCulture.IetfLanguageTag)));
- // Set up global exception handlers for debugging
- AppDomain.CurrentDomain.UnhandledException += (s, args) =>
- {
- var ex = args.ExceptionObject as Exception;
- LogException("AppDomain.UnhandledException", ex);
- };
-
- DispatcherUnhandledException += (s, args) =>
- {
- LogException("Dispatcher.UnhandledException", args.Exception);
- args.Handled = true; // Prevent immediate crash to see the error
- };
-
- TaskScheduler.UnobservedTaskException += (s, args) =>
- {
- LogException("TaskScheduler.UnobservedTaskException", args.Exception);
- };
var jList = new JumpList { ShowRecentCategory = false };
jList.Apply();
@@ -74,22 +56,17 @@ public App()
}
///
- /// Logs an exception to both debug output and a file for diagnosis.
+ /// Handles dispatcher exceptions that are known to be benign.
///
- private static void LogException(string source, Exception? ex)
+ /// The event sender.
+ /// The dispatcher exception event arguments.
+ private void Application_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
- var separator = new string('=', 50);
- var message = $"\n{separator}\n{source}\n{separator}\n{ex}\n";
- System.Diagnostics.Debug.WriteLine(message);
-
- // Also write to a file so we can see it
- try
+ System.Diagnostics.Debug.WriteLine($"Application.DispatcherUnhandledException: {e.Exception}");
+ if (e.Exception is COMException comException && comException.ErrorCode == -2147221040)
{
- var logPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "FrameworkUI_Demo_Error.txt");
- File.AppendAllText(logPath, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\n{message}\n\n");
- GenericControls.MessageBox.Show($"Exception logged to: {logPath}\n\n{ex?.Message}", source, MessageBoxButton.OK, MessageBoxImage.Error);
+ e.Handled = true;
}
- catch (Exception logEx) { System.Diagnostics.Debug.WriteLine($"Failed to write exception log: {logEx.Message}"); }
}
///
@@ -104,8 +81,8 @@ private void Application_Startup(object sender, StartupEventArgs e)
// ThemeManager bridges to ThemeService and loads ProjectUI-specific resources
FrameworkUI.ThemeManager.SetTheme(FrameworkUI.ThemeColor.Light);
- FrameworkUI.ShellPublicVariables.SoftwareVersionDate = "December 2025";
- FrameworkUI.ShellPublicVariables.SoftwareExtension = ".fun";
+ FrameworkUI.ShellPublicVariables.SoftwareVersionDate = "July 2026";
+ FrameworkUI.ShellPublicVariables.SoftwareExtension = ".demo";
FrameworkUI.UserSettings.CreateAutoRecoverBackup = false;
// Create the project model. Use TryFindResource so a missing TreeViewItemStyle
@@ -139,7 +116,7 @@ private void Application_Startup(object sender, StartupEventArgs e)
// =================================================================
// Auto-check settings (not part of UpdateOptions, handled locally)
- bool autoCheckOnStartup = true;
+ bool autoCheckOnStartup = false;
int autoCheckDelayMs = 5000; // 5 second delay after startup
// Configure update options for your GitHub repository
diff --git a/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj b/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
index c716ad90..ddc0cee6 100644
--- a/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
+++ b/src/FrameworkUI.Demo/FrameworkUI.Demo.csproj
@@ -8,6 +8,12 @@
FrameworkUI.Demo
A demonstration application for the FrameworkUI WPF framework, showcasing project management, AvalonDock layout, theming, and standard controls.
U.S. Army Corps of Engineers
+ WPF Framework Demo
+ WPF Framework Demo
+ 1.0.0
+ 1.0.0.0
+ 1.0.0.0
+ Resources\FrameworkUIDemo.ico
disable
true
enable
@@ -46,7 +52,7 @@
-
+
diff --git a/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElement.cs b/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElement.cs
deleted file mode 100644
index 93d77c52..00000000
--- a/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElement.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-using System;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// Represents a consequence element in the demo project.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- internal class ConsequenceElement : ElementBase
- {
- ///
- /// Initializes a new instance of the class.
- ///
- /// The name of the consequence element.
- /// The parent collection that contains this element.
- public ConsequenceElement(string name, IElementCollection parentCollection) : base(name, parentCollection)
- {
- Name = name;
- _description = "Consequence Element";
- _creationDate = DateTime.Now;
- _lastModified = _creationDate;
- SetIsDirty(false);
- }
-
- ///
- /// Gets or sets the name of the consequence element.
- ///
- public override string Name { get; set; }
-
- ///
- /// Gets or sets the description of the consequence element.
- ///
- public override string Description { get => _description; set => _description = value; }
-
- ///
- /// Gets the creation date of the consequence element.
- ///
- public override DateTime CreationDate => _creationDate;
-
- ///
- /// Gets the last modified date of the consequence element.
- ///
- public override DateTime LastModified => _lastModified;
-
- ///
- /// Gets the name used when saving the element to disk.
- ///
- public override string NameOnDisk => "Consequence Element";
-
- ///
- /// Gets the image icon representing the element.
- ///
- private static readonly Lazy s_icon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
- public override ImageSource ElementImage => s_icon.Value;
-
- ///
- /// Gets a value indicating whether this element can be copied from an external project.
- ///
- public override bool CanCopyFromExternal => true;
-
- ///
- /// Gets a value indicating whether the element is valid.
- ///
- public override bool IsValid { get => true; }
-
- ///
- /// Creates a copy of the consequence element.
- ///
- /// The name for the copied element. If null, a default name will be used.
- /// A copy of the element, or null if copying is not implemented.
- public override IElement Copy(string newName = null)
- {
- //throw new NotImplementedException();
- return null;
- }
-
- ///
- /// Copies an element from an external project file.
- ///
- /// The name of the item to copy.
- /// The full path to the external file.
- /// A copy of the external element, or null if not implemented.
- public override IElement CopyFromExternal(string itemName, string fullFileName)
- {
- //throw new NotImplementedException();
- return null;
- }
-
- ///
- /// Deletes the consequence element.
- ///
- public override void Delete()
- {
- //throw new NotImplementedException();
- }
-
-
- ///
- /// Opens the consequence element for editing.
- ///
- public override void Open()
- {
- //throw new NotImplementedException();
- }
-
- ///
- /// Saves the consequence element to disk.
- ///
- public override void Save()
- {
- //throw new NotImplementedException();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElementCollection.cs b/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElementCollection.cs
deleted file mode 100644
index 86d65e88..00000000
--- a/src/FrameworkUI.Demo/Model/Consequence Elements/ConsequenceElementCollection.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// Collection of consequence function elements for the demo project.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- internal class ConsequenceElementCollection : ElementCollectionBase
- {
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The parent project that contains this collection.
- public ConsequenceElementCollection(IProject parentProject) : base(parentProject)
- {
- }
-
- ///
- /// Gets the name of the collection.
- ///
- public override string Name
- {
- get
- {
- return "Consequence Functions";
- }
- }
-
- ///
- /// Opens the collection and loads elements from disk.
- ///
- public override void Open()
- {
- _opening = true;
- // read from disk
- _opening = false;
- }
-
- ///
- /// Adds a consequence element to the collection.
- ///
- /// The element to add to the collection.
- public override void Add(IElement item)
- {
- item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
- ElementList.Add((ConsequenceElement)item);
- // Save to disk
- if (_opening == false)
- {
- //item.IsValid();
- SetIsDirty(true);
- }
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Inserts a consequence element at the specified index in the collection.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The element to insert.
- public override void Insert(int index, IElement item)
- {
- item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
- ElementList.Insert(index, (ConsequenceElement)item);
- // save to disk
- SetIsDirty(true);
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Deletes the collection from disk.
- ///
- public override void Delete()
- {
- // Delete from disk
- }
-
- ///
- /// Inserts an element from an external project at the specified index.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The name of the element to insert.
- /// The type of the element.
- /// The full path to the external project file.
- public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
- {
- throw new System.NotImplementedException();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/DemoProject.cs b/src/FrameworkUI.Demo/Model/DemoProject.cs
index cfb002fd..975715a9 100644
--- a/src/FrameworkUI.Demo/Model/DemoProject.cs
+++ b/src/FrameworkUI.Demo/Model/DemoProject.cs
@@ -5,10 +5,10 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
+using System.Data;
+using System.Data.SQLite;
using System.Windows.Media.Imaging;
using System.IO;
-using System.IO.Compression;
-using System.Reflection;
using System.Windows.Media;
@@ -32,7 +32,7 @@ namespace FrameworkUI.Demo
///
[Category("Project")]
[DisplayName("Demo Project")]
- [Description("The Demo project includes hazard, response, and consequence input functions.")]
+ [Description("The Demo project showcases a fully implemented hazard function element.")]
[Browsable(true)]
public class DemoProject : ProjectBase
{
@@ -51,15 +51,11 @@ private DemoProject()
InitializeMessages();
_hazardFunctions = new HazardElementCollection(this);
- _responseFunctions = new ResponseElementCollection(this);
- _consequenceFunctions = new ConsequenceElementCollection(this);
- _hazardFunctions.ObjectSaved += ElementCollection_Saved;
- _responseFunctions.ObjectSaved += ElementCollection_Saved;
- _consequenceFunctions.ObjectSaved += ElementCollection_Saved;
+ _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
+ { _hazardFunctions });
- _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions, _responseFunctions, _consequenceFunctions});
+ SubscribeCollectionEvents();
}
///
@@ -101,9 +97,8 @@ private void InitializeMessages()
private static readonly char[] _InvalidNameCharacters = new List(Path.GetInvalidFileNameChars()) { '\'', '[', ']' }.ToArray();
private HazardElementCollection _hazardFunctions;
- private ResponseElementCollection _responseFunctions;
- private ConsequenceElementCollection _consequenceFunctions;
private bool _nameValid = false;
+ private bool _collectionEventsSubscribed;
private List _messages = new List();
private Messenger _messenger = Messenger.GetInstance();
private BasicMessageItem _descriptionMsg;
@@ -131,41 +126,7 @@ public override string Name
item.SourceName = value;
}
- // Check if name is nothing.
- if (string.IsNullOrEmpty(Name))
- {
- _nameValid = false;
- _messenger.Add(_noNameMsg);
- }
- else
- {
- _messenger.Remove(_noNameMsg);
- }
-
- // Check the length of the name.
- if (Name.Length > 50)
- {
- _nameValid = false;
- _messenger.Add(_longNameMsg);
- }
- else
- {
- _messenger.Remove(_longNameMsg);
- }
-
- // Check if there are bad characters.
- _messenger.Remove(_badCharMsg);
- foreach (char badChar in DemoProject.InvalidNameCharacters)
- {
- if (Name.Contains(badChar))
- {
- _nameValid = false;
- string badCharacters = "<>:" + (char)34 + "/\\|?*";
- _badCharMsg.Description = $"Invalid character in project name: '{badChar}'. Invalid characters are: {badCharacters}";
- _messenger.Add(_badCharMsg);
- break;
- }
- }
+ ValidateName();
RecordPropertyChange(nameof(Name), oldValue, value);
}
@@ -199,11 +160,37 @@ public override string Description
}
///
- [Category("Meta Data"), DisplayName("Software Version"), Description("The version of RMC-TotalRisk used to last modify the project file."), Browsable(true)]
- public override string SoftwareVersion => "1.0";
+ [Category("Meta Data"), DisplayName("Software Version"), Description("The version of WPF Framework Demo used to last modify the project file."), Browsable(true)]
+ public override string SoftwareVersion => "1.0.0";
+
+ ///
+ /// Lazy-loaded project icon. Returns null if the icon resource cannot be loaded
+ /// so a missing resource degrades gracefully instead of crashing the explorer tree.
+ ///
+ private static readonly Lazy s_projectIcon = new(() =>
+ {
+ try
+ {
+ var uri = new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/FrameworkUIDemo.ico");
+ var decoder = BitmapDecoder.Create(uri, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
+ BitmapFrame bestFrame = null;
+ foreach (var frame in decoder.Frames)
+ {
+ if (bestFrame == null || frame.PixelWidth * frame.PixelHeight > bestFrame.PixelWidth * bestFrame.PixelHeight)
+ {
+ bestFrame = frame;
+ }
+ }
+
+ bestFrame?.Freeze();
+ return bestFrame;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ });
- ///
- private static readonly Lazy s_projectIcon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
///
public override ImageSource ProjectImage => s_projectIcon.Value;
@@ -217,6 +204,196 @@ public override string Description
public static char[] InvalidNameCharacters => _InvalidNameCharacters;
+ #endregion
+
+ #region Validation
+
+ ///
+ /// Validates the project name against all naming rules and updates validation messages.
+ ///
+ ///
+ /// Checks that the name is not blank, does not exceed 50 characters, and contains
+ /// no invalid file name characters.
+ ///
+ private void ValidateName()
+ {
+ _nameValid = true;
+
+ // Check if name is nothing.
+ if (string.IsNullOrEmpty(Name))
+ {
+ _nameValid = false;
+ _messenger.Add(_noNameMsg);
+ }
+ else
+ {
+ _messenger.Remove(_noNameMsg);
+ }
+
+ // Check the length of the name.
+ if (Name != null && Name.Length > 50)
+ {
+ _nameValid = false;
+ _messenger.Add(_longNameMsg);
+ }
+ else
+ {
+ _messenger.Remove(_longNameMsg);
+ }
+
+ // Check if there are bad characters.
+ _messenger.Remove(_badCharMsg);
+ if (Name != null)
+ {
+ foreach (char badChar in DemoProject.InvalidNameCharacters)
+ {
+ if (Name.Contains(badChar))
+ {
+ _nameValid = false;
+ string badCharacters = "<>:" + (char)34 + "/\\|?*";
+ _badCharMsg.Description = $"Invalid character in project name: '{badChar}'. Invalid characters are: {badCharacters}";
+ _messenger.Add(_badCharMsg);
+ break;
+ }
+ }
+ }
+ }
+
+ ///
+ public override bool IsValid()
+ {
+ if (!_nameValid) return false;
+ return true;
+ }
+
+ #endregion
+
+ #region Collection Events
+
+ ///
+ /// Subscribes to the per-collection events that drive project-level save cascades
+ /// and dirty aggregation.
+ ///
+ private void SubscribeCollectionEvents()
+ {
+ if (_collectionEventsSubscribed) return;
+
+ _hazardFunctions.ObjectSaved += ElementCollection_Saved;
+ _hazardFunctions.ElementIsDirtyChanged += OnElementIsDirtyChanged;
+ _hazardFunctions.PropertyChanged += OnCollectionPropertyChanged;
+ _collectionEventsSubscribed = true;
+ }
+
+ ///
+ /// Unsubscribes from every per-collection event subscribed by .
+ ///
+ private void UnsubscribeCollectionEvents()
+ {
+ if (!_collectionEventsSubscribed) return;
+
+ _hazardFunctions.ObjectSaved -= ElementCollection_Saved;
+ _hazardFunctions.ElementIsDirtyChanged -= OnElementIsDirtyChanged;
+ _hazardFunctions.PropertyChanged -= OnCollectionPropertyChanged;
+ _collectionEventsSubscribed = false;
+ }
+
+ ///
+ /// Aggregates element-level dirty transitions into the project-level
+ /// flag so the Save button reflects unsaved child edits.
+ ///
+ /// The event sender.
+ /// The event arguments.
+ private void OnElementIsDirtyChanged(object sender, EventArgs e)
+ {
+ if (_openingProject) return;
+ SetIsDirty(true);
+ }
+
+ ///
+ /// Aggregates collection-level dirty transitions (structural changes such as
+ /// add, remove, move, and sort) into the project-level flag.
+ ///
+ /// The event sender.
+ /// The property changed event arguments.
+ private void OnCollectionPropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ if (_openingProject) return;
+ if (e.PropertyName != nameof(IElementCollection.IsDirty)) return;
+ if (sender is IElementCollection collection && collection.IsDirty)
+ SetIsDirty(true);
+ }
+
+ #endregion
+
+ #region SQLite Persistence
+
+ ///
+ /// The name of the SQLite table storing the project meta data.
+ ///
+ private readonly string _tableName = "Project";
+
+ ///
+ /// Gets the required columns for the SQLite project table.
+ ///
+ ///
+ /// If you want to add a new column, add it to the end of the dictionary to maintain
+ /// backward compatibility with existing project files.
+ ///
+ private static Dictionary RequiredColumns { get; } = new Dictionary() {
+ { nameof(Name), typeof(string) },
+ { nameof(Description), typeof(string) },
+ { nameof(CreationDate), typeof(string) },
+ { nameof(LastModified), typeof(string) },
+ { nameof(FullFileName), typeof(string) },
+ { nameof(SoftwareVersion), typeof(string) },
+ { nameof(AvalonDockLayout), typeof(string) },
+ { nameof(ProjectExplorerLayout), typeof(string) } };
+
+ ///
+ /// Creates or updates the SQLite project table with required columns.
+ ///
+ /// The SQLite database manager instance.
+ ///
+ /// If the table doesn't exist, it is created. If the table exists but is missing
+ /// columns, the missing columns are added. This supports forward compatibility
+ /// when opening older project files.
+ ///
+ private void CreateTable(SQLiteManager sqlite)
+ {
+ if (sqlite.TableNames.Contains(_tableName) == false)
+ {
+ // If the table does not exist, then create the table
+ var dataTable = new DataTable(_tableName);
+ foreach (KeyValuePair column in RequiredColumns)
+ dataTable.Columns.Add(column.Key, column.Value);
+ sqlite.SaveDataTable(dataTable);
+ }
+ else
+ {
+ // Add any required columns that don't exist
+ var dt = sqlite.GetTableManager(_tableName);
+ int columnIndex;
+ foreach (KeyValuePair column in RequiredColumns)
+ {
+ columnIndex = Array.IndexOf(dt.ColumnNames, column.Key);
+ // If the column doesn't exist in the database then create it.
+ if (columnIndex < 0)
+ {
+ dt.AddColumn(column.Key, column.Value);
+ }
+ else
+ {
+ if (dt.ColumnTypes[columnIndex] != column.Value)
+ {
+ dt.DeleteColumn(columnIndex);
+ dt.AddColumn(column.Key, column.Value);
+ }
+ }
+ }
+ dt.ApplyEdits();
+ }
+ }
+
#endregion
#region Methods
@@ -224,18 +401,28 @@ public override string Description
///
public override void CreateNew(string fullFileName)
{
- // Set project meta data & properties
- FullFileName = fullFileName;
- Name = Path.GetFileNameWithoutExtension(fullFileName);
- Description = "";
- CreationDate = DateTime.Now;
- LastModified = DateTime.Now;
-
- // Load element collections.
- _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions, _responseFunctions, _consequenceFunctions});
-
- Save();
+ IsUndoEnabled = false;
+ try
+ {
+ // Set project meta data & properties
+ FullFileName = fullFileName;
+ Name = Path.GetFileNameWithoutExtension(fullFileName);
+ Description = "";
+ CreationDate = DateTime.Now;
+ LastModified = DateTime.Now;
+
+ // Load element collections.
+ _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
+ { _hazardFunctions });
+ SubscribeCollectionEvents();
+
+ Save();
+ }
+ finally
+ {
+ IsUndoEnabled = true;
+ ClearUndoHistory();
+ }
}
///
@@ -243,18 +430,28 @@ public override void CreateNew(string fullFileName)
///
public void CreateNewDummyProject()
{
- // Set project meta data and properties
- FullFileName = System.IO.Path.GetTempPath() + Guid.NewGuid().ToString() + ".demo";
- Name = "Blank Project";
- Description = "This is a blank project file.";
- CreationDate = DateTime.Now;
- LastModified = DateTime.Now;
-
- // Load element collections.
- _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions, _responseFunctions, _consequenceFunctions});
-
- Save();
+ IsUndoEnabled = false;
+ try
+ {
+ // Set project meta data and properties
+ FullFileName = System.IO.Path.GetTempPath() + Guid.NewGuid().ToString() + ShellPublicVariables.SoftwareExtension;
+ Name = "Blank Project";
+ Description = "This is a blank project file.";
+ CreationDate = DateTime.Now;
+ LastModified = DateTime.Now;
+
+ // Load element collections.
+ _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
+ { _hazardFunctions });
+ SubscribeCollectionEvents();
+
+ Save();
+ }
+ finally
+ {
+ IsUndoEnabled = true;
+ ClearUndoHistory();
+ }
}
///
@@ -262,40 +459,113 @@ public override void Open()
{
_openingProject = true;
IsUndoEnabled = false;
+ SetIsDirty(false);
+ try
+ {
+ var sqlite = new SQLiteManager(FullFileName);
+ sqlite.Open();
+
+ // The user might have renamed the SQLite file from Windows Explorer.
+ // In this case, the FullFileName will not match the meta data stored within
+ // the database. Check if the values are different, and if so, update the
+ // meta data table.
+ string winExpName = Path.GetFileNameWithoutExtension(FullFileName);
+ string winExpFullFileName = FullFileName;
+
+ var dtView = sqlite.GetTableManager(_tableName);
+ if (dtView.NumberOfRows != 1)
+ {
+ sqlite.Close();
+ }
+ else
+ {
+ // Get name and full file name using backing fields to avoid undo recording
+ if (dtView.ColumnNames.Contains(nameof(Name))) _name = dtView.GetCell(nameof(Name), 0).ToString();
+ if (dtView.ColumnNames.Contains(nameof(FullFileName))) _fullFileName = dtView.GetCell(nameof(FullFileName), 0).ToString();
- //
- // Load from disk or database
- //
+ // Reconcile a Windows Explorer rename with the stored meta data.
+ if (FullFileName != winExpFullFileName)
+ {
+ FullFileName = winExpFullFileName;
+ dtView.EditCell(0, nameof(FullFileName), winExpFullFileName);
+ dtView.ApplyEdits();
+ }
+ if (_name != winExpName)
+ {
+ _name = winExpName;
+ foreach (var item in _messages)
+ item.SourceName = _name;
+ ValidateName();
+ dtView.EditCell(0, nameof(Name), winExpName);
+ dtView.ApplyEdits();
+ }
- // Open element collections.
- _hazardFunctions.Open();
- _responseFunctions.Open();
- _consequenceFunctions.Open();
+ // Get the rest of the properties using backing fields
+ if (dtView.ColumnNames.Contains(nameof(Description))) _description = dtView.GetCell(nameof(Description), 0).ToString();
+ if (dtView.ColumnNames.Contains(nameof(CreationDate))) CreationDate = FrameworkInterfaces.Utilities.Tools.DateFromString(dtView.GetCell(nameof(CreationDate), 0).ToString()) ?? DateTime.MinValue;
+ if (dtView.ColumnNames.Contains(nameof(LastModified))) LastModified = FrameworkInterfaces.Utilities.Tools.DateFromString(dtView.GetCell(nameof(LastModified), 0).ToString()) ?? DateTime.MinValue;
+ if (dtView.ColumnNames.Contains(nameof(AvalonDockLayout))) AvalonDockLayout = dtView.GetCell(nameof(AvalonDockLayout), 0).ToString();
+ if (dtView.ColumnNames.Contains(nameof(ProjectExplorerLayout))) _projectExplorerLayout = dtView.GetCell(nameof(ProjectExplorerLayout), 0).ToString();
- // Load element collections.
- _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
- { _hazardFunctions, _responseFunctions, _consequenceFunctions});
+ // Opening from disk is not a layout edit; clear the flag that the
+ // AvalonDockLayout setter above may have just flipped.
+ LayoutDirty = false;
- NameOnDisk = Name;
- IsUndoEnabled = true;
- ClearUndoHistory();
- SetIsDirty(false);
- _openingProject = false;
+ sqlite.Close();
+ }
+
+ // Validate description message
+ if (string.IsNullOrEmpty(_description))
+ _messenger.Add(_descriptionMsg);
+ else
+ _messenger.Remove(_descriptionMsg);
+
+ // Open element collections.
+ _hazardFunctions.Open();
+
+ // Load element collections.
+ _readOnlyElementCollections = new ReadOnlyCollection(new IElementCollection[]
+ { _hazardFunctions });
+ SubscribeCollectionEvents();
+
+ NameOnDisk = Name;
+
+ // Raise PropertyChanged for properties loaded via backing fields
+ // so that WPF bindings (e.g., ProjectNode header) update correctly.
+ RaisePropertyChange(nameof(Name), false);
+ RaisePropertyChange(nameof(Description), false);
+ }
+ finally
+ {
+ _openingProject = false;
+ IsUndoEnabled = true;
+ ClearUndoHistory();
+ SetIsDirty(false);
+ }
}
///
public override void Close()
{
+ UnsubscribeCollectionEvents();
_messenger.Clear(this);
- // Close all project element collections..
+ // Close all project element collections.
_hazardFunctions.Clear();
- _responseFunctions.Clear();
- _consequenceFunctions.Clear();
}
///
+ ///
+ /// Two paths are possible:
+ ///
+ /// - Layout-only save (IsDirty == false and LayoutDirty == true):
+ /// only the two layout columns are rewritten and LastModified is not stamped, so
+ /// window-layout changes persist without drifting the project's modification timestamp.
+ /// - Full save (all other cases): project row, layout, and element
+ /// collections are all written.
+ ///
+ ///
public override void Save()
{
bool cancel = false;
@@ -305,50 +575,89 @@ public override void Save()
// Must have a file name to save.
if (FullFileName == null) return;
- // Check if the file name needs to be renamed
- string winExpName = Path.GetFileNameWithoutExtension(FullFileName);
- string winExpFullFileName = FullFileName;
+ bool layoutOnly = !IsDirty && LayoutDirty;
- if (winExpName != Name && Name != "Blank Project")
+ // Filename-rename check is meaningful only when we are writing project meta.
+ if (!layoutOnly)
{
- var newFullFileName = Path.Combine(FileDirectory ?? string.Empty, Name + ".fun");
- try
- {
- File.Move(winExpFullFileName, newFullFileName);
- FullFileName = newFullFileName;
- }
- catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is FileNotFoundException)
+ string winExpName = Path.GetFileNameWithoutExtension(FullFileName);
+ string winExpFullFileName = FullFileName;
+
+ if (winExpName != Name && Name != "Blank Project")
{
- FrameworkInterfaces.Messaging.Messenger.GetInstance().Add(
- new BasicMessageItem(FrameworkInterfaces.MessageType.Warning,
+ var newFullFileName = Path.Combine(FileDirectory ?? string.Empty, Name + ShellPublicVariables.SoftwareExtension);
+ try
+ {
+ File.Move(winExpFullFileName, newFullFileName);
+ FullFileName = newFullFileName;
+ }
+ catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is FileNotFoundException)
+ {
+ _messenger.Add(new BasicMessageItem(MessageType.Warning,
$"Failed to rename project file: {ex.Message}",
this, "DemoProject", Name, "Save"));
- return;
+ return;
+ }
}
}
- // Update last edited
- _lastModified = DateTime.Now;
+ var sqlite = new SQLiteManager(FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (layoutOnly)
+ {
+ // Layout-only save: only persist the two layout columns.
+ // Do not stamp LastModified, do not iterate element collections.
+ CreateTable(sqlite);
+ var layoutView = sqlite.GetTableManager(_tableName);
+ if (layoutView.NumberOfRows == 0) layoutView.AddRow();
+ layoutView.EditCell(0, nameof(AvalonDockLayout), AvalonDockLayout ?? string.Empty);
+ layoutView.EditCell(0, nameof(ProjectExplorerLayout), ProjectExplorerLayout ?? string.Empty);
+ layoutView.ApplyEdits();
+ sqlite.Close();
+ LayoutDirty = false;
+ return;
+ }
- // Save project meta data
+ // Only update LastModified if user data has actually changed at the
+ // project level. Saves triggered solely by dirty children end up
+ // stamping LastModified via the ElementCollection_Saved handler.
+ if (IsDirty)
+ LastModified = DateTime.Now;
+ CreateTable(sqlite);
+
+ var dtView = sqlite.GetTableManager(_tableName);
+ if (dtView.NumberOfRows == 0) dtView.AddRow();
+ dtView.EditCell(0, nameof(Name), Name);
+ dtView.EditCell(0, nameof(Description), Description);
+ dtView.EditCell(0, nameof(CreationDate), FrameworkInterfaces.Utilities.Tools.DateToUniversalString(CreationDate));
+ dtView.EditCell(0, nameof(LastModified), FrameworkInterfaces.Utilities.Tools.DateToUniversalString(LastModified));
+ dtView.EditCell(0, nameof(FullFileName), FullFileName);
+ dtView.EditCell(0, nameof(SoftwareVersion), SoftwareVersion);
+ dtView.EditCell(0, nameof(AvalonDockLayout), AvalonDockLayout ?? string.Empty);
+ dtView.EditCell(0, nameof(ProjectExplorerLayout), ProjectExplorerLayout ?? string.Empty);
+ dtView.ApplyEdits();
+
+ // Next, save element collections
+ for (int i = 0; i < ElementCollections.Count; i++)
+ {
+ ElementCollections[i].Save();
+ }
- // Next, save element collections
- for (int i = 0; i < ElementCollections.Count; i++)
+ sqlite.Close();
+ NameOnDisk = Name;
+ SetIsDirty(false);
+ LayoutDirty = false;
+ MarkUndoSavePoint();
+ RaiseObjectSaved(this);
+ }
+ finally
{
- ElementCollections[i].Save();
+ // Defensive close — handles the case where any of the dtView edits,
+ // child collection saves, or post-success state changes throw.
+ if (sqlite.DataBaseOpen) sqlite.Close();
}
-
- NameOnDisk = Name;
- MarkUndoSavePoint();
- SetIsDirty(false);
- RaiseObjectSaved(this);
- }
-
- ///
- public override bool IsValid()
- {
- if (!_nameValid) return false;
- return true;
}
#region Compact and Optimize Project File
@@ -356,13 +665,22 @@ public override bool IsValid()
///
public override void Compact()
{
- // Compact or vacuum SQLite database to reduce file size.
+ // A basic connection is set so the size of the "-journal" temp file can be obtained.
+ var sqlite = new SQLiteManager(FullFileName);
+ var connectionBuilder = new SQLiteConnectionStringBuilder
+ {
+ Version = 3,
+ DataSource = FullFileName
+ };
+ sqlite.SetDatabaseConnection(connectionBuilder);
+ sqlite.Vacuum();
}
///
public override void Optimize()
{
- // Optimize the SQLite database for better performance.
+ var sqlite = new SQLiteManager(FullFileName);
+ sqlite.Optimize();
}
#endregion
diff --git a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
index 9da02535..67173819 100644
--- a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
+++ b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElement.cs
@@ -1,14 +1,21 @@
-using FrameworkInterfaces;
+using DatabaseManager;
+using FrameworkInterfaces;
using FrameworkInterfaces.Messaging;
using FrameworkInterfaces.Undo;
+using FrameworkInterfaces.Undo.Actions;
using Numerics.Distributions;
using Numerics.Data;
+using OxyPlot.Wpf;
+using OxyPlot.Wpf.Serialization;
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
+using System.Data;
+using System.Globalization;
using System.Windows.Media;
using System.Windows.Media.Imaging;
+using System.Xml.Linq;
namespace FrameworkUI.Demo
{
@@ -40,39 +47,46 @@ public class HazardElement : ElementBase, IUndoableElement
/// Optional parameter to open the function from disk upon construction.
public HazardElement(string name, IElementCollection parentCollection, bool openFromFile = false) : base(name, parentCollection)
{
- Name = name;
- CreationDate = DateTime.Now;
- LastModified = DateTime.Now;
+ IsUndoEnabled = false;
+ try
+ {
+ Name = name;
+ _nameOnDisk = name;
+ _creationDate = DateTime.Now;
+ _lastModified = DateTime.Now;
- ProbabilityOrdinates = new ProbabilityOrdinates();
-
+ // The default constructor seeds the standard 25 probability ordinates.
+ _probabilityOrdinates.CollectionChanged += ProbabilityOrdinates_CollectionChanged;
- // Create the undo bridge for collection changes
- // Note: Use UndoManager property (not _undoManager field) to ensure lazy initialization
- _ordinatesBridge = new UndoableCollectionBridge(
- ProbabilityOrdinates,
- () => IsUndoEnabled ? UndoManager : null,
- "probability ordinates",
- this
- );
+ // Create the element-owned frequency plot. The plot object lives on the
+ // element so its visual state can be tracked for undo and persisted.
+ _frequencyPlot = CreateDefaultFrequencyPlot();
+ InitializeMessages();
- InitializeMessages();
+ if (openFromFile)
+ {
+ Open();
+ _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
+ }
+ else
+ {
+ _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
+ _messenger.Add(_descriptionMsg);
+ _messenger.Add(_estimatedMsg);
+ }
- if (openFromFile)
- {
- Open();
- _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
+ SetElementValidation();
+ SetIsDirty(false);
}
- else
+ finally
{
- _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
- _messenger.Add(_descriptionMsg);
- _messenger.Add(_estimatedMsg);
+ // Open() creates the bridges itself; only create them here when
+ // constructing a fresh element.
+ if (!openFromFile) SetupBridges();
+ IsUndoEnabled = true;
+ ClearUndoHistory();
}
-
- SetElementValidation();
- SetIsDirty(false);
}
///
@@ -202,26 +216,32 @@ public override string Description
///
[Category("Meta Data"), DisplayName("Creation Date"), Description("The date and time this hazard function was first created."), Browsable(true)]
- public override DateTime CreationDate { get; }
+ public override DateTime CreationDate => _creationDate;
///
[Category("Meta Data"), DisplayName("Last Edited"), Description("The date and time this hazard function was last modified."), Browsable(true)]
- public override DateTime LastModified { get; }
+ public override DateTime LastModified => _lastModified;
#endregion
#region IElement Properties
///
- private static readonly Lazy s_icon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
- ///
- public override ImageSource ElementImage => s_icon.Value;
+ public override ImageSource ElementImage =>
+ System.Windows.Application.Current?.TryFindResource(ElementImageResourceKey) as ImageSource;
+
+ ///
+ /// Gets the resource key for the theme-aware element icon. The framework's project
+ /// explorer and window menus bind to this key with SetResourceReference so the
+ /// icon re-resolves when the theme changes.
+ ///
+ public string ElementImageResourceKey => "HazardElementIcon";
///
public override bool CanCopyFromExternal => true;
///
- public override string NameOnDisk { get; }
+ public override string NameOnDisk => _nameOnDisk;
///
public override bool IsValid => _isValid;
@@ -238,7 +258,9 @@ public override string Description
private ParameterEstimationMethod _estimationMethod = ParameterEstimationMethod.MethodOfMoments;
private ProbabilityOrdinates _probabilityOrdinates = new ProbabilityOrdinates();
private UndoableCollectionBridge _ordinatesBridge;
- private string _plotSettings;
+ private Plot _frequencyPlot;
+ private PlotUndoManager _frequencyPlotUndo;
+ private XElement _distributionSnapshot;
private bool _isEstimated = false;
private bool _minmaxComputed = false;
private double[] _minmax = new double[2];
@@ -280,25 +302,20 @@ public UnivariateDistributionBase ParentDistribution
get => _parentDistribution;
set
{
- if (_parentDistribution != value)
- {
- var oldValue = _parentDistribution;
- _parentDistribution = value;
-
- // Validate parent distribution parameters
- _distributionValid = true;
- _messenger.Remove(_parentDistMsg);
- if (!_parentDistribution.ParametersValid)
- {
- _distributionValid = false;
- _messenger.Add(_parentDistMsg);
- }
-
- IsEstimated = false;
- ClearResults();
- ValidateEstimationMethod();
- RecordPropertyChange(nameof(ParentDistribution), oldValue, value);
- }
+ if (value == null) return;
+
+ var oldSnapshot = _parentDistribution?.ToXElement();
+ var incomingSnapshot = value.ToXElement();
+ if (oldSnapshot != null && XNode.DeepEquals(oldSnapshot, incomingSnapshot)) return;
+
+ _parentDistribution = value.Clone();
+ IsEstimated = false;
+ ClearResults();
+ ValidateParentDistribution();
+ ValidateEstimationMethod();
+ RecordDistributionUndo(nameof(ParentDistribution), oldSnapshot, _parentDistribution.ToXElement());
+ _distributionSnapshot = _parentDistribution.ToXElement();
+ RaisePropertyChange(nameof(ParentDistribution), setDirty: false);
}
}
@@ -485,40 +502,19 @@ public ParameterEstimationMethod EstimationMethod
}
///
- /// Gets and sets the exceedance probability values used for plotting the distribution.
+ /// Gets the exceedance probability values used for plotting the distribution.
+ /// The collection is mutated in place (e.g., via the ordinates table or
+ /// );
+ /// edits are recorded for undo through the collection bridge.
///
- public ProbabilityOrdinates ProbabilityOrdinates
- {
- get => _probabilityOrdinates;
- set
- {
- if (_probabilityOrdinates != null)
- _probabilityOrdinates.CollectionChanged -= ProbabilityOrdinates_CollectionChanged;
-
- _probabilityOrdinates = value ?? new ProbabilityOrdinates();
-
- _probabilityOrdinates.CollectionChanged += ProbabilityOrdinates_CollectionChanged;
-
- RaisePropertyChange(nameof(ProbabilityOrdinates));
- }
- }
+ public ProbabilityOrdinates ProbabilityOrdinates => _probabilityOrdinates;
///
- /// Gets and sets the plot settings.
+ /// Gets the element-owned frequency plot. The view hosts this plot directly, so
+ /// user styling changes are recorded for undo through the plot undo manager and
+ /// persisted with the element.
///
- public string PlotSettings
- {
- get => _plotSettings;
- set
- {
- if (_plotSettings != value)
- {
- var oldValue = _plotSettings;
- _plotSettings = value;
- RecordPropertyChange(nameof(PlotSettings), oldValue, value);
- }
- }
- }
+ public Plot FrequencyPlot => _frequencyPlot;
///
/// Determines whether the distribution has been bootstrapped.
@@ -565,29 +561,34 @@ private set
///
public override IElement Copy(string newName = null)
{
- var element = new HazardElement(newName ?? Name, ParentCollection)
- {
- Description = Description,
- ParentDistribution = ParentDistribution.Clone(),
- IsUncertain = IsUncertain,
- EffectiveRecordLength = EffectiveRecordLength,
- ConfidenceIntervalWidth = ConfidenceIntervalWidth,
- Realizations = Realizations,
- PRNGSeed = PRNGSeed,
- EstimationMethod = EstimationMethod
- };
+ var element = new HazardElement(newName ?? Name, ParentCollection);
- // Disable undo recording while copying collection data
+ // Disable undo recording while copying data
element.IsUndoEnabled = false;
- element._probabilityOrdinates.Clear();
- foreach (var p in ProbabilityOrdinates)
+ try
+ {
+ element.Description = Description;
+ element.ParentDistribution = ParentDistribution.Clone();
+ element.IsUncertain = IsUncertain;
+ element.EffectiveRecordLength = EffectiveRecordLength;
+ element.ConfidenceIntervalWidth = ConfidenceIntervalWidth;
+ element.Realizations = Realizations;
+ element.PRNGSeed = PRNGSeed;
+ element.EstimationMethod = EstimationMethod;
+
+ element._probabilityOrdinates.Clear();
+ element._probabilityOrdinates.AddRange(ProbabilityOrdinates);
+ element._isEstimated = false;
+
+ // Copy the plot visual state via an XElement round-trip.
+ PlotSerializer.FromXElement(element._frequencyPlot, PlotSerializer.ToXElement(_frequencyPlot));
+ }
+ finally
{
- element._probabilityOrdinates.Add(p);
+ element.SetupBridges();
+ element.IsUndoEnabled = true;
+ element.ClearUndoHistory();
}
- element._plotSettings = _plotSettings;
- element._isEstimated = false;
- element.IsUndoEnabled = true;
- element.ClearUndoHistory();
return element;
}
@@ -595,37 +596,189 @@ public override IElement Copy(string newName = null)
///
public override IElement CopyFromExternal(string itemName, string fullFileName)
{
- // Copy from external file
- return null;
+ // Create SQLite connection to the external project file
+ var sqlite = new SQLiteManager(fullFileName);
+ var element = new HazardElement(itemName, ParentCollection);
+ element.Open(sqlite);
+ return element;
}
///
public override void Delete()
{
+ if (Name == null) return;
+ DisposeBridges();
+ SetIsDirty(false);
+
+ var sqlite = new SQLiteManager(ParentCollection.ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (sqlite.TableNames.Contains(ParentCollection.Name))
+ {
+ var dtView = sqlite.GetTableManager(ParentCollection.Name);
+ int rowIndex = dtView.SearchColumn(0, dtView.NumberOfRows - 1, "Name", NameOnDisk, true, true);
+ if (rowIndex >= 0 && rowIndex < dtView.NumberOfRows) dtView.DeleteRow(rowIndex);
+ dtView.ApplyEdits();
+ }
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ }
+
_messenger.Clear(this);
_undoManager.Clear();
- _ordinatesBridge?.Dispose();
- SetIsDirty(false);
- //
- // Delete from disk or database
- //
RaiseDeleted(this);
}
///
public override void Open()
{
- _messenger.Clear(this);
- IsUndoEnabled =false;
+ Open(new SQLiteManager(ParentCollection.ParentProject.FullFileName));
+ }
+
+ ///
+ /// Opens the element from disk using the specified SQLite manager.
+ ///
+ /// The SQLite manager to use for opening the element.
+ public void Open(SQLiteManager sqlite)
+ {
+ var wasUndoEnabled = IsUndoEnabled;
+ IsUndoEnabled = false;
+ try
+ {
+ _messenger.Clear(this);
- //
- // Load from disk or database
- //
+ var wasOpen = sqlite.DataBaseOpen;
+ if (wasOpen == false) sqlite.Open();
- SetElementValidation();
- IsUndoEnabled = true;
- ClearUndoHistory();
- SetIsDirty(false);
+ var dtView = sqlite.GetTableManager(ParentCollection.Name);
+ int rowIndex = dtView.SearchColumn(0, dtView.NumberOfRows - 1, "Name", NameOnDisk, true, true);
+ if (rowIndex != -1)
+ {
+ // Read scalar meta data using backing fields to avoid undo recording.
+ if (dtView.ColumnNames.Contains(nameof(Name)))
+ {
+ _name = dtView.GetCell(nameof(Name), rowIndex).ToString();
+ foreach (var item in _messages) item.SourceName = _name;
+ _nameValid = ValidateName(DemoProject.InvalidNameCharacters, 50, "PHF");
+ }
+ if (dtView.ColumnNames.Contains(nameof(Description)))
+ {
+ _description = dtView.GetCell(nameof(Description), rowIndex).ToString();
+ if (string.IsNullOrEmpty(_description))
+ _messenger.Add(_descriptionMsg);
+ else
+ _messenger.Remove(_descriptionMsg);
+ }
+ if (dtView.ColumnNames.Contains(nameof(CreationDate))) _creationDate = FrameworkInterfaces.Utilities.Tools.DateFromString(dtView.GetCell(nameof(CreationDate), rowIndex).ToString()) ?? DateTime.MinValue;
+ if (dtView.ColumnNames.Contains(nameof(LastModified))) _lastModified = FrameworkInterfaces.Utilities.Tools.DateFromString(dtView.GetCell(nameof(LastModified), rowIndex).ToString()) ?? DateTime.MinValue;
+
+ // Load the probability ordinates before the results so the collection
+ // changed handler (which clears results) cannot wipe restored output.
+ if (dtView.ColumnNames.Contains(nameof(ProbabilityOrdinates)))
+ {
+ ProbabilityOrdinates.FromDelimitedString(
+ dtView.GetCell(nameof(ProbabilityOrdinates), rowIndex).ToString(), "|");
+ }
+
+ // Parent distribution from XML, with a try/catch guard for legacy or
+ // corrupted files.
+ if (dtView.ColumnNames.Contains(nameof(ParentDistribution)))
+ {
+ try
+ {
+ var distXml = dtView.GetCell(nameof(ParentDistribution), rowIndex).ToString();
+ if (!string.IsNullOrEmpty(distXml))
+ _parentDistribution = UnivariateDistributionFactory.CreateDistribution(XElement.Parse(distXml));
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Could not deserialize the parent distribution for '{Name}': {ex.Message}");
+ }
+ }
+ _distributionValid = _parentDistribution != null && _parentDistribution.ParametersValid;
+ if (!_distributionValid) _messenger.Add(_parentDistMsg);
+
+ // Scalar simulation inputs. Parse into temporaries so a value that
+ // fails to parse leaves the element's default in place.
+ if (dtView.ColumnNames.Contains(nameof(IsUncertain)) && bool.TryParse(dtView.GetCell(nameof(IsUncertain), rowIndex).ToString(), out bool isUncertain)) _isUncertain = isUncertain;
+ if (dtView.ColumnNames.Contains(nameof(EffectiveRecordLength)) && int.TryParse(dtView.GetCell(nameof(EffectiveRecordLength), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out int erl)) _effectiveRecordLength = erl;
+ if (dtView.ColumnNames.Contains(nameof(ConfidenceIntervalWidth)) && double.TryParse(dtView.GetCell(nameof(ConfidenceIntervalWidth), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out double ciWidth)) _confidenceIntervalWidth = ciWidth;
+ if (dtView.ColumnNames.Contains(nameof(Realizations)) && int.TryParse(dtView.GetCell(nameof(Realizations), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out int realizations)) _realizations = realizations;
+ if (dtView.ColumnNames.Contains(nameof(PRNGSeed)) && int.TryParse(dtView.GetCell(nameof(PRNGSeed), rowIndex).ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out int seed)) _prngSeed = seed;
+ if (dtView.ColumnNames.Contains(nameof(EstimationMethod)) && Enum.TryParse(dtView.GetCell(nameof(EstimationMethod), rowIndex).ToString(), out ParameterEstimationMethod method)) _estimationMethod = method;
+ if (dtView.ColumnNames.Contains(nameof(IsEstimated)) && bool.TryParse(dtView.GetCell(nameof(IsEstimated), rowIndex).ToString(), out bool isEstimated)) _isEstimated = isEstimated;
+
+ // Bootstrap results from a compressed byte array BLOB.
+ if (dtView.ColumnNames.Contains(nameof(Results)))
+ {
+ try
+ {
+ if (dtView.GetCell(nameof(Results), rowIndex) is byte[] bytes && bytes.Length > 0)
+ Results = ResultsFromByteArray(Numerics.Tools.Decompress(bytes));
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Could not deserialize the results for '{Name}': {ex.Message}");
+ Results = null;
+ }
+ }
+
+ // Check for corrupted results.
+ if (_isEstimated && _isUncertain && (Results == null || Results.ParameterSets == null || Results.ParameterSets.Length == 0))
+ {
+ _isEstimated = false;
+ Results = new UncertaintyAnalysisResults();
+ }
+ if (Results == null) Results = new UncertaintyAnalysisResults();
+ if (!_isEstimated) _messenger.Add(_estimatedMsg);
+
+ // Deserialize the element-owned frequency plot.
+ if (dtView.ColumnNames.Contains("FrequencyPlotSettings"))
+ {
+ var plotXml = dtView.GetCell("FrequencyPlotSettings", rowIndex).ToString();
+ if (!string.IsNullOrEmpty(plotXml))
+ {
+ try
+ {
+ PlotSerializer.FromXElement(_frequencyPlot, XElement.Parse(plotXml));
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"Could not deserialize the frequency plot for '{Name}': {ex.Message}");
+ }
+ }
+ }
+ }
+
+ if (wasOpen == false) sqlite.Close();
+ SetupBridges();
+
+ ValidateEstimationMethod();
+ SetElementValidation();
+ SetIsDirty(false);
+
+ // Notify bindings for properties loaded via backing fields.
+ RaisePropertyChange(nameof(Name), setDirty: false);
+ RaisePropertyChange(nameof(ProbabilityOrdinates), setDirty: false);
+ RaisePropertyChange(nameof(IsEstimated), setDirty: false);
+ }
+ finally
+ {
+ IsUndoEnabled = wasUndoEnabled;
+ if (wasUndoEnabled) ClearUndoHistory();
+ }
+ }
+
+ ///
+ /// Raises the PreviewObjectSaved event before saving the element.
+ ///
+ /// Output parameter that determines if the save operation should be canceled.
+ public void RaisePreviewSaved(ref bool cancel)
+ {
+ RaisePreviewObjectSaved(this, ref cancel);
}
///
@@ -636,17 +789,156 @@ public override void Save()
RaisePreviewObjectSaved(this, ref cancel);
if (cancel) return;
- // Update last edited
- _lastModified = DateTime.Now;
+ // Create SQLite connection
+ var sqlite = new SQLiteManager(ParentCollection.ParentProject.FullFileName);
+ sqlite.Open();
+ DateTime previousLastModified = _lastModified;
+ bool committed = false;
+ try
+ {
+ // Only update last edited if user data actually changed
+ if (IsDirty)
+ {
+ _lastModified = DateTime.Now;
+ RaisePropertyChange(nameof(LastModified));
+ }
- //
- // Save to disk or database
- //
+ // Create the element collection table if it doesn't exist.
+ CreateTable(sqlite);
- _nameOnDisk = Name;
- MarkUndoSavePoint();
- SetIsDirty(false);
- RaiseObjectSaved(this);
+ var dtView = sqlite.GetTableManager(ParentCollection.Name);
+ int rowIndex = dtView.SearchColumn(0, dtView.NumberOfRows - 1, "Name", NameOnDisk, true, true);
+ if (rowIndex < 0 || rowIndex >= dtView.NumberOfRows)
+ {
+ dtView.AddRow();
+ rowIndex = dtView.NumberOfRows - 1;
+ }
+
+ dtView.EditCell(rowIndex, nameof(Name), Name);
+ dtView.EditCell(rowIndex, nameof(Description), Description);
+ dtView.EditCell(rowIndex, nameof(CreationDate), FrameworkInterfaces.Utilities.Tools.DateToUniversalString(CreationDate));
+ dtView.EditCell(rowIndex, nameof(LastModified), FrameworkInterfaces.Utilities.Tools.DateToUniversalString(LastModified));
+ dtView.EditCell(rowIndex, nameof(ParentDistribution), ParentDistribution.ToXElement().ToString());
+ dtView.EditCell(rowIndex, nameof(IsUncertain), IsUncertain);
+ dtView.EditCell(rowIndex, nameof(EffectiveRecordLength), EffectiveRecordLength);
+ dtView.EditCell(rowIndex, nameof(ConfidenceIntervalWidth), ConfidenceIntervalWidth.ToString("G17", CultureInfo.InvariantCulture));
+ dtView.EditCell(rowIndex, nameof(Realizations), Realizations);
+ dtView.EditCell(rowIndex, nameof(PRNGSeed), PRNGSeed);
+ dtView.EditCell(rowIndex, nameof(EstimationMethod), EstimationMethod.ToString());
+ dtView.EditCell(rowIndex, nameof(ProbabilityOrdinates), ProbabilityOrdinates?.ToDelimitedString("|") ?? "");
+ dtView.EditCell(rowIndex, nameof(IsEstimated), IsEstimated);
+ dtView.EditCell(rowIndex, nameof(Results), Numerics.Tools.Compress(ResultsToByteArray()));
+ dtView.EditCell(rowIndex, "FrequencyPlotSettings", _frequencyPlot != null ? PlotSerializer.ToXElement(_frequencyPlot).ToString() : "");
+
+ dtView.ApplyEdits();
+ sqlite.Close();
+ committed = true;
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ if (!committed && _lastModified != previousLastModified)
+ {
+ _lastModified = previousLastModified;
+ RaisePropertyChange(nameof(LastModified));
+ }
+ }
+
+ // Only mark clean / fire ObjectSaved when the commit actually succeeded.
+ if (committed)
+ {
+ SetIsDirty(false);
+ MarkUndoSavePoint();
+ _nameOnDisk = Name;
+ RaiseObjectSaved(this);
+ }
+ }
+
+ #endregion
+
+ #region SQLite Persistence
+
+ ///
+ /// The required columns for the SQLite table.
+ /// If you want to add a new column, add it to the end of the dictionary.
+ ///
+ private static Dictionary RequiredColumns { get; } = new Dictionary() {
+ { nameof(Name), typeof(string) },
+ { nameof(Description), typeof(string) },
+ { nameof(CreationDate), typeof(string) },
+ { nameof(LastModified), typeof(string) },
+ { nameof(ParentDistribution), typeof(string) },
+ { nameof(IsUncertain), typeof(bool) },
+ { nameof(EffectiveRecordLength), typeof(int) },
+ { nameof(ConfidenceIntervalWidth), typeof(string) },
+ { nameof(Realizations), typeof(int) },
+ { nameof(PRNGSeed), typeof(int) },
+ { nameof(EstimationMethod), typeof(string) },
+ { nameof(ProbabilityOrdinates), typeof(string) },
+ { nameof(IsEstimated), typeof(bool) },
+ { nameof(Results), typeof(byte[]) },
+ { "FrequencyPlotSettings", typeof(string) } };
+
+ ///
+ /// Creates or updates the SQLite database table for storing hazard elements.
+ ///
+ /// The SQLite database manager instance.
+ internal void CreateTable(SQLiteManager sqlite)
+ {
+ if (sqlite.TableNames.Contains(ParentCollection.Name) == false)
+ {
+ // If the table does not exist, then create the table
+ var dataTable = new DataTable(ParentCollection.Name);
+ foreach (KeyValuePair column in RequiredColumns)
+ dataTable.Columns.Add(column.Key, column.Value);
+ sqlite.SaveDataTable(dataTable);
+ }
+ else
+ {
+ // Add any required columns that don't exist
+ var dt = sqlite.GetTableManager(ParentCollection.Name);
+ int columnIndex;
+ foreach (KeyValuePair column in RequiredColumns)
+ {
+ columnIndex = Array.IndexOf(dt.ColumnNames, column.Key);
+ // If the column doesn't exist in the database then create it.
+ if (columnIndex < 0)
+ {
+ dt.AddColumn(column.Key, column.Value);
+ }
+ else
+ {
+ if (dt.ColumnTypes[columnIndex] != column.Value)
+ {
+ dt.DeleteColumn(columnIndex);
+ dt.AddColumn(column.Key, column.Value);
+ }
+ }
+ }
+ dt.ApplyEdits();
+ }
+ }
+
+ ///
+ /// Converts the bootstrap results to a byte array for BLOB storage. The byte array
+ /// format retains the bootstrap parameter sets, which the XML format does not.
+ ///
+ /// The serialized results, or an empty array when there are no results.
+ private byte[] ResultsToByteArray()
+ {
+ if (Results == null) return Array.Empty();
+ return UncertaintyAnalysisResults.ToByteArray(Results);
+ }
+
+ ///
+ /// Reconstructs the bootstrap results from a byte array.
+ ///
+ /// The serialized results.
+ /// The reconstructed results, or null when the array is empty.
+ private static UncertaintyAnalysisResults ResultsFromByteArray(byte[] bytes)
+ {
+ if (bytes == null || bytes.Length == 0) return null;
+ return UncertaintyAnalysisResults.FromByteArray(bytes);
}
#endregion
@@ -707,6 +999,13 @@ private void ValidateEstimationMethod()
_messenger.Remove(_pmomMsg);
_messenger.Remove(_lmomMsg);
+ if (ParentDistribution == null)
+ {
+ _estimationMethodValid = false;
+ SetElementValidation();
+ return;
+ }
+
if (IsUncertain && EstimationMethod == ParameterEstimationMethod.MethodOfMoments)
{
if (ParentDistribution.Type == UnivariateDistributionType.GeneralizedNormal ||
@@ -729,6 +1028,18 @@ private void ValidateEstimationMethod()
SetElementValidation();
}
+ /// Refreshes validation state and messages for the current parent distribution.
+ private void ValidateParentDistribution()
+ {
+ _distributionValid = true;
+ _messenger.Remove(_parentDistMsg);
+ if (_parentDistribution == null || !_parentDistribution.ParametersValid)
+ {
+ _distributionValid = false;
+ _messenger.Add(_parentDistMsg);
+ }
+ }
+
///
/// Sets the IsValid property for the element, this needs to be called whenever a property is changed.
///
@@ -774,29 +1085,36 @@ private void ClearResults()
/// The list of parameters.
public void SetDistributionParameters(IList parameters)
{
+ if (parameters == null || ParentDistribution == null) return;
if (parameters.Count != ParentDistribution.NumberOfParameters) return;
+
+ var oldSnapshot = ParentDistribution.ToXElement();
var parms = ParentDistribution.GetParameters;
+ bool changed = false;
for (int i = 0; i < ParentDistribution.NumberOfParameters; i++)
{
if (parms[i] != parameters[i])
{
- IsEstimated = false;
- ClearResults();
- RaisePropertyChange("Distribution Parameters");
+ changed = true;
break;
}
}
ParentDistribution.SetParameters(parameters);
+ ValidateParentDistribution();
+ ValidateEstimationMethod();
- _distributionValid = true;
- _messenger.Remove(_parentDistMsg);
- if (!ParentDistribution.ParametersValid)
+ if (changed)
{
- _distributionValid = false;
- _messenger.Add(_parentDistMsg);
+ IsEstimated = false;
+ ClearResults();
+ RecordDistributionUndo("Distribution Parameters", oldSnapshot, ParentDistribution.ToXElement());
+ _distributionSnapshot = ParentDistribution.ToXElement();
}
+
+ RaisePropertyChange("Distribution Parameters", setDirty: false);
+ RaisePropertyChange(nameof(ParentDistribution), setDirty: false);
}
///
@@ -858,6 +1176,8 @@ public IUnivariateDistribution SampleFunction()
if (IsUncertain)
{
+ if (!HasCompatibleCurveResults()) return null;
+
var xValues = Results.MeanCurve.ToArray();
var pValues = new double[ProbabilityOrdinates.Count];
@@ -884,17 +1204,16 @@ public IUnivariateDistribution SampleFunction()
public IUnivariateDistribution SampleFunction(double percentile)
{
if (!IsEstimated) return null;
+ if (double.IsNaN(percentile) || percentile < 0 || percentile > 1) return null;
if (IsUncertain)
{
- var distribution = ParentDistribution.Clone();
- // Get parameter set
- int i = Math.Min((int)Math.Floor(percentile * Realizations), Realizations - 1);
- if (Results?.ParameterSets?[i].Values != null)
- {
- distribution.SetParameters(Results.ParameterSets[i].Values);
- }
- return distribution;
+ var parameterSets = Results?.ParameterSets;
+ if (parameterSets == null || parameterSets.Length == 0) return null;
+
+ int index = (int)Math.Floor(percentile * parameterSets.Length);
+ if (index >= parameterSets.Length) index = parameterSets.Length - 1;
+ return SampleFunction(index);
}
else
{
@@ -909,16 +1228,15 @@ public IUnivariateDistribution SampleFunction(double percentile)
public IUnivariateDistribution SampleFunction(int index)
{
if (!IsEstimated) return null;
- if (index < 0 || index > Realizations - 1) return null;
if (IsUncertain)
{
+ var parameterSets = Results?.ParameterSets;
+ if (parameterSets == null || index < 0 || index >= parameterSets.Length) return null;
+ if (parameterSets[index].Values == null) return null;
+
var distribution = ParentDistribution.Clone();
- // Get parameter set
- if (Results?.ParameterSets?[index].Values != null)
- {
- distribution.SetParameters(Results.ParameterSets[index].Values);
- }
+ distribution.SetParameters(parameterSets[index].Values);
return distribution;
}
else
@@ -954,6 +1272,14 @@ public double MaxHazard(bool meanOnly)
///
private void ComputeMinMax(bool meanOnly)
{
+ if (ProbabilityOrdinates == null || ProbabilityOrdinates.Count == 0)
+ {
+ _minmax[0] = double.NaN;
+ _minmax[1] = double.NaN;
+ _minmaxComputed = true;
+ return;
+ }
+
double minP = Numerics.Tools.Min(ProbabilityOrdinates);
double maxP = Numerics.Tools.Max(ProbabilityOrdinates);
@@ -963,16 +1289,13 @@ private void ComputeMinMax(bool meanOnly)
_minmax[0] = dist == null ? double.NaN : dist.InverseCDF(1 - maxP);
_minmax[1] = dist == null ? double.NaN : dist.InverseCDF(1 - minP);
}
- else if (!meanOnly && IsUncertain && Results != null)
+ else if (!meanOnly && IsUncertain && Results?.ParameterSets != null && Results.ParameterSets.Length > 0)
{
- _minmax[0] = double.MaxValue;
- _minmax[1] = double.MinValue;
-
double localMin = double.MaxValue;
double localMax = double.MinValue;
object lockObj = new object();
- Parallel.For(0, Realizations, idx =>
+ Parallel.For(0, Results.ParameterSets.Length, idx =>
{
var dist = ParentDistribution.Clone();
if (Results?.ParameterSets?[idx].Values != null)
@@ -988,8 +1311,8 @@ private void ComputeMinMax(bool meanOnly)
}
});
- _minmax[0] = localMin;
- _minmax[1] = localMax;
+ _minmax[0] = localMin == double.MaxValue ? double.NaN : localMin;
+ _minmax[1] = localMax == double.MinValue ? double.NaN : localMax;
}
else
{
@@ -1000,6 +1323,205 @@ private void ComputeMinMax(bool meanOnly)
_minmaxComputed = true;
}
+ /// Checks whether stored curve results match the current probability ordinates and uncertainty mode.
+ /// True when every required result array has the expected dimensions.
+ private bool HasCompatibleCurveResults()
+ {
+ int count = ProbabilityOrdinates?.Count ?? 0;
+ if (count == 0 || Results?.ModeCurve == null || Results.ModeCurve.Length != count) return false;
+
+ if (IsUncertain)
+ {
+ if (Results.MeanCurve == null || Results.MeanCurve.Length != count) return false;
+ if (Results.ConfidenceIntervals == null || Results.ConfidenceIntervals.GetLength(0) != count || Results.ConfidenceIntervals.GetLength(1) < 2) return false;
+ }
+
+ return true;
+ }
+
+ #endregion
+
+ #region Plot Factory Methods
+
+ ///
+ /// Applies the default plot style used for the element-owned plot.
+ ///
+ /// The plot to style.
+ private static void ApplyDefaultPlotStyle(Plot plot)
+ {
+ plot.BorderThickness = new System.Windows.Thickness(0);
+ plot.Background = System.Windows.Media.Brushes.Transparent;
+ plot.LegendBackground = (Color)ColorConverter.ConvertFromString("#8CFFFFFF");
+ plot.LegendBorder = Colors.DarkGray;
+ plot.LegendPosition = OxyPlot.Legends.LegendPosition.TopRight;
+ plot.Padding = new System.Windows.Thickness(10, 10, 14, 10);
+ plot.PlotAreaBackground = new SolidColorBrush(Colors.White);
+ }
+
+ ///
+ /// Creates the default frequency plot with a logarithmic hazard axis and a normal
+ /// probability exceedance axis.
+ ///
+ /// A new configured for hazard frequency display.
+ private static Plot CreateDefaultFrequencyPlot()
+ {
+ var plot = new Plot();
+ ApplyDefaultPlotStyle(plot);
+ plot.Title = "Hazard Distribution";
+ plot.LegendPosition = OxyPlot.Legends.LegendPosition.TopLeft;
+ plot.Axes.Add(new LogarithmicAxis
+ {
+ Key = "Yaxis",
+ Position = OxyPlot.Axes.AxisPosition.Left,
+ PowerPadding = true,
+ Title = "Discharge",
+ Unit = "cfs",
+ AxisTitleDistance = 20,
+ TitleFontSize = 16,
+ FontSize = 12,
+ MajorGridlineStyle = OxyPlot.LineStyle.Solid,
+ MinorGridlineStyle = OxyPlot.LineStyle.Dash,
+ StringFormat = "N0"
+ });
+ plot.Axes.Add(new NormalProbabilityAxis
+ {
+ Key = "Xaxis",
+ Title = "Exceedance Probability ",
+ Unit = "P(X > x)",
+ Position = OxyPlot.Axes.AxisPosition.Bottom,
+ AxisTitleDistance = 20,
+ TitleFontSize = 16,
+ FontSize = 12,
+ });
+ return plot;
+ }
+
+ #endregion
+
+ #region Distribution Undo
+
+ /// Records a changed distribution snapshot when undo tracking is enabled.
+ /// The property named in the undo action.
+ /// The distribution state before the change.
+ /// The distribution state after the change.
+ private void RecordDistributionUndo(string propertyName, XElement oldSnapshot, XElement newSnapshot)
+ {
+ var undoManager = IsUndoEnabled ? UndoManager : null;
+ if (undoManager == null || undoManager.IsExecutingAction) return;
+ if (oldSnapshot == null || newSnapshot == null) return;
+ if (XNode.DeepEquals(oldSnapshot, newSnapshot)) return;
+
+ var undoSnapshot = new XElement(oldSnapshot);
+ var redoSnapshot = new XElement(newSnapshot);
+ var action = new DelegateAction(
+ $"Change {propertyName}",
+ () => RestoreDistributionFromSnapshot(redoSnapshot),
+ () => RestoreDistributionFromSnapshot(undoSnapshot),
+ this);
+ undoManager.RecordAction(action);
+ SetIsDirty(true);
+ }
+
+ /// Restores a distribution snapshot and refreshes validation and dependent result state.
+ /// The serialized distribution state to restore.
+ private void RestoreDistributionFromSnapshot(XElement snapshot)
+ {
+ if (snapshot == null) return;
+
+ _parentDistribution = UnivariateDistributionFactory.CreateDistribution(new XElement(snapshot));
+ IsEstimated = false;
+ ClearResults();
+ ValidateParentDistribution();
+ ValidateEstimationMethod();
+ _distributionSnapshot = _parentDistribution.ToXElement();
+ RaisePropertyChange(nameof(ParentDistribution), setDirty: false);
+ RaisePropertyChange("Distribution Parameters", setDirty: false);
+ }
+
+ #endregion
+
+ #region Undo Bridge Management
+
+ ///
+ /// Creates undo bridges for the ordinates collection and the element-owned plot.
+ /// Disposes any existing bridges before creating new ones.
+ ///
+ private void SetupBridges()
+ {
+ DisposeBridges();
+
+ // Subscribe to UndoManager.StateChanged to revalidate after undo/redo completes.
+ UndoManager.StateChanged += UndoManager_StateChanged;
+
+ // Collection bridge for probability ordinates.
+ // Note: Use UndoManager property (not _undoManager field) to ensure lazy initialization.
+ _ordinatesBridge = new UndoableCollectionBridge(
+ _probabilityOrdinates,
+ () => IsUndoEnabled ? UndoManager : null,
+ "probability ordinates",
+ this);
+
+ _distributionSnapshot = _parentDistribution?.ToXElement();
+
+ // Plot undo manager for the element-owned frequency plot.
+ if (_frequencyPlot != null)
+ {
+ _frequencyPlotUndo = new PlotUndoManager(
+ _frequencyPlot,
+ () => IsUndoEnabled ? UndoManager : null,
+ "frequency plot",
+ this,
+ () => SetIsDirty(true));
+ }
+ }
+
+ ///
+ /// Handles UndoManager.StateChanged to revalidate the element after undo/redo.
+ ///
+ /// The event sender.
+ /// The event arguments.
+ private void UndoManager_StateChanged(object sender, EventArgs e)
+ {
+ SetElementValidation();
+ }
+
+ ///
+ /// Disposes all undo bridges and sets their references to null.
+ ///
+ private void DisposeBridges()
+ {
+ UndoManager.StateChanged -= UndoManager_StateChanged;
+
+ _ordinatesBridge?.Dispose();
+ _ordinatesBridge = null;
+
+ _frequencyPlotUndo?.Dispose();
+ _frequencyPlotUndo = null;
+ _distributionSnapshot = null;
+ }
+
+ ///
+ /// Suspends the plot undo bridges so that programmatic plot changes (series population,
+ /// plot settings restoration) do not create undo entries.
+ ///
+ /// An that resumes recording when disposed.
+ public IDisposable SuspendPlotBridges()
+ {
+ var suspensions = new List();
+ if (_frequencyPlotUndo != null) suspensions.Add(_frequencyPlotUndo.SuspendRecording());
+ return new AggregateDisposable(suspensions);
+ }
+
+ ///
+ /// Rebuilds series and annotation bridges for the specified plot after a bulk series update.
+ /// Call this after populating series inside a block.
+ ///
+ /// The plot whose bridges should be rebuilt.
+ public void RebuildSeriesAndAnnotationBridges(Plot plot)
+ {
+ if (plot == _frequencyPlot) _frequencyPlotUndo?.RebuildSeriesAndAnnotationBridges();
+ }
+
#endregion
#endregion
diff --git a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
index e62ccac0..f0f7469d 100644
--- a/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
+++ b/src/FrameworkUI.Demo/Model/Hazard Elements/HazardElementCollection.cs
@@ -1,4 +1,6 @@
-using FrameworkInterfaces;
+using DatabaseManager;
+using FrameworkInterfaces;
+using System;
namespace FrameworkUI.Demo
{
@@ -25,85 +27,222 @@ public class HazardElementCollection : ElementCollectionBase
/// Initializes a new instance of the class.
///
/// The parent project that contains this collection.
- public HazardElementCollection(IProject parentProject) : base(parentProject)
- {
- }
+ public HazardElementCollection(IProject parentProject) : base(parentProject) { }
+
+ ///
+ /// Gets the name of the collection. This is also the name of the SQLite table
+ /// storing the hazard elements.
+ ///
+ public override string Name => "Hazard Functions";
///
- /// Gets the name of the collection.
+ /// Tracks whether the loaded collection table contained blank or duplicate rows.
///
- public override string Name
+ private bool _needsTableCompaction;
+
+ ///
+ /// Saves all hazard elements in the collection to disk.
+ ///
+ ///
+ /// This method raises preview events for each element, allowing cancellation, and
+ /// only saves elements that are marked as dirty.
+ ///
+ public override void Save()
{
- get
+ bool cancel = false;
+ RaisePreviewObjectSaved(this, ref cancel);
+ if (cancel == true) return;
+
+ _savingAll = true;
+ try
{
- return "Hazard Functions";
+ if (_needsTableCompaction)
+ {
+ // The loaded table contained stale rows. Rewrite it from scratch so
+ // the on-disk table matches the in-memory collection exactly.
+ for (int i = 0; i < ElementList.Count; i++)
+ {
+ ((HazardElement)ElementList[i]).RaisePreviewSaved(ref cancel);
+ if (cancel == true) return;
+ }
+
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (sqlite.TableNames.Contains(Name))
+ {
+ sqlite.DeleteTable(Name);
+ }
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ }
+
+ for (int i = 0; i < ElementList.Count; i++)
+ {
+ ElementList[i].Save();
+ }
+
+ _needsTableCompaction = false;
+ }
+ else
+ {
+ // Save all elements.
+ for (int i = 0; i < ElementList.Count; i++)
+ {
+ // Always raise preview saved. This allows the plot settings to always be saved.
+ ((HazardElement)ElementList[i]).RaisePreviewSaved(ref cancel);
+ if (cancel == true) continue;
+ // Only save if dirty
+ if (ElementList[i].IsDirty)
+ ElementList[i].Save();
+ }
+ }
+
+ SetIsDirty(false);
+ RaiseObjectSaved();
+ }
+ finally
+ {
+ _savingAll = false;
}
}
///
- /// Opens the collection and loads elements from disk.
+ /// Loads all hazard elements from disk into the collection.
///
+ ///
+ /// This method reads the SQLite database table and creates a
+ /// instance for each row found.
+ ///
public override void Open()
{
_opening = true;
- // read from disk
- _opening = false;
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (sqlite.TableNames.Contains(Name) == true)
+ {
+ var dtView = sqlite.GetTableManager(Name);
+ bool needsRewrite;
+ foreach (string elementName in CollectionPersistenceHelper.BuildSingleTableLoadEntries(dtView, out needsRewrite))
+ {
+ var element = new HazardElement(elementName, this, true);
+ Add(element);
+ }
+
+ _needsTableCompaction = needsRewrite;
+ }
+ sqlite.Close();
+ SetIsDirty(_needsTableCompaction);
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ _opening = false;
+ }
}
///
/// Adds a hazard element to the collection.
///
/// The element to add to the collection.
+ ///
+ /// This method subscribes to property change and deletion events, saves the element
+ /// to disk if needed, and raises the ElementAdded event.
+ ///
public override void Add(IElement item)
{
+ item.PropertyChanged += ElementPropertyChanged;
item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
ElementList.Add((HazardElement)item);
- // Save to disk
if (_opening == false)
{
- //item.IsValid();
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (CollectionPersistenceHelper.NamedRowExists(sqlite, Name, item.Name) == false)
+ {
+ item.Save();
+ }
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ }
SetIsDirty(true);
}
RaiseElementAddedEvent(item);
}
///
- /// Inserts a hazard element at the specified index in the collection.
+ /// Inserts a hazard element into the collection at the specified index.
///
/// The zero-based index at which the element should be inserted.
- /// The element to insert.
+ /// The element to insert into the collection.
+ ///
+ /// This method subscribes to property change and deletion events, saves the element
+ /// to disk if needed, and raises the ElementAdded event.
+ ///
public override void Insert(int index, IElement item)
{
+ item.PropertyChanged += ElementPropertyChanged;
item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
ElementList.Insert(index, (HazardElement)item);
- // save to disk
- SetIsDirty(true);
+ if (_opening == false)
+ {
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ if (CollectionPersistenceHelper.NamedRowExists(sqlite, Name, item.Name) == false)
+ {
+ item.Save();
+ }
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ }
+ SetIsDirty(true);
+ }
RaiseElementAddedEvent(item);
}
///
- /// Deletes the collection from disk.
+ /// Copies a hazard element from an external project and inserts it into this
+ /// collection at the specified index.
///
- public override void Delete()
+ /// The zero-based index at which the element should be inserted.
+ /// The name of the element to copy.
+ /// The fully qualified type name of the element.
+ /// The full file path to the external project database.
+ public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
{
- // Delete from disk
+ var element = new HazardElement(elementName, this);
+ element.Open(new SQLiteManager(fullFileName));
+ Insert(index, element);
}
///
- /// Inserts an element from an external project at the specified index.
+ /// Deletes the entire hazard element collection and removes all associated data from disk.
///
- /// The zero-based index at which the element should be inserted.
- /// The name of the element to insert.
- /// The type of the element.
- /// The full path to the external project file.
- public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
+ public override void Delete()
{
- Insert(index, new HazardElement(elementName, this));
- //throw new System.NotImplementedException();
+ var sqlite = new SQLiteManager(ParentProject.FullFileName);
+ sqlite.Open();
+ try
+ {
+ sqlite.DeleteTable(Name);
+ }
+ finally
+ {
+ if (sqlite.DataBaseOpen) sqlite.Close();
+ }
}
}
}
diff --git a/src/FrameworkUI.Demo/Model/Response Elements/ResponseElement.cs b/src/FrameworkUI.Demo/Model/Response Elements/ResponseElement.cs
deleted file mode 100644
index 7f8f0b17..00000000
--- a/src/FrameworkUI.Demo/Model/Response Elements/ResponseElement.cs
+++ /dev/null
@@ -1,129 +0,0 @@
-using System;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// Represents a response element in the demo project.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- internal class ResponseElement : ElementBase
- {
- ///
- /// Initializes a new instance of the class.
- ///
- /// The name of the response element.
- /// The parent collection that contains this element.
- public ResponseElement(string name, IElementCollection parentCollection) : base(name, parentCollection)
- {
- Name = name;
- _description = "Response Element";
- _creationDate = DateTime.Now;
- _lastModified = _creationDate;
- SetIsDirty(false);
- }
-
- ///
- /// Gets or sets the name of the response element.
- ///
- public override string Name { get; set; }
-
- ///
- /// Gets or sets the description of the response element.
- ///
- public override string Description { get => _description; set => _description = value; }
-
- ///
- /// Gets the creation date of the response element.
- ///
- public override DateTime CreationDate => _creationDate;
-
- ///
- /// Gets the last modified date of the response element.
- ///
- public override DateTime LastModified => _lastModified;
-
- ///
- /// Gets the name used when saving the element to disk.
- ///
- public override string NameOnDisk => "Response Element";
-
- ///
- /// Gets the image icon representing the element.
- ///
- private static readonly Lazy s_icon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
- public override ImageSource ElementImage => s_icon.Value;
-
- ///
- /// Gets a value indicating whether this element can be copied from an external project.
- ///
- public override bool CanCopyFromExternal => true;
-
- ///
- /// Gets a value indicating whether the element is valid.
- ///
- public override bool IsValid { get => true; }
-
- ///
- /// Creates a copy of the response element.
- ///
- /// The name for the copied element. If null, a default name will be used.
- /// A copy of the element, or null if copying is not implemented.
- public override IElement Copy(string newName = null)
- {
- //throw new NotImplementedException();
- return null;
- }
-
- ///
- /// Copies an element from an external project file.
- ///
- /// The name of the item to copy.
- /// The full path to the external file.
- /// A copy of the external element, or null if not implemented.
- public override IElement CopyFromExternal(string itemName, string fullFileName)
- {
- //throw new NotImplementedException();
- return null;
- }
-
- ///
- /// Deletes the response element.
- ///
- public override void Delete()
- {
- //throw new NotImplementedException();
- }
-
-
- ///
- /// Opens the response element for editing.
- ///
- public override void Open()
- {
- //throw new NotImplementedException();
- }
-
- ///
- /// Saves the response element to disk.
- ///
- public override void Save()
- {
- //throw new NotImplementedException();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/Response Elements/ResponseElementCollection.cs b/src/FrameworkUI.Demo/Model/Response Elements/ResponseElementCollection.cs
deleted file mode 100644
index 6863aaf7..00000000
--- a/src/FrameworkUI.Demo/Model/Response Elements/ResponseElementCollection.cs
+++ /dev/null
@@ -1,113 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// Collection of response function elements for the demo project.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- internal class ResponseElementCollection : ElementCollectionBase
- {
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The parent project that contains this collection.
- public ResponseElementCollection(IProject parentProject) : base(parentProject)
- {
- }
-
- ///
- /// Gets the name of the collection.
- ///
- public override string Name
- {
- get
- {
- return "Response Functions";
- }
- }
-
- ///
- /// Opens the collection and loads elements from disk.
- ///
- public override void Open()
- {
- _opening = true;
- // read from disk
- _opening = false;
- }
-
- ///
- /// Adds a response element to the collection.
- ///
- /// The element to add to the collection.
- public override void Add(IElement item)
- {
- item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
- ElementList.Add((ResponseElement)item);
- // Save to disk
- if (_opening == false)
- {
- //item.IsValid();
- SetIsDirty(true);
- }
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Inserts a response element at the specified index in the collection.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The element to insert.
- public override void Insert(int index, IElement item)
- {
- item.Deleted += ElementDeleted;
- //item.AddMessage += RaiseAddMessage;
- //item.RemoveMessage += RaiseRemoveMessage;
- ElementList.Insert(index, (ResponseElement)item);
- // save to disk
- SetIsDirty(true);
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Deletes the collection from disk.
- ///
- public override void Delete()
- {
- // Delete from disk
- }
-
- ///
- /// Inserts an element from an external project at the specified index.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The name of the element to insert.
- /// The type of the element.
- /// The full path to the external project file.
- public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
- {
- throw new System.NotImplementedException();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/Support/AggregateDisposable.cs b/src/FrameworkUI.Demo/Model/Support/AggregateDisposable.cs
new file mode 100644
index 00000000..bc92e5df
--- /dev/null
+++ b/src/FrameworkUI.Demo/Model/Support/AggregateDisposable.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+
+namespace FrameworkUI.Demo
+{
+ ///
+ /// Aggregates multiple instances into a single disposable that
+ /// disposes them all in reverse order when disposed. Used by SuspendPlotBridges()
+ /// methods across UI element classes to collect and manage suspension tokens from
+ /// multiple instances.
+ ///
+ ///
+ ///
+ /// Authors:
+ /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
+ ///
+ ///
+ /// This class is safe against double-dispose: once disposed, subsequent calls to
+ /// are no-ops. Disposes are performed in reverse insertion order
+ /// to mirror try/finally unwinding semantics.
+ ///
+ ///
+ internal sealed class AggregateDisposable : IDisposable
+ {
+ ///
+ /// The list of disposable objects to aggregate.
+ ///
+ private readonly List _disposables;
+
+ ///
+ /// Indicates whether this instance has already been disposed.
+ ///
+ private bool _disposed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The list of disposable objects to aggregate.
+ public AggregateDisposable(List disposables) => _disposables = disposables;
+
+ ///
+ /// Disposes all aggregated objects in reverse order. Safe to call multiple times.
+ ///
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ for (int i = _disposables.Count - 1; i >= 0; i--)
+ _disposables[i]?.Dispose();
+ }
+ }
+}
diff --git a/src/FrameworkUI.Demo/Model/Support/CollectionPersistenceHelper.cs b/src/FrameworkUI.Demo/Model/Support/CollectionPersistenceHelper.cs
new file mode 100644
index 00000000..54f8237c
--- /dev/null
+++ b/src/FrameworkUI.Demo/Model/Support/CollectionPersistenceHelper.cs
@@ -0,0 +1,97 @@
+using DatabaseManager;
+using System;
+using System.Collections.Generic;
+
+namespace FrameworkUI.Demo
+{
+ ///
+ /// Provides safe SQLite load-entry helpers for element collections.
+ ///
+ ///
+ ///
+ /// Authors:
+ /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
+ ///
+ ///
+ /// Collection tables can become stale independently from the elements they index. These
+ /// helpers implement a consistent first-row-wins repair policy while preserving true
+ /// distinct element-name conflicts for the normal validation layer.
+ ///
+ ///
+ internal static class CollectionPersistenceHelper
+ {
+ ///
+ /// Builds load entries from a single-table collection using first duplicate name wins.
+ ///
+ /// The collection table view.
+ /// Receives true when blank or duplicate rows were skipped.
+ /// The ordered loadable element names.
+ public static List BuildSingleTableLoadEntries(DataTableView dtView, out bool needsRewrite)
+ {
+ needsRewrite = false;
+ var entries = new List();
+ var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ for (int i = 0; i < dtView.NumberOfRows; i++)
+ {
+ string elementName = ReadName(dtView, i);
+ if (string.IsNullOrWhiteSpace(elementName))
+ {
+ needsRewrite = true;
+ continue;
+ }
+
+ if (seenNames.Add(elementName) == false)
+ {
+ needsRewrite = true;
+ continue;
+ }
+
+ entries.Add(elementName);
+ }
+
+ return entries;
+ }
+
+ ///
+ /// Determines whether a table contains a row with the specified element name.
+ ///
+ /// The SQLite project connection.
+ /// The table name to search.
+ /// The element name to find.
+ /// true when a matching row exists; otherwise, false.
+ public static bool NamedRowExists(SQLiteManager sqlite, string tableName, string elementName)
+ {
+ if (string.IsNullOrWhiteSpace(elementName) ||
+ sqlite.TableNames.Contains(tableName) == false)
+ {
+ return false;
+ }
+
+ DataTableView dtView = sqlite.GetTableManager(tableName);
+ if (dtView.ColumnNames.Contains("Name") == false)
+ {
+ return false;
+ }
+
+ return dtView.SearchColumn(0, dtView.NumberOfRows - 1, "Name", elementName, true, true) != -1;
+ }
+
+ ///
+ /// Reads an element name from a single-table collection row.
+ ///
+ /// The table view to read.
+ /// The row index to read.
+ /// The row name, or an empty string when absent.
+ private static string ReadName(DataTableView dtView, int rowIndex)
+ {
+ if (dtView.ColumnNames.Contains("Name"))
+ {
+ return dtView.GetCell("Name", rowIndex)?.ToString() ?? string.Empty;
+ }
+
+ object[] row = dtView.GetRow(rowIndex);
+ return row.Length != 0 ? row[0]?.ToString() ?? string.Empty : string.Empty;
+ }
+ }
+}
diff --git a/src/FrameworkUI.Demo/Model/Support/PlotUndoManager.cs b/src/FrameworkUI.Demo/Model/Support/PlotUndoManager.cs
new file mode 100644
index 00000000..66f5fca3
--- /dev/null
+++ b/src/FrameworkUI.Demo/Model/Support/PlotUndoManager.cs
@@ -0,0 +1,549 @@
+// --------------------------------------------------------------------------------------------------------------------
+//
+// Copyright (c) 2024 USACE Risk Management Center. All rights reserved.
+//
+//
+// Manages undo bridges for a single OxyPlot Plot and all its child elements.
+//
+// --------------------------------------------------------------------------------------------------------------------
+
+namespace FrameworkUI.Demo
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Collections.Specialized;
+ using System.ComponentModel;
+ using System.Linq;
+ using FrameworkInterfaces;
+ using FrameworkInterfaces.Undo;
+ using FrameworkInterfaces.Undo.Actions;
+ using OxyPlot.Wpf;
+ using OxyPlot.Wpf.Serialization;
+
+ ///
+ /// Manages undo bridges for a single OxyPlot and all its child elements
+ /// (axes, series, annotations).
+ ///
+ ///
+ ///
+ /// Authors:
+ /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
+ ///
+ ///
+ /// Each element creates one per plot. The manager automatically
+ /// monitors and collection changes
+ /// to rebuild bridges when axes change type or annotations are added/removed via the toolbar.
+ ///
+ ///
+ /// Series bridges are NOT auto-monitored because is cleared and
+ /// repopulated during data update operations. Instead, call
+ /// explicitly after each UpdateXxxPlot() method, outside of any scope.
+ ///
+ ///
+ public class PlotUndoManager : IDisposable
+ {
+ ///
+ /// WPF theming properties that change automatically when
+ /// cascades through the visual tree (e.g., disabled state styling). These must NOT be tracked for undo
+ /// because they are not user-initiated changes. For example, when DisableOpenWindows() sets
+ /// IsEnabled = false on all document controls during MCMC estimation, WPF coerces
+ /// Foreground/Background on OxyPlot Series objects, which would create spurious undo entries
+ /// on every open element.
+ ///
+ private static readonly HashSet WpfThemingProperties = new()
+ {
+ "Foreground", "Background", "IsEnabled"
+ };
+
+ ///
+ /// Plot visual properties for undo tracking, excluding WPF theming properties.
+ /// The full list is still used for serialization.
+ ///
+ private static readonly IReadOnlyList PlotUndoProperties =
+ PlotSerializer.PlotVisualProperties.Where(p => !WpfThemingProperties.Contains(p)).ToList();
+
+ ///
+ /// Axis visual properties for undo tracking, excluding WPF theming properties.
+ /// The full list is still used for serialization.
+ ///
+ private static readonly IReadOnlyList AxisUndoProperties =
+ AxisSerializer.AxisVisualProperties.Where(p => !WpfThemingProperties.Contains(p)).ToList();
+
+ ///
+ /// Series visual properties for undo tracking, excluding WPF theming properties.
+ /// The full list is still used for serialization.
+ ///
+ private static readonly IReadOnlyList SeriesUndoProperties =
+ SeriesSerializer.SeriesVisualProperties.Where(p => !WpfThemingProperties.Contains(p)).ToList();
+
+ ///
+ /// Annotation visual properties for undo tracking, excluding WPF theming properties.
+ /// The full list is still used for serialization.
+ ///
+ private static readonly IReadOnlyList AnnotationUndoProperties =
+ AnnotationSerializer.AnnotationVisualProperties.Where(p => !WpfThemingProperties.Contains(p)).ToList();
+
+ ///
+ /// The plot being managed.
+ ///
+ private readonly Plot _plot;
+
+ ///
+ /// Lambda that returns the when undo is enabled, or null otherwise.
+ ///
+ private readonly Func _getUndoManager;
+
+ ///
+ /// Human-readable description for undo action labels (e.g. "time series plot").
+ ///
+ private readonly string _description;
+
+ ///
+ /// The owning element, used as the undo action target.
+ ///
+ private readonly object _target;
+
+ ///
+ /// Callback invoked when any bridge records an undo action.
+ ///
+ private readonly Action _onActionRecorded;
+
+ ///
+ /// Bridge for plot-level visual properties (Title, LegendPosition, Background, etc.).
+ ///
+ private UndoableStateBridge _plotBridge;
+
+ ///
+ /// Per-axis state bridges. Rebuilt when collection changes.
+ ///
+ private readonly List _axisBridges = new List();
+
+ ///
+ /// Per-series state bridges. Rebuilt explicitly via .
+ ///
+ private readonly List _seriesBridges = new List();
+
+ ///
+ /// Per-annotation state bridges. Rebuilt when collection changes
+ /// or explicitly via .
+ ///
+ private readonly List _annotationBridges = new List();
+
+ ///
+ /// Whether recording is currently suspended.
+ ///
+ private bool _isSuspended;
+
+ ///
+ /// Reference count for nested calls.
+ /// Only the outermost scope actually suspends/resumes the bridges;
+ /// inner scopes are no-ops that just increment/decrement the depth.
+ ///
+ private int _suspensionDepth;
+
+ ///
+ /// Whether was called while suspended.
+ /// When true, the outermost will perform the deferred
+ /// rebuild upon dispose.
+ ///
+ private bool _needsRebuild;
+
+ ///
+ /// Whether this instance has been disposed.
+ ///
+ private bool _isDisposed;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The plot to manage undo bridges for.
+ /// Lambda returning the or null.
+ /// Human-readable description for undo action labels.
+ /// The owning element for undo action association.
+ /// Callback when any bridge records an undo action.
+ ///
+ /// Thrown when , ,
+ /// or is null.
+ ///
+ public PlotUndoManager(Plot plot, Func getUndoManager, string description, object target, Action onActionRecorded)
+ {
+ _plot = plot ?? throw new ArgumentNullException(nameof(plot));
+ _getUndoManager = getUndoManager ?? throw new ArgumentNullException(nameof(getUndoManager));
+ _description = description ?? "plot";
+ _target = target;
+ _onActionRecorded = onActionRecorded ?? throw new ArgumentNullException(nameof(onActionRecorded));
+
+ CreateAllBridges();
+
+ // Auto-monitor axis collection for type changes (axis object replacement).
+ _plot.Axes.CollectionChanged += OnAxesCollectionChanged;
+
+ // Auto-monitor annotation collection for toolbar add/remove.
+ _plot.Annotations.CollectionChanged += OnAnnotationsCollectionChanged;
+
+ // Listen for axis type changes to record undoable replacement actions.
+ _plot.AxisReplaced += OnAxisReplaced;
+
+ // Listen for bulk annotation modification notifications (e.g. after suppressed
+ // drag placement completes and Plot.NotifyAnnotationsModified() is called).
+ _plot.PropertyChanged += OnPlotPropertyChanged;
+ }
+
+ ///
+ /// Gets the plot that this manager is tracking.
+ ///
+ public Plot Plot => _plot;
+
+ ///
+ /// Suspends undo recording on all bridges managed by this instance.
+ ///
+ /// An that resumes recording when disposed.
+ ///
+ ///
+ /// Use this during bulk UpdateXxxPlot() operations to prevent spurious undo entries.
+ /// After disposing, call to reconnect
+ /// bridges to the new series and annotations.
+ ///
+ ///
+ /// Supports nesting: only the outermost call suspends the bridges; inner calls are
+ /// lightweight no-ops. The bridges resume only when the outermost scope disposes.
+ ///
+ ///
+ public IDisposable SuspendRecording()
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(PlotUndoManager),
+ "Cannot suspend recording on a disposed PlotUndoManager. Re-create the manager " +
+ "via the owning element's SetupBridges if a fresh suspension is needed after Dispose().");
+ _suspensionDepth++;
+ _isSuspended = true;
+
+ // Nested call — bridges are already suspended by the outer scope.
+ if (_suspensionDepth > 1)
+ return new SuspensionScope(this, new List());
+
+ // Outermost call — actually suspend all bridges.
+ var suspensions = new List();
+
+ if (_plotBridge != null)
+ suspensions.Add(_plotBridge.SuspendRecording());
+
+ foreach (var b in _axisBridges)
+ suspensions.Add(b.SuspendRecording());
+
+ foreach (var b in _seriesBridges)
+ suspensions.Add(b.SuspendRecording());
+
+ foreach (var b in _annotationBridges)
+ suspensions.Add(b.SuspendRecording());
+
+ return new SuspensionScope(this, suspensions);
+ }
+
+ ///
+ /// Rebuilds per-item state bridges for series and annotations.
+ ///
+ ///
+ ///
+ /// Call this after UpdateXxxPlot() methods that clear and repopulate
+ /// and .
+ ///
+ ///
+ /// If called while recording is suspended (inside a scope),
+ /// the rebuild is deferred until the outermost scope disposes. This prevents creating
+ /// live bridges that would fire PropertyChanged events during bulk updates.
+ ///
+ ///
+ public void RebuildSeriesAndAnnotationBridges()
+ {
+ if (_isDisposed)
+ throw new ObjectDisposedException(nameof(PlotUndoManager),
+ "Cannot rebuild bridges on a disposed PlotUndoManager. Calling this on a " +
+ "stale instance after Dispose() typically indicates a wrong-reference bug — " +
+ "the App-side control should use the owning element's current bridge manager.");
+
+ // Defer rebuild until the outermost suspension scope exits.
+ // Creating bridges while suspended would leave them live (unsuspended),
+ // causing PropertyChanged recording during subsequent plot updates.
+ if (_isSuspended)
+ {
+ _needsRebuild = true;
+ return;
+ }
+
+ DisposeSeriesBridges();
+ DisposeAnnotationBridges();
+
+ foreach (Series series in _plot.Series)
+ {
+ _seriesBridges.Add(new UndoableStateBridge(
+ series, _getUndoManager, $"{_description} series", _target,
+ includedProperties: SeriesUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+
+ foreach (Annotation annotation in _plot.Annotations)
+ {
+ _annotationBridges.Add(new UndoableStateBridge(
+ annotation, _getUndoManager, $"{_description} annotation", _target,
+ includedProperties: AnnotationUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+ }
+
+ ///
+ /// Releases all bridges and unsubscribes from collection events.
+ ///
+ public void Dispose()
+ {
+ if (_isDisposed) return;
+ _isDisposed = true;
+
+ _plot.Axes.CollectionChanged -= OnAxesCollectionChanged;
+ _plot.Annotations.CollectionChanged -= OnAnnotationsCollectionChanged;
+ _plot.AxisReplaced -= OnAxisReplaced;
+ _plot.PropertyChanged -= OnPlotPropertyChanged;
+
+ _plotBridge?.Dispose();
+ _plotBridge = null;
+
+ DisposeAxisBridges();
+ DisposeSeriesBridges();
+ DisposeAnnotationBridges();
+ }
+
+ ///
+ /// Creates all bridges for the plot, axes, series, and annotations.
+ ///
+ private void CreateAllBridges()
+ {
+ _plotBridge = new UndoableStateBridge(
+ _plot, _getUndoManager, _description, _target,
+ includedProperties: PlotUndoProperties,
+ onActionRecorded: _onActionRecorded);
+
+ foreach (Axis axis in _plot.Axes)
+ {
+ _axisBridges.Add(new UndoableStateBridge(
+ axis, _getUndoManager, $"{_description} axis", _target,
+ includedProperties: AxisUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+
+ foreach (Series series in _plot.Series)
+ {
+ _seriesBridges.Add(new UndoableStateBridge(
+ series, _getUndoManager, $"{_description} series", _target,
+ includedProperties: SeriesUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+
+ foreach (Annotation annotation in _plot.Annotations)
+ {
+ _annotationBridges.Add(new UndoableStateBridge(
+ annotation, _getUndoManager, $"{_description} annotation", _target,
+ includedProperties: AnnotationUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+ }
+
+ ///
+ /// Handles collection changes to rebuild axis bridges.
+ ///
+ /// The source of the event.
+ /// The event arguments.
+ ///
+ /// When the user changes an axis type (e.g. Linear to Logarithmic), the
+ /// AxesControl removes the old axis and adds a new one.
+ /// This handler rebuilds axis bridges so the new axis object is monitored.
+ /// Changes during suspended recording are ignored; the caller should
+ /// call after resuming.
+ ///
+ private void OnAxesCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (_isDisposed || _isSuspended) return;
+ RebuildAxisBridges();
+ }
+
+ ///
+ /// Handles events to record an undoable axis type change.
+ ///
+ /// The axis that was removed from the collection.
+ /// The axis that was inserted in its place.
+ ///
+ /// When the user changes an axis type (e.g. Linear to Logarithmic) via AxisControl,
+ /// fires this event after atomically swapping the axis objects.
+ /// This handler records a so the swap can be undone with Ctrl+Z.
+ /// The guard prevents re-recording during undo/redo replay,
+ /// since the DelegateAction's execute/undo lambdas also call which
+ /// re-fires this event.
+ ///
+ private void OnAxisReplaced(Axis oldAxis, Axis newAxis)
+ {
+ if (_isDisposed || _isSuspended) return;
+ var undoManager = _getUndoManager();
+ if (undoManager == null || undoManager.IsExecutingAction) return;
+
+ var action = new DelegateAction(
+ $"Change {_description} axis type",
+ () => _plot.ReplaceAxis(oldAxis, newAxis),
+ () => _plot.ReplaceAxis(newAxis, oldAxis),
+ _target);
+ undoManager.RecordAction(action);
+ _onActionRecorded?.Invoke();
+ }
+
+ ///
+ /// Handles collection changes to rebuild annotation bridges.
+ ///
+ /// The source of the event.
+ /// The event arguments.
+ ///
+ /// When the user adds or removes an annotation via the toolbar, the
+ /// AnnotationSelectorControl modifies .
+ /// This handler rebuilds annotation bridges for the new set of annotations.
+ /// Changes during suspended recording are ignored.
+ ///
+ private void OnAnnotationsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ if (_isDisposed || _isSuspended) return;
+ RebuildAnnotationBridgesFromCollection();
+ }
+
+ ///
+ /// Handles events to detect bulk annotation modifications.
+ ///
+ /// The source of the event.
+ /// The event arguments.
+ ///
+ /// When the OxyPlotToolbar finishes placing an annotation, it calls
+ /// which fires PropertyChanged("Annotations").
+ /// This handler rebuilds annotation bridges so they pick up the final property values
+ /// as their shadow baseline, rather than the initial values from before the drag.
+ ///
+ private void OnPlotPropertyChanged(object sender, PropertyChangedEventArgs e)
+ {
+ if (_isDisposed || _isSuspended) return;
+ if (e.PropertyName == "Annotations")
+ {
+ RebuildAnnotationBridgesFromCollection();
+ }
+ }
+
+ ///
+ /// Disposes and rebuilds all axis bridges from the current collection.
+ ///
+ private void RebuildAxisBridges()
+ {
+ DisposeAxisBridges();
+ foreach (Axis axis in _plot.Axes)
+ {
+ _axisBridges.Add(new UndoableStateBridge(
+ axis, _getUndoManager, $"{_description} axis", _target,
+ includedProperties: AxisUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+ }
+
+ ///
+ /// Disposes and rebuilds annotation bridges from the current collection.
+ ///
+ private void RebuildAnnotationBridgesFromCollection()
+ {
+ DisposeAnnotationBridges();
+ foreach (Annotation annotation in _plot.Annotations)
+ {
+ _annotationBridges.Add(new UndoableStateBridge(
+ annotation, _getUndoManager, $"{_description} annotation", _target,
+ includedProperties: AnnotationUndoProperties,
+ onActionRecorded: _onActionRecorded));
+ }
+ }
+
+ ///
+ /// Disposes all axis bridges and clears the list.
+ ///
+ private void DisposeAxisBridges()
+ {
+ foreach (var b in _axisBridges) b.Dispose();
+ _axisBridges.Clear();
+ }
+
+ ///
+ /// Disposes all series bridges and clears the list.
+ ///
+ private void DisposeSeriesBridges()
+ {
+ foreach (var b in _seriesBridges) b.Dispose();
+ _seriesBridges.Clear();
+ }
+
+ ///
+ /// Disposes all annotation bridges and clears the list.
+ ///
+ private void DisposeAnnotationBridges()
+ {
+ foreach (var b in _annotationBridges) b.Dispose();
+ _annotationBridges.Clear();
+ }
+
+ ///
+ /// Aggregates multiple suspension tokens and
+ /// manages the flag via reference counting.
+ ///
+ ///
+ /// Only the outermost scope (the one that holds the actual bridge tokens)
+ /// resumes recording on dispose. Inner (nested) scopes hold empty lists
+ /// and simply decrement the depth counter.
+ ///
+ private class SuspensionScope : IDisposable
+ {
+ ///
+ /// The owning .
+ ///
+ private readonly PlotUndoManager _owner;
+
+ ///
+ /// The individual bridge suspension tokens. Empty for nested scopes.
+ ///
+ private readonly List _suspensions;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The owning .
+ /// The individual bridge suspension tokens.
+ public SuspensionScope(PlotUndoManager owner, List suspensions)
+ {
+ _owner = owner;
+ _suspensions = suspensions;
+ }
+
+ ///
+ /// Decrements the suspension depth. When the outermost scope disposes,
+ /// resumes recording on all bridges, clears the suspended flag, and
+ /// performs any deferred call.
+ ///
+ public void Dispose()
+ {
+ _owner._suspensionDepth--;
+ if (_owner._suspensionDepth <= 0)
+ {
+ // Resume the original bridge suspension tokens.
+ for (int i = _suspensions.Count - 1; i >= 0; i--)
+ _suspensions[i].Dispose();
+ _owner._isSuspended = false;
+ _owner._suspensionDepth = 0;
+
+ // Perform deferred rebuild if any UpdateXxxPlot called
+ // RebuildSeriesAndAnnotationBridges while we were suspended.
+ if (_owner._needsRebuild)
+ {
+ _owner._needsRebuild = false;
+ _owner.RebuildSeriesAndAnnotationBridges();
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElement.cs b/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElement.cs
deleted file mode 100644
index 25ccb24b..00000000
--- a/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElement.cs
+++ /dev/null
@@ -1,276 +0,0 @@
-using FrameworkInterfaces;
-using FrameworkInterfaces.Undo;
-using System;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// A demo element that showcases the undo/redo functionality. This element demonstrates how to use RecordPropertyChange for undo support.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public class UndoDemoElement : ElementBase, IUndoableElement
- {
- private string _customValue = "Initial Value";
- private int _numericValue = 0;
- private bool _booleanValue = false;
- private DateTime _dateValue = DateTime.Today;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The name of the element.
- /// The parent collection that contains this element.
- public UndoDemoElement(string name, IElementCollection parentCollection) : base(name, parentCollection)
- {
- Name = name;
- SetIsDirty(false);
- }
-
- #region Properties with Undo Support
-
- ///
- /// Gets or sets the name of the element with undo support.
- ///
- public override string Name
- {
- get => NameField;
- set
- {
- if (NameField != value)
- {
- var oldValue = NameField;
- NameField = value;
- RecordPropertyChange(nameof(Name), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets the description with undo support.
- ///
- public override string Description
- {
- get => _description ?? "Undo Demo Element";
- set
- {
- if (_description != value)
- {
- var oldValue = _description;
- _description = value;
- RecordPropertyChange(nameof(Description), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets a custom string value with undo support.
- ///
- public string CustomValue
- {
- get => _customValue;
- set
- {
- if (_customValue != value)
- {
- var oldValue = _customValue;
- _customValue = value;
- RecordPropertyChange(nameof(CustomValue), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets a numeric value with undo support.
- ///
- public int NumericValue
- {
- get => _numericValue;
- set
- {
- if (_numericValue != value)
- {
- var oldValue = _numericValue;
- _numericValue = value;
- RecordPropertyChange(nameof(NumericValue), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets a boolean value with undo support.
- ///
- public bool BooleanValue
- {
- get => _booleanValue;
- set
- {
- if (_booleanValue != value)
- {
- var oldValue = _booleanValue;
- _booleanValue = value;
- RecordPropertyChange(nameof(BooleanValue), oldValue, value);
- }
- }
- }
-
- ///
- /// Gets or sets a date value with undo support.
- ///
- public DateTime DateValue
- {
- get => _dateValue;
- set
- {
- if (_dateValue != value)
- {
- var oldValue = _dateValue;
- _dateValue = value;
- RecordPropertyChange(nameof(DateValue), oldValue, value);
- }
- }
- }
-
- #endregion
-
- #region Required Abstract Properties
-
- ///
- /// Gets the creation date of the element.
- ///
- public override DateTime CreationDate => _creationDate;
-
- ///
- /// Gets the last modified date of the element.
- ///
- public override DateTime LastModified => _lastModified;
-
- ///
- /// Gets the name used when saving the element to disk.
- ///
- public override string NameOnDisk => Name;
-
- ///
- /// Gets the image icon representing the element.
- ///
- private static readonly Lazy s_icon = new(() => { var img = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")); img.Freeze(); return img; });
- ///
- public override ImageSource ElementImage => s_icon.Value;
-
- ///
- /// Gets a value indicating whether this element can be copied from an external project.
- ///
- public override bool CanCopyFromExternal => false;
-
- ///
- /// Gets a value indicating whether the element is valid.
- ///
- public override bool IsValid => true;
-
- #endregion
-
- #region Required Abstract Methods
-
- ///
- /// Creates a copy of the element.
- ///
- /// The name for the copied element. If null, a default name will be generated.
- /// A copy of the element.
- public override IElement Copy(string newName = null)
- {
- var copy = new UndoDemoElement(newName ?? $"{Name}_Copy", ParentCollection);
- copy.CustomValue = this.CustomValue;
- copy.NumericValue = this.NumericValue;
- copy.BooleanValue = this.BooleanValue;
- copy.DateValue = this.DateValue;
- copy.Description = this.Description;
- return copy;
- }
-
- ///
- /// Copies an element from an external project file.
- ///
- /// The name of the item to copy.
- /// The full path to the external file.
- /// Null, as external copying is not supported for this element type.
- public override IElement CopyFromExternal(string itemName, string fullFileName)
- {
- return null;
- }
-
- ///
- /// Deletes the element and raises the appropriate events.
- ///
- public override void Delete()
- {
- bool cancel = false;
- RaisePreviewDeleted(this, ref cancel);
- if (!cancel)
- {
- RaiseDeleted(this);
- }
- }
-
- ///
- /// Opens the element and loads its data from disk. Clears the undo history after loading.
- ///
- public override void Open()
- {
- // Simulate loading from disk
- IsUndoEnabled = false;
- // ... load data ...
- IsUndoEnabled = true;
- ClearUndoHistory();
- SetIsDirty(false);
- }
-
- ///
- /// Saves the element to disk and marks the current state as the undo save point.
- ///
- public override void Save()
- {
- bool cancel = false;
- RaisePreviewObjectSaved(this, ref cancel);
- if (!cancel)
- {
- // Simulate saving to disk
- // ... save data ...
- MarkUndoSavePoint();
- SetIsDirty(false);
- RaiseObjectSaved(this);
- }
- }
-
- #endregion
-
- ///
- /// Performs a batch update using a transaction. All changes within the transaction can be undone with a single Undo.
- ///
- /// The new custom string value.
- /// The new numeric value.
- /// The new boolean value.
- public void PerformBatchUpdate(string newCustomValue, int newNumericValue, bool newBooleanValue)
- {
- // Using statement ensures transaction is committed when disposed
- using (UndoManager.BeginTransaction("Batch Update"))
- {
- CustomValue = newCustomValue;
- NumericValue = newNumericValue;
- BooleanValue = newBooleanValue;
- }
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElementCollection.cs b/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElementCollection.cs
deleted file mode 100644
index 74b46537..00000000
--- a/src/FrameworkUI.Demo/Model/Undo Demo/UndoDemoElementCollection.cs
+++ /dev/null
@@ -1,187 +0,0 @@
-using FrameworkInterfaces;
-
-namespace FrameworkUI.Demo
-{
- ///
- /// A demo element collection that showcases the undo/redo functionality for collection operations. This collection demonstrates how to use RecordAddElement and RecordRemoveElement for undo support.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public class UndoDemoElementCollection : ElementCollectionBase
- {
- ///
- /// Initializes a new instance of the class and adds initial demo elements.
- ///
- /// The parent project that contains this collection.
- public UndoDemoElementCollection(IProject parentProject) : base(parentProject)
- {
- // Add some initial demo elements
- Add(new UndoDemoElement("Demo Element 1", this));
- Add(new UndoDemoElement("Demo Element 2", this));
- Add(new UndoDemoElement("Demo Element 3", this));
-
- // Mark as clean after initialization
- SetIsDirty(false);
- }
-
- ///
- /// Gets the name of the collection.
- ///
- public override string Name => "Undo Demo Elements";
-
- ///
- /// Opens the collection and loads elements from disk. Clears the undo history after loading.
- ///
- public override void Open()
- {
- _opening = true;
- // Simulate reading from disk
- // ...
- _opening = false;
- ClearUndoHistory();
- SetIsDirty(false);
- }
-
- ///
- /// Adds an element to the collection with undo support.
- ///
- public override void Add(IElement item)
- {
- item.Deleted += ElementDeleted;
- ElementList.Add((UndoDemoElement)item);
-
- // Record the add action for undo support (only if not opening)
- if (!_opening)
- {
- RecordAddElement(item);
- SetIsDirty(true);
- }
-
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Inserts an element at the specified index with undo support.
- ///
- public override void Insert(int index, IElement item)
- {
- item.Deleted += ElementDeleted;
- ElementList.Insert(index, (UndoDemoElement)item);
-
- // Record the add action for undo support
- RecordAddElement(item, index);
- SetIsDirty(true);
-
- RaiseElementAddedEvent(item);
- }
-
- ///
- /// Removes an element from the collection with undo support.
- ///
- public override bool Remove(IElement item)
- {
- if (item == null) return false;
-
- int index = ElementList.IndexOf((UndoDemoElement)item);
- if (index < 0) return false;
-
- // Record the remove action for undo support before actually removing
- RecordRemoveElement(item);
-
- item.Deleted -= ElementDeleted;
- ElementList.Remove((UndoDemoElement)item);
- SetIsDirty(true);
-
- RaiseElementRemoved(item);
- return true;
- }
-
- ///
- /// Removes the element at the specified index with undo support.
- ///
- public override void RemoveAt(int index)
- {
- if (index < 0 || index >= ElementList.Count) return;
-
- var item = ElementList[index];
-
- // Record the remove action for undo support
- RecordRemoveElement(item);
-
- item.Deleted -= ElementDeleted;
- ElementList.RemoveAt(index);
- SetIsDirty(true);
-
- RaiseElementRemoved(item);
- }
-
- ///
- /// Moves an element within the collection with undo support.
- ///
- /// The current index of the element.
- /// The new index where the element should be moved.
- public void MoveElement(int oldIndex, int newIndex)
- {
- if (oldIndex < 0 || oldIndex >= ElementList.Count) return;
- if (newIndex < 0 || newIndex >= ElementList.Count) return;
- if (oldIndex == newIndex) return;
-
- var item = ElementList[oldIndex];
-
- // Record the move action for undo support
- RecordMoveElement(item, oldIndex, newIndex);
-
- ElementList.RemoveAt(oldIndex);
- ElementList.Insert(newIndex, (UndoDemoElement)item);
- SetIsDirty(true);
- }
-
- ///
- /// Deletes the collection and all its elements.
- ///
- public override void Delete()
- {
- // Clear all elements
- foreach (var element in ElementList)
- {
- element.Delete();
- }
- ElementList.Clear();
- }
-
- ///
- /// Inserts an element from an external project at the specified index.
- ///
- /// The zero-based index at which the element should be inserted.
- /// The name of the element to insert.
- /// The type of the element.
- /// The full path to the external project file.
- public override void InsertFromExternalProject(int index, string elementName, string elementType, string fullFileName)
- {
- Insert(index, new UndoDemoElement(elementName, this));
- }
-
- ///
- /// Creates a new demo element with undo support and adds it to the collection.
- ///
- /// The name of the new element.
- /// The newly created element.
- public UndoDemoElement CreateNewElement(string name)
- {
- var element = new UndoDemoElement(name, this);
- Add(element);
- return element;
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/Resources/FrameworkUIDemo.ico b/src/FrameworkUI.Demo/Resources/FrameworkUIDemo.ico
new file mode 100644
index 00000000..d2bbd3f5
Binary files /dev/null and b/src/FrameworkUI.Demo/Resources/FrameworkUIDemo.ico differ
diff --git a/src/FrameworkUI.Demo/Resources/Hazard_Icon.png b/src/FrameworkUI.Demo/Resources/Hazard_Icon.png
deleted file mode 100644
index e3f7f409..00000000
Binary files a/src/FrameworkUI.Demo/Resources/Hazard_Icon.png and /dev/null differ
diff --git a/src/FrameworkUI.Demo/Resources/IconDictionary.xaml b/src/FrameworkUI.Demo/Resources/IconDictionary.xaml
new file mode 100644
index 00000000..4842b694
--- /dev/null
+++ b/src/FrameworkUI.Demo/Resources/IconDictionary.xaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/FrameworkUI.Demo/UI/DemoProjectNode.cs b/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
index ee08bffe..13f30fcd 100644
--- a/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
+++ b/src/FrameworkUI.Demo/UI/DemoProjectNode.cs
@@ -78,6 +78,21 @@ public DemoProjectNode(IProject project) : base(project)
/// run operations for analysis collections. The method disables the default "Create New" context item and
/// replaces it with custom, type-specific menu items.
///
+ ///
+ /// Creates an Image whose Source is bound to a theme-aware icon resource key.
+ /// SetResourceReference keeps the icon live across theme changes, unlike a
+ /// one-time StaticResource lookup.
+ ///
+ /// The icon resource key to bind to.
+ /// An Image bound to the themed icon resource.
+ private static Image CreateThemedIcon(string resourceKey)
+ {
+ var img = new Image();
+ img.SetResourceReference(Image.SourceProperty, resourceKey);
+ return img;
+ }
+
+ /// Adds the hazard-element creation command to compatible collection nodes.
protected override void DefineProjectExplorerMenuItems()
{
for (int i = 0; i < ChildNodes.Count; i++)
@@ -91,7 +106,7 @@ protected override void DefineProjectExplorerMenuItems()
// Hazard Element
if (elementNodeCollection.ElementCollection.GetType() == typeof(HazardElementCollection))
{
- MenuItem menuItem = new MenuItem() { Header = "New HazardElement...", Icon = new Image() { Source = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")) } };
+ MenuItem menuItem = new MenuItem() { Header = "New Hazard Element...", Icon = CreateThemedIcon("HazardElementIcon") };
menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewHazardElement(elementNodeCollection);
elementNodeCollection.CustomContextItems.Add(menuItem);
}
@@ -118,7 +133,7 @@ protected override void DefineProjectMenuItems()
// Hazard Element
if (collection.GetType() == typeof(HazardElementCollection))
{
- MenuItem menuItem = new MenuItem() { Header = "New Hazard Element...", Icon = new Image() { Source = new BitmapImage(new Uri("pack://application:,,,/FrameworkUI.Demo;component/Resources/Hazard_Icon.png")) } };
+ MenuItem menuItem = new MenuItem() { Header = "New Hazard Element...", Icon = CreateThemedIcon("HazardElementIcon") };
menuItem.Click += (object sender, RoutedEventArgs e) => CreateNewHazardElement_Click(collection);
_projectMenuItems.Add(menuItem);
}
@@ -132,10 +147,11 @@ protected override void DefineProjectMenuItems()
/// Creates a new hazard element with a user-specified name.
///
/// The element node collection to add the new element to.
- /// The newly created hazard element.
+ /// The newly created hazard element, or when the dialog is canceled.
private HazardElement CreateNewHazardElement(ElementNodeCollection elementNodes)
{
string newName = CreateNewNameDialog($"Create New {elementNodes.ElementCollection.Name}...", $"{elementNodes.ElementCollection.Name}_{elementNodes.ElementCollection.Count + 1}", elementNodes.ElementCollection.Select(x => x.Name.ToString()).ToList());
+ if (newName == "") return null;
var newHazard = new HazardElement(newName, elementNodes.ElementCollection);
elementNodes.ElementCollection.Add(newHazard);
return newHazard;
@@ -289,7 +305,6 @@ public override void DocumentClosed(UIElement documentControl)
if (documentControl as HazardControl != null)
{
var cntrl = (HazardControl)documentControl;
- //cntrl.Element.UpdatePlotSettings(OxyPlotSettingsSerializer.ToXelement(cntrl.Plot).ToString());
cntrl.PlotToolbar.PropertiesCalled -= PlotPropertiesCalled;
cntrl.PreviewControlClicked -= DocumentControl_PreviewClicked;
}
diff --git a/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml b/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml
deleted file mode 100644
index d7fdd16d..00000000
--- a/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
- I am a document control
-
-
diff --git a/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml.cs b/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml.cs
deleted file mode 100644
index a575de5c..00000000
--- a/src/FrameworkUI.Demo/UI/ElementDocumentControl.xaml.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Documents;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Navigation;
-using System.Windows.Shapes;
-
-namespace FrameworkUI.Demo.UI
-{
- ///
- /// User control for displaying element documents in the main window.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public partial class ElementDocumentControl : UserControl
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public ElementDocumentControl()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml b/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml
deleted file mode 100644
index b20d8162..00000000
--- a/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
- Prop 1
- Prop 2
-
-
-
diff --git a/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml.cs b/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml.cs
deleted file mode 100644
index d332dec3..00000000
--- a/src/FrameworkUI.Demo/UI/ElementPropertiesControl.xaml.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using System.Windows;
-using System.Windows.Controls;
-using System.Windows.Data;
-using System.Windows.Documents;
-using System.Windows.Input;
-using System.Windows.Media;
-using System.Windows.Media.Imaging;
-using System.Windows.Navigation;
-using System.Windows.Shapes;
-
-namespace FrameworkUI.Demo.UI
-{
- ///
- /// User control for displaying and editing element properties.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public partial class ElementPropertiesControl : UserControl
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public ElementPropertiesControl()
- {
- InitializeComponent();
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml
index 6fb884e1..0d6abf5f 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml
@@ -5,7 +5,6 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:FrameworkUI.Demo.UI"
xmlns:cntrls="clr-namespace:GenericControls;assembly=GenericControls"
- xmlns:oxy="http://oxyplot.org/wpf"
xmlns:oxyCntrls="clr-namespace:OxyPlotControls;assembly=OxyPlotControls"
xmlns:dbCntrls="clr-namespace:DatabaseControls;assembly=DatabaseControls"
xmlns:glob="clr-namespace:System.Globalization;assembly=mscorlib"
@@ -15,6 +14,8 @@
d:DesignHeight="450" d:DesignWidth="800"
Loaded="HazardFunctionControl_Loaded"
Unloaded="HazardFunctionControl_Unloaded"
+ PreviewMouseDown="HazardFunctionControl_PreviewMouseDown"
+ LostFocus="HazardFunctionControl_LostFocus"
Background="{DynamicResource EnvironmentWindowBackground}"
Foreground="{DynamicResource EnvironmentWindowText}"
DataContext="{Binding RelativeSource={RelativeSource Self}}">
@@ -35,83 +36,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
+ ToolBarOrientation="Vertical"
+ VerticalAlignment="Stretch"/>
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
index 7868e04e..df25b79b 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardControl.xaml.cs
@@ -1,5 +1,4 @@
using DatabaseManager;
-using DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing;
using FrameworkInterfaces;
using OxyPlot;
using OxyPlot.Wpf;
@@ -44,17 +43,19 @@ public partial class HazardControl : UserControl
public HazardControl()
{
InitializeComponent();
- // Update plot series tracker format string
- ConfidenceInterval.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
- MeanLine.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
- ModeLine.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
}
+ ///
+ /// The tracker format string applied to the frequency curve series.
+ ///
+ private static readonly string s_trackerFormatString =
+ "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
+
#endregion
#region Members
- private bool _isLoaded = false;
+ private bool _themeSubscribed;
private List _frequencyCurveList = new List();
private List _summaryStatisticsList = new List();
@@ -133,6 +134,8 @@ private static void ElementPropertyChanged(DependencyObject d, DependencyPropert
if (oldElement != null)
{
oldElement.PropertyChanged -= thisControl.HazardFunctionPropertyChanged;
+ thisControl.FrequencyPlotHost.Content = null;
+ thisControl.PlotToolbar.Plot = null;
}
}
@@ -143,7 +146,14 @@ private static void ElementPropertyChanged(DependencyObject d, DependencyPropert
newElement.PropertyChanged += thisControl.HazardFunctionPropertyChanged;
- thisControl.LoadPlotSettings();
+ // Host the element-owned plot. Suspend the plot undo bridges so the
+ // programmatic hookup does not create undo entries.
+ using (newElement.SuspendPlotBridges())
+ {
+ thisControl.FrequencyPlotHost.Content = newElement.FrequencyPlot;
+ thisControl.PlotToolbar.Plot = newElement.FrequencyPlot;
+ }
+
thisControl.UpdatePlot();
}
@@ -159,6 +169,12 @@ public HazardElement Element
set => SetValue(ElementProperty, value);
}
+ ///
+ /// Gets the element-owned frequency plot hosted by this control, or null when no
+ /// element is bound.
+ ///
+ public Plot Plot => Element?.FrequencyPlot;
+
#endregion
#region Methods
@@ -170,36 +186,19 @@ public HazardElement Element
/// The instance containing the event data.
private void HazardFunctionControl_Loaded(object sender, RoutedEventArgs e)
{
- if (!_isLoaded)
+ UpdatePlot();
+ SetColumnStringFormats();
+ BindFrequencyCurveTable();
+ SetFrequencyCurveTableColumnHeaders();
+ BindSummaryStatisticsDataGrid();
+ BindParameterSetDataGrid();
+ UpdateUncertaintyVisibility();
+
+ if (!_themeSubscribed)
{
- LoadPlotSettings();
- UpdatePlot();
- SetColumnStringFormats();
- BindFrequencyCurveTable();
- SetFrequencyCurveTableColumnHeaders();
- BindSummaryStatisticsDataGrid();
- BindParameterSetDataGrid();
-
- if (Element != null && Element.IsUncertain)
- {
- UpperColumn.Visibility = Visibility.Visible;
- LowerColumn.Visibility = Visibility.Visible;
- PredictiveColumn.Visibility = Visibility.Visible;
- ParameterSetsTab.Visibility = Visibility.Visible;
- }
- else
- {
- ParameterSetsTab.Visibility = Visibility.Collapsed;
- UpperColumn.Visibility = Visibility.Collapsed;
- LowerColumn.Visibility = Visibility.Collapsed;
- PredictiveColumn.Visibility = Visibility.Collapsed;
- }
-
- // Subscribe to theme changes to refresh plot when theme changes
ThemeService.Instance.ThemeChanged += OnThemeChanged;
+ _themeSubscribed = true;
}
-
- _isLoaded = true;
}
///
@@ -209,8 +208,11 @@ private void HazardFunctionControl_Loaded(object sender, RoutedEventArgs e)
/// The instance containing the event data.
private void HazardFunctionControl_Unloaded(object sender, RoutedEventArgs e)
{
- // Unsubscribe from theme changes to prevent memory leaks
- ThemeService.Instance.ThemeChanged -= OnThemeChanged;
+ if (_themeSubscribed)
+ {
+ ThemeService.Instance.ThemeChanged -= OnThemeChanged;
+ _themeSubscribed = false;
+ }
}
///
@@ -240,6 +242,15 @@ private void OnThemeChanged(object sender, ThemeChangedEventArgs e)
/// The instance containing the property name.
private void HazardFunctionPropertyChanged(object sender, PropertyChangedEventArgs e)
{
+ // Property changes can arrive on a worker thread (e.g., after a background
+ // estimation completes). Marshal to the UI thread before touching the plot
+ // or the data grids.
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(new Action(() => HazardFunctionPropertyChanged(sender, e)));
+ return;
+ }
+
if (e.PropertyName == nameof(Element.IsEstimated))
{
UpdatePlot();
@@ -266,20 +277,7 @@ private void HazardFunctionPropertyChanged(object sender, PropertyChangedEventAr
if (e.PropertyName == nameof(Element.IsUncertain))
{
- if (Element != null && Element.IsUncertain)
- {
- UpperColumn.Visibility = Visibility.Visible;
- LowerColumn.Visibility = Visibility.Visible;
- PredictiveColumn.Visibility = Visibility.Visible;
- ParameterSetsTab.Visibility = Visibility.Visible;
- }
- else
- {
- ParameterSetsTab.Visibility = Visibility.Collapsed;
- UpperColumn.Visibility = Visibility.Collapsed;
- LowerColumn.Visibility = Visibility.Collapsed;
- PredictiveColumn.Visibility = Visibility.Collapsed;
- }
+ UpdateUncertaintyVisibility();
}
}
@@ -287,119 +285,145 @@ private void HazardFunctionPropertyChanged(object sender, PropertyChangedEventAr
#region Plot Methods
- private bool _probabilityOnX = true;
-
///
- /// Loads and applies the saved plot settings from the element's chart settings.
+ /// Updates the plot with the current hazard function results.
///
///
- /// This method restores axis configurations, bindings, and series settings from XML.
- /// It also handles backward compatibility for logarithmic axis conversion.
+ ///
+ /// Series are looked up by name and only created when missing, so user-customized
+ /// styling survives Save/Open round trips. Series are populated inside a
+ /// scope so the bulk update does not
+ /// create undo entries, and the bridges are rebuilt afterwards so the new series
+ /// objects are tracked.
+ ///
+ ///
+ /// The plot shows the confidence interval area and mean curve (when uncertainty is
+ /// enabled) plus the mode (user-specified) curve.
+ ///
///
- private void LoadPlotSettings()
+ public void UpdatePlot()
{
- try
- {
- // This is a hack to make the log-scale plot behave when the series data is negative
- // Without this hack, when the axis is later changed to linear, the negative values will not render correctly
- // We need to fix this in OxyPlot later
- OxyPlot.Wpf.Axis oldAxis = null;
- OxyPlot.Wpf.Axis newAxis = null;
+ // Element can be null during the swap-out phase of ElementPropertyChanged if
+ // the DP callback fires between the old value being nulled and the new value
+ // being assigned. Bail instead of NRE'ing on Element.Results below.
+ var plot = Element?.FrequencyPlot;
+ if (plot == null) return;
- foreach (var axis in Plot.Axes)
+ // Lookup-or-create named series so user-customized styling survives Save/Open.
+ var confidenceInterval = plot.Series.OfType().FirstOrDefault(s => s.Name == "ConfidenceInterval")
+ ?? new OxyPlot.Wpf.AreaSeries
{
- if (axis.Key == "Hazard")
- {
- oldAxis = axis;
- newAxis = AxisControl.ConvertAxisToLogarithmicAxis(oldAxis);
- break;
- }
- }
-
- Plot.Axes.Remove(oldAxis);
- Plot.Axes.Add(newAxis);
- Plot.InvalidatePlot();
-
- if (Element == null || Element.PlotSettings == null) return;
+ Name = "ConfidenceInterval",
+ Title = "90% Confidence Interval",
+ Fill = (Color)ColorConverter.ConvertFromString("#4A688CAF"),
+ Color = (Color)ColorConverter.ConvertFromString("#FF353B7A"),
+ MarkerFill = Colors.Transparent,
+ StrokeThickness = 1,
+ DataFieldX = nameof(AreaPoint.X1),
+ DataFieldX2 = nameof(AreaPoint.X2),
+ DataFieldY = nameof(AreaPoint.Y1),
+ DataFieldY2 = nameof(AreaPoint.Y2),
+ };
+ var meanLine = plot.Series.OfType().FirstOrDefault(s => s.Name == "MeanLine")
+ ?? new OxyPlot.Wpf.LineSeries
+ {
+ Name = "MeanLine",
+ Title = "Mean",
+ Color = Colors.Blue,
+ MarkerFill = Colors.Transparent,
+ StrokeThickness = 1,
+ LineStyle = OxyPlot.LineStyle.Dash,
+ };
+ var modeLine = plot.Series.OfType().FirstOrDefault(s => s.Name == "ModeLine")
+ ?? new OxyPlot.Wpf.LineSeries
+ {
+ Name = "ModeLine",
+ Title = "User-Specified",
+ Color = Colors.Black,
+ MarkerFill = Colors.Transparent,
+ StrokeThickness = 1,
+ LineStyle = OxyPlot.LineStyle.Solid,
+ };
+
+ // Always rewire the item sources and tracker formats; deserialized series
+ // restore visual styling only.
+ confidenceInterval.ItemsSource = ConfidencePoints;
+ confidenceInterval.TrackerFormatString = s_trackerFormatString;
+ meanLine.ItemsSource = MeanLinePoints;
+ meanLine.TrackerFormatString = s_trackerFormatString;
+ modeLine.ItemsSource = ModeLinePoints;
+ modeLine.TrackerFormatString = s_trackerFormatString;
+
+ using (Element.SuspendPlotBridges())
+ {
+ plot.Series.Clear();
+ ConfidencePoints.Clear();
+ MeanLinePoints.Clear();
+ ModeLinePoints.Clear();
- // OxyPlotControls.FromXElement(Plot, System.Xml.Linq.XElement.Parse(Element.ChartSettings));
+ int resultPointCount = GetCompatibleResultPointCount();
- foreach (var series in Plot.Series)
+ if (Element.IsEstimated && resultPointCount > 0)
{
- if (series.Name == ModeLine.Name) series.ItemsSource = ModeLinePoints;
- if (series.Name == MeanLine.Name) series.ItemsSource = MeanLinePoints;
- if (series.Name == ConfidenceInterval.Name) series.ItemsSource = ConfidencePoints;
-
- if (_probabilityOnX)
+ for (int i = 0; i < resultPointCount; i++)
{
- series.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:0.####E+0}" + Environment.NewLine + "{3}: {4:" + UserSettings.ValueStringFormat + "}";
+ double aep = Element.ProbabilityOrdinates[i];
+
+ if (Element.IsUncertain)
+ {
+ ConfidencePoints.Add(new AreaPoint(
+ new OxyPlot.DataPoint(aep, Element.Results.ConfidenceIntervals[i, 0]),
+ new OxyPlot.DataPoint(aep, Element.Results.ConfidenceIntervals[i, 1])));
+ MeanLinePoints.Add(new OxyPlot.DataPoint(aep, Element.Results.MeanCurve[i]));
+ }
+ ModeLinePoints.Add(new OxyPlot.DataPoint(aep, Element.Results.ModeCurve[i]));
}
- else
+
+ if (Element.IsUncertain)
{
- series.TrackerFormatString = "{0}" + Environment.NewLine + "{1}: {2:" + UserSettings.ValueStringFormat + "}" + Environment.NewLine + "{3}: {4:0.####E+0}";
+ confidenceInterval.Title = (Element.ConfidenceIntervalWidth * 100).ToString("F0", CultureInfo.CurrentCulture) + "% Confidence Interval";
+ plot.Series.Add(confidenceInterval);
+ plot.Series.Add(meanLine);
}
+
+ plot.Series.Add(modeLine);
}
+
+ plot.InvalidatePlot(true);
}
- catch (Exception ex)
- {
- Debug.WriteLine(ex);
- // Couldn't parse the chart settings xml. It doesn't necessarily mean an error since it could have never been set.
- }
+
+ // Reconnect undo tracking to the new series objects.
+ Element.RebuildSeriesAndAnnotationBridges(plot);
}
- ///
- /// Updates the plot with the current hazard function results.
- ///
- ///
- /// This method clears existing series data and repopulates the plot with:
- ///
- /// - Confidence interval area (if uncertainty is enabled)
- /// - Mean curve line (if uncertainty is enabled)
- /// - Mode (user-specified) curve line
- ///
- /// The axis orientation is determined by the ProbabilityAxisCheckBox state.
- ///
- public void UpdatePlot()
+ /// Gets the number of result points compatible with the current probability ordinates.
+ /// The matching point count, or zero when estimated results are absent or incompatible.
+ private int GetCompatibleResultPointCount()
{
- if (Plot == null) return;
+ var element = Element;
+ if (element?.Results == null || !element.IsEstimated) return 0;
- Plot.Series.Clear();
- ConfidencePoints.Clear();
- MeanLinePoints.Clear();
- ModeLinePoints.Clear();
-
- // Element can be null during the swap-out phase of ElementPropertyChanged if
- // the DP callback fires between the old value being nulled and the new value
- // being assigned. Bail instead of NRE'ing on Element.Results below.
- if (Element == null || Element.Results == null) return;
+ int count = element.ProbabilityOrdinates?.Count ?? 0;
+ if (count == 0) return 0;
+ if (element.Results.ModeCurve == null || element.Results.ModeCurve.Length != count) return 0;
- if (Element.IsEstimated)
+ if (element.IsUncertain)
{
- for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++)
- {
- double aep = Element.ProbabilityOrdinates[i];
-
- if (Element.IsUncertain)
- {
- ConfidencePoints.Add(new AreaPoint(
- new OxyPlot.DataPoint(aep, Element.Results.ConfidenceIntervals[i, 0]),
- new OxyPlot.DataPoint(aep, Element.Results.ConfidenceIntervals[i, 1])));
- MeanLinePoints.Add(new OxyPlot.DataPoint(aep, Element.Results.MeanCurve[i]));
- }
- ModeLinePoints.Add(new OxyPlot.DataPoint(aep, Element.Results.ModeCurve[i]));
- }
-
- if (Element.IsUncertain)
- {
- ConfidenceInterval.Title = (Element.ConfidenceIntervalWidth * 100).ToString("F0", CultureInfo.CurrentCulture) + "% Confidence Interval";
- Plot.Series.Add(ConfidenceInterval);
- Plot.Series.Add(MeanLine);
- }
-
- Plot.Series.Add(ModeLine);
+ if (element.Results.MeanCurve == null || element.Results.MeanCurve.Length != count) return 0;
+ if (element.Results.ConfidenceIntervals == null || element.Results.ConfidenceIntervals.GetLength(0) != count || element.Results.ConfidenceIntervals.GetLength(1) < 2) return 0;
}
- Plot.InvalidatePlot(true);
+ return count;
+ }
+
+ /// Shows uncertainty columns and parameter sets only for an uncertain hazard element.
+ private void UpdateUncertaintyVisibility()
+ {
+ bool showUncertainty = Element != null && Element.IsUncertain;
+ UpperColumn.Visibility = showUncertainty ? Visibility.Visible : Visibility.Collapsed;
+ LowerColumn.Visibility = showUncertainty ? Visibility.Visible : Visibility.Collapsed;
+ PredictiveColumn.Visibility = showUncertainty ? Visibility.Visible : Visibility.Collapsed;
+ ParameterSetsTab.Visibility = showUncertainty ? Visibility.Visible : Visibility.Collapsed;
}
///
@@ -409,6 +433,8 @@ public void UpdatePlot()
/// The instance containing the event data.
private void HazardFunctionControl_PreviewMouseDown(object sender, MouseButtonEventArgs e)
{
+ if (Plot == null) return;
+
var plotHitResult = VisualTreeHelper.HitTest(Plot, e.GetPosition(Plot));
var toolbarHitResult = VisualTreeHelper.HitTest(PlotToolbar, e.GetPosition(PlotToolbar));
@@ -438,6 +464,8 @@ private void HazardFunctionControl_LostFocus(object sender, RoutedEventArgs e)
///
private void SetFrequencyCurveTableColumnHeaders()
{
+ if (Element == null) return;
+
double alpha = (1 - Element.ConfidenceIntervalWidth) / 2;
UpperColumn.Header = ((1 - alpha) * 100).ToString("F1", CultureInfo.CurrentCulture) + "%-ile";
LowerColumn.Header = (alpha * 100).ToString("F1", CultureInfo.CurrentCulture) + "%-ile";
@@ -467,11 +495,16 @@ public void BindFrequencyCurveTable()
FrequencyCurveTable.ItemsSource = null;
_frequencyCurveList.Clear();
- if (Element.Results == null) return;
+ if (Element == null)
+ {
+ FrequencyCurveTable.Items.Refresh();
+ return;
+ }
- if (Element.IsEstimated)
+ int resultPointCount = GetCompatibleResultPointCount();
+ if (Element.IsEstimated && resultPointCount > 0)
{
- for (int i = 0; i < Element.ProbabilityOrdinates.Count; i++)
+ for (int i = 0; i < resultPointCount; i++)
{
double aep = Element.ProbabilityOrdinates[i];
@@ -549,6 +582,12 @@ public void BindSummaryStatisticsDataGrid()
SummaryStatisticsTable.ItemsSource = null;
_summaryStatisticsList.Clear();
+ if (Element == null)
+ {
+ SummaryStatisticsTable.Items.Refresh();
+ return;
+ }
+
if (Element.IsEstimated)
{
// Get parameters
@@ -628,7 +667,11 @@ private void SummaryStatisticsTable_LoadingRow(object sender, DataGridRowEventAr
///
private void BindParameterSetDataGrid()
{
- if (Element.Results == null) return;
+ if (Element?.Results == null)
+ {
+ ParameterSetTableViewer.DataView = null;
+ return;
+ }
if (!Element.IsUncertain)
{
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml
index e401a659..6dafda08 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml
@@ -219,7 +219,8 @@
Grid.ColumnSpan="2"
Style="{DynamicResource PropertyExpanderStyle}">
-
+
diff --git a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml.cs b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml.cs
index 038e7b8f..9bcd23e2 100644
--- a/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml.cs
+++ b/src/FrameworkUI.Demo/UI/Hazard Controls/HazardPropertiesControl.xaml.cs
@@ -58,6 +58,7 @@ public HazardPropertiesControl()
private ComboBox _combobox = new ComboBox();
private string _previousName = string.Empty;
private ObservableCollection _parameterList = new ObservableCollection();
+ private bool _syncingDistributionSelection;
///
/// Identifies the dependency property.
@@ -80,32 +81,27 @@ private static void ElementPropertyChanged(DependencyObject d, DependencyPropert
var thisControl = (HazardPropertiesControl)d;
- // Remove any handlers from the old element
- var oldElement = e.OldValue as HazardElement;
- if (oldElement != null)
+ if (e.OldValue is HazardElement oldElement)
{
oldElement.PropertyChanged -= thisControl.Element_PropertyChanged;
- thisControl._combobox.SelectionChanged -= thisControl.DistributionCombobox_SelectionChanged;
}
- var newElement = e.NewValue as HazardElement;
- if (newElement != null)
- {
- for (int i = 0; i < thisControl.DistributionOptions.Length; i++)
- {
- if (thisControl.DistributionOptions[i].Type == newElement.ParentDistribution.Type)
- {
- thisControl._combobox.SelectedIndex = i;
- break;
- }
- }
+ thisControl._combobox.SelectionChanged -= thisControl.DistributionCombobox_SelectionChanged;
+ thisControl.ClearParameterRows();
- newElement.PropertyChanged += thisControl.Element_PropertyChanged;
- thisControl._combobox.SelectionChanged += thisControl.DistributionCombobox_SelectionChanged;
- thisControl.UpdateParameterDataGrid();
- thisControl.SetDataGridStyle();
- thisControl.ParametersTable.ItemsSource = thisControl._parameterList;
+ if (e.NewValue is not HazardElement newElement)
+ {
+ thisControl.SyncDistributionSelection(null);
+ thisControl.ParametersTable.ItemsSource = null;
+ return;
}
+
+ thisControl.SyncDistributionSelection(newElement.ParentDistribution?.Type);
+ newElement.PropertyChanged += thisControl.Element_PropertyChanged;
+ thisControl._combobox.SelectionChanged += thisControl.DistributionCombobox_SelectionChanged;
+ thisControl.PropertyAttributes.GetClassAttributes(newElement);
+ thisControl.UpdateParameterDataGrid();
+ thisControl.SetDataGridStyle();
}
///
@@ -141,30 +137,66 @@ public string[] ExistingNames
private set => SetValue(ExistingNamesProperty, value);
}
- private static readonly UnivariateDistributionBase[] _distributionOptions = new[] {
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.Exponential),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GammaDistribution),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GeneralizedExtremeValue),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GeneralizedLogistic),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GeneralizedNormal),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.GeneralizedPareto),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.Gumbel),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.KappaFour),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.LnNormal),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.LogNormal),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.LogPearsonTypeIII),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.Normal),
- UnivariateDistributionFactory.CreateDistribution(UnivariateDistributionType.PearsonTypeIII)
+ private static readonly DistributionOption[] _distributionOptions = new[] {
+ CreateDistributionOption(UnivariateDistributionType.Exponential),
+ CreateDistributionOption(UnivariateDistributionType.GammaDistribution),
+ CreateDistributionOption(UnivariateDistributionType.GeneralizedExtremeValue),
+ CreateDistributionOption(UnivariateDistributionType.GeneralizedLogistic),
+ CreateDistributionOption(UnivariateDistributionType.GeneralizedNormal),
+ CreateDistributionOption(UnivariateDistributionType.GeneralizedPareto),
+ CreateDistributionOption(UnivariateDistributionType.Gumbel),
+ CreateDistributionOption(UnivariateDistributionType.KappaFour),
+ CreateDistributionOption(UnivariateDistributionType.LnNormal),
+ CreateDistributionOption(UnivariateDistributionType.LogNormal),
+ CreateDistributionOption(UnivariateDistributionType.LogPearsonTypeIII),
+ CreateDistributionOption(UnivariateDistributionType.Normal),
+ CreateDistributionOption(UnivariateDistributionType.PearsonTypeIII)
};
///
/// Gets the available parent distribution options.
///
///
- /// An array of instances representing
- /// the supported probability distributions for parametric hazard functions.
+ /// An array of distribution option items representing the supported probability
+ /// distributions for parametric hazard functions.
///
- public UnivariateDistributionBase[] DistributionOptions => _distributionOptions;
+ public DistributionOption[] DistributionOptions => _distributionOptions;
+
+ ///
+ /// Lightweight distribution choice used by the parent-distribution combo box.
+ ///
+ public sealed class DistributionOption
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The display name shown in the combo box.
+ /// The distribution type to create when selected.
+ public DistributionOption(string displayName, UnivariateDistributionType type)
+ {
+ DisplayName = displayName;
+ Type = type;
+ }
+
+ ///
+ /// Gets the display name shown in the combo box.
+ ///
+ public string DisplayName { get; }
+
+ ///
+ /// Gets the distribution type represented by this option.
+ ///
+ public UnivariateDistributionType Type { get; }
+ }
+
+ /// Creates a selection option using the distribution factory's display name.
+ /// The distribution type represented by the option.
+ /// The display name and type used by the distribution selector.
+ private static DistributionOption CreateDistributionOption(UnivariateDistributionType type)
+ {
+ var distribution = UnivariateDistributionFactory.CreateDistribution(type);
+ return new DistributionOption(distribution.DisplayName, type);
+ }
private static readonly ObservableCollection _confidenceIntervalWidthList = new ObservableCollection(new[]
{
@@ -341,6 +373,16 @@ private void DataGrid_PreviewMouseLeftButtonDown(object sender, MouseButtonEvent
PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution.");
}
+ ///
+ /// Handles the PreviewMouseLeftButtonDown event for the ProbabilityOrdinatesControl.
+ ///
+ /// The source of the event.
+ /// The instance containing the event data.
+ private void ProbabilityOrdinatesControl_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ PropertyAttributes.SetDefaultAttributes("Probability Ordinates", "The exceedance probabilities used for plotting the probability distribution.");
+ }
+
///
/// Handles the PreviewMouseLeftButtonDown event for the TabItem control to display property attributes.
///
@@ -393,7 +435,9 @@ private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(Element.ParentDistribution))
{
+ SyncDistributionSelection(Element?.ParentDistribution?.Type);
UpdateParameterDataGrid();
+ SetDataGridStyle();
}
}
@@ -404,9 +448,10 @@ private void Element_PropertyChanged(object sender, PropertyChangedEventArgs e)
/// The instance containing the event data.
private void DistributionCombobox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
- if (_combobox.SelectedIndex != -1)
+ if (_syncingDistributionSelection || Element == null) return;
+ if (_combobox.SelectedItem is DistributionOption option)
{
- Element.ParentDistribution = (UnivariateDistributionBase)_combobox.SelectedItem;
+ Element.ParentDistribution = UnivariateDistributionFactory.CreateDistribution(option.Type);
}
}
@@ -415,7 +460,13 @@ private void DistributionCombobox_SelectionChanged(object sender, SelectionChang
///
private void UpdateParameterDataGrid()
{
- _parameterList.Clear();
+ ClearParameterRows();
+ if (Element?.ParentDistribution == null)
+ {
+ ParametersTable.ItemsSource = null;
+ return;
+ }
+
var names = Element.ParentDistribution.GetParameterPropertyNames;
var display = Element.ParentDistribution.ParametersToString;
var parms = Element.ParentDistribution.GetParameters;
@@ -426,6 +477,8 @@ private void UpdateParameterDataGrid()
parm.PropertyChanged += ParameterChanged;
_parameterList.Add(parm);
}
+
+ ParametersTable.ItemsSource = _parameterList;
}
///
@@ -443,6 +496,8 @@ private void ParameterChanged(object sender, PropertyChangedEventArgs e)
_parameterList[i].ErrorMessage = null;
}
+ if (Element == null) return;
+
Element.SetDistributionParameters(_parameterList.Select(x => x.Value).ToArray());
if (!Element.ParentDistribution.ParametersValid)
@@ -473,8 +528,9 @@ private void SetInvalidParameter(string parameterName, string errorMessage)
///
private void SetDataGridStyle()
{
- NameColumn.CellStyle = (Style)FindResource("Left_CellStyle");
- ValueColumn.CellStyle = (Style)FindResource("Right_CellStyle");
+ if (TryFindResource("Left_CellStyle") is Style leftStyle) NameColumn.CellStyle = leftStyle;
+ if (TryFindResource("Right_CellStyle") is Style rightStyle) ValueColumn.CellStyle = rightStyle;
+ if (ValueColumn.CellStyle == null) return;
var style = new Style();
if (style != null)
@@ -507,9 +563,45 @@ private void SetDataGridStyle()
/// The instance containing the event data.
private void SimulateButton_Click(object sender, RoutedEventArgs e)
{
- Mouse.OverrideCursor = Cursors.Wait;
- Element.Estimate();
- Mouse.OverrideCursor = null;
+ if (Element == null) return;
+
+ try
+ {
+ Mouse.OverrideCursor = Cursors.Wait;
+ Element.Estimate();
+ }
+ finally
+ {
+ Mouse.OverrideCursor = null;
+ }
+ }
+
+ /// Synchronizes the selected distribution without treating the update as a user edit.
+ /// The type to select, or null to clear the selection.
+ private void SyncDistributionSelection(UnivariateDistributionType? distributionType)
+ {
+ _syncingDistributionSelection = true;
+ try
+ {
+ _combobox.SelectedItem = distributionType.HasValue
+ ? DistributionOptions.FirstOrDefault(x => x.Type == distributionType.Value)
+ : null;
+ }
+ finally
+ {
+ _syncingDistributionSelection = false;
+ }
+ }
+
+ /// Detaches parameter change handlers before removing the displayed parameter rows.
+ private void ClearParameterRows()
+ {
+ foreach (var parameter in _parameterList)
+ {
+ parameter.PropertyChanged -= ParameterChanged;
+ }
+
+ _parameterList.Clear();
}
}
}
diff --git a/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml b/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml
deleted file mode 100644
index 8183483f..00000000
--- a/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml
+++ /dev/null
@@ -1,267 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml.cs b/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml.cs
deleted file mode 100644
index b4caa84f..00000000
--- a/src/FrameworkUI.Demo/UI/ThemeDemoControl.xaml.cs
+++ /dev/null
@@ -1,200 +0,0 @@
-using System.Collections.Generic;
-using System.Windows;
-using System.Windows.Controls;
-using Themes;
-
-namespace FrameworkUI.Demo.UI
-{
- ///
- /// User control that demonstrates the runtime theme switching capability provided by the Themes library.
- ///
- ///
- ///
- /// This control demonstrates the runtime theme switching capability provided by
- /// the Themes library. It showcases how all WPF controls automatically update
- /// when the theme is changed via .
- ///
- ///
- /// The control includes:
- ///
- ///
- /// - Theme selection radio buttons (Light, Blue, Dark)
- /// - A comprehensive showcase of styled WPF controls
- /// - Sample data for DataGrid demonstration
- ///
- ///
- /// When a theme is selected, the control calls
- /// which in turn updates and loads the appropriate
- /// color palette. All controls using DynamicResource bindings update automatically.
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public partial class ThemeDemoControl : UserControl
- {
- ///
- /// Initializes a new instance of the class.
- ///
- public ThemeDemoControl()
- {
- InitializeComponent();
- LoadSampleData();
- UpdateThemeRadioButton();
-
- Loaded += OnLoaded;
- Unloaded += OnUnloaded;
- }
-
- ///
- /// Handles the Loaded event. Subscribes to theme changes.
- ///
- private void OnLoaded(object sender, System.Windows.RoutedEventArgs e)
- {
- // Subscribe to theme changes to keep UI in sync
- ThemeService.Instance.ThemeChanged += OnThemeChanged;
- }
-
- ///
- /// Handles the Unloaded event. Unsubscribes from theme changes to prevent memory leaks.
- ///
- private void OnUnloaded(object sender, System.Windows.RoutedEventArgs e)
- {
- ThemeService.Instance.ThemeChanged -= OnThemeChanged;
- }
-
- ///
- /// Loads sample data into the DataGrid for demonstration purposes.
- ///
- private void LoadSampleData()
- {
- var sampleData = new List
- {
- new SampleDataItem { Name = "Item A", Value = 100, IsActive = true },
- new SampleDataItem { Name = "Item B", Value = 200, IsActive = false },
- new SampleDataItem { Name = "Item C", Value = 150, IsActive = true },
- new SampleDataItem { Name = "Item D", Value = 300, IsActive = true },
- };
- SampleDataGrid.ItemsSource = sampleData;
- }
-
- ///
- /// Updates the radio button selection to match the current theme.
- ///
- private void UpdateThemeRadioButton()
- {
- var currentTheme = ThemeService.Instance.CurrentTheme;
- switch (currentTheme)
- {
- case Theme.Light:
- LightThemeRadio.IsChecked = true;
- break;
- case Theme.Blue:
- BlueThemeRadio.IsChecked = true;
- break;
- case Theme.Dark:
- DarkThemeRadio.IsChecked = true;
- break;
- }
- UpdateCurrentThemeText(currentTheme);
- }
-
- ///
- /// Updates the current theme display text.
- ///
- /// The current theme.
- private void UpdateCurrentThemeText(Theme theme)
- {
- CurrentThemeText.Text = $"Current Theme: {theme}";
- }
-
- ///
- /// Handles theme changes from external sources.
- ///
- /// The event sender.
- /// The theme changed event arguments.
- private void OnThemeChanged(object sender, ThemeChangedEventArgs e)
- {
- // Update the radio button and text when theme changes externally
- Dispatcher.Invoke(() =>
- {
- switch (e.NewTheme)
- {
- case Theme.Light:
- LightThemeRadio.IsChecked = true;
- break;
- case Theme.Blue:
- BlueThemeRadio.IsChecked = true;
- break;
- case Theme.Dark:
- DarkThemeRadio.IsChecked = true;
- break;
- }
- UpdateCurrentThemeText(e.NewTheme);
- });
- }
-
- ///
- /// Handles the Checked event of theme radio buttons.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void ThemeRadio_Checked(object sender, RoutedEventArgs e)
- {
- if (sender is RadioButton radio && radio.IsChecked == true)
- {
- FrameworkUI.ThemeColor themeColor;
-
- if (radio == LightThemeRadio)
- {
- themeColor = FrameworkUI.ThemeColor.Light;
- }
- else if (radio == BlueThemeRadio)
- {
- themeColor = FrameworkUI.ThemeColor.Blue;
- }
- else if (radio == DarkThemeRadio)
- {
- themeColor = FrameworkUI.ThemeColor.Dark;
- }
- else
- {
- return;
- }
-
- // Use FrameworkUI.ThemeManager which bridges to ThemeService
- // This ensures both Themes library colors AND ProjectUI-specific resources are updated
- FrameworkUI.ThemeManager.SetTheme(themeColor);
- }
- }
-
- ///
- /// Sample data class for DataGrid demonstration.
- ///
- private class SampleDataItem
- {
- ///
- /// Gets or sets the name of the sample data item.
- ///
- public string Name { get; set; }
-
- ///
- /// Gets or sets the numeric value of the sample data item.
- ///
- public int Value { get; set; }
-
- ///
- /// Gets or sets a value indicating whether the item is active.
- ///
- public bool IsActive { get; set; }
- }
- }
-}
diff --git a/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml b/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml
deleted file mode 100644
index fe3a3ee1..00000000
--- a/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml.cs b/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml.cs
deleted file mode 100644
index f8a9a4fd..00000000
--- a/src/FrameworkUI.Demo/UI/UndoDemoControl.xaml.cs
+++ /dev/null
@@ -1,259 +0,0 @@
-using System;
-using System.Windows;
-using System.Windows.Controls;
-using FrameworkInterfaces.Undo;
-
-namespace FrameworkUI.Demo.UI
-{
- ///
- /// A demo user control that showcases the undo/redo functionality with interactive UI elements and stack visualization.
- ///
- ///
- ///
- /// Authors:
- ///
- /// -
- /// Haden Smith, USACE Risk Management Center, cole.h.smith@usace.army.mil
- ///
- /// -
- /// Woodrow Fields, USACE Risk Management Center, woodrow.l.fields@usace.army.mil
- ///
- ///
- ///
- ///
- public partial class UndoDemoControl : UserControl
- {
- private UndoDemoElement _element;
- private bool _isUpdatingUI = false;
-
- ///
- /// Initializes a new instance of the class.
- ///
- public UndoDemoControl()
- {
- InitializeComponent();
- }
-
- ///
- /// Gets or sets the element being edited.
- ///
- public UndoDemoElement Element
- {
- get => _element;
- set
- {
- if (_element != null)
- {
- _element.PropertyChanged -= Element_PropertyChanged;
- }
- _element = value;
- if (_element != null)
- {
- _element.PropertyChanged += Element_PropertyChanged;
- RefreshUI();
- }
- }
- }
-
- ///
- /// Handles the PropertyChanged event of the element to refresh the UI.
- ///
- /// The event sender.
- /// The property changed event arguments.
- private void Element_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
- {
- if (!_isUpdatingUI)
- {
- RefreshUI();
- }
- }
-
- ///
- /// Refreshes the UI to reflect the current element state.
- ///
- private void RefreshUI()
- {
- if (_element == null) return;
-
- _isUpdatingUI = true;
- try
- {
- NameTextBox.Text = _element.Name ?? string.Empty;
- CustomValueTextBox.Text = _element.CustomValue ?? string.Empty;
- NumericValueTextBox.Text = _element.NumericValue.ToString();
- BooleanValueCheckBox.IsChecked = _element.BooleanValue;
- DateValuePicker.SelectedDate = _element.DateValue;
-
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
- finally
- {
- _isUpdatingUI = false;
- }
- }
-
- ///
- /// Updates the undo/redo stack displays.
- ///
- private void RefreshUndoRedoStacks()
- {
- UndoStackListBox.Items.Clear();
- RedoStackListBox.Items.Clear();
-
- if (_element?.UndoManager == null) return;
-
- var undoStack = _element.UndoManager.UndoStack;
- var redoStack = _element.UndoManager.RedoStack;
-
- foreach (var action in undoStack)
- {
- UndoStackListBox.Items.Add(action.Description);
- }
-
- foreach (var action in redoStack)
- {
- RedoStackListBox.Items.Add(action.Description);
- }
- }
-
- ///
- /// Updates the enabled state of the undo/redo buttons.
- ///
- private void UpdateButtonStates()
- {
- UndoButton.IsEnabled = _element?.UndoManager?.CanUndo == true;
- RedoButton.IsEnabled = _element?.UndoManager?.CanRedo == true;
- }
-
- ///
- /// Handles text property changes for the name and custom value text boxes.
- ///
- /// The event sender.
- /// The text changed event arguments.
- private void Property_Changed(object sender, TextChangedEventArgs e)
- {
- if (_isUpdatingUI || _element == null) return;
-
- var textBox = sender as TextBox;
- if (textBox == NameTextBox)
- {
- _element.Name = textBox.Text;
- }
- else if (textBox == CustomValueTextBox)
- {
- _element.CustomValue = textBox.Text;
- }
-
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
-
- ///
- /// Handles numeric property changes for the numeric value text box.
- ///
- /// The event sender.
- /// The text changed event arguments.
- private void NumericProperty_Changed(object sender, TextChangedEventArgs e)
- {
- if (_isUpdatingUI || _element == null) return;
-
- if (int.TryParse(NumericValueTextBox.Text, out int value))
- {
- _element.NumericValue = value;
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
- }
-
- ///
- /// Handles boolean property changes for the checkbox.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void BooleanProperty_Changed(object sender, RoutedEventArgs e)
- {
- if (_isUpdatingUI || _element == null) return;
-
- _element.BooleanValue = BooleanValueCheckBox.IsChecked == true;
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
-
- ///
- /// Handles date property changes for the date picker.
- ///
- /// The event sender.
- /// The selection changed event arguments.
- private void DateProperty_Changed(object sender, SelectionChangedEventArgs e)
- {
- if (_isUpdatingUI || _element == null) return;
-
- if (DateValuePicker.SelectedDate.HasValue)
- {
- _element.DateValue = DateValuePicker.SelectedDate.Value;
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
- }
-
- ///
- /// Handles the undo button click to undo the last change.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void UndoButton_Click(object sender, RoutedEventArgs e)
- {
- if (_element?.UndoManager?.CanUndo == true)
- {
- _element.UndoManager.Undo();
- RefreshUI();
- }
- }
-
- ///
- /// Handles the redo button click to redo the last undone change.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void RedoButton_Click(object sender, RoutedEventArgs e)
- {
- if (_element?.UndoManager?.CanRedo == true)
- {
- _element.UndoManager.Redo();
- RefreshUI();
- }
- }
-
- ///
- /// Handles the batch update button click to perform multiple changes in a single transaction.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void BatchUpdateButton_Click(object sender, RoutedEventArgs e)
- {
- if (_element == null) return;
-
- // Demonstrate batch update - all changes will be undone together
- _element.PerformBatchUpdate(
- "Batch Updated Value",
- 999,
- !_element.BooleanValue
- );
-
- RefreshUI();
- }
-
- ///
- /// Handles the clear history button click to clear the undo/redo history.
- ///
- /// The event sender.
- /// The routed event arguments.
- private void ClearHistoryButton_Click(object sender, RoutedEventArgs e)
- {
- _element?.UndoManager?.Clear();
- RefreshUndoRedoStacks();
- UpdateButtonStates();
- }
- }
-}
diff --git a/src/FrameworkUI/Main Window/MainWindow.xaml.cs b/src/FrameworkUI/Main Window/MainWindow.xaml.cs
index 66e4afef..db566374 100644
--- a/src/FrameworkUI/Main Window/MainWindow.xaml.cs
+++ b/src/FrameworkUI/Main Window/MainWindow.xaml.cs
@@ -1777,7 +1777,7 @@ private void ExitCommandBinding_Executed(object sender, ExecutedRoutedEventArgs
/// The cancel event data.
private void MainWindow_Closing(object sender, CancelEventArgs e)
{
- if (ShellPublicVariables.SimulationInProgress == true)
+ if (ShellPublicVariables.SimulationInProgress || ShellPublicVariables.CompactionInProgress)
{
e.Cancel = true;
return;
@@ -1795,7 +1795,21 @@ private void MainWindow_Closing(object sender, CancelEventArgs e)
if (UserSettings.CompressProjectFileOnClose == true && ProjectNode?.Project is not null)
{
// Compact and Optimize
- FileSizeManager.CompactAndOptimizeFile(ProjectNode.Project);
+ try
+ {
+ FileSizeManager.CompactAndOptimizeFile(ProjectNode.Project);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine(ex.ToString());
+ GenericControls.MessageBox.Show("The project file could not be compacted and optimized. " + ex.Message,
+ "Compact & Optimize Project Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ if (ShellPublicVariables.CompactionInProgress)
+ {
+ e.Cancel = true;
+ return;
+ }
+ }
}
// Close the current project
@@ -2425,7 +2439,7 @@ private void CompactProjectFile_Click(object sender, RoutedEventArgs e)
catch (Exception ex)
{
Debug.WriteLine(ex.ToString());
- GenericControls.MessageBox.Show("There was an unexpected error when trying to compact and optimize the project file.", "Compact & Optimize Project Error", MessageBoxButton.OK, MessageBoxImage.Error);
+ GenericControls.MessageBox.Show("The project file could not be compacted and optimized. " + ex.Message, "Compact & Optimize Project Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
diff --git a/src/FrameworkUI/Tools Menu/File Size Management/FileSizeManager.cs b/src/FrameworkUI/Tools Menu/File Size Management/FileSizeManager.cs
index bab0c1b3..a8ee6d5d 100644
--- a/src/FrameworkUI/Tools Menu/File Size Management/FileSizeManager.cs
+++ b/src/FrameworkUI/Tools Menu/File Size Management/FileSizeManager.cs
@@ -1,7 +1,10 @@
-using System.ComponentModel;
+using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
+using System.Runtime.ExceptionServices;
+using System.Threading;
+using System.Windows;
using System.Windows.Threading;
using FrameworkInterfaces;
@@ -52,6 +55,11 @@ public class FileSizeManager
private static DispatcherTimer? _timer;
private static FrameworkUI.CompactProgressControl? _progressControl;
private static BackgroundWorker? _backgroundWorker;
+ private static int _operationActive;
+ private static bool _workerStarted;
+ private static bool _workerCompleted;
+ private static ExceptionDispatchInfo? _workerError;
+ private static DispatcherFrame? _recoveryFrame;
#endregion
@@ -73,70 +81,215 @@ public class FileSizeManager
#region Public Methods
///
- /// Compact and optimize the project file.
+ /// Compacts and optimizes the project file, returning only after the worker has completed.
///
/// The project to compact and optimize.
+ /// The project is null.
+ /// Another compaction is active, or its completion cannot be observed safely.
+ /// Worker errors are rethrown on the caller after owned resources have been released.
public static void CompactAndOptimizeFile(IProject project)
{
- // Set the project file
- _project = project;
- // Get the 'before' file size text. Guard against a missing file (new / unsaved
- // project, file moved between open and compact) - FileInfo.Length throws
- // FileNotFoundException on an absent path, on the UI thread.
- if (!string.IsNullOrEmpty(_project.FullFileName) && File.Exists(_project.FullFileName))
+ if (Interlocked.CompareExchange(ref _operationActive, 1, 0) != 0)
+ throw new InvalidOperationException("Project compaction is already in progress.");
+
+ ExceptionDispatchInfo? failure = null;
+ string? message = null;
+ try
{
- _fileSizeBefore = GetFileSizeText(_project.FullFileName);
+ _project = project ?? throw new ArgumentNullException(nameof(project));
+ ShellPublicVariables.CompactionInProgress = true;
+ _workerStarted = false;
+ _workerCompleted = false;
+ _workerError = null;
+ _fileSizeBefore = !string.IsNullOrEmpty(project.FullFileName) && File.Exists(project.FullFileName)
+ ? GetFileSizeText(project.FullFileName) : string.Empty;
+
+ _progressControl = new CompactProgressControl();
+ _progressControl.ProgressText.Text = "Analyzing Project File...";
+ _progressControl.ProgressBar.IsIndeterminate = true;
+ _progressControl.ProgressBar.Minimum = 0d;
+ _progressControl.ProgressBar.Maximum = 100d;
+ _progressControl.ProgressBar.Value = 0d;
+ _progressControl.Closing += ProgressControl_Closing;
+ _progressControl.Loaded += ProgressControl_Loaded;
+
+ _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(0.1d) };
+ _timer.Tick += Timer_Tick;
+ _backgroundWorker = new BackgroundWorker { WorkerReportsProgress = true };
+ _backgroundWorker.DoWork += BackgroundWorker_Dowork;
+ _backgroundWorker.ProgressChanged += BackgroundWorker_ProgressChanged;
+ _backgroundWorker.RunWorkerCompleted += BackgroundWorker_WorkerComplete;
+
+ try
+ {
+ _progressControl.ShowDialog();
+ }
+ catch (Exception ex)
+ {
+ failure = ExceptionDispatchInfo.Capture(ex);
+ }
+
+ // Hide(), dispatcher shutdown, or a dialog exception must not release a live writer.
+ if (_workerStarted && !_workerCompleted)
+ WaitForWorkerCompletion();
+
+ if (failure == null && _workerError == null)
+ {
+ if (!_workerCompleted)
+ throw new InvalidOperationException("Project compaction did not complete.");
+ _fileSizeAfter = GetFileSizeText(project.FullFileName);
+ message = _fileSizeBefore == _fileSizeAfter
+ ? "The file was optimized. However, there was no unused space to compact in '" + project.Name + "', so the file size remains unchanged at " + _fileSizeAfter + "."
+ : "The file was compacted and optimized. The project '" + project.Name + "' was compacted from " + _fileSizeBefore + " to " + _fileSizeAfter + ".";
+ }
}
- else
+ catch (Exception ex)
{
- _fileSizeBefore = string.Empty;
+ PreserveFailure(ref failure, ex, "operation");
+ }
+ finally
+ {
+ if (_workerError != null) PreserveFailure(ref failure, _workerError.SourceException, "worker");
+ // If recovery itself aborts, retain ownership and the public guard. Callers must
+ // not save or close a project while its completion remains unobserved.
+ if (!_workerStarted || _workerCompleted)
+ {
+ try { CleanupOperation(); }
+ catch (Exception ex)
+ {
+ PreserveFailure(ref failure, ex, "cleanup");
+ }
+ }
}
- // Begin compaction
- ShellPublicVariables.CompactionInProgress = true;
+ failure?.Throw();
+ if (message != null) ReportProgress?.Invoke(message);
+ }
- // Clean up existing timer if any
- if (_timer != null)
+ /// Starts the prepared worker once the modal progress window is loaded.
+ /// The progress window.
+ /// The loaded event.
+ private static void ProgressControl_Loaded(object sender, RoutedEventArgs e)
+ {
+ if (sender is FrameworkElement progressControl) progressControl.Loaded -= ProgressControl_Loaded;
+ if (_workerStarted || _workerCompleted || _backgroundWorker == null) return;
+ try
+ {
+ _timer?.Start();
+ _workerStarted = true;
+ _backgroundWorker.RunWorkerAsync(_project);
+ }
+ catch (Exception ex)
{
- _timer.Stop();
- _timer.Tick -= Timer_Tick;
+ _workerStarted = false;
+ _workerError = ExceptionDispatchInfo.Capture(ex);
+ _progressControl?.Close();
}
+ }
- // Start the timer
- _timer = new DispatcherTimer() { Interval = TimeSpan.FromSeconds(0.1d) };
- _timer.Tick += Timer_Tick;
- _timer.IsEnabled = true;
- _timer.Start();
+ /// Preserves modal protection when the progress dialog returns before its worker.
+ /// The dispatcher stops before completion is observed.
+ private static void WaitForWorkerCompletion()
+ {
+ var dispatcher = _progressControl!.Dispatcher;
+ var disabledWindows = new List();
+ try
+ {
+ if (Application.Current != null)
+ {
+ foreach (Window window in Application.Current.Windows)
+ {
+ if (window.Dispatcher == dispatcher && window.IsEnabled)
+ {
+ disabledWindows.Add(window);
+ window.SetCurrentValue(UIElement.IsEnabledProperty, false);
+ }
+ }
+ }
+ _recoveryFrame = new DispatcherFrame();
+ if (!dispatcher.HasShutdownStarted && !dispatcher.HasShutdownFinished && !_workerCompleted)
+ Dispatcher.PushFrame(_recoveryFrame);
+ if (!_workerCompleted)
+ throw new InvalidOperationException("Project compaction is still running; the project cannot be saved or closed.");
+ }
+ finally
+ {
+ _recoveryFrame = null;
+ if (_workerCompleted)
+ {
+ ExceptionDispatchInfo? restoreFailure = null;
+ foreach (Window window in disabledWindows)
+ AttemptCleanup(() => window.SetCurrentValue(UIElement.IsEnabledProperty, true), ref restoreFailure);
+ restoreFailure?.Throw();
+ }
+ }
+ }
- // Clean up existing background worker if any
- if (_backgroundWorker != null)
+ /// Releases the completed operation's handlers, timer, worker, window, and guard.
+ /// Every cleanup step is attempted; the first cleanup error is propagated afterward.
+ private static void CleanupOperation()
+ {
+ ExceptionDispatchInfo? failure = null;
+ try
{
- _backgroundWorker.DoWork -= BackgroundWorker_Dowork;
- _backgroundWorker.ProgressChanged -= BackgroundWorker_ProgressChanged;
- _backgroundWorker.RunWorkerCompleted -= BackgroundWorker_WorkerComplete;
- _backgroundWorker.Dispose();
+ var timer = _timer;
+ if (timer != null)
+ {
+ AttemptCleanup(timer.Stop, ref failure);
+ AttemptCleanup(() => timer.Tick -= Timer_Tick, ref failure);
+ }
+ var worker = _backgroundWorker;
+ if (worker != null)
+ {
+ AttemptCleanup(() => worker.DoWork -= BackgroundWorker_Dowork, ref failure);
+ AttemptCleanup(() => worker.ProgressChanged -= BackgroundWorker_ProgressChanged, ref failure);
+ AttemptCleanup(() => worker.RunWorkerCompleted -= BackgroundWorker_WorkerComplete, ref failure);
+ AttemptCleanup(worker.Dispose, ref failure);
+ }
+ var progressControl = _progressControl;
+ if (progressControl != null)
+ {
+ AttemptCleanup(() => progressControl.Loaded -= ProgressControl_Loaded, ref failure);
+ AttemptCleanup(() => progressControl.Closing -= ProgressControl_Closing, ref failure);
+ AttemptCleanup(progressControl.Close, ref failure);
+ }
}
+ finally
+ {
+ _timer = null;
+ _backgroundWorker = null;
+ _progressControl = null;
+ _project = null;
+ _fileSizeBefore = null;
+ _fileSizeAfter = null;
+ _workerError = null;
+ _workerStarted = false;
+ _workerCompleted = false;
+ ShellPublicVariables.CompactionInProgress = false;
+ Interlocked.Exchange(ref _operationActive, 0);
+ }
+ failure?.Throw();
+ }
- // Create compact background worker
- _backgroundWorker = new BackgroundWorker();
- _backgroundWorker.WorkerSupportsCancellation = true;
- _backgroundWorker.WorkerReportsProgress = true;
- _backgroundWorker.DoWork += BackgroundWorker_Dowork;
- _backgroundWorker.ProgressChanged += BackgroundWorker_ProgressChanged;
- _backgroundWorker.RunWorkerCompleted += BackgroundWorker_WorkerComplete;
- _backgroundWorker.RunWorkerAsync();
-
- // Open the progress bar control
- _progressControl = new FrameworkUI.CompactProgressControl();
- _progressControl.ProgressText.Text = "Analyzing Project File...";
- _progressControl.ProgressBar.IsIndeterminate = true;
- _progressControl.ProgressBar.Minimum = 0d;
- _progressControl.ProgressBar.Maximum = 100d;
- _progressControl.ProgressBar.Value = 0d;
- _progressControl.Closing += ProgressControl_Closing;
- _progressControl.ShowDialog();
+ /// Attempts one cleanup action without preventing subsequent cleanup.
+ /// The resource cleanup or window restoration action.
+ /// The first failure, if one has already occurred.
+ private static void AttemptCleanup(Action cleanup, ref ExceptionDispatchInfo? failure)
+ {
+ try { cleanup(); }
+ catch (Exception ex) { PreserveFailure(ref failure, ex, "cleanup"); }
}
+ /// Preserves the first failure and records secondary diagnostic failures.
+ /// The first failure delivered to the caller.
+ /// The newly observed failure.
+ /// The operation producing this diagnostic.
+ private static void PreserveFailure(ref ExceptionDispatchInfo? failure, Exception error, string operation)
+ {
+ if (failure == null) failure = ExceptionDispatchInfo.Capture(error);
+ else if (!ReferenceEquals(failure.SourceException, error))
+ Debug.WriteLine($"FileSizeManager secondary {operation} failure: {error}");
+ }
///
/// Background worker for performing file compaction.
///
@@ -144,13 +297,13 @@ public static void CompactAndOptimizeFile(IProject project)
/// The event arguments.
private static void BackgroundWorker_Dowork(object? sender, DoWorkEventArgs e)
{
- if (sender is not BackgroundWorker worker || _project == null) return;
+ if (sender is not BackgroundWorker worker || e.Argument is not IProject project) return;
// Update progress bar to compacting
worker.ReportProgress(0);
- _project.Compact();
+ project.Compact();
// Update progress bar to optimizing
worker.ReportProgress(100);
- _project.Optimize();
+ project.Optimize();
}
///
@@ -185,66 +338,26 @@ private static void BackgroundWorker_ProgressChanged(object? sender, ProgressCha
/// The event arguments.
private static void BackgroundWorker_WorkerComplete(object? sender, RunWorkerCompletedEventArgs e)
{
- // Stop and clean up timer
- if (_timer != null)
- {
- _timer.Stop();
- _timer.IsEnabled = false;
- _timer.Tick -= Timer_Tick;
- }
-
- // Stop and clean up worker
- if (_backgroundWorker != null)
- {
- _backgroundWorker.CancelAsync();
- _backgroundWorker.DoWork -= BackgroundWorker_Dowork;
- _backgroundWorker.ProgressChanged -= BackgroundWorker_ProgressChanged;
- _backgroundWorker.RunWorkerCompleted -= BackgroundWorker_WorkerComplete;
- _backgroundWorker.Dispose();
- _backgroundWorker = null;
- }
-
- if (_progressControl != null)
- {
- _progressControl.Closing -= ProgressControl_Closing;
- _progressControl.Close();
- _progressControl = null;
- }
- if (_timer != null) { _timer.Stop(); _timer.Tick -= Timer_Tick; _timer = null; }
- ShellPublicVariables.CompactionInProgress = false;
-
- if (_project == null) return;
-
- // Get new file size
- _fileSizeAfter = GetFileSizeText(_project.FullFileName);
-
- // Report final compression
- string message;
- if (_fileSizeBefore == _fileSizeAfter)
- {
- message = "The file was optimized. However, there was no unused space to compact in '" + _project.Name + "', so the file size remains unchanged at " + _fileSizeAfter + ".";
- }
- else
+ if (e.Error != null) _workerError = ExceptionDispatchInfo.Capture(e.Error);
+ else if (e.Cancelled) _workerError = ExceptionDispatchInfo.Capture(new OperationCanceledException("Project compaction was canceled."));
+ _workerCompleted = true;
+ if (_recoveryFrame != null) _recoveryFrame.Continue = false;
+ try { _progressControl?.Close(); }
+ catch (Exception ex)
{
- message = "The file was compacted and optimized. The project '" + _project.Name + "' was compacted from " + _fileSizeBefore + " to " + _fileSizeAfter + ".";
+ _workerError ??= ExceptionDispatchInfo.Capture(ex);
+ Debug.WriteLine($"FileSizeManager progress window close failed: {ex}");
+ _progressControl?.Hide();
}
-
- ReportProgress?.Invoke(message);
}
- ///
- /// Handles the progress control window closing event.
- /// Cancels the background worker when the user closes the window.
- ///
- /// The source of the event.
- /// The event arguments.
+ /// Vetoes closing while a non-cancelable compaction worker remains active.
+ /// The progress window.
+ /// The closing event.
private static void ProgressControl_Closing(object? sender, CancelEventArgs e)
{
- _backgroundWorker?.CancelAsync();
- if (_timer != null) { _timer.Stop(); _timer.Tick -= Timer_Tick; _timer = null; }
- ShellPublicVariables.CompactionInProgress = false;
+ if (_workerStarted && !_workerCompleted) e.Cancel = true;
}
-
///
/// Timer used to update the progress bar.
///
diff --git a/src/OxyPlotControls/Properties/AssemblyInfo.cs b/src/OxyPlotControls/Properties/AssemblyInfo.cs
index 39c2b885..60a1ae78 100644
--- a/src/OxyPlotControls/Properties/AssemblyInfo.cs
+++ b/src/OxyPlotControls/Properties/AssemblyInfo.cs
@@ -28,8 +28,8 @@
// Build Number
// Revision
//
-[assembly: AssemblyVersion("1.0.4.0")]
-[assembly: AssemblyFileVersion("1.0.4.0")]
+[assembly: AssemblyVersion("1.0.5.0")]
+[assembly: AssemblyFileVersion("1.0.5.0")]
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
diff --git a/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.csproj b/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.csproj
index 10a47ee8..724867b2 100644
--- a/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.csproj
+++ b/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.csproj
@@ -3,7 +3,7 @@
net10.0-windows
true
false
- 1.0.4
+ 1.0.5
RMC.Wpf.Framework.Controls.nuspec
$(MSBuildProjectDirectory)
configuration=$(Configuration);version=$(Version);rmcNumericsPackageDependencyVersion=$(RmcNumericsPackageDependencyVersion)
diff --git a/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.nuspec b/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.nuspec
index a9bb67aa..71011b8b 100644
--- a/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.nuspec
+++ b/src/Packaging/RMC.Wpf.Framework.Controls/RMC.Wpf.Framework.Controls.nuspec
@@ -10,7 +10,7 @@
README.md
WPF controls, framework UI, DAG controls, database controls, numeric controls, OxyPlot controls, and AvalonDock assemblies for the RMC WPF Framework.
- Version 1.0.4 updates RMC.Numerics to 2.1.4 and improves NumericControls validation and distribution selector compatibility.
+ Version 1.0.5 keeps compaction active until database work completes, reports maintenance errors, and updates FrameworkUI demo workflows.
RMC WPF framework controls AvalonDock OxyPlot numeric database DAG
diff --git a/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.csproj b/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.csproj
index 0b1c3f8b..7d92e224 100644
--- a/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.csproj
+++ b/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.csproj
@@ -3,7 +3,7 @@
net10.0-windows
true
false
- 1.0.4
+ 1.0.5
RMC.Wpf.Framework.Core.nuspec
$(MSBuildProjectDirectory)
configuration=$(Configuration);version=$(Version)
diff --git a/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.nuspec b/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.nuspec
index e3879e63..2c02a8b9 100644
--- a/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.nuspec
+++ b/src/Packaging/RMC.Wpf.Framework.Core/RMC.Wpf.Framework.Core.nuspec
@@ -10,7 +10,7 @@
README.md
Core interfaces, project model contracts, undo/redo infrastructure, messaging, and WPF themes for the RMC WPF Framework.
- Coordinated WPF Framework 1.0.4 release; no package-specific functional changes.
+ Version 1.0.5 keeps element display names synchronized when names change during undo or redo.
RMC WPF framework themes undo-redo
diff --git a/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.csproj b/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.csproj
index 54709311..04e34ad1 100644
--- a/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.csproj
+++ b/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.csproj
@@ -3,7 +3,7 @@
net10.0-windows
true
false
- 1.0.4
+ 1.0.5
RMC.Wpf.Framework.Models.nuspec
$(MSBuildProjectDirectory)
configuration=$(Configuration);version=$(Version);closedXmlVersion=$(ClosedXmlVersion);documentFormatOpenXmlVersion=$(DocumentFormatOpenXmlVersion);excelNumberFormatVersion=$(ExcelNumberFormatVersion);fastMemberVersion=$(FastMemberVersion);sourceGearSqlite3Version=$(SourceGearSqlite3Version);systemDataSQLiteVersion=$(SystemDataSQLiteVersion)
diff --git a/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.nuspec b/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.nuspec
index 2801dba9..35952950 100644
--- a/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.nuspec
+++ b/src/Packaging/RMC.Wpf.Framework.Models/RMC.Wpf.Framework.Models.nuspec
@@ -10,7 +10,7 @@
README.md
Model, graph, database, expression-parser, and vendored OxyPlot assemblies for the RMC WPF Framework.
- Coordinated WPF Framework 1.0.4 release; no package-specific functional changes.
+ Version 1.0.5 releases SQLite connections when initialization or database operations fail.
RMC WPF framework database expression-parser DAG OxyPlot
diff --git a/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.csproj b/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.csproj
index d6fdd71b..fa92a1c2 100644
--- a/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.csproj
+++ b/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.csproj
@@ -3,7 +3,7 @@
net10.0-windows
true
false
- 1.0.4
+ 1.0.5
$(NoWarn);NU5100
RMC.Wpf.Framework.Support.nuspec
diff --git a/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.nuspec b/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.nuspec
index 86f13fe6..6766a02b 100644
--- a/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.nuspec
+++ b/src/Packaging/RMC.Wpf.Framework.Support/RMC.Wpf.Framework.Support.nuspec
@@ -10,7 +10,7 @@
README.md
Software update services and deployable updater helper for the RMC WPF Framework.
- Coordinated WPF Framework 1.0.4 release; no package-specific functional changes.
+ Coordinated WPF Framework 1.0.5 release; no package-specific functional changes.
RMC WPF framework software-update updater
diff --git a/src/SoftwareUpdate.Updater/SoftwareUpdate.Updater.csproj b/src/SoftwareUpdate.Updater/SoftwareUpdate.Updater.csproj
index 18444878..567ee248 100644
--- a/src/SoftwareUpdate.Updater/SoftwareUpdate.Updater.csproj
+++ b/src/SoftwareUpdate.Updater/SoftwareUpdate.Updater.csproj
@@ -8,9 +8,9 @@
enable
enable
latest
- 1.0.4
- 1.0.4.0
- 1.0.4.0
+ 1.0.5
+ 1.0.5.0
+ 1.0.5.0
diff --git a/tests/FrameworkInterfaces.Tests/AuditRegressionTests.cs b/tests/FrameworkInterfaces.Tests/AuditRegressionTests.cs
index ef024af6..4fc406f4 100644
--- a/tests/FrameworkInterfaces.Tests/AuditRegressionTests.cs
+++ b/tests/FrameworkInterfaces.Tests/AuditRegressionTests.cs
@@ -39,6 +39,7 @@ public void TriggerRaise(string propertyName, bool isDirty) =>
RaisePropertyChange(propertyName, isDirty);
}
+ /// Verifies that a notify-only property change preserves an existing dirty state.
[Fact]
public void A001_ElementBaseBuff_RaisePropertyChange_PreservesIsDirty_OnFalse()
{
@@ -56,6 +57,7 @@ public void A001_ElementBaseBuff_RaisePropertyChange_PreservesIsDirty_OnFalse()
Assert.True(element.IsDirty);
}
+ /// Verifies that a property change requesting dirty promotion marks a clean element dirty.
[Fact]
public void A001_ElementBaseBuff_RaisePropertyChange_PromotesIsDirty_OnTrue()
{
@@ -71,6 +73,7 @@ public void A001_ElementBaseBuff_RaisePropertyChange_PromotesIsDirty_OnTrue()
Assert.True(element.IsDirty);
}
+ /// Verifies that a notify-only change still raises the named property notification.
[Fact]
public void A001_ElementBaseBuff_RaisePropertyChange_RaisesPropertyChangedEvent()
{
@@ -121,6 +124,7 @@ public override void Open() { }
public override void Delete() { }
}
+ /// Verifies that moving an element records an undo action restoring the original order.
[Fact]
public void A007_ElementCollectionBase_MoveElement_IsUndoable()
{
@@ -154,6 +158,7 @@ public void A007_ElementCollectionBase_MoveElement_IsUndoable()
Assert.Same(c, collection[2]);
}
+ /// Verifies that redoing an undone element move restores the moved order.
[Fact]
public void A007_ElementCollectionBase_MoveElement_RedoRestoresMovedOrder()
{
diff --git a/tests/FrameworkInterfaces.Tests/Messaging/MessengerTests.cs b/tests/FrameworkInterfaces.Tests/Messaging/MessengerTests.cs
index cd22c072..b596d14b 100644
--- a/tests/FrameworkInterfaces.Tests/Messaging/MessengerTests.cs
+++ b/tests/FrameworkInterfaces.Tests/Messaging/MessengerTests.cs
@@ -279,7 +279,6 @@ public void Clear_RemovesAllMessages()
///
/// Verifies that Clear with a specific source only removes messages from that source.
///
- /// The source object to filter messages by.
[Fact]
public void Clear_WithSource_RemovesOnlySourceMessages()
{
diff --git a/tests/FrameworkUI.Tests/FileSizeManagement/CompactionFailureScenarios.cs b/tests/FrameworkUI.Tests/FileSizeManagement/CompactionFailureScenarios.cs
new file mode 100644
index 00000000..2fa52b19
--- /dev/null
+++ b/tests/FrameworkUI.Tests/FileSizeManagement/CompactionFailureScenarios.cs
@@ -0,0 +1,133 @@
+using System.ComponentModel;
+using System.IO;
+using System.Reflection;
+using System.Windows;
+using System.Windows.Threading;
+using Xunit;
+
+namespace FrameworkUI.Tests.FileSizeManagement;
+
+/// Injects actual dispatcher and disposal failures in isolated compaction test processes.
+internal static class CompactionFailureScenarios
+{
+ /// Exercises the selected error through modal recovery or owned resource cleanup.
+ /// The failure scenario selected by the parent test.
+ public static void Run(string testCase)
+ {
+ if (testCase == "cleanup-failure") { AssertCleanupContinues(); return; }
+ if (testCase == "worker-cleanup-failure") { AssertWorkerErrorSurvivesCleanup(); return; }
+ using var scenario = new CompactionScenario();
+ using var release = new ManualResetEventSlim();
+ var primary = new IOException("The modal or recovery operation failed first.");
+ var secondary = new IOException("The worker failure must remain secondary.");
+ var window = new Window { Width = 100, Height = 100, ShowInTaskbar = false };
+ window.Show();
+ var secondWindow = new Window { Width = 100, Height = 100, ShowInTaskbar = false };
+ secondWindow.Show();
+ DependencyPropertyChangedEventHandler restoreFailure = (_, e) =>
+ {
+ if ((bool)e.NewValue) throw primary;
+ };
+ if (testCase == "recovery-worker-failure") window.IsEnabledChanged += restoreFailure;
+ scenario.Project.CompactAction = () =>
+ {
+ scenario.Dispatcher.Invoke(() =>
+ {
+ scenario.ObserveCompletion();
+ if (testCase == "modal-worker-failure")
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.Normal, new Action(() => throw primary));
+ else
+ CompactionScenario.Dialog().Hide();
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(release.Set));
+ });
+ if (!release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("The recovery frame did not release its worker.");
+ throw secondary;
+ };
+ try
+ {
+ Assert.Same(primary, scenario.Run());
+ Assert.True(secondWindow.IsEnabled, "Later window states must be restored even when an earlier restoration raises an error.");
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ }
+ finally
+ {
+ release.Set();
+ window.IsEnabledChanged -= restoreFailure;
+ window.Close();
+ secondWindow.Close();
+ }
+ }
+
+ /// Verifies that a completed worker error survives a later owned-resource disposal error.
+ private static void AssertWorkerErrorSurvivesCleanup()
+ {
+ using var scenario = new CompactionScenario();
+ var primary = new IOException("The worker failed before cleanup.");
+ var secondary = new IOException("Cleanup must not replace the worker failure.");
+ scenario.Project.CompactAction = () =>
+ {
+ scenario.Dispatcher.Invoke(() =>
+ {
+ var worker = Assert.IsType(CompactionScenario.Field("_backgroundWorker"));
+ worker.RunWorkerCompleted += (_, _) =>
+ {
+ // Substitute only after the real callback has completed its work.
+ SetField("_backgroundWorker", new ThrowingDisposeWorker(secondary));
+ worker.Dispose();
+ };
+ });
+ throw primary;
+ };
+ Assert.Same(primary, scenario.Run());
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ }
+ /// Verifies that disposal failure cannot skip closing later owned resources.
+ private static void AssertCleanupContinues()
+ {
+ using var scenario = new CompactionScenario();
+ var failure = new IOException("Disposing the completed worker failed.");
+ var timer = new DispatcherTimer { Interval = TimeSpan.FromHours(1) };
+ var dialog = new CompactProgressControl();
+ var worker = new ThrowingDisposeWorker(failure);
+ timer.Start();
+ dialog.Show();
+ SetField("_timer", timer);
+ SetField("_progressControl", dialog);
+ SetField("_backgroundWorker", worker);
+ SetField("_project", scenario.Project);
+ SetField("_operationActive", 1);
+ ShellPublicVariables.CompactionInProgress = true;
+ try
+ {
+ var thrown = Assert.Throws(() =>
+ typeof(FileSizeManager).GetMethod("CleanupOperation", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, null));
+ Assert.Same(failure, thrown.InnerException);
+ Assert.False(timer.IsEnabled);
+ Assert.False(dialog.IsVisible, "The progress window must close even when worker disposal fails.");
+ scenario.AssertClean();
+ }
+ finally { timer.Stop(); dialog.Close(); }
+ }
+
+ /// Sets an owned resource for a focused cleanup fault without exposing production hooks.
+ /// The existing private field.
+ /// The test-owned resource or state.
+ private static void SetField(string name, object value) => typeof(FileSizeManager).GetField(name, BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, value);
+
+ /// Raises a disposal failure after normal BackgroundWorker cleanup.
+ private sealed class ThrowingDisposeWorker : BackgroundWorker
+ {
+ private readonly Exception failure;
+ /// Stores the exact error instance to raise during disposal.
+ /// The injected cleanup failure.
+ public ThrowingDisposeWorker(Exception failure) => this.failure = failure;
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+ if (disposing) throw failure;
+ }
+ }
+}
diff --git a/tests/FrameworkUI.Tests/FileSizeManagement/FileSizeManagerLifecycleTests.cs b/tests/FrameworkUI.Tests/FileSizeManagement/FileSizeManagerLifecycleTests.cs
new file mode 100644
index 00000000..10b334d0
--- /dev/null
+++ b/tests/FrameworkUI.Tests/FileSizeManagement/FileSizeManagerLifecycleTests.cs
@@ -0,0 +1,434 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.IO;
+using System.Reflection;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Threading;
+using FrameworkInterfaces;
+using FrameworkUI.Tests.FrameworkUIDemo;
+using Xunit;
+
+namespace FrameworkUI.Tests.FileSizeManagement;
+
+/// Exercises compaction through its real modal window and background worker.
+public class FileSizeManagerLifecycleTests
+{
+ /// Closing either phase must not release the caller or the compaction guard.
+ /// Whether to attempt closing during optimization.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void CloseDuringWork_IsVetoedUntilCompletion(bool duringOptimize)
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ bool remainedVisible = false;
+ bool remainedGuarded = false;
+ Action attemptClose = () => scenario.Dispatcher.Invoke(() =>
+ {
+ scenario.ObserveCompletion();
+ var dialog = CompactionScenario.Dialog();
+ dialog.Close();
+ remainedVisible = dialog.IsVisible;
+ remainedGuarded = ShellPublicVariables.CompactionInProgress;
+ });
+ if (duringOptimize) scenario.Project.OptimizeAction = attemptClose;
+ else scenario.Project.CompactAction = attemptClose;
+
+ var error = scenario.Run();
+
+ Assert.Null(error);
+ Assert.True(remainedVisible, "Closing the progress window must be vetoed while its worker is running.");
+ Assert.True(remainedGuarded);
+ Assert.False(scenario.ReturnedBeforeCompletion);
+ Assert.Equal(new[] { "Compact", "Optimize" }, scenario.Project.Calls);
+ Assert.Single(scenario.Reports);
+ scenario.AssertClean();
+ });
+ }
+
+ /// Worker failures reach the synchronous caller unchanged and never report success.
+ /// Whether optimization should fail after compaction succeeds.
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void WorkerFailure_PreservesExceptionAndAllowsReuse(bool duringOptimize)
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ var failure = new IOException("Injected compaction failure.");
+ Action fail = () => throw failure;
+ if (duringOptimize) scenario.Project.OptimizeAction = fail;
+ else scenario.Project.CompactAction = fail;
+
+ var error = scenario.Run();
+
+ Assert.Same(failure, error);
+ Assert.Contains(nameof(CompactionProject), error!.StackTrace);
+ Assert.Equal(duringOptimize ? new[] { "Compact", "Optimize" } : new[] { "Compact" }, scenario.Project.Calls);
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ scenario.Project.CompactAction = null;
+ scenario.Project.OptimizeAction = null;
+ Assert.Null(scenario.Run());
+ Assert.Single(scenario.Reports);
+ scenario.AssertClean();
+ });
+ }
+
+ /// Reentry must reject the second project before touching the first operation.
+ [Fact]
+ public void Reentry_LeavesFirstProjectAndDialogUntouched()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ var second = new CompactionProject(scenario.Project.FullFileName);
+ Exception? rejected = null;
+ bool sameDialog = false;
+ scenario.Project.CompactAction = () => scenario.Dispatcher.Invoke(() =>
+ {
+ var dialog = CompactionScenario.Dialog();
+ scenario.ObserveCompletion();
+ rejected = Record.Exception(() => FileSizeManager.CompactAndOptimizeFile(second));
+ sameDialog = ReferenceEquals(dialog, CompactionScenario.Dialog());
+ });
+
+ Assert.Null(scenario.Run());
+ var rejection = Assert.IsType(rejected);
+ Assert.Equal("Project compaction is already in progress.", rejection.Message);
+ Assert.True(sameDialog);
+ Assert.Empty(second.Calls);
+ Assert.Equal(new[] { "Compact", "Optimize" }, scenario.Project.Calls);
+ scenario.AssertClean();
+ });
+ }
+
+ /// An immediate worker may complete only after its dialog has been prepared and shown.
+ [Fact]
+ public void ImmediateCompletion_StartsOnceAndReportsAfterCleanup()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ bool dialogWasVisible = false;
+ scenario.Project.CompactAction = () => scenario.Dispatcher.Invoke(() =>
+ {
+ var dialog = CompactionScenario.Dialog();
+ dialogWasVisible = dialog.IsVisible;
+ // A duplicate Loaded notification must not start a second worker.
+ dialog.RaiseEvent(new RoutedEventArgs(FrameworkElement.LoadedEvent));
+ });
+
+ Assert.Null(scenario.Run());
+ Assert.True(dialogWasVisible);
+ Assert.Equal(new[] { "Compact", "Optimize" }, scenario.Project.Calls);
+ Assert.Single(scenario.Reports);
+ Assert.True(scenario.ReportsObservedCleanState);
+ scenario.AssertClean();
+ });
+ }
+
+ /// Failure before showing a dialog must release the guard and permit a later operation.
+ [Fact]
+ public void StartupFailure_CleansStateAndAllowsReuse()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ var failure = new IOException("Injected startup failure.");
+ scenario.Project.PathFailure = failure;
+
+ Assert.Same(failure, scenario.Run());
+ Assert.Empty(scenario.Project.Calls);
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ scenario.Project.PathFailure = null;
+ Assert.Null(scenario.Run());
+ scenario.AssertClean();
+ });
+ }
+
+ /// Hiding the modal dialog must retain protection until actual worker completion.
+ [Fact]
+ public void HideDuringWork_RecoversModalProtectionAndRestoresWindowStates()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ using var release = new ManualResetEventSlim();
+ var enabledWindow = new Window { Width = 100, Height = 100, ShowInTaskbar = false };
+ var disabledWindow = new Window { Width = 100, Height = 100, ShowInTaskbar = false, IsEnabled = false };
+ enabledWindow.Show();
+ disabledWindow.Show();
+ bool protectedDuringRecovery = false;
+ bool windowsDisabled = false;
+ bool returnedDuringRecovery = false;
+ try
+ {
+ scenario.Project.CompactAction = () =>
+ {
+ scenario.Dispatcher.Invoke(() =>
+ {
+ scenario.ObserveCompletion();
+ CompactionScenario.Dialog().Hide();
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() =>
+ {
+ protectedDuringRecovery = ShellPublicVariables.CompactionInProgress;
+ windowsDisabled = !enabledWindow.IsEnabled && !disabledWindow.IsEnabled;
+ returnedDuringRecovery = scenario.Returned;
+ release.Set();
+ }));
+ });
+ if (!release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("The recovery dispatcher did not process its probe.");
+ };
+
+ Assert.Null(scenario.Run());
+ Assert.True(protectedDuringRecovery);
+ Assert.True(windowsDisabled);
+ Assert.False(returnedDuringRecovery);
+ Assert.False(scenario.ReturnedBeforeCompletion);
+ Assert.True(enabledWindow.IsEnabled);
+ Assert.False(disabledWindow.IsEnabled);
+ scenario.AssertClean();
+ }
+ finally
+ {
+ release.Set();
+ enabledWindow.Close();
+ disabledWindow.Close();
+ }
+ });
+ }
+
+ /// Aborted recovery retains the live writer and rejects another operation until observed completion.
+ [Fact]
+ public void AbortedRecovery_RetainsGuardAndResourcesWhileWorkerIsActive()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ using var release = new ManualResetEventSlim();
+ var completion = new DispatcherFrame();
+ bool completed = false;
+ scenario.Project.CompactAction = () =>
+ {
+ scenario.Dispatcher.Invoke(() =>
+ {
+ var worker = Assert.IsType(CompactionScenario.Field("_backgroundWorker"));
+ worker.RunWorkerCompleted += (_, _) => { completed = true; completion.Continue = false; };
+ CompactionScenario.Dialog().Hide();
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() =>
+ Assert.IsType(CompactionScenario.Field("_recoveryFrame")).Continue = false));
+ });
+ if (!release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("Recovery-abort fixture was not released.");
+ };
+ try
+ {
+ var failure = Assert.Throws(() => FileSizeManager.CompactAndOptimizeFile(scenario.Project));
+ Assert.Contains("cannot be saved or closed", failure.Message);
+ Assert.True(ShellPublicVariables.CompactionInProgress);
+ Assert.NotNull(CompactionScenario.Field("_backgroundWorker"));
+ Assert.NotNull(CompactionScenario.Field("_timer"));
+ Assert.Same(scenario.Project, CompactionScenario.Field("_project"));
+ Assert.False(completed);
+ Assert.Empty(scenario.Reports);
+ var rejected = Assert.Throws(() => FileSizeManager.CompactAndOptimizeFile(scenario.Project));
+ Assert.Equal("Project compaction is already in progress.", rejected.Message);
+ }
+ finally
+ {
+ release.Set();
+ var watchdog = new DispatcherTimer { Interval = TimeSpan.FromSeconds(25) };
+ watchdog.Tick += (_, _) => completion.Continue = false;
+ watchdog.Start();
+ try { if (!completed) Dispatcher.PushFrame(completion); }
+ finally { watchdog.Stop(); }
+ Assert.True(completed);
+ // The production caller intentionally retains the guard after an aborted frame.
+ // The isolated fixture releases it only after observing the actual callback.
+ typeof(FileSizeManager).GetMethod("CleanupOperation", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, null);
+ }
+ scenario.AssertClean();
+ });
+ }
+
+ /// Success observers run after cleanup and may start a new independent operation.
+ [Fact]
+ public void SuccessObserver_CanStartNextOperation()
+ {
+ DispatcherTestHost.Run(() =>
+ {
+ using var scenario = new CompactionScenario();
+ var second = new CompactionProject(scenario.Project.FullFileName);
+ bool entered = false;
+ FileSizeManager.ReportProgressEventHandler observer = _ =>
+ {
+ if (entered) return;
+ entered = true;
+ FileSizeManager.CompactAndOptimizeFile(second);
+ };
+ FileSizeManager.ReportProgress += observer;
+ try
+ {
+ Assert.Null(scenario.Run());
+ Assert.Equal(new[] { "Compact", "Optimize" }, second.Calls);
+ scenario.AssertClean();
+ }
+ finally
+ {
+ FileSizeManager.ReportProgress -= observer;
+ }
+ });
+ }
+}
+
+/// Owns a temporary file and observes a real compaction invocation on the shared dispatcher.
+internal sealed class CompactionScenario : IDisposable
+{
+ private bool completionObserved;
+ private bool completionSubscribed;
+ private DispatcherFrame? completionFrame;
+ private readonly string path = Path.Combine(Path.GetTempPath(), $"framework-compaction-{Guid.NewGuid():N}.sqlite");
+
+ /// Creates a file-backed fake project and subscribes to final reporting.
+ public CompactionScenario()
+ {
+ File.WriteAllText(path, "Compaction lifecycle fixture");
+ Project = new CompactionProject(path);
+ FileSizeManager.ReportProgress += OnReport;
+ }
+
+ /// Gets the UI dispatcher on which the operation runs.
+ public Dispatcher Dispatcher { get; } = System.Windows.Threading.Dispatcher.CurrentDispatcher;
+ /// Gets the controlled project.
+ public CompactionProject Project { get; }
+ /// Gets the final reports.
+ public List Reports { get; } = new();
+ /// Gets whether the public method returned before the completion callback.
+ public bool ReturnedBeforeCompletion { get; private set; }
+ /// Gets whether the invocation has returned.
+ public bool Returned { get; private set; }
+ /// Gets whether all reporting observed released resources.
+ public bool ReportsObservedCleanState { get; private set; } = true;
+
+ /// Invokes compaction and drains any prematurely detached worker before assertions.
+ /// The exception delivered to the synchronous caller.
+ public Exception? Run()
+ {
+ Returned = false;
+ var error = Record.Exception(() => FileSizeManager.CompactAndOptimizeFile(Project));
+ Returned = true;
+ ReturnedBeforeCompletion = completionSubscribed && !completionObserved;
+ if (ReturnedBeforeCompletion)
+ {
+ completionFrame = new DispatcherFrame();
+ var watchdog = new DispatcherTimer { Interval = TimeSpan.FromSeconds(25) };
+ watchdog.Tick += (_, _) => completionFrame.Continue = false;
+ watchdog.Start();
+ try { Dispatcher.PushFrame(completionFrame); }
+ finally { watchdog.Stop(); }
+ Assert.True(completionObserved, "The detached worker failed to complete.");
+ }
+ return error;
+ }
+
+ /// Attaches a test observer to the currently active real worker.
+ public void ObserveCompletion()
+ {
+ if (completionSubscribed) return;
+ completionSubscribed = true;
+ var worker = Assert.IsType(Field("_backgroundWorker"));
+ worker.RunWorkerCompleted += (_, _) =>
+ {
+ completionObserved = true;
+ if (completionFrame != null) completionFrame.Continue = false;
+ };
+ }
+
+ /// Gets the currently visible compaction dialog.
+ /// The operation's progress window.
+ public static CompactProgressControl Dialog() => Application.Current.Windows.OfType().Single();
+
+ /// Asserts that a completed operation released all owned state.
+ public void AssertClean()
+ {
+ Assert.False(ShellPublicVariables.CompactionInProgress);
+ Assert.Null(Field("_backgroundWorker"));
+ Assert.Null(Field("_timer"));
+ Assert.Null(Field("_progressControl"));
+ Assert.Null(Field("_project"));
+ }
+
+ /// Reads owned resource fields without introducing production test seams.
+ /// The field name.
+ /// The current field value.
+ public static object? Field(string name) => typeof(FileSizeManager).GetField(name, BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null);
+
+ /// Records whether success is emitted only after operation cleanup.
+ /// The final success message.
+ private void OnReport(string message)
+ {
+ Reports.Add(message);
+ ReportsObservedCleanState &= !ShellPublicVariables.CompactionInProgress && Field("_project") == null && Field("_backgroundWorker") == null && Field("_timer") == null && Field("_progressControl") == null;
+ }
+
+ /// Unsubscribes reporting and deletes the temporary file.
+ public void Dispose()
+ {
+ FileSizeManager.ReportProgress -= OnReport;
+ File.Delete(path);
+ }
+}
+
+/// Provides controlled Compact and Optimize operations without real database work.
+internal sealed class CompactionProject : ProjectBase, IProject
+{
+ /// Creates a project with a real temporary file.
+ /// The owned fixture path.
+ public CompactionProject(string path)
+ {
+ FullFileName = path;
+ IsDirty = false;
+ ElementCollections = new ReadOnlyCollection(new List());
+ }
+ /// Sets a clean model with a pending layout save for shell close tests.
+ public void RequireLayoutSave() { IsDirty = false; LayoutDirty = true; }
+ /// Gets or sets the compaction operation.
+ public Action? CompactAction { get; set; }
+ /// Gets or sets the optimization operation.
+ public Action? OptimizeAction { get; set; }
+ /// Gets or sets the startup failure raised when reading the project path.
+ public Exception? PathFailure { get; set; }
+ /// Gets or sets a failure raised by the final save.
+ public Exception? SaveFailure { get; set; }
+ /// Gets the ordered operations requested by production code.
+ public List Calls { get; } = new();
+ ///
+ public override string Name { get; set; } = "Compaction fixture";
+ ///
+ public override string Description { get; set; } = string.Empty;
+ ///
+ public override string SoftwareVersion => "1.0";
+ ///
+ public override ImageSource ProjectImage => null!;
+ ///
+ string IProject.FullFileName { get => PathFailure == null ? FullFileName : throw PathFailure; set => FullFileName = value; }
+ ///
+ public override bool IsValid() => true;
+ ///
+ public override void CreateNew(string newFullFileName) => FullFileName = newFullFileName;
+ ///
+ public override void Open() => throw new NotSupportedException();
+ ///
+ public override void Save() { Calls.Add("Save"); if (SaveFailure != null) throw SaveFailure; }
+ ///
+ public override void Close() => Calls.Add("Close");
+ ///
+ public override void Compact() { Calls.Add("Compact"); CompactAction?.Invoke(); }
+ ///
+ public override void Optimize() { Calls.Add("Optimize"); OptimizeAction?.Invoke(); }
+}
diff --git a/tests/FrameworkUI.Tests/FileSizeManagement/MainWindowCompactionTests.cs b/tests/FrameworkUI.Tests/FileSizeManagement/MainWindowCompactionTests.cs
new file mode 100644
index 00000000..627ab50b
--- /dev/null
+++ b/tests/FrameworkUI.Tests/FileSizeManagement/MainWindowCompactionTests.cs
@@ -0,0 +1,204 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Threading;
+using FrameworkInterfaces;
+using FrameworkUI.Tests.FrameworkUIDemo;
+using Xunit;
+
+namespace FrameworkUI.Tests.FileSizeManagement;
+
+/// Exercises shell compaction callers in isolated processes because closing shuts down WPF.
+public class MainWindowCompactionTests
+{
+ private const string ChildCaseVariable = "FRAMEWORK_COMPACTION_CHILD_CASE";
+
+ /// Preserves final save ordering and errors while reporting the actual compaction failure.
+ /// The isolated close or manual-command scenario.
+ /// The asynchronous child-process verification.
+ [Theory]
+ [InlineData("success")]
+ [InlineData("compact-failure")]
+ [InlineData("optimize-failure")]
+ [InlineData("save-failure")]
+ [InlineData("manual-failure")]
+ [InlineData("active-guard")]
+ [InlineData("modal-worker-failure")]
+ [InlineData("recovery-worker-failure")]
+ [InlineData("cleanup-failure")]
+ [InlineData("worker-cleanup-failure")]
+ public async Task ShellCompaction_PreservesSaveAndErrorContracts(string testCase)
+ {
+ var childCase = Environment.GetEnvironmentVariable(ChildCaseVariable);
+ if (childCase != null)
+ {
+ if (childCase == testCase) DispatcherTestHost.Run(() => RunChild(testCase));
+ return;
+ }
+
+ var start = new ProcessStartInfo("dotnet") { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
+ start.ArgumentList.Add("vstest");
+ start.ArgumentList.Add(typeof(MainWindowCompactionTests).Assembly.Location);
+ start.ArgumentList.Add("--TestCaseFilter:FullyQualifiedName~MainWindowCompactionTests.ShellCompaction_PreservesSaveAndErrorContracts");
+ start.Environment[ChildCaseVariable] = testCase;
+ using var child = Process.Start(start)!;
+ Task output = child.StandardOutput.ReadToEndAsync();
+ Task error = child.StandardError.ReadToEndAsync();
+ using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(90));
+ try { await child.WaitForExitAsync(deadline.Token); }
+ catch (OperationCanceledException)
+ {
+ child.Kill(entireProcessTree: true);
+ throw new TimeoutException($"The isolated compaction scenario '{testCase}' did not finish.");
+ }
+ Assert.True(child.ExitCode == 0, $"{testCase}: {await output}\n{await error}");
+ }
+
+ /// Runs one real main-window scenario with isolated settings and a controlled project.
+ /// The scenario to execute.
+ private static void RunChild(string testCase)
+ {
+ if (testCase is "modal-worker-failure" or "recovery-worker-failure" or "cleanup-failure" or "worker-cleanup-failure")
+ {
+ CompactionFailureScenarios.Run(testCase);
+ return;
+ }
+ string folder = Path.Combine(Path.GetTempPath(), $"framework-shell-compaction-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(folder);
+ try
+ {
+ foreach (var field in typeof(ShellPublicVariables).GetFields(BindingFlags.Public | BindingFlags.Static))
+ {
+ if (field.IsInitOnly || field.FieldType != typeof(string) || !field.Name.EndsWith("Path", StringComparison.Ordinal)) continue;
+ field.SetValue(null, field.Name.EndsWith("FolderPath", StringComparison.Ordinal) ? folder + Path.DirectorySeparatorChar : Path.Combine(folder, field.Name + ".xml"));
+ }
+ ThemeManager.SetTheme(ThemeColor.Light);
+ Application.Current.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/GenericControls;component/Themes/GenericControlsTheme.xaml") });
+ Application.Current.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/FrameworkUI;component/Icons/IconDictionary.xaml") });
+ using var scenario = new CompactionScenario();
+ var window = new MainWindow { ProjectNode = new CompactionController(scenario.Project), ShowInTaskbar = false };
+ UserSettings.SaveWindowLayout = false;
+ UserSettings.CreateAutoRecoverBackup = false;
+ UserSettings.CompressProjectFileOnClose = true;
+ window.Show();
+ // Shutdown clears application resources; queued undo command queries are outside this lifecycle test.
+ window.CommandBindings.Clear();
+ scenario.Project.RequireLayoutSave();
+ var injected = new IOException($"Actual {testCase} error.");
+ string? displayedError = null;
+ bool saveWasBlockedDuringWork = false;
+ Action fail = () =>
+ {
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() => displayedError = RespondToMessage("OK")));
+ throw injected;
+ };
+ scenario.Project.CompactAction = () => scenario.Dispatcher.Invoke(() =>
+ {
+ CompactionScenario.Dialog().Close();
+ saveWasBlockedDuringWork = !scenario.Project.Calls.Contains("Save") && !scenario.Project.Calls.Contains("Close") && ShellPublicVariables.CompactionInProgress;
+ });
+ if (testCase is "compact-failure" or "manual-failure") scenario.Project.CompactAction = fail;
+ if (testCase == "optimize-failure") scenario.Project.OptimizeAction = fail;
+ if (testCase == "save-failure") scenario.Project.SaveFailure = injected;
+
+ if (testCase == "manual-failure")
+ {
+ scenario.Dispatcher.BeginInvoke(DispatcherPriority.ApplicationIdle, new Action(() => RespondToMessage("Yes")));
+ Invoke(window, "CompactProjectFile_Click", window, new RoutedEventArgs());
+ Assert.Contains(injected.Message, displayedError);
+ Assert.Equal(new[] { "Compact" }, scenario.Project.Calls);
+ Assert.Empty(scenario.Reports);
+ scenario.AssertClean();
+ return;
+ }
+ if (testCase == "active-guard")
+ {
+ scenario.Project.CompactAction = () => scenario.Dispatcher.Invoke(() =>
+ {
+ var closing = new CancelEventArgs();
+ Invoke(window, "MainWindow_Closing", window, closing);
+ Assert.True(closing.Cancel);
+ Assert.DoesNotContain("Save", scenario.Project.Calls);
+ Assert.DoesNotContain("Close", scenario.Project.Calls);
+ });
+ Assert.Null(scenario.Run());
+ Assert.Equal(new[] { "Compact", "Optimize" }, scenario.Project.Calls);
+ return;
+ }
+
+ Exception? failure = Record.Exception(window.Close);
+ if (testCase == "save-failure")
+ {
+ Assert.Same(injected, failure);
+ Assert.Equal(new[] { "Compact", "Optimize", "Save" }, scenario.Project.Calls);
+ }
+ else
+ {
+ Assert.Null(failure);
+ Assert.Equal(testCase == "compact-failure" ? new[] { "Compact", "Save", "Close" } : new[] { "Compact", "Optimize", "Save", "Close" }, scenario.Project.Calls);
+ }
+ if (testCase is "compact-failure" or "optimize-failure")
+ {
+ Assert.Contains(injected.Message, displayedError);
+ Assert.Empty(scenario.Reports);
+ }
+ else
+ {
+ Assert.True(saveWasBlockedDuringWork);
+ Assert.Single(scenario.Reports);
+ }
+ scenario.AssertClean();
+ }
+ finally { Directory.Delete(folder, recursive: true); }
+ }
+
+ /// Reads and responds to the actual themed modal message without user interaction.
+ /// The response button text.
+ /// The message shown to the user.
+ private static string RespondToMessage(string button)
+ {
+ var dialog = Application.Current.Windows.OfType().Single();
+ string message = ((TextBlock)dialog.FindName("MessageText")).Text;
+ var panel = (StackPanel)dialog.FindName("ButtonPanel");
+ panel.Children.OfType