Add test harness extensions: reactive signals + web/DOM mocking

web/harness-reactive.sx — signal testing (no DOM dependency):
  assert-signal-value, assert-signal-has-subscribers,
  assert-signal-subscriber-count, assert-computed-dep-count,
  assert-computed-depends-on, simulate-signal-set!/swap!,
  make-test-signal (signal + history tracking), assert-batch-coalesces

web/harness-web.sx — web platform testing (mock DOM, no browser):
  mock-element, mock-set-text!, mock-append-child!, mock-set-attr!,
  mock-add-listener!, simulate-click, simulate-input, simulate-event,
  assert-text, assert-attr, assert-class, assert-no-class,
  assert-child-count, assert-event-fired, assert-no-event,
  event-fire-count, make-web-harness

Both extend spec/harness.sx. The reactive harness uses spec/signals.sx
directly — works on any host. The web harness provides lightweight DOM
stubs that record operations for assertion, no real browser needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 00:47:50 +00:00
parent b104663481
commit 4fa0850c01
2 changed files with 269 additions and 0 deletions

115
web/harness-reactive.sx Normal file
View File

@@ -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)))))

154
web/harness-web.sx Normal file
View File

@@ -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)))