From 26320abd647f9a5bf266807785d52d3c0385d4db Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 8 Mar 2026 09:44:18 +0000 Subject: [PATCH] Add signal test suite (17/17) and Island type to evaluator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- shared/sx/evaluator.py | 54 +++++- shared/sx/ref/signals.sx | 17 +- shared/sx/ref/test-signals.sx | 173 ++++++++++++++++++ shared/sx/tests/run.py | 324 +++++++++++++++++++++++++++++++++- shared/sx/types.py | 27 ++- 5 files changed, 580 insertions(+), 15 deletions(-) create mode 100644 shared/sx/ref/test-signals.sx diff --git a/shared/sx/evaluator.py b/shared/sx/evaluator.py index 022e4ef..683fb6f 100644 --- a/shared/sx/evaluator.py +++ b/shared/sx/evaluator.py @@ -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, diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index 0439471..4d6d1c1 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -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)))))) ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/test-signals.sx b/shared/sx/ref/test-signals.sx new file mode 100644 index 0000000..3ae5695 --- /dev/null +++ b/shared/sx/ref/test-signals.sx @@ -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")))) diff --git a/shared/sx/tests/run.py b/shared/sx/tests/run.py index 63ac207..35b5244 100644 --- a/shared/sx/tests/run.py +++ b/shared/sx/tests/run.py @@ -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) diff --git a/shared/sx/types.py b/shared/sx/types.py index f923e32..2b8d53d 100644 --- a/shared/sx/types.py +++ b/shared/sx/types.py @@ -189,6 +189,31 @@ class Component: return f"" +# --------------------------------------------------------------------------- +# 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"" + + # --------------------------------------------------------------------------- # 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