596 lines
18 KiB
TypeScript
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'));
|
||
|
|
});
|
||
|
|
});
|