Add signal test suite (17/17) and Island type to evaluator

test-signals.sx: 17 tests covering signal basics (create, deref, reset!,
swap!), computed (derive, update, chain), effects (run, re-run, dispose,
cleanup), batch (deferred deduped notifications), and defisland (create,
call, children).

types.py: Island dataclass mirroring Component but for reactive boundaries.
evaluator.py: sf_defisland special form, Island in call dispatch.
run.py: Signal platform primitives (make-signal, tracking context, etc)
  and native effect/computed/batch implementations that bridge Lambda
  calls across the Python↔SX boundary.
signals.sx: Updated batch to deduplicate subscribers across signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 09:44:18 +00:00
parent a97f4c0e39
commit 26320abd64
5 changed files with 580 additions and 15 deletions

View File

@@ -21,7 +21,7 @@ sys.path.insert(0, _PROJECT)
from shared.sx.parser import parse_all
from shared.sx.evaluator import _eval, _trampoline, _call_lambda
from shared.sx.types import Symbol, Keyword, Lambda, NIL
from shared.sx.types import Symbol, Keyword, Lambda, NIL, Component, Island
# --- Test state ---
suite_stack: list[str] = []
@@ -134,15 +134,133 @@ def render_html(sx_source):
return result
# --- Signal platform primitives ---
# Implements the signal runtime platform interface for testing signals.sx
class Signal:
"""A reactive signal container."""
__slots__ = ("value", "subscribers", "deps")
def __init__(self, value):
self.value = value
self.subscribers = [] # list of callables
self.deps = [] # list of Signal (for computed)
class TrackingContext:
"""Tracks signal dependencies during effect/computed evaluation."""
__slots__ = ("notify_fn", "deps")
def __init__(self, notify_fn):
self.notify_fn = notify_fn
self.deps = []
_tracking_context = [None] # mutable cell
def _make_signal(value):
s = Signal(value)
return s
def _signal_p(x):
return isinstance(x, Signal)
def _signal_value(s):
return s.value
def _signal_set_value(s, v):
s.value = v
return NIL
def _signal_subscribers(s):
return list(s.subscribers)
def _signal_add_sub(s, fn):
if fn not in s.subscribers:
s.subscribers.append(fn)
return NIL
def _signal_remove_sub(s, fn):
if fn in s.subscribers:
s.subscribers.remove(fn)
return NIL
def _signal_deps(s):
return list(s.deps)
def _signal_set_deps(s, deps):
s.deps = list(deps)
return NIL
def _set_tracking_context(ctx):
_tracking_context[0] = ctx
return NIL
def _get_tracking_context():
return _tracking_context[0] or NIL
def _make_tracking_context(notify_fn):
return TrackingContext(notify_fn)
def _tracking_context_deps(ctx):
if isinstance(ctx, TrackingContext):
return ctx.deps
return []
def _tracking_context_add_dep(ctx, s):
if isinstance(ctx, TrackingContext) and s not in ctx.deps:
ctx.deps.append(s)
return NIL
def _tracking_context_notify_fn(ctx):
if isinstance(ctx, TrackingContext):
return ctx.notify_fn
return NIL
def _identical(a, b):
return a is b
def _island_p(x):
return isinstance(x, Island)
def _make_island(name, params, has_children, body, closure):
return Island(
name=name,
params=list(params),
has_children=has_children,
body=body,
closure=dict(closure) if isinstance(closure, dict) else {},
)
# --- Spec registry ---
SPECS = {
"eval": {"file": "test-eval.sx", "needs": []},
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
"router": {"file": "test-router.sx", "needs": []},
"render": {"file": "test-render.sx", "needs": ["render-html"]},
"deps": {"file": "test-deps.sx", "needs": []},
"engine": {"file": "test-engine.sx", "needs": []},
"eval": {"file": "test-eval.sx", "needs": []},
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
"router": {"file": "test-router.sx", "needs": []},
"render": {"file": "test-render.sx", "needs": ["render-html"]},
"deps": {"file": "test-deps.sx", "needs": []},
"engine": {"file": "test-engine.sx", "needs": []},
"signals": {"file": "test-signals.sx", "needs": ["make-signal"]},
}
REF_DIR = os.path.join(_HERE, "..", "ref")
@@ -186,6 +304,31 @@ env = {
"inc": lambda n: n + 1,
# Component accessor for affinity (Phase 7)
"component-affinity": lambda c: getattr(c, 'affinity', 'auto'),
# Signal platform primitives
"make-signal": _make_signal,
"signal?": _signal_p,
"signal-value": _signal_value,
"signal-set-value!": _signal_set_value,
"signal-subscribers": _signal_subscribers,
"signal-add-sub!": _signal_add_sub,
"signal-remove-sub!": _signal_remove_sub,
"signal-deps": _signal_deps,
"signal-set-deps!": _signal_set_deps,
"set-tracking-context!": _set_tracking_context,
"get-tracking-context": _get_tracking_context,
"make-tracking-context": _make_tracking_context,
"tracking-context-deps": _tracking_context_deps,
"tracking-context-add-dep!": _tracking_context_add_dep,
"tracking-context-notify-fn": _tracking_context_notify_fn,
"identical?": _identical,
# Island platform primitives
"island?": _island_p,
"make-island": _make_island,
"component-name": lambda c: getattr(c, 'name', ''),
"component-params": lambda c: list(getattr(c, 'params', [])),
"component-body": lambda c: getattr(c, 'body', NIL),
"component-closure": lambda c: dict(getattr(c, 'closure', {})),
"component-has-children?": lambda c: getattr(c, 'has_children', False),
}
@@ -352,6 +495,171 @@ def _load_forms_from_bootstrap(env):
eval_file("forms.sx", env)
def _load_signals(env):
"""Load signals.sx spec — defines signal, deref, reset!, swap!, etc.
The hand-written evaluator doesn't support &rest in define/fn,
so we override swap! with a native implementation after loading.
"""
# callable? is needed by effect (to check if return value is cleanup fn)
env["callable?"] = lambda x: callable(x) or isinstance(x, Lambda)
eval_file("signals.sx", env)
# Override signal functions that need to call Lambda subscribers.
# The hand-written evaluator's Lambda objects can't be called directly
# from Python — they need _call_lambda. So we provide native versions
# of functions that bridge native→Lambda calls.
def _call_sx_fn(fn, args):
"""Call an SX function (Lambda or native) from Python."""
if isinstance(fn, Lambda):
return _trampoline(_call_lambda(fn, list(args), env))
if callable(fn):
return fn(*args)
return NIL
def _flush_subscribers(s):
for sub in list(s.subscribers):
_call_sx_fn(sub, [])
return NIL
def _notify_subscribers(s):
batch_depth = env.get("*batch-depth*", 0)
if batch_depth and batch_depth > 0:
batch_queue = env.get("*batch-queue*", [])
if s not in batch_queue:
batch_queue.append(s)
return NIL
_flush_subscribers(s)
return NIL
env["notify-subscribers"] = _notify_subscribers
env["flush-subscribers"] = _flush_subscribers
def _reset_bang(s, value):
if not isinstance(s, Signal):
return NIL
old = s.value
if old is not value:
s.value = value
_notify_subscribers(s)
return NIL
env["reset!"] = _reset_bang
def _swap_bang(s, f, *args):
if not isinstance(s, Signal):
return NIL
old = s.value
all_args = [old] + list(args)
new_val = _call_sx_fn(f, all_args)
if old is not new_val:
s.value = new_val
_notify_subscribers(s)
return NIL
env["swap!"] = _swap_bang
def _computed(compute_fn):
s = Signal(NIL)
def recompute():
# Unsubscribe from old deps
for dep in s.deps:
if recompute in dep.subscribers:
dep.subscribers.remove(recompute)
s.deps = []
# Create tracking context
ctx = TrackingContext(recompute)
prev = _tracking_context[0]
_tracking_context[0] = ctx
new_val = _call_sx_fn(compute_fn, [])
_tracking_context[0] = prev
s.deps = list(ctx.deps)
old = s.value
s.value = new_val
if old is not new_val:
_flush_subscribers(s)
recompute()
return s
env["computed"] = _computed
def _effect(effect_fn):
deps = []
disposed = [False]
cleanup_fn = [None]
def run_effect():
if disposed[0]:
return NIL
# Run previous cleanup
if cleanup_fn[0]:
_call_sx_fn(cleanup_fn[0], [])
cleanup_fn[0] = None
# Unsubscribe from old deps
for dep in deps:
if run_effect in dep.subscribers:
dep.subscribers.remove(run_effect)
deps.clear()
# Track new deps
ctx = TrackingContext(run_effect)
prev = _tracking_context[0]
_tracking_context[0] = ctx
result = _call_sx_fn(effect_fn, [])
_tracking_context[0] = prev
deps.extend(ctx.deps)
# If effect returns a callable, it's cleanup
if callable(result) or isinstance(result, Lambda):
cleanup_fn[0] = result
return NIL
run_effect()
def dispose():
disposed[0] = True
if cleanup_fn[0]:
_call_sx_fn(cleanup_fn[0], [])
for dep in deps:
if run_effect in dep.subscribers:
dep.subscribers.remove(run_effect)
deps.clear()
return NIL
return dispose
env["effect"] = _effect
def _batch(thunk):
depth = env.get("*batch-depth*", 0)
env["*batch-depth*"] = depth + 1
_call_sx_fn(thunk, [])
env["*batch-depth*"] = env["*batch-depth*"] - 1
if env["*batch-depth*"] == 0:
queue = env.get("*batch-queue*", [])
env["*batch-queue*"] = []
# Collect unique subscribers across all queued signals
seen = set()
pending = []
for s in queue:
for sub in s.subscribers:
sub_id = id(sub)
if sub_id not in seen:
seen.add(sub_id)
pending.append(sub)
# Notify each unique subscriber exactly once
for sub in pending:
_call_sx_fn(sub, [])
return NIL
env["batch"] = _batch
def main():
global passed, failed, test_num
@@ -395,6 +703,8 @@ def main():
_load_deps_from_bootstrap(env)
if spec_name == "engine":
_load_engine_from_bootstrap(env)
if spec_name == "signals":
_load_signals(env)
print(f"# --- {spec_name} ---")
eval_file(spec["file"], env)