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

596 lines
18 KiB
TypeScript

/**
* Unit tests for the Imhotep geometry constraint solver.
*
* Covers:
* - Relation evaluation (leftOf, rightOf, above, below, overlaps, inside)
* - Size evaluation (atLeast, atMost, between, aspectRatio)
* - Quantifier behavior (all, any, none, pairwise)
* - Proof generation
*/
import { describe, it } from 'node:test';
import assert from 'node:assert';
import {
clearRegistry,
type GeometryWorld,
type ClauseDescriptor,
} from './registry.js';
import {
evaluateLeftOf,
evaluateRightOf,
evaluateAbove,
evaluateBelow,
evaluateOverlaps,
evaluateInside,
} from './relations.js';
import {
evaluateAtLeast,
evaluateAtMost,
evaluateBetween,
evaluateAspectRatio,
} from './size.js';
import { evaluateQuantifier, type QuantifierClause } from './quantifiers.js';
import { generateProof, resetProofCounter } from './proofs.js';
import {
evaluate,
registerDefaultClauses,
collectRequiredFacts,
} from './engine.js';
// --- Test helpers ------------------------------------------------------------
function makeWorld(overrides?: Partial<GeometryWorld>): 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: [0, 110],
borderTop: [0, 50],
borderRight: [100, 210],
borderBottom: [40, 90],
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: [],
},
...overrides,
};
}
function makeClause(kind: string, overrides?: Partial<ClauseDescriptor>): ClauseDescriptor {
return {
clauseId: 'clause_1',
clauseKind: kind,
version: 1,
subjectRef: 1,
referenceRef: 2,
...overrides,
};
}
// --- Relation tests ----------------------------------------------------------
describe('relation evaluators', () => {
it('leftOf passes when gap is inside bounds', () => {
const world = makeWorld();
const clause = makeClause('relation.leftOf', { bounds: { minGap: 5, maxGap: 15 } });
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.truth, 'determinate');
assert.strictEqual(result.metrics?.observedGap, 10);
});
it('leftOf fails when gap is too small', () => {
const world = makeWorld();
const clause = makeClause('relation.leftOf', { bounds: { minGap: 15, maxGap: 20 } });
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'fail');
assert.strictEqual(result.metrics?.observedGap, 10);
});
it('rightOf passes when gap is inside bounds', () => {
// Subject 1 to the right of reference 2 with a 5px gap.
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [105, 0],
borderTop: [0, 0],
borderRight: [205, 100],
borderBottom: [40, 40],
},
});
const clause = makeClause('relation.rightOf', { bounds: { minGap: 5, maxGap: 15 } });
const result = evaluateRightOf(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 5);
});
it('above passes when vertical gap is inside bounds', () => {
const world = makeWorld();
const clause = makeClause('relation.above', { bounds: { minGap: 5, maxGap: 15 } });
const result = evaluateAbove(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 10);
});
it('below passes when vertical gap is inside bounds', () => {
// Subject 1 below reference 2 with a 5px gap.
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [0, 0],
borderTop: [45, 0],
borderRight: [100, 100],
borderBottom: [85, 40],
},
});
const clause = makeClause('relation.below', { bounds: { minGap: 5, maxGap: 15 } });
const result = evaluateBelow(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observedGap, 5);
});
it('leftOf without gap bounds fails when subject is to the right', () => {
// Subject 1 at [150,250], reference 2 at [0,100] — subject is to the right
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [150, 0],
borderRight: [250, 100],
},
});
const clause = makeClause('relation.leftOf'); // no bounds
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('rightOf without gap bounds fails when subject is to the left', () => {
const world = makeWorld(); // subject [0,100], reference [110,210]
const clause = makeClause('relation.rightOf'); // no bounds
const result = evaluateRightOf(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('above without gap bounds fails when subject is below', () => {
// Subject 1 at [50,90], reference 2 at [0,40] — subject is below
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderTop: [50, 0],
borderBottom: [90, 40],
},
});
const clause = makeClause('relation.above'); // no bounds
const result = evaluateAbove(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('below without gap bounds fails when subject is above', () => {
const world = makeWorld(); // subject [0,40], reference [50,90]
const clause = makeClause('relation.below'); // no bounds
const result = evaluateBelow(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('overlaps detects intersection', () => {
const world = makeWorld();
// Overlap: subject 1 is [0,0,100,40], reference 2 is [110,50,210,90] — no overlap
let result = evaluateOverlaps(world, makeClause('relation.overlaps'));
assert.strictEqual(result.status, 'fail');
// Move reference to overlap
const overlappingWorld = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [0, 50],
borderTop: [0, 20],
borderRight: [100, 150],
borderBottom: [40, 60],
},
});
result = evaluateOverlaps(overlappingWorld, makeClause('relation.overlaps'));
assert.strictEqual(result.status, 'pass');
});
it('inside detects containment', () => {
const world = makeWorld();
// Subject [0,0,100,40] is inside reference [110,50,210,90]? No.
let result = evaluateInside(world, makeClause('relation.inside'));
assert.strictEqual(result.status, 'fail');
const containedWorld = makeWorld({
boxes: {
...makeWorld().boxes,
borderLeft: [120, 100],
borderTop: [60, 50],
borderRight: [180, 210],
borderBottom: [80, 90],
},
});
result = evaluateInside(containedWorld, makeClause('relation.inside'));
assert.strictEqual(result.status, 'pass');
});
it('relation evaluators return error for missing subject', () => {
const world = makeWorld();
const clause = makeClause('relation.leftOf', { subjectRef: 999 });
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'error');
assert.strictEqual(result.truth, 'indeterminate');
});
});
// --- Size tests --------------------------------------------------------------
describe('size evaluators', () => {
it('atLeast passes when dimension is large enough', () => {
const world = makeWorld();
const clause = makeClause('size.atLeast', {
bounds: { min: 80 },
options: { dimension: 'width' },
});
const result = evaluateAtLeast(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observed, 100);
});
it('atLeast fails when dimension is too small', () => {
const world = makeWorld();
const clause = makeClause('size.atLeast', {
bounds: { min: 120 },
options: { dimension: 'width' },
});
const result = evaluateAtLeast(world, clause);
assert.strictEqual(result.status, 'fail');
});
it('atMost passes when dimension is small enough', () => {
const world = makeWorld();
const clause = makeClause('size.atMost', {
bounds: { max: 120 },
options: { dimension: 'width' },
});
const result = evaluateAtMost(world, clause);
assert.strictEqual(result.status, 'pass');
});
it('between passes when dimension is inside range', () => {
const world = makeWorld();
const clause = makeClause('size.between', {
bounds: { min: 80, max: 120 },
options: { dimension: 'width' },
});
const result = evaluateBetween(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observed, 100);
});
it('aspectRatio passes when ratio is inside range', () => {
const world = makeWorld();
// Subject 1: width 100, height 40 => ratio 2.5
const clause = makeClause('size.aspectRatio', {
bounds: { minRatio: 2, maxRatio: 3 },
});
const result = evaluateAspectRatio(world, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.observed, 2.5);
});
it('aspectRatio returns error when height is zero', () => {
const world = makeWorld({
boxes: {
...makeWorld().boxes,
borderBottom: [40, 90],
borderTop: [40, 90],
},
});
const clause = makeClause('size.aspectRatio');
const result = evaluateAspectRatio(world, clause);
assert.strictEqual(result.status, 'error');
assert.strictEqual(result.truth, 'indeterminate');
});
});
// --- Quantifier tests --------------------------------------------------------
describe('quantifier evaluators', () => {
it('all passes when every sub-result passes', () => {
const subResults = [
{ clauseId: 'c1', status: 'pass' as const, truth: 'determinate' as const },
{ clauseId: 'c2', status: 'pass' as const, truth: 'determinate' as const },
];
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.all',
version: 1,
quantifier: 'all',
subClauseIds: ['c1', 'c2'],
};
const result = evaluateQuantifier(subResults, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.total, 2);
});
it('all fails when any sub-result fails', () => {
const subResults = [
{ clauseId: 'c1', status: 'pass' as const, truth: 'determinate' as const },
{ clauseId: 'c2', status: 'fail' as const, truth: 'determinate' as const },
];
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.all',
version: 1,
quantifier: 'all',
subClauseIds: ['c1', 'c2'],
};
const result = evaluateQuantifier(subResults, clause);
assert.strictEqual(result.status, 'fail');
assert.strictEqual(result.metrics?.failed, 1);
});
it('any passes when at least one sub-result passes', () => {
const subResults = [
{ clauseId: 'c1', status: 'fail' as const, truth: 'determinate' as const },
{ clauseId: 'c2', status: 'pass' as const, truth: 'determinate' as const },
];
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.any',
version: 1,
quantifier: 'any',
subClauseIds: ['c1', 'c2'],
};
const result = evaluateQuantifier(subResults, clause);
assert.strictEqual(result.status, 'pass');
});
it('none passes when no sub-result passes', () => {
const subResults = [
{ clauseId: 'c1', status: 'fail' as const, truth: 'determinate' as const },
{ clauseId: 'c2', status: 'fail' as const, truth: 'determinate' as const },
];
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.none',
version: 1,
quantifier: 'none',
subClauseIds: ['c1', 'c2'],
};
const result = evaluateQuantifier(subResults, clause);
assert.strictEqual(result.status, 'pass');
assert.strictEqual(result.metrics?.passed, 0);
});
it('pairwise requires at least two sub-clauses', () => {
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.pairwise',
version: 1,
quantifier: 'pairwise',
subClauseIds: ['c1'],
};
const result = evaluateQuantifier(
[{ clauseId: 'c1', status: 'pass' as const, truth: 'determinate' as const }],
clause,
);
assert.strictEqual(result.status, 'error');
assert.strictEqual(result.truth, 'indeterminate');
});
it('quantifier returns error for empty sub-results', () => {
const clause: QuantifierClause = {
clauseId: 'q1',
clauseKind: 'quantifier.all',
version: 1,
quantifier: 'all',
subClauseIds: [],
};
const result = evaluateQuantifier([], clause);
assert.strictEqual(result.status, 'error');
assert.strictEqual(result.truth, 'indeterminate');
});
});
// --- Proof generation tests --------------------------------------------------
describe('proof generation', () => {
it('generateProof creates a proof with outcome and witness', () => {
resetProofCounter();
const world = makeWorld();
const clause = makeClause('relation.leftOf');
const result = evaluateLeftOf(world, clause);
const proof = generateProof(result, clause, world, [101, 102]);
assert.strictEqual(proof.proofId, 'proof_1');
assert.strictEqual(proof.clauseId, 'clause_1');
assert.strictEqual(proof.outcome, result.status);
assert.strictEqual(proof.truth, result.truth);
assert.deepStrictEqual(proof.usedFacts, [101, 102]);
assert.strictEqual(proof.witness?.subjectId, 1);
assert.strictEqual(proof.witness?.referenceId, 2);
assert.strictEqual(proof.witness?.snapshotId, 'snap_1');
});
it('generateProof synthesizes failedPredicate on failure', () => {
resetProofCounter();
const world = makeWorld();
const clause = makeClause('relation.leftOf', { bounds: { minGap: 50 } });
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'fail');
const proof = generateProof(result, clause, world);
assert.ok(proof.failedPredicate);
assert.strictEqual(typeof proof.failedPredicate?.left, 'number');
assert.strictEqual(typeof proof.failedPredicate?.right, 'number');
});
it('generateProof omits failedPredicate on pass', () => {
resetProofCounter();
const world = makeWorld();
const clause = makeClause('relation.leftOf');
const result = evaluateLeftOf(world, clause);
assert.strictEqual(result.status, 'pass');
const proof = generateProof(result, clause, world);
assert.strictEqual(proof.failedPredicate, undefined);
});
});
// --- Engine integration tests ------------------------------------------------
describe('solver engine', () => {
it('evaluate runs registered evaluators and produces proofs', () => {
clearRegistry();
registerDefaultClauses();
const world = makeWorld();
const clauses: ClauseDescriptor[] = [
makeClause('relation.leftOf', { clauseId: 'c1', bounds: { minGap: 5 } }),
makeClause('size.atLeast', {
clauseId: 'c2',
subjectRef: 1,
referenceRef: undefined,
bounds: { min: 50 },
options: { dimension: 'width' },
}),
];
const output = evaluate(world, clauses);
assert.strictEqual(output.clauseResults.length, 2);
assert.strictEqual(output.proofs.length, 2);
assert.strictEqual(output.clauseResults[0].status, 'pass');
assert.strictEqual(output.clauseResults[1].status, 'pass');
});
it('collectRequiredFacts gathers facts across clauses', () => {
clearRegistry();
registerDefaultClauses();
const clauses: ClauseDescriptor[] = [
makeClause('relation.leftOf'),
makeClause('size.atLeast', {
subjectRef: 1,
referenceRef: undefined,
}),
];
const facts = collectRequiredFacts(clauses);
assert.ok(facts.includes('subject.primaryBox'));
assert.ok(facts.includes('reference.primaryBox'));
});
it('evaluate emits error for unregistered clause kinds', () => {
clearRegistry();
const world = makeWorld();
const clauses: ClauseDescriptor[] = [
makeClause('relation.unknown', { clauseId: 'c1' }),
];
const output = evaluate(world, clauses);
assert.strictEqual(output.clauseResults[0].status, 'error');
assert.ok(output.diagnostics.some((d) => d.code === 'IMH_EVALUATOR_MISSING'));
});
});