From 0d1b2cf4e8e1a8727bbca25da0772f0590cde977 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 16 Aug 2026 17:45:35 +0530 Subject: [PATCH] feat(label-coverage): report the addressable share of an app's interactive surface reads the hierarchies a run already recorded and splits each screen's interactive elements by the strongest selector that can name them, so a spec's reach over an app is a number rather than an impression. --- cmd/internal-tools/label-coverage/main.go | 549 ++++++++++++++++++ .../label-coverage/main_test.go | 446 ++++++++++++++ 2 files changed, 995 insertions(+) create mode 100644 cmd/internal-tools/label-coverage/main.go create mode 100644 cmd/internal-tools/label-coverage/main_test.go diff --git a/cmd/internal-tools/label-coverage/main.go b/cmd/internal-tools/label-coverage/main.go new file mode 100644 index 0000000..b8f3190 --- /dev/null +++ b/cmd/internal-tools/label-coverage/main.go @@ -0,0 +1,549 @@ +// Command label-coverage reports how much of an app's interactive surface a +// spec can address, from the hierarchies a run already recorded. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "unicode" +) + +type element struct { + ResourceID string `json:"resourceId"` + Text string `json:"text"` + Description string `json:"description"` + Class string `json:"class"` + Package string `json:"package"` + Clickable bool `json:"clickable"` + Editable bool `json:"editable"` + Enabled bool `json:"enabled"` +} + +type step struct { + Index int `json:"step"` + Screen string `json:"screen"` + Hierarchy *struct { + Elements []element `json:"elements"` + } `json:"hierarchy"` +} + +// Counts splits a screen's interactive elements by the strongest selector that +// can reach them. Text is separated from identifier and description because a +// row labelled only by the customer name it displays is addressable in one run +// and gone in the next, which is not the same thing as being addressable. A +// description that carries the data with it is separated for the same reason. +type Counts struct { + Screen string `json:"screen"` + Observations int `json:"observations"` + Elements int `json:"elements"` + Interactive int `json:"interactive"` + ByIdentifier int `json:"by_identifier"` + ByDataID int `json:"by_data_carrying_identifier"` + ByDescription int `json:"by_description"` + ByVolatile int `json:"by_volatile_description"` + ByTextOnly int `json:"by_text_only"` + Unaddressable int `json:"unaddressable"` + AmbiguousIDs int `json:"ambiguous_identifiers"` + // Needing lists the interactive elements no durable selector reaches, which + // is the work list for a label pass rather than a statistic about it. + Needing []string `json:"needing_labels,omitempty"` +} + +func (c Counts) stableShare() float64 { + if c.Interactive == 0 { + return 0 + } + return float64( + c.ByIdentifier+c.ByDescription, + ) / float64( + c.Interactive, + ) * 100 +} + +func main() { + jsonOut := flag.Bool("json", false, "emit JSON instead of a table") + show := flag.Int( + "show", + 0, + "list up to this many controls per screen that no durable selector reaches", + ) + pkg := flag.String( + "package", + "", + "count only elements belonging to this package; system UI is dropped either way", + ) + flag.Usage = func() { + fmt.Fprintln( + os.Stderr, + "usage: label-coverage [--json] [--show N] ...", + ) + } + flag.Parse() + if flag.NArg() == 0 { + flag.Usage() + os.Exit(2) + } + + traces, err := collect(flag.Args()) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(traces) == 0 { + fmt.Fprintln(os.Stderr, "no trace.jsonl found under the given paths") + os.Exit(1) + } + + byScreen := map[string]*Counts{} + for _, path := range traces { + if err := accumulate(path, byScreen, *pkg); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", path, err) + os.Exit(1) + } + } + + screens := make([]Counts, 0, len(byScreen)) + for _, counts := range byScreen { + screens = append(screens, *counts) + } + sort.Slice( + screens, + func(i, j int) bool { return screens[i].Screen < screens[j].Screen }, + ) + + if *jsonOut { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(screens); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + render(os.Stdout, screens, len(traces), *show) +} + +func collect(paths []string) ([]string, error) { + var traces []string + for _, path := range paths { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if !info.IsDir() { + traces = append(traces, path) + continue + } + err = filepath.WalkDir( + path, + func(candidate string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if !entry.IsDir() && entry.Name() == "trace.jsonl" { + traces = append(traces, candidate) + } + return nil + }, + ) + if err != nil { + return nil, err + } + } + sort.Strings(traces) + return traces, nil +} + +// accumulate folds one trace into byScreen, counting each distinct hierarchy +// once. A run that idles on a screen observes it many times, and summing those +// observations would report the screen the explorer sat on rather than the +// screen with the most unlabelled controls. +// systemPackages own nodes that share the screen with the app under test. A +// status bar contributes three well-labelled controls to every capture, and +// counting them lifts an app with no identifiers at all off the floor. +var systemPackages = map[string]bool{ + "com.android.systemui": true, + "android": true, + "com.google.android.inputmethod.latin": true, + "com.android.inputmethod.latin": true, + "com.google.android.apps.nexuslauncher": true, + "com.google.android.googlequicksearchbox": true, +} + +// scoped drops the windows the app under test does not own. Most of an app's +// own nodes carry no package attribute at all, since only the window roots are +// stamped with one, so an empty package is treated as belonging to the app +// rather than filtered out. Filtering on an exact match alone discards the +// entire application and reports a clean zero. +func scoped(elements []element, pkg string) []element { + kept := make([]element, 0, len(elements)) + for _, item := range elements { + if item.Package == "" { + kept = append(kept, item) + continue + } + if pkg != "" { + if item.Package == pkg { + kept = append(kept, item) + } + continue + } + if !systemPackages[item.Package] { + kept = append(kept, item) + } + } + return kept +} + +func accumulate(path string, byScreen map[string]*Counts, pkg string) error { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + seen := map[string]bool{} + decoder := json.NewDecoder(file) + for { + var recorded step + if err := decoder.Decode(&recorded); err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + if recorded.Hierarchy == nil || len(recorded.Hierarchy.Elements) == 0 { + continue + } + elements := scoped(recorded.Hierarchy.Elements, pkg) + if len(elements) == 0 { + continue + } + screen := recorded.Screen + if screen == "" { + screen = shape(elements) + } + key := screen + "\x00" + signature(elements) + if seen[key] { + continue + } + seen[key] = true + + observed := measure(elements) + observed.Screen = screen + counts, ok := byScreen[screen] + if !ok { + observed.Observations = 1 + byScreen[screen] = &observed + continue + } + if observed.Interactive > counts.Interactive { + observed.Observations = counts.Observations + *counts = observed + } + counts.Observations++ + } + return nil +} + +// shape names a screen the app did not name itself, by the set of distinct +// controls it shows. Repetition is dropped deliberately: a ledger holding three +// rows and the same ledger holding five is one screen, not two. +func shape(elements []element) string { + distinct := map[string]bool{} + for _, item := range elements { + key := item.ResourceID + if key == "" { + key = item.Class + "/" + item.Description + } + distinct[key] = true + } + keys := make([]string, 0, len(distinct)) + for key := range distinct { + keys = append(keys, key) + } + sort.Strings(keys) + sum := sha256.Sum256([]byte(strings.Join(keys, "\x00"))) + return "shape:" + hex.EncodeToString(sum[:])[:8] +} + +// volatile reports whether a description carries the data it labels. Compose +// merges a row's children into one description, so a ledger row arrives +// labelled with the customer name, the balance and a relative age that reprices +// itself every month. Such a description exists, which is why a presence check +// scores it as a selector, and it is not one: the run that recorded it is the +// only run it matches. The test is a heuristic and deliberately blunt, since +// the alternative is to call every one of them stable. +func volatile(description string) bool { + trimmed := strings.TrimSpace(description) + // A one-character label is an avatar initial, and it is the first letter of + // a name the run happened to observe. Renaming the record changes it. The + // digit test below catches an initial drawn from a numeric name and would + // miss every alphabetic one, which is how four of them were counted as + // durable before this was noticed on a real ledger. + if len([]rune(trimmed)) == 1 && + (unicode.IsLetter([]rune(trimmed)[0]) || unicode.IsDigit([]rune(trimmed)[0])) { + return true + } + for _, character := range trimmed { + if unicode.IsDigit(character) { + return true + } + } + lowered := strings.ToLower(trimmed) + for _, marker := range []string{" ago", "since ", "yesterday", "today", "tomorrow", "last ", "due ", "minute", "hour", "day", "week", "month", "year"} { + if strings.Contains(lowered, marker) { + return true + } + } + return false +} + +// dataCarryingID reports whether an identifier embeds the record it names. A +// list row tagged `customer_row_` names its role durably and its instance +// not at all, so an exact match on it survives exactly one run. The prefix is +// still worth something, which is what `idPrefix:` is for, but a measure that +// counts the whole string as a durable selector overstates what a spec can say. +// Scoped to the local name so an Android package prefix cannot trip it, and +// tuned to leave ordinary names like `button2` alone. +func dataCarryingID(identifier string) bool { + local := identifier + if index := strings.LastIndex(local, "/"); index >= 0 { + local = local[index+1:] + } + digits := 0 + for _, character := range local { + if unicode.IsDigit(character) { + digits++ + if digits >= 4 { + return true + } + continue + } + digits = 0 + } + hex, groups := 0, 0 + for _, character := range local + "-" { + if isHexDigit(character) { + hex++ + continue + } + if hex >= 4 { + groups++ + } + hex = 0 + } + return groups >= 2 +} + +func isHexDigit(character rune) bool { + return unicode.IsDigit(character) || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F') +} + +// keypadDigits reports whether this screen shows a numeric keypad, in which case +// its single-character digit labels name fixed keys rather than the first +// character of somebody's name. Without the screen for context the two are +// indistinguishable: an avatar initial and a calculator key are both one +// character, and only one of them survives a data change. +func keypadDigits(elements []element) bool { + seen := map[rune]bool{} + for _, item := range elements { + label := strings.TrimSpace(item.Description) + if label == "" { + label = strings.TrimSpace(item.Text) + } + runes := []rune(label) + if len(runes) == 1 && unicode.IsDigit(runes[0]) { + seen[runes[0]] = true + } + } + return len(seen) >= 6 +} + +func isSingleDigit(label string) bool { + runes := []rune(strings.TrimSpace(label)) + return len(runes) == 1 && unicode.IsDigit(runes[0]) +} + +func measure(elements []element) Counts { + counts := Counts{Elements: len(elements)} + keypad := keypadDigits(elements) + identifiers := map[string]int{} + for _, item := range elements { + if !item.Clickable && !item.Editable { + continue + } + counts.Interactive++ + switch { + case item.ResourceID != "" && !dataCarryingID(item.ResourceID): + counts.ByIdentifier++ + identifiers[item.ResourceID]++ + case item.ResourceID != "": + counts.ByDataID++ + counts.Needing = append(counts.Needing, describe(item)) + case item.Description != "" && keypad && isSingleDigit(item.Description): + counts.ByDescription++ + case item.Description != "" && !volatile(item.Description): + counts.ByDescription++ + case item.Description != "": + counts.ByVolatile++ + counts.Needing = append(counts.Needing, describe(item)) + case item.Text != "": + counts.ByTextOnly++ + counts.Needing = append(counts.Needing, describe(item)) + default: + counts.Unaddressable++ + counts.Needing = append(counts.Needing, describe(item)) + } + } + for _, repeats := range identifiers { + if repeats > 1 { + counts.AmbiguousIDs += repeats + } + } + return counts +} + +func describe(item element) string { + class := item.Class + if class == "" { + class = "(no class)" + } + switch { + case item.ResourceID != "" && dataCarryingID(item.ResourceID): + return fmt.Sprintf( + "%s id=%q (names its record, not its role)", + class, + item.ResourceID, + ) + case item.Description != "": + return fmt.Sprintf( + "%s desc=%q (carries its own data)", + class, + item.Description, + ) + case item.Text != "": + return fmt.Sprintf("%s text=%q", class, item.Text) + default: + return class + " (no label at all)" + } +} + +func signature(elements []element) string { + var builder strings.Builder + for _, item := range elements { + builder.WriteString(item.Class) + builder.WriteByte('|') + builder.WriteString(item.ResourceID) + builder.WriteByte('|') + builder.WriteString(item.Description) + builder.WriteByte(';') + } + return builder.String() +} + +func render(out io.Writer, screens []Counts, traces int, show int) { + fmt.Fprintf(out, "%d trace(s), %d screen(s)\n\n", traces, len(screens)) + fmt.Fprintf( + out, + "%-28s %5s %5s %5s %4s %5s %5s %5s %5s %7s\n", + "screen", + "obs", + "inter", + "id", + "id~", + "desc", + "vol", + "text", + "none", + "stable%", + ) + total := Counts{Screen: "TOTAL"} + for _, screen := range screens { + fmt.Fprintf( + out, + "%-28s %5d %5d %5d %4d %5d %5d %5d %5d %6.1f%%\n", + truncate( + screen.Screen, + 28, + ), + screen.Observations, + screen.Interactive, + screen.ByIdentifier, + screen.ByDataID, + screen.ByDescription, + screen.ByVolatile, + screen.ByTextOnly, + screen.Unaddressable, + screen.stableShare(), + ) + total.Observations += screen.Observations + total.Elements += screen.Elements + total.Interactive += screen.Interactive + total.ByIdentifier += screen.ByIdentifier + total.ByDataID += screen.ByDataID + total.ByDescription += screen.ByDescription + total.ByVolatile += screen.ByVolatile + total.ByTextOnly += screen.ByTextOnly + total.Unaddressable += screen.Unaddressable + total.AmbiguousIDs += screen.AmbiguousIDs + } + fmt.Fprintf(out, "%-28s %5d %5d %5d %4d %5d %5d %5d %5d %6.1f%%\n", + total.Screen, total.Observations, total.Interactive, total.ByIdentifier, + total.ByDataID, total.ByDescription, total.ByVolatile, total.ByTextOnly, + total.Unaddressable, total.stableShare()) + fmt.Fprintf( + out, + "\n%d interactive element(s) carry an identifier, which is the number that survives a data change\n", + total.ByIdentifier, + ) + fmt.Fprintf( + out, + "%d share a resource id with another on the same screen\n", + total.AmbiguousIDs, + ) + if show <= 0 { + return + } + for _, screen := range screens { + if len(screen.Needing) == 0 { + continue + } + fmt.Fprintf( + out, + "\n%s, %d control(s) no durable selector reaches:\n", + screen.Screen, + len(screen.Needing), + ) + for index, item := range screen.Needing { + if index == show { + fmt.Fprintf( + out, + " ... and %d more\n", + len(screen.Needing)-show, + ) + break + } + fmt.Fprintf(out, " %s\n", item) + } + } +} + +func truncate(value string, width int) string { + if len(value) <= width { + return value + } + return value[:width-1] + "~" +} diff --git a/cmd/internal-tools/label-coverage/main_test.go b/cmd/internal-tools/label-coverage/main_test.go new file mode 100644 index 0000000..4559897 --- /dev/null +++ b/cmd/internal-tools/label-coverage/main_test.go @@ -0,0 +1,446 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMeasureRanksSelectorsByDurability(t *testing.T) { + counts := measure([]element{ + { + Class: "Button", + ResourceID: "app:id/save", + Text: "Save", + Clickable: true, + }, + { + Class: "Button", + Description: "Filter", + Text: "Filter", + Clickable: true, + }, + {Class: "View", Text: "Ramesh Kumar", Clickable: true}, + {Class: "View", Clickable: true}, + {Class: "EditText", ResourceID: "app:id/amount", Editable: true}, + {Class: "TextView", Text: "Balance", Clickable: false}, + }) + if counts.Elements != 6 { + t.Fatalf("elements: want 6, got %d", counts.Elements) + } + if counts.Interactive != 5 { + t.Fatalf("interactive: want 5, got %d", counts.Interactive) + } + if counts.ByIdentifier != 2 { + t.Errorf("by identifier: want 2, got %d", counts.ByIdentifier) + } + if counts.ByDescription != 1 { + t.Errorf("by description: want 1, got %d", counts.ByDescription) + } + if counts.ByTextOnly != 1 { + t.Errorf("by text only: want 1, got %d", counts.ByTextOnly) + } + if counts.Unaddressable != 1 { + t.Errorf("unaddressable: want 1, got %d", counts.Unaddressable) + } +} + +// The row description a merged Compose semantics node produces carries the +// balance and a relative age that reprices itself every month. It is present, +// so a presence check scores it as a selector; it matches only the run that +// recorded it. +func TestVolatileDescriptionsAreNotCountedAsStable(t *testing.T) { + row := "Ramesh ji, 95, Pending Collection Since 15 months, Due" + if !volatile(row) { + t.Fatalf("expected %q to be treated as data-carrying", row) + } + counts := measure([]element{ + {Class: "Button", Description: row, Clickable: true}, + {Class: "Button", Description: "Filter", Clickable: true}, + }) + if counts.ByVolatile != 1 { + t.Errorf("volatile: want 1, got %d", counts.ByVolatile) + } + if counts.ByDescription != 1 { + t.Errorf("stable description: want 1, got %d", counts.ByDescription) + } + if got := counts.stableShare(); got != 50 { + t.Fatalf("stable share: want 50, got %.1f", got) + } + if len(counts.Needing) != 1 || + !strings.Contains(counts.Needing[0], "carries its own data") { + t.Fatalf( + "the volatile row belongs on the work list, got %v", + counts.Needing, + ) + } +} + +func TestVolatileLeavesPlainLabelsAlone(t *testing.T) { + for _, label := range []string{"Filter", "Search", "Add Relationship", "Share", "Skip"} { + if volatile(label) { + t.Errorf("%q should count as a durable label", label) + } + } + for _, label := range []string{"₹95", "2 days ago", "Due today", "Pending Since 15 months", "Edited on 11 Jun 2026"} { + if !volatile(label) { + t.Errorf("%q carries data and should not count as durable", label) + } + } +} + +func TestStableShareExcludesDataDependentText(t *testing.T) { + counts := measure([]element{ + {ResourceID: "app:id/add", Clickable: true}, + {Text: "Ramesh Kumar", Clickable: true}, + {Text: "Suresh Patel", Clickable: true}, + {Clickable: true}, + }) + if got := counts.stableShare(); got != 25 { + t.Fatalf("stable share: want 25, got %.1f", got) + } +} + +func TestMeasureCountsRepeatedIdentifiersAsAmbiguous(t *testing.T) { + counts := measure([]element{ + {ResourceID: "app:id/row", Text: "Ramesh", Clickable: true}, + {ResourceID: "app:id/row", Text: "Suresh", Clickable: true}, + {ResourceID: "app:id/add", Clickable: true}, + }) + if counts.AmbiguousIDs != 2 { + t.Fatalf("ambiguous: want 2, got %d", counts.AmbiguousIDs) + } +} + +func TestAccumulateKeepsRichestObservationPerScreen(t *testing.T) { + trace := writeTrace( + t, + `{"step":0,"screen":"ledger","hierarchy":{"elements":[{"resourceId":"app:id/add","clickable":true}]}} +{"step":1,"screen":"ledger","hierarchy":{"elements":[{"resourceId":"app:id/add","clickable":true},{"text":"Ramesh","clickable":true},{"class":"View","clickable":true}]}} +{"step":2,"screen":"home","hierarchy":{"elements":[{"description":"Filter","clickable":true}]}} +`, + ) + byScreen := map[string]*Counts{} + if err := accumulate(trace, byScreen, ""); err != nil { + t.Fatal(err) + } + ledger := byScreen["ledger"] + if ledger.Interactive != 3 { + t.Fatalf("ledger interactive: want 3, got %d", ledger.Interactive) + } + if ledger.Observations != 2 { + t.Fatalf("ledger observations: want 2, got %d", ledger.Observations) + } + if ledger.Unaddressable != 1 { + t.Errorf("ledger unaddressable: want 1, got %d", ledger.Unaddressable) + } + if byScreen["home"].ByDescription != 1 { + t.Errorf( + "home by description: want 1, got %d", + byScreen["home"].ByDescription, + ) + } +} + +// An idling probe observes one screen many times. Counting each observation +// would report how long the explorer sat there rather than what it could reach. +func TestAccumulateCountsIdenticalHierarchiesOnce(t *testing.T) { + line := `{"step":0,"screen":"ledger","hierarchy":{"elements":[{"resourceId":"app:id/add","clickable":true}]}}` + "\n" + byScreen := map[string]*Counts{} + if err := accumulate(writeTrace(t, strings.Repeat(line, 5)), byScreen, ""); err != nil { + t.Fatal(err) + } + if got := byScreen["ledger"].Observations; got != 1 { + t.Fatalf("observations: want 1, got %d", got) + } +} + +func TestAccumulateSkipsStepsWithoutHierarchy(t *testing.T) { + byScreen := map[string]*Counts{} + err := accumulate(writeTrace(t, `{"step":0,"screen":"ledger"} +{"step":1,"screen":"ledger","hierarchy":{"elements":[]}} +`), byScreen, "") + if err != nil { + t.Fatal(err) + } + if len(byScreen) != 0 { + t.Fatalf("want no screens, got %d", len(byScreen)) + } +} + +func TestShapeIgnoresRepetitionButNotComposition(t *testing.T) { + threeRows := shape([]element{ + {ResourceID: "app:id/list"}, + {ResourceID: "app:id/row"}, + {ResourceID: "app:id/row"}, + {ResourceID: "app:id/row"}, + }) + fiveRows := shape([]element{ + {ResourceID: "app:id/list"}, + {ResourceID: "app:id/row"}, + {ResourceID: "app:id/row"}, + {ResourceID: "app:id/row"}, + {ResourceID: "app:id/row"}, + {ResourceID: "app:id/row"}, + }) + if threeRows != fiveRows { + t.Fatalf( + "row count changed the screen identity: %s vs %s", + threeRows, + fiveRows, + ) + } + withDialog := shape([]element{ + {ResourceID: "app:id/list"}, + {ResourceID: "app:id/row"}, + {ResourceID: "app:id/confirm_dialog"}, + }) + if withDialog == threeRows { + t.Fatal( + "a screen showing a dialog must not collapse into the screen behind it", + ) + } +} + +func TestAccumulateSeparatesUnnamedScreensByShape(t *testing.T) { + byScreen := map[string]*Counts{} + err := accumulate( + writeTrace( + t, + `{"step":0,"hierarchy":{"elements":[{"resourceId":"app:id/ledger","clickable":true}]}} +{"step":1,"hierarchy":{"elements":[{"resourceId":"app:id/add_transaction","clickable":true}]}} +`, + ), + byScreen, + "", + ) + if err != nil { + t.Fatal(err) + } + if len(byScreen) != 2 { + t.Fatalf("want 2 screens, got %d", len(byScreen)) + } + for name := range byScreen { + if !strings.HasPrefix(name, "shape:") { + t.Errorf("unnamed screen should be keyed by shape, got %q", name) + } + } +} + +func TestCollectFindsTracesUnderRunDirectories(t *testing.T) { + root := t.TempDir() + for _, run := range []string{"run-1", "run-2"} { + directory := filepath.Join(root, run) + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "trace.jsonl"), []byte("{}\n"), 0o644); err != nil { + t.Fatal(err) + } + } + traces, err := collect([]string{root}) + if err != nil { + t.Fatal(err) + } + if len(traces) != 2 { + t.Fatalf("want 2 traces, got %d: %v", len(traces), traces) + } +} + +func writeTrace(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "trace.jsonl") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// A status bar contributes three well-labelled controls to every capture. An +// app with no identifiers of its own scores 100 percent if they are counted. +func TestAccumulateDropsSystemUIByDefault(t *testing.T) { + trace := writeTrace( + t, + `{"step":0,"screen":"ledger","hierarchy":{"elements":[ +{"resourceId":"com.android.systemui:id/clock","package":"com.android.systemui","clickable":true}, +{"resourceId":"com.android.systemui:id/battery","package":"com.android.systemui","clickable":true}, +{"class":"android.view.View","package":"in.okcredit.merchant.debug","clickable":true}]}} +`, + ) + byScreen := map[string]*Counts{} + if err := accumulate(trace, byScreen, ""); err != nil { + t.Fatal(err) + } + counts := byScreen["ledger"] + if counts.Interactive != 1 { + t.Fatalf( + "interactive: want 1 after dropping system UI, got %d", + counts.Interactive, + ) + } + if counts.ByIdentifier != 0 { + t.Fatalf( + "system identifiers leaked into the app's score: %d", + counts.ByIdentifier, + ) + } + if got := counts.stableShare(); got != 0 { + t.Fatalf("stable share: want 0, got %.1f", got) + } +} + +func TestAccumulatePackageFlagPinsExactly(t *testing.T) { + trace := writeTrace( + t, + `{"step":0,"screen":"ledger","hierarchy":{"elements":[ +{"resourceId":"other:id/x","package":"com.other.app","clickable":true}, +{"class":"android.view.View","package":"in.okcredit.merchant.debug","clickable":true}]}} +`, + ) + byScreen := map[string]*Counts{} + if err := accumulate(trace, byScreen, "in.okcredit.merchant.debug"); err != nil { + t.Fatal(err) + } + if got := byScreen["ledger"].Interactive; got != 1 { + t.Fatalf("interactive: want 1, got %d", got) + } +} + +// Only window roots carry a package attribute in a hierarchy dump. Treating an +// unstamped node as foreign discards the application and reports a clean zero. +func TestScopedKeepsUnstampedNodes(t *testing.T) { + elements := []element{ + { + Package: "com.android.systemui", + ResourceID: "sysui:id/clock", + Clickable: true, + }, + {Package: "in.okcredit.merchant.debug", ResourceID: "app:id/root"}, + {Class: "android.view.View", Clickable: true}, + } + if got := len(scoped(elements, "")); got != 2 { + t.Fatalf("denylist mode: want 2 kept, got %d", got) + } + if got := len(scoped(elements, "in.okcredit.merchant.debug")); got != 2 { + t.Fatalf("pinned mode: want 2 kept, got %d", got) + } + if got := len(scoped(elements, "com.other.app")); got != 1 { + t.Fatalf( + "pinned to a foreign package: want 1 unstamped node kept, got %d", + got, + ) + } +} + +// An avatar renders the first letter of the name beside it, so a one-character +// description is the name in disguise. Four of these were scored as durable on +// a real ledger before the rule existed. +func TestSingleCharacterDescriptionsAreAvatarInitials(t *testing.T) { + for _, initial := range []string{"R", "C", "T", "A", "9", " R "} { + if !volatile(initial) { + t.Errorf( + "%q is an avatar initial and changes when the record is renamed", + initial, + ) + } + } + for _, keypad := range []string{"+", "=", "AC"} { + if volatile(keypad) { + t.Errorf( + "%q is a fixed control label and should count as durable", + keypad, + ) + } + } +} + +// A row tagged with its record's uuid names its role durably and its instance +// not at all, so an exact match on it survives exactly one run. +func TestDataCarryingIdentifiers(t *testing.T) { + for _, identifier := range []string{ + "customer_row_5f338c10-feef-411c-a070-8999b4890a62", + "in.okcredit.merchant.debug:id/customer_row_5f338c10-feef-411c-a070-8999b4890a62", + "txn_20260812", + } { + if !dataCarryingID(identifier) { + t.Errorf("%q embeds the record it names", identifier) + } + } + for _, identifier := range []string{ + "customer_supplier_list", + "summary_card", + "in.okcredit.merchant.debug:id/buttonLogin", + "button2", + "add_relationship", + } { + if dataCarryingID(identifier) { + t.Errorf("%q names a role and should count as durable", identifier) + } + } +} + +func TestMeasureSeparatesDataCarryingIdentifiers(t *testing.T) { + counts := measure([]element{ + {Class: "View", ResourceID: "summary_card", Clickable: true}, + { + Class: "View", + ResourceID: "customer_row_5f338c10-feef-411c-a070-8999b4890a62", + Clickable: true, + }, + { + Class: "View", + ResourceID: "customer_row_8ea26690-f809-4d23-8560-9cca6d1a5bcd", + Clickable: true, + }, + }) + if counts.ByIdentifier != 1 { + t.Errorf("durable identifiers: want 1, got %d", counts.ByIdentifier) + } + if counts.ByDataID != 2 { + t.Errorf("data-carrying identifiers: want 2, got %d", counts.ByDataID) + } + if got := counts.stableShare(); got < 33 || got > 34 { + t.Errorf("stable share: want about 33.3, got %.1f", got) + } + if len(counts.Needing) != 2 { + t.Fatalf( + "both row identifiers belong on the work list, got %v", + counts.Needing, + ) + } +} + +// A calculator key and an avatar initial are both one character. Only the +// screen they sit on tells them apart, so the keypad rule needs that context. +func TestKeypadDigitsAreDurableButAvatarInitialsAreNot(t *testing.T) { + keypad := make([]element, 0, 10) + for _, key := range []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "0"} { + keypad = append( + keypad, + element{Class: "Button", Description: key, Clickable: true}, + ) + } + counts := measure(keypad) + if counts.ByDescription != 10 { + t.Fatalf( + "keypad keys are fixed labels: want 10 durable, got %d", + counts.ByDescription, + ) + } + + counts = measure([]element{ + {Class: "Button", Description: "R", Clickable: true}, + {Class: "Button", Description: "9", Clickable: true}, + {Class: "Button", Description: "Filter", Clickable: true}, + }) + if counts.ByVolatile != 2 { + t.Fatalf( + "avatar initials carry data: want 2 volatile, got %d", + counts.ByVolatile, + ) + } + if counts.ByDescription != 1 { + t.Fatalf("durable descriptions: want 1, got %d", counts.ByDescription) + } +}