Files
BakNRet/tests/Run-Tests.ps1
T
Shuery e114cae8c8 P0-P3 全量重构:退出码 / 解析修复、manifest 与 7z t 校验、干跑、排除规则、日志、测试与计划任务
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 全部一致。
2026-09-21 23:02:47 +08:00

519 lines
24 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.SYNOPSIS
BakNRet 测试套件(零依赖,Windows PowerShell 5.1 与 PowerShell 7.x 均可运行)。
.DESCRIPTION
为什么不用 Pester:本机只装了 Pester 3.4.0,`Should -Be` 这类 Pester 4+ 语法
直接语法错误,而为了跑测试去装 Pester 5 不值得。这里自带一个极简断言运行器,
对备份工具这种小脚本来说更可靠。
覆盖范围:
* BackupList.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