diff --git a/internal/uiaselector/report.go b/internal/uiaselector/report.go new file mode 100644 index 0000000..99fe377 --- /dev/null +++ b/internal/uiaselector/report.go @@ -0,0 +1,102 @@ +package uiaselector + +import "time" + +const ( + StrictExitOK = 0 + StrictExitReportGenerationFailure = 1 + StrictExitUnmatched = 2 + StrictExitAmbiguous = 3 + StrictExitInvalidCatalog = 4 +) + +type Report struct { + OK bool `json:"ok"` + SourceDump string `json:"source_dump"` + CatalogSize int `json:"catalog_size"` + MatchedCount int `json:"matched_count"` + UnmatchedCount int `json:"unmatched_count"` + AmbiguousCount int `json:"ambiguous_count"` + GeneratedAtUTC string `json:"generated_at_utc"` + Selectors []SelectorReport `json:"selectors"` + Warnings []string `json:"warnings"` + Error *ReportError `json:"error,omitempty"` +} + +type ReportError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type SelectorReport struct { + SelectorID string `json:"selector_id"` + Description string `json:"description"` + AllowedUse string `json:"allowed_use"` + StabilityScore int `json:"stability_score"` + Evidence Evidence `json:"evidence"` + Matched bool `json:"matched"` + MatchCount int `json:"match_count"` + Matches []NodeMatch `json:"matches"` + Warnings []string `json:"warnings"` +} + +func RunCatalogReport(sourceDump string, root *Node, catalog []Selector, generatedAt time.Time) Report { + report := Report{ + OK: true, + SourceDump: sourceDump, + CatalogSize: len(catalog), + GeneratedAtUTC: generatedAt.UTC().Format(time.RFC3339), + Warnings: []string{}, + } + if root == nil { + report.OK = false + report.Error = &ReportError{Code: "NO_ROOT", Message: "UIA dump root is nil"} + return report + } + + for _, selector := range catalog { + if selector.AllowedUse != AllowedUseReadOnlyLocate { + report.OK = false + report.Error = &ReportError{Code: "INVALID_CATALOG", Message: "selector is not read-only: " + selector.ID} + return report + } + match := MatchSelector(root, catalog, selector.ID) + selectorReport := SelectorReport{ + SelectorID: selector.ID, + Description: selector.Description, + AllowedUse: selector.AllowedUse, + StabilityScore: selector.StabilityScore, + Evidence: selector.Evidence, + Matched: match.Matched, + MatchCount: match.MatchCount, + Matches: match.Matches, + Warnings: append([]string{}, match.Warnings...), + } + report.Selectors = append(report.Selectors, selectorReport) + if match.Matched { + report.MatchedCount++ + } else { + report.UnmatchedCount++ + } + if match.MatchCount > 1 { + report.AmbiguousCount++ + } + } + return report +} + +func StrictExitCode(report Report) int { + if !report.OK { + if report.Error != nil && report.Error.Code == "INVALID_CATALOG" { + return StrictExitInvalidCatalog + } + return StrictExitReportGenerationFailure + } + if report.UnmatchedCount > 0 { + return StrictExitUnmatched + } + if report.AmbiguousCount > 0 { + return StrictExitAmbiguous + } + return StrictExitOK +} diff --git a/internal/uiaselector/report_test.go b/internal/uiaselector/report_test.go new file mode 100644 index 0000000..c227721 --- /dev/null +++ b/internal/uiaselector/report_test.go @@ -0,0 +1,98 @@ +package uiaselector + +import ( + "testing" + "time" +) + +func fixedReportTime() time.Time { + return time.Date(2026, 7, 9, 0, 0, 0, 0, time.UTC) +} + +func loadN14Fixture(t *testing.T) *Node { + t.Helper() + root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json") + if err != nil { + t.Fatalf("LoadDumpFile: %v", err) + } + return root +} + +func TestRunCatalogReportMatchesAllN13Selectors(t *testing.T) { + report := RunCatalogReport( + "testdata/n12r-2026-07-09-uia-redacted.json", + loadN14Fixture(t), + DefaultCatalog(), + fixedReportTime(), + ) + if !report.OK { + t.Fatalf("report OK=false: %#v", report.Error) + } + if report.SourceDump != "testdata/n12r-2026-07-09-uia-redacted.json" { + t.Fatalf("source_dump = %q", report.SourceDump) + } + if report.GeneratedAtUTC != "2026-07-09T00:00:00Z" { + t.Fatalf("generated_at_utc = %q", report.GeneratedAtUTC) + } + if report.CatalogSize != 10 { + t.Fatalf("catalog_size = %d, want 10", report.CatalogSize) + } + if report.MatchedCount != 10 { + t.Fatalf("matched_count = %d, want 10", report.MatchedCount) + } + if report.UnmatchedCount != 0 { + t.Fatalf("unmatched_count = %d, want 0", report.UnmatchedCount) + } + if report.AmbiguousCount != 0 { + t.Fatalf("ambiguous_count = %d, want 0", report.AmbiguousCount) + } + if got := StrictExitCode(report); got != StrictExitOK { + t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitOK) + } +} + +func TestStrictModeClassifiesFailures(t *testing.T) { + catalog := []Selector{ + { + ID: "missing_panel", + Description: "missing structural test selector", + AllowedUse: AllowedUseReadOnlyLocate, + Required: Criteria{AutomationID: "does_not_exist", ControlType: "Pane", FrameworkID: "WinForm"}, + StabilityScore: 80, + Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root/missing"}, + }, + } + report := RunCatalogReport("fixture.json", loadN14Fixture(t), catalog, fixedReportTime()) + if !report.OK { + t.Fatalf("report OK=false: %#v", report.Error) + } + if report.UnmatchedCount != 1 { + t.Fatalf("unmatched_count = %d, want 1", report.UnmatchedCount) + } + if got := StrictExitCode(report); got != StrictExitUnmatched { + t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitUnmatched) + } +} + +func TestReportRejectsNonReadOnlyCatalogEntries(t *testing.T) { + catalog := []Selector{ + { + ID: "bad_write_selector", + Description: "invalid write selector", + AllowedUse: "write", + Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"}, + StabilityScore: 1, + Evidence: Evidence{CaptureID: "unit-test", UIAPath: "root"}, + }, + } + report := RunCatalogReport("fixture.json", loadN14Fixture(t), catalog, fixedReportTime()) + if report.OK { + t.Fatalf("report OK=true for invalid catalog") + } + if report.Error == nil || report.Error.Code != "INVALID_CATALOG" { + t.Fatalf("report error = %#v, want INVALID_CATALOG", report.Error) + } + if got := StrictExitCode(report); got != StrictExitInvalidCatalog { + t.Fatalf("StrictExitCode = %d, want %d", got, StrictExitInvalidCatalog) + } +}