diff --git a/web/harness-reactive.sx b/web/harness-reactive.sx new file mode 100644 index 00000000..0a934912 --- /dev/null +++ b/web/harness-reactive.sx @@ -0,0 +1,115 @@ +;; ========================================================================== +;; web/harness-reactive.sx — Signal and reactive testing extensions +;; +;; Extends spec/harness.sx with assertions for the reactive signal system. +;; Depends on spec/signals.sx (core reactive primitives). +;; No DOM dependency — works on any host. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Signal assertions +;; -------------------------------------------------------------------------- + +;; Assert a signal has a specific value +(define assert-signal-value :effects [] + (fn ((sig :as any) expected) + (let ((actual (signal-value sig))) + (assert= actual expected + (str "Expected signal value " expected ", got " actual))))) + +;; Assert a signal has subscribers (i.e., something is watching it) +(define assert-signal-has-subscribers :effects [] + (fn ((sig :as any)) + (assert (> (len (signal-subscribers sig)) 0) + "Expected signal to have subscribers"))) + +;; Assert a signal has no subscribers +(define assert-signal-no-subscribers :effects [] + (fn ((sig :as any)) + (assert (= (len (signal-subscribers sig)) 0) + "Expected signal to have no subscribers"))) + +;; Assert a signal has exactly N subscribers +(define assert-signal-subscriber-count :effects [] + (fn ((sig :as any) (n :as number)) + (let ((actual (len (signal-subscribers sig)))) + (assert= actual n + (str "Expected " n " subscribers, got " actual))))) + + +;; -------------------------------------------------------------------------- +;; Signal simulation +;; -------------------------------------------------------------------------- + +;; Set a signal value directly (like a user action would) +(define simulate-signal-set! :effects [mutation] + (fn ((sig :as any) value) + (reset! sig value))) + +;; Swap a signal value (like a button click handler would) +(define simulate-signal-swap! :effects [mutation] + (fn ((sig :as any) (f :as lambda) &rest args) + (apply swap! (cons sig (cons f args))))) + + +;; -------------------------------------------------------------------------- +;; Computed assertions +;; -------------------------------------------------------------------------- + +;; Assert a computed signal tracks the expected number of dependencies +(define assert-computed-dep-count :effects [] + (fn ((sig :as any) (n :as number)) + (let ((actual (len (signal-deps sig)))) + (assert= actual n + (str "Expected " n " deps, got " actual))))) + +;; Assert a computed signal depends on a specific signal +(define assert-computed-depends-on :effects [] + (fn ((computed-sig :as any) (dep-sig :as any)) + (assert (contains? (signal-deps computed-sig) dep-sig) + "Expected computed to depend on the given signal"))) + + +;; -------------------------------------------------------------------------- +;; Effect tracking +;; -------------------------------------------------------------------------- + +;; Run a function and count how many times an effect fires +(define count-effect-runs :effects [mutation] + (fn ((thunk :as lambda)) + (let ((count (signal 0))) + (effect (fn () (deref count))) ;; subscribe to count changes + (let ((run-count 0) + (tracker (effect (fn () + (set! run-count (+ run-count 1)) + (cek-call thunk nil))))) + run-count)))) + +;; Create a signal + effect pair for testing, returns dict with :signal, :history +(define make-test-signal :effects [mutation] + (fn (initial-value) + (let ((sig (signal initial-value)) + (history (list))) + (effect (fn () + (append! history (deref sig)))) + {:signal sig :history history}))) + + +;; -------------------------------------------------------------------------- +;; Batch assertions +;; -------------------------------------------------------------------------- + +;; Assert that a batch of signal writes only triggers N subscriber notifications +(define assert-batch-coalesces :effects [mutation] + (fn ((thunk :as lambda) (expected-notify-count :as number)) + (let ((notify-count 0) + (sig (signal 0))) + (effect (fn () + (deref sig) + (set! notify-count (+ notify-count 1)))) + ;; Initial effect run counts as 1 + (set! notify-count 0) + (batch thunk) + (assert= notify-count expected-notify-count + (str "Expected " expected-notify-count " notifications, got " notify-count))))) diff --git a/web/harness-web.sx b/web/harness-web.sx new file mode 100644 index 00000000..52d90194 --- /dev/null +++ b/web/harness-web.sx @@ -0,0 +1,154 @@ +;; ========================================================================== +;; web/harness-web.sx — Web platform testing extensions +;; +;; Extends spec/harness.sx with DOM mocking, event simulation, and +;; web-specific assertions. Depends on web/signals.sx for reactive features. +;; +;; Mock DOM: lightweight element stubs that record operations. +;; No real browser needed — runs on any host. +;; ========================================================================== + + +;; -------------------------------------------------------------------------- +;; Mock DOM elements +;; -------------------------------------------------------------------------- + +;; Create a mock element with tag name, attrs dict, children list, and event log +(define mock-element :effects [] + (fn ((tag :as string) &key class id) + {:tag tag + :attrs (merge {} (if class {:class class} {}) (if id {:id id} {})) + :children (list) + :text "" + :event-log (list) + :listeners {}})) + +;; Set text content on mock element +(define mock-set-text! :effects [mutation] + (fn (el (text :as string)) + (dict-set! el "text" text))) + +;; Append child to mock element +(define mock-append-child! :effects [mutation] + (fn (parent child) + (append! (get parent "children") child))) + +;; Set attribute on mock element +(define mock-set-attr! :effects [mutation] + (fn (el (name :as string) value) + (dict-set! (get el "attrs") name value))) + +;; Get attribute from mock element +(define mock-get-attr :effects [] + (fn (el (name :as string)) + (get (get el "attrs") name))) + +;; Add event listener to mock element +(define mock-add-listener! :effects [mutation] + (fn (el (event-name :as string) (handler :as lambda)) + (let ((listeners (get el "listeners"))) + (when (not (has-key? listeners event-name)) + (dict-set! listeners event-name (list))) + (append! (get listeners event-name) handler)))) + + +;; -------------------------------------------------------------------------- +;; Event simulation +;; -------------------------------------------------------------------------- + +;; Simulate a click event on a mock element +(define simulate-click :effects [mutation] + (fn (el) + (let ((handlers (get (get el "listeners") "click"))) + (when handlers + (for-each (fn (h) (cek-call h (list {:type "click" :target el}))) + handlers)) + (append! (get el "event-log") {:type "click"})))) + +;; Simulate an input event with a value +(define simulate-input :effects [mutation] + (fn (el (value :as string)) + (mock-set-attr! el "value" value) + (let ((handlers (get (get el "listeners") "input"))) + (when handlers + (for-each (fn (h) (cek-call h (list {:type "input" :target el}))) + handlers)) + (append! (get el "event-log") {:type "input" :value value})))) + +;; Simulate a custom event (for lake→island bridge) +(define simulate-event :effects [mutation] + (fn (el (event-name :as string) detail) + (let ((handlers (get (get el "listeners") event-name))) + (when handlers + (for-each (fn (h) (cek-call h (list {:type event-name :detail detail :target el}))) + handlers)) + (append! (get el "event-log") {:type event-name :detail detail})))) + + +;; -------------------------------------------------------------------------- +;; DOM assertions +;; -------------------------------------------------------------------------- + +;; Assert mock element has specific text content +(define assert-text :effects [] + (fn (el (expected :as string)) + (let ((actual (get el "text"))) + (assert= actual expected + (str "Expected text \"" expected "\", got \"" actual "\""))))) + +;; Assert mock element has an attribute with expected value +(define assert-attr :effects [] + (fn (el (name :as string) expected) + (let ((actual (mock-get-attr el name))) + (assert= actual expected + (str "Expected attr " name "=\"" expected "\", got \"" actual "\""))))) + +;; Assert mock element has a CSS class +(define assert-class :effects [] + (fn (el (class-name :as string)) + (let ((classes (or (mock-get-attr el "class") ""))) + (assert (contains? (split classes " ") class-name) + (str "Expected class \"" class-name "\" in \"" classes "\""))))) + +;; Assert mock element does NOT have a CSS class +(define assert-no-class :effects [] + (fn (el (class-name :as string)) + (let ((classes (or (mock-get-attr el "class") ""))) + (assert (not (contains? (split classes " ") class-name)) + (str "Expected no class \"" class-name "\" but found in \"" classes "\""))))) + +;; Assert mock element has N children +(define assert-child-count :effects [] + (fn (el (n :as number)) + (let ((actual (len (get el "children")))) + (assert= actual n + (str "Expected " n " children, got " actual))))) + +;; Assert an event was fired on mock element +(define assert-event-fired :effects [] + (fn (el (event-name :as string)) + (assert (some (fn (e) (= (get e "type") event-name)) (get el "event-log")) + (str "Expected event \"" event-name "\" to have been fired")))) + +;; Assert an event was NOT fired on mock element +(define assert-no-event :effects [] + (fn (el (event-name :as string)) + (assert (not (some (fn (e) (= (get e "type") event-name)) (get el "event-log"))) + (str "Expected event \"" event-name "\" to NOT have been fired")))) + +;; Count how many times an event was fired +(define event-fire-count :effects [] + (fn (el (event-name :as string)) + (len (filter (fn (e) (= (get e "type") event-name)) (get el "event-log"))))) + + +;; -------------------------------------------------------------------------- +;; Web harness constructor — extends make-harness with DOM mock state +;; -------------------------------------------------------------------------- + +(define make-web-harness :effects [] + (fn (&key platform) + (let ((h (make-harness :platform platform))) + (harness-set! h "dom" {:root (mock-element "div" :id "root") + :elements {}}) + h)))