Files
BakNRet/tests/BakNRet.Formats.Tests.ps1
T
Shuery 2d26f78d15 fix: 源文件改存 UTF-8 with BOM,让 Windows PowerShell 5.1 真正可用
改造前:全仓 6/6 个源文件在 5.1 上解析失败(README 却承诺支持 5.1)。原因是文件是无 BOM 的
UTF-8,而 5.1 没有 BOM 就按 ANSI 代码页解码源码,中文变乱码、全角问号吃掉引号,整块语法塌掉。
现在 28/28 个文件在 5.1 与 7 上都解析零错误,E2E 36 项在 5.1 上全绿。

顺带修掉一个被 5.1 掩盖的缺陷:带 [CmdletBinding()] 的脚本在 5.1 上,param() 默认值里
拿不到 $PSScriptRoot(实测为空串,7 上正常)。于是 Backup.ps1 / Restore.ps1 在 5.1 上不传
路径参数就报错退出 —— 而计划任务恰恰不传。E2E 之所以看不见,是因为它总是显式传路径。
8 处默认值全部移到 param() 之后的解析段,沿用本仓库对 -BackupDir 一直在用的写法。

新增三条可重放的约定,让编码不再是一次性动作:
  .gitattributes 接管行尾(本机 core.autocrlf=true,会把工作区改成 CRLF 制造伪 diff)
  .editorconfig 用 charset = utf-8-bom 锁住 BOM
  tools\Set-SourceEncoding.ps1 是规范化脚本,tools\Invoke-* 之外的任何改动之后都能重放
  test.ps1 是唯一验收入口:Encode + Parse + Unit + Smoke + E2E,在 7 与 5.1 上各跑一遍

test.ps1 的 Encode 层直接检查"必须有 BOM"这条规则。加它的原因很实际:实测本仓库用的
编辑工具在保存时会悄悄去掉 BOM,而丢了 BOM 的文件只在 5.1 上出错、在 7 上完全正常,
没有这条检查就会一直漏过去。

已知未修(下一步处理):5.1 上 Unit 有 6 项、Smoke 有 1 项失败,全部源于测试夹具写
临时文件时没指定编码(5.1 的 Set-Content 默认 ANSI),与产品代码无关。
2026-09-26 22:05:42 +08:00

646 lines
34 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 测试:软件名 / 手写路径两种写法,
Slot 形状的 SoftwareCatalog.psd1,以及 `::` / `:-` / `:+` / `:encrypt` / `@ Key='Value'`。
.DESCRIPTION
这里覆盖的是清单/名录的**输入格式与归档布局**契约(重构后的新契约):
* 写法一:直接写 SoftwareCatalog.psd1 里的软件名;
* 写法二:用户手写目录(含 \ / 或 % 就按路径处理);
* 软件名条目 -> 一个归档,归档内是 `<Slot>\<内容>`;Path 是文件时归档内是名为
`<Slot>` 的文件(没有扩展名);
* 手写路径条目 -> 历史布局 `<末级名>\...`,现有清单不需要改写;
* `::` 覆盖 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($false))
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 -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory"
}
New-Item -ItemType Directory -Path (Join-Path $script:DirA 'Cache') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:DirA 'Cache\c.bin') 'cache'
$script:CfgFile = Join-Path $script:FormatRoot 'settings.json'
Set-Content -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 -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 -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') 'one'
Set-Content -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') 'deep'
Set-Content -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') 'cache'
Set-Content -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') 'two'
Set-Content -LiteralPath (Join-Path $script:SlotCfgDir 'settings.json') '{"a":1}'
Set-Content -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 的文件,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 -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 -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') -Raw).Trim() | Should -Be 'one'
(Get-Content -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') -Raw).Trim() | Should -Be 'deep'
(Get-Content -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') -Raw).Trim() | Should -Be 'two'
(Get-Content -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 -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 -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 -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 -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 -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 -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
}
}