// The widgets' front ends (python/wai_quantum/static/*.js), run under node on the // data the Python package builds (written by test_widgets.py), against a small // document of their own: elements, SVG elements, attributes, classes, events and // the selectors the front ends use. The engine's SVG, which the Circuit widget // places as markup, is read by a small XML reader, so its gates can be clicked. // // node tests/front_ends.mjs payloads.json import { readFileSync } from 'node:fs'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import assert from 'node:assert/strict'; const here = dirname(fileURLToPath(import.meta.url)); const staticDir = join(here, '..', 'python', 'wai_quantum', 'static'); const payloads = JSON.parse(readFileSync(process.argv[2], 'utf8')); class Text { constructor(s) { this.text = String(s); this.parent = null; } get textContent() { return this.text; } } class ClassList { constructor(e) { this.e = e; } get set() { return new Set((this.e.attrs.class || '').split(/\s+/).filter(Boolean)); } contains(c) { return this.set.has(c); } add(c) { const s = this.set; s.add(c); this.e.attrs.class = [...s].join(' '); } remove(c) { const s = this.set; s.delete(c); this.e.attrs.class = [...s].join(' '); } toggle(c, on) { const want = on === undefined ? !this.contains(c) : !!on; if (want) this.add(c); else this.remove(c); return want; } } class Element { constructor(tag, ns = null) { this.tagName = tag; this.ns = ns; this.attrs = {}; this.children = []; this.parent = null; this.listeners = {}; this.classList = new ClassList(this); this.style = {}; } get className() { return this.attrs.class || ''; } set className(v) { this.attrs.class = v; } get dataset() { const d = {}; for (const [k, v] of Object.entries(this.attrs)) if (k.startsWith('data-')) d[k.slice(5).replace(/-(\w)/g, (_, c) => c.toUpperCase())] = v; return d; } setAttribute(k, v) { this.attrs[k] = String(v); } getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; } append(...kids) { for (const k of kids) { const n = typeof k === 'string' ? new Text(k) : k; if (n.parent) n.parent.children = n.parent.children.filter((c) => c !== n); n.parent = this; this.children.push(n); } } replaceChildren(...kids) { for (const c of this.children) c.parent = null; this.children = []; this.append(...kids); } replaceWith(n) { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); n.parent = p; p.children[i] = n; this.parent = null; } get textContent() { return this.children.map((c) => c.textContent).join(''); } set textContent(s) { this.replaceChildren(String(s)); } set innerHTML(s) { this.replaceChildren(...parseXml(s)); } get checked() { return !!this._checked; } set checked(v) { this._checked = !!v; } get value() { return this.attrs.value ?? ''; } set value(v) { this.attrs.value = String(v); } addEventListener(ev, f) { (this.listeners[ev] ||= []).push(f); } dispatch(ev, detail = {}) { for (const f of this.listeners[ev] || []) f({ target: this, pointerId: 1, ...detail }); } setPointerCapture() {} *walk() { for (const c of this.children) if (c instanceof Element) { yield c; yield* c.walk(); } } querySelectorAll(sel) { return [...this.walk()].filter((e) => matches(e, sel)); } querySelector(sel) { return this.querySelectorAll(sel)[0] || null; } } // Selectors: `tag`, `.a.b`, `[attr]`, and a child step `x > y`; enough for the front ends. function matchesOne(e, s) { const m = /^([a-z]*)((?:\.[\w-]+)*)((?:\[[\w-]+\])*)$/i.exec(s.trim()); if (!m) throw new Error(`selector not handled here: ${s}`); const [, tag, classes, attrs] = m; if (tag && e.tagName !== tag) return false; for (const c of classes.split('.').filter(Boolean)) if (!e.classList.contains(c)) return false; for (const a of attrs.match(/[\w-]+/g) || []) if (!(a in e.attrs)) return false; return true; } function matches(e, sel) { return sel.split(',').some((part) => { const steps = part.split('>').map((x) => x.trim()); if (!matchesOne(e, steps.at(-1))) return false; return steps.length === 1 || (e.parent instanceof Element && matchesOne(e.parent, steps[0])); }); } // The engine's SVG is XML it writes itself: elements, attributes in double quotes, // text with the five entities. function parseXml(s) { const root = new Element('#root'); const stack = [root]; const unesc = (t) => t.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/&/g, '&'); const re = /<(\/?)([\w:-]+)((?:\s+[\w:-]+="[^"]*")*)\s*(\/?)>|([^<]+)/g; let m; while ((m = re.exec(s))) { const [, close, tag, attrs, self, text] = m; if (text !== undefined) { stack.at(-1).append(unesc(text)); continue; } if (close) { const top = stack.pop(); assert.equal(top.tagName, tag, `unbalanced `); continue; } const e = new Element(tag, 'svg'); for (const a of attrs.matchAll(/([\w:-]+)="([^"]*)"/g)) e.attrs[a[1]] = unesc(a[2]); stack.at(-1).append(e); if (!self) stack.push(e); } assert.equal(stack.length, 1, 'unclosed elements in the SVG'); return root.children.slice(); } globalThis.document = { createElement: (t) => new Element(t), createElementNS: (ns, t) => new Element(t, ns), }; function model(data, traits = {}) { const state = { data, ...traits }; const on = {}; let saves = 0; return { get: (k) => state[k], set: (k, v) => { state[k] = v; for (const f of on[`change:${k}`] || []) f(); }, save_changes: () => { saves += 1; }, on: (ev, f) => (on[ev] ||= []).push(f), off: (ev, f) => { on[ev] = (on[ev] || []).filter((g) => g !== f); }, get saves() { return saves; }, }; } async function widget(name) { return (await import(pathToFileURL(join(staticDir, `${name}.js`)).href)).default; } const checks = []; function check(what, f) { checks.push([what, f]); } check('circuit', async () => { const w = await widget('circuit'); const m = model(payloads.circuit, { selected: null }); const host = new Element('div'); const cleanup = w.render({ model: m, el: host }); const svg = host.querySelector('svg'); assert.ok(svg, 'the engine drawing is placed'); const gates = host.querySelectorAll('.qc-item[data-op]'); assert.equal(gates.length, 2); assert.equal(host.querySelectorAll('.qc-item').length, 4); gates[1].dispatch('click'); assert.equal(m.get('selected'), 1); assert.match(host.textContent, /operation 1: ● on q0, ⊕ on q1/); assert.ok(host.querySelector('.qc-item.wq-selected[data-op]')); host.querySelectorAll('.qc-item[data-op]')[1].dispatch('click'); assert.equal(m.get('selected'), null, 'a second click clears it'); assert.equal(host.querySelectorAll('input').length, 0, 'no authorship toggle without marks'); cleanup(); // With marks: the toggle hides and shows them. const mh = model(payloads.circuit_hands, { selected: null }); const h2 = new Element('div'); w.render({ model: mh, el: h2 }); const box = h2.querySelector('input'); assert.ok(box && box.checked); assert.ok(h2.querySelector('.qc-item.qc-by-assistant')); box.checked = false; box.dispatch('change'); assert.ok(h2.querySelector('svg').classList.contains('wq-unmarked')); }); check('result', async () => { const w = await widget('result'); const d = payloads.result; const m = model(d, { frequencies: false, top: 16 }); const host = new Element('div'); w.render({ model: m, el: host }); const text = host.textContent; assert.match(text, /stabilizer/); assert.match(text, /exact/); assert.match(text, /verified/); const bars = host.querySelectorAll('rect'); assert.equal(bars.length, Object.keys(d.counts).length); const values = () => host.querySelector('svg').children.filter((c) => c.tagName === 'text' && c.attrs.class === 'wq-small').map((c) => c.textContent); assert.deepEqual(values(), Object.values(d.counts).map(String)); m.set('frequencies', true); assert.deepEqual(values(), Object.values(d.counts).map((v) => (v / d.shots).toFixed(4))); m.set('top', 1); assert.equal(host.querySelectorAll('rect').length, 1); assert.match(host.textContent, /more shots fell on outcomes not drawn/); }); check('receipt', async () => { const w = await widget('receipt'); const host = new Element('div'); w.render({ model: model(payloads.receipt), el: host }); assert.match(host.textContent, /verified/); assert.ok(host.textContent.includes(payloads.receipt.signer)); assert.ok(host.textContent.includes(payloads.receipt.digest)); assert.match(host.textContent, /published demo key/); const bad = new Element('div'); w.render({ model: model(payloads.receipt_forged), el: bad }); assert.match(bad.textContent, /INVALID/); assert.doesNotMatch(bad.textContent, /verified/); }); check('bloch', async () => { const w = await widget('bloch'); const d = payloads.bloch; const m = model(d, { view: [-35, 20] }); const host = new Element('div'); w.render({ model: m, el: host }); assert.equal(host.querySelectorAll('svg').length, d.qubits.length); assert.match(host.textContent, /q0/); assert.match(host.textContent, /length 1/); // q0 is |+⟩: from the view straight on, its tip is a radius to the right of the centre. m.set('view', [0, 0]); const tip = host.querySelectorAll('svg')[0].children.filter((c) => c.tagName === 'circle')[1]; const r = 150 * 0.36; assert.ok(Math.abs(Number(tip.attrs.cx) - (75 + r)) < 0.02 && Math.abs(Number(tip.attrs.cy) - 79) < 0.02, JSON.stringify(tip.attrs)); // A drag turns the view and keeps it in the model. const grid = host.querySelector('.wq-grid'); grid.dispatch('pointerdown', { clientX: 0, clientY: 0 }); grid.dispatch('pointermove', { clientX: 50, clientY: 25 }); grid.dispatch('pointerup', {}); assert.deepEqual(m.get('view'), [30, 10]); assert.ok(m.saves >= 1); }); let n = 0; for (const [what, f] of checks) { await f(); n += 1; console.log(`ok ${what}`); } console.log(`front ends: ${n} widgets, every check passed`);