From dc194b05eb6481d5179e8d33517614e5cc221010 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 23 Apr 2026 11:22:53 +0000 Subject: [PATCH] =?UTF-8?q?HS=20test=20generator:=20pair=20each=20`expect(?= =?UTF-8?q?result)`=20with=20the=20matching=20`run()`=20=E2=80=94=20+4=20a?= =?UTF-8?q?sExpression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- spec/tests/test-hyperscript-behavioral.sx | 44 +++--- tests/playwright/generate-sx-tests.py | 173 ++++++++++++++-------- 2 files changed, 138 insertions(+), 79 deletions(-) diff --git a/spec/tests/test-hyperscript-behavioral.sx b/spec/tests/test-hyperscript-behavioral.sx index 17916bb0..040bd2e8 100644 --- a/spec/tests/test-hyperscript-behavioral.sx +++ b/spec/tests/test-hyperscript-behavioral.sx @@ -3425,29 +3425,29 @@ (error "SKIP (untranslated): converts value as Date")) (deftest "converts value as Fixed" (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" (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" (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" (assert= (eval-hs "{foo:'bar'} as JSONString") "{"foo":"bar"}") ) (deftest "converts value as Number" (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" (assert= (host-get (eval-hs-locals "x as Object" (list (list (quote x) {:foo "bar"}))) "foo") "bar") ) (deftest "converts value as String" (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" (assert= (host-get (eval-hs "'{\"foo\":\"bar\"}' as JSON") "foo") "bar") @@ -6355,8 +6355,8 @@ (defsuite "hs-upstream-expressions/strings" (deftest "handles strings properly" (assert= (eval-hs "\"foo\"") "foo") - (assert= (eval-hs "\"foo\"") "fo'o") - (assert= (eval-hs "\"foo\"") "foo") + (assert= (eval-hs "\"fo'o\"") "fo'o") + (assert= (eval-hs "'foo'") "foo") ) (deftest "should handle back slashes in non-template content" (assert= (eval-hs-locals "`https://${foo}`" (list (list (quote foo) "bar"))) "https://bar") @@ -6365,9 +6365,9 @@ (error "SKIP (untranslated): should handle strings with tags and quotes")) (deftest "string templates preserve white space" (assert= (eval-hs "` ${1 + 2} ${1 + 2} `") " 3 3 ") - (assert= (eval-hs "` ${1 + 2} ${1 + 2} `") "3 3 ") - (assert= (eval-hs "` ${1 + 2} ${1 + 2} `") "33 ") - (assert= (eval-hs "` ${1 + 2} ${1 + 2} `") "3 3") + (assert= (eval-hs "`${1 + 2} ${1 + 2} `") "3 3 ") + (assert= (eval-hs "`${1 + 2}${1 + 2} `") "33 ") + (assert= (eval-hs "`${1 + 2} ${1 + 2}`") "3 3") ) (deftest "string templates work properly" (assert= (eval-hs "`$1`") "1") @@ -8479,21 +8479,29 @@ ;; ── make (8 tests) ── (defsuite "hs-upstream-make" (deftest "can make elements" - (error "SKIP (untranslated): can make elements")) + (assert= (eval-hs "make a

set window.obj to it") "P") + ) (deftest "can make elements with id and classes" - (error "SKIP (untranslated): can make elements with id and classes")) + (assert= (eval-hs "make a set window.obj to it") "P") + ) (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" - (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" - (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" - (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" - (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" - (error "SKIP (untranslated): creates a div by default")) + (assert= (eval-hs "make a <.a/> set window.obj to it") "DIV") + ) ) ;; ── measure (6 tests) ── diff --git a/tests/playwright/generate-sx-tests.py b/tests/playwright/generate-sx-tests.py index a4a51f43..120892ea 100644 --- a/tests/playwright/generate-sx-tests.py +++ b/tests/playwright/generate-sx-tests.py @@ -1316,74 +1316,125 @@ def generate_eval_only_test(test, idx): obj_str = re.sub(r'\s+', ' ', m.group(3)).strip() 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: - run_match = re.search( - r'(?:var|let|const)\s+\w+\s*=\s*' + _RUN_OPEN + _RUN_ARGS + r'\)', - body, re.DOTALL - ) - if run_match: - hs_expr = extract_hs_expr(run_match.group(2)) - var_name = re.search(r'(?:var|let|const)\s+(\w+)', body).group(1) - # Capture locals from the run() call, if present. Use balanced-brace - # extraction so nested {a: {b: 1}} doesn't truncate at the inner }. - local_pairs = [] - locals_idx = body.find('locals:') - if locals_idx >= 0: - # Find the opening { after "locals:" - open_idx = body.find('{', locals_idx) - if open_idx >= 0: - depth = 0 - end_idx = -1 - in_str = None - for i in range(open_idx, len(body)): - ch = body[i] - if in_str: - if ch == in_str and body[i-1] != '\\': - in_str = None - continue - if ch in ('"', "'", '`'): - in_str = ch - continue - if ch == '{': - depth += 1 - elif ch == '}': - depth -= 1 - if depth == 0: - end_idx = i - break - if end_idx > open_idx: - locals_str = body[open_idx + 1:end_idx].strip() - for kv in split_top_level(locals_str): - kv = kv.strip() - m = re.match(r'^(\w+)\s*:\s*(.+)$', kv, re.DOTALL) - if m: - local_pairs.append((m.group(1), js_val_to_sx(m.group(2).strip()))) - # Merge window setups into local_pairs so evaluate() globals are visible to HS. - merged_pairs = list(window_setups) + local_pairs - locals_sx = '(list ' + ' '.join( - f'(list (quote {n}) {v})' for n, v in merged_pairs - ) + ')' if merged_pairs else None - def eval_call(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): + decl_match = re.search(r'(?:var|let|const)\s+(\w+)', body) + if decl_match: + var_name = decl_match.group(1) + # Find every run() occurrence (with or without var = prefix), and + # capture per-call `{locals: {...}}` opts (balanced-brace). + # The trailing `_RUN_ARGS\)` anchors the lazy `(.+?)\1` so it + # picks the *outer* HS-source quote, not the first inner `\'`. + run_iter = list(re.finditer( + r'(?:(?:var|let|const)\s+\w+\s*=\s*|' + re.escape(var_name) + r'\s*=\s*)?' + + _RUN_OPEN + _RUN_ARGS + r'\)', body, re.DOTALL + )) + + def parse_run_locals(rm): + """If the run() match has `, {locals: {...}}` in its args, + return (name, sx_value) pairs; else [].""" + # Args between the closing HS-source quote and run's `)`. + args_str = body[rm.end(2) + 1:rm.end() - 1] + lm = re.search(r'locals:\s*\{', args_str) + 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] + if in_str: + if ch == in_str and body[i - 1] != '\\': + in_str = None + continue + if ch in ('"', "'", '`'): + in_str = ch + continue + if ch == '{': + d += 1 + elif ch == '}': + d -= 1 + if d == 0: + end = i + break + if end < 0: + return [] + pairs = [] + for kv in split_top_level(body[start:end]): + kv = kv.strip() + km = re.match(r'^(\w+)\s*:\s*(.+)$', kv, re.DOTALL) + if km: + pairs.append((km.group(1), js_val_to_sx(km.group(2).strip()))) + return 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( + f'(list (quote {n}) {v})' for n, v in pairs) + ')' + return f'(eval-hs-locals "{hs_expr}" {locals_sx})' + return f'(eval-hs "{hs_expr}")' + + 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) 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):]) - if prop_m: - prop = prop_m.group(1) or prop_m.group(2) - assertions.append(f' (assert= (host-get {eval_call(hs_expr)} "{prop}") {expected_sx})') - else: - assertions.append(f' (assert= {eval_call(hs_expr)} {expected_sx})') - for m in re.finditer(r'expect\(' + re.escape(var_name) + r'(?:\.\w+)?\)\.toEqual\((\[.*?\])\)', body, re.DOTALL): + prop = prop_m.group(1) or prop_m.group(2) if prop_m else None + assertions.append(emit_for(hs_expr, pairs, expected_sx, prop)) + + for m in re.finditer( + r'expect\(' + re.escape(var_name) + r'(?:\.\w+)?\)\.toEqual\((\[.*?\])\)', + 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)) - assertions.append(f' (assert= {eval_call(hs_expr)} {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): + assertions.append(emit_for(hs_expr, pairs, expected_sx)) + + 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) 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 # e.g.: await run(`expr`, {locals: {arr: [1,2,3]}});