feat: add N14 selector report model
This commit is contained in:
102
internal/uiaselector/report.go
Normal file
102
internal/uiaselector/report.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
98
internal/uiaselector/report_test.go
Normal file
98
internal/uiaselector/report_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user