test: property-based testing foundation (fast-check) over parse/print and geometry kernels (#1437)

* test: property-based testing foundation over parse/print and geometry kernels (#1413)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: derive selector and replay command domains from their owning registries

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot]
2026-07-27 17:15:39 +02:00
committed by GitHub
parent e8b779cb32
commit 8670a88afa
11 changed files with 738 additions and 3 deletions
+16
View File
@@ -48,6 +48,22 @@ to make a test easier — widening the public surface for a test is a product ch
outlive the test that motivated them. If a seam is genuinely missing, add it as a real one rather
than as a test affordance (the workflow separately forbids test-only `typeof` DI params).
## Properties over examples (pure parsers and geometry)
**Pure parser or geometry change → extend a property, not another example.** The parse/print and
geometry kernels (selectors, `@eN~sM` refs, `.ad` script lines, gesture planning, snapshot diff) are
covered by fast-check properties living in each owning module's test file. Their generators are
shared in `src/__tests__/test-utils/property-arbitraries.ts` and exported through the test-utils
barrel, so a new hazard (another quote shape, a new gesture kind, another `.ad` command) belongs in
the generator, where every property inherits it — not in a new hand-pinned case.
- Keep examples that document a specific decision or a real past bug; add the general guarantee as a
property alongside them.
- Bound `numRuns` with the shared `PROPERTY_RUNS` / `PROPERTY_RUNS_SMALL` constants: properties run
in `unit-core` under the same slow-test budget as everything else.
- A failing property prints the shrunk counterexample plus the seed and path to replay it; paste
that seed into `fc.assert(..., { seed, path })` to re-run exactly that case.
## Affected-check selector (`pnpm check:affected`)
`pnpm check:affected --base <ref>` derives which local checks a diff needs, so
+1
View File
@@ -231,6 +231,7 @@
"@types/node": "^22.19.21",
"@vitest/coverage-v8": "4.1.8",
"fallow": "^2.95.0",
"fast-check": "^4.9.0",
"oxc-parser": "^0.138.0",
"oxfmt": "^0.42.0",
"oxlint": "^1.69.0",
+16
View File
@@ -33,6 +33,9 @@ importers:
fallow:
specifier: ^2.95.0
version: 2.95.0
fast-check:
specifier: ^4.9.0
version: 4.9.0
oxc-parser:
specifier: ^0.138.0
version: 0.138.0
@@ -1483,6 +1486,10 @@ packages:
engines: {node: '>=16'}
hasBin: true
fast-check@4.9.0:
resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==}
engines: {node: '>=12.17.0'}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1978,6 +1985,9 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
pure-rand@8.4.2:
resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==}
quansync@1.0.0:
resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
@@ -3528,6 +3538,10 @@ snapshots:
'@fallow-cli/win32-arm64-msvc': 2.95.0
'@fallow-cli/win32-x64-msvc': 2.95.0
fast-check@4.9.0:
dependencies:
pure-rand: 8.4.2
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -4375,6 +4389,8 @@ snapshots:
proxy-from-env@2.1.0: {}
pure-rand@8.4.2: {}
quansync@1.0.0: {}
react-dom@19.2.7(react@19.2.7):
+13
View File
@@ -28,6 +28,19 @@ export {
export { makeSessionStore } from './store-factory.ts';
export {
COMPACT_VIEWPORTS,
formatRef,
formatSelectorChainExpression,
gestureInViewportArb,
PROPERTY_RUNS,
PROPERTY_RUNS_SMALL,
rawSnapshotNodesArb,
refArb,
replayScriptArb,
selectorChainArb,
} from './property-arbitraries.ts';
export { withNoColor } from './color.ts';
export {
@@ -0,0 +1,500 @@
import fc from 'fast-check';
import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts';
import { GESTURE_KINDS, type GesturePayload } from '../../contracts/gesture-input.ts';
import { SCROLL_DIRECTIONS, SWIPE_PRESETS } from '../../contracts/scroll-gesture.ts';
import type { Point, RawSnapshotNode, Rect } from '../../kernel/snapshot.ts';
import { SELECTOR_KEY_NAMES, type SelectorKey, type SelectorTerm } from '../../selectors/parse.ts';
/**
* Shared fast-check generators for the pure parse/print and geometry kernels
* (#1413). Each property lives in its owning module's test file; the inputs
* live here so the hazard vocabulary (quote/apostrophe/escape shapes, compact
* viewports, pinned refs) is written once and every property inherits the next
* hazard someone adds.
*
* fast-check reports the SHRUNK counterexample plus the seed/path to replay it,
* so a failure names a minimal input rather than the raw random one.
*/
/**
* Run budget for every property in the unit suite. Properties share the unit
* slow-test budget (2.5s per file, see docs/agents/testing.md), so the count is
* bounded here rather than per call site — raise it in one place, and only with
* a measured file duration.
*/
export const PROPERTY_RUNS = 100;
/** Cheaper budget for properties whose single run does real work (diff, planning). */
export const PROPERTY_RUNS_SMALL = 40;
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
/**
* Value shape per selector key. The parser owns the key set; this only says how
* each key's right-hand side is spelled, and `Record<SelectorKey, …>` makes a
* new key a typecheck failure here instead of a silent hole in the property.
*/
const SELECTOR_KEY_VALUE_KINDS = {
id: 'text',
role: 'text',
text: 'text',
label: 'text',
value: 'text',
appname: 'text',
windowtitle: 'text',
visible: 'boolean',
hidden: 'boolean',
editable: 'boolean',
selected: 'boolean',
focused: 'boolean',
enabled: 'boolean',
hittable: 'boolean',
} satisfies Record<SelectorKey, 'text' | 'boolean'>;
/** Keys of one value shape, derived from the parser's own `SELECTOR_KEY_NAMES`. */
function selectorKeysOfKind(kind: 'text' | 'boolean'): SelectorKey[] {
const keys = SELECTOR_KEY_NAMES.filter((key) => {
const valueKind: string | undefined = SELECTOR_KEY_VALUE_KINDS[key];
if (valueKind === undefined) {
throw new Error(
`selector key "${key}" from SELECTOR_KEY_NAMES (src/selectors/parse.ts) has no value shape: ` +
'add it to SELECTOR_KEY_VALUE_KINDS in src/__tests__/test-utils/property-arbitraries.ts',
);
}
return valueKind === kind;
});
if (keys.length === 0) throw new Error(`no selector keys of value shape "${kind}"`);
return keys;
}
/**
* The shapes that broke hand-written selector examples: both quote characters,
* backslash runs before a quote, the `||` fallback separator and `=` inside a
* value, whitespace-only values, and non-BMP text.
*/
const SELECTOR_VALUE_HAZARDS = [
'',
' ',
'"',
"'",
"it's",
'say "hi"',
'\\',
'\\"',
'a\\\\b',
'a || b',
'key=value',
'line\nbreak',
'\tTab',
'Ünïcøde',
'😀 emoji',
] as const;
const selectorTextValueArb: fc.Arbitrary<string> = fc.oneof(
fc.constantFrom(...SELECTOR_VALUE_HAZARDS),
fc.string({ minLength: 0, maxLength: 12 }),
fc
.array(fc.constantFrom(...SELECTOR_VALUE_HAZARDS), { minLength: 2, maxLength: 3 })
.map((parts) => parts.join('')),
);
const selectorTermArb: fc.Arbitrary<SelectorTerm> = fc.oneof(
fc
.record({
key: fc.constantFrom(...selectorKeysOfKind('text')),
value: selectorTextValueArb,
})
// fast-check records are null-prototype; parsed terms are plain objects.
.map(({ key, value }) => ({ key, value })),
fc
.record({
key: fc.constantFrom(...selectorKeysOfKind('boolean')),
value: fc.boolean(),
})
.map(({ key, value }) => ({ key, value })),
);
export type GeneratedSelectorChain = {
/** Terms per fallback segment, in source order. */
selectors: SelectorTerm[][];
/** A source expression for those terms; boolean terms may use the bare form. */
expression: string;
};
/**
* A selector chain plus one source expression that denotes it. `bareBooleans`
* emits `visible` instead of `visible=true`, the other accepted spelling of a
* true boolean term.
*/
export const selectorChainArb: fc.Arbitrary<GeneratedSelectorChain> = fc
.record({
selectors: fc.array(fc.array(selectorTermArb, { minLength: 1, maxLength: 4 }), {
minLength: 1,
maxLength: 3,
}),
bareBooleans: fc.boolean(),
})
.filter(({ selectors }) => selectors.length > 0)
.map(({ selectors, bareBooleans }) => ({
selectors,
expression: formatSelectorChainExpression(selectors, { bareBooleans }),
}));
/** Canonical printer: every text value quoted, every boolean term explicit. */
function formatSelectorTerm(term: SelectorTerm, options: { bareBooleans?: boolean } = {}): string {
if (typeof term.value === 'boolean') {
return options.bareBooleans && term.value ? term.key : `${term.key}=${term.value}`;
}
return `${term.key}=${JSON.stringify(term.value)}`;
}
export function formatSelectorChainExpression(
selectors: readonly (readonly SelectorTerm[])[],
options: { bareBooleans?: boolean } = {},
): string {
return selectors
.map((terms) => terms.map((term) => formatSelectorTerm(term, options)).join(' '))
.join(' || ');
}
// ---------------------------------------------------------------------------
// Refs (`@eN~sM`)
// ---------------------------------------------------------------------------
export type GeneratedRef = { base: string; generation?: number };
export const refArb: fc.Arbitrary<GeneratedRef> = fc
.record({
at: fc.boolean(),
index: fc.nat({ max: 999 }),
generation: fc.option(fc.nat({ max: 999 }), { nil: undefined }),
})
.map(({ at, index, generation }) => ({
base: `${at ? '@' : ''}e${index}`,
...(generation === undefined ? {} : { generation }),
}));
/** The `@eN~sM` spelling of a split ref; the inverse of `splitRefGenerationSuffix`. */
export function formatRef(ref: GeneratedRef): string {
return ref.generation === undefined ? ref.base : `${ref.base}~s${ref.generation}`;
}
// ---------------------------------------------------------------------------
// Rects / viewports
// ---------------------------------------------------------------------------
/** Smallest supported iPhone viewports — the geometry examples worth keeping. */
export const COMPACT_VIEWPORTS: readonly Rect[] = [
{ x: 0, y: 0, width: 320, height: 568 },
{ x: 0, y: 0, width: 375, height: 667 },
];
const viewportRectArb: fc.Arbitrary<Rect> = fc.oneof(
fc.constantFrom(...COMPACT_VIEWPORTS),
fc.record({
x: fc.integer({ min: 0, max: 200 }),
y: fc.integer({ min: 0, max: 200 }),
width: fc.integer({ min: 1, max: 2400 }),
height: fc.integer({ min: 1, max: 2400 }),
}),
);
/** A point sampled from the viewport's own box, so most gestures are plannable. */
function pointInViewportArb(viewport: Rect): fc.Arbitrary<Point> {
return fc.record({
x: fc.integer({ min: Math.floor(viewport.x), max: Math.floor(viewport.x + viewport.width) }),
y: fc.integer({ min: Math.floor(viewport.y), max: Math.floor(viewport.y + viewport.height) }),
});
}
// ---------------------------------------------------------------------------
// Gestures
// ---------------------------------------------------------------------------
/**
* One payload arbitrary per canonical gesture kind. Keyed by `GESTURE_KINDS`,
* so a new kind fails to typecheck here instead of silently escaping the
* geometry property.
*/
function gesturePayloadArbByKind(
viewport: Rect,
): Record<(typeof GESTURE_KINDS)[number], fc.Arbitrary<GesturePayload>> {
const origin = pointInViewportArb(viewport);
const delta = fc.record({
x: fc.integer({ min: -viewport.width, max: viewport.width }),
y: fc.integer({ min: -viewport.height, max: viewport.height }),
});
const durationMs = fc.option(fc.integer({ min: 16, max: 2000 }), { nil: undefined });
const scale = fc.double({ min: 0.1, max: 4, noNaN: true, noDefaultInfinity: true });
return {
pan: fc.record({
kind: fc.constant('pan' as const),
origin,
delta,
pointerCount: fc.constantFrom(undefined, 1 as const, 2 as const),
durationMs,
}),
fling: fc.record({
kind: fc.constant('fling' as const),
direction: fc.constantFrom(...SCROLL_DIRECTIONS),
origin,
distance: fc.option(fc.integer({ min: 1, max: 1200 }), { nil: undefined }),
}),
swipe: fc.record({
kind: fc.constant('swipe' as const),
preset: fc.constantFrom(...SWIPE_PRESETS),
}),
pinch: fc.record({
kind: fc.constant('pinch' as const),
scale,
origin: fc.option(origin, { nil: undefined }),
}),
rotate: fc.record({
kind: fc.constant('rotate' as const),
degrees: fc.integer({ min: -720, max: 720 }),
origin: fc.option(origin, { nil: undefined }),
}),
transform: fc.record({
kind: fc.constant('transform' as const),
origin,
delta,
scale,
degrees: fc.integer({ min: -720, max: 720 }),
durationMs,
}),
};
}
/** A viewport with a gesture payload of every supported kind planned against it. */
export const gestureInViewportArb: fc.Arbitrary<{ viewport: Rect; gesture: GesturePayload }> =
viewportRectArb.chain((viewport) => {
const byKind = gesturePayloadArbByKind(viewport);
return fc.record({
viewport: fc.constant(viewport),
gesture: fc.oneof(...GESTURE_KINDS.map((kind) => byKind[kind])),
});
});
// ---------------------------------------------------------------------------
// Snapshot trees
// ---------------------------------------------------------------------------
const SNAPSHOT_NODE_TYPES = [
'XCUIElementTypeWindow',
'XCUIElementTypeButton',
'XCUIElementTypeStaticText',
'XCUIElementTypeTextField',
'XCUIElementTypeCell',
'android.widget.TextView',
'android.widget.Button',
] as const;
const SNAPSHOT_LABELS = ['Increment', 'Submit', 'Email', '67', '', 'Ünïcøde', 'a b c'] as const;
/** A snapshot tree shaped like a rendered one: sequential indexes, bounded depth. */
export const rawSnapshotNodesArb: fc.Arbitrary<RawSnapshotNode[]> = fc
.array(
fc.record({
type: fc.constantFrom(...SNAPSHOT_NODE_TYPES),
label: fc.option(fc.constantFrom(...SNAPSHOT_LABELS), { nil: undefined }),
value: fc.option(fc.constantFrom(...SNAPSHOT_LABELS), { nil: undefined }),
depth: fc.integer({ min: 0, max: 3 }),
enabled: fc.boolean(),
selected: fc.boolean(),
hittable: fc.boolean(),
}),
{ maxLength: 12 },
)
.map((nodes) => nodes.map((node, index) => ({ ...node, index })));
// ---------------------------------------------------------------------------
// `.ad` script lines
// ---------------------------------------------------------------------------
const scriptTextArb: fc.Arbitrary<string> = fc.oneof(
fc.constantFrom('Submit', 'Log in', 'a "quoted" label', "it's fine", 'tab\tseparated', 'Ünïcøde'),
fc.string({ minLength: 1, maxLength: 10 }),
);
const scriptTargetArb: fc.Arbitrary<string> = fc.oneof(
refArb.map(formatRef),
scriptTextArb.map((text) => JSON.stringify(`label=${text}`)),
);
const coordinateArb = fc.integer({ min: 0, max: 1200 }).map(String);
/**
* A command is either generated from a line template or explicitly waived with
* the reason it needs none. `Record<PublicCommandName, …>` makes the
* classification exhaustive: a new public command does not compile until it is
* given a template or a waiver.
*/
/**
* Every command name a `.ad` line can carry. Internal commands are included
* because scripts record them too (`runtime` has its own parse/print branch).
*/
type ReplayCommandName =
| (typeof PUBLIC_COMMANDS)[keyof typeof PUBLIC_COMMANDS]
| (typeof INTERNAL_COMMANDS)[keyof typeof INTERNAL_COMMANDS];
type ReplayLinePlan = fc.Arbitrary<string> | { waived: string };
/**
* Commands whose `.ad` line is a bare `<command> <token>…` handled by the
* generic parse/print branch (`appendGenericActionScriptArgs`), whose shape the
* `wait`/`longpress` templates already exercise. A command that grows its own
* branch in src/replay/script.ts or src/replay/script-formatting.ts must move
* to a template.
*/
const GENERIC_REPLAY_LINE = {
waived: 'generic line shape, covered by the wait/longpress templates',
} as const;
const REPLAY_SCRIPT_LINE_PLANS = {
click: fc.oneof(
scriptTargetArb.map((target) => `click ${target}`),
fc.tuple(coordinateArb, coordinateArb).map(([x, y]) => `click ${x} ${y}`),
fc
.tuple(scriptTargetArb, fc.integer({ min: 1, max: 5 }))
.map(([target, count]) => `click ${target} --count ${count}`),
),
press: fc.oneof(
scriptTargetArb.map((target) => `press ${target}`),
fc
.tuple(scriptTargetArb, fc.constantFrom('primary', 'secondary', 'middle'))
.map(([target, button]) => `press ${target} --button ${button}`),
),
longpress: scriptTargetArb.map((target) => `longpress ${target}`),
wait: fc
.tuple(scriptTargetArb, fc.integer({ min: 100, max: 5000 }))
.map(([target, timeout]) => `wait ${target} ${timeout}`),
fill: fc
.tuple(scriptTargetArb, scriptTextArb)
.map(([target, text]) => `fill ${target} ${JSON.stringify(text)}`),
type: scriptTextArb.map((text) => `type ${JSON.stringify(text)}`),
get: fc
.tuple(fc.constantFrom('text', 'value', 'label'), scriptTargetArb)
.map(([property, target]) => `get ${property} ${target}`),
swipe: fc
.tuple(coordinateArb, coordinateArb, coordinateArb, coordinateArb)
.map((coordinates) => `swipe ${coordinates.join(' ')}`),
gesture: fc.oneof(
fc
.tuple(coordinateArb, coordinateArb, coordinateArb, coordinateArb)
.map((coordinates) => `gesture pan ${coordinates.join(' ')}`),
fc
.tuple(fc.constantFrom(...SCROLL_DIRECTIONS), coordinateArb, coordinateArb)
.map(([direction, x, y]) => `gesture fling ${direction} ${x} ${y}`),
fc.constantFrom(...SWIPE_PRESETS).map((preset) => `gesture swipe ${preset}`),
fc.integer({ min: -180, max: 180 }).map((degrees) => `gesture rotate ${degrees}`),
),
snapshot: fc.oneof(
fc.constant('snapshot'),
fc.constant('snapshot -i'),
fc.integer({ min: 0, max: 6 }).map((depth) => `snapshot -d ${depth}`),
scriptTextArb.map((scope) => `snapshot -s ${JSON.stringify(scope)}`),
),
screenshot: fc.constantFrom('screenshot', 'screenshot out.png'),
open: fc.oneof(
fc.constantFrom('open com.example.app', 'open com.example.app --relaunch'),
fc
.tuple(fc.constantFrom('ios', 'android'), fc.integer({ min: 1, max: 65_535 }))
.map(
([platform, port]) => `open com.example.app --platform ${platform} --metro-port ${port}`,
),
),
runtime: fc.oneof(
fc.constant('runtime metro'),
fc.constantFrom('ios', 'android').map((platform) => `runtime metro --platform ${platform}`),
),
record: fc.oneof(
fc.constantFrom('record start', 'record stop', 'record start --hide-touches'),
fc.integer({ min: 1, max: 120 }).map((fps) => `record start --fps ${fps}`),
),
alert: GENERIC_REPLAY_LINE,
'app-switcher': GENERIC_REPLAY_LINE,
apps: GENERIC_REPLAY_LINE,
appstate: GENERIC_REPLAY_LINE,
artifacts: GENERIC_REPLAY_LINE,
audio: GENERIC_REPLAY_LINE,
back: GENERIC_REPLAY_LINE,
batch: GENERIC_REPLAY_LINE,
boot: GENERIC_REPLAY_LINE,
capabilities: GENERIC_REPLAY_LINE,
clipboard: GENERIC_REPLAY_LINE,
close: GENERIC_REPLAY_LINE,
devices: GENERIC_REPLAY_LINE,
diff: GENERIC_REPLAY_LINE,
doctor: GENERIC_REPLAY_LINE,
events: GENERIC_REPLAY_LINE,
find: GENERIC_REPLAY_LINE,
focus: GENERIC_REPLAY_LINE,
home: GENERIC_REPLAY_LINE,
install: GENERIC_REPLAY_LINE,
'install-from-source': GENERIC_REPLAY_LINE,
is: GENERIC_REPLAY_LINE,
keyboard: GENERIC_REPLAY_LINE,
logs: GENERIC_REPLAY_LINE,
network: GENERIC_REPLAY_LINE,
orientation: GENERIC_REPLAY_LINE,
perf: GENERIC_REPLAY_LINE,
prepare: GENERIC_REPLAY_LINE,
push: GENERIC_REPLAY_LINE,
'react-native': GENERIC_REPLAY_LINE,
reinstall: GENERIC_REPLAY_LINE,
replay: GENERIC_REPLAY_LINE,
scroll: GENERIC_REPLAY_LINE,
settings: GENERIC_REPLAY_LINE,
shutdown: GENERIC_REPLAY_LINE,
test: GENERIC_REPLAY_LINE,
trace: GENERIC_REPLAY_LINE,
'trigger-app-event': GENERIC_REPLAY_LINE,
'tv-remote': GENERIC_REPLAY_LINE,
viewport: GENERIC_REPLAY_LINE,
install_source: GENERIC_REPLAY_LINE,
lease_allocate: GENERIC_REPLAY_LINE,
lease_heartbeat: GENERIC_REPLAY_LINE,
lease_release: GENERIC_REPLAY_LINE,
release_materialized_paths: GENERIC_REPLAY_LINE,
session_list: GENERIC_REPLAY_LINE,
session_save_script: GENERIC_REPLAY_LINE,
} satisfies Record<ReplayCommandName, ReplayLinePlan>;
/**
* Line templates for every command, resolved through the runtime command
* catalog so a command that exists at runtime but not in the plan fails loudly.
*/
function replayScriptLineArbs(): fc.Arbitrary<string>[] {
const arbitraries: fc.Arbitrary<string>[] = [];
const commands: ReplayCommandName[] = [
...Object.values(PUBLIC_COMMANDS),
...Object.values(INTERNAL_COMMANDS),
];
for (const command of commands) {
const plan: ReplayLinePlan | undefined = REPLAY_SCRIPT_LINE_PLANS[command];
if (plan === undefined) {
throw new Error(
`replay command "${command}" from src/command-catalog.ts is unclassified: ` +
'add a line template or a waiver to REPLAY_SCRIPT_LINE_PLANS in ' +
'src/__tests__/test-utils/property-arbitraries.ts',
);
}
if (!('waived' in plan)) arbitraries.push(plan);
}
return arbitraries;
}
const replayScriptLineArb: fc.Arbitrary<string> = fc.oneof(...replayScriptLineArbs());
/** A valid `.ad` script: action lines interleaved with comments and blank lines. */
export const replayScriptArb: fc.Arbitrary<string> = fc
.array(
fc.oneof(
{ weight: 8, arbitrary: replayScriptLineArb },
{ weight: 1, arbitrary: fc.constantFrom('# a comment', '') },
),
{ minLength: 1, maxLength: 8 },
)
.map((lines) => `${lines.join('\n')}\n`);
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import type { Point, Rect } from '../../kernel/snapshot.ts';
import { buildGesturePlan, type MultiTouchGesturePlan } from '../../contracts/gesture-plan.ts';
import {
buildGesturePlan,
type GesturePlan,
type MultiTouchGesturePlan,
} from '../../contracts/gesture-plan.ts';
export const PORTRAIT: Rect = { x: 0, y: 0, width: 390, height: 844 };
export const LANDSCAPE: Rect = { x: 0, y: 0, width: 844, height: 390 };
@@ -48,7 +52,7 @@ export function rotationDelta(plan: MultiTouchGesturePlan): number {
return normalizeDegrees(end - start);
}
export function assertAllSamplesInViewport(plan: MultiTouchGesturePlan): void {
export function assertAllSamplesInViewport(plan: GesturePlan): void {
for (const pointer of plan.pointers) {
for (const { point } of pointer.samples) {
assert.ok(point.x >= plan.viewport.x && point.x <= plan.viewport.x + plan.viewport.width);
@@ -1,6 +1,18 @@
import assert from 'node:assert/strict';
import fc from 'fast-check';
import { describe, test } from 'vitest';
import { buildGesturePlan, GESTURE_SAMPLE_INTERVAL_MS } from '../../contracts/gesture-plan.ts';
import {
gesturePayloadFromPositionals,
normalizePublicGesture,
} from '../../contracts/gesture-normalization.ts';
import { PUBLIC_PLATFORMS } from '../../kernel/device.ts';
import { AppError } from '../../kernel/errors.ts';
import {
COMPACT_VIEWPORTS,
gestureInViewportArb,
PROPERTY_RUNS_SMALL,
} from '../../__tests__/test-utils/index.ts';
import {
assertAllSamplesInViewport,
isErrorWithReason,
@@ -142,6 +154,43 @@ describe('viewport-aware multi-touch geometry', () => {
});
});
// Property, not another example: the planner's whole job is that a synthesized
// point never leaves the active viewport. It either plans in bounds or refuses
// with a structured INVALID_ARGS — never a silently out-of-bounds path. The
// generator enumerates every gesture kind from GESTURE_KINDS, so a new kind
// inherits the guarantee instead of needing its own pinned viewport case.
describe('gesture planning never synthesizes an out-of-bounds point', () => {
test('holds for every gesture kind, viewport, and platform', () => {
fc.assert(
fc.property(
gestureInViewportArb,
fc.constantFrom(...PUBLIC_PLATFORMS),
({ viewport, gesture }, platform) => {
let plan;
try {
plan = buildGesturePlan(normalizePublicGesture(gesture).gesture, viewport, platform);
} catch (error) {
assert.ok(error instanceof AppError, `unexpected error type: ${String(error)}`);
assert.equal(error.code, 'INVALID_ARGS');
return;
}
assertAllSamplesInViewport(plan);
},
),
{ numRuns: PROPERTY_RUNS_SMALL },
);
});
// The smallest supported iPhone viewports, kept as examples of the general
// property above: a recorded swipe must still fit a compact screen.
test.each(COMPACT_VIEWPORTS)('fits a recorded swipe into %j', (viewport) => {
const gesture = normalizePublicGesture(
gesturePayloadFromPositionals(['pan', '160', '400', '0', '-120']),
).gesture;
assertAllSamplesInViewport(buildGesturePlan(gesture, viewport, 'ios'));
});
});
function buildTransformPlan(durationMs: number) {
return requireTwoPointerPlan(
buildGesturePlan(
+55 -1
View File
@@ -1,7 +1,9 @@
import fc from 'fast-check';
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { buildSnapshotDiff } from '../../snapshot/snapshot-diff.ts';
import { buildSnapshotDiff, countSnapshotComparableLines } from '../../snapshot/snapshot-diff.ts';
import { buildNodes as nodes } from '../../__tests__/test-utils/snapshot-builders.ts';
import { PROPERTY_RUNS_SMALL, rawSnapshotNodesArb } from '../../__tests__/test-utils/index.ts';
test('buildSnapshotDiff reports unchanged lines when snapshots are equal', () => {
const previous = nodes([
@@ -106,3 +108,55 @@ test('buildSnapshotDiff flatten option uses flat snapshot line shape', () => {
assert.equal(line.text.startsWith(' '), false);
}
});
// Properties, not more examples: the two invariants a diff consumer relies on
// hold for every tree pair, not for the hand-built ones above — an unchanged
// UI must produce no churn, and the summary must be a count of the rendered
// lines rather than an independently maintained tally.
test('diffing a tree against itself reports no change', () => {
fc.assert(
fc.property(rawSnapshotNodesArb, fc.boolean(), (raw, flatten) => {
const tree = nodes(raw);
const diff = buildSnapshotDiff(tree, tree, { flatten });
assert.equal(diff.summary.additions, 0);
assert.equal(diff.summary.removals, 0);
assert.equal(diff.summary.unchanged, countSnapshotComparableLines(tree, { flatten }));
assert.deepEqual(
diff.lines.filter((line) => line.kind !== 'unchanged'),
[],
);
}),
{ numRuns: PROPERTY_RUNS_SMALL },
);
});
test('diff summary counts always equal the rendered line counts', () => {
fc.assert(
fc.property(
rawSnapshotNodesArb,
rawSnapshotNodesArb,
fc.boolean(),
(rawPrevious, rawCurrent, flatten) => {
const previous = nodes(rawPrevious);
const current = nodes(rawCurrent);
const diff = buildSnapshotDiff(previous, current, { flatten });
const counted = { additions: 0, removals: 0, unchanged: 0 };
for (const line of diff.lines) {
if (line.kind === 'added') counted.additions += 1;
if (line.kind === 'removed') counted.removals += 1;
if (line.kind === 'unchanged') counted.unchanged += 1;
}
assert.deepEqual(diff.summary, counted);
assert.equal(
diff.summary.additions + diff.summary.unchanged,
countSnapshotComparableLines(current, { flatten }),
);
assert.equal(
diff.summary.removals + diff.summary.unchanged,
countSnapshotComparableLines(previous, { flatten }),
);
},
),
{ numRuns: PROPERTY_RUNS_SMALL },
);
});
@@ -1,4 +1,6 @@
import fc from 'fast-check';
import { expect, test } from 'vitest';
import { formatRef, PROPERTY_RUNS, refArb } from '../../__tests__/test-utils/index.ts';
import { normalizeRef, splitRefGenerationSuffix } from '../snapshot.ts';
// #1076 versioned refs: `~s<generation>` is accepted INPUT on every ref parse
@@ -29,6 +31,22 @@ test('splitRefGenerationSuffix rejects malformed suffixes', () => {
expect(splitRefGenerationSuffix('~s3')).toBeNull();
});
// Property, not another example: `@eN~sM` is a two-part grammar, so split and
// join must be inverses for every ref and generation, not just the pinned
// examples above.
test('splitting then rejoining any pinned ref returns the same token', () => {
fc.assert(
fc.property(refArb, (ref) => {
const token = formatRef(ref);
const split = splitRefGenerationSuffix(token);
expect(split).toEqual(ref);
expect(split && formatRef(split)).toBe(token);
expect(normalizeRef(token)).toBe(ref.base.replace(/^@/, ''));
}),
{ numRuns: PROPERTY_RUNS },
);
});
test('normalizeRef keeps legacy behavior for plain refs', () => {
expect(normalizeRef('@e12')).toBe('e12');
expect(normalizeRef('e12')).toBe('e12');
+24
View File
@@ -1,5 +1,7 @@
import fc from 'fast-check';
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { PROPERTY_RUNS, replayScriptArb } from '../../__tests__/test-utils/index.ts';
import { AppError } from '../../kernel/errors.ts';
import {
parseReplayScriptDetailed,
@@ -546,3 +548,25 @@ test('formatDivergenceActionLabel categorically drops fill/type text but keeps t
'click "label=\\"Save\\""',
);
});
// Property, not another example: `.ad` scripts are written by hand, recorded,
// and rewritten, so the parser and the line formatter must agree on ONE
// canonical form — re-serializing a parsed script has to be a fixed point.
// Generated lines come from the shared `.ad` generator, so a new command shape
// extends the generator rather than adding another pinned script here.
test('serializing a parsed script is a fixed point for generated scripts', () => {
fc.assert(
fc.property(replayScriptArb, (script) => {
const parsed = parseReplayScriptDetailed(script).actions;
const canonical = formatReplayScriptForTest(parsed);
const reparsed = parseReplayScriptDetailed(canonical).actions;
assert.equal(formatReplayScriptForTest(reparsed), canonical);
// The action identity survives the rewrite: same commands, same targets.
assert.deepEqual(
reparsed.map((action) => [action.command, action.positionals]),
parsed.map((action) => [action.command, action.positionals]),
);
}),
{ numRuns: PROPERTY_RUNS },
);
});
+40
View File
@@ -0,0 +1,40 @@
import fc from 'fast-check';
import { test } from 'vitest';
import assert from 'node:assert/strict';
import {
formatSelectorChainExpression,
PROPERTY_RUNS,
selectorChainArb,
} from '../__tests__/test-utils/index.ts';
import { parseSelectorChain } from './parse.ts';
import type { SelectorTerm } from './parse.ts';
function chainTerms(expression: string): SelectorTerm[][] {
return parseSelectorChain(expression).selectors.map((selector) => selector.terms);
}
// Property, not another example: a selector value carries user text, so the
// grammar has to survive both quote characters, backslash runs, `||` and `=`
// inside a value, and whitespace-only values. Extend the hazard vocabulary in
// src/__tests__/test-utils/property-arbitraries.ts rather than pinning one more
// case here.
test('parsing a printed selector chain yields the terms it was printed from', () => {
fc.assert(
fc.property(selectorChainArb, ({ selectors, expression }) => {
assert.deepEqual(chainTerms(expression), selectors);
}),
{ numRuns: PROPERTY_RUNS },
);
});
test('parse -> serialize -> parse is idempotent for every selector chain', () => {
fc.assert(
fc.property(selectorChainArb, ({ expression }) => {
const terms = chainTerms(expression);
const canonical = formatSelectorChainExpression(terms);
assert.deepEqual(chainTerms(canonical), terms);
assert.equal(formatSelectorChainExpression(chainTerms(canonical)), canonical);
}),
{ numRuns: PROPERTY_RUNS },
);
});