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
128 changes: 128 additions & 0 deletions src/platforms/android/__tests__/rect-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { Rect } from '@agent-device/kernel/snapshot';
import fc from 'fast-check';
import { expect, test } from 'vitest';
import { PROPERTY_RUNS } from '../../../__tests__/test-utils/property-arbitraries.ts';
import { unionCoverage } from '../rect-coverage.ts';
import { createAndroidSnapshotPresentationBudget } from '../snapshot-presentation.ts';

const smallRect = fc.record({
x: fc.integer({ min: -4, max: 20 }),
y: fc.integer({ min: -4, max: 20 }),
width: fc.integer({ min: 0, max: 6 }),
height: fc.integer({ min: 0, max: 6 }),
});

test('matches an exact unit-cell oracle for integer rectangles', () => {
fc.assert(
fc.property(
fc.array(smallRect, { maxLength: 6 }),
fc.array(smallRect, { maxLength: 6 }),
(covering, covered) => {
const coverage = unionCoverage(covering, covered);
expect(coverage).toBe(bruteForceCoverage(covering, covered));
expect(coverage).toBeGreaterThanOrEqual(0);
expect(coverage).toBeLessThanOrEqual(1);
expect(unionCoverage([...covering].reverse(), [...covered].reverse())).toBe(coverage);
expect(unionCoverage([...covering, ...covering], [...covered, ...covered])).toBe(coverage);
},
),
{ numRuns: PROPERTY_RUNS },
);
});

test('uses edges from both sets when measuring partial coverage', () => {
expect(
unionCoverage([{ x: 0, y: 0, width: 5, height: 10 }], [{ x: 0, y: 0, width: 10, height: 10 }]),
).toBe(0.5);
});

test('handles numeric edge ordering, adjacency, duplicates, and negative coordinates', () => {
expect(
unionCoverage(
[{ x: 9, y: -10, width: 91, height: 10 }],
[{ x: 9, y: -10, width: 91, height: 10 }],
),
).toBe(1);
expect(
unionCoverage(
[
{ x: -10, y: 0, width: 10, height: 10 },
{ x: 10, y: 0, width: 10, height: 10 },
],
[{ x: 0, y: 0, width: 10, height: 10 }],
),
).toBe(0);
expect(
unionCoverage(
[{ x: -10, y: -10, width: 10, height: 10 }],
[
{ x: -10, y: -10, width: 10, height: 10 },
{ x: -10, y: -10, width: 10, height: 10 },
],
),
).toBe(1);
});

test('ignores degenerate rectangles and keeps the ratio within its exact threshold', () => {
expect(
unionCoverage(
[
{ x: 0, y: 0, width: 90, height: 1 },
{ x: 0, y: 0, width: 0, height: 100 },
],
[{ x: 0, y: 0, width: 100, height: 1 }],
),
).toBe(0.9);
expect(
unionCoverage([{ x: 0, y: 0, width: 89, height: 1 }], [{ x: 0, y: 0, width: 100, height: 1 }]),
).toBe(0.89);
});

test('charges deterministic work and rejects overlapping hostile inputs', () => {
const covering = Array.from({ length: 100 }, () => ({ x: 0, y: 0, width: 100, height: 100 }));
const covered = Array.from({ length: 100 }, () => ({ x: 0, y: 0, width: 100, height: 100 }));
const first = createAndroidSnapshotPresentationBudget(
{ deadlineAtMs: Number.POSITIVE_INFINITY },
10_000,
);
const second = createAndroidSnapshotPresentationBudget(
{ deadlineAtMs: Number.POSITIVE_INFINITY },
10_000,
);

expect(unionCoverage(covering, covered, first)).toBe(1);
expect(unionCoverage(covering, covered, second)).toBe(1);
expect(first.workUnitCount).toBe(1001);
expect(first.workUnitCount).toBe(second.workUnitCount);

const constrained = createAndroidSnapshotPresentationBudget(
{ deadlineAtMs: Number.POSITIVE_INFINITY, maxWorkUnits: 900 },
10_000,
);
expect(() => unionCoverage(covering, covered, constrained)).toThrow(
'Android snapshot presentation exceeded its linear work budget',
);
});

function bruteForceCoverage(covering: Rect[], covered: Rect[]): number {
const coveringCells = cellsOf(covering);
const coveredCells = cellsOf(covered);
if (coveredCells.size === 0) return 0;
let overlap = 0;
for (const cell of coveredCells) {
if (coveringCells.has(cell)) overlap += 1;
}
return overlap / coveredCells.size;
}

