diff --git a/internal/uiaselector/matcher.go b/internal/uiaselector/matcher.go new file mode 100644 index 0000000..f6872af --- /dev/null +++ b/internal/uiaselector/matcher.go @@ -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), + } +} diff --git a/internal/uiaselector/matcher_test.go b/internal/uiaselector/matcher_test.go new file mode 100644 index 0000000..cad9fe8 --- /dev/null +++ b/internal/uiaselector/matcher_test.go @@ -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) + } +}