Files
BakNRet/tests/BakNRet.Formats.Tests.ps1
T
Shuery 43fa4e52dd 清单格式:两种写法(软件名 / 手写目录)都支持 :+ 追加与 :- 排除;名录改用对象数组并逐条介绍目录
需求
- 支持两种条目写法:1) 直接写软件名录里的软件名;2) 用户手写目录。
- 两种写法都必须支持追加(:+)与排除(:-)。
- 软件名录要改进:scoop 合并成"一个软件 + 一个目录数组"。
- 运行时要把"分别是哪些目录、每个目录是干什么的、排除/追加的理由"讲清楚。

实现
- 名录(SoftwareCatalog.psd1 / Get-SoftwareCatalog)
  * 一个软件挂多个目录时写成**对象数组**:@{ Path = '...'; Description = '...' };
    也接受纯字符串数组与旧的 @{ Dirs = ... } / @{ Variants = ... }。
  * 目录说明(Description)一路带到运行日志里。
  * "声明了但当前不存在"的目录不再被丢掉:备份跳过,恢复仍然知道它该回到哪个位置。
  * scoop 合并成一个数组条目(%UserProfile%\scoop\persist + %UserProfile%\.config\scoop);
    ScoopApps-persist 保持独立条目 —— 它和前者末级名同为 persist,并进同一个归档会在包里撞名。
- 解析与解析结果(ConvertFrom-BackupListLine / Resolve-BackupEntry)
  * `:+` 以前只对"软件名且能解析出目录"的写法生效,**手写目录的 :+ 会被整段丢掉**;
    现在统一生效,且 :+ 后面写软件名会按名录展开。
  * 行尾 `# 说明` 解析成 Comment,运行时打印。
- 归档与恢复
  * 同一条目里两个同名目录:打包前明确报错(退出码 1),不再静默混成一棵树。
    (7z 命令行没有"入库改名"的能力,归档内顶层名只能是文件系统上的那个名字。)
  * 恢复时每个源只解出**它自己那棵子树**,不会再往别的父目录里复制兄弟目录。
- 可解释性
  * 新增 Write-BackupEntryPlan:打包前打印条目的目录(含来源与介绍)以及排除/追加的出处;
    Restore.ps1 同样打印"哪棵子树还原到哪、会新建还是覆盖"。
- 孤儿归档审计修正:判据只看当前清单,不再把 manifest 的历史记录当成"已知"。
  否则"条目被合并/改名后留下的旧归档"会被历史记录遮住,永远不会报警。

验证
- Pester 84 项、零依赖单元 49 项、端到端 23 项,全部通过。
- 真实机器:合并后的 scoop.7z 233 MB / 29478 项 / 7z t 通过,manifest.roots=[persist|scoop];
  恢复演练 26982/26982 逐字节一致(.ssh、legendary、Aria 同批通过)。
- 迁移提醒:scoop-config.7z 与 scoop-persist.7z 已无清单条目指向,会出现在孤儿审计里;
  确认 scoop.7z 无误后可以自行删除。
2026-09-22 08:18:16 +08:00

