v1.1.0: pooled runtime, 959 tests, production hardening (0 squash)
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
// 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 },
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user