今天這一篇是一個「以為合成輸入壞了,結果是作業系統在認真工作」的故事。
這也是整個系列裡,最能說明「環境隱含假設」代價的一篇。
故事的起點是一段為了追求「最真實輸入模擬」而寫的測試:
def test_physical_keys_deliver_text(dialog):
edit = edit_of(dialog)
assert edit.send_physical_keys("hello") is True
assert value_of(edit, "hello") == "hello"
在 GitHub Actions 的 CI runner(標準的英文版 windows-latest)上,這段測試跑了幾十次,每次都是俐落的綠燈。
然而,當我在自己開的一台 Windows 虛擬機(繁體中文 zh-TW 桌面)上執行時,測試毫無預警地亮起紅燈:edit.get_value() 回傳了 空字串 "" 。
輸入欄位空空如也,一個字都沒有進去。
當你看到 assert edit.send_physical_keys("hello") is True 成功回傳、但後面的輸入框卻是空白時,工程師的第一個直覺通常是:
「
SendInput的掃描碼模擬有 bug,或者某些控制項根本不吃掃描碼。這條路走不通。」
因為在 Day 11 我們提過:Unicode 注入(KEYEVENTF_UNICODE)是直接把字元碼位送交控制項,它快、穩定,而且不管在什麼環境下打 "hello",輸入框裡永遠都是 "hello"。
如果換成 Unicode 注入就秒過,那顯然是「掃描碼這條路壞了」吧?
但這個結論經不起進一步的推敲。
與其猜測是 Windows 的輸入佇列掉了事件,不如把唯一可能影響按鍵意義的變因隔開。
同一個文字控制項、同一支測試程式、同一台電腦,我不動任何程式碼,只做了一件事: 切換作用中的鍵盤佈局(Keyboard Layout / HKL) 。
layout=0x04040404 (zh-TW 繁體中文注音) → edit.get_value() == ""
layout=0x04090409 (en-US 美式英文) → edit.get_value() == "hello"
把鍵盤佈局切到 en-US,測試立刻綠燈;切回 zh-TW 注音,欄位再次歸零。
答案一槍斃命: 不是合成輸入壞了,是注音輸入法把按鍵全部攔截進組字區(Composition)了!
退一步想想注音鍵盤的真實運作方式:
在繁體中文標準注音鍵盤上,那些沒有加修飾鍵的英文字母, 根本不是英文字母 :
h 是「ㄘ」e 是「ㄍ」l 是「ㄠ」o 是「ㄟ」當真實使用者坐在電腦前,在中文模式下按下鍵盤上的 h-e-l-l-o 時,使用者 不是 要輸入英文單字 "hello",使用者是要拼寫注音符號!
這時 Windows 的輸入法架構(IME)會介入:
字元從來沒有到達過那個 EDIT 控制項——它們卡在輸入法的肚子裡。
想通這一點的瞬間,整個問題的因果徹底倒過來了。
回顧 Day 11 的三條輸入路徑:
| 輸入路徑 | 傳送內容 | 經過輸入法(IME)嗎? |
|---|---|---|
| 虛擬鍵碼(VK) | 抽象鍵概念(如 VK_RETURN) |
視按鍵而定(功能鍵通過,字母鍵可能觸發) |
| 掃描碼(Scan Code) | 物理鍵盤位置(send_physical_keys) |
一定會經過輸入法(IME Sees Everything) |
| Unicode 注入 | 字元碼位(KEYEVENTF_UNICODE) |
完全跳過(Bypasses IME) |
我們當初為什麼要實作 send_physical_keys?
正是為了要提供一條 最高保真度、完全比照真實人類物理敲擊 的輸入路徑。
既然是物理敲擊,它就必須承受真實世界中實體鍵盤會遭遇的一切:鍵盤佈局的轉譯、輸入法的攔截、組字視窗的扣押。
如果 send_physical_keys("hello") 在中文注音環境下,居然能神不知鬼不覺地直接把 "hello" 送進文字框,那才叫大 bug!那代表我們的合成輸入根本沒有騙過作業系統,它在底層抄了小路,繞過了真實世界的輸入管線。
換句話說: 底層沒有壞,注音輸入法正在忠實執行它的職責。壞的是我的測試斷言。
assert edit.get_value() == "hello"
表面上它在斷言「掃描碼能把文字送進控制項」。
但實際上,它成立的前提是: 「執行這台測試的機器,沒有載入任何會攔截拉丁字母的輸入法。」
也就是說,這段測試真正斷言的是:
「跑這個測試的人,用的是純英文環境。」
這是一個非常糟糕、卻在開源界屢見不鮮的 隱含假設(Implicit Assumption) 。
GitHub Actions、Azure DevOps、AWS CodeBuild 這些雲端 CI runner,預設乾淨的 Windows 映像全部都是 en-US。在英文 CI 上,這段測試永遠綠燈通關;
但只要哪天一個台灣、日本(Romaji/Kana)或韓國的開源開發者把專案 clone 到自己的本機跑測試,整套測試套件就會全面陣亡——而且失敗訊息只會冷冷地噴出 AssertionError: assert '' == 'hello',完全不會給出任何提示。
為了更清楚看見這道牆,我們看一下 Win32 訊息層的傳遞過程:
[ send_physical_keys("h") 送出掃描碼 ]
│
▼
WM_KEYDOWN (VK_H, ScanCode 0x23)
│
▼
TranslateMessage(&msg)
│
├──► 輸入法(IME / TSF)判定為注音聲符「ㄘ」
│ 攔截按鍵,不生成 WM_CHAR!
│ 轉發 WM_IME_COMPOSITION 寫入組字緩衝區
│
▼
DispatchMessage(&msg)
│
▼
[ EDIT 控制項 ]
(完全沒有收到 WM_CHAR,欄位維持空白)
普通的 Win32 文字方塊是收到 WM_CHAR 才把字元貼入編輯器的。輸入法把訊息扣住了,控制項當然一片空白。
我們可以呼叫 Win32 的 IMM32 API,直接把卡在組字緩衝區裡的字串抓出來看:
from wintegrate.interop import imm32, GCS_COMPSTR
import ctypes
def get_ime_composition_string(hwnd: int) -> str:
himc = imm32.ImmGetContext(hwnd)
if not himc:
return ""
try:
size = imm32.ImmGetCompositionStringW(himc, GCS_COMPSTR, None, 0)
if size <= 0:
return ""
buf = ctypes.create_unicode_buffer(size // 2 + 1)
imm32.ImmGetCompositionStringW(himc, GCS_COMPSTR, buf, size)
return buf.value
finally:
imm32.ImmReleaseContext(hwnd, himc)
在打完 "hello" 的瞬間執行這段函式:
edit.get_value() → ""
get_ime_composition_string(dialog.hwnd) → "ㄘㄍㄠㄠㄟ"
字元沒有掉,五個注音符號整整齊齊地躺在輸入法的組字緩衝區裡!
這個現象在驅動真實開源應用程式(如 KeePassXC、Notepad++、PowerToys)時,會引發連鎖災難:
在 KeePassXC 的自動化測試中(例如測試 PR #13648 / Issue #12956 的 0x200a 安全桌面憑證提示),腳本必須在密碼框填入主密碼。
如果當前環境處於中文注音模式,送出的密碼全部變成注音卡在緩衝區,後續送出 Enter 鍵時,密碼根本沒進去,直接觸發「密碼不正確」或系統警告音,整個測試直接掛死逾時。
在 Notepad++ 尋找視窗、或是 PowerToys Run / Shortcut Guide 的搜尋欄中,自動化測試需要鍵入關鍵字來過濾清單。在中文環境下,搜尋欄完全空白,列表沒有任何項目被過濾,後續的所有定位器與斷言直接全死。
在沒有按鍵 HUD 的 CI 錄影裡,維護者看到的畫面是: 視窗開著,但文字框一直空白,像是在發呆 。
只有在我們為 wintegrate 0.5.5 打造了純記憶體合成的鍵盤 HUD(透過 WH_KEYBOARD_LL 鉤子直接在影像幀上打按鍵膠囊)之後,錄影才還原了現場:
影片底部清清楚楚跳出了 [ H ] [ E ] [ L ] [ L ] [ O ],但上方的文字框紋絲不動——這正是「按鍵真的有送出,是被輸入法攔走」無可辯駁的視覺證據。
那我能不能在測試一開頭先寫個 if is_ime_active():,如果是中文環境才處理?
不行。模式只能「建立」,不能「偵測」。
在實際探索 Win32 輸入機制時會發現:現代 Windows 控制項混雜了 TSF(Text Services Framework)與輸入佇列隔離機制。如果直接跨處理程序對目標視窗查詢輸入狀態,往往只能拿到不可靠的資訊或 NULL Context。
既然在跨處理程序的環境下無法穩定偵測當前狀態,唯一的活路就是 強制建立確定性 :
在送出實體按鍵前,無條件透過 Context Manager 將目標輸入環境推入已知的 英數轉換模式(Alphanumeric Mode) ,執行完畢後再精確復原。
就像真實人類在注音輸入法下打英文前,會按一下 Shift 一樣。
然而,在建立輸入法模式時,我們又撞上了另一個隱密的刺客: Caps Lock(大寫鎖定) 。
Caps Lock 在 Windows 中是 桌面全域鎖存狀態(Desktop-Global Latched State) 。它不屬於任何一個視窗或處理程序,而是整個桌面共用的。
如果跑測試前使用者的鍵盤剛好開著 Caps Lock,或者前一個崩潰的測試留下了 Caps Lock:
send_physical_keys("hello"),實體按鍵受到大寫鎖存影響,輸入框收到的會是 "HELLO"!測試噴出 assert 'HELLO' == 'hello' 陣亡。因此,一個合格的測試框架在切換輸入法模式時,必須 同時對 Caps Lock 進行正規化與狀態復原 。
不要放寬斷言(例如改寫成 assert val in ["hello", ""],那是鴕鳥心態);也不要試圖把整台機器的鍵盤佈局(HKL)強切到 en-US(那是破壞性的,而且跨處理程序根本切不動,明天詳談)。
正確的做法是用 Context Manager 鎖定 轉換模式(Conversion Mode) 與 Caps Lock :
@contextmanager
def ime_mode(self, conversion: int):
"""Puts this window's IME into a known conversion mode for the block."""
original = get_ime_conversion(self.hwnd)
caps_was_on = get_toggle_key_state(VK_CAPITAL)
# 1. 建立目標輸入法轉換模式(如 ALPHANUMERIC)
self._set_ime_conversion_settled(int(conversion))
# 2. 正規化 Caps Lock,避免把 hello 變成 HELLO
if caps_was_on:
set_caps_lock(False)
try:
yield self
finally:
# 3. 離開時精確復原
if original is not None:
self._set_ime_conversion_settled(int(original))
if caps_was_on:
set_caps_lock(True)
有了這個工具,測試就可以問出一個 乾淨且與環境無關的問題 :
@pytest.fixture
def latin_dialog(dialog):
"""The fixture dialog with the IME in alphanumeric mode."""
with dialog.ime_mode(ImeConversion.ALPHANUMERIC):
yield dialog
def test_physical_keys_deliver_text(latin_dialog):
edit = edit_of(latin_dialog)
assert edit.send_physical_keys("hello") is True
assert value_of(edit, "hello") == "hello"
這段測試在英文的 GitHub Actions CI 上全綠,在中文的 Windows 桌面開發機上也全綠。我們不再把「環境碰巧是英文」當成測試通過的隱含前提。
最後,既然知道注音在原生中文模式下必定會攔截按鍵,我們何不把它當成一個 feature 寫進測試套件?
如果我們這樣寫:
# 脆弱的單向測試
def test_native_mode_intercepts_unshifted_scan_codes(dialog):
edit = edit_of(dialog)
with dialog.ime_mode(ImeConversion.NATIVE):
edit.send_physical_keys("hello")
assert edit.get_value() == "" # 被吃掉了
這個測試 毫無證明力 。
因為「輸入框是空的」有太多種可能的原因:
SendInput;在任何一種災難性的 bug 發生時,這個測試都會通過。 一個只驗證失敗方向的測試,證明不了失敗的原因。
要讓它具備嚴謹的科學證明力,必須採用 成對對稱斷言 :
@requires_ime
def test_native_mode_intercepts_unshifted_scan_codes(dialog):
"""Asserts both directions: scan codes reach the IME, not the ether."""
edit = edit_of(dialog)
def type_hello() -> str:
edit.send_keys("{HOME}+{END}{DELETE}")
value_of(edit, "")
edit.send_physical_keys("hello")
return value_in(edit, ("hello", ""))
# 1. 中文原生模式:必須攔截,欄位必須為空
with dialog.ime_mode(ImeConversion.NATIVE):
swallowed = type_hello()
# 2. 英數模式:同一批掃描碼必須通行無阻,欄位必須得到 hello
with dialog.ime_mode(ImeConversion.ALPHANUMERIC):
delivered = type_hello()
assert delivered == "hello", "英數模式必須讓掃描碼暢通無阻到達控制項"
assert swallowed == "", "中文原生模式必須將相同的按鍵吃進組字狀態機"
只有當第二個斷言 delivered == "hello" 成立,排除了「這台機器根本打不出字」的所有替代假設之後,第一個斷言 swallowed == "" 才真正具有證明力。
成對的斷言,才能鎖定因果。
send_physical_keys 在中文注音下打不出英文字母—— 這是正確行為 ,掃描碼就是要忠實反映輸入法管線。TranslateMessage 被轉成 WM_IME_COMPOSITION,扣押在 GCS_COMPSTR 組字緩衝區中。明天講一個我一度以為是解法、後來變成教科書級反例的東西: 為什麼切換鍵盤佈局(HKL)是一則「請求」而不是「指令」?以及六種試圖跨處理程序強制切換佈局的做法,為什麼全部慘烈失敗。