test(spec): assert data-tree shapes for action factories

This commit is contained in:
pj committed 2026-06-01 16:27:29 +05:30
1 parent 804cee70d2
commit 51200ed829
2 files changed
+131 -226

No files matched your search

+110 -134
View File
@@ -4,38 +4,45 @@ import { test } from "node:test";
import { import {
DoubleTap, DoubleTap,
InputText, InputText,
LongPress,
PressKey, PressKey,
Scroll,
Swipe, Swipe,
Tap, Tap,
Wait, Wait,
actions, actions,
always, always,
doubleTaps,
eventually, eventually,
extract, extract,
from, from,
keyedBy, keyedBy,
longPresses,
next, next,
now, now,
pressKey, pressKey,
scrolls,
swipes, swipes,
taps, taps,
typing,
waitOnce, waitOnce,
weighted, weighted,
whenRoute, whenRoute,
} from "../src/index.ts"; } from "../src/index.ts";
import { setSamplerRng } from "../src/actions.ts";
import { Pcg } from "../src/pcg.ts";
import type { import type {
AccessibilityElement, AccessibilityElement,
Action,
ActionGenerator,
EventuallyFormula, EventuallyFormula,
Extracted, Extracted,
Formula, Formula,
Sampler,
State, State,
SanderlingRuntime, SanderlingRuntime,
WeightedEntry,
} from "../src/types.ts"; } from "../src/types.ts";
// extract() and the LTL constructors stay host-bound on __sanderling__; the
// action factories now return plain data and need no runtime. This fake records
// the host-bound calls so the forwarding tests can assert them.
interface RecordedRuntime extends SanderlingRuntime { interface RecordedRuntime extends SanderlingRuntime {
extracts: Array<(state: State) => unknown>; extracts: Array<(state: State) => unknown>;
extractNames: Array<string | undefined>; extractNames: Array<string | undefined>;
@@ -48,13 +55,10 @@ interface RecordedRuntime extends SanderlingRuntime {
orCalls: number; orCalls: number;
andCalls: number; andCalls: number;
notCalls: number; notCalls: number;
actionGenerators: Array<() => Action[]>;
weightedCalls: WeightedEntry[][];
fromCalls: unknown[][];
} }
function makeChainableFormula(record: RecordedRuntime): Formula { function makeChainableFormula(record: RecordedRuntime): Formula {
const formula: Formula = { return {
__sanderlingFormula: true, __sanderlingFormula: true,
implies(other: Formula): Formula { implies(other: Formula): Formula {
record.impliesCalls++; record.impliesCalls++;
@@ -76,7 +80,6 @@ function makeChainableFormula(record: RecordedRuntime): Formula {
return makeChainableFormula(record); return makeChainableFormula(record);
}, },
}; };
return formula;
} }
function makeChainableEventually(record: RecordedRuntime): EventuallyFormula { function makeChainableEventually(record: RecordedRuntime): EventuallyFormula {
@@ -103,11 +106,8 @@ function installFakeRuntime(): RecordedRuntime {
orCalls: 0, orCalls: 0,
andCalls: 0, andCalls: 0,
notCalls: 0, notCalls: 0,
actionGenerators: [] as Array<() => Action[]>,
weightedCalls: [] as WeightedEntry[][],
fromCalls: [] as unknown[][],
}; };
const runtime = { const runtime: SanderlingRuntime = {
extract: <T>(getter: (state: State) => T, name?: string): Extracted<T> => { extract: <T>(getter: (state: State) => T, name?: string): Extracted<T> => {
calls.extracts.push(getter as (state: State) => unknown); calls.extracts.push(getter as (state: State) => unknown);
calls.extractNames.push(name); calls.extractNames.push(name);
@@ -129,40 +129,7 @@ function installFakeRuntime(): RecordedRuntime {
calls.eventuallyPredicates.push(predicate); calls.eventuallyPredicates.push(predicate);
return makeChainableEventually(recorded); return makeChainableEventually(recorded);
}, },
actions: (generator: () => Action[]): ActionGenerator => { };
calls.actionGenerators.push(generator);
return { __sanderlingActionGenerator: true, generate: generator };
},
weighted: (...entries: WeightedEntry[]): ActionGenerator => {
calls.weightedCalls.push(entries);
return { __sanderlingActionGenerator: true, generate: () => [] };
},
from: <T>(items: readonly T[]): Sampler<T> => {
calls.fromCalls.push(items as unknown[]);
return { generate: () => items[0] as T };
},
tap: ({ on }) => ({ kind: "Tap", on }),
doubleTap: ({ on }) => ({ kind: "DoubleTap", on }),
longPress: ({ on }) => ({ kind: "LongPress", on }),
scroll: ({ direction, in: container }) => ({ kind: "Scroll", direction, in: container }),
inputText: ({ into, text }) => ({ kind: "InputText", into, text }),
swipe: ({ from: fromPoint, to, durationMillis }) => ({
kind: "Swipe",
from: fromPoint,
to,
durationMillis,
}),
pressKey: ({ key }) => ({ kind: "PressKey", key }),
wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }),
taps: { __sanderlingActionGenerator: true, generate: () => [] },
doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] },
longPresses: { __sanderlingActionGenerator: true, generate: () => [] },
scrolls: { __sanderlingActionGenerator: true, generate: () => [] },
typing: { __sanderlingActionGenerator: true, generate: () => [] },
swipes: { __sanderlingActionGenerator: true, generate: () => [] },
waitOnce: { __sanderlingActionGenerator: true, generate: () => [] },
pressKeys: { __sanderlingActionGenerator: true, generate: () => [] },
} satisfies SanderlingRuntime;
const recorded = Object.assign(runtime, calls) as RecordedRuntime; const recorded = Object.assign(runtime, calls) as RecordedRuntime;
globalThis.__sanderling__ = recorded; globalThis.__sanderling__ = recorded;
return recorded; return recorded;
@@ -202,14 +169,6 @@ test("always wraps a predicate into a formula via the runtime", () => {
assert.equal(formula.__sanderlingFormula, true); assert.equal(formula.__sanderlingFormula, true);
}); });
test("always accepts a formula handle", () => {
const runtime = installFakeRuntime();
const inner = now(() => true);
const wrapped = always(inner);
assert.equal(runtime.alwaysArgs.at(-1), inner);
assert.equal(wrapped.__sanderlingFormula, true);
});
test("now/next/eventually forward predicates", () => { test("now/next/eventually forward predicates", () => {
const runtime = installFakeRuntime(); const runtime = installFakeRuntime();
const p1 = () => true; const p1 = () => true;
@@ -240,20 +199,30 @@ test("formula chaining exposes implies/or/and/not", () => {
assert.equal(runtime.notCalls, 1); assert.equal(runtime.notCalls, 1);
}); });
test("Tap returns a TapAction with the supplied selector", () => { test("Tap returns a TapAction descriptor", () => {
installFakeRuntime(); assert.deepEqual(Tap({ on: "id:login_continue" }), {
const action = Tap({ on: "id:login_continue" }); kind: "Tap",
assert.deepEqual(action, { kind: "Tap", on: "id:login_continue" }); on: "id:login_continue",
});
}); });
test("DoubleTap returns a DoubleTapAction with the supplied selector", () => { test("DoubleTap returns a DoubleTapAction descriptor", () => {
installFakeRuntime(); assert.deepEqual(DoubleTap({ on: "id:save" }), { kind: "DoubleTap", on: "id:save" });
const action = DoubleTap({ on: "id:save" }); });
assert.deepEqual(action, { kind: "DoubleTap", on: "id:save" });
test("LongPress returns a LongPressAction descriptor", () => {
assert.deepEqual(LongPress({ on: "id:row" }), { kind: "LongPress", on: "id:row" });
});
test("Scroll returns a ScrollAction descriptor", () => {
assert.deepEqual(Scroll({ direction: "down", in: "id:list" }), {
kind: "Scroll",
direction: "down",
in: "id:list",
});
}); });
test("Tap accepts an AccessibilityElement", () => { test("Tap accepts an AccessibilityElement", () => {
installFakeRuntime();
const element: AccessibilityElement = { const element: AccessibilityElement = {
id: "login_continue", id: "login_continue",
find: () => undefined, find: () => undefined,
@@ -264,63 +233,86 @@ test("Tap accepts an AccessibilityElement", () => {
assert.equal(action.on, element); assert.equal(action.on, element);
}); });
test("InputText returns an InputTextAction", () => { test("InputText returns an InputTextAction descriptor", () => {
installFakeRuntime(); assert.deepEqual(InputText({ into: "id:phone", text: "+1234567890" }), {
const action = InputText({ into: "id:phone", text: "+1234567890" }); kind: "InputText",
assert.deepEqual(action, { kind: "InputText", into: "id:phone", text: "+1234567890" }); into: "id:phone",
}); text: "+1234567890",
test("Swipe returns a SwipeAction with the supplied endpoints", () => {
installFakeRuntime();
const action = Swipe({ from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 });
assert.deepEqual(action, {
kind: "Swipe",
from: { x: 10, y: 20 },
to: { x: 30, y: 40 },
durationMillis: 400,
}); });
}); });
test("PressKey returns a PressKeyAction", () => { test("Swipe returns a SwipeAction descriptor", () => {
installFakeRuntime(); assert.deepEqual(
const action = PressKey({ key: "back" }); Swipe({ from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 }),
assert.deepEqual(action, { kind: "PressKey", key: "back" }); { kind: "Swipe", from: { x: 10, y: 20 }, to: { x: 30, y: 40 }, durationMillis: 400 },
);
}); });
test("Wait returns a WaitAction", () => { test("PressKey returns a PressKeyAction descriptor", () => {
installFakeRuntime(); assert.deepEqual(PressKey({ key: "back" }), { kind: "PressKey", key: "back" });
const action = Wait({ durationMillis: 500 });
assert.deepEqual(action, { kind: "Wait", durationMillis: 500 });
}); });
test("actions wraps a generator into the runtime's ActionGenerator", () => { test("Wait returns a WaitAction descriptor", () => {
const runtime = installFakeRuntime(); assert.deepEqual(Wait({ durationMillis: 500 }), { kind: "Wait", durationMillis: 500 });
});
test("actions returns an actions node carrying the generator", () => {
const generator = () => [Tap({ on: "id:x" })]; const generator = () => [Tap({ on: "id:x" })];
const wrapped = actions(generator); const node = actions(generator);
assert.equal(runtime.actionGenerators[0], generator); assert.equal(node.kind, "actions");
assert.equal(wrapped.__sanderlingActionGenerator, true); assert.equal((node as { generate: unknown }).generate, generator);
}); });
test("weighted forwards weighted entries to the runtime", () => { test("weighted returns a weighted node carrying the branches", () => {
const runtime = installFakeRuntime(); const node = weighted([80, taps], [20, swipes]);
const entries: WeightedEntry[] = [ assert.equal(node.kind, "weighted");
assert.deepEqual((node as { branches: unknown }).branches, [
[80, taps], [80, taps],
[20, swipes], [20, swipes],
]; ]);
weighted(...entries);
assert.deepEqual(runtime.weightedCalls[0], entries);
}); });
test("from forwards items to the runtime", () => { test("builtin factories return builtin nodes", () => {
const runtime = installFakeRuntime(); assert.deepEqual(taps, { kind: "builtin", verb: "taps" });
const sampler = from(["a", "b", "c"]); assert.deepEqual(doubleTaps, { kind: "builtin", verb: "doubleTaps" });
assert.deepEqual(runtime.fromCalls[0], ["a", "b", "c"]); assert.deepEqual(longPresses, { kind: "builtin", verb: "longPresses" });
assert.equal(sampler.generate(), "a"); assert.deepEqual(scrolls, { kind: "builtin", verb: "scrolls" });
assert.deepEqual(typing, { kind: "builtin", verb: "typing" });
assert.deepEqual(swipes, { kind: "builtin", verb: "swipes" });
assert.deepEqual(waitOnce, { kind: "builtin", verb: "waitOnce" });
assert.deepEqual(pressKey, { kind: "builtin", verb: "pressKeys" });
}); });
function elementWithChildren( test("from with a single item returns it without drawing", () => {
cells: Record<string, string>, const sampler = from(["only"]);
): AccessibilityElement { setSamplerRng(new Pcg(42n, 0n));
try {
assert.equal(sampler.generate(), "only");
} finally {
setSamplerRng(null);
}
});
test("from draws intN(len) from the active picker rng", () => {
const items = ["a", "b", "c"];
const sampler = from(items);
const rng = new Pcg(42n, 0n);
const oracle = new Pcg(42n, 0n);
setSamplerRng(rng);
try {
const index = oracle.intN(items.length);
assert.equal(sampler.generate(), items[index]);
} finally {
setSamplerRng(null);
}
});
test("from falls back to the first item outside a picker walk", () => {
setSamplerRng(null);
assert.equal(from(["a", "b", "c"]).generate(), "a");
});
function elementWithChildren(cells: Record<string, string>): AccessibilityElement {
return { return {
find: selector => { find: selector => {
if (typeof selector === "string" || Array.isArray(selector)) return undefined; if (typeof selector === "string" || Array.isArray(selector)) return undefined;
@@ -346,58 +338,42 @@ test("keyedBy joins testTag-resolved texts with a stable delimiter", () => {
}); });
test("keyedBy returns empty string for an undefined element", () => { test("keyedBy returns empty string for an undefined element", () => {
installFakeRuntime();
assert.equal(keyedBy(undefined, ["TxnDate"]), ""); assert.equal(keyedBy(undefined, ["TxnDate"]), "");
}); });
test("keyedBy substitutes empty strings for missing children", () => { test("keyedBy substitutes empty strings for missing children", () => {
installFakeRuntime();
const row = elementWithChildren({ TxnDate: "2026-04-26" }); const row = elementWithChildren({ TxnDate: "2026-04-26" });
assert.equal( assert.equal(keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]), "2026-04-26\x1f\x1f");
keyedBy(row, ["TxnDate", "TxnNote", "TxnAmount"]),
"2026-04-26\x1f\x1f",
);
}); });
test("whenRoute returns [] when current route does not match", () => { test("whenRoute body is skipped when the current route does not match", () => {
installFakeRuntime();
const route = { current: "home" as string | null }; const route = { current: "home" as string | null };
let bodyCalled = false; let bodyCalled = false;
const generator = whenRoute(route, "ledger", () => { const node = whenRoute(route, "ledger", () => {
bodyCalled = true; bodyCalled = true;
return [Tap({ on: "id:x" })]; return [Tap({ on: "id:x" })];
}); });
assert.deepEqual(generator.generate(), []); assert.equal(node.kind, "actions");
assert.deepEqual((node as { generate: () => unknown }).generate(), []);
assert.equal(bodyCalled, false); assert.equal(bodyCalled, false);
}); });
test("whenRoute calls body when current route matches", () => { test("whenRoute runs the body when the current route matches", () => {
installFakeRuntime();
const route = { current: "ledger" as string | null }; const route = { current: "ledger" as string | null };
const generator = whenRoute(route, "ledger", () => [Tap({ on: "id:x" })]); const node = whenRoute(route, "ledger", () => [Tap({ on: "id:x" })]);
const result = generator.generate(); const result = (node as { generate: () => Array<{ kind: string }> }).generate();
assert.equal(result.length, 1); assert.equal(result.length, 1);
assert.equal(result[0]?.kind, "Tap"); assert.equal(result[0]?.kind, "Tap");
}); });
test("whenRoute accepts an array of allowed routes", () => { test("whenRoute accepts an array of allowed routes", () => {
installFakeRuntime();
const route = { current: "add-account" as string | null }; const route = { current: "add-account" as string | null };
const generator = whenRoute(route, ["home", "add-account"], () => [Tap({ on: "id:x" })]); const node = whenRoute(route, ["home", "add-account"], () => [Tap({ on: "id:x" })]);
assert.equal(generator.generate().length, 1); assert.equal((node as { generate: () => unknown[] }).generate().length, 1);
}); });
test("whenRoute returns [] for null route", () => { test("whenRoute body is skipped for a null route", () => {
installFakeRuntime();
const route = { current: null as string | null }; const route = { current: null as string | null };
const generator = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]); const node = whenRoute(route, ["home"], () => [Tap({ on: "id:x" })]);
assert.deepEqual(generator.generate(), []); assert.deepEqual((node as { generate: () => unknown }).generate(), []);
});
test("default generators proxy through to the runtime", () => {
installFakeRuntime();
assert.equal(taps.__sanderlingActionGenerator, true);
assert.equal(swipes.__sanderlingActionGenerator, true);
assert.equal(waitOnce.__sanderlingActionGenerator, true);
assert.equal(pressKey.__sanderlingActionGenerator, true);
}); });
+21 -92
View File
@@ -2,99 +2,29 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { import type {
Action,
ActionGenerator,
EventuallyFormula, EventuallyFormula,
Extracted, Extracted,
Formula, Formula,
Sampler,
State, State,
SanderlingRuntime, SanderlingRuntime,
WeightedEntry,
} from "../src/types.ts"; } from "../src/types.ts";
interface RecordedRuntime extends SanderlingRuntime { // The defaults bundle still calls extract() and the LTL constructors, which
currentState: State; // stay host-bound on __sanderling__. Action factories now return plain data
extractors: Array<(state: State) => unknown>; // trees, so the fake runtime only needs the extract + formula surface.
alwaysArgs: Array<(() => boolean) | Formula>; function installRuntime(initialState: State): void {
lastPredicate: (() => boolean) | undefined; const state = { current: initialState };
} const runtime: SanderlingRuntime = {
extract: <T>(getter: (s: State) => T): Extracted<T> => ({
function installRuntime(initialState: State): RecordedRuntime { current: getter(state.current),
const extractors: Array<(state: State) => unknown> = []; previous: undefined,
const extracted: Array<{ value: unknown }> = []; }),
const alwaysArgs: Array<(() => boolean) | Formula> = []; always: () => ({ __sanderlingFormula: true } as Formula),
let lastPredicate: (() => boolean) | undefined;
const runtime = {
extract: <T>(getter: (state: State) => T): Extracted<T> => {
extractors.push(getter as (state: State) => unknown);
const slot = { value: getter(state.currentState) };
extracted.push(slot);
return {
get current(): T {
return slot.value as T;
},
previous: undefined,
};
},
always: (predicateOrFormula: (() => boolean) | Formula): Formula => {
alwaysArgs.push(predicateOrFormula);
if (typeof predicateOrFormula === "function") {
lastPredicate = predicateOrFormula;
}
return { __sanderlingFormula: true } as Formula;
},
now: () => ({ __sanderlingFormula: true } as Formula), now: () => ({ __sanderlingFormula: true } as Formula),
next: () => ({ __sanderlingFormula: true } as Formula), next: () => ({ __sanderlingFormula: true } as Formula),
eventually: () => ({ __sanderlingFormula: true } as EventuallyFormula), eventually: () => ({ __sanderlingFormula: true } as EventuallyFormula),
actions: (generator: () => Action[]): ActionGenerator => ({ };
__sanderlingActionGenerator: true, globalThis.__sanderling__ = runtime;
generate: generator,
}),
weighted: (..._entries: WeightedEntry[]): ActionGenerator => ({
__sanderlingActionGenerator: true,
generate: () => [],
}),
from: <T>(_items: readonly T[]): Sampler<T> => ({ generate: () => _items[0] as T }),
tap: ({ on }) => ({ kind: "Tap", on }),
doubleTap: ({ on }) => ({ kind: "DoubleTap", on }),
longPress: ({ on }) => ({ kind: "LongPress", on }),
scroll: ({ direction, in: container }) => ({ kind: "Scroll", direction, in: container }),
inputText: ({ into, text }) => ({ kind: "InputText", into, text }),
swipe: (p) => ({ kind: "Swipe", from: p.from, to: p.to, durationMillis: p.durationMillis }),
pressKey: ({ key }) => ({ kind: "PressKey", key }),
wait: ({ durationMillis }) => ({ kind: "Wait", durationMillis }),
taps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
doubleTaps: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
longPresses: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
scrolls: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
typing: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
swipes: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
waitOnce: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
pressKeys: { __sanderlingActionGenerator: true, generate: () => [] } as ActionGenerator,
} satisfies SanderlingRuntime;
const state = { currentState: initialState };
const recorded = Object.assign(runtime, {
currentState: initialState,
extractors,
alwaysArgs,
get lastPredicate() {
return lastPredicate;
},
}) as unknown as RecordedRuntime;
globalThis.__sanderling__ = recorded;
// Re-bind state ref so subsequent extract() calls read the up-to-date state.
Object.defineProperty(recorded, "currentState", {
get() {
return state.currentState;
},
set(next: State) {
state.currentState = next;
},
});
return recorded;
} }
const emptyState: State = { const emptyState: State = {
@@ -116,29 +46,28 @@ test("defaults bundle exports formulas tagged as LTL properties", async () => {
assert.equal(defaults.noLogcatErrors.__sanderlingFormula, true); assert.equal(defaults.noLogcatErrors.__sanderlingFormula, true);
}); });
test("defaults bundle exports defaultActions as a valid ActionGenerator", async () => { test("defaults bundle exports defaultActions as a weighted node", async () => {
installRuntime(emptyState); installRuntime(emptyState);
const defaults = await import("../src/defaults/actions.ts"); const defaults = await import("../src/defaults/actions.ts");
assert.equal(defaults.defaultActions.__sanderlingActionGenerator, true); assert.equal(defaults.defaultActions.kind, "weighted");
assert.equal(typeof defaults.defaultActions.generate, "function");
}); });
test("typing resolves as a valid ActionGenerator", async () => { test("typing resolves as a builtin node", async () => {
installRuntime(emptyState); installRuntime(emptyState);
const { typing } = await import("../src/defaults/actions.ts"); const { typing } = await import("../src/defaults/actions.ts");
assert.equal(typing.__sanderlingActionGenerator, true); assert.deepEqual(typing, { kind: "builtin", verb: "typing" });
}); });
test("longPresses and scrolls resolve as valid ActionGenerators", async () => { test("longPresses and scrolls resolve as builtin nodes", async () => {
installRuntime(emptyState); installRuntime(emptyState);
const { longPresses, scrolls } = await import("../src/defaults/actions.ts"); const { longPresses, scrolls } = await import("../src/defaults/actions.ts");
assert.equal(longPresses.__sanderlingActionGenerator, true); assert.deepEqual(longPresses, { kind: "builtin", verb: "longPresses" });
assert.equal(scrolls.__sanderlingActionGenerator, true); assert.deepEqual(scrolls, { kind: "builtin", verb: "scrolls" });
}); });
test("defaults barrel re-exports both properties and actions", async () => { test("defaults barrel re-exports both properties and actions", async () => {
installRuntime(emptyState); installRuntime(emptyState);
const barrel = await import("../src/defaults/index.ts"); const barrel = await import("../src/defaults/index.ts");
assert.equal(barrel.defaultActions.__sanderlingActionGenerator, true); assert.equal(barrel.defaultActions.kind, "weighted");
assert.equal(barrel.noUncaughtExceptions.__sanderlingFormula, true); assert.equal(barrel.noUncaughtExceptions.__sanderlingFormula, true);
}); });