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
31 changes: 31 additions & 0 deletions packages/selectors/src/interaction-touch-point.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,37 @@ test('keeps the exact center when there are no competing interactive descendants
});
});

test.each<{ name: string; rect: Rect }>([
{ name: 'zero width and height', rect: { x: 0, y: 0, width: 0, height: 0 } },
{ name: 'zero width', rect: { x: 40, y: 120, width: 0, height: 60 } },
{ name: 'zero height', rect: { x: 40, y: 120, width: 60, height: 0 } },
])('refuses a $name target instead of resolving its origin', ({ rect }) => {
const nodes = makeSnapshotState([
{ index: 0, depth: 0, type: 'Icon', label: 'Clock', rect, hittable: false },
]).nodes;

assert.deepEqual(resolveInteractionTouchPoint(nodes, nodes[0]!), { kind: 'invalid' });
});

test('still resolves the smallest positive-area target', () => {
const nodes = makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Button',
label: 'Dot',
rect: { x: 40, y: 120, width: 1, height: 1 },
hittable: true,
},
]).nodes;

assert.deepEqual(resolveInteractionTouchPoint(nodes, nodes[0]!), {
kind: 'resolved',
point: { x: 41, y: 121 },
strategy: 'center',
});
});

test('keeps a parent-owned center in a thin row when a child is safely off to the side', () => {
const nodes = makeSnapshotState([
{
Expand Down
6 changes: 5 additions & 1 deletion packages/selectors/src/interaction-touch-point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function resolveInteractionTouchPoint(
): InteractionTouchPointResolution {
const targetRect = normalizeRect(node.rect);
const center = resolveRectCenter(targetRect ?? undefined);
if (!targetRect || !center) return { kind: 'invalid' };
if (!targetRect || !center || !hasArea(targetRect)) return { kind: 'invalid' };

const competitors = collectInteractiveDescendantRects(nodes, node, targetRect);
if (competitors.length === 0) {
Expand All @@ -59,6 +59,10 @@ export function resolveInteractionTouchPoint(
return { kind: 'resolved', point, strategy: 'parent-owned' };
}

function hasArea(rect: Rect): boolean {
return rect.width > 0 && rect.height > 0;
}

function collectInteractiveDescendantRects(
nodes: readonly SnapshotNode[],
node: SnapshotNode,
Expand Down
42 changes: 42 additions & 0 deletions src/commands/interaction/runtime/resolution-touch-point.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,45 @@ test('runtime ref and selector paths dispatch the same parent-owned post point',
assert.ok(refCalls[0]!.y < 292);
assert.notDeepEqual(refCalls[0], { x: 201, y: 440 });
});

test('runtime press refuses a selector match with a zero-area rect and dispatches no tap', async () => {
const taps: Point[] = [];
// SpringBoard page 2 keeps page-1 icons in the tree as 0x0 rects at the origin.
const snapshot = makeSnapshotState([
{
index: 0,
depth: 0,
type: 'Application',
label: 'SpringBoard',
rect: { x: 0, y: 0, width: 402, height: 874 },
hittable: true,
},
{
index: 1,
depth: 1,
parentIndex: 0,
type: 'Icon',
label: 'Clock',
rect: { x: 0, y: 0, width: 0, height: 0 },
hittable: false,
},
]);
const device = createInteractionDevice(snapshot, {
tap: async (_context, point) => {
taps.push(point);
return { ok: true };
},
});

await assert.rejects(
() => device.interactions.press(selector('label="Clock"'), { session: 'default' }),
(error: unknown) => {
const details = (error as { details?: Record<string, unknown> }).details;
assert.equal((error as { code?: string }).code, 'COMMAND_FAILED');
assert.equal(details?.reason, 'target_bounds_invalid');
assert.equal(details?.dispatched, 'no');
return true;
},
);
assert.deepEqual(taps, []);
});
Loading