scenarios.js 54 KB

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