mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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.
This commit is contained in:
1 parent
b47509a8e8
commit
0d1b2cf4e8
2 files changed
+995
No files matched your search
@@ -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] <trace.jsonl | run directory> ...",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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_<uuid>` 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] + "~"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user