HS: resize observer mock + on resize (+3 tests)

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).
This commit is contained in:
2026-04-24 10:08:11 +00:00
parent 99c5911347
commit 304a52d2cf
3 changed files with 96 additions and 4 deletions

View File

@@ -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")
))
)

View File

@@ -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||'')});

View File

@@ -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