整个 TUI 的地基:位置、对齐、边框都靠它。而它算错了**不会报错** —— 表现只是菜单右边框歪一点、光标偏一列,所以每条边界都在断言里钉住。 为什么必须自己算:.Length 是 UTF-16 code unit 的个数(汉字 1 个 char、2 列);而 $Host.UI.RawUI.LengthInBufferCells 在 PowerShell 7 上对、在 5.1 上错(实测 中文 返回 2、字体边框字符 返回 6),且不抛错 —— 这种"一边对一边错"的 API 比两边都错更危险。 表里三个边界是实测踩过的:半角片假名 U+FF61–FF9F 是 1 列(不能划进全角区);全角拉丁 U+FF00–FF60 是 2 列;emoji 是代理对,按一个码点算。 验收:断言 8 条(含空串与控制字符);test.ps1 9/9 全绿(5.1 与 7)。
1444 lines
73 KiB
PowerShell
1444 lines
73 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 列'
|
||
}
|
||
|
||
# ============================================================================
|
||
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
|