390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
// Unit tests for topology spatial predicates (V1.1)
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert';
|
|
|
|
import {
|
|
above,
|
|
below,
|
|
leftOf,
|
|
rightOf,
|
|
inside,
|
|
overlaps,
|
|
PredicateTruth,
|
|
} from './predicate-queries.js';
|
|
|
|
import {
|
|
GeometryWorld,
|
|
StringTable,
|
|
internString,
|
|
} from 'imhotep-geometry';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers: build a minimal GeometryWorld with just enough box data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeMinimalWorldWithBoxes(
|
|
boxes: Array<{ subjectId: number; left: number; top: number; right: number; bottom: number }>,
|
|
): GeometryWorld {
|
|
const strings: StringTable = { values: [] };
|
|
const n = boxes.length;
|
|
const boxId = new Uint32Array(n);
|
|
const subjectId = new Uint32Array(n);
|
|
const frameId = new Uint32Array(n);
|
|
const borderLeft = new Float64Array(n);
|
|
const borderTop = new Float64Array(n);
|
|
const borderRight = new Float64Array(n);
|
|
const borderBottom = new Float64Array(n);
|
|
const paddingLeft = new Float64Array(n);
|
|
const paddingTop = new Float64Array(n);
|
|
const paddingRight = new Float64Array(n);
|
|
const paddingBottom = new Float64Array(n);
|
|
const contentLeft = new Float64Array(n);
|
|
const contentTop = new Float64Array(n);
|
|
const contentRight = new Float64Array(n);
|
|
const contentBottom = new Float64Array(n);
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
const b = boxes[i];
|
|
boxId[i] = i + 1;
|
|
subjectId[i] = b.subjectId;
|
|
frameId[i] = 0;
|
|
contentLeft[i] = b.left;
|
|
contentTop[i] = b.top;
|
|
contentRight[i] = b.right;
|
|
contentBottom[i] = b.bottom;
|
|
}
|
|
|
|
const emptyEnv = {
|
|
viewportWidth: 1440,
|
|
viewportHeight: 900,
|
|
deviceScaleFactor: 1,
|
|
colorScheme: internString(strings, 'dark'),
|
|
pointer: internString(strings, 'fine'),
|
|
hover: internString(strings, 'none'),
|
|
reducedMotion: false,
|
|
locale: internString(strings, 'en-US'),
|
|
writingMode: internString(strings, 'horizontal-tb'),
|
|
};
|
|
|
|
const emptySource = {
|
|
url: internString(strings, 'https://example.test'),
|
|
browserName: internString(strings, 'Chromium'),
|
|
browserVersion: internString(strings, '123'),
|
|
engine: internString(strings, 'chromium-cdp'),
|
|
extractedAt: Date.now(),
|
|
};
|
|
|
|
return {
|
|
sceneId: internString(strings, 'scene'),
|
|
snapshotId: internString(strings, 'snap'),
|
|
env: emptyEnv,
|
|
source: emptySource,
|
|
strings,
|
|
subjects: {
|
|
ids: new Uint32Array(0),
|
|
domNodeId: new Uint32Array(0),
|
|
subjectKind: new Uint16Array(0),
|
|
primaryBoxId: new Uint32Array(0),
|
|
firstFragmentId: new Uint32Array(0),
|
|
fragmentCount: new Uint16Array(0),
|
|
firstTextRunId: new Uint32Array(0),
|
|
textRunCount: new Uint16Array(0),
|
|
},
|
|
dom: {
|
|
nodeId: new Uint32Array(0),
|
|
backendNodeId: new Uint32Array(0),
|
|
parentNodeId: new Uint32Array(0),
|
|
firstChildIndex: new Uint32Array(0),
|
|
childCount: new Uint16Array(0),
|
|
shadowRootKind: new Uint8Array(0),
|
|
tagNameStringId: new Uint32Array(0),
|
|
classNameStringIds: [],
|
|
roleStringId: new Uint32Array(0),
|
|
ariaNameStringId: new Uint32Array(0),
|
|
},
|
|
frames: {
|
|
frameId: new Uint32Array(0),
|
|
frameKind: new Uint16Array(0),
|
|
ownerSubjectId: new Uint32Array(0),
|
|
parentFrameId: new Uint32Array(0),
|
|
originX: new Float64Array(0),
|
|
originY: new Float64Array(0),
|
|
axisMatrixStart: new Int32Array(0),
|
|
clipRectId: new Uint32Array(0),
|
|
scrollContainerId: new Uint32Array(0),
|
|
writingMode: new Uint16Array(0),
|
|
},
|
|
matrices: { values: new Float64Array(0) },
|
|
rects: { rectId: new Uint32Array(0), left: new Float64Array(0), top: new Float64Array(0), right: new Float64Array(0), bottom: new Float64Array(0) },
|
|
boxes: {
|
|
boxId,
|
|
subjectId,
|
|
frameId,
|
|
borderLeft,
|
|
borderTop,
|
|
borderRight,
|
|
borderBottom,
|
|
paddingLeft,
|
|
paddingTop,
|
|
paddingRight,
|
|
paddingBottom,
|
|
contentLeft,
|
|
contentTop,
|
|
contentRight,
|
|
contentBottom,
|
|
},
|
|
fragments: {
|
|
fragmentId: new Uint32Array(0),
|
|
subjectId: new Uint32Array(0),
|
|
fragmentKind: new Uint16Array(0),
|
|
boxLeft: new Float64Array(0),
|
|
boxTop: new Float64Array(0),
|
|
boxRight: new Float64Array(0),
|
|
boxBottom: new Float64Array(0),
|
|
lineIndex: new Int32Array(0),
|
|
flowIndex: new Int32Array(0),
|
|
parentFragmentId: new Uint32Array(0),
|
|
},
|
|
transforms: {
|
|
transformId: new Uint32Array(0),
|
|
subjectId: new Uint32Array(0),
|
|
matrixStart: new Int32Array(0),
|
|
matrixLength: new Uint16Array(0),
|
|
originX: new Float64Array(0),
|
|
originY: new Float64Array(0),
|
|
},
|
|
styles: {
|
|
subjectId: new Uint32Array(0),
|
|
display: new Uint16Array(0),
|
|
position: new Uint16Array(0),
|
|
zIndexKind: new Uint8Array(0),
|
|
zIndexValue: new Int32Array(0),
|
|
overflowX: new Uint16Array(0),
|
|
overflowY: new Uint16Array(0),
|
|
opacity: new Float32Array(0),
|
|
visibility: new Uint16Array(0),
|
|
containFlags: new Uint32Array(0),
|
|
pointerEvents: new Uint16Array(0),
|
|
lineHeight: new Float64Array(0),
|
|
fontFamilyStringId: new Uint32Array(0),
|
|
fontSize: new Float64Array(0),
|
|
fontWeight: new Uint16Array(0),
|
|
},
|
|
text: {
|
|
runId: new Uint32Array(0),
|
|
subjectId: new Uint32Array(0),
|
|
contentStringId: new Uint32Array(0),
|
|
lineBoxId: new Uint32Array(0),
|
|
inkLeft: new Float64Array(0),
|
|
inkTop: new Float64Array(0),
|
|
inkRight: new Float64Array(0),
|
|
inkBottom: new Float64Array(0),
|
|
baselineY: new Float64Array(0),
|
|
capHeight: new Float64Array(0),
|
|
computedLineHeight: new Float64Array(0),
|
|
},
|
|
topology: {
|
|
containingBlockOf: new Uint32Array(0),
|
|
nearestPositionedAncestorOf: new Uint32Array(0),
|
|
scrollContainerOf: new Uint32Array(0),
|
|
stackingContextOf: new Uint32Array(0),
|
|
formattingContextOf: new Uint32Array(0),
|
|
clippingRootOf: new Uint32Array(0),
|
|
paintOrderBucket: new Uint16Array(0),
|
|
paintOrderIndex: new Uint32Array(0),
|
|
},
|
|
scroll: {
|
|
containerId: new Uint32Array(0),
|
|
scrollLeft: new Float64Array(0),
|
|
scrollTop: new Float64Array(0),
|
|
scrollWidth: new Float64Array(0),
|
|
scrollHeight: new Float64Array(0),
|
|
clientWidth: new Float64Array(0),
|
|
clientHeight: new Float64Array(0),
|
|
},
|
|
clipping: {
|
|
clipNodeId: new Uint32Array(0),
|
|
subjectId: new Uint32Array(0),
|
|
clipKind: new Uint16Array(0),
|
|
clipLeft: new Float64Array(0),
|
|
clipTop: new Float64Array(0),
|
|
clipRight: new Float64Array(0),
|
|
clipBottom: new Float64Array(0),
|
|
parentClipNodeId: new Uint32Array(0),
|
|
},
|
|
paint: {
|
|
paintNodeId: new Uint32Array(0),
|
|
subjectId: new Uint32Array(0),
|
|
stackingContextId: new Uint32Array(0),
|
|
bucket: new Uint16Array(0),
|
|
localPaintIndex: new Uint32Array(0),
|
|
},
|
|
visibility: {
|
|
subjectId: new Uint32Array(0),
|
|
isRendered: new Uint8Array(0),
|
|
isVisible: new Uint8Array(0),
|
|
visibleArea: new Float64Array(0),
|
|
clippedArea: new Float64Array(0),
|
|
},
|
|
provenance: {
|
|
factId: new Uint32Array(0),
|
|
extractionStepId: new Uint32Array(0),
|
|
sourceKind: new Uint16Array(0),
|
|
sourceRef: new Uint32Array(0),
|
|
},
|
|
confidence: {
|
|
factId: new Uint32Array(0),
|
|
confidence: new Float32Array(0),
|
|
reasonCode: new Uint16Array(0),
|
|
},
|
|
selectorIndex: new Map(),
|
|
ancestorIndex: new Map(),
|
|
lineBoxIndex: new Map(),
|
|
textRunIndex: new Map(),
|
|
};
|
|
}
|
|
|
|
function assertTruth(result: PredicateTruth, expected: PredicateTruth['kind']) {
|
|
assert.equal(result.kind, expected, `expected ${expected}, got ${result.kind}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spatial predicate tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('above', () => {
|
|
it('returns true when a is above b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 0, right: 100, bottom: 50 },
|
|
{ subjectId: 2, left: 0, top: 60, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(above(1, 2, world), 'true');
|
|
});
|
|
|
|
it('returns false when a is not above b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 60, right: 100, bottom: 100 },
|
|
{ subjectId: 2, left: 0, top: 0, right: 100, bottom: 50 },
|
|
]);
|
|
assertTruth(above(1, 2, world), 'false');
|
|
});
|
|
|
|
it('returns indeterminate for missing box', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 0, right: 100, bottom: 50 },
|
|
]);
|
|
const result = above(1, 99, world);
|
|
assert.equal(result.kind, 'indeterminate');
|
|
if (result.kind === 'indeterminate') {
|
|
assert.equal(result.reason, 'missing box geometry');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('below', () => {
|
|
it('returns true when a is below b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 60, right: 100, bottom: 100 },
|
|
{ subjectId: 2, left: 0, top: 0, right: 100, bottom: 50 },
|
|
]);
|
|
assertTruth(below(1, 2, world), 'true');
|
|
});
|
|
|
|
it('returns false when a is not below b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 0, right: 100, bottom: 50 },
|
|
{ subjectId: 2, left: 0, top: 60, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(below(1, 2, world), 'false');
|
|
});
|
|
});
|
|
|
|
describe('leftOf', () => {
|
|
it('returns true when a is left of b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 0, right: 50, bottom: 100 },
|
|
{ subjectId: 2, left: 60, top: 0, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(leftOf(1, 2, world), 'true');
|
|
});
|
|
|
|
it('returns false when a is not left of b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 60, top: 0, right: 100, bottom: 100 },
|
|
{ subjectId: 2, left: 0, top: 0, right: 50, bottom: 100 },
|
|
]);
|
|
assertTruth(leftOf(1, 2, world), 'false');
|
|
});
|
|
});
|
|
|
|
describe('rightOf', () => {
|
|
it('returns true when a is right of b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 60, top: 0, right: 100, bottom: 100 },
|
|
{ subjectId: 2, left: 0, top: 0, right: 50, bottom: 100 },
|
|
]);
|
|
assertTruth(rightOf(1, 2, world), 'true');
|
|
});
|
|
|
|
it('returns false when a is not right of b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 0, right: 50, bottom: 100 },
|
|
{ subjectId: 2, left: 60, top: 0, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(rightOf(1, 2, world), 'false');
|
|
});
|
|
});
|
|
|
|
describe('inside', () => {
|
|
it('returns true when a is fully inside b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 10, top: 10, right: 90, bottom: 90 },
|
|
{ subjectId: 2, left: 0, top: 0, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(inside(1, 2, world), 'true');
|
|
});
|
|
|
|
it('returns false when a is partially outside b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 50, top: 50, right: 150, bottom: 150 },
|
|
{ subjectId: 2, left: 0, top: 0, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(inside(1, 2, world), 'false');
|
|
});
|
|
|
|
it('returns false when a is completely outside b', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 200, top: 200, right: 300, bottom: 300 },
|
|
{ subjectId: 2, left: 0, top: 0, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(inside(1, 2, world), 'false');
|
|
});
|
|
});
|
|
|
|
describe('overlaps', () => {
|
|
it('returns true when boxes overlap', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 0, right: 60, bottom: 60 },
|
|
{ subjectId: 2, left: 40, top: 40, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(overlaps(1, 2, world), 'true');
|
|
});
|
|
|
|
it('returns false when boxes do not overlap', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 0, right: 40, bottom: 40 },
|
|
{ subjectId: 2, left: 50, top: 50, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(overlaps(1, 2, world), 'false');
|
|
});
|
|
|
|
it('returns false for edge-touching boxes (zero-area intersection)', () => {
|
|
const world = makeMinimalWorldWithBoxes([
|
|
{ subjectId: 1, left: 0, top: 0, right: 50, bottom: 50 },
|
|
{ subjectId: 2, left: 50, top: 50, right: 100, bottom: 100 },
|
|
]);
|
|
assertTruth(overlaps(1, 2, world), 'false');
|
|
});
|
|
});
|