Download scripts/test-v0160.mjs from EHS-Studio/dssl: direct link, hf CLI and curl.
- Browser
- Download file 27.2 kB
-
https://huggingface.co/EHS-Studio/dssl/resolve/main/scripts/test-v0160.mjs
- Command line
-
hf download hf://EHS-Studio/dssl/scripts/test-v0160.mjs
-
curl -L -o test-v0160.mjs https://huggingface.co/EHS-Studio/dssl/resolve/main/scripts/test-v0160.mjs
27.2 kB
| /* test-v0160.mjs: the third round of feedback from the game builder (0.16.0): plain CSS next to DSSL, valid CSS that was refused, | |
| * a token error that pointed nowhere, an aura that lit the box instead of the shape. Plus the new aura types and textures. | |
| * Real Chrome decides anything that is drawn. */ | |
| import { spawnSync } from 'node:child_process'; | |
| import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs'; | |
| import { tmpdir } from 'node:os'; | |
| import path from 'node:path'; | |
| import { compile } from '../public/dssl/index.js'; | |
| import { parseAura, aroundPoints, AROUND_NAMES, SHAPES } from '../public/dssl/aura.js'; | |
| import { PRESET_NAMES, PRESETS, GENERATORS, buildTexture, parseTexture } from '../public/dssl/texture.js'; | |
| import { parseColor } from '../public/dssl/color.js'; | |
| import { withBrowser, ROOT } from './lib/browser-harness.mjs'; | |
| import { decodePng, px } from './lib/pixels.mjs'; | |
| let pass = 0, fail = 0; | |
| const ok = (name, cond, extra) => { if (cond) { pass++; console.log(` ok ${name}`); } else { fail++; console.log(`FAIL ${name}${extra !== undefined ? '; ' + extra : ''}`); } }; | |
| const section = (t) => console.log(`\n== ${t}`); | |
| const page = (dssl, body = '', head = '', css = '') => `<!doctype html><meta charset=utf-8><body style="margin:0;background:#000;height:3000px">${head}${css ? `<style>${css}</style>` : ''}<style type="text/dssl">${dssl}</style>${body}<script type="module" src="/dssl/boot.js"></script>`; | |
| const ready = (p) => p.waitForFunction(`window.DSSL && DSSL.__debug.engines.get('global') && document.documentElement.classList.contains('dssl-ready')`); | |
| const errs = (src) => compile(src, 't.dssl').errors; | |
| const rules = (src) => compile(src, 't.dssl').doc.rules; | |
| const decl = (src, prop) => { for (const r of rules(src)) for (const d of r.decls) if (d.prop === prop) return d.value; return undefined; }; | |
| const lit = (c) => c[0] > 40 && c[0] > c[2] * 1.5; // red-ish light on black | |
| section('1. a token line with two tokens, and an error that says where'); | |
| { | |
| const r = compile('tokens { radius { pill: 999 card: 22 } }\n.a { radius: card }', 't.dssl'); | |
| ok('radius { pill: 999 card: 22 } on one line is two tokens', r.errors.length === 0 && decl('tokens { radius { pill: 999 card: 22 } }', '--dssl-radius-pill') === '999px' && decl('tokens { radius { pill: 999 card: 22 } }', '--dssl-radius-card') === '22px', JSON.stringify(r.errors)); | |
| ok('and the second one can be used', r.doc.rules.some((x) => x.decls.some((d) => d.prop === 'border-radius' && d.value === 'var(--dssl-radius-card)'))); | |
| ok('semicolons still work', errs('tokens { radius { a: 1; b: 2 } }').length === 0 && decl('tokens { radius { a: 1; b: 2 } }', '--dssl-radius-b') === '2px'); | |
| ok('several lines, several colours on one line', errs('tokens { color { a: #111 b: oklch(60% 0.2 30) c: #333 } }').length === 0 && decl('tokens { color { a: #111 b: oklch(60% 0.2 30) c: #333 } }', '--dssl-color-c') === '#333'); | |
| ok('a font stack with commas and quotes stays one token', decl('tokens { font { body: "Fraunces", Georgia, serif } }', '--dssl-font-body') === '"Fraunces", Georgia, serif'); | |
| const bad = compile('tokens {\n color {\n x: nonsense\n y: #fff\n }\n}\n', 't.dssl').errors; | |
| ok('a bad token points at its own line (it said line 0), once', bad.length === 1 && bad[0].line === 3 && /nonsense/.test(bad[0].message), JSON.stringify(bad)); | |
| const two = compile('tokens { color { ok: #111 bad: nonsense } }', 't.dssl').errors; | |
| ok('on one line it names the right token', two.length === 1 && /color\.bad/.test(two[0].message), JSON.stringify(two)); | |
| } | |
| section('2. valid CSS is not refused'); | |
| { | |
| const good = [ | |
| '.a { padding-top: env(safe-area-inset-top) }', | |
| '.a { padding: env(safe-area-inset-top, 20) env(safe-area-inset-right, 0) max(env(safe-area-inset-bottom), 12) 0 }', | |
| '.a { font-variant-numeric: tabular-nums }', | |
| '.a { font-variant-numeric: lining-nums slashed-zero; font-variant-caps: all-small-caps; font-variant-ligatures: no-common-ligatures }', | |
| '.a { touch-action: manipulation; overscroll-behavior: contain; scroll-snap-type: x mandatory; scroll-snap-align: start }', | |
| '.a { contain: layout paint; content-visibility: auto; text-decoration-thickness: from-font; writing-mode: vertical-rl }', | |
| '.a { text-wrap: balance; hyphens: auto; user-select: none; accent-color: auto; mix-blend-mode: multiply; image-rendering: pixelated }', | |
| '.a { direction: rtl; caption-side: bottom; max-block-size: 10; text-size-adjust: 100%; widows: 2; orphans: 3 }', | |
| '.a { -webkit-user-select: none; -webkit-touch-callout: none; -webkit-overflow-scrolling: touch; -webkit-appearance: none; -moz-appearance: none }', | |
| '.a { background: rebeccapurple; color: darkslateblue; border-color: lightgoldenrodyellow }', | |
| '.a { font-feature-settings: "tnum" 1, "liga" 0; font-variation-settings: "wght" 650; tab-size: 4; -webkit-line-clamp: 3 }', | |
| ]; | |
| for (const src of good) ok(`accepted: ${src.slice(0, 70)}`, errs(src).length === 0, JSON.stringify(errs(src).map((e) => e.message))); | |
| ok('env() keeps the name and gives the fallback a unit', decl('.a { padding-top: env(safe-area-inset-top, 20) }', 'padding-top') === 'env(safe-area-inset-top, 20px)'); | |
| ok('a tabular figure feature number is not turned into px', decl('.a { font-feature-settings: "tnum" 1 }', 'font-feature-settings') === '"tnum" 1'); | |
| ok('a typo is still loud, with did-you-mean', errs('.a { text-align: cetner }').some((e) => /cetner/.test(e.message) && /center/.test(e.hint || ''))); | |
| ok('a property typo is still loud', errs('.a { colour: red }').length === 1 && errs('.a { max-heigth: 1 }').length === 1); | |
| ok('so is a made-up value for a real property', errs('.a { display: flexx }').length === 1); | |
| } | |
| section('3. yield: plain CSS wins where you say so'); | |
| await withBrowser(async ({ open, errors }) => { | |
| const p = await open('/y'); await ready(p); await p.waitForTimeout(250); | |
| const cs = (id, prop) => p.evaluate(([i, pr]) => getComputedStyle(document.getElementById(i))[pr], [id, prop]); | |
| ok('without yield, DSSL\'s inline padding beats the stylesheet (the friction)', await cs('plain', 'paddingTop') === '10px'); | |
| ok('with yield: padding-top the stylesheet decides, and no !important is needed', await cs('one', 'paddingTop') === '33px'); | |
| ok('the other sides are still DSSL\'s (the shorthand was split, not dropped)', await cs('one', 'paddingRight') === '12px' && await cs('one', 'paddingBottom') === '14px' && await cs('one', 'paddingLeft') === '16px'); | |
| ok('yield: padding hands over the whole family', await cs('fam', 'paddingTop') === '4px' && await cs('fam', 'paddingLeft') === '5px'); | |
| ok('env() works in plain CSS beside DSSL (a desktop has no notch, so it is 0px, and not the 7px DSSL would have written)', await cs('env', 'paddingBottom') === '0px'); | |
| ok('what is not yielded is still DSSL\'s', await cs('one', 'marginTop') === '9px'); | |
| ok('yield: none gives it back to DSSL', await cs('none', 'paddingTop') === '10px'); | |
| ok('a longhand a rule also yields is dropped from the cascade, whichever rule wrote it', await cs('late', 'paddingTop') === '33px' && await cs('late', 'color') === 'rgb(0, 0, 255)'); | |
| await p.evaluate(() => document.getElementById('late').classList.add('off')); await p.waitForTimeout(150); | |
| ok('and when the yielding rule stops matching DSSL takes the property back', await cs('late', 'paddingTop') === '10px'); | |
| const found = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).filter((f) => f.kind === 'css-loses').map((f) => f.selector + ' | ' + f.message)); | |
| ok('DSSL.lint names the CSS rule that loses, and the way out', found.some((m) => /^div#plain/.test(m) && /padding-top/.test(m) && /yield: padding-top/.test(m)), JSON.stringify(found)); | |
| ok('and says nothing about the ones that yield', !found.some((m) => /#one|#fam|#env/.test(m)), JSON.stringify(found)); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/y': page( | |
| '#plain { padding: 10 }\n#one { padding: 10 12 14 16; margin-top: 9; yield: padding-top }\n#fam { padding: 3; yield: padding }\n#env { padding-bottom: 7; yield: padding-bottom }\n#none { padding: 10; yield: padding-top; yield: none }\n#late { padding: 10; color: #00f }\n#late:not(.off) { yield: padding-top }', | |
| '<div id=plain></div><div id=one></div><div id=fam></div><div id=env></div><div id=none></div><div id=late></div>', '', | |
| '#plain { padding-top: 40px } #one { padding-top: 33px } #fam { padding-top: 4px; padding-left: 5px } #env { padding-bottom: env(safe-area-inset-bottom, 21px) } #none { padding-top: 50px } #late { padding-top: 33px }') } }); | |
| { | |
| ok('yield needs properties', errs('.a { yield: }').length === 1); | |
| ok('yield checks the names (did-you-mean)', errs('.a { yield: pading-top }').some((e) => /pading-top/.test(e.message) && /padding-top/.test(e.hint || ''))); | |
| const dir = mkdtempSync(path.join(tmpdir(), 'dssl-yield-')); | |
| writeFileSync(path.join(dir, 'a.dssl'), '.app { padding: 10; yield: padding-top }\n'); | |
| const r = spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), 'build', path.join(dir, 'a.dssl'), '-o', path.join(dir, 'a.css')], { encoding: 'utf8' }); | |
| const css = readFileSync(path.join(dir, 'a.css'), 'utf8'); | |
| ok('dssl build writes the padding and does not count or print yield (a stylesheet has nothing to hand back)', r.status === 0 && /padding: 10px/.test(css) && !/yield/.test(css) && !/engine-native/.test(r.stdout), r.stdout + r.stderr); | |
| } | |
| { | |
| // a page that ships `dssl build` output AND runs the engine says every rule twice: that is not a conflict | |
| const built = (await import('../public/dssl/index.js')).toCSS('.dup { padding: 10; color: #123456 }', { file: 'a.dssl' }).css; | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/b'); await ready(p); await p.waitForTimeout(250); | |
| const n = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).filter((f) => f.kind === 'css-loses').length); | |
| ok('compiled CSS and the engine saying the same thing is not reported as css-loses', n === 0, String(n)); | |
| }, { pages: { '/b': page('.dup { padding: 10; color: #123456 }', '<div class=dup></div>', '', built) } }); | |
| // and the built reset sits under everything on purpose: it must not be called a conflict either | |
| const builtReset = (await import('../public/dssl/index.js')).toCSS('.dup { padding: 10; color: #123456 }', { file: 'a.dssl', reset: true }).css; | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/b2'); await ready(p); await p.waitForTimeout(250); | |
| const n = await p.evaluate(async () => (await DSSL.lint(document.body, { log: false })).filter((f) => f.kind === 'css-loses').length); | |
| ok('the reset layer of a built stylesheet is not called a conflict', n === 0, String(n)); | |
| }, { pages: { '/b2': page('p { margin: 7 }\n.dup { padding: 10; color: #123456 }', '<p class=dup>x</p><a href=#>a</a>', '', builtReset) } }); | |
| } | |
| section('4. aura follows the real shape'); | |
| { | |
| const around = (w) => { const out = parseAura('aura-glow', `#f00 30 around ${w}`, { error() { }, color: (c) => c, suggest: () => null }); return out && out.glow && out.glow[0] && out.glow[0].around; }; | |
| ok('every named shape parses', AROUND_NAMES.every((n) => around(n) && around(n).name === n)); | |
| ok('polygon(...) parses to fractions', JSON.stringify(around('polygon(50% 0, 100% 100%, 0 100%)')) === '{"name":"polygon","pts":[[0.5,0],[1,1],[0,1]]}'); | |
| ok('the named polygons really are polygons', ['diamond', 'triangle', 'hexagon', 'octagon', 'pentagon', 'star', 'star4', 'star6', 'cross', 'heart', 'drop', 'bolt'].every((n) => (aroundPoints({ name: n }) || []).length >= 3)); | |
| const e = []; | |
| parseAura('aura-glow', '#f00 30 around blob', { error: (m, h) => e.push(m + ' | ' + h), color: (c) => c, suggest: () => 'box' }); | |
| ok('an unknown shape is an error that lists the choices', e.length === 1 && /unknown around "blob"/.test(e[0])); | |
| ok('a polygon with two points is an error', errs('.a { aura-glow: #f00 30 around polygon(0 0, 100% 100%) }').length === 1); | |
| ok('a polygon in px is an error that says percent', errs('.a { aura-glow: #f00 30 around polygon(0px 0px, 10px 10px, 0px 10px) }').some((x) => /percent/.test(x.message))); | |
| const around2 = '.o { position: absolute; left: 150px; top: 150px; width: 100px; height: 100px; background: #222; aura-glow: #f00 40 essential intensity 1.6 }'; | |
| const shot = async (dssl, clip = { x: 0, y: 0, width: 420, height: 420 }) => { | |
| let png; | |
| await withBrowser(async ({ open }) => { const p = await open('/a'); await ready(p); await p.waitForTimeout(1100); png = decodePng(await p.screenshot({ clip })); }, { pages: { '/a': page(dssl, '<div class=o></div>') }, viewport: { width: 420, height: 420 } }); | |
| return png; | |
| }; | |
| // 4a. clip-path diamond | |
| const dia = await shot(`${around2}\n.o { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%) }`); | |
| const boxed = await shot(`${around2}\n.o { clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); aura-glow: #f00 40 essential intensity 1.6 around box }`); | |
| ok('a clip-path diamond: light just above its top tip', lit(px(dia, 200, 142))); | |
| ok('a clip-path diamond: nothing out at the box corner (it used to be a square halo)', !lit(px(dia, 140, 140)) && !lit(px(dia, 260, 140)) && !lit(px(dia, 140, 260)), JSON.stringify([px(dia, 140, 140), px(dia, 260, 260)])); | |
| ok('`around box` still lights the box on purpose', lit(px(boxed, 146, 146)), JSON.stringify(px(boxed, 146, 146))); | |
| // 4b. a square turned 45 degrees | |
| const rot = await shot('.o { position: absolute; left: 165px; top: 165px; width: 70px; height: 70px; background: #222; rotate: 45deg; aura-glow: #f00 40 essential intensity 1.6 }'); | |
| ok('a square turned 45deg is lit around the diamond: above its top tip', lit(px(rot, 200, 200 - 49 - 8)), JSON.stringify(px(rot, 200, 143))); | |
| ok('and not at the corner of the box around it', !lit(px(rot, 200 - 56, 200 - 56)) && !lit(px(rot, 200 + 56, 200 + 56)), JSON.stringify([px(rot, 144, 144), px(rot, 256, 256)])); | |
| // 4c. round | |
| const rnd = await shot(`${around2}\n.o { border-radius: 50% }`); | |
| ok('border-radius: 50% follows the circle (it used to read 50 as 50px)', lit(px(rnd, 200, 142)) && !lit(px(rnd, 144, 144)), JSON.stringify([px(rnd, 200, 142), px(rnd, 144, 144)])); | |
| // 4d. named star around a plain square | |
| const star = await shot(`${around2}\n.o { aura-glow: #f00 30 essential intensity 2.6 around star; background: transparent }`); | |
| ok('around star on a plain box: light follows the star, so the corner between two points is dark', lit(px(star, 200, 150 - 3)) && !lit(px(star, 150 - 6, 150 - 6)), JSON.stringify([px(star, 200, 147), px(star, 144, 144)])); | |
| // 4e. inner glow | |
| const inner = await shot(`${around2}\n.o { border-radius: 20px; aura-glow: #f00 30 essential inner intensity 2.4 }`); | |
| ok('inner: light just inside the edge', lit(px(inner, 154, 200)) || lit(px(inner, 200, 154)), JSON.stringify([px(inner, 154, 200), px(inner, 200, 154)])); | |
| ok('inner: nothing outside, and the middle is dark', !lit(px(inner, 140, 200)) && px(inner, 200, 200)[0] < 70, JSON.stringify([px(inner, 140, 200), px(inner, 200, 200)])); | |
| // 4f. rays | |
| const rays = await shot('.o { position: absolute; left: 180px; top: 180px; width: 40px; height: 40px; background: #222; aura-glow: #f00 70 essential rays 8 spin 0 nohalo intensity 2 }'); | |
| ok('rays: lit along a ray, dark between two', lit(px(rays, 200 + 50, 200)) && lit(px(rays, 200, 200 - 50)) && !lit(px(rays, 200 + Math.round(50 * Math.cos(Math.PI / 8)), 200 + Math.round(50 * Math.sin(Math.PI / 8)))), JSON.stringify([px(rays, 250, 200), px(rays, 246, 219)])); | |
| ok('rays: not over the element itself', px(rays, 200, 200)[0] < 70, JSON.stringify(px(rays, 200, 200))); | |
| // 4g. ripple | |
| const rip = await shot('.o { position: absolute; left: 160px; top: 160px; width: 80px; height: 80px; background: #222; border-radius: 50%; aura-glow: #f00 60 essential ripple 4000 around round nohalo intensity 2 }'); | |
| let ringPx = 0; for (let x = 245; x < 300; x++) if (lit(px(rip, x, 200))) ringPx++; | |
| ok('ripple: a ring outside the shape', ringPx >= 1, String(ringPx)); | |
| // 4h. alpha of an image | |
| let alpha; | |
| await withBrowser(async ({ open }) => { | |
| const p = await open('/i'); await ready(p); | |
| await p.evaluate(() => { const c = document.createElement('canvas'); c.width = c.height = 100; const x = c.getContext('2d'); x.fillStyle = '#336'; x.beginPath(); x.arc(50, 50, 38, 0, Math.PI * 2); x.fill(); const im = document.getElementById('im'); im.src = c.toDataURL(); return im.decode(); }); | |
| await p.evaluate(() => DSSL.refresh()); await p.waitForTimeout(1100); | |
| alpha = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 420, height: 420 } })); | |
| }, { pages: { '/i': page('#im { position: absolute; left: 150px; top: 150px; width: 100px; height: 100px; aura-glow: #f00 25 essential around alpha intensity 2 }', '<img id=im width=100 height=100>') }, viewport: { width: 420, height: 420 } }); | |
| ok('around alpha: lit just outside the picture\'s circle', lit(px(alpha, 200, 200 - 38 - 6)), JSON.stringify(px(alpha, 200, 156))); | |
| ok('around alpha: not at the empty corner of the picture', !lit(px(alpha, 154, 154)) && !lit(px(alpha, 246, 246)), JSON.stringify([px(alpha, 154, 154), px(alpha, 246, 246)])); | |
| } | |
| section('5. more aura: particles that follow the outline, new shapes, palette, orbit, wind'); | |
| { | |
| ok('every particle shape is a known word', ['heart', 'diamond', 'triangle', 'plus', 'drop', 'bubble', 'snowflake', 'confetti', 'leaf'].every((n) => SHAPES.includes(n)) && SHAPES.every((n) => errs(`.a { aura-particle: #fff 10 shape ${n} }`).length === 0)); | |
| ok('from outline, palette, spin, wind, orbit are known', errs('.a { aura-particle: #f00 #0f0 #00f count 20 from outline palette spin 90 wind 20 orbit 45 around diamond }').length === 0); | |
| ok('inner, rays, ripple, nohalo, spin and around are known on a glow', errs('.a { aura-glow: #fc4 40 inner rays 8 spin 20 ripple 1500 nohalo around hexagon }').length === 0); | |
| await withBrowser(async ({ open, errors }) => { | |
| const p = await open('/o'); await ready(p); await p.waitForTimeout(900); | |
| const samples = await p.evaluate(() => { const a = DSSL.__debug.engines.get('global').aura.debug(); const e = a.emitters.find((x) => x.el.id === 'dia'); const r = e.el.getBoundingClientRect(); return { cx: r.left + r.width / 2, cy: r.top + r.height / 2, hw: r.width / 2, hh: r.height / 2, pts: e.layers[0].sample }; }); | |
| const dev = samples.pts.map((q) => Math.abs((q.x - samples.cx) / samples.hw) + Math.abs((q.y - samples.cy) / samples.hh)); | |
| ok('from outline on a diamond: particles are born on the diamond\'s edges (|x|/hw + |y|/hh is 1), not on the box', dev.length >= 3 && dev.every((d) => Math.abs(d - 1) < 0.12), JSON.stringify(dev)); | |
| const orb = await p.evaluate(async () => { | |
| const a = DSSL.__debug.engines.get('global').aura; | |
| const e = () => a.debug().emitters.find((x) => x.el.id === 'orb'); | |
| const cen = document.getElementById('orb').getBoundingClientRect(); | |
| const c = { x: cen.left + cen.width / 2, y: cen.top + cen.height / 2 }; | |
| const ang = (q) => Math.atan2(q.y - c.y, q.x - c.x) * 180 / Math.PI; | |
| const first = e().layers[0].sample[0]; if (!first) return null; | |
| const a0 = ang(first), age0 = first.age; | |
| await new Promise((r) => setTimeout(r, 400)); | |
| const now = e().layers[0].sample.find((q) => Math.abs(q.age - age0 - 0.4) < 0.25 && Math.hypot(q.x - c.x, q.y - c.y) > 5); | |
| return { turned: now ? ((ang(now) - a0 + 540) % 360) - 180 : null }; | |
| }); | |
| ok('orbit 90 turns particles about 36 degrees in 0.4s around the middle (some frames are skipped, so a range)', orb && orb.turned !== null && orb.turned > 15 && orb.turned < 60, JSON.stringify(orb)); | |
| const wind = await p.evaluate(() => { const a = DSSL.__debug.engines.get('global').aura.debug(); const e = a.emitters.find((x) => x.el.id === 'wind'); const r = e.el.getBoundingClientRect(); const s = e.layers[0].sample.filter((q) => q.age > 0.3); return s.length ? s.reduce((m, q) => m + (q.x - (r.left + r.width / 2)), 0) / s.length : null; }); | |
| ok('wind pushes particles sideways (to the right for a positive number)', wind !== null && wind > 20, String(wind)); | |
| const pcs = await p.evaluate(() => new Set(DSSL.__debug.engines.get('global').aura.emitters.get(document.getElementById('conf')).layers[0].parts.map((q) => q.pc % 3)).size); | |
| ok('palette: each particle takes one of the colours (all three are used)', pcs === 3, String(pcs)); | |
| const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 700, height: 300 } })); | |
| let reds = 0, greens = 0, blues = 0; | |
| for (let y = 0; y < 300; y += 1) for (let x = 440; x < 700; x += 1) { const c = px(png, x, y); if (c[0] > 150 && c[1] < 90 && c[2] < 90) reds++; if (c[1] > 150 && c[0] < 90 && c[2] < 90) greens++; if (c[2] > 150 && c[0] < 90 && c[1] < 90) blues++; } | |
| ok('and the confetti really is drawn in all three colours', reds > 5 && greens > 5 && blues > 5, `${reds}/${greens}/${blues}`); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/o': page( | |
| '.s { position: absolute; top: 100px; width: 60px; height: 60px; background: #222 }\n#dia { left: 40px; clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); aura-particle: #ff5 count 30 life 8000 size 4 speed 0 from outline essential }\n#orb { left: 160px; aura-particle: #7cf count 12 life 6000 size 4 speed 0 orbit 90 from outline essential }\n#wind { left: 300px; aura-particle: #fff count 12 life 5000 size 4 speed 0 wind 600 from center essential }\n#conf { left: 520px; aura-particle: #f00 #0f0 #00f count 60 life 3000 size 10 shape confetti palette dir up spread 60 speed 70 from top essential }', | |
| '<div class=s id=dia></div><div class=s id=orb></div><div class=s id=wind></div><div class=s id=conf></div>') }, viewport: { width: 700, height: 300 } }); | |
| } | |
| section('6. textures: more of them, and they all build'); | |
| { | |
| const rc = (w) => parseColor(w); | |
| const fresh = ['bricks', 'bricks-old', 'herringbone', 'plaid', 'tartan', 'gingham', 'scales', 'mermaid', 'shingles', 'chevron', 'argyle', 'lowpoly', 'tiles', 'blueprint', 'granite', 'slate', 'sandstone', 'terrazzo', 'confetti', 'rust', 'corrugated', 'cork', 'kraft', 'felt', 'burlap', 'stucco', 'starfield', 'snow', 'grass', 'water', 'rain', 'lava', 'topo']; | |
| ok(`${fresh.length} new presets, ${PRESET_NAMES.length} in all`, fresh.every((n) => PRESETS[n]) && PRESET_NAMES.length >= 70); | |
| ok('nine new generators', ['bricks', 'herringbone', 'plaid', 'scales', 'chevron', 'diamonds', 'triangles', 'starfield', 'terrazzo'].every((g) => GENERATORS.includes(g))); | |
| let bad = []; | |
| for (const n of PRESET_NAMES) { | |
| try { const layers = parseTexture(n, { error: (m) => bad.push(n + ': ' + m), color: (c) => c, suggest: () => null }); const t = buildTexture(layers, rc); if (!t.count || !t.image) bad.push(n + ': empty'); } | |
| catch (e) { bad.push(n + ': ' + e.message); } | |
| } | |
| ok('every preset parses and builds into CSS', bad.length === 0, bad.join(' | ')); | |
| ok('every preset has a one-line doc', PRESET_NAMES.every((n) => PRESETS[n].doc && PRESETS[n].doc.length > 8 && !/—|–/.test(PRESETS[n].doc))); | |
| const t = (src) => compile(`.a { texture: ${src} }`, 't.dssl').errors.map((e) => e.message); | |
| ok('density is a setting, and plaid takes radius', t('starfield density 2').length === 0 && t('terrazzo density 0.4 seed 3').length === 0 && t('plaid radius 0.4 scale 40').length === 0); | |
| ok('a generator on its own works with the new words: bricks scale 40 thickness 2', t('bricks scale 40 thickness 2 color #333 color2 #a33').length === 0); | |
| ok('symmetrical still applies to the new image textures', t('terrazzo symmetrical').length === 0 && t('scales symmetrical x').length === 0); | |
| // seamless where it should be: a tile edge must not show. Render the pattern 3x3 and compare the mean colour of a stripe on the seam with its surroundings. | |
| await withBrowser(async ({ open, errors }) => { | |
| const p = await open('/t'); await ready(p); await p.waitForTimeout(500); | |
| const png = decodePng(await p.screenshot({ clip: { x: 0, y: 0, width: 1200, height: 360 } })); | |
| // how much the pixels in each patch differ from their own average: a flat colour is 0 | |
| const spread = (i) => { | |
| const x0 = 20 + i * 195; let sum = 0, n = 0; | |
| for (let y = 20; y < 340; y += 2) for (let x = x0; x < x0 + 170; x += 2) { const c = px(png, x, y); sum += c[0] + c[1] + c[2]; n++; } | |
| const m = sum / n; let dev = 0; | |
| for (let y = 20; y < 340; y += 2) for (let x = x0; x < x0 + 170; x += 2) { const c = px(png, x, y); dev += Math.abs(c[0] + c[1] + c[2] - m); } | |
| return dev / n / 3; | |
| }; | |
| const sp = [0, 1, 2, 3, 4, 5].map(spread); | |
| ok('bricks, herringbone, tartan and scales are drawn, not a flat colour (terrazzo chips and a starfield are sparse, so their bar is lower)', sp.slice(0, 4).every((v) => v > 6) && sp[4] > 0.4 && sp[5] > 0.4, JSON.stringify(sp.map((v) => +v.toFixed(2)))); | |
| ok('no page errors', errors.length === 0, errors.join(' | ')); | |
| }, { pages: { '/t': page('.b { position: absolute; top: 20px; width: 170px; height: 330px; background: #888 }\n#b1 { left: 20px; texture: bricks } #b2 { left: 215px; texture: herringbone } #b3 { left: 410px; texture: tartan } #b4 { left: 605px; texture: mermaid } #b5 { left: 800px; texture: terrazzo } #b6 { left: 995px; background: #07091a; texture: starfield }', '<div class=b id=b1></div><div class=b id=b2></div><div class=b id=b3></div><div class=b id=b4></div><div class=b id=b5></div><div class=b id=b6></div>') }, viewport: { width: 1200, height: 360 } }); | |
| // a build keeps them as plain CSS | |
| const dir = mkdtempSync(path.join(tmpdir(), 'dssl-tex-')); | |
| writeFileSync(path.join(dir, 'a.dssl'), '.wall { texture: bricks }\n.floor { texture: herringbone }\n.sky { texture: starfield }\n'); | |
| const r = spawnSync(process.execPath, ['--no-warnings', path.join(ROOT, 'cli/dssl.mjs'), 'build', path.join(dir, 'a.dssl'), '-o', path.join(dir, 'a.css')], { encoding: 'utf8' }); | |
| ok('dssl build bakes the new textures into the CSS', r.status === 0 && /3 texture\(s\) baked/.test(r.stdout), r.stdout + r.stderr); | |
| } | |
| section('7. docs and version'); | |
| { | |
| const read = (f) => readFileSync(path.join(ROOT, f), 'utf8'); | |
| const API = await import('../public/dssl/index.js'); | |
| ok('the engine says 0.16', /^0\.16\./.test(API.VERSION)); | |
| for (const f of ['GUIDE.md', 'README.md', 'llms.txt', 'PARITY.md', 'src/components/dssl/views/ChangelogView.tsx', 'dssl-vscode/CHANGELOG.md']) ok(`${f} has a 0.16 section`, /0\.16\.\d/.test(read(f))); | |
| const guide = read('GUIDE.md'); | |
| for (const w of ['yield:', 'around diamond', 'from outline', 'palette', 'orbit', 'herringbone', 'tartan', 'css-loses', 'env(safe-area-inset-top']) ok(`the guide covers ${w}`, guide.includes(w)); | |
| ok('no em dashes in the new guide chapter', !/—/.test(guide.slice(guide.indexOf('### Round three (0.16.0)'), guide.indexOf('### Fonts')))); | |
| ok('llms.txt and public/llms.txt are the same file', read('llms.txt') === read('public/llms.txt')); | |
| } | |
| console.log(`\n${pass} passed, ${fail} failed`); | |
| process.exit(fail ? 1 : 0); | |