Files
BakNRet/tests/Run-Tests.ps1
T
Shuery 8736bb2c67 fix: 行首方向标记贴在目标上时失效(真实清单 29 条里 24 条被静默跳过)
量到的事实:用真实清单只读干跑,28 个条目里 27 个被当成"源不存在"跳过、退出码 0,
只有不写方向标记的 Scoop 真的被备份 —— 磁盘上最新那份归档正是 Scoop.7z(4.6 GB / 09-24)。
修复后同一批条目:29 条全部分解出方向(26 仅备份 / 2 仅恢复 / 1 双向),零个标记残留。

原因:解析器只认"独立记号"形态的方向标记(+ Name),而清单里 24 条贴在目标上
(+WindowsTerminal)。后者被当成一个名叫 +WindowsTerminal 的软件名,名录里查不到就退回
当目录名,目录又不存在 → 静默记成 missing-source。它隐形的理由是两件本身正确的设计叠在
一起:"源不存在只算跳过不算失败" 加上两种写法只差一个空格。

README 的方向标记表格写的是"行首 + / -",并没有要求标记后面跟空格;要求带空白的是
修饰符(:: / :- / :+ / @)那一节。所以让解析器接受行首贴在一起的形态,而不是去改清单。
只放宽"行首"这一个位置:记号中间的 + / - 仍是普通字符(C:\a:-b 那条断言继续盯着)。

新增 tests\Run-RealSmoke.ps1:真实清单 + 真实归档上的只读冒烟。它存在的理由就是这个
缺陷 —— 夹具测试全绿,只有拿真实清单跑才看得见。它检查四件事:方向标记全部被剥掉、没有
软件名退化成"名录里没有"、两个只读模式退出 0、manifest.json 的 SHA256 前后不变。

验收:test.ps1 9/9 全绿(7 与 5.1);tests\Run-RealSmoke.ps1 4/4 全绿。
2026-09-26 22:26:45 +08:00

