dssl / scripts /test-v072.mjs
EHS-Studio's picture
Claude Sonnet 5.5
Site and docs for 0.9.0; drop template leftovers; scripts without hard-coded paths
794b22f
Raw History Blame Contribute Delete
7.78 kB
#!/usr/bin/env node
// v0.7.2 - regression suite for the external-review findings.
// Every assertion maps to a reported bug or a fixed behavior.
import { parse, STATE_WORDS } from '../public/dssl/parser.js';
import { resolve } from '../public/dssl/resolver.js';
import { VERSION } from '../public/dssl/index.js';
import { stateReach, gatePass } from '../public/dssl/engine.js';
let pass = 0, fail = 0;
const ok = (cond, msg) => { if (cond) { pass++; } else { fail++; console.error('FAIL:', msg); } };
function compile(src) {
const { ast, errors } = parse(src, 't.dssl');
const doc = resolve(ast, 't.dssl', {});
return { doc, parseErrors: errors };
}
const declsOf = (doc, sel) => doc.rules.filter(r => r.sel === sel).flatMap(r => r.decls);
/* 1 - THE headline: nested state must sort AFTER the base rule */
{
const { doc } = compile('.card {\n background: #26201a\n hover { background: #c0392b }\n}');
const base = doc.rules.find(r => r.sel === '.card');
const hov = doc.rules.find(r => r.sel === '.card:hover');
ok(base && hov, 'nested hover: both rules exist');
ok(hov.order > base.order, 'nested hover sorts after base (cascade fix)');
ok(declsOf(doc, '.card').some(d => d.value === '#26201a'), 'base declaration survives');
ok(declsOf(doc, '.card:hover').some(d => d.value === '#c0392b'), 'hover declaration survives');
}
/* 1b - nested media keeps its condition AND sorts after base */
{
const { doc } = compile('.card {\n color: red\n @media dark { color: blue }\n}');
const dark = doc.rules.find(r => r.sel === '.card' && r.media.length);
ok(dark && dark.media.length === 1, 'nested @media keeps its condition');
const base = doc.rules.find(r => r.sel === '.card' && !r.media.length);
ok(dark && base && dark.order > base.order, 'nested @media sorts after base');
}
/* 1c - nested signal keeps its condition */
{
const { doc } = compile('.card {\n color: red\n @signal on(true) { color: blue }\n}');
const sig = doc.rules.find(r => r.sel === '.card' && r.signals.length);
ok(sig && sig.signals.length === 1, 'nested @signal keeps its condition');
}
/* 1d - nested layer keeps its layer */
{
const { doc } = compile('.card {\n color: red\n @layer utilities { color: blue }\n}');
const lay = doc.rules.find(r => r.sel === '.card' && r.layer === 'utilities');
ok(lay, 'nested @layer keeps its layer');
}
/* 1e - decls AFTER a nested block become a later fragment (later wins) */
{
const { doc } = compile('.card {\n color: red\n hover { color: green }\n color: blue\n}');
ok(declsOf(doc, '.card').some(d => d.value === 'red'), 'pre-block decl kept');
ok(declsOf(doc, '.card').some(d => d.value === 'blue'), 'post-block decl kept');
ok(declsOf(doc, '.card:hover').some(d => d.value === 'green'), 'nested decl kept');
}
/* 2 - raw hex alpha: #ff0000/50 is color-mix, never /50px */
{
const { doc } = compile('.card { background: #ff0000/50 }');
const v = declsOf(doc, '.card')[0]?.value;
ok(v === 'color-mix(in oklab, #ff0000 50%, transparent)', 'raw hex /50 becomes color-mix, got: ' + v);
}
/* 3 - time properties: bare numbers are ms */
{
const { doc } = compile('.card { transition-delay: 200; animation-duration: 0.5 }');
const d = declsOf(doc, '.card');
ok(d.some(x => x.prop === 'transition-delay' && x.value === '200ms'), 'transition-delay: 200 -> 200ms');
ok(d.some(x => x.prop === 'animation-duration' && x.value === '0.5ms' || x.prop === 'animation-duration' && x.value === '500ms'), 'animation-duration bare -> ms, got: ' + JSON.stringify(d));
}
/* 4 - grid shorthand: gap is a first-class word */
{
const { doc } = compile('.grid { grid: 3 gap 12 }');
const d = declsOf(doc, '.grid');
ok(d.some(x => x.prop === 'grid-template-columns' && x.value === 'repeat(3, 1fr)'), 'grid: 3 -> repeat(3, 1fr)');
ok(d.some(x => x.prop === 'gap' && x.value === '12px'), 'grid gap 12 -> gap: 12px');
const { doc: d2 } = compile('.grid { grid: 2 rows 3 gap 8 }');
const d2l = declsOf(d2, '.grid');
ok(d2l.some(x => x.prop === 'grid-template-rows' && x.value === 'repeat(3, 1fr)'), 'grid rows 3 -> template rows');
}
/* 5 - seen { } with arrow is a LOUD error pointing at the real patterns */
{
const { doc } = compile('.card { seen { opacity: 0 -> 1 } }');
ok(doc.errors.some(e => e.message.includes('->') && e.hint.includes('animate')), 'seen arrow -> loud error with animate hint');
const { doc: d2 } = compile('motion rise { from { opacity: 0 } }\n.card { seen { animate: rise 400 } }');
ok(d2.errors.length === 0, 'seen { animate } stays legal');
}
/* 6 - unknown nested state word warns instead of silently becoming a descendant */
{
const { doc } = compile('.card { hovr { background: red } }');
ok(doc.warnings.some(w => w.message.includes('hovr') && w.hint.includes('hover')), 'hovr typo warns with did-you-mean');
const { doc: d2 } = compile('.card { title { color: red } }');
ok(!d2.warnings.some(w => w.message.includes('title')), 'legit descendant word does not warn');
}
/* 7 - SVG + modern properties pass through */
{
const { doc } = compile('.card { fill: red; stroke: blue; stroke-width: 2; text-wrap: balance; content-visibility: auto }');
const d = declsOf(doc, '.card');
for (const [p, v] of [['fill', 'red'], ['stroke', 'blue'], ['stroke-width', '2px'], ['text-wrap', 'balance'], ['content-visibility', 'auto']]) {
ok(d.some(x => x.prop === p && x.value === v), `property ${p}: ${v} resolves`);
}
}
/* 8 - ::part() rides the shadow pipeline, ::slotted() is a loud error */
{
const { doc } = compile('my-card::part(header) { background: red }');
ok(doc.errors.length === 0, '::part() compiles');
ok(doc.shadowRules.some(r => r.sel === 'my-card' && r.pseudo.includes('part(header)')), '::part() rides shadowRules');
const { doc: d2 } = compile('my-card::slotted(p) { background: red }');
ok(d2.errors.some(e => e.message.includes('shadow tree')), '::slotted() gets the shadow-tree explanation');
}
/* 9 - one-line decls + nested block on the same line */
{
const { doc, parseErrors } = compile('.card { background: red; hover { background: blue } }');
ok(parseErrors.length === 0, 'one-line mix parses');
ok(declsOf(doc, '.card').some(d => d.value === 'red'), 'one-line: base decl survives');
ok(declsOf(doc, '.card:hover').some(d => d.value === 'blue'), 'one-line: hover decl survives');
}
/* 10 - stateReach: the hover scoping math */
{
ok(stateReach('.card:hover', 'hover') === null, 'subject-state reach = null (chain only)');
const d = stateReach('.card:hover .title', 'hover');
ok(d && d.prefix === '.card' && d.rest === '.title', 'descendant reach: prefix .card rest .title');
const s = stateReach('.tab:hover + .panel', 'hover');
ok(s && s.sib === '+' && s.firstSeg === '.panel', 'sibling reach: + .panel');
const f = stateReach('.card:has(:hover) .x', 'hover');
ok(f && f.fallback === true, ':has() wrapper falls back to page-wide superset');
const deep = stateReach('.list .card:hover .title', 'hover');
ok(deep && deep.prefix === '.list .card' && deep.rest === '.title', 'deep ancestor prefix');
}
/* 11 - gatePass still duck-types on fake elements (unchanged contract) */
{
ok(gatePass({ tagName: 'div', classList: { contains: () => true } }, null) === true, 'gatePass null gate = true');
}
/* 12 - version */
{
// v0.8.0 - pin loosened the same way v070's was: the suite must survive
// later releases, but catch an accidental DOWNGRADE below 0.7.2.
const [maj, min, pat] = String(VERSION).split('.').map(Number);
ok(maj === 0 && (min > 7 || (min === 7 && pat >= 2)), 'engine VERSION is >= 0.7.2 (got ' + VERSION + ')');
ok(STATE_WORDS.has('hover') && STATE_WORDS.has('seen'), 'STATE_WORDS exported');
}
console.log(`${pass} passed, ${fail} failed`);
process.exitCode = fail ? 1 : 0;