spec/ now contains only the language definition (5 files): evaluator.sx, parser.sx, primitives.sx, render.sx, special-forms.sx lib/ contains code written IN the language (8 files): stdlib.sx, types.sx, freeze.sx, content.sx, bytecode.sx, compiler.sx, vm.sx, callcc.sx Test files follow source: spec/tests/ for core language tests, lib/tests/ for library tests (continuations, freeze, types, vm). Updated all consumers: - JS/Python/OCaml bootstrappers: added lib/ to source search paths - OCaml bridge: spec_dir for parser/render, lib_dir for compiler/freeze - JS test runner: scans spec/tests/ (always) + lib/tests/ (--full) - OCaml test runner: scans spec/tests/, lib tests via explicit request - Docker dev mounts: added ./lib:/app/lib:ro Tests: 1041 JS standard, 1322 JS full, 1101 OCaml — all pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
349 lines
12 KiB
Plaintext
349 lines
12 KiB
Plaintext
;; ==========================================================================
|
|
;; test-signals-advanced.sx — Stress tests for the reactive signal system
|
|
;;
|
|
;; Requires: test-framework.sx loaded first.
|
|
;; Modules tested: signals.sx (signal, deref, reset!, swap!, computed,
|
|
;; effect, batch)
|
|
;;
|
|
;; Note: Multi-expression lambda bodies are wrapped in (do ...) for
|
|
;; compatibility with evaluators that support only single-expression bodies.
|
|
;; ==========================================================================
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Signal basics extended
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defsuite "signal-basics-extended"
|
|
(deftest "signal with nil initial value"
|
|
(let ((s (signal nil)))
|
|
(assert-true (signal? s))
|
|
(assert-nil (deref s))))
|
|
|
|
(deftest "signal with list value"
|
|
(let ((s (signal (list 1 2 3))))
|
|
(assert-equal (list 1 2 3) (deref s))
|
|
(reset! s (list 4 5 6))
|
|
(assert-equal (list 4 5 6) (deref s))))
|
|
|
|
(deftest "signal with dict value"
|
|
(let ((s (signal {:name "alice" :score 42})))
|
|
(assert-equal "alice" (get (deref s) "name"))
|
|
(assert-equal 42 (get (deref s) "score"))))
|
|
|
|
(deftest "signal with lambda value"
|
|
(let ((fn-val (fn (x) (* x 2)))
|
|
(s (signal nil)))
|
|
(reset! s fn-val)
|
|
;; The stored lambda should be callable
|
|
(assert-equal 10 ((deref s) 5))))
|
|
|
|
(deftest "multiple signals independent of each other"
|
|
(let ((a (signal 1))
|
|
(b (signal 2))
|
|
(c (signal 3)))
|
|
(reset! a 10)
|
|
;; b and c must be unchanged
|
|
(assert-equal 10 (deref a))
|
|
(assert-equal 2 (deref b))
|
|
(assert-equal 3 (deref c))
|
|
(reset! b 20)
|
|
(assert-equal 10 (deref a))
|
|
(assert-equal 20 (deref b))
|
|
(assert-equal 3 (deref c))))
|
|
|
|
(deftest "deref returns current value not a stale snapshot"
|
|
(let ((s (signal "first")))
|
|
(let ((snap1 (deref s)))
|
|
(reset! s "second")
|
|
(let ((snap2 (deref s)))
|
|
;; snap1 holds the string "first" (immutable), snap2 is "second"
|
|
(assert-equal "first" snap1)
|
|
(assert-equal "second" snap2))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Computed chains
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defsuite "computed-chains"
|
|
(deftest "chain of three computed signals"
|
|
(let ((base (signal 2))
|
|
(doubled (computed (fn () (* 2 (deref base)))))
|
|
(tripled (computed (fn () (* 3 (deref doubled))))))
|
|
;; Initial: base=2 → doubled=4 → tripled=12
|
|
(assert-equal 4 (deref doubled))
|
|
(assert-equal 12 (deref tripled))
|
|
;; Update propagates through the entire chain
|
|
(reset! base 5)
|
|
(assert-equal 10 (deref doubled))
|
|
(assert-equal 30 (deref tripled))))
|
|
|
|
(deftest "computed depending on multiple signals"
|
|
(let ((x (signal 3))
|
|
(y (signal 4))
|
|
(hypo (computed (fn ()
|
|
;; sqrt(x^2 + y^2) — Pythagorean hypotenuse (integer approx)
|
|
(+ (* (deref x) (deref x))
|
|
(* (deref y) (deref y)))))))
|
|
(assert-equal 25 (deref hypo))
|
|
(reset! x 0)
|
|
(assert-equal 16 (deref hypo))
|
|
(reset! y 0)
|
|
(assert-equal 0 (deref hypo))))
|
|
|
|
(deftest "computed with conditional logic"
|
|
(let ((flag (signal true))
|
|
(a (signal 10))
|
|
(b (signal 99))
|
|
(result (computed (fn ()
|
|
(if (deref flag) (deref a) (deref b))))))
|
|
(assert-equal 10 (deref result))
|
|
(reset! flag false)
|
|
(assert-equal 99 (deref result))
|
|
(reset! b 42)
|
|
(assert-equal 42 (deref result))
|
|
(reset! flag true)
|
|
(assert-equal 10 (deref result))))
|
|
|
|
(deftest "diamond dependency: A->B, A->C, B+C->D"
|
|
;; A change in A must propagate via both B and C to D,
|
|
;; but D must still hold a coherent (not intermediate) value.
|
|
(let ((A (signal 1))
|
|
(B (computed (fn () (* 2 (deref A)))))
|
|
(C (computed (fn () (* 3 (deref A)))))
|
|
(D (computed (fn () (+ (deref B) (deref C))))))
|
|
;; A=1 → B=2, C=3 → D=5
|
|
(assert-equal 2 (deref B))
|
|
(assert-equal 3 (deref C))
|
|
(assert-equal 5 (deref D))
|
|
;; A=4 → B=8, C=12 → D=20
|
|
(reset! A 4)
|
|
(assert-equal 8 (deref B))
|
|
(assert-equal 12 (deref C))
|
|
(assert-equal 20 (deref D))))
|
|
|
|
(deftest "computed returns nil when source signal is nil"
|
|
(let ((s (signal nil))
|
|
(c (computed (fn ()
|
|
(let ((v (deref s)))
|
|
(when (not (nil? v)) (* v 2)))))))
|
|
(assert-nil (deref c))
|
|
(reset! s 7)
|
|
(assert-equal 14 (deref c))
|
|
(reset! s nil)
|
|
(assert-nil (deref c)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Effect patterns
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defsuite "effect-patterns"
|
|
(deftest "effect runs immediately on creation"
|
|
(let ((ran (signal false)))
|
|
(effect (fn () (reset! ran true)))
|
|
(assert-true (deref ran))))
|
|
|
|
(deftest "effect re-runs when dependency changes"
|
|
(let ((n (signal 0))
|
|
(calls (signal 0)))
|
|
(effect (fn () (do (deref n) (swap! calls inc))))
|
|
;; Initial run counts as 1
|
|
(assert-equal 1 (deref calls))
|
|
(reset! n 1)
|
|
(assert-equal 2 (deref calls))
|
|
(reset! n 2)
|
|
(assert-equal 3 (deref calls))))
|
|
|
|
(deftest "effect with multiple dependencies"
|
|
(let ((a (signal "x"))
|
|
(b (signal "y"))
|
|
(calls (signal 0)))
|
|
(effect (fn () (do (deref a) (deref b) (swap! calls inc))))
|
|
(assert-equal 1 (deref calls))
|
|
;; Changing a triggers re-run
|
|
(reset! a "x2")
|
|
(assert-equal 2 (deref calls))
|
|
;; Changing b also triggers re-run
|
|
(reset! b "y2")
|
|
(assert-equal 3 (deref calls))))
|
|
|
|
(deftest "effect cleanup function called on re-run"
|
|
(let ((trigger (signal 0))
|
|
(cleanups (signal 0)))
|
|
(effect (fn () (do
|
|
(deref trigger)
|
|
;; Return a cleanup function
|
|
(fn () (swap! cleanups inc)))))
|
|
;; First run — no previous cleanup to call
|
|
(assert-equal 0 (deref cleanups))
|
|
;; Second run — previous cleanup fires first
|
|
(reset! trigger 1)
|
|
(assert-equal 1 (deref cleanups))
|
|
;; Third run — second cleanup fires
|
|
(reset! trigger 2)
|
|
(assert-equal 2 (deref cleanups))))
|
|
|
|
(deftest "effect tracks only actually-deref'd signals"
|
|
;; An effect that conditionally reads signal B should only re-run
|
|
;; for B changes when B is actually read (flag=true).
|
|
(let ((flag (signal true))
|
|
(b (signal 0))
|
|
(calls (signal 0)))
|
|
(effect (fn () (do
|
|
(deref flag)
|
|
(when (deref flag) (deref b))
|
|
(swap! calls inc))))
|
|
;; Initial run reads both flag and b
|
|
(assert-equal 1 (deref calls))
|
|
;; flip flag to false — re-run, but now b is NOT deref'd
|
|
(reset! flag false)
|
|
(assert-equal 2 (deref calls))
|
|
;; Changing b should NOT trigger another run (b wasn't deref'd last time)
|
|
(reset! b 99)
|
|
(assert-equal 2 (deref calls)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Batch behavior
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defsuite "batch-behavior"
|
|
(deftest "batch coalesces multiple signal updates into one effect run"
|
|
(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))
|
|
;; Two writes inside a single batch → one effect run, not two
|
|
(batch (fn () (do
|
|
(reset! a 1)
|
|
(reset! b 2))))
|
|
(assert-equal 2 (deref run-count))))
|
|
|
|
(deftest "nested batch — inner batch does not flush, outer batch does"
|
|
(let ((s (signal 0))
|
|
(run-count (signal 0)))
|
|
(effect (fn () (do (deref s) (swap! run-count inc))))
|
|
(assert-equal 1 (deref run-count))
|
|
(batch (fn ()
|
|
(batch (fn ()
|
|
(reset! s 1)))
|
|
;; Still inside outer batch — should not have fired yet
|
|
(reset! s 2)))
|
|
;; Outer batch ends → exactly one more run
|
|
(assert-equal 2 (deref run-count))
|
|
;; Final value is the last write
|
|
(assert-equal 2 (deref s))))
|
|
|
|
(deftest "batch with computed — computed updates once not per signal write"
|
|
(let ((x (signal 0))
|
|
(y (signal 0))
|
|
(sum (computed (fn () (+ (deref x) (deref y)))))
|
|
(recomps (signal 0)))
|
|
;; Track recomputations by wrapping via an effect
|
|
(effect (fn () (do (deref sum) (swap! recomps inc))))
|
|
;; Initial: effect + computed both ran once
|
|
(assert-equal 1 (deref recomps))
|
|
(batch (fn () (do
|
|
(reset! x 10)
|
|
(reset! y 20))))
|
|
;; sum must reflect both changes
|
|
(assert-equal 30 (deref sum))
|
|
;; effect re-ran at most once more (not twice)
|
|
(assert-equal 2 (deref recomps))))
|
|
|
|
(deftest "batch executes the thunk"
|
|
;; batch runs the thunk for side effects; return value is implementation-defined
|
|
(let ((s (signal 0)))
|
|
(batch (fn () (reset! s 42)))
|
|
(assert-equal 42 (deref s)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Swap patterns
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(defsuite "swap-patterns"
|
|
(deftest "swap! with increment function"
|
|
(let ((n (signal 0)))
|
|
(swap! n inc)
|
|
(assert-equal 1 (deref n))
|
|
(swap! n inc)
|
|
(assert-equal 2 (deref n))))
|
|
|
|
(deftest "swap! with list append"
|
|
(let ((items (signal (list))))
|
|
(swap! items (fn (l) (append l "a")))
|
|
(swap! items (fn (l) (append l "b")))
|
|
(swap! items (fn (l) (append l "c")))
|
|
(assert-equal (list "a" "b" "c") (deref items))))
|
|
|
|
(deftest "swap! with dict assoc"
|
|
(let ((store (signal {})))
|
|
(swap! store (fn (d) (assoc d "x" 1)))
|
|
(swap! store (fn (d) (assoc d "y" 2)))
|
|
(assert-equal 1 (get (deref store) "x"))
|
|
(assert-equal 2 (get (deref store) "y"))))
|
|
|
|
(deftest "multiple swap! in sequence build up correct value"
|
|
(let ((acc (signal 0)))
|
|
(swap! acc + 10)
|
|
(swap! acc + 5)
|
|
(swap! acc - 3)
|
|
(assert-equal 12 (deref acc)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; call-lambda + trampoline — event handler pattern
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; Regression: dom-on wraps Lambda event handlers in JS functions that
|
|
;; call callLambda. callLambda returns a Thunk (TCO), but the wrapper
|
|
;; never trampolined it, so the handler body (swap!, reset!, etc.)
|
|
;; never executed. Buttons rendered but clicks had no effect.
|
|
;;
|
|
;; These tests verify the pattern that dom-on uses:
|
|
;; (trampoline (call-lambda handler (list arg)))
|
|
;; must resolve thunks and execute side effects.
|
|
|
|
(defsuite "call-lambda-trampoline-handlers"
|
|
(deftest "call-lambda + trampoline executes signal mutation"
|
|
(let ((count (signal 0))
|
|
(handler (fn () (swap! count + 1))))
|
|
(trampoline (call-lambda handler (list)))
|
|
(assert-equal 1 (deref count))))
|
|
|
|
(deftest "call-lambda + trampoline with event arg"
|
|
(let ((last-val (signal nil))
|
|
(handler (fn (e) (reset! last-val e))))
|
|
(trampoline (call-lambda handler (list "click-event")))
|
|
(assert-equal "click-event" (deref last-val))))
|
|
|
|
(deftest "call-lambda + trampoline executes multi-statement body"
|
|
(let ((a (signal 0))
|
|
(b (signal 0))
|
|
(handler (fn ()
|
|
(reset! a 10)
|
|
(reset! b 20))))
|
|
(trampoline (call-lambda handler (list)))
|
|
(assert-equal 10 (deref a))
|
|
(assert-equal 20 (deref b))))
|
|
|
|
(deftest "repeated call-lambda accumulates side effects"
|
|
(let ((count (signal 0))
|
|
(handler (fn () (swap! count + 1))))
|
|
(trampoline (call-lambda handler (list)))
|
|
(trampoline (call-lambda handler (list)))
|
|
(trampoline (call-lambda handler (list)))
|
|
(assert-equal 3 (deref count))))
|
|
|
|
(deftest "call-lambda handler calling another lambda"
|
|
(let ((log (signal (list)))
|
|
(inner (fn (msg) (reset! log (append (deref log) (list msg)))))
|
|
(outer (fn () (inner "hello") (inner "world"))))
|
|
(trampoline (call-lambda outer (list)))
|
|
(assert-equal (list "hello" "world") (deref log)))))
|