iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Software Development

Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影系列 第 10

Day 10:Control Pattern——問一個元素「你能做什麼」

  • 分享至 

  • xImage
  •  

第二章的最後一天。前面四天講的都是「怎麼找到元素」和「找到之後怎麼相信它」。今天講找到之後你能對它做什麼、底層 COM 到底如何向元素索取能力,以及一個關於錯誤訊息的工程設計問題。


UIA 的核心抽象不是控制項型別

直覺上你會這樣想:這是一個按鈕,所以我可以點它;這是一個核取方塊,所以我可以勾它。

UIA 的設計不是這樣。它把「這是什麼」和「這能做什麼」拆開:

  • Control Type:這是什麼。Button、Edit、TreeItem、DataGrid⋯⋯
  • Control Pattern:這能做什麼。Invoke、Value、Toggle、ExpandCollapse⋯⋯

一個元素支援哪些 pattern,決定了你能對它做什麼——而這跟它是什麼型別沒有必然關係

這個設計是對的,因為現實世界的 UI 就是這麼混亂。一個「按鈕」可能是可切換開關的(Toggle)、可能會展開下拉選單(ExpandCollapse)、可能兩者都是(例如分裂按鈕 SplitButton)。用型別去猜能力,你會一直猜錯。

常見的 pattern 大致分三類:

類別 Pattern 用途
動作 Invoke 按下去、觸發單次動作
Toggle 切換二態或三態開關
ExpandCollapse 展開/收合階層或選單
資料 Value 讀寫單一值或簡單字串
Text 讀取富文本內容、選取範圍與格式屬性
結構 Selection / SelectionItem 容器與項目的選取關係
Grid / GridItem 二維表格行列定位
Table / TableItem 表格加上標頭語意
Scroll / ScrollItem 容器捲動與將元素捲入視野
ItemContainer / VirtualizedItem 虛擬化集合項目定位與具現化(第五章的主角)

COM 底層真相:GetCurrentPattern 與雙階段轉型

在程式碼層面,當我們想對一個元素執行某個 pattern 的操作時,底層在 COM 層級到底發生了什麼事?

當你呼叫 UI Automation Client 的 GetCurrentPattern(patternId) 時,是一條跨處理程序的 IPC 呼叫鏈:

  1. 向 Provider 查詢介面:UIA Core 透過 Windows RPC 向目標應用程式處理程序內的 Provider 詢問 IRawElementProviderSimple::GetPatternProvider(patternId)
  2. 回傳泛型指標:如果目標控制項實作了該 pattern,Provider 會回傳一個泛型的 IUnknown* COM 介面指標;如果不支援,則回傳 NULL
  3. 客戶端轉型(QueryInterface):Client 端拿到 IUnknown* 後,必須在自身處理程序內透過 COM 的 QueryInterface 轉型為強型別介面(例如 IUIAutomationTogglePatternIUIAutomationValuePattern)。

在 Python(例如使用 comtypes)的實作裡,這段邏輯長這樣:

def _pattern(self, pattern_id: int, interface_name: str):
    """Resolves a UIA control pattern, or None when the element doesn't support it."""
    try:
        raw = self._element.GetCurrentPattern(pattern_id)
        if not raw:
            return None
        module = __import__("comtypes.gen.UIAutomationClient", fromlist=[interface_name])
        return raw.QueryInterface(getattr(module, interface_name))
    except Exception as exc:
        logger.debug(f"Pattern {interface_name} unavailable ({type(exc).__name__}): {exc}")
        return None

這裡藏著一個 COM 崩潰陷阱

你可能會以為:「如果控制項不支援,頂多回傳 None,會有什麼問題?」

問題在於真實世界裡的第三方 UIA Provider 實作極度不穩定。有些自訂控制項在處理不支援的 patternId 時,內部不是優雅地回傳 S_OK 附帶 NULL,而是拋出未處理例外;更常見的是在動態 UI 中,控制項在呼叫 GetCurrentPattern 的那一瞬間剛好被銷毀或關閉。

這時 COM 底層會直接拋出 RPC_E_DISCONNECTED(遠端連線中斷)或 CO_E_OBJNOTCONNECTED。如果你的封裝層沒有把查詢包裹在嚴密的 try-except 內,你的自動化行程就會在「問它會不會打字」這種單純的自省動作中直接暴斃。


