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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user