P0 正确性 - 退出码:改用 .NET Process 直接启动、让子进程继承控制台,不再用 Start-Process -PassThru (在 7.7.0-preview.4 上 ExitCode 恒为 $null,会把成功的压缩判成失败); 7z / RAR / tar 三条解压分支统一走同一个取退出码的封装。 - BackupList 解析:先按第一个 :: 切段再处理引号(整行被一对引号包住的写法不再把排除表 吞进路径);排除表同时接受 , 与 ;(旧实现只认 ;,导致排除从未生效);支持 :- / :+ / @flag。 - 补回 .ssh 与孤儿归档:.ssh 进清单;孤儿归档在备份端也做审计并点名; 带 -Only / -Skip 时不再把未选中的归档误报成孤儿。 - Resolve-BackupEntry 里 $rootName 在赋值前被引用(会读到外层作用域残留值),已提前赋值。 P1 归档可靠性 - 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因, 并区分 warnings(在位归档)与 attemptWarnings(本次尝试)。 - 归档后做 7z t 内容校验,先写 .tmp、校验通过再原子替换(File.Move overwrite)。 - manifest.roots 记录归档内**真实**的顶层条目名(原先记的是软件名,Edge 实际是 "User Data")。 P2 可用性 - Restore 支持 -WhatIf / -DryRun / -VerifyOnly / -Only / -Skip; 这三种"只看不写"的模式一个字节都不写(原先会写回 manifest.json)。 - Edge 等高缓存条目加排除规则并实测:1781 MB / 27961 项 -> 72 MB / 2294 项; 书签、密码、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留。 普通模式是相对归档根目录锚定的,嵌套的那些(如 OneAuth\WebView2 里的 Crashpad) 改用 ! 组件形式才会命中。 - 日志落盘 logs/<backup|restore>-<时间戳>.log;退出码按失败数返回。 - tools/Register-BackupTask.ps1 注册每日计划任务;tools/Rename-Archives.ps1 迁移旧归档名。 - root= 标记此前静默失效,现在明确告警(该功能尚未实现)。 P3 测试与验证 - tests/BakNRet.Tests.ps1:Pester 5 套件 62 项(含用子进程跑 Backup.ps1 / Restore.ps1 的端到端与针对上述缺陷的回归)。 - tests/Run-Pester.ps1 + tools/Install-TestDependencies.ps1:把 Pester 装到仓库内 .tools/, 不动机器上的全局模块(系统自带的 3.4.0 缺 Should -Be)。 - tests/Restore-Drill.ps1:真实归档恢复演练,明确区分"源在备份后变过"与"归档/解压有问题"。 - tests/Run-Tests.ps1(49 项,零依赖)与 tests/Run-E2E.ps1(23 项)继续可用;三套共 134 项全通过。 真实机器验证 - 生产归档 22/22 通过 7z t;-VerifyOnly 不再改动 manifest.json(SHA256 前后一致)。 - 真实恢复演练 12/12 通过,27,670 个文件与活源逐字节一致。 - 修复了生产 scoop-persist.7z:原先只有 90 字节(空归档)而源有 1.3 GB, 重打包后 233 MB,恢复演练 26981/26981 全部一致。
519 lines
24 KiB
PowerShell
519 lines
24 KiB
PowerShell
<#
|
||
.SYNOPSIS
|
||
BakNRet 测试套件(零依赖,Windows PowerShell 5.1 与 PowerShell 7.x 均可运行)。
|
||
|
||
.DESCRIPTION
|
||
为什么不用 Pester:本机只装了 Pester 3.4.0,`Should -Be` 这类 Pester 4+ 语法
|
||
直接语法错误,而为了跑测试去装 Pester 5 不值得。这里自带一个极简断言运行器,
|
||
对备份工具这种小脚本来说更可靠。
|
||
|
||
覆盖范围:
|
||
* BackupList.txt 语法解析(包含历史上出错的两种写法)
|
||
* 归档命名与逆向解析
|
||
* 7z 排除参数翻译(含空格、! 前缀、重复前缀、禁用引号)
|
||
* Windows 命令行拼接
|
||
* manifest 读写往返
|
||
* 配置默认值与嵌套合并
|
||
* 集成测试:真的调用 7z 打包 + 解压,验证排除结果落在文件系统上
|
||
|
||
.EXAMPLE
|
||
pwsh -File .\tests\Run-Tests.ps1
|
||
#>
|
||
|
||
[CmdletBinding()]
|
||
param(
|
||
[string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'Common.psm1')
|
||
)
|
||
|
||
$ErrorActionPreference = 'Stop'
|
||
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 '裸路径' {
|
||
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
|
||
Assert-Equal 'C:\Programs\FooClolor' $r.Path
|
||
Assert-Equal 0 $r.ExcludePatterns.Count
|
||
Assert-Equal 0 $r.Flags.Count
|
||
}
|
||
|
||
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]
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
Test-Case '整行被一对引号包住时,:: 之后的排除表不被吞进路径(真实第 27 行)' {
|
||
$line = '"C:\Programs\ScoopApps\persist :: persist\a\DawnCache,persist\a\GPUCache"'
|
||
$r = ConvertFrom-BackupListLine -Line $line
|
||
Assert-Equal 'C:\Programs\ScoopApps\persist' $r.Path
|
||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||
Assert-Equal 'persist\a\DawnCache' $r.ExcludePatterns[0]
|
||
}
|
||
|
||
Test-Case '@ 标记单独出现' {
|
||
$r = ConvertFrom-BackupListLine -Line '%UserProfile%\.ssh @encrypt'
|
||
Assert-Equal '%UserProfile%\.ssh' $r.Path
|
||
Assert-Equal 1 $r.Flags.Count
|
||
Assert-Equal 'encrypt' $r.Flags[0]
|
||
Assert-Equal 0 $r.ExcludePatterns.Count
|
||
}
|
||
|
||
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 'encrypt' $r.Flags[0]
|
||
}
|
||
Test-Case ':+ 追加目录(可多个、位置无关)' {
|
||
$r = ConvertFrom-BackupListLine -Line 'MyApp :+ D:\a :+ D:\b'
|
||
Assert-Equal 'MyApp' $r.Path
|
||
Assert-Equal 2 $r.AddedPaths.Count
|
||
Assert-Equal 'D:\a' $r.AddedPaths[0]
|
||
Assert-Equal 'D:\b' $r.AddedPaths[1]
|
||
Assert-Equal 0 $r.ExcludePatterns.Count
|
||
}
|
||
|
||
Test-Case ':- 排除,与 :+ 混用且顺序任意' {
|
||
$r = ConvertFrom-BackupListLine -Line 'MyApp :- logs\ :+ D:\a :- !*Cache'
|
||
Assert-Equal 'MyApp' $r.Path
|
||
Assert-Equal 1 $r.AddedPaths.Count
|
||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||
Assert-Equal 'logs\' $r.ExcludePatterns[0]
|
||
Assert-Equal '!*Cache' $r.ExcludePatterns[1]
|
||
}
|
||
|
||
Test-Case ':: 与 :- 等价' {
|
||
$a = ConvertFrom-BackupListLine -Line 'X :: logs\'
|
||
$b = ConvertFrom-BackupListLine -Line 'X :- logs\'
|
||
Assert-Equal $a.ExcludePatterns[0] $b.ExcludePatterns[0]
|
||
Assert-Equal 'logs\' $b.ExcludePatterns[0]
|
||
}
|
||
|
||
Test-Case ':+ 段里的 @标记也能被摘出' {
|
||
$r = ConvertFrom-BackupListLine -Line 'MyApp :+ D:\a @encrypt'
|
||
Assert-Equal 'MyApp' $r.Path
|
||
Assert-Equal 'D:\a' $r.AddedPaths[0]
|
||
Assert-Equal 'encrypt' $r.Flags[0]
|
||
}
|
||
|
||
Test-Case '盘符里的单个冒号不被误当分隔符' {
|
||
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\Foo'
|
||
Assert-Equal 'C:\Programs\Foo' $r.Path
|
||
Assert-Equal 0 $r.AddedPaths.Count
|
||
Assert-Equal 0 $r.ExcludePatterns.Count
|
||
}
|
||
|
||
Test-Case '名录 Dirs 数组:一个软件多个目录' {
|
||
$dirsSandbox = Join-Path $env:TEMP ("baknret-dirs-" + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||
New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'App') -Force | Out-Null
|
||
New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'Config') -Force | Out-Null
|
||
$catPath = Join-Path $dirsSandbox 'c.psd1'
|
||
$content = "@{`n MyApp = @{ Dirs = @('$dirsSandbox\App', '$dirsSandbox\Config') }`n}`n"
|
||
[System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false))
|
||
|
||
$catalog = Get-SoftwareCatalog -Path $catPath
|
||
Assert-Equal 'Multi' $catalog['MyApp'].Kind
|
||
Assert-Equal 2 $catalog['MyApp'].Dirs.Count
|
||
|
||
$item = ConvertFrom-BackupListLine -Line 'MyApp'
|
||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath
|
||
Assert-Equal 'MyApp' $resolved.BaseName
|
||
Assert-Equal 2 $resolved.Sources.Count '每个目录都该成为一条源'
|
||
Assert-Equal 'App' $resolved.Sources[0].RelativePaths[0]
|
||
Assert-Equal 'Config' $resolved.Sources[1].RelativePaths[0]
|
||
Remove-Item -LiteralPath $dirsSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
Test-Case '清单 :+ 追加的目录叠加在名录目录之后' {
|
||
$dirsSandbox = Join-Path $env:TEMP ("baknret-adds-" + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||
New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'App') -Force | Out-Null
|
||
New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'Extra') -Force | Out-Null
|
||
$catPath = Join-Path $dirsSandbox 'c.psd1'
|
||
$content = "@{`n MyApp = '$dirsSandbox\App'`n}`n"
|
||
[System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false))
|
||
|
||
$item = ConvertFrom-BackupListLine -Line "MyApp :+ $dirsSandbox\Extra"
|
||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath
|
||
Assert-Equal 2 $resolved.Sources.Count '名录 1 个 + 追加 1 个'
|
||
Assert-Equal 'App' $resolved.Sources[0].RelativePaths[0]
|
||
Assert-Equal 'Extra' $resolved.Sources[1].RelativePaths[0]
|
||
Remove-Item -LiteralPath $dirsSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== 归档命名 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
Test-Case '基础命名规则' {
|
||
Assert-Equal 'legendary_from_%UserProfile%+.config' (Get-BackupBaseName -RawPath '%UserProfile%/.config/legendary')
|
||
Assert-Equal 'FooClolor_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\FooClolor')
|
||
Assert-Equal 'settings.json_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\settings.json')
|
||
}
|
||
|
||
Test-Case '/ 与 \ 以及重复分隔符结果一致' {
|
||
$a = Get-BackupBaseName -RawPath '%UserProfile%/.config/scoop'
|
||
$b = Get-BackupBaseName -RawPath '%UserProfile%\.config\scoop'
|
||
$c = Get-BackupBaseName -RawPath '%UserProfile%\\Documents\\WindowsPowerShell'
|
||
Assert-Equal $a $b
|
||
Assert-Equal 'WindowsPowerShell_from_%UserProfile%+Documents' $c
|
||
}
|
||
|
||
Test-Case '命名 <-> 路径往返' {
|
||
$cases = @(
|
||
'%UserProfile%/.config/legendary',
|
||
'C:\Programs\FooClolor',
|
||
'%LocalAppData%\Microsoft\Edge\User Data'
|
||
)
|
||
foreach ($case in $cases) {
|
||
$base = Get-BackupBaseName -RawPath $case
|
||
$back = Convert-BackupFileNameToPath -FileName "$base.7z"
|
||
Assert-Equal ($case -replace '[/\\]+', '\') $back "往返 $case"
|
||
}
|
||
}
|
||
|
||
Test-Case '归档名里不含非法文件名字符' {
|
||
$base = Get-BackupBaseName -RawPath 'C:\a\b?c*d|e'
|
||
$invalid = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') }
|
||
foreach ($ch in $invalid) {
|
||
Assert-False ($base.Contains([string]$ch)) "归档名里出现了非法字符 $ch"
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== 7z 排除参数翻译 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
Test-Case '相对模式自动补上归档根目录名' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'March7thAssistant' -Patterns @('logs\')
|
||
Assert-Equal '-x!March7thAssistant\logs' $exclude[0]
|
||
}
|
||
|
||
Test-Case '已经带根目录名时不重复前缀' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'March7thAssistant' -Patterns @('March7thAssistant\logs\')
|
||
Assert-Equal '-x!March7thAssistant\logs' $exclude[0]
|
||
}
|
||
|
||
Test-Case '! 前缀翻译成递归组件匹配' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache')
|
||
Assert-Equal '-xr!*Cache' $exclude[0]
|
||
}
|
||
|
||
Test-Case '模式里的空格转成 ? (7z 的模式不支持空格)' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('Default\Code Cache')
|
||
Assert-Equal '-x!User?Data\Default\Code?Cache' $exclude[0]
|
||
}
|
||
|
||
Test-Case '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
|
||
$patterns = @('Default\Code Cache', '!*Cache', 'logs\', 'March7thAssistant\3rdparty\WebBrowser\UserProfile\Integrated')
|
||
foreach ($pattern in $patterns) {
|
||
foreach ($entry in (Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @($pattern))) {
|
||
Assert-False ($entry.Contains('"')) "参数里出现了引号: $entry"
|
||
}
|
||
}
|
||
}
|
||
|
||
Test-Case '空模式被忽略' {
|
||
$exclude = Get-ArchiveExcludeArgument -ItemName 'x' -Patterns @('', ' ', 'real\')
|
||
Assert-Equal 1 $exclude.Count
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== 命令行拼接 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
Test-Case '无空格参数原样输出' {
|
||
Assert-Equal 'a -t7z' (ConvertTo-NativeArgumentString -ArgumentList @('a', '-t7z'))
|
||
}
|
||
|
||
Test-Case '含空格参数加引号' {
|
||
Assert-Equal '"C:\Program Files\x"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\x'))
|
||
}
|
||
|
||
Test-Case '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
|
||
Assert-Equal '"C:\Program Files\\"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\'))
|
||
}
|
||
|
||
Test-Case '内部引号被转义' {
|
||
Assert-Equal '"say \"hi\""' (ConvertTo-NativeArgumentString -ArgumentList @('say "hi"'))
|
||
}
|
||
|
||
Test-Case '空参数输出一对空引号' {
|
||
Assert-Equal '""' (ConvertTo-NativeArgumentString -ArgumentList @(''))
|
||
}
|
||
|
||
# ============================================================================
|
||
Write-Host "`n== manifest 与配置 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
$sandbox = Join-Path $env:TEMP ("baknret-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
|
||
Test-Case 'manifest 读写往返' {
|
||
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['FooClolor_from_C_+Programs'] = [ordered]@{ baseName = 'FooClolor_from_C_+Programs'; action = 'backed-up'; archiveBytes = 12345 }
|
||
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['FooClolor_from_C_+Programs'].action
|
||
|
||
$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 -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== 软件名录 ==" -ForegroundColor Cyan
|
||
# ============================================================================
|
||
|
||
$catalogSandbox = Join-Path $env:TEMP ("baknret-cat-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||
New-Item -ItemType Directory -Path $catalogSandbox -Force | Out-Null
|
||
|
||
Test-Case '名录解析:裸键、引号键、带 - 与 . 的名字' {
|
||
$catPath = Join-Path $catalogSandbox 'SoftwareCatalog.psd1'
|
||
$content = "@{`n FooClolor = 'C:\Programs\FooClolor'`n 'scoop-config' = '%UserProfile%\.config\scoop'`n '.ssh' = '%UserProfile%\.ssh'`n}`n"
|
||
[System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false))
|
||
|
||
$catalog = Get-SoftwareCatalog -Path $catPath
|
||
Assert-Equal 3 $catalog.Count
|
||
Assert-True $catalog.ContainsKey('FooClolor')
|
||
Assert-True $catalog.ContainsKey('scoop-config') '带连字符的键必须能解析'
|
||
Assert-True $catalog.ContainsKey('.ssh') '带点的键必须能解析'
|
||
}
|
||
|
||
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 = '$root\legendary'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||
|
||
$catalog = Get-SoftwareCatalog -Path $catPath
|
||
Assert-Equal 'Single' $catalog['legendary'].Kind
|
||
Assert-True ($catalog['legendary'].ResolvedPath -like '*legendary_2.0.4') '应补全到实际目录'
|
||
}
|
||
|
||
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 = '$root\Legendary'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||
|
||
$catalog = Get-SoftwareCatalog -Path $catPath
|
||
Assert-Equal 'Unresolved' $catalog['Legendary'].Kind '必须以下一个字符是 _ 或 - 为界'
|
||
}
|
||
|
||
Test-Case '软件名条目:默认用软件名做归档名' {
|
||
$item = ConvertFrom-BackupListLine -Line 'FooClolor'
|
||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1')
|
||
Assert-Equal 'FooClolor' $resolved.BaseName
|
||
Assert-Equal 'name' $resolved.ArchiveFlavor
|
||
Assert-True $resolved.IsName
|
||
}
|
||
|
||
Test-Case '字面路径条目:仍用路径命名算法(现有清单无需改写)' {
|
||
$item = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
|
||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1')
|
||
Assert-False $resolved.IsName
|
||
Assert-Equal 'path' $resolved.ArchiveFlavor
|
||
Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName
|
||
}
|
||
|
||
Test-Case '@pathname 用真实路径命名,而不是软件名' {
|
||
$item = ConvertFrom-BackupListLine -Line 'FooClolor @pathname'
|
||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1')
|
||
Assert-Equal 'path' $resolved.ArchiveFlavor
|
||
Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName
|
||
}
|
||
|
||
Test-Case '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' {
|
||
$item = ConvertFrom-BackupListLine -Line 'no-such-thing'
|
||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1')
|
||
Assert-Equal 'no-such-thing' $resolved.BaseName
|
||
Assert-True ($null -ne $resolved.Error) '应给出错误说明'
|
||
Assert-Equal 0 $resolved.Sources.Count
|
||
}
|
||
|
||
Test-Case '名录里的路径不存在时仍给出 Sources(恢复要靠它还原回原位)' {
|
||
$catPath = Join-Path $catalogSandbox 'gone.psd1'
|
||
[System.IO.File]::WriteAllText($catPath, "@{`n 'gone-app' = 'C:\definitely\not\here'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||
$item = ConvertFrom-BackupListLine -Line 'gone-app'
|
||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath
|
||
Assert-True ($null -ne $resolved.Error) '备份端据 Error 跳过'
|
||
Assert-Equal 1 $resolved.Sources.Count '恢复端据 Sources 还原'
|
||
Assert-Equal 'C:\definitely\not\here' $resolved.Sources[0].SourcePath
|
||
}
|
||
|
||
Test-Case 'Includes:分文件维护的名录会被合并' {
|
||
$basePath = Join-Path $catalogSandbox 'Base.psd1'
|
||
$extraPath = Join-Path $catalogSandbox 'Extra.psd1'
|
||
[System.IO.File]::WriteAllText($extraPath, "@{`n 'extra-app' = 'C:\Programs\Extra'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||
[System.IO.File]::WriteAllText($basePath, "@{`n Includes = @('Extra.psd1')`n 'base-app' = 'C:\Programs\Base'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||
|
||
$catalog = Get-SoftwareCatalog -Path $basePath
|
||
Assert-True $catalog.ContainsKey('base-app')
|
||
Assert-True $catalog.ContainsKey('extra-app') 'Includes 引入的条目也应在'
|
||
}
|
||
|
||
Test-Case '软件名会被规范化成合法文件名' {
|
||
Assert-Equal 'keep%and&and+plus' (Format-CatalogName -Name 'keep%and&and+plus')
|
||
Assert-True ((Format-CatalogName -Name 'a/b:c') -notmatch '[/:]') '非法字符应被替换'
|
||
}
|
||
|
||
Remove-Item -LiteralPath $catalogSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||
|
||
# ============================================================================
|
||
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 -LiteralPath (Join-Path $source 'keep.txt') 'keep'
|
||
Set-Content -LiteralPath (Join-Path $source 'Default\Cache\c.bin') 'c'
|
||
Set-Content -LiteralPath (Join-Path $source 'Default\Code Cache\cc.bin') 'cc'
|
||
Set-Content -LiteralPath (Join-Path $source 'Default\Extensions\e.bin') 'e'
|
||
Set-Content -LiteralPath (Join-Path $source 'component_crx_cache\x.bin') 'x'
|
||
Set-Content -LiteralPath (Join-Path $source 'Default\IndexedDB\i.bin') 'i'
|
||
|
||
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
|
||
$excludeArgs = Get-ArchiveExcludeArgument -ItemName $itemName -Patterns $patterns
|
||
|
||
$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 '对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档)' {
|
||
$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 '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 'Invoke-ExternalCommand 能拿到真实退出码(旧实现用 Start-Process 拿不到)' {
|
||
$code = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')
|
||
Assert-Equal 7 $code
|
||
}
|
||
|
||
# ============================================================================
|
||
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||
|
||
$failed = Write-TestSummary -Title '单元测试'
|
||
if ($failed -gt 0) { exit 1 }
|
||
exit 0
|