第三章開始。今天的問題聽起來很蠢:在 Windows 上模擬一次按鍵,有幾種方法?
答案是至少三種,而選錯的話,你的測試會在中文環境下整組失效。
SendInput 這個 API 是所有合成輸入的入口,但它可以送出三種不同性質的東西。
send_vk_input(VK_RETURN)
送出「Enter 這個鍵」這個抽象概念。系統知道 VK_RETURN 是什麼意思,不需要知道你的鍵盤長什麼樣。
適合功能鍵:Enter、Tab、Esc、方向鍵、F1。這些鍵在所有鍵盤佈局上意義相同。
send_physical_keys("hello")
送出「鍵盤上第 X 個位置的鍵被按下了」這個物理事實。系統接著查目前的鍵盤佈局,把位置翻譯成字元。
這是最接近真實使用者的一條路。因為它是物理事件,所以:
for ch in "你好":
send_char_input(ch)
KEYEVENTF_UNICODE 這個旗標讓你直接送出一個字元的碼位,完全跳過鍵盤佈局。系統不去查任何按鍵位置,直接把這個字元交給有焦點的控制項。一次一個碼位,所以 send_char_input 吃的是單一字元。
VK_PACKET (0xE7) 與被誤解的 scanCode當你使用 KEYEVENTF_UNICODE 注入字元時,Windows 底層的 KEYBDINPUT 結構會把虛擬鍵碼固定設為 VK_PACKET = 0xE7,並將真正的 16 位元 Unicode 字元塞進 wScan(即掃描碼欄位)。
這導致了一個有趣的第三方工具 bug:許多在低階鍵盤鉤子(WH_KEYBOARD_LL)上監聽按鍵的螢幕視覺化工具,如果沒有特別處理 VK_PACKET,會盲目將 wScan 當作實體鍵盤的虛擬鍵碼來解讀——
'q'(Unicode 113 / 0x71 = VK_F2)被解讀成 F2
'a'(Unicode 97 / 0x61 = VK_NUMPAD1)被解讀成 1
好處是它能打出任何字元,不管你的佈局上有沒有那個鍵。壞處是——
這是三條路裡最重要的一個差別,wintegrate 把它列在文件的陷阱頁上:
## `KEYEVENTF_UNICODE` bypasses the IME
Unicode injection hands the codepoint straight to the control. An IME never sees
it, so composition never starts and no candidate window appears. Testing IME
behaviour requires scan-code input (`send_physical_keys`).
想想這代表什麼。
如果你要測「使用者用注音打字」這件事,用 Unicode 注入是不可能測到的。你可以把「你好」兩個字塞進欄位裡,欄位裡確實會出現「你好」——但組字過程從來沒有發生過。沒有候選字視窗、沒有組字字串、輸入法的狀態機從頭到尾沒有被觸發。
你測到的是「這個欄位能顯示中文」,不是「注音輸入法能用」。
反過來說,如果你只是要在一個欄位裡放進某段文字好繼續測後面的流程,Unicode 注入才是對的選擇——它快、可靠、不受環境影響。
沒有哪一條路「比較好」。有的是你想測的到底是哪一層。
| 你要測的 | 該用 | wintegrate API |
|---|---|---|
| 輸入法組字、候選字、中英切換 | 掃描碼 | send_physical_keys(...) |
| 功能鍵、快捷鍵 | 虛擬鍵碼 | send_vk_input(...) / send_hotkey(...) |
| 只是要把文字放進欄位 | Unicode 注入 | send_char_input(...) / send_keys(...) |
SendInput 會部分失敗,而且不拋例外三條路共用的一個坑:SendInput 的回傳值是它成功送出了幾個事件。
n = user32.SendInput(count, inputs, ctypes.sizeof(INPUT))
如果 n < count,代表有事件被丟掉了:另一個執行緒用 BlockInput 擋住了輸入,或是佇列滿了。
但有一種丟法它不會告訴你。 UIPI(使用者介面權限隔離)擋下往更高完整性等級視窗送的輸入時,微軟文件明寫回傳值與 GetLastError 都不會反映這件事——SendInput 回報全數送出,欄位是空的。Day 7 那個對著 0x200a 憑證提示的案例量到的就是這個。所以回傳值要檢查,但它抓不到 UIPI;那一種只能靠比較兩邊的完整性等級,或靠後置條件。
多數程式碼連回傳值都不檢查。於是你送了五個按鍵、系統收下零個,而你的程式繼續往下跑,一路到某個斷言失敗為止。
def _send_input_checked(arr, label: str) -> bool:
sent = user32.SendInput(len(arr), arr, ctypes.sizeof(INPUT))
if sent != len(arr):
err = ctypes.get_last_error()
logger.warning(f"SendInput for {label} sent {sent}/{len(arr)} events (error {err})")
return False
return True
又是同一個主題:檢查回傳值,不要假設成功。這在整個系列裡已經出現太多次了。
send_keys("{HOME}+{END}{DELETE}") 這種字串語法需要一個解析器。而解析器是這整個函式庫裡少數不需要 Windows 就能測的東西。
所以它被刻意拆成一個純函式:
# 註:此處 KeyAction 抽象表示 (kind, value, modifiers) 的動作 tuple
def parse_key_spec(spec: str) -> list[KeyAction]:
"""Parses a key specification string into actions. Pure — no Win32 calls."""
拆出來之後就可以測邊界條件了。例如重複次數:
send_keys("{DOWN 5}") # 按五次向下
send_keys("{DOWN 1000000}") # ← 這個要擋掉
一百萬次按鍵會讓測試看起來像當機。所以有上限:
MAX_KEY_REPEAT = 1000
而且重複次數只接受純數字。{DOWN -5}、{DOWN +3}、{DOWN 1_000_000} 全部拒絕——Python 的 int() 很寬容,它接受底線分隔和正負號,如果直接拿來解析使用者輸入,1_000_000 會變成一百萬。
這種「語言本身的寬容變成你的漏洞」的狀況,我們在 Day 15 講 property-based testing 時會再看到。
SendInput 有三種送法:虛擬鍵碼(抽象的鍵)、掃描碼(物理事件)、Unicode(直接送字元)SendInput 會部分失敗且不拋例外,回傳值一定要檢查明天先岔開一天,講一個跟輸入有關、但影響範圍遠超過輸入的東西:ctypes.windll 是整個處理程序共享的全域狀態,而在它上面設定型別會波及每一個用 ctypes 的套件。