tests/DFPackageUniverse.Merge.Tests.ps1
|
BeforeAll { Set-StrictMode -Version Latest Import-Module PSSQLite . "$PSScriptRoot/../build/Private/DFPackageUniverse.Links.ps1" # Get-DFPackageUniverseRepoKey . "$PSScriptRoot/../build/Private/DFPackageUniverse.Merge.ps1" function New-MergeTestDb { $db = Join-Path ([System.IO.Path]::GetTempPath()) ("mergetest-" + [guid]::NewGuid().ToString('N') + ".db") Invoke-SqliteQuery -DataSource $db -Query @' CREATE TABLE raw_packages (id INTEGER PRIMARY KEY, source TEXT, package_id TEXT, name TEXT, version TEXT, description TEXT, homepage TEXT, license TEXT, publisher TEXT, tags TEXT, extra TEXT, fetched_at TEXT, UNIQUE(source, package_id)); CREATE TABLE pipeline_log (id INTEGER PRIMARY KEY, stage TEXT, source TEXT, package_id TEXT, level TEXT, message TEXT, logged_at TEXT); CREATE TABLE cluster_members (cluster_id INTEGER, source TEXT, package_id TEXT, join_method TEXT, join_confidence REAL, PRIMARY KEY(source, package_id)); '@ Initialize-DFPackageUniverseToolsSchema -DatabasePath $db $db } function Add-RawRow { param($Db, $Source, $PackageId, $Name, $Description = 'd', $Homepage = $null, $License = 'MIT', $Publisher = $null, $Tags = $null, $Extra = $null) Invoke-SqliteQuery -DataSource $Db -Query @' INSERT INTO raw_packages (source, package_id, name, version, description, homepage, license, publisher, tags, extra, fetched_at) VALUES (@s, @p, @n, '1', @d, @h, @l, @pub, @t, @e, 'now'); '@ -SqlParameters @{ s = $Source; p = $PackageId; n = $Name; d = $Description; h = $Homepage; l = $License; pub = $Publisher; t = $Tags; e = $Extra } } function Add-ClusterMember { param($Db, $Cid, $Source, $PackageId) Invoke-SqliteQuery -DataSource $Db -Query "INSERT INTO cluster_members (cluster_id, source, package_id, join_method, join_confidence) VALUES (@c, @s, @p, 'repo', 1.0);" -SqlParameters @{ c = $Cid; s = $Source; p = $PackageId } } } Describe 'DFPackageUniverse.Merge' { Context 'ConvertFrom-DFDbNull' { It 'maps DBNull to null and passes other values through' { ConvertFrom-DFDbNull ([DBNull]::Value) | Should -BeNullOrEmpty ConvertFrom-DFDbNull 'hello' | Should -Be 'hello' } } Context 'ConvertTo-DFNormalizedLicense' { It 'folds MIT and MIT License to the same identifier' { (ConvertTo-DFNormalizedLicense 'MIT') | Should -Be (ConvertTo-DFNormalizedLicense 'MIT License') } It 'treats a license URL as null (choco stores LicenseUrl, not an SPDX id)' { ConvertTo-DFNormalizedLicense 'https://github.com/x/y/blob/main/LICENSE' | Should -BeNullOrEmpty } It 'returns null for empty input' { ConvertTo-DFNormalizedLicense '' | Should -BeNullOrEmpty } It 'distinguishes genuinely different licenses' { (ConvertTo-DFNormalizedLicense 'Apache-2.0') | Should -Not -Be (ConvertTo-DFNormalizedLicense 'MIT') } It 'folds the -only SPDX modifier' { (ConvertTo-DFNormalizedLicense 'GPL-3.0-only') | Should -Be (ConvertTo-DFNormalizedLicense 'GPL-3.0') } It 'folds the -or-later SPDX modifier' { (ConvertTo-DFNormalizedLicense 'GPL-3.0-or-later') | Should -Be (ConvertTo-DFNormalizedLicense 'GPL-3.0') } It 'folds the + SPDX modifier' { (ConvertTo-DFNormalizedLicense 'GPL-3.0+') | Should -Be (ConvertTo-DFNormalizedLicense 'GPL-3.0') } It 'keeps LGPL distinct from GPL (regression: must not over-collapse)' { (ConvertTo-DFNormalizedLicense 'LGPL-3.0-only') | Should -Not -Be (ConvertTo-DFNormalizedLicense 'GPL-3.0') } } Context 'Resolve-DFPackageUniverseToolRecord' { BeforeAll { # Pester 5.8.0 does not carry a function defined directly in a # Context body (outside a hook) into Run-phase It scope; wrapping # in BeforeAll is the idiomatic fix. Body/behavior unchanged. function M { param($Source, $PackageId, $Name = $null, $Description = $null, $Homepage = $null, $License = $null, $Publisher = $null, $Extra = $null) [pscustomobject]@{ source = $Source; package_id = $PackageId; name = $Name; version = '1' description = $Description; homepage = $Homepage; license = $License publisher = $Publisher; tags = $null; extra = $Extra } } } It 'prefers the winget friendly name over choco and scoop' { $members = @( (M -Source 'scoop' -PackageId 'main/bat' -Name 'bat') (M -Source 'winget' -PackageId 'sharkdp.bat' -Name 'bat') (M -Source 'choco' -PackageId 'bat' -Name 'Bat') ) $rec = Resolve-DFPackageUniverseToolRecord -Members $members $rec.Name | Should -Be 'bat' $rec.NameSource | Should -Be 'winget' $rec.SourceCount | Should -Be 3 } It 'picks the richer winget description over a terse scoop one' { $members = @( (M -Source 'scoop' -PackageId 'main/x' -Name 'x' -Description 'short') (M -Source 'winget' -PackageId 'A.X' -Name 'x' -Description 'A full description of what X does.') ) $rec = Resolve-DFPackageUniverseToolRecord -Members $members $rec.Description | Should -Be 'A full description of what X does.' $rec.DescriptionSource | Should -Be 'winget' } It 'derives repo_url from choco ProjectSourceUrl' { $extra = ConvertTo-Json -Compress @{ ProjectSourceUrl = 'https://github.com/sharkdp/bat' } $members = @( (M -Source 'choco' -PackageId 'bat' -Name 'bat' -Extra $extra) ) $rec = Resolve-DFPackageUniverseToolRecord -Members $members $rec.RepoUrl | Should -Be 'https://github.com/sharkdp/bat' } It 'flags a genuine license conflict but not MIT vs MIT License' { $conflict = Resolve-DFPackageUniverseToolRecord -Members @( (M -Source 'winget' -PackageId 'A.X' -Name 'x' -License 'MIT') (M -Source 'choco' -PackageId 'x' -Name 'x' -License 'Apache-2.0') ) $conflict.NeedsReview | Should -BeTrue $conflict.ReviewReasons[0] | Should -Match 'license-conflict' $ok = Resolve-DFPackageUniverseToolRecord -Members @( (M -Source 'winget' -PackageId 'A.X' -Name 'x' -License 'MIT') (M -Source 'choco' -PackageId 'x' -Name 'x' -License 'MIT License') ) $ok.NeedsReview | Should -BeFalse } It 'does not flag winget MIT against a choco license URL' { $rec = Resolve-DFPackageUniverseToolRecord -Members @( (M -Source 'winget' -PackageId 'A.X' -Name 'x' -License 'MIT') (M -Source 'choco' -PackageId 'x' -Name 'x' -License 'https://opensource.org/licenses/MIT') ) $rec.NeedsReview | Should -BeFalse } It 'falls back to package_id when no member has a name (NOT NULL guard)' { $rec = Resolve-DFPackageUniverseToolRecord -Members @( (M -Source 'scoop' -PackageId 'main/thing') ) $rec.Name | Should -Be 'main/thing' } It 'rejects an empty member collection (a tool always has at least one package)' { { Resolve-DFPackageUniverseToolRecord -Members @() } | Should -Throw } It 'strips the chocolatey packaging suffix from the merged display name when the picked name is choco-only' { $members = @( (M -Source 'choco' -PackageId '7zip.install' -Name '7zip.install') (M -Source 'choco' -PackageId '7zip.portable' -Name '7zip.portable') ) $rec = Resolve-DFPackageUniverseToolRecord -Members $members $rec.Name | Should -Be '7zip' $rec.NameSource | Should -Be 'choco' } It 'leaves a winget-sourced display name untouched (regression: strip is choco-only)' { $members = @( (M -Source 'winget' -PackageId 'sharkdp.bat' -Name 'bat') (M -Source 'scoop' -PackageId 'main/bat' -Name 'bat') ) $rec = Resolve-DFPackageUniverseToolRecord -Members $members $rec.Name | Should -Be 'bat' $rec.NameSource | Should -Be 'winget' } } Context 'Get-DFPackageUniverseToolTags' { It 'unions and normalizes tags across members, deduping' { $members = @( [pscustomobject]@{ source = 'winget'; package_id = 'A.X'; tags = (ConvertTo-Json -Compress @('Cat', 'Viewer')) } [pscustomobject]@{ source = 'choco'; package_id = 'x'; tags = (ConvertTo-Json -Compress @('cat', 'less')) } [pscustomobject]@{ source = 'scoop'; package_id = 'main/x'; tags = $null } ) $tags = Get-DFPackageUniverseToolTags -Members $members $tags | Should -Contain 'cat' $tags | Should -Contain 'viewer' $tags | Should -Contain 'less' @($tags | Where-Object { $_ -eq 'cat' }).Count | Should -Be 1 } It 'does not throw under strict on a DBNull tags cell' { $members = @([pscustomobject]@{ source = 'scoop'; package_id = 'main/x'; tags = [DBNull]::Value }) { Get-DFPackageUniverseToolTags -Members $members } | Should -Not -Throw @(Get-DFPackageUniverseToolTags -Members $members).Count | Should -Be 0 } } Context 'categories' { BeforeAll { $script:rulesFile = Join-Path ([System.IO.Path]::GetTempPath()) ("catrules-" + [guid]::NewGuid().ToString('N') + ".jsonc") @' { // keyword -> category rules (first-pass) "schemaVersion": 1, "rules": [ { "category": "viewer", "keywords": ["cat", "pager", "viewer"] }, { "category": "search", "keywords": ["search", "grep", "find"] } ] } '@ | Set-Content -Path $script:rulesFile -Encoding utf8 } AfterAll { Remove-Item -Path $script:rulesFile -ErrorAction Ignore } It 'loads rules, ignoring comments' { $rules = Import-DFPackageUniverseCategoryRules -Path $script:rulesFile @($rules).Count | Should -Be 2 $rules[0].Category | Should -Be 'viewer' $rules[0].Keywords | Should -Contain 'cat' } It 'returns empty for a missing rules file' { @(Import-DFPackageUniverseCategoryRules -Path 'C:\nope\missing.jsonc').Count | Should -Be 0 } It 'returns empty for an empty/all-comment rules file' { $script:commentOnlyFile = Join-Path ([System.IO.Path]::GetTempPath()) ("catrules-comments-" + [guid]::NewGuid().ToString('N') + ".jsonc") try { @' // this file intentionally has no JSON, only comments /* placeholder for future rules */ '@ | Set-Content -Path $script:commentOnlyFile -Encoding utf8 @(Import-DFPackageUniverseCategoryRules -Path $script:commentOnlyFile).Count | Should -Be 0 } finally { Remove-Item -Path $script:commentOnlyFile -ErrorAction Ignore } } It 'includes a winget Moniker in the token set' { $members = @([pscustomobject]@{ source = 'winget'; package_id = 'A.X'; extra = (ConvertTo-Json -Compress @{ Moniker = 'grep' }) }) $tokens = Get-DFPackageUniverseCategoryTokens -Members $members -Tags @('viewer') $tokens | Should -Contain 'grep' $tokens | Should -Contain 'viewer' } It 'maps tokens to categories via the rules, deduped' { $rules = Import-DFPackageUniverseCategoryRules -Path $script:rulesFile $cats = ConvertTo-DFPackageUniverseCategories -Tokens @('cat', 'grep') -Rules $rules $cats | Should -Contain 'viewer' $cats | Should -Contain 'search' @($cats).Count | Should -Be 2 } It 'yields no category for unmatched tokens' { $rules = Import-DFPackageUniverseCategoryRules -Path $script:rulesFile @(ConvertTo-DFPackageUniverseCategories -Tokens @('zzz') -Rules $rules).Count | Should -Be 0 } } Context 'Get-DFPackageUniverseToolGroups' { BeforeAll { # Pester 5.8.0 does not carry a function defined directly in a # Context body (outside a hook) into Run-phase It scope; wrapping # in BeforeAll is the idiomatic fix. Body/behavior unchanged. # PowerShell ships a built-in alias 'r' -> Invoke-History, and alias # resolution wins over a same-named function -- remove it first so # calls to R() actually hit our helper instead of Invoke-History. Remove-Item -Path Alias:r -Force -ErrorAction Ignore function R($s, $p) { [pscustomobject]@{ source = $s; package_id = $p } } function CM($cid, $s, $p) { [pscustomobject]@{ cluster_id = $cid; source = $s; package_id = $p } } } AfterAll { Set-Alias -Name r -Value Invoke-History -Scope Global -ErrorAction Ignore } It 'groups clustered rows together and makes each unclustered row a singleton' { $rows = @( (R 'scoop' 'main/bat'), (R 'winget' 'sharkdp.bat'), (R 'choco' 'solo') ) $members = @( (CM 1 'scoop' 'main/bat'), (CM 1 'winget' 'sharkdp.bat') ) $groups = Get-DFPackageUniverseToolGroups -Rows $rows -ClusterMembers $members @($groups).Count | Should -Be 2 $groups[0].ClusterId | Should -Be 1 @($groups[0].Members).Count | Should -Be 2 $groups[1].ClusterId | Should -BeNullOrEmpty $groups[1].Members[0].package_id | Should -Be 'solo' } It 'accounts for every row exactly once' { $rows = @( (R 'scoop' 'a'), (R 'winget' 'b'), (R 'choco' 'c') ) $members = @( (CM 7 'scoop' 'a'), (CM 7 'winget' 'b') ) $groups = Get-DFPackageUniverseToolGroups -Rows $rows -ClusterMembers $members $total = (@($groups | ForEach-Object { $_.Members.Count } | Measure-Object -Sum).Sum) $total | Should -Be 3 } It 'orders clustered groups by cluster_id ascending then singletons by source|package_id ascending, regardless of input order' { # Rows are fed with cluster 5 before cluster 2, and singleton # "zzz|last" before singleton "aaa|first" -- the opposite of the # required output order -- so a missing/reversed Sort-Object would # surface as a failing assertion below rather than passing by luck. $rows = @( (R 'winget' 'foo.bar') # cluster 5 member (R 'scoop' 'main/foo') # cluster 5 member (R 'zzz' 'last') # singleton, key "zzz|last" (R 'winget' 'baz.qux') # cluster 2 member (R 'choco' 'baz') # cluster 2 member (R 'aaa' 'first') # singleton, key "aaa|first" ) $members = @( (CM 5 'winget' 'foo.bar') (CM 5 'scoop' 'main/foo') (CM 2 'winget' 'baz.qux') (CM 2 'choco' 'baz') ) $groups = Get-DFPackageUniverseToolGroups -Rows $rows -ClusterMembers $members @($groups).Count | Should -Be 4 $groups[0].ClusterId | Should -Be 2 @($groups[0].Members).Count | Should -Be 2 $groups[1].ClusterId | Should -Be 5 @($groups[1].Members).Count | Should -Be 2 $groups[2].ClusterId | Should -BeNullOrEmpty $groups[2].Members[0].source | Should -Be 'aaa' $groups[2].Members[0].package_id | Should -Be 'first' $groups[3].ClusterId | Should -BeNullOrEmpty $groups[3].Members[0].source | Should -Be 'zzz' $groups[3].Members[0].package_id | Should -Be 'last' } } Context 'Initialize-DFPackageUniverseToolsSchema' { It 'creates the four tables and clears only stage=merge log rows' { $db = Join-Path ([System.IO.Path]::GetTempPath()) ("schematest-" + [guid]::NewGuid().ToString('N') + ".db") try { Invoke-SqliteQuery -DataSource $db -Query 'CREATE TABLE pipeline_log (id INTEGER PRIMARY KEY, stage TEXT, source TEXT, package_id TEXT, level TEXT, message TEXT, logged_at TEXT);' Invoke-SqliteQuery -DataSource $db -Query "INSERT INTO pipeline_log (stage, level, message, logged_at) VALUES ('link', 'review', 'keep me', 'now'), ('merge', 'review', 'drop me', 'now');" Initialize-DFPackageUniverseToolsSchema -DatabasePath $db $tables = @(Invoke-SqliteQuery -DataSource $db -Query "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").name $tables | Should -Contain 'tools' $tables | Should -Contain 'tool_packages' $tables | Should -Contain 'tool_tags' $tables | Should -Contain 'tool_categories' $logs = @(Invoke-SqliteQuery -DataSource $db -Query 'SELECT stage FROM pipeline_log') @($logs).Count | Should -Be 1 $logs[0].stage | Should -Be 'link' } finally { Remove-Item -Path $db -ErrorAction Ignore } } } Context 'Save-DFPackageUniverseTools' { It 'writes the parent, packages, tags, categories, and a review log row' { $db = New-MergeTestDb try { $tool = [pscustomobject]@{ ClusterId = 1 Record = [pscustomobject]@{ Name = 'bat'; NameSource = 'winget'; Description = 'A cat clone'; DescriptionSource = 'winget' Homepage = 'https://github.com/sharkdp/bat'; RepoUrl = 'https://github.com/sharkdp/bat' License = 'MIT'; SourceCount = 2; NeedsReview = $true; ReviewReasons = @('license-conflict: MIT | Apache-2.0') } Members = @( [pscustomobject]@{ source = 'winget'; package_id = 'sharkdp.bat'; name = 'bat'; version = '0.24'; description = 'A cat clone'; homepage = 'h'; license = 'MIT'; publisher = 'sharkdp'; extra = '{"a":1}' } [pscustomobject]@{ source = 'choco'; package_id = 'bat'; name = 'Bat'; version = '0.24.0'; description = 'd'; homepage = 'h'; license = 'https://x/LICENSE'; publisher = 'p'; extra = $null } ) Tags = @('cat', 'viewer') Categories = @('viewer') } $conn = New-SQLiteConnection -DataSource $db try { Save-DFPackageUniverseTools -Connection $conn -Tools @($tool) } finally { $conn.Close() } (Invoke-SqliteQuery -DataSource $db -Query 'SELECT COUNT(*) n FROM tools').n | Should -Be 1 (Invoke-SqliteQuery -DataSource $db -Query 'SELECT name, name_source, needs_review FROM tools').name | Should -Be 'bat' (Invoke-SqliteQuery -DataSource $db -Query 'SELECT needs_review FROM tools').needs_review | Should -Be 1 (Invoke-SqliteQuery -DataSource $db -Query 'SELECT COUNT(*) n FROM tool_packages').n | Should -Be 2 (Invoke-SqliteQuery -DataSource $db -Query "SELECT extra FROM tool_packages WHERE source='winget'").extra | Should -Be '{"a":1}' (Invoke-SqliteQuery -DataSource $db -Query 'SELECT COUNT(*) n FROM tool_tags').n | Should -Be 2 (Invoke-SqliteQuery -DataSource $db -Query 'SELECT COUNT(*) n FROM tool_categories').n | Should -Be 1 (Invoke-SqliteQuery -DataSource $db -Query "SELECT COUNT(*) n FROM pipeline_log WHERE stage='merge' AND level='review'").n | Should -Be 1 (Invoke-SqliteQuery -DataSource $db -Query 'SELECT review_reasons FROM tools').review_reasons | Should -Be '["license-conflict: MIT | Apache-2.0"]' } finally { Remove-Item -Path $db -ErrorAction Ignore } } } Context 'Invoke-DFPackageUniverseToolMerge' { It 'merges a 3-catalog cluster into one tool and keeps a singleton separate (no data lost)' { $db = New-MergeTestDb try { $batExtra = ConvertTo-Json -Compress @{ ProjectSourceUrl = 'https://github.com/sharkdp/bat' } Add-RawRow -Db $db -Source 'scoop' -PackageId 'main/bat' -Name 'bat' -Description 'short' -Tags (ConvertTo-Json -Compress @('cat')) Add-RawRow -Db $db -Source 'winget' -PackageId 'sharkdp.bat' -Name 'bat' -Description 'A cat(1) clone with wings.' -Tags (ConvertTo-Json -Compress @('cat', 'less')) Add-RawRow -Db $db -Source 'choco' -PackageId 'bat' -Name 'Bat' -License 'https://x/LICENSE' -Extra $batExtra Add-RawRow -Db $db -Source 'scoop' -PackageId 'main/solo' -Name 'solo' Add-ClusterMember -Db $db -Cid 1 -Source 'scoop' -PackageId 'main/bat' Add-ClusterMember -Db $db -Cid 1 -Source 'winget' -PackageId 'sharkdp.bat' Add-ClusterMember -Db $db -Cid 1 -Source 'choco' -PackageId 'bat' $conn = New-SQLiteConnection -DataSource $db try { $summary = Invoke-DFPackageUniverseToolMerge -Connection $conn -CategoryRulesPath $null } finally { $conn.Close() } $summary.RowsRead | Should -Be 4 $summary.Tools | Should -Be 2 $summary.Packages | Should -Be 4 # no data lost: every package accounted for $summary.Singletons | Should -Be 1 $bat = Invoke-SqliteQuery -DataSource $db -Query "SELECT * FROM tools WHERE cluster_id = 1" $bat.name | Should -Be 'bat' $bat.name_source | Should -Be 'winget' $bat.description_source | Should -Be 'winget' $bat.repo_url | Should -Be 'https://github.com/sharkdp/bat' $bat.source_count | Should -Be 3 (Invoke-SqliteQuery -DataSource $db -Query "SELECT COUNT(*) n FROM tool_packages WHERE tool_id = $($bat.tool_id)").n | Should -Be 3 $tags = @(Invoke-SqliteQuery -DataSource $db -Query "SELECT tag FROM tool_tags WHERE tool_id = $($bat.tool_id)").tag $tags | Should -Contain 'cat' $tags | Should -Contain 'less' # Core contract: sum of children equals the input row count. (Invoke-SqliteQuery -DataSource $db -Query 'SELECT COUNT(*) n FROM tool_packages').n | Should -Be 4 } finally { Remove-Item -Path $db -ErrorAction Ignore } } It 'is idempotent: a second run reproduces identical row counts' { $db = New-MergeTestDb try { Add-RawRow -Db $db -Source 'winget' -PackageId 'A.X' -Name 'x' -Tags (ConvertTo-Json -Compress @('grep')) Add-RawRow -Db $db -Source 'scoop' -PackageId 'main/y' -Name 'y' foreach ($i in 1..2) { $conn = New-SQLiteConnection -DataSource $db try { Invoke-DFPackageUniverseToolMerge -Connection $conn -CategoryRulesPath $null | Out-Null } finally { $conn.Close() } Initialize-DFPackageUniverseToolsSchema -DatabasePath $db # truncate before re-run, as the orchestrator does } # Final populated run (schema was just truncated): $conn = New-SQLiteConnection -DataSource $db try { $s = Invoke-DFPackageUniverseToolMerge -Connection $conn -CategoryRulesPath $null } finally { $conn.Close() } $s.Tools | Should -Be 2 (Invoke-SqliteQuery -DataSource $db -Query 'SELECT COUNT(*) n FROM tool_packages').n | Should -Be 2 } finally { Remove-Item -Path $db -ErrorAction Ignore } } } Context 'Build-DFPackageUniverseTools.ps1' { It 'runs end-to-end against a prepared DB and returns a summary' { $db = New-MergeTestDb try { Add-RawRow -Db $db -Source 'winget' -PackageId 'A.X' -Name 'x' Add-RawRow -Db $db -Source 'scoop' -PackageId 'main/y' -Name 'y' $summary = & "$PSScriptRoot/../build/Build-DFPackageUniverseTools.ps1" -DatabasePath $db -CategoryRulesPath $null 6>$null $summary.Tools | Should -Be 2 $summary.Packages | Should -Be 2 } finally { Remove-Item -Path $db -ErrorAction Ignore } } It 'throws a helpful error when cluster_members is missing (Phase B not run)' { $db = Join-Path ([System.IO.Path]::GetTempPath()) ("nob-" + [guid]::NewGuid().ToString('N') + ".db") try { Invoke-SqliteQuery -DataSource $db -Query 'CREATE TABLE raw_packages (id INTEGER PRIMARY KEY, source TEXT, package_id TEXT, fetched_at TEXT); CREATE TABLE pipeline_log (id INTEGER PRIMARY KEY, stage TEXT, source TEXT, package_id TEXT, level TEXT, message TEXT, logged_at TEXT);' { & "$PSScriptRoot/../build/Build-DFPackageUniverseTools.ps1" -DatabasePath $db -CategoryRulesPath $null 6>$null } | Should -Throw '*cluster_members*' } finally { Remove-Item -Path $db -ErrorAction Ignore } } } } |