自動化實戰的三大 Pattern 陷阱

理解了 Pattern 抽象後,在撰寫自動化測試時,有三個經常讓人耗費數小時的實戰大坑:

陷阱一:ValuePattern.SetValue() 的合成謊言(The Synthetic Value Trap)

很多初學者看到文字框支援 ValuePattern,就以為可以用 value_pat.SetValue("hello") 塞值省下打字時間。

這在整合測試中通常是災難。

SetValue() 本質上是由 Provider 直接覆寫記憶體緩衝區(相當於 Win32 的 WM_SETTEXT)。它完全不會觸發硬體鍵盤事件(沒有 WM_KEYDOWNWM_KEYUP,也沒有 XAML 或 Web 的合成 input / change 事件)。

結果往往是:文字確實填進輸入框了,但表單下方的「送出」按鈕依然是灰色的(Disabled),因為 ViewModel 的資料繫結或輸入驗證器根本沒收到任何通知!

準則ValuePattern 適合用在測試前快速重設表單狀態,或是驗證輸出欄位的值。只要你要測試的是真正的「使用者輸入行為」與業務邏輯,就必須走 Day 8 與 Day 11 的 type_verified 實體輸入。

陷阱二:SysListView32 不是 Grid

傳統 Win32 的清單檢視(SysListView32),即使切換到「詳細資料」(Report View)模式、視覺上長得完全就是一個有欄有列的試算表,在 UIA 眼裡它依然是 List,只支援 SelectionScroll絕對不支援 GridPatternTablePattern

真正的 GridPattern 是 WPF 的 DataGrid、WinForms 的 DataGridView 這類具備二維儲存格座標(Row/Column)語意的現代控制項。

如果你拿 SysListView32 當成 Grid 寫測試,所有行列操作都會失敗。選錯了測試對象,你測的只是一個根本不存在的偽需求。

陷阱三:TextPattern 與自訂控制項的沈默(Scintilla 的謊言)

在文字編輯器領域,Win32 的 EDIT 支援 ValuePattern,讀寫單一純文字字串;但 Windows 11 的新版 Notepad(RichEdit 核心)或者 Word 這類富文字編輯器,往往提供的是基於 ITextRangeProviderTextPattern,不一定有簡單的 .Value

更極端的例子是 Notepad++ 的核心編輯器 Scintilla
在 UIA 樹狀結構裡,Scintilla 常常回報自己是一個泛型的 Document,但當你去問它的 pattern 時,TextPatternValuePattern 竟然通通回報不支援(patterns=[])。

如果你不知道這件事,你的測試就會在 UIA 裡盲目等待或重試直到超時。一旦元素能夠自我說明,發現 patterns=[],自動化框架就能果斷放棄 UIA,降級走 Win32 專屬通道(如 WM_GETTEXT 或 Scintilla 專屬的 SCI_GETTEXT 訊息)。


一個沒用的錯誤訊息

這就帶出了一個關於錯誤訊息的根本設計問題。

當你對一個不支援某 pattern 的元素動手時,早期的 wintegrate 會這樣回報:

ActionVerificationError: Element does not support GridPattern

這句話技術上完全正確,實務上毫無幫助

它告訴你「你想做的事做不到」,但沒告訴你任何有助於解決的資訊。你手上這個元素到底是什麼?它支援什麼?我是找錯元素了,還是找對了但這個控制項真的沒有這個能力?

我自己就被這句話擋過:我以為我拿到的是一個 DataGrid,實際上是一個 SysListView32。這兩件事的解法完全不同,但錯誤訊息對此隻字未提。


讓元素自我介紹

解法不是寫更多文件,而是讓錯誤發生時,元素自己把身分吐出來:

# Pattern id -> 名稱對照表
_PATTERN_NAMES = {
    10000: "Invoke",
    10001: "Selection",
    10002: "Value",
    10004: "Scroll",
    10005: "ExpandCollapse",
    10006: "Grid",
    10007: "GridItem",
    10010: "SelectionItem",
    10012: "Table",
    10013: "TableItem",
    10014: "Text",
    10015: "Toggle",
    10017: "ScrollItem",
    10019: "ItemContainer",
    10020: "VirtualizedItem",
}

