Files
Imhotep/packages/imhotep-dsl/src/ir-equivalence.property.test.ts
T

538 lines
18 KiB
TypeScript

// 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<SharedOptions> = 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 },
)
})
})