HS reset/first/last: defaultValue tracking, list-of-elements reset, find().first|last

Mock DOM:
- El now tracks defaultValue/defaultChecked/defaultSelected and a reset()
  method that walks descendant form controls, restoring them.
- setAttribute(value|checked|selected) sets the matching default-* too, so
  the initial HTML state can be restored later.
- parseHTMLFragments + _setInnerHTML capture a textarea's textContent as
  its value AND defaultValue.

Generator (pw-body):
- add_action / add_assertion extract .first() / .last() / .nth(N) modifiers
  into (nth (dom-query-all …) i) or a (let ((_all …)) (nth _all (- … 1)))
  tail so multi-match helpers hit the right element.

Compiler:
- emit-reset! with a .<class>/.<sel> query target now compiles to hs-query-all
  so 'reset .resettable' resets every matching control (not just the first).

Net: reset 1→8 (100%).
This commit is contained in:
2026-04-23 17:15:40 +00:00
parent 5b31d935bd
commit d6137f0d6f
5 changed files with 153 additions and 91 deletions

View File

@@ -33,8 +33,8 @@ function mkStyle(tag) {
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={}; this.dataset={}; this.open=false; this.value=''; this.checked=false; this.disabled=false; this.type=''; this.name=''; this.selectedIndex=-1; 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; if(n==='name')this.name=v; if(n==='type')this.type=v; if(n==='checked')this.checked=true; if(n==='selected')this.selected=true; if(n==='multiple')this.multiple=true; if(n==='disabled')this.disabled=true; 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);}} } }
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={}; this.dataset={}; 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(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; }
hasAttribute(n) { return n in this.attributes; }
@@ -52,6 +52,28 @@ class El {
contains(o) { if(o===this)return true; for(const c of this.children)if(c===o||c.contains(o))return true; return false; }
cloneNode(d) { const e=new El(this.tagName.toLowerCase()); Object.assign(e.attributes,this.attributes); e.id=this.id; e.className=this.className; e.classList._sync(this.className); for(const k of Object.keys(this.style)){if(typeof this.style[k]!=='function')e.style[k]=this.style[k];} e.textContent=this.textContent; e.innerHTML=this.innerHTML; e.value=this.value; if(d)for(const c of this.children)e.appendChild(c.cloneNode(true)); return e; }
focus(){} blur(){} click(){this.dispatchEvent(new Ev('click',{bubbles:true}));} remove(){if(this.parentElement)this.parentElement.removeChild(this);}
reset(){
// Form reset: walk descendants, restore defaultValue / defaultChecked /
// defaultSelected.
const walk = (el) => {
for (const c of (el.children || [])) {
const tag = c.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
if (c.type === 'checkbox' || c.type === 'radio') c.checked = !!c.defaultChecked;
else c.value = c.defaultValue || '';
} else if (tag === 'OPTION') {
c.selected = !!c.defaultSelected;
} else if (tag === 'SELECT') {
for (const o of (c.options || [])) o.selected = !!o.defaultSelected;
const first = (c.options || []).findIndex(o => o.selected);
c.selectedIndex = first >= 0 ? first : 0;
c.value = first >= 0 && c.options[first] ? (c.options[first].value || '') : '';
}
walk(c);
}
};
walk(this);
}
_syncText() {
// Sync textContent from children
const t = this.children.map(c => c.textContent || '').join('');
@@ -70,6 +92,12 @@ class El {
} else {
this.textContent = '';
}
// Textarea: its value is its textContent. Capture defaultValue on first
// set so later reset() can restore.
if (this.tagName === 'TEXTAREA') {
this.value = this.textContent;
if (!('_origDefault' in this)) { this.defaultValue = this.textContent; this._origDefault = true; }
}
}
get firstElementChild() { return this.children[0]||null; }
get lastElementChild() { return this.children[this.children.length-1]||null; }
@@ -159,6 +187,16 @@ function parseHTMLFragments(html) {
el.textContent = inner;
}
el.innerHTML = inner;
// Textarea: its "value" comes from inner text, not an attr.
if (el.tagName === 'TEXTAREA') {
el.value = inner;
el.defaultValue = inner;
}
// Option: textContent is the label; if no value attr, it defaults to
// the label. Track defaultSelected separately from runtime selected.
if (el.tagName === 'OPTION' && !el.attributes.value) {
el.value = inner.trim();
}
}
results.push(el);
lastIndex = re.lastIndex;

View File

@@ -800,19 +800,24 @@ def parse_dev_body(body, elements, var_names):
def add_action(stmt):
am = re.search(
r"find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\)|\.nth\((\d+)\))?"
r"find\((['\"])(.+?)\1\)(?:\.(first|last)\(\)|\.nth\((\d+)\))?"
r"\.(click|dispatchEvent|fill|check|uncheck|focus|selectOption)\(([^)]*)\)",
stmt,
)
if not am or 'expect' in stmt:
return False
selector = am.group(2)
nth_idx = am.group(3)
action_type = am.group(4)
action_arg = am.group(5).strip("'\"")
first_last = am.group(3)
nth_idx = am.group(4)
action_type = am.group(5)
action_arg = am.group(6).strip("'\"")
target = selector_to_sx(selector, elements, var_names)
if nth_idx is not None:
target = f'(nth (dom-query-all (dom-body) "{selector}") {nth_idx})'
elif first_last == 'last':
target = f'(let ((_all (dom-query-all (dom-body) "{selector}"))) (nth _all (- (len _all) 1)))'
elif first_last == 'first':
target = f'(nth (dom-query-all (dom-body) "{selector}") 0)'
if action_type == 'click':
ops.append(f'(dom-dispatch {target} "click" nil)')
elif action_type == 'dispatchEvent':
@@ -837,7 +842,7 @@ def parse_dev_body(body, elements, var_names):
def add_assertion(stmt):
em = re.search(
r"expect\(find\((['\"])(.+?)\1\)(?:\.(?:first|last)\(\)|\.nth\((\d+)\))?\)\.(not\.)?"
r"expect\(find\((['\"])(.+?)\1\)(?:\.(first|last)\(\)|\.nth\((\d+)\))?\)\.(not\.)?"
r"(toHaveText|toHaveClass|toHaveCSS|toHaveAttribute|toHaveValue|toBeVisible|toBeHidden|toBeChecked)"
r"\(((?:[^()]|\([^()]*\))*)\)",
stmt,
@@ -845,13 +850,18 @@ def parse_dev_body(body, elements, var_names):
if not em:
return False
selector = em.group(2)
nth_idx = em.group(3)
negated = bool(em.group(4))
assert_type = em.group(5)
args_str = em.group(6)
first_last = em.group(3)
nth_idx = em.group(4)
negated = bool(em.group(5))
assert_type = em.group(6)
args_str = em.group(7)
target = selector_to_sx(selector, elements, var_names)
if nth_idx is not None:
target = f'(nth (dom-query-all (dom-body) "{selector}") {nth_idx})'
elif first_last == 'last':
target = f'(let ((_all (dom-query-all (dom-body) "{selector}"))) (nth _all (- (len _all) 1)))'
elif first_last == 'first':
target = f'(nth (dom-query-all (dom-body) "{selector}") 0)'
sx = pw_assertion_to_sx(target, negated, assert_type, args_str)
if sx:
ops.append(sx)