回到 UIA 的複雜控制項。今天兩件事:表格相關的四個 pattern 分工不是望文生義的,以及你找不到第 150 列,是因為它真的不存在。
| Pattern | 掛在誰身上 | 回答什麼 |
|---|---|---|
Grid |
容器 | 有幾列幾欄?第 (r, c) 格是哪個元素? |
GridItem |
單一格 | 我在第幾列第幾欄?我的容器是誰? |
Table |
容器 | 標頭在哪裡? |
TableItem |
單一格 | 我這一格對應到哪些標頭? |
關鍵的區分:Grid 是幾何,Table 是語意。
Grid 只知道「這是一個 r × c 的矩陣」,它不知道哪一欄叫「姓名」——那是 Table 的事。所以一個純粹的版面配置格線可以只支援 Grid;一個有欄位標頭的資料表格會同時支援 Grid 和 Table。兩者是疊加的,不是二選一。
grid = element.as_data_grid()
grid.row_count # GridPattern.CurrentRowCount
cell = grid.get_cell(row=3, column=1) # GridPattern.GetItem(3, 1)
cell.row # GridItemPattern.CurrentRow
cell.column_header # TableItemPattern.GetCurrentColumnHeaderItems()
GetItem(r, c) 是 GridPattern 的核心:直接用座標拿到那一格,比「列舉所有子元素然後自己算位置」可靠得多——尤其表格有合併儲存格或有捲動時。
grid.get_cell(row=3, column="Email") # 而不是 column=2
grid.find_row_by_cell_value("Email", "a@b.c")
這是「不要用視窗標題找視窗」那個原則的延伸:編號會因為欄位順序調整而失效,名稱不會。
名稱當然有在地化的問題,但欄位標頭通常是應用程式自己定義的資料語意,比視窗標題穩定得多。column_index() 兩種都接受:
def column_index(self, column: int | str) -> int:
if isinstance(column, int):
return column
headers = self.get_column_headers()
return headers.index(column)
SysListView32 不是 Grid第一版的測試 fixture 用傳統 Win32 的 ListView(SysListView32),開在「詳細資料」檢視——畫面上就是一個標準的多欄表格。所有 Grid 相關的測試都失敗:
ActionVerificationError: Element does not support GridPattern, so it is not a grid: <...>
因為 SysListView32 在 UIA 裡是 List,不是 Grid:
<list class='SysListView32' name='' id='2002' patterns=['Selection', 'Scroll']>
<header class='SysHeader32' name='Header Control' id='Header' patterns=[]>
<list item class='' name='alpha' patterns=['Invoke', 'SelectionItem', 'ScrollItem']>
它的子元素是 list item,一列就是一個項目,沒有「格」這個概念。那個看起來像欄位的東西是視覺呈現,UIA 樹上並不存在對應的結構。
真正支援 GridPattern 的是 WPF 的 DataGrid、WinForms 的 DataGridView 這類把「二維資料」當成模型的控制項。
選錯測試對象,你測的是一個不存在的問題。 我花了一段時間懷疑自己的 GridPattern 包裝寫錯了,實際上程式碼從頭到尾都是對的——而如果錯誤訊息當初就印出上面那一行,我五分鐘就會發現。
前面講過「找不到但其實存在」的元素。這裡是相反的情況。
一個綁定了十萬列資料的 WPF DataGrid,不會建立十萬個 UI 元素。它只為目前可見的那幾十列建立實際的視覺元素,捲動時回收離開畫面的、建立新進入的。這叫 UI 虛擬化,是所有現代 UI 框架的標準做法。
UIA 樹上只有你捲到的那部分。第 150 列不在樹上,因為它在畫面上不存在。
row = grid.find_row_by_cell_value("Email", "user150@example.com")
# ElementNotFoundError
這個失敗會誤導你,因為它看起來跟「查詢條件寫錯」一模一樣。你會去檢查欄位名稱、值有沒有多空白、大小寫——而真正的原因是那一列還沒被建立出來。
更麻煩的是它在小資料集上不會發生。用 10 列的測試資料開發一切正常,上線後資料變成 500 列,測試開始失敗。
| Pattern | 掛在 | 作用 |
|---|---|---|
ScrollItem |
項目 | 把我捲進可視範圍 |
VirtualizedItem |
虛擬項目 | 把我具體化成真的元素 |
ItemContainer |
容器 | 依屬性找出一個項目,即使它還沒具體化 |
ItemContainer 是最強的一個,因為它是唯一能在元素不存在時找到它的機制——它問的是容器(容器知道自己有哪些資料),而不是問 UIA 樹。
包成由淺到深三層,呼叫端實務上只需要最後一個:
element.scroll_into_view() # ScrollItemPattern
element.realize() # VirtualizedItemPattern
element.ensure_available() # 兩者都試,回傳一個可用的元素
我原本寫了這樣一個測試:
def test_virtualization_helpers(grid):
assert grid.row(0).realize() is False # 第 0 列可見,不需要具體化
assert grid.row(199).realize() is True # 第 199 列需要
它在 SysListView32 的舊 fixture 上通過,換成 WPF DataGrid 之後失敗——因為在 WPF 上連第 0 列都是虛擬化的。
我一開始想改成 assert row0.realize() is True,但那只是把一個錯的假設換成另一個。「這一列需不需要具體化」取決於框架的實作、目前的捲動位置、甚至面板的快取策略——它不是一個 API 契約,是一個實作細節。
def test_virtualization_helpers_are_transparent(grid):
"""The helpers must be safe to call regardless of whether the item was
already realized — the caller should not have to know."""
cell = grid.get_cell(150, "Email")
assert isinstance(cell.realize(), bool) # 不拋例外,回傳布林
assert cell.ensure_available().value == "user150@example.com" # 之後可用
現在它斷言的是這些輔助方法的契約:不管項目原本是什麼狀態,呼叫都安全,而且呼叫完之後那個元素可以用。這才是呼叫端真正依賴的性質。
先分清楚哪些是契約、哪些是當下的實作行為,只對前者寫斷言。
虛擬化在小資料集上不會發生,所以測試 fixture 刻意做了 200 列。200 不是隨便選的——它要大於任何合理的可視列數,這樣後段的列才一定是虛擬化的。如果只有 30 列,在一個大視窗上可能全部都可見,虛擬化路徑就永遠沒被測到。
一個測不到目標路徑的測試,比沒有測試更糟,因為它給你錯誤的信心。
values = [[grid.get_cell(r, c).value
for c in range(grid.column_count)]
for r in range(grid.row_count)]
這在一個 200 × 5 的表格上會非常慢。每一次 get_cell 是一次跨處理程序呼叫,每一次 .value 又是一次——1000 格就是至少 2000 次 IPC。
而虛擬化讓它更慢:每一次跨越可視範圍的存取都可能觸發一次具體化,而具體化是要建立 UI 元素、跑版面配置的。所以 for r in range(10000) 不只是 10000 次 IPC,還是 10000 次「建立元素、算版面、回收元素」。
# 慢,而且斷言失敗時看不出是哪裡不對
assert grid_to_list() == expected_whole_table
# 快,而且失敗訊息直接指向問題
assert grid.get_cell(3, "Email").value == "a@b.c"
assert grid.row_count == 200
# 更好:用 ItemContainer 直接問容器,一次呼叫,跳過整個走訪
row = grid.find_row_by_cell_value("Email", "user150@example.com")
問具體的問題還有一個好處:它表達了你真正在乎什麼。 一個比對整張表的斷言,會因為任何一個不相干的欄位變動而失敗。
Grid 是幾何(幾列幾欄),Table 是語意(標頭),兩者疊加Grid/Table,格子身上掛 GridItem/TableItem
SysListView32 是 UIA 的 List 不是 Grid——看起來像表格不代表結構上是明天講兩個更麻煩的狀況:控制項支援那個 pattern 卻回答你一個空結果,以及一個要走好幾步才能到的目標,失敗訊息該怎麼寫。