1178 lines
57 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 测试套件(零依赖,Windows PowerShell 5.1 与 PowerShell 7.x 均可运行)。
.DESCRIPTION
为什么不用 Pester:本机只装了 Pester 3.4.0,`Should -Be` 这类 Pester 4+ 语法
直接语法错误,而为了跑测试去装 Pester 5 不值得。这里自带一个极简断言运行器,
对备份工具这种小脚本来说更可靠。
覆盖范围(与重构后的 BackupList / SoftwareCatalog 契约一致):
* BackupList.txt 行语法:方向标记、`::` 路径覆盖、`:-` / `:+`、`:encrypt`、
`@ Key='Value'` 覆盖、历史写法、行尾 `# 说明`、记号边界
* 软件名录新结构(`软件名 -> Slot -> @{ Path = ... }`):Slot 排序、前缀补全、
多候选报错、`%VAR%` / `$( ... )` 展开、缓存与 -NoCache
* 归档命名与逆向解析(软件名 / 字面路径 / @pathname)
* 归档项与归档内布局(New-BaknretArchiveItem / 暂存 junction / 硬链接)
* 7z 排除参数翻译(!前缀、!re: 正则展开、Slot 前缀分配、去重)
* Windows 命令行拼接
* manifest 读写往返与 layouts
* 配置默认值与嵌套合并
* 集成测试:真的调用 7z 打包 + 解压,验证排除结果落在文件系统上
.EXAMPLE
pwsh -File .\tests\Run-Tests.ps1
#>
[CmdletBinding()]
param(
[string]$ModulePath
)
$ErrorActionPreference = 'Stop'
# 默认值不能写在 param() 里:Windows PowerShell 5.1 在带 [CmdletBinding()] 的脚本上,
# 参数绑定阶段还没有给 $PSScriptRoot 赋值,默认值表达式会拿到空串(实测:带
# [CmdletBinding()] -> 空串,不带 -> 正常;PowerShell 7 两种都正常)。所以默认值
# 一律在这里补 —— 这也是本仓库对 -BackupDir / -ConfigPath 一直在用的写法。
if (-not $ModulePath) { $ModulePath = Join-Path (Split-Path -Parent $PSScriptRoot) 'Common.psm1' }
Import-Module $ModulePath -Force
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
Reset-TestResult
# ============================================================================
Write-Host "`n== BackupList 解析 ==" -ForegroundColor Cyan
# ============================================================================
Test-Case '注释与空行返回 $null' {
Assert-Null (ConvertFrom-BackupListLine -Line '# 这是注释')
Assert-Null (ConvertFrom-BackupListLine -Line ' ')
Assert-Null (ConvertFrom-BackupListLine -Line '')
}
Test-Case '只有方向标记没有目标时返回 $null(不抛异常)' {
Assert-Null (ConvertFrom-BackupListLine -Line '+')
Assert-Null (ConvertFrom-BackupListLine -Line '-')
}
Test-Case '只有修饰符没有目标时返回 $null' {
Assert-Null (ConvertFrom-BackupListLine -Line '::')
Assert-Null (ConvertFrom-BackupListLine -Line ':- x')
}
Test-Case '裸路径:方向 both、按字面路径处理、没有任何覆盖' {
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
Assert-Equal 'C:\Programs\FooClolor' $r.Path
Assert-Equal 'both' $r.Direction
Assert-False $r.IsName
Assert-Equal 0 $r.Overrides.Keys.Count
Assert-Equal 0 $r.ExcludePatterns.Count
Assert-Equal 0 $r.Flags.Count
Assert-Equal 0 $r.UnknownKeys.Count
}
Test-Case '行首 + 表示仅备份' {
$r = ConvertFrom-BackupListLine -Line '+ C:\Programs\FooClolor :- logs\'
Assert-Equal 'backup' $r.Direction
Assert-Equal 'C:\Programs\FooClolor' $r.Path
Assert-Equal 1 $r.ExcludePatterns.Count
}
Test-Case '行首 - 表示仅恢复' {
$r = ConvertFrom-BackupListLine -Line '- C:\Programs\FooClolor'
Assert-Equal 'restore' $r.Direction
Assert-Equal 'C:\Programs\FooClolor' $r.Path
}
Test-Case '行首标记贴在目标上也算(+Name / -Path),且标记不进入目标' {
# 这是本仓库清单里的主流写法,也是曾经被静默忽略的那种:标记与目标之间没有空格。
# 过去只认独立记号,于是 `+WindowsTerminal` 被当成"名叫 +WindowsTerminal 的软件名",
# 名录查不到、目录不存在,整条静默记成 missing-source 跳过,而备份仍然退出 0。
$attached = ConvertFrom-BackupListLine -Line '+FooClolor'
Assert-Equal 'backup' $attached.Direction
Assert-Equal 'FooClolor' $attached.Path
Assert-True $attached.IsName
$restoreAttached = ConvertFrom-BackupListLine -Line '-C:\Programs\Foo'
Assert-Equal 'restore' $restoreAttached.Direction
Assert-Equal 'C:\Programs\Foo' $restoreAttached.Path
Assert-False $restoreAttached.IsName
# 贴在一起的写法照样能带修饰符
$withOverride = ConvertFrom-BackupListLine -Line '+FooClolor @ Encrypt=''$false'''
Assert-Equal 'backup' $withOverride.Direction
Assert-Equal 'FooClolor' $withOverride.Path
Assert-False $withOverride.Overrides['Encrypt']
}
Test-Case '非行首的 + / - 不是方向标记' {
# 只放宽"行首"这一个位置:记号中间的 + / - 仍然只是普通字符,
# 否则 C:\a:-b 这类路径会被切坏(这条一直有断言盯着)。
$plusInside = ConvertFrom-BackupListLine -Line 'C:\a+b'
Assert-Equal 'both' $plusInside.Direction
Assert-Equal 'C:\a+b' $plusInside.Path
$dashInside = ConvertFrom-BackupListLine -Line 'foo-bar'
Assert-Equal 'both' $dashInside.Direction
Assert-Equal 'foo-bar' $dashInside.Path
}
Test-Case '没有方向标记时是 both(同时备份与恢复)' {
Assert-Equal 'both' (ConvertFrom-BackupListLine -Line 'C:\x').Direction
}
Test-Case ':- 的逗号分隔排除表被拆成多个模式' {
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\March7thAssistant :- a\b,c\d\'
Assert-Equal 'C:\Programs\March7thAssistant' $r.Path
Assert-Equal 2 $r.ExcludePatterns.Count
Assert-Equal 'a\b' $r.ExcludePatterns[0]
Assert-Equal 'c\d\' $r.ExcludePatterns[1]
Assert-True $r.Overrides.ContainsKey('Exclude')
}
Test-Case ':- 的分号分隔同样支持' {
$r = ConvertFrom-BackupListLine -Line 'C:\x :- a;b'
Assert-Equal 2 $r.ExcludePatterns.Count
}
Test-Case '引号只包路径时排除表正常解析' {
$r = ConvertFrom-BackupListLine -Line '"C:\Program Files\App" :- cache\*,logs\'
Assert-Equal 'C:\Program Files\App' $r.Path
Assert-Equal 2 $r.ExcludePatterns.Count
Assert-Equal 'cache\*' $r.ExcludePatterns[0]
}
Test-Case '模式值本身可以带引号(真实清单里的 :- ''a,b'' 写法)' {
$r = ConvertFrom-BackupListLine -Line "C:\x :- 'a,b'"
Assert-Equal 2 $r.ExcludePatterns.Count
Assert-Equal 'a' $r.ExcludePatterns[0]
Assert-Equal 'b' $r.ExcludePatterns[1]
}
Test-Case '整行被一对引号包住时是一个记号:引号内的 :: / :- 不再是指令' {
$line = '"C:\Programs\ScoopApps\persist :- persist\a\DawnCache,persist\a\GPUCache"'
$r = ConvertFrom-BackupListLine -Line $line
Assert-Equal 'C:\Programs\ScoopApps\persist :- persist\a\DawnCache,persist\a\GPUCache' $r.Path
Assert-Equal 0 $r.ExcludePatterns.Count
Assert-Equal 0 $r.Overrides.Keys.Count
}
Test-Case ':: 现在表示覆盖 Path,而不是 :- 的历史别名' {
$r = ConvertFrom-BackupListLine -Line 'MyApp :: C:\overridden'
Assert-Equal 'MyApp' $r.Path
Assert-True $r.Overrides.ContainsKey('Path') '必须记下 Path 覆盖'
Assert-Equal 'C:\overridden' $r.Overrides['Path']
Assert-False $r.Overrides.ContainsKey('Exclude') ':: 不能再被当成排除'
Assert-Equal 0 $r.ExcludePatterns.Count
}
Test-Case ':- 与 @ Exclude= 走同一条路(都是排除)' {
$a = ConvertFrom-BackupListLine -Line 'X :- logs\'
$b = ConvertFrom-BackupListLine -Line "X @ Exclude='logs\'"
Assert-Equal 'logs\' $a.ExcludePatterns[0]
Assert-Equal 'logs\' $b.ExcludePatterns[0]
}
Test-Case ':+ 追加包含项:一个记号可以带多个逗号分隔的值' {
$r = ConvertFrom-BackupListLine -Line 'MyApp :+ ModA:D:\a,ModB:D:\b'
Assert-Equal 'MyApp' $r.Path
Assert-True $r.Overrides.ContainsKey('Include')
Assert-Equal 2 $r.Includes.Count
Assert-Equal 'ModA:D:\a' $r.Includes[0]
Assert-Equal 'ModB:D:\b' $r.Includes[1]
Assert-Equal 0 $r.ExcludePatterns.Count
}
Test-Case ':- 与 :+ 混用且顺序任意(每个记号带自己的值表)' {
$r = ConvertFrom-BackupListLine -Line 'MyApp :- logs\,!*Cache :+ Mod:D:\a'
Assert-Equal 'MyApp' $r.Path
Assert-Equal 1 $r.Includes.Count
Assert-Equal 'Mod:D:\a' $r.Includes[0]
Assert-Equal 2 $r.ExcludePatterns.Count
Assert-Equal 'logs\' $r.ExcludePatterns[0]
Assert-Equal '!*Cache' $r.ExcludePatterns[1]
}
Test-Case '同一行重复写 :- / :+ 时累积(不静默丢掉前一条规则)' {
$r = ConvertFrom-BackupListLine -Line 'MyApp :- logs\ :+ D:\a :- !*Cache'
Assert-Equal 2 $r.ExcludePatterns.Count
Assert-Equal 'logs\' $r.ExcludePatterns[0]
Assert-Equal '!*Cache' $r.ExcludePatterns[1]
Assert-Equal 1 $r.Includes.Count
Assert-Equal 'D:\a' $r.Includes[0]
}
Test-Case ':encrypt / :!encrypt 直接给出布尔覆盖' {
$on = ConvertFrom-BackupListLine -Line 'MyApp :encrypt'
Assert-True $on.Overrides.ContainsKey('Encrypt')
Assert-Equal $true $on.Overrides['Encrypt']
$off = ConvertFrom-BackupListLine -Line 'MyApp :!encrypt'
Assert-Equal $false $off.Overrides['Encrypt']
}
Test-Case '@ Key=''Value'' 覆盖:Exclude / Include / Encrypt' {
$r = ConvertFrom-BackupListLine -Line "App @ Exclude='a,b' @ Include='Mod:D:\m' @ Encrypt='`$false'"
Assert-Equal 2 $r.ExcludePatterns.Count
Assert-Equal 1 $r.Includes.Count
Assert-Equal 'Mod:D:\m' $r.Includes[0]
Assert-Equal $false $r.Overrides['Encrypt']
}
Test-Case '@ Encrypt=''yes'' 之类的真值也能被识别' {
foreach ($truthy in '$true', 'true', '1', 'yes', 'on') {
$r = ConvertFrom-BackupListLine -Line "App @ Encrypt='$truthy'"
Assert-Equal $true $r.Overrides['Encrypt'] "值 $truthy 应视为真"
}
foreach ($falsy in '$false', 'false', '0', 'no', 'off') {
$r = ConvertFrom-BackupListLine -Line "App @ Encrypt='$falsy'"
Assert-Equal $false $r.Overrides['Encrypt'] "值 $falsy 应视为假"
}
}
Test-Case '未知的 @ 字段被记入 UnknownKeys 而不是静默吞掉' {
$r = ConvertFrom-BackupListLine -Line "App @ UnknownKey='v'"
Assert-Equal 1 $r.UnknownKeys.Count
Assert-Equal 'UnknownKey' $r.UnknownKeys[0]
Assert-Equal 0 $r.Overrides.Keys.Count
}
Test-Case '历史写法 @encrypt / @!encrypt 仍然被识别成加密覆盖' {
$on = ConvertFrom-BackupListLine -Line '%UserProfile%\.ssh @encrypt'
Assert-Equal '%UserProfile%\.ssh' $on.Path
Assert-True $on.Overrides.ContainsKey('Encrypt')
Assert-Equal $true $on.Overrides['Encrypt']
Assert-Equal 0 $on.Flags.Count '不再作为无意义的 Flags 传下去'
$off = ConvertFrom-BackupListLine -Line '%UserProfile%\.ssh @!encrypt'
Assert-Equal $false $off.Overrides['Encrypt']
}
Test-Case '历史写法 @pathname / @root=<名> 被记入 Flags' {
Assert-Equal 'pathname' (ConvertFrom-BackupListLine -Line 'App @pathname').Flags[0]
Assert-Equal 'root=xyz' (ConvertFrom-BackupListLine -Line 'App @root=xyz').Flags[0]
}
Test-Case '@ 标记跟在排除表后面' {
$r = ConvertFrom-BackupListLine -Line 'C:\x :- a,b @encrypt'
Assert-Equal 'C:\x' $r.Path
Assert-Equal 2 $r.ExcludePatterns.Count
Assert-Equal $true $r.Overrides['Encrypt']
}
Test-Case '盘符里的单个冒号不被误当分隔符' {
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\Foo'
Assert-Equal 'C:\Programs\Foo' $r.Path
Assert-Equal 0 $r.Includes.Count
Assert-Equal 0 $r.ExcludePatterns.Count
}
Test-Case '标记必须是独立记号:C:\a:-b 仍然是一个路径' {
$r = ConvertFrom-BackupListLine -Line 'C:\a:-b'
Assert-Equal 'C:\a:-b' $r.Path
Assert-Equal 0 $r.Overrides.Keys.Count
Assert-Null (Test-BaknretMarker -Token 'C:\a:-b')
Assert-Null (Test-BaknretMarker -Token ':+X')
}
Test-Case 'Test-BaknretMarker 识别全部标记(大小写敏感)' {
Assert-Equal 'path' (Test-BaknretMarker -Token '::')
Assert-Equal 'exclude' (Test-BaknretMarker -Token ':-')
Assert-Equal 'include' (Test-BaknretMarker -Token ':+')
Assert-Equal 'encrypt' (Test-BaknretMarker -Token ':encrypt')
Assert-Equal 'noencrypt' (Test-BaknretMarker -Token ':!encrypt')
Assert-Equal 'at' (Test-BaknretMarker -Token '@x')
Assert-Equal 'path' (Test-BaknretMarker -Token ' :: ')
}
Test-Case '记号里有空格时目标可以带空格(第一个修饰符之前全是目标)' {
$r = ConvertFrom-BackupListLine -Line 'My App With Spaces :- logs\'
Assert-Equal 'My App With Spaces' $r.Path
Assert-Equal 1 $r.ExcludePatterns.Count
}
Test-Case '行尾 # 说明会被摘出来' {
$r = ConvertFrom-BackupListLine -Line 'App :- a\b # 这是说明'
Assert-Equal 'App' $r.Path
Assert-Equal '这是说明' $r.Comment
Assert-Equal 'a\b' $r.ExcludePatterns[0]
}
Test-Case '路径里紧贴的 # 不会被当成注释' {
$r = ConvertFrom-BackupListLine -Line 'C:\p\a#b'
Assert-Equal 'C:\p\a#b' $r.Path
Assert-Null $r.Comment
}
Test-Case '没有说明时 Comment 为 $null' {
Assert-Null (ConvertFrom-BackupListLine -Line 'App').Comment
}
Test-Case 'Split-BaknretToken:引号内的空白不切分,引号保留' {
$tokens = @(Split-BaknretToken -Text 'a "b c" d :- ''e,f''')
Assert-Equal 5 $tokens.Count
Assert-Equal 'a' $tokens[0]
Assert-Equal '"b c"' $tokens[1]
Assert-Equal 'd' $tokens[2]
Assert-Equal ':-' $tokens[3]
Assert-Equal "'e,f'" $tokens[4]
}
Test-Case 'Remove-BaknretQuote:成对引号去掉,不成对原样返回' {
Assert-Equal 'a' (Remove-BaknretQuote -Text "'a'")
Assert-Equal 'a' (Remove-BaknretQuote -Text '"a"')
Assert-Equal '"a' (Remove-BaknretQuote -Text '"a')
Assert-Equal 'plain' (Remove-BaknretQuote -Text 'plain')
}
Test-Case 'Test-LiteralPath:带分隔符或 % 的按路径,其余按软件名' {
Assert-True (Test-LiteralPath -Path 'C:\a')
Assert-True (Test-LiteralPath -Path 'C:/a')
Assert-True (Test-LiteralPath -Path '%UserProfile%\.ssh')
Assert-False (Test-LiteralPath -Path 'MyApp')
Assert-True (Test-LiteralPath -Path '')
}
# ============================================================================
Write-Host "`n== 归档命名 ==" -ForegroundColor Cyan
# ============================================================================
Test-Case '基础命名规则' {
Assert-Equal 'legendary_from_%UserProfile%+.config' (Get-BackupBaseName -RawPath '%UserProfile%/.config/legendary')
Assert-Equal 'FooClolor_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\FooClolor')
Assert-Equal 'settings.json_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\settings.json')
}
Test-Case '盘符冒号被归一化成下划线' {
Assert-Equal 'a_from_C_' (Get-BackupBaseName -RawPath 'C:\a')
}
Test-Case '/ 与 \ 以及重复分隔符结果一致' {
$a = Get-BackupBaseName -RawPath '%UserProfile%/.config/scoop'
$b = Get-BackupBaseName -RawPath '%UserProfile%\.config\scoop'
$c = Get-BackupBaseName -RawPath '%UserProfile%\\Documents\\WindowsPowerShell'
Assert-Equal $a $b
Assert-Equal 'WindowsPowerShell_from_%UserProfile%+Documents' $c
}
Test-Case '命名 <-> 路径往返' {
$cases = @(
'%UserProfile%/.config/legendary',
'C:\Programs\FooClolor',
'%LocalAppData%\Microsoft\Edge\User Data'
)
foreach ($case in $cases) {
$base = Get-BackupBaseName -RawPath $case
$back = Convert-BackupFileNameToPath -FileName "$base.7z"
Assert-Equal ($case -replace '[/\\]+', '\') $back "往返 $case"
}
}
Test-Case '归档名里不含非法文件名字符(保留 & % +)' {
$base = Get-BackupBaseName -RawPath 'C:\a\b?c*d|e'
$invalid = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') }
foreach ($ch in $invalid) {
Assert-False ($base.Contains([string]$ch)) "归档名里出现了非法字符 $ch"
}
Assert-Equal 'keep%and&and+plus' (Format-CatalogName -Name 'keep%and&and+plus')
}
Test-Case 'Get-BaknretArchiveTopName:取归档内相对路径的第一段' {
Assert-Equal 'Slot' (Get-BaknretArchiveTopName -ArchivePath 'Slot\a\b')
Assert-Equal 'Slot' (Get-BaknretArchiveTopName -ArchivePath '\Slot\')
Assert-Equal 'leaf' (Get-BaknretArchiveTopName -ArchivePath 'leaf')
Assert-Equal '' (Get-BaknretArchiveTopName -ArchivePath ' ')
}
# ============================================================================
Write-Host "`n== 归档项构造与暂存 ==" -ForegroundColor Cyan
# ============================================================================
$itemSandbox = Join-Path $env:TEMP ("baknret-item-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
Test-Case 'New-BaknretArchiveItem:归档内路径被清理,TopName 是第一段' {
$item = New-BaknretArchiveItem -ArchivePath '\AlphaData\' -RealPath 'C:\x'
Assert-Equal 'AlphaData' $item.ArchivePath
Assert-Equal 'AlphaData' $item.TopName
Assert-Equal 'slot' $item.Kind
Assert-Equal 'catalog' $item.Origin
Assert-False $item.Exists
Assert-False $item.IsFile
Assert-Equal 0 $item.Exclude.Count
$nested = New-BaknretArchiveItem -ArchivePath 'Alpha\sub' -RealPath 'C:\y' -Kind 'include' -Origin 'include'
Assert-Equal 'Alpha' $nested.TopName
Assert-Equal 'include' $nested.Kind
}
Test-Case '归档项的属性集与契约一致(没有旧的 Sources / Dirs / Variants)' {
$item = New-BaknretArchiveItem -ArchivePath 'A' -RealPath 'C:\a'
foreach ($expected in 'ArchivePath', 'TopName', 'RealPath', 'Kind', 'Slot', 'Description', 'Origin', 'Exists', 'IsFile', 'Exclude') {
Assert-True ($item.PSObject.Properties.Name -contains $expected) "缺少属性 $expected"
}
foreach ($gone in 'Sources', 'Dirs', 'Variants', 'ResolvedPath', 'RootName') {
Assert-False ($item.PSObject.Properties.Name -contains $gone) "不该再有旧属性 $gone"
}
}
Test-Case 'New-BaknretArchiveStaging:目录走 junction、文件走硬链接,按归档内名字挂载' {
$root = Join-Path $itemSandbox 'stage-src'
New-Item -ItemType Directory -Path (Join-Path $root 'App') -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $root 'App\f.txt') -Value 'x'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $root 'settings.json') -Value '{}'
$items = @(
(New-BaknretArchiveItem -ArchivePath 'AlphaData' -RealPath (Join-Path $root 'App') -Exists $true),
(New-BaknretArchiveItem -ArchivePath 'BetaFile' -RealPath (Join-Path $root 'settings.json') -Exists $true -IsFile $true)
)
$staging = New-BaknretArchiveStaging -Items $items -Root (Join-Path $itemSandbox 'stage')
try {
$dirLink = Get-Item -LiteralPath (Join-Path $staging 'AlphaData') -Force
$fileLink = Get-Item -LiteralPath (Join-Path $staging 'BetaFile') -Force
Assert-Equal 'Junction' $dirLink.LinkType
Assert-Equal 'HardLink' $fileLink.LinkType
Assert-True (Test-Path -LiteralPath (Join-Path $staging 'AlphaData\f.txt')) '应能穿过 junction 看到内容'
} finally {
Remove-BaknretArchiveStaging -Root $staging
}
Assert-False (Test-Path -LiteralPath $staging) '暂存目录应被清理'
Assert-True (Test-Path -LiteralPath (Join-Path $root 'App\f.txt')) '清理暂存绝不能删到真实数据'
}
Test-Case 'New-BaknretJunction / Remove-BaknretJunction:只删连接点,不动目标' {
$target = Join-Path $itemSandbox 'j-target'
$link = Join-Path $itemSandbox 'j-link'
New-Item -ItemType Directory -Path $target -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $target 'keep.txt') -Value 'keep'
New-BaknretJunction -Path $link -Target $target | Out-Null
Assert-Equal 'Junction' (Get-Item -LiteralPath $link -Force).LinkType
Remove-BaknretJunction -Path $link
Assert-False (Test-Path -LiteralPath $link)
Assert-True (Test-Path -LiteralPath (Join-Path $target 'keep.txt')) '目标内容必须留着'
try {
New-BaknretJunction -Path $link -Target $target | Out-Null
Remove-BaknretJunction -Path $link
New-BaknretJunction -Path $link -Target $target | Out-Null
Assert-True $true
} finally {
Remove-BaknretJunction -Path $link
}
}
Test-Case 'New-BaknretJunction:目标已存在时明确抛错' {
$target = Join-Path $itemSandbox 'j2-target'
$link = Join-Path $itemSandbox 'j2-link'
New-Item -ItemType Directory -Path $target -Force | Out-Null
New-Item -ItemType Directory -Path $link -Force | Out-Null
$threw = $false
try { New-BaknretJunction -Path $link -Target $target | Out-Null } catch { $threw = $true }
Assert-True $threw '同名实体存在时必须抛错,不能静默替换'
}
# ============================================================================
Write-Host "`n== 7z 排除参数翻译 ==" -ForegroundColor Cyan
# ============================================================================
Test-Case '旧的 Get-ArchiveExcludeArgument 已经不存在(契约破坏点)' {
Assert-Null (Get-Command Get-ArchiveExcludeArgument -ErrorAction SilentlyContinue)
}
Test-Case '相对模式自动补上归档项名(锚定在归档根)' {
$item = New-BaknretArchiveItem -ArchivePath 'March7thAssistant' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('logs\')
Assert-Equal 1 $exclude.Arguments.Count
Assert-Equal '-x!March7thAssistant\logs' $exclude.Arguments[0]
Assert-Null $exclude.Error
}
Test-Case '模式里再写一遍归档项名不会被当成前缀剥掉(新语义:总是拼在项名之后)' {
$item = New-BaknretArchiveItem -ArchivePath 'March7thAssistant' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('March7thAssistant\logs\')
Assert-Equal '-x!March7thAssistant\March7thAssistant\logs' $exclude.Arguments[0]
}
Test-Case '! 前缀翻译成任意层级匹配' {
$item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!*Cache')
Assert-Equal '-xr!*Cache' $exclude.Arguments[0]
}
Test-Case '模式里的空格转成 ? (7z 的模式不支持空格)' {
$item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('Default\Code Cache')
Assert-Equal '-x!UserData\Default\Code?Cache' $exclude.Arguments[0]
}
Test-Case '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
$item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x'
$patterns = @('Default\Code Cache', '!*Cache', 'logs\', 'UserData\3rdparty\WebBrowser\UserProfile\Integrated')
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns $patterns
Assert-Equal 4 $exclude.Arguments.Count
foreach ($argument in $exclude.Arguments) {
Assert-False ($argument.Contains('"')) "参数里出现了引号: $argument"
}
}
Test-Case '空模式被忽略' {
$item = New-BaknretArchiveItem -ArchivePath 'x' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('', ' ', 'real\')
Assert-Equal 1 $exclude.Arguments.Count
Assert-Equal '-x!x\real' $exclude.Arguments[0]
}
Test-Case '!re:<正则> 展开成源目录里命中的精确路径' {
$root = Join-Path $itemSandbox 'regex-src'
foreach ($dir in 'Cache1', 'Cache2', 'Keep', 'sub\Cache3') {
New-Item -ItemType Directory -Path (Join-Path $root $dir) -Force | Out-Null
}
$item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^Cache\d+$')
Assert-Null $exclude.Error
$sorted = @($exclude.Arguments | Sort-Object)
Assert-Equal 3 $sorted.Count
Assert-Equal '-x!Data\Cache1' $sorted[0]
Assert-Equal '-x!Data\Cache2' $sorted[1]
Assert-Equal '-x!Data\sub\Cache3' $sorted[2]
}
Test-Case '非法的 !re: 正则给出明确的 Error,而不是静默漏排除' {
$root = Join-Path $itemSandbox 'regex-src'
$item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:*Cache')
Assert-True ($null -ne $exclude.Error)
Assert-Equal 0 $exclude.Arguments.Count
}
Test-Case '空的 !re: 被忽略' {
$root = Join-Path $itemSandbox 'regex-src'
$item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:')
Assert-Equal 0 $exclude.Arguments.Count
Assert-Null $exclude.Error
}
Test-Case '排除参数超过命令行安全长度时明确报错' {
$root = Join-Path $itemSandbox 'regex-src'
$item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('aaaa\bbbb') -MaxCommandLineChars 5
Assert-True ($null -ne $exclude.Error)
}
Test-Case 'Split-BaknretPatternScope:`<项名>\` 前缀把模式分配给对应归档项' {
$items = @(
(New-BaknretArchiveItem -ArchivePath 'DefaultConfig' -RealPath 'C:\c'),
(New-BaknretArchiveItem -ArchivePath 'GlobalPersist' -RealPath 'C:\g'),
(New-BaknretArchiveItem -ArchivePath 'UserPersist' -RealPath 'C:\u')
)
$map = Split-BaknretPatternScope -Items $items -Patterns @('GlobalPersist\steam\steamapps', 'plain\one', '!*Cache')
Assert-Equal 'steam\steamapps' $map[1][0]
Assert-Equal 'plain\one' $map[0][0] '没点名任何项的模式对每一项各展开一份'
Assert-Equal 'plain\one' $map[2][0]
foreach ($index in 0, 1, 2) {
Assert-True ($map[$index] -contains '!*Cache') "! 开头的模式应广播到第 $index 项"
}
}
Test-Case 'Merge-BaknretExcludeArgument:去重且保序' {
$merged = Merge-BaknretExcludeArgument -ArgumentLists @(@('-x!a', '-xr!c'), @('-x!a', '-x!b'))
Assert-Equal 3 $merged.Count
Assert-Equal '-x!a' $merged[0]
Assert-Equal '-xr!c' $merged[1]
Assert-Equal '-x!b' $merged[2]
}
# ============================================================================
Write-Host "`n== 命令行拼接 ==" -ForegroundColor Cyan
# ============================================================================
Test-Case '无空格参数原样输出' {
Assert-Equal 'a -t7z' (ConvertTo-NativeArgumentString -ArgumentList @('a', '-t7z'))
}
Test-Case '含空格参数加引号' {
Assert-Equal '"C:\Program Files\x"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\x'))
}
Test-Case '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
Assert-Equal '"C:\Program Files\\"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\'))
}
Test-Case '内部引号被转义' {
Assert-Equal '"say \"hi\""' (ConvertTo-NativeArgumentString -ArgumentList @('say "hi"'))
}
Test-Case '空参数输出一对空引号' {
Assert-Equal '""' (ConvertTo-NativeArgumentString -ArgumentList @(''))
}
# ============================================================================
Write-Host "`n== manifest 与配置 ==" -ForegroundColor Cyan
# ============================================================================
$sandbox = Join-Path $env:TEMP ("baknret-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
Test-Case 'manifest 读写往返(含 layouts)' {
New-Item -ItemType Directory -Path $sandbox -Force | Out-Null
$path = Join-Path $sandbox 'manifest.json'
$m = Read-BaknretManifest -Path $path
Assert-Equal 0 $m.items.Count
$m.compressor = [pscustomobject]@{ name = '7z'; version = '26.03' }
$m.items['my-app'] = [ordered]@{
baseName = 'my-app'
action = 'backed-up'
roots = @('AlphaData', 'BetaFile')
layouts = @(
[ordered]@{ name = 'AlphaData'; kind = 'dir' },
[ordered]@{ name = 'BetaFile'; kind = 'file' }
)
}
Write-BaknretManifest -Path $path -Manifest $m | Out-Null
$again = Read-BaknretManifest -Path $path
Assert-Equal 1 $again.items.Count
Assert-Equal 'backed-up' $again.items['my-app'].action
Assert-Equal 2 $again.items['my-app'].layouts.Count
Assert-Equal 'BetaFile' $again.items['my-app'].layouts[1].name
Assert-Equal 'file' $again.items['my-app'].layouts[1].kind
$bytes = [System.IO.File]::ReadAllBytes($path)
Assert-False (($bytes[0] -eq 0xEF) -and ($bytes[1] -eq 0xBB)) 'manifest 不应带 BOM'
}
Test-Case 'manifest 损坏时不抛异常' {
$path = Join-Path $sandbox 'broken.json'
[System.IO.File]::WriteAllText($path, '{ this is not json')
$m = Read-BaknretManifest -Path $path
Assert-Equal 0 $m.items.Count
}
Test-Case '配置缺失时返回默认值' {
$config = Get-BaknretConfig -Path (Join-Path $sandbox 'nope.psd1')
Assert-Equal 'Backups' $config.BackupDir
Assert-True ($config.MinFreeSpaceGB -gt 0)
}
Test-Case '配置嵌套段落合并且不丢默认键' {
$path = Join-Path $sandbox 'cfg.psd1'
Set-Content -Encoding UTF8 -LiteralPath $path -Value "@{ Encryption = @{ Enabled = `$true }; CompressionLevel = 5 }"
$config = Get-BaknretConfig -Path $path
Assert-Equal 5 $config.CompressionLevel
Assert-True $config.Encryption.Enabled
Assert-True $config.Encryption.EncryptHeaders '未覆盖的嵌套键应保留默认值'
}
Test-Case '口令:环境变量可读取,取不到返回 $null' {
$old = $env:BAKNRET_PASSWORD
try {
Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue
Assert-Null (Get-BaknretPassword -PasswordFile (Join-Path $sandbox 'nope'))
$env:BAKNRET_PASSWORD = 'from-env'
Assert-Equal 'from-env' (Get-BaknretPassword)
} finally {
Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue
if ($old) { $env:BAKNRET_PASSWORD = $old }
}
}
# ============================================================================
Write-Host "`n== 软件名录(新 Slot 结构) ==" -ForegroundColor Cyan
# ============================================================================
$catalogSandbox = Join-Path $env:TEMP ("baknret-cat-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path (Join-Path $catalogSandbox 'App') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $catalogSandbox 'Config') -Force | Out-Null
$appDir = Join-Path $catalogSandbox 'App'
$configDir = Join-Path $catalogSandbox 'Config'
Test-Case '名录解析:裸键、引号键、带 - 与 . 的名字' {
$catPath = Join-Path $catalogSandbox 'SoftwareCatalog.psd1'
$content = "@{`n FooClolor = @{ DefaultData = @{ Path = '$appDir' } }`n 'scoop-config' = @{ DefaultConfig = @{ Path = '$configDir' } }`n '.ssh' = @{ DefaultData = @{ Path = '$appDir' } }`n}`n"
[System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache
Assert-Equal 3 $catalog.Count
Assert-True $catalog.ContainsKey('FooClolor')
Assert-True $catalog.ContainsKey('scoop-config') '带连字符的键必须能解析'
Assert-True $catalog.ContainsKey('.ssh') '带点的键必须能解析'
Assert-Equal 'Single' $catalog['FooClolor'].Kind
}
Test-Case '名录:Slot 按名字排序(哈希表顺序不可依赖)' {
$catPath = Join-Path $catalogSandbox 'sorted.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n FooClolor = @{ Zeta = @{ Path = '$appDir' }; Alpha = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache
Assert-Equal 'Multi' $catalog['FooClolor'].Kind
Assert-Equal 2 $catalog['FooClolor'].Slots.Count
Assert-Equal 'Alpha' $catalog['FooClolor'].Slots[0].Name
Assert-Equal 'Zeta' $catalog['FooClolor'].Slots[1].Name
}
Test-Case '名录:Slot 对象字段与契约一致' {
$catPath = Join-Path $catalogSandbox 'fields.psd1'
$filePath = Join-Path $catalogSandbox 'App\settings.json'
Set-Content -Encoding UTF8 -LiteralPath $filePath -Value '{}'
[System.IO.File]::WriteAllText($catPath, "@{`n Rich = @{`n Beta = @{ Path = '$filePath'; Encrypt = `$true; Description = 'beta 说明' }`n Alpha = @{ Path = '$configDir'; Exclude = '!*Cache,sub\one'; Include = 'Modules:$appDir' }`n }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache
$entry = $catalog['Rich']
foreach ($expected in 'Name', 'Path', 'Description', 'Slots', 'Kind', 'Error', 'Missing', 'Raw') {
Assert-True ($entry.PSObject.Properties.Name -contains $expected) "名录条目缺少 $expected"
}
foreach ($gone in 'Dirs', 'Variants', 'ResolvedPath', 'RootName') {
Assert-False ($entry.PSObject.Properties.Name -contains $gone) "名录条目不该再有旧属性 $gone"
}
$beta = $entry.Slots[1]
foreach ($expected in 'Name', 'Declared', 'Resolved', 'Exists', 'IsFile', 'Suffixed', 'Description', 'Exclude', 'Include', 'Encrypt') {
Assert-True ($beta.PSObject.Properties.Name -contains $expected) "Slot 缺少 $expected"
}
Assert-Equal 'Beta' $beta.Name
Assert-Equal $filePath $beta.Resolved
Assert-True $beta.IsFile 'Path 指向文件时 IsFile 必须为真'
Assert-True $beta.Encrypt
Assert-Equal 'beta 说明' $beta.Description
$alpha = $entry.Slots[0]
Assert-False $alpha.IsFile
Assert-Equal 2 $alpha.Exclude.Count
Assert-Equal '!*Cache' $alpha.Exclude[0]
Assert-Equal 1 $alpha.Include.Count
Assert-Equal "Modules:$appDir" $alpha.Include[0]
}
Test-Case '名录解析:目录带版本后缀时按前缀补全(legendary_2.0.4)' {
$root = Join-Path $catalogSandbox 'probe'
New-Item -ItemType Directory -Path (Join-Path $root 'legendary_2.0.4') -Force | Out-Null
$catPath = Join-Path $catalogSandbox 'suffix.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n legendary = @{ DefaultConfig = @{ Path = '$root\legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache
Assert-Equal 'Single' $catalog['legendary'].Kind
Assert-True $catalog['legendary'].Slots[0].Resolved -like '*legendary_2.0.4'
Assert-True $catalog['legendary'].Slots[0].Suffixed '补全出来的路径要标记 Suffixed'
}
Test-Case '名录解析:不会把 Legendary 误配成 LegendarySomething' {
$root = Join-Path $catalogSandbox 'strict'
New-Item -ItemType Directory -Path (Join-Path $root 'LegendarySomething') -Force | Out-Null
$catPath = Join-Path $catalogSandbox 'strict.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n Legendary = @{ DefaultConfig = @{ Path = '$root\Legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache
Assert-Equal 'Unresolved' $catalog['Legendary'].Kind '必须以下一个字符是 _ 或 - 为界'
Assert-False $catalog['Legendary'].Slots[0].Exists
Assert-Equal 1 $catalog['Legendary'].Missing.Count
}
Test-Case 'Find-ChildDirectoryByName:_ / - 边界之外不补全' {
$root = Join-Path $catalogSandbox 'boundary'
New-Item -ItemType Directory -Path (Join-Path $root 'Legendary_1.0') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'Legendary-2.0') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'LegendarySomething') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'Unrelated') -Force | Out-Null
$found = @(Find-ChildDirectoryByName -Parent $root -Name 'Legendary')
Assert-Equal 2 $found.Count
Assert-True (@($found | Where-Object { $_ -like '*Legendary_1.0' }).Count -eq 1)
Assert-True (@($found | Where-Object { $_ -like '*Legendary-2.0' }).Count -eq 1)
}
Test-Case '名录:一个 Slot 补全出多个目录时明确报错(否则会混成一棵树)' {
$root = Join-Path $catalogSandbox 'multi-cand'
New-Item -ItemType Directory -Path (Join-Path $root 'Legendary_1') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'Legendary_2') -Force | Out-Null
$catPath = Join-Path $catalogSandbox 'multicand.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n Legendary = @{ DefaultConfig = @{ Path = '$root\Legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache
$entry = $catalog['Legendary']
Assert-True ($null -ne $entry.Error)
Assert-True ($entry.Error -like '*拆成多个 Slot*') '错误里要给出可操作的指引'
}
Test-Case '名录:Slot 写法不对 / 缺少 Path 都记成 Error,而不是崩掉' {
$bad = Join-Path $catalogSandbox 'bad.psd1'
[System.IO.File]::WriteAllText($bad, "@{`n A = @{ S = 'C:\x' }`n B = @{ S = @{ Description = 'x' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $bad -NoCache
Assert-Equal 'Invalid' $catalog['A'].Kind
Assert-True ($catalog['A'].Error -like '*Slot S*')
Assert-Equal 'Invalid' $catalog['B'].Kind
Assert-True ($catalog['B'].Error -like '*缺少 Path*')
}
Test-Case '名录:裸字符串 / 数组等旧结构会被 ERROR 跳过(不再当目录用)' {
$legacy = Join-Path $catalogSandbox 'legacy.psd1'
[System.IO.File]::WriteAllText($legacy, "@{`n A = 'C:\Programs\A'`n B = @('C:\1','C:\2')`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $legacy -NoCache
Assert-Equal 0 $catalog.Count '旧结构必须被跳过,不能猜成目录'
$dirs = Join-Path $catalogSandbox 'dirs.psd1'
[System.IO.File]::WriteAllText($dirs, "@{`n C = @{ Dirs = @('C:\1','C:\2') }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $dirs -NoCache
Assert-Equal 1 $catalog.Count
Assert-Equal 'Invalid' $catalog['C'].Kind
Assert-True ($catalog['C'].Error -like '*Dirs*')
}
Test-Case '名录:%VAR% 与 $( ... ) 子表达式都会被展开' {
Assert-Equal (Join-Path $env:TEMP 'x') (Expand-CatalogPathText -Text '%TEMP%\x')
Assert-Equal (Join-Path $env:TEMP 'sub') (Expand-CatalogPathText -Text '$(Join-Path $env:TEMP "sub")')
Assert-Equal '$(Join-Path' (Expand-CatalogPathText -Text '$(Join-Path') '括号不配对时原样保留,不抛异常'
}
Test-Case '名录:Includes 分文件维护会被合并' {
$extraPath = Join-Path $catalogSandbox 'Extra.psd1'
$basePath = Join-Path $catalogSandbox 'Base.psd1'
[System.IO.File]::WriteAllText($extraPath, "@{`n 'extra-app' = @{ D = @{ Path = '$appDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
[System.IO.File]::WriteAllText($basePath, "@{`n Includes = @('Extra.psd1')`n 'base-app' = @{ D = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $basePath -NoCache
Assert-True $catalog.ContainsKey('base-app')
Assert-True $catalog.ContainsKey('extra-app') 'Includes 引入的条目也应在'
}
Test-Case '名录:结果按文件指纹缓存,-NoCache 强制重读' {
$catPath = Join-Path $catalogSandbox 'cache.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n Cached = @{ D = @{ Path = '$appDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$first = Get-SoftwareCatalog -Path $catPath
$second = Get-SoftwareCatalog -Path $catPath
Assert-True ([object]::ReferenceEquals($first, $second)) '同一文件同一指纹应命中缓存'
$fresh = Get-SoftwareCatalog -Path $catPath -NoCache
Assert-False ([object]::ReferenceEquals($first, $fresh)) '-NoCache 必须重新读'
[System.IO.File]::WriteAllText($catPath, "@{`n Cached = @{ D = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$changed = Get-SoftwareCatalog -Path $catPath
Assert-Equal $configDir $changed['Cached'].Slots[0].Resolved '文件变了缓存必须失效'
}
# ============================================================================
Write-Host "`n== Resolve-BackupEntry(归档项解析) ==" -ForegroundColor Cyan
# ============================================================================
$resolveSandbox = Join-Path $env:TEMP ("baknret-resolve-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$dirA = Join-Path $resolveSandbox 'A'
$dirB = Join-Path $resolveSandbox 'B'
$dirExtra = Join-Path $resolveSandbox 'Extra'
foreach ($d in $dirA, $dirB, $dirExtra) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
$resolveCatalog = Join-Path $resolveSandbox 'catalog.psd1'
[System.IO.File]::WriteAllText($resolveCatalog, @"
@{
FooClolor = @{ DefaultData = @{ Path = '$dirA'; Encrypt = `$true } }
MultiApp = @{ Alpha = @{ Path = '$dirA' }; Beta = @{ Path = '$dirB' } }
}
"@, [System.Text.UTF8Encoding]::new($false))
Test-Case '软件名条目:一个 Slot -> 一个归档项,路径是 Slot 名,归档名是软件名' {
$item = ConvertFrom-BackupListLine -Line 'FooClolor'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-True $resolved.IsName
Assert-Equal 'name' $resolved.ArchiveFlavor
Assert-Equal 'FooClolor' $resolved.BaseName
Assert-Equal 'both' $resolved.Direction
Assert-Equal 1 $resolved.Items.Count
Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath
Assert-Equal 'DefaultData' $resolved.Items[0].TopName
Assert-Equal $dirA $resolved.Items[0].RealPath
Assert-Equal 'slot' $resolved.Items[0].Kind
Assert-Equal 'DefaultData' $resolved.Items[0].Slot
Assert-Equal 'catalog' $resolved.Items[0].Origin
Assert-True $resolved.Items[0].Exists
}
Test-Case 'Resolve-BackupEntry 的返回值里没有旧的 Sources / RootName 等属性' {
$item = ConvertFrom-BackupListLine -Line 'FooClolor'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
foreach ($expected in 'IsName', 'CatalogEntry', 'BaseName', 'ArchiveFlavor', 'Direction', 'Items', 'Encrypt', 'ExcludePatterns', 'HasExcludeOverride', 'Includes', 'HasIncludeOverride', 'Source', 'Error', 'Blocking') {
Assert-True ($resolved.PSObject.Properties.Name -contains $expected) "缺少属性 $expected"
}
foreach ($gone in 'Sources', 'RootName', 'Variants', 'ResolvedPath') {
Assert-False ($resolved.PSObject.Properties.Name -contains $gone) "不该再有旧属性 $gone"
}
}
Test-Case '字面路径条目:仍用路径命名算法,归档内是历史布局 <末级名>' {
$item = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-False $resolved.IsName
Assert-Equal 'path' $resolved.ArchiveFlavor
Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName
Assert-Equal 1 $resolved.Items.Count
Assert-Equal 'FooClolor' $resolved.Items[0].ArchivePath
Assert-Equal 'path' $resolved.Items[0].Kind
}
Test-Case '@pathname 用名录里的真实路径跑命名算法' {
$item = ConvertFrom-BackupListLine -Line 'FooClolor @pathname'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-Equal (Get-BackupBaseName -RawPath $dirA) $resolved.BaseName
Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath '归档内名字仍由 Slot 决定'
}
Test-Case ':: / @ Path= 覆盖单 Slot 条目的真实路径' {
$item = ConvertFrom-BackupListLine -Line "FooClolor :: $dirB"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-Equal 1 $resolved.Items.Count
Assert-Equal $dirB $resolved.Items[0].RealPath
Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath
Assert-Null $resolved.Blocking
$atItem = ConvertFrom-BackupListLine -Line "FooClolor @ Path='$dirB'"
$atResolved = Resolve-BackupEntry -Entry $atItem -CatalogPath $resolveCatalog
Assert-Equal $dirB $atResolved.Items[0].RealPath
}
Test-Case ':: 覆盖路径时,多 Slot 的条目会 Blocking(说不清是哪个 Slot)' {
$item = ConvertFrom-BackupListLine -Line "MultiApp :: $dirB"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-True ($null -ne $resolved.Blocking)
Assert-True ($resolved.Blocking -like '*2 个 Slot*')
}
Test-Case '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' {
$item = ConvertFrom-BackupListLine -Line 'no-such-thing'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-Equal 'no-such-thing' $resolved.BaseName
Assert-True ($null -ne $resolved.Error) '应给出错误说明'
Assert-Equal 0 $resolved.Items.Count
}
Test-Case '名录里的路径不存在时仍给出归档项(恢复要靠它还原回原位)' {
$catPath = Join-Path $resolveSandbox 'gone.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n 'gone-app' = @{ DefaultData = @{ Path = 'C:\definitely\not\here' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$item = ConvertFrom-BackupListLine -Line 'gone-app'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath
Assert-Equal 1 $resolved.Items.Count '恢复端据 Items 还原'
Assert-Equal 'C:\definitely\not\here' $resolved.Items[0].RealPath
Assert-False $resolved.Items[0].Exists
Assert-Equal 'Unresolved' $resolved.CatalogEntry.Kind
}
Test-Case ':+ 追加项:<归档内相对路径>:<宿主机绝对路径>' {
$item = ConvertFrom-BackupListLine -Line "FooClolor :+ Modules:$dirExtra"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-Equal 2 $resolved.Items.Count
Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath
Assert-Equal 'Modules' $resolved.Items[1].ArchivePath
Assert-Equal $dirExtra $resolved.Items[1].RealPath
Assert-Equal 'include' $resolved.Items[1].Kind
Assert-Equal 'include' $resolved.Items[1].Origin
Assert-True $resolved.HasIncludeOverride
}
Test-Case '条目级 :+ 覆盖名录里的 Include(不是叠加)' {
$incDir = Join-Path $resolveSandbox 'Inc'
New-Item -ItemType Directory -Path $incDir -Force | Out-Null
$catPath = Join-Path $resolveSandbox 'withinc.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n IncApp = @{ DefaultData = @{ Path = '$dirA'; Include = 'CatalogInc:$incDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$plain = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'IncApp') -CatalogPath $catPath
Assert-Equal 2 $plain.Items.Count
Assert-Equal 'CatalogInc' $plain.Items[1].ArchivePath
$overridden = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line "IncApp :+ EntryInc:$dirExtra") -CatalogPath $catPath
Assert-Equal 2 $overridden.Items.Count
Assert-Equal 'EntryInc' $overridden.Items[1].ArchivePath
Assert-Equal $dirExtra $overridden.Items[1].RealPath
}
Test-Case '归档内路径冲突:同名追加项 -> Blocking' {
$item = ConvertFrom-BackupListLine -Line "FooClolor :+ DefaultData:$dirExtra"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-True ($null -ne $resolved.Blocking)
Assert-True ($resolved.Blocking -like '*冲突*')
}
Test-Case '归档内路径冲突:父子关系 -> Blocking' {
$item = ConvertFrom-BackupListLine -Line "FooClolor :+ DefaultData\sub:$dirExtra"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-True ($null -ne $resolved.Blocking)
Assert-True ($resolved.Blocking -like '*父子*')
}
Test-Case '加密:名录 Slot 的 Encrypt 之和,条目级覆盖优先' {
$catalogEncrypt = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor') -CatalogPath $resolveCatalog
Assert-True $catalogEncrypt.Encrypt
$off = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor :!encrypt') -CatalogPath $resolveCatalog
Assert-False $off.Encrypt
$on = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'MultiApp :encrypt') -CatalogPath $resolveCatalog
Assert-True $on.Encrypt '名录没开、条目开了 -> 开'
Assert-False $on.HasExcludeOverride
}
Test-Case '方向标记随条目一路传到 Resolve-BackupEntry' {
Assert-Equal 'backup' (Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ FooClolor') -CatalogPath $resolveCatalog).Direction
Assert-Equal 'restore' (Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '- FooClolor') -CatalogPath $resolveCatalog).Direction
}
Test-Case '条目级 :- 覆盖名录里的 Exclude(不是叠加)' {
$catPath = Join-Path $resolveSandbox 'withexc.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n ExcApp = @{ DefaultData = @{ Path = '$dirA'; Exclude = '!*CatalogCache' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$plain = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'ExcApp') -CatalogPath $catPath
Assert-False $plain.HasExcludeOverride
Assert-Equal 0 $plain.ExcludePatterns.Count
Assert-Equal '!*CatalogCache' $plain.Items[0].Exclude[0]
$overridden = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'ExcApp :- !*Listed') -CatalogPath $catPath
Assert-True $overridden.HasExcludeOverride
Assert-Equal '!*Listed' $overridden.ExcludePatterns[0]
}
# ============================================================================
Write-Host "`n== 集成测试:真的跑 7z,验证排除结果落在文件系统上 ==" -ForegroundColor Cyan
# ============================================================================
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $sevenZip) {
Write-Host ' 跳过:未找到 7z' -ForegroundColor Yellow
} else {
Test-Case '排除规则与空格处理在真实归档上生效' {
$root = Join-Path $sandbox 'src'
$itemName = 'User Data'
$source = Join-Path $root $itemName
New-Item -ItemType Directory -Path $root -Force | Out-Null
foreach ($d in 'Default\Cache', 'Default\Code Cache', 'Default\Extensions', 'component_crx_cache', 'Default\IndexedDB') {
New-Item -ItemType Directory -Path (Join-Path $source $d) -Force | Out-Null
}
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source 'keep.txt') 'keep'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source 'Default\Cache\c.bin') 'c'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source 'Default\Code Cache\cc.bin') 'cc'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source 'Default\Extensions\e.bin') 'e'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source 'component_crx_cache\x.bin') 'x'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source 'Default\IndexedDB\i.bin') 'i'
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
$archiveItem = New-BaknretArchiveItem -ArchivePath $itemName -RealPath $source -Kind 'path' -Exists $true
$excludeArgs = @((Get-BaknretExcludeArgument -Item $archiveItem -Patterns $patterns).Arguments)
Assert-True ($excludeArgs.Count -ge 5) '排除参数应被翻译出来'
$archive = Join-Path $sandbox 'excl.7z'
$argument = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArgs + @($archive, $itemName)
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument -WorkingDirectory $root
Assert-Equal 0 $code '7z 打包退出码'
$verify = Join-Path $sandbox 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)
Assert-Equal 0 $code '7z 解压退出码'
Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\keep.txt")) '保留的文件应当存在'
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) 'Default\Cache 应被 !*Cache 排除'
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Code Cache\cc.bin")) 'Code Cache 应被排除(空格转 ? 生效)'
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Extensions\e.bin")) 'Extensions 应被排除'
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\component_crx_cache\x.bin")) 'component_crx_cache 应被排除'
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\IndexedDB\i.bin")) 'IndexedDB 应被排除'
}
Test-Case '!re:<正则> 展开出的排除参数在真实归档上生效' {
$root = Join-Path $sandbox 'src-re'
$itemName = 'Data'
$source = Join-Path $root $itemName
foreach ($d in 'Cache1', 'Cache2', 'Keep', 'sub\Cache3') {
New-Item -ItemType Directory -Path (Join-Path $source $d) -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source "$d\x.bin") 'x'
}
$archiveItem = New-BaknretArchiveItem -ArchivePath $itemName -RealPath $source -Kind 'slot' -Exists $true
$excludeArgs = @((Get-BaknretExcludeArgument -Item $archiveItem -Patterns @('!re:^Cache\d+$')).Arguments)
Assert-Equal 3 $excludeArgs.Count '两个顶层 Cache 目录 + 嵌套的 sub\Cache3'
$archive = Join-Path $sandbox 're.7z'
$argument = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArgs + @($archive, $itemName)
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument -WorkingDirectory $root
Assert-Equal 0 $code '7z 打包退出码'
$verify = Join-Path $sandbox 'verify-re'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)
Assert-Equal 0 $code '7z 解压退出码'
Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\Keep\x.bin")) '不命中正则的目录要留着'
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Cache1\x.bin")) 'Cache1 应被正则排除'
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Cache2\x.bin")) 'Cache2 应被正则排除'
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\sub\Cache3\x.bin")) '嵌套的 Cache3 也应被排除'
}
Test-Case '对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档)' {
$root = Join-Path $sandbox 'src'
$itemName = 'User Data'
$archive = Join-Path $sandbox 'full.7z'
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, $itemName) -WorkingDirectory $root
Assert-Equal 0 $code
$verify = Join-Path $sandbox 'verify-full'
New-Item -ItemType Directory -Path $verify -Force | Out-Null
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)
Assert-Equal 0 $code
Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) '对照组应当包含被排除的那个文件'
}
Test-Case 'Get-ArchiveTopLevelNames 能列出归档内的顶层条目名' {
$root = Join-Path $sandbox 'topnames'
New-Item -ItemType Directory -Path (Join-Path $root 'Alpha') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'Beta') -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $root 'Alpha\a.txt') 'a'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $root 'Beta\b.txt') 'b'
$archive = Join-Path $sandbox 'topnames.7z'
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, 'Alpha', 'Beta') -WorkingDirectory $root
Assert-Equal 0 $code
$names = @(Get-ArchiveTopLevelNames -ArchivePath $archive -SevenZip $sevenZip)
$sorted = @($names | Sort-Object)
Assert-Equal 2 $sorted.Count
Assert-Equal 'Alpha' $sorted[0]
Assert-Equal 'Beta' $sorted[1]
}
Test-Case '7z t 对完好归档返回 0,对损坏归档返回非 0' {
$good = Join-Path $sandbox 'full.7z'
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('t', '-bso0', '-bsp0', $good)
Assert-Equal 0 $code '完好归档应当校验通过'
$bad = Join-Path $sandbox '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)
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('t', '-bso0', '-bsp0', $bad)
Assert-True ($code -ne 0) '损坏的归档应当校验失败'
}
}
# ============================================================================
Write-Host "`n== 外部命令退出码 ==" -ForegroundColor Cyan
# ============================================================================
Test-Case 'Invoke-ExternalCommand 能拿到真实退出码(旧实现用 Start-Process 拿不到)' {
$code = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')
Assert-Equal 7 $code
}
# ============================================================================
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $catalogSandbox -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $resolveSandbox -Recurse -Force -ErrorAction SilentlyContinue
$failed = Write-TestSummary -Title '单元测试'
if ($failed -gt 0) { exit 1 }
exit 0