iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

回到 UIA 的複雜控制項。今天兩件事:表格相關的四個 pattern 分工不是望文生義的,以及你找不到第 150 列,是因為它真的不存在


四個 pattern

Pattern 掛在誰身上 回答什麼
Grid 容器 有幾列幾欄?第 (r, c) 格是哪個元素?
GridItem 單一格 我在第幾列第幾欄?我的容器是誰?
Table 容器 標頭在哪裡?
TableItem 單一格 我這一格對應到哪些標頭?

關鍵的區分:Grid 是幾何,Table 是語意。

Grid 只知道「這是一個 r × c 的矩陣」,它不知道哪一欄叫「姓名」——那是 Table 的事。所以一個純粹的版面配置格線可以只支援 Grid;一個有欄位標頭的資料表格會同時支援 GridTable兩者是疊加的,不是二選一。

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()

相關原始碼:src/wintegrate/controls.py#L122-L246

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

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——看起來像表格不代表結構上是
  • UI 虛擬化讓 UIA 樹上只有可見的那部分,其餘的列真的不存在
  • 這個失敗看起來像「查詢條件錯」,而且在小資料集上不會發生
  • 不要斷言「某一列有沒有被虛擬化」——那是實作細節,不是契約
  • 測試資料要大於可視範圍,否則測不到虛擬化路徑
  • 讀整張表是 O(列×欄) 次 IPC 加上同樣次數的具體化,要問具體問題

明天講兩個更麻煩的狀況:控制項支援那個 pattern 卻回答你一個空結果,以及一個要走好幾步才能到的目標,失敗訊息該怎麼寫。


上一篇
Day 24:同一個版本,兩個程式
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言