HS: intersection observer mock + on intersection (+3 tests)

Applied from worktree-agent-ad6e17cbc4ea0c94b (commit 0a0fe314)
with manual re-apply onto post-cluster-26 HEAD:

- Parser: parse-on-feat collects `having margin X threshold Y`
  clauses between `from X` and the body; packs them into a
  `:having {"margin" M "threshold" T}` dict on the parts list.
- Compiler: scan-on threads a new `having-info` parameter through
  all recursions; when event-name is "intersection", wraps the
  hs-on call with `(do on-call (hs-on-intersection-attach! target
  margin threshold))`.
- Runtime: hs-on-intersection-attach! constructs an
  IntersectionObserver with {rootMargin, threshold} options and a
  callback that dispatches an "intersection" DOM event carrying
  {intersecting, entry} detail.
- Runner: HsIntersectionObserver mock fires the callback
  synchronously on observe() with isIntersecting=true so handlers
  run during activation; ignores margin/threshold (tests assert
  only that the handler fires).

Suite hs-upstream-on: 33/70 -> 36/70 (on intersection: 0/3 -> 3/3).
Smoke 0-195 unchanged at 165/195.
This commit is contained in:
2026-04-24 10:44:01 +00:00
parent cee9ae7f22
commit 0c31dd2735
7 changed files with 350 additions and 213 deletions

View File

