第五章倒數第二天。兩個主題:API 回答了、回答得很有禮貌,而那個答案是空的,以及一個要走好幾步才能到的目標,失敗訊息該怎麼寫。
要拿一個表格的欄位標頭,正規做法是 TablePattern:
headers = table_pattern.GetCurrentColumnHeaders()
# headers.Length == 0
長度是 0。而這個表格是一個 WPF DataGrid——它畫面上明明有五個欄位標頭,而且它確實支援 TablePattern(不然建構的時候就會拋 ActionVerificationError)。
所以狀況是:專門為表格設計的 pattern,在一個標準的表格控制項上,回報這個表格沒有標頭。
如果它拋例外,我五秒鐘就知道要換做法。但它回傳一個合法的、長度為 0 的集合——從型別上看沒有任何問題,從程式碼上看你也沒做錯什麼。於是:
headers = grid.get_column_headers() # []
idx = headers.index("Email") # ValueError: 'Email' is not in list
錯誤發生在下一步,而且訊息是「Email 不在清單裡」。你會去檢查欄位名稱的拼字、大小寫、有沒有多空白——完全錯誤的方向。
回傳一個合法但錯誤的答案,比拋例外難查得多。
標頭當然還是存在的,只是不在 TablePattern 回答的那個地方——它們是 UIA 樹上的 HeaderItem 子元素(control type 50035):
<data grid ...>
<header item name='Name'>
<header item name='Email'>
<header item name='Role'>
用 provider report 一看就知道。而這再次說明為什麼那份報告值得印出來——當 API 給你的答案和畫面對不上時,你需要一個獨立的視角來看樹到底長什麼樣。
def get_column_headers(self) -> list[str]:
"""...a WPF DataGrid supports TablePattern but answers
GetCurrentColumnHeaders with an empty collection, keeping the header texts
on HeaderItem children instead."""
pat = self._table()
if pat is not None:
try:
headers = pat.GetCurrentColumnHeaders()
if headers and headers.Length: # ← 關鍵在這個檢查
return [UiaElement(headers.GetElement(i)).name
for i in range(headers.Length)]
except Exception:
pass
names = [item.name for item in
self.element.find_all(control_type_id=UIA_HeaderItemControlTypeId)
if item.name]
return names[: self.column_count]
三個細節:
if headers and headers.Length——不能只檢查有沒有拋例外,要檢查結果是不是有內容。這一行就是這一段的重點。
names[: self.column_count]——HeaderItem 可能不只表頭那些(有些控制項的篩選按鈕、群組列也是 header item),所以用欄位數截斷。
過濾掉空的 name——沒有名字的標頭對「用名稱定位欄位」沒有意義。
| 場景 | API 說 | 實際上 |
|---|---|---|
| 桌面 | SendInput 成功 |
送到別張桌面去了 |
| 截圖 | PrintWindow 成功 |
圖是全黑的 |
| 搜尋 | FindFirst 找不到 |
元素存在,只是跨不過邊界 |
| 探索 | find() 回傳一個視窗 |
是錯的視窗 |
| 輸入 | SendInput 回傳了 |
只送出了一部分 |
| 輸入法 | has_context: False |
輸入法正在攔截 |
| 表格 | GetCurrentColumnHeaders() 成功 |
空的 |
每一個外部呼叫,除了檢查它有沒有失敗,還要檢查它的結果是否可用。
而「可用」的定義要具體:不是 None、長度大於 0、不是全黑、值真的變了。這些檢查很囉唆,但它們是把「安靜的錯誤」轉成「明確的失敗」的唯一辦法。
反過來也要有分寸。每加一條 fallback,行為就多一種分支、多一種要維護的路徑。
判準是:這個 fallback 有沒有對應到一個我親眼見過的真實控制項?
None 而不是空集合」——沒見過,不加。推測出來的 fallback 是負債:它沒有測試覆蓋(因為你造不出那個情境)、沒有人知道它對不對,而且它會讓程式碼看起來比實際上更周全。
只為你見過的失敗寫 fallback。 見到新的再加。
樹狀控制項同時涉及非同步、虛擬化,和多步驟操作。
| Pattern | 作用 |
|---|---|
ExpandCollapse |
展開/收合,以及查詢目前狀態 |
SelectionItem |
選取,以及查詢是否被選取 |
ExpandCollapseState 有四種值:Collapsed、Expanded、PartiallyExpanded、LeafNode。最後一種代表「這個節點沒有子節點,展開對它沒有意義」。把它獨立成 is_leaf 屬性,是為了讓呼叫端區分「還沒展開」和「沒東西可展開」——這兩者在走訪邏輯裡的處理完全不同。
item.expand()
children = item.children_items() # 有時候是空的
樹狀控制項的展開特別慢,因為它常常要去載入資料——展開一個節點可能觸發一次資料庫查詢、一次 API 呼叫、一次檔案系統掃描。所以 expand_verified() 送出之後輪詢到狀態真的變成 Expanded 為止。
真正的使用情境很少是「展開一個節點」,而是「走到 Root A / Level 1 / Level 2」。天真的寫法有三個獨立的問題:
所以包成一個具名動作:
tree.navigate_path_verified(["Root A", "Level 1", "Level 2"])
menu.select_cascade("File > Open...")
相關原始碼:
src/wintegrate/controls.py#L354-L400,以及選單路徑串接src/wintegrate/controls.py#L770-L814
對路徑上的每一段:找到節點、ensure_available()、展開並驗證、再往下一層。最後一段選取並驗證。好處不只是少寫幾行——它讓錯誤訊息可以帶上完整脈絡:
Failed to navigate ['Root A', 'Level 1', 'Level 2']: expanded 'Root A' ok,
but no child named 'Level 1' among ['Level 1a', 'Level 1b']
這句話直接告訴你:前一步是對的、這一步的候選有哪些、你要的那個不在裡面。跟「找不到 Level 1」相比,除錯時間差了一個數量級。
錯誤訊息的資訊量,是多步驟 API 最重要的設計面向之一。
要測樹狀控制項,得先有一棵樹。我用 Win32 API 建了一個 SysTreeView32,然後在插入項目時卡住——TVM_INSERTITEMW 一直失敗。原因是兩個常數:
#define TVI_ROOT ((HTREEITEM)(ULONG_PTR)-0x10000)
#define TVI_LAST ((HTREEITEM)(ULONG_PTR)-0x0FFFE)
它們是負的、指標寬度的常數。在 Python 裡直接寫 -0x10000,ctypes 會把它當成 32 位元的值處理,在 64 位元系統上高 32 位是 0——而 Windows 期待的是全部都是 1(符號延伸)。
_PTR_MASK = (1 << (8 * ctypes.sizeof(ctypes.c_void_p))) - 1
TVI_ROOT = (-0x10000) & _PTR_MASK
C 的巨集常數在 Python 裡沒有型別資訊,你得自己把寬度和符號補回去。 而失敗的方式是「函式回傳 0」,沒有任何線索。
LeafNode 是獨立的狀態,「沒東西可展開」不等於「還沒展開」明天是第五章的最後一天,也是整章真正要回答的那個問題:這個工具真的抓得到 bug 嗎? 不是抓我自己寫的測試 app 的 bug,是抓四個從來沒聽過它的真實應用程式的 bug。