Something went wrong. Try again.
My website. cameron.stream
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330import { 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, /<circle cx="300" cy="300" r="300" fill="#FFFFFF" \/>/); assert.match(svg, /<g transform="rotate\(45 300 300\)">/); // 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, /<circle cx="300" cy="300" r="300" fill="#FFFFFF" \/>/); assert.match(svg, /<g transform="rotate\(45 300 300\)">/); 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"/);});