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

@@ -33,7 +33,7 @@ from __future__ import annotations
from typing import Any
from .types import Component, Continuation, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal
from .types import Component, Continuation, HandlerDef, Island, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal
from .primitives import _PRIMITIVES
@@ -147,13 +147,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
fn = _trampoline(_eval(head, env))
args = [_trampoline(_eval(a, env)) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
return fn(*args)
if isinstance(fn, Lambda):
return _call_lambda(fn, args, env)
if isinstance(fn, Component):
if isinstance(fn, (Component, Island)):
return _call_component(fn, expr[1:], env)
raise EvalError(f"Not callable: {fn!r}")
@@ -555,6 +555,51 @@ def _sf_defcomp(expr: list, env: dict) -> Component:
return comp
def _sf_defisland(expr: list, env: dict) -> Island:
"""``(defisland ~name (&key ...) body)``"""
if len(expr) < 4:
raise EvalError("defisland requires name, params, and body")
name_sym = expr[1]
if not isinstance(name_sym, Symbol):
raise EvalError(f"defisland name must be symbol, got {type(name_sym).__name__}")
comp_name = name_sym.name.lstrip("~")
params_expr = expr[2]
if not isinstance(params_expr, list):
raise EvalError("defisland params must be a list")
params: list[str] = []
has_children = False
in_key = False
for p in params_expr:
if isinstance(p, Symbol):
if p.name == "&key":
in_key = True
continue
if p.name == "&rest":
has_children = True
continue
if in_key or has_children:
if not has_children:
params.append(p.name)
else:
params.append(p.name)
elif isinstance(p, str):
params.append(p)
body = expr[-1]
island = Island(
name=comp_name,
params=params,
has_children=has_children,
body=body,
closure=dict(env),
)
env[name_sym.name] = island
return island
def _defcomp_kwarg(expr: list, key: str, default: str) -> str:
"""Extract a keyword annotation from defcomp, e.g. :affinity :client."""
# Scan from index 3 to second-to-last for :key value pairs
@@ -592,7 +637,7 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
else:
fn = _trampoline(_eval(form, env))
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
result = fn(*args)
elif isinstance(fn, Lambda):
result = _trampoline(_call_lambda(fn, args, env))
@@ -1021,6 +1066,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
"define": _sf_define,
"defstyle": _sf_defstyle,
"defcomp": _sf_defcomp,
"defisland": _sf_defisland,
"defrelation": _sf_defrelation,
"begin": _sf_begin,
"do": _sf_begin,

View File

@@ -194,9 +194,20 @@
(when (= *batch-depth* 0)
(let ((queue *batch-queue*))
(set! *batch-queue* (list))
(for-each
(fn (s) (flush-subscribers s))
queue)))))
;; Collect unique subscribers across all queued signals,
;; then notify each exactly once.
(let ((seen (list))
(pending (list)))
(for-each
(fn (s)
(for-each
(fn (sub)
(when (not (contains? seen sub))
(append! seen sub)
(append! pending sub)))
(signal-subscribers s)))
queue)
(for-each (fn (sub) (sub)) pending))))))
;; --------------------------------------------------------------------------

View File

