|
@@ -0,0 +1,520 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * DOM-walk + forms-module scenarios for the Statum client.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Each scenario runs in a FRESH QuickJS context (run.sh invokes this script
|
|
|
|
|
+ * once per scenario) with a minimal DOM shim (shim.js) that supports the
|
|
|
|
|
+ * render walk and the forms module end to end, including capture-phase
|
|
|
|
|
+ * events, form reset defaults and a scriptable fetch stub.
|
|
|
|
|
+ *
|
|
|
|
|
+ * State enters through the real machinery: a `set` directive returned by the
|
|
|
|
|
+ * fetch stub and applied by bridge.loadEntrypoint() (which re-renders through
|
|
|
|
|
+ * applyDirectives -> renderAll). Signatures vary per step so the idempotency
|
|
|
|
|
+ * guard never short-circuits a deliberate re-render.
|
|
|
|
|
+ *
|
|
|
|
|
+ * 1 walk: stm-if/stm-else chain, stm-text, stm-class.*
|
|
|
|
|
+ * 2 stm-for keyed: node identity preserved per key, removal honored
|
|
|
|
|
+ * 3 stm-for unkeyed: instances rebuilt (all identities change)
|
|
|
|
|
+ * 4 stm-prefill: input + textarea property writes (no attribute)
|
|
|
|
|
+ * 5 stm-prefill: select single + multiple (array)
|
|
|
|
|
+ * 6 stm-prefill: checkbox truthy/falsy/null
|
|
|
|
|
+ * 7 stm-prefill: radio group, same expression per member
|
|
|
|
|
+ * 8 dirty skip: dirty input keeps user value, clean sibling re-fills
|
|
|
|
|
+ * 9 dirty is per element within a form
|
|
|
|
|
+ * 10 reset event un-dirties; prefill re-applies after reset
|
|
|
|
|
+ * 11 stm-clear-on-success: success resets + un-dirties the form
|
|
|
|
|
+ * 12 stm-clear-on-success: failure keeps values and dirty state
|
|
|
|
|
+ * 13 keyed loop of forms: clearing instance 2 leaves 1 and 3 alone
|
|
|
|
|
+ * 14 two sibling forms: clearing A leaves B alone
|
|
|
|
|
+ * 15 statum.forms.reset: resets exactly the given form
|
|
|
|
|
+ * 16 handler order: stm-prefill registered before stm-class.*
|
|
|
|
|
+ *
|
|
|
|
|
+ * Usage: qjs tests/dom/scenarios.js <1..16>
|
|
|
|
|
+ */
|
|
|
|
|
+import * as std from 'std';
|
|
|
|
|
+import { installShim } from './shim.js';
|
|
|
|
|
+
|
|
|
|
|
+// Repo root as a CWD-relative path; run.sh cds to the repo root first (same
|
|
|
|
|
+// convention as tests/markdown/spec-test.js).
|
|
|
|
|
+var ROOT = '.';
|
|
|
|
|
+var scenario = scriptArgs.length > 1 ? scriptArgs[1] : '';
|
|
|
|
|
+
|
|
|
|
|
+function fail(msg) {
|
|
|
|
|
+ std.err.puts('FAIL [' + scenario + '] ' + msg + '\n');
|
|
|
|
|
+ std.exit(1);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function check(cond, msg) {
|
|
|
|
|
+ if (!cond) fail(msg);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function load(name) {
|
|
|
|
|
+ (0, eval)(std.loadFile(ROOT + '/js/' + name));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function loadAll() {
|
|
|
|
|
+ load('statum-core.js');
|
|
|
|
|
+ load('statum-document.js');
|
|
|
|
|
+ load('statum-markdown.js');
|
|
|
|
|
+ load('statum-forms.js');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Yield to the job queue n times so promise chains settle. */
|
|
|
|
|
+async function spin(n) {
|
|
|
|
|
+ for (var i = 0; i < (n || 30); i++) await Promise.resolve();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+var sigCounter = 0;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Put `public` data into a slot type through a set directive, then re-render.
|
|
|
|
|
+ * Uses loadEntrypoint (public bridge machinery) so the exact production path
|
|
|
|
|
+ * (fetch -> handleResponse -> applyDirectives -> renderAll) runs.
|
|
|
|
|
+ */
|
|
|
|
|
+async function setState(typeName, publicData) {
|
|
|
|
|
+ sigCounter++;
|
|
|
|
|
+ var snapshot = { slot: { key: 'k-' + typeName, type: typeName, scope: 'page' }, public: publicData };
|
|
|
|
|
+ globalThis.__shim.setFetch(function () {
|
|
|
|
|
+ return globalThis.__shim.okResponse([{
|
|
|
|
|
+ type: 'set',
|
|
|
|
|
+ snapshot_frame: { content: JSON.stringify(snapshot), signature: 'sig-' + sigCounter }
|
|
|
|
|
+ }]);
|
|
|
|
|
+ });
|
|
|
|
|
+ await globalThis.statum._bridge.loadEntrypoint();
|
|
|
|
|
+ await spin();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Fire a real (bubbling, non-cancelable-by-default) event on an element. */
|
|
|
|
|
+function fire(el, type) {
|
|
|
|
|
+ var evt = globalThis.__shim.makeEvent(type);
|
|
|
|
|
+ el.dispatchEvent(evt);
|
|
|
|
|
+ return evt;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function el(tag) { return globalThis.__shim.makeElement(tag); }
|
|
|
|
|
+function input(type, name, value) {
|
|
|
|
|
+ var i = el('input');
|
|
|
|
|
+ if (type) i.setAttribute('type', type);
|
|
|
|
|
+ if (name) i.setAttribute('name', name);
|
|
|
|
|
+ if (value !== undefined) i.setAttribute('value', value);
|
|
|
|
|
+ return i;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function main() {
|
|
|
|
|
+ installShim();
|
|
|
|
|
+ var log = globalThis.__shim.log;
|
|
|
|
|
+ var body = globalThis.__shim.body;
|
|
|
|
|
+ loadAll();
|
|
|
|
|
+
|
|
|
|
|
+ var s = globalThis.statum;
|
|
|
|
|
+ check(s && s._bridge, 'statum did not load');
|
|
|
|
|
+ check(s.forms && typeof s.forms.reset === 'function', 'statum.forms.reset missing');
|
|
|
|
|
+ check(typeof s.forms.markDirty === 'function' && typeof s.forms.isDirty === 'function',
|
|
|
|
|
+ 'statum.forms.markDirty/isDirty missing');
|
|
|
|
|
+ check(s._bridge.formsDirty instanceof WeakSet, 'bridge.formsDirty missing');
|
|
|
|
|
+ check(typeof s._bridge.markDirty === 'function' && typeof s._bridge.isDirty === 'function',
|
|
|
|
|
+ 'bridge.markDirty/isDirty missing');
|
|
|
|
|
+
|
|
|
|
|
+ if (scenario === '1') {
|
|
|
|
|
+ // if/else-if/else chain + stm-text + stm-class.*
|
|
|
|
|
+ var w = el('div');
|
|
|
|
|
+ var a = el('p'); a.setAttribute('stm-if', "m.mood === 'up'"); a.textContent = 'up';
|
|
|
|
|
+ var b = el('p'); b.setAttribute('stm-else-if', "m.mood === 'mid'"); b.textContent = 'mid';
|
|
|
|
|
+ var c = el('p'); c.setAttribute('stm-else', ''); c.textContent = 'down';
|
|
|
|
|
+ w.appendChild(a); w.appendChild(b); w.appendChild(c);
|
|
|
|
|
+ var t = el('span'); t.setAttribute('stm-text', 'm.mood');
|
|
|
|
|
+ var cl = el('span'); cl.setAttribute('stm-class.hi', "m.mood === 'up'");
|
|
|
|
|
+ w.appendChild(t); w.appendChild(cl);
|
|
|
|
|
+ body.appendChild(w);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('m', { mood: 'up' });
|
|
|
|
|
+ check(!a.hasAttribute('hidden') && b.hasAttribute('hidden') && c.hasAttribute('hidden'),
|
|
|
|
|
+ 'branch up not exclusively shown');
|
|
|
|
|
+ check(t.textContent === 'up', 'stm-text wrote ' + JSON.stringify(t.textContent));
|
|
|
|
|
+ check(cl.classList.contains('hi'), 'stm-class.hi not applied');
|
|
|
|
|
+
|
|
|
|
|
+ await setState('m', { mood: 'mid' });
|
|
|
|
|
+ check(a.hasAttribute('hidden') && !b.hasAttribute('hidden') && c.hasAttribute('hidden'),
|
|
|
|
|
+ 'branch mid not exclusively shown');
|
|
|
|
|
+ check(!cl.classList.contains('hi'), 'stm-class.hi not removed');
|
|
|
|
|
+ check(t.textContent === 'mid', 'stm-text not updated');
|
|
|
|
|
+
|
|
|
|
|
+ await setState('m', { mood: 'zzz' });
|
|
|
|
|
+ check(a.hasAttribute('hidden') && b.hasAttribute('hidden') && !c.hasAttribute('hidden'),
|
|
|
|
|
+ 'else branch not shown');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '2') {
|
|
|
|
|
+ // Keyed loop: identity per key preserved; removal honored.
|
|
|
|
|
+ var holder = el('div');
|
|
|
|
|
+ holder.setAttribute('stm-for-item-in', 'list.items');
|
|
|
|
|
+ holder.setAttribute('stm-key', 'item.id');
|
|
|
|
|
+ var row = el('p');
|
|
|
|
|
+ row.setAttribute('stm-text', 'item.name');
|
|
|
|
|
+ holder.appendChild(row);
|
|
|
|
|
+ body.appendChild(holder);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('list', { items: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 3, name: 'c' }] });
|
|
|
|
|
+ var insts = holder.parentNode.children.filter(function (n) { return n !== holder; });
|
|
|
|
|
+ // holder is children[0]; instances follow it in order.
|
|
|
|
|
+ var kids = body.children.filter(function (n) { return n !== holder; });
|
|
|
|
|
+ check(kids.length === 3, 'expected 3 instances, got ' + kids.length);
|
|
|
|
|
+ var k1 = kids[0], k2 = kids[1], k3 = kids[2];
|
|
|
|
|
+ // The stm-text binding is on the template's child, so read the child.
|
|
|
|
|
+ check(k1.children[0].textContent === 'a' && k2.children[0].textContent === 'b' &&
|
|
|
|
|
+ k3.children[0].textContent === 'c',
|
|
|
|
|
+ 'instance texts: ' + kids.map(function (n) { return n.children[0] && n.children[0].textContent; }).join(','));
|
|
|
|
|
+
|
|
|
|
|
+ await setState('list', { items: [{ id: 1, name: 'a' }, { id: 3, name: 'c3' }] });
|
|
|
|
|
+ var kids2 = body.children.filter(function (n) { return n !== holder; });
|
|
|
|
|
+ check(kids2.length === 2, 'expected 2 instances after removal, got ' + kids2.length);
|
|
|
|
|
+ check(kids2[0] === k1, 'key 1 node identity not preserved');
|
|
|
|
|
+ check(kids2[1] === k3, 'key 3 node identity not preserved');
|
|
|
|
|
+ check(kids2[1].children[0].textContent === 'c3', 'key 3 text not refreshed');
|
|
|
|
|
+ check(k2.parentNode === null, 'removed instance still attached');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '3') {
|
|
|
|
|
+ // Unkeyed loop: rebuild wholesale.
|
|
|
|
|
+ var holder = el('div');
|
|
|
|
|
+ holder.setAttribute('stm-for-item-in', 'list.items');
|
|
|
|
|
+ var row = el('p');
|
|
|
|
|
+ row.setAttribute('stm-text', 'item.name');
|
|
|
|
|
+ holder.appendChild(row);
|
|
|
|
|
+ body.appendChild(holder);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('list', { items: [{ name: 'a' }, { name: 'b' }] });
|
|
|
|
|
+ var kids = body.children.filter(function (n) { return n !== holder; });
|
|
|
|
|
+ check(kids.length === 2 && kids[0].children[0].textContent === 'a' &&
|
|
|
|
|
+ kids[1].children[0].textContent === 'b', 'initial render');
|
|
|
|
|
+
|
|
|
|
|
+ await setState('list', { items: [{ name: 'a' }, { name: 'b' }] });
|
|
|
|
|
+ var kids2 = body.children.filter(function (n) { return n !== holder; });
|
|
|
|
|
+ check(kids2.length === 2, 'instance count changed');
|
|
|
|
|
+ check(kids2[0] !== kids[0] && kids2[1] !== kids[1], 'unkeyed loop reused nodes');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '4') {
|
|
|
|
|
+ // Prefill writes the value PROPERTY, never the value attribute.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ var ti = input('text', 'who'); ti.setAttribute('stm-prefill', 'p.who');
|
|
|
|
|
+ var ta = el('textarea'); ta.setAttribute('name', 'bio'); ta.setAttribute('stm-prefill', 'p.bio');
|
|
|
|
|
+ f.appendChild(ti); f.appendChild(ta);
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { who: 'ada', bio: 'hi' });
|
|
|
|
|
+ check(ti.value === 'ada', 'text input value = ' + JSON.stringify(ti.value));
|
|
|
|
|
+ check(ta.value === 'hi', 'textarea value = ' + JSON.stringify(ta.value));
|
|
|
|
|
+ check(ti.getAttribute('value') === null, 'text input value ATTRIBUTE was written');
|
|
|
|
|
+ check(ta.getAttribute('stm-prefill') === 'p.bio', 'stm-prefill attribute consumed');
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { who: null, bio: undefined });
|
|
|
|
|
+ check(ti.value === '', 'null should prefill as empty string, got ' + JSON.stringify(ti.value));
|
|
|
|
|
+ check(ta.value === 'hi', 'undefined must leave the element untouched');
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { who: 42, bio: 'x' });
|
|
|
|
|
+ check(ti.value === '42', 'number stringified: ' + JSON.stringify(ti.value));
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '5') {
|
|
|
|
|
+ // Select single + multiple.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ var sel = el('select'); sel.setAttribute('stm-prefill', 'p.pick');
|
|
|
|
|
+ var o1 = el('option'); o1.setAttribute('value', 'a'); o1.textContent = 'A';
|
|
|
|
|
+ var o2 = el('option'); o2.setAttribute('value', 'b'); o2.textContent = 'B';
|
|
|
|
|
+ sel.appendChild(o1); sel.appendChild(o2);
|
|
|
|
|
+ var multi = el('select'); multi.setAttribute('multiple', ''); multi.setAttribute('stm-prefill', 'p.set');
|
|
|
|
|
+ var m1 = el('option'); m1.setAttribute('value', 'x');
|
|
|
|
|
+ var m2 = el('option'); m2.setAttribute('value', 'y');
|
|
|
|
|
+ var m3 = el('option'); m3.setAttribute('value', 'z');
|
|
|
|
|
+ multi.appendChild(m1); multi.appendChild(m2); multi.appendChild(m3);
|
|
|
|
|
+ f.appendChild(sel); f.appendChild(multi);
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { pick: 'b', set: ['x', 'z'] });
|
|
|
|
|
+ check(sel.value === 'b', 'single select value = ' + JSON.stringify(sel.value));
|
|
|
|
|
+ check(o2.selected === true && o1.selected === false, 'single select option states wrong');
|
|
|
|
|
+ check(m1.selected === true && m2.selected === false && m3.selected === true,
|
|
|
|
|
+ 'multiple select option states wrong');
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { pick: 'nope', set: 'y' });
|
|
|
|
|
+ check(sel.value === '', 'unmatched single select should yield empty value');
|
|
|
|
|
+ check(m1.selected === false && m2.selected === true && m3.selected === false,
|
|
|
|
|
+ 'non-array multiple value must behave like single');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '6') {
|
|
|
|
|
+ // Checkbox: truthy -> checked, falsy/null -> unchecked.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ var cb = input('checkbox', 'ok'); cb.setAttribute('stm-prefill', 'p.ok');
|
|
|
|
|
+ f.appendChild(cb);
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { ok: true });
|
|
|
|
|
+ check(cb.checked === true, 'truthy did not check');
|
|
|
|
|
+ await setState('p', { ok: false });
|
|
|
|
|
+ check(cb.checked === false, 'falsy did not uncheck');
|
|
|
|
|
+ await setState('p', { ok: null });
|
|
|
|
|
+ check(cb.checked === false, 'null did not uncheck');
|
|
|
|
|
+ await setState('p', { ok: 'yes' });
|
|
|
|
|
+ check(cb.checked === true, 'non-empty string should check');
|
|
|
|
|
+ await setState('p', {}); // ok undefined -> untouched
|
|
|
|
|
+ check(cb.checked === true, 'undefined must leave checked untouched');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '7') {
|
|
|
|
|
+ // Radio group: every member carries the same expression; the member whose
|
|
|
|
|
+ // own value matches the resolved value gets checked.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ var mk = function (v) {
|
|
|
|
|
+ var r = input('radio', 'g', v);
|
|
|
|
|
+ r.setAttribute('stm-prefill', 'p.choice');
|
|
|
|
|
+ f.appendChild(r);
|
|
|
|
|
+ return r;
|
|
|
|
|
+ };
|
|
|
|
|
+ var ra = mk('a'), rb = mk('b'), rc = mk('c');
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { choice: 'b' });
|
|
|
|
|
+ check(ra.checked === false && rb.checked === true && rc.checked === false,
|
|
|
|
|
+ 'state b checked the wrong member(s)');
|
|
|
|
|
+ await setState('p', { choice: 'd' });
|
|
|
|
|
+ check(ra.checked === false && rb.checked === false && rc.checked === false,
|
|
|
|
|
+ 'state d (no match) must leave all unchecked');
|
|
|
|
|
+ await setState('p', { choice: null });
|
|
|
|
|
+ check(rb.checked === false, 'null must uncheck (String(null) matches nothing)');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '8') {
|
|
|
|
|
+ // A dirty element is never overwritten; a clean sibling still fills.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.one');
|
|
|
|
|
+ var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.two');
|
|
|
|
|
+ f.appendChild(i1); f.appendChild(i2);
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { one: 'x', two: 'x' });
|
|
|
|
|
+ check(i1.value === 'x' && i2.value === 'x', 'initial fill failed');
|
|
|
|
|
+ fire(i1, 'input'); // user edits input 1
|
|
|
|
|
+ check(s.forms.isDirty(i1), 'input event did not mark the element dirty');
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { one: 'y', two: 'y' });
|
|
|
|
|
+ check(i1.value === 'x', 'dirty element was overwritten: ' + JSON.stringify(i1.value));
|
|
|
|
|
+ check(i2.value === 'y', 'clean sibling not re-filled');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '9') {
|
|
|
|
|
+ // Dirtiness is per element.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.v');
|
|
|
|
|
+ var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.v');
|
|
|
|
|
+ f.appendChild(i1); f.appendChild(i2);
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { v: 'a' });
|
|
|
|
|
+ fire(i1, 'change');
|
|
|
|
|
+ await setState('p', { v: 'b' });
|
|
|
|
|
+ check(i1.value === 'a', 'dirty element overwritten');
|
|
|
|
|
+ check(i2.value === 'b', 'clean element not filled');
|
|
|
|
|
+ check(s.forms.isDirty(i1) && !s.forms.isDirty(i2), 'dirty flags wrong');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '10') {
|
|
|
|
|
+ // Reset restores defaults, un-dirties, and prefill re-applies.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ var i1 = input('text', 'one', 'init'); i1.setAttribute('stm-prefill', 'p.v');
|
|
|
|
|
+ f.appendChild(i1);
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { v: 'state' });
|
|
|
|
|
+ check(i1.value === 'state', 'prefill did not apply');
|
|
|
|
|
+ fire(i1, 'input');
|
|
|
|
|
+ check(s.forms.isDirty(i1), 'not dirty after edit');
|
|
|
|
|
+
|
|
|
|
|
+ f.reset();
|
|
|
|
|
+ check(i1.value === 'init', 'reset did not restore the attribute default: ' + JSON.stringify(i1.value));
|
|
|
|
|
+ check(!s.forms.isDirty(i1), 'reset did not un-dirty');
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { v: 'zzz' });
|
|
|
|
|
+ check(i1.value === 'zzz', 'prefill did not re-apply after reset');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '11') {
|
|
|
|
|
+ // Clear-on-success: action resolves -> form resets and un-dirties.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ f.setAttribute('stm-action', 'p.act');
|
|
|
|
|
+ f.setAttribute('stm-clear-on-success', '');
|
|
|
|
|
+ var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v');
|
|
|
|
|
+ f.appendChild(i1);
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } });
|
|
|
|
|
+ check(i1.value === 'typed', 'prefill did not apply');
|
|
|
|
|
+ fire(i1, 'input'); // user edits -> dirty; reset must still win via clear
|
|
|
|
|
+
|
|
|
|
|
+ globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
|
|
|
|
|
+ fire(f, 'submit');
|
|
|
|
|
+ await spin(50);
|
|
|
|
|
+
|
|
|
|
|
+ check(globalThis.__shim.fetchCalls.some(function (c) { return c.url === '/save'; }),
|
|
|
|
|
+ 'the action request never fired');
|
|
|
|
|
+ check(i1.value === 'dflt', 'form not reset after success: ' + JSON.stringify(i1.value));
|
|
|
|
|
+ check(!s.forms.isDirty(i1), 'form not un-dirtied after success');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '12') {
|
|
|
|
|
+ // Clear-on-success: failure keeps values and dirty state.
|
|
|
|
|
+ var f = el('form');
|
|
|
|
|
+ f.setAttribute('stm-action', 'p.act');
|
|
|
|
|
+ f.setAttribute('stm-clear-on-success', '');
|
|
|
|
|
+ var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v');
|
|
|
|
|
+ f.appendChild(i1);
|
|
|
|
|
+ body.appendChild(f);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } });
|
|
|
|
|
+ fire(i1, 'input');
|
|
|
|
|
+ i1.value = 'edited';
|
|
|
|
|
+
|
|
|
|
|
+ globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
|
|
|
|
|
+ fire(f, 'submit');
|
|
|
|
|
+ await spin(50);
|
|
|
|
|
+
|
|
|
|
|
+ check(i1.value === 'edited', 'form was reset despite failure: ' + JSON.stringify(i1.value));
|
|
|
|
|
+ check(s.forms.isDirty(i1), 'dirty state lost on failure');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ // Error-directive variant (statum response carrying an error directive).
|
|
|
|
|
+ globalThis.__shim.setFetch(function () {
|
|
|
|
|
+ return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
|
|
|
|
|
+ });
|
|
|
|
|
+ fire(f, 'submit');
|
|
|
|
|
+ await spin(50);
|
|
|
|
|
+ check(i1.value === 'edited', 'form was reset after an error directive');
|
|
|
|
|
+ // Core logs error directives by design; nothing else may error.
|
|
|
|
|
+ check(log.errors.length === 1 && log.errors[0].indexOf('error directive') !== -1,
|
|
|
|
|
+ 'unexpected console.error: ' + JSON.stringify(log.errors));
|
|
|
|
|
+ log.errors.length = 0;
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '13') {
|
|
|
|
|
+ // Keyed loop of forms: clearing one instance leaves the others intact.
|
|
|
|
|
+ var holder = el('div');
|
|
|
|
|
+ holder.setAttribute('stm-for-item-in', 'p.rows');
|
|
|
|
|
+ holder.setAttribute('stm-key', 'item.id');
|
|
|
|
|
+ var row = el('form');
|
|
|
|
|
+ row.setAttribute('stm-action', 'item.act');
|
|
|
|
|
+ row.setAttribute('stm-clear-on-success', '');
|
|
|
|
|
+ var ri = input('text', 'val', 'blank');
|
|
|
|
|
+ ri.setAttribute('stm-prefill', 'item.val');
|
|
|
|
|
+ row.appendChild(ri);
|
|
|
|
|
+ holder.appendChild(row);
|
|
|
|
|
+ body.appendChild(holder);
|
|
|
|
|
+
|
|
|
|
|
+ var mkAct = function (uri) { return { uri: uri, method: 'POST' }; };
|
|
|
|
|
+ await setState('p', {
|
|
|
|
|
+ rows: [
|
|
|
|
|
+ { id: 1, val: 'one', act: mkAct('/save1') },
|
|
|
|
|
+ { id: 2, val: 'two', act: mkAct('/save2') },
|
|
|
|
|
+ { id: 3, val: 'three', act: mkAct('/save3') }
|
|
|
|
|
+ ]
|
|
|
|
|
+ });
|
|
|
|
|
+ var forms = body.querySelectorAll('form').filter(function (n) {
|
|
|
|
|
+ // The loop template lives inside the (hidden) holder; instances sit in
|
|
|
|
|
+ // their own DIVs outside it.
|
|
|
|
|
+ var p = n.parentNode;
|
|
|
|
|
+ return p !== holder && p.tagName === 'DIV';
|
|
|
|
|
+ });
|
|
|
|
|
+ check(forms.length === 3, 'expected 3 instance forms, got ' + forms.length);
|
|
|
|
|
+ check(forms[0].children[0].value === 'one', 'instance 1 prefill failed');
|
|
|
|
|
+
|
|
|
|
|
+ // Dirty instance 2's input by editing it.
|
|
|
|
|
+ var i2 = forms[1].children[0];
|
|
|
|
|
+ i2.value = 'user2';
|
|
|
|
|
+ fire(i2, 'input');
|
|
|
|
|
+ // Instance 1 and 3 inputs are clean (state-filled).
|
|
|
|
|
+ globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
|
|
|
|
|
+ fire(forms[1], 'submit');
|
|
|
|
|
+ await spin(50);
|
|
|
|
|
+
|
|
|
|
|
+ check(forms[1].children[0].value === 'blank', 'instance 2 not reset: ' + JSON.stringify(forms[1].children[0].value));
|
|
|
|
|
+ check(!s.forms.isDirty(forms[1].children[0]), 'instance 2 not un-dirtied');
|
|
|
|
|
+ check(forms[0].children[0].value === 'one', 'instance 1 was touched');
|
|
|
|
|
+ check(forms[2].children[0].value === 'three', 'instance 3 was touched');
|
|
|
|
|
+ var saved = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; })
|
|
|
|
|
+ .map(function (c) { return c.url; });
|
|
|
|
|
+ check(saved.indexOf('/save2') !== -1 && saved.indexOf('/save1') === -1 && saved.indexOf('/save3') === -1,
|
|
|
|
|
+ 'wrong action fired: ' + JSON.stringify(saved));
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '14') {
|
|
|
|
|
+ // Two sibling forms: clear-on-success on A only.
|
|
|
|
|
+ var fa = el('form');
|
|
|
|
|
+ fa.setAttribute('stm-action', 'p.act');
|
|
|
|
|
+ fa.setAttribute('stm-clear-on-success', '');
|
|
|
|
|
+ var ia = input('text', 'a', 'dflt-a');
|
|
|
|
|
+ ia.setAttribute('stm-prefill', 'p.va');
|
|
|
|
|
+ fa.appendChild(ia);
|
|
|
|
|
+ var fb = el('form');
|
|
|
|
|
+ fb.setAttribute('stm-action', 'p.act');
|
|
|
|
|
+ var ib = input('text', 'b', 'dflt-b');
|
|
|
|
|
+ ib.setAttribute('stm-prefill', 'p.vb');
|
|
|
|
|
+ fb.appendChild(ib);
|
|
|
|
|
+ body.appendChild(fa); body.appendChild(fb);
|
|
|
|
|
+
|
|
|
|
|
+ await setState('p', { va: 'A', vb: 'B', act: { uri: '/save', method: 'POST' } });
|
|
|
|
|
+ fire(ib, 'input'); // B becomes dirty
|
|
|
|
|
+
|
|
|
|
|
+ globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
|
|
|
|
|
+ fire(fa, 'submit');
|
|
|
|
|
+ await spin(50);
|
|
|
|
|
+
|
|
|
|
|
+ check(ia.value === 'dflt-a', 'form A not reset');
|
|
|
|
|
+ check(!s.forms.isDirty(ia), 'form A not un-dirtied');
|
|
|
|
|
+ check(ib.value === 'B', 'form B value was touched');
|
|
|
|
|
+ check(s.forms.isDirty(ib), 'form B dirty state was touched');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '15') {
|
|
|
|
|
+ // statum.forms.reset resets exactly the given form.
|
|
|
|
|
+ var fa = el('form');
|
|
|
|
|
+ var ia = input('text', 'a', 'dflt-a');
|
|
|
|
|
+ fa.appendChild(ia);
|
|
|
|
|
+ var fb = el('form');
|
|
|
|
|
+ var ib = input('text', 'b', 'dflt-b');
|
|
|
|
|
+ fb.appendChild(ib);
|
|
|
|
|
+ body.appendChild(fa); body.appendChild(fb);
|
|
|
|
|
+
|
|
|
|
|
+ ia.value = 'x'; fire(ia, 'input');
|
|
|
|
|
+ ib.value = 'y'; fire(ib, 'input');
|
|
|
|
|
+ s.forms.reset(fa);
|
|
|
|
|
+ check(ia.value === 'dflt-a', 'form A not reset');
|
|
|
|
|
+ check(!s.forms.isDirty(ia), 'form A not un-dirtied');
|
|
|
|
|
+ check(ib.value === 'y', 'form B was touched');
|
|
|
|
|
+ check(s.forms.isDirty(ib), 'form B dirty state was touched');
|
|
|
|
|
+
|
|
|
|
|
+ // Passing a control inside a form resolves the owning form.
|
|
|
|
|
+ ib.value = 'z';
|
|
|
|
|
+ s.forms.reset(ib);
|
|
|
|
|
+ check(ib.value === 'dflt-b', 'forms.reset(el) did not resolve the owning form');
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else if (scenario === '16') {
|
|
|
|
|
+ // Handler order: stm-prefill first (property writes feed later predicates).
|
|
|
|
|
+ var names = s._bridge.attributeHandlers.map(function (h) { return h.name; });
|
|
|
|
|
+ check(names.indexOf('stm-prefill') === 0, 'stm-prefill is not first: ' + JSON.stringify(names));
|
|
|
|
|
+ check(names.indexOf('stm-class.') === 1, 'stm-class. is not second: ' + JSON.stringify(names));
|
|
|
|
|
+ check(names.indexOf('stm-markdown') === names.indexOf('stm-html') + 1, 'markdown ordering broke');
|
|
|
|
|
+ check(names[names.length - 1] === 'stm-h-demotion', 'last handler changed: ' + JSON.stringify(names));
|
|
|
|
|
+ check(log.warns.length === 0, 'warnings: ' + JSON.stringify(log.warns));
|
|
|
|
|
+ check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
|
|
|
|
|
+
|
|
|
|
|
+ } else {
|
|
|
|
|
+ fail('unknown scenario: ' + scenario);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ check(log.errors.length === 0, 'unexpected console.error: ' + JSON.stringify(log.errors));
|
|
|
|
|
+ print('scenario ' + scenario + ': OK');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+main().catch(function (e) {
|
|
|
|
|
+ std.err.puts('FAIL [' + scenario + '] ' + (e && e.stack || e) + '\n');
|
|
|
|
|
+ std.exit(1);
|
|
|
|
|
+});
|