Files
artemis_control/internal/ui/change.go
Sam Rolfe a7c07c645e feat: Phase 5 — maps_navigate, and read the screen before the input
The Maps recipe: open the search box, type the place, and confirm the
result list names it. Every label comes from a live uiautomator dump
(search_omnibox_text_box, typed_suggest_container, the account sheet),
never from a guess. Text only, one step_timeout/total_timeout, guards for
the account sheet.

The first live run exposed an ordering bug in expect_change. A dump is not
instant, so a baseline taken after the input already showed the app's
response and the check could never see a wedge; it failed against a
healthy Maps. The engine now reads the screen before the step above runs
and hands that signature to the check. Each retry re-reads it.

Live: maps_navigate ok in 27.6s, result list names "Melbourne Museum".
The wedge reason and its bounded retry are demonstrated with a probe
recipe whose input changes nothing; a real wedge could not be forced, and
docs/phase-5-maps-recipe.md says so plainly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-16 16:18:06 +11:00

84 lines
2.7 KiB
Go

package ui
import (
"fmt"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/artemis_control/internal/adb"
)
// ScreenSignature returns a fingerprint of what is on screen: the foreground
// package plus every visible label and the bounds it sits in.
//
// Two dumps of an unchanged screen produce the same signature, and any change a
// human would see — new text, different text, an element moved or a different
// app in front — produces a different one. It is the raw material for wedge
// detection: if an input is delivered and the signature never moves, the app
// never acted on that input.
//
// The foreground package is best effort. If it cannot be read the labels alone
// still make a usable fingerprint, because a screen that stops changing is
// exactly the failure this guards against.
func ScreenSignature(r *adb.Runner) (string, error) {
h, err := Dump(r)
if err != nil {
return "", err
}
fg, _ := ForegroundPackage(r)
return h.Signature(fg), nil
}
// Signature is the pure half of ScreenSignature: a stable fingerprint of one
// parsed dump. Labels are sorted so that the order uiautomator happened to
// report them in never looks like a change, and bounds are included so that an
// element moving is a change even when the words stay the same.
func (h *Hierarchy) Signature(foreground string) string {
parts := make([]string, 0, len(h.TextNodes()))
h.Walk(func(n *Node) {
label := strings.TrimSpace(n.Text)
if label == "" {
label = strings.TrimSpace(n.ContentDesc)
}
if label == "" {
return
}
parts = append(parts, strings.TrimSpace(n.Bounds)+"|"+label)
})
sort.Strings(parts)
return foreground + "\n" + strings.Join(parts, "\n")
}
// WaitForChange waits until the screen no longer matches before, and returns
// the new signature.
//
// The caller captures before with ScreenSignature *before* the input it wants to
// judge. That ordering is the whole check: an app that has stopped consuming
// input still draws the screen it had, so the signature after the input is
// identical to the one before it. A dump is not instant, so a baseline taken
// after the input would already include the app's response and could never see
// the wedge.
func WaitForChange(r *adb.Runner, before string, timeout time.Duration) (string, error) {
if strings.TrimSpace(before) == "" {
return "", fmt.Errorf("no screen signature was captured before the input")
}
if timeout <= 0 {
timeout = 10 * time.Second
}
deadline := time.Now().Add(timeout)
for {
sig, err := ScreenSignature(r)
if err != nil {
return "", err
}
if sig != before {
return sig, nil
}
if time.Now().After(deadline) {
return before, fmt.Errorf("the screen did not change within %s", timeout)
}
time.Sleep(PollInterval)
}
}