def supported_patterns(self) -> list[str]:
    """Which control patterns this element actually implements."""
    found = []
    for pattern_id, name in self._PATTERN_NAMES.items():
        try:
            if self._element.GetCurrentPattern(pattern_id):
                found.append(name)
        except Exception:
            continue
    return found

def describe(self) -> str:
    """A one-line identity: control type, name, automation id, patterns."""
    return (
        f"<{self.control_type_name or self.control_type_id} "
        f"class={self.class_name!r} name={self.name!r} "
        f"id={self.automation_id!r} patterns={self.supported_patterns()}>"
    )

相關原始碼:src/wintegrate/element.py#L1073-L1096

然後讓斷言失敗時把 describe() 印出來:

ActionVerificationError: Element does not support GridPattern, so it is not a grid:
<list class='SysListView32' name='' id='2002' patterns=['Selection', 'Scroll']>

現在這句話有救了。你一眼看到三件事:

  1. 它是 List 不是 Grid
  2. 它的底層視窗類別是傳統 Win32 的 SysListView32
  3. 它真正實作的能力只有 SelectionScroll

下一步該做什麼變得很明顯:不要對它要 Grid,改用清單項目導覽,或者將測試對象換成真正的 WPF/WinUI DataGrid。

錯誤訊息的價值不在於精確描述失敗,而在於縮短使用者到解法的距離。
「不支援 GridPattern」是前者,上面那句是後者。


把自省變成 CI 產物

describe() 還有第二個用途。wintegrate 的測試會在 CI 上印出一份完整的樹狀控制項報表:

--- provider report ---
  <tree class='SysTreeView32' name='' id='2001' patterns=['Selection']>
     <tree item class='' name='Root A' patterns=['ExpandCollapse', 'SelectionItem', 'ScrollItem']>
     <tree item class='' name='Root B' patterns=['ExpandCollapse', 'SelectionItem', 'ScrollItem']>
  <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']>
--- end provider report ---

這份產物的核心價值在於跨環境異構比對

同一個控制項在 Windows 11 與 Windows Server 2025、在 x64 與 ARM64、甚至在開啟桌面主題服務(Themes service)與無介面 Headless 模式下,provider 回報的 pattern 集合不一定一樣

例如:某個自訂按鈕在本地開發機因為載入了完整主題,能被辨識為具備 TogglePattern;但在極簡配置的 CI runner 上,UIA fallback provider 卻可能因為缺少某些視覺樣式庫,將它降級為只剩 InvokePattern

當測試在某個環境單獨爆紅時,第一件事就是把本地與 CI 的 provider report 做文字 diff。問題在哪個環節被閹割,立刻水落石出。

這再次呼應了第一章的主題:在你連不進去的機器上,你需要的是它主動留下的自述,而不是你去猜。


小結:第二章的五日心智模型

第二章「UI Automation 的真相」到這裡正式劃下句點。五天下來,我們建立了一套在無人看管的 CI 上駕馭 UIA 的完整架構觀:

主題 核心心法
Day 6 物件模型 UIA 是跨處理程序的 COM 代理,參照隨時會過期;跨越子 HWND 必須依賴 RawViewWalker 才能穿透邊界。
Day 7 身分定位 一個條件不是身分。多條件查詢必須全部 AND,否則快得反常的查詢只會抓中同處理程序下的錯誤視窗。
Day 8 時序同調 桌面操作是非同步的。捨棄盲目的 time.sleep,用輪詢業務後置條件(Post-condition)換取真正的確定性。
Day 9 佇列賽跑 穿透 WinUI 3 Content Island。承認焦點後的選取狀態在底層是未定義的,由測試主動構造預期的邊界狀態。
Day 10 能力自省 解耦「型別」與「能力」。用 Pattern 判定操作空間,並讓元素在失敗時自我介紹,縮短除錯距離。

掌握了這五層防禦,你才算真正「拿到」並「看懂」了畫面上的元素。

明天我們將跨入第三章:輸入的底層。我們要問一個看起來很簡單、底層卻無比兇險的問題——「按一個鍵」到底有幾種做法? 為什麼那個差別會直接決定你的自動化測試能不能在跨語系與輸入法環境下存活?


上一篇
Day 9:穿透 Content Island——WinUI 3 焦點路由與 Win32 佇列賽跑實錄
下一篇
Day 11:「按一個鍵」有三種做法,而它們送到不同的地方
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言