File size: 13,427 Bytes
794b22f 7bd797e 794b22f 7bd797e 0ade7b1 794b22f 0ade7b1 794b22f 0ade7b1 794b22f 0ade7b1 e6c76da 0ade7b1 7bd797e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 | /* v0.5.0 regression tests - the review complaints, verbatim scenarios:
* 1. ".card::before { } compiles ok: true and then matches nothing" β now a real shadow rule
* 2. animate has no infinite β infinite + alternate accepted, WAAPI spec carries them
* 3. quoted-only font names in tokens β bare names pass in font-family + font.* tokens
* 4. hyphens: manual rejected β accepted
* + loud errors preserved: unknown pseudo, :seen in shadow, mixed lists, still-loud values
*/
import { parse } from '../public/dssl/parser.js';
import { resolve } from '../public/dssl/resolver.js';
let pass = 0, fail = 0;
const check = (name, cond, extra = '') => {
if (cond) { pass++; console.log(` ok ${name}`); }
else { fail++; console.log(`FAIL ${name}${extra ? ' - ' + extra : ''}`); }
};
function compile(src) {
const { ast, errors: parseErrors } = parse(src, 'test.dssl');
const doc = resolve(ast, 'test.dssl', null);
return { doc, errors: [...parseErrors, ...doc.errors] };
}
console.log('1. the pseudo-element trap');
{
const { doc, errors } = compile(`
.card::before {
content: ""
background: color.accent
width: 12
}
`);
check('no errors', errors.length === 0, JSON.stringify(errors));
check('inline rules empty (nothing dead)', doc.rules.length === 0);
check('one shadow rule', doc.shadowRules.length === 1);
const r = doc.shadowRules[0];
check('sel split to .card', r.sel === '.card', r.sel);
check('pseudo = before', r.pseudo === 'before', r.pseudo);
check('content auto-quoted', r.decls.some(d => d.prop === 'content' && d.value === '""'));
check('token resolved', r.decls.some(d => d.prop === 'background' && d.value.includes('--dssl-color-accent')));
}
console.log('2. nested form still works (unchanged)');
{
const { doc, errors } = compile(`
.card {
::after { content: "β" }
hover { color: color.accent }
}
`);
check('no errors', errors.length === 0, JSON.stringify(errors));
check('shadow rule via nested', doc.shadowRules.length === 1 && doc.shadowRules[0].pseudo === 'after');
check('state rule intact', doc.rules.length === 1 && doc.rules[0].sel === '.card:hover');
}
console.log('3. &::before inside a block (also was silently dead)');
{
const { doc, errors } = compile(`
.card {
&::before { content: "" }
}
`);
check('no errors', errors.length === 0, JSON.stringify(errors));
check('routed to shadow CSS', doc.shadowRules.length === 1 && doc.shadowRules[0].sel === '.card' && doc.shadowRules[0].pseudo === 'before');
}
console.log('4. top-level pseudo + vendor + states + media');
{
const { doc, errors } = compile(`
@media 768 {
.nav::-webkit-scrollbar-thumb:hover { background: color.ink/45 }
}
.card:hover::before { background: color.accent }
`);
check('no errors', errors.length === 0, JSON.stringify(errors));
check('two shadow rules', doc.shadowRules.length === 2, `got ${doc.shadowRules.length}`);
const vendor = doc.shadowRules.find(r => r.pseudo.startsWith('-webkit-'));
check('vendor pseudo keeps trailing state', vendor && vendor.pseudo === '-webkit-scrollbar-thumb:hover' && vendor.sel === '.nav', vendor && vendor.pseudo);
check('media attached to vendor rule', vendor && vendor.media.length === 1);
const hov = doc.shadowRules.find(r => r.pseudo === 'before');
check('state before :: survives split', hov && hov.sel === '.card:hover', hov && hov.sel);
}
console.log('5. loud errors stay loud');
{
const a = compile('.card::befor { content: "" }');
check('unknown pseudo is a LOUD error', a.errors.length === 1 && /unknown pseudo-element/.test(a.errors[0].message), JSON.stringify(a.errors));
check('and nothing dead is emitted', a.doc.rules.length === 0 && a.doc.shadowRules.length === 0);
const b = compile('.card:seen::before { content: "" }');
check(':seen + pseudo is a LOUD error', b.errors.length === 1 && /seen/.test(b.errors[0].message), JSON.stringify(b.errors));
const c = compile('.a, .b::before { content: "" }');
check('mixed selector list is a LOUD error', c.errors.length === 1 && /mixes/.test(c.errors[0].message), JSON.stringify(c.errors));
const d = compile('.card { color: oops }');
check('unknown value still loud', d.errors.length === 1 && /unknown value/.test(d.errors[0].message), JSON.stringify(d.errors));
}
console.log('6. infinite + alternate');
{
const { doc, errors } = compile(`
motion pulse {
from { opacity: 1 }
to { opacity: 0.4 }
}
.dot {
animate: pulse 1600 infinite alternate
}
`);
check('no errors', errors.length === 0, JSON.stringify(errors));
const anim = doc.rules[0].decls.find(d => d.prop === '@animate');
check('iterations = Infinity', anim && anim.value.iterations === Infinity, JSON.stringify(anim && anim.value));
check('direction = alternate', anim && anim.value.direction === 'alternate', JSON.stringify(anim && anim.value));
const one = compile('motion p { from { opacity: 1 } to { opacity: 0 } } .x { animate: p 400 }');
const a1 = one.doc.rules[0].decls.find(d => d.prop === '@animate');
check('default stays 1 iteration / normal', a1 && a1.value.iterations === undefined && a1.value.direction === undefined);
const bad = compile('motion p { from { opacity: 1 } } .x { animate: p 400 forevr }');
check('typo in animate part still loud', bad.errors.length === 1 && /unknown animate part/.test(bad.errors[0].message), JSON.stringify(bad.errors));
}
console.log('7. bare font names');
{
const { doc, errors } = compile(`
tokens {
font {
display: JetBrains Mono, monospace
quote: Iowan Old Style
}
}
.card { font-family: Public Sans, serif }
.quoted { font-family: "JetBrains Mono", monospace }
`);
check('no errors', errors.length === 0, JSON.stringify(errors));
const fam = doc.rules.find(r => r.sel === '.card').decls.find(d => d.prop === 'font-family');
check('bare font-family passes verbatim', fam && fam.value === 'Public Sans, serif', fam && fam.value);
const tok = doc.registryRule.decls.find(d => d.prop === '--dssl-font-display');
check('bare token font name passes', tok && tok.value === 'JetBrains Mono, monospace', tok && tok.value);
const q = doc.rules.find(r => r.sel === '.quoted').decls.find(d => d.prop === 'font-family');
check('quoted still fine', q && q.value === '"JetBrains Mono", monospace', q && q.value);
const bad = compile('.card { color: steel blue }');
check('bare words elsewhere still loud', bad.errors.length >= 1 && /unknown value/.test(bad.errors[0].message), JSON.stringify(bad.errors));
}
console.log('8. hyphens: manual');
{
const { doc, errors } = compile('.prose { hyphens: manual }');
check('no errors', errors.length === 0, JSON.stringify(errors));
check('emitted as-is', doc.rules[0].decls[0].prop === 'hyphens' && doc.rules[0].decls[0].value === 'manual');
}
console.log('9. site.dssl regression (the actual site styles, token registry chained like the runtime)');
{
const fs = await import('node:fs');
const path = await import('node:path');
const { DEFAULT_TOKENS } = await import('../public/dssl/resolver.js');
const here = path.dirname(new URL(import.meta.url).pathname);
const stylesDir = path.join(here, '..', 'public', 'styles');
const registry = new Map(Object.entries(DEFAULT_TOKENS));
for (const name of ['tokens', 'site', 'home', 'docs', 'playground', 'changelog']) {
const f = path.join(stylesDir, `${name}.dssl`);
if (!fs.existsSync(f)) { console.log(` skip ${name}`); continue; }
const src = fs.readFileSync(f, 'utf8');
const { doc, errors } = resolveChain(src, registry);
check(`${name}.dssl compiles clean`, errors.length === 0, JSON.stringify(errors.slice(0, 2)));
if (name === 'site.dssl') {
check('site.dssl scrollbar shadow rules present', doc.shadowRules.length >= 4, `got ${doc.shadowRules.length}`);
}
}
}
/** compile with a shared registry (like index.js does) and fold tokens back in */
function resolveChain(src, registry) {
const { ast, errors: parseErrors } = parse(src, 'chain.dssl');
const doc = resolve(ast, 'chain.dssl', Object.fromEntries(registry));
for (const [k, v] of Object.entries(doc.tokens)) registry.set(k, v);
return { doc, errors: [...parseErrors, ...doc.errors] };
}
console.log('10. compile() stats surface the shadow rules');
{
const { doc, errors } = compile('.card::before { content: "" } .card::after { content: "" } .card { padding: 12 }');
check('2 shadow + 1 inline', doc.shadowRules.length === 2 && doc.rules.length === 1);
}
console.log(`\n${pass} passed, ${fail} failed`);
// 11. bare content: gets auto-quoted in the full-selector path (parity with nested)
{
const { doc, errors } = compile('.card::before { content: }');
check('11 no errors', errors.length === 0, JSON.stringify(errors));
const c = doc.shadowRules[0]?.decls.find(d => d.prop === 'content');
check('11 bare content auto-quoted', c && c.value === '""', c && c.value);
}
console.log(`${pass} passed, ${fail} failed`);
// 12. overlap policy - the "nothing below 100% opacity overlaps" feature
{
const { doc, errors } = compile(`
body { overlap: auto }
.badge { overlap: deny }
.scrim { overlap: allow }
.off { overlap: none }
`);
check('12 no errors', errors.length === 0, JSON.stringify(errors));
const get = (sel) => doc.rules.find(r => r.sel === sel)?.decls.find(d => d.prop === '@overlap')?.value;
check('12 body policy = auto', get('body') === 'auto', get('body'));
check('12 deny passes through', get('.badge') === 'deny', get('.badge'));
check('12 allow passes through', get('.scrim') === 'allow', get('.scrim'));
check('12 none is an alias of allow', get('.off') === 'allow', get('.off'));
const bad = compile('.card { overlap: never }');
check('12 bad policy is a LOUD error', bad.errors.length === 1 && /unknown overlap policy/.test(bad.errors[0].message), JSON.stringify(bad.errors));
check('12 hint names the vocabulary', /allow|deny|auto/.test(bad.errors[0].hint || ''));
const dead = compile('.card { overlap: nevr }');
check('12 nothing emitted on error', dead.doc.rules.length === 0);
// site.dssl now carries the global switch - regression-guard it
const fs = await import('node:fs');
const site = fs.readFileSync(new URL('../public/styles/site.dssl', import.meta.url).pathname, 'utf8');
check('12 site.dssl body opts in', /overlap:\s*auto/.test(site));
}
// 13. the escape math (overlapNudge) - pure function, no DOM needed
{
const { overlapNudge } = await import('../public/dssl/engine.js');
const box = (l, t, r, b) => ({ left: l, top: t, right: r, bottom: b });
// candidate [0,0,100,100] vs obstacle [50,0,150,100]: push left past the
// near edge costs 50, push right past the far edge costs 150 β left wins
let n = overlapNudge(box(0, 0, 100, 100), [box(50, 0, 150, 100)]);
check('13 shortest escape wins (left 50)', n.x === -50 && n.y === 0, JSON.stringify(n));
// deep obstacle above, 10px of y-penetration β dropping down 10 beats climbing 200
n = overlapNudge(box(0, 90, 200, 200), [box(0, 0, 200, 100)]);
check('13 vertical kiss resolves down 10', n.x === 0 && n.y === 10, JSON.stringify(n));
// no overlap β no nudge
n = overlapNudge(box(0, 0, 10, 10), [box(20, 20, 30, 30)]);
check('13 clear boxes stay put', n.x === 0 && n.y === 0, JSON.stringify(n));
// touching edges are NOT overlap (the shared-border tremble guard)
n = overlapNudge(box(0, 0, 100, 100), [box(100, 0, 200, 100)]);
check('13 touching edges do not count', n.x === 0 && n.y === 0, JSON.stringify(n));
// two walls with a real gap between them β nudge must land inside the gap
n = overlapNudge(box(70, 0, 90, 100), [box(0, 0, 80, 100), box(120, 0, 200, 100)]);
const cleared = (b, obs) => obs.every(o =>
Math.min(b.right, o.right) - Math.max(b.left, o.left) <= 0.01 ||
Math.min(b.bottom, o.bottom) - Math.max(b.top, o.top) <= 0.01);
const moved = box(70 + n.x, 0 + n.y, 90 + n.x, 100 + n.y);
check('13 box clears both walls into the gap', n.x === 10 && cleared(moved, [box(0, 0, 80, 100), box(120, 0, 200, 100)]), JSON.stringify({ n, moved }));
}
console.log(`${pass} passed, ${fail} failed`);
// 14. engine-native props inside pseudo blocks fail loud (no junk shadow CSS)
{
// 0.14.0: animate: on a pseudo-element is allowed now (it becomes a CSS animation); what is still engine-only fails loud
const a = compile('motion pop { from { opacity: 0 } to { opacity: 1 } }\n.card::before { content: "x"; animate: pop 400 }');
check('14 animate on a pseudo-element is allowed since 0.14 and becomes a CSS animation', a.errors.length === 0 && a.doc.shadowRules.length === 1 && a.doc.shadowRules[0].decls.some((d) => d.prop === '@animate'), JSON.stringify(a.errors));
check('14 an unknown motion there is still LOUD', compile('.card::before { content: "x"; animate: pop 400 }').errors.length === 1);
const b = compile('.card::before { overlap: deny }');
check('14 top-level overlap-in-pseudo is LOUD', b.errors.length === 1 && /engine-native/.test(b.errors[0].message), JSON.stringify(b.errors));
const c = compile('.card { ::after { --x: 1 } }');
check('14 custom props still pass in pseudo blocks', c.errors.length === 0 && c.doc.shadowRules.length === 1, JSON.stringify(c.errors));
}
console.log(`${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
|