Files
Imhotep/packages/imhotep-core/src/perf.test.ts
T

380 lines
11 KiB
TypeScript
Raw Normal View History

// perf.test.ts - Performance regression tests for Imhotep core hot paths
// Uses Node.js built-in test runner and performance hooks.
// Targets (V1 developer guide):
// Unit tests < 10ms each
// Integration tests < 100ms each
// Compilation < 5ms per assertion
// Extraction < 50ms per page
// Solving < 1ms per clause
import { describe, it } from 'node:test'
import assert from 'node:assert'
import { performance } from 'node:perf_hooks'
import { compile } from 'imhotep-dsl'
import {
normalizeSubjects,
normalizeBoxes,
normalizeTopology,
normalizeFrames,
buildTransformChain,
composeTransform2D,
clearTransformCaches,
} from 'imhotep-geometry'
import { evaluate, registerDefaultClauses } from 'imhotep-solver'
describe('Performance regression — compilation', () => {
it('compiles 100 assertions in under 500ms', () => {
const assertions = generateAssertions(100)
const start = performance.now()
const ir = compile(assertions)
const duration = performance.now() - start
assert.strictEqual(
duration < 500,
true,
`Compilation took ${duration.toFixed(2)}ms`
)
})
it('compiles 10 assertions in under 50ms (5ms per assertion)', () => {
const assertions = generateAssertions(10)
const start = performance.now()
compile(assertions)
const duration = performance.now() - start
assert.strictEqual(
duration < 50,
true,
`Compilation took ${duration.toFixed(2)}ms`
)
})
})
describe('Performance regression — world creation', () => {
it('normalizes 1000 subjects in under 50ms', () => {
const raw = generateRawSubjects(1000)
const start = performance.now()
normalizeSubjects(raw)
const duration = performance.now() - start
assert.strictEqual(
duration < 50,
true,
`Subject normalization took ${duration.toFixed(2)}ms`
)
})
it('normalizes 1000 boxes in under 50ms', () => {
const raw = generateRawBoxes(1000)
const start = performance.now()
normalizeBoxes(raw)
const duration = performance.now() - start
assert.strictEqual(
duration < 50,
true,
`Box normalization took ${duration.toFixed(2)}ms`
)
})
it('normalizes topology in under 25ms', () => {
const raw = generateRawTopology(1000)
const start = performance.now()
normalizeTopology(raw, 1000)
const duration = performance.now() - start
assert.strictEqual(
duration < 25,
true,
`Topology normalization took ${duration.toFixed(2)}ms`
)
})
})
describe('Performance regression — frame resolution', () => {
it('resolves 100 transform chains in under 50ms', () => {
const matrices = generateTransformMatrices()
const start = performance.now()
for (let i = 0; i < 100; i++) {
buildTransformChain(matrices)
}
const duration = performance.now() - start
assert.strictEqual(
duration < 50,
true,
`Frame resolution took ${duration.toFixed(2)}ms`
)
})
it('composes 100 2D transforms in under 100ms', () => {
const start = performance.now()
for (let i = 0; i < 100; i++) {
composeTransform2D(0, 0, i, i, 0, 1, 1)
}
const duration = performance.now() - start
assert.strictEqual(
duration < 100,
true,
`Transform composition took ${duration.toFixed(2)}ms`
)
})
it('caches repeated transform chains (second pass under 1ms)', () => {
const matrices = generateTransformMatrices()
clearTransformCaches()
buildTransformChain(matrices) // warm cache
const start = performance.now()
for (let i = 0; i < 100; i++) {
buildTransformChain(matrices)
}
const duration = performance.now() - start
assert.strictEqual(
duration < 1,
true,
`Cached frame resolution took ${duration.toFixed(2)}ms`
)
})
})
describe('Performance regression — solving', () => {
it('evaluates 100 clauses in under 100ms (1ms per clause)', () => {
registerDefaultClauses()
const world = generateMinimalWorld()
const clauses = generateClauses(100)
const start = performance.now()
evaluate(world, clauses, { skipMissingEvaluators: true })
const duration = performance.now() - start
assert.strictEqual(
duration < 100,
true,
`Solving took ${duration.toFixed(2)}ms`
)
})
it('evaluates 10 clauses in under 25ms', () => {
registerDefaultClauses()
const world = generateMinimalWorld()
const clauses = generateClauses(10)
const start = performance.now()
evaluate(world, clauses, { skipMissingEvaluators: true })
const duration = performance.now() - start
assert.strictEqual(
duration < 25,
true,
`Solving took ${duration.toFixed(2)}ms`
)
})
})
// ---------------------------------------------------------------------------
// Mock data generators
// ---------------------------------------------------------------------------
function generateAssertions(n: number): Array<{
type: 'RelationAssertion'
relation: string
subject: { type: 'SelectorRef'; value: string }
reference: { type: 'SelectorRef'; value: string }
options: { type: 'RelationOptions' }
}> {
return Array.from({ length: n }, (_, i) => ({
type: 'RelationAssertion' as const,
relation: i % 2 === 0 ? 'leftOf' : 'above',
subject: { type: 'SelectorRef' as const, value: `.s-${i}` },
reference: { type: 'SelectorRef' as const, value: `.r-${i}` },
options: { type: 'RelationOptions' as const },
}))
}
function generateRawSubjects(n: number): Array<{
id: number
domNodeId: number
kind: string
primaryBoxId: number
firstFragmentId: number
fragmentCount: number
firstTextRunId: number
textRunCount: number
}> {
return Array.from({ length: n }, (_, i) => ({
id: i,
domNodeId: i,
kind: 'element',
primaryBoxId: i,
firstFragmentId: 0,
fragmentCount: 0,
firstTextRunId: 0,
textRunCount: 0,
}))
}
function generateRawBoxes(n: number): Array<{
id: number
subjectId: number
frameId: number
borderLeft: number
borderTop: number
borderRight: number
borderBottom: number
paddingLeft: number
paddingTop: number
paddingRight: number
paddingBottom: number
contentLeft: number
contentTop: number
contentRight: number
contentBottom: number
}> {
return Array.from({ length: n }, (_, i) => ({
id: i,
subjectId: i,
frameId: 0,
borderLeft: 0,
borderTop: 0,
borderRight: 10,
borderBottom: 10,
paddingLeft: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
contentLeft: 0,
contentTop: 0,
contentRight: 10,
contentBottom: 10,
}))
}
function generateRawTopology(n: number): {
containingBlockOf: number[]
nearestPositionedAncestorOf: number[]
scrollContainerOf: number[]
stackingContextOf: number[]
formattingContextOf: number[]
clippingRootOf: number[]
paintOrderBucket: number[]
paintOrderIndex: number[]
} {
return {
containingBlockOf: Array.from({ length: n }, () => 0),
nearestPositionedAncestorOf: Array.from({ length: n }, () => 0),
scrollContainerOf: Array.from({ length: n }, () => 0),
stackingContextOf: Array.from({ length: n }, () => 0),
formattingContextOf: Array.from({ length: n }, () => 0),
clippingRootOf: Array.from({ length: n }, () => 0),
paintOrderBucket: Array.from({ length: n }, () => 0),
paintOrderIndex: Array.from({ length: n }, () => 0),
}
}
function generateTransformMatrices(): Float64Array[] {
return [
new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 10, 0, 1]),
]
}
function generateMinimalWorld(): any {
return {
sceneId: 'scene_1',
snapshotId: 'snapshot_1',
env: {
viewportWidth: 1280,
viewportHeight: 720,
deviceScaleFactor: 1,
colorScheme: 'light',
pointer: 'fine',
hover: false,
reducedMotion: false,
locale: 'en',
writingMode: 'horizontal-tb',
},
strings: { values: [] },
subjects: {
ids: new Uint32Array([0, 1]),
domNodeId: new Uint32Array([0, 1]),
subjectKind: new Uint16Array([1, 1]),
primaryBoxId: new Uint32Array([0, 1]),
firstFragmentId: new Uint32Array([0, 0]),
fragmentCount: new Uint16Array([0, 0]),
},
boxes: {
boxId: new Uint32Array([0, 1]),
subjectId: new Uint32Array([0, 1]),
frameId: new Uint32Array([0, 0]),
borderLeft: new Float64Array([0, 0]),
borderTop: new Float64Array([0, 0]),
borderRight: new Float64Array([10, 10]),
borderBottom: new Float64Array([10, 10]),
paddingLeft: new Float64Array([0, 0]),
paddingTop: new Float64Array([0, 0]),
paddingRight: new Float64Array([0, 0]),
paddingBottom: new Float64Array([0, 0]),
contentLeft: new Float64Array([0, 0]),
contentTop: new Float64Array([0, 0]),
contentRight: new Float64Array([10, 10]),
contentBottom: new Float64Array([10, 10]),
},
rects: {
rectId: new Uint32Array([0, 1]),
left: new Float64Array([0, 5]),
top: new Float64Array([0, 5]),
right: new Float64Array([10, 15]),
bottom: new Float64Array([10, 15]),
},
topology: {
containingBlockOf: new Uint32Array([0, 0]),
nearestPositionedAncestorOf: new Uint32Array([0, 0]),
scrollContainerOf: new Uint32Array([0, 0]),
stackingContextOf: new Uint32Array([0, 0]),
formattingContextOf: new Uint32Array([0, 0]),
clippingRootOf: new Uint32Array([0, 0]),
paintOrderBucket: new Uint16Array([0, 0]),
paintOrderIndex: new Uint32Array([0, 1]),
},
scroll: {
containerId: new Uint32Array([0]),
scrollLeft: new Float64Array([0]),
scrollTop: new Float64Array([0]),
scrollWidth: new Float64Array([1280]),
scrollHeight: new Float64Array([720]),
clientWidth: new Float64Array([1280]),
clientHeight: new Float64Array([720]),
},
clipping: {
clipNodeId: new Uint32Array([0]),
subjectId: new Uint32Array([0]),
clipKind: new Uint16Array([1]),
clipLeft: new Float64Array([0]),
clipTop: new Float64Array([0]),
clipRight: new Float64Array([1280]),
clipBottom: new Float64Array([720]),
parentClipNodeId: new Uint32Array([0]),
},
visibility: {
subjectId: new Uint32Array([0, 1]),
isRendered: new Uint8Array([1, 1]),
isVisible: new Uint8Array([1, 1]),
visibleArea: new Float64Array([100, 100]),
clippedArea: new Float64Array([0, 0]),
},
}
}
function generateClauses(n: number): Array<{
clauseId: string
clauseKind: string
version: number
subjectRef: number
referenceRef?: number
}> {
const kinds = [
'relation.leftOf',
'relation.above',
'relation.overlaps',
'relation.inside',
]
return Array.from({ length: n }, (_, i) => ({
clauseId: `clause-${i}`,
clauseKind: kinds[i % kinds.length],
version: 1,
subjectRef: 0,
referenceRef: 1,
}))
}