重构为可核对、可恢复的备份工具(P0-P3)
修复(P0) - 退出码:改用 .NET Process 继承控制台启动外部命令。Start-Process -PassThru 的 ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null,会把成功的压缩判成失败, 并让 "exit 2 -> 删档重试" 的自愈分支永远不可达。 - BackupList.txt 解析:先按第一个 :: 切开再处理引号,修正整行被引号包住时 排除表被吞进路径的问题(该条目此前被静默跳过,其 2.8 GB 归档成了孤儿)。 - 排除分隔符同时接受 , 与 ;:此前解析器只认 ; 而清单里写的是 ,, 等于所有排除规则都没生效。 - 7z 排除参数不再嵌引号,含空格的模式自动转成 ?:旧写法 -x!"路径" 会让引号 成为模式的一部分,导致排除对所有条目都失效。 加固(P1) - 先写临时归档 -> 7z t 校验 -> 原子替换,中断不再污染正式归档。 - 放弃 7z 的更新模式 u:固实压缩下收益极小,却让排除规则改动与已删文件 永远进不了归档。 - 新增 Backups/manifest.json 与 logs/*.log,跳过/失败有据可查。 - 结尾按失败数 exit;恢复支持 -WhatIf / -DryRun / -VerifyOnly / -Only。 - 恢复优先用 manifest 定位归档,并精确比较 BaseName(不再用 -Filter 通配)。 - 修正 tar 分支用 $LASTEXITCODE 判断成功与否的缺陷。 - 有警告(文件被占用)时拒绝用不完整的归档覆盖完整归档,需显式 -AcceptWarnings。 策略与安全(P2) - Edge 条目加排除规则:解压后 4.22 GB 中 3.79 GB 是可再生的缓存/遥测/扩展本体, 保留书签、密码、偏好、历史与站点数据。 - 可选 7z 加密(@encrypt 标记或全局开关),取不到口令时明确失败,绝不写明文。 - 磁盘空间守卫:放不下就跳过该条目,低于阈值告警。 工程化(P3) - 新增 BackupConfig.psd1、README.md、.gitignore。 - tests/Run-Tests.ps1(32 项)与 tests/Run-E2E.ps1(16 项端到端验收)。 - tools/Register-BackupTask.ps1 注册每日计划任务。 - 归档命名算法保持不变,已有归档不会失联。
This commit is contained in:
commit
dbc0c00554
11 files changed
+2934
No files matched your search
@@ -0,0 +1,282 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。
|
||||
|
||||
.DESCRIPTION
|
||||
单元测试只验证函数行为,这个脚本验证整条链路真的能用:
|
||||
1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用);
|
||||
2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确;
|
||||
3. 解压归档,断言被排除的内容确实不在里面;
|
||||
4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原;
|
||||
5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘;
|
||||
6. 源路径不存在时记为 missing-source,而不是静默忽略。
|
||||
|
||||
全程只在临时目录里操作,不会碰到真实备份。
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\tests\Run-E2E.ps1
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$WorkRoot,
|
||||
[switch]$KeepWorkRoot
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$backupScript = Join-Path $projectRoot 'Backup.ps1'
|
||||
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
|
||||
Import-Module (Join-Path $projectRoot 'Common.psm1') -Force
|
||||
|
||||
Reset-TestResult
|
||||
|
||||
if (-not $WorkRoot) {
|
||||
$WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
}
|
||||
|
||||
$sourceParent = Join-Path $WorkRoot 'src'
|
||||
$source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用
|
||||
$backupDir = Join-Path $WorkRoot 'Backups'
|
||||
$listPath = Join-Path $WorkRoot 'list.txt'
|
||||
$dryBackupDir = Join-Path $WorkRoot 'Backups-dry'
|
||||
$verifyDir = Join-Path $WorkRoot 'verify'
|
||||
|
||||
Write-Host ""
|
||||
Write-Host '== 端到端:备份 -> 排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan
|
||||
Write-Host " 工作目录:$WorkRoot"
|
||||
|
||||
# ============================================================================
|
||||
# 1. 造数据
|
||||
# ============================================================================
|
||||
|
||||
foreach ($dir in 'logs', 'sub', 'Cache') {
|
||||
New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -LiteralPath (Join-Path $source 'keep.txt') -Value 'keep-me' -Encoding UTF8
|
||||
Set-Content -LiteralPath (Join-Path $source 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8
|
||||
Set-Content -LiteralPath (Join-Path $source 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8
|
||||
Set-Content -LiteralPath (Join-Path $source 'Cache\c.bin') -Value 'must-be-excluded-too' -Encoding UTF8
|
||||
|
||||
# 一个确定性随机二进制文件,用于逐字节对拍
|
||||
$blob = New-Object byte[] 8192
|
||||
(New-Object System.Random 42).NextBytes($blob)
|
||||
[System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $blob)
|
||||
|
||||
[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :: logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$expectedHashes = @{}
|
||||
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
|
||||
$expectedHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $source $relative) -Algorithm SHA256).Hash
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 2. 备份
|
||||
# ============================================================================
|
||||
|
||||
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool
|
||||
$backupExitCode = $LASTEXITCODE
|
||||
|
||||
Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
|
||||
Assert-Equal 0 $backupExitCode
|
||||
}
|
||||
|
||||
$archives = @(Get-ChildItem -LiteralPath $backupDir -File -Filter *.7z -ErrorAction SilentlyContinue)
|
||||
|
||||
Test-Case '归档已生成' {
|
||||
Assert-Equal 1 $archives.Count
|
||||
Assert-True ($archives[0].Length -gt 0) '归档不应为空'
|
||||
}
|
||||
|
||||
$manifestPath = Join-Path $backupDir 'manifest.json'
|
||||
|
||||
Test-Case 'manifest 记录了条目、动作与校验结果' {
|
||||
Assert-FileExists $manifestPath
|
||||
$manifest = Read-BaknretManifest -Path $manifestPath
|
||||
Assert-Equal 1 $manifest.items.Count
|
||||
|
||||
$record = $manifest.items[$archives[0].BaseName]
|
||||
Assert-True ($null -ne $record) 'manifest 里应有对应条目'
|
||||
Assert-Equal 'backed-up' $record.action
|
||||
Assert-Equal $true $record.verified
|
||||
Assert-Equal 0 $record.exitCode
|
||||
Assert-Equal $source $record.source
|
||||
Assert-True ($record.sourceFiles -ge 4) '源文件数应不少于 4'
|
||||
}
|
||||
|
||||
Test-Case '备份过程写了日志文件' {
|
||||
$logDir = Join-Path $projectRoot 'logs'
|
||||
$logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
|
||||
Assert-True ($logs.Count -gt 0) '应生成 backup-*.log'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 3. 解压归档,验证排除真的生效
|
||||
# ============================================================================
|
||||
|
||||
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
|
||||
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||
|
||||
if ($sevenZip) {
|
||||
$null = Invoke-ExternalCommand -FilePath $sevenZip `
|
||||
-ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName)
|
||||
}
|
||||
|
||||
Test-Case '归档里保留了应当保留的内容' {
|
||||
Assert-FileExists (Join-Path $verifyDir 'My Code Space\keep.txt')
|
||||
Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt')
|
||||
Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin')
|
||||
}
|
||||
|
||||
Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' {
|
||||
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\logs\a.log') 'logs\ 应被排除'
|
||||
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录'
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 4. 删源后恢复,逐字节对拍
|
||||
# ============================================================================
|
||||
|
||||
Remove-Item -LiteralPath $source -Recurse -Force
|
||||
|
||||
Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' {
|
||||
Assert-FileMissing $source
|
||||
}
|
||||
|
||||
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -Force
|
||||
$restoreExitCode = $LASTEXITCODE
|
||||
|
||||
Test-Case '恢复退出码为 0' {
|
||||
Assert-Equal 0 $restoreExitCode
|
||||
}
|
||||
|
||||
Test-Case '恢复出的文件与源逐字节一致' {
|
||||
foreach ($relative in $expectedHashes.Keys) {
|
||||
$restored = Join-Path $source $relative
|
||||
Assert-FileExists $restored
|
||||
Assert-Equal $expectedHashes[$relative] (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash "对拍 $relative"
|
||||
}
|
||||
}
|
||||
|
||||
Test-Case '被排除的内容没有被恢复出来' {
|
||||
Assert-FileMissing (Join-Path $source 'logs\a.log')
|
||||
Assert-FileMissing (Join-Path $source 'Cache\c.bin')
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 4.5 保护规则:有警告时不拿不完整的归档覆盖完整归档
|
||||
# ============================================================================
|
||||
|
||||
$lockSource = Join-Path $sourceParent 'Locked Case'
|
||||
$lockBackupDir = Join-Path $WorkRoot 'Backups-lock'
|
||||
$lockList = Join-Path $WorkRoot 'lock.txt'
|
||||
New-Item -ItemType Directory -Path $lockSource -Force | Out-Null
|
||||
Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8
|
||||
[System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
# 第一轮:没有占用,归档是"干净"的
|
||||
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
|
||||
$cleanExitCode = $LASTEXITCODE
|
||||
$cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
|
||||
$cleanSize = $cleanArchive.Length
|
||||
$lockManifestPath = Join-Path $lockBackupDir 'manifest.json'
|
||||
$cleanRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$cleanArchive.BaseName]
|
||||
|
||||
Test-Case '干净归档:退出码 0,manifest 记 warnings=false' {
|
||||
Assert-Equal 0 $cleanExitCode
|
||||
Assert-Equal 'backed-up' $cleanRecord.action
|
||||
Assert-Equal $false $cleanRecord.warnings
|
||||
}
|
||||
|
||||
# 第二轮:独占锁住一个文件,真实制造"有文件读不到"的 7z 警告
|
||||
Set-Content -LiteralPath (Join-Path $lockSource 'b.txt') -Value 'bbb' -Encoding UTF8
|
||||
$lockedPath = Join-Path $lockSource 'locked.bin'
|
||||
Set-Content -LiteralPath $lockedPath -Value 'locked' -Encoding UTF8
|
||||
$lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
|
||||
|
||||
try {
|
||||
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
|
||||
$warnExitCode = $LASTEXITCODE
|
||||
$afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
|
||||
$afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
|
||||
|
||||
Test-Case '有警告时拒绝覆盖完整归档:退出码 1,旧归档原样保留' {
|
||||
Assert-Equal 1 $warnExitCode
|
||||
Assert-Equal $cleanSize $afterArchive.Length '在位归档应与被保护的那份完全一致'
|
||||
Assert-Equal 'failed' $afterRecord.action
|
||||
Assert-Equal $false $afterRecord.warnings '在位归档仍然是完整的'
|
||||
Assert-Equal $true $afterRecord.attemptWarnings '这次尝试确实报了警告'
|
||||
}
|
||||
|
||||
# 明确接受之后才允许覆盖
|
||||
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool -AcceptWarnings
|
||||
$acceptExitCode = $LASTEXITCODE
|
||||
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
|
||||
|
||||
Test-Case '-AcceptWarnings 允许覆盖,并在 manifest 记 warnings=true' {
|
||||
Assert-Equal 0 $acceptExitCode
|
||||
Assert-Equal 'backed-up' $acceptedRecord.action
|
||||
Assert-Equal $true $acceptedRecord.warnings
|
||||
}
|
||||
} finally {
|
||||
$lockStream.Close()
|
||||
$lockStream.Dispose()
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 5. DryRun 不写盘
|
||||
# ============================================================================
|
||||
|
||||
if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force }
|
||||
|
||||
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -DryRun
|
||||
$dryRestoreExitCode = $LASTEXITCODE
|
||||
|
||||
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
|
||||
Assert-Equal 0 $dryRestoreExitCode
|
||||
Assert-FileMissing $source
|
||||
}
|
||||
|
||||
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun
|
||||
$dryBackupExitCode = $LASTEXITCODE
|
||||
|
||||
Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
|
||||
Assert-Equal 0 $dryBackupExitCode
|
||||
if (Test-Path -LiteralPath $dryBackupDir) {
|
||||
$written = @(Get-ChildItem -LiteralPath $dryBackupDir -File -ErrorAction SilentlyContinue)
|
||||
Assert-Equal 0 $written.Count '试运行不应写入任何文件'
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# 6. 失败路径:源不存在时必须留下可核对的记录
|
||||
# ============================================================================
|
||||
|
||||
$missingList = Join-Path $WorkRoot 'missing.txt'
|
||||
[System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false))
|
||||
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -Force -QuietTool
|
||||
$missingExitCode = $LASTEXITCODE
|
||||
|
||||
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {
|
||||
Assert-Equal 0 $missingExitCode
|
||||
$after = Read-BaknretManifest -Path $manifestPath
|
||||
$record = $after.items['definitely-not-here-12345_from_Z_']
|
||||
Assert-True ($null -ne $record) 'manifest 里应有该条目的记录'
|
||||
Assert-Equal 'missing-source' $record.action
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
|
||||
if ($KeepWorkRoot) {
|
||||
Write-Host "`n工作目录保留在:$WorkRoot" -ForegroundColor Yellow
|
||||
} else {
|
||||
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$failed = Write-TestSummary -Title '端到端验收'
|
||||
if ($failed -gt 0) { exit 1 }
|
||||
exit 0
|
||||
@@ -0,0 +1,344 @@
|
||||
<#
|
||||
.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
|
||||
@@ -0,0 +1,111 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
BakNRet 测试用的极简断言运行器(零依赖,PS 5.1 / 7.x 通用)。
|
||||
|
||||
.DESCRIPTION
|
||||
不用 Pester:本机只装了 3.4.0,缺少 `Should -Be` 这类 Pester 4+ 语法,
|
||||
为跑几个测试去装 Pester 5 不划算。这里只需要 Test-Case + Assert-* 就够。
|
||||
#>
|
||||
|
||||
$script:Passed = 0
|
||||
$script:Failed = 0
|
||||
$script:Failures = @()
|
||||
|
||||
function Reset-TestResult {
|
||||
$script:Passed = 0
|
||||
$script:Failed = 0
|
||||
$script:Failures = @()
|
||||
}
|
||||
|
||||
function Get-TestResult {
|
||||
return [pscustomobject]@{
|
||||
Passed = $script:Passed
|
||||
Failed = $script:Failed
|
||||
Failures = @($script:Failures)
|
||||
}
|
||||
}
|
||||
|
||||
function Test-Case {
|
||||
param([string]$Name, [scriptblock]$Body)
|
||||
|
||||
try {
|
||||
& $Body
|
||||
$script:Passed++
|
||||
Write-Host " [PASS] $Name" -ForegroundColor Green
|
||||
} catch {
|
||||
$script:Failed++
|
||||
$script:Failures += "$Name —— $($_.Exception.Message)"
|
||||
Write-Host " [FAIL] $Name" -ForegroundColor Red
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Equal {
|
||||
param($Expected, $Actual, [string]$Because)
|
||||
if ($Expected -ne $Actual) {
|
||||
throw ("期望 [{0}],实际 [{1}]{2}" -f $Expected, $Actual, $(if ($Because) { "($Because)" } else { '' }))
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-True {
|
||||
param($Condition, [string]$Because)
|
||||
if (-not $Condition) { throw ("断言为真失败{0}" -f $(if ($Because) { ":$Because" } else { '' })) }
|
||||
}
|
||||
|
||||
function Assert-False {
|
||||
param($Condition, [string]$Because)
|
||||
if ($Condition) { throw ("断言为假失败{0}" -f $(if ($Because) { ":$Because" } else { '' })) }
|
||||
}
|
||||
|
||||
function Assert-Null {
|
||||
param($Value, [string]$Because)
|
||||
if ($null -ne $Value) {
|
||||
throw ("期望为 null,实际 [{0}]{1}" -f $Value, $(if ($Because) { "($Because)" } else { '' }))
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-FileExists {
|
||||
param([string]$Path, [string]$Because)
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
throw ("文件应当存在但不存在:{0}{1}" -f $Path, $(if ($Because) { "($Because)" } else { '' }))
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-FileMissing {
|
||||
param([string]$Path, [string]$Because)
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
throw ("文件不应存在但存在:{0}{1}" -f $Path, $(if ($Because) { "($Because)" } else { '' }))
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-FileHashEqual {
|
||||
param([string]$ExpectedPath, [string]$ActualPath)
|
||||
Assert-FileExists $ExpectedPath
|
||||
Assert-FileExists $ActualPath
|
||||
$a = (Get-FileHash -LiteralPath $ExpectedPath -Algorithm SHA256).Hash
|
||||
$b = (Get-FileHash -LiteralPath $ActualPath -Algorithm SHA256).Hash
|
||||
if ($a -ne $b) { throw ("内容不一致:{0} vs {1}" -f $ExpectedPath, $ActualPath) }
|
||||
}
|
||||
|
||||
function Write-TestSummary {
|
||||
param([string]$Title = '测试')
|
||||
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 60)
|
||||
if ($script:Failed -eq 0) {
|
||||
Write-Host "$Title 全部通过:$($script:Passed) 项" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "$Title 通过 $($script:Passed) 项,失败 $($script:Failed) 项" -ForegroundColor Red
|
||||
foreach ($failure in $script:Failures) { Write-Host " - $failure" -ForegroundColor Red }
|
||||
}
|
||||
Write-Host ("=" * 60)
|
||||
|
||||
return $script:Failed
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function @(
|
||||
'Reset-TestResult', 'Get-TestResult', 'Test-Case',
|
||||
'Assert-Equal', 'Assert-True', 'Assert-False', 'Assert-Null',
|
||||
'Assert-FileExists', 'Assert-FileMissing', 'Assert-FileHashEqual',
|
||||
'Write-TestSummary'
|
||||
)
|
||||
Reference in new issue
Block a user