466 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|