装配:选行 → 选新方向 → 逐行校验 → 校验后原子保存 + 时间戳备份。输入走 -Driver,所以门禁里能用 -InputScript @('Enter','Down','Enter') 走完整流程,断言看返回值与**文件内容**,不需要捕获控制台输出。
四条"不猜":取消时什么都不写也不产生备份;方向菜单**预选当前值**(不预选的话"连按两次回车"会把字段改成菜单第一项 —— 不是没改,是改错了);方向没变直接返回"没有变化";保存前逐行校验(每行都要能被 ConvertFrom-BackupListLine 解析)—— 这正是 TUI 编辑器比手改安全的地方。
这个函数我试了两次才落地,两次都栽在同一件事上:**用脚本做多行字符串替换时,换行被折成空格**,于是注释与赋值落进同一行、被整段注释掉(症状是"对象没有属性"/"流程莫名走取消分支")。所以这条现在当硬约束执行:多行代码一律整份写文件,不做插入+删除式的拼接。
判据 12 条,覆盖保存 / 取消 / 无变化三条路径,并逐条断言注释行与未编辑行一字不动、备份里存原文、取消时不新增备份。
验收:test.ps1 9/9 全绿(5.1 与 7)。
1697 lines
89 KiB
PowerShell
1697 lines
89 KiB
PowerShell
<#
|
||
.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
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
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
|