Files
Imhotep/packages/imhotep-topology/src/predicate-queries.test.ts
T

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');
});
});