ebox/tests/ebox-core-render-tests.el
2026-08-26 00:09:53 +08:00

14762 lines
684 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.
(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--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-flex-rows-demand-driven ()
"Root reflow should not cross an explicit flex item's atomic row eagerly."
(ebox-test--reset-runtime-state)
(let (plain-buffer flex-buffer)
(unwind-protect
(progn
(setq plain-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-plain-lookahead*")
(ebox-create :content "plain" :width 120)))
(setq flex-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-flex-lookahead*")
(ebox-flex
:width '(120)
(ebox-flex-item
(ebox-create :content "item" :width '(40))))))
(should
(= (ebox--root-reflow-scroll-lookahead-lines-for-buffer
plain-buffer)
ebox--root-reflow-scroll-lookahead-lines))
(should
(= (ebox--root-reflow-scroll-lookahead-lines-for-buffer
flex-buffer)
0)))
(dolist (buffer (list plain-buffer flex-buffer))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))))
(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-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-create :content "" :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
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" "#123456")
(:bgcolor "#FDE68A" "#FDE68A")
(:border-left-color "#111111" "#111111")
(:border-right-color "#222222" "#222222")
(:border-top-color "#333333" "#333333")
(:border-bottom-color "#444444" "#444444")
(:border-top-p t t)
(:border-bottom-p t t))
"Paint-only box longhand update cases: (PROPERTY VALUE EXPECTED).")
(defconst ebox-test--region-update-geometry-cases
'((:box-sizing content-box content-box owner-rerender)
(:width (120) 120 owner-rerender)
(:min-width (100) 100 owner-rerender)
(:max-width (140) 140 owner-rerender)
(:height 3 3 owner-rerender)
(:min-height 3 3 owner-rerender)
(:max-height 1 1 owner-rerender)
(:padding-left-pixel 9 9 span-patch)
(:padding-right-pixel 10 10 span-patch)
(:padding-top-height 1 1 span-patch)
(:padding-bottom-height 1 1 span-patch)
(:margin-left-pixel 7 7 owner-rerender)
(:margin-right-pixel 8 8 owner-rerender)
(:margin-top-height 1 1 owner-rerender)
(:margin-bottom-height 1 1 owner-rerender)
(:border-left-pixel 5 5 span-patch)
(:border-right-pixel 6 6 span-patch)
(:text-align center center span-patch)
(:vertical-align middle center span-patch)
(:overflow hidden hidden owner-rerender)
(:wrap-mode char char span-patch)
(:scroll-offset 1 1 span-patch)
(:visibility hidden hidden span-patch))
"Geometry box longhand update cases: (PROPERTY VALUE 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-build
'(box :width (viewport) :height (viewport-height)
:bgcolor "#151A23"
(column
(box :width (viewport) :height 1 :bgcolor "#202938"
"surface")
(box :width (viewport) :height 1 "transparent")
(box :width (viewport) :height 1 :bgcolor "#29364A"
"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-create :class 'card :content "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-ecss-value-resolver-uses-tp-computed-contract ()
"ECSS should resolve tagged TP values while preserving literal functions."
(let ((calls 0)
(literal (lambda () 'literal)))
(should
(= (plist-get
(ebox-style-compute
(list :font-height
(tp-computed (lambda () (cl-incf calls) 1.25))))
:font-height)
1.25))
(should (= calls 1))
(should
(eq (plist-get (ebox-style-compute (list :font-height literal))
:font-height)
literal))))
(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 (= 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-cascade-inherits-color-but-honors-explicit-nil ()
"A child nil color should block inheritance without inheriting background."
(ebox-test--reset-runtime-state)
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(child (ebox-create :id 'child :content "Child" :color nil
:width '(96)))
(root (ebox-create :class 'panel :ebox-content-node child
:wrap-mode nil :width '(96))))
(ebox-style-add-rule
".panel" '(:color "#E2E8F0" :bgcolor "#0F172A")
:layer 'components)
(ebox-test--with-rendered-buffer root
(let* ((runtime-root (ebox-test--runtime-root (current-buffer)))
(runtime-child (plist-get runtime-root :ebox-content-node))
(child-style (plist-get runtime-child :ebox-computed-style))
(values (ecss-computed-style-values child-style))
(active (ecss-computed-style-active-properties child-style)))
(should (memq 'ebox/color active))
(should (null (plist-get values 'ebox/color)))
(should (null (plist-get values 'ebox/background-color)))
(should-not (memq 'ebox/background-color active))
(goto-char (point-min))
(search-forward "Child")
(let ((face (get-text-property (match-beginning 0) 'face)))
(should (equal (car face) '(:inherit default))))))))
(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-flex
(ebox-create :content "A")
(ebox-create :content "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-create :class 'card :content "Rule"))
(inline-node
(ebox-create :class 'card :content "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-grid
(ebox-create :class 'placed :content "Placed")
(ebox-create :content "Auto"))
(let* ((root (ebox-test--runtime-root (current-buffer)))
(placed (car (plist-get root :children))))
(should (equal (plist-get (plist-get root :props)
:grid-template-columns)
'((40) (40))))
(should (equal (plist-get (plist-get root :props) :grid-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* ((child
(ebox-create :id 'child :content "MMMM"
:font-family "Child Family"))
(root
(ebox-create :ebox-content-node child :wrap-mode nil
:font-family "Parent Family"
:font-height 1.25 :font-weight 'bold))
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 (plist-get runtime-root :ebox-content-node))
(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-height) 1.25))
(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) 1.25))
(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) 1.25))))
(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-build
`(column :width (120) :height 5 :padding-block 1
:bgcolor ,parent
(box :width (120) :height 1 :margin-bottom 1
:bgcolor ,child "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-create :id 'target :content "Paint" :width '(60)
:color "#E2E8F0" :bgcolor "#1E293B"))
(root
(ebox-flex
:width '(160)
(ebox-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-region-ids-assigns-stable-ids-before-render ()
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "L" :width 40))
(right (ebox-create :content "R" :width 50))
(layout (ebox-concat left right))
(ids-before (ebox-region-ids layout)))
(should (equal ids-before '(1 2)))
(should (equal (ebox-region-ids layout) ids-before))
(should (equal (ebox-region-ids (ebox-render layout)) ids-before))))
(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-build
'(box :id "root" :content "before\nhidden" :width (120)
:color "#111111" :height 1 :overflow scroll)))
(buffer (generate-new-buffer " *ebox-live-copy-source*")))
(unwind-protect
(progn
(should-not (plist-member source :region-id))
(ebox-render-to-buffer buffer source)
(let* ((runtime-root (ebox-test--runtime-root buffer))
(handle (ebox-region-resolve buffer "root")))
(should-not (eq runtime-root source))
(ebox-test--region-update handle
:content "after\nhidden"
:color "#222222"
:width '(140))
(setq runtime-root (ebox-test--runtime-root buffer))
(should-not (plist-member source :region-id))
(should-not (plist-member source :node-id))
(should (equal (ebox-get source :content) "before\nhidden"))
(should (equal (ebox-get source :color) "#111111"))
(should (equal (ebox-get source :width) 120))
(should (equal (ebox-get source :scroll-offset) 0))
(should (equal (ebox-get runtime-root :content) "after\nhidden"))
(should (equal (ebox-get runtime-root :color) "#222222"))
(should (equal (ebox-get runtime-root :width) 140))
(with-current-buffer buffer
(should (string-match-p "after"
(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-build
'(box :id "root" :content "owned\nhidden" :width (120)
:height 1 :overflow scroll)))
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))
(let ((first-handle (ebox-region-resolve first-buffer "root"))
(second-handle (ebox-region-resolve second-buffer "root")))
(should-not (eq first-handle second-handle))
(ebox-test--region-update first-handle :content "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-test--region-update second-handle :content "second changed")
(with-current-buffer second-buffer
(should (string-match-p "second changed" (buffer-string))))
(should (equal (ebox-get source :content) "owned\nhidden"))
(should-not (plist-member source :region-id))))
(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-create :id "old" :content "old" :width 120))
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 :content "stale")
:type 'user-error))
(setq secondary-buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-live-secondary*")
old-source))
(ebox-test--region-update (ebox-region-resolve secondary-buffer "old")
:content "reused")
(with-current-buffer secondary-buffer
(should (string-match-p "reused" (buffer-string))))
(should (equal (ebox-get old-source :content) "old")))
(dolist (buffer (list primary-buffer secondary-buffer))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))))
(ert-deftest ebox-runtime-node-ids-cover-layout-and-box-nodes ()
"Every runtime node should have a stable internal node id."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-stack
(ebox-create :content "Top" :width 60)
(ebox-concat
(ebox-create :content "Left" :width 40)
(ebox-create :content "Right" :width 40))))
(ids-before (ebox--runtime-node-ids layout)))
(should (= (length ids-before) 5))
(should (= (length (delete-dups (copy-sequence ids-before))) 5))
(ebox-render layout)
(should (equal (ebox--runtime-node-ids layout) ids-before))))
(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-build
'(column :padding 1
(box :content "One")
(box :content "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-maps-boxes-and-flex-wrapper ()
"Region lookup should map each box to its smallest renderable owner."
(ebox-test--reset-runtime-state)
(let* ((plain (ebox-create :id "plain" :content "Plain" :width '(80)))
(flex-child (ebox-create :id "child" :content "Flex child"
:width '(80)))
(flex (ebox-flex :width '(120) :padding 1 flex-child))
(layout (ebox-column plain flex)))
(ebox-test--with-rendered-buffer layout
(let* ((buffer (current-buffer))
(runtime-root (ebox-test--runtime-root buffer))
(runtime-plain (car (plist-get runtime-root :children)))
(runtime-flex (cadr (plist-get runtime-root :children)))
(runtime-wrapper (plist-get runtime-flex :box))
(runtime-child (car (plist-get runtime-flex :children)))
(table (ebox--buffer-region-node-table buffer))
(plain-region-id (plist-get runtime-plain :region-id))
(wrapper-region-id (plist-get runtime-wrapper :region-id))
(child-region-id (plist-get runtime-child :region-id))
(plain-node-id (plist-get runtime-plain :node-id))
(flex-node-id (plist-get runtime-flex :node-id))
(wrapper-node-id (plist-get runtime-wrapper :node-id))
(child-node-id (plist-get runtime-child :node-id)))
(should (hash-table-p table))
(should (equal (gethash plain-region-id table) plain-node-id))
(should (equal (gethash wrapper-region-id table) flex-node-id))
(should-not (equal (gethash wrapper-region-id table)
wrapper-node-id))
(should (equal (gethash child-region-id table) child-node-id))
(should (eq (ebox--buffer-region-render-owner-node
buffer plain-region-id)
runtime-plain))
(should (eq (ebox--buffer-region-render-owner-node
buffer wrapper-region-id)
runtime-flex))
(should (eq (ebox--buffer-region-render-owner-node
buffer child-region-id)
runtime-child))))))
(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-create :content "First" :width '(80)))
(flex (ebox-flex :width '(120)
(ebox-create :content "Later" :width '(80))))
(wrapper (plist-get flex :box))
(layout (ebox-column plain flex)))
(ebox-put plain :region-id region-id)
(ebox-put wrapper :region-id region-id)
(let* ((index (ebox--runtime-index layout))
(table (plist-get index :region-node-table))
(expected (car (ebox--node-path-to-region layout region-id))))
(should (eq expected plain))
(should (equal (gethash region-id table)
(ebox--ensure-node-id expected))))))
(ert-deftest ebox-region-render-owner-index-keeps-outer-flex-duplicate ()
"Nested duplicate flex wrappers should retain recursive first-match lookup."
(ebox-test--reset-runtime-state)
(let* ((region-id 9002)
(inner (ebox-flex :width '(100) :padding 1
(ebox-create :content "Inner" :width '(80))))
(outer (ebox-flex :width '(140) :padding 1 inner))
(inner-wrapper (plist-get inner :box))
(outer-wrapper (plist-get outer :box)))
(ebox-put inner-wrapper :region-id region-id)
(ebox-put outer-wrapper :region-id region-id)
(let* ((index (ebox--runtime-index outer))
(table (plist-get index :region-node-table))
(expected (car (ebox--node-path-to-region outer region-id))))
(should (eq expected outer))
(should (equal (gethash region-id table)
(ebox--ensure-node-id expected))))))
(ert-deftest ebox-runtime-index-region-box-table-matches-complete-render ()
"Runtime indexing should collect the box table with render overwrite order."
(ebox-test--reset-runtime-state)
(let* ((shared-region-id 9003)
(inner (ebox-create :content "Inner" :width '(60)))
(outer (ebox-create :content "" :width '(90)))
(flex-child (ebox-create :content "Flex child" :width '(50)))
(flex (ebox-flex :width '(120) :padding 1 flex-child))
(layout (ebox-column outer flex))
(render-table (make-hash-table :test 'equal)))
(plist-put outer :ebox-content-node inner)
;; Complete rendering records parents after their child content. A
;; duplicated legacy id therefore resolves to OUTER in the box table.
(ebox-put inner :region-id shared-region-id)
(ebox-put outer :region-id shared-region-id)
(let* ((index (ebox--runtime-index layout t))
(index-table (plist-get index :region-box-table))
(ebox--region-box-table render-table)
(ebox--surface-materialization-active t)
(ebox--scroll-global-state (make-hash-table :test 'equal)))
(ebox--render-layout layout)
(should (= (hash-table-count index-table)
(hash-table-count render-table)))
(maphash
(lambda (region-id box)
(should (eq (gethash region-id index-table) box)))
render-table)
(should (eq (gethash shared-region-id index-table) outer))
(should
(eq (gethash (ebox--ensure-region-id (plist-get flex :box))
index-table)
(plist-get flex :box))))))
(ert-deftest ebox-declarative-candidate-reuses-indexed-region-box-table ()
"Candidate preparation should not rewalk the tree to collect source boxes."
(ebox-test--reset-runtime-state)
(let ((root (ebox-column
(ebox-create :key 'first :content "First" :width '(80))
(ebox-create :key 'second :content "Second" :width '(80))))
(next-root
(ebox-column
(ebox-create :key 'first :content "Changed" :width '(80))
(ebox-create :key 'second :content "Second" :width '(80)))))
(ebox-test--with-rendered-buffer root
(let* ((buffer (current-buffer))
(old-state (ebox--buffer-render-state buffer))
(recache-calls 0)
prepared
candidate-state)
(cl-letf (((symbol-function 'ebox--flex-recache-source-boxes)
(lambda (&rest _)
(cl-incf recache-calls))))
(setq prepared
(ebox-incremental--prepare-declarative-root
buffer old-state next-root))
(setq candidate-state
(ebox-incremental--candidate-state
old-state
(plist-get prepared :root)
(plist-get prepared :index)
prepared)))
(should (= recache-calls 0))
(should
(eq (plist-get candidate-state :region-box-table)
(plist-get (plist-get prepared :index) :region-box-table)))
(should (= (hash-table-count
(plist-get candidate-state :region-box-table))
2))))))
(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-create :content "Flex child" :width '(80)))
(flex (ebox-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-flex
:flex-wrap 'wrap
(ebox-create :content "A" :width '(100))
(ebox-create :content "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 (ebox--runtime-tree
(ebox-column
(ebox-create :key 'a :content "A" :width 40)
(ebox-create :key 'b :content "B" :width 40))))
(old-b-id (ebox--runtime-node-id-by-key old 'b))
(new (ebox--reconcile-runtime-tree
old
(ebox-column
(ebox-create :key 'x :content "X" :width 40)
(ebox-create :key 'b :content "B2" :width 40)))))
(should (equal (ebox--runtime-node-id-by-key new 'b) 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-build
'(column :padding 1
(box :content "Child" :width (120)))))
(children (ebox-tree-children 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-replaces-flex-item-node ()
"A flex-item copy should replace its wrapped node by identity."
(let* ((old-child (list :ebox-type 'box :content "old"))
(new-child (list :ebox-type 'box :content "new"))
(props (list :flex-grow 1))
(node (list :ebox-type 'flex-item :node old-child :props props))
(copy
(ebox-tree-copy-with-direct-child-replacements
node (list (cons old-child new-child)))))
(should-not (eq copy node))
(should (eq (plist-get copy :node) new-child))
(should (eq (plist-get copy :props) props))))
(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-create :content "old" :scroll-offset 0))
(node-id (ebox--ensure-node-id old-box))
(candidate-box (ebox-create :content "candidate" :scroll-offset 0))
(node-table (make-hash-table :test 'eql))
(template
(list :box old-box
: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-box :node-id node-id)
(puthash node-id candidate-box 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-box))
(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-create :content "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-build
`(box :id "root" :width (viewport)
(flex :width (viewport) :gap 1
,@(cl-loop for index below 8
collect
`(box :id ,(format "card-%d" index)
:content ,(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-build
`(column :id "viewport" :width (viewport)
(box :id "scroll" :width (viewport) :height 4
:overflow scroll
(column :width (viewport)
,@(cl-loop for index below 32
collect
`(box :content ,(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-build
'(box :id "root" :width (240)
(box :id "child" :content "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-build '(box :id "root" :width (240) :content "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)
(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-build
'(box :id "root" :width (viewport)
(box :content "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-build
'(box :id "root" :width (240)
(box :content "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-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-create :content "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-own-color-copy-in-place ()
"Colored scroll lines should add ownership to the color copy in place."
(let* ((box (ebox-create :width 120
:color "#112233"
:bgcolor "#F4F5F6"))
(region-id 901)
(source (propertize "child" 'ebox-content 77
'ebox-content-owner 77
'ebox-content-owners '(77)))
(source-before (copy-sequence source))
(filler (ebox-pixel-space (ebox--content-pixel box)))
(expected
(ebox--propertize-colors
(ebox--propertize-content-line source region-id 4 filler)
(ebox-get box :color)
(ebox-get 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
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-create :content "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-create :content "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-stronger-same-owner-operation ()
"Same-owner paint and geometry must preserve the stronger operation."
(dolist (order '((paint geometry) (geometry paint)))
(ebox-test--reset-runtime-state)
(let* ((box (ebox-create :content "Batch width" :width '(160)
:color "black"))
(id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(let ((buffer (current-buffer)))
(ebox-incremental-begin-batch buffer)
(dolist (operation order)
(pcase operation
('paint (ebox-test--region-update id :color "red"))
('geometry (ebox-test--region-update id :width '(80)))))
(ebox-incremental-flush buffer))
(let ((report (ebox-test--buffer-update-report)))
(let ((runtime-box
(gethash id (ebox--buffer-region-box-table
(current-buffer)))))
(should (equal (ebox-get box :width) 160))
(should (= (ebox-get runtime-box :width) 80)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(should-not (eq (plist-get report :strategy) 'paint-patch)))))))
(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-build
'(box :id parent :width (120) :color "black"
(box :id child :content "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-build
'(box :id parent :width (160) :color "black"
(box :id child :content "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-build
'(flex :id matrix :width (500) :flex-flow (row wrap)
:column-gap (12) :row-gap 1 :padding (0 (12))
:border "#999999"
(column :width (210) :border "#999999"
(box :content "Title A" :width (190))
(box :id target-a :content "Target A" :width (160)
:height 3 :padding (0 (8)) :border "#999999"
:text-align right)
(box :content "Metric A" :width (190) :height 2))
(column :width (210) :border "#999999"
(box :content "Peer A" :width (190))
(box :content "Same first row" :width (190) :height 3)
(box :content "Metric B" :width (190) :height 2))
(column :width (210) :border "#999999"
(box :content "Title B" :width (190))
(box :id target-b :content "Target B" :width (160)
:height 3 :padding (0 (8)) :border "#999999"
:text-align right)
(box :content "Metric C" :width (190) :height 2))
(column :width (210) :border "#999999"
(box :content "Peer B" :width (190))
(box :content "Same second row" :width (190) :height 3)
(box :content "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-build
'(column
(row
(box :content "A" :width (40))
(box :content "B" :width (40))))))))
(should (string-match-p "A" rendered))
(should (string-match-p "B" rendered))))
(ert-deftest ebox-layout-snapshot-details-are-computed-lazily ()
"Initial render should store cheap snapshots and complete spans on demand."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "L" :width 40 :border t))
(right (ebox-create :content "R" :width 40 :border t))
(layout (ebox-concat left right))
buffer)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(let* ((root-id (plist-get (ebox-test--runtime-root buffer)
:node-id))
(snapshot (ebox--layout-snapshot buffer root-id)))
(should snapshot)
(should (eq (plist-get snapshot :type) 'concat))
(should-not (plist-member snapshot :line-signature))
(should-not (plist-member snapshot :buffer-spans))
(let ((detailed
(ebox--ensure-layout-snapshot-details buffer root-id)))
(should (plist-get detailed :line-signature))
(should (plist-get detailed :buffer-spans))
(should (plist-member
(ebox--layout-snapshot buffer root-id)
:line-signature)))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(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-create :content "Footprint" :width '(120)
:height 1 :border t))
(node-id (ebox--ensure-node-id 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-role-topology-signature-uses-published-span-index ()
"Role topology should reflect the published TP mount index."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-create :content "Indexed" :width '(120) :border t))
(region-id (ebox--ensure-region-id box)))
(ebox-test--with-rendered-buffer box
(let ((roles (plist-get (ebox--role-topology-signature
(list region-id))
:roles)))
(should (ebox-surface-region-mounts (current-buffer) region-id))
(should (memq 'content roles))
(should (memq 'content-owner roles))))))
(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-column
(cl-loop for index below 8
collect
(ebox-flex
:border "#64748B" :bgcolor "#F8FAFC"
:flex-flow '(row wrap)
(ebox-create
:content (format "A%d" index)
:width 60 :bgcolor "#DBEAFE")
(ebox-create
:content (format "B%d" index)
:width 60 :bgcolor "#DCFCE7")
(ebox-create
:content (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-column
(cl-loop for index below 12
collect
(ebox-create
:content (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-layout-snapshot-records-display-style-and-wrapper-children ()
"Snapshots should describe display, style, and child ids for wrapper boxes."
(ebox-test--reset-runtime-state)
(let* ((node (ebox-build
'(column :padding 1 :bgcolor "linen"
(box :content "Child A" :width (120))
(box :content "Child B" :width (120)))))
buffer)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
node))
(let* ((snapshot (ebox--layout-snapshot
buffer
(plist-get (ebox-test--runtime-root buffer)
:node-id)))
(child-ids (plist-get snapshot :child-ids))
(child-snapshot (ebox--layout-snapshot buffer (car child-ids))))
(should (equal (plist-get snapshot :display) '(block flow)))
(should (equal (plist-get child-snapshot :display)
'(block column)))
(should (plist-get snapshot :style-signature))
(should child-ids)
(should (= (length child-ids) 1))))
(when (and buffer (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-create :content "Paint" :width 80
:border t :border-color "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-flex :flex-wrap 'wrap
(ebox-create :content "A" :width '(120))
(ebox-create :content "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-build
'(column :padding 1
(box :content "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-create :content "Paint" :width 80
:border t :border-color "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-flex
:border "#5E7F6A" :bgcolor "#F8FCF8"
(ebox-create :content "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-patch-set-promotes-interleaved-row-child-to-row-owner ()
"Interleaved row children should promote to their common render owner."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "Left" :width 60))
(right (ebox-create :content "Right" :width 60))
(layout (ebox-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 (memq (plist-get report :owner-type) '(concat row)))
(should-not (eq (plist-get report :owner-type) 'box))))))
(ert-deftest ebox-viewport-resize-patches-single-dependent-box ()
"Viewport resize should patch the smallest dependent owner when possible."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-column
(ebox-create :content "Fixed" :width '(120) :border t)
(ebox-create :content "Fluid" :width 'stretch :border t)))
buffer
ids-before)
(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-region-ids layout))
(let ((returned
(ebox-rerender-buffer-with-context buffer 260)))
(should (equal returned
(ebox-buffer-update-report buffer))))
(should (equal (ebox-region-ids layout) ids-before))
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender root-rerender)))
(should (plist-get report :dirty-count))
(should (= (plist-get report :dirty-count) 1))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(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-flex :flex-wrap 'wrap
(ebox-create :content "A" :width '(120))
(ebox-create :content "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-create
:content "Top text should wrap into more lines when narrow."
:wrap-mode 'word))
(bottom (ebox-create
:content "Bottom text should also survive after top grows."
:wrap-mode 'word))
(layout (ebox-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-create :content "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-create :content "Left" :width 60))
(right (ebox-create :content "Right" :width 60))
(layout (ebox-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-build
'(box :id "root" :width (240) :height (viewport-height)
:overflow scroll
(column
(box :content "first" :width (viewport))
(box :content "second" :width (viewport))
(box :content "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-region-width-update-promotes-parent-layout-owner ()
"Child geometry changes should patch the parent layout that reflows siblings."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "Left" :width 60))
(right (ebox-create :content "Right" :width 60))
(layout (ebox-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-owner-type) 'box))
(should (eq (plist-get report :owner-type) 'concat))
(should (equal (plist-get report :patch-ops)
'(owner-rerender))))
(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-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-build
'(column :padding 1
(row
(box :content "Left" :width (60))
(box :content "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-build
'(column
(box :content "Stable header" :width (180))
(column :padding 1
(box :content "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-row
(cl-loop for index below 24
collect
(ebox-create
:content (format "Fluid %02d" index)
:border t))))
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-column
(cl-loop for index below 12
collect
(ebox-create
:content (format "Fluid section %02d" index)
:border t))))
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-column
(cl-loop for _index below 12
collect
(ebox-create
:content content
:width 'viewport
:wrap-mode 'word
:border t))))
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-column
(cl-loop for _index below 12
collect
(ebox-create
:content content
:width 'viewport
:wrap-mode 'word
:border t))))
(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-create :content "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-create :content "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-create :content "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-create :content "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-create :content "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-create :content "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-create :width '(viewport) :content "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-create :width '(viewport) :content "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-column
(cl-loop for section below 8
collect
(apply
#'ebox-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(cl-loop for index below 4
collect
(ebox-create
:content (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-column
(cl-loop for section below 8
collect
(apply
#'ebox-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(cl-loop for index below 4
collect
(ebox-create
:content (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-build
'(column :id "root" :width (480)
(box :id "target" :content "mixed padding"
:width (220) :height 3 :box-sizing border-box
:padding (0 (8)) :border "#999999")
(box :content "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 role ranges should be numeric snapshots of TP-owned mounts."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(column :id "root" :width (480)
(box :id "target" :content "target" :width (220)
:height 1 :border "#999999")
(box :id "peer" :content "peer" :width (220)
:height 1 :border "#999999")))))
(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 '(content)))
(peer-mounts
(ebox-surface-region-mounts
(current-buffer) peer-id '(content))))
(dolist (mount (append target-mounts peer-mounts))
(should (integerp (plist-get mount :start)))
(should (integerp (plist-get mount :end)))
(should-not (markerp (plist-get mount :start)))
(should-not (markerp (plist-get mount :end))))
(should target-mounts)
(should peer-mounts)
(should (ebox-surface-region-bounds
(current-buffer) target-id '(content)))))))
(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-row
(cl-loop for section below 8
collect
(apply
#'ebox-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(cl-loop for index below 4
collect
(ebox-create
:content (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-flex
:flex-wrap 'wrap
(cl-loop for index below 12
collect
(ebox-create
:content (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-flex
:flex-wrap 'wrap
:height 4
:align-items 'stretch
(cl-loop for index below 10
collect
(ebox-create
:content (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-flex
:flex-wrap 'wrap
(cl-loop for index below 12
collect
(ebox-create
:content (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-flex
:width 180 :height 3
:flex-flow '(row wrap)
:column-gap '(8)
(ebox-create
:content (format "A%d" index)
:width 48 :height 1)
(ebox-create
:content (format "B%d" index)
:width 48 :height 1))))
(layout
(apply
#'ebox-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(mapcar (lambda (inner)
(ebox-column
(ebox-create :content "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-flex
:flex-flow '(row wrap)
:column-gap '(8)
(cl-loop for index below 6
collect
(ebox-column
(ebox-create :content (format "Card %d" index)
:width 96 :height 1)
(ebox-create :content "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-uses-boolean-viewport-dependency-cache ()
"Render cacheability should not enumerate viewport-dependent node ids."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-column
(ebox-create :content "Static" :width 120)
(ebox-create :content "Also static" :width 120)))
(calls 0)
(original (symbol-function 'ebox--viewport-dependent-node-ids)))
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--viewport-dependent-subtree-cache
(make-hash-table :test 'eq)))
(cl-letf (((symbol-function 'ebox--viewport-dependent-node-ids)
(lambda (&rest args)
(cl-incf calls)
(apply original args))))
(should (ebox--render-cacheable-node-p layout))
(should (ebox--render-cacheable-node-p layout)))
(should (= calls 0)))))
(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-build
'(column :padding 1
(box :content "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-build
'(column :width (210) :padding 1
(box :content "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-create :content "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-build
'(column :padding 1
(box :content "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-build
'(column :width (210) :padding 1
(box :content "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-build
'(column :width (120)
(box :id child
:content "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 (ebox-create :content "Width" :width '(viewport)))
(height-only (ebox-create :content "Height"
:width '(120)
:height '(viewport-height)))
(both (ebox-create :content "Both"
:width '(viewport)
:height '(viewport-height)))
(layout (ebox-column width-only height-only both)))
(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-build
'(column :width (120) :height (viewport-height)
(box :content "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-create :content "Width" :width '(viewport)))
(ordinary-height-nodes
(cl-loop for (property value)
in '((:height (viewport-height))
(:min-height (- (viewport-height) 1))
(:max-height (+ (viewport-height) 1)))
collect
(apply #'ebox-create
:content "Ordinary"
(list property value))))
(flex-height-nodes
(cl-loop for (property value)
in '((:height (viewport-height))
(:min-height (- (viewport-height) 1))
(:max-height (+ (viewport-height) 1)))
collect
(apply #'ebox-flex
(append
(list property value)
(list (ebox-create :content "Flex"))))))
(layout
(apply #'ebox-column
(append (list width-only)
ordinary-height-nodes
flex-height-nodes)))
(node (ebox-create :width '(210)
:wrap-mode nil
:ebox-content-node 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 width-only :node-id) ids))
(dolist (vertical-node (append ordinary-height-nodes flex-height-nodes))
(should (member (plist-get vertical-node :node-id) ids)))))
(ert-deftest ebox-viewport-dependencies-include-direct-flex-basis ()
"Viewport dependency discovery should include direct child flex-basis props."
(ebox-test--reset-runtime-state)
(let* ((child (ebox-create :content "A" :width 80
:flex-basis '(viewport)))
(node (ebox-flex :width 300
child
(ebox-create :content "B" :width 80)))
(ids (let ((ebox-viewport-width 360))
(ebox--runtime-node-ids node)
(ebox--viewport-dependent-node-ids node))))
(should (member (plist-get child :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 (ebox-create :content "A" :width 80))
(item (ebox-flex-item child))
(node (ebox-flex :width 240 item))
(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-viewport-dependencies-include-flex-item-viewport-basis ()
"Flex item wrappers should stay dirty when their flex-basis uses viewport."
(ebox-test--reset-runtime-state)
(let* ((child (ebox-create :content "A" :width 80))
(item (ebox-flex-item child :flex-basis '(viewport)))
(node (ebox-flex :width 240 item))
(ids (let ((ebox-viewport-width 360))
(ebox--runtime-node-ids node)
(ebox--viewport-dependent-node-ids node))))
(should (member (plist-get item :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-create :content "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-flex
:width 180 :height 3
:flex-flow '(row wrap)
:column-gap '(8)
(ebox-create
:content (format "A%d" index)
:width 48 :height 1)
(ebox-create
:content (format "B%d" index)
:width 48 :height 1))))
(layout
(apply
#'ebox-flex
:flex-flow '(row wrap)
:row-gap 1
:column-gap '(8)
(mapcar (lambda (inner)
(ebox-column
(ebox-create :content "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
(ebox-build
`(column :width (viewport)
,@(cl-loop for idx below 24
collect
`(box :id ,(format "resize-snapshot-%02d" idx)
:content ,(format "row %02d" idx)
:width (viewport)
:height 1)))))
(node-count (length (ebox--runtime-node-ids layout)))
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
(ebox-build
`(column :width (viewport)
,@(cl-loop for idx below 24
collect
`(box :id ,(format "resize-mount-%02d" idx)
:content ,(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-viewport-dependent-node-ids-are-render-pass-memoized ()
"Viewport dependency discovery should not recurse twice for the same node."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-flex
:width 240
(ebox-column
(ebox-create :content "A" :width 80)
(ebox-create :content "B" :width 80))))
(node-count (length (ebox--runtime-node-ids layout)))
(width-check-count 0)
(height-check-count 0)
(original-width
(symbol-function 'ebox--node-direct-viewport-width-dependent-p))
(original-height
(symbol-function 'ebox--node-direct-viewport-height-dependent-p))
(ebox--viewport-dependent-node-ids-cache
(make-hash-table :test 'eq)))
(cl-letf (((symbol-function
'ebox--node-direct-viewport-width-dependent-p)
(lambda (&rest args)
(cl-incf width-check-count)
(apply original-width args)))
((symbol-function
'ebox--node-direct-viewport-height-dependent-p)
(lambda (&rest args)
(cl-incf height-check-count)
(apply original-height args))))
(ebox--viewport-dependent-node-ids layout)
(ebox--viewport-dependent-node-ids layout))
(should (= width-check-count node-count))
(should (= height-check-count node-count))))
(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-flex
:width 240
(ebox-column
(ebox-create :content "A" :width 80)
(ebox-create :content "B" :width 80))))
(node-count (length (ebox--runtime-node-ids layout)))
(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)
(ebox--render-cache-node-signature layout))
(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-create :content "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-rendered-region-identity ()
"Cached render strings should never cross distinct region identities."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "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-create :content "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-create :content "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-create :content "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-create :content "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-create :content "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-column
(ebox-create :content "A" :width '(viewport))
(ebox-create :content "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-flex
:width 240
(ebox-column
(ebox-create :content "A" :width 80)
(ebox-create :content "B" :width 80))))
(node-count (length (ebox--runtime-node-ids layout)))
(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))
(ebox--with-buffer-render-context buffer
(ebox--render-cache-node-body-signature layout)))
(should (= signature-count node-count)))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-buffer-render-signature-invalidates-mutated-path ()
"Runtime mutation should not reuse a stale ancestor body signature."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "Before" :width 80))
(right (ebox-create :content "Stable" :width 80))
(layout (ebox-column left right))
(left-region-id (car (ebox-region-ids left)))
old-root-signature
new-root-signature
buffer)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(let ((runtime-root (ebox--buffer-root-node buffer)))
(setq old-root-signature
(ebox--with-buffer-render-context buffer
(ebox--render-cache-node-body-signature runtime-root))))
(with-current-buffer buffer
(ebox-test--region-update left-region-id :content "After")
(should (equal (ebox-get left :content) "Before"))
(setq new-root-signature
(ebox--with-buffer-render-context buffer
(ebox--render-cache-node-body-signature
(ebox--buffer-root-node buffer))))
(should-not (equal old-root-signature new-root-signature))
(should
(string-match-p
"After"
(buffer-substring-no-properties (point-min) (point-max))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-render-cache-signature-ignores-viewport-for-static-column ()
"Static column signatures should not churn only because outer viewport changed."
(ebox-test--reset-runtime-state)
(let ((node (ebox-column
(ebox-create :content "A" :width 80)
(ebox-create :content "B" :width 80))))
(ebox--runtime-node-ids node)
(let ((left (let ((ebox-viewport-width 320))
(ebox--render-cache-node-signature node)))
(right (let ((ebox-viewport-width 640))
(ebox--render-cache-node-signature node))))
(should (equal left right)))))
(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-create :content "Signature" :width 80 :border t))
(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 (ebox-create :content content :width 80 :wrap-mode nil))
(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-stack-content-skips-parent-justify-measurement ()
"A target-width stack should not be measured again by its parent box."
(ebox-test--reset-runtime-state)
(let* ((child (ebox-column
(ebox-create :content "A" :width '(viewport))
(ebox-create :content "B" :width '(viewport))))
(parent (ebox-create :width '(viewport) :wrap-mode nil
:ebox-content-node child))
(justify-count 0)
(original (symbol-function 'ebox--lines-justify)))
(let ((ebox-viewport-width 120))
(cl-letf (((symbol-function 'ebox--lines-justify)
(lambda (&rest args)
(cl-incf justify-count)
(apply original args))))
(ebox-render parent)))
;; The two leaf boxes still justify their own text. The parent does not
;; add a third full-stack pass.
(should (= justify-count 2))))
(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 (ebox-create :content "" :width '(120)
:wrap-mode wrap-mode :text-align 'left))
(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 (ebox-create :content "" :width '(120)
:wrap-mode (nth 0 case)
:text-align (nth 1 case)))
(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-create :content "A" :width '(viewport) :border t
:region-id 'child-a))
(child-b (ebox-create :content "B" :width '(viewport) :border t
:region-id 'child-b))
(parent (ebox-create :width '(viewport) :wrap-mode nil
:region-id 'parent
:ebox-content-node
(ebox-column 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-box-fast-path-is-exact ()
"One-pass exact wrappers should preserve generic chrome and ownership."
(ebox-test--reset-runtime-state)
(let* ((box
(ebox-create
:content "" :width '(140) :wrap-mode nil :region-id 'wrapper
:padding-left-pixel 7 :padding-right-pixel 5
:padding-top-height 1 :padding-bottom-height 2
:border-left-pixel 2 :border-left-color "#AA0000"
:border-right-pixel 3 :border-right-color "#00AA00"
:border-top-p t :border-top-color "#0000AA"
:border-bottom-p t :border-bottom-color "#AA00AA"
:color "#111111" :bgcolor "#EEEEEE"))
(content-width (ebox--content-pixel box))
(full-line (ebox-pixel-space content-width))
(content (concat full-line "\n\n" full-line))
(fast-path-calls 0)
(original
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))
generic fast)
(plist-put box :content content)
(plist-put box :ebox-content-width-exact-p t)
(let ((ebox--flat-preformatted-box-fast-path-disabled t))
(setq generic (ebox-render box)))
(clrhash ebox--region-box-table)
(clrhash ebox--scroll-global-state)
(cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
(lambda (&rest args)
(cl-incf fast-path-calls)
(apply original args))))
(setq fast (ebox-render box)))
(should (= fast-path-calls 1))
(should (equal-including-properties fast generic))))
(ert-deftest ebox-flat-preformatted-formatted-string-fast-path-is-exact ()
"One-pass formatted strings should preserve generic chrome and ownership."
(ebox-test--reset-runtime-state)
(let* ((box
(ebox-create
:content "Alpha beta gamma delta" :width '(88) :wrap-mode 'word
:text-align 'center :region-id 'formatted-wrapper
:padding-left-pixel 4 :padding-right-pixel 6
:border-left-pixel 1 :border-left-color "#AA0000"
:border-right-pixel 2 :border-right-color "#00AA00"
:color "#111111" :bgcolor "#EEEEEE"))
(fast-path-calls 0)
(original
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))
generic fast)
(let ((ebox--flat-preformatted-box-fast-path-disabled t))
(setq generic (ebox-render box)))
(clrhash ebox--region-box-table)
(clrhash ebox--scroll-global-state)
(cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
(lambda (&rest arguments)
(cl-incf fast-path-calls)
(apply original arguments))))
(setq fast (ebox-render box)))
(should (= fast-path-calls 1))
(should (equal-including-properties fast generic))))
(ert-deftest ebox-flat-preformatted-string-proves-width-before-reuse ()
"Formatted string reuse must not publish an unproven uniform width."
(ebox-test--reset-runtime-state)
(let* ((child (ebox-create :content "This content must wrap" :width '(5)
:wrap-mode nil :region-id 'overflowing-child))
(parent (ebox-create :width '(5) :wrap-mode 'word
:region-id 'wrapping-parent
:ebox-content-node child))
generic fast)
(let ((ebox--flat-preformatted-box-fast-path-disabled t))
(setq generic (ebox-render parent)))
(clrhash ebox--rendered-uniform-width-table)
(clrhash ebox--region-box-table)
(let ((ebox--flat-preformatted-box-fast-path-disabled nil))
(setq fast (ebox-render parent)))
(should (equal-including-properties fast generic))
(should (= (ebox-string-height fast) 5))))
(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 (ebox-create :content "" :width '(40) :wrap-mode nil
:region-id 'intrinsic-wrapper))
(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-render box))
(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)))
(right
(propertize
(ebox-pixel-space 60)
'ebox-content 'right 'ebox-content-idx 0
'ebox-content-owner 'right 'ebox-content-owners '(right)))
(box (ebox-create :content "" :width 100 :wrap-mode nil
:color "#112233" :bgcolor "#DDEEFF"))
(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 (ebox-create :content "" :width '(40) :wrap-mode nil
:region-id 'transparent-child))
(parent (ebox-create :content "" :width '(40) :wrap-mode nil
:region-id 'transparent-parent
:ebox-content-node child))
(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-render parent)
(clrhash ebox--region-box-table)
(setq region-id (ebox--ensure-region-id parent))
(puthash region-id state ebox--scroll-global-state)
(let ((ebox--intrinsic-layout-measurement t))
(ebox-render parent))
(should (eq (gethash region-id ebox--scroll-global-state) state))))
(ert-deftest ebox-flat-preformatted-fixed-hidden-box-fast-path-is-exact ()
"Exact fixed-height hidden wrappers should preserve generic output."
(ebox-test--reset-runtime-state)
(let* ((box
(ebox-create
:content "" :width '(140) :height 6 :box-sizing 'border-box
:overflow 'hidden :wrap-mode nil :region-id 'fixed-wrapper
:padding-left-pixel 7 :padding-right-pixel 5
:padding-top-height 1 :padding-bottom-height 2
:border-left-pixel 2 :border-left-color "#AA0000"
:border-right-pixel 3 :border-right-color "#00AA00"
:border-top-p t :border-top-color "#0000AA"
:border-bottom-p t :border-bottom-color "#AA00AA"
:color "#111111" :bgcolor "#EEEEEE"))
(content-width (ebox--content-pixel box))
(full-line (ebox-pixel-space content-width))
(content (concat full-line "\n\n" full-line))
(fast-path-calls 0)
(original
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))
generic fast)
(plist-put box :content content)
(plist-put box :ebox-content-width-exact-p t)
(let ((ebox--flat-preformatted-box-fast-path-disabled t))
(setq generic (ebox-render box)))
(clrhash ebox--region-box-table)
(clrhash ebox--scroll-global-state)
(cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
(lambda (&rest arguments)
(cl-incf fast-path-calls)
(apply original arguments))))
(setq fast (ebox-render box)))
(should (= fast-path-calls 1))
(should-not (gethash 'fixed-wrapper ebox--region-box-table))
(should-not (gethash 'fixed-wrapper ebox--scroll-global-state))
(should (equal-including-properties fast generic))))
(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
(ebox-create
:content (nth 0 case) :width '(120) :height 5
:box-sizing 'border-box :overflow (nth 1 case)
:vertical-align (nth 2 case) :wrap-mode nil))
(calls 0)
(original
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)))
(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-render box))
(should (= calls 0)))))
(ert-deftest ebox-overflowing-stack-content-still-uses-parent-formatting ()
"An overflowing child stack must not publish uniform parent-width metadata."
(ebox-test--reset-runtime-state)
(let* ((child (ebox-column
(ebox-create :content "A" :width 140)
(ebox-create :content "B" :width 140)))
(parent (ebox-create :width 120 :wrap-mode nil
:ebox-content-node child))
(justify-count 0)
(original (symbol-function 'ebox--lines-justify))
rendered)
(let ((ebox-viewport-width 120))
(cl-letf (((symbol-function 'ebox--lines-justify)
(lambda (&rest args)
(cl-incf justify-count)
(apply original args))))
(setq rendered (ebox-render parent))))
(should (= justify-count 3))
(should (= (ebox--string-max-pixel-width rendered) 140))))
(ert-deftest ebox-box-content-node-publishes-only-proven-exact-width ()
"A wrapper should mark child output exact only at its proven viewport width."
(ebox-test--reset-runtime-state)
(let* ((overflow-child
(ebox-column
(ebox-create :content "A" :width 140)
(ebox-create :content "B" :width 140)))
(overflow-parent
(ebox-create :width 120 :wrap-mode nil
:ebox-content-node overflow-child))
(exact-child
(ebox-column
(ebox-create :content "A" :width 120)
(ebox-create :content "B" :width 120)))
(exact-parent
(ebox-create :width 120 :wrap-mode nil
:ebox-content-node exact-child))
(ebox-viewport-width 120)
(ebox--box-content-render-cache (make-hash-table :test 'eq))
overflow exact)
(setq overflow
(ebox--render-box-content-node overflow-parent overflow-child))
(setq exact (ebox--render-box-content-node exact-parent exact-child))
(should (= (ebox--string-max-pixel-width overflow) 140))
(should-not (plist-get overflow-parent :ebox-content-width-exact-p))
(should (= (ebox--string-max-pixel-width exact) 120))
(should (plist-get exact-parent :ebox-content-width-exact-p))))
(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-render-caches-lazy-box-content-within-render-pass ()
"A lazy child layout should be rendered once per box/context in one pass."
(ebox-test--reset-runtime-state)
(let* ((child (ebox-column
(ebox-create :content "A" :width 80)
(ebox-create :content "B" :width 80)))
(parent (ebox-create :width 'max-content :padding 2))
(render-count 0)
(original (symbol-function 'ebox--render-node-as-box-content)))
(plist-put parent :ebox-content-node child)
(cl-letf (((symbol-function 'ebox--render-node-as-box-content)
(lambda (&rest args)
(cl-incf render-count)
(apply original args))))
(ebox-render parent))
(should (= render-count 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-create :content "Report" :width 80
:border t :border-color "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-create :content "First" :width '(80)))
(second-box (ebox-create :content "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-create :content "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-create :content "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-create :content "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-create :content "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-create :content "First" :width '(80)
:color "#111111"))
(second-box (ebox-create :content "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-create :content "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-create :content "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-create :content "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-create :content "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-concat-content-update-preserves-neighbor-box ()
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "A\nB" :width 60))
(right (ebox-create :content "R1\nR2\nR3" :width 80))
(layout (ebox-concat left right))
(ids (ebox-region-ids layout))
(left-id (nth 0 ids))
(right-id (nth 1 ids)))
(ebox-test--with-rendered-buffer layout
(should (ebox-surface-region-mounts (current-buffer) left-id))
(ebox-test--region-update left-id :content "AA\nBB\nCC")
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "CC" plain))
(should (string-match-p "R3" plain)))
(should (equal (ebox-region-ids layout) ids))
(should (gethash right-id ebox--region-box-table)))))
(ert-deftest ebox-geometry-update-rerenders-root-once-safely ()
(ebox-test--reset-runtime-state)
(let* ((top (ebox-create :content "Top" :width 60))
(left (ebox-create :content "L" :width 40))
(right (ebox-create :content "R" :width 40))
(layout (ebox-stack top (ebox-concat 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* ((left (ebox-create :content "L" :width 40
:border t :border-color "red"))
(right (ebox-create :content "R" :width 40
:border t :border-color "green"))
(layout (ebox-concat left right))
(left-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update left-id :border-color "blue"))))
(should (= full-rerenders 0)))
(let ((border-face (get-text-property (point-min) 'face)))
(should (ebox-test--face-has-key-value-p border-face
:overline "blue")))
(should (string-match-p
"R"
(buffer-substring-no-properties (point-min) (point-max)))))))
(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-build
'(flex :flex-flow (row wrap) :column-gap (12)
:padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
(column
(box :id left :content "Left" :width (120)
:padding (0 (8)) :border "#D97757")
(box :content "Body" :width (120)
:padding (0 (8)) :border "#D97757"
:border-top-p nil))
(column
(box :id right :content "Right" :width (120)
:padding (0 (8)) :border "#5E7F6A")
(box :content "Body" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-top-p nil)))))
(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-build
'(flex :flex-flow (row wrap) :column-gap (12)
:padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
(column
(box :id left :content "Left" :width (120)
:padding (0 (8)) :border "#D97757"
:border-bottom-p nil)
(box :content "Body" :width (120)
:padding (0 (8)) :border "#D97757"
:border-top-p nil))
(column
(box :content "Right" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-bottom-p nil)
(box :content "Body" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-top-p nil)))))
(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-p t
: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-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-build
'(flex :flex-flow (row wrap) :column-gap (12)
:padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
(column
(box :id left :content "Left" :width (120)
:padding (0 (8)) :border "#D97757"
:border-bottom-p nil)
(box :content "Body" :width (120)
:padding (0 (8)) :border "#D97757"
:border-top-p nil))
(column
(box :content "Right" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-bottom-p nil)
(box :content "Body" :width (120)
:padding (0 (8)) :border "#5E7F6A"
:border-top-p nil)))))
(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-p t
:border-bottom-color "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 bt (bb bl br))
(:border-bottom-p bb (bt bl br))))
(let* ((property (nth 0 case))
(removed-role (nth 1 case))
(kept-roles (nth 2 case))
(layout (ebox-create :content "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)))
(ebox-test--region-update region-id property nil)
(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-side-border-color-patch-toggles-default-paint-exactly ()
"Side color patches should match default and explicit full-render paint."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-create
:content "Border" :width 80
:border-left-pixel 2 :border-left-color "#AA0000"
:border-right-pixel 2 :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 nil
:border-right-color nil)
(dolist (role '(bl br))
(let ((face
(ebox-test--face-at-property
(buffer-string) (alist-get role ebox-region-types))))
(should
(ebox-test--face-has-key-value-p face :inverse-video t))
(should-not (ebox-test--face-has-key-p face :background))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(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-region-update-reuses-buffer-render-viewport-context ()
"Incremental rerender should use the viewport from the original buffer render."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-flex :padding '(0 4) :border "#64748B"
(ebox-create :content "Flex Child" :width '(120)
:border t)))
(region-id (car (ebox--node-all-region-ids layout)))
buffer)
(unwind-protect
(progn
(let ((ebox-viewport-width 240))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout)))
(with-current-buffer buffer
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update region-id
:border-color "#2563EB"))))
(should (= full-rerenders 0)))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'paint-patch))
(should (eq (plist-get report :constraint-owner-type) 'flex))
(should (eq (plist-get report :tp-render-scope) 'objects))
(should-not (plist-get report :tp-full-root))
(should (equal (plist-get report :patch-ops)
'(paint-patch))))))
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(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-build
'(box :content "Viewport" :width (viewport) :border t))))
buffer)
(unwind-protect
(progn
(should (eq (plist-get 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-build
'(box :content "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-stable-content-update-patches-without-full-root-rerender ()
"A content update with unchanged rendered geometry should stay local."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "Alpha" :width 80 :height 1))
(right (ebox-create :content "Right" :width 80 :height 1))
(layout (ebox-concat left right))
(left-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update left-id :content "Beta"))))
(should (= full-rerenders 0)))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "Beta" plain))
(should-not (string-match-p "Alpha" plain))
(should (string-match-p "Right" plain))))))
(ert-deftest ebox-nested-stable-content-update-patches-leaf-owner ()
"Stable content updates inside nested layouts should not patch the root."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
'(flex :flex-flow (row wrap) :column-gap (12)
:padding (0 (12)) :border "#999999"
(column :width (210) :border "#999999"
(box :content "Title" :width (190))
(box :id target :content "before\nsame"
:width (190) :height 3
:padding (0 (8)) :border "#999999")
(box :content "Metric" :width (190)))
(column :width (210) :border "#999999"
(box :content "Other" :width (190))
(box :content "Still here" :width (190))))))
(target (car (ebox-selector-query-all layout "#target")))
(target-id (plist-get target :region-id))
(target-node-id (plist-get target :node-id)))
(ebox-test--with-rendered-buffer layout
(let ((snapshot (ebox--ensure-layout-snapshot-spans
(current-buffer) target-node-id)))
(should snapshot)
(should (plist-get snapshot :buffer-spans)))
(ebox-test--region-update target-id :content "after\nsame")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :owner-ids) (list target-node-id)))
(should (< (plist-get report :span-count) 10))
(should-not (plist-get report :full-rerender)))
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
(should (string-match-p "after" plain))
(should-not (string-match-p "before" plain))
(should (string-match-p "Still here" plain))))))
(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-build
'(column :id root :width (520) :padding (1 (18))
(flex :id matrix :flex-flow (row wrap)
:column-gap (12) :row-gap 1 :padding (0 (12))
:border "#999999"
(column :width (210) :border "#999999"
(box :content "Title" :width (190))
(box :id target :content "width target\nsame height"
:width (160) :height 3
:padding (0 (8)) :border "#999999")
(box :content "Metric" :width (190)))
(column :width (210) :border "#999999"
(box :content "Other" :width (190))
(box :content "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-create :content "width target\nready to grow"
:width '(190) :height 3 :padding '(0 (10))
:border "#999999"))
(owner
(apply #'ebox-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-column
(ebox-create :content "Width" :width '(300) :height 2)
owner
(ebox-create :content "The target stays in this card"
:width '(300) :height 3)
(ebox-create :content "Metric" :width '(300) :height 4)))
(peer
(ebox-column
(ebox-create :content "Peer" :width '(300) :height 2)
(ebox-create :content "Stable peer" :width '(300) :height 5)
(ebox-create :content "Footer" :width '(300) :height 7)))
(layout
(ebox-flex :width '(720) :flex-flow '(row wrap)
:column-gap '(12) card peer)))
(list :layout layout :target target :owner owner)))
(ert-deftest ebox-fixed-flex-context-contains-child-width-update ()
"Fixed flex contexts should patch their five lines, not the whole card."
(ebox-test--reset-runtime-state)
(let* ((fixture (ebox-test--nested-width-containment-layout 5))
(layout (plist-get fixture :layout))
(target (plist-get fixture :target))
(owner (plist-get fixture :owner))
(target-region-id (car (ebox-region-ids target)))
(owner-node-id (ebox--ensure-node-id owner)))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update target-region-id :width '(280))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :owner-ids)
(list owner-node-id)))
(should (eq (plist-get report :owner-type) 'flex))
(should (> (plist-get report :span-count) 0))
(should-not (plist-get report :root-rerender))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-contained-partial-flex-content-change-preserves-fixed-slot ()
"A fixed partial flex should update without disturbing adjacent siblings."
(ebox-test--reset-runtime-state)
(let* ((mark (ebox-create :key 'mark :host-ref 'mark
:content "○ OFF" :width 'max-content
:overflow 'hidden))
(label (ebox-create :key 'label :content " Power off"
:host-ref 'label :width 'max-content
:overflow 'hidden))
(switch (ebox-create :key 'switch :host-ref 'switch
:width 'max-content :wrap-mode nil
:overflow 'hidden
:ebox-content-node (ebox-row mark label)))
(owner (ebox-flex :width '(496) :min-width '(496)
:max-width '(496) :height 1 :min-height 1
:max-height 1 :overflow 'hidden
:bgcolor "#F7F8FA" :wrap-mode nil
:flex-flow '(column nowrap) switch))
(caption (ebox-create :content "switch" :width '(170)
:min-width '(170) :max-width '(170)
:height 1 :min-height 1 :max-height 1))
(outer (ebox-flex :width '(680) :min-width '(680)
:max-width '(680) :height 1 :min-height 1
:max-height 1 :overflow 'hidden
:bgcolor "#F7F8FA" :wrap-mode nil
:column-gap '(14) :flex-flow '(row nowrap)
caption owner))
(footer (ebox-create :content "FOOTER" :width '(680) :height 1))
(layout (ebox-column outer footer))
(mark-node-id (ebox--ensure-node-id mark))
(label-node-id (ebox--ensure-node-id label))
(owner-node-id (ebox--ensure-node-id owner)))
(plist-put owner :key 'owner)
(plist-put owner :host-ref 'owner)
(plist-put outer :key 'outer)
(plist-put outer :host-ref 'outer)
(ebox-test--with-rendered-buffer layout
(should (ebox--contained-partial-flex-owner-p
(current-buffer) owner-node-id))
(let ((candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace
candidate mark-node-id
(ebox-create :key 'mark :host-ref 'mark
:content " ON ● " :width 'max-content
:overflow 'hidden))
(ebox-candidate-replace
candidate label-node-id
(ebox-create :key 'label :host-ref 'label
:content " Much longer power on state"
:width 'max-content
:overflow 'hidden))
(ebox-commit (current-buffer) candidate))
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender)))
(should (equal (plist-get report :owner-ids) (list owner-node-id)))
(should (eq (plist-get report :owner-type) 'flex))
(should (= (plist-get report :span-count) 1))
(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))
(should
(ebox-test--buffer-propertized-matches-runtime-render-p))))))
(ert-deftest ebox-contained-partial-flex-structure-change-preserves-fixed-slot ()
"A fixed partial flex should own bounded descendant structure changes."
(ebox-test--reset-runtime-state)
(let* ((input (ebox-create :key 'input :content "query"
:overflow 'hidden))
(content
(ebox-create :key 'content :host-ref 'content
:width 'max-content :overflow 'hidden
:ebox-content-node (ebox-column input)))
(owner (ebox-flex :width '(180) :height 2 :overflow 'hidden
:flex-flow '(column nowrap) content))
(caption (ebox-create :content "search" :width '(80) :height 2))
(outer (ebox-row caption owner))
(footer (ebox-create :content "FOOTER" :width '(260) :height 1))
(layout (ebox-column outer footer))
(content-node-id (ebox--ensure-node-id content))
(owner-node-id (ebox--ensure-node-id owner)))
(plist-put owner :key 'owner)
(plist-put owner :host-ref 'owner)
(ebox-test--with-rendered-buffer layout
(let ((candidate (ebox-candidate-begin (current-buffer))))
(ebox-candidate-replace
candidate content-node-id
(ebox-create
:key 'content :host-ref 'content
:width 'max-content :overflow 'hidden
:ebox-content-node
(ebox-column
(ebox-create :key 'input :content "query" :overflow 'hidden)
(ebox-create :key 'option :content "Apple" :overflow 'hidden))))
(ebox-commit (current-buffer) candidate))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids) (list owner-node-id)))
(should (eq (plist-get report :owner-type) 'flex))
(should (= (plist-get report :span-count) 2))
(should-not (plist-get report :tp-full-root))
(should-not (plist-get report :tp-scope-fallback))
(should
(ebox-test--buffer-propertized-matches-runtime-render-p))))))
(ert-deftest ebox-auto-height-flex-context-keeps-ancestor-width-fallback ()
"Auto-height flex contexts must not contain descendant width reflow."
(ebox-test--reset-runtime-state)
(let* ((fixture (ebox-test--nested-width-containment-layout))
(layout (plist-get fixture :layout))
(target (plist-get fixture :target))
(owner (plist-get fixture :owner))
(target-region-id (car (ebox-region-ids target)))
(owner-node-id (ebox--ensure-node-id owner)))
(ebox-test--with-rendered-buffer layout
(should-not (ebox--definite-containment-formatting-context-p
(current-buffer) owner-node-id))
(ebox-test--region-update target-region-id :width '(280))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (equal (plist-get report :owner-ids)
(list owner-node-id)))
(should (eq (plist-get report :owner-type) 'flex))
(should-not (plist-get report :root-rerender))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-intrinsic-constrained-flex-context-keeps-ancestor-fallback ()
"Intrinsic min/max constraints must disable definite containment."
(dolist (max-width '(min-content (fit-content 260)))
(ebox-test--reset-runtime-state)
(let* ((fixture (ebox-test--nested-width-containment-layout 5))
(layout (plist-get fixture :layout))
(target (plist-get fixture :target))
(owner (plist-get fixture :owner))
(owner-box (plist-get owner :box))
(target-region-id (car (ebox-region-ids target)))
(owner-node-id (ebox--ensure-node-id owner)))
(ebox-put owner-box :max-width max-width)
(ebox-test--with-rendered-buffer layout
(should-not (ebox--definite-containment-formatting-context-p
(current-buffer) owner-node-id))
(ebox-test--region-update target-region-id :width '(280))
(let ((report (ebox-test--buffer-update-report)))
(should-not (equal (plist-get report :owner-ids)
(list owner-node-id)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-visible-overflow-flex-context-keeps-ancestor-fallback ()
"A fixed context must not contain an unowned visible-overflow suffix."
(ebox-test--reset-runtime-state)
(let* ((fixture (ebox-test--nested-width-containment-layout 5))
(layout (plist-get fixture :layout))
(target (plist-get fixture :target))
(owner (plist-get fixture :owner))
(target-region-id (car (ebox-region-ids target)))
(owner-node-id (ebox--ensure-node-id owner)))
(ebox-put target :content "line 1\nline 2\nline 3\nline 4")
(ebox-put target :height 2)
(ebox-put target :overflow 'visible)
(ebox-test--with-rendered-buffer layout
(should-not (ebox--definite-containment-formatting-context-p
(current-buffer) owner-node-id))
(ebox-test--region-update target-region-id :width '(280))
(let ((report (ebox-test--buffer-update-report)))
(should-not (equal (plist-get report :owner-ids)
(list owner-node-id)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(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-create :content "child" :width '(190)
:height 5 :min-height 5))
(owner
(ebox-flex :width '(300) :height 2 :overflow 'visible
:align-items 'flex-start target))
(layout
(ebox-stack owner
(ebox-create :content "footer" :width '(300)
:height 2)))
(owner-node-id (ebox--ensure-node-id 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-context-contains-child-block-size-update ()
"A fixed flex should contain a child block-size change in its five lines."
(ebox-test--reset-runtime-state)
(let* ((fixture (ebox-test--nested-width-containment-layout 5))
(layout (plist-get fixture :layout))
(target (plist-get fixture :target))
(target-region-id (car (ebox-region-ids target)))
(owner-node-id
(ebox--ensure-node-id (plist-get fixture :owner))))
(ebox-test--with-rendered-buffer layout
(let ((root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update target-region-id :height 4)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :owner-ids)
(list owner-node-id)))
(should (eq (plist-get report :owner-type) 'flex))
(should (> (plist-get report :span-count) 0))
(should-not (member root-id (plist-get report :owner-ids))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(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-build
'(column :id root :width (520) :padding (1 (18))
(flex :id matrix :flex-flow (row wrap)
:column-gap (12) :row-gap 1 :padding (0 (12))
:border "#999999"
(column :width (210) :border "#999999"
(box :content "Title" :width (190))
(box :id target :content "width target\nsame height"
:width (160) :height 3
:padding (0 (8)) :border "#999999")
(box :content "Metric" :width (190)))
(column :width (210) :border "#999999"
(box :content "Other" :width (190))
(box :content "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-create :id "target" :content "width target\nmoves sibling layout"
:width '(160) :height 3 :padding '(0 (8))
:border "#999999"))
(first
(ebox-column
(ebox-create :content "Title" :width '(190))
target
(ebox-create :content "Metric" :width '(190) :height 4))))
(apply #'ebox-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-column
(ebox-create
:content (format "Title %02d" index)
:width '(190))
(ebox-create :content "Peer\nsame row"
:width '(190) :height 3
:padding '(0 (8))
:border "#999999")
(ebox-create :content "Metric"
:width '(190) :height 4)))))))
(ert-deftest ebox-flex-owner-update-reports-scoped-tp-publication ()
"A contained flex owner update should publish through a scoped TP commit."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--flex-line-width-owner-layout)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer) "#target")))
(ebox-test--region-update region-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 (eq (plist-get report :owner-type) 'flex))
(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-visually-matches-runtime-render-p)))))))
(ert-deftest ebox-flex-line-rerender-skips-auto-width-owner ()
"Auto-width flex owners should fall back instead of slicing a moving suffix."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--flex-line-width-owner-layout 12 :auto)))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer) "#target")))
(ebox-test--region-update region-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 (eq (plist-get report :owner-type) 'flex))
(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-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-build
'(column :id root :width (1200) :padding (1 (18))
(flex :id matrix :flex-flow (row wrap)
:column-gap (12) :row-gap 1 :padding (0 (12))
:border "#999999"
(column :width (210) :border "#999999"
(box :content "Reference\nshort" :width (190))
(box :id scroll :content "line 1\nline 2\nline 3\nline 4"
:width (190) :height 2 :padding (0 (8))
:border "#999999" :overflow scroll)
(box :content "Metric" :width (190) :height 4))
(column :width (210) :border "#999999"
(box :content "Height\nowner" :width (190))
(box :id height :content "height target\nexpands block"
:width (190) :height 2 :padding (0 (8))
:border "#999999")
(box :content "Metric" :width (190) :height 4))
(column :width (210) :border "#999999"
(box :content "Margin\nslot stable" :width (190))
(box :id margin :content "margin target\nparent placement"
:width (170) :height 3 :padding (0 (8))
:border "#999999")
(box :content "Metric" :width (190) :height 4))
(column :width (210) :border "#999999"
(box :content "Overflow\nowner" :width (190))
(box :id overflow :content "overflow target\nextra line\nanother line"
:width (190) :height 2 :padding (0 (8))
:border "#999999" :overflow scroll)
(box :content "Metric" :width (190) :height 4))
(column :width (210) :border "#999999"
(box :content "Tall peer\nkeeps row slot" :width (190))
(box :content "tall\npeer\nsets\nline\ncross" :width (190)
:height 5 :padding (0 (8)) :border "#999999")
(box :content "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-build
'(column :id root :width (720)
(box :content "Header" :width (720))
(column :id panel :width (720) :border "#999999"
(flex :id matrix :flex-flow (row wrap)
:column-gap (12) :row-gap 1 :padding (0 (12))
:border "#999999"
(column :width (210) :border "#999999"
(box :content "Height\nowner" :width (190))
(box :id height :content "height target\nexpands block"
:width (190) :height 2 :padding (0 (8))
:border "#999999")
(box :content "Metric" :width (190) :height 2))
(column :width (210) :border "#999999"
(box :content "Peer" :width (190))
(box :content "peer\nsame row" :width (190)
:height 2 :padding (0 (8)) :border "#999999")
(box :content "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* ((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 (ebox--buffer-root-node (current-buffer))))))
(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* ((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 (ebox--buffer-root-node (current-buffer))))))
(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-build
'(box :id scroll
:content "第 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-build
'(box :id scroll :content
"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-build
'(box :id root :width (180) :height 2 :overflow scroll
(column
(box :id inner :width (180) :height 2 :overflow scroll
:content "inner 0\ninner 1\ninner 2\ninner 3")
(box :content "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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :id "a" :content "A" :height 1)
(box :id "b" :content "B" :height 1)
(box :id "c" :content "C" :height 1)
(box :id "d" :content "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-build
'(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-p t :border-bottom-color "blue"
(column
(box :id "a" :content "A" :height 1)
(box :id "b" :content "B" :height 1)
(box :id "c" :content "C" :height 1)
(box :id "d" :content "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 (list 0 '(8) 0 '(8))
:margin-inline '((8) (8))
:margin-block '(0 0)
:margin-top 0 :margin-right '(8)
:margin-bottom 0 :margin-left '(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-build
'(column :id root :width (480)
(flex :id row :flex-flow (row nowrap) :column-gap (12)
(box :id padding :content "padding target"
:width (190) :height 3 :box-sizing border-box
:padding (0 (8)) :border "#999999")
(box :content "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-build
'(column :id root :width (480)
(flex :id row :flex-flow (row nowrap) :column-gap (12)
(box :id padding :content "padding target"
:width (190) :height 4 :box-sizing border-box
:padding (0 (8)) :border "#999999")
(box :content "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-build
'(box :id padding :content "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-owner-rerender-preserves-missing-wrapper-context ()
"Nested owner rerenders should retain outer paint and surface metadata."
(ebox-test--reset-runtime-state)
(let ((layout (ebox-test--dynamic-update-row-layout)))
(ebox-put layout :color "#1F2328")
(ebox-put layout :bgcolor "#FAF7F0")
(ebox-put layout :surface-properties
'(ebox-test-role outer-wrapper))
(ebox-put (plist-get (car (ebox--node-children layout)) :box)
:bgcolor "#FBF9FF")
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id
(current-buffer) "#overflow")))
(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 "overflow target")
(let ((face (get-text-property (match-beginning 0) 'face)))
(should (ebox-test--face-has-key-value-p
face :foreground "#1F2328"))
(should (ebox-test--face-has-key-value-p
face :background "#FBF9FF"))
(should
(eq (get-text-property
(match-beginning 0) 'ebox-test-role)
'outer-wrapper)))))))
(ert-deftest ebox-visible-scroll-content-update-preserves-wrapper-paint ()
"Visible cached content patches should preserve nested wrapper paint."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(box :id "root" :height 1 :overflow scroll
(column
(box :id "wrapper" :height 1 :overflow scroll
:bgcolor "#E2E8F0"
(column
(box :id "target" :content "old" :width (80) :height 1
:border "#64748B" :bgcolor "#1D4ED8")
(box :content "inner tail" :height 1)))
(box :content "outer tail" :height 1))))))
(ebox-test--with-rendered-buffer layout
(let ((root-id (ebox-test--selector-region-id
(current-buffer) "#root"))
(wrapper-id (ebox-test--selector-region-id
(current-buffer) "#wrapper"))
(target-id (ebox-test--selector-region-id
(current-buffer) "#target")))
(should (plist-get (ebox-scroll-state root-id)
:rendered-content-lines))
(should (plist-get (ebox-scroll-state wrapper-id)
:rendered-content-lines))
(ebox-test--region-update target-id :content "new")
(let* ((report (ebox-test--buffer-update-report))
(border-pos (text-property-any
(point-min) (point-max) 'ebox-bl target-id))
(content-pos (text-property-any
(point-min) (point-max)
'ebox-content target-id))
(border-face (and border-pos
(get-text-property border-pos 'face)))
(content-face (and content-pos
(get-text-property content-pos 'face)))
(content-face-specs
(if (and (listp content-face)
(keywordp (car content-face)))
(list content-face)
content-face))
(effective-content-background
(cl-loop for spec in content-face-specs
when (and (listp spec)
(plist-member spec :background))
return (plist-get spec :background))))
(should (eq (plist-get report :strategy) 'span-patch))
(should (plist-get report :scroll-state-patch))
(should (plist-get report :runtime-published))
(should (eq (plist-get report :tp-render-scope) 'objects))
(should-not (plist-get report :tp-full-root))
(should (= (plist-get report :tp-scope-range-count) 1))
(should border-pos)
(should content-pos)
(should (ebox-test--face-has-key-value-p
border-face :background "#E2E8F0"))
(should (member wrapper-id
(get-text-property
border-pos 'ebox-content-owners)))
(should (ebox-test--face-has-key-value-p
content-face :background "#E2E8F0"))
(should (equal effective-content-background "#1D4ED8"))
(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-removes-visible-suffix ()
"Clipping a visible target should replace the ancestor-owned old suffix."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(column :id root :width (760)
(flex :id matrix :width (600) :height 5
:flex-flow (row nowrap)
(box :id card :width (300)
(box :id overflow
:content "overflow target\nextra line\nanother line"
:width (280) :height 2 :overflow scroll))
(box :id peer :content "P\nQ\nR\nS\nT"
:width (300) :height 5))))))
(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)))
coverage-owner-id)
(let ((full-rerenders
(ebox-test--count-full-surface-updates
(ebox-test--region-update region-id :overflow 'visible)
(let ((report (ebox-test--buffer-update-report)))
(setq coverage-owner-id
(car (plist-get report :owner-ids)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should coverage-owner-id)
(should-not (equal coverage-owner-id root-id))
(should (eq (plist-get report :owner-type) 'flex))
(should (= (plist-get report :span-count) 5))
(should-not (plist-get report :root-rerender))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))
(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 (equal (plist-get report :owner-ids)
(list coverage-owner-id)))
(should (= (plist-get report :span-count) 5))
(should-not (plist-get report :root-rerender))
(should-not (string-match-p "another line" plain))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(should (= full-rerenders 0)))))))
(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-create :content "A\nB\nC" :height 1
:overflow 'visible))
(region-id (car (ebox-region-ids target)))
(inner (ebox-stack target (ebox-create :content "TAIL")))
(layout (ebox-stack inner (ebox-create :content "ROOT TAIL")))
(inner-id (ebox--ensure-node-id inner))
(root-id (ebox--ensure-node-id 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-create :content "A\nB\nC" :height 1
:overflow 'visible))
(region-id (car (ebox-region-ids target)))
(wrapper (ebox-create :ebox-content-node target :width '(100)
:height 1 :overflow 'visible))
(layout (ebox-stack wrapper (ebox-create :content "TAIL")))
(wrapper-id (ebox--ensure-node-id wrapper))
(root-id (ebox--ensure-node-id 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-build
'(column :id root :width (100)
(box :id outer :width (100) :height 2 :overflow visible
(box :id inner :width (100)
(box :id target :content "A\nB\nC" :height 1
:overflow visible)))
(box :content "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-build
`(flex :id root :width (200)
(box :id target :content "A\nB\nC" :width (80) :height 1
:overflow ,old-overflow)
(box :id peer :content "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-build
`(flex :id root :width (220)
(box :id card :width (100)
(box :id target :content "A\nB\nC" :width (80) :height 1
:overflow ,old-overflow))
(box :id peer :content "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-build
'(column :id root :width (100)
(box :id wrapper :width (100)
(column
(box :id target :content "A\nB\nC" :height 1
:overflow visible)
(box :content "TAIL")))
(box :content "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-build
'(column :id root :width (760)
(flex :id top-matrix :width (600) :height 5
:flex-flow (row nowrap)
(box :id top-card :width (300)
(box :id top-target
:content "top A\nsubstantially longer top overflow\ntop C"
:width (280) :height 2 :overflow scroll))
(box :content "top P\ntop Q\ntop R\ntop S\ntop T"
:width (300) :height 5))
(flex :id bottom-matrix :width (600) :height 5
:flex-flow (row nowrap)
(box :id bottom-card :width (300)
(box :id bottom-target
:content "bottom A\nbottom overflow\nbottom C"
:width (280) :height 2 :overflow scroll))
(box :content "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 (list 0 '(8) 0 '(8))
:margin-inline '((8) (8))
:margin-block '(0 0)
:margin-top 0 :margin-right '(8)
:margin-bottom 0 :margin-left '(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))
(actual-spans (ebox--region-set-line-spans
(list region-id))))
(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-sequential-sibling-span-patches-invalidate-stale-coordinates ()
"Text-changing sibling patches should not reuse translated snapshot spans."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(column :id root :width (120)
(box :id a :content "A" :width (120) :height 1)
(box :id b :content "B" :width (120) :height 1)))))
(ebox-test--with-rendered-buffer layout
(let* ((a-handle (car (ebox-selector-query-buffer
(current-buffer) "#a")))
(b-handle (car (ebox-selector-query-buffer
(current-buffer) "#b")))
(a-region-id (plist-get a-handle :region-id))
(b-region-id (plist-get b-handle :region-id))
(b-node-id (plist-get b-handle :node-id))
sibling-snapshot-current-p
reports)
(ebox-test--region-update b-region-id :content "BB")
(push (ebox-test--buffer-update-report) reports)
(ebox-test--region-update a-region-id :content "AAAA")
(push (ebox-test--buffer-update-report) reports)
(let* ((snapshot (ebox--layout-snapshot
(current-buffer) b-node-id))
(snapshot-spans (plist-get snapshot :buffer-spans))
(actual-spans (ebox--region-set-line-spans
(list b-region-id))))
(setq sibling-snapshot-current-p
(or (not (plist-member snapshot :buffer-spans))
(equal snapshot-spans actual-spans))))
(ebox-test--region-update b-region-id :content "CCC")
(push (ebox-test--buffer-update-report) reports)
(dolist (report reports)
(should (eq (plist-get report :strategy) 'span-patch)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(should sibling-snapshot-current-p)))))
(ert-deftest ebox-flex-direct-item-inline-margin-rerenders-flex-owner ()
"Direct flex item margin changes should patch the flex line owner."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(flex :id matrix :width (260) :flex-flow (row nowrap)
:column-gap (0)
(box :id target :content "A" :width (80) :height 1
:border "#999999")
(box :id peer :content "B" :width (80) :height 1
:border "#999999")))))
(ebox-test--with-rendered-buffer layout
(let ((region-id (ebox-test--selector-region-id (current-buffer)
"#target")))
(ebox-test--region-update region-id
:margin-left '(30)
:margin-right '(30))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (eq (plist-get report :owner-type) 'flex))
(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-build
'(flex :id matrix :width (680) :flex-flow (row wrap)
:column-gap (12) :row-gap 1
(column :width (210) :border "#999999"
(box :content "Title A" :width (190))
(box :id target :content "width target\nmoves sibling layout"
:width (160) :height 3
:padding (0 (8)) :border "#999999"))
(column :width (210) :border "#999999"
(box :content "Title B" :width (190))
(box :content "Still here" :width (190)))
(column :width (210) :border "#999999"
(box :content "Title C" :width (190))
(box :content "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
(ebox--buffer-root-node (current-buffer))))))
(should (string= (substring-no-properties patched)
(substring-no-properties full))))))))
(ert-deftest ebox-flex-shrunk-definite-item-content-update-keeps-current-slot ()
"A definite 520x4 item may update inside its published shrunk slot."
(ebox-test--reset-runtime-state)
(let ((ebox-viewport-width 500)
(layout
(ebox-build
'(flex :id root :width (500) :height 4
:flex-flow (row nowrap) :align-items flex-start
(box :id fixed :content "peer" :width (320) :height 4
:flex-grow 0 :flex-shrink 0)
(box :id target :content "READY" :width (520) :height 4
:overflow hidden :wrap-mode word)))))
(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))
(old-widths
(plist-get (plist-get snapshot :span-footprint-signature)
:line-pixel-widths)))
(should (= (length old-widths) 4))
(should (< (apply #'max old-widths) 520))
;; Flex measurement may have rendered a temporary sized copy. Keep
;; this regression focused on the current-slot update contract.
(puthash region-id node ebox--region-box-table)
(ebox-test--region-update region-id :content "DONE")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :owner-ids) (list node-id)))
(should-not (plist-get report :full-rerender)))
(should (string-match-p
"DONE"
(buffer-substring-no-properties (point-min) (point-max))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-flex-grown-fixed-basis-item-content-update-keeps-current-slot ()
"A zero-minimum fixed-basis item may update inside its grown slot."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(flex :id root :width (900) :height 24
:flex-flow (row nowrap) :align-items stretch
(box :id list :content "peer" :width stretch :min-width 0
:min-height 24 :flex-grow 4 :flex-shrink 1
:flex-basis (620))
(box :id target :content "detail one" :width stretch :min-width 0
:min-height 24 :flex-grow 2 :flex-shrink 1
:flex-basis (340))))))
(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))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update region-id :content "detail two")
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'span-patch))
(should (equal (plist-get report :owner-ids) (list node-id)))
(should-not (member root-id (plist-get report :owner-ids))))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(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-build
'(flex :id root :width (300) :height 1
:flex-flow (row nowrap) :align-items flex-start
(box :id target :content "A" :width auto :height 1
:overflow hidden :wrap-mode word)
(box :id peer :content "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-buffer-update-report-describes-span-patch ()
"The update report should expose the actual minimal patch strategy."
(ebox-test--reset-runtime-state)
(let* ((box (ebox-create :content "Trace" :width 80 :height 1))
(region-id (car (ebox-region-ids box))))
(ebox-test--with-rendered-buffer box
(ebox-test--region-update region-id :content "Patch")
(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 :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0))
(should (> (plist-get report :tp-operation-count) 0))
(should (> (plist-get report :span-count) 0))
(should (ebox-test--buffer-propertized-matches-runtime-render-p))))))
(ert-deftest ebox-buffer-update-report-describes-owner-rerender-fallback ()
"The update report should expose geometry owner rerender fallback."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "L" :width 40))
(right (ebox-create :content "R" :width 40))
(layout (ebox-concat left right))
(region-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update region-id :width '(120))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (eq (plist-get report :owner-type) 'concat))
(should-not (plist-get report :tp-full-root))
(should (> (plist-get report :tp-scope-count) 0))
(should (ebox-test--buffer-propertized-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-create :content "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-parent-layout-change-keeps-owner-rerender ()
"Geometry updates that move siblings should not be forced into span-patch."
(ebox-test--reset-runtime-state)
(let* ((left (ebox-create :content "L" :width '(40)))
(right (ebox-create :content "R" :width '(40)))
(layout (ebox-concat left right))
(region-id (car (ebox-region-ids layout))))
(ebox-test--with-rendered-buffer layout
(ebox-test--region-update region-id :width '(120))
(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 (eq (plist-get report :owner-type) 'concat))
(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-create :content "Left" :width '(80) :height 1
:text-align 'left))
(right (ebox-create :content "Right" :width '(80) :height 1))
(layout (ebox-concat left right))
(left-id (car (ebox-region-ids layout))))
(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-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-create :content "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-pixel 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-create :content "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-pixel 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-flex-item-box-sizing-update-rerenders-contained-flex ()
"Changing a flex item's box model should rerun its parent's placement."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(column :id root :width (760)
(flex :id outer :width (600) :height 5 :flex-flow (row nowrap)
(box :id card :width (300)
(flex :id inner :width (300) :height 5
:padding (0 (10)) :border "#6F6A95"
:justify-content center :align-items center
(box :id target :content "A\nB"
:width (230) :height 3 :padding (0 (10))
:border "#999999" :box-sizing content-box)))
(box :id peer :content "P\nQ\nR\nS\nT"
:width (300) :height 5))))))
(ebox-test--with-rendered-buffer layout
(let* ((region-id
(ebox-test--selector-region-id (current-buffer) "#target"))
(target-node
(ebox--buffer-region-render-owner-node
(current-buffer) region-id))
(flex-id
(ebox--runtime-parent-id
(current-buffer) (ebox--ensure-node-id target-node)))
(root-id (ebox--buffer-root-node-id (current-buffer))))
(ebox-test--region-update region-id :box-sizing 'border-box)
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (equal (plist-get report :owner-ids) (list flex-id)))
(should (eq (plist-get report :owner-type) 'flex))
(should (= (plist-get report :span-count) 5))
(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-item-block-size-updates-use-fixed-containment-owner ()
"Block-size updates should skip flex-slot proof below fixed containment."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(column :id root :width (760)
(flex :id outer :width (600) :height 5 :flex-flow (row nowrap)
(box :id card :width (300)
(flex :id inner :width (300) :height 5
:padding (0 (10)) :border "#6F6A95"
:justify-content center :align-items center
(box :id target :content "A\nB\nC\nD"
:width (230) :height 2 :overflow scroll
:padding (0 (10)) :border "#999999")))
(box :id peer :content "P\nQ\nR\nS\nT"
:width (300) :height 5))))))
(ebox-test--with-rendered-buffer layout
(let* ((region-id
(ebox-test--selector-region-id (current-buffer) "#target"))
(target-node
(ebox--buffer-region-render-owner-node
(current-buffer) region-id))
(flex-id
(ebox--runtime-parent-id
(current-buffer) (ebox--ensure-node-id target-node)))
(root-id (ebox--buffer-root-node-id (current-buffer)))
(slot-proof-count 0)
(original-slot-proof
(symbol-function 'ebox--flex-item-slot-footprint-safe-p)))
(cl-letf (((symbol-function 'ebox--flex-item-slot-footprint-safe-p)
(lambda (&rest args)
(cl-incf slot-proof-count)
(apply original-slot-proof args))))
(dolist (change '((:height 4) (:min-height 3) (:max-height 1)))
(ebox-test--region-update region-id (car change) (cadr change))
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(span-patch owner-rerender)))
(should (equal (plist-get report :owner-ids) (list flex-id)))
(should (eq (plist-get report :owner-type) 'flex))
(should (= (plist-get report :span-count) 5))
(should-not (member root-id (plist-get report :owner-ids)))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(should (= slot-proof-count 0))))))
(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-create :content "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-pixel 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-covers-all-box-longhands ()
"The update tests should classify every non-content box longhand."
(let ((covered (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-not (cl-set-difference ebox--region-update-longhand-props
covered
:test #'eq))
(should-not (cl-set-difference covered
ebox--region-update-longhand-props
:test #'eq))
(should-not (cl-set-difference ebox--paint-style-signature-keys
(ebox-test--region-update-case-properties
ebox-test--region-update-paint-cases)
:test #'eq))))
(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 ,expected) case))
(ert-info ((format "property %S" property))
(ebox-test--reset-runtime-state)
(let* ((box (ebox-create :content "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 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 ,expected ,_strategy) case))
(ert-info ((format "property %S" property))
(ebox-test--reset-runtime-state)
(let* ((box (ebox-create :content "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 property) expected)))
(let ((report (ebox-test--buffer-update-report)))
(should (memq (plist-get report :strategy)
'(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-create :content "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-get 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-create :content "Color" :width 60
:background-color "#111111"))
(bgcolor-box
(ebox-create :content "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-get background-box :bgcolor) "#111111"))
(should (equal (ebox-get bgcolor-box :bgcolor) "#111111"))
(should-not (plist-member 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-get background-box :bgcolor) "#111111"))
(should (equal (ebox-get runtime-box :bgcolor) "#222222"))
(should-not (plist-member runtime-box :background-color)))
(should-not (plist-member 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-vertical-align-middle-alias-matches-center ()
"Creation and updates should normalize middle to center before rendering."
(ebox-test--reset-runtime-state)
(let ((middle (ebox-create :content "X" :height 3
:vertical-align 'middle)))
(should (equal (ebox--expand-property :vertical-align 'middle)
'(:vertical-align center)))
(should (eq (ebox-get middle :vertical-align) 'center))
(should (= (cl-position-if
(lambda (line)
(string-match-p "X" (substring-no-properties line)))
(ebox-string-lines (ebox-render middle)))
1))
(let* ((updated (ebox-create :content "X" :height 3
:vertical-align 'top))
(region-id (car (ebox-region-ids updated))))
(ebox-test--with-rendered-buffer updated
(goto-char (point-min))
(search-forward "X")
(should (= (line-number-at-pos (match-beginning 0)) 1))
(ebox-test--region-update region-id :vertical-align 'middle)
(should (eq (ebox-get updated :vertical-align) 'top))
(should (eq (ebox-get (gethash region-id
(ebox--buffer-region-box-table
(current-buffer)))
:vertical-align)
'center))
(goto-char (point-min))
(search-forward "X")
(should (= (line-number-at-pos (match-beginning 0)) 2))
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
'span-patch))
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
(ert-deftest ebox-update-reuses-shorthand-property-expansion ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-create :content "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 '(2 "#abcdef")
:margin-inline 3)
(let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (= (ebox-get 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)
(* 2 (ebox--space-pixel-width))))
(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-create :content "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 box :padding-top-height) 0))
(should (= (ebox-get box :padding-right-pixel) 0))
(should (= (ebox-get box :border-left-pixel) 0))
(should (null (ebox-get box :border-right-color)))
(should (= (ebox-get 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)
(ebox--space-pixel-width)))
(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-get 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-color-and-visibility-are-real-and-display-is-ignored ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-create :content "Paint"
:color "#eeeeee"
:display 'none
:visibility 'hidden))
(region-id (car (ebox-region-ids box))))
(should (equal (ebox-get box :color) "#eeeeee"))
(should (equal (ebox--computed-display box) '(block flow)))
(should (eq (ebox-get box :visibility) 'hidden))
(ebox-test--with-rendered-buffer box
;; Hidden at mount: the text is present but carries no face ink.
(let ((position (string-match
"Paint"
(buffer-substring-no-properties (point-min)
(point-max)))))
(should position)
(should-not (get-text-property (+ (point-min) position) 'face)))
(ebox-test--region-update region-id
:color "#123456"
:display 'block
:visibility 'visible)
(let ((runtime-box
(gethash region-id (ebox--buffer-region-box-table
(current-buffer)))))
(should (equal (ebox-get box :color) "#eeeeee"))
(should (equal (ebox-get runtime-box :color) "#123456"))
(should (eq (ebox-get runtime-box :visibility) 'visible)))
(should (equal (ebox--computed-display box) '(block flow)))
(should (eq (ebox-get box :visibility) 'hidden))
(let ((position (string-match
"Paint"
(buffer-substring-no-properties (point-min)
(point-max)))))
(should position)
(should (get-text-property (+ (point-min) position) 'face))))))
(ert-deftest ebox-row-column-and-spacer-compose-layouts ()
(ebox-test--reset-runtime-state)
(let* ((a (ebox-create :content "A" :width 40))
(b (ebox-create :content "B" :width 40))
(c (ebox-create :content "C" :width 40))
(row (ebox-row a nil b c))
(column (ebox-column a b nil c))
(spacer (ebox-spacer :width 20 :height 2)))
(should (equal (ebox-region-ids row) '(1 2 3)))
(should (equal (ebox-region-ids column) '(1 2 3)))
(should (eq (plist-get spacer :ebox-type) 'box))
(should (string-match-p "A.*B.*C"
(substring-no-properties (ebox-render row))))
(should (string-match-p "A[^\n]*\nB[^\n]*\nC"
(substring-no-properties (ebox-render column))))))
(ert-deftest ebox-row-and-column-store-children-as-flat-containers ()
"Row and column should be one container node with direct children."
(ebox-test--reset-runtime-state)
(let* ((children (cl-loop for label in '("A" "B" "C" "D")
collect (ebox-create :content label :width 40)))
(row (apply #'ebox-row children))
(column (apply #'ebox-column children)))
(should (= (length (ebox--node-children row)) 4))
(should (= (length (ebox--node-children column)) 4))
(should (= (length (ebox--runtime-node-ids row)) 5))
(should (= (length (ebox--runtime-node-ids column)) 5))))
(ert-deftest ebox-overflow-hidden-clips-extra-content-lines ()
(ebox-test--reset-runtime-state)
(let* ((box (ebox-create :content "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-create :content "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)
"Assert RENDERED keeps visible overflow painted but unowned."
(let* ((lines (ebox-string-lines rendered))
(body (car lines))
(overflow-lines (cdr lines))
(ownership-properties
(append '(ebox-content-idx ebox-content-owners)
(mapcar #'cdr ebox-region-types)))
(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 (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))
(dolist (property ownership-properties)
(should-not (get-text-property pos property line)))))))
(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-create :content "A\nB\nC"
:width '(40)
:height 1
:overflow 'visible
:color "#abcdef"
:bgcolor "#123456"
:padding-inline '(6)
:margin-inline '(4))))
(ebox-test--assert-visible-overflow-paint
(ebox-render box) "#abcdef" "#123456")))
(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-create :content "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")
(ebox-test--region-update region-id :color "#fedcba")
(ebox-test--assert-visible-overflow-paint
(buffer-string) "#fedcba" "#123456")
(ebox-test--region-update region-id :color nil)
(cl-loop for line in (ebox-string-lines (buffer-string))
for glyph in '(?A ?B ?C)
for glyph-pos = (cl-position glyph line)
do (should (integerp glyph-pos))
do (should-not
(ebox-test--face-has-key-p
(get-text-property glyph-pos 'face line)
:foreground))))))
(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-create :content "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-not (get-text-property pos 'ebox-content-owner))
(should-not (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-create :content "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-create :content "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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :id "child" :content "A" :height 1)
(box :content "B" :height 1)
(box :content "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-create :content "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-create :content "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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :id "child" :content "A" :height 1)
(box :content "B" :height 1)
(box :content "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-build
'(column
(box :id "short-a" :content "A" :height 1)
(box :id "short-b" :content "B" :height 1)
(box :id "root" :height 1 :overflow scroll
(column
(box :content "C" :height 1)
(box :content "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-build
'(box :id "root" :height 1 :overflow scroll
(column
(box :id "child" :content "A" :height 1)
(box :content "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-create :content "row-1\nrow-2\nrow-3"
:width 120
:height 2
:overflow 'scroll
:color "#112233"
:bgcolor "#ddeeff"))
(region-id (ebox--ensure-region-id box))
(reference (ebox-create :content "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 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-create :content content
:width 140
:height 5
:overflow 'scroll
:vertical-align 'top
:color "#112233"
:bgcolor "#ddeeff"))
(region-id (ebox--ensure-region-id box))
(reference (ebox-create :content content
:width 140
:height 5
:overflow 'hidden
:vertical-align 'top
:color "#112233"
:bgcolor "#ddeeff"))
(expected (progn
(plist-put 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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(column :id "viewport" :width (viewport)
:height (viewport-height)
(box :content "" :width (viewport) :height 1)
(box :id "root" :width (viewport)
:height (- (viewport-height) 2)
:overflow scroll
(column :width (viewport) :padding (1 (8))
,@(cl-loop for idx below 80
collect
`(box :content ,(format "row %02d" idx)
:width (viewport)
:height 1)))
(box :content "" :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) 12)))
(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-offset-viewport-resize-stays-lazy ()
"Viewport resize after scrolling should not materialize all hidden content."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
`(column :id "viewport" :width (viewport)
:height (viewport-height)
(box :content "" :width (viewport) :height 1)
(box :id "root" :width (viewport)
:height (- (viewport-height) 2)
:overflow scroll
(column :width (viewport) :padding (1 (8))
,@(cl-loop for idx below 80
collect
`(box :content ,(format "offset row %02d" idx)
:width (viewport)
:height 1)))
(box :content "" :width (viewport) :height 1)))))
buffer
root-id)
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 12)
(ebox-wheel-scroll-step 16)
(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"))
(with-current-buffer buffer
(should (ebox--scroll-region-by root-id 16)))
(ebox-rerender-buffer-with-context buffer 320 14)
(let ((state (ebox--scroll-get-state root-id)))
(should (= (plist-get state :scroll-offset) 16))
(should (plist-get state :render-content-prefix))
(should (plist-get state :materialize-content-lines))
(should (< (length (plist-get state :content-lines)) 80))
(should (>= (length (plist-get state :content-lines))
(+ (plist-get state :scroll-offset)
(plist-get state :content-height))))))
(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-build
`(box :id "root" :width (viewport)
:height (- (viewport-height) 2)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :content ,(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) 10))
(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-build
`(box :id "root" :width (viewport)
:height (- (viewport-height) 10)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :content ,(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-cache-miss-returns-pending ()
"GUI scroll cache misses should not synchronously render missing content."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
`(box :id "root" :width (viewport)
:height (- (viewport-height) 10)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :content ,(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 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 (eq 'pending
(ebox--scroll-region-by root-id 4)))))
(should (= prefix-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-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-build
`(box :id "root" :width (viewport)
:height (- (viewport-height) 10)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :content ,(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)
`(flex :width (viewport) :flex-flow (row wrap)
,@(cl-loop for idx below 4
collect
`(box :content ,(format "%s-%d" label idx)
:width (110)
:height 1)))))
(layout
(ebox-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,(funcall flex-section "a")
(spacer :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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
(box :id "head" :content "head" :width (viewport) :height 1)
(flex :width (viewport) :flex-flow (row wrap)
:row-gap 0 :column-gap 0
,@(cl-loop for idx below 12
collect
`(box :id ,(format "item-%02d" idx)
:content ,(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-lines-match-full-render-prefix ()
"Partial flex window rendering should match the full render prefix."
(ebox-test--reset-runtime-state)
(let* ((node
(ebox-build
`(flex :width (240) :flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
,@(cl-loop for idx below 6
collect
`(box :id ,(format "prefix-item-%02d" idx)
:content ,(format "prefix %02d" idx)
:width (100) :height 2)))))
(limit 4)
(_index (ebox--runtime-index node t))
(full-lines
(let ((ebox--surface-materialization-active t))
(ebox-string-lines (ebox--render-layout node))))
(window (ebox--render-flex-window-lines node limit)))
(should-not (plist-get window :complete))
(should (equal (mapcar #'substring-no-properties
(seq-take full-lines limit))
(mapcar #'substring-no-properties
(plist-get window :lines))))))
(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-build
`(flex :width (160) :flex-flow (row wrap)
(box :id "shrink-a" :content "alpha beta gamma delta"
:width (200) :height 2)
(box :id "shrink-b" :content "second row content"
:width (160) :height 2))))
(limit 2)
(full-lines (ebox-string-lines (ebox-render node)))
(window (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
(ebox-build
`(flex :width (100) :wrap-mode char :flex-flow (row wrap)
:row-gap 0 :column-gap 0
,@(cl-loop for char across "ABCD"
collect
`(box :content ,(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--render-stack-window-lines node 1))
(extended
(ebox--render-stack-window-lines
node 3 (plist-get first :cache)))
(producer (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-flex-window-aligned-wrapper-falls-back-to-full-prefix ()
"Non-left-aligned flex prefixes should use the full-render fallback."
(ebox-test--reset-runtime-state)
(let* ((node
(ebox-build
`(flex :width (100) :text-align center :flex-flow (row wrap)
:row-gap 0 :column-gap 0
,@(cl-loop for char across "ABCDEF"
collect
`(box :content ,(char-to-string char)
:width (50) :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--render-stack-window-lines node 1))
(extended
(ebox--render-stack-window-lines
node 3 (plist-get first :cache)))
(producer (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-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
(ebox-build
`(flex :id "flex-window-wrapper" :width (240)
:flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
,@(cl-loop for idx below 8
collect
`(box :content ,(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--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-window-lines-use-chunk-wrapper-for-first-completion ()
"A compatible seedless completion should avoid the generic full wrapper."
(ebox-test--reset-runtime-state)
(let* ((node
(ebox-build
`(flex :id "flex-window-complete-wrapper" :width (240)
:flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
,@(cl-loop for idx below 4
collect
`(box :content ,(format "complete %02d" idx)
:width (100) :height 2)))))
(_index (ebox--runtime-index node t))
(full
(let ((ebox--surface-materialization-active t))
(ebox--render-layout node)))
(full-wrapper-count 0)
(chunk-wrapper-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)))
(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))))
(let* ((ebox--surface-materialization-active t)
(window (ebox--render-flex-window-lines node 8)))
(should window)
(should (plist-get window :complete))
(should
(equal-including-properties
full
(ebox-lines-join (plist-get window :lines))))))
(should (= full-wrapper-count 0))
(should (= chunk-wrapper-count 1))))
(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-create :width 120
:padding-left 8
:padding-right 8
:border-left 1
:border-right 1
:border-color "#D97757"
:bgcolor "#FFF8F2"))
(source (propertize "child" 'ebox-content 77
'ebox-content-owner 77
'ebox-content-owners '(77)))
(source-before (copy-sequence source))
(expected
(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--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
(ebox-build
`(flex :width (240) :flex-flow (row wrap)
:row-gap 1 :column-gap 0
,@(cl-loop for idx below 8
collect
`(box :id ,(format "cache-item-%02d" idx)
:content ,(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--render-flex-window-lines node 4))
(let ((after-first row-render-count))
(should (> after-first 0))
(should (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
(ebox-build
`(flex :width (240) :flex-flow (row wrap)
:row-gap 1 :column-gap 0
,@(cl-loop for idx below 8
collect
`(box :id ,(format "sig-item-%02d" idx)
:content ,(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) node)
(cl-incf signature-count))
(apply original-signature args))))
(should (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
(ebox-build
`(flex :id "flex-prefix-wrapper" :width (240)
:flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
,@(cl-loop for idx below 8
collect
`(box :content ,(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--render-flex-window-lines node 4))
(should (= generic-wrapper-render-count 0))
(let ((window (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
(ebox-build
`(flex :width (240) :flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8)) :border "#D97757"
:border-top-p nil :bgcolor "#FFF8F2"
,@(cl-loop for idx below 16
collect
`(box :content ,(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--render-flex-window-lines node 4))
(setq join-piece-counts nil)
(should (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
(ebox-build
`(flex :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 "#D97757"
:border-top-p nil
:bgcolor "#FFF8F2"
,@(cl-loop for idx below 2
collect
`(box :content ,(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--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
(ebox-build
`(flex :id "flex-complete-border" :width (240)
:flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8))
:border "#D97757"
:border-top-p nil
:bgcolor "#FFF8F2"
,@(cl-loop for idx below 4
collect
`(box :content ,(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--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--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
(ebox-build
`(flex :id "flex-atomic-prefix" :width (240)
:flex-flow (row wrap)
:row-gap 1 :column-gap 0
:padding (0 (8))
:border "#D97757"
:border-top-p nil
,@(cl-loop for idx below 4
collect
`(box :content ,(format "atomic %02d" idx)
:width (100) :height 2)))))
(ebox--render-cache-table (make-hash-table :test 'equal))
(context (ebox--flex-window-cache-context node)))
(should (ebox--render-flex-window-lines node 4))
(let* ((before (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--render-flex-window-lines node 8)))
(let ((after (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--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-create :border-bottom-p t
:padding-bottom 0)))))
(should
(= 1
(ebox--flex-window-completion-prefix-tail-rewrite-count
(list :box (ebox-create :border-bottom-p t
:padding-bottom 0
:margin-bottom 2)))))
(should
(= 0
(ebox--flex-window-completion-prefix-tail-rewrite-count
(list :box (ebox-create :border-bottom-p t
:padding-bottom 2)))))
(should
(= 0
(ebox--flex-window-completion-prefix-tail-rewrite-count
(list :box (ebox-create :border-bottom-p nil
: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-create :content "" :width '(278) :wrap-mode nil))
(source (list :stack :test-stack :wrapper 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-create :content "" :width 20 :wrap-mode nil))
(source (list :stack :test-stack :wrapper 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-create :content "" :width 20 :wrap-mode nil
:margin-top 1 :padding-top 2)))
(cl-labels
((mapped-range (source-range)
(let* ((source
(list :stack :test-stack :wrapper 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-create :content "" :width 20 :height 1
:wrap-mode nil))
(plain-lines '("head" "body" "tail"))
(source-lines (copy-sequence plain-lines))
(complete-tail
(ebox--propertize-underline "tail" "blue"))
(renderer
(ebox--scroll-window-prefix-renderer 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-create :content "" :width 20 :height 1
:wrap-mode nil))
(wrapper (ebox-create :content "" :width 20 :wrap-mode nil
:padding-top 2 :margin-top 1))
(source (list :stack :test-stack :wrapper 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 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-create :content "" :width 20 :height 1
:wrap-mode nil))
(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-create :content "" :width 20 :height 1
:wrap-mode nil))
(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
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-create :content "" :width 20 :height 1
:wrap-mode nil :overflow 'scroll))
(renderer
(ebox--scroll-window-prefix-renderer
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-scroll-stack-window-defers-hidden-leaf-render ()
"Large stack-backed scroll boxes should render hidden rows only on demand."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport) :padding (1 0)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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)
(ebox-wheel-smooth-scroll t))
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (equal (ebox-tree-node-id 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 (string-match-p "row 00"
(buffer-substring-no-properties
(point-min) (point-max))))
(should-not (string-match-p "row 30"
(buffer-substring-no-properties
(point-min) (point-max))))
(should (ebox--scroll-region-by root-id 31))
(should (= hidden-render-count 0))
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
(dotimes (_ 40)
(when (= hidden-render-count 0)
(ebox--scroll-idle-prefetch root-id)))
(should (> hidden-render-count 0))
(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))))
(should (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-incremental-render-covers-wheel-drain ()
"Incremental lazy scroll renders should cover one smooth wheel drain."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "budget-row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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 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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "cache-row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "smooth-cache-row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "smooth-inline-row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 200
collect
`(box :id ,(format "smooth-large-row-%03d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "smooth-defer-row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "smooth-resume-row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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 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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :content ,(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-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-atomic-item-work ()
"Item-heavy buffers should request only one new line per idle render turn."
(ebox-test--reset-runtime-state)
(let ((region-id "scroll")
buffer
requested-lines)
(unwind-protect
(progn
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-item-prefetch*")
(ebox-build
'(flex :width (240)
(item (box :content "atomic" :width (120)))))))
(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)
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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)))))
(ert-deftest ebox-lazy-scroll-prefix-reuses-exact-source-lines ()
"Exact-width lazy scroll source lines should not be reformatted."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 20
collect
`(box :id ,(format "row-%02d" idx)
:content ,(format "row %02d" idx)
:height 1))))))
(buffer nil)
root-id
(format-count 0)
(original-format (symbol-function 'ebox--format-content-string)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 1)
(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))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(cl-letf (((symbol-function 'ebox--format-content-string)
(lambda (&rest args)
(when (equal (and (listp (car args))
(ebox-get (car args) :id))
"root")
(cl-incf format-count))
(apply original-format args))))
(with-current-buffer buffer
(should (ebox--scroll-region-by root-id 2))))
(should (= format-count 0)))
(when (and buffer (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-build
'(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
(box :content "row 00" :height 1)
(box :id "target" :content "target row" :height 1
:color "#111111")
(box :id "second-target" :content "second target row" :height 1
:color "#111111")
(box :content "row 03" :height 1)
(box :content "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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 12
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 12
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-mixed-scroll-cache-sources ()
"A mixed batch should publish coherent visible and cached scroll content."
(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 :content "batched target row")
(ebox-test--region-update target-id :color "#2563EB")
(ebox-incremental-flush buffer)
(let* ((runtime (gethash target-id ebox--region-box-table))
(visible-before (buffer-string))
(state (ebox-scroll-state root-id))
(content-lines (plist-get state :content-lines))
(rendered-lines (plist-get state :rendered-content-lines)))
(should (= 2 (ebox--scroll-region-by root-id 2)))
(should (= -2 (ebox--scroll-region-by root-id -2)))
(let ((visible-after (buffer-string)))
(should
(equal
(list (ebox-get runtime :content)
(ebox-get runtime :color)
(and (string-match-p
"batched target row"
(substring-no-properties visible-before))
t)
(ebox-test--text-has-foreground-p
visible-before "batched target row" "#2563EB")
(ebox-test--lines-have-text-p
content-lines "batched target row")
(ebox-test--lines-have-text-foreground-p
content-lines "batched target row" "#2563EB")
(ebox-test--lines-have-text-p
rendered-lines "batched target row")
(ebox-test--lines-have-text-foreground-p
rendered-lines "batched target row" "#2563EB")
(and (string-match-p
"batched target row"
(substring-no-properties visible-after))
t)
(ebox-test--text-has-foreground-p
visible-after "batched target row" "#2563EB"))
'("batched target row" "#2563EB"
t t t t 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-create :content "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-appends-rendered-content-lines ()
"Lazy prefix extension should render only newly appended visible lines."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :id ,(format "rendered-tail-row-%02d" idx)
:content ,(format "rendered tail row %02d" idx)
:height 1))))))
(buffer nil)
root-id
rendered-line-counts
(original-rendered-lines
(symbol-function 'ebox--scroll-rendered-content-lines)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-wheel-scroll-step 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(cl-letf (((symbol-function 'ebox--scroll-rendered-content-lines)
(lambda (&rest args)
(push (list (length (cadr args))
(nth 3 args))
rendered-line-counts)
(apply original-rendered-lines args))))
(dotimes (_ 2)
(let* ((state (ebox--scroll-get-state root-id))
(current-lines (length (plist-get state
:content-lines))))
(ebox--scroll-state-ensure-prefix-lines
root-id state (+ current-lines 4)))))
(should
(cl-some (lambda (entry)
(and (<= (car entry) 5)
(integerp (cadr entry))
(> (cadr entry) 0)))
rendered-line-counts))
(dolist (entry rendered-line-counts)
(should (<= (car entry) 8))))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-prefix-avoids-full-prefix-joins ()
"Lazy prefix extension should not join the full cached prefix string."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :id ,(format "join-row-%02d" idx)
:content ,(format "join row %02d" idx)
:height 1))))))
(buffer nil)
root-id
join-sizes
(original-join (symbol-function 'ebox-lines-join)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 2)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-wheel-scroll-step 0))
(setq buffer
(ebox-render-to-buffer
(generate-new-buffer-name " *ebox-test*")
layout))
(setq root-id (ebox-test--selector-region-id buffer "#root"))
(cl-letf (((symbol-function 'ebox-lines-join)
(lambda (&rest args)
(push (length (car args)) join-sizes)
(apply original-join args))))
(let* ((state (ebox--scroll-get-state root-id))
(current-lines (length (plist-get state
:content-lines))))
(ebox--scroll-state-ensure-prefix-lines
root-id state (+ current-lines 4))))
(should-not (cl-some (lambda (size) (> size 5)) join-sizes)))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll)))
(source-node
(ebox-build
`(column :width (viewport)
(flex :id "signature-flex" :width (viewport)
:flex-flow (row wrap)
:row-gap 1 :column-gap (8)
:padding (0 (12)) :border "#D97757"
:border-top-p nil
,@(cl-loop for idx below 24
collect
`(box :content ,(format "signature %02d" idx)
:width (100) :height 2))))))
(source (list :stack source-node))
(renderer (ebox--scroll-window-prefix-renderer
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-prefix-appends-generic-wrapper-lines ()
"Lazy prefix extension should append wrapper chrome without rerendering it."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport) :box-sizing border-box
:padding (1 (8)) :border "#555555"
:bgcolor "#FAFAFA" :color "#111111"
,@(cl-loop for idx below 20
collect
`(box :id ,(format "wrapped-row-%02d" idx)
:content ,(format "wrapped row %02d" idx)
:height 1))))))
(buffer nil)
root-id
(wrapper-render-count 0)
(original-render (symbol-function 'ebox-render)))
(unwind-protect
(let ((ebox-viewport-width 240)
(ebox-viewport-height 1)
(ebox-wheel-scroll-step 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-scroll-lazy-idle-prefetch-lines 3)
(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"))
(with-current-buffer buffer
(let ((state (ebox--scroll-get-state root-id)))
(should (= (length (plist-get state :content-lines)) 2))
(should (plist-get state :render-content-prefix)))
(cl-letf (((symbol-function 'ebox-render)
(lambda (node)
(when (and (listp node)
(eq (plist-get node :ebox-type) 'box)
(plist-get node
:ebox-content-width-exact-p)
(null (plist-get node
:ebox-content-node)))
(cl-incf wrapper-render-count))
(funcall original-render node))))
(ebox--scroll-idle-prefetch root-id))
(should (= wrapper-render-count 0))
(should (> (length (plist-get (ebox--scroll-get-state root-id)
:content-lines))
2))))
(ebox--scroll-cancel-idle-prefetch root-id)
(when (and buffer (buffer-live-p buffer))
(kill-buffer buffer)))))
(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "resize-row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 80
collect
`(box :id ,(format "urgent-row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :content ,(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-build
'(box :id parent :width (120) :color "black"
(column :width (120)
(box :id child :content "Child" :width (120)
:color "blue")
(box :id plain :content "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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 20
collect
`(box :content ,(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 box))
(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-build
'(box :id "root" :width (viewport)
:content "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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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 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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 32
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for index below 40
collect
`(box :content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for index below 40
collect
`(box :content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
(flex :width (300) :height 5 :padding (0 (10))
:border "#6F6A95" :justify-content center
:align-items center
(box :id "target" :content "width target\nexpands"
:width (190) :height 3 :padding (0 (10))
:border "#9CA3AF" :text-align center))
,@(cl-loop for index below 40
collect
`(box :content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-create :content "" :width 10))
(renderer (ebox--scroll-window-prefix-renderer
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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 8
collect
`(box :id ,(format "metric-%02d" idx)
:content "待运行"
: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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 8
collect
`(box :id ,(format "metric-%02d" idx)
:content "待运行"
: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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 24
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
'(column :width (viewport)
(box :content "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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 32
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
,@(cl-loop for idx below 32
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
'(box :id "root" :width (viewport) :height 2
(box :content "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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(flex :width (viewport) :gap 1
,@(cl-loop for index below 8
collect
`(box :id ,(format "card-%d" index)
:content ,(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-build
'(box :id "root" :width (viewport) :content "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-local-update-cancels-without-scheduling-reflow-prewarm ()
"Single-property updates should cancel stale predictions and schedule none."
(ebox-test--reset-runtime-state)
(let* ((layout
(ebox-build
'(box :id "root" :width (viewport)
(box :id "target" :content "old" :width 80))))
buffer)
(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))
(ebox-rerender-buffer-with-context buffer 260 4)
(ebox-rerender-buffer-with-context buffer 280 4)
(should (gethash buffer ebox--reflow-cache-prewarm-timers))
(ebox-selector-update-buffer buffer "#target" :content "new")
(should-not (gethash buffer ebox--reflow-cache-prewarm-timers))
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
'span-patch)))
(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-build
`(box :id "root" :width (viewport) :height 2 :overflow scroll
(column
,@(cl-loop for idx below 40
collect
`(box :id ,(format "row-%02d" idx)
:content ,(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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :content "one" :height 1)
(box :content "two" :height 1)
(box :content "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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :content "one" :height 1)
(box :content "two" :height 1)
(box :content "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-build
'(box :id "root" :width (viewport) :height 2
:overflow scroll
(column
(box :content "one" :height 1)
(box :content "two" :height 1)
(box :content "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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :id "target" :content "target" :height 1)
(box :id "row-1" :content "row 1" :height 1)
(box :id "row-2" :content "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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :content "row 0" :height 1)
(box :content "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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :content "row 0" :height 1)
(box :content "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-build
'(box :height 2 :overflow scroll
(column
(box :content "row 0" :height 1)
(box :content "row 1" :height 1)
(box :content "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-build
'(column
(box :id "a" :content "A")
(box :id "b" :content "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-build
'(column
(box :id "target" :content "old")
(box :id "sibling" :content "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-build '(box :id "target" :content "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-build
'(column
(box :content "row 0")
(box :content "row 1")
(box :content "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-build
'(box :width (210)
(column
(box :id "width-only" :width (viewport))
(box :id "height-only"
:height (viewport-height))
(flex :id "flex-height"
:min-height (- (viewport-height) 1)
(box :content "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-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-build
`(box :id "root" :height 2 :overflow scroll
(column
,@(cl-loop for idx below 40
collect
`(box :content ,(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-build
'(box :id "root" :height 1 :overflow scroll
(column
(box :content "visible" :height 1)
(box :id "target" :content "old" :height 1)
(box :content "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 `(box :id ,(format "row-%02d" idx)
:content ,(format "row %02d" idx)
:height 1)))
(layout (ebox-build
`(box :id "root" :height 2 :overflow scroll
(column ,@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-build
`(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
(flex :id "scene" :width (viewport) :height 4
:align-items center :justify-content center
(box :id "target" :content "SEED" :width (180) :height 3
:overflow hidden :bgcolor "#DDEAE4"))
,@(cl-loop for index below 40
collect
`(box :content ,(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 `(box :id ,(format "row-%02d" index)
:content ,(format "row %02d" index)
:height 1)))
(layout
(ebox-build
`(column :width (viewport)
(box :id "rail" :content "ready" :width (viewport)
:height 1)
(box :id "root" :width (viewport)
:height (viewport-height) :overflow scroll
(column ,@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-build
'(box :id "root" :width (180) :height 2 :overflow scroll
(column
(box :content "row 0" :height 1)
(box :content "row 1" :height 1)
(box :content "row 2" :height 1)
(box :id "target" :content "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-scroll-hidden-update-keeps-the-visible-window ()
"Updating a scrolled-away node should not reuse its stale buffer spans."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(box :id "root" :width (180) :height 2 :overflow scroll
(column
(box :id "target" :content "target" :width (120) :height 1
:box-sizing border-box :padding-left (2))
(box :content "row 1" :height 1)
(box :content "row 2" :height 1)
(box :content "row 3" :height 1)
(box :content "row 4" :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"))
(should (ebox--scroll-region-by root-id 3 48))
(let ((visible-before
(buffer-substring-no-properties (point-min) (point-max))))
(ebox-test--region-update target-id :padding-left (list 12))
(should (equal visible-before
(buffer-substring-no-properties
(point-min) (point-max)))))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should (plist-get report :scroll-state-patch))
(should-not (plist-get report :tp-full-root))
(should (= (plist-get report :tp-operation-count) 0)))
(should-not
(text-property-any
(point-min) (point-max) 'ebox-content target-id))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(let ((visible-before
(buffer-substring-no-properties (point-min) (point-max))))
(ebox-test--region-update target-id :width (list 400))
(should (equal visible-before
(buffer-substring-no-properties
(point-min) (point-max)))))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (plist-get report :root-rerender))
(should (plist-get report :scroll-state-patch))
(should (= (plist-get report :tp-operation-count) 0)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(should (ebox--scroll-region-by root-id -3 48))
(should
(text-property-any
(point-min) (point-max) 'ebox-content target-id))
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
(ert-deftest ebox-nested-scroll-hidden-geometry-replays-both-caches ()
"A hidden inner-scroll update should survive inner and outer replay."
(ebox-test--reset-runtime-state)
(let ((layout
(ebox-build
'(box :id "root" :width (180) :height 1 :overflow scroll
(column
(box :id "wrapper" :width (180) :height 1 :overflow scroll
(column
(box :id "target" :content "target" :width (120)
:height 1 :box-sizing border-box
:padding-left (2))
(box :content "inner tail" :height 1)))
(box :content "outer tail" :height 1)))))
root-id wrapper-id target-id)
(ebox-test--with-rendered-buffer layout
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
(setq wrapper-id
(ebox-test--selector-region-id (current-buffer) "#wrapper"))
(setq target-id
(ebox-test--selector-region-id (current-buffer) "#target"))
(should (ebox--scroll-region-by wrapper-id 1 48))
(should-not
(text-property-any
(point-min) (point-max) 'ebox-content target-id))
(let ((visible-before
(buffer-substring-no-properties (point-min) (point-max))))
(ebox-test--region-update target-id :padding-left (list 12))
(should (equal visible-before
(buffer-substring-no-properties
(point-min) (point-max)))))
(let ((report (ebox-test--buffer-update-report)))
(should (eq (plist-get report :strategy) 'owner-rerender))
(should-not (plist-get report :root-rerender))
(should (plist-get report :scroll-state-patch))
(should (= (plist-get report :scroll-state-sync-count) 2))
(should (= (plist-get report :tp-operation-count) 0)))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(should (ebox--scroll-region-by root-id 1 48))
(should (ebox--scroll-region-by root-id -1 48))
(should-not
(text-property-any
(point-min) (point-max) 'ebox-content target-id))
(should (ebox-test--buffer-visually-matches-runtime-render-p))
(should (ebox--scroll-region-by wrapper-id -1 48))
(should
(text-property-any
(point-min) (point-max) 'ebox-content 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-build
'(box :id "root" :height 2 :overflow scroll
(column
(box :content "A" :height 1)
(box :content "B" :height 1)
(box :content "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-build
'(box :id "outer" :height 4 :overflow scroll
(column
(box :id "middle" :height 2 :overflow scroll
(column
(box :id "inner" :content "inner 0\ninner 1"
:height 1 :overflow scroll)
(box :content "middle 0" :height 1)
(box :content "middle 1" :height 1)
(box :content "middle 2" :height 1)))
(box :content "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-build
'(box :id "root" :content "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-build
'(box :id "root" :height 1 :overflow scroll
(column
(box :content "A" :height 1)
(box :content "B" :height 1)
(box :content "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-build
'(box :id "root" :height 1 :overflow scroll
(column
(box :content "A" :height 1)
(box :content "B" :height 1)
(box :content "C" :height 1)
(box :content "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-build
'(box :id "root" :height 1 :overflow scroll
(column
(box :content "A" :height 1)
(box :content "B" :height 1)
(box :content "C" :height 1)
(box :content "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-build
'(box :id "root" :height 3 :overflow scroll
(column
(box :id "child" :content "A" :height 1)
(box :content "B" :height 1)
(box :content "C" :height 1)
(box :content "D" :height 1)
(box :content "E" :height 1)
(box :content "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-scroll-rerender-preserves-viewport-height-context ()
"Scroll rerender fallback should keep viewport-height boxes clipped."
(ebox-test--reset-runtime-state)
(let* ((layout (ebox-build
'(box :id "root" :height (viewport-height) :overflow scroll
(column
(box :id "child" :content "A" :height 1)
(box :content "B" :height 1)
(box :content "C" :height 1)
(box :content "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-create :content "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-create :content "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-create :content "A"
:box-sizing 'content-box
:min-height 5
:max-height 3
:padding '(1 0)
:overflow 'hidden))
(rendered (ebox-render box)))
(should (= (ebox--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-create :content "A"
:box-sizing 'border-box
:min-height 5
:max-height 3
:padding '(1 0)
:overflow 'hidden))
(rendered (ebox-render box)))
(should (= (ebox--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-create :content "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--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-create :content content))
(rendered (ebox-render box))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))))
(should (= (ebox--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-create
(append (list :content "short line wraps into viewport"
:padding-inline '(20)
:border-left '(10)
:border-right '(10)
:margin-inline '(5))
(when width-value
(list :width width-value))))))
(should (= (ebox--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-create :content 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-build
'(column :width (120)
(box :content "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-build
'(column :width (400)
(box :content "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-build
'(column :width (240)
(flex :flex-wrap wrap :column-gap (10)
(box :content "A" :width (100))
(box :content "B" :width (100))
(box :content "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-column
(ebox-create :content "Auto row")
(ebox-create :content "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--content-pixel
(ebox-create :content content :width 'auto))
max-content))
(should (= (ebox--content-pixel
(ebox-create :content content :width 'max-content))
max-content))
(should (= (ebox--content-pixel
(ebox-create :content content :width 'min-content))
min-content))
(should (= (ebox--content-pixel
(ebox-create :content content
:width '(80)
:max-width 'none))
80))
(should-error (ebox-create :content 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-create
:width 'max-content
:ebox-content-node
(apply #'ebox-column
(mapcar (lambda (line)
(ebox-create :content 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-create :content "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--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-create :content 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--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-create :content "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--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--content-pixel
(ebox-create :content content
:width (list 'fit-content (list between))))
between))
(should (= (ebox--content-pixel
(ebox-create :content content
:width (list 'fit-content (list 1))))
min-content))
(should (= (ebox--content-pixel
(ebox-create :content 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--content-pixel
(ebox-create :content 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--content-pixel
(ebox-create :content content
:width '(1)
:min-width 'max-content))
max-content))
(should (= (ebox--content-pixel
(ebox-create :content content
:width (list (+ max-content 50))
:max-width 'min-content))
min-content))
(should (= (ebox--content-pixel
(ebox-create :content content
:width '(1)
:min-width (list 'fit-content
(list between))))
between))
(should (= (ebox--content-pixel
(ebox-create :content 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-create :content "x"
:width keyword
:padding-inline '(20)
:border-left '(10)
:border-right '(10)
:margin-inline '(5))))
(should (= (ebox--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-create :content "W"
:max-width '(20)
:wrap-mode 'char)))
(should (= (ebox--content-pixel box) 20))
(should (= (ebox--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-create :content "short"
:width '(40)
:min-width '(120)
:max-width '(80))))
(should (= (ebox--content-pixel box) 120))
(should (= (ebox--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-create :content "bad" :width '(-1)) :type 'error)
(should-error (ebox-create :content "bad" :min-width '(-1)) :type 'error)
(should-error (ebox-create :content "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-create (append '(:content "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-create :content "Atomic"
:width '(120)
:bgcolor "#111111"
:padding-left-pixel 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-pixel -1)
:type 'error)
(should (equal (ebox-get box :bgcolor) "#111111"))
(should (= (ebox-get box :padding-left-pixel) 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-build
'(box :id "root" :width (240) :height 1 :overflow scroll
(box :id "target" :content "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-build
'(box :id "root" :width (viewport) :height (viewport-height)
:overflow scroll
(column :width (viewport)
(box :id "target" :content "paint" :width (120)
:color "#111111")
(box :content "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-build
'(box :id "target" :content "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-create :content 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-create :content "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-create :content "ignored"
:width '(100)
:wrap-mode 'kp
:text-align align))
(width (ebox--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-create :content "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-create :content "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-create :content "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-create :content "Border"
:width 60
:padding '(1 1)
:border t)))
(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-create :content "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-create :content "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-create :content "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-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-flex-recache-skips-visited-source-subtrees ()
"Repeated source recache calls should not walk the same subtree twice."
(ebox-test--reset-runtime-state)
(let* ((source (ebox-column
(ebox-create :content "A")
(ebox-create :content "B")))
(ebox--render-recached-source-node-cache
(make-hash-table :test 'eq))
(original-puthash (symbol-function 'puthash))
(region-table-writes 0))
(ebox-region-ids source)
(cl-letf (((symbol-function 'puthash)
(lambda (key value table)
(when (eq table ebox--region-box-table)
(cl-incf region-table-writes))
(funcall original-puthash key value table))))
(ebox--flex-recache-source-boxes source)
(ebox--flex-recache-source-boxes source))
(should (= region-table-writes 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-create :content "Hello" :width (list 90) :border t
:border-color "#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-create
:width (list 300)
:ebox-content-node
(ebox-column
(ebox-create :content "Header line" :id 'header
:width (list 300))
(ebox-create :content "Secret content" :id 'secret
:width (list 300) :border t
:bgcolor "#224466" :color "#FFFFFF")
(ebox-create :content "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-create
:id 'parent :width (list 400)
:border (list '(4) 'solid parent-color)
:padding '(1 (12))
:ebox-content-node
(ebox-column
(ebox-create :id 'child :content "CHILD" :width (list 200)
:border '((4) 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))))
;;; ebox-core-render-tests.el ends here