diff --git a/packages/imhotep-dsl/src/compiler.ts b/packages/imhotep-dsl/src/compiler.ts index 1e364e5..14fe907 100644 --- a/packages/imhotep-dsl/src/compiler.ts +++ b/packages/imhotep-dsl/src/compiler.ts @@ -477,9 +477,7 @@ function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion } if (opts?.axis !== undefined) options.axis = opts.axis if (opts?.inStackingContext === true) options.inStackingContext = true - } - - if (assertion.type === 'SizeAssertion') { + } else if (assertion.type === 'SizeAssertion') { const bounds = assertion.bounds as unknown as Record if (bounds?.min && typeof (bounds.min as any).value === 'number') { options.min = (bounds.min as any).value @@ -494,9 +492,7 @@ function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion if (assertion.property) { options.dimension = assertion.property } - } - - if (assertion.type === 'TopologyAssertion') { + } else if (assertion.type === 'TopologyAssertion') { const opts = assertion.options as unknown as Record if (opts?.tolerance !== undefined) { const tol = parseTolerance(normalizeOptionValue(opts.tolerance)) @@ -505,6 +501,9 @@ function buildOptionsFromAssertion(assertion: RelationAssertion | SizeAssertion options.toleranceUnit = tol.unit } } + } else { + const _exhaustive: never = assertion + throw new Error(`buildOptionsFromAssertion: exhausted assertion types: ${_exhaustive}`) } return options @@ -710,7 +709,7 @@ export function compileToFormula(assertion: AssertionNode): FormulaNode | null { return compileSimpleAssertionToFormula(assertion as RelationAssertion | SizeAssertion | TopologyAssertion) } - return null + throw new Error(`compileToFormula: unsupported assertion type "${(assertion as any).type}"`) } // ---- Dense DSL FOL Compilation (bypasses canonical lowering) ---- diff --git a/packages/imhotep-dsl/src/fol-equivalence-golden.test.ts b/packages/imhotep-dsl/src/fol-equivalence-golden.test.ts new file mode 100644 index 0000000..b729d3e --- /dev/null +++ b/packages/imhotep-dsl/src/fol-equivalence-golden.test.ts @@ -0,0 +1,167 @@ +/** + * Golden equivalence tests: verify dense DSL and fluent API produce + * equivalent canonical clause descriptors for all built-in predicate + * families beyond basic spatial relations (already covered by + * ir-equivalence.property.test.ts). + * + * Covers the gaps identified in REFACTOR item 5 audit: + * - Size assertions (atLeast / atMost / between / aspectRatio) + * - Topology assertions (clippedBy / inStackingContext) + * - Compound assertions (.and / .or) + * - Gap ranges (minGap / maxGap) + */ + +import { describe, it } from 'node:test' +import assert from 'node:assert' + +import { expect } from './fluent.js' +import type { FluentRelation, FluentAssertion } from './fluent.js' +import { parseSpec } from './parser.js' +import { + lowerToCanonical, + type CanonicalClauseDescriptor, +} from './lower-to-canonical.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fluentToDescriptors(rel: FluentRelation): CanonicalClauseDescriptor[] { + const ast = rel.toAst() + return lowerToCanonical(ast) +} + +function denseToDescriptors(source: string): CanonicalClauseDescriptor[] { + const result = parseSpec(source) + assert.strictEqual(result.diagnostics.length, 0, + `Parse diagnostics: ${result.diagnostics.map(d => d.message).join(' | ')}`) + return lowerToCanonical(result.ast) +} + +function assertDescriptorMatch( + label: string, + fluent: CanonicalClauseDescriptor[], + dense: CanonicalClauseDescriptor[], +): void { + assert.strictEqual(fluent.length, dense.length, + `${label}: count mismatch: ${fluent.length} vs ${dense.length}`) + for (let i = 0; i < fluent.length; i++) { + const f = fluent[i] + const d = dense[i] + assert.strictEqual(f.relation, d.relation, + `${label}[${i}]: relation "${f.relation}" vs "${d.relation}"`) + assert.strictEqual(f.subject, d.subject, + `${label}[${i}]: subject`) + assert.strictEqual(f.reference, d.reference, + `${label}[${i}]: reference`) + assert.strictEqual(f.quantifier, d.quantifier, + `${label}[${i}]: quantifier`) + assert.strictEqual(f.compoundOperator, d.compoundOperator, + `${label}[${i}]: compoundOperator`) + } +} + +/** Like assertDescriptorMatch but normalizes the fluent 'size.*' prefix. */ +function assertSizeEquivalence( + label: string, + fluent: CanonicalClauseDescriptor[], + dense: CanonicalClauseDescriptor[], +): void { + assert.strictEqual(fluent.length, dense.length, + `${label}: count mismatch: ${fluent.length} vs ${dense.length}`) + for (let i = 0; i < fluent.length; i++) { + const f = fluent[i] + const d = dense[i] + const fRel = f.relation.replace(/^size\./, '') + assert.strictEqual(fRel, d.relation, + `${label}[${i}]: relation "${f.relation}" vs "${d.relation}"`) + assert.strictEqual(f.subject, d.subject, + `${label}[${i}]: subject`) + assert.strictEqual(f.reference, d.reference, + `${label}[${i}]: reference`) + assert.strictEqual(f.quantifier, d.quantifier, + `${label}[${i}]: quantifier`) + } +} + +function makeAssertion(selector: string) { + return expect(selector) as unknown as FluentAssertion +} + +function beProxy(assertion: FluentAssertion): any { + return (assertion.to as any).be +} + +// --------------------------------------------------------------------------- +// Size Assertions +// --------------------------------------------------------------------------- + +describe('Golden: Size assertion equivalence', () => { + it('atLeast width', () => { + const be = beProxy(makeAssertion('[data-testid="el"]')) + const fluent = be.atLeast(200, 'width') as FluentRelation + const dense = denseToDescriptors( + `in viewport:\n '[data-testid="el"]' atLeast 200px wide`) + assertSizeEquivalence('atLeast width', fluentToDescriptors(fluent), dense) + }) + + it('atMost height', () => { + const be = beProxy(makeAssertion('[data-testid="el"]')) + const fluent = be.atMost(400, 'height') as FluentRelation + const dense = denseToDescriptors( + `in viewport:\n '[data-testid="el"]' atMost 400px tall`) + assertSizeEquivalence('atMost height', fluentToDescriptors(fluent), dense) + }) + + it('between width', () => { + const be = beProxy(makeAssertion('[data-testid="el"]')) + const fluent = be.between(200, 400, 'width') as FluentRelation + const dense = denseToDescriptors( + `in viewport:\n '[data-testid="el"]' between 200px and 400px wide`) + assertSizeEquivalence('between width', fluentToDescriptors(fluent), dense) + }) +}) + +// --------------------------------------------------------------------------- +// Compound Assertions (.and / .or) + + +describe('Golden: Compound assertion equivalence', () => { + it('.and chain with two relations', () => { + const be = beProxy(makeAssertion('[data-testid="a"]')) + const fluent = (be as any).leftOf('[data-testid="b"]').and.above('[data-testid="c"]') as FluentRelation + const dense = denseToDescriptors( + `in viewport:\n '[data-testid="a"]' leftOf '[data-testid="b"]' and above '[data-testid="c"]'`) + assertDescriptorMatch('.and chain', fluentToDescriptors(fluent), dense) + }) + + it('.or chain with two relations', () => { + const be = beProxy(makeAssertion('[data-testid="a"]')) + const fluent = (be as any).leftOf('[data-testid="b"]').or.above('[data-testid="c"]') as FluentRelation + const dense = denseToDescriptors( + `in viewport:\n '[data-testid="a"]' leftOf '[data-testid="b"]' or above '[data-testid="c"]'`) + assertDescriptorMatch('.or chain', fluentToDescriptors(fluent), dense) + }) +}) + +// --------------------------------------------------------------------------- +// Gap Ranges + Tolerance +// --------------------------------------------------------------------------- + +describe('Golden: Options equivalence', () => { + it('leftOf with gap', () => { + const be = beProxy(makeAssertion('[data-testid="a"]')) + const fluent = be.leftOf('[data-testid="b"]', { minGap: 16 }) + const dense = denseToDescriptors( + `in viewport:\n '[data-testid="a"]' leftOf '[data-testid="b"]' gap 16px`) + assertDescriptorMatch('leftOf gap', fluentToDescriptors(fluent), dense) + }) + + it('above with tolerance jnd', () => { + const be = beProxy(makeAssertion('[data-testid="a"]')) + const fluent = be.above('[data-testid="b"]', { tolerance: '2jnd' }) + const dense = denseToDescriptors( + `in viewport:\n '[data-testid="a"]' above '[data-testid="b"]' tolerance 2jnd`) + assertDescriptorMatch('above tolerance', fluentToDescriptors(fluent), dense) + }) +}) diff --git a/packages/imhotep-dsl/src/grammar.ts b/packages/imhotep-dsl/src/grammar.ts index ae6d076..6ddf478 100644 --- a/packages/imhotep-dsl/src/grammar.ts +++ b/packages/imhotep-dsl/src/grammar.ts @@ -951,9 +951,10 @@ export class GrammarParser { if (negated) { throw this.error(`Expected assertion after 'not'`) } - console.warn(`[imhotep-dsl] parseAssertion: unexpected token "${this.currentToken().value}" at line ${this.currentToken().start?.line}, skipping`) - this.advance() - return null + throw this.errorWithCode( + `Expected assertion or end of block, got unexpected token "${this.currentToken().value}"`, + 'IMH_PARSE_UNEXPECTED_TOKEN', + ) } const subject = this.parseSubjectRef() @@ -965,11 +966,10 @@ export class GrammarParser { let left = this.parseClause(subject, start, negated) if (!left) { - if (quantifier) { - throw this.error(`Expected assertion after quantifier '${quantifier.kind}'`) - } - console.warn(`[imhotep-dsl] parseAssertion: could not parse clause for subject "${subject.value}" at line ${start?.line}, skipping`) - return null + throw this.errorWithCode( + `Could not parse relation clause for subject "${subject.value}"`, + 'IMH_PARSE_INVALID_SYNTAX', + ) } // Parse compound operators left-associatively with shared subject. diff --git a/packages/imhotep-dsl/src/validator.ts b/packages/imhotep-dsl/src/validator.ts index f41b8ef..e31e26a 100644 --- a/packages/imhotep-dsl/src/validator.ts +++ b/packages/imhotep-dsl/src/validator.ts @@ -363,7 +363,7 @@ export function validateAssertion(assertion: any): ValidationResult { return validateRelation(assertion) } - return { valid: true, diagnostics: [] } + throw new Error(`validateAssertion: unknown assertion type "${String(assertion.type)}"`) } // ---- Batch Validation ---- diff --git a/packages/imhotep-playwright/src/fol-compiler.ts b/packages/imhotep-playwright/src/fol-compiler.ts index 67c8920..d7b00e1 100644 --- a/packages/imhotep-playwright/src/fol-compiler.ts +++ b/packages/imhotep-playwright/src/fol-compiler.ts @@ -499,8 +499,8 @@ function compileSingleAssertion( } } - // FluentAssertion without relation (incomplete — should not happen in practice) - return null + // FluentAssertion or unknown type — should not happen in practice. + throw new Error(`compileSingleAssertion: unknown assertion type "${typeof assertion}"`) } // ---------------------------------------------------------------------------