Files
Imhotep/packages/imhotep-geometry/src/world.test.ts
T

503 lines
16 KiB
TypeScript

import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
GeometryWorld,
StringTable,
SubjectKind,
FrameKind,
DisplayValue,
PositionValue,
buildWorldIndex,
WorldIndex,
internString,
} from './world.js'
import {
multiply4x4,
translateMatrix,
scaleMatrix,
rotateMatrix,
transformPoint,
invert4x4,
buildTransformChain,
IDENTITY_4X4,
} from './transforms.js'
import {
resolveViewportFrame,
walkFrameAncestors,
buildFrameTransform,
buildFrameToFrameTransform,
convertPoint,
convertRect,
} from './frames.js'
import {
getBorderBox,
getPaddingBox,
getContentBox,
rectSize,
rectArea,
rectsIntersect,
rectIntersection,
getPrimaryBoxInViewport,
} from './boxes.js'
import {
materializeWorld,
materializeWorldWithIndex,
validateWorld,
} from './materialize.js'
import {
RawExtractionResult,
RawSubject,
RawFrame,
RawBox,
RawRect,
} from './normalize.js'
// ---------------------------------------------------------------------------
// Helper: build a minimal valid raw extraction result
// ---------------------------------------------------------------------------
function makeMinimalRaw(): RawExtractionResult {
return {
sceneId: 'scene_1',
snapshotId: 'snap_1',
env: {
viewportWidth: 1440,
viewportHeight: 900,
deviceScaleFactor: 1,
colorScheme: 'dark',
pointer: 'fine',
hover: 'none',
reducedMotion: false,
locale: 'en-US',
writingMode: 'horizontal-tb',
},
source: {
url: 'https://example.test',
browserName: 'Chromium',
browserVersion: '123',
engine: 'chromium-cdp',
extractedAt: Date.now(),
},
subjects: [
{ id: 1, domNodeId: 10, kind: 'element', primaryBoxId: 100, firstFragmentId: 0, fragmentCount: 0, firstTextRunId: 0, textRunCount: 0 },
{ id: 2, domNodeId: 20, kind: 'element', primaryBoxId: 200, firstFragmentId: 0, fragmentCount: 0, firstTextRunId: 0, textRunCount: 0 },
],
dom: [
{ nodeId: 10, backendNodeId: 1000, parentNodeId: 0, firstChildIndex: 0, childCount: 0, shadowRootKind: '', tagName: 'div', classNames: [], role: '', ariaName: '' },
{ nodeId: 20, backendNodeId: 2000, parentNodeId: 10, firstChildIndex: 0, childCount: 0, shadowRootKind: '', tagName: 'span', classNames: [], role: '', ariaName: '' },
],
frames: [
{ id: 1, kind: 'viewport', ownerSubjectId: 0, parentFrameId: 0, originX: 0, originY: 0, clipRectId: 0, scrollContainerId: 0, writingMode: 'horizontal-tb' },
{ id: 2, kind: 'containingBlock', ownerSubjectId: 1, parentFrameId: 1, originX: 10, originY: 20, clipRectId: 0, scrollContainerId: 0, writingMode: 'horizontal-tb' },
],
rects: [
{ id: 1, left: 0, top: 0, right: 1440, bottom: 900 },
],
boxes: [
{ id: 100, subjectId: 1, frameId: 2, borderLeft: 10, borderTop: 20, borderRight: 110, borderBottom: 120, paddingLeft: 12, paddingTop: 22, paddingRight: 108, paddingBottom: 118, contentLeft: 14, contentTop: 24, contentRight: 106, contentBottom: 116 },
{ id: 200, subjectId: 2, frameId: 2, borderLeft: 30, borderTop: 40, borderRight: 130, borderBottom: 140, paddingLeft: 32, paddingTop: 42, paddingRight: 128, paddingBottom: 138, contentLeft: 34, contentTop: 44, contentRight: 126, contentBottom: 136 },
],
fragments: [],
transforms: [],
styles: [
{ subjectId: 1, display: 'block', position: 'static', zIndex: 'auto', overflowX: 'visible', overflowY: 'visible', opacity: 1, visibility: 'visible', contain: '', pointerEvents: 'auto', lineHeight: 1.5, fontFamily: 'sans-serif', fontSize: 16, fontWeight: 400 },
],
text: [],
topology: {
containingBlockOf: [0, 1],
nearestPositionedAncestorOf: [0, 0],
scrollContainerOf: [0, 0],
stackingContextOf: [0, 0],
formattingContextOf: [0, 0],
clippingRootOf: [0, 0],
paintOrderBucket: [0, 0],
paintOrderIndex: [0, 0],
},
scroll: [],
clipping: [],
paint: [],
visibility: [
{ subjectId: 1, isRendered: true, isVisible: true, visibleArea: 10000, clippedArea: 0 },
{ subjectId: 2, isRendered: true, isVisible: true, visibleArea: 10000, clippedArea: 0 },
],
provenance: [],
confidence: [],
}
}
// ---------------------------------------------------------------------------
// World construction tests
// ---------------------------------------------------------------------------
describe('world construction', () => {
it('materializes a minimal world', () => {
const raw = makeMinimalRaw()
const world = materializeWorld(raw)
assert.strictEqual(world.subjects.ids.length, 2)
assert.strictEqual(world.boxes.boxId.length, 2)
assert.strictEqual(world.frames.frameId.length, 2)
assert.strictEqual(world.strings.values.length > 0, true)
})
it('builds a world index', () => {
const raw = makeMinimalRaw()
const { world, index } = materializeWorldWithIndex(raw)
assert.strictEqual(index.subjectById.get(1), 0)
assert.strictEqual(index.subjectById.get(2), 1)
assert.strictEqual(index.boxById.get(100), 0)
assert.strictEqual(index.frameById.get(1), 0)
assert.strictEqual(index.frameById.get(2), 1)
})
it('interns strings into a deduplicated table', () => {
const table: StringTable = { values: [] }
const idx1 = internString(table, 'hello')
const idx2 = internString(table, 'hello')
const idx3 = internString(table, 'world')
assert.strictEqual(idx1, idx2)
assert.notStrictEqual(idx1, idx3)
assert.strictEqual(table.values.length, 2)
})
it('validates a correct world with no errors', () => {
const raw = makeMinimalRaw()
const world = materializeWorld(raw)
const errors = validateWorld(world)
assert.strictEqual(errors.length, 0)
})
it('detects invalid box subject references', () => {
const raw = makeMinimalRaw()
raw.boxes.push({
id: 300,
subjectId: 999, // nonexistent
frameId: 1,
borderLeft: 0,
borderTop: 0,
borderRight: 10,
borderBottom: 10,
paddingLeft: 0,
paddingTop: 0,
paddingRight: 10,
paddingBottom: 10,
contentLeft: 0,
contentTop: 0,
contentRight: 10,
contentBottom: 10,
})
const world = materializeWorld(raw)
const errors = validateWorld(world)
const invalidSubject = errors.find((e) => e.code === 'INVALID_SUBJECT_REF')
assert.ok(invalidSubject)
})
it('detects invalid frame parent references', () => {
const raw = makeMinimalRaw()
raw.frames.push({
id: 3,
kind: 'containingBlock',
ownerSubjectId: 2,
parentFrameId: 999, // nonexistent
originX: 0,
originY: 0,
clipRectId: 0,
scrollContainerId: 0,
writingMode: 'horizontal-tb',
})
const world = materializeWorld(raw)
const errors = validateWorld(world)
const invalidParent = errors.find((e) => e.code === 'INVALID_PARENT_FRAME')
assert.ok(invalidParent)
})
})
// ---------------------------------------------------------------------------
// Frame resolution tests
// ---------------------------------------------------------------------------
describe('frame resolution', () => {
it('resolves the viewport frame', () => {
const raw = makeMinimalRaw()
const world = materializeWorld(raw)
const viewportIdx = resolveViewportFrame(world.frames)
assert.strictEqual(viewportIdx, 0)
assert.strictEqual(world.frames.frameKind[viewportIdx], FrameKind.Viewport)
})
it('walks frame ancestors', () => {
const raw = makeMinimalRaw()
const world = materializeWorld(raw)
const ancestors = walkFrameAncestors(world.frames, 1) // frame 2 (child of viewport)
assert.deepStrictEqual(ancestors, [1, 0])
})
it('handles orphaned frames gracefully', () => {
const raw = makeMinimalRaw()
raw.frames.push({
id: 3,
kind: 'synthetic',
ownerSubjectId: 0,
parentFrameId: 0,
originX: 0,
originY: 0,
clipRectId: 0,
scrollContainerId: 0,
writingMode: 'horizontal-tb',
})
const world = materializeWorld(raw)
const ancestors = walkFrameAncestors(world.frames, 2)
assert.deepStrictEqual(ancestors, [2])
})
})
// ---------------------------------------------------------------------------
// Transform matrix tests
// ---------------------------------------------------------------------------
describe('transform matrices', () => {
it('identity matrix leaves points unchanged', () => {
const out = transformPoint(new Float64Array(IDENTITY_4X4), 5, 7)
assert.strictEqual(out[0], 5)
assert.strictEqual(out[1], 7)
})
it('translation matrix moves points', () => {
const m = translateMatrix(10, 20)
const out = transformPoint(m, 5, 7)
assert.strictEqual(out[0], 15)
assert.strictEqual(out[1], 27)
})
it('scale matrix scales points', () => {
const m = scaleMatrix(2, 3)
const out = transformPoint(m, 5, 7)
assert.strictEqual(out[0], 10)
assert.strictEqual(out[1], 21)
})
it('rotation matrix rotates points', () => {
const m = rotateMatrix(Math.PI / 2) // 90 degrees
const out = transformPoint(m, 1, 0)
// Should be approximately (0, 1)
assert.ok(Math.abs(out[0]) < 1e-10)
assert.ok(Math.abs(out[1] - 1) < 1e-10)
})
it('matrix multiplication is associative', () => {
const a = translateMatrix(1, 0)
const b = scaleMatrix(2, 2)
const c = rotateMatrix(Math.PI / 4)
const ab = multiply4x4(a, b)
const abc1 = multiply4x4(ab, c)
const bc = multiply4x4(b, c)
const abc2 = multiply4x4(a, bc)
for (let i = 0; i < 16; i++) {
assert.ok(
Math.abs(abc1[i] - abc2[i]) < 1e-10,
`Mismatch at index ${i}: ${abc1[i]} vs ${abc2[i]}`
)
}
})
it('inverse of identity is identity', () => {
const inv = invert4x4(new Float64Array(IDENTITY_4X4))
assert.ok(inv)
for (let i = 0; i < 16; i++) {
assert.strictEqual(inv[i], IDENTITY_4X4[i])
}
})
it('inverse times original gives identity', () => {
const m = translateMatrix(10, 20)
const inv = invert4x4(m)
assert.ok(inv)
const prod = multiply4x4(m, inv)
for (let i = 0; i < 16; i++) {
const expected = IDENTITY_4X4[i]
assert.ok(
Math.abs(prod[i] - expected) < 1e-10,
`Mismatch at ${i}: ${prod[i]} vs ${expected}`
)
}
})
it('buildTransformChain composes in order', () => {
const t1 = translateMatrix(1, 0)
const t2 = translateMatrix(0, 1)
const chain = buildTransformChain([t1, t2])
const out = transformPoint(chain, 0, 0)
assert.strictEqual(out[0], 1)
assert.strictEqual(out[1], 1)
})
it('returns identity for empty chain', () => {
const chain = buildTransformChain([])
const out = transformPoint(chain, 5, 7)
assert.strictEqual(out[0], 5)
assert.strictEqual(out[1], 7)
})
})
// ---------------------------------------------------------------------------
// Coordinate transform tests
// ---------------------------------------------------------------------------
describe('coordinate transforms', () => {
it('converts a point between identical frames', () => {
const raw = makeMinimalRaw()
const { world, index } = materializeWorldWithIndex(raw)
const viewportIdx = resolveViewportFrame(world.frames)
// Frame 2 has origin (10, 20) relative to viewport, no transform
const result = convertPoint(world, viewportIdx, 1, 5, 5, index)
assert.ok(result)
assert.strictEqual(result!.x, -5) // 5 - 10
assert.strictEqual(result!.y, -15) // 5 - 20
})
it('converts a rect between frames', () => {
const raw = makeMinimalRaw()
const { world, index } = materializeWorldWithIndex(raw)
const viewportIdx = resolveViewportFrame(world.frames)
// Rect (0,0,50,50) in frame 1 (origin 10,20) maps to (10,20,60,70) in viewport
const result = convertRect(world, 1, viewportIdx, 0, 0, 50, 50, index)
assert.ok(result)
assert.strictEqual(result!.left, 10)
assert.strictEqual(result!.top, 20)
assert.strictEqual(result!.right, 60)
assert.strictEqual(result!.bottom, 70)
})
it('applies transform matrices in frame chains', () => {
const raw = makeMinimalRaw()
// Add a scale transform to frame 2
const scaleMat = Array.from(scaleMatrix(2, 2))
raw.frames[1].matrix = scaleMat
const { world, index } = materializeWorldWithIndex(raw)
const viewportIdx = resolveViewportFrame(world.frames)
// Point (10, 20) in frame 2: scaled by 2, then offset by origin (10, 20)
// In viewport: (10*2 + 10, 20*2 + 20) = (30, 60)
// Inverse: viewport to frame 2
const result = convertPoint(world, viewportIdx, 1, 30, 60, index)
assert.ok(result)
assert.ok(Math.abs(result!.x - 10) < 1e-10)
assert.ok(Math.abs(result!.y - 20) < 1e-10)
})
})
// ---------------------------------------------------------------------------
// Box geometry tests
// ---------------------------------------------------------------------------
describe('box geometry', () => {
it('reads border box correctly', () => {
const raw = makeMinimalRaw()
const world = materializeWorld(raw)
const box = getBorderBox(world.boxes, 0)
assert.strictEqual(box.left, 10)
assert.strictEqual(box.top, 20)
assert.strictEqual(box.right, 110)
assert.strictEqual(box.bottom, 120)
})
it('reads padding box correctly', () => {
const raw = makeMinimalRaw()
const world = materializeWorld(raw)
const box = getPaddingBox(world.boxes, 0)
assert.strictEqual(box.left, 12)
assert.strictEqual(box.top, 22)
assert.strictEqual(box.right, 108)
assert.strictEqual(box.bottom, 118)
})
it('reads content box correctly', () => {
const raw = makeMinimalRaw()
const world = materializeWorld(raw)
const box = getContentBox(world.boxes, 0)
assert.strictEqual(box.left, 14)
assert.strictEqual(box.top, 24)
assert.strictEqual(box.right, 106)
assert.strictEqual(box.bottom, 116)
})
it('computes rect size', () => {
const size = rectSize({ left: 10, top: 20, right: 110, bottom: 120 })
assert.strictEqual(size.width, 100)
assert.strictEqual(size.height, 100)
})
it('computes rect area', () => {
const area = rectArea({ left: 0, top: 0, right: 10, bottom: 20 })
assert.strictEqual(area, 200)
})
it('detects intersecting rects', () => {
const a = { left: 0, top: 0, right: 10, bottom: 10 }
const b = { left: 5, top: 5, right: 15, bottom: 15 }
assert.strictEqual(rectsIntersect(a, b), true)
})
it('detects non-intersecting rects', () => {
const a = { left: 0, top: 0, right: 10, bottom: 10 }
const b = { left: 10, top: 10, right: 20, bottom: 20 }
assert.strictEqual(rectsIntersect(a, b), false)
})
it('computes rect intersection', () => {
const a = { left: 0, top: 0, right: 10, bottom: 10 }
const b = { left: 5, top: 5, right: 15, bottom: 15 }
const inter = rectIntersection(a, b)
assert.ok(inter)
assert.strictEqual(inter!.left, 5)
assert.strictEqual(inter!.top, 5)
assert.strictEqual(inter!.right, 10)
assert.strictEqual(inter!.bottom, 10)
})
it('returns null for non-intersecting rects', () => {
const a = { left: 0, top: 0, right: 10, bottom: 10 }
const b = { left: 20, top: 20, right: 30, bottom: 30 }
const inter = rectIntersection(a, b)
assert.strictEqual(inter, null)
})
it('gets primary box in viewport', () => {
const raw = makeMinimalRaw()
const { world, index } = materializeWorldWithIndex(raw)
const box = getPrimaryBoxInViewport(world, 1, index)
assert.ok(box)
// Box 100 is in frame 2 with origin (10, 20)
// border box: left=10, top=20, right=110, bottom=120
// In viewport: offset by (10, 20) -> left=20, top=40, right=120, bottom=140
assert.strictEqual(box!.left, 20)
assert.strictEqual(box!.top, 40)
assert.strictEqual(box!.right, 120)
assert.strictEqual(box!.bottom, 140)
})
})
// ---------------------------------------------------------------------------
// String table tests
// ---------------------------------------------------------------------------
describe('string table', () => {
it('deduplicates identical strings', () => {
const table: StringTable = { values: [] }
const a = internString(table, 'div')
const b = internString(table, 'div')
const c = internString(table, 'span')
assert.strictEqual(a, b)
assert.notStrictEqual(a, c)
assert.strictEqual(table.values[a], 'div')
assert.strictEqual(table.values[c], 'span')
})
})