Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions packages/contracts/src/snapshot-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ export function findSnapshotAncestor<T>(
return null;
}

/**
* Returns the nearest ancestor matching `predicate`; false means keep walking.
*/
/** Returns the nearest ancestor matching `predicate`; false means keep walking. */
export function findNearestAncestor(
nodes: SnapshotNode[],
node: SnapshotNode,
Expand Down
7 changes: 3 additions & 4 deletions packages/contracts/src/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,9 @@ test('findNearestAncestor adapts a predicate to the shared tree walk', () => {
{ ref: 'e20', index: 20, parentIndex: 10, type: 'Cell' },
];

assert.equal(
findNearestAncestor(nodes, nodes[1]!, (ancestor) => ancestor.type === 'Window')?.index,
10,
);
const isWindow = (ancestor: SnapshotNode) => ancestor.type === 'Window';

assert.equal(findNearestAncestor(nodes, nodes[1]!, isWindow)?.index, 10);
});

test('snapshot tree and scroll semantics identify nodes through their stable indexes', () => {
Expand Down
107 changes: 107 additions & 0 deletions src/core/interaction-targeting.fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,110 @@ export const ELEMENT14_DISTINCT_SUBTREE_NODES: RawSnapshotNode[] = [
hittable: true,
},
];

/**
* One node per decision `resolveActionableTouchResolution` can reach, so an
* indexed pass and an unindexed one can be compared across the whole policy
* rather than on the branch a single example happens to hit: a same-rect
* actionable descendant (1 -> 2), semantic targets (3, 4), a nonhittable leaf
* under a hittable ancestor (5), both overly-broad shapes — a scrolling
* container (7) and the viewport-sized root (10) — a covered node (8), and a
* parentless, rectless node with no usable target at all (9).
*/
export const INDEXED_PARITY_POLICY_NODES: RawSnapshotNode[] = [
{
index: 0,
depth: 0,
type: 'XCUIElementTypeApplication',
rect: { x: 0, y: 0, width: 390, height: 844 },
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'XCUIElementTypeOther',
label: 'Save wrapper',
rect: { x: 20, y: 100, width: 120, height: 44 },
hittable: false,
},
{
index: 2,
depth: 2,
parentIndex: 1,
type: 'XCUIElementTypeImage',
identifier: 'save-hit-area',
rect: { x: 20, y: 100, width: 120, height: 44 },
hittable: true,
},
{
index: 3,
depth: 1,
parentIndex: 0,
type: 'XCUIElementTypeButton',
label: 'Save',
rect: { x: 20, y: 200, width: 100, height: 40 },
hittable: false,
},
{
index: 4,
depth: 1,
parentIndex: 0,
type: 'XCUIElementTypeCell',
label: 'Account row',
rect: { x: 10, y: 260, width: 370, height: 60 },
hittable: true,
},
{
index: 5,
depth: 2,
parentIndex: 4,
type: 'XCUIElementTypeStaticText',
label: 'Account',
rect: { x: 24, y: 272, width: 80, height: 20 },
hittable: false,
},
{
index: 6,
depth: 1,
parentIndex: 0,
type: 'XCUIElementTypeScrollView',
rect: { x: 0, y: 340, width: 390, height: 300 },
hittable: true,
},
{
index: 7,
depth: 2,
parentIndex: 6,
type: 'XCUIElementTypeOther',
label: 'Feed item',
rect: { x: 20, y: 360, width: 200, height: 40 },
hittable: false,
},
{
index: 8,
depth: 1,
parentIndex: 0,
type: 'XCUIElementTypeStaticText',
label: 'Under overlay',
rect: { x: 20, y: 700, width: 100, height: 20 },
hittable: false,
interactionBlocked: 'covered',
},
{
index: 9,
depth: 0,
type: 'XCUIElementTypeOther',
label: 'Virtual item',
hittable: false,
},
{
index: 10,
depth: 1,
parentIndex: 0,
type: 'XCUIElementTypeStaticText',
label: 'Status',
rect: { x: 20, y: 760, width: 60, height: 20 },
hittable: false,
},
];
30 changes: 30 additions & 0 deletions src/core/interaction-targeting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import {
import { makeSnapshotState } from '../__tests__/test-utils/snapshot-builders.ts';
import {
classifyActionableTouchCandidates,
createActionableTouchResolver,
resolveActionableTouchResolution,
} from './interaction-targeting.ts';
import {
ELEMENT14_DISTINCT_SUBTREE_NODES,
EQUIVALENT_WRAPPER_CHAIN_NODES,
INDEXED_PARITY_POLICY_NODES,
} from './interaction-targeting.fixtures.ts';

test('collapses one same-label wrapper chain to its shared actionable node', () => {
Expand Down Expand Up @@ -201,3 +203,31 @@ test('falls back to the original node when no usable touch target exists', () =>
assert.equal(resolution.reason, 'original');
assert.equal(resolution.node.label, 'Virtual item');
});

test('the batch resolver preserves every actionability policy branch', () => {
const snapshot = makeSnapshotState(INDEXED_PARITY_POLICY_NODES);
const resolveTouch = createActionableTouchResolver(snapshot.nodes);

const unindexed = snapshot.nodes.map((node) =>
resolveActionableTouchResolution(snapshot.nodes, node),
);
const indexed = snapshot.nodes.map(resolveTouch);

assert.deepEqual(indexed, unindexed);
assert.deepEqual(
indexed.map((resolution) => [resolution.node.index, resolution.reason]),
[
[0, 'hittable-ancestor'],
[2, 'same-rect-descendant'],
[2, 'hittable-ancestor'],
[3, 'semantic-target'],
[4, 'semantic-target'],
[4, 'hittable-ancestor'],
[6, 'hittable-ancestor'],
[7, 'overly-broad-ancestor'],
[8, 'covered'],
[9, 'original'],
[10, 'overly-broad-ancestor'],
],
);
});
Loading
Loading