File size: 10,575 Bytes
47b8a5f
 
 
 
 
 
 
 
 
 
 
 
 
 
39309b1
47b8a5f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39309b1
 
47b8a5f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/* 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);