Files
Imhotep/packages/imhotep-topology/src/topology.test.ts
T

466 lines
15 KiB
TypeScript

// 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);
});
});