mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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
107 lines
3.7 KiB
Go
107 lines
3.7 KiB
Go
package verifier
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestCrossRuntimeParity is the W2 acceptance gate: for a FIXED seed and a FIXED
|
|
// candidate state, the goja-bundled picker must emit the SAME action stream as
|
|
// the node/web pick.ts. Both engines run the SHARED pick.ts over the SHARED Pcg,
|
|
// so each asserts the SAME committed golden (pkg/spec/test/fixtures/
|
|
// parity-golden.json); the node side does so in pkg/spec/test/parity.test.ts.
|
|
// Matching one golden on both sides proves they match each other without either
|
|
// invoking the other.
|
|
//
|
|
// The candidate ORDER and the per-tick PCG draw order are the parity contract.
|
|
// A stub __sanderlingHost__ feeds the SAME three candidates (in the SAME order)
|
|
// for every verb, so the hierarchy filter is out of the picture and the only
|
|
// variable left is the JS engine. The weighted root mixes a tap branch with a
|
|
// typing branch, exercising weighted selection, a builtin, and the input corpus
|
|
// within the 20-tick window; reordering candidates or adding/dropping a draw on
|
|
// either side shifts the stream and fails the golden.
|
|
func TestCrossRuntimeParity(t *testing.T) {
|
|
const seed uint64 = 0x9e3779b97f4a7c15
|
|
golden := loadParityGolden(t)
|
|
|
|
verifier := newVerifier(t, WithSeed(seed))
|
|
installStubHost(t, verifier)
|
|
loadActionSpec(t, verifier, `
|
|
import { taps, typing, weighted } from "@sanderling/spec";
|
|
globalThis.actions = weighted([3, taps], [1, typing]);
|
|
`)
|
|
|
|
if len(golden) == 0 {
|
|
t.Fatal("golden stream is empty")
|
|
}
|
|
for step, want := range golden {
|
|
got, err := verifier.NextAction()
|
|
if err != nil {
|
|
t.Fatalf("goja next action at step %d: %v", step, err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("step %d diverged from golden:\n goja=%+v\n want=%+v", step, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// installStubHost replaces the verifier's hierarchy-backed __sanderlingHost__
|
|
// with one returning a FIXED target list, keeping the seed the verifier was
|
|
// constructed with. Every fact is set so the shared eligibility rule admits all
|
|
// 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) {
|
|
t.Helper()
|
|
const stub = `
|
|
const facts = { clickable: true, enabled: true, editable: true, scrollable: true };
|
|
const targets = [
|
|
{ x: 50, y: 60, selector: "id:alpha", width: 100, height: 40, ...facts },
|
|
{ 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 seedLo = globalThis.__sanderlingHost__.seedLo;
|
|
globalThis.__sanderlingHost__ = {
|
|
platform: () => "android",
|
|
queryTargets: () => targets,
|
|
reportUnsupported: () => {},
|
|
seedHi,
|
|
seedLo,
|
|
};
|
|
`
|
|
if _, err := verifier.runtime.RunString(stub); err != nil {
|
|
t.Fatalf("install stub host: %v", err)
|
|
}
|
|
}
|
|
|
|
// loadParityGolden decodes the shared golden stream with the SAME DecodeAction
|
|
// the runner uses, so the goja comparison is apples-to-apples with the wire the
|
|
// node picker emits.
|
|
func loadParityGolden(t *testing.T) []Action {
|
|
t.Helper()
|
|
path, err := filepath.Abs("../../pkg/spec/test/fixtures/parity-golden.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read golden: %v", err)
|
|
}
|
|
var raw []json.RawMessage
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
t.Fatalf("decode golden: %v", err)
|
|
}
|
|
stream := make([]Action, len(raw))
|
|
for i, message := range raw {
|
|
action, err := DecodeAction(message)
|
|
if err != nil {
|
|
t.Fatalf("decode golden action %d: %v", i, err)
|
|
}
|
|
stream[i] = action
|
|
}
|
|
return stream
|
|
}
|