HS test generator: pair each expect(result) with the matching run() — +4 asExpression

Pattern 2 was binding all `expect(result)` assertions in a body to the
*first* `run()`, even when the body re-assigned `result` between checks:

  let result = await run("'10' as Float")    expect(result).toBe(10)
  result = await run("'10.4' as Float")      expect(result).toBe(10.4)

Both assertions ran against `'10' as Float`, so half failed. Now the
generator walks `run()` calls in order, parses per-call `{locals: {...}}`
opts (balanced-brace, with the closing `\)` anchoring the lazy quote
match), and pairs each `expect(result)` with the most recent preceding
run.

asExpression 15/42 → 19/42 (+4: as Float / Number / String / Fixed sub-
assertions now check the right expression). Other suites unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 11:22:53 +00:00
parent 781e0d427a
commit dc194b05eb
2 changed files with 138 additions and 79 deletions

View File

@@ -3425,29 +3425,29 @@
(error "SKIP (untranslated): converts value as Date")) (error "SKIP (untranslated): converts value as Date"))
(deftest "converts value as Fixed" (deftest "converts value as Fixed"
(assert= (eval-hs "'10.4' as Fixed") "10") (assert= (eval-hs "'10.4' as Fixed") "10")
(assert= (eval-hs "'10.4' as Fixed") "10.49") (assert= (eval-hs "'10.4899' as Fixed:2") "10.49")
) )
(deftest "converts value as Float" (deftest "converts value as Float"
(assert= (eval-hs "'10' as Float") 10) (assert= (eval-hs "'10' as Float") 10)
(assert= (eval-hs "'10' as Float") 10.4) (assert= (eval-hs "'10.4' as Float") 10.4)
) )
(deftest "converts value as Int" (deftest "converts value as Int"
(assert= (eval-hs "'10' as Int") 10) (assert= (eval-hs "'10' as Int") 10)
(assert= (eval-hs "'10' as Int") 10) (assert= (eval-hs "'10.4' as Int") 10)
) )
(deftest "converts value as JSONString" (deftest "converts value as JSONString"
(assert= (eval-hs "{foo:'bar'} as JSONString") "{"foo":"bar"}") (assert= (eval-hs "{foo:'bar'} as JSONString") "{"foo":"bar"}")
) )
(deftest "converts value as Number" (deftest "converts value as Number"
(assert= (eval-hs "'10' as Number") 10) (assert= (eval-hs "'10' as Number") 10)
(assert= (eval-hs "'10' as Number") 10.4) (assert= (eval-hs "'10.4' as Number") 10.4)
) )
(deftest "converts value as Object" (deftest "converts value as Object"
(assert= (host-get (eval-hs-locals "x as Object" (list (list (quote x) {:foo "bar"}))) "foo") "bar") (assert= (host-get (eval-hs-locals "x as Object" (list (list (quote x) {:foo "bar"}))) "foo") "bar")
) )
(deftest "converts value as String" (deftest "converts value as String"
(assert= (eval-hs "10 as String") "10") (assert= (eval-hs "10 as String") "10")
(assert= (eval-hs "10 as String") "true") (assert= (eval-hs "true as String") "true")
) )
(deftest "parses string as JSON to object" (deftest "parses string as JSON to object"
(assert= (host-get (eval-hs "'{\"foo\":\"bar\"}' as JSON") "foo") "bar") (assert= (host-get (eval-hs "'{\"foo\":\"bar\"}' as JSON") "foo") "bar")
@@ -6355,8 +6355,8 @@
(defsuite "hs-upstream-expressions/strings" (defsuite "hs-upstream-expressions/strings"
(deftest "handles strings properly" (deftest "handles strings properly"
(assert= (eval-hs "\"foo\"") "foo") (assert= (eval-hs "\"foo\"") "foo")
(assert= (eval-hs "\"foo\"") "fo'o") (assert= (eval-hs "\"fo'o\"") "fo'o")
(assert= (eval-hs "\"foo\"") "foo") (assert= (eval-hs "'foo'") "foo")
) )
(deftest "should handle back slashes in non-template content" (deftest "should handle back slashes in non-template content"
(assert= (eval-hs-locals "`https://${foo}`" (list (list (quote foo) "bar"))) "https://bar") (assert= (eval-hs-locals "`https://${foo}`" (list (list (quote foo) "bar"))) "https://bar")
@@ -8479,21 +8479,29 @@
;; ── make (8 tests) ── ;; ── make (8 tests) ──
(defsuite "hs-upstream-make" (defsuite "hs-upstream-make"
(deftest "can make elements" (deftest "can make elements"
(error "SKIP (untranslated): can make elements")) (assert= (eval-hs "make a <p/> set window.obj to it") "P")
)
(deftest "can make elements with id and classes" (deftest "can make elements with id and classes"
(error "SKIP (untranslated): can make elements with id and classes")) (assert= (eval-hs "make a <p.a#id.b.c/> set window.obj to it") "P")
)
(deftest "can make named objects" (deftest "can make named objects"
(error "SKIP (untranslated): can make named objects")) (assert= (eval-hs "make a WeakMap called wm then set window.obj to wm") true)
)
(deftest "can make named objects w/ global scope" (deftest "can make named objects w/ global scope"
(error "SKIP (untranslated): can make named objects w/ global scope")) (assert= (eval-hs "make a WeakMap called $wm") true)
)
(deftest "can make named objects with arguments" (deftest "can make named objects with arguments"
(error "SKIP (untranslated): can make named objects with arguments")) (assert= (eval-hs "make a URL from \"/playground/\", \"https://hyperscript.org/\" called u set window.obj to u") true)
)
(deftest "can make objects" (deftest "can make objects"
(error "SKIP (untranslated): can make objects")) (assert= (eval-hs "make a WeakMap then set window.obj to it") true)
)
(deftest "can make objects with arguments" (deftest "can make objects with arguments"
(error "SKIP (untranslated): can make objects with arguments")) (assert= (eval-hs "make a URL from \"/playground/\", \"https://hyperscript.org/\" set window.obj to it") true)
)
(deftest "creates a div by default" (deftest "creates a div by default"
(error "SKIP (untranslated): creates a div by default")) (assert= (eval-hs "make a <.a/> set window.obj to it") "DIV")
)
) )
;; ── measure (6 tests) ── ;; ── measure (6 tests) ──

View File

@@ -1316,27 +1316,35 @@ def generate_eval_only_test(test, idx):
obj_str = re.sub(r'\s+', ' ', m.group(3)).strip() obj_str = re.sub(r'\s+', ' ', m.group(3)).strip()
assertions.append(f' ;; TODO: assert= (eval-hs "{hs_expr}") against {obj_str}') assertions.append(f' ;; TODO: assert= (eval-hs "{hs_expr}") against {obj_str}')
# Pattern 2: Two-line — var result = await run(`expr`, opts); expect(result...).toBe/toEqual(val) # Pattern 2: var result = await run(`expr`, opts); expect(result...).toBe/toEqual(val)
# Reassignments are common (`result = await run(...)` repeated for multiple
# checks). Walk the body in order, pairing each expect(result) with the
# most recent preceding run().
if not assertions: if not assertions:
run_match = re.search( decl_match = re.search(r'(?:var|let|const)\s+(\w+)', body)
r'(?:var|let|const)\s+\w+\s*=\s*' + _RUN_OPEN + _RUN_ARGS + r'\)', if decl_match:
body, re.DOTALL var_name = decl_match.group(1)
) # Find every run() occurrence (with or without var = prefix), and
if run_match: # capture per-call `{locals: {...}}` opts (balanced-brace).
hs_expr = extract_hs_expr(run_match.group(2)) # The trailing `_RUN_ARGS\)` anchors the lazy `(.+?)\1` so it
var_name = re.search(r'(?:var|let|const)\s+(\w+)', body).group(1) # picks the *outer* HS-source quote, not the first inner `\'`.
# Capture locals from the run() call, if present. Use balanced-brace run_iter = list(re.finditer(
# extraction so nested {a: {b: 1}} doesn't truncate at the inner }. r'(?:(?:var|let|const)\s+\w+\s*=\s*|' + re.escape(var_name) + r'\s*=\s*)?' +
local_pairs = [] _RUN_OPEN + _RUN_ARGS + r'\)', body, re.DOTALL
locals_idx = body.find('locals:') ))
if locals_idx >= 0:
# Find the opening { after "locals:" def parse_run_locals(rm):
open_idx = body.find('{', locals_idx) """If the run() match has `, {locals: {...}}` in its args,
if open_idx >= 0: return (name, sx_value) pairs; else []."""
depth = 0 # Args between the closing HS-source quote and run's `)`.
end_idx = -1 args_str = body[rm.end(2) + 1:rm.end() - 1]
in_str = None lm = re.search(r'locals:\s*\{', args_str)
for i in range(open_idx, len(body)): if not lm:
return []
# Balanced-brace from after `locals: {`.
start = rm.end(2) + 1 + lm.end()
d, in_str, end = 1, None, -1
for i in range(start, len(body)):
ch = body[i] ch = body[i]
if in_str: if in_str:
if ch == in_str and body[i - 1] != '\\': if ch == in_str and body[i - 1] != '\\':
@@ -1346,44 +1354,87 @@ def generate_eval_only_test(test, idx):
in_str = ch in_str = ch
continue continue
if ch == '{': if ch == '{':
depth += 1 d += 1
elif ch == '}': elif ch == '}':
depth -= 1 d -= 1
if depth == 0: if d == 0:
end_idx = i end = i
break break
if end_idx > open_idx: if end < 0:
locals_str = body[open_idx + 1:end_idx].strip() return []
for kv in split_top_level(locals_str): pairs = []
for kv in split_top_level(body[start:end]):
kv = kv.strip() kv = kv.strip()
m = re.match(r'^(\w+)\s*:\s*(.+)$', kv, re.DOTALL) km = re.match(r'^(\w+)\s*:\s*(.+)$', kv, re.DOTALL)
if m: if km:
local_pairs.append((m.group(1), js_val_to_sx(m.group(2).strip()))) pairs.append((km.group(1), js_val_to_sx(km.group(2).strip())))
# Merge window setups into local_pairs so evaluate() globals are visible to HS. return pairs
merged_pairs = list(window_setups) + local_pairs
# Pre-compute per-run locals (window_setups + per-call locals).
run_data = []
for rm in run_iter:
local_pairs = parse_run_locals(rm)
merged = list(window_setups) + local_pairs
run_data.append((rm.start(), rm.end(), extract_hs_expr(rm.group(2)), merged))
def call_for(hs_expr, pairs):
if pairs:
locals_sx = '(list ' + ' '.join( locals_sx = '(list ' + ' '.join(
f'(list (quote {n}) {v})' for n, v in merged_pairs f'(list (quote {n}) {v})' for n, v in pairs) + ')'
) + ')' if merged_pairs else None return f'(eval-hs-locals "{hs_expr}" {locals_sx})'
def eval_call(expr): return f'(eval-hs "{hs_expr}")'
return f'(eval-hs-locals "{expr}" {locals_sx})' if locals_sx else f'(eval-hs "{expr}")'
for m in re.finditer(r'expect\((' + re.escape(var_name) + r'(?:\["[^"]+"\]|\.\w+)?)\)\.toBe\(([^)]+)\)', body): def run_at(pos):
"""Return (hs_expr, pairs) for the most recent run() that ends before `pos`."""
last = None
for rd in run_data:
if rd[1] >= 0 and rd[1] < pos:
last = rd
return last
def emit_for(hs_expr, pairs, expected_sx, prop=None):
call = call_for(hs_expr, pairs)
if prop:
return f' (assert= (host-get {call} "{prop}") {expected_sx})'
return f' (assert= {call} {expected_sx})'
for m in re.finditer(
r'expect\((' + re.escape(var_name) + r'(?:\["[^"]+"\]|\.\w+)?)\)\.toBe\(([^)]+)\)',
body
):
rd = run_at(m.start())
if rd is None:
continue
_, _, hs_expr, pairs = rd
accessor = m.group(1) accessor = m.group(1)
expected_sx = js_val_to_sx(m.group(2)) expected_sx = js_val_to_sx(m.group(2))
# Check for property access: result["foo"] or result.foo
prop_m = re.search(r'\["([^"]+)"\]|\.(\w+)', accessor[len(var_name):]) prop_m = re.search(r'\["([^"]+)"\]|\.(\w+)', accessor[len(var_name):])
if prop_m: prop = prop_m.group(1) or prop_m.group(2) if prop_m else None
prop = prop_m.group(1) or prop_m.group(2) assertions.append(emit_for(hs_expr, pairs, expected_sx, prop))
assertions.append(f' (assert= (host-get {eval_call(hs_expr)} "{prop}") {expected_sx})')
else: for m in re.finditer(
assertions.append(f' (assert= {eval_call(hs_expr)} {expected_sx})') r'expect\(' + re.escape(var_name) + r'(?:\.\w+)?\)\.toEqual\((\[.*?\])\)',
for m in re.finditer(r'expect\(' + re.escape(var_name) + r'(?:\.\w+)?\)\.toEqual\((\[.*?\])\)', body, re.DOTALL): body, re.DOTALL
):
rd = run_at(m.start())
if rd is None:
continue
_, _, hs_expr, pairs = rd
expected_sx = js_val_to_sx(m.group(1)) expected_sx = js_val_to_sx(m.group(1))
assertions.append(f' (assert= {eval_call(hs_expr)} {expected_sx})') assertions.append(emit_for(hs_expr, pairs, expected_sx))
# Handle .map(x => x.prop) before toEqual
for m in re.finditer(r'expect\(' + re.escape(var_name) + r'\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.toEqual\((\[.*?\])\)', body, re.DOTALL): for m in re.finditer(
r'expect\(' + re.escape(var_name) + r'\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.toEqual\((\[.*?\])\)',
body, re.DOTALL
):
rd = run_at(m.start())
if rd is None:
continue
_, _, hs_expr, pairs = rd
prop = m.group(1) prop = m.group(1)
expected_sx = js_val_to_sx(m.group(2)) expected_sx = js_val_to_sx(m.group(2))
assertions.append(f' (assert= (map (fn (x) (get x "{prop}")) {eval_call(hs_expr)}) {expected_sx})') call = call_for(hs_expr, pairs)
assertions.append(f' (assert= (map (fn (x) (get x "{prop}")) {call}) {expected_sx})')
# Pattern 2b: run() with locals + evaluate(window.X) + expect().toBe/toEqual # Pattern 2b: run() with locals + evaluate(window.X) + expect().toBe/toEqual
# e.g.: await run(`expr`, {locals: {arr: [1,2,3]}}); # e.g.: await run(`expr`, {locals: {arr: [1,2,3]}});