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 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -