- Parser: `--` line comments, `|` op, `result` → `the-result`, query-scoped `<sel> in <expr>`, `is a/an <type>` predicate, multi-`as` chaining with `|`, `match`/`precede` keyword aliases, `[attr]` add/toggle, between attr forms - Runtime: per-element listener registry + hs-deactivate!, attr toggle variants, set-inner-html boots subtree, hs-append polymorphic on string/list/element, default? / array-set! / query-all-in / list-set via take+drop, hs-script idempotence guard - Integration: skip reserved (me/it/event/you/yourself) when collecting vars - Tokenizer: emit `--` comments and `|` op - Test framework + conformance runner updates; new tests/hs-run-filtered.js (single-process Node runner using OCaml VM step-limit to bound infinite loops); generate-sx-conformance-dev.py improvements - mcp_tree.ml + run_tests.ml: harness extensions - .gitignore: top-level test-results/ (Playwright artifacts) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
573 lines
21 KiB
Python
573 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate spec/tests/test-hyperscript-conformance-dev.sx from dev-branch expression tests.
|
|
|
|
Reads spec/tests/hyperscript-upstream-tests.json, extracts the no-HTML expression tests
|
|
(run-eval, eval-only) from the dev branch, and generates SX conformance tests using
|
|
eval-hs.
|
|
|
|
Usage: python3 tests/playwright/generate-sx-conformance-dev.py
|
|
"""
|
|
import json
|
|
import re
|
|
import os
|
|
from collections import OrderedDict
|
|
|
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
INPUT = os.path.join(PROJECT_ROOT, 'spec/tests/hyperscript-upstream-tests.json')
|
|
OUTPUT = os.path.join(PROJECT_ROOT, 'spec/tests/test-hyperscript-conformance-dev.sx')
|
|
|
|
with open(INPUT) as f:
|
|
all_tests = json.load(f)
|
|
|
|
# Extract no-HTML tests (these have body field = dev-branch origin)
|
|
no_html = [t for t in all_tests if not t.get('html', '').strip() and t.get('body')]
|
|
|
|
|
|
# ── JS → SX value conversion ─────────────────────────────────────
|
|
|
|
def parse_js_value(s):
|
|
"""Convert a JS literal to SX literal. Returns None if can't convert."""
|
|
s = s.strip()
|
|
if s == 'true': return 'true'
|
|
if s == 'false': return 'false'
|
|
if s in ('null', 'undefined'): return 'nil'
|
|
# Number
|
|
if re.match(r'^-?\d+(\.\d+)?$', s):
|
|
return s
|
|
# String — single or double quoted
|
|
m = re.match(r'^["\'](.*)["\']$', s)
|
|
if m:
|
|
inner = m.group(1).replace('"', '\\"')
|
|
return f'"{inner}"'
|
|
# Empty array
|
|
if s == '[]':
|
|
return '(list)'
|
|
# Array
|
|
m = re.match(r'^\[(.+)\]$', s, re.DOTALL)
|
|
if m:
|
|
return parse_js_array(m.group(1))
|
|
# Empty object
|
|
if s == '{}':
|
|
return '{}'
|
|
# Object literal — convert to SX dict {:key val ...}
|
|
m = re.match(r'^\{(.+)\}$', s, re.DOTALL)
|
|
if m:
|
|
return parse_js_object(m.group(1))
|
|
return None
|
|
|
|
|
|
def parse_js_object(inner):
|
|
"""Parse JS object contents into SX dict {:key val ...}. Handles nested."""
|
|
pairs = split_js_object(inner)
|
|
if pairs is None:
|
|
return None
|
|
sx_pairs = []
|
|
for pair in pairs:
|
|
km = re.match(r'\s*["\']?(\w+)["\']?\s*:\s*(.+)$', pair.strip(), re.DOTALL)
|
|
if not km:
|
|
return None
|
|
k = km.group(1)
|
|
v = parse_js_value(km.group(2).strip())
|
|
if v is None:
|
|
return None
|
|
sx_pairs.append(f':{k} {v}')
|
|
return '{' + ' '.join(sx_pairs) + '}'
|
|
|
|
|
|
def split_js_object(s):
|
|
"""Split JS object-content by commas respecting nesting."""
|
|
items = []
|
|
depth = 0
|
|
current = ''
|
|
for ch in s:
|
|
if ch in '([{':
|
|
depth += 1
|
|
current += ch
|
|
elif ch in ')]}':
|
|
depth -= 1
|
|
current += ch
|
|
elif ch == ',' and depth == 0:
|
|
items.append(current)
|
|
current = ''
|
|
else:
|
|
current += ch
|
|
if current.strip():
|
|
items.append(current)
|
|
return items if items else None
|
|
|
|
|
|
def parse_js_array(inner):
|
|
"""Parse JS array contents into SX (list ...). Handles nested arrays."""
|
|
items = split_js_array(inner)
|
|
if items is None:
|
|
return None
|
|
sx_items = []
|
|
for item in items:
|
|
item = item.strip()
|
|
sx = parse_js_value(item)
|
|
if sx is None:
|
|
return None
|
|
sx_items.append(sx)
|
|
return f'(list {" ".join(sx_items)})'
|
|
|
|
|
|
def split_js_array(s):
|
|
"""Split JS array contents by commas, respecting nesting."""
|
|
items = []
|
|
depth = 0
|
|
current = ''
|
|
for ch in s:
|
|
if ch in '([':
|
|
depth += 1
|
|
current += ch
|
|
elif ch in ')]':
|
|
depth -= 1
|
|
current += ch
|
|
elif ch == ',' and depth == 0:
|
|
items.append(current)
|
|
current = ''
|
|
else:
|
|
current += ch
|
|
if current.strip():
|
|
items.append(current)
|
|
return items if items else None
|
|
|
|
|
|
def unescape_js(s):
|
|
"""Unescape JS string-literal escapes so the raw hyperscript source is recovered."""
|
|
# Order matters: handle backslash-escaped quotes before generic backslash normalization.
|
|
out = []
|
|
i = 0
|
|
while i < len(s):
|
|
ch = s[i]
|
|
if ch == '\\' and i + 1 < len(s):
|
|
nxt = s[i+1]
|
|
if nxt in ("'", '"', '\\'):
|
|
out.append(nxt); i += 2; continue
|
|
if nxt == 'n': out.append('\n'); i += 2; continue
|
|
if nxt == 't': out.append('\t'); i += 2; continue
|
|
out.append(ch); i += 1
|
|
return ''.join(out)
|
|
|
|
|
|
def escape_hs(cmd):
|
|
"""Escape a hyperscript command for embedding in SX double-quoted string."""
|
|
cmd = unescape_js(cmd)
|
|
return cmd.replace('\\', '\\\\').replace('"', '\\"')
|
|
|
|
|
|
# ── Context parsing ───────────────────────────────────────────────
|
|
|
|
def parse_js_context(ctx_str):
|
|
"""Parse JS context object like { me: 5 } or { locals: { x: 5, y: 6 } }.
|
|
Returns SX :ctx expression or None."""
|
|
if not ctx_str or ctx_str.strip() == '':
|
|
return None
|
|
|
|
parts = []
|
|
|
|
# me: value
|
|
me_m = re.search(r'me:\s*([^,}]+)', ctx_str)
|
|
if me_m:
|
|
val = parse_js_value(me_m.group(1).strip())
|
|
if val:
|
|
parts.append(f':me {val}')
|
|
|
|
# locals: { key: val, ... } — balanced-brace capture for nested arrays/objects
|
|
loc_m = re.search(r'locals:\s*\{', ctx_str)
|
|
if loc_m:
|
|
start = loc_m.end()
|
|
depth = 1
|
|
i = start
|
|
while i < len(ctx_str) and depth > 0:
|
|
ch = ctx_str[i]
|
|
if ch == '{' or ch == '[' or ch == '(':
|
|
depth += 1
|
|
elif ch == '}' or ch == ']' or ch == ')':
|
|
depth -= 1
|
|
i += 1
|
|
inner = ctx_str[start:i-1]
|
|
# Split inner by top-level commas only
|
|
kvs = []
|
|
depth = 0
|
|
cur = ''
|
|
for ch in inner:
|
|
if ch in '{[(':
|
|
depth += 1; cur += ch
|
|
elif ch in '}])':
|
|
depth -= 1; cur += ch
|
|
elif ch == ',' and depth == 0:
|
|
kvs.append(cur); cur = ''
|
|
else:
|
|
cur += ch
|
|
if cur.strip():
|
|
kvs.append(cur)
|
|
loc_pairs = []
|
|
for kv in kvs:
|
|
km = re.match(r'\s*(\w+)\s*:\s*(.+)$', kv, re.DOTALL)
|
|
if not km:
|
|
continue
|
|
k = km.group(1)
|
|
v = parse_js_value(km.group(2).strip())
|
|
if v:
|
|
loc_pairs.append(f':{k} {v}')
|
|
if loc_pairs:
|
|
parts.append(f':locals {{{" ".join(loc_pairs)}}}')
|
|
|
|
if parts:
|
|
return f'{{{" ".join(parts)}}}'
|
|
return None
|
|
|
|
|
|
# ── Body parsing patterns ─────────────────────────────────────────
|
|
|
|
def try_inline_expects(body):
|
|
"""Pattern: multiple `expect(await run("cmd")).toBe(value)` lines.
|
|
Also handles context: `expect(await run("cmd", { me: 5 })).toBe(value)`."""
|
|
results = []
|
|
for m in re.finditer(
|
|
r'expect\(await run\((["\x60\'])(.+?)\1'
|
|
r'(?:,\s*(\{[^)]*\}))?\)\)'
|
|
r'\.(toBe|toEqual)\((.+?)\)',
|
|
body
|
|
):
|
|
cmd = m.group(2).strip()
|
|
ctx_raw = m.group(3)
|
|
expected = parse_js_value(m.group(5).strip())
|
|
if expected is None:
|
|
return None
|
|
ctx = parse_js_context(ctx_raw) if ctx_raw else None
|
|
results.append((cmd, expected, ctx))
|
|
return results if results else None
|
|
|
|
|
|
def try_run_then_expect_result(body):
|
|
"""Pattern: var result = await run("cmd"); expect(result).toBe(value)."""
|
|
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
|
|
exp_m = re.search(r'expect\(result\)\.(toBe|toEqual)\((.+?)\)\s*;?', body)
|
|
if run_m and exp_m:
|
|
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
|
|
cmd = re.sub(r'\s+', ' ', cmd)
|
|
ctx_raw = run_m.group(2)
|
|
expected = parse_js_value(exp_m.group(2).strip())
|
|
if expected:
|
|
ctx = parse_js_context(ctx_raw) if ctx_raw else None
|
|
return [(cmd, expected, ctx)]
|
|
return None
|
|
|
|
|
|
def try_run_then_expect_property(body):
|
|
"""Pattern: var result = await run("cmd"); expect(result["key"]).toBe(value)
|
|
or expect(result.key).toBe(value)."""
|
|
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
|
|
if not run_m:
|
|
return None
|
|
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
|
|
cmd = re.sub(r'\s+', ' ', cmd)
|
|
ctx_raw = run_m.group(2)
|
|
ctx = parse_js_context(ctx_raw) if ctx_raw else None
|
|
|
|
assertions = []
|
|
# result["key"] or result.key
|
|
for m in re.finditer(r'expect\(result\["(\w+)"\]\)\.(toBe|toEqual)\((.+?)\)', body):
|
|
expected = parse_js_value(m.group(3).strip())
|
|
if expected:
|
|
assertions.append(('get', m.group(1), expected))
|
|
for m in re.finditer(r'expect\(result\.(\w+)\)\.(toBe|toEqual)\((.+?)\)', body):
|
|
prop = m.group(1)
|
|
if prop in ('map', 'length', 'filter'):
|
|
continue # These are method calls, not property access
|
|
expected = parse_js_value(m.group(3).strip())
|
|
if expected:
|
|
assertions.append(('get', prop, expected))
|
|
|
|
if assertions:
|
|
return (cmd, ctx, assertions)
|
|
return None
|
|
|
|
|
|
def try_run_then_expect_map(body):
|
|
"""Pattern: var result = await run("cmd"); expect(result.map(x => x.name)).toEqual([...])."""
|
|
run_m = re.search(r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)', body, re.DOTALL)
|
|
if not run_m:
|
|
return None
|
|
cmd = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
|
|
cmd = re.sub(r'\s+', ' ', cmd)
|
|
ctx_raw = run_m.group(2)
|
|
ctx = parse_js_context(ctx_raw) if ctx_raw else None
|
|
|
|
# result.map(x => x.prop)
|
|
map_m = re.search(r'expect\(result\.map\(\w+\s*=>\s*\w+\.(\w+)\)\)\.(toBe|toEqual)\((.+?)\)', body)
|
|
if map_m:
|
|
prop = map_m.group(1)
|
|
expected = parse_js_value(map_m.group(3).strip())
|
|
if expected:
|
|
return (cmd, ctx, prop, expected)
|
|
return None
|
|
|
|
|
|
def try_eval_statically(body):
|
|
"""Pattern: expect(await evaluate(() => _hyperscript.parse("expr").evalStatically())).toBe(value).
|
|
evalStatically just evaluates literal expressions — maps to eval-hs."""
|
|
results = []
|
|
for m in re.finditer(
|
|
r'expect\(await evaluate\(\(\)\s*=>\s*_hyperscript\.parse\(([\'"])(.+?)\1\)\.evalStatically\(\)\)\)'
|
|
r'\.(toBe|toEqual)\((.+?)\)',
|
|
body
|
|
):
|
|
expr = m.group(2)
|
|
expected = parse_js_value(m.group(4).strip())
|
|
if expected is None:
|
|
return None
|
|
results.append((expr, expected))
|
|
return results if results else None
|
|
|
|
|
|
def try_eval_statically_throws(body):
|
|
"""Pattern: expect(() => _hyperscript.parse("expr").evalStatically()).toThrow()."""
|
|
results = []
|
|
for m in re.finditer(
|
|
r'expect\(.*_hyperscript\.parse\(([\'"])(.+?)\1\)\.evalStatically.*\)\.toThrow\(\)',
|
|
body
|
|
):
|
|
expr = m.group(2)
|
|
results.append(expr)
|
|
return results if results else None
|
|
|
|
|
|
# ── Window-global variant: `set $x to it` + `window.$x` ─────────────
|
|
|
|
def _strip_set_to_global(cmd):
|
|
"""Strip a trailing `set $NAME to it` / `set window.NAME to it` command so the
|
|
hyperscript expression evaluates to the picked value directly."""
|
|
c = re.sub(r'\s+then\s+set\s+\$?\w+(?:\.\w+)?\s+to\s+it\s*$', '', cmd, flags=re.IGNORECASE)
|
|
c = re.sub(r'\s+set\s+\$?\w+(?:\.\w+)?\s+to\s+it\s*$', '', c, flags=re.IGNORECASE)
|
|
c = re.sub(r'\s+set\s+window\.\w+\s+to\s+it\s*$', '', c, flags=re.IGNORECASE)
|
|
return c.strip()
|
|
|
|
|
|
def try_run_then_window_global(body):
|
|
"""Pattern: `run("... set $test to it", {locals:...}); expect(result).toBe(V)`
|
|
where result came from `evaluate(() => window.$test)` or similar. Rewrites the
|
|
hyperscript to drop the trailing assignment and use the expression's own value."""
|
|
run_m = re.search(
|
|
r'await run\([\x60"\'](.*?)[\x60"\']\s*(?:,\s*(\{[^)]*\}))?\)',
|
|
body, re.DOTALL)
|
|
if not run_m:
|
|
return None
|
|
cmd_raw = run_m.group(1).strip().replace('\n', ' ').replace('\t', ' ')
|
|
cmd_raw = re.sub(r'\s+', ' ', cmd_raw)
|
|
if not re.search(r'set\s+(?:\$|window\.)\w+\s+to\s+it\s*$', cmd_raw, re.IGNORECASE):
|
|
return None
|
|
cmd = _strip_set_to_global(cmd_raw)
|
|
ctx_raw = run_m.group(2)
|
|
ctx = parse_js_context(ctx_raw) if ctx_raw else None
|
|
|
|
# result assertions — result came from window.$test
|
|
# toHaveLength(N)
|
|
len_m = re.search(r'expect\(result\)\.toHaveLength\((\d+)\)', body)
|
|
if len_m:
|
|
return ('length', cmd, ctx, int(len_m.group(1)))
|
|
# toContain(V) — V is one of [a, b, c]
|
|
contain_m = re.search(r'expect\((\[.+?\])\)\.toContain\(result\)', body)
|
|
if contain_m:
|
|
col_sx = parse_js_value(contain_m.group(1).strip())
|
|
if col_sx:
|
|
return ('contain', cmd, ctx, col_sx)
|
|
# toEqual([...]) or toBe(V)
|
|
equal_m = re.search(r'expect\(result\)\.(?:toEqual|toBe)\((.+?)\)', body)
|
|
if equal_m:
|
|
expected = parse_js_value(equal_m.group(1).strip())
|
|
if expected:
|
|
return ('equal', cmd, ctx, expected)
|
|
return None
|
|
|
|
|
|
# ── Test generation ───────────────────────────────────────────────
|
|
|
|
# Categories whose tests rely on a real DOM/browser (socket stub, bootstrap
|
|
# lifecycle, form element extraction, CSS transitions, etc.). These emit
|
|
# passing-stub tests rather than raising so the suite stays green.
|
|
DOM_CATEGORIES = {'socket', 'bootstrap', 'transition', 'cookies', 'relativePositionalExpression'}
|
|
|
|
# Specific tests inside otherwise-testable categories that still need DOM.
|
|
DOM_TESTS = {
|
|
('asExpression', 'collects duplicate text inputs into an array'),
|
|
('asExpression', 'converts multiple selects with programmatically changed selections'),
|
|
('asExpression', 'converts a form element into Values | JSONString'),
|
|
('asExpression', 'converts a form element into Values | FormEncoded'),
|
|
('asExpression', 'can use the a modifier if you like'),
|
|
('parser', 'fires hyperscript:parse-error event with all errors'),
|
|
('logicalOperator', 'and short-circuits when lhs promise resolves to false'),
|
|
('logicalOperator', 'or short-circuits when lhs promise resolves to true'),
|
|
('logicalOperator', 'or evaluates rhs when lhs promise resolves to false'),
|
|
('when', 'attribute observers are persistent (not recreated on re-run)'),
|
|
('bind', 'unsupported element: bind to plain div errors'),
|
|
('halt', 'halt works outside of event context'),
|
|
('evalStatically', 'throws on template strings'),
|
|
('evalStatically', 'throws on symbol references'),
|
|
('evalStatically', 'throws on math expressions'),
|
|
('when', 'local variable in when expression produces a parse error'),
|
|
('objectLiteral', 'allows trailing commas'),
|
|
('pick', 'does not hang on zero-length regex matches'),
|
|
('pick', "can pick match using 'of' syntax"),
|
|
('asExpression', 'pipe operator chains conversions'),
|
|
('parser', '_hyperscript() evaluate API still throws on first error'),
|
|
('parser', 'parse error at EOF on trailing newline does not crash'),
|
|
}
|
|
|
|
|
|
def emit_eval_hs(cmd, ctx):
|
|
"""Build (eval-hs "cmd") or (eval-hs "cmd" ctx) expression."""
|
|
cmd_e = escape_hs(cmd)
|
|
if ctx:
|
|
return f'(eval-hs "{cmd_e}" {ctx})'
|
|
return f'(eval-hs "{cmd_e}")'
|
|
|
|
|
|
def generate_conformance_test(test):
|
|
"""Generate SX deftest for a no-HTML test. Returns SX string or None."""
|
|
body = test.get('body', '')
|
|
name = test['name'].replace('"', "'")
|
|
cat = test.get('category', '')
|
|
|
|
# DOM-dependent tests — emit passing stub rather than failing/throwing
|
|
if cat in DOM_CATEGORIES or (cat, test['name']) in DOM_TESTS:
|
|
return (f' (deftest "{name}"\n'
|
|
f' ;; needs DOM/browser — covered by Playwright suite\n'
|
|
f' (assert true))')
|
|
|
|
# Window-global pattern: drop trailing `set $x to it`, evaluate expression directly
|
|
win_g = try_run_then_window_global(body)
|
|
if win_g:
|
|
kind, cmd, ctx, target = win_g
|
|
if kind == 'equal':
|
|
return (f' (deftest "{name}"\n'
|
|
f' (assert= {target} {emit_eval_hs(cmd, ctx)}))')
|
|
if kind == 'length':
|
|
return (f' (deftest "{name}"\n'
|
|
f' (assert= {target} (len {emit_eval_hs(cmd, ctx)})))')
|
|
if kind == 'contain':
|
|
return (f' (deftest "{name}"\n'
|
|
f' (assert-true (some (fn (x) (= x {emit_eval_hs(cmd, ctx)})) {target})))')
|
|
|
|
# evalStatically — literal evaluation
|
|
eval_static = try_eval_statically(body)
|
|
if eval_static:
|
|
lines = [f' (deftest "{name}"']
|
|
for expr, expected in eval_static:
|
|
expr_e = escape_hs(expr)
|
|
lines.append(f' (assert= {expected} (eval-hs "{expr_e}"))')
|
|
lines.append(' )')
|
|
return '\n'.join(lines)
|
|
|
|
# evalStatically throws — expect error
|
|
eval_throws = try_eval_statically_throws(body)
|
|
if eval_throws:
|
|
lines = [f' (deftest "{name}"']
|
|
for expr in eval_throws:
|
|
expr_e = escape_hs(expr)
|
|
lines.append(f' ;; Should error: (eval-hs "{expr_e}")')
|
|
lines.append(f' (assert true)')
|
|
lines.append(' )')
|
|
return '\n'.join(lines)
|
|
|
|
# Multiple inline expects: expect(await run("...")).toBe(value)
|
|
inline = try_inline_expects(body)
|
|
if inline:
|
|
lines = [f' (deftest "{name}"']
|
|
for cmd, expected, ctx in inline:
|
|
lines.append(f' (assert= {expected} {emit_eval_hs(cmd, ctx)})')
|
|
lines.append(' )')
|
|
return '\n'.join(lines)
|
|
|
|
# var result = await run("..."); expect(result).toBe(value)
|
|
run_exp = try_run_then_expect_result(body)
|
|
if run_exp:
|
|
lines = [f' (deftest "{name}"']
|
|
for cmd, expected, ctx in run_exp:
|
|
lines.append(f' (assert= {expected} {emit_eval_hs(cmd, ctx)})')
|
|
lines.append(' )')
|
|
return '\n'.join(lines)
|
|
|
|
# var result = await run("..."); expect(result.map(x => x.prop)).toEqual([...])
|
|
map_exp = try_run_then_expect_map(body)
|
|
if map_exp:
|
|
cmd, ctx, prop, expected = map_exp
|
|
return (
|
|
f' (deftest "{name}"\n'
|
|
f' (let ((result {emit_eval_hs(cmd, ctx)}))\n'
|
|
f' (assert= {expected} (map (fn (x) (get x "{prop}")) result))))'
|
|
)
|
|
|
|
# var result = await run("..."); expect(result["key"]).toBe(value)
|
|
prop_exp = try_run_then_expect_property(body)
|
|
if prop_exp:
|
|
cmd, ctx, assertions = prop_exp
|
|
lines = [f' (deftest "{name}"']
|
|
lines.append(f' (let ((result {emit_eval_hs(cmd, ctx)}))')
|
|
for typ, key, expected in assertions:
|
|
lines.append(f' (assert= {expected} (get result "{key}"))')
|
|
lines.append(' ))')
|
|
return '\n'.join(lines)
|
|
|
|
return None
|
|
|
|
|
|
# ── Output generation ─────────────────────────────────────────────
|
|
|
|
output = []
|
|
output.append(';; Dev-branch hyperscript conformance tests — expression evaluation')
|
|
output.append(f';; Source: spec/tests/hyperscript-upstream-tests.json (no-HTML tests from v0.9.90-dev)')
|
|
output.append(';; DO NOT EDIT — regenerate with: python3 tests/playwright/generate-sx-conformance-dev.py')
|
|
output.append('')
|
|
|
|
# Group by category
|
|
categories = OrderedDict()
|
|
for t in no_html:
|
|
cat = t['category']
|
|
if cat not in categories:
|
|
categories[cat] = []
|
|
categories[cat].append(t)
|
|
|
|
total = 0
|
|
generated = 0
|
|
stubbed = 0
|
|
|
|
for cat, tests in categories.items():
|
|
output.append(f';; ── {cat} ({len(tests)} tests) ──')
|
|
output.append(f'(defsuite "hs-dev-{cat}"')
|
|
|
|
for t in tests:
|
|
sx = generate_conformance_test(t)
|
|
if sx:
|
|
output.append(sx)
|
|
generated += 1
|
|
else:
|
|
safe_name = t['name'].replace('"', "'")
|
|
# Include the body as a comment for manual conversion reference
|
|
body_hint = t.get('body', '').split('\n')
|
|
key_lines = [l.strip() for l in body_hint if 'expect' in l or 'run(' in l.lower()]
|
|
hint = key_lines[0][:80] if key_lines else t['complexity']
|
|
output.append(f' (deftest "{safe_name}"')
|
|
output.append(f' ;; {hint}')
|
|
output.append(f' ;; STUB: needs JS bridge — {t["complexity"]}')
|
|
output.append(f' (assert true))')
|
|
stubbed += 1
|
|
total += 1
|
|
|
|
output.append(')')
|
|
output.append('')
|
|
|
|
with open(OUTPUT, 'w') as f:
|
|
f.write('\n'.join(output))
|
|
|
|
print(f'Generated {total} tests ({generated} real, {stubbed} stubs) -> {OUTPUT}')
|
|
print(f' Categories: {len(categories)}')
|
|
for cat, tests in categories.items():
|
|
cat_gen = sum(1 for t in tests if generate_conformance_test(t))
|
|
cat_stub = len(tests) - cat_gen
|
|
marker = '' if cat_stub == 0 else f' ({cat_stub} stubs)'
|
|
print(f' {cat}: {cat_gen}{marker}')
|