ebox/tests/ebox-core-render-tests.el

13213 lines
604 KiB
EmacsLisp

;;; ebox-core-render-tests.el --- Core render and update regression tests -*- lexical-binding: t; -*-
(require 'ert)
(require 'cl-lib)
(unless load-file-name
(error "This test file must be loaded from disk, not eval'ed directly"))
(setq load-prefer-newer t)
(load-file (expand-file-name "../ebox.el" (file-name-directory load-file-name)))
;; Core tests assert named Elisp render/patch strategies. Keep optional local
;; native artifacts from replacing those strategies; native execution has
;; separate module and integration gates.
(defvar ebox-native-reflow-module-path)
(setq ebox-native-reflow-module-path nil)
(defvar ebox--scroll-window-initial-lookahead-lines-override nil)
(defun ebox-test--reset-runtime-state ()
"Reset global runtime state used by ebox rendering tests."
(setq ebox--region-id-counter 0)
(setq ebox--runtime-node-id-counter 0)
(setq ebox--render-region-id nil)
(setq ebox--scroll-allow-noninteractive-prefetch t)
(ebox-cache-reset-default-registry)
(dolist (table (list ebox--region-box-table
ebox--buffer-render-state-table
ebox--scroll-global-state
ebox--render-root-cache-table-table
ebox--render-cache-entry-side-effects-table
ebox--rendered-uniform-width-table
ebox--flex-sized-render-observation-table))
(clrhash table))
(maphash (lambda (_region-id timer)
(when (timerp timer)
(cancel-timer timer)))
ebox--scroll-idle-prefetch-timers)
(clrhash ebox--scroll-idle-prefetch-timers)
(maphash (lambda (_buffer timer)
(when (timerp timer)
(cancel-timer timer)))
ebox--runtime-prewarm-timers)
(clrhash ebox--runtime-prewarm-timers)
(clrhash ebox--runtime-prewarm-jobs)
(maphash (lambda (_buffer timer)
(when (timerp timer)
(cancel-timer timer)))
ebox--reflow-cache-prewarm-timers)
(clrhash ebox--reflow-cache-prewarm-timers)
(when (timerp ebox--deferred-render-gc-timer)
(cancel-timer ebox--deferred-render-gc-timer))
(setq ebox--deferred-render-gc-timer nil)
(setq ebox--deferred-render-gc-state nil)
(setq ebox--deferred-render-gc-depth 0)
(setq ebox--render-burst-stack nil)
(clrhash ebox--render-burst-records))
(defmacro ebox-test--with-rendered-buffer (layout &rest body)
"Render LAYOUT into a temporary ebox buffer and run BODY there."
(declare (indent 1))
`(let* ((buffer-name (generate-new-buffer-name " *ebox-test*"))
(buffer (ebox-render-to-buffer buffer-name ,layout)))
(unwind-protect
(with-current-buffer buffer
,@body)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(defun ebox-test--set-buffer-update-report (buffer report)
"Store REPORT in BUFFER's render state."
(plist-put (ebox--buffer-render-state buffer) :last-update-report report))
(defun ebox-test--buffer-update-report (&optional buffer)
"Return BUFFER's update report, defaulting to the active rendered buffer."
(let ((target
(or buffer
(and (ebox--buffer-render-state (current-buffer))
(current-buffer))
(let (candidate ambiguous)
(maphash
(lambda (render-buffer _state)
(when (buffer-live-p render-buffer)
(if candidate
(setq ambiguous t)
(setq candidate render-buffer))))
ebox--buffer-render-state-table)
(and (not ambiguous) candidate)))))
(ebox-buffer-update-report target)))
(defun ebox-test--runtime-root (buffer)
"Return BUFFER's surface-owned runtime root."
(plist-get (ebox--buffer-render-state buffer) :root-node))
(defun ebox-test--input-content-pixel (input)
"Return INPUT's used content width at the private layout boundary."
(let ((ebox--render-source-index (ebox-test-source-index input)))
(ebox--content-pixel (ebox-test-root input))))
(defun ebox-test--input-total-pixel (input)
"Return INPUT's used outer width at the private layout boundary."
(let ((ebox--render-source-index (ebox-test-source-index input)))
(ebox--total-pixel (ebox-test-root input))))
(defun ebox-test--input-content-height (input line-count)
"Return INPUT's used content height for LINE-COUNT layout lines."
(let ((ebox--render-source-index (ebox-test-source-index input)))
(ebox--content-height (ebox-test-root input) line-count)))
(defun ebox-test--render-input-node (input)
"Render INPUT through the private node boundary with its retained facts."
(ebox--render-node (ebox-test-root input) (ebox-test-source-index input)))
(defun ebox-test--call-input-node (function input &rest arguments)
"Call private node FUNCTION for INPUT with its retained source generation."
(let ((ebox--render-source-index (ebox-test-source-index input)))
(apply function (ebox-test-root input) arguments)))
(defun ebox-test--selector-node (buffer selector)
"Return the first runtime node matching SELECTOR in BUFFER."
(plist-get (car (ebox-selector-query-buffer buffer selector)) :node))
(defun ebox-test--selector-node-id (buffer selector)
"Return the first runtime node id matching SELECTOR in BUFFER."
(plist-get (car (ebox-selector-query-buffer buffer selector)) :node-id))
(defun ebox-test--region-handle-for-id (region-id)
"Return the unique live surface handle for test REGION-ID."
(let (handle ambiguous)
(maphash
(lambda (buffer state)
(when (and (buffer-live-p buffer)
(gethash region-id (plist-get state :region-id-set)))
(let* ((object
(gethash region-id
(plist-get state :region-surface-object-table)))
(candidate
(and object
(ebox-selector--live-region-handle buffer object))))
(when candidate
(if handle
(setq ambiguous t)
(setq handle candidate))))))
ebox--buffer-render-state-table)
(when ambiguous
(error "Test region id is ambiguous across mounted surfaces: %S"
region-id))
(or handle
(error "Test region id has no mounted surface handle: %S" region-id))))
(defun ebox-test--region-update (target &rest props)
"Update test TARGET through the public surface-scoped handle API."
(apply #'ebox-region-update
(if (ebox-region-handle-p target)
target
(ebox-test--region-handle-for-id target))
props))
(ert-deftest ebox-scroll-window-lookahead-accepts-root-reflow-override ()
"Root reflow should be able to defer all hidden lookahead work."
(let ((ebox-scroll-lazy-prefix-lookahead-lines 8)
(ebox-wheel-scroll-step 16)
(ebox--scroll-window-initial-lookahead-lines-override 0))
(should (= (ebox--scroll-window-initial-lookahead-lines) 0))))
(ert-deftest ebox-root-reflow-lookahead-keeps-large-runtimes-demand-driven ()
"Root reflow should not eagerly cross costly rows in a large runtime."
(ebox-test--reset-runtime-state)
(let ((buffer (generate-new-buffer " *ebox-large-lookahead*"))
(node-table (make-hash-table :test 'equal))
(runtime-type-count-table (make-hash-table :test 'eq)))
(unwind-protect
(progn
(dotimes (index 512)
(puthash index t node-table))
(puthash buffer
(list :node-table node-table
:runtime-type-count-table runtime-type-count-table)
ebox--buffer-render-state-table)
(should
(= (ebox--root-reflow-scroll-lookahead-lines-for-buffer buffer)
0)))
(remhash buffer ebox--buffer-render-state-table)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-root-reflow-lookahead-keeps-typed-flex-demand-driven ()
"A typed Flex runtime should not eagerly cross hidden composite rows."
(ebox-test--reset-runtime-state)
(let ((buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-typed-flex-lookahead*")
(ebox-build
'(flex :width (240) :flex-flow (row wrap)
(box :width (100) "A")
(box :width (100) "B"))))))
(unwind-protect
(should (zerop
(ebox--root-reflow-scroll-lookahead-lines-for-buffer
buffer)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-exact-scroll-prefix-skips-completion-sentinel ()
"Interactive prefix extension should request only the visible line bound."
(ebox-test--reset-runtime-state)
(let* ((region-id 'exact-prefix)
(all-lines '("zero" "one" "two" "three" "four"))
requested-limits
(render-prefix
(lambda (_state _region-id limit)
(push limit requested-limits)
(list :content-lines (seq-take all-lines limit)
:rendered-content-lines (seq-take all-lines limit)
:content-height 2
:complete nil)))
(state (list :content-lines (seq-take all-lines 3)
:rendered-content-lines (seq-take all-lines 3)
:content-height 2
:render-content-prefix render-prefix)))
(let ((ebox-scroll-lazy-prefix-lookahead-lines 0))
(ebox--scroll-state-ensure-prefix-lines region-id state 4 t))
(should (equal requested-limits '(4)))
(setq requested-limits nil
state (list :content-lines (seq-take all-lines 3)
:rendered-content-lines (seq-take all-lines 3)
:content-height 2
:render-content-prefix render-prefix))
(let ((ebox-scroll-lazy-prefix-lookahead-lines 0))
(ebox--scroll-state-ensure-prefix-lines region-id state 4))
(should (equal requested-limits '(5)))))
(ert-deftest ebox-scroll-lightweight-line-bounds-extend-incrementally ()
"Lazy line bounds should extend without rebuilding prior prefix entries."
(ebox-test--reset-runtime-state)
(let* ((line-a (propertize "a" 'ebox-content 11))
(line-b (propertize "b" 'ebox-content 12))
(line-c (propertize "c" 'ebox-content 11))
(ebox--defer-scroll-content-index t)
(state (ebox--scroll-state-set-lines nil (list line-a line-b)))
(index (plist-get state :region-line-bounds-index)))
(should (equal (gethash 11 index) '(0 . 0)))
(should (equal (gethash 12 index) '(1 . 1)))
(setq state
(ebox--scroll-state-set-lines
state (list line-a line-b line-c)))
(should (eq index (plist-get state :region-line-bounds-index)))
(should (equal (gethash 11 index) '(0 . 2)))
(should (equal (gethash 12 index) '(1 . 1)))
(should (plist-get state :region-line-span-index-deferred))))
(ert-deftest ebox-scroll-prefix-publishes-private-line-with-suffix-paint ()
"A cached hidden source line should not rebuild the rendered prefix."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 2))
(content-lines '("zero" "one" "two" "three"))
(state (list :content-lines (seq-take content-lines 3)
:rendered-content-lines
(seq-take content-lines 3)
:content-height 2))
(source-renders 0)
rendered-requests
(original-rendered-lines
(symbol-function 'ebox--scroll-rendered-content-lines))
(renderer
(ebox--scroll-window-prefix-renderer
(ebox-test-root box) '(:stack ignored) 20 2 nil content-lines)))
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (&rest _)
(cl-incf source-renders)
(error "Private cached line should satisfy the prefix")))
((symbol-function 'ebox--scroll-rendered-content-lines)
(lambda (&rest args)
(push (list (length (cadr args)) (nth 3 args))
rendered-requests)
(apply original-rendered-lines args))))
(let ((result (funcall renderer state 'private-prefix 4)))
(should (= source-renders 0))
(should (equal rendered-requests '((1 3))))
(should (= (length (plist-get result :content-lines)) 4))
(should (= (length (plist-get result :rendered-content-lines)) 4))))))
(defun ebox-test--face-has-key-value-p (face key value)
"Return non-nil when FACE contains KEY with VALUE."
(cond
((null face) nil)
((and (listp face) (keywordp (car face)))
(equal (plist-get face key) value))
((listp face)
(cl-some (lambda (item)
(ebox-test--face-has-key-value-p item key value))
face))
(t nil)))
(defun ebox-test--face-has-key-p (face key)
"Return non-nil when FACE contains KEY."
(cond
((null face) nil)
((and (listp face) (keywordp (car face)))
(plist-member face key))
((listp face)
(cl-some (lambda (item)
(ebox-test--face-has-key-p item key))
face))
(t nil)))
(defun ebox-test--effective-face-value (face key)
"Return the first anonymous FACE value declared for KEY."
(cond
((null face) nil)
((and (listp face) (keywordp (car face)))
(and (plist-member face key) (plist-get face key)))
((listp face)
(cl-loop for item in face
when (and (listp item)
(keywordp (car item))
(plist-member item key))
return (plist-get item key)))))
(defun ebox-test--face-visual-attributes (face)
"Return FACE attributes normalized by Emacs anonymous-face precedence."
(let ((entries (if (and (listp face) (not (keywordp (car-safe face))))
face
(list face)))
attributes named)
(dolist (entry entries)
(cond
((null entry))
((and (listp entry) (keywordp (car-safe entry)))
(cl-loop for (key value) on entry by #'cddr
unless (assq key attributes)
do (push (cons key value) attributes)))
(t (setq named t))))
(if named
(list 'raw face)
(list 'visual
(sort attributes
(lambda (left right)
(string< (symbol-name (car left))
(symbol-name (car right)))))))))
(defun ebox-test--border-face-has-color-p (face role color)
"Return non-nil when FACE paints border ROLE with COLOR."
(pcase role
('bt
(ebox-test--face-has-key-value-p face :overline color))
('bb
(ebox-test--face-has-key-value-p
face :underline (list :position t :color color)))
((or 'bl 'br)
(ebox-test--face-has-key-value-p face :background color))))
(defun ebox-test--face-at-property (string property)
"Return first face in STRING at PROPERTY."
(let ((pos 0)
(len (length string))
face)
(while (and (< pos len) (not face))
(when (get-text-property pos property string)
(setq face (get-text-property pos 'face string)))
(setq pos (or (next-single-property-change pos property string)
(1+ pos))))
face))
(defun ebox-test--first-content-face (string)
"Return first content face in STRING."
(ebox-test--face-at-property string 'ebox-content))
(defmacro ebox-test--count-full-surface-updates (&rest body)
"Run BODY and return the number of full TP surface updates it triggered."
(declare (indent 0))
`(let ((full-updates 0)
(original-full-update (symbol-function 'tp-surface-update)))
(cl-letf (((symbol-function 'tp-surface-update)
(lambda (&rest args)
(cl-incf full-updates)
(apply original-full-update args))))
,@body)
full-updates))
(defconst ebox-test--region-update-paint-cases
'((:color "#123456" :color "#123456")
(:bgcolor "#FDE68A" :bgcolor "#FDE68A")
(:border-left-color "#111111" :border-left-color "#111111")
(:border-right-color "#222222" :border-right-color "#222222")
(:border-top-color "#333333" :border-top-color "#333333")
(:border-bottom-color "#444444" :border-bottom-color "#444444")
(:visibility hidden :visibility hidden))
"Paint updates as (AUTHOR VALUE ENGINE-FIELD EXPECTED).")
(defconst ebox-test--region-update-geometry-cases
'((:box-sizing content-box :box-sizing content-box owner-rerender)
(:width (120) :width 120 owner-rerender)
(:min-width (100) :min-width 100 owner-rerender)
(:max-width (140) :max-width 140 owner-rerender)
(:height 3 :height 3 owner-rerender)
(:min-height 3 :min-height 3 owner-rerender)
(:max-height 1 :max-height 1 owner-rerender)
(:padding-left (9) :padding-left-pixel 9 span-patch)
(:padding-right (10) :padding-right-pixel 10 span-patch)
(:padding-top 1 :padding-top-height 1 span-patch)
(:padding-bottom 1 :padding-bottom-height 1 span-patch)
(:margin-left (7) :margin-left-pixel 7 owner-rerender)
(:margin-right (8) :margin-right-pixel 8 owner-rerender)
(:margin-top 1 :margin-top-height 1 owner-rerender)
(:margin-bottom 1 :margin-bottom-height 1 owner-rerender)
(:border-top-p t :border-top-pixel 1 span-patch)
(:text-align center :text-align center span-patch)
(:overflow hidden :overflow hidden owner-rerender)
(:wrap-mode char :wrap-mode char span-patch)
(:scroll-offset 1 :scroll-offset 1 span-patch))
"Geometry updates as (AUTHOR VALUE ENGINE-FIELD EXPECTED STRATEGY).")
(defun ebox-test--region-update-case-properties (cases)
"Return the update property names covered by CASES."
(mapcar #'car cases))
(ert-deftest ebox-root-background-covers-transparent-layout-wrappers ()
"A root canvas background should remain under transparent child wrappers."
(ebox-test--reset-runtime-state)
(let* ((ebox-viewport-width 240)
(ebox-viewport-height 6)
(layout
(ebox-test-box :width '(viewport) :height '(viewport-height) :bgcolor
"#151A23"
(ebox-test-column
(ebox-test-box :width '(viewport) :height 1 :bgcolor
"#202938" (ebox-test-text "surface"))
(ebox-test-box :width '(viewport) :height 1
(ebox-test-text "transparent"))
(ebox-test-box :width '(viewport) :height 1 :bgcolor
"#29364A"
(ebox-test-text "surface-two")))))
(lines (ebox-string-lines (ebox-render layout))))
(should (= (length lines) 6))
(dolist (line lines)
(should (ebox-test--face-has-key-value-p
(get-text-property 0 'face line)
:background "#151A23")))
(should (ebox-test--face-has-key-value-p
(get-text-property 0 'face (nth 0 lines))
:background "#202938"))
(should (ebox-test--face-has-key-value-p
(get-text-property 0 'face (nth 2 lines))
:background "#29364A"))))
(ert-deftest ebox-production-render-consumes-isolated-ecss-cascade ()
"Ebox rules should reach the real render path through ECSS cascade."
(ebox-test--reset-runtime-state)
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule
".card" '(:color "#F8FAFC" :bgcolor "#1E293B")
:layer 'components)
(let* ((node (ebox-test-box :class 'card (ebox-test-text "Cascade") :width '(96)))
(face (ebox-test--first-content-face (ebox-render node))))
(should (ebox-test--face-has-key-value-p
face :foreground "#F8FAFC"))
(should (ebox-test--face-has-key-value-p
face :background "#1E293B")))))
(ert-deftest ebox-font-size-rejects-runtime-and-function-values ()
"Standalone Ebox font size accepts only positive CSS reference pixels."
(should-error
(ebox-style-compute
(list :font-size (tp-computed (lambda () 16))))
:type 'error)
(should-error
(ebox-style-compute (list :font-size (lambda () 16)))
:type 'error))
(ert-deftest ebox-ecss-computed-fact-retains-owner-impact-and-provenance ()
"The Ebox package schema should remain attached to one computed fact."
(let* ((style
(ecss-compute-style
ebox-style-schemas (ecss-subject-create :type "box")
:declarations (ebox-style-compile-declarations '(:width (120)) t)
:provenance t))
(fact (ecss-computed-style-property-fact style 'ebox/width)))
(should (eq 'ebox/width (ecss-computed-property-fact-property fact)))
(should (eq 'ebox (ecss-computed-property-fact-owner fact)))
(should (equal '(geometry)
(ecss-computed-property-fact-impacts fact)))
(should (equal '(layout)
(ecss-computed-property-fact-projections fact)))
(should (equal (ecss-computed-property-fact-value fact)
(ecss-computed-style-value style 'ebox/width)))
(should (eq 'declaration
(plist-get (ecss-computed-property-fact-provenance fact)
:source)))))
(ert-deftest ebox-ecss-multi-impact-fact-shares-one-computed-value ()
"Typography geometry and paint consumers should share one value identity."
(let* ((style
(ecss-compute-style
ebox-style-schemas (ecss-subject-create :type "box")
:declarations
(ebox-style-compile-declarations '(:font-weight bold) t)))
(fact
(ecss-computed-style-property-fact style 'ebox/font-weight))
(geometry-value (ecss-computed-property-fact-value fact))
(paint-value (ecss-computed-property-fact-value fact)))
(should (equal '(geometry paint)
(ecss-computed-property-fact-impacts fact)))
(should (equal '(measurement paint)
(ecss-computed-property-fact-projections fact)))
(should (eq (plist-get
(ecss-computed-property-fact-metadata fact) :owner)
'font-metrics-projection))
(should (eq geometry-value paint-value))
(should (equal geometry-value
(ecss-computed-style-value style 'ebox/font-weight)))))
(ert-deftest ebox-style-context-reads-each-ecss-snapshot-once ()
"Style projection should not repeatedly copy one computed ECSS snapshot."
(let* ((style
(ebox-style-compute-subject
(ecss-subject-create :type "box")
(ebox-style-compile-declarations
'(:width (120) :padding (1 (8)) :bgcolor "#1E293B"))))
(original-values (symbol-function 'ecss-computed-style-values))
(original-active
(symbol-function 'ecss-computed-style-active-properties))
(original-specified
(symbol-function 'ecss-computed-style-specified-properties))
(original-provenance
(symbol-function 'ecss-computed-style-provenance))
(values-reads 0)
(active-reads 0)
(specified-reads 0)
(provenance-reads 0))
(cl-letf (((symbol-function 'ecss-computed-style-values)
(lambda (computed)
(cl-incf values-reads)
(funcall original-values computed)))
((symbol-function 'ecss-computed-style-active-properties)
(lambda (computed)
(cl-incf active-reads)
(funcall original-active computed)))
((symbol-function 'ecss-computed-style-specified-properties)
(lambda (computed)
(cl-incf specified-reads)
(funcall original-specified computed)))
((symbol-function 'ecss-computed-style-provenance)
(lambda (computed)
(cl-incf provenance-reads)
(funcall original-provenance computed))))
(should (ebox-style--context-values style 'box t))
(should (ebox-style--context-values style 'box t))
(should (ebox-style--context-values style 'box nil)))
(should (= values-reads 1))
(should (= active-reads 1))
(should (= specified-reads 1))
(should (= provenance-reads 0))))
(ert-deftest ebox-style-apply-computed-reuses-one-ecss-snapshot ()
"One computed style projection should reuse its ECSS snapshot."
(let* ((style
(ebox-style-compute-subject
(ecss-subject-create :type "flex")
(ebox-style-compile-declarations
'(:width (120) :flex-direction row :color "#F8FAFC"))))
(node (list :ebox-type 'flex :raw-props nil :props nil))
(original-values (symbol-function 'ecss-computed-style-values))
(original-active
(symbol-function 'ecss-computed-style-active-properties))
(original-specified
(symbol-function 'ecss-computed-style-specified-properties))
(values-reads 0)
(active-reads 0)
(specified-reads 0))
(cl-letf (((symbol-function 'ecss-computed-style-values)
(lambda (computed)
(cl-incf values-reads)
(funcall original-values computed)))
((symbol-function 'ecss-computed-style-active-properties)
(lambda (computed)
(cl-incf active-reads)
(funcall original-active computed)))
((symbol-function 'ecss-computed-style-specified-properties)
(lambda (computed)
(cl-incf specified-reads)
(funcall original-specified computed))))
(ebox-style-apply-computed node style))
(should (= values-reads 1))
(should (= active-reads 1))
(should (= specified-reads 1))))
(ert-deftest ebox-default-foreground-reset-preserves-explicit-background ()
"Resolve GUI default foreground without inheriting its background."
(let ((original (symbol-function 'face-attribute)))
(cl-letf (((symbol-function 'face-attribute)
(lambda (face attribute &rest arguments)
(if (and (eq face 'default) (eq attribute :foreground))
"Black"
(apply original face attribute arguments)))))
(should
(equal
(ebox-buffer-paint-text-properties
'(:color ebox/default-foreground :bgcolor "#111827") 'content)
'(face (:foreground "Black" :background "#111827")))))))
(ert-deftest ebox-flex-layout-properties-come-from-ecss-cascade ()
"An ECSS rule should determine a Flex container's actual main axis."
(ebox-test--reset-runtime-state)
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule
"flex" '(:flex-direction column :width (96))
:layer 'layout)
(let ((rendered
(substring-no-properties
(ebox-render
(ebox-test-flex
(ebox-test-box (ebox-test-text "A"))
(ebox-test-box (ebox-test-text "B")))))))
(should (equal (mapcar #'string-trim-right
(split-string rendered "\n"))
'("A" "B"))))))
(ert-deftest ebox-cascade-layers-inline-values-and-reset-use-one-winner ()
"Production rendering should honor ECSS layers, inline values, and reset."
(ebox-test--reset-runtime-state)
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule ".card" '(:color "#94A3B8") :layer 'base)
(ebox-style-add-rule ".card" '(:color "#38BDF8") :layer 'components)
(let* ((rule-node (ebox-test-box :class 'card (ebox-test-text "Rule")))
(inline-node
(ebox-test-box :class 'card (ebox-test-text "Inline") :color "#F8FAFC")))
(should
(equal (ebox-test--effective-face-value
(ebox-test--first-content-face (ebox-render rule-node))
:foreground)
"#38BDF8"))
(should
(equal (ebox-test--effective-face-value
(ebox-test--first-content-face (ebox-render inline-node))
:foreground)
"#F8FAFC"))
(ebox-style-reset-rules)
(should-not
(ebox-test--face-has-key-p
(ebox-test--first-content-face (ebox-render rule-node))
:foreground)))))
(ert-deftest ebox-grid-container-and-item-properties-come-from-ecss-cascade ()
"Grid container and item rules should reach the production runtime tree."
(ebox-test--reset-runtime-state)
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule
"grid" '(:grid-template-columns ((40) (40)) :column-gap (4))
:layer 'layout)
(ebox-style-add-rule ".placed" '(:grid-column 2) :layer 'layout)
(ebox-test--with-rendered-buffer
(ebox-test-grid
(ebox-test-box :class 'placed (ebox-test-text "Placed"))
(ebox-test-box (ebox-test-text "Auto")))
(let* ((root (ebox-test--runtime-root (current-buffer)))
(config (plist-get root :ebox-layout-config))
(props (ebox-layout-config-props config))
(placed (car (ebox-tree-layout-children root))))
(should (eq (ebox-layout-config-kind config) 'grid))
(should (equal (plist-get props :grid-template-columns)
'((:kind fixed :size 40)
(:kind fixed :size 40))))
(should (equal (plist-get props :column-gap)
4))
(should (= (plist-get placed :grid-column) 2))))))
(ert-deftest ebox-font-cascade-inherits-overrides-and-precedes-measurement ()
"Font computed values should reach child text before pixel measurement."
(ebox-test--reset-runtime-state)
(let* ((root
(ebox-test-box :wrap-mode 'none :font-family "Parent Family"
:font-size 16 :font-weight 'bold
(ebox-test-box :id 'child :font-family "Child Family"
(ebox-test-text "MMMM"))))
measured-face)
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (string)
(when (string-match-p "MMMM" (substring-no-properties string))
(setq measured-face (get-text-property 0 'face string)))
40)))
(ebox-test--with-rendered-buffer root
(let* ((runtime-root (ebox-test--runtime-root (current-buffer)))
(runtime-child (car (ebox-tree-layout-children runtime-root)))
(style (plist-get runtime-child :ebox-computed-style))
(values (ecss-computed-style-values style))
(face (ebox-test--first-content-face (buffer-string))))
(should (equal (plist-get values 'ebox/font-family) "Child Family"))
(should (= (plist-get values 'ebox/font-size) 16))
(should (eq (plist-get values 'ebox/font-weight) 'bold))
(should
(equal (ebox-test--effective-face-value face :family)
"Child Family"))
(should (= (ebox-test--effective-face-value face :height) 120))
(should (eq (ebox-test--effective-face-value face :weight) 'bold)))))
(should measured-face)
(should
(equal (ebox-test--effective-face-value measured-face :family)
"Child Family"))
(should (= (ebox-test--effective-face-value measured-face :height) 120))))
(ert-deftest ebox-column-background-owns-blank-lines-with-child-override ()
"A column background should fill its blank lines without hiding child paint."
(ebox-test--reset-runtime-state)
(let* ((parent "#0F172A")
(child "#1D4ED8")
(rendered
(ebox-render
(ebox-test-column :width '(120) :height 5 :padding-block 1 :bgcolor
parent
(ebox-test-box :width '(120) :height 1
:margin-bottom 1 :bgcolor child
(ebox-test-text "Child")))))
(lines (ebox-string-lines rendered)))
(should (= (length lines) 5))
(dolist (line lines)
(should
(ebox-test--face-has-key-value-p
(get-text-property 0 'face line) :background parent)))
(let ((child-line
(cl-find-if
(lambda (line)
(string-match-p "Child" (substring-no-properties line)))
lines))
(blank-line
(cl-find-if
(lambda (line)
(and (string-blank-p (substring-no-properties line))
(equal (ebox-test--effective-face-value
(get-text-property 0 'face line) :background)
parent)))
lines)))
(should child-line)
(should blank-line)
(should
(equal (ebox-test--effective-face-value
(get-text-property 0 'face child-line) :background)
child)))))
(ert-deftest ebox-paint-only-update-skips-measurement-flex-and-grid ()
"A pure paint change should not recompute spatial layout."
(ebox-test--reset-runtime-state)
(let* ((target
(ebox-test-box :id 'target (ebox-test-text "Paint") :width '(60)
:color "#E2E8F0" :bgcolor "#1E293B"))
(root
(ebox-test-flex
:width '(160)
(ebox-test-grid :grid-template-columns '((60)) target))))
(ebox-test--with-rendered-buffer root
(let ((handle (ebox-region-resolve (current-buffer) 'target)))
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (&rest _)
(error "Paint update measured text")))
((symbol-function 'ebox--render-flex)
(lambda (&rest _)
(error "Paint update recomputed Flex")))
((symbol-function 'ebox--render-grid)
(lambda (&rest _)
(error "Paint update recomputed Grid"))))
(ebox-region-update handle :bgcolor "#334155"))
(goto-char (point-min))
(search-forward "Paint")
(should
(equal (ebox-test--effective-face-value
(get-text-property (match-beginning 0) 'face)
:background)
"#334155"))
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
'paint-patch))))))
(ert-deftest ebox-live-mount-uses-runtime-copy-and-leaves-source-stable ()
"Live publication should update a surface copy without mutating SOURCE."
(ebox-test--reset-runtime-state)
(let* ((source (ebox-test-box :id 'root :width '(120) :color "#111111" :height 1
:overflow 'scroll (ebox-test-text "before\nhidden")))
(source-before (copy-tree source))
(buffer (generate-new-buffer " *ebox-live-copy-source*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer source)
(ebox-commit
buffer
(ebox-test-box :id 'root :width '(140) :color "#222222" :height 1
:overflow 'scroll (ebox-test-text "after\nhidden")))
(should (equal source source-before))
(with-current-buffer buffer
(should (string-match-p "after" (buffer-string)))
(should-not (string-match-p "before" (buffer-string)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-live-mount-allows-one-source-on-independent-surfaces ()
"One immutable source should mount and update independently in two buffers."
(ebox-test--reset-runtime-state)
(let* ((source (ebox-test-box :id 'root :width '(120) :height 1 :overflow 'scroll
(ebox-test-text "owned\nhidden")))
(source-before (copy-tree source))
first-buffer second-buffer)
(unwind-protect
(progn
(setq first-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-first*")
source))
(setq second-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-second*")
source))
(ebox-commit first-buffer
(ebox-test-box :id 'root (ebox-test-text "first changed")))
(with-current-buffer first-buffer
(should (string-match-p "first changed" (buffer-string))))
(with-current-buffer second-buffer
(should-not (string-match-p "first changed" (buffer-string))))
(ebox-commit second-buffer
(ebox-test-box :id 'root (ebox-test-text "second changed")))
(with-current-buffer second-buffer
(should (string-match-p "second changed" (buffer-string))))
(with-current-buffer first-buffer
(should-not (string-match-p "second changed" (buffer-string))))
(should (equal source source-before)))
(dolist (buffer (list first-buffer second-buffer))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))))
(ert-deftest ebox-live-mount-kill-invalidates-handle ()
"Killing a surface buffer should invalidate its region handles."
(ebox-test--reset-runtime-state)
(let* ((old-source (ebox-test-box :id 'old :width 120 (ebox-test-text "old")))
(source-before (copy-tree old-source))
primary-buffer secondary-buffer)
(unwind-protect
(progn
(setq primary-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-primary*")
old-source))
(let ((old-handle (ebox-region-resolve primary-buffer "old")))
(kill-buffer primary-buffer)
(setq primary-buffer nil)
(should-error (ebox-test--region-update old-handle :width '(140))
:type 'user-error))
(setq secondary-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-secondary*")
old-source))
(ebox-commit secondary-buffer
(ebox-test-box :id 'old :width 120 (ebox-test-text "reused")))
(with-current-buffer secondary-buffer
(should (string-match-p "reused" (buffer-string))))
(should (equal old-source source-before)))
(dolist (buffer (list primary-buffer secondary-buffer))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))))
(ert-deftest ebox-wrapper-child-nodes-are-in-runtime-index ()
"A wrapper box's child layout should remain visible in runtime state."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-column :padding 1 (ebox-test-box (ebox-test-text "One"))
(ebox-test-box (ebox-test-text "Two"))))
buffer)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node))
(let ((ids (ebox--runtime-node-ids
(ebox-test--runtime-root buffer))))
(should (> (length ids) 1))
(dolist (node-id ids)
(should (ebox--buffer-runtime-node buffer node-id)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-region-render-owner-index-keeps-earlier-duplicate-box ()
"A later flex wrapper should not replace an earlier duplicate region owner."
(ebox-test--reset-runtime-state)
(let* ((region-id 9001)
(plain (ebox-test-box (ebox-test-text "First") :width '(80)))
(flex (ebox-test-flex :width '(120)
(ebox-test-box (ebox-test-text "Later") :width '(80))))
(wrapper (ebox-test-root flex))
(layout (ebox-test-column plain flex))
(plain-node (ebox-test-root plain))
(layout-node (ebox-test-root layout)))
(ebox-put plain-node :region-id region-id)
(ebox-put wrapper :region-id region-id)
(let* ((index (ebox--runtime-index
layout-node nil (ebox-test-source-index layout)))
(table (plist-get index :region-node-table))
(expected (car (ebox--node-path-to-region
layout-node region-id))))
(should (eq expected plain-node))
(should (equal (gethash region-id table)
(ebox--ensure-node-id expected))))))
(ert-deftest ebox-region-render-owner-index-falls-back-for-legacy-state ()
"A runtime state without the new table should retain recursive lookup."
(ebox-test--reset-runtime-state)
(let* ((child (ebox-test-box (ebox-test-text "Flex child") :width '(80)))
(flex (ebox-test-flex :width '(120) :padding 1 child))
(wrapper (plist-get flex :box)))
(ebox-test--with-rendered-buffer flex
(let* ((buffer (current-buffer))
(state (ebox--buffer-render-state buffer))
(table (ebox--buffer-region-node-table buffer))
(region-id (ebox--ensure-region-id wrapper))
(expected-node-id
(ebox--buffer-region-render-owner-node-id buffer region-id))
(expected-node
(ebox--buffer-region-render-owner-node buffer region-id))
(original-path
(symbol-function 'ebox--node-path-to-region))
(recursive-calls 0))
(unwind-protect
(progn
(plist-put state :region-node-table nil)
(cl-letf (((symbol-function 'ebox--node-path-to-region)
(lambda (&rest args)
(cl-incf recursive-calls)
(apply original-path args))))
(should (equal
(ebox--buffer-region-render-owner-node-id
buffer region-id)
expected-node-id))
(should (eq (ebox--buffer-region-render-owner-node
buffer region-id)
expected-node)))
(should (> recursive-calls 0)))
(plist-put state :region-node-table table))))))
(ert-deftest ebox-runtime-node-id-survives-viewport-rerender ()
"Viewport-only rerender should not assign new runtime node ids."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-flex
:flex-wrap 'wrap
(ebox-test-box (ebox-test-text "A") :width '(100))
(ebox-test-box (ebox-test-text "B") :width '(100))))
(ids-before nil)
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 240))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(setq ids-before (ebox--runtime-node-ids layout))
(let ((ebox-viewport-width 160))
(ebox--rerender-buffer-preserving-runtime buffer))
(should (equal (ebox--runtime-node-ids layout) ids-before)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-reconcile-preserves-keyed-child-id ()
"Keyed children should keep runtime ids across structural rebuilds."
(ebox-test--reset-runtime-state)
(let* ((old-input
(ebox-test-column
(ebox-test-box :key 'a (ebox-test-text "A") :width 40)
(ebox-test-box :key 'b (ebox-test-text "B") :width 40)))
(old (ebox--runtime-tree (ebox-test-root old-input)))
(old-b-id
(plist-get
(car (ebox-selector-query-all old-input "[key=b]")) :node-id))
(new-input
(ebox-test-column
(ebox-test-box :key 'x (ebox-test-text "X") :width 40)
(ebox-test-box :key 'b (ebox-test-text "B2") :width 40)))
(new
(ebox-tree-reconcile-runtime
old (ebox-test-source-index old-input)
(ebox-test-root new-input) (ebox-test-source-index new-input))))
(should
(equal
(plist-get
(car (ebox-selector-query-all
(ebox-canonical-input-create
(list new) (ebox-test-source-index new-input))
"[key=b]"))
:node-id)
old-b-id))))
(ert-deftest ebox-tree-helpers-see-wrapper-child-nodes ()
"Tree helpers should see wrapper children without rendering static content."
(let* ((node (ebox-test-column :padding 1
(ebox-test-box (ebox-test-text "Child") :width '(120))))
(children (ebox-tree-children (ebox-test-root node))))
(should children)
(should (plist-get (car children) :node-id))))
(ert-deftest ebox-tree-shallow-copy-replaces-box-content-node ()
"A box content-node replacement should leave other values shared."
(let* ((old-content (list :ebox-type 'box :content "old"))
(equal-content (copy-tree old-content))
(new-content (list :ebox-type 'box :content "new"))
(wrong-content (list :ebox-type 'box :content "wrong"))
(metadata (list :owner 'host))
(node (list :ebox-type 'box
:ebox-content-node old-content
:metadata metadata))
(copy
(ebox-tree-copy-with-direct-child-replacements
node (list (cons equal-content wrong-content)
(cons old-content new-content)))))
(should-not (eq copy node))
(should (eq (plist-get copy :ebox-content-node) new-content))
(should (eq (plist-get copy :metadata) metadata))
(should (eq (plist-get node :ebox-content-node) old-content))))
(ert-deftest ebox-tree-shallow-copy-replaces-concat-children-and-aliases ()
"A concat copy should update flat children and both legacy aliases."
(let* ((left (list :ebox-type 'box :content "left"))
(right (list :ebox-type 'box :content "right"))
(new-left (list :ebox-type 'box :content "new-left"))
(children (list left right))
(node (list :ebox-type 'concat
:children children
:left left
:right right))
(copy
(ebox-tree-copy-with-direct-child-replacements
node (list (cons left new-left))))
(copy-children (plist-get copy :children)))
(should-not (eq copy node))
(should-not (eq copy-children children))
(should (eq (car copy-children) new-left))
(should (eq (cadr copy-children) right))
(should (eq (plist-get copy :left) new-left))
(should (eq (plist-get copy :right) right))))
(ert-deftest ebox-tree-shallow-copy-replaces-stack-children-and-aliases ()
"A stack copy should update flat children and both legacy aliases."
(let* ((top (list :ebox-type 'box :content "top"))
(bottom (list :ebox-type 'box :content "bottom"))
(new-bottom (list :ebox-type 'box :content "new-bottom"))
(children (list top bottom))
(node (list :ebox-type 'stack
:children children
:top top
:bottom bottom))
(copy
(ebox-tree-copy-with-direct-child-replacements
node (list (cons bottom new-bottom))))
(copy-children (plist-get copy :children)))
(should-not (eq copy node))
(should-not (eq copy-children children))
(should (eq (car copy-children) top))
(should (eq (cadr copy-children) new-bottom))
(should (eq (plist-get copy :top) top))
(should (eq (plist-get copy :bottom) new-bottom))))
(ert-deftest ebox-tree-shallow-copy-replaces-flex-box-and-children-once ()
"One flex copy should apply multiple replacements and share one sibling."
(let* ((box (list :ebox-type 'box :content "wrapper"))
(first (list :ebox-type 'box :content "first"))
(untouched (list :ebox-type 'box :content "untouched"))
(last (list :ebox-type 'box :content "last"))
(new-box (list :ebox-type 'box :content "new-wrapper"))
(new-first (list :ebox-type 'box :content "new-first"))
(new-last (list :ebox-type 'box :content "new-last"))
(children (list first untouched last))
(node (list :ebox-type 'flex :box box :children children))
(copy
(ebox-tree-copy-with-direct-child-replacements
node (list (cons box new-box)
(cons first new-first)
(cons last new-last))))
(copy-children (plist-get copy :children)))
(should-not (eq copy node))
(should (eq (plist-get copy :box) new-box))
(should (eq (nth 0 copy-children) new-first))
(should (eq (nth 1 copy-children) untouched))
(should (eq (nth 2 copy-children) new-last))
(should (eq (plist-get node :box) box))
(should (eq (nth 0 children) first))
(should (eq (nth 1 children) untouched))
(should (eq (nth 2 children) last))))
(ert-deftest ebox-tree-shallow-copy-without-replacements-shares-values ()
"A zero-replacement copy should share every nested value."
(let* ((first (list :ebox-type 'box :content "first"))
(second (list :ebox-type 'box :content "second"))
(children (list first second))
(node (list :ebox-type 'concat :children children))
(copy (ebox-tree-copy-with-direct-child-replacements node nil)))
(should-not (eq copy node))
(should (eq (plist-get copy :children) children))
(should (eq (car (plist-get copy :children)) first))
(should (eq (cadr (plist-get copy :children)) second))))
(ert-deftest ebox-fragment-signature-excludes-backend-spans ()
"Fragment signatures should describe layout facts, not backend markers."
(let ((fragment (ebox-fragment-create
:node-id "n1"
:region-ids '("r1")
:size '(120 . 24)
:line-signature '(("Child"))
:buffer-span '(1 . 8))))
(should (equal (ebox-fragment-layout-signature fragment)
'(:node-id "n1" :region-ids ("r1") :size (120 . 24)
:line-signature (("Child")))))))
(ert-deftest ebox-cache-registry-records-scope-deps-and-lifetime ()
"Cache specs should make ownership, scope, dependencies, and lifetime explicit."
(unwind-protect
(let ((spec (ebox-cache-register
:name 'computed-style
:owner 'ebox-style
:scope 'node
:key-deps '(specified-style class-version theme-version)
:invalidates '(style class theme)
:lifetime 'buffer-runtime
:value-owner 'ebox-style)))
(should (eq (ebox-cache-spec-name spec) 'computed-style))
(should (eq (ebox-cache-spec-owner spec) 'ebox-style))
(should (eq (ebox-cache-spec-scope spec) 'node))
(should (equal (ebox-cache-spec-invalidates spec)
'(style class theme)))
(should (eq (ebox-cache-spec-lifetime spec) 'buffer-runtime)))
(ebox-cache-reset-default-registry)))
(ert-deftest ebox-cache-invalidates-by-dirty-kind ()
"Dirty facts should map to the cache specs they invalidate."
(unwind-protect
(progn
(ebox-cache-clear-registry)
(ebox-cache-register
:name 'render-body
:owner 'ebox-buffer-backend
:scope 'buffer
:key-deps '(fragment-signature paint-signature backend-display-signature)
:invalidates '(paint geometry structure backend-display)
:lifetime 'buffer-runtime
:value-owner 'ebox-buffer-backend)
(ebox-cache-register
:name 'layout-fragment
:owner 'ebox-layout
:scope 'formatting-context
:key-deps '(node-id computed-style-signature child-signatures available-size)
:invalidates '(geometry structure viewport)
:lifetime 'buffer-runtime
:value-owner 'ebox-layout)
(should (equal (sort (ebox-cache-invalidated-spec-names '(paint))
#'string<)
'(render-body)))
(should (equal (sort (ebox-cache-invalidated-spec-names '(geometry))
#'string<)
'(layout-fragment render-body))))
(ebox-cache-reset-default-registry)))
(ert-deftest ebox-render-cache-retains-distinct-viewport-widths ()
"Returning to a warm width should not evict its prior rendered value."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-cache-report-buffer nil)
(width-a '(display-a 720 42 body))
(width-b '(display-a 900 42 body)))
(ebox--render-cache-store 'responsive width-a 'rendered-a)
(ebox--render-cache-store 'responsive width-b 'rendered-b)
(should (eq (ebox--render-cache-lookup 'responsive width-a)
'rendered-a))
(should (eq (ebox--render-cache-lookup 'responsive width-b)
'rendered-b))))
(ert-deftest ebox-render-cache-retains-distinct-box-width-signatures ()
"Ordinary box-width variants should coexist under one cache key."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-cache-report-buffer nil)
(width-a '(display-a nil 42 (:width 720)))
(width-b '(display-a nil 42 (:width 900))))
(ebox--render-cache-store 'responsive width-a 'rendered-a)
(ebox--render-cache-store 'responsive width-b 'rendered-b)
(should (eq (ebox--render-cache-lookup 'responsive width-a)
'rendered-a))
(should (eq (ebox--render-cache-lookup 'responsive width-b)
'rendered-b))))
(ert-deftest ebox-render-cache-distinguishes-content-text-properties ()
"Equal characters with different properties must not share rendered output."
(let* ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-cache-report-buffer nil)
(normal
(list 'display-a nil 42
(list :content
(propertize "Same" 'face 'default))))
(selected
(list 'display-a nil 42
(list :content
(propertize "Same" 'face 'highlight)))))
(ebox--render-cache-store 'cell normal 'rendered-normal)
(should-not (ebox--render-cache-lookup 'cell selected))
(ebox--render-cache-store 'cell selected 'rendered-selected)
(should (eq (ebox--render-cache-lookup 'cell selected)
'rendered-selected))))
(ert-deftest ebox-render-cache-evicts-oldest-entry-at-capacity ()
"A full cache should retain newer width variants without unbounded growth."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 2)
(ebox-cache-report-buffer nil))
(ebox--render-cache-store 'responsive 'width-a 'rendered-a)
(ebox--render-cache-store 'responsive 'width-b 'rendered-b)
(ebox--render-cache-store 'responsive 'width-c 'rendered-c)
(should (= (hash-table-count ebox--render-cache-table) 2))
(should-not (ebox--render-cache-lookup 'responsive 'width-a))
(should (eq (ebox--render-cache-lookup 'responsive 'width-b)
'rendered-b))
(should (eq (ebox--render-cache-lookup 'responsive 'width-c)
'rendered-c))))
(ert-deftest ebox-render-cache-evicts-oldest-entry-at-byte-budget ()
"Large rendered values should be bounded even below the entry limit."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 16)
(ebox-render-cache-max-bytes 400)
(ebox-cache-report-buffer nil))
(ebox--render-cache-store 'responsive 'width-a "aa")
(ebox--render-cache-store 'responsive 'width-b "bb")
(should-not (ebox--render-cache-lookup 'responsive 'width-a))
(should (equal (ebox--render-cache-lookup 'responsive 'width-b) "bb"))
(should (= (hash-table-count ebox--render-cache-table) 1))))
(ert-deftest ebox-render-cache-does-not-retain-one-oversized-value ()
"One value above the byte budget should be returned but not retained."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 16)
(ebox-render-cache-max-bytes 128)
(ebox-cache-report-buffer nil))
(should (equal (ebox--render-cache-store
'responsive 'width-a "oversized")
"oversized"))
(should (= (hash-table-count ebox--render-cache-table) 0))
(should-not (ebox--render-cache-lookup 'responsive 'width-a))))
(ert-deftest ebox-render-cache-oversized-store-preserves-healthy-entries ()
"One oversized store must not purge unrelated retained entries."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 16)
(ebox-render-cache-max-bytes 1024)
(ebox-cache-report-buffer nil))
(ebox--render-cache-store 'responsive 'width-a "aa")
(ebox--render-cache-store 'responsive 'width-b "bb")
(should (equal (ebox--render-cache-store
'responsive 'width-oversized (make-string 64 ?x))
(make-string 64 ?x)))
(should-not (ebox--render-cache-lookup 'responsive 'width-oversized))
(should (equal (ebox--render-cache-lookup 'responsive 'width-a) "aa"))
(should (equal (ebox--render-cache-lookup 'responsive 'width-b) "bb"))
(should (= (hash-table-count ebox--render-cache-table) 2))))
(ert-deftest ebox-render-cache-oversized-update-releases-only-that-entry ()
"Growing one entry above the budget must release only that entry."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 16)
(ebox-render-cache-max-bytes 1024)
(ebox-cache-report-buffer nil))
(ebox--render-cache-store 'responsive 'width-a "aa")
(ebox--render-cache-store 'responsive 'width-b "bb")
(ebox--render-cache-store 'responsive 'width-b (make-string 64 ?x))
(should-not (ebox--render-cache-lookup 'responsive 'width-b))
(should (equal (ebox--render-cache-lookup 'responsive 'width-a) "aa"))
(should (= (hash-table-count ebox--render-cache-table) 1))))
(ert-deftest ebox-render-cache-retains-distinct-sxhash-collisions ()
"Distinct signatures with one bounded hash must retain separate entries."
(let* ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 16)
(ebox-cache-report-buffer nil)
(signature-a '(same (same (same (same "a")))))
(signature-b '(same (same (same (same "b"))))))
(should-not (equal signature-a signature-b))
(should (= (sxhash-equal signature-a) (sxhash-equal signature-b)))
(ebox--render-cache-store 'page signature-a 'rendered-a)
(ebox--render-cache-store 'page signature-b 'rendered-b)
(should (= (hash-table-count ebox--render-cache-table) 2))
(should (eq (ebox--render-cache-lookup 'page signature-a) 'rendered-a))
(should (eq (ebox--render-cache-lookup 'page signature-b) 'rendered-b))))
(ert-deftest ebox-render-cache-byte-budget-includes-scroll-side-effects ()
"Scroll-state metadata must not bypass the retained-byte budget."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 16)
(ebox-render-cache-max-bytes 1024)
(ebox-cache-report-buffer nil)
(side-effects
(list :scroll-actions
(list
(list 'set 'scroll
(list :content-lines
(list (make-string 4096 ?x)))))
:stateful-p t
:restorable-p t)))
(should (equal (ebox--render-cache-store
'responsive 'width-a "x" side-effects)
"x"))
(should (= (hash-table-count ebox--render-cache-table) 0))
(should-not (ebox--render-cache-lookup 'responsive 'width-a))))
(ert-deftest ebox-render-cache-reuses-portable-scroll-state-in-candidate-tree ()
"Portable scroll templates rebind complete state to the candidate box."
(ebox-test--reset-runtime-state)
(let* ((buffer (generate-new-buffer " *ebox-portable-scroll-cache*"))
(old-box (ebox-test-box (ebox-test-text "old") :scroll-offset 0))
(old-node (ebox-test-root old-box))
(node-id (ebox--ensure-node-id old-node))
(candidate-box (ebox-test-box (ebox-test-text "candidate") :scroll-offset 0))
(candidate-node (ebox-test-root candidate-box))
(node-table (make-hash-table :test 'eql))
(template
(list :box old-node
:content-lines '("alpha" "beta")
:rendered-content-lines '("alpha")
:content-height 1
:scroll-offset 0))
(actions (list (list 'set 'scroll template)))
(entry (cons 'signature "rendered"))
(metadata
(list :scroll-actions actions
:stateful-p t
:portable-p t
:restorable-p t))
(ebox--render-cache-scroll-state-restorable-p nil))
(plist-put candidate-node :node-id node-id)
(puthash node-id candidate-node node-table)
(puthash buffer (list :node-table node-table)
ebox--buffer-render-state-table)
(puthash entry metadata ebox--render-cache-entry-side-effects-table)
(unwind-protect
(with-current-buffer buffer
(should (ebox--scroll-cache-actions-portable-p actions))
(should (ebox--render-cache-entry-reusable-p entry))
(ebox--render-cache-replay-entry-side-effects entry)
(let ((state (ebox--scroll-get-state 'scroll)))
(should (eq (plist-get state :box) candidate-node))
(should (equal (plist-get state :content-lines)
'("alpha" "beta")))
(should (equal (plist-get state :rendered-content-lines)
'("alpha")))))
(ebox--scroll-clear-state 'scroll)
(remhash entry ebox--render-cache-entry-side-effects-table)
(remhash buffer ebox--buffer-render-state-table)
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-render-cache-rejects-nonportable-candidate-scroll-state ()
"Candidate publication must not replay closures owned by the old tree."
(let* ((prefix-renderer (lambda (_count) '("deferred")))
(materializer (lambda () '("materialized")))
(actions
(list
(list 'set 'scroll
(list :box (ebox-test-box (ebox-test-text "old") :scroll-offset 0)
:content-lines '("alpha")
:render-content-prefix prefix-renderer
:materialize-content-lines materializer))))
(ebox--render-cache-scroll-state-restorable-p nil)
(entry (cons 'signature "rendered"))
(metadata
(list :scroll-actions actions
:stateful-p t
:portable-p nil
:restorable-p t)))
(puthash entry metadata ebox--render-cache-entry-side-effects-table)
(unwind-protect
(progn
(should-not (ebox--scroll-cache-actions-portable-p actions))
(should-not (ebox--render-cache-entry-reusable-p entry)))
(remhash entry ebox--render-cache-entry-side-effects-table))))
(ert-deftest ebox-render-cache-ring-recovers-after-clrhash ()
"An exceptional cache clear should not leave stale eviction metadata."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 2)
(ebox-cache-report-buffer nil))
(ebox--render-cache-store 'responsive 'width-a 'rendered-a)
(ebox--render-cache-store 'responsive 'width-b 'rendered-b)
(clrhash ebox--render-cache-table)
(ebox--render-cache-store 'responsive 'width-c 'rendered-c)
(ebox--render-cache-store 'responsive 'width-d 'rendered-d)
(ebox--render-cache-store 'responsive 'width-e 'rendered-e)
(should (= (hash-table-count ebox--render-cache-table) 2))
(should-not (ebox--render-cache-lookup 'responsive 'width-c))
(should (eq (ebox--render-cache-lookup 'responsive 'width-d)
'rendered-d))
(should (eq (ebox--render-cache-lookup 'responsive 'width-e)
'rendered-e))))
(ert-deftest ebox-render-cache-ring-retains-newest-entries-when-limit-shrinks ()
"Lowering the cache limit should retain the newest insertion-order entries."
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox-render-cache-max-entries 3)
(ebox-cache-report-buffer nil))
(ebox--render-cache-store 'responsive 'width-a 'rendered-a)
(ebox--render-cache-store 'responsive 'width-b 'rendered-b)
(ebox--render-cache-store 'responsive 'width-c 'rendered-c)
(setq ebox-render-cache-max-entries 2)
(ebox--render-cache-store 'responsive 'width-d 'rendered-d)
(should (= (hash-table-count ebox--render-cache-table) 2))
(should-not (ebox--render-cache-lookup 'responsive 'width-a))
(should-not (ebox--render-cache-lookup 'responsive 'width-b))
(should (eq (ebox--render-cache-lookup 'responsive 'width-c)
'rendered-c))
(should (eq (ebox--render-cache-lookup 'responsive 'width-d)
'rendered-d))))
(ert-deftest ebox-buffer-render-cache-stays-bounded-across-unique-widths ()
"Continuous resize should not retain an unbounded set of rendered widths."
(ebox-test--reset-runtime-state)
(let* ((ebox-render-cache-max-entries 32)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(layout
(ebox-test-box :id "root" :width '(viewport)
(apply #'ebox-test-flex
(append (list :width '(viewport) :gap 1)
(cl-loop for index below 8 collect
(ebox-test-box :id (format "card-%d" index)
(ebox-test-text (format "card %d" index)) :width
72 :height 1))))))
buffer)
(unwind-protect
(let ((ebox-viewport-width 240))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-cache-bound*") layout))
(cl-loop for width from 241 to 280
do (ebox-rerender-buffer-with-context buffer width))
(let ((cache (ebox--buffer-render-cache buffer)))
(should (<= (hash-table-count cache)
ebox-render-cache-max-entries)))
(with-current-buffer buffer
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-returning-width-cache-restores-lazy-scroll-context ()
"A cached subtree must restore lazy scroll state for its render context."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-column :id "viewport" :width '(viewport)
(ebox-test-box :id "scroll" :width '(viewport)
:height 4 :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for index below 32 collect
(ebox-test-box (ebox-test-text (format "cached row %02d" index))
:width '(viewport) :height 1)))))))
buffer
scroll-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 6)
;; Evict the first complete root so the return leg renders the
;; root while retaining its generic cached scroll subtree.
(ebox-render-root-cache-max-entries 1)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-scroll-cache-context*")
layout))
(setq scroll-id
(ebox-test--selector-region-id buffer "#scroll"))
(ebox-rerender-buffer-with-context buffer 320 6)
(ebox-rerender-buffer-with-context buffer 240 6)
(let ((state (ebox--scroll-get-state scroll-id)))
(should state)
(should (= (ebox--string-pixel-width
(car (plist-get state :rendered-content-lines)))
240))
(dotimes (_ 16)
(setq state
(ebox--scroll-state-ensure-prefix-lines
scroll-id state
(1+ (length (plist-get state :content-lines)))
t))))
(with-current-buffer buffer
(dotimes (_ 6)
(should (= (ebox--scroll-region-by scroll-id 1) 1)))
(let ((scrolled (buffer-string)))
(ebox-rerender-buffer-with-context buffer 240 6)
(should (equal-including-properties
scrolled (buffer-string))))))
(when scroll-id
(ebox--scroll-cancel-idle-prefetch scroll-id))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-prepared-root-render-is-exact-one-shot-fast-path ()
"An exact prepared root should bypass layout once and never affect children."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(240)
(ebox-test-box :id "child" (ebox-test-text "prepared") :height
1)))
buffer)
(unwind-protect
(let ((ebox-viewport-width 900)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prepared-root*") layout))
(let* ((state (ebox--buffer-render-state buffer))
(root (plist-get state :root-node))
(child (ebox--buffer-region-render-owner-node
buffer
(ebox-test--selector-region-id buffer "#child")))
(rendered (propertize "prepared-root" 'face 'bold))
(prepared
(list :rendered rendered
:render-state state
:node-id (ebox--ensure-node-id root)
:runtime-revision 0
:display-signature
(with-current-buffer buffer
(ebox-display-signature))
:kind 'root-width
:region-id
(ebox-test--selector-region-id buffer "#root")
:root-width 240
:viewport-width 900
:viewport-height 4)))
(with-current-buffer buffer
(ebox--with-buffer-render-context buffer
(let ((ebox--prepared-root-render prepared))
;; A local subtree lookup must not inspect or consume the
;; root-only handoff.
(ebox--render-with-cache child)
(should-not (plist-get prepared :consumed-p))
(cl-letf (((symbol-function 'ebox-render)
(lambda (&rest _)
(ert-fail "Exact prepared root reran layout"))))
(should (eq (ebox--render-with-cache root t) rendered)))
(should (plist-get prepared :consumed-p))
(should-not ebox--prepared-root-render))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-prepared-root-render-rejects-stale-runtime ()
"A stale prepared root should fall back without entering later updates."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :width '(240) (ebox-test-text "old")))
buffer
(fallback-count 0))
(unwind-protect
(let ((ebox-viewport-width 900)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-stale-prepared-root*")
layout))
(let* ((state (ebox--buffer-render-state buffer))
(root (plist-get state :root-node))
(prepared
(list :rendered "stale"
:render-state state
:node-id (ebox--ensure-node-id root)
:runtime-revision 1
:display-signature
(with-current-buffer buffer
(ebox-display-signature))
:kind 'root-width
:region-id
(ebox-test--selector-region-id buffer "#root")
:root-width 240
:viewport-width 900
:viewport-height 4)))
(with-current-buffer buffer
(ebox--with-buffer-render-context buffer
(let ((ebox--prepared-root-render prepared)
(ebox--render-cache-table
(make-hash-table :test 'equal)))
(cl-letf (((symbol-function 'ebox--render-node)
(lambda (&rest _)
(cl-incf fallback-count)
"fresh")))
(should (equal (ebox--render-with-cache root t) "fresh")))
(should (= fallback-count 1))
(should-not (plist-get prepared :consumed-p))
(should-not ebox--prepared-root-render))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-prewarm-reuses-live-isolated-scratch ()
"Viewport predictions should reuse one nonmutating isolated live tree."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport)
(ebox-test-box (ebox-test-text "viewport scratch") :width
'(viewport) :height 1)))
buffer)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-viewport-scratch*") layout))
(let* ((state (ebox--buffer-render-state buffer))
(live-root (plist-get state :root-node))
scratch tree)
(should
(ebox--prewarm-buffer-render-cache-at-context
buffer state 0 240 260 4))
(setq scratch (plist-get state :reflow-prewarm-scratch)
tree (plist-get scratch :tree))
(should scratch)
(should-not (eq tree live-root))
(should
(ebox--prewarm-buffer-render-cache-at-context
buffer state 0 240 280 4))
(should (eq tree
(plist-get
(plist-get state :reflow-prewarm-scratch) :tree)))
(should (eq live-root (plist-get state :root-node)))
(should-not (plist-get scratch :predicted-revision))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-root-width-cache-prewarm-yields-to-user-input ()
"Ordinary root prediction should run only after an idle interval."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(240)
(ebox-test-box (ebox-test-text "scheduled ordinary root")
:height 1)))
buffer
scheduled-delay
scheduled-repeat
scheduled-function
scheduled-arguments)
(unwind-protect
(let ((ebox-viewport-width 900)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm t)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-root-schedule*")
layout))
(let ((root-id (ebox-test--selector-region-id buffer "#root")))
(cl-letf (((symbol-function 'run-at-time)
(lambda (&rest _)
(ert-fail "Resize prediction must yield to input")))
((symbol-function 'run-with-idle-timer)
(lambda (delay repeat function &rest arguments)
(setq scheduled-delay delay
scheduled-repeat repeat
scheduled-function function
scheduled-arguments arguments)
'scheduled-reflow-timer)))
(ebox--schedule-buffer-root-width-cache-prewarm
buffer root-id 240 260)))
(should (= scheduled-delay
ebox-runtime-idle-reflow-cache-prewarm-delay))
(should-not scheduled-repeat)
(should (eq scheduled-function
#'ebox--run-root-width-cache-prewarm))
(should (equal (car scheduled-arguments) buffer))
(should (eq (gethash buffer ebox--reflow-cache-prewarm-timers)
'scheduled-reflow-timer)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-scheduled-reflow-prewarms-use-offscreen-gc-pressure ()
"Both predicted reflow paths should move GC pressure off visible frames."
(let ((prewarm-count 0)
(gc-count 0)
observed)
(cl-letf (((symbol-function
'ebox--prewarm-buffer-render-cache-at-root-width)
(lambda (&rest _)
(cl-incf prewarm-count)
(push (list gc-cons-threshold gc-cons-percentage
ebox-render-gc-cons-threshold
ebox-render-gc-cons-percentage)
observed)
t))
((symbol-function
'ebox--prewarm-buffer-render-cache-at-context)
(lambda (&rest _)
(cl-incf prewarm-count)
(push (list gc-cons-threshold gc-cons-percentage
ebox-render-gc-cons-threshold
ebox-render-gc-cons-percentage)
observed)
t))
((symbol-function 'garbage-collect)
(lambda () (cl-incf gc-count))))
(let ((ebox-reflow-cache-prewarm-gc-cons-threshold 123456)
(ebox-reflow-cache-prewarm-gc-cons-percentage 0.2))
(ebox--run-root-width-cache-prewarm
nil nil 0 'root 300 315)
(ebox--run-reflow-cache-prewarm
nil nil 0 300 315 20)))
(should (= prewarm-count 2))
(should (= gc-count 0))
(should (equal observed
'((123456 0.2 123456 0.2)
(123456 0.2 123456 0.2))))))
(ert-deftest ebox-reflow-prewarm-gc-adapts-to-measured-layout-cost ()
"Automatic prewarm GC should follow measured allocation and layout time."
(let ((ebox--physical-memory-bytes (* 16 1024 1024 1024))
(ebox-reflow-cache-prewarm-gc-auto-frame-budget 0.2)
(ebox-reflow-cache-prewarm-gc-auto-min-threshold (* 64 1024 1024))
(ebox-reflow-cache-prewarm-gc-auto-max-threshold (* 1024 1024 1024))
(ebox-reflow-cache-prewarm-gc-auto-target-layouts 24))
(should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation
(* 28 1024 1024) 0.16)
(* 672 1024 1024)))
(should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation
(* 8 1024 1024) 0.08)
(* 128 1024 1024)))
(should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation
(* 2 1024 1024) 0.02)
(* 64 1024 1024)))
(should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation
(* 64 1024 1024) 0.21)
(* 1024 1024 1024)))
(should (= (ebox--memory-use-counts-estimated-bytes
'(0 0 0 0 0 0 0) '(1 1 1 1 1 1 1))
161))
(let ((state (list :runtime-revision 0)))
(ebox--record-reflow-cache-prewarm-cost
state 0.16 0 (* 512 1024 1024) (* 28 1024 1024))
(should (= (plist-get state :reflow-prewarm-gc-threshold)
(* 672 1024 1024)))
(ebox--record-reflow-cache-prewarm-cost
state 0.5 1 (* 672 1024 1024) (* 28 1024 1024))
(should (= (plist-get state :reflow-prewarm-gc-threshold)
(* 672 1024 1024)))
(should (= (plist-get state :reflow-prewarm-layout-seconds) 0.16)))))
(ert-deftest ebox-buffer-backend-maps-paint-style-to-text-properties ()
"Paint mapping should be a backend operation, not a style-model operation."
(let* ((style (ebox-style-compute
(list :color "#111111"
:background-color "#eeeeee"
:border-color "#333333")))
(props (ebox-buffer-paint-text-properties style 'content)))
(should props)
(should (plist-member props 'face))))
(ert-deftest ebox-text-decoration-projects-to-one-emacs-face ()
"Canonical decoration facts should remain paint-only and visibly compose."
(let* ((style
(ebox-style-compute
'(:text-decoration-line (underline overline line-through)
:text-decoration-color "#336699"
:text-decoration-style wavy)))
(face (plist-get
(ebox-buffer-paint-text-properties style 'content) 'face))
(rendered
(ebox-render
(ebox-test-text
"Decorated"
:text-decoration-line '(underline overline line-through)
:text-decoration-color "#336699"
:text-decoration-style 'wavy)))
(rendered-face (get-text-property 0 'face rendered)))
(dolist (candidate (list face rendered-face))
(should (ebox-test--face-has-key-value-p
candidate :underline '(:color "#336699" :style wave)))
(should (ebox-test--face-has-key-value-p
candidate :overline "#336699"))
(should (ebox-test--face-has-key-value-p
candidate :strike-through "#336699")))))
(ert-deftest ebox-render-keeps-tp-paint-slot-address-stable ()
"A TP paint slot should recolor retained output without property rewrites."
(let* ((slot (tp-paint-slot-create '(:foreground "red")))
(face (tp-paint-slot-face slot))
(rendered (ebox-render (ebox-test-box (ebox-test-text "paint") :color slot)))
(property (get-text-property 0 'face rendered)))
(should (eq face property))
(tp-paint-slot-update slot '(:foreground "blue"))
(should (eq property (get-text-property 0 'face rendered)))
(should (equal "blue" (face-attribute face :foreground nil nil)))))
(ert-deftest ebox-buffer-backend-does-not-map-layout-only-properties ()
"Layout-only properties should not produce text properties directly."
(let* ((style (ebox-style-compute
(list :width '(120)
:padding '(1 2))))
(props (ebox-buffer-paint-text-properties style 'content)))
(should-not (memq :width props))
(should-not (memq :padding-inline-start props))))
(ert-deftest ebox-add-content-owner-ignores-paint-only-segment-changes ()
"Owner stacking should segment only when owner metadata changes."
(let ((line (copy-sequence "abcdefgh"))
(stack-lookups 0)
(original-stack-at (symbol-function 'ebox--content-owner-stack-at)))
(add-text-properties 0 4 '(ebox-content-owner child-a) line)
(add-text-properties 4 8 '(ebox-content-owner child-b) line)
(add-text-properties 1 2 '(face (:foreground "#111111")) line)
(add-text-properties 2 3 '(display (space :width (3))) line)
(add-text-properties 5 6 '(face (:background "#eeeeee")) line)
(cl-letf (((symbol-function 'ebox--content-owner-stack-at)
(lambda (&rest args)
(cl-incf stack-lookups)
(apply original-stack-at args))))
(let ((owned (ebox--add-content-owner line 'root)))
(should (= stack-lookups 2))
(should (equal '(child-a root)
(get-text-property 0 'ebox-content-owners owned)))
(should (equal '(child-b root)
(get-text-property 4 'ebox-content-owners owned)))))))
(ert-deftest ebox-add-content-owner-scans-complete-owner-stacks-once ()
"Complete owner stacks should not require a parallel owner-property scan."
(let ((line (copy-sequence "abcdefgh"))
(property-scans 0)
(original-next (symbol-function 'next-single-property-change)))
(add-text-properties
0 4 '(ebox-content-owner child-a ebox-content-owners (child-a)) line)
(add-text-properties
4 8 '(ebox-content-owner child-b ebox-content-owners (child-b)) line)
(cl-letf (((symbol-function 'next-single-property-change)
(lambda (position property &rest args)
(when (memq property
'(ebox-content-owner ebox-content-owners))
(cl-incf property-scans))
(apply original-next position property args))))
(let ((owned (ebox--add-content-owner line 'root)))
(should (= property-scans 2))
(should (equal '(child-a root)
(get-text-property 0 'ebox-content-owners owned)))
(should (equal '(child-b root)
(get-text-property 4 'ebox-content-owners owned)))))))
(ert-deftest ebox-add-content-owners-merges-all-owners-in-one-scan ()
"Adding several owners should scan each original owner run only once."
(let ((line (copy-sequence "abcdefgh"))
(property-scans 0)
(original-next (symbol-function 'next-single-property-change)))
(add-text-properties 0 4 '(ebox-content-owner child-a) line)
(add-text-properties 4 8 '(ebox-content-owner child-b) line)
(cl-letf (((symbol-function 'next-single-property-change)
(lambda (position property &rest args)
(when (memq property
'(ebox-content-owner ebox-content-owners))
(cl-incf property-scans))
(apply original-next position property args))))
(let ((owned (ebox--add-content-owners line '(middle root))))
(should (= property-scans 4))
(should (eq (get-text-property 0 'ebox-content-owner owned) 'root))
(should (equal '(child-a middle root)
(get-text-property 0 'ebox-content-owners owned)))
(should (equal '(child-b middle root)
(get-text-property 4 'ebox-content-owners owned)))))))
(ert-deftest ebox-propertize-content-line-can-own-private-line-in-place ()
"A private split line should accept wrapper ownership without another copy."
(let* ((source (propertize "content"
'ebox-content 'child
'ebox-content-owner 'child
'ebox-content-owners '(child)))
(source-before (copy-sequence source))
(private-line (copy-sequence source))
(regular
(ebox--propertize-content-line source 'wrapper 0 "fallback"))
(owned
(let ((ebox--propertize-private-content-line-p t))
(ebox--propertize-content-line
private-line 'wrapper 0 "fallback"))))
(should-not (eq regular source))
(should (equal-including-properties source source-before))
(should (eq owned private-line))
(should (eq (get-text-property 0 'ebox-content-owner owned) 'wrapper))
(should (equal (get-text-property 0 'ebox-content-owners owned)
'(child wrapper)))))
(ert-deftest ebox-scroll-rendered-content-lines-preserve-text-paint-in-place ()
"Scroll ownership should preserve Text paint and add Box background."
(let* ((box (ebox-test-box :width 120 :bgcolor "#F4F5F6"))
(ebox--render-source-index (ebox-test-source-index box))
(region-id 901)
(source (propertize "child" 'ebox-content 77
'ebox-content-owner 77
'ebox-content-owners '(77)
'face '(:foreground "#112233")))
(source-before (copy-sequence source))
(filler (ebox-pixel-space
(ebox-test--input-content-pixel box)))
(expected
(ebox--propertize-colors
(ebox--propertize-content-line source region-id 4 filler)
nil
(ebox-style-node-specified-value box :bgcolor)))
(copying-owner-count 0)
(original-copying-owner
(symbol-function 'ebox--add-content-owner))
actual)
(cl-letf (((symbol-function 'ebox--add-content-owner)
(lambda (&rest args)
(cl-incf copying-owner-count)
(apply original-copying-owner args))))
(setq actual
(car (ebox--scroll-rendered-content-lines
(ebox-test-root box) (list source) region-id 4))))
(should (equal-including-properties source source-before))
(should (equal-including-properties actual expected))
(should (= copying-owner-count 0))))
(ert-deftest ebox-box-render-coalesces-foreground-background-pass ()
"Foreground and background should not each copy the rendered box lines."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "paint")
:width 12 :height 1
:color "#112233"
:bgcolor "#ddeeff"))
(reference (ebox-render box))
(original-color (symbol-function 'ebox--propertize-color))
(original-bgcolor (symbol-function 'ebox--propertize-bgcolor))
(color-calls 0)
(bgcolor-calls 0)
actual)
(cl-letf (((symbol-function 'ebox--propertize-color)
(lambda (&rest args)
(cl-incf color-calls)
(apply original-color args)))
((symbol-function 'ebox--propertize-bgcolor)
(lambda (&rest args)
(cl-incf bgcolor-calls)
(apply original-bgcolor args))))
(setq actual (ebox-render box)))
(should (= color-calls 0))
(should (= bgcolor-calls 0))
(should (equal-including-properties actual reference))))
(ert-deftest ebox-incremental-update-scheduler-batches-multiple-style-changes ()
"Scheduler seed should coalesce multiple property changes into one flush."
(let* ((node (ebox-test-box (ebox-test-text "Batch") :border-color "#111111"))
(id (car (ebox-region-ids node))))
(ebox-test--with-rendered-buffer node
(ebox-incremental-begin-batch (current-buffer))
(ebox-test--region-update id :border-color "#222222")
(ebox-test--region-update id :background-color "#eeeeee")
(should (= (ebox-incremental-pending-count (current-buffer)) 2))
(ebox-incremental-flush (current-buffer))
(let ((report (ebox-test--buffer-update-report)))
(should (= (plist-get report :flush-count) 1))
(should (= (plist-get report :patch-count) 1))
(should (member 'paint (plist-get report :dirty-kinds)))))))
(ert-deftest ebox-explicit-batch-keeps-descendant-paint-after-ancestor-paint ()
"Ancestor and child paint must execute general-to-specific in any input order."
(dolist (order '((parent child) (child parent)))
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id 'parent :width '(120) :color "black"
(ebox-test-box :id 'child (ebox-test-text "Child") :width
'(120) :color "black"))))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(parent-id (ebox-test--selector-region-id buffer "#parent"))
(child-id (ebox-test--selector-region-id buffer "#child")))
(ebox-incremental-begin-batch buffer)
(dolist (operation order)
(pcase operation
('parent (ebox-test--region-update parent-id :color "red"))
('child (ebox-test--region-update child-id :color "blue"))))
(ebox-incremental-flush buffer))
(goto-char (point-min))
(search-forward "Child")
(let ((face (get-text-property (- (point) (length "Child")) 'face)))
(should (ebox-test--face-has-key-value-p
face :foreground "blue")))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-explicit-batch-paints-before-descendant-text-change ()
"Ancestor paint must run before a descendant text-changing operation."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id 'parent :width '(160) :color "black"
(ebox-test-box :id 'child (ebox-test-text "Child") :width
'(120) :color "blue"))))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(parent-id (ebox-test--selector-region-id buffer "#parent"))
(child-id (ebox-test--selector-region-id buffer "#child")))
(ebox-incremental-begin-batch buffer)
(ebox-test--region-update parent-id :color "red")
(ebox-test--region-update child-id :width '(80))
(ebox-incremental-flush buffer))
(goto-char (point-min))
(search-forward "Child")
(let ((face (get-text-property (- (point) (length "Child")) 'face)))
(should (ebox-test--face-has-key-value-p
face :foreground "blue")))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(defun ebox-test--multi-row-batch-flex-layout ()
"Return a wrapped flex fixture with targets on separate rows."
(ebox-test-flex :id 'matrix :width '(500) :flex-flow '(row wrap)
:column-gap '(12) :row-gap 1 :padding '(0 (12))
:border "#999999"
(ebox-test-column :width '(210) :border "#999999"
(ebox-test-box (ebox-test-text "Title A")
:width '(190))
(ebox-test-box :id 'target-a
(ebox-test-text "Target A")
:width '(160) :height
3 :padding '(0 (8))
:border "#999999"
:text-align 'right)
(ebox-test-box (ebox-test-text "Metric A")
:width '(190) :height
2))
(ebox-test-column :width '(210) :border "#999999"
(ebox-test-box (ebox-test-text "Peer A")
:width '(190))
(ebox-test-box (ebox-test-text "Same first row")
:width '(190) :height
3)
(ebox-test-box (ebox-test-text "Metric B")
:width '(190) :height
2))
(ebox-test-column :width '(210) :border "#999999"
(ebox-test-box (ebox-test-text "Title B")
:width '(190))
(ebox-test-box :id 'target-b
(ebox-test-text "Target B")
:width '(160) :height
3 :padding '(0 (8))
:border "#999999"
:text-align 'right)
(ebox-test-box (ebox-test-text "Metric C")
:width '(190) :height
2))
(ebox-test-column :width '(210) :border "#999999"
(ebox-test-box (ebox-test-text "Peer B")
:width '(190))
(ebox-test-box (ebox-test-text "Same second row")
:width '(190) :height
3)
(ebox-test-box (ebox-test-text "Metric D")
:width '(190) :height
2))))
(ert-deftest ebox-explicit-batch-multiple-flex-rows-skip-line-fast-path ()
"Dirty descendants on separate flex rows must rerender in either order."
(dolist (order '((target-a target-b) (target-b target-a)))
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--multi-row-batch-flex-layout)))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(target-a-id
(ebox-test--selector-region-id buffer "#target-a"))
(target-b-id
(ebox-test--selector-region-id buffer "#target-b")))
(ebox-incremental-begin-batch buffer)
(dolist (target order)
(ebox-test--region-update
(if (eq target 'target-a) target-a-id target-b-id)
:width '(210) :min-width '(150) :max-width '(260)))
(ebox-incremental-flush buffer))
(let ((report (ebox-test--buffer-update-report)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(should-not (plist-get report :flex-line-rerender)))))))
(ert-deftest ebox-explicit-batch-absorbed-flex-descendant-keeps-provenance ()
"An absorbed descendant update must disable a partial flex owner rerender."
(dolist (order '((geometry paint) (paint geometry)))
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--multi-row-batch-flex-layout)))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(target-a-id
(ebox-test--selector-region-id buffer "#target-a"))
(target-b-id
(ebox-test--selector-region-id buffer "#target-b")))
(ebox-incremental-begin-batch buffer)
(dolist (operation order)
(pcase operation
('geometry
(ebox-test--region-update target-a-id
:width '(210)
:min-width '(150)
:max-width '(260)))
('paint
(ebox-test--region-update target-b-id :color "blue"))))
(ebox-incremental-flush buffer))
(goto-char (point-min))
(search-forward "Target B")
(let* ((start (- (point) (length "Target B")))
(face (get-text-property start 'face))
(report (ebox-test--buffer-update-report)))
(should (ebox-test--face-has-key-value-p
face :foreground "blue"))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(should-not (plist-get report :flex-line-rerender)))))))
(ert-deftest ebox-layout-module-renders-row-and-column-through-facade ()
"Facade rendering should still use extracted layout owners."
(let ((rendered (ebox-render
(ebox-test-column
(ebox-test-row (ebox-test-box (ebox-test-text "A") :width '(40))
(ebox-test-box (ebox-test-text "B") :width '(40)))))))
(should (string-match-p "A" rendered))
(should (string-match-p "B" rendered))))
(ert-deftest ebox-layout-snapshot-details-include-footprint-signatures ()
"Detailed snapshots should expose explicit L2 footprint signatures."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Footprint") :width '(120)
:height 1 :border '(1 solid)))
(node-id (ebox--ensure-node-id (ebox-test-root box))))
(ebox-test--with-rendered-buffer box
(let* ((snapshot (ebox--ensure-layout-snapshot-details
(current-buffer) node-id))
(span-footprint
(plist-get snapshot :span-footprint-signature))
(external-footprint
(plist-get snapshot :external-footprint-signature))
(parent-slot
(plist-get snapshot :parent-slot-signature))
(role-topology
(plist-get snapshot :role-topology-signature))
(overflow
(plist-get snapshot :overflow-signature)))
(should span-footprint)
(should external-footprint)
(should parent-slot)
(should role-topology)
(should overflow)
(should (= (plist-get span-footprint :span-count) 1))
(should (equal (plist-get span-footprint :line-pixel-widths)
(plist-get snapshot :line-signature)))
(should (= (plist-get external-footprint :block-lines) 1))
(should (= (plist-get external-footprint :max-line-pixel-width)
(car (plist-get snapshot :line-signature))))
(should (= (plist-get parent-slot :slot-count) 1))
(should (memq 'content (plist-get role-topology :roles)))
(should-not (plist-get overflow :visible))))))
(ert-deftest ebox-layout-snapshot-capture-skips-line-ownership-scan ()
"Initial snapshot capture should not scan line ownership for every node."
(ebox-test--reset-runtime-state)
(let* ((layout (apply
#'ebox-test-column
(cl-loop for index below 8
collect
(ebox-test-flex
:border "#64748B" :bgcolor "#F8FAFC"
:flex-flow '(row wrap)
(ebox-test-box
(ebox-test-text (format "A%d" index))
:width 60 :bgcolor "#DBEAFE")
(ebox-test-box
(ebox-test-text (format "B%d" index))
:width 60 :bgcolor "#DCFCE7")
(ebox-test-box
(ebox-test-text (format "C%d" index))
:width 60 :bgcolor "#FEF3C7")))))
(calls 0)
(original (symbol-function 'ebox--pos-region-ids))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 900))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(ebox--clear-layout-snapshots buffer)
(cl-letf (((symbol-function 'ebox--pos-region-ids)
(lambda (&rest args)
(cl-incf calls)
(apply original args))))
(ebox--refresh-buffer-layout-snapshots buffer))
(should (= calls 0)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-stack-render-flattens-before-width-measurement ()
"Column rendering should not repeatedly measure already stacked strings."
(ebox-test--reset-runtime-state)
(let* ((layout (apply
#'ebox-test-column
(cl-loop for index below 12
collect
(ebox-test-box
(ebox-test-text (format "Row %02d" index))
:width (+ 40 (* index 2))))))
(multiline-width-calls 0)
(original (symbol-function 'ebox--string-pixel-width)))
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (string)
(when (and (stringp string)
(string-match-p "\n" string))
(cl-incf multiline-width-calls))
(funcall original string))))
(ebox-render layout))
(should (= multiline-width-calls 0))))
(ert-deftest ebox-lines-concat-horizontal-splits-each-input-once ()
"Horizontal line concatenation should avoid split-pad-resplit work."
(ebox-test--reset-runtime-state)
(let ((split-count 0)
(original (symbol-function 'ebox-string-lines))
result)
(cl-letf (((symbol-function 'ebox-string-lines)
(lambda (&rest args)
(cl-incf split-count)
(apply original args))))
(setq result
(ebox--lines-concat-horizontal
"A\nB\nC"
"1\n2"
"x")))
(should (string= (substring-no-properties result)
"A1x\nB2 \nC "))
(should (= split-count 3))))
(ert-deftest ebox-layout-snapshot-table-is-created-when-missing ()
"Snapshot storage should tolerate render states created before snapshots."
(ebox-test--reset-runtime-state)
(let ((buffer (generate-new-buffer " *ebox-test*"))
(node-id "legacy-node"))
(unwind-protect
(progn
(puthash buffer
(list :root-node nil
:viewport-width 80)
ebox--buffer-render-state-table)
(ebox--put-layout-snapshot buffer node-id (list :node-id node-id))
(should (equal (plist-get (ebox--layout-snapshot buffer node-id)
:node-id)
node-id)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-dirty-set-classifies-paint-only-change ()
"A color-only change should produce paint dirty, not geometry dirty."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Paint") :width 80
:border '(1 solid "red")))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((old (ebox--current-layout-snapshots (current-buffer))))
(ebox-test--region-update region-id :border-color "blue")
(let ((dirty (ebox--diff-layout-snapshots
old
(ebox--current-layout-snapshots (current-buffer)))))
(should dirty)
(should (eq (plist-get (car dirty) :dirty-kind) 'paint)))))))
(ert-deftest ebox-dirty-set-classifies-viewport-wrap-change ()
"Viewport wrapping should produce geometry or placement dirty."
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 240)
buffer
layout)
(setq layout
(ebox-test-flex :flex-wrap 'wrap
(ebox-test-box (ebox-test-text "A") :width '(120))
(ebox-test-box (ebox-test-text "B") :width '(120))))
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(let ((old (ebox--current-layout-snapshots buffer t)))
(ebox-rerender-buffer-with-context buffer 130)
(let ((dirty (ebox--diff-layout-snapshots
old
(ebox--current-layout-snapshots buffer t))))
(should (cl-some
(lambda (entry)
(memq (plist-get entry :dirty-kind)
'(geometry placement structure)))
dirty)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-dirty-set-classifies-wrapper-child-viewport-geometry ()
"Viewport reflow under a wrapper should produce geometry dirty, not no-op."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-column :padding 1
(ebox-test-box (ebox-test-text "Text that wraps differently when narrow")
:wrap-mode 'word)))
buffer)
(unwind-protect
(progn
(setq buffer
(let ((ebox-viewport-width 520))
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node)))
(ebox-rerender-buffer-with-context buffer 36)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'viewport))
(should (member 'geometry (plist-get report :dirty-kinds)))
(should-not (eq (plist-get report :strategy) 'no-op))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-patch-set-paints-without-rendered-text-replace ()
"Paint-only dirty entries should produce paint-patch ops."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Paint") :width 80
:border '(1 solid "red")))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update region-id :border-color "blue"))))
(should (= full-rerenders 0)))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))
(should (eq (plist-get report :full-rerender) nil))))))
(ert-deftest ebox-flex-wrapper-style-update-uses-paint-patch ()
"Flex wrapper style updates should patch faces without replacing spans."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-flex
:border "#5E7F6A" :bgcolor "#F8FCF8"
(ebox-test-box (ebox-test-text "A") :width 60
:bgcolor "#DCEBDD")))
(region-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update region-id :bgcolor "#FDE68A")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))
(should-not (plist-get report :full-rerender))
(should (equal (plist-get report :patch-ops)
'(paint-patch)))))))
(ert-deftest ebox-viewport-rerender-report-describes-constraint-change ()
"Viewport resize reports should use the shared constraint-change model."
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 240)
buffer
layout)
(setq layout
(ebox-test-flex :flex-wrap 'wrap
(ebox-test-box (ebox-test-text "A") :width '(120))
(ebox-test-box (ebox-test-text "B") :width '(120))))
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(ebox-rerender-buffer-with-context buffer 160)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'viewport))
(should (eq (plist-get report :constraint-owner-type)
'viewport))
(should (equal (plist-get report :constraint-owner-id)
'viewport))
(should (equal (plist-get report :constraint-root-node-id)
(plist-get (ebox-test--runtime-root buffer)
:node-id)))
(should (equal (plist-get report :dirty-kinds)
'(geometry)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-rerender-patches-column-children-independently ()
"Viewport resize should publish the correct column scope through TP."
(ebox-test--reset-runtime-state)
(let* ((top (ebox-test-box
(ebox-test-text "Top text should wrap into more lines when narrow.")
:wrap-mode 'word))
(bottom (ebox-test-box
(ebox-test-text "Bottom text should also survive after top grows.")
:wrap-mode 'word))
(layout (ebox-test-column top bottom))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 260))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(ebox-rerender-buffer-with-context buffer 90)
(let ((report (ebox-test--buffer-update-report))
(plain (with-current-buffer buffer
(buffer-substring-no-properties
(point-min) (point-max)))))
(should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0))
(should-not (plist-get report :tp-scope-fallback))
(should (string-match-p "Top text" plain))
(should (string-match-p "Bottom text" plain))
(should
(with-current-buffer buffer
(ebox-test--buffer-propertized-matches-runtime-render-p)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-rerender-noops-without-viewport-deps ()
"Viewport resize should not rerender when no node depends on viewport width."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test-box (ebox-test-text "Fixed") :width 80))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 240))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(ebox-rerender-buffer-with-context buffer 160)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'no-op))
(should (= (plist-get report :dirty-count) 0))
(should (= (plist-get report :patch-count) 0))
(should-not (plist-get report :full-rerender))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-region-width-update-report-describes-constraint-change ()
"Ordinary box geometry updates should use the shared constraint-change model."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-test-box (ebox-test-text "Left") :width 60))
(right (ebox-test-box (ebox-test-text "Right") :width 60))
(layout (ebox-test-row left right))
(left-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update left-id :width '(120))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'region))
(should (eq (plist-get report :constraint-owner-type) 'box))
(should
(equal (plist-get report :constraint-owner-id)
(gethash left-id
(ebox--buffer-region-node-table
(current-buffer)))))
(should (equal (plist-get report :dirty-kinds)
'(geometry)))))))
(ert-deftest ebox-root-region-width-reflow-rebinds-lazy-content-viewport ()
"A root width update should constrain lazy descendants like viewport resize."
(ebox-test--reset-runtime-state)
(let* ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(layout
(ebox-test-box :id "root" :width '(240) :height '(viewport-height)
:overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "first") :width '(viewport))
(ebox-test-box (ebox-test-text "second") :width '(viewport))
(ebox-test-box (ebox-test-text "third") :width '(viewport)))))
(root-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update root-id :width '(160))
(let ((widths
(mapcar #'ebox--string-pixel-width
(ebox-string-lines (buffer-string)))))
(should widths)
(should (= (apply #'max widths) 160))))))
(ert-deftest ebox-wrapper-child-width-update-promotes-to-wrapper-context ()
"Child geometry inside a wrapper should promote to the wrapper formatting context."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-column :padding 1
(ebox-test-row
(ebox-test-box (ebox-test-text "Left") :width '(60))
(ebox-test-box (ebox-test-text "Right") :width '(60)))))
(ids (ebox-region-ids node))
(left-id (nth 1 ids)))
(ebox-test--with-rendered-buffer node
(let ((root-node-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update left-id :width '(120))
(let ((report (ebox-test--buffer-update-report)))
(should (equal (plist-get report :owner-ids)
(list root-node-id)))
(should (= (plist-get report :tp-scope-count) 1))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'region))
(should (memq 'owner-rerender (plist-get report :patch-ops))))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "Left" plain))
(should (string-match-p "Right" plain))))))
(ert-deftest ebox-patch-set-uses-wrapper-owner-for-contained-layout-change ()
"Contained layout changes should patch a real owner instead of vanishing."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-column
(ebox-test-box (ebox-test-text "Stable header") :width '(180))
(ebox-test-column :padding 1
(ebox-test-box (ebox-test-text "Child text wraps under the wrapper viewport")
:wrap-mode 'word))))
buffer)
(unwind-protect
(progn
(setq buffer
(let ((ebox-viewport-width 520))
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node)))
(ebox-rerender-buffer-with-context buffer 36)
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(owner-rerender root-rerender)))
(should (plist-get report :owner-ids))
(should-not (equal (plist-get report :patch-ops)
'(paint-patch)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-patch-set-skips-dirty-nodes-covered-by-owner ()
"Viewport patch planning should not repeatedly inspect covered dirty nodes."
(ebox-test--reset-runtime-state)
(let* ((layout (apply
#'ebox-test-row
(cl-loop for index below 24
collect
(ebox-test-box
(ebox-test-text (format "Fluid %02d" index))
:border '(1 solid)))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 640))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(let* ((root (ebox--buffer-root-node buffer))
(dirty (ebox--viewport-dirty-set root))
(detail-calls 0)
(original
(symbol-function 'ebox--ensure-layout-snapshot-details))
patches)
(should (> (length dirty) 10))
(cl-letf (((symbol-function
'ebox--ensure-layout-snapshot-details)
(lambda (&rest args)
(cl-incf detail-calls)
(apply original args))))
(setq patches
(ebox-incremental--declarative-tentative-patch-set
buffer dirty)))
(should (= (length patches) 1))
(should (eq (plist-get (car patches) :op)
'owner-rerender))
(should (<= detail-calls 4))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-patch-set-coalesces-large-column-coverage ()
"Large local owner sets should coalesce when they cover most root lines."
(ebox-test--reset-runtime-state)
(let* ((layout (apply
#'ebox-test-column
(cl-loop for index below 12
collect
(ebox-test-box
(ebox-test-text (format "Fluid section %02d" index))
:border '(1 solid)))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 640))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(let* ((root (ebox--buffer-root-node buffer))
(dirty (ebox--viewport-dirty-set root))
(patches
(ebox-incremental--declarative-tentative-patch-set
buffer dirty))
(source-ids
(ebox--dirty-provenance-node-ids
(plist-get (car patches) :dirty))))
(should (> (length dirty) 8))
(should (= (length patches) 1))
(should (equal (plist-get (car patches) :owner-id)
(plist-get root :node-id)))
(should (= (length source-ids) (length dirty)))
(dolist (entry dirty)
(should (member (plist-get entry :node-id) source-ids)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-rerender-root-owner-allows-line-count-change ()
"Root owner rerender should patch even when viewport wrapping changes height."
(ebox-test--reset-runtime-state)
(let* ((content
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu")
(layout (apply
#'ebox-test-column
(cl-loop repeat 12
collect
(ebox-test-box
(ebox-test-text content)
:width 'viewport
:wrap-mode 'word
:border '(1 solid)))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 120))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(let ((wide-height (with-current-buffer buffer
(count-lines (point-min) (point-max)))))
(ebox-rerender-buffer-with-context buffer 20)
(let ((report (ebox-test--buffer-update-report))
(narrow-height (with-current-buffer buffer
(count-lines (point-min) (point-max)))))
(should (> narrow-height wide-height))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (plist-get report :root-rerender)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-root-owner-rerender-skips-partial-slot-probe ()
"Whole-line root owner rerenders should not run partial-slot probing."
(ebox-test--reset-runtime-state)
(let* ((content
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu")
(layout (apply
#'ebox-test-column
(cl-loop repeat 12
collect
(ebox-test-box
(ebox-test-text content)
:width 'viewport
:wrap-mode 'word
:border '(1 solid)))))
(slot-probe-count 0)
(original (symbol-function 'ebox-buffer--rendered-in-existing-slots))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 120))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(cl-letf (((symbol-function 'ebox-buffer--rendered-in-existing-slots)
(lambda (&rest args)
(cl-incf slot-probe-count)
(apply original args))))
(ebox-rerender-buffer-with-context buffer 20))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids)
(list (plist-get
(ebox-test--runtime-root buffer)
:node-id)))))
(should (= slot-probe-count 0)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-buffer-render-context-selects-target-buffer ()
"Buffer render contexts should evaluate render-sensitive work in target buffers."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-box (ebox-test-text "context target")))
(buffer nil)
(other nil)
seen-buffer)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node))
(setq other (generate-new-buffer " *ebox-other*"))
(with-current-buffer other
(ebox--with-buffer-render-context buffer
(setq seen-buffer (current-buffer))))
(should (eq seen-buffer buffer)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))
(when (and other (buffer-live-p other))
(kill-buffer other)))))
(ert-deftest ebox-buffer-render-context-preserves-caller-state-binding ()
"Render context internals should not capture a caller's STATE variable."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-box (ebox-test-text "context state")))
(buffer nil)
(state 'caller-scroll-state)
seen-state)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*") node))
(ebox--with-buffer-render-context buffer
(setq seen-state state))
(should (eq seen-state 'caller-scroll-state)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-deferred-render-gc-uses-interactive-burst-threshold ()
"Interactive render bursts should stay bounded and collect when settled."
(let ((gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-deferred-render-gc-cons-threshold 2000)
(ebox-render-gc-cons-percentage 0.6)
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil)
(gc-count 0))
(unwind-protect
(cl-letf (((symbol-function 'garbage-collect)
(lambda () (cl-incf gc-count))))
(ebox--deferred-render-gc-enter)
(should (= gc-cons-threshold 2000))
(should (= gc-cons-percentage 0.6))
(ebox--deferred-render-gc-restore)
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should (= gc-count 1))
(ebox--deferred-render-gc-restore)
(should (= gc-count 1)))
(when (timerp ebox--deferred-render-gc-timer)
(cancel-timer ebox--deferred-render-gc-timer)))))
(ert-deftest ebox-public-render-burst-batch-restores-nested-settings-lifo ()
"Batch bursts should nest strictly and restore exact caller GC settings."
(let ((noninteractive t)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-render-gc-cons-threshold 4000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--render-burst-stack nil)
(gc-count 0))
(cl-letf (((symbol-function 'garbage-collect)
(lambda () (cl-incf gc-count))))
(let ((outer (ebox-render-burst-begin)))
(should (= gc-cons-threshold 4000))
(should (= gc-cons-percentage 0.8))
(let ((inner (ebox-render-burst-begin)))
(should-error (ebox-render-burst-end outer))
(should (= gc-cons-threshold 4000))
(ebox-render-burst-end inner)
(should (= gc-cons-threshold 4000)))
(ebox-render-burst-end outer)
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should-error (ebox-render-burst-end outer)))
(should (= gc-count 0)))))
(ert-deftest ebox-public-render-burst-raises-gc-before-private-allocation ()
"Begin should cross the GC boundary before creating any ownership record."
(let ((noninteractive t)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-render-gc-cons-threshold 4000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--render-burst-stack nil)
(ebox--render-burst-records (make-hash-table :test #'eq))
(original-make-symbol (symbol-function 'make-symbol))
(original-make-record
(symbol-function 'ebox--render-burst-create-record))
token-threshold
record-threshold)
(cl-letf (((symbol-function 'make-symbol)
(lambda (name)
(setq token-threshold gc-cons-threshold)
(funcall original-make-symbol name)))
((symbol-function 'ebox--render-burst-create-record)
(lambda (&rest args)
(setq record-threshold gc-cons-threshold)
(apply original-make-record args))))
(let ((token (ebox-render-burst-begin)))
(should (= token-threshold 4000))
(should (= record-threshold 4000))
(ebox-render-burst-end token)))
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))))
(ert-deftest ebox-public-render-burst-restores-batch-gc-when-begin-fails ()
"A batch begin allocation failure should restore its caller's GC settings."
(let ((noninteractive t)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-render-gc-cons-threshold 4000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--render-burst-stack nil)
(ebox--render-burst-records (make-hash-table :test #'eq)))
(cl-letf (((symbol-function 'make-symbol)
(lambda (_name)
(should (= gc-cons-threshold 4000))
(error "token allocation failed"))))
(should-error (ebox-render-burst-begin)))
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should-not ebox--render-burst-stack)
(should (zerop (hash-table-count ebox--render-burst-records)))))
(ert-deftest ebox-interactive-render-burst-raises-gc-before-state-allocation ()
"Interactive entry should save scalars and raise GC before state allocation."
(let ((noninteractive nil)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-render-gc-cons-threshold 4000)
(ebox-deferred-render-gc-cons-threshold 8000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil)
(ebox--deferred-render-gc-depth 0)
(original-make-state
(symbol-function 'ebox--make-deferred-render-gc-state))
observed-threshold)
(unwind-protect
(cl-letf (((symbol-function 'ebox--make-deferred-render-gc-state)
(lambda (threshold percentage)
(setq observed-threshold gc-cons-threshold)
(funcall original-make-state threshold percentage))))
(ebox--deferred-render-gc-enter)
(should (= observed-threshold 8000))
(should (= (plist-get ebox--deferred-render-gc-state
:gc-cons-threshold)
1000))
(should (= (plist-get ebox--deferred-render-gc-state
:gc-cons-percentage)
0.1)))
(ebox--deferred-render-gc-restore))))
(ert-deftest ebox-public-render-burst-wrapper-cleans-up-errors-and-quits ()
"The call wrapper should restore batch settings across nonlocal exits."
(let ((noninteractive t)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-render-gc-cons-threshold 4000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--render-burst-stack nil)
quit-seen)
(should-error
(ebox-call-with-render-burst
(lambda ()
(should (= gc-cons-threshold 4000))
(error "render failed"))))
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(condition-case nil
(ebox-call-with-render-burst (lambda () (signal 'quit nil)))
(quit (setq quit-seen t)))
(should quit-seen)
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should-not ebox--render-burst-stack)))
(ert-deftest ebox-public-render-burst-interactive-reuses-safe-restore-timer ()
"Interactive bursts should defer once and ignore a cancelled timer callback."
(let ((noninteractive nil)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-deferred-render-gc-cons-threshold 4000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil)
(ebox--deferred-render-gc-depth 0)
(ebox--deferred-render-gc-generation 0)
(ebox--render-burst-stack nil)
callbacks
cancelled
(timer-index 0)
(gc-count 0))
(cl-letf (((symbol-function 'run-at-time)
(lambda (delay repeat function &rest args)
(let ((timer (list 'ebox-test-timer (cl-incf timer-index))))
(push (list delay repeat function args timer) callbacks)
timer)))
((symbol-function 'timerp)
(lambda (value) (eq (car-safe value) 'ebox-test-timer)))
((symbol-function 'cancel-timer)
(lambda (timer) (push timer cancelled)))
((symbol-function 'garbage-collect)
(lambda () (cl-incf gc-count))))
(should (eq (ebox-call-with-render-burst #'identity 'done) 'done))
(should (= gc-cons-threshold 4000))
(should (= (length callbacks) 1))
(let ((stale (car callbacks))
(token (ebox-render-burst-begin)))
(should (= (length cancelled) 1))
(apply (nth 2 stale) (nth 3 stale))
(should (= gc-cons-threshold 4000))
(should (= gc-count 0))
(ebox-render-burst-end token))
(should (= (length callbacks) 2))
(let ((current (car callbacks)))
(should (= (nth 0 current) 0.2))
(apply (nth 2 current) (nth 3 current)))
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should (= gc-count 1)))))
(ert-deftest ebox-public-render-burst-nests-around-interactive-commit ()
"A framework burst should own GC before and throughout Ebox commit."
(ebox-test--reset-runtime-state)
(let ((buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
(ebox-test-box (ebox-test-text "before")))))
(unwind-protect
(let ((noninteractive nil)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-deferred-render-gc-cons-threshold 4000)
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil)
(ebox--deferred-render-gc-depth 0)
(ebox--render-burst-stack nil)
scheduled
observed-depth)
(cl-letf (((symbol-function 'run-at-time)
(lambda (delay _repeat function &rest args)
(setq scheduled (list delay function args))
'ebox-test-timer))
((symbol-function 'timerp)
(lambda (value) (eq value 'ebox-test-timer)))
((symbol-function 'cancel-timer) #'ignore))
(let ((token (ebox-render-burst-begin)))
(ebox-commit
buffer (ebox-test-box (ebox-test-text "after"))
(lambda (_report)
(setq observed-depth ebox--deferred-render-gc-depth)))
(should (= observed-depth 2))
(should (= ebox--deferred-render-gc-depth 1))
(should-not scheduled)
(ebox-render-burst-end token))
(should (= ebox--deferred-render-gc-depth 0))
(should (= (car scheduled) 0.2))
(apply (nth 1 scheduled) (nth 2 scheduled))
(should (= gc-cons-threshold 1000))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-deferred-render-gc-keeps-overlapping-burst-owned ()
"A nested idle slice must not restore GC during an active animation."
(let ((gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-deferred-render-gc-cons-threshold 4000)
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil)
(ebox--deferred-render-gc-depth 0)
scheduled)
(cl-letf (((symbol-function 'run-at-time)
(lambda (&rest _)
(setq scheduled 'restore-timer)))
((symbol-function 'timerp)
(lambda (value) (eq value 'restore-timer))))
(ebox--deferred-render-gc-enter)
(ebox--deferred-render-gc-enter)
(should (= ebox--deferred-render-gc-depth 2))
(ebox--deferred-render-gc-schedule-restore)
(should (= ebox--deferred-render-gc-depth 1))
(should-not scheduled)
(should (= gc-cons-threshold 4000))
(ebox--deferred-render-gc-schedule-restore)
(should (eq scheduled 'restore-timer))
(should (= ebox--deferred-render-gc-depth 0))
(should (= gc-cons-threshold 4000)))))
(ert-deftest ebox-deferred-render-gc-default-is-memory-bounded ()
"The interactive burst default should scale within finite memory bounds."
(should (eq ebox-deferred-render-gc-cons-threshold 'auto))
(let ((gc-cons-threshold (* 512 1024 1024))
(gc-cons-percentage 0.1)
(ebox--physical-memory-bytes (* 16 1024 1024 1024))
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil))
(unwind-protect
(progn
(ebox--deferred-render-gc-enter)
(should (= gc-cons-threshold (* 512 1024 1024))))
(ebox--deferred-render-gc-restore))))
(ert-deftest ebox-interactive-commit-owns-deferred-render-gc ()
"Keep GC outside an interactive declarative commit and defer restoration."
(ebox-test--reset-runtime-state)
(let ((buffer nil))
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
(ebox-test-box (ebox-test-text "before"))))
(let ((noninteractive nil)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-deferred-render-gc-cons-threshold 4000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil)
(ebox--deferred-render-gc-depth 0)
observed-threshold)
(unwind-protect
(progn
(ebox-commit
buffer (ebox-test-box (ebox-test-text "after"))
(lambda (_report)
(setq observed-threshold gc-cons-threshold)))
(should (= observed-threshold 4000))
(should (= gc-cons-threshold 4000))
(should (= gc-cons-percentage 0.8))
(should ebox--deferred-render-gc-state)
(should (timerp ebox--deferred-render-gc-timer)))
(when (timerp ebox--deferred-render-gc-timer)
(cancel-timer ebox--deferred-render-gc-timer))
(setq ebox--deferred-render-gc-state nil
ebox--deferred-render-gc-timer nil
ebox--deferred-render-gc-depth 0))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-interactive-viewport-rerender-owns-deferred-render-gc ()
"Keep GC outside an interactive viewport transaction until it settles."
(ebox-test--reset-runtime-state)
(let ((ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(buffer nil))
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
(ebox-test-box :width '(viewport) (ebox-test-text "resize"))))
(let ((noninteractive nil)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-deferred-render-gc-cons-threshold 4000)
(ebox-render-gc-cons-percentage 0.8)
(ebox--deferred-render-gc-state nil)
(ebox--deferred-render-gc-timer nil)
(ebox--deferred-render-gc-depth 0)
(original-update
(symbol-function 'ebox-surface-update-buffer-viewport))
observed-threshold
observed-depth
scheduled
(gc-count 0))
(cl-letf (((symbol-function 'ebox-surface-update-buffer-viewport)
(lambda (&rest arguments)
(setq observed-threshold gc-cons-threshold
observed-depth ebox--deferred-render-gc-depth)
(apply original-update arguments)))
((symbol-function 'run-at-time)
(lambda (delay _repeat function &rest arguments)
(setq scheduled (list delay function arguments))
'ebox-test-viewport-gc-timer))
((symbol-function 'timerp)
(lambda (value)
(eq value 'ebox-test-viewport-gc-timer)))
((symbol-function 'cancel-timer) #'ignore)
((symbol-function 'garbage-collect)
(lambda () (cl-incf gc-count))))
(ebox-rerender-buffer-with-context buffer 320)
(should (= observed-threshold 4000))
(should (= observed-depth 1))
(should (= (plist-get (ebox--buffer-render-state buffer)
:viewport-width)
320))
(should (= ebox--deferred-render-gc-depth 0))
(should ebox--deferred-render-gc-state)
(should (eq ebox--deferred-render-gc-timer
'ebox-test-viewport-gc-timer))
(should (= (car scheduled) 0.2))
(apply (nth 1 scheduled) (nth 2 scheduled))
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should (= gc-count 1)))))
(ebox-test--reset-runtime-state)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-batch-viewport-rerender-uses-render-gc-boundary ()
"Raise GC only during a batch viewport transaction and restore on return."
(ebox-test--reset-runtime-state)
(let ((ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil)
(buffer nil))
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
(ebox-test-box :width '(viewport) (ebox-test-text "resize"))))
(let ((noninteractive t)
(gc-cons-threshold 1000)
(gc-cons-percentage 0.1)
(ebox-render-gc-cons-threshold 4000)
(ebox-render-gc-cons-percentage 0.8)
(original-update
(symbol-function 'ebox-surface-update-buffer-viewport))
observed-threshold
observed-percentage)
(cl-letf (((symbol-function 'ebox-surface-update-buffer-viewport)
(lambda (&rest arguments)
(setq observed-threshold gc-cons-threshold
observed-percentage gc-cons-percentage)
(apply original-update arguments))))
(ebox-rerender-buffer-with-context buffer 320))
(should (= observed-threshold 4000))
(should (= observed-percentage 0.8))
(should (= gc-cons-threshold 1000))
(should (= gc-cons-percentage 0.1))
(should-not ebox--deferred-render-gc-state)
(should-not ebox--deferred-render-gc-timer)))
(ebox-test--reset-runtime-state)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-deferred-render-gc-can-raise-an-active-burst-budget ()
"Measured layout pressure should only raise an active burst budget."
(let ((gc-cons-threshold 1000)
(ebox--deferred-render-gc-state nil))
(ebox--deferred-render-gc-raise-threshold 4000)
(should (= gc-cons-threshold 1000))
(setq ebox--deferred-render-gc-state
'(:gc-cons-threshold 1000 :gc-cons-percentage 0.1))
(ebox--deferred-render-gc-raise-threshold 4000)
(should (= gc-cons-threshold 4000))
(ebox--deferred-render-gc-raise-threshold 2000)
(should (= gc-cons-threshold 4000))
(ebox--deferred-render-gc-raise-threshold nil)
(should (= gc-cons-threshold 4000))))
(ert-deftest ebox-deferred-render-gc-can-bound-an-active-burst-budget ()
"A specialized burst may lower its raised budget but not the saved setting."
(let ((gc-cons-threshold 5000)
(ebox--deferred-render-gc-state nil))
(ebox--deferred-render-gc-set-threshold 3000)
(should (= gc-cons-threshold 5000))
(setq ebox--deferred-render-gc-state
'(:gc-cons-threshold 1000 :gc-cons-percentage 0.1))
(ebox--deferred-render-gc-set-threshold 3000)
(should (= gc-cons-threshold 3000))
(ebox--deferred-render-gc-set-threshold 500)
(should (= gc-cons-threshold 1000))
(ebox--deferred-render-gc-set-threshold nil)
(should (= gc-cons-threshold 1000))))
(ert-deftest ebox-viewport-patch-planning-skips-line-signatures ()
"Owner rerender planning should not compute expensive line signatures."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply
#'ebox-test-column
(cl-loop for section below 8
collect
(apply
#'ebox-test-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(cl-loop for index below 4
collect
(ebox-test-box
(ebox-test-text (format "S%d-%d" section index))
:width 80
:height 1))))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 360))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(let* ((root (ebox--buffer-root-node buffer))
(dirty (ebox--viewport-dirty-set root))
(line-signature-count 0)
(original (symbol-function 'ebox--spans-line-signature)))
(should dirty)
(cl-letf (((symbol-function 'ebox--spans-line-signature)
(lambda (&rest args)
(cl-incf line-signature-count)
(apply original args))))
(ebox-incremental--declarative-tentative-patch-set
buffer dirty))
(should (= line-signature-count 0))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-patch-planning-coalesces-before-line-index ()
"Broad viewport dirtiness should coalesce before building a line index."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply
#'ebox-test-column
(cl-loop for section below 8
collect
(apply
#'ebox-test-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(cl-loop for index below 4
collect
(ebox-test-box
(ebox-test-text (format "S%d-%d" section index))
:width 80
:height 1))))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 360))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(let* ((root (ebox--buffer-root-node buffer))
(dirty (ebox--viewport-dirty-set root))
(index-build-count 0)
(original (symbol-function 'ebox--build-region-line-index))
patches)
(should dirty)
(cl-letf (((symbol-function 'ebox--build-region-line-index)
(lambda (&rest args)
(cl-incf index-build-count)
(apply original args))))
(setq patches
(ebox-incremental--declarative-tentative-patch-set
buffer dirty)))
(should (= index-build-count 0))
(should (= (length patches) 1))
(should (eq (plist-get (car patches) :op) 'owner-rerender))
(should (equal (plist-get (car patches) :owner-id)
(plist-get root :node-id)))
(should (= (length
(ebox--dirty-provenance-node-ids
(plist-get (car patches) :dirty)))
(length dirty)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-block-padding-planning-promotes-without-full-line-index ()
"Block padding should publish through its parent without rebuilding line indexes."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-column :id "root" :width '(480)
(ebox-test-box :id "target" (ebox-test-text "mixed padding")
:width '(220) :height 3 :box-sizing
'border-box :padding '(0 (8)) :border
"#999999")
(ebox-test-box (ebox-test-text "peer") :width '(220) :height
3 :border "#999999")))
(index-build-count 0))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#target"))
(root-id (ebox--buffer-root-node-id (current-buffer)))
(original (symbol-function 'ebox--build-region-line-index)))
(cl-letf (((symbol-function 'ebox--build-region-line-index)
(lambda (&rest args)
(cl-incf index-build-count)
(apply original args))))
(ebox-test--region-update region-id :padding (list 1 '(18) 1 '(18))))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids) (list root-id)))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (= index-build-count 0))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-region-role-ranges-come-from-tp-mounts ()
"Published public bounds should agree with TP-owned mount snapshots."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-column :id "root" :width '(480)
(ebox-test-box :id "target" :width '(220) :height 1
:border "#999999"
(ebox-test-text "target"))
(ebox-test-box :id "peer" :width '(220) :height 1
:border "#999999"
(ebox-test-text "peer")))))
(ebox-test--with-rendered-buffer layout
(let* ((target-id (ebox-test--selector-region-id (current-buffer)
"#target"))
(peer-id (ebox-test--selector-region-id (current-buffer)
"#peer"))
(target-mounts
(ebox-surface-region-mounts (current-buffer) target-id))
(peer-mounts
(ebox-surface-region-mounts (current-buffer) peer-id))
(target-bounds
(ebox-surface-region-bounds (current-buffer) target-id))
(peer-bounds
(ebox-surface-region-bounds (current-buffer) peer-id)))
(should target-mounts)
(should peer-mounts)
(should (equal target-bounds
(cons (apply #'min (mapcar
(lambda (mount)
(plist-get mount :start))
target-mounts))
(apply #'max (mapcar
(lambda (mount)
(plist-get mount :end))
target-mounts)))))
(should (or (<= (cdr target-bounds) (car peer-bounds))
(<= (cdr peer-bounds) (car target-bounds))))
(ebox-test--region-update target-id :bgcolor "#112233")
(should (equal peer-bounds
(ebox-surface-region-bounds
(current-buffer) peer-id)))))))
(ert-deftest ebox-viewport-root-owner-spans-skip-region-scan ()
"Root owner spans should use full buffer lines, not region-property scans."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply
#'ebox-test-row
(cl-loop for section below 8
collect
(apply
#'ebox-test-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(cl-loop for index below 4
collect
(ebox-test-box
(ebox-test-text (format "S%d-%d" section index))
:width 80
:height 1))))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 360))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(let* ((root (ebox--buffer-root-node buffer))
(dirty (ebox--viewport-dirty-set root))
(region-scan-count 0)
(original (symbol-function 'ebox--region-set-line-spans)))
(should dirty)
(cl-letf (((symbol-function 'ebox--region-set-line-spans)
(lambda (&rest args)
(cl-incf region-scan-count)
(apply original args))))
(ebox-incremental--declarative-tentative-patch-set
buffer dirty))
(should (= region-scan-count 0))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-rerender-reuses-static-flex-item-renders ()
"Static flex item renders should be reused across viewport rerenders."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply
#'ebox-test-flex
:flex-wrap 'wrap
(cl-loop for index below 12
collect
(ebox-test-box
(ebox-test-text (format "Item %02d" index))
:width 80
:height 1))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 420))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(ebox-rerender-buffer-with-context buffer 360)
(let ((box-render-count 0)
(original (symbol-function 'ebox--render-box)))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (&rest args)
(cl-incf box-render-count)
(apply original args))))
(ebox-rerender-buffer-with-context buffer 500))
(should (<= box-render-count 3))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-rerender-reuses-sized-flex-item-renders ()
"Flex item renders with stable computed sizes should be reused."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply
#'ebox-test-flex
:flex-wrap 'wrap
:height 4
:align-items 'stretch
(cl-loop for index below 10
collect
(ebox-test-box
(ebox-test-text (format "Stretch %02d" index))
:width 80))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 420))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(ebox-rerender-buffer-with-context buffer 360)
(let ((box-render-count 0)
(original (symbol-function 'ebox--render-box)))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (&rest args)
(cl-incf box-render-count)
(apply original args))))
(ebox-rerender-buffer-with-context buffer 500))
(should (<= box-render-count 6))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-rerender-reuses-flex-item-measurements ()
"Stable flex item intrinsic measurements should be reused across resizes."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply
#'ebox-test-flex
:flex-wrap 'wrap
(cl-loop for index below 12
collect
(ebox-test-box
(ebox-test-text (format "Measured %02d" index))
:width 80
:height 1))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 420))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(ebox-rerender-buffer-with-context buffer 360)
(let ((main-count 0)
(min-count 0)
(original-main (symbol-function 'ebox--flex-string-main))
(original-min (symbol-function 'ebox--content-min-pixel)))
(cl-letf (((symbol-function 'ebox--flex-string-main)
(lambda (&rest args)
(cl-incf main-count)
(apply original-main args)))
((symbol-function 'ebox--content-min-pixel)
(lambda (&rest args)
(cl-incf min-count)
(apply original-min args))))
(ebox-rerender-buffer-with-context buffer 500))
(should (<= main-count 3))
(should (<= min-count 3))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-rerender-reuses-static-composite-subtrees ()
"Viewport-dependent flex sections should reuse static composite item sources."
(ebox-test--reset-runtime-state)
(let* ((inner-flexes
(cl-loop for index below 6
collect
(ebox-test-flex
:width 180 :height 3
:flex-flow '(row wrap)
:column-gap '(8)
(ebox-test-box
(ebox-test-text (format "A%d" index))
:width 48 :height 1)
(ebox-test-box
(ebox-test-text (format "B%d" index))
:width 48 :height 1))))
(layout
(apply
#'ebox-test-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(mapcar (lambda (inner)
(ebox-test-column
(ebox-test-box (ebox-test-text "Static card")
:width 180
:height 1)
inner))
inner-flexes)))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 320))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(ebox-rerender-buffer-with-context buffer 360)
(let ((static-flex-renders 0)
(original (symbol-function 'ebox--render-flex)))
(cl-letf (((symbol-function 'ebox--render-flex)
(lambda (node)
(when (memq node inner-flexes)
(cl-incf static-flex-renders))
(funcall original node))))
(ebox-rerender-buffer-with-context buffer 420))
(should (= static-flex-renders 0))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-flex-window-reuses-cached-composite-measurements ()
"Flex prefix layout should reuse warm measurements for fixed composites."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply
#'ebox-test-flex
:flex-flow '(row wrap)
:column-gap '(8)
(cl-loop for index below 6
collect
(ebox-test-column
(ebox-test-box (ebox-test-text (format "Card %d" index))
:width 96 :height 1)
(ebox-test-box (ebox-test-text "Body") :width 96 :height 2)))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 320))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*") layout)))
(let* ((root (ebox--buffer-root-node buffer))
(child (car (plist-get root :children))))
(ebox--with-buffer-render-context buffer
(should (ebox--flex-window-cheap-item child 'row 0)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-render-cacheability-rejects-viewport-dependent-wrapper-child ()
"Wrapper boxes with viewport-dependent child layout should not reuse static render cache."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-root
(ebox-test-column :padding 1
(ebox-test-box (ebox-test-text "Auto child follows viewport")
:wrap-mode 'word)))))
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--viewport-dependent-subtree-cache
(make-hash-table :test 'eq)))
(should-not (ebox--render-cacheable-node-p node)))))
(ert-deftest ebox-render-cacheability-allows-definite-width-wrapper-child ()
"Definite-width boxes bound descendant viewport-dependent layout."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-root
(ebox-test-column :width '(210) :padding 1
(ebox-test-box (ebox-test-text "Child wraps inside the card")
:wrap-mode 'word)))))
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--viewport-dependent-subtree-cache
(make-hash-table :test 'eq)))
(should (ebox--viewport-dependent-subtree-p node))
(should (ebox--render-cacheable-node-p node)))))
(ert-deftest ebox-region-width-update-invalidates-viewport-dependencies ()
"Width updates can change which boxes participate in viewport resize."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Resizable") :width 80))
(box-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-rerender-buffer-with-context (current-buffer) 260)
(ebox-test--region-update box-id :width '(viewport))
(ebox-rerender-buffer-with-context (current-buffer) 200)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'viewport))
(should (= (plist-get report :dirty-count) 1))))))
(ert-deftest ebox-viewport-dependencies-include-wrapper-child-layout ()
"Viewport dependency discovery should see lazy child layout under wrapper boxes."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-root
(ebox-test-column :padding 1
(ebox-test-box (ebox-test-text "Auto child follows the preview width")
:wrap-mode 'word))))
(child (car (ebox--node-children node)))
(ids (let ((ebox-viewport-width 360))
(ebox--runtime-node-ids node)
(ebox--viewport-dependent-node-ids node))))
(should ids)
(should (member (plist-get node :node-id) ids))
(should (member (plist-get child :node-id) ids))))
(ert-deftest ebox-viewport-dependencies-stop-at-definite-width-wrapper ()
"Definite wrappers should contain descendant viewport dependencies."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-root
(ebox-test-column :width '(210) :padding 1
(ebox-test-box (ebox-test-text "Auto child wraps inside the fixed card")
:wrap-mode 'word))))
(child (car (ebox--node-children node)))
(ids (let ((ebox-viewport-width 360))
(ebox--runtime-node-ids node)
(ebox--viewport-dependent-node-ids node))))
(should-not (member (plist-get node :node-id) ids))
(should-not (member (plist-get child :node-id) ids))))
(ert-deftest ebox-viewport-height-dependency-crosses-definite-width-wrapper ()
"Definite width should not contain a descendant viewport-height dependency."
(ebox-test--reset-runtime-state)
(let* ((viewport-width 120)
(node (let ((ebox-viewport-width viewport-width)
(ebox-viewport-height 2))
(ebox-test-column :width '(120)
(ebox-test-box :id 'child (ebox-test-text "One\nTwo\nThree\nFour") :height
'(viewport-height) :overflow 'scroll))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width viewport-width)
(ebox-viewport-height 2))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node)))
(with-current-buffer buffer
(should (= (length (ebox-string-lines
(buffer-substring-no-properties
(point-min) (point-max))))
2)))
(let ((report
(ebox-rerender-buffer-with-context
buffer viewport-width 4)))
(should-not (eq (plist-get report :strategy) 'no-op)))
(with-current-buffer buffer
(should (= (length (ebox-string-lines
(buffer-substring-no-properties
(point-min) (point-max))))
4))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-dependency-axes-classify-both-dimensions ()
"A node may depend on width, height, or both viewport axes."
(ebox-test--reset-runtime-state)
(let* ((width-only-input
(ebox-test-box (ebox-test-text "Width") :width '(viewport)))
(height-only-input
(ebox-test-box (ebox-test-text "Height")
:width '(120)
:height '(viewport-height)))
(both-input
(ebox-test-box (ebox-test-text "Both")
:width '(viewport)
:height '(viewport-height)))
(layout-input
(ebox-test-column width-only-input height-only-input both-input))
(width-only (ebox-test-root width-only-input))
(height-only (ebox-test-root height-only-input))
(both (ebox-test-root both-input))
(layout (ebox-test-root layout-input)))
(let ((ebox-viewport-width 360)
(ebox-viewport-height 12)
(ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq))
(ebox--viewport-height-dependent-subtree-cache
(make-hash-table :test 'eq)))
(ebox--runtime-node-ids layout)
(pcase-let ((`(,width-ids . ,height-ids)
(ebox--viewport-dependent-node-id-axes layout)))
(should (member (plist-get width-only :node-id) width-ids))
(should-not (member (plist-get width-only :node-id) height-ids))
(should-not (member (plist-get height-only :node-id) width-ids))
(should (member (plist-get height-only :node-id) height-ids))
(should (member (plist-get both :node-id) width-ids))
(should (member (plist-get both :node-id) height-ids))))))
(ert-deftest ebox-viewport-width-rerender-skips-height-only-root ()
"Width-only viewport rerender should not dirty height-only roots."
(ebox-test--reset-runtime-state)
(let* ((node (let ((ebox-viewport-height 4))
(ebox-test-column :width '(120) :height '(viewport-height)
(ebox-test-box (ebox-test-text "Static") :width '(120)))))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 240)
(ebox-viewport-height 4))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node)))
(let ((report (ebox-rerender-buffer-with-context buffer 300)))
(should (eq (plist-get report :strategy) 'no-op))
(should (= (plist-get report :dirty-count) 0))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-dependencies-keep-only-vertical-axis-under-fixed-width ()
"Fixed width should retain vertical dependencies without exposing width-only ones."
(ebox-test--reset-runtime-state)
(let* ((width-only
(ebox-test-box (ebox-test-text "Width") :width '(viewport)))
(ordinary-height-nodes
(cl-loop for (property value)
in '((:height (viewport-height))
(:max-height (viewport-height)))
collect
(apply #'ebox-test-box
(ebox-test-text "Ordinary")
(list property value))))
(flex-height-nodes
(cl-loop for (property value)
in '((:height (viewport-height))
(:max-height (viewport-height)))
collect
(apply #'ebox-test-flex
(append
(list property value)
(list (ebox-test-box (ebox-test-text "Flex")))))))
(layout
(apply #'ebox-test-column
(append (list width-only)
ordinary-height-nodes
flex-height-nodes)))
(node (ebox-test-root
(ebox-test-box :width '(210)
:wrap-mode 'none
layout)))
(ids (let ((ebox-viewport-width 360)
(ebox-viewport-height 12))
(ebox--runtime-node-ids node)
(ebox--viewport-dependent-node-ids node))))
(should-not (member (plist-get (ebox-test-root width-only) :node-id)
ids))
(dolist (vertical-node (append ordinary-height-nodes flex-height-nodes))
(should (member (plist-get (ebox-test-root vertical-node) :node-id)
ids)))))
(ert-deftest ebox-viewport-dependencies-ignore-flex-item-wrapper-auto-width ()
"Flex item wrappers should not be dirty only because their own width is auto."
(ebox-test--reset-runtime-state)
(let* ((child-input (ebox-test-box (ebox-test-text "A") :width 80))
(item-input (ebox-test-flex-item child-input))
(node (ebox-test-root (ebox-test-flex :width 240 item-input)))
(child (ebox-test-root child-input))
(item (ebox-test-root item-input))
(ids (let ((ebox-viewport-width 360))
(ebox--runtime-node-ids node)
(ebox--viewport-dependent-node-ids node))))
(should-not (member (plist-get item :node-id) ids))
(should-not (member (plist-get child :node-id) ids))))
(ert-deftest ebox-buffer-viewport-dependency-cache-recomputes-after-style-change ()
"Changing responsive style should publish its exact viewport dependency."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Resizable") :width 80))
(box-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-rerender-buffer-with-context (current-buffer) 320)
(ebox-test--region-update box-id :width '(viewport))
(let* ((state (ebox--buffer-render-state (current-buffer)))
(axes (plist-get state :viewport-dependent-node-id-axes))
(owner-id
(ebox--buffer-region-render-owner-node-id
(current-buffer) box-id)))
(should (plist-get state :viewport-dependent-node-ids-ready))
(should (member owner-id (car axes))))
(ebox-rerender-buffer-with-context (current-buffer) 240)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :constraint-source) 'viewport))
(should (= (plist-get report :dirty-count) 1))))))
(ert-deftest ebox-initial-render-primes-static-composite-subtree-cache ()
"The first viewport resize should reuse cache populated by initial render."
(ebox-test--reset-runtime-state)
(let* ((inner-flexes
(cl-loop for index below 6
collect
(ebox-test-flex
:width 180 :height 3
:flex-flow '(row wrap)
:column-gap '(8)
(ebox-test-box
(ebox-test-text (format "A%d" index))
:width 48 :height 1)
(ebox-test-box
(ebox-test-text (format "B%d" index))
:width 48 :height 1))))
(layout
(apply
#'ebox-test-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(mapcar (lambda (inner)
(ebox-test-column
(ebox-test-box (ebox-test-text "Static card")
:width 180
:height 1)
inner))
inner-flexes)))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 320))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(let ((static-flex-renders 0)
(original (symbol-function 'ebox--render-flex)))
(cl-letf (((symbol-function 'ebox--render-flex)
(lambda (node)
(when (memq node inner-flexes)
(cl-incf static-flex-renders))
(funcall original node))))
(ebox-rerender-buffer-with-context buffer 420))
(should (= static-flex-renders 0))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-resize-invalidates-snapshot-details-lazily ()
"Viewport resize should not recapture every lightweight snapshot eagerly."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 24 collect
(ebox-test-box :id
(format "resize-snapshot-%02d"
idx)
(ebox-test-text (format "row %02d" idx)) :width
'(viewport) :height 1)))))
(layout-node (ebox-test-root layout))
(node-count (length (ebox--runtime-node-ids layout-node)))
buffer
(snapshot-count 0)
(original (symbol-function 'ebox--node-layout-snapshot)))
(unwind-protect
(let ((ebox-viewport-width 240))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(cl-letf (((symbol-function 'ebox--node-layout-snapshot)
(lambda (&rest args)
(cl-incf snapshot-count)
(apply original args))))
(ebox-rerender-buffer-with-context buffer 320))
(should (< snapshot-count node-count)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-resize-preserves-tp-mount-index ()
"Viewport resize should republish region ranges through TP mounts."
(ebox-test--reset-runtime-state)
(let* ((layout
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 24 collect
(ebox-test-box :id
(format "resize-mount-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :width
'(viewport) :height 1)))))
buffer
target-id)
(unwind-protect
(let ((ebox-viewport-width 240))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq target-id
(ebox-test--selector-region-id
buffer "#resize-mount-12"))
(ebox-rerender-buffer-with-context buffer 320)
(with-current-buffer buffer
(should (ebox-surface-region-mounts buffer target-id))
(ebox-test--region-update target-id :content "updated after resize")
(should (ebox-surface-region-mounts buffer target-id))
(should (string-match-p "updated after resize"
(buffer-substring-no-properties
(point-min) (point-max))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-render-cache-node-signature-is-render-pass-memoized ()
"Render cache signatures should not recurse twice for the same node."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-flex
:width 240
(ebox-test-column
(ebox-test-box (ebox-test-text "A") :width 80)
(ebox-test-box (ebox-test-text "B") :width 80))))
(layout-node (ebox-test-root layout))
(node-count (length (ebox--runtime-node-ids layout-node)))
(signature-count 0)
(original (symbol-function 'ebox--render-cache-plist-signature))
(ebox--render-cache-signature-cache
(make-hash-table :test 'eq)))
(cl-letf (((symbol-function 'ebox--render-cache-plist-signature)
(lambda (&rest args)
(cl-incf signature-count)
(apply original args))))
(ebox--render-cache-node-signature layout-node)
(ebox--render-cache-node-signature layout-node))
(should (= signature-count node-count))))
(ert-deftest ebox-render-cache-value-signature-preserves-dotted-pairs ()
"Native values such as sparse keymaps may contain dotted pairs."
(let ((value '(keymap (mouse-1 . ebox-test-activate)
(13 . ebox-test-activate))))
(should
(equal (ebox--render-cache-value-signature value)
value))))
(ert-deftest ebox-render-cache-signature-includes-inline-auto-width-context ()
"Row intrinsic sizing must not reuse a cross-axis stretch render."
(ebox-test--reset-runtime-state)
(let ((node (ebox-test-root
(ebox-test-box (ebox-test-text "context-sensitive")))))
(let ((normal (let ((ebox--inline-auto-width-intrinsic-p nil))
(ebox--render-cache-node-signature node)))
(intrinsic (let ((ebox--inline-auto-width-intrinsic-p t))
(ebox--render-cache-node-signature node))))
(should-not (equal normal intrinsic)))))
(ert-deftest ebox-render-cache-signature-includes-intrinsic-layout-context ()
"Final layout must not reuse an intrinsic measurement render."
(ebox-test--reset-runtime-state)
(let ((node (ebox-test-root
(ebox-test-box (ebox-test-text "context-sensitive") :width '(80)))))
(let ((final (let ((ebox--intrinsic-layout-measurement nil))
(ebox--render-cache-node-signature node)))
(intrinsic (let ((ebox--intrinsic-layout-measurement t))
(ebox--render-cache-node-signature node))))
(should-not (equal final intrinsic)))))
(ert-deftest ebox-render-cache-signature-includes-rendered-region-identity ()
"Cached render strings should never cross distinct region identities."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-test-root
(ebox-test-box (ebox-test-text "Stable") :width 80)))
(right (copy-tree left)))
(plist-put left :node-id 1)
(plist-put right :node-id 1)
(plist-put left :region-id 11)
(plist-put right :region-id 12)
(should-not
(equal (ebox--render-cache-node-signature left)
(ebox--render-cache-node-signature right)))))
(ert-deftest ebox-render-cache-probe-reuses-first-render-output ()
"The first cache probe should retain the identity used by its output."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-root
(ebox-test-box (ebox-test-text "Stable") :width 80)))
(ebox--surface-materialization-active t)
(ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
(ebox--box-content-render-cache (make-hash-table :test 'eq))
(ebox--region-box-table (make-hash-table :test 'equal))
(ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq))
(ebox--viewport-dependent-subtree-cache
(make-hash-table :test 'eq))
(first (ebox--render-with-cache node))
(probe (ebox--render-cache-probe node)))
(should (plist-member probe :rendered))
(should (equal-including-properties first
(plist-get probe :rendered)))))
(ert-deftest ebox-render-transaction-snapshots-display-signature ()
"One render transaction should sample its display signature once."
(ebox-test--reset-runtime-state)
(let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80)))
(calls 0))
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda ()
(list 'display (cl-incf calls)))))
(ebox--with-validated-display-cache
(ebox--render-cache-node-signature node)
(ebox--render-cache-node-signature node)))
(should (= calls 1))))
(ert-deftest ebox-render-transaction-nesting-reuses-display-signature ()
"A nested render transaction should reuse the outer display snapshot."
(ebox-test--reset-runtime-state)
(let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80)))
(calls 0))
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda ()
(list 'display (cl-incf calls)))))
(ebox--with-validated-display-cache
(ebox--with-validated-display-cache
(ebox--render-cache-node-signature node))))
(should (= calls 1))))
(ert-deftest ebox-render-transactions-refresh-display-signature ()
"Separate render transactions should sample display state independently."
(ebox-test--reset-runtime-state)
(let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80)))
(calls 0)
signatures)
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda ()
(list 'display (cl-incf calls)))))
(dotimes (_ 2)
(push
(ebox--with-validated-display-cache
(car (ebox--render-cache-node-signature node)))
signatures)))
(should (= calls 2))
(should-not (equal (car signatures) (cadr signatures)))))
(ert-deftest ebox-current-display-signature-is-live-outside-transaction ()
"The internal accessor should sample live state outside a transaction."
(ebox-test--reset-runtime-state)
(let ((calls 0)
signatures)
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda ()
(list 'display (cl-incf calls)))))
(setq signatures
(list (ebox--current-display-signature)
(ebox--current-display-signature))))
(should (= calls 2))
(should-not (equal (car signatures) (cadr signatures)))))
(ert-deftest ebox-public-display-signature-stays-live-in-render-transaction ()
"The public display signature should bypass a render transaction snapshot."
(ebox-test--reset-runtime-state)
(let ((calls 0)
signatures)
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda ()
(list 'display (cl-incf calls)))))
(setq signatures
(ebox--with-validated-display-cache
(list (ebox-display-signature)
(ebox-display-signature)))))
(should (= calls 3))
(should-not (equal (car signatures) (cadr signatures)))))
(ert-deftest ebox-clear-cache-samples-live-display-in-render-transaction ()
"Clearing caches should store live display state inside a transaction."
(ebox-test--reset-runtime-state)
(let ((calls 0))
(puthash '(stale) 1 ebox--string-pixel-width-cache)
(aset ebox--string-pixel-width-cache-ring 0 '(stale))
(setq ebox--string-pixel-width-cache-ring-index 1)
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda ()
(list 'display (cl-incf calls)))))
(ebox--with-validated-display-cache
(ebox-clear-cache)))
(should (= calls 2))
(should (= (hash-table-count ebox--string-pixel-width-cache) 0))
(should (= ebox--string-pixel-width-cache-ring-index 0))
(should (equal ebox--display-signature-cache '(display 2)))))
(ert-deftest ebox-render-cache-signature-keeps-viewport-axes-with-snapshot ()
"Display snapshots should not collapse width or height cache dimensions."
(ebox-test--reset-runtime-state)
(let ((node (ebox-test-root
(ebox-test-box (ebox-test-text "A")
:width '(viewport)
:height '(viewport-height))))
signatures)
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda () '(display stable))))
(ebox--with-validated-display-cache
(dolist (viewport '((80 10) (90 10) (90 11)))
(let ((ebox-viewport-width (car viewport))
(ebox-viewport-height (cadr viewport)))
(push (ebox--render-cache-node-signature node)
signatures)))))
(setq signatures (nreverse signatures))
(should (equal (mapcar #'car signatures)
'((display stable) (display stable) (display stable))))
(should-not (equal (nth 0 signatures) (nth 1 signatures)))
(should-not (equal (nth 1 signatures) (nth 2 signatures)))))
(ert-deftest ebox-render-cache-signature-ignores-unrelated-viewport-height ()
"Width-only subtree signatures should survive unrelated height changes."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-test-root
(ebox-test-column
(ebox-test-box (ebox-test-text "A") :width '(viewport))
(ebox-test-box (ebox-test-text "B") :width 80))))
(left (let ((ebox-viewport-width 320)
(ebox-viewport-height 10))
(ebox--render-cache-node-signature node)))
(right (let ((ebox-viewport-width 320)
(ebox-viewport-height 11))
(ebox--render-cache-node-signature node))))
(should (equal left right))))
(ert-deftest ebox-buffer-render-signatures-persist-across-transactions ()
"Unchanged runtime body signatures should survive buffer render transactions."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-flex
:width 240
(ebox-test-column
(ebox-test-box (ebox-test-text "A") :width 80)
(ebox-test-box (ebox-test-text "B") :width 80))))
(layout-node (ebox-test-root layout))
(node-count (length (ebox--runtime-node-ids layout-node)))
(signature-count 0)
(original (symbol-function 'ebox--render-cache-plist-signature))
buffer)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(when-let* ((cache
(plist-get (ebox--buffer-render-state buffer)
:render-signature-cache)))
(clrhash cache))
(cl-letf (((symbol-function 'ebox--render-cache-plist-signature)
(lambda (&rest args)
(cl-incf signature-count)
(apply original args))))
(ebox--with-buffer-render-context buffer
(ebox--render-cache-node-body-signature layout-node))
(ebox--with-buffer-render-context buffer
(ebox--render-cache-node-body-signature layout-node)))
(should (= signature-count node-count)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-render-cache-plist-signature-avoids-append-churn ()
"Render cache plist signatures should be built linearly."
(ebox-test--reset-runtime-state)
(let ((node (ebox-test-root
(ebox-test-box (ebox-test-text "Signature") :width 80 :border '(1 solid))))
(original-append (symbol-function 'append))
(append-calls 0))
(cl-letf (((symbol-function 'append)
(lambda (&rest args)
(cl-incf append-calls)
(apply original-append args))))
(ebox--render-cache-plist-signature node))
(should (= append-calls 0))))
(ert-deftest ebox-preformatted-exact-content-skips-justify-measurement ()
"Exact-width preformatted content should not be remeasured for left justify."
(ebox-test--reset-runtime-state)
(let* ((content (ebox-lines-join
(list (ebox-pixel-space 80)
(ebox-pixel-space 80))))
(box-input
(ebox-test-box (ebox-test-text content) :width 80 :wrap-mode 'none))
(box (ebox-test-root box-input))
(ebox--render-source-index (ebox-test-source-index box-input))
(measure-count 0)
(original (symbol-function 'ebox--string-pixel-width)))
(plist-put box :ebox-content-width-exact-p t)
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (&rest args)
(cl-incf measure-count)
(apply original args))))
(ebox--format-content box))
(should (= measure-count 0))))
(ert-deftest ebox-uniform-content-skips-noop-word-and-char-formatting ()
"Exact uniform lines should bypass proven no-op word/char formatting."
(ebox-test--reset-runtime-state)
(dolist (wrap-mode '(word char))
(let* ((box-input
(ebox-test-box (ebox-test-text "") :width '(120)
:wrap-mode wrap-mode :text-align 'left))
(box (ebox-test-root box-input))
(ebox--render-source-index (ebox-test-source-index box-input))
(pixel-width (ebox--content-pixel box))
(line (propertize (ebox-pixel-space pixel-width)
'face '(:foreground "#123456")))
(content (concat line "\n" line))
(wrap-count 0)
(justify-count 0)
(original-wrap (symbol-function 'ebox--wrap-text))
(original-justify (symbol-function 'ebox--lines-justify)))
(ebox--record-rendered-uniform-width content pixel-width)
(cl-letf (((symbol-function 'ebox--wrap-text)
(lambda (&rest arguments)
(cl-incf wrap-count)
(apply original-wrap arguments)))
((symbol-function 'ebox--lines-justify)
(lambda (&rest arguments)
(cl-incf justify-count)
(apply original-justify arguments))))
(should (eq (ebox--format-content-string box content) content)))
(should (= wrap-count 0))
(should (= justify-count 0)))))
(ert-deftest ebox-uniform-content-keeps-semantic-formatting-paths ()
"KP, non-left alignment and width mismatch must retain formatting."
(ebox-test--reset-runtime-state)
(dolist (case '((kp left 120)
(word center 120)
(char left 119)))
(let* ((box-input
(ebox-test-box (ebox-test-text "") :width '(120)
:wrap-mode (nth 0 case)
:text-align (nth 1 case)))
(box (ebox-test-root box-input))
(ebox--render-source-index (ebox-test-source-index box-input))
(content (ebox-pixel-space 120))
(wrap-count 0)
(original-wrap (symbol-function 'ebox--wrap-text)))
(ebox--record-rendered-uniform-width content (nth 2 case))
(cl-letf (((symbol-function 'ebox--wrap-text)
(lambda (&rest arguments)
(cl-incf wrap-count)
(apply original-wrap arguments))))
(ebox--format-content-string box content))
(should (> wrap-count 0)))))
(ert-deftest ebox-transparent-preformatted-box-fast-path-is-exact ()
"Transparent exact-width wrappers should match the generic box pipeline."
(ebox-test--reset-runtime-state)
(let* ((child-a (ebox-test-box (ebox-test-text "A") :width '(viewport) :border '(1 solid)
:region-id 'child-a))
(child-b (ebox-test-box (ebox-test-text "B") :width '(viewport) :border '(1 solid)
:region-id 'child-b))
(parent (ebox-test-column
:width '(viewport) :region-id 'parent child-a child-b))
generic fast)
(let ((ebox-viewport-width 120)
(ebox--transparent-preformatted-box-fast-path-disabled t))
(setq generic (ebox-render parent)))
(clrhash ebox--rendered-uniform-width-table)
(let ((ebox-viewport-width 120))
(setq fast (ebox-render parent)))
(should (equal-including-properties fast generic))))
(ert-deftest ebox-flat-preformatted-fast-path-keeps-intrinsic-scroll-state ()
"Intrinsic flat rendering must not clear the published scroll state."
(ebox-test--reset-runtime-state)
(let* ((box-input
(ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none
:region-id 'intrinsic-wrapper))
(box (ebox-test-root box-input))
(content (ebox-pixel-space 40))
(state (list :scroll-offset 0 :sentinel t))
region-id)
(plist-put box :content content)
(plist-put box :ebox-content-width-exact-p t)
(setq region-id (ebox--ensure-region-id box))
(puthash region-id state ebox--scroll-global-state)
(let ((ebox--intrinsic-layout-measurement t))
(ebox-test--render-input-node box-input))
(should (eq (gethash region-id ebox--scroll-global-state) state))))
(ert-deftest ebox-flat-preformatted-blank-line-preserves-child-owner-segments ()
"Blank composite child segments must retain their distinct ownership."
(ebox-test--reset-runtime-state)
(let* ((left
(propertize
(ebox-pixel-space 40)
'ebox-content 'left 'ebox-content-idx 0
'ebox-content-owner 'left 'ebox-content-owners '(left)
'face '(:foreground "#112233")))
(right
(propertize
(ebox-pixel-space 60)
'ebox-content 'right 'ebox-content-idx 0
'ebox-content-owner 'right 'ebox-content-owners '(right)
'face '(:foreground "#112233")))
(box-input
(ebox-test-box (ebox-test-text "") :width 100 :wrap-mode 'none
:bgcolor "#DDEEFF"))
(box (ebox-test-root box-input))
(ebox--render-source-index (ebox-test-source-index box-input))
(rendered
(car
(ebox--window-render-flat-wrapper-chunk-lines
box (list (concat left right)) 0 'outer
(ebox-pixel-space 100) t t))))
(should (= (length rendered) 2))
(should (= (ebox--string-pixel-width rendered) 100))
(should (eq (get-text-property 0 'ebox-content rendered) 'left))
(should (eq (get-text-property 1 'ebox-content rendered) 'right))
(should (equal (get-text-property 0 'ebox-content-owners rendered)
'(left outer)))
(should (equal (get-text-property 1 'ebox-content-owners rendered)
'(right outer)))
(should (eq (get-text-property 0 'ebox-content-owner rendered) 'outer))
(should (eq (get-text-property 1 'ebox-content-owner rendered) 'outer))
(dolist (position '(0 1))
(let ((face (get-text-property position 'face rendered)))
(should (ebox-test--face-has-key-value-p
face :foreground "#112233"))
(should (ebox-test--face-has-key-value-p
face :background "#DDEEFF"))))))
(ert-deftest ebox-transparent-preformatted-fast-path-keeps-intrinsic-scroll-state ()
"Intrinsic transparent rendering must not clear the published scroll state."
(ebox-test--reset-runtime-state)
(let* ((child-input
(ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none
:region-id 'transparent-child))
(child (ebox-test-root child-input))
(parent (ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none
:region-id 'transparent-parent
child-input))
(content (ebox-pixel-space 40))
(state (list :scroll-offset 0 :sentinel t))
region-id)
(plist-put child :content content)
(plist-put child :ebox-content-width-exact-p t)
(ebox-test--render-input-node parent)
(clrhash ebox--region-box-table)
(setq region-id (ebox--ensure-region-id (ebox-test-root parent)))
(puthash region-id state ebox--scroll-global-state)
(let ((ebox--intrinsic-layout-measurement t))
(ebox-test--render-input-node parent))
(should (eq (gethash region-id ebox--scroll-global-state) state))))
(ert-deftest ebox-flat-preformatted-fixed-box-rejects-layout-work ()
"Fixed wrappers should stay generic when clipping or alignment is required."
(ebox-test--reset-runtime-state)
(dolist (case '(("two\nlines" hidden top)
("one\ntwo\nthree\nfour" hidden top)
("one\ntwo\nthree" scroll top)
("one\ntwo\nthree" hidden center)))
(let* ((box-input
(ebox-test-box
(ebox-test-text (nth 0 case)) :width '(120) :height 5
:box-sizing 'border-box :overflow (nth 1 case)
:wrap-mode 'none))
(box (ebox-test-root box-input))
(calls 0)
(original
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)))
(plist-put box :vertical-align (nth 2 case))
(plist-put box :ebox-content-width-exact-p t)
(cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
(lambda (&rest arguments)
(cl-incf calls)
(apply original arguments))))
(ebox-test--render-input-node box-input))
(should (= calls 0)))))
(ert-deftest ebox-string-pixel-width-cache-ignores-ebox-ownership-metadata ()
"Reuse visual width measurements across Ebox-owned text properties."
(let ((line (propertize "cached row"
'ebox-content 7
'ebox-content-idx 0
'ebox-content-owner 7
'ebox-content-owners '(7)))
(calls 0)
(original (symbol-function 'ebox--scaled-string-pixel-width)))
(ebox--clear-string-pixel-width-cache)
(cl-letf (((symbol-function 'ebox--scaled-string-pixel-width)
(lambda (&rest args)
(cl-incf calls)
(apply original args))))
(let ((first (ebox--string-pixel-width line))
(second (ebox--string-pixel-width (copy-sequence line))))
(should (> first 0))
(should (= first second))))
(should (= calls 1))))
(ert-deftest ebox-buffer-update-report-includes-dirty-and-patch-summary ()
"Update reports should show dirty and patch-set summaries."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Report") :width 80
:border '(1 solid "red")))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :border-color "blue")
(let ((report (ebox-test--buffer-update-report)))
(should (plist-member report :dirty-count))
(should (plist-member report :patch-count))
(should (plist-member report :patch-ops))
(should (equal (plist-get report :dirty-keys)
'(:border-top-color :border-right-color
:border-bottom-color :border-left-color)))))))
(ert-deftest ebox-buffer-update-report-is-buffer-local ()
"Updating one rendered buffer should not replace another buffer's report."
(ebox-test--reset-runtime-state)
(let* ((first-box (ebox-test-box (ebox-test-text "First") :width '(80)))
(second-box (ebox-test-box (ebox-test-text "Second") :width '(80)))
(first-id (car (ebox-region-ids first-box)))
first-buffer second-buffer)
(unwind-protect
(progn
(setq first-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-report-first*")
first-box))
(setq second-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-report-second*")
second-box))
(ebox-test--set-buffer-update-report
second-buffer '(:strategy second-prior :owner-ids (second)))
(with-current-buffer first-buffer
(let ((report (ebox-test--region-update first-id :content "First!")))
(should (equal report
(ebox-buffer-update-report first-buffer)))
(should-not (equal report
(ebox-buffer-update-report
second-buffer)))
(should (equal (ebox-buffer-update-report second-buffer)
'(:strategy second-prior
:owner-ids (second)))))))
(dolist (buffer (list first-buffer second-buffer))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-buffer-update-report-returns-defensive-copy ()
"The public accessor should not return mutable render-state storage."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Copy") :width '(80)))
buffer)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-report-copy*")
box))
(ebox-test--set-buffer-update-report
buffer '(:strategy paint-patch :owner-ids (1 2)))
(let ((report (ebox-buffer-update-report buffer)))
(should (equal report '(:strategy paint-patch :owner-ids (1 2))))
(plist-put report :strategy 'mutated)
(setcar (plist-get report :owner-ids) 99))
(should (equal (ebox-buffer-update-report buffer)
'(:strategy paint-patch :owner-ids (1 2)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-buffer-update-report-user-errors-without-rendered-buffer ()
"The public accessor should reject missing, dead, and unrendered buffers."
(ebox-test--reset-runtime-state)
(let ((dead (generate-new-buffer " *ebox-report-dead*"))
(unrendered (generate-new-buffer " *ebox-report-unrendered*")))
(unwind-protect
(progn
(kill-buffer dead)
(should-error (ebox-buffer-update-report nil) :type 'user-error)
(should-error (ebox-buffer-update-report dead) :type 'user-error)
(should-error (ebox-buffer-update-report unrendered)
:type 'user-error))
(when (buffer-live-p unrendered)
(kill-buffer unrendered)))))
(ert-deftest ebox-region-update-return-matches-buffer-update-report ()
"A direct region update should return the report stored on its buffer."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Return") :width '(80)
:color "#111111"))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((report (ebox-test--region-update region-id :color "#222222")))
(should (equal report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))))))
(ert-deftest ebox-region-update-no-op-replaces-stale-buffer-report ()
"A direct unchanged update should publish its own no-op report."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "No-op") :width '(80)
:color "#111111"))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((prior '(:strategy paint-patch :owner-ids (prior))))
(ebox-test--set-buffer-update-report (current-buffer) prior)
(let ((report
(ebox-test--region-update region-id :color "#111111")))
(should (eq (plist-get report :strategy) 'no-op))
(should (= (plist-get report :dirty-count) 0))
(should (= (plist-get report :patch-count) 0))
(should (equal report (ebox-test--buffer-update-report)))
(should-not (equal report prior)))))))
(ert-deftest ebox-batched-region-update-keeps-prior-report-until-flush ()
"Batched region updates should return nil and publish only on flush."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Batch") :width '(80)
:color "#111111"))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((buffer (current-buffer))
(prior '(:strategy prior :owner-ids (prior))))
(ebox-test--set-buffer-update-report buffer prior)
(ebox-incremental-begin-batch buffer)
(should-not (ebox-test--region-update region-id :color "#333333"))
(should (equal (ebox-buffer-update-report buffer) prior))
(let ((flush-report (ebox-incremental-flush buffer)))
(should flush-report)
(should (equal flush-report
(ebox-buffer-update-report buffer)))
(should (eq (plist-get flush-report :strategy)
'paint-patch)))))))
(ert-deftest ebox-batch-flush-stores-report-on-flushed-buffer-only ()
"Empty and non-empty flushes should update only the target buffer report."
(ebox-test--reset-runtime-state)
(let* ((first-box (ebox-test-box (ebox-test-text "First") :width '(80)
:color "#111111"))
(second-box (ebox-test-box (ebox-test-text "Second") :width '(80)
:color "#111111"))
(second-id (car (ebox-region-ids second-box)))
first-buffer second-buffer)
(unwind-protect
(progn
(setq first-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-flush-first*")
first-box))
(setq second-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-flush-second*")
second-box))
(ebox-test--set-buffer-update-report
first-buffer '(:strategy first-prior))
(ebox-test--set-buffer-update-report
second-buffer '(:strategy second-prior))
(ebox-incremental-begin-batch first-buffer)
(let ((first-report (ebox-incremental-flush first-buffer)))
(should (eq (plist-get first-report :strategy) 'no-op))
(should (equal (ebox-buffer-update-report first-buffer)
first-report))
(should (equal (ebox-buffer-update-report second-buffer)
'(:strategy second-prior)))
(with-current-buffer second-buffer
(ebox-incremental-begin-batch second-buffer)
(should-not
(ebox-test--region-update second-id :color "#444444")))
(let ((second-report (ebox-incremental-flush second-buffer)))
(should (eq (plist-get second-report :strategy) 'paint-patch))
(should (equal (ebox-buffer-update-report second-buffer)
second-report))
(should (equal (ebox-buffer-update-report first-buffer)
first-report)))))
(dolist (buffer (list first-buffer second-buffer))
(when (buffer-live-p buffer)
(kill-buffer buffer))))))
(ert-deftest ebox-render-to-buffer-does-not-switch-current-buffer ()
(ebox-test--reset-runtime-state)
(let ((origin (current-buffer))
buffer)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
(ebox-test-box (ebox-test-text "Hello") :width 40)))
(should (eq (current-buffer) origin))
(should (buffer-live-p buffer))
(with-current-buffer buffer
(should ebox-buffer-mode)
(should buffer-read-only)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-render-to-buffer-keeps-existing-local-map-as-parent ()
(ebox-test--reset-runtime-state)
(let* ((buffer-name (generate-new-buffer-name " *ebox-test*"))
(buffer (get-buffer-create buffer-name))
(parent-map (make-sparse-keymap)))
(define-key parent-map (kbd "x") #'ignore)
(unwind-protect
(with-current-buffer buffer
(use-local-map parent-map)
(ebox-render-to-buffer buffer-name
(ebox-test-box (ebox-test-text "Hello") :width 40))
(should (eq (current-local-map) parent-map))
(should (eq (lookup-key (current-local-map) (kbd "x")) #'ignore)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-buffer-kill-clears-region-runtime-caches ()
"Killing an ebox buffer should clear region-scoped runtime cache entries."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :width 80 :height 1
:overflow 'scroll))
(region-id (car (ebox-region-ids box)))
(buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
box)))
(should (gethash region-id ebox--region-box-table))
(should (ebox--scroll-get-state region-id))
(kill-buffer buffer)
(should-not (gethash region-id ebox--region-box-table))
(should-not (ebox--scroll-get-state region-id))))
(ert-deftest ebox-buffer-kill-discards-buffer-owned-runtime-state ()
"Killing an Ebox buffer should discard every buffer-keyed runtime entry."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Lifecycle") :color "#111111"))
(region-id (car (ebox-region-ids box)))
buffer surface handle)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*") box))
(setq surface
(with-current-buffer buffer ebox-surface--buffer-surface)
handle (ebox-test--region-handle-for-id region-id))
(ebox-test--region-update region-id :color "#222222")
;; A later update remains pending in the explicit batch.
(ebox-incremental-begin-batch buffer)
(ebox-test--region-update region-id :background-color "#eeeeee")
(should (gethash buffer ebox--buffer-render-state-table))
(should (gethash buffer ebox-incremental--batch-table))
(should (tp-surface-live-p surface))
(should (ebox-buffer-update-report buffer))
(should (= (ebox-incremental-pending-count buffer) 1))
(kill-buffer buffer)
(should-not (gethash buffer ebox--buffer-render-state-table))
(should-not (gethash buffer ebox-cache--buffer-report-table))
(should-not (gethash buffer ebox-incremental--batch-table))
(should-not (tp-surface-live-p surface))
(should-error (ebox-region-update handle :content "stale")
:type 'user-error)
(should-error (ebox-buffer-update-report buffer)
:type 'user-error))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))
(when buffer
(remhash buffer ebox--buffer-render-state-table)
(remhash buffer ebox-cache--buffer-report-table)
(remhash buffer ebox-incremental--batch-table)))))
(ert-deftest ebox-geometry-update-rerenders-root-once-safely ()
(ebox-test--reset-runtime-state)
(let* ((top (ebox-test-box (ebox-test-text "Top") :width 60))
(left (ebox-test-box (ebox-test-text "L") :width 40))
(right (ebox-test-box (ebox-test-text "R") :width 40))
(layout (ebox-test-column top (ebox-test-row left right)))
(ids (ebox-region-ids layout))
(left-id (nth 1 ids)))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update left-id :width '(120))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "Top" plain))
(should (string-match-p "L" plain))
(should (string-match-p "R" plain)))
(should (equal (ebox-region-ids layout) ids)))))
(ert-deftest ebox-border-color-update-patches-without-full-root-rerender ()
"A pure border color change should patch owned spans, not rebuild the root."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-row
(ebox-test-box :id 'left :width 40 :border "red" (ebox-test-text "L"))
(ebox-test-box :id 'right :width 40 :border "green"
(ebox-test-text "R")))))
(ebox-test--with-rendered-buffer layout
(let ((left-id (ebox-test--selector-region-id
(current-buffer) "#left")))
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update left-id :border-color "blue"))))
(should (= full-rerenders 0)))
(goto-char (point-min))
(search-forward "L")
(let ((border-face (get-text-property (match-beginning 0) 'face)))
(should (ebox-test--face-has-key-value-p border-face
:overline "blue"))))
(should (string-match-p "R" (buffer-string))))))
(ert-deftest ebox-flex-row-border-color-update-patches-region-role ()
"Paint-only updates inside flex rows should not fall back to root rerender."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding
'(0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
(ebox-test-column
(ebox-test-box :id 'left (ebox-test-text "Left") :width
'(120) :padding '(0 (8)) :border
"#D97757")
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
'(0 (8)) :border-right "#D97757"
:border-bottom "#D97757" :border-left
"#D97757"))
(ebox-test-column
(ebox-test-box :id 'right (ebox-test-text "Right") :width
'(120) :padding '(0 (8)) :border
"#5E7F6A")
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
'(0 (8)) :border-right "#5E7F6A"
:border-bottom "#5E7F6A" :border-left
"#5E7F6A"))))
(left-id (plist-get
(car (ebox-selector-query-all layout "#left"))
:region-id)))
(ebox-test--with-rendered-buffer layout
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update left-id :border-bottom-color "blue"))))
(should (= full-rerenders 0)))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))
(should-not (plist-get report :root-rerender)))
(let (bottom-face)
(save-excursion
(goto-char (point-min))
(while (and (not bottom-face) (< (point) (point-max)))
(when (equal (get-text-property (point) 'ebox-bb) left-id)
(setq bottom-face (get-text-property (point) 'face)))
(forward-char 1)))
(should (ebox-test--face-has-key-value-p
bottom-face :underline '(:position t :color "blue")))))))
(ert-deftest ebox-paint-update-synthesizes-missing-horizontal-border-surface ()
"Adding a missing top/bottom paint surface should patch from region anchors."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding
'(0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
(ebox-test-column
(ebox-test-box :id 'left (ebox-test-text "Left") :width
'(120) :padding '(0 (8)) :border-top
"#D97757" :border-right "#D97757"
:border-left "#D97757")
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
'(0 (8)) :border-right "#D97757"
:border-bottom "#D97757" :border-left
"#D97757"))
(ebox-test-column
(ebox-test-box (ebox-test-text "Right") :width '(120)
:padding '(0 (8)) :border-top
"#5E7F6A" :border-right "#5E7F6A"
:border-left "#5E7F6A")
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
'(0 (8)) :border-right "#5E7F6A"
:border-bottom "#5E7F6A" :border-left
"#5E7F6A"))))
(left-id (plist-get
(car (ebox-selector-query-all layout "#left"))
:region-id)))
(ebox-test--with-rendered-buffer layout
(should-not (text-property-search-forward 'ebox-bb left-id t))
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update
left-id :border-bottom '(1 solid "blue")))))
(should (= full-rerenders 0)))
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender)))
(should-not (plist-get report :root-rerender)))
(let (bottom-face)
(save-excursion
(goto-char (point-min))
(while (and (not bottom-face) (< (point) (point-max)))
(when (equal (get-text-property (point) 'ebox-bb) left-id)
(setq bottom-face (get-text-property (point) 'face)))
(forward-char 1)))
(should (ebox-test--face-has-key-value-p
bottom-face :underline '(:position t :color "blue")))))))
(ert-deftest ebox-paint-projection-keeps-old-fragment-metadata-immutable ()
"Paint projection should not mutate the previous state's fragment metadata."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding
'(0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
(ebox-test-column
(ebox-test-box :id 'left (ebox-test-text "Left") :width
'(120) :padding '(0 (8)) :border-top
"#D97757" :border-right "#D97757"
:border-left "#D97757")
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
'(0 (8)) :border-right "#D97757"
:border-bottom "#D97757" :border-left
"#D97757"))
(ebox-test-column
(ebox-test-box (ebox-test-text "Right") :width '(120)
:padding '(0 (8)) :border-top
"#5E7F6A" :border-right "#5E7F6A"
:border-left "#5E7F6A")
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
'(0 (8)) :border-right "#5E7F6A"
:border-bottom "#5E7F6A" :border-left
"#5E7F6A"))))
(left-id (plist-get
(car (ebox-selector-query-all layout "#left"))
:region-id)))
(ebox-test--with-rendered-buffer layout
(let* ((old-state (ebox--buffer-render-state (current-buffer)))
(old-fragments (plist-get old-state :surface-fragments))
(old-metadata (copy-tree old-fragments)))
(save-restriction
(goto-char (1+ (point-min)))
(narrow-to-region (point) (point-max))
(ebox-test--region-update
left-id :border-bottom '(1 solid "blue")))
(should (equal old-metadata
(plist-get old-state :surface-fragments)))
(should (cl-every (lambda (fragment)
(not (memq :text fragment)))
old-fragments))))))
(ert-deftest ebox-horizontal-border-removal-preserves-other-border-colors ()
"Removing top/bottom border surfaces must not corrupt other border faces."
(ebox-test--reset-runtime-state)
(dolist (case '(((:border-top-p nil) bt (bb bl br))
((:border-bottom-width 0) bb (bt bl br))))
(let* ((update (nth 0 case))
(removed-role (nth 1 case))
(kept-roles (nth 2 case))
(layout (ebox-test-box (ebox-test-text "Border")
:width 80
:padding '(0 (8))
:border "#D97757"))
(region-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(cl-labels ((face-at-role
(role)
(let ((prop (alist-get role ebox-region-types))
face)
(save-excursion
(goto-char (point-min))
(while (and (not face) (< (point) (point-max)))
(when (equal (get-text-property (point) prop)
region-id)
(setq face (get-text-property (point) 'face)))
(forward-char 1)))
face)))
(apply #'ebox-test--region-update region-id update)
(should-not (face-at-role removed-role))
(dolist (role kept-roles)
(should
(ebox-test--border-face-has-color-p
(face-at-role role) role "#D97757"))))))))
(ert-deftest ebox-horizontal-border-capability-error-preserves-mounted-state ()
"An unsupported horizontal width must fail before replacing publication."
(ebox-test--reset-runtime-state)
(let* ((input
(ebox-test-box :id "root" (ebox-test-text "Border")
:border '(1 solid "#D97757")))
(buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-border-capability*")
input))
(state (ebox--buffer-render-state buffer))
(text (with-current-buffer buffer (buffer-string))))
(unwind-protect
(progn
(should-error
(ebox-selector-update-buffer
buffer "#root" :border-top '(2 solid "#D97757")))
(should (eq state (ebox--buffer-render-state buffer)))
(should (equal-including-properties
text (with-current-buffer buffer (buffer-string)))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-side-border-color-patch-replaces-paint-exactly ()
"Side color replacements should match the full-render paint result."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box
(ebox-test-text "Border") :width 80
:border-left-width 2 :border-left-style 'solid
:border-left-color "#AA0000"
:border-right-width 2 :border-right-style 'solid
:border-right-color "#AA0000"))
(region-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update region-id
:border-left-color "#00AA00"
:border-right-color "#00AA00")
(dolist (role '(bl br))
(let ((face
(ebox-test--face-at-property
(buffer-string) (alist-get role ebox-region-types))))
(should-not (ebox-test--face-has-key-p face :inverse-video))
(should
(ebox-test--face-has-key-value-p
face :background "#00AA00"))))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-rerender-buffer-with-context-resolves-runtime-viewport ()
"Runtime rerender should resolve viewport-sized boxes with the new context."
(ebox-test--reset-runtime-state)
(let* ((node (let ((ebox-viewport-width 240))
(ebox-test-box (ebox-test-text "Viewport") :width '(viewport) :border '(1 solid))))
buffer)
(unwind-protect
(progn
(should (eq (ebox-get (ebox-test-root node) :width) 'viewport))
(let ((ebox-viewport-width 240))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node)))
(with-current-buffer buffer
(should (= (ebox--string-pixel-width
(buffer-substring (point-min) (line-end-position)))
240)))
(ebox-rerender-buffer-with-context buffer 160)
(with-current-buffer buffer
(should (= (ebox--string-pixel-width
(buffer-substring (point-min) (line-end-position)))
160))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-rerender-buffer-with-context-resolves-runtime-viewport-height ()
"Runtime rerender should resolve viewport-height boxes with the new context."
(ebox-test--reset-runtime-state)
(let* ((node (let ((ebox-viewport-height 2))
(ebox-test-box (ebox-test-text "One\nTwo\nThree\nFour") :width '(120) :height
'(viewport-height) :overflow 'scroll)))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-height 2))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node)))
(with-current-buffer buffer
(should (= (length (ebox-string-lines
(buffer-substring-no-properties
(point-min) (point-max))))
2)))
(ebox-rerender-buffer-with-context buffer 120 3)
(with-current-buffer buffer
(should (= (length (ebox-string-lines
(buffer-substring-no-properties
(point-min) (point-max))))
3))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-flex-contained-geometry-update-does-not-promote-to-root ()
"Stable flex-contained geometry changes should stop before root."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-column :id 'root :width '(520) :padding '(1 (18))
(ebox-test-flex :id 'matrix :flex-flow '(row wrap)
:column-gap '(12) :row-gap 1
:padding '(0 (12)) :border "#999999"
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Title")
:width '(190))
(ebox-test-box :id
'target
(ebox-test-text "width target\nsame height")
:width
'(160)
:height
3
:padding
'(0
(8))
:border
"#999999")
(ebox-test-box
(ebox-test-text "Metric")
:width '(190)))
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Other")
:width '(190))
(ebox-test-box
(ebox-test-text "Still here")
:width '(190))))))
(target (car (ebox-selector-query-all layout "#target")))
(target-id (plist-get target :region-id)))
(ebox-test--with-rendered-buffer layout
(let ((root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update target-id :width '(190))
(let ((report (ebox-test--buffer-update-report)))
(should-not (plist-get report :full-rerender))
(should-not (member root-id (plist-get report :owner-ids)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender)))
(should (< (plist-get report :span-count) 40)))))))
(defun ebox-test--nested-width-containment-layout (&optional height)
"Return a nested flex width-update fixture with optional fixed HEIGHT."
(let* ((target
(ebox-test-box (ebox-test-text "width target\nready to grow")
:width '(190) :height 3 :padding '(0 (10))
:border "#999999"))
(owner
(apply #'ebox-test-flex
(append
(list :width '(300) :padding '(0 (10))
:border "#999999" :justify-content 'center
:align-items 'center)
(when height (list :height height))
(list target))))
(card
(ebox-test-column
(ebox-test-box (ebox-test-text "Width") :width '(300) :height 2)
owner
(ebox-test-box (ebox-test-text "The target stays in this card")
:width '(300) :height 3)
(ebox-test-box (ebox-test-text "Metric") :width '(300) :height 4)))
(peer
(ebox-test-column
(ebox-test-box (ebox-test-text "Peer") :width '(300) :height 2)
(ebox-test-box (ebox-test-text "Stable peer") :width '(300) :height 5)
(ebox-test-box (ebox-test-text "Footer") :width '(300) :height 7)))
(layout
(ebox-test-flex :width '(720) :flex-flow '(row wrap)
:column-gap '(12) card peer)))
(list :layout layout :target target :owner owner)))
(ert-deftest ebox-visible-overflow-flex-wrapper-disables-width-containment ()
"A visible-overflow flex wrapper must not contain child width changes."
(ebox-test--reset-runtime-state)
(let* ((target
(ebox-test-box (ebox-test-text "child") :width '(190)
:height 5 :min-height 5))
(owner
(ebox-test-flex :width '(300) :height 2 :overflow 'visible
:align-items 'flex-start target))
(layout
(ebox-test-column owner
(ebox-test-box (ebox-test-text "footer") :width '(300)
:height 2)))
(owner-node-id (ebox--ensure-node-id (ebox-test-root owner))))
(ebox-test--with-rendered-buffer layout
(should (= (ebox-string-height (ebox-render owner)) 5))
(should-not (ebox--definite-containment-formatting-context-p
(current-buffer) owner-node-id)))))
(ert-deftest ebox-fixed-flex-own-width-change-promotes-outside-containment ()
"A fixed flex must not contain a change to its own outer width."
(ebox-test--reset-runtime-state)
(let* ((fixture (ebox-test--nested-width-containment-layout 5))
(layout (plist-get fixture :layout))
(owner (plist-get fixture :owner))
(owner-region-id (car (ebox-region-ids owner))))
(ebox-test--with-rendered-buffer layout
(let ((root-node-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update owner-region-id :width '(320))
(let ((report (ebox-test--buffer-update-report)))
(should (equal (plist-get report :owner-ids)
(list root-node-id)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-flex-main-footprint-change-skips-flex-span-probe ()
"Flex main footprint changes should not render a large flex owner twice."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-column :id 'root :width '(520) :padding '(1 (18))
(ebox-test-flex :id 'matrix :flex-flow '(row wrap)
:column-gap '(12) :row-gap 1
:padding '(0 (12)) :border "#999999"
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Title")
:width '(190))
(ebox-test-box :id
'target
(ebox-test-text "width target\nsame height")
:width
'(160)
:height
3
:padding
'(0
(8))
:border
"#999999")
(ebox-test-box
(ebox-test-text "Metric")
:width '(190)))
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Other")
:width '(190))
(ebox-test-box
(ebox-test-text "Still here")
:width '(190))))))
(target (car (ebox-selector-query-all layout "#target")))
(target-id (plist-get target :region-id)))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update target-id
:width '(210)
:min-width '(150)
:max-width '(260))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (plist-get report :root-rerender))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(defun ebox-test--flex-line-width-owner-layout (&optional count width)
"Return a wide flex layout whose first row has a nested width target."
(let* ((count (or count 12))
(resolved-width (if (eq width :auto)
nil
(or width '(2600))))
(target
(ebox-test-box :id "target" (ebox-test-text "width target\nmoves sibling layout")
:width '(160) :height 3 :padding '(0 (8))
:border "#999999"))
(first
(ebox-test-column
(ebox-test-box (ebox-test-text "Title") :width '(190))
target
(ebox-test-box (ebox-test-text "Metric") :width '(190) :height 4))))
(apply #'ebox-test-flex
(append (list :flex-flow '(row wrap)
:column-gap '(12)
:row-gap 1
:padding '(0 (12))
:border "#999999")
(when resolved-width
(list :width resolved-width))
(list first)
(cl-loop for index from 1 below count
collect
(ebox-test-column
(ebox-test-box
(ebox-test-text (format "Title %02d" index))
:width '(190))
(ebox-test-box (ebox-test-text "Peer\nsame row")
:width '(190) :height 3
:padding '(0 (8))
:border "#999999")
(ebox-test-box (ebox-test-text "Metric")
:width '(190) :height 4)))))))
(ert-deftest ebox-flex-owner-update-keeps-unrelated-snapshots-current ()
"A scoped flex update should leave unrelated snapshots valid."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--flex-line-width-owner-layout 16)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))
(unrelated-child-id
(plist-get
(car (last (ebox--node-children
(ebox-test--runtime-root (current-buffer)))))
:node-id)))
(ebox-test--region-update region-id
:width '(210)
:min-width '(150)
:max-width '(260))
(let ((report (ebox-test--buffer-update-report)))
(should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0))
(should-not (plist-get report :tp-scope-fallback))
(should
(plist-get
(ebox--ensure-layout-snapshot-spans
(current-buffer) unrelated-child-id)
:buffer-spans))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(defun ebox-test--dynamic-update-row-layout ()
"Return a small flex-row layout shaped like the dynamic update matrix."
(ebox-test-column :id 'root :width '(1200) :padding '(1 (18))
(ebox-test-flex :id 'matrix :flex-flow '(row wrap)
:column-gap '(12) :row-gap 1
:padding '(0 (12)) :border "#999999"
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Reference\nshort")
:width '(190))
(ebox-test-box :id
'scroll
(ebox-test-text "line 1\nline 2\nline 3\nline 4")
:width
'(190)
:height
2
:padding
'(0
(8))
:border
"#999999"
:overflow
'scroll)
(ebox-test-box
(ebox-test-text "Metric")
:width '(190)
:height 4))
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Height\nowner")
:width '(190))
(ebox-test-box :id
'height
(ebox-test-text "height target\nexpands block")
:width
'(190)
:height
2
:padding
'(0
(8))
:border
"#999999")
(ebox-test-box
(ebox-test-text "Metric")
:width '(190)
:height 4))
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Margin\nslot stable")
:width '(190))
(ebox-test-box :id
'margin
(ebox-test-text "margin target\nparent placement")
:width
'(170)
:height
3
:padding
'(0
(8))
:border
"#999999")
(ebox-test-box
(ebox-test-text "Metric")
:width '(190)
:height 4))
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Overflow\nowner")
:width '(190))
(ebox-test-box :id
'overflow
(ebox-test-text "overflow target\nextra line\nanother line")
:width
'(190)
:height
2
:padding
'(0
(8))
:border
"#999999"
:overflow
'scroll)
(ebox-test-box
(ebox-test-text "Metric")
:width '(190)
:height 4))
(ebox-test-column :width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Tall peer\nkeeps row slot")
:width '(190))
(ebox-test-box
(ebox-test-text "tall\npeer\nsets\nline\ncross")
:width '(190)
:height 5
:padding '(0 (8))
:border "#999999")
(ebox-test-box
(ebox-test-text "Metric")
:width '(190)
:height 4)))))
(defun ebox-test--dynamic-update-tight-row-layout ()
"Return a flex-row layout whose target growth exceeds its old item slot."
(ebox-test-column :id 'root :width '(720)
(ebox-test-box (ebox-test-text "Header") :width '(720))
(ebox-test-column :id 'panel :width '(720) :border
"#999999"
(ebox-test-flex :id 'matrix
:flex-flow
'(row wrap)
:column-gap '(12)
:row-gap 1
:padding '(0 (12))
:border "#999999"
(ebox-test-column
:width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Height\nowner")
:width '(190))
(ebox-test-box
:id 'height
(ebox-test-text "height target\nexpands block")
:width '(190)
:height 2
:padding
'(0 (8)) :border
"#999999")
(ebox-test-box
(ebox-test-text "Metric") :width
'(190) :height 2))
(ebox-test-column
:width '(210)
:border "#999999"
(ebox-test-box
(ebox-test-text "Peer")
:width '(190))
(ebox-test-box
(ebox-test-text "peer\nsame row")
:width '(190)
:height 2
:padding
'(0 (8)) :border
"#999999")
(ebox-test-box
(ebox-test-text "Metric") :width
'(190) :height 2))))))
(defun ebox-test--selector-region-id (buffer selector)
"Return the first editable region id matching SELECTOR in BUFFER."
(plist-get (car (ebox-selector-query-buffer buffer selector)) :region-id))
(defun ebox-test--visual-line-signature (line)
"Return LINE's visible glyph positions and total pixel width."
(let ((pixel 0)
glyphs)
(cl-loop for index below (length line)
for next = (1+ index)
for char = (aref line index)
for width = (ebox--substring-pixel-width line index next)
do (progn
(unless (eq char ?\s)
(push (cons pixel char) glyphs))
(setq pixel (+ pixel width))))
(list :width pixel :glyphs (nreverse glyphs))))
(defun ebox-test--rendered-visually-equal-p (actual expected)
"Return non-nil when ACTUAL and EXPECTED have the same visible layout."
(let ((actual-lines (ebox-string-lines actual))
(expected-lines (ebox-string-lines expected))
(ok t))
(setq ok (= (length actual-lines) (length expected-lines)))
(while (and ok actual-lines expected-lines)
(setq ok (equal (ebox-test--visual-line-signature (car actual-lines))
(ebox-test--visual-line-signature (car expected-lines)))
actual-lines (cdr actual-lines)
expected-lines (cdr expected-lines)))
ok))
(defun ebox-test--buffer-visually-matches-runtime-render-p ()
"Return non-nil when current buffer visually equals its runtime root render."
(let* ((state (ebox--buffer-render-state (current-buffer)))
(ebox--scroll-global-state (make-hash-table :test 'equal))
(ebox--region-box-table (make-hash-table :test 'equal))
(expected
(ebox--with-buffer-render-context (current-buffer)
(ebox--render-node
(plist-get state :root-node)
(plist-get state :source-index)))))
(ebox-test--rendered-visually-equal-p
(buffer-substring (point-min) (point-max))
expected)))
(defun ebox-test--buffer-propertized-matches-runtime-render-p ()
"Return non-nil when the current buffer exactly matches a runtime render."
(let* ((state (ebox--buffer-render-state (current-buffer)))
(ebox--scroll-global-state (make-hash-table :test 'equal))
(ebox--region-box-table (make-hash-table :test 'equal))
(expected
(ebox--with-buffer-render-context (current-buffer)
(ebox--render-node
(plist-get state :root-node)
(plist-get state :source-index)))))
(equal-including-properties
(buffer-substring (point-min) (point-max)) expected)))
(defun ebox-test--assert-scoped-tp-publication (&optional report)
"Assert REPORT describes a scoped TP publication without root fallback."
(let ((report (or report (ebox-test--buffer-update-report))))
(should (plist-get report :runtime-published))
(should-not (plist-get report :tp-full-root))
(should (> (or (plist-get report :tp-scope-count) 0) 0))
(should-not (plist-get report :tp-scope-fallback))))
(defun ebox-test--assert-buffer-runtime-current (&optional report)
"Assert REPORT published a current buffer matching its Ebox runtime."
(ebox-test--assert-scoped-tp-publication report)
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))
(ert-deftest ebox-scroll-offset-fast-path-preserves-line-display-properties ()
"TP scroll publication must preserve per-character display properties."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id 'scroll (ebox-test-text "第 1 行\n第 2 行\n第 3 行\n第 4 行") :width '(230)
:height 2 :padding '(0 (10)) :border "#9CA3AF" :bgcolor
"#F8FAFC" :color "#3D3A35" :text-align 'center
:overflow 'scroll :scroll-offset 0)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#scroll"))
(surface ebox-surface--buffer-surface))
(let ((revision (tp-surface-revision surface)))
(ebox-test--region-update region-id :scroll-offset 2)
(should (= (tp-surface-revision surface) (1+ revision))))
(let ((report (ebox-test--buffer-update-report)))
(should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0))
(should-not (plist-get report :tp-scope-fallback)))
(should-not
(cl-loop for pos from (point-min) below (point-max)
thereis (and (not (eq (char-after pos) ?\s))
(get-text-property pos 'display))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-scroll-offset-fast-path-requires-stable-marker-lengths ()
"Scroll marker patches should not shift later buffer positions."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id 'scroll (ebox-test-text "x\nsubstantially longer visible line") :width '(180)
:height 1 :overflow 'scroll :scroll-offset 0)))
(ebox-test--with-rendered-buffer layout
(let* ((region-id (ebox-test--selector-region-id (current-buffer)
"#scroll"))
(state (ebox--scroll-get-state region-id))
(lines (plist-get state :content-lines)))
(should (not (= (length (nth 0 lines))
(length (nth 1 lines)))))
(ebox-test--region-update region-id :scroll-offset 1)
(let ((report (ebox-test--buffer-update-report)))
(should-not (plist-get report :scroll-fast-path))
(let ((state (ebox-scroll-state region-id)))
(should (= (plist-get state :scroll-offset) 1))
(should-not (plist-get state :lazy-scroll-prefix-dirty))
(should-not
(plist-get state :lazy-scroll-window-refresh-required)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-nested-scroll-offset-transaction-replays-outer-cache ()
"Nested scroll updates should atomically survive outer cache replay."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id 'root :width '(180) :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box :id 'inner :width '(180) :height 2
:overflow 'scroll (ebox-test-text "inner 0\ninner 1\ninner 2\ninner 3"))
(ebox-test-box (ebox-test-text "outer 0\nouter 1") :height 2))))
root-id inner-id)
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root")
inner-id (ebox-test--selector-region-id (current-buffer) "#inner"))
(ebox-test--region-update inner-id :scroll-offset 1)
(let ((report (ebox-test--buffer-update-report))
(state (ebox-scroll-state inner-id))
(plain (buffer-substring-no-properties (point-min) (point-max))))
(should (eq (plist-get report :strategy) 'span-patch))
(should (= (plist-get state :scroll-offset) 1))
(should (string-match-p "inner 1" plain))
(should (string-match-p "inner 2" plain))
(should-not (string-match-p "inner 0" plain)))
(should (ebox--scroll-region-by root-id 2 48))
(should (string-match-p
"outer 0"
(buffer-substring-no-properties (point-min) (point-max))))
(should (ebox--scroll-region-by root-id -2 48))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "inner 1" plain))
(should (string-match-p "inner 2" plain))
(should-not (string-match-p "inner 0" plain)))
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 1))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-scroll-child-layout-window-publishes-through-tp ()
"Child-layout scroll boxes should publish one retained viewport update."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box :id "a" (ebox-test-text "A") :height 1)
(ebox-test-box :id "b" (ebox-test-text "B") :height 1)
(ebox-test-box :id "c" (ebox-test-text "C") :height 1)
(ebox-test-box :id "d" (ebox-test-text "D") :height 1))))
(root-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(let* ((state (ebox--scroll-get-state root-id))
(surface ebox-surface--buffer-surface)
(revision (tp-surface-revision surface)))
(should (= (plist-get state :content-height) 2))
(should (ebox-surface-region-mounts (current-buffer) root-id))
(should (= (ebox--scroll-region-by root-id 1) 1))
(should (= (tp-surface-revision surface) (1+ revision))))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((plain (buffer-substring-no-properties
(point-min) (point-max))))
(should (= (length (ebox-string-lines plain)) 2))
(should (string-match-p "B" plain))
(should (string-match-p "C" plain))
(should-not (string-match-p "A" plain)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-scroll-child-layout-window-keeps-viewport-padding-and-border ()
"Fast scroll window patches should keep scroll box padding and border."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :height 4 :box-sizing 'border-box :overflow
'scroll :padding-top 1 :padding-bottom 1 :border-top-p
t :border-top-color "red"
:border-bottom-width 1 :border-bottom-style 'solid
:border-bottom-color "blue"
(ebox-test-column
(ebox-test-box :id "a" (ebox-test-text "A") :height 1)
(ebox-test-box :id "b" (ebox-test-text "B") :height 1)
(ebox-test-box :id "c" (ebox-test-text "C") :height 1)
(ebox-test-box :id "d" (ebox-test-text "D") :height 1))))
(root-id (car (ebox-region-ids layout)))
)
(ebox-test--with-rendered-buffer layout
(should (plist-get (ebox--scroll-get-state root-id)
:rendered-content-lines))
(should (ebox--scroll-region-by root-id 1))
(should (equal (get-text-property (point-min) 'ebox-bt)
root-id))
(save-excursion
(goto-char (point-min))
(forward-line 3)
(should (equal (get-text-property (point) 'ebox-bb)
root-id)))
(should (equal (get-text-property (point-min) 'ebox-pt)
root-id))
(save-excursion
(goto-char (point-min))
(forward-line 3)
(should (equal (get-text-property (point) 'ebox-pb)
root-id)))
(let ((plain (buffer-substring-no-properties
(point-min) (point-max))))
(should (= (length (ebox-string-lines plain)) 4))
(should (string-match-p "B" plain))
(should (string-match-p "C" plain))
(should-not (string-match-p "A" plain))))))
(ert-deftest ebox-flex-row-height-update-rerenders-slot-owner-not-root ()
"Flex row item height changes should patch the old row slot, not the root."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-row-layout)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#height"))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update region-id
:height 4 :min-height 3 :max-height 6
:padding-top 1 :padding-bottom 1)
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender)))
(should-not (plist-get report :root-rerender))
(should-not (member root-id (plist-get report :owner-ids)))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-flex-row-height-growth-rerenders-flex-ancestor-not-root ()
"Flex row item growth beyond its old slot should use a flex ancestor fallback."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-tight-row-layout)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#height"))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update region-id
:height 4 :min-height 3 :max-height 6
:padding-top 1 :padding-bottom 1)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids) (list root-id)))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-flex-row-inline-margin-update-uses-slot-span-patch ()
"Inline margin changes inside a stable flex row slot should stay span-local."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-row-layout)))
(ebox-test--with-rendered-buffer layout
(let ((height-id (ebox-test--selector-region-id (current-buffer)
"#height"))
(region-id (ebox-test--selector-region-id (current-buffer)
"#margin"))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update height-id
:height 4 :min-height 3 :max-height 6
:padding-top 1 :padding-bottom 1)
(ebox-test--region-update region-id
:margin-inline '((8) (8)))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (eq (plist-get report :owner-type) 'box))
(should-not (plist-get report :root-rerender))
(should-not (member root-id (plist-get report :owner-ids)))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-border-box-padding-impact-stays-internal-fragment ()
"Fixed border-box padding changes should remain an internal fragment impact."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-column :id 'root :width '(480)
(ebox-test-flex :id 'row :flex-flow '(row nowrap)
:column-gap '(12)
(ebox-test-box :id 'padding (ebox-test-text "padding target")
:width '(190) :height
3 :box-sizing
'border-box :padding
'(0 (8)) :border
"#999999")
(ebox-test-box (ebox-test-text "peer")
:width '(190) :height
3 :border "#999999")))))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#padding")))
(ebox-test--region-update region-id :padding-left (list 24))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (member 'internal-fragment
(plist-get report :impact-vector)))
(should-not (member 'parent-layout
(plist-get report :impact-vector)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-border-box-padding-shorthand-uses-one-local-span-patch ()
"Mixed block/inline padding shorthand should not promote to a root rerender."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-column :id 'root :width '(480)
(ebox-test-flex :id 'row :flex-flow '(row nowrap)
:column-gap '(12)
(ebox-test-box :id 'padding (ebox-test-text "padding target")
:width '(190) :height
4 :box-sizing
'border-box :padding
'(0 (8)) :border
"#999999")
(ebox-test-box (ebox-test-text "peer")
:width '(190) :height
4 :border "#999999")))))
(ebox-test--with-rendered-buffer layout
(let* ((match (car (ebox-selector-query-buffer
(current-buffer) "#padding")))
(node-id (ebox--ensure-node-id (plist-get match :node)))
(region-id (plist-get match :region-id))
(mixed-entry
(list :node-id node-id :dirty-kind 'geometry
:changed-keys
'(:padding-top-height :padding-right-pixel
:padding-bottom-height :padding-left-pixel))))
(should
(ebox--dirty-entry-border-box-internal-span-p
(current-buffer) mixed-entry))
(ebox-test--region-update region-id :padding '(2 (20) 0 (8)))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :patch-ops) '(span-patch)))
(should-not (plist-get report :root-rerender))
(should (member 'internal-fragment
(plist-get report :impact-vector)))
(should (member 'external-footprint-proof
(plist-get report :impact-vector)))
(should-not (member 'parent-layout
(plist-get report :impact-vector)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-border-box-padding-span-proof-rejects-overfull-height ()
"Border-box padding cannot claim a fixed footprint without a content line."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id 'padding (ebox-test-text "padding target") :width '(190)
:height 4 :box-sizing 'border-box :padding 2 :border
"#999999")))
(ebox-test--with-rendered-buffer layout
(let* ((match (car (ebox-selector-query-buffer
(current-buffer) "#padding")))
(node-id (ebox--ensure-node-id (plist-get match :node))))
(let ((entry
(list :node-id node-id :dirty-kind 'geometry
:changed-keys
'(:padding-top-height :padding-right-pixel
:padding-bottom-height :padding-left-pixel))))
(should-not
(ebox--dirty-entry-border-box-internal-span-p
(current-buffer) entry)))))))
(ert-deftest ebox-overflow-impact-keeps-conservative-owner-rerender ()
"Overflow changes should stay conservative until clipping proof exists."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-row-layout)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#overflow")))
(ebox-test--region-update region-id :overflow 'hidden)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (member 'clipping (plist-get report :impact-vector)))
(should (member 'external-footprint
(plist-get report :impact-vector)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-overflow-impact-skips-span-patch-owner-probe ()
"Clipping changes should not spend time proving impossible span patches."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-row-layout)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#overflow"))
(span-probe-count 0)
(original (symbol-function 'ebox--span-patchable-owner-p)))
(cl-letf (((symbol-function 'ebox--span-patchable-owner-p)
(lambda (&rest args)
(cl-incf span-probe-count)
(apply original args))))
(ebox-test--region-update region-id :overflow 'hidden))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (member 'clipping (plist-get report :impact-vector)))
(should (= span-probe-count 0))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-overflow-impact-skips-patchable-owner-promotion ()
"Clipping changes should not pre-prove conservative owner candidates."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-row-layout)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#overflow"))
(promote-count 0)
(original (symbol-function 'ebox--promote-to-patchable-owner)))
(cl-letf (((symbol-function 'ebox--promote-to-patchable-owner)
(lambda (&rest args)
(cl-incf promote-count)
(apply original args))))
(ebox-test--region-update region-id :overflow 'visible))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (member 'clipping (plist-get report :impact-vector)))
(should (= promote-count 0))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-flex-row-visible-overflow-update-rerenders-slot-owner-not-root ()
"Visible overflow that fits the existing flex row slot should not rerender root."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-row-layout)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#overflow"))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update region-id :overflow 'visible)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (plist-get report :root-rerender))
(should-not (member root-id (plist-get report :owner-ids)))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(let* ((node (ebox--buffer-region-render-owner-node
(current-buffer) region-id))
(snapshot (ebox--ensure-layout-snapshot-spans
(current-buffer) (ebox--ensure-node-id node))))
(should (ebox--layout-snapshot-spans-p snapshot))
(should (eq (plist-get (plist-get snapshot :style-signature)
:overflow)
'visible)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-overflow-reversal-skips-layout-only-ancestor ()
"Old visible suffix coverage should skip ancestors without wrapper ownership."
(ebox-test--reset-runtime-state)
(let* ((target (ebox-test-box (ebox-test-text "A\nB\nC") :height 1
:overflow 'visible))
(region-id (car (ebox-region-ids target)))
(inner (ebox-test-column target (ebox-test-box (ebox-test-text "TAIL"))))
(layout (ebox-test-column inner (ebox-test-box (ebox-test-text "ROOT TAIL"))))
(inner-id (ebox--ensure-node-id (ebox-test-root inner)))
(root-id (ebox--ensure-node-id (ebox-test-root layout))))
(ebox-test--with-rendered-buffer layout
(ebox--clear-layout-snapshots (current-buffer))
(ebox-test--region-update region-id :overflow 'hidden)
(let ((report (ebox-test--buffer-update-report))
(plain (buffer-substring-no-properties
(point-min) (point-max))))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (member inner-id (plist-get report :owner-ids)))
(should (equal (plist-get report :owner-ids) (list root-id)))
(should-not (string-match-p "[BC]" plain))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-overflow-reversal-skips-visible-wrapper-ancestor ()
"A visible wrapper cannot cover its own old unowned overflow suffix."
(ebox-test--reset-runtime-state)
(let* ((target (ebox-test-box (ebox-test-text "A\nB\nC") :height 1
:overflow 'visible))
(region-id (car (ebox-region-ids target)))
(wrapper (ebox-test-box target :width '(100)
:height 1 :overflow 'visible))
(layout (ebox-test-column wrapper (ebox-test-box (ebox-test-text "TAIL"))))
(wrapper-id (ebox--ensure-node-id (ebox-test-root wrapper)))
(root-id (ebox--ensure-node-id (ebox-test-root layout))))
(ebox-test--with-rendered-buffer layout
(ebox--clear-layout-snapshots (current-buffer))
(ebox-test--region-update region-id :overflow 'hidden)
(let ((report (ebox-test--buffer-update-report))
(plain (buffer-substring-no-properties
(point-min) (point-max))))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (member wrapper-id (plist-get report :owner-ids)))
(should (equal (plist-get report :owner-ids) (list root-id)))
(should-not (string-match-p "[BC]" plain))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-overflow-coverage-crosses-enclosing-visible-wrapper ()
"Coverage should climb above an enclosing visible wrapper's old footprint."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-column :id 'root :width '(100)
(ebox-test-box :id 'outer :width '(100) :height 2
:overflow 'visible
(ebox-test-box :id 'inner :width
'(100)
(ebox-test-box :id
'target
(ebox-test-text "A\nB\nC")
:height
1
:overflow
'visible)))
(ebox-test-box (ebox-test-text "TAIL")))))
(ebox-test--with-rendered-buffer layout
(let* ((region-id (ebox-test--selector-region-id
(current-buffer) "#target"))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox--clear-layout-snapshots (current-buffer))
(ebox-test--region-update region-id :overflow 'hidden)
(let ((report (ebox-test--buffer-update-report))
(target-node
(ebox--buffer-region-render-owner-node
(current-buffer) region-id))
(outer-node
(plist-get (car (ebox-selector-query-buffer
(current-buffer) "#outer"))
:node)))
(should (eq (ebox-get (ebox-fragment-style-source-node target-node)
:overflow)
'hidden))
(should (eq (ebox-get (ebox-fragment-style-source-node outer-node)
:overflow)
'visible))
(should (equal (plist-get report :owner-ids) (list root-id)))
(should-not (plist-get report :flex-line-rerender))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-overflow-coverage-root-flex-replaces-whole-footprint ()
"Root flex fallback should bypass line rerender for visible overflow changes."
(dolist (transition '((scroll visible) (visible hidden)))
(ebox-test--reset-runtime-state)
(let* ((old-overflow (car transition))
(new-overflow (cadr transition))
(layout
(ebox-test-flex :id 'root :width '(200)
(ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :width
'(80) :height 1 :overflow old-overflow)
(ebox-test-box :id 'peer (ebox-test-text "P") :width '(80)
:height 1))))
(ebox-test--with-rendered-buffer layout
(let* ((region-id (ebox-test--selector-region-id
(current-buffer) "#target"))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox--clear-layout-snapshots (current-buffer))
(ebox-test--region-update region-id :overflow new-overflow)
(let ((report (ebox-test--buffer-update-report))
(target-node
(ebox--buffer-region-render-owner-node
(current-buffer) region-id)))
(should (eq (ebox-get (ebox-fragment-style-source-node target-node)
:overflow)
new-overflow))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids) (list root-id)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))))
(ert-deftest ebox-overflow-coverage-promotes-partial-flex-item ()
"Visible overflow changes should not preserve a flex item's stale cross size."
(dolist (transition '((scroll visible) (visible hidden)))
(ebox-test--reset-runtime-state)
(let* ((old-overflow (car transition))
(new-overflow (cadr transition))
(layout
(ebox-test-flex :id 'root :width '(220)
(ebox-test-box :id 'card :width '(100)
(ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :width '(80)
:height 1 :overflow
old-overflow))
(ebox-test-box :id 'peer (ebox-test-text "P") :width '(80)
:height 1))))
(ebox-test--with-rendered-buffer layout
(let* ((region-id (ebox-test--selector-region-id
(current-buffer) "#target"))
(card-id (plist-get (car (ebox-selector-query-buffer
(current-buffer) "#card"))
:node-id))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox--clear-layout-snapshots (current-buffer))
(ebox-test--region-update region-id :overflow new-overflow)
(let ((report (ebox-test--buffer-update-report))
(target-node
(ebox--buffer-region-render-owner-node
(current-buffer) region-id)))
(should (eq (ebox-get (ebox-fragment-style-source-node target-node)
:overflow)
new-overflow))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (member card-id (plist-get report :owner-ids)))
(should (equal (plist-get report :owner-ids) (list root-id)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))))
(ert-deftest ebox-overflow-coverage-batch-preserves-old-style ()
"A batch should carry pre-mutation overflow into coverage planning."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-column :id 'root :width '(100)
(ebox-test-box :id 'wrapper :width '(100)
(ebox-test-column
(ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :height 1
:overflow 'visible)
(ebox-test-box (ebox-test-text "TAIL"))))
(ebox-test-box (ebox-test-text "ROOT TAIL")))))
(ebox-test--with-rendered-buffer layout
(let* ((region-id (ebox-test--selector-region-id
(current-buffer) "#target"))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox--clear-layout-snapshots (current-buffer))
(ebox-incremental-begin-batch (current-buffer))
(ebox-test--region-update region-id :overflow 'hidden)
(let* ((state (ebox-incremental--batch-state (current-buffer)))
(pending (car (plist-get state :pending))))
(should (equal (plist-get pending :old-style-values)
'(:overflow visible))))
(ebox-incremental-flush (current-buffer))
(let ((report (ebox-test--buffer-update-report))
(target-node
(ebox--buffer-region-render-owner-node
(current-buffer) region-id)))
(should (eq (ebox-get (ebox-fragment-style-source-node target-node)
:overflow)
'hidden))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids) (list root-id)))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-overflow-coverage-multi-owner-batch-normalizes-scoped-snapshots ()
"Multiple owner replacements normalize shifted spans before scoped refresh."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-column :id 'root :width '(760)
(ebox-test-flex :id 'top-matrix :width '(600)
:height 5 :flex-flow '(row nowrap)
(ebox-test-box :id 'top-card :width
'(300)
(ebox-test-box :id
'top-target
(ebox-test-text "top A\nsubstantially longer top overflow\ntop C")
:width
'(280)
:height
2
:overflow
'scroll))
(ebox-test-box (ebox-test-text "top P\ntop Q\ntop R\ntop S\ntop T")
:width '(300) :height
5))
(ebox-test-flex :id 'bottom-matrix :width '(600)
:height 5 :flex-flow '(row nowrap)
(ebox-test-box :id 'bottom-card
:width '(300)
(ebox-test-box :id
'bottom-target
(ebox-test-text "bottom A\nbottom overflow\nbottom C")
:width
'(280)
:height
2
:overflow
'scroll))
(ebox-test-box (ebox-test-text "bottom P\nbottom Q\nbottom R\nbottom S\nbottom T")
:width '(300) :height
5)))))
(ebox-test--with-rendered-buffer layout
(let ((top-region-id
(ebox-test--selector-region-id (current-buffer) "#top-target"))
(bottom-region-id
(ebox-test--selector-region-id
(current-buffer) "#bottom-target")))
(ebox-incremental-begin-batch (current-buffer))
(ebox-test--region-update top-region-id :overflow 'visible)
(ebox-test--region-update bottom-region-id :overflow 'visible)
(ebox-incremental-flush (current-buffer))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (= (plist-get report :patch-count) 2))
(should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0))
(should-not (plist-get report :tp-scope-fallback))
(dolist (owner-id (plist-get report :owner-ids))
(let* ((node (ebox--buffer-runtime-node
(current-buffer) owner-id))
(snapshot-spans
(plist-get (ebox--ensure-layout-snapshot-spans
(current-buffer) owner-id)
:buffer-spans))
(actual-spans
(ebox--region-set-line-spans
(ebox--node-all-region-ids node))))
(should (equal snapshot-spans actual-spans))))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))
(ebox-test--region-update bottom-region-id :overflow 'hidden)
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-span-patch-keeps-descendant-mount-snapshots-current ()
"Ancestor updates should keep descendant snapshots aligned with TP mounts."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-row-layout)))
(ebox-test--with-rendered-buffer layout
(let* ((handle (car (ebox-selector-query-buffer
(current-buffer) "#margin")))
(region-id (plist-get handle :region-id))
(node-id (plist-get handle :node-id)))
(ebox-test--region-update region-id
:margin-inline '((8) (8)))
(let* ((report (ebox-test--buffer-update-report))
(snapshot (ebox--ensure-layout-snapshot-spans
(current-buffer) node-id))
(snapshot-spans (plist-get snapshot :buffer-spans))
(node (ebox--buffer-runtime-node (current-buffer) node-id))
(actual-spans (ebox--region-set-line-spans
(ebox--node-all-region-ids node))))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal snapshot-spans actual-spans))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-flex-child-width-expansion-reflows-ancestor-after-span-fallback ()
"Failed promoted span patches must fall back to a layout-safe ancestor."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-flex :id 'matrix :width '(680) :flex-flow '(row wrap)
:column-gap '(12) :row-gap 1
(ebox-test-column :width '(210) :border "#999999"
(ebox-test-box (ebox-test-text "Title A")
:width '(190))
(ebox-test-box :id 'target (ebox-test-text "width target\nmoves sibling layout")
:width '(160) :height
3 :padding '(0 (8))
:border "#999999"))
(ebox-test-column :width '(210) :border "#999999"
(ebox-test-box (ebox-test-text "Title B")
:width '(190))
(ebox-test-box (ebox-test-text "Still here")
:width '(190)))
(ebox-test-column :width '(210) :border "#999999"
(ebox-test-box (ebox-test-text "Title C")
:width '(190))
(ebox-test-box (ebox-test-text "Third card")
:width '(190))))))
(ebox-test--with-rendered-buffer layout
(let* ((match (car (ebox-selector-query-buffer
(current-buffer) "#target")))
(region-id (plist-get match :region-id)))
(ebox-test--region-update region-id :width '(250))
(let* ((patched (buffer-string))
(full
(ebox--with-buffer-render-context (current-buffer)
(ebox--render-node
(ebox--buffer-root-node (current-buffer))
(plist-get (ebox--buffer-render-state (current-buffer))
:source-index)))))
(should (string= (substring-no-properties patched)
(substring-no-properties full))))))))
(ert-deftest ebox-rendered-owned-lines-trims-only-contiguous-outer-chrome ()
"Role projection keeps owned lines and rejects an unowned interior gap."
(let* ((owned-a (propertize "owned-a" 'ebox-content 7))
(owned-b (propertize "owned-b" 'ebox-content 7))
(projected
(ebox-buffer--rendered-owned-lines
(string-join (list "chrome-top" owned-a owned-b "chrome-bottom")
"\n")
'(7))))
(should (equal (substring-no-properties projected)
"owned-a\nowned-b"))
(should-not
(ebox-buffer--rendered-owned-lines
(string-join (list owned-a "foreign-gap" owned-b) "\n") '(7)))
(should-not
(ebox-buffer--rendered-owned-lines
(concat owned-a (propertize "foreign" 'ebox-content 8)) '(7)))
(should
(equal
(substring-no-properties
(ebox-buffer--rendered-owned-lines
(string-join
(list (propertize "border-top" 'ebox-bt 7)
owned-a owned-b
(propertize "border-bottom" 'ebox-bb 7))
"\n")
'(7) 2))
"owned-a\nowned-b"))))
(ert-deftest ebox-flex-auto-size-content-update-refuses-current-slot ()
"Auto-sized content must reflow instead of being forced into its old slot."
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 300)
(layout
(ebox-test-flex :id 'root :width '(300) :height 1 :flex-flow
'(row nowrap) :align-items 'flex-start
(ebox-test-box :id 'target (ebox-test-text "A") :width 'auto
:height 1 :overflow 'hidden :wrap-mode
'word)
(ebox-test-box :id 'peer (ebox-test-text "P") :width '(40)
:height 1 :flex-grow 0 :flex-shrink 0))))
(ebox-test--with-rendered-buffer layout
(let* ((match (car (ebox-selector-query-buffer
(current-buffer) "#target")))
(region-id (plist-get match :region-id))
(node-id (plist-get match :node-id))
(node (ebox--buffer-runtime-node (current-buffer) node-id))
(snapshot (ebox--ensure-layout-snapshot-details
(current-buffer) node-id)))
(puthash region-id node ebox--region-box-table)
(should-not
(ebox--render-node-in-current-flex-slot
(current-buffer) node-id snapshot '(:content)))
(ebox-test--region-update region-id :content "HELLO")
(let ((report (ebox-test--buffer-update-report)))
(should-not
(and (eq (plist-get report :strategy) 'span-patch)
(equal (plist-get report :owner-ids) (list node-id)))))
(should (string-match-p
"HELLO"
(buffer-substring-no-properties (point-min) (point-max))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-region-update-stable-geometry-uses-span-patch ()
"Geometry updates with stable span footprint should use the L2 planner path."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Align") :width '(120) :height 1
:text-align 'left))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :text-align 'center)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :patch-ops) '(span-patch)))
(should (equal (plist-get report :dirty-kinds) '(geometry)))
(should (eq (plist-get report :owner-type) 'box))
(should-not (plist-get report :full-rerender))))))
(ert-deftest ebox-region-update-inline-child-stable-geometry-uses-span-patch ()
"Stable internal changes inside an inline child should not rerender siblings."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-test-box (ebox-test-text "Left") :width '(80) :height 1
:text-align 'left))
(right (ebox-test-box (ebox-test-text "Right") :width '(80) :height 1))
(layout (ebox-test-row left right))
(left-id (car (ebox-region-ids left))))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update left-id :text-align 'center)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :patch-ops) '(span-patch)))
(should (eq (plist-get report :owner-type) 'box))
(should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0)))
(should (string-match-p
"Right"
(buffer-substring-no-properties (point-min) (point-max))))
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))
(ert-deftest ebox-region-update-completes-border-width-style-pairs ()
"A one-property border delta should retain its computed counterpart."
(ebox-test--reset-runtime-state)
(let* ((input
(ebox-test-box (ebox-test-text "Border") :width 80
:border-left '(2 none "#AA0000")))
(region-id (car (ebox-region-ids input))))
(ebox-test--with-rendered-buffer input
(ebox-test--region-update region-id :border-left-style 'solid)
(let ((box (gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (= (ebox-get box :border-left-pixel) 2)))
(ebox-test--region-update region-id :border-left-width 3)
(let ((box (gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (= (ebox-get box :border-left-pixel) 3)))
(ebox-test--region-update region-id :border-left-style 'none)
(let ((box (gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (= (ebox-get box :border-left-pixel) 0)))
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))
(ert-deftest ebox-region-update-projects-outer-and-layout-config ()
"Region updates should project structural and LayoutConfig facts once."
(ebox-test--reset-runtime-state)
(let* ((input
(ebox-test-row :id "axis" :outer 'inline :item-gap 1
(ebox-test-box (ebox-test-text "A"))
(ebox-test-box (ebox-test-text "B"))))
(buffer (ebox-render-to-buffer
(generate-new-buffer-name " *ebox-axis-update*") input)))
(unwind-protect
(progn
(ebox-selector-update-buffer
buffer "#axis" :outer 'block :item-gap 3)
(let* ((root (plist-get (ebox--buffer-render-state buffer)
:root-node))
(props (ebox-layout-config-props
(ebox-box-node-layout root))))
(should (equal (plist-get root :display) '(block row)))
(should (= (plist-get props :item-gap) 3))
(should (with-current-buffer buffer
(ebox-test--buffer-propertized-matches-runtime-render-p)))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-region-update-border-box-inline-change-uses-span-patch ()
"Border-box left/right spacing can be internal when outer width is fixed."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Pad") :width '(120) :height 1
:box-sizing 'border-box))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :padding-left '(8))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :patch-ops) '(span-patch)))
(should-not (plist-get report :full-rerender))))))
(ert-deftest ebox-region-update-content-box-inline-change-rerenders-owner ()
"Content-box left/right spacing changes the parent-facing outer width."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Pad") :width '(120) :height 1
:box-sizing 'content-box))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :padding-left '(8))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :patch-ops) '(owner-rerender)))
(should-not (plist-get report :full-rerender))))))
(ert-deftest ebox-region-update-span-candidate-falls-back-on-wrap-change ()
"A wrap-changing update should publish a correct scoped TP plan."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "abcdefghijklmnopqrstuvwxyz")
:width '(120)
:box-sizing 'border-box
:wrap-mode 'char))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :padding-left '(100))
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender)))
(should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0))
(should-not (plist-get report :tp-scope-fallback))
(should (ebox-test--buffer-propertized-matches-runtime-render-p))))))
(ert-deftest ebox-region-update-matrix-uses-only-public-properties ()
"Region update coverage must not preserve private engine spellings."
(dolist (property
(append (ebox-test--region-update-case-properties
ebox-test--region-update-paint-cases)
(ebox-test--region-update-case-properties
ebox-test--region-update-geometry-cases)))
(should (or (memq property ebox--region-update-control-properties)
(ebox-style-schema-id property))))
(dolist (property '(:padding-left-pixel :padding-right-pixel
:padding-top-height :padding-bottom-height
:margin-left-pixel :margin-right-pixel
:margin-top-height :margin-bottom-height
:border-left-pixel :border-right-pixel
:border-top-pixel :border-bottom-pixel
:border-left-style :border-right-style
:border-top-style :border-bottom-style
:vertical-align))
(should-not
(memq property
(append (ebox-test--region-update-case-properties
ebox-test--region-update-paint-cases)
(ebox-test--region-update-case-properties
ebox-test--region-update-geometry-cases))))))
(ert-deftest ebox-region-update-all-paint-longhands-use-paint-patch ()
"Every paint longhand should patch text properties without replacing spans."
(dolist (case ebox-test--region-update-paint-cases)
(pcase-let ((`(,property ,value ,engine-property ,expected) case))
(ert-info ((format "property %S" property))
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Paint") :width '(120)
:height 1))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update region-id property value))))
(should (= full-rerenders 0)))
(let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (eq (gethash region-id ebox--region-box-table)
runtime-box))
(should-not (eq runtime-box box))
(should (equal (ebox-get runtime-box engine-property)
expected)))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))
(should (equal (plist-get report :patch-ops)
'(paint-patch)))
(should (equal (plist-get report :dirty-kinds) '(paint)))
(should-not (plist-get report :full-rerender)))))))))
(ert-deftest ebox-region-update-all-geometry-longhands-use-minimal-strategy ()
"Every geometry longhand should use a correct scoped non-root strategy."
(dolist (case ebox-test--region-update-geometry-cases)
(pcase-let
((`(,property ,value ,engine-property ,expected ,_strategy) case))
(ert-info ((format "property %S" property))
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Alpha\nBeta\nGamma\nDelta")
:width '(160)
:height 2
:overflow 'scroll
:wrap-mode 'word))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update region-id property value))))
(should (= full-rerenders 0)))
(let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (eq (gethash region-id ebox--region-box-table)
runtime-box))
(should-not (eq runtime-box box))
(should (equal (ebox-get runtime-box engine-property)
expected)))
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(paint-patch span-patch owner-rerender)))
(should (equal (plist-get report :dirty-kinds) '(geometry)))
(should (eq (plist-get report :owner-type) 'box))
(should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0))
(should-not (plist-get report :tp-scope-fallback))
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))))))
(ert-deftest ebox-bgcolor-update-mutates-box-and-keeps-content ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Color") :width 60 :bgcolor "#111111"))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :bgcolor "#222222")
(should (equal (ebox-style-node-specified-value box :bgcolor)
"#111111"))
(should (equal (ebox-get (gethash region-id
(ebox--buffer-region-box-table
(current-buffer)))
:bgcolor)
"#222222"))
(should (string-match-p
"Color"
(buffer-substring-no-properties (point-min) (point-max)))))))
(ert-deftest ebox-background-color-creation-matches-bgcolor-updates ()
"Background color spellings should share one engine longhand and paint path."
(ebox-test--reset-runtime-state)
(let* ((background-box
(ebox-test-box (ebox-test-text "Color") :width 60
:background-color "#111111"))
(bgcolor-box
(ebox-test-box (ebox-test-text "Color") :width 60 :bgcolor "#111111"))
(background-face
(ebox-test--first-content-face (ebox-render background-box)))
(bgcolor-face
(ebox-test--first-content-face (ebox-render bgcolor-box))))
(should (equal (ebox-style-node-specified-value
background-box :bgcolor)
"#111111"))
(should (equal (ebox-style-node-specified-value bgcolor-box :bgcolor)
"#111111"))
(should-not (plist-member (ebox-test-root background-box)
:background-color))
(should (equal background-face bgcolor-face))
(should (ebox-test--face-has-key-value-p
background-face :background "#111111"))
(let ((region-id (car (ebox-region-ids background-box))))
(ebox-test--with-rendered-buffer background-box
(ebox-test--region-update region-id :background-color "#222222")
(let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (equal (ebox-style-node-specified-value
background-box :bgcolor)
"#111111"))
(should (equal (ebox-get runtime-box :bgcolor) "#222222"))
(should-not (plist-member runtime-box :background-color)))
(should-not (plist-member (ebox-test-root background-box)
:background-color))
(should (ebox-test--face-has-key-value-p
(ebox-test--first-content-face (buffer-string))
:background "#222222"))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))
(should (equal (plist-get report :patch-ops) '(paint-patch)))
(should (equal (plist-get report :dirty-kinds) '(paint)))
(should-not (plist-get report :full-rerender)))))))
(ert-deftest ebox-update-reuses-shorthand-property-expansion ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Box") :width 60))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id
:padding '(1 2)
:border '(1 "#abcdef")
:margin-inline 3)
(let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (= (ebox-get (ebox-test-root box) :padding-top-height) 0))
(should (= (ebox-get runtime-box :padding-top-height) 1))
(should (= (ebox-get runtime-box :padding-right-pixel)
(* 2 (ebox--space-pixel-width))))
(should (= (ebox-get runtime-box :border-left-pixel) 1))
(should (equal (ebox-get runtime-box :border-right-color) "#abcdef"))
(should (= (ebox-get runtime-box :margin-left-pixel)
(* 3 (ebox--space-pixel-width))))))))
(ert-deftest ebox-region-update-adds-absent-shorthand-properties-incrementally ()
"Shorthand updates should locate the same box and patch without root rebuilds."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Absent properties") :width '(160)))
(region-id (car (ebox-region-ids box)))
(ids-before (ebox-region-ids box)))
(ebox-test--with-rendered-buffer box
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update region-id
:padding '(1 2)
:border '(1 "#abcdef")
:margin-inline 3))))
(should (= full-rerenders 0)))
(let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (eq (gethash region-id ebox--region-box-table)
runtime-box))
(should-not (eq runtime-box box))
(should (equal (ebox-region-ids box) ids-before))
(should (= (ebox-get (ebox-test-root box) :padding-top-height) 0))
(should (= (ebox-get (ebox-test-root box) :padding-right-pixel) 0))
(should (= (ebox-get (ebox-test-root box) :border-left-pixel) 0))
(should (null (ebox-get (ebox-test-root box) :border-right-color)))
(should (= (ebox-get (ebox-test-root box) :margin-left-pixel) 0))
(should (= (ebox-get runtime-box :padding-top-height) 1))
(should (= (ebox-get runtime-box :padding-right-pixel)
(* 2 (ebox--space-pixel-width))))
(should (= (ebox-get runtime-box :border-left-pixel) 1))
(should (equal (ebox-get runtime-box :border-right-color) "#abcdef"))
(should (= (ebox-get runtime-box :margin-left-pixel)
(* 3 (ebox--space-pixel-width)))))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :patch-ops)
'(owner-rerender)))
(should-not (plist-get report :full-rerender)))
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update region-id
:background-color "#fed7aa"))))
(should (= full-rerenders 0)))
(should (null (ebox-style-node-specified-value box :bgcolor)))
(should (equal (ebox-get (gethash region-id
(ebox--buffer-region-box-table
(current-buffer)))
:bgcolor)
"#fed7aa"))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))
(should (equal (plist-get report :patch-ops)
'(paint-patch)))
(should-not (plist-get report :full-rerender))))))
(ert-deftest ebox-overflow-hidden-clips-extra-content-lines ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
:height 1
:overflow 'hidden))
(plain (substring-no-properties (ebox-render box))))
(should (string-match-p "A" plain))
(should-not (string-match-p "B" plain))
(should-not (string-match-p "C" plain))
(should (= (ebox-string-height plain) 1))))
(ert-deftest ebox-overflow-visible-appends-extra-content-lines ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
:height 1
:overflow 'visible))
(plain (substring-no-properties (ebox-render box))))
(should (string-match-p "A" plain))
(should (string-match-p "B" plain))
(should (string-match-p "C" plain))
(should (= (ebox-string-height plain) 3))))
(defun ebox-test--assert-visible-overflow-paint
(rendered foreground background outer-region-id)
"Assert RENDERED overflow keeps Text identity without OUTER-REGION-ID."
(let* ((lines (ebox-string-lines rendered))
(body (car lines))
(overflow-lines (cdr lines))
(body-pos (cl-position ?A body)))
(should (= (length lines) 3))
(should (integerp body-pos))
(let ((body-face (get-text-property body-pos 'face body)))
(should (ebox-test--face-has-key-value-p
body-face :foreground foreground))
(should (ebox-test--face-has-key-value-p
body-face :background background)))
(cl-loop for line in overflow-lines
for glyph in '(?B ?C)
do
(let ((glyph-pos (cl-position glyph line)))
(should (integerp glyph-pos))
(should (get-text-property glyph-pos 'ebox-content line))
(should (ebox-test--face-has-key-value-p
(get-text-property glyph-pos 'face line)
:foreground foreground)))
(dolist (offset-pos (list 0 (1- (length line))))
(should (get-text-property offset-pos 'display line))
(should-not
(ebox-test--face-has-key-p
(get-text-property offset-pos 'face line)
:foreground)))
(dotimes (pos (length line))
(should-not
(ebox-test--face-has-key-p
(get-text-property pos 'face line)
:background))
(should-not
(member outer-region-id
(get-text-property pos 'ebox-content-owners line)))
(dolist (property (mapcar #'cdr ebox-region-types))
(should-not
(equal (get-text-property pos property line)
outer-region-id)))))))
(ert-deftest ebox-overflow-visible-paints-foreground-without-ownership ()
"Visible overflow should inherit only the outer box foreground."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
:width '(40)
:height 1
:overflow 'visible
:color "#abcdef"
:bgcolor "#123456"
:padding-inline '(6)
:margin-inline '(4)))
(region-id (ebox--ensure-region-id (ebox-test-root box))))
(ebox-test--assert-visible-overflow-paint
(ebox-render box) "#abcdef" "#123456" region-id)))
(ert-deftest ebox-region-update-refreshes-visible-overflow-foreground ()
"Color updates should add, replace, and remove visible overflow paint."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
:width '(40)
:height 1
:overflow 'visible
:bgcolor "#123456"
:padding-inline '(6)
:margin-inline '(4)))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :color "#abcdef")
(ebox-test--assert-visible-overflow-paint
(buffer-string) "#abcdef" "#123456" region-id)
(ebox-test--region-update region-id :color "#fedcba")
(ebox-test--assert-visible-overflow-paint
(buffer-string) "#fedcba" "#123456" region-id)
(let ((published (buffer-string)))
(should-error
(ebox-test--region-update region-id :color nil)
:type 'error)
(should (equal-including-properties published (buffer-string)))
(ebox-test--assert-visible-overflow-paint
(buffer-string) "#fedcba" "#123456" region-id)))))
(ert-deftest ebox-owner-rerender-keeps-visible-overflow-unowned ()
"Owner rerenders should not restore box ownership to visible overflow."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
:width '(40)
:height 1
:overflow 'scroll
:color "#abcdef"
:bgcolor "#123456"))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :overflow 'visible)
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
'owner-rerender))
(goto-char (point-min))
(search-forward "C")
(let* ((pos (match-beginning 0))
(face (get-text-property pos 'face)))
(should (ebox-test--face-has-key-value-p
face :foreground "#abcdef"))
(should-not (ebox-test--face-has-key-value-p
face :background "#123456"))
(should (get-text-property pos 'ebox-content))
(should-not
(eq (get-text-property pos 'ebox-content-owner) region-id))
(should-not
(member region-id
(get-text-property pos 'ebox-content-owners)))))))
(ert-deftest ebox-overflow-scroll-keeps-window-height-and-state ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
:height 1
:overflow 'scroll))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "A" plain))
(should-not (string-match-p "B" plain))
(should (= (ebox-string-height plain) 1)))
(should (equal (plist-get (ebox--scroll-get-state region-id)
:content-height)
1))
(goto-char (point-min))
(ebox-scroll-down 1)
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "B" plain))
(should-not (string-match-p "A" plain))
(should (= (ebox-string-height plain) 1))))))
(ert-deftest ebox-scroll-falls-back-when-region-cannot-scroll ()
"Scroll commands should let the buffer move when the box has no scroll work."
(ebox-test--reset-runtime-state)
(with-temp-buffer
(insert (ebox-render (ebox-test-box (ebox-test-text "short") :height 1)))
(insert "\n")
(dotimes (index 40)
(insert (format "tail-%02d\n" index)))
(goto-char (point-min))
(should (get-text-property (point) 'ebox-content))
(let (fallback-called)
(cl-letf (((symbol-function 'scroll-up)
(lambda (&optional _arg)
(setq fallback-called t))))
(ebox-scroll-down 1))
(should fallback-called))))
(ert-deftest ebox-scroll-bubbles-from-child-to-scrollable-ancestor ()
"Key scrolling over a child box should scroll the nearest scrollable ancestor."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1)
(ebox-test-box (ebox-test-text "C") :height 1))))
(ids (ebox-region-ids layout))
(root-id (car ids)))
(ebox-test--with-rendered-buffer layout
(goto-char (point-min))
(search-forward "A")
(backward-char)
(should-not (equal (get-text-property (point) 'ebox-content)
root-id))
(let (fallback-called)
(cl-letf (((symbol-function 'scroll-up)
(lambda (&optional _arg)
(setq fallback-called t))))
(ebox-scroll-down 1))
(should-not fallback-called))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "B" plain))
(should (string-match-p "C" plain))))))
(ert-deftest ebox-wheel-scroll-falls-back-to-default-mwheel ()
"Mouse wheel fallback should preserve Emacs' normal scroll behavior."
(ebox-test--reset-runtime-state)
(let (mwheel-args scroll-up-called)
(cl-letf (((symbol-function 'mwheel-scroll)
(lambda (&rest args)
(setq mwheel-args args)))
((symbol-function 'scroll-up)
(lambda (&optional _arg)
(setq scroll-up-called t))))
(ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg))
(should (equal mwheel-args '(fake-wheel-event fake-arg)))
(should-not scroll-up-called)))
(ert-deftest ebox-wheel-scroll-falls-back-for-non-scrollable-region ()
"Mouse wheel over a normal box should keep default buffer scrolling."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "short") :height 1))
(region-id (car (ebox-region-ids box)))
mwheel-args)
(ebox-test--with-rendered-buffer box
(cl-letf (((symbol-function 'ebox--wheel-region-ids)
(lambda (_event) (list region-id)))
((symbol-function 'mwheel-scroll)
(lambda (&rest args)
(setq mwheel-args args))))
(ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg))
(should (equal mwheel-args '(fake-wheel-event fake-arg))))))
(ert-deftest ebox-wheel-scroll-consumes-scrollable-region ()
"Mouse wheel should still scroll an overflow='scroll box when it can."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
:height 1
:overflow 'scroll))
(region-id (car (ebox-region-ids box)))
mwheel-called)
(ebox-test--with-rendered-buffer box
(cl-letf (((symbol-function 'ebox--wheel-region-ids)
(lambda (_event) (list region-id)))
((symbol-function 'mwheel-scroll)
(lambda (&rest _args)
(setq mwheel-called t))))
(ebox--wheel-scroll 'fake-wheel-event 1))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "B" plain))
(should-not (string-match-p "A" plain)))
(should-not mwheel-called))))
(ert-deftest ebox-wheel-scroll-bubbles-from-child-to-scrollable-ancestor ()
"Mouse wheel scrolling over a child box should scroll its scrollable ancestor."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1)
(ebox-test-box (ebox-test-text "C") :height 1))))
(ids (ebox-region-ids layout))
(root-id (car ids))
(child-id (cadr ids))
mwheel-called)
(ebox-test--with-rendered-buffer layout
(cl-letf (((symbol-function 'ebox--wheel-region-ids)
(lambda (_event) (list child-id root-id)))
((symbol-function 'mwheel-scroll)
(lambda (&rest _args)
(setq mwheel-called t))))
(ebox--wheel-scroll 'fake-wheel-event 1))
(should-not mwheel-called)
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "B" plain))
(should (string-match-p "C" plain))))))
(ert-deftest ebox-scroll-state-is-created-only-for-scrollable-boxes ()
"Non-overflowing boxes should not inflate the global scroll hit-test table."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-column (ebox-test-box :id "short-a" (ebox-test-text "A") :height 1)
(ebox-test-box :id "short-b" (ebox-test-text "B") :height 1)
(ebox-test-box :id "root" :height 1 :overflow
'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "C") :height
1)
(ebox-test-box (ebox-test-text "D") :height
1)))))
root-id short-a-id short-b-id)
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
(setq short-a-id (ebox-test--selector-region-id (current-buffer)
"#short-a"))
(setq short-b-id (ebox-test--selector-region-id (current-buffer)
"#short-b"))
(should-not (ebox--scroll-get-state short-a-id))
(should-not (ebox--scroll-get-state short-b-id))
(should (ebox--scroll-get-state root-id)))))
(ert-deftest ebox-scroll-hit-test-uses-owner-stack-before-mount-lookup ()
"Wheel hit testing should avoid TP mount lookup when owners suffice."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
(ebox-test-column
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1))))
(root-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(goto-char (point-min))
(cl-letf (((symbol-function 'ebox--scroll-region-ids-in-mounts-at-pos)
(lambda (&rest _args)
(error "unexpected mount lookup"))))
(should (member root-id
(ebox--scroll-region-ids-at-pos (point))))))))
(ert-deftest ebox-chrome-free-scroll-render-reuses-propertized-prefix-lines ()
"A chrome-free scroll box should not propertize its visible prefix twice."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "row-1\nrow-2\nrow-3")
:width 120
:height 2
:overflow 'scroll
:color "#112233"
:bgcolor "#ddeeff"))
(region-id (ebox--ensure-region-id (ebox-test-root box)))
(reference (ebox-test-box (ebox-test-text "row-1\nrow-2\nrow-3")
:width 120
:height 2
:overflow 'hidden
:color "#112233"
:bgcolor "#ddeeff"))
(original (symbol-function 'ebox--propertize-content-line))
(root-propertize-count 0)
actual)
(plist-put (ebox-test-root reference) :region-id region-id)
(cl-letf (((symbol-function 'ebox--propertize-content-line)
(lambda (line owner idx fallback)
(when (equal owner region-id)
(cl-incf root-propertize-count))
(funcall original line owner idx fallback))))
(setq actual (ebox-render box)))
(should (= root-propertize-count 3))
(should-not
(text-property-not-all 0 (length actual)
'ebox-scroll-window region-id actual))
(let ((actual-without-window-tag (copy-sequence actual)))
(remove-text-properties
0 (length actual-without-window-tag)
'(ebox-scroll-window nil)
actual-without-window-tag)
(should (equal-including-properties
actual-without-window-tag
(ebox-render reference))))))
(ert-deftest ebox-underfilled-chrome-free-scroll-fuses-line-finishing-passes ()
"A complete top-aligned underfill should keep exact output in one line pass."
(ebox-test--reset-runtime-state)
(let* ((content
(concat (propertize "alpha" 'face '(:weight bold)
'help-echo "rich")
"\n \nomega"))
(box (ebox-test-box (ebox-test-text content)
:width 140
:height 5
:overflow 'scroll
:color "#112233"
:bgcolor "#ddeeff"))
(region-id (ebox--ensure-region-id (ebox-test-root box)))
(reference (ebox-test-box (ebox-test-text content)
:width 140
:height 5
:overflow 'hidden
:color "#112233"
:bgcolor "#ddeeff"))
(expected (progn
(plist-put (ebox-test-root reference)
:region-id region-id)
(ebox-render reference)))
(original-align (symbol-function 'ebox--lines-align-vertical))
(original-maplines (symbol-function 'ebox--maplines))
(align-calls 0)
(mapline-calls 0)
actual)
(cl-letf (((symbol-function 'ebox--lines-align-vertical)
(lambda (&rest args)
(cl-incf align-calls)
(apply original-align args)))
((symbol-function 'ebox--maplines)
(lambda (&rest args)
(cl-incf mapline-calls)
(apply original-maplines args))))
(setq actual (ebox-render box)))
(should (= align-calls 0))
;; Formatting may still justify the source once; finishing must not add
;; the former blank-normalization and color map passes.
(should (<= mapline-calls 1))
(should (equal-including-properties actual expected))
(should-not (ebox--scroll-get-state region-id))))
(ert-deftest ebox-lazy-scroll-rendered-window-uses-preformatted-lines ()
"Lazy scroll windows should not rebuild visible content lines per tick."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let ((state (ebox--scroll-get-state root-id)))
(should (plist-get state :rendered-content-lines))
(should (plist-get state :render-content-prefix)))
(let* ((surface ebox-surface--buffer-surface)
(revision (tp-surface-revision surface)))
(should (ebox--scroll-region-by root-id 8))
(should (= (tp-surface-revision surface) (1+ revision))))
(let ((state (ebox--scroll-get-state root-id)))
(should (= (plist-get state :scroll-offset) 8))
(should (plist-get state :rendered-content-lines)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-after-viewport-resize-keeps-window-range ()
"Viewport resize must preserve lazy scroll's fixed viewport fast path."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-column :id "viewport" :width '(viewport) :height
'(viewport-height)
(ebox-test-box (ebox-test-text "") :width '(viewport)
:height 1)
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow
'scroll
(apply #'ebox-test-column
(append (list :width '(viewport) :padding '(1 (8)))
(cl-loop for idx below 80 collect
(ebox-test-box (ebox-test-text (format "row %02d" idx)) :width
'(viewport) :height 1))))
(ebox-test-box (ebox-test-text "") :width
'(viewport) :height 1))))
buffer
root-id
mount-bounds
mount-line-count
(materialize-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 12)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-interval 60.0)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(ebox-rerender-buffer-with-context buffer 320 14)
(with-current-buffer buffer
(let ((state (ebox--scroll-get-state root-id)))
(setq mount-bounds
(ebox-surface-region-bounds buffer root-id))
(should mount-bounds)
(setq mount-line-count
(count-lines (car mount-bounds) (cdr mount-bounds)))
(ert-info ((format "state=%S"
(list :content-height
(plist-get state :content-height)
:content-lines
(length (plist-get state
:content-lines))
:rendered-lines
(length (plist-get state
:rendered-content-lines)))))
(should (= (plist-get state :content-height) 14)))
(when-let* ((materialize
(plist-get state :materialize-content-lines)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state)))
(should (ebox--smooth-scroll-region-by root-id 16))
(while (gethash root-id ebox--smooth-scroll-state-table)
(ebox--smooth-scroll-tick root-id))
(let ((current-bounds
(ebox-surface-region-bounds buffer root-id)))
(should current-bounds)
(should (= mount-line-count
(count-lines (car current-bounds)
(cdr current-bounds)))))
(should (= materialize-count 0))))
(when root-id
(ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-intrinsic-measurement-skips-window-source ()
"Flex intrinsic measurement should not render lazy scroll window content."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box (ebox-test-text (format "measurement row %02d"
idx))
:width '(viewport) :height 1))))))
(calls 0)
rendered)
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (&rest _args)
(cl-incf calls)
(list :lines nil :complete nil :cache nil))))
(let ((ebox-viewport-width 240)
(ebox-viewport-height 12)
(ebox--intrinsic-layout-measurement t))
(setq rendered (ebox-render layout)))
(should (= calls 0))
(should (= (ebox-string-height rendered) 12))
(let ((ebox-viewport-width 240)
(ebox-viewport-height 12)
(ebox--intrinsic-layout-measurement nil))
(ebox-render layout))
(should (= calls 1)))))
(ert-deftest ebox-lazy-scroll-sync-prefix-does-not-require-idle-prefetch ()
"Interactive scrolling should extend a lazy prefix even with idle prefetch off."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 80 collect
(ebox-test-box (ebox-test-text (format "sync row %02d" idx))
:width '(viewport) :height 1))))))
buffer
root-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 20)
(ebox-scroll-step 4)
(ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((before (ebox--scroll-get-state root-id))
(initial-lines (length (plist-get before :content-lines)))
(content-height (plist-get before :content-height))
(boundary-offset (- initial-lines content-height)))
(should (> boundary-offset 0))
(with-current-buffer buffer
(should (= boundary-offset
(ebox--scroll-region-by root-id
boundary-offset)))))
(with-current-buffer buffer
(should (= 4 (ebox--scroll-region-by root-id 4))))
(let ((after (ebox--scroll-get-state root-id)))
(should (> (plist-get after :scroll-offset) 4))
(should (>= (length (plist-get after :content-lines))
(+ (plist-get after :scroll-offset)
(plist-get after :content-height))))
(should-not (plist-get after :content-lines-complete-p))))
(when root-id
(ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-gui-small-cache-miss-stays-in-ebox-coordinate ()
"A bounded GUI cache miss should extend and scroll in one Ebox commit."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 80 collect
(ebox-test-box (ebox-test-text (format "gui row %02d" idx))
:width '(viewport) :height 1))))))
buffer
root-id
(prefix-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 20)
(ebox-scroll-step 4)
(ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-scroll-lazy-prefix-lookahead-lines 8)
(noninteractive nil))
(cl-letf (((symbol-function 'display-graphic-p)
(lambda (&optional _display) t)))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(initial-lines (length (plist-get state :content-lines)))
(content-height (plist-get state :content-height))
(boundary-offset (- initial-lines content-height)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state)
(with-current-buffer buffer
(should (= boundary-offset
(ebox--scroll-region-by root-id
boundary-offset)))
(should (= 4 (ebox--scroll-region-by root-id 4)))
(should (= (+ boundary-offset 4)
(plist-get (ebox--scroll-get-state root-id)
:scroll-offset)))
(should (= (+ initial-lines 12)
(length
(plist-get (ebox--scroll-get-state root-id)
:content-lines))))
(should
(= content-height
(length
(plist-get
(plist-get (ebox--buffer-render-state buffer)
:surface-fragments)
:scroll-lines))))))
(should (= prefix-count 1))))
(when root-id
(ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-explicit-budget-syncs-prefix-in-gui ()
"A caller-provided prefix budget should synchronously extend GUI lazy scrolls."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 80 collect
(ebox-test-box (ebox-test-text (format "budget row %02d" idx))
:width '(viewport) :height 1))))))
buffer
root-id
(prefix-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 20)
(ebox-scroll-step 4)
(ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(noninteractive nil))
(cl-letf (((symbol-function 'display-graphic-p)
(lambda (&optional _display) t)))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(initial-lines (length (plist-get state :content-lines)))
(content-height (plist-get state :content-height))
(boundary-offset (- initial-lines content-height)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state)
(with-current-buffer buffer
(should (= boundary-offset
(ebox--scroll-region-by root-id
boundary-offset)))
(should (= 4 (ebox--scroll-region-by root-id 4 4)))))
(should (= prefix-count 1))))
(when root-id
(ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-prefix-reuses-viewport-dependent-flex-leaves ()
"Lazy prefix extension should not rerender prior viewport flex leaves."
(ebox-test--reset-runtime-state)
(let* ((flex-section
(lambda (label)
(apply #'ebox-test-flex
(append
(list :width '(viewport) :flex-flow '(row wrap))
(cl-loop for idx below 4
collect
(ebox-test-box
(ebox-test-text (format "%s-%d" label idx))
:width '(110)
:height 1))))))
(layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(ebox-test-column :width '(viewport)
(funcall flex-section "a")
(ebox-test-box :height 1)
(funcall flex-section "b"))))
(buffer nil)
root-id
(flex-render-count 0)
(original-render-flex (symbol-function 'ebox--render-flex)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let ((state (ebox--scroll-get-state root-id)))
(should (= (length (plist-get state :content-lines)) 3))
(should (plist-get state :render-content-prefix)))
(cl-letf (((symbol-function 'ebox--render-flex)
(lambda (&rest args)
(cl-incf flex-render-count)
(apply original-render-flex args))))
(should (ebox--scroll-region-by root-id 3)))
(should (<= flex-render-count 1))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-flex-prefix-renders-only-needed-rows ()
"Lazy scroll prefixes should not render hidden row-wrap flex rows."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(ebox-test-column :width '(viewport)
(ebox-test-box :id "head" (ebox-test-text "head") :width
'(viewport) :height 1)
(apply #'ebox-test-flex
(append
(list :width '(viewport) :flex-flow '(row wrap) :row-gap 0
:column-gap 0)
(cl-loop for idx below 12 collect
(ebox-test-box :id (format "item-%02d" idx) (ebox-test-text (format "item %02d" idx)) :width '(100)
:height 2)))))))
(buffer nil)
(hidden-render-count 0)
(original-render-box (symbol-function 'ebox--render-box)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 1)
(ebox-wheel-scroll-step 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when-let* ((id (and (listp box) (ebox-get box :id))))
(when (and (string-prefix-p "item-" id)
(>= (string-to-number
(substring id (length "item-")))
4))
(cl-incf hidden-render-count)))
(funcall original-render-box box))))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(should (= hidden-render-count 0)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-flex-window-shrinks-definite-wrapping-items ()
"Window prefix rendering must not freeze shrinkable definite widths."
(ebox-test--reset-runtime-state)
(let* ((node
(ebox-test-flex :width '(160) :flex-flow '(row wrap)
(ebox-test-box :id "shrink-a" (ebox-test-text "alpha beta gamma delta") :width '(200)
:height 2)
(ebox-test-box :id "shrink-b" (ebox-test-text "second row content") :width '(160)
:height 2)))
(limit 2)
(full-lines (ebox-string-lines (ebox-render node)))
(window (ebox-test--call-input-node
#'ebox--render-flex-window-lines node limit)))
;; A nil window means the full-render fallback takes over, which is
;; also correct. Produced window lines must match the full
;; renderer's prefix geometry instead of freezing the declared
;; 200px width inside the 160px container.
(when window
(should (equal (mapcar #'ebox--string-pixel-width
(seq-take full-lines limit))
(mapcar #'ebox--string-pixel-width
(plist-get window :lines)))))))
(ert-deftest ebox-flex-window-char-wrapper-falls-back-to-full-prefix ()
"Char-wrapped flex prefixes should use the full-render fallback."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :width '(100) :wrap-mode 'char :flex-flow '(row wrap)
:row-gap 0 :column-gap 0)
(cl-loop for char across "ABCD" collect
(ebox-test-box (ebox-test-text (char-to-string char)) :width
'(150) :height 1)))))
(full-lines (ebox-string-lines (ebox-render node)))
(expected-signatures
(mapcar #'ebox-test--visual-line-signature
(seq-take full-lines 3)))
(ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
(first (ebox-test--call-input-node
#'ebox--render-stack-window-lines node 1))
(extended
(ebox-test--call-input-node
#'ebox--render-stack-window-lines node 3 (plist-get first :cache)))
(producer (ebox-test--call-input-node
#'ebox--render-node-window-lines node 3))
(actual-signatures
(mapcar #'ebox-test--visual-line-signature
(plist-get extended :lines))))
(should (equal expected-signatures actual-signatures))
(should-not producer)))
(ert-deftest ebox-canonical-column-scroll-window-renders-a-bounded-prefix ()
"A canonical Column should expose only the requested scroll prefix."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-column
(cl-loop for index below 20
collect
(ebox-test-box
(ebox-test-text (format "row %02d" index))
:height 1))))
(full (ebox-string-lines (ebox-render node)))
(render-count 0)
(original (symbol-function 'ebox--render-with-cache)))
(cl-letf (((symbol-function 'ebox--render-with-cache)
(lambda (&rest arguments)
(cl-incf render-count)
(apply original arguments))))
(let ((window (ebox-test--call-input-node
#'ebox--render-stack-window-lines node 3)))
(should-not (plist-get window :complete))
(should
(equal (mapcar #'substring-no-properties (seq-take full 3))
(mapcar #'substring-no-properties
(plist-get window :lines))))
(should
(equal (mapcar #'ebox--string-pixel-width (seq-take full 3))
(mapcar #'ebox--string-pixel-width
(plist-get window :lines))))))
(should (< render-count 20))))
(ert-deftest ebox-flex-window-lines-use-chunk-wrapper-for-first-prefix ()
"A seedless flex prefix should avoid the generic full wrapper renderer."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :id "flex-window-wrapper" :width '(240) :flex-flow
'(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8))
:border-right "#D97757" :border-bottom "#D97757"
:border-left "#D97757" :bgcolor "#FFF8F2")
(cl-loop for idx below 8 collect
(ebox-test-box (ebox-test-text (format "wrapper %02d" idx))
:width '(100) :height 2)))))
(full-lines (ebox-string-lines (ebox-render node)))
(full-wrapper-count 0)
(chunk-wrapper-count 0)
(multiline-horizontal-concat-count 0)
(original-full-wrapper
(symbol-function 'ebox--flex-window-render-box-lines))
(original-chunk-wrapper
(symbol-function 'ebox--flex-window-render-wrapper-chunk-lines))
(original-horizontal-concat
(symbol-function 'ebox--lines-concat-horizontal)))
(cl-letf (((symbol-function 'ebox--flex-window-render-box-lines)
(lambda (&rest args)
(cl-incf full-wrapper-count)
(apply original-full-wrapper args)))
((symbol-function 'ebox--flex-window-render-wrapper-chunk-lines)
(lambda (&rest args)
(cl-incf chunk-wrapper-count)
(apply original-chunk-wrapper args)))
((symbol-function 'ebox--lines-concat-horizontal)
(lambda (&rest args)
(cl-incf multiline-horizontal-concat-count)
(apply original-horizontal-concat args))))
(let* ((ebox--surface-materialization-active t)
(window (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 4)))
(should window)
(should-not (plist-get window :complete))
(should (= (length (plist-get window :lines)) 4))
(should
(ebox-test--rendered-visually-equal-p
(ebox-lines-join (seq-take full-lines 4))
(ebox-lines-join (plist-get window :lines))))))
(should (= full-wrapper-count 0))
(should (= chunk-wrapper-count 1))
(should (= multiline-horizontal-concat-count 0))))
(ert-deftest ebox-flex-flat-wrapper-owns-color-copy-in-place ()
"Flat flex wrappers should decorate one private line copy per source line."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box :width 120
:padding-left 8
:padding-right 8
:border-left '(1 solid "#D97757")
:border-right '(1 solid "#D97757")
:bgcolor "#FFF8F2"))
(source (propertize "child" 'ebox-content 77
'ebox-content-owner 77
'ebox-content-owners '(77)))
(source-before (copy-sequence source))
(expected
(ebox-test--call-input-node
#'ebox--flex-window-render-wrapper-chunk-lines
box (list source) 0 t))
(copying-owner-count 0)
(original-copying-owner
(symbol-function 'ebox--add-content-owner))
actual)
(cl-letf (((symbol-function 'ebox--add-content-owner)
(lambda (&rest args)
(cl-incf copying-owner-count)
(apply original-copying-owner args))))
(setq actual
(ebox-test--call-input-node
#'ebox--flex-window-render-wrapper-chunk-lines
box (list source) 0 t)))
(should (equal-including-properties source source-before))
(should
(equal-including-properties
(ebox-lines-join actual)
(ebox-lines-join expected)))
(should (= copying-owner-count 0))))
(ert-deftest ebox-flex-window-lines-reuses-render-cache ()
"Repeated flex window renders should reuse the render cache."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :width '(240) :flex-flow '(row wrap) :row-gap 1
:column-gap 0)
(cl-loop for idx below 8 collect
(ebox-test-box :id (format "cache-item-%02d" idx)
(ebox-test-text (format "cache %02d" idx))
:width '(100) :height 2)))))
(ebox--render-cache-table (make-hash-table :test 'equal))
(row-render-count 0)
(original-render-row-line
(symbol-function 'ebox--flex-render-row-line)))
(cl-letf (((symbol-function 'ebox--flex-render-row-line)
(lambda (&rest args)
(cl-incf row-render-count)
(apply original-render-row-line args))))
(should (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 4))
(let ((after-first row-render-count))
(should (> after-first 0))
(should (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 4))
(should (= row-render-count after-first))))))
(ert-deftest ebox-flex-window-lines-reuses-node-cache-signature ()
"One flex window render should not repeatedly compute the node signature."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :width '(240) :flex-flow '(row wrap) :row-gap 1
:column-gap 0)
(cl-loop for idx below 8 collect
(ebox-test-box :id (format "sig-item-%02d" idx)
(ebox-test-text (format "sig %02d" idx))
:width '(100) :height 2)))))
(ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
(signature-count 0)
(original-signature
(symbol-function 'ebox--render-cache-node-signature)))
(cl-letf (((symbol-function 'ebox--render-cache-node-signature)
(lambda (&rest args)
(when (eq (car args) (ebox-test-root node))
(cl-incf signature-count))
(apply original-signature args))))
(should (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 4)))
(should (<= signature-count 2))))
(ert-deftest ebox-flex-window-prefix-extension-reuses-wrapper-lines ()
"Extending a flex prefix should not rerender prior wrapper lines."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :id "flex-prefix-wrapper" :width '(240) :flex-flow
'(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8))
:border-right "#D97757" :border-bottom "#D97757"
:border-left "#D97757" :bgcolor "#FFF8F2")
(cl-loop for idx below 8 collect
(ebox-test-box (ebox-test-text (format "extend %02d" idx))
:width '(100) :height 2)))))
(full-lines (mapcar #'substring-no-properties
(ebox-string-lines (ebox-render node))))
(ebox--render-cache-table (make-hash-table :test 'equal))
(generic-wrapper-render-count 0)
(original-render-box (symbol-function 'ebox--render-box)))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (equal (and (listp box) (ebox-get box :id))
"flex-prefix-wrapper")
(cl-incf generic-wrapper-render-count))
(funcall original-render-box box))))
(should (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 4))
(should (= generic-wrapper-render-count 0))
(let ((window (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 8)))
(should window)
(should (= generic-wrapper-render-count 0))
(should (equal (seq-take full-lines 8)
(mapcar #'substring-no-properties
(plist-get window :lines))))))))
(ert-deftest ebox-flex-window-prefix-extension-joins-only-new-pieces ()
"Extending a flex prefix should not rejoin the whole cached prefix."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :width '(240) :flex-flow '(row wrap) :row-gap 1
:column-gap 0 :padding '(0 (8)) :border-right "#D97757"
:border-bottom "#D97757" :border-left "#D97757" :bgcolor
"#FFF8F2")
(cl-loop for idx below 16 collect
(ebox-test-box (ebox-test-text (format "piece %02d" idx))
:width '(100) :height 2)))))
(ebox--render-cache-table (make-hash-table :test 'equal))
join-piece-counts
(original-join-pieces
(symbol-function 'ebox--flex-window-lines-from-pieces)))
(cl-letf (((symbol-function 'ebox--flex-window-lines-from-pieces)
(lambda (pieces)
(push (length pieces) join-piece-counts)
(funcall original-join-pieces pieces))))
(should (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 4))
(setq join-piece-counts nil)
(should (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 8)))
(should join-piece-counts)
(should-not (cl-some (lambda (count) (> count 4))
join-piece-counts))))
(ert-deftest ebox-flex-window-complete-prefix-defers-bottom-wrapper ()
"A complete flex source should not full-render bottom chrome before LIMIT."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :id "flex-complete-prefix" :width '(240) :flex-flow
'(row wrap) :row-gap 1 :column-gap 0 :padding
'(0 (8) 2 (8)) :margin-bottom 2 :border-right "#D97757"
:border-bottom "#D97757" :border-left "#D97757" :bgcolor
"#FFF8F2")
(cl-loop for idx below 2 collect
(ebox-test-box (ebox-test-text (format "complete %02d" idx))
:width '(100) :height 8)))))
(limit 4)
(full-lines (ebox-string-lines (ebox-render node)))
(complete-wrapper-count 0)
(original-render-box-lines
(symbol-function 'ebox--flex-window-render-box-lines)))
(cl-letf (((symbol-function 'ebox--flex-window-render-box-lines)
(lambda (node content complete limit)
(when complete
(cl-incf complete-wrapper-count))
(funcall original-render-box-lines
node content complete limit))))
(let ((window (ebox-test--call-input-node
#'ebox--render-flex-window-lines node limit)))
(should window)
(should-not (plist-get window :complete))
(should (= complete-wrapper-count 0))
(should
(cl-every
(lambda (pair)
(let ((full (car pair))
(prefix (cdr pair)))
(or (equal (substring-no-properties full)
(substring-no-properties prefix))
(and (string-blank-p full)
(string-blank-p prefix)
(= (ebox--string-pixel-width full)
(ebox--string-pixel-width prefix))))))
(cl-mapcar #'cons (seq-take full-lines limit)
(plist-get window :lines))))))))
(ert-deftest ebox-flex-window-complete-extension-appends-bottom-border ()
"Completing a cached flex prefix should append bottom border without full render."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :id "flex-complete-border" :width '(240) :flex-flow
'(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8))
:border-right "#D97757" :border-bottom "#D97757"
:border-left "#D97757" :bgcolor "#FFF8F2")
(cl-loop for idx below 4 collect
(ebox-test-box (ebox-test-text (format "border %02d" idx))
:width '(100) :height 2)))))
(ebox--render-cache-table (make-hash-table :test 'equal))
(full-lines (mapcar #'substring-no-properties
(ebox-string-lines (ebox-render node))))
(complete-wrapper-count 0)
(original-render-box-lines
(symbol-function 'ebox--flex-window-render-box-lines)))
(should (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 4))
(cl-letf (((symbol-function 'ebox--flex-window-render-box-lines)
(lambda (node content complete limit)
(when complete
(cl-incf complete-wrapper-count))
(funcall original-render-box-lines
node content complete limit))))
(let ((window (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 8)))
(should window)
(should (plist-get window :complete))
(should (= complete-wrapper-count 0))
(should (equal (seq-take full-lines 8)
(mapcar #'substring-no-properties
(plist-get window :lines))))))))
(ert-deftest ebox-flex-window-failed-completion-keeps-prefix-cache-atomic ()
"A failed flex completion should not publish half-appended cache lists."
(ebox-test--reset-runtime-state)
(let* ((node
(apply #'ebox-test-flex
(append
(list :id "flex-atomic-prefix" :width '(240) :flex-flow
'(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8))
:border-right "#D97757" :border-bottom "#D97757"
:border-left "#D97757")
(cl-loop for idx below 4 collect
(ebox-test-box (ebox-test-text (format "atomic %02d" idx))
:width '(100) :height 2)))))
(ebox--render-cache-table (make-hash-table :test 'equal))
(context (ebox-test--call-input-node
#'ebox--flex-window-cache-context node)))
(should (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 4))
(let* ((before (ebox-test--call-input-node
#'ebox--flex-window-prefix-cache-lookup node context))
(before-lines (plist-get before :lines))
(before-content-lines (plist-get before :content-lines))
(before-line-count (plist-get before :line-count))
(before-content-count (plist-get before :content-line-count)))
(cl-letf (((symbol-function 'ebox--flex-window-complete-tail-line)
(lambda (&rest _)
(error "injected flex completion failure"))))
(should-error
(ebox-test--call-input-node
#'ebox--render-flex-window-lines node 8)))
(let ((after (ebox-test--call-input-node
#'ebox--flex-window-prefix-cache-lookup node context)))
(should (eq before-lines (plist-get after :lines)))
(should (eq before-content-lines
(plist-get after :content-lines)))
(should (= before-line-count (length before-lines)))
(should (= before-content-count (length before-content-lines)))
(should (= (plist-get after :line-count)
(length (plist-get after :lines))))
(should (= (plist-get after :content-line-count)
(length (plist-get after :content-lines)))))
(should (plist-get (ebox-test--call-input-node
#'ebox--render-flex-window-lines node 8)
:complete)))))
(ert-deftest ebox-stack-window-completion-reconciles-declared-prefix-tail ()
"A completed leaf should refresh declared chrome on its cached tail."
(ebox-test--reset-runtime-state)
(let* ((type 'ebox-test-completing-window)
(leaf (list :ebox-type type))
(stack (list :ebox-type 'stack :children (list leaf)))
(plain-tail (propertize "tail" 'face '(:foreground "red")))
(complete-tail
(ebox--propertize-underline
(copy-sequence plain-tail) "blue")))
(unwind-protect
(progn
(ebox--register-window-line-renderer
type
(lambda (_node limit)
(if (< limit 3)
(list :lines (list "head" plain-tail)
:complete nil)
(list :lines (list "head" complete-tail)
:complete t
:completion-prefix-tail-rewrite-count 1))))
(let* ((prefix (ebox--render-stack-window-lines stack 2))
(completed
(ebox--render-stack-window-lines
stack 3 (plist-get prefix :cache)))
(tail (cadr (plist-get completed :lines)))
(face (get-text-property 0 'face tail))
(delta (plist-get completed :prefix-delta)))
(should (plist-get completed :complete))
(should (= (length (plist-get completed :lines)) 2))
(should (plist-get delta :exact-p))
(should (= (plist-get delta :base-count) 2))
(should (equal (plist-get delta :rewrite-range) '(1 . 2)))
(should
(ebox-test--face-has-key-value-p
face :underline '(:position t :color "blue")))))
(remhash type ebox--window-line-renderer-table))))
(ert-deftest ebox-stack-window-invalid-completion-rewrite-rebuilds-prefix ()
"Invalid completion metadata should discard a potentially stale prefix."
(ebox-test--reset-runtime-state)
(dolist (metadata '(3 -1 invalid missing))
(let* ((type 'ebox-test-invalid-completing-window)
(leaf (list :ebox-type type))
(stack (list :ebox-type 'stack :children (list leaf)))
(plain-tail "tail")
(complete-tail
(ebox--propertize-underline "tail" "blue"))
(calls 0))
(unwind-protect
(progn
(ebox--register-window-line-renderer
type
(lambda (_node limit)
(cl-incf calls)
(if (< limit 3)
(list :lines (list "head" plain-tail)
:complete nil)
(append
(list :lines (list "head" complete-tail)
:complete t)
(unless (eq metadata 'missing)
(list :completion-prefix-tail-rewrite-count
metadata))))))
(let* ((prefix (ebox--render-stack-window-lines stack 2))
(completed
(ebox--render-stack-window-lines
stack 3 (plist-get prefix :cache)))
(tail (cadr (plist-get completed :lines)))
(face (get-text-property 0 'face tail))
(delta (plist-get completed :prefix-delta)))
(should (= calls 3))
(should-not (plist-get delta :exact-p))
(should
(ebox-test--face-has-key-value-p
face :underline '(:position t :color "blue")))))
(remhash type ebox--window-line-renderer-table)))))
(ert-deftest ebox-flex-window-completion-tail-metadata-is-border-specific ()
"Only an existing bottom-border line should request a prefix rewrite."
(ebox-test--reset-runtime-state)
(should
(= 1
(ebox--flex-window-completion-prefix-tail-rewrite-count
(list :box (ebox-test-root
(ebox-test-box :border-bottom-width 1
:border-bottom-style 'solid
:padding-bottom 0))))))
(should
(= 1
(ebox--flex-window-completion-prefix-tail-rewrite-count
(list :box (ebox-test-root
(ebox-test-box :border-bottom-width 1
:border-bottom-style 'solid
:padding-bottom 0
:margin-bottom 2))))))
(should
(= 0
(ebox--flex-window-completion-prefix-tail-rewrite-count
(list :box (ebox-test-root
(ebox-test-box :border-bottom-width 1
:border-bottom-style 'solid
:padding-bottom 2))))))
(should
(= 0
(ebox--flex-window-completion-prefix-tail-rewrite-count
(list :box (ebox-test-root
(ebox-test-box :padding-bottom 0)))))))
(ert-deftest ebox-flex-window-cache-rejects-short-incomplete-exact-hit ()
"An underfilled exact-limit cache must resume from its prefix seed."
(ebox-test--reset-runtime-state)
(let* ((lines (cl-loop repeat 112 collect "line"))
(entry (list :lines lines
:line-count 112
:line-tail (last lines)
:complete nil))
looked-up-keys)
(cl-letf (((symbol-function 'ebox--render-cache-lookup)
(lambda (key _signature)
(push key looked-up-keys)
(cond
((equal key '(7 flex-window prefix)) entry)
((equal key '(7 flex-window 118)) entry)))))
(should-not
(ebox--flex-window-cache-lookup
:test-node 118 '(:node-id 7 :signature test-signature))))
(should (member '(7 flex-window 118) looked-up-keys))))
(ert-deftest ebox-byte-compiled-scroll-window-binds-wrapper-viewport ()
"Compiled lazy rendering must dynamically bind the wrapper content width."
(ebox-test--reset-runtime-state)
(let* ((wrapper (ebox-test-box (ebox-test-text "") :width '(278) :wrap-mode 'none))
(source (list :stack :test-stack
:wrapper (ebox-test-root wrapper)))
observed-viewport)
(cl-letf (((symbol-function 'ebox--render-stack-window-lines)
(lambda (&rest _args)
(setq observed-viewport ebox-viewport-width)
(throw 'viewport-observed nil))))
(let ((ebox-viewport-width 900))
(catch 'viewport-observed
(ebox--render-scroll-window-source source 1))))
(should (= observed-viewport 278))))
(ert-deftest ebox-scroll-window-wrapper-applies-declared-prefix-rewrite ()
"Wrapper prefix reuse should rerender only a declared changed range."
(ebox-test--reset-runtime-state)
(let* ((wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none))
(source (list :stack :test-stack
:wrapper (ebox-test-root wrapper)))
(plain-tail "tail")
(complete-tail
(ebox--propertize-underline "tail" "blue"))
(stack-lines (list plain-tail))
(calls 0)
(second-call-line-count 0)
(original-chunk-renderer
(symbol-function
'ebox--scroll-window-render-wrapper-chunk-lines)))
(cl-letf (((symbol-function 'ebox--render-stack-window-lines)
(lambda (&rest _args)
(cl-incf calls)
(if (= calls 1)
(list :lines stack-lines
:line-count 1
:line-tail stack-lines
:complete nil
:cache :first
:prefix-delta
'(:exact-p nil :base-count 0
:rewrite-range nil
:rewrite-lines nil
:append-lines nil))
(setcar stack-lines complete-tail)
(list :lines stack-lines
:line-count 1
:line-tail stack-lines
:complete nil
:cache :second
:prefix-delta
(list :exact-p t :base-count 1
:rewrite-range '(0 . 1)
:rewrite-lines (list complete-tail)
:append-lines nil)))))
((symbol-function
'ebox--scroll-window-render-wrapper-chunk-lines)
(lambda (&rest args)
(when (= calls 2)
(cl-incf second-call-line-count
(length (cadr args))))
(apply original-chunk-renderer args)))
((symbol-function 'equal-including-properties)
(lambda (&rest _)
(ert-fail "wrapper reuse scanned the whole prefix"))))
(let* ((prefix (ebox--render-scroll-window-source source 1))
(extended
(ebox--render-scroll-window-source
source 2 (plist-get prefix :cache)))
(line (car (plist-get extended :lines)))
(face (get-text-property 0 'face line))
(delta (plist-get extended :prefix-delta)))
(should (= calls 2))
(should (= second-call-line-count 1))
(should (plist-get delta :exact-p))
(should (= (plist-get delta :base-count) 1))
(should (equal (plist-get delta :rewrite-range) '(0 . 1)))
(should
(ebox-test--face-has-key-value-p
face :underline '(:position t :color "blue")))))))
(ert-deftest ebox-scroll-window-wrapper-maps-top-chrome-rewrite-ranges ()
"Wrapper reuse should include top chrome only when source line zero changes."
(ebox-test--reset-runtime-state)
(let ((wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none
:margin-top 1 :padding-top 2)))
(cl-labels
((mapped-range (source-range)
(let* ((source
(list :stack :test-stack
:wrapper (ebox-test-root wrapper)))
(source-lines (list "head" "body"))
(calls 0))
(cl-letf
(((symbol-function 'ebox--render-stack-window-lines)
(lambda (&rest _)
(cl-incf calls)
(when (= calls 2)
(setcar (nthcdr (car source-range) source-lines)
"changed"))
(list
:lines source-lines
:line-count 2
:line-tail (last source-lines)
:complete nil
:cache calls
:prefix-delta
(if (= calls 1)
'(:exact-p nil :base-count 0
:rewrite-range nil :rewrite-lines nil
:append-lines nil)
(list :exact-p t :base-count 2
:rewrite-range source-range
:rewrite-lines (list "changed")
:append-lines nil))))))
(let* ((prefix
(ebox--render-scroll-window-source source 2))
(extended
(ebox--render-scroll-window-source
source 3 (plist-get prefix :cache))))
(plist-get (plist-get extended :prefix-delta)
:rewrite-range))))))
(should (equal (mapped-range '(0 . 1)) '(0 . 4)))
(should (equal (mapped-range '(1 . 2)) '(4 . 5))))))
(ert-deftest ebox-scroll-prefix-renderer-applies-equal-length-prefix-rewrite ()
"Equal-length completion should rerender only its declared changed line."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1
:wrap-mode 'none))
(plain-lines '("head" "body" "tail"))
(source-lines (copy-sequence plain-lines))
(complete-tail
(ebox--propertize-underline "tail" "blue"))
(renderer
(ebox--scroll-window-prefix-renderer
(ebox-test-root box) :test-source 20 4))
(rendered-line-count 0)
(original-renderer
(symbol-function 'ebox--scroll-rendered-content-lines)))
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (_source limit &optional _cache)
(when (> limit 3)
(setcar (nthcdr 2 source-lines) complete-tail))
(list :lines source-lines
:line-count 3
:line-tail (last source-lines)
:cache nil
:complete (> limit 3)
:content-width-exact-p t
:rendered-lines nil
:prefix-delta
(if (= limit 3)
'(:exact-p nil :base-count 0
:rewrite-range nil
:rewrite-lines nil
:append-lines nil)
(list :exact-p t :base-count 3
:rewrite-range '(2 . 3)
:rewrite-lines (list complete-tail)
:append-lines nil)))))
((symbol-function 'ebox--scroll-window-formatted-lines)
(lambda (_box window) (plist-get window :lines)))
((symbol-function 'ebox--scroll-window-source-lines-exact-p)
(lambda (&rest _) t))
((symbol-function 'ebox--scroll-rendered-content-lines)
(lambda (&rest args)
(cl-incf rendered-line-count (length (cadr args)))
(apply original-renderer args)))
((symbol-function 'equal-including-properties)
(lambda (&rest _)
(ert-fail "rendered prefix reuse scanned all old lines"))))
(let* ((prefix (funcall renderer nil 1 3))
(_ (setq rendered-line-count 0))
(completed (funcall renderer prefix 1 4))
(lines (plist-get completed :rendered-content-lines))
(line (nth 2 lines))
(face (get-text-property 0 'face line)))
(should (plist-get completed :complete))
(should (= (length lines) 3))
(should (= rendered-line-count 1))
(should
(ebox-test--face-has-key-value-p
face :underline '(:position t :color "blue")))))))
(ert-deftest ebox-scroll-prefix-projects-wrapper-top-chrome-delta ()
"A published prefix should reuse a source cache with hidden top chrome."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1
:wrap-mode 'none))
(wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none
:padding-top 2 :margin-top 1))
(source (list :stack :test-stack
:wrapper (ebox-test-root wrapper)))
stack-lines
stack-tail
(stack-count 0)
(calls 0)
(rendered-line-count 0)
(original-renderer
(symbol-function 'ebox--scroll-rendered-content-lines))
(renderer
(ebox--scroll-window-prefix-renderer
(ebox-test-root box) source 20 4)))
(cl-letf (((symbol-function 'ebox--render-stack-window-lines)
(lambda (_stack limit &optional _cache)
(cl-incf calls)
(let ((base stack-count)
appended-lines)
(while (< stack-count limit)
(let ((cell (list (format "row-%d" stack-count))))
(if stack-tail
(setcdr stack-tail cell)
(setq stack-lines cell))
(setq stack-tail cell)
(push (car cell) appended-lines)
(cl-incf stack-count)))
(setq appended-lines (nreverse appended-lines))
(list :lines stack-lines
:line-count stack-count
:line-tail stack-tail
:complete nil
:cache (list :call calls)
:prefix-delta
(if (= calls 1)
'(:exact-p nil :base-count 0
:rewrite-range nil
:rewrite-lines nil
:append-lines nil)
(list :exact-p t
:base-count base
:rewrite-range nil
:rewrite-lines nil
:append-lines appended-lines))))))
((symbol-function 'ebox--scroll-window-formatted-lines)
(lambda (_box window) (plist-get window :lines)))
((symbol-function 'ebox--scroll-window-source-lines-exact-p)
(lambda (&rest _) t))
((symbol-function 'ebox--scroll-rendered-content-lines)
(lambda (&rest args)
(cl-incf rendered-line-count (length (cadr args)))
(apply original-renderer args)))
((symbol-function 'equal-including-properties)
(lambda (&rest _)
(ert-fail "top-chrome reuse scanned the whole prefix"))))
(let* ((prefix (funcall renderer nil 1 6))
(prefix-count
(length (plist-get prefix :content-lines)))
(_ (setq rendered-line-count 0))
(extended (funcall renderer prefix 1 7)))
(should (= calls 2))
(should (= prefix-count 6))
(should (= (length (plist-get extended :content-lines)) 7))
(should (= rendered-line-count 1))))))
(ert-deftest ebox-scroll-prefix-exact-extension-scans-only-appended-lines ()
"A long exact prefix should retain its cons cells and scan only its delta."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1
:wrap-mode 'none))
(source-lines
(cl-loop for idx below 1000 collect (format "line-%d" idx)))
(source-tail (last source-lines))
(source-count 1000)
(calls 0)
scan-sizes
record-scans
(original-seq-take (symbol-function 'seq-take))
(renderer
(ebox--scroll-window-prefix-renderer box :test-source 20 4)))
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (_source limit &optional _cache)
(cl-incf calls)
(let ((base source-count)
appended-lines)
(while (< source-count limit)
(let ((cell (list (format "line-%d" source-count))))
(setcdr source-tail cell)
(setq source-tail cell)
(push (car cell) appended-lines)
(cl-incf source-count)))
(setq appended-lines (nreverse appended-lines))
(list :lines source-lines
:line-count source-count
:line-tail source-tail
:cache :same
:complete nil
:content-width-exact-p t
:rendered-lines nil
:prefix-delta
(if (= calls 1)
'(:exact-p nil :base-count 0
:rewrite-range nil
:rewrite-lines nil
:append-lines nil)
(list :exact-p t :base-count base
:rewrite-range nil
:rewrite-lines nil
:append-lines appended-lines))))))
((symbol-function 'ebox--scroll-window-formatted-lines)
(lambda (_box window) (plist-get window :lines)))
((symbol-function 'ebox--scroll-window-source-lines-exact-p)
(lambda (&rest _) t))
((symbol-function 'ebox--scroll-rendered-content-lines)
(lambda (_box lines _region-id _start-index) lines))
((symbol-function 'seq-take)
(lambda (sequence count)
(when record-scans
(push (length sequence) scan-sizes))
(funcall original-seq-take sequence count))))
(let* ((prefix (funcall renderer nil 1 1000))
(content-head (plist-get prefix :content-lines)))
(setq record-scans t)
(let ((extended (funcall renderer prefix 1 1001)))
(should (eq content-head
(plist-get extended :content-lines)))
(should (= (length (plist-get extended :content-lines)) 1001))
(should scan-sizes)
(should (<= (apply #'max scan-sizes) 1)))))))
(ert-deftest ebox-stack-window-completion-rewrite-uses-delta-payload ()
"A long leaf completion should not scan its full prefix for one rewrite."
(ebox-test--reset-runtime-state)
(let* ((type 'ebox-test-long-completing-window)
(leaf (list :ebox-type type))
(stack (list :ebox-type 'stack :children (list leaf)))
(leaf-lines
(cl-loop for idx below 1000 collect (format "line-%d" idx)))
(leaf-tail (last leaf-lines))
(complete-tail
(ebox--propertize-underline "line-999" "blue"))
(original-length (symbol-function 'length))
(original-nthcdr (symbol-function 'nthcdr))
(original-seq-subseq (symbol-function 'seq-subseq))
record-scans
(full-length-count 0)
(full-nthcdr-count 0)
(full-subseq-count 0))
(unwind-protect
(progn
(ebox--register-window-line-renderer
type
(lambda (_node limit)
(if (<= limit 1000)
(list :lines leaf-lines
:line-count 1000
:line-tail leaf-tail
:complete nil)
(setcar leaf-tail complete-tail)
(list :lines leaf-lines
:line-count 1000
:line-tail leaf-tail
:complete t
:completion-prefix-tail-rewrite-count 1
:prefix-delta
(list :exact-p t :base-count 1000
:rewrite-range '(999 . 1000)
:rewrite-lines (list complete-tail)
:append-lines nil)))))
(let ((prefix (ebox--render-stack-window-lines stack 1000)))
(cl-letf (((symbol-function 'length)
(lambda (sequence)
(when (and record-scans (eq sequence leaf-lines))
(cl-incf full-length-count))
(funcall original-length sequence)))
((symbol-function 'nthcdr)
(lambda (count sequence)
(when (and record-scans (eq sequence leaf-lines))
(cl-incf full-nthcdr-count))
(funcall original-nthcdr count sequence)))
((symbol-function 'seq-subseq)
(lambda (sequence start &optional end)
(when (and record-scans (eq sequence leaf-lines))
(cl-incf full-subseq-count))
(funcall original-seq-subseq sequence start end))))
(setq record-scans t)
(let ((completed
(ebox--render-stack-window-lines
stack 1001 (plist-get prefix :cache))))
(setq record-scans nil)
(should (plist-get completed :complete))
(should (eq (car (plist-get completed :line-tail))
complete-tail)))))
(should (= full-length-count 0))
(should (= full-nthcdr-count 0))
(should (= full-subseq-count 0)))
(remhash type ebox--window-line-renderer-table))))
(ert-deftest ebox-scroll-prefix-renderer-failure-keeps-published-pair-atomic ()
"A failed rendered delta should preserve both published prefix lists."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1
:wrap-mode 'none))
(source-lines (list "line-0" "line-1" "line-2"))
(source-tail (last source-lines))
(calls 0)
cache-arguments
fail-render
(original-renderer
(symbol-function 'ebox--scroll-rendered-content-lines))
(ebox--render-cache-table (make-hash-table :test 'equal))
(renderer
(ebox--scroll-window-prefix-renderer
(ebox-test-root box) :test-source 20 4)))
(cl-letf
(((symbol-function 'ebox--render-scroll-window-source)
(lambda (_source _limit &optional cache)
(push cache cache-arguments)
(cl-incf calls)
(cond
((= calls 1)
(list :lines source-lines
:line-count 3
:line-tail source-tail
:cache :first
:complete nil
:content-width-exact-p t
:rendered-lines source-lines
:prefix-delta
'(:exact-p nil :base-count 0
:rewrite-range nil :rewrite-lines nil
:append-lines nil)))
((= calls 2)
(let ((cell (list "line-3")))
(setcdr source-tail cell)
(setq source-tail cell))
(list :lines source-lines
:line-count 4
:line-tail source-tail
:cache :second
:complete nil
:content-width-exact-p t
:rendered-lines source-lines
:prefix-delta
'(:exact-p t :base-count 3
:rewrite-range nil :rewrite-lines nil
:append-lines ("line-3"))))
(t
(let ((fresh (list "line-0" "line-1" "line-2" "line-3")))
(list :lines fresh
:line-count 4
:line-tail (last fresh)
:cache :recovered
:complete nil
:content-width-exact-p t
:rendered-lines fresh
:prefix-delta
'(:exact-p nil :base-count 0
:rewrite-range nil :rewrite-lines nil
:append-lines nil)))))))
((symbol-function 'ebox--scroll-window-formatted-lines)
(lambda (_box window) (plist-get window :lines)))
((symbol-function 'ebox--scroll-window-source-lines-exact-p)
(lambda (&rest _) t))
((symbol-function 'ebox--scroll-rendered-content-lines)
(lambda (&rest args)
(if (and fail-render (= (nth 3 args) 3))
(error "injected rendered-prefix failure")
(apply original-renderer args)))))
(let* ((prefix (funcall renderer nil 1 3))
(content-lines (plist-get prefix :content-lines))
(rendered-lines (plist-get prefix :rendered-content-lines)))
(puthash :poison t ebox--render-cache-table)
(setq fail-render t)
(should-error (funcall renderer prefix 1 4))
(should (= (length content-lines) 3))
(should (= (length rendered-lines) 3))
(should-not (cdr (last content-lines)))
(should-not (cdr (last rendered-lines)))
(should (= (hash-table-count ebox--render-cache-table) 0))
(setq fail-render nil)
(let ((recovered (funcall renderer prefix 1 4)))
(should (= (length (plist-get recovered :content-lines)) 4))
(should (= (length (plist-get recovered
:rendered-content-lines))
4))))
(setq cache-arguments (nreverse cache-arguments))
(should (equal cache-arguments '(nil :first nil))))))
(ert-deftest ebox-stack-window-leaf-lines-uses-registered-producer ()
"Lazy stack scrolling should use generic window-line producers."
(ebox-test--reset-runtime-state)
(let ((node (list :ebox-type 'test-window-producer))
(call-count 0)
result)
(unwind-protect
(progn
(ebox--register-window-line-renderer
'test-window-producer
(lambda (_node limit)
(cl-incf call-count)
(list :lines (seq-take '("aa" "bb" "cc") limit)
:complete nil)))
(setq result
(ebox--render-stack-window-leaf-lines node 2 0))
(should (= call-count 1))
(should-not (plist-get result :complete))
(should (equal '("aa" "bb")
(mapcar #'substring-no-properties
(plist-get result :lines)))))
(remhash 'test-window-producer
ebox--window-line-renderer-table))))
(ert-deftest ebox-scroll-prefix-yielding-prewarm-converges-after-zero-line-slice ()
"A prewarm-only prefix slice should yield once, then publish exact content."
(ebox-test--reset-runtime-state)
(let* ((node (list :ebox-type 'test-yielding-prewarm))
(box (ebox-test-box (ebox-test-text "") :width 20 :height 1
:wrap-mode 'none :overflow 'scroll))
(renderer
(ebox--scroll-window-prefix-renderer
(ebox-test-root box) (list :stack node) 20 1))
(prewarm-count 0)
(render-count 0))
(unwind-protect
(progn
(ebox--register-window-line-prewarmer
'test-yielding-prewarm
(lambda (_node _target-width)
(cl-incf prewarm-count)
(= prewarm-count 1)))
(ebox--register-window-line-renderer
'test-yielding-prewarm
(lambda (_node _limit)
(cl-incf render-count)
(list :lines '("ready") :line-count 1
:line-tail '("ready") :complete t)))
(let* ((ebox--scroll-window-yielding-prewarm-p t)
(ebox--render-cache-table (make-hash-table :test 'equal))
(first (funcall renderer nil "yield-region" 1))
(second (funcall renderer first "yield-region" 1))
(complete (funcall renderer second "yield-region" 2)))
(should (= prewarm-count 2))
(should (= render-count 1))
(should-not (plist-get first :content-lines))
(should-not (plist-get first :complete))
(should
(equal '("ready")
(mapcar #'substring-no-properties
(plist-get second :content-lines))))
(should-not (plist-get second :complete))
(should (plist-get complete :complete))
(should
(equal '("ready")
(mapcar #'substring-no-properties
(plist-get complete :content-lines))))))
(remhash 'test-yielding-prewarm
ebox--window-line-prewarmer-table)
(remhash 'test-yielding-prewarm
ebox--window-line-renderer-table))))
(ert-deftest ebox-lazy-scroll-incremental-render-covers-wheel-drain ()
"Incremental lazy scroll renders should cover one smooth wheel drain."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id
(format "budget-row-%02d" idx)
(ebox-test-text (format "budget row %02d" idx))
:height 1))))))
(limits nil)
(original-render-source
(symbol-function 'ebox--render-scroll-window-source)))
(let ((ebox-viewport-width 240)
(ebox-viewport-height 6)
(ebox--defer-scroll-content-index t)
(ebox-wheel-smooth-scroll t)
(ebox-wheel-scroll-step 16)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-scroll-lazy-prefix-lookahead-lines 8))
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (source limit &optional cache)
(push limit limits)
(funcall original-render-source source limit cache))))
(ebox-render layout)))
(should (equal (nreverse limits) '(23)))))
(ert-deftest ebox-lazy-scroll-small-step-extends-prefix-only ()
"Small lazy scroll steps should not materialize the whole source."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id
(hidden-render-count 0)
(original-render-box (symbol-function 'ebox--render-box)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (equal
(ebox-tree-node-id
(ebox-test-source-index layout) box)
"row-30")
(cl-incf hidden-render-count))
(funcall original-render-box box))))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(should (= hidden-render-count 0))
(with-current-buffer buffer
(should (ebox--scroll-region-by root-id 1))
(should (= hidden-render-count 0))
(let ((state (ebox--scroll-get-state root-id)))
(should (= (plist-get state :scroll-offset) 1))
(should (< (length (plist-get state :content-lines)) 40))
(should (plist-get state :materialize-content-lines)))
(should (string-match-p "row 01"
(buffer-substring-no-properties
(point-min) (point-max))))
(should-not (string-match-p "row 30"
(buffer-substring-no-properties
(point-min) (point-max)))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-large-cache-miss-schedules-prefetch ()
"Large lazy scroll misses should schedule prefetch without inline render."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id
(format "cache-row-%02d" idx)
(ebox-test-text (format "cache row %02d" idx))
:height 1))))))
(buffer nil)
root-id
(prefix-count 0)
(materialize-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(materialize (plist-get state :materialize-content-lines)))
(should (= (length (plist-get state :content-lines)) 3))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--scroll-region-by root-id 30))
(should (= prefix-count 0))
(should (= materialize-count 0))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(should (< (length (plist-get (ebox--scroll-get-state root-id)
:content-lines))
40))
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
(should-not (string-match-p "cache row 30"
(buffer-substring-no-properties
(point-min) (point-max))))))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-smooth-scroll-cache-miss-renders-bounded-prefix ()
"Smooth scroll cache misses should keep moving with bounded prefix render."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id
(format "smooth-cache-row-%02d"
idx)
(ebox-test-text (format "smooth cache row %02d"
idx))
:height 1))))))
(buffer nil)
root-id
(prefix-count 0)
(materialize-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 0)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-interval 60.0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(materialize (plist-get state :materialize-content-lines)))
(should (= (length (plist-get state :content-lines)) 3))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16)))
(should (= prefix-count 1))
(should (= materialize-count 0))
(should (> (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should (> (or (plist-get entry :pending) 0) 0))
(should-not (plist-get entry :waiting-prefetch))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-smooth-scroll-cache-miss-continues-with-bounded-prefix ()
"Smooth scroll cache misses should extend a bounded prefix without stalling."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id
(format
"smooth-inline-row-%02d" idx)
(ebox-test-text (format
"smooth inline row %02d" idx))
:height 1))))))
(buffer nil)
root-id
(prefix-count 0)
(materialize-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 0)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-interval 60.0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(materialize (plist-get state :materialize-content-lines)))
(should (= (length (plist-get state :content-lines)) 3))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16))
(ebox--smooth-scroll-tick root-id))
(should (> prefix-count 0))
(should (= materialize-count 0))
(should (> (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should-not (plist-get entry :waiting-prefetch))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-smooth-scroll-large-pending-uses-base-tick-at-prefix-boundary ()
"Large smooth pending input should not render a large lazy prefix at once."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 200 collect
(ebox-test-box :id
(format "smooth-large-row-%03d"
idx)
(ebox-test-text (format "smooth large row %03d"
idx))
:height 1))))))
(buffer nil)
root-id
(prefix-count 0)
requested-lines)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 0)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-target-ticks 8)
(ebox-wheel-smooth-scroll-interval 60.0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(should (= (length (plist-get state :content-lines)) 3))
(plist-put
state :render-content-prefix
(lambda (state region-id required-lines)
(cl-incf prefix-count)
(setq requested-lines required-lines)
(funcall render-prefix state region-id required-lines)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 1000)))
(should (= prefix-count 1))
(should (<= requested-lines 8))
(should (<= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
ebox-wheel-smooth-scroll-lines-per-tick))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should-not (plist-get entry :waiting-prefetch))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-smooth-scroll-cache-miss-defers-prefetch-while-active ()
"Idle prefetch should not compete with an active smooth scroll timer."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id
(format "smooth-defer-row-%02d"
idx)
(ebox-test-text (format "smooth defer row %02d"
idx))
:height 1))))))
(buffer nil)
root-id
(prefix-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 0)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-interval 60.0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16)))
(should (gethash root-id ebox--smooth-scroll-state-table))
(let ((prefix-after-start prefix-count))
(should (> prefix-after-start 0))
(ebox--scroll-idle-prefetch root-id)
(should (= prefix-count prefix-after-start))
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-idle-prefetch root-id)
(should (> prefix-count prefix-after-start))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-smooth-scroll-cache-miss-never-enters-prefetch-wait ()
"Smooth scroll should not pause at a lazy prefix boundary."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id
(format
"smooth-resume-row-%02d" idx)
(ebox-test-text (format
"smooth resume row %02d" idx))
:height 1))))))
(buffer nil)
root-id
(prefix-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 0)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-interval 60.0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16)))
(should (> prefix-count 0))
(let ((offset-before (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)))
(ebox--smooth-scroll-tick root-id)
(should (> (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
offset-before)))
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
(should entry)
(should-not (plist-get entry :waiting-prefetch))
(should (timerp (plist-get entry :timer)))))
(ebox--smooth-scroll-stop root-id)
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-repeated-small-steps-keep-tp-mounts ()
"Repeated lazy scroll steps should keep TP mounts valid."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id
(hidden-render-count 0)
(materialize-count 0)
(original-render-box (symbol-function 'ebox--render-box)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (equal
(ebox-tree-node-id
(ebox-test-source-index layout) box)
"row-30")
(cl-incf hidden-render-count))
(funcall original-render-box box))))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(materialize (plist-get state :materialize-content-lines)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(cl-loop repeat 8
do (should (ebox--scroll-region-by root-id 1)))
(should (= hidden-render-count 0))
(should (= materialize-count 0))
(let ((state (ebox--scroll-get-state root-id)))
(should (= (plist-get state :scroll-offset) 8))
(should (< (length (plist-get state :content-lines)) 40))
(should (plist-get state :materialize-content-lines)))
(should (ebox-surface-region-mounts buffer root-id))
(should (string-match-p "row 08"
(buffer-substring-no-properties
(point-min) (point-max))))
(should-not (string-match-p "row 30"
(buffer-substring-no-properties
(point-min) (point-max)))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-repeated-small-steps-prefetch-prefix ()
"Repeated lazy scroll steps should reuse a prefetched lazy prefix."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id
(prefix-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(cl-loop repeat 8
do (should (ebox--scroll-region-by root-id 1)))
(should (= prefix-count 0))
(let ((state (ebox--scroll-get-state root-id)))
(should (= (plist-get state :scroll-offset) 8))
(should (plist-get state :render-content-prefix))
(should (<= (+ (plist-get state :scroll-offset)
(plist-get state :content-height)
1)
(length (plist-get state :content-lines)))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-idle-prefetch-extends-prefix-cache ()
"Idle prefetch should fill lazy scroll lines before wheel ticks need them."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 80 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id
(prefix-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-idle-prefetch-lines 16))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(before-lines (length (plist-get state :content-lines)))
(render-prefix (plist-get state :render-content-prefix)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state)
(ebox--scroll-idle-prefetch root-id)
(let ((prefetched (ebox--scroll-get-state root-id)))
(should (> (length (plist-get prefetched :content-lines))
before-lines))
(should (< (length (plist-get prefetched :content-lines))
80))
(should (= prefix-count 1))))
(with-current-buffer buffer
(cl-loop repeat 8
do (should (ebox--scroll-region-by root-id 1))))
(should (= prefix-count 1)))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-prefix-reuses-display-measurement-cache ()
"Lazy prefix slices should retain the initial render's display cache."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 80 collect
(ebox-test-box (ebox-test-text (format "repeated row %02d" idx))
:height 1))))))
(buffer nil)
root-id
caches)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(original
(symbol-function 'ebox--render-scroll-window-source)))
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (&rest arguments)
(push ebox--render-string-pixel-width-cache caches)
(apply original arguments))))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prefix-cache*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(before (length (plist-get state :content-lines))))
(ebox--scroll-state-ensure-prefix-lines
root-id state (1+ before) t)))
(should (>= (length caches) 2))
(should (car caches))
(should (cl-every (lambda (cache) (eq cache (car caches)))
caches)))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-canonical-column-viewport-height-measures-text-as-text ()
"A typed Text leaf must not enter legacy Box geometry measurement."
(let ((ebox-viewport-width 640)
(ebox-viewport-height 24))
(should
(string-match-p
"Viewport"
(substring-no-properties
(ebox-render
(ebox-build
'(column :width (viewport) :height (viewport-height)
"Viewport"))))))))
(ert-deftest ebox-lazy-scroll-idle-prefetch-covers-visible-window ()
"Idle prefetch should warm beyond the current visible scroll window."
(ebox-test--reset-runtime-state)
(let ((region-id "scroll")
requested-lines)
(puthash
region-id
(list :scroll-offset 0
:content-height 10
:content-lines (make-list 3 "")
:render-content-prefix
(lambda (_state _region-id target-lines)
(setq requested-lines target-lines)
(list :content-lines (make-list target-lines "")
:content-height 10
:complete nil)))
ebox--scroll-global-state)
(let ((ebox-scroll-lazy-idle-prefetch-lines 5)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(ebox--scroll-idle-prefetch region-id))
(should requested-lines)
(should (>= requested-lines 15))))
(ert-deftest ebox-lazy-scroll-idle-prefetch-is-sliced ()
"Idle prefetch should extend lazy prefixes in bounded foreground slices."
(ebox-test--reset-runtime-state)
(let ((region-id "scroll")
requested-lines)
(puthash
region-id
(list :scroll-offset 0
:content-height 10
:content-lines (make-list 3 "")
:render-content-prefix
(lambda (_state _region-id target-lines)
(setq requested-lines target-lines)
(list :content-lines (make-list target-lines "")
:content-height 10
:complete nil)))
ebox--scroll-global-state)
(let ((ebox-scroll-lazy-idle-prefetch-lines 64)
(ebox-scroll-lazy-idle-prefetch-slice-lines 4)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox--scroll-allow-noninteractive-prefetch t))
(ebox--scroll-idle-prefetch region-id))
;; Three cached lines plus the configured four-line slice. Completion is
;; probed on a later turn instead of exceeding this callback's budget.
(should (= requested-lines 7))
(should (gethash region-id ebox--scroll-idle-prefetch-timers))
(ebox--scroll-cancel-idle-prefetch region-id)))
(ert-deftest ebox-lazy-scroll-idle-prefetch-bounds-large-runtime-work ()
"Large runtimes should request only one new line per idle render turn."
(ebox-test--reset-runtime-state)
(let ((region-id "scroll")
(buffer (generate-new-buffer " *ebox-large-prefetch*"))
(node-table (make-hash-table :test 'equal))
(runtime-type-count-table (make-hash-table :test 'eq))
requested-lines)
(unwind-protect
(progn
(dotimes (index 512)
(puthash index t node-table))
(puthash buffer
(list :node-table node-table
:runtime-type-count-table runtime-type-count-table)
ebox--buffer-render-state-table)
(puthash
region-id
(list :buffer buffer
:scroll-offset 0
:content-height 10
:content-lines (make-list 3 "")
:render-content-prefix
(lambda (_state _region-id target-lines)
(setq requested-lines target-lines)
(list :content-lines (make-list target-lines "")
:content-height 10
:complete nil)))
ebox--scroll-global-state)
(let ((ebox-scroll-lazy-idle-prefetch-lines 64)
(ebox-scroll-lazy-idle-prefetch-slice-lines 16)
(ebox-scroll-lazy-prefix-lookahead-lines 13)
(ebox--scroll-allow-noninteractive-prefetch t))
(ebox--scroll-idle-prefetch region-id))
;; Three cached lines + one atomic requested line. The completion
;; probe must not silently double a latency-sensitive slice.
(should (= requested-lines 4)))
(ebox--scroll-cancel-idle-prefetch region-id)
(remhash buffer ebox--buffer-render-state-table)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-idle-prefetch-defers-visible-render-gc ()
"Visible idle prefix slices should not combine atomic rendering with GC."
(ebox-test--reset-runtime-state)
(let* ((region-id "visible-prefix-gc")
(buffer (generate-new-buffer " *ebox-visible-prefix-gc*"))
(state
(list :buffer buffer
:scroll-offset 0
:content-height 1
:content-lines '("row 0")
:render-content-prefix
(lambda (input-state _region-id target-lines &optional _exact)
(setq input-state
(plist-put input-state :content-lines
(make-list target-lines "row")))
(plist-put input-state :content-lines-complete-p t))))
(gc-enters 0)
(gc-restores 0))
(unwind-protect
(let ((ebox-scroll-lazy-idle-prefetch-lines 1)
(ebox-scroll-lazy-idle-prefetch-slice-lines 1)
(ebox--scroll-allow-noninteractive-prefetch t))
(puthash region-id state ebox--scroll-global-state)
(cl-letf (((symbol-function 'get-buffer-window-list)
(lambda (&rest _) (list (selected-window))))
((symbol-function 'display-graphic-p)
(lambda (&rest _) t))
((symbol-function 'ebox--deferred-render-gc-enter)
(lambda () (cl-incf gc-enters)))
((symbol-function
'ebox--deferred-render-gc-schedule-restore)
(lambda () (cl-incf gc-restores)))
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
(lambda (&rest _) nil)))
(ebox--scroll-idle-prefetch region-id))
(should (= gc-enters 1))
(should (= gc-restores 1)))
(ebox--scroll-cancel-idle-prefetch region-id)
(remhash region-id ebox--scroll-global-state)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-prefetch-yields-to-input-between-slices ()
"Background prefix work should use idle time and rearm beyond current idle."
(ebox-test--reset-runtime-state)
(let ((region-id "scroll")
scheduled-delay
scheduled-function)
(puthash
region-id
(list :content-height 2
:content-lines '("row 0")
:render-content-prefix
(lambda (_state _region-id target-lines)
(list :content-lines (make-list target-lines "row")
:content-height 2
:complete nil)))
ebox--scroll-global-state)
(let ((ebox-scroll-lazy-idle-prefetch-lines 8)
(ebox-scroll-lazy-idle-prefetch-slice-lines 1)
(ebox-scroll-lazy-idle-prefetch-delay 0.15)
(ebox--scroll-allow-noninteractive-prefetch t))
(cl-letf (((symbol-function 'current-idle-time)
(lambda () (seconds-to-time 0.4)))
((symbol-function 'run-at-time)
(lambda (&rest _)
(ert-fail "Background scroll work must yield to input")))
((symbol-function 'run-with-idle-timer)
(lambda (delay _repeat function &rest _arguments)
(setq scheduled-delay delay
scheduled-function function)
'scheduled-scroll-timer)))
(ebox--scroll-idle-prefetch region-id)))
(should (< (abs (- scheduled-delay 0.55)) 0.000001))
(should (eq scheduled-function #'ebox--scroll-idle-prefetch))
(should (eq (gethash region-id ebox--scroll-idle-prefetch-timers)
'scheduled-scroll-timer))
(remhash region-id ebox--scroll-idle-prefetch-timers)))
(ert-deftest ebox-lazy-scroll-idle-prefetch-rearms-after-scroll ()
"Successful lazy scroll steps should schedule the next generic prefetch."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 80 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(ebox--scroll-cancel-idle-prefetch root-id)
(with-current-buffer buffer
(should (ebox--scroll-region-by root-id 1)))
(should (gethash root-id ebox--scroll-idle-prefetch-timers)))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-idle-prefetch-pauses-during-smooth-scroll ()
"Smooth scroll should prefetch only after the active animation stops."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 80 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-idle-prefetch-delay 999)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-target-ticks 4)
(ebox-wheel-smooth-scroll-interval 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16)))
(should (gethash root-id ebox--smooth-scroll-state-table))
(should-not (gethash root-id ebox--scroll-idle-prefetch-timers))
(ebox--smooth-scroll-stop root-id)
(should (gethash root-id ebox--scroll-idle-prefetch-timers)))
(ebox--scroll-cancel-idle-prefetch root-id)
(ebox--smooth-scroll-stop root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-idle-prefetch-pauses-for-resize-animation ()
"A resize animation should own idle time until its final frame settles."
(ebox-test--reset-runtime-state)
(let* ((buffer (generate-new-buffer " *ebox-resize-prefetch*"))
(region-id "scroll")
scheduled)
(unwind-protect
(progn
(puthash
region-id
(list :buffer buffer
:content-height 2
:content-lines '("row 0")
:render-content-prefix #'ignore
:content-lines-complete-p nil)
ebox--scroll-global-state)
(cl-letf (((symbol-function 'run-with-idle-timer)
(lambda (&rest _)
(setq scheduled 'scroll-timer))))
(ebox--scroll-inhibit-idle-prefetch-for-buffer buffer)
(ebox--scroll-schedule-idle-prefetch region-id 0)
(should-not scheduled)
(ebox--scroll-resume-idle-prefetch-for-buffer buffer)
(should (eq scheduled 'scroll-timer))))
(remhash region-id ebox--scroll-global-state)
(remhash buffer ebox--scroll-idle-prefetch-inhibited-buffers)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(defun ebox-test--complete-lazy-scroll-batch-layout ()
"Return a complete five-row lazy scroll cache fixture."
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(ebox-test-column :width '(viewport)
(ebox-test-box (ebox-test-text "row 00")
:height 1)
(ebox-test-box :id "target" (ebox-test-text "target row") :height 1
:color "#111111")
(ebox-test-box :id "second-target"
(ebox-test-text "second target row")
:height 1 :color
"#111111")
(ebox-test-box (ebox-test-text "row 03")
:height 1)
(ebox-test-box (ebox-test-text "row 04")
:height 1))))
(defun ebox-test--text-has-foreground-p (string text color)
"Return non-nil when TEXT in STRING has foreground COLOR."
(let ((pos (string-match (regexp-quote text)
(substring-no-properties string))))
(and pos
(ebox-test--face-has-key-value-p
(get-text-property pos 'face string)
:foreground color))))
(defun ebox-test--lines-have-text-foreground-p (lines text color)
"Return non-nil when one of LINES paints TEXT with foreground COLOR."
(cl-some (lambda (line)
(ebox-test--text-has-foreground-p line text color))
lines))
(defun ebox-test--lines-have-text-p (lines text)
"Return non-nil when one of LINES contains TEXT."
(and (cl-some (lambda (line)
(string-match-p (regexp-quote text)
(substring-no-properties line)))
lines)
t))
(ert-deftest ebox-lazy-scroll-prefix-extension-keeps-runtime-updates ()
"Extending a lazy prefix should not replay producer caches from before an update."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 12 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-wheel-scroll-step 0)
(ebox-wheel-smooth-scroll nil))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(root-id (ebox-test--selector-region-id buffer "#root"))
(target-id (ebox-test--selector-region-id buffer "#row-01"))
(state (ebox-scroll-state root-id)))
(should-not (plist-get state :content-lines-complete-p))
(ebox-test--region-update target-id :content "updated row 01")
(setq state (ebox-scroll-state root-id))
(should (ebox-test--lines-have-text-p
(plist-get state :content-lines) "updated row 01"))
(setq state
(ebox--scroll-state-ensure-prefix-lines root-id state 8))
(should (ebox-test--lines-have-text-p
(plist-get state :content-lines) "updated row 01"))))))
(ert-deftest ebox-lazy-scroll-prefix-does-not-publish-unflushed-batch ()
"Extending a lazy prefix should preserve committed content until batch flush."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 12 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-wheel-scroll-step 0)
(ebox-wheel-smooth-scroll nil))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(root-id (ebox-test--selector-region-id buffer "#root"))
(target-id (ebox-test--selector-region-id buffer "#row-01"))
(initial-revision
(plist-get (ebox--buffer-render-state buffer)
:runtime-revision)))
(ebox-test--set-buffer-update-report
buffer '(:strategy span-patch))
(ebox-incremental-begin-batch buffer)
(ebox-test--region-update target-id :content "unflushed row 01")
(should (= (plist-get (ebox--buffer-render-state buffer)
:runtime-revision)
initial-revision))
(let ((state
(ebox--scroll-state-ensure-prefix-lines
root-id (ebox-scroll-state root-id) 8)))
(should (ebox-test--lines-have-text-p
(plist-get state :content-lines) "row 01"))
(should-not (ebox-test--lines-have-text-p
(plist-get state :content-lines)
"unflushed row 01")))
(should (= 2 (ebox--scroll-region-by root-id 2)))
(should (= (plist-get (ebox--buffer-render-state buffer)
:runtime-revision)
(+ initial-revision 1)))
(should (= -2 (ebox--scroll-region-by root-id -2)))
(should (= (plist-get (ebox--buffer-render-state buffer)
:runtime-revision)
(+ initial-revision 2)))
(should (string-match-p "row 01"
(substring-no-properties (buffer-string))))
(should-not (string-match-p
"unflushed row 01"
(substring-no-properties (buffer-string))))
(ebox-incremental-flush buffer)
(should (= (plist-get (ebox--buffer-render-state buffer)
:runtime-revision)
(+ initial-revision 3)))
(should (ebox-test--lines-have-text-p
(plist-get (ebox-scroll-state root-id) :content-lines)
"unflushed row 01"))
(should (string-match-p
"unflushed row 01"
(substring-no-properties (buffer-string))))))))
(ert-deftest ebox-explicit-batch-synchronizes-complete-scroll-paint-cache ()
"A paint-only batch should not replay stale complete scroll cache faces."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--complete-lazy-scroll-batch-layout))
(ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-wheel-scroll-step 16)
(ebox-scroll-lazy-idle-prefetch-lines 0))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(root-id (ebox-test--selector-region-id buffer "#root"))
(target-id (ebox-test--selector-region-id buffer "#target")))
(should (plist-get (ebox-scroll-state root-id)
:content-lines-complete-p))
(ebox-incremental-begin-batch buffer)
(ebox-test--region-update target-id :color "#DC2626")
(ebox-incremental-flush buffer)
(let* ((runtime (gethash target-id ebox--region-box-table))
(visible-before
(ebox-test--text-has-foreground-p
(buffer-string) "target row" "#DC2626"))
(state (ebox-scroll-state root-id))
(content-cache
(ebox-test--lines-have-text-foreground-p
(plist-get state :content-lines)
"target row" "#DC2626"))
(rendered-cache
(ebox-test--lines-have-text-foreground-p
(plist-get state :rendered-content-lines)
"target row" "#DC2626")))
(should (= 2 (ebox--scroll-region-by root-id 2)))
(should (= -2 (ebox--scroll-region-by root-id -2)))
(should
(equal
(list (ebox-get runtime :color)
visible-before
content-cache
rendered-cache
(ebox-test--text-has-foreground-p
(buffer-string) "target row" "#DC2626"))
'("#DC2626" t t t t))))))))
(ert-deftest ebox-explicit-batch-synchronizes-distinct-scroll-cache-nodes ()
"A batch flush should update every affected cached scroll node."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--complete-lazy-scroll-batch-layout))
(ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-wheel-scroll-step 16)
(ebox-scroll-lazy-idle-prefetch-lines 0))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(root-id (ebox-test--selector-region-id buffer "#root"))
(target-id (ebox-test--selector-region-id buffer "#target"))
(second-id
(ebox-test--selector-region-id buffer "#second-target")))
(should (plist-get (ebox-scroll-state root-id)
:content-lines-complete-p))
(ebox-incremental-begin-batch buffer)
(with-temp-buffer
(ebox-test--region-update target-id :color "#DC2626")
(ebox-test--region-update second-id :color "#2563EB")
(ebox-incremental-flush buffer))
(let* ((state (ebox-scroll-state root-id))
(content-lines (plist-get state :content-lines))
(rendered-lines (plist-get state :rendered-content-lines)))
(should (ebox-test--lines-have-text-foreground-p
content-lines "target row" "#DC2626"))
(should (ebox-test--lines-have-text-foreground-p
rendered-lines "target row" "#DC2626"))
(should (ebox-test--lines-have-text-foreground-p
content-lines "second target row" "#2563EB"))
(should (ebox-test--lines-have-text-foreground-p
rendered-lines "second target row" "#2563EB")))))))
(ert-deftest ebox-explicit-batch-flush-retries-after-quit ()
"A quit during a batch flush must keep the recorded changes retryable."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Batch retry") :color "black"))
(id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((buffer (current-buffer)))
(ebox-incremental-begin-batch buffer)
(ebox-test--region-update id :color "red")
(should (= (ebox-incremental-pending-count buffer) 1))
(cl-letf (((symbol-function 'ebox--publish-surface-region-batch)
(lambda (&rest _) (signal 'quit nil))))
(condition-case nil
(ebox-incremental-flush buffer)
(quit nil)))
;; The interrupted flush published nothing, so the batch must
;; still be active with the recorded change pending.
(should (ebox-incremental-batching-p buffer))
(should (= (ebox-incremental-pending-count buffer) 1))
;; The retry must execute the preserved change instead of no-op.
(let ((report (ebox-incremental-flush buffer)))
(should report)
(should-not (eq (plist-get report :strategy) 'no-op)))
(should-not (ebox-incremental-batching-p buffer))
(should (= (ebox-incremental-pending-count buffer) 0)))
(goto-char (point-min))
(search-forward "Batch retry")
(let ((face (get-text-property (- (point) (length "Batch retry"))
'face)))
(should (ebox-test--face-has-key-value-p
face :foreground "red")))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-lazy-scroll-prefix-reuses-signature-cache ()
"Lazy prefix extension should not recompute node signatures each slice."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll))
(source-node
(ebox-test-column :width '(viewport)
(apply #'ebox-test-flex
(append
(list :id "signature-flex" :width '(viewport) :flex-flow
'(row wrap) :row-gap 1 :column-gap '(8) :padding
'(0 (12)) :border-right "#D97757" :border-bottom
"#D97757" :border-left "#D97757")
(cl-loop for idx below 24 collect
(ebox-test-box (ebox-test-text (format "signature %02d" idx))
:width '(100) :height 2))))))
(combined-input (ebox-test-forest-input layout source-node))
(ebox--render-source-index
(ebox-test-source-index combined-input))
(source (list :stack (ebox-test-root source-node)))
(renderer (ebox--scroll-window-prefix-renderer
(ebox-test-root layout) source 240 2 nil nil))
signature-caches
(original-node-signature
(symbol-function 'ebox--render-cache-node-signature)))
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--render-cache-signature-cache nil))
(cl-letf (((symbol-function 'ebox--render-cache-node-signature)
(lambda (node)
(when (and (listp node)
ebox--render-cache-signature-cache)
(push ebox--render-cache-signature-cache
signature-caches))
(funcall original-node-signature node))))
(funcall renderer (list :rendered-content-lines nil)
"signature-region" 4)
(funcall renderer (list :rendered-content-lines nil)
"signature-region" 8))
(should (> (length signature-caches) 1))
(let ((first-cache (car signature-caches)))
(dolist (cache (cdr signature-caches))
(should (eq cache first-cache)))))))
(ert-deftest ebox-lazy-scroll-first-smooth-wheel-uses-prefetched-prefix ()
"The first smooth wheel input should only replace visible lines."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id
(prefix-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 16)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-interval 60.0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(should (<= (+ (plist-get state :content-height)
ebox-wheel-scroll-step)
(length (plist-get state :content-lines))))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16))
(while (gethash root-id ebox--smooth-scroll-state-table)
(ebox--smooth-scroll-tick root-id))
(should (string-match-p "row 16"
(buffer-substring-no-properties
(point-min) (point-max))))))
(should (= prefix-count 0))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
16)))
(ebox--smooth-scroll-stop root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))
(ert-deftest ebox-lazy-scroll-incremental-smooth-wheel-uses-prefetched-prefix ()
"Post-resize lazy scroll should not extend prefixes inside smooth ticks."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id
(format "resize-row-%02d" idx)
(ebox-test-text (format "resize row %02d" idx))
:height 1))))))
(buffer nil)
root-id
(prefix-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 6)
(ebox--defer-scroll-content-index t)
(ebox-wheel-smooth-scroll t)
(ebox-wheel-scroll-step 16)
(ebox-wheel-smooth-scroll-lines-per-tick 4)
(ebox-wheel-smooth-scroll-interval 60.0)
(ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox--scroll-allow-noninteractive-prefetch nil)
(ebox-scroll-lazy-prefix-lookahead-lines 8))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(render-prefix (plist-get state :render-content-prefix)))
(plist-put
state :render-content-prefix
(lambda (&rest args)
(cl-incf prefix-count)
(apply render-prefix args)))
(puthash root-id state ebox--scroll-global-state))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 16))
(while (gethash root-id ebox--smooth-scroll-state-table)
(ebox--smooth-scroll-tick root-id))
(should (string-match-p "resize row 16"
(buffer-substring-no-properties
(point-min) (point-max))))))
(should (= prefix-count 0))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
16)))
(ebox--smooth-scroll-stop root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))
(ert-deftest ebox-viewport-rerender-schedules-urgent-scroll-prefetch ()
"Viewport rerender should warm lazy scroll prefixes before fast scrolling."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 80 collect
(ebox-test-box :id
(format "urgent-row-%02d" idx)
(ebox-test-text (format "urgent row %02d" idx))
:height 1))))))
(buffer nil)
root-id
delays
(original-schedule
(symbol-function 'ebox--scroll-schedule-idle-prefetch)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 4)
(ebox-scroll-lazy-idle-prefetch-delay 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(ebox--scroll-cancel-idle-prefetch root-id)
(cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (region-id &optional delay)
(push delay delays)
(funcall original-schedule region-id delay))))
(ebox-rerender-buffer-with-context buffer 300 4))
(should (member 0 delays)))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-smooth-scroll-defers-gc-during-animation ()
"Smooth scrolling should keep render GC settings until the animation stops."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box (ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id
(old-threshold gc-cons-threshold)
(old-percentage gc-cons-percentage)
(target-threshold 2000))
(unwind-protect
(let ((gc-cons-threshold 1000)
(ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-render-gc-cons-threshold target-threshold)
(ebox-deferred-render-gc-cons-threshold target-threshold)
(ebox-render-gc-cons-percentage 0.9)
(ebox-wheel-smooth-scroll-lines-per-tick 1)
(ebox-wheel-smooth-scroll-interval 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(with-current-buffer buffer
(should (ebox--smooth-scroll-region-by root-id 4)))
(should (>= gc-cons-threshold target-threshold))
(should (>= gc-cons-percentage 0.9))
(should ebox--deferred-render-gc-state)
(ebox--smooth-scroll-stop root-id)
(should (timerp ebox--deferred-render-gc-timer)))
(ebox--smooth-scroll-stop root-id)
(when (timerp ebox--deferred-render-gc-timer)
(cancel-timer ebox--deferred-render-gc-timer))
(setq ebox--deferred-render-gc-timer nil)
(setq ebox--deferred-render-gc-state nil)
(setq gc-cons-threshold old-threshold)
(setq gc-cons-percentage old-percentage)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(defun ebox-test--visible-foreground (face)
"Return the highest-priority :foreground carried by FACE."
(cond
((null face) nil)
((and (listp face) (keywordp (car face)))
(plist-get face :foreground))
((listp face)
(cl-some #'ebox-test--visible-foreground face))
(t nil)))
(ert-deftest ebox-ancestor-paint-patch-preserves-descendant-own-paint ()
"A parent paint-patch must not clobber a child's own declared paint."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id 'parent :width '(120) :color "black"
(ebox-test-column :width '(120)
(ebox-test-box :id 'child (ebox-test-text "Child") :width '(120)
:color "blue")
(ebox-test-box :id 'plain (ebox-test-text "Plain") :width '(120))))))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(parent-id (ebox-test--selector-region-id buffer "#parent")))
(ebox-test--region-update parent-id :color "red")
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
'paint-patch))
(goto-char (point-min))
(search-forward "Child")
;; The child's own declaration stays the highest-priority paint.
(should (equal (ebox-test--visible-foreground
(get-text-property (match-beginning 0) 'face))
"blue"))
(goto-char (point-min))
(search-forward "Plain")
;; A non-declaring sibling inherits the new parent paint.
(should (equal (ebox-test--visible-foreground
(get-text-property (match-beginning 0) 'face))
"red"))))))
(ert-deftest ebox-intrinsic-measurement-preserves-scroll-state ()
"Intrinsic measurement must not reset live scroll offsets or state."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 20 collect
(ebox-test-box (ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 4))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(with-current-buffer buffer
(should (ebox--scroll-region-by root-id 5 48)))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
5))
(let ((box (gethash root-id ebox--region-box-table)))
(should box)
;; Flex ancestors measure live subtrees with the intrinsic
;; flag bound; the measurement render must be side-effect
;; free for the published runtime.
(let ((ebox--intrinsic-layout-measurement t))
(ebox--render-node
box
(plist-get (ebox--buffer-render-state buffer)
:source-index)))
(should (equal (ebox-get box :scroll-offset) 5)))
(let ((state (ebox--scroll-get-state root-id)))
(should state)
(should (= (plist-get state :scroll-offset) 5))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-rerender-retries-after-quit ()
"A quit during viewport reflow must not wedge later same-size reflows."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) (ebox-test-text "viewport reflow retry")))
(buffer nil))
(unwind-protect
(let ((ebox-viewport-width 240))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(cl-letf (((symbol-function 'tp-surface-update-scoped)
(lambda (&rest _) (signal 'quit nil))))
(condition-case nil
(ebox-rerender-buffer-with-context buffer 320)
(quit nil)))
;; The interrupted reflow published nothing, so the stored
;; dimensions must still match the published render.
(should (equal (plist-get (ebox--buffer-render-state buffer)
:viewport-width)
240))
;; The same-size retry must reflow instead of no-op.
(let ((report (ebox-rerender-buffer-with-context buffer 320)))
(should report)
(should-not (eq (plist-get report :strategy) 'no-op)))
(should (equal (plist-get (ebox--buffer-render-state buffer)
:viewport-width)
320)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-smooth-scroll-multi-event-restores-gc-depth ()
"Coalesced wheel events must not leak the deferred GC lease depth."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box (ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id
(old-threshold gc-cons-threshold)
(old-percentage gc-cons-percentage))
(unwind-protect
(let ((gc-cons-threshold 1000)
(ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-deferred-render-gc-cons-threshold 2000)
(ebox-render-gc-cons-percentage 0.9)
(ebox-wheel-smooth-scroll-lines-per-tick 1)
(ebox-wheel-smooth-scroll-interval 999))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(with-current-buffer buffer
;; One trackpad flick delivers many wheel events that
;; coalesce into a single running animation.
(dotimes (_ 5)
(should (ebox--smooth-scroll-region-by root-id 4))))
(should (= ebox--deferred-render-gc-depth 1))
(ebox--smooth-scroll-stop root-id)
(should (= ebox--deferred-render-gc-depth 0))
(should (timerp ebox--deferred-render-gc-timer)))
(when root-id
(ebox--smooth-scroll-stop root-id))
(when (timerp ebox--deferred-render-gc-timer)
(cancel-timer ebox--deferred-render-gc-timer))
(setq ebox--deferred-render-gc-timer nil)
(setq ebox--deferred-render-gc-state nil)
(setq ebox--deferred-render-gc-depth 0)
(setq gc-cons-threshold old-threshold)
(setq gc-cons-percentage old-percentage)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-smooth-scroll-clamps-without-full-materialize ()
"Smooth wheel clamp should extend lazy scroll prefixes without full render."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id
(hidden-render-count 0)
(original-render-box (symbol-function 'ebox--render-box)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-wheel-scroll-step 16))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (equal
(ebox-tree-node-id
(ebox-test-source-index layout) box)
"row-30")
(cl-incf hidden-render-count))
(funcall original-render-box box))))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let ((ebox-wheel-smooth-scroll-lines-per-tick 1)
(ebox-wheel-smooth-scroll-interval 60.0))
(should (ebox--smooth-scroll-region-by root-id 2))
(should (= hidden-render-count 0))
(let ((state (ebox--scroll-get-state root-id)))
(should (= (plist-get state :scroll-offset) 1))
(should (< (length (plist-get state :content-lines)) 40))
(should (plist-get state :materialize-content-lines))))))
(ebox--smooth-scroll-stop root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-hidden-child-update-does-not-materialize ()
"Hidden updates inside lazy scroll windows should stay deferred."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 32 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
(buffer nil)
root-id target-id next-target-id
(materialize-count 0)
(line-span-scans 0)
(membership-builds 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(setq target-id (ebox-test--selector-region-id buffer "#row-30"))
(setq next-target-id
(ebox-test--selector-region-id buffer "#row-31"))
(should target-id)
(should next-target-id)
(let ((state (ebox--scroll-get-state root-id)))
(should (plist-get state :materialize-content-lines))
(let ((materialize (plist-get state
:materialize-content-lines)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state)))
(let ((original-line-span-scan
(symbol-function 'ebox--scroll-state-region-line-spans))
(original-membership-build
(symbol-function
'ebox--scroll-build-content-region-id-set)))
(cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans)
(lambda (&rest args)
(cl-incf line-span-scans)
(apply original-line-span-scan args)))
((symbol-function
'ebox--scroll-build-content-region-id-set)
(lambda (&rest args)
(cl-incf membership-builds)
(apply original-membership-build args))))
(ebox-selector-update-buffer buffer "#row-30"
:content "new row 30")
(ebox-selector-update-buffer buffer "#row-31"
:content "new row 31")))
(let ((report (ebox-test--buffer-update-report)))
(should (= materialize-count 0))
(should (= line-span-scans 0))
(should (= membership-builds 1))
(should (plist-get (ebox--scroll-get-state root-id)
:content-region-id-set))
(should (eq (plist-get report :strategy) 'span-patch))
(should (plist-get report :lazy-scroll-deferred-patch)))
(should-not (string-match-p "new row 30"
(buffer-substring-no-properties
(point-min) (point-max))))
(should (ebox--scroll-region-by root-id 30))
(should (= materialize-count 0))
(let ((state (ebox--scroll-get-state root-id)))
(should-not (plist-get state :content-lines-complete-p))
(should (gethash root-id ebox--scroll-idle-prefetch-timers)))
(ebox--scroll-idle-prefetch root-id)
(let* ((state (ebox--scroll-get-state root-id))
(delta (- 30 (or (plist-get state :scroll-offset)
0))))
(when (> delta 0)
(should (ebox--scroll-region-by root-id delta))))
(let ((state (ebox--scroll-get-state root-id)))
(should (plist-get state :content-lines-complete-p))
(should-not (plist-get state :materialize-content-lines)))
(should (string-match-p "new row 30"
(buffer-substring-no-properties
(point-min) (point-max))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-prefix-extension-keeps-live-window-settled ()
"A clean offscreen prefix extension should not stall the live window."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for index below 40 collect
(ebox-test-box (ebox-test-text (format "row %02d" index))
:width '(300) :height 1))))))
root-id
(ebox-viewport-width 300)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id
(current-buffer) "#root"))
(let* ((state (ebox-scroll-state root-id))
(required-lines
(+ 5 (length (plist-get state :content-lines)))))
(should-not (plist-get state :content-lines-complete-p))
(setq state
(ebox--scroll-state-ensure-prefix-lines
root-id state required-lines t))
(should-not
(plist-get state :lazy-scroll-window-refresh-required)))
(should (= (ebox--scroll-region-by root-id 1 1) 1))
(should-not (plist-get (ebox-scroll-state root-id)
:lazy-scroll-window-refresh-required))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-lazy-scroll-prefix-refreshes-without-offset-motion ()
"A visibly changed prefix should publish atomically without offset motion."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for index below 40 collect
(ebox-test-box (ebox-test-text (format "row %02d" index))
:width '(300) :height 1))))))
root-id
(ebox-viewport-width 300)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id
(current-buffer) "#root"))
(let* ((state (ebox-scroll-state root-id))
(render-prefix (plist-get state :render-content-prefix))
(required-lines
(+ 5 (length (plist-get state :content-lines)))))
(plist-put
state :render-content-prefix
(lambda (source-state region-id limit)
(let* ((rendered
(funcall render-prefix source-state region-id limit))
(lines
(copy-sequence
(plist-get rendered :rendered-content-lines)))
(first (copy-sequence (car lines))))
(when (> (length first) 0)
(put-text-property 0 1 'ebox-test-generation t first))
(setf (car lines) first)
(plist-put rendered :rendered-content-lines lines)
rendered)))
(ebox--scroll-state-ensure-prefix-lines
root-id state required-lines t)
(should (plist-get (ebox-scroll-state root-id)
:lazy-scroll-window-refresh-required)))
(should (eq (ebox--scroll-region-by root-id 0) 'refreshed))
(should (= (plist-get (ebox-scroll-state root-id) :scroll-offset) 0))
(should-not (plist-get (ebox-scroll-state root-id)
:lazy-scroll-window-refresh-required))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-visible-geometry-slot-miss-refreshes-lazy-window-atomically ()
"A visible slot miss should publish rebuilt lazy lines before returning."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append
(list :width '(viewport)
(ebox-test-flex :width '(300) :height 5 :padding
'(0 (10)) :border "#6F6A95"
:justify-content 'center :align-items
'center
(ebox-test-box :id "target" (ebox-test-text "width target\nexpands")
:width '(190) :height 3
:padding '(0 (10))
:border "#9CA3AF"
:text-align 'center)))
(cl-loop for index below 40 collect
(ebox-test-box (ebox-test-text (format "row %02d" index))
:width '(300) :height 1))))))
root-id target-id
(ebox-viewport-width 300)
(ebox-viewport-height 5)
(ebox-scroll-lazy-prefix-lookahead-lines 12))
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id
(current-buffer) "#root"))
(setq target-id (ebox-test--selector-region-id
(current-buffer) "#target"))
(let ((state (ebox-scroll-state root-id)))
(should (plist-get state :render-content-prefix))
(should-not (plist-get state :content-lines-complete-p))
;; Cache enough untouched lines that the next one-line scroll does not
;; cross the old prefix boundary. Dirty-state refresh must not depend
;; on reaching that boundary.
(setq state
(ebox--scroll-state-ensure-prefix-lines
root-id state 12 t))
(should (> (- (length (plist-get state :content-lines))
(plist-get state :content-height))
1)))
(ebox-test--region-update target-id :width '(280))
(let ((report (ebox-test--buffer-update-report))
(state (ebox-scroll-state root-id)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (plist-get report :scroll-state-sync-refreshed))
(should (= (plist-get report :scroll-state-sync-refreshed-count) 1))
(should-not (plist-get report :scroll-state-sync-deferred))
(should-not (plist-get state :lazy-scroll-prefix-dirty))
(should-not (plist-get state
:lazy-scroll-window-refresh-required))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))
;; Later movement must start from the already-published cache generation.
(should (= (ebox--scroll-region-by root-id 1 1) 1))
(should-not (plist-get (ebox-scroll-state root-id)
:lazy-scroll-prefix-dirty))
(should-not (plist-get (ebox-scroll-state root-id)
:lazy-scroll-window-refresh-required))
(should (= (ebox--scroll-region-by root-id -1 1) -1))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-lazy-scroll-cached-hidden-child-update-patches-prefix ()
"Hidden updates should patch cached lazy prefix lines before deferring."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
buffer
root-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(should (ebox--scroll-region-by root-id 30 32))
(should (string-match-p "row 30"
(buffer-substring-no-properties
(point-min) (point-max))))
(should (ebox--scroll-region-by root-id -30))
(should-not (string-match-p "new row 30"
(buffer-substring-no-properties
(point-min) (point-max))))
(ebox-selector-update-buffer buffer "#row-30"
:content "new row 30")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should-not (plist-get report :lazy-scroll-deferred-patch))
(should (plist-get report :scroll-state-patch)))
(should (ebox--scroll-region-by root-id 30))
(let ((plain (buffer-substring-no-properties
(point-min) (point-max))))
(should (string-match-p "new row 30" plain))
(should-not (string-match-p "^row 30$" plain)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-deferred-update-dirties-future-prefix ()
"Deferred hidden updates should be visible when lazy prefix reaches them."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
buffer
root-id
(materialize-count 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox--scroll-get-state root-id))
(materialize (plist-get state :materialize-content-lines)))
(plist-put
state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args)))
(puthash root-id state ebox--scroll-global-state))
(ebox-selector-update-buffer buffer "#row-30"
:content "new row 30")
(let ((report (ebox-test--buffer-update-report)))
(should (plist-get report :lazy-scroll-deferred-patch)))
(should (= materialize-count 0))
(cl-loop repeat 30
do (should (ebox--scroll-region-by root-id 1 1)))
(should (= materialize-count 0))
(let ((plain (buffer-substring-no-properties
(point-min) (point-max))))
(should (string-match-p "new row 30" plain))
(should-not (string-match-p "^row 30$" plain)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-dirty-prefix-rebinds-signature-cache ()
"Dirty lazy prefixes should rebind the render signature cache before rendering."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "") :width 10))
(renderer (ebox--scroll-window-prefix-renderer
(ebox-test-root box) :test-source 120 4))
first-cache
second-cache
(render-count 0))
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
(lambda (&rest _args)
(cl-incf render-count)
(if first-cache
(setq second-cache ebox--render-cache-signature-cache)
(setq first-cache ebox--render-cache-signature-cache))
(list :lines (list (format "line %d" render-count))
:cache nil
:complete nil
:content-width-exact-p t
:rendered-lines
(list (format "line %d" render-count)))))
((symbol-function 'ebox--scroll-window-formatted-lines)
(lambda (_box window)
(plist-get window :lines)))
((symbol-function 'ebox--scroll-rendered-content-lines)
(lambda (_box lines _region-id _start-index)
lines)))
(funcall renderer nil 1 1)
(puthash :stale t first-cache)
(funcall renderer '(:lazy-scroll-prefix-dirty t) 1 1)
(should (= render-count 2))
(should-not (eq first-cache second-cache))
(should-not (gethash :stale second-cache)))))
(ert-deftest ebox-visible-scroll-child-content-paint-patches-scroll-lines ()
"Visible content plus paint updates should patch scroll lines directly."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 8 collect
(ebox-test-box :id (format "metric-%02d" idx)
(ebox-test-text "待运行") :width '(120)
:height 2))))))
buffer
root-id)
(unwind-protect
(let ((ebox-viewport-width 260)
(ebox-viewport-height 4)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(ebox-selector-update-buffer buffer "#metric-01"
:content "结果:通过"
:color "#B91C1C")
(let* ((report (ebox-test--buffer-update-report))
(state (ebox--scroll-get-state root-id))
(plain (mapconcat #'substring-no-properties
(plist-get state :content-lines)
"\n")))
(should (plist-get report :scroll-state-patch))
(should (string-match-p "结果:通过" plain))
(should-not (string-match-p "metric-01.*待运行" plain)))
(should (ebox--scroll-region-by root-id 1 1))
(let ((plain (buffer-substring-no-properties
(point-min) (point-max))))
(should (string-match-p "结果:通过" plain)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-visible-scroll-child-sync-retains-tp-mounts ()
"Post-patch scroll line sync should retain TP-owned viewport ranges."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 8 collect
(ebox-test-box :id (format "metric-%02d" idx)
(ebox-test-text "待运行") :width '(120)
:height 2))))))
buffer
root-id)
(unwind-protect
(let ((ebox-viewport-width 260)
(ebox-viewport-height 4)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(ebox-selector-update-buffer buffer "#metric-01"
:padding-left (list 8)
:color "#B91C1C")
(let* ((report (ebox-test--buffer-update-report))
(state (ebox--scroll-get-state root-id)))
(should (plist-get report :scroll-state-sync))
(ebox-test--assert-scoped-tp-publication report)
(should (eq (plist-get state :buffer) buffer))
(should (ebox-surface-region-mounts buffer root-id)))
(should (ebox--scroll-region-by root-id 1 1))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-scroll-retains-tp-mounts-after-paint-update ()
"Paint updates and later scrolling should retain TP-owned ranges."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 24 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :height
1))))))
buffer
root-id)
(unwind-protect
(let ((ebox-viewport-width 260)
(ebox-viewport-height 4)
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((surface ebox-surface--buffer-surface)
(revision (tp-surface-revision surface)))
(should (ebox-surface-region-mounts buffer root-id))
(ebox-selector-update-buffer buffer "#row-01"
:color "#B91C1C")
(should (= (tp-surface-revision surface) (1+ revision)))
(setq revision (tp-surface-revision surface))
(let ((report (ebox-test--buffer-update-report)))
(should (plist-get report :scroll-state-paint-sync))
(ebox-test--assert-scoped-tp-publication report))
(cl-loop repeat 6
do
(should (ebox--scroll-region-by root-id 1 1))
(should (= (tp-surface-revision surface)
(1+ revision)))
(setq revision (tp-surface-revision surface))
(should (ebox-surface-region-mounts buffer root-id))
(should
(ebox-test--buffer-visually-matches-runtime-render-p))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-owner-rerender-without-scroll-skips-scroll-state-lookup ()
"A scroll-free root reflow should not inspect scroll containment."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-column :width '(viewport)
(ebox-test-box (ebox-test-text "resize") :width '(viewport))))
buffer
(containment-count 0)
(original-containment
(symbol-function
'ebox--scroll-state-region-ids-containing-node-ids)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm nil))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-no-scroll-publication*")
layout))
(cl-letf
(((symbol-function
'ebox--scroll-state-region-ids-containing-node-ids)
(lambda (&rest arguments)
(cl-incf containment-count)
(apply original-containment arguments))))
(ebox-rerender-buffer-with-context buffer 260 4))
(should (= containment-count 0))
(with-current-buffer buffer
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-hidden-geometry-update-defers-rerender ()
"Hidden geometry changes in lazy scroll windows should not rerender roots."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 32 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :width
'(120) :height 1))))))
(buffer nil)
target-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq target-id (ebox-test--selector-region-id buffer "#row-30"))
(should target-id)
(ebox-selector-update-buffer buffer "#row-30" :width '(180))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (plist-get report :lazy-scroll-deferred-patch)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-hidden-paint-update-keeps-paint-path ()
"Paint-only updates should not pay lazy scroll geometry deferral costs."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append (list :width '(viewport))
(cl-loop for idx below 32 collect
(ebox-test-box :id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx)) :color
"#111111" :height 1))))))
(buffer nil)
target-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(with-current-buffer buffer
(setq target-id (ebox-test--selector-region-id buffer "#row-30"))
(should target-id)
(ebox-selector-update-buffer buffer "#row-30" :color "#cc0000")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))
(should-not (plist-get report :lazy-scroll-deferred-patch)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-scroll-state-set-lines-defers-line-indexes-when-bound ()
"Deferred scroll state setup should avoid building full line indexes."
(ebox-test--reset-runtime-state)
(let ((content-lines (list (propertize "a" 'ebox-content 1)
(propertize "b" 'ebox-content 2)))
(rendered-lines (list (propertize "a" 'ebox-content 1)))
(build-count 0)
(original (symbol-function 'ebox--scroll-build-region-line-span-index))
(ebox--defer-scroll-content-index t))
(cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index)
(lambda (&rest args)
(cl-incf build-count)
(apply original args))))
(let ((state (ebox--scroll-state-set-lines nil content-lines
rendered-lines)))
(should (= build-count 0))
(should-not (plist-get state :region-line-span-index))
(should (plist-get state :region-line-span-index-deferred))
(should-not (plist-get state :rendered-region-line-span-index))
(should (plist-get state
:rendered-region-line-span-index-deferred))))))
(ert-deftest ebox-lazy-scroll-set-state-defers-line-indexes ()
"Lazy scroll setup should not build hidden content indexes on first render."
(ebox-test--reset-runtime-state)
(let ((content-lines (list (propertize "a" 'ebox-content 1)
(propertize "b" 'ebox-content 2)))
(rendered-lines (list (propertize "a" 'ebox-content 1)))
(build-count 0)
(original (symbol-function 'ebox--scroll-build-region-line-span-index))
(ebox-scroll-lazy-idle-prefetch-lines 0))
(cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index)
(lambda (&rest args)
(cl-incf build-count)
(apply original args))))
(ebox--scroll-set-state
42
(list :scroll-offset 0
:content-height 1
:content-lines content-lines
:rendered-content-lines rendered-lines
:render-content-prefix (lambda (&rest _args) nil)
:materialize-content-lines (lambda (&rest _args) nil)))
(let ((state (ebox--scroll-get-state 42)))
(should (= build-count 0))
(should-not (plist-get state :region-line-span-index))
(should (plist-get state :region-line-span-index-deferred))
(should-not (plist-get state :rendered-region-line-span-index))
(should (plist-get state
:rendered-region-line-span-index-deferred))))))
(ert-deftest ebox-reflow-cache-prewarm-uses-first-valid-width-delta ()
"Predictive warming should start from the first valid resize velocity."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height 2
(ebox-test-box (ebox-test-text "steady") :width '(viewport)
:height 1)))
buffer
scheduled-delay
scheduled-repeat
scheduled-function
scheduled-arguments)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil)
(ebox-runtime-idle-reflow-cache-prewarm t)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*") layout))
(cl-letf (((symbol-function 'run-at-time)
(lambda (&rest _)
(ert-fail "Resize prediction must yield to input")))
((symbol-function 'run-with-idle-timer)
(lambda (delay repeat function &rest arguments)
(setq scheduled-delay delay
scheduled-repeat repeat
scheduled-function function
scheduled-arguments arguments)
'scheduled-reflow-timer)))
(ebox-rerender-buffer-with-context buffer 260 4))
(should (= scheduled-delay
ebox-runtime-idle-reflow-cache-prewarm-delay))
(should-not scheduled-repeat)
(should (eq scheduled-function #'ebox--run-reflow-cache-prewarm))
(should (equal (car scheduled-arguments) buffer))
(should (eq (gethash buffer ebox--reflow-cache-prewarm-timers)
'scheduled-reflow-timer))
(should (= (plist-get (ebox--buffer-render-state buffer)
:last-viewport-width-delta)
20)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-reflow-cache-prewarm-isolates-published-runtime-state ()
"Predicted renders should publish only persistent render-cache entries."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-flex
(append (list :width '(viewport) :gap 1)
(cl-loop for index below 8 collect
(ebox-test-box :id (format "card-%d" index)
(ebox-test-text (format "card %d" index)) :width
72 :height 1))))))
buffer)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*") layout))
(ebox--cancel-buffer-runtime-prewarm buffer)
(let* ((state (ebox--buffer-render-state buffer))
(root (plist-get state :root-node))
(root-before (copy-tree root))
(text-before
(with-current-buffer buffer (buffer-string)))
(cache (plist-get state :render-cache))
(cache-count (hash-table-count cache))
(signature-count
(hash-table-count
(plist-get state :render-signature-cache)))
(root-id (ebox-test--selector-region-id buffer "#root"))
(scroll-before
(copy-tree (ebox--scroll-get-state root-id)))
(region-table-count (hash-table-count ebox--region-box-table)))
(ebox-test--set-buffer-update-report
buffer 'keep-this-report)
(should
(ebox--prewarm-buffer-render-cache-at-context
buffer state 0 240 280 4))
(should (> (hash-table-count cache) cache-count))
(should (= (hash-table-count
(plist-get state :render-signature-cache))
signature-count))
(should (equal root root-before))
(should (equal-including-properties
(with-current-buffer buffer (buffer-string))
text-before))
(should (= (plist-get state :viewport-width) 240))
(should (equal (ebox--scroll-get-state root-id) scroll-before))
(should (= (hash-table-count ebox--region-box-table)
region-table-count))
(should (eq (ebox-buffer-update-report buffer)
'keep-this-report))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-reflow-cache-prewarm-rejects-stale-runtime-revision ()
"A stale predicted render should exit before touching render caches."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) (ebox-test-text "stale")))
buffer
(render-count 0)
(original-render (symbol-function 'ebox-render)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 4)
(ebox-runtime-idle-prewarm nil))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*") layout))
(ebox--cancel-buffer-runtime-prewarm buffer)
(let* ((state (ebox--buffer-render-state buffer))
(cache (plist-get state :render-cache))
(cache-count (hash-table-count cache)))
(cl-letf (((symbol-function 'ebox-render)
(lambda (&rest args)
(cl-incf render-count)
(apply original-render args))))
(should-not
(ebox--prewarm-buffer-render-cache-at-context
buffer state 1 240 280 4)))
(should (= render-count 0))
(should (= (hash-table-count cache) cache-count))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-prepares-shared-update-state ()
"Idle prewarm should prepare shared update state without materializing scrolls."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height 2 :overflow
'scroll
(apply #'ebox-test-column
(append
(cl-loop for idx below 40 collect
(ebox-test-box :id (format "row-%02d" idx) (ebox-test-text (format "row %02d" idx)) :height 1))))))
(buffer nil)
root-id
expected-viewport-ids
(materialize-count 0))
(unwind-protect
(let ((ebox-viewport-width 900)
(ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-runtime-idle-prewarm-slice-size 10000)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*") layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(setq expected-viewport-ids
(ebox--viewport-dependent-node-ids
(ebox-test--runtime-root buffer)))
(let* ((state (ebox-scroll-state root-id))
(before-lines (length (plist-get state :content-lines)))
(materialize (plist-get state :materialize-content-lines)))
(when materialize
(plist-put state :materialize-content-lines
(lambda (&rest args)
(cl-incf materialize-count)
(apply materialize args))))
(ebox--runtime-prewarm-drain buffer)
(let ((render-state (ebox--buffer-render-state buffer))
(warmed (ebox-scroll-state root-id)))
(should
(plist-get render-state :layout-snapshots-complete-p))
(should
(plist-get render-state :viewport-dependent-node-ids-ready))
(should
(equal (plist-get render-state :viewport-dependent-node-ids)
expected-viewport-ids))
(should (plist-get warmed :region-line-span-index))
(should (plist-get warmed :rendered-region-line-span-index))
(should (= (length (plist-get warmed :content-lines))
before-lines))
(should (= materialize-count 0)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-visible-buffer-rearms-native-scroll-prewarm ()
"A buffer rendered off-window should switch to native scrolling when shown."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
(scheduled 0)
(fake-window 'ebox-test-window))
(unwind-protect
(let ((ebox--runtime-prewarm-allow-noninteractive t)
(ebox-native-buffer-scroll t)
;; The production hook is intentionally GUI-only. Bind the
;; flag for this lifecycle test and stub the window primitives.
(noninteractive nil))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-visible-prewarm*")
(ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "one") :height 1)
(ebox-test-box (ebox-test-text "two") :height 1)
(ebox-test-box (ebox-test-text "three") :height 1)))))
;; Rendering happened before the buffer had a window, so the
;; original job is allowed to finish without native materializing.
(ebox--runtime-prewarm-drain buffer)
(should-not (plist-get (ebox--buffer-render-state buffer)
:native-buffer-scroll-p))
(cl-letf (((symbol-function 'window-live-p)
(lambda (window) (eq window fake-window)))
((symbol-function 'window-buffer)
(lambda (_window) buffer))
((symbol-function 'get-buffer-window)
(lambda (candidate &optional _frame)
(and (eq candidate buffer) fake-window)))
((symbol-function 'ebox--viewport-dependent-subtree-p)
(lambda (&rest _) nil))
((symbol-function
'ebox--viewport-height-dependent-subtree-p)
(lambda (&rest _) nil))
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
(lambda (candidate &optional _delay _retained-only-p)
(should (eq candidate buffer))
(cl-incf scheduled))))
(let ((render-state (ebox--buffer-render-state buffer)))
;; This fixture stands for a viewport-independent root; the
;; production proof consumes the published axes in O(1).
(plist-put render-state :viewport-dependent-node-ids-ready t)
(plist-put render-state :viewport-dependent-node-id-axes
(list nil))
(let ((job (ebox--runtime-prewarm-new-job
buffer render-state)))
(should (eq (plist-get job :phase) 'native-scroll))
(should (plist-get job :native-scroll-region-id))))
(ebox--window-buffer-change fake-window))
(should (= scheduled 1)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-native-scroll-prewarm-is-isolated-from-updates ()
"Ordinary runtime prewarm must not materialize a native scroll surface.
Native-window scrolling remains enabled, but only the initial display or an
explicit visible-window handoff may admit its full-content publication."
(ebox-test--reset-runtime-state)
(let ((buffer nil))
(unwind-protect
(let ((noninteractive nil)
(ebox--runtime-prewarm-allow-noninteractive t)
(ebox-native-buffer-scroll t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-native-scroll-opt-in*")
(ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "one") :height 1)
(ebox-test-box (ebox-test-text "two") :height 1)
(ebox-test-box (ebox-test-text "three") :height 1)))))
(let ((state (ebox--buffer-render-state buffer)))
(cl-letf (((symbol-function
'ebox--native-buffer-scroll-root-region-id)
(lambda (&rest _) 1))
((symbol-function
'ebox--native-buffer-scroll-root-proof-p)
(lambda (&rest _) t)))
(let ((job (ebox--runtime-prewarm-new-job buffer state t)))
(should (eq (plist-get job :phase) 'snapshots))
(should-not (plist-get job :native-scroll-region-id)))
(let ((job (ebox--runtime-prewarm-new-job buffer state nil)))
(should (eq (plist-get job :phase) 'native-scroll))
(should (= (plist-get job :native-scroll-region-id) 1))))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-native-scroll-rejects-viewport-dependent-root ()
"Native full-content materialization must not freeze a resizable root."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
(fake-window 'ebox-test-window))
(unwind-protect
(let ((noninteractive nil)
(ebox-viewport-width 120)
(ebox-viewport-height 2)
(ebox-native-buffer-scroll t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-native-scroll-viewport*")
(ebox-test-box :id "root" :width '(viewport) :height 2 :overflow
'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "one") :height 1)
(ebox-test-box (ebox-test-text "two") :height 1)
(ebox-test-box (ebox-test-text "three") :height 1)))))
(let* ((state (ebox--buffer-render-state buffer))
(region-id (car (plist-get state :scroll-region-ids))))
(cl-letf (((symbol-function 'get-buffer-window)
(lambda (&rest _) fake-window))
((symbol-function 'window-live-p)
(lambda (window) (eq window fake-window))))
(should-not
(ebox--native-buffer-scroll-root-proof-p
buffer region-id state)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-does-not-wait-for-prefix-prefetch ()
"Prewarm should index current scroll lines while lazy prefix work is active."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box :id "target" (ebox-test-text "target") :height
1)
(ebox-test-box :id "row-1" (ebox-test-text "row 1") :height 1)
(ebox-test-box :id "row-2" (ebox-test-text "row 2") :height 1))))
(buffer nil)
root-id)
(unwind-protect
(let ((ebox-runtime-idle-prewarm-slice-size 10000)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*") layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(puthash root-id t ebox--scroll-idle-prefetch-timers)
(ebox--runtime-prewarm-drain buffer)
(let ((state (ebox-scroll-state root-id)))
(should (plist-get state :region-line-span-index))
(should (plist-get state :rendered-region-line-span-index))))
(remhash root-id ebox--scroll-idle-prefetch-timers)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-takes-priority-over-prefix-prefetch ()
"Lazy prefix warming should yield while shared runtime prewarming is active."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
root-id
(prefix-render-count 0)
exact-prefix
scheduled-delay)
(unwind-protect
(let ((ebox-scroll-lazy-idle-prefetch-lines 8)
(ebox--scroll-allow-noninteractive-prefetch t)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*")
(ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "row 0") :height 1)
(ebox-test-box (ebox-test-text "row 1") :height 1)))))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let* ((state (ebox-scroll-state root-id))
(job (gethash buffer ebox--runtime-prewarm-jobs)))
(setq state (plist-put state :render-content-prefix t))
(setq state (plist-put state :content-lines-complete-p nil))
(setq state (plist-put state :buffer buffer))
(puthash root-id state ebox--scroll-global-state)
(cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines)
(lambda (&rest _args)
(cl-incf prefix-render-count)
state))
((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (_region-id &optional delay)
(setq scheduled-delay delay))))
(ebox--scroll-idle-prefetch root-id))
(should (= prefix-render-count 0))
(should (eq (gethash buffer ebox--runtime-prewarm-jobs) job))
(should (equal scheduled-delay
ebox-runtime-idle-prewarm-prefix-resume-delay))))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-does-not-delay-cache-miss-prefetch ()
"Urgent cache-miss prefetch should bypass shared prewarm priority."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
root-id
(prefix-render-count 0)
scheduled-delay)
(unwind-protect
(let ((ebox-scroll-lazy-idle-prefetch-lines 8)
(ebox--scroll-allow-noninteractive-prefetch t)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*")
(ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "row 0") :height 1)
(ebox-test-box (ebox-test-text "row 1") :height 1)))))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(let ((state (ebox-scroll-state root-id)))
(setq state (plist-put state :render-content-prefix t))
(setq state (plist-put state :content-lines-complete-p nil))
(setq state (plist-put state :cache-miss-prefetch-target-lines 8))
(setq state (plist-put state :buffer buffer))
(puthash root-id state ebox--scroll-global-state)
(cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines)
(lambda (_region-id input-state target-lines
&optional exact)
(cl-incf prefix-render-count)
(setq exact-prefix exact)
(setq input-state
(plist-put input-state :content-lines
(make-list target-lines
(ebox-pixel-space 1))))
(plist-put input-state :content-lines-complete-p t)))
((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (_region-id &optional delay)
(setq scheduled-delay delay)))
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
(lambda (&rest _args) nil)))
(ebox--scroll-idle-prefetch root-id)))
(should (= prefix-render-count 1))
(should exact-prefix)
(should-not (equal scheduled-delay
ebox-runtime-idle-prewarm-prefix-resume-delay)))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-idle-prefix-slices-do-not-restart-runtime-prewarm ()
"Consecutive prefix slices should not starve behind repeated tree prewarms."
(ebox-test--reset-runtime-state)
(let* ((buffer (generate-new-buffer " *ebox-prefix-slice-test*"))
(region-id "prefix-slice-scroll")
(state (list :buffer buffer
:scroll-offset 0
:content-height 2
:content-lines '("row 0" "row 1")
:render-content-prefix t
:content-lines-complete-p nil))
(runtime-prewarm-count 0)
(prefix-reschedule-count 0))
(unwind-protect
(let ((ebox-scroll-lazy-idle-prefetch-lines 8)
(ebox-scroll-lazy-idle-prefetch-slice-lines 1))
(puthash region-id state ebox--scroll-global-state)
(cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines)
(lambda (_region-id input-state _target-lines
&optional _exact)
(plist-put input-state :content-lines
(append (plist-get input-state :content-lines)
'("row 2")))
(puthash region-id input-state
ebox--scroll-global-state)
input-state))
((symbol-function 'ebox--scroll-schedule-idle-prefetch)
(lambda (&rest _args)
(cl-incf prefix-reschedule-count)))
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
(lambda (&rest _args)
(cl-incf runtime-prewarm-count))))
(ebox--scroll-idle-prefetch region-id))
(should (= prefix-reschedule-count 1))
(should (= runtime-prewarm-count 0)))
(remhash region-id ebox--scroll-global-state)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-yields-between-slices ()
"Each prewarm slice should leave an idle interval for redisplay."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
scheduled-delay)
(unwind-protect
(let ((ebox-runtime-idle-prewarm-delay 0.1)
(ebox-runtime-idle-prewarm-slice-size 1)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*")
(ebox-test-box :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "row 0") :height 1)
(ebox-test-box (ebox-test-text "row 1") :height 1)
(ebox-test-box (ebox-test-text "row 2") :height 1)))))
(cl-letf (((symbol-function 'current-idle-time)
(lambda () (seconds-to-time 0.4)))
((symbol-function 'ebox--runtime-prewarm-schedule-timer)
(lambda (_buffer &optional delay)
(setq scheduled-delay delay))))
(ebox--runtime-prewarm-timer buffer))
(should (equal scheduled-delay 0.5)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-discards-stale-runtime-revisions ()
"A runtime mutation should invalidate an older sliced prewarm job."
(ebox-test--reset-runtime-state)
(let ((buffer nil))
(unwind-protect
(let ((ebox-runtime-idle-prewarm-slice-size 1)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*")
(ebox-test-column (ebox-test-box :id "a" (ebox-test-text "A"))
(ebox-test-box :id "b" (ebox-test-text "B")))))
(should (gethash buffer ebox--runtime-prewarm-jobs))
(ebox--bump-buffer-runtime-revision buffer)
(should-not (ebox--runtime-prewarm-step buffer))
(should-not (gethash buffer ebox--runtime-prewarm-jobs)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-reschedules-after-batched-updates ()
"A successful explicit batch should schedule shared-state prewarming."
(ebox-test--reset-runtime-state)
(let ((buffer nil))
(unwind-protect
(let ((ebox-runtime-idle-prewarm-slice-size 10000)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*")
(ebox-test-column (ebox-test-box :id "target" (ebox-test-text "old"))
(ebox-test-box :id "sibling" (ebox-test-text "same")))))
(ebox--runtime-prewarm-drain buffer)
(should-not (gethash buffer ebox--runtime-prewarm-jobs))
(let ((target-id
(ebox-test--selector-region-id buffer "#target")))
(ebox-incremental-begin-batch buffer)
(with-current-buffer buffer
(ebox-test--region-update target-id :color "#DC2626")
(ebox-incremental-flush buffer)))
(let ((job (gethash buffer ebox--runtime-prewarm-jobs))
(state (ebox--buffer-render-state buffer)))
(should job)
(should (= (plist-get job :runtime-revision)
(plist-get state :runtime-revision)))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-cancels-during-open-batch ()
"An unflushed batch mutation should cancel stale shared-state prewarming."
(ebox-test--reset-runtime-state)
(let ((buffer nil))
(unwind-protect
(let ((ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*")
(ebox-test-box :id "target" (ebox-test-text "old"))))
(should (gethash buffer ebox--runtime-prewarm-jobs))
(let ((target-id
(ebox-test--selector-region-id buffer "#target")))
(ebox-incremental-begin-batch buffer)
(with-current-buffer buffer
(ebox-test--region-update target-id :padding-left 9)))
(should (= (ebox-incremental-pending-count buffer) 1))
(should-not (gethash buffer ebox--runtime-prewarm-jobs))
(should-not (gethash buffer ebox--runtime-prewarm-timers)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-avoids-unbounded-tree-scans ()
"Scheduling and draining prewarm should not scan all nodes in one callback."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
(node-table-scan-count 0)
(viewport-scan-count 0))
(unwind-protect
(let ((ebox--runtime-prewarm-allow-noninteractive t)
(ebox-runtime-idle-prewarm-slice-size 1))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*")
(ebox-test-column (ebox-test-box (ebox-test-text "row 0"))
(ebox-test-box (ebox-test-text "row 1"))
(ebox-test-box (ebox-test-text "row 2")))))
(ebox--cancel-buffer-runtime-prewarm buffer)
(let ((node-table (ebox--buffer-node-table buffer))
(original-maphash (symbol-function 'maphash)))
(cl-letf (((symbol-function 'maphash)
(lambda (function table)
(when (eq table node-table)
(cl-incf node-table-scan-count))
(funcall original-maphash function table))))
(ebox--schedule-buffer-runtime-prewarm buffer)))
(let ((original-viewport
(symbol-function
'ebox--buffer-viewport-dependent-node-ids)))
(cl-letf
(((symbol-function 'ebox--buffer-viewport-dependent-node-ids)
(lambda (&rest args)
(cl-incf viewport-scan-count)
(apply original-viewport args))))
(ebox--runtime-prewarm-drain buffer)))
(should (= node-table-scan-count 0))
(should (= viewport-scan-count 0)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-preserves-viewport-dependency-semantics ()
"Sliced dependency discovery should match the canonical tree algorithm."
(ebox-test--reset-runtime-state)
(let ((buffer nil))
(unwind-protect
(let ((ebox-viewport-width 360)
(ebox-viewport-height 12)
(ebox-runtime-idle-prewarm-slice-size 1)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*")
(ebox-test-box :width '(210)
(ebox-test-column
(ebox-test-box :id "width-only" :width '(viewport))
(ebox-test-box :id "height-only" :height
'(viewport-height))
(ebox-test-flex :id "flex-height" :height
'(viewport-height)
(ebox-test-box (ebox-test-text "Flex")))))))
(let* ((state (ebox--buffer-render-state buffer))
(root (plist-get state :root-node))
(expected-axes
(let ((ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq))
(ebox--viewport-height-dependent-subtree-cache
(make-hash-table :test 'eq)))
(ebox--viewport-dependent-node-id-axes root)))
(expected
(delete-dups
(copy-sequence
(append (car expected-axes) (cdr expected-axes)))))
(width-only-node-id
(plist-get
(car (ebox-selector-query-buffer buffer "#width-only"))
:node-id))
(height-only-node-id
(plist-get
(car (ebox-selector-query-buffer buffer "#height-only"))
:node-id)))
(ebox--runtime-prewarm-drain buffer)
(should (equal (plist-get state :viewport-dependent-node-id-axes)
expected-axes))
(should (equal (plist-get state :viewport-dependent-node-ids)
expected))
(pcase-let ((`(,width-ids . ,height-ids)
(plist-get state
:viewport-dependent-node-id-axes)))
(should-not (member width-only-node-id width-ids))
(should-not (member width-only-node-id height-ids))
(should-not (member height-only-node-id width-ids))
(should (member height-only-node-id height-ids)))
(should (plist-get state :viewport-dependent-node-ids-ready))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-viewport-reflow-consumes-prepared-context-axes ()
"A topology-stable viewport commit must not rederive its axis partition."
(ebox-test--reset-runtime-state)
(let ((buffer nil)
(axis-scans 0)
(validation-scans 0)
(inheritance-scans 0)
(reconciliations 0))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 6))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-axis-proof*")
(ebox-test-column :width '(viewport)
(ebox-test-box (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1))))
(let ((original-axes
(symbol-function 'ebox-surface--context-axes))
(original-validation
(symbol-function 'ebox-tree-validate-declarative-root))
(original-inheritance
(symbol-function
'ebox-surface--inline-inheritance-required-p))
(original-reconcile
(symbol-function 'ebox-surface--reconcile-candidate)))
(cl-letf (((symbol-function 'ebox-surface--context-axes)
(lambda (&rest arguments)
(cl-incf axis-scans)
(apply original-axes arguments)))
((symbol-function 'ebox-tree-validate-declarative-root)
(lambda (&rest arguments)
(cl-incf validation-scans)
(apply original-validation arguments)))
((symbol-function
'ebox-surface--inline-inheritance-required-p)
(lambda (&rest arguments)
(cl-incf inheritance-scans)
(apply original-inheritance arguments)))
((symbol-function 'ebox-surface--reconcile-candidate)
(lambda (&rest arguments)
(cl-incf reconciliations)
(apply original-reconcile arguments))))
(let ((report
(ebox-rerender-buffer-with-context buffer 220 6)))
(should (eq (plist-get report :projection-kind)
'viewport-reflow)))))
(should (zerop axis-scans))
(should (zerop validation-scans))
(should (zerop inheritance-scans))
(should (zerop reconciliations)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-runtime-idle-prewarm-scroll-slices-use-list-cursors ()
"Late cached-line slices should not restart traversal from the list head."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :height 2 :overflow 'scroll
(apply #'ebox-test-column
(append
(cl-loop for idx below 40 collect
(ebox-test-box (ebox-test-text (format "row %02d" idx))
:height 1))))))
(buffer nil)
(nth-count 0)
(original-nth (symbol-function 'nth)))
(unwind-protect
(let ((ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-runtime-idle-prewarm-slice-size 3)
(ebox--runtime-prewarm-allow-noninteractive t))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-prewarm-test*") layout))
(cl-letf (((symbol-function 'nth)
(lambda (&rest args)
(cl-incf nth-count)
(apply original-nth args))))
(ebox--runtime-prewarm-drain buffer))
(should (= nth-count 0)))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-hidden-scroll-child-content-update-patches-scroll-state ()
"Hidden scroll children should update cached scroll lines without rerendering."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "visible") :height 1)
(ebox-test-box :id "target" (ebox-test-text "old") :height 1)
(ebox-test-box (ebox-test-text "tail") :height 1))))
root-id target-id)
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
(setq target-id (ebox-test--selector-region-id (current-buffer)
"#target"))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "visible" plain))
(should-not (string-match-p "old" plain)))
(ebox-test--region-update target-id :content "new")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (plist-get report :scroll-state-patch))
(should-not (plist-get report :full-rerender)))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "visible" plain))
(should-not (string-match-p "new" plain)))
(should (ebox--scroll-region-by root-id 1))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "new" plain))
(should-not (string-match-p "old" plain))))))
(ert-deftest ebox-visible-lazy-scroll-child-update-does-not-defer ()
"Lazy scroll children in the current visible range should patch immediately."
(ebox-test--reset-runtime-state)
(let* ((children (cl-loop for idx below 40
collect
(ebox-test-box
:id (format "row-%02d" idx)
(ebox-test-text (format "row %02d" idx))
:height 1)))
(layout (ebox-test-box :id "root" :height 2 :overflow 'scroll
(apply #'ebox-test-column (append children))))
root-id target-id)
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
(should (ebox--scroll-region-by root-id 30))
(setq target-id (ebox-test--selector-region-id (current-buffer)
"#row-30"))
(should target-id)
(should (string-match-p "row 30"
(buffer-substring-no-properties
(point-min) (point-max))))
(cl-letf (((symbol-function 'ebox--region-ids-visible-in-buffer-p)
(lambda (&rest _args) nil)))
(ebox-test--region-update target-id :content "new row 30"))
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender root-rerender)))
(should-not (plist-get report :lazy-scroll-deferred-patch)))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "new row 30" plain))))))
(ert-deftest ebox-visible-empty-scroll-child-content-update-settles-window ()
"An empty fixed box in a visible scene must not be mistaken for hidden."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append
(list :width '(viewport)
(ebox-test-flex :id "scene" :width '(viewport) :height 4
:align-items 'center :justify-content
'center
(ebox-test-box :id "target" (ebox-test-text "SEED") :width '(180)
:height 3 :overflow
'hidden :bgcolor
"#DDEAE4")))
(cl-loop for index below 40 collect
(ebox-test-box (ebox-test-text (format "row %02d" index))
:height 1))))))
(ebox-viewport-width 300)
(ebox-viewport-height 4)
(ebox-scroll-lazy-prefix-lookahead-lines 12)
root-id target-id)
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root")
target-id (ebox-test--selector-region-id
(current-buffer) "#target"))
(should (plist-get (ebox-scroll-state root-id)
:render-content-prefix))
(ebox-test--region-update target-id :content "")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (plist-get report :scroll-state-patch))
(should-not
(member (ebox--buffer-root-node-id (current-buffer))
(plist-get report :owner-ids))))
(ebox-test--region-update target-id :content "NOW VISIBLE")
(let ((report (ebox-test--buffer-update-report))
(state (ebox-scroll-state root-id)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (plist-get report :scroll-state-patch))
(should-not
(member (ebox--buffer-root-node-id (current-buffer))
(plist-get report :owner-ids)))
(should-not (plist-get report :lazy-scroll-deferred-patch))
(should-not (plist-get state :lazy-scroll-prefix-dirty))
(should-not (plist-get state
:lazy-scroll-window-refresh-required)))
(should (string-match-p
"NOW VISIBLE"
(buffer-substring-no-properties (point-min) (point-max))))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-lazy-scroll-ignores-updates-to-sibling-status-box ()
"A sibling update must not materialize an unrelated lazy scroll state."
(ebox-test--reset-runtime-state)
(let* ((rows (cl-loop for index below 40
collect
(ebox-test-box
:id (format "row-%02d" index)
(ebox-test-text (format "row %02d" index))
:height 1)))
(layout
(ebox-test-column :width '(viewport)
(ebox-test-box :id "rail" (ebox-test-text "ready") :width
'(viewport) :height 1)
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(apply #'ebox-test-column
(append rows)))))
(ebox-viewport-width 240)
(ebox-viewport-height 3)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
rail-id root-id materialize-count)
(ebox-test--with-rendered-buffer layout
(setq rail-id (ebox-test--selector-region-id
(current-buffer) "#rail")
root-id (ebox-test--selector-region-id
(current-buffer) "#root"))
(let* ((state (ebox-scroll-state root-id))
(materialize (plist-get state :materialize-content-lines)))
(should materialize)
(should-not (plist-get state :content-lines-complete-p))
(should (plist-get state :rendered-content-lines))
(plist-put
state :materialize-content-lines
(lambda (&rest arguments)
(cl-incf materialize-count)
(apply materialize arguments)))
(puthash root-id state ebox--scroll-global-state))
(ebox-test--region-update rail-id :content "updated")
(should (= (or materialize-count 0) 0))
(let ((state (ebox-scroll-state root-id)))
(should-not (plist-get state :content-lines-complete-p))
(should (plist-get state :rendered-content-lines)))
(should (string-match-p
"updated"
(buffer-substring-no-properties (point-min) (point-max)))))))
(ert-deftest ebox-scroll-reveal-keeps-visible-geometry-indexed ()
"Geometry updates on a revealed child should use a scoped TP plan."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id "root" :width '(180) :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "row 0") :height 1)
(ebox-test-box (ebox-test-text "row 1") :height 1)
(ebox-test-box (ebox-test-text "row 2") :height 1)
(ebox-test-box :id "target" (ebox-test-text "target") :width
'(120) :height 1 :box-sizing
'border-box :padding-left '(2)))))
root-id target-id)
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
(setq target-id
(ebox-test--selector-region-id (current-buffer) "#target"))
(should (ebox--scroll-region-by root-id 2 48))
(ebox-test--region-update target-id :padding-left (list 12))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (eq (plist-get report :tp-render-scope) 'objects))
(should-not (plist-get report :tp-scope-fallback)))
(should (ebox-surface-region-mounts (current-buffer) target-id))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-wheel-scroll-uses-buffer-scroll-region-without-point ()
"Wheel events from fringe-like areas should still scroll the ebox page."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1)
(ebox-test-box (ebox-test-text "C") :height 1))))
(root-id (car (ebox-region-ids layout)))
mwheel-called)
(ebox-test--with-rendered-buffer layout
(switch-to-buffer (current-buffer))
(cl-letf (((symbol-function 'event-start)
(lambda (_event)
(list (selected-window) 'mode-line '(0 . 0)
0 nil nil nil nil nil nil)))
((symbol-function 'mwheel-scroll)
(lambda (&rest _args)
(setq mwheel-called t))))
(ebox--wheel-scroll 'fake-wheel-event 1))
(should-not mwheel-called)
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "B" plain))
(should (string-match-p "C" plain))))))
(ert-deftest ebox-scroll-first-region-ignores-zero-consumption ()
"A zero-line scroll result should not consume the scroll event."
(let (visited)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (region-id _delta &optional _prefix-budget-lines)
(push region-id visited)
(if (eq region-id 'stalled) 0 1))))
(should (ebox--scroll-first-region-by '(stalled active) 1)))
(should (equal (nreverse visited) '(stalled active)))))
(ert-deftest ebox-scroll-intent-distributes-forward-residual-inner-to-buffer ()
"A positive line intent should drain inner, parent, then native buffer."
(let ((capacity '((inner . 1) (middle . 2) (outer . 0)))
native-residual)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (region-id delta &optional _prefix-budget-lines)
(let* ((available (alist-get region-id capacity))
(consumed (min delta available)))
(setf (alist-get region-id capacity) (- available consumed))
consumed))))
(let ((result
(ebox--scroll-consume-intent
(ebox--scroll-intent-create 5 '(inner middle outer))
(lambda (delta) (setq native-residual delta)))))
(should (= (plist-get result :requested) 5))
(should (equal (plist-get result :consumption)
'((inner . 1) (middle . 2) (outer . 0))))
(should (= (plist-get result :ebox-consumed) 3))
(should (= (plist-get result :native-residual) 2))
(should (= native-residual 2))))))
(ert-deftest ebox-scroll-intent-distributes-reverse-residual-inner-to-buffer ()
"A negative line intent should preserve direction through the owner chain."
(let ((capacity '((inner . 1) (middle . 2) (outer . 0)))
native-residual)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (region-id delta &optional _prefix-budget-lines)
(let* ((available (alist-get region-id capacity))
(magnitude (min (- delta) available))
(consumed (- magnitude)))
(setf (alist-get region-id capacity) (- available magnitude))
consumed))))
(let ((result
(ebox--scroll-consume-intent
(ebox--scroll-intent-create -5 '(inner middle outer))
(lambda (delta) (setq native-residual delta)))))
(should (equal (plist-get result :consumption)
'((inner . -1) (middle . -2) (outer . 0))))
(should (= (plist-get result :ebox-consumed) -3))
(should (= (plist-get result :native-residual) -2))
(should (= native-residual -2))))))
(ert-deftest ebox-scroll-intent-distributes-real-nested-owner-capacity ()
"Nested boxes should consume their capacity before the native buffer."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id "outer" :height 4 :overflow 'scroll
(ebox-test-column
(ebox-test-box :id "middle" :height 2 :overflow
'scroll
(ebox-test-column
(ebox-test-box :id "inner" (ebox-test-text "inner 0\ninner 1")
:height 1 :overflow
'scroll)
(ebox-test-box (ebox-test-text "middle 0")
:height 1)
(ebox-test-box (ebox-test-text "middle 1")
:height 1)
(ebox-test-box (ebox-test-text "middle 2")
:height 1)))
(ebox-test-box (ebox-test-text "outer 0\nouter 1") :height 2)))))
(ebox-test--with-rendered-buffer layout
(let* ((surface (with-current-buffer
(current-buffer) ebox-surface--buffer-surface))
(revision (tp-surface-revision surface))
(inner-id (ebox-test--selector-region-id
(current-buffer) "#inner"))
(middle-id (ebox-test--selector-region-id
(current-buffer) "#middle"))
(outer-id (ebox-test--selector-region-id
(current-buffer) "#outer"))
native-lines)
(should (ebox-scroll-state inner-id))
(should (ebox-scroll-state middle-id))
(should-not (ebox-scroll-state outer-id))
(goto-char (point-min))
(search-forward "inner 0")
(backward-char)
(cl-letf (((symbol-function 'scroll-up)
(lambda (lines) (setq native-lines lines))))
(ebox-scroll-down 5))
;; Each owner is currently a separately committed retained surface
;; update; the later atomic multi-owner scroll transaction will reduce
;; this to one revision without changing the consumption contract.
(should (= (tp-surface-revision surface) (+ revision 2)))
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 1))
(should (= (plist-get (ebox-scroll-state middle-id) :scroll-offset) 2))
(should-not (ebox-scroll-state outer-id))
(should (= native-lines 2))
(setq native-lines nil)
(ebox--scroll-consume-intent
(ebox--scroll-intent-create -5
(list inner-id middle-id outer-id))
(lambda (residual) (setq native-lines residual)))
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 0))
(should (= (plist-get (ebox-scroll-state middle-id) :scroll-offset) 0))
(should-not (ebox-scroll-state outer-id))
(should (= native-lines -2))))))
(ert-deftest ebox-scroll-intent-refresh-at-boundary-does-not-swallow-input ()
"Refreshing unchanged pixels should leave the complete line intent residual."
(let (native-residual)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (&rest _) 'refreshed)))
(let ((result
(ebox--scroll-consume-intent
(ebox--scroll-intent-create 1 '(inner))
(lambda (delta) (setq native-residual delta)))))
(should (equal (plist-get result :consumption) '((inner . 0))))
(should (= (plist-get result :ebox-consumed) 0))
(should (= (plist-get result :native-residual) 1))
(should (= native-residual 1))))))
(ert-deftest ebox-scroll-intent-pending-prefix-does-not-swallow-input ()
"An unfulfilled lazy-prefix request should leave its line intent residual."
(let (native-residual)
(cl-letf (((symbol-function 'ebox--scroll-region-by)
(lambda (&rest _) 'pending)))
(let ((result
(ebox--scroll-consume-intent
(ebox--scroll-intent-create 3 '(inner parent))
(lambda (delta) (setq native-residual delta)))))
(should (equal (plist-get result :consumption)
'((inner . 0) (parent . 0))))
(should (= (plist-get result :ebox-consumed) 0))
(should (= (plist-get result :native-residual) 3))
(should (= native-residual 3))))))
(ert-deftest ebox-scroll-intent-anchor-routes-keyboard-and-wheel-independently ()
"Keyboard anchors at point while wheel anchors at the event position."
(cl-letf (((symbol-function 'ebox--scroll-region-ids-at-point)
(lambda () '(point-inner point-outer)))
((symbol-function 'ebox--wheel-region-ids)
(lambda (_event) '(wheel-inner wheel-outer))))
(should (equal (plist-get (ebox--scroll-intent-at-point 1) :chain)
'(point-inner point-outer)))
(should (equal (plist-get
(ebox--scroll-intent-at-event 'fake-event 1) :chain)
'(wheel-inner wheel-outer)))))
(ert-deftest ebox-scroll-intent-native-fallback-preserves-direction-and-count ()
"Keyboard residuals should call the matching native command exactly once."
(let (native-up native-down)
(cl-letf (((symbol-function 'ebox--scroll-region-ids-at-point)
(lambda () '(inner)))
((symbol-function 'ebox--scroll-region-by)
(lambda (_region-id delta &optional _budget)
(if (> delta 0) 1 -1)))
((symbol-function 'scroll-up)
(lambda (lines) (setq native-up lines)))
((symbol-function 'scroll-down)
(lambda (lines) (setq native-down lines))))
(ebox-scroll-down 3)
(ebox-scroll-up 3))
(should (= native-up 2))
(should (= native-down 2))))
(ert-deftest ebox-wheel-partial-residual-uses-exact-native-direction ()
"A partial wheel residual should not replay the complete wheel event."
(let (native-up native-down mwheel-called)
(cl-letf (((symbol-function 'event-start)
(lambda (_event) (list (selected-window) (point-min))))
((symbol-function 'scroll-up)
(lambda (lines) (setq native-up lines)))
((symbol-function 'scroll-down)
(lambda (lines) (setq native-down lines)))
((symbol-function 'mwheel-scroll)
(lambda (&rest _) (setq mwheel-called t))))
(ebox--wheel-native-residual 'event 2 5 nil)
(ebox--wheel-native-residual 'event -3 -5 nil))
(should (= native-up 2))
(should (= native-down 3))
(should-not mwheel-called)))
(ert-deftest ebox-wheel-smooth-boundary-defers-to-residual-chain ()
"Smooth mode should use the chain allocator when an owner cannot finish."
(let ((ebox-wheel-smooth-scroll t)
(capacity '((inner . 1) (parent . 2)))
native-residual
smooth-called)
(cl-letf (((symbol-function 'ebox--scroll-intent-at-event)
(lambda (_event delta)
(ebox--scroll-intent-create delta '(inner parent))))
((symbol-function 'ebox--first-scrollable-region)
(lambda (&rest _) 'inner))
((symbol-function 'ebox--scroll-region-can-consume-complete-p)
(lambda (&rest _) nil))
((symbol-function 'ebox--smooth-scroll-region-by)
(lambda (&rest _) (setq smooth-called t)))
((symbol-function 'ebox--scroll-region-by)
(lambda (region-id delta &optional _budget)
(let* ((available (alist-get region-id capacity))
(consumed (min delta available)))
(setf (alist-get region-id capacity) (- available consumed))
consumed)))
((symbol-function 'ebox--wheel-native-residual)
(lambda (_event residual _requested _arg)
(setq native-residual residual))))
(ebox--wheel-scroll 'event 5 nil t))
(should-not smooth-called)
(should (= native-residual 2))))
(ert-deftest ebox-scroll-mount-fallback-orders-equal-spans-deterministically ()
"Equal mounted spans should use semantic depth and a stable id tie-break."
(let ((ebox--scroll-global-state (make-hash-table :test 'equal)))
(puthash 'z-owner t ebox--scroll-global-state)
(puthash 'a-owner t ebox--scroll-global-state)
(cl-letf (((symbol-function 'ebox-surface-region-bounds)
(lambda (&rest _) '(1 . 5)))
((symbol-function 'ebox--scroll-region-semantic-depth)
(lambda (_region-id) 2)))
(should (equal (ebox--scroll-region-ids-in-mounts-at-pos 2)
'(a-owner z-owner))))))
(ert-deftest ebox-scroll-fast-report-uses-boolean-proof-flag ()
"Retained-window proof payload should not leak into the public report."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test-box :id "root" (ebox-test-text "A\nB") :height 1 :overflow 'scroll)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id
(current-buffer) "#root")))
(should (= (ebox--scroll-region-by region-id 1) 1))
(let ((flag (plist-get (ebox-test--buffer-update-report)
:scroll-patch-fast-p)))
(should (memq flag '(nil t))))))))
(ert-deftest ebox-scroll-defaults-follow-one-line-native-buffer-scrolling ()
"Default wheel input should be one immediate document line."
(should (= ebox-wheel-scroll-step 1))
(should-not ebox-wheel-smooth-scroll))
(ert-deftest ebox-scroll-progress-requires-real-delta-or-pending ()
"Scroll progress is either a non-zero consumed line count or lazy pending."
(should (ebox--scroll-progress-p 1))
(should (ebox--scroll-progress-p -1))
(should (ebox--scroll-progress-p 'pending))
(should-not (ebox--scroll-progress-p 'refreshed))
(should-not (ebox--scroll-progress-p 0))
(should-not (ebox--scroll-progress-p nil)))
(ert-deftest ebox-smooth-wheel-scroll-moves-immediately-then-drains ()
"Smooth wheel scrolling should respond immediately and drain pending lines."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1)
(ebox-test-box (ebox-test-text "C") :height 1))))
(root-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(let ((ebox-wheel-smooth-scroll-lines-per-tick 1)
(ebox-wheel-smooth-scroll-interval 60.0))
(should (ebox--smooth-scroll-region-by root-id 2))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(ebox--smooth-scroll-tick root-id)
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
2))
(should-not (gethash root-id ebox--smooth-scroll-state-table))))))
(ert-deftest ebox-smooth-wheel-scroll-coalesces-while-timer-active ()
"Further wheel input should queue behind the active timer, not edit at once."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1)
(ebox-test-box (ebox-test-text "C") :height 1)
(ebox-test-box (ebox-test-text "D") :height 1))))
(root-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(let ((ebox-wheel-smooth-scroll-lines-per-tick 1)
(ebox-wheel-smooth-scroll-interval 60.0))
(should (ebox--smooth-scroll-region-by root-id 2))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(should (gethash root-id ebox--smooth-scroll-state-table))
(should (ebox--smooth-scroll-region-by root-id 2))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(while (gethash root-id ebox--smooth-scroll-state-table)
(ebox--smooth-scroll-tick root-id))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
3))))))
(ert-deftest ebox-smooth-wheel-scroll-clamps-pending-to-range ()
"Large smooth wheel input should not leave an oversized timer backlog."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
(ebox-test-column
(ebox-test-box (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1)
(ebox-test-box (ebox-test-text "C") :height 1)
(ebox-test-box (ebox-test-text "D") :height 1))))
(root-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(let ((ebox-wheel-smooth-scroll-lines-per-tick 1)
(ebox-wheel-smooth-scroll-interval 60.0))
(should (ebox--smooth-scroll-region-by root-id 1000))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(should (<= (plist-get
(gethash root-id ebox--smooth-scroll-state-table)
:pending)
2))
(while (gethash root-id ebox--smooth-scroll-state-table)
(ebox--smooth-scroll-tick root-id))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
3))))))
(ert-deftest ebox-page-scroll-bubbles-from-child-to-scrollable-ancestor ()
"Page scrolling over a child box should scroll the nearest scrollable ancestor."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height 3 :overflow 'scroll
(ebox-test-column
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1)
(ebox-test-box (ebox-test-text "C") :height 1)
(ebox-test-box (ebox-test-text "D") :height 1)
(ebox-test-box (ebox-test-text "E") :height 1)
(ebox-test-box (ebox-test-text "F") :height 1))))
(root-id (car (ebox-region-ids layout)))
fallback-called)
(ebox-test--with-rendered-buffer layout
(goto-char (point-min))
(search-forward "A")
(backward-char)
(cl-letf (((symbol-function 'window-body-height)
(lambda (&optional _window _pixelwise) 5))
((symbol-function 'scroll-up-command)
(lambda (&optional _arg)
(setq fallback-called t))))
(ebox-scroll-page-down))
(should-not fallback-called)
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
3))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "D" plain))
(should (string-match-p "F" plain))
(should-not (string-match-p "A" plain))))))
(ert-deftest ebox-page-scroll-preserves-native-page-fallback-semantics ()
"Whole-page fallback should stay native; residuals should stay exact."
(let (down-argument up-argument partial-p)
(cl-letf (((symbol-function 'ebox--scroll-page-lines)
(lambda (&optional arg)
(if arg (prefix-numeric-value arg) 10)))
((symbol-function 'ebox--scroll-by)
(lambda (delta fallback)
(funcall fallback (if partial-p 2 (abs delta)))))
((symbol-function 'scroll-up-command)
(lambda (&optional arg) (setq down-argument arg)))
((symbol-function 'scroll-down-command)
(lambda (&optional arg) (setq up-argument arg))))
(ebox-scroll-page-down)
(ebox-scroll-page-up)
(should-not down-argument)
(should-not up-argument)
(ebox-scroll-page-down 7)
(ebox-scroll-page-up 7)
(should (= down-argument 7))
(should (= up-argument 7))
(setq partial-p t)
(ebox-scroll-page-down)
(ebox-scroll-page-up)
(should (= down-argument 2))
(should (= up-argument 2)))))
(ert-deftest ebox-scroll-rerender-preserves-viewport-height-context ()
"Scroll rerender fallback should keep viewport-height boxes clipped."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "root" :height '(viewport-height) :overflow 'scroll
(ebox-test-column
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
(ebox-test-box (ebox-test-text "B") :height 1)
(ebox-test-box (ebox-test-text "C") :height 1)
(ebox-test-box (ebox-test-text "D") :height 1))))
(root-id (car (ebox-region-ids layout))))
(let ((ebox-viewport-height 2))
(ebox-test--with-rendered-buffer layout
(should (= (plist-get (ebox--scroll-get-state root-id)
:content-height)
2))
(goto-char (point-min))
(search-forward "A")
(backward-char)
(ebox-scroll-down 1)
(should (= (plist-get (ebox--scroll-get-state root-id)
:content-height)
2))
(should (= (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)
1))
(let ((plain (buffer-substring-no-properties
(point-min) (point-max))))
(should (string-match-p "B" plain))
(should (string-match-p "C" plain))
(should-not (string-match-p "A" plain)))))))
(ert-deftest ebox-wheel-scroll-falls-back-at-scroll-boundary ()
"Mouse wheel should bubble to the buffer when inner scroll is at its edge."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
:height 1
:overflow 'scroll))
(region-id (car (ebox-region-ids box)))
mwheel-args)
(ebox-test--with-rendered-buffer box
(should (ebox--scroll-region-by region-id 3))
(cl-letf (((symbol-function 'ebox--wheel-region-ids)
(lambda (_event) (list region-id)))
((symbol-function 'mwheel-scroll)
(lambda (&rest args)
(setq mwheel-args args))))
(ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg))
(should (equal mwheel-args '(fake-wheel-event fake-arg))))))
(ert-deftest ebox-scroll-map-keeps-wheel-on-event-aware-handler ()
"Mouse wheel bindings should use event-aware handlers, not line fallbacks."
(should (eq (lookup-key ebox-scroll-map [wheel-down])
#'ebox-wheel-scroll-down))
(should (eq (lookup-key ebox-scroll-map [wheel-up])
#'ebox-wheel-scroll-up))
(should (eq (lookup-key ebox-scroll-map [mouse-5])
#'ebox-wheel-scroll-down))
(should (eq (lookup-key ebox-scroll-map [mouse-4])
#'ebox-wheel-scroll-up))
(should (eq (lookup-key ebox-scroll-map [right-fringe wheel-down])
#'ebox-wheel-scroll-down))
(should (eq (lookup-key ebox-scroll-map [right-fringe wheel-up])
#'ebox-wheel-scroll-up))
(should (eq (lookup-key ebox-scroll-map [mode-line wheel-down])
#'ebox-wheel-scroll-down))
(should (eq (lookup-key ebox-scroll-map [mode-line wheel-up])
#'ebox-wheel-scroll-up))
(should (eq (lookup-key ebox-scroll-map [vertical-scroll-bar wheel-down])
#'ebox-wheel-scroll-down))
(should (eq (lookup-key ebox-scroll-map [vertical-scroll-bar wheel-up])
#'ebox-wheel-scroll-up))
(should (eq (lookup-key ebox-scroll-map [remap scroll-up-command])
#'ebox-scroll-page-down))
(should (eq (lookup-key ebox-scroll-map [remap scroll-down-command])
#'ebox-scroll-page-up))
(should (eq (lookup-key ebox-scroll-map (kbd "C-v"))
#'ebox-scroll-page-down))
(should (eq (lookup-key ebox-scroll-map (kbd "<next>"))
#'ebox-scroll-page-down))
(should (eq (lookup-key ebox-scroll-map (kbd "M-v"))
#'ebox-scroll-page-up))
(should (eq (lookup-key ebox-scroll-map (kbd "<prior>"))
#'ebox-scroll-page-up))
(should (eq (lookup-key ebox-scroll-map (kbd "n"))
#'ebox-scroll-down))
(should (eq (lookup-key ebox-scroll-map (kbd "p"))
#'ebox-scroll-up)))
(ert-deftest ebox-buffer-mode-overrides-pixel-scroll-bindings ()
"Ebox buffers should keep their own scroll bindings under pixel scrolling."
(unless (fboundp 'pixel-scroll-precision-mode)
(ert-skip "pixel-scroll-precision-mode is unavailable"))
(let ((was-enabled pixel-scroll-precision-mode))
(unwind-protect
(with-temp-buffer
(pixel-scroll-precision-mode 1)
(ebox-buffer-mode 1)
(should (eq (key-binding [wheel-down])
#'ebox-wheel-scroll-down))
(should (eq (key-binding [wheel-up])
#'ebox-wheel-scroll-up))
(should (eq (key-binding [right-fringe wheel-down])
#'ebox-wheel-scroll-down))
(should (eq (key-binding [mode-line wheel-down])
#'ebox-wheel-scroll-down))
(should (eq (key-binding [vertical-scroll-bar wheel-down])
#'ebox-wheel-scroll-down))
(should (eq (key-binding (kbd "<next>"))
#'ebox-scroll-page-down))
(should (eq (key-binding (kbd "<prior>"))
#'ebox-scroll-page-up)))
(unless was-enabled
(pixel-scroll-precision-mode -1)))))
(ert-deftest ebox-border-box-max-height-includes-vertical-padding ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC\nD")
:box-sizing 'border-box
:max-height 3
:padding '(1 0)
:overflow 'hidden))
(plain (substring-no-properties (ebox-render box))))
(should (= (ebox-string-height plain) 3))
(should-not (string-match-p "C" plain))
(should-not (string-match-p "D" plain))))
(ert-deftest ebox-content-box-min-height-wins-when-min-exceeds-max ()
"Content-box height conflicts should use the minimum as the used height."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A")
:box-sizing 'content-box
:min-height 5
:max-height 3
:padding '(1 0)
:overflow 'hidden))
(rendered (ebox-render box)))
(should (= (ebox-test--input-content-height box 1) 5))
(should (= (ebox-string-height rendered) 7))))
(ert-deftest ebox-border-box-min-height-wins-when-min-exceeds-max ()
"Border-box height conflicts should use the minimum as the outer height."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "A")
:box-sizing 'border-box
:min-height 5
:max-height 3
:padding '(1 0)
:overflow 'hidden))
(rendered (ebox-render box)))
(should (= (ebox-test--input-content-height box 1) 3))
(should (= (ebox-string-height rendered) 5))))
(ert-deftest ebox-border-box-max-width-includes-horizontal-padding-and-border ()
(ebox-test--reset-runtime-state)
(let ((box (ebox-test-box (ebox-test-text "abc abc abc abc abc abc abc abc abc abc")
:box-sizing 'border-box
:max-width '(100)
:padding-inline '(20)
:border-left '(10)
:border-right '(10)
:overflow 'hidden)))
(should (<= (ebox-test--input-total-pixel box) 100))))
(ert-deftest ebox-auto-width-without-viewport-uses-max-content-across-all-lines ()
"Omitted :width should fall back to the widest unwrapped content line."
(ebox-test--reset-runtime-state)
(let* ((content "short\nmuch wider line")
(expected (apply #'max
(mapcar #'ebox--string-pixel-width
(ebox-string-lines content))))
(box (ebox-test-box (ebox-test-text content)))
(rendered (ebox-render box))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))))
(should (= (ebox-test--input-content-pixel box) expected))
(should (cl-every (lambda (width) (= width expected)) widths))))
(ert-deftest ebox-auto-width-stretches-to-viewport-when-available ()
"Omitted :width and :width auto should fill the viewport margin box."
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 240))
(dolist (width-value '(nil auto))
(let ((box (apply #'ebox-test-box
(ebox-test-text "short line wraps into viewport")
(append (list :padding-inline '(20)
:border-left '(10)
:border-right '(10)
:margin-inline '(5))
(when width-value
(list :width width-value))))))
(should (= (ebox-test--input-total-pixel box) 240))))))
(ert-deftest ebox-explicit-width-can-overflow-containing-block ()
"A definite width remains definite even when the containing block is narrower."
(ebox-test--reset-runtime-state)
(let* ((ebox-viewport-width 120)
(content "alpha beta gamma delta")
(box (ebox-test-box (ebox-test-text content) :width '(220) :wrap-mode 'word))
(rendered (ebox-render box))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))))
(should (cl-some (lambda (width) (> width ebox-viewport-width))
widths))
(should (cl-every (lambda (width) (= width 220)) widths))))
(ert-deftest ebox-column-wrapper-width-reflows-auto-child-layout ()
"A column wrapper width should contain auto-sized children."
(ebox-test--reset-runtime-state)
(let* ((ebox-viewport-width 1000)
(node (ebox-test-column :width '(120)
(ebox-test-box (ebox-test-text "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega")
:wrap-mode 'word)))
(rendered (ebox-render node))
(plain (substring-no-properties rendered))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))))
(should (cl-every (lambda (width) (<= width 120)) widths))
(dolist (word '("alpha" "beta" "gamma" "delta" "epsilon" "theta"
"omega"))
(should (string-match-p word plain)))
(should (> (ebox-string-height rendered) 1))))
(ert-deftest ebox-wrapper-child-layout-keeps-pixel-width-with-wide-font ()
"Engine pixel widths must not be reparsed as public character widths."
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 1000))
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
(let* ((node (ebox-test-column :width '(400) (ebox-test-box (ebox-test-text "X"))))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-render node)))))
(should (equal widths '(400)))))))
(ert-deftest ebox-column-wrapper-width-reflows-auto-flex-layout ()
"A column wrapper width should become the viewport for nested auto flex."
(ebox-test--reset-runtime-state)
(let* ((ebox-viewport-width 1000)
(node (ebox-test-column :width '(240)
(ebox-test-flex :flex-wrap 'wrap :column-gap '(10)
(ebox-test-box (ebox-test-text "A") :width
'(100))
(ebox-test-box (ebox-test-text "B") :width
'(100))
(ebox-test-box (ebox-test-text "C") :width
'(100)))))
(rendered (ebox-render node))
(plain-lines (ebox-string-lines
(substring-no-properties rendered)))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))))
(should (cl-every (lambda (width) (<= width 240)) widths))
(should (string-match-p "A.*B" (car plain-lines)))
(should (cl-some (lambda (line) (string-match-p "C" line))
(cdr plain-lines)))))
(ert-deftest ebox-column-containing-block-does-not-expand-to-overflow-child ()
"An overflowing definite child should not widen unrelated column rows."
(ebox-test--reset-runtime-state)
(let* ((ebox-viewport-width 200)
(node (ebox-test-column
(ebox-test-box (ebox-test-text "Auto row"))
(ebox-test-box (ebox-test-text "Fixed row") :width '(300))))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-render node)))))
(should (equal widths '(200 300)))))
(ert-deftest ebox-width-keywords-resolve-intrinsic-content-sizes ()
"auto, min-content, max-content, and none should keep CSS-like meanings."
(ebox-test--reset-runtime-state)
(let* ((content "aa bbbb")
(max-content (ebox--string-pixel-width content))
(min-content (ebox--string-pixel-width "bbbb")))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content) :width 'auto))
max-content))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content) :width 'max-content))
max-content))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content) :width 'min-content))
min-content))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width '(80)
:max-width 'none))
80))
(should-error (ebox-test-box (ebox-test-text content) :width 'none)
:type 'error)))
(ert-deftest ebox-max-content-wrapper-measures-composite-child-intrinsically ()
"A max-content wrapper should not stretch its composite child to the viewport."
(ebox-test--reset-runtime-state)
(let* ((ebox-viewport-width 480)
(lines '("Compact" "Natural width"))
(expected
(apply #'max (mapcar #'ebox--string-pixel-width lines)))
(node
(ebox-test-box
:width 'max-content
(apply #'ebox-test-column
(mapcar (lambda (line)
(ebox-test-box (ebox-test-text line)))
lines))))
(widths
(mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-render node)))))
(should (equal widths (make-list (length lines) expected)))))
(ert-deftest ebox-definite-width-skips-unused-max-content-measurement ()
"A definite preferred width should not evaluate its unused auto fallback."
(ebox-test--reset-runtime-state)
(let ((box (ebox-test-box (ebox-test-text "measurement must stay cold")
:width '(80)))
(measurements 0)
(original (symbol-function 'ebox--content-max-pixel)))
(cl-letf (((symbol-function 'ebox--content-max-pixel)
(lambda (candidate)
(cl-incf measurements)
(funcall original candidate))))
(should (= (ebox-test--input-content-pixel box) 80))
(should (= measurements 0)))))
(ert-deftest ebox-definite-width-still-resolves-intrinsic-constraints ()
"Intrinsic min/max constraints must still measure definite-width content."
(ebox-test--reset-runtime-state)
(let* ((content "intrinsic constraint")
(expected (ebox--string-pixel-width content))
(box (ebox-test-box (ebox-test-text content)
:width '(20)
:min-width 'max-content))
(measurements 0)
(original (symbol-function 'ebox--content-max-pixel)))
(cl-letf (((symbol-function 'ebox--content-max-pixel)
(lambda (candidate)
(cl-incf measurements)
(funcall original candidate))))
(should (= (ebox-test--input-content-pixel box) expected))
(should (= measurements 1)))))
(ert-deftest ebox-auto-width-keeps-max-content-fallback-without-viewport ()
"An unresolved auto width should retain its intrinsic max-content fallback."
(ebox-test--reset-runtime-state)
(let ((box (ebox-test-box (ebox-test-text "auto fallback")))
(measurements 0)
(original (symbol-function 'ebox--content-max-pixel)))
(cl-letf (((symbol-function 'ebox--content-max-pixel)
(lambda (candidate)
(cl-incf measurements)
(funcall original candidate))))
(should (> (ebox-test--input-content-pixel box) 0))
(should (= measurements 1)))))
(ert-deftest ebox-fit-content-width-clamps-between-min-and-max-content ()
"fit-content() should clamp by min-content and max-content."
(ebox-test--reset-runtime-state)
(let* ((content "aa widest")
(min-content (ebox--string-pixel-width "widest"))
(max-content (ebox--string-pixel-width content))
(between (+ min-content (/ (- max-content min-content) 2))))
(should (< min-content between))
(should (< between max-content))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width (list 'fit-content (list between))))
between))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width (list 'fit-content (list 1))))
min-content))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width (list 'fit-content
(list (+ max-content 50)))))
max-content))))
(ert-deftest ebox-fit-content-keyword-uses-stretch-when-viewport-is-definite ()
"The fit-content keyword should clamp stretch between intrinsic sizes."
(ebox-test--reset-runtime-state)
(let* ((content "aa widest")
(min-content (ebox--string-pixel-width "widest"))
(max-content (ebox--string-pixel-width content))
(stretch (+ min-content (/ (- max-content min-content) 2)))
(ebox-viewport-width stretch))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content) :width 'fit-content))
stretch))))
(ert-deftest ebox-min-and-max-width-keywords-constrain-preferred-width ()
"Intrinsic keywords should work on min-width and max-width too."
(ebox-test--reset-runtime-state)
(let* ((content "aa widest")
(min-content (ebox--string-pixel-width "widest"))
(max-content (ebox--string-pixel-width content))
(between (+ min-content (/ (- max-content min-content) 2))))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width '(1)
:min-width 'max-content))
max-content))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width (list (+ max-content 50))
:max-width 'min-content))
min-content))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width '(1)
:min-width (list 'fit-content
(list between))))
between))
(should (= (ebox-test--input-content-pixel
(ebox-test-box (ebox-test-text content)
:width (list (+ max-content 50))
:max-width (list 'fit-content
(list between))))
between))))
(ert-deftest ebox-stretch-and-contain-width-fill-viewport-margin-box ()
"stretch and contain should resolve against the current viewport."
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 240))
(dolist (keyword '(stretch contain))
(let ((box (ebox-test-box (ebox-test-text "x")
:width keyword
:padding-inline '(20)
:border-left '(10)
:border-right '(10)
:margin-inline '(5))))
(should (= (ebox-test--input-total-pixel box) 240))))))
(ert-deftest ebox-max-width-clamps-used-width-before-content-minimum ()
"A max-width smaller than one glyph should constrain used width."
(ebox-test--reset-runtime-state)
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (_string) 60))
((symbol-function 'ebox--max-char-pixel)
(lambda (_string) 60)))
(let ((box (ebox-test-box (ebox-test-text "W")
:max-width '(20)
:wrap-mode 'char)))
(should (= (ebox-test--input-content-pixel box) 20))
(should (= (ebox-test--input-total-pixel box) 20)))))
(ert-deftest ebox-min-width-wins-when-min-width-exceeds-max-width ()
"CSS width constraints make min-width override max-width conflicts."
(ebox-test--reset-runtime-state)
(let ((box (ebox-test-box (ebox-test-text "short")
:width '(40)
:min-width '(120)
:max-width '(80))))
(should (= (ebox-test--input-content-pixel box) 120))
(should (= (ebox-test--input-total-pixel box) 120))))
(ert-deftest ebox-width-properties-reject-negative-values ()
"Width, min-width, and max-width should reject negative lengths."
(ebox-test--reset-runtime-state)
(should-error (ebox-test-box (ebox-test-text "bad") :width '(-1)) :type 'error)
(should-error (ebox-test-box (ebox-test-text "bad") :min-width '(-1)) :type 'error)
(should-error (ebox-test-box (ebox-test-text "bad") :max-width '(-1)) :type 'error))
(ert-deftest ebox-edge-properties-reject-negative-values ()
"Public edge properties and engine longhands should reject negatives."
(ebox-test--reset-runtime-state)
(dolist (props '((:padding -1)
(:padding-left-pixel -1)
(:margin -1)
(:margin-top-height -1)
(:border-width -1)
(:border-top-width -1)
(:border-left-pixel -1)))
(should-error
(apply #'ebox-test-box (ebox-test-text "bad") props)
:type 'error)))
(ert-deftest ebox-line-min-content-skips-discarded-space-measurements ()
"Literal spaces split runs and plain ASCII reuses exact character widths."
(let ((line (copy-sequence "fi x"))
(ebox--display-cache-validated t)
(ebox--char-width-cache (make-hash-table :test 'eql))
measured)
(puthash ?i 4 ebox--char-width-cache)
(add-text-properties 0 1 '(face (:height 1.2)) line)
(add-text-properties 3 4 '(display (raise 0.1)) line)
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (string)
(push (substring-no-properties string) measured)
(pcase (substring-no-properties string)
("f" 3)
("x" 2)
(" " 99)
(_ 99)))))
;; Per-grapheme addition remains intentional: measuring the whole "fi"
;; could produce a non-additive ligature width and change the contract.
(should (= (ebox--line-min-content-pixel line) 7))
(should (equal (nreverse measured) '("f" "x"))))))
(ert-deftest ebox-region-update-valid-paint-and-invalid-edge-is-atomic ()
"Invalid geometry should fail before a valid sibling property mutates."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-test-box (ebox-test-text "Atomic")
:width '(120)
:bgcolor "#111111"
:padding-left 2))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(should-error
(ebox-test--region-update region-id
:bgcolor "#222222"
:padding-left -1)
:type 'error)
(should (equal (ebox-style-node-specified-value box :bgcolor)
"#111111"))
(should (= (ebox-style-node-specified-value
box :padding-left)
2)))))
(ert-deftest ebox-region-update-no-op-skips-rollback-snapshot ()
"A legal no-op should return before discovering rollback scope."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id "root" :width '(240) :height 1 :overflow 'scroll
(ebox-test-box :id "target" (ebox-test-text "same") :width
'(120)))))
(ebox-test--with-rendered-buffer layout
(let ((target-id
(ebox-test--selector-region-id (current-buffer) "#target")))
(cl-letf
(((symbol-function 'ebox--node-all-region-ids)
(lambda (&rest _)
(ert-fail "no-op discovered subtree rollback scope")))
((symbol-function
'ebox--scroll-state-region-ids-containing-node-ids)
(lambda (&rest _)
(ert-fail "no-op discovered containing scroll scope"))))
(let ((report
(ebox-test--region-update target-id :content "same")))
(should (eq (plist-get report :strategy) 'no-op))
(should (= (plist-get report :dirty-count) 0))))))))
(ert-deftest ebox-region-update-rollback-snapshot-keeps-scroll-owner-box-by-identity ()
"A child rollback snapshot should retain its scroll owner's box identity."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-test-box :id "root" :width '(viewport) :height
'(viewport-height) :overflow 'scroll
(ebox-test-column :width '(viewport)
(ebox-test-box :id "target" (ebox-test-text "paint") :width '(120)
:color "#111111")
(ebox-test-box (ebox-test-text "tail") :width
'(120))))))
(let ((ebox-viewport-width 240)
(ebox-viewport-height 1))
(ebox-test--with-rendered-buffer layout
(let* ((root-id
(ebox-test--selector-region-id (current-buffer) "#root"))
(target-id
(ebox-test--selector-region-id (current-buffer) "#target"))
(scroll-box
(plist-get (ebox--scroll-get-state root-id) :box))
(original-copy-tree (symbol-function 'copy-tree)))
(should scroll-box)
(cl-letf
(((symbol-function 'copy-tree)
(lambda (tree &optional vectors-and-records)
(when (eq tree scroll-box)
(ert-fail "deep-copied containing scroll owner box"))
(funcall original-copy-tree tree vectors-and-records))))
(ebox-test--region-update target-id :color "#222222"))
(should (equal
(ebox-get
(gethash target-id ebox--region-box-table) :color)
"#222222")))))))
(ert-deftest ebox-region-update-rolls-back-when-rerender-fails ()
"A failed local rerender must leave model, buffer, and report unchanged."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-test-box :id "target" (ebox-test-text "before") :width '(120)))
(report-before 'keep-report))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(region-id (ebox-test--selector-region-id buffer "#target"))
(box (gethash region-id ebox--region-box-table))
(box-before (copy-tree box))
(text-before (buffer-string)))
(ebox-test--set-buffer-update-report buffer report-before)
(let ((failure (lambda (&rest _)
(error "Injected rerender failure"))))
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
failure)
((symbol-function 'ebox-surface--render-candidate-node)
failure))
(should-error
(ebox-test--region-update region-id :content "after")
:type 'error)))
(should (equal box box-before))
(should (equal-including-properties (buffer-string) text-before))
(should (eq (ebox-buffer-update-report buffer) report-before))))))
(ert-deftest ebox-cjk-wraps-by-character-width-in-word-mode ()
(ebox-test--reset-runtime-state)
(let* ((content "你好世界")
(width (* 2 (ebox--string-pixel-width "")))
(lines (ebox-string-lines
(substring-no-properties
(ebox-render
(ebox-test-box (ebox-test-text content)
:width (list width)
:wrap-mode 'word))))))
(should (= (length lines) 2))
(should (string-prefix-p "你好" (nth 0 lines)))
(should (string-prefix-p "世界" (nth 1 lines)))
(dolist (line lines)
(should (<= (ebox--string-pixel-width line) width)))))
(ert-deftest ebox-wrap-keeps-combining-mark-with-base-character ()
(ebox-test--reset-runtime-state)
(let* ((cluster "")
(text (concat cluster cluster))
(width (ebox--string-pixel-width cluster)))
(should (equal (mapcar #'substring-no-properties
(ebox--wrap-line text width 'char))
(list cluster cluster)))))
(ert-deftest ebox-word-wrap-skips-overflowing-separator-at-line-break ()
(ebox-test--reset-runtime-state)
(let* ((text "normal behaves stretch-like for auto cross size in this DSL.")
(width (ebox--string-pixel-width "stretch-like for"))
(lines (ebox--wrap-line text width 'word)))
(dolist (line lines)
(should (<= (ebox--string-pixel-width line) width)))
(should-not (cl-some (lambda (line)
(string-prefix-p " " line))
lines))))
(ert-deftest ebox-wrap-keeps-emoji-zwj-and-regional-indicator-clusters ()
(ebox-test--reset-runtime-state)
(let ((family "👨‍👩‍👧‍👦")
(china "🇨🇳")
(usa "🇺🇸"))
(should (equal (mapcar #'substring-no-properties
(ebox--wrap-line (concat family family)
(ebox--string-pixel-width family)
'char))
(list family family)))
(should (equal (mapcar #'substring-no-properties
(ebox--wrap-line (concat china usa)
(ebox--string-pixel-width china)
'char))
(list china usa)))))
(ert-deftest ebox-kp-wrap-mode-invokes-ekp-justification ()
(ebox-test--reset-runtime-state)
(let (require-called justify-args)
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(when (eq feature 'ekp)
(setq require-called t))
t))
((symbol-function 'ekp-pixel-justify)
(lambda (text width)
(setq justify-args (list text width))
text)))
(ebox-render (ebox-test-box (ebox-test-text "abc")
:width '(100)
:wrap-mode 'kp)))
(should require-called)
(should (equal justify-args (list "abc" 100)))))
(ert-deftest ebox-kp-wrap-mode-respects-text-align ()
"KP-wrapped lines should honor the box's horizontal text alignment."
(ebox-test--reset-runtime-state)
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(when (eq feature 'ekp) t)))
((symbol-function 'ekp-pixel-justify)
(lambda (&rest _)
"abc\nx")))
(dolist (align '(center right))
(let* ((box (ebox-test-box (ebox-test-text "ignored")
:width '(100)
:wrap-mode 'kp
:text-align align))
(width (ebox-test--input-content-pixel box))
(rendered (ebox-render box))
(line (car (ebox-string-lines rendered)))
(expected (ebox--pixel-reach "abc" width align)))
(should (equal (get-text-property 0 'display line)
(get-text-property 0 'display expected)))))))
(ert-deftest ebox-kp-wrap-preserves-content-width-for-ragged-lines ()
(ebox-test--reset-runtime-state)
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(when (eq feature 'ekp) t)))
((symbol-function 'ekp-pixel-justify)
(lambda (_text width)
(concat (ebox-pixel-space width) "\nshort"))))
(let* ((rendered (ebox-render
(ebox-test-box (ebox-test-text "ignored")
:width '(100)
:wrap-mode 'kp)))
(widths (mapcar #'ebox-string-pixel-width
(ebox-string-lines rendered))))
(should (equal widths '(100 100))))))
(ert-deftest ebox-content-face-keeps-foreground-and-background ()
(ebox-test--reset-runtime-state)
(let* ((rendered (ebox-render
(ebox-test-box (ebox-test-text "Face")
:width 40
:color "#abcdef"
:bgcolor "#123456")))
(face (ebox-test--first-content-face rendered)))
(should (ebox-test--face-has-key-value-p face :foreground "#abcdef"))
(should (ebox-test--face-has-key-value-p face :background "#123456"))))
(ert-deftest ebox-border-faces-keep-side-specific-properties ()
(ebox-test--reset-runtime-state)
(let* ((rendered (ebox-render
(ebox-test-box (ebox-test-text "Border")
:width 60
:padding '(1 1)
:border '(1 "#ff0000"))))
(left-face (ebox-test--face-at-property rendered 'ebox-bl))
(right-face (ebox-test--face-at-property rendered 'ebox-br))
(top-face (ebox-test--face-at-property rendered 'ebox-bt))
(bottom-face (ebox-test--face-at-property rendered 'ebox-bb)))
(should-not (ebox-test--face-has-key-p left-face :inverse-video))
(should (ebox-test--face-has-key-value-p left-face :background "#ff0000"))
(should-not (ebox-test--face-has-key-p right-face :inverse-video))
(should (ebox-test--face-has-key-value-p right-face :background "#ff0000"))
(should (ebox-test--face-has-key-value-p top-face :overline "#ff0000"))
(should (ebox-test--face-has-key-value-p
bottom-face
:underline
'(:position t :color "#ff0000")))))
(ert-deftest ebox-border-faces-omit-unspecified-frame-colors ()
(ebox-test--reset-runtime-state)
(let ((original-frame-parameter (symbol-function 'frame-parameter)))
(dolist (sentinel '(unspecified unspecified-fg unspecified-bg
"unspecified" "unspecified-fg" "unspecified-bg"))
(cl-letf (((symbol-function 'frame-parameter)
(lambda (frame parameter)
(if (eq parameter 'foreground-color)
sentinel
(funcall original-frame-parameter frame parameter)))))
(let* ((rendered (ebox-render
(ebox-test-box (ebox-test-text "Border")
:width 60
:padding '(1 1)
:border '(1 solid))))
(left-face (ebox-test--face-at-property rendered 'ebox-bl))
(right-face (ebox-test--face-at-property rendered 'ebox-br))
(top-face (ebox-test--face-at-property rendered 'ebox-bt))
(bottom-face (ebox-test--face-at-property rendered 'ebox-bb)))
(should (ebox-test--face-has-key-value-p
left-face :inverse-video t))
(should (ebox-test--face-has-key-value-p
right-face :inverse-video t))
(should (ebox-test--face-has-key-value-p top-face :overline t))
(should (ebox-test--face-has-key-value-p
bottom-face :underline '(:position t))))))))
(ert-deftest ebox-horizontal-borders-cover-side-border-corners ()
(ebox-test--reset-runtime-state)
(require 'face-remap)
(let* ((text-scale-mode-amount 2)
(rendered (ebox-render
(ebox-test-box (ebox-test-text "Border")
:width 120
:padding '(0 (8))
:border '(1 "#ff0000"))))
(lines (ebox-string-lines rendered))
(top (car lines))
(bottom (car (last lines)))
(top-left (get-text-property 0 'face top))
(top-right (get-text-property (1- (length top)) 'face top))
(bottom-left (get-text-property 0 'face bottom))
(bottom-right (get-text-property (1- (length bottom)) 'face bottom)))
(dolist (face (list top-left top-right))
(should (ebox-test--face-has-key-value-p
face :background "#ff0000"))
(should (ebox-test--face-has-key-value-p face :overline "#ff0000")))
(dolist (face (list bottom-left bottom-right))
(should (ebox-test--face-has-key-value-p
face :background "#ff0000"))
(should (ebox-test--face-has-key-value-p
face :underline '(:position t :color "#ff0000"))))))
(ert-deftest ebox-horizontal-borders-cover-side-border-corners-when-shrunk ()
(ebox-test--reset-runtime-state)
(require 'face-remap)
(let* ((text-scale-mode-amount -2)
(rendered (ebox-render
(ebox-test-box (ebox-test-text "Border")
:width 120
:padding '(0 (8))
:border '(1 "#ff0000"))))
(lines (ebox-string-lines rendered))
(top (car lines))
(bottom (car (last lines)))
(top-left (get-text-property 0 'face top))
(top-right (get-text-property (1- (length top)) 'face top))
(bottom-left (get-text-property 0 'face bottom))
(bottom-right (get-text-property (1- (length bottom)) 'face bottom)))
(dolist (face (list top-left top-right))
(should (ebox-test--face-has-key-value-p
face :background "#ff0000"))
(should (ebox-test--face-has-key-value-p face :overline "#ff0000")))
(dolist (face (list bottom-left bottom-right))
(should (ebox-test--face-has-key-value-p
face :background "#ff0000"))
(should (ebox-test--face-has-key-value-p
face :underline '(:position t :color "#ff0000"))))))
(ert-deftest ebox-horizontal-borders-do-not-cover-horizontal-margins ()
(ebox-test--reset-runtime-state)
(let* ((rendered (ebox-render
(ebox-test-box (ebox-test-text "Border")
:width 80
:margin '(0 (8) 0 (8))
:border '(1 "#ff0000"))))
(lines (ebox-string-lines rendered))
(top (car lines))
(bottom (car (last lines)))
(top-left-margin (get-text-property 0 'face top))
(top-left-border (get-text-property 1 'face top))
(top-right-border (get-text-property (- (length top) 2) 'face top))
(top-right-margin (get-text-property (1- (length top)) 'face top))
(bottom-left-margin (get-text-property 0 'face bottom))
(bottom-left-border (get-text-property 1 'face bottom))
(bottom-right-border (get-text-property (- (length bottom) 2) 'face bottom))
(bottom-right-margin (get-text-property (1- (length bottom)) 'face bottom)))
(dolist (face (list top-left-border top-right-border))
(should (ebox-test--face-has-key-value-p
face :background "#ff0000"))
(should (ebox-test--face-has-key-value-p face :overline "#ff0000")))
(dolist (face (list bottom-left-border bottom-right-border))
(should (ebox-test--face-has-key-value-p
face :background "#ff0000"))
(should (ebox-test--face-has-key-value-p
face :underline '(:position t :color "#ff0000"))))
(dolist (face (list top-left-margin top-right-margin))
(should-not (ebox-test--face-has-key-p face :overline)))
(dolist (face (list bottom-left-margin bottom-right-margin))
(should-not (ebox-test--face-has-key-p face :underline)))))
(ert-deftest ebox-pixel-cache-invalidates-when-display-signature-changes ()
(ebox-test--reset-runtime-state)
(cl-letf (((symbol-function 'ebox--display-signature)
(let ((signature 'first))
(lambda () signature))))
(clrhash ebox--char-width-cache)
(ebox--clear-string-pixel-width-cache)
(setq ebox--space-pixel-cache nil)
(setq ebox--display-signature-cache nil)
(ebox--char-pixel-width ?A)
(should (= (hash-table-count ebox--char-width-cache) 1))
(should (> (hash-table-count ebox--string-pixel-width-cache) 0))
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda () 'second)))
(ebox--ensure-display-cache-current)
(should (= (hash-table-count ebox--char-width-cache) 0))
(should (= (hash-table-count ebox--string-pixel-width-cache) 0))
(should-not ebox--space-pixel-cache)
(should (equal ebox--display-signature-cache 'second)))))
(ert-deftest ebox-display-signature-includes-face-remapping ()
(ebox-test--reset-runtime-state)
(let ((plain (ebox--display-signature))
(face-remapping-alist '((default (:height 1.4)))))
(should-not (equal plain (ebox--display-signature)))))
(ert-deftest ebox-display-signature-ignores-tp-paint-slot-remapping ()
"Installing a TP paint layer must not invalidate geometry measurement."
(ebox-test--reset-runtime-state)
(let ((plain (ebox--display-signature))
(face-remapping-alist
'((tp-paint-slot-99 (:foreground "#123456")
(:background "#F8FAFC")))))
(should (equal plain (ebox--display-signature)))))
(ert-deftest ebox-pixel-width-respects-text-scale-with-fixed-spaces ()
(ebox-test--reset-runtime-state)
(require 'face-remap)
(let* ((plain (ebox--string-pixel-width "AAAA"))
(fixed (ebox-pixel-space 30))
(text-scale-mode-amount 2)
(scaled (ebox--string-pixel-width "AAAA"))
(scaled-with-fixed (ebox--string-pixel-width
(concat "AAAA" fixed))))
(should (> scaled plain))
(should (= scaled-with-fixed (+ scaled 30)))))
(ert-deftest ebox-pixel-width-caches-plain-lines-during-render-context ()
"Repeated plain text width checks should share the render-local cache."
(ebox-test--reset-runtime-state)
(let ((ebox--display-cache-validated t)
(ebox--render-string-pixel-width-cache (make-hash-table :test 'equal))
(ebox--string-pixel-width-cache (make-hash-table :test 'equal))
(ebox--string-pixel-width-cache-ring
(make-vector ebox--string-pixel-width-cache-max-entries nil))
(ebox--string-pixel-width-cache-ring-index 0)
(calls 0))
(cl-letf (((symbol-function 'string-pixel-width)
(lambda (string)
(cl-incf calls)
(length string))))
(should (= (ebox--string-pixel-width "cached") 6))
(should (= (ebox--string-pixel-width "cached") 6))
(should (= calls 1)))))
(ert-deftest ebox-pixel-width-reuses-plain-lines-across-render-transactions ()
"Stable display transactions should share plain string measurements."
(ebox-test--reset-runtime-state)
(let ((ebox--display-signature-cache 'stable)
(probe (format "cross-render-%S" (make-symbol "probe")))
(calls 0))
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda () 'stable))
((symbol-function 'string-pixel-width)
(lambda (string)
(cl-incf calls)
(length string))))
(dotimes (_ 2)
(ebox--with-validated-display-cache
(ebox--string-pixel-width probe)))
(should (= calls 1)))))
(ert-deftest ebox-pixel-width-reuses-face-only-lines-with-face-isolation ()
"Display-scoped measurements should reuse equal faces without collisions."
(ebox-test--reset-runtime-state)
(let ((ebox--display-signature-cache 'stable)
(bold (propertize "face-cache" 'face 'bold))
(italic (propertize "face-cache" 'face 'italic))
(calls 0))
(cl-letf (((symbol-function 'ebox--display-signature)
(lambda () 'stable))
((symbol-function 'string-pixel-width)
(lambda (string)
(cl-incf calls)
(if (eq (get-text-property 0 'face string) 'bold)
10
20))))
(should
(= (ebox--with-validated-display-cache
(ebox--string-pixel-width bold))
10))
(should
(= (ebox--with-validated-display-cache
(ebox--string-pixel-width bold))
10))
(should
(= (ebox--with-validated-display-cache
(ebox--string-pixel-width italic))
20))
(should (= calls 2)))))
(ert-deftest ebox-persistent-pixel-width-cache-stays-bounded ()
"Display-scoped reusable string widths should evict at their entry cap."
(ebox-test--reset-runtime-state)
(let ((ebox--display-cache-validated t)
(ebox--render-string-pixel-width-cache nil)
(ebox--string-pixel-width-cache (make-hash-table :test 'equal))
(ebox--string-pixel-width-cache-ring
(make-vector ebox--string-pixel-width-cache-max-entries nil))
(ebox--string-pixel-width-cache-ring-index 0))
(cl-letf (((symbol-function 'string-pixel-width)
(lambda (string) (length string))))
(dotimes (index (+ ebox--string-pixel-width-cache-max-entries 17))
(ebox--string-pixel-width (format "measurement-%d" index))))
(should
(= (hash-table-count ebox--string-pixel-width-cache)
ebox--string-pixel-width-cache-max-entries))))
(ert-deftest ebox-string-max-pixel-width-works-without-render-cache ()
"Multiline max-width measurement should not require a render-local cache."
(ebox-test--reset-runtime-state)
(let ((ebox--display-cache-validated t)
(ebox--render-string-max-pixel-width-cache nil))
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (line)
(length (substring-no-properties line)))))
(should (= (ebox--string-max-pixel-width "wide\nn") 4)))))
(ert-deftest ebox-flex-string-width-caches-rendered-strings ()
"Repeated flex width checks should reuse render-local rendered string widths."
(ebox-test--reset-runtime-state)
(let ((ebox--display-cache-validated t)
(ebox--render-string-pixel-width-cache (make-hash-table :test 'equal))
(ebox--render-string-max-pixel-width-cache (make-hash-table :test 'eq))
(rendered (concat (propertize "wide" 'face 'bold)
"\n"
(propertize "narrow" 'display '(space :width (2)))))
(calls 0))
(cl-letf (((symbol-function 'ebox--string-pixel-width)
(lambda (line)
(cl-incf calls)
(length (substring-no-properties line)))))
(should (= (ebox--flex-string-width rendered) 6))
(should (= (ebox--flex-string-width rendered) 6))
(should (= calls 2)))))
(ert-deftest ebox-face-height-width-detection-handles-shrinking-text ()
(ebox-test--reset-runtime-state)
(let ((ebox--face-height-width-cache nil)
(factor 0.7))
(cl-letf (((symbol-function 'string-pixel-width)
(lambda (string)
(if (get-text-property 0 'face string)
70
101))))
(should (ebox--face-height-affects-width-p factor))
(should (= (ebox--scaled-string-pixel-width "probe" factor)
70)))))
(ert-deftest ebox-visibility-hidden-suppresses-ink-and-keeps-footprint ()
"A hidden box renders blank with identical text, lengths, and metadata."
(let* ((make-box
(lambda (visibility)
(ebox-test-box (ebox-test-text "Hello") :width (list 90)
:border '(1 solid "#AA3311") :bgcolor "#223344"
:color "#FFFFFF" :padding '(0 1)
:visibility visibility)))
(visible (ebox-render (funcall make-box 'visible)))
(hidden (ebox-render (funcall make-box 'hidden))))
(should (equal (substring-no-properties visible)
(substring-no-properties hidden)))
(should (equal (mapcar #'length (ebox-string-lines visible))
(mapcar #'length (ebox-string-lines hidden))))
;; No character carries a face, every character renders under a
;; display spec, and each line's blank cover is measured to the
;; visible line's exact pixel width.
(dotimes (index (length hidden))
(unless (eq (aref hidden index) ?\n)
(should-not (get-text-property index 'face hidden))
(should (get-text-property index 'display hidden))))
(cl-loop for visible-line in (ebox-string-lines visible)
for hidden-line in (ebox-string-lines hidden)
do (should (= (ebox-string-pixel-width visible-line)
(ebox-string-pixel-width hidden-line))))
;; Region metadata still identifies the same owner surfaces at the
;; content characters.
(let ((position (string-match "Hello"
(substring-no-properties visible))))
(should position)
(should (get-text-property position 'ebox-content-owner visible))
(should (get-text-property position 'ebox-content-owner hidden)))))
(ert-deftest ebox-visibility-toggle-is-local-and-restores-exactly ()
"Hiding and showing one region never widens beyond its owner.
The buffer text never changes; showing again restores the visible
publication with properties intact."
(let* ((buffer (generate-new-buffer " *ebox-visibility*"))
(root (ebox-test-box
:width (list 300)
(ebox-test-column
(ebox-test-box (ebox-test-text "Header line") :id 'header
:width (list 300))
(ebox-test-box (ebox-test-text "Secret content") :id 'secret
:width (list 300) :border '(1 solid)
:bgcolor "#224466" :color "#FFFFFF")
(ebox-test-box (ebox-test-text "Footer line") :id 'footer
:width (list 300))))))
(unwind-protect
(progn
(ebox-render-to-buffer buffer root)
(let ((before (with-current-buffer buffer (buffer-string))))
(ebox-selector-update-buffer buffer "#secret"
:visibility 'hidden)
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender paint-patch)))
(should (= (plist-get report :patch-count) 1)))
(should (equal (with-current-buffer buffer
(substring-no-properties (buffer-string)))
(substring-no-properties before)))
(ebox-selector-update-buffer buffer "#secret"
:visibility 'visible)
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender paint-patch)))
(should (= (plist-get report :patch-count) 1)))
(should (equal-including-properties
(with-current-buffer buffer (buffer-string))
before))))
(kill-buffer buffer))))
(ert-deftest ebox-parent-border-color-patch-never-recolors-descendants ()
"A parent border-color paint patch touches only the owner's chrome.
Border surfaces belong to each box: a full render never propagates an
ancestor border color into a descendant's border runs, so the cheap
face patch must match that exactly (issue014)."
(ebox-test--reset-runtime-state)
(let* ((make-tree
(lambda (parent-color)
(ebox-test-box
:id 'parent :width (list 400)
:border (list 1 'solid parent-color)
:padding '(1 (12))
(ebox-test-column
(ebox-test-box :id 'child (ebox-test-text "CHILD") :width (list 200)
:border '(1 solid "#00AA00"))))))
(visual-runs
(lambda (buffer role)
(with-current-buffer buffer
(let (runs (position (point-min)))
(while (< position (point-max))
(when (get-text-property position role)
(push (list (line-number-at-pos position)
(get-text-property position role)
(ebox-test--face-visual-attributes
(get-text-property position 'face)))
runs))
(setq position
(or (next-single-property-change position role)
(point-max))))
(nreverse runs)))))
(patched (generate-new-buffer " *i14-patched*"))
(reference (generate-new-buffer " *i14-reference*")))
(unwind-protect
(progn
(ebox-render-to-buffer patched (funcall make-tree "#AA0000"))
(ebox-selector-update-buffer patched "#parent"
:border-color "#0000AA")
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
'paint-patch))
(ebox-test--reset-runtime-state)
(ebox-render-to-buffer reference (funcall make-tree "#0000AA"))
(dolist (role '(ebox-bl ebox-br ebox-bt ebox-bb))
(ert-info ((format "border role %S" role))
(should (equal
(mapcar (lambda (run) (cons (car run) (caddr run)))
(funcall visual-runs patched role))
(mapcar (lambda (run) (cons (car run) (caddr run)))
(funcall visual-runs reference role)))))))
(kill-buffer patched)
(kill-buffer reference))))
(ert-deftest ebox-typed-commits-equal-fresh-runtime-render ()
"Typed content, geometry, structure, and overflow commits stay exact."
(dolist
(case
'(((box :key root "Before")
(box :key root "After") "After")
((flex :key root :width (240)
(box :key item :width (80) "Item"))
(flex :key root :width (240)
(box :key item :width (120) "Item")) "Item")
((column :key root (box :key first "A"))
(column :key root
(box :key first "A") (box :key second "B")) "B")
((box :key root :height 1 :overflow visible "A\nB")
(box :key root :height 1 :overflow hidden "A\nB") "A")))
(let ((buffer (generate-new-buffer " *ebox-typed-fresh-equality*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer (ebox-build (nth 0 case)))
(let ((report (ebox-commit buffer (ebox-build (nth 1 case)))))
(should (plist-get report :runtime-published))
(with-current-buffer buffer
(should (string-match-p (nth 2 case) (buffer-string)))
(should
(ebox-test--buffer-propertized-matches-runtime-render-p)))))
(when (buffer-live-p buffer) (kill-buffer buffer))))))
(ert-deftest ebox-typed-commit-preserves-unaffected-source-range ()
"A keyed peer keeps identity and bounds across a sibling-only commit."
(let* ((buffer (generate-new-buffer " *ebox-typed-peer-range*"))
(before-root
(ebox-build
'(column :key root
(box :key target :id target "Before")
(box :key peer :id peer "Peer"))))
(after-root
(ebox-build
'(column :key root
(box :key target :id target "After!")
(box :key peer :id peer "Peer")))))
(unwind-protect
(progn
(ebox-render-to-buffer buffer before-root)
(let* ((before (car (ebox-selector-query-buffer buffer "#peer")))
(before-node (plist-get before :node-id))
(before-region (plist-get before :region-id))
(before-bounds
(ebox-surface-region-bounds buffer before-region)))
(ebox-commit buffer after-root)
(let ((after (car (ebox-selector-query-buffer buffer "#peer"))))
(should (= (plist-get after :node-id) before-node))
(should (= (plist-get after :region-id) before-region))
(should (equal (ebox-surface-region-bounds
buffer before-region)
before-bounds)))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-typed-scroll-window-stays-equal-to-runtime-render ()
"Scroll and viewport updates preserve visible text properties exactly."
(let ((buffer (generate-new-buffer " *ebox-typed-scroll-equality*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-build
'(column :id scroller :width (viewport) :height 3
:overflow scroll
(box "A") (box "B") (box "C") (box "D") (box "E"))))
(with-current-buffer buffer
(goto-char (point-min))
(ebox-scroll-down 1)
(should
(ebox-test--buffer-propertized-matches-runtime-render-p)))
(ebox-rerender-buffer-with-context buffer 320 3)
(with-current-buffer buffer
(should
(ebox-test--buffer-propertized-matches-runtime-render-p))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-typed-local-updates-keep-publication-scoped ()
"Paint and geometry updates keep an unrelated keyed peer outside full-root work."
(dolist (update '((:background-color "#DBEAFE") (:width (120))))
(let ((buffer (generate-new-buffer " *ebox-typed-scoped-update*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer
(ebox-build
'(row :width (260)
(box :id target :width (100) "Target")
(box :id peer :width (100) "Peer"))))
(let* ((target (ebox-region-resolve buffer "target"))
(peer (car (ebox-selector-query-buffer buffer "#peer")))
(peer-node-id (plist-get peer :node-id)))
(apply #'ebox-region-update target update)
(let ((report (ebox-buffer-update-report buffer))
(peer-after
(car (ebox-selector-query-buffer buffer "#peer"))))
(should-not (plist-get report :full-rerender))
(should-not (plist-get report :tp-full-root))
(should (> (or (plist-get report :tp-scope-count) 0) 0))
(should (= (plist-get peer-after :node-id) peer-node-id)))))
(when (buffer-live-p buffer) (kill-buffer buffer))))))
;;; ebox-core-render-tests.el ends here