@@ -0,0 +1,173 @@
;; ==========================================================================
;; test-signals.sx — Tests for signals and reactive islands
;;
;; Requires: test-framework.sx loaded first.
;; Modules tested: signals.sx, eval.sx (defisland)
;;
;; Note: Multi-expression lambda bodies are wrapped in (do ...) for
;; compatibility with the hand-written evaluator which only supports
;; single-expression lambda bodies.
;; ==========================================================================
;; --------------------------------------------------------------------------
;; Signal creation and basic read/write
;; --------------------------------------------------------------------------
(defsuite "signal basics"
(deftest "signal creates a reactive container"
(let ((s (signal 42)))
(assert-true (signal? s))
(assert-equal 42 (deref s))))
(deftest "deref on non-signal passes through"
(assert-equal 5 (deref 5))
(assert-equal "hello" (deref "hello"))
(assert-nil (deref nil)))
(deftest "reset! changes value"
(let ((s (signal 0)))
(reset! s 10)
(assert-equal 10 (deref s))))
(deftest "reset! does not notify when value unchanged"
(let ((s (signal 5))
(count (signal 0)))
(effect (fn () (do (deref s) (swap! count inc))))
;; Effect runs once on creation → count=1
(let ((c1 (deref count)))
(reset! s 5) ;; same value — no notification
(assert-equal c1 (deref count)))))
(deftest "swap! applies function to current value"
(let ((s (signal 10)))
(swap! s inc)
(assert-equal 11 (deref s))))
(deftest "swap! passes extra args"
(let ((s (signal 10)))
(swap! s + 5)
(assert-equal 15 (deref s)))))
;; --------------------------------------------------------------------------
;; Computed signals
;; --------------------------------------------------------------------------
(defsuite "computed"
(deftest "computed derives initial value"
(let ((a (signal 3))
(b (signal 4))
(sum (computed (fn () (+ (deref a) (deref b))))))
(assert-equal 7 (deref sum))))
(deftest "computed updates when dependency changes"
(let ((a (signal 2))
(doubled (computed (fn () (* 2 (deref a))))))
(assert-equal 4 (deref doubled))
(reset! a 5)
(assert-equal 10 (deref doubled))))
(deftest "computed chains"
(let ((base (signal 1))
(doubled (computed (fn () (* 2 (deref base)))))
(quadrupled (computed (fn () (* 2 (deref doubled))))))
(assert-equal 4 (deref quadrupled))
(reset! base 3)
(assert-equal 12 (deref quadrupled)))))
;; --------------------------------------------------------------------------
;; Effects
;; --------------------------------------------------------------------------
(defsuite "effects"
(deftest "effect runs immediately"
(let ((ran (signal false)))
(effect (fn () (reset! ran true)))
(assert-true (deref ran))))
(deftest "effect re-runs when dependency changes"
(let ((source (signal "a"))
(log (signal (list))))
(effect (fn ()
(swap! log (fn (l) (append l (deref source))))))
;; Initial run logs "a"
(assert-equal (list "a") (deref log))
;; Change triggers re-run
(reset! source "b")
(assert-equal (list "a" "b") (deref log))))
(deftest "effect dispose stops tracking"
(let ((source (signal 0))
(count (signal 0)))
(let ((dispose (effect (fn () (do
(deref source)
(swap! count inc))))))
;; Effect ran once
(assert-equal 1 (deref count))
;; Trigger
(reset! source 1)
(assert-equal 2 (deref count))
;; Dispose
(dispose)
;; Should NOT trigger
(reset! source 2)
(assert-equal 2 (deref count)))))
(deftest "effect cleanup runs before re-run"
(let ((source (signal 0))
(cleanups (signal 0)))
(effect (fn () (do
(deref source)
(fn () (swap! cleanups inc))))) ;; return cleanup fn
;; No cleanup yet (first run)
(assert-equal 0 (deref cleanups))
;; Change triggers cleanup of previous run
(reset! source 1)
(assert-equal 1 (deref cleanups)))))
;; --------------------------------------------------------------------------
;; Batch
;; --------------------------------------------------------------------------
(defsuite "batch"
(deftest "batch defers notifications"
(let ((a (signal 0))
(b (signal 0))
(run-count (signal 0)))
(effect (fn () (do
(deref a) (deref b)
(swap! run-count inc))))
;; Initial run
(assert-equal 1 (deref run-count))
;; Without batch: 2 writes → 2 effect runs
;; With batch: 2 writes → 1 effect run
(batch (fn () (do
(reset! a 1)
(reset! b 2))))
;; Should be 2 (initial + 1 batched), not 3
(assert-equal 2 (deref run-count)))))
;; --------------------------------------------------------------------------
;; defisland
;; --------------------------------------------------------------------------
(defsuite "defisland"
(deftest "defisland creates an island"
(defisland ~test-island (&key value)
(list "island" value))
(assert-true (island? ~test-island)))
(deftest "island is callable like component"
(defisland ~greeting (&key name)
(str "Hello, " name "!"))
(assert-equal "Hello, World!" (~greeting :name "World")))
(deftest "island accepts children"
(defisland ~wrapper (&rest children)
(list "wrap" children))
(assert-equal (list "wrap" (list "a" "b"))
(~wrapper "a" "b"))))

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)

View File

@@ -189,6 +189,31 @@ class Component:
return f"<Component ~{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# Island
# ---------------------------------------------------------------------------
@dataclass
class Island:
"""A reactive UI component defined via ``(defisland ~name (&key ...) body)``.
Islands are like components but create a reactive boundary. Inside an
island, signals are tracked — deref subscribes DOM nodes to signals.
On the server, islands render as static HTML with hydration attributes.
"""
name: str
params: list[str]
has_children: bool
body: Any
closure: dict[str, Any] = field(default_factory=dict)
css_classes: set[str] = field(default_factory=set)
deps: set[str] = field(default_factory=set)
io_refs: set[str] = field(default_factory=set)
def __repr__(self):
return f"<Island ~{self.name}({', '.join(self.params)})>"
# ---------------------------------------------------------------------------
# HandlerDef
# ---------------------------------------------------------------------------
@@ -355,4 +380,4 @@ class _ShiftSignal(BaseException):
# ---------------------------------------------------------------------------
# An s-expression value after evaluation
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Island | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None