Files
nodeagent/tests/unit/input-script.test.mjs
T
jkooe 689bb8c98d feat(v1.5): 快捷键控制范围扩展 —— 字符串热键 / 预设别名 / 长按 / 后台定向投递
## 背景
原 `input.key.press` 只接受数组和弦(keys/sequence),按键表也不全:
AI/MCP 调用要拼 ["ctrl","shift","esc"],且 **win+d、ctrl++、win+. 这类
"修饰键 + OEM 符号键"的组合根本打不出来**(表里没有符号键)。

## 四类扩展
① **字符串热键**:`hotkey: "ctrl+shift+esc"`、`hotkeys: ["ctrl+c","ctrl+v"]`
   —— 分隔符支持 `+ - 空格`,大小写不敏感,别名归一(command/cmd→win、del→delete…)
② **预设别名**:约 50 个语义名(copy / show_desktop / task_manager / screenshot /
   volume_mute_toggle…),AI 调用免记键位;`preset` 与 `presets`(序列)均支持
③ **长按** `hold_ms`(≤5s):按下→保持→释放,插在"按完"与"释放"之间
④ **后台定向投递** `route=post` + `target_pid`:向目标进程**全部顶层窗口**
   PostMessage WM_KEYDOWN/UP —— 目标窗口被遮挡/最小化时可用
   (媒体键的 WM_APPCOMMAND 机制早已验证,此为其等价物)
   文档明示局限:游戏/部分输入型程序不响应后台键消息

## 按键表补齐(单测覆盖断言防漂移)
- 左右侧修饰键 lctrl/rctrl/lshift/rshift/lalt/ralt/lwin/rwin(游戏常区分左右)
- 小键盘 numpad0-9 + 四则运算,**与数字键分开**(Excel 靠 NumLock 区分)
- OEM 符号键 oem_1..oem_8 / oem_minus/plus/comma/period(解锁 win+d、ctrl++、win+.)
- 浏览器收藏夹/主页、系统键(sleep/help/select/execute/clear/separator/oem_clear)
- IME 键(kana/hangul/kanji/hanja/convert/nonconvert/accept/modechange)
- 补齐 pause/apps(重构时一度漏掉,被覆盖断言测试立刻抓到)

## 可测性重构
脚本生成抽成**纯函数** `apps/agent/src/capabilities/input-script.ts`
(VK_MAP / MEDIA_COMMANDS / toVk / build{Media,Foreground,Post}Body),
行内生成逻辑全部改为调用 —— 于是**按下顺序/逆序释放/hold 插入位置/post 路由**
这些最容易写错的部分,现在由 14 个单测逐行断言,真机只需验证最终效果。

## 参数校验
protocol `expandChords()` 统一展开六种输入形式,**混用即报错**(消除"以谁为准"歧义);
同一和弦重复修饰键、超 4 键、空输入均拒绝。渲染返回值新增 presets/via/channel/sent_windows。

## CLI / MCP
- CLI:`nodeagent key press "ctrl+shift+esc"` / `key press copy` / 老形式 `key press ctrl c`
  新增 `--hold`、`--route`;回显预设展开与后台投递命中窗口数
- MCP `na_key`:同步支持 hotkey/hotkeys/preset/presets/hold_ms/route/target_pid

## 验证
- 单测 **179 → 195**:hotkeys 解析与别名 17 例、**脚本生成逐行断言 14 例**、
  按键覆盖断言(HOTKEY_KEYS ⊆ VK_MAP)、新参数 schema 校验 2 例
- e2e **29** 全绿
- ⚠️ **真机未验证**:Windows 被控端当前离线(连接超时)。待上线后需验:
  `key press "win+d"`(切桌面再切回)、`key press copy` 配剪贴板、
  `route=post` 定向一个最小化窗口。验证通过后再发版。
2026-10-07 00:59:40 +08:00

