## 背景
原 `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` 定向一个最小化窗口。验证通过后再发版。
128 lines
5.3 KiB
JavaScript
128 lines
5.3 KiB
JavaScript
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);
|
||
});
|