mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-05 04:27:09 +00:00
refactor(spec): one candidate producer over one target-eligibility rule
Both hosts routed verbs themselves and both policies enumerated their own actions, and all four drifted. Web sent `swipes` to scrollable containers only, so swipe-to-dismiss on a list row was reachable on native and unreachable on web; the model policy folded gestures its own way and could not reach what the seeded picker drew. A host now reports facts about every element and never decides which verb may act on it: targets.ts acceptsTarget owns that for both. pick.ts builtinCandidates is the single enumeration, and the model policy reads it through __sanderlingEnumerateBuiltin__ instead of reimplementing it in Go. Gesture verbs change with it: scrolls stay vertical over scrollable containers, swipes go free-form in all four directions from any element with real bounds. Claude-Session: https://claude.ai/code/session_01Fj4wJUikdABuMQEETwW55J
This commit is contained in:
1 parent
ef04a6de9d
commit
b5f64bf665
23 files changed
+1464
-564
No files matched your search
@@ -91,7 +91,7 @@ func (v *Verifier) installRuntimeBindings() error {
|
|||||||
|
|
||||||
// installHost exposes globalThis.__sanderlingHost__ for the goja runtime entry.
|
// installHost exposes globalThis.__sanderlingHost__ for the goja runtime entry.
|
||||||
// The shared picker (pick.ts) draws against it: platform() drives the verb
|
// The shared picker (pick.ts) draws against it: platform() drives the verb
|
||||||
// matrix and press-key pool; seedHi/seedLo construct its Pcg; queryCandidates
|
// matrix and press-key pool; seedHi/seedLo construct its Pcg; queryTargets
|
||||||
// enumerates targets over the hierarchy tree; reportUnsupported records the
|
// enumerates targets over the hierarchy tree; reportUnsupported records the
|
||||||
// verb for the run report.
|
// verb for the run report.
|
||||||
func (v *Verifier) installHost() error {
|
func (v *Verifier) installHost() error {
|
||||||
@@ -111,7 +111,7 @@ func (v *Verifier) installHost() error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := host.Set("queryCandidates", v.bindQueryCandidates); err != nil {
|
if err := host.Set("queryTargets", v.bindQueryTargets); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := host.Set("reportUnsupported", func(call goja.FunctionCall) goja.Value {
|
if err := host.Set("reportUnsupported", func(call goja.FunctionCall) goja.Value {
|
||||||
@@ -133,20 +133,25 @@ func (v *Verifier) recordUnsupported(verb string) {
|
|||||||
v.unsupported = append(v.unsupported, verb)
|
v.unsupported = append(v.unsupported, verb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// bindQueryCandidates returns the host-enumerated targets for a verb as an
|
// bindQueryTargets returns every host-enumerated target as an array of
|
||||||
// array of {x, y, selector, width, height}, in tree order.
|
// {x, y, selector, width, height, clickable, enabled, editable, scrollable}, in
|
||||||
func (v *Verifier) bindQueryCandidates(call goja.FunctionCall) goja.Value {
|
// tree order. It takes no verb: the shared rule in targets.ts decides which of
|
||||||
verb := call.Argument(0).String()
|
// these a verb may act on.
|
||||||
candidates := v.candidatesForVerb(verb)
|
func (v *Verifier) bindQueryTargets(goja.FunctionCall) goja.Value {
|
||||||
|
targets := v.targets()
|
||||||
array := v.runtime.NewArray()
|
array := v.runtime.NewArray()
|
||||||
for index, candidate := range candidates {
|
for index, target := range targets {
|
||||||
item := v.runtime.NewObject()
|
item := v.runtime.NewObject()
|
||||||
_ = item.Set("x", candidate.x)
|
_ = item.Set("x", target.x)
|
||||||
_ = item.Set("y", candidate.y)
|
_ = item.Set("y", target.y)
|
||||||
_ = item.Set("width", candidate.width)
|
_ = item.Set("width", target.width)
|
||||||
_ = item.Set("height", candidate.height)
|
_ = item.Set("height", target.height)
|
||||||
if candidate.selector != "" {
|
_ = item.Set("clickable", target.clickable)
|
||||||
_ = item.Set("selector", candidate.selector)
|
_ = item.Set("enabled", target.enabled)
|
||||||
|
_ = item.Set("editable", target.editable)
|
||||||
|
_ = item.Set("scrollable", target.scrollable)
|
||||||
|
if target.selector != "" {
|
||||||
|
_ = item.Set("selector", target.selector)
|
||||||
}
|
}
|
||||||
_ = array.Set(fmt.Sprintf("%d", index), item)
|
_ = array.Set(fmt.Sprintf("%d", index), item)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hostParityScreen is the canonical screen both hosts are driven over: one row
|
||||||
|
// per fact combination that any verb distinguishes. Each host builds it in its
|
||||||
|
// own model (this file as a hierarchy tree, pkg/spec/test/host-parity.test.ts as
|
||||||
|
// a DOM), enumerates every verb, and asserts the SAME committed golden.
|
||||||
|
var hostParityScreen = []struct {
|
||||||
|
name string
|
||||||
|
clickable bool
|
||||||
|
enabled bool
|
||||||
|
editable bool
|
||||||
|
scrollable bool
|
||||||
|
positiveBounds bool
|
||||||
|
}{
|
||||||
|
{name: "root", enabled: true, scrollable: true, positiveBounds: true},
|
||||||
|
{name: "save", clickable: true, enabled: true, positiveBounds: true},
|
||||||
|
{name: "cancel", clickable: true, positiveBounds: true},
|
||||||
|
{name: "amount", enabled: true, editable: true, positiveBounds: true},
|
||||||
|
{name: "list", enabled: true, scrollable: true, positiveBounds: true},
|
||||||
|
{name: "row", enabled: true, positiveBounds: true},
|
||||||
|
{name: "collapsed", clickable: true, enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostParityTreeJSON is hostParityScreen as the native host sees it. Tree order
|
||||||
|
// is pre-order, so it matches hostParityScreen index for index.
|
||||||
|
const hostParityTreeJSON = `{
|
||||||
|
"attributes": {"resource-id": "root", "scrollable": "true", "bounds": "[0,0,400,800]"},
|
||||||
|
"enabled": true,
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"resource-id": "save", "bounds": "[0,0,200,60]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "cancel", "bounds": "[200,0,400,60]"}, "clickable": true, "enabled": false, "children": []},
|
||||||
|
{"attributes": {"resource-id": "amount", "bounds": "[0,100,400,160]"}, "editable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "list", "scrollable": "true", "bounds": "[0,200,400,600]"}, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "row", "bounds": "[0,600,400,680]"}, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "collapsed", "bounds": "[0,0,0,0]"}, "clickable": true, "enabled": true, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// TestHostsAgreeOnTargetEligibility is the guard on the claim that one
|
||||||
|
// specification induces one action space on every platform. The native host and
|
||||||
|
// the web host used to route verbs themselves and had drifted: web sent
|
||||||
|
// `swipes` to scrollable containers only, so a swipe on a list row was
|
||||||
|
// reachable on Android and unreachable on web for the same spec.
|
||||||
|
//
|
||||||
|
// Per-verb eligibility now has ONE definition (pkg/spec/src/targets.ts); a host
|
||||||
|
// reports facts and never filters. This test is what notices if a second
|
||||||
|
// definition grows back on either side: both hosts enumerate the same canonical
|
||||||
|
// screen and must name the same targets, verb for verb, in the same order.
|
||||||
|
// pkg/spec/test/host-parity.test.ts asserts the SAME golden from the web host,
|
||||||
|
// so a match on both sides proves the two hosts agree without either invoking
|
||||||
|
// the other.
|
||||||
|
func TestHostsAgreeOnTargetEligibility(t *testing.T) {
|
||||||
|
golden := loadHostParityGolden(t)
|
||||||
|
for _, verb := range policyVerbs {
|
||||||
|
t.Run(verb, func(t *testing.T) {
|
||||||
|
want, ok := golden[verb]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("golden has no entry for %s", verb)
|
||||||
|
}
|
||||||
|
verifier := newVerifier(t, WithSeed(0x5eed))
|
||||||
|
loadActionSpec(t, verifier, fmt.Sprintf(
|
||||||
|
"import { %s } from \"@sanderling/spec\";\nglobalThis.actions = %s;", verb, verb))
|
||||||
|
pushTree(t, verifier, hostParityTreeJSON)
|
||||||
|
|
||||||
|
entries, err := verifier.enumerateBuiltin(verb)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("enumerate %s: %v", verb, err)
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatalf("%s enumerated nothing at all", verb)
|
||||||
|
}
|
||||||
|
got := []string{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.targetIndex < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry.targetIndex >= len(hostParityScreen) {
|
||||||
|
t.Fatalf("%s produced target index %d, off the %d-row screen",
|
||||||
|
verb, entry.targetIndex, len(hostParityScreen))
|
||||||
|
}
|
||||||
|
got = append(got, hostParityScreen[entry.targetIndex].name)
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Errorf("native host targets for %s\n got=%v\nwant=%v", verb, got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadHostParityGolden(t *testing.T) map[string][]string {
|
||||||
|
t.Helper()
|
||||||
|
path, err := filepath.Abs("../../pkg/spec/test/fixtures/host-parity-golden.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read golden: %v", err)
|
||||||
|
}
|
||||||
|
golden := map[string][]string{}
|
||||||
|
if err := json.Unmarshal(body, &golden); err != nil {
|
||||||
|
t.Fatalf("decode golden: %v", err)
|
||||||
|
}
|
||||||
|
return golden
|
||||||
|
}
|
||||||
+98
-119
@@ -1,6 +1,7 @@
|
|||||||
package verifier
|
package verifier
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -130,54 +131,21 @@ type ActionCandidate struct {
|
|||||||
prob float64
|
prob float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// verbActionKind maps a picker verb to the action kind it dispatches.
|
|
||||||
func verbActionKind(verb string) ActionKind {
|
|
||||||
switch verb {
|
|
||||||
case "taps":
|
|
||||||
return ActionKindTap
|
|
||||||
case "doubleTaps":
|
|
||||||
return ActionKindDoubleTap
|
|
||||||
case "longPresses":
|
|
||||||
return ActionKindLongPress
|
|
||||||
case "typing":
|
|
||||||
return ActionKindInputText
|
|
||||||
case "scrolls":
|
|
||||||
return ActionKindScroll
|
|
||||||
case "swipes":
|
|
||||||
return ActionKindSwipe
|
|
||||||
default:
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// maxLabelRunes caps a visible-text label so joined descendant text stays short
|
// maxLabelRunes caps a visible-text label so joined descendant text stays short
|
||||||
// enough to render on one numbered line.
|
// enough to render on one numbered line.
|
||||||
const maxLabelRunes = 40
|
const maxLabelRunes = 40
|
||||||
|
|
||||||
// gestureDirections are the directional scrolls emitted per scrollable
|
|
||||||
// container. Vertical only: most mobile lists scroll up/down, and keeping the
|
|
||||||
// set tiny is the whole point of folding per-element swipes away.
|
|
||||||
var gestureDirections = []string{"down", "up"}
|
|
||||||
|
|
||||||
// Candidates enumerates every action the spec's weighted actionsRoot yields at
|
// Candidates enumerates every action the spec's weighted actionsRoot yields at
|
||||||
// the current step, each tagged with a plainly-worded description and its
|
// the current step, each tagged with a plainly-worded description and its
|
||||||
// effective weight, for the LLM generator to pick one number from. It walks the
|
// effective weight, for the LLM generator to pick one number from. It walks the
|
||||||
// SAME tree the seeded picker draws: weighted branches recurse (accumulating the
|
// SAME tree the seeded picker draws: weighted branches recurse (accumulating the
|
||||||
// selection probability), authored actions()/whenRoute leaves are called once
|
// selection probability), authored actions()/whenRoute leaves are called once
|
||||||
// for their concrete actions, and builtin verbs enumerate per applicable
|
// for their concrete actions, and builtin verbs come straight from the picker's
|
||||||
// element. Disabled controls are dropped, per-element gestures fold into a few
|
// own enumeration. Identical descriptions dedup, summing weight.
|
||||||
// directional scrolls over scrollable containers, and identical descriptions
|
|
||||||
// dedup (summing weight).
|
|
||||||
func (v *Verifier) Candidates() []ActionCandidate {
|
func (v *Verifier) Candidates() []ActionCandidate {
|
||||||
if v.lastTree == nil {
|
if v.lastTree == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// A cross-fade frame's layout is mid-animation, often in a collapsed
|
|
||||||
// coordinate space, so acting on it taps garbage (e.g. the soft keyboard).
|
|
||||||
// Skip it so the LLM re-observes a settled frame next step.
|
|
||||||
if v.lastTree.Transitional() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
root := v.runtime.GlobalObject().Get("actions")
|
root := v.runtime.GlobalObject().Get("actions")
|
||||||
if root == nil || goja.IsUndefined(root) || goja.IsNull(root) {
|
if root == nil || goja.IsUndefined(root) || goja.IsNull(root) {
|
||||||
return nil
|
return nil
|
||||||
@@ -333,6 +301,18 @@ func (v *Verifier) candidateFromDescriptor(value goja.Value, nodeIndex map[*hier
|
|||||||
Direction: direction,
|
Direction: direction,
|
||||||
Action: Action{Kind: kind, On: target.selector, Direction: direction},
|
Action: Action{Kind: kind, On: target.selector, Direction: direction},
|
||||||
}, true
|
}, true
|
||||||
|
case ActionKindSwipe:
|
||||||
|
from := v.resolveTarget(object.Get("from"), nodeIndex)
|
||||||
|
to := v.resolveTarget(object.Get("to"), nodeIndex)
|
||||||
|
return ActionCandidate{
|
||||||
|
Kind: kind,
|
||||||
|
Action: Action{
|
||||||
|
Kind: kind,
|
||||||
|
FromX: from.x, FromY: from.y,
|
||||||
|
ToX: to.x, ToY: to.y,
|
||||||
|
DurationMillis: intField(object, "durationMillis"),
|
||||||
|
},
|
||||||
|
}, true
|
||||||
case ActionKindPressKey:
|
case ActionKindPressKey:
|
||||||
return ActionCandidate{
|
return ActionCandidate{
|
||||||
Kind: kind,
|
Kind: kind,
|
||||||
@@ -410,103 +390,80 @@ func (v *Verifier) findBySelector(selector string) *hierarchy.Element {
|
|||||||
return v.lastTree.Find(selector)
|
return v.lastTree.Find(selector)
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectBuiltin enumerates a builtin verb over the current tree: tap-family and
|
// collectBuiltin turns the picker's own enumeration of a builtin verb into
|
||||||
// typing emit one candidate per applicable element; scrolls/swipes fold into
|
// candidates. The list comes from the bundle's __sanderlingEnumerateBuiltin__
|
||||||
// directional gestures over scrollable containers.
|
// (pick.ts builtinCandidates), which is what the seeded policy draws from, so
|
||||||
|
// the two policies select over one action space and cannot drift apart. Each
|
||||||
|
// entry's action arrives on the wire contract DecodeAction already reads, so a
|
||||||
|
// chosen candidate executes the action the seeded draw would have executed.
|
||||||
func (v *Verifier) collectBuiltin(verb string, prob float64, weighted bool, nodeIndex map[*hierarchy.Element]*hierarchy.Node, out *[]ActionCandidate) {
|
func (v *Verifier) collectBuiltin(verb string, prob float64, weighted bool, nodeIndex map[*hierarchy.Element]*hierarchy.Node, out *[]ActionCandidate) {
|
||||||
switch verb {
|
entries, err := v.enumerateBuiltin(verb)
|
||||||
case "taps", "doubleTaps", "longPresses":
|
if err != nil {
|
||||||
kind := verbActionKind(verb)
|
return
|
||||||
for _, element := range v.elementsForVerb(verb) {
|
}
|
||||||
x, y := element.Bounds.Center()
|
targets := v.targets()
|
||||||
*out = append(*out, ActionCandidate{
|
for _, entry := range entries {
|
||||||
Kind: kind,
|
candidate := ActionCandidate{
|
||||||
Label: visibleLabel(element, nodeIndex),
|
Kind: entry.action.Kind,
|
||||||
Action: Action{Kind: kind, On: selectorForElement(v.lastTree, element), X: x, Y: y},
|
Direction: entry.action.Direction,
|
||||||
|
// Builtin typing enumerates the field, not the value: the seeded
|
||||||
|
// policy draws its text from the corpus and the model writes its own.
|
||||||
|
LLMText: entry.action.Kind == ActionKindInputText,
|
||||||
|
Action: entry.action,
|
||||||
prob: prob,
|
prob: prob,
|
||||||
Weighted: weighted,
|
Weighted: weighted,
|
||||||
})
|
|
||||||
}
|
}
|
||||||
case "typing":
|
if entry.targetIndex >= 0 && entry.targetIndex < len(targets) {
|
||||||
for _, element := range v.elementsForVerb(verb) {
|
element := targets[entry.targetIndex].element
|
||||||
x, y := element.Bounds.Center()
|
candidate.Label = visibleLabel(element, nodeIndex)
|
||||||
*out = append(*out, ActionCandidate{
|
candidate.InputType = inputTypeHint(element)
|
||||||
Kind: ActionKindInputText,
|
|
||||||
Label: visibleLabel(element, nodeIndex),
|
|
||||||
InputType: inputTypeHint(element),
|
|
||||||
LLMText: true,
|
|
||||||
Action: Action{Kind: ActionKindInputText, On: selectorForElement(v.lastTree, element), X: x, Y: y},
|
|
||||||
prob: prob,
|
|
||||||
Weighted: weighted,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
case "scrolls", "swipes":
|
*out = append(*out, candidate)
|
||||||
v.collectGestures(prob, weighted, out)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectGestures emits directional scrolls scoped to each scrollable container,
|
// builtinCandidate is one entry of the shared builtin enumeration: the concrete
|
||||||
// never per element and never element-labeled. Folding both scrolls and swipes
|
// action, plus the index of the host candidate it targets (-1 when the verb has
|
||||||
// here is what removes the flood of mislabeled `Swipe "X"` gestures.
|
// no target, as for a key press or a wait).
|
||||||
func (v *Verifier) collectGestures(prob float64, weighted bool, out *[]ActionCandidate) {
|
type builtinCandidate struct {
|
||||||
scope := v.scopedElements()
|
action Action
|
||||||
for _, element := range v.lastTree.Elements {
|
targetIndex int
|
||||||
if !scope[element] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if element.Attributes["scrollable"] != "true" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if element.Bounds.Width() <= 0 || element.Bounds.Height() <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
selector := selectorForElement(v.lastTree, element)
|
|
||||||
for _, direction := range gestureDirections {
|
|
||||||
action := Action{Kind: ActionKindScroll, On: selector, Direction: direction}
|
|
||||||
if selector == "" {
|
|
||||||
action.FromX, action.FromY, action.ToX, action.ToY = scrollGeometry(element.Bounds, direction)
|
|
||||||
}
|
|
||||||
*out = append(*out, ActionCandidate{
|
|
||||||
Kind: ActionKindScroll,
|
|
||||||
Direction: direction,
|
|
||||||
Action: action,
|
|
||||||
prob: prob,
|
|
||||||
Weighted: weighted,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// scrollGeometry lowers a directional scroll to swipe endpoints over the given
|
// enumerateBuiltin invokes the bundle's shared enumeration for one verb. A spec
|
||||||
// container bounds, matching the runner's own derivation, used only when the
|
// loaded without the runtime entry (a raw-JS unit fixture) has no enumerator, so
|
||||||
// container has no resolving selector.
|
// the verb contributes nothing rather than falling back to a second enumeration.
|
||||||
func scrollGeometry(bounds hierarchy.Bounds, direction string) (fromX, fromY, toX, toY int) {
|
func (v *Verifier) enumerateBuiltin(verb string) ([]builtinCandidate, error) {
|
||||||
cx, cy := bounds.Center()
|
if v.enumerateBuiltinFn == nil {
|
||||||
fromX, fromY, toX, toY = cx, cy, cx, cy
|
return nil, errors.New("verifier: builtin enumeration not available")
|
||||||
switch direction {
|
|
||||||
case "down":
|
|
||||||
toY = cy - 4*bounds.Height()/10
|
|
||||||
case "up":
|
|
||||||
toY = cy + 4*bounds.Height()/10
|
|
||||||
case "left":
|
|
||||||
toX = cx + 4*bounds.Width()/10
|
|
||||||
case "right":
|
|
||||||
toX = cx - 4*bounds.Width()/10
|
|
||||||
}
|
}
|
||||||
return fromX, fromY, max(0, toX), max(0, toY)
|
value, err := v.enumerateBuiltinFn(goja.Undefined(), v.runtime.ToValue(verb))
|
||||||
}
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("enumerate %s: %w", verb, err)
|
||||||
// elementsForVerb returns the in-scope elements a builtin verb applies to, in
|
|
||||||
// tree order (the seeded picker's enumeration order), reusing verbAccepts.
|
|
||||||
func (v *Verifier) elementsForVerb(verb string) []*hierarchy.Element {
|
|
||||||
scope := v.scopedElements()
|
|
||||||
var elements []*hierarchy.Element
|
|
||||||
for _, element := range v.lastTree.Elements {
|
|
||||||
if scope[element] && verbAccepts(verb, element) {
|
|
||||||
elements = append(elements, element)
|
|
||||||
}
|
}
|
||||||
|
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
return elements
|
raw, err := json.Marshal(value.Export())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal %s enumeration: %w", verb, err)
|
||||||
|
}
|
||||||
|
var wire []struct {
|
||||||
|
Action json.RawMessage `json:"action"`
|
||||||
|
TargetIndex int `json:"targetIndex"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &wire); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode %s enumeration: %w", verb, err)
|
||||||
|
}
|
||||||
|
entries := make([]builtinCandidate, 0, len(wire))
|
||||||
|
for _, item := range wire {
|
||||||
|
action, err := DecodeAction(item.Action)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entries = append(entries, builtinCandidate{action: action, targetIndex: item.TargetIndex})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalizeCandidates renders each candidate's description, dedups identical
|
// finalizeCandidates renders each candidate's description, dedups identical
|
||||||
@@ -555,6 +512,18 @@ func describeCandidate(candidate ActionCandidate) string {
|
|||||||
return fmt.Sprintf("Type %q into %q", candidate.Action.Text, candidate.Label)
|
return fmt.Sprintf("Type %q into %q", candidate.Action.Text, candidate.Label)
|
||||||
case ActionKindScroll:
|
case ActionKindScroll:
|
||||||
return "Scroll " + candidate.Direction
|
return "Scroll " + candidate.Direction
|
||||||
|
case ActionKindSwipe:
|
||||||
|
// A swipe carries endpoints and no selector, so the coordinates are what
|
||||||
|
// keep two swipes distinct. The label is prepended when the origin
|
||||||
|
// element has one, because "swipe that row" is the interaction a model
|
||||||
|
// reaches for and a bare pair of points does not say which row.
|
||||||
|
where := fmt.Sprintf("from (%d,%d) to (%d,%d)",
|
||||||
|
candidate.Action.FromX, candidate.Action.FromY,
|
||||||
|
candidate.Action.ToX, candidate.Action.ToY)
|
||||||
|
if candidate.Label == "" {
|
||||||
|
return "Swipe " + where
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Swipe %q %s", candidate.Label, where)
|
||||||
case ActionKindPressKey:
|
case ActionKindPressKey:
|
||||||
return "Press " + candidate.Action.Key
|
return "Press " + candidate.Action.Key
|
||||||
case ActionKindWait:
|
case ActionKindWait:
|
||||||
@@ -682,3 +651,13 @@ func stringField(object *goja.Object, key string) string {
|
|||||||
}
|
}
|
||||||
return value.String()
|
return value.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// intField reads a numeric property off a goja object, returning 0 when absent,
|
||||||
|
// null, or undefined.
|
||||||
|
func intField(object *goja.Object, key string) int {
|
||||||
|
value := object.Get(key)
|
||||||
|
if value == nil || goja.IsUndefined(value) || goja.IsNull(value) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(value.ToInteger())
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
// enumTreeJSON exercises every labeling path: a clickable wrapper whose own text
|
// enumTreeJSON exercises every labeling path: a clickable wrapper whose own text
|
||||||
// is empty but whose child Text reads "Add credit" (descendant borrowing), an
|
// is empty but whose child Text reads "Add credit" (descendant borrowing), an
|
||||||
// editable field labeled by its hint, a text-labeled button, a DISABLED button,
|
// editable field labeled by its hint, a text-labeled button, a DISABLED button,
|
||||||
// and a scrollable list (the only valid gesture origin).
|
// and a scrollable list (the only valid scroll origin).
|
||||||
const enumTreeJSON = `{
|
const enumTreeJSON = `{
|
||||||
"attributes": {"bounds": "[0,0,1080,2400]"},
|
"attributes": {"bounds": "[0,0,1080,2400]"},
|
||||||
"children": [
|
"children": [
|
||||||
@@ -25,10 +25,13 @@ const enumTreeJSON = `{
|
|||||||
}`
|
}`
|
||||||
|
|
||||||
// enumVerifier loads a spec whose actions root is the given plain-object graph
|
// enumVerifier loads a spec whose actions root is the given plain-object graph
|
||||||
// and stages the given tree, so Candidates walks a controlled action tree.
|
// and stages the given tree, so Candidates walks a controlled action tree. The
|
||||||
|
// spec is bundled with the goja runtime entry because the model arm reads the
|
||||||
|
// picker's own builtin enumeration out of that bundle.
|
||||||
func enumVerifier(t *testing.T, actionsJS, treeJSON string) *Verifier {
|
func enumVerifier(t *testing.T, actionsJS, treeJSON string) *Verifier {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
v := newLoadedVerifier(t, "globalThis.actions = "+actionsJS+";")
|
v := newVerifier(t)
|
||||||
|
loadActionSpec(t, v, "globalThis.actions = "+actionsJS+";")
|
||||||
tree, err := hierarchy.Parse(treeJSON)
|
tree, err := hierarchy.Parse(treeJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("parse tree: %v", err)
|
t.Fatalf("parse tree: %v", err)
|
||||||
@@ -119,26 +122,38 @@ func TestCandidatesLabelsEditableFieldByHintNotTypedValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCandidatesFoldsGesturesIntoDirectionalScrolls(t *testing.T) {
|
func TestCandidatesKeepsGestureVerbsDistinct(t *testing.T) {
|
||||||
v := enumVerifier(t,
|
v := enumVerifier(t,
|
||||||
"{kind:'weighted', branches:[[1,{kind:'builtin',verb:'scrolls'}],[1,{kind:'builtin',verb:'swipes'}]]}",
|
"{kind:'weighted', branches:[[1,{kind:'builtin',verb:'scrolls'}],[1,{kind:'builtin',verb:'swipes'}]]}",
|
||||||
enumTreeJSON)
|
enumTreeJSON)
|
||||||
candidates := v.Candidates()
|
candidates := v.Candidates()
|
||||||
|
|
||||||
// Gestures are directional and scoped to the one scrollable container: no
|
// `scrolls` folds to one directional pair over the single scrollable
|
||||||
// per-element, element-labeled Swipe entries.
|
// container, which is what keeps the list short.
|
||||||
for _, candidate := range candidates {
|
|
||||||
if strings.HasPrefix(candidate.Description, "Swipe") {
|
|
||||||
t.Errorf("gesture kept as element-labeled swipe: %q", candidate.Description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasCandidate(candidates, "Scroll down") || !hasCandidate(candidates, "Scroll up") {
|
if !hasCandidate(candidates, "Scroll down") || !hasCandidate(candidates, "Scroll up") {
|
||||||
t.Errorf("want directional scrolls, got %v", descriptions(candidates))
|
t.Errorf("want directional scrolls, got %v", descriptions(candidates))
|
||||||
}
|
}
|
||||||
// scrolls and swipes fold into the SAME directional entries: one each.
|
|
||||||
if got := count(candidates, "Scroll down"); got != 1 {
|
if got := count(candidates, "Scroll down"); got != 1 {
|
||||||
t.Errorf("Scroll down appears %d times, want 1 (folded)", got)
|
t.Errorf("Scroll down appears %d times, want 1 over the one container", got)
|
||||||
}
|
}
|
||||||
|
// `swipes` is a different verb, not a second name for the scroll: a
|
||||||
|
// free-form drag from any element, named by the control it starts on. That
|
||||||
|
// is what puts swipe-to-dismiss on a row within the model's reach.
|
||||||
|
if !hasCandidatePrefix(candidates, `Swipe "Sign in"`) {
|
||||||
|
t.Errorf("want a swipe naming the non-scrollable row, got %v", descriptions(candidates))
|
||||||
|
}
|
||||||
|
if hasCandidatePrefix(candidates, "Scroll \"") {
|
||||||
|
t.Errorf("scroll candidates must stay container-scoped: %v", descriptions(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasCandidatePrefix(candidates []ActionCandidate, prefix string) bool {
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if strings.HasPrefix(candidate.Description, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCandidatesWeightsCombineAcrossPaths(t *testing.T) {
|
func TestCandidatesWeightsCombineAcrossPaths(t *testing.T) {
|
||||||
@@ -228,6 +243,23 @@ func TestCandidatesCallsAuthoredLeafOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCandidatesSurfaceAuthoredUntargetedActions(t *testing.T) {
|
||||||
|
// A spec that authors a swipe, a key press, or a wait must reach the model
|
||||||
|
// with all three: the seeded picker executes whatever the leaf returns, so a
|
||||||
|
// kind the enumeration drops is an action only one policy can take.
|
||||||
|
actions := `{kind:'actions', generate: () => [
|
||||||
|
{kind:'Swipe', from:{x:10,y:600}, to:{x:10,y:100}, durationMillis: 250},
|
||||||
|
{kind:'PressKey', key:'back'},
|
||||||
|
{kind:'Wait'}
|
||||||
|
]}`
|
||||||
|
candidates := enumVerifier(t, actions, enumTreeJSON).Candidates()
|
||||||
|
for _, want := range []string{"Swipe from (10,600) to (10,100)", "Press back", "Wait"} {
|
||||||
|
if !hasCandidate(candidates, want) {
|
||||||
|
t.Errorf("authored %q missing: %v", want, descriptions(candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCandidatesOffRouteLeafYieldsNothing(t *testing.T) {
|
func TestCandidatesOffRouteLeafYieldsNothing(t *testing.T) {
|
||||||
v := enumVerifier(t, "{kind:'actions', generate: () => []}", enumTreeJSON)
|
v := enumVerifier(t, "{kind:'actions', generate: () => []}", enumTreeJSON)
|
||||||
if got := v.Candidates(); len(got) != 0 {
|
if got := v.Candidates(); len(got) != 0 {
|
||||||
@@ -248,9 +280,14 @@ func TestCandidatesSkipsCrossFadeFrames(t *testing.T) {
|
|||||||
]
|
]
|
||||||
}`
|
}`
|
||||||
v := enumVerifier(t, "{kind:'builtin', verb:'taps'}", crossFade)
|
v := enumVerifier(t, "{kind:'builtin', verb:'taps'}", crossFade)
|
||||||
if got := v.Candidates(); got != nil {
|
if got := v.Candidates(); len(got) != 0 {
|
||||||
t.Errorf("cross-fade frame should yield no candidates, got %v", descriptions(got))
|
t.Errorf("cross-fade frame should yield no candidates, got %v", descriptions(got))
|
||||||
}
|
}
|
||||||
|
// The seeded policy is skipped by the SAME guard, in the shared producer,
|
||||||
|
// so neither arm acts on a mid-animation layout.
|
||||||
|
if got := v.targets(); len(got) != 0 {
|
||||||
|
t.Errorf("cross-fade frame should yield no host targets, got %d", len(got))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCandidatesNilWithoutTreeOrActions(t *testing.T) {
|
func TestCandidatesNilWithoutTreeOrActions(t *testing.T) {
|
||||||
@@ -345,5 +382,3 @@ func newLoadedVerifier(t *testing.T, source string) *Verifier {
|
|||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -48,22 +48,25 @@ func TestCrossRuntimeParity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// installStubHost replaces the verifier's hierarchy-backed __sanderlingHost__
|
// installStubHost replaces the verifier's hierarchy-backed __sanderlingHost__
|
||||||
// with one returning a FIXED candidate list for every verb, keeping the seed the
|
// with one returning a FIXED target list, keeping the seed the verifier was
|
||||||
// verifier was constructed with. It must run BEFORE Load, because the bundled
|
// constructed with. Every fact is set so the shared eligibility rule admits all
|
||||||
// goja runtime entry captures the host when the spec evaluates.
|
// three targets for every verb, leaving the draw order as the only variable. It
|
||||||
|
// must run BEFORE Load, because the bundled goja runtime entry captures the host
|
||||||
|
// when the spec evaluates.
|
||||||
func installStubHost(t *testing.T, verifier *Verifier) {
|
func installStubHost(t *testing.T, verifier *Verifier) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
const stub = `
|
const stub = `
|
||||||
const candidates = [
|
const facts = { clickable: true, enabled: true, editable: true, scrollable: true };
|
||||||
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 },
|
const targets = [
|
||||||
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48 },
|
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40, ...facts },
|
||||||
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 },
|
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48, ...facts },
|
||||||
|
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32, ...facts },
|
||||||
];
|
];
|
||||||
const seedHi = globalThis.__sanderlingHost__.seedHi;
|
const seedHi = globalThis.__sanderlingHost__.seedHi;
|
||||||
const seedLo = globalThis.__sanderlingHost__.seedLo;
|
const seedLo = globalThis.__sanderlingHost__.seedLo;
|
||||||
globalThis.__sanderlingHost__ = {
|
globalThis.__sanderlingHost__ = {
|
||||||
platform: () => "android",
|
platform: () => "android",
|
||||||
queryCandidates: () => candidates,
|
queryTargets: () => targets,
|
||||||
reportUnsupported: () => {},
|
reportUnsupported: () => {},
|
||||||
seedHi,
|
seedHi,
|
||||||
seedLo,
|
seedLo,
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// policyTreeJSON gives every builtin verb something to act on, with every label
|
||||||
|
// distinct so no two candidates render the same way.
|
||||||
|
const policyTreeJSON = `{
|
||||||
|
"attributes": {"bounds": "[0,0,400,800]"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"resource-id": "Save", "text": "Save", "bounds": "[0,0,200,60]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "Cancel", "text": "Cancel", "bounds": "[200,0,400,60]"}, "clickable": true, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "Amount", "class": "EditText", "hintText": "Amount", "bounds": "[0,100,400,160]"}, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "Note", "class": "EditText", "hintText": "Note", "bounds": "[0,200,400,260]"}, "enabled": true, "children": []},
|
||||||
|
{"attributes": {"resource-id": "List", "scrollable": "true", "bounds": "[0,300,400,700]"}, "children": []}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// policyVerbs is every builtin verb a spec can put in its action tree.
|
||||||
|
var policyVerbs = []string{
|
||||||
|
"taps", "doubleTaps", "longPresses", "typing", "scrolls", "swipes", "pressKeys", "waitOnce",
|
||||||
|
}
|
||||||
|
|
||||||
|
// seededDrawBudget is how many times the seeded picker is driven per verb. The
|
||||||
|
// candidate sets here are a handful of entries wide, so this exhausts them many
|
||||||
|
// times over; a miss would mean the picker cannot reach one of its own
|
||||||
|
// candidates, which is itself the bug worth failing on.
|
||||||
|
const seededDrawBudget = 300
|
||||||
|
|
||||||
|
// TestPoliciesEnumerateTheSameActions is the guard on the claim the paper makes
|
||||||
|
// about the two action policies: they differ in the pick and in nothing else.
|
||||||
|
// For every builtin verb, the actions the seeded picker can draw and the
|
||||||
|
// candidates the model policy is offered must be the same set. Enumeration lives
|
||||||
|
// in one place (pick.ts builtinCandidates) precisely so this cannot drift, and
|
||||||
|
// this test is what notices if a second one ever grows back.
|
||||||
|
func TestPoliciesEnumerateTheSameActions(t *testing.T) {
|
||||||
|
for _, verb := range policyVerbs {
|
||||||
|
t.Run(verb, func(t *testing.T) {
|
||||||
|
seeded := seededReachableActions(t, verb)
|
||||||
|
model := modelOfferedActions(t, verb)
|
||||||
|
if len(model) == 0 {
|
||||||
|
t.Fatalf("%s offered the model no candidates at all", verb)
|
||||||
|
}
|
||||||
|
if !slices.Equal(slices.Sorted(maps.Keys(seeded)), slices.Sorted(maps.Keys(model))) {
|
||||||
|
t.Errorf("action spaces differ for %s\n seeded=%v\n model=%v",
|
||||||
|
verb, slices.Sorted(maps.Keys(seeded)), slices.Sorted(maps.Keys(model)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelCandidateDescriptionsAreUniqueAndNamed checks the rendering half of
|
||||||
|
// the contract: every enumerated action reaches the model as its own distinctly
|
||||||
|
// named line, so the number it picks and the action that executes agree.
|
||||||
|
func TestModelCandidateDescriptionsAreUniqueAndNamed(t *testing.T) {
|
||||||
|
for _, verb := range policyVerbs {
|
||||||
|
t.Run(verb, func(t *testing.T) {
|
||||||
|
verifier := loadVerbSpec(t, verb)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, candidate := range verifier.Candidates() {
|
||||||
|
if candidate.Description == "" {
|
||||||
|
t.Fatalf("%s produced a candidate with no description: %+v", verb, candidate.Action)
|
||||||
|
}
|
||||||
|
if seen[candidate.Description] {
|
||||||
|
t.Errorf("%s rendered %q twice, so the model cannot address both",
|
||||||
|
verb, candidate.Description)
|
||||||
|
}
|
||||||
|
seen[candidate.Description] = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelIsOfferedTheUntargetedVerbs pins the two verbs the model arm used to
|
||||||
|
// be blind to: with no element to enumerate over, a key press and a wait were
|
||||||
|
// dropped, so the model could never navigate back or let the app settle.
|
||||||
|
func TestModelIsOfferedTheUntargetedVerbs(t *testing.T) {
|
||||||
|
verifier := loadVerbSpec(t, "pressKeys")
|
||||||
|
if !hasCandidate(verifier.Candidates(), "Press back") {
|
||||||
|
t.Errorf("pressKeys missing from the model's candidates: %v",
|
||||||
|
descriptions(verifier.Candidates()))
|
||||||
|
}
|
||||||
|
verifier = loadVerbSpec(t, "waitOnce")
|
||||||
|
if !hasCandidate(verifier.Candidates(), "Wait") {
|
||||||
|
t.Errorf("waitOnce missing from the model's candidates: %v",
|
||||||
|
descriptions(verifier.Candidates()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadVerbSpec builds a verifier whose whole action tree is one builtin verb,
|
||||||
|
// with policyTreeJSON pushed as the current state.
|
||||||
|
func loadVerbSpec(t *testing.T, verb string) *Verifier {
|
||||||
|
t.Helper()
|
||||||
|
verifier := newVerifier(t, WithSeed(0x5eed))
|
||||||
|
loadActionSpec(t, verifier, fmt.Sprintf(
|
||||||
|
"import { %s } from \"@sanderling/spec\";\nglobalThis.actions = %s;", verb, verb))
|
||||||
|
pushTree(t, verifier, policyTreeJSON)
|
||||||
|
return verifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// seededReachableActions drives the seeded picker over its draw budget and
|
||||||
|
// returns every distinct action it produced.
|
||||||
|
func seededReachableActions(t *testing.T, verb string) map[string]Action {
|
||||||
|
t.Helper()
|
||||||
|
verifier := loadVerbSpec(t, verb)
|
||||||
|
reachable := map[string]Action{}
|
||||||
|
for range seededDrawBudget {
|
||||||
|
action, err := verifier.NextAction()
|
||||||
|
if errors.Is(err, ErrNoAction) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s next action: %v", verb, err)
|
||||||
|
}
|
||||||
|
reachable[actionIdentity(action)] = action
|
||||||
|
}
|
||||||
|
return reachable
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelOfferedActions returns the actions behind the numbered list the model
|
||||||
|
// policy picks from.
|
||||||
|
func modelOfferedActions(t *testing.T, verb string) map[string]Action {
|
||||||
|
t.Helper()
|
||||||
|
verifier := loadVerbSpec(t, verb)
|
||||||
|
offered := map[string]Action{}
|
||||||
|
for _, candidate := range verifier.Candidates() {
|
||||||
|
offered[actionIdentity(candidate.Action)] = candidate.Action
|
||||||
|
}
|
||||||
|
return offered
|
||||||
|
}
|
||||||
|
|
||||||
|
// actionIdentity keys an action by everything except the values the policy owns
|
||||||
|
// rather than the candidate set: the typed text, which the seeded arm draws from
|
||||||
|
// the edge-case corpus and the model writes itself, and a swipe's drag distance,
|
||||||
|
// which the seeded arm draws and the enumeration lists at a nominal length.
|
||||||
|
// Comparing those would compare policies instead of action spaces. A swipe's
|
||||||
|
// direction is NOT policy-owned, so it survives as the sign of the drag.
|
||||||
|
func actionIdentity(action Action) string {
|
||||||
|
action.Text = ""
|
||||||
|
if action.Kind == ActionKindSwipe {
|
||||||
|
action.ToX = sign(action.ToX - action.FromX)
|
||||||
|
action.ToY = sign(action.ToY - action.FromY)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%+v", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sign(value int) int {
|
||||||
|
switch {
|
||||||
|
case value > 0:
|
||||||
|
return 1
|
||||||
|
case value < 0:
|
||||||
|
return -1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,30 +105,108 @@ func TestTyping_ExcludeOffAppPackage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSwipes_ExcludeOffAppPackage proves swipes anchor on app nodes only, so
|
// gestureTreeJSON gives each gesture verb a legal and an illegal anchor: an app
|
||||||
// exploration never scrolls the keyboard's emoji list instead of the app.
|
// list holding a plain row, against the soft keyboard's own scrollable strip
|
||||||
func TestSwipes_ExcludeOffAppPackage(t *testing.T) {
|
// holding one of its keys.
|
||||||
|
const gestureTreeJSON = `{
|
||||||
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,500]", "package": "com.folio"},
|
||||||
|
"children": [
|
||||||
|
{"attributes": {"testTag": "AppList", "scrollable": "true", "bounds": "[0,0,100,300]", "package": "com.folio"}, "children": [
|
||||||
|
{"attributes": {"testTag": "Row", "bounds": "[0,0,100,60]", "package": "com.folio"}, "children": []}
|
||||||
|
]},
|
||||||
|
{"attributes": {"testTag": "EmojiStrip", "scrollable": "true", "bounds": "[0,400,100,440]", "package": "com.google.android.inputmethod.latin"}, "children": [
|
||||||
|
{"attributes": {"testTag": "EmojiKey", "bounds": "[0,400,100,420]", "package": "com.google.android.inputmethod.latin"}, "children": []}
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// TestGestures_ExcludeOffAppPackage proves both gesture verbs anchor on app
|
||||||
|
// nodes only, so exploration never drags the keyboard instead of the app, and
|
||||||
|
// pins what each verb accepts within the app and which way it drags. Scrolls
|
||||||
|
// anchor on the scrollable list alone and stay vertical, because every
|
||||||
|
// scrollable container earns a candidate and scrolling a list means up and down.
|
||||||
|
// Swipes anchor on any app element, the row and the root included, and drag in
|
||||||
|
// all four directions, which is what puts swipe-to-dismiss on a list row inside
|
||||||
|
// the action space.
|
||||||
|
func TestGestures_ExcludeOffAppPackage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
verb string
|
||||||
|
kind ActionKind
|
||||||
|
anchors map[[2]int]bool
|
||||||
|
directions map[string]bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"scrolls",
|
||||||
|
ActionKindScroll,
|
||||||
|
map[[2]int]bool{{50, 150}: true},
|
||||||
|
map[string]bool{"down": true, "up": true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"swipes",
|
||||||
|
ActionKindSwipe,
|
||||||
|
map[[2]int]bool{{50, 250}: true, {50, 150}: true, {50, 30}: true},
|
||||||
|
map[string]bool{"down": true, "up": true, "left": true, "right": true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.verb, func(t *testing.T) {
|
||||||
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
verifier := newVerifier(t, WithAppPackage("com.folio"))
|
||||||
loadActionSpec(t, verifier, `
|
loadActionSpec(t, verifier, `
|
||||||
import { swipes } from "@sanderling/spec";
|
import { `+test.verb+` } from "@sanderling/spec";
|
||||||
globalThis.actions = swipes;
|
globalThis.actions = `+test.verb+`;
|
||||||
`)
|
`)
|
||||||
pushTree(t, verifier, scopedTreeJSON)
|
pushTree(t, verifier, gestureTreeJSON)
|
||||||
|
|
||||||
// Both the root and SubmitButton (com.folio) are valid anchors; only the
|
// Draw many times so neither the exclusion nor the coverage below is
|
||||||
// keyboard key at center (50,420) must be excluded. Draw many times so the
|
// satisfied by a lucky seed. The keyboard strip (50,420) and its key
|
||||||
// invariant is not satisfied by a lucky seed.
|
// (50,410) are the anchors that must never appear.
|
||||||
for i := range 200 {
|
seen := map[[2]int]bool{}
|
||||||
|
seenDirections := map[string]bool{}
|
||||||
|
for i := range 400 {
|
||||||
action, err := verifier.NextAction()
|
action, err := verifier.NextAction()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if action.Kind != ActionKindSwipe {
|
if action.Kind != test.kind {
|
||||||
t.Fatalf("kind = %v, want Swipe", action.Kind)
|
t.Fatalf("kind = %v, want %v", action.Kind, test.kind)
|
||||||
}
|
}
|
||||||
if action.FromX == 50 && action.FromY == 420 {
|
anchor := [2]int{action.FromX, action.FromY}
|
||||||
t.Fatalf("draw %d anchored on the keyboard key (50,420); off-app node leaked into swipe targets", i)
|
if !test.anchors[anchor] {
|
||||||
|
t.Fatalf("draw %d anchored at %v, outside %v", i, anchor, test.anchors)
|
||||||
}
|
}
|
||||||
|
direction := gestureDirection(action)
|
||||||
|
if !test.directions[direction] {
|
||||||
|
t.Fatalf("draw %d dragged %s, outside %v", i, direction, test.directions)
|
||||||
|
}
|
||||||
|
seen[anchor] = true
|
||||||
|
seenDirections[direction] = true
|
||||||
|
}
|
||||||
|
if len(seen) != len(test.anchors) {
|
||||||
|
t.Errorf("reached anchors %v, want all of %v", seen, test.anchors)
|
||||||
|
}
|
||||||
|
if len(seenDirections) != len(test.directions) {
|
||||||
|
t.Errorf("reached directions %v, want all of %v", seenDirections, test.directions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gestureDirection names which way a drawn gesture drags. A scroll carries the
|
||||||
|
// name it was enumerated under; a swipe carries only its endpoints, so the sign
|
||||||
|
// of the drag is what says where the finger went.
|
||||||
|
func gestureDirection(action Action) string {
|
||||||
|
if action.Kind == ActionKindScroll {
|
||||||
|
return action.Direction
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case action.ToX > action.FromX:
|
||||||
|
return "right"
|
||||||
|
case action.ToX < action.FromX:
|
||||||
|
return "left"
|
||||||
|
case action.ToY > action.FromY:
|
||||||
|
return "down"
|
||||||
|
default:
|
||||||
|
return "up"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-39
@@ -39,6 +39,12 @@ type Verifier struct {
|
|||||||
// reimplementing the corpus on the Go side.
|
// reimplementing the corpus on the Go side.
|
||||||
sampleInputFn goja.Callable
|
sampleInputFn goja.Callable
|
||||||
|
|
||||||
|
// enumerateBuiltinFn is the bundle-installed __sanderlingEnumerateBuiltin__,
|
||||||
|
// which lists every action a builtin verb can yield right now. It is the same
|
||||||
|
// enumeration the seeded picker draws from, so the LLM action backend selects
|
||||||
|
// over the picker's action space rather than one of its own.
|
||||||
|
enumerateBuiltinFn goja.Callable
|
||||||
|
|
||||||
evaluators map[string]*ltl.Evaluator
|
evaluators map[string]*ltl.Evaluator
|
||||||
|
|
||||||
priorVerdicts map[string]ltl.Verdict
|
priorVerdicts map[string]ltl.Verdict
|
||||||
@@ -49,6 +55,8 @@ type Verifier struct {
|
|||||||
lastScreenshot []byte
|
lastScreenshot []byte
|
||||||
scopeCache map[*hierarchy.Element]bool
|
scopeCache map[*hierarchy.Element]bool
|
||||||
scopeCacheTree *hierarchy.Tree
|
scopeCacheTree *hierarchy.Tree
|
||||||
|
targetCache []targetElement
|
||||||
|
targetCacheTree *hierarchy.Tree
|
||||||
lastAction *Action
|
lastAction *Action
|
||||||
lastLogs []LogEntry
|
lastLogs []LogEntry
|
||||||
lastExceptions []Exception
|
lastExceptions []Exception
|
||||||
@@ -178,6 +186,12 @@ func (v *Verifier) Load(source string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fn := v.runtime.GlobalObject().Get("__sanderlingEnumerateBuiltin__"); fn != nil {
|
||||||
|
if callable, ok := goja.AssertFunction(fn); ok {
|
||||||
|
v.enumerateBuiltinFn = callable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,66 +764,65 @@ func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// candidatesForVerb enumerates the host-side targets a builtin verb may draw
|
// targets enumerates every element this host can offer, in v.lastTree.Elements
|
||||||
// from, in v.lastTree.Elements ORDER (the order is part of the picker's parity
|
// ORDER (the order is part of the picker's parity contract). It is the native
|
||||||
// contract). The filters are LIFTED from the old Go picker:
|
// half of the single candidate producer: the picker reads it through
|
||||||
|
// __sanderlingHost__.queryTargets, applies the SHARED per-verb eligibility rule
|
||||||
|
// (pkg/spec/src/targets.ts), and expands what survives into concrete actions for
|
||||||
|
// both policies. Which verb may act on which element is decided there, once, so
|
||||||
|
// this host and the web host cannot mean different things by the same verb.
|
||||||
//
|
//
|
||||||
// taps/doubleTaps/longPresses: clickable + enabled + positive bounds
|
// This host's job is the facts: clickable/enabled/editable come off the
|
||||||
// typing: editable + enabled + positive bounds
|
// accessibility node, scrollable off its attribute, and the geometry off its
|
||||||
// scrolls: scrollable attribute + positive bounds
|
// bounds. Every target carries the resolving selector so the runner can re-route
|
||||||
// swipes: any in-scope element with positive bounds
|
// by id/text. Out-of-scope nodes (the soft keyboard, system UI, the launcher)
|
||||||
|
// are dropped by scopedElements.
|
||||||
//
|
//
|
||||||
// Every candidate carries the resolving selector so the runner can re-route by
|
// A cross-fade frame yields nothing at all. Its layout is mid-animation, often
|
||||||
// id/text. Out-of-scope nodes (the soft keyboard, system UI, the launcher) are
|
// in a collapsed coordinate space, so acting on it lands on garbage; skipping it
|
||||||
// dropped by scopedElements.
|
// here rather than in one policy means both policies re-observe a settled frame
|
||||||
func (v *Verifier) candidatesForVerb(verb string) []candidate {
|
// instead of one of them acting on the animation.
|
||||||
if v.lastTree == nil {
|
func (v *Verifier) targets() []targetElement {
|
||||||
|
if v.lastTree == nil || v.lastTree.Transitional() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if v.targetCacheTree == v.lastTree {
|
||||||
|
return v.targetCache
|
||||||
|
}
|
||||||
scope := v.scopedElements()
|
scope := v.scopedElements()
|
||||||
var result []candidate
|
result := make([]targetElement, 0, len(v.lastTree.Elements))
|
||||||
for _, element := range v.lastTree.Elements {
|
for _, element := range v.lastTree.Elements {
|
||||||
if !scope[element] {
|
if !scope[element] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !verbAccepts(verb, element) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
x, y := element.Bounds.Center()
|
x, y := element.Bounds.Center()
|
||||||
result = append(result, candidate{
|
result = append(result, targetElement{
|
||||||
|
element: element,
|
||||||
x: x,
|
x: x,
|
||||||
y: y,
|
y: y,
|
||||||
width: element.Bounds.Width(),
|
width: element.Bounds.Width(),
|
||||||
height: element.Bounds.Height(),
|
height: element.Bounds.Height(),
|
||||||
selector: selectorForElement(v.lastTree, element),
|
selector: selectorForElement(v.lastTree, element),
|
||||||
|
clickable: element.Clickable,
|
||||||
|
enabled: element.Enabled,
|
||||||
|
editable: element.Editable,
|
||||||
|
scrollable: element.Attributes["scrollable"] == "true",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
v.targetCache = result
|
||||||
|
v.targetCacheTree = v.lastTree
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
type candidate struct {
|
// targetElement is one host-enumerated element with the facts the shared
|
||||||
|
// eligibility rule reads. The host reports facts; it does not filter by verb.
|
||||||
|
type targetElement struct {
|
||||||
|
element *hierarchy.Element
|
||||||
x, y int
|
x, y int
|
||||||
width, height int
|
width, height int
|
||||||
selector string
|
selector string
|
||||||
}
|
clickable bool
|
||||||
|
enabled bool
|
||||||
// verbAccepts applies the per-verb element filter.
|
editable bool
|
||||||
func verbAccepts(verb string, element *hierarchy.Element) bool {
|
scrollable bool
|
||||||
positiveBounds := element.Bounds.Width() > 0 && element.Bounds.Height() > 0
|
|
||||||
switch verb {
|
|
||||||
case "taps", "doubleTaps", "longPresses":
|
|
||||||
return element.Clickable && element.Enabled && positiveBounds
|
|
||||||
case "typing":
|
|
||||||
return element.Editable && element.Enabled && positiveBounds
|
|
||||||
case "scrolls":
|
|
||||||
return element.Attributes["scrollable"] == "true" && positiveBounds
|
|
||||||
case "swipes":
|
|
||||||
// Any visible element is a valid swipe origin, but it must have real
|
|
||||||
// bounds: a zero-bounds node centers at (0,0), and a downward swipe from
|
|
||||||
// the top-left corner is the system gesture that pulls down the
|
|
||||||
// notification shade, dragging the fuzzer out of the app.
|
|
||||||
return positiveBounds
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,45 @@
|
|||||||
package verifier
|
package verifier
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
"github.com/priyanshujain/sanderling/internal/hierarchy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestVerbAcceptsSwipeRequiresPositiveBounds locks the fix for the notification
|
// TestTargetsReportFactsWithoutFiltering pins the native host's half of the
|
||||||
// shade: a zero-bounds element centers at (0,0), and a downward swipe from the
|
// split introduced to stop the two hosts drifting: it reports what an element
|
||||||
// top-left corner is the system gesture that pulls the shade over the app. The
|
// IS and never decides which verb may act on it. The verb decision is one shared
|
||||||
// swipe verb must reject zero-bounds nodes like every other verb does.
|
// rule (pkg/spec/src/targets.ts) both hosts consume, asserted across engines by
|
||||||
func TestVerbAcceptsSwipeRequiresPositiveBounds(t *testing.T) {
|
// TestHostsAgreeOnTargetEligibility.
|
||||||
zeroBounds := &hierarchy.Element{Bounds: hierarchy.Bounds{}}
|
func TestTargetsReportFactsWithoutFiltering(t *testing.T) {
|
||||||
if verbAccepts("swipes", zeroBounds) {
|
tree, err := hierarchy.Parse(hostParityTreeJSON)
|
||||||
t.Error("swipes must reject a zero-bounds element (it centers at (0,0) and pulls the notification shade)")
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v := &Verifier{lastTree: tree}
|
||||||
|
targets := v.targets()
|
||||||
|
if len(targets) != len(hostParityScreen) {
|
||||||
|
t.Fatalf("targets() returned %d elements, want all %d in the tree",
|
||||||
|
len(targets), len(hostParityScreen))
|
||||||
|
}
|
||||||
|
for index, target := range targets {
|
||||||
|
want := hostParityScreen[index]
|
||||||
|
got := []bool{
|
||||||
|
target.clickable, target.enabled, target.editable, target.scrollable,
|
||||||
|
}
|
||||||
|
expected := []bool{
|
||||||
|
want.clickable, want.enabled, want.editable, want.scrollable,
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, expected) {
|
||||||
|
t.Errorf("%s clickable/enabled/editable/scrollable = %v, want %v",
|
||||||
|
want.name, got, expected)
|
||||||
|
}
|
||||||
|
positiveBounds := target.width > 0 && target.height > 0
|
||||||
|
if positiveBounds != want.positiveBounds {
|
||||||
|
t.Errorf("%s positive bounds = %v, want %v",
|
||||||
|
want.name, positiveBounds, want.positiveBounds)
|
||||||
}
|
}
|
||||||
|
|
||||||
realBounds := &hierarchy.Element{Bounds: hierarchy.Bounds{Left: 100, Top: 400, Right: 980, Bottom: 600}}
|
|
||||||
if !verbAccepts("swipes", realBounds) {
|
|
||||||
t.Error("swipes must accept an element with positive bounds")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ export type GeneratorNode =
|
|||||||
| { kind: "builtin"; verb: BuiltinVerb }
|
| { kind: "builtin"; verb: BuiltinVerb }
|
||||||
| { kind: "llm"; config: { model: string; instructions?: string } };
|
| { kind: "llm"; config: { model: string; instructions?: string } };
|
||||||
|
|
||||||
// Candidate is one host-enumerated target for a builtin verb. The host
|
// Candidate is the resolved geometry of one target. The host resolves it (and a
|
||||||
// resolves geometry (and a native selector) so no element handle crosses into
|
// native selector) so no element handle crosses into the picker. width/height
|
||||||
// the picker. width/height let swipe/scroll size a gesture off the element.
|
// let swipe/scroll size a gesture off the element.
|
||||||
export interface Candidate {
|
export interface Candidate {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -76,15 +76,27 @@ export interface Candidate {
|
|||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TargetElement is one host-enumerated element, offered to EVERY verb, carrying
|
||||||
|
// the facts per-verb eligibility is decided from. The host reports the facts; it
|
||||||
|
// does not apply them. targets.ts acceptsTarget owns that decision for both
|
||||||
|
// hosts, so a verb cannot mean one thing on native and another on web.
|
||||||
|
export interface TargetElement extends Candidate {
|
||||||
|
clickable: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
scrollable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// Host is the platform backing the picker draws against. In this foundation
|
// Host is the platform backing the picker draws against. In this foundation
|
||||||
// workflow only the interface is defined and exercised against a stub; the
|
// workflow only the interface is defined and exercised against a stub; the
|
||||||
// goja and DOM implementations land in the rewire workflow.
|
// goja and DOM implementations land in the rewire workflow.
|
||||||
export interface Host {
|
export interface Host {
|
||||||
platform(): "android" | "ios" | "web";
|
platform(): "android" | "ios" | "web";
|
||||||
// queryCandidates returns the host-enumerated targets for a verb, in a
|
// queryTargets returns every element the host can offer, in a deterministic
|
||||||
// deterministic order. The picker indexes into this list with the PCG, so
|
// order, with no per-verb filtering: the picker applies acceptsTarget. The
|
||||||
// the order is part of the parity contract.
|
// picker indexes into this list with the PCG, so the order is part of the
|
||||||
queryCandidates(verb: BuiltinVerb): Candidate[];
|
// parity contract.
|
||||||
|
queryTargets(): TargetElement[];
|
||||||
// reportUnsupported is invoked at most once per verb@platform (see verbs.ts)
|
// reportUnsupported is invoked at most once per verb@platform (see verbs.ts)
|
||||||
// when a verb has no support on this platform.
|
// when a verb has no support on this platform.
|
||||||
reportUnsupported(verb: BuiltinVerb): void;
|
reportUnsupported(verb: BuiltinVerb): void;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Goja-side runtime entry for the native verifier (internal/verifier).
|
// Goja-side runtime entry for the native verifier (internal/verifier).
|
||||||
//
|
//
|
||||||
// Go installs globalThis.__sanderlingHost__ (platform/seed/queryCandidates/
|
// Go installs globalThis.__sanderlingHost__ (platform/seed/queryTargets/
|
||||||
// reportUnsupported, implemented over the hierarchy tree in bindings.go) before
|
// reportUnsupported, implemented over the hierarchy tree in bindings.go) before
|
||||||
// the spec evaluates. This module bundles AFTER the spec, reads that host, and
|
// the spec evaluates. This module bundles AFTER the spec, reads that host, and
|
||||||
// wires the shared picker via installRuntime so the goja verifier and the V8 web
|
// wires the shared picker via installRuntime so the goja verifier and the V8 web
|
||||||
|
|||||||
+160
-118
@@ -1,29 +1,44 @@
|
|||||||
// The shared deterministic action picker for W2 approach B.
|
// The shared deterministic action picker.
|
||||||
//
|
//
|
||||||
// walk() traverses a GeneratorNode tree, and nextAction() wraps it with the
|
// walk() traverses a GeneratorNode tree, and nextAction() wraps it with the
|
||||||
// 16-attempt retry that matches worker.go NextAction. Both engines (the goja
|
// 16-attempt retry that matches worker.go NextAction. Both engines (the goja
|
||||||
// verifier and the V8 web runtime) run THIS code, drawing through the shared
|
// verifier and the V8 web runtime) run THIS code, drawing through the shared
|
||||||
// Pcg, so a given seed yields an identical action stream on every platform.
|
// Pcg, so a given seed yields an identical action stream on every platform.
|
||||||
//
|
//
|
||||||
|
// builtinCandidates() is the ONE enumeration of what a builtin verb can do at
|
||||||
|
// the current step. The seeded policy draws a single entry from it below; the
|
||||||
|
// model policy (Go, internal/verifier/llm.go) reads the same list through
|
||||||
|
// __sanderlingEnumerateBuiltin__. Neither policy can reach an action the other
|
||||||
|
// cannot, because neither owns an enumeration of its own.
|
||||||
|
//
|
||||||
// PARITY CONTRACT - draw order.
|
// PARITY CONTRACT - draw order.
|
||||||
// Every random decision goes through the Pcg in a FIXED, pinned order. Changing
|
// Every random decision goes through the Pcg in a FIXED, pinned order. Changing
|
||||||
// this order shifts the stream for a seed and breaks cross-engine
|
// this order shifts the stream for a seed and breaks cross-engine
|
||||||
// reproducibility, so treat it as load-bearing:
|
// reproducibility, so treat it as load-bearing:
|
||||||
//
|
//
|
||||||
|
// pickUniform(list): NO draw for an empty or single-entry list, otherwise ONE
|
||||||
|
// intN(list.length) draw. Both the actions node and the
|
||||||
|
// builtin node select through it.
|
||||||
// weighted node: ONE float64() draw, then an ASCENDING cumulative scan over
|
// weighted node: ONE float64() draw, then an ASCENDING cumulative scan over
|
||||||
// max(0, weight). (matches worker.go pickWeighted.)
|
// max(0, weight). (matches worker.go pickWeighted.)
|
||||||
// actions node: the generator runs FIRST (any from(...).generate() inside
|
// actions node: the generator runs FIRST (any from(...).generate() inside
|
||||||
// draws intN(itemCount) for >1 items, nothing otherwise),
|
// draws intN(itemCount) for >1 items, nothing otherwise),
|
||||||
// THEN if the returned list has >1 entry, ONE intN(len) draw;
|
// THEN pickUniform over the returned list.
|
||||||
// a 0- or 1-element list draws nothing. (pickFromResult.)
|
// builtin node: pickUniform over builtinCandidates(verb), THEN the one
|
||||||
// builtin node, per verb, in this exact sequence:
|
// value that verb's enumeration leaves to the policy:
|
||||||
// taps/doubleTaps/longPresses: intN(candidateCount) [1 draw]
|
// `typing` ONE intN(corpusLength) draw for the text,
|
||||||
// typing: intN(candidateCount), intN(corpusLength)
|
// `swipes` ONE 200 + intN(401) draw for the drag distance.
|
||||||
// swipes: intN(candidateCount),
|
// Every other verb draws nothing further.
|
||||||
// 200 + intN(401) magnitude, intN(4) direction
|
//
|
||||||
// scrolls: intN(candidateCount), intN(4) direction
|
// The enumerated candidate count per verb, over the host targets the verb
|
||||||
// pressKeys: intN(keyCount)
|
// accepts (targets.ts acceptsTarget), which is what pickUniform draws over:
|
||||||
// waitOnce: no draw
|
//
|
||||||
|
// taps/doubleTaps/longPresses: one per accepted target
|
||||||
|
// typing: one per accepted target
|
||||||
|
// scrolls: two per scrollable container (down, up)
|
||||||
|
// swipes: four per accepted target (down, up, left, right)
|
||||||
|
// pressKeys: one per key in the platform's pool
|
||||||
|
// waitOnce: exactly one, so it never draws
|
||||||
//
|
//
|
||||||
// Builtin targets are resolved to a {x, y} Point by the host BEFORE the picker
|
// Builtin targets are resolved to a {x, y} Point by the host BEFORE the picker
|
||||||
// sees them, so no element handle crosses into this module.
|
// sees them, so no element handle crosses into this module.
|
||||||
@@ -32,24 +47,114 @@ import type { Pcg } from "./pcg.ts";
|
|||||||
import type {
|
import type {
|
||||||
ActionDescriptor,
|
ActionDescriptor,
|
||||||
BuiltinVerb,
|
BuiltinVerb,
|
||||||
|
Candidate,
|
||||||
GeneratorNode,
|
GeneratorNode,
|
||||||
Host,
|
Host,
|
||||||
} from "./action-tree.ts";
|
} from "./action-tree.ts";
|
||||||
import type { Direction, Point } from "./types.ts";
|
import type { Direction, Point } from "./types.ts";
|
||||||
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "./corpus.ts";
|
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "./corpus.ts";
|
||||||
import { setSamplerRng } from "./sampler-rng.ts";
|
import { setSamplerRng } from "./sampler-rng.ts";
|
||||||
|
import { acceptsTarget } from "./targets.ts";
|
||||||
import { supports, warnUnsupportedOnce } from "./verbs.ts";
|
import { supports, warnUnsupportedOnce } from "./verbs.ts";
|
||||||
|
|
||||||
// SWIPE_MIN_MAGNITUDE / SWIPE_MAGNITUDE_SPAN reproduce worker.go's
|
const WAIT_MILLIS = 500;
|
||||||
// `200 + rng.IntN(401)` swipe distance in pixels (200..600 inclusive).
|
|
||||||
|
// SCROLL_DIRECTIONS and SWIPE_DIRECTIONS are the directions each gesture verb
|
||||||
|
// enumerates, one candidate per (target, direction). Scrolls stay vertical:
|
||||||
|
// they target every scrollable container, so they are what makes the enumerated
|
||||||
|
// list long, and scrolling a mobile list means up and down. Swipes take all
|
||||||
|
// four, because swipe-to-dismiss and swipe-to-delete are horizontal gestures on
|
||||||
|
// list rows, and folding them away puts that defect class out of reach.
|
||||||
|
const SCROLL_DIRECTIONS: readonly Direction[] = ["down", "up"];
|
||||||
|
const SWIPE_DIRECTIONS: readonly Direction[] = ["down", "up", "left", "right"];
|
||||||
|
|
||||||
|
// SWIPE_MIN_MAGNITUDE / SWIPE_MAGNITUDE_SPAN are the free-form swipe distance in
|
||||||
|
// pixels the seeded policy draws, 200..600 inclusive. SWIPE_NOMINAL_MAGNITUDE is
|
||||||
|
// the distance the enumeration lists, so every enumerated candidate is already a
|
||||||
|
// runnable gesture before any policy has drawn anything.
|
||||||
const SWIPE_MIN_MAGNITUDE = 200;
|
const SWIPE_MIN_MAGNITUDE = 200;
|
||||||
const SWIPE_MAGNITUDE_SPAN = 401;
|
const SWIPE_MAGNITUDE_SPAN = 401;
|
||||||
|
const SWIPE_NOMINAL_MAGNITUDE = 400;
|
||||||
const SWIPE_DURATION_MILLIS = 250;
|
const SWIPE_DURATION_MILLIS = 250;
|
||||||
|
|
||||||
const DIRECTIONS: readonly Direction[] = ["up", "down", "left", "right"];
|
|
||||||
|
|
||||||
const MAX_RETRIES = 16;
|
const MAX_RETRIES = 16;
|
||||||
|
|
||||||
|
// BuiltinCandidate is one enumerated action for a builtin verb, paired with the
|
||||||
|
// index of the host target it acts on, in host.queryTargets() order (NOT the
|
||||||
|
// verb-filtered order, so a host can resolve it without repeating the filter).
|
||||||
|
// targetIndex is -1 for the untargeted verbs (a key press, a wait); the model
|
||||||
|
// policy uses it to name the control the action lands on.
|
||||||
|
export interface BuiltinCandidate {
|
||||||
|
action: ActionDescriptor;
|
||||||
|
targetIndex: number;
|
||||||
|
// swipe is set on a `swipes` candidate only. The enumeration fixes where the
|
||||||
|
// drag starts and which way it travels and lists a nominal distance; the
|
||||||
|
// seeded policy rebuilds the gesture from these at a drawn distance, the way a
|
||||||
|
// typing candidate names the field and leaves the text to the policy.
|
||||||
|
swipe?: { origin: Point; direction: Direction };
|
||||||
|
}
|
||||||
|
|
||||||
|
// builtinCandidates enumerates EVERY action a builtin verb can yield against the
|
||||||
|
// host's current state. It is the single candidate producer both policies read,
|
||||||
|
// over the single eligibility rule both hosts consume. An unsupported verb
|
||||||
|
// reports once and enumerates nothing, so a platform that cannot dispatch a verb
|
||||||
|
// never offers it to either policy.
|
||||||
|
export function builtinCandidates(verb: BuiltinVerb, host: Host): BuiltinCandidate[] {
|
||||||
|
if (!supports(verb, host.platform())) {
|
||||||
|
warnUnsupportedOnce(host, verb);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (verb === "waitOnce") {
|
||||||
|
return [{ action: { kind: "Wait", durationMillis: WAIT_MILLIS }, targetIndex: -1 }];
|
||||||
|
}
|
||||||
|
if (verb === "pressKeys") {
|
||||||
|
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
|
||||||
|
return keys.map((key) => ({
|
||||||
|
action: { kind: "PressKey", key } as ActionDescriptor,
|
||||||
|
targetIndex: -1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates: BuiltinCandidate[] = [];
|
||||||
|
host.queryTargets().forEach((target, targetIndex) => {
|
||||||
|
if (!acceptsTarget(verb, target)) return;
|
||||||
|
const point = withSelector({ x: target.x, y: target.y }, target.selector);
|
||||||
|
const add = (action: ActionDescriptor) => candidates.push({ action, targetIndex });
|
||||||
|
switch (verb) {
|
||||||
|
case "taps":
|
||||||
|
add({ kind: "Tap", on: point });
|
||||||
|
break;
|
||||||
|
case "doubleTaps":
|
||||||
|
add({ kind: "DoubleTap", on: point });
|
||||||
|
break;
|
||||||
|
case "longPresses":
|
||||||
|
add({ kind: "LongPress", on: point });
|
||||||
|
break;
|
||||||
|
case "typing":
|
||||||
|
// text is left empty: it is the one value the policy supplies, drawn
|
||||||
|
// from the corpus by the seeded arm and written by the model.
|
||||||
|
add({ kind: "InputText", into: point, text: "" });
|
||||||
|
break;
|
||||||
|
case "scrolls":
|
||||||
|
for (const direction of SCROLL_DIRECTIONS) {
|
||||||
|
add(scrollDescriptor({ x: target.x, y: target.y }, direction, target));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "swipes":
|
||||||
|
for (const direction of SWIPE_DIRECTIONS) {
|
||||||
|
const origin = { x: target.x, y: target.y };
|
||||||
|
candidates.push({
|
||||||
|
action: swipeDescriptor(origin, direction, SWIPE_NOMINAL_MAGNITUDE),
|
||||||
|
targetIndex,
|
||||||
|
swipe: { origin, direction },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
// nextAction resolves an action for the current step, retrying walk() up to 16
|
// nextAction resolves an action for the current step, retrying walk() up to 16
|
||||||
// times when it yields null (matches worker.go NextAction). Returns null when
|
// times when it yields null (matches worker.go NextAction). Returns null when
|
||||||
// every attempt comes up empty.
|
// every attempt comes up empty.
|
||||||
@@ -79,7 +184,7 @@ export function walk(
|
|||||||
// generator so author sampling shares this single deterministic stream.
|
// generator so author sampling shares this single deterministic stream.
|
||||||
setSamplerRng(rng);
|
setSamplerRng(rng);
|
||||||
try {
|
try {
|
||||||
return walkActions(node.generate(), rng);
|
return pickUniform(node.generate(), rng);
|
||||||
} finally {
|
} finally {
|
||||||
setSamplerRng(null);
|
setSamplerRng(null);
|
||||||
}
|
}
|
||||||
@@ -115,13 +220,13 @@ function walkWeighted(
|
|||||||
return last ? walk(last[1], rng, host) : null;
|
return last ? walk(last[1], rng, host) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkActions(
|
// pickUniform selects one entry from a list, drawing nothing when there is no
|
||||||
generated: ActionDescriptor[],
|
// choice to make. It is the only selection rule in this module: the seeded
|
||||||
rng: Pcg,
|
// policy is exactly "enumerate, then pickUniform".
|
||||||
): ActionDescriptor | null {
|
function pickUniform<T>(list: readonly T[], rng: Pcg): T | null {
|
||||||
if (generated.length === 0) return null;
|
if (list.length === 0) return null;
|
||||||
if (generated.length === 1) return generated[0] ?? null;
|
if (list.length === 1) return list[0] ?? null;
|
||||||
return generated[rng.intN(generated.length)] ?? null;
|
return list[rng.intN(list.length)] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkBuiltin(
|
function walkBuiltin(
|
||||||
@@ -129,41 +234,17 @@ function walkBuiltin(
|
|||||||
rng: Pcg,
|
rng: Pcg,
|
||||||
host: Host,
|
host: Host,
|
||||||
): ActionDescriptor | null {
|
): ActionDescriptor | null {
|
||||||
if (!supports(verb, host.platform())) {
|
const picked = pickUniform(builtinCandidates(verb, host), rng);
|
||||||
warnUnsupportedOnce(host, verb);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (verb === "waitOnce") {
|
|
||||||
return { kind: "Wait", durationMillis: 500 };
|
|
||||||
}
|
|
||||||
if (verb === "pressKeys") {
|
|
||||||
return walkPressKey(rng, host);
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = host.queryCandidates(verb);
|
|
||||||
if (candidates.length === 0) return null;
|
|
||||||
const picked = candidates[rng.intN(candidates.length)];
|
|
||||||
if (!picked) return null;
|
if (!picked) return null;
|
||||||
const point: Point = { x: picked.x, y: picked.y };
|
if (picked.action.kind === "InputText") {
|
||||||
|
return { ...picked.action, text: INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "" };
|
||||||
switch (verb) {
|
|
||||||
case "taps":
|
|
||||||
return tapDescriptor("Tap", point, picked.selector);
|
|
||||||
case "doubleTaps":
|
|
||||||
return tapDescriptor("DoubleTap", point, picked.selector);
|
|
||||||
case "longPresses":
|
|
||||||
return tapDescriptor("LongPress", point, picked.selector);
|
|
||||||
case "typing": {
|
|
||||||
const text = INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "";
|
|
||||||
return { kind: "InputText", into: withSelector(point, picked.selector), text };
|
|
||||||
}
|
|
||||||
case "swipes":
|
|
||||||
return buildSwipe(point, rng);
|
|
||||||
case "scrolls": {
|
|
||||||
const direction = DIRECTIONS[rng.intN(DIRECTIONS.length)] ?? "down";
|
|
||||||
return buildScroll(point, direction, picked, rng);
|
|
||||||
}
|
}
|
||||||
|
if (picked.swipe) {
|
||||||
|
const magnitude = SWIPE_MIN_MAGNITUDE + rng.intN(SWIPE_MAGNITUDE_SPAN);
|
||||||
|
const { origin, direction } = picked.swipe;
|
||||||
|
return swipeDescriptor(origin, direction, magnitude);
|
||||||
}
|
}
|
||||||
|
return picked.action;
|
||||||
}
|
}
|
||||||
|
|
||||||
// withSelector attaches a native selector to a resolved Point so the runner can
|
// withSelector attaches a native selector to a resolved Point so the runner can
|
||||||
@@ -173,82 +254,43 @@ function withSelector(point: Point, selector?: string): Point {
|
|||||||
return { ...point, selector } as Point;
|
return { ...point, selector } as Point;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tapDescriptor(
|
// scrollDescriptor lowers a scroll to a drag over the container, matching
|
||||||
kind: "Tap" | "DoubleTap" | "LongPress",
|
// runner.go's geometry: the gesture drags opposite the named content motion,
|
||||||
point: Point,
|
|
||||||
selector?: string,
|
|
||||||
): ActionDescriptor {
|
|
||||||
return { kind, on: withSelector(point, selector) } as ActionDescriptor;
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildScroll lowers a scroll to a swipe over the container, matching
|
|
||||||
// worker.go's geometry: the gesture drags opposite the named content motion,
|
|
||||||
// magnitude 40% of the container extent. Missing width/height (web root) yields
|
// magnitude 40% of the container extent. Missing width/height (web root) yields
|
||||||
// a zero-length endpoint, which the runner re-derives from container bounds.
|
// a zero-length endpoint, which the runner re-derives from container bounds.
|
||||||
function buildScroll(
|
function scrollDescriptor(
|
||||||
from: Point,
|
from: Point,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
candidate: { width?: number; height?: number },
|
candidate: Candidate,
|
||||||
_rng: Pcg,
|
|
||||||
): ActionDescriptor {
|
): ActionDescriptor {
|
||||||
const width = candidate.width ?? 0;
|
|
||||||
const height = candidate.height ?? 0;
|
const height = candidate.height ?? 0;
|
||||||
let toX = from.x;
|
|
||||||
let toY = from.y;
|
let toY = from.y;
|
||||||
switch (direction) {
|
if (direction === "down") toY = from.y - Math.trunc((4 * height) / 10);
|
||||||
case "down":
|
if (direction === "up") toY = from.y + Math.trunc((4 * height) / 10);
|
||||||
toY = from.y - Math.trunc((4 * height) / 10);
|
|
||||||
break;
|
|
||||||
case "up":
|
|
||||||
toY = from.y + Math.trunc((4 * height) / 10);
|
|
||||||
break;
|
|
||||||
case "left":
|
|
||||||
toX = from.x + Math.trunc((4 * width) / 10);
|
|
||||||
break;
|
|
||||||
case "right":
|
|
||||||
toX = from.x - Math.trunc((4 * width) / 10);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
kind: "Scroll",
|
kind: "Scroll",
|
||||||
direction,
|
direction,
|
||||||
in: from,
|
in: from,
|
||||||
from,
|
from,
|
||||||
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
|
to: { x: from.x, y: Math.max(0, toY) },
|
||||||
} as ActionDescriptor;
|
} as ActionDescriptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
function walkPressKey(rng: Pcg, host: Host): ActionDescriptor | null {
|
// swipeDescriptor builds a free-form drag from a point over a raw pixel
|
||||||
const keys = host.platform() === "web" ? WEB_PRESS_KEYS : NATIVE_PRESS_KEYS;
|
// distance, rather than a fraction of a container extent: `swipes` targets any
|
||||||
if (keys.length === 0) return null;
|
// element with real bounds, and most of those have no scroll extent to size a
|
||||||
const key = keys[rng.intN(keys.length)] ?? keys[0];
|
// gesture against. `direction` names where the finger travels, because a swipe
|
||||||
if (key === undefined) return null;
|
// IS the gesture; a scroll names content motion and drags the other way.
|
||||||
return { kind: "PressKey", key };
|
function swipeDescriptor(
|
||||||
|
from: Point,
|
||||||
|
direction: Direction,
|
||||||
|
magnitude: number,
|
||||||
|
): ActionDescriptor {
|
||||||
|
const horizontal = direction === "left" || direction === "right";
|
||||||
|
const forward = direction === "down" || direction === "right";
|
||||||
|
const travel = forward ? magnitude : -magnitude;
|
||||||
|
const to = horizontal
|
||||||
|
? { x: Math.max(0, from.x + travel), y: from.y }
|
||||||
|
: { x: from.x, y: Math.max(0, from.y + travel) };
|
||||||
|
return { kind: "Swipe", from, to, durationMillis: SWIPE_DURATION_MILLIS };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSwipe(from: Point, rng: Pcg): ActionDescriptor {
|
|
||||||
const magnitude = SWIPE_MIN_MAGNITUDE + rng.intN(SWIPE_MAGNITUDE_SPAN);
|
|
||||||
let toX = from.x;
|
|
||||||
let toY = from.y;
|
|
||||||
switch (rng.intN(4)) {
|
|
||||||
case 0:
|
|
||||||
toY = from.y - magnitude;
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
toY = from.y + magnitude;
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
toX = from.x - magnitude;
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
toX = from.x + magnitude;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
kind: "Swipe",
|
|
||||||
from,
|
|
||||||
to: { x: Math.max(0, toX), y: Math.max(0, toY) },
|
|
||||||
durationMillis: SWIPE_DURATION_MILLIS,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
// identical action stream by construction.
|
// identical action stream by construction.
|
||||||
|
|
||||||
import { Pcg } from "./pcg.ts";
|
import { Pcg } from "./pcg.ts";
|
||||||
import { nextAction, walk } from "./pick.ts";
|
import { builtinCandidates, nextAction, walk } from "./pick.ts";
|
||||||
import { INPUT_CORPUS } from "./corpus.ts";
|
import { INPUT_CORPUS } from "./corpus.ts";
|
||||||
import type { ActionDescriptor, GeneratorNode, Host } from "./action-tree.ts";
|
import type { ActionDescriptor, BuiltinVerb, GeneratorNode, Host } from "./action-tree.ts";
|
||||||
import type { Point } from "./types.ts";
|
import type { Point } from "./types.ts";
|
||||||
|
|
||||||
// SerializedAction is the flat, camelCase wire shape JS emits and Go decodes
|
// SerializedAction is the flat, camelCase wire shape JS emits and Go decodes
|
||||||
@@ -138,6 +138,16 @@ export function installRuntime(
|
|||||||
"__sanderlingSampleInput__",
|
"__sanderlingSampleInput__",
|
||||||
() => INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "",
|
() => INPUT_CORPUS[rng.intN(INPUT_CORPUS.length)] ?? "",
|
||||||
);
|
);
|
||||||
|
// The model policy (Go) selects from the SAME enumeration the seeded picker
|
||||||
|
// draws from, reached through here rather than reimplemented on the Go side.
|
||||||
|
// Each entry is serialized with the wire contract Go already decodes, so the
|
||||||
|
// two policies also agree on the action a chosen candidate executes.
|
||||||
|
defineLockedGlobal("__sanderlingEnumerateBuiltin__", (verb: BuiltinVerb) =>
|
||||||
|
builtinCandidates(verb, host).map((candidate) => ({
|
||||||
|
action: serializeAction(candidate.action),
|
||||||
|
targetIndex: candidate.targetIndex,
|
||||||
|
})),
|
||||||
|
);
|
||||||
defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors());
|
defineLockedGlobal("__sanderlingExtractors__", () => evaluateExtractors());
|
||||||
// __sanderlingSetupAction__ walks ONLY the setup generator once, for the LLM
|
// __sanderlingSetupAction__ walks ONLY the setup generator once, for the LLM
|
||||||
// action generator (Go), which drives selection itself and must not run the
|
// action generator (Go), which drives selection itself and must not run the
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Per-verb target eligibility: the ONE definition both hosts consume.
|
||||||
|
//
|
||||||
|
// A host enumerates every element it can offer (the goja verifier over the
|
||||||
|
// hierarchy tree, the V8 web runtime over the DOM) and reports a fixed set of
|
||||||
|
// facts about each. It does NOT decide which verb may act on which element:
|
||||||
|
// acceptsTarget does, here, once. When each host routed verbs itself the two
|
||||||
|
// drifted, and the same spec induced a different action space per platform --
|
||||||
|
// web sent `swipes` to scrollable containers only, so swipe-to-dismiss on a
|
||||||
|
// list row was reachable on native and unreachable on web.
|
||||||
|
//
|
||||||
|
// What stays platform-specific is how a fact is COMPUTED, because the source
|
||||||
|
// models genuinely differ: `clickable` is an accessibility attribute on
|
||||||
|
// Android/iOS and a CSS selector match on web. The fact vocabulary below is the
|
||||||
|
// contract; the mapping onto it is each host's business, and it is the only
|
||||||
|
// place the platforms are allowed to disagree.
|
||||||
|
|
||||||
|
import type { BuiltinVerb, TargetElement } from "./action-tree.ts";
|
||||||
|
|
||||||
|
// TargetFact names one property of a target a verb can require.
|
||||||
|
export type TargetFact =
|
||||||
|
| "clickable"
|
||||||
|
| "enabled"
|
||||||
|
| "editable"
|
||||||
|
| "scrollable"
|
||||||
|
| "positiveBounds";
|
||||||
|
|
||||||
|
// VERB_REQUIRED_FACTS lists the facts a target must have for a verb to act on
|
||||||
|
// it. `null` marks a verb with no target at all: a key press and a wait are
|
||||||
|
// enumerated by the picker without consulting the host.
|
||||||
|
const VERB_REQUIRED_FACTS: Record<BuiltinVerb, readonly TargetFact[] | null> = {
|
||||||
|
taps: ["clickable", "enabled", "positiveBounds"],
|
||||||
|
doubleTaps: ["clickable", "enabled", "positiveBounds"],
|
||||||
|
longPresses: ["clickable", "enabled", "positiveBounds"],
|
||||||
|
typing: ["editable", "enabled", "positiveBounds"],
|
||||||
|
// Scrolls target containers that can actually scroll, and enumerate down/up.
|
||||||
|
scrolls: ["scrollable", "positiveBounds"],
|
||||||
|
// Any visible element is a valid swipe origin. Swipe-to-dismiss and
|
||||||
|
// swipe-to-delete live on list rows and cards, which are not scrollable
|
||||||
|
// containers, so scoping swipes to containers would put that whole class of
|
||||||
|
// interaction out of reach.
|
||||||
|
swipes: ["positiveBounds"],
|
||||||
|
pressKeys: null,
|
||||||
|
waitOnce: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// acceptsTarget is the per-verb element filter. Every targeted verb demands
|
||||||
|
// real bounds: a zero-bounds node centers at (0,0), and a downward gesture from
|
||||||
|
// the top-left corner is the system gesture that pulls down the notification
|
||||||
|
// shade, dragging the fuzzer out of the app.
|
||||||
|
export function acceptsTarget(verb: BuiltinVerb, target: TargetElement): boolean {
|
||||||
|
const required = VERB_REQUIRED_FACTS[verb];
|
||||||
|
if (required === null) return false;
|
||||||
|
return required.every((fact) => hasFact(target, fact));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFact(target: TargetElement, fact: TargetFact): boolean {
|
||||||
|
switch (fact) {
|
||||||
|
case "clickable":
|
||||||
|
return target.clickable;
|
||||||
|
case "enabled":
|
||||||
|
return target.enabled;
|
||||||
|
case "editable":
|
||||||
|
return target.editable;
|
||||||
|
case "scrollable":
|
||||||
|
return target.scrollable;
|
||||||
|
case "positiveBounds":
|
||||||
|
return (target.width ?? 0) > 0 && (target.height ?? 0) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-72
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// This file is the WEB Host. It installs globalThis.__sanderling__ (extract +
|
// This file is the WEB Host. It installs globalThis.__sanderling__ (extract +
|
||||||
// LTL formula binds) before the spec evaluates, implements the Host interface
|
// LTL formula binds) before the spec evaluates, implements the Host interface
|
||||||
// (platform/seed/queryCandidates/reportUnsupported) over the live DOM, and then
|
// (platform/seed/queryTargets/reportUnsupported) over the live DOM, and then
|
||||||
// delegates ALL action generation to the shared picker via installRuntime
|
// delegates ALL action generation to the shared picker via installRuntime
|
||||||
// (runtime-entry.ts -> pick.ts). The goja verifier runs the SAME picker over the
|
// (runtime-entry.ts -> pick.ts). The goja verifier runs the SAME picker over the
|
||||||
// SAME Pcg, so a given seed yields an identical action stream by construction.
|
// SAME Pcg, so a given seed yields an identical action stream by construction.
|
||||||
@@ -13,11 +13,11 @@
|
|||||||
// window.__sanderlingNextAction__() over CDP each tick. LTL predicates are
|
// window.__sanderlingNextAction__() over CDP each tick. LTL predicates are
|
||||||
// stubbed: properties run host-side in goja, which loads its own bundle.
|
// stubbed: properties run host-side in goja, which loads its own bundle.
|
||||||
//
|
//
|
||||||
// Element references never cross V8/host. queryCandidates resolves each element
|
// Element references never cross V8/host. queryTargets resolves each element to
|
||||||
// to a {x, y} Point via getBoundingClientRect before the picker sees it.
|
// a {x, y} Point via getBoundingClientRect before the picker sees it.
|
||||||
|
|
||||||
import { installRuntime } from "./runtime-entry.ts";
|
import { installRuntime } from "./runtime-entry.ts";
|
||||||
import type { BuiltinVerb, Candidate, Host } from "./action-tree.ts";
|
import type { BuiltinVerb, Candidate, Host, TargetElement } from "./action-tree.ts";
|
||||||
|
|
||||||
interface Handle {
|
interface Handle {
|
||||||
readonly current: unknown;
|
readonly current: unknown;
|
||||||
@@ -467,9 +467,12 @@ function sanitizeAt(value: unknown, depth: number, seen: WeakSet<object>): unkno
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-verb DOM selector sets. The tappable set backs taps/doubleTaps/longPresses
|
// The DOM half of the fact mapping. Which verb may act on which target is NOT
|
||||||
// (every tappable element is also a valid long-press target); the editable set
|
// decided here: queryTargets reports facts and targets.ts acceptsTarget applies
|
||||||
// backs typing; swipes/scrolls target the scrollable element.
|
// them, the same rule the native host's targets run through. These selectors are
|
||||||
|
// only how the DOM answers "is this clickable" / "is this editable", the two
|
||||||
|
// facts with no direct DOM equivalent of the accessibility attributes native
|
||||||
|
// platforms expose.
|
||||||
const TAPPABLE_SELECTOR = 'a, button, input, select, textarea, [role="button"], [onclick]';
|
const TAPPABLE_SELECTOR = 'a, button, input, select, textarea, [role="button"], [onclick]';
|
||||||
const EDITABLE_SELECTOR = "input, textarea, [contenteditable]";
|
const EDITABLE_SELECTOR = "input, textarea, [contenteditable]";
|
||||||
|
|
||||||
@@ -477,12 +480,6 @@ const NON_TEXT_INPUT_TYPES = [
|
|||||||
"button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset",
|
"button", "submit", "checkbox", "radio", "range", "color", "file", "image", "reset",
|
||||||
];
|
];
|
||||||
|
|
||||||
function isVisible(element: HTMLElement): boolean {
|
|
||||||
if ((element as HTMLButtonElement).disabled) return false;
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && rect.height > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEditableElement(element: HTMLElement): boolean {
|
function isEditableElement(element: HTMLElement): boolean {
|
||||||
if (element.isContentEditable) return true;
|
if (element.isContentEditable) return true;
|
||||||
const tag = element.tagName.toLowerCase();
|
const tag = element.tagName.toLowerCase();
|
||||||
@@ -494,6 +491,14 @@ function isEditableElement(element: HTMLElement): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isScrollable mirrors the native `scrollable` accessibility attribute: the
|
||||||
|
// container can actually scroll, i.e. its content overflows its box. The
|
||||||
|
// document scrolling root is not special-cased in: when the page does not
|
||||||
|
// overflow there is no scroll to perform, and native would offer none either.
|
||||||
|
function isScrollable(element: HTMLElement): boolean {
|
||||||
|
return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
|
||||||
|
}
|
||||||
|
|
||||||
function pointOf(element: Element): Candidate {
|
function pointOf(element: Element): Candidate {
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
return {
|
return {
|
||||||
@@ -504,41 +509,33 @@ function pointOf(element: Element): Candidate {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tappableCandidates(): Candidate[] {
|
// collectTargets walks the document ONCE and reports every element with the facts
|
||||||
return Array.from(document.querySelectorAll<HTMLElement>(TAPPABLE_SELECTOR))
|
// the shared eligibility rule reads. The tappable/editable membership sets are
|
||||||
.filter(isVisible)
|
// resolved by selector first so the DOM's answer to "clickable" and "editable"
|
||||||
.map(pointOf);
|
// stays expressed in CSS, as it always was.
|
||||||
|
function collectTargets(): TargetElement[] {
|
||||||
|
const clickable = new Set<Element>(Array.from(document.querySelectorAll(TAPPABLE_SELECTOR)));
|
||||||
|
const editable = new Set<Element>(
|
||||||
|
Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR)).filter(
|
||||||
|
isEditableElement,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Array.from(document.querySelectorAll<HTMLElement>("*")).map((element) => ({
|
||||||
|
...pointOf(element),
|
||||||
|
clickable: clickable.has(element),
|
||||||
|
enabled: !(element as HTMLButtonElement).disabled,
|
||||||
|
editable: editable.has(element),
|
||||||
|
scrollable: isScrollable(element),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function editableCandidates(): Candidate[] {
|
// Per-tick target cache: the picker's 16-attempt retry re-queries every tick, so
|
||||||
return Array.from(document.querySelectorAll<HTMLElement>(EDITABLE_SELECTOR))
|
// we avoid re-walking the DOM and re-flushing layout within one tick.
|
||||||
.filter((element) => isEditableElement(element) && isVisible(element))
|
|
||||||
.map(pointOf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scrollable candidates back swipe/scroll: elements that overflow their box,
|
|
||||||
// plus the scrolling root so a page-level scroll always has a target.
|
|
||||||
function scrollableCandidates(): Candidate[] {
|
|
||||||
const root = document.scrollingElement ?? document.documentElement;
|
|
||||||
const candidates: Candidate[] = root ? [pointOf(root)] : [];
|
|
||||||
for (const element of Array.from(document.querySelectorAll<HTMLElement>("*"))) {
|
|
||||||
if (element === root) continue;
|
|
||||||
if (element.scrollHeight <= element.clientHeight && element.scrollWidth <= element.clientWidth) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isVisible(element)) continue;
|
|
||||||
candidates.push(pointOf(element));
|
|
||||||
}
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-tick candidate cache: the picker's 16-attempt retry re-queries the same
|
|
||||||
// verb, so we avoid re-walking the DOM and re-flushing layout within one tick.
|
|
||||||
// installRuntime resets it before each __sanderlingNextAction__ invocation.
|
// installRuntime resets it before each __sanderlingNextAction__ invocation.
|
||||||
const candidateCache = new Map<BuiltinVerb, Candidate[]>();
|
let cachedTargets: TargetElement[] | null = null;
|
||||||
|
|
||||||
function resetCandidateCache(): void {
|
function resetTargetCache(): void {
|
||||||
candidateCache.clear();
|
cachedTargets = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const host: Host = {
|
const host: Host = {
|
||||||
@@ -546,28 +543,9 @@ const host: Host = {
|
|||||||
seedHi: () => SEED_HI,
|
seedHi: () => SEED_HI,
|
||||||
// lo = 0 matches the goja side's rand.NewPCG(seed, 0).
|
// lo = 0 matches the goja side's rand.NewPCG(seed, 0).
|
||||||
seedLo: () => 0n,
|
seedLo: () => 0n,
|
||||||
queryCandidates(verb: BuiltinVerb): Candidate[] {
|
queryTargets(): TargetElement[] {
|
||||||
const cached = candidateCache.get(verb);
|
if (!cachedTargets) cachedTargets = collectTargets();
|
||||||
if (cached) return cached;
|
return cachedTargets;
|
||||||
let candidates: Candidate[];
|
|
||||||
switch (verb) {
|
|
||||||
case "taps":
|
|
||||||
case "doubleTaps":
|
|
||||||
case "longPresses":
|
|
||||||
candidates = tappableCandidates();
|
|
||||||
break;
|
|
||||||
case "typing":
|
|
||||||
candidates = editableCandidates();
|
|
||||||
break;
|
|
||||||
case "swipes":
|
|
||||||
case "scrolls":
|
|
||||||
candidates = scrollableCandidates();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
candidates = [];
|
|
||||||
}
|
|
||||||
candidateCache.set(verb, candidates);
|
|
||||||
return candidates;
|
|
||||||
},
|
},
|
||||||
reportUnsupported(verb: BuiltinVerb): void {
|
reportUnsupported(verb: BuiltinVerb): void {
|
||||||
console.warn(`[sanderling] verb ${verb} is unsupported on web`);
|
console.warn(`[sanderling] verb ${verb} is unsupported on web`);
|
||||||
@@ -578,11 +556,11 @@ const host: Host = {
|
|||||||
// this module (the web bundle imports the runtime first). Resolve the root
|
// this module (the web bundle imports the runtime first). Resolve the root
|
||||||
// lazily so installRuntime captures it once the spec has evaluated. The root
|
// lazily so installRuntime captures it once the spec has evaluated. The root
|
||||||
// resolver runs once per __sanderlingNextAction__ tick (before the retry loop),
|
// resolver runs once per __sanderlingNextAction__ tick (before the retry loop),
|
||||||
// so it is also where we reset the per-tick candidate cache.
|
// so it is also where we reset the per-tick target cache.
|
||||||
installRuntime(
|
installRuntime(
|
||||||
host,
|
host,
|
||||||
() => {
|
() => {
|
||||||
resetCandidateCache();
|
resetTargetCache();
|
||||||
return (globalThis as { actions?: import("./action-tree.ts").GeneratorNode }).actions ?? null;
|
return (globalThis as { actions?: import("./action-tree.ts").GeneratorNode }).actions ?? null;
|
||||||
},
|
},
|
||||||
evaluateExtractors,
|
evaluateExtractors,
|
||||||
@@ -593,10 +571,10 @@ installRuntime(
|
|||||||
export const __testing__ = {
|
export const __testing__ = {
|
||||||
host,
|
host,
|
||||||
seedBigInt,
|
seedBigInt,
|
||||||
tappableCandidates,
|
collectTargets,
|
||||||
editableCandidates,
|
TAPPABLE_SELECTOR,
|
||||||
scrollableCandidates,
|
EDITABLE_SELECTOR,
|
||||||
resetCandidateCache,
|
resetTargetCache,
|
||||||
runtime,
|
runtime,
|
||||||
extractors,
|
extractors,
|
||||||
evaluateExtractors,
|
evaluateExtractors,
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ import type {
|
|||||||
function installRuntime(initialState: State): void {
|
function installRuntime(initialState: State): void {
|
||||||
const state = { current: initialState };
|
const state = { current: initialState };
|
||||||
const runtime: SanderlingRuntime = {
|
const runtime: SanderlingRuntime = {
|
||||||
extract: <T>(getter: (s: State) => T): Extracted<T> => ({
|
extract: <T>(getter: (s: State) => T): Extracted<T> => {
|
||||||
|
const handle: Extracted<T> = {
|
||||||
current: getter(state.current),
|
current: getter(state.current),
|
||||||
previous: undefined,
|
previous: undefined,
|
||||||
}),
|
named: () => handle,
|
||||||
|
};
|
||||||
|
return handle;
|
||||||
|
},
|
||||||
always: () => ({ __sanderlingFormula: true } as Formula),
|
always: () => ({ __sanderlingFormula: true } as Formula),
|
||||||
now: () => ({ __sanderlingFormula: true } as Formula),
|
now: () => ({ __sanderlingFormula: true } as Formula),
|
||||||
next: () => ({ __sanderlingFormula: true } as Formula),
|
next: () => ({ __sanderlingFormula: true } as Formula),
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"taps": [
|
||||||
|
"save"
|
||||||
|
],
|
||||||
|
"doubleTaps": [
|
||||||
|
"save"
|
||||||
|
],
|
||||||
|
"longPresses": [
|
||||||
|
"save"
|
||||||
|
],
|
||||||
|
"typing": [
|
||||||
|
"amount"
|
||||||
|
],
|
||||||
|
"scrolls": [
|
||||||
|
"root",
|
||||||
|
"root",
|
||||||
|
"list",
|
||||||
|
"list"
|
||||||
|
],
|
||||||
|
"swipes": [
|
||||||
|
"root",
|
||||||
|
"root",
|
||||||
|
"root",
|
||||||
|
"root",
|
||||||
|
"save",
|
||||||
|
"save",
|
||||||
|
"save",
|
||||||
|
"save",
|
||||||
|
"cancel",
|
||||||
|
"cancel",
|
||||||
|
"cancel",
|
||||||
|
"cancel",
|
||||||
|
"amount",
|
||||||
|
"amount",
|
||||||
|
"amount",
|
||||||
|
"amount",
|
||||||
|
"list",
|
||||||
|
"list",
|
||||||
|
"list",
|
||||||
|
"list",
|
||||||
|
"row",
|
||||||
|
"row",
|
||||||
|
"row",
|
||||||
|
"row"
|
||||||
|
],
|
||||||
|
"pressKeys": [],
|
||||||
|
"waitOnce": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
import { builtinCandidates } from "../src/pick.ts";
|
||||||
|
import { resetWarnings } from "../src/verbs.ts";
|
||||||
|
import type { BuiltinVerb } from "../src/action-tree.ts";
|
||||||
|
import { fakeElement, withFakeDocument, type FakeElementSpec } from "./web-dom-harness.ts";
|
||||||
|
import { __testing__ } from "../src/web-runtime.ts";
|
||||||
|
|
||||||
|
const { host } = __testing__;
|
||||||
|
|
||||||
|
const goldenPath = fileURLToPath(new URL("./fixtures/host-parity-golden.json", import.meta.url));
|
||||||
|
const golden: Record<string, string[]> = JSON.parse(readFileSync(goldenPath, "utf8"));
|
||||||
|
|
||||||
|
const VERBS: BuiltinVerb[] = [
|
||||||
|
"taps",
|
||||||
|
"doubleTaps",
|
||||||
|
"longPresses",
|
||||||
|
"typing",
|
||||||
|
"scrolls",
|
||||||
|
"swipes",
|
||||||
|
"pressKeys",
|
||||||
|
"waitOnce",
|
||||||
|
];
|
||||||
|
|
||||||
|
// SCREEN is the canonical screen both hosts are driven over: one row per fact
|
||||||
|
// combination that any verb distinguishes. The native host builds the same rows,
|
||||||
|
// in the same order, as a hierarchy tree in
|
||||||
|
// internal/verifier/host_parity_test.go.
|
||||||
|
const SCREEN: (FakeElementSpec & { name: string })[] = [
|
||||||
|
{ name: "root", tag: "html", x: 0, y: 0, width: 400, height: 800, overflows: true },
|
||||||
|
{ name: "save", tag: "button", x: 0, y: 0, width: 200, height: 60, clickable: true },
|
||||||
|
{
|
||||||
|
name: "cancel",
|
||||||
|
tag: "button",
|
||||||
|
x: 200,
|
||||||
|
y: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
clickable: true,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{ name: "amount", tag: "input", x: 0, y: 100, width: 400, height: 60, editable: true },
|
||||||
|
{ name: "list", tag: "div", x: 0, y: 200, width: 400, height: 400, overflows: true },
|
||||||
|
{ name: "row", tag: "div", x: 0, y: 600, width: 400, height: 80 },
|
||||||
|
{ name: "collapsed", tag: "button", x: 0, y: 0, width: 0, height: 0, clickable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// The web host and the native host used to route verbs themselves and had
|
||||||
|
// drifted: web sent `swipes` to scrollable containers only, so a swipe on a list
|
||||||
|
// row was reachable on Android and unreachable on web for the same spec.
|
||||||
|
//
|
||||||
|
// Per-verb eligibility now has ONE definition (src/targets.ts); a host reports
|
||||||
|
// facts and never filters. This test is what notices if a second definition
|
||||||
|
// grows back on either side. internal/verifier/host_parity_test.go asserts the
|
||||||
|
// SAME golden from the native host, so a match on both sides proves the two
|
||||||
|
// hosts agree without either invoking the other.
|
||||||
|
test("web host targets match the cross-host golden, verb for verb", () => {
|
||||||
|
const elements = SCREEN.map(fakeElement);
|
||||||
|
withFakeDocument(elements, () => {
|
||||||
|
for (const verb of VERBS) {
|
||||||
|
resetWarnings();
|
||||||
|
const candidates = builtinCandidates(verb, host);
|
||||||
|
assert.notEqual(candidates.length, 0, `${verb} enumerated nothing at all`);
|
||||||
|
const named = candidates
|
||||||
|
.filter((candidate) => candidate.targetIndex >= 0)
|
||||||
|
.map((candidate) => SCREEN[candidate.targetIndex]!.name);
|
||||||
|
assert.deepEqual(named, golden[verb], `web host targets for ${verb}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
// Shared cross-runtime parity scenario: the FIXED seed, FIXED candidate list,
|
// Shared cross-runtime parity scenario: the FIXED seed, FIXED target list,
|
||||||
// and FIXED action root that both the node test (parity.test.ts) and the goja
|
// and FIXED action root that both the node test (parity.test.ts) and the goja
|
||||||
// test (internal/verifier/parity_test.go) drive. Each side runs the SAME
|
// test (internal/verifier/parity_test.go) drive. Each side runs the SAME
|
||||||
// pick.ts over the SAME Pcg and asserts the SAME committed golden
|
// pick.ts over the SAME Pcg and asserts the SAME committed golden
|
||||||
// (fixtures/parity-golden.json); matching one golden on both sides proves the
|
// (fixtures/parity-golden.json); matching one golden on both sides proves the
|
||||||
// two engines agree without either invoking the other.
|
// two engines agree without either invoking the other.
|
||||||
//
|
//
|
||||||
// The candidate ORDER and the per-tick PCG draw order are the parity contract.
|
// The target ORDER and the per-tick PCG draw order are the parity contract.
|
||||||
// The weighted root mixes a tap branch (1 candidate draw) with a typing branch
|
// The weighted root mixes a tap branch (1 candidate draw) with a typing branch
|
||||||
// (1 candidate draw + 1 corpus draw), so a tick exercises weighted selection, a
|
// (1 candidate draw + 1 corpus draw), so a tick exercises weighted selection, a
|
||||||
// builtin, and the input corpus together; reordering candidates or adding or
|
// builtin, and the input corpus together; reordering candidates or adding or
|
||||||
@@ -15,25 +15,29 @@ import { Pcg } from "../src/pcg.ts";
|
|||||||
import { nextAction } from "../src/pick.ts";
|
import { nextAction } from "../src/pick.ts";
|
||||||
import { serializeAction, type SerializedAction } from "../src/runtime-entry.ts";
|
import { serializeAction, type SerializedAction } from "../src/runtime-entry.ts";
|
||||||
import { taps, typing, weighted } from "../src/actions.ts";
|
import { taps, typing, weighted } from "../src/actions.ts";
|
||||||
import type { BuiltinVerb, Candidate, GeneratorNode, Host } from "../src/action-tree.ts";
|
import type { GeneratorNode, Host, TargetElement } from "../src/action-tree.ts";
|
||||||
|
|
||||||
export const PARITY_SEED_HI = 0x9e3779b97f4a7c15n;
|
export const PARITY_SEED_HI = 0x9e3779b97f4a7c15n;
|
||||||
export const PARITY_STEPS = 20;
|
export const PARITY_STEPS = 20;
|
||||||
|
|
||||||
export const PARITY_CANDIDATES: Candidate[] = [
|
// Every fact is set so the shared eligibility rule admits all three targets for
|
||||||
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40 },
|
// every verb, leaving the draw order as the only variable.
|
||||||
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48 },
|
const EVERY_FACT = { clickable: true, enabled: true, editable: true, scrollable: true };
|
||||||
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32 },
|
|
||||||
|
export const PARITY_TARGETS: TargetElement[] = [
|
||||||
|
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40, ...EVERY_FACT },
|
||||||
|
{ x: 150, y: 160, selector: "id:beta", width: 120, height: 48, ...EVERY_FACT },
|
||||||
|
{ x: 250, y: 260, selector: "id:gamma", width: 80, height: 32, ...EVERY_FACT },
|
||||||
];
|
];
|
||||||
|
|
||||||
// A 3:1 weighted split over taps and typing. The stub host returns the same
|
// A 3:1 weighted split over taps and typing. The stub host offers the same
|
||||||
// candidate list for every verb so the only variables are the draw order and
|
// target list to every verb so the only variables are the draw order and the JS
|
||||||
// the JS engine's number/bigint behavior.
|
// engine's number/bigint behavior.
|
||||||
export const PARITY_ROOT: GeneratorNode = weighted([3, taps], [1, typing]);
|
export const PARITY_ROOT: GeneratorNode = weighted([3, taps], [1, typing]);
|
||||||
|
|
||||||
const HOST: Host = {
|
const HOST: Host = {
|
||||||
platform: () => "android",
|
platform: () => "android",
|
||||||
queryCandidates: (_verb: BuiltinVerb) => PARITY_CANDIDATES,
|
queryTargets: () => PARITY_TARGETS,
|
||||||
reportUnsupported: () => {},
|
reportUnsupported: () => {},
|
||||||
seedHi: () => PARITY_SEED_HI,
|
seedHi: () => PARITY_SEED_HI,
|
||||||
seedLo: () => 0n,
|
seedLo: () => 0n,
|
||||||
|
|||||||
+241
-60
@@ -1,30 +1,31 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { Pcg } from "../src/pcg.ts";
|
import { Pcg } from "../src/pcg.ts";
|
||||||
import { nextAction, walk } from "../src/pick.ts";
|
import { builtinCandidates, nextAction, walk } from "../src/pick.ts";
|
||||||
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "../src/corpus.ts";
|
import { INPUT_CORPUS, NATIVE_PRESS_KEYS, WEB_PRESS_KEYS } from "../src/corpus.ts";
|
||||||
import { resetWarnings } from "../src/verbs.ts";
|
import { resetWarnings } from "../src/verbs.ts";
|
||||||
import type {
|
import type {
|
||||||
ActionDescriptor,
|
ActionDescriptor,
|
||||||
BuiltinVerb,
|
BuiltinVerb,
|
||||||
Candidate,
|
|
||||||
GeneratorNode,
|
GeneratorNode,
|
||||||
Host,
|
Host,
|
||||||
|
TargetElement,
|
||||||
} from "../src/action-tree.ts";
|
} from "../src/action-tree.ts";
|
||||||
|
import type { Direction, Point } from "../src/types.ts";
|
||||||
|
|
||||||
type Platform = "android" | "ios" | "web";
|
type Platform = "android" | "ios" | "web";
|
||||||
|
|
||||||
// stubHost returns a fixed candidate list for every verb and records each
|
// stubHost returns a fixed target list, eligible for every verb, and records
|
||||||
// reportUnsupported call so warn-once semantics are observable.
|
// each reportUnsupported call so warn-once semantics are observable.
|
||||||
function stubHost(
|
function stubHost(
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
candidates: Candidate[],
|
targets: TargetElement[],
|
||||||
): Host & { unsupported: BuiltinVerb[] } {
|
): Host & { unsupported: BuiltinVerb[] } {
|
||||||
const unsupported: BuiltinVerb[] = [];
|
const unsupported: BuiltinVerb[] = [];
|
||||||
return {
|
return {
|
||||||
unsupported,
|
unsupported,
|
||||||
platform: () => platform,
|
platform: () => platform,
|
||||||
queryCandidates: () => candidates,
|
queryTargets: () => targets,
|
||||||
reportUnsupported: (verb) => {
|
reportUnsupported: (verb) => {
|
||||||
unsupported.push(verb);
|
unsupported.push(verb);
|
||||||
},
|
},
|
||||||
@@ -33,10 +34,12 @@ function stubHost(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const POINTS: Candidate[] = [
|
const EVERY_FACT = { clickable: true, enabled: true, editable: true, scrollable: true };
|
||||||
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200 },
|
|
||||||
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200 },
|
const POINTS: TargetElement[] = [
|
||||||
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200 },
|
{ x: 10, y: 20, selector: "id:a", width: 100, height: 200, ...EVERY_FACT },
|
||||||
|
{ x: 30, y: 40, selector: "id:b", width: 100, height: 200, ...EVERY_FACT },
|
||||||
|
{ x: 50, y: 60, selector: "id:c", width: 100, height: 200, ...EVERY_FACT },
|
||||||
];
|
];
|
||||||
|
|
||||||
function builtin(verb: BuiltinVerb): GeneratorNode {
|
function builtin(verb: BuiltinVerb): GeneratorNode {
|
||||||
@@ -81,59 +84,132 @@ test("typing draws candidate index then corpus index, in that order", () => {
|
|||||||
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
|
assert.equal(action.text, INPUT_CORPUS[corpusIndex]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("swipes draw candidate, magnitude (200+intN(401)), then direction", () => {
|
// scrollCandidate mirrors what the picker builds for one (container, direction)
|
||||||
|
// pair: a drag opposite the named content motion, 40% of the container extent.
|
||||||
|
function scrollCandidate(target: TargetElement, direction: "down" | "up") {
|
||||||
|
const from = { x: target.x, y: target.y };
|
||||||
|
const extent = Math.trunc((4 * (target.height ?? 0)) / 10);
|
||||||
|
const toY = direction === "down" ? from.y - extent : from.y + extent;
|
||||||
|
return {
|
||||||
|
kind: "Scroll",
|
||||||
|
direction,
|
||||||
|
in: from,
|
||||||
|
from,
|
||||||
|
to: { x: from.x, y: Math.max(0, toY) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// swipeCandidate mirrors the free-form drag: a raw pixel distance, with the
|
||||||
|
// direction naming where the finger travels.
|
||||||
|
function swipeCandidate(
|
||||||
|
target: Point,
|
||||||
|
direction: Direction,
|
||||||
|
magnitude: number,
|
||||||
|
) {
|
||||||
|
const from = { x: target.x, y: target.y };
|
||||||
|
const horizontal = direction === "left" || direction === "right";
|
||||||
|
const forward = direction === "down" || direction === "right";
|
||||||
|
const travel = forward ? magnitude : -magnitude;
|
||||||
|
return {
|
||||||
|
kind: "Swipe",
|
||||||
|
from,
|
||||||
|
to: horizontal
|
||||||
|
? { x: Math.max(0, from.x + travel), y: from.y }
|
||||||
|
: { x: from.x, y: Math.max(0, from.y + travel) },
|
||||||
|
durationMillis: 250,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOMINAL_SWIPE_MAGNITUDE = 400;
|
||||||
|
|
||||||
|
const SCROLL_DIRECTIONS = ["down", "up"] as const;
|
||||||
|
const SWIPE_DIRECTIONS = ["down", "up", "left", "right"] as const;
|
||||||
|
|
||||||
|
// swipeDirection recovers which way a drawn swipe travelled from its endpoints.
|
||||||
|
function swipeDirection(from: Point, to: Point): Direction {
|
||||||
|
if (to.x !== from.x) return to.x > from.x ? "right" : "left";
|
||||||
|
return to.y > from.y ? "down" : "up";
|
||||||
|
}
|
||||||
|
|
||||||
|
test("scrolls enumerate every container up and down only", () => {
|
||||||
resetWarnings();
|
resetWarnings();
|
||||||
const host = stubHost("android", POINTS);
|
const host = stubHost("android", POINTS);
|
||||||
const rng = new Pcg(7n, 0n);
|
assert.deepEqual(
|
||||||
const oracle = new Pcg(7n, 0n);
|
builtinCandidates("scrolls", host),
|
||||||
const candidateIndex = oracle.intN(POINTS.length);
|
POINTS.flatMap((target, targetIndex) =>
|
||||||
const magnitude = 200 + oracle.intN(401);
|
SCROLL_DIRECTIONS.map((direction) => ({
|
||||||
const direction = oracle.intN(4);
|
action: scrollCandidate(target, direction),
|
||||||
|
targetIndex,
|
||||||
const from = { x: POINTS[candidateIndex]!.x, y: POINTS[candidateIndex]!.y };
|
})),
|
||||||
const expectedTo = { x: from.x, y: from.y };
|
),
|
||||||
switch (direction) {
|
);
|
||||||
case 0:
|
|
||||||
expectedTo.y = Math.max(0, from.y - magnitude);
|
|
||||||
break;
|
|
||||||
case 1:
|
|
||||||
expectedTo.y = Math.max(0, from.y + magnitude);
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
expectedTo.x = Math.max(0, from.x - magnitude);
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
expectedTo.x = Math.max(0, from.x + magnitude);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const action = walk(builtin("swipes"), rng, host) as ActionDescriptor & {
|
|
||||||
kind: "Swipe";
|
|
||||||
};
|
|
||||||
assert.equal(action.kind, "Swipe");
|
|
||||||
assert.deepEqual(action.from, from);
|
|
||||||
assert.deepEqual(action.to, expectedTo);
|
|
||||||
assert.equal(action.durationMillis, 250);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("scrolls draw candidate index then direction", () => {
|
test("swipes enumerate a free-form drag per target in all four directions", () => {
|
||||||
resetWarnings();
|
resetWarnings();
|
||||||
const host = stubHost("android", POINTS);
|
const host = stubHost("android", POINTS);
|
||||||
const rng = new Pcg(99n, 0n);
|
// The swipe candidate is its own action shape, sized in raw pixels rather than
|
||||||
const oracle = new Pcg(99n, 0n);
|
// off the target's extent, and it carries what the policy needs to redraw the
|
||||||
const candidateIndex = oracle.intN(POINTS.length);
|
// distance. It is not the scroll gesture under a second name.
|
||||||
const directionIndex = oracle.intN(4);
|
assert.deepEqual(
|
||||||
const directions = ["up", "down", "left", "right"] as const;
|
builtinCandidates("swipes", host),
|
||||||
|
POINTS.flatMap((target, targetIndex) =>
|
||||||
|
SWIPE_DIRECTIONS.map((direction) => ({
|
||||||
|
action: swipeCandidate(target, direction, NOMINAL_SWIPE_MAGNITUDE),
|
||||||
|
targetIndex,
|
||||||
|
swipe: { origin: { x: target.x, y: target.y }, direction },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const action = walk(builtin("scrolls"), rng, host) as ActionDescriptor & {
|
test("the gesture verbs differ in target filter and in direction set", () => {
|
||||||
kind: "Scroll";
|
resetWarnings();
|
||||||
};
|
// Same host, same targets: what separates the two verbs here is the direction
|
||||||
assert.equal(action.kind, "Scroll");
|
// set alone. Scrolls stay vertical because every scrollable container gets a
|
||||||
assert.equal(action.direction, directions[directionIndex]);
|
// candidate; swipes reach sideways because swipe-to-dismiss does.
|
||||||
assert.deepEqual(action.in, {
|
const host = stubHost("android", POINTS);
|
||||||
x: POINTS[candidateIndex]!.x,
|
const scrolls = builtinCandidates("scrolls", host);
|
||||||
y: POINTS[candidateIndex]!.y,
|
const swipes = builtinCandidates("swipes", host);
|
||||||
});
|
|
||||||
|
const scrollDirections = new Set(
|
||||||
|
scrolls.map(
|
||||||
|
(entry) => (entry.action as ActionDescriptor & { kind: "Scroll" }).direction,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const swipeDirections = new Set(swipes.map((entry) => entry.swipe!.direction));
|
||||||
|
|
||||||
|
assert.deepEqual([...scrollDirections].sort(), ["down", "up"]);
|
||||||
|
assert.deepEqual([...swipeDirections].sort(), ["down", "left", "right", "up"]);
|
||||||
|
assert.equal(scrolls.length, POINTS.length * 2);
|
||||||
|
assert.equal(swipes.length, POINTS.length * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("scrolls draw one index over the enumerated candidates", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const enumerated = builtinCandidates("scrolls", host);
|
||||||
|
const oracle = new Pcg(99n, 0n);
|
||||||
|
const index = oracle.intN(enumerated.length);
|
||||||
|
|
||||||
|
const action = walk(builtin("scrolls"), new Pcg(99n, 0n), host);
|
||||||
|
assert.deepEqual(action, enumerated[index]!.action);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("swipes draw candidate index then magnitude, in that order", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const enumerated = builtinCandidates("swipes", host);
|
||||||
|
const oracle = new Pcg(7n, 0n);
|
||||||
|
const index = oracle.intN(enumerated.length);
|
||||||
|
const magnitude = 200 + oracle.intN(401);
|
||||||
|
const picked = enumerated[index]!.swipe!;
|
||||||
|
|
||||||
|
const action = walk(builtin("swipes"), new Pcg(7n, 0n), host);
|
||||||
|
assert.deepEqual(
|
||||||
|
action,
|
||||||
|
swipeCandidate(picked.origin, picked.direction, magnitude),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("doubleTaps and longPresses draw exactly one candidate index", () => {
|
test("doubleTaps and longPresses draw exactly one candidate index", () => {
|
||||||
@@ -174,14 +250,28 @@ test("waitOnce emits a 500ms wait and draws nothing", () => {
|
|||||||
assert.notEqual(before, after);
|
assert.notEqual(before, after);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pressKeys on native draws from NATIVE_PRESS_KEYS", () => {
|
test("pressKeys enumerates the platform's whole key pool", () => {
|
||||||
|
resetWarnings();
|
||||||
|
for (const [platform, keys] of [
|
||||||
|
["android", NATIVE_PRESS_KEYS],
|
||||||
|
["web", WEB_PRESS_KEYS],
|
||||||
|
] as const) {
|
||||||
|
const enumerated = builtinCandidates("pressKeys", stubHost(platform, POINTS));
|
||||||
|
assert.deepEqual(
|
||||||
|
enumerated,
|
||||||
|
keys.map((key) => ({ action: { kind: "PressKey", key }, targetIndex: -1 })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pressKeys on native emits the only key without drawing", () => {
|
||||||
resetWarnings();
|
resetWarnings();
|
||||||
const host = stubHost("android", POINTS);
|
const host = stubHost("android", POINTS);
|
||||||
const rng = new Pcg(42n, 0n);
|
const rng = new Pcg(42n, 0n);
|
||||||
const oracle = new Pcg(42n, 0n);
|
|
||||||
const index = oracle.intN(NATIVE_PRESS_KEYS.length);
|
|
||||||
const action = walk(builtin("pressKeys"), rng, host);
|
const action = walk(builtin("pressKeys"), rng, host);
|
||||||
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[index] });
|
assert.deepEqual(action, { kind: "PressKey", key: NATIVE_PRESS_KEYS[0] });
|
||||||
|
// One key is no choice, so the pool consumed no draw.
|
||||||
|
assert.equal(rng.float64(), new Pcg(42n, 0n).float64());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
|
test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
|
||||||
@@ -194,6 +284,97 @@ test("pressKeys on web draws from WEB_PRESS_KEYS", () => {
|
|||||||
assert.deepEqual(action, { kind: "PressKey", key: WEB_PRESS_KEYS[index] });
|
assert.deepEqual(action, { kind: "PressKey", key: WEB_PRESS_KEYS[index] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("an unsupported verb enumerates nothing and reports once", () => {
|
||||||
|
resetWarnings();
|
||||||
|
// No platform in the matrix declines a verb today, so the branch is reached
|
||||||
|
// through a platform the matrix has never heard of.
|
||||||
|
const host = stubHost("desktop" as Platform, POINTS);
|
||||||
|
assert.deepEqual(builtinCandidates("taps", host), []);
|
||||||
|
assert.deepEqual(builtinCandidates("taps", host), []);
|
||||||
|
assert.deepEqual(host.unsupported, ["taps"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the seeded pick is an index into the shared enumeration", () => {
|
||||||
|
resetWarnings();
|
||||||
|
// Whatever the verb, the drawn action is one of the enumerated entries: the
|
||||||
|
// seeded policy adds a choice, never an action the model policy cannot see.
|
||||||
|
// `typing` and `swipes` are covered separately, being the two verbs whose
|
||||||
|
// enumeration leaves one value for the policy to fill in.
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
for (const verb of [
|
||||||
|
"taps",
|
||||||
|
"doubleTaps",
|
||||||
|
"longPresses",
|
||||||
|
"scrolls",
|
||||||
|
"pressKeys",
|
||||||
|
"waitOnce",
|
||||||
|
] as const) {
|
||||||
|
const enumerated = builtinCandidates(verb, host).map((entry) =>
|
||||||
|
JSON.stringify(entry.action),
|
||||||
|
);
|
||||||
|
for (let seed = 1; seed <= 50; seed++) {
|
||||||
|
const action = walk(builtin(verb), new Pcg(BigInt(seed), 0n), host);
|
||||||
|
assert.ok(
|
||||||
|
enumerated.includes(JSON.stringify(action)),
|
||||||
|
`${verb} drew ${JSON.stringify(action)}, which is not an enumerated candidate`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("typing enumerates the field and the policy supplies the text", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const enumerated = builtinCandidates("typing", host);
|
||||||
|
// The candidate set names fields only: an empty text is the slot the seeded
|
||||||
|
// corpus draw and the model's own value both fill.
|
||||||
|
for (const entry of enumerated) {
|
||||||
|
assert.equal((entry.action as ActionDescriptor & { kind: "InputText" }).text, "");
|
||||||
|
}
|
||||||
|
const oracle = new Pcg(2024n, 0n);
|
||||||
|
const index = oracle.intN(enumerated.length);
|
||||||
|
const text = INPUT_CORPUS[oracle.intN(INPUT_CORPUS.length)];
|
||||||
|
assert.deepEqual(walk(builtin("typing"), new Pcg(2024n, 0n), host), {
|
||||||
|
...enumerated[index]!.action,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("swipes enumerate origin and direction, the policy adds distance", () => {
|
||||||
|
resetWarnings();
|
||||||
|
const host = stubHost("android", POINTS);
|
||||||
|
const origins = new Set(
|
||||||
|
builtinCandidates("swipes", host).map((entry) =>
|
||||||
|
JSON.stringify([entry.swipe!.origin, entry.swipe!.direction]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const drawn = new Set<Direction>();
|
||||||
|
for (let seed = 1; seed <= 200; seed++) {
|
||||||
|
const action = walk(builtin("swipes"), new Pcg(BigInt(seed), 0n), host) as
|
||||||
|
ActionDescriptor & { kind: "Swipe"; from: Point; to: Point };
|
||||||
|
assert.equal(action.kind, "Swipe");
|
||||||
|
const direction = swipeDirection(action.from, action.to);
|
||||||
|
drawn.add(direction);
|
||||||
|
assert.ok(
|
||||||
|
origins.has(JSON.stringify([action.from, direction])),
|
||||||
|
`swipe from ${JSON.stringify(action.from)} going ${direction} is not enumerated`,
|
||||||
|
);
|
||||||
|
// The drawn distance stays inside 200..600, clamped at the screen edge.
|
||||||
|
const horizontal = direction === "left" || direction === "right";
|
||||||
|
const distance = horizontal
|
||||||
|
? Math.abs(action.to.x - action.from.x)
|
||||||
|
: Math.abs(action.to.y - action.from.y);
|
||||||
|
const clamped = horizontal ? action.to.x === 0 : action.to.y === 0;
|
||||||
|
assert.ok(distance <= 600, `drag of ${distance}px exceeds the drawn range`);
|
||||||
|
assert.ok(
|
||||||
|
distance >= 200 || clamped,
|
||||||
|
`drag of ${distance}px is under the drawn range and not clamped`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Every enumerated direction is reachable by a draw, sideways included.
|
||||||
|
assert.deepEqual([...drawn].sort(), ["down", "left", "right", "up"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("empty candidate list yields null without drawing", () => {
|
test("empty candidate list yields null without drawing", () => {
|
||||||
resetWarnings();
|
resetWarnings();
|
||||||
const host = stubHost("android", []);
|
const host = stubHost("android", []);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function countingHost(platform: "android" | "ios" | "web"): Host & { calls: Buil
|
|||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
platform: () => platform,
|
platform: () => platform,
|
||||||
queryCandidates: () => [],
|
queryTargets: () => [],
|
||||||
reportUnsupported: (verb) => {
|
reportUnsupported: (verb) => {
|
||||||
calls.push(verb);
|
calls.push(verb);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// A minimal stand-in for the DOM surface the web host reads, shared by the web
|
||||||
|
// runtime's own tests and the cross-host eligibility test. The host asks the
|
||||||
|
// document for three things -- every element, the tappable set, the editable set
|
||||||
|
// -- and reads geometry, `disabled` and the scroll extents off each element, so
|
||||||
|
// that is all a fake has to answer.
|
||||||
|
|
||||||
|
import { __testing__ } from "../src/web-runtime.ts";
|
||||||
|
|
||||||
|
const { TAPPABLE_SELECTOR, EDITABLE_SELECTOR } = __testing__;
|
||||||
|
|
||||||
|
export interface FakeElementSpec {
|
||||||
|
tag: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
// clickable/editable place the element in the selector sets the host queries;
|
||||||
|
// the fake answers those queries directly rather than matching CSS.
|
||||||
|
clickable?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
// overflows makes the element's content taller than its box, which is how the
|
||||||
|
// host decides an element is scrollable.
|
||||||
|
overflows?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FakeElement extends FakeElementSpec {
|
||||||
|
tagName: string;
|
||||||
|
type: string;
|
||||||
|
isContentEditable: boolean;
|
||||||
|
scrollHeight: number;
|
||||||
|
clientHeight: number;
|
||||||
|
scrollWidth: number;
|
||||||
|
clientWidth: number;
|
||||||
|
getBoundingClientRect(): {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fakeElement(spec: FakeElementSpec): FakeElement {
|
||||||
|
const editable = spec.editable ?? false;
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
tagName: spec.tag.toUpperCase(),
|
||||||
|
type: spec.tag === "input" ? "text" : "",
|
||||||
|
isContentEditable: editable && spec.tag !== "input" && spec.tag !== "textarea",
|
||||||
|
scrollHeight: spec.overflows ? spec.height * 2 : spec.height,
|
||||||
|
clientHeight: spec.height,
|
||||||
|
scrollWidth: spec.width,
|
||||||
|
clientWidth: spec.width,
|
||||||
|
getBoundingClientRect: () => ({
|
||||||
|
left: spec.x,
|
||||||
|
top: spec.y,
|
||||||
|
width: spec.width,
|
||||||
|
height: spec.height,
|
||||||
|
right: spec.x + spec.width,
|
||||||
|
bottom: spec.y + spec.height,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// withFakeDocument installs a document answering the host's three queries over
|
||||||
|
// `elements`, resets the host's per-tick cache, and restores the real document
|
||||||
|
// afterwards.
|
||||||
|
export function withFakeDocument(elements: FakeElement[], run: () => void): void {
|
||||||
|
const global = globalThis as Record<string, unknown>;
|
||||||
|
const original = global.document;
|
||||||
|
const answers: Record<string, FakeElement[]> = {
|
||||||
|
"*": elements,
|
||||||
|
[TAPPABLE_SELECTOR]: elements.filter((element) => element.clickable),
|
||||||
|
[EDITABLE_SELECTOR]: elements.filter((element) => element.editable),
|
||||||
|
};
|
||||||
|
global.document = {
|
||||||
|
querySelectorAll: (selector: string) => answers[selector] ?? [],
|
||||||
|
};
|
||||||
|
__testing__.resetTargetCache();
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
__testing__.resetTargetCache();
|
||||||
|
global.document = original;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,78 +83,60 @@ test("installRuntime defined the host-invoked globals", () => {
|
|||||||
assert.equal(typeof g.__sanderling__, "object");
|
assert.equal(typeof g.__sanderling__, "object");
|
||||||
});
|
});
|
||||||
|
|
||||||
// A button and a text input, each with a deterministic bounding box, exercise
|
const { fakeElement, withFakeDocument } = await import("./web-dom-harness.ts");
|
||||||
// the per-verb selector routing without a full DOM.
|
|
||||||
function fakeElement(tag: string, rect: { x: number; y: number; w: number; h: number }) {
|
|
||||||
return {
|
|
||||||
tagName: tag.toUpperCase(),
|
|
||||||
disabled: false,
|
|
||||||
isContentEditable: false,
|
|
||||||
type: tag === "input" ? "text" : "",
|
|
||||||
scrollHeight: 0,
|
|
||||||
clientHeight: 0,
|
|
||||||
scrollWidth: 0,
|
|
||||||
clientWidth: 0,
|
|
||||||
getBoundingClientRect: () => ({
|
|
||||||
left: rect.x,
|
|
||||||
top: rect.y,
|
|
||||||
width: rect.w,
|
|
||||||
height: rect.h,
|
|
||||||
right: rect.x + rect.w,
|
|
||||||
bottom: rect.y + rect.h,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function withFakeDocument(map: Record<string, unknown[]>, run: () => void) {
|
// The host reports facts and never routes verbs: which of these a verb may act
|
||||||
const g = globalThis as Record<string, unknown>;
|
// on is decided by the shared rule in src/targets.ts, exercised across both
|
||||||
const original = g.document;
|
// engines by host-parity.test.ts.
|
||||||
g.document = {
|
test("queryTargets reports the tappable selector set as clickable", () => {
|
||||||
querySelectorAll: (selector: string) => map[selector] ?? [],
|
const button = fakeElement({ tag: "button", x: 10, y: 20, width: 40, height: 8, clickable: true });
|
||||||
scrollingElement: null,
|
const plain = fakeElement({ tag: "div", x: 0, y: 0, width: 100, height: 100 });
|
||||||
documentElement: null,
|
withFakeDocument([button, plain], () => {
|
||||||
};
|
const targets = host.queryTargets();
|
||||||
try {
|
assert.equal(targets.length, 2);
|
||||||
run();
|
assert.equal(targets[0]!.clickable, true);
|
||||||
} finally {
|
assert.deepEqual({ x: targets[0]!.x, y: targets[0]!.y }, { x: 30, y: 24 });
|
||||||
g.document = original;
|
assert.equal(targets[1]!.clickable, false);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("queryCandidates routes taps to the tappable selector set", () => {
|
|
||||||
const button = fakeElement("button", { x: 10, y: 20, w: 40, h: 8 });
|
|
||||||
withFakeDocument(
|
|
||||||
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [button] },
|
|
||||||
() => {
|
|
||||||
__testing__.resetCandidateCache();
|
|
||||||
const candidates = host.queryCandidates("taps");
|
|
||||||
assert.equal(candidates.length, 1);
|
|
||||||
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 30, y: 24 });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("queryCandidates routes typing to editable inputs only", () => {
|
|
||||||
const input = fakeElement("input", { x: 0, y: 0, w: 100, h: 20 });
|
|
||||||
withFakeDocument({ "input, textarea, [contenteditable]": [input] }, () => {
|
|
||||||
__testing__.resetCandidateCache();
|
|
||||||
const candidates = host.queryCandidates("typing");
|
|
||||||
assert.equal(candidates.length, 1);
|
|
||||||
assert.deepEqual({ x: candidates[0]!.x, y: candidates[0]!.y }, { x: 50, y: 10 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("queryCandidates caches within a tick until reset", () => {
|
test("queryTargets reports only real text inputs as editable", () => {
|
||||||
const first = fakeElement("button", { x: 0, y: 0, w: 10, h: 10 });
|
const input = fakeElement({ tag: "input", x: 0, y: 0, width: 100, height: 20, editable: true });
|
||||||
withFakeDocument(
|
const checkbox = fakeElement({ tag: "input", x: 0, y: 40, width: 20, height: 20, editable: true });
|
||||||
{ 'a, button, input, select, textarea, [role="button"], [onclick]': [first] },
|
checkbox.type = "checkbox";
|
||||||
() => {
|
withFakeDocument([input, checkbox], () => {
|
||||||
__testing__.resetCandidateCache();
|
const targets = host.queryTargets();
|
||||||
const a = host.queryCandidates("taps");
|
assert.equal(targets[0]!.editable, true);
|
||||||
const b = host.queryCandidates("taps");
|
assert.deepEqual({ x: targets[0]!.x, y: targets[0]!.y }, { x: 50, y: 10 });
|
||||||
assert.equal(a, b);
|
assert.equal(targets[1]!.editable, false);
|
||||||
},
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
|
// A disabled control used to be dropped from every verb's candidates, because
|
||||||
|
// the web host folded `disabled` into its visibility check. It is a fact of its
|
||||||
|
// own now, so `taps` still skips it while `swipes` can still start on it, which
|
||||||
|
// is what the native host has always done.
|
||||||
|
test("queryTargets reports a disabled control rather than dropping it", () => {
|
||||||
|
const disabled = fakeElement({
|
||||||
|
tag: "button", x: 0, y: 0, width: 40, height: 20, clickable: true, disabled: true,
|
||||||
|
});
|
||||||
|
withFakeDocument([disabled], () => {
|
||||||
|
const targets = host.queryTargets();
|
||||||
|
assert.equal(targets.length, 1);
|
||||||
|
assert.equal(targets[0]!.clickable, true);
|
||||||
|
assert.equal(targets[0]!.enabled, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queryTargets caches within a tick until reset", () => {
|
||||||
|
const button = fakeElement({ tag: "button", x: 0, y: 0, width: 10, height: 10, clickable: true });
|
||||||
|
withFakeDocument([button], () => {
|
||||||
|
const first = host.queryTargets();
|
||||||
|
const second = host.queryTargets();
|
||||||
|
assert.equal(first, second);
|
||||||
|
__testing__.resetTargetCache();
|
||||||
|
assert.notEqual(host.queryTargets(), first);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// evaluateExtractors builds State, which references document and window.
|
// evaluateExtractors builds State, which references document and window.
|
||||||
|
|||||||
Reference in new issue
Block a user