Files
BakNRet/tests/Run-Tests.ps1
T
Shuery effc38fdd3 feat(tui): 名录编辑器循环 + 挂进 Edit-Config(第三轮第二块·完成)
装配:Get-BakNRetCatalogField → 菜单选字段 → Read-BakNRetLine → Set-BakNRetCatalogField → Save-BakNRetConfigFile(校验通过才原子替换 + 时间戳备份)。

菜单里**只列可编辑的字段**(单行的 Encrypt / Description):把只读的 Path / Exclude 也列出来再拒绝,会让每个只读项浪费用户一次回车。

校验用模块自己的 Import-BaknretDataFile:这份名录有 $( ) 动态表达式与跨行拼接,普通数据文件读取器读不了 —— 而正是它保证"改完整份文件还能被程序读回来"(值能读回来才算改成功)。定位用 (软件名, Slot, 字段名) 三元组,因为同一软件可以有多个 Slot。

Edit-Config 的三个界面(清单 / 设置 / 名录)至此全部完成。

判据 9 条:改一个值后只动一行、注释行数不变、改完能读回新值、留下时间戳备份且备份里是原文;取消时不写盘也不产生备份。

验收:test.ps1 9/9 全绿(5.1 与 7);真实清单只读冒烟 4/4。
2026-09-27 21:30:14 +08:00

