第五章最後一天。前面六天都在證明「這個函式庫驅動得動別人的 app」,今天要回答更難的那個問題:
它抓得到 bug 嗎? 不是抓我自己寫的測試 app 的 bug——那只能證明它跟自己一致。
做法是挑四個已經被修好的真實 issue,找出「有 bug 的那一版」和「修好的那一版」,然後讓測試在前者上失敗、在後者上通過。
第一個案例我就挑錯邊,而錯法很值得記。
WinMerge #3055:「在 Options 選『插入製表符』,離開設定後會跳回『插入空格』。」issue 開在 2025-11-29,而 2.16.52.2 是 11-27 發佈的。看起來很清楚:52.2 有 bug,下一版 2.16.53 修好。
我把兩版都抓下來、寫好測試——兩版行為完全一樣,都是正確的。
回去翻 issue,維護者第一則留言就寫了:
Duplicates of #3015. This issue has been fixed in the latest version 2.16.52.2.
#3055 是 #3015 的重複,而 #3015 已經在 2.16.52.2 修好了。真正的配對是 2.16.52 / 2.16.52.2。
回報日期只告訴你誰在什麼時候注意到;只有修正 commit 才告訴你分界線在哪。
而 #3015 的根因漂亮得像教科書:PropEditor.cpp 把一個 std::clamp(v, 1, MAX_TABSIZE) 的驗證器掛到了 OPT_TAB_TYPE,但它是要給 OPT_TAB_SIZE 的。「插入製表符」的值是 0,低於 clamp 的下界,所以被夾成 1——那就是「插入空格」。
因為這個失敗是不對稱的:
| 選擇 | 寫入的值 | 2.16.52 | 2.16.52.2 |
|---|---|---|---|
| 插入空格 | 1 | ✅ 正確 | ✅ 正確 |
| 插入製表符 | 0 → 被夾成 1 | ❌ 變成空格 | ✅ 正確 |
「插入空格」的方向在兩版都正常,因為 1 本來就在 clamp 範圍內。
所以測試除了斷言壞掉的方向,也斷言正常的方向。少了後者,一個「不管你選什麼都寫 1」的版本會完美通過前者。
一個只證明舊版會壞的測試,沒有告訴你新版是修好了還是只是壞得不一樣。
| app | issue | 症狀 | 為什麼值得示範 | 測試實作 |
|---|---|---|---|---|
| Notepad++ | #16326 | Ctrl+Shift+D 插入看不見的 0x04 |
那個字元不會被畫出來,照著重現步驟做的人什麼都看不到 | tests/test_regression_notepadpp_16326.py |
| WinMerge | #3015 | clamp 掛錯 option | 失敗是不對稱的 | tests/test_regression_winmerge_3015.py |
| DB Browser | #3735 | 複製一格多一個換行 | 後置條件在應用程式之外:剪貼簿 | tests/test_regression_sqlitebrowser_3735.py |
| Files | #18815 | Alt+Enter 會叫一聲 | 整個症狀就只是一個聲音 | tests/test_regression_files_18815.py |
最後一個特別值得說。
Alt+Enter 會讓 DefWindowProc 進入功能表追蹤,於是送出 WM_MENUCHAR 去找對應的助憶字元;找不到,預設回答是 MNC_IGNORE,而那會播放系統的 Asterisk 音效。修正是在 Files 自己的 window subclass 裡回答 MNC_CLOSE。
兩個版本的畫面像素完全相同。截圖看不出來,錄影也看不出來——除非你錄音。
而一個訊息的回傳值就講完了:
| 版本 | Enter | a |
Esc |
|---|---|---|---|
| 4.2.9.0(修好) | 0x00010000 |
0 |
0 |
| 4.2.7.0(有 bug) | 0x00000000 |
0 |
0 |
沒有任何 UI 互動要等,沒有時序要抓。而且 WM_MENUCHAR 是 0x0120,小於 WM_USER——所以 USER32 會把它派送進別的處理程序的 window subclass。這正是 Day 22 那條界線:自訂訊息只會被當成兩個整數送過去,根本進不到那個 handler。
修正只攔 '\r',所以 a 和 Esc 也要斷言:一個對所有字元都回 MNC_CLOSE 的版本會吃掉所有真正的助憶字元,卻在只測 Enter 的測試裡看起來是修好的。
同一個 app 的 #18820(在改名框裡 Ctrl+C 複製不到文字)我試了很久,最後放棄 了,而放棄的理由值得寫下來。
修正的內容是:右鍵選單的主要命令列在關閉後沒有清掉 KeyboardAccelerators,於是 Ctrl+C 被 CopyItem 攔走。我照著這條路做,一度以為成功了——剪貼簿上是檔案(CF_HDROP)而不是文字。
然後我去跑修好的那一版。它也一樣。
真相是:在 flyout 真的關閉之前複製,兩版都會拿到檔案;等它關閉之後再複製,兩版都會拿到文字。我做的不是重現,是在跟修正所安裝的那個 Closed handler 賽跑,而修好的版本輸掉這場賽跑的機率一樣高。
一個只在有 bug 的版本上跑過的「重現」,不是重現。 對照組不是嚴謹,是最低限度。
換成 #18815 之後,同一個 app 換來一個更好的案例。
click() 會安靜地什麼都不做這一天真正花掉最多時間的不是任何一個 bug,是同一個 API 行為的三種化身。
click() 瞄準元素邊界矩形的中心,而在沒有矩形的時候直接 return:
def click(self):
left, top, right, bottom = self.bounding_rectangle
if right > left and bottom > top: # ← 沒有矩形就什麼都不做
send_mouse_click((left + right) // 2, (top + bottom) // 2)
它長出來的三個樣子:
(0, 0, 0, 0)。點了沒反應,而唯一的症狀是「焦點一直沒有進到改名框」。clicked 23 of 27 pages and none carried control 1038——而且只有 arm64 會壞,因為 runner 的桌面比我的 VM 小,捲出去的節點更多。沒有一個看起來像「這個點擊根本沒發生」。
兩個修法各自對應到不同的成因:Files 那邊改用 invoke()(UIA pattern,不需要座標);WinMerge 那邊改成先點一個有矩形的節點取得焦點,然後用 {DOWN} 走——樹狀控制項會自己把游標捲進可視範圍,這是視窗大小無關的走法。
上面兩個都是繞路。第四次撞到之後我做了該做的事:讓 click() 在沒有矩形時拋例外。
def click(self, require_rectangle: bool = True):
left, top, right, bottom = self.bounding_rectangle
if right > left and bottom > top:
send_mouse_click((left + right) // 2, (top + bottom) // 2)
return True
if require_rectangle:
raise ActionVerificationError(
f"{self} has an empty bounding rectangle ..., so there is no point to "
"click. It may be scrolled out of view, not laid out yet, or hosted "
"somewhere that publishes no rectangle — try invoke(), or "
"scroll_into_view() first."
)
return False
require_rectangle=False 保留舊行為,而整個函式庫裡只有一個地方用得到它:set_focus() 的實體點擊備援。那裡本來就是「SetFocus 可能已經成功了,這個備援對沒有矩形的元素也幫不上忙」。
這是一個 breaking change,所以它自己也帶出一課:專案的 CHANGELOG 開頭原本寫「1.0 之前 API 只會在 minor 版之間變動」,而這次是 patch 版。與其留一個發版行為並不遵守的承諾,不如把承諾改成真的——現在寫的是「1.0 之前任何版本都可能改 API,每一項都會在 ### Changed 裡點出來並說明怎麼處理;那個條目才是保證,不是版本號」。
一個安靜的 no-op 不會只讓你付一次代價。 它會在每一個不同的地方,用一個不同的、看起來無關的症狀,再向你收一次。
要點 WinMerge 的「選項」,我第一版比對的是 '(O)'——括號裡的助憶字元。在中文桌面上這看起來完全與語言無關。
英文 runner 上全部的測試都掛了,錯誤是一個光禿禿的 StopIteration。
因為括號助憶字元是 CJK 的慣例,英文 Windows 寫的是 &Options...。兩種寫法真正共有的是快捷鍵,而它就在項目名稱裡:
zh-TW ' 選項 (O)...\tCtrl+,'
en-US '&Options...\tCtrl+,'
同樣的道理,Files 那六顆主要命令按鈕的 automation id 帶著在地化的後綴(ContextMenuPrimaryButton_重新命名),但它們的 AccessKey 是 Alt, M,來自命令定義而不是翻譯過的文字。
而 WinMerge 的 Options 頁面,我完全不去比對節點名字——那 27 個節點全是在地化的。改成一頁一頁走,直到某一頁擁有 control id 1038 為止。資源 ID 不會被翻譯。
「與語言無關」是一個要驗證的性質,不是一個看起來像就算數的性質。
| 天 | 主題 | 核心教訓 |
|---|---|---|
| 21 | 四個標的 | 為測函式庫而寫的 app 只能證明它跟自己一致 |
| 22 | Scintilla | 系統訊息會被 marshal,自訂訊息不會 |
| 23 | WinMerge | UIA 看不到的東西,像素是唯一證據 |
| 24 | Qt5 vs Qt6 | 同一個版本可以是兩個不同的程式 |
| 25 | Grid 與虛擬化 | 幾何 ≠ 語意;找不到可能是真的不存在 |
| 26 | 空答案與路徑 | 呼叫成功 ≠ 結果可用;多步驟要帶脈絡 |
| 27 | 抓別人的 bug | 版本配對要從修正挑;沒有對照組的重現不算重現 |
click() 在元素沒有矩形時安靜地什麼都不做,四個症狀一個成因——最後的修法是改掉那個 API,不是繞過它明天進入最後一章,也是這個系列真正的目的地:把前面 27 天的東西組成一個可以照抄的 GitHub Actions workflow。