538 lines
18 KiB
TypeScript
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 },
|
|
)
|
|
})
|
|
})
|