503 lines
16 KiB
TypeScript
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')
|
|
})
|
|
})
|