File size: 13,806 Bytes
794b22f
c3fdf10
 
 
 
 
 
 
 
 
 
47b8a5f
c3fdf10
 
 
 
794b22f
c3fdf10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47b8a5f
c3fdf10
47b8a5f
c3fdf10
47b8a5f
c3fdf10
47b8a5f
c3fdf10
 
794b22f
c3fdf10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444a402
 
c3fdf10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
444a402
c3fdf10
 
 
 
 
 
47b8a5f
c3fdf10
 
 
 
 
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
/* test-v073.mjs - one assertion per fix in the 0.8.0 "the exit drop".
 * Source: the 2026-10-23 Claude review (5 bugs) + wishlist items 1/3/4/6/8.
 * Every claim was reproduced first (scripts/repro-v073.mjs), these assert the
 * fixes and the new features against the real compiler. */
import { parse } from '../public/dssl/parser.js';
import { resolve } from '../public/dssl/resolver.js';
import { toCSS } from '../public/dssl/static.js';
import { execFileSync } from 'node:child_process';
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { VERSION } from '../public/dssl/index.js';

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] };
};
const declsOf = (doc, i = 0) => doc.rules[i]?.decls || [];
const count = (decls, prop) => decls.filter(d => d.prop === prop).length;
const val = (decls, prop) => decls.find(d => d.prop === prop)?.value;
const lastVal = (decls, prop) => decls.filter(d => d.prop === prop).pop()?.value;

/* ---- bug 1: row/column axis roles ---- */
{
  const { doc, errors } = compileDoc('toolbar { row: gap 8 between center }');
  const d = declsOf(doc);
  ok('1a. between center: justify-content is space-between, written once', count(d, 'justify-content') === 1 && val(d, 'justify-content') === 'space-between');
  ok('1b. between center: align-items is center', val(d, 'align-items') === 'center');
  ok('1c. between center: zero errors', errors.length === 0);
  const w = compileDoc('toolbar { row: gap 8 center between }');
  ok('1d. center between: warns about the justify-content override', w.doc.warnings.some(x => /overrides "center"/.test(x.message)));
  ok('1e. center between: space-between still wins (later word, cascade takes last)', lastVal(declsOf(w.doc), 'justify-content') === 'space-between');
  const dupe = compileDoc('toolbar { row: center center }');
  ok('1f. center center: duplicate-word warning', dupe.doc.warnings.some(x => /written twice/.test(x.message)));
  const nowhere = compileDoc('toolbar { row: start end }');
  ok('1g. start end: "end" has nowhere to go, loudly', nowhere.doc.warnings.some(x => /nowhere to go/.test(x.message)));
  const stretch = compileDoc('toolbar { row: gap 8 stretch }');
  ok('1h. row: stretch sets align-items only (no justify-content filler)', count(declsOf(stretch.doc), 'align-items') === 1 && count(declsOf(stretch.doc), 'justify-content') === 0);
  const stack = compileDoc('.wrap { stack: center }');
  ok('1i. stack: center unchanged (place-items center center)', val(declsOf(stack.doc), 'place-items') === 'center center');
  const col = compileDoc('.list { column: gap 2 evenly stretch }');
  const cd = declsOf(col.doc);
  ok('1j. column evenly stretch: main space-evenly + cross stretch', val(cd, 'justify-content') === 'space-evenly' && val(cd, 'align-items') === 'stretch');
}

/* ---- bug 2: @font-face must not throw ---- */
{
  let threw = null, doc = null, errors = [];
  try {
    const r = compileDoc('@font-face { font-family: X; src: url(x.woff2) }');
    doc = r.doc; errors = r.errors;
  } catch (e) { threw = e; }
  ok('2a. @font-face does not throw', !threw, threw && threw.message);
  ok('2b. @font-face reports exactly one clean error', errors.length === 1 && /@font-face/.test(errors[0].message));
  ok('2c. @font-face inner decls leak nowhere', !doc.rules.some(r => r.decls?.some(d => d.prop === 'src' || d.prop === 'font-family')));
}

