From 8bb94287c58a8601354dc261babb67820880b4ff Mon Sep 17 00:00:00 2001 From: Ludovico Fischer Date: Thu, 1 Oct 2026 00:38:37 +0200 Subject: [PATCH] test: reduce copy-pasted code --- test/helpers/benchmark-artifact.js | 34 +++++ test/helpers/numeric.js | 6 + test/helpers/parse-source.js | 9 ++ test/helpers/testValue.js | 21 +++ test/index-cases.test.cjs | 33 +--- test/index-edge-cases.test.cjs | 20 +-- test/index-options.test.cjs | 17 +-- test/property/algebraic-exponential.test.js | 6 +- test/property/algebraic-mod-rem.test.js | 6 +- test/property/algebraic-round.test.js | 6 +- test/property/algebraic-sign.test.js | 6 +- test/property/algebraic-trigonometry.test.js | 6 +- test/property/naive-exponential.test.js | 20 +-- test/property/naive-stepped.test.js | 59 +++----- test/property/naive-trigonometry.test.js | 20 +-- test/property/properties.test.js | 149 +++++++------------ test/unit/analyze-opaque.test.js | 7 +- test/unit/analyze.test.js | 7 +- test/unit/compare-parser-benchmarks.test.js | 60 +------- test/unit/corpus-benchmark.test.js | 51 +------ test/unit/parser-core.test.js | 28 ++-- test/unit/parser-opaque.test.js | 112 ++++---------- 22 files changed, 210 insertions(+), 473 deletions(-) create mode 100644 test/helpers/numeric.js create mode 100644 test/helpers/parse-source.js diff --git a/test/helpers/benchmark-artifact.js b/test/helpers/benchmark-artifact.js index d02f76f..c546436 100644 --- a/test/helpers/benchmark-artifact.js +++ b/test/helpers/benchmark-artifact.js @@ -1,5 +1,6 @@ /* oxlint-disable no-bitwise */ import { + CORPUS_INTERVAL_METHOD, DECISION_CONFIG_VERSION, DECISION_INTERVAL_METHOD, PRECISION_METHOD, @@ -26,6 +27,39 @@ export const SYNTHETIC_DECISION_CONFIG = { intervalMethod: DECISION_INTERVAL_METHOD, }; +export const CORPUS_DECISION_CONFIG = { + ...SYNTHETIC_DECISION_CONFIG, + warmupMinimum: 0, + warmupMaximum: 0, + measuredBatchCount: 6, + intervalMethod: CORPUS_INTERVAL_METHOD, + replicates: 20, + batches: 6, + calibrationOrderBalanced: true, +}; + +const CORPUS_CATEGORIES = [ + 'accepted', + 'both-failed', + 'known-divergence', + 'malformed-input', + 'parser-rejected', + 'reference-rejected', + 'unexpected-divergence', +]; + +/** Correctness block for a corpus artifact with two accepted inputs. */ +export function corpusCorrectness() { + return { + accepted: 2, + counts: Object.fromEntries( + CORPUS_CATEGORIES.map((c) => [c, c === 'accepted' ? 2 : 0]) + ), + categoryHashes: Object.fromEntries(CORPUS_CATEGORIES.map((c) => [c, c])), + inputHash: 'inputs', + }; +} + /** * Build a parser artifact from already-generated observations. `rows` contains * one complete independent block per item, with baseline and candidate arrays diff --git a/test/helpers/numeric.js b/test/helpers/numeric.js new file mode 100644 index 0000000..3458a3c --- /dev/null +++ b/test/helpers/numeric.js @@ -0,0 +1,6 @@ +// Read a number out of serialized CSS such as `calc(1.5)` or `-2`. +export const scalarText = (text) => + text.startsWith('calc(') && text.endsWith(')') + ? text.slice('calc('.length, -1) + : text; +export const numeric = (text) => Number.parseFloat(scalarText(text)); diff --git a/test/helpers/parse-source.js b/test/helpers/parse-source.js new file mode 100644 index 0000000..403e57f --- /dev/null +++ b/test/helpers/parse-source.js @@ -0,0 +1,9 @@ +// Parse CSS source over its full token range with the production parser. +import { tokenize } from '@csstools/css-tokenizer'; +import { indexBlocks } from '../../src/lib/block-index.js'; +import { parse } from '../../src/lib/parser.js'; + +export const parseSource = (css) => { + const tokens = tokenize({ css }); + return parse(tokens, 0, tokens.length, indexBlocks(tokens)); +}; diff --git a/test/helpers/testValue.js b/test/helpers/testValue.js index daa7fde..9bc1c5e 100644 --- a/test/helpers/testValue.js +++ b/test/helpers/testValue.js @@ -19,3 +19,24 @@ export function testValue(fixture, expected, opts = {}) { assert.strictEqual(result.css, expected); }; } + +export function testCss(fixture, expected, opts = {}) { + return async () => { + const result = await postcss(reduceCalc(opts)).process( + fixture, + postcssOpts + ); + assert.strictEqual(result.css, expected); + }; +} + +export function testCssDoesNotThrow(fixture, expected, opts = {}) { + return async () => { + const result = await postcss(reduceCalc(opts)).process( + fixture, + postcssOpts + ); + assert.strictEqual(result.css, expected); + assert.strictEqual(result.warnings().length, 0); + }; +} diff --git a/test/index-cases.test.cjs b/test/index-cases.test.cjs index bb1ead4..7a361ae 100644 --- a/test/index-cases.test.cjs +++ b/test/index-cases.test.cjs @@ -1,34 +1,11 @@ 'use strict'; const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const postcss = require('postcss'); -const reduceCalc = (opts = {}) => require('../src/index.js')(opts); -const { testValue } = require('./helpers/testValue.js'); - -const postcssOpts = { from: undefined }; - -function testCss(fixture, expected, opts = {}) { - return async () => { - const result = await postcss(reduceCalc(opts)).process( - fixture, - postcssOpts - ); - assert.strictEqual(result.css, expected); - }; -} - -function testCssDoesNotThrow(fixture, expected, opts = {}) { - return async () => { - const result = await postcss(reduceCalc(opts)).process( - fixture, - postcssOpts - ); - assert.strictEqual(result.css, expected); - const warnings = result.warnings(); - assert.strictEqual(warnings.length, 0); - }; -} +const { + testValue, + testCss, + testCssDoesNotThrow, +} = require('./helpers/testValue.js'); describe('CSS custom properties', () => { test( diff --git a/test/index-edge-cases.test.cjs b/test/index-edge-cases.test.cjs index f1cf5a5..782fa1e 100644 --- a/test/index-edge-cases.test.cjs +++ b/test/index-edge-cases.test.cjs @@ -1,24 +1,8 @@ 'use strict'; const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const postcss = require('postcss'); -const reduceCalc = (opts = {}) => require('../src/index.js')(opts); -const { testValue } = require('./helpers/testValue.js'); - -const postcssOpts = { from: undefined }; - -function testCssDoesNotThrow(fixture, expected, opts = {}) { - return async () => { - const result = await postcss(reduceCalc(opts)).process( - fixture, - postcssOpts - ); - assert.strictEqual(result.css, expected); - const warnings = result.warnings(); - assert.strictEqual(warnings.length, 0); - }; -} +const { testValue, testCssDoesNotThrow } = require('./helpers/testValue.js'); + describe('Throw', () => { test( 'should not throw an exception when unknow function exist in calc', diff --git a/test/index-options.test.cjs b/test/index-options.test.cjs index 6f631ef..808ff77 100644 --- a/test/index-options.test.cjs +++ b/test/index-options.test.cjs @@ -1,22 +1,7 @@ 'use strict'; const { describe, test } = require('node:test'); -const assert = require('node:assert/strict'); -const postcss = require('postcss'); -const reduceCalc = (opts = {}) => require('../src/index.js')(opts); -const { testValue } = require('./helpers/testValue.js'); - -const postcssOpts = { from: undefined }; - -function testCss(fixture, expected, opts = {}) { - return async () => { - const result = await postcss(reduceCalc(opts)).process( - fixture, - postcssOpts - ); - assert.strictEqual(result.css, expected); - }; -} +const { testValue, testCss } = require('./helpers/testValue.js'); describe('Discard zero values', () => { test( diff --git a/test/property/algebraic-exponential.test.js b/test/property/algebraic-exponential.test.js index dd46520..e4c6ae0 100644 --- a/test/property/algebraic-exponential.test.js +++ b/test/property/algebraic-exponential.test.js @@ -11,16 +11,12 @@ import { describe, test } from 'node:test'; import fc from 'fast-check'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; +import { numeric } from '../helpers/numeric.js'; import { call, num } from '../../src/lib/node.js'; const NUM_RUNS = 500; const out = (n) => serialize(simplify(n), { precision: 10 }); -const scalarText = (text) => - text.startsWith('calc(') && text.endsWith(')') - ? text.slice('calc('.length, -1) - : text; -const numeric = (text) => Number.parseFloat(scalarText(text)); // --- pow / sqrt / log / exp / hypot laws (§10.5) ---------------------- test('law: pow(x, 1) ≡ x for finite x', () => { diff --git a/test/property/algebraic-mod-rem.test.js b/test/property/algebraic-mod-rem.test.js index 797152e..a25b76c 100644 --- a/test/property/algebraic-mod-rem.test.js +++ b/test/property/algebraic-mod-rem.test.js @@ -11,16 +11,12 @@ import { describe, test } from 'node:test'; import fc from 'fast-check'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; +import { numeric } from '../helpers/numeric.js'; import { call, num, ident } from '../../src/lib/node.js'; const NUM_RUNS = 500; const out = (n) => serialize(simplify(n), { precision: 10 }); -const scalarText = (text) => - text.startsWith('calc(') && text.endsWith(')') - ? text.slice('calc('.length, -1) - : text; -const numeric = (text) => Number.parseFloat(scalarText(text)); // Finite, non-zero numeric leaf — domain for most laws. const finiteNum = fc.integer({ min: -1000, max: 1000 }).map(num); diff --git a/test/property/algebraic-round.test.js b/test/property/algebraic-round.test.js index b96852d..a0b845e 100644 --- a/test/property/algebraic-round.test.js +++ b/test/property/algebraic-round.test.js @@ -11,16 +11,12 @@ import { describe, test } from 'node:test'; import fc from 'fast-check'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; +import { numeric } from '../helpers/numeric.js'; import { call, num, ident } from '../../src/lib/node.js'; const NUM_RUNS = 500; const out = (n) => serialize(simplify(n), { precision: 10 }); -const scalarText = (text) => - text.startsWith('calc(') && text.endsWith(')') - ? text.slice('calc('.length, -1) - : text; -const numeric = (text) => Number.parseFloat(scalarText(text)); // Finite, non-zero numeric leaf — domain for most laws. const finiteNum = fc.integer({ min: -1000, max: 1000 }).map(num); diff --git a/test/property/algebraic-sign.test.js b/test/property/algebraic-sign.test.js index 382489b..8784a9e 100644 --- a/test/property/algebraic-sign.test.js +++ b/test/property/algebraic-sign.test.js @@ -11,16 +11,12 @@ import { describe, test } from 'node:test'; import fc from 'fast-check'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; +import { numeric, scalarText } from '../helpers/numeric.js'; import { call, num, dim } from '../../src/lib/node.js'; const NUM_RUNS = 500; const out = (n) => serialize(simplify(n), { precision: 10 }); -const scalarText = (text) => - text.startsWith('calc(') && text.endsWith(')') - ? text.slice('calc('.length, -1) - : text; -const numeric = (text) => Number.parseFloat(scalarText(text)); // Finite, non-zero numeric leaf — domain for most laws. const finiteNum = fc.integer({ min: -1000, max: 1000 }).map(num); diff --git a/test/property/algebraic-trigonometry.test.js b/test/property/algebraic-trigonometry.test.js index bc4cf7c..a9b7e0f 100644 --- a/test/property/algebraic-trigonometry.test.js +++ b/test/property/algebraic-trigonometry.test.js @@ -11,16 +11,12 @@ import { describe, test } from 'node:test'; import fc from 'fast-check'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; +import { numeric } from '../helpers/numeric.js'; import { call, num } from '../../src/lib/node.js'; const NUM_RUNS = 500; const out = (n) => serialize(simplify(n), { precision: 10 }); -const scalarText = (text) => - text.startsWith('calc(') && text.endsWith(')') - ? text.slice('calc('.length, -1) - : text; -const numeric = (text) => Number.parseFloat(scalarText(text)); // --- trig laws (§10.4) --------------------------------------------------- // diff --git a/test/property/naive-exponential.test.js b/test/property/naive-exponential.test.js index d0be57f..4e30038 100644 --- a/test/property/naive-exponential.test.js +++ b/test/property/naive-exponential.test.js @@ -10,23 +10,9 @@ // random gen to roll them. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; -import { parse } from '../../src/lib/parser.js'; -import { simplify } from '../../src/lib/simplify.js'; -import { serialize } from '../../src/lib/serialize.js'; -const out = (s) => { - const tokens = tokenize({ css: s }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { precision: 10 } - ); -}; -const scalarText = (text) => - text.startsWith('calc(') && text.endsWith(')') - ? text.slice('calc('.length, -1) - : text; -const numeric = (text) => Number.parseFloat(scalarText(text)); +import { out as pipeline } from '../helpers/out.js'; +import { numeric } from '../helpers/numeric.js'; +const out = (s) => pipeline(s, { precision: 10 }); // --- §10.5 oracle rows for pow / sqrt / exp / log / hypot -------------- const POW_INPUTS = [ [2, 3], diff --git a/test/property/naive-stepped.test.js b/test/property/naive-stepped.test.js index d2cffdc..5832801 100644 --- a/test/property/naive-stepped.test.js +++ b/test/property/naive-stepped.test.js @@ -10,23 +10,20 @@ // random gen to roll them. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; -import { parse } from '../../src/lib/parser.js'; -import { simplify } from '../../src/lib/simplify.js'; -import { serialize } from '../../src/lib/serialize.js'; -const out = (s) => { - const tokens = tokenize({ css: s }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { precision: 10 } - ); -}; -const scalarText = (text) => - text.startsWith('calc(') && text.endsWith(')') - ? text.slice('calc('.length, -1) - : text; -const numeric = (text) => Number.parseFloat(scalarText(text)); +import { out as pipeline } from '../helpers/out.js'; +import { numeric } from '../helpers/numeric.js'; +const out = (s) => pipeline(s, { precision: 10 }); +// NaN must match NaN; otherwise allow tiny FP drift between algorithm shapes. +function assertAgrees(expected, got) { + if (Number.isNaN(expected)) { + assert.ok(Number.isNaN(got), `expected NaN, got ${got}`); + } else { + assert.ok( + Math.abs(expected - got) < 1e-9, + `naive=${expected}, prod=${got}` + ); + } +} // --- Naive reference impls ---------------------------------------------- // // These use a *different* algorithmic shape from `simplify.ts`: @@ -134,15 +131,7 @@ for (const row of rows) { const expected = naiveRound(strategy, row.a, row.b); const got = numeric(out(`round(${strategy}, ${row.a}, ${row.b})`)); // NaN === NaN check via Object.is. - if (Number.isNaN(expected)) { - assert.ok(Number.isNaN(got), `expected NaN, got ${got}`); - } else { - // Allow tiny FP drift between algorithm shapes. - assert.ok( - Math.abs(expected - got) < 1e-9, - `naive=${expected}, prod=${got}` - ); - } + assertAgrees(expected, got); }); } } @@ -153,26 +142,12 @@ for (const row of rows) { test(`oracle: mod(${row.a}, ${row.b}) [${row.desc}]`, () => { const expected = naiveMod(row.a, row.b); const got = numeric(out(`mod(${row.a}, ${row.b})`)); - if (Number.isNaN(expected)) { - assert.ok(Number.isNaN(got), `expected NaN, got ${got}`); - } else { - assert.ok( - Math.abs(expected - got) < 1e-9, - `naive=${expected}, prod=${got}` - ); - } + assertAgrees(expected, got); }); test(`oracle: rem(${row.a}, ${row.b}) [${row.desc}]`, () => { const expected = naiveRem(row.a, row.b); const got = numeric(out(`rem(${row.a}, ${row.b})`)); - if (Number.isNaN(expected)) { - assert.ok(Number.isNaN(got), `expected NaN, got ${got}`); - } else { - assert.ok( - Math.abs(expected - got) < 1e-9, - `naive=${expected}, prod=${got}` - ); - } + assertAgrees(expected, got); }); } // --- abs / sign — small but covers signed zero ------------------------- diff --git a/test/property/naive-trigonometry.test.js b/test/property/naive-trigonometry.test.js index 8297c8f..f659bd1 100644 --- a/test/property/naive-trigonometry.test.js +++ b/test/property/naive-trigonometry.test.js @@ -10,23 +10,9 @@ // random gen to roll them. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; -import { parse } from '../../src/lib/parser.js'; -import { simplify } from '../../src/lib/simplify.js'; -import { serialize } from '../../src/lib/serialize.js'; -const out = (s) => { - const tokens = tokenize({ css: s }); - return serialize( - simplify(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - { precision: 10 } - ); -}; -const scalarText = (text) => - text.startsWith('calc(') && text.endsWith(')') - ? text.slice('calc('.length, -1) - : text; -const numeric = (text) => Number.parseFloat(scalarText(text)); +import { out as pipeline } from '../helpers/out.js'; +import { numeric } from '../helpers/numeric.js'; +const out = (s) => pipeline(s, { precision: 10 }); // --- trig oracle (§10.4) ------------------------------------------------- // // Curated angles in radians (since simplifier accepts bare numbers as diff --git a/test/property/properties.test.js b/test/property/properties.test.js index 12cc6fe..a0b677f 100644 --- a/test/property/properties.test.js +++ b/test/property/properties.test.js @@ -10,9 +10,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import fc from 'fast-check'; -import { tokenize } from '@csstools/css-tokenizer'; -import { indexBlocks } from '../../src/lib/block-index.js'; -import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { analyze } from '../../src/lib/analyze.js'; import { serialize } from '../../src/lib/serialize.js'; @@ -21,69 +18,70 @@ import { astArbWithDegenerate, numericAstArb, } from '../helpers/arbitraries.js'; +import { parseSource } from '../helpers/parse-source.js'; import { num, mkProduct, mkSum, negate } from '../../src/lib/node.js'; const NUM_RUNS = 500; function str(n) { return serialize(n, { precision: false }); } -// --- Idempotence --------------------------------------------------------- -// simplify(x) must equal simplify(simplify(x)). Canonical-form + one-pass -// simplify should make this trivially true. -test('property: simplify is idempotent', () => { - fc.assert( - fc.property(astArb(4), (ast) => { - const once = simplify(ast); - const twice = simplify(once); - return str(once) === str(twice); - }), - { numRuns: NUM_RUNS } - ); -}); +// --- Idempotence and round-trip ------------------------------------------ +// simplify(x) must equal simplify(simplify(x)), and serialize(simplify(x)) +// parsed+simplified back must be indistinguishable at the string level. +// Checked for the regular generator and for one with Infinity / NaN / +// FP-imprecise leaves mixed in (§10.13 paths and IEEE-754 propagation). +for (const [label, arb] of [ + ['', astArb(4)], + [' under degenerate / float leaves', astArbWithDegenerate(4)], +]) { + test(`property: simplify is idempotent${label}`, () => { + fc.assert( + fc.property(arb, (ast) => { + const once = simplify(ast); + return str(once) === str(simplify(once)); + }), + { numRuns: NUM_RUNS } + ); + }); + test(`property: simplify → serialize → parse → simplify is a fixed point${label}`, () => { + fc.assert( + fc.property(arb, (ast) => { + const str1 = str(simplify(ast)); + return str1 === str(simplify(parseSource(str1))); + }), + { numRuns: NUM_RUNS } + ); + }); +} // --- Analysis/simplification contract ----------------------------------- // Analysis summarizes the original tree, while simplification may refine // coarse unknown types. It must not invalidate a valid tree, change a known // type, or introduce unresolved state. -test('property: simplification preserves analysis invariants', () => { - fc.assert( - fc.property(astArb(4), (ast) => { - const before = analyze(ast); - if (!before.valid) return true; - const after = analyze(simplify(ast)); - assert.deepEqual(after.valid, true); - if (before.type !== 'unknown') { - assert.deepEqual(after.type, before.type); - } - if (!before.unresolved) { - assert.deepEqual(after.unresolved, false); - } - return true; - }), - { numRuns: NUM_RUNS } - ); -}); - -test('property: simplification preserves analysis invariants on degenerate trees', () => { - fc.assert( - fc.property(astArbWithDegenerate(3), (ast) => { - const before = analyze(ast); - if (!before.valid) return true; - const after = analyze(simplify(ast)); - assert.deepEqual(after.valid, true); - if (before.type !== 'unknown') { - assert.deepEqual(after.type, before.type); - } - if (!before.unresolved) { - assert.deepEqual(after.unresolved, false); - } - return true; - }), - { numRuns: NUM_RUNS } - ); -}); +for (const [label, arb] of [ + ['', astArb(4)], + [' on degenerate trees', astArbWithDegenerate(3)], +]) { + test(`property: simplification preserves analysis invariants${label}`, () => { + fc.assert( + fc.property(arb, (ast) => { + const before = analyze(ast); + if (!before.valid) return true; + const after = analyze(simplify(ast)); + assert.deepEqual(after.valid, true); + if (before.type !== 'unknown') { + assert.deepEqual(after.type, before.type); + } + if (!before.unresolved) { + assert.deepEqual(after.unresolved, false); + } + return true; + }), + { numRuns: NUM_RUNS } + ); + }); +} test('property: percentage division is typed as a number before simplification', () => { - const tokens = tokenize({ css: '10% / 5%' }); - const ast = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const ast = parseSource('10% / 5%'); const before = analyze(ast); const after = analyze(simplify(ast)); assert.deepEqual(before, { @@ -100,20 +98,6 @@ test('property: percentage division is typed as a number before simplification', // --- Parse-serialize round-trip ------------------------------------------ // serialize(simplify(x)) parsed+simplified back must be indistinguishable // from the first simplified form at the string level. -test('property: simplify → serialize → parse → simplify is a fixed point', () => { - fc.assert( - fc.property(astArb(4), (ast) => { - const first = simplify(ast); - const str1 = str(first); - const tokens = tokenize({ css: str1 }); - const second = simplify( - parse(tokens, 0, tokens.length, indexBlocks(tokens)) - ); - return str1 === str(second); - }), - { numRuns: NUM_RUNS } - ); -}); // --- Multiplicative identity --------------------------------------------- test('property: x * 1 ≡ simplify(x)', () => { fc.assert( @@ -162,30 +146,3 @@ test('property: -(-x) ≡ simplify(x)', () => { { numRuns: NUM_RUNS } ); }); -// --- Degenerate / float stress ------------------------------------------- -// Same invariants as above but with Infinity / NaN / FP-imprecise leaves -// mixed in. Catches regressions in §10.13 paths and IEEE-754 propagation. -test('property: simplify is idempotent under degenerate / float leaves', () => { - fc.assert( - fc.property(astArbWithDegenerate(4), (ast) => { - const once = simplify(ast); - const twice = simplify(once); - return str(once) === str(twice); - }), - { numRuns: NUM_RUNS } - ); -}); -test('property: round-trip stable under degenerate / float leaves', () => { - fc.assert( - fc.property(astArbWithDegenerate(4), (ast) => { - const first = simplify(ast); - const str1 = str(first); - const tokens = tokenize({ css: str1 }); - const second = simplify( - parse(tokens, 0, tokens.length, indexBlocks(tokens)) - ); - return str1 === str(second); - }), - { numRuns: NUM_RUNS } - ); -}); diff --git a/test/unit/analyze-opaque.test.js b/test/unit/analyze-opaque.test.js index e38df05..9c4e95c 100644 --- a/test/unit/analyze-opaque.test.js +++ b/test/unit/analyze-opaque.test.js @@ -2,14 +2,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { analyze } from '../../src/lib/analyze.js'; import { call, num } from '../../src/lib/node.js'; -import { indexBlocks } from '../../src/lib/block-index.js'; -import { parse } from '../../src/lib/parser.js'; import reduceCalc from '../../src/reduce.js'; -import { tokenize } from '@csstools/css-tokenizer'; +import { parseSource } from '../helpers/parse-source.js'; function analyzeSource(source) { - const tokens = tokenize({ css: source }); - return analyze(parse(tokens, 0, tokens.length, indexBlocks(tokens))); + return analyze(parseSource(source)); } test('analyze: a non-percentage denominator survives percentage ratio cancellation', () => { diff --git a/test/unit/analyze.test.js b/test/unit/analyze.test.js index 2bad273..11dd191 100644 --- a/test/unit/analyze.test.js +++ b/test/unit/analyze.test.js @@ -1,14 +1,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { analyze } from '../../src/lib/analyze.js'; -import { indexBlocks } from '../../src/lib/block-index.js'; -import { parse } from '../../src/lib/parser.js'; import reduceCalc from '../../src/reduce.js'; -import { tokenize } from '@csstools/css-tokenizer'; +import { parseSource } from '../helpers/parse-source.js'; function analyzeSource(source) { - const tokens = tokenize({ css: source }); - return analyze(parse(tokens, 0, tokens.length, indexBlocks(tokens))); + return analyze(parseSource(source)); } test('analyze: reports CSS type and validity in one result', () => { diff --git a/test/unit/compare-parser-benchmarks.test.js b/test/unit/compare-parser-benchmarks.test.js index 416931d..fb29b74 100644 --- a/test/unit/compare-parser-benchmarks.test.js +++ b/test/unit/compare-parser-benchmarks.test.js @@ -9,12 +9,11 @@ import { exitCodeFor, reanalyzeParserBenchmark, } from '../../scripts/benchmark/compare-parser-benchmarks.js'; -import { syntheticParserArtifact } from '../helpers/benchmark-artifact.js'; import { - CORPUS_INTERVAL_METHOD, - DECISION_CONFIG_VERSION, - PRECISION_METHOD, -} from '../../scripts/benchmark/benchmark.js'; + CORPUS_DECISION_CONFIG, + corpusCorrectness, + syntheticParserArtifact, +} from '../helpers/benchmark-artifact.js'; test('exitCodeFor maps benchmark analysis statuses to exit codes', () => { assert.equal(exitCodeFor('pass'), 0); @@ -59,56 +58,9 @@ test('reanalyzes schema-v2 corpus benchmark artifacts', () => { schema: 2, benchmark: 'corpus', seed: 123, - config: { - decisionConfigVersion: DECISION_CONFIG_VERSION, - requestedBlocks: 20, - minimumBlocks: 20, - maxAttempts: 20, - targetBatchMs: 25, - warmupMinimum: 0, - warmupMaximum: 0, - measuredBatchCount: 6, - driftThreshold: 0.15, - bootstrapResamples: 1_000, - confidence: 0.95, - runtimeNonRegressionMargin: 1.1, - equivalenceMargin: 1.1, - precisionMargin: 1.1, - precisionMethod: PRECISION_METHOD, - growthThreshold: 2.5, - orderInteractionThreshold: Math.log(1.1), - intervalMethod: CORPUS_INTERVAL_METHOD, - replicates: 20, - batches: 6, - calibrationOrderBalanced: true, - }, + config: CORPUS_DECISION_CONFIG, corpus: { lengthStrata: {}, rootShapeCounts: { sum: 2 } }, - correctness: { - accepted: 2, - counts: Object.fromEntries( - [ - 'accepted', - 'both-failed', - 'known-divergence', - 'malformed-input', - 'parser-rejected', - 'reference-rejected', - 'unexpected-divergence', - ].map((c) => [c, c === 'accepted' ? 2 : 0]) - ), - categoryHashes: Object.fromEntries( - [ - 'accepted', - 'both-failed', - 'known-divergence', - 'malformed-input', - 'parser-rejected', - 'reference-rejected', - 'unexpected-divergence', - ].map((c) => [c, c]) - ), - inputHash: 'inputs', - }, + correctness: corpusCorrectness(), replicates: Array.from({ length: 20 }, (_, replicate) => ({ replicate, calibrationOrder: replicate < 10 ? 'ours-first' : 'reference-first', diff --git a/test/unit/corpus-benchmark.test.js b/test/unit/corpus-benchmark.test.js index 77026d1..aa25824 100644 --- a/test/unit/corpus-benchmark.test.js +++ b/test/unit/corpus-benchmark.test.js @@ -10,10 +10,9 @@ import { groupResults, } from '../../scripts/benchmark/corpus-benchmark.js'; import { - CORPUS_INTERVAL_METHOD, - DECISION_CONFIG_VERSION, - PRECISION_METHOD, -} from '../../scripts/benchmark/benchmark.js'; + CORPUS_DECISION_CONFIG, + corpusCorrectness, +} from '../helpers/benchmark-artifact.js'; function artifact(ratioForReplicate) { const groups = ['exact', 'sum']; @@ -66,48 +65,8 @@ function artifact(ratioForReplicate) { function strictArtifact(ratioForReplicate) { const value = artifact(ratioForReplicate); - const categories = [ - 'accepted', - 'both-failed', - 'known-divergence', - 'malformed-input', - 'parser-rejected', - 'reference-rejected', - 'unexpected-divergence', - ]; - value.config = { - decisionConfigVersion: DECISION_CONFIG_VERSION, - requestedBlocks: 20, - minimumBlocks: 20, - maxAttempts: 20, - targetBatchMs: 25, - warmupMinimum: 0, - warmupMaximum: 0, - measuredBatchCount: 6, - driftThreshold: 0.15, - bootstrapResamples: 1_000, - confidence: 0.95, - runtimeNonRegressionMargin: 1.1, - equivalenceMargin: 1.1, - precisionMargin: 1.1, - precisionMethod: PRECISION_METHOD, - growthThreshold: 2.5, - orderInteractionThreshold: Math.log(1.1), - intervalMethod: CORPUS_INTERVAL_METHOD, - replicates: 20, - batches: 6, - calibrationOrderBalanced: true, - }; - value.correctness = { - accepted: 2, - counts: Object.fromEntries( - categories.map((category) => [category, category === 'accepted' ? 2 : 0]) - ), - categoryHashes: Object.fromEntries( - categories.map((category) => [category, category]) - ), - inputHash: 'inputs', - }; + value.config = CORPUS_DECISION_CONFIG; + value.correctness = corpusCorrectness(); value.corpus = { lengthStrata: {}, rootShapeCounts: { sum: 2 } }; delete value.analysis; return value; diff --git a/test/unit/parser-core.test.js b/test/unit/parser-core.test.js index 110612f..b0e95ae 100644 --- a/test/unit/parser-core.test.js +++ b/test/unit/parser-core.test.js @@ -8,11 +8,11 @@ import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { serialize } from '../../src/lib/serialize.js'; import { sexpr } from '../helpers/sexpr.js'; +import { parseSource } from '../helpers/parse-source.js'; /** Parse input, return its S-expression. */ const ast = (input) => { - const tokens = tokenize({ css: input }); - return sexpr(parse(tokens, 0, tokens.length, indexBlocks(tokens))); + return sexpr(parseSource(input)); }; test('parser: accepts a bounded range of a shared native token stream', () => { @@ -48,8 +48,7 @@ test('parser: bounded virtual EOF scans trailing trivia before reporting its pos }); test('parser: native EOF scans trailing trivia and keeps its exact position', () => { - const tokens = tokenize({ css: '1 + ' }); - assert.throws(() => parse(tokens, 0, tokens.length, indexBlocks(tokens)), { + assert.throws(() => parseSource('1 + '), { message: 'Unexpected token "" at position 6', }); }); @@ -96,8 +95,7 @@ describe('parser: values', () => { test('parser: signed textual zero forms normalize to positive zero', () => { for (const source of ['-0', '-.0', '-0e10', '-0px', '-.0E-3%']) { - const tokens = tokenize({ css: source }); - const leaf = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const leaf = parseSource(source); assert.ok(leaf.type === 'Num' || leaf.type === 'Dim'); assert.equal(leaf.value, 0, source); assert.equal(Object.is(leaf.value, -0), false, source); @@ -140,8 +138,7 @@ describe('parser: long arithmetic chains', () => { test('parser: long additive chain has one canonical Sum', () => { const input = Array(termCount).fill('1').join(' + '); - const tokens = tokenize({ css: input }); - const tree = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const tree = parseSource(input); assert.equal(tree.type, 'Sum'); assert.equal(tree.terms.length, termCount); @@ -151,8 +148,7 @@ describe('parser: long arithmetic chains', () => { test('parser: long multiplicative chain has one canonical Product', () => { // Use 2 rather than 1: `mkProduct` correctly removes factors of one. const input = Array(termCount).fill('2').join(' * '); - const tokens = tokenize({ css: input }); - const tree = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const tree = parseSource(input); assert.equal(tree.type, 'Product'); assert.equal(tree.factors.length, termCount); @@ -229,8 +225,7 @@ describe('parser: function calls', () => { }); test('parser: native function tokens preserve escaped opaque names', () => { - const tokens = tokenize({ css: String.raw`f\,n(1px)` }); - const tree = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const tree = parseSource(String.raw`f\,n(1px)`); assert.equal(serialize(tree), String.raw`f\,n(1px)`); }); @@ -240,18 +235,13 @@ describe('parser: function calls', () => { String.raw`var(--x\,fallback)`, String.raw`var(--x\ fallback)`, ]) { - const tokens = tokenize({ css: input }); - assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - input - ); + assert.equal(serialize(parseSource(input)), input); } }); test('parser: custom dimension preserves escaped unit spelling', () => { - const tokens = tokenize({ css: String.raw`10\foo` }); assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + serialize(parseSource(String.raw`10\foo`)), String.raw`calc(10\foo)` ); }); diff --git a/test/unit/parser-opaque.test.js b/test/unit/parser-opaque.test.js index ae2eb7d..78ff0d3 100644 --- a/test/unit/parser-opaque.test.js +++ b/test/unit/parser-opaque.test.js @@ -8,11 +8,11 @@ import { indexBlocks } from '../../src/lib/block-index.js'; import { parse } from '../../src/lib/parser.js'; import { serialize } from '../../src/lib/serialize.js'; import { sexpr } from '../helpers/sexpr.js'; +import { parseSource } from '../helpers/parse-source.js'; /** Parse input, return its S-expression. */ const ast = (input) => { - const tokens = tokenize({ css: input }); - return sexpr(parse(tokens, 0, tokens.length, indexBlocks(tokens))); + return sexpr(parseSource(input)); }; // --- Opaque non-math functions ------------------------------------------- // @@ -21,60 +21,47 @@ const ast = (input) => { // updated for every future CSS function. describe('parser: opaque expressions and invalid syntax', () => { test('parser: anchor() with ` ` parses as opaque single arg', () => { - const tokens = tokenize({ css: 'anchor(--foo top)' }); assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + serialize(parseSource('anchor(--foo top)')), 'anchor(--foo top)' ); }); test('parser: anchor() with `implicit ` keyword name', () => { - const tokens = tokenize({ css: 'anchor(implicit bottom)' }); assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + serialize(parseSource('anchor(implicit bottom)')), 'anchor(implicit bottom)' ); }); test('parser: anchor() with comma-separated fallback', () => { - const tokens = tokenize({ css: 'anchor(--foo top, 50px)' }); assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + serialize(parseSource('anchor(--foo top, 50px)')), 'anchor(--foo top, 50px)' ); }); test('parser: anchor-size() also takes space-separated args', () => { - const tokens = tokenize({ css: 'anchor-size(--foo height)' }); assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + serialize(parseSource('anchor-size(--foo height)')), 'anchor-size(--foo height)' ); }); test('parser: anchor() composes inside calc() arithmetic', () => { - const tokens = tokenize({ css: 'calc(anchor(--foo top) - 42px)' }); assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), + serialize(parseSource('calc(anchor(--foo top) - 42px)')), 'calc(anchor(--foo top) - 42px)' ); }); test('parser: anchor() with single side keyword', () => { - const tokens = tokenize({ css: 'anchor(top)' }); - assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - 'anchor(top)' - ); + assert.equal(serialize(parseSource('anchor(top)')), 'anchor(top)'); }); test('parser: anchor arguments preserve escaped lexical spelling', () => { const input = String.raw`anchor(--x\ top left)`; - const tokens = tokenize({ css: input }); - assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - input - ); + assert.equal(serialize(parseSource(input)), input); }); test('parser: arbitrary non-math function contents stay opaque', () => { @@ -83,11 +70,7 @@ describe('parser: opaque expressions and invalid syntax', () => { 'future-fn("x", [a b] {c: #fff})', 'unknown(1px + 2px)', ]) { - const tokens = tokenize({ css: input }); - assert.equal( - serialize(parse(tokens, 0, tokens.length, indexBlocks(tokens))), - input - ); + assert.equal(serialize(parseSource(input)), input); } }); @@ -113,8 +96,7 @@ describe('parser: opaque expressions and invalid syntax', () => { }); test('parser: opaque component trees retain nested math ASTs', () => { - const tokens = tokenize({ css: 'unknown(calc(1px + 2px))' }); - const node = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const node = parseSource('unknown(calc(1px + 2px))'); assert.equal(node.type, 'OpaqueCall'); if (node.type === 'OpaqueCall') { assert.equal(node.components.length, 1); @@ -130,8 +112,7 @@ describe('parser: opaque expressions and invalid syntax', () => { test('parser: opaque trees preserve raw and nested component structure', () => { const input = String.raw`f( /*lead*/ \66 oo\20 bar, raw([x, y], {z: q}), c\61 lc(1px + var(--x)), calc(1PX+2PX), calc(-(var(--x) + 1px)) )`; - const tokens = tokenize({ css: input }); - const node = parse(tokens, 0, tokens.length, indexBlocks(tokens)); + const node = parseSource(input); assert.deepEqual(node, { type: 'OpaqueCall', @@ -170,11 +151,7 @@ describe('parser: opaque expressions and invalid syntax', () => { }); test('parser: unclosed anchor() throws', () => { - const tokens = tokenize({ css: 'anchor(--foo top' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Unclosed anchor\(/ - ); + assert.throws(() => parseSource('anchor(--foo top'), /Unclosed anchor\(/); }); // --- Calc-keyword folding ------------------------------------------------- test('parser: `pi` folds to Math.PI', () => { @@ -197,20 +174,15 @@ describe('parser: opaque expressions and invalid syntax', () => { }); // --- Strict whitespace around +/- ---------------------------------------- test('parser: `1px + 2px` is valid', () => { - const tokens = tokenize({ css: '1px + 2px' }); - assert.doesNotThrow(() => - parse(tokens, 0, tokens.length, indexBlocks(tokens)) - ); + assert.doesNotThrow(() => parseSource('1px + 2px')); }); test('parser: a signed token after whitespace still fails at its position', () => { - const tokens = tokenize({ css: '1 +2' }); - assert.throws(() => parse(tokens, 0, tokens.length, indexBlocks(tokens)), { + assert.throws(() => parseSource('1 +2'), { message: '"+" must be surrounded by whitespace at position 2', }); }); test('parser: a unary plus after multiplication has the canonical AST', () => { - const tokens = tokenize({ css: '1 * +2' }); - assert.deepEqual(parse(tokens, 0, tokens.length, indexBlocks(tokens)), { + assert.deepEqual(parseSource('1 * +2'), { type: 'Num', value: 2, }); @@ -219,9 +191,8 @@ describe('parser: opaque expressions and invalid syntax', () => { // cases (no/before-only/after-only) must throw the same way. for (const input of ['1px+2px', '1px +2px', '1px+ 2px']) { test(`parser: \`${input}\` throws (asymmetric whitespace around +)`, () => { - const tokens = tokenize({ css: input }); assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), + () => parseSource(input), /must be surrounded by whitespace/ ); }); @@ -232,13 +203,9 @@ describe('parser: opaque expressions and invalid syntax', () => { ['1px +2px', 4], ['1px+ 2px', 3], ]) { - const tokens = tokenize({ css: input }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - { - message: `"+" must be surrounded by whitespace at position ${position}`, - } - ); + assert.throws(() => parseSource(input), { + message: `"+" must be surrounded by whitespace at position ${position}`, + }); } }); test('parser: * / do not require whitespace (spec allows both)', () => { @@ -275,41 +242,24 @@ describe('parser: opaque expressions and invalid syntax', () => { test('parser: trailing operator throws (whitespace-before-EOF fails)', () => { // `1 +` has space before `+` but nothing after — EOF has ws=false, so // the strict-whitespace check fires before the unexpected-token path. - const tokens = tokenize({ css: '1 +' }); assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), + () => parseSource('1 +'), /must be surrounded by whitespace|Unexpected token/ ); }); test('parser: unclosed paren expects )', () => { - const tokens = tokenize({ css: '(1 + 2' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Expected/ - ); + assert.throws(() => parseSource('(1 + 2'), /Expected/); }); test('parser: stacked operators throw', () => { - const tokens = tokenize({ css: '1 * * 2' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Unexpected token/ - ); + assert.throws(() => parseSource('1 * * 2'), /Unexpected token/); }); // --- expect() failures (unclosed groups) ----------------------------------- test('parser: unclosed paren throws with expected-token message', () => { - const tokens = tokenize({ css: '(1 + 2' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Expected \)/ - ); + assert.throws(() => parseSource('(1 + 2'), /Expected \)/); }); test('parser: unclosed call throws with expected-token message', () => { - const tokens = tokenize({ css: 'min(1, 2' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Expected \)/ - ); + assert.throws(() => parseSource('min(1, 2'), /Expected \)/); }); test('parser: unclosed var throws with unclosed message and position', () => { @@ -321,18 +271,10 @@ describe('parser: opaque expressions and invalid syntax', () => { }); // --- Trailing tokens ------------------------------------------------------ test('parse: rejects input with trailing tokens after a complete expression', () => { - const tokens = tokenize({ css: '1 2' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Unexpected token/ - ); + assert.throws(() => parseSource('1 2'), /Unexpected token/); }); test('parse: empty input throws', () => { - const tokens = tokenize({ css: '' }); - assert.throws( - () => parse(tokens, 0, tokens.length, indexBlocks(tokens)), - /Unexpected token/ - ); + assert.throws(() => parseSource(''), /Unexpected token/); }); test('parser: punctuation helper methods match and expect punctuation tokens', () => {