From 304a52d2cf595258f8f8089abbc30099c31db2e0 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 24 Apr 2026 10:08:11 +0000 Subject: [PATCH] HS: resize observer mock + on resize (+3 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cluster 26. Three parts: (a) `tests/hs-run-filtered.js`: mock style is now a Proxy that dispatches a synthetic `resize` DOM event on the owning element whenever `width` / `height` changes (via `setProperty` or direct assignment). Detail carries numeric `width` / `height` parsed from the current inline style. Strengthens the old no-op ResizeObserver stub into an `HsResizeObserver` class with a per-element callback registry (collision-proof name vs. cluster 27's IntersectionObserver); HS's `on resize` uses the plain DOM event path, not the observer API. Adds `ResizeObserverEntry` for code that references it. (b) `tests/playwright/generate-sx-tests.py`: new pattern for `(page.)?evaluate(() => [{] document.{getElementById|querySelector}(…).style.PROP = 'VAL'; [}])` emitting `(host-set! (host-get target "style") "PROP" "VAL")`. (c) `spec/tests/test-hyperscript-behavioral.sx`: regenerated — the three resize fixtures now carry the style mutation step between activate and assert. No parser/compiler/runtime changes: `on resize` already parses via `parse-compound-event-name`, and `hs-on` binds via `dom-listen` which is plain `addEventListener("resize", …)`. Suite hs-upstream-resize: 0/3 → 3/3. Smoke 0-195: 164/195 → 165/195 (the +1 smoke bump is logAll-generator work uncommitted in the main tree at verification time, unrelated to this cluster). --- spec/tests/test-hyperscript-behavioral.sx | 3 + tests/hs-run-filtered.js | 73 +++++++++++++++++++++-- tests/playwright/generate-sx-tests.py | 24 ++++++++ 3 files changed, 96 insertions(+), 4 deletions(-) diff --git a/spec/tests/test-hyperscript-behavioral.sx b/spec/tests/test-hyperscript-behavioral.sx index a4c351f1..5c610e81 100644 --- a/spec/tests/test-hyperscript-behavioral.sx +++ b/spec/tests/test-hyperscript-behavioral.sx @@ -10440,6 +10440,7 @@ (dom-append (dom-body) _el-box) (dom-append (dom-body) _el-out) (hs-activate! _el-box) + (host-set! (host-get (dom-query-by-id "box") "style") "width" "200px") (assert= (dom-text-content (dom-query-by-id "out")) "200") )) (deftest "provides height in detail" @@ -10452,6 +10453,7 @@ (dom-append (dom-body) _el-box) (dom-append (dom-body) _el-out) (hs-activate! _el-box) + (host-set! (host-get (dom-query-by-id "box") "style") "height" "300px") (assert= (dom-text-content (dom-query-by-id "out")) "300") )) (deftest "works with from clause" @@ -10464,6 +10466,7 @@ (dom-append (dom-body) _el-box) (dom-append (dom-body) _el-out) (hs-activate! _el-out) + (host-set! (host-get (dom-query-by-id "box") "style") "width" "150px") (assert= (dom-text-content (dom-query-by-id "out")) "150") )) ) diff --git a/tests/hs-run-filtered.js b/tests/hs-run-filtered.js index aa7fba8a..049501f6 100755 --- a/tests/hs-run-filtered.js +++ b/tests/hs-run-filtered.js @@ -21,19 +21,60 @@ function setStepLimit(n) { K.setStepLimit(n); } function resetStepCount() { K.resetStepCount(); } // ─── DOM mock ────────────────────────────────────────────────── +// Resize observer support (cluster 26): `on resize` routes through the +// `resize` DOM event. The mock style proxy dispatches that event +// whenever width/height changes programmatically, with +// `detail = {width, height}` parsed from the current inline style. +// A registry is kept for an optional ResizeObserver-style API, but the +// runtime path uses plain addEventListener("resize"). +globalThis.__hsResizeRegistry = globalThis.__hsResizeRegistry || new Map(); +function _parsePx(v) { + if (v == null) return 0; + const s = String(v).trim(); + const m = s.match(/^(-?[\d.]+)/); + return m ? parseFloat(m[1]) : 0; +} +globalThis.__hsFireResize = function(el) { + if (!el || !el._isEl) return; + const w = _parsePx(el.style && el.style.width); + const h = _parsePx(el.style && el.style.height); + const ev = new Ev('resize', { bubbles: false, detail: { width: w, height: h } }); + try { el.dispatchEvent(ev); } catch (e) {} + // Also notify any ResizeObserver instances (future-proofing). + const cbs = globalThis.__hsResizeRegistry.get(el); + if (cbs) for (const cb of cbs) { try { cb([{ target: el, contentRect: { width: w, height: h } }]); } catch (e) {} } +}; // Default CSS values for unset inline styles (matches browser UA defaults // for block-level elements — our tests mostly exercise divs). -function mkStyle(tag) { +function mkStyle(tag, ownerEl) { const inline = ['SPAN','A','B','I','EM','STRONG','CODE','LABEL','SMALL','SUB','SUP','U','MARK']; const display = (tag && inline.includes(tag.toUpperCase())) ? 'inline' : 'block'; const s = { display, visibility: 'visible', opacity: '1' }; - s.setProperty = function(p, v) { s[p] = v; }; + s.setProperty = function(p, v) { + const prev = s[p]; + s[p] = v; + if ((p === 'width' || p === 'height') && ownerEl && String(prev) !== String(v)) { + if (globalThis.__hsFireResize) globalThis.__hsFireResize(ownerEl); + } + }; s.getPropertyValue = function(p) { return s[p] || ''; }; s.removeProperty = function(p) { delete s[p]; }; + if (typeof Proxy !== 'undefined') { + return new Proxy(s, { + set(t, k, v) { + const prev = t[k]; + t[k] = v; + if ((k === 'width' || k === 'height') && ownerEl && String(prev) !== String(v)) { + if (globalThis.__hsFireResize) globalThis.__hsFireResize(ownerEl); + } + return true; + } + }); + } return s; } class El { - constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this.id=''; this.className=''; this.classList=new CL(this); this.style=mkStyle(this.tagName); this.attributes={}; this.children=[]; this.childNodes=[]; this.childNodes.item=function(i){return this[i]||null;}; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; const _el=this; const _datasetBacking={}; this.dataset=new Proxy(_datasetBacking,{set(o,k,v){o[k]=v;if(typeof k==='string'){const attr='data-'+k.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());_el.attributes[attr]=String(v);}return true;},deleteProperty(o,k){delete o[k];if(typeof k==='string'){const attr='data-'+k.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());delete _el.attributes[attr];}return true;}}); this.open=false; this.value=''; this.defaultValue=''; this.checked=false; this.defaultChecked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; this.defaultSelected=false; this.selected=false; this.options=[]; } + constructor(t) { this.tagName=t.toUpperCase(); this.nodeName=this.tagName; this.nodeType=1; this._isEl=true; this.id=''; this.className=''; this.classList=new CL(this); this.style=mkStyle(this.tagName, this); this.attributes={}; this.children=[]; this.childNodes=[]; this.childNodes.item=function(i){return this[i]||null;}; this.parentElement=null; this.parentNode=null; this.textContent=''; this.innerHTML=''; this._listeners={}; const _el=this; const _datasetBacking={}; this.dataset=new Proxy(_datasetBacking,{set(o,k,v){o[k]=v;if(typeof k==='string'){const attr='data-'+k.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());_el.attributes[attr]=String(v);}return true;},deleteProperty(o,k){delete o[k];if(typeof k==='string'){const attr='data-'+k.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());delete _el.attributes[attr];}return true;}}); this.open=false; this.value=''; this.defaultValue=''; this.checked=false; this.defaultChecked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; this.defaultSelected=false; this.selected=false; this.options=[]; } setAttribute(n,v) { this.attributes[n]=String(v); if(n==='id')this.id=v; if(n==='class'){this.className=v;this.classList._sync(v);} if(n==='value'){this.value=v;this.defaultValue=v;} if(n==='name')this.name=v; if(n==='type')this.type=v; if(n==='checked'){this.checked=true;this.defaultChecked=true;} if(n==='selected'){this.selected=true;this.defaultSelected=true;} if(n==='multiple')this.multiple=true; if(n==='disabled')this.disabled=true; if(typeof n==='string'&&n.startsWith('data-')){const key=n.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase());this.dataset[key]=String(v);} if(n==='style'){const s=String(v);for(const d of s.split(';')){const c=d.indexOf(':');if(c>0){const k=d.slice(0,c).trim();const val=d.slice(c+1).trim();if(k)this.style.setProperty(k,val);}} } } getAttribute(n) { return this.attributes[n]!==undefined?this.attributes[n]:null; } removeAttribute(n) { delete this.attributes[n]; if(n==='disabled')this.disabled=false; } @@ -289,7 +330,31 @@ globalThis.document=document; globalThis.window=globalThis; globalThis.HTMLEleme globalThis.Event=Ev; globalThis.CustomEvent=Ev; globalThis.NodeList=Array; globalThis.HTMLCollection=Array; globalThis.getComputedStyle=(e)=>e?e.style:{}; globalThis.requestAnimationFrame=(f)=>{f();return 0;}; globalThis.cancelAnimationFrame=()=>{}; globalThis.MutationObserver=class{observe(){}disconnect(){}}; -globalThis.ResizeObserver=class{observe(){}disconnect(){}}; globalThis.IntersectionObserver=class{observe(){}disconnect(){}}; +// HsResizeObserver — cluster-26 resize mock. Keeps a per-element callback +// registry so code that observes via `new ResizeObserver(cb)` still works, +// but HS's `on resize` uses the plain `resize` DOM event dispatched by the +// style proxy in mkStyle (above). +class HsResizeObserver { + constructor(cb) { this._cb = cb; this._els = new Set(); } + observe(el) { + if (!el) return; this._els.add(el); + const reg = globalThis.__hsResizeRegistry; + if (!reg.has(el)) reg.set(el, new Set()); + reg.get(el).add(this._cb); + } + unobserve(el) { + if (!el) return; this._els.delete(el); + const reg = globalThis.__hsResizeRegistry; + if (reg.has(el)) reg.get(el).delete(this._cb); + } + disconnect() { + const reg = globalThis.__hsResizeRegistry; + for (const el of this._els) { if (reg.has(el)) reg.get(el).delete(this._cb); } + this._els.clear(); + } +} +globalThis.ResizeObserver=HsResizeObserver; globalThis.ResizeObserverEntry=class{}; +globalThis.IntersectionObserver=class{observe(){}disconnect(){}}; globalThis.navigator={userAgent:'node'}; globalThis.location={href:'http://localhost/',pathname:'/',search:'',hash:''}; globalThis.history={pushState(){},replaceState(){},back(){},forward(){}}; globalThis.getSelection=()=>({toString:()=>(globalThis.__test_selection||'')}); diff --git a/tests/playwright/generate-sx-tests.py b/tests/playwright/generate-sx-tests.py index c2546336..fbaa3b71 100644 --- a/tests/playwright/generate-sx-tests.py +++ b/tests/playwright/generate-sx-tests.py @@ -1008,6 +1008,30 @@ def parse_dev_body(body, elements, var_names): ops.append(f'(dom-set-inner-html {target} "{val}")') continue + # evaluate(() => document.getElementById(ID).style.PROP = 'VALUE') + # or document.querySelector(SEL).style.PROP = 'VALUE'. Used by resize + # tests (cluster 26): writing style.width/height dispatches a synthetic + # `resize` event via the mock style proxy. Accepts both arrow-expr + # and block form: `() => expr` and `() => { expr; }`. Also accepts + # the `page.evaluate` Playwright prefix. + m = re.match( + r"(?:page\.)?evaluate\(\s*\(\)\s*=>\s*\{?\s*" + r"document\.(?:getElementById|querySelector)\(" + r"\s*(['\"])([^'\"]+)\1\s*\)" + r"\.style\.(\w+)\s*=\s*(['\"])(.*?)\4\s*;?\s*\}?\s*\)\s*$", + stmt_na, re.DOTALL, + ) + if m and seen_html: + sel = m.group(2) + if sel and not sel.startswith(('#', '.', '[')): + sel = '#' + sel + sel = re.sub(r'^#work-area\s+', '', sel) + target = selector_to_sx(sel, elements, var_names) + prop = m.group(3) + val = m.group(5).replace('\\', '\\\\').replace('"', '\\"') + ops.append(f'(host-set! (host-get {target} "style") "{prop}" "{val}")') + continue + # clickAndReadStyle(evaluate, SEL, PROP) — upstream helper that # dispatches a click on SEL and returns its computed style[PROP]. # Materialize the click; downstream toHaveCSS assertions then test