function cellsOf(rects: Rect[]): Set<string> {
const cells = new Set<string>();
for (const rect of rects) {
for (let x = rect.x; x < rect.x + rect.width; x += 1) {
for (let y = rect.y; y < rect.y + rect.height; y += 1) {
cells.add(`${x},${y}`);
}
}
}
return cells;
}
68 changes: 34 additions & 34 deletions src/platforms/android/__tests__/snapshot-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,36 +209,35 @@ test('hostile nested Android presentation stays under a deterministic linear wor
);
});

test('broad Android presentation rejects an oversized descendant footprint budget', () => {
test('broad sibling footprints resolve coverage inside the deterministic budget', () => {
const siblingCount = 24;
const buttons = (offset: number) =>
const buttons = (labelPrefix: string) =>
Array.from({ length: siblingCount }, (_, index) => {
const x = offset + index * 16;
const x = index * 16;
const y = 16 + index * 16;
return `<node class="android.widget.Button" text="${offset}-${index}" bounds="[${x},${y}][${x + 8},${y + 8}]" clickable="true" visible-to-user="true" />`;
return `<node class="android.widget.Button" text="${labelPrefix}-${index}" bounds="[${x},${y}][${x + 8},${y + 8}]" clickable="true" visible-to-user="true" />`;
}).join('');
const tree = parseUiHierarchyTree(
`<hierarchy><node class="android.widget.FrameLayout" bounds="[0,0][512,512]" visible-to-user="true">
<node class="android.view.ViewGroup" drawing-order="1" bounds="[0,0][512,512]" visible-to-user="true">${buttons(0)}</node>
<node class="android.view.ViewGroup" drawing-order="2" bounds="[0,0][512,512]" visible-to-user="true">${buttons(0)}</node>
<node class="android.view.ViewGroup" drawing-order="1" bounds="[0,0][512,512]" visible-to-user="true">${buttons('covered')}</node>
<node class="android.view.ViewGroup" drawing-order="2" bounds="[0,0][512,512]" visible-to-user="true">${buttons('covering')}</node>
</node></hierarchy>`,
);

assert.throws(
() =>
buildUiHierarchySnapshot(tree, undefined, {
androidPresentation: {
deadlineAtMs: Number.POSITIVE_INFINITY,
maxWorkUnits: 1024,
},
}),
(error: unknown) => {
assert.equal(isAndroidSnapshotPresentationFailure(error), true);
assert(error instanceof AndroidSnapshotPresentationFailure);
assert.equal(error.details.phase, 'complexity');
assert(error.details.workUnits > 1024);
return true;
const built = buildUiHierarchySnapshot(tree, undefined, {
androidPresentation: {
deadlineAtMs: Number.POSITIVE_INFINITY,
maxWorkUnits: 1024,
},
});

assert.equal(
built.nodes.some((node) => node.label?.startsWith('covered-')),
false,
);
assert.equal(
built.nodes.filter((node) => node.label?.startsWith('covering-')).length,
siblingCount,
);
});

Expand All @@ -253,6 +252,7 @@ test('hostile equal-order same-rect siblings charge the broad candidate scan', (
`<hierarchy><node class="android.widget.FrameLayout" bounds="[0,0][100,100]" visible-to-user="true">${siblings}</node></hierarchy>`,
);

// Equal drawing orders skip coverage calculation, so this isolates the sibling candidate scan.
assert.throws(
() =>
buildUiHierarchySnapshot(tree, undefined, {
Expand Down Expand Up @@ -287,7 +287,7 @@ test('default budget admits a bounded flat hierarchy', () => {
assert.equal(built.nodes.length, 101);
});

test('one-axis footprint indexing remains inside the deterministic work budget', () => {
test('dense one-axis footprints resolve coverage inside the deterministic budget', () => {
const childCount = 80;
const labels = Array.from(
{ length: childCount },
Expand All @@ -301,19 +301,19 @@ test('one-axis footprint indexing remains inside the deterministic work budget',
</node></hierarchy>`,
);

assert.throws(
() =>
buildUiHierarchySnapshot(tree, undefined, {
androidPresentation: {
deadlineAtMs: Number.POSITIVE_INFINITY,
maxWorkUnits: 1650,
},
}),
(error: unknown) => {
assert.equal(isAndroidSnapshotPresentationFailure(error), true);
assert(error instanceof AndroidSnapshotPresentationFailure);
assert.equal(error.details.phase, 'complexity');
return true;
const built = buildUiHierarchySnapshot(tree, undefined, {
androidPresentation: {
deadlineAtMs: Number.POSITIVE_INFINITY,
maxWorkUnits: 1650,
},
});

assert.equal(
built.nodes.some((node) => node.label?.startsWith('Label ')),
false,
);
assert.equal(
built.nodes.some((node) => node.label === 'Cover'),
true,
);
});
113 changes: 113 additions & 0 deletions src/platforms/android/rect-coverage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { isPositiveFiniteRect } from '@agent-device/kernel/rect';
import type { Rect } from '@agent-device/kernel/snapshot';
import type { AndroidSnapshotPresentationBudget } from './snapshot-presentation.ts';

type Interval = { start: number; end: number };
type SweepRect = Rect & { endX: number };

/** Fraction of the covered rects' union that lies under the covering rects' union. */
export function unionCoverage(
coveringRects: readonly Rect[],
coveredRects: readonly Rect[],
presentationBudget?: AndroidSnapshotPresentationBudget,
): number {
presentationBudget?.consume(coveringRects.length + coveredRects.length);
const covered = sweepRects(coveredRects);
if (covered.length === 0) return 0;

let minX = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
for (const rect of covered) {
minX = Math.min(minX, rect.x);
maxX = Math.max(maxX, rect.endX);
}
const covering = sweepRects(coveringRects).filter((rect) => rect.x < maxX && rect.endX > minX);
if (covering.length === 0) return 0;

const xEdges = [
...covered.flatMap((rect) => [rect.x, rect.endX]),
...covering.flatMap((rect) => [Math.max(rect.x, minX), Math.min(rect.endX, maxX)]),
];
presentationBudget?.consume(covered.length + covering.length + xEdges.length);
covered.sort(compareRectsByX);
covering.sort(compareRectsByX);
const xs = [...new Set(xEdges)].sort((left, right) => left - right);

let activeCovered: SweepRect[] = [];
let activeCovering: SweepRect[] = [];
let nextCovered = 0;
let nextCovering = 0;
let coveredArea = 0;
let overlapArea = 0;

for (let index = 0; index < xs.length - 1; index += 1) {
const x = xs[index]!;
const nextX = xs[index + 1]!;

activeCovered = activeCovered.filter((rect) => rect.endX > x);
activeCovering = activeCovering.filter((rect) => rect.endX > x);
while (nextCovered < covered.length && covered[nextCovered]!.x <= x) {
activeCovered.push(covered[nextCovered]!);
nextCovered += 1;
}
while (nextCovering < covering.length && covering[nextCovering]!.x <= x) {
activeCovering.push(covering[nextCovering]!);
nextCovering += 1;
}

presentationBudget?.consume(1 + activeCovered.length + activeCovering.length);
const coveredY = mergeYIntervals(activeCovered);
const coveringY = mergeYIntervals(activeCovering);
const width = nextX - x;
coveredArea += width * totalLength(coveredY);
overlapArea += width * intersectionLength(coveredY, coveringY);
}

return coveredArea <= 0 ? 0 : overlapArea / coveredArea;
}

function sweepRects(rects: readonly Rect[]): SweepRect[] {
return rects.filter(isPositiveFiniteRect).map((rect) => ({ ...rect, endX: rect.x + rect.width }));
}

function compareRectsByX(left: SweepRect, right: SweepRect): number {
return left.x - right.x || left.endX - right.endX;
}

function mergeYIntervals(rects: readonly Rect[]): Interval[] {
const intervals = rects
.map((rect) => ({ start: rect.y, end: rect.y + rect.height }))
.sort((left, right) => left.start - right.start || left.end - right.end);
const merged: Interval[] = [];
for (const interval of intervals) {
const previous = merged.at(-1);
if (!previous || interval.start > previous.end) {
merged.push({ ...interval });
} else {
previous.end = Math.max(previous.end, interval.end);
}
}
return merged;
}

function totalLength(intervals: readonly Interval[]): number {
return intervals.reduce((total, interval) => total + interval.end - interval.start, 0);
}

function intersectionLength(left: readonly Interval[], right: readonly Interval[]): number {
let total = 0;
let leftIndex = 0;
let rightIndex = 0;
while (leftIndex < left.length && rightIndex < right.length) {
const leftInterval = left[leftIndex]!;
const rightInterval = right[rightIndex]!;
total += Math.max(
0,
Math.min(leftInterval.end, rightInterval.end) -
Math.max(leftInterval.start, rightInterval.start),
);
if (leftInterval.end <= rightInterval.end) leftIndex += 1;
else rightIndex += 1;
}
return total;
}
Loading
Loading