Files
BakNRet/tests/BakNRet.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

1386 lines
70 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
BakNRet 的 Pester 测试套件(单元 + 集成 + 回归)。
.DESCRIPTION
与 tests/Run-Tests.ps1 的分工:
* Run-Tests.ps1 是**零依赖**冒烟套件——机器上没装 Pester 时也能跑,
适合"插上别人的机器先确认没坏";
* 本文件是 Pester 套件,覆盖同样的单元面,并额外用**子进程**真正调用
Backup.ps1 / Restore.ps1。用子进程有两个原因:
1. 两个脚本结尾都会 `exit`,在同一个进程里用 `&` 调用会把 Pester 宿主
一起带走;
2. 子进程给出的是真正的进程退出码,正好独立验证"退出码取法"这条修复。
本套件断言的是**重构后的新契约**(BackupList.txt / SoftwareCatalog.psd1):
* 行首 `+` = 仅备份、`-` = 仅恢复;
* `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`;
* `:+` / `@ Include=` 是"把宿主机路径放到归档内指定位置";
* `:encrypt` / `:!encrypt` / `@ Encrypt=` 控制单个归档加密;
* 软件名条目 -> 一个归档,归档内是 `<Slot>\<内容>`(Path 是文件时是名为
`<Slot>` 的文件);手写路径条目沿用历史布局 `<末级名>\...`。
跑法:
.\tests\Run-Pester.ps1 # 推荐:会自动找到 Pester(含 .tools 下的本地副本)
Invoke-Pester -Path .\tests\BakNRet.Tests.ps1
需要 Pester 5.0+。Pester 3.4.0 不支持 `Should -Be`,会被显式拒绝并给出提示。
.EXAMPLE
pwsh -File .\tests\Run-Pester.ps1
#>
# 发现阶段(discovery)也会执行文件顶层代码,因此 -Skip: 用到的判据必须在这里算好:
# BeforeAll 里的变量在 discovery 阶段还不存在,直接引用会永远是 $null。
$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-pester-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
# 在子进程里跑 Backup.ps1 / Restore.ps1,拿到真实退出码与完整输出。
#
# 为什么不用 `$lines = & pwsh @arguments 2>&1`:本机沙箱禁止 PowerShell 为捕获
# 原生子进程输出创建管道(“Access to the path '\\.\pipe\LOCAL\dotnet_...' is denied”),
# 那条路会直接失败。Invoke-ExternalCommand 让子进程继承 stdio,不受影响,
# 于是这里改成:把整条命令写进临时 .cmd,由 cmd 自己把输出重定向到文件,
# 再用 Invoke-ExternalCommand 调 `cmd /c <批处理>`。退出码来自真实进程,
# 输出从文件读回,全程不经过 PowerShell 的管道。
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))
}
}
# 造一棵确定性的源目录树,返回需要逐字节对拍的文件(相对路径 -> SHA256)。
function New-VerifiableSourceTree {
param([Parameter(Mandatory = $true)][string]$Root)
New-Item -ItemType Directory -Path $Root -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $Root 'sub') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $Root 'Cache') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $Root 'logs') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $Root 'keep.txt') -Value 'keep-me' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $Root 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $Root 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8
Set-Content -LiteralPath (Join-Path $Root 'Cache\c.bin') -Value 'must-be-excluded-too' -Encoding UTF8
$blob = New-Object byte[] 8192
(New-Object System.Random 42).NextBytes($blob)
[System.IO.File]::WriteAllBytes((Join-Path $Root 'blob.bin'), $blob)
$hashes = @{}
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
$hashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $Root $relative) -Algorithm SHA256).Hash
}
return $hashes
}
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 'BackupList.txt 解析' {
# ============================================================================
It '注释行与空行返回 $null' {
ConvertFrom-BackupListLine -Line '# 这是注释' | Should -BeNullOrEmpty
ConvertFrom-BackupListLine -Line ' ' | Should -BeNullOrEmpty
ConvertFrom-BackupListLine -Line '' | Should -BeNullOrEmpty
}
It '裸路径:Path 原样、方向 both、不是软件名、没有任何覆盖' {
$result = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
$result.Path | Should -Be 'C:\Programs\FooClolor'
$result.IsName | Should -BeFalse
$result.Direction | Should -Be 'both'
@($result.Overrides.Keys).Count | Should -Be 0
@($result.ExcludePatterns).Count | Should -Be 0
@($result.Includes).Count | Should -Be 0
@($result.Flags).Count | Should -Be 0
@($result.UnknownKeys).Count | Should -Be 0
$result.Comment | Should -BeNullOrEmpty
$result.Raw | Should -Be 'C:\Programs\FooClolor'
}
It '软件名:IsName 为真' {
$result = ConvertFrom-BackupListLine -Line 'my-app'
$result.Path | Should -Be 'my-app'
$result.IsName | Should -BeTrue
}
It 'IsName 判定:含分隔符或 % 就算字面路径' {
(ConvertFrom-BackupListLine -Line 'a/b').IsName | Should -BeFalse
(ConvertFrom-BackupListLine -Line '%TEMP%\x').IsName | Should -BeFalse
(ConvertFrom-BackupListLine -Line 'my-app').IsName | Should -BeTrue
}
It '标记必须是独立记号:C:\a:-b 仍然只是一个路径' {
$result = ConvertFrom-BackupListLine -Line 'C:\a:-b'
$result.Path | Should -Be 'C:\a:-b'
$result.Overrides.ContainsKey('Exclude') | Should -BeFalse
@($result.ExcludePatterns).Count | Should -Be 0
}
It '行首 + 仅备份、- 仅恢复,方向标记不进入目标' {
$backupOnly = ConvertFrom-BackupListLine -Line '+ Foo'
$backupOnly.Direction | Should -Be 'backup'
$backupOnly.Path | Should -Be 'Foo'
$restoreOnly = ConvertFrom-BackupListLine -Line '- Foo'
$restoreOnly.Direction | Should -Be 'restore'
$restoreOnly.Path | Should -Be 'Foo'
(ConvertFrom-BackupListLine -Line 'Foo').Direction | Should -Be 'both'
}
It '只有方向标记、没有目标 -> 忽略该行' {
ConvertFrom-BackupListLine -Line '+' | Should -BeNullOrEmpty
ConvertFrom-BackupListLine -Line '-' | Should -BeNullOrEmpty
}
# ---- 新契约的核心::: 表示覆盖 Path,不再是 :- 的别名 ----
It ':: 现在表示"覆盖 Path",与 :- 彻底分开' {
$result = ConvertFrom-BackupListLine -Line 'Foo :: D:\bar'
$result.Path | Should -Be 'Foo'
$result.Overrides.ContainsKey('Path') | Should -BeTrue
$result.Overrides['Path'] | Should -Be 'D:\bar'
$result.Overrides.ContainsKey('Exclude') | Should -BeFalse
@($result.ExcludePatterns).Count | Should -Be 0
}
It ':: 的值可以带空格(一直取到下一个标记之前)' {
$result = ConvertFrom-BackupListLine -Line 'Foo :: C:\Program Files\App :encrypt'
$result.Overrides['Path'] | Should -Be 'C:\Program Files\App'
$result.Overrides['Encrypt'] | Should -BeTrue
}
It ':- 的逗号 / 分号列表拆成多个模式' {
$result = ConvertFrom-BackupListLine -Line 'C:\x :- a\b,c\d\;e'
@($result.ExcludePatterns).Count | Should -Be 3
$result.ExcludePatterns[0] | Should -Be 'a\b'
$result.ExcludePatterns[1] | Should -Be 'c\d\'
$result.ExcludePatterns[2] | Should -Be 'e'
}
It '排除模式的引号只保护空白,不保护逗号' {
$spaced = ConvertFrom-BackupListLine -Line "Foo :- 'Default\Code Cache'"
@($spaced.ExcludePatterns).Count | Should -Be 1
$spaced.ExcludePatterns[0] | Should -Be 'Default\Code Cache'
$comma = ConvertFrom-BackupListLine -Line "Foo :- 'a,b'"
@($comma.ExcludePatterns).Count | Should -Be 2
}
It ':+ 的包含项是 <归档内相对路径>:<宿主机绝对路径>' {
$result = ConvertFrom-BackupListLine -Line 'Foo :+ Modules:D:\extra,More:D:\extra2'
$result.Overrides.ContainsKey('Include') | Should -BeTrue
(@($result.Includes) -join '|') | Should -Be 'Modules:D:\extra|More:D:\extra2'
}
It ':encrypt 与 :!encrypt 控制该条目的加密开关' {
$on = ConvertFrom-BackupListLine -Line 'Foo :encrypt'
$on.Overrides.ContainsKey('Encrypt') | Should -BeTrue
$on.Overrides['Encrypt'] | Should -BeTrue
$off = ConvertFrom-BackupListLine -Line 'Foo :!encrypt'
$off.Overrides['Encrypt'] | Should -BeFalse
}
It '@ Key=''Value'' 覆盖 Path / Exclude / Include / Encrypt' {
$path = ConvertFrom-BackupListLine -Line "Foo @ Path='D:\x'"
$path.Overrides['Path'] | Should -Be 'D:\x'
$exclude = ConvertFrom-BackupListLine -Line "Foo @ Exclude='a,b'"
(@($exclude.ExcludePatterns) -join '|') | Should -Be 'a|b'
$include = ConvertFrom-BackupListLine -Line "Foo @ Include='Modules:D:\extra'"
(@($include.Includes) -join '|') | Should -Be 'Modules:D:\extra'
$encryptOn = ConvertFrom-BackupListLine -Line "Foo @ Encrypt='`$true'"
$encryptOn.Overrides['Encrypt'] | Should -BeTrue
$encryptOff = ConvertFrom-BackupListLine -Line "Foo @ Encrypt='false'"
$encryptOff.Overrides['Encrypt'] | Should -BeFalse
}
It '@ 的键名大小写不敏感,且支持紧跟 @ 的写法' {
$upper = ConvertFrom-BackupListLine -Line "Foo @ PATH='D:\x'"
$upper.Overrides['Path'] | Should -Be 'D:\x'
$inline = ConvertFrom-BackupListLine -Line "Foo @Exclude='q,w'"
(@($inline.ExcludePatterns) -join '|') | Should -Be 'q|w'
}
It '历史写法 @encrypt / @!encrypt 仍然被识别成加密覆盖' {
(ConvertFrom-BackupListLine -Line 'Foo @encrypt').Overrides['Encrypt'] | Should -BeTrue
(ConvertFrom-BackupListLine -Line 'Foo @!encrypt').Overrides['Encrypt'] | Should -BeFalse
}
It '历史写法 @pathname / @root=<名> 进入 Flags' {
(ConvertFrom-BackupListLine -Line 'Foo @pathname').Flags | Should -Contain 'pathname'
(ConvertFrom-BackupListLine -Line 'Foo @root=Bar').Flags | Should -Contain 'root=Bar'
}
It '未知的 @ 字段进入 UnknownKeys,且不影响其余字段解析' {
$result = ConvertFrom-BackupListLine -Line "Foo @ UnknownKey='v' :- logs\"
$result.UnknownKeys | Should -Contain 'UnknownKey'
$result.Overrides.ContainsKey('Exclude') | Should -BeTrue
$result.Path | Should -Be 'Foo'
}
It '行尾的 # 说明会成为 Comment' {
$result = ConvertFrom-BackupListLine -Line 'Edge :- !*Cache # 缓存可再生'
$result.Path | Should -Be 'Edge'
(@($result.ExcludePatterns) -join '|') | Should -Be '!*Cache'
$result.Comment | Should -Be '缓存可再生'
}
It '路径里紧贴的 # 不会被当成注释' {
$result = ConvertFrom-BackupListLine -Line 'C:\a#b\c'
$result.Path | Should -Be 'C:\a#b\c'
$result.Comment | Should -BeNullOrEmpty
}
It '没有说明时 Comment 为空' {
ConvertFrom-BackupListLine -Line 'legendary' | Select-Object -ExpandProperty Comment | Should -BeNullOrEmpty
}
It '目标可以带空格:第一个标记之前整段都是目标' {
$bare = ConvertFrom-BackupListLine -Line 'C:\Program Files\App :- logs\'
$bare.Path | Should -Be 'C:\Program Files\App'
$bare.IsName | Should -BeFalse
$quoted = ConvertFrom-BackupListLine -Line '"C:\Program Files\App" :- logs\'
$quoted.Path | Should -Be 'C:\Program Files\App'
(@($quoted.ExcludePatterns) -join '|') | Should -Be 'logs\'
}
It '缺少目标(标记出现在第一个位置)会被忽略并告警' {
ConvertFrom-BackupListLine -Line ':- logs' | Should -BeNullOrEmpty
ConvertFrom-BackupListLine -Line "@ Exclude='x'" | Should -BeNullOrEmpty
}
It '记号切分:引号内的空白不切分,引号本身留在记号里' {
(@(Split-BaknretToken -Text 'A "B C" D') -join '|') | Should -Be 'A|"B C"|D'
# 引号不配对时按"引号延伸到行尾"处理,不抛异常
(@(Split-BaknretToken -Text 'A "B C') -join '|') | Should -Be 'A|"B C'
(@(Split-BaknretToken -Text '') -join '|') | Should -Be ''
}
It '记号识别只认完整记号' {
(Test-BaknretMarker -Token '::') | Should -Be 'path'
(Test-BaknretMarker -Token ':-') | Should -Be 'exclude'
(Test-BaknretMarker -Token ':+') | Should -Be 'include'
(Test-BaknretMarker -Token ':encrypt') | Should -Be 'encrypt'
(Test-BaknretMarker -Token ':!encrypt') | Should -Be 'noencrypt'
(Test-BaknretMarker -Token '@') | Should -Be 'at'
(Test-BaknretMarker -Token 'C:\a:-b') | Should -BeNullOrEmpty
(Test-BaknretMarker -Token ':') | Should -BeNullOrEmpty
}
It '去引号:成对才去,不成对原样返回' {
(Remove-BaknretQuote -Text '"a"') | Should -Be 'a'
(Remove-BaknretQuote -Text "'a'") | Should -Be 'a'
(Remove-BaknretQuote -Text 'a') | Should -Be 'a'
(Remove-BaknretQuote -Text '"a') | Should -Be '"a'
(Remove-BaknretQuote -Text '""') | Should -Be ''
}
}
# ============================================================================
Describe '归档命名' {
# ============================================================================
BeforeAll {
$script:NamingRoot = Join-Path $script:Sandbox 'naming'
$script:NamingApp = Join-Path $script:NamingRoot 'Real App'
New-Item -ItemType Directory -Path $script:NamingApp -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:NamingApp 'keep.txt') 'k'
$script:NamingCatalog = Write-ListFile -Path (Join-Path $script:NamingRoot 'cat.psd1') -Content @"
@{
'my-app' = @{ Main = @{ Path = '$script:NamingApp' } }
}
"@
}
# 注意:Pester 的 It 名字里出现 $( ) 会被求值,所以标题里不要写子表达式。
It '基础命名规则:末级名_from_上级路径用加号连接' {
Get-BackupBaseName -RawPath 'C:\Programs\FooClolor' | Should -Be 'FooClolor_from_C_+Programs'
}
It '/ 与 \ 以及重复分隔符结果一致' {
$a = Get-BackupBaseName -RawPath 'C:\a/b'
$b = Get-BackupBaseName -RawPath 'C:\\a\\\\b'
$a | Should -Be $b
}
It '命名与路径往返' {
$base = Get-BackupBaseName -RawPath 'D:\UserData\Documents\Aria'
Convert-BackupFileNameToPath -FileName ($base + '.7z') | Should -Be 'D:\UserData\Documents\Aria'
}
It '归档名里不含非法文件名字符' {
$base = Get-BackupBaseName -RawPath 'C:\a<b>c|d?e*f'
($base.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
}
It '%变量% 写法里的 % 会保留在归档名里' {
Get-BackupBaseName -RawPath '%UserProfile%\.ssh' | Should -Be '.ssh_from_%UserProfile%'
}
It '软件名条目:默认用软件名做归档名' {
$entry = ConvertFrom-BackupListLine -Line 'my-app'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'my-app'
}
It '字面路径条目:仍用路径命名算法(现有清单无需改写)' {
$entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'FooClolor_from_C_+Programs'
}
It '@pathname 用名录里的真实路径命名,而不是软件名' {
$entry = ConvertFrom-BackupListLine -Line 'my-app @pathname'
$expected = Get-BackupBaseName -RawPath $script:NamingApp
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be $expected
}
It '名录里没有该名字:归档名退回可读目录名,并给出 Error' {
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'no-such-thing'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3
$resolved.Error | Should -Not -BeNullOrEmpty
@($resolved.Items).Count | Should -Be 0
}
}
# ============================================================================
Describe '7z 排除参数翻译' {
# ============================================================================
BeforeAll {
$script:ExcludeTree = Join-Path $script:Sandbox 'exclude-tree'
New-Item -ItemType Directory -Path (Join-Path $script:ExcludeTree 'sub\Cache') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:ExcludeTree 'Keep') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:ExcludeTree 'sub\Cache\x.bin') 'x'
Set-Content -LiteralPath (Join-Path $script:ExcludeTree 'Keep\y.bin') 'y'
function New-TestArchiveItem {
param([string]$ArchivePath, [string]$RealPath = 'C:\nonexistent-path')
New-BaknretArchiveItem -ArchivePath $ArchivePath -RealPath $RealPath -Kind 'path' -Origin 'path' -Exists $false -IsFile $false
}
}
It '相对模式自动补上归档根目录名' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('logs\')
@($result.Arguments) | Should -Contain '-x!Foo\logs'
$result.Error | Should -BeNullOrEmpty
}
It '模式已带根名前缀时,Split-BaknretPatternScope 先摘掉前缀,结果不重复' {
$item = New-TestArchiveItem -ArchivePath 'Foo'
$map = Split-BaknretPatternScope -Items @($item) -Patterns @('Foo\logs\')
(@($map[0]) -join '|') | Should -Be 'logs'
$result = Get-BaknretExcludeArgument -Item $item -Patterns @($map[0])
@($result.Arguments) | Should -Contain '-x!Foo\logs'
@($result.Arguments) | Should -Not -Contain '-x!Foo\Foo\logs'
}
It '! 前缀翻译成递归组件匹配(-xr!)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache')
@($result.Arguments) | Should -Contain '-xr!*Cache'
}
It '模式里的空格转成 ?(归档项自己的名字按原样保留)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('Default\Code Cache')
@($result.Arguments) | Should -Contain '-x!User Data\Default\Code?Cache'
$plain = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('Default\Code Cache')
@($plain.Arguments) | Should -Contain '-x!Foo\Default\Code?Cache'
}
It '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache', 'Default\Code Cache')
(@($result.Arguments) -join ' ') | Should -Not -Match '"'
}
It '空模式被忽略' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('', ' ', '!')
@($result.Arguments).Count | Should -Be 0
$result.Error | Should -BeNullOrEmpty
}
It '!re: 会把正则展开成精确的 -x! 参数' {
$item = New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree
$result = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^Cache$')
@($result.Arguments) | Should -Contain '-x!Top\sub\Cache'
# 命中目录后不再往下走(一个命中只产生一条参数,避免命令行爆炸)
$pruned = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^sub$')
@($pruned.Arguments) | Should -Contain '-x!Top\sub'
@($pruned.Arguments).Count | Should -Be 1
}
It '非法正则给出 Error,而不是抛异常' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) -Patterns @('!re:[')
@($result.Arguments).Count | Should -Be 0
$result.Error | Should -Not -BeNullOrEmpty
}
It '正则命中数超过上限时明确报错' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) `
-Patterns @('!re:.') -MaxRegexMatches 1
$result.Error | Should -Not -BeNullOrEmpty
}
It '参数总长超过安全上限时明确报错' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top') `
-Patterns @('!*zzz') -MaxCommandLineChars 5
$result.Error | Should -Not -BeNullOrEmpty
}
It 'Split-BaknretPatternScope:指名 Slot 的模式只分给那个 Slot' {
$items = @(
New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta'
)
$map = Split-BaknretPatternScope -Items $items -Patterns @('Alpha\logs\')
(@($map[0]) -join '|') | Should -Be 'logs'
@($map[1]).Count | Should -Be 0
# 用 / 分隔也一样
$slash = Split-BaknretPatternScope -Items $items -Patterns @('Beta/sub')
(@($slash[1]) -join '|') | Should -Be 'sub'
@($slash[0]).Count | Should -Be 0
}
It 'Split-BaknretPatternScope:没点名任何 Slot 的模式广播给每一项' {
$items = @(
New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta'
)
$map = Split-BaknretPatternScope -Items $items -Patterns @('plain\path', 'Alpha')
(@($map[0]) -join '|') | Should -Be 'plain\path|Alpha'
(@($map[1]) -join '|') | Should -Be 'plain\path|Alpha'
}
It 'Split-BaknretPatternScope:! 与 !re: 模式广播给每一项' {
$items = @(
New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta'
)
$map = Split-BaknretPatternScope -Items $items -Patterns @('!*Cache', '!re:^x$')
(@($map[0]) -join '|') | Should -Be '!*Cache|!re:^x$'
(@($map[1]) -join '|') | Should -Be '!*Cache|!re:^x$'
}
It 'Split-BaknretPatternScope:每个归档项都有键(没有模式时是空数组)' {
$items = @(
New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta'
)
$map = Split-BaknretPatternScope -Items $items -Patterns @()
$map.ContainsKey(0) | Should -BeTrue
$map.ContainsKey(1) | Should -BeTrue
@($map[0]).Count | Should -Be 0
}
It 'Merge-BaknretExcludeArgument 去重且保持顺序' {
$merged = Merge-BaknretExcludeArgument -ArgumentLists @(@('-x!a', '-x!b'), @('-x!b', '-x!c'))
(@($merged) -join ' ') | Should -Be '-x!a -x!b -x!c'
}
It '多 Slot 条目:逐项分配 + 翻译 + 去重合成一份参数' {
$items = @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:ExcludeTree -Kind 'slot' -Slot 'Alpha' -Origin 'catalog' -Exists $true
New-BaknretArchiveItem -ArchivePath 'Beta' -RealPath $script:ExcludeTree -Kind 'slot' -Slot 'Beta' -Origin 'catalog' -Exists $true
)
$patterns = @('Alpha\logs\', '!*Cache', 'Beta\sub', '!*Cache')
$map = Split-BaknretPatternScope -Items $items -Patterns $patterns
$lists = @()
for ($index = 0; $index -lt $items.Count; $index++) {
$lists += , @((Get-BaknretExcludeArgument -Item $items[$index] -Patterns @($map[$index])).Arguments)
}
$effective = @(Merge-BaknretExcludeArgument -ArgumentLists $lists)
$effective | Should -Contain '-x!Alpha\logs'
$effective | Should -Contain '-x!Beta\sub'
$effective | Should -Contain '-xr!*Cache'
# 同一个 -xr!*Cache 只留一份
@($effective | Where-Object { $_ -eq '-xr!*Cache' }).Count | Should -Be 1
}
}
# ============================================================================
Describe '归档项与暂存目录' {
# ============================================================================
BeforeAll {
$script:StagingRoot = Join-Path $script:Sandbox 'staging'
$script:StagingDir = Join-Path $script:StagingRoot 'real-dir'
$script:StagingFile = Join-Path $script:StagingRoot 'settings.json'
New-Item -ItemType Directory -Path (Join-Path $script:StagingDir 'inner') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:StagingDir 'inner\a.txt') 'a'
Set-Content -LiteralPath $script:StagingFile 'file-content'
}
It 'New-BaknretArchiveItem 规范化归档内路径并算出 TopName' {
$item = New-BaknretArchiveItem -ArchivePath '\Alpha\Sub\' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha'
$item.ArchivePath | Should -Be 'Alpha\Sub'
$item.TopName | Should -Be 'Alpha'
$item.Kind | Should -Be 'slot'
$item.Slot | Should -Be 'Alpha'
$item.Origin | Should -Be 'catalog'
@($item.Exclude).Count | Should -Be 0
}
It 'Get-BaknretArchiveTopName 取归档内相对路径的第一段' {
(Get-BaknretArchiveTopName -ArchivePath 'A\B\c.txt') | Should -Be 'A'
(Get-BaknretArchiveTopName -ArchivePath ' A ') | Should -Be 'A'
(Get-BaknretArchiveTopName -ArchivePath '') | Should -Be ''
}
It '目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径)' {
$items = @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false
New-BaknretArchiveItem -ArchivePath 'Nested\Deep' -RealPath $script:StagingDir -Kind 'include' -Origin 'include' -Exists $true -IsFile $false
New-BaknretArchiveItem -ArchivePath 'Cfg' -RealPath $script:StagingFile -Kind 'slot' -Slot 'Cfg' -Exists $true -IsFile $true
)
$stage = New-BaknretArchiveStaging -Items $items
try {
$alpha = Get-Item -LiteralPath (Join-Path $stage 'Alpha') -Force
$alpha.LinkType | Should -Be 'Junction'
$nested = Get-Item -LiteralPath (Join-Path $stage 'Nested\Deep') -Force
$nested.LinkType | Should -Be 'Junction'
# 文件项:归档内的名字就叫 Cfg(没有扩展名),内容与源文件一致
$cfg = Get-Item -LiteralPath (Join-Path $stage 'Cfg') -Force
$cfg.PSIsContainer | Should -BeFalse
(Get-Content -LiteralPath $cfg.FullName -Raw).Trim() | Should -Be 'file-content'
} finally {
Remove-BaknretArchiveStaging -Root $stage
}
}
It 'Remove-BaknretArchiveStaging 只删连接点,不顺着走进真实目录' {
$stage = New-BaknretArchiveStaging -Items @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false
)
Remove-BaknretArchiveStaging -Root $stage
Test-Path -LiteralPath $stage | Should -BeFalse
Test-Path -LiteralPath (Join-Path $script:StagingDir 'inner\a.txt') | Should -BeTrue
}
}
# ============================================================================
Describe '命令行参数拼接' {
# ============================================================================
It '无空格参数原样输出' {
ConvertTo-NativeArgumentString -ArgumentList @('a', '-mx=9') | Should -Be 'a -mx=9'
}
It '含空格参数加引号' {
ConvertTo-NativeArgumentString -ArgumentList @('C:\a b\c') | Should -Be '"C:\a b\c"'
}
It '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
ConvertTo-NativeArgumentString -ArgumentList @('C:\a b\') | Should -Be '"C:\a b\\"'
}
It '内部引号被转义' {
ConvertTo-NativeArgumentString -ArgumentList @('a"b c') | Should -Be '"a\"b c"'
}
It '空参数输出一对空引号' {
ConvertTo-NativeArgumentString -ArgumentList @('') | Should -Be '""'
}
}
# ============================================================================
Describe 'manifest 与配置' {
# ============================================================================
It 'manifest 读写往返' {
$path = Join-Path $script:Sandbox 'roundtrip\manifest.json'
$manifest = Read-BaknretManifest -Path $path
$manifest.items['demo'] = [pscustomobject]@{ archive = 'demo.7z'; action = 'backed-up'; verified = $true }
Write-BaknretManifest -Path $path -Manifest $manifest | Out-Null
$again = Read-BaknretManifest -Path $path
$again.items.Count | Should -Be 1
$again.items['demo'].archive | Should -Be 'demo.7z'
$again.items['demo'].verified | Should -BeTrue
}
It 'manifest 原样保存 layouts(name/kind),全新恢复时靠它判断目录还是文件' {
$path = Join-Path $script:Sandbox 'layouts\manifest.json'
$manifest = Read-BaknretManifest -Path $path
$manifest.items['app'] = [pscustomobject]@{
archive = 'app.7z'
layouts = @(
[pscustomobject]@{ name = 'Data'; kind = 'dir' }
[pscustomobject]@{ name = 'Cfg'; kind = 'file' }
)
}
Write-BaknretManifest -Path $path -Manifest $manifest | Out-Null
$again = Read-BaknretManifest -Path $path
@($again.items['app'].layouts).Count | Should -Be 2
(@($again.items['app'].layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join '|') | Should -Be 'Data:dir|Cfg:file'
}
It 'manifest 损坏时不抛异常,而是重建空清单' {
$path = Write-ListFile -Path (Join-Path $script:Sandbox 'broken.json') -Content '{ this is not json'
{ $script:broken = Read-BaknretManifest -Path $path } | Should -Not -Throw
$script:broken.items.Count | Should -Be 0
}
It '配置缺失时返回默认值' {
$config = Get-BaknretConfig -Path (Join-Path $script:Sandbox 'no-such-config.psd1')
$config.BackupDir | Should -Be 'Backups'
$config.VerifyArchive | Should -BeTrue
}
It '配置嵌套段落合并且不丢默认键' {
$path = Write-ListFile -Path (Join-Path $script:Sandbox 'cfg.psd1') -Content "@{ MinFreeSpaceGB = 3; Encryption = @{ Enabled = `$true } }"
$config = Get-BaknretConfig -Path $path
$config.MinFreeSpaceGB | Should -Be 3
$config.Encryption.Enabled | Should -BeTrue
# 没写到的子键要保留默认值。Encryption 是哈希表,用 ContainsKey 查。
$config.Encryption.ContainsKey('EncryptHeaders') | Should -BeTrue
}
It '口令:环境变量可读取,取不到时返回 $null(绝不退化成明文)' {
$saved = $env:BAKNRET_PASSWORD
try {
$env:BAKNRET_PASSWORD = 'sekrit'
(Get-BaknretPassword) | Should -Be 'sekrit'
$env:BAKNRET_PASSWORD = $null
(Get-BaknretPassword) | Should -BeNullOrEmpty
} finally {
$env:BAKNRET_PASSWORD = $saved
}
}
}
# ============================================================================
Describe '软件名录' {
# ============================================================================
BeforeAll {
$script:CatRoot = Join-Path $script:Sandbox 'catalog'
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'legendary_2.0.4') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'LegendarySomething') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dash-1.2') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dup_1') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dup_2') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'real app') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'other') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'inc') -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:CatRoot 'real app\keep.txt') 'k'
Set-Content -LiteralPath (Join-Path $script:CatRoot 'file.txt') 'file-content'
$script:CatReal = Join-Path $script:CatRoot 'real app'
$script:CatOther = Join-Path $script:CatRoot 'other'
$script:CatInc = Join-Path $script:CatRoot 'inc'
# 路径先算好再拼名录内容:在 @"..."@ 里嵌 $ 很容易把引号吃掉,
# 生成一个解析不了的 psd1,而"名录读不到"会让断言静默通过。
$script:CatFile = Write-ListFile -Path (Join-Path $script:CatRoot 'SoftwareCatalog.psd1') -Content @"
@{
'single' = @{ Main = @{ Path = '$script:CatReal'; Description = '主 Slot' } }
'multi' = @{
Zeta = @{ Path = '$script:CatOther' }
Alpha = @{ Path = '$script:CatReal'; Exclude = '!*Cache,logs\'; Description = 'alpha slot' }
}
'partial' = @{ Ok = @{ Path = '$script:CatReal' }; Gone = @{ Path = '$script:CatRoot\not-here' } }
'unres' = @{ Gone = @{ Path = '$script:CatRoot\nope' } }
'legendary' = @{ L = @{ Path = '$script:CatRoot\legendary' } }
'dashy' = @{ D = @{ Path = '$script:CatRoot\dash' } }
'dupslot' = @{ D = @{ Path = '$script:CatRoot\dup' } }
'fileslot' = @{ Cfg = @{ Path = '$script:CatRoot\file.txt'; Encrypt = `$true } }
'envpath' = @{ E = @{ Path = '%TEMP%' } }
'encapp' = @{ A = @{ Path = '$script:CatReal'; Encrypt = `$true }; B = @{ Path = '$script:CatOther' } }
'incapp' = @{ A = @{ Path = '$script:CatReal'; Include = 'Mods:$script:CatInc' } }
'conflict' = @{ Data = @{ Path = '$script:CatReal' } }
'badslot' = @{ S = 'not-a-map' }
'nopath' = @{ S = @{ Description = 'no path' } }
}
"@
$script:LegacyFile = Write-ListFile -Path (Join-Path $script:CatRoot 'Legacy.psd1') -Content @"
@{
'bare' = 'C:\x'
'arr' = @('C:\a', 'C:\b')
'objarr' = @(@{ Path = 'C:\a' }, @{ Path = 'C:\b' })
'dirstyle' = @{ Dirs = @('C:\a', 'C:\b') }
}
"@
}
It '名录解析:条目与 Slot 的字段集合就是新契约' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$entry = $catalog['single']
(@($entry.PSObject.Properties.Name) -join ',') | Should -Be 'Name,Path,Description,Slots,Kind,Missing,Error,Raw'
$entry.Name | Should -Be 'single'
$entry.Path | Should -Be $script:CatReal
$entry.Description | Should -Be '主 Slot'
$entry.Kind | Should -Be 'Single'
@($entry.Missing).Count | Should -Be 0
$entry.Error | Should -BeNullOrEmpty
$slot = @($entry.Slots)[0]
(@($slot.PSObject.Properties.Name) -join ',') | Should -Be 'Name,Declared,Resolved,Exists,IsFile,Suffixed,Description,Exclude,Include,Encrypt'
$slot.Name | Should -Be 'Main'
$slot.Declared | Should -Be $script:CatReal
$slot.Resolved | Should -Be $script:CatReal
$slot.Exists | Should -BeTrue
$slot.IsFile | Should -BeFalse
$slot.Suffixed | Should -BeFalse
$slot.Encrypt | Should -BeFalse
}
It '一个软件多个 Slot:Kind=Multi,Slot 按名字排序' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['multi'].Kind | Should -Be 'Multi'
@($catalog['multi'].Slots).Count | Should -Be 2
(@($catalog['multi'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'Alpha,Zeta'
$catalog['multi'].Slots[0].Description | Should -Be 'alpha slot'
(@($catalog['multi'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
}
It 'Kind:Single / Multi / Partial / Unresolved / Invalid' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['single'].Kind | Should -Be 'Single'
$catalog['multi'].Kind | Should -Be 'Multi'
$catalog['partial'].Kind | Should -Be 'Partial'
$catalog['unres'].Kind | Should -Be 'Unresolved'
$catalog['badslot'].Kind | Should -Be 'Invalid'
$catalog['nopath'].Kind | Should -Be 'Invalid'
@($catalog['partial'].Missing).Count | Should -Be 1
@($catalog['unres'].Missing).Count | Should -Be 1
$catalog['badslot'].Error | Should -Match 'Slot S'
$catalog['nopath'].Error | Should -Match '缺少 Path'
}
It '前缀补全:<名>_<后缀> 与 <名>-<后缀> 都会被补全' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['legendary'].Slots[0].Resolved | Should -Be (Join-Path $script:CatRoot 'legendary_2.0.4')
$catalog['legendary'].Slots[0].Suffixed | Should -BeTrue
$catalog['dashy'].Slots[0].Resolved | Should -Be (Join-Path $script:CatRoot 'dash-1.2')
$catalog['dashy'].Slots[0].Suffixed | Should -BeTrue
}
It '不会把 Legendary 误配成 LegendarySomething' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['legendary'].Slots[0].Resolved | Should -Not -Be (Join-Path $script:CatRoot 'LegendarySomething')
}
It '一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$entry = $catalog['dupslot']
$entry.Error | Should -Match '只能对应一个目录'
$entry.Error | Should -Match 'dup_1'
$entry.Error | Should -Match 'dup_2'
@($entry.Slots).Count | Should -Be 1
$entry.Slots[0].Suffixed | Should -BeTrue
}
It '文件 Slot:Path 指向文件时 IsFile 为真,Encrypt 也带上' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$slot = $catalog['fileslot'].Slots[0]
$slot.IsFile | Should -BeTrue
$slot.Encrypt | Should -BeTrue
}
It '%变量% 会在名录路径里展开' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['envpath'].Slots[0].Resolved | Should -Be ([Environment]::ExpandEnvironmentVariables('%TEMP%'))
}
It '$( ... ) 子表达式会被求值(含嵌套)' {
(Expand-CatalogPathText -Text '$(Join-Path $env:TEMP "s")') | Should -Be (Join-Path $env:TEMP 's')
(Expand-CatalogPathText -Text '$(Join-Path $env:TEMP "$(Join-Path ''a'' ''b'')")') | Should -Be (Join-Path $env:TEMP 'a\b')
# 括号不配对时原样保留,不抛异常
(Expand-CatalogPathText -Text '$(Join-Path') | Should -Be '$(Join-Path'
}
It '读取结果按"路径 + 时间戳 + 长度 + 内容 MD5"缓存' {
$first = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3
$second = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3
[object]::ReferenceEquals($first, $second) | Should -BeTrue
$fresh = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
[object]::ReferenceEquals($first, $fresh) | Should -BeFalse
}
It '名录文件内容变了以后缓存自动失效' {
$path = Write-ListFile -Path (Join-Path $script:CatRoot 'cache.psd1') -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'one' } } }"
(Get-SoftwareCatalog -Path $path -MaxDepth 3)['x'].Description | Should -Be 'one'
Write-ListFile -Path $path -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'two' } } }" | Out-Null
(Get-SoftwareCatalog -Path $path -MaxDepth 3)['x'].Description | Should -Be 'two'
}
It 'Includes:分文件维护的名录会被合并' {
Write-ListFile -Path (Join-Path $script:CatRoot 'Extra.psd1') -Content "@{ 'extra-app' = @{ S = @{ Path = '$script:CatReal' } } }" | Out-Null
$main = Write-ListFile -Path (Join-Path $script:CatRoot 'Main.psd1') -Content "@{ Includes = @('Extra.psd1'); 'main-app' = @{ S = @{ Path = '$script:CatReal' } } }"
$catalog = Get-SoftwareCatalog -Path $main -MaxDepth 3 -NoCache
$catalog.ContainsKey('main-app') | Should -BeTrue
$catalog.ContainsKey('extra-app') | Should -BeTrue
}
It '旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过' {
$catalog = Get-SoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache
$catalog.ContainsKey('bare') | Should -BeFalse
$catalog.ContainsKey('arr') | Should -BeFalse
$catalog.ContainsKey('objarr') | Should -BeFalse
}
It '旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因' {
$catalog = Get-SoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache
$catalog.ContainsKey('dirstyle') | Should -BeTrue
$catalog['dirstyle'].Kind | Should -Be 'Invalid'
$catalog['dirstyle'].Error | Should -Not -BeNullOrEmpty
@($catalog['dirstyle'].Slots).Count | Should -Be 0
}
It '软件名会被规范化成合法文件名' {
$clean = Format-CatalogName -Name 'a<b>c:d/e'
($clean.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
(Format-CatalogName -Name ' My App ') | Should -Be 'My App'
}
}
# ============================================================================
Describe 'Resolve-BackupEntry:条目解析' {
# ============================================================================
BeforeAll {
$script:CatRoot = Join-Path $script:Sandbox 'catalog'
$script:CatFile = Join-Path $script:CatRoot 'SoftwareCatalog.psd1'
$script:CatReal = Join-Path $script:CatRoot 'real app'
$script:CatOther = Join-Path $script:CatRoot 'other'
$script:CatInc = Join-Path $script:CatRoot 'inc'
}
It 'resolve 与归档项的字段集合就是新契约(旧字段已删除)' {
$entry = ConvertFrom-BackupListLine -Line 'single'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
(@($resolved.PSObject.Properties.Name) -join ',') | Should -Be 'IsName,CatalogEntry,BaseName,ArchiveFlavor,Direction,Items,Encrypt,ExcludePatterns,HasExcludeOverride,Includes,HasIncludeOverride,Source,Error,Blocking'
@($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'ArchivePath'
@($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'Origin'
$resolved.PSObject.Properties['Sources'] | Should -BeNullOrEmpty
}
It '软件名条目:isName/CatalogEntry/BaseName/ArchiveFlavor/Source' {
$entry = ConvertFrom-BackupListLine -Line 'single'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.IsName | Should -BeTrue
$resolved.BaseName | Should -Be 'single'
$resolved.ArchiveFlavor | Should -Be 'name'
$resolved.Source | Should -Be 'single'
$resolved.CatalogEntry | Should -Not -BeNullOrEmpty
$resolved.Error | Should -BeNullOrEmpty
$resolved.Blocking | Should -BeNullOrEmpty
}
It '软件名条目:Items 来自 Slot(ArchivePath=Slot 名,Kind=slot,Origin=catalog)' {
$entry = ConvertFrom-BackupListLine -Line 'multi'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 2
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'Alpha,Zeta'
(@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot'
(@($resolved.Items | ForEach-Object { $_.Origin }) -join ',') | Should -Be 'catalog,catalog'
(@($resolved.Items | ForEach-Object { $_.TopName }) -join ',') | Should -Be 'Alpha,Zeta'
$resolved.Items[0].Slot | Should -Be 'Alpha'
$resolved.Items[0].RealPath | Should -Be $script:CatReal
}
It '字面路径条目:一个 path 项(ArchivePath 是末级名)' {
$entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.IsName | Should -BeFalse
$resolved.ArchiveFlavor | Should -Be 'path'
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].ArchivePath | Should -Be 'FooClolor'
$resolved.Items[0].Kind | Should -Be 'path'
$resolved.Items[0].Origin | Should -Be 'path'
$resolved.Items[0].RealPath | Should -Be 'C:\Programs\FooClolor'
}
It '字面路径条目上的 @ Path= 覆盖目标路径,但归档名仍按原路径' {
$entry = ConvertFrom-BackupListLine -Line "C:\Programs\Foo @ Path='D:\Elsewhere'"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Items[0].RealPath | Should -Be 'D:\Elsewhere'
$resolved.BaseName | Should -Be 'Foo_from_C_+Programs'
}
It '名录里的路径不存在时仍给出 Items(恢复要靠它还原回原位)' {
$missingCatalog = Write-ListFile -Path (Join-Path $script:Sandbox 'catalog\Missing.psd1') -Content "@{ 'gone' = @{ S = @{ Path = 'C:\definitely-not-here-98765' } } }"
$entry = ConvertFrom-BackupListLine -Line 'gone'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $missingCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].Exists | Should -BeFalse
$resolved.Items[0].RealPath | Should -Be 'C:\definitely-not-here-98765'
}
It ':: 覆盖 Path:单 Slot 条目直接生效' {
$entry = ConvertFrom-BackupListLine -Line "single :: $script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 1
$resolved.Items[0].ArchivePath | Should -Be 'Main'
$resolved.Items[0].RealPath | Should -Be $script:CatOther
$resolved.Blocking | Should -BeNullOrEmpty
}
It ':: / @ Path= 覆盖遇到多 Slot 条目 -> Blocking(不猜是哪一个)' {
foreach ($line in @("multi :: $script:CatOther", "multi @ Path='$script:CatOther'")) {
$entry = ConvertFrom-BackupListLine -Line $line
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 0
$resolved.Blocking | Should -Match '不能用一个'
}
}
It '条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真' {
$entry = ConvertFrom-BackupListLine -Line 'single :- logs\,!*Cache'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.HasExcludeOverride | Should -BeTrue
(@($resolved.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache'
}
It '名录 Slot 的 Include 会追加成 include 项' {
$entry = ConvertFrom-BackupListLine -Line 'incapp'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 2
$include = @($resolved.Items | Where-Object { $_.Origin -eq 'include' })
$include.Count | Should -Be 1
$include[0].ArchivePath | Should -Be 'Mods'
$include[0].Kind | Should -Be 'include'
$include[0].RealPath | Should -Be $script:CatInc
}
It '条目级 :+ / @ Include= 覆盖名录里的 Include' {
foreach ($line in @("incapp :+ Other:$script:CatOther", "incapp @ Include='Other:$script:CatOther'")) {
$entry = ConvertFrom-BackupListLine -Line $line
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.HasIncludeOverride | Should -BeTrue
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,Other'
@($resolved.Items)[1].RealPath | Should -Be $script:CatOther
}
}
It '加密:名录里各 Slot 取或;条目级 :encrypt / :!encrypt 覆盖' {
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeFalse
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp :!encrypt') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeFalse
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single :encrypt') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line "single @ Encrypt='true'") -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
}
It '方向标记会传递到 Resolve-BackupEntry.Direction' {
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'backup'
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '- single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'restore'
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'both'
}
It '归档内路径冲突(include 与 Slot 同名)-> Blocking' {
$entry = ConvertFrom-BackupListLine -Line "conflict :+ Data:$script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Blocking | Should -Match '归档内路径冲突'
}
It '归档内路径冲突(父子关系)-> Blocking' {
$entry = ConvertFrom-BackupListLine -Line "conflict :+ Data\sub:$script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Blocking | Should -Match '父子关系'
}
It '名录里没有该名字:Error 给出,Items 为空' {
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Error | Should -Match 'no-such-thing'
@($resolved.Items).Count | Should -Be 0
}
}
# ============================================================================
Describe '外部命令退出码(旧实现的核心缺陷)' {
# ============================================================================
It 'Invoke-ExternalCommand 能拿到真实退出码' {
(Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')) | Should -Be 7
}
It '成功时拿到 0' {
(Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0')) | Should -Be 0
}
}
# ============================================================================
Describe '集成:真实 7z 压缩与排除规则' -Skip:(-not $script:HasSevenZip) {
# ============================================================================
BeforeAll {
$script:IntegrationRoot = Join-Path $script:Sandbox 'integration'
$script:IntegrationSource = Join-Path $script:IntegrationRoot 'src'
$script:IntegrationItem = 'User Data'
$script:IntegrationPath = Join-Path $script:IntegrationSource $script:IntegrationItem
foreach ($directory in 'Default\Cache', 'Default\Code Cache', 'Default\Extensions', 'component_crx_cache', 'Default\IndexedDB') {
New-Item -ItemType Directory -Path (Join-Path $script:IntegrationPath $directory) -Force | Out-Null
}
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'keep.txt') 'keep'
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Cache\c.bin') 'c'
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Code Cache\cc.bin') 'cc'
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\Extensions\e.bin') 'e'
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'component_crx_cache\x.bin') 'x'
Set-Content -LiteralPath (Join-Path $script:IntegrationPath 'Default\IndexedDB\i.bin') 'i'
}
It '排除规则与空格处理在真实归档上生效' {
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
$item = New-BaknretArchiveItem -ArchivePath $script:IntegrationItem -RealPath $script:IntegrationPath `
-Kind 'path' -Origin 'path' -Exists $true -IsFile $false
$excludeArguments = @((Get-BaknretExcludeArgument -Item $item -Patterns $patterns).Arguments)
$archive = Join-Path $script:IntegrationRoot 'excl.7z'
$arguments = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArguments + @($archive, $script:IntegrationItem)
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList $arguments -WorkingDirectory $script:IntegrationSource) | Should -Be 0
$verify = Join-Path $script:IntegrationRoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\keep.txt") | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Cache\c.bin") | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Code Cache\cc.bin") | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Extensions\e.bin") | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\component_crx_cache\x.bin") | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\IndexedDB\i.bin") | Should -BeFalse
}
It '对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档)' {
$archive = Join-Path $script:IntegrationRoot 'full.7z'
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, $script:IntegrationItem) -WorkingDirectory $script:IntegrationSource) | Should -Be 0
$verify = Join-Path $script:IntegrationRoot 'verify-full'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify "$script:IntegrationItem\Default\Cache\c.bin") | Should -BeTrue
}
It '7z t 对完好归档返回 0,对损坏归档返回非 0(归档后校验的依据)' {
$good = Join-Path $script:IntegrationRoot 'full.7z'
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('t', '-bso0', '-bsp0', $good)) | Should -Be 0
$bad = Join-Path $script:IntegrationRoot 'corrupt.7z'
$bytes = [System.IO.File]::ReadAllBytes($good)
for ($i = [math]::Max(0, $bytes.Length - 40); $i -lt $bytes.Length; $i++) { $bytes[$i] = 0xFF }
[System.IO.File]::WriteAllBytes($bad, $bytes)
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('t', '-bso0', '-bsp0', $bad)) | Should -Not -Be 0
}
}
# ============================================================================
Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
BeforeAll {
$script:E2ERoot = Join-Path $script:Sandbox 'e2e'
$script:E2ESource = Join-Path $script:E2ERoot 'src\My Code Space'
$script:E2EBackupDir = Join-Path $script:E2ERoot 'Backups'
$script:E2EList = Join-Path $script:E2ERoot 'list.txt'
$script:E2EHashes = New-VerifiableSourceTree -Root $script:E2ESource
Write-ListFile -Path $script:E2EList -Content "# e2e`n$script:E2ESource :- logs\,!*Cache`n" | Out-Null
$script:BackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
Force = $true
QuietTool = $true
}
$script:E2EManifestPath = Join-Path $script:E2EBackupDir 'manifest.json'
$script:E2EManifest = Read-BaknretManifest -Path $script:E2EManifestPath
$script:E2EArchive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0]
}
It '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
$script:BackupRun.ExitCode | Should -Be 0
}
It '归档已生成且 manifest 记录了条目、动作、校验结果与 layouts' {
@(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z).Count | Should -Be 1
$script:E2EArchive.Length | Should -BeGreaterThan 0
$record = $script:E2EManifest.items[$script:E2EArchive.BaseName]
$record | Should -Not -BeNullOrEmpty
$record.action | Should -Be 'backed-up'
$record.verified | Should -BeTrue
$record.exitCode | Should -Be 0
$record.roots | Should -Contain 'My Code Space'
@($record.layouts).Count | Should -Be 1
$record.layouts[0].name | Should -Be 'My Code Space'
$record.layouts[0].kind | Should -Be 'dir'
}
It '备份过程写了日志文件' {
$logs = @(Get-ChildItem -LiteralPath (Join-Path $script:ProjectRoot 'logs') -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
$logs.Count | Should -BeGreaterThan 0
}
It '字面路径条目沿用 <末级名> 布局;归档里保留应保留内容、不含被排除项' {
(Get-BaknretArchiveTopName -ArchivePath 'My Code Space\keep.txt') | Should -Be 'My Code Space'
$verify = Join-Path $script:E2ERoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $script:E2EArchive.FullName)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify 'My Code Space\keep.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'My Code Space\sub\b.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'My Code Space\logs\a.log') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $verify 'My Code Space\Cache\c.bin') | Should -BeFalse
}
# ---- 回归:manifest.roots 曾经记录的是软件名,而不是归档里真实的顶层条目名 ----
It '[回归] manifest.roots 记录的是归档内真实的顶层条目名' {
$record = $script:E2EManifest.items[$script:E2EArchive.BaseName]
$record.roots | Should -Contain 'My Code Space'
# 和归档里的真实内容对一次账,而不是只信 manifest 自己
$topLevel = @(Get-ArchiveTopLevelNames -ArchivePath $script:E2EArchive.FullName -SevenZip $script:SevenZip)
(@($topLevel | Sort-Object) -join ',') | Should -Be 'My Code Space'
}
It 'Restore -DryRun 退出码 0,且一个字节都不写(manifest SHA256 不变)' {
$before = (Get-FileHash -LiteralPath $script:E2EManifestPath -Algorithm SHA256).Hash
Remove-Item -LiteralPath $script:E2ESource -Recurse -Force
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
DryRun = $true
}
$run.ExitCode | Should -Be 0
Test-Path -LiteralPath $script:E2ESource | Should -BeFalse
(Get-FileHash -LiteralPath $script:E2EManifestPath -Algorithm SHA256).Hash | Should -Be $before
}
It 'Restore -WhatIf 同样不写盘' {
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
WhatIf = $true
}
$run.ExitCode | Should -Be 0
Test-Path -LiteralPath $script:E2ESource | Should -BeFalse
}
It '真实恢复:退出码 0,文件逐字节一致,被排除项没有被恢复出来' {
Test-Path -LiteralPath $script:E2ESource | Should -BeFalse # 保证不是空操作
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
Force = $true
}
$run.ExitCode | Should -Be 0
foreach ($relative in $script:E2EHashes.Keys) {
$restored = Join-Path $script:E2ESource $relative
Test-Path -LiteralPath $restored | Should -BeTrue
(Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash | Should -Be $script:E2EHashes[$relative]
}
Test-Path -LiteralPath (Join-Path $script:E2ESource 'logs\a.log') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $script:E2ESource 'Cache\c.bin') | Should -BeFalse
}
It '真实恢复之后 manifest 才被更新(lastRestoreAt)' {
$manifest = Read-BaknretManifest -Path $script:E2EManifestPath
$record = $manifest.items[$script:E2EArchive.BaseName]
$record.lastRestoreAt | Should -Not -BeNullOrEmpty
}
It '孤儿归档会被点名报告,但不影响退出码' {
Copy-Item -LiteralPath $script:E2EArchive.FullName -Destination (Join-Path $script:E2EBackupDir 'Orphaned-Archive.7z') -Force
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
QuietTool = $true
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '孤儿归档'
$run.Output | Should -Match 'Orphaned-Archive\.7z'
}
It '带 -Only 时不做孤儿审计(避免把未选中的归档误报成孤儿)' {
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir
Only = @('My Code Space')
QuietTool = $true
}
$run.Output | Should -Not -Match '孤儿归档'
}
It '源路径不存在时记为 missing-source,退出码仍为 0(跳过不算失败)' {
$missingList = Write-ListFile -Path (Join-Path $script:E2ERoot 'missing.txt') -Content "Z:\definitely-not-here-12345`n"
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $missingList
BackupDir = $script:E2EBackupDir
QuietTool = $true
}
$run.ExitCode | Should -Be 0
$manifest = Read-BaknretManifest -Path $script:E2EManifestPath
$manifest.items['definitely-not-here-12345_from_Z_'].action | Should -Be 'missing-source'
}
It '归档名重复时直接报失败(退出码 1),不静默互相覆盖' {
$duplicateList = Write-ListFile -Path (Join-Path $script:E2ERoot 'dup.txt') -Content "$script:E2ESource`n$script:E2ESource`n"
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $duplicateList
BackupDir = $script:E2EBackupDir
Force = $true
QuietTool = $true
}
$run.ExitCode | Should -Be 1
}
}
# ============================================================================
Describe '集成:方向标记与孤儿审计' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
# `+` / `-` 的归档名必须在方向过滤**之前**登记:这些条目自己不产生归档,
# 但它们对应的归档是有主的,不能被孤儿审计当成没人要的孤儿。
# 同时放一个真孤儿做对照,证明审计确实在跑。
BeforeAll {
$script:DirectionRoot = Join-Path $script:Sandbox 'direction'
$script:DirectionSource = Join-Path $script:DirectionRoot 'src\Keep Me'
New-Item -ItemType Directory -Path $script:DirectionSource -Force | Out-Null
Set-Content -LiteralPath (Join-Path $script:DirectionSource 'data.txt') 'keep'
$script:DirectionBackupDir = Join-Path $script:DirectionRoot 'Backups'
$script:DirectionList = Join-Path $script:DirectionRoot 'list.txt'
# 正确方向:先正常备份出归档
Write-ListFile -Path $script:DirectionList -Content "$script:DirectionSource`n" | Out-Null
$null = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir
Force = $true
QuietTool = $true
}
$script:DirectionArchiveName = @(Get-ChildItem -LiteralPath $script:DirectionBackupDir -File -Filter *.7z)[0].BaseName
# 对照组:一个清单里没有条目指向的归档,必须被点名
Copy-Item -LiteralPath (Join-Path $script:DirectionBackupDir "$script:DirectionArchiveName.7z") `
-Destination (Join-Path $script:DirectionBackupDir 'Stray.7z') -Force
# 行首 -:仅恢复,备份端应当跳过它,但要把它算作"有主"
Write-ListFile -Path $script:DirectionList -Content "- $script:DirectionSource`n" | Out-Null
$script:BackupDirectionRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir
QuietTool = $true
}
# 行首 +:仅备份,恢复端应当跳过它,同样要登记归档名
Remove-Item -LiteralPath $script:DirectionSource -Recurse -Force
Write-ListFile -Path $script:DirectionList -Content "+ $script:DirectionSource`n" | Out-Null
$script:RestoreDirectionRun = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir
Force = $true
Verbose = $true
}
Remove-Item -LiteralPath (Join-Path $script:DirectionBackupDir 'Stray.7z') -Force -ErrorAction SilentlyContinue
}
It '行首 - 的条目:备份跳过它,但仍把它算作"有主"(不报成孤儿)' {
$script:BackupDirectionRun.ExitCode | Should -Be 0
$script:BackupDirectionRun.Output | Should -Match '行首 -'
# 对照组先在:真孤儿被点名;而 `-` 条目对应的归档没有被点名
$script:BackupDirectionRun.Output | Should -Match 'Stray\.7z'
$script:BackupDirectionRun.Output | Should -Not -Match ('- ' + [regex]::Escape($script:DirectionArchiveName) + '\.7z')
Test-Path -LiteralPath (Join-Path $script:DirectionBackupDir "$script:DirectionArchiveName.7z") | Should -BeTrue
}
It '行首 + 的条目:恢复跳过它、不写回目标,同时登记归档名' {
$script:RestoreDirectionRun.ExitCode | Should -Be 0
$script:RestoreDirectionRun.Output | Should -Match '行首 \+'
Test-Path -LiteralPath $script:DirectionSource | Should -BeFalse
$script:RestoreDirectionRun.Output | Should -Match 'Stray\.7z'
$script:RestoreDirectionRun.Output | Should -Not -Match ('- ' + [regex]::Escape($script:DirectionArchiveName) + '\.7z')
}
}