File size: 9,020 Bytes
5240484
794b22f
5240484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794b22f
5240484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794b22f
5240484
 
 
 
 
 
 
 
794b22f
 
 
 
5240484
 
 
 
 
794b22f
5240484
 
794b22f
5240484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794b22f
5240484
 
 
 
794b22f
5240484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794b22f
5240484
 
794b22f
5240484
 
 
 
 
 
0ade7b1
5240484
 
 
 
 
 
 
 
794b22f
5240484
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/* =========================================================================
 * DSSL v0.7.0 - speed (gates + freeze), selectors, warnings, numeric keys
 * Run: node scripts/test-v070.mjs
 * ========================================================================= */

import { parse } from '../public/dssl/parser.js';
import { resolve, DEFAULT_TOKENS } from '../public/dssl/resolver.js';
import { gateFor, gatePass } from '../public/dssl/engine.js';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
let pass = 0, fail = 0;
const ok = (cond, label) => {
  if (cond) { pass++; }
  else { fail++; console.error('  FAIL: ' + label); }
};

const compile = (src, file = 'test.dssl') => {
  const { ast, errors } = parse(src, file);
  const doc = resolve(ast, file, Object.fromEntries(new Map(Object.entries(DEFAULT_TOKENS))));
  return { ...doc, errors: [...errors, ...doc.errors] };
};

console.log('1 - numeric token keys');
{
  const doc = compile('tokens { space { 9: 96 } }\n.a { padding: space.9 }');
  ok(doc.errors.length === 0, 'numeric token key compiles clean: ' + JSON.stringify(doc.errors));
  ok(doc.tokens['space.9'] === '96', 'token space.9 stored');
  const reg = doc.registryRule && doc.registryRule.decls.find(d => d.prop === '--dssl-space-9');
  ok(reg && reg.value === '96px', 'registry emits --dssl-space-9: 96px, got ' + JSON.stringify(reg));
  const rule = doc.rules.find(r => r.sel === '.a');
  ok(rule && rule.decls.some(d => d.prop === 'padding' && d.value.includes('var(--dssl-space-9)')), 'space.9 reference resolves to the registry var');

  const doc2 = compile('tokens { color { 5: #ff0000 } }\n.b { color: color.5 }');
  ok(doc2.errors.length === 0, 'numeric color token key compiles');
  ok(doc2.tokens['color.5'] === '#ff0000', 'color.5 stored');

  const bad = compile('.card { 9: 96 }');
  ok(bad.errors.length === 1, 'numeric prop outside tokens errors');
  ok((bad.errors[0].hint || '').includes('tokens'), 'the error hint says where numbers ARE legal');

  const dec = compile('tokens { space { 12: 160 } }\n.c { margin: space.12 }');
  ok(dec.errors.length === 0 && dec.tokens['space.12'] === '160', 'multi-digit keys work');
}

console.log('2 - display ordering linter');
{
  const w = (src) => compile(src).warnings || [];
  const nav = w('.nav-links { display: none; column: gap space.2 }');
  ok(nav.length === 1, 'critic case warns exactly once, got ' + nav.length);
  ok(nav[0] && nav[0].message.includes('"column:"'), 'warning names the shorthand');
  ok(nav[0] && nav[0].message.includes('display: none'), 'warning names the dead line');
  ok(nav[0] && nav[0].line === 1, 'warning points at the shorthand line');

  ok(w('.nav-links { column: gap 2; display: none }').length === 0, 'explicit display after the shorthand is intentional - silent');
  ok(w('.nav-links { display: none } .nav-links.open { column: gap 2 }').length === 0, 'cross-rule overrides are the cascade - silent');
  ok(w('.x { display: flex; row: center }').length === 0, 'shorthand that agrees with the explicit display - silent');
  ok(w('.x { display: none; display: flex }').length === 0, 'explicit vs explicit is the documented model - silent');
  ok(w('.menu { display: none; column: gap 2 } .other { display: block; stack: center }').length === 2, 'grid:/stack: expansions warn too');
  ok(w('.x { column: gap 2; display: none }').length === 0, 'reverse order never warns (state-toggle pattern)');

  // warning does not block compilation and does not leak into errors
  const doc = compile('.a { display: none; column: gap 2 }');
  ok(doc.errors.length === 0, 'warnings are not errors - ok stays ok');
}

console.log('3 - structural selectors compile and ride native matching');
{
  const doc = compile(`
.card:nth-child(2) { color: red }
.b:not(.x) { color: blue }
.c:is(.d, .e) { color: green }
.f:where(.g) { color: teal }
.h:has(> img) { color: gold }
.list { .item:nth-child(odd) { color: pink } }
.a:not(.b, .c) { color: gray }
`);
  ok(doc.errors.length === 0, 'nth-child/not/is/where/has all compile clean: ' + JSON.stringify(doc.errors));
  const sels = doc.rules.map(r => r.sel);
  ok(sels.includes('.card:nth-child(2)'), 'nth-child kept verbatim');
  ok(sels.includes('.b:not(.x)'), 'not kept verbatim');
  ok(sels.includes('.c:is(.d, .e)'), 'is kept verbatim');
  ok(sels.includes('.f:where(.g)'), 'where kept verbatim');
  ok(sels.includes('.h:has(> img)'), 'has kept verbatim');
  ok(sels.includes('.list .item:nth-child(odd)'), 'nested + nth-child combines');
}

