diff --git a/packages/imhotep-fixtures/src/e2e-edge.test.ts b/packages/imhotep-fixtures/src/e2e-edge.test.ts
index 21ae42c..d0dffcd 100644
--- a/packages/imhotep-fixtures/src/e2e-edge.test.ts
+++ b/packages/imhotep-fixtures/src/e2e-edge.test.ts
@@ -185,11 +185,50 @@ test.describe('Edge Feature Tests', () => {
expect(result.diagnostics.some(d => (d.code as string) === 'IMH_CARDINALITY_ATMOSTN_FAILED')).toBe(true)
})
- // ───────────────────────────────────────────────
- // Topology: clippedBy
- // ───────────────────────────────────────────────
- test.skip('clippedBy - documented but not yet implemented', async () => {
- // TODO: implement clippedBy topology predicate
+ // Add clippedBy to dense DSL test page with overflow:hidden container
+ async function loadClippingTestPage(page: Page) {
+ await page.setContent(`
+
+
+
+
+
+
+
+
+
+ `)
+ await page.waitForTimeout(50)
+ }
+
+ test('clippedBy - child is clipped by overflow:hidden container', async ({ page }) => {
+ await loadClippingTestPage(page)
+ const ui = await imhotep(page)
+
+ ui.spec(`in viewport:
+ '[data-testid="clipped-child"]' clippedBy '[data-testid="clip-container"]'`)
+
+ const result = await ui.checkAll()
+ expect(result.passed).toBe(true)
+ expect(result.clauseResults.length).toBe(1)
+ expect(result.clauseResults[0].status).toBe('pass')
+ // clipKind: 1=contain:paint, 2=overflow — container has overflow:hidden
+ expect(result.clauseResults[0].metrics?.clipKind).toBe(2)
})
// ───────────────────────────────────────────────
diff --git a/packages/imhotep-solver/src/predicates.test.ts b/packages/imhotep-solver/src/predicates.test.ts
index e07f6f5..a9fc35a 100644
--- a/packages/imhotep-solver/src/predicates.test.ts
+++ b/packages/imhotep-solver/src/predicates.test.ts
@@ -477,4 +477,125 @@ describe('spatial alias predicates', () => {
assert.strictEqual(result.metrics?.max, 540);
});
});
+
+ // --- CSS contain metrics tests ------------------------------------------
+
+ describe('CSS contain metrics', () => {
+ beforeEach(() => {
+ clearPredicateRegistry();
+ registerDefaultPredicates();
+ });
+
+ it('inside reports hasClippedOverflow when reference clips and subject overflows', () => {
+ const evaluator = getPredicateEvaluator('inside')!;
+ // Subject 1 (10,10)-(90,30) is NOT fully inside Reference 2 (0,0)-(50,50)
+ // overflowLeft=0, overflowTop=0, overflowRight=40, overflowBottom=0
+ // Reference 2 clips (overflow:hidden → clipKind=1)
+ const world = makeWorld({
+ boxes: {
+ boxId: [100, 200],
+ subjectId: [1, 2],
+ frameId: [1, 1],
+ borderLeft: [10, 0],
+ borderTop: [10, 0],
+ borderRight: [90, 50],
+ borderBottom: [30, 50],
+ paddingLeft: [0, 0], paddingTop: [0, 0], paddingRight: [0, 0], paddingBottom: [0, 0],
+ contentLeft: [10, 0], contentTop: [10, 0], contentRight: [90, 50], contentBottom: [30, 50],
+ },
+ visualBoxes: {
+ boxId: [100, 200],
+ subjectId: [1, 2],
+ frameId: [1, 1],
+ borderLeft: [10, 0], borderTop: [10, 0], borderRight: [90, 50], borderBottom: [30, 50],
+ paddingLeft: [0, 0], paddingTop: [0, 0], paddingRight: [0, 0], paddingBottom: [0, 0],
+ contentLeft: [10, 0], contentTop: [10, 0], contentRight: [90, 50], contentBottom: [30, 50],
+ },
+ subjects: { ids: [1, 2], domNodeId: [10, 20], subjectKind: [1, 1], primaryBoxId: [100, 200], firstFragmentId: [0, 0], fragmentCount: [0, 0] },
+ dom: { nodeId: [10, 20], parentNodeId: [0, 0], childCount: [0, 0], tagNameStringId: [0, 0] },
+ clipping: {
+ clipNodeId: [0],
+ subjectId: [2], // reference 2 is a clipping container (overflow:hidden)
+ clipKind: [1],
+ clipLeft: [0], clipTop: [0], clipRight: [50], clipBottom: [50],
+ parentClipNodeId: [0],
+ },
+ });
+ const result = evaluator.evaluateTuple(world, [1, 2]);
+ assert.strictEqual(result.truth, 'false');
+ assert.strictEqual(result.metrics?.overflowRight, 40);
+ assert.strictEqual(result.metrics?.hasClippedOverflow, 1);
+ });
+
+ it('inside does not report hasClippedOverflow when reference does not clip', () => {
+ const evaluator = getPredicateEvaluator('inside')!;
+ // Reference 2 does NOT clip (no clipping entry)
+ const world = makeWorld({
+ boxes: {
+ boxId: [100, 200],
+ subjectId: [1, 2],
+ frameId: [1, 1],
+ borderLeft: [10, 0],
+ borderTop: [10, 0],
+ borderRight: [90, 50],
+ borderBottom: [30, 50],
+ paddingLeft: [0, 0], paddingTop: [0, 0], paddingRight: [0, 0], paddingBottom: [0, 0],
+ contentLeft: [10, 0], contentTop: [10, 0], contentRight: [90, 50], contentBottom: [30, 50],
+ },
+ visualBoxes: {
+ boxId: [100, 200],
+ subjectId: [1, 2],
+ frameId: [1, 1],
+ borderLeft: [10, 0], borderTop: [10, 0], borderRight: [90, 50], borderBottom: [30, 50],
+ paddingLeft: [0, 0], paddingTop: [0, 0], paddingRight: [0, 0], paddingBottom: [0, 0],
+ contentLeft: [10, 0], contentTop: [10, 0], contentRight: [90, 50], contentBottom: [30, 50],
+ },
+ subjects: { ids: [1, 2], domNodeId: [10, 20], subjectKind: [1, 1], primaryBoxId: [100, 200], firstFragmentId: [0, 0], fragmentCount: [0, 0] },
+ dom: { nodeId: [10, 20], parentNodeId: [0, 0], childCount: [0, 0], tagNameStringId: [0, 0] },
+ });
+ const result = evaluator.evaluateTuple(world, [1, 2]);
+ assert.strictEqual(result.truth, 'false');
+ assert.strictEqual(result.metrics?.overflowRight, 40);
+ assert.strictEqual(result.metrics?.hasClippedOverflow, undefined);
+ });
+
+ it('clippedBy reports clipKind=1 for contain:paint clip', () => {
+ const evaluator = getPredicateEvaluator('clippedBy')!;
+ // Subject 1 is clipped by Reference 2 (contain:paint → clipKind=5)
+ const world = makeWorld({
+ subjects: { ids: [1, 2], domNodeId: [10, 20], subjectKind: [1, 1], primaryBoxId: [100, 200], firstFragmentId: [0, 0], fragmentCount: [0, 0] },
+ dom: { nodeId: [10, 20], parentNodeId: [0, 0], childCount: [0, 0], tagNameStringId: [0, 0] },
+ topology: { clippingRootOf: [2], stackingContextOf: [], scrollContainerOf: [], containingBlockOf: [], formattingContextOf: [], nearestPositionedAncestorOf: [], paintOrderBucket: [], paintOrderIndex: [] },
+ clipping: {
+ clipNodeId: [0],
+ subjectId: [2],
+ clipKind: [5], // Contain=5
+ clipLeft: [0], clipTop: [0], clipRight: [100], clipBottom: [100],
+ parentClipNodeId: [0],
+ },
+ });
+ const result = evaluator.evaluateTuple(world, [1, 2]);
+ assert.strictEqual(result.truth, 'true');
+ assert.strictEqual(result.metrics?.clipKind, 1); // 1=contain:paint in predicate encoding
+ });
+
+ it('clippedBy reports clipKind=2 for overflow:hidden clip', () => {
+ const evaluator = getPredicateEvaluator('clippedBy')!;
+ const world = makeWorld({
+ subjects: { ids: [1, 2], domNodeId: [10, 20], subjectKind: [1, 1], primaryBoxId: [100, 200], firstFragmentId: [0, 0], fragmentCount: [0, 0] },
+ dom: { nodeId: [10, 20], parentNodeId: [0, 0], childCount: [0, 0], tagNameStringId: [0, 0] },
+ topology: { clippingRootOf: [2], stackingContextOf: [], scrollContainerOf: [], containingBlockOf: [], formattingContextOf: [], nearestPositionedAncestorOf: [], paintOrderBucket: [], paintOrderIndex: [] },
+ clipping: {
+ clipNodeId: [0],
+ subjectId: [2],
+ clipKind: [1], // Overflow=1
+ clipLeft: [0], clipTop: [0], clipRight: [100], clipBottom: [100],
+ parentClipNodeId: [0],
+ },
+ });
+ const result = evaluator.evaluateTuple(world, [1, 2]);
+ assert.strictEqual(result.truth, 'true');
+ assert.strictEqual(result.metrics?.clipKind, 2); // 2=overflow in predicate encoding
+ });
+ });
});