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"/);
});