feat: match N13 UIA selectors offline
This commit is contained in:
186
internal/uiaselector/matcher.go
Normal file
186
internal/uiaselector/matcher.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package uiaselector
|
||||
|
||||
import "strings"
|
||||
|
||||
type MatchResult struct {
|
||||
OK bool `json:"ok"`
|
||||
SelectorID string `json:"selector_id"`
|
||||
Matched bool `json:"matched"`
|
||||
MatchCount int `json:"match_count"`
|
||||
Matches []NodeMatch `json:"matches"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Error MatchError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type MatchError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type NodeMatch struct {
|
||||
Path string `json:"path"`
|
||||
ControlType string `json:"control_type"`
|
||||
AutomationID string `json:"automation_id"`
|
||||
ClassName string `json:"class_name"`
|
||||
FrameworkID string `json:"framework_id"`
|
||||
HasName bool `json:"has_name"`
|
||||
NameLength int `json:"name_length"`
|
||||
Name string `json:"-"`
|
||||
}
|
||||
|
||||
type nodeRef struct {
|
||||
path string
|
||||
node *Node
|
||||
}
|
||||
|
||||
func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult {
|
||||
selector, ok := findSelector(catalog, selectorID)
|
||||
if !ok {
|
||||
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}}
|
||||
}
|
||||
if selector.AllowedUse != AllowedUseReadOnlyLocate {
|
||||
return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}}
|
||||
}
|
||||
refs := flatten(root)
|
||||
matches := matchCriteria(refs, selector.Required)
|
||||
if len(matches) == 0 {
|
||||
for _, fallback := range selector.Fallback {
|
||||
matches = matchFallback(refs, fallback)
|
||||
if len(matches) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}}
|
||||
for _, ref := range matches {
|
||||
result.Matches = append(result.Matches, summarizeNode(ref))
|
||||
}
|
||||
if !result.Matched {
|
||||
result.Warnings = append(result.Warnings, "selector_not_found")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func findSelector(catalog []Selector, selectorID string) (Selector, bool) {
|
||||
for _, selector := range catalog {
|
||||
if selector.ID == selectorID {
|
||||
return selector, true
|
||||
}
|
||||
}
|
||||
return Selector{}, false
|
||||
}
|
||||
|
||||
func flatten(root *Node) []nodeRef {
|
||||
refs := []nodeRef{}
|
||||
var walk func(node *Node, path string)
|
||||
walk = func(node *Node, path string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
refs = append(refs, nodeRef{path: path, node: node})
|
||||
for i := range node.Children {
|
||||
walk(&node.Children[i], path+"/"+itoa(i))
|
||||
}
|
||||
}
|
||||
walk(root, "root")
|
||||
return refs
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
digits := []byte{}
|
||||
for value > 0 {
|
||||
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||
value = value / 10
|
||||
}
|
||||
return string(digits)
|
||||
}
|
||||
|
||||
func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef {
|
||||
matches := []nodeRef{}
|
||||
for _, ref := range refs {
|
||||
if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID {
|
||||
continue
|
||||
}
|
||||
if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType {
|
||||
continue
|
||||
}
|
||||
if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID {
|
||||
continue
|
||||
}
|
||||
if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName {
|
||||
continue
|
||||
}
|
||||
if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, ref)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef {
|
||||
matches := []nodeRef{}
|
||||
for _, ref := range refs {
|
||||
if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType {
|
||||
continue
|
||||
}
|
||||
if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) {
|
||||
continue
|
||||
}
|
||||
if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) {
|
||||
continue
|
||||
}
|
||||
if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, ref)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func hasChildAutomationIDs(node *Node, ids []string) bool {
|
||||
for _, id := range ids {
|
||||
found := false
|
||||
for i := range node.Children {
|
||||
if node.Children[i].AutomationID == id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func hasChildControlTypes(node *Node, controlTypes []string) bool {
|
||||
for _, controlType := range controlTypes {
|
||||
found := false
|
||||
for i := range node.Children {
|
||||
if node.Children[i].ControlType == controlType {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func summarizeNode(ref nodeRef) NodeMatch {
|
||||
return NodeMatch{
|
||||
Path: ref.path,
|
||||
ControlType: ref.node.ControlType,
|
||||
AutomationID: ref.node.AutomationID,
|
||||
ClassName: ref.node.ClassName,
|
||||
FrameworkID: ref.node.FrameworkID,
|
||||
HasName: ref.node.Name != "",
|
||||
NameLength: len(ref.node.Name),
|
||||
}
|
||||
}
|
||||
73
internal/uiaselector/matcher_test.go
Normal file
73
internal/uiaselector/matcher_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package uiaselector
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
selectorID string
|
||||
path string
|
||||
control string
|
||||
autoID string
|
||||
}{
|
||||
{selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"},
|
||||
{selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"},
|
||||
{selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := MatchSelector(root, DefaultCatalog(), tt.selectorID)
|
||||
if !result.OK {
|
||||
t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error)
|
||||
}
|
||||
if !result.Matched {
|
||||
t.Fatalf("%s matched=false", tt.selectorID)
|
||||
}
|
||||
if result.MatchCount != 1 {
|
||||
t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount)
|
||||
}
|
||||
match := result.Matches[0]
|
||||
if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID {
|
||||
t.Fatalf("%s match = %#v", tt.selectorID, match)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
result := MatchSelector(root, DefaultCatalog(), "main_window")
|
||||
if !result.OK || !result.Matched || len(result.Matches) != 1 {
|
||||
t.Fatalf("unexpected result: %#v", result)
|
||||
}
|
||||
match := result.Matches[0]
|
||||
if match.Name != "" {
|
||||
t.Fatalf("match returned visible name text %q", match.Name)
|
||||
}
|
||||
if !match.HasName {
|
||||
t.Fatalf("main_window should report has_name=true")
|
||||
}
|
||||
if match.NameLength == 0 {
|
||||
t.Fatalf("main_window should report non-zero name_length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) {
|
||||
root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDumpFile: %v", err)
|
||||
}
|
||||
result := MatchSelector(root, DefaultCatalog(), "missing_selector")
|
||||
if result.OK {
|
||||
t.Fatalf("OK=true for missing selector: %#v", result)
|
||||
}
|
||||
if result.Error.Code != "UNKNOWN_SELECTOR" {
|
||||
t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user