scenarios.js 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
  1. /**
  2. * DOM-walk + forms-module scenarios for the Statum client.
  3. *
  4. * Each scenario runs in a FRESH QuickJS context (run.sh invokes this script
  5. * once per scenario) with a minimal DOM shim (shim.js) that supports the
  6. * render walk and the forms module end to end, including capture-phase
  7. * events, form reset defaults and a scriptable fetch stub.
  8. *
  9. * State enters through the real machinery: a `set` directive returned by the
  10. * fetch stub and applied by bridge.loadEntrypoint() (which re-renders through
  11. * applyDirectives -> renderAll). Signatures vary per step so the idempotency
  12. * guard never short-circuits a deliberate re-render.
  13. *
  14. * 1 walk: stm-if/stm-else chain, stm-text, stm-class.*
  15. * 2 stm-for keyed: node identity preserved per key, removal honored
  16. * 3 stm-for unkeyed: instances rebuilt (all identities change)
  17. * 4 stm-prefill: input + textarea property writes (no attribute)
  18. * 5 stm-prefill: select single + multiple (array)
  19. * 6 stm-prefill: checkbox truthy/falsy/null
  20. * 7 stm-prefill: radio group, same expression per member
  21. * 8 dirty skip: dirty input keeps user value, clean sibling re-fills
  22. * 9 dirty is per element within a form
  23. * 10 reset event un-dirties; prefill re-applies after reset
  24. * 11 stm-clear-on-success: success resets + un-dirties the form
  25. * 12 stm-clear-on-success: failure keeps values and dirty state
  26. * 13 keyed loop of forms: clearing instance 2 leaves 1 and 3 alone
  27. * 14 two sibling forms: clearing A leaves B alone
  28. * 15 statum.document.reset: resets exactly the given form
  29. * 16 handler order: stm-prefill registered before stm-class.*
  30. * 17 reenter: action triggers an in-place entrypoint refresh (fetched twice)
  31. * 18 reenter ordering: refresh before navigate, both execute
  32. * 19 reenter failure: logged only; navigate still runs; promise resolves
  33. * 20 stm-on-success: applied on success, cleared on the next attempt
  34. * 21 stm-on-error/on-success mirroring: failure clears success + vice versa
  35. * 22 stm-clear-on-error: a failing action resets the form
  36. * 23 declined stm-confirm does NOT clear (clear-on-error present)
  37. * 24 headless core: stubbed fetch applies state, no walk, no crash
  38. *
  39. * Usage: qjs tests/dom/scenarios.js <1..34>
  40. */
  41. import * as std from 'std';
  42. import { installShim } from './shim.js';
  43. // Repo root as a CWD-relative path; run.sh cds to the repo root first (same
  44. // convention as tests/markdown/spec-test.js).
  45. var ROOT = '.';
  46. var scenario = scriptArgs.length > 1 ? scriptArgs[1] : '';
  47. function fail(msg) {
  48. std.err.puts('FAIL [' + scenario + '] ' + msg + '\n');
  49. std.exit(1);
  50. }
  51. function check(cond, msg) {
  52. if (!cond) fail(msg);
  53. }
  54. function load(name) {
  55. (0, eval)(std.loadFile(ROOT + '/js/' + name));
  56. }
  57. function loadAll() {
  58. load('statum-core.js');
  59. load('statum-document.js');
  60. load('statum-markdown.js');
  61. }
  62. /** Yield to the job queue n times so promise chains settle. */
  63. async function spin(n) {
  64. for (var i = 0; i < (n || 30); i++) await Promise.resolve();
  65. }
  66. var sigCounter = 0;
  67. /**
  68. * Put `public` data into a slot type through a set directive, then re-render.
  69. * Uses loadEntrypoint (public bridge machinery) so the exact production path
  70. * (fetch -> handleResponse -> applyDirectives -> renderAll) runs.
  71. */
  72. async function setState(typeName, publicData) {
  73. sigCounter++;
  74. var snapshot = { slot: { key: 'k-' + typeName, type: typeName, scope: 'page' }, public: publicData };
  75. globalThis.__shim.setFetch(function () {
  76. return globalThis.__shim.okResponse([{
  77. type: 'set',
  78. snapshot_frame: { content: JSON.stringify(snapshot), signature: 'sig-' + sigCounter }
  79. }]);
  80. });
  81. await globalThis.statum._bridge.loadEntrypoint();
  82. await spin();
  83. }
  84. /** Fire a real (bubbling, non-cancelable-by-default) event on an element. */
  85. function fire(el, type) {
  86. var evt = globalThis.__shim.makeEvent(type);
  87. el.dispatchEvent(evt);
  88. return evt;
  89. }
  90. function el(tag) { return globalThis.__shim.makeElement(tag); }
  91. function input(type, name, value) {
  92. var i = el('input');
  93. if (type) i.setAttribute('type', type);
  94. if (name) i.setAttribute('name', name);
  95. if (value !== undefined) i.setAttribute('value', value);
  96. return i;
  97. }
  98. async function main() {
  99. installShim();
  100. var log = globalThis.__shim.log;
  101. var body = globalThis.__shim.body;
  102. // Scenario 24 runs HEADLESS: core alone, no document module.
  103. if (scenario !== '24') loadAll();
  104. else load('statum-core.js');
  105. // Binding-module scenarios additionally load it (after document).
  106. if (scenario >= 25) load('statum-binding.js');
  107. var s = globalThis.statum;
  108. check(s && s._bridge, 'statum did not load');
  109. if (scenario === '24') {
  110. // Headless core: stub the entrypoint fetch, boot, and prove state was
  111. // applied with no walk and no crash.
  112. check(s.registerAttribute === undefined,
  113. 'headless core must not expose registerAttribute (it is document API)');
  114. check(s.document === undefined, 'headless core must not expose statum.document');
  115. sigCounter++;
  116. var snap = { slot: { key: 'k-h', type: 'h', scope: 'page' }, public: { hello: 'world' } };
  117. globalThis.__shim.setFetch(function () {
  118. return globalThis.__shim.okResponse([{
  119. type: 'set',
  120. snapshot_frame: { content: JSON.stringify(snap), signature: 'sig-h-' + sigCounter }
  121. }]);
  122. });
  123. await s.init();
  124. await spin(50);
  125. check(s.read('h') && s.read('h').hello === 'world',
  126. 'headless boot did not apply the entrypoint state: ' + JSON.stringify(s.state()));
  127. check(JSON.stringify(s.state()) === '{"h":{"hello":"world"}}',
  128. 'unexpected state: ' + JSON.stringify(s.state()));
  129. // A render cycle with no driver and no hooks must not crash or warn.
  130. s._bridge.renderAll();
  131. await spin(20);
  132. check(log.errors.length === 0, 'headless core errored: ' + JSON.stringify(log.errors));
  133. check(log.warns.length === 0, 'headless core warned: ' + JSON.stringify(log.warns));
  134. print('scenario ' + scenario + ': OK');
  135. return;
  136. }
  137. check(s.document && typeof s.document.reset === 'function', 'statum.document.reset missing');
  138. check(typeof s.document.markDirty === 'function' && typeof s.document.isDirty === 'function',
  139. 'statum.document.markDirty/isDirty missing');
  140. check(s.forms === undefined, 'statum.forms must be gone (merged into statum.document)');
  141. check(s._bridge.formsDirty instanceof WeakSet, 'bridge.formsDirty missing');
  142. check(typeof s._bridge.markDirty === 'function' && typeof s._bridge.isDirty === 'function',
  143. 'bridge.markDirty/isDirty missing');
  144. if (scenario === '1') {
  145. // if/else-if/else chain + stm-text + stm-class.*
  146. var w = el('div');
  147. var a = el('p'); a.setAttribute('stm-if', "m.mood === 'up'"); a.textContent = 'up';
  148. var b = el('p'); b.setAttribute('stm-else-if', "m.mood === 'mid'"); b.textContent = 'mid';
  149. var c = el('p'); c.setAttribute('stm-else', ''); c.textContent = 'down';
  150. w.appendChild(a); w.appendChild(b); w.appendChild(c);
  151. var t = el('span'); t.setAttribute('stm-text', 'm.mood');
  152. var cl = el('span'); cl.setAttribute('stm-class.hi', "m.mood === 'up'");
  153. w.appendChild(t); w.appendChild(cl);
  154. body.appendChild(w);
  155. await setState('m', { mood: 'up' });
  156. check(!a.hasAttribute('hidden') && b.hasAttribute('hidden') && c.hasAttribute('hidden'),
  157. 'branch up not exclusively shown');
  158. check(t.textContent === 'up', 'stm-text wrote ' + JSON.stringify(t.textContent));
  159. check(cl.classList.contains('hi'), 'stm-class.hi not applied');
  160. await setState('m', { mood: 'mid' });
  161. check(a.hasAttribute('hidden') && !b.hasAttribute('hidden') && c.hasAttribute('hidden'),
  162. 'branch mid not exclusively shown');
  163. check(!cl.classList.contains('hi'), 'stm-class.hi not removed');
  164. check(t.textContent === 'mid', 'stm-text not updated');
  165. await setState('m', { mood: 'zzz' });
  166. check(a.hasAttribute('hidden') && b.hasAttribute('hidden') && !c.hasAttribute('hidden'),
  167. 'else branch not shown');
  168. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  169. } else if (scenario === '2') {
  170. // Keyed loop: identity per key preserved; removal honored.
  171. var holder = el('div');
  172. holder.setAttribute('stm-for-item-in', 'list.items');
  173. holder.setAttribute('stm-key', 'item.id');
  174. var row = el('p');
  175. row.setAttribute('stm-text', 'item.name');
  176. holder.appendChild(row);
  177. body.appendChild(holder);
  178. await setState('list', { items: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 3, name: 'c' }] });
  179. var insts = holder.parentNode.children.filter(function (n) { return n !== holder; });
  180. // holder is children[0]; instances follow it in order.
  181. var kids = body.children.filter(function (n) { return n !== holder; });
  182. check(kids.length === 3, 'expected 3 instances, got ' + kids.length);
  183. var k1 = kids[0], k2 = kids[1], k3 = kids[2];
  184. // The stm-text binding is on the template's child, so read the child.
  185. check(k1.children[0].textContent === 'a' && k2.children[0].textContent === 'b' &&
  186. k3.children[0].textContent === 'c',
  187. 'instance texts: ' + kids.map(function (n) { return n.children[0] && n.children[0].textContent; }).join(','));
  188. await setState('list', { items: [{ id: 1, name: 'a' }, { id: 3, name: 'c3' }] });
  189. var kids2 = body.children.filter(function (n) { return n !== holder; });
  190. check(kids2.length === 2, 'expected 2 instances after removal, got ' + kids2.length);
  191. check(kids2[0] === k1, 'key 1 node identity not preserved');
  192. check(kids2[1] === k3, 'key 3 node identity not preserved');
  193. check(kids2[1].children[0].textContent === 'c3', 'key 3 text not refreshed');
  194. check(k2.parentNode === null, 'removed instance still attached');
  195. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  196. } else if (scenario === '3') {
  197. // Unkeyed loop: rebuild wholesale.
  198. var holder = el('div');
  199. holder.setAttribute('stm-for-item-in', 'list.items');
  200. var row = el('p');
  201. row.setAttribute('stm-text', 'item.name');
  202. holder.appendChild(row);
  203. body.appendChild(holder);
  204. await setState('list', { items: [{ name: 'a' }, { name: 'b' }] });
  205. var kids = body.children.filter(function (n) { return n !== holder; });
  206. check(kids.length === 2 && kids[0].children[0].textContent === 'a' &&
  207. kids[1].children[0].textContent === 'b', 'initial render');
  208. await setState('list', { items: [{ name: 'a' }, { name: 'b' }] });
  209. var kids2 = body.children.filter(function (n) { return n !== holder; });
  210. check(kids2.length === 2, 'instance count changed');
  211. check(kids2[0] !== kids[0] && kids2[1] !== kids[1], 'unkeyed loop reused nodes');
  212. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  213. } else if (scenario === '4') {
  214. // Prefill writes the value PROPERTY, never the value attribute.
  215. var f = el('form');
  216. var ti = input('text', 'who'); ti.setAttribute('stm-prefill', 'p.who');
  217. var ta = el('textarea'); ta.setAttribute('name', 'bio'); ta.setAttribute('stm-prefill', 'p.bio');
  218. f.appendChild(ti); f.appendChild(ta);
  219. body.appendChild(f);
  220. await setState('p', { who: 'ada', bio: 'hi' });
  221. check(ti.value === 'ada', 'text input value = ' + JSON.stringify(ti.value));
  222. check(ta.value === 'hi', 'textarea value = ' + JSON.stringify(ta.value));
  223. check(ti.getAttribute('value') === null, 'text input value ATTRIBUTE was written');
  224. check(ta.getAttribute('stm-prefill') === 'p.bio', 'stm-prefill attribute consumed');
  225. await setState('p', { who: null, bio: undefined });
  226. check(ti.value === '', 'null should prefill as empty string, got ' + JSON.stringify(ti.value));
  227. check(ta.value === 'hi', 'undefined must leave the element untouched');
  228. await setState('p', { who: 42, bio: 'x' });
  229. check(ti.value === '42', 'number stringified: ' + JSON.stringify(ti.value));
  230. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  231. } else if (scenario === '5') {
  232. // Select single + multiple.
  233. var f = el('form');
  234. var sel = el('select'); sel.setAttribute('stm-prefill', 'p.pick');
  235. var o1 = el('option'); o1.setAttribute('value', 'a'); o1.textContent = 'A';
  236. var o2 = el('option'); o2.setAttribute('value', 'b'); o2.textContent = 'B';
  237. sel.appendChild(o1); sel.appendChild(o2);
  238. var multi = el('select'); multi.setAttribute('multiple', ''); multi.setAttribute('stm-prefill', 'p.set');
  239. var m1 = el('option'); m1.setAttribute('value', 'x');
  240. var m2 = el('option'); m2.setAttribute('value', 'y');
  241. var m3 = el('option'); m3.setAttribute('value', 'z');
  242. multi.appendChild(m1); multi.appendChild(m2); multi.appendChild(m3);
  243. f.appendChild(sel); f.appendChild(multi);
  244. body.appendChild(f);
  245. await setState('p', { pick: 'b', set: ['x', 'z'] });
  246. check(sel.value === 'b', 'single select value = ' + JSON.stringify(sel.value));
  247. check(o2.selected === true && o1.selected === false, 'single select option states wrong');
  248. check(m1.selected === true && m2.selected === false && m3.selected === true,
  249. 'multiple select option states wrong');
  250. await setState('p', { pick: 'nope', set: 'y' });
  251. check(sel.value === '', 'unmatched single select should yield empty value');
  252. check(m1.selected === false && m2.selected === true && m3.selected === false,
  253. 'non-array multiple value must behave like single');
  254. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  255. } else if (scenario === '6') {
  256. // Checkbox: truthy -> checked, falsy/null -> unchecked.
  257. var f = el('form');
  258. var cb = input('checkbox', 'ok'); cb.setAttribute('stm-prefill', 'p.ok');
  259. f.appendChild(cb);
  260. body.appendChild(f);
  261. await setState('p', { ok: true });
  262. check(cb.checked === true, 'truthy did not check');
  263. await setState('p', { ok: false });
  264. check(cb.checked === false, 'falsy did not uncheck');
  265. await setState('p', { ok: null });
  266. check(cb.checked === false, 'null did not uncheck');
  267. await setState('p', { ok: 'yes' });
  268. check(cb.checked === true, 'non-empty string should check');
  269. await setState('p', {}); // ok undefined -> untouched
  270. check(cb.checked === true, 'undefined must leave checked untouched');
  271. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  272. } else if (scenario === '7') {
  273. // Radio group: every member carries the same expression; the member whose
  274. // own value matches the resolved value gets checked.
  275. var f = el('form');
  276. var mk = function (v) {
  277. var r = input('radio', 'g', v);
  278. r.setAttribute('stm-prefill', 'p.choice');
  279. f.appendChild(r);
  280. return r;
  281. };
  282. var ra = mk('a'), rb = mk('b'), rc = mk('c');
  283. body.appendChild(f);
  284. await setState('p', { choice: 'b' });
  285. check(ra.checked === false && rb.checked === true && rc.checked === false,
  286. 'state b checked the wrong member(s)');
  287. await setState('p', { choice: 'd' });
  288. check(ra.checked === false && rb.checked === false && rc.checked === false,
  289. 'state d (no match) must leave all unchecked');
  290. await setState('p', { choice: null });
  291. check(rb.checked === false, 'null must uncheck (String(null) matches nothing)');
  292. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  293. } else if (scenario === '8') {
  294. // A dirty element is never overwritten; a clean sibling still fills.
  295. var f = el('form');
  296. var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.one');
  297. var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.two');
  298. f.appendChild(i1); f.appendChild(i2);
  299. body.appendChild(f);
  300. await setState('p', { one: 'x', two: 'x' });
  301. check(i1.value === 'x' && i2.value === 'x', 'initial fill failed');
  302. fire(i1, 'input'); // user edits input 1
  303. check(s.document.isDirty(i1), 'input event did not mark the element dirty');
  304. await setState('p', { one: 'y', two: 'y' });
  305. check(i1.value === 'x', 'dirty element was overwritten: ' + JSON.stringify(i1.value));
  306. check(i2.value === 'y', 'clean sibling not re-filled');
  307. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  308. } else if (scenario === '9') {
  309. // Dirtiness is per element.
  310. var f = el('form');
  311. var i1 = input('text', 'one'); i1.setAttribute('stm-prefill', 'p.v');
  312. var i2 = input('text', 'two'); i2.setAttribute('stm-prefill', 'p.v');
  313. f.appendChild(i1); f.appendChild(i2);
  314. body.appendChild(f);
  315. await setState('p', { v: 'a' });
  316. fire(i1, 'change');
  317. await setState('p', { v: 'b' });
  318. check(i1.value === 'a', 'dirty element overwritten');
  319. check(i2.value === 'b', 'clean element not filled');
  320. check(s.document.isDirty(i1) && !s.document.isDirty(i2), 'dirty flags wrong');
  321. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  322. } else if (scenario === '10') {
  323. // Reset restores defaults, un-dirties, and prefill re-applies.
  324. var f = el('form');
  325. var i1 = input('text', 'one', 'init'); i1.setAttribute('stm-prefill', 'p.v');
  326. f.appendChild(i1);
  327. body.appendChild(f);
  328. await setState('p', { v: 'state' });
  329. check(i1.value === 'state', 'prefill did not apply');
  330. fire(i1, 'input');
  331. check(s.document.isDirty(i1), 'not dirty after edit');
  332. f.reset();
  333. check(i1.value === 'init', 'reset did not restore the attribute default: ' + JSON.stringify(i1.value));
  334. check(!s.document.isDirty(i1), 'reset did not un-dirty');
  335. await setState('p', { v: 'zzz' });
  336. check(i1.value === 'zzz', 'prefill did not re-apply after reset');
  337. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  338. } else if (scenario === '11') {
  339. // Clear-on-success: action resolves -> form resets and un-dirties.
  340. var f = el('form');
  341. f.setAttribute('stm-action', 'p.act');
  342. f.setAttribute('stm-clear-on-success', '');
  343. var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v');
  344. f.appendChild(i1);
  345. body.appendChild(f);
  346. await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } });
  347. check(i1.value === 'typed', 'prefill did not apply');
  348. fire(i1, 'input'); // user edits -> dirty; reset must still win via clear
  349. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  350. fire(f, 'submit');
  351. await spin(50);
  352. check(globalThis.__shim.fetchCalls.some(function (c) { return c.url === '/save'; }),
  353. 'the action request never fired');
  354. check(i1.value === 'dflt', 'form not reset after success: ' + JSON.stringify(i1.value));
  355. check(!s.document.isDirty(i1), 'form not un-dirtied after success');
  356. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  357. } else if (scenario === '12') {
  358. // Clear-on-success: failure keeps values and dirty state.
  359. var f = el('form');
  360. f.setAttribute('stm-action', 'p.act');
  361. f.setAttribute('stm-clear-on-success', '');
  362. var i1 = input('text', 'one', 'dflt'); i1.setAttribute('stm-prefill', 'p.v');
  363. f.appendChild(i1);
  364. body.appendChild(f);
  365. await setState('p', { v: 'typed', act: { uri: '/save', method: 'POST' } });
  366. fire(i1, 'input');
  367. i1.value = 'edited';
  368. globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
  369. fire(f, 'submit');
  370. await spin(50);
  371. check(i1.value === 'edited', 'form was reset despite failure: ' + JSON.stringify(i1.value));
  372. check(s.document.isDirty(i1), 'dirty state lost on failure');
  373. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  374. // Error-directive variant (statum response carrying an error directive).
  375. globalThis.__shim.setFetch(function () {
  376. return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
  377. });
  378. fire(f, 'submit');
  379. await spin(50);
  380. check(i1.value === 'edited', 'form was reset after an error directive');
  381. // Core logs error directives by design; nothing else may error.
  382. check(log.errors.length === 1 && log.errors[0].indexOf('error directive') !== -1,
  383. 'unexpected console.error: ' + JSON.stringify(log.errors));
  384. log.errors.length = 0;
  385. } else if (scenario === '13') {
  386. // Keyed loop of forms: clearing one instance leaves the others intact.
  387. var holder = el('div');
  388. holder.setAttribute('stm-for-item-in', 'p.rows');
  389. holder.setAttribute('stm-key', 'item.id');
  390. var row = el('form');
  391. row.setAttribute('stm-action', 'item.act');
  392. row.setAttribute('stm-clear-on-success', '');
  393. var ri = input('text', 'val', 'blank');
  394. ri.setAttribute('stm-prefill', 'item.val');
  395. row.appendChild(ri);
  396. holder.appendChild(row);
  397. body.appendChild(holder);
  398. var mkAct = function (uri) { return { uri: uri, method: 'POST' }; };
  399. await setState('p', {
  400. rows: [
  401. { id: 1, val: 'one', act: mkAct('/save1') },
  402. { id: 2, val: 'two', act: mkAct('/save2') },
  403. { id: 3, val: 'three', act: mkAct('/save3') }
  404. ]
  405. });
  406. var forms = body.querySelectorAll('form').filter(function (n) {
  407. // The loop template lives inside the (hidden) holder; instances sit in
  408. // their own DIVs outside it.
  409. var p = n.parentNode;
  410. return p !== holder && p.tagName === 'DIV';
  411. });
  412. check(forms.length === 3, 'expected 3 instance forms, got ' + forms.length);
  413. check(forms[0].children[0].value === 'one', 'instance 1 prefill failed');
  414. // Dirty instance 2's input by editing it.
  415. var i2 = forms[1].children[0];
  416. i2.value = 'user2';
  417. fire(i2, 'input');
  418. // Instance 1 and 3 inputs are clean (state-filled).
  419. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  420. fire(forms[1], 'submit');
  421. await spin(50);
  422. check(forms[1].children[0].value === 'blank', 'instance 2 not reset: ' + JSON.stringify(forms[1].children[0].value));
  423. check(!s.document.isDirty(forms[1].children[0]), 'instance 2 not un-dirtied');
  424. check(forms[0].children[0].value === 'one', 'instance 1 was touched');
  425. check(forms[2].children[0].value === 'three', 'instance 3 was touched');
  426. var saved = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; })
  427. .map(function (c) { return c.url; });
  428. check(saved.indexOf('/save2') !== -1 && saved.indexOf('/save1') === -1 && saved.indexOf('/save3') === -1,
  429. 'wrong action fired: ' + JSON.stringify(saved));
  430. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  431. } else if (scenario === '14') {
  432. // Two sibling forms: clear-on-success on A only.
  433. var fa = el('form');
  434. fa.setAttribute('stm-action', 'p.act');
  435. fa.setAttribute('stm-clear-on-success', '');
  436. var ia = input('text', 'a', 'dflt-a');
  437. ia.setAttribute('stm-prefill', 'p.va');
  438. fa.appendChild(ia);
  439. var fb = el('form');
  440. fb.setAttribute('stm-action', 'p.act');
  441. var ib = input('text', 'b', 'dflt-b');
  442. ib.setAttribute('stm-prefill', 'p.vb');
  443. fb.appendChild(ib);
  444. body.appendChild(fa); body.appendChild(fb);
  445. await setState('p', { va: 'A', vb: 'B', act: { uri: '/save', method: 'POST' } });
  446. fire(ib, 'input'); // B becomes dirty
  447. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  448. fire(fa, 'submit');
  449. await spin(50);
  450. check(ia.value === 'dflt-a', 'form A not reset');
  451. check(!s.document.isDirty(ia), 'form A not un-dirtied');
  452. check(ib.value === 'B', 'form B value was touched');
  453. check(s.document.isDirty(ib), 'form B dirty state was touched');
  454. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  455. } else if (scenario === '15') {
  456. // statum.forms.reset resets exactly the given form.
  457. var fa = el('form');
  458. var ia = input('text', 'a', 'dflt-a');
  459. fa.appendChild(ia);
  460. var fb = el('form');
  461. var ib = input('text', 'b', 'dflt-b');
  462. fb.appendChild(ib);
  463. body.appendChild(fa); body.appendChild(fb);
  464. ia.value = 'x'; fire(ia, 'input');
  465. ib.value = 'y'; fire(ib, 'input');
  466. s.document.reset(fa);
  467. check(ia.value === 'dflt-a', 'form A not reset');
  468. check(!s.document.isDirty(ia), 'form A not un-dirtied');
  469. check(ib.value === 'y', 'form B was touched');
  470. check(s.document.isDirty(ib), 'form B dirty state was touched');
  471. // Passing a control inside a form resolves the owning form.
  472. ib.value = 'z';
  473. s.document.reset(ib);
  474. check(ib.value === 'dflt-b', 'forms.reset(el) did not resolve the owning form');
  475. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  476. } else if (scenario === '16') {
  477. // Handler order: stm-prefill first (property writes feed later predicates).
  478. var names = s._bridge.attributeHandlers.map(function (h) { return h.name; });
  479. check(names.indexOf('stm-prefill') === 0, 'stm-prefill is not first: ' + JSON.stringify(names));
  480. check(names.indexOf('stm-class.') === 1, 'stm-class. is not second: ' + JSON.stringify(names));
  481. check(names.indexOf('stm-markdown') === names.indexOf('stm-html') + 1, 'markdown ordering broke');
  482. check(names[names.length - 1] === 'stm-h-demotion', 'last handler changed: ' + JSON.stringify(names));
  483. check(log.warns.length === 0, 'warnings: ' + JSON.stringify(log.warns));
  484. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  485. } else if (scenario === '17') {
  486. // reenter: an action response re-fetches the entrypoint in place.
  487. var t = el('span'); t.setAttribute('stm-text', 'm.mood');
  488. body.appendChild(t);
  489. sigCounter++;
  490. var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
  491. var snap2 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'second' } };
  492. var entrypointCount = 0;
  493. globalThis.__shim.setFetch(function (url) {
  494. if (String(url).indexOf('/_statum/entrypoint') !== -1) {
  495. entrypointCount++;
  496. return globalThis.__shim.okResponse([{
  497. type: 'set',
  498. snapshot_frame: {
  499. content: JSON.stringify(entrypointCount === 1 ? snap1 : snap2),
  500. signature: 'sig-r17-' + entrypointCount
  501. }
  502. }]);
  503. }
  504. // The action request: reply with a reenter directive.
  505. return globalThis.__shim.okResponse([{ type: 'reenter' }]);
  506. });
  507. await s._bridge.loadEntrypoint();
  508. await spin();
  509. check(t.textContent === 'first', 'initial entrypoint state not applied: ' + JSON.stringify(t.textContent));
  510. var initialFetches = globalThis.__shim.fetchCalls.length;
  511. await s._bridge.performAction({ uri: '/save', method: 'POST' }, {});
  512. await spin(50);
  513. var entryFetches = globalThis.__shim.fetchCalls.filter(function (c) {
  514. return c.url.indexOf('/_statum/entrypoint') !== -1;
  515. });
  516. check(entryFetches.length === 2, 'entrypoint fetched ' + entryFetches.length + ' time(s), expected 2 (initial + reenter)');
  517. check(globalThis.__shim.fetchCalls.length === initialFetches + 2,
  518. 'exactly two new requests were expected (action + refresh), got ' +
  519. (globalThis.__shim.fetchCalls.length - initialFetches));
  520. check(t.textContent === 'second', 'reentered entrypoint state not applied: ' + JSON.stringify(t.textContent));
  521. check(globalThis.location.href === 'http://localhost/', 'a bare reenter must not navigate');
  522. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  523. } else if (scenario === '18') {
  524. // reenter + navigate in one response: refresh first, then navigate wins.
  525. var t = el('span'); t.setAttribute('stm-text', 'm.mood');
  526. body.appendChild(t);
  527. sigCounter++;
  528. var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
  529. var snap2 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'second' } };
  530. var entrypointCount = 0;
  531. var order = []; // shared log: reenter's fetch vs the location change
  532. globalThis.__shim.setFetch(function (url) {
  533. if (String(url).indexOf('/_statum/entrypoint') !== -1) {
  534. entrypointCount++;
  535. order.push('entrypoint:' + entrypointCount);
  536. return globalThis.__shim.okResponse([{
  537. type: 'set',
  538. snapshot_frame: {
  539. content: JSON.stringify(entrypointCount === 1 ? snap1 : snap2),
  540. signature: 'sig-r18-' + entrypointCount
  541. }
  542. }]);
  543. }
  544. return globalThis.__shim.okResponse([{ type: 'reenter' }, { type: 'navigate', uri: '/after' }]);
  545. });
  546. // Record the navigation in the shared order log.
  547. var originalHref = globalThis.location.href;
  548. Object.defineProperty(globalThis.location, 'href', {
  549. configurable: true,
  550. get: function () { return originalHref; },
  551. set: function (v) { order.push('navigate:' + v); }
  552. });
  553. await s._bridge.loadEntrypoint();
  554. await spin();
  555. check(t.textContent === 'first', 'initial entrypoint state not applied');
  556. await s._bridge.performAction({ uri: '/save', method: 'POST' }, {});
  557. await spin(50);
  558. check(globalThis.__shim.fetchCalls.filter(function (c) {
  559. return c.url.indexOf('/_statum/entrypoint') !== -1;
  560. }).length === 2, 'reenter did not refresh the entrypoint');
  561. check(order.indexOf('navigate:/after') !== -1, 'navigate did not run');
  562. check(order.indexOf('entrypoint:2') < order.indexOf('navigate:/after'),
  563. 'reenter must run before navigate, order was ' + JSON.stringify(order));
  564. check(t.textContent === 'second', 'refresh state not applied before navigating');
  565. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  566. } else if (scenario === '19') {
  567. // A failing reenter refresh: logged, swallowed, navigate still runs, and
  568. // the action promise resolves.
  569. var t = el('span'); t.setAttribute('stm-text', 'm.mood');
  570. body.appendChild(t);
  571. sigCounter++;
  572. var snap1 = { slot: { key: 'k-m', type: 'm', scope: 'page' }, public: { mood: 'first' } };
  573. var entrypointCount = 0;
  574. globalThis.__shim.setFetch(function (url) {
  575. if (String(url).indexOf('/_statum/entrypoint') !== -1) {
  576. entrypointCount++;
  577. if (entrypointCount === 1) {
  578. return globalThis.__shim.okResponse([{
  579. type: 'set',
  580. snapshot_frame: { content: JSON.stringify(snap1), signature: 'sig-r19-1' }
  581. }]);
  582. }
  583. return Promise.reject(new Error('network down'));
  584. }
  585. return globalThis.__shim.okResponse([{ type: 'reenter' }, { type: 'navigate', uri: '/after' }]);
  586. });
  587. var navigatedTo = null;
  588. Object.defineProperty(globalThis.location, 'href', {
  589. configurable: true,
  590. get: function () { return 'http://localhost/'; },
  591. set: function (v) { navigatedTo = v; }
  592. });
  593. await s._bridge.loadEntrypoint();
  594. await spin();
  595. var resolved = false;
  596. var rejected = null;
  597. await s._bridge.performAction({ uri: '/save', method: 'POST' }, {}).then(function () {
  598. resolved = true;
  599. }, function (e) {
  600. rejected = e;
  601. });
  602. await spin(50);
  603. check(resolved && rejected === null, 'action promise should resolve after a failed reenter, rejected=' + String(rejected));
  604. check(log.errors.some(function (m) { return m.indexOf('[statum] reenter: entrypoint refresh failed') !== -1; }),
  605. 'reenter failure was not logged: ' + JSON.stringify(log.errors));
  606. check(navigatedTo === '/after', 'navigate did not run after the failed reenter, navigatedTo=' + JSON.stringify(navigatedTo));
  607. check(log.errors.length === 1, 'exactly one console.error expected: ' + JSON.stringify(log.errors));
  608. // Core logs the swallowed reenter failure by design; nothing else may error.
  609. log.errors.length = 0;
  610. } else if (scenario === '20') {
  611. // stm-on-success: applied on success, cleared on the next attempt.
  612. var b = el('button');
  613. b.setAttribute('stm-action', 'p.act');
  614. b.setAttribute('stm-on-success', 'saved ok');
  615. body.appendChild(b);
  616. await setState('p', { act: { uri: '/save', method: 'POST' } });
  617. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  618. fire(b, 'click');
  619. await spin(50);
  620. check(b.classList.contains('saved') && b.classList.contains('ok'),
  621. 'success classes not applied: ' + JSON.stringify(b.classList));
  622. // A new attempt clears them at attempt start. The fetch stub below never
  623. // resolves, freezing the attempt mid-flight so the cleared state (before
  624. // any outcome re-applies classes) is observable.
  625. var hang = new Promise(function () {});
  626. globalThis.__shim.setFetch(function () { return hang; });
  627. fire(b, 'click');
  628. await spin(20);
  629. check(!b.classList.contains('saved') && !b.classList.contains('ok'),
  630. 'success classes not cleared on a new attempt');
  631. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  632. } else if (scenario === '21') {
  633. // Mirroring: a failure applies stm-on-error and clears stm-on-success;
  634. // a later success applies stm-on-success and clears stm-on-error.
  635. var b = el('button');
  636. b.setAttribute('stm-action', 'p.act');
  637. b.setAttribute('stm-on-error', 'boom');
  638. b.setAttribute('stm-on-success', 'yay');
  639. body.appendChild(b);
  640. await setState('p', { act: { uri: '/save', method: 'POST' } });
  641. globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
  642. fire(b, 'click');
  643. await spin(50);
  644. check(b.classList.contains('boom'), 'error classes not applied');
  645. check(!b.classList.contains('yay'), 'success classes survived a failure');
  646. log.errors.length = 0;
  647. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  648. fire(b, 'click');
  649. await spin(50);
  650. check(b.classList.contains('yay'), 'success classes not applied after a failure');
  651. check(!b.classList.contains('boom'), 'error classes survived a success');
  652. // And an error-directive failure mirrors the HTTP failure exactly.
  653. globalThis.__shim.setFetch(function () {
  654. return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
  655. });
  656. fire(b, 'click');
  657. await spin(50);
  658. check(b.classList.contains('boom'), 'error classes not applied (error directive)');
  659. check(!b.classList.contains('yay'), 'success classes survived an error directive');
  660. log.errors.length = 0;
  661. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  662. } else if (scenario === '22') {
  663. // stm-clear-on-error: a failing action resets the form (values and dirty).
  664. var f = el('form');
  665. f.setAttribute('stm-action', 'p.act');
  666. f.setAttribute('stm-clear-on-error', '');
  667. var i1 = input('text', 'a', 'dflt');
  668. i1.setAttribute('stm-prefill', 'p.v');
  669. f.appendChild(i1);
  670. body.appendChild(f);
  671. await setState('p', { v: 'from state', act: { uri: '/save', method: 'POST' } });
  672. i1.value = 'user typed';
  673. fire(i1, 'input');
  674. check(s.document.isDirty(i1), 'input not dirty after edit');
  675. globalThis.__shim.setFetch(function () { return globalThis.__shim.failResponse(500); });
  676. fire(f, 'submit');
  677. await spin(50);
  678. check(i1.value === 'dflt', 'form was not reset on failure: ' + JSON.stringify(i1.value));
  679. check(!s.document.isDirty(i1), 'form was not un-dirtied on failure');
  680. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  681. // A success must NOT reset a stm-clear-on-error form.
  682. i1.value = 'typed again';
  683. fire(i1, 'input');
  684. globalThis.__shim.setFetch(function () { return globalThis.__shim.okResponse([]); });
  685. fire(f, 'submit');
  686. await spin(50);
  687. check(i1.value === 'typed again', 'stm-clear-on-error fired on success: ' + JSON.stringify(i1.value));
  688. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  689. } else if (scenario === '23') {
  690. // A declined stm-confirm never clears: decline is cancellation, not error.
  691. var f = el('form');
  692. f.setAttribute('stm-action', 'p.act');
  693. f.setAttribute('stm-confirm', 'sure?');
  694. f.setAttribute('stm-clear-on-error', '');
  695. var i1 = input('text', 'a', 'dflt');
  696. f.appendChild(i1);
  697. body.appendChild(f);
  698. await setState('p', { act: { uri: '/save', method: 'POST' } });
  699. i1.value = 'user typed';
  700. fire(i1, 'input');
  701. // Decline the confirm (custom handler returning false).
  702. s.onConfirmHandler = function () { return false; };
  703. fire(f, 'submit');
  704. await spin(50);
  705. check(i1.value === 'user typed', 'declined confirm cleared the form: ' + JSON.stringify(i1.value));
  706. check(s.document.isDirty(i1), 'declined confirm un-dirtied the form');
  707. var posts = globalThis.__shim.fetchCalls.filter(function (c) { return c.method === 'POST'; });
  708. check(posts.length === 0, 'a declined confirm still fired a request: ' + JSON.stringify(posts));
  709. check(log.errors.length === 0, 'errors: ' + JSON.stringify(log.errors));
  710. // The error-directive path of clear-on-error: failure with a Statum body.
  711. s.onConfirmHandler = null; // window.confirm fallback: shim returns true
  712. i1.value = 'typed more';
  713. fire(i1, 'input');
  714. globalThis.__shim.setFetch(function () {
  715. return globalThis.__shim.okResponse([{ type: 'error', message: 'nope' }]);
  716. });
  717. fire(f, 'submit');
  718. await spin(50);
  719. check(i1.value === 'dflt', 'error directive did not reset a clear-on-error form: ' + JSON.stringify(i1.value));
  720. log.errors.length = 0;
  721. } else if (scenario === '25') {
  722. // stm-local basic: subtree scoping + evaluation through stm-text.
  723. var holder = el('div');
  724. holder.setAttribute('stm-local-x', '1 + 1');
  725. var span = el('span'); span.setAttribute('stm-text', 'x');
  726. holder.appendChild(span);
  727. body.appendChild(holder);
  728. var outside = el('span'); outside.setAttribute('stm-text', 'typeof x');
  729. body.appendChild(outside);
  730. await s._bridge.renderAll(); await spin();
  731. check(span.textContent === '2', 'stm-local-x did not resolve: ' + span.textContent);
  732. check(outside.textContent === 'undefined', 'local leaked outside its subtree');
  733. } else if (scenario === '26') {
  734. // Lazy evaluation: once per pass even when read many times.
  735. var calls = 0;
  736. globalThis.__count = function () { calls++; return calls; };
  737. var holder2 = el('div');
  738. holder2.setAttribute('stm-local-n', '__count()');
  739. var a2 = el('span'); a2.setAttribute('stm-text', 'n');
  740. var b2 = el('span'); b2.setAttribute('stm-text', 'n + n');
  741. holder2.appendChild(a2); holder2.appendChild(b2);
  742. body.appendChild(holder2);
  743. await s._bridge.renderAll(); await spin();
  744. check(a2.textContent === '1' && b2.textContent === '2', 'lazy value wrong: ' + a2.textContent + '/' + b2.textContent);
  745. check(calls === 1, 'expression evaluated ' + calls + ' times in one pass (must be 1)');
  746. await s._bridge.renderAll(); await spin();
  747. check(calls === 2, 'second pass did not re-evaluate (calls=' + calls + ')');
  748. } else if (scenario === '27') {
  749. // stm-global: visible page-wide on the FIRST pass (the pass-start sweep
  750. // exposes lazy getters; a reader before the declaring element resolves
  751. // the same binding on demand), and stable across passes.
  752. var early = el('span'); early.setAttribute('stm-text', 'typeof g');
  753. body.appendChild(early);
  754. var late = el('div'); late.setAttribute('stm-global-g', '41 + 1');
  755. var lateSpan = el('span'); lateSpan.setAttribute('stm-text', 'g');
  756. late.appendChild(lateSpan);
  757. body.appendChild(late);
  758. await s._bridge.renderAll(); await spin();
  759. check(early.textContent === 'number', 'global not visible before its declaration on pass 1');
  760. await s._bridge.renderAll(); await spin();
  761. check(early.textContent === 'number', 'global lost on second pass');
  762. // Prove the VALUE is reachable, not just the type, via an if-predicate.
  763. var gate = el('div'); gate.setAttribute('stm-if', 'g === 42');
  764. body.appendChild(gate);
  765. await s._bridge.renderAll(); await spin();
  766. check(!gate.hasAttribute('hidden'), 'g === 42 did not evaluate true');
  767. } else if (scenario === '28') {
  768. // Duplicate stm-global: last in document order wins, one warn per pass.
  769. var g1 = el('div'); g1.setAttribute('stm-global-dup', '1');
  770. var g2 = el('div'); g2.setAttribute('stm-global-dup', '2');
  771. var rd = el('span'); rd.setAttribute('stm-text', 'dup');
  772. body.appendChild(g1); body.appendChild(g2); body.appendChild(rd);
  773. log.warns.length = 0;
  774. await s._bridge.renderAll(); await spin();
  775. check(rd.textContent === '2', 'last duplicate did not win: ' + rd.textContent);
  776. check(log.warns.length === 1, 'expected exactly one duplicate warn, got ' + log.warns.length);
  777. log.warns.length = 0;
  778. await s._bridge.renderAll(); await spin();
  779. check(log.warns.length === 1, 'warn must fire once per pass');
  780. } else if (scenario === '29') {
  781. // Cycles: logged as an error, variables read undefined, walk survives.
  782. var ca = el('div'); ca.setAttribute('stm-global-a', 'b');
  783. var cb = el('div'); cb.setAttribute('stm-global-b', 'a');
  784. var ra = el('span'); ra.setAttribute('stm-text', 'typeof a');
  785. body.appendChild(ca); body.appendChild(cb); body.appendChild(ra);
  786. await s._bridge.renderAll(); await spin();
  787. check(log.errors.length >= 1 && /variable cycle/.test(log.errors[0]),
  788. 'cycle not logged: ' + JSON.stringify(log.errors));
  789. check(ra.textContent === 'undefined', 'cyclic variable must read undefined');
  790. log.errors.length = 0;
  791. } else if (scenario === '30') {
  792. // from-attribute (non-control): observer redraws on real change only.
  793. var host = el('div'); host.setAttribute('title', 'first');
  794. host.setAttribute('stm-local-t', 't'); // reads via the from binding below
  795. host.setAttribute('stm-local-t-from-title', '');
  796. // ^ both spellings would conflict; use only the from- form:
  797. host.removeAttribute('stm-local-t');
  798. var outT = el('span'); outT.setAttribute('stm-text', 't');
  799. host.appendChild(outT);
  800. body.appendChild(host);
  801. var cycles = 0;
  802. s.afterEvaluate(function () { cycles++; });
  803. await s._bridge.renderAll(); await spin();
  804. check(outT.textContent === 'first', 'from-title not read: ' + outT.textContent);
  805. cycles = 0;
  806. host.setAttribute('title', 'first'); // same value: no redraw
  807. await spin(10);
  808. check(cycles === 0, 'same-value attribute write triggered a redraw');
  809. host.setAttribute('title', 'second'); // real change: redraw
  810. await spin(10);
  811. check(cycles >= 1, 'changed attribute did not redraw');
  812. check(outT.textContent === 'second', 'variable not updated after change: ' + outT.textContent);
  813. } else if (scenario === '31') {
  814. // The operator's example: declare + local-from-value + stm-if.
  815. var box = el('div'); box.setAttribute('stm-declare-username', '');
  816. var f31 = el('form');
  817. var inp = input('text', 'username', '');
  818. inp.setAttribute('stm-local-username-from-value', '');
  819. f31.appendChild(inp);
  820. var alertBox = el('div'); alertBox.setAttribute('stm-if', 'username.length < 3');
  821. alertBox.textContent = 'Username is too short';
  822. var okBox = el('div'); okBox.setAttribute('stm-if', 'username.length >= 3');
  823. var okName = el('span'); okName.setAttribute('stm-text', 'username');
  824. okBox.appendChild(okName);
  825. box.appendChild(f31); box.appendChild(alertBox); box.appendChild(okBox);
  826. body.appendChild(box);
  827. await s._bridge.renderAll(); await spin();
  828. check(!alertBox.hasAttribute('hidden'), 'alert must show with empty username');
  829. check(okBox.hasAttribute('hidden'), 'ok box must hide with empty username');
  830. inp.value = 'ab'; fire(inp, 'input'); await spin(10);
  831. check(!alertBox.hasAttribute('hidden'), 'alert must show for "ab"');
  832. inp.value = 'abc'; fire(inp, 'input'); await spin(10);
  833. check(alertBox.hasAttribute('hidden'), 'alert must hide for "abc"');
  834. check(!okBox.hasAttribute('hidden') && okName.textContent === 'abc',
  835. 'ok box wrong: hidden=' + okBox.hasAttribute('hidden') + ' text=' + okName.textContent);
  836. } else if (scenario === '32') {
  837. // from-checked on a checkbox.
  838. var box32 = el('div'); box32.setAttribute('stm-declare-agree', '');
  839. var cbx = input('checkbox', 'agree', '');
  840. cbx.setAttribute('stm-local-agree-from-checked', '');
  841. var lab = el('span'); lab.setAttribute('stm-text', 'agree ? "yes" : "no"');
  842. box32.appendChild(cbx); box32.appendChild(lab);
  843. body.appendChild(box32);
  844. await s._bridge.renderAll(); await spin();
  845. check(lab.textContent === 'no', 'unchecked must read false: ' + lab.textContent);
  846. cbx.checked = true; fire(cbx, 'change'); await spin(10);
  847. check(lab.textContent === 'yes', 'checked must read true: ' + lab.textContent);
  848. } else if (scenario === '33') {
  849. // Declare without binder: undefined, page alive; shadowing.
  850. var box33 = el('div'); box33.setAttribute('stm-declare-later', '');
  851. var probe = el('span'); probe.setAttribute('stm-text', 'typeof later');
  852. box33.appendChild(probe);
  853. body.appendChild(box33);
  854. await s._bridge.renderAll(); await spin();
  855. check(probe.textContent === 'undefined', 'declared-but-unbound must read undefined');
  856. // Shadowing: a variable named like a slot type wins in its scope.
  857. await setState('counter', { n: 5 });
  858. var sh = el('div'); sh.setAttribute('stm-local-counter', '99');
  859. var shOut = el('span'); shOut.setAttribute('stm-text', 'typeof counter');
  860. sh.appendChild(shOut); body.appendChild(sh);
  861. var plain = el('span'); plain.setAttribute('stm-text', 'counter.n');
  862. body.appendChild(plain);
  863. await s._bridge.renderAll(); await spin();
  864. check(shOut.textContent === 'number', 'shadowing variable must hide the slot type: ' + shOut.textContent);
  865. check(plain.textContent === '5', 'slot type must still read outside the shadow');
  866. } else if (scenario === '34') {
  867. // stm-local replaces stm-def: the demo's stamp pattern, plus confirm the
  868. // old attribute is inert (documented removal).
  869. var box34 = el('div'); box34.setAttribute('stm-local-stamp', "'updated '" + ' + counter.n');
  870. var out34 = el('span'); out34.setAttribute('stm-text', 'stamp');
  871. box34.appendChild(out34); body.appendChild(box34);
  872. await setState('counter', { n: 7 });
  873. check(out34.textContent === 'updated 7', 'stamp local wrong: ' + out34.textContent);
  874. var legacy = el('div'); legacy.setAttribute('stm-def-old-as', '1');
  875. var legacyOut = el('span'); legacyOut.setAttribute('stm-text', 'typeof old');
  876. legacy.appendChild(legacyOut); body.appendChild(legacy);
  877. await s._bridge.renderAll(); await spin();
  878. check(legacyOut.textContent === 'undefined', 'stm-def must be inert after removal');
  879. } else {
  880. fail('unknown scenario: ' + scenario);
  881. }
  882. check(log.errors.length === 0, 'unexpected console.error: ' + JSON.stringify(log.errors));
  883. print('scenario ' + scenario + ': OK');
  884. }
  885. main().catch(function (e) {
  886. std.err.puts('FAIL [' + scenario + '] ' + (e && e.stack || e) + '\n');
  887. std.exit(1);
  888. });