/* ---- bug 3: unknown at-rules skip their whole block ---- */
{
  const { doc, errors } = compileDoc('@supports (display:grid) { .a { display: grid } }\n.b { color: blue }');
  ok('3a. @supports inner rule does NOT apply unconditionally', !doc.rules.some(r => r.sel === 'a' || r.sel === '.a'));
  ok('3b. one clean error for @supports (no stray unexpected })', errors.length === 1 && /@supports/.test(errors[0].message));
  ok('3c. the rule after the block still parses', doc.rules.some(r => (r.sel || '').includes('b')));
  ok('3d. the hint points at raw { }', /raw \{ \}/.test(errors[0].hint || ''));
  const unclosed = compileDoc('@font-face { font-family: X');
  ok('3e. unclosed unknown block says so instead of eating the file', unclosed.errors.some(e => /never closed/.test(e.message)));
}

/* ---- bug 4: url() is atomic ---- */
{
  const { doc, errors } = compileDoc('.a { background: url(a.png) no-repeat }');
  ok('4a. unquoted url() + no-repeat: zero errors', errors.length === 0);
  ok('4b. value passes through untouched', val(declsOf(doc), 'background') === 'url(a.png) no-repeat');
  const q = compileDoc('.a { background: url("a.png") no-repeat center / cover }');
  ok('4c. quoted url + position/size also clean', q.errors.length === 0 && /url\("a\.png"\) no-repeat center \/ cover/.test(val(declsOf(q.doc), 'background')));
  const s = compileDoc('.a { background-image: url(img/hero.png), linear-gradient(90, red -> blue) }');
  ok('4d. unquoted url inside a list with a gradient: clean', s.errors.length === 0);
}

