feat: match N13 UIA selectors offline

This commit is contained in:
石头
2026-07-09 15:10:01 +08:00
parent 4d5a9b0ae8
commit 27c87d318d
2 changed files with 259 additions and 0 deletions

View 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),
}
}

View 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)
}
}