v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)

This commit is contained in:
John Dvorak
2025-08-15 10:00:00 -07:00
commit 92deb689cd
321 changed files with 79170 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
{
"name": "imhotep-topology",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/anomalyco/imhotep.git"
},
"engines": {
"node": ">=18.0.0"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "node --test dist/**/*.test.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"dependencies": {
"imhotep-geometry": "^1.0.0"
}
}
+176
View File
@@ -0,0 +1,176 @@
// Ancestor resolution for Imhotep topology engine.
// Computes nearest positioned ancestor and containing block for every node.
import {
type DomTree,
type StyleFacts,
type TopologyGraph,
Position,
Display,
ConfidenceLevel,
INVALID_ID,
setConfidence,
} from './graph.js';
// ---------------------------------------------------------------------------
// Positioned element predicate
// ---------------------------------------------------------------------------
/** Returns true if the element's computed position is not static. */
export function isPositioned(position: number): boolean {
return position !== Position.STATIC;
}
// ---------------------------------------------------------------------------
// Containing block resolution (CSS 2.1 Section 10.1)
// ---------------------------------------------------------------------------
/**
* For a given subject, walk up the DOM tree and return the ID of the node
* that acts as its containing block.
*
* Rules (simplified):
* - For fixed positioned elements, the containing block is the viewport
* (represented here as INVALID_ID because it lives outside the DOM tree).
* - For absolute positioned elements, the containing block is the nearest
* ancestor that is positioned (not static).
* - For other elements, the containing block is the nearest block container
* ancestor (a block, inline-block, flex item, grid item, or table cell).
*/
export function resolveContainingBlock(
subjectId: number,
dom: DomTree,
styles: StyleFacts,
): number {
const pos = styles.position[subjectId];
if (pos === Position.FIXED) {
// Viewport is outside the node tree; caller maps INVALID_ID to a synthetic frame.
return INVALID_ID;
}
if (pos === Position.ABSOLUTE) {
return walkUpForPositionedAncestor(subjectId, dom, styles);
}
// For static, relative, and sticky, the containing block is the nearest
// block-container ancestor. In a simplified model we treat every ancestor
// that is display:block, inline-block, flex, grid, or table as a containing
// block. If we reach the root without finding one, the root itself is the
// containing block.
let cursor = dom.parentNodeId[subjectId];
while (cursor !== INVALID_ID) {
const disp = styles.display[cursor];
if (
disp === Display.BLOCK ||
disp === Display.INLINE_BLOCK ||
disp === Display.FLEX ||
disp === Display.GRID ||
disp === Display.TABLE
) {
return cursor;
}
cursor = dom.parentNodeId[cursor];
}
return INVALID_ID;
}
// ---------------------------------------------------------------------------
// Nearest positioned ancestor
// ---------------------------------------------------------------------------
/**
* Walks up the parent chain until it finds a node whose position value is
* not STATIC. Returns INVALID_ID if none is found.
*/
export function walkUpForPositionedAncestor(
subjectId: number,
dom: DomTree,
styles: StyleFacts,
): number {
let cursor = dom.parentNodeId[subjectId];
while (cursor !== INVALID_ID) {
if (isPositioned(styles.position[cursor])) {
return cursor;
}
cursor = dom.parentNodeId[cursor];
}
return INVALID_ID;
}
// ---------------------------------------------------------------------------
// Batch ancestor resolution for the whole tree
// ---------------------------------------------------------------------------
export interface AncestorTables {
containingBlockOf: Uint32Array;
nearestPositionedAncestorOf: Uint32Array;
}
/**
* Builds the containing-block and nearest-positioned-ancestor tables for
* every node in the DOM tree.
*
* This is a single upward-walk per node. Complexity: O(N * depth).
* For very deep or wide trees the caller may memoize.
*/
export function buildAncestorTables(
dom: DomTree,
styles: StyleFacts,
): AncestorTables {
const containingBlockOf = new Uint32Array(dom.nodeCount).fill(INVALID_ID);
const nearestPositionedAncestorOf = new Uint32Array(dom.nodeCount).fill(INVALID_ID);
for (let i = 0; i < dom.nodeCount; i++) {
containingBlockOf[i] = resolveContainingBlock(i, dom, styles);
nearestPositionedAncestorOf[i] = walkUpForPositionedAncestor(i, dom, styles);
}
return { containingBlockOf, nearestPositionedAncestorOf };
}
// ---------------------------------------------------------------------------
// Write ancestor tables into the topology graph (with confidence)
// ---------------------------------------------------------------------------
/**
* Computes ancestor tables and writes them into `graph`, tagging each node
* with the appropriate confidence level.
*
* Positioned-ancestor resolution is deterministic (DERIVED) when styles are
* present. Containing-block resolution may be APPROXIMATE when we lack full
// CSS box-model data (e.g. table-cell detection).
*/
export function computeAncestors(
dom: DomTree,
styles: StyleFacts,
graph: TopologyGraph,
): void {
const tables = buildAncestorTables(dom, styles);
for (let i = 0; i < dom.nodeCount; i++) {
graph.containingBlockOf[i] = tables.containingBlockOf[i];
graph.nearestPositionedAncestorOf[i] = tables.nearestPositionedAncestorOf[i];
// Positioned ancestors are exact when styles are exact.
setConfidence(graph, i, ConfidenceLevel.DERIVED, 1);
// Containing block is exact for absolute/fixed (purely style-based).
// For static/relative/sticky it depends on display value; if display is
// missing or approximated we downgrade confidence.
const pos = styles.position[i];
if (pos === Position.ABSOLUTE || pos === Position.FIXED) {
setConfidence(graph, i, ConfidenceLevel.DERIVED, 2);
} else {
// Normal flow containing block: confidence depends on display accuracy.
// If display is Display.NONE (missing / not extracted) we mark low.
const disp = styles.display[i];
if (disp === Display.NONE || disp === Display.OTHER) {
setConfidence(graph, i, ConfidenceLevel.MEDIUM, 3);
} else {
setConfidence(graph, i, ConfidenceLevel.DERIVED, 4);
}
}
}
}
+214
View File
@@ -0,0 +1,214 @@
// Clipping and scroll container chain computation for Imhotep topology engine.
import {
type DomTree,
type StyleFacts,
type TopologyGraph,
Overflow,
Position,
Display,
ClipKind,
ScrollKind,
ConfidenceLevel,
INVALID_ID,
setConfidence,
} from './graph.js';
// ---------------------------------------------------------------------------
// Scroll container predicate
// ---------------------------------------------------------------------------
/**
* Returns true if the element acts as a scroll container.
*
* In the full platform this also requires checking whether the element has
* scrollable overflow dimensions. Here we approximate from the used
* overflow value: scroll and clip always create a scroll container; auto and
* hidden *may* create one depending on content overflow (which we cannot
// know without box metrics). When metrics are absent we treat auto/hidden
// as potential scroll containers with lowered confidence.
*/
export function isScrollContainer(
overflowX: number,
overflowY: number,
hasMetrics: boolean,
): { kind: number; confidence: number } {
const xScroll =
overflowX === Overflow.SCROLL ||
overflowX === Overflow.AUTO ||
overflowX === Overflow.HIDDEN;
const yScroll =
overflowY === Overflow.SCROLL ||
overflowY === Overflow.AUTO ||
overflowY === Overflow.HIDDEN;
if (!xScroll && !yScroll) {
return { kind: ScrollKind.NONE, confidence: ConfidenceLevel.EXACT };
}
// overflow:scroll is definitely a scroll container.
if (overflowX === Overflow.SCROLL || overflowY === Overflow.SCROLL) {
return { kind: ScrollKind.SCROLL, confidence: ConfidenceLevel.DERIVED };
}
// overflow:clip cannot scroll but still establishes a scroll port.
if (overflowX === Overflow.CLIP || overflowY === Overflow.CLIP) {
return { kind: ScrollKind.SCROLL, confidence: ConfidenceLevel.DERIVED };
}
// auto / hidden need overflow metrics to be certain.
if (hasMetrics) {
return { kind: ScrollKind.AUTO, confidence: ConfidenceLevel.DERIVED };
}
return { kind: ScrollKind.HIDDEN_SCROLLABLE, confidence: ConfidenceLevel.MEDIUM };
}
// ---------------------------------------------------------------------------
// Clipping root predicate
// ---------------------------------------------------------------------------
/**
* Returns true if the element itself introduces clipping for descendants.
*
* Any overflow other than visible creates a clip. We also treat
* contain:paint (bit 0x2) as a clipping root.
*/
export function introducesClipping(
overflowX: number,
overflowY: number,
containFlags: number,
): { clips: boolean; kind: number; confidence: number } {
// CSS contain: paint forces a containing block and clipping.
if (containFlags & 0x02) {
return { clips: true, kind: ClipKind.CONTAIN, confidence: ConfidenceLevel.DERIVED };
}
const xClips = overflowX !== Overflow.VISIBLE;
const yClips = overflowY !== Overflow.VISIBLE;
if (!xClips && !yClips) {
return { clips: false, kind: ClipKind.NONE, confidence: ConfidenceLevel.EXACT };
}
// Both axes clip => overflow clip.
return { clips: true, kind: ClipKind.OVERFLOW, confidence: ConfidenceLevel.DERIVED };
}
// ---------------------------------------------------------------------------
// Scroll chain builder
// ---------------------------------------------------------------------------
/**
* For each node, find the nearest ancestor that is a scroll container.
* The result is written into `graph.scrollContainerOf`.
*/
export function buildScrollChain(
dom: DomTree,
styles: StyleFacts,
graph: TopologyGraph,
hasMetrics: boolean,
): void {
// Pre-compute which nodes are scroll containers so we can walk upward
// quickly. We store the scroll-container ID for each node (or INVALID_ID).
const scrollContainerSelf = new Uint32Array(dom.nodeCount).fill(INVALID_ID);
for (let i = 0; i < dom.nodeCount; i++) {
const { kind } = isScrollContainer(styles.overflowX[i], styles.overflowY[i], hasMetrics);
if (kind !== ScrollKind.NONE) {
scrollContainerSelf[i] = i;
}
}
// For each node, walk up until we hit a scroll-container ancestor.
for (let i = 0; i < dom.nodeCount; i++) {
let cursor = dom.parentNodeId[i];
let found = INVALID_ID;
while (cursor !== INVALID_ID) {
if (scrollContainerSelf[cursor] !== INVALID_ID) {
found = cursor;
break;
}
cursor = dom.parentNodeId[cursor];
}
graph.scrollContainerOf[i] = found;
const confidence = found === INVALID_ID
? ConfidenceLevel.EXACT
: ConfidenceLevel.DERIVED;
setConfidence(graph, i, confidence, 10 /* scroll chain */);
}
}
// ---------------------------------------------------------------------------
// Clipping chain builder
// ---------------------------------------------------------------------------
/**
* For each node, find the nearest ancestor that introduces clipping.
* The result is written into `graph.clippingRootOf`.
*/
export function buildClipChain(
dom: DomTree,
styles: StyleFacts,
graph: TopologyGraph,
): void {
// Pre-compute clipping roots.
const clipRootSelf = new Uint32Array(dom.nodeCount).fill(INVALID_ID);
for (let i = 0; i < dom.nodeCount; i++) {
const { clips } = introducesClipping(
styles.overflowX[i],
styles.overflowY[i],
styles.containFlags[i],
);
if (clips) {
clipRootSelf[i] = i;
}
}
// Fixed positioned elements escape overflow clips of intermediate ancestors
// but are still clipped by the viewport. In our simplified model we treat
// the viewport as a clipping root represented by INVALID_ID.
for (let i = 0; i < dom.nodeCount; i++) {
let cursor = dom.parentNodeId[i];
let found = INVALID_ID;
// Fixed elements skip all ancestor overflow clips.
const isFixed = styles.position[i] === Position.FIXED;
while (cursor !== INVALID_ID) {
if (clipRootSelf[cursor] !== INVALID_ID) {
// Fixed positioned elements are not clipped by ancestor overflow,
// but the viewport clip (represented by INVALID_ID) still applies.
if (isFixed && styles.position[cursor] !== Position.FIXED) {
// Skip this overflow clip for fixed descendants.
} else {
found = cursor;
break;
}
}
cursor = dom.parentNodeId[cursor];
}
graph.clippingRootOf[i] = found;
setConfidence(graph, i, ConfidenceLevel.DERIVED, 20 /* clip chain */);
}
}
// ---------------------------------------------------------------------------
// Combined chain builder (scroll + clip)
// ---------------------------------------------------------------------------
export function buildChains(
dom: DomTree,
styles: StyleFacts,
graph: TopologyGraph,
hasMetrics: boolean,
): void {
buildScrollChain(dom, styles, graph, hasMetrics);
buildClipChain(dom, styles, graph);
}
+126
View File
@@ -0,0 +1,126 @@
// Formatting context boundary computation for Imhotep topology engine.
import {
type DomTree,
type StyleFacts,
type TopologyGraph,
Display,
Overflow,
Position,
ConfidenceLevel,
INVALID_ID,
setConfidence,
} from './graph.js';
// ---------------------------------------------------------------------------
// Formatting context establishment predicates
// ---------------------------------------------------------------------------
/**
* Returns true if the element establishes an independent formatting context.
*
* Rules (simplified, covering the common cases):
* 1. Root element.
* 2. display: inline-block, flex, grid, table, table-cell.
* 3. overflow != visible on a block container (block formatting context).
* 4. position: absolute or fixed (they establish a new containing block,
* which implies an independent formatting context for their contents).
* 5. contain: layout or paint (CSS Containment).
*
* Floats also establish BFCs, but we do not model float here.
*/
export function establishesFormattingContext(
subjectId: number,
isRoot: boolean,
styles: StyleFacts,
): { yes: boolean; confidence: number } {
if (isRoot) {
return { yes: true, confidence: ConfidenceLevel.EXACT };
}
const disp = styles.display[subjectId];
// Inline-block, flex, grid, table all establish independent formatting contexts.
if (
disp === Display.INLINE_BLOCK ||
disp === Display.FLEX ||
disp === Display.GRID ||
disp === Display.TABLE
) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
// Block container with overflow != visible establishes a BFC.
if (disp === Display.BLOCK) {
const overflowHidden =
styles.overflowX[subjectId] !== Overflow.VISIBLE ||
styles.overflowY[subjectId] !== Overflow.VISIBLE;
if (overflowHidden) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
}
// Absolutely or fixed positioned elements establish an independent formatting
// context for their contents because they form a containing block.
const pos = styles.position[subjectId];
if (pos === Position.ABSOLUTE || pos === Position.FIXED) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
// CSS contain: layout or paint.
const contain = styles.containFlags[subjectId];
if (contain & (0x01 | 0x02)) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
return { yes: false, confidence: ConfidenceLevel.EXACT };
}
// ---------------------------------------------------------------------------
// Formatting context assignment
// ---------------------------------------------------------------------------
/**
* For each node, find the nearest ancestor (including itself) that establishes
* a formatting context. This is the formatting context the node participates in.
*/
export function assignFormattingContexts(
dom: DomTree,
styles: StyleFacts,
graph: TopologyGraph,
): void {
// Pre-compute formatting context roots.
const isFormattingRoot = new Uint8Array(dom.nodeCount).fill(0);
const formattingConfidence = new Float32Array(dom.nodeCount).fill(ConfidenceLevel.EXACT);
for (let i = 0; i < dom.nodeCount; i++) {
const isRoot = dom.parentNodeId[i] === INVALID_ID;
const { yes, confidence } = establishesFormattingContext(i, isRoot, styles);
if (yes) {
isFormattingRoot[i] = 1;
}
formattingConfidence[i] = confidence;
}
for (let i = 0; i < dom.nodeCount; i++) {
let cursor = i;
let found = INVALID_ID;
while (cursor !== INVALID_ID) {
if (isFormattingRoot[cursor]) {
found = cursor;
break;
}
cursor = dom.parentNodeId[cursor];
}
graph.formattingContextOf[i] = found;
const minConfidence: number =
found === INVALID_ID
? ConfidenceLevel.INDETERMINATE
: Math.min(formattingConfidence[found], ConfidenceLevel.DERIVED);
setConfidence(graph, i, minConfidence as any, 40 /* formatting context */);
}
}
+253
View File
@@ -0,0 +1,253 @@
// Topology graph data structures for Imhotep.
// Uses a data-oriented design: integer IDs, flat arrays, and compact enums.
// All arrays are indexed by subject ID (a 0-based dense integer).
// ---------------------------------------------------------------------------
// Confidence levels for approximated or inferred facts
// ---------------------------------------------------------------------------
export const ConfidenceLevel = {
/** Fact was extracted directly from the browser or runtime with no approximation. */
EXACT: 1.0,
/** Fact was derived through unambiguous deterministic rules from exact inputs. */
DERIVED: 0.95,
/** Fact was approximated from observable data with high confidence (e.g. matching known patterns). */
HIGH: 0.85,
/** Fact was approximated with moderate confidence; edge cases may differ from browser behavior. */
MEDIUM: 0.65,
/** Fact was guessed or inferred from incomplete data; should be used with caution. */
LOW: 0.4,
/** Fact is indeterminate due to missing or unstable inputs. */
INDETERMINATE: 0.0,
} as const;
export type ConfidenceLevelValue = (typeof ConfidenceLevel)[keyof typeof ConfidenceLevel];
// ---------------------------------------------------------------------------
// Enum codes for position and overflow (mirrors CSS used values)
// ---------------------------------------------------------------------------
export const Position = {
STATIC: 0,
RELATIVE: 1,
ABSOLUTE: 2,
FIXED: 3,
STICKY: 4,
} as const;
export const Overflow = {
VISIBLE: 0,
HIDDEN: 1,
CLIP: 2,
SCROLL: 3,
AUTO: 4,
} as const;
export const Display = {
NONE: 0,
BLOCK: 1,
INLINE: 2,
INLINE_BLOCK: 3,
FLEX: 4,
GRID: 5,
TABLE: 6,
CONTENTS: 7,
OTHER: 255,
} as const;
// ---------------------------------------------------------------------------
// Z-index kind (determines whether an element establishes a stacking context)
// ---------------------------------------------------------------------------
export const ZIndexKind = {
/** No z-index specified. */
AUTO: 0,
/** Integer z-index value was specified (even 0). */
INTEGER: 1,
} as const;
// ---------------------------------------------------------------------------
// Per-node style facts that the topology engine needs from the geometry world.
// ---------------------------------------------------------------------------
export interface StyleFacts {
/** display code (Display.*) */
display: Uint16Array;
/** position code (Position.*) */
position: Uint16Array;
/** z-index kind (ZIndexKind.*) */
zIndexKind: Uint8Array;
/** z-index integer value when kind === INTEGER */
zIndexValue: Int32Array;
/** overflow-x code (Overflow.*) */
overflowX: Uint16Array;
/** overflow-y code (Overflow.*) */
overflowY: Uint16Array;
/** opacity value [0, 1] */
opacity: Float32Array;
/** CSS contain flags (bitmask) */
containFlags: Uint32Array;
}
// ---------------------------------------------------------------------------
// DOM ancestry (parent pointer tree)
// ---------------------------------------------------------------------------
export interface DomTree {
/** parentNodeId[i] is the parent subject ID of node i, or INVALID_ID for the root. */
parentNodeId: Uint32Array;
/** Number of nodes in the tree. */
nodeCount: number;
}
// ---------------------------------------------------------------------------
// Invalid / sentinel ID
// ---------------------------------------------------------------------------
export const INVALID_ID = 0xffffffff;
// ---------------------------------------------------------------------------
// Topology graph (output of the topology engine)
// ---------------------------------------------------------------------------
export interface TopologyGraph {
/** For each subject, the ID of its containing block root. INVALID_ID if none. */
containingBlockOf: Uint32Array;
/** For each subject, the ID of its nearest positioned ancestor. INVALID_ID if none. */
nearestPositionedAncestorOf: Uint32Array;
/** For each subject, the ID of its nearest scroll container ancestor. INVALID_ID if none. */
scrollContainerOf: Uint32Array;
/** For each subject, the ID of the stacking context it participates in. INVALID_ID if none. */
stackingContextOf: Uint32Array;
/** For each subject, the ID of the formatting context it belongs to. INVALID_ID if none. */
formattingContextOf: Uint32Array;
/** For each subject, the ID of the nearest clipping root ancestor. INVALID_ID if none. */
clippingRootOf: Uint32Array;
/** For each subject, the paint-order bucket within its stacking context. */
paintOrderBucket: Uint16Array;
/** For each subject, the local paint index within its bucket. */
paintOrderIndex: Uint32Array;
/** Confidence for each derived topology fact (parallel to the arrays above). */
confidence: Float32Array;
/** Reason code for each confidence value (opaque enum for diagnostics). */
confidenceReason: Uint16Array;
}
// ---------------------------------------------------------------------------
// Paint-order bucket constants (CSS 2.1 Appendix E layering)
// ---------------------------------------------------------------------------
export const PaintBucket = {
/** Background and borders of the stacking context root itself. */
ROOT_BACKGROUND_BORDERS: 0,
/** Negative z-index children. */
NEGATIVE_Z_INDEX: 1,
/** Block-level non-positioned descendants in tree order. */
BLOCK_LEVEL: 2,
/** Floats. */
FLOATS: 3,
/** Inline-level non-positioned descendants in tree order. */
INLINE_LEVEL: 4,
/** Positioned descendants with z-index: auto (or no z-index). */
POSITIONED_AUTO: 5,
/** Positive z-index children. */
POSITIVE_Z_INDEX: 6,
} as const;
// ---------------------------------------------------------------------------
// Clipping root kinds
// ---------------------------------------------------------------------------
export const ClipKind = {
/** No clipping applies. */
NONE: 0,
/** overflow != visible on an ancestor. */
OVERFLOW: 1,
/** clip-path or mask (approximated). */
PATH: 2,
/** Containment clip (e.g. CSS contain: paint). */
CONTAIN: 3,
} as const;
// ---------------------------------------------------------------------------
// Scroll container flags
// ---------------------------------------------------------------------------
export const ScrollKind = {
/** Not a scroll container. */
NONE: 0,
/** overflow: scroll. */
SCROLL: 1,
/** overflow: auto with scrollable overflow. */
AUTO: 2,
/** overflow: hidden with scrollable overflow (programmatic scroll). */
HIDDEN_SCROLLABLE: 3,
} as const;
// ---------------------------------------------------------------------------
// Helpers for allocating topology arrays
// ---------------------------------------------------------------------------
export function createTopologyGraph(nodeCount: number): TopologyGraph {
return {
containingBlockOf: new Uint32Array(nodeCount).fill(INVALID_ID),
nearestPositionedAncestorOf: new Uint32Array(nodeCount).fill(INVALID_ID),
scrollContainerOf: new Uint32Array(nodeCount).fill(INVALID_ID),
stackingContextOf: new Uint32Array(nodeCount).fill(INVALID_ID),
formattingContextOf: new Uint32Array(nodeCount).fill(INVALID_ID),
clippingRootOf: new Uint32Array(nodeCount).fill(INVALID_ID),
paintOrderBucket: new Uint16Array(nodeCount).fill(PaintBucket.BLOCK_LEVEL),
paintOrderIndex: new Uint32Array(nodeCount).fill(0),
confidence: new Float32Array(nodeCount).fill(ConfidenceLevel.EXACT),
confidenceReason: new Uint16Array(nodeCount).fill(0),
};
}
// ---------------------------------------------------------------------------
// Confidence annotation helpers
// ---------------------------------------------------------------------------
export function setConfidence(
graph: TopologyGraph,
subjectId: number,
level: ConfidenceLevelValue,
reason: number,
): void {
graph.confidence[subjectId] = level;
graph.confidenceReason[subjectId] = reason;
}
+80
View File
@@ -0,0 +1,80 @@
// imhotep-topology — derived graph facts for clipping, scrolling, containing blocks, and stacking contexts.
export {
// Constants
ConfidenceLevel,
Position,
Overflow,
Display,
ZIndexKind,
PaintBucket,
ClipKind,
ScrollKind,
INVALID_ID,
// Types
type ConfidenceLevelValue,
type StyleFacts,
type DomTree,
type TopologyGraph,
// Helpers
createTopologyGraph,
setConfidence,
} from './graph.js';
export {
isPositioned,
resolveContainingBlock,
walkUpForPositionedAncestor,
buildAncestorTables,
computeAncestors,
type AncestorTables,
} from './ancestors.js';
export {
isScrollContainer,
introducesClipping,
buildScrollChain,
buildClipChain,
buildChains,
} from './chains.js';
export {
establishesStackingContext,
classifyPaintBucket,
assignStackingContexts,
type StackingContextAssignment,
} from './stacking.js';
export {
establishesFormattingContext,
assignFormattingContexts,
} from './formatting.js';
export {
inStackingContext,
clippedBy,
attachedToScrollContainer,
comparePaintOrder,
isAbove,
isBelow,
containingBlockOf,
nearestPositionedAncestorOf,
scrollContainerOf,
stackingContextOf,
formattingContextOf,
clippingRootOf,
type TopologyQueryResult,
type PaintOrderComparison,
} from './queries.js';
export {
above,
below,
leftOf,
rightOf,
inside,
overlaps,
type PredicateTruth,
} from './predicate-queries.js';
@@ -0,0 +1,389 @@
// 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');
});
});
@@ -0,0 +1,139 @@
// Topology spatial predicates callable from the logic engine (V1.1)
// These operate over a materialized geometry world and return explicit truth
// values with indeterminate handling — no silent defaults for missing facts.
import { GeometryWorld } from 'imhotep-geometry';
// ---------------------------------------------------------------------------
// Predicate truth model
// ---------------------------------------------------------------------------
export type PredicateTruth =
| { kind: 'true' }
| { kind: 'false' }
| { kind: 'indeterminate'; reason: string };
// ---------------------------------------------------------------------------
// Helpers: box lookup
// ---------------------------------------------------------------------------
function findBoxIndexForSubject(world: GeometryWorld, subjectId: number): number {
// Boxes are indexed by subjectId; linear scan is acceptable for the
// current data-oriented layout because the solver will cache lookups.
for (let i = 0; i < world.boxes.boxId.length; i++) {
if (world.boxes.subjectId[i] === subjectId) {
return i;
}
}
return -1;
}
interface BoxCoords {
left: number;
top: number;
right: number;
bottom: number;
}
function getBox(world: GeometryWorld, subjectId: number): BoxCoords | undefined {
const idx = findBoxIndexForSubject(world, subjectId);
if (idx === -1) return undefined;
return {
left: world.boxes.contentLeft[idx],
top: world.boxes.contentTop[idx],
right: world.boxes.contentRight[idx],
bottom: world.boxes.contentBottom[idx],
};
}
// ---------------------------------------------------------------------------
// Spatial predicates
// ---------------------------------------------------------------------------
/**
* Returns true if `aId` is spatially above `bId`.
* "Above" means a's bottom edge is at or above b's top edge.
*/
export function above(aId: number, bId: number, world: GeometryWorld): PredicateTruth {
const a = getBox(world, aId);
const b = getBox(world, bId);
if (!a || !b) {
return { kind: 'indeterminate', reason: 'missing box geometry' };
}
return { kind: a.bottom <= b.top ? 'true' : 'false' };
}
/**
* Returns true if `aId` is spatially below `bId`.
* "Below" means a's top edge is at or below b's bottom edge.
*/
export function below(aId: number, bId: number, world: GeometryWorld): PredicateTruth {
const a = getBox(world, aId);
const b = getBox(world, bId);
if (!a || !b) {
return { kind: 'indeterminate', reason: 'missing box geometry' };
}
return { kind: a.top >= b.bottom ? 'true' : 'false' };
}
/**
* Returns true if `aId` is spatially to the left of `bId`.
* "Left of" means a's right edge is at or to the left of b's left edge.
*/
export function leftOf(aId: number, bId: number, world: GeometryWorld): PredicateTruth {
const a = getBox(world, aId);
const b = getBox(world, bId);
if (!a || !b) {
return { kind: 'indeterminate', reason: 'missing box geometry' };
}
return { kind: a.right <= b.left ? 'true' : 'false' };
}
/**
* Returns true if `aId` is spatially to the right of `bId`.
* "Right of" means a's left edge is at or to the right of b's right edge.
*/
export function rightOf(aId: number, bId: number, world: GeometryWorld): PredicateTruth {
const a = getBox(world, aId);
const b = getBox(world, bId);
if (!a || !b) {
return { kind: 'indeterminate', reason: 'missing box geometry' };
}
return { kind: a.left >= b.right ? 'true' : 'false' };
}
/**
* Returns true if `aId` is fully inside `bId`.
* "Inside" means a's box is completely contained within b's box.
*/
export function inside(aId: number, bId: number, world: GeometryWorld): PredicateTruth {
const a = getBox(world, aId);
const b = getBox(world, bId);
if (!a || !b) {
return { kind: 'indeterminate', reason: 'missing box geometry' };
}
const value =
a.left >= b.left &&
a.top >= b.top &&
a.right <= b.right &&
a.bottom <= b.bottom;
return { kind: value ? 'true' : 'false' };
}
/**
* Returns true if `aId` spatially overlaps `bId`.
* "Overlaps" means the intersection of their boxes has positive area.
*/
export function overlaps(aId: number, bId: number, world: GeometryWorld): PredicateTruth {
const a = getBox(world, aId);
const b = getBox(world, bId);
if (!a || !b) {
return { kind: 'indeterminate', reason: 'missing box geometry' };
}
const intersectLeft = Math.max(a.left, b.left);
const intersectTop = Math.max(a.top, b.top);
const intersectRight = Math.min(a.right, b.right);
const intersectBottom = Math.min(a.bottom, b.bottom);
const value = intersectLeft < intersectRight && intersectTop < intersectBottom;
return { kind: value ? 'true' : 'false' };
}
@@ -0,0 +1,296 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import fc from 'fast-check';
import {
createTopologyGraph,
INVALID_ID,
ConfidenceLevel,
Position,
Overflow,
Display,
ZIndexKind,
PaintBucket,
ClipKind,
ScrollKind,
type DomTree,
type StyleFacts,
type TopologyGraph,
} from './graph.js';
import {
isPositioned,
resolveContainingBlock,
walkUpForPositionedAncestor,
buildAncestorTables,
computeAncestors,
} from './ancestors.js';
import {
isScrollContainer,
introducesClipping,
buildScrollChain,
buildClipChain,
buildChains,
} from './chains.js';
import {
establishesStackingContext,
classifyPaintBucket,
assignStackingContexts,
} from './stacking.js';
// ---- Arbitraries ----
const nodeCountArb = fc.integer({ min: 2, max: 30 });
function makeDomTreeArb(nodeCount: number) {
// Generate a valid parent-pointer tree where node 0 is root
return fc.array(
fc.integer({ min: 0, max: nodeCount - 1 }),
{ minLength: nodeCount, maxLength: nodeCount }
).map(parents => {
const parentNodeId = new Uint32Array(nodeCount);
parentNodeId[0] = INVALID_ID;
for (let i = 1; i < nodeCount; i++) {
// Ensure parent index < child index to avoid cycles
parentNodeId[i] = parents[i] < i ? parents[i] : 0;
}
return { parentNodeId, nodeCount };
});
}
function makeStylesArb(nodeCount: number) {
return fc.record({
display: fc.array(fc.integer({ min: 0, max: 7 }), { minLength: nodeCount, maxLength: nodeCount }),
position: fc.array(fc.integer({ min: 0, max: 4 }), { minLength: nodeCount, maxLength: nodeCount }),
zIndexKind: fc.array(fc.integer({ min: 0, max: 1 }), { minLength: nodeCount, maxLength: nodeCount }),
zIndexValue: fc.array(fc.integer({ min: -10, max: 10 }), { minLength: nodeCount, maxLength: nodeCount }),
overflowX: fc.array(fc.integer({ min: 0, max: 4 }), { minLength: nodeCount, maxLength: nodeCount }),
overflowY: fc.array(fc.integer({ min: 0, max: 4 }), { minLength: nodeCount, maxLength: nodeCount }),
opacity: fc.array(fc.float({ min: 0, max: 1 }), { minLength: nodeCount, maxLength: nodeCount }),
containFlags: fc.array(fc.integer({ min: 0, max: 3 }), { minLength: nodeCount, maxLength: nodeCount }),
}).map(raw => ({
display: new Uint16Array(raw.display),
position: new Uint16Array(raw.position),
zIndexKind: new Uint8Array(raw.zIndexKind),
zIndexValue: new Int32Array(raw.zIndexValue),
overflowX: new Uint16Array(raw.overflowX),
overflowY: new Uint16Array(raw.overflowY),
opacity: new Float32Array(raw.opacity),
containFlags: new Uint32Array(raw.containFlags),
}));
}
// ---- Property Tests ----
describe('topology property tests', () => {
it('property: ancestor chain is transitive', () => {
fc.assert(
fc.property(
nodeCountArb.chain(n =>
fc.tuple(makeDomTreeArb(n), makeStylesArb(n))
),
([dom, styles]) => {
const tables = buildAncestorTables(dom, styles);
for (let i = 0; i < dom.nodeCount; i++) {
const containingBlock = tables.containingBlockOf[i];
const positionedAncestor = tables.nearestPositionedAncestorOf[i];
// containingBlock should either be INVALID_ID or a valid ancestor
if (containingBlock !== INVALID_ID) {
assert.ok(containingBlock < dom.nodeCount, 'Containing block must be a valid node');
// Walk up from containingBlock to verify it's an ancestor of i
let cursor = dom.parentNodeId[i];
let found = false;
while (cursor !== INVALID_ID) {
if (cursor === containingBlock) {
found = true;
break;
}
cursor = dom.parentNodeId[cursor];
}
assert.ok(found || containingBlock === 0, 'Containing block should be an ancestor (or root)');
}
if (positionedAncestor !== INVALID_ID) {
assert.ok(positionedAncestor < dom.nodeCount, 'Positioned ancestor must be a valid node');
let cursor = dom.parentNodeId[i];
let found = false;
while (cursor !== INVALID_ID) {
if (cursor === positionedAncestor) {
found = true;
break;
}
cursor = dom.parentNodeId[cursor];
}
assert.ok(found || positionedAncestor === 0, 'Positioned ancestor should be an ancestor (or root)');
}
// Transitivity: if A is ancestor of B and B is ancestor of C, then A is ancestor of C
// We verify by checking that walkUpForPositionedAncestor returns consistent results
if (positionedAncestor !== INVALID_ID) {
const paOfPa = walkUpForPositionedAncestor(positionedAncestor, dom, styles);
if (paOfPa !== INVALID_ID) {
const directPa = walkUpForPositionedAncestor(i, dom, styles);
// The positioned ancestor of i should not be deeper than the positioned ancestor of its positioned ancestor
assert.ok(
directPa === positionedAncestor || directPa === paOfPa || directPa === INVALID_ID,
'Ancestor chain should respect transitivity'
);
}
}
}
}
),
{ numRuns: 200, seed: 42 }
);
});
it('property: clipping chain respects ancestor relationship', () => {
fc.assert(
fc.property(
nodeCountArb.chain(n =>
fc.tuple(makeDomTreeArb(n), makeStylesArb(n))
),
([dom, styles]) => {
const graph = createTopologyGraph(dom.nodeCount);
buildClipChain(dom, styles, graph);
for (let i = 0; i < dom.nodeCount; i++) {
const clipRoot = graph.clippingRootOf[i];
if (clipRoot === INVALID_ID) {
// No clipping root is always valid
continue;
}
assert.ok(clipRoot < dom.nodeCount, 'Clip root must be a valid node');
// The clip root must be an ancestor of i (or the root)
let cursor = dom.parentNodeId[i];
let found = false;
while (cursor !== INVALID_ID) {
if (cursor === clipRoot) {
found = true;
break;
}
cursor = dom.parentNodeId[cursor];
}
assert.ok(found || clipRoot === 0, 'Clip root should be an ancestor');
// Verify the clip root actually introduces clipping
const { clips } = introducesClipping(
styles.overflowX[clipRoot],
styles.overflowY[clipRoot],
styles.containFlags[clipRoot]
);
assert.ok(clips, 'Clip root must introduce clipping');
}
}
),
{ numRuns: 200, seed: 123 }
);
});
it('property: stacking context assignment is deterministic', () => {
fc.assert(
fc.property(
nodeCountArb.chain(n =>
fc.tuple(makeDomTreeArb(n), makeStylesArb(n))
),
([dom, styles]) => {
const graph1 = createTopologyGraph(dom.nodeCount);
const graph2 = createTopologyGraph(dom.nodeCount);
assignStackingContexts(dom, styles, graph1);
assignStackingContexts(dom, styles, graph2);
// Results should be identical across runs with same input
for (let i = 0; i < dom.nodeCount; i++) {
assert.strictEqual(
graph1.stackingContextOf[i],
graph2.stackingContextOf[i],
`Stacking context assignment should be deterministic for node ${i}`
);
assert.strictEqual(
graph1.paintOrderBucket[i],
graph2.paintOrderBucket[i],
`Paint bucket assignment should be deterministic for node ${i}`
);
assert.strictEqual(
graph1.paintOrderIndex[i],
graph2.paintOrderIndex[i],
`Paint order index should be deterministic for node ${i}`
);
}
// Every node should have a valid stacking context (root establishes one)
for (let i = 0; i < dom.nodeCount; i++) {
assert.notStrictEqual(
graph1.stackingContextOf[i],
INVALID_ID,
`Every node should be assigned a stacking context`
);
}
// Stacking context root should itself be a stacking context
for (let i = 0; i < dom.nodeCount; i++) {
const sc = graph1.stackingContextOf[i];
assert.ok(sc < dom.nodeCount, 'Stacking context must be a valid node');
const isRoot = dom.parentNodeId[sc] === INVALID_ID;
const { yes } = establishesStackingContext(sc, isRoot, styles);
assert.ok(yes, `Node ${sc} assigned as stacking context should establish one`);
}
}
),
{ numRuns: 200, seed: 77 }
);
});
it('property: scroll container chain respects ancestor relationship', () => {
fc.assert(
fc.property(
nodeCountArb.chain(n =>
fc.tuple(makeDomTreeArb(n), makeStylesArb(n))
),
([dom, styles]) => {
const graph = createTopologyGraph(dom.nodeCount);
buildScrollChain(dom, styles, graph, false);
for (let i = 0; i < dom.nodeCount; i++) {
const scrollContainer = graph.scrollContainerOf[i];
if (scrollContainer === INVALID_ID) {
continue;
}
assert.ok(scrollContainer < dom.nodeCount, 'Scroll container must be a valid node');
// The scroll container must be an ancestor of i
let cursor = dom.parentNodeId[i];
let found = false;
while (cursor !== INVALID_ID) {
if (cursor === scrollContainer) {
found = true;
break;
}
cursor = dom.parentNodeId[cursor];
}
assert.ok(found || scrollContainer === 0, 'Scroll container should be an ancestor');
// Verify the scroll container actually is a scroll container
const { kind } = isScrollContainer(
styles.overflowX[scrollContainer],
styles.overflowY[scrollContainer],
false
);
assert.notStrictEqual(kind, ScrollKind.NONE, 'Scroll container must actually scroll');
}
}
),
{ numRuns: 200, seed: 88 }
);
});
});
+298
View File
@@ -0,0 +1,298 @@
// Topology query API for Imhotep.
// Provides high-level predicates used by the solver and scene explorer.
import {
type TopologyGraph,
type DomTree,
INVALID_ID,
PaintBucket,
} from './graph.js';
// ---------------------------------------------------------------------------
// Query result shape
// ---------------------------------------------------------------------------
export interface TopologyQueryResult {
/** Whether the predicate holds. */
value: boolean;
/** Confidence in the result [0, 1]. */
confidence: number;
/** Subject ID that the query was about. */
subjectId: number;
/** Optional reference subject ID (for binary predicates). */
referenceId?: number;
/** Diagnostic reason code (opaque). */
reasonCode: number;
}
// ---------------------------------------------------------------------------
// inStackingContext
// ---------------------------------------------------------------------------
/**
* Returns true if `subjectId` paints inside the stacking context rooted at
* `contextId` (or any stacking context when contextId is omitted).
*/
export function inStackingContext(
graph: TopologyGraph,
subjectId: number,
contextId?: number,
): TopologyQueryResult {
const actualContext = graph.stackingContextOf[subjectId];
if (actualContext === INVALID_ID) {
return {
value: false,
confidence: graph.confidence[subjectId],
subjectId,
reasonCode: 100 /* no stacking context assigned */,
};
}
if (contextId === undefined) {
return {
value: true,
confidence: graph.confidence[subjectId],
subjectId,
reasonCode: 101 /* participates in a stacking context */,
};
}
return {
value: actualContext === contextId,
confidence: graph.confidence[subjectId],
subjectId,
referenceId: contextId,
reasonCode: actualContext === contextId ? 102 : 103,
};
}
// ---------------------------------------------------------------------------
// clippedBy
// ---------------------------------------------------------------------------
/**
* Returns true if `subjectId` is clipped by `ancestorId`.
*
* An element is clipped by an ancestor when that ancestor is on its clipping
* chain. If `ancestorId` is omitted, returns true if the element is clipped
* by *any* ancestor.
*/
export function clippedBy(
graph: TopologyGraph,
subjectId: number,
ancestorId?: number,
): TopologyQueryResult {
const clipRoot = graph.clippingRootOf[subjectId];
if (clipRoot === INVALID_ID) {
return {
value: false,
confidence: graph.confidence[subjectId],
subjectId,
reasonCode: 200 /* not clipped */,
};
}
if (ancestorId === undefined) {
return {
value: true,
confidence: graph.confidence[subjectId],
subjectId,
reasonCode: 201 /* clipped by some ancestor */,
};
}
// Walk up the clipping chain to see if ancestorId appears.
const cursor = clipRoot;
while (cursor !== INVALID_ID) {
if (cursor === ancestorId) {
return {
value: true,
confidence: graph.confidence[subjectId],
subjectId,
referenceId: ancestorId,
reasonCode: 202 /* clipped by specified ancestor */,
};
}
// We need the parent of the clip root to continue walking; the graph
// does not store the full chain, only the nearest root. For a full
// check we would need the DOM tree. As a pragmatic fallback we treat
// a mismatch on the nearest root as inconclusive when the caller asks
// for a specific ancestor.
break;
}
return {
value: false,
confidence: graph.confidence[subjectId],
subjectId,
referenceId: ancestorId,
reasonCode: 203 /* not clipped by specified ancestor */,
};
}
// ---------------------------------------------------------------------------
// attachedToScrollContainer
// ---------------------------------------------------------------------------
/**
* Returns true if `subjectId` is attached to (scrolls with) the scroll
* container `containerId`. When containerId is omitted, returns true if
* the element is attached to *any* scroll container.
*/
export function attachedToScrollContainer(
graph: TopologyGraph,
subjectId: number,
containerId?: number,
): TopologyQueryResult {
const scrollContainer = graph.scrollContainerOf[subjectId];
if (scrollContainer === INVALID_ID) {
return {
value: false,
confidence: graph.confidence[subjectId],
subjectId,
reasonCode: 300 /* no scroll container */,
};
}
if (containerId === undefined) {
return {
value: true,
confidence: graph.confidence[subjectId],
subjectId,
reasonCode: 301 /* attached to some scroll container */,
};
}
return {
value: scrollContainer === containerId,
confidence: graph.confidence[subjectId],
subjectId,
referenceId: containerId,
reasonCode: scrollContainer === containerId ? 302 : 303,
};
}
// ---------------------------------------------------------------------------
// paintOrderComparison (helper for "paints above / below")
// ---------------------------------------------------------------------------
export type PaintOrderComparison = 'before' | 'after' | 'same' | 'incomparable';
/**
* Compare the paint order of two subjects.
*
* Returns:
* 'before' subjectId paints *before* referenceId (i.e. is underneath).
* 'after' subjectId paints *after* referenceId (i.e. is on top).
* 'same' same paint index (rare, e.g. siblings in same bucket).
* 'incomparable' different stacking contexts; no direct order.
*/
export function comparePaintOrder(
graph: TopologyGraph,
subjectId: number,
referenceId: number,
): PaintOrderComparison {
const scA = graph.stackingContextOf[subjectId];
const scB = graph.stackingContextOf[referenceId];
if (scA === INVALID_ID || scB === INVALID_ID) {
return 'incomparable';
}
if (scA !== scB) {
return 'incomparable';
}
const bucketA = graph.paintOrderBucket[subjectId];
const bucketB = graph.paintOrderBucket[referenceId];
if (bucketA < bucketB) return 'before';
if (bucketA > bucketB) return 'after';
const idxA = graph.paintOrderIndex[subjectId];
const idxB = graph.paintOrderIndex[referenceId];
if (idxA < idxB) return 'before';
if (idxA > idxB) return 'after';
return 'same';
}
// ---------------------------------------------------------------------------
// isAbove / isBelow (convenience wrappers)
// ---------------------------------------------------------------------------
export function isAbove(
graph: TopologyGraph,
subjectId: number,
referenceId: number,
): TopologyQueryResult {
const order = comparePaintOrder(graph, subjectId, referenceId);
const value = order === 'after';
return {
value,
confidence: graph.confidence[subjectId],
subjectId,
referenceId,
reasonCode: value ? 400 : 401,
};
}
export function isBelow(
graph: TopologyGraph,
subjectId: number,
referenceId: number,
): TopologyQueryResult {
const order = comparePaintOrder(graph, subjectId, referenceId);
const value = order === 'before';
return {
value,
confidence: graph.confidence[subjectId],
subjectId,
referenceId,
reasonCode: value ? 410 : 411,
};
}
// ---------------------------------------------------------------------------
// Predicate-friendly fact lookups over bound subjects (V1.1)
// These return raw topology facts for use by the logic engine.
// ---------------------------------------------------------------------------
/** Return the containing block subject ID for `elementId`, or INVALID_ID if none. */
export function containingBlockOf(graph: TopologyGraph, elementId: number): number {
return graph.containingBlockOf[elementId];
}
/** Return the nearest positioned ancestor subject ID for `elementId`, or INVALID_ID if none. */
export function nearestPositionedAncestorOf(graph: TopologyGraph, elementId: number): number {
return graph.nearestPositionedAncestorOf[elementId];
}
/** Return the scroll container subject ID for `elementId`, or INVALID_ID if none. */
export function scrollContainerOf(graph: TopologyGraph, elementId: number): number {
return graph.scrollContainerOf[elementId];
}
/** Return the stacking context subject ID for `elementId`, or INVALID_ID if none. */
export function stackingContextOf(graph: TopologyGraph, elementId: number): number {
return graph.stackingContextOf[elementId];
}
/** Return the formatting context subject ID for `elementId`, or INVALID_ID if none. */
export function formattingContextOf(graph: TopologyGraph, elementId: number): number {
return graph.formattingContextOf[elementId];
}
/** Return the clipping root subject ID for `elementId`, or INVALID_ID if none. */
export function clippingRootOf(graph: TopologyGraph, elementId: number): number {
return graph.clippingRootOf[elementId];
}
+199
View File
@@ -0,0 +1,199 @@
// Stacking context assignment and paint-order bucket computation for Imhotep topology engine.
import {
type DomTree,
type StyleFacts,
type TopologyGraph,
Position,
ZIndexKind,
Display,
Overflow,
PaintBucket,
ConfidenceLevel,
INVALID_ID,
setConfidence,
} from './graph.js';
// ---------------------------------------------------------------------------
// Stacking-context establishment predicates (CSS 2.1 + CSS Positioned Layout)
// ---------------------------------------------------------------------------
/**
* Returns true if the element establishes a new stacking context.
*
* Simplified rules that cover the vast majority of real-world UIs:
* 1. Root element always establishes a stacking context.
* 2. z-index with an integer value (including 0).
* 3. opacity < 1.
* 4. position: fixed or sticky (always creates a stacking context in modern browsers).
* 5. transform or perspective (not modelled here; approximated via contain or will-change).
* 6. CSS contain: paint or layout (approximated via containFlags).
* 7. display: flex or grid with z-index (any value, even auto).
*
* When a rule requires data we do not have (e.g. transform), we fall back to
* a conservative approximation with lowered confidence.
*/
export function establishesStackingContext(
subjectId: number,
isRoot: boolean,
styles: StyleFacts,
): { yes: boolean; confidence: number } {
if (isRoot) {
return { yes: true, confidence: ConfidenceLevel.EXACT };
}
// Rule 2: explicit integer z-index.
if (styles.zIndexKind[subjectId] === ZIndexKind.INTEGER) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
// Rule 3: opacity < 1.
if (styles.opacity[subjectId] < 1.0 - Number.EPSILON) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
// Rule 4: fixed or sticky positioning.
const pos = styles.position[subjectId];
if (pos === Position.FIXED || pos === Position.STICKY) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
// Rule 6: CSS contain: paint / layout (bit 0x02 = paint, 0x01 = layout).
const contain = styles.containFlags[subjectId];
if (contain & (0x01 | 0x02)) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
// Rule 7: flex or grid with z-index (any value).
const disp = styles.display[subjectId];
if ((disp === Display.FLEX || disp === Display.GRID) && styles.zIndexKind[subjectId] !== ZIndexKind.AUTO) {
return { yes: true, confidence: ConfidenceLevel.DERIVED };
}
// Approximate: treat overflow != visible + position != static as a weak
// stacking-context signal (some old browsers did this; modern ones do not).
// We keep confidence LOW so callers know this is a guess.
if (
pos !== Position.STATIC &&
(styles.overflowX[subjectId] !== Overflow.VISIBLE ||
styles.overflowY[subjectId] !== Overflow.VISIBLE)
) {
return { yes: false, confidence: ConfidenceLevel.LOW };
}
return { yes: false, confidence: ConfidenceLevel.EXACT };
}
// ---------------------------------------------------------------------------
// Paint bucket classification within a stacking context
// ---------------------------------------------------------------------------
/**
* Classifies a non-stacking-context node into a paint-order bucket.
*
* This follows CSS 2.1 Appendix E layering order.
*/
export function classifyPaintBucket(
subjectId: number,
styles: StyleFacts,
): number {
const pos = styles.position[subjectId];
const zKind = styles.zIndexKind[subjectId];
const disp = styles.display[subjectId];
// Positioned with integer z-index => stacking context root, not a bucket.
if (pos !== Position.STATIC && zKind === ZIndexKind.INTEGER) {
// These nodes form their own stacking context; their children get buckets.
return PaintBucket.POSITIONED_AUTO;
}
// Positioned with auto / no z-index => positioned auto bucket.
if (pos !== Position.STATIC) {
return PaintBucket.POSITIONED_AUTO;
}
// Floats (approximated: we do not have float data, so this is a no-op).
// If we had float info we would return FLOATS here.
// Inline-level elements.
if (disp === Display.INLINE || disp === Display.INLINE_BLOCK) {
return PaintBucket.INLINE_LEVEL;
}
// Default block-level.
return PaintBucket.BLOCK_LEVEL;
}
// ---------------------------------------------------------------------------
// Stacking context assignment (iterative upward walk)
// ---------------------------------------------------------------------------
export interface StackingContextAssignment {
stackingContextOf: Uint32Array;
paintOrderBucket: Uint16Array;
paintOrderIndex: Uint32Array;
}
/**
* For every node, find the nearest ancestor (including itself) that
* establishes a stacking context. This ancestor is the stacking context
* that the node paints into.
*
* Paint-order indices are assigned per stacking context in tree-order.
* Because we process nodes in ascending ID order (which is assumed to be
* document / tree order), the index naturally reflects paint order.
*/
export function assignStackingContexts(
dom: DomTree,
styles: StyleFacts,
graph: TopologyGraph,
): void {
// Pre-compute which nodes are stacking context roots.
const isStackingRoot = new Uint8Array(dom.nodeCount).fill(0);
const stackingConfidence = new Float32Array(dom.nodeCount).fill(ConfidenceLevel.EXACT);
for (let i = 0; i < dom.nodeCount; i++) {
const isRoot = dom.parentNodeId[i] === INVALID_ID;
const { yes, confidence } = establishesStackingContext(i, isRoot, styles);
if (yes) {
isStackingRoot[i] = 1;
}
stackingConfidence[i] = confidence;
}
// Per-stacking-context paint counters.
const paintCounters = new Uint32Array(dom.nodeCount).fill(0);
for (let i = 0; i < dom.nodeCount; i++) {
// Walk upward to find the nearest stacking context root.
let cursor = i;
let found = INVALID_ID;
while (cursor !== INVALID_ID) {
if (isStackingRoot[cursor]) {
found = cursor;
break;
}
cursor = dom.parentNodeId[cursor];
}
graph.stackingContextOf[i] = found;
if (found !== INVALID_ID) {
graph.paintOrderBucket[i] = classifyPaintBucket(i, styles);
graph.paintOrderIndex[i] = paintCounters[found]++;
} else {
// Orphan node without a stacking context (should not happen for well-formed trees).
graph.paintOrderBucket[i] = PaintBucket.BLOCK_LEVEL;
graph.paintOrderIndex[i] = 0;
}
// Confidence is the minimum of the stacking-context establishment confidence
// and the ancestor-walk confidence (which is exact when data is present).
const minConfidence: number = Math.min(
stackingConfidence[found === INVALID_ID ? i : found],
ConfidenceLevel.DERIVED,
);
setConfidence(graph, i, minConfidence as any, 30 /* stacking context */);
}
}
@@ -0,0 +1,465 @@
// Unit tests for imhotep-topology engine.
// Uses Node.js built-in test runner.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
createTopologyGraph,
INVALID_ID,
ConfidenceLevel,
Position,
Overflow,
Display,
ZIndexKind,
PaintBucket,
ClipKind,
ScrollKind,
} from './graph.js';
import {
isPositioned,
resolveContainingBlock,
walkUpForPositionedAncestor,
buildAncestorTables,
computeAncestors,
} from './ancestors.js';
import {
isScrollContainer,
introducesClipping,
buildScrollChain,
buildClipChain,
buildChains,
} from './chains.js';
import {
establishesStackingContext,
classifyPaintBucket,
assignStackingContexts,
} from './stacking.js';
import {
establishesFormattingContext,
assignFormattingContexts,
} from './formatting.js';
import {
inStackingContext,
clippedBy,
attachedToScrollContainer,
comparePaintOrder,
isAbove,
isBelow,
} from './queries.js';
// ---------------------------------------------------------------------------
// Helpers to build minimal DOM + style fixtures
// ---------------------------------------------------------------------------
function makeDom(parents: number[]) {
return {
parentNodeId: new Uint32Array(parents),
nodeCount: parents.length,
};
}
function makeStyles(nodeCount: number, overrides: Record<string, number[]> = {}) {
const display = new Uint16Array(nodeCount).fill(Display.BLOCK);
const position = new Uint16Array(nodeCount).fill(Position.STATIC);
const zIndexKind = new Uint8Array(nodeCount).fill(ZIndexKind.AUTO);
const zIndexValue = new Int32Array(nodeCount).fill(0);
const overflowX = new Uint16Array(nodeCount).fill(Overflow.VISIBLE);
const overflowY = new Uint16Array(nodeCount).fill(Overflow.VISIBLE);
const opacity = new Float32Array(nodeCount).fill(1.0);
const containFlags = new Uint32Array(nodeCount).fill(0);
// Apply overrides by property
for (const [prop, indices] of Object.entries(overrides)) {
for (const idx of indices as number[]) {
switch (prop) {
case 'flex':
display[idx] = Display.FLEX;
break;
case 'grid':
display[idx] = Display.GRID;
break;
case 'inline':
display[idx] = Display.INLINE;
break;
case 'inlineBlock':
display[idx] = Display.INLINE_BLOCK;
break;
case 'relative':
position[idx] = Position.RELATIVE;
break;
case 'absolute':
position[idx] = Position.ABSOLUTE;
break;
case 'fixed':
position[idx] = Position.FIXED;
break;
case 'sticky':
position[idx] = Position.STICKY;
break;
case 'zIndex':
zIndexKind[idx] = ZIndexKind.INTEGER;
break;
case 'overflowHidden':
overflowX[idx] = Overflow.HIDDEN;
overflowY[idx] = Overflow.HIDDEN;
break;
case 'overflowScroll':
overflowX[idx] = Overflow.SCROLL;
overflowY[idx] = Overflow.SCROLL;
break;
case 'opacity':
opacity[idx] = 0.5;
break;
case 'containPaint':
containFlags[idx] = 0x02;
break;
}
}
}
return { display, position, zIndexKind, zIndexValue, overflowX, overflowY, opacity, containFlags };
}
// ---------------------------------------------------------------------------
// Ancestor resolution tests
// ---------------------------------------------------------------------------
describe('ancestors', () => {
it('isPositioned returns false for static', () => {
assert.equal(isPositioned(Position.STATIC), false);
assert.equal(isPositioned(Position.RELATIVE), true);
assert.equal(isPositioned(Position.ABSOLUTE), true);
});
it('walkUpForPositionedAncestor finds nearest positioned ancestor', () => {
// Tree: 0(root) <- 1 <- 2 <- 3
// Node 1 is relative.
const dom = makeDom([INVALID_ID, 0, 1, 2]);
const styles = makeStyles(4, { relative: [1] });
assert.equal(walkUpForPositionedAncestor(3, dom, styles), 1);
assert.equal(walkUpForPositionedAncestor(2, dom, styles), 1);
assert.equal(walkUpForPositionedAncestor(1, dom, styles), INVALID_ID);
assert.equal(walkUpForPositionedAncestor(0, dom, styles), INVALID_ID);
});
it('resolveContainingBlock for absolute uses nearest positioned ancestor', () => {
// Tree: 0 <- 1(relative) <- 2 <- 3(absolute)
const dom = makeDom([INVALID_ID, 0, 1, 2]);
const styles = makeStyles(4, { relative: [1], absolute: [3] });
assert.equal(resolveContainingBlock(3, dom, styles), 1);
});
it('resolveContainingBlock for fixed returns INVALID_ID (viewport)', () => {
const dom = makeDom([INVALID_ID, 0]);
const styles = makeStyles(2, { fixed: [1] });
assert.equal(resolveContainingBlock(1, dom, styles), INVALID_ID);
});
it('buildAncestorTables produces correct arrays', () => {
// Tree: 0 <- 1(relative) <- 2 <- 3(absolute)
const dom = makeDom([INVALID_ID, 0, 1, 2]);
const styles = makeStyles(4, { relative: [1], absolute: [3] });
const tables = buildAncestorTables(dom, styles);
assert.deepEqual(
Array.from(tables.nearestPositionedAncestorOf),
[INVALID_ID, INVALID_ID, 1, 1],
);
assert.deepEqual(Array.from(tables.containingBlockOf), [INVALID_ID, 0, 1, 1]);
});
it('computeAncestors writes into graph with confidence', () => {
const dom = makeDom([INVALID_ID, 0, 1, 2]);
const styles = makeStyles(4, { relative: [1], absolute: [3] });
const graph = createTopologyGraph(4);
computeAncestors(dom, styles, graph);
assert.equal(graph.nearestPositionedAncestorOf[3], 1);
assert.equal(graph.containingBlockOf[3], 1);
assert.ok(graph.confidence[3] > 0);
});
});
// ---------------------------------------------------------------------------
// Chain building tests
// ---------------------------------------------------------------------------
describe('chains', () => {
it('isScrollContainer recognizes overflow:scroll', () => {
const result = isScrollContainer(Overflow.SCROLL, Overflow.SCROLL, false);
assert.equal(result.kind, ScrollKind.SCROLL);
assert.equal(result.confidence, ConfidenceLevel.DERIVED);
});
it('isScrollContainer returns NONE for visible', () => {
const result = isScrollContainer(Overflow.VISIBLE, Overflow.VISIBLE, false);
assert.equal(result.kind, ScrollKind.NONE);
});
it('introducesClipping returns true for overflow hidden', () => {
const result = introducesClipping(Overflow.HIDDEN, Overflow.HIDDEN, 0);
assert.equal(result.clips, true);
assert.equal(result.kind, ClipKind.OVERFLOW);
});
it('buildScrollChain assigns nearest scroll container', () => {
// Tree: 0 <- 1(scroll) <- 2 <- 3
const dom = makeDom([INVALID_ID, 0, 1, 2]);
const styles = makeStyles(4, { overflowScroll: [1] });
const graph = createTopologyGraph(4);
buildScrollChain(dom, styles, graph, false);
assert.equal(graph.scrollContainerOf[3], 1);
assert.equal(graph.scrollContainerOf[2], 1);
assert.equal(graph.scrollContainerOf[1], INVALID_ID);
assert.equal(graph.scrollContainerOf[0], INVALID_ID);
});
it('buildClipChain assigns nearest clipping ancestor', () => {
// Tree: 0 <- 1(overflow:hidden) <- 2 <- 3
const dom = makeDom([INVALID_ID, 0, 1, 2]);
const styles = makeStyles(4, { overflowHidden: [1] });
const graph = createTopologyGraph(4);
buildClipChain(dom, styles, graph);
assert.equal(graph.clippingRootOf[3], 1);
assert.equal(graph.clippingRootOf[2], 1);
assert.equal(graph.clippingRootOf[1], INVALID_ID);
assert.equal(graph.clippingRootOf[0], INVALID_ID);
});
it('buildClipChain lets fixed elements escape ancestor overflow clips', () => {
// Tree: 0 <- 1(overflow:hidden) <- 2(fixed)
const dom = makeDom([INVALID_ID, 0, 1]);
const styles = makeStyles(3, { overflowHidden: [1], fixed: [2] });
const graph = createTopologyGraph(3);
buildClipChain(dom, styles, graph);
// Fixed element should NOT be clipped by the overflow:hidden ancestor.
assert.equal(graph.clippingRootOf[2], INVALID_ID);
});
it('buildChains runs both scroll and clip builders', () => {
// Tree: 0 <- 1(overflow:hidden + scroll) <- 2 <- 3
const dom = makeDom([INVALID_ID, 0, 1, 2]);
const styles = makeStyles(4, { overflowScroll: [1] });
const graph = createTopologyGraph(4);
buildChains(dom, styles, graph, false);
assert.equal(graph.scrollContainerOf[3], 1);
assert.equal(graph.clippingRootOf[3], 1);
});
});
// ---------------------------------------------------------------------------
// Stacking context tests
// ---------------------------------------------------------------------------
describe('stacking', () => {
it('establishesStackingContext for root is exact', () => {
const styles = makeStyles(1);
const result = establishesStackingContext(0, true, styles);
assert.equal(result.yes, true);
assert.equal(result.confidence, ConfidenceLevel.EXACT);
});
it('establishesStackingContext for z-index integer', () => {
const styles = makeStyles(1, { zIndex: [0] });
const result = establishesStackingContext(0, false, styles);
assert.equal(result.yes, true);
});
it('establishesStackingContext for opacity < 1', () => {
const styles = makeStyles(1, { opacity: [0] });
const result = establishesStackingContext(0, false, styles);
assert.equal(result.yes, true);
});
it('establishesStackingContext for fixed position', () => {
const styles = makeStyles(1, { fixed: [0] });
const result = establishesStackingContext(0, false, styles);
assert.equal(result.yes, true);
});
it('classifyPaintBucket puts positioned auto into POSITIONED_AUTO', () => {
const styles = makeStyles(1, { relative: [0] });
assert.equal(classifyPaintBucket(0, styles), PaintBucket.POSITIONED_AUTO);
});
it('classifyPaintBucket puts inline into INLINE_LEVEL', () => {
const styles = makeStyles(1, { inline: [0] });
assert.equal(classifyPaintBucket(0, styles), PaintBucket.INLINE_LEVEL);
});
it('assignStackingContexts roots every node in a stacking context', () => {
// Tree: 0(root) <- 1 <- 2
const dom = makeDom([INVALID_ID, 0, 1]);
const styles = makeStyles(3);
const graph = createTopologyGraph(3);
assignStackingContexts(dom, styles, graph);
// Root is the stacking context for everyone.
assert.equal(graph.stackingContextOf[0], 0);
assert.equal(graph.stackingContextOf[1], 0);
assert.equal(graph.stackingContextOf[2], 0);
});
it('assignStackingContexts nests when a descendant establishes a new context', () => {
// Tree: 0(root) <- 1(z-index) <- 2
const dom = makeDom([INVALID_ID, 0, 1]);
const styles = makeStyles(3, { zIndex: [1] });
const graph = createTopologyGraph(3);
assignStackingContexts(dom, styles, graph);
assert.equal(graph.stackingContextOf[0], 0);
assert.equal(graph.stackingContextOf[1], 1); // Node 1 is its own context root
assert.equal(graph.stackingContextOf[2], 1); // Node 2 paints inside 1's context
});
});
// ---------------------------------------------------------------------------
// Formatting context tests
// ---------------------------------------------------------------------------
describe('formatting', () => {
it('establishesFormattingContext for root', () => {
const styles = makeStyles(1);
const result = establishesFormattingContext(0, true, styles);
assert.equal(result.yes, true);
});
it('establishesFormattingContext for flex container', () => {
const styles = makeStyles(1, { flex: [0] });
const result = establishesFormattingContext(0, false, styles);
assert.equal(result.yes, true);
});
it('establishesFormattingContext for block with overflow hidden', () => {
const styles = makeStyles(1, { overflowHidden: [0] });
const result = establishesFormattingContext(0, false, styles);
assert.equal(result.yes, true);
});
it('assignFormattingContexts assigns root to all in simple tree', () => {
const dom = makeDom([INVALID_ID, 0, 1]);
const styles = makeStyles(3);
const graph = createTopologyGraph(3);
assignFormattingContexts(dom, styles, graph);
assert.equal(graph.formattingContextOf[0], 0);
assert.equal(graph.formattingContextOf[1], 0);
assert.equal(graph.formattingContextOf[2], 0);
});
it('assignFormattingContexts isolates inline-block subtree', () => {
// Tree: 0 <- 1(inline-block) <- 2
const dom = makeDom([INVALID_ID, 0, 1]);
const styles = makeStyles(3, { inlineBlock: [1] });
const graph = createTopologyGraph(3);
assignFormattingContexts(dom, styles, graph);
assert.equal(graph.formattingContextOf[0], 0);
assert.equal(graph.formattingContextOf[1], 1);
assert.equal(graph.formattingContextOf[2], 1);
});
});
// ---------------------------------------------------------------------------
// Query API tests
// ---------------------------------------------------------------------------
describe('queries', () => {
it('inStackingContext returns true when context matches', () => {
const graph = createTopologyGraph(3);
graph.stackingContextOf[0] = 0;
graph.stackingContextOf[1] = 0;
graph.stackingContextOf[2] = 1;
const r = inStackingContext(graph, 1, 0);
assert.equal(r.value, true);
});
it('inStackingContext returns false when context mismatches', () => {
const graph = createTopologyGraph(3);
graph.stackingContextOf[0] = 0;
graph.stackingContextOf[1] = 0;
const r = inStackingContext(graph, 1, 2);
assert.equal(r.value, false);
});
it('clippedBy returns true when clipped', () => {
const graph = createTopologyGraph(2);
graph.clippingRootOf[1] = 0;
const r = clippedBy(graph, 1);
assert.equal(r.value, true);
});
it('clippedBy returns false when not clipped', () => {
const graph = createTopologyGraph(1);
graph.clippingRootOf[0] = INVALID_ID;
const r = clippedBy(graph, 0);
assert.equal(r.value, false);
});
it('attachedToScrollContainer returns true when attached', () => {
const graph = createTopologyGraph(2);
graph.scrollContainerOf[1] = 0;
const r = attachedToScrollContainer(graph, 1);
assert.equal(r.value, true);
});
it('comparePaintOrder orders by bucket', () => {
const graph = createTopologyGraph(2);
graph.stackingContextOf[0] = 0;
graph.stackingContextOf[1] = 0;
graph.paintOrderBucket[0] = PaintBucket.BLOCK_LEVEL;
graph.paintOrderBucket[1] = PaintBucket.POSITIONED_AUTO;
assert.equal(comparePaintOrder(graph, 0, 1), 'before');
assert.equal(comparePaintOrder(graph, 1, 0), 'after');
});
it('isAbove returns true when subject paints after reference', () => {
const graph = createTopologyGraph(2);
graph.stackingContextOf[0] = 0;
graph.stackingContextOf[1] = 0;
graph.paintOrderBucket[0] = PaintBucket.BLOCK_LEVEL;
graph.paintOrderBucket[1] = PaintBucket.POSITIONED_AUTO;
const r = isAbove(graph, 1, 0);
assert.equal(r.value, true);
});
it('isBelow returns true when subject paints before reference', () => {
const graph = createTopologyGraph(2);
graph.stackingContextOf[0] = 0;
graph.stackingContextOf[1] = 0;
graph.paintOrderBucket[0] = PaintBucket.BLOCK_LEVEL;
graph.paintOrderBucket[1] = PaintBucket.POSITIONED_AUTO;
const r = isBelow(graph, 0, 1);
assert.equal(r.value, true);
});
});
@@ -0,0 +1,16 @@
{
"$schema":"../../node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"packageManager":"npm",
"reporters":["html","clear-text","progress"],
"testRunner":"command",
"commandRunner":{
"command":"npm run build && npm test"
},
"coverageAnalysis":"off",
"mutate":["src/**/*.ts","!src/**/*.test.ts"],
"thresholds":{
"high":80,
"low":60,
"break":50
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"paths": {},
"composite": false,
"noEmitOnError": false
},
"include": [
"src/**/*"
]
}
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": false,
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}