<# .SYNOPSIS 清单与名录的"格式契约"Pester 测试:软件名 / 手写路径两种写法, Slot 形状的 SoftwareCatalog.psd1,以及 `::` / `:-` / `:+` / `:encrypt` / `@ Key='Value'`。 .DESCRIPTION 这里覆盖的是清单/名录的**输入格式与归档布局**契约(重构后的新契约): * 写法一:直接写 SoftwareCatalog.psd1 里的软件名; * 写法二:用户手写目录(含 \ / 或 % 就按路径处理); * 软件名条目 -> 一个归档,归档内是 `\<内容>`;Path 是文件时归档内是名为 `` 的文件(没有扩展名); * 手写路径条目 -> 历史布局 `<末级名>\...`,现有清单不需要改写; * `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`; * `:+` / `@ Include=` 是 `<归档内相对路径>:<宿主机绝对路径>`; * 同一条目里两个归档项抢同一个包内位置时,必须在归档前就明确报错(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 # 见 BakNRet.Tests.ps1 里的说明:本机沙箱禁止 PowerShell 为捕获原生子进程输出建管道, # 所以走"临时 .cmd + 文件重定向 + Invoke-ExternalCommand(继承 stdio)"这条路。 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 } } $outFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-out-" + [guid]::NewGuid().ToString('N') + '.txt') $cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-cmd-" + [guid]::NewGuid().ToString('N') + '.cmd') $argString = (@($arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' ') $batch = "@echo off`r`n" + 'pwsh ' + $argString + ' > "' + $outFile + '" 2>&1' + "`r`nexit /b %ERRORLEVEL%`r`n" [System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false)) $exitCode = $null $lines = @() try { $exitCode = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdFile) $lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue) } finally { Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $cmdFile -Force -ErrorAction SilentlyContinue } return [pscustomobject]@{ ExitCode = $exitCode 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($true)) return $Path } } AfterAll { if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) { Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue } } # ============================================================================ Describe '软件名录:Slot 形状(新契约)' { # ============================================================================ 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 -Encoding UTF8 -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory" } New-Item -ItemType Directory -Path (Join-Path $script:DirA 'Cache') -Force | Out-Null Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:DirA 'Cache\c.bin') 'cache' $script:CfgFile = Join-Path $script:FormatRoot 'settings.json' Set-Content -Encoding UTF8 -LiteralPath $script:CfgFile '{"a":1}' # 两个不同父目录下各有一个**同名**子目录 —— 供"归档内同名"冲突测试用 $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 -Encoding UTF8 -LiteralPath (Join-Path $script:CollideRoot "$parent\dupdir\x.txt") $parent } $script:MissingDir = Join-Path $script:FormatRoot 'not-here' $script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @" @{ 'pair' = @{ A = @{ Path = '$script:DirA'; Description = '第一个 Slot' } B = @{ Path = '$script:DirB'; Description = '第二个 Slot' } } 'solo' = @{ Only = @{ Path = '$script:DirA' } } 'partial' = @{ Ok = @{ Path = '$script:DirA' }; Gone = @{ Path = '$script:MissingDir' } } 'slotex' = @{ Data = @{ Path = '$script:DirA'; Exclude = '!*Cache,logs\' } } 'fileapp' = @{ Cfg = @{ Path = '$script:CfgFile'; Encrypt = `$true } } 'conflict' = @{ Data = @{ Path = '$script:DirA' } } 'legacyarr' = @('$script:DirA', '$script:DirB') 'legacydirs' = @{ Dirs = @('$script:DirA', '$script:DirB') } 'legacystr' = '$script:DirA' } "@ } It '一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留' { $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog['pair'].Kind | Should -Be 'Multi' @($catalog['pair'].Slots).Count | Should -Be 2 (@($catalog['pair'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'A,B' $catalog['pair'].Slots[0].Description | Should -Be '第一个 Slot' $catalog['pair'].Slots[1].Description | Should -Be '第二个 Slot' $catalog['pair'].Slots[0].Resolved | Should -Be $script:DirA $catalog['pair'].Slots[1].Resolved | Should -Be $script:DirB } It '每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog)' { $entry = ConvertFrom-BackupListLine -Line 'pair' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 @($resolved.Items).Count | Should -Be 2 (@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,B' (@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot' (@($resolved.Items | ForEach-Object { $_.Origin }) -join ',') | Should -Be 'catalog,catalog' $resolved.Items[0].RealPath | Should -Be $script:DirA $resolved.Items[0].Description | Should -Be '第一个 Slot' $resolved.Items[1].Description | Should -Be '第二个 Slot' } It 'Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根)' { $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache (@($catalog['slotex'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\' $entry = ConvertFrom-BackupListLine -Line 'slotex' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 (@($resolved.Items[0].Exclude) -join '|') | Should -Be '!*Cache|logs\' $resolved.HasExcludeOverride | Should -BeFalse } It '数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位)' { $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog['partial'].Kind | Should -Be 'Partial' @($catalog['partial'].Missing) | Should -Contain $script:MissingDir $entry = ConvertFrom-BackupListLine -Line 'partial' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 @($resolved.Items).Count | Should -Be 2 @($resolved.Items | ForEach-Object { $_.RealPath }) | Should -Contain $script:MissingDir $resolved.Blocking | Should -BeNullOrEmpty # 源不存在不是致命错误 } It '文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件)' { $entry = ConvertFrom-BackupListLine -Line 'fileapp' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 @($resolved.Items).Count | Should -Be 1 $resolved.Items[0].IsFile | Should -BeTrue $resolved.Items[0].ArchivePath | Should -Be 'Cfg' $resolved.Items[0].RealPath | Should -Be $script:CfgFile $resolved.Encrypt | Should -BeTrue } It '旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过)' { $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog.ContainsKey('legacystr') | Should -BeFalse $catalog.ContainsKey('legacyarr') | Should -BeFalse } It '旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因' { $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog.ContainsKey('legacydirs') | Should -BeTrue $catalog['legacydirs'].Kind | Should -Be 'Invalid' $catalog['legacydirs'].Error | Should -Not -BeNullOrEmpty @($catalog['legacydirs'].Slots).Count | Should -Be 0 } It '多 Slot 的名录条目在清单里仍然按软件名命名归档' { $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:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1' $script:DirA = Join-Path $script:FormatRoot 'dirA' $script:DirB = Join-Path $script:FormatRoot 'dirB' $script:CollideRoot = Join-Path $script:FormatRoot 'collide' } It ':: 覆盖 Path:单 Slot 条目直接生效' { $entry = ConvertFrom-BackupListLine -Line "solo :: $script:DirB" $entry.Overrides.ContainsKey('Path') | Should -BeTrue $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 @($resolved.Items).Count | Should -Be 1 $resolved.Items[0].ArchivePath | Should -Be 'Only' $resolved.Items[0].RealPath | Should -Be $script:DirB $resolved.Blocking | Should -BeNullOrEmpty } It ':: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜)' { $entry = ConvertFrom-BackupListLine -Line "pair :: $script:DirB" $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 @($resolved.Items).Count | Should -Be 0 $resolved.Blocking | Should -Match '不能用一个' } It ':- 排除与 :+ 包含并存,顺序任意' { $entry = ConvertFrom-BackupListLine -Line "pair :- logs\,!*Cache :+ Mods:$script:DirB" (@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache' (@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB" $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved.HasExcludeOverride | Should -BeTrue $resolved.HasIncludeOverride | Should -BeTrue @($resolved.Items | ForEach-Object { $_.ArchivePath }) | Should -Contain 'Mods' $resolved.Blocking | Should -BeNullOrEmpty } It '@ Exclude / @ Include / @ Path 与记号写法等价' { $marks = ConvertFrom-BackupListLine -Line "pair :- logs\ :+ Mods:$script:DirB" $ats = ConvertFrom-BackupListLine -Line "pair @ Exclude='logs\' @ Include='Mods:$script:DirB'" (@($marks.ExcludePatterns) -join '|') | Should -Be (@($ats.ExcludePatterns) -join '|') (@($marks.Includes) -join '|') | Should -Be (@($ats.Includes) -join '|') } It ':encrypt / :!encrypt 覆盖名录里的加密默认值' { $base = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp') -CatalogPath $script:FormatCatalog -MaxDepth 3 $base.Encrypt | Should -BeTrue # 名录里 Cfg Slot 标了 Encrypt $off = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp :!encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3 $off.Encrypt | Should -BeFalse $on = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'pair :encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3 $on.Encrypt | Should -BeTrue } It '遗留写法 @encrypt / @pathname / @root= 仍可解析' { (ConvertFrom-BackupListLine -Line 'pair @encrypt').Overrides['Encrypt'] | Should -BeTrue (ConvertFrom-BackupListLine -Line 'pair @pathname').Flags | Should -Contain 'pathname' (ConvertFrom-BackupListLine -Line 'pair @root=Bar').Flags | Should -Contain 'root=Bar' # @pathname 对软件名条目也会改用真实路径命名 $entry = ConvertFrom-BackupListLine -Line 'pair @pathname' $expected = Get-BackupBaseName -RawPath $script:DirA (Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be $expected } It '同一行里重复写同类记号会累积(不静默丢掉前一条规则)' { # `:+ a :+ b` 与 `:+ a,b` 等价:两条规则都生效。 # 静默丢掉前一条排除/追加规则是这工具最不该犯的错,所以这里是"累加"语义。 $entry = ConvertFrom-BackupListLine -Line "pair :+ Mods:$script:DirB,More:$script:DirA" (@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB|More:$script:DirA" $repeated = ConvertFrom-BackupListLine -Line "pair :+ Mods:$script:DirB :+ More:$script:DirA" (@($repeated.Includes) -join '|') | Should -Be "Mods:$script:DirB|More:$script:DirA" $excludes = ConvertFrom-BackupListLine -Line 'pair :- logs\ :- !*Cache :- temp\' (@($excludes.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache|temp\' # @ Exclude= 与 :- 也是累加关系 $mixed = ConvertFrom-BackupListLine -Line "pair @ Exclude='a' :- b" (@($mixed.ExcludePatterns) -join '|') | Should -Be 'a|b' } It '行尾说明与缺少目标的行' { $entry = ConvertFrom-BackupListLine -Line 'pair :- logs\ # 日志可再生' $entry.Comment | Should -Be '日志可再生' (@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\' ConvertFrom-BackupListLine -Line ':- logs\' | Should -BeNullOrEmpty } } # ============================================================================ Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { # ============================================================================ BeforeAll { $script:SlotRoot = Join-Path $script:Sandbox 'slots-e2e' $script:SlotAppOne = Join-Path $script:SlotRoot 'apps\AppOne' $script:SlotAppTwo = Join-Path $script:SlotRoot 'apps\AppTwo' $script:SlotCfgDir = Join-Path $script:SlotRoot 'apps\AppCfg' $script:SlotInclude = Join-Path $script:SlotRoot 'psmodules' New-Item -ItemType Directory -Path (Join-Path $script:SlotAppOne 'Cache') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $script:SlotAppOne 'sub') -Force | Out-Null New-Item -ItemType Directory -Path $script:SlotAppTwo -Force | Out-Null New-Item -ItemType Directory -Path $script:SlotCfgDir -Force | Out-Null New-Item -ItemType Directory -Path $script:SlotInclude -Force | Out-Null Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') 'one' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') 'deep' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') 'cache' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') 'two' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotCfgDir 'settings.json') '{"a":1}' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') 'mod' $appOne = $script:SlotAppOne $appTwo = $script:SlotAppTwo $cfgFile = Join-Path $script:SlotCfgDir 'settings.json' $includeDir = $script:SlotInclude $script:SlotCatalog = Write-ListFile -Path (Join-Path $script:SlotRoot 'cat.psd1') -Content @" @{ 'appkit' = @{ Cfg = @{ Path = '$cfgFile' } Data = @{ Path = '$appOne'; Exclude = '!*Cache' } Extra = @{ Path = '$appTwo'; Include = 'Modules:$includeDir' } } } "@ $script:SlotConfig = Write-ListFile -Path (Join-Path $script:SlotRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:SlotCatalog' }" $script:SlotList = Write-ListFile -Path (Join-Path $script:SlotRoot 'list.txt') -Content "appkit`n" $script:SlotBackupDir = Join-Path $script:SlotRoot 'Backups' $script:SlotBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ BackupListPath = $script:SlotList BackupDir = $script:SlotBackupDir ConfigPath = $script:SlotConfig Force = $true QuietTool = $true } $script:SlotManifest = Read-BaknretManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json') $script:SlotArchive = @(Get-ChildItem -LiteralPath $script:SlotBackupDir -File -Filter *.7z)[0] } It '备份退出码 0,归档名就是软件名' { $script:SlotBackupRun.ExitCode | Should -Be 0 $script:SlotArchive.BaseName | Should -Be 'appkit' } It '归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项)' { $top = @(Get-ArchiveTopLevelNames -ArchivePath $script:SlotArchive.FullName -SevenZip $script:SevenZip) (@($top | Sort-Object) -join ',') | Should -Be 'Cfg,Data,Extra,Modules' } It 'manifest.layouts 记下每个归档项是目录还是文件' { $record = $script:SlotManifest.items['appkit'] $record.action | Should -Be 'backed-up' $record.roots | Should -Contain 'Data' (@($record.layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join ',') | Should -Be 'Cfg:file,Data:dir,Extra:dir,Modules:dir' } It '归档内容:\<内容> 布局,文件 Slot 是名为 Slot 的文件,Slot 排除生效' { $verify = Join-Path $script:SlotRoot 'verify' New-Item -ItemType Directory -Path $verify -Force | Out-Null (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $script:SlotArchive.FullName)) | Should -Be 0 Test-Path -LiteralPath (Join-Path $verify 'Data\one.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'Data\sub\deep.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'Data\Cache\c.bin') | Should -BeFalse Test-Path -LiteralPath (Join-Path $verify 'Extra\two.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'Modules\mod.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'Cfg') -PathType Leaf | Should -BeTrue (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $verify 'Cfg') -Raw).Trim() | Should -Be '{"a":1}' } It '真实恢复:目录 Slot、文件 Slot 与 Include 都落回各自的原位' { Remove-Item -LiteralPath (Join-Path $script:SlotRoot 'apps') -Recurse -Force Remove-Item -LiteralPath $script:SlotInclude -Recurse -Force $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ BackupListPath = $script:SlotList BackupDir = $script:SlotBackupDir ConfigPath = $script:SlotConfig Force = $true } $run.ExitCode | Should -Be 0 $run.Output | Should -Match '恢复成功: appkit' (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') -Raw).Trim() | Should -Be 'one' (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') -Raw).Trim() | Should -Be 'deep' (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') -Raw).Trim() | Should -Be 'two' (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') -Raw).Trim() | Should -Be 'mod' # 被 Slot 排除的缓存没有进过归档,自然也不会被恢复出来 Test-Path -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') | Should -BeFalse } It '文件 Slot 在目标不存在时靠 manifest.layouts 恢复成文件(而不是目录)' { # 目标文件被删掉了,名录解析只能得到 IsFile=false;判据要靠 manifest 的 layouts。 $restored = Join-Path $script:SlotCfgDir 'settings.json' (Test-Path -LiteralPath $restored -PathType Leaf) | Should -BeTrue (Get-Content -Encoding UTF8 -LiteralPath $restored -Raw).Trim() | Should -Be '{"a":1}' } It '恢复之后 manifest 记下 lastRestoreAt' { $manifest = Read-BaknretManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json') $manifest.items['appkit'].lastRestoreAt | Should -Not -BeNullOrEmpty } } # ============================================================================ Describe '集成:旧布局归档的回退恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { # ============================================================================ # Slot 布局是重构后才有的,Backups\ 里还躺着按旧布局(包内直接是 <源目录名>\...) # 生成的归档。恢复这类归档时必须回退到"把 <目标末级名> 解到目标父目录"的旧语义。 BeforeAll { $script:LegacyRoot = Join-Path $script:Sandbox 'legacy-layout' $script:LegacyHolder = Join-Path $script:LegacyRoot 'holder' $script:LegacyDestParent = Join-Path $script:LegacyRoot 'dest' $script:LegacyLeaf = 'My Code Space' New-Item -ItemType Directory -Path (Join-Path $script:LegacyHolder $script:LegacyLeaf) -Force | Out-Null New-Item -ItemType Directory -Path $script:LegacyDestParent -Force | Out-Null Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:LegacyHolder "$script:LegacyLeaf\legacy.txt") 'old-layout' $destPath = Join-Path $script:LegacyDestParent $script:LegacyLeaf $script:LegacyCatalog = Write-ListFile -Path (Join-Path $script:LegacyRoot 'cat.psd1') -Content @" @{ 'oldapp' = @{ SlotX = @{ Path = '$destPath' } } } "@ $script:LegacyConfig = Write-ListFile -Path (Join-Path $script:LegacyRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:LegacyCatalog' }" $script:LegacyList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'list.txt') -Content "oldapp`n" $script:LegacyBackupDir = Join-Path $script:LegacyRoot 'Backups' New-Item -ItemType Directory -Path $script:LegacyBackupDir -Force | Out-Null # 手工造一个旧布局归档:顶层就是源目录名,不是 Slot 名。 $script:LegacyArchive = Join-Path $script:LegacyBackupDir 'oldapp.7z' (Invoke-ExternalCommand -FilePath $script:SevenZip ` -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $script:LegacyArchive, $script:LegacyLeaf) ` -WorkingDirectory $script:LegacyHolder) | Should -Be 0 } It '归档确实是旧布局:顶层是源目录名而不是 Slot 名' { $top = @(Get-ArchiveTopLevelNames -ArchivePath $script:LegacyArchive -SevenZip $script:SevenZip) (@($top | Sort-Object) -join ',') | Should -Be $script:LegacyLeaf } It '归档里缺 Slot 层(真实旧归档)时按旧布局回退,把内容还原回原位' { # 归档里没有 SlotX,但有旧布局的 <目标末级名>;恢复端必须先问归档"这条路径在不在", # 不能靠 7z 的退出码猜(7z 对不存在的条目同样返回 0)。 Remove-Item -LiteralPath (Join-Path $script:LegacyDestParent $script:LegacyLeaf) -Recurse -Force -ErrorAction SilentlyContinue $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ BackupListPath = $script:LegacyList BackupDir = $script:LegacyBackupDir ConfigPath = $script:LegacyConfig Force = $true } $run.ExitCode | Should -Be 0 $run.Output | Should -Match '按旧布局回退' Test-Path -LiteralPath (Join-Path $script:LegacyDestParent "$script:LegacyLeaf\legacy.txt") | Should -BeTrue (Get-Content -Encoding UTF8 -LiteralPath (Join-Path $script:LegacyDestParent "$script:LegacyLeaf\legacy.txt") -Raw).Trim() | Should -Be 'old-layout' } It '归档里既没有 Slot 层、也没有旧布局名字时明确失败(不再"成功地什么都没恢复")' { # 用 :: 覆盖把目标换成一个归档里根本不存在的末级名:两条路都走不通, # 必须报失败并说明原因,而不是打一句"恢复成功"却一个文件都没落地。 $missingList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'missing-list.txt') ` -Content "oldapp :: $script:LegacyRoot\dest2\Nothing Here`n" $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ BackupListPath = $missingList BackupDir = $script:LegacyBackupDir ConfigPath = $script:LegacyConfig Force = $true } $run.ExitCode | Should -Be 1 $run.Output | Should -Match '既没有' Test-Path -LiteralPath (Join-Path $script:LegacyRoot 'dest2\Nothing Here') | 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 -Encoding UTF8 -LiteralPath (Join-Path $directory 'x.txt') 'x' } # Slot 叫 Data,Include 也要放进包内的 Data -> 同一个位置,必须报错 Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @{ Data = @{ Path = '$p1' } }`n}`n" | Out-Null $script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide :+ Data:$p2`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 -Encoding UTF8 -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello' $script:OrphanCatalog = Write-ListFile -Path (Join-Path $script:OrphanRoot 'cat.psd1') ` -Content "@{ 'my-app' = @{ Default = @{ Path = '$script:OrphanSource' } } }`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 里还留着它的历史记录' } } # ============================================================================ Describe 'manifest 一致性:archive 字段只在文件真的存在时才写' { # ============================================================================ BeforeAll { $script:SyncRoot = Join-Path $script:Sandbox 'sync' New-Item -ItemType Directory -Path $script:SyncRoot -Force | Out-Null Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SyncRoot 'real.7z') 'x' $script:SyncManifestPath = Join-Path $script:SyncRoot 'manifest.json' $manifest = Read-BaknretManifest -Path $script:SyncManifestPath $manifest.items['real'] = [pscustomobject]@{ archive = 'real.7z'; action = 'backed-up'; source = 'real' } $manifest.items['gone'] = [pscustomobject]@{ archive = 'gone.7z'; action = 'missing-source'; source = 'gone' } $manifest.items['none'] = [pscustomobject]@{ archive = $null; action = 'missing-source'; source = 'none' } Write-BaknretManifest -Path $script:SyncManifestPath -Manifest $manifest | Out-Null } It '存在的归档保留 archive,不存在的被清空' { $manifest = Read-BaknretManifest -Path $script:SyncManifestPath $cleared = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot $cleared.Count | Should -Be 1 $cleared[0] | Should -Be 'gone' $manifest.items['real'].archive | Should -Be 'real.7z' $manifest.items['gone'].archive | Should -BeNullOrEmpty $manifest.items['none'].archive | Should -BeNullOrEmpty } It '清空 archive 时保留条目本身的历史(source / action 不动)' { $manifest = Read-BaknretManifest -Path $script:SyncManifestPath $null = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot $manifest.items['gone'].source | Should -Be 'gone' $manifest.items['gone'].action | Should -Be 'missing-source' } It '人工删掉归档之后再同步一次,记录会被纠正过来' { Remove-Item -LiteralPath (Join-Path $script:SyncRoot 'real.7z') -Force $manifest = Read-BaknretManifest -Path $script:SyncManifestPath $cleared = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot $cleared | Should -Contain 'real' $manifest.items['real'].archive | Should -BeNullOrEmpty } }