console.log('4 - gateFor: the cheap necessary conditions');
{
  const g = (sel) => gateFor(sel);
  ok(g('.a') !== null && g('.a')[0].classes.includes('a'), 'single class gate');
  ok(g('.a.b:hover')[0].classes.join(',') === 'a,b', 'multi-class compound keeps both');
  ok(g('div span')[0].tag === 'SPAN', 'rightmost compound is the gate - tag only');
  ok(g('div > .item')[0].classes.includes('item') && g('div > .item')[0].tag === null, 'child combinator gates the right side');
  ok(g('#hero.x')[0].id === 'hero' && g('#hero.x')[0].classes.includes('x'), 'id + class');
  ok(g('li:nth-child(2)')[0].tag === 'LI', 'nth-child: parens stripped, tag kept');
  ok(JSON.stringify(g('.a:not(.b)')[0].classes) === '["a"]', 'not(): inner classes are NOT requirements');
  ok(g(':is(.a,.b) span')[0].tag === 'SPAN', 'is() head does not poison the rightmost gate');
  ok(g('.h:has(> img)')[0].classes.includes('h'), 'has(): parens stripped, own class kept');
  ok(g('.a, .b') !== null && g('.a, .b').length === 2, 'comma list gates each part');
  ok(gatePass({ tagName: 'DIV', id: '', classList: { contains: (c) => c === 'a' } }, g('.a')), 'gatePass says yes for a real candidate');
  ok(!gatePass({ tagName: 'P', id: '', classList: { contains: () => false } }, g('.a')), 'gatePass says no for a non-candidate');

  // things the gate must NOT pretend to understand
  ok(g('[data-x]') === null, 'attribute selectors bail');
  ok(g('.a[title="hi"]') === null, 'attr inside compound bails');
  ok(g('*') === null, 'universal bails');
  ok(g(':root') === null, 'root bails');
  ok(g(':is(.a, .b)') === null, 'all parts unfilterable → null (no filter), not a fake gate');
  ok(g(':is(.a,.b), span') !== null && g(':is(.a,.b), span').includes(null) && g(':is(.a,.b), span').some(g => g && g.tag === 'SPAN'), 'mixed: unfilterable part stays null, tag part still gates');
  ok(g('.a:hover, span') !== null, 'mixed list with a tag part still gates');
  ok(gatePass({ tagName: 'SPAN', id: '', classList: { contains: () => false } }, g('.a:hover, span')), 'OR semantics: span part passes → rule stays a candidate');
  ok(gatePass({ tagName: 'SPAN', id: '', classList: { contains: () => true } }, g('.a:hover, span')), 'class part passes → candidate');
  ok(g('.a[hidden]') === null, 'brackets always bail');
  ok(g('section .card.x') !== null && g('section .card.x')[0].classes.join(',') === 'card,x', 'deep descendant gates the leaf');
}

console.log('5 - freeze/unfreeze/explain exist as API surface');
{
  const eng = await import('../public/dssl/index.js').catch(() => null);
  // index.js touches window at import - in node it skips the boot branch but
  // the exports still exist. If the import failed entirely, report it.
  ok(eng !== null, 'index.js imports cleanly in node (no syntax errors)');
  if (eng) {
    ok(typeof eng.freeze === 'function', 'freeze exported');
    ok(typeof eng.unfreeze === 'function', 'unfreeze exported');
    ok(typeof eng.explain === 'function', 'explain exported');
    ok(parseInt(String(eng.VERSION).split('.')[1]) >= 7, 'VERSION is 0.7.x or newer');
    ok(typeof eng.DSSL.freeze === 'function' && typeof eng.DSSL.explain === 'function', 'DSSL object carries the new API');
  }
  const engMod = await import('../public/dssl/engine.js');
  ok(typeof engMod.DsslEngine === 'function', 'engine class imports cleanly');
  const r = compile('.a { color: red }');
  ok(Array.isArray(r.warnings), 'resolve() returns a warnings array');
}

console.log('6 - the six site stylesheets still compile (registry chained)');
{
  const files = ['tokens.dssl', 'site.dssl', 'home.dssl', 'docs.dssl', 'playground.dssl', 'changelog.dssl'];
  const { readFileSync } = await import('node:fs');
  const registry = new Map(Object.entries(DEFAULT_TOKENS));
  let totalRules = 0, allErrors = [];
  for (const f of files) {
    const src = readFileSync(join(ROOT, 'public/styles', f), 'utf8');
    const { ast, errors } = parse(src, f);
    const doc = resolve(ast, f, Object.fromEntries(registry));
    for (const [k, v] of Object.entries(doc.tokens)) registry.set(k, v);
    allErrors.push(...doc.errors);
    totalRules += doc.rules.length + doc.shadowRules.length;
  }
  ok(allErrors.length === 0, '6 sheets compile 0 errors: ' + JSON.stringify(allErrors.slice(0, 3)));
  ok(totalRules > 200, 'rule volume sane: ' + totalRules);
}

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