iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0

第五章倒數第二天。兩個主題:API 回答了、回答得很有禮貌,而那個答案是空的,以及一個要走好幾步才能到的目標,失敗訊息該怎麼寫。


現象:支援那個 pattern,卻回報沒有標頭

要拿一個表格的欄位標頭,正規做法是 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,行為就多一種分支、多一種要維護的路徑。

判準是:這個 fallback 有沒有對應到一個我親眼見過的真實控制項?

  • 「WPF DataGrid 的 TablePattern 回空」——測試裡看到了,有實例,加。
  • 「也許某些控制項會回 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」。天真的寫法有三個獨立的問題:

  1. 每一步都可能還沒完成就進到下一步
  2. 中間任何一步找不到,錯誤訊息只會說「找不到 Level 1」,不會告訴你是因為 Root A 根本沒展開
  3. 子節點可能是虛擬化的,要先具體化

所以包成一個具名動作:

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 最重要的設計面向之一。

順帶一提:測試 fixture 本身的坑

要測樹狀控制項,得先有一棵樹。我用 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」,沒有任何線索。


小結

  • 一個控制項可以支援某個 pattern,卻對它回答空的結果
  • 空答案比例外難查,因為錯誤會延後到下一步才出現
  • Fallback 的觸發條件是「結果沒用」,不只是「拋例外」
  • 只為見過的失敗寫 fallback,推測出來的是負債
  • LeafNode 是獨立的狀態,「沒東西可展開」不等於「還沒展開」
  • 多步驟導覽包成具名動作,讓錯誤訊息帶上完整脈絡
  • C 的負值指標常數在 ctypes 裡要自己做符號延伸

明天是第五章的最後一天,也是整章真正要回答的那個問題:這個工具真的抓得到 bug 嗎? 不是抓我自己寫的測試 app 的 bug,是抓四個從來沒聽過它的真實應用程式的 bug。


上一篇
Day 25:Grid、Table,與那些真的不存在的列
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言