mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17: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
+1494
-594
No files matched your search
@@ -10,7 +10,7 @@ import (
|
||||
// enumTreeJSON exercises every labeling path: a clickable wrapper whose own text
|
||||
// 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,
|
||||
// and a scrollable list (the only valid gesture origin).
|
||||
// and a scrollable list (the only valid scroll origin).
|
||||
const enumTreeJSON = `{
|
||||
"attributes": {"bounds": "[0,0,1080,2400]"},
|
||||
"children": [
|
||||
@@ -25,10 +25,13 @@ const enumTreeJSON = `{
|
||||
}`
|
||||
|
||||
// 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 {
|
||||
t.Helper()
|
||||
v := newLoadedVerifier(t, "globalThis.actions = "+actionsJS+";")
|
||||
v := newVerifier(t)
|
||||
loadActionSpec(t, v, "globalThis.actions = "+actionsJS+";")
|
||||
tree, err := hierarchy.Parse(treeJSON)
|
||||
if err != nil {
|
||||
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,
|
||||
"{kind:'weighted', branches:[[1,{kind:'builtin',verb:'scrolls'}],[1,{kind:'builtin',verb:'swipes'}]]}",
|
||||
enumTreeJSON)
|
||||
candidates := v.Candidates()
|
||||
|
||||
// Gestures are directional and scoped to the one scrollable container: no
|
||||
// per-element, element-labeled Swipe entries.
|
||||
for _, candidate := range candidates {
|
||||
if strings.HasPrefix(candidate.Description, "Swipe") {
|
||||
t.Errorf("gesture kept as element-labeled swipe: %q", candidate.Description)
|
||||
}
|
||||
}
|
||||
// `scrolls` folds to one directional pair over the single scrollable
|
||||
// container, which is what keeps the list short.
|
||||
if !hasCandidate(candidates, "Scroll down") || !hasCandidate(candidates, "Scroll up") {
|
||||
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 {
|
||||
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) {
|
||||
@@ -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) {
|
||||
v := enumVerifier(t, "{kind:'actions', generate: () => []}", enumTreeJSON)
|
||||
if got := v.Candidates(); len(got) != 0 {
|
||||
@@ -248,9 +280,14 @@ func TestCandidatesSkipsCrossFadeFrames(t *testing.T) {
|
||||
]
|
||||
}`
|
||||
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))
|
||||
}
|
||||
// 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) {
|
||||
@@ -345,5 +382,3 @@ func newLoadedVerifier(t *testing.T, source string) *Verifier {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user