1910 lines
105 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-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'
}
Test-Case '清单行表与单行替换:只动那一行,注释与其它行逐字节不变' {
$text = @(
'# 注释一',
'Edge',
'',
'+WindowsTerminal :encrypt',
'# 注释二',
'-OldApp :- logs\'
) -join "`n"
# 行表必须包含注释与空行,否则行号会错位(外科式改写按行号动手)
$rows = Get-BakNRetBackupListRow -Text $text
Assert-Equal 6 $rows.Count '总行数'
Assert-Equal 1 $rows[0].Line '首行行号是 1'
Assert-True ($null -eq $rows[0].Record) '注释行不解析'
Assert-True ($null -eq $rows[2].Record) '空行不解析'
Assert-Equal 'Edge' $rows[1].Record.Path '第 2 行是条目'
Assert-Equal 'backup' $rows[3].Record.Direction '第 4 行方向是 backup'
Assert-Equal 'Edge' $rows[1].Raw 'Raw 是原始行文本'
# 往返:每一行原样写回 → 文本必须逐字节相同(这是"外科式"的最强形式)
$same = $text
foreach ($row in $rows) { $same = Set-BakNRetBackupListRow -Text $same -Line $row.Line -NewText $row.Raw }
Assert-Equal $text $same '每行原样写回后文本逐字节相同'
# 改一行:只有那一行不同,注释行数不变,原文本不被就地改动
$edited = Set-BakNRetBackupListRow -Text $text -Line 2 -NewText '-Edge'
$before = @($text -split "`n")
$after = @($edited -split "`n")
Assert-Equal $before.Count $after.Count '行数不变'
Assert-Equal '-Edge' $after[1] '第 2 行改成了 -Edge'
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
Assert-Equal 2 @($after | Where-Object { $_.Trim().StartsWith('#') }).Count '注释行数不变'
Assert-Equal 'Edge' $before[1] '原文本没有被就地改动'
# 行号越界必须抛错:静默不动会让"保存成功但其实没改"发生
$threw = $false
try { Set-BakNRetBackupListRow -Text $text -Line 99 -NewText 'x' | Out-Null } catch { $threw = $true }
Assert-True $threw '行号越界必须抛错'
}
Test-Case '保存配置:校验不过什么都不动,过了才备份 + 原子替换,且保留 BOM' {
$dir = Join-Path $env:TEMP ("bnr-save-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$file = Join-Path $dir 'BackupList.txt'
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
[System.IO.File]::WriteAllText($file, "Edge`n", $utf8Bom)
try {
# 1) 校验不过:文件不动、也不产生备份(留备份会让人以为"动过了")
$bad = Save-BakNRetConfigFile -Path $file -Text "坏行`n" -Validate { param($t) @('第 1 行有问题') } -BackupDirectory (Join-Path $dir 'bak')
Assert-Equal $false $bad.Saved '校验不过不能保存'
Assert-True ($bad.Error -like '*第 1 行有问题*') '错误原文要带回来(TUI 要显示它)'
Assert-Equal "Edge`n" ([System.IO.File]::ReadAllText($file)) '文件内容不动'
Assert-True (-not (Test-Path (Join-Path $dir 'bak'))) '校验不过不该产生备份'
# 2) 校验通过:写进去 + 备份里是原文 + 保留 BOM
$ok = Save-BakNRetConfigFile -Path $file -Text "+WindowsTerminal`n" -Validate { param($t) @() } -BackupDirectory (Join-Path $dir 'bak')
Assert-Equal $true $ok.Saved '校验通过应当保存'
Assert-Equal "+WindowsTerminal`n" ([System.IO.File]::ReadAllText($file)) '新内容写进去了'
Assert-True (Test-Path $ok.BackupPath) '应当产生时间戳备份'
Assert-Equal "Edge`n" ([System.IO.File]::ReadAllText($ok.BackupPath)) '备份里是原文'
$bytes = [System.IO.File]::ReadAllBytes($file)
Assert-True ($bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) '写回必须保留 UTF-8 BOM'
} finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
Test-Case '改方向:只动行首标记,其余逐字节保持,且保留用户写法风格' {
Assert-Equal '+Edge' (Set-BakNRetBackupListDirection -Raw 'Edge' -Direction 'backup') '裸行加 +'
Assert-Equal 'Edge' (Set-BakNRetBackupListDirection -Raw '+Edge' -Direction 'both') '去掉标记回到 both'
Assert-Equal '-Edge' (Set-BakNRetBackupListDirection -Raw '+Edge' -Direction 'restore') '备份改恢复'
Assert-Equal '-Edge :encrypt :- logs\' (Set-BakNRetBackupListDirection -Raw '+Edge :encrypt :- logs\' -Direction 'restore') '其余部分逐字节保持'
# 风格保留:留空格的写法不能被改成贴着写(真实清单里两种都有)
Assert-Equal '+ WindowsTerminal' (Set-BakNRetBackupListDirection -Raw '+ WindowsTerminal' -Direction 'backup') '留空格风格保留'
Assert-Equal '- WindowsTerminal' (Set-BakNRetBackupListDirection -Raw '+ WindowsTerminal' -Direction 'restore') '改完仍是留空格'
Assert-Equal 'WindowsTerminal' (Set-BakNRetBackupListDirection -Raw '+ WindowsTerminal' -Direction 'both') '去掉留空格的标记'
# 往返:设成当前方向 → 一行都不该变
foreach ($line in @('Edge', '+Edge', '-Edge', '+ WindowsTerminal', '+Edge :encrypt # 说明')) {
$current = (ConvertFrom-BackupListLine -Line $line).Direction
Assert-Equal $line (Set-BakNRetBackupListDirection -Raw $line -Direction $current) ("设成当前方向应当原样返回:" + $line)
}
# 注释行没有方向可改:抛错而不是静默返回原样
$threw = $false
try { Set-BakNRetBackupListDirection -Raw '# 注释' -Direction 'backup' | Out-Null } catch { $threw = $true }
Assert-True $threw '注释行改方向必须抛错'
}
Test-Case '清单编辑器:脚本驱动走完"选行→改方向→保存",只动一行且留下备份' {
$dir = Join-Path $env:TEMP ("bnr-listed-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$file = Join-Path $dir 'BackupList.txt'
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
$original = "# 注释一`nEdge`n+WindowsTerminal :encrypt`n"
[System.IO.File]::WriteAllText($file, $original, $utf8Bom)
$backupDir = Join-Path $dir 'logs\config-backups'
try {
# 1) 选第一行(回车)、方向菜单预选当前值 both、下移到 backup、回车应用
$r = Invoke-BakNRetBackupListEditor -Path $file -Driver (New-BakNRetInputDriver -Script @('Enter', 'Down', 'Enter'))
Assert-Equal $true $r.Saved '应当保存'
$after = @((Get-Content -LiteralPath $file -Raw -Encoding UTF8) -split "`r?`n")
Assert-Equal '# 注释一' $after[0] '注释行一字不动'
Assert-Equal '+Edge' $after[1] '第一行改成了 +Edge'
Assert-Equal '+WindowsTerminal :encrypt' $after[2] '第三行一字不动'
Assert-True (Test-Path $r.BackupPath) '应当留下时间戳备份'
Assert-Equal $original ([System.IO.File]::ReadAllText($r.BackupPath)) '备份里是原文'
# 2) 取消(Esc):什么都不写,也不产生新备份
$backupsBefore = @(Get-ChildItem $backupDir -File | Measure-Object).Count
$contentBefore = [System.IO.File]::ReadAllText($file)
$c = Invoke-BakNRetBackupListEditor -Path $file -Driver (New-BakNRetInputDriver -Script @('Esc'))
Assert-Equal $false $c.Saved '取消不该保存'
Assert-Equal $false $c.Changed '取消不该标记为已改'
Assert-Equal $contentBefore ([System.IO.File]::ReadAllText($file)) '取消后文件内容不变'
Assert-Equal $backupsBefore @(Get-ChildItem $backupDir -File | Measure-Object).Count '取消不该新增备份'
# 3) 方向没变:菜单预选当前值 backup,直接回车 → 说明"没有变化",不白写一次盘
$same = Invoke-BakNRetBackupListEditor -Path $file -Driver (New-BakNRetInputDriver -Script @('Enter', 'Enter'))
Assert-Equal $false $same.Saved '方向没变不该保存'
Assert-True ($same.Error -like '*没有变化*') '应当说明原因'
Assert-Equal $contentBefore ([System.IO.File]::ReadAllText($file)) '文件内容仍然不变'
} finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
Test-Case '源码里不得出现"左边空的赋值"(用脚本生成代码时插值把左边吃掉的指纹)' {
# 这条检查是为一个反复出现的坑立的:用双引号拼一段 PowerShell 代码时,$变量 会在**生成脚本
# 的那一刻**被插值成空,写出来的文件里就出现 ` = 'SilentlyContinue'` 这种行 —— 而它是
# **合法语法**(等于调用一个叫 'SilentlyContinue' 的命令),Parse 层抓不到,只有跑到那一步
# 才炸。这个坑在同一块代码里出现过五次、症状每次都不一样,所以不再靠"我记得"。
$repoRoot = Split-Path -Parent $PSScriptRoot
$skip = '\\(\.git|\.tools|\.scratch|Backups|logs|dist)\\'
$offenders = @()
foreach ($file in @(Get-ChildItem -LiteralPath $repoRoot -Recurse -File -Include *.ps1, *.psm1 | Where-Object { $_.FullName -notmatch $skip })) {
$number = 0
foreach ($line in @(Get-Content -Encoding UTF8 -LiteralPath $file.FullName)) {
$number++
if ($line.TrimStart() -match '^=\s') { $offenders += ($file.Name + ':' + $number + ' ' + $line.Trim()) }
}
}
Assert-Equal 0 $offenders.Count ('这些行看起来是赋值、左边却是空的:' + ($offenders -join ' / '))
}
Test-Case '主入口:-Quiet 不画界面、菜单能渲染、进不去界面时明确失败(退出码 2)' {
$repoRoot = Split-Path -Parent $PSScriptRoot
$probe = Join-Path $env:TEMP ("bnr-entry-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $probe -Force | Out-Null
try {
# 输出检查走**文件重定向**(子 pwsh 自己把输出写文件),不走管道捕获 ——
# 与 E2E 套件同一个理由:沙箱下给子进程建管道可能失败,而写文件不会。
$quietOut = Join-Path $probe 'quiet.txt'
& pwsh -NoProfile -File (Join-Path $repoRoot 'Manage-Backup.ps1') -Action Backup -Quiet -DryRun *> $quietOut
Assert-Equal 0 $LASTEXITCODE '-Quiet 干跑应当退出 0'
$quietText = Get-Content -LiteralPath $quietOut -Raw -Encoding UTF8
Assert-True ($quietText -match '日志文件') '-Quiet 下子进程的输出应当被转发出来'
Assert-True ($quietText -notmatch '↑↓') '-Quiet 下不该出现任何 TUI 提示'
# 菜单:给按键序列就能驱动(Esc 直接退出,退出码 0),并且真的画了界面
$menuOut = Join-Path $probe 'menu.txt'
& pwsh -NoProfile -File (Join-Path $repoRoot 'Manage-Backup.ps1') -InputScript Esc *> $menuOut
Assert-Equal 0 $LASTEXITCODE '菜单按 Esc 应当退出 0'
$menuText = Get-Content -LiteralPath $menuOut -Raw -Encoding UTF8
Assert-True ($menuText -match '↑↓ 选择') '菜单应当渲染出提示行'
Assert-True ($menuText -match '备份') '菜单应当列出动作'
# 没有控制台也没有按键序列:明确失败,绝不挂起
$noConsoleOut = Join-Path $probe 'noconsole.txt'
& pwsh -NoProfile -File (Join-Path $repoRoot 'Manage-Backup.ps1') *> $noConsoleOut
Assert-Equal 2 $LASTEXITCODE '进不了交互界面应当退出 2'
$ncText = Get-Content -LiteralPath $noConsoleOut -Raw -Encoding UTF8
Assert-True ($ncText -match '-Action') '报错里应当告诉你怎么非交互执行'
} finally {
Remove-Item -LiteralPath $probe -Recurse -Force -ErrorAction SilentlyContinue
}
}
Test-Case '配置标量:只认单行标量;改一个值只动那一行;设成当前值则逐字节不变' {
$configPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'
$text = Get-Content -LiteralPath $configPath -Raw -Encoding UTF8
$rows = @(Get-BakNRetConfigSetting -Text $text)
Assert-True ($rows.Count -ge 15) ('真实配置里应当认出足够多的标量,实际 ' + $rows.Count)
Assert-Equal "'Backups'" ($rows | Where-Object { $_.Path -eq 'BackupDir' }).Value '顶层标量(读出来的是含引号的原文 —— 原样写回才逐字节等价)'
Assert-Equal 'Snapshot.Enabled' (($rows | Where-Object { $_.Path -eq 'Snapshot.Enabled' }).Path) '嵌套里的键要用点号路径(Enabled 有两处)'
Assert-Equal '$true' (($rows | Where-Object { $_.Path -eq 'Encryption.EncryptHeaders' }).Value) '布尔值按原文读出($true 本身就是原文)'
# 非标量必须被排除:行内空哈希与数组都不是"一行一个值"
Assert-Equal 0 @($rows | Where-Object { $_.Path -eq 'Security.SidMap' }).Count '行内哈希不算标量'
Assert-Equal 0 @($rows | Where-Object { $_.Path -like 'DefaultExcludes*' }).Count '数组不算标量'
# 往返:把每个标量设成它当前的值,文本必须**逐字节相同**(外科式改写的最强形式)
$roundTripped = $text
foreach ($row in $rows) { $roundTripped = Set-BakNRetConfigSetting -Text $roundTripped -Path $row.Path -Value $row.Value }
Assert-Equal $text $roundTripped '每个标量设成当前值后文本逐字节相同'
# 真改一个:只有那一行不同,注释行数不变,并且改完还能读出新值
$changed = Set-BakNRetConfigSetting -Text $text -Path 'BackupDir' -Value "'Backups2'"
$before = @($text -split "`n")
$after = @($changed -split "`n")
Assert-Equal $before.Count $after.Count '行数不变'
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
$commentCount = @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count
Assert-Equal $commentCount @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变'
Assert-Equal "'Backups2'" ((Get-BakNRetConfigSetting -Text $changed | Where-Object { $_.Path -eq 'BackupDir' }).Value) '改完还能读出同一个值'
# 找不到 / 不是标量:必须抛错,不能静默不动
$threw = $false
try { Set-BakNRetConfigSetting -Text $text -Path 'No.Such.Key' -Value '1' | Out-Null } catch { $threw = $true }
Assert-True $threw '找不到的路径必须抛错'
$threw2 = $false
try { Set-BakNRetConfigSetting -Text $text -Path 'Security.SidMap' -Value '@{}' | Out-Null } catch { $threw2 = $true }
Assert-True $threw2 '非标量(行内哈希)也必须抛错'
}
Test-Case '输入行:字符追加、Backspace 删除、Esc 取消返回 $null(与空串区分)' {
# 全部用按键序列驱动 —— 这也是它不用 Read-Host 的原因:Read-Host 在门禁里会挂住。
Assert-Equal 'ab' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:a', 'Char:b', 'Enter'))) '逐字符输入'
Assert-Equal 'a' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:a', 'Char:b', 'Backspace', 'Enter'))) 'Backspace 删一个'
Assert-Equal '' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Backspace', 'Enter'))) '空缓冲按 Backspace 不抛错'
Assert-True ($null -eq (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:a', 'Esc')))) 'Esc 应当返回 $null 表示取消'
Assert-Equal '' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Enter'))) '直接回车给空串(空串是合法意图)'
Assert-Equal 'xy' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:y', 'Enter')) -Initial 'x') '初始值之后继续追加'
Assert-Equal 'ab' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:a', 'Down', 'Char:b', 'Enter'))) '方向键在输入行里被忽略(无副作用)'
Assert-Equal 'k' (Read-BakNRetLine -Driver (New-BakNRetInputDriver -Script @('Char:K', 'Enter'))) '字母按小写存(与键名归一化一致)'
}
Test-Case '配置编辑器:脚本驱动改一个值,只动那一行且留下备份;取消不写盘' {
$dir = Join-Path $env:TEMP ("bnr-cfged-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$file = Join-Path $dir 'BackupConfig.psd1'
Copy-Item -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1') -Destination $file
$original = [System.IO.File]::ReadAllText($file)
$backupDir = Join-Path $dir 'logs\config-backups'
try {
# MinFreeSpaceGB 是文件里第 5 个标量设置:下移四次选中它,输入 9
$keys = @('Down', 'Down', 'Down', 'Down', 'Enter', 'Char:9', 'Enter')
$result = Invoke-BakNRetConfigEditor -Path $file -Driver (New-BakNRetInputDriver -Script $keys)
Assert-Equal $true $result.Saved '应当保存'
$before = @($original -split "`n")
$after = @([System.IO.File]::ReadAllText($file) -split "`n")
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
Assert-Equal @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变'
Assert-True (([System.IO.File]::ReadAllText($file)) -match 'MinFreeSpaceGB = 9') '新值写进去了'
Assert-True (Test-Path $result.BackupPath) '应当留下时间戳备份'
Assert-Equal $original ([System.IO.File]::ReadAllText($result.BackupPath)) '备份里是原文'
# 取消(选设置时 Esc):不写盘、不产生新备份
$countBefore = @(Get-ChildItem $backupDir -File | Measure-Object).Count
$contentBefore = [System.IO.File]::ReadAllText($file)
$cancelled = Invoke-BakNRetConfigEditor -Path $file -Driver (New-BakNRetInputDriver -Script @('Esc'))
Assert-Equal $false $cancelled.Saved '取消不该保存'
Assert-Equal $contentBefore ([System.IO.File]::ReadAllText($file)) '取消后内容不变'
Assert-Equal $countBefore @(Get-ChildItem $backupDir -File | Measure-Object).Count '取消不该新增备份'
} finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
Test-Case '名录字段:只有单行的 Encrypt / Description 可编辑;改一个只动那一行;往返逐字节等价' {
$catalogPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'SoftwareCatalog.psd1'
$text = Get-Content -LiteralPath $catalogPath -Raw -Encoding UTF8
$fields = @(Get-BakNRetCatalogField -Text $text)
Assert-True ($fields.Count -ge 60) ('应当认出足够多的 Slot 字段,实际 ' + $fields.Count)
# 头部注释模板里的 SoftWareName 示例必须被排除(块注释要跳过)
Assert-Equal 0 @($fields | Where-Object { $_.App -eq 'SoftWareName' }).Count '模板示例不该被当成软件'
# 可编辑面的规则:只有单行的 Encrypt / Description
$editable = @($fields | Where-Object { $_.Editable })
Assert-True ($editable.Count -ge 40) ('可编辑字段应当有四十多个,实际 ' + $editable.Count)
Assert-Equal 0 @($editable | Where-Object { $_.Key -notin @('Encrypt', 'Description') }).Count '只有 Encrypt / Description 可编辑'
Assert-Equal 0 @($fields | Where-Object { $_.Key -eq 'Path' -and $_.Editable }).Count 'Path 一律只读(常带 $() 表达式)'
Assert-Equal 0 @($fields | Where-Object { $_.Key -eq 'Exclude' -and $_.Editable }).Count 'Exclude 一律只读(Edge 那条是跨行拼接)'
# Edge 的 Description 是跨行的:必须只读
$edgeDescription = @($fields | Where-Object { $_.App -eq 'MicrosoftEdge' -and $_.Key -eq 'Description' })
Assert-Equal 1 $edgeDescription.Count 'Edge 的 Description 应当认出来'
Assert-Equal $false $edgeDescription[0].Editable '跨行的 Description 必须只读'
# 往返:把每个可编辑字段设成它当前的值,文本必须逐字节相同
$roundTripped = $text
foreach ($field in $editable) {
$roundTripped = Set-BakNRetCatalogField -Text $roundTripped -App $field.App -Slot $field.Slot -Key $field.Key -Value $field.Value
}
Assert-Equal $text $roundTripped '每个可编辑字段设成当前值后文本逐字节相同'
# 真改一个(AutoDarkMode 的 Encrypt),只动那一行、注释行数不变、改完能读回新值
$changed = Set-BakNRetCatalogField -Text $text -App 'AutoDarkMode' -Slot 'DefaultData' -Key 'Encrypt' -Value '$false'
$before = @($text -split "`n")
$after = @($changed -split "`n")
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
Assert-Equal @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变'
Assert-Equal '$false' ((Get-BakNRetCatalogField -Text $changed | Where-Object { $_.App -eq 'AutoDarkMode' -and $_.Key -eq 'Encrypt' }).Value) '改完能读回新值'
# 拒绝:不可编辑的字段、跨行的 Description、不存在的字段,都必须抛错
foreach ($bad in @(
@{ A = 'AutoDarkMode'; S = 'DefaultData'; K = 'Path' },
@{ A = 'MicrosoftEdge'; S = 'DefaultData'; K = 'Description' },
@{ A = 'NoSuchApp'; S = 'NoSuchSlot'; K = 'Encrypt' }
)) {
$threw = $false
try { Set-BakNRetCatalogField -Text $text -App $bad.A -Slot $bad.S -Key $bad.K -Value 'x' | Out-Null } catch { $threw = $true }
Assert-True $threw ('应当拒绝:' + $bad.A + '.' + $bad.S + '.' + $bad.K)
}
}
Test-Case '名录编辑器:脚本驱动改一个 Encrypt,只动那一行且留下备份;取消不写盘' {
$dir = Join-Path $env:TEMP ("bnr-cated-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
$file = Join-Path $dir 'SoftwareCatalog.psd1'
Copy-Item -LiteralPath (Join-Path (Split-Path -Parent $PSScriptRoot) 'SoftwareCatalog.psd1') -Destination $file
$original = [System.IO.File]::ReadAllText($file)
$backupDir = Join-Path $dir 'logs\config-backups'
try {
# 第一个可编辑字段是 AutoDarkMode.DefaultData.Encrypt(当前 $true)→ 输入 $false
$keys = @('Enter', 'Char:$', 'Char:f', 'Char:a', 'Char:l', 'Char:s', 'Char:e', 'Enter')
$result = Invoke-BakNRetCatalogEditor -Path $file -Driver (New-BakNRetInputDriver -Script $keys)
Assert-Equal $true $result.Saved '应当保存'
$before = @($original -split "`n")
$after = @([System.IO.File]::ReadAllText($file) -split "`n")
Assert-Equal 2 @(Compare-Object -ReferenceObject $before -DifferenceObject $after).Count '只应当有一行不同'
Assert-Equal @($before | Where-Object { $_.TrimStart().StartsWith('#') }).Count @($after | Where-Object { $_.TrimStart().StartsWith('#') }).Count '注释行数不变'
$nowValue = ((Get-BakNRetCatalogField -Text ([System.IO.File]::ReadAllText($file)) | Where-Object { $_.App -eq 'AutoDarkMode' -and $_.Slot -eq 'DefaultData' -and $_.Key -eq 'Encrypt' }).Value)
Assert-Equal '$false' $nowValue '改完能读回新值'
Assert-True (Test-Path $result.BackupPath) '应当留下时间戳备份'
Assert-Equal $original ([System.IO.File]::ReadAllText($result.BackupPath)) '备份里是原文'
# 取消(选字段时 Esc):不写盘、不产生新备份
$countBefore = @(Get-ChildItem $backupDir -File | Measure-Object).Count
$contentBefore = [System.IO.File]::ReadAllText($file)
$cancelled = Invoke-BakNRetCatalogEditor -Path $file -Driver (New-BakNRetInputDriver -Script @('Esc'))
Assert-Equal $false $cancelled.Saved '取消不该保存'
Assert-Equal $contentBefore ([System.IO.File]::ReadAllText($file)) '取消后内容不变'
Assert-Equal $countBefore @(Get-ChildItem $backupDir -File | Measure-Object).Count '取消不该新增备份'
} finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# ============================================================================
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