440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
|
|
import { describe, it } from 'node:test';
|
||
|
|
import assert from 'node:assert';
|
||
|
|
import fc from 'fast-check';
|
||
|
|
|
||
|
|
import {
|
||
|
|
type GeometryWorld,
|
||
|
|
type ClauseDescriptor,
|
||
|
|
} from './registry.js';
|
||
|
|
|
||
|
|
import {
|
||
|
|
evaluateLeftOf,
|
||
|
|
evaluateRightOf,
|
||
|
|
evaluateAbove,
|
||
|
|
evaluateBelow,
|
||
|
|
evaluateOverlaps,
|
||
|
|
evaluateInside,
|
||
|
|
} from './relations.js';
|
||
|
|
|
||
|
|
import {
|
||
|
|
evaluateQuantifier,
|
||
|
|
type QuantifierClause,
|
||
|
|
} from './quantifiers.js';
|
||
|
|
|
||
|
|
import {
|
||
|
|
generateProof,
|
||
|
|
resetProofCounter,
|
||
|
|
} from './proofs.js';
|
||
|
|
|
||
|
|
// ---- Test Helpers ----
|
||
|
|
|
||
|
|
function makeWorldFromRects(
|
||
|
|
sLeft: number, sTop: number, sRight: number, sBottom: number,
|
||
|
|
rLeft: number, rTop: number, rRight: number, rBottom: number,
|
||
|
|
): GeometryWorld {
|
||
|
|
return {
|
||
|
|
sceneId: 'scene_1',
|
||
|
|
snapshotId: 'snap_1',
|
||
|
|
env: {
|
||
|
|
viewportWidth: 1280,
|
||
|
|
viewportHeight: 800,
|
||
|
|
deviceScaleFactor: 1,
|
||
|
|
colorScheme: 'light',
|
||
|
|
pointer: 'fine',
|
||
|
|
hover: false,
|
||
|
|
reducedMotion: false,
|
||
|
|
locale: 'en',
|
||
|
|
writingMode: 'horizontal-tb',
|
||
|
|
},
|
||
|
|
strings: { values: [] },
|
||
|
|
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],
|
||
|
|
},
|
||
|
|
boxes: {
|
||
|
|
boxId: [100, 200],
|
||
|
|
subjectId: [1, 2],
|
||
|
|
frameId: [1, 1],
|
||
|
|
borderLeft: [sLeft, rLeft],
|
||
|
|
borderTop: [sTop, rTop],
|
||
|
|
borderRight: [sRight, rRight],
|
||
|
|
borderBottom: [sBottom, rBottom],
|
||
|
|
paddingLeft: [0, 0],
|
||
|
|
paddingTop: [0, 0],
|
||
|
|
paddingRight: [0, 0],
|
||
|
|
paddingBottom: [0, 0],
|
||
|
|
contentLeft: [0, 0],
|
||
|
|
contentTop: [0, 0],
|
||
|
|
contentRight: [0, 0],
|
||
|
|
contentBottom: [0, 0],
|
||
|
|
},
|
||
|
|
visualBoxes: {
|
||
|
|
boxId: [], subjectId: [], frameId: [],
|
||
|
|
borderLeft: [], borderTop: [], borderRight: [], borderBottom: [],
|
||
|
|
paddingLeft: [], paddingTop: [], paddingRight: [], paddingBottom: [],
|
||
|
|
contentLeft: [], contentTop: [], contentRight: [], contentBottom: [],
|
||
|
|
},
|
||
|
|
transforms: {
|
||
|
|
transformId: [], subjectId: [], matrixStart: [], matrixLength: [],
|
||
|
|
originX: [], originY: [],
|
||
|
|
},
|
||
|
|
matrices: { values: [] },
|
||
|
|
rects: {
|
||
|
|
rectId: [],
|
||
|
|
left: [],
|
||
|
|
top: [],
|
||
|
|
right: [],
|
||
|
|
bottom: [],
|
||
|
|
},
|
||
|
|
topology: {
|
||
|
|
containingBlockOf: [0, 0],
|
||
|
|
nearestPositionedAncestorOf: [0, 0],
|
||
|
|
scrollContainerOf: [0, 0],
|
||
|
|
stackingContextOf: [0, 0],
|
||
|
|
formattingContextOf: [0, 0],
|
||
|
|
clippingRootOf: [0, 0],
|
||
|
|
paintOrderBucket: [0, 0],
|
||
|
|
paintOrderIndex: [0, 0],
|
||
|
|
},
|
||
|
|
scroll: {
|
||
|
|
containerId: [],
|
||
|
|
scrollLeft: [],
|
||
|
|
scrollTop: [],
|
||
|
|
scrollWidth: [],
|
||
|
|
scrollHeight: [],
|
||
|
|
clientWidth: [],
|
||
|
|
clientHeight: [],
|
||
|
|
},
|
||
|
|
clipping: {
|
||
|
|
clipNodeId: [],
|
||
|
|
subjectId: [],
|
||
|
|
clipKind: [],
|
||
|
|
clipLeft: [],
|
||
|
|
clipTop: [],
|
||
|
|
clipRight: [],
|
||
|
|
clipBottom: [],
|
||
|
|
parentClipNodeId: [],
|
||
|
|
},
|
||
|
|
visibility: {
|
||
|
|
subjectId: [],
|
||
|
|
isRendered: [],
|
||
|
|
isVisible: [],
|
||
|
|
visibleArea: [],
|
||
|
|
clippedArea: [],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function makeClause(kind: string, overrides?: Partial<ClauseDescriptor>): ClauseDescriptor {
|
||
|
|
return {
|
||
|
|
clauseId: 'clause_1',
|
||
|
|
clauseKind: kind,
|
||
|
|
version: 1,
|
||
|
|
subjectRef: 1,
|
||
|
|
referenceRef: 2,
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Arbitraries ----
|
||
|
|
|
||
|
|
const coordArb = fc.integer({ min: 0, max: 500 });
|
||
|
|
|
||
|
|
const rectArb = fc
|
||
|
|
.tuple(coordArb, coordArb, coordArb, coordArb)
|
||
|
|
.filter(([l, t, r, b]) => l < r && t < b)
|
||
|
|
.map(([l, t, r, b]) => ({ left: l, top: t, right: r, bottom: b }));
|
||
|
|
|
||
|
|
const gapArb = fc.integer({ min: 0, max: 200 });
|
||
|
|
|
||
|
|
// ---- Property Tests ----
|
||
|
|
|
||
|
|
describe('solver property tests', () => {
|
||
|
|
it('property: leftOf and rightOf are inverse relations with same gap', () => {
|
||
|
|
fc.assert(
|
||
|
|
fc.property(
|
||
|
|
rectArb,
|
||
|
|
rectArb,
|
||
|
|
gapArb,
|
||
|
|
(subjectRect, referenceRect, gap) => {
|
||
|
|
// Place subject left of reference with exact gap
|
||
|
|
const sLeft = 0;
|
||
|
|
const sTop = 0;
|
||
|
|
const sRight = sLeft + (subjectRect.right - subjectRect.left);
|
||
|
|
const sBottom = sTop + (subjectRect.bottom - subjectRect.top);
|
||
|
|
|
||
|
|
const rLeft = sRight + gap;
|
||
|
|
const rTop = 0;
|
||
|
|
const rRight = rLeft + (referenceRect.right - referenceRect.left);
|
||
|
|
const rBottom = rTop + (referenceRect.bottom - referenceRect.top);
|
||
|
|
|
||
|
|
// World where subject is left of reference
|
||
|
|
const worldLR = makeWorldFromRects(sLeft, sTop, sRight, sBottom, rLeft, rTop, rRight, rBottom);
|
||
|
|
// World where subject is right of reference (swapped)
|
||
|
|
const worldRL = makeWorldFromRects(rLeft, rTop, rRight, rBottom, sLeft, sTop, sRight, sBottom);
|
||
|
|
|
||
|
|
const leftOfClause = makeClause('relation.leftOf', {
|
||
|
|
bounds: { minGap: gap, maxGap: gap },
|
||
|
|
});
|
||
|
|
const rightOfClause = makeClause('relation.rightOf', {
|
||
|
|
bounds: { minGap: gap, maxGap: gap },
|
||
|
|
});
|
||
|
|
|
||
|
|
const leftResult = evaluateLeftOf(worldLR, leftOfClause);
|
||
|
|
const rightResult = evaluateRightOf(worldRL, rightOfClause);
|
||
|
|
|
||
|
|
assert.strictEqual(leftResult.status, 'pass');
|
||
|
|
assert.strictEqual(rightResult.status, 'pass');
|
||
|
|
assert.strictEqual(leftResult.metrics?.observedGap, gap);
|
||
|
|
assert.strictEqual(rightResult.metrics?.observedGap, gap);
|
||
|
|
}
|
||
|
|
),
|
||
|
|
{ numRuns: 200, seed: 42 }
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('property: above and below are inverse relations', () => {
|
||
|
|
fc.assert(
|
||
|
|
fc.property(
|
||
|
|
rectArb,
|
||
|
|
rectArb,
|
||
|
|
gapArb,
|
||
|
|
(subjectRect, referenceRect, gap) => {
|
||
|
|
const sLeft = 0;
|
||
|
|
const sTop = 0;
|
||
|
|
const sRight = sLeft + (subjectRect.right - subjectRect.left);
|
||
|
|
const sBottom = sTop + (subjectRect.bottom - subjectRect.top);
|
||
|
|
|
||
|
|
const rLeft = 0;
|
||
|
|
const rTop = sBottom + gap;
|
||
|
|
const rRight = rLeft + (referenceRect.right - referenceRect.left);
|
||
|
|
const rBottom = rTop + (referenceRect.bottom - referenceRect.top);
|
||
|
|
|
||
|
|
// World where subject is above reference
|
||
|
|
const worldAB = makeWorldFromRects(sLeft, sTop, sRight, sBottom, rLeft, rTop, rRight, rBottom);
|
||
|
|
// World where subject is below reference (swapped)
|
||
|
|
const worldBA = makeWorldFromRects(rLeft, rTop, rRight, rBottom, sLeft, sTop, sRight, sBottom);
|
||
|
|
|
||
|
|
const aboveClause = makeClause('relation.above', {
|
||
|
|
bounds: { minGap: gap, maxGap: gap },
|
||
|
|
});
|
||
|
|
const belowClause = makeClause('relation.below', {
|
||
|
|
bounds: { minGap: gap, maxGap: gap },
|
||
|
|
});
|
||
|
|
|
||
|
|
const aboveResult = evaluateAbove(worldAB, aboveClause);
|
||
|
|
const belowResult = evaluateBelow(worldBA, belowClause);
|
||
|
|
|
||
|
|
assert.strictEqual(aboveResult.status, 'pass');
|
||
|
|
assert.strictEqual(belowResult.status, 'pass');
|
||
|
|
assert.strictEqual(aboveResult.metrics?.observedGap, gap);
|
||
|
|
assert.strictEqual(belowResult.metrics?.observedGap, gap);
|
||
|
|
}
|
||
|
|
),
|
||
|
|
{ numRuns: 200, seed: 123 }
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('property: if A is inside B, then B contains A', () => {
|
||
|
|
fc.assert(
|
||
|
|
fc.property(
|
||
|
|
rectArb,
|
||
|
|
fc.integer({ min: 0, max: 50 }),
|
||
|
|
(innerRect, padding) => {
|
||
|
|
const outerRect = {
|
||
|
|
left: innerRect.left - padding,
|
||
|
|
top: innerRect.top - padding,
|
||
|
|
right: innerRect.right + padding,
|
||
|
|
bottom: innerRect.bottom + padding,
|
||
|
|
};
|
||
|
|
|
||
|
|
if (outerRect.left >= outerRect.right || outerRect.top >= outerRect.bottom) {
|
||
|
|
return; // Skip invalid outer rect
|
||
|
|
}
|
||
|
|
|
||
|
|
// A inside B
|
||
|
|
const world1 = makeWorldFromRects(
|
||
|
|
innerRect.left, innerRect.top, innerRect.right, innerRect.bottom,
|
||
|
|
outerRect.left, outerRect.top, outerRect.right, outerRect.bottom
|
||
|
|
);
|
||
|
|
const insideClause = makeClause('relation.inside');
|
||
|
|
const insideResult = evaluateInside(world1, insideClause);
|
||
|
|
assert.strictEqual(insideResult.status, 'pass', 'A should be inside B');
|
||
|
|
|
||
|
|
// B contains A (swap subject/reference)
|
||
|
|
const world2 = makeWorldFromRects(
|
||
|
|
outerRect.left, outerRect.top, outerRect.right, outerRect.bottom,
|
||
|
|
innerRect.left, innerRect.top, innerRect.right, innerRect.bottom
|
||
|
|
);
|
||
|
|
// Use leftOf with a huge gap as a proxy for contains, or just verify the geometry relation
|
||
|
|
// Since there is no direct "contains" evaluator in relations.ts, we verify via inside logic:
|
||
|
|
// If A is inside B, then B's rect fully contains A's rect.
|
||
|
|
assert.ok(
|
||
|
|
innerRect.left >= outerRect.left &&
|
||
|
|
innerRect.top >= outerRect.top &&
|
||
|
|
innerRect.right <= outerRect.right &&
|
||
|
|
innerRect.bottom <= outerRect.bottom,
|
||
|
|
'Containment geometry should hold'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
),
|
||
|
|
{ numRuns: 200, seed: 77 }
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('property: if A overlaps B, then B overlaps A', () => {
|
||
|
|
fc.assert(
|
||
|
|
fc.property(
|
||
|
|
rectArb,
|
||
|
|
rectArb,
|
||
|
|
(rectA, rectB) => {
|
||
|
|
const world = makeWorldFromRects(
|
||
|
|
rectA.left, rectA.top, rectA.right, rectA.bottom,
|
||
|
|
rectB.left, rectB.top, rectB.right, rectB.bottom
|
||
|
|
);
|
||
|
|
|
||
|
|
const overlapsClause = makeClause('relation.overlaps');
|
||
|
|
const resultAB = evaluateOverlaps(world, overlapsClause);
|
||
|
|
|
||
|
|
// Swap subject and reference by creating a new world with swapped boxes
|
||
|
|
const worldBA = makeWorldFromRects(
|
||
|
|
rectB.left, rectB.top, rectB.right, rectB.bottom,
|
||
|
|
rectA.left, rectA.top, rectA.right, rectA.bottom
|
||
|
|
);
|
||
|
|
const resultBA = evaluateOverlaps(worldBA, overlapsClause);
|
||
|
|
|
||
|
|
assert.strictEqual(resultAB.status, resultBA.status, 'Overlap should be symmetric');
|
||
|
|
}
|
||
|
|
),
|
||
|
|
{ numRuns: 300, seed: 88 }
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('property: all quantifier passes iff every sub-clause passes', () => {
|
||
|
|
fc.assert(
|
||
|
|
fc.property(
|
||
|
|
fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }),
|
||
|
|
(passFlags) => {
|
||
|
|
const subResults = passFlags.map((pass, i) => ({
|
||
|
|
clauseId: `c${i}`,
|
||
|
|
status: pass ? 'pass' as const : 'fail' as const,
|
||
|
|
truth: 'determinate' as const,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const clause: QuantifierClause = {
|
||
|
|
clauseId: 'q1',
|
||
|
|
clauseKind: 'quantifier.all',
|
||
|
|
version: 1,
|
||
|
|
quantifier: 'all',
|
||
|
|
subClauseIds: passFlags.map((_, i) => `c${i}`),
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateQuantifier(subResults, clause);
|
||
|
|
const expected = passFlags.every(Boolean) ? 'pass' : 'fail';
|
||
|
|
assert.strictEqual(result.status, expected);
|
||
|
|
}
|
||
|
|
),
|
||
|
|
{ numRuns: 200, seed: 55 }
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('property: any quantifier passes iff at least one sub-clause passes', () => {
|
||
|
|
fc.assert(
|
||
|
|
fc.property(
|
||
|
|
fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }),
|
||
|
|
(passFlags) => {
|
||
|
|
const subResults = passFlags.map((pass, i) => ({
|
||
|
|
clauseId: `c${i}`,
|
||
|
|
status: pass ? 'pass' as const : 'fail' as const,
|
||
|
|
truth: 'determinate' as const,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const clause: QuantifierClause = {
|
||
|
|
clauseId: 'q1',
|
||
|
|
clauseKind: 'quantifier.any',
|
||
|
|
version: 1,
|
||
|
|
quantifier: 'any',
|
||
|
|
subClauseIds: passFlags.map((_, i) => `c${i}`),
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateQuantifier(subResults, clause);
|
||
|
|
const expected = passFlags.some(Boolean) ? 'pass' : 'fail';
|
||
|
|
assert.strictEqual(result.status, expected);
|
||
|
|
}
|
||
|
|
),
|
||
|
|
{ numRuns: 200, seed: 66 }
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('property: none quantifier passes iff no sub-clause passes', () => {
|
||
|
|
fc.assert(
|
||
|
|
fc.property(
|
||
|
|
fc.array(fc.boolean(), { minLength: 1, maxLength: 20 }),
|
||
|
|
(passFlags) => {
|
||
|
|
const subResults = passFlags.map((pass, i) => ({
|
||
|
|
clauseId: `c${i}`,
|
||
|
|
status: pass ? 'pass' as const : 'fail' as const,
|
||
|
|
truth: 'determinate' as const,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const clause: QuantifierClause = {
|
||
|
|
clauseId: 'q1',
|
||
|
|
clauseKind: 'quantifier.none',
|
||
|
|
version: 1,
|
||
|
|
quantifier: 'none',
|
||
|
|
subClauseIds: passFlags.map((_, i) => `c${i}`),
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = evaluateQuantifier(subResults, clause);
|
||
|
|
const expected = !passFlags.some(Boolean) ? 'pass' : 'fail';
|
||
|
|
assert.strictEqual(result.status, expected);
|
||
|
|
}
|
||
|
|
),
|
||
|
|
{ numRuns: 200, seed: 33 }
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('property: proof generation preserves failure/success', () => {
|
||
|
|
fc.assert(
|
||
|
|
fc.property(
|
||
|
|
fc.tuple(coordArb, coordArb, coordArb, coordArb),
|
||
|
|
fc.tuple(coordArb, coordArb, coordArb, coordArb),
|
||
|
|
gapArb,
|
||
|
|
([sL, sT, sR, sB], [rL, rT, rR, rB], minGap) => {
|
||
|
|
if (sL >= sR || sT >= sB || rL >= rR || rT >= rB) return;
|
||
|
|
|
||
|
|
const world = makeWorldFromRects(sL, sT, sR, sB, rL, rT, rR, rB);
|
||
|
|
const clause = makeClause('relation.leftOf', {
|
||
|
|
bounds: { minGap },
|
||
|
|
});
|
||
|
|
|
||
|
|
resetProofCounter();
|
||
|
|
const evalResult = evaluateLeftOf(world, clause);
|
||
|
|
const proof = generateProof(evalResult, clause, world);
|
||
|
|
|
||
|
|
assert.strictEqual(proof.outcome, evalResult.status);
|
||
|
|
assert.strictEqual(proof.truth, evalResult.truth);
|
||
|
|
assert.strictEqual(proof.clauseId, clause.clauseId);
|
||
|
|
|
||
|
|
if (evalResult.status === 'fail') {
|
||
|
|
assert.ok(proof.failedPredicate, 'Failed proof should have failedPredicate');
|
||
|
|
} else {
|
||
|
|
assert.strictEqual(proof.failedPredicate, undefined);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
),
|
||
|
|
{ numRuns: 300, seed: 99 }
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|