Phase 4: Move web framework files to web/ and web/tests/
signals.sx, engine.sx, orchestration.sx, boot.sx, router.sx, deps.sx, forms.sx, page-helpers.sx, adapters, boundary files → web/ Web tests → web/tests/ Test runners updated with _SPEC_TESTS and _WEB_TESTS paths. All 89 tests pass (20 signal + 43 CEK + 26 CEK reactive). Both bootstrappers build fully (5993 Python lines, 387KB JS). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
346
web/tests/test-aser.sx
Normal file
346
web/tests/test-aser.sx
Normal file
@@ -0,0 +1,346 @@
|
||||
;; ==========================================================================
|
||||
;; test-aser.sx — Tests for the SX wire format (aser) adapter
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: adapter-sx.sx (aser, aser-call, aser-fragment, aser-special)
|
||||
;;
|
||||
;; Platform functions required (beyond test framework):
|
||||
;; render-sx (sx-source) -> SX wire format string
|
||||
;; Parses the sx-source string, evaluates via aser in a
|
||||
;; fresh env, and returns the resulting SX wire format string.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Basic serialization
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-basics"
|
||||
(deftest "number literal passes through"
|
||||
(assert-equal "42"
|
||||
(render-sx "42")))
|
||||
|
||||
(deftest "string literal passes through"
|
||||
;; aser returns the raw string value; render-sx concatenates it directly
|
||||
(assert-equal "hello"
|
||||
(render-sx "\"hello\"")))
|
||||
|
||||
(deftest "boolean true passes through"
|
||||
(assert-equal "true"
|
||||
(render-sx "true")))
|
||||
|
||||
(deftest "boolean false passes through"
|
||||
(assert-equal "false"
|
||||
(render-sx "false")))
|
||||
|
||||
(deftest "nil produces empty"
|
||||
(assert-equal ""
|
||||
(render-sx "nil"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; HTML tag serialization
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-tags"
|
||||
(deftest "simple div"
|
||||
(assert-equal "(div \"hello\")"
|
||||
(render-sx "(div \"hello\")")))
|
||||
|
||||
(deftest "nested tags"
|
||||
(assert-equal "(div (span \"hi\"))"
|
||||
(render-sx "(div (span \"hi\"))")))
|
||||
|
||||
(deftest "multiple children"
|
||||
(assert-equal "(div (p \"a\") (p \"b\"))"
|
||||
(render-sx "(div (p \"a\") (p \"b\"))")))
|
||||
|
||||
(deftest "attributes serialize"
|
||||
(assert-equal "(div :class \"foo\" \"bar\")"
|
||||
(render-sx "(div :class \"foo\" \"bar\")")))
|
||||
|
||||
(deftest "multiple attributes"
|
||||
(assert-equal "(a :href \"/home\" :class \"link\" \"Home\")"
|
||||
(render-sx "(a :href \"/home\" :class \"link\" \"Home\")")))
|
||||
|
||||
(deftest "void elements"
|
||||
(assert-equal "(br)"
|
||||
(render-sx "(br)")))
|
||||
|
||||
(deftest "void element with attrs"
|
||||
(assert-equal "(img :src \"pic.jpg\")"
|
||||
(render-sx "(img :src \"pic.jpg\")"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Fragment serialization
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-fragments"
|
||||
(deftest "simple fragment"
|
||||
(assert-equal "(<> (p \"a\") (p \"b\"))"
|
||||
(render-sx "(<> (p \"a\") (p \"b\"))")))
|
||||
|
||||
(deftest "empty fragment"
|
||||
(assert-equal ""
|
||||
(render-sx "(<>)")))
|
||||
|
||||
(deftest "single-child fragment"
|
||||
(assert-equal "(<> (div \"x\"))"
|
||||
(render-sx "(<> (div \"x\"))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Control flow in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-control-flow"
|
||||
(deftest "if true branch"
|
||||
(assert-equal "(p \"yes\")"
|
||||
(render-sx "(if true (p \"yes\") (p \"no\"))")))
|
||||
|
||||
(deftest "if false branch"
|
||||
(assert-equal "(p \"no\")"
|
||||
(render-sx "(if false (p \"yes\") (p \"no\"))")))
|
||||
|
||||
(deftest "when true"
|
||||
(assert-equal "(p \"ok\")"
|
||||
(render-sx "(when true (p \"ok\"))")))
|
||||
|
||||
(deftest "when false"
|
||||
(assert-equal ""
|
||||
(render-sx "(when false (p \"ok\"))")))
|
||||
|
||||
(deftest "cond serializes matching branch"
|
||||
(assert-equal "(p \"two\")"
|
||||
(render-sx "(cond false (p \"one\") true (p \"two\") :else (p \"three\"))")))
|
||||
|
||||
(deftest "cond with 2-element predicate test"
|
||||
;; Regression: cond misclassifies (nil? x) as scheme-style clause.
|
||||
(assert-equal "(p \"yes\")"
|
||||
(render-sx "(cond (nil? nil) (p \"yes\") :else (p \"no\"))"))
|
||||
(assert-equal "(p \"no\")"
|
||||
(render-sx "(cond (nil? \"x\") (p \"yes\") :else (p \"no\"))")))
|
||||
|
||||
(deftest "let binds then serializes"
|
||||
(assert-equal "(p \"hello\")"
|
||||
(render-sx "(let ((x \"hello\")) (p x))")))
|
||||
|
||||
(deftest "let preserves outer scope bindings"
|
||||
;; Regression: process-bindings must preserve parent env scope chain.
|
||||
;; Using merge() instead of env-extend loses parent scope items.
|
||||
(assert-equal "(p \"outer\")"
|
||||
(render-sx "(do (define theme \"outer\") (let ((x 1)) (p theme)))")))
|
||||
|
||||
(deftest "nested let preserves outer scope"
|
||||
(assert-equal "(div (span \"hello\") (span \"world\"))"
|
||||
(render-sx "(do (define a \"hello\")
|
||||
(define b \"world\")
|
||||
(div (let ((x 1)) (span a))
|
||||
(let ((y 2)) (span b))))")))
|
||||
|
||||
(deftest "begin serializes last"
|
||||
(assert-equal "(p \"last\")"
|
||||
(render-sx "(begin (p \"first\") (p \"last\"))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; THE BUG — map/filter list flattening in children (critical regression)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-list-flattening"
|
||||
(deftest "map inside tag flattens children"
|
||||
(assert-equal "(div (span \"a\") (span \"b\") (span \"c\"))"
|
||||
(render-sx "(do (define items (list \"a\" \"b\" \"c\"))
|
||||
(div (map (fn (x) (span x)) items)))")))
|
||||
|
||||
(deftest "map inside tag with other children"
|
||||
(assert-equal "(ul (li \"first\") (li \"a\") (li \"b\"))"
|
||||
(render-sx "(do (define items (list \"a\" \"b\"))
|
||||
(ul (li \"first\") (map (fn (x) (li x)) items)))")))
|
||||
|
||||
(deftest "filter result via let binding as children"
|
||||
;; Note: (filter ...) is treated as an SVG tag in aser dispatch (SVG has <filter>),
|
||||
;; so we evaluate filter via let binding + map to serialize children
|
||||
(assert-equal "(ul (li \"a\") (li \"b\"))"
|
||||
(render-sx "(do (define items (list \"a\" nil \"b\"))
|
||||
(define kept (filter (fn (x) (not (nil? x))) items))
|
||||
(ul (map (fn (x) (li x)) kept)))")))
|
||||
|
||||
(deftest "map inside fragment flattens"
|
||||
(assert-equal "(<> (p \"a\") (p \"b\"))"
|
||||
(render-sx "(do (define items (list \"a\" \"b\"))
|
||||
(<> (map (fn (x) (p x)) items)))")))
|
||||
|
||||
(deftest "nested map does not double-wrap"
|
||||
(assert-equal "(div (span \"1\") (span \"2\"))"
|
||||
(render-sx "(do (define nums (list 1 2))
|
||||
(div (map (fn (n) (span (str n))) nums)))")))
|
||||
|
||||
(deftest "map with component-like output flattens"
|
||||
(assert-equal "(div (li \"x\") (li \"y\"))"
|
||||
(render-sx "(do (define items (list \"x\" \"y\"))
|
||||
(div (map (fn (x) (li x)) items)))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Component serialization (NOT expanded in basic aser mode)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-components"
|
||||
(deftest "unknown component serializes as-is"
|
||||
(assert-equal "(~foo :title \"bar\")"
|
||||
(render-sx "(~foo :title \"bar\")")))
|
||||
|
||||
(deftest "defcomp then unexpanded component call"
|
||||
(assert-equal "(~card :title \"Hi\")"
|
||||
(render-sx "(do (defcomp ~card (&key title) (h1 title)) (~card :title \"Hi\"))")))
|
||||
|
||||
(deftest "component with children serializes unexpanded"
|
||||
(assert-equal "(~box (p \"inside\"))"
|
||||
(render-sx "(do (defcomp ~box (&key &rest children) (div children))
|
||||
(~box (p \"inside\")))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Definition forms in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-definitions"
|
||||
(deftest "define evaluates for side effects, returns nil"
|
||||
(assert-equal "(p 42)"
|
||||
(render-sx "(do (define x 42) (p x))")))
|
||||
|
||||
(deftest "defcomp evaluates and returns nil"
|
||||
(assert-equal "(~tag :x 1)"
|
||||
(render-sx "(do (defcomp ~tag (&key x) (span x)) (~tag :x 1))")))
|
||||
|
||||
(deftest "defisland evaluates AND serializes"
|
||||
(let ((result (render-sx "(defisland ~counter (&key count) (span count))")))
|
||||
(assert-true (string-contains? result "defisland")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Function calls in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-function-calls"
|
||||
(deftest "named function call evaluates fully"
|
||||
(assert-equal "3"
|
||||
(render-sx "(do (define inc1 (fn (x) (+ x 1))) (inc1 2))")))
|
||||
|
||||
(deftest "define + call"
|
||||
(assert-equal "10"
|
||||
(render-sx "(do (define double (fn (x) (* x 2))) (double 5))")))
|
||||
|
||||
(deftest "native callable with multiple args"
|
||||
;; Regression: async-aser-eval-call passed evaled-args list to
|
||||
;; async-invoke (&rest), wrapping it in another list. apply(f, [list])
|
||||
;; calls f(list) instead of f(*list).
|
||||
(assert-equal "3"
|
||||
(render-sx "(do (define my-add +) (my-add 1 2))")))
|
||||
|
||||
(deftest "native callable with two args via alias"
|
||||
(assert-equal "hello world"
|
||||
(render-sx "(do (define my-join str) (my-join \"hello\" \" world\"))")))
|
||||
|
||||
(deftest "higher-order: map returns list"
|
||||
(let ((result (render-sx "(map (fn (x) (+ x 1)) (list 1 2 3))")))
|
||||
;; map at top level returns a list, not serialized tags
|
||||
(assert-true (not (nil? result))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; and/or short-circuit in aser mode
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-logic"
|
||||
(deftest "and short-circuits on false"
|
||||
(assert-equal "false"
|
||||
(render-sx "(and true false true)")))
|
||||
|
||||
(deftest "and returns last truthy"
|
||||
(assert-equal "3"
|
||||
(render-sx "(and 1 2 3)")))
|
||||
|
||||
(deftest "or short-circuits on true"
|
||||
(assert-equal "1"
|
||||
(render-sx "(or 1 2 3)")))
|
||||
|
||||
(deftest "or returns last falsy"
|
||||
(assert-equal "false"
|
||||
(render-sx "(or false false)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Spread serialization
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "aser-spreads"
|
||||
(deftest "spread in element merges attrs"
|
||||
(assert-equal "(div :class \"card\" \"hello\")"
|
||||
(render-sx "(div (make-spread {:class \"card\"}) \"hello\")")))
|
||||
|
||||
(deftest "multiple spreads merge into element"
|
||||
(assert-equal "(div :class \"card\" :style \"color:red\" \"hello\")"
|
||||
(render-sx "(div (make-spread {:class \"card\"}) (make-spread {:style \"color:red\"}) \"hello\")")))
|
||||
|
||||
(deftest "spread in fragment is silently dropped"
|
||||
(assert-equal "(<> \"hello\")"
|
||||
(render-sx "(<> (make-spread {:class \"card\"}) \"hello\")")))
|
||||
|
||||
(deftest "stored spread in let binding"
|
||||
(assert-equal "(div :class \"card\" \"hello\")"
|
||||
(render-sx "(let ((card (make-spread {:class \"card\"})))
|
||||
(div card \"hello\"))")))
|
||||
|
||||
(deftest "spread in nested element"
|
||||
(assert-equal "(div (span :class \"inner\" \"hi\"))"
|
||||
(render-sx "(div (span (make-spread {:class \"inner\"}) \"hi\"))")))
|
||||
|
||||
(deftest "spread in non-element context silently drops"
|
||||
(assert-equal "hello"
|
||||
(render-sx "(do (make-spread {:class \"card\"}) \"hello\")"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Scope tests — unified scope primitive
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scope"
|
||||
|
||||
(deftest "scope with value and context"
|
||||
(assert-equal "dark"
|
||||
(render-sx "(scope \"sc-theme\" :value \"dark\" (context \"sc-theme\"))")))
|
||||
|
||||
(deftest "scope without value defaults to nil"
|
||||
(assert-equal ""
|
||||
(render-sx "(scope \"sc-nil\" (str (context \"sc-nil\")))")))
|
||||
|
||||
(deftest "scope with emit!/emitted"
|
||||
(assert-equal "a,b"
|
||||
(render-sx "(scope \"sc-emit\" (emit! \"sc-emit\" \"a\") (emit! \"sc-emit\" \"b\") (join \",\" (emitted \"sc-emit\")))")))
|
||||
|
||||
(deftest "provide is equivalent to scope with value"
|
||||
(assert-equal "42"
|
||||
(render-sx "(provide \"sc-prov\" 42 (str (context \"sc-prov\")))")))
|
||||
|
||||
(deftest "collect! works via scope (lazy root scope)"
|
||||
(assert-equal "x,y"
|
||||
(render-sx "(do (collect! \"sc-coll\" \"x\") (collect! \"sc-coll\" \"y\") (join \",\" (collected \"sc-coll\")))")))
|
||||
|
||||
(deftest "collect! deduplicates"
|
||||
(assert-equal "a"
|
||||
(render-sx "(do (collect! \"sc-dedup\" \"a\") (collect! \"sc-dedup\" \"a\") (join \",\" (collected \"sc-dedup\")))")))
|
||||
|
||||
(deftest "clear-collected! clears scope accumulator"
|
||||
(assert-equal ""
|
||||
(render-sx "(do (collect! \"sc-clear\" \"x\") (clear-collected! \"sc-clear\") (join \",\" (collected \"sc-clear\")))")))
|
||||
|
||||
(deftest "nested scope shadows outer"
|
||||
(assert-equal "inner"
|
||||
(render-sx "(scope \"sc-nest\" :value \"outer\" (scope \"sc-nest\" :value \"inner\" (context \"sc-nest\")))")))
|
||||
|
||||
(deftest "scope pops correctly after body"
|
||||
(assert-equal "outer"
|
||||
(render-sx "(scope \"sc-pop\" :value \"outer\" (scope \"sc-pop\" :value \"inner\" \"ignore\") (context \"sc-pop\"))"))))
|
||||
279
web/tests/test-cek-reactive.sx
Normal file
279
web/tests/test-cek-reactive.sx
Normal file
@@ -0,0 +1,279 @@
|
||||
;; ==========================================================================
|
||||
;; test-cek-reactive.sx — Tests for deref-as-shift reactive rendering
|
||||
;;
|
||||
;; Tests that (deref signal) inside a reactive-reset boundary performs
|
||||
;; continuation capture: the rest of the expression becomes the subscriber.
|
||||
;;
|
||||
;; Requires: test-framework.sx, frames.sx, cek.sx, signals.sx loaded first.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Basic deref behavior through CEK
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "deref pass-through"
|
||||
(deftest "deref non-signal passes through"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(deref 42)")
|
||||
(test-env))))
|
||||
(assert-equal 42 result)))
|
||||
|
||||
(deftest "deref nil passes through"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(deref nil)")
|
||||
(test-env))))
|
||||
(assert-nil result)))
|
||||
|
||||
(deftest "deref string passes through"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(deref \"hello\")")
|
||||
(test-env))))
|
||||
(assert-equal "hello" result))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Deref signal without reactive-reset (no shift)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "deref signal without reactive-reset"
|
||||
(deftest "deref signal returns current value"
|
||||
(let ((s (signal 99)))
|
||||
(env-set! (test-env) "test-sig" s)
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(deref test-sig)")
|
||||
(test-env))))
|
||||
(assert-equal 99 result))))
|
||||
|
||||
(deftest "deref signal in expression returns computed value"
|
||||
(let ((s (signal 10)))
|
||||
(env-set! (test-env) "test-sig" s)
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(+ 5 (deref test-sig))")
|
||||
(test-env))))
|
||||
(assert-equal 15 result)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Reactive reset + deref: continuation capture
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "reactive-reset shift"
|
||||
(deftest "deref signal with reactive-reset captures continuation"
|
||||
(let ((s (signal 42))
|
||||
(captured-val nil))
|
||||
;; Run CEK with a ReactiveResetFrame
|
||||
(let ((result (cek-run
|
||||
(make-cek-state
|
||||
(sx-parse-one "(deref test-sig)")
|
||||
(let ((e (env-extend (test-env))))
|
||||
(env-set! e "test-sig" s)
|
||||
e)
|
||||
(list (make-reactive-reset-frame
|
||||
(test-env)
|
||||
(fn (v) (set! captured-val v))
|
||||
true))))))
|
||||
;; Initial render: returns current value, update-fn NOT called (first-render)
|
||||
(assert-equal 42 result)
|
||||
(assert-nil captured-val))))
|
||||
|
||||
(deftest "signal change invokes subscriber with update-fn"
|
||||
(let ((s (signal 10))
|
||||
(update-calls (list)))
|
||||
;; Set up reactive-reset with tracking update-fn
|
||||
(scope-push! "sx-island-scope" nil)
|
||||
(let ((e (env-extend (test-env))))
|
||||
(env-set! e "test-sig" s)
|
||||
(cek-run
|
||||
(make-cek-state
|
||||
(sx-parse-one "(deref test-sig)")
|
||||
e
|
||||
(list (make-reactive-reset-frame
|
||||
e
|
||||
(fn (v) (append! update-calls v))
|
||||
true)))))
|
||||
;; Change signal — subscriber should fire
|
||||
(reset! s 20)
|
||||
(assert-equal 1 (len update-calls))
|
||||
(assert-equal 20 (first update-calls))
|
||||
;; Change again
|
||||
(reset! s 30)
|
||||
(assert-equal 2 (len update-calls))
|
||||
(assert-equal 30 (nth update-calls 1))
|
||||
(scope-pop! "sx-island-scope")))
|
||||
|
||||
(deftest "expression with deref captures rest as continuation"
|
||||
(let ((s (signal 5))
|
||||
(update-calls (list)))
|
||||
(scope-push! "sx-island-scope" nil)
|
||||
(let ((e (env-extend (test-env))))
|
||||
(env-set! e "test-sig" s)
|
||||
;; (str "val=" (deref test-sig)) — continuation captures (str "val=" [HOLE])
|
||||
(let ((result (cek-run
|
||||
(make-cek-state
|
||||
(sx-parse-one "(str \"val=\" (deref test-sig))")
|
||||
e
|
||||
(list (make-reactive-reset-frame
|
||||
e
|
||||
(fn (v) (append! update-calls v))
|
||||
true))))))
|
||||
(assert-equal "val=5" result)))
|
||||
;; Change signal — should get updated string
|
||||
(reset! s 42)
|
||||
(assert-equal 1 (len update-calls))
|
||||
(assert-equal "val=42" (first update-calls))
|
||||
(scope-pop! "sx-island-scope"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Disposal and cleanup
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "disposal"
|
||||
(deftest "scope cleanup unsubscribes continuation"
|
||||
(let ((s (signal 1))
|
||||
(update-calls (list))
|
||||
(disposers (list)))
|
||||
;; Create island scope with collector that accumulates disposers
|
||||
(scope-push! "sx-island-scope" (fn (d) (append! disposers d)))
|
||||
(let ((e (env-extend (test-env))))
|
||||
(env-set! e "test-sig" s)
|
||||
(cek-run
|
||||
(make-cek-state
|
||||
(sx-parse-one "(deref test-sig)")
|
||||
e
|
||||
(list (make-reactive-reset-frame
|
||||
e
|
||||
(fn (v) (append! update-calls v))
|
||||
true)))))
|
||||
;; Pop scope — call all disposers
|
||||
(scope-pop! "sx-island-scope")
|
||||
(for-each (fn (d) (cek-call d nil)) disposers)
|
||||
;; Change signal — no update should fire
|
||||
(reset! s 999)
|
||||
(assert-equal 0 (len update-calls)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; cek-call integration — computed/effect use cek-call dispatch
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "cek-call dispatch"
|
||||
(deftest "cek-call invokes native function"
|
||||
(let ((log (list)))
|
||||
(cek-call (fn (x) (append! log x)) (list 42))
|
||||
(assert-equal (list 42) log)))
|
||||
|
||||
(deftest "cek-call invokes zero-arg lambda"
|
||||
(let ((result (cek-call (fn () (+ 1 2)) nil)))
|
||||
(assert-equal 3 result)))
|
||||
|
||||
(deftest "cek-call with nil function returns nil"
|
||||
(assert-nil (cek-call nil nil)))
|
||||
|
||||
(deftest "computed tracks deps via cek-call"
|
||||
(let ((s (signal 10)))
|
||||
(let ((c (computed (fn () (* 2 (deref s))))))
|
||||
(assert-equal 20 (deref c))
|
||||
(reset! s 5)
|
||||
(assert-equal 10 (deref c)))))
|
||||
|
||||
(deftest "effect runs and re-runs via cek-call"
|
||||
(let ((s (signal "a"))
|
||||
(log (list)))
|
||||
(effect (fn () (append! log (deref s))))
|
||||
(assert-equal (list "a") log)
|
||||
(reset! s "b")
|
||||
(assert-equal (list "a" "b") log)))
|
||||
|
||||
(deftest "effect cleanup runs on re-trigger"
|
||||
(let ((s (signal 0))
|
||||
(log (list)))
|
||||
(effect (fn ()
|
||||
(let ((val (deref s)))
|
||||
(append! log (str "run:" val))
|
||||
;; Return cleanup function
|
||||
(fn () (append! log (str "clean:" val))))))
|
||||
(assert-equal (list "run:0") log)
|
||||
(reset! s 1)
|
||||
(assert-equal (list "run:0" "clean:0" "run:1") log)))
|
||||
|
||||
(deftest "batch coalesces via cek-call"
|
||||
(let ((s (signal 0))
|
||||
(count (signal 0)))
|
||||
(effect (fn () (do (deref s) (swap! count inc))))
|
||||
(assert-equal 1 (deref count))
|
||||
(batch (fn ()
|
||||
(reset! s 1)
|
||||
(reset! s 2)
|
||||
(reset! s 3)))
|
||||
;; batch should coalesce — effect runs once, not three times
|
||||
(assert-equal 2 (deref count)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; CEK-native higher-order forms
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "CEK higher-order forms"
|
||||
(deftest "map through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(map (fn (x) (* x 2)) (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-equal (list 2 4 6) result)))
|
||||
|
||||
(deftest "map-indexed through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(map-indexed (fn (i x) (+ i x)) (list 10 20 30))")
|
||||
(test-env))))
|
||||
(assert-equal (list 10 21 32) result)))
|
||||
|
||||
(deftest "filter through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(filter (fn (x) (> x 2)) (list 1 2 3 4 5))")
|
||||
(test-env))))
|
||||
(assert-equal (list 3 4 5) result)))
|
||||
|
||||
(deftest "reduce through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(reduce (fn (acc x) (+ acc x)) 0 (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-equal 6 result)))
|
||||
|
||||
(deftest "some through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(some (fn (x) (> x 3)) (list 1 2 3 4 5))")
|
||||
(test-env))))
|
||||
(assert-true result)))
|
||||
|
||||
(deftest "some returns false when none match"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(some (fn (x) (> x 10)) (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-false result)))
|
||||
|
||||
(deftest "every? through CEK"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(every? (fn (x) (> x 0)) (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-true result)))
|
||||
|
||||
(deftest "every? returns false on first falsy"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(every? (fn (x) (> x 2)) (list 1 2 3))")
|
||||
(test-env))))
|
||||
(assert-false result)))
|
||||
|
||||
(deftest "for-each through CEK"
|
||||
(let ((log (list)))
|
||||
(env-set! (test-env) "test-log" log)
|
||||
(eval-expr-cek
|
||||
(sx-parse-one "(for-each (fn (x) (append! test-log x)) (list 1 2 3))")
|
||||
(test-env))
|
||||
(assert-equal (list 1 2 3) log)))
|
||||
|
||||
(deftest "map on empty list"
|
||||
(let ((result (eval-expr-cek
|
||||
(sx-parse-one "(map (fn (x) x) (list))")
|
||||
(test-env))))
|
||||
(assert-equal (list) result))))
|
||||
327
web/tests/test-deps.sx
Normal file
327
web/tests/test-deps.sx
Normal file
@@ -0,0 +1,327 @@
|
||||
;; ==========================================================================
|
||||
;; test-deps.sx — Tests for component dependency analysis (deps.sx)
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions: scan-refs, transitive-deps, components-needed,
|
||||
;; component-pure?, scan-io-refs, transitive-io-refs,
|
||||
;; scan-components-from-source, test-env
|
||||
;; (loaded from bootstrapped output by test runners)
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Test component definitions — these exist in the test env for dep analysis
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~dep-leaf ()
|
||||
(span "leaf"))
|
||||
|
||||
(defcomp ~dep-branch ()
|
||||
(div (~dep-leaf)))
|
||||
|
||||
(defcomp ~dep-trunk ()
|
||||
(div (~dep-branch) (~dep-leaf)))
|
||||
|
||||
(defcomp ~dep-conditional (&key show?)
|
||||
(if show?
|
||||
(~dep-leaf)
|
||||
(~dep-branch)))
|
||||
|
||||
(defcomp ~dep-nested-cond (&key mode)
|
||||
(cond
|
||||
(= mode "a") (~dep-leaf)
|
||||
(= mode "b") (~dep-branch)
|
||||
:else (~dep-trunk)))
|
||||
|
||||
(defcomp ~dep-island ()
|
||||
(div "no deps"))
|
||||
|
||||
;; Islands with dependencies — defisland bodies must be scanned
|
||||
(defisland ~dep-island-with-child ()
|
||||
(div (~dep-leaf) "island content"))
|
||||
|
||||
(defisland ~dep-island-with-chain ()
|
||||
(div (~dep-branch) "deep island"))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. scan-refs — finds component references in AST nodes
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scan-refs"
|
||||
|
||||
(deftest "empty for string literal"
|
||||
(assert-equal (list) (scan-refs "hello")))
|
||||
|
||||
(deftest "empty for number"
|
||||
(assert-equal (list) (scan-refs 42)))
|
||||
|
||||
(deftest "finds component symbol"
|
||||
(let ((refs (scan-refs (quote (~dep-leaf)))))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds in nested list"
|
||||
(let ((refs (scan-refs (quote (div (span (~dep-leaf)))))))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds multiple refs"
|
||||
(let ((refs (scan-refs (quote (div (~dep-leaf) (~dep-branch))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "deduplicates"
|
||||
(let ((refs (scan-refs (quote (div (~dep-leaf) (~dep-leaf))))))
|
||||
(assert-equal 1 (len refs))))
|
||||
|
||||
(deftest "walks if branches"
|
||||
(let ((refs (scan-refs (quote (if true (~dep-leaf) (~dep-branch))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "walks cond branches"
|
||||
(let ((refs (scan-refs (quote (cond (= x 1) (~dep-leaf) :else (~dep-trunk))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-trunk" refs)))
|
||||
|
||||
(deftest "ignores non-component symbols"
|
||||
(let ((refs (scan-refs (quote (div class "foo")))))
|
||||
(assert-equal 0 (len refs)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. scan-components-from-source — regex-based source string scanning
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scan-components-from-source"
|
||||
|
||||
(deftest "finds single component"
|
||||
(let ((refs (scan-components-from-source "(~dep-leaf)")))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds multiple components"
|
||||
(let ((refs (scan-components-from-source "(div (~dep-leaf) (~dep-branch))")))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "no false positives on plain text"
|
||||
(let ((refs (scan-components-from-source "(div \"hello world\")")))
|
||||
(assert-equal 0 (len refs))))
|
||||
|
||||
(deftest "handles hyphenated names"
|
||||
(let ((refs (scan-components-from-source "(~my-component :key val)")))
|
||||
(assert-contains "~my-component" refs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. transitive-deps — transitive dependency closure
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "transitive-deps"
|
||||
|
||||
(deftest "leaf has no deps"
|
||||
(let ((deps (transitive-deps "~dep-leaf" (test-env))))
|
||||
(assert-equal 0 (len deps))))
|
||||
|
||||
(deftest "direct dependency"
|
||||
(let ((deps (transitive-deps "~dep-branch" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "transitive closure"
|
||||
(let ((deps (transitive-deps "~dep-trunk" (test-env))))
|
||||
(assert-contains "~dep-branch" deps)
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "excludes self"
|
||||
(let ((deps (transitive-deps "~dep-trunk" (test-env))))
|
||||
(assert-false (contains? deps "~dep-trunk"))))
|
||||
|
||||
(deftest "walks conditional branches"
|
||||
(let ((deps (transitive-deps "~dep-conditional" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)
|
||||
(assert-contains "~dep-branch" deps)))
|
||||
|
||||
(deftest "walks all cond branches"
|
||||
(let ((deps (transitive-deps "~dep-nested-cond" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)
|
||||
(assert-contains "~dep-branch" deps)
|
||||
(assert-contains "~dep-trunk" deps)))
|
||||
|
||||
(deftest "island has no deps"
|
||||
(let ((deps (transitive-deps "~dep-island" (test-env))))
|
||||
(assert-equal 0 (len deps))))
|
||||
|
||||
(deftest "accepts name without tilde"
|
||||
(let ((deps (transitive-deps "dep-branch" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "island direct dep scanned"
|
||||
(let ((deps (transitive-deps "~dep-island-with-child" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "island transitive deps scanned"
|
||||
(let ((deps (transitive-deps "~dep-island-with-chain" (test-env))))
|
||||
(assert-contains "~dep-branch" deps)
|
||||
(assert-contains "~dep-leaf" deps))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. components-needed — page bundle computation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "components-needed"
|
||||
|
||||
(deftest "finds direct and transitive"
|
||||
(let ((needed (components-needed "(~dep-trunk)" (test-env))))
|
||||
(assert-contains "~dep-trunk" needed)
|
||||
(assert-contains "~dep-branch" needed)
|
||||
(assert-contains "~dep-leaf" needed)))
|
||||
|
||||
(deftest "deduplicates"
|
||||
(let ((needed (components-needed "(div (~dep-leaf) (~dep-leaf))" (test-env))))
|
||||
;; ~dep-leaf should appear only once
|
||||
(assert-true (contains? needed "~dep-leaf"))))
|
||||
|
||||
(deftest "handles leaf page"
|
||||
(let ((needed (components-needed "(~dep-island)" (test-env))))
|
||||
(assert-contains "~dep-island" needed)
|
||||
(assert-equal 1 (len needed))))
|
||||
|
||||
(deftest "handles multiple top-level components"
|
||||
(let ((needed (components-needed "(div (~dep-leaf) (~dep-island))" (test-env))))
|
||||
(assert-contains "~dep-leaf" needed)
|
||||
(assert-contains "~dep-island" needed)))
|
||||
|
||||
(deftest "island deps included in page bundle"
|
||||
(let ((needed (components-needed "(~dep-island-with-chain)" (test-env))))
|
||||
(assert-contains "~dep-island-with-chain" needed)
|
||||
(assert-contains "~dep-branch" needed)
|
||||
(assert-contains "~dep-leaf" needed))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. IO detection — scan-io-refs, component-pure?
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Define components that reference "io" functions for testing
|
||||
(defcomp ~dep-pure ()
|
||||
(div (~dep-leaf) "static"))
|
||||
|
||||
(defcomp ~dep-io ()
|
||||
(div (fetch-data "/api")))
|
||||
|
||||
(defcomp ~dep-io-indirect ()
|
||||
(div (~dep-io)))
|
||||
|
||||
(defsuite "scan-io-refs"
|
||||
|
||||
(deftest "no IO in pure AST"
|
||||
(let ((refs (scan-io-refs (quote (div "hello" (span "world"))) (list "fetch-data"))))
|
||||
(assert-equal 0 (len refs))))
|
||||
|
||||
(deftest "finds IO reference"
|
||||
(let ((refs (scan-io-refs (quote (div (fetch-data "/api"))) (list "fetch-data"))))
|
||||
(assert-contains "fetch-data" refs)))
|
||||
|
||||
(deftest "multiple IO refs"
|
||||
(let ((refs (scan-io-refs (quote (div (fetch-data "/a") (query-db "x"))) (list "fetch-data" "query-db"))))
|
||||
(assert-contains "fetch-data" refs)
|
||||
(assert-contains "query-db" refs)))
|
||||
|
||||
(deftest "ignores non-IO symbols"
|
||||
(let ((refs (scan-io-refs (quote (div (map str items))) (list "fetch-data"))))
|
||||
(assert-equal 0 (len refs)))))
|
||||
|
||||
|
||||
(defsuite "component-pure?"
|
||||
|
||||
(deftest "pure component is pure"
|
||||
(assert-true (component-pure? "~dep-pure" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "IO component is not pure"
|
||||
(assert-false (component-pure? "~dep-io" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "indirect IO is not pure"
|
||||
(assert-false (component-pure? "~dep-io-indirect" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "leaf component is pure"
|
||||
(assert-true (component-pure? "~dep-leaf" (test-env) (list "fetch-data")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. render-target — boundary decision with affinity
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Components with explicit affinity annotations
|
||||
(defcomp ~dep-force-client (&key x)
|
||||
:affinity :client
|
||||
(div (fetch-data "/api") x))
|
||||
|
||||
(defcomp ~dep-force-server (&key x)
|
||||
:affinity :server
|
||||
(div x))
|
||||
|
||||
(defcomp ~dep-auto-pure (&key x)
|
||||
(div x))
|
||||
|
||||
(defcomp ~dep-auto-io (&key x)
|
||||
(div (fetch-data "/api")))
|
||||
|
||||
(defsuite "render-target"
|
||||
|
||||
(deftest "pure auto component targets client"
|
||||
(assert-equal "client" (render-target "~dep-auto-pure" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "IO auto component targets server"
|
||||
(assert-equal "server" (render-target "~dep-auto-io" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "affinity client overrides IO to client"
|
||||
(assert-equal "client" (render-target "~dep-force-client" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "affinity server overrides pure to server"
|
||||
(assert-equal "server" (render-target "~dep-force-server" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "leaf component targets client"
|
||||
(assert-equal "client" (render-target "~dep-leaf" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "unknown name targets server"
|
||||
(assert-equal "server" (render-target "~nonexistent" (test-env) (list "fetch-data")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. page-render-plan — per-page boundary plan
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; A page component that uses both pure and IO components
|
||||
(defcomp ~plan-page (&key data)
|
||||
(div
|
||||
(~dep-auto-pure :x "hello")
|
||||
(~dep-auto-io :x data)
|
||||
(~dep-force-client :x "interactive")))
|
||||
|
||||
(defsuite "page-render-plan"
|
||||
|
||||
(deftest "plan classifies components correctly"
|
||||
(let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data"))))
|
||||
;; ~plan-page has transitive IO deps (via ~dep-auto-io) so targets server
|
||||
(assert-equal "server" (dict-get (get plan :components) "~plan-page"))
|
||||
(assert-equal "client" (dict-get (get plan :components) "~dep-auto-pure"))
|
||||
(assert-equal "server" (dict-get (get plan :components) "~dep-auto-io"))
|
||||
(assert-equal "client" (dict-get (get plan :components) "~dep-force-client"))))
|
||||
|
||||
(deftest "plan server list contains IO components"
|
||||
(let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data"))))
|
||||
(assert-true (contains? (get plan :server) "~dep-auto-io"))))
|
||||
|
||||
(deftest "plan client list contains pure components"
|
||||
(let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data"))))
|
||||
(assert-true (contains? (get plan :client) "~dep-auto-pure"))
|
||||
(assert-true (contains? (get plan :client) "~dep-force-client"))))
|
||||
|
||||
(deftest "plan collects IO deps from server components"
|
||||
(let ((plan (page-render-plan "(~plan-page :data d)" (test-env) (list "fetch-data"))))
|
||||
(assert-true (contains? (get plan :io-deps) "fetch-data"))))
|
||||
|
||||
(deftest "pure-only page has empty server list"
|
||||
(let ((plan (page-render-plan "(~dep-auto-pure :x 1)" (test-env) (list "fetch-data"))))
|
||||
(assert-equal 0 (len (get plan :server)))
|
||||
(assert-true (> (len (get plan :client)) 0)))))
|
||||
212
web/tests/test-engine.sx
Normal file
212
web/tests/test-engine.sx
Normal file
@@ -0,0 +1,212 @@
|
||||
;; ==========================================================================
|
||||
;; test-engine.sx — Tests for SxEngine pure logic (engine.sx)
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions: parse-time, parse-trigger-spec, default-trigger,
|
||||
;; parse-swap-spec, parse-retry-spec, next-retry-ms, filter-params
|
||||
;; (loaded from bootstrapped output by test runners)
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. parse-time — time string parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-time"
|
||||
|
||||
(deftest "seconds to ms"
|
||||
(assert-equal 2000 (parse-time "2s")))
|
||||
|
||||
(deftest "milliseconds"
|
||||
(assert-equal 500 (parse-time "500ms")))
|
||||
|
||||
(deftest "nil returns 0"
|
||||
(assert-equal 0 (parse-time nil)))
|
||||
|
||||
(deftest "plain number string"
|
||||
(assert-equal 100 (parse-time "100")))
|
||||
|
||||
(deftest "one second"
|
||||
(assert-equal 1000 (parse-time "1s")))
|
||||
|
||||
(deftest "large seconds"
|
||||
(assert-equal 30000 (parse-time "30s"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. parse-trigger-spec — trigger attribute parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-trigger-spec"
|
||||
|
||||
(deftest "nil returns nil"
|
||||
(assert-nil (parse-trigger-spec nil)))
|
||||
|
||||
(deftest "single event"
|
||||
(let ((triggers (parse-trigger-spec "click")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "event with once modifier"
|
||||
(let ((triggers (parse-trigger-spec "click once")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))
|
||||
(assert-true (get (get (first triggers) "modifiers") "once"))))
|
||||
|
||||
(deftest "event with delay modifier"
|
||||
(let ((triggers (parse-trigger-spec "click delay:500ms")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal 500 (get (get (first triggers) "modifiers") "delay"))))
|
||||
|
||||
(deftest "multiple triggers comma-separated"
|
||||
(let ((triggers (parse-trigger-spec "click,change")))
|
||||
(assert-equal 2 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))
|
||||
(assert-equal "change" (get (nth triggers 1) "event"))))
|
||||
|
||||
(deftest "polling trigger"
|
||||
(let ((triggers (parse-trigger-spec "every 3s")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "every" (get (first triggers) "event"))
|
||||
(assert-equal 3000 (get (get (first triggers) "modifiers") "interval"))))
|
||||
|
||||
(deftest "event with from modifier"
|
||||
(let ((triggers (parse-trigger-spec "click from:body")))
|
||||
(assert-equal "body" (get (get (first triggers) "modifiers") "from"))))
|
||||
|
||||
(deftest "event with changed modifier"
|
||||
(let ((triggers (parse-trigger-spec "keyup changed")))
|
||||
(assert-equal "keyup" (get (first triggers) "event"))
|
||||
(assert-true (get (get (first triggers) "modifiers") "changed")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. default-trigger — default trigger by element tag
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "default-trigger"
|
||||
|
||||
(deftest "form submits"
|
||||
(let ((triggers (default-trigger "FORM")))
|
||||
(assert-equal "submit" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "input changes"
|
||||
(let ((triggers (default-trigger "INPUT")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "select changes"
|
||||
(let ((triggers (default-trigger "SELECT")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "textarea changes"
|
||||
(let ((triggers (default-trigger "TEXTAREA")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "div clicks"
|
||||
(let ((triggers (default-trigger "DIV")))
|
||||
(assert-equal "click" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "button clicks"
|
||||
(let ((triggers (default-trigger "BUTTON")))
|
||||
(assert-equal "click" (get (first triggers) "event")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. parse-swap-spec — swap specification parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-swap-spec"
|
||||
|
||||
(deftest "default swap"
|
||||
(let ((spec (parse-swap-spec nil false)))
|
||||
(assert-equal "outerHTML" (get spec "style"))
|
||||
(assert-false (get spec "transition"))))
|
||||
|
||||
(deftest "innerHTML"
|
||||
(let ((spec (parse-swap-spec "innerHTML" false)))
|
||||
(assert-equal "innerHTML" (get spec "style"))))
|
||||
|
||||
(deftest "with transition true"
|
||||
(let ((spec (parse-swap-spec "innerHTML transition:true" false)))
|
||||
(assert-equal "innerHTML" (get spec "style"))
|
||||
(assert-true (get spec "transition"))))
|
||||
|
||||
(deftest "transition false overrides global"
|
||||
(let ((spec (parse-swap-spec "outerHTML transition:false" true)))
|
||||
(assert-equal "outerHTML" (get spec "style"))
|
||||
(assert-false (get spec "transition"))))
|
||||
|
||||
(deftest "global transition when not overridden"
|
||||
(let ((spec (parse-swap-spec "innerHTML" true)))
|
||||
(assert-equal "innerHTML" (get spec "style"))
|
||||
(assert-true (get spec "transition")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. parse-retry-spec — retry specification parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-retry-spec"
|
||||
|
||||
(deftest "nil returns nil"
|
||||
(assert-nil (parse-retry-spec nil)))
|
||||
|
||||
(deftest "exponential backoff"
|
||||
(let ((spec (parse-retry-spec "exponential:1000:30000")))
|
||||
(assert-equal "exponential" (get spec "strategy"))
|
||||
(assert-equal 1000 (get spec "start-ms"))
|
||||
(assert-equal 30000 (get spec "cap-ms"))))
|
||||
|
||||
(deftest "linear strategy"
|
||||
(let ((spec (parse-retry-spec "linear:2000:60000")))
|
||||
(assert-equal "linear" (get spec "strategy"))
|
||||
(assert-equal 2000 (get spec "start-ms"))
|
||||
(assert-equal 60000 (get spec "cap-ms")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. next-retry-ms — exponential backoff calculation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "next-retry-ms"
|
||||
|
||||
(deftest "doubles current"
|
||||
(assert-equal 2000 (next-retry-ms 1000 30000)))
|
||||
|
||||
(deftest "caps at maximum"
|
||||
(assert-equal 30000 (next-retry-ms 20000 30000)))
|
||||
|
||||
(deftest "exact cap"
|
||||
(assert-equal 30000 (next-retry-ms 15000 30000)))
|
||||
|
||||
(deftest "small initial"
|
||||
(assert-equal 200 (next-retry-ms 100 30000))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. filter-params — form parameter filtering
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "filter-params"
|
||||
|
||||
(deftest "nil passes all through"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 2 (len (filter-params nil params)))))
|
||||
|
||||
(deftest "none returns empty"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 0 (len (filter-params "none" params)))))
|
||||
|
||||
(deftest "star passes all"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 2 (len (filter-params "*" params)))))
|
||||
|
||||
(deftest "whitelist"
|
||||
(let ((params (list (list "name" "Jo") (list "age" "30") (list "secret" "x"))))
|
||||
(let ((filtered (filter-params "name,age" params)))
|
||||
(assert-equal 2 (len filtered)))))
|
||||
|
||||
(deftest "blacklist with not"
|
||||
(let ((params (list (list "name" "Jo") (list "csrf" "tok") (list "age" "30"))))
|
||||
(let ((filtered (filter-params "not csrf" params)))
|
||||
(assert-equal 2 (len filtered))))))
|
||||
170
web/tests/test-orchestration.sx
Normal file
170
web/tests/test-orchestration.sx
Normal file
@@ -0,0 +1,170 @@
|
||||
;; ==========================================================================
|
||||
;; test-orchestration.sx — Tests for orchestration.sx Phase 7c + 7d
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions mocked by test runner:
|
||||
;; now-ms, log-info, log-warn, execute-action, try-rerender-page
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. page-data-cache — basic cache operations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "page-data-cache"
|
||||
|
||||
(deftest "cache-key bare page name"
|
||||
(assert-equal "my-page" (page-data-cache-key "my-page" nil)))
|
||||
|
||||
(deftest "cache-key with params"
|
||||
(let ((key (page-data-cache-key "my-page" {"id" "42"})))
|
||||
(assert-equal "my-page:id=42" key)))
|
||||
|
||||
(deftest "cache-set then get"
|
||||
(let ((key "test-cache-1"))
|
||||
(page-data-cache-set key {"items" (list 1 2 3)})
|
||||
(let ((result (page-data-cache-get key)))
|
||||
(assert-equal (list 1 2 3) (get result "items")))))
|
||||
|
||||
(deftest "cache miss returns nil"
|
||||
(assert-nil (page-data-cache-get "nonexistent-key"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. optimistic-cache-update — predicted mutation with snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-update"
|
||||
|
||||
(deftest "applies mutator to cached data"
|
||||
(let ((key "opt-test-1"))
|
||||
;; Seed the cache
|
||||
(page-data-cache-set key {"count" 10})
|
||||
;; Apply optimistic mutation
|
||||
(let ((predicted (optimistic-cache-update key
|
||||
(fn (data) (merge data {"count" 11})))))
|
||||
(assert-equal 11 (get predicted "count")))))
|
||||
|
||||
(deftest "updates cache with prediction"
|
||||
(let ((key "opt-test-2"))
|
||||
(page-data-cache-set key {"count" 5})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"count" 6})))
|
||||
;; Cache now has predicted value
|
||||
(let ((cached (page-data-cache-get key)))
|
||||
(assert-equal 6 (get cached "count")))))
|
||||
|
||||
(deftest "returns nil when no cached data"
|
||||
(let ((result (optimistic-cache-update "no-such-key"
|
||||
(fn (data) (merge data {"x" 1})))))
|
||||
(assert-nil result))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. optimistic-cache-revert — restore from snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-revert"
|
||||
|
||||
(deftest "reverts to original data"
|
||||
(let ((key "revert-test-1"))
|
||||
(page-data-cache-set key {"count" 10})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"count" 99})))
|
||||
;; Cache now has 99
|
||||
(assert-equal 99 (get (page-data-cache-get key) "count"))
|
||||
;; Revert
|
||||
(let ((restored (optimistic-cache-revert key)))
|
||||
(assert-equal 10 (get restored "count"))
|
||||
;; Cache is back to original
|
||||
(assert-equal 10 (get (page-data-cache-get key) "count")))))
|
||||
|
||||
(deftest "returns nil when no snapshot"
|
||||
(assert-nil (optimistic-cache-revert "never-mutated"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. optimistic-cache-confirm — discard snapshot
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "optimistic-cache-confirm"
|
||||
|
||||
(deftest "confirm clears snapshot"
|
||||
(let ((key "confirm-test-1"))
|
||||
(page-data-cache-set key {"val" "a"})
|
||||
(optimistic-cache-update key (fn (data) (merge data {"val" "b"})))
|
||||
;; Confirm — accepts the optimistic value
|
||||
(optimistic-cache-confirm key)
|
||||
;; Revert should now return nil (no snapshot)
|
||||
(assert-nil (optimistic-cache-revert key))
|
||||
;; Cache still has optimistic value
|
||||
(assert-equal "b" (get (page-data-cache-get key) "val")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. offline-is-online? / offline-set-online! — connectivity tracking
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-connectivity"
|
||||
|
||||
(deftest "initially online"
|
||||
(assert-true (offline-is-online?)))
|
||||
|
||||
(deftest "set offline"
|
||||
(offline-set-online! false)
|
||||
(assert-false (offline-is-online?)))
|
||||
|
||||
(deftest "set back online"
|
||||
(offline-set-online! true)
|
||||
(assert-true (offline-is-online?))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. offline-queue-mutation — queue entries when offline
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-queue-mutation"
|
||||
|
||||
(deftest "queues an entry"
|
||||
;; Seed cache so optimistic update works
|
||||
(let ((key (page-data-cache-key "notes" nil)))
|
||||
(page-data-cache-set key {"items" (list "a" "b")})
|
||||
(let ((entry (offline-queue-mutation "add-note"
|
||||
{"text" "c"}
|
||||
"notes" nil
|
||||
(fn (data) (merge data {"items" (list "a" "b" "c")})))))
|
||||
(assert-equal "add-note" (get entry "action"))
|
||||
(assert-equal "pending" (get entry "status")))))
|
||||
|
||||
(deftest "pending count increases"
|
||||
;; Previous test queued 1 entry; count should be >= 1
|
||||
(assert-true (> (offline-pending-count) 0))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. offline-aware-mutation — routes by connectivity
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "offline-aware-mutation"
|
||||
|
||||
(deftest "when online calls submit-mutation path"
|
||||
(offline-set-online! true)
|
||||
(let ((key (page-data-cache-key "test-page" nil)))
|
||||
(page-data-cache-set key {"v" 1})
|
||||
;; This will trigger execute-action (mocked) which calls success cb
|
||||
(let ((status nil))
|
||||
(offline-aware-mutation "test-page" nil "do-thing" {"x" 1}
|
||||
(fn (data) (merge data {"v" 2}))
|
||||
(fn (s) (set! status s)))
|
||||
;; Mock execute-action calls success immediately
|
||||
(assert-equal "confirmed" status))))
|
||||
|
||||
(deftest "when offline queues mutation"
|
||||
(offline-set-online! false)
|
||||
(let ((key (page-data-cache-key "test-page-2" nil)))
|
||||
(page-data-cache-set key {"v" 1})
|
||||
(let ((status nil))
|
||||
(offline-aware-mutation "test-page-2" nil "do-thing" {"x" 1}
|
||||
(fn (data) (merge data {"v" 2}))
|
||||
(fn (s) (set! status s)))
|
||||
(assert-equal "queued" status)))
|
||||
;; Clean up: go back online
|
||||
(offline-set-online! true)))
|
||||
708
web/tests/test-router.sx
Normal file
708
web/tests/test-router.sx
Normal file
@@ -0,0 +1,708 @@
|
||||
;; ==========================================================================
|
||||
;; test-router.sx — Tests for client-side route matching & SX URL algebra
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Modules tested: router.sx
|
||||
;;
|
||||
;; No additional platform functions needed — router.sx is pure.
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; split-path-segments
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "split-path-segments"
|
||||
(deftest "root path"
|
||||
(assert-equal (list) (split-path-segments "/")))
|
||||
|
||||
(deftest "single segment"
|
||||
(assert-equal (list "docs") (split-path-segments "/docs")))
|
||||
|
||||
(deftest "multiple segments"
|
||||
(assert-equal (list "docs" "hello") (split-path-segments "/docs/hello")))
|
||||
|
||||
(deftest "trailing slash stripped"
|
||||
(assert-equal (list "docs") (split-path-segments "/docs/")))
|
||||
|
||||
(deftest "deep path"
|
||||
(assert-equal (list "a" "b" "c" "d") (split-path-segments "/a/b/c/d"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; parse-route-pattern
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-route-pattern"
|
||||
(deftest "static pattern"
|
||||
(let ((segs (parse-route-pattern "/docs/intro")))
|
||||
(assert-length 2 segs)
|
||||
(assert-equal "literal" (get (first segs) "type"))
|
||||
(assert-equal "docs" (get (first segs) "value"))
|
||||
(assert-equal "literal" (get (nth segs 1) "type"))
|
||||
(assert-equal "intro" (get (nth segs 1) "value"))))
|
||||
|
||||
(deftest "pattern with param"
|
||||
(let ((segs (parse-route-pattern "/docs/<slug>")))
|
||||
(assert-length 2 segs)
|
||||
(assert-equal "literal" (get (first segs) "type"))
|
||||
(assert-equal "docs" (get (first segs) "value"))
|
||||
(assert-equal "param" (get (nth segs 1) "type"))
|
||||
(assert-equal "slug" (get (nth segs 1) "value"))))
|
||||
|
||||
(deftest "multiple params"
|
||||
(let ((segs (parse-route-pattern "/users/<uid>/posts/<pid>")))
|
||||
(assert-length 4 segs)
|
||||
(assert-equal "param" (get (nth segs 1) "type"))
|
||||
(assert-equal "uid" (get (nth segs 1) "value"))
|
||||
(assert-equal "param" (get (nth segs 3) "type"))
|
||||
(assert-equal "pid" (get (nth segs 3) "value"))))
|
||||
|
||||
(deftest "root pattern"
|
||||
(assert-equal (list) (parse-route-pattern "/"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; match-route
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "match-route"
|
||||
(deftest "exact match returns empty params"
|
||||
(let ((result (match-route "/docs/intro" "/docs/intro")))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-length 0 (keys result))))
|
||||
|
||||
(deftest "param match extracts value"
|
||||
(let ((result (match-route "/docs/hello" "/docs/<slug>")))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "hello" (get result "slug"))))
|
||||
|
||||
(deftest "no match returns nil"
|
||||
(assert-nil (match-route "/docs/hello" "/essays/<slug>"))
|
||||
(assert-nil (match-route "/docs" "/docs/<slug>")))
|
||||
|
||||
(deftest "segment count mismatch returns nil"
|
||||
(assert-nil (match-route "/a/b/c" "/a/<b>"))
|
||||
(assert-nil (match-route "/a" "/a/b")))
|
||||
|
||||
(deftest "root matches root"
|
||||
(let ((result (match-route "/" "/")))
|
||||
(assert-true (not (nil? result)))))
|
||||
|
||||
(deftest "multiple params extracted"
|
||||
(let ((result (match-route "/users/42/posts/99" "/users/<uid>/posts/<pid>")))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "42" (get result "uid"))
|
||||
(assert-equal "99" (get result "pid")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; find-matching-route
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "find-matching-route"
|
||||
(deftest "finds first matching route"
|
||||
(let ((routes (list
|
||||
{:pattern "/docs/" :parsed (parse-route-pattern "/docs/") :name "docs-index"}
|
||||
{:pattern "/docs/<slug>" :parsed (parse-route-pattern "/docs/<slug>") :name "docs-page"})))
|
||||
(let ((result (find-matching-route "/docs/hello" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "docs-page" (get result "name"))
|
||||
(assert-equal "hello" (get (get result "params") "slug")))))
|
||||
|
||||
(deftest "returns nil for no match"
|
||||
(let ((routes (list
|
||||
{:pattern "/docs/<slug>" :parsed (parse-route-pattern "/docs/<slug>") :name "docs-page"})))
|
||||
(assert-nil (find-matching-route "/essays/hello" routes))))
|
||||
|
||||
(deftest "matches exact routes before param routes"
|
||||
(let ((routes (list
|
||||
{:pattern "/docs/" :parsed (parse-route-pattern "/docs/") :name "docs-index"}
|
||||
{:pattern "/docs/<slug>" :parsed (parse-route-pattern "/docs/<slug>") :name "docs-page"})))
|
||||
(let ((result (find-matching-route "/docs/" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "docs-index" (get result "name")))))
|
||||
|
||||
(deftest "propagates stream flag from route"
|
||||
(let ((routes (list
|
||||
{:pattern "/demo/streaming"
|
||||
:parsed (parse-route-pattern "/demo/streaming")
|
||||
:name "streaming-demo"
|
||||
:stream true
|
||||
:has-data true})))
|
||||
(let ((result (find-matching-route "/demo/streaming" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal true (get result "stream"))
|
||||
(assert-equal true (get result "has-data")))))
|
||||
|
||||
(deftest "non-streaming route has no stream flag"
|
||||
(let ((routes (list
|
||||
{:pattern "/about"
|
||||
:parsed (parse-route-pattern "/about")
|
||||
:name "about"
|
||||
:has-data false})))
|
||||
(let ((result (find-matching-route "/about" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-nil (get result "stream"))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; sx-url-to-path — SX expression URL → old-style path
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "sx-url-to-path"
|
||||
(deftest "simple two-level"
|
||||
(assert-equal "/language/docs/introduction"
|
||||
(sx-url-to-path "/(language.(doc.introduction))")))
|
||||
|
||||
(deftest "deep nesting"
|
||||
(assert-equal "/geography/hypermedia/reference/attributes"
|
||||
(sx-url-to-path "/(geography.(hypermedia.(reference.attributes)))")))
|
||||
|
||||
(deftest "section index"
|
||||
(assert-equal "/language"
|
||||
(sx-url-to-path "/(language)")))
|
||||
|
||||
(deftest "function name mapping — doc to docs"
|
||||
(assert-equal "/language/docs/getting-started"
|
||||
(sx-url-to-path "/(language.(doc.getting-started))")))
|
||||
|
||||
(deftest "function name mapping — spec to specs"
|
||||
(assert-equal "/language/specs/core"
|
||||
(sx-url-to-path "/(language.(spec.core))")))
|
||||
|
||||
(deftest "function name mapping — example to examples"
|
||||
(assert-equal "/geography/hypermedia/examples/click-to-load"
|
||||
(sx-url-to-path "/(geography.(hypermedia.(example.click-to-load)))")))
|
||||
|
||||
(deftest "function name mapping — essay to essays"
|
||||
(assert-equal "/etc/essays/sx-sucks"
|
||||
(sx-url-to-path "/(etc.(essay.sx-sucks))")))
|
||||
|
||||
(deftest "function name mapping — plan to plans"
|
||||
(assert-equal "/etc/plans/spec-explorer"
|
||||
(sx-url-to-path "/(etc.(plan.spec-explorer))")))
|
||||
|
||||
(deftest "function name mapping — test to testing"
|
||||
(assert-equal "/language/testing/eval"
|
||||
(sx-url-to-path "/(language.(test.eval))")))
|
||||
|
||||
(deftest "function name mapping — bootstrapper to bootstrappers"
|
||||
(assert-equal "/language/bootstrappers/python"
|
||||
(sx-url-to-path "/(language.(bootstrapper.python))")))
|
||||
|
||||
(deftest "function name mapping — protocol to protocols"
|
||||
(assert-equal "/applications/protocols/wire-format"
|
||||
(sx-url-to-path "/(applications.(protocol.wire-format))")))
|
||||
|
||||
(deftest "function name mapping — reference-detail to reference"
|
||||
(assert-equal "/geography/hypermedia/reference/attributes"
|
||||
(sx-url-to-path "/(geography.(hypermedia.(reference-detail.attributes)))")))
|
||||
|
||||
(deftest "non-SX URL returns nil"
|
||||
(assert-nil (sx-url-to-path "/language/docs/introduction"))
|
||||
(assert-nil (sx-url-to-path "https://example.com"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; find-matching-route with SX URLs
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "find-matching-route-sx-urls"
|
||||
(deftest "SX URL auto-converts for matching"
|
||||
(let ((routes (list
|
||||
{:pattern "/language/docs/<slug>"
|
||||
:parsed (parse-route-pattern "/language/docs/<slug>")
|
||||
:name "docs-page"})))
|
||||
(let ((result (find-matching-route "/(language.(doc.introduction))" routes)))
|
||||
(assert-true (not (nil? result)))
|
||||
(assert-equal "docs-page" (get result "name"))
|
||||
(assert-equal "introduction" (get (get result "params") "slug"))))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; SX URL Resolution — Structural Navigation
|
||||
;; ==========================================================================
|
||||
|
||||
(defsuite "relative-sx-url?"
|
||||
(deftest "paren-form relative"
|
||||
(assert-true (relative-sx-url? "(.slug)"))
|
||||
(assert-true (relative-sx-url? "(..)"))
|
||||
(assert-true (relative-sx-url? "(..reactive.demo)")))
|
||||
|
||||
(deftest "bare-dot relative"
|
||||
(assert-true (relative-sx-url? ".slug"))
|
||||
(assert-true (relative-sx-url? ".."))
|
||||
(assert-true (relative-sx-url? "..."))
|
||||
(assert-true (relative-sx-url? ".:page.4")))
|
||||
|
||||
(deftest "absolute URLs are not relative"
|
||||
(assert-false (relative-sx-url? "/(language.(doc.intro))"))
|
||||
(assert-false (relative-sx-url? "/"))
|
||||
(assert-false (relative-sx-url? "/language/docs/intro")))
|
||||
|
||||
(deftest "special form URLs are not relative"
|
||||
(assert-false (relative-sx-url? "/(!source.(~essay))"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: append at current level (1 dot)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: append (.slug)"
|
||||
(deftest "append to deep URL"
|
||||
(assert-equal "/(geography.(hypermedia.(example.progress-bar)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(.progress-bar)")))
|
||||
|
||||
(deftest "append to single-level URL"
|
||||
(assert-equal "/(language.intro)"
|
||||
(resolve-relative-url "/(language)" "(.intro)")))
|
||||
|
||||
(deftest "append with multi-token body"
|
||||
(assert-equal "/(geography.(hypermedia.(example.progress-bar.v2)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(.progress-bar.v2)")))
|
||||
|
||||
(deftest "bare-dot shorthand"
|
||||
(assert-equal "/(geography.(hypermedia.(example.progress-bar)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
".progress-bar"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: go up one level (2 dots)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: up one (..slug)"
|
||||
(deftest "sibling call"
|
||||
(assert-equal "/(geography.(hypermedia.(reactive.demo)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(..reactive.demo)")))
|
||||
|
||||
(deftest "just go up — no new content"
|
||||
(assert-equal "/(geography.(hypermedia))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(..)")))
|
||||
|
||||
(deftest "bare-dot shorthand for up"
|
||||
(assert-equal "/(geography.(hypermedia))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"..")))
|
||||
|
||||
(deftest "up from two-level URL"
|
||||
(assert-equal "/(language)"
|
||||
(resolve-relative-url "/(language.(doc))" "(..)")))
|
||||
|
||||
(deftest "up from single-level pops to root"
|
||||
(assert-equal "/"
|
||||
(resolve-relative-url "/(language)" "(..)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: go up two levels (3 dots)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: up two (...slug)"
|
||||
(deftest "up two and push"
|
||||
(assert-equal "/(geography.(marshes))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(...marshes)")))
|
||||
|
||||
(deftest "just up two — no content"
|
||||
(assert-equal "/(geography)"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"(...)")))
|
||||
|
||||
(deftest "bare-dot shorthand for up two"
|
||||
(assert-equal "/(geography)"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example)))"
|
||||
"...")))
|
||||
|
||||
(deftest "up two from two-level pops to root"
|
||||
(assert-equal "/"
|
||||
(resolve-relative-url "/(language.(doc))" "(...)")))
|
||||
|
||||
(deftest "up two and push from deep URL"
|
||||
;; 4-level URL, ... = 3 dots = pop 2 levels → at hypermedia level
|
||||
(assert-equal "/(geography.(hypermedia.(reactive.demo)))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(reference.(attributes))))"
|
||||
"(...reactive.demo)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: up N levels (N+1 dots)
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: up N"
|
||||
(deftest "up three levels (4 dots) from 4-level URL"
|
||||
;; 4-level URL, .... = 4 dots = pop 3 levels → at geography level
|
||||
(assert-equal "/(geography)"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(reference.(attributes))))"
|
||||
"(....)")))
|
||||
|
||||
(deftest "up three and push from 4-level URL"
|
||||
;; 4 dots = pop 3 → at geography, then push new-section
|
||||
(assert-equal "/(geography.(new-section))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(reference.(attributes))))"
|
||||
"(....new-section)")))
|
||||
|
||||
(deftest "up four levels (5 dots) pops to root"
|
||||
(assert-equal "/"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(reference.(attributes))))"
|
||||
"(.....)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Structural: current (1 dot, no body) = no-op
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: current level no-op"
|
||||
(deftest "dot with no body is identity"
|
||||
;; (.): dots=1, body="" → no positional, no keywords → current unchanged
|
||||
(assert-equal "/(language.(doc.intro))"
|
||||
(resolve-relative-url "/(language.(doc.intro))" "(.)")))
|
||||
|
||||
(deftest "bare dot shorthand"
|
||||
(assert-equal "/(language.(doc.intro))"
|
||||
(resolve-relative-url "/(language.(doc.intro))" "."))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; SX URL Resolution — Keyword Operations
|
||||
;; ==========================================================================
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Keyword set: absolute value
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: keyword set"
|
||||
(deftest "set keyword on URL without keywords"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals)))"
|
||||
"(.:page.4)")))
|
||||
|
||||
(deftest "replace existing keyword"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.4)")))
|
||||
|
||||
(deftest "set keyword with bare-dot shorthand"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
".:page.4")))
|
||||
|
||||
(deftest "set keyword on single-level URL"
|
||||
(assert-equal "/(language.:page.1)"
|
||||
(resolve-relative-url "/(language)" "(.:page.1)")))
|
||||
|
||||
(deftest "set multiple keywords"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4.:section.batch)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.4.:section.batch)")))
|
||||
|
||||
(deftest "add new keyword preserving existing"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.3.:section.batch)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:section.batch)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Keyword delta: +N / -N
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: keyword delta"
|
||||
(deftest "increment by 1"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.+1)")))
|
||||
|
||||
(deftest "decrement by 1"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.2)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.-1)")))
|
||||
|
||||
(deftest "increment by larger amount"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.13)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(.:page.+10)")))
|
||||
|
||||
(deftest "delta with bare-dot shorthand"
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.4)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
".:page.+1")))
|
||||
|
||||
(deftest "delta on missing keyword uses literal"
|
||||
;; If :page doesn't exist, +1 is used as-is (not numeric delta)
|
||||
(assert-equal "/(language.(spec.(explore.signals.:page.+1)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals)))"
|
||||
"(.:page.+1)"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Composed: structural + keyword
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "resolve-relative-url: composed structural + keyword"
|
||||
(deftest "append slug + set keyword"
|
||||
(assert-equal "/(language.(spec.(explore.signals.batch.:page.1)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals)))"
|
||||
"(.batch.:page.1)")))
|
||||
|
||||
(deftest "sibling + set keyword"
|
||||
(assert-equal "/(language.(spec.(eval.:page.1)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"(..eval.:page.1)")))
|
||||
|
||||
(deftest "up two + set keyword"
|
||||
(assert-equal "/(geography.(reactive.demo.:page.1))"
|
||||
(resolve-relative-url
|
||||
"/(geography.(hypermedia.(example.progress-bar)))"
|
||||
"(...reactive.demo.:page.1)")))
|
||||
|
||||
(deftest "bare-dot composed"
|
||||
(assert-equal "/(language.(spec.(eval.:page.1)))"
|
||||
(resolve-relative-url
|
||||
"/(language.(spec.(explore.signals.:page.3)))"
|
||||
"..eval.:page.1"))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; SX URL Parsing — parse-sx-url
|
||||
;; ==========================================================================
|
||||
|
||||
(defsuite "parse-sx-url"
|
||||
(deftest "home URL"
|
||||
(let ((parsed (parse-sx-url "/")))
|
||||
(assert-equal "home" (get parsed "type"))
|
||||
(assert-equal "/" (get parsed "raw"))))
|
||||
|
||||
(deftest "absolute SX URL"
|
||||
(let ((parsed (parse-sx-url "/(language.(doc.intro))")))
|
||||
(assert-equal "absolute" (get parsed "type"))))
|
||||
|
||||
(deftest "relative paren-form"
|
||||
(let ((parsed (parse-sx-url "(.slug)")))
|
||||
(assert-equal "relative" (get parsed "type"))))
|
||||
|
||||
(deftest "relative bare-dot"
|
||||
(let ((parsed (parse-sx-url ".slug")))
|
||||
(assert-equal "relative" (get parsed "type"))))
|
||||
|
||||
(deftest "relative double-dot"
|
||||
(let ((parsed (parse-sx-url "..")))
|
||||
(assert-equal "relative" (get parsed "type"))))
|
||||
|
||||
(deftest "direct component"
|
||||
(let ((parsed (parse-sx-url "/(~essay-sx-sucks)")))
|
||||
(assert-equal "direct-component" (get parsed "type"))
|
||||
(assert-equal "~essay-sx-sucks" (get parsed "name"))))
|
||||
|
||||
(deftest "old-style path"
|
||||
(let ((parsed (parse-sx-url "/language/docs/intro")))
|
||||
(assert-equal "path" (get parsed "type")))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; URL Special Forms (! prefix)
|
||||
;; ==========================================================================
|
||||
|
||||
(defsuite "url-special-form?"
|
||||
(deftest "known special forms"
|
||||
(assert-true (url-special-form? "!source"))
|
||||
(assert-true (url-special-form? "!inspect"))
|
||||
(assert-true (url-special-form? "!diff"))
|
||||
(assert-true (url-special-form? "!search"))
|
||||
(assert-true (url-special-form? "!raw"))
|
||||
(assert-true (url-special-form? "!json")))
|
||||
|
||||
(deftest "unknown bang-prefix is not a special form"
|
||||
(assert-false (url-special-form? "!unknown"))
|
||||
(assert-false (url-special-form? "!foo")))
|
||||
|
||||
(deftest "non-bang names are not special forms"
|
||||
(assert-false (url-special-form? "source"))
|
||||
(assert-false (url-special-form? "language"))
|
||||
(assert-false (url-special-form? "~essay"))))
|
||||
|
||||
|
||||
(defsuite "parse-sx-url: special forms"
|
||||
(deftest "source special form"
|
||||
(let ((parsed (parse-sx-url "/(!source.(~essay-sx-sucks))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!source" (get parsed "form"))
|
||||
(assert-equal "(~essay-sx-sucks)" (get parsed "inner"))))
|
||||
|
||||
(deftest "inspect special form"
|
||||
(let ((parsed (parse-sx-url "/(!inspect.(language.(doc.primitives)))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!inspect" (get parsed "form"))
|
||||
(assert-equal "(language.(doc.primitives))" (get parsed "inner"))))
|
||||
|
||||
(deftest "diff special form with two args"
|
||||
(let ((parsed (parse-sx-url "/(!diff.(language.(spec.signals)).(language.(spec.eval)))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!diff" (get parsed "form"))
|
||||
(assert-equal "(language.(spec.signals)).(language.(spec.eval))" (get parsed "inner"))))
|
||||
|
||||
(deftest "raw special form"
|
||||
(let ((parsed (parse-sx-url "/(!raw.(~some-component))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!raw" (get parsed "form"))
|
||||
(assert-equal "(~some-component)" (get parsed "inner"))))
|
||||
|
||||
(deftest "json special form"
|
||||
(let ((parsed (parse-sx-url "/(!json.(language.(doc.primitives)))")))
|
||||
(assert-equal "special-form" (get parsed "type"))
|
||||
(assert-equal "!json" (get parsed "form"))
|
||||
(assert-equal "(language.(doc.primitives))" (get parsed "inner")))))
|
||||
|
||||
|
||||
(defsuite "url-special-form-name"
|
||||
(deftest "extracts form name"
|
||||
(assert-equal "!source"
|
||||
(url-special-form-name "/(!source.(~essay))")))
|
||||
|
||||
(deftest "returns nil for non-special-form"
|
||||
(assert-nil (url-special-form-name "/(language.(doc.intro))"))
|
||||
(assert-nil (url-special-form-name "/"))
|
||||
(assert-nil (url-special-form-name "(.slug)"))))
|
||||
|
||||
|
||||
(defsuite "url-special-form-inner"
|
||||
(deftest "extracts inner expression"
|
||||
(assert-equal "(~essay)"
|
||||
(url-special-form-inner "/(!source.(~essay))")))
|
||||
|
||||
(deftest "extracts multi-arg inner"
|
||||
(assert-equal "(a).(b)"
|
||||
(url-special-form-inner "/(!diff.(a).(b))")))
|
||||
|
||||
(deftest "returns nil for non-special-form"
|
||||
(assert-nil (url-special-form-inner "/(language.(doc.intro))"))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
;; Internal helpers — additional edge cases
|
||||
;; ==========================================================================
|
||||
|
||||
(defsuite "internal: _pop-sx-url-level"
|
||||
(deftest "pop three-level"
|
||||
(assert-equal "/(a.(b))"
|
||||
(_pop-sx-url-level "/(a.(b.(c)))")))
|
||||
|
||||
(deftest "pop two-level"
|
||||
(assert-equal "/(a)"
|
||||
(_pop-sx-url-level "/(a.(b))")))
|
||||
|
||||
(deftest "pop single-level to root"
|
||||
(assert-equal "/"
|
||||
(_pop-sx-url-level "/(a)")))
|
||||
|
||||
(deftest "pop root stays root"
|
||||
(assert-equal "/"
|
||||
(_pop-sx-url-level "/"))))
|
||||
|
||||
(defsuite "internal: _extract-innermost"
|
||||
(deftest "single-level URL"
|
||||
(let ((parts (_extract-innermost "/(language)")))
|
||||
(assert-equal "/(" (get parts "before"))
|
||||
(assert-equal "language" (get parts "content"))
|
||||
(assert-equal ")" (get parts "suffix"))))
|
||||
|
||||
(deftest "two-level URL"
|
||||
(let ((parts (_extract-innermost "/(language.(doc.intro))")))
|
||||
(assert-equal "/(language.(" (get parts "before"))
|
||||
(assert-equal "doc.intro" (get parts "content"))
|
||||
(assert-equal "))" (get parts "suffix"))))
|
||||
|
||||
(deftest "three-level URL with keywords"
|
||||
(let ((parts (_extract-innermost "/(a.(b.(c.d.:page.3)))")))
|
||||
(assert-equal "/(a.(b.(" (get parts "before"))
|
||||
(assert-equal "c.d.:page.3" (get parts "content"))
|
||||
(assert-equal ")))" (get parts "suffix")))))
|
||||
|
||||
(defsuite "internal: _find-keyword-value"
|
||||
(deftest "finds keyword"
|
||||
(assert-equal "3"
|
||||
(_find-keyword-value "explore.signals.:page.3" ":page")))
|
||||
|
||||
(deftest "returns nil when not found"
|
||||
(assert-nil (_find-keyword-value "explore.signals" ":page")))
|
||||
|
||||
(deftest "finds among multiple keywords"
|
||||
(assert-equal "batch"
|
||||
(_find-keyword-value "explore.signals.:page.3.:section.batch" ":section"))))
|
||||
|
||||
(defsuite "internal: _set-keyword-in-content"
|
||||
(deftest "replace existing"
|
||||
(assert-equal "a.b.:page.4"
|
||||
(_set-keyword-in-content "a.b.:page.3" ":page" "4")))
|
||||
|
||||
(deftest "append when missing"
|
||||
(assert-equal "a.b.:page.1"
|
||||
(_set-keyword-in-content "a.b" ":page" "1")))
|
||||
|
||||
(deftest "replace with multiple keywords present"
|
||||
(assert-equal "a.:page.4.:section.batch"
|
||||
(_set-keyword-in-content "a.:page.3.:section.batch" ":page" "4"))))
|
||||
|
||||
(defsuite "internal: _is-delta-value?"
|
||||
(deftest "positive delta"
|
||||
(assert-true (_is-delta-value? "+1"))
|
||||
(assert-true (_is-delta-value? "+10")))
|
||||
|
||||
(deftest "negative delta"
|
||||
(assert-true (_is-delta-value? "-1"))
|
||||
(assert-true (_is-delta-value? "-10")))
|
||||
|
||||
(deftest "bare minus is not delta"
|
||||
(assert-false (_is-delta-value? "-")))
|
||||
|
||||
(deftest "bare plus is not delta"
|
||||
(assert-false (_is-delta-value? "+")))
|
||||
|
||||
(deftest "plain number is not delta"
|
||||
(assert-false (_is-delta-value? "3"))
|
||||
(assert-false (_is-delta-value? "0")))
|
||||
|
||||
(deftest "empty string is not delta"
|
||||
(assert-false (_is-delta-value? ""))))
|
||||
|
||||
(defsuite "internal: _apply-delta"
|
||||
(deftest "increment"
|
||||
(assert-equal "4" (_apply-delta "3" "+1")))
|
||||
|
||||
(deftest "decrement"
|
||||
(assert-equal "2" (_apply-delta "3" "-1")))
|
||||
|
||||
(deftest "large increment"
|
||||
(assert-equal "13" (_apply-delta "3" "+10")))
|
||||
|
||||
(deftest "non-numeric current falls back"
|
||||
(assert-equal "+1" (_apply-delta "abc" "+1"))))
|
||||
216
web/tests/test-signals.sx
Normal file
216
web/tests/test-signals.sx
Normal file
@@ -0,0 +1,216 @@
|
||||
;; ==========================================================================
|
||||
;; 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"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Scope integration — reactive tracking uses scope-push!/scope-pop!
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scope integration"
|
||||
(deftest "deref outside reactive scope does not subscribe"
|
||||
(let ((s (signal 42)))
|
||||
;; Reading outside any reactive context should not add subscribers
|
||||
(assert-equal 42 (deref s))
|
||||
(assert-equal 0 (len (signal-subscribers s)))))
|
||||
|
||||
(deftest "computed uses scope for tracking"
|
||||
(let ((a (signal 1))
|
||||
(b (signal 2))
|
||||
(sum (computed (fn () (+ (deref a) (deref b))))))
|
||||
;; Each signal should have exactly 1 subscriber (the computed's recompute)
|
||||
(assert-equal 1 (len (signal-subscribers a)))
|
||||
(assert-equal 1 (len (signal-subscribers b)))
|
||||
;; Verify computed value
|
||||
(assert-equal 3 (deref sum))))
|
||||
|
||||
(deftest "nested effects with overlapping deps use scope correctly"
|
||||
(let ((shared (signal 0))
|
||||
(inner-only (signal 0))
|
||||
(outer-count (signal 0))
|
||||
(inner-count (signal 0)))
|
||||
;; Outer effect tracks shared
|
||||
(effect (fn () (do (deref shared) (swap! outer-count inc))))
|
||||
;; Inner effect tracks shared AND inner-only
|
||||
(effect (fn () (do (deref shared) (deref inner-only) (swap! inner-count inc))))
|
||||
;; Both ran once
|
||||
(assert-equal 1 (deref outer-count))
|
||||
(assert-equal 1 (deref inner-count))
|
||||
;; Changing shared triggers both
|
||||
(reset! shared 1)
|
||||
(assert-equal 2 (deref outer-count))
|
||||
(assert-equal 2 (deref inner-count))
|
||||
;; Changing inner-only triggers only inner
|
||||
(reset! inner-only 1)
|
||||
(assert-equal 2 (deref outer-count))
|
||||
(assert-equal 3 (deref inner-count)))))
|
||||
Reference in New Issue
Block a user