dssl / scripts /test-v081.mjs
EHS-Studio's picture
Claude Sonnet 5.5
v0.9.0: texture (30 presets), aura glow and particles, range sliders, lint, CSS import
39309b1
Raw History Blame Contribute Delete
10.6 kB
/* test-v081.mjs; one assertion per fix in 0.8.1.
* Source: the 2026-10-23 re-test of 0.8.0 (two bugs and one nit).
* 1. fluid computed clamp(16px, calc(16px + 0.952vw), 24px): no intercept,
* so it was already 19.6px at a 375px phone and hit 24px near 840px.
* 2. the guide's multi-line raw { } example came out as "undefined @font-face"
* and, with no semicolons, was not valid CSS.
* 3. dssl build had no --strict, so CI could not fail on warnings.
* The fluid tests do not compare strings. They evaluate the emitted clamp()
* at real viewport widths, which is the thing that was wrong. */
import { parse } from '../public/dssl/parser.js';
import { resolve } from '../public/dssl/resolver.js';
import { toCSS } from '../public/dssl/static.js';
import { VERSION } from '../public/dssl/index.js';
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdtempSync, rmSync, mkdirSync, copyFileSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
let pass = 0, fail = 0;
const ok = (name, cond, extra) => {
if (cond) { pass++; console.log(` ok ${name}`); }
else { fail++; console.log(`FAIL ${name}${extra ? '; ' + extra : ''}`); }
};
const compileDoc = (src, file = 't.dssl') => {
const { ast, errors } = parse(src, file);
const doc = resolve(ast, file);
return { doc, errors: [...errors, ...doc.errors], ast };
};
const declsOf = (doc, i = 0) => doc.rules[i]?.decls || [];
const val = (decls, prop) => decls.find(d => d.prop === prop)?.value;
/** evaluate `clamp(L, calc(I + Wvw), H)` at a viewport width, in px (rem = 16px) */
const evalClamp = (css, vw) => {
const m = /^clamp\(\s*(-?[\d.]+)(px|rem|em),\s*calc\(\s*(-?[\d.]+)(px|rem|em)\s*\+\s*(-?[\d.]+)vw\s*\),\s*(-?[\d.]+)(px|rem|em)\s*\)$/.exec(css);
if (!m) return NaN;
const f = (u) => (u === 'px' ? 1 : 16);
const lo = parseFloat(m[1]) * f(m[2]);
const mid = parseFloat(m[3]) * f(m[4]) + parseFloat(m[5]) * vw / 100;
const hi = parseFloat(m[6]) * f(m[7]);
return Math.min(hi, Math.max(lo, mid));
};
const near = (a, b, eps = 0.01) => Math.abs(a - b) <= eps;
const fluidOf = (src) => val(declsOf(compileDoc(src).doc), 'font-size');
/* ---- 1: fluid math ---- */
{
const c = fluidOf('.a { font-size: fluid 16 24 }');
ok('1a. fluid 16 24 is exactly 16px at the 360px lower bound', near(evalClamp(c, 360), 16), `got ${evalClamp(c, 360)}`);
ok('1b. fluid 16 24 is exactly 24px at the 1200px upper bound', near(evalClamp(c, 1200), 24), `got ${evalClamp(c, 1200)}`);
ok('1c. a 375px phone gets 16.14px, not the 19.57px 0.8.0 gave', near(evalClamp(c, 375), 16.143), `got ${evalClamp(c, 375)}`);
ok('1d. the middle of the range is the middle of the sizes (780px = 20px)', near(evalClamp(c, 780), 20), `got ${evalClamp(c, 780)}`);
ok('1e. an 800px laptop window gets 20.19px, not 23.6px', near(evalClamp(c, 800), 20.19), `got ${evalClamp(c, 800)}`);
ok('1f. it is clamped below the lower bound and above the upper bound', near(evalClamp(c, 200), 16) && near(evalClamp(c, 2000), 24));
const b = fluidOf('.a { font-size: fluid 16 24 400 800 }');
ok('1g. custom bounds: 16px at 400, 24px at 800, 20px at 600', near(evalClamp(b, 400), 16) && near(evalClamp(b, 800), 24) && near(evalClamp(b, 600), 20));
const shrink = fluidOf('.a { font-size: fluid 24 16 }');
ok('1h. a shrinking size works: 24px at 360, 16px at 1200', near(evalClamp(shrink, 360), 24) && near(evalClamp(shrink, 1200), 16), shrink);
ok('1i. a shrinking size keeps the lower value first in clamp()', /^clamp\(16px,/.test(shrink), shrink);
const rem = fluidOf('.a { font-size: fluid 1rem 1.5rem }');
ok('1j. rem sizes interpolate too: 16px at 360, 24px at 1200', near(evalClamp(rem, 360), 16) && near(evalClamp(rem, 1200), 24), rem);
const legacy = fluidOf('.a { fluid: 16, 24 }');
ok('1k. the old fluid: 16, 24 form gets the same fix', near(evalClamp(legacy, 375), 16.143), legacy);
const gap = val(declsOf(compileDoc('.a { gap: fluid 8 16 }').doc), 'gap');
ok('1l. gap takes fluid with the same math (8px at 360, 16px at 1200)', near(evalClamp(gap, 360), 8) && near(evalClamp(gap, 1200), 16));
const pct = compileDoc('.a { width: fluid 50% 100% }');
ok('1m. a percentage is refused with a clear message', pct.errors.some(e => /percentage/.test(e.message)));
const mixed = compileDoc('.a { font-size: fluid 16px 2rem }');
ok('1n. mixed units are refused', mixed.errors.some(e => /mixes units/.test(e.message)));
const backwards = compileDoc('.a { font-size: fluid 16 24 1200 360 }');
ok('1o. viewport bounds in the wrong order are refused', backwards.errors.some(e => /larger viewport second/.test(e.message)));
}
/* ---- 2: raw { } ---- */
{
const guide = `raw {
@font-face {
font-family: Inter;
src: url(/fonts/inter.woff2);
}
}`;
const g = compileDoc(guide);
const css = g.doc.raws[0]?.css || '';
ok('2a. multi-line raw has no "undefined" in front of it', !/undefined/.test(css), css.slice(0, 40));
ok('2b. multi-line raw starts with the @font-face', /^@font-face\s*\{/.test(css), css.slice(0, 40));
ok('2c. the guide example compiles with zero errors and zero warnings', g.errors.length === 0 && g.doc.warnings.length === 0);
ok('2d. the body is kept verbatim, semicolons included', /font-family: Inter;/.test(css) && /src: url\(\/fonts\/inter\.woff2\);/.test(css));
const sameLine = compileDoc('raw { @font-face {\n font-family: X;\n src: local(Arial);\n} }');
ok('2e. content on the same line as "raw {" is not dropped', /^@font-face/.test(sameLine.doc.raws[0]?.css || ''), sameLine.doc.raws[0]?.css);
ok('2f. a raw block that closes on the same line as its last rule still works', sameLine.doc.raws.length === 1 && sameLine.errors.length === 0);
const old = compileDoc(`raw {
@font-face {
font-family: Inter
src: url(/fonts/inter.woff2)
}
}`);
const w = old.doc.warnings.filter(x => /needs a ";"/.test(x.message));
ok('2g. the old no-semicolon style warns on each bare declaration', w.length === 2, JSON.stringify(old.doc.warnings.map(x => x.message)));
ok('2h. the warning points at the right source line', w.length === 2 && w[0].line === 3 && w[1].line === 4, w.map(x => x.line).join(','));
ok('2i. the warning explains why', w.length > 0 && /semicolons are required inside raw/.test(w[0].hint));
const one = compileDoc('raw { @font-face { font-family: X; src: local(Arial) } }');
ok('2j. single-line raw has no warning (the declarations sit inside braces on one line)', one.doc.warnings.length === 0 && one.errors.length === 0);
const sel = compileDoc('raw {\n a:hover,\n a:focus { color: red; }\n .grid { grid-template-areas: "a b"\n "c d"; }\n}');
ok('2k. selectors with colons and multi-line values are not flagged', sel.doc.warnings.length === 0, JSON.stringify(sel.doc.warnings.map(x => x.message)));
const cmt = compileDoc('raw {\n /* color: red */\n .a { color: blue; }\n}');
ok('2l. a declaration inside a comment is not flagged', cmt.doc.warnings.length === 0);
const built = toCSS(g.doc, { file: 't.dssl', banner: false });
ok('2m. the built CSS carries the raw block clean and valid', !/undefined/.test(built.css) && /font-family: Inter;/.test(built.css));
}
/* ---- 3: dssl build --strict (CLI) ---- */
{
const here = dirname(fileURLToPath(import.meta.url));
const cli = join(here, '..', 'cli', 'dssl.mjs');
const dir = mkdtempSync(join(tmpdir(), 'dssl-v081-'));
const warnFile = join(dir, 'warn.dssl');
const cleanFile = join(dir, 'clean.dssl');
writeFileSync(warnFile, '.a { row: center center }\n');
writeFileSync(cleanFile, '.a { color: red }\n');
const run = (args) => {
try { return { code: 0, out: execFileSync('node', [cli, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) }; }
catch (e) { return { code: e.status, out: (e.stdout || '') + (e.stderr || '') }; }
};
const plain = run(['build', warnFile, '-o', join(dir, 'a.css')]);
ok('3a. build with a warning still exits 0 without --strict', plain.code === 0);
const strict = run(['build', warnFile, '-o', join(dir, 'b.css'), '--strict']);
ok('3b. build --strict exits 1 on a warning', strict.code === 1);
const clean = run(['build', cleanFile, '-o', join(dir, 'c.css'), '--strict']);
ok('3c. build --strict exits 0 when there is nothing to warn about', clean.code === 0);
const js = JSON.parse(run(['build', warnFile, '--json', '--strict']).out);
ok('3d. build --json --strict reports ok:false', js.ok === false && js.warnings.length === 1);
const jn = JSON.parse(run(['build', warnFile, '--json']).out);
ok('3e. build --json without --strict still reports ok:true', jn.ok === true);
const v = run(['--version']);
ok('3f. the CLI reports the engine version', v.out.trim() === VERSION);
rmSync(dir, { recursive: true, force: true });
}
/* ---- 4: the packaged CLI (dssl-engine.zip layout: cli/ next to dssl/) ---- */
{
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, '..');
const dir = mkdtempSync(join(tmpdir(), 'dssl-pkg-'));
mkdirSync(join(dir, 'cli')); mkdirSync(join(dir, 'dssl'));
copyFileSync(join(root, 'cli', 'dssl.mjs'), join(dir, 'cli', 'dssl.mjs'));
// the package ships every engine module; a hardcoded list here is how the 0.8.0 package broke
for (const f of readdirSync(join(root, 'public', 'dssl'))) if (f.endsWith('.js')) copyFileSync(join(root, 'public', 'dssl', f), join(dir, 'dssl', f));
const src = join(dir, 'a.dssl');
writeFileSync(src, 'h1 { font-size: fluid 16 24 }\n');
const run = (args) => {
try { return { code: 0, out: execFileSync('node', [join(dir, 'cli', 'dssl.mjs'), ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) }; }
catch (e) { return { code: e.status, out: (e.stdout || '') + (e.stderr || '') }; }
};
const v = run(['--version']);
ok('4a. the packaged CLI starts (0.8.0 crashed with module not found)', v.code === 0 && v.out.trim() === VERSION, v.out.slice(0, 120));
const b = run(['build', src, '-o', join(dir, 'a.css'), '--no-banner']);
ok('4b. the packaged CLI builds a stylesheet', b.code === 0, b.out.slice(0, 120));
const css = b.code === 0 ? execFileSync('cat', [join(dir, 'a.css')], { encoding: 'utf8' }) : '';
ok('4c. and the output has the corrected fluid math', /calc\(12\.571px \+ 0\.952vw\)/.test(css), css.slice(0, 160));
rmSync(dir, { recursive: true, force: true });
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);