HS: fix empty/halt/morph/reset/dialog — 17 upstream tests pass

- parser `empty` no-target → (ref "me") (was bogus (sym "me"))
- parser `halt` modes distinguish: "all"/"bubbling"/"default" halt execution
  (raise hs-return), "the-event"/"the event's" only stop propagation/default.
  "'s" now matched as op token, not keyword.
- parser `get` cmd: dispatch + cmd-kw list + parse-get-cmd (parses expr with
  optional `as TYPE`). Required for `get result as JSON` in fetch chains.
- compiler empty-target for (local X): emit (set! X (hs-empty-like X)) so
  arrays/sets/maps clear the variable, not call DOM empty on the value.
- runtime hs-empty-like: container-of-same-type empty value.
- runtime hs-empty-target!: drop dead FORM branch that was short-circuiting
  to innerHTML=""; the querySelectorAll-over-inputs branch now runs.
- runtime hs-halt!: take ev param (was free `event` lookup); raise hs-return
  to stop execution unless mode is "the-event".
- runtime hs-reset!: type-aware — FORM → reset, INPUT/TEXTAREA → value/checked
  from defaults, SELECT → defaultSelected option.
- runtime hs-open!/hs-close!: toggle `open` attribute on details elements
  (not just the prop) so dom-has-attr? assertions work.
- runtime hs-coerce JSON: json-stringify dict/list (was str).
- test-runner mock: host-get on List + "length"/"size" (was only Dict);
  dom-set-attr tracks defaultChecked / defaultSelected / defaultValue;
  mock_query_all supports comma-separated selector groups.
- generator: emit boolean attrs (checked/selected/etc) even with null value;
  drop overcautious "skip HS with bare quotes or embedded HTML" guard so
  morph tests (source contains embedded <div>) emit properly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 15:36:01 +00:00
parent 5c66095b0f
commit 802ccd23e8
12 changed files with 1340 additions and 345 deletions

View File

@@ -227,12 +227,15 @@ def parse_html(html):
'attrs': {}, 'inner': '', 'depth': len(stack),
'children': [], 'parent_idx': None
}
BOOL_ATTRS = {'checked', 'selected', 'disabled', 'multiple',
'required', 'readonly', 'autofocus', 'hidden', 'open'}
for name, val in attrs:
if name == 'id': el['id'] = val
elif name == 'class': el['classes'] = (val or '').split()
elif name == '_': el['hs'] = val
elif name == 'style': el['attrs']['style'] = val or ''
elif val is not None: el['attrs'][name] = val
elif name in BOOL_ATTRS: el['attrs'][name] = ''
# Track parent-child relationship
if stack:
parent = stack[-1]
@@ -483,7 +486,23 @@ def make_ref_fn(elements, var_names, action_str=''):
# most likely refers to an *other* element (often the ID'd one).
action_uses_alias = any(n not in tags for n in action_vars)
# Build var→element lookup for depth checks
var_to_el = {var_names[i]: elements[i] for i in range(len(var_names))}
def ref(name):
# Special case for `d1`, `d2`, ... (upstream convention `var d1 = make(HTML)`
# binds to the outermost wrapper). If the HTML also has an element with
# id='d1' *nested inside* the wrapper, the JS variable shadows it — so
# `d1.click()` / `d1.innerHTML` in the check refer to the wrapper, not
# the nested element. Prefer the top-level positional element here.
pos_match = re.match(r'^d(\d+)$', name)
if pos_match and name in id_to_var:
id_el = var_to_el.get(id_to_var[name])
if id_el is not None and id_el.get('depth', 0) > 0:
idx = int(pos_match.group(1)) - 1
if 0 <= idx < len(top_level_vars):
return top_level_vars[idx]
# Exact ID match first
if name in id_to_var:
return id_to_var[name]
@@ -499,8 +518,13 @@ def make_ref_fn(elements, var_names, action_str=''):
return tag_to_id[name]
if name in tag_to_unnamed:
return tag_to_unnamed[name]
# Fallback: first element of that tag (even if named)
return tag_to_all.get(name, [first_var])[0]
if name in tag_to_all and tag_to_all[name]:
# Static element of that tag exists — use it
return tag_to_all[name][0]
# No static element of this tag: it must be dynamically inserted
# by the hyperscript (e.g. `button` after the handler creates one).
# Query the DOM at action/check time with a tag selector.
return f'(dom-query "{name}")'
# Tag + number: div1→1st div, div2→2nd div, form1→1st form, etc.
m = re.match(r'^([a-z]+)(\d+)$', name)
@@ -791,8 +815,6 @@ def emit_element_setup(lines, elements, var_names, root='(dom-body)', indent='
hs_val = process_hs_val(el['hs'])
if not hs_val:
pass # no HS to set
elif hs_val.startswith('"') or (hs_val.endswith('"') and '<' in hs_val):
lines.append(f'{indent};; HS source has bare quotes or embedded HTML')
else:
hs_escaped = hs_val.replace('\\', '\\\\').replace('"', '\\"')
lines.append(f'{indent}(dom-set-attr {var} "_" "{hs_escaped}")')
@@ -1416,7 +1438,14 @@ output.append(' (fn (src)')
output.append(' (let ((sx (hs-to-sx (hs-compile src))))')
output.append(' (let ((handler (eval-expr-cek')
output.append(' (list (quote fn) (list (quote me)) (list (quote let) (list (list (quote it) nil) (list (quote event) nil)) sx)))))')
output.append(' (handler nil)))))')
output.append(' (guard')
output.append(' (_e')
output.append(' (true')
output.append(' (if')
output.append(' (and (list? _e) (= (first _e) "hs-return"))')
output.append(' (nth _e 1)')
output.append(' (raise _e))))')
output.append(' (handler nil))))))')
output.append('')
output.append(';; Evaluate with a specific me value (for "I am between" etc.)')
output.append('(define eval-hs-with-me')
@@ -1424,7 +1453,14 @@ output.append(' (fn (src me-val)')
output.append(' (let ((sx (hs-to-sx (hs-compile src))))')
output.append(' (let ((handler (eval-expr-cek')
output.append(' (list (quote fn) (list (quote me)) (list (quote let) (list (list (quote it) nil) (list (quote event) nil)) sx)))))')
output.append(' (handler me-val)))))')
output.append(' (guard')
output.append(' (_e')
output.append(' (true')
output.append(' (if')
output.append(' (and (list? _e) (= (first _e) "hs-return"))')
output.append(' (nth _e 1)')
output.append(' (raise _e))))')
output.append(' (handler me-val))))))')
output.append('')
# Group by category