Files
BakNRet/tests/Run-Tests.ps1
T
Shuery 2ad7987ffe refactor: Common.psm1 拆成 BakNRet/{Public,Private},一函数一文件 + 薄加载器
3152 行、66 个函数的单文件模块拆成:
  BakNRet\BakNRet.psd1   模块清单:FunctionsToExport 是显式白名单(62 个名字)
  BakNRet\BakNRet.psm1   加载器:点源顺序的唯一一处声明
  BakNRet\Public\*.ps1   62 个对外函数,一函数一文件,文件名 = 函数名
  BakNRet\Private\*.ps1  4 个内部函数 + State.ps1(模块级状态集中一处)

为什么是一函数一文件:这是社区里脚本模块的主流形态(调研实测:winutil 79 个、
Terminal-Icons 24 个、ModuleBuilder 23 个,全部如此)。收益是改动落在小文件里、diff 按职责
可读、模块级状态有唯一去处。

为什么这不算"打散":模块内 dot-source 的文件共享同一个模块作用域(实测确认),所以
"按顺序点源 67 个文件"与"点源一个大文件"在语义上等价;顺序只在加载器里出现一次,
tools\Build-BakNRetModule.ps1 从那里读出顺序就能拼回单文件 —— 本次产物 dist\BakNRet.psm1
3240 行、两个版本都解析零错。

新增一条断言把这条承诺钉住:加载器点源的文件集合必须与磁盘一致、导出名单必须与
Public\ 一一对应。漏一个文件或漏一个名字就是静默少一个函数 —— 而那种错在运行时只表现为
"找不到命令"。

引用更新:11 个文件里的 Common.psm1 改成 BakNRet\BakNRet.psd1(走清单导入,
FunctionsToExport 才真的说了算);Common.psm1 直接删除,不留转发垫片。

验收:test.ps1 9/9 全绿(7 与 5.1),98 个文件两版解析零错,276 个断言原样通过 ——
这次搬家没有改变任何可观察行为。
2026-09-27 09:34:10 +08:00

1367 lines
68 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) 'BakNRet\BakNRet.psd1' }
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 '暂存半途失败会自己清干净(不留指向真实数据的 junction)' {
# 这是"全仓唯一会把 junction 留在真实数据上"那个缺陷的回归断言。
# 函数抛错时没有返回值,调用方的 finally 拿到 $null 就什么都不会清 —— 所以清理必须
# 由函数自己在 catch 里做,不能指望调用方。
$base = Join-Path $env:TEMP ("bnr-stage-leak-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$realDir = Join-Path $base 'real'
$sandbox = Join-Path $base 'sandbox'
New-Item -ItemType Directory -Path $realDir, $sandbox -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $realDir 'inner.txt') -Value 'inner'
$items = @(
# 第一项会成功挂上,而且是**目录走 junction** —— 这正是危险的那个物件:它指向
# 真实数据,而临时目录迟早会被某次 Remove-Item -Recurse 扫到。
(New-BaknretArchiveItem -ArchivePath 'Good' -RealPath $realDir -Kind 'slot' -Slot 'Good' -Exists $true -IsFile $false)
# 第二项必然失败:源文件不存在,硬链接与复制都会失败
(New-BaknretArchiveItem -ArchivePath 'Missing.txt' -RealPath (Join-Path $base 'does-not-exist.txt') -Kind 'slot' -Slot 'Missing' -Exists $false -IsFile $true)
)
$threw = $false
try { New-BaknretArchiveStaging -Items $items -Root (Join-Path $sandbox 'stage') | Out-Null } catch { $threw = $true }
Assert-True $threw '第二项应当抛错(源文件不存在,硬链接与复制都会失败)'
$left = @(Get-ChildItem -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue)
$junctions = @($left | Where-Object { $_.LinkType -eq 'Junction' })
Assert-Equal 0 $junctions.Count ('残留了指向真实数据的连接点:' + ($junctions.FullName -join '、'))
Assert-Equal 0 $left.Count ('暂存目录没有清干净,残留:' + ($left.FullName -join '、'))
Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue
}
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 '口令不会进日志:DEBUG 下打印的命令行要遮蔽 -p 参数' {
# 7z / RAR 只接受命令行口令,所以口令必然出现在参数表里。一旦 -Verbose 打开 DEBUG,
# 整条命令行就会落进 logs\*.log —— 而 BackupConfig.psd1 与文档都承诺过"口令不落盘"。
$sandbox = Join-Path $env:TEMP ("bnr-secret-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $sandbox -Force | Out-Null
$sentinel = 'SENTINEL-PW-9f3a'
$logPath = $null
try {
$logPath = Start-BaknretLog -Directory $sandbox -Prefix 'secret'
Set-BaknretDebug
$null = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0', "-p$sentinel")
} finally {
Set-BaknretDebug -Enabled:$false
Stop-BaknretLog
}
$logText = Get-Content -Encoding UTF8 -LiteralPath $logPath -Raw
# 这条是"测试的测试":没有它,万一 DEBUG 那行压根没写进去,后面两条会空跑通过
Assert-True ($logText -match '执行:') '日志里没有那行 DEBUG 的命令行记录,这条断言就是空跑'
Assert-False ($logText -match [regex]::Escape($sentinel)) '口令明文进了日志'
Assert-True ($logText -match '口令已隐藏') '日志里应当出现遮蔽占位符'
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
}
Test-Case 'Invoke-ExternalCommand 能拿到真实退出码(旧实现用 Start-Process 拿不到)' {
$code = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')
Assert-Equal 7 $code
}
# ============================================================================
Write-Host "`n== 原子写与空间守卫的输入 ==" -ForegroundColor Cyan
# ============================================================================
Test-Case '空目录的摘要给 0 而不是 $null(否则空间守卫会静默失效)' {
# Measure-Object 对空输入返回 $null 而不是 0,于是 .Sum 也是 $null;而 $null / 1GB 得 0,
# 空间守卫判的是 -gt 0 —— 这一档就不再拦截了。
$empty = Join-Path $env:TEMP ("bnr-empty-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $empty -Force | Out-Null
try {
$summary = Get-FolderSummary -FolderPath $empty
Assert-Equal 0 $summary.FileCount
Assert-True ($null -ne $summary.TotalSize) 'TotalSize 不能是 $null'
Assert-True ($summary.TotalSize -is [long]) 'TotalSize 应当是整数'
Assert-Equal 0 $summary.TotalSize
} finally {
Remove-Item -LiteralPath $empty -Recurse -Force -ErrorAction SilentlyContinue
}
}
Test-Case '原子替换:成功时新内容到位且不留 .tmp;失败时旧内容完好' {
$base = Join-Path $env:TEMP ("bnr-atomic-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $base -Force | Out-Null
$target = Join-Path $base 'text.json'
try {
Write-BaknretAtomicText -Path $target -Text 'FIRST' | Out-Null
Assert-Equal 'FIRST' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
# 目标已存在时走 File.Replace
Write-BaknretAtomicText -Path $target -Text 'SECOND' | Out-Null
Assert-Equal 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
Assert-FileMissing "$target.tmp"
# 目标只读 -> 替换必然失败 -> 旧内容必须还在。这正是 File.Replace 与"先删后移"的区别:
# 后者失败时旧文件已经没了,而 manifest 丢了就只能靠文件名反推。
(Get-Item -LiteralPath $target).IsReadOnly = $true
$threw = $false
try { Write-BaknretAtomicText -Path $target -Text 'SHOULD-NOT-LAND' | Out-Null } catch { $threw = $true }
Assert-True $threw '目标只读时替换应当抛错'
Assert-Equal 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
} finally {
$file = Get-Item -LiteralPath $target -ErrorAction SilentlyContinue
if ($file) { $file.IsReadOnly = $false }
Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
Write-Host "`n== 运行锁 ==" -ForegroundColor Cyan
# ============================================================================
Test-Case '运行锁:同一份备份目录同时只能有一个持有者' {
# Backup.ps1 与 Restore.ps1 都会写 manifest.json,也都会用 <归档>.tmp 这个名字;
# 计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本、抢对方的临时归档。
$dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
$first = $null
$second = $null
try {
$first = Enter-BaknretRunLock -Directory $dir
Assert-True ($null -ne $first) '第一次应当拿到锁'
$second = Enter-BaknretRunLock -Directory $dir
Assert-True ($null -eq $second) '同一目录的第二次不应当拿到锁'
Assert-FileExists (Get-BaknretRunLockPath -Directory $dir)
} finally {
Exit-BaknretRunLock -Lock $second
Exit-BaknretRunLock -Lock $first
}
# 锁文件是**独占**打开的(FileShare.None),内容只能在释放之后读 —— 而它本来就该
# 留在那里给排查的人看:"到底是谁占着"这个问题不该靠猜。
$info = Get-Content -Encoding UTF8 -LiteralPath (Get-BaknretRunLockPath -Directory $dir) -Raw
Assert-True ($info -match "pid=$PID") "锁文件里应当写明持有进程,实际内容是:$info"
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
Test-Case '运行锁:释放之后可以重新取得' {
$dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
try {
$first = Enter-BaknretRunLock -Directory $dir
Assert-True ($null -ne $first) '第一次应当拿到锁'
Exit-BaknretRunLock -Lock $first
$second = Enter-BaknretRunLock -Directory $dir
Assert-True ($null -ne $second) '释放之后应当能重新拿到锁'
Exit-BaknretRunLock -Lock $second
} finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
Test-Case '模块可以合回单文件:加载器的顺序声明完整、导出名单与 Public\ 一一对应' {
# 这是"拆分可逆"那条承诺的守卫。顺序只在加载器里出现一次、导出面只在清单里出现一次,
# 两者都必须与磁盘上的文件对得上 —— 否则哪天有人加了文件却忘了点源,工具照样能跑,
# 而"合回单文件"会静默少一个函数。
$repoRoot = Split-Path -Parent $PSScriptRoot
$moduleRoot = Join-Path $repoRoot 'BakNRet'
$loaderPath = Join-Path $moduleRoot 'BakNRet.psm1'
Assert-FileExists $loaderPath
$loader = @(Get-Content -Encoding UTF8 -LiteralPath $loaderPath)
# 用 Split 取路径,不用正则:这一行里同时有引号与反斜杠,正则嵌进测试文件之后
# 多一层转义,很容易取到 0 个(第一版就是这样,而手工核对是 67 个)。
$sources = @()
foreach ($line in $loader) {
if ($line -like '*Join-Path*' -and $line -like "*.ps1')*") {
$quoted = $line.Split("'")
if ($quoted.Count -ge 3) { $sources += $quoted[1] }
}
}
Assert-True ($sources.Count -ge 60) "加载器里只点源了 $($sources.Count) 个文件,太少"
foreach ($rel in $sources) { Assert-FileExists (Join-Path $moduleRoot ($rel -replace '/', '\')) }
# 磁盘上的每个函数文件都必须被点源到 —— 漏掉一个就是静默少一个函数
$onDisk = @(Get-ChildItem -LiteralPath (Join-Path $moduleRoot 'Public'), (Join-Path $moduleRoot 'Private') -Filter *.ps1 |
ForEach-Object { "$($_.Directory.Name)\$($_.Name)" })
$missingFromLoader = @($onDisk | Where-Object { $sources -notcontains $_ })
Assert-Equal 0 $missingFromLoader.Count ("这些文件没被加载器点源:" + ($missingFromLoader -join '、'))
# 导出名单必须与 Public\ 下的文件一一对应(少一个就是静默不导出)
$exported = @()
foreach ($line in $loader) {
$trimmed = $line.Trim()
if ($trimmed.StartsWith("'")) { $exported += $trimmed.TrimStart("'").TrimEnd("'", ',') }
}
$publicNames = @(Get-ChildItem -LiteralPath (Join-Path $moduleRoot 'Public') -Filter *.ps1 | ForEach-Object { $_.BaseName })
$notExported = @($publicNames | Where-Object { $exported -notcontains $_ })
Assert-Equal 0 $notExported.Count ("Public\ 下有文件没写进导出名单:" + ($notExported -join '、'))
$noFile = @($exported | Where-Object { $publicNames -notcontains $_ })
Assert-Equal 0 $noFile.Count ("导出名单里有名字没有对应文件:" + ($noFile -join '、'))
}
# ============================================================================
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