@@ -66,14 +66,30 @@
;; Wait for a DOM event on a target.
;; (hs-wait-for target event-name) — suspends until event fires
(define hs-init (fn (thunk) (thunk)))
(define
hs-on-intersection-attach!
(fn
(target margin threshold)
(let
((opts (dict)))
(when margin (dict-set! opts "rootMargin" margin))
(when threshold (dict-set! opts "threshold" threshold))
(let
((cb (fn (entries observer) (let ((entry (if (> (len entries) 0) (nth entries 0) nil))) (when entry (let ((intersecting (host-get entry "isIntersecting"))) (dom-dispatch target "intersection" (dict "intersecting" intersecting "entry" entry))))))))
(let
((observer (host-new "IntersectionObserver" cb opts)))
(host-call observer "observe" target)
observer)))))
;; Wait for CSS transitions/animations to settle on an element.
(define hs-wait (fn (ms) (perform (list (quote io-sleep) ms))))
(define hs-init (fn (thunk) (thunk)))
;; ── Class manipulation ──────────────────────────────────────────
;; Toggle a single class on an element.
(define hs-wait (fn (ms) (perform (list (quote io-sleep) ms))))
;; Toggle between two classes — exactly one is active at a time.
(begin
(define
hs-wait-for
@@ -86,19 +102,21 @@
(target event-name timeout-ms)
(perform (list (quote io-wait-event) target event-name timeout-ms)))))
;; Toggle between two classes — exactly one is active at a time.
(define hs-settle (fn (target) (perform (list (quote io-settle) target))))
;; Take a class from siblings — add to target, remove from others.
;; (hs-take! target cls) — like radio button class behavior
(define
hs-toggle-class!
(fn (target cls) (host-call (host-get target "classList") "toggle" cls)))
(define hs-settle (fn (target) (perform (list (quote io-settle) target))))
;; ── DOM insertion ───────────────────────────────────────────────
;; Put content at a position relative to a target.
;; pos: "into" | "before" | "after"
(define
hs-toggle-class!
(fn (target cls) (host-call (host-get target "classList") "toggle" cls)))
;; ── Navigation / traversal ──────────────────────────────────────
;; Navigate to a URL.
(define
hs-toggle-between!
(fn
@@ -108,9 +126,7 @@
(do (dom-remove-class target cls1) (dom-add-class target cls2))
(do (dom-remove-class target cls2) (dom-add-class target cls1)))))
;; ── Navigation / traversal ──────────────────────────────────────
;; Navigate to a URL.
;; Find next sibling matching a selector (or any sibling).
(define
hs-toggle-style!
(fn
@@ -134,7 +150,7 @@
(dom-set-style target prop "hidden")
(dom-set-style target prop "")))))))
;; Find next sibling matching a selector (or any sibling).
;; Find previous sibling matching a selector.
(define
hs-toggle-style-between!
(fn
@@ -146,7 +162,7 @@
(dom-set-style target prop val2)
(dom-set-style target prop val1)))))
;; Find previous sibling matching a selector.
;; First element matching selector within a scope.
(define
hs-toggle-style-cycle!
(fn
@@ -167,7 +183,7 @@
(true (find-next (rest remaining))))))
(dom-set-style target prop (find-next vals)))))
;; First element matching selector within a scope.
;; Last element matching selector.
(define
hs-take!
(fn
@@ -207,7 +223,7 @@
(dom-set-attr target name attr-val)
(dom-set-attr target name ""))))))))
;; Last element matching selector.
;; First/last within a specific scope.
(begin
(define
hs-element?
@@ -319,7 +335,6 @@
(dom-insert-adjacent-html target "beforeend" value)
(hs-boot-subtree! target)))))))))
;; First/last within a specific scope.
(define
hs-add-to!
(fn
@@ -332,6 +347,9 @@
(append target (list value))))
(true (do (host-call target "push" value) target)))))
;; ── Iteration ───────────────────────────────────────────────────
;; Repeat a thunk N times.
(define
hs-remove-from!
(fn
@@ -341,9 +359,7 @@
(filter (fn (x) (not (= x value))) target)
(host-call target "splice" (host-call target "indexOf" value) 1))))
;; ── Iteration ───────────────────────────────────────────────────
;; Repeat a thunk N times.
;; Repeat forever (until break — relies on exception/continuation).
(define
hs-splice-at!
(fn
@@ -367,7 +383,10 @@
(host-call target "splice" i 1))))
target))))
;; Repeat forever (until break — relies on exception/continuation).
;; ── Fetch ───────────────────────────────────────────────────────
;; Fetch a URL, parse response according to format.
;; (hs-fetch url format) — format is "json" | "text" | "html"
(define
hs-put-at!
(fn
@@ -386,10 +405,10 @@
((= pos "start") (host-call target "unshift" value)))
target)))))
;; ── Fetch ───────────────────────────────────────────────────────
;; ── Type coercion ───────────────────────────────────────────────
;; Fetch a URL, parse response according to format.
;; (hs-fetch url format) — format is "json" | "text" | "html"
;; Coerce a value to a type by name.
;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc.
(define
hs-dict-without
(fn
@@ -410,27 +429,27 @@
(host-call (host-global "Reflect") "deleteProperty" out key)
out)))))
;; ── Type coercion ───────────────────────────────────────────────
;; ── Object creation ─────────────────────────────────────────────
;; Coerce a value to a type by name.
;; (hs-coerce value type-name) — type-name is "Int", "Float", "String", etc.
;; Make a new object of a given type.
;; (hs-make type-name) — creates empty object/collection
(define
hs-set-on!
(fn
(props target)
(for-each (fn (k) (host-set! target k (get props k))) (keys props))))
;; ── Object creation ─────────────────────────────────────────────
;; Make a new object of a given type.
;; (hs-make type-name) — creates empty object/collection
(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url))))
;; ── Behavior installation ───────────────────────────────────────
;; Install a behavior on an element.
;; A behavior is a function that takes (me ...params) and sets up features.
;; (hs-install behavior-fn me ...args)
(define hs-navigate! (fn (url) (perform (list (quote io-navigate) url))))
;; ── Measurement ─────────────────────────────────────────────────
;; Measure an element's bounding rect, store as local variables.
;; Returns a dict with x, y, width, height, top, left, right, bottom.
(define
hs-scroll!
(fn
@@ -443,10 +462,10 @@
((= position "bottom") (dict :block "end"))
(true (dict :block "start")))))))
;; ── Measurement ─────────────────────────────────────────────────
;; Measure an element's bounding rect, store as local variables.
;; Returns a dict with x, y, width, height, top, left, right, bottom.
;; Return the current text selection as a string. In the browser this is
;; `window.getSelection().toString()`. In the mock test runner, a test
;; setup stashes the desired selection text at `window.__test_selection`
;; and the fallback path returns that so tests can assert on the result.
(define
hs-halt!
(fn
@@ -467,17 +486,13 @@
(host-call ev "stopPropagation")))))
(when (not (= mode "the-event")) (raise (list "hs-return" nil))))))
;; Return the current text selection as a string. In the browser this is
;; `window.getSelection().toString()`. In the mock test runner, a test
;; setup stashes the desired selection text at `window.__test_selection`
;; and the fallback path returns that so tests can assert on the result.
(define hs-select! (fn (target) (host-call target "select" (list))))
;; ── Transition ──────────────────────────────────────────────────
;; Transition a CSS property to a value, optionally with duration.
;; (hs-transition target prop value duration)
(define hs-select! (fn (target) (host-call target "select" (list))))
(define
hs-get-selection
(fn
@@ -612,12 +627,12 @@
((all (dom-query-all (dom-body) sel)))
(if (> (len all) 0) (nth all (- (len all) 1)) nil))))
(define hs-first (fn (scope sel) (dom-query-all scope sel)))
(define
hs-last
(fn
@@ -625,7 +640,8 @@
(let
((all (dom-query-all scope sel)))
(if (> (len all) 0) (nth all (- (len all) 1)) nil))))
;; ── Sandbox/test runtime additions ──────────────────────────────
;; Property access — dot notation and .length
(define
hs-repeat-times
(fn
@@ -643,8 +659,7 @@
((= signal "hs-continue") (do-repeat (+ i 1)))
(true (do-repeat (+ i 1))))))))
(do-repeat 0)))
;; ── Sandbox/test runtime additions ──────────────────────────────
;; Property access — dot notation and .length
;; DOM query stub — sandbox returns empty list
(define
hs-repeat-forever
(fn
@@ -660,7 +675,7 @@
((= signal "hs-continue") (do-forever))
(true (do-forever))))))
(do-forever)))
;; DOM query stub — sandbox returns empty list
;; Method dispatch — obj.method(args)
(define
hs-repeat-while
(fn
@@ -673,7 +688,9 @@
((= signal "hs-break") nil)
((= signal "hs-continue") (hs-repeat-while cond-fn thunk))
(true (hs-repeat-while cond-fn thunk)))))))
;; Method dispatch — obj.method(args)
;; ── 0.9.90 features ─────────────────────────────────────────────
;; beep! — debug logging, returns value unchanged
(define
hs-repeat-until
(fn
@@ -685,9 +702,7 @@
((= signal "hs-continue")
(if (cond-fn) nil (hs-repeat-until cond-fn thunk)))
(true (if (cond-fn) nil (hs-repeat-until cond-fn thunk)))))))
;; ── 0.9.90 features ─────────────────────────────────────────────
;; beep! — debug logging, returns value unchanged
;; Property-based is — check obj.key truthiness
(define
hs-for-each
(fn
@@ -707,7 +722,7 @@
((= signal "hs-continue") (do-loop (rest remaining)))
(true (do-loop (rest remaining))))))))
(do-loop items))))
;; Property-based is — check obj.key truthiness
;; Array slicing (inclusive both ends)
(begin
(define
hs-append
@@ -735,7 +750,7 @@
((hs-element? target)
(dom-insert-adjacent-html target "beforeend" (str value)))
(true nil)))))
;; Array slicing (inclusive both ends)
;; Collection: sorted by
(define
hs-sender
(fn
@@ -743,7 +758,7 @@
(let
((detail (host-get event "detail")))
(if detail (host-get detail "sender") nil))))
;; Collection: sorted by
;; Collection: sorted by descending
(define
hs-host-to-sx
(fn
@@ -797,7 +812,7 @@
(dict-set! out k (hs-host-to-sx (host-get v k))))
(host-call (host-global "Object") "keys" v))
out)))))))))))
;; Collection: sorted by descending
;; Collection: split by
(define
hs-fetch
(fn
@@ -807,7 +822,7 @@
(let
((raw (perform (list "io-fetch" url fmt))))
(cond ((= fmt "json") (hs-host-to-sx raw)) (true raw))))))
;; Collection: split by
;; Collection: joined by
(define
hs-json-escape
(fn
@@ -838,7 +853,7 @@
(walk)
out)
"\"")))
;; Collection: joined by
(define
hs-json-stringify
(fn