/* ---- bug 5a: animation gets motion guidance, not "span" ---- */
{
  const defined = compileDoc('motion spin { from { opacity: 0 } to { opacity: 1 } }\n.a { animation: spin 1s infinite }');
  ok('5a-1. animation with a defined motion: silent', defined.errors.length === 0 && defined.doc.warnings.length === 0);
  const missing = compileDoc('.a { animation: ghost 1s infinite }');
  ok('5a-2. animation with an undefined motion: warning names the DSSL way', missing.doc.warnings.some(w => /motion/.test(w.message) && /animate/.test(w.hint || '')));
  ok('5a-3. no "span" suggestion anywhere', !JSON.stringify(missing.errors).includes('span'));
  ok('5a-4. bare duration becomes ms, not px', /400ms/.test((() => { const r = compileDoc('.a { animation: ghost 400 }'); return val(declsOf(r.doc), 'animation'); })()));
  const animateHint = compileDoc('.a { animate: spin 400 }');
  ok('5a-5. unknown motion hint teaches the motion block', /motion spin \{/.test(animateHint.errors[0].hint || ''));
}

/* ---- bug 5b: !important is loud and NOT emitted ---- */
{
  const { doc, errors } = compileDoc('.a { color: red !important }');
  ok('5b-1. !important errors', errors.length === 1 && /!important does not exist/.test(errors[0].message));
  ok('5b-2. the error names pin { } as the answer', /pin \{ \}/.test(errors[0].hint || ''));
  ok('5b-3. nothing is emitted', declsOf(doc).length === 0);
  const quoted = compileDoc('.a { content: "!important" }');
  ok('5b-4. quoted "!important" is just text, no false trip', quoted.errors.length === 0 && val(declsOf(quoted.doc), 'content') === '"!important"');
}

/* ---- bug 5c: CSS transition form teaches the DSSL form ---- */
{
  const { errors } = compileDoc('.a { transition: color .2s ease }');
  ok('5c-1. CSS transition form errors (not silently dropped)', errors.length === 1);
  ok('5c-2. the hint shows the exact conversion', /transition: 200 ease color/.test(errors[0].hint || ''));
  const dssl = compileDoc('.a { transition: 200 ease color }');
  ok('5c-3. the DSSL form still compiles clean', dssl.errors.length === 0 && val(declsOf(dssl.doc), 'transition') === 'color 200ms ease');
}

/* ---- feature: raw { } / @css { } verbatim passthrough ---- */
{
  const one = compileDoc('raw { @font-face { font-family: X; src: url(x.woff2) } }');
  ok('6a. raw { } captured verbatim', one.doc.raws.length === 1 && one.doc.raws[0].css.includes('@font-face') && one.doc.raws[0].css.includes('url(x.woff2)'));
  const alias = compileDoc('@css { @keyframes x { from { opacity: 0 } } }');
  ok('6b. @css alias works', alias.doc.raws.length === 1 && alias.doc.raws[0].css.includes('@keyframes x'));
  const multi = compileDoc('raw {\n  @supports (display: grid) {\n    .a { display: grid }\n  }\n}\n.b { color: red }');
  ok('6c. multi-line raw captured whole, parsing resumes after', multi.doc.raws.length === 1 && multi.doc.raws[0].css.includes('@supports') && multi.doc.rules.some(r => (r.sel || '').includes('b')));
  const nested = compileDoc('.card { raw { a } }');
  ok('6d. nested raw is a loud error', nested.errors.some(e => /raw \{ \} must be top-level/.test(e.message)));
  const unclosed = compileDoc('raw { @font-face {');
  ok('6e. unclosed raw is a loud error', unclosed.errors.some(e => /raw \{ \} block was never closed/.test(e.message)));
}

/* ---- feature: fluid sugar ---- */
{
  const inline = compileDoc('.a { font-size: fluid 16 24 }');
  ok('7a. font-size: fluid 16 24 → clamp', val(declsOf(inline.doc), 'font-size') === 'clamp(16px, calc(12.571px + 0.952vw), 24px)');
  const bounds = compileDoc('.a { font-size: fluid 16 24 400 800 }');
  ok('7b. custom viewport bounds change the slope', val(declsOf(bounds.doc), 'font-size') === 'clamp(16px, calc(8px + 2vw), 24px)');
  const gap = compileDoc('.a { gap: fluid 8 16 }');
  ok('7c. gap takes inline fluid too', val(declsOf(gap.doc), 'gap') === 'clamp(8px, calc(4.571px + 0.952vw), 16px)');
  const legacy = compileDoc('.a { fluid: 16, 24 }');
  ok('7d. the fluid: 16, 24 form still works', val(declsOf(legacy.doc), 'font-size') === 'clamp(16px, calc(12.571px + 0.952vw), 24px)');
}

/* ---- feature: toCSS - the exit lane ---- */
{
  const src = `tokens { color { ink: #16161d } }
@layer utilities { .toolbar { row: gap 8 between center } }
.card {
  background: color.card
  hover { background: #f5f0e8 }
  seen { opacity: 1 }
  display: grid
  animate: rise 400
  ::before { content: "→" }
}
pin { .legal { color: red } }
@media dark { .card { background: #111 } }
@signal theme == dark { .card { background: #000 } }
motion rise { from { opacity: 0 } to { opacity: 1 } }
raw { @font-face { font-family: X; src: url(x.woff2) } }`;
  const { ast, errors } = parse(src, 'build.dssl');
  const doc = resolve(ast, 'build.dssl');
  const built = toCSS(doc, { file: 'build.dssl', banner: false });
  const unwhere = (s) => s.replace(/:where\(((?:[^()]|\([^()]*\))*)\)/g, '$1');   // built selectors are wrapped in :where() since 0.13.0
  const css = unwhere(built.css);
  ok('8a. @layer statement declares the engine order', css.includes('@layer tokens, base, components, utilities, pin;'));
  ok('8b. tokens become :root custom properties', css.includes('--dssl-color-ink: #16161d'));
  ok('8c. nested hover becomes .card:hover', /\.card:hover\s*\{[^}]*#f5f0e8/.test(css));
  ok('8d. seen applies unconditionally in the build', /\.card\s*\{[^}]*opacity: 1/.test(css) && !/\.card:seen/.test(css));
  ok('8e. ::before is native CSS in the build', /\.card::before\s*\{[^}]*content: "→"/.test(css));
  ok('8f. pin becomes !important', /\.legal\s*\{[^}]*color: red !important/.test(css));
  ok('8g. @media dark wraps the rule', css.includes('@media (prefers-color-scheme: dark)'));
  ok('8h. motion becomes @keyframes rise', css.includes('@keyframes rise'));
  ok('8i. raw { } lands verbatim', css.includes('@font-face { font-family: X; src: url(x.woff2) }'));
  ok('8i-2. animate-only decls are dropped, static ones kept', /\.card\s*\{[^}]*display: grid/.test(css) && !css.includes('@animate'));
  ok('8j. @signal rules are skipped and counted', built.report.skippedSignals === 1 && !css.includes('theme == dark'));
  ok('8k. report counts what shipped', built.report.rules >= 5 && built.report.keyframes === 1 && built.report.raws === 1 && built.report.seenStripped === 1);
}

/* ---- feature: the CLI ---- */
{
  const dir = mkdtempSync(join(tmpdir(), 'dssl-cli-'));
  const cleanFile = join(dir, 'clean.dssl');
  const badFile = join(dir, 'bad.dssl');
  const warnFile = join(dir, 'warn.dssl');
  writeFileSync(cleanFile, '.a { color: red\n  hover { color: blue } }\n');
  writeFileSync(badFile, '.a { color: red !important }\n');
  writeFileSync(warnFile, '.a { animation: ghost 400 }\n');
  const cli = join(import.meta.dirname, '..', 'cli', 'dssl.mjs');
  const run = (args) => {
    try { return { out: execFileSync('node', [cli, ...args], { encoding: 'utf8' }), code: 0 }; }
    catch (e) { return { out: (e.stdout || '') + (e.stderr || ''), code: e.status }; }
  };
  const c0 = run(['check', cleanFile]);
  ok('9a. check clean file exits 0', c0.code === 0);
  const c1 = run(['check', badFile]);
  ok('9b. check file with errors exits 1', c1.code === 1 && /!important/.test(c1.out));
  const cj = JSON.parse(run(['check', badFile, '--json']).out);
  ok('9c. check --json is machine-readable', cj.files[0].ok === false && cj.files[0].errors[0].line === 1);
  const cs = run(['check', warnFile, '--strict']);
  ok('9d. check --strict makes warnings fatal', cs.code === 1);
  const cw = run(['check', warnFile]);
  ok('9e. check without --strict: warnings are not fatal', cw.code === 0);
  const cssOut = join(dir, 'out.css');
  const b = run(['build', cleanFile, '-o', cssOut]);
  ok('9f. build writes a .css file and reports', b.code === 0 && /\.css/.test(b.out));
  const built = readFileSync(cssOut, 'utf8').replace(/:where\(((?:[^()]|\([^()]*\))*)\)/g, '$1');
  ok('9g. built CSS contains the hover rule', /\.a:hover\s*\{[^}]*blue/.test(built));
  const bj = JSON.parse(run(['build', cleanFile, '--json']).out);
  ok('9h. build --json carries css + report', typeof bj.css === 'string' && bj.report.rules >= 1);
  const missing = run(['check', join(dir, 'nope.dssl')]);
  ok('9i. missing file exits 2', missing.code === 2);
  const v = run(['--version']);
  ok('9j. --version prints the engine version', v.out.trim() === VERSION);
  rmSync(dir, { recursive: true, force: true });
}

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);