// IR Equivalence Property Tests // Verifies that fluent API and string DSL lower to the same canonical IR. // Uses fast-check for property-based generation with fixed seeds for determinism. import { describe, it } from 'node:test' import assert from 'node:assert' import fc from 'fast-check' import { expect, frame, state } from './fluent.js' import { parseSpec } from './parser.js' import { compile } from './compiler.js' import type { RelationAssertion } from 'imhotep-core' import { lowerToCanonical, areEquivalent, areSemanticallyEquivalent, reconstructFromCanonical, type CanonicalClauseDescriptor, } from './lower-to-canonical.js' // --------------------------------------------------------------------------- // Generators // --------------------------------------------------------------------------- // Selectors that work in both fluent and string DSL. // String DSL requires quoting for CSS selectors containing dots, // so we generate simple identifiers or quoted strings. const selectorArb = fc .string({ minLength: 1, maxLength: 20 }) .map((s) => s.replace(/['"\n\r]/g, '').trim()) .filter((s) => s.length > 0 && /[a-zA-Z0-9_-]/.test(s)) // Relations supported by both fluent API and string parser const sharedRelationArb = fc.constantFrom( 'leftOf', 'rightOf', 'above', 'below', 'alignedWith', 'centeredWithin', 'inside', 'overlaps', ) const gapArb = fc.integer({ min: 0, max: 1000 }) const toleranceArb = fc.integer({ min: 0, max: 100 }) // Options supported by both paths interface SharedOptions { minGap?: number maxGap?: number tolerance?: number axis?: 'centerX' | 'centerY' inStackingContext?: boolean } // Note: String DSL gap syntax always sets minGap (range sets both). // maxGap-only cannot be expressed in string DSL, so we exclude it. const sharedOptionsArb: fc.Arbitrary = fc.oneof( fc.constant({}), fc.record({ minGap: gapArb }), fc.record({ tolerance: toleranceArb }), fc.record({ axis: fc.constantFrom('centerX', 'centerY') }), fc.record({ inStackingContext: fc.boolean() }), // Combined options fc.record({ minGap: gapArb, tolerance: toleranceArb }), fc.record({ minGap: gapArb, maxGap: gapArb }), fc.record({ minGap: gapArb, maxGap: gapArb, tolerance: toleranceArb }), ) // Build a fluent AST from generated parameters function buildFluentAst( subject: string, relation: string, reference: string, opts: SharedOptions, ): RelationAssertion { const chain = (expect(subject).to.be as any)[relation](reference, { minGap: opts.minGap, maxGap: opts.maxGap, tolerance: opts.tolerance, axis: opts.axis, inStackingContext: opts.inStackingContext, }) return chain.toAst() as RelationAssertion } // Build string DSL source from generated parameters function buildStringDsl( subject: string, relation: string, reference: string, opts: SharedOptions, ): string { let source = `'${subject}' ${relation} '${reference}'` if (opts.minGap !== undefined && opts.maxGap !== undefined) { source += ` gap ${opts.minGap}px..${opts.maxGap}px` } else if (opts.minGap !== undefined) { source += ` gap ${opts.minGap}px` } else if (opts.maxGap !== undefined) { // String DSL gap without min is unusual; represent as range with 0 min source += ` gap 0px..${opts.maxGap}px` } if (opts.tolerance !== undefined) { source += ` tolerance ${opts.tolerance}px` } if (opts.axis !== undefined) { source += ` axis ${opts.axis}` } if (opts.inStackingContext === true) { source += ' inStackingContext true' } else if (opts.inStackingContext === false) { source += ' inStackingContext false' } return source } // Normalize options so minGap <= maxGap. // Also filters out maxGap-only since string DSL cannot express it. function normalizeOpts(opts: SharedOptions): SharedOptions { const normalized = { ...opts } // String DSL gap syntax always sets minGap; maxGap-only is unrepresentable. if (normalized.maxGap !== undefined && normalized.minGap === undefined) { delete normalized.maxGap } if ( normalized.minGap !== undefined && normalized.maxGap !== undefined && normalized.minGap > normalized.maxGap ) { const tmp = normalized.minGap normalized.minGap = normalized.maxGap normalized.maxGap = tmp } return normalized } // --------------------------------------------------------------------------- // Property: Simple relation equivalence // --------------------------------------------------------------------------- describe('IR equivalence property tests', () => { it('property: fluent and string produce equivalent canonical IR for simple relations', () => { fc.assert( fc.property( selectorArb, selectorArb, sharedRelationArb, sharedOptionsArb, (subj, ref, rel, opts) => { const normalizedOpts = normalizeOpts(opts) const fluentAst = buildFluentAst(subj, rel, ref, normalizedOpts) const stringResult = parseSpec(buildStringDsl(subj, rel, ref, normalizedOpts)) if (stringResult.diagnostics.length > 0) { // String parse failure means the generator produced invalid syntax. // Filter this case rather than failing the property. return true } const fluentCanonical = lowerToCanonical(fluentAst) const stringCanonical = lowerToCanonical(stringResult.ast) if (!areEquivalent(fluentCanonical, stringCanonical)) { // Return false so fast-check reports the counterexample return false } return true }, ), { numRuns: 200, seed: 42 }, ) }) // --------------------------------------------------------------------------- // Property: Frame equivalence // --------------------------------------------------------------------------- it('property: fluent and string with frames produce equivalent canonical IR', () => { const frameKindArb = fc.constantFrom( 'viewport', 'document', 'nearestPositionedAncestor', 'containingBlock', 'scrollContainer', 'namedGridArea', 'landmark', ) const frameArgArb = fc.string({ minLength: 1, maxLength: 15 }).filter((s) => s.length > 0) fc.assert( fc.property( selectorArb, selectorArb, sharedRelationArb, sharedOptionsArb, frameKindArb, frameArgArb, (subj, ref, rel, opts, frameKind, frameArg) => { const normalizedOpts = normalizeOpts(opts) // Fluent: attach frame via .in() const fluentBuilder = expect(subj).in( frameKind === 'namedGridArea' ? frame.namedGridArea(frameArg) : frameKind === 'landmark' ? frame.landmark(frameArg) : frameKind === 'viewport' ? frame.viewport() : frameKind === 'document' ? frame.document() : (frame as any)[frameKind](frameArg), ) const fluentAst = (fluentBuilder.to.be as any)[rel](ref, normalizedOpts).toAst() // String: wrap in frame block let frameDecl: string if (frameKind === 'viewport' || frameKind === 'document') { frameDecl = `in ${frameKind}:` } else if (frameKind === 'namedGridArea' || frameKind === 'landmark') { frameDecl = `in ${frameKind}('${frameArg}'):` } else { frameDecl = `in ${frameKind}('${frameArg}'):` } const inner = buildStringDsl(subj, rel, ref, normalizedOpts) const source = `${frameDecl}\n ${inner}` const stringResult = parseSpec(source) if (stringResult.diagnostics.length > 0) { return true } const fluentCanonical = lowerToCanonical(fluentAst) const stringCanonical = lowerToCanonical(stringResult.ast) return areEquivalent(fluentCanonical, stringCanonical) }, ), { numRuns: 150, seed: 77 }, ) }) // --------------------------------------------------------------------------- // Property: Quantifier equivalence // --------------------------------------------------------------------------- it('property: fluent and string with quantifiers produce equivalent canonical IR', () => { const quantifierArb = fc.constantFrom('all', 'any', 'none') fc.assert( fc.property( selectorArb, selectorArb, sharedRelationArb, sharedOptionsArb, quantifierArb, (subj, ref, rel, opts, quantifier) => { const normalizedOpts = normalizeOpts(opts) // Fluent: attach quantifier const fluentBuilder = (expect(subj) as any)[quantifier] const fluentAst = (fluentBuilder.to.be as any)[rel](ref, normalizedOpts).toAst() // String: prepend quantifier const source = `${quantifier} ${buildStringDsl(subj, rel, ref, normalizedOpts)}` const stringResult = parseSpec(source) if (stringResult.diagnostics.length > 0) { return true } const fluentCanonical = lowerToCanonical(fluentAst) const stringCanonical = lowerToCanonical(stringResult.ast) return areEquivalent(fluentCanonical, stringCanonical) }, ), { numRuns: 150, seed: 88 }, ) }) // --------------------------------------------------------------------------- // Property: State equivalence // --------------------------------------------------------------------------- it('property: fluent and string with states produce equivalent canonical IR', () => { const stateKindArb = fc.constantFrom('default', 'hover', 'focus', 'focusVisible', 'active') fc.assert( fc.property( selectorArb, selectorArb, sharedRelationArb, sharedOptionsArb, stateKindArb, (subj, ref, rel, opts, stateKind) => { const normalizedOpts = normalizeOpts(opts) // Fluent: attach state const fluentBuilder = expect(subj).inState( stateKind === 'default' ? state.default() : stateKind === 'hover' ? state.hover() : stateKind === 'focus' ? state.focus() : stateKind === 'focusVisible' ? state.focusVisible() : state.active(), ) const fluentAst = (fluentBuilder.to.be as any)[rel](ref, normalizedOpts).toAst() // Note: String DSL does not have inline state syntax for bare assertions. // States in string DSL are typically handled via block structure or // are not directly equivalent. For this property we test fluent vs // fluent-with-state to ensure the lowering captures state correctly. const withoutState = buildFluentAst(subj, rel, ref, normalizedOpts) const withStateCanonical = lowerToCanonical(fluentAst) const withoutStateCanonical = lowerToCanonical(withoutState) // The state should be the only difference return ( withStateCanonical.length === 1 && withoutStateCanonical.length === 1 && withStateCanonical[0].state?.kind === stateKind && withoutStateCanonical[0].state === undefined ) }, ), { numRuns: 100, seed: 99 }, ) }) // --------------------------------------------------------------------------- // Property: AST → IR → AST round-trip preserves semantics // --------------------------------------------------------------------------- it('property: AST → canonical IR → AST preserves semantic meaning', () => { fc.assert( fc.property( selectorArb, selectorArb, sharedRelationArb, sharedOptionsArb, (subj, ref, rel, opts) => { const normalizedOpts = normalizeOpts(opts) const originalAst = buildFluentAst(subj, rel, ref, normalizedOpts) const canonical = lowerToCanonical(originalAst) const recovered = reconstructFromCanonical(canonical) if (recovered.length !== 1) { return false } return areSemanticallyEquivalent(originalAst, recovered[0]) }, ), { numRuns: 200, seed: 123 }, ) }) // --------------------------------------------------------------------------- // Property: Empty selectors edge case // --------------------------------------------------------------------------- it('property: empty selectors lower consistently', () => { fc.assert( fc.property(sharedRelationArb, sharedOptionsArb, (rel, opts) => { const normalizedOpts = normalizeOpts(opts) const fluentAst = buildFluentAst('', rel, '.ref', normalizedOpts) const stringResult = parseSpec(buildStringDsl('', rel, '.ref', normalizedOpts)) const fluentCanonical = lowerToCanonical(fluentAst) if (stringResult.diagnostics.length > 0) { // Empty selector may fail string parse return true } const stringCanonical = lowerToCanonical(stringResult.ast) return areEquivalent(fluentCanonical, stringCanonical) }), { numRuns: 50, seed: 11 }, ) }) // --------------------------------------------------------------------------- // Property: Unicode selectors // --------------------------------------------------------------------------- it('property: unicode selectors lower consistently', () => { const unicodeSelectorArb = fc .string({ minLength: 1, maxLength: 10 }) .map((s) => s.replace(/['"\n\r]/g, '')) .filter((s) => s.length > 0) fc.assert( fc.property( unicodeSelectorArb, unicodeSelectorArb, sharedRelationArb, fc.record({ minGap: gapArb }), (subj, ref, rel, opts) => { const fluentAst = buildFluentAst(subj, rel, ref, opts) const stringResult = parseSpec(buildStringDsl(subj, rel, ref, opts)) if (stringResult.diagnostics.length > 0) { return true } const fluentCanonical = lowerToCanonical(fluentAst) const stringCanonical = lowerToCanonical(stringResult.ast) return areEquivalent(fluentCanonical, stringCanonical) }, ), { numRuns: 100, seed: 222 }, ) }) // --------------------------------------------------------------------------- // Property: Multiple assertions in program equivalence // --------------------------------------------------------------------------- it('property: programs with multiple assertions lower consistently', () => { const assertionArb = fc.tuple(selectorArb, selectorArb, sharedRelationArb, sharedOptionsArb).map( ([subj, ref, rel, opts]) => ({ subj, ref, rel, opts: normalizeOpts(opts), }), ) fc.assert( fc.property( fc.array(assertionArb, { minLength: 1, maxLength: 5 }), (assertions) => { const fluentAsts = assertions.map((a) => buildFluentAst(a.subj, a.rel, a.ref, a.opts)) const stringSource = assertions.map((a) => buildStringDsl(a.subj, a.rel, a.ref, a.opts)).join('\n') const stringResult = parseSpec(stringSource) if (stringResult.diagnostics.length > 0) { return true } const fluentCanonical = fluentAsts.flatMap((ast) => lowerToCanonical(ast)) const stringCanonical = lowerToCanonical(stringResult.ast) return areEquivalent(fluentCanonical, stringCanonical) }, ), { numRuns: 100, seed: 333 }, ) }) // --------------------------------------------------------------------------- // Property: Compiler integration equivalence // --------------------------------------------------------------------------- it('property: both paths compile to equivalent execution IR structure', () => { fc.assert( fc.property( selectorArb, selectorArb, sharedRelationArb, sharedOptionsArb, (subj, ref, rel, opts) => { const normalizedOpts = normalizeOpts(opts) const fluentAst = buildFluentAst(subj, rel, ref, normalizedOpts) const stringResult = parseSpec(buildStringDsl(subj, rel, ref, normalizedOpts)) if (stringResult.diagnostics.length > 0) { return true } // Extract relation assertions from string AST (may be wrapped in blocks) const stringAssertions: RelationAssertion[] = [] for (const child of stringResult.ast.children as any[]) { if (child.type === 'RelationAssertion') { stringAssertions.push(child as RelationAssertion) } else if (child.children) { for (const inner of child.children as any[]) { if (inner.type === 'RelationAssertion') { stringAssertions.push(inner as RelationAssertion) } } } } if (stringAssertions.length === 0) { return true } // Compile both const fluentCompile = compile([fluentAst]) const stringCompile = compile(stringAssertions) // Compare execution IR structure (clause count, relation codes, arg packing) const fluentExec = fluentCompile.executionIr const stringExec = stringCompile.executionIr if (fluentExec.clauseCount !== stringExec.clauseCount) { return false } if (fluentExec.clauseCount === 0) { return true } return ( fluentExec.clauseType[0] === stringExec.clauseType[0] && fluentExec.clauseFlags[0] === stringExec.clauseFlags[0] && fluentExec.clauseArg0[0] === stringExec.clauseArg0[0] && fluentExec.clauseArg1[0] === stringExec.clauseArg1[0] ) }, ), { numRuns: 150, seed: 444 }, ) }) })