v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user