import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import vm from 'node:vm'; const INPUTS = ['#intensity', '#gap', '#scale', '#weight', '#rotation']; function makeHarness() { const elements = new Map(); const element = selector => { if (!elements.has(selector)) elements.set(selector, { value: undefined, defaultValue: undefined, attributes: {}, listeners: {}, textContent: undefined, setAttribute(key, value) { this.attributes[key] = value; }, addEventListener(key, handler) { this.listeners[key] = handler; }, }); return elements.get(selector); }; const defaults = { '#intensity': '125', '#gap': '10', '#scale': '100', '#weight': '24', '#rotation': '0', }; for (const [sel, val] of Object.entries(defaults)) { element(sel).value = val; element(sel).defaultValue = val; } // New controls: order select, theme toggle, export button. element('#order').value = 'clay,ochre,slate'; element('#order').defaultValue = 'clay,ochre,slate'; element('#theme').value = 'light'; element('#theme').defaultValue = 'light'; const created = []; const context = { document: { querySelector: element, createElement: tag => { const el = { tag, listeners: {}, click() { created.push(`click:${tag}`); }, set href(v) { this._href = v; }, get href() { return this._href; } }; return el; }, body: { appendChild(el) { created.push(`append:${el.tag}`); }, }, }, Blob: class { constructor(parts, opts) { this.parts = parts; this.type = opts && opts.type; } }, URL: { createObjectURL: () => 'blob:mock', revokeObjectURL() {} }, }; vm.runInNewContext(readFileSync(new URL('../public/color-rings.js', import.meta.url), 'utf8'), context); return { element, created }; } test('color rings preserve the base palette, stay in sRGB, and reset', () => { const { element } = makeHarness(); const slider = element('#intensity'); const colors = () => ['clay', 'ochre', 'slate'].map(name => { const hex = element(`#hex-${name}`).textContent; assert.equal(element(`#ring-${name}`).attributes.stroke, hex); return hex; }); const initial = colors(); for (let value = 0; value <= 220; value++) { slider.value = String(value); slider.listeners.input(); for (const hex of colors()) assert.match(hex, /^#[0-9A-F]{6}$/); if (value === 0) for (const hex of colors()) assert.match(hex, /^#([0-9A-F]{2})\1\1$/); if (value === 100) assert.deepEqual(colors(), ['#C98576', '#D6B779', '#8FA7B5']); } element('.controls').listeners.reset({ preventDefault() {} }); assert.equal(slider.value, '125'); assert.deepEqual(colors(), initial); assert.equal(element('#intensity-value').value, '125%'); }); test('gap slider moves inner rings while the outer ring stays fixed', () => { const { element } = makeHarness(); const gap = element('#gap'); gap.value = '0'; gap.listeners.input(); assert.equal(element('#ring-clay').attributes.r, '250'); assert.equal(element('#ring-ochre').attributes.r, '190'); assert.equal(element('#ring-slate').attributes.r, '130'); gap.value = '60'; gap.listeners.input(); assert.equal(element('#ring-clay').attributes.r, '250'); assert.equal(element('#ring-ochre').attributes.r, '130'); assert.equal(element('#ring-slate').attributes.r, String(Math.max(110 / 4, 110 - 2 * 50))); gap.value = '10'; gap.listeners.input(); assert.equal(element('#ring-ochre').attributes.r, '180'); assert.equal(element('#ring-slate').attributes.r, '110'); }); test('scale slider zooms the whole mark, keeping band spacing even', () => { const { element } = makeHarness(); const scale = element('#scale'); // Default scale 100: deployed radii 250/180/110. assert.equal(element('#ring-clay').attributes.r, '250'); assert.equal(element('#ring-ochre').attributes.r, '180'); assert.equal(element('#ring-slate').attributes.r, '110'); // Scale 80 zooms every ring about the shared center. The radius step // scales with it (70 -> 56), so the band spacing stays even everywhere // instead of the outer band collapsing onto the middle one. scale.value = '80'; scale.listeners.input(); assert.equal(element('#ring-clay').attributes.r, '200'); assert.equal(element('#ring-ochre').attributes.r, '144'); assert.equal(element('#ring-slate').attributes.r, '88'); assert.equal(element('#scale-value').value, '80%'); // Scale is independent of thickness: changing the weight does not move // any radius, so both band gaps stay equal (56 - 40 = 16 each here). const weight = element('#weight'); weight.value = '40'; weight.listeners.input(); assert.equal(element('#ring-clay').attributes.r, '200'); assert.equal(element('#ring-ochre').attributes.r, '144'); assert.equal(element('#ring-slate').attributes.r, '88'); // Scale past 100 grows the mark toward the canvas edge. scale.value = '110'; scale.listeners.input(); assert.equal(element('#ring-clay').attributes.r, '275'); assert.equal(element('#ring-ochre').attributes.r, '198'); assert.equal(element('#ring-slate').attributes.r, '121'); // Gap adjustments compose with scale: the gap-compressed radii then zoom. const gap = element('#gap'); gap.value = '30'; gap.listeners.input(); assert.equal(element('#ring-ochre').attributes.r, '176'); assert.equal(element('#ring-slate').attributes.r, '77'); element('.controls').listeners.reset({ preventDefault() {} }); assert.equal(scale.value, '100'); assert.equal(element('#ring-clay').attributes.r, '250'); }); test('thickness and rotation apply to the group', () => { const { element } = makeHarness(); const weight = element('#weight'); const rotation = element('#rotation'); weight.value = '12'; weight.listeners.input(); assert.equal(element('#rings-group').attributes['stroke-width'], '12'); assert.equal(element('#weight-value').value, '12'); rotation.value = '90'; rotation.listeners.input(); assert.equal(element('#rings-group').attributes.transform, 'rotate(90 300 300)'); assert.equal(element('#rotation-value').value, '90°'); element('.controls').listeners.reset({ preventDefault() {} }); assert.equal(element('#rings-group').attributes['stroke-width'], '24'); assert.equal(element('#rings-group').attributes.transform, 'rotate(0 300 300)'); }); test('order select reassigns radii and position labels, keeping colors fixed', () => { const { element } = makeHarness(); const order = element('#order'); const before = ['clay', 'ochre', 'slate'].map(name => element(`#ring-${name}`).attributes.stroke); order.value = 'slate,ochre,clay'; order.listeners.input(); // Radii follow position: slate takes the outer 250, clay drops to inner. assert.equal(element('#ring-slate').attributes.r, '250'); assert.equal(element('#ring-ochre').attributes.r, '180'); assert.equal(element('#ring-clay').attributes.r, '110'); // Colors stay attached to their names. assert.equal(element('#ring-clay').attributes.stroke, before[0]); assert.equal(element('#ring-ochre').attributes.stroke, before[1]); assert.equal(element('#ring-slate').attributes.stroke, before[2]); // Position readouts follow the new assignment. assert.equal(element('#position-clay').textContent, 'inner'); assert.equal(element('#position-slate').textContent, 'outer'); // Order interacts with the gap slider: the radius comes from the position, // not the color name. const gap = element('#gap'); gap.value = '60'; gap.listeners.input(); assert.equal(element('#ring-slate').attributes.r, '250'); assert.equal(element('#ring-clay').attributes.r, String(Math.max(110 / 4, 110 - 2 * 50))); element('.controls').listeners.reset({ preventDefault() {} }); assert.equal(order.value, 'clay,ochre,slate'); assert.equal(element('#ring-clay').attributes.r, '250'); assert.equal(element('#position-clay').textContent, 'outer'); }); test('theme toggle switches the document theme attribute', () => { const { element } = makeHarness(); const theme = element('#theme'); const html = element('html'); theme.listeners.click(); assert.equal(theme.value, 'dark'); assert.equal(html.attributes['data-rings-theme'], 'dark'); assert.equal(theme.attributes['aria-pressed'], 'true'); assert.equal(theme.textContent, 'Light mode'); theme.listeners.click(); assert.equal(theme.value, 'light'); assert.equal(html.attributes['data-rings-theme'], 'light'); assert.equal(theme.attributes['aria-pressed'], 'false'); assert.equal(theme.textContent, 'Dark mode'); // Reset returns to the default light theme. theme.listeners.click(); element('.controls').listeners.reset({ preventDefault() {} }); assert.equal(theme.value, 'light'); assert.equal(html.attributes['data-rings-theme'], 'light'); }); test('export produces a standalone SVG with a white background circle', () => { const { element, created } = makeHarness(); // Non-default state: reorder, rotate, and set gap so the export reflects // the live controls. element('#order').value = 'ochre,slate,clay'; element('#order').listeners.input(); element('#rotation').value = '45'; element('#rotation').listeners.input(); element('#gap').value = '30'; element('#gap').listeners.input(); let blob; const context = { document: { querySelector: element } }; // Re-run with a capturing Blob to inspect the exported SVG text. const elements2 = new Map(); const element2 = selector => { if (!elements2.has(selector)) elements2.set(selector, element(selector)); return elements2.get(selector); }; const CapturingBlob = class { constructor(parts, opts) { blob = { parts, type: opts && opts.type }; } }; const url = { createObjectURL: () => 'blob:mock', revokeObjectURL() {} }; const linkEl = { href: '', download: '', click() {}, remove() {} }; const doc2 = { querySelector: element2, createElement: () => linkEl, body: { appendChild() {}, }, }; vm.runInNewContext(readFileSync(new URL('../public/color-rings.js', import.meta.url), 'utf8'), { document: doc2, Blob: CapturingBlob, URL: url, }); // Restore harness element state mutated by the second run, then fire export. element2('#order').value = 'ochre,slate,clay'; element2('#order').listeners.input(); element2('#rotation').value = '45'; element2('#rotation').listeners.input(); element2('#gap').value = '30'; element2('#gap').listeners.input(); element2('#export').listeners.click(); assert.ok(blob, 'export built a Blob'); assert.equal(blob.type, 'image/svg+xml'); const svg = blob.parts[0]; assert.match(svg, /^<\?xml version="1\.0" encoding="UTF-8"\?>/); assert.match(svg, //); assert.match(svg, //); // Ordered outer → inner: ochre at the fixed 250, clay innermost at // 110 - 2*(gap 30 - base 10) = 70. const ochreCircle = svg.split('\n').find(line => line.includes('stroke="#E2BB6D"')); assert.ok(ochreCircle, 'ochre circle present'); assert.match(ochreCircle, /r="250"/); const clayCircle = svg.split('\n').find(line => line.includes('stroke="#D37E6C"')); assert.match(clayCircle, /r="70"/); // Export is theme-independent: no page classes or data attributes leak in. assert.ok(!svg.includes('rings-group')); assert.ok(!svg.includes('data-rings-theme')); }); test('PNG export rasterizes the standalone SVG onto a transparent 2048px canvas', () => { const { element } = makeHarness(); const blobs = []; const revoked = []; const drawn = []; const downloads = []; let image; const canvas = { getContext: kind => ({ drawImage: (...args) => drawn.push([kind, ...args]) }), toBlob(callback, type) { callback({ png: true, type }); }, }; const link = { click() { downloads.push({ href: this.href, download: this.download }); }, remove() {} }; vm.runInNewContext(readFileSync(new URL('../public/color-rings.js', import.meta.url), 'utf8'), { document: { querySelector: element, createElement: tag => (tag === 'canvas' ? canvas : link), body: { appendChild() {} }, }, Blob: class { constructor(parts, opts) { this.parts = parts; this.type = opts && opts.type; blobs.push(this); } }, URL: { createObjectURL: blob => `blob:${blob.png ? 'png' : 'svg'}`, revokeObjectURL: url => revoked.push(url) }, Image: class { constructor() { image = this; } }, }); element('#rotation').value = '45'; element('#rotation').listeners.input(); element('#export-png').listeners.click(); // The source is the same standalone SVG as the SVG export, sized up so the // browser rasterizes it at full resolution. assert.equal(blobs.length, 1); assert.equal(blobs[0].type, 'image/svg+xml'); const svg = blobs[0].parts[0]; assert.match(svg, /viewBox="0 0 600 600" width="2048" height="2048"/); assert.match(svg, //); assert.match(svg, //); assert.equal(image.src, 'blob:svg'); assert.equal(downloads.length, 0, 'nothing downloads until the image loads'); image.onload(); assert.equal(canvas.width, 2048); assert.equal(canvas.height, 2048); // Only the image is drawn: no fill behind it, so the corners stay clear. assert.equal(drawn.length, 1); assert.equal(drawn[0][0], '2d'); assert.equal(drawn[0][1], image); assert.deepEqual(drawn[0].slice(2), [0, 0, 2048, 2048]); assert.deepEqual(downloads, [{ href: 'blob:png', download: 'color-rings.png' }]); assert.deepEqual(revoked, ['blob:svg', 'blob:png']); }); test('SVG export keeps its 600px intrinsic size', () => { const { element } = makeHarness(); let blob; vm.runInNewContext(readFileSync(new URL('../public/color-rings.js', import.meta.url), 'utf8'), { document: { querySelector: element, createElement: () => ({ click() {}, remove() {} }), body: { appendChild() {} }, }, Blob: class { constructor(parts) { blob = parts[0]; } }, URL: { createObjectURL: () => 'blob:mock', revokeObjectURL() {} }, }); element('#export').listeners.click(); assert.match(blob, /viewBox="0 0 600 600" width="600" height="600"/); });