From f2959a4279853107b6d352c9b7b9974154e14eab Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sun, 27 Sep 2026 15:49:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(tui):=20=E8=AF=BB=E9=94=AE=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=8C=96=E4=B8=8E=20-InputScript=20=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E7=BC=9D=EF=BC=88=E7=AC=AC=E4=B8=80=E8=BD=AE=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E5=9D=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三层拆开,让唯一能无控制台测试的那层露出来:Get-BakNRetKeyName 纯映射(键码→键名)、New-BakNRetInputDriver 造驱动对象、Read-BakNRetKey 取下一个键。门禁把方向键/回车/空格/字母全钉住,只把"真的读到一个键"留给人工。 两条"不猜"的设计:序列用尽必须抛错(绝不退回去读真终端 —— 门禁挂起比变红糟得多);没有控制台也没有序列时不返回任何默认键(没人按键却继续执行,在备份工具里等于替你做了决定)。 这块连撞三个"看起来该有值、其实是空的"坑,全被"绿了才提交"拦在提交之外:① 键名少写冒号;② @($null) 造出含 $null 的单元素数组,"空序列"被当成"有一个键",且两版本表现不同(7 静默返回、5.1 抛错);③ **多行插入时换行被折成空格**,于是注释与 `Script = $scriptKeys` 一起落进同一行注释,那条赋值从未执行 —— 症状是"对象一个属性都没有"。教训:往文件里插多行代码不能靠拼接字符串,写文件就整份重写。 验收:断言 15 条(含驱动序列消费与两条必须报错);两版本手工输出一致;test.ps1 9/9 全绿。 --- BakNRet/BakNRet.psd1 | 3 ++ BakNRet/BakNRet.psm1 | 7 +++++ BakNRet/Public/Get-BakNRetKeyName.ps1 | 34 ++++++++++++++++++++ BakNRet/Public/New-BakNRetInputDriver.ps1 | 38 +++++++++++++++++++++++ BakNRet/Public/Read-BakNRetKey.ps1 | 30 ++++++++++++++++++ tests/Run-Tests.ps1 | 33 ++++++++++++++++++++ 6 files changed, 145 insertions(+) create mode 100644 BakNRet/Public/Get-BakNRetKeyName.ps1 create mode 100644 BakNRet/Public/New-BakNRetInputDriver.ps1 create mode 100644 BakNRet/Public/Read-BakNRetKey.ps1 diff --git a/BakNRet/BakNRet.psd1 b/BakNRet/BakNRet.psd1 index bfc2ff1..b531964 100644 --- a/BakNRet/BakNRet.psd1 +++ b/BakNRet/BakNRet.psd1 @@ -19,6 +19,9 @@ CompatiblePSEditions = @('Desktop', 'Core') FunctionsToExport = @( + 'Get-BakNRetKeyName', + 'New-BakNRetInputDriver', + 'Read-BakNRetKey', 'Get-BakNRetCellWidth', 'Write-BakNRetRunSummary', 'Save-BakNRetItemRecord', diff --git a/BakNRet/BakNRet.psm1 b/BakNRet/BakNRet.psm1 index 1ebbfa2..1b68153 100644 --- a/BakNRet/BakNRet.psm1 +++ b/BakNRet/BakNRet.psm1 @@ -146,7 +146,14 @@ . (Join-Path $PSScriptRoot 'Public\Get-BakNRetCellWidth.ps1') +. (Join-Path $PSScriptRoot 'Public\Get-BakNRetKeyName.ps1') +. (Join-Path $PSScriptRoot 'Public\New-BakNRetInputDriver.ps1') +. (Join-Path $PSScriptRoot 'Public\Read-BakNRetKey.ps1') + Export-ModuleMember -Function @( + 'Get-BakNRetKeyName', + 'New-BakNRetInputDriver', + 'Read-BakNRetKey', 'Get-BakNRetCellWidth', 'Write-BakNRetRunSummary', 'Save-BakNRetItemRecord', diff --git a/BakNRet/Public/Get-BakNRetKeyName.ps1 b/BakNRet/Public/Get-BakNRetKeyName.ps1 new file mode 100644 index 0000000..9c6da21 --- /dev/null +++ b/BakNRet/Public/Get-BakNRetKeyName.ps1 @@ -0,0 +1,34 @@ +function Get-BakNRetKeyName { + <# + .SYNOPSIS + 把一次按键(虚拟键码 + 字符)归一化成一个稳定的键名,供 TUI 的各个界面判断。 + + .DESCRIPTION + 为什么把它单独拆出来:**这是整个 TUI 里唯一能无控制台测试的部分**。读键本身必须有真终端, + 而映射不需要 —— 把纯逻辑与 I/O 分开,门禁里就能把"上/下/回车/空格/字母"这些路径钉住, + 只把"真的读到一个键"留给人工。 + + 返回的键名:Up / Down / Left / Right / Enter / Esc / Space / Tab / Backspace / + Char:<小写字符> / Unknown<键码>。 + #> + param( + [Parameter(Mandatory = $true)][int]$VirtualKeyCode, + [string]$Character + ) + + switch ($VirtualKeyCode) { + 38 { return 'Up' } + 40 { return 'Down' } + 37 { return 'Left' } + 39 { return 'Right' } + 13 { return 'Enter' } + 27 { return 'Esc' } + 32 { return 'Space' } + 9 { return 'Tab' } + 8 { return 'Backspace' } + } + + # 可打印 ASCII 归一成 Char:<小写>,这样 -InputScript 里写 'Char:k' 就是按 k + if ($Character -and $Character -match '^[ -~]$') { return 'Char:' + $Character.ToLowerInvariant() } + return 'Unknown' + $VirtualKeyCode +} diff --git a/BakNRet/Public/New-BakNRetInputDriver.ps1 b/BakNRet/Public/New-BakNRetInputDriver.ps1 new file mode 100644 index 0000000..281f367 --- /dev/null +++ b/BakNRet/Public/New-BakNRetInputDriver.ps1 @@ -0,0 +1,38 @@ +function New-BakNRetInputDriver { + <# + .SYNOPSIS + 造一个输入驱动:要么按给定的按键序列走(给门禁用),要么读真终端。 + + .DESCRIPTION + 为什么是"驱动对象"而不是模块级状态:这个仓库的原则是**状态属于调用方**(见 ADR-0011)。 + 界面循环自己持有这个对象,读完就丢;模块只提供读的能力。 + + -Script 给的是键名序列(例如 @('Down', 'Enter')),它会**按顺序**被消费; + 序列用尽而界面还在等输入时必须**报错退出**,绝不能退回去读真终端 —— 那会让门禁挂起, + 而挂起比变红糟得多(红会告诉你哪里错了,挂起只会耗掉你的时间)。 + #> + param( + [string[]]$Script, + [switch]$NonInteractive + ) + + $hasConsole = -not [Console]::IsOutputRedirected + if ($NonInteractive -and -not $Script -and $hasConsole) { + # 明确要求非交互,却既没有脚本又坐在真终端前:不猜,直接报错 + throw '指定了 -NonInteractive 但没有给出 -Script:没有可用的输入来源。' + } + + # 先把数组算好,再放进哈希。 + # 两个都踩过的坑:① `if` 是**语句**不是表达式,写在哈希字面量的值位置上会得不到结果; + # ② 不能写 @($Script) —— $Script 为 $null 时 @($null) 会造出**一个含 $null 的单元素数组**, + # 于是"空序列"被当成"有一个键",读者静默返回 $null 而不报错(而且两个 PowerShell 版本表现 + # 还不一样:实测 7 静默返回、5.1 抛错)。所以显式分岔,让"没有序列"就是 0 个元素。 + $scriptKeys = if ($Script) { @($Script) } else { @() } + + return [pscustomobject]@{ + Script = $scriptKeys + Index = 0 + HasConsole = $hasConsole + NonInteractive = [bool]$NonInteractive + } +} diff --git a/BakNRet/Public/Read-BakNRetKey.ps1 b/BakNRet/Public/Read-BakNRetKey.ps1 new file mode 100644 index 0000000..997cc4f --- /dev/null +++ b/BakNRet/Public/Read-BakNRetKey.ps1 @@ -0,0 +1,30 @@ +function Read-BakNRetKey { + <# + .SYNOPSIS + 从驱动里取下一个键名。 + + .DESCRIPTION + 三条来源,按优先级:① 驱动里的脚本序列(门禁用)② 真终端的 RawUI.ReadKey(人用) + ③ 都没有 → 报错。**绝不静默返回一个默认键**:那样界面会自己往下走,而"没人按键却继续 + 执行"在备份工具里等于替你做了决定。 + + ReadKey 必须带 NoEcho:否则你按的键会回显到界面上,把刚画好的菜单弄花。 + #> + param([Parameter(Mandatory = $true)]$Driver) + + if ($Driver.Script.Count -gt 0) { + if ($Driver.Index -ge $Driver.Script.Count) { + throw ("按键序列已用尽(共 {0} 个),但界面仍在等待输入。门禁里这种情况必须失败而不是挂起;" -f $Driver.Script.Count) + } + $key = $Driver.Script[$Driver.Index] + $Driver.Index = $Driver.Index + 1 + return $key + } + + if (-not $Driver.HasConsole) { + throw '没有可用的控制台,也没有给出按键序列:无法读取输入。' + } + + $pressed = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + return (Get-BakNRetKeyName -VirtualKeyCode $pressed.VirtualKeyCode -Character $pressed.Character) +} diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index a7e3f44..6ffe1bd 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1432,6 +1432,39 @@ Test-Case '控制台列宽:中文 2 列、半角片假名 1 列、emoji 按一 Assert-Equal 0 (Get-BakNRetCellWidth -Text '') '空串 = 0 列' } +Test-Case '按键归一化与输入缝:映射可无控制台测,序列用尽必须报错而不是挂起' { + # 纯映射:整个 TUI 里唯一能无控制台测的部分,所以每条路径都钉住。 + Assert-Equal 'Up' (Get-BakNRetKeyName -VirtualKeyCode 38) '上' + Assert-Equal 'Down' (Get-BakNRetKeyName -VirtualKeyCode 40) '下' + Assert-Equal 'Left' (Get-BakNRetKeyName -VirtualKeyCode 37) '左' + Assert-Equal 'Right' (Get-BakNRetKeyName -VirtualKeyCode 39) '右' + Assert-Equal 'Enter' (Get-BakNRetKeyName -VirtualKeyCode 13) '回车' + Assert-Equal 'Esc' (Get-BakNRetKeyName -VirtualKeyCode 27) 'Esc' + Assert-Equal 'Space' (Get-BakNRetKeyName -VirtualKeyCode 32) '空格' + Assert-Equal 'Char:k' (Get-BakNRetKeyName -VirtualKeyCode 75 -Character 'K') '字母统一成小写,且带冒号前缀' + Assert-Equal 'Unknown255' (Get-BakNRetKeyName -VirtualKeyCode 255) '未知键也要有稳定名字,不能返回空' + + # 驱动:按序列顺序消费(这一条曾经因为 Script 被折进注释而静默变成"没有序列") + $driver = New-BakNRetInputDriver -Script @('Down', 'Enter') + Assert-Equal 2 $driver.Script.Count '驱动里应当有两个键' + Assert-Equal 'Down' (Read-BakNRetKey -Driver $driver) '第一个键' + Assert-Equal 'Enter' (Read-BakNRetKey -Driver $driver) '第二个键' + + # 关键一条:序列用尽必须失败。门禁里挂起比变红糟得多,而"退回去读真终端"正是挂起的成因。 + $threw = $false + try { Read-BakNRetKey -Driver $driver | Out-Null } catch { $threw = $true } + Assert-True $threw '按键序列用尽后必须抛错' + + # 另一个"不猜"的路径:没有控制台也没有序列时,不能静默返回一个默认键 + $threw2 = $false + try { + $driver2 = New-BakNRetInputDriver -NonInteractive + Assert-Equal 0 $driver2.Script.Count '空序列必须是 0 个元素(不能是含 $null 的单元素数组)' + Read-BakNRetKey -Driver $driver2 | Out-Null + } catch { $threw2 = $true } + Assert-True $threw2 '没有任何输入来源时必须抛错' +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue