<# .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] } # ============================================================================ 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== 集成测试:真的跑 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