417 lines
20 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
清单"两种写法 + 追加/排除"的 Pester 测试:软件名、手写路径,:+/:- 两者都要生效。
.DESCRIPTION
这里覆盖的是清单/名录的**输入格式**契约:
* 写法一:直接写 SoftwareCatalog.psd1 里的软件名;
* 写法二:用户手写目录(含 \ / 或 % 就按路径处理);
* 两种写法都要支持 `:+` 追加与 `:-` 排除;
* 名录里一个软件可以挂**对象数组**(每个目录带 Description),运行时会逐条介绍;
* 同一条目里出现两个同名目录时,必须在归档前就明确报错(Blocking),
而不是把两棵树悄悄混在一起。
跟 BakNRet.Tests.ps1 一样,脚本调用统一走**子进程**:Backup.ps1 / Restore.ps1 结尾会
`exit`,同进程 `&` 调用会把 Pester 宿主一起带走。
.EXAMPLE
pwsh -File .\tests\Run-Pester.ps1
#>
$script:HasSevenZip = [bool](Get-Command 7z -ErrorAction SilentlyContinue)
$script:HasPwsh = [bool](Get-Command pwsh -ErrorAction SilentlyContinue)
BeforeAll {
$script:ProjectRoot = Split-Path -Parent $PSScriptRoot
$script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1'
$script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1'
$script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force
$script:Sandbox = Join-Path $env:TEMP ('baknret-formats-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
function Invoke-BaknretScript {
param(
[Parameter(Mandatory = $true)][string]$Script,
[hashtable]$Parameters = @{}
)
$arguments = @('-NoProfile', '-NonInteractive', '-File', $Script)
foreach ($name in ($Parameters.Keys | Sort-Object)) {
$value = $Parameters[$name]
if ($value -is [bool]) {
if ($value) { $arguments += "-$name" }
continue
}
$arguments += "-$name"
if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value }
}
$lines = & pwsh @arguments 2>&1
return [pscustomobject]@{
ExitCode = $LASTEXITCODE
Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String))
}
}
function Write-ListFile {
param([Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Content)
[System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false))
return $Path
}
}
AfterAll {
if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) {
Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
Describe '软件名录:对象数组写法' {
# ============================================================================
BeforeAll {
$script:FormatRoot = Join-Path $script:Sandbox 'format'
New-Item -ItemType Directory -Path $script:FormatRoot -Force | Out-Null
$script:DirA = Join-Path $script:FormatRoot 'dirA'
$script:DirB = Join-Path $script:FormatRoot 'dirB'
foreach ($directory in $script:DirA, $script:DirB) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory"
}
# 两个不同父目录下各有一个**同名**子目录 —— 用来看"归档内同名"有没有被拦住
$script:CollideRoot = Join-Path $script:FormatRoot 'collide'
foreach ($parent in 'p1', 'p2') {
New-Item -ItemType Directory -Path (Join-Path $script:CollideRoot "$parent\dupdir") -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:CollideRoot "$parent\dupdir\x.txt") $parent
}
$dirAPath = $script:DirA
$dirBPath = $script:DirB
$collideP1 = Join-Path $script:CollideRoot 'p1\dupdir'
$collideP2 = Join-Path $script:CollideRoot 'p2\dupdir'
$script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @"
@{
'pair' = @(
@{ Path = '$dirAPath'; Description = '第一个目录' }
@{ Path = '$dirBPath'; Description = '第二个目录' }
)
'collide' = @(
@{ Path = '$collideP1'; Description = 'p1 里的' }
@{ Path = '$collideP2'; Description = 'p2 里的' }
)
}
"@
$script:MissingDir = Join-Path $script:FormatRoot 'not-here'
$missingPath = $script:MissingDir
$script:PartialCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-partial.psd1') -Content @"
@{
'pair' = @(
@{ Path = '$dirAPath'; Description = '存在' }
@{ Path = '$missingPath'; Description = '不存在' }
)
}
"@
}
It '一个软件多个目录:顺序与说明都被保留' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3
$catalog['pair'].Kind | Should -Be 'Multi'
@($catalog['pair'].Items).Count | Should -Be 2
$catalog['pair'].Items[0].Resolved | Should -Be $script:DirA
$catalog['pair'].Items[0].Description | Should -Be '第一个目录'
$catalog['pair'].Items[1].Description | Should -Be '第二个目录'
}
It '解析成多个源,每个源带着自己的说明' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
$resolved.Sources[0].SourcePath | Should -Be $script:DirA
$resolved.Sources[0].Description | Should -Be '第一个目录'
$resolved.Sources[1].Description | Should -Be '第二个目录'
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Be @('catalog', 'catalog')
}
It '数组里"当前不存在"的目录仍然产出源(恢复要靠它还原回原位)' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:PartialCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
@($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:MissingDir
$resolved.Error | Should -Not -BeNullOrEmpty # 有提示
$resolved.Blocking | Should -BeNullOrEmpty # 但不算致命
}
It '纯字符串数组写法继续可用' {
$plain = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-plain.psd1') -Content "@{ 'pair2' = @('$script:DirA', '$script:DirB') }"
$catalog = Get-SoftwareCatalog -Path $plain -MaxDepth 3
$catalog['pair2'].Kind | Should -Be 'Multi'
@($catalog['pair2'].Dirs).Count | Should -Be 2
}
It '旧的 @{ Dirs = @(...) } 写法继续可用' {
$legacy = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-legacy.psd1') -Content "@{ 'pair3' = @{ Dirs = @('$script:DirA', '$script:DirB') } }"
$catalog = Get-SoftwareCatalog -Path $legacy -MaxDepth 3
$catalog['pair3'].Kind | Should -Be 'Multi'
@($catalog['pair3'].Dirs).Count | Should -Be 2
}
It '数组形式的名录条目在清单里仍然按软件名命名归档' {
$entry = ConvertFrom-BackupListLine -Line 'pair'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair'
}
}
# ============================================================================
Describe '两种写法都要支持 :+ 追加与 :- 排除' {
# ============================================================================
BeforeAll {
$script:FormatRoot = Join-Path $script:Sandbox 'format'
$script:DirA = Join-Path $script:FormatRoot 'dirA'
$script:DirB = Join-Path $script:FormatRoot 'dirB'
$script:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1'
$script:CollideRoot = Join-Path $script:FormatRoot 'collide'
}
It '软件名写法::+ 追加一个目录' {
$entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 3
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path'
}
It '软件名写法::+ 追加"另一个软件名"会按名录展开成它的全部目录' {
$entry = ConvertFrom-BackupListLine -Line 'pair :+ pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 4
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-catalog'
}
# ---- 回归:手写路径的 :+ 以前会被整段丢掉 ----
It '[回归] 手写路径写法::+ 追加一个目录' {
$entry = ConvertFrom-BackupListLine -Line "$script:DirA :+ $script:DirB"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
@($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:DirB
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path'
}
It '手写路径写法::- 排除与 :+ 追加并存' {
$entry = ConvertFrom-BackupListLine -Line "$script:DirA :+ $script:DirB :- skip.log,!*Cache"
$entry.ExcludePatterns.Count | Should -Be 2
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
$resolved.Blocking | Should -BeNullOrEmpty
}
It '软件名与手写路径混在一行也认得(主目录是软件名,追加是路径)' {
$entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot :- logs\"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
$resolved.IsName | Should -BeTrue
@($resolved.Sources).Count | Should -Be 3
$entry.ExcludePatterns | Should -Be @('logs\')
}
It '同一条目里出现两个同名目录 -> Blocking(明确报错,不静默混成一棵树)' {
$entry = ConvertFrom-BackupListLine -Line 'collide'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2
$resolved.Blocking | Should -Match '顶层同名'
}
}
# ============================================================================
Describe '清单行尾的 `# 说明`' {
# ============================================================================
It '会作为这条目的说明解析出来' {
$entry = ConvertFrom-BackupListLine -Line 'Edge :- !*Cache # 缓存可再生'
$entry.Path | Should -Be 'Edge'
$entry.ExcludePatterns | Should -Be @('!*Cache')
$entry.Comment | Should -Be '缓存可再生'
}
It '路径里紧贴的 # 不会被当成注释' {
$entry = ConvertFrom-BackupListLine -Line 'C:\a#b\c'
$entry.Path | Should -Be 'C:\a#b\c'
$entry.Comment | Should -BeNullOrEmpty
}
It '没有说明时 Comment 为空' {
ConvertFrom-BackupListLine -Line 'legendary' | Select-Object -ExpandProperty Comment | Should -BeNullOrEmpty
}
}
# ============================================================================
Describe '集成:手写路径 + :+ 追加 的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:AppendRoot = Join-Path $script:Sandbox 'append-e2e'
# 刻意放在**两个不同的父目录**下:只有这样才能验证
# "恢复时不会把兄弟目录也复制过去"
$script:AppendA = Join-Path $script:AppendRoot 'srcA\dirA'
$script:AppendB = Join-Path $script:AppendRoot 'srcB\dirB'
foreach ($directory in $script:AppendA, $script:AppendB) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
}
Set-Content -LiteralPath (Join-Path $script:AppendA 'a.txt') 'A'
Set-Content -LiteralPath (Join-Path $script:AppendB 'b.txt') 'B'
Set-Content -LiteralPath (Join-Path $script:AppendA 'skip.log') 'S'
$script:AppendList = Write-ListFile -Path (Join-Path $script:AppendRoot 'list.txt') `
-Content "$script:AppendA :+ $script:AppendB :- skip.log`n"
$script:AppendBackupDir = Join-Path $script:AppendRoot 'Backups'
$script:AppendBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:AppendList
BackupDir = $script:AppendBackupDir
Force = $true
QuietTool = $true
}
}
It '备份成功,manifest.roots 记录两棵子树' {
$script:AppendBackupRun.ExitCode | Should -Be 0
$archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0]
$record = (Read-BaknretManifest -Path (Join-Path $script:AppendBackupDir 'manifest.json')).items[$archive.BaseName]
$record.roots | Should -Contain 'dirA'
$record.roots | Should -Contain 'dirB'
}
It '归档里两棵树都在,且 :- 排除生效' {
$verify = Join-Path $script:AppendRoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
$archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0]
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive.FullName)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify 'dirA\a.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'dirB\b.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'dirA\skip.log') | Should -BeFalse
}
It '删源后恢复:每个目录只落回自己的父目录,兄弟目录不会被复制过去' {
Remove-Item -LiteralPath $script:AppendA -Recurse -Force
Remove-Item -LiteralPath $script:AppendB -Recurse -Force
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:AppendList
BackupDir = $script:AppendBackupDir
Force = $true
}
$run.ExitCode | Should -Be 0
Test-Path -LiteralPath (Join-Path $script:AppendA 'a.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $script:AppendB 'b.txt') | Should -BeTrue
# 关键:srcA 下不该冒出 dirB,srcB 下也不该冒出 dirA
Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcA\dirB') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcB\dirA') | Should -BeFalse
}
}
# ============================================================================
Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:RejectRoot = Join-Path $script:Sandbox 'collide-e2e'
$script:RejectCatalog = Join-Path $script:RejectRoot 'cat.psd1'
$p1 = Join-Path $script:RejectRoot 'p1\dupdir'
$p2 = Join-Path $script:RejectRoot 'p2\dupdir'
foreach ($directory in $p1, $p2) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -LiteralPath (Join-Path $directory 'x.txt') 'x'
}
Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @('$p1', '$p2')`n}`n" | Out-Null
$script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide`n"
$script:RejectConfig = Write-ListFile -Path (Join-Path $script:RejectRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:RejectCatalog' }`n"
$script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups'
$script:RejectRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:RejectList
BackupDir = $script:RejectBackupDir
ConfigPath = $script:RejectConfig
Force = $true
QuietTool = $true
}
}
It '退出码 1,且给出"顶层同名"的原因,不生成归档' {
$script:RejectRun.ExitCode | Should -Be 1
$script:RejectRun.Output | Should -Match '顶层同名'
@(Get-ChildItem -LiteralPath $script:RejectBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue).Count | Should -Be 0
}
It 'manifest 里记下这次是 failed,并带上原因' {
$manifest = Read-BaknretManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json')
$record = $manifest.items['collide']
$record.action | Should -Be 'failed'
$record.reason | Should -Match '顶层同名'
}
}
# ============================================================================
Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
# 这是"合并/改名条目"的真实后果:归档还在,manifest 里也还留着历史记录,
# 但清单里已经没有任何条目指向它 —— Restore.ps1 按条目名找归档,所以它恢复不到。
# 审计如果拿 manifest 当"已知",这种归档会被历史记录永远遮住,正是最该报的情况。
BeforeAll {
$script:OrphanRoot = Join-Path $script:Sandbox 'orphan-e2e'
$script:OrphanSource = Join-Path $script:OrphanRoot 'src\My App'
New-Item -ItemType Directory -Path $script:OrphanSource -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello'
$script:OrphanCatalog = Write-ListFile -Path (Join-Path $script:OrphanRoot 'cat.psd1') `
-Content "@{`n 'my-app' = '$script:OrphanSource'`n}`n"
$script:OrphanConfig = Write-ListFile -Path (Join-Path $script:OrphanRoot 'config.psd1') `
-Content "@{ SoftwareCatalog = '$script:OrphanCatalog' }`n"
$script:OrphanList = Join-Path $script:OrphanRoot 'list.txt'
$script:OrphanBackupDir = Join-Path $script:OrphanRoot 'Backups'
# 第一次:清单里有 my-app
Write-ListFile -Path $script:OrphanList -Content "my-app`n" | Out-Null
$null = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:OrphanList
BackupDir = $script:OrphanBackupDir
ConfigPath = $script:OrphanConfig
Force = $true
QuietTool = $true
}
# 第二次:把条目从清单里拿掉,归档留在磁盘上
Write-ListFile -Path $script:OrphanList -Content "# 清单里没有条目了`n" | Out-Null
$script:OrphanRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:OrphanList
BackupDir = $script:OrphanBackupDir
ConfigPath = $script:OrphanConfig
QuietTool = $true
}
}
It '归档确实还在磁盘上,manifest 里也还留着历史记录' {
Test-Path -LiteralPath (Join-Path $script:OrphanBackupDir 'my-app.7z') | Should -BeTrue
$manifest = Read-BaknretManifest -Path (Join-Path $script:OrphanBackupDir 'manifest.json')
$manifest.items.Contains('my-app') | Should -BeTrue
}
It '第二次运行把 my-app.7z 点名成孤儿,并说明 manifest 里还有历史记录' {
$script:OrphanRun.ExitCode | Should -Be 0
$script:OrphanRun.Output | Should -Match '孤儿归档'
$script:OrphanRun.Output | Should -Match 'my-app\.7z'
$script:OrphanRun.Output | Should -Match 'manifest 里还留着它的历史记录'
}
}