128 lines
5.3 KiB
JavaScript
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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
VK_MAP,
MEDIA_COMMANDS,
toVk,
isAllMedia,
buildMediaBody,
buildForegroundBody,
buildPostBody,
} from '../../apps/agent/dist/capabilities/input-script.js';
const OPTS = { gapMs: 40, holdMs: 0, targetPid: 0, route: 'foreground' };
// ---------- toVk / 按键映射 ----------
test('toVk:修饰键与扩展按键', () => {
assert.equal(toVk('ctrl'), 0x11);
assert.equal(toVk(' lalt '), 0xa4);
assert.equal(toVk('rctrl'), 0xa3);
assert.equal(toVk('rwin'), 0x5c);
assert.equal(toVk('d'), 0x44, '字母走 ASCII 大写映射');
assert.equal(toVk('7'), 0x37);
assert.equal(toVk('numpad7'), 0x67, 'numpad7 与 7 必须不同 VK');
assert.notEqual(toVk('numpad7'), toVk('7'));
assert.equal(toVk('numpad_add'), 0x6b);
assert.equal(toVk('oem_plus'), 0xbb);
assert.equal(toVk('oem_comma'), 0xbc);
assert.equal(toVk('oem_period'), 0xbe);
assert.equal(toVk('f13'), 0x7c);
assert.equal(toVk('convert'), 0x1c);
assert.equal(toVk('sleep'), 0x5f);
});
test('toVk:未知键必须抛错', () => {
assert.throws(() => toVk('nope'), /不支持的按键/);
});
// ---------- 前台注入脚本 ----------
test('buildForegroundBody:按下顺序 + 释放必须逆序(组合键生死线)', () => {
const body = buildForegroundBody([['ctrl', 'c']], OPTS);
assert.deepEqual(body, ['[NAInput]::Vk(17, $false)', '[NAInput]::Vk(67, $false)', '[NAInput]::Vk(67, $true)', '[NAInput]::Vk(17, $true)']);
// 断言语义:第一个 down 是修饰键,第一个 up 是主键(即释放逆序)
assert.ok(body[0].includes('Vk(17'), '按顺序按下:ctrl 先');
assert.ok(body[2].includes('Vk(67'),'逆序释放:主键先起');
});
test('buildForegroundBody:hold_ms 插在"按完"与"释放"之间', () => {
const body = buildForegroundBody([['ctrl', 'a']], { ...OPTS, holdMs: 500 });
assert.ok(body.includes('Start-Sleep -Milliseconds 500'));
const sleepAt = body.indexOf('Start-Sleep -Milliseconds 500');
assert.ok(body[sleepAt - 1].includes('$false'), '长按前必须是按下动作');
assert.ok(body[sleepAt + 1].includes('$true'), '长按后必须是释放动作');
});
test('buildForegroundBody:多和弦之间插 interval', () => {
const body = buildForegroundBody([['ctrl', 'c'], ['ctrl', 'v']], { ...OPTS, gapMs: 100 });
assert.ok(body.includes('Start-Sleep -Milliseconds 100'));
// 间隔只出现在两个和弦之间(不应出现在和弦内部,hold=0 时)
assert.equal(body.filter((l) => l.includes('-Milliseconds 100')).length, 1);
});
test('buildForegroundBody:hold=0 时不产生多余 sleep', () => {
const body = buildForegroundBody([['ctrl', 'c']], { ...OPTS, holdMs: 0 });
assert.equal(body.filter((l) => l.includes('Start-Sleep')).length, 0);
});
// ---------- 媒体键脚本 ----------
test('buildMediaBody:使用 APPCOMMAND 常量而非 VK', () => {
const body = buildMediaBody([['media_next'], ['media_play_pause']], { ...OPTS, gapMs: 0 });
assert.deepEqual(body, ['[NAInput]::MediaCommand(7, 0)', '[NAInput]::MediaCommand(12, 0)']);
});
test('buildMediaBody:target_pid 透传(定向该进程全窗口)', () => {
const body = buildMediaBody([['media_next']], { ...OPTS, targetPid: 4242 });
assert.deepEqual(body, ['[NAInput]::MediaCommand(7, 4242)']);
});
test('isAllMedia:混入普通键则整条不走 APPCOMMAND', () => {
assert.ok(isAllMedia([['media_next'], ['media_stop']]));
assert.ok(!isAllMedia([['media_next'], ['enter']]), '混入 enter 必须回退 VK 注入');
assert.ok(!isAllMedia([['ctrl', 'c']]));
assert.ok(!isAllMedia([]), '空序列不算全媒体(避免空走媒体通道)');
});
test('音量键不在 MEDIA_COMMANDS 表(避免 APPCOMMAND device 位歧义)', () => {
assert.ok(!('volume_mute' in MEDIA_COMMANDS));
assert.ok(!('volume_up' in MEDIA_COMMANDS));
assert.ok(!('volume_down' in MEDIA_COMMANDS));
});
// ---------- 后台 PostMessage 脚本 ----------
test('buildPostBody:按下顺序 + 释放逆序,且带 PID', () => {
const body = buildPostBody([['ctrl', 'c']], { gapMs: 0, holdMs: 0, targetPid: 777, route: 'post' });
assert.deepEqual(body, [
'[NAInput]::PostChord(@(17, 67), 777, $false)',
'[NAInput]::PostChord(@(67, 17), 777, $true)',
]);
});
test('buildPostBody:hold 与 interval 与前台语义一致', () => {
const body = buildPostBody([['alt', 'tab'], ['esc']], { gapMs: 50, holdMs: 300, targetPid: 9, route: 'post' });
assert.deepEqual(body, [
'[NAInput]::PostChord(@(18, 9), 9, $false)',
'Start-Sleep -Milliseconds 300',
'[NAInput]::PostChord(@(9, 18), 9, $true)',
'Start-Sleep -Milliseconds 50',
'[NAInput]::PostChord(@(27), 9, $false)',
'Start-Sleep -Milliseconds 300',
'[NAInput]::PostChord(@(27), 9, $true)',
]);
});
// ---------- 回归:real-world 快捷键展开后的脚本 ----------
test('端到端脚本样例:win+d 显示桌面(OEM 符号键链路)', () => {
const body = buildForegroundBody([['win', 'd']], OPTS);
assert.deepEqual(body, ['[NAInput]::Vk(91, $false)', '[NAInput]::Vk(68, $false)', '[NAInput]::Vk(68, $true)', '[NAInput]::Vk(91, $true)']);
});
test('VK_MAP 与 MEDIA_COMMANDS 存在且非空(防误清空)', () => {
assert.ok(Object.keys(VK_MAP).length > 60, '按键表应已覆盖扩展键');
assert.ok(Object.keys(MEDIA_COMMANDS).length >= 10);
});