<# .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-BakNRetLiteralPath:带分隔符或 % 的按路径,其余按软件名' { Assert-True (Test-BakNRetLiteralPath -Path 'C:\a') Assert-True (Test-BakNRetLiteralPath -Path 'C:/a') Assert-True (Test-BakNRetLiteralPath -Path '%UserProfile%\.ssh') Assert-False (Test-BakNRetLiteralPath -Path 'MyApp') Assert-True (Test-BakNRetLiteralPath -Path '') } # ============================================================================ Write-Host "`n== 归档命名 ==" -ForegroundColor Cyan # ============================================================================ Test-Case '基础命名规则' { Assert-Equal 'legendary_from_%UserProfile%+.config' (Get-BakNRetBackupBaseName -RawPath '%UserProfile%/.config/legendary') Assert-Equal 'FooClolor_from_C_+Programs' (Get-BakNRetBackupBaseName -RawPath 'C:\Programs\FooClolor') Assert-Equal 'settings.json_from_C_+Programs' (Get-BakNRetBackupBaseName -RawPath 'C:\Programs\settings.json') } Test-Case '盘符冒号被归一化成下划线' { Assert-Equal 'a_from_C_' (Get-BakNRetBackupBaseName -RawPath 'C:\a') } Test-Case '/ 与 \ 以及重复分隔符结果一致' { $a = Get-BakNRetBackupBaseName -RawPath '%UserProfile%/.config/scoop' $b = Get-BakNRetBackupBaseName -RawPath '%UserProfile%\.config\scoop' $c = Get-BakNRetBackupBaseName -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-BakNRetBackupBaseName -RawPath $case $back = Convert-BakNRetBackupFileNameToPath -FileName "$base.7z" Assert-Equal ($case -replace '[/\\]+', '\') $back "往返 $case" } } Test-Case '归档名里不含非法文件名字符(保留 & % +)' { $base = Get-BakNRetBackupBaseName -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-BakNRetCatalogName -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-BakNRetNativeArgumentString -ArgumentList @('a', '-t7z')) } Test-Case '含空格参数加引号' { Assert-Equal '"C:\Program Files\x"' (ConvertTo-BakNRetNativeArgumentString -ArgumentList @('C:\Program Files\x')) } Test-Case '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' { Assert-Equal '"C:\Program Files\\"' (ConvertTo-BakNRetNativeArgumentString -ArgumentList @('C:\Program Files\')) } Test-Case '内部引号被转义' { Assert-Equal '"say \"hi\""' (ConvertTo-BakNRetNativeArgumentString -ArgumentList @('say "hi"')) } Test-Case '空参数输出一对空引号' { Assert-Equal '""' (ConvertTo-BakNRetNativeArgumentString -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -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-BakNRetChildDirectoryByName:_ / - 边界之外不补全' { $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-BakNRetChildDirectoryByName -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -Path $catPath $second = Get-BakNRetSoftwareCatalog -Path $catPath Assert-True ([object]::ReferenceEquals($first, $second)) '同一文件同一指纹应命中缓存' $fresh = Get-BakNRetSoftwareCatalog -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-BakNRetSoftwareCatalog -Path $catPath Assert-Equal $configDir $changed['Cached'].Slots[0].Resolved '文件变了缓存必须失效' } # ============================================================================ Write-Host "`n== Resolve-BakNRetBackupEntry(归档项解析) ==" -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-BakNRetBackupEntry -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-BakNRetBackupEntry 的返回值里没有旧的 Sources / RootName 等属性' { $item = ConvertFrom-BackupListLine -Line 'FooClolor' $resolved = Resolve-BakNRetBackupEntry -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-BakNRetBackupEntry -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-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog Assert-Equal (Get-BakNRetBackupBaseName -RawPath $dirA) $resolved.BaseName Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath '归档内名字仍由 Slot 决定' } Test-Case ':: / @ Path= 覆盖单 Slot 条目的真实路径' { $item = ConvertFrom-BackupListLine -Line "FooClolor :: $dirB" $resolved = Resolve-BakNRetBackupEntry -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-BakNRetBackupEntry -Entry $atItem -CatalogPath $resolveCatalog Assert-Equal $dirB $atResolved.Items[0].RealPath } Test-Case ':: 覆盖路径时,多 Slot 的条目会 Blocking(说不清是哪个 Slot)' { $item = ConvertFrom-BackupListLine -Line "MultiApp :: $dirB" $resolved = Resolve-BakNRetBackupEntry -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-BakNRetBackupEntry -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-BakNRetBackupEntry -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-BakNRetBackupEntry -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-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'IncApp') -CatalogPath $catPath Assert-Equal 2 $plain.Items.Count Assert-Equal 'CatalogInc' $plain.Items[1].ArchivePath $overridden = Resolve-BakNRetBackupEntry -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-BakNRetBackupEntry -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-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog Assert-True ($null -ne $resolved.Blocking) Assert-True ($resolved.Blocking -like '*父子*') } Test-Case '加密:名录 Slot 的 Encrypt 之和,条目级覆盖优先' { $catalogEncrypt = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor') -CatalogPath $resolveCatalog Assert-True $catalogEncrypt.Encrypt $off = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor :!encrypt') -CatalogPath $resolveCatalog Assert-False $off.Encrypt $on = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'MultiApp :encrypt') -CatalogPath $resolveCatalog Assert-True $on.Encrypt '名录没开、条目开了 -> 开' Assert-False $on.HasExcludeOverride } Test-Case '方向标记随条目一路传到 Resolve-BakNRetBackupEntry' { Assert-Equal 'backup' (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ FooClolor') -CatalogPath $resolveCatalog).Direction Assert-Equal 'restore' (Resolve-BakNRetBackupEntry -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-BakNRetBackupEntry -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-BakNRetBackupEntry -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-BakNRetArchiveTopLevelNames 能列出归档内的顶层条目名' { $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-BakNRetArchiveTopLevelNames -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-BakNRetFolderSummary -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 '、')) } Test-Case '条目记录的字段名与顺序是 manifest 的 schema(顺序变了 diff 就没法读)' { # manifest.json 是给人看的:字段按固定顺序排列,两次运行之间只有值会变, # 于是 git diff / 文本对比才读得下去。顺序被无意改掉时,这个断言会拦下来。 $expected = @( 'baseName', 'source', 'resolvedSource', 'roots', 'layouts', 'catalog', 'archive', 'action', 'reason', 'phase', 'attemptedAt', 'finishedAt', 'durationSec', 'exitCode', 'verified', 'warnings', 'attemptWarnings', 'encrypted', 'security', 'sourceFiles', 'sourceBytes', 'archiveBytes', 'sha256', 'lastSuccessAt', 'successCount', 'failCount' ) $record = New-BakNRetItemRecord -BaseName 'x' -Source 's' -ResolvedSource 'rs' -Phase 'backup' Assert-Equal $expected.Count @($record.Keys).Count '字段数不对' for ($i = 0; $i -lt $expected.Count; $i++) { Assert-Equal $expected[$i] @($record.Keys)[$i] "第 $($i + 1) 个字段应当是 $($expected[$i])" } Assert-True (@($record.attemptedAt) -match '^\d{4}-\d{2}-\d{2}T') 'attemptedAt 应当是 ISO 时间' # 时间戳每次现取,不能被写成模块加载时的常量 $again = New-BakNRetItemRecord -BaseName 'y' -Source 's' -ResolvedSource 'rs' -Phase 'backup' Assert-True ($record.attemptedAt -ne $again.attemptedAt) '两条记录的 attemptedAt 不该相同' } Test-Case '结果汇总:分类列出成功/跳过/失败,带原因与失败明细,空分组不打印' { $dir = Join-Path $env:TEMP ("bnr-summary-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $dir -Force | Out-Null $manifest = @{ items = [ordered]@{} } $started = (Get-Date).AddSeconds(-30) $specs = @( @{ N = 'GoodApp'; A = 'backed-up'; R = $null; X = @{ archive = 'GoodApp.7z'; sha256 = 'abcdef1234567890'; sourceBytes = 1048576 } }, @{ N = 'OldApp'; A = 'skip-unchanged'; R = '源最新修改时间不晚于归档时间'; X = @{} }, @{ N = 'BadApp'; A = 'failed'; R = '压缩失败(退出码 2)'; X = @{} } ) foreach ($spec in $specs) { $rec = New-BakNRetItemRecord -BaseName $spec.N -Source 'x' -ResolvedSource 'y' -Phase 'backup' $rec.action = $spec.A $rec.reason = $spec.R foreach ($k in $spec.X.Keys) { $rec[$k] = $spec.X[$k] } $rec.finishedAt = (Get-Date).ToString('o') $manifest.items[$spec.N] = $rec } try { Start-BakNRetLog -Directory $dir -Prefix 'summary' | Out-Null Write-BakNRetRunSummary -Mode 'backup' -Manifest $manifest -StartedAt $started -Failures @('BadApp:压缩失败(退出码 2)') -Counters @{ 失败 = 1 } $logPath = Get-BakNRetLogPath Stop-BakNRetLog $text = Get-Content -Encoding UTF8 -LiteralPath $logPath -Raw Assert-True ($text -match '结果汇总') '应当打印汇总标题' Assert-True ($text -match 'GoodApp') '应当列出成功的条目' Assert-True ($text -match '源未更新') '应当有跳过分组' Assert-True ($text -match '压缩失败(退出码 2)') '应当带上失败原因原文' Assert-True ($text -match '失败明细') '应当单列失败明细' Assert-True ($text -notmatch 'missing-source') '空分组不该出现' } finally { if (Get-BakNRetLogPath) { Stop-BakNRetLog } Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue } } Test-Case '控制台列宽:中文 2 列、半角片假名 1 列、emoji 按一个码点算、控制字符 0 列' { # 这张表算错了不会报错,只会让菜单右边框歪一点 —— 所以每条边界都钉一个断言。 Assert-Equal 8 (Get-BakNRetCellWidth -Text '12345678') '8 个 ASCII = 8 列' Assert-Equal 4 (Get-BakNRetCellWidth -Text '中文') '2 个汉字 = 4 列(.Length 是 2)' Assert-Equal 3 (Get-BakNRetCellWidth -Text ([string]::new([char[]]@(0x250C, 0x2500, 0x2510)))) '字体边框字符 = 1 列/个(5.1 的 LengthInBufferCells 在这里会返回 6)' Assert-Equal 4 (Get-BakNRetCellWidth -Text ([string]::new([char[]]@(0xFF21, 0xFF22)))) '全角拉丁 = 2 列/个' Assert-Equal 2 (Get-BakNRetCellWidth -Text ([string]::new([char[]]@(0xFF71, 0xFF72)))) '半角片假名 = 1 列/个(不能划进全角区)' Assert-Equal 2 (Get-BakNRetCellWidth -Text ([char]::ConvertFromUtf32(0x1F600))) 'emoji = 一个码点 2 列' Assert-Equal 0 (Get-BakNRetCellWidth -Text ([string]::new([char[]]@(9, 10)))) '控制字符 = 0 列' Assert-Equal 0 (Get-BakNRetCellWidth -Text '') '空串 = 0 列' } Test-Case '按键归一化与输入缝:映射可无控制台测,序列用尽必须报错而不是挂起' { # 纯映射:整个 TUI 里唯一能无控制台测的部分,所以每条路径都钉住。 Assert-Equal 'Up' (Get-BakNRetKeyName -VirtualKeyCode 38) '上' Assert-Equal 'Down' (Get-BakNRetKeyName -VirtualKeyCode 40) '下' Assert-Equal 'Left' (Get-BakNRetKeyName -VirtualKeyCode 37) '左' Assert-Equal 'Right' (Get-BakNRetKeyName -VirtualKeyCode 39) '右' Assert-Equal 'Enter' (Get-BakNRetKeyName -VirtualKeyCode 13) '回车' Assert-Equal 'Esc' (Get-BakNRetKeyName -VirtualKeyCode 27) 'Esc' Assert-Equal 'Space' (Get-BakNRetKeyName -VirtualKeyCode 32) '空格' Assert-Equal 'Char:k' (Get-BakNRetKeyName -VirtualKeyCode 75 -Character 'K') '字母统一成小写,且带冒号前缀' Assert-Equal 'Unknown255' (Get-BakNRetKeyName -VirtualKeyCode 255) '未知键也要有稳定名字,不能返回空' # 驱动:按序列顺序消费(这一条曾经因为 Script 被折进注释而静默变成"没有序列") $driver = New-BakNRetInputDriver -Script @('Down', 'Enter') Assert-Equal 2 $driver.Script.Count '驱动里应当有两个键' # 单键序列:单元素数组会被 [pscustomobject] 拆成字符串,于是 [0] 变成首字母 —— 这条断言就是 # 为那个坑立的(块 ② 当时只覆盖了两键序列,所以它溜过去了)。 $single = New-BakNRetInputDriver -Script @('Esc') Assert-Equal 'Esc' (Read-BakNRetKey -Driver $single) '单键序列必须整键返回(不是首字母)' Assert-Equal 'Down' (Read-BakNRetKey -Driver $driver) '第一个键' Assert-Equal 'Enter' (Read-BakNRetKey -Driver $driver) '第二个键' # 关键一条:序列用尽必须失败。门禁里挂起比变红糟得多,而"退回去读真终端"正是挂起的成因。 $threw = $false try { Read-BakNRetKey -Driver $driver | Out-Null } catch { $threw = $true } Assert-True $threw '按键序列用尽后必须抛错' # 另一个"不猜"的路径:没有控制台也没有序列时,不能静默返回一个默认键 $threw2 = $false try { $driver2 = New-BakNRetInputDriver -NonInteractive Assert-Equal 0 $driver2.Script.Count '空序列必须是 0 个元素(不能是含 $null 的单元素数组)' Read-BakNRetKey -Driver $driver2 | Out-Null } catch { $threw2 = $true } Assert-True $threw2 '没有任何输入来源时必须抛错' } Test-Case 'TUI 文本对齐:按列宽补齐/裁剪,绝不切开宽字符,无控制台下不抛错' { # 补齐:按列宽而不是字符数 $padded = Format-BakNRetPaddedText -Text '中文' -Width 10 Assert-Equal 10 (Get-BakNRetCellWidth -Text $padded) '补齐后应当恰好 10 列' Assert-Equal 8 $padded.Length '10 列 = 8 个字符(两个汉字各占 2 列)' Assert-Equal 'AB ' (Format-BakNRetPaddedText -Text 'AB' -Width 5) 'ASCII 补齐' # 裁剪:'中文字' 是 6 列,裁到 5 列必须停在 '中文'(4 列)+ 1 个空格, # 而不是把第三个汉字劈成半个 —— 那会显示成乱码。 $clipped = Format-BakNRetPaddedText -Text '中文字' -Width 5 Assert-Equal 5 (Get-BakNRetCellWidth -Text $clipped) '裁剪后应当恰好 5 列' Assert-Equal 3 $clipped.Length '3 个字符(中文 + 1 空格),不是 4' Assert-Equal '中' $clipped.Substring(0, 1) '前两个汉字保留' Assert-Equal ' ' $clipped.Substring(2, 1) '剩下的 1 列用空格补' # 边界:宽度 0 / 负数返回空串;空文本也能补齐 Assert-Equal 0 (Format-BakNRetPaddedText -Text 'abc' -Width 0).Length '宽度 0 返回空串' Assert-Equal 4 (Format-BakNRetPaddedText -Text '' -Width 4).Length '空文本补成纯空格' # 无控制台(本进程的真实环境就是重定向)+ 越界坐标:不能抛错 $threw = $false try { Write-BakNRetAt -X 9999 -Y 9999 -Text 'x' } catch { $threw = $true } Assert-True (-not $threw) '没有控制台且坐标越界时也不能抛错(TUI 画得难看,好过运行失败)' } Test-Case '菜单状态机:绕回、空格切换、确认/取消、空菜单不崩' { $items = @([pscustomobject]@{ Key = 'a' }, [pscustomobject]@{ Key = 'b' }, [pscustomobject]@{ Key = 'c' }) # 到头绕回(菜单短的时候比夹住好用) Assert-Equal 2 (Update-BakNRetMenuState -Items $items -Index 0 -Key 'Up').Index '从第一个往上绕到最后一个' Assert-Equal 0 (Update-BakNRetMenuState -Items $items -Index 2 -Key 'Down').Index '从最后一个往下绕回第一个' Assert-Equal 1 (Update-BakNRetMenuState -Items $items -Index 0 -Key 'Down').Index '往下走一格' Assert-Equal 2 (Update-BakNRetMenuState -Items $items -Index 0 -Key 'Char:k').Index 'vim 的 k = 上' Assert-Equal 1 (Update-BakNRetMenuState -Items $items -Index 0 -Key 'Char:j').Index 'vim 的 j = 下' # 空格只切换当前项,再按一次取消 $s1 = Update-BakNRetMenuState -Items $items -Index 1 -Key 'Space' Assert-Equal 1 @($s1.Checked.Keys).Count '勾选一项' Assert-True $s1.Checked.ContainsKey('b') '勾的是当前项' $s2 = Update-BakNRetMenuState -Items $items -Index 1 -Key 'Space' -Checked $s1.Checked Assert-Equal 0 @($s2.Checked.Keys).Count '再按一次取消勾选' Assert-Equal 0 @(Update-BakNRetMenuState -Items $items -Index 0 -Key 'Space' -MultiSelect $false).Checked.Keys.Count '单选模式下空格不勾选' # 确认 / 取消 / 其它键 Assert-Equal 'confirm' (Update-BakNRetMenuState -Items $items -Key 'Enter').Action '回车 = 确认' Assert-Equal 'cancel' (Update-BakNRetMenuState -Items $items -Key 'Esc').Action 'Esc = 取消' Assert-Equal 'none' (Update-BakNRetMenuState -Items $items -Key 'Char:z').Action '别的键什么都不做' # 空菜单:第一次使用时归档目录就是空的,打开菜单不该崩 $empty = @() Assert-Equal 0 (Update-BakNRetMenuState -Items $empty -Key 'Down').Index '空菜单按方向键不抛错' Assert-Equal 0 @(Update-BakNRetMenuState -Items $empty -Key 'Space').Checked.Keys.Count '空菜单按空格不抛错' Assert-Equal 'cancel' (Update-BakNRetMenuState -Items $empty -Key 'Esc').Action '空菜单仍然能退出' } Test-Case '菜单循环:按数量分档验(0 项 / 1 项 / 3 项),单键与多键序列都能走完' { $items = @( [pscustomobject]@{ Key = 'a'; Text = '第一个' }, [pscustomobject]@{ Key = 'b'; Text = '第二个' }, [pscustomobject]@{ Key = 'c'; Text = '第三个' } ) # 按数量分档是踩出来的:单键序列曾经因为"单元素数组被拆成字符串"而变成首字母, # 而只跑多键序列的断言看不见它。所以 1 项的档位与 0 项的档位各自都要有断言。 $esc = Invoke-BakNRetMenu -Title '测试' -Items $items -Driver (New-BakNRetInputDriver -Script @('Esc')) Assert-Equal 'cancel' $esc.Action '单键 Esc 应当直接取消' Assert-Equal 'confirm' (Invoke-BakNRetMenu -Title '测试' -Items $items -Driver (New-BakNRetInputDriver -Script @('Enter'))).Action '单键回车应当确认' # 1 项:上下键都停在 0,回车仍能确认 $oneItem = @([pscustomobject]@{ Key = 'only'; Text = '只有一项' }) $single = Invoke-BakNRetMenu -Title '测试' -Items $oneItem -Driver (New-BakNRetInputDriver -Script @('Up', 'Enter')) Assert-Equal 'confirm' $single.Action '只有一项时也能确认' Assert-Equal 0 $single.Index '只有一项时按上键仍停在 0' # 3 项:下移 + 勾选 + 确认 $result = Invoke-BakNRetMenu -Title '测试' -Items $items -Driver (New-BakNRetInputDriver -Script @('Down', 'Space', 'Enter')) Assert-Equal 'confirm' $result.Action '回车确认' Assert-Equal 1 $result.Index '下移一格' Assert-Equal 'b' $result.Chosen.Key '选中的是第二项' Assert-True $result.Checked.ContainsKey('b') 'b 被勾上' # 0 项:驱动器里一个键都没有 —— 若它读键就会抛错,所以"不抛错且返回 cancel"同时证明它没等按键 $emptyList = @() $empty = Invoke-BakNRetMenu -Title '测试' -Items $emptyList -Driver (New-BakNRetInputDriver -Script @()) Assert-Equal 'cancel' $empty.Action '空菜单直接返回 cancel' Assert-Equal 'empty' $empty.Reason '原因标为 empty' } # ============================================================================ 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