Reconcile duplicated native ancestry against the verified equivalent base. Preserve current source ownership, scroll partition, allocated owner rendering, and later retained-state repairs while adapting their consumers and existing fixtures to persistent runtime indexes. Validation: strict byte compilation of 28 source files, static syntax across 56 Lisp files, cargo check, and native release build passed. Regression suites and benchmarks were not run at the user's direction.
14043 lines
648 KiB
EmacsLisp
14043 lines
648 KiB
EmacsLisp
;;; ebox-core-render-tests.el --- Core render and update regression tests -*- lexical-binding: t; -*-
|
|
|
|
(require 'ert)
|
|
(require 'cl-lib)
|
|
|
|
(unless load-file-name
|
|
(error "This test file must be loaded from disk, not eval'ed directly"))
|
|
|
|
(setq load-prefer-newer t)
|
|
|
|
(load-file (expand-file-name "../ebox.el" (file-name-directory load-file-name)))
|
|
|
|
;; Core tests assert named Elisp render/patch strategies. Keep optional local
|
|
;; native artifacts from replacing those strategies; native execution has
|
|
;; separate module and integration gates.
|
|
(defvar ebox-native-reflow-module-path)
|
|
(setq ebox-native-reflow-module-path nil)
|
|
|
|
(defvar ebox--scroll-window-initial-lookahead-lines-override nil)
|
|
|
|
(defun ebox-test--reset-runtime-state ()
|
|
"Reset global runtime state used by ebox rendering tests."
|
|
(setq ebox--region-id-counter 0)
|
|
(setq ebox--runtime-node-id-counter 0)
|
|
(setq ebox--render-region-id nil)
|
|
(setq ebox--scroll-allow-noninteractive-prefetch t)
|
|
(ebox-cache-reset-default-registry)
|
|
(dolist (table (list ebox--region-box-table
|
|
ebox--buffer-render-state-table
|
|
ebox--scroll-global-state
|
|
ebox--render-root-cache-table-table
|
|
ebox--render-cache-entry-side-effects-table
|
|
ebox--rendered-uniform-width-table
|
|
ebox--flex-sized-render-observation-table))
|
|
(clrhash table))
|
|
(maphash (lambda (_region-id timer)
|
|
(when (timerp timer)
|
|
(cancel-timer timer)))
|
|
ebox--scroll-idle-prefetch-timers)
|
|
(clrhash ebox--scroll-idle-prefetch-timers)
|
|
(maphash (lambda (_buffer timer)
|
|
(when (timerp timer)
|
|
(cancel-timer timer)))
|
|
ebox--runtime-prewarm-timers)
|
|
(clrhash ebox--runtime-prewarm-timers)
|
|
(clrhash ebox--runtime-prewarm-jobs)
|
|
(maphash (lambda (_buffer timer)
|
|
(when (timerp timer)
|
|
(cancel-timer timer)))
|
|
ebox--reflow-cache-prewarm-timers)
|
|
(clrhash ebox--reflow-cache-prewarm-timers)
|
|
(when (timerp ebox--deferred-render-gc-timer)
|
|
(cancel-timer ebox--deferred-render-gc-timer))
|
|
(setq ebox--deferred-render-gc-timer nil)
|
|
(setq ebox--deferred-render-gc-state nil)
|
|
(setq ebox--deferred-render-gc-depth 0)
|
|
(setq ebox--render-burst-stack nil)
|
|
(clrhash ebox--render-burst-records))
|
|
|
|
(defmacro ebox-test--with-rendered-buffer (layout &rest body)
|
|
"Render LAYOUT into a temporary ebox buffer and run BODY there."
|
|
(declare (indent 1))
|
|
`(let* ((buffer-name (generate-new-buffer-name " *ebox-test*"))
|
|
(buffer (ebox-render-to-buffer buffer-name ,layout)))
|
|
(unwind-protect
|
|
(with-current-buffer buffer
|
|
,@body)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(defun ebox-test--set-buffer-update-report (buffer report)
|
|
"Store REPORT in BUFFER's render state."
|
|
(plist-put (ebox--buffer-render-state buffer) :last-update-report report))
|
|
|
|
(defun ebox-test--buffer-update-report (&optional buffer)
|
|
"Return BUFFER's update report, defaulting to the active rendered buffer."
|
|
(let ((target
|
|
(or buffer
|
|
(and (ebox--buffer-render-state (current-buffer))
|
|
(current-buffer))
|
|
(let (candidate ambiguous)
|
|
(maphash
|
|
(lambda (render-buffer _state)
|
|
(when (buffer-live-p render-buffer)
|
|
(if candidate
|
|
(setq ambiguous t)
|
|
(setq candidate render-buffer))))
|
|
ebox--buffer-render-state-table)
|
|
(and (not ambiguous) candidate)))))
|
|
(ebox-buffer-update-report target)))
|
|
|
|
(defun ebox-test--runtime-root (buffer)
|
|
"Return BUFFER's surface-owned runtime root."
|
|
(plist-get (ebox--buffer-render-state buffer) :root-node))
|
|
|
|
(defun ebox-test--input-content-pixel (input)
|
|
"Return INPUT's used content width at the private layout boundary."
|
|
(let ((ebox--render-source-index (ebox-test-source-index input)))
|
|
(ebox--content-pixel (ebox-test-root input))))
|
|
|
|
(defun ebox-test--input-total-pixel (input)
|
|
"Return INPUT's used outer width at the private layout boundary."
|
|
(let ((ebox--render-source-index (ebox-test-source-index input)))
|
|
(ebox--total-pixel (ebox-test-root input))))
|
|
|
|
(defun ebox-test--input-content-height (input line-count)
|
|
"Return INPUT's used content height for LINE-COUNT layout lines."
|
|
(let ((ebox--render-source-index (ebox-test-source-index input)))
|
|
(ebox--content-height (ebox-test-root input) line-count)))
|
|
|
|
(defun ebox-test--render-input-node (input)
|
|
"Render INPUT through the private node boundary with its retained facts."
|
|
(ebox--render-node (ebox-test-root input) (ebox-test-source-index input)))
|
|
|
|
(defun ebox-test--call-input-node (function input &rest arguments)
|
|
"Call private node FUNCTION for INPUT with its retained source generation."
|
|
(let ((ebox--render-source-index (ebox-test-source-index input)))
|
|
(apply function (ebox-test-root input) arguments)))
|
|
|
|
(defun ebox-test--selector-node (buffer selector)
|
|
"Return the first runtime node matching SELECTOR in BUFFER."
|
|
(plist-get (car (ebox-selector-query-buffer buffer selector)) :node))
|
|
|
|
(defun ebox-test--selector-node-id (buffer selector)
|
|
"Return the first runtime node id matching SELECTOR in BUFFER."
|
|
(plist-get (car (ebox-selector-query-buffer buffer selector)) :node-id))
|
|
|
|
(defun ebox-test--region-handle-for-id (region-id)
|
|
"Return the unique live surface handle for test REGION-ID."
|
|
(let (handle ambiguous)
|
|
(maphash
|
|
(lambda (buffer state)
|
|
(when (and (buffer-live-p buffer)
|
|
(gethash region-id (plist-get state :region-id-set)))
|
|
(let* ((object
|
|
(gethash region-id
|
|
(plist-get state :region-surface-object-table)))
|
|
(candidate
|
|
(and object
|
|
(ebox-selector--live-region-handle buffer object))))
|
|
(when candidate
|
|
(if handle
|
|
(setq ambiguous t)
|
|
(setq handle candidate))))))
|
|
ebox--buffer-render-state-table)
|
|
(when ambiguous
|
|
(error "Test region id is ambiguous across mounted surfaces: %S"
|
|
region-id))
|
|
(or handle
|
|
(error "Test region id has no mounted surface handle: %S" region-id))))
|
|
|
|
(defun ebox-test--region-update (target &rest props)
|
|
"Update test TARGET through the public surface-scoped handle API."
|
|
(apply #'ebox-region-update
|
|
(if (ebox-region-handle-p target)
|
|
target
|
|
(ebox-test--region-handle-for-id target))
|
|
props))
|
|
|
|
(ert-deftest ebox-scroll-window-lookahead-accepts-root-reflow-override ()
|
|
"Root reflow should be able to defer all hidden lookahead work."
|
|
(let ((ebox-scroll-lazy-prefix-lookahead-lines 8)
|
|
(ebox-wheel-scroll-step 16)
|
|
(ebox--scroll-window-initial-lookahead-lines-override 0))
|
|
(should (= (ebox--scroll-window-initial-lookahead-lines) 0))))
|
|
|
|
(ert-deftest ebox-root-reflow-lookahead-keeps-large-runtimes-demand-driven ()
|
|
"Root reflow should not eagerly cross costly rows in a large runtime."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer (generate-new-buffer " *ebox-large-lookahead*"))
|
|
(node-table (make-hash-table :test 'equal))
|
|
(runtime-type-count-table (make-hash-table :test 'eq)))
|
|
(unwind-protect
|
|
(progn
|
|
(dotimes (index 512)
|
|
(puthash index t node-table))
|
|
(puthash buffer
|
|
(list :node-table node-table
|
|
:runtime-type-count-table runtime-type-count-table)
|
|
ebox--buffer-render-state-table)
|
|
(should
|
|
(= (ebox--root-reflow-scroll-lookahead-lines-for-buffer buffer)
|
|
0)))
|
|
(remhash buffer ebox--buffer-render-state-table)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-root-reflow-lookahead-keeps-typed-flex-demand-driven ()
|
|
"A typed Flex runtime should not eagerly cross hidden composite rows."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-typed-flex-lookahead*")
|
|
(ebox-build
|
|
'(flex :width (240) :flex-flow (row wrap)
|
|
(box :width (100) "A")
|
|
(box :width (100) "B"))))))
|
|
(unwind-protect
|
|
(should (zerop
|
|
(ebox--root-reflow-scroll-lookahead-lines-for-buffer
|
|
buffer)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-exact-scroll-prefix-skips-completion-sentinel ()
|
|
"Interactive prefix extension should request only the visible line bound."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((region-id 'exact-prefix)
|
|
(all-lines '("zero" "one" "two" "three" "four"))
|
|
requested-limits
|
|
(render-prefix
|
|
(lambda (_state _region-id limit)
|
|
(push limit requested-limits)
|
|
(list :content-lines (seq-take all-lines limit)
|
|
:rendered-content-lines (seq-take all-lines limit)
|
|
:content-height 2
|
|
:complete nil)))
|
|
(state (list :content-lines (seq-take all-lines 3)
|
|
:rendered-content-lines (seq-take all-lines 3)
|
|
:content-height 2
|
|
:render-content-prefix render-prefix)))
|
|
(let ((ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(ebox--scroll-state-ensure-prefix-lines region-id state 4 t))
|
|
(should (equal requested-limits '(4)))
|
|
(setq requested-limits nil
|
|
state (list :content-lines (seq-take all-lines 3)
|
|
:rendered-content-lines (seq-take all-lines 3)
|
|
:content-height 2
|
|
:render-content-prefix render-prefix))
|
|
(let ((ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(ebox--scroll-state-ensure-prefix-lines region-id state 4))
|
|
(should (equal requested-limits '(5)))))
|
|
|
|
(ert-deftest ebox-scroll-lightweight-line-bounds-extend-incrementally ()
|
|
"Lazy line bounds should extend without rebuilding prior prefix entries."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((line-a (propertize "a" 'ebox-content 11))
|
|
(line-b (propertize "b" 'ebox-content 12))
|
|
(line-c (propertize "c" 'ebox-content 11))
|
|
(ebox--defer-scroll-content-index t)
|
|
(state (ebox--scroll-state-set-lines nil (list line-a line-b)))
|
|
(index (plist-get state :region-line-bounds-index)))
|
|
(should (equal (gethash 11 index) '(0 . 0)))
|
|
(should (equal (gethash 12 index) '(1 . 1)))
|
|
(setq state
|
|
(ebox--scroll-state-set-lines
|
|
state (list line-a line-b line-c)))
|
|
(should (eq index (plist-get state :region-line-bounds-index)))
|
|
(should (equal (gethash 11 index) '(0 . 2)))
|
|
(should (equal (gethash 12 index) '(1 . 1)))
|
|
(should (plist-get state :region-line-span-index-deferred))))
|
|
|
|
(ert-deftest ebox-scroll-prefix-publishes-private-line-with-suffix-paint ()
|
|
"A cached hidden source line should not rebuild the rendered prefix."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 2))
|
|
(content-lines '("zero" "one" "two" "three"))
|
|
(state (list :content-lines (seq-take content-lines 3)
|
|
:rendered-content-lines
|
|
(seq-take content-lines 3)
|
|
:content-height 2))
|
|
(source-renders 0)
|
|
rendered-requests
|
|
(original-rendered-lines
|
|
(symbol-function 'ebox--scroll-rendered-content-lines))
|
|
(renderer
|
|
(ebox--scroll-window-prefix-renderer
|
|
(ebox-test-root box) '(:stack ignored) 20 2 nil content-lines)))
|
|
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
|
|
(lambda (&rest _)
|
|
(cl-incf source-renders)
|
|
(error "Private cached line should satisfy the prefix")))
|
|
((symbol-function 'ebox--scroll-rendered-content-lines)
|
|
(lambda (&rest args)
|
|
(push (list (length (cadr args)) (nth 3 args))
|
|
rendered-requests)
|
|
(apply original-rendered-lines args))))
|
|
(let ((result (funcall renderer state 'private-prefix 4)))
|
|
(should (= source-renders 0))
|
|
(should (equal rendered-requests '((1 3))))
|
|
(should (= (length (plist-get result :content-lines)) 4))
|
|
(should (= (length (plist-get result :rendered-content-lines)) 4))))))
|
|
|
|
(defun ebox-test--face-has-key-value-p (face key value)
|
|
"Return non-nil when FACE contains KEY with VALUE."
|
|
(cond
|
|
((null face) nil)
|
|
((and (listp face) (keywordp (car face)))
|
|
(equal (plist-get face key) value))
|
|
((listp face)
|
|
(cl-some (lambda (item)
|
|
(ebox-test--face-has-key-value-p item key value))
|
|
face))
|
|
(t nil)))
|
|
|
|
(defun ebox-test--face-has-key-p (face key)
|
|
"Return non-nil when FACE contains KEY."
|
|
(cond
|
|
((null face) nil)
|
|
((and (listp face) (keywordp (car face)))
|
|
(plist-member face key))
|
|
((listp face)
|
|
(cl-some (lambda (item)
|
|
(ebox-test--face-has-key-p item key))
|
|
face))
|
|
(t nil)))
|
|
|
|
(defun ebox-test--effective-face-value (face key)
|
|
"Return the first anonymous FACE value declared for KEY."
|
|
(cond
|
|
((null face) nil)
|
|
((and (listp face) (keywordp (car face)))
|
|
(and (plist-member face key) (plist-get face key)))
|
|
((listp face)
|
|
(cl-loop for item in face
|
|
when (and (listp item)
|
|
(keywordp (car item))
|
|
(plist-member item key))
|
|
return (plist-get item key)))))
|
|
|
|
(defun ebox-test--face-visual-attributes (face)
|
|
"Return FACE attributes normalized by Emacs anonymous-face precedence."
|
|
(let ((entries (if (and (listp face) (not (keywordp (car-safe face))))
|
|
face
|
|
(list face)))
|
|
attributes named)
|
|
(dolist (entry entries)
|
|
(cond
|
|
((null entry))
|
|
((and (listp entry) (keywordp (car-safe entry)))
|
|
(cl-loop for (key value) on entry by #'cddr
|
|
unless (assq key attributes)
|
|
do (push (cons key value) attributes)))
|
|
(t (setq named t))))
|
|
(if named
|
|
(list 'raw face)
|
|
(list 'visual
|
|
(sort attributes
|
|
(lambda (left right)
|
|
(string< (symbol-name (car left))
|
|
(symbol-name (car right)))))))))
|
|
|
|
(defun ebox-test--border-face-has-color-p (face role color)
|
|
"Return non-nil when FACE paints border ROLE with COLOR."
|
|
(pcase role
|
|
('bt
|
|
(ebox-test--face-has-key-value-p face :overline color))
|
|
('bb
|
|
(ebox-test--face-has-key-value-p
|
|
face :underline (list :position t :color color)))
|
|
((or 'bl 'br)
|
|
(ebox-test--face-has-key-value-p face :background color))))
|
|
|
|
(defun ebox-test--face-at-property (string property)
|
|
"Return first face in STRING at PROPERTY."
|
|
(let ((pos 0)
|
|
(len (length string))
|
|
face)
|
|
(while (and (< pos len) (not face))
|
|
(when (get-text-property pos property string)
|
|
(setq face (get-text-property pos 'face string)))
|
|
(setq pos (or (next-single-property-change pos property string)
|
|
(1+ pos))))
|
|
face))
|
|
|
|
(defun ebox-test--first-content-face (string)
|
|
"Return first content face in STRING."
|
|
(ebox-test--face-at-property string 'ebox-content))
|
|
|
|
(defmacro ebox-test--count-full-surface-updates (&rest body)
|
|
"Run BODY and return the number of full TP surface updates it triggered."
|
|
(declare (indent 0))
|
|
`(let ((full-updates 0)
|
|
(original-full-update (symbol-function 'tp-surface-update)))
|
|
(cl-letf (((symbol-function 'tp-surface-update)
|
|
(lambda (&rest args)
|
|
(cl-incf full-updates)
|
|
(apply original-full-update args))))
|
|
,@body)
|
|
full-updates))
|
|
|
|
(defconst ebox-test--region-update-paint-cases
|
|
'((:color "#123456" :color "#123456")
|
|
(:bgcolor "#FDE68A" :bgcolor "#FDE68A")
|
|
(:border-left-color "#111111" :border-left-color "#111111")
|
|
(:border-right-color "#222222" :border-right-color "#222222")
|
|
(:border-top-color "#333333" :border-top-color "#333333")
|
|
(:border-bottom-color "#444444" :border-bottom-color "#444444")
|
|
(:visibility hidden :visibility hidden))
|
|
"Paint updates as (AUTHOR VALUE ENGINE-FIELD EXPECTED).")
|
|
|
|
(defconst ebox-test--region-update-geometry-cases
|
|
'((:box-sizing content-box :box-sizing content-box owner-rerender)
|
|
(:width (120) :width 120 owner-rerender)
|
|
(:min-width (100) :min-width 100 owner-rerender)
|
|
(:max-width (140) :max-width 140 owner-rerender)
|
|
(:height 3 :height 3 owner-rerender)
|
|
(:min-height 3 :min-height 3 owner-rerender)
|
|
(:max-height 1 :max-height 1 owner-rerender)
|
|
(:padding-left (9) :padding-left-pixel 9 span-patch)
|
|
(:padding-right (10) :padding-right-pixel 10 span-patch)
|
|
(:padding-top 1 :padding-top-height 1 span-patch)
|
|
(:padding-bottom 1 :padding-bottom-height 1 span-patch)
|
|
(:margin-left (7) :margin-left-pixel 7 owner-rerender)
|
|
(:margin-right (8) :margin-right-pixel 8 owner-rerender)
|
|
(:margin-top 1 :margin-top-height 1 owner-rerender)
|
|
(:margin-bottom 1 :margin-bottom-height 1 owner-rerender)
|
|
(:border-top-p t :border-top-pixel 1 span-patch)
|
|
(:text-align center :text-align center span-patch)
|
|
(:overflow hidden :overflow hidden owner-rerender)
|
|
(:wrap-mode char :wrap-mode char span-patch)
|
|
(:scroll-offset 1 :scroll-offset 1 span-patch))
|
|
"Geometry updates as (AUTHOR VALUE ENGINE-FIELD EXPECTED STRATEGY).")
|
|
|
|
(defun ebox-test--region-update-case-properties (cases)
|
|
"Return the update property names covered by CASES."
|
|
(mapcar #'car cases))
|
|
|
|
(ert-deftest ebox-normal-single-text-uses-content-width-once ()
|
|
"One Text and fragmented Text fit the same padded responsive content box."
|
|
(let ((ebox--string-pixel-width-cache (make-hash-table :test 'equal)))
|
|
(cl-letf (((symbol-function 'string-pixel-width)
|
|
(lambda (string) (* 7 (string-width string))))
|
|
((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
|
|
(dolist (width '(stretch (viewport) (96)))
|
|
(dolist (label '("未完成" "已完成" "Pending"))
|
|
(let* ((ebox-viewport-width 96)
|
|
(props (list :width width :padding '(0 (14))
|
|
:border '(1 solid)))
|
|
(single (ebox-render
|
|
(apply #'ebox-test-box
|
|
(append props (list (ebox-test-text label))))))
|
|
(fragmented
|
|
(ebox-render
|
|
(apply #'ebox-test-box
|
|
(append props
|
|
(mapcar (lambda (char)
|
|
(ebox-test-text (char-to-string char)))
|
|
(string-to-list label)))))))
|
|
(should (= (ebox-string-height single) 1))
|
|
(should (= (ebox--string-max-pixel-width single) 96))
|
|
(should (equal (substring-no-properties single)
|
|
(substring-no-properties fragmented)))
|
|
(should (equal (mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines single))
|
|
(mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines fragmented))))))))))
|
|
|
|
(ert-deftest ebox-normal-single-text-preserves-genuine-narrow-wrap ()
|
|
"A real content-width shortage still wraps CJK without losing characters."
|
|
(let ((ebox-viewport-width 58)
|
|
(ebox--string-pixel-width-cache (make-hash-table :test 'equal)))
|
|
(cl-letf (((symbol-function 'string-pixel-width)
|
|
(lambda (string) (* 7 (string-width string))))
|
|
((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
|
|
(let ((single
|
|
(ebox-render
|
|
(ebox-test-box :width 'stretch :padding '(0 (14))
|
|
:border '(1 solid)
|
|
(ebox-test-text "未完成"))))
|
|
(fragmented
|
|
(ebox-render
|
|
(ebox-test-box :width 'stretch :padding '(0 (14))
|
|
:border '(1 solid)
|
|
(ebox-test-text "未") (ebox-test-text "完")
|
|
(ebox-test-text "成")))))
|
|
(should (= (ebox-string-height single) 2))
|
|
(should (equal (mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines single))
|
|
'(58 58)))
|
|
(should (equal (substring-no-properties single)
|
|
(substring-no-properties fragmented)))
|
|
(should (string-match-p "未完[^\n]*\n[^\n]*成" single)))
|
|
(let ((unwrapped
|
|
(ebox-render
|
|
(ebox-test-box :width 'stretch :padding '(0 (14))
|
|
:border '(1 solid) :wrap-mode 'none
|
|
(ebox-test-text "未完成")))))
|
|
(should (= (ebox-string-height unwrapped) 1))
|
|
(should (= (ebox--string-max-pixel-width unwrapped) 72))
|
|
(should (string-match-p "未完成" unwrapped))))))
|
|
|
|
(ert-deftest ebox-root-background-covers-transparent-layout-wrappers ()
|
|
"A root canvas background should remain under transparent child wrappers."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 6)
|
|
(layout
|
|
(ebox-test-box :width '(viewport) :height '(viewport-height) :bgcolor
|
|
"#151A23"
|
|
(ebox-test-column
|
|
(ebox-test-box :width '(viewport) :height 1 :bgcolor
|
|
"#202938" (ebox-test-text "surface"))
|
|
(ebox-test-box :width '(viewport) :height 1
|
|
(ebox-test-text "transparent"))
|
|
(ebox-test-box :width '(viewport) :height 1 :bgcolor
|
|
"#29364A"
|
|
(ebox-test-text "surface-two")))))
|
|
(lines (ebox-string-lines (ebox-render layout))))
|
|
(should (= (length lines) 6))
|
|
(dolist (line lines)
|
|
(should (ebox-test--face-has-key-value-p
|
|
(get-text-property 0 'face line)
|
|
:background "#151A23")))
|
|
(should (ebox-test--face-has-key-value-p
|
|
(get-text-property 0 'face (nth 0 lines))
|
|
:background "#202938"))
|
|
(should (ebox-test--face-has-key-value-p
|
|
(get-text-property 0 'face (nth 2 lines))
|
|
:background "#29364A"))))
|
|
|
|
(ert-deftest ebox-production-render-consumes-isolated-ecss-cascade ()
|
|
"Ebox rules should reach the real render path through ECSS cascade."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
|
|
(ebox-style-add-rule
|
|
".card" '(:color "#F8FAFC" :bgcolor "#1E293B")
|
|
:layer 'components)
|
|
(let* ((node (ebox-test-box :class 'card (ebox-test-text "Cascade") :width '(96)))
|
|
(face (ebox-test--first-content-face (ebox-render node))))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :foreground "#F8FAFC"))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :background "#1E293B")))))
|
|
|
|
(ert-deftest ebox-font-size-rejects-runtime-and-function-values ()
|
|
"Standalone Ebox font size accepts only positive CSS reference pixels."
|
|
(should-error
|
|
(ebox-style-compute
|
|
(list :font-size (tp-computed (lambda () 16))))
|
|
:type 'error)
|
|
(should-error
|
|
(ebox-style-compute (list :font-size (lambda () 16)))
|
|
:type 'error))
|
|
|
|
(ert-deftest ebox-ecss-computed-fact-retains-owner-impact-and-provenance ()
|
|
"The Ebox package schema should remain attached to one computed fact."
|
|
(let* ((style
|
|
(ecss-compute-style
|
|
ebox-style-schemas (ecss-subject-create :type "box")
|
|
:declarations (ebox-style-compile-declarations '(:width (120)) t)
|
|
:provenance t))
|
|
(fact (ecss-computed-style-property-fact style 'ebox/width)))
|
|
(should (eq 'ebox/width (ecss-computed-property-fact-property fact)))
|
|
(should (eq 'ebox (ecss-computed-property-fact-owner fact)))
|
|
(should (equal '(geometry)
|
|
(ecss-computed-property-fact-impacts fact)))
|
|
(should (equal '(layout)
|
|
(ecss-computed-property-fact-projections fact)))
|
|
(should (equal (ecss-computed-property-fact-value fact)
|
|
(ecss-computed-style-value style 'ebox/width)))
|
|
(should (eq 'declaration
|
|
(plist-get (ecss-computed-property-fact-provenance fact)
|
|
:source)))))
|
|
|
|
(ert-deftest ebox-ecss-multi-impact-fact-shares-one-computed-value ()
|
|
"Typography geometry and paint consumers should share one value identity."
|
|
(let* ((style
|
|
(ecss-compute-style
|
|
ebox-style-schemas (ecss-subject-create :type "box")
|
|
:declarations
|
|
(ebox-style-compile-declarations '(:font-weight bold) t)))
|
|
(fact
|
|
(ecss-computed-style-property-fact style 'ebox/font-weight))
|
|
(geometry-value (ecss-computed-property-fact-value fact))
|
|
(paint-value (ecss-computed-property-fact-value fact)))
|
|
(should (equal '(geometry paint)
|
|
(ecss-computed-property-fact-impacts fact)))
|
|
(should (equal '(measurement paint)
|
|
(ecss-computed-property-fact-projections fact)))
|
|
(should (eq (plist-get
|
|
(ecss-computed-property-fact-metadata fact) :owner)
|
|
'font-metrics-projection))
|
|
(should (eq geometry-value paint-value))
|
|
(should (equal geometry-value
|
|
(ecss-computed-style-value style 'ebox/font-weight)))))
|
|
|
|
(ert-deftest ebox-style-context-reads-each-ecss-snapshot-once ()
|
|
"Style projection should not repeatedly copy one computed ECSS snapshot."
|
|
(let* ((style
|
|
(ebox-style-compute-subject
|
|
(ecss-subject-create :type "box")
|
|
(ebox-style-compile-declarations
|
|
'(:width (120) :padding (1 (8)) :bgcolor "#1E293B"))))
|
|
(original-values (symbol-function 'ecss-computed-style-values))
|
|
(original-active
|
|
(symbol-function 'ecss-computed-style-active-properties))
|
|
(original-specified
|
|
(symbol-function 'ecss-computed-style-specified-properties))
|
|
(original-provenance
|
|
(symbol-function 'ecss-computed-style-provenance))
|
|
(values-reads 0)
|
|
(active-reads 0)
|
|
(specified-reads 0)
|
|
(provenance-reads 0))
|
|
(cl-letf (((symbol-function 'ecss-computed-style-values)
|
|
(lambda (computed)
|
|
(cl-incf values-reads)
|
|
(funcall original-values computed)))
|
|
((symbol-function 'ecss-computed-style-active-properties)
|
|
(lambda (computed)
|
|
(cl-incf active-reads)
|
|
(funcall original-active computed)))
|
|
((symbol-function 'ecss-computed-style-specified-properties)
|
|
(lambda (computed)
|
|
(cl-incf specified-reads)
|
|
(funcall original-specified computed)))
|
|
((symbol-function 'ecss-computed-style-provenance)
|
|
(lambda (computed)
|
|
(cl-incf provenance-reads)
|
|
(funcall original-provenance computed))))
|
|
(should (ebox-style--context-values style 'box t))
|
|
(should (ebox-style--context-values style 'box t))
|
|
(should (ebox-style--context-values style 'box nil)))
|
|
(should (= values-reads 1))
|
|
(should (= active-reads 1))
|
|
(should (= specified-reads 1))
|
|
(should (= provenance-reads 0))))
|
|
|
|
(ert-deftest ebox-style-apply-computed-reuses-one-ecss-snapshot ()
|
|
"One computed style projection should reuse its ECSS snapshot."
|
|
(let* ((style
|
|
(ebox-style-compute-subject
|
|
(ecss-subject-create :type "flex")
|
|
(ebox-style-compile-declarations
|
|
'(:width (120) :flex-direction row :color "#F8FAFC"))))
|
|
(node (list :ebox-type 'flex :raw-props nil :props nil))
|
|
(original-values (symbol-function 'ecss-computed-style-values))
|
|
(original-active
|
|
(symbol-function 'ecss-computed-style-active-properties))
|
|
(original-specified
|
|
(symbol-function 'ecss-computed-style-specified-properties))
|
|
(values-reads 0)
|
|
(active-reads 0)
|
|
(specified-reads 0))
|
|
(cl-letf (((symbol-function 'ecss-computed-style-values)
|
|
(lambda (computed)
|
|
(cl-incf values-reads)
|
|
(funcall original-values computed)))
|
|
((symbol-function 'ecss-computed-style-active-properties)
|
|
(lambda (computed)
|
|
(cl-incf active-reads)
|
|
(funcall original-active computed)))
|
|
((symbol-function 'ecss-computed-style-specified-properties)
|
|
(lambda (computed)
|
|
(cl-incf specified-reads)
|
|
(funcall original-specified computed))))
|
|
(ebox-style-apply-computed node style))
|
|
(should (= values-reads 1))
|
|
(should (= active-reads 1))
|
|
(should (= specified-reads 1))))
|
|
|
|
(ert-deftest ebox-default-foreground-reset-preserves-explicit-background ()
|
|
"Resolve GUI default foreground without inheriting its background."
|
|
(let ((original (symbol-function 'face-attribute)))
|
|
(cl-letf (((symbol-function 'face-attribute)
|
|
(lambda (face attribute &rest arguments)
|
|
(if (and (eq face 'default) (eq attribute :foreground))
|
|
"Black"
|
|
(apply original face attribute arguments)))))
|
|
(should
|
|
(equal
|
|
(ebox-buffer-paint-text-properties
|
|
'(:color ebox/default-foreground :bgcolor "#111827") 'content)
|
|
'(face (:foreground "Black" :background "#111827")))))))
|
|
|
|
(ert-deftest ebox-flex-layout-properties-come-from-ecss-cascade ()
|
|
"An ECSS rule should determine a Flex container's actual main axis."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
|
|
(ebox-style-add-rule
|
|
"flex" '(:flex-direction column :width (96))
|
|
:layer 'layout)
|
|
(let ((rendered
|
|
(substring-no-properties
|
|
(ebox-render
|
|
(ebox-test-flex
|
|
(ebox-test-box (ebox-test-text "A"))
|
|
(ebox-test-box (ebox-test-text "B")))))))
|
|
(should (equal (mapcar #'string-trim-right
|
|
(split-string rendered "\n"))
|
|
'("A" "B"))))))
|
|
|
|
(ert-deftest ebox-cascade-layers-inline-values-and-reset-use-one-winner ()
|
|
"Production rendering should honor ECSS layers, inline values, and reset."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
|
|
(ebox-style-add-rule ".card" '(:color "#94A3B8") :layer 'base)
|
|
(ebox-style-add-rule ".card" '(:color "#38BDF8") :layer 'components)
|
|
(let* ((rule-node (ebox-test-box :class 'card (ebox-test-text "Rule")))
|
|
(inline-node
|
|
(ebox-test-box :class 'card (ebox-test-text "Inline") :color "#F8FAFC")))
|
|
(should
|
|
(equal (ebox-test--effective-face-value
|
|
(ebox-test--first-content-face (ebox-render rule-node))
|
|
:foreground)
|
|
"#38BDF8"))
|
|
(should
|
|
(equal (ebox-test--effective-face-value
|
|
(ebox-test--first-content-face (ebox-render inline-node))
|
|
:foreground)
|
|
"#F8FAFC"))
|
|
(ebox-style-reset-rules)
|
|
(should-not
|
|
(ebox-test--face-has-key-p
|
|
(ebox-test--first-content-face (ebox-render rule-node))
|
|
:foreground)))))
|
|
|
|
(ert-deftest ebox-grid-container-and-item-properties-come-from-ecss-cascade ()
|
|
"Grid container and item rules should reach the production runtime tree."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
|
|
(ebox-style-add-rule
|
|
"grid" '(:grid-template-columns ((40) (40)) :column-gap (4))
|
|
:layer 'layout)
|
|
(ebox-style-add-rule ".placed" '(:grid-column 2) :layer 'layout)
|
|
(ebox-test--with-rendered-buffer
|
|
(ebox-test-grid
|
|
(ebox-test-box :class 'placed (ebox-test-text "Placed"))
|
|
(ebox-test-box (ebox-test-text "Auto")))
|
|
(let* ((root (ebox-test--runtime-root (current-buffer)))
|
|
(config (plist-get root :ebox-layout-config))
|
|
(props (ebox-layout-config-props config))
|
|
(placed (car (ebox-tree-layout-children root))))
|
|
(should (eq (ebox-layout-config-kind config) 'grid))
|
|
(should (equal (plist-get props :grid-template-columns)
|
|
'((:kind fixed :size 40)
|
|
(:kind fixed :size 40))))
|
|
(should (equal (plist-get props :column-gap)
|
|
4))
|
|
(should (= (plist-get placed :grid-column) 2))))))
|
|
|
|
(ert-deftest ebox-font-cascade-inherits-overrides-and-precedes-measurement ()
|
|
"Font computed values should reach child text before pixel measurement."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((root
|
|
(ebox-test-box :wrap-mode 'none :font-family "Parent Family"
|
|
:font-size 16 :font-weight 'bold
|
|
(ebox-test-box :id 'child :font-family "Child Family"
|
|
(ebox-test-text "MMMM"))))
|
|
measured-face)
|
|
(cl-letf (((symbol-function 'ebox--string-pixel-width)
|
|
(lambda (string)
|
|
(when (string-match-p "MMMM" (substring-no-properties string))
|
|
(setq measured-face (get-text-property 0 'face string)))
|
|
40)))
|
|
(ebox-test--with-rendered-buffer root
|
|
(let* ((runtime-root (ebox-test--runtime-root (current-buffer)))
|
|
(runtime-child (car (ebox-tree-layout-children runtime-root)))
|
|
(style (plist-get runtime-child :ebox-computed-style))
|
|
(values (ecss-computed-style-values style))
|
|
(face (ebox-test--first-content-face (buffer-string))))
|
|
(should (equal (plist-get values 'ebox/font-family) "Child Family"))
|
|
(should (= (plist-get values 'ebox/font-size) 16))
|
|
(should (eq (plist-get values 'ebox/font-weight) 'bold))
|
|
(should
|
|
(equal (ebox-test--effective-face-value face :family)
|
|
"Child Family"))
|
|
(should (= (ebox-test--effective-face-value face :height) 120))
|
|
(should (eq (ebox-test--effective-face-value face :weight) 'bold)))))
|
|
(should measured-face)
|
|
(should
|
|
(equal (ebox-test--effective-face-value measured-face :family)
|
|
"Child Family"))
|
|
(should (= (ebox-test--effective-face-value measured-face :height) 120))))
|
|
|
|
(defun ebox-test--intrinsic-subtree-input (content)
|
|
"Return a styled Flex whose intrinsic Column contains CONTENT."
|
|
(ebox-test-flex
|
|
:width '(240) :color "#112233" :font-family "Parent Family"
|
|
:font-size 16 :font-weight 'bold
|
|
(ebox-test-column
|
|
:id 'item :width 'max-content :flex-shrink 0
|
|
(ebox-test-box :id 'label :min-width 'max-content :wrap-mode 'none
|
|
(ebox-test-text content)))
|
|
(ebox-test-box :id 'sibling :width '(40) (ebox-test-text "other"))))
|
|
|
|
(ert-deftest ebox-retained-intrinsic-subtree-keeps-inherited-style ()
|
|
"A retained subtree probe must keep computed ancestry and private caches."
|
|
(ebox-test--reset-runtime-state)
|
|
(ebox-test--with-rendered-buffer (ebox-test--intrinsic-subtree-input "alpha")
|
|
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
|
(root (plist-get state :root-node))
|
|
(node (ebox-test--selector-node (current-buffer) "#label"))
|
|
(root-before (copy-tree root))
|
|
(index (plist-get state :source-index))
|
|
(cache (plist-get state :render-cache))
|
|
(cache-before (and cache (copy-hash-table cache)))
|
|
(expected-face (get-text-property (point-min) 'face))
|
|
(ebox--render-source-states (list state))
|
|
(ebox--render-source-index index)
|
|
(ebox--render-root-parent-kind 'column)
|
|
(rendered (ebox--render-node node)))
|
|
(should (string-match-p "alpha" rendered))
|
|
(should (equal (get-text-property 0 'face rendered) expected-face))
|
|
(should (equal (ebox-test--effective-face-value expected-face :family)
|
|
"Parent Family"))
|
|
(should (eq (ebox-test--effective-face-value expected-face :weight)
|
|
'bold))
|
|
(should (equal root root-before))
|
|
(should (eq index (plist-get state :source-index)))
|
|
(when cache
|
|
(should (eq cache (plist-get state :render-cache)))
|
|
(should (= (hash-table-count cache) (hash-table-count cache-before)))
|
|
(maphash (lambda (key value)
|
|
(should (eq value (gethash key cache))))
|
|
cache-before)))))
|
|
|
|
(ert-deftest ebox-retained-intrinsic-subtree-requires-exact-source-ownership ()
|
|
"A retained probe cannot bless explicit roots, copied nodes, or new rules."
|
|
(ebox-test--reset-runtime-state)
|
|
(ebox-test--with-rendered-buffer (ebox-test--intrinsic-subtree-input "alpha")
|
|
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
|
(node (ebox-test--selector-node (current-buffer) "#label"))
|
|
(index (plist-get state :source-index))
|
|
(ebox--render-source-states (list state))
|
|
(ebox--render-source-index index)
|
|
(ebox--render-root-parent-kind 'column))
|
|
;; Explicit canonical input must still own exactly its complete forest.
|
|
(should-error (ebox--render-node node index))
|
|
;; A copied node with the same runtime id has no retained-state proof.
|
|
(should-error (ebox--render-node (copy-sequence node)))
|
|
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
|
|
(ebox-style-add-rule "#label" '(:font-size 24))
|
|
(should-error (ebox--render-node node))))))
|
|
|
|
(ert-deftest ebox-max-content-column-update-keeps-inherited-layout ()
|
|
"A public update may intrinsically measure a styled max-content Column."
|
|
(ebox-test--reset-runtime-state)
|
|
(ebox-test--with-rendered-buffer (ebox-test--intrinsic-subtree-input "alpha")
|
|
(ebox-commit (current-buffer)
|
|
(ebox-test--intrinsic-subtree-input "alpha expanded"))
|
|
(should (string-match-p "alpha expanded" (buffer-string)))
|
|
(let ((face (get-text-property (point-min) 'face)))
|
|
(should (equal (ebox-test--effective-face-value face :family)
|
|
"Parent Family"))
|
|
(should (eq (ebox-test--effective-face-value face :weight) 'bold)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
|
|
|
|
(ert-deftest ebox-column-background-owns-blank-lines-with-child-override ()
|
|
"A column background should fill its blank lines without hiding child paint."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((parent "#0F172A")
|
|
(child "#1D4ED8")
|
|
(rendered
|
|
(ebox-render
|
|
(ebox-test-column :width '(120) :height 5 :padding-block 1 :bgcolor
|
|
parent
|
|
(ebox-test-box :width '(120) :height 1
|
|
:margin-bottom 1 :bgcolor child
|
|
(ebox-test-text "Child")))))
|
|
(lines (ebox-string-lines rendered)))
|
|
(should (= (length lines) 5))
|
|
(dolist (line lines)
|
|
(should
|
|
(ebox-test--face-has-key-value-p
|
|
(get-text-property 0 'face line) :background parent)))
|
|
(let ((child-line
|
|
(cl-find-if
|
|
(lambda (line)
|
|
(string-match-p "Child" (substring-no-properties line)))
|
|
lines))
|
|
(blank-line
|
|
(cl-find-if
|
|
(lambda (line)
|
|
(and (string-blank-p (substring-no-properties line))
|
|
(equal (ebox-test--effective-face-value
|
|
(get-text-property 0 'face line) :background)
|
|
parent)))
|
|
lines)))
|
|
(should child-line)
|
|
(should blank-line)
|
|
(should
|
|
(equal (ebox-test--effective-face-value
|
|
(get-text-property 0 'face child-line) :background)
|
|
child)))))
|
|
|
|
(ert-deftest ebox-paint-only-update-skips-measurement-flex-and-grid ()
|
|
"A pure paint change should not recompute spatial layout."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((target
|
|
(ebox-test-box :id 'target (ebox-test-text "Paint") :width '(60)
|
|
:color "#E2E8F0" :bgcolor "#1E293B"))
|
|
(root
|
|
(ebox-test-flex
|
|
:width '(160)
|
|
(ebox-test-grid :grid-template-columns '((60)) target))))
|
|
(ebox-test--with-rendered-buffer root
|
|
(let ((handle (ebox-region-resolve (current-buffer) 'target)))
|
|
(cl-letf (((symbol-function 'ebox--string-pixel-width)
|
|
(lambda (&rest _)
|
|
(error "Paint update measured text")))
|
|
((symbol-function 'ebox--render-flex)
|
|
(lambda (&rest _)
|
|
(error "Paint update recomputed Flex")))
|
|
((symbol-function 'ebox--render-grid)
|
|
(lambda (&rest _)
|
|
(error "Paint update recomputed Grid"))))
|
|
(ebox-region-update handle :bgcolor "#334155"))
|
|
(goto-char (point-min))
|
|
(search-forward "Paint")
|
|
(should
|
|
(equal (ebox-test--effective-face-value
|
|
(get-text-property (match-beginning 0) 'face)
|
|
:background)
|
|
"#334155"))
|
|
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
|
|
'paint-patch))))))
|
|
|
|
(ert-deftest ebox-live-mount-uses-runtime-copy-and-leaves-source-stable ()
|
|
"Live publication should update a surface copy without mutating SOURCE."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((source (ebox-test-box :id 'root :width '(120) :color "#111111" :height 1
|
|
:overflow 'scroll (ebox-test-text "before\nhidden")))
|
|
(source-before (copy-tree source))
|
|
(buffer (generate-new-buffer " *ebox-live-copy-source*")))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-render-to-buffer buffer source)
|
|
(ebox-commit
|
|
buffer
|
|
(ebox-test-box :id 'root :width '(140) :color "#222222" :height 1
|
|
:overflow 'scroll (ebox-test-text "after\nhidden")))
|
|
(should (equal source source-before))
|
|
(with-current-buffer buffer
|
|
(should (string-match-p "after" (buffer-string)))
|
|
(should-not (string-match-p "before" (buffer-string)))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-live-mount-allows-one-source-on-independent-surfaces ()
|
|
"One immutable source should mount and update independently in two buffers."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((source (ebox-test-box :id 'root :width '(120) :height 1 :overflow 'scroll
|
|
(ebox-test-text "owned\nhidden")))
|
|
(source-before (copy-tree source))
|
|
first-buffer second-buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq first-buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-live-first*")
|
|
source))
|
|
(setq second-buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-live-second*")
|
|
source))
|
|
(ebox-commit first-buffer
|
|
(ebox-test-box :id 'root (ebox-test-text "first changed")))
|
|
(with-current-buffer first-buffer
|
|
(should (string-match-p "first changed" (buffer-string))))
|
|
(with-current-buffer second-buffer
|
|
(should-not (string-match-p "first changed" (buffer-string))))
|
|
(ebox-commit second-buffer
|
|
(ebox-test-box :id 'root (ebox-test-text "second changed")))
|
|
(with-current-buffer second-buffer
|
|
(should (string-match-p "second changed" (buffer-string))))
|
|
(with-current-buffer first-buffer
|
|
(should-not (string-match-p "second changed" (buffer-string))))
|
|
(should (equal source source-before)))
|
|
(dolist (buffer (list first-buffer second-buffer))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer))))))
|
|
|
|
(ert-deftest ebox-live-mount-kill-invalidates-handle ()
|
|
"Killing a surface buffer should invalidate its region handles."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((old-source (ebox-test-box :id 'old :width 120 (ebox-test-text "old")))
|
|
(source-before (copy-tree old-source))
|
|
primary-buffer secondary-buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq primary-buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-live-primary*")
|
|
old-source))
|
|
(let ((old-handle (ebox-region-resolve primary-buffer "old")))
|
|
(kill-buffer primary-buffer)
|
|
(setq primary-buffer nil)
|
|
(should-error (ebox-test--region-update old-handle :width '(140))
|
|
:type 'user-error))
|
|
(setq secondary-buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-live-secondary*")
|
|
old-source))
|
|
(ebox-commit secondary-buffer
|
|
(ebox-test-box :id 'old :width 120 (ebox-test-text "reused")))
|
|
(with-current-buffer secondary-buffer
|
|
(should (string-match-p "reused" (buffer-string))))
|
|
(should (equal old-source source-before)))
|
|
(dolist (buffer (list primary-buffer secondary-buffer))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer))))))
|
|
|
|
(ert-deftest ebox-wrapper-child-nodes-are-in-runtime-index ()
|
|
"A wrapper box's child layout should remain visible in runtime state."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-column :padding 1 (ebox-test-box (ebox-test-text "One"))
|
|
(ebox-test-box (ebox-test-text "Two"))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
node))
|
|
(let ((ids (ebox--runtime-node-ids
|
|
(ebox-test--runtime-root buffer))))
|
|
(should (> (length ids) 1))
|
|
(dolist (node-id ids)
|
|
(should (ebox--buffer-runtime-node buffer node-id)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-bootstrap-uses-persistent-core-indexes-and-id-postorder ()
|
|
"Runtime bootstrap stores persistent core indexes and stable node ids."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((input
|
|
(ebox-test-column
|
|
(ebox-test-box :key 'left (ebox-test-text "Left"))
|
|
(ebox-test-box :key 'right (ebox-test-text "Right"))))
|
|
(root (ebox-test-root input))
|
|
(index (ebox--runtime-index root t (ebox-test-source-index input)))
|
|
(nodes (plist-get index :node-table))
|
|
(postorder (plist-get index :native-node-postorder)))
|
|
(should (ebox-runtime-index-p nodes))
|
|
(should (ebox-runtime-index-p (plist-get index :parent-table)))
|
|
(should (ebox-runtime-index-p (plist-get index :region-node-table)))
|
|
(should (vectorp postorder))
|
|
(should (> (length postorder) 0))
|
|
(should
|
|
(cl-every
|
|
(lambda (node-id)
|
|
(and (not (listp node-id))
|
|
(ebox-runtime-index-get node-id nodes)))
|
|
postorder))
|
|
(should (equal (aref postorder (1- (length postorder)))
|
|
(plist-get root :node-id)))))
|
|
|
|
(ert-deftest ebox-region-render-owner-index-keeps-earlier-duplicate-box ()
|
|
"A later flex wrapper should not replace an earlier duplicate region owner."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((region-id 9001)
|
|
(plain (ebox-test-box (ebox-test-text "First") :width '(80)))
|
|
(flex (ebox-test-flex :width '(120)
|
|
(ebox-test-box (ebox-test-text "Later") :width '(80))))
|
|
(wrapper (ebox-test-root flex))
|
|
(layout (ebox-test-column plain flex))
|
|
(plain-node (ebox-test-root plain))
|
|
(layout-node (ebox-test-root layout)))
|
|
(ebox-put plain-node :region-id region-id)
|
|
(ebox-put wrapper :region-id region-id)
|
|
(let* ((index (ebox--runtime-index
|
|
layout-node nil (ebox-test-source-index layout)))
|
|
(table (plist-get index :region-node-table))
|
|
(expected (car (ebox--node-path-to-region
|
|
layout-node region-id))))
|
|
(should (eq expected plain-node))
|
|
(should (equal (ebox-runtime-index-get region-id table)
|
|
(ebox--ensure-node-id expected))))))
|
|
|
|
(ert-deftest ebox-region-render-owner-index-falls-back-for-legacy-state ()
|
|
"A runtime state without the new table should retain recursive lookup."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((child (ebox-test-box (ebox-test-text "Flex child") :width '(80)))
|
|
(flex (ebox-test-flex :width '(120) :padding 1 child))
|
|
(wrapper (plist-get flex :box)))
|
|
(ebox-test--with-rendered-buffer flex
|
|
(let* ((buffer (current-buffer))
|
|
(state (ebox--buffer-render-state buffer))
|
|
(table (ebox--buffer-region-node-table buffer))
|
|
(region-id (ebox--ensure-region-id wrapper))
|
|
(expected-node-id
|
|
(ebox--buffer-region-render-owner-node-id buffer region-id))
|
|
(expected-node
|
|
(ebox--buffer-region-render-owner-node buffer region-id))
|
|
(original-path
|
|
(symbol-function 'ebox--node-path-to-region))
|
|
(recursive-calls 0))
|
|
(unwind-protect
|
|
(progn
|
|
(plist-put state :region-node-table nil)
|
|
(cl-letf (((symbol-function 'ebox--node-path-to-region)
|
|
(lambda (&rest args)
|
|
(cl-incf recursive-calls)
|
|
(apply original-path args))))
|
|
(should (equal
|
|
(ebox--buffer-region-render-owner-node-id
|
|
buffer region-id)
|
|
expected-node-id))
|
|
(should (eq (ebox--buffer-region-render-owner-node
|
|
buffer region-id)
|
|
expected-node)))
|
|
(should (> recursive-calls 0)))
|
|
(plist-put state :region-node-table table))))))
|
|
|
|
(ert-deftest ebox-runtime-node-id-survives-viewport-rerender ()
|
|
"Viewport-only rerender should not assign new runtime node ids."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-flex
|
|
:flex-wrap 'wrap
|
|
(ebox-test-box (ebox-test-text "A") :width '(100))
|
|
(ebox-test-box (ebox-test-text "B") :width '(100))))
|
|
(ids-before nil)
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 240))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(setq ids-before (ebox--runtime-node-ids layout))
|
|
(let ((ebox-viewport-width 160))
|
|
(ebox--rerender-buffer-preserving-runtime buffer))
|
|
(should (equal (ebox--runtime-node-ids layout) ids-before)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-reconcile-preserves-keyed-child-id ()
|
|
"Keyed children should keep runtime ids across structural rebuilds."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((old-input
|
|
(ebox-test-column
|
|
(ebox-test-box :key 'a (ebox-test-text "A") :width 40)
|
|
(ebox-test-box :key 'b (ebox-test-text "B") :width 40)))
|
|
(old (ebox--runtime-tree (ebox-test-root old-input)))
|
|
(old-b-id
|
|
(plist-get
|
|
(car (ebox-selector-query-all old-input "[key=b]")) :node-id))
|
|
(new-input
|
|
(ebox-test-column
|
|
(ebox-test-box :key 'x (ebox-test-text "X") :width 40)
|
|
(ebox-test-box :key 'b (ebox-test-text "B2") :width 40)))
|
|
(new
|
|
(ebox-tree-reconcile-runtime
|
|
old (ebox-test-source-index old-input)
|
|
(ebox-test-root new-input) (ebox-test-source-index new-input))))
|
|
(should
|
|
(equal
|
|
(plist-get
|
|
(car (ebox-selector-query-all
|
|
(ebox-canonical-input-create
|
|
(list new) (ebox-test-source-index new-input))
|
|
"[key=b]"))
|
|
:node-id)
|
|
old-b-id))))
|
|
|
|
(ert-deftest ebox-tree-helpers-see-wrapper-child-nodes ()
|
|
"Tree helpers should see wrapper children without rendering static content."
|
|
(let* ((node (ebox-test-column :padding 1
|
|
(ebox-test-box (ebox-test-text "Child") :width '(120))))
|
|
(children (ebox-tree-children (ebox-test-root node))))
|
|
(should children)
|
|
(should (plist-get (car children) :node-id))))
|
|
|
|
(ert-deftest ebox-tree-shallow-copy-replaces-box-content-node ()
|
|
"A box content-node replacement should leave other values shared."
|
|
(let* ((old-content (list :ebox-type 'box :content "old"))
|
|
(equal-content (copy-tree old-content))
|
|
(new-content (list :ebox-type 'box :content "new"))
|
|
(wrong-content (list :ebox-type 'box :content "wrong"))
|
|
(metadata (list :owner 'host))
|
|
(node (list :ebox-type 'box
|
|
:ebox-content-node old-content
|
|
:metadata metadata))
|
|
(copy
|
|
(ebox-tree-copy-with-direct-child-replacements
|
|
node (list (cons equal-content wrong-content)
|
|
(cons old-content new-content)))))
|
|
(should-not (eq copy node))
|
|
(should (eq (plist-get copy :ebox-content-node) new-content))
|
|
(should (eq (plist-get copy :metadata) metadata))
|
|
(should (eq (plist-get node :ebox-content-node) old-content))))
|
|
|
|
(ert-deftest ebox-tree-shallow-copy-replaces-concat-children-and-aliases ()
|
|
"A concat copy should update flat children and both legacy aliases."
|
|
(let* ((left (list :ebox-type 'box :content "left"))
|
|
(right (list :ebox-type 'box :content "right"))
|
|
(new-left (list :ebox-type 'box :content "new-left"))
|
|
(children (list left right))
|
|
(node (list :ebox-type 'concat
|
|
:children children
|
|
:left left
|
|
:right right))
|
|
(copy
|
|
(ebox-tree-copy-with-direct-child-replacements
|
|
node (list (cons left new-left))))
|
|
(copy-children (plist-get copy :children)))
|
|
(should-not (eq copy node))
|
|
(should-not (eq copy-children children))
|
|
(should (eq (car copy-children) new-left))
|
|
(should (eq (cadr copy-children) right))
|
|
(should (eq (plist-get copy :left) new-left))
|
|
(should (eq (plist-get copy :right) right))))
|
|
|
|
(ert-deftest ebox-tree-shallow-copy-replaces-stack-children-and-aliases ()
|
|
"A stack copy should update flat children and both legacy aliases."
|
|
(let* ((top (list :ebox-type 'box :content "top"))
|
|
(bottom (list :ebox-type 'box :content "bottom"))
|
|
(new-bottom (list :ebox-type 'box :content "new-bottom"))
|
|
(children (list top bottom))
|
|
(node (list :ebox-type 'stack
|
|
:children children
|
|
:top top
|
|
:bottom bottom))
|
|
(copy
|
|
(ebox-tree-copy-with-direct-child-replacements
|
|
node (list (cons bottom new-bottom))))
|
|
(copy-children (plist-get copy :children)))
|
|
(should-not (eq copy node))
|
|
(should-not (eq copy-children children))
|
|
(should (eq (car copy-children) top))
|
|
(should (eq (cadr copy-children) new-bottom))
|
|
(should (eq (plist-get copy :top) top))
|
|
(should (eq (plist-get copy :bottom) new-bottom))))
|
|
|
|
(ert-deftest ebox-tree-shallow-copy-replaces-flex-box-and-children-once ()
|
|
"One flex copy should apply multiple replacements and share one sibling."
|
|
(let* ((box (list :ebox-type 'box :content "wrapper"))
|
|
(first (list :ebox-type 'box :content "first"))
|
|
(untouched (list :ebox-type 'box :content "untouched"))
|
|
(last (list :ebox-type 'box :content "last"))
|
|
(new-box (list :ebox-type 'box :content "new-wrapper"))
|
|
(new-first (list :ebox-type 'box :content "new-first"))
|
|
(new-last (list :ebox-type 'box :content "new-last"))
|
|
(children (list first untouched last))
|
|
(node (list :ebox-type 'flex :box box :children children))
|
|
(copy
|
|
(ebox-tree-copy-with-direct-child-replacements
|
|
node (list (cons box new-box)
|
|
(cons first new-first)
|
|
(cons last new-last))))
|
|
(copy-children (plist-get copy :children)))
|
|
(should-not (eq copy node))
|
|
(should (eq (plist-get copy :box) new-box))
|
|
(should (eq (nth 0 copy-children) new-first))
|
|
(should (eq (nth 1 copy-children) untouched))
|
|
(should (eq (nth 2 copy-children) new-last))
|
|
(should (eq (plist-get node :box) box))
|
|
(should (eq (nth 0 children) first))
|
|
(should (eq (nth 1 children) untouched))
|
|
(should (eq (nth 2 children) last))))
|
|
|
|
(ert-deftest ebox-tree-shallow-copy-without-replacements-shares-values ()
|
|
"A zero-replacement copy should share every nested value."
|
|
(let* ((first (list :ebox-type 'box :content "first"))
|
|
(second (list :ebox-type 'box :content "second"))
|
|
(children (list first second))
|
|
(node (list :ebox-type 'concat :children children))
|
|
(copy (ebox-tree-copy-with-direct-child-replacements node nil)))
|
|
(should-not (eq copy node))
|
|
(should (eq (plist-get copy :children) children))
|
|
(should (eq (car (plist-get copy :children)) first))
|
|
(should (eq (cadr (plist-get copy :children)) second))))
|
|
|
|
(ert-deftest ebox-fragment-signature-excludes-backend-spans ()
|
|
"Fragment signatures should describe layout facts, not backend markers."
|
|
(let ((fragment (ebox-fragment-create
|
|
:node-id "n1"
|
|
:region-ids '("r1")
|
|
:size '(120 . 24)
|
|
:line-signature '(("Child"))
|
|
:buffer-span '(1 . 8))))
|
|
(should (equal (ebox-fragment-layout-signature fragment)
|
|
'(:node-id "n1" :region-ids ("r1") :size (120 . 24)
|
|
:line-signature (("Child")))))))
|
|
|
|
(ert-deftest ebox-cache-registry-records-scope-deps-and-lifetime ()
|
|
"Cache specs should make ownership, scope, dependencies, and lifetime explicit."
|
|
(unwind-protect
|
|
(let ((spec (ebox-cache-register
|
|
:name 'computed-style
|
|
:owner 'ebox-style
|
|
:scope 'node
|
|
:key-deps '(specified-style class-version theme-version)
|
|
:invalidates '(style class theme)
|
|
:lifetime 'buffer-runtime
|
|
:value-owner 'ebox-style)))
|
|
(should (eq (ebox-cache-spec-name spec) 'computed-style))
|
|
(should (eq (ebox-cache-spec-owner spec) 'ebox-style))
|
|
(should (eq (ebox-cache-spec-scope spec) 'node))
|
|
(should (equal (ebox-cache-spec-invalidates spec)
|
|
'(style class theme)))
|
|
(should (eq (ebox-cache-spec-lifetime spec) 'buffer-runtime)))
|
|
(ebox-cache-reset-default-registry)))
|
|
|
|
(ert-deftest ebox-cache-invalidates-by-dirty-kind ()
|
|
"Dirty facts should map to the cache specs they invalidate."
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-cache-clear-registry)
|
|
(ebox-cache-register
|
|
:name 'render-body
|
|
:owner 'ebox-buffer-backend
|
|
:scope 'buffer
|
|
:key-deps '(fragment-signature paint-signature backend-display-signature)
|
|
:invalidates '(paint geometry structure backend-display)
|
|
:lifetime 'buffer-runtime
|
|
:value-owner 'ebox-buffer-backend)
|
|
(ebox-cache-register
|
|
:name 'layout-fragment
|
|
:owner 'ebox-layout
|
|
:scope 'formatting-context
|
|
:key-deps '(node-id computed-style-signature child-signatures available-size)
|
|
:invalidates '(geometry structure viewport)
|
|
:lifetime 'buffer-runtime
|
|
:value-owner 'ebox-layout)
|
|
(should (equal (sort (ebox-cache-invalidated-spec-names '(paint))
|
|
#'string<)
|
|
'(render-body)))
|
|
(should (equal (sort (ebox-cache-invalidated-spec-names '(geometry))
|
|
#'string<)
|
|
'(layout-fragment render-body))))
|
|
(ebox-cache-reset-default-registry)))
|
|
|
|
(ert-deftest ebox-render-cache-retains-distinct-viewport-widths ()
|
|
"Returning to a warm width should not evict its prior rendered value."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-cache-report-buffer nil)
|
|
(width-a '(display-a 720 42 body))
|
|
(width-b '(display-a 900 42 body)))
|
|
(ebox--render-cache-store 'responsive width-a 'rendered-a)
|
|
(ebox--render-cache-store 'responsive width-b 'rendered-b)
|
|
(should (eq (ebox--render-cache-lookup 'responsive width-a)
|
|
'rendered-a))
|
|
(should (eq (ebox--render-cache-lookup 'responsive width-b)
|
|
'rendered-b))))
|
|
|
|
(ert-deftest ebox-render-cache-retains-distinct-box-width-signatures ()
|
|
"Ordinary box-width variants should coexist under one cache key."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-cache-report-buffer nil)
|
|
(width-a '(display-a nil 42 (:width 720)))
|
|
(width-b '(display-a nil 42 (:width 900))))
|
|
(ebox--render-cache-store 'responsive width-a 'rendered-a)
|
|
(ebox--render-cache-store 'responsive width-b 'rendered-b)
|
|
(should (eq (ebox--render-cache-lookup 'responsive width-a)
|
|
'rendered-a))
|
|
(should (eq (ebox--render-cache-lookup 'responsive width-b)
|
|
'rendered-b))))
|
|
|
|
(ert-deftest ebox-render-cache-distinguishes-content-text-properties ()
|
|
"Equal characters with different properties must not share rendered output."
|
|
(let* ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-cache-report-buffer nil)
|
|
(normal
|
|
(list 'display-a nil 42
|
|
(list :content
|
|
(propertize "Same" 'face 'default))))
|
|
(selected
|
|
(list 'display-a nil 42
|
|
(list :content
|
|
(propertize "Same" 'face 'highlight)))))
|
|
(ebox--render-cache-store 'cell normal 'rendered-normal)
|
|
(should-not (ebox--render-cache-lookup 'cell selected))
|
|
(ebox--render-cache-store 'cell selected 'rendered-selected)
|
|
(should (eq (ebox--render-cache-lookup 'cell selected)
|
|
'rendered-selected))))
|
|
|
|
(ert-deftest ebox-render-cache-evicts-oldest-entry-at-capacity ()
|
|
"A full cache should retain newer width variants without unbounded growth."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 2)
|
|
(ebox-cache-report-buffer nil))
|
|
(ebox--render-cache-store 'responsive 'width-a 'rendered-a)
|
|
(ebox--render-cache-store 'responsive 'width-b 'rendered-b)
|
|
(ebox--render-cache-store 'responsive 'width-c 'rendered-c)
|
|
(should (= (hash-table-count ebox--render-cache-table) 2))
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-a))
|
|
(should (eq (ebox--render-cache-lookup 'responsive 'width-b)
|
|
'rendered-b))
|
|
(should (eq (ebox--render-cache-lookup 'responsive 'width-c)
|
|
'rendered-c))))
|
|
|
|
(ert-deftest ebox-render-cache-evicts-oldest-entry-at-byte-budget ()
|
|
"Large rendered values should be bounded even below the entry limit."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 16)
|
|
(ebox-render-cache-max-bytes 400)
|
|
(ebox-cache-report-buffer nil))
|
|
(ebox--render-cache-store 'responsive 'width-a "aa")
|
|
(ebox--render-cache-store 'responsive 'width-b "bb")
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-a))
|
|
(should (equal (ebox--render-cache-lookup 'responsive 'width-b) "bb"))
|
|
(should (= (hash-table-count ebox--render-cache-table) 1))))
|
|
|
|
(ert-deftest ebox-render-cache-does-not-retain-one-oversized-value ()
|
|
"One value above the byte budget should be returned but not retained."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 16)
|
|
(ebox-render-cache-max-bytes 128)
|
|
(ebox-cache-report-buffer nil))
|
|
(should (equal (ebox--render-cache-store
|
|
'responsive 'width-a "oversized")
|
|
"oversized"))
|
|
(should (= (hash-table-count ebox--render-cache-table) 0))
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-a))))
|
|
|
|
(ert-deftest ebox-render-cache-oversized-store-preserves-healthy-entries ()
|
|
"One oversized store must not purge unrelated retained entries."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 16)
|
|
(ebox-render-cache-max-bytes 1024)
|
|
(ebox-cache-report-buffer nil))
|
|
(ebox--render-cache-store 'responsive 'width-a "aa")
|
|
(ebox--render-cache-store 'responsive 'width-b "bb")
|
|
(should (equal (ebox--render-cache-store
|
|
'responsive 'width-oversized (make-string 64 ?x))
|
|
(make-string 64 ?x)))
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-oversized))
|
|
(should (equal (ebox--render-cache-lookup 'responsive 'width-a) "aa"))
|
|
(should (equal (ebox--render-cache-lookup 'responsive 'width-b) "bb"))
|
|
(should (= (hash-table-count ebox--render-cache-table) 2))))
|
|
|
|
(ert-deftest ebox-render-cache-oversized-update-releases-only-that-entry ()
|
|
"Growing one entry above the budget must release only that entry."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 16)
|
|
(ebox-render-cache-max-bytes 1024)
|
|
(ebox-cache-report-buffer nil))
|
|
(ebox--render-cache-store 'responsive 'width-a "aa")
|
|
(ebox--render-cache-store 'responsive 'width-b "bb")
|
|
(ebox--render-cache-store 'responsive 'width-b (make-string 64 ?x))
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-b))
|
|
(should (equal (ebox--render-cache-lookup 'responsive 'width-a) "aa"))
|
|
(should (= (hash-table-count ebox--render-cache-table) 1))))
|
|
|
|
(ert-deftest ebox-render-cache-retains-distinct-sxhash-collisions ()
|
|
"Distinct signatures with one bounded hash must retain separate entries."
|
|
(let* ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 16)
|
|
(ebox-cache-report-buffer nil)
|
|
(signature-a '(same (same (same (same "a")))))
|
|
(signature-b '(same (same (same (same "b"))))))
|
|
(should-not (equal signature-a signature-b))
|
|
(should (= (sxhash-equal signature-a) (sxhash-equal signature-b)))
|
|
(ebox--render-cache-store 'page signature-a 'rendered-a)
|
|
(ebox--render-cache-store 'page signature-b 'rendered-b)
|
|
(should (= (hash-table-count ebox--render-cache-table) 2))
|
|
(should (eq (ebox--render-cache-lookup 'page signature-a) 'rendered-a))
|
|
(should (eq (ebox--render-cache-lookup 'page signature-b) 'rendered-b))))
|
|
|
|
(ert-deftest ebox-render-cache-byte-budget-includes-scroll-side-effects ()
|
|
"Scroll-state metadata must not bypass the retained-byte budget."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 16)
|
|
(ebox-render-cache-max-bytes 1024)
|
|
(ebox-cache-report-buffer nil)
|
|
(side-effects
|
|
(list :scroll-actions
|
|
(list
|
|
(list 'set 'scroll
|
|
(list :content-lines
|
|
(list (make-string 4096 ?x)))))
|
|
:stateful-p t
|
|
:restorable-p t)))
|
|
(should (equal (ebox--render-cache-store
|
|
'responsive 'width-a "x" side-effects)
|
|
"x"))
|
|
(should (= (hash-table-count ebox--render-cache-table) 0))
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-a))))
|
|
|
|
(ert-deftest ebox-render-cache-reuses-portable-scroll-state-in-candidate-tree ()
|
|
"Portable scroll templates rebind complete state to the candidate box."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((buffer (generate-new-buffer " *ebox-portable-scroll-cache*"))
|
|
(old-box (ebox-test-box (ebox-test-text "old") :scroll-offset 0))
|
|
(old-node (ebox-test-root old-box))
|
|
(node-id (ebox--ensure-node-id old-node))
|
|
(candidate-box (ebox-test-box (ebox-test-text "candidate") :scroll-offset 0))
|
|
(candidate-node (ebox-test-root candidate-box))
|
|
(node-table (make-hash-table :test 'eql))
|
|
(template
|
|
(list :box old-node
|
|
:content-lines '("alpha" "beta")
|
|
:rendered-content-lines '("alpha")
|
|
:content-height 1
|
|
:scroll-offset 0))
|
|
(actions (list (list 'set 'scroll template)))
|
|
(entry (cons 'signature "rendered"))
|
|
(metadata
|
|
(list :scroll-actions actions
|
|
:stateful-p t
|
|
:portable-p t
|
|
:restorable-p t))
|
|
(ebox--render-cache-scroll-state-restorable-p nil))
|
|
(plist-put candidate-node :node-id node-id)
|
|
(puthash node-id candidate-node node-table)
|
|
(puthash buffer (list :node-table node-table)
|
|
ebox--buffer-render-state-table)
|
|
(puthash entry metadata ebox--render-cache-entry-side-effects-table)
|
|
(unwind-protect
|
|
(with-current-buffer buffer
|
|
(should (ebox--scroll-cache-actions-portable-p actions))
|
|
(should (ebox--render-cache-entry-reusable-p entry))
|
|
(ebox--render-cache-replay-entry-side-effects entry)
|
|
(let ((state (ebox--scroll-get-state 'scroll)))
|
|
(should (eq (plist-get state :box) candidate-node))
|
|
(should (equal (plist-get state :content-lines)
|
|
'("alpha" "beta")))
|
|
(should (equal (plist-get state :rendered-content-lines)
|
|
'("alpha")))))
|
|
(ebox--scroll-clear-state 'scroll)
|
|
(remhash entry ebox--render-cache-entry-side-effects-table)
|
|
(remhash buffer ebox--buffer-render-state-table)
|
|
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-render-cache-rejects-nonportable-candidate-scroll-state ()
|
|
"Candidate publication must not replay closures owned by the old tree."
|
|
(let* ((prefix-renderer (lambda (_count) '("deferred")))
|
|
(materializer (lambda () '("materialized")))
|
|
(actions
|
|
(list
|
|
(list 'set 'scroll
|
|
(list :box (ebox-test-box (ebox-test-text "old") :scroll-offset 0)
|
|
:content-lines '("alpha")
|
|
:render-content-prefix prefix-renderer
|
|
:materialize-content-lines materializer))))
|
|
(ebox--render-cache-scroll-state-restorable-p nil)
|
|
(entry (cons 'signature "rendered"))
|
|
(metadata
|
|
(list :scroll-actions actions
|
|
:stateful-p t
|
|
:portable-p nil
|
|
:restorable-p t)))
|
|
(puthash entry metadata ebox--render-cache-entry-side-effects-table)
|
|
(unwind-protect
|
|
(progn
|
|
(should-not (ebox--scroll-cache-actions-portable-p actions))
|
|
(should-not (ebox--render-cache-entry-reusable-p entry)))
|
|
(remhash entry ebox--render-cache-entry-side-effects-table))))
|
|
|
|
(ert-deftest ebox-render-cache-ring-recovers-after-clrhash ()
|
|
"An exceptional cache clear should not leave stale eviction metadata."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 2)
|
|
(ebox-cache-report-buffer nil))
|
|
(ebox--render-cache-store 'responsive 'width-a 'rendered-a)
|
|
(ebox--render-cache-store 'responsive 'width-b 'rendered-b)
|
|
(clrhash ebox--render-cache-table)
|
|
(ebox--render-cache-store 'responsive 'width-c 'rendered-c)
|
|
(ebox--render-cache-store 'responsive 'width-d 'rendered-d)
|
|
(ebox--render-cache-store 'responsive 'width-e 'rendered-e)
|
|
(should (= (hash-table-count ebox--render-cache-table) 2))
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-c))
|
|
(should (eq (ebox--render-cache-lookup 'responsive 'width-d)
|
|
'rendered-d))
|
|
(should (eq (ebox--render-cache-lookup 'responsive 'width-e)
|
|
'rendered-e))))
|
|
|
|
(ert-deftest ebox-render-cache-ring-retains-newest-entries-when-limit-shrinks ()
|
|
"Lowering the cache limit should retain the newest insertion-order entries."
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox-render-cache-max-entries 3)
|
|
(ebox-cache-report-buffer nil))
|
|
(ebox--render-cache-store 'responsive 'width-a 'rendered-a)
|
|
(ebox--render-cache-store 'responsive 'width-b 'rendered-b)
|
|
(ebox--render-cache-store 'responsive 'width-c 'rendered-c)
|
|
(setq ebox-render-cache-max-entries 2)
|
|
(ebox--render-cache-store 'responsive 'width-d 'rendered-d)
|
|
(should (= (hash-table-count ebox--render-cache-table) 2))
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-a))
|
|
(should-not (ebox--render-cache-lookup 'responsive 'width-b))
|
|
(should (eq (ebox--render-cache-lookup 'responsive 'width-c)
|
|
'rendered-c))
|
|
(should (eq (ebox--render-cache-lookup 'responsive 'width-d)
|
|
'rendered-d))))
|
|
|
|
(ert-deftest ebox-buffer-render-cache-stays-bounded-across-unique-widths ()
|
|
"Continuous resize should not retain an unbounded set of rendered widths."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-render-cache-max-entries 32)
|
|
(ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
|
(layout
|
|
(ebox-test-box :id "root" :width '(viewport)
|
|
(apply #'ebox-test-flex
|
|
(append (list :width '(viewport) :gap 1)
|
|
(cl-loop for index below 8 collect
|
|
(ebox-test-box :id (format "card-%d" index)
|
|
(ebox-test-text (format "card %d" index)) :width
|
|
72 :height 1))))))
|
|
buffer)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-cache-bound*") layout))
|
|
(cl-loop for width from 241 to 280
|
|
do (ebox-rerender-buffer-with-context buffer width))
|
|
(let ((cache (ebox--buffer-render-cache buffer)))
|
|
(should (<= (hash-table-count cache)
|
|
ebox-render-cache-max-entries)))
|
|
(with-current-buffer buffer
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-returning-width-cache-restores-lazy-scroll-context ()
|
|
"A cached subtree must restore lazy scroll state for its render context."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-column :id "viewport" :width '(viewport)
|
|
(ebox-test-box :id "scroll" :width '(viewport)
|
|
:height 4 :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for index below 32 collect
|
|
(ebox-test-box (ebox-test-text (format "cached row %02d" index))
|
|
:width '(viewport) :height 1)))))))
|
|
buffer
|
|
scroll-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 6)
|
|
;; Evict the first complete root so the return leg renders the
|
|
;; root while retaining its generic cached scroll subtree.
|
|
(ebox-render-root-cache-max-entries 1)
|
|
(ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-scroll-cache-context*")
|
|
layout))
|
|
(setq scroll-id
|
|
(ebox-test--selector-region-id buffer "#scroll"))
|
|
(ebox-rerender-buffer-with-context buffer 320 6)
|
|
(ebox-rerender-buffer-with-context buffer 240 6)
|
|
(let ((state (ebox--scroll-get-state scroll-id)))
|
|
(should state)
|
|
(should (= (ebox--string-pixel-width
|
|
(car (plist-get state :rendered-content-lines)))
|
|
240))
|
|
(dotimes (_ 16)
|
|
(setq state
|
|
(ebox--scroll-state-ensure-prefix-lines
|
|
scroll-id state
|
|
(1+ (length (plist-get state :content-lines)))
|
|
t))))
|
|
(with-current-buffer buffer
|
|
(dotimes (_ 6)
|
|
(should (= (ebox--scroll-region-by scroll-id 1) 1)))
|
|
(let ((scrolled (buffer-string)))
|
|
(ebox-rerender-buffer-with-context buffer 240 6)
|
|
(should (equal-including-properties
|
|
scrolled (buffer-string))))))
|
|
(when scroll-id
|
|
(ebox--scroll-cancel-idle-prefetch scroll-id))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-prepared-root-render-is-exact-one-shot-fast-path ()
|
|
"An exact prepared root should bypass layout once and never affect children."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(240)
|
|
(ebox-test-box :id "child" (ebox-test-text "prepared") :height
|
|
1)))
|
|
buffer)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 900)
|
|
(ebox-viewport-height 4)
|
|
(ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm nil))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prepared-root*") layout))
|
|
(let* ((state (ebox--buffer-render-state buffer))
|
|
(root (plist-get state :root-node))
|
|
(child (ebox--buffer-region-render-owner-node
|
|
buffer
|
|
(ebox-test--selector-region-id buffer "#child")))
|
|
(rendered (propertize "prepared-root" 'face 'bold))
|
|
(prepared
|
|
(list :rendered rendered
|
|
:render-state state
|
|
:node-id (ebox--ensure-node-id root)
|
|
:runtime-revision 0
|
|
:display-signature
|
|
(with-current-buffer buffer
|
|
(ebox-display-signature))
|
|
:kind 'root-width
|
|
:region-id
|
|
(ebox-test--selector-region-id buffer "#root")
|
|
:root-width 240
|
|
:viewport-width 900
|
|
:viewport-height 4)))
|
|
(with-current-buffer buffer
|
|
(ebox--with-buffer-render-context buffer
|
|
(let ((ebox--prepared-root-render prepared))
|
|
;; A local subtree lookup must not inspect or consume the
|
|
;; root-only handoff.
|
|
(ebox--render-with-cache child)
|
|
(should-not (plist-get prepared :consumed-p))
|
|
(cl-letf (((symbol-function 'ebox-render)
|
|
(lambda (&rest _)
|
|
(ert-fail "Exact prepared root reran layout"))))
|
|
(should (eq (ebox--render-with-cache root t) rendered)))
|
|
(should (plist-get prepared :consumed-p))
|
|
(should-not ebox--prepared-root-render))))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-prepared-root-render-rejects-stale-runtime ()
|
|
"A stale prepared root should fall back without entering later updates."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :width '(240) (ebox-test-text "old")))
|
|
buffer
|
|
(fallback-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 900)
|
|
(ebox-viewport-height 4)
|
|
(ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm nil))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-stale-prepared-root*")
|
|
layout))
|
|
(let* ((state (ebox--buffer-render-state buffer))
|
|
(root (plist-get state :root-node))
|
|
(prepared
|
|
(list :rendered "stale"
|
|
:render-state state
|
|
:node-id (ebox--ensure-node-id root)
|
|
:runtime-revision 1
|
|
:display-signature
|
|
(with-current-buffer buffer
|
|
(ebox-display-signature))
|
|
:kind 'root-width
|
|
:region-id
|
|
(ebox-test--selector-region-id buffer "#root")
|
|
:root-width 240
|
|
:viewport-width 900
|
|
:viewport-height 4)))
|
|
(with-current-buffer buffer
|
|
(ebox--with-buffer-render-context buffer
|
|
(let ((ebox--prepared-root-render prepared)
|
|
(ebox--render-cache-table
|
|
(make-hash-table :test 'equal)))
|
|
(cl-letf (((symbol-function 'ebox--render-node)
|
|
(lambda (&rest _)
|
|
(cl-incf fallback-count)
|
|
"fresh")))
|
|
(should (equal (ebox--render-with-cache root t) "fresh")))
|
|
(should (= fallback-count 1))
|
|
(should-not (plist-get prepared :consumed-p))
|
|
(should-not ebox--prepared-root-render))))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-prewarm-reuses-live-isolated-scratch ()
|
|
"Viewport predictions should reuse one nonmutating isolated live tree."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport)
|
|
(ebox-test-box (ebox-test-text "viewport scratch") :width
|
|
'(viewport) :height 1)))
|
|
buffer)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 4)
|
|
(ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm nil))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-viewport-scratch*") layout))
|
|
(let* ((state (ebox--buffer-render-state buffer))
|
|
(live-root (plist-get state :root-node))
|
|
scratch tree)
|
|
(should
|
|
(ebox--prewarm-buffer-render-cache-at-context
|
|
buffer state 0 240 260 4))
|
|
(setq scratch (plist-get state :reflow-prewarm-scratch)
|
|
tree (plist-get scratch :tree))
|
|
(should scratch)
|
|
(should-not (eq tree live-root))
|
|
(should
|
|
(ebox--prewarm-buffer-render-cache-at-context
|
|
buffer state 0 240 280 4))
|
|
(should (eq tree
|
|
(plist-get
|
|
(plist-get state :reflow-prewarm-scratch) :tree)))
|
|
(should (eq live-root (plist-get state :root-node)))
|
|
(should-not (plist-get scratch :predicted-revision))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-root-width-cache-prewarm-yields-to-user-input ()
|
|
"Ordinary root prediction should wait a fresh interval even while idle."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(240)
|
|
(ebox-test-box (ebox-test-text "scheduled ordinary root")
|
|
:height 1)))
|
|
buffer
|
|
scheduled-delay
|
|
scheduled-repeat
|
|
scheduled-function
|
|
scheduled-arguments)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 900)
|
|
(ebox-viewport-height 4)
|
|
(ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm t)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-root-schedule*")
|
|
layout))
|
|
(let ((root-id (ebox-test--selector-region-id buffer "#root")))
|
|
(cl-letf (((symbol-function 'current-idle-time)
|
|
(lambda () (seconds-to-time 0.4)))
|
|
((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
|
|
(+ 0.4 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-reflow-prewarm-replacement-and-one-shot-completion ()
|
|
"Fresh and zero-delay predictions replace old jobs and complete once."
|
|
(ebox-test--reset-runtime-state)
|
|
(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)
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-one-shot*")
|
|
(ebox-test-box :id "root" :width '(240) :height 1
|
|
(ebox-test-text "prediction"))))
|
|
(let ((state (ebox--buffer-render-state buffer))
|
|
(root-id (ebox-test--selector-region-id buffer "#root")))
|
|
(dolist (kind '(viewport root-width))
|
|
(dolist (case '((nil 0.15 0.15) (0.4 0 0.4)))
|
|
(pcase-let* ((`(,idle-age ,delay ,threshold) case)
|
|
(ebox-runtime-idle-reflow-cache-prewarm-delay delay)
|
|
(scratch (plist-get state :reflow-prewarm-scratch))
|
|
(cancel (symbol-function 'cancel-timer))
|
|
(registrations 0)
|
|
(scheduled nil)
|
|
(cancelled nil))
|
|
(cl-letf
|
|
(((symbol-function 'current-idle-time)
|
|
(lambda () (and idle-age (seconds-to-time idle-age))))
|
|
((symbol-function 'cancel-timer)
|
|
(lambda (timer)
|
|
(push timer cancelled)
|
|
(funcall cancel timer)))
|
|
((symbol-function 'run-with-idle-timer)
|
|
(lambda (seconds repeat function &rest arguments)
|
|
(cl-incf registrations)
|
|
(setq scheduled (list seconds repeat function arguments))
|
|
(timer-create))))
|
|
(dotimes (attempt 2)
|
|
(let ((old (gethash buffer ebox--reflow-cache-prewarm-timers)))
|
|
(pcase kind
|
|
('viewport
|
|
(ebox--schedule-buffer-reflow-cache-prewarm
|
|
buffer 880 900))
|
|
('root-width
|
|
(ebox--schedule-buffer-root-width-cache-prewarm
|
|
buffer root-id 240 260)))
|
|
(when (= attempt 1)
|
|
(should (memq old cancelled))
|
|
(should-not
|
|
(eq old (gethash buffer ebox--reflow-cache-prewarm-timers))))))
|
|
(should (= registrations 2))
|
|
(should (= (car scheduled) threshold))
|
|
(should-not (cadr scheduled))
|
|
(should (eq scratch (plist-get state :reflow-prewarm-scratch)))
|
|
;; Advance the same idle period beyond the fixed threshold.
|
|
;; Run the real callback, including its state/revision guards.
|
|
(setq idle-age (+ threshold 1))
|
|
(should (apply (nth 2 scheduled) (nth 3 scheduled)))
|
|
(should (plist-get state :reflow-prewarm-scratch))
|
|
(should-not (gethash buffer ebox--reflow-cache-prewarm-timers))
|
|
(should (= registrations 2))))))))
|
|
(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-buffer-backend-composes-mixed-paint-slots-and-typography ()
|
|
"Literal and named colors compose in both directions without losing fonts."
|
|
(let* ((foreground (tp-paint-slot-create '(:foreground "#112233")))
|
|
(background (tp-paint-slot-create '(:background "#ddeeff")))
|
|
(font (ebox-font-project-values
|
|
'(ebox/font-weight bold ebox/font-style italic)
|
|
'(ebox/font-weight ebox/font-style) nil))
|
|
(font-before (ebox-font-paint-face font)))
|
|
(dolist (typography '(nil t))
|
|
(dolist (slots '((t nil) (nil t) (t t) (nil nil)))
|
|
(let* ((style
|
|
(append (list :color (if (car slots) foreground "#112233")
|
|
:bgcolor (if (cadr slots) background "#ddeeff"))
|
|
(and typography
|
|
(list :ebox-font-fact font
|
|
:text-decoration-line 'underline))))
|
|
(before (copy-tree style))
|
|
(face (plist-get (ebox-buffer-paint-text-properties
|
|
style 'content) 'face))
|
|
(faces (if (or (symbolp face) (keywordp (car-safe face)))
|
|
(list face)
|
|
face)))
|
|
;; Emacs accepts a named face, one face plist, or a flat list of
|
|
;; those contributions. A named face is never a mutable plist.
|
|
(should (cl-every (lambda (entry)
|
|
(or (symbolp entry)
|
|
(keywordp (car-safe entry))))
|
|
faces))
|
|
(should (equal (cl-remove-if-not #'symbolp faces)
|
|
(append
|
|
(and (cadr slots) (list (tp-paint-slot-face background)))
|
|
(and (car slots) (list (tp-paint-slot-face foreground))))))
|
|
(if (car slots)
|
|
(should (memq (tp-paint-slot-face foreground) faces))
|
|
(should (ebox-test--face-has-key-value-p face :foreground "#112233")))
|
|
(if (cadr slots)
|
|
(should (memq (tp-paint-slot-face background) faces))
|
|
(should (ebox-test--face-has-key-value-p face :background "#ddeeff")))
|
|
(when typography
|
|
(should (ebox-test--face-has-key-value-p face :weight 'bold))
|
|
(should (ebox-test--face-has-key-value-p face :slant 'italic))
|
|
(should (ebox-test--face-has-key-p face :underline)))
|
|
(should (equal style before))
|
|
(should (equal font-before (ebox-font-paint-face font))))))))
|
|
|
|
(ert-deftest ebox-text-decoration-projects-to-one-emacs-face ()
|
|
"Canonical decoration facts should remain paint-only and visibly compose."
|
|
(let* ((style
|
|
(ebox-style-compute
|
|
'(:text-decoration-line (underline overline line-through)
|
|
:text-decoration-color "#336699"
|
|
:text-decoration-style wavy)))
|
|
(face (plist-get
|
|
(ebox-buffer-paint-text-properties style 'content) 'face))
|
|
(rendered
|
|
(ebox-render
|
|
(ebox-test-text
|
|
"Decorated"
|
|
:text-decoration-line '(underline overline line-through)
|
|
:text-decoration-color "#336699"
|
|
:text-decoration-style 'wavy)))
|
|
(rendered-face (get-text-property 0 'face rendered)))
|
|
(dolist (candidate (list face rendered-face))
|
|
(should (ebox-test--face-has-key-value-p
|
|
candidate :underline '(:color "#336699" :style wave)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
candidate :overline "#336699"))
|
|
(should (ebox-test--face-has-key-value-p
|
|
candidate :strike-through "#336699")))))
|
|
|
|
(ert-deftest ebox-render-keeps-tp-paint-slot-address-stable ()
|
|
"A TP paint slot should recolor retained output without property rewrites."
|
|
(let* ((slot (tp-paint-slot-create '(:foreground "red")))
|
|
(face (tp-paint-slot-face slot))
|
|
(rendered (ebox-render (ebox-test-box (ebox-test-text "paint") :color slot)))
|
|
(property (get-text-property 0 'face rendered)))
|
|
(should (eq face property))
|
|
(tp-paint-slot-update slot '(:foreground "blue"))
|
|
(should (eq property (get-text-property 0 'face rendered)))
|
|
(should (equal "blue" (face-attribute face :foreground nil nil)))))
|
|
|
|
(ert-deftest ebox-buffer-backend-does-not-map-layout-only-properties ()
|
|
"Layout-only properties should not produce text properties directly."
|
|
(let* ((style (ebox-style-compute
|
|
(list :width '(120)
|
|
:padding '(1 2))))
|
|
(props (ebox-buffer-paint-text-properties style 'content)))
|
|
(should-not (memq :width props))
|
|
(should-not (memq :padding-inline-start props))))
|
|
|
|
(ert-deftest ebox-add-content-owner-ignores-paint-only-segment-changes ()
|
|
"Owner stacking should segment only when owner metadata changes."
|
|
(let ((line (copy-sequence "abcdefgh"))
|
|
(stack-lookups 0)
|
|
(original-stack-at (symbol-function 'ebox--content-owner-stack-at)))
|
|
(add-text-properties 0 4 '(ebox-content-owner child-a) line)
|
|
(add-text-properties 4 8 '(ebox-content-owner child-b) line)
|
|
(add-text-properties 1 2 '(face (:foreground "#111111")) line)
|
|
(add-text-properties 2 3 '(display (space :width (3))) line)
|
|
(add-text-properties 5 6 '(face (:background "#eeeeee")) line)
|
|
(cl-letf (((symbol-function 'ebox--content-owner-stack-at)
|
|
(lambda (&rest args)
|
|
(cl-incf stack-lookups)
|
|
(apply original-stack-at args))))
|
|
(let ((owned (ebox--add-content-owner line 'root)))
|
|
(should (= stack-lookups 2))
|
|
(should (equal '(child-a root)
|
|
(get-text-property 0 'ebox-content-owners owned)))
|
|
(should (equal '(child-b root)
|
|
(get-text-property 4 'ebox-content-owners owned)))))))
|
|
|
|
(ert-deftest ebox-add-content-owner-scans-complete-owner-stacks-once ()
|
|
"Complete owner stacks should not require a parallel owner-property scan."
|
|
(let ((line (copy-sequence "abcdefgh"))
|
|
(property-scans 0)
|
|
(original-next (symbol-function 'next-single-property-change)))
|
|
(add-text-properties
|
|
0 4 '(ebox-content-owner child-a ebox-content-owners (child-a)) line)
|
|
(add-text-properties
|
|
4 8 '(ebox-content-owner child-b ebox-content-owners (child-b)) line)
|
|
(cl-letf (((symbol-function 'next-single-property-change)
|
|
(lambda (position property &rest args)
|
|
(when (memq property
|
|
'(ebox-content-owner ebox-content-owners))
|
|
(cl-incf property-scans))
|
|
(apply original-next position property args))))
|
|
(let ((owned (ebox--add-content-owner line 'root)))
|
|
(should (= property-scans 2))
|
|
(should (equal '(child-a root)
|
|
(get-text-property 0 'ebox-content-owners owned)))
|
|
(should (equal '(child-b root)
|
|
(get-text-property 4 'ebox-content-owners owned)))))))
|
|
|
|
(ert-deftest ebox-add-content-owners-merges-all-owners-in-one-scan ()
|
|
"Adding several owners should scan each original owner run only once."
|
|
(let ((line (copy-sequence "abcdefgh"))
|
|
(property-scans 0)
|
|
(original-next (symbol-function 'next-single-property-change)))
|
|
(add-text-properties 0 4 '(ebox-content-owner child-a) line)
|
|
(add-text-properties 4 8 '(ebox-content-owner child-b) line)
|
|
(cl-letf (((symbol-function 'next-single-property-change)
|
|
(lambda (position property &rest args)
|
|
(when (memq property
|
|
'(ebox-content-owner ebox-content-owners))
|
|
(cl-incf property-scans))
|
|
(apply original-next position property args))))
|
|
(let ((owned (ebox--add-content-owners line '(middle root))))
|
|
(should (= property-scans 4))
|
|
(should (eq (get-text-property 0 'ebox-content-owner owned) 'root))
|
|
(should (equal '(child-a middle root)
|
|
(get-text-property 0 'ebox-content-owners owned)))
|
|
(should (equal '(child-b middle root)
|
|
(get-text-property 4 'ebox-content-owners owned)))))))
|
|
|
|
(ert-deftest ebox-propertize-content-line-can-own-private-line-in-place ()
|
|
"A private split line should accept wrapper ownership without another copy."
|
|
(let* ((source (propertize "content"
|
|
'ebox-content 'child
|
|
'ebox-content-owner 'child
|
|
'ebox-content-owners '(child)))
|
|
(source-before (copy-sequence source))
|
|
(private-line (copy-sequence source))
|
|
(regular
|
|
(ebox--propertize-content-line source 'wrapper 0 "fallback"))
|
|
(owned
|
|
(let ((ebox--propertize-private-content-line-p t))
|
|
(ebox--propertize-content-line
|
|
private-line 'wrapper 0 "fallback"))))
|
|
(should-not (eq regular source))
|
|
(should (equal-including-properties source source-before))
|
|
(should (eq owned private-line))
|
|
(should (eq (get-text-property 0 'ebox-content-owner owned) 'wrapper))
|
|
(should (equal (get-text-property 0 'ebox-content-owners owned)
|
|
'(child wrapper)))))
|
|
|
|
(ert-deftest ebox-scroll-rendered-content-lines-preserve-text-paint-in-place ()
|
|
"Scroll ownership should preserve Text paint and add Box background."
|
|
(let* ((box (ebox-test-box :width 120 :bgcolor "#F4F5F6"))
|
|
(ebox--render-source-index (ebox-test-source-index box))
|
|
(region-id 901)
|
|
(source (propertize "child" 'ebox-content 77
|
|
'ebox-content-owner 77
|
|
'ebox-content-owners '(77)
|
|
'face '(:foreground "#112233")))
|
|
(source-before (copy-sequence source))
|
|
(filler (ebox-pixel-space
|
|
(ebox-test--input-content-pixel box)))
|
|
(expected
|
|
(ebox--propertize-colors
|
|
(ebox--propertize-content-line source region-id 4 filler)
|
|
nil
|
|
(ebox-style-node-specified-value box :bgcolor)))
|
|
(copying-owner-count 0)
|
|
(original-copying-owner
|
|
(symbol-function 'ebox--add-content-owner))
|
|
actual)
|
|
(cl-letf (((symbol-function 'ebox--add-content-owner)
|
|
(lambda (&rest args)
|
|
(cl-incf copying-owner-count)
|
|
(apply original-copying-owner args))))
|
|
(setq actual
|
|
(car (ebox--scroll-rendered-content-lines
|
|
(ebox-test-root box) (list source) region-id 4))))
|
|
(should (equal-including-properties source source-before))
|
|
(should (equal-including-properties actual expected))
|
|
(should (= copying-owner-count 0))))
|
|
|
|
(ert-deftest ebox-box-render-coalesces-foreground-background-pass ()
|
|
"Foreground and background should not each copy the rendered box lines."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "paint")
|
|
:width 12 :height 1
|
|
:color "#112233"
|
|
:bgcolor "#ddeeff"))
|
|
(reference (ebox-render box))
|
|
(original-color (symbol-function 'ebox--propertize-color))
|
|
(original-bgcolor (symbol-function 'ebox--propertize-bgcolor))
|
|
(color-calls 0)
|
|
(bgcolor-calls 0)
|
|
actual)
|
|
(cl-letf (((symbol-function 'ebox--propertize-color)
|
|
(lambda (&rest args)
|
|
(cl-incf color-calls)
|
|
(apply original-color args)))
|
|
((symbol-function 'ebox--propertize-bgcolor)
|
|
(lambda (&rest args)
|
|
(cl-incf bgcolor-calls)
|
|
(apply original-bgcolor args))))
|
|
(setq actual (ebox-render box)))
|
|
(should (= color-calls 0))
|
|
(should (= bgcolor-calls 0))
|
|
(should (equal-including-properties actual reference))))
|
|
|
|
(ert-deftest ebox-incremental-update-scheduler-batches-multiple-style-changes ()
|
|
"Scheduler seed should coalesce multiple property changes into one flush."
|
|
(let* ((node (ebox-test-box (ebox-test-text "Batch") :border-color "#111111"))
|
|
(id (car (ebox-region-ids node))))
|
|
(ebox-test--with-rendered-buffer node
|
|
(ebox-incremental-begin-batch (current-buffer))
|
|
(ebox-test--region-update id :border-color "#222222")
|
|
(ebox-test--region-update id :background-color "#eeeeee")
|
|
(should (= (ebox-incremental-pending-count (current-buffer)) 2))
|
|
(ebox-incremental-flush (current-buffer))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (= (plist-get report :flush-count) 1))
|
|
(should (= (plist-get report :patch-count) 1))
|
|
(should (member 'paint (plist-get report :dirty-kinds)))))))
|
|
|
|
(ert-deftest ebox-explicit-batch-keeps-descendant-paint-after-ancestor-paint ()
|
|
"Ancestor and child paint must execute general-to-specific in any input order."
|
|
(dolist (order '((parent child) (child parent)))
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id 'parent :width '(120) :color "black"
|
|
(ebox-test-box :id 'child (ebox-test-text "Child") :width
|
|
'(120) :color "black"))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(parent-id (ebox-test--selector-region-id buffer "#parent"))
|
|
(child-id (ebox-test--selector-region-id buffer "#child")))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(dolist (operation order)
|
|
(pcase operation
|
|
('parent (ebox-test--region-update parent-id :color "red"))
|
|
('child (ebox-test--region-update child-id :color "blue"))))
|
|
(ebox-incremental-flush buffer))
|
|
(goto-char (point-min))
|
|
(search-forward "Child")
|
|
(let ((face (get-text-property (- (point) (length "Child")) 'face)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :foreground "blue")))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
|
|
|
|
(ert-deftest ebox-explicit-batch-paints-before-descendant-text-change ()
|
|
"Ancestor paint must run before a descendant text-changing operation."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id 'parent :width '(160) :color "black"
|
|
(ebox-test-box :id 'child (ebox-test-text "Child") :width
|
|
'(120) :color "blue"))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(parent-id (ebox-test--selector-region-id buffer "#parent"))
|
|
(child-id (ebox-test--selector-region-id buffer "#child")))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(ebox-test--region-update parent-id :color "red")
|
|
(ebox-test--region-update child-id :width '(80))
|
|
(ebox-incremental-flush buffer))
|
|
(goto-char (point-min))
|
|
(search-forward "Child")
|
|
(let ((face (get-text-property (- (point) (length "Child")) 'face)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :foreground "blue")))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(defun ebox-test--multi-row-batch-flex-layout ()
|
|
"Return a wrapped flex fixture with targets on separate rows."
|
|
(ebox-test-flex :id 'matrix :width '(500) :flex-flow '(row wrap)
|
|
:column-gap '(12) :row-gap 1 :padding '(0 (12))
|
|
:border "#999999"
|
|
(ebox-test-column :width '(210) :border "#999999"
|
|
(ebox-test-box (ebox-test-text "Title A")
|
|
:width '(190))
|
|
(ebox-test-box :id 'target-a
|
|
(ebox-test-text "Target A")
|
|
:width '(160) :height
|
|
3 :padding '(0 (8))
|
|
:border "#999999"
|
|
:text-align 'right)
|
|
(ebox-test-box (ebox-test-text "Metric A")
|
|
:width '(190) :height
|
|
2))
|
|
(ebox-test-column :width '(210) :border "#999999"
|
|
(ebox-test-box (ebox-test-text "Peer A")
|
|
:width '(190))
|
|
(ebox-test-box (ebox-test-text "Same first row")
|
|
:width '(190) :height
|
|
3)
|
|
(ebox-test-box (ebox-test-text "Metric B")
|
|
:width '(190) :height
|
|
2))
|
|
(ebox-test-column :width '(210) :border "#999999"
|
|
(ebox-test-box (ebox-test-text "Title B")
|
|
:width '(190))
|
|
(ebox-test-box :id 'target-b
|
|
(ebox-test-text "Target B")
|
|
:width '(160) :height
|
|
3 :padding '(0 (8))
|
|
:border "#999999"
|
|
:text-align 'right)
|
|
(ebox-test-box (ebox-test-text "Metric C")
|
|
:width '(190) :height
|
|
2))
|
|
(ebox-test-column :width '(210) :border "#999999"
|
|
(ebox-test-box (ebox-test-text "Peer B")
|
|
:width '(190))
|
|
(ebox-test-box (ebox-test-text "Same second row")
|
|
:width '(190) :height
|
|
3)
|
|
(ebox-test-box (ebox-test-text "Metric D")
|
|
:width '(190) :height
|
|
2))))
|
|
|
|
(ert-deftest ebox-explicit-batch-multiple-flex-rows-skip-line-fast-path ()
|
|
"Dirty descendants on separate flex rows must rerender in either order."
|
|
(dolist (order '((target-a target-b) (target-b target-a)))
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--multi-row-batch-flex-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(target-a-id
|
|
(ebox-test--selector-region-id buffer "#target-a"))
|
|
(target-b-id
|
|
(ebox-test--selector-region-id buffer "#target-b")))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(dolist (target order)
|
|
(ebox-test--region-update
|
|
(if (eq target 'target-a) target-a-id target-b-id)
|
|
:width '(210) :min-width '(150) :max-width '(260)))
|
|
(ebox-incremental-flush buffer))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))
|
|
(should-not (plist-get report :flex-line-rerender)))))))
|
|
|
|
(ert-deftest ebox-explicit-batch-absorbed-flex-descendant-keeps-provenance ()
|
|
"An absorbed descendant update must disable a partial flex owner rerender."
|
|
(dolist (order '((geometry paint) (paint geometry)))
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--multi-row-batch-flex-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(target-a-id
|
|
(ebox-test--selector-region-id buffer "#target-a"))
|
|
(target-b-id
|
|
(ebox-test--selector-region-id buffer "#target-b")))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(dolist (operation order)
|
|
(pcase operation
|
|
('geometry
|
|
(ebox-test--region-update target-a-id
|
|
:width '(210)
|
|
:min-width '(150)
|
|
:max-width '(260)))
|
|
('paint
|
|
(ebox-test--region-update target-b-id :color "blue"))))
|
|
(ebox-incremental-flush buffer))
|
|
(goto-char (point-min))
|
|
(search-forward "Target B")
|
|
(let* ((start (- (point) (length "Target B")))
|
|
(face (get-text-property start 'face))
|
|
(report (ebox-test--buffer-update-report)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :foreground "blue"))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))
|
|
(should-not (plist-get report :flex-line-rerender)))))))
|
|
|
|
(ert-deftest ebox-layout-module-renders-row-and-column-through-facade ()
|
|
"Facade rendering should still use extracted layout owners."
|
|
(let ((rendered (ebox-render
|
|
(ebox-test-column
|
|
(ebox-test-row (ebox-test-box (ebox-test-text "A") :width '(40))
|
|
(ebox-test-box (ebox-test-text "B") :width '(40)))))))
|
|
(should (string-match-p "A" rendered))
|
|
(should (string-match-p "B" rendered))))
|
|
|
|
(ert-deftest ebox-layout-snapshot-details-include-footprint-signatures ()
|
|
"Detailed snapshots should expose explicit L2 footprint signatures."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Footprint") :width '(120)
|
|
:height 1 :border '(1 solid)))
|
|
(node-id (ebox--ensure-node-id (ebox-test-root box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let* ((snapshot (ebox--ensure-layout-snapshot-details
|
|
(current-buffer) node-id))
|
|
(span-footprint
|
|
(plist-get snapshot :span-footprint-signature))
|
|
(external-footprint
|
|
(plist-get snapshot :external-footprint-signature))
|
|
(parent-slot
|
|
(plist-get snapshot :parent-slot-signature))
|
|
(role-topology
|
|
(plist-get snapshot :role-topology-signature))
|
|
(overflow
|
|
(plist-get snapshot :overflow-signature)))
|
|
(should span-footprint)
|
|
(should external-footprint)
|
|
(should parent-slot)
|
|
(should role-topology)
|
|
(should overflow)
|
|
(should (= (plist-get span-footprint :span-count) 1))
|
|
(should (equal (plist-get span-footprint :line-pixel-widths)
|
|
(plist-get snapshot :line-signature)))
|
|
(should (= (plist-get external-footprint :block-lines) 1))
|
|
(should (= (plist-get external-footprint :max-line-pixel-width)
|
|
(car (plist-get snapshot :line-signature))))
|
|
(should (= (plist-get parent-slot :slot-count) 1))
|
|
(should (memq 'content (plist-get role-topology :roles)))
|
|
(should-not (plist-get overflow :visible))))))
|
|
|
|
(ert-deftest ebox-layout-snapshot-capture-skips-line-ownership-scan ()
|
|
"Initial snapshot capture should not scan line ownership for every node."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (apply
|
|
#'ebox-test-column
|
|
(cl-loop for index below 8
|
|
collect
|
|
(ebox-test-flex
|
|
:border "#64748B" :bgcolor "#F8FAFC"
|
|
:flex-flow '(row wrap)
|
|
(ebox-test-box
|
|
(ebox-test-text (format "A%d" index))
|
|
:width 60 :bgcolor "#DBEAFE")
|
|
(ebox-test-box
|
|
(ebox-test-text (format "B%d" index))
|
|
:width 60 :bgcolor "#DCFCE7")
|
|
(ebox-test-box
|
|
(ebox-test-text (format "C%d" index))
|
|
:width 60 :bgcolor "#FEF3C7")))))
|
|
(calls 0)
|
|
(original (symbol-function 'ebox--pos-region-ids))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 900))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(ebox--clear-layout-snapshots buffer)
|
|
(cl-letf (((symbol-function 'ebox--pos-region-ids)
|
|
(lambda (&rest args)
|
|
(cl-incf calls)
|
|
(apply original args))))
|
|
(ebox--refresh-buffer-layout-snapshots buffer))
|
|
(should (= calls 0)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-stack-render-flattens-before-width-measurement ()
|
|
"Column rendering should not repeatedly measure already stacked strings."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (apply
|
|
#'ebox-test-column
|
|
(cl-loop for index below 12
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "Row %02d" index))
|
|
:width (+ 40 (* index 2))))))
|
|
(multiline-width-calls 0)
|
|
(original (symbol-function 'ebox--string-pixel-width)))
|
|
(cl-letf (((symbol-function 'ebox--string-pixel-width)
|
|
(lambda (string)
|
|
(when (and (stringp string)
|
|
(string-match-p "\n" string))
|
|
(cl-incf multiline-width-calls))
|
|
(funcall original string))))
|
|
(ebox-render layout))
|
|
(should (= multiline-width-calls 0))))
|
|
|
|
(ert-deftest ebox-lines-concat-horizontal-splits-each-input-once ()
|
|
"Horizontal line concatenation should avoid split-pad-resplit work."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((split-count 0)
|
|
(original (symbol-function 'ebox-string-lines))
|
|
result)
|
|
(cl-letf (((symbol-function 'ebox-string-lines)
|
|
(lambda (&rest args)
|
|
(cl-incf split-count)
|
|
(apply original args))))
|
|
(setq result
|
|
(ebox--lines-concat-horizontal
|
|
"A\nB\nC"
|
|
"1\n2"
|
|
"x")))
|
|
(should (string= (substring-no-properties result)
|
|
"A1x\nB2 \nC "))
|
|
(should (= split-count 3))))
|
|
|
|
(ert-deftest ebox-layout-snapshot-table-is-created-when-missing ()
|
|
"Snapshot storage should tolerate render states created before snapshots."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer (generate-new-buffer " *ebox-test*"))
|
|
(node-id "legacy-node"))
|
|
(unwind-protect
|
|
(progn
|
|
(puthash buffer
|
|
(list :root-node nil
|
|
:viewport-width 80)
|
|
ebox--buffer-render-state-table)
|
|
(ebox--put-layout-snapshot buffer node-id (list :node-id node-id))
|
|
(should (equal (plist-get (ebox--layout-snapshot buffer node-id)
|
|
:node-id)
|
|
node-id)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-dirty-set-classifies-paint-only-change ()
|
|
"A color-only change should produce paint dirty, not geometry dirty."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Paint") :width 80
|
|
:border '(1 solid "red")))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((old (ebox--current-layout-snapshots (current-buffer))))
|
|
(ebox-test--region-update region-id :border-color "blue")
|
|
(let ((dirty (ebox--diff-layout-snapshots
|
|
old
|
|
(ebox--current-layout-snapshots (current-buffer)))))
|
|
(should dirty)
|
|
(should (eq (plist-get (car dirty) :dirty-kind) 'paint)))))))
|
|
|
|
(ert-deftest ebox-dirty-set-classifies-viewport-wrap-change ()
|
|
"Viewport wrapping should produce geometry or placement dirty."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-viewport-width 240)
|
|
buffer
|
|
layout)
|
|
(setq layout
|
|
(ebox-test-flex :flex-wrap 'wrap
|
|
(ebox-test-box (ebox-test-text "A") :width '(120))
|
|
(ebox-test-box (ebox-test-text "B") :width '(120))))
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(let ((old (ebox--current-layout-snapshots buffer t)))
|
|
(ebox-rerender-buffer-with-context buffer 130)
|
|
(let ((dirty (ebox--diff-layout-snapshots
|
|
old
|
|
(ebox--current-layout-snapshots buffer t))))
|
|
(should (cl-some
|
|
(lambda (entry)
|
|
(memq (plist-get entry :dirty-kind)
|
|
'(geometry placement structure)))
|
|
dirty)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-dirty-set-classifies-wrapper-child-viewport-geometry ()
|
|
"Viewport reflow under a wrapper should produce geometry dirty, not no-op."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-column :padding 1
|
|
(ebox-test-box (ebox-test-text "Text that wraps differently when narrow")
|
|
:wrap-mode 'word)))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(let ((ebox-viewport-width 520))
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
node)))
|
|
(ebox-rerender-buffer-with-context buffer 36)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :constraint-source) 'viewport))
|
|
(should (member 'geometry (plist-get report :dirty-kinds)))
|
|
(should-not (eq (plist-get report :strategy) 'no-op))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-patch-set-paints-without-rendered-text-replace ()
|
|
"Paint-only dirty entries should produce paint-patch ops."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Paint") :width 80
|
|
:border '(1 solid "red")))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((full-rerenders
|
|
(ebox-test--count-full-surface-updates
|
|
(ebox-test--region-update region-id :border-color "blue"))))
|
|
(should (= full-rerenders 0)))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'paint-patch))
|
|
(should (eq (plist-get report :full-rerender) nil))))))
|
|
|
|
(ert-deftest ebox-flex-wrapper-style-update-uses-paint-patch ()
|
|
"Flex wrapper style updates should patch faces without replacing spans."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-flex
|
|
:border "#5E7F6A" :bgcolor "#F8FCF8"
|
|
(ebox-test-box (ebox-test-text "A") :width 60
|
|
:bgcolor "#DCEBDD")))
|
|
(region-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox-test--region-update region-id :bgcolor "#FDE68A")
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'paint-patch))
|
|
(should-not (plist-get report :full-rerender))
|
|
(should (equal (plist-get report :patch-ops)
|
|
'(paint-patch)))))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-report-describes-constraint-change ()
|
|
"Viewport resize reports should use the shared constraint-change model."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-viewport-width 240)
|
|
buffer
|
|
layout)
|
|
(setq layout
|
|
(ebox-test-flex :flex-wrap 'wrap
|
|
(ebox-test-box (ebox-test-text "A") :width '(120))
|
|
(ebox-test-box (ebox-test-text "B") :width '(120))))
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(ebox-rerender-buffer-with-context buffer 160)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :constraint-source) 'viewport))
|
|
(should (eq (plist-get report :constraint-owner-type)
|
|
'viewport))
|
|
(should (equal (plist-get report :constraint-owner-id)
|
|
'viewport))
|
|
(should (equal (plist-get report :constraint-root-node-id)
|
|
(plist-get (ebox-test--runtime-root buffer)
|
|
:node-id)))
|
|
(should (equal (plist-get report :dirty-kinds)
|
|
'(geometry)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-patches-column-children-independently ()
|
|
"Viewport resize should publish the correct column scope through TP."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((top (ebox-test-box
|
|
(ebox-test-text "Top text should wrap into more lines when narrow.")
|
|
:wrap-mode 'word))
|
|
(bottom (ebox-test-box
|
|
(ebox-test-text "Bottom text should also survive after top grows.")
|
|
:wrap-mode 'word))
|
|
(layout (ebox-test-column top bottom))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 260))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(ebox-rerender-buffer-with-context buffer 90)
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(plain (with-current-buffer buffer
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max)))))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (plist-get report :tp-scope-count) 0))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should (string-match-p "Top text" plain))
|
|
(should (string-match-p "Bottom text" plain))
|
|
(should
|
|
(with-current-buffer buffer
|
|
(ebox-test--buffer-propertized-matches-runtime-render-p)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-noops-without-viewport-deps ()
|
|
"Viewport resize should not rerender when no node depends on viewport width."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test-box (ebox-test-text "Fixed") :width 80))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 240))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(ebox-rerender-buffer-with-context buffer 160)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'no-op))
|
|
(should (= (plist-get report :dirty-count) 0))
|
|
(should (= (plist-get report :patch-count) 0))
|
|
(should-not (plist-get report :full-rerender))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-region-width-update-report-describes-constraint-change ()
|
|
"Ordinary box geometry updates should use the shared constraint-change model."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((left (ebox-test-box (ebox-test-text "Left") :width 60))
|
|
(right (ebox-test-box (ebox-test-text "Right") :width 60))
|
|
(layout (ebox-test-row left right))
|
|
(left-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox-test--region-update left-id :width '(120))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :constraint-source) 'region))
|
|
(should (eq (plist-get report :constraint-owner-type) 'box))
|
|
(should
|
|
(equal (plist-get report :constraint-owner-id)
|
|
(ebox-runtime-index-get left-id
|
|
(ebox--buffer-region-node-table
|
|
(current-buffer)))))
|
|
(should (equal (plist-get report :dirty-kinds)
|
|
'(geometry)))))))
|
|
|
|
(ert-deftest ebox-root-region-width-reflow-rebinds-lazy-content-viewport ()
|
|
"A root width update should constrain lazy descendants like viewport resize."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(layout
|
|
(ebox-test-box :id "root" :width '(240) :height '(viewport-height)
|
|
:overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "first") :width '(viewport))
|
|
(ebox-test-box (ebox-test-text "second") :width '(viewport))
|
|
(ebox-test-box (ebox-test-text "third") :width '(viewport)))))
|
|
(root-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox-test--region-update root-id :width '(160))
|
|
(let ((widths
|
|
(mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines (buffer-string)))))
|
|
(should widths)
|
|
(should (= (apply #'max widths) 160))))))
|
|
|
|
(ert-deftest ebox-wrapper-child-width-update-promotes-to-wrapper-context ()
|
|
"Child geometry inside a wrapper should promote to the wrapper formatting context."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-column :padding 1
|
|
(ebox-test-row
|
|
(ebox-test-box (ebox-test-text "Left") :width '(60))
|
|
(ebox-test-box (ebox-test-text "Right") :width '(60)))))
|
|
(ids (ebox-region-ids node))
|
|
(left-id (nth 1 ids)))
|
|
(ebox-test--with-rendered-buffer node
|
|
(let ((root-node-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox-test--region-update left-id :width '(120))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (equal (plist-get report :owner-ids)
|
|
(list root-node-id)))
|
|
(should (= (plist-get report :tp-scope-count) 1))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should-not (plist-get report :tp-scope-fallback))))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :constraint-source) 'region))
|
|
(should (memq 'owner-rerender (plist-get report :patch-ops))))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "Left" plain))
|
|
(should (string-match-p "Right" plain))))))
|
|
|
|
(ert-deftest ebox-patch-set-uses-wrapper-owner-for-contained-layout-change ()
|
|
"Contained layout changes should patch a real owner instead of vanishing."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Stable header") :width '(180))
|
|
(ebox-test-column :padding 1
|
|
(ebox-test-box (ebox-test-text "Child text wraps under the wrapper viewport")
|
|
:wrap-mode 'word))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(let ((ebox-viewport-width 520))
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
node)))
|
|
(ebox-rerender-buffer-with-context buffer 36)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(owner-rerender root-rerender)))
|
|
(should (plist-get report :owner-ids))
|
|
(should-not (equal (plist-get report :patch-ops)
|
|
'(paint-patch)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-patch-set-skips-dirty-nodes-covered-by-owner ()
|
|
"Viewport patch planning should not repeatedly inspect covered dirty nodes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (apply
|
|
#'ebox-test-row
|
|
(cl-loop for index below 24
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "Fluid %02d" index))
|
|
:border '(1 solid)))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 640))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(let* ((root (ebox--buffer-root-node buffer))
|
|
(dirty (ebox--viewport-dirty-set root))
|
|
(detail-calls 0)
|
|
(original
|
|
(symbol-function 'ebox--ensure-layout-snapshot-details))
|
|
patches)
|
|
(should (> (length dirty) 10))
|
|
(cl-letf (((symbol-function
|
|
'ebox--ensure-layout-snapshot-details)
|
|
(lambda (&rest args)
|
|
(cl-incf detail-calls)
|
|
(apply original args))))
|
|
(setq patches
|
|
(ebox-incremental--declarative-tentative-patch-set
|
|
buffer dirty)))
|
|
(should (= (length patches) 1))
|
|
(should (eq (plist-get (car patches) :op)
|
|
'owner-rerender))
|
|
(should (<= detail-calls 4))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-patch-set-coalesces-large-column-coverage ()
|
|
"Large local owner sets should coalesce when they cover most root lines."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (apply
|
|
#'ebox-test-column
|
|
(cl-loop for index below 12
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "Fluid section %02d" index))
|
|
:border '(1 solid)))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 640))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(let* ((root (ebox--buffer-root-node buffer))
|
|
(dirty (ebox--viewport-dirty-set root))
|
|
(patches
|
|
(ebox-incremental--declarative-tentative-patch-set
|
|
buffer dirty))
|
|
(source-ids
|
|
(ebox--dirty-provenance-node-ids
|
|
(plist-get (car patches) :dirty))))
|
|
(should (> (length dirty) 8))
|
|
(should (= (length patches) 1))
|
|
(should (equal (plist-get (car patches) :owner-id)
|
|
(plist-get root :node-id)))
|
|
(should (= (length source-ids) (length dirty)))
|
|
(dolist (entry dirty)
|
|
(should (member (plist-get entry :node-id) source-ids)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-root-owner-allows-line-count-change ()
|
|
"Root owner rerender should patch even when viewport wrapping changes height."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content
|
|
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu")
|
|
(layout (apply
|
|
#'ebox-test-column
|
|
(cl-loop repeat 12
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text content)
|
|
:width 'viewport
|
|
:wrap-mode 'word
|
|
:border '(1 solid)))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 120))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(let ((wide-height (with-current-buffer buffer
|
|
(count-lines (point-min) (point-max)))))
|
|
(ebox-rerender-buffer-with-context buffer 20)
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(narrow-height (with-current-buffer buffer
|
|
(count-lines (point-min) (point-max)))))
|
|
(should (> narrow-height wide-height))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should-not (plist-get report :root-rerender)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-root-owner-rerender-skips-partial-slot-probe ()
|
|
"Whole-line root owner rerenders should not run partial-slot probing."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content
|
|
"alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu")
|
|
(layout (apply
|
|
#'ebox-test-column
|
|
(cl-loop repeat 12
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text content)
|
|
:width 'viewport
|
|
:wrap-mode 'word
|
|
:border '(1 solid)))))
|
|
(slot-probe-count 0)
|
|
(original (symbol-function 'ebox-buffer--rendered-in-existing-slots))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 120))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(cl-letf (((symbol-function 'ebox-buffer--rendered-in-existing-slots)
|
|
(lambda (&rest args)
|
|
(cl-incf slot-probe-count)
|
|
(apply original args))))
|
|
(ebox-rerender-buffer-with-context buffer 20))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (equal (plist-get report :owner-ids)
|
|
(list (plist-get
|
|
(ebox-test--runtime-root buffer)
|
|
:node-id)))))
|
|
(should (= slot-probe-count 0)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-buffer-render-context-selects-target-buffer ()
|
|
"Buffer render contexts should evaluate render-sensitive work in target buffers."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-box (ebox-test-text "context target")))
|
|
(buffer nil)
|
|
(other nil)
|
|
seen-buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
node))
|
|
(setq other (generate-new-buffer " *ebox-other*"))
|
|
(with-current-buffer other
|
|
(ebox--with-buffer-render-context buffer
|
|
(setq seen-buffer (current-buffer))))
|
|
(should (eq seen-buffer buffer)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer))
|
|
(when (and other (buffer-live-p other))
|
|
(kill-buffer other)))))
|
|
|
|
(ert-deftest ebox-buffer-render-context-preserves-caller-state-binding ()
|
|
"Render context internals should not capture a caller's STATE variable."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-box (ebox-test-text "context state")))
|
|
(buffer nil)
|
|
(state 'caller-scroll-state)
|
|
seen-state)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*") node))
|
|
(ebox--with-buffer-render-context buffer
|
|
(setq seen-state state))
|
|
(should (eq seen-state 'caller-scroll-state)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-deferred-render-gc-uses-interactive-burst-threshold ()
|
|
"Interactive bursts restore caller policy without forcing a global collection."
|
|
(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 0))
|
|
(ebox--deferred-render-gc-restore)
|
|
(should (= gc-count 0)))
|
|
(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 0)))))
|
|
|
|
(ert-deftest ebox-interactive-render-burst-restores-policy-after-nonlocal-exit ()
|
|
"Error and quit release the render lease without collecting unrelated heaps."
|
|
(dolist (exit '(error quit))
|
|
(let ((noninteractive nil)
|
|
(gc-cons-threshold 1000000) (gc-cons-percentage 0.1)
|
|
(ebox-deferred-render-gc-cons-threshold 4000000)
|
|
(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)
|
|
(ebox--render-burst-records (make-hash-table :test 'eq))
|
|
callback observed-exit (collections 0))
|
|
(cl-letf (((symbol-function 'run-at-time)
|
|
(lambda (_delay _repeat function &rest args)
|
|
(setq callback (cons function args)) 'test-timer))
|
|
((symbol-function 'garbage-collect)
|
|
(lambda () (cl-incf collections))))
|
|
(condition-case condition
|
|
(ebox-call-with-render-burst
|
|
(lambda () (signal exit '("failed render"))))
|
|
((error quit) (setq observed-exit (car condition))))
|
|
(should (eq observed-exit exit))
|
|
(should-not ebox--render-burst-stack)
|
|
(should (= 0 (hash-table-count ebox--render-burst-records)))
|
|
(should (= 0 ebox--deferred-render-gc-depth))
|
|
(should callback)
|
|
(apply (car callback) (cdr callback))
|
|
(should (= gc-cons-threshold 1000000))
|
|
(should (= gc-cons-percentage 0.1))
|
|
(should-not ebox--deferred-render-gc-state)
|
|
(should-not ebox--deferred-render-gc-timer)
|
|
(apply (car callback) (cdr callback))
|
|
(should (= 0 collections))))))
|
|
|
|
(ert-deftest ebox-public-render-burst-nests-around-interactive-commit ()
|
|
"A framework burst should own GC before and throughout Ebox commit."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer (ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
(ebox-test-box (ebox-test-text "before")))))
|
|
(unwind-protect
|
|
(let ((noninteractive nil)
|
|
(gc-cons-threshold 1000)
|
|
(gc-cons-percentage 0.1)
|
|
(ebox-deferred-render-gc-cons-threshold 4000)
|
|
(ebox--deferred-render-gc-state nil)
|
|
(ebox--deferred-render-gc-timer nil)
|
|
(ebox--deferred-render-gc-depth 0)
|
|
(ebox--render-burst-stack nil)
|
|
scheduled
|
|
observed-depth)
|
|
(cl-letf (((symbol-function 'run-at-time)
|
|
(lambda (delay _repeat function &rest args)
|
|
(setq scheduled (list delay function args))
|
|
'ebox-test-timer))
|
|
((symbol-function 'timerp)
|
|
(lambda (value) (eq value 'ebox-test-timer)))
|
|
((symbol-function 'cancel-timer) #'ignore))
|
|
(let ((token (ebox-render-burst-begin)))
|
|
(ebox-commit
|
|
buffer (ebox-test-box (ebox-test-text "after"))
|
|
(lambda (_report)
|
|
(setq observed-depth ebox--deferred-render-gc-depth)))
|
|
(should (= observed-depth 2))
|
|
(should (= ebox--deferred-render-gc-depth 1))
|
|
(should-not scheduled)
|
|
(ebox-render-burst-end token))
|
|
(should (= ebox--deferred-render-gc-depth 0))
|
|
(should (= (car scheduled) 0.2))
|
|
(apply (nth 1 scheduled) (nth 2 scheduled))
|
|
(should (= gc-cons-threshold 1000))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-deferred-render-gc-keeps-overlapping-burst-owned ()
|
|
"A nested idle slice must not restore GC during an active animation."
|
|
(let ((gc-cons-threshold 1000)
|
|
(gc-cons-percentage 0.1)
|
|
(ebox-deferred-render-gc-cons-threshold 4000)
|
|
(ebox--deferred-render-gc-state nil)
|
|
(ebox--deferred-render-gc-timer nil)
|
|
(ebox--deferred-render-gc-depth 0)
|
|
scheduled)
|
|
(cl-letf (((symbol-function 'run-at-time)
|
|
(lambda (&rest _)
|
|
(setq scheduled 'restore-timer)))
|
|
((symbol-function 'timerp)
|
|
(lambda (value) (eq value 'restore-timer))))
|
|
(ebox--deferred-render-gc-enter)
|
|
(ebox--deferred-render-gc-enter)
|
|
(should (= ebox--deferred-render-gc-depth 2))
|
|
(ebox--deferred-render-gc-schedule-restore)
|
|
(should (= ebox--deferred-render-gc-depth 1))
|
|
(should-not scheduled)
|
|
(should (= gc-cons-threshold 4000))
|
|
(ebox--deferred-render-gc-schedule-restore)
|
|
(should (eq scheduled 'restore-timer))
|
|
(should (= ebox--deferred-render-gc-depth 0))
|
|
(should (= gc-cons-threshold 4000)))))
|
|
|
|
(ert-deftest ebox-deferred-render-gc-default-is-memory-bounded ()
|
|
"The interactive burst default should scale within finite memory bounds."
|
|
(should (eq ebox-deferred-render-gc-cons-threshold 'auto))
|
|
(let ((gc-cons-threshold (* 512 1024 1024))
|
|
(gc-cons-percentage 0.1)
|
|
(ebox--physical-memory-bytes (* 16 1024 1024 1024))
|
|
(ebox--deferred-render-gc-state nil)
|
|
(ebox--deferred-render-gc-timer nil))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox--deferred-render-gc-enter)
|
|
(should (= gc-cons-threshold (* 512 1024 1024))))
|
|
(ebox--deferred-render-gc-restore))))
|
|
|
|
(ert-deftest ebox-interactive-commit-owns-deferred-render-gc ()
|
|
"Keep GC outside an interactive declarative commit and defer restoration."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil))
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
(ebox-test-box (ebox-test-text "before"))))
|
|
(let ((noninteractive nil)
|
|
(gc-cons-threshold 1000)
|
|
(gc-cons-percentage 0.1)
|
|
(ebox-deferred-render-gc-cons-threshold 4000)
|
|
(ebox-render-gc-cons-percentage 0.8)
|
|
(ebox--deferred-render-gc-state nil)
|
|
(ebox--deferred-render-gc-timer nil)
|
|
(ebox--deferred-render-gc-depth 0)
|
|
observed-threshold)
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-commit
|
|
buffer (ebox-test-box (ebox-test-text "after"))
|
|
(lambda (_report)
|
|
(setq observed-threshold gc-cons-threshold)))
|
|
(should (= observed-threshold 4000))
|
|
(should (= gc-cons-threshold 4000))
|
|
(should (= gc-cons-percentage 0.8))
|
|
(should ebox--deferred-render-gc-state)
|
|
(should (timerp ebox--deferred-render-gc-timer)))
|
|
(when (timerp ebox--deferred-render-gc-timer)
|
|
(cancel-timer ebox--deferred-render-gc-timer))
|
|
(setq ebox--deferred-render-gc-state nil
|
|
ebox--deferred-render-gc-timer nil
|
|
ebox--deferred-render-gc-depth 0))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-interactive-viewport-rerender-owns-deferred-render-gc ()
|
|
"Keep GC outside an interactive viewport transaction until it settles."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
|
(buffer nil))
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
(ebox-test-box :width '(viewport) (ebox-test-text "resize"))))
|
|
(let ((noninteractive nil)
|
|
(gc-cons-threshold 1000)
|
|
(gc-cons-percentage 0.1)
|
|
(ebox-deferred-render-gc-cons-threshold 4000)
|
|
(ebox-render-gc-cons-percentage 0.8)
|
|
(ebox--deferred-render-gc-state nil)
|
|
(ebox--deferred-render-gc-timer nil)
|
|
(ebox--deferred-render-gc-depth 0)
|
|
(original-update
|
|
(symbol-function 'ebox-surface-update-buffer-viewport))
|
|
observed-threshold
|
|
observed-depth
|
|
scheduled
|
|
(gc-count 0))
|
|
(cl-letf (((symbol-function 'ebox-surface-update-buffer-viewport)
|
|
(lambda (&rest arguments)
|
|
(setq observed-threshold gc-cons-threshold
|
|
observed-depth ebox--deferred-render-gc-depth)
|
|
(apply original-update arguments)))
|
|
((symbol-function 'run-at-time)
|
|
(lambda (delay _repeat function &rest arguments)
|
|
(setq scheduled (list delay function arguments))
|
|
'ebox-test-viewport-gc-timer))
|
|
((symbol-function 'timerp)
|
|
(lambda (value)
|
|
(eq value 'ebox-test-viewport-gc-timer)))
|
|
((symbol-function 'cancel-timer) #'ignore)
|
|
((symbol-function 'garbage-collect)
|
|
(lambda () (cl-incf gc-count))))
|
|
(ebox-rerender-buffer-with-context buffer 320)
|
|
(should (= observed-threshold 4000))
|
|
(should (= observed-depth 1))
|
|
(should (= (plist-get (ebox--buffer-render-state buffer)
|
|
:viewport-width)
|
|
320))
|
|
(should (= ebox--deferred-render-gc-depth 0))
|
|
(should ebox--deferred-render-gc-state)
|
|
(should (eq ebox--deferred-render-gc-timer
|
|
'ebox-test-viewport-gc-timer))
|
|
(should (= (car scheduled) 0.2))
|
|
(apply (nth 1 scheduled) (nth 2 scheduled))
|
|
(should (= gc-cons-threshold 1000))
|
|
(should (= gc-cons-percentage 0.1))
|
|
(should (= gc-count 0)))))
|
|
(ebox-test--reset-runtime-state)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-batch-viewport-rerender-uses-render-gc-boundary ()
|
|
"Raise GC only during a batch viewport transaction and restore on return."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm nil)
|
|
(buffer nil))
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
(ebox-test-box :width '(viewport) (ebox-test-text "resize"))))
|
|
(let ((noninteractive t)
|
|
(gc-cons-threshold 1000)
|
|
(gc-cons-percentage 0.1)
|
|
(ebox-render-gc-cons-threshold 4000)
|
|
(ebox-render-gc-cons-percentage 0.8)
|
|
(original-update
|
|
(symbol-function 'ebox-surface-update-buffer-viewport))
|
|
observed-threshold
|
|
observed-percentage)
|
|
(cl-letf (((symbol-function 'ebox-surface-update-buffer-viewport)
|
|
(lambda (&rest arguments)
|
|
(setq observed-threshold gc-cons-threshold
|
|
observed-percentage gc-cons-percentage)
|
|
(apply original-update arguments))))
|
|
(ebox-rerender-buffer-with-context buffer 320))
|
|
(should (= observed-threshold 4000))
|
|
(should (= observed-percentage 0.8))
|
|
(should (= gc-cons-threshold 1000))
|
|
(should (= gc-cons-percentage 0.1))
|
|
(should-not ebox--deferred-render-gc-state)
|
|
(should-not ebox--deferred-render-gc-timer)))
|
|
(ebox-test--reset-runtime-state)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-deferred-render-gc-can-raise-an-active-burst-budget ()
|
|
"Measured layout pressure should only raise an active burst budget."
|
|
(let ((gc-cons-threshold 1000)
|
|
(ebox--deferred-render-gc-state nil))
|
|
(ebox--deferred-render-gc-raise-threshold 4000)
|
|
(should (= gc-cons-threshold 1000))
|
|
(setq ebox--deferred-render-gc-state
|
|
'(:gc-cons-threshold 1000 :gc-cons-percentage 0.1))
|
|
(ebox--deferred-render-gc-raise-threshold 4000)
|
|
(should (= gc-cons-threshold 4000))
|
|
(ebox--deferred-render-gc-raise-threshold 2000)
|
|
(should (= gc-cons-threshold 4000))
|
|
(ebox--deferred-render-gc-raise-threshold nil)
|
|
(should (= gc-cons-threshold 4000))))
|
|
|
|
(ert-deftest ebox-deferred-render-gc-can-bound-an-active-burst-budget ()
|
|
"A specialized burst may lower its raised budget but not the saved setting."
|
|
(let ((gc-cons-threshold 5000)
|
|
(ebox--deferred-render-gc-state nil))
|
|
(ebox--deferred-render-gc-set-threshold 3000)
|
|
(should (= gc-cons-threshold 5000))
|
|
(setq ebox--deferred-render-gc-state
|
|
'(:gc-cons-threshold 1000 :gc-cons-percentage 0.1))
|
|
(ebox--deferred-render-gc-set-threshold 3000)
|
|
(should (= gc-cons-threshold 3000))
|
|
(ebox--deferred-render-gc-set-threshold 500)
|
|
(should (= gc-cons-threshold 1000))
|
|
(ebox--deferred-render-gc-set-threshold nil)
|
|
(should (= gc-cons-threshold 1000))))
|
|
|
|
(ert-deftest ebox-viewport-patch-planning-skips-line-signatures ()
|
|
"Owner rerender planning should not compute expensive line signatures."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply
|
|
#'ebox-test-column
|
|
(cl-loop for section below 8
|
|
collect
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-flow '(row wrap)
|
|
:row-gap 1
|
|
:column-gap '(8)
|
|
(cl-loop for index below 4
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "S%d-%d" section index))
|
|
:width 80
|
|
:height 1))))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 360))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(let* ((root (ebox--buffer-root-node buffer))
|
|
(dirty (ebox--viewport-dirty-set root))
|
|
(line-signature-count 0)
|
|
(original (symbol-function 'ebox--spans-line-signature)))
|
|
(should dirty)
|
|
(cl-letf (((symbol-function 'ebox--spans-line-signature)
|
|
(lambda (&rest args)
|
|
(cl-incf line-signature-count)
|
|
(apply original args))))
|
|
(ebox-incremental--declarative-tentative-patch-set
|
|
buffer dirty))
|
|
(should (= line-signature-count 0))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-patch-planning-coalesces-before-line-index ()
|
|
"Broad viewport dirtiness should coalesce before building a line index."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply
|
|
#'ebox-test-column
|
|
(cl-loop for section below 8
|
|
collect
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-flow '(row wrap)
|
|
:row-gap 1
|
|
:column-gap '(8)
|
|
(cl-loop for index below 4
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "S%d-%d" section index))
|
|
:width 80
|
|
:height 1))))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 360))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(let* ((root (ebox--buffer-root-node buffer))
|
|
(dirty (ebox--viewport-dirty-set root))
|
|
(index-build-count 0)
|
|
(original (symbol-function 'ebox--build-region-line-index))
|
|
patches)
|
|
(should dirty)
|
|
(cl-letf (((symbol-function 'ebox--build-region-line-index)
|
|
(lambda (&rest args)
|
|
(cl-incf index-build-count)
|
|
(apply original args))))
|
|
(setq patches
|
|
(ebox-incremental--declarative-tentative-patch-set
|
|
buffer dirty)))
|
|
(should (= index-build-count 0))
|
|
(should (= (length patches) 1))
|
|
(should (eq (plist-get (car patches) :op) 'owner-rerender))
|
|
(should (equal (plist-get (car patches) :owner-id)
|
|
(plist-get root :node-id)))
|
|
(should (= (length
|
|
(ebox--dirty-provenance-node-ids
|
|
(plist-get (car patches) :dirty)))
|
|
(length dirty)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-block-padding-planning-promotes-without-full-line-index ()
|
|
"Block padding should publish through its parent without rebuilding line indexes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-column :id "root" :width '(480)
|
|
(ebox-test-box :id "target" (ebox-test-text "mixed padding")
|
|
:width '(220) :height 3 :box-sizing
|
|
'border-box :padding '(0 (8)) :border
|
|
"#999999")
|
|
(ebox-test-box (ebox-test-text "peer") :width '(220) :height
|
|
3 :border "#999999")))
|
|
(index-build-count 0))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#target"))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer)))
|
|
(original (symbol-function 'ebox--build-region-line-index)))
|
|
(cl-letf (((symbol-function 'ebox--build-region-line-index)
|
|
(lambda (&rest args)
|
|
(cl-incf index-build-count)
|
|
(apply original args))))
|
|
(ebox-test--region-update region-id :padding (list 1 '(18) 1 '(18))))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (equal (plist-get report :owner-ids) (list root-id)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should (= index-build-count 0))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-region-role-ranges-come-from-tp-mounts ()
|
|
"Published public bounds should agree with TP-owned mount snapshots."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-column :id "root" :width '(480)
|
|
(ebox-test-box :id "target" :width '(220) :height 1
|
|
:border "#999999"
|
|
(ebox-test-text "target"))
|
|
(ebox-test-box :id "peer" :width '(220) :height 1
|
|
:border "#999999"
|
|
(ebox-test-text "peer")))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((target-id (ebox-test--selector-region-id (current-buffer)
|
|
"#target"))
|
|
(peer-id (ebox-test--selector-region-id (current-buffer)
|
|
"#peer"))
|
|
(target-mounts
|
|
(ebox-surface-region-mounts (current-buffer) target-id))
|
|
(peer-mounts
|
|
(ebox-surface-region-mounts (current-buffer) peer-id))
|
|
(target-bounds
|
|
(ebox-surface-region-bounds (current-buffer) target-id))
|
|
(peer-bounds
|
|
(ebox-surface-region-bounds (current-buffer) peer-id)))
|
|
(should target-mounts)
|
|
(should peer-mounts)
|
|
(should (equal target-bounds
|
|
(cons (apply #'min (mapcar
|
|
(lambda (mount)
|
|
(plist-get mount :start))
|
|
target-mounts))
|
|
(apply #'max (mapcar
|
|
(lambda (mount)
|
|
(plist-get mount :end))
|
|
target-mounts)))))
|
|
(should (or (<= (cdr target-bounds) (car peer-bounds))
|
|
(<= (cdr peer-bounds) (car target-bounds))))
|
|
(ebox-test--region-update target-id :bgcolor "#112233")
|
|
(should (equal peer-bounds
|
|
(ebox-surface-region-bounds
|
|
(current-buffer) peer-id)))))))
|
|
|
|
(ert-deftest ebox-viewport-root-owner-spans-skip-region-scan ()
|
|
"Root owner spans should use full buffer lines, not region-property scans."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply
|
|
#'ebox-test-row
|
|
(cl-loop for section below 8
|
|
collect
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-flow '(row wrap)
|
|
:row-gap 1
|
|
:column-gap '(8)
|
|
(cl-loop for index below 4
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "S%d-%d" section index))
|
|
:width 80
|
|
:height 1))))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 360))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(let* ((root (ebox--buffer-root-node buffer))
|
|
(dirty (ebox--viewport-dirty-set root))
|
|
(region-scan-count 0)
|
|
(original (symbol-function 'ebox--region-set-line-spans)))
|
|
(should dirty)
|
|
(cl-letf (((symbol-function 'ebox--region-set-line-spans)
|
|
(lambda (&rest args)
|
|
(cl-incf region-scan-count)
|
|
(apply original args))))
|
|
(ebox-incremental--declarative-tentative-patch-set
|
|
buffer dirty))
|
|
(should (= region-scan-count 0))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-reuses-static-flex-item-renders ()
|
|
"Static flex item renders should be reused across viewport rerenders."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-wrap 'wrap
|
|
(cl-loop for index below 12
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "Item %02d" index))
|
|
:width 80
|
|
:height 1))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 420))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(ebox-rerender-buffer-with-context buffer 360)
|
|
(let ((box-render-count 0)
|
|
(original (symbol-function 'ebox--render-box)))
|
|
(cl-letf (((symbol-function 'ebox--render-box)
|
|
(lambda (&rest args)
|
|
(cl-incf box-render-count)
|
|
(apply original args))))
|
|
(ebox-rerender-buffer-with-context buffer 500))
|
|
(should (<= box-render-count 3))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-reuses-sized-flex-item-renders ()
|
|
"Flex item renders with stable computed sizes should be reused."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-wrap 'wrap
|
|
:height 4
|
|
:align-items 'stretch
|
|
(cl-loop for index below 10
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "Stretch %02d" index))
|
|
:width 80))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 420))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(ebox-rerender-buffer-with-context buffer 360)
|
|
(let ((box-render-count 0)
|
|
(original (symbol-function 'ebox--render-box)))
|
|
(cl-letf (((symbol-function 'ebox--render-box)
|
|
(lambda (&rest args)
|
|
(cl-incf box-render-count)
|
|
(apply original args))))
|
|
(ebox-rerender-buffer-with-context buffer 500))
|
|
(should (<= box-render-count 6))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-reuses-flex-item-measurements ()
|
|
"Stable flex item intrinsic measurements should be reused across resizes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-wrap 'wrap
|
|
(cl-loop for index below 12
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "Measured %02d" index))
|
|
:width 80
|
|
:height 1))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 420))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(ebox-rerender-buffer-with-context buffer 360)
|
|
(let ((main-count 0)
|
|
(min-count 0)
|
|
(original-main (symbol-function 'ebox--flex-string-main))
|
|
(original-min (symbol-function 'ebox--content-min-pixel)))
|
|
(cl-letf (((symbol-function 'ebox--flex-string-main)
|
|
(lambda (&rest args)
|
|
(cl-incf main-count)
|
|
(apply original-main args)))
|
|
((symbol-function 'ebox--content-min-pixel)
|
|
(lambda (&rest args)
|
|
(cl-incf min-count)
|
|
(apply original-min args))))
|
|
(ebox-rerender-buffer-with-context buffer 500))
|
|
(should (<= main-count 3))
|
|
(should (<= min-count 3))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-reuses-static-composite-subtrees ()
|
|
"Viewport-dependent flex sections should reuse static composite item sources."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((inner-flexes
|
|
(cl-loop for index below 6
|
|
collect
|
|
(ebox-test-flex
|
|
:width 180 :height 3
|
|
:flex-flow '(row wrap)
|
|
:column-gap '(8)
|
|
(ebox-test-box
|
|
(ebox-test-text (format "A%d" index))
|
|
:width 48 :height 1)
|
|
(ebox-test-box
|
|
(ebox-test-text (format "B%d" index))
|
|
:width 48 :height 1))))
|
|
(layout
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-flow '(row wrap)
|
|
:row-gap 1
|
|
:column-gap '(8)
|
|
(mapcar (lambda (inner)
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Static card")
|
|
:width 180
|
|
:height 1)
|
|
inner))
|
|
inner-flexes)))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 320))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(ebox-rerender-buffer-with-context buffer 360)
|
|
(let ((static-flex-renders 0)
|
|
(original (symbol-function 'ebox--render-flex)))
|
|
(cl-letf (((symbol-function 'ebox--render-flex)
|
|
(lambda (node)
|
|
(when (memq node inner-flexes)
|
|
(cl-incf static-flex-renders))
|
|
(funcall original node))))
|
|
(ebox-rerender-buffer-with-context buffer 420))
|
|
(should (= static-flex-renders 0))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-flex-window-reuses-cached-composite-measurements ()
|
|
"Flex prefix layout should reuse warm measurements for fixed composites."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-flow '(row wrap)
|
|
:column-gap '(8)
|
|
(cl-loop for index below 6
|
|
collect
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text (format "Card %d" index))
|
|
:width 96 :height 1)
|
|
(ebox-test-box (ebox-test-text "Body") :width 96 :height 2)))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 320))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*") layout)))
|
|
(let* ((root (ebox--buffer-root-node buffer))
|
|
(child (car (plist-get root :children))))
|
|
(ebox--with-buffer-render-context buffer
|
|
(should (ebox--flex-window-cheap-item child 'row 0)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-render-cacheability-rejects-viewport-dependent-wrapper-child ()
|
|
"Wrapper boxes with viewport-dependent child layout should not reuse static render cache."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-root
|
|
(ebox-test-column :padding 1
|
|
(ebox-test-box (ebox-test-text "Auto child follows viewport")
|
|
:wrap-mode 'word)))))
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox--viewport-dependent-subtree-cache
|
|
(make-hash-table :test 'eq)))
|
|
(should-not (ebox--render-cacheable-node-p node)))))
|
|
|
|
(ert-deftest ebox-render-cacheability-allows-definite-width-wrapper-child ()
|
|
"Definite-width boxes bound descendant viewport-dependent layout."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-root
|
|
(ebox-test-column :width '(210) :padding 1
|
|
(ebox-test-box (ebox-test-text "Child wraps inside the card")
|
|
:wrap-mode 'word)))))
|
|
(let ((ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox--viewport-dependent-subtree-cache
|
|
(make-hash-table :test 'eq)))
|
|
(should (ebox--viewport-dependent-subtree-p node))
|
|
(should (ebox--render-cacheable-node-p node)))))
|
|
|
|
(ert-deftest ebox-region-width-update-invalidates-viewport-dependencies ()
|
|
"Width updates can change which boxes participate in viewport resize."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Resizable") :width 80))
|
|
(box-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-rerender-buffer-with-context (current-buffer) 260)
|
|
(ebox-test--region-update box-id :width '(viewport))
|
|
(ebox-rerender-buffer-with-context (current-buffer) 200)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :constraint-source) 'viewport))
|
|
(should (= (plist-get report :dirty-count) 1))))))
|
|
|
|
(ert-deftest ebox-viewport-dependencies-include-wrapper-child-layout ()
|
|
"Viewport dependency discovery should see lazy child layout under wrapper boxes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-root
|
|
(ebox-test-column :padding 1
|
|
(ebox-test-box (ebox-test-text "Auto child follows the preview width")
|
|
:wrap-mode 'word))))
|
|
(child (car (ebox--node-children node)))
|
|
(ids (let ((ebox-viewport-width 360))
|
|
(ebox--runtime-node-ids node)
|
|
(ebox--viewport-dependent-node-ids node))))
|
|
(should ids)
|
|
(should (member (plist-get node :node-id) ids))
|
|
(should (member (plist-get child :node-id) ids))))
|
|
|
|
(ert-deftest ebox-viewport-dependencies-stop-at-definite-width-wrapper ()
|
|
"Definite wrappers should contain descendant viewport dependencies."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-root
|
|
(ebox-test-column :width '(210) :padding 1
|
|
(ebox-test-box (ebox-test-text "Auto child wraps inside the fixed card")
|
|
:wrap-mode 'word))))
|
|
(child (car (ebox--node-children node)))
|
|
(ids (let ((ebox-viewport-width 360))
|
|
(ebox--runtime-node-ids node)
|
|
(ebox--viewport-dependent-node-ids node))))
|
|
(should-not (member (plist-get node :node-id) ids))
|
|
(should-not (member (plist-get child :node-id) ids))))
|
|
|
|
(ert-deftest ebox-viewport-height-dependency-crosses-definite-width-wrapper ()
|
|
"Definite width should not contain a descendant viewport-height dependency."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((viewport-width 120)
|
|
(node (let ((ebox-viewport-width viewport-width)
|
|
(ebox-viewport-height 2))
|
|
(ebox-test-column :width '(120)
|
|
(ebox-test-box :id 'child (ebox-test-text "One\nTwo\nThree\nFour") :height
|
|
'(viewport-height) :overflow 'scroll))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width viewport-width)
|
|
(ebox-viewport-height 2))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
node)))
|
|
(with-current-buffer buffer
|
|
(should (= (length (ebox-string-lines
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
2)))
|
|
(let ((report
|
|
(ebox-rerender-buffer-with-context
|
|
buffer viewport-width 4)))
|
|
(should-not (eq (plist-get report :strategy) 'no-op)))
|
|
(with-current-buffer buffer
|
|
(should (= (length (ebox-string-lines
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
4))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-dependency-axes-classify-both-dimensions ()
|
|
"A node may depend on width, height, or both viewport axes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((width-only-input
|
|
(ebox-test-box (ebox-test-text "Width") :width '(viewport)))
|
|
(height-only-input
|
|
(ebox-test-box (ebox-test-text "Height")
|
|
:width '(120)
|
|
:height '(viewport-height)))
|
|
(both-input
|
|
(ebox-test-box (ebox-test-text "Both")
|
|
:width '(viewport)
|
|
:height '(viewport-height)))
|
|
(layout-input
|
|
(ebox-test-column width-only-input height-only-input both-input))
|
|
(width-only (ebox-test-root width-only-input))
|
|
(height-only (ebox-test-root height-only-input))
|
|
(both (ebox-test-root both-input))
|
|
(layout (ebox-test-root layout-input)))
|
|
(let ((ebox-viewport-width 360)
|
|
(ebox-viewport-height 12)
|
|
(ebox--viewport-dependent-node-ids-cache
|
|
(make-hash-table :test 'eq))
|
|
(ebox--viewport-height-dependent-subtree-cache
|
|
(make-hash-table :test 'eq)))
|
|
(ebox--runtime-node-ids layout)
|
|
(pcase-let ((`(,width-ids . ,height-ids)
|
|
(ebox--viewport-dependent-node-id-axes layout)))
|
|
(should (member (plist-get width-only :node-id) width-ids))
|
|
(should-not (member (plist-get width-only :node-id) height-ids))
|
|
(should-not (member (plist-get height-only :node-id) width-ids))
|
|
(should (member (plist-get height-only :node-id) height-ids))
|
|
(should (member (plist-get both :node-id) width-ids))
|
|
(should (member (plist-get both :node-id) height-ids))))))
|
|
|
|
(ert-deftest ebox-viewport-width-rerender-skips-height-only-root ()
|
|
"Width-only viewport rerender should not dirty height-only roots."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (let ((ebox-viewport-height 4))
|
|
(ebox-test-column :width '(120) :height '(viewport-height)
|
|
(ebox-test-box (ebox-test-text "Static") :width '(120)))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 4))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
node)))
|
|
(let ((report (ebox-rerender-buffer-with-context buffer 300)))
|
|
(should (eq (plist-get report :strategy) 'no-op))
|
|
(should (= (plist-get report :dirty-count) 0))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-dependencies-keep-only-vertical-axis-under-fixed-width ()
|
|
"Fixed width should retain vertical dependencies without exposing width-only ones."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((width-only
|
|
(ebox-test-box (ebox-test-text "Width") :width '(viewport)))
|
|
(ordinary-height-nodes
|
|
(cl-loop for (property value)
|
|
in '((:height (viewport-height))
|
|
(:max-height (viewport-height)))
|
|
collect
|
|
(apply #'ebox-test-box
|
|
(ebox-test-text "Ordinary")
|
|
(list property value))))
|
|
(flex-height-nodes
|
|
(cl-loop for (property value)
|
|
in '((:height (viewport-height))
|
|
(:max-height (viewport-height)))
|
|
collect
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list property value)
|
|
(list (ebox-test-box (ebox-test-text "Flex")))))))
|
|
(layout
|
|
(apply #'ebox-test-column
|
|
(append (list width-only)
|
|
ordinary-height-nodes
|
|
flex-height-nodes)))
|
|
(node (ebox-test-root
|
|
(ebox-test-box :width '(210)
|
|
:wrap-mode 'none
|
|
layout)))
|
|
(ids (let ((ebox-viewport-width 360)
|
|
(ebox-viewport-height 12))
|
|
(ebox--runtime-node-ids node)
|
|
(ebox--viewport-dependent-node-ids node))))
|
|
(should-not (member (plist-get (ebox-test-root width-only) :node-id)
|
|
ids))
|
|
(dolist (vertical-node (append ordinary-height-nodes flex-height-nodes))
|
|
(should (member (plist-get (ebox-test-root vertical-node) :node-id)
|
|
ids)))))
|
|
|
|
(ert-deftest ebox-viewport-dependencies-ignore-flex-item-wrapper-auto-width ()
|
|
"Flex item wrappers should not be dirty only because their own width is auto."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((child-input (ebox-test-box (ebox-test-text "A") :width 80))
|
|
(item-input (ebox-test-flex-item child-input))
|
|
(node (ebox-test-root (ebox-test-flex :width 240 item-input)))
|
|
(child (ebox-test-root child-input))
|
|
(item (ebox-test-root item-input))
|
|
(ids (let ((ebox-viewport-width 360))
|
|
(ebox--runtime-node-ids node)
|
|
(ebox--viewport-dependent-node-ids node))))
|
|
(should-not (member (plist-get item :node-id) ids))
|
|
(should-not (member (plist-get child :node-id) ids))))
|
|
|
|
(ert-deftest ebox-buffer-viewport-dependency-cache-recomputes-after-style-change ()
|
|
"Changing responsive style should publish its exact viewport dependency."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Resizable") :width 80))
|
|
(box-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-rerender-buffer-with-context (current-buffer) 320)
|
|
(ebox-test--region-update box-id :width '(viewport))
|
|
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
|
(axes (plist-get state :viewport-dependent-node-id-axes))
|
|
(owner-id
|
|
(ebox--buffer-region-render-owner-node-id
|
|
(current-buffer) box-id)))
|
|
(should (plist-get state :viewport-dependent-node-ids-ready))
|
|
(should (member owner-id (car axes))))
|
|
(ebox-rerender-buffer-with-context (current-buffer) 240)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :constraint-source) 'viewport))
|
|
(should (= (plist-get report :dirty-count) 1))))))
|
|
|
|
(ert-deftest ebox-initial-render-primes-static-composite-subtree-cache ()
|
|
"The first viewport resize should reuse cache populated by initial render."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((inner-flexes
|
|
(cl-loop for index below 6
|
|
collect
|
|
(ebox-test-flex
|
|
:width 180 :height 3
|
|
:flex-flow '(row wrap)
|
|
:column-gap '(8)
|
|
(ebox-test-box
|
|
(ebox-test-text (format "A%d" index))
|
|
:width 48 :height 1)
|
|
(ebox-test-box
|
|
(ebox-test-text (format "B%d" index))
|
|
:width 48 :height 1))))
|
|
(layout
|
|
(apply
|
|
#'ebox-test-flex
|
|
:flex-flow '(row wrap)
|
|
:row-gap 1
|
|
:column-gap '(8)
|
|
(mapcar (lambda (inner)
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Static card")
|
|
:width 180
|
|
:height 1)
|
|
inner))
|
|
inner-flexes)))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-width 320))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(let ((static-flex-renders 0)
|
|
(original (symbol-function 'ebox--render-flex)))
|
|
(cl-letf (((symbol-function 'ebox--render-flex)
|
|
(lambda (node)
|
|
(when (memq node inner-flexes)
|
|
(cl-incf static-flex-renders))
|
|
(funcall original node))))
|
|
(ebox-rerender-buffer-with-context buffer 420))
|
|
(should (= static-flex-renders 0))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-resize-invalidates-snapshot-details-lazily ()
|
|
"Viewport resize should not recapture every lightweight snapshot eagerly."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 24 collect
|
|
(ebox-test-box :id
|
|
(format "resize-snapshot-%02d"
|
|
idx)
|
|
(ebox-test-text (format "row %02d" idx)) :width
|
|
'(viewport) :height 1)))))
|
|
(layout-node (ebox-test-root layout))
|
|
(node-count (length (ebox--runtime-node-ids layout-node)))
|
|
buffer
|
|
(snapshot-count 0)
|
|
(original (symbol-function 'ebox--node-layout-snapshot)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(cl-letf (((symbol-function 'ebox--node-layout-snapshot)
|
|
(lambda (&rest args)
|
|
(cl-incf snapshot-count)
|
|
(apply original args))))
|
|
(ebox-rerender-buffer-with-context buffer 320))
|
|
(should (< snapshot-count node-count)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-resize-preserves-tp-mount-index ()
|
|
"Viewport resize should republish region ranges through TP mounts."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 24 collect
|
|
(ebox-test-box :id
|
|
(format "resize-mount-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :width
|
|
'(viewport) :height 1)))))
|
|
buffer
|
|
target-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq target-id
|
|
(ebox-test--selector-region-id
|
|
buffer "#resize-mount-12"))
|
|
(ebox-rerender-buffer-with-context buffer 320)
|
|
(with-current-buffer buffer
|
|
(should (ebox-surface-region-mounts buffer target-id))
|
|
(ebox-test--region-update target-id :content "updated after resize")
|
|
(should (ebox-surface-region-mounts buffer target-id))
|
|
(should (string-match-p "updated after resize"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-render-cache-node-signature-is-render-pass-memoized ()
|
|
"Render cache signatures should not recurse twice for the same node."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-flex
|
|
:width 240
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "A") :width 80)
|
|
(ebox-test-box (ebox-test-text "B") :width 80))))
|
|
(layout-node (ebox-test-root layout))
|
|
(node-count (length (ebox--runtime-node-ids layout-node)))
|
|
(signature-count 0)
|
|
(original (symbol-function 'ebox--render-cache-plist-signature))
|
|
(ebox--render-cache-signature-cache
|
|
(make-hash-table :test 'eq)))
|
|
(cl-letf (((symbol-function 'ebox--render-cache-plist-signature)
|
|
(lambda (&rest args)
|
|
(cl-incf signature-count)
|
|
(apply original args))))
|
|
(ebox--render-cache-node-signature layout-node)
|
|
(ebox--render-cache-node-signature layout-node))
|
|
(should (= signature-count node-count))))
|
|
|
|
(ert-deftest ebox-render-cache-value-signature-preserves-dotted-pairs ()
|
|
"Native values such as sparse keymaps may contain dotted pairs."
|
|
(let ((value '(keymap (mouse-1 . ebox-test-activate)
|
|
(13 . ebox-test-activate))))
|
|
(should
|
|
(equal (ebox--render-cache-value-signature value)
|
|
value))))
|
|
|
|
(ert-deftest ebox-render-cache-signature-includes-inline-auto-width-context ()
|
|
"Row intrinsic sizing must not reuse a cross-axis stretch render."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((node (ebox-test-root
|
|
(ebox-test-box (ebox-test-text "context-sensitive")))))
|
|
(let ((normal (let ((ebox--inline-auto-width-intrinsic-p nil))
|
|
(ebox--render-cache-node-signature node)))
|
|
(intrinsic (let ((ebox--inline-auto-width-intrinsic-p t))
|
|
(ebox--render-cache-node-signature node))))
|
|
(should-not (equal normal intrinsic)))))
|
|
|
|
(ert-deftest ebox-render-cache-signature-includes-intrinsic-layout-context ()
|
|
"Final layout must not reuse an intrinsic measurement render."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((node (ebox-test-root
|
|
(ebox-test-box (ebox-test-text "context-sensitive") :width '(80)))))
|
|
(let ((final (let ((ebox--intrinsic-layout-measurement nil))
|
|
(ebox--render-cache-node-signature node)))
|
|
(intrinsic (let ((ebox--intrinsic-layout-measurement t))
|
|
(ebox--render-cache-node-signature node))))
|
|
(should-not (equal final intrinsic)))))
|
|
|
|
(ert-deftest ebox-render-cache-signature-includes-rendered-region-identity ()
|
|
"Cached render strings should never cross distinct region identities."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((left (ebox-test-root
|
|
(ebox-test-box (ebox-test-text "Stable") :width 80)))
|
|
(right (copy-tree left)))
|
|
(plist-put left :node-id 1)
|
|
(plist-put right :node-id 1)
|
|
(plist-put left :region-id 11)
|
|
(plist-put right :region-id 12)
|
|
(should-not
|
|
(equal (ebox--render-cache-node-signature left)
|
|
(ebox--render-cache-node-signature right)))))
|
|
|
|
(ert-deftest ebox-render-cache-probe-reuses-first-render-output ()
|
|
"The first cache probe should retain the identity used by its output."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-root
|
|
(ebox-test-box (ebox-test-text "Stable") :width 80)))
|
|
(ebox--surface-materialization-active t)
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
|
|
(ebox--box-content-render-cache (make-hash-table :test 'eq))
|
|
(ebox--region-box-table (make-hash-table :test 'equal))
|
|
(ebox--viewport-dependent-node-ids-cache
|
|
(make-hash-table :test 'eq))
|
|
(ebox--viewport-dependent-subtree-cache
|
|
(make-hash-table :test 'eq))
|
|
(first (ebox--render-with-cache node))
|
|
(probe (ebox--render-cache-probe node)))
|
|
(should (plist-member probe :rendered))
|
|
(should (equal-including-properties first
|
|
(plist-get probe :rendered)))))
|
|
|
|
(ert-deftest ebox-render-transaction-snapshots-display-signature ()
|
|
"One render transaction should sample its display signature once."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80)))
|
|
(calls 0))
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda ()
|
|
(list 'display (cl-incf calls)))))
|
|
(ebox--with-validated-display-cache
|
|
(ebox--render-cache-node-signature node)
|
|
(ebox--render-cache-node-signature node)))
|
|
(should (= calls 1))))
|
|
|
|
(ert-deftest ebox-render-transaction-nesting-reuses-display-signature ()
|
|
"A nested render transaction should reuse the outer display snapshot."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80)))
|
|
(calls 0))
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda ()
|
|
(list 'display (cl-incf calls)))))
|
|
(ebox--with-validated-display-cache
|
|
(ebox--with-validated-display-cache
|
|
(ebox--render-cache-node-signature node))))
|
|
(should (= calls 1))))
|
|
|
|
(ert-deftest ebox-render-transactions-refresh-display-signature ()
|
|
"Separate render transactions should sample display state independently."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((node (ebox-test-root (ebox-test-box (ebox-test-text "A") :width 80)))
|
|
(calls 0)
|
|
signatures)
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda ()
|
|
(list 'display (cl-incf calls)))))
|
|
(dotimes (_ 2)
|
|
(push
|
|
(ebox--with-validated-display-cache
|
|
(car (ebox--render-cache-node-signature node)))
|
|
signatures)))
|
|
(should (= calls 2))
|
|
(should-not (equal (car signatures) (cadr signatures)))))
|
|
|
|
(ert-deftest ebox-current-display-signature-is-live-outside-transaction ()
|
|
"The internal accessor should sample live state outside a transaction."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((calls 0)
|
|
signatures)
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda ()
|
|
(list 'display (cl-incf calls)))))
|
|
(setq signatures
|
|
(list (ebox--current-display-signature)
|
|
(ebox--current-display-signature))))
|
|
(should (= calls 2))
|
|
(should-not (equal (car signatures) (cadr signatures)))))
|
|
|
|
(ert-deftest ebox-public-display-signature-stays-live-in-render-transaction ()
|
|
"The public display signature should bypass a render transaction snapshot."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((calls 0)
|
|
signatures)
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda ()
|
|
(list 'display (cl-incf calls)))))
|
|
(setq signatures
|
|
(ebox--with-validated-display-cache
|
|
(list (ebox-display-signature)
|
|
(ebox-display-signature)))))
|
|
(should (= calls 3))
|
|
(should-not (equal (car signatures) (cadr signatures)))))
|
|
|
|
(ert-deftest ebox-clear-cache-samples-live-display-in-render-transaction ()
|
|
"Clearing caches should store live display state inside a transaction."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((calls 0))
|
|
(puthash '(stale) 1 ebox--string-pixel-width-cache)
|
|
(aset ebox--string-pixel-width-cache-ring 0 '(stale))
|
|
(setq ebox--string-pixel-width-cache-ring-index 1)
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda ()
|
|
(list 'display (cl-incf calls)))))
|
|
(ebox--with-validated-display-cache
|
|
(ebox-clear-cache)))
|
|
(should (= calls 2))
|
|
(should (= (hash-table-count ebox--string-pixel-width-cache) 0))
|
|
(should (= ebox--string-pixel-width-cache-ring-index 0))
|
|
(should (equal ebox--display-signature-cache '(display 2)))))
|
|
|
|
(ert-deftest ebox-render-cache-signature-keeps-viewport-axes-with-snapshot ()
|
|
"Display snapshots should not collapse width or height cache dimensions."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((node (ebox-test-root
|
|
(ebox-test-box (ebox-test-text "A")
|
|
:width '(viewport)
|
|
:height '(viewport-height))))
|
|
signatures)
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda () '(display stable))))
|
|
(ebox--with-validated-display-cache
|
|
(dolist (viewport '((80 10) (90 10) (90 11)))
|
|
(let ((ebox-viewport-width (car viewport))
|
|
(ebox-viewport-height (cadr viewport)))
|
|
(push (ebox--render-cache-node-signature node)
|
|
signatures)))))
|
|
(setq signatures (nreverse signatures))
|
|
(should (equal (mapcar #'car signatures)
|
|
'((display stable) (display stable) (display stable))))
|
|
(should-not (equal (nth 0 signatures) (nth 1 signatures)))
|
|
(should-not (equal (nth 1 signatures) (nth 2 signatures)))))
|
|
|
|
(ert-deftest ebox-render-cache-signature-ignores-unrelated-viewport-height ()
|
|
"Width-only subtree signatures should survive unrelated height changes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (ebox-test-root
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "A") :width '(viewport))
|
|
(ebox-test-box (ebox-test-text "B") :width 80))))
|
|
(left (let ((ebox-viewport-width 320)
|
|
(ebox-viewport-height 10))
|
|
(ebox--render-cache-node-signature node)))
|
|
(right (let ((ebox-viewport-width 320)
|
|
(ebox-viewport-height 11))
|
|
(ebox--render-cache-node-signature node))))
|
|
(should (equal left right))))
|
|
|
|
(ert-deftest ebox-buffer-render-signatures-persist-across-transactions ()
|
|
"Unchanged runtime body signatures should survive buffer render transactions."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-flex
|
|
:width 240
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "A") :width 80)
|
|
(ebox-test-box (ebox-test-text "B") :width 80))))
|
|
(layout-node (ebox-test-root layout))
|
|
(node-count (length (ebox--runtime-node-ids layout-node)))
|
|
(signature-count 0)
|
|
(original (symbol-function 'ebox--render-cache-plist-signature))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(when-let* ((cache
|
|
(plist-get (ebox--buffer-render-state buffer)
|
|
:render-signature-cache)))
|
|
(clrhash cache))
|
|
(cl-letf (((symbol-function 'ebox--render-cache-plist-signature)
|
|
(lambda (&rest args)
|
|
(cl-incf signature-count)
|
|
(apply original args))))
|
|
(ebox--with-buffer-render-context buffer
|
|
(ebox--render-cache-node-body-signature layout-node))
|
|
(ebox--with-buffer-render-context buffer
|
|
(ebox--render-cache-node-body-signature layout-node)))
|
|
(should (= signature-count node-count)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-render-cache-plist-signature-avoids-append-churn ()
|
|
"Render cache plist signatures should be built linearly."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((node (ebox-test-root
|
|
(ebox-test-box (ebox-test-text "Signature") :width 80 :border '(1 solid))))
|
|
(original-append (symbol-function 'append))
|
|
(append-calls 0))
|
|
(cl-letf (((symbol-function 'append)
|
|
(lambda (&rest args)
|
|
(cl-incf append-calls)
|
|
(apply original-append args))))
|
|
(ebox--render-cache-plist-signature node))
|
|
(should (= append-calls 0))))
|
|
|
|
(ert-deftest ebox-preformatted-exact-content-skips-justify-measurement ()
|
|
"Exact-width preformatted content should not be remeasured for left justify."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content (ebox-lines-join
|
|
(list (ebox-pixel-space 80)
|
|
(ebox-pixel-space 80))))
|
|
(box-input
|
|
(ebox-test-box (ebox-test-text content) :width 80 :wrap-mode 'none))
|
|
(box (ebox-test-root box-input))
|
|
(ebox--render-source-index (ebox-test-source-index box-input))
|
|
(measure-count 0)
|
|
(original (symbol-function 'ebox--string-pixel-width)))
|
|
(plist-put box :ebox-content-width-exact-p t)
|
|
(cl-letf (((symbol-function 'ebox--string-pixel-width)
|
|
(lambda (&rest args)
|
|
(cl-incf measure-count)
|
|
(apply original args))))
|
|
(ebox--format-content box))
|
|
(should (= measure-count 0))))
|
|
|
|
(ert-deftest ebox-uniform-content-skips-noop-word-and-char-formatting ()
|
|
"Exact uniform lines should bypass proven no-op word/char formatting."
|
|
(ebox-test--reset-runtime-state)
|
|
(dolist (wrap-mode '(word char))
|
|
(let* ((box-input
|
|
(ebox-test-box (ebox-test-text "") :width '(120)
|
|
:wrap-mode wrap-mode :text-align 'left))
|
|
(box (ebox-test-root box-input))
|
|
(ebox--render-source-index (ebox-test-source-index box-input))
|
|
(pixel-width (ebox--content-pixel box))
|
|
(line (propertize (ebox-pixel-space pixel-width)
|
|
'face '(:foreground "#123456")))
|
|
(content (concat line "\n" line))
|
|
(wrap-count 0)
|
|
(justify-count 0)
|
|
(original-wrap (symbol-function 'ebox--wrap-text))
|
|
(original-justify (symbol-function 'ebox--lines-justify)))
|
|
(ebox--record-rendered-uniform-width content pixel-width)
|
|
(cl-letf (((symbol-function 'ebox--wrap-text)
|
|
(lambda (&rest arguments)
|
|
(cl-incf wrap-count)
|
|
(apply original-wrap arguments)))
|
|
((symbol-function 'ebox--lines-justify)
|
|
(lambda (&rest arguments)
|
|
(cl-incf justify-count)
|
|
(apply original-justify arguments))))
|
|
(should (eq (ebox--format-content-string box content) content)))
|
|
(should (= wrap-count 0))
|
|
(should (= justify-count 0)))))
|
|
|
|
(ert-deftest ebox-uniform-content-keeps-semantic-formatting-paths ()
|
|
"KP, non-left alignment and width mismatch must retain formatting."
|
|
(ebox-test--reset-runtime-state)
|
|
(dolist (case '((kp left 120)
|
|
(word center 120)
|
|
(char left 119)))
|
|
(let* ((box-input
|
|
(ebox-test-box (ebox-test-text "") :width '(120)
|
|
:wrap-mode (nth 0 case)
|
|
:text-align (nth 1 case)))
|
|
(box (ebox-test-root box-input))
|
|
(ebox--render-source-index (ebox-test-source-index box-input))
|
|
(content (ebox-pixel-space 120))
|
|
(wrap-count 0)
|
|
(original-wrap (symbol-function 'ebox--wrap-text)))
|
|
(ebox--record-rendered-uniform-width content (nth 2 case))
|
|
(cl-letf (((symbol-function 'ebox--wrap-text)
|
|
(lambda (&rest arguments)
|
|
(cl-incf wrap-count)
|
|
(apply original-wrap arguments))))
|
|
(ebox--format-content-string box content))
|
|
(should (> wrap-count 0)))))
|
|
|
|
(ert-deftest ebox-transparent-preformatted-box-fast-path-is-exact ()
|
|
"Transparent exact-width wrappers should match the generic box pipeline."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((child-a (ebox-test-box (ebox-test-text "A") :width '(viewport) :border '(1 solid)
|
|
:region-id 'child-a))
|
|
(child-b (ebox-test-box (ebox-test-text "B") :width '(viewport) :border '(1 solid)
|
|
:region-id 'child-b))
|
|
(parent (ebox-test-column
|
|
:width '(viewport) :region-id 'parent child-a child-b))
|
|
generic fast)
|
|
(let ((ebox-viewport-width 120)
|
|
(ebox--transparent-preformatted-box-fast-path-disabled t))
|
|
(setq generic (ebox-render parent)))
|
|
(clrhash ebox--rendered-uniform-width-table)
|
|
(let ((ebox-viewport-width 120))
|
|
(setq fast (ebox-render parent)))
|
|
(should (equal-including-properties fast generic))))
|
|
|
|
(ert-deftest ebox-flat-preformatted-fast-path-keeps-intrinsic-scroll-state ()
|
|
"Intrinsic flat rendering must not clear the published scroll state."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box-input
|
|
(ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none
|
|
:region-id 'intrinsic-wrapper))
|
|
(box (ebox-test-root box-input))
|
|
(content (ebox-pixel-space 40))
|
|
(state (list :scroll-offset 0 :sentinel t))
|
|
region-id)
|
|
(plist-put box :content content)
|
|
(plist-put box :ebox-content-width-exact-p t)
|
|
(setq region-id (ebox--ensure-region-id box))
|
|
(puthash region-id state ebox--scroll-global-state)
|
|
(let ((ebox--intrinsic-layout-measurement t))
|
|
(ebox-test--render-input-node box-input))
|
|
(should (eq (gethash region-id ebox--scroll-global-state) state))))
|
|
|
|
(ert-deftest ebox-flat-preformatted-blank-line-preserves-child-owner-segments ()
|
|
"Blank composite child segments must retain their distinct ownership."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((left
|
|
(propertize
|
|
(ebox-pixel-space 40)
|
|
'ebox-content 'left 'ebox-content-idx 0
|
|
'ebox-content-owner 'left 'ebox-content-owners '(left)
|
|
'face '(:foreground "#112233")))
|
|
(right
|
|
(propertize
|
|
(ebox-pixel-space 60)
|
|
'ebox-content 'right 'ebox-content-idx 0
|
|
'ebox-content-owner 'right 'ebox-content-owners '(right)
|
|
'face '(:foreground "#112233")))
|
|
(box-input
|
|
(ebox-test-box (ebox-test-text "") :width 100 :wrap-mode 'none
|
|
:bgcolor "#DDEEFF"))
|
|
(box (ebox-test-root box-input))
|
|
(ebox--render-source-index (ebox-test-source-index box-input))
|
|
(rendered
|
|
(car
|
|
(ebox--window-render-flat-wrapper-chunk-lines
|
|
box (list (concat left right)) 0 'outer
|
|
(ebox-pixel-space 100) t t))))
|
|
(should (= (length rendered) 2))
|
|
(should (= (ebox--string-pixel-width rendered) 100))
|
|
(should (eq (get-text-property 0 'ebox-content rendered) 'left))
|
|
(should (eq (get-text-property 1 'ebox-content rendered) 'right))
|
|
(should (equal (get-text-property 0 'ebox-content-owners rendered)
|
|
'(left outer)))
|
|
(should (equal (get-text-property 1 'ebox-content-owners rendered)
|
|
'(right outer)))
|
|
(should (eq (get-text-property 0 'ebox-content-owner rendered) 'outer))
|
|
(should (eq (get-text-property 1 'ebox-content-owner rendered) 'outer))
|
|
(dolist (position '(0 1))
|
|
(let ((face (get-text-property position 'face rendered)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :foreground "#112233"))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :background "#DDEEFF"))))))
|
|
|
|
(ert-deftest ebox-transparent-preformatted-fast-path-keeps-intrinsic-scroll-state ()
|
|
"Intrinsic transparent rendering must not clear the published scroll state."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((child-input
|
|
(ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none
|
|
:region-id 'transparent-child))
|
|
(child (ebox-test-root child-input))
|
|
(parent (ebox-test-box (ebox-test-text "") :width '(40) :wrap-mode 'none
|
|
:region-id 'transparent-parent
|
|
child-input))
|
|
(content (ebox-pixel-space 40))
|
|
(state (list :scroll-offset 0 :sentinel t))
|
|
region-id)
|
|
(plist-put child :content content)
|
|
(plist-put child :ebox-content-width-exact-p t)
|
|
(ebox-test--render-input-node parent)
|
|
(clrhash ebox--region-box-table)
|
|
(setq region-id (ebox--ensure-region-id (ebox-test-root parent)))
|
|
(puthash region-id state ebox--scroll-global-state)
|
|
(let ((ebox--intrinsic-layout-measurement t))
|
|
(ebox-test--render-input-node parent))
|
|
(should (eq (gethash region-id ebox--scroll-global-state) state))))
|
|
|
|
(defun ebox-test--legacy-box-chrome-lines (box lines region-id filler)
|
|
"Decorate prepared LINES using the former string-based Box algorithm.
|
|
This independent reference keeps each flattening step to verify the shared
|
|
line compositor's complete property output, including empty lines and chrome."
|
|
(let* ((pt (floor (ebox-get box :padding-top-height)))
|
|
(pb (floor (ebox-get box :padding-bottom-height)))
|
|
(pl (ebox-get box :padding-left-pixel))
|
|
(pr (ebox-get box :padding-right-pixel))
|
|
(bl (ebox-get box :border-left-pixel))
|
|
(br (ebox-get box :border-right-pixel))
|
|
(height (+ (length lines) pt pb))
|
|
(color (and (ebox-style--text-paint-owner-p box)
|
|
(ebox-get box :color)))
|
|
(bgcolor (ebox-get box :bgcolor))
|
|
(result (ebox-lines-join lines)))
|
|
(when (or (> pt 0) (> pb 0))
|
|
(setq result
|
|
(ebox--lines-stack-vertical
|
|
(when (> pt 0)
|
|
(ebox--propertize-region
|
|
(ebox--string-repeat-lines filler pt) 'ebox-pt region-id))
|
|
result
|
|
(when (> pb 0)
|
|
(ebox--propertize-region
|
|
(ebox--string-repeat-lines filler pb) 'ebox-pb region-id)))))
|
|
(when (or (> pl 0) (> pr 0))
|
|
(setq result
|
|
(ebox--lines-concat-horizontal
|
|
(when (> pl 0)
|
|
(ebox--propertize-region
|
|
(ebox--pixel-blank pl height) 'ebox-pl region-id))
|
|
result
|
|
(when (> pr 0)
|
|
(ebox--propertize-region
|
|
(ebox--pixel-blank pr height) 'ebox-pr region-id)))))
|
|
(when (or color bgcolor)
|
|
(setq result (ebox--maplines
|
|
(lambda (line) (ebox--propertize-colors line color bgcolor))
|
|
result)))
|
|
(when (or (> bl 0) (> br 0))
|
|
(setq result
|
|
(ebox--lines-concat-horizontal
|
|
(when (> bl 0)
|
|
(ebox--propertize-region
|
|
(ebox--pixel-border bl height (ebox-get box :border-left-color))
|
|
'ebox-bl region-id))
|
|
result
|
|
(when (> br 0)
|
|
(ebox--propertize-region
|
|
(ebox--pixel-border br height (ebox-get box :border-right-color))
|
|
'ebox-br region-id)))))
|
|
(let ((result-lines (ebox-string-lines result)))
|
|
(when (> (ebox-get box :border-top-pixel) 0)
|
|
(setcar result-lines
|
|
(propertize
|
|
(ebox--propertize-overline
|
|
(car result-lines) (ebox-get box :border-top-color))
|
|
'ebox-bt region-id)))
|
|
(when (> (ebox-get box :border-bottom-pixel) 0)
|
|
(let ((tail (last result-lines)))
|
|
(setcar tail
|
|
(propertize
|
|
(ebox--propertize-underline
|
|
(car tail) (ebox-get box :border-bottom-color))
|
|
'ebox-bb region-id))))
|
|
(setq result (ebox-lines-join result-lines)))
|
|
(ebox--apply-surface-properties result (ebox-get box :surface-properties))
|
|
(ebox-string-lines result)))
|
|
|
|
(defun ebox-test--box-full-height-joins (input min-height)
|
|
"Render INPUT and record real post-format joins reaching MIN-HEIGHT."
|
|
(let* ((handle (ebox-node-source-handle (ebox-test-root input)))
|
|
(original-box (symbol-function 'ebox--render-box))
|
|
(original-format (symbol-function 'ebox--format-content))
|
|
(original-mapconcat (symbol-function 'mapconcat))
|
|
active formatting full-height-joins rendered)
|
|
(cl-letf (((symbol-function 'ebox--render-box)
|
|
(lambda (box)
|
|
(let ((previous active))
|
|
(setq active (eq (ebox-node-source-handle box) handle))
|
|
(unwind-protect (funcall original-box box)
|
|
(setq active previous)))))
|
|
((symbol-function 'ebox--format-content)
|
|
(lambda (box)
|
|
(let ((previous formatting))
|
|
(setq formatting t)
|
|
(unwind-protect (funcall original-format box)
|
|
(setq formatting previous)))))
|
|
((symbol-function 'mapconcat)
|
|
(lambda (function sequence separator)
|
|
(let ((result (funcall original-mapconcat
|
|
function sequence separator)))
|
|
(when (and active (not formatting)
|
|
(equal separator "\n")
|
|
(>= (ebox-string-height result) min-height))
|
|
(push (list (ebox-string-height result)
|
|
(string-bytes result))
|
|
full-height-joins))
|
|
result))))
|
|
(setq rendered (ebox-render input)))
|
|
(list :rendered rendered :joins (nreverse full-height-joins))))
|
|
|
|
(ert-deftest ebox-box-chrome-avoids-intermediate-multiline-materialization ()
|
|
"One Box should not flatten full-height strips for padding and borders."
|
|
(let* ((input
|
|
(ebox-build
|
|
'(box :width (120) :height 8 :overflow hidden
|
|
:padding (1 (3)) :border (1 solid "#112233")
|
|
:bgcolor "#ddeeff"
|
|
(text "alpha\nbeta"))))
|
|
(observed (ebox-test--box-full-height-joins input 6))
|
|
(joins (plist-get observed :joins)))
|
|
(should (= (ebox-string-height (plist-get observed :rendered)) 8))
|
|
;; Alignment and blank normalization retain lines; the shared compositor
|
|
;; performs the only full-height join at the border-box output boundary.
|
|
(ert-info ((format "Materialized (height bytes): %S"
|
|
joins))
|
|
(should (= (length joins) 1)))))
|
|
|
|
(ert-deftest ebox-box-chrome-keeps-complete-scroll-materialization ()
|
|
"Existing fully painted scroll output must not enter decoration again."
|
|
(dolist (text '("alpha\nbeta" "1\n2\n3\n4\n5\n6\n7\n8\n9"))
|
|
(let* ((input
|
|
(ebox-build
|
|
`(box :width (120) :height 8 :overflow scroll :bgcolor "#ddeeff"
|
|
(text ,text))))
|
|
(observed (ebox-test--box-full-height-joins input 8)))
|
|
(should (= (ebox-string-height (plist-get observed :rendered)) 8))
|
|
(ert-info ((format "Complete scroll joins: %S" (plist-get observed :joins)))
|
|
(should (= (length (plist-get observed :joins)) 1))))))
|
|
|
|
(ert-deftest ebox-box-chrome-matches-legacy-full-property-output ()
|
|
"Shared decoration preserves alignment, margins, overflow and rich blanks."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((rich (concat "\n" (propertize "alpha" 'face '(:weight bold)
|
|
'help-echo "leaf")
|
|
"\n" (propertize " " 'help-echo "rich blank")
|
|
"\nomega\n"))
|
|
(foreground (tp-paint-slot-create '(:foreground "#112233")))
|
|
(background (tp-paint-slot-create '(:background "#ddeeff"))))
|
|
(dolist (height '(0 4 8))
|
|
(dolist (align '(top center bottom))
|
|
(dolist (overflow '(hidden visible scroll))
|
|
(dolist (margins '(nil t))
|
|
(let* ((input
|
|
(ebox-test-box
|
|
(ebox-test-text rich) :width '(120) :height height
|
|
:padding '(1 (3)) :margin (if margins '(1 (2)) 0)
|
|
:border '(1 solid "#112233") :overflow overflow
|
|
:color (if margins foreground "#112233")
|
|
:bgcolor (if margins background "#ddeeff")
|
|
:surface-properties '(help-echo "host" mouse-face highlight)))
|
|
(box (ebox-test-root input))
|
|
(original
|
|
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))
|
|
actual expected)
|
|
(plist-put box :vertical-align align)
|
|
(setq actual (ebox-render input))
|
|
(cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
|
|
(lambda (&rest arguments)
|
|
(if (nth 7 arguments)
|
|
(ebox-test--legacy-box-chrome-lines
|
|
(nth 0 arguments) (nth 1 arguments)
|
|
(nth 3 arguments) (nth 4 arguments))
|
|
(apply original arguments)))))
|
|
(setq expected (ebox-render input)))
|
|
(ert-info ((format "height=%S align=%S overflow=%S margins=%S"
|
|
height align overflow margins))
|
|
(should (equal-including-properties actual expected))))))))))
|
|
|
|
(ert-deftest ebox-flat-preformatted-fixed-box-rejects-layout-work ()
|
|
"Fixed wrappers should stay generic when clipping or alignment is required."
|
|
(ebox-test--reset-runtime-state)
|
|
(dolist (case '(("two\nlines" hidden top)
|
|
("one\ntwo\nthree\nfour" hidden top)
|
|
("one\ntwo\nthree" scroll top)
|
|
("one\ntwo\nthree" hidden center)))
|
|
(let* ((box-input
|
|
(ebox-test-box
|
|
(ebox-test-text (nth 0 case)) :width '(120) :height 5
|
|
:box-sizing 'border-box :overflow (nth 1 case)
|
|
:wrap-mode 'none))
|
|
(box (ebox-test-root box-input))
|
|
(raw-calls 0)
|
|
(prepared-calls 0)
|
|
(original
|
|
(symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)))
|
|
(plist-put box :vertical-align (nth 2 case))
|
|
(plist-put box :ebox-content-width-exact-p t)
|
|
(cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)
|
|
(lambda (&rest arguments)
|
|
(if (nth 7 arguments)
|
|
(cl-incf prepared-calls)
|
|
(cl-incf raw-calls))
|
|
(apply original arguments))))
|
|
(ebox-test--render-input-node box-input))
|
|
;; Clipping/alignment must finish in the generic renderer before the
|
|
;; shared decorator consumes prepared lines; raw window input is unsafe.
|
|
(should (= raw-calls 0))
|
|
;; Complete chrome-free scroll output already owns its final paint.
|
|
(should (= prepared-calls (if (eq (nth 1 case) 'scroll) 0 1))))))
|
|
|
|
(ert-deftest ebox-string-pixel-width-cache-ignores-ebox-ownership-metadata ()
|
|
"Reuse visual width measurements across Ebox-owned text properties."
|
|
(let ((line (propertize "cached row"
|
|
'ebox-content 7
|
|
'ebox-content-idx 0
|
|
'ebox-content-owner 7
|
|
'ebox-content-owners '(7)))
|
|
(calls 0)
|
|
(original (symbol-function 'ebox--scaled-string-pixel-width)))
|
|
(ebox--clear-string-pixel-width-cache)
|
|
(cl-letf (((symbol-function 'ebox--scaled-string-pixel-width)
|
|
(lambda (&rest args)
|
|
(cl-incf calls)
|
|
(apply original args))))
|
|
(let ((first (ebox--string-pixel-width line))
|
|
(second (ebox--string-pixel-width (copy-sequence line))))
|
|
(should (> first 0))
|
|
(should (= first second))))
|
|
(should (= calls 1))))
|
|
|
|
(ert-deftest ebox-buffer-update-report-includes-dirty-and-patch-summary ()
|
|
"Update reports should show dirty and patch-set summaries."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Report") :width 80
|
|
:border '(1 solid "red")))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id :border-color "blue")
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (plist-member report :dirty-count))
|
|
(should (plist-member report :patch-count))
|
|
(should (plist-member report :patch-ops))
|
|
(should (equal (plist-get report :dirty-keys)
|
|
'(:border-top-color :border-right-color
|
|
:border-bottom-color :border-left-color)))))))
|
|
|
|
(ert-deftest ebox-buffer-update-report-is-buffer-local ()
|
|
"Updating one rendered buffer should not replace another buffer's report."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((first-box (ebox-test-box (ebox-test-text "First") :width '(80)))
|
|
(second-box (ebox-test-box (ebox-test-text "Second") :width '(80)))
|
|
(first-id (car (ebox-region-ids first-box)))
|
|
first-buffer second-buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq first-buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-report-first*")
|
|
first-box))
|
|
(setq second-buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-report-second*")
|
|
second-box))
|
|
(ebox-test--set-buffer-update-report
|
|
second-buffer '(:strategy second-prior :owner-ids (second)))
|
|
(with-current-buffer first-buffer
|
|
(let ((report (ebox-test--region-update first-id :content "First!")))
|
|
(should (equal report
|
|
(ebox-buffer-update-report first-buffer)))
|
|
(should-not (equal report
|
|
(ebox-buffer-update-report
|
|
second-buffer)))
|
|
(should (equal (ebox-buffer-update-report second-buffer)
|
|
'(:strategy second-prior
|
|
:owner-ids (second)))))))
|
|
(dolist (buffer (list first-buffer second-buffer))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer))))))
|
|
|
|
(ert-deftest ebox-buffer-update-report-returns-defensive-copy ()
|
|
"The public accessor should not return mutable render-state storage."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Copy") :width '(80)))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-report-copy*")
|
|
box))
|
|
(ebox-test--set-buffer-update-report
|
|
buffer '(:strategy paint-patch :owner-ids (1 2)))
|
|
(let ((report (ebox-buffer-update-report buffer)))
|
|
(should (equal report '(:strategy paint-patch :owner-ids (1 2))))
|
|
(plist-put report :strategy 'mutated)
|
|
(setcar (plist-get report :owner-ids) 99))
|
|
(should (equal (ebox-buffer-update-report buffer)
|
|
'(:strategy paint-patch :owner-ids (1 2)))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-buffer-update-report-user-errors-without-rendered-buffer ()
|
|
"The public accessor should reject missing, dead, and unrendered buffers."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((dead (generate-new-buffer " *ebox-report-dead*"))
|
|
(unrendered (generate-new-buffer " *ebox-report-unrendered*")))
|
|
(unwind-protect
|
|
(progn
|
|
(kill-buffer dead)
|
|
(should-error (ebox-buffer-update-report nil) :type 'user-error)
|
|
(should-error (ebox-buffer-update-report dead) :type 'user-error)
|
|
(should-error (ebox-buffer-update-report unrendered)
|
|
:type 'user-error))
|
|
(when (buffer-live-p unrendered)
|
|
(kill-buffer unrendered)))))
|
|
|
|
(ert-deftest ebox-region-update-return-matches-buffer-update-report ()
|
|
"A direct region update should return the report stored on its buffer."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Return") :width '(80)
|
|
:color "#111111"))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((report (ebox-test--region-update region-id :color "#222222")))
|
|
(should (equal report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'paint-patch))))))
|
|
|
|
(ert-deftest ebox-region-update-no-op-replaces-stale-buffer-report ()
|
|
"A direct unchanged update should publish its own no-op report."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "No-op") :width '(80)
|
|
:color "#111111"))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((prior '(:strategy paint-patch :owner-ids (prior))))
|
|
(ebox-test--set-buffer-update-report (current-buffer) prior)
|
|
(let ((report
|
|
(ebox-test--region-update region-id :color "#111111")))
|
|
(should (eq (plist-get report :strategy) 'no-op))
|
|
(should (= (plist-get report :dirty-count) 0))
|
|
(should (= (plist-get report :patch-count) 0))
|
|
(should (equal report (ebox-test--buffer-update-report)))
|
|
(should-not (equal report prior)))))))
|
|
|
|
(ert-deftest ebox-batched-region-update-keeps-prior-report-until-flush ()
|
|
"Batched region updates should return nil and publish only on flush."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Batch") :width '(80)
|
|
:color "#111111"))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((buffer (current-buffer))
|
|
(prior '(:strategy prior :owner-ids (prior))))
|
|
(ebox-test--set-buffer-update-report buffer prior)
|
|
(ebox-incremental-begin-batch buffer)
|
|
(should-not (ebox-test--region-update region-id :color "#333333"))
|
|
(should (equal (ebox-buffer-update-report buffer) prior))
|
|
(let ((flush-report (ebox-incremental-flush buffer)))
|
|
(should flush-report)
|
|
(should (equal flush-report
|
|
(ebox-buffer-update-report buffer)))
|
|
(should (eq (plist-get flush-report :strategy)
|
|
'paint-patch)))))))
|
|
|
|
(ert-deftest ebox-batch-flush-stores-report-on-flushed-buffer-only ()
|
|
"Empty and non-empty flushes should update only the target buffer report."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((first-box (ebox-test-box (ebox-test-text "First") :width '(80)
|
|
:color "#111111"))
|
|
(second-box (ebox-test-box (ebox-test-text "Second") :width '(80)
|
|
:color "#111111"))
|
|
(second-id (car (ebox-region-ids second-box)))
|
|
first-buffer second-buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq first-buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-flush-first*")
|
|
first-box))
|
|
(setq second-buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-flush-second*")
|
|
second-box))
|
|
(ebox-test--set-buffer-update-report
|
|
first-buffer '(:strategy first-prior))
|
|
(ebox-test--set-buffer-update-report
|
|
second-buffer '(:strategy second-prior))
|
|
(ebox-incremental-begin-batch first-buffer)
|
|
(let ((first-report (ebox-incremental-flush first-buffer)))
|
|
(should (eq (plist-get first-report :strategy) 'no-op))
|
|
(should (equal (ebox-buffer-update-report first-buffer)
|
|
first-report))
|
|
(should (equal (ebox-buffer-update-report second-buffer)
|
|
'(:strategy second-prior)))
|
|
(with-current-buffer second-buffer
|
|
(ebox-incremental-begin-batch second-buffer)
|
|
(should-not
|
|
(ebox-test--region-update second-id :color "#444444")))
|
|
(let ((second-report (ebox-incremental-flush second-buffer)))
|
|
(should (eq (plist-get second-report :strategy) 'paint-patch))
|
|
(should (equal (ebox-buffer-update-report second-buffer)
|
|
second-report))
|
|
(should (equal (ebox-buffer-update-report first-buffer)
|
|
first-report)))))
|
|
(dolist (buffer (list first-buffer second-buffer))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer))))))
|
|
|
|
(ert-deftest ebox-render-to-buffer-does-not-switch-current-buffer ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((origin (current-buffer))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
(ebox-test-box (ebox-test-text "Hello") :width 40)))
|
|
(should (eq (current-buffer) origin))
|
|
(should (buffer-live-p buffer))
|
|
(with-current-buffer buffer
|
|
(should ebox-buffer-mode)
|
|
(should buffer-read-only)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-render-to-buffer-keeps-existing-local-map-as-parent ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((buffer-name (generate-new-buffer-name " *ebox-test*"))
|
|
(buffer (get-buffer-create buffer-name))
|
|
(parent-map (make-sparse-keymap)))
|
|
(define-key parent-map (kbd "x") #'ignore)
|
|
(unwind-protect
|
|
(with-current-buffer buffer
|
|
(use-local-map parent-map)
|
|
(ebox-render-to-buffer buffer-name
|
|
(ebox-test-box (ebox-test-text "Hello") :width 40))
|
|
(should (eq (current-local-map) parent-map))
|
|
(should (eq (lookup-key (current-local-map) (kbd "x")) #'ignore)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-buffer-kill-clears-region-runtime-caches ()
|
|
"Killing an ebox buffer should clear region-scoped runtime cache entries."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC") :width 80 :height 1
|
|
:overflow 'scroll))
|
|
(region-id (car (ebox-region-ids box)))
|
|
(buffer (ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
box)))
|
|
(should (gethash region-id ebox--region-box-table))
|
|
(should (ebox--scroll-get-state region-id))
|
|
(kill-buffer buffer)
|
|
(should-not (gethash region-id ebox--region-box-table))
|
|
(should-not (ebox--scroll-get-state region-id))))
|
|
|
|
(ert-deftest ebox-buffer-kill-discards-buffer-owned-runtime-state ()
|
|
"Killing an Ebox buffer should discard every buffer-keyed runtime entry."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Lifecycle") :color "#111111"))
|
|
(region-id (car (ebox-region-ids box)))
|
|
buffer surface handle)
|
|
(unwind-protect
|
|
(progn
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*") box))
|
|
(setq surface
|
|
(with-current-buffer buffer ebox-surface--buffer-surface)
|
|
handle (ebox-test--region-handle-for-id region-id))
|
|
|
|
(ebox-test--region-update region-id :color "#222222")
|
|
|
|
;; A later update remains pending in the explicit batch.
|
|
(ebox-incremental-begin-batch buffer)
|
|
(ebox-test--region-update region-id :background-color "#eeeeee")
|
|
|
|
(should (gethash buffer ebox--buffer-render-state-table))
|
|
(should (gethash buffer ebox-incremental--batch-table))
|
|
(should (tp-surface-live-p surface))
|
|
(should (ebox-buffer-update-report buffer))
|
|
(should (= (ebox-incremental-pending-count buffer) 1))
|
|
|
|
(kill-buffer buffer)
|
|
(should-not (gethash buffer ebox--buffer-render-state-table))
|
|
(should-not (gethash buffer ebox-cache--buffer-report-table))
|
|
(should-not (gethash buffer ebox-incremental--batch-table))
|
|
(should-not (tp-surface-live-p surface))
|
|
(should-error (ebox-region-update handle :content "stale")
|
|
:type 'user-error)
|
|
(should-error (ebox-buffer-update-report buffer)
|
|
:type 'user-error))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer))
|
|
(when buffer
|
|
(remhash buffer ebox--buffer-render-state-table)
|
|
(remhash buffer ebox-cache--buffer-report-table)
|
|
(remhash buffer ebox-incremental--batch-table)))))
|
|
|
|
(ert-deftest ebox-geometry-update-rerenders-root-once-safely ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((top (ebox-test-box (ebox-test-text "Top") :width 60))
|
|
(left (ebox-test-box (ebox-test-text "L") :width 40))
|
|
(right (ebox-test-box (ebox-test-text "R") :width 40))
|
|
(layout (ebox-test-column top (ebox-test-row left right)))
|
|
(ids (ebox-region-ids layout))
|
|
(left-id (nth 1 ids)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox-test--region-update left-id :width '(120))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "Top" plain))
|
|
(should (string-match-p "L" plain))
|
|
(should (string-match-p "R" plain)))
|
|
(should (equal (ebox-region-ids layout) ids)))))
|
|
|
|
(ert-deftest ebox-border-color-update-patches-without-full-root-rerender ()
|
|
"A pure border color change should patch owned spans, not rebuild the root."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-row
|
|
(ebox-test-box :id 'left :width 40 :border "red" (ebox-test-text "L"))
|
|
(ebox-test-box :id 'right :width 40 :border "green"
|
|
(ebox-test-text "R")))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((left-id (ebox-test--selector-region-id
|
|
(current-buffer) "#left")))
|
|
(let ((full-rerenders
|
|
(ebox-test--count-full-surface-updates
|
|
(ebox-test--region-update left-id :border-color "blue"))))
|
|
(should (= full-rerenders 0)))
|
|
(goto-char (point-min))
|
|
(search-forward "L")
|
|
(let ((border-face (get-text-property (match-beginning 0) 'face)))
|
|
(should (ebox-test--face-has-key-value-p border-face
|
|
:overline "blue"))))
|
|
(should (string-match-p "R" (buffer-string))))))
|
|
|
|
(ert-deftest ebox-flex-row-border-color-update-patches-region-role ()
|
|
"Paint-only updates inside flex rows should not fall back to root rerender."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding
|
|
'(0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
|
|
(ebox-test-column
|
|
(ebox-test-box :id 'left (ebox-test-text "Left") :width
|
|
'(120) :padding '(0 (8)) :border
|
|
"#D97757")
|
|
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
|
|
'(0 (8)) :border-right "#D97757"
|
|
:border-bottom "#D97757" :border-left
|
|
"#D97757"))
|
|
(ebox-test-column
|
|
(ebox-test-box :id 'right (ebox-test-text "Right") :width
|
|
'(120) :padding '(0 (8)) :border
|
|
"#5E7F6A")
|
|
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
|
|
'(0 (8)) :border-right "#5E7F6A"
|
|
:border-bottom "#5E7F6A" :border-left
|
|
"#5E7F6A"))))
|
|
(left-id (plist-get
|
|
(car (ebox-selector-query-all layout "#left"))
|
|
:region-id)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((full-rerenders
|
|
(ebox-test--count-full-surface-updates
|
|
(ebox-test--region-update left-id :border-bottom-color "blue"))))
|
|
(should (= full-rerenders 0)))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'paint-patch))
|
|
(should-not (plist-get report :root-rerender)))
|
|
(let (bottom-face)
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(while (and (not bottom-face) (< (point) (point-max)))
|
|
(when (equal (get-text-property (point) 'ebox-bb) left-id)
|
|
(setq bottom-face (get-text-property (point) 'face)))
|
|
(forward-char 1)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
bottom-face :underline '(:position t :color "blue")))))))
|
|
|
|
(ert-deftest ebox-paint-update-synthesizes-missing-horizontal-border-surface ()
|
|
"Adding a missing top/bottom paint surface should patch from region anchors."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding
|
|
'(0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
|
|
(ebox-test-column
|
|
(ebox-test-box :id 'left (ebox-test-text "Left") :width
|
|
'(120) :padding '(0 (8)) :border-top
|
|
"#D97757" :border-right "#D97757"
|
|
:border-left "#D97757")
|
|
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
|
|
'(0 (8)) :border-right "#D97757"
|
|
:border-bottom "#D97757" :border-left
|
|
"#D97757"))
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Right") :width '(120)
|
|
:padding '(0 (8)) :border-top
|
|
"#5E7F6A" :border-right "#5E7F6A"
|
|
:border-left "#5E7F6A")
|
|
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
|
|
'(0 (8)) :border-right "#5E7F6A"
|
|
:border-bottom "#5E7F6A" :border-left
|
|
"#5E7F6A"))))
|
|
(left-id (plist-get
|
|
(car (ebox-selector-query-all layout "#left"))
|
|
:region-id)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(should-not (text-property-search-forward 'ebox-bb left-id t))
|
|
(let ((full-rerenders
|
|
(ebox-test--count-full-surface-updates
|
|
(ebox-test--region-update
|
|
left-id :border-bottom '(1 solid "blue")))))
|
|
(should (= full-rerenders 0)))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(span-patch owner-rerender)))
|
|
(should-not (plist-get report :root-rerender)))
|
|
(let (bottom-face)
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(while (and (not bottom-face) (< (point) (point-max)))
|
|
(when (equal (get-text-property (point) 'ebox-bb) left-id)
|
|
(setq bottom-face (get-text-property (point) 'face)))
|
|
(forward-char 1)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
bottom-face :underline '(:position t :color "blue")))))))
|
|
|
|
(ert-deftest ebox-paint-projection-keeps-old-fragment-metadata-immutable ()
|
|
"Paint projection should not mutate the previous state's fragment metadata."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-flex :flex-flow '(row wrap) :column-gap '(12) :padding
|
|
'(0 (12)) :border "#D97757" :bgcolor "#FFF8F2"
|
|
(ebox-test-column
|
|
(ebox-test-box :id 'left (ebox-test-text "Left") :width
|
|
'(120) :padding '(0 (8)) :border-top
|
|
"#D97757" :border-right "#D97757"
|
|
:border-left "#D97757")
|
|
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
|
|
'(0 (8)) :border-right "#D97757"
|
|
:border-bottom "#D97757" :border-left
|
|
"#D97757"))
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Right") :width '(120)
|
|
:padding '(0 (8)) :border-top
|
|
"#5E7F6A" :border-right "#5E7F6A"
|
|
:border-left "#5E7F6A")
|
|
(ebox-test-box (ebox-test-text "Body") :width '(120) :padding
|
|
'(0 (8)) :border-right "#5E7F6A"
|
|
:border-bottom "#5E7F6A" :border-left
|
|
"#5E7F6A"))))
|
|
(left-id (plist-get
|
|
(car (ebox-selector-query-all layout "#left"))
|
|
:region-id)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((old-state (ebox--buffer-render-state (current-buffer)))
|
|
(old-fragments (plist-get old-state :surface-fragments))
|
|
(old-metadata (copy-tree old-fragments)))
|
|
(save-restriction
|
|
(goto-char (1+ (point-min)))
|
|
(narrow-to-region (point) (point-max))
|
|
(ebox-test--region-update
|
|
left-id :border-bottom '(1 solid "blue")))
|
|
(should (equal old-metadata
|
|
(plist-get old-state :surface-fragments)))
|
|
(should (cl-every (lambda (fragment)
|
|
(not (memq :text fragment)))
|
|
old-fragments))))))
|
|
|
|
(ert-deftest ebox-horizontal-border-removal-preserves-other-border-colors ()
|
|
"Removing top/bottom border surfaces must not corrupt other border faces."
|
|
(ebox-test--reset-runtime-state)
|
|
(dolist (case '(((:border-top-p nil) bt (bb bl br))
|
|
((:border-bottom-width 0) bb (bt bl br))))
|
|
(let* ((update (nth 0 case))
|
|
(removed-role (nth 1 case))
|
|
(kept-roles (nth 2 case))
|
|
(layout (ebox-test-box (ebox-test-text "Border")
|
|
:width 80
|
|
:padding '(0 (8))
|
|
:border "#D97757"))
|
|
(region-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(cl-labels ((face-at-role
|
|
(role)
|
|
(let ((prop (alist-get role ebox-region-types))
|
|
face)
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(while (and (not face) (< (point) (point-max)))
|
|
(when (equal (get-text-property (point) prop)
|
|
region-id)
|
|
(setq face (get-text-property (point) 'face)))
|
|
(forward-char 1)))
|
|
face)))
|
|
(apply #'ebox-test--region-update region-id update)
|
|
(should-not (face-at-role removed-role))
|
|
(dolist (role kept-roles)
|
|
(should
|
|
(ebox-test--border-face-has-color-p
|
|
(face-at-role role) role "#D97757"))))))))
|
|
|
|
(ert-deftest ebox-horizontal-border-capability-error-preserves-mounted-state ()
|
|
"An unsupported horizontal width must fail before replacing publication."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((input
|
|
(ebox-test-box :id "root" (ebox-test-text "Border")
|
|
:border '(1 solid "#D97757")))
|
|
(buffer (ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-border-capability*")
|
|
input))
|
|
(state (ebox--buffer-render-state buffer))
|
|
(text (with-current-buffer buffer (buffer-string))))
|
|
(unwind-protect
|
|
(progn
|
|
(should-error
|
|
(ebox-selector-update-buffer
|
|
buffer "#root" :border-top '(2 solid "#D97757")))
|
|
(should (eq state (ebox--buffer-render-state buffer)))
|
|
(should (equal-including-properties
|
|
text (with-current-buffer buffer (buffer-string)))))
|
|
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-side-border-color-patch-replaces-paint-exactly ()
|
|
"Side color replacements should match the full-render paint result."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box
|
|
(ebox-test-text "Border") :width 80
|
|
:border-left-width 2 :border-left-style 'solid
|
|
:border-left-color "#AA0000"
|
|
:border-right-width 2 :border-right-style 'solid
|
|
:border-right-color "#AA0000"))
|
|
(region-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox-test--region-update region-id
|
|
:border-left-color "#00AA00"
|
|
:border-right-color "#00AA00")
|
|
(dolist (role '(bl br))
|
|
(let ((face
|
|
(ebox-test--face-at-property
|
|
(buffer-string) (alist-get role ebox-region-types))))
|
|
(should-not (ebox-test--face-has-key-p face :inverse-video))
|
|
(should
|
|
(ebox-test--face-has-key-value-p
|
|
face :background "#00AA00"))))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-rerender-buffer-with-context-resolves-runtime-viewport ()
|
|
"Runtime rerender should resolve viewport-sized boxes with the new context."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (let ((ebox-viewport-width 240))
|
|
(ebox-test-box (ebox-test-text "Viewport") :width '(viewport) :border '(1 solid))))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(should (eq (ebox-get (ebox-test-root node) :width) 'viewport))
|
|
(let ((ebox-viewport-width 240))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
node)))
|
|
(with-current-buffer buffer
|
|
(should (= (ebox--string-pixel-width
|
|
(buffer-substring (point-min) (line-end-position)))
|
|
240)))
|
|
(ebox-rerender-buffer-with-context buffer 160)
|
|
(with-current-buffer buffer
|
|
(should (= (ebox--string-pixel-width
|
|
(buffer-substring (point-min) (line-end-position)))
|
|
160))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-rerender-buffer-with-context-resolves-runtime-viewport-height ()
|
|
"Runtime rerender should resolve viewport-height boxes with the new context."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (let ((ebox-viewport-height 2))
|
|
(ebox-test-box (ebox-test-text "One\nTwo\nThree\nFour") :width '(120) :height
|
|
'(viewport-height) :overflow 'scroll)))
|
|
buffer)
|
|
(unwind-protect
|
|
(progn
|
|
(let ((ebox-viewport-height 2))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
node)))
|
|
(with-current-buffer buffer
|
|
(should (= (length (ebox-string-lines
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
2)))
|
|
(ebox-rerender-buffer-with-context buffer 120 3)
|
|
(with-current-buffer buffer
|
|
(should (= (length (ebox-string-lines
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
3))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-flex-contained-geometry-update-does-not-promote-to-root ()
|
|
"Stable flex-contained geometry changes should stop before root."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-column :id 'root :width '(520) :padding '(1 (18))
|
|
(ebox-test-flex :id 'matrix :flex-flow '(row wrap)
|
|
:column-gap '(12) :row-gap 1
|
|
:padding '(0 (12)) :border "#999999"
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Title")
|
|
:width '(190))
|
|
(ebox-test-box :id
|
|
'target
|
|
(ebox-test-text "width target\nsame height")
|
|
:width
|
|
'(160)
|
|
:height
|
|
3
|
|
:padding
|
|
'(0
|
|
(8))
|
|
:border
|
|
"#999999")
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric")
|
|
:width '(190)))
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Other")
|
|
:width '(190))
|
|
(ebox-test-box
|
|
(ebox-test-text "Still here")
|
|
:width '(190))))))
|
|
(target (car (ebox-selector-query-all layout "#target")))
|
|
(target-id (plist-get target :region-id)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox-test--region-update target-id :width '(190))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should-not (plist-get report :full-rerender))
|
|
(should-not (member root-id (plist-get report :owner-ids)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(span-patch owner-rerender)))
|
|
(should (< (plist-get report :span-count) 40)))))))
|
|
|
|
(defun ebox-test--nested-width-containment-layout (&optional height)
|
|
"Return a nested flex width-update fixture with optional fixed HEIGHT."
|
|
(let* ((target
|
|
(ebox-test-box (ebox-test-text "width target\nready to grow")
|
|
:width '(190) :height 3 :padding '(0 (10))
|
|
:border "#999999"))
|
|
(owner
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :width '(300) :padding '(0 (10))
|
|
:border "#999999" :justify-content 'center
|
|
:align-items 'center)
|
|
(when height (list :height height))
|
|
(list target))))
|
|
(card
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Width") :width '(300) :height 2)
|
|
owner
|
|
(ebox-test-box (ebox-test-text "The target stays in this card")
|
|
:width '(300) :height 3)
|
|
(ebox-test-box (ebox-test-text "Metric") :width '(300) :height 4)))
|
|
(peer
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Peer") :width '(300) :height 2)
|
|
(ebox-test-box (ebox-test-text "Stable peer") :width '(300) :height 5)
|
|
(ebox-test-box (ebox-test-text "Footer") :width '(300) :height 7)))
|
|
(layout
|
|
(ebox-test-flex :width '(720) :flex-flow '(row wrap)
|
|
:column-gap '(12) card peer)))
|
|
(list :layout layout :target target :owner owner)))
|
|
|
|
(ert-deftest ebox-visible-overflow-flex-wrapper-disables-width-containment ()
|
|
"A visible-overflow flex wrapper must not contain child width changes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((target
|
|
(ebox-test-box (ebox-test-text "child") :width '(190)
|
|
:height 5 :min-height 5))
|
|
(owner
|
|
(ebox-test-flex :width '(300) :height 2 :overflow 'visible
|
|
:align-items 'flex-start target))
|
|
(layout
|
|
(ebox-test-column owner
|
|
(ebox-test-box (ebox-test-text "footer") :width '(300)
|
|
:height 2)))
|
|
(owner-node-id (ebox--ensure-node-id (ebox-test-root owner))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(should (= (ebox-string-height (ebox-render owner)) 5))
|
|
(should-not (ebox--definite-containment-formatting-context-p
|
|
(current-buffer) owner-node-id)))))
|
|
|
|
(ert-deftest ebox-fixed-flex-own-width-change-promotes-outside-containment ()
|
|
"A fixed flex must not contain a change to its own outer width."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((fixture (ebox-test--nested-width-containment-layout 5))
|
|
(layout (plist-get fixture :layout))
|
|
(owner (plist-get fixture :owner))
|
|
(owner-region-id (car (ebox-region-ids owner))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((root-node-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox-test--region-update owner-region-id :width '(320))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (equal (plist-get report :owner-ids)
|
|
(list root-node-id)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should-not (plist-get report :tp-scope-fallback))))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-flex-main-footprint-change-skips-flex-span-probe ()
|
|
"Flex main footprint changes should not render a large flex owner twice."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-column :id 'root :width '(520) :padding '(1 (18))
|
|
(ebox-test-flex :id 'matrix :flex-flow '(row wrap)
|
|
:column-gap '(12) :row-gap 1
|
|
:padding '(0 (12)) :border "#999999"
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Title")
|
|
:width '(190))
|
|
(ebox-test-box :id
|
|
'target
|
|
(ebox-test-text "width target\nsame height")
|
|
:width
|
|
'(160)
|
|
:height
|
|
3
|
|
:padding
|
|
'(0
|
|
(8))
|
|
:border
|
|
"#999999")
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric")
|
|
:width '(190)))
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Other")
|
|
:width '(190))
|
|
(ebox-test-box
|
|
(ebox-test-text "Still here")
|
|
:width '(190))))))
|
|
(target (car (ebox-selector-query-all layout "#target")))
|
|
(target-id (plist-get target :region-id)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox-test--region-update target-id
|
|
:width '(210)
|
|
:min-width '(150)
|
|
:max-width '(260))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should-not (plist-get report :root-rerender))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
|
|
|
|
(defun ebox-test--flex-line-width-owner-layout (&optional count width)
|
|
"Return a wide flex layout whose first row has a nested width target."
|
|
(let* ((count (or count 12))
|
|
(resolved-width (if (eq width :auto)
|
|
nil
|
|
(or width '(2600))))
|
|
(target
|
|
(ebox-test-box :id "target" (ebox-test-text "width target\nmoves sibling layout")
|
|
:width '(160) :height 3 :padding '(0 (8))
|
|
:border "#999999"))
|
|
(first
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Title") :width '(190))
|
|
target
|
|
(ebox-test-box (ebox-test-text "Metric") :width '(190) :height 4))))
|
|
(apply #'ebox-test-flex
|
|
(append (list :flex-flow '(row wrap)
|
|
:column-gap '(12)
|
|
:row-gap 1
|
|
:padding '(0 (12))
|
|
:border "#999999")
|
|
(when resolved-width
|
|
(list :width resolved-width))
|
|
(list first)
|
|
(cl-loop for index from 1 below count
|
|
collect
|
|
(ebox-test-column
|
|
(ebox-test-box
|
|
(ebox-test-text (format "Title %02d" index))
|
|
:width '(190))
|
|
(ebox-test-box (ebox-test-text "Peer\nsame row")
|
|
:width '(190) :height 3
|
|
:padding '(0 (8))
|
|
:border "#999999")
|
|
(ebox-test-box (ebox-test-text "Metric")
|
|
:width '(190) :height 4)))))))
|
|
|
|
(ert-deftest ebox-flex-owner-update-keeps-unrelated-snapshots-current ()
|
|
"A scoped flex update should leave unrelated snapshots valid."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--flex-line-width-owner-layout 16)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))
|
|
(unrelated-child-id
|
|
(plist-get
|
|
(car (last (ebox--node-children
|
|
(ebox-test--runtime-root (current-buffer)))))
|
|
:node-id)))
|
|
(ebox-test--region-update region-id
|
|
:width '(210)
|
|
:min-width '(150)
|
|
:max-width '(260))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (plist-get report :tp-scope-count) 0))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should
|
|
(plist-get
|
|
(ebox--ensure-layout-snapshot-spans
|
|
(current-buffer) unrelated-child-id)
|
|
:buffer-spans))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(defun ebox-test--dynamic-update-row-layout ()
|
|
"Return a small flex-row layout shaped like the dynamic update matrix."
|
|
(ebox-test-column :id 'root :width '(1200) :padding '(1 (18))
|
|
(ebox-test-flex :id 'matrix :flex-flow '(row wrap)
|
|
:column-gap '(12) :row-gap 1
|
|
:padding '(0 (12)) :border "#999999"
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Reference\nshort")
|
|
:width '(190))
|
|
(ebox-test-box :id
|
|
'scroll
|
|
(ebox-test-text "line 1\nline 2\nline 3\nline 4")
|
|
:width
|
|
'(190)
|
|
:height
|
|
2
|
|
:padding
|
|
'(0
|
|
(8))
|
|
:border
|
|
"#999999"
|
|
:overflow
|
|
'scroll)
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric")
|
|
:width '(190)
|
|
:height 4))
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Height\nowner")
|
|
:width '(190))
|
|
(ebox-test-box :id
|
|
'height
|
|
(ebox-test-text "height target\nexpands block")
|
|
:width
|
|
'(190)
|
|
:height
|
|
2
|
|
:padding
|
|
'(0
|
|
(8))
|
|
:border
|
|
"#999999")
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric")
|
|
:width '(190)
|
|
:height 4))
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Margin\nslot stable")
|
|
:width '(190))
|
|
(ebox-test-box :id
|
|
'margin
|
|
(ebox-test-text "margin target\nparent placement")
|
|
:width
|
|
'(170)
|
|
:height
|
|
3
|
|
:padding
|
|
'(0
|
|
(8))
|
|
:border
|
|
"#999999")
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric")
|
|
:width '(190)
|
|
:height 4))
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Overflow\nowner")
|
|
:width '(190))
|
|
(ebox-test-box :id
|
|
'overflow
|
|
(ebox-test-text "overflow target\nextra line\nanother line")
|
|
:width
|
|
'(190)
|
|
:height
|
|
2
|
|
:padding
|
|
'(0
|
|
(8))
|
|
:border
|
|
"#999999"
|
|
:overflow
|
|
'scroll)
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric")
|
|
:width '(190)
|
|
:height 4))
|
|
(ebox-test-column :width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Tall peer\nkeeps row slot")
|
|
:width '(190))
|
|
(ebox-test-box
|
|
(ebox-test-text "tall\npeer\nsets\nline\ncross")
|
|
:width '(190)
|
|
:height 5
|
|
:padding '(0 (8))
|
|
:border "#999999")
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric")
|
|
:width '(190)
|
|
:height 4)))))
|
|
|
|
(defun ebox-test--dynamic-update-tight-row-layout ()
|
|
"Return a flex-row layout whose target growth exceeds its old item slot."
|
|
(ebox-test-column :id 'root :width '(720)
|
|
(ebox-test-box (ebox-test-text "Header") :width '(720))
|
|
(ebox-test-column :id 'panel :width '(720) :border
|
|
"#999999"
|
|
(ebox-test-flex :id 'matrix
|
|
:flex-flow
|
|
'(row wrap)
|
|
:column-gap '(12)
|
|
:row-gap 1
|
|
:padding '(0 (12))
|
|
:border "#999999"
|
|
(ebox-test-column
|
|
:width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Height\nowner")
|
|
:width '(190))
|
|
(ebox-test-box
|
|
:id 'height
|
|
(ebox-test-text "height target\nexpands block")
|
|
:width '(190)
|
|
:height 2
|
|
:padding
|
|
'(0 (8)) :border
|
|
"#999999")
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric") :width
|
|
'(190) :height 2))
|
|
(ebox-test-column
|
|
:width '(210)
|
|
:border "#999999"
|
|
(ebox-test-box
|
|
(ebox-test-text "Peer")
|
|
:width '(190))
|
|
(ebox-test-box
|
|
(ebox-test-text "peer\nsame row")
|
|
:width '(190)
|
|
:height 2
|
|
:padding
|
|
'(0 (8)) :border
|
|
"#999999")
|
|
(ebox-test-box
|
|
(ebox-test-text "Metric") :width
|
|
'(190) :height 2))))))
|
|
|
|
(defun ebox-test--selector-region-id (buffer selector)
|
|
"Return the first editable region id matching SELECTOR in BUFFER."
|
|
(plist-get (car (ebox-selector-query-buffer buffer selector)) :region-id))
|
|
|
|
(defun ebox-test--visual-line-signature (line)
|
|
"Return LINE's visible glyph positions and total pixel width."
|
|
(let ((pixel 0)
|
|
glyphs)
|
|
(cl-loop for index below (length line)
|
|
for next = (1+ index)
|
|
for char = (aref line index)
|
|
for width = (ebox--substring-pixel-width line index next)
|
|
do (progn
|
|
(unless (eq char ?\s)
|
|
(push (cons pixel char) glyphs))
|
|
(setq pixel (+ pixel width))))
|
|
(list :width pixel :glyphs (nreverse glyphs))))
|
|
|
|
(defun ebox-test--rendered-visually-equal-p (actual expected)
|
|
"Return non-nil when ACTUAL and EXPECTED have the same visible layout."
|
|
(let ((actual-lines (ebox-string-lines actual))
|
|
(expected-lines (ebox-string-lines expected))
|
|
(ok t))
|
|
(setq ok (= (length actual-lines) (length expected-lines)))
|
|
(while (and ok actual-lines expected-lines)
|
|
(setq ok (equal (ebox-test--visual-line-signature (car actual-lines))
|
|
(ebox-test--visual-line-signature (car expected-lines)))
|
|
actual-lines (cdr actual-lines)
|
|
expected-lines (cdr expected-lines)))
|
|
ok))
|
|
|
|
(defun ebox-test--buffer-visually-matches-runtime-render-p ()
|
|
"Return non-nil when current buffer visually equals its runtime root render."
|
|
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
|
(ebox--scroll-global-state (make-hash-table :test 'equal))
|
|
(ebox--region-box-table (make-hash-table :test 'equal))
|
|
(expected
|
|
(ebox--with-buffer-render-context (current-buffer)
|
|
(ebox--render-node
|
|
(plist-get state :root-node)
|
|
(plist-get state :source-index)))))
|
|
(ebox-test--rendered-visually-equal-p
|
|
(buffer-substring (point-min) (point-max))
|
|
expected)))
|
|
|
|
(defun ebox-test--buffer-propertized-matches-runtime-render-p ()
|
|
"Return non-nil when the current buffer exactly matches a runtime render."
|
|
(let* ((state (ebox--buffer-render-state (current-buffer)))
|
|
(ebox--scroll-global-state (make-hash-table :test 'equal))
|
|
(ebox--region-box-table (make-hash-table :test 'equal))
|
|
(expected
|
|
(ebox--with-buffer-render-context (current-buffer)
|
|
(ebox--render-node
|
|
(plist-get state :root-node)
|
|
(plist-get state :source-index)))))
|
|
(equal-including-properties
|
|
(buffer-substring (point-min) (point-max)) expected)))
|
|
|
|
(defun ebox-test--assert-scoped-tp-publication (&optional report)
|
|
"Assert REPORT describes a scoped TP publication without root fallback."
|
|
(let ((report (or report (ebox-test--buffer-update-report))))
|
|
(should (plist-get report :runtime-published))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (or (plist-get report :tp-scope-count) 0) 0))
|
|
(should-not (plist-get report :tp-scope-fallback))))
|
|
|
|
(defun ebox-test--assert-buffer-runtime-current (&optional report)
|
|
"Assert REPORT published a current buffer matching its Ebox runtime."
|
|
(ebox-test--assert-scoped-tp-publication report)
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))
|
|
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))
|
|
|
|
(ert-deftest ebox-scroll-offset-fast-path-preserves-line-display-properties ()
|
|
"TP scroll publication must preserve per-character display properties."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id 'scroll (ebox-test-text "第 1 行\n第 2 行\n第 3 行\n第 4 行") :width '(230)
|
|
:height 2 :padding '(0 (10)) :border "#9CA3AF" :bgcolor
|
|
"#F8FAFC" :color "#3D3A35" :text-align 'center
|
|
:overflow 'scroll :scroll-offset 0)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#scroll"))
|
|
(surface ebox-surface--buffer-surface))
|
|
(let ((revision (tp-surface-revision surface)))
|
|
(ebox-test--region-update region-id :scroll-offset 2)
|
|
(should (= (tp-surface-revision surface) (1+ revision))))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (plist-get report :tp-scope-count) 0))
|
|
(should-not (plist-get report :tp-scope-fallback)))
|
|
(should-not
|
|
(cl-loop for pos from (point-min) below (point-max)
|
|
thereis (and (not (eq (char-after pos) ?\s))
|
|
(get-text-property pos 'display))))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
|
|
|
|
(ert-deftest ebox-scroll-offset-fast-path-requires-stable-marker-lengths ()
|
|
"Scroll marker patches should not shift later buffer positions."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id 'scroll (ebox-test-text "x\nsubstantially longer visible line") :width '(180)
|
|
:height 1 :overflow 'scroll :scroll-offset 0)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#scroll"))
|
|
(state (ebox--scroll-get-state region-id))
|
|
(lines (plist-get state :content-lines)))
|
|
(should (not (= (length (nth 0 lines))
|
|
(length (nth 1 lines)))))
|
|
(ebox-test--region-update region-id :scroll-offset 1)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should-not (plist-get report :scroll-fast-path))
|
|
(let ((state (ebox-scroll-state region-id)))
|
|
(should (= (plist-get state :scroll-offset) 1))
|
|
(should-not (plist-get state :lazy-scroll-prefix-dirty))
|
|
(should-not
|
|
(plist-get state :lazy-scroll-window-refresh-required)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-nested-scroll-offset-transaction-replays-outer-cache ()
|
|
"Nested scroll updates should atomically survive outer cache replay."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id 'root :width '(180) :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id 'inner :width '(180) :height 2
|
|
:overflow 'scroll (ebox-test-text "inner 0\ninner 1\ninner 2\ninner 3"))
|
|
(ebox-test-box (ebox-test-text "outer 0\nouter 1") :height 2))))
|
|
root-id inner-id)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root")
|
|
inner-id (ebox-test--selector-region-id (current-buffer) "#inner"))
|
|
(ebox-test--region-update inner-id :scroll-offset 1)
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(state (ebox-scroll-state inner-id))
|
|
(plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (= (plist-get state :scroll-offset) 1))
|
|
(should (string-match-p "inner 1" plain))
|
|
(should (string-match-p "inner 2" plain))
|
|
(should-not (string-match-p "inner 0" plain)))
|
|
(should (ebox--scroll-region-by root-id 2 48))
|
|
(should (string-match-p
|
|
"outer 0"
|
|
(buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (ebox--scroll-region-by root-id -2 48))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "inner 1" plain))
|
|
(should (string-match-p "inner 2" plain))
|
|
(should-not (string-match-p "inner 0" plain)))
|
|
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 1))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-scroll-child-layout-window-publishes-through-tp ()
|
|
"Child-layout scroll boxes should publish one retained viewport update."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "a" (ebox-test-text "A") :height 1)
|
|
(ebox-test-box :id "b" (ebox-test-text "B") :height 1)
|
|
(ebox-test-box :id "c" (ebox-test-text "C") :height 1)
|
|
(ebox-test-box :id "d" (ebox-test-text "D") :height 1))))
|
|
(root-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(surface ebox-surface--buffer-surface)
|
|
(revision (tp-surface-revision surface)))
|
|
(should (= (plist-get state :content-height) 2))
|
|
(should (ebox-surface-region-mounts (current-buffer) root-id))
|
|
(should (= (ebox--scroll-region-by root-id 1) 1))
|
|
(should (= (tp-surface-revision surface) (1+ revision))))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(let ((plain (buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (= (length (ebox-string-lines plain)) 2))
|
|
(should (string-match-p "B" plain))
|
|
(should (string-match-p "C" plain))
|
|
(should-not (string-match-p "A" plain)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-scroll-child-layout-window-keeps-viewport-padding-and-border ()
|
|
"Fast scroll window patches should keep scroll box padding and border."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :height 4 :box-sizing 'border-box :overflow
|
|
'scroll :padding-top 1 :padding-bottom 1 :border-top-p
|
|
t :border-top-color "red"
|
|
:border-bottom-width 1 :border-bottom-style 'solid
|
|
:border-bottom-color "blue"
|
|
(ebox-test-column
|
|
(ebox-test-box :id "a" (ebox-test-text "A") :height 1)
|
|
(ebox-test-box :id "b" (ebox-test-text "B") :height 1)
|
|
(ebox-test-box :id "c" (ebox-test-text "C") :height 1)
|
|
(ebox-test-box :id "d" (ebox-test-text "D") :height 1))))
|
|
(root-id (car (ebox-region-ids layout)))
|
|
)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(should (plist-get (ebox--scroll-get-state root-id)
|
|
:rendered-content-lines))
|
|
(should (ebox--scroll-region-by root-id 1))
|
|
(should (equal (get-text-property (point-min) 'ebox-bt)
|
|
root-id))
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(forward-line 3)
|
|
(should (equal (get-text-property (point) 'ebox-bb)
|
|
root-id)))
|
|
(should (equal (get-text-property (point-min) 'ebox-pt)
|
|
root-id))
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(forward-line 3)
|
|
(should (equal (get-text-property (point) 'ebox-pb)
|
|
root-id)))
|
|
(let ((plain (buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (= (length (ebox-string-lines plain)) 4))
|
|
(should (string-match-p "B" plain))
|
|
(should (string-match-p "C" plain))
|
|
(should-not (string-match-p "A" plain))))))
|
|
|
|
(ert-deftest ebox-flex-row-height-update-rerenders-slot-owner-not-root ()
|
|
"Flex row item height changes should patch the old row slot, not the root."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--dynamic-update-row-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#height"))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox-test--region-update region-id
|
|
:height 4 :min-height 3 :max-height 6
|
|
:padding-top 1 :padding-bottom 1)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(span-patch owner-rerender)))
|
|
(should-not (plist-get report :root-rerender))
|
|
(should-not (member root-id (plist-get report :owner-ids)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-flex-row-height-growth-rerenders-flex-ancestor-not-root ()
|
|
"Flex row item growth beyond its old slot should use a flex ancestor fallback."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--dynamic-update-tight-row-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#height"))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox-test--region-update region-id
|
|
:height 4 :min-height 3 :max-height 6
|
|
:padding-top 1 :padding-bottom 1)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (equal (plist-get report :owner-ids) (list root-id)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-flex-row-inline-margin-update-uses-slot-span-patch ()
|
|
"Inline margin changes inside a stable flex row slot should stay span-local."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--dynamic-update-row-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((height-id (ebox-test--selector-region-id (current-buffer)
|
|
"#height"))
|
|
(region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#margin"))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox-test--region-update height-id
|
|
:height 4 :min-height 3 :max-height 6
|
|
:padding-top 1 :padding-bottom 1)
|
|
(ebox-test--region-update region-id
|
|
:margin-inline '((8) (8)))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (eq (plist-get report :owner-type) 'box))
|
|
(should-not (plist-get report :root-rerender))
|
|
(should-not (member root-id (plist-get report :owner-ids)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-border-box-padding-impact-stays-internal-fragment ()
|
|
"Fixed border-box padding changes should remain an internal fragment impact."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-column :id 'root :width '(480)
|
|
(ebox-test-flex :id 'row :flex-flow '(row nowrap)
|
|
:column-gap '(12)
|
|
(ebox-test-box :id 'padding (ebox-test-text "padding target")
|
|
:width '(190) :height
|
|
3 :box-sizing
|
|
'border-box :padding
|
|
'(0 (8)) :border
|
|
"#999999")
|
|
(ebox-test-box (ebox-test-text "peer")
|
|
:width '(190) :height
|
|
3 :border "#999999")))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#padding")))
|
|
(ebox-test--region-update region-id :padding-left (list 24))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (member 'internal-fragment
|
|
(plist-get report :impact-vector)))
|
|
(should-not (member 'parent-layout
|
|
(plist-get report :impact-vector)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-border-box-padding-shorthand-uses-one-local-span-patch ()
|
|
"Mixed block/inline padding shorthand should not promote to a root rerender."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-column :id 'root :width '(480)
|
|
(ebox-test-flex :id 'row :flex-flow '(row nowrap)
|
|
:column-gap '(12)
|
|
(ebox-test-box :id 'padding (ebox-test-text "padding target")
|
|
:width '(190) :height
|
|
4 :box-sizing
|
|
'border-box :padding
|
|
'(0 (8)) :border
|
|
"#999999")
|
|
(ebox-test-box (ebox-test-text "peer")
|
|
:width '(190) :height
|
|
4 :border "#999999")))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((match (car (ebox-selector-query-buffer
|
|
(current-buffer) "#padding")))
|
|
(node-id (ebox--ensure-node-id (plist-get match :node)))
|
|
(region-id (plist-get match :region-id))
|
|
(mixed-entry
|
|
(list :node-id node-id :dirty-kind 'geometry
|
|
:changed-keys
|
|
'(:padding-top-height :padding-right-pixel
|
|
:padding-bottom-height :padding-left-pixel))))
|
|
(should
|
|
(ebox--dirty-entry-border-box-internal-span-p
|
|
(current-buffer) mixed-entry))
|
|
(ebox-test--region-update region-id :padding '(2 (20) 0 (8)))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (equal (plist-get report :patch-ops) '(span-patch)))
|
|
(should-not (plist-get report :root-rerender))
|
|
(should (member 'internal-fragment
|
|
(plist-get report :impact-vector)))
|
|
(should (member 'external-footprint-proof
|
|
(plist-get report :impact-vector)))
|
|
(should-not (member 'parent-layout
|
|
(plist-get report :impact-vector)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-border-box-padding-span-proof-rejects-overfull-height ()
|
|
"Border-box padding cannot claim a fixed footprint without a content line."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id 'padding (ebox-test-text "padding target") :width '(190)
|
|
:height 4 :box-sizing 'border-box :padding 2 :border
|
|
"#999999")))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((match (car (ebox-selector-query-buffer
|
|
(current-buffer) "#padding")))
|
|
(node-id (ebox--ensure-node-id (plist-get match :node))))
|
|
(let ((entry
|
|
(list :node-id node-id :dirty-kind 'geometry
|
|
:changed-keys
|
|
'(:padding-top-height :padding-right-pixel
|
|
:padding-bottom-height :padding-left-pixel))))
|
|
(should-not
|
|
(ebox--dirty-entry-border-box-internal-span-p
|
|
(current-buffer) entry)))))))
|
|
|
|
(ert-deftest ebox-overflow-impact-keeps-conservative-owner-rerender ()
|
|
"Overflow changes should stay conservative until clipping proof exists."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--dynamic-update-row-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#overflow")))
|
|
(ebox-test--region-update region-id :overflow 'hidden)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (member 'clipping (plist-get report :impact-vector)))
|
|
(should (member 'external-footprint
|
|
(plist-get report :impact-vector)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-overflow-impact-skips-span-patch-owner-probe ()
|
|
"Clipping changes should not spend time proving impossible span patches."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--dynamic-update-row-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#overflow"))
|
|
(span-probe-count 0)
|
|
(original (symbol-function 'ebox--span-patchable-owner-p)))
|
|
(cl-letf (((symbol-function 'ebox--span-patchable-owner-p)
|
|
(lambda (&rest args)
|
|
(cl-incf span-probe-count)
|
|
(apply original args))))
|
|
(ebox-test--region-update region-id :overflow 'hidden))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (member 'clipping (plist-get report :impact-vector)))
|
|
(should (= span-probe-count 0))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-overflow-impact-skips-patchable-owner-promotion ()
|
|
"Clipping changes should not pre-prove conservative owner candidates."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--dynamic-update-row-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#overflow"))
|
|
(promote-count 0)
|
|
(original (symbol-function 'ebox--promote-to-patchable-owner)))
|
|
(cl-letf (((symbol-function 'ebox--promote-to-patchable-owner)
|
|
(lambda (&rest args)
|
|
(cl-incf promote-count)
|
|
(apply original args))))
|
|
(ebox-test--region-update region-id :overflow 'visible))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (member 'clipping (plist-get report :impact-vector)))
|
|
(should (= promote-count 0))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-flex-row-visible-overflow-update-rerenders-slot-owner-not-root ()
|
|
"Visible overflow that fits the existing flex row slot should not rerender root."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--dynamic-update-row-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id (current-buffer)
|
|
"#overflow"))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox-test--region-update region-id :overflow 'visible)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should-not (plist-get report :root-rerender))
|
|
(should-not (member root-id (plist-get report :owner-ids)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(let* ((node (ebox--buffer-region-render-owner-node
|
|
(current-buffer) region-id))
|
|
(snapshot (ebox--ensure-layout-snapshot-spans
|
|
(current-buffer) (ebox--ensure-node-id node))))
|
|
(should (ebox--layout-snapshot-spans-p snapshot))
|
|
(should (eq (plist-get (plist-get snapshot :style-signature)
|
|
:overflow)
|
|
'visible)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-overflow-reversal-skips-layout-only-ancestor ()
|
|
"Old visible suffix coverage should skip ancestors without wrapper ownership."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((target (ebox-test-box (ebox-test-text "A\nB\nC") :height 1
|
|
:overflow 'visible))
|
|
(region-id (car (ebox-region-ids target)))
|
|
(inner (ebox-test-column target (ebox-test-box (ebox-test-text "TAIL"))))
|
|
(layout (ebox-test-column inner (ebox-test-box (ebox-test-text "ROOT TAIL"))))
|
|
(inner-id (ebox--ensure-node-id (ebox-test-root inner)))
|
|
(root-id (ebox--ensure-node-id (ebox-test-root layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox--clear-layout-snapshots (current-buffer))
|
|
(ebox-test--region-update region-id :overflow 'hidden)
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(plain (buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should-not (member inner-id (plist-get report :owner-ids)))
|
|
(should (equal (plist-get report :owner-ids) (list root-id)))
|
|
(should-not (string-match-p "[BC]" plain))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
|
|
|
|
(ert-deftest ebox-overflow-reversal-skips-visible-wrapper-ancestor ()
|
|
"A visible wrapper cannot cover its own old unowned overflow suffix."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((target (ebox-test-box (ebox-test-text "A\nB\nC") :height 1
|
|
:overflow 'visible))
|
|
(region-id (car (ebox-region-ids target)))
|
|
(wrapper (ebox-test-box target :width '(100)
|
|
:height 1 :overflow 'visible))
|
|
(layout (ebox-test-column wrapper (ebox-test-box (ebox-test-text "TAIL"))))
|
|
(wrapper-id (ebox--ensure-node-id (ebox-test-root wrapper)))
|
|
(root-id (ebox--ensure-node-id (ebox-test-root layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox--clear-layout-snapshots (current-buffer))
|
|
(ebox-test--region-update region-id :overflow 'hidden)
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(plain (buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should-not (member wrapper-id (plist-get report :owner-ids)))
|
|
(should (equal (plist-get report :owner-ids) (list root-id)))
|
|
(should-not (string-match-p "[BC]" plain))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
|
|
|
|
(ert-deftest ebox-overflow-coverage-crosses-enclosing-visible-wrapper ()
|
|
"Coverage should climb above an enclosing visible wrapper's old footprint."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-column :id 'root :width '(100)
|
|
(ebox-test-box :id 'outer :width '(100) :height 2
|
|
:overflow 'visible
|
|
(ebox-test-box :id 'inner :width
|
|
'(100)
|
|
(ebox-test-box :id
|
|
'target
|
|
(ebox-test-text "A\nB\nC")
|
|
:height
|
|
1
|
|
:overflow
|
|
'visible)))
|
|
(ebox-test-box (ebox-test-text "TAIL")))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((region-id (ebox-test--selector-region-id
|
|
(current-buffer) "#target"))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox--clear-layout-snapshots (current-buffer))
|
|
(ebox-test--region-update region-id :overflow 'hidden)
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(target-node
|
|
(ebox--buffer-region-render-owner-node
|
|
(current-buffer) region-id))
|
|
(outer-node
|
|
(plist-get (car (ebox-selector-query-buffer
|
|
(current-buffer) "#outer"))
|
|
:node)))
|
|
(should (eq (ebox-get (ebox-fragment-style-source-node target-node)
|
|
:overflow)
|
|
'hidden))
|
|
(should (eq (ebox-get (ebox-fragment-style-source-node outer-node)
|
|
:overflow)
|
|
'visible))
|
|
(should (equal (plist-get report :owner-ids) (list root-id)))
|
|
(should-not (plist-get report :flex-line-rerender))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-overflow-coverage-root-flex-replaces-whole-footprint ()
|
|
"Root flex fallback should bypass line rerender for visible overflow changes."
|
|
(dolist (transition '((scroll visible) (visible hidden)))
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((old-overflow (car transition))
|
|
(new-overflow (cadr transition))
|
|
(layout
|
|
(ebox-test-flex :id 'root :width '(200)
|
|
(ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :width
|
|
'(80) :height 1 :overflow old-overflow)
|
|
(ebox-test-box :id 'peer (ebox-test-text "P") :width '(80)
|
|
:height 1))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((region-id (ebox-test--selector-region-id
|
|
(current-buffer) "#target"))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox--clear-layout-snapshots (current-buffer))
|
|
(ebox-test--region-update region-id :overflow new-overflow)
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(target-node
|
|
(ebox--buffer-region-render-owner-node
|
|
(current-buffer) region-id)))
|
|
(should (eq (ebox-get (ebox-fragment-style-source-node target-node)
|
|
:overflow)
|
|
new-overflow))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (equal (plist-get report :owner-ids) (list root-id)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))))
|
|
|
|
(ert-deftest ebox-overflow-coverage-promotes-partial-flex-item ()
|
|
"Visible overflow changes should not preserve a flex item's stale cross size."
|
|
(dolist (transition '((scroll visible) (visible hidden)))
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((old-overflow (car transition))
|
|
(new-overflow (cadr transition))
|
|
(layout
|
|
(ebox-test-flex :id 'root :width '(220)
|
|
(ebox-test-box :id 'card :width '(100)
|
|
(ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :width '(80)
|
|
:height 1 :overflow
|
|
old-overflow))
|
|
(ebox-test-box :id 'peer (ebox-test-text "P") :width '(80)
|
|
:height 1))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((region-id (ebox-test--selector-region-id
|
|
(current-buffer) "#target"))
|
|
(card-id (plist-get (car (ebox-selector-query-buffer
|
|
(current-buffer) "#card"))
|
|
:node-id))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox--clear-layout-snapshots (current-buffer))
|
|
(ebox-test--region-update region-id :overflow new-overflow)
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(target-node
|
|
(ebox--buffer-region-render-owner-node
|
|
(current-buffer) region-id)))
|
|
(should (eq (ebox-get (ebox-fragment-style-source-node target-node)
|
|
:overflow)
|
|
new-overflow))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should-not (member card-id (plist-get report :owner-ids)))
|
|
(should (equal (plist-get report :owner-ids) (list root-id)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))))
|
|
|
|
(ert-deftest ebox-overflow-coverage-batch-preserves-old-style ()
|
|
"A batch should carry pre-mutation overflow into coverage planning."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-column :id 'root :width '(100)
|
|
(ebox-test-box :id 'wrapper :width '(100)
|
|
(ebox-test-column
|
|
(ebox-test-box :id 'target (ebox-test-text "A\nB\nC") :height 1
|
|
:overflow 'visible)
|
|
(ebox-test-box (ebox-test-text "TAIL"))))
|
|
(ebox-test-box (ebox-test-text "ROOT TAIL")))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((region-id (ebox-test--selector-region-id
|
|
(current-buffer) "#target"))
|
|
(root-id (ebox--buffer-root-node-id (current-buffer))))
|
|
(ebox--clear-layout-snapshots (current-buffer))
|
|
(ebox-incremental-begin-batch (current-buffer))
|
|
(ebox-test--region-update region-id :overflow 'hidden)
|
|
(let* ((state (ebox-incremental--batch-state (current-buffer)))
|
|
(pending (car (plist-get state :pending))))
|
|
(should (equal (plist-get pending :old-style-values)
|
|
'(:overflow visible))))
|
|
(ebox-incremental-flush (current-buffer))
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(target-node
|
|
(ebox--buffer-region-render-owner-node
|
|
(current-buffer) region-id)))
|
|
(should (eq (ebox-get (ebox-fragment-style-source-node target-node)
|
|
:overflow)
|
|
'hidden))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (equal (plist-get report :owner-ids) (list root-id)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-overflow-coverage-multi-owner-batch-normalizes-scoped-snapshots ()
|
|
"Multiple owner replacements normalize shifted spans before scoped refresh."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-column :id 'root :width '(760)
|
|
(ebox-test-flex :id 'top-matrix :width '(600)
|
|
:height 5 :flex-flow '(row nowrap)
|
|
(ebox-test-box :id 'top-card :width
|
|
'(300)
|
|
(ebox-test-box :id
|
|
'top-target
|
|
(ebox-test-text "top A\nsubstantially longer top overflow\ntop C")
|
|
:width
|
|
'(280)
|
|
:height
|
|
2
|
|
:overflow
|
|
'scroll))
|
|
(ebox-test-box (ebox-test-text "top P\ntop Q\ntop R\ntop S\ntop T")
|
|
:width '(300) :height
|
|
5))
|
|
(ebox-test-flex :id 'bottom-matrix :width '(600)
|
|
:height 5 :flex-flow '(row nowrap)
|
|
(ebox-test-box :id 'bottom-card
|
|
:width '(300)
|
|
(ebox-test-box :id
|
|
'bottom-target
|
|
(ebox-test-text "bottom A\nbottom overflow\nbottom C")
|
|
:width
|
|
'(280)
|
|
:height
|
|
2
|
|
:overflow
|
|
'scroll))
|
|
(ebox-test-box (ebox-test-text "bottom P\nbottom Q\nbottom R\nbottom S\nbottom T")
|
|
:width '(300) :height
|
|
5)))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((top-region-id
|
|
(ebox-test--selector-region-id (current-buffer) "#top-target"))
|
|
(bottom-region-id
|
|
(ebox-test--selector-region-id
|
|
(current-buffer) "#bottom-target")))
|
|
(ebox-incremental-begin-batch (current-buffer))
|
|
(ebox-test--region-update top-region-id :overflow 'visible)
|
|
(ebox-test--region-update bottom-region-id :overflow 'visible)
|
|
(ebox-incremental-flush (current-buffer))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (= (plist-get report :patch-count) 2))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (plist-get report :tp-scope-count) 0))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(dolist (owner-id (plist-get report :owner-ids))
|
|
(let* ((node (ebox--buffer-runtime-node
|
|
(current-buffer) owner-id))
|
|
(snapshot-spans
|
|
(plist-get (ebox--ensure-layout-snapshot-spans
|
|
(current-buffer) owner-id)
|
|
:buffer-spans))
|
|
(actual-spans
|
|
(ebox--region-set-line-spans
|
|
(ebox--node-all-region-ids node))))
|
|
(should (equal snapshot-spans actual-spans))))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))
|
|
(ebox-test--region-update bottom-region-id :overflow 'hidden)
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
|
|
|
|
(ert-deftest ebox-span-patch-keeps-descendant-mount-snapshots-current ()
|
|
"Ancestor updates should keep descendant snapshots aligned with TP mounts."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--dynamic-update-row-layout)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((handle (car (ebox-selector-query-buffer
|
|
(current-buffer) "#margin")))
|
|
(region-id (plist-get handle :region-id))
|
|
(node-id (plist-get handle :node-id)))
|
|
(ebox-test--region-update region-id
|
|
:margin-inline '((8) (8)))
|
|
(let* ((report (ebox-test--buffer-update-report))
|
|
(snapshot (ebox--ensure-layout-snapshot-spans
|
|
(current-buffer) node-id))
|
|
(snapshot-spans (plist-get snapshot :buffer-spans))
|
|
(node (ebox--buffer-runtime-node (current-buffer) node-id))
|
|
(actual-spans (ebox--region-set-line-spans
|
|
(ebox--node-all-region-ids node))))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (equal snapshot-spans actual-spans))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))))
|
|
|
|
(ert-deftest ebox-flex-child-width-expansion-reflows-ancestor-after-span-fallback ()
|
|
"Failed promoted span patches must fall back to a layout-safe ancestor."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-flex :id 'matrix :width '(680) :flex-flow '(row wrap)
|
|
:column-gap '(12) :row-gap 1
|
|
(ebox-test-column :width '(210) :border "#999999"
|
|
(ebox-test-box (ebox-test-text "Title A")
|
|
:width '(190))
|
|
(ebox-test-box :id 'target (ebox-test-text "width target\nmoves sibling layout")
|
|
:width '(160) :height
|
|
3 :padding '(0 (8))
|
|
:border "#999999"))
|
|
(ebox-test-column :width '(210) :border "#999999"
|
|
(ebox-test-box (ebox-test-text "Title B")
|
|
:width '(190))
|
|
(ebox-test-box (ebox-test-text "Still here")
|
|
:width '(190)))
|
|
(ebox-test-column :width '(210) :border "#999999"
|
|
(ebox-test-box (ebox-test-text "Title C")
|
|
:width '(190))
|
|
(ebox-test-box (ebox-test-text "Third card")
|
|
:width '(190))))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((match (car (ebox-selector-query-buffer
|
|
(current-buffer) "#target")))
|
|
(region-id (plist-get match :region-id)))
|
|
(ebox-test--region-update region-id :width '(250))
|
|
(let* ((patched (buffer-string))
|
|
(full
|
|
(ebox--with-buffer-render-context (current-buffer)
|
|
(ebox--render-node
|
|
(ebox--buffer-root-node (current-buffer))
|
|
(plist-get (ebox--buffer-render-state (current-buffer))
|
|
:source-index)))))
|
|
(should (string= (substring-no-properties patched)
|
|
(substring-no-properties full))))))))
|
|
|
|
(ert-deftest ebox-rendered-owned-lines-trims-only-contiguous-outer-chrome ()
|
|
"Role projection keeps owned lines and rejects an unowned interior gap."
|
|
(let* ((owned-a (propertize "owned-a" 'ebox-content 7))
|
|
(owned-b (propertize "owned-b" 'ebox-content 7))
|
|
(projected
|
|
(ebox-buffer--rendered-owned-lines
|
|
(string-join (list "chrome-top" owned-a owned-b "chrome-bottom")
|
|
"\n")
|
|
'(7))))
|
|
(should (equal (substring-no-properties projected)
|
|
"owned-a\nowned-b"))
|
|
(should-not
|
|
(ebox-buffer--rendered-owned-lines
|
|
(string-join (list owned-a "foreign-gap" owned-b) "\n") '(7)))
|
|
(should-not
|
|
(ebox-buffer--rendered-owned-lines
|
|
(concat owned-a (propertize "foreign" 'ebox-content 8)) '(7)))
|
|
(should
|
|
(equal
|
|
(substring-no-properties
|
|
(ebox-buffer--rendered-owned-lines
|
|
(string-join
|
|
(list (propertize "border-top" 'ebox-bt 7)
|
|
owned-a owned-b
|
|
(propertize "border-bottom" 'ebox-bb 7))
|
|
"\n")
|
|
'(7) 2))
|
|
"owned-a\nowned-b"))))
|
|
|
|
(ert-deftest ebox-flex-auto-size-content-update-refuses-current-slot ()
|
|
"Auto-sized content must reflow instead of being forced into its old slot."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-viewport-width 300)
|
|
(layout
|
|
(ebox-test-flex :id 'root :width '(300) :height 1 :flex-flow
|
|
'(row nowrap) :align-items 'flex-start
|
|
(ebox-test-box :id 'target (ebox-test-text "A") :width 'auto
|
|
:height 1 :overflow 'hidden :wrap-mode
|
|
'word)
|
|
(ebox-test-box :id 'peer (ebox-test-text "P") :width '(40)
|
|
:height 1 :flex-grow 0 :flex-shrink 0))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((match (car (ebox-selector-query-buffer
|
|
(current-buffer) "#target")))
|
|
(region-id (plist-get match :region-id))
|
|
(node-id (plist-get match :node-id))
|
|
(node (ebox--buffer-runtime-node (current-buffer) node-id))
|
|
(snapshot (ebox--ensure-layout-snapshot-details
|
|
(current-buffer) node-id)))
|
|
(puthash region-id node ebox--region-box-table)
|
|
(should-not
|
|
(ebox--render-node-in-current-flex-slot
|
|
(current-buffer) node-id snapshot '(:content)))
|
|
(ebox-test--region-update region-id :content "HELLO")
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should-not
|
|
(and (eq (plist-get report :strategy) 'span-patch)
|
|
(equal (plist-get report :owner-ids) (list node-id)))))
|
|
(should (string-match-p
|
|
"HELLO"
|
|
(buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))))
|
|
|
|
(ert-deftest ebox-region-update-stable-geometry-uses-span-patch ()
|
|
"Geometry updates with stable span footprint should use the L2 planner path."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Align") :width '(120) :height 1
|
|
:text-align 'left))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id :text-align 'center)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (equal (plist-get report :patch-ops) '(span-patch)))
|
|
(should (equal (plist-get report :dirty-kinds) '(geometry)))
|
|
(should (eq (plist-get report :owner-type) 'box))
|
|
(should-not (plist-get report :full-rerender))))))
|
|
|
|
(ert-deftest ebox-region-update-inline-child-stable-geometry-uses-span-patch ()
|
|
"Stable internal changes inside an inline child should not rerender siblings."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((left (ebox-test-box (ebox-test-text "Left") :width '(80) :height 1
|
|
:text-align 'left))
|
|
(right (ebox-test-box (ebox-test-text "Right") :width '(80) :height 1))
|
|
(layout (ebox-test-row left right))
|
|
(left-id (car (ebox-region-ids left))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(ebox-test--region-update left-id :text-align 'center)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (equal (plist-get report :patch-ops) '(span-patch)))
|
|
(should (eq (plist-get report :owner-type) 'box))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (plist-get report :tp-scope-count) 0)))
|
|
(should (string-match-p
|
|
"Right"
|
|
(buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-region-update-completes-border-width-style-pairs ()
|
|
"A one-property border delta should retain its computed counterpart."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((input
|
|
(ebox-test-box (ebox-test-text "Border") :width 80
|
|
:border-left '(2 none "#AA0000")))
|
|
(region-id (car (ebox-region-ids input))))
|
|
(ebox-test--with-rendered-buffer input
|
|
(ebox-test--region-update region-id :border-left-style 'solid)
|
|
(let ((box (gethash region-id (ebox--buffer-region-box-table
|
|
(current-buffer)))))
|
|
(should (= (ebox-get box :border-left-pixel) 2)))
|
|
(ebox-test--region-update region-id :border-left-width 3)
|
|
(let ((box (gethash region-id (ebox--buffer-region-box-table
|
|
(current-buffer)))))
|
|
(should (= (ebox-get box :border-left-pixel) 3)))
|
|
(ebox-test--region-update region-id :border-left-style 'none)
|
|
(let ((box (gethash region-id (ebox--buffer-region-box-table
|
|
(current-buffer)))))
|
|
(should (= (ebox-get box :border-left-pixel) 0)))
|
|
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-region-update-projects-outer-and-layout-config ()
|
|
"Region updates should project structural and LayoutConfig facts once."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((input
|
|
(ebox-test-row :id "axis" :outer 'inline :item-gap 1
|
|
(ebox-test-box (ebox-test-text "A"))
|
|
(ebox-test-box (ebox-test-text "B"))))
|
|
(buffer (ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-axis-update*") input)))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-selector-update-buffer
|
|
buffer "#axis" :outer 'block :item-gap 3)
|
|
(let* ((root (plist-get (ebox--buffer-render-state buffer)
|
|
:root-node))
|
|
(props (ebox-layout-config-props
|
|
(ebox-box-node-layout root))))
|
|
(should (equal (plist-get root :display) '(block row)))
|
|
(should (= (plist-get props :item-gap) 3))
|
|
(should (with-current-buffer buffer
|
|
(ebox-test--buffer-propertized-matches-runtime-render-p)))))
|
|
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-region-update-border-box-inline-change-uses-span-patch ()
|
|
"Border-box left/right spacing can be internal when outer width is fixed."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Pad") :width '(120) :height 1
|
|
:box-sizing 'border-box))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id :padding-left '(8))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (equal (plist-get report :patch-ops) '(span-patch)))
|
|
(should-not (plist-get report :full-rerender))))))
|
|
|
|
(ert-deftest ebox-region-update-content-box-inline-change-rerenders-owner ()
|
|
"Content-box left/right spacing changes the parent-facing outer width."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Pad") :width '(120) :height 1
|
|
:box-sizing 'content-box))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id :padding-left '(8))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (equal (plist-get report :patch-ops) '(owner-rerender)))
|
|
(should-not (plist-get report :full-rerender))))))
|
|
|
|
(ert-deftest ebox-region-update-span-candidate-falls-back-on-wrap-change ()
|
|
"A wrap-changing update should publish a correct scoped TP plan."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "abcdefghijklmnopqrstuvwxyz")
|
|
:width '(120)
|
|
:box-sizing 'border-box
|
|
:wrap-mode 'char))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id :padding-left '(100))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(span-patch owner-rerender)))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (plist-get report :tp-scope-count) 0))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should (ebox-test--buffer-propertized-matches-runtime-render-p))))))
|
|
|
|
(ert-deftest ebox-region-update-matrix-uses-only-public-properties ()
|
|
"Region update coverage must not preserve private engine spellings."
|
|
(dolist (property
|
|
(append (ebox-test--region-update-case-properties
|
|
ebox-test--region-update-paint-cases)
|
|
(ebox-test--region-update-case-properties
|
|
ebox-test--region-update-geometry-cases)))
|
|
(should (or (memq property ebox--region-update-control-properties)
|
|
(ebox-style-schema-id property))))
|
|
(dolist (property '(:padding-left-pixel :padding-right-pixel
|
|
:padding-top-height :padding-bottom-height
|
|
:margin-left-pixel :margin-right-pixel
|
|
:margin-top-height :margin-bottom-height
|
|
:border-left-pixel :border-right-pixel
|
|
:border-top-pixel :border-bottom-pixel
|
|
:border-left-style :border-right-style
|
|
:border-top-style :border-bottom-style
|
|
:vertical-align))
|
|
(should-not
|
|
(memq property
|
|
(append (ebox-test--region-update-case-properties
|
|
ebox-test--region-update-paint-cases)
|
|
(ebox-test--region-update-case-properties
|
|
ebox-test--region-update-geometry-cases))))))
|
|
|
|
(ert-deftest ebox-region-update-all-paint-longhands-use-paint-patch ()
|
|
"Every paint longhand should patch text properties without replacing spans."
|
|
(dolist (case ebox-test--region-update-paint-cases)
|
|
(pcase-let ((`(,property ,value ,engine-property ,expected) case))
|
|
(ert-info ((format "property %S" property))
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Paint") :width '(120)
|
|
:height 1))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((full-rerenders
|
|
(ebox-test--count-full-surface-updates
|
|
(ebox-test--region-update region-id property value))))
|
|
(should (= full-rerenders 0)))
|
|
(let ((runtime-box
|
|
(gethash region-id (ebox--buffer-region-box-table
|
|
(current-buffer)))))
|
|
(should (eq (gethash region-id ebox--region-box-table)
|
|
runtime-box))
|
|
(should-not (eq runtime-box box))
|
|
(should (equal (ebox-get runtime-box engine-property)
|
|
expected)))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'paint-patch))
|
|
(should (equal (plist-get report :patch-ops)
|
|
'(paint-patch)))
|
|
(should (equal (plist-get report :dirty-kinds) '(paint)))
|
|
(should-not (plist-get report :full-rerender)))))))))
|
|
|
|
(ert-deftest ebox-region-update-all-geometry-longhands-use-minimal-strategy ()
|
|
"Every geometry longhand should use a correct scoped non-root strategy."
|
|
(dolist (case ebox-test--region-update-geometry-cases)
|
|
(pcase-let
|
|
((`(,property ,value ,engine-property ,expected ,_strategy) case))
|
|
(ert-info ((format "property %S" property))
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Alpha\nBeta\nGamma\nDelta")
|
|
:width '(160)
|
|
:height 2
|
|
:overflow 'scroll
|
|
:wrap-mode 'word))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((full-rerenders
|
|
(ebox-test--count-full-surface-updates
|
|
(ebox-test--region-update region-id property value))))
|
|
(should (= full-rerenders 0)))
|
|
(let ((runtime-box
|
|
(gethash region-id (ebox--buffer-region-box-table
|
|
(current-buffer)))))
|
|
(should (eq (gethash region-id ebox--region-box-table)
|
|
runtime-box))
|
|
(should-not (eq runtime-box box))
|
|
(should (equal (ebox-get runtime-box engine-property)
|
|
expected)))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(paint-patch span-patch owner-rerender)))
|
|
(should (equal (plist-get report :dirty-kinds) '(geometry)))
|
|
(should (eq (plist-get report :owner-type) 'box))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (plist-get report :tp-scope-count) 0))
|
|
(should-not (plist-get report :tp-scope-fallback))
|
|
(should (ebox-test--buffer-propertized-matches-runtime-render-p)))))))))
|
|
|
|
(ert-deftest ebox-bgcolor-update-mutates-box-and-keeps-content ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Color") :width 60 :bgcolor "#111111"))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id :bgcolor "#222222")
|
|
(should (equal (ebox-style-node-specified-value box :bgcolor)
|
|
"#111111"))
|
|
(should (equal (ebox-get (gethash region-id
|
|
(ebox--buffer-region-box-table
|
|
(current-buffer)))
|
|
:bgcolor)
|
|
"#222222"))
|
|
(should (string-match-p
|
|
"Color"
|
|
(buffer-substring-no-properties (point-min) (point-max)))))))
|
|
|
|
(ert-deftest ebox-background-color-creation-matches-bgcolor-updates ()
|
|
"Background color spellings should share one engine longhand and paint path."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((background-box
|
|
(ebox-test-box (ebox-test-text "Color") :width 60
|
|
:background-color "#111111"))
|
|
(bgcolor-box
|
|
(ebox-test-box (ebox-test-text "Color") :width 60 :bgcolor "#111111"))
|
|
(background-face
|
|
(ebox-test--first-content-face (ebox-render background-box)))
|
|
(bgcolor-face
|
|
(ebox-test--first-content-face (ebox-render bgcolor-box))))
|
|
(should (equal (ebox-style-node-specified-value
|
|
background-box :bgcolor)
|
|
"#111111"))
|
|
(should (equal (ebox-style-node-specified-value bgcolor-box :bgcolor)
|
|
"#111111"))
|
|
(should-not (plist-member (ebox-test-root background-box)
|
|
:background-color))
|
|
(should (equal background-face bgcolor-face))
|
|
(should (ebox-test--face-has-key-value-p
|
|
background-face :background "#111111"))
|
|
(let ((region-id (car (ebox-region-ids background-box))))
|
|
(ebox-test--with-rendered-buffer background-box
|
|
(ebox-test--region-update region-id :background-color "#222222")
|
|
(let ((runtime-box
|
|
(gethash region-id (ebox--buffer-region-box-table
|
|
(current-buffer)))))
|
|
(should (equal (ebox-style-node-specified-value
|
|
background-box :bgcolor)
|
|
"#111111"))
|
|
(should (equal (ebox-get runtime-box :bgcolor) "#222222"))
|
|
(should-not (plist-member runtime-box :background-color)))
|
|
(should-not (plist-member (ebox-test-root background-box)
|
|
:background-color))
|
|
(should (ebox-test--face-has-key-value-p
|
|
(ebox-test--first-content-face (buffer-string))
|
|
:background "#222222"))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'paint-patch))
|
|
(should (equal (plist-get report :patch-ops) '(paint-patch)))
|
|
(should (equal (plist-get report :dirty-kinds) '(paint)))
|
|
(should-not (plist-get report :full-rerender)))))))
|
|
|
|
(ert-deftest ebox-update-reuses-shorthand-property-expansion ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Box") :width 60))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id
|
|
:padding '(1 2)
|
|
:border '(1 "#abcdef")
|
|
:margin-inline 3)
|
|
(let ((runtime-box
|
|
(gethash region-id (ebox--buffer-region-box-table
|
|
(current-buffer)))))
|
|
(should (= (ebox-get (ebox-test-root box) :padding-top-height) 0))
|
|
(should (= (ebox-get runtime-box :padding-top-height) 1))
|
|
(should (= (ebox-get runtime-box :padding-right-pixel)
|
|
(* 2 (ebox--space-pixel-width))))
|
|
(should (= (ebox-get runtime-box :border-left-pixel) 1))
|
|
(should (equal (ebox-get runtime-box :border-right-color) "#abcdef"))
|
|
(should (= (ebox-get runtime-box :margin-left-pixel)
|
|
(* 3 (ebox--space-pixel-width))))))))
|
|
|
|
(ert-deftest ebox-region-update-adds-absent-shorthand-properties-incrementally ()
|
|
"Shorthand updates should locate the same box and patch without root rebuilds."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Absent properties") :width '(160)))
|
|
(region-id (car (ebox-region-ids box)))
|
|
(ids-before (ebox-region-ids box)))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((full-rerenders
|
|
(ebox-test--count-full-surface-updates
|
|
(ebox-test--region-update region-id
|
|
:padding '(1 2)
|
|
:border '(1 "#abcdef")
|
|
:margin-inline 3))))
|
|
(should (= full-rerenders 0)))
|
|
(let ((runtime-box
|
|
(gethash region-id (ebox--buffer-region-box-table
|
|
(current-buffer)))))
|
|
(should (eq (gethash region-id ebox--region-box-table)
|
|
runtime-box))
|
|
(should-not (eq runtime-box box))
|
|
(should (equal (ebox-region-ids box) ids-before))
|
|
(should (= (ebox-get (ebox-test-root box) :padding-top-height) 0))
|
|
(should (= (ebox-get (ebox-test-root box) :padding-right-pixel) 0))
|
|
(should (= (ebox-get (ebox-test-root box) :border-left-pixel) 0))
|
|
(should (null (ebox-get (ebox-test-root box) :border-right-color)))
|
|
(should (= (ebox-get (ebox-test-root box) :margin-left-pixel) 0))
|
|
(should (= (ebox-get runtime-box :padding-top-height) 1))
|
|
(should (= (ebox-get runtime-box :padding-right-pixel)
|
|
(* 2 (ebox--space-pixel-width))))
|
|
(should (= (ebox-get runtime-box :border-left-pixel) 1))
|
|
(should (equal (ebox-get runtime-box :border-right-color) "#abcdef"))
|
|
(should (= (ebox-get runtime-box :margin-left-pixel)
|
|
(* 3 (ebox--space-pixel-width)))))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'owner-rerender))
|
|
(should (equal (plist-get report :patch-ops)
|
|
'(owner-rerender)))
|
|
(should-not (plist-get report :full-rerender)))
|
|
(let ((full-rerenders
|
|
(ebox-test--count-full-surface-updates
|
|
(ebox-test--region-update region-id
|
|
:background-color "#fed7aa"))))
|
|
(should (= full-rerenders 0)))
|
|
(should (null (ebox-style-node-specified-value box :bgcolor)))
|
|
(should (equal (ebox-get (gethash region-id
|
|
(ebox--buffer-region-box-table
|
|
(current-buffer)))
|
|
:bgcolor)
|
|
"#fed7aa"))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'paint-patch))
|
|
(should (equal (plist-get report :patch-ops)
|
|
'(paint-patch)))
|
|
(should-not (plist-get report :full-rerender))))))
|
|
|
|
(ert-deftest ebox-overflow-hidden-clips-extra-content-lines ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
|
|
:height 1
|
|
:overflow 'hidden))
|
|
(plain (substring-no-properties (ebox-render box))))
|
|
(should (string-match-p "A" plain))
|
|
(should-not (string-match-p "B" plain))
|
|
(should-not (string-match-p "C" plain))
|
|
(should (= (ebox-string-height plain) 1))))
|
|
|
|
(ert-deftest ebox-overflow-visible-appends-extra-content-lines ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
|
|
:height 1
|
|
:overflow 'visible))
|
|
(plain (substring-no-properties (ebox-render box))))
|
|
(should (string-match-p "A" plain))
|
|
(should (string-match-p "B" plain))
|
|
(should (string-match-p "C" plain))
|
|
(should (= (ebox-string-height plain) 3))))
|
|
|
|
(defun ebox-test--assert-visible-overflow-paint
|
|
(rendered foreground background outer-region-id)
|
|
"Assert RENDERED overflow keeps Text identity without OUTER-REGION-ID."
|
|
(let* ((lines (ebox-string-lines rendered))
|
|
(body (car lines))
|
|
(overflow-lines (cdr lines))
|
|
(body-pos (cl-position ?A body)))
|
|
(should (= (length lines) 3))
|
|
(should (integerp body-pos))
|
|
(let ((body-face (get-text-property body-pos 'face body)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
body-face :foreground foreground))
|
|
(should (ebox-test--face-has-key-value-p
|
|
body-face :background background)))
|
|
(cl-loop for line in overflow-lines
|
|
for glyph in '(?B ?C)
|
|
do
|
|
(let ((glyph-pos (cl-position glyph line)))
|
|
(should (integerp glyph-pos))
|
|
(should (get-text-property glyph-pos 'ebox-content line))
|
|
(should (ebox-test--face-has-key-value-p
|
|
(get-text-property glyph-pos 'face line)
|
|
:foreground foreground)))
|
|
(dolist (offset-pos (list 0 (1- (length line))))
|
|
(should (get-text-property offset-pos 'display line))
|
|
(should-not
|
|
(ebox-test--face-has-key-p
|
|
(get-text-property offset-pos 'face line)
|
|
:foreground)))
|
|
(dotimes (pos (length line))
|
|
(should-not
|
|
(ebox-test--face-has-key-p
|
|
(get-text-property pos 'face line)
|
|
:background))
|
|
(should-not
|
|
(member outer-region-id
|
|
(get-text-property pos 'ebox-content-owners line)))
|
|
(dolist (property (mapcar #'cdr ebox-region-types))
|
|
(should-not
|
|
(equal (get-text-property pos property line)
|
|
outer-region-id)))))))
|
|
|
|
(ert-deftest ebox-overflow-visible-paints-foreground-without-ownership ()
|
|
"Visible overflow should inherit only the outer box foreground."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
|
|
:width '(40)
|
|
:height 1
|
|
:overflow 'visible
|
|
:color "#abcdef"
|
|
:bgcolor "#123456"
|
|
:padding-inline '(6)
|
|
:margin-inline '(4)))
|
|
(region-id (ebox--ensure-region-id (ebox-test-root box))))
|
|
(ebox-test--assert-visible-overflow-paint
|
|
(ebox-render box) "#abcdef" "#123456" region-id)))
|
|
|
|
(ert-deftest ebox-region-update-refreshes-visible-overflow-foreground ()
|
|
"Color updates should add, replace, and remove visible overflow paint."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
|
|
:width '(40)
|
|
:height 1
|
|
:overflow 'visible
|
|
:bgcolor "#123456"
|
|
:padding-inline '(6)
|
|
:margin-inline '(4)))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id :color "#abcdef")
|
|
(ebox-test--assert-visible-overflow-paint
|
|
(buffer-string) "#abcdef" "#123456" region-id)
|
|
(ebox-test--region-update region-id :color "#fedcba")
|
|
(ebox-test--assert-visible-overflow-paint
|
|
(buffer-string) "#fedcba" "#123456" region-id)
|
|
(let ((published (buffer-string)))
|
|
(should-error
|
|
(ebox-test--region-update region-id :color nil)
|
|
:type 'error)
|
|
(should (equal-including-properties published (buffer-string)))
|
|
(ebox-test--assert-visible-overflow-paint
|
|
(buffer-string) "#fedcba" "#123456" region-id)))))
|
|
|
|
(ert-deftest ebox-owner-rerender-keeps-visible-overflow-unowned ()
|
|
"Owner rerenders should not restore box ownership to visible overflow."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
|
|
:width '(40)
|
|
:height 1
|
|
:overflow 'scroll
|
|
:color "#abcdef"
|
|
:bgcolor "#123456"))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(ebox-test--region-update region-id :overflow 'visible)
|
|
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
|
|
'owner-rerender))
|
|
(goto-char (point-min))
|
|
(search-forward "C")
|
|
(let* ((pos (match-beginning 0))
|
|
(face (get-text-property pos 'face)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :foreground "#abcdef"))
|
|
(should-not (ebox-test--face-has-key-value-p
|
|
face :background "#123456"))
|
|
(should (get-text-property pos 'ebox-content))
|
|
(should-not
|
|
(eq (get-text-property pos 'ebox-content-owner) region-id))
|
|
(should-not
|
|
(member region-id
|
|
(get-text-property pos 'ebox-content-owners)))))))
|
|
|
|
(ert-deftest ebox-overflow-scroll-keeps-window-height-and-state ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
|
|
:height 1
|
|
:overflow 'scroll))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "A" plain))
|
|
(should-not (string-match-p "B" plain))
|
|
(should (= (ebox-string-height plain) 1)))
|
|
(should (equal (plist-get (ebox--scroll-get-state region-id)
|
|
:content-height)
|
|
1))
|
|
(goto-char (point-min))
|
|
(ebox-scroll-down 1)
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "B" plain))
|
|
(should-not (string-match-p "A" plain))
|
|
(should (= (ebox-string-height plain) 1))))))
|
|
|
|
(ert-deftest ebox-scroll-falls-back-when-region-cannot-scroll ()
|
|
"Scroll commands should let the buffer move when the box has no scroll work."
|
|
(ebox-test--reset-runtime-state)
|
|
(with-temp-buffer
|
|
(insert (ebox-render (ebox-test-box (ebox-test-text "short") :height 1)))
|
|
(insert "\n")
|
|
(dotimes (index 40)
|
|
(insert (format "tail-%02d\n" index)))
|
|
(goto-char (point-min))
|
|
(should (get-text-property (point) 'ebox-content))
|
|
(let (fallback-called)
|
|
(cl-letf (((symbol-function 'scroll-up)
|
|
(lambda (&optional _arg)
|
|
(setq fallback-called t))))
|
|
(ebox-scroll-down 1))
|
|
(should fallback-called))))
|
|
|
|
(ert-deftest ebox-scroll-bubbles-from-child-to-scrollable-ancestor ()
|
|
"Key scrolling over a child box should scroll the nearest scrollable ancestor."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1)
|
|
(ebox-test-box (ebox-test-text "C") :height 1))))
|
|
(ids (ebox-region-ids layout))
|
|
(root-id (car ids)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(goto-char (point-min))
|
|
(search-forward "A")
|
|
(backward-char)
|
|
(should-not (equal (get-text-property (point) 'ebox-content)
|
|
root-id))
|
|
(let (fallback-called)
|
|
(cl-letf (((symbol-function 'scroll-up)
|
|
(lambda (&optional _arg)
|
|
(setq fallback-called t))))
|
|
(ebox-scroll-down 1))
|
|
(should-not fallback-called))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "B" plain))
|
|
(should (string-match-p "C" plain))))))
|
|
|
|
(ert-deftest ebox-wheel-scroll-falls-back-to-default-mwheel ()
|
|
"Mouse wheel fallback should preserve Emacs' normal scroll behavior."
|
|
(ebox-test--reset-runtime-state)
|
|
(let (mwheel-args scroll-up-called)
|
|
(cl-letf (((symbol-function 'mwheel-scroll)
|
|
(lambda (&rest args)
|
|
(setq mwheel-args args)))
|
|
((symbol-function 'scroll-up)
|
|
(lambda (&optional _arg)
|
|
(setq scroll-up-called t))))
|
|
(ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg))
|
|
(should (equal mwheel-args '(fake-wheel-event fake-arg)))
|
|
(should-not scroll-up-called)))
|
|
|
|
(ert-deftest ebox-wheel-scroll-falls-back-for-non-scrollable-region ()
|
|
"Mouse wheel over a normal box should keep default buffer scrolling."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "short") :height 1))
|
|
(region-id (car (ebox-region-ids box)))
|
|
mwheel-args)
|
|
(ebox-test--with-rendered-buffer box
|
|
(cl-letf (((symbol-function 'ebox--wheel-region-ids)
|
|
(lambda (_event) (list region-id)))
|
|
((symbol-function 'mwheel-scroll)
|
|
(lambda (&rest args)
|
|
(setq mwheel-args args))))
|
|
(ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg))
|
|
(should (equal mwheel-args '(fake-wheel-event fake-arg))))))
|
|
|
|
(ert-deftest ebox-wheel-scroll-consumes-scrollable-region ()
|
|
"Mouse wheel should still scroll an overflow='scroll box when it can."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
|
|
:height 1
|
|
:overflow 'scroll))
|
|
(region-id (car (ebox-region-ids box)))
|
|
mwheel-called)
|
|
(ebox-test--with-rendered-buffer box
|
|
(cl-letf (((symbol-function 'ebox--wheel-region-ids)
|
|
(lambda (_event) (list region-id)))
|
|
((symbol-function 'mwheel-scroll)
|
|
(lambda (&rest _args)
|
|
(setq mwheel-called t))))
|
|
(ebox--wheel-scroll 'fake-wheel-event 1))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "B" plain))
|
|
(should-not (string-match-p "A" plain)))
|
|
(should-not mwheel-called))))
|
|
|
|
(ert-deftest ebox-wheel-scroll-bubbles-from-child-to-scrollable-ancestor ()
|
|
"Mouse wheel scrolling over a child box should scroll its scrollable ancestor."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1)
|
|
(ebox-test-box (ebox-test-text "C") :height 1))))
|
|
(ids (ebox-region-ids layout))
|
|
(root-id (car ids))
|
|
(child-id (cadr ids))
|
|
mwheel-called)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(cl-letf (((symbol-function 'ebox--wheel-region-ids)
|
|
(lambda (_event) (list child-id root-id)))
|
|
((symbol-function 'mwheel-scroll)
|
|
(lambda (&rest _args)
|
|
(setq mwheel-called t))))
|
|
(ebox--wheel-scroll 'fake-wheel-event 1))
|
|
(should-not mwheel-called)
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "B" plain))
|
|
(should (string-match-p "C" plain))))))
|
|
|
|
(ert-deftest ebox-scroll-state-is-created-only-for-scrollable-boxes ()
|
|
"Non-overflowing boxes should not inflate the global scroll hit-test table."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-column (ebox-test-box :id "short-a" (ebox-test-text "A") :height 1)
|
|
(ebox-test-box :id "short-b" (ebox-test-text "B") :height 1)
|
|
(ebox-test-box :id "root" :height 1 :overflow
|
|
'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "C") :height
|
|
1)
|
|
(ebox-test-box (ebox-test-text "D") :height
|
|
1)))))
|
|
root-id short-a-id short-b-id)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
|
|
(setq short-a-id (ebox-test--selector-region-id (current-buffer)
|
|
"#short-a"))
|
|
(setq short-b-id (ebox-test--selector-region-id (current-buffer)
|
|
"#short-b"))
|
|
(should-not (ebox--scroll-get-state short-a-id))
|
|
(should-not (ebox--scroll-get-state short-b-id))
|
|
(should (ebox--scroll-get-state root-id)))))
|
|
|
|
(ert-deftest ebox-scroll-hit-test-uses-owner-stack-before-mount-lookup ()
|
|
"Wheel hit testing should avoid TP mount lookup when owners suffice."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1))))
|
|
(root-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(goto-char (point-min))
|
|
(cl-letf (((symbol-function 'ebox--scroll-region-ids-in-mounts-at-pos)
|
|
(lambda (&rest _args)
|
|
(error "unexpected mount lookup"))))
|
|
(should (member root-id
|
|
(ebox--scroll-region-ids-at-pos (point))))))))
|
|
|
|
(ert-deftest ebox-chrome-free-scroll-render-reuses-propertized-prefix-lines ()
|
|
"A chrome-free scroll box should not propertize its visible prefix twice."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "row-1\nrow-2\nrow-3")
|
|
:width 120
|
|
:height 2
|
|
:overflow 'scroll
|
|
:color "#112233"
|
|
:bgcolor "#ddeeff"))
|
|
(region-id (ebox--ensure-region-id (ebox-test-root box)))
|
|
(reference (ebox-test-box (ebox-test-text "row-1\nrow-2\nrow-3")
|
|
:width 120
|
|
:height 2
|
|
:overflow 'hidden
|
|
:color "#112233"
|
|
:bgcolor "#ddeeff"))
|
|
(original (symbol-function 'ebox--propertize-content-line))
|
|
(root-propertize-count 0)
|
|
actual)
|
|
(plist-put (ebox-test-root reference) :region-id region-id)
|
|
(cl-letf (((symbol-function 'ebox--propertize-content-line)
|
|
(lambda (line owner idx fallback)
|
|
(when (equal owner region-id)
|
|
(cl-incf root-propertize-count))
|
|
(funcall original line owner idx fallback))))
|
|
(setq actual (ebox-render box)))
|
|
(should (= root-propertize-count 3))
|
|
(should-not
|
|
(text-property-not-all 0 (length actual)
|
|
'ebox-scroll-window region-id actual))
|
|
(let ((actual-without-window-tag (copy-sequence actual)))
|
|
(remove-text-properties
|
|
0 (length actual-without-window-tag)
|
|
'(ebox-scroll-window nil)
|
|
actual-without-window-tag)
|
|
(should (equal-including-properties
|
|
actual-without-window-tag
|
|
(ebox-render reference))))))
|
|
|
|
(ert-deftest ebox-underfilled-chrome-free-scroll-fuses-line-finishing-passes ()
|
|
"A complete top-aligned underfill should keep exact output in one line pass."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content
|
|
(concat (propertize "alpha" 'face '(:weight bold)
|
|
'help-echo "rich")
|
|
"\n \nomega"))
|
|
(box (ebox-test-box (ebox-test-text content)
|
|
:width 140
|
|
:height 5
|
|
:overflow 'scroll
|
|
:color "#112233"
|
|
:bgcolor "#ddeeff"))
|
|
(region-id (ebox--ensure-region-id (ebox-test-root box)))
|
|
(reference (ebox-test-box (ebox-test-text content)
|
|
:width 140
|
|
:height 5
|
|
:overflow 'hidden
|
|
:color "#112233"
|
|
:bgcolor "#ddeeff"))
|
|
(expected (progn
|
|
(plist-put (ebox-test-root reference)
|
|
:region-id region-id)
|
|
(ebox-render reference)))
|
|
(original-align (symbol-function 'ebox--lines-align-vertical))
|
|
(original-maplines (symbol-function 'ebox--maplines))
|
|
(align-calls 0)
|
|
(mapline-calls 0)
|
|
actual)
|
|
(cl-letf (((symbol-function 'ebox--lines-align-vertical)
|
|
(lambda (&rest args)
|
|
(cl-incf align-calls)
|
|
(apply original-align args)))
|
|
((symbol-function 'ebox--maplines)
|
|
(lambda (&rest args)
|
|
(cl-incf mapline-calls)
|
|
(apply original-maplines args))))
|
|
(setq actual (ebox-render box)))
|
|
(should (= align-calls 0))
|
|
;; Formatting may still justify the source once; finishing must not add
|
|
;; the former blank-normalization and color map passes.
|
|
(should (<= mapline-calls 1))
|
|
(should (equal-including-properties actual expected))
|
|
(should-not (ebox--scroll-get-state region-id))))
|
|
|
|
(ert-deftest ebox-underfilled-scroll-paints-stable-slot-faces ()
|
|
"Fused scroll finishing must publish slot face addresses on every line."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((foreground (tp-paint-slot-create '(:foreground "#112233")))
|
|
(background (tp-paint-slot-create '(:background "#ddeeff")))
|
|
(content (concat (propertize "alpha" 'face '(:weight bold))
|
|
"\n \nomega"))
|
|
(box (ebox-test-box (ebox-test-text content)
|
|
:width 140 :height 5 :overflow 'scroll
|
|
:color foreground :bgcolor background))
|
|
(region-id (ebox--ensure-region-id (ebox-test-root box)))
|
|
(reference (ebox-test-box (ebox-test-text content)
|
|
:width 140 :height 5 :overflow 'hidden
|
|
:color foreground :bgcolor background))
|
|
(original
|
|
(symbol-function 'ebox--render-underfilled-simple-scroll-lines))
|
|
(finish-calls 0)
|
|
actual)
|
|
(plist-put (ebox-test-root reference) :region-id region-id)
|
|
(cl-letf (((symbol-function 'ebox--render-underfilled-simple-scroll-lines)
|
|
(lambda (&rest args)
|
|
(cl-incf finish-calls)
|
|
(apply original args))))
|
|
(setq actual (ebox-render box)))
|
|
(should (= finish-calls 1))
|
|
(should (equal-including-properties actual (ebox-render reference)))
|
|
;; Include rich content, normalized blanks, and added viewport filler.
|
|
(let ((lines (ebox-string-lines actual)))
|
|
(should (= (length lines) 5))
|
|
(dolist (line lines)
|
|
(let ((face-values (flatten-tree (get-text-property 0 'face line))))
|
|
(should (memq (tp-paint-slot-face background) face-values))
|
|
(should-not (seq-some #'tp-paint-slot-p face-values)))))
|
|
(should (memq (tp-paint-slot-face foreground)
|
|
(flatten-tree (get-text-property 0 'face actual))))
|
|
(let ((painted (copy-sequence actual)))
|
|
(tp-paint-slot-update foreground '(:foreground "#445566"))
|
|
(tp-paint-slot-update background '(:background "#aabbcc"))
|
|
(should (equal-including-properties actual painted))
|
|
(should (equal (face-attribute (tp-paint-slot-face foreground)
|
|
:foreground nil nil)
|
|
"#445566"))
|
|
(should (equal (face-attribute (tp-paint-slot-face background)
|
|
:background nil nil)
|
|
"#aabbcc")))))
|
|
|
|
(ert-deftest ebox-underfilled-scroll-resolves-foreground-faces ()
|
|
"Fused scroll finishing must resolve slot and default foreground faces."
|
|
(ebox-test--reset-runtime-state)
|
|
(cl-letf (((symbol-function 'ebox-buffer--default-foreground-face)
|
|
(lambda () '(:foreground "#123456"))))
|
|
(dolist (color (list (tp-paint-slot-create '(:foreground "#112233"))
|
|
'ebox/default-foreground))
|
|
(let ((lines (ebox--render-underfilled-simple-scroll-lines
|
|
(list (copy-sequence "alpha"))
|
|
1 3 (ebox-pixel-space 140) color "#ddeeff"))
|
|
(expected (get-text-property
|
|
0 'face
|
|
(ebox--propertize-colors "alpha" color "#ddeeff"))))
|
|
(should (= (length lines) 3))
|
|
(dolist (line lines)
|
|
(should (equal (get-text-property 0 'face line) expected)))))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-rendered-window-uses-preformatted-lines ()
|
|
"Lazy scroll windows should not rebuild visible content lines per tick."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (plist-get state :rendered-content-lines))
|
|
(should (plist-get state :render-content-prefix)))
|
|
(let* ((surface ebox-surface--buffer-surface)
|
|
(revision (tp-surface-revision surface)))
|
|
(should (ebox--scroll-region-by root-id 8))
|
|
(should (= (tp-surface-revision surface) (1+ revision))))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (= (plist-get state :scroll-offset) 8))
|
|
(should (plist-get state :rendered-content-lines)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-after-viewport-resize-keeps-window-range ()
|
|
"Viewport resize must preserve lazy scroll's fixed viewport fast path."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-column :id "viewport" :width '(viewport) :height
|
|
'(viewport-height)
|
|
(ebox-test-box (ebox-test-text "") :width '(viewport)
|
|
:height 1)
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow
|
|
'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport) :padding '(1 (8)))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" idx)) :width
|
|
'(viewport) :height 1))))
|
|
(ebox-test-box (ebox-test-text "") :width
|
|
'(viewport) :height 1))))
|
|
buffer
|
|
root-id
|
|
mount-bounds
|
|
mount-line-count
|
|
(materialize-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 12)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-interval 60.0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(ebox-rerender-buffer-with-context buffer 320 14)
|
|
(with-current-buffer buffer
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(setq mount-bounds
|
|
(ebox-surface-region-bounds buffer root-id))
|
|
(should mount-bounds)
|
|
(setq mount-line-count
|
|
(count-lines (car mount-bounds) (cdr mount-bounds)))
|
|
(ert-info ((format "state=%S"
|
|
(list :content-height
|
|
(plist-get state :content-height)
|
|
:content-lines
|
|
(length (plist-get state
|
|
:content-lines))
|
|
:rendered-lines
|
|
(length (plist-get state
|
|
:rendered-content-lines)))))
|
|
(should (= (plist-get state :content-height) 14)))
|
|
(when-let* ((materialize
|
|
(plist-get state :materialize-content-lines)))
|
|
(plist-put
|
|
state :materialize-content-lines
|
|
(lambda (&rest args)
|
|
(cl-incf materialize-count)
|
|
(apply materialize args)))
|
|
(puthash root-id state ebox--scroll-global-state)))
|
|
(should (ebox--smooth-scroll-region-by root-id 16))
|
|
(while (gethash root-id ebox--smooth-scroll-state-table)
|
|
(ebox--smooth-scroll-tick root-id))
|
|
(let ((current-bounds
|
|
(ebox-surface-region-bounds buffer root-id)))
|
|
(should current-bounds)
|
|
(should (= mount-line-count
|
|
(count-lines (car current-bounds)
|
|
(cdr current-bounds)))))
|
|
(should (= materialize-count 0))))
|
|
(when root-id
|
|
(ebox--smooth-scroll-stop root-id))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-intrinsic-measurement-skips-window-source ()
|
|
"Flex intrinsic measurement should not render lazy scroll window content."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box (ebox-test-text (format "measurement row %02d"
|
|
idx))
|
|
:width '(viewport) :height 1))))))
|
|
(calls 0)
|
|
rendered)
|
|
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
|
|
(lambda (&rest _args)
|
|
(cl-incf calls)
|
|
(list :lines nil :complete nil :cache nil))))
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 12)
|
|
(ebox--intrinsic-layout-measurement t))
|
|
(setq rendered (ebox-render layout)))
|
|
(should (= calls 0))
|
|
(should (= (ebox-string-height rendered) 12))
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 12)
|
|
(ebox--intrinsic-layout-measurement nil))
|
|
(ebox-render layout))
|
|
(should (= calls 1)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-sync-prefix-does-not-require-idle-prefetch ()
|
|
"Interactive scrolling should extend a lazy prefix even with idle prefetch off."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box (ebox-test-text (format "sync row %02d" idx))
|
|
:width '(viewport) :height 1))))))
|
|
buffer
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 20)
|
|
(ebox-scroll-step 4)
|
|
(ebox-scroll-lazy-idle-prefetch-lines 0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((before (ebox--scroll-get-state root-id))
|
|
(initial-lines (length (plist-get before :content-lines)))
|
|
(content-height (plist-get before :content-height))
|
|
(boundary-offset (- initial-lines content-height)))
|
|
(should (> boundary-offset 0))
|
|
(with-current-buffer buffer
|
|
(should (= boundary-offset
|
|
(ebox--scroll-region-by root-id
|
|
boundary-offset)))))
|
|
(with-current-buffer buffer
|
|
(should (= 4 (ebox--scroll-region-by root-id 4))))
|
|
(let ((after (ebox--scroll-get-state root-id)))
|
|
(should (> (plist-get after :scroll-offset) 4))
|
|
(should (>= (length (plist-get after :content-lines))
|
|
(+ (plist-get after :scroll-offset)
|
|
(plist-get after :content-height))))
|
|
(should-not (plist-get after :content-lines-complete-p))))
|
|
(when root-id
|
|
(ebox--smooth-scroll-stop root-id))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-gui-small-cache-miss-stays-in-ebox-coordinate ()
|
|
"A bounded GUI cache miss should extend and scroll in one Ebox commit."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box (ebox-test-text (format "gui row %02d" idx))
|
|
:width '(viewport) :height 1))))))
|
|
buffer
|
|
root-id
|
|
(prefix-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 20)
|
|
(ebox-scroll-step 4)
|
|
(ebox-scroll-lazy-idle-prefetch-lines 0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 8)
|
|
(noninteractive nil))
|
|
(cl-letf (((symbol-function 'display-graphic-p)
|
|
(lambda (&optional _display) t)))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix))
|
|
(initial-lines (length (plist-get state :content-lines)))
|
|
(content-height (plist-get state :content-height))
|
|
(boundary-offset (- initial-lines content-height)))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(puthash root-id state ebox--scroll-global-state)
|
|
(with-current-buffer buffer
|
|
(should (= boundary-offset
|
|
(ebox--scroll-region-by root-id
|
|
boundary-offset)))
|
|
(should (= 4 (ebox--scroll-region-by root-id 4)))
|
|
(should (= (+ boundary-offset 4)
|
|
(plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)))
|
|
(should (= (+ initial-lines 12)
|
|
(length
|
|
(plist-get (ebox--scroll-get-state root-id)
|
|
:content-lines))))
|
|
(should
|
|
(= content-height
|
|
(length
|
|
(plist-get
|
|
(plist-get (ebox--buffer-render-state buffer)
|
|
:surface-fragments)
|
|
:scroll-lines))))))
|
|
(should (= prefix-count 1))))
|
|
(when root-id
|
|
(ebox--smooth-scroll-stop root-id))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-explicit-budget-syncs-prefix-in-gui ()
|
|
"A caller-provided prefix budget should synchronously extend GUI lazy scrolls."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box (ebox-test-text (format "budget row %02d" idx))
|
|
:width '(viewport) :height 1))))))
|
|
buffer
|
|
root-id
|
|
(prefix-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 20)
|
|
(ebox-scroll-step 4)
|
|
(ebox-scroll-lazy-idle-prefetch-lines 0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(noninteractive nil))
|
|
(cl-letf (((symbol-function 'display-graphic-p)
|
|
(lambda (&optional _display) t)))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix))
|
|
(initial-lines (length (plist-get state :content-lines)))
|
|
(content-height (plist-get state :content-height))
|
|
(boundary-offset (- initial-lines content-height)))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(puthash root-id state ebox--scroll-global-state)
|
|
(with-current-buffer buffer
|
|
(should (= boundary-offset
|
|
(ebox--scroll-region-by root-id
|
|
boundary-offset)))
|
|
(should (= 4 (ebox--scroll-region-by root-id 4 4)))))
|
|
(should (= prefix-count 1))))
|
|
(when root-id
|
|
(ebox--smooth-scroll-stop root-id))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-prefix-reuses-viewport-dependent-flex-leaves ()
|
|
"Lazy prefix extension should not rerender prior viewport flex leaves."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((flex-section
|
|
(lambda (label)
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :width '(viewport) :flex-flow '(row wrap))
|
|
(cl-loop for idx below 4
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "%s-%d" label idx))
|
|
:width '(110)
|
|
:height 1))))))
|
|
(layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(ebox-test-column :width '(viewport)
|
|
(funcall flex-section "a")
|
|
(ebox-test-box :height 1)
|
|
(funcall flex-section "b"))))
|
|
(buffer nil)
|
|
root-id
|
|
(flex-render-count 0)
|
|
(original-render-flex (symbol-function 'ebox--render-flex)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (= (length (plist-get state :content-lines)) 3))
|
|
(should (plist-get state :render-content-prefix)))
|
|
(cl-letf (((symbol-function 'ebox--render-flex)
|
|
(lambda (&rest args)
|
|
(cl-incf flex-render-count)
|
|
(apply original-render-flex args))))
|
|
(should (ebox--scroll-region-by root-id 3)))
|
|
(should (<= flex-render-count 1))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-flex-prefix-renders-only-needed-rows ()
|
|
"Lazy scroll prefixes should not render hidden row-wrap flex rows."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(ebox-test-column :width '(viewport)
|
|
(ebox-test-box :id "head" (ebox-test-text "head") :width
|
|
'(viewport) :height 1)
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :width '(viewport) :flex-flow '(row wrap) :row-gap 0
|
|
:column-gap 0)
|
|
(cl-loop for idx below 12 collect
|
|
(ebox-test-box :id (format "item-%02d" idx) (ebox-test-text (format "item %02d" idx)) :width '(100)
|
|
:height 2)))))))
|
|
(buffer nil)
|
|
(hidden-render-count 0)
|
|
(original-render-box (symbol-function 'ebox--render-box)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 1)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(cl-letf (((symbol-function 'ebox--render-box)
|
|
(lambda (box)
|
|
(when-let* ((id (and (listp box) (ebox-get box :id))))
|
|
(when (and (string-prefix-p "item-" id)
|
|
(>= (string-to-number
|
|
(substring id (length "item-")))
|
|
4))
|
|
(cl-incf hidden-render-count)))
|
|
(funcall original-render-box box))))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout)))
|
|
(should (= hidden-render-count 0)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-flex-window-shrinks-definite-wrapping-items ()
|
|
"Window prefix rendering must not freeze shrinkable definite widths."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(ebox-test-flex :width '(160) :flex-flow '(row wrap)
|
|
(ebox-test-box :id "shrink-a" (ebox-test-text "alpha beta gamma delta") :width '(200)
|
|
:height 2)
|
|
(ebox-test-box :id "shrink-b" (ebox-test-text "second row content") :width '(160)
|
|
:height 2)))
|
|
(limit 2)
|
|
(full-lines (ebox-string-lines (ebox-render node)))
|
|
(window (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node limit)))
|
|
;; A nil window means the full-render fallback takes over, which is
|
|
;; also correct. Produced window lines must match the full
|
|
;; renderer's prefix geometry instead of freezing the declared
|
|
;; 200px width inside the 160px container.
|
|
(when window
|
|
(should (equal (mapcar #'ebox--string-pixel-width
|
|
(seq-take full-lines limit))
|
|
(mapcar #'ebox--string-pixel-width
|
|
(plist-get window :lines)))))))
|
|
|
|
(ert-deftest ebox-flex-window-char-wrapper-falls-back-to-full-prefix ()
|
|
"Char-wrapped flex prefixes should use the full-render fallback."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :width '(100) :wrap-mode 'char :flex-flow '(row wrap)
|
|
:row-gap 0 :column-gap 0)
|
|
(cl-loop for char across "ABCD" collect
|
|
(ebox-test-box (ebox-test-text (char-to-string char)) :width
|
|
'(150) :height 1)))))
|
|
(full-lines (ebox-string-lines (ebox-render node)))
|
|
(expected-signatures
|
|
(mapcar #'ebox-test--visual-line-signature
|
|
(seq-take full-lines 3)))
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
|
|
(first (ebox-test--call-input-node
|
|
#'ebox--render-stack-window-lines node 1))
|
|
(extended
|
|
(ebox-test--call-input-node
|
|
#'ebox--render-stack-window-lines node 3 (plist-get first :cache)))
|
|
(producer (ebox-test--call-input-node
|
|
#'ebox--render-node-window-lines node 3))
|
|
(actual-signatures
|
|
(mapcar #'ebox-test--visual-line-signature
|
|
(plist-get extended :lines))))
|
|
(should (equal expected-signatures actual-signatures))
|
|
(should-not producer)))
|
|
|
|
(ert-deftest ebox-canonical-column-scroll-window-renders-a-bounded-prefix ()
|
|
"A canonical Column should expose only the requested scroll prefix."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-column
|
|
(cl-loop for index below 20
|
|
collect
|
|
(ebox-test-box
|
|
(ebox-test-text (format "row %02d" index))
|
|
:height 1))))
|
|
(full (ebox-string-lines (ebox-render node)))
|
|
(render-count 0)
|
|
(original (symbol-function 'ebox--render-with-cache)))
|
|
(cl-letf (((symbol-function 'ebox--render-with-cache)
|
|
(lambda (&rest arguments)
|
|
(cl-incf render-count)
|
|
(apply original arguments))))
|
|
(let ((window (ebox-test--call-input-node
|
|
#'ebox--render-stack-window-lines node 3)))
|
|
(should-not (plist-get window :complete))
|
|
(should
|
|
(equal (mapcar #'substring-no-properties (seq-take full 3))
|
|
(mapcar #'substring-no-properties
|
|
(plist-get window :lines))))
|
|
(should
|
|
(equal (mapcar #'ebox--string-pixel-width (seq-take full 3))
|
|
(mapcar #'ebox--string-pixel-width
|
|
(plist-get window :lines))))))
|
|
(should (< render-count 20))))
|
|
|
|
(ert-deftest ebox-flex-window-lines-use-chunk-wrapper-for-first-prefix ()
|
|
"A seedless flex prefix should avoid the generic full wrapper renderer."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :id "flex-window-wrapper" :width '(240) :flex-flow
|
|
'(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8))
|
|
:border-right "#D97757" :border-bottom "#D97757"
|
|
:border-left "#D97757" :bgcolor "#FFF8F2")
|
|
(cl-loop for idx below 8 collect
|
|
(ebox-test-box (ebox-test-text (format "wrapper %02d" idx))
|
|
:width '(100) :height 2)))))
|
|
(full-lines (ebox-string-lines (ebox-render node)))
|
|
(full-wrapper-count 0)
|
|
(chunk-wrapper-count 0)
|
|
(multiline-horizontal-concat-count 0)
|
|
(original-full-wrapper
|
|
(symbol-function 'ebox--flex-window-render-box-lines))
|
|
(original-chunk-wrapper
|
|
(symbol-function 'ebox--flex-window-render-wrapper-chunk-lines))
|
|
(original-horizontal-concat
|
|
(symbol-function 'ebox--lines-concat-horizontal)))
|
|
(cl-letf (((symbol-function 'ebox--flex-window-render-box-lines)
|
|
(lambda (&rest args)
|
|
(cl-incf full-wrapper-count)
|
|
(apply original-full-wrapper args)))
|
|
((symbol-function 'ebox--flex-window-render-wrapper-chunk-lines)
|
|
(lambda (&rest args)
|
|
(cl-incf chunk-wrapper-count)
|
|
(apply original-chunk-wrapper args)))
|
|
((symbol-function 'ebox--lines-concat-horizontal)
|
|
(lambda (&rest args)
|
|
(cl-incf multiline-horizontal-concat-count)
|
|
(apply original-horizontal-concat args))))
|
|
(let* ((ebox--surface-materialization-active t)
|
|
(window (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 4)))
|
|
(should window)
|
|
(should-not (plist-get window :complete))
|
|
(should (= (length (plist-get window :lines)) 4))
|
|
(should
|
|
(ebox-test--rendered-visually-equal-p
|
|
(ebox-lines-join (seq-take full-lines 4))
|
|
(ebox-lines-join (plist-get window :lines))))))
|
|
(should (= full-wrapper-count 0))
|
|
(should (= chunk-wrapper-count 1))
|
|
(should (= multiline-horizontal-concat-count 0))))
|
|
|
|
(ert-deftest ebox-flex-flat-wrapper-owns-color-copy-in-place ()
|
|
"Flat flex wrappers should decorate one private line copy per source line."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box :width 120
|
|
:padding-left 8
|
|
:padding-right 8
|
|
:border-left '(1 solid "#D97757")
|
|
:border-right '(1 solid "#D97757")
|
|
:bgcolor "#FFF8F2"))
|
|
(source (propertize "child" 'ebox-content 77
|
|
'ebox-content-owner 77
|
|
'ebox-content-owners '(77)))
|
|
(source-before (copy-sequence source))
|
|
(expected
|
|
(ebox-test--call-input-node
|
|
#'ebox--flex-window-render-wrapper-chunk-lines
|
|
box (list source) 0 t))
|
|
(copying-owner-count 0)
|
|
(original-copying-owner
|
|
(symbol-function 'ebox--add-content-owner))
|
|
actual)
|
|
(cl-letf (((symbol-function 'ebox--add-content-owner)
|
|
(lambda (&rest args)
|
|
(cl-incf copying-owner-count)
|
|
(apply original-copying-owner args))))
|
|
(setq actual
|
|
(ebox-test--call-input-node
|
|
#'ebox--flex-window-render-wrapper-chunk-lines
|
|
box (list source) 0 t)))
|
|
(should (equal-including-properties source source-before))
|
|
(should
|
|
(equal-including-properties
|
|
(ebox-lines-join actual)
|
|
(ebox-lines-join expected)))
|
|
(should (= copying-owner-count 0))))
|
|
|
|
(ert-deftest ebox-flex-window-lines-reuses-render-cache ()
|
|
"Repeated flex window renders should reuse the render cache."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :width '(240) :flex-flow '(row wrap) :row-gap 1
|
|
:column-gap 0)
|
|
(cl-loop for idx below 8 collect
|
|
(ebox-test-box :id (format "cache-item-%02d" idx)
|
|
(ebox-test-text (format "cache %02d" idx))
|
|
:width '(100) :height 2)))))
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(row-render-count 0)
|
|
(original-render-row-line
|
|
(symbol-function 'ebox--flex-render-row-line)))
|
|
(cl-letf (((symbol-function 'ebox--flex-render-row-line)
|
|
(lambda (&rest args)
|
|
(cl-incf row-render-count)
|
|
(apply original-render-row-line args))))
|
|
(should (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 4))
|
|
(let ((after-first row-render-count))
|
|
(should (> after-first 0))
|
|
(should (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 4))
|
|
(should (= row-render-count after-first))))))
|
|
|
|
(ert-deftest ebox-flex-window-lines-reuses-node-cache-signature ()
|
|
"One flex window render should not repeatedly compute the node signature."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :width '(240) :flex-flow '(row wrap) :row-gap 1
|
|
:column-gap 0)
|
|
(cl-loop for idx below 8 collect
|
|
(ebox-test-box :id (format "sig-item-%02d" idx)
|
|
(ebox-test-text (format "sig %02d" idx))
|
|
:width '(100) :height 2)))))
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
|
|
(signature-count 0)
|
|
(original-signature
|
|
(symbol-function 'ebox--render-cache-node-signature)))
|
|
(cl-letf (((symbol-function 'ebox--render-cache-node-signature)
|
|
(lambda (&rest args)
|
|
(when (eq (car args) (ebox-test-root node))
|
|
(cl-incf signature-count))
|
|
(apply original-signature args))))
|
|
(should (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 4)))
|
|
(should (<= signature-count 2))))
|
|
|
|
(ert-deftest ebox-flex-window-prefix-extension-reuses-wrapper-lines ()
|
|
"Extending a flex prefix should not rerender prior wrapper lines."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :id "flex-prefix-wrapper" :width '(240) :flex-flow
|
|
'(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8))
|
|
:border-right "#D97757" :border-bottom "#D97757"
|
|
:border-left "#D97757" :bgcolor "#FFF8F2")
|
|
(cl-loop for idx below 8 collect
|
|
(ebox-test-box (ebox-test-text (format "extend %02d" idx))
|
|
:width '(100) :height 2)))))
|
|
(full-lines (mapcar #'substring-no-properties
|
|
(ebox-string-lines (ebox-render node))))
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(generic-wrapper-render-count 0)
|
|
(original-render-box (symbol-function 'ebox--render-box)))
|
|
(cl-letf (((symbol-function 'ebox--render-box)
|
|
(lambda (box)
|
|
(when (equal (and (listp box) (ebox-get box :id))
|
|
"flex-prefix-wrapper")
|
|
(cl-incf generic-wrapper-render-count))
|
|
(funcall original-render-box box))))
|
|
(should (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 4))
|
|
(should (= generic-wrapper-render-count 0))
|
|
(let ((window (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 8)))
|
|
(should window)
|
|
(should (= generic-wrapper-render-count 0))
|
|
(should (equal (seq-take full-lines 8)
|
|
(mapcar #'substring-no-properties
|
|
(plist-get window :lines))))))))
|
|
|
|
(ert-deftest ebox-flex-window-prefix-extension-joins-only-new-pieces ()
|
|
"Extending a flex prefix should not rejoin the whole cached prefix."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :width '(240) :flex-flow '(row wrap) :row-gap 1
|
|
:column-gap 0 :padding '(0 (8)) :border-right "#D97757"
|
|
:border-bottom "#D97757" :border-left "#D97757" :bgcolor
|
|
"#FFF8F2")
|
|
(cl-loop for idx below 16 collect
|
|
(ebox-test-box (ebox-test-text (format "piece %02d" idx))
|
|
:width '(100) :height 2)))))
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
join-piece-counts
|
|
(original-join-pieces
|
|
(symbol-function 'ebox--flex-window-lines-from-pieces)))
|
|
(cl-letf (((symbol-function 'ebox--flex-window-lines-from-pieces)
|
|
(lambda (pieces)
|
|
(push (length pieces) join-piece-counts)
|
|
(funcall original-join-pieces pieces))))
|
|
(should (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 4))
|
|
(setq join-piece-counts nil)
|
|
(should (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 8)))
|
|
(should join-piece-counts)
|
|
(should-not (cl-some (lambda (count) (> count 4))
|
|
join-piece-counts))))
|
|
|
|
(ert-deftest ebox-flex-window-complete-prefix-defers-bottom-wrapper ()
|
|
"A complete flex source should not full-render bottom chrome before LIMIT."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :id "flex-complete-prefix" :width '(240) :flex-flow
|
|
'(row wrap) :row-gap 1 :column-gap 0 :padding
|
|
'(0 (8) 2 (8)) :margin-bottom 2 :border-right "#D97757"
|
|
:border-bottom "#D97757" :border-left "#D97757" :bgcolor
|
|
"#FFF8F2")
|
|
(cl-loop for idx below 2 collect
|
|
(ebox-test-box (ebox-test-text (format "complete %02d" idx))
|
|
:width '(100) :height 8)))))
|
|
(limit 4)
|
|
(full-lines (ebox-string-lines (ebox-render node)))
|
|
(complete-wrapper-count 0)
|
|
(original-render-box-lines
|
|
(symbol-function 'ebox--flex-window-render-box-lines)))
|
|
(cl-letf (((symbol-function 'ebox--flex-window-render-box-lines)
|
|
(lambda (node content complete limit)
|
|
(when complete
|
|
(cl-incf complete-wrapper-count))
|
|
(funcall original-render-box-lines
|
|
node content complete limit))))
|
|
(let ((window (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node limit)))
|
|
(should window)
|
|
(should-not (plist-get window :complete))
|
|
(should (= complete-wrapper-count 0))
|
|
(should
|
|
(cl-every
|
|
(lambda (pair)
|
|
(let ((full (car pair))
|
|
(prefix (cdr pair)))
|
|
(or (equal (substring-no-properties full)
|
|
(substring-no-properties prefix))
|
|
(and (string-blank-p full)
|
|
(string-blank-p prefix)
|
|
(= (ebox--string-pixel-width full)
|
|
(ebox--string-pixel-width prefix))))))
|
|
(cl-mapcar #'cons (seq-take full-lines limit)
|
|
(plist-get window :lines))))))))
|
|
|
|
(ert-deftest ebox-flex-window-complete-extension-appends-bottom-border ()
|
|
"Completing a cached flex prefix should append bottom border without full render."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :id "flex-complete-border" :width '(240) :flex-flow
|
|
'(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8))
|
|
:border-right "#D97757" :border-bottom "#D97757"
|
|
:border-left "#D97757" :bgcolor "#FFF8F2")
|
|
(cl-loop for idx below 4 collect
|
|
(ebox-test-box (ebox-test-text (format "border %02d" idx))
|
|
:width '(100) :height 2)))))
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(full-lines (mapcar #'substring-no-properties
|
|
(ebox-string-lines (ebox-render node))))
|
|
(complete-wrapper-count 0)
|
|
(original-render-box-lines
|
|
(symbol-function 'ebox--flex-window-render-box-lines)))
|
|
(should (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 4))
|
|
(cl-letf (((symbol-function 'ebox--flex-window-render-box-lines)
|
|
(lambda (node content complete limit)
|
|
(when complete
|
|
(cl-incf complete-wrapper-count))
|
|
(funcall original-render-box-lines
|
|
node content complete limit))))
|
|
(let ((window (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 8)))
|
|
(should window)
|
|
(should (plist-get window :complete))
|
|
(should (= complete-wrapper-count 0))
|
|
(should (equal (seq-take full-lines 8)
|
|
(mapcar #'substring-no-properties
|
|
(plist-get window :lines))))))))
|
|
|
|
(ert-deftest ebox-flex-window-failed-completion-keeps-prefix-cache-atomic ()
|
|
"A failed flex completion should not publish half-appended cache lists."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :id "flex-atomic-prefix" :width '(240) :flex-flow
|
|
'(row wrap) :row-gap 1 :column-gap 0 :padding '(0 (8))
|
|
:border-right "#D97757" :border-bottom "#D97757"
|
|
:border-left "#D97757")
|
|
(cl-loop for idx below 4 collect
|
|
(ebox-test-box (ebox-test-text (format "atomic %02d" idx))
|
|
:width '(100) :height 2)))))
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(context (ebox-test--call-input-node
|
|
#'ebox--flex-window-cache-context node)))
|
|
(should (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 4))
|
|
(let* ((before (ebox-test--call-input-node
|
|
#'ebox--flex-window-prefix-cache-lookup node context))
|
|
(before-lines (plist-get before :lines))
|
|
(before-content-lines (plist-get before :content-lines))
|
|
(before-line-count (plist-get before :line-count))
|
|
(before-content-count (plist-get before :content-line-count)))
|
|
(cl-letf (((symbol-function 'ebox--flex-window-complete-tail-line)
|
|
(lambda (&rest _)
|
|
(error "injected flex completion failure"))))
|
|
(should-error
|
|
(ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 8)))
|
|
(let ((after (ebox-test--call-input-node
|
|
#'ebox--flex-window-prefix-cache-lookup node context)))
|
|
(should (eq before-lines (plist-get after :lines)))
|
|
(should (eq before-content-lines
|
|
(plist-get after :content-lines)))
|
|
(should (= before-line-count (length before-lines)))
|
|
(should (= before-content-count (length before-content-lines)))
|
|
(should (= (plist-get after :line-count)
|
|
(length (plist-get after :lines))))
|
|
(should (= (plist-get after :content-line-count)
|
|
(length (plist-get after :content-lines)))))
|
|
(should (plist-get (ebox-test--call-input-node
|
|
#'ebox--render-flex-window-lines node 8)
|
|
:complete)))))
|
|
|
|
(ert-deftest ebox-stack-window-completion-reconciles-declared-prefix-tail ()
|
|
"A completed leaf should refresh declared chrome on its cached tail."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((type 'ebox-test-completing-window)
|
|
(leaf (list :ebox-type type))
|
|
(stack (list :ebox-type 'stack :children (list leaf)))
|
|
(plain-tail (propertize "tail" 'face '(:foreground "red")))
|
|
(complete-tail
|
|
(ebox--propertize-underline
|
|
(copy-sequence plain-tail) "blue")))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox--register-window-line-renderer
|
|
type
|
|
(lambda (_node limit)
|
|
(if (< limit 3)
|
|
(list :lines (list "head" plain-tail)
|
|
:complete nil)
|
|
(list :lines (list "head" complete-tail)
|
|
:complete t
|
|
:completion-prefix-tail-rewrite-count 1))))
|
|
(let* ((prefix (ebox--render-stack-window-lines stack 2))
|
|
(completed
|
|
(ebox--render-stack-window-lines
|
|
stack 3 (plist-get prefix :cache)))
|
|
(tail (cadr (plist-get completed :lines)))
|
|
(face (get-text-property 0 'face tail))
|
|
(delta (plist-get completed :prefix-delta)))
|
|
(should (plist-get completed :complete))
|
|
(should (= (length (plist-get completed :lines)) 2))
|
|
(should (plist-get delta :exact-p))
|
|
(should (= (plist-get delta :base-count) 2))
|
|
(should (equal (plist-get delta :rewrite-range) '(1 . 2)))
|
|
(should
|
|
(ebox-test--face-has-key-value-p
|
|
face :underline '(:position t :color "blue")))))
|
|
(remhash type ebox--window-line-renderer-table))))
|
|
|
|
(ert-deftest ebox-stack-window-invalid-completion-rewrite-rebuilds-prefix ()
|
|
"Invalid completion metadata should discard a potentially stale prefix."
|
|
(ebox-test--reset-runtime-state)
|
|
(dolist (metadata '(3 -1 invalid missing))
|
|
(let* ((type 'ebox-test-invalid-completing-window)
|
|
(leaf (list :ebox-type type))
|
|
(stack (list :ebox-type 'stack :children (list leaf)))
|
|
(plain-tail "tail")
|
|
(complete-tail
|
|
(ebox--propertize-underline "tail" "blue"))
|
|
(calls 0))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox--register-window-line-renderer
|
|
type
|
|
(lambda (_node limit)
|
|
(cl-incf calls)
|
|
(if (< limit 3)
|
|
(list :lines (list "head" plain-tail)
|
|
:complete nil)
|
|
(append
|
|
(list :lines (list "head" complete-tail)
|
|
:complete t)
|
|
(unless (eq metadata 'missing)
|
|
(list :completion-prefix-tail-rewrite-count
|
|
metadata))))))
|
|
(let* ((prefix (ebox--render-stack-window-lines stack 2))
|
|
(completed
|
|
(ebox--render-stack-window-lines
|
|
stack 3 (plist-get prefix :cache)))
|
|
(tail (cadr (plist-get completed :lines)))
|
|
(face (get-text-property 0 'face tail))
|
|
(delta (plist-get completed :prefix-delta)))
|
|
(should (= calls 3))
|
|
(should-not (plist-get delta :exact-p))
|
|
(should
|
|
(ebox-test--face-has-key-value-p
|
|
face :underline '(:position t :color "blue")))))
|
|
(remhash type ebox--window-line-renderer-table)))))
|
|
|
|
(ert-deftest ebox-flex-window-completion-tail-metadata-is-border-specific ()
|
|
"Only an existing bottom-border line should request a prefix rewrite."
|
|
(ebox-test--reset-runtime-state)
|
|
(should
|
|
(= 1
|
|
(ebox--flex-window-completion-prefix-tail-rewrite-count
|
|
(list :box (ebox-test-root
|
|
(ebox-test-box :border-bottom-width 1
|
|
:border-bottom-style 'solid
|
|
:padding-bottom 0))))))
|
|
(should
|
|
(= 1
|
|
(ebox--flex-window-completion-prefix-tail-rewrite-count
|
|
(list :box (ebox-test-root
|
|
(ebox-test-box :border-bottom-width 1
|
|
:border-bottom-style 'solid
|
|
:padding-bottom 0
|
|
:margin-bottom 2))))))
|
|
(should
|
|
(= 0
|
|
(ebox--flex-window-completion-prefix-tail-rewrite-count
|
|
(list :box (ebox-test-root
|
|
(ebox-test-box :border-bottom-width 1
|
|
:border-bottom-style 'solid
|
|
:padding-bottom 2))))))
|
|
(should
|
|
(= 0
|
|
(ebox--flex-window-completion-prefix-tail-rewrite-count
|
|
(list :box (ebox-test-root
|
|
(ebox-test-box :padding-bottom 0)))))))
|
|
|
|
(ert-deftest ebox-flex-window-cache-rejects-short-incomplete-exact-hit ()
|
|
"An underfilled exact-limit cache must resume from its prefix seed."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((lines (cl-loop repeat 112 collect "line"))
|
|
(entry (list :lines lines
|
|
:line-count 112
|
|
:line-tail (last lines)
|
|
:complete nil))
|
|
looked-up-keys)
|
|
(cl-letf (((symbol-function 'ebox--render-cache-lookup)
|
|
(lambda (key _signature)
|
|
(push key looked-up-keys)
|
|
(cond
|
|
((equal key '(7 flex-window prefix)) entry)
|
|
((equal key '(7 flex-window 118)) entry)))))
|
|
(should-not
|
|
(ebox--flex-window-cache-lookup
|
|
:test-node 118 '(:node-id 7 :signature test-signature))))
|
|
(should (member '(7 flex-window 118) looked-up-keys))))
|
|
|
|
(ert-deftest ebox-byte-compiled-scroll-window-binds-wrapper-viewport ()
|
|
"Compiled lazy rendering must dynamically bind the wrapper content width."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((wrapper (ebox-test-box (ebox-test-text "") :width '(278) :wrap-mode 'none))
|
|
(source (list :stack :test-stack
|
|
:wrapper (ebox-test-root wrapper)))
|
|
observed-viewport)
|
|
(cl-letf (((symbol-function 'ebox--render-stack-window-lines)
|
|
(lambda (&rest _args)
|
|
(setq observed-viewport ebox-viewport-width)
|
|
(throw 'viewport-observed nil))))
|
|
(let ((ebox-viewport-width 900))
|
|
(catch 'viewport-observed
|
|
(ebox--render-scroll-window-source source 1))))
|
|
(should (= observed-viewport 278))))
|
|
|
|
(ert-deftest ebox-scroll-window-wrapper-applies-declared-prefix-rewrite ()
|
|
"Wrapper prefix reuse should rerender only a declared changed range."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none))
|
|
(source (list :stack :test-stack
|
|
:wrapper (ebox-test-root wrapper)))
|
|
(plain-tail "tail")
|
|
(complete-tail
|
|
(ebox--propertize-underline "tail" "blue"))
|
|
(stack-lines (list plain-tail))
|
|
(calls 0)
|
|
(second-call-line-count 0)
|
|
(original-chunk-renderer
|
|
(symbol-function
|
|
'ebox--scroll-window-render-wrapper-chunk-lines)))
|
|
(cl-letf (((symbol-function 'ebox--render-stack-window-lines)
|
|
(lambda (&rest _args)
|
|
(cl-incf calls)
|
|
(if (= calls 1)
|
|
(list :lines stack-lines
|
|
:line-count 1
|
|
:line-tail stack-lines
|
|
:complete nil
|
|
:cache :first
|
|
:prefix-delta
|
|
'(:exact-p nil :base-count 0
|
|
:rewrite-range nil
|
|
:rewrite-lines nil
|
|
:append-lines nil))
|
|
(setcar stack-lines complete-tail)
|
|
(list :lines stack-lines
|
|
:line-count 1
|
|
:line-tail stack-lines
|
|
:complete nil
|
|
:cache :second
|
|
:prefix-delta
|
|
(list :exact-p t :base-count 1
|
|
:rewrite-range '(0 . 1)
|
|
:rewrite-lines (list complete-tail)
|
|
:append-lines nil)))))
|
|
((symbol-function
|
|
'ebox--scroll-window-render-wrapper-chunk-lines)
|
|
(lambda (&rest args)
|
|
(when (= calls 2)
|
|
(cl-incf second-call-line-count
|
|
(length (cadr args))))
|
|
(apply original-chunk-renderer args)))
|
|
((symbol-function 'equal-including-properties)
|
|
(lambda (&rest _)
|
|
(ert-fail "wrapper reuse scanned the whole prefix"))))
|
|
(let* ((prefix (ebox--render-scroll-window-source source 1))
|
|
(extended
|
|
(ebox--render-scroll-window-source
|
|
source 2 (plist-get prefix :cache)))
|
|
(line (car (plist-get extended :lines)))
|
|
(face (get-text-property 0 'face line))
|
|
(delta (plist-get extended :prefix-delta)))
|
|
(should (= calls 2))
|
|
(should (= second-call-line-count 1))
|
|
(should (plist-get delta :exact-p))
|
|
(should (= (plist-get delta :base-count) 1))
|
|
(should (equal (plist-get delta :rewrite-range) '(0 . 1)))
|
|
(should
|
|
(ebox-test--face-has-key-value-p
|
|
face :underline '(:position t :color "blue")))))))
|
|
|
|
(ert-deftest ebox-scroll-window-wrapper-maps-top-chrome-rewrite-ranges ()
|
|
"Wrapper reuse should include top chrome only when source line zero changes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none
|
|
:margin-top 1 :padding-top 2)))
|
|
(cl-labels
|
|
((mapped-range (source-range)
|
|
(let* ((source
|
|
(list :stack :test-stack
|
|
:wrapper (ebox-test-root wrapper)))
|
|
(source-lines (list "head" "body"))
|
|
(calls 0))
|
|
(cl-letf
|
|
(((symbol-function 'ebox--render-stack-window-lines)
|
|
(lambda (&rest _)
|
|
(cl-incf calls)
|
|
(when (= calls 2)
|
|
(setcar (nthcdr (car source-range) source-lines)
|
|
"changed"))
|
|
(list
|
|
:lines source-lines
|
|
:line-count 2
|
|
:line-tail (last source-lines)
|
|
:complete nil
|
|
:cache calls
|
|
:prefix-delta
|
|
(if (= calls 1)
|
|
'(:exact-p nil :base-count 0
|
|
:rewrite-range nil :rewrite-lines nil
|
|
:append-lines nil)
|
|
(list :exact-p t :base-count 2
|
|
:rewrite-range source-range
|
|
:rewrite-lines (list "changed")
|
|
:append-lines nil))))))
|
|
(let* ((prefix
|
|
(ebox--render-scroll-window-source source 2))
|
|
(extended
|
|
(ebox--render-scroll-window-source
|
|
source 3 (plist-get prefix :cache))))
|
|
(plist-get (plist-get extended :prefix-delta)
|
|
:rewrite-range))))))
|
|
(should (equal (mapped-range '(0 . 1)) '(0 . 4)))
|
|
(should (equal (mapped-range '(1 . 2)) '(4 . 5))))))
|
|
|
|
(ert-deftest ebox-scroll-prefix-renderer-applies-equal-length-prefix-rewrite ()
|
|
"Equal-length completion should rerender only its declared changed line."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1
|
|
:wrap-mode 'none))
|
|
(plain-lines '("head" "body" "tail"))
|
|
(source-lines (copy-sequence plain-lines))
|
|
(complete-tail
|
|
(ebox--propertize-underline "tail" "blue"))
|
|
(renderer
|
|
(ebox--scroll-window-prefix-renderer
|
|
(ebox-test-root box) :test-source 20 4))
|
|
(rendered-line-count 0)
|
|
(original-renderer
|
|
(symbol-function 'ebox--scroll-rendered-content-lines)))
|
|
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
|
|
(lambda (_source limit &optional _cache)
|
|
(when (> limit 3)
|
|
(setcar (nthcdr 2 source-lines) complete-tail))
|
|
(list :lines source-lines
|
|
:line-count 3
|
|
:line-tail (last source-lines)
|
|
:cache nil
|
|
:complete (> limit 3)
|
|
:content-width-exact-p t
|
|
:rendered-lines nil
|
|
:prefix-delta
|
|
(if (= limit 3)
|
|
'(:exact-p nil :base-count 0
|
|
:rewrite-range nil
|
|
:rewrite-lines nil
|
|
:append-lines nil)
|
|
(list :exact-p t :base-count 3
|
|
:rewrite-range '(2 . 3)
|
|
:rewrite-lines (list complete-tail)
|
|
:append-lines nil)))))
|
|
((symbol-function 'ebox--scroll-window-formatted-lines)
|
|
(lambda (_box window) (plist-get window :lines)))
|
|
((symbol-function 'ebox--scroll-window-source-lines-exact-p)
|
|
(lambda (&rest _) t))
|
|
((symbol-function 'ebox--scroll-rendered-content-lines)
|
|
(lambda (&rest args)
|
|
(cl-incf rendered-line-count (length (cadr args)))
|
|
(apply original-renderer args)))
|
|
((symbol-function 'equal-including-properties)
|
|
(lambda (&rest _)
|
|
(ert-fail "rendered prefix reuse scanned all old lines"))))
|
|
(let* ((prefix (funcall renderer nil 1 3))
|
|
(_ (setq rendered-line-count 0))
|
|
(completed (funcall renderer prefix 1 4))
|
|
(lines (plist-get completed :rendered-content-lines))
|
|
(line (nth 2 lines))
|
|
(face (get-text-property 0 'face line)))
|
|
(should (plist-get completed :complete))
|
|
(should (= (length lines) 3))
|
|
(should (= rendered-line-count 1))
|
|
(should
|
|
(ebox-test--face-has-key-value-p
|
|
face :underline '(:position t :color "blue")))))))
|
|
|
|
(ert-deftest ebox-scroll-prefix-projects-wrapper-top-chrome-delta ()
|
|
"A published prefix should reuse a source cache with hidden top chrome."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1
|
|
:wrap-mode 'none))
|
|
(wrapper (ebox-test-box (ebox-test-text "") :width 20 :wrap-mode 'none
|
|
:padding-top 2 :margin-top 1))
|
|
(source (list :stack :test-stack
|
|
:wrapper (ebox-test-root wrapper)))
|
|
stack-lines
|
|
stack-tail
|
|
(stack-count 0)
|
|
(calls 0)
|
|
(rendered-line-count 0)
|
|
(original-renderer
|
|
(symbol-function 'ebox--scroll-rendered-content-lines))
|
|
(renderer
|
|
(ebox--scroll-window-prefix-renderer
|
|
(ebox-test-root box) source 20 4)))
|
|
(cl-letf (((symbol-function 'ebox--render-stack-window-lines)
|
|
(lambda (_stack limit &optional _cache)
|
|
(cl-incf calls)
|
|
(let ((base stack-count)
|
|
appended-lines)
|
|
(while (< stack-count limit)
|
|
(let ((cell (list (format "row-%d" stack-count))))
|
|
(if stack-tail
|
|
(setcdr stack-tail cell)
|
|
(setq stack-lines cell))
|
|
(setq stack-tail cell)
|
|
(push (car cell) appended-lines)
|
|
(cl-incf stack-count)))
|
|
(setq appended-lines (nreverse appended-lines))
|
|
(list :lines stack-lines
|
|
:line-count stack-count
|
|
:line-tail stack-tail
|
|
:complete nil
|
|
:cache (list :call calls)
|
|
:prefix-delta
|
|
(if (= calls 1)
|
|
'(:exact-p nil :base-count 0
|
|
:rewrite-range nil
|
|
:rewrite-lines nil
|
|
:append-lines nil)
|
|
(list :exact-p t
|
|
:base-count base
|
|
:rewrite-range nil
|
|
:rewrite-lines nil
|
|
:append-lines appended-lines))))))
|
|
((symbol-function 'ebox--scroll-window-formatted-lines)
|
|
(lambda (_box window) (plist-get window :lines)))
|
|
((symbol-function 'ebox--scroll-window-source-lines-exact-p)
|
|
(lambda (&rest _) t))
|
|
((symbol-function 'ebox--scroll-rendered-content-lines)
|
|
(lambda (&rest args)
|
|
(cl-incf rendered-line-count (length (cadr args)))
|
|
(apply original-renderer args)))
|
|
((symbol-function 'equal-including-properties)
|
|
(lambda (&rest _)
|
|
(ert-fail "top-chrome reuse scanned the whole prefix"))))
|
|
(let* ((prefix (funcall renderer nil 1 6))
|
|
(prefix-count
|
|
(length (plist-get prefix :content-lines)))
|
|
(_ (setq rendered-line-count 0))
|
|
(extended (funcall renderer prefix 1 7)))
|
|
(should (= calls 2))
|
|
(should (= prefix-count 6))
|
|
(should (= (length (plist-get extended :content-lines)) 7))
|
|
(should (= rendered-line-count 1))))))
|
|
|
|
(ert-deftest ebox-scroll-prefix-exact-extension-scans-only-appended-lines ()
|
|
"A long exact prefix should retain its cons cells and scan only its delta."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1
|
|
:wrap-mode 'none))
|
|
(source-lines
|
|
(cl-loop for idx below 1000 collect (format "line-%d" idx)))
|
|
(source-tail (last source-lines))
|
|
(source-count 1000)
|
|
(calls 0)
|
|
scan-sizes
|
|
record-scans
|
|
(original-seq-take (symbol-function 'seq-take))
|
|
(renderer
|
|
(ebox--scroll-window-prefix-renderer
|
|
(ebox-test-root box) :test-source 20 4)))
|
|
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
|
|
(lambda (_source limit &optional _cache)
|
|
(cl-incf calls)
|
|
(let ((base source-count)
|
|
appended-lines)
|
|
(while (< source-count limit)
|
|
(let ((cell (list (format "line-%d" source-count))))
|
|
(setcdr source-tail cell)
|
|
(setq source-tail cell)
|
|
(push (car cell) appended-lines)
|
|
(cl-incf source-count)))
|
|
(setq appended-lines (nreverse appended-lines))
|
|
(list :lines source-lines
|
|
:line-count source-count
|
|
:line-tail source-tail
|
|
:cache :same
|
|
:complete nil
|
|
:content-width-exact-p t
|
|
:rendered-lines nil
|
|
:prefix-delta
|
|
(if (= calls 1)
|
|
'(:exact-p nil :base-count 0
|
|
:rewrite-range nil
|
|
:rewrite-lines nil
|
|
:append-lines nil)
|
|
(list :exact-p t :base-count base
|
|
:rewrite-range nil
|
|
:rewrite-lines nil
|
|
:append-lines appended-lines))))))
|
|
((symbol-function 'ebox--scroll-window-formatted-lines)
|
|
(lambda (_box window) (plist-get window :lines)))
|
|
((symbol-function 'ebox--scroll-window-source-lines-exact-p)
|
|
(lambda (&rest _) t))
|
|
((symbol-function 'ebox--scroll-rendered-content-lines)
|
|
(lambda (_box lines _region-id _start-index) lines))
|
|
((symbol-function 'seq-take)
|
|
(lambda (sequence count)
|
|
(when record-scans
|
|
(push (length sequence) scan-sizes))
|
|
(funcall original-seq-take sequence count))))
|
|
(let* ((prefix (funcall renderer nil 1 1000))
|
|
(content-head (plist-get prefix :content-lines)))
|
|
(setq record-scans t)
|
|
(let ((extended (funcall renderer prefix 1 1001)))
|
|
(should (eq content-head
|
|
(plist-get extended :content-lines)))
|
|
(should (= (length (plist-get extended :content-lines)) 1001))
|
|
(should scan-sizes)
|
|
(should (<= (apply #'max scan-sizes) 1)))))))
|
|
|
|
(ert-deftest ebox-stack-window-completion-rewrite-uses-delta-payload ()
|
|
"A long leaf completion should not scan its full prefix for one rewrite."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((type 'ebox-test-long-completing-window)
|
|
(leaf (list :ebox-type type))
|
|
(stack (list :ebox-type 'stack :children (list leaf)))
|
|
(leaf-lines
|
|
(cl-loop for idx below 1000 collect (format "line-%d" idx)))
|
|
(leaf-tail (last leaf-lines))
|
|
(complete-tail
|
|
(ebox--propertize-underline "line-999" "blue"))
|
|
(original-length (symbol-function 'length))
|
|
(original-nthcdr (symbol-function 'nthcdr))
|
|
(original-seq-subseq (symbol-function 'seq-subseq))
|
|
record-scans
|
|
(full-length-count 0)
|
|
(full-nthcdr-count 0)
|
|
(full-subseq-count 0))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox--register-window-line-renderer
|
|
type
|
|
(lambda (_node limit)
|
|
(if (<= limit 1000)
|
|
(list :lines leaf-lines
|
|
:line-count 1000
|
|
:line-tail leaf-tail
|
|
:complete nil)
|
|
(setcar leaf-tail complete-tail)
|
|
(list :lines leaf-lines
|
|
:line-count 1000
|
|
:line-tail leaf-tail
|
|
:complete t
|
|
:completion-prefix-tail-rewrite-count 1
|
|
:prefix-delta
|
|
(list :exact-p t :base-count 1000
|
|
:rewrite-range '(999 . 1000)
|
|
:rewrite-lines (list complete-tail)
|
|
:append-lines nil)))))
|
|
(let ((prefix (ebox--render-stack-window-lines stack 1000)))
|
|
(cl-letf (((symbol-function 'length)
|
|
(lambda (sequence)
|
|
(when (and record-scans (eq sequence leaf-lines))
|
|
(cl-incf full-length-count))
|
|
(funcall original-length sequence)))
|
|
((symbol-function 'nthcdr)
|
|
(lambda (count sequence)
|
|
(when (and record-scans (eq sequence leaf-lines))
|
|
(cl-incf full-nthcdr-count))
|
|
(funcall original-nthcdr count sequence)))
|
|
((symbol-function 'seq-subseq)
|
|
(lambda (sequence start &optional end)
|
|
(when (and record-scans (eq sequence leaf-lines))
|
|
(cl-incf full-subseq-count))
|
|
(funcall original-seq-subseq sequence start end))))
|
|
(setq record-scans t)
|
|
(let ((completed
|
|
(ebox--render-stack-window-lines
|
|
stack 1001 (plist-get prefix :cache))))
|
|
(setq record-scans nil)
|
|
(should (plist-get completed :complete))
|
|
(should (eq (car (plist-get completed :line-tail))
|
|
complete-tail)))))
|
|
(should (= full-length-count 0))
|
|
(should (= full-nthcdr-count 0))
|
|
(should (= full-subseq-count 0)))
|
|
(remhash type ebox--window-line-renderer-table))))
|
|
|
|
(ert-deftest ebox-scroll-prefix-renderer-failure-keeps-published-pair-atomic ()
|
|
"A failed rendered delta should preserve both published prefix lists."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "") :width 20 :height 1
|
|
:wrap-mode 'none))
|
|
(source-lines (list "line-0" "line-1" "line-2"))
|
|
(source-tail (last source-lines))
|
|
(calls 0)
|
|
cache-arguments
|
|
fail-render
|
|
(original-renderer
|
|
(symbol-function 'ebox--scroll-rendered-content-lines))
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(renderer
|
|
(ebox--scroll-window-prefix-renderer
|
|
(ebox-test-root box) :test-source 20 4)))
|
|
(cl-letf
|
|
(((symbol-function 'ebox--render-scroll-window-source)
|
|
(lambda (_source _limit &optional cache)
|
|
(push cache cache-arguments)
|
|
(cl-incf calls)
|
|
(cond
|
|
((= calls 1)
|
|
(list :lines source-lines
|
|
:line-count 3
|
|
:line-tail source-tail
|
|
:cache :first
|
|
:complete nil
|
|
:content-width-exact-p t
|
|
:rendered-lines source-lines
|
|
:prefix-delta
|
|
'(:exact-p nil :base-count 0
|
|
:rewrite-range nil :rewrite-lines nil
|
|
:append-lines nil)))
|
|
((= calls 2)
|
|
(let ((cell (list "line-3")))
|
|
(setcdr source-tail cell)
|
|
(setq source-tail cell))
|
|
(list :lines source-lines
|
|
:line-count 4
|
|
:line-tail source-tail
|
|
:cache :second
|
|
:complete nil
|
|
:content-width-exact-p t
|
|
:rendered-lines source-lines
|
|
:prefix-delta
|
|
'(:exact-p t :base-count 3
|
|
:rewrite-range nil :rewrite-lines nil
|
|
:append-lines ("line-3"))))
|
|
(t
|
|
(let ((fresh (list "line-0" "line-1" "line-2" "line-3")))
|
|
(list :lines fresh
|
|
:line-count 4
|
|
:line-tail (last fresh)
|
|
:cache :recovered
|
|
:complete nil
|
|
:content-width-exact-p t
|
|
:rendered-lines fresh
|
|
:prefix-delta
|
|
'(:exact-p nil :base-count 0
|
|
:rewrite-range nil :rewrite-lines nil
|
|
:append-lines nil)))))))
|
|
((symbol-function 'ebox--scroll-window-formatted-lines)
|
|
(lambda (_box window) (plist-get window :lines)))
|
|
((symbol-function 'ebox--scroll-window-source-lines-exact-p)
|
|
(lambda (&rest _) t))
|
|
((symbol-function 'ebox--scroll-rendered-content-lines)
|
|
(lambda (&rest args)
|
|
(if (and fail-render (= (nth 3 args) 3))
|
|
(error "injected rendered-prefix failure")
|
|
(apply original-renderer args)))))
|
|
(let* ((prefix (funcall renderer nil 1 3))
|
|
(content-lines (plist-get prefix :content-lines))
|
|
(rendered-lines (plist-get prefix :rendered-content-lines)))
|
|
(puthash :poison t ebox--render-cache-table)
|
|
(setq fail-render t)
|
|
(should-error (funcall renderer prefix 1 4))
|
|
(should (= (length content-lines) 3))
|
|
(should (= (length rendered-lines) 3))
|
|
(should-not (cdr (last content-lines)))
|
|
(should-not (cdr (last rendered-lines)))
|
|
(should (= (hash-table-count ebox--render-cache-table) 0))
|
|
(setq fail-render nil)
|
|
(let ((recovered (funcall renderer prefix 1 4)))
|
|
(should (= (length (plist-get recovered :content-lines)) 4))
|
|
(should (= (length (plist-get recovered
|
|
:rendered-content-lines))
|
|
4))))
|
|
(setq cache-arguments (nreverse cache-arguments))
|
|
(should (equal cache-arguments '(nil :first nil))))))
|
|
|
|
(ert-deftest ebox-stack-window-leaf-lines-uses-registered-producer ()
|
|
"Lazy stack scrolling should use generic window-line producers."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((node (list :ebox-type 'test-window-producer))
|
|
(call-count 0)
|
|
result)
|
|
(unwind-protect
|
|
(progn
|
|
(ebox--register-window-line-renderer
|
|
'test-window-producer
|
|
(lambda (_node limit)
|
|
(cl-incf call-count)
|
|
(list :lines (seq-take '("aa" "bb" "cc") limit)
|
|
:complete nil)))
|
|
(setq result
|
|
(ebox--render-stack-window-leaf-lines node 2 0))
|
|
(should (= call-count 1))
|
|
(should-not (plist-get result :complete))
|
|
(should (equal '("aa" "bb")
|
|
(mapcar #'substring-no-properties
|
|
(plist-get result :lines)))))
|
|
(remhash 'test-window-producer
|
|
ebox--window-line-renderer-table))))
|
|
|
|
(ert-deftest ebox-scroll-prefix-yielding-prewarm-converges-after-zero-line-slice ()
|
|
"A prewarm-only prefix slice should yield once, then publish exact content."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((node (list :ebox-type 'test-yielding-prewarm))
|
|
(box (ebox-test-box (ebox-test-text "") :width 20 :height 1
|
|
:wrap-mode 'none :overflow 'scroll))
|
|
(renderer
|
|
(ebox--scroll-window-prefix-renderer
|
|
(ebox-test-root box) (list :stack node) 20 1))
|
|
(prewarm-count 0)
|
|
(render-count 0))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox--register-window-line-prewarmer
|
|
'test-yielding-prewarm
|
|
(lambda (_node _target-width)
|
|
(cl-incf prewarm-count)
|
|
(= prewarm-count 1)))
|
|
(ebox--register-window-line-renderer
|
|
'test-yielding-prewarm
|
|
(lambda (_node _limit)
|
|
(cl-incf render-count)
|
|
(list :lines '("ready") :line-count 1
|
|
:line-tail '("ready") :complete t)))
|
|
(let* ((ebox--scroll-window-yielding-prewarm-p t)
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(first (funcall renderer nil "yield-region" 1))
|
|
(second (funcall renderer first "yield-region" 1))
|
|
(complete (funcall renderer second "yield-region" 2)))
|
|
(should (= prewarm-count 2))
|
|
(should (= render-count 1))
|
|
(should-not (plist-get first :content-lines))
|
|
(should-not (plist-get first :complete))
|
|
(should
|
|
(equal '("ready")
|
|
(mapcar #'substring-no-properties
|
|
(plist-get second :content-lines))))
|
|
(should-not (plist-get second :complete))
|
|
(should (plist-get complete :complete))
|
|
(should
|
|
(equal '("ready")
|
|
(mapcar #'substring-no-properties
|
|
(plist-get complete :content-lines))))))
|
|
(remhash 'test-yielding-prewarm
|
|
ebox--window-line-prewarmer-table)
|
|
(remhash 'test-yielding-prewarm
|
|
ebox--window-line-renderer-table))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-incremental-render-covers-wheel-drain ()
|
|
"Incremental lazy scroll renders should cover one smooth wheel drain."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id
|
|
(format "budget-row-%02d" idx)
|
|
(ebox-test-text (format "budget row %02d" idx))
|
|
:height 1))))))
|
|
(limits nil)
|
|
(original-render-source
|
|
(symbol-function 'ebox--render-scroll-window-source)))
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 6)
|
|
(ebox--defer-scroll-content-index t)
|
|
(ebox-wheel-smooth-scroll t)
|
|
(ebox-wheel-scroll-step 16)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 8))
|
|
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
|
|
(lambda (source limit &optional cache)
|
|
(push limit limits)
|
|
(funcall original-render-source source limit cache))))
|
|
(ebox-render layout)))
|
|
(should (equal (nreverse limits) '(23)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-small-step-extends-prefix-only ()
|
|
"Small lazy scroll steps should not materialize the whole source."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(hidden-render-count 0)
|
|
(original-render-box (symbol-function 'ebox--render-box)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2))
|
|
(cl-letf (((symbol-function 'ebox--render-box)
|
|
(lambda (box)
|
|
(when (equal
|
|
(ebox-tree-node-id
|
|
(ebox-test-source-index layout) box)
|
|
"row-30")
|
|
(cl-incf hidden-render-count))
|
|
(funcall original-render-box box))))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(should (= hidden-render-count 0))
|
|
(with-current-buffer buffer
|
|
(should (ebox--scroll-region-by root-id 1))
|
|
(should (= hidden-render-count 0))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (= (plist-get state :scroll-offset) 1))
|
|
(should (< (length (plist-get state :content-lines)) 40))
|
|
(should (plist-get state :materialize-content-lines)))
|
|
(should (string-match-p "row 01"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should-not (string-match-p "row 30"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max)))))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-large-cache-miss-schedules-prefetch ()
|
|
"Large lazy scroll misses should schedule prefetch without inline render."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id
|
|
(format "cache-row-%02d" idx)
|
|
(ebox-test-text (format "cache row %02d" idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0)
|
|
(materialize-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix))
|
|
(materialize (plist-get state :materialize-content-lines)))
|
|
(should (= (length (plist-get state :content-lines)) 3))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(plist-put
|
|
state :materialize-content-lines
|
|
(lambda (&rest args)
|
|
(cl-incf materialize-count)
|
|
(apply materialize args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(should (ebox--scroll-region-by root-id 30))
|
|
(should (= prefix-count 0))
|
|
(should (= materialize-count 0))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(should (< (length (plist-get (ebox--scroll-get-state root-id)
|
|
:content-lines))
|
|
40))
|
|
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
|
|
(should-not (string-match-p "cache row 30"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-smooth-scroll-cache-miss-renders-bounded-prefix ()
|
|
"Smooth scroll cache misses should keep moving with bounded prefix render."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id
|
|
(format "smooth-cache-row-%02d"
|
|
idx)
|
|
(ebox-test-text (format "smooth cache row %02d"
|
|
idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0)
|
|
(materialize-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-interval 60.0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix))
|
|
(materialize (plist-get state :materialize-content-lines)))
|
|
(should (= (length (plist-get state :content-lines)) 3))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(plist-put
|
|
state :materialize-content-lines
|
|
(lambda (&rest args)
|
|
(cl-incf materialize-count)
|
|
(apply materialize args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 16)))
|
|
(should (= prefix-count 1))
|
|
(should (= materialize-count 0))
|
|
(should (> (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
|
|
(should entry)
|
|
(should (> (or (plist-get entry :pending) 0) 0))
|
|
(should-not (plist-get entry :waiting-prefetch))))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-smooth-scroll-cache-miss-continues-with-bounded-prefix ()
|
|
"Smooth scroll cache misses should extend a bounded prefix without stalling."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id
|
|
(format
|
|
"smooth-inline-row-%02d" idx)
|
|
(ebox-test-text (format
|
|
"smooth inline row %02d" idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0)
|
|
(materialize-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-interval 60.0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix))
|
|
(materialize (plist-get state :materialize-content-lines)))
|
|
(should (= (length (plist-get state :content-lines)) 3))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(plist-put
|
|
state :materialize-content-lines
|
|
(lambda (&rest args)
|
|
(cl-incf materialize-count)
|
|
(apply materialize args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 16))
|
|
(ebox--smooth-scroll-tick root-id))
|
|
(should (> prefix-count 0))
|
|
(should (= materialize-count 0))
|
|
(should (> (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
|
|
(should entry)
|
|
(should-not (plist-get entry :waiting-prefetch))))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-smooth-scroll-large-pending-uses-base-tick-at-prefix-boundary ()
|
|
"Large smooth pending input should not render a large lazy prefix at once."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 200 collect
|
|
(ebox-test-box :id
|
|
(format "smooth-large-row-%03d"
|
|
idx)
|
|
(ebox-test-text (format "smooth large row %03d"
|
|
idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0)
|
|
requested-lines)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-target-ticks 8)
|
|
(ebox-wheel-smooth-scroll-interval 60.0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix)))
|
|
(should (= (length (plist-get state :content-lines)) 3))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (state region-id required-lines)
|
|
(cl-incf prefix-count)
|
|
(setq requested-lines required-lines)
|
|
(funcall render-prefix state region-id required-lines)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 1000)))
|
|
(should (= prefix-count 1))
|
|
(should (<= requested-lines 8))
|
|
(should (<= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
ebox-wheel-smooth-scroll-lines-per-tick))
|
|
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
|
|
(should entry)
|
|
(should-not (plist-get entry :waiting-prefetch))))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-smooth-scroll-cache-miss-defers-prefetch-while-active ()
|
|
"Idle prefetch should not compete with an active smooth scroll timer."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id
|
|
(format "smooth-defer-row-%02d"
|
|
idx)
|
|
(ebox-test-text (format "smooth defer row %02d"
|
|
idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-interval 60.0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix)))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 16)))
|
|
(should (gethash root-id ebox--smooth-scroll-state-table))
|
|
(let ((prefix-after-start prefix-count))
|
|
(should (> prefix-after-start 0))
|
|
(ebox--scroll-idle-prefetch root-id)
|
|
(should (= prefix-count prefix-after-start))
|
|
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(ebox--scroll-idle-prefetch root-id)
|
|
(should (> prefix-count prefix-after-start))))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-smooth-scroll-cache-miss-never-enters-prefetch-wait ()
|
|
"Smooth scroll should not pause at a lazy prefix boundary."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id
|
|
(format
|
|
"smooth-resume-row-%02d" idx)
|
|
(ebox-test-text (format
|
|
"smooth resume row %02d" idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-interval 60.0)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix)))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 16)))
|
|
(should (> prefix-count 0))
|
|
(let ((offset-before (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)))
|
|
(ebox--smooth-scroll-tick root-id)
|
|
(should (> (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
offset-before)))
|
|
(let ((entry (gethash root-id ebox--smooth-scroll-state-table)))
|
|
(should entry)
|
|
(should-not (plist-get entry :waiting-prefetch))
|
|
(should (timerp (plist-get entry :timer)))))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-repeated-small-steps-keep-tp-mounts ()
|
|
"Repeated lazy scroll steps should keep TP mounts valid."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(hidden-render-count 0)
|
|
(materialize-count 0)
|
|
(original-render-box (symbol-function 'ebox--render-box)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2))
|
|
(cl-letf (((symbol-function 'ebox--render-box)
|
|
(lambda (box)
|
|
(when (equal
|
|
(ebox-tree-node-id
|
|
(ebox-test-source-index layout) box)
|
|
"row-30")
|
|
(cl-incf hidden-render-count))
|
|
(funcall original-render-box box))))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(materialize (plist-get state :materialize-content-lines)))
|
|
(plist-put
|
|
state :materialize-content-lines
|
|
(lambda (&rest args)
|
|
(cl-incf materialize-count)
|
|
(apply materialize args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(cl-loop repeat 8
|
|
do (should (ebox--scroll-region-by root-id 1)))
|
|
(should (= hidden-render-count 0))
|
|
(should (= materialize-count 0))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (= (plist-get state :scroll-offset) 8))
|
|
(should (< (length (plist-get state :content-lines)) 40))
|
|
(should (plist-get state :materialize-content-lines)))
|
|
(should (ebox-surface-region-mounts buffer root-id))
|
|
(should (string-match-p "row 08"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should-not (string-match-p "row 30"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max)))))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-repeated-small-steps-prefetch-prefix ()
|
|
"Repeated lazy scroll steps should reuse a prefetched lazy prefix."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix)))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(cl-loop repeat 8
|
|
do (should (ebox--scroll-region-by root-id 1)))
|
|
(should (= prefix-count 0))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (= (plist-get state :scroll-offset) 8))
|
|
(should (plist-get state :render-content-prefix))
|
|
(should (<= (+ (plist-get state :scroll-offset)
|
|
(plist-get state :content-height)
|
|
1)
|
|
(length (plist-get state :content-lines)))))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-idle-prefetch-extends-prefix-cache ()
|
|
"Idle prefetch should fill lazy scroll lines before wheel ticks need them."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-idle-prefetch-lines 16))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(before-lines (length (plist-get state :content-lines)))
|
|
(render-prefix (plist-get state :render-content-prefix)))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(puthash root-id state ebox--scroll-global-state)
|
|
(ebox--scroll-idle-prefetch root-id)
|
|
(let ((prefetched (ebox--scroll-get-state root-id)))
|
|
(should (> (length (plist-get prefetched :content-lines))
|
|
before-lines))
|
|
(should (< (length (plist-get prefetched :content-lines))
|
|
80))
|
|
(should (= prefix-count 1))))
|
|
(with-current-buffer buffer
|
|
(cl-loop repeat 8
|
|
do (should (ebox--scroll-region-by root-id 1))))
|
|
(should (= prefix-count 1)))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-prefix-reuses-display-measurement-cache ()
|
|
"Lazy prefix slices should retain the initial render's display cache."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box (ebox-test-text (format "repeated row %02d" idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
caches)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(original
|
|
(symbol-function 'ebox--render-scroll-window-source)))
|
|
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
|
|
(lambda (&rest arguments)
|
|
(push ebox--render-string-pixel-width-cache caches)
|
|
(apply original arguments))))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prefix-cache*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(before (length (plist-get state :content-lines))))
|
|
(ebox--scroll-state-ensure-prefix-lines
|
|
root-id state (1+ before) t)))
|
|
(should (>= (length caches) 2))
|
|
(should (car caches))
|
|
(should (cl-every (lambda (cache) (eq cache (car caches)))
|
|
caches)))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-canonical-column-viewport-height-measures-text-as-text ()
|
|
"A typed Text leaf must not enter legacy Box geometry measurement."
|
|
(let ((ebox-viewport-width 640)
|
|
(ebox-viewport-height 24))
|
|
(should
|
|
(string-match-p
|
|
"Viewport"
|
|
(substring-no-properties
|
|
(ebox-render
|
|
(ebox-build
|
|
'(column :width (viewport) :height (viewport-height)
|
|
"Viewport"))))))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-idle-prefetch-covers-visible-window ()
|
|
"Idle prefetch should warm beyond the current visible scroll window."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((region-id "scroll")
|
|
requested-lines)
|
|
(puthash
|
|
region-id
|
|
(list :scroll-offset 0
|
|
:content-height 10
|
|
:content-lines (make-list 3 "")
|
|
:render-content-prefix
|
|
(lambda (_state _region-id target-lines)
|
|
(setq requested-lines target-lines)
|
|
(list :content-lines (make-list target-lines "")
|
|
:content-height 10
|
|
:complete nil)))
|
|
ebox--scroll-global-state)
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 5)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(ebox--scroll-idle-prefetch region-id))
|
|
(should requested-lines)
|
|
(should (>= requested-lines 15))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-idle-prefetch-is-sliced ()
|
|
"Idle prefetch should extend lazy prefixes in bounded foreground slices."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((region-id "scroll")
|
|
requested-lines)
|
|
(puthash
|
|
region-id
|
|
(list :scroll-offset 0
|
|
:content-height 10
|
|
:content-lines (make-list 3 "")
|
|
:render-content-prefix
|
|
(lambda (_state _region-id target-lines)
|
|
(setq requested-lines target-lines)
|
|
(list :content-lines (make-list target-lines "")
|
|
:content-height 10
|
|
:complete nil)))
|
|
ebox--scroll-global-state)
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 64)
|
|
(ebox-scroll-lazy-idle-prefetch-slice-lines 4)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox--scroll-allow-noninteractive-prefetch t))
|
|
(ebox--scroll-idle-prefetch region-id))
|
|
;; Three cached lines plus the configured four-line slice. Completion is
|
|
;; probed on a later turn instead of exceeding this callback's budget.
|
|
(should (= requested-lines 7))
|
|
(should (gethash region-id ebox--scroll-idle-prefetch-timers))
|
|
(ebox--scroll-cancel-idle-prefetch region-id)))
|
|
|
|
(ert-deftest ebox-lazy-scroll-idle-prefetch-bounds-large-runtime-work ()
|
|
"Large runtimes should request only one new line per idle render turn."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((region-id "scroll")
|
|
(buffer (generate-new-buffer " *ebox-large-prefetch*"))
|
|
(node-table (make-hash-table :test 'equal))
|
|
(runtime-type-count-table (make-hash-table :test 'eq))
|
|
requested-lines)
|
|
(unwind-protect
|
|
(progn
|
|
(dotimes (index 512)
|
|
(puthash index t node-table))
|
|
(puthash buffer
|
|
(list :node-table node-table
|
|
:runtime-type-count-table runtime-type-count-table)
|
|
ebox--buffer-render-state-table)
|
|
(puthash
|
|
region-id
|
|
(list :buffer buffer
|
|
:scroll-offset 0
|
|
:content-height 10
|
|
:content-lines (make-list 3 "")
|
|
:render-content-prefix
|
|
(lambda (_state _region-id target-lines)
|
|
(setq requested-lines target-lines)
|
|
(list :content-lines (make-list target-lines "")
|
|
:content-height 10
|
|
:complete nil)))
|
|
ebox--scroll-global-state)
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 64)
|
|
(ebox-scroll-lazy-idle-prefetch-slice-lines 16)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 13)
|
|
(ebox--scroll-allow-noninteractive-prefetch t))
|
|
(ebox--scroll-idle-prefetch region-id))
|
|
;; Three cached lines + one atomic requested line. The completion
|
|
;; probe must not silently double a latency-sensitive slice.
|
|
(should (= requested-lines 4)))
|
|
(ebox--scroll-cancel-idle-prefetch region-id)
|
|
(remhash buffer ebox--buffer-render-state-table)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-idle-prefetch-defers-visible-render-gc ()
|
|
"Visible idle prefix slices should not combine atomic rendering with GC."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((region-id "visible-prefix-gc")
|
|
(buffer (generate-new-buffer " *ebox-visible-prefix-gc*"))
|
|
(state
|
|
(list :buffer buffer
|
|
:scroll-offset 0
|
|
:content-height 1
|
|
:content-lines '("row 0")
|
|
:render-content-prefix
|
|
(lambda (input-state _region-id target-lines &optional _exact)
|
|
(setq input-state
|
|
(plist-put input-state :content-lines
|
|
(make-list target-lines "row")))
|
|
(plist-put input-state :content-lines-complete-p t))))
|
|
(gc-enters 0)
|
|
(gc-restores 0))
|
|
(unwind-protect
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 1)
|
|
(ebox-scroll-lazy-idle-prefetch-slice-lines 1)
|
|
(ebox--scroll-allow-noninteractive-prefetch t))
|
|
(puthash region-id state ebox--scroll-global-state)
|
|
(cl-letf (((symbol-function 'get-buffer-window-list)
|
|
(lambda (&rest _) (list (selected-window))))
|
|
((symbol-function 'display-graphic-p)
|
|
(lambda (&rest _) t))
|
|
((symbol-function 'ebox--deferred-render-gc-enter)
|
|
(lambda () (cl-incf gc-enters)))
|
|
((symbol-function
|
|
'ebox--deferred-render-gc-schedule-restore)
|
|
(lambda () (cl-incf gc-restores)))
|
|
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
|
|
(lambda (&rest _) nil)))
|
|
(ebox--scroll-idle-prefetch region-id))
|
|
(should (= gc-enters 1))
|
|
(should (= gc-restores 1)))
|
|
(ebox--scroll-cancel-idle-prefetch region-id)
|
|
(remhash region-id ebox--scroll-global-state)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-prefetch-yields-to-input-between-slices ()
|
|
"Background prefix work should use idle time and rearm beyond current idle."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((region-id "scroll")
|
|
scheduled-delay
|
|
scheduled-function)
|
|
(puthash
|
|
region-id
|
|
(list :content-height 2
|
|
:content-lines '("row 0")
|
|
:render-content-prefix
|
|
(lambda (_state _region-id target-lines)
|
|
(list :content-lines (make-list target-lines "row")
|
|
:content-height 2
|
|
:complete nil)))
|
|
ebox--scroll-global-state)
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 8)
|
|
(ebox-scroll-lazy-idle-prefetch-slice-lines 1)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 0.15)
|
|
(ebox--scroll-allow-noninteractive-prefetch t))
|
|
(cl-letf (((symbol-function 'current-idle-time)
|
|
(lambda () (seconds-to-time 0.4)))
|
|
((symbol-function 'run-at-time)
|
|
(lambda (&rest _)
|
|
(ert-fail "Background scroll work must yield to input")))
|
|
((symbol-function 'run-with-idle-timer)
|
|
(lambda (delay _repeat function &rest _arguments)
|
|
(setq scheduled-delay delay
|
|
scheduled-function function)
|
|
'scheduled-scroll-timer)))
|
|
(ebox--scroll-idle-prefetch region-id)))
|
|
(should (< (abs (- scheduled-delay 0.55)) 0.000001))
|
|
(should (eq scheduled-function #'ebox--scroll-idle-prefetch))
|
|
(should (eq (gethash region-id ebox--scroll-idle-prefetch-timers)
|
|
'scheduled-scroll-timer))
|
|
(remhash region-id ebox--scroll-idle-prefetch-timers)))
|
|
|
|
(ert-deftest ebox-lazy-scroll-idle-prefetch-rearms-after-scroll ()
|
|
"Successful lazy scroll steps should schedule the next generic prefetch."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(with-current-buffer buffer
|
|
(should (ebox--scroll-region-by root-id 1)))
|
|
(should (gethash root-id ebox--scroll-idle-prefetch-timers)))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-idle-prefetch-pauses-during-smooth-scroll ()
|
|
"Smooth scroll should prefetch only after the active animation stops."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-target-ticks 4)
|
|
(ebox-wheel-smooth-scroll-interval 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(should (gethash root-id ebox--scroll-idle-prefetch-timers))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 16)))
|
|
(should (gethash root-id ebox--smooth-scroll-state-table))
|
|
(should-not (gethash root-id ebox--scroll-idle-prefetch-timers))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(should (gethash root-id ebox--scroll-idle-prefetch-timers)))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-idle-prefetch-pauses-for-resize-animation ()
|
|
"A resize animation should own idle time until its final frame settles."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((buffer (generate-new-buffer " *ebox-resize-prefetch*"))
|
|
(region-id "scroll")
|
|
scheduled)
|
|
(unwind-protect
|
|
(progn
|
|
(puthash
|
|
region-id
|
|
(list :buffer buffer
|
|
:content-height 2
|
|
:content-lines '("row 0")
|
|
:render-content-prefix #'ignore
|
|
:content-lines-complete-p nil)
|
|
ebox--scroll-global-state)
|
|
(cl-letf (((symbol-function 'run-with-idle-timer)
|
|
(lambda (&rest _)
|
|
(setq scheduled 'scroll-timer))))
|
|
(ebox--scroll-inhibit-idle-prefetch-for-buffer buffer)
|
|
(ebox--scroll-schedule-idle-prefetch region-id 0)
|
|
(should-not scheduled)
|
|
(ebox--scroll-resume-idle-prefetch-for-buffer buffer)
|
|
(should (eq scheduled 'scroll-timer))))
|
|
(remhash region-id ebox--scroll-global-state)
|
|
(remhash buffer ebox--scroll-idle-prefetch-inhibited-buffers)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(defun ebox-test--complete-lazy-scroll-batch-layout ()
|
|
"Return a complete five-row lazy scroll cache fixture."
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(ebox-test-column :width '(viewport)
|
|
(ebox-test-box (ebox-test-text "row 00")
|
|
:height 1)
|
|
(ebox-test-box :id "target" (ebox-test-text "target row") :height 1
|
|
:color "#111111")
|
|
(ebox-test-box :id "second-target"
|
|
(ebox-test-text "second target row")
|
|
:height 1 :color
|
|
"#111111")
|
|
(ebox-test-box (ebox-test-text "row 03")
|
|
:height 1)
|
|
(ebox-test-box (ebox-test-text "row 04")
|
|
:height 1))))
|
|
|
|
(defun ebox-test--text-has-foreground-p (string text color)
|
|
"Return non-nil when TEXT in STRING has foreground COLOR."
|
|
(let ((pos (string-match (regexp-quote text)
|
|
(substring-no-properties string))))
|
|
(and pos
|
|
(ebox-test--face-has-key-value-p
|
|
(get-text-property pos 'face string)
|
|
:foreground color))))
|
|
|
|
(defun ebox-test--lines-have-text-foreground-p (lines text color)
|
|
"Return non-nil when one of LINES paints TEXT with foreground COLOR."
|
|
(cl-some (lambda (line)
|
|
(ebox-test--text-has-foreground-p line text color))
|
|
lines))
|
|
|
|
(defun ebox-test--lines-have-text-p (lines text)
|
|
"Return non-nil when one of LINES contains TEXT."
|
|
(and (cl-some (lambda (line)
|
|
(string-match-p (regexp-quote text)
|
|
(substring-no-properties line)))
|
|
lines)
|
|
t))
|
|
|
|
(ert-deftest ebox-lazy-scroll-prefix-extension-keeps-runtime-updates ()
|
|
"Extending a lazy prefix should not replay producer caches from before an update."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 12 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-wheel-smooth-scroll nil))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(target-id (ebox-test--selector-region-id buffer "#row-01"))
|
|
(state (ebox-scroll-state root-id)))
|
|
(should-not (plist-get state :content-lines-complete-p))
|
|
(ebox-test--region-update target-id :content "updated row 01")
|
|
(setq state (ebox-scroll-state root-id))
|
|
(should (ebox-test--lines-have-text-p
|
|
(plist-get state :content-lines) "updated row 01"))
|
|
(setq state
|
|
(ebox--scroll-state-ensure-prefix-lines root-id state 8))
|
|
(should (ebox-test--lines-have-text-p
|
|
(plist-get state :content-lines) "updated row 01"))))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-prefix-does-not-publish-unflushed-batch ()
|
|
"Extending a lazy prefix should preserve committed content until batch flush."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 12 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-wheel-scroll-step 0)
|
|
(ebox-wheel-smooth-scroll nil))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(target-id (ebox-test--selector-region-id buffer "#row-01"))
|
|
(initial-revision
|
|
(plist-get (ebox--buffer-render-state buffer)
|
|
:runtime-revision)))
|
|
(ebox-test--set-buffer-update-report
|
|
buffer '(:strategy span-patch))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(ebox-test--region-update target-id :content "unflushed row 01")
|
|
(should (= (plist-get (ebox--buffer-render-state buffer)
|
|
:runtime-revision)
|
|
initial-revision))
|
|
(let ((state
|
|
(ebox--scroll-state-ensure-prefix-lines
|
|
root-id (ebox-scroll-state root-id) 8)))
|
|
(should (ebox-test--lines-have-text-p
|
|
(plist-get state :content-lines) "row 01"))
|
|
(should-not (ebox-test--lines-have-text-p
|
|
(plist-get state :content-lines)
|
|
"unflushed row 01")))
|
|
(should (= 2 (ebox--scroll-region-by root-id 2)))
|
|
(should (= (plist-get (ebox--buffer-render-state buffer)
|
|
:runtime-revision)
|
|
(+ initial-revision 1)))
|
|
(should (= -2 (ebox--scroll-region-by root-id -2)))
|
|
(should (= (plist-get (ebox--buffer-render-state buffer)
|
|
:runtime-revision)
|
|
(+ initial-revision 2)))
|
|
(should (string-match-p "row 01"
|
|
(substring-no-properties (buffer-string))))
|
|
(should-not (string-match-p
|
|
"unflushed row 01"
|
|
(substring-no-properties (buffer-string))))
|
|
(ebox-incremental-flush buffer)
|
|
(should (= (plist-get (ebox--buffer-render-state buffer)
|
|
:runtime-revision)
|
|
(+ initial-revision 3)))
|
|
(should (ebox-test--lines-have-text-p
|
|
(plist-get (ebox-scroll-state root-id) :content-lines)
|
|
"unflushed row 01"))
|
|
(should (string-match-p
|
|
"unflushed row 01"
|
|
(substring-no-properties (buffer-string))))))))
|
|
|
|
(ert-deftest ebox-explicit-batch-synchronizes-complete-scroll-paint-cache ()
|
|
"A paint-only batch should not replay stale complete scroll cache faces."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--complete-lazy-scroll-batch-layout))
|
|
(ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-wheel-scroll-step 16)
|
|
(ebox-scroll-lazy-idle-prefetch-lines 0))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(target-id (ebox-test--selector-region-id buffer "#target")))
|
|
(should (plist-get (ebox-scroll-state root-id)
|
|
:content-lines-complete-p))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(ebox-test--region-update target-id :color "#DC2626")
|
|
(ebox-incremental-flush buffer)
|
|
(let* ((runtime (gethash target-id ebox--region-box-table))
|
|
(visible-before
|
|
(ebox-test--text-has-foreground-p
|
|
(buffer-string) "target row" "#DC2626"))
|
|
(state (ebox-scroll-state root-id))
|
|
(content-cache
|
|
(ebox-test--lines-have-text-foreground-p
|
|
(plist-get state :content-lines)
|
|
"target row" "#DC2626"))
|
|
(rendered-cache
|
|
(ebox-test--lines-have-text-foreground-p
|
|
(plist-get state :rendered-content-lines)
|
|
"target row" "#DC2626")))
|
|
(should (= 2 (ebox--scroll-region-by root-id 2)))
|
|
(should (= -2 (ebox--scroll-region-by root-id -2)))
|
|
(should
|
|
(equal
|
|
(list (ebox-get runtime :color)
|
|
visible-before
|
|
content-cache
|
|
rendered-cache
|
|
(ebox-test--text-has-foreground-p
|
|
(buffer-string) "target row" "#DC2626"))
|
|
'("#DC2626" t t t t))))))))
|
|
|
|
(ert-deftest ebox-explicit-batch-synchronizes-distinct-scroll-cache-nodes ()
|
|
"A batch flush should update every affected cached scroll node."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test--complete-lazy-scroll-batch-layout))
|
|
(ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
(ebox-wheel-scroll-step 16)
|
|
(ebox-scroll-lazy-idle-prefetch-lines 0))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(target-id (ebox-test--selector-region-id buffer "#target"))
|
|
(second-id
|
|
(ebox-test--selector-region-id buffer "#second-target")))
|
|
(should (plist-get (ebox-scroll-state root-id)
|
|
:content-lines-complete-p))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(with-temp-buffer
|
|
(ebox-test--region-update target-id :color "#DC2626")
|
|
(ebox-test--region-update second-id :color "#2563EB")
|
|
(ebox-incremental-flush buffer))
|
|
(let* ((state (ebox-scroll-state root-id))
|
|
(content-lines (plist-get state :content-lines))
|
|
(rendered-lines (plist-get state :rendered-content-lines)))
|
|
(should (ebox-test--lines-have-text-foreground-p
|
|
content-lines "target row" "#DC2626"))
|
|
(should (ebox-test--lines-have-text-foreground-p
|
|
rendered-lines "target row" "#DC2626"))
|
|
(should (ebox-test--lines-have-text-foreground-p
|
|
content-lines "second target row" "#2563EB"))
|
|
(should (ebox-test--lines-have-text-foreground-p
|
|
rendered-lines "second target row" "#2563EB")))))))
|
|
|
|
(ert-deftest ebox-explicit-batch-flush-retries-after-quit ()
|
|
"A quit during a batch flush must keep the recorded changes retryable."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Batch retry") :color "black"))
|
|
(id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(let ((buffer (current-buffer)))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(ebox-test--region-update id :color "red")
|
|
(should (= (ebox-incremental-pending-count buffer) 1))
|
|
(cl-letf (((symbol-function 'ebox--publish-surface-region-batch)
|
|
(lambda (&rest _) (signal 'quit nil))))
|
|
(condition-case nil
|
|
(ebox-incremental-flush buffer)
|
|
(quit nil)))
|
|
;; The interrupted flush published nothing, so the batch must
|
|
;; still be active with the recorded change pending.
|
|
(should (ebox-incremental-batching-p buffer))
|
|
(should (= (ebox-incremental-pending-count buffer) 1))
|
|
;; The retry must execute the preserved change instead of no-op.
|
|
(let ((report (ebox-incremental-flush buffer)))
|
|
(should report)
|
|
(should-not (eq (plist-get report :strategy) 'no-op)))
|
|
(should-not (ebox-incremental-batching-p buffer))
|
|
(should (= (ebox-incremental-pending-count buffer) 0)))
|
|
(goto-char (point-min))
|
|
(search-forward "Batch retry")
|
|
(let ((face (get-text-property (- (point) (length "Batch retry"))
|
|
'face)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :foreground "red")))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-prefix-reuses-signature-cache ()
|
|
"Lazy prefix extension should not recompute node signatures each slice."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll))
|
|
(source-node
|
|
(ebox-test-column :width '(viewport)
|
|
(apply #'ebox-test-flex
|
|
(append
|
|
(list :id "signature-flex" :width '(viewport) :flex-flow
|
|
'(row wrap) :row-gap 1 :column-gap '(8) :padding
|
|
'(0 (12)) :border-right "#D97757" :border-bottom
|
|
"#D97757" :border-left "#D97757")
|
|
(cl-loop for idx below 24 collect
|
|
(ebox-test-box (ebox-test-text (format "signature %02d" idx))
|
|
:width '(100) :height 2))))))
|
|
(combined-input (ebox-test-forest-input layout source-node))
|
|
(ebox--render-source-index
|
|
(ebox-test-source-index combined-input))
|
|
(source (list :stack (ebox-test-root source-node)))
|
|
(renderer (ebox--scroll-window-prefix-renderer
|
|
(ebox-test-root layout) source 240 2 nil nil))
|
|
signature-caches
|
|
(original-node-signature
|
|
(symbol-function 'ebox--render-cache-node-signature)))
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox--render-cache-table (make-hash-table :test 'equal))
|
|
(ebox--render-cache-signature-cache nil))
|
|
(cl-letf (((symbol-function 'ebox--render-cache-node-signature)
|
|
(lambda (node)
|
|
(when (and (listp node)
|
|
ebox--render-cache-signature-cache)
|
|
(push ebox--render-cache-signature-cache
|
|
signature-caches))
|
|
(funcall original-node-signature node))))
|
|
(funcall renderer (list :rendered-content-lines nil)
|
|
"signature-region" 4)
|
|
(funcall renderer (list :rendered-content-lines nil)
|
|
"signature-region" 8))
|
|
(should (> (length signature-caches) 1))
|
|
(let ((first-cache (car signature-caches)))
|
|
(dolist (cache (cdr signature-caches))
|
|
(should (eq cache first-cache)))))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-first-smooth-wheel-uses-prefetched-prefix ()
|
|
"The first smooth wheel input should only replace visible lines."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 16)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-interval 60.0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix)))
|
|
(should (<= (+ (plist-get state :content-height)
|
|
ebox-wheel-scroll-step)
|
|
(length (plist-get state :content-lines))))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 16))
|
|
(while (gethash root-id ebox--smooth-scroll-state-table)
|
|
(ebox--smooth-scroll-tick root-id))
|
|
(should (string-match-p "row 16"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))))
|
|
(should (= prefix-count 0))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
16)))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-incremental-smooth-wheel-uses-prefetched-prefix ()
|
|
"Post-resize lazy scroll should not extend prefixes inside smooth ticks."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id
|
|
(format "resize-row-%02d" idx)
|
|
(ebox-test-text (format "resize row %02d" idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(prefix-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 6)
|
|
(ebox--defer-scroll-content-index t)
|
|
(ebox-wheel-smooth-scroll t)
|
|
(ebox-wheel-scroll-step 16)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 4)
|
|
(ebox-wheel-smooth-scroll-interval 60.0)
|
|
(ebox-scroll-lazy-idle-prefetch-lines 0)
|
|
(ebox--scroll-allow-noninteractive-prefetch nil)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 8))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix)))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (&rest args)
|
|
(cl-incf prefix-count)
|
|
(apply render-prefix args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 16))
|
|
(while (gethash root-id ebox--smooth-scroll-state-table)
|
|
(ebox--smooth-scroll-tick root-id))
|
|
(should (string-match-p "resize row 16"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))))
|
|
(should (= prefix-count 0))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
16)))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-schedules-urgent-scroll-prefetch ()
|
|
"Viewport rerender should warm lazy scroll prefixes before fast scrolling."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 80 collect
|
|
(ebox-test-box :id
|
|
(format "urgent-row-%02d" idx)
|
|
(ebox-test-text (format "urgent row %02d" idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
delays
|
|
(original-schedule
|
|
(symbol-function 'ebox--scroll-schedule-idle-prefetch)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 4)
|
|
(ebox-scroll-lazy-idle-prefetch-delay 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch)
|
|
(lambda (region-id &optional delay)
|
|
(push delay delays)
|
|
(funcall original-schedule region-id delay))))
|
|
(ebox-rerender-buffer-with-context buffer 300 4))
|
|
(should (member 0 delays)))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-smooth-scroll-defers-gc-during-animation ()
|
|
"Smooth scrolling should keep render GC settings until the animation stops."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(old-threshold gc-cons-threshold)
|
|
(old-percentage gc-cons-percentage)
|
|
(target-threshold 2000))
|
|
(unwind-protect
|
|
(let ((gc-cons-threshold 1000)
|
|
(ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-render-gc-cons-threshold target-threshold)
|
|
(ebox-deferred-render-gc-cons-threshold target-threshold)
|
|
(ebox-render-gc-cons-percentage 0.9)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 1)
|
|
(ebox-wheel-smooth-scroll-interval 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(with-current-buffer buffer
|
|
(should (ebox--smooth-scroll-region-by root-id 4)))
|
|
(should (>= gc-cons-threshold target-threshold))
|
|
(should (>= gc-cons-percentage 0.9))
|
|
(should ebox--deferred-render-gc-state)
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(should (timerp ebox--deferred-render-gc-timer)))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(when (timerp ebox--deferred-render-gc-timer)
|
|
(cancel-timer ebox--deferred-render-gc-timer))
|
|
(setq ebox--deferred-render-gc-timer nil)
|
|
(setq ebox--deferred-render-gc-state nil)
|
|
(setq gc-cons-threshold old-threshold)
|
|
(setq gc-cons-percentage old-percentage)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(defun ebox-test--visible-foreground (face)
|
|
"Return the highest-priority :foreground carried by FACE."
|
|
(cond
|
|
((null face) nil)
|
|
((and (listp face) (keywordp (car face)))
|
|
(plist-get face :foreground))
|
|
((listp face)
|
|
(cl-some #'ebox-test--visible-foreground face))
|
|
(t nil)))
|
|
|
|
(ert-deftest ebox-ancestor-paint-patch-preserves-descendant-own-paint ()
|
|
"A parent paint-patch must not clobber a child's own declared paint."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id 'parent :width '(120) :color "black"
|
|
(ebox-test-column :width '(120)
|
|
(ebox-test-box :id 'child (ebox-test-text "Child") :width '(120)
|
|
:color "blue")
|
|
(ebox-test-box :id 'plain (ebox-test-text "Plain") :width '(120))))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(parent-id (ebox-test--selector-region-id buffer "#parent")))
|
|
(ebox-test--region-update parent-id :color "red")
|
|
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
|
|
'paint-patch))
|
|
(goto-char (point-min))
|
|
(search-forward "Child")
|
|
;; The child's own declaration stays the highest-priority paint.
|
|
(should (equal (ebox-test--visible-foreground
|
|
(get-text-property (match-beginning 0) 'face))
|
|
"blue"))
|
|
(goto-char (point-min))
|
|
(search-forward "Plain")
|
|
;; A non-declaring sibling inherits the new parent paint.
|
|
(should (equal (ebox-test--visible-foreground
|
|
(get-text-property (match-beginning 0) 'face))
|
|
"red"))))))
|
|
|
|
(ert-deftest ebox-intrinsic-measurement-preserves-scroll-state ()
|
|
"Intrinsic measurement must not reset live scroll offsets or state."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 20 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 4))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(with-current-buffer buffer
|
|
(should (ebox--scroll-region-by root-id 5 48)))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
5))
|
|
(let ((box (gethash root-id ebox--region-box-table)))
|
|
(should box)
|
|
;; Flex ancestors measure live subtrees with the intrinsic
|
|
;; flag bound; the measurement render must be side-effect
|
|
;; free for the published runtime.
|
|
(let ((ebox--intrinsic-layout-measurement t))
|
|
(ebox--render-node
|
|
box
|
|
(plist-get (ebox--buffer-render-state buffer)
|
|
:source-index)))
|
|
(should (equal (ebox-get box :scroll-offset) 5)))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should state)
|
|
(should (= (plist-get state :scroll-offset) 5))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-rerender-retries-after-quit ()
|
|
"A quit during viewport reflow must not wedge later same-size reflows."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) (ebox-test-text "viewport reflow retry")))
|
|
(buffer nil))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(cl-letf (((symbol-function 'tp-surface-update-scoped)
|
|
(lambda (&rest _) (signal 'quit nil))))
|
|
(condition-case nil
|
|
(ebox-rerender-buffer-with-context buffer 320)
|
|
(quit nil)))
|
|
;; The interrupted reflow published nothing, so the stored
|
|
;; dimensions must still match the published render.
|
|
(should (equal (plist-get (ebox--buffer-render-state buffer)
|
|
:viewport-width)
|
|
240))
|
|
;; The same-size retry must reflow instead of no-op.
|
|
(let ((report (ebox-rerender-buffer-with-context buffer 320)))
|
|
(should report)
|
|
(should-not (eq (plist-get report :strategy) 'no-op)))
|
|
(should (equal (plist-get (ebox--buffer-render-state buffer)
|
|
:viewport-width)
|
|
320)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-smooth-scroll-multi-event-restores-gc-depth ()
|
|
"Coalesced wheel events must not leak the deferred GC lease depth."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(old-threshold gc-cons-threshold)
|
|
(old-percentage gc-cons-percentage))
|
|
(unwind-protect
|
|
(let ((gc-cons-threshold 1000)
|
|
(ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-deferred-render-gc-cons-threshold 2000)
|
|
(ebox-render-gc-cons-percentage 0.9)
|
|
(ebox-wheel-smooth-scroll-lines-per-tick 1)
|
|
(ebox-wheel-smooth-scroll-interval 999))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(with-current-buffer buffer
|
|
;; One trackpad flick delivers many wheel events that
|
|
;; coalesce into a single running animation.
|
|
(dotimes (_ 5)
|
|
(should (ebox--smooth-scroll-region-by root-id 4))))
|
|
(should (= ebox--deferred-render-gc-depth 1))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(should (= ebox--deferred-render-gc-depth 0))
|
|
(should (timerp ebox--deferred-render-gc-timer)))
|
|
(when root-id
|
|
(ebox--smooth-scroll-stop root-id))
|
|
(when (timerp ebox--deferred-render-gc-timer)
|
|
(cancel-timer ebox--deferred-render-gc-timer))
|
|
(setq ebox--deferred-render-gc-timer nil)
|
|
(setq ebox--deferred-render-gc-state nil)
|
|
(setq ebox--deferred-render-gc-depth 0)
|
|
(setq gc-cons-threshold old-threshold)
|
|
(setq gc-cons-percentage old-percentage)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-smooth-scroll-clamps-without-full-materialize ()
|
|
"Smooth wheel clamp should extend lazy scroll prefixes without full render."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id
|
|
(hidden-render-count 0)
|
|
(original-render-box (symbol-function 'ebox--render-box)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-wheel-scroll-step 16))
|
|
(cl-letf (((symbol-function 'ebox--render-box)
|
|
(lambda (box)
|
|
(when (equal
|
|
(ebox-tree-node-id
|
|
(ebox-test-source-index layout) box)
|
|
"row-30")
|
|
(cl-incf hidden-render-count))
|
|
(funcall original-render-box box))))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let ((ebox-wheel-smooth-scroll-lines-per-tick 1)
|
|
(ebox-wheel-smooth-scroll-interval 60.0))
|
|
(should (ebox--smooth-scroll-region-by root-id 2))
|
|
(should (= hidden-render-count 0))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (= (plist-get state :scroll-offset) 1))
|
|
(should (< (length (plist-get state :content-lines)) 40))
|
|
(should (plist-get state :materialize-content-lines))))))
|
|
(ebox--smooth-scroll-stop root-id)
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-hidden-child-update-does-not-materialize ()
|
|
"Hidden updates inside lazy scroll windows should stay deferred."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 32 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
(buffer nil)
|
|
root-id target-id next-target-id
|
|
(materialize-count 0)
|
|
(line-span-scans 0)
|
|
(membership-builds 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(setq target-id (ebox-test--selector-region-id buffer "#row-30"))
|
|
(setq next-target-id
|
|
(ebox-test--selector-region-id buffer "#row-31"))
|
|
(should target-id)
|
|
(should next-target-id)
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (plist-get state :materialize-content-lines))
|
|
(let ((materialize (plist-get state
|
|
:materialize-content-lines)))
|
|
(plist-put
|
|
state :materialize-content-lines
|
|
(lambda (&rest args)
|
|
(cl-incf materialize-count)
|
|
(apply materialize args)))
|
|
(puthash root-id state ebox--scroll-global-state)))
|
|
(let ((original-line-span-scan
|
|
(symbol-function 'ebox--scroll-state-region-line-spans))
|
|
(original-membership-build
|
|
(symbol-function
|
|
'ebox--scroll-build-content-region-id-set)))
|
|
(cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans)
|
|
(lambda (&rest args)
|
|
(cl-incf line-span-scans)
|
|
(apply original-line-span-scan args)))
|
|
((symbol-function
|
|
'ebox--scroll-build-content-region-id-set)
|
|
(lambda (&rest args)
|
|
(cl-incf membership-builds)
|
|
(apply original-membership-build args))))
|
|
(ebox-selector-update-buffer buffer "#row-30"
|
|
:content "new row 30")
|
|
(ebox-selector-update-buffer buffer "#row-31"
|
|
:content "new row 31")))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (= materialize-count 0))
|
|
(should (= line-span-scans 0))
|
|
(should (= membership-builds 1))
|
|
(should (plist-get (ebox--scroll-get-state root-id)
|
|
:content-region-id-set))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (plist-get report :lazy-scroll-deferred-patch)))
|
|
(should-not (string-match-p "new row 30"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (ebox--scroll-region-by root-id 30))
|
|
(should (= materialize-count 0))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should-not (plist-get state :content-lines-complete-p))
|
|
(should (gethash root-id ebox--scroll-idle-prefetch-timers)))
|
|
(ebox--scroll-idle-prefetch root-id)
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(delta (- 30 (or (plist-get state :scroll-offset)
|
|
0))))
|
|
(when (> delta 0)
|
|
(should (ebox--scroll-region-by root-id delta))))
|
|
(let ((state (ebox--scroll-get-state root-id)))
|
|
(should (plist-get state :content-lines-complete-p))
|
|
(should-not (plist-get state :materialize-content-lines)))
|
|
(should (string-match-p "new row 30"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-prefix-extension-keeps-live-window-settled ()
|
|
"A clean offscreen prefix extension should not stall the live window."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for index below 40 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" index))
|
|
:width '(300) :height 1))))))
|
|
root-id
|
|
(ebox-viewport-width 300)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id
|
|
(current-buffer) "#root"))
|
|
(let* ((state (ebox-scroll-state root-id))
|
|
(required-lines
|
|
(+ 5 (length (plist-get state :content-lines)))))
|
|
(should-not (plist-get state :content-lines-complete-p))
|
|
(setq state
|
|
(ebox--scroll-state-ensure-prefix-lines
|
|
root-id state required-lines t))
|
|
(should-not
|
|
(plist-get state :lazy-scroll-window-refresh-required)))
|
|
(should (= (ebox--scroll-region-by root-id 1 1) 1))
|
|
(should-not (plist-get (ebox-scroll-state root-id)
|
|
:lazy-scroll-window-refresh-required))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-prefix-refreshes-without-offset-motion ()
|
|
"A visibly changed prefix should publish atomically without offset motion."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for index below 40 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" index))
|
|
:width '(300) :height 1))))))
|
|
root-id
|
|
(ebox-viewport-width 300)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id
|
|
(current-buffer) "#root"))
|
|
(let* ((state (ebox-scroll-state root-id))
|
|
(render-prefix (plist-get state :render-content-prefix))
|
|
(required-lines
|
|
(+ 5 (length (plist-get state :content-lines)))))
|
|
(plist-put
|
|
state :render-content-prefix
|
|
(lambda (source-state region-id limit)
|
|
(let* ((rendered
|
|
(funcall render-prefix source-state region-id limit))
|
|
(lines
|
|
(copy-sequence
|
|
(plist-get rendered :rendered-content-lines)))
|
|
(first (copy-sequence (car lines))))
|
|
(when (> (length first) 0)
|
|
(put-text-property 0 1 'ebox-test-generation t first))
|
|
(setf (car lines) first)
|
|
(plist-put rendered :rendered-content-lines lines)
|
|
rendered)))
|
|
(ebox--scroll-state-ensure-prefix-lines
|
|
root-id state required-lines t)
|
|
(should (plist-get (ebox-scroll-state root-id)
|
|
:lazy-scroll-window-refresh-required)))
|
|
(should (eq (ebox--scroll-region-by root-id 0) 'refreshed))
|
|
(should (= (plist-get (ebox-scroll-state root-id) :scroll-offset) 0))
|
|
(should-not (plist-get (ebox-scroll-state root-id)
|
|
:lazy-scroll-window-refresh-required))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-visible-geometry-slot-miss-refreshes-lazy-window-atomically ()
|
|
"A visible slot miss should publish rebuilt lazy lines before returning."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append
|
|
(list :width '(viewport)
|
|
(ebox-test-flex :width '(300) :height 5 :padding
|
|
'(0 (10)) :border "#6F6A95"
|
|
:justify-content 'center :align-items
|
|
'center
|
|
(ebox-test-box :id "target" (ebox-test-text "width target\nexpands")
|
|
:width '(190) :height 3
|
|
:padding '(0 (10))
|
|
:border "#9CA3AF"
|
|
:text-align 'center)))
|
|
(cl-loop for index below 40 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" index))
|
|
:width '(300) :height 1))))))
|
|
root-id target-id
|
|
(ebox-viewport-width 300)
|
|
(ebox-viewport-height 5)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 12))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id
|
|
(current-buffer) "#root"))
|
|
(setq target-id (ebox-test--selector-region-id
|
|
(current-buffer) "#target"))
|
|
(let ((state (ebox-scroll-state root-id)))
|
|
(should (plist-get state :render-content-prefix))
|
|
(should-not (plist-get state :content-lines-complete-p))
|
|
;; Cache enough untouched lines that the next one-line scroll does not
|
|
;; cross the old prefix boundary. Dirty-state refresh must not depend
|
|
;; on reaching that boundary.
|
|
(setq state
|
|
(ebox--scroll-state-ensure-prefix-lines
|
|
root-id state 12 t))
|
|
(should (> (- (length (plist-get state :content-lines))
|
|
(plist-get state :content-height))
|
|
1)))
|
|
(ebox-test--region-update target-id :width '(280))
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(state (ebox-scroll-state root-id)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (plist-get report :scroll-state-sync-refreshed))
|
|
(should (= (plist-get report :scroll-state-sync-refreshed-count) 1))
|
|
(should-not (plist-get report :scroll-state-sync-deferred))
|
|
(should-not (plist-get state :lazy-scroll-prefix-dirty))
|
|
(should-not (plist-get state
|
|
:lazy-scroll-window-refresh-required))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))
|
|
;; Later movement must start from the already-published cache generation.
|
|
(should (= (ebox--scroll-region-by root-id 1 1) 1))
|
|
(should-not (plist-get (ebox-scroll-state root-id)
|
|
:lazy-scroll-prefix-dirty))
|
|
(should-not (plist-get (ebox-scroll-state root-id)
|
|
:lazy-scroll-window-refresh-required))
|
|
(should (= (ebox--scroll-region-by root-id -1 1) -1))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-cached-hidden-child-update-patches-prefix ()
|
|
"Hidden updates should patch cached lazy prefix lines before deferring."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
buffer
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(should (ebox--scroll-region-by root-id 30 32))
|
|
(should (string-match-p "row 30"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (ebox--scroll-region-by root-id -30))
|
|
(should-not (string-match-p "new row 30"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(ebox-selector-update-buffer buffer "#row-30"
|
|
:content "new row 30")
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should-not (plist-get report :lazy-scroll-deferred-patch))
|
|
(should (plist-get report :scroll-state-patch)))
|
|
(should (ebox--scroll-region-by root-id 30))
|
|
(let ((plain (buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (string-match-p "new row 30" plain))
|
|
(should-not (string-match-p "^row 30$" plain)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-deferred-update-dirties-future-prefix ()
|
|
"Deferred hidden updates should be visible when lazy prefix reaches them."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
buffer
|
|
root-id
|
|
(materialize-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox--scroll-get-state root-id))
|
|
(materialize (plist-get state :materialize-content-lines)))
|
|
(plist-put
|
|
state :materialize-content-lines
|
|
(lambda (&rest args)
|
|
(cl-incf materialize-count)
|
|
(apply materialize args)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(ebox-selector-update-buffer buffer "#row-30"
|
|
:content "new row 30")
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (plist-get report :lazy-scroll-deferred-patch)))
|
|
(should (= materialize-count 0))
|
|
(cl-loop repeat 30
|
|
do (should (ebox--scroll-region-by root-id 1 1)))
|
|
(should (= materialize-count 0))
|
|
(let ((plain (buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (string-match-p "new row 30" plain))
|
|
(should-not (string-match-p "^row 30$" plain)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-dirty-prefix-rebinds-signature-cache ()
|
|
"Dirty lazy prefixes should rebind the render signature cache before rendering."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "") :width 10))
|
|
(renderer (ebox--scroll-window-prefix-renderer
|
|
(ebox-test-root box) :test-source 120 4))
|
|
first-cache
|
|
second-cache
|
|
(render-count 0))
|
|
(cl-letf (((symbol-function 'ebox--render-scroll-window-source)
|
|
(lambda (&rest _args)
|
|
(cl-incf render-count)
|
|
(if first-cache
|
|
(setq second-cache ebox--render-cache-signature-cache)
|
|
(setq first-cache ebox--render-cache-signature-cache))
|
|
(list :lines (list (format "line %d" render-count))
|
|
:cache nil
|
|
:complete nil
|
|
:content-width-exact-p t
|
|
:rendered-lines
|
|
(list (format "line %d" render-count)))))
|
|
((symbol-function 'ebox--scroll-window-formatted-lines)
|
|
(lambda (_box window)
|
|
(plist-get window :lines)))
|
|
((symbol-function 'ebox--scroll-rendered-content-lines)
|
|
(lambda (_box lines _region-id _start-index)
|
|
lines)))
|
|
(funcall renderer nil 1 1)
|
|
(puthash :stale t first-cache)
|
|
(funcall renderer '(:lazy-scroll-prefix-dirty t) 1 1)
|
|
(should (= render-count 2))
|
|
(should-not (eq first-cache second-cache))
|
|
(should-not (gethash :stale second-cache)))))
|
|
|
|
(ert-deftest ebox-visible-scroll-child-content-paint-patches-scroll-lines ()
|
|
"Visible content plus paint updates should patch scroll lines directly."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 8 collect
|
|
(ebox-test-box :id (format "metric-%02d" idx)
|
|
(ebox-test-text "待运行") :width '(120)
|
|
:height 2))))))
|
|
buffer
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 260)
|
|
(ebox-viewport-height 4)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(ebox-selector-update-buffer buffer "#metric-01"
|
|
:content "结果:通过"
|
|
:color "#B91C1C")
|
|
(let* ((report (ebox-test--buffer-update-report))
|
|
(state (ebox--scroll-get-state root-id))
|
|
(plain (mapconcat #'substring-no-properties
|
|
(plist-get state :content-lines)
|
|
"\n")))
|
|
(should (plist-get report :scroll-state-patch))
|
|
(should (string-match-p "结果:通过" plain))
|
|
(should-not (string-match-p "metric-01.*待运行" plain)))
|
|
(should (ebox--scroll-region-by root-id 1 1))
|
|
(let ((plain (buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (string-match-p "结果:通过" plain)))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-visible-scroll-child-sync-retains-tp-mounts ()
|
|
"Post-patch scroll line sync should retain TP-owned viewport ranges."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 8 collect
|
|
(ebox-test-box :id (format "metric-%02d" idx)
|
|
(ebox-test-text "待运行") :width '(120)
|
|
:height 2))))))
|
|
buffer
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 260)
|
|
(ebox-viewport-height 4)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(ebox-selector-update-buffer buffer "#metric-01"
|
|
:padding-left (list 8)
|
|
:color "#B91C1C")
|
|
(let* ((report (ebox-test--buffer-update-report))
|
|
(state (ebox--scroll-get-state root-id)))
|
|
(should (plist-get report :scroll-state-sync))
|
|
(ebox-test--assert-scoped-tp-publication report)
|
|
(should (eq (plist-get state :buffer) buffer))
|
|
(should (ebox-surface-region-mounts buffer root-id)))
|
|
(should (ebox--scroll-region-by root-id 1 1))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-scroll-retains-tp-mounts-after-paint-update ()
|
|
"Paint updates and later scrolling should retain TP-owned ranges."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 24 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :height
|
|
1))))))
|
|
buffer
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 260)
|
|
(ebox-viewport-height 4)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((surface ebox-surface--buffer-surface)
|
|
(revision (tp-surface-revision surface)))
|
|
(should (ebox-surface-region-mounts buffer root-id))
|
|
(ebox-selector-update-buffer buffer "#row-01"
|
|
:color "#B91C1C")
|
|
(should (= (tp-surface-revision surface) (1+ revision)))
|
|
(setq revision (tp-surface-revision surface))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (plist-get report :scroll-state-paint-sync))
|
|
(ebox-test--assert-scoped-tp-publication report))
|
|
(cl-loop repeat 6
|
|
do
|
|
(should (ebox--scroll-region-by root-id 1 1))
|
|
(should (= (tp-surface-revision surface)
|
|
(1+ revision)))
|
|
(setq revision (tp-surface-revision surface))
|
|
(should (ebox-surface-region-mounts buffer root-id))
|
|
(should
|
|
(ebox-test--buffer-visually-matches-runtime-render-p))))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-owner-rerender-without-scroll-skips-scroll-state-lookup ()
|
|
"A scroll-free root reflow should not inspect scroll containment."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-column :width '(viewport)
|
|
(ebox-test-box (ebox-test-text "resize") :width '(viewport))))
|
|
buffer
|
|
(containment-count 0)
|
|
(original-containment
|
|
(symbol-function
|
|
'ebox--scroll-state-region-ids-containing-node-ids)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 4)
|
|
(ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm nil))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-no-scroll-publication*")
|
|
layout))
|
|
(cl-letf
|
|
(((symbol-function
|
|
'ebox--scroll-state-region-ids-containing-node-ids)
|
|
(lambda (&rest arguments)
|
|
(cl-incf containment-count)
|
|
(apply original-containment arguments))))
|
|
(ebox-rerender-buffer-with-context buffer 260 4))
|
|
(should (= containment-count 0))
|
|
(with-current-buffer buffer
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-hidden-geometry-update-defers-rerender ()
|
|
"Hidden geometry changes in lazy scroll windows should not rerender roots."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 32 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :width
|
|
'(120) :height 1))))))
|
|
(buffer nil)
|
|
target-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq target-id (ebox-test--selector-region-id buffer "#row-30"))
|
|
(should target-id)
|
|
(ebox-selector-update-buffer buffer "#row-30" :width '(180))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (plist-get report :lazy-scroll-deferred-patch)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-hidden-paint-update-keeps-paint-path ()
|
|
"Paint-only updates should not pay lazy scroll geometry deferral costs."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append (list :width '(viewport))
|
|
(cl-loop for idx below 32 collect
|
|
(ebox-test-box :id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx)) :color
|
|
"#111111" :height 1))))))
|
|
(buffer nil)
|
|
target-id)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 2))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*")
|
|
layout))
|
|
(with-current-buffer buffer
|
|
(setq target-id (ebox-test--selector-region-id buffer "#row-30"))
|
|
(should target-id)
|
|
(ebox-selector-update-buffer buffer "#row-30" :color "#cc0000")
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'paint-patch))
|
|
(should-not (plist-get report :lazy-scroll-deferred-patch)))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-scroll-state-set-lines-defers-line-indexes-when-bound ()
|
|
"Deferred scroll state setup should avoid building full line indexes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((content-lines (list (propertize "a" 'ebox-content 1)
|
|
(propertize "b" 'ebox-content 2)))
|
|
(rendered-lines (list (propertize "a" 'ebox-content 1)))
|
|
(build-count 0)
|
|
(original (symbol-function 'ebox--scroll-build-region-line-span-index))
|
|
(ebox--defer-scroll-content-index t))
|
|
(cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index)
|
|
(lambda (&rest args)
|
|
(cl-incf build-count)
|
|
(apply original args))))
|
|
(let ((state (ebox--scroll-state-set-lines nil content-lines
|
|
rendered-lines)))
|
|
(should (= build-count 0))
|
|
(should-not (plist-get state :region-line-span-index))
|
|
(should (plist-get state :region-line-span-index-deferred))
|
|
(should-not (plist-get state :rendered-region-line-span-index))
|
|
(should (plist-get state
|
|
:rendered-region-line-span-index-deferred))))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-set-state-defers-line-indexes ()
|
|
"Lazy scroll setup should not build hidden content indexes on first render."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((content-lines (list (propertize "a" 'ebox-content 1)
|
|
(propertize "b" 'ebox-content 2)))
|
|
(rendered-lines (list (propertize "a" 'ebox-content 1)))
|
|
(build-count 0)
|
|
(original (symbol-function 'ebox--scroll-build-region-line-span-index))
|
|
(ebox-scroll-lazy-idle-prefetch-lines 0))
|
|
(cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index)
|
|
(lambda (&rest args)
|
|
(cl-incf build-count)
|
|
(apply original args))))
|
|
(ebox--scroll-set-state
|
|
42
|
|
(list :scroll-offset 0
|
|
:content-height 1
|
|
:content-lines content-lines
|
|
:rendered-content-lines rendered-lines
|
|
:render-content-prefix (lambda (&rest _args) nil)
|
|
:materialize-content-lines (lambda (&rest _args) nil)))
|
|
(let ((state (ebox--scroll-get-state 42)))
|
|
(should (= build-count 0))
|
|
(should-not (plist-get state :region-line-span-index))
|
|
(should (plist-get state :region-line-span-index-deferred))
|
|
(should-not (plist-get state :rendered-region-line-span-index))
|
|
(should (plist-get state
|
|
:rendered-region-line-span-index-deferred))))))
|
|
|
|
(ert-deftest ebox-reflow-cache-prewarm-uses-first-valid-width-delta ()
|
|
"First resize prediction should wait a fresh interval even while idle."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height 2
|
|
(ebox-test-box (ebox-test-text "steady") :width '(viewport)
|
|
:height 1)))
|
|
buffer
|
|
scheduled-delay
|
|
scheduled-repeat
|
|
scheduled-function
|
|
scheduled-arguments)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 4)
|
|
(ebox-runtime-idle-prewarm nil)
|
|
(ebox-runtime-idle-reflow-cache-prewarm t)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*") layout))
|
|
(cl-letf (((symbol-function 'current-idle-time)
|
|
(lambda () (seconds-to-time 0.4)))
|
|
((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
|
|
(+ 0.4 ebox-runtime-idle-reflow-cache-prewarm-delay)))
|
|
(should-not scheduled-repeat)
|
|
(should (eq scheduled-function #'ebox--run-reflow-cache-prewarm))
|
|
(should (equal (car scheduled-arguments) buffer))
|
|
(should (eq (gethash buffer ebox--reflow-cache-prewarm-timers)
|
|
'scheduled-reflow-timer))
|
|
(should (= (plist-get (ebox--buffer-render-state buffer)
|
|
:last-viewport-width-delta)
|
|
20)))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-reflow-cache-prewarm-isolates-published-runtime-state ()
|
|
"Predicted renders should publish only persistent render-cache entries."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-flex
|
|
(append (list :width '(viewport) :gap 1)
|
|
(cl-loop for index below 8 collect
|
|
(ebox-test-box :id (format "card-%d" index)
|
|
(ebox-test-text (format "card %d" index)) :width
|
|
72 :height 1))))))
|
|
buffer)
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 4)
|
|
(ebox-runtime-idle-prewarm nil))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*") layout))
|
|
(ebox--cancel-buffer-runtime-prewarm buffer)
|
|
(let* ((state (ebox--buffer-render-state buffer))
|
|
(root (plist-get state :root-node))
|
|
(root-before (copy-tree root))
|
|
(text-before
|
|
(with-current-buffer buffer (buffer-string)))
|
|
(cache (plist-get state :render-cache))
|
|
(cache-count (hash-table-count cache))
|
|
(signature-count
|
|
(hash-table-count
|
|
(plist-get state :render-signature-cache)))
|
|
(root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(scroll-before
|
|
(copy-tree (ebox--scroll-get-state root-id)))
|
|
(region-table-count (hash-table-count ebox--region-box-table)))
|
|
(ebox-test--set-buffer-update-report
|
|
buffer 'keep-this-report)
|
|
(should
|
|
(ebox--prewarm-buffer-render-cache-at-context
|
|
buffer state 0 240 280 4))
|
|
(should (> (hash-table-count cache) cache-count))
|
|
(should (= (hash-table-count
|
|
(plist-get state :render-signature-cache))
|
|
signature-count))
|
|
(should (equal root root-before))
|
|
(should (equal-including-properties
|
|
(with-current-buffer buffer (buffer-string))
|
|
text-before))
|
|
(should (= (plist-get state :viewport-width) 240))
|
|
(should (equal (ebox--scroll-get-state root-id) scroll-before))
|
|
(should (= (hash-table-count ebox--region-box-table)
|
|
region-table-count))
|
|
(should (eq (ebox-buffer-update-report buffer)
|
|
'keep-this-report))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-reflow-cache-prewarm-rejects-stale-runtime-revision ()
|
|
"A stale predicted render should exit before touching render caches."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) (ebox-test-text "stale")))
|
|
buffer
|
|
(render-count 0)
|
|
(original-render (symbol-function 'ebox-render)))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 4)
|
|
(ebox-runtime-idle-prewarm nil))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-test*") layout))
|
|
(ebox--cancel-buffer-runtime-prewarm buffer)
|
|
(let* ((state (ebox--buffer-render-state buffer))
|
|
(cache (plist-get state :render-cache))
|
|
(cache-count (hash-table-count cache)))
|
|
(cl-letf (((symbol-function 'ebox-render)
|
|
(lambda (&rest args)
|
|
(cl-incf render-count)
|
|
(apply original-render args))))
|
|
(should-not
|
|
(ebox--prewarm-buffer-render-cache-at-context
|
|
buffer state 1 240 280 4)))
|
|
(should (= render-count 0))
|
|
(should (= (hash-table-count cache) cache-count))))
|
|
(when (and buffer (buffer-live-p buffer))
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-prepares-shared-update-state ()
|
|
"Idle prewarm should prepare shared update state without materializing scrolls."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height 2 :overflow
|
|
'scroll
|
|
(apply #'ebox-test-column
|
|
(append
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box :id (format "row-%02d" idx) (ebox-test-text (format "row %02d" idx)) :height 1))))))
|
|
(buffer nil)
|
|
root-id
|
|
expected-viewport-ids
|
|
(materialize-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 900)
|
|
(ebox-scroll-lazy-idle-prefetch-lines 0)
|
|
(ebox-runtime-idle-prewarm-slice-size 10000)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*") layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(setq expected-viewport-ids
|
|
(ebox--viewport-dependent-node-ids
|
|
(ebox-test--runtime-root buffer)))
|
|
(let* ((state (ebox-scroll-state root-id))
|
|
(before-lines (length (plist-get state :content-lines)))
|
|
(materialize (plist-get state :materialize-content-lines)))
|
|
(when materialize
|
|
(plist-put state :materialize-content-lines
|
|
(lambda (&rest args)
|
|
(cl-incf materialize-count)
|
|
(apply materialize args))))
|
|
(ebox--runtime-prewarm-drain buffer)
|
|
(let ((render-state (ebox--buffer-render-state buffer))
|
|
(warmed (ebox-scroll-state root-id)))
|
|
(should
|
|
(plist-get render-state :layout-snapshots-complete-p))
|
|
(should
|
|
(plist-get render-state :viewport-dependent-node-ids-ready))
|
|
(should
|
|
(equal (plist-get render-state :viewport-dependent-node-ids)
|
|
expected-viewport-ids))
|
|
(should (plist-get warmed :region-line-span-index))
|
|
(should (plist-get warmed :rendered-region-line-span-index))
|
|
(should (= (length (plist-get warmed :content-lines))
|
|
before-lines))
|
|
(should (= materialize-count 0)))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-visible-buffer-rearms-native-scroll-prewarm ()
|
|
"A buffer rendered off-window should switch to native scrolling when shown."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil)
|
|
(scheduled 0)
|
|
(fake-window 'ebox-test-window))
|
|
(unwind-protect
|
|
(let ((ebox--runtime-prewarm-allow-noninteractive t)
|
|
(ebox-native-buffer-scroll t)
|
|
;; The production hook is intentionally GUI-only. Bind the
|
|
;; flag for this lifecycle test and stub the window primitives.
|
|
(noninteractive nil))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-visible-prewarm*")
|
|
(ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "one") :height 1)
|
|
(ebox-test-box (ebox-test-text "two") :height 1)
|
|
(ebox-test-box (ebox-test-text "three") :height 1)))))
|
|
;; Rendering happened before the buffer had a window, so the
|
|
;; original job is allowed to finish without native materializing.
|
|
(ebox--runtime-prewarm-drain buffer)
|
|
(should-not (plist-get (ebox--buffer-render-state buffer)
|
|
:native-buffer-scroll-p))
|
|
(cl-letf (((symbol-function 'window-live-p)
|
|
(lambda (window) (eq window fake-window)))
|
|
((symbol-function 'window-buffer)
|
|
(lambda (_window) buffer))
|
|
((symbol-function 'get-buffer-window)
|
|
(lambda (candidate &optional _frame)
|
|
(and (eq candidate buffer) fake-window)))
|
|
((symbol-function 'ebox--viewport-dependent-subtree-p)
|
|
(lambda (&rest _) nil))
|
|
((symbol-function
|
|
'ebox--viewport-height-dependent-subtree-p)
|
|
(lambda (&rest _) nil))
|
|
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
|
|
(lambda (candidate &optional _delay _retained-only-p)
|
|
(should (eq candidate buffer))
|
|
(cl-incf scheduled))))
|
|
(let ((render-state (ebox--buffer-render-state buffer)))
|
|
;; This fixture stands for a viewport-independent root; the
|
|
;; production proof consumes the published axes in O(1).
|
|
(plist-put render-state :viewport-dependent-node-ids-ready t)
|
|
(plist-put render-state :viewport-dependent-node-id-axes
|
|
(list nil))
|
|
(let ((job (ebox--runtime-prewarm-new-job
|
|
buffer render-state)))
|
|
(should (eq (plist-get job :phase) 'native-scroll))
|
|
(should (plist-get job :native-scroll-region-id))))
|
|
(ebox--window-buffer-change fake-window))
|
|
(should (= scheduled 1)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-native-scroll-prewarm-is-isolated-from-updates ()
|
|
"Ordinary runtime prewarm must not materialize a native scroll surface.
|
|
Native-window scrolling remains enabled, but only the initial display or an
|
|
explicit visible-window handoff may admit its full-content publication."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil))
|
|
(unwind-protect
|
|
(let ((noninteractive nil)
|
|
(ebox--runtime-prewarm-allow-noninteractive t)
|
|
(ebox-native-buffer-scroll t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-native-scroll-opt-in*")
|
|
(ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "one") :height 1)
|
|
(ebox-test-box (ebox-test-text "two") :height 1)
|
|
(ebox-test-box (ebox-test-text "three") :height 1)))))
|
|
(let ((state (ebox--buffer-render-state buffer)))
|
|
(cl-letf (((symbol-function
|
|
'ebox--native-buffer-scroll-root-region-id)
|
|
(lambda (&rest _) 1))
|
|
((symbol-function
|
|
'ebox--native-buffer-scroll-root-proof-p)
|
|
(lambda (&rest _) t)))
|
|
(let ((job (ebox--runtime-prewarm-new-job buffer state t)))
|
|
(should (eq (plist-get job :phase) 'snapshots))
|
|
(should-not (plist-get job :native-scroll-region-id)))
|
|
(let ((job (ebox--runtime-prewarm-new-job buffer state nil)))
|
|
(should (eq (plist-get job :phase) 'native-scroll))
|
|
(should (= (plist-get job :native-scroll-region-id) 1))))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-native-scroll-rejects-viewport-dependent-root ()
|
|
"Native full-content materialization must not freeze a resizable root."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil)
|
|
(fake-window 'ebox-test-window))
|
|
(unwind-protect
|
|
(let ((noninteractive nil)
|
|
(ebox-viewport-width 120)
|
|
(ebox-viewport-height 2)
|
|
(ebox-native-buffer-scroll t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-native-scroll-viewport*")
|
|
(ebox-test-box :id "root" :width '(viewport) :height 2 :overflow
|
|
'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "one") :height 1)
|
|
(ebox-test-box (ebox-test-text "two") :height 1)
|
|
(ebox-test-box (ebox-test-text "three") :height 1)))))
|
|
(let* ((state (ebox--buffer-render-state buffer))
|
|
(region-id (car (plist-get state :scroll-region-ids))))
|
|
(cl-letf (((symbol-function 'get-buffer-window)
|
|
(lambda (&rest _) fake-window))
|
|
((symbol-function 'window-live-p)
|
|
(lambda (window) (eq window fake-window))))
|
|
(should-not
|
|
(ebox--native-buffer-scroll-root-proof-p
|
|
buffer region-id state)))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-does-not-wait-for-prefix-prefetch ()
|
|
"Prewarm should index current scroll lines while lazy prefix work is active."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "target" (ebox-test-text "target") :height
|
|
1)
|
|
(ebox-test-box :id "row-1" (ebox-test-text "row 1") :height 1)
|
|
(ebox-test-box :id "row-2" (ebox-test-text "row 2") :height 1))))
|
|
(buffer nil)
|
|
root-id)
|
|
(unwind-protect
|
|
(let ((ebox-runtime-idle-prewarm-slice-size 10000)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*") layout))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(puthash root-id t ebox--scroll-idle-prefetch-timers)
|
|
(ebox--runtime-prewarm-drain buffer)
|
|
(let ((state (ebox-scroll-state root-id)))
|
|
(should (plist-get state :region-line-span-index))
|
|
(should (plist-get state :rendered-region-line-span-index))))
|
|
(remhash root-id ebox--scroll-idle-prefetch-timers)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-takes-priority-over-prefix-prefetch ()
|
|
"Lazy prefix warming should yield while shared runtime prewarming is active."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil)
|
|
root-id
|
|
(prefix-render-count 0)
|
|
exact-prefix
|
|
scheduled-delay)
|
|
(unwind-protect
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 8)
|
|
(ebox--scroll-allow-noninteractive-prefetch t)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*")
|
|
(ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "row 0") :height 1)
|
|
(ebox-test-box (ebox-test-text "row 1") :height 1)))))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let* ((state (ebox-scroll-state root-id))
|
|
(job (gethash buffer ebox--runtime-prewarm-jobs)))
|
|
(setq state (plist-put state :render-content-prefix t))
|
|
(setq state (plist-put state :content-lines-complete-p nil))
|
|
(setq state (plist-put state :buffer buffer))
|
|
(puthash root-id state ebox--scroll-global-state)
|
|
(cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines)
|
|
(lambda (&rest _args)
|
|
(cl-incf prefix-render-count)
|
|
state))
|
|
((symbol-function 'ebox--scroll-schedule-idle-prefetch)
|
|
(lambda (_region-id &optional delay)
|
|
(setq scheduled-delay delay))))
|
|
(ebox--scroll-idle-prefetch root-id))
|
|
(should (= prefix-render-count 0))
|
|
(should (eq (gethash buffer ebox--runtime-prewarm-jobs) job))
|
|
(should (equal scheduled-delay
|
|
ebox-runtime-idle-prewarm-prefix-resume-delay))))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-does-not-delay-cache-miss-prefetch ()
|
|
"Urgent cache-miss prefetch should bypass shared prewarm priority."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil)
|
|
root-id
|
|
(prefix-render-count 0)
|
|
scheduled-delay)
|
|
(unwind-protect
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 8)
|
|
(ebox--scroll-allow-noninteractive-prefetch t)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*")
|
|
(ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "row 0") :height 1)
|
|
(ebox-test-box (ebox-test-text "row 1") :height 1)))))
|
|
(setq root-id (ebox-test--selector-region-id buffer "#root"))
|
|
(let ((state (ebox-scroll-state root-id)))
|
|
(setq state (plist-put state :render-content-prefix t))
|
|
(setq state (plist-put state :content-lines-complete-p nil))
|
|
(setq state (plist-put state :cache-miss-prefetch-target-lines 8))
|
|
(setq state (plist-put state :buffer buffer))
|
|
(puthash root-id state ebox--scroll-global-state)
|
|
(cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines)
|
|
(lambda (_region-id input-state target-lines
|
|
&optional exact)
|
|
(cl-incf prefix-render-count)
|
|
(setq exact-prefix exact)
|
|
(setq input-state
|
|
(plist-put input-state :content-lines
|
|
(make-list target-lines
|
|
(ebox-pixel-space 1))))
|
|
(plist-put input-state :content-lines-complete-p t)))
|
|
((symbol-function 'ebox--scroll-schedule-idle-prefetch)
|
|
(lambda (_region-id &optional delay)
|
|
(setq scheduled-delay delay)))
|
|
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
|
|
(lambda (&rest _args) nil)))
|
|
(ebox--scroll-idle-prefetch root-id)))
|
|
(should (= prefix-render-count 1))
|
|
(should exact-prefix)
|
|
(should-not (equal scheduled-delay
|
|
ebox-runtime-idle-prewarm-prefix-resume-delay)))
|
|
(ebox--scroll-cancel-idle-prefetch root-id)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-idle-prefix-slices-do-not-restart-runtime-prewarm ()
|
|
"Consecutive prefix slices should not starve behind repeated tree prewarms."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((buffer (generate-new-buffer " *ebox-prefix-slice-test*"))
|
|
(region-id "prefix-slice-scroll")
|
|
(state (list :buffer buffer
|
|
:scroll-offset 0
|
|
:content-height 2
|
|
:content-lines '("row 0" "row 1")
|
|
:render-content-prefix t
|
|
:content-lines-complete-p nil))
|
|
(runtime-prewarm-count 0)
|
|
(prefix-reschedule-count 0))
|
|
(unwind-protect
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 8)
|
|
(ebox-scroll-lazy-idle-prefetch-slice-lines 1))
|
|
(puthash region-id state ebox--scroll-global-state)
|
|
(cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines)
|
|
(lambda (_region-id input-state _target-lines
|
|
&optional _exact)
|
|
(plist-put input-state :content-lines
|
|
(append (plist-get input-state :content-lines)
|
|
'("row 2")))
|
|
(puthash region-id input-state
|
|
ebox--scroll-global-state)
|
|
input-state))
|
|
((symbol-function 'ebox--scroll-schedule-idle-prefetch)
|
|
(lambda (&rest _args)
|
|
(cl-incf prefix-reschedule-count)))
|
|
((symbol-function 'ebox--schedule-buffer-runtime-prewarm)
|
|
(lambda (&rest _args)
|
|
(cl-incf runtime-prewarm-count))))
|
|
(ebox--scroll-idle-prefetch region-id))
|
|
(should (= prefix-reschedule-count 1))
|
|
(should (= runtime-prewarm-count 0)))
|
|
(remhash region-id ebox--scroll-global-state)
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-yields-between-slices ()
|
|
"Each prewarm slice should leave an idle interval for redisplay."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil)
|
|
scheduled-delay)
|
|
(unwind-protect
|
|
(let ((ebox-runtime-idle-prewarm-delay 0.1)
|
|
(ebox-runtime-idle-prewarm-slice-size 1)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*")
|
|
(ebox-test-box :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "row 0") :height 1)
|
|
(ebox-test-box (ebox-test-text "row 1") :height 1)
|
|
(ebox-test-box (ebox-test-text "row 2") :height 1)))))
|
|
(cl-letf (((symbol-function 'current-idle-time)
|
|
(lambda () (seconds-to-time 0.4)))
|
|
((symbol-function 'ebox--runtime-prewarm-schedule-timer)
|
|
(lambda (_buffer &optional delay)
|
|
(setq scheduled-delay delay))))
|
|
(ebox--runtime-prewarm-timer buffer))
|
|
(should (equal scheduled-delay 0.5)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-discards-stale-runtime-revisions ()
|
|
"A runtime mutation should invalidate an older sliced prewarm job."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil))
|
|
(unwind-protect
|
|
(let ((ebox-runtime-idle-prewarm-slice-size 1)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*")
|
|
(ebox-test-column (ebox-test-box :id "a" (ebox-test-text "A"))
|
|
(ebox-test-box :id "b" (ebox-test-text "B")))))
|
|
(should (gethash buffer ebox--runtime-prewarm-jobs))
|
|
(ebox--bump-buffer-runtime-revision buffer)
|
|
(should-not (ebox--runtime-prewarm-step buffer))
|
|
(should-not (gethash buffer ebox--runtime-prewarm-jobs)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-reschedules-after-batched-updates ()
|
|
"A successful explicit batch should schedule shared-state prewarming."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil))
|
|
(unwind-protect
|
|
(let ((ebox-runtime-idle-prewarm-slice-size 10000)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*")
|
|
(ebox-test-column (ebox-test-box :id "target" (ebox-test-text "old"))
|
|
(ebox-test-box :id "sibling" (ebox-test-text "same")))))
|
|
(ebox--runtime-prewarm-drain buffer)
|
|
(should-not (gethash buffer ebox--runtime-prewarm-jobs))
|
|
(let ((target-id
|
|
(ebox-test--selector-region-id buffer "#target")))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(with-current-buffer buffer
|
|
(ebox-test--region-update target-id :color "#DC2626")
|
|
(ebox-incremental-flush buffer)))
|
|
(let ((job (gethash buffer ebox--runtime-prewarm-jobs))
|
|
(state (ebox--buffer-render-state buffer)))
|
|
(should job)
|
|
(should (= (plist-get job :runtime-revision)
|
|
(plist-get state :runtime-revision)))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-cancels-during-open-batch ()
|
|
"An unflushed batch mutation should cancel stale shared-state prewarming."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil))
|
|
(unwind-protect
|
|
(let ((ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*")
|
|
(ebox-test-box :id "target" (ebox-test-text "old"))))
|
|
(should (gethash buffer ebox--runtime-prewarm-jobs))
|
|
(let ((target-id
|
|
(ebox-test--selector-region-id buffer "#target")))
|
|
(ebox-incremental-begin-batch buffer)
|
|
(with-current-buffer buffer
|
|
(ebox-test--region-update target-id :padding-left 9)))
|
|
(should (= (ebox-incremental-pending-count buffer) 1))
|
|
(should-not (gethash buffer ebox--runtime-prewarm-jobs))
|
|
(should-not (gethash buffer ebox--runtime-prewarm-timers)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-avoids-unbounded-tree-scans ()
|
|
"Scheduling and draining prewarm should not scan all nodes in one callback."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil)
|
|
(node-table-scan-count 0)
|
|
(viewport-scan-count 0))
|
|
(unwind-protect
|
|
(let ((ebox--runtime-prewarm-allow-noninteractive t)
|
|
(ebox-runtime-idle-prewarm-slice-size 1))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*")
|
|
(ebox-test-column (ebox-test-box (ebox-test-text "row 0"))
|
|
(ebox-test-box (ebox-test-text "row 1"))
|
|
(ebox-test-box (ebox-test-text "row 2")))))
|
|
(ebox--cancel-buffer-runtime-prewarm buffer)
|
|
(let ((node-table (ebox--buffer-node-table buffer))
|
|
(original-maphash (symbol-function 'maphash)))
|
|
(cl-letf (((symbol-function 'maphash)
|
|
(lambda (function table)
|
|
(when (eq table node-table)
|
|
(cl-incf node-table-scan-count))
|
|
(funcall original-maphash function table))))
|
|
(ebox--schedule-buffer-runtime-prewarm buffer)))
|
|
(let ((original-viewport
|
|
(symbol-function
|
|
'ebox--buffer-viewport-dependent-node-ids)))
|
|
(cl-letf
|
|
(((symbol-function 'ebox--buffer-viewport-dependent-node-ids)
|
|
(lambda (&rest args)
|
|
(cl-incf viewport-scan-count)
|
|
(apply original-viewport args))))
|
|
(ebox--runtime-prewarm-drain buffer)))
|
|
(should (= node-table-scan-count 0))
|
|
(should (= viewport-scan-count 0)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-preserves-viewport-dependency-semantics ()
|
|
"Sliced dependency discovery should match the canonical tree algorithm."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 360)
|
|
(ebox-viewport-height 12)
|
|
(ebox-runtime-idle-prewarm-slice-size 1)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*")
|
|
(ebox-test-box :width '(210)
|
|
(ebox-test-column
|
|
(ebox-test-box :id "width-only" :width '(viewport))
|
|
(ebox-test-box :id "height-only" :height
|
|
'(viewport-height))
|
|
(ebox-test-flex :id "flex-height" :height
|
|
'(viewport-height)
|
|
(ebox-test-box (ebox-test-text "Flex")))))))
|
|
(let* ((state (ebox--buffer-render-state buffer))
|
|
(root (plist-get state :root-node))
|
|
(expected-axes
|
|
(let ((ebox--viewport-dependent-node-ids-cache
|
|
(make-hash-table :test 'eq))
|
|
(ebox--viewport-height-dependent-subtree-cache
|
|
(make-hash-table :test 'eq)))
|
|
(ebox--viewport-dependent-node-id-axes root)))
|
|
(expected
|
|
(delete-dups
|
|
(copy-sequence
|
|
(append (car expected-axes) (cdr expected-axes)))))
|
|
(width-only-node-id
|
|
(plist-get
|
|
(car (ebox-selector-query-buffer buffer "#width-only"))
|
|
:node-id))
|
|
(height-only-node-id
|
|
(plist-get
|
|
(car (ebox-selector-query-buffer buffer "#height-only"))
|
|
:node-id)))
|
|
(ebox--runtime-prewarm-drain buffer)
|
|
(should (equal (plist-get state :viewport-dependent-node-id-axes)
|
|
expected-axes))
|
|
(should (equal (plist-get state :viewport-dependent-node-ids)
|
|
expected))
|
|
(pcase-let ((`(,width-ids . ,height-ids)
|
|
(plist-get state
|
|
:viewport-dependent-node-id-axes)))
|
|
(should-not (member width-only-node-id width-ids))
|
|
(should-not (member width-only-node-id height-ids))
|
|
(should-not (member height-only-node-id width-ids))
|
|
(should (member height-only-node-id height-ids)))
|
|
(should (plist-get state :viewport-dependent-node-ids-ready))))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-viewport-reflow-consumes-prepared-context-axes ()
|
|
"A topology-stable viewport commit must not rederive its axis partition."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((buffer nil)
|
|
(axis-scans 0)
|
|
(validation-scans 0)
|
|
(inheritance-scans 0)
|
|
(reconciliations 0))
|
|
(unwind-protect
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 6))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-axis-proof*")
|
|
(ebox-test-column :width '(viewport)
|
|
(ebox-test-box (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1))))
|
|
(let ((original-axes
|
|
(symbol-function 'ebox-surface--context-axes))
|
|
(original-validation
|
|
(symbol-function 'ebox-tree-validate-declarative-root))
|
|
(original-inheritance
|
|
(symbol-function
|
|
'ebox-surface--inline-inheritance-required-p))
|
|
(original-reconcile
|
|
(symbol-function 'ebox-surface--reconcile-candidate)))
|
|
(cl-letf (((symbol-function 'ebox-surface--context-axes)
|
|
(lambda (&rest arguments)
|
|
(cl-incf axis-scans)
|
|
(apply original-axes arguments)))
|
|
((symbol-function 'ebox-tree-validate-declarative-root)
|
|
(lambda (&rest arguments)
|
|
(cl-incf validation-scans)
|
|
(apply original-validation arguments)))
|
|
((symbol-function
|
|
'ebox-surface--inline-inheritance-required-p)
|
|
(lambda (&rest arguments)
|
|
(cl-incf inheritance-scans)
|
|
(apply original-inheritance arguments)))
|
|
((symbol-function 'ebox-surface--reconcile-candidate)
|
|
(lambda (&rest arguments)
|
|
(cl-incf reconciliations)
|
|
(apply original-reconcile arguments))))
|
|
(let ((report
|
|
(ebox-rerender-buffer-with-context buffer 220 6)))
|
|
(should (eq (plist-get report :projection-kind)
|
|
'viewport-reflow)))))
|
|
(should (zerop axis-scans))
|
|
(should (zerop validation-scans))
|
|
(should (zerop inheritance-scans))
|
|
(should (zerop reconciliations)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-runtime-idle-prewarm-scroll-slices-use-list-cursors ()
|
|
"Late cached-line slices should not restart traversal from the list head."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append
|
|
(cl-loop for idx below 40 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" idx))
|
|
:height 1))))))
|
|
(buffer nil)
|
|
(nth-count 0)
|
|
(original-nth (symbol-function 'nth)))
|
|
(unwind-protect
|
|
(let ((ebox-scroll-lazy-idle-prefetch-lines 0)
|
|
(ebox-runtime-idle-prewarm-slice-size 3)
|
|
(ebox--runtime-prewarm-allow-noninteractive t))
|
|
(setq buffer
|
|
(ebox-render-to-buffer
|
|
(generate-new-buffer-name " *ebox-prewarm-test*") layout))
|
|
(cl-letf (((symbol-function 'nth)
|
|
(lambda (&rest args)
|
|
(cl-incf nth-count)
|
|
(apply original-nth args))))
|
|
(ebox--runtime-prewarm-drain buffer))
|
|
(should (= nth-count 0)))
|
|
(when (buffer-live-p buffer)
|
|
(kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-hidden-scroll-child-content-update-patches-scroll-state ()
|
|
"Hidden scroll children should update cached scroll lines without rerendering."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "visible") :height 1)
|
|
(ebox-test-box :id "target" (ebox-test-text "old") :height 1)
|
|
(ebox-test-box (ebox-test-text "tail") :height 1))))
|
|
root-id target-id)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
|
|
(setq target-id (ebox-test--selector-region-id (current-buffer)
|
|
"#target"))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "visible" plain))
|
|
(should-not (string-match-p "old" plain)))
|
|
(ebox-test--region-update target-id :content "new")
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (plist-get report :scroll-state-patch))
|
|
(should-not (plist-get report :full-rerender)))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "visible" plain))
|
|
(should-not (string-match-p "new" plain)))
|
|
(should (ebox--scroll-region-by root-id 1))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "new" plain))
|
|
(should-not (string-match-p "old" plain))))))
|
|
|
|
(ert-deftest ebox-visible-lazy-scroll-child-update-does-not-defer ()
|
|
"Lazy scroll children in the current visible range should patch immediately."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((children (cl-loop for idx below 40
|
|
collect
|
|
(ebox-test-box
|
|
:id (format "row-%02d" idx)
|
|
(ebox-test-text (format "row %02d" idx))
|
|
:height 1)))
|
|
(layout (ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(apply #'ebox-test-column (append children))))
|
|
root-id target-id)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
|
|
(should (ebox--scroll-region-by root-id 30))
|
|
(setq target-id (ebox-test--selector-region-id (current-buffer)
|
|
"#row-30"))
|
|
(should target-id)
|
|
(should (string-match-p "row 30"
|
|
(buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(cl-letf (((symbol-function 'ebox--region-ids-visible-in-buffer-p)
|
|
(lambda (&rest _args) nil)))
|
|
(ebox-test--region-update target-id :content "new row 30"))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(span-patch owner-rerender root-rerender)))
|
|
(should-not (plist-get report :lazy-scroll-deferred-patch)))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "new row 30" plain))))))
|
|
|
|
(ert-deftest ebox-visible-empty-scroll-child-content-update-settles-window ()
|
|
"An empty fixed box in a visible scene must not be mistaken for hidden."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append
|
|
(list :width '(viewport)
|
|
(ebox-test-flex :id "scene" :width '(viewport) :height 4
|
|
:align-items 'center :justify-content
|
|
'center
|
|
(ebox-test-box :id "target" (ebox-test-text "SEED") :width '(180)
|
|
:height 3 :overflow
|
|
'hidden :bgcolor
|
|
"#DDEAE4")))
|
|
(cl-loop for index below 40 collect
|
|
(ebox-test-box (ebox-test-text (format "row %02d" index))
|
|
:height 1))))))
|
|
(ebox-viewport-width 300)
|
|
(ebox-viewport-height 4)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 12)
|
|
root-id target-id)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root")
|
|
target-id (ebox-test--selector-region-id
|
|
(current-buffer) "#target"))
|
|
(should (plist-get (ebox-scroll-state root-id)
|
|
:render-content-prefix))
|
|
(ebox-test--region-update target-id :content "")
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (plist-get report :scroll-state-patch))
|
|
(should-not
|
|
(member (ebox--buffer-root-node-id (current-buffer))
|
|
(plist-get report :owner-ids))))
|
|
(ebox-test--region-update target-id :content "NOW VISIBLE")
|
|
(let ((report (ebox-test--buffer-update-report))
|
|
(state (ebox-scroll-state root-id)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (plist-get report :scroll-state-patch))
|
|
(should-not
|
|
(member (ebox--buffer-root-node-id (current-buffer))
|
|
(plist-get report :owner-ids)))
|
|
(should-not (plist-get report :lazy-scroll-deferred-patch))
|
|
(should-not (plist-get state :lazy-scroll-prefix-dirty))
|
|
(should-not (plist-get state
|
|
:lazy-scroll-window-refresh-required)))
|
|
(should (string-match-p
|
|
"NOW VISIBLE"
|
|
(buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-lazy-scroll-ignores-updates-to-sibling-status-box ()
|
|
"A sibling update must not materialize an unrelated lazy scroll state."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((rows (cl-loop for index below 40
|
|
collect
|
|
(ebox-test-box
|
|
:id (format "row-%02d" index)
|
|
(ebox-test-text (format "row %02d" index))
|
|
:height 1)))
|
|
(layout
|
|
(ebox-test-column :width '(viewport)
|
|
(ebox-test-box :id "rail" (ebox-test-text "ready") :width
|
|
'(viewport) :height 1)
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(apply #'ebox-test-column
|
|
(append rows)))))
|
|
(ebox-viewport-width 240)
|
|
(ebox-viewport-height 3)
|
|
(ebox-scroll-lazy-prefix-lookahead-lines 0)
|
|
rail-id root-id materialize-count)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq rail-id (ebox-test--selector-region-id
|
|
(current-buffer) "#rail")
|
|
root-id (ebox-test--selector-region-id
|
|
(current-buffer) "#root"))
|
|
(let* ((state (ebox-scroll-state root-id))
|
|
(materialize (plist-get state :materialize-content-lines)))
|
|
(should materialize)
|
|
(should-not (plist-get state :content-lines-complete-p))
|
|
(should (plist-get state :rendered-content-lines))
|
|
(plist-put
|
|
state :materialize-content-lines
|
|
(lambda (&rest arguments)
|
|
(cl-incf materialize-count)
|
|
(apply materialize arguments)))
|
|
(puthash root-id state ebox--scroll-global-state))
|
|
(ebox-test--region-update rail-id :content "updated")
|
|
(should (= (or materialize-count 0) 0))
|
|
(let ((state (ebox-scroll-state root-id)))
|
|
(should-not (plist-get state :content-lines-complete-p))
|
|
(should (plist-get state :rendered-content-lines)))
|
|
(should (string-match-p
|
|
"updated"
|
|
(buffer-substring-no-properties (point-min) (point-max)))))))
|
|
|
|
(ert-deftest ebox-scroll-reveal-keeps-visible-geometry-indexed ()
|
|
"Geometry updates on a revealed child should use a scoped TP plan."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id "root" :width '(180) :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "row 0") :height 1)
|
|
(ebox-test-box (ebox-test-text "row 1") :height 1)
|
|
(ebox-test-box (ebox-test-text "row 2") :height 1)
|
|
(ebox-test-box :id "target" (ebox-test-text "target") :width
|
|
'(120) :height 1 :box-sizing
|
|
'border-box :padding-left '(2)))))
|
|
root-id target-id)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(setq root-id (ebox-test--selector-region-id (current-buffer) "#root"))
|
|
(setq target-id
|
|
(ebox-test--selector-region-id (current-buffer) "#target"))
|
|
(should (ebox--scroll-region-by root-id 2 48))
|
|
(ebox-test--region-update target-id :padding-left (list 12))
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (eq (plist-get report :tp-render-scope) 'objects))
|
|
(should-not (plist-get report :tp-scope-fallback)))
|
|
(should (ebox-surface-region-mounts (current-buffer) target-id))
|
|
(should (ebox-test--buffer-visually-matches-runtime-render-p)))))
|
|
|
|
(ert-deftest ebox-wheel-scroll-without-hovered-text-keeps-native-routing ()
|
|
"Non-text and invalid wheel positions must not select any scroll owner."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 2 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1)
|
|
(ebox-test-box (ebox-test-text "C") :height 1))))
|
|
(root-id (car (ebox-region-ids layout)))
|
|
mwheel-args)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(save-window-excursion
|
|
(switch-to-buffer (current-buffer))
|
|
(let ((before (buffer-string)))
|
|
;; The hovered box is authoritative. An earlier contract routed
|
|
;; mode-line events to any root scroll box in the buffer instead.
|
|
(dolist (position (list 'mode-line 'left-fringe 'right-fringe
|
|
'vertical-scroll-bar nil
|
|
(point-max) (+ (point-max) 10)
|
|
(1- (point-min))))
|
|
(ert-info ((format "wheel position: %S" position))
|
|
(let ((event (list 'wheel-down
|
|
(list (selected-window) position '(0 . 0)
|
|
0 nil nil nil nil nil nil))))
|
|
(setq mwheel-args nil)
|
|
(cl-letf (((symbol-function 'mwheel-scroll)
|
|
(lambda (&rest args) (setq mwheel-args args))))
|
|
(ebox--wheel-scroll event 1 'prefix))
|
|
(should (equal mwheel-args (list event 'prefix)))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
0))
|
|
(should (equal-including-properties before (buffer-string)))))))))))
|
|
|
|
(defun ebox-test--wheel-columns ()
|
|
"Return a scroll box whose mounted lines are separated by another column."
|
|
(ebox-test-row
|
|
(ebox-test-box :id "peer" :width '(20) :height 3
|
|
(ebox-test-text "peer0\npeer1\npeer2"))
|
|
(ebox-test-box :id "help" :width '(20) :height 3 :overflow 'scroll
|
|
(ebox-test-text "help0\nhelp1\nhelp2\nhelp3\nhelp4"))))
|
|
|
|
(ert-deftest ebox-wheel-native-event-rejects-non-text-and-out-of-range-positions ()
|
|
"Native root wheel routing must validate the mouse position before dispatch."
|
|
(with-temp-buffer
|
|
(insert "abc")
|
|
(save-window-excursion
|
|
(switch-to-buffer (current-buffer))
|
|
(let (calls)
|
|
(cl-letf (((symbol-function 'ebox--native-buffer-scroll-at-position)
|
|
(lambda (buffer position delta)
|
|
(push (list buffer position delta) calls)
|
|
1)))
|
|
(dolist (position (list 'mode-line 'right-fringe nil
|
|
(point-max) (+ (point-max) 10)
|
|
(1- (point-min))))
|
|
(ert-info ((format "native wheel position: %S" position))
|
|
(ebox--native-buffer-scroll-at-event
|
|
(list 'wheel-down (list (selected-window) position '(0 . 0) 0))
|
|
1)
|
|
(should-not calls)))
|
|
(goto-char (point-max))
|
|
(should (= 1 (ebox--native-buffer-scroll-at-event
|
|
(list 'wheel-down (list (selected-window) 2 '(0 . 0) 0))
|
|
1)))
|
|
(should (equal calls (list (list (current-buffer) 2 1)))))))))
|
|
|
|
(ert-deftest ebox-scroll-mount-hit-test-excludes-between-line-column-gaps ()
|
|
"Only exact mounted spans belong to a multi-line scroll box."
|
|
(ebox-test--reset-runtime-state)
|
|
(ebox-test--with-rendered-buffer (ebox-test--wheel-columns)
|
|
(let* ((region-id (ebox-test--selector-region-id (current-buffer) "#help"))
|
|
(mounts (ebox-surface-region-mounts (current-buffer) region-id))
|
|
(bounds (ebox-surface-region-bounds (current-buffer) region-id))
|
|
(gap-count 0))
|
|
(should (>= (length mounts) 3))
|
|
(cl-loop for position from (car bounds) below (cdr bounds)
|
|
for inside = (cl-some
|
|
(lambda (mount)
|
|
(and (<= (plist-get mount :start) position)
|
|
(< position (plist-get mount :end))))
|
|
mounts)
|
|
do (unless inside (cl-incf gap-count))
|
|
do (ert-info ((format "mounted hit at position %S" position))
|
|
(should (eq (and (memq region-id
|
|
(ebox--scroll-region-ids-in-mounts-at-pos
|
|
position))
|
|
t)
|
|
inside))))
|
|
(should (> gap-count 0)))))
|
|
|
|
(ert-deftest ebox-wheel-scroll-follows-hovered-column-instead-of-point ()
|
|
"A column gap stays native; hovering help scrolls it with point elsewhere."
|
|
(ebox-test--reset-runtime-state)
|
|
(ebox-test--with-rendered-buffer (ebox-test--wheel-columns)
|
|
(save-window-excursion
|
|
(switch-to-buffer (current-buffer))
|
|
(let* ((region-id (ebox-test--selector-region-id (current-buffer) "#help"))
|
|
(mounts (ebox-surface-region-mounts (current-buffer) region-id))
|
|
(inside (plist-get (car mounts) :start))
|
|
(gap (save-excursion
|
|
(goto-char (point-min))
|
|
(search-forward "peer1")
|
|
(1- (point))))
|
|
(before (buffer-string))
|
|
native-events)
|
|
(should (< inside gap))
|
|
(should-not (cl-some (lambda (mount)
|
|
(and (<= (plist-get mount :start) gap)
|
|
(< gap (plist-get mount :end))))
|
|
mounts))
|
|
(goto-char inside)
|
|
(let ((event (list 'wheel-down
|
|
(list (selected-window) gap '(0 . 0) 0))))
|
|
(cl-letf (((symbol-function 'mwheel-scroll)
|
|
(lambda (&rest args) (push args native-events))))
|
|
(ebox--wheel-scroll event 1))
|
|
(should (equal native-events (list (list event nil))))
|
|
(should (= (plist-get (ebox-scroll-state region-id) :scroll-offset) 0))
|
|
(should (equal-including-properties before (buffer-string))))
|
|
(setq native-events nil)
|
|
(goto-char (point-min))
|
|
(let ((event (list 'wheel-down
|
|
(list (selected-window) inside '(0 . 0) 0))))
|
|
(cl-letf (((symbol-function 'mwheel-scroll)
|
|
(lambda (&rest args) (push args native-events))))
|
|
(ebox--wheel-scroll event 1)))
|
|
(should-not native-events)
|
|
(should (= (plist-get (ebox-scroll-state region-id) :scroll-offset) 1))
|
|
(should (string-match-p "help3" (buffer-string)))
|
|
(should-not (string-match-p "help0" (buffer-string)))
|
|
(should (string-match-p "peer0" (buffer-string)))))))
|
|
|
|
(ert-deftest ebox-scroll-first-region-ignores-zero-consumption ()
|
|
"A zero-line scroll result should not consume the scroll event."
|
|
(let (visited)
|
|
(cl-letf (((symbol-function 'ebox--scroll-region-by)
|
|
(lambda (region-id _delta &optional _prefix-budget-lines)
|
|
(push region-id visited)
|
|
(if (eq region-id 'stalled) 0 1))))
|
|
(should (ebox--scroll-first-region-by '(stalled active) 1)))
|
|
(should (equal (nreverse visited) '(stalled active)))))
|
|
|
|
(ert-deftest ebox-scroll-intent-distributes-forward-residual-inner-to-buffer ()
|
|
"A positive line intent should drain inner, parent, then native buffer."
|
|
(let ((capacity '((inner . 1) (middle . 2) (outer . 0)))
|
|
native-residual)
|
|
(cl-letf (((symbol-function 'ebox--scroll-region-by)
|
|
(lambda (region-id delta &optional _prefix-budget-lines)
|
|
(let* ((available (alist-get region-id capacity))
|
|
(consumed (min delta available)))
|
|
(setf (alist-get region-id capacity) (- available consumed))
|
|
consumed))))
|
|
(let ((result
|
|
(ebox--scroll-consume-intent
|
|
(ebox--scroll-intent-create 5 '(inner middle outer))
|
|
(lambda (delta) (setq native-residual delta)))))
|
|
(should (= (plist-get result :requested) 5))
|
|
(should (equal (plist-get result :consumption)
|
|
'((inner . 1) (middle . 2) (outer . 0))))
|
|
(should (= (plist-get result :ebox-consumed) 3))
|
|
(should (= (plist-get result :native-residual) 2))
|
|
(should (= native-residual 2))))))
|
|
|
|
(ert-deftest ebox-scroll-intent-distributes-reverse-residual-inner-to-buffer ()
|
|
"A negative line intent should preserve direction through the owner chain."
|
|
(let ((capacity '((inner . 1) (middle . 2) (outer . 0)))
|
|
native-residual)
|
|
(cl-letf (((symbol-function 'ebox--scroll-region-by)
|
|
(lambda (region-id delta &optional _prefix-budget-lines)
|
|
(let* ((available (alist-get region-id capacity))
|
|
(magnitude (min (- delta) available))
|
|
(consumed (- magnitude)))
|
|
(setf (alist-get region-id capacity) (- available magnitude))
|
|
consumed))))
|
|
(let ((result
|
|
(ebox--scroll-consume-intent
|
|
(ebox--scroll-intent-create -5 '(inner middle outer))
|
|
(lambda (delta) (setq native-residual delta)))))
|
|
(should (equal (plist-get result :consumption)
|
|
'((inner . -1) (middle . -2) (outer . 0))))
|
|
(should (= (plist-get result :ebox-consumed) -3))
|
|
(should (= (plist-get result :native-residual) -2))
|
|
(should (= native-residual -2))))))
|
|
|
|
(ert-deftest ebox-scroll-intent-distributes-real-nested-owner-capacity ()
|
|
"Nested boxes should consume their capacity before the native buffer."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id "outer" :height 4 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "middle" :height 2 :overflow
|
|
'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "inner" (ebox-test-text "inner 0\ninner 1")
|
|
:height 1 :overflow
|
|
'scroll)
|
|
(ebox-test-box (ebox-test-text "middle 0")
|
|
:height 1)
|
|
(ebox-test-box (ebox-test-text "middle 1")
|
|
:height 1)
|
|
(ebox-test-box (ebox-test-text "middle 2")
|
|
:height 1)))
|
|
(ebox-test-box (ebox-test-text "outer 0\nouter 1") :height 2)))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((surface (with-current-buffer
|
|
(current-buffer) ebox-surface--buffer-surface))
|
|
(revision (tp-surface-revision surface))
|
|
(inner-id (ebox-test--selector-region-id
|
|
(current-buffer) "#inner"))
|
|
(middle-id (ebox-test--selector-region-id
|
|
(current-buffer) "#middle"))
|
|
(outer-id (ebox-test--selector-region-id
|
|
(current-buffer) "#outer"))
|
|
native-lines)
|
|
(should (ebox-scroll-state inner-id))
|
|
(should (ebox-scroll-state middle-id))
|
|
(should-not (ebox-scroll-state outer-id))
|
|
(goto-char (point-min))
|
|
(search-forward "inner 0")
|
|
(backward-char)
|
|
(cl-letf (((symbol-function 'scroll-up)
|
|
(lambda (lines) (setq native-lines lines))))
|
|
(ebox-scroll-down 5))
|
|
;; Each owner is currently a separately committed retained surface
|
|
;; update; the later atomic multi-owner scroll transaction will reduce
|
|
;; this to one revision without changing the consumption contract.
|
|
(should (= (tp-surface-revision surface) (+ revision 2)))
|
|
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 1))
|
|
(should (= (plist-get (ebox-scroll-state middle-id) :scroll-offset) 2))
|
|
(should-not (ebox-scroll-state outer-id))
|
|
(should (= native-lines 2))
|
|
(setq native-lines nil)
|
|
(ebox--scroll-consume-intent
|
|
(ebox--scroll-intent-create -5
|
|
(list inner-id middle-id outer-id))
|
|
(lambda (residual) (setq native-lines residual)))
|
|
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset) 0))
|
|
(should (= (plist-get (ebox-scroll-state middle-id) :scroll-offset) 0))
|
|
(should-not (ebox-scroll-state outer-id))
|
|
(should (= native-lines -2))))))
|
|
|
|
(ert-deftest ebox-wheel-scroll-distributes-real-nested-capacity-both-directions ()
|
|
"Real wheel hits drain nested owners and preserve signed native residuals."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id "outer" :height 6 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "middle" :height 4 :overflow 'scroll
|
|
(ebox-test-column
|
|
;; Keep the inner viewport visible after the middle owner
|
|
;; moves, so the reverse event also uses an actual mouse hit.
|
|
(ebox-test-box (ebox-test-text "before 0\nbefore 1\nbefore 2")
|
|
:height 3)
|
|
(ebox-test-box :id "inner" :height 1 :overflow 'scroll
|
|
(ebox-test-text "inner 0\ninner 1"))
|
|
(ebox-test-box (ebox-test-text "middle 0\nmiddle 1") :height 2)))
|
|
(ebox-test-box (ebox-test-text "outer 0\nouter 1") :height 2)))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(save-window-excursion
|
|
(switch-to-buffer (current-buffer))
|
|
(let ((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")))
|
|
(dolist (delta '(5 -5))
|
|
(ert-info ((format "nested wheel request: %S" delta))
|
|
(let* ((forward-p (> delta 0))
|
|
(hover
|
|
(save-excursion
|
|
(goto-char (point-min))
|
|
(search-forward (if forward-p "inner 0" "inner 1"))
|
|
(1- (point))))
|
|
(event (list (if forward-p 'wheel-down 'wheel-up)
|
|
(list (selected-window) hover '(0 . 0) 0)))
|
|
native-calls result)
|
|
(goto-char (point-min))
|
|
(search-forward "outer 0")
|
|
(cl-letf (((symbol-function 'ebox--wheel-native-residual)
|
|
(lambda (received residual requested arg)
|
|
(push (list received residual requested arg)
|
|
native-calls))))
|
|
(setq result (ebox--wheel-scroll event delta)))
|
|
(should (= (plist-get (ebox-scroll-state inner-id) :scroll-offset)
|
|
(if forward-p 1 0)))
|
|
(should (= (plist-get (ebox-scroll-state middle-id) :scroll-offset)
|
|
(if forward-p 2 0)))
|
|
(should-not (ebox-scroll-state outer-id))
|
|
(should (= (alist-get inner-id (plist-get result :consumption))
|
|
(if forward-p 1 -1)))
|
|
(should (= (alist-get middle-id (plist-get result :consumption))
|
|
(if forward-p 2 -2)))
|
|
(should (= (plist-get result :requested) delta))
|
|
(should (= delta (+ (plist-get result :ebox-consumed)
|
|
(plist-get result :native-residual))))
|
|
(should (equal native-calls
|
|
(list (list event (if forward-p 2 -2) delta nil))))
|
|
(should (string-match-p (if forward-p "inner 1" "inner 0")
|
|
(buffer-string)))
|
|
(should (string-match-p "outer 0" (buffer-string)))))))))))
|
|
|
|
(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-mounts)
|
|
(lambda (&rest _) '((:start 1 :end 5))))
|
|
((symbol-function 'ebox--scroll-region-semantic-depth)
|
|
(lambda (_region-id) 2)))
|
|
(should (equal (ebox--scroll-region-ids-in-mounts-at-pos 2)
|
|
'(a-owner z-owner))))))
|
|
|
|
(ert-deftest ebox-scroll-fast-report-uses-boolean-proof-flag ()
|
|
"Retained-window proof payload should not leak into the public report."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout (ebox-test-box :id "root" (ebox-test-text "A\nB") :height 1 :overflow 'scroll)))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((region-id (ebox-test--selector-region-id
|
|
(current-buffer) "#root")))
|
|
(should (= (ebox--scroll-region-by region-id 1) 1))
|
|
(let ((flag (plist-get (ebox-test--buffer-update-report)
|
|
:scroll-patch-fast-p)))
|
|
(should (memq flag '(nil t))))))))
|
|
|
|
(ert-deftest ebox-scroll-defaults-follow-one-line-native-buffer-scrolling ()
|
|
"Default wheel input should be one immediate document line."
|
|
(should (= ebox-wheel-scroll-step 1))
|
|
(should-not ebox-wheel-smooth-scroll))
|
|
|
|
(ert-deftest ebox-scroll-progress-requires-real-delta-or-pending ()
|
|
"Scroll progress is either a non-zero consumed line count or lazy pending."
|
|
(should (ebox--scroll-progress-p 1))
|
|
(should (ebox--scroll-progress-p -1))
|
|
(should (ebox--scroll-progress-p 'pending))
|
|
(should-not (ebox--scroll-progress-p 'refreshed))
|
|
(should-not (ebox--scroll-progress-p 0))
|
|
(should-not (ebox--scroll-progress-p nil)))
|
|
|
|
(ert-deftest ebox-smooth-wheel-scroll-moves-immediately-then-drains ()
|
|
"Smooth wheel scrolling should respond immediately and drain pending lines."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1)
|
|
(ebox-test-box (ebox-test-text "C") :height 1))))
|
|
(root-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((ebox-wheel-smooth-scroll-lines-per-tick 1)
|
|
(ebox-wheel-smooth-scroll-interval 60.0))
|
|
(should (ebox--smooth-scroll-region-by root-id 2))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(ebox--smooth-scroll-tick root-id)
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
2))
|
|
(should-not (gethash root-id ebox--smooth-scroll-state-table))))))
|
|
|
|
(ert-deftest ebox-smooth-wheel-scroll-coalesces-while-timer-active ()
|
|
"Further wheel input should queue behind the active timer, not edit at once."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1)
|
|
(ebox-test-box (ebox-test-text "C") :height 1)
|
|
(ebox-test-box (ebox-test-text "D") :height 1))))
|
|
(root-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((ebox-wheel-smooth-scroll-lines-per-tick 1)
|
|
(ebox-wheel-smooth-scroll-interval 60.0))
|
|
(should (ebox--smooth-scroll-region-by root-id 2))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(should (gethash root-id ebox--smooth-scroll-state-table))
|
|
(should (ebox--smooth-scroll-region-by root-id 2))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(while (gethash root-id ebox--smooth-scroll-state-table)
|
|
(ebox--smooth-scroll-tick root-id))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
3))))))
|
|
|
|
(ert-deftest ebox-smooth-wheel-scroll-clamps-pending-to-range ()
|
|
"Large smooth wheel input should not leave an oversized timer backlog."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 1 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1)
|
|
(ebox-test-box (ebox-test-text "C") :height 1)
|
|
(ebox-test-box (ebox-test-text "D") :height 1))))
|
|
(root-id (car (ebox-region-ids layout))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((ebox-wheel-smooth-scroll-lines-per-tick 1)
|
|
(ebox-wheel-smooth-scroll-interval 60.0))
|
|
(should (ebox--smooth-scroll-region-by root-id 1000))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(should (<= (plist-get
|
|
(gethash root-id ebox--smooth-scroll-state-table)
|
|
:pending)
|
|
2))
|
|
(while (gethash root-id ebox--smooth-scroll-state-table)
|
|
(ebox--smooth-scroll-tick root-id))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
3))))))
|
|
|
|
(ert-deftest ebox-page-scroll-bubbles-from-child-to-scrollable-ancestor ()
|
|
"Page scrolling over a child box should scroll the nearest scrollable ancestor."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height 3 :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1)
|
|
(ebox-test-box (ebox-test-text "C") :height 1)
|
|
(ebox-test-box (ebox-test-text "D") :height 1)
|
|
(ebox-test-box (ebox-test-text "E") :height 1)
|
|
(ebox-test-box (ebox-test-text "F") :height 1))))
|
|
(root-id (car (ebox-region-ids layout)))
|
|
fallback-called)
|
|
(ebox-test--with-rendered-buffer layout
|
|
(goto-char (point-min))
|
|
(search-forward "A")
|
|
(backward-char)
|
|
(cl-letf (((symbol-function 'window-body-height)
|
|
(lambda (&optional _window _pixelwise) 5))
|
|
((symbol-function 'scroll-up-command)
|
|
(lambda (&optional _arg)
|
|
(setq fallback-called t))))
|
|
(ebox-scroll-page-down))
|
|
(should-not fallback-called)
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
3))
|
|
(let ((plain (buffer-substring-no-properties (point-min) (point-max))))
|
|
(should (string-match-p "D" plain))
|
|
(should (string-match-p "F" plain))
|
|
(should-not (string-match-p "A" plain))))))
|
|
|
|
(ert-deftest ebox-page-scroll-preserves-native-page-fallback-semantics ()
|
|
"Whole-page fallback should stay native; residuals should stay exact."
|
|
(let (down-argument up-argument partial-p)
|
|
(cl-letf (((symbol-function 'ebox--scroll-page-lines)
|
|
(lambda (&optional arg)
|
|
(if arg (prefix-numeric-value arg) 10)))
|
|
((symbol-function 'ebox--scroll-by)
|
|
(lambda (delta fallback)
|
|
(funcall fallback (if partial-p 2 (abs delta)))))
|
|
((symbol-function 'scroll-up-command)
|
|
(lambda (&optional arg) (setq down-argument arg)))
|
|
((symbol-function 'scroll-down-command)
|
|
(lambda (&optional arg) (setq up-argument arg))))
|
|
(ebox-scroll-page-down)
|
|
(ebox-scroll-page-up)
|
|
(should-not down-argument)
|
|
(should-not up-argument)
|
|
(ebox-scroll-page-down 7)
|
|
(ebox-scroll-page-up 7)
|
|
(should (= down-argument 7))
|
|
(should (= up-argument 7))
|
|
(setq partial-p t)
|
|
(ebox-scroll-page-down)
|
|
(ebox-scroll-page-up)
|
|
(should (= down-argument 2))
|
|
(should (= up-argument 2)))))
|
|
|
|
(ert-deftest ebox-scroll-rerender-preserves-viewport-height-context ()
|
|
"Scroll rerender fallback should keep viewport-height boxes clipped."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "root" :height '(viewport-height) :overflow 'scroll
|
|
(ebox-test-column
|
|
(ebox-test-box :id "child" (ebox-test-text "A") :height 1)
|
|
(ebox-test-box (ebox-test-text "B") :height 1)
|
|
(ebox-test-box (ebox-test-text "C") :height 1)
|
|
(ebox-test-box (ebox-test-text "D") :height 1))))
|
|
(root-id (car (ebox-region-ids layout))))
|
|
(let ((ebox-viewport-height 2))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:content-height)
|
|
2))
|
|
(goto-char (point-min))
|
|
(search-forward "A")
|
|
(backward-char)
|
|
(ebox-scroll-down 1)
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:content-height)
|
|
2))
|
|
(should (= (plist-get (ebox--scroll-get-state root-id)
|
|
:scroll-offset)
|
|
1))
|
|
(let ((plain (buffer-substring-no-properties
|
|
(point-min) (point-max))))
|
|
(should (string-match-p "B" plain))
|
|
(should (string-match-p "C" plain))
|
|
(should-not (string-match-p "A" plain)))))))
|
|
|
|
(ert-deftest ebox-wheel-scroll-falls-back-at-scroll-boundary ()
|
|
"Mouse wheel should bubble to the buffer when inner scroll is at its edge."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC")
|
|
:height 1
|
|
:overflow 'scroll))
|
|
(region-id (car (ebox-region-ids box)))
|
|
mwheel-args)
|
|
(ebox-test--with-rendered-buffer box
|
|
(should (ebox--scroll-region-by region-id 3))
|
|
(cl-letf (((symbol-function 'ebox--wheel-region-ids)
|
|
(lambda (_event) (list region-id)))
|
|
((symbol-function 'mwheel-scroll)
|
|
(lambda (&rest args)
|
|
(setq mwheel-args args))))
|
|
(ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg))
|
|
(should (equal mwheel-args '(fake-wheel-event fake-arg))))))
|
|
|
|
(ert-deftest ebox-scroll-map-keeps-wheel-on-event-aware-handler ()
|
|
"Mouse wheel bindings should use event-aware handlers, not line fallbacks."
|
|
(should (eq (lookup-key ebox-scroll-map [wheel-down])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (lookup-key ebox-scroll-map [wheel-up])
|
|
#'ebox-wheel-scroll-up))
|
|
(should (eq (lookup-key ebox-scroll-map [mouse-5])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (lookup-key ebox-scroll-map [mouse-4])
|
|
#'ebox-wheel-scroll-up))
|
|
(should (eq (lookup-key ebox-scroll-map [right-fringe wheel-down])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (lookup-key ebox-scroll-map [right-fringe wheel-up])
|
|
#'ebox-wheel-scroll-up))
|
|
(should (eq (lookup-key ebox-scroll-map [mode-line wheel-down])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (lookup-key ebox-scroll-map [mode-line wheel-up])
|
|
#'ebox-wheel-scroll-up))
|
|
(should (eq (lookup-key ebox-scroll-map [vertical-scroll-bar wheel-down])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (lookup-key ebox-scroll-map [vertical-scroll-bar wheel-up])
|
|
#'ebox-wheel-scroll-up))
|
|
(should (eq (lookup-key ebox-scroll-map [remap scroll-up-command])
|
|
#'ebox-scroll-page-down))
|
|
(should (eq (lookup-key ebox-scroll-map [remap scroll-down-command])
|
|
#'ebox-scroll-page-up))
|
|
(should (eq (lookup-key ebox-scroll-map (kbd "C-v"))
|
|
#'ebox-scroll-page-down))
|
|
(should (eq (lookup-key ebox-scroll-map (kbd "<next>"))
|
|
#'ebox-scroll-page-down))
|
|
(should (eq (lookup-key ebox-scroll-map (kbd "M-v"))
|
|
#'ebox-scroll-page-up))
|
|
(should (eq (lookup-key ebox-scroll-map (kbd "<prior>"))
|
|
#'ebox-scroll-page-up))
|
|
(should (eq (lookup-key ebox-scroll-map (kbd "n"))
|
|
#'ebox-scroll-down))
|
|
(should (eq (lookup-key ebox-scroll-map (kbd "p"))
|
|
#'ebox-scroll-up)))
|
|
|
|
(ert-deftest ebox-buffer-mode-overrides-pixel-scroll-bindings ()
|
|
"Ebox buffers should keep their own scroll bindings under pixel scrolling."
|
|
(unless (fboundp 'pixel-scroll-precision-mode)
|
|
(ert-skip "pixel-scroll-precision-mode is unavailable"))
|
|
(let ((was-enabled pixel-scroll-precision-mode))
|
|
(unwind-protect
|
|
(with-temp-buffer
|
|
(pixel-scroll-precision-mode 1)
|
|
(ebox-buffer-mode 1)
|
|
(should (eq (key-binding [wheel-down])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (key-binding [wheel-up])
|
|
#'ebox-wheel-scroll-up))
|
|
(should (eq (key-binding [right-fringe wheel-down])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (key-binding [mode-line wheel-down])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (key-binding [vertical-scroll-bar wheel-down])
|
|
#'ebox-wheel-scroll-down))
|
|
(should (eq (key-binding (kbd "<next>"))
|
|
#'ebox-scroll-page-down))
|
|
(should (eq (key-binding (kbd "<prior>"))
|
|
#'ebox-scroll-page-up)))
|
|
(unless was-enabled
|
|
(pixel-scroll-precision-mode -1)))))
|
|
|
|
(ert-deftest ebox-border-box-max-height-includes-vertical-padding ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A\nB\nC\nD")
|
|
:box-sizing 'border-box
|
|
:max-height 3
|
|
:padding '(1 0)
|
|
:overflow 'hidden))
|
|
(plain (substring-no-properties (ebox-render box))))
|
|
(should (= (ebox-string-height plain) 3))
|
|
(should-not (string-match-p "C" plain))
|
|
(should-not (string-match-p "D" plain))))
|
|
|
|
(ert-deftest ebox-content-box-min-height-wins-when-min-exceeds-max ()
|
|
"Content-box height conflicts should use the minimum as the used height."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A")
|
|
:box-sizing 'content-box
|
|
:min-height 5
|
|
:max-height 3
|
|
:padding '(1 0)
|
|
:overflow 'hidden))
|
|
(rendered (ebox-render box)))
|
|
(should (= (ebox-test--input-content-height box 1) 5))
|
|
(should (= (ebox-string-height rendered) 7))))
|
|
|
|
(ert-deftest ebox-border-box-min-height-wins-when-min-exceeds-max ()
|
|
"Border-box height conflicts should use the minimum as the outer height."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "A")
|
|
:box-sizing 'border-box
|
|
:min-height 5
|
|
:max-height 3
|
|
:padding '(1 0)
|
|
:overflow 'hidden))
|
|
(rendered (ebox-render box)))
|
|
(should (= (ebox-test--input-content-height box 1) 3))
|
|
(should (= (ebox-string-height rendered) 5))))
|
|
|
|
(ert-deftest ebox-border-box-max-width-includes-horizontal-padding-and-border ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((box (ebox-test-box (ebox-test-text "abc abc abc abc abc abc abc abc abc abc")
|
|
:box-sizing 'border-box
|
|
:max-width '(100)
|
|
:padding-inline '(20)
|
|
:border-left '(10)
|
|
:border-right '(10)
|
|
:overflow 'hidden)))
|
|
(should (<= (ebox-test--input-total-pixel box) 100))))
|
|
|
|
(ert-deftest ebox-auto-width-without-viewport-uses-max-content-across-all-lines ()
|
|
"Omitted :width should fall back to the widest unwrapped content line."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content "short\nmuch wider line")
|
|
(expected (apply #'max
|
|
(mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines content))))
|
|
(box (ebox-test-box (ebox-test-text content)))
|
|
(rendered (ebox-render box))
|
|
(widths (mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines rendered))))
|
|
(should (= (ebox-test--input-content-pixel box) expected))
|
|
(should (cl-every (lambda (width) (= width expected)) widths))))
|
|
|
|
(ert-deftest ebox-auto-width-stretches-to-viewport-when-available ()
|
|
"Omitted :width and :width auto should fill the viewport margin box."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-viewport-width 240))
|
|
(dolist (width-value '(nil auto))
|
|
(let ((box (apply #'ebox-test-box
|
|
(ebox-test-text "short line wraps into viewport")
|
|
(append (list :padding-inline '(20)
|
|
:border-left '(10)
|
|
:border-right '(10)
|
|
:margin-inline '(5))
|
|
(when width-value
|
|
(list :width width-value))))))
|
|
(should (= (ebox-test--input-total-pixel box) 240))))))
|
|
|
|
(ert-deftest ebox-explicit-width-can-overflow-containing-block ()
|
|
"A definite width remains definite even when the containing block is narrower."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-viewport-width 120)
|
|
(content "alpha beta gamma delta")
|
|
(box (ebox-test-box (ebox-test-text content) :width '(220) :wrap-mode 'word))
|
|
(rendered (ebox-render box))
|
|
(widths (mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines rendered))))
|
|
(should (cl-some (lambda (width) (> width ebox-viewport-width))
|
|
widths))
|
|
(should (cl-every (lambda (width) (= width 220)) widths))))
|
|
|
|
(ert-deftest ebox-column-wrapper-width-reflows-auto-child-layout ()
|
|
"A column wrapper width should contain auto-sized children."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-viewport-width 1000)
|
|
(node (ebox-test-column :width '(120)
|
|
(ebox-test-box (ebox-test-text "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega")
|
|
:wrap-mode 'word)))
|
|
(rendered (ebox-render node))
|
|
(plain (substring-no-properties rendered))
|
|
(widths (mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines rendered))))
|
|
(should (cl-every (lambda (width) (<= width 120)) widths))
|
|
(dolist (word '("alpha" "beta" "gamma" "delta" "epsilon" "theta"
|
|
"omega"))
|
|
(should (string-match-p word plain)))
|
|
(should (> (ebox-string-height rendered) 1))))
|
|
|
|
(ert-deftest ebox-column-wraps-direct-text-in-containing-block ()
|
|
"A direct Text child inherits the containing Box wrap policy."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-viewport-width 1000)
|
|
(node
|
|
(ebox-test-column
|
|
:width '(20) :wrap-mode 'word
|
|
(ebox-test-text
|
|
"alpha beta gamma delta epsilon zeta eta theta iota kappa")))
|
|
(rendered (ebox-render node))
|
|
(widths
|
|
(mapcar #'ebox--string-pixel-width (ebox-string-lines rendered))))
|
|
(should (> (length widths) 1))
|
|
(should (cl-every (lambda (width) (<= width 20)) widths))
|
|
(should (string-match-p "alpha" (substring-no-properties rendered)))
|
|
(should (string-match-p "kappa" (substring-no-properties rendered)))))
|
|
|
|
(ert-deftest ebox-wrapper-child-layout-keeps-pixel-width-with-wide-font ()
|
|
"Engine pixel widths must not be reparsed as public character widths."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-viewport-width 1000))
|
|
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
|
|
(let* ((node (ebox-test-column :width '(400) (ebox-test-box (ebox-test-text "X"))))
|
|
(widths (mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines (ebox-render node)))))
|
|
(should (equal widths '(400)))))))
|
|
|
|
(ert-deftest ebox-column-wrapper-width-reflows-auto-flex-layout ()
|
|
"A column wrapper width should become the viewport for nested auto flex."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-viewport-width 1000)
|
|
(node (ebox-test-column :width '(240)
|
|
(ebox-test-flex :flex-wrap 'wrap :column-gap '(10)
|
|
(ebox-test-box (ebox-test-text "A") :width
|
|
'(100))
|
|
(ebox-test-box (ebox-test-text "B") :width
|
|
'(100))
|
|
(ebox-test-box (ebox-test-text "C") :width
|
|
'(100)))))
|
|
(rendered (ebox-render node))
|
|
(plain-lines (ebox-string-lines
|
|
(substring-no-properties rendered)))
|
|
(widths (mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines rendered))))
|
|
(should (cl-every (lambda (width) (<= width 240)) widths))
|
|
(should (string-match-p "A.*B" (car plain-lines)))
|
|
(should (cl-some (lambda (line) (string-match-p "C" line))
|
|
(cdr plain-lines)))))
|
|
|
|
(ert-deftest ebox-column-containing-block-does-not-expand-to-overflow-child ()
|
|
"An overflowing definite child should not widen unrelated column rows."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-viewport-width 200)
|
|
(node (ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Auto row"))
|
|
(ebox-test-box (ebox-test-text "Fixed row") :width '(300))))
|
|
(widths (mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines (ebox-render node)))))
|
|
(should (equal widths '(200 300)))))
|
|
|
|
(ert-deftest ebox-width-keywords-resolve-intrinsic-content-sizes ()
|
|
"auto, min-content, max-content, and none should keep CSS-like meanings."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content "aa bbbb")
|
|
(max-content (ebox--string-pixel-width content))
|
|
(min-content (ebox--string-pixel-width "bbbb")))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content) :width 'auto))
|
|
max-content))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content) :width 'max-content))
|
|
max-content))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content) :width 'min-content))
|
|
min-content))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width '(80)
|
|
:max-width 'none))
|
|
80))
|
|
(should-error (ebox-test-box (ebox-test-text content) :width 'none)
|
|
:type 'error)))
|
|
|
|
(ert-deftest ebox-max-content-wrapper-measures-composite-child-intrinsically ()
|
|
"A max-content wrapper should not stretch its composite child to the viewport."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((ebox-viewport-width 480)
|
|
(lines '("Compact" "Natural width"))
|
|
(expected
|
|
(apply #'max (mapcar #'ebox--string-pixel-width lines)))
|
|
(node
|
|
(ebox-test-box
|
|
:width 'max-content
|
|
(apply #'ebox-test-column
|
|
(mapcar (lambda (line)
|
|
(ebox-test-box (ebox-test-text line)))
|
|
lines))))
|
|
(widths
|
|
(mapcar #'ebox--string-pixel-width
|
|
(ebox-string-lines (ebox-render node)))))
|
|
(should (equal widths (make-list (length lines) expected)))))
|
|
|
|
(ert-deftest ebox-definite-width-skips-unused-max-content-measurement ()
|
|
"A definite preferred width should not evaluate its unused auto fallback."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((box (ebox-test-box (ebox-test-text "measurement must stay cold")
|
|
:width '(80)))
|
|
(measurements 0)
|
|
(original (symbol-function 'ebox--content-max-pixel)))
|
|
(cl-letf (((symbol-function 'ebox--content-max-pixel)
|
|
(lambda (candidate)
|
|
(cl-incf measurements)
|
|
(funcall original candidate))))
|
|
(should (= (ebox-test--input-content-pixel box) 80))
|
|
(should (= measurements 0)))))
|
|
|
|
(ert-deftest ebox-definite-width-still-resolves-intrinsic-constraints ()
|
|
"Intrinsic min/max constraints must still measure definite-width content."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content "intrinsic constraint")
|
|
(expected (ebox--string-pixel-width content))
|
|
(box (ebox-test-box (ebox-test-text content)
|
|
:width '(20)
|
|
:min-width 'max-content))
|
|
(measurements 0)
|
|
(original (symbol-function 'ebox--content-max-pixel)))
|
|
(cl-letf (((symbol-function 'ebox--content-max-pixel)
|
|
(lambda (candidate)
|
|
(cl-incf measurements)
|
|
(funcall original candidate))))
|
|
(should (= (ebox-test--input-content-pixel box) expected))
|
|
(should (= measurements 1)))))
|
|
|
|
(ert-deftest ebox-auto-width-keeps-max-content-fallback-without-viewport ()
|
|
"An unresolved auto width should retain its intrinsic max-content fallback."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((box (ebox-test-box (ebox-test-text "auto fallback")))
|
|
(measurements 0)
|
|
(original (symbol-function 'ebox--content-max-pixel)))
|
|
(cl-letf (((symbol-function 'ebox--content-max-pixel)
|
|
(lambda (candidate)
|
|
(cl-incf measurements)
|
|
(funcall original candidate))))
|
|
(should (> (ebox-test--input-content-pixel box) 0))
|
|
(should (= measurements 1)))))
|
|
|
|
(ert-deftest ebox-fit-content-width-clamps-between-min-and-max-content ()
|
|
"fit-content() should clamp by min-content and max-content."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content "aa widest")
|
|
(min-content (ebox--string-pixel-width "widest"))
|
|
(max-content (ebox--string-pixel-width content))
|
|
(between (+ min-content (/ (- max-content min-content) 2))))
|
|
(should (< min-content between))
|
|
(should (< between max-content))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width (list 'fit-content (list between))))
|
|
between))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width (list 'fit-content (list 1))))
|
|
min-content))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width (list 'fit-content
|
|
(list (+ max-content 50)))))
|
|
max-content))))
|
|
|
|
(ert-deftest ebox-fit-content-keyword-uses-stretch-when-viewport-is-definite ()
|
|
"The fit-content keyword should clamp stretch between intrinsic sizes."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content "aa widest")
|
|
(min-content (ebox--string-pixel-width "widest"))
|
|
(max-content (ebox--string-pixel-width content))
|
|
(stretch (+ min-content (/ (- max-content min-content) 2)))
|
|
(ebox-viewport-width stretch))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content) :width 'fit-content))
|
|
stretch))))
|
|
|
|
(ert-deftest ebox-min-and-max-width-keywords-constrain-preferred-width ()
|
|
"Intrinsic keywords should work on min-width and max-width too."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content "aa widest")
|
|
(min-content (ebox--string-pixel-width "widest"))
|
|
(max-content (ebox--string-pixel-width content))
|
|
(between (+ min-content (/ (- max-content min-content) 2))))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width '(1)
|
|
:min-width 'max-content))
|
|
max-content))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width (list (+ max-content 50))
|
|
:max-width 'min-content))
|
|
min-content))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width '(1)
|
|
:min-width (list 'fit-content
|
|
(list between))))
|
|
between))
|
|
(should (= (ebox-test--input-content-pixel
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width (list (+ max-content 50))
|
|
:max-width (list 'fit-content
|
|
(list between))))
|
|
between))))
|
|
|
|
(ert-deftest ebox-stretch-and-contain-width-fill-viewport-margin-box ()
|
|
"stretch and contain should resolve against the current viewport."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox-viewport-width 240))
|
|
(dolist (keyword '(stretch contain))
|
|
(let ((box (ebox-test-box (ebox-test-text "x")
|
|
:width keyword
|
|
:padding-inline '(20)
|
|
:border-left '(10)
|
|
:border-right '(10)
|
|
:margin-inline '(5))))
|
|
(should (= (ebox-test--input-total-pixel box) 240))))))
|
|
|
|
(ert-deftest ebox-max-width-clamps-used-width-before-content-minimum ()
|
|
"A max-width smaller than one glyph should constrain used width."
|
|
(ebox-test--reset-runtime-state)
|
|
(cl-letf (((symbol-function 'ebox--string-pixel-width)
|
|
(lambda (_string) 60))
|
|
((symbol-function 'ebox--max-char-pixel)
|
|
(lambda (_string) 60)))
|
|
(let ((box (ebox-test-box (ebox-test-text "W")
|
|
:max-width '(20)
|
|
:wrap-mode 'char)))
|
|
(should (= (ebox-test--input-content-pixel box) 20))
|
|
(should (= (ebox-test--input-total-pixel box) 20)))))
|
|
|
|
(ert-deftest ebox-min-width-wins-when-min-width-exceeds-max-width ()
|
|
"CSS width constraints make min-width override max-width conflicts."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((box (ebox-test-box (ebox-test-text "short")
|
|
:width '(40)
|
|
:min-width '(120)
|
|
:max-width '(80))))
|
|
(should (= (ebox-test--input-content-pixel box) 120))
|
|
(should (= (ebox-test--input-total-pixel box) 120))))
|
|
|
|
(ert-deftest ebox-width-properties-reject-negative-values ()
|
|
"Width, min-width, and max-width should reject negative lengths."
|
|
(ebox-test--reset-runtime-state)
|
|
(should-error (ebox-test-box (ebox-test-text "bad") :width '(-1)) :type 'error)
|
|
(should-error (ebox-test-box (ebox-test-text "bad") :min-width '(-1)) :type 'error)
|
|
(should-error (ebox-test-box (ebox-test-text "bad") :max-width '(-1)) :type 'error))
|
|
|
|
(ert-deftest ebox-edge-properties-reject-negative-values ()
|
|
"Public edge properties and engine longhands should reject negatives."
|
|
(ebox-test--reset-runtime-state)
|
|
(dolist (props '((:padding -1)
|
|
(:padding-left-pixel -1)
|
|
(:margin -1)
|
|
(:margin-top-height -1)
|
|
(:border-width -1)
|
|
(:border-top-width -1)
|
|
(:border-left-pixel -1)))
|
|
(should-error
|
|
(apply #'ebox-test-box (ebox-test-text "bad") props)
|
|
:type 'error)))
|
|
|
|
(ert-deftest ebox-line-min-content-skips-discarded-space-measurements ()
|
|
"Literal spaces split runs and plain ASCII reuses exact character widths."
|
|
(let ((line (copy-sequence "fi x"))
|
|
(ebox--display-cache-validated t)
|
|
(ebox--char-width-cache (make-hash-table :test 'eql))
|
|
measured)
|
|
(puthash ?i 4 ebox--char-width-cache)
|
|
(add-text-properties 0 1 '(face (:height 1.2)) line)
|
|
(add-text-properties 3 4 '(display (raise 0.1)) line)
|
|
(cl-letf (((symbol-function 'ebox--string-pixel-width)
|
|
(lambda (string)
|
|
(push (substring-no-properties string) measured)
|
|
(pcase (substring-no-properties string)
|
|
("f" 3)
|
|
("x" 2)
|
|
(" " 99)
|
|
(_ 99)))))
|
|
;; Per-grapheme addition remains intentional: measuring the whole "fi"
|
|
;; could produce a non-additive ligature width and change the contract.
|
|
(should (= (ebox--line-min-content-pixel line) 7))
|
|
(should (equal (nreverse measured) '("f" "x"))))))
|
|
|
|
(ert-deftest ebox-region-update-valid-paint-and-invalid-edge-is-atomic ()
|
|
"Invalid geometry should fail before a valid sibling property mutates."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((box (ebox-test-box (ebox-test-text "Atomic")
|
|
:width '(120)
|
|
:bgcolor "#111111"
|
|
:padding-left 2))
|
|
(region-id (car (ebox-region-ids box))))
|
|
(ebox-test--with-rendered-buffer box
|
|
(should-error
|
|
(ebox-test--region-update region-id
|
|
:bgcolor "#222222"
|
|
:padding-left -1)
|
|
:type 'error)
|
|
(should (equal (ebox-style-node-specified-value box :bgcolor)
|
|
"#111111"))
|
|
(should (= (ebox-style-node-specified-value
|
|
box :padding-left)
|
|
2)))))
|
|
|
|
(ert-deftest ebox-region-update-no-op-skips-rollback-snapshot ()
|
|
"A legal no-op should return before discovering rollback scope."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id "root" :width '(240) :height 1 :overflow 'scroll
|
|
(ebox-test-box :id "target" (ebox-test-text "same") :width
|
|
'(120)))))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let ((target-id
|
|
(ebox-test--selector-region-id (current-buffer) "#target")))
|
|
(cl-letf
|
|
(((symbol-function 'ebox--node-all-region-ids)
|
|
(lambda (&rest _)
|
|
(ert-fail "no-op discovered subtree rollback scope")))
|
|
((symbol-function
|
|
'ebox--scroll-state-region-ids-containing-node-ids)
|
|
(lambda (&rest _)
|
|
(ert-fail "no-op discovered containing scroll scope"))))
|
|
(let ((report
|
|
(ebox-test--region-update target-id :content "same")))
|
|
(should (eq (plist-get report :strategy) 'no-op))
|
|
(should (= (plist-get report :dirty-count) 0))))))))
|
|
|
|
(ert-deftest ebox-region-update-rollback-snapshot-keeps-scroll-owner-box-by-identity ()
|
|
"A child rollback snapshot should retain its scroll owner's box identity."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((layout
|
|
(ebox-test-box :id "root" :width '(viewport) :height
|
|
'(viewport-height) :overflow 'scroll
|
|
(ebox-test-column :width '(viewport)
|
|
(ebox-test-box :id "target" (ebox-test-text "paint") :width '(120)
|
|
:color "#111111")
|
|
(ebox-test-box (ebox-test-text "tail") :width
|
|
'(120))))))
|
|
(let ((ebox-viewport-width 240)
|
|
(ebox-viewport-height 1))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((root-id
|
|
(ebox-test--selector-region-id (current-buffer) "#root"))
|
|
(target-id
|
|
(ebox-test--selector-region-id (current-buffer) "#target"))
|
|
(scroll-box
|
|
(plist-get (ebox--scroll-get-state root-id) :box))
|
|
(original-copy-tree (symbol-function 'copy-tree)))
|
|
(should scroll-box)
|
|
(cl-letf
|
|
(((symbol-function 'copy-tree)
|
|
(lambda (tree &optional vectors-and-records)
|
|
(when (eq tree scroll-box)
|
|
(ert-fail "deep-copied containing scroll owner box"))
|
|
(funcall original-copy-tree tree vectors-and-records))))
|
|
(ebox-test--region-update target-id :color "#222222"))
|
|
(should (equal
|
|
(ebox-get
|
|
(gethash target-id ebox--region-box-table) :color)
|
|
"#222222")))))))
|
|
|
|
(ert-deftest ebox-region-update-rolls-back-when-rerender-fails ()
|
|
"A failed local rerender must leave model, buffer, and report unchanged."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((layout (ebox-test-box :id "target" (ebox-test-text "before") :width '(120)))
|
|
(report-before 'keep-report))
|
|
(ebox-test--with-rendered-buffer layout
|
|
(let* ((buffer (current-buffer))
|
|
(region-id (ebox-test--selector-region-id buffer "#target"))
|
|
(box (gethash region-id ebox--region-box-table))
|
|
(box-before (copy-tree box))
|
|
(text-before (buffer-string)))
|
|
(ebox-test--set-buffer-update-report buffer report-before)
|
|
(let ((failure (lambda (&rest _)
|
|
(error "Injected rerender failure"))))
|
|
(cl-letf (((symbol-function 'ebox-surface--render-candidate)
|
|
failure)
|
|
((symbol-function 'ebox-surface--render-candidate-node)
|
|
failure))
|
|
(should-error
|
|
(ebox-test--region-update region-id :content "after")
|
|
:type 'error)))
|
|
(should (equal box box-before))
|
|
(should (equal-including-properties (buffer-string) text-before))
|
|
(should (eq (ebox-buffer-update-report buffer) report-before))))))
|
|
|
|
(ert-deftest ebox-cjk-wraps-by-character-width-in-word-mode ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((content "你好世界")
|
|
(width (* 2 (ebox--string-pixel-width "你")))
|
|
(lines (ebox-string-lines
|
|
(substring-no-properties
|
|
(ebox-render
|
|
(ebox-test-box (ebox-test-text content)
|
|
:width (list width)
|
|
:wrap-mode 'word))))))
|
|
(should (= (length lines) 2))
|
|
(should (string-prefix-p "你好" (nth 0 lines)))
|
|
(should (string-prefix-p "世界" (nth 1 lines)))
|
|
(dolist (line lines)
|
|
(should (<= (ebox--string-pixel-width line) width)))))
|
|
|
|
(ert-deftest ebox-wrap-keeps-combining-mark-with-base-character ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((cluster "é")
|
|
(text (concat cluster cluster))
|
|
(width (ebox--string-pixel-width cluster)))
|
|
(should (equal (mapcar #'substring-no-properties
|
|
(ebox--wrap-line text width 'char))
|
|
(list cluster cluster)))))
|
|
|
|
(ert-deftest ebox-word-wrap-skips-overflowing-separator-at-line-break ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((text "normal behaves stretch-like for auto cross size in this DSL.")
|
|
(width (ebox--string-pixel-width "stretch-like for"))
|
|
(lines (ebox--wrap-line text width 'word)))
|
|
(dolist (line lines)
|
|
(should (<= (ebox--string-pixel-width line) width)))
|
|
(should-not (cl-some (lambda (line)
|
|
(string-prefix-p " " line))
|
|
lines))))
|
|
|
|
(ert-deftest ebox-wrap-keeps-emoji-zwj-and-regional-indicator-clusters ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((family "👨👩👧👦")
|
|
(china "🇨🇳")
|
|
(usa "🇺🇸"))
|
|
(should (equal (mapcar #'substring-no-properties
|
|
(ebox--wrap-line (concat family family)
|
|
(ebox--string-pixel-width family)
|
|
'char))
|
|
(list family family)))
|
|
(should (equal (mapcar #'substring-no-properties
|
|
(ebox--wrap-line (concat china usa)
|
|
(ebox--string-pixel-width china)
|
|
'char))
|
|
(list china usa)))))
|
|
|
|
(ert-deftest ebox-kp-wrap-mode-invokes-ekp-justification ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let (require-called justify-args)
|
|
(cl-letf (((symbol-function 'require)
|
|
(lambda (feature &optional _filename _noerror)
|
|
(when (eq feature 'ekp)
|
|
(setq require-called t))
|
|
t))
|
|
((symbol-function 'ekp-pixel-justify)
|
|
(lambda (text width)
|
|
(setq justify-args (list text width))
|
|
text)))
|
|
(ebox-render (ebox-test-box (ebox-test-text "abc")
|
|
:width '(100)
|
|
:wrap-mode 'kp)))
|
|
(should require-called)
|
|
(should (equal justify-args (list "abc" 100)))))
|
|
|
|
(ert-deftest ebox-kp-wrap-mode-respects-text-align ()
|
|
"KP-wrapped lines should honor the box's horizontal text alignment."
|
|
(ebox-test--reset-runtime-state)
|
|
(cl-letf (((symbol-function 'require)
|
|
(lambda (feature &optional _filename _noerror)
|
|
(when (eq feature 'ekp) t)))
|
|
((symbol-function 'ekp-pixel-justify)
|
|
(lambda (&rest _)
|
|
"abc\nx")))
|
|
(dolist (align '(center right))
|
|
(let* ((box (ebox-test-box (ebox-test-text "ignored")
|
|
:width '(100)
|
|
:wrap-mode 'kp
|
|
:text-align align))
|
|
(width (ebox-test--input-content-pixel box))
|
|
(rendered (ebox-render box))
|
|
(line (car (ebox-string-lines rendered)))
|
|
(expected (ebox--pixel-reach "abc" width align)))
|
|
(should (equal (get-text-property 0 'display line)
|
|
(get-text-property 0 'display expected)))))))
|
|
|
|
(ert-deftest ebox-kp-wrap-preserves-content-width-for-ragged-lines ()
|
|
(ebox-test--reset-runtime-state)
|
|
(cl-letf (((symbol-function 'require)
|
|
(lambda (feature &optional _filename _noerror)
|
|
(when (eq feature 'ekp) t)))
|
|
((symbol-function 'ekp-pixel-justify)
|
|
(lambda (_text width)
|
|
(concat (ebox-pixel-space width) "\nshort"))))
|
|
(let* ((rendered (ebox-render
|
|
(ebox-test-box (ebox-test-text "ignored")
|
|
:width '(100)
|
|
:wrap-mode 'kp)))
|
|
(widths (mapcar #'ebox-string-pixel-width
|
|
(ebox-string-lines rendered))))
|
|
(should (equal widths '(100 100))))))
|
|
|
|
(ert-deftest ebox-content-face-keeps-foreground-and-background ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((rendered (ebox-render
|
|
(ebox-test-box (ebox-test-text "Face")
|
|
:width 40
|
|
:color "#abcdef"
|
|
:bgcolor "#123456")))
|
|
(face (ebox-test--first-content-face rendered)))
|
|
(should (ebox-test--face-has-key-value-p face :foreground "#abcdef"))
|
|
(should (ebox-test--face-has-key-value-p face :background "#123456"))))
|
|
|
|
(ert-deftest ebox-border-faces-keep-side-specific-properties ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((rendered (ebox-render
|
|
(ebox-test-box (ebox-test-text "Border")
|
|
:width 60
|
|
:padding '(1 1)
|
|
:border '(1 "#ff0000"))))
|
|
(left-face (ebox-test--face-at-property rendered 'ebox-bl))
|
|
(right-face (ebox-test--face-at-property rendered 'ebox-br))
|
|
(top-face (ebox-test--face-at-property rendered 'ebox-bt))
|
|
(bottom-face (ebox-test--face-at-property rendered 'ebox-bb)))
|
|
(should-not (ebox-test--face-has-key-p left-face :inverse-video))
|
|
(should (ebox-test--face-has-key-value-p left-face :background "#ff0000"))
|
|
(should-not (ebox-test--face-has-key-p right-face :inverse-video))
|
|
(should (ebox-test--face-has-key-value-p right-face :background "#ff0000"))
|
|
(should (ebox-test--face-has-key-value-p top-face :overline "#ff0000"))
|
|
(should (ebox-test--face-has-key-value-p
|
|
bottom-face
|
|
:underline
|
|
'(:position t :color "#ff0000")))))
|
|
|
|
(ert-deftest ebox-border-faces-omit-unspecified-frame-colors ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((original-frame-parameter (symbol-function 'frame-parameter)))
|
|
(dolist (sentinel '(unspecified unspecified-fg unspecified-bg
|
|
"unspecified" "unspecified-fg" "unspecified-bg"))
|
|
(cl-letf (((symbol-function 'frame-parameter)
|
|
(lambda (frame parameter)
|
|
(if (eq parameter 'foreground-color)
|
|
sentinel
|
|
(funcall original-frame-parameter frame parameter)))))
|
|
(let* ((rendered (ebox-render
|
|
(ebox-test-box (ebox-test-text "Border")
|
|
:width 60
|
|
:padding '(1 1)
|
|
:border '(1 solid))))
|
|
(left-face (ebox-test--face-at-property rendered 'ebox-bl))
|
|
(right-face (ebox-test--face-at-property rendered 'ebox-br))
|
|
(top-face (ebox-test--face-at-property rendered 'ebox-bt))
|
|
(bottom-face (ebox-test--face-at-property rendered 'ebox-bb)))
|
|
(should (ebox-test--face-has-key-value-p
|
|
left-face :inverse-video t))
|
|
(should (ebox-test--face-has-key-value-p
|
|
right-face :inverse-video t))
|
|
(should (ebox-test--face-has-key-value-p top-face :overline t))
|
|
(should (ebox-test--face-has-key-value-p
|
|
bottom-face :underline '(:position t))))))))
|
|
|
|
(ert-deftest ebox-horizontal-borders-cover-side-border-corners ()
|
|
(ebox-test--reset-runtime-state)
|
|
(require 'face-remap)
|
|
(let* ((text-scale-mode-amount 2)
|
|
(rendered (ebox-render
|
|
(ebox-test-box (ebox-test-text "Border")
|
|
:width 120
|
|
:padding '(0 (8))
|
|
:border '(1 "#ff0000"))))
|
|
(lines (ebox-string-lines rendered))
|
|
(top (car lines))
|
|
(bottom (car (last lines)))
|
|
(top-left (get-text-property 0 'face top))
|
|
(top-right (get-text-property (1- (length top)) 'face top))
|
|
(bottom-left (get-text-property 0 'face bottom))
|
|
(bottom-right (get-text-property (1- (length bottom)) 'face bottom)))
|
|
(dolist (face (list top-left top-right))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :background "#ff0000"))
|
|
(should (ebox-test--face-has-key-value-p face :overline "#ff0000")))
|
|
(dolist (face (list bottom-left bottom-right))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :background "#ff0000"))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :underline '(:position t :color "#ff0000"))))))
|
|
|
|
(ert-deftest ebox-horizontal-borders-cover-side-border-corners-when-shrunk ()
|
|
(ebox-test--reset-runtime-state)
|
|
(require 'face-remap)
|
|
(let* ((text-scale-mode-amount -2)
|
|
(rendered (ebox-render
|
|
(ebox-test-box (ebox-test-text "Border")
|
|
:width 120
|
|
:padding '(0 (8))
|
|
:border '(1 "#ff0000"))))
|
|
(lines (ebox-string-lines rendered))
|
|
(top (car lines))
|
|
(bottom (car (last lines)))
|
|
(top-left (get-text-property 0 'face top))
|
|
(top-right (get-text-property (1- (length top)) 'face top))
|
|
(bottom-left (get-text-property 0 'face bottom))
|
|
(bottom-right (get-text-property (1- (length bottom)) 'face bottom)))
|
|
(dolist (face (list top-left top-right))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :background "#ff0000"))
|
|
(should (ebox-test--face-has-key-value-p face :overline "#ff0000")))
|
|
(dolist (face (list bottom-left bottom-right))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :background "#ff0000"))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :underline '(:position t :color "#ff0000"))))))
|
|
|
|
(ert-deftest ebox-horizontal-borders-do-not-cover-horizontal-margins ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((rendered (ebox-render
|
|
(ebox-test-box (ebox-test-text "Border")
|
|
:width 80
|
|
:margin '(0 (8) 0 (8))
|
|
:border '(1 "#ff0000"))))
|
|
(lines (ebox-string-lines rendered))
|
|
(top (car lines))
|
|
(bottom (car (last lines)))
|
|
(top-left-margin (get-text-property 0 'face top))
|
|
(top-left-border (get-text-property 1 'face top))
|
|
(top-right-border (get-text-property (- (length top) 2) 'face top))
|
|
(top-right-margin (get-text-property (1- (length top)) 'face top))
|
|
(bottom-left-margin (get-text-property 0 'face bottom))
|
|
(bottom-left-border (get-text-property 1 'face bottom))
|
|
(bottom-right-border (get-text-property (- (length bottom) 2) 'face bottom))
|
|
(bottom-right-margin (get-text-property (1- (length bottom)) 'face bottom)))
|
|
(dolist (face (list top-left-border top-right-border))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :background "#ff0000"))
|
|
(should (ebox-test--face-has-key-value-p face :overline "#ff0000")))
|
|
(dolist (face (list bottom-left-border bottom-right-border))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :background "#ff0000"))
|
|
(should (ebox-test--face-has-key-value-p
|
|
face :underline '(:position t :color "#ff0000"))))
|
|
(dolist (face (list top-left-margin top-right-margin))
|
|
(should-not (ebox-test--face-has-key-p face :overline)))
|
|
(dolist (face (list bottom-left-margin bottom-right-margin))
|
|
(should-not (ebox-test--face-has-key-p face :underline)))))
|
|
|
|
(ert-deftest ebox-pixel-cache-invalidates-when-display-signature-changes ()
|
|
(ebox-test--reset-runtime-state)
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(let ((signature 'first))
|
|
(lambda () signature))))
|
|
(clrhash ebox--char-width-cache)
|
|
(ebox--clear-string-pixel-width-cache)
|
|
(setq ebox--space-pixel-cache nil)
|
|
(setq ebox--display-signature-cache nil)
|
|
(ebox--char-pixel-width ?A)
|
|
(should (= (hash-table-count ebox--char-width-cache) 1))
|
|
(should (> (hash-table-count ebox--string-pixel-width-cache) 0))
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda () 'second)))
|
|
(ebox--ensure-display-cache-current)
|
|
(should (= (hash-table-count ebox--char-width-cache) 0))
|
|
(should (= (hash-table-count ebox--string-pixel-width-cache) 0))
|
|
(should-not ebox--space-pixel-cache)
|
|
(should (equal ebox--display-signature-cache 'second)))))
|
|
|
|
(ert-deftest ebox-display-signature-includes-face-remapping ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((plain (ebox--display-signature))
|
|
(face-remapping-alist '((default (:height 1.4)))))
|
|
(should-not (equal plain (ebox--display-signature)))))
|
|
|
|
(ert-deftest ebox-display-signature-ignores-tp-paint-slot-remapping ()
|
|
"Installing a TP paint layer must not invalidate geometry measurement."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((plain (ebox--display-signature))
|
|
(face-remapping-alist
|
|
'((tp-paint-slot-99 (:foreground "#123456")
|
|
(:background "#F8FAFC")))))
|
|
(should (equal plain (ebox--display-signature)))))
|
|
|
|
(ert-deftest ebox-pixel-width-respects-text-scale-with-fixed-spaces ()
|
|
(ebox-test--reset-runtime-state)
|
|
(require 'face-remap)
|
|
(let* ((plain (ebox--string-pixel-width "AAAA"))
|
|
(fixed (ebox-pixel-space 30))
|
|
(text-scale-mode-amount 2)
|
|
(scaled (ebox--string-pixel-width "AAAA"))
|
|
(scaled-with-fixed (ebox--string-pixel-width
|
|
(concat "AAAA" fixed))))
|
|
(should (> scaled plain))
|
|
(should (= scaled-with-fixed (+ scaled 30)))))
|
|
|
|
(ert-deftest ebox-pixel-width-caches-plain-lines-during-render-context ()
|
|
"Repeated plain text width checks should share the render-local cache."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox--display-cache-validated t)
|
|
(ebox--render-string-pixel-width-cache (make-hash-table :test 'equal))
|
|
(ebox--string-pixel-width-cache (make-hash-table :test 'equal))
|
|
(ebox--string-pixel-width-cache-ring
|
|
(make-vector ebox--string-pixel-width-cache-max-entries nil))
|
|
(ebox--string-pixel-width-cache-ring-index 0)
|
|
(calls 0))
|
|
(cl-letf (((symbol-function 'string-pixel-width)
|
|
(lambda (string)
|
|
(cl-incf calls)
|
|
(length string))))
|
|
(should (= (ebox--string-pixel-width "cached") 6))
|
|
(should (= (ebox--string-pixel-width "cached") 6))
|
|
(should (= calls 1)))))
|
|
|
|
(ert-deftest ebox-pixel-width-reuses-plain-lines-across-render-transactions ()
|
|
"Stable display transactions should share plain string measurements."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox--display-signature-cache 'stable)
|
|
(probe (format "cross-render-%S" (make-symbol "probe")))
|
|
(calls 0))
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda () 'stable))
|
|
((symbol-function 'string-pixel-width)
|
|
(lambda (string)
|
|
(cl-incf calls)
|
|
(length string))))
|
|
(dotimes (_ 2)
|
|
(ebox--with-validated-display-cache
|
|
(ebox--string-pixel-width probe)))
|
|
(should (= calls 1)))))
|
|
|
|
(ert-deftest ebox-pixel-width-reuses-face-only-lines-with-face-isolation ()
|
|
"Display-scoped measurements should reuse equal faces without collisions."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox--display-signature-cache 'stable)
|
|
(bold (propertize "face-cache" 'face 'bold))
|
|
(italic (propertize "face-cache" 'face 'italic))
|
|
(calls 0))
|
|
(cl-letf (((symbol-function 'ebox--display-signature)
|
|
(lambda () 'stable))
|
|
((symbol-function 'string-pixel-width)
|
|
(lambda (string)
|
|
(cl-incf calls)
|
|
(if (eq (get-text-property 0 'face string) 'bold)
|
|
10
|
|
20))))
|
|
(should
|
|
(= (ebox--with-validated-display-cache
|
|
(ebox--string-pixel-width bold))
|
|
10))
|
|
(should
|
|
(= (ebox--with-validated-display-cache
|
|
(ebox--string-pixel-width bold))
|
|
10))
|
|
(should
|
|
(= (ebox--with-validated-display-cache
|
|
(ebox--string-pixel-width italic))
|
|
20))
|
|
(should (= calls 2)))))
|
|
|
|
(ert-deftest ebox-persistent-pixel-width-cache-stays-bounded ()
|
|
"Display-scoped reusable string widths should evict at their entry cap."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox--display-cache-validated t)
|
|
(ebox--render-string-pixel-width-cache nil)
|
|
(ebox--string-pixel-width-cache (make-hash-table :test 'equal))
|
|
(ebox--string-pixel-width-cache-ring
|
|
(make-vector ebox--string-pixel-width-cache-max-entries nil))
|
|
(ebox--string-pixel-width-cache-ring-index 0))
|
|
(cl-letf (((symbol-function 'string-pixel-width)
|
|
(lambda (string) (length string))))
|
|
(dotimes (index (+ ebox--string-pixel-width-cache-max-entries 17))
|
|
(ebox--string-pixel-width (format "measurement-%d" index))))
|
|
(should
|
|
(= (hash-table-count ebox--string-pixel-width-cache)
|
|
ebox--string-pixel-width-cache-max-entries))))
|
|
|
|
(ert-deftest ebox-string-max-pixel-width-works-without-render-cache ()
|
|
"Multiline max-width measurement should not require a render-local cache."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox--display-cache-validated t)
|
|
(ebox--render-string-max-pixel-width-cache nil))
|
|
(cl-letf (((symbol-function 'ebox--string-pixel-width)
|
|
(lambda (line)
|
|
(length (substring-no-properties line)))))
|
|
(should (= (ebox--string-max-pixel-width "wide\nn") 4)))))
|
|
|
|
(ert-deftest ebox-flex-string-width-caches-rendered-strings ()
|
|
"Repeated flex width checks should reuse render-local rendered string widths."
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox--display-cache-validated t)
|
|
(ebox--render-string-pixel-width-cache (make-hash-table :test 'equal))
|
|
(ebox--render-string-max-pixel-width-cache (make-hash-table :test 'eq))
|
|
(rendered (concat (propertize "wide" 'face 'bold)
|
|
"\n"
|
|
(propertize "narrow" 'display '(space :width (2)))))
|
|
(calls 0))
|
|
(cl-letf (((symbol-function 'ebox--string-pixel-width)
|
|
(lambda (line)
|
|
(cl-incf calls)
|
|
(length (substring-no-properties line)))))
|
|
(should (= (ebox--flex-string-width rendered) 6))
|
|
(should (= (ebox--flex-string-width rendered) 6))
|
|
(should (= calls 2)))))
|
|
|
|
(ert-deftest ebox-face-height-width-detection-handles-shrinking-text ()
|
|
(ebox-test--reset-runtime-state)
|
|
(let ((ebox--face-height-width-cache nil)
|
|
(factor 0.7))
|
|
(cl-letf (((symbol-function 'string-pixel-width)
|
|
(lambda (string)
|
|
(if (get-text-property 0 'face string)
|
|
70
|
|
101))))
|
|
(should (ebox--face-height-affects-width-p factor))
|
|
(should (= (ebox--scaled-string-pixel-width "probe" factor)
|
|
70)))))
|
|
|
|
(ert-deftest ebox-visibility-hidden-suppresses-ink-and-keeps-footprint ()
|
|
"A hidden box renders blank with identical text, lengths, and metadata."
|
|
(let* ((make-box
|
|
(lambda (visibility)
|
|
(ebox-test-box (ebox-test-text "Hello") :width (list 90)
|
|
:border '(1 solid "#AA3311") :bgcolor "#223344"
|
|
:color "#FFFFFF" :padding '(0 1)
|
|
:visibility visibility)))
|
|
(visible (ebox-render (funcall make-box 'visible)))
|
|
(hidden (ebox-render (funcall make-box 'hidden))))
|
|
(should (equal (substring-no-properties visible)
|
|
(substring-no-properties hidden)))
|
|
(should (equal (mapcar #'length (ebox-string-lines visible))
|
|
(mapcar #'length (ebox-string-lines hidden))))
|
|
;; No character carries a face, every character renders under a
|
|
;; display spec, and each line's blank cover is measured to the
|
|
;; visible line's exact pixel width.
|
|
(dotimes (index (length hidden))
|
|
(unless (eq (aref hidden index) ?\n)
|
|
(should-not (get-text-property index 'face hidden))
|
|
(should (get-text-property index 'display hidden))))
|
|
(cl-loop for visible-line in (ebox-string-lines visible)
|
|
for hidden-line in (ebox-string-lines hidden)
|
|
do (should (= (ebox-string-pixel-width visible-line)
|
|
(ebox-string-pixel-width hidden-line))))
|
|
;; Region metadata still identifies the same owner surfaces at the
|
|
;; content characters.
|
|
(let ((position (string-match "Hello"
|
|
(substring-no-properties visible))))
|
|
(should position)
|
|
(should (get-text-property position 'ebox-content-owner visible))
|
|
(should (get-text-property position 'ebox-content-owner hidden)))))
|
|
|
|
(ert-deftest ebox-visibility-toggle-is-local-and-restores-exactly ()
|
|
"Hiding and showing one region never widens beyond its owner.
|
|
The buffer text never changes; showing again restores the visible
|
|
publication with properties intact."
|
|
(let* ((buffer (generate-new-buffer " *ebox-visibility*"))
|
|
(root (ebox-test-box
|
|
:width (list 300)
|
|
(ebox-test-column
|
|
(ebox-test-box (ebox-test-text "Header line") :id 'header
|
|
:width (list 300))
|
|
(ebox-test-box (ebox-test-text "Secret content") :id 'secret
|
|
:width (list 300) :border '(1 solid)
|
|
:bgcolor "#224466" :color "#FFFFFF")
|
|
(ebox-test-box (ebox-test-text "Footer line") :id 'footer
|
|
:width (list 300))))))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-render-to-buffer buffer root)
|
|
(let ((before (with-current-buffer buffer (buffer-string))))
|
|
(ebox-selector-update-buffer buffer "#secret"
|
|
:visibility 'hidden)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(span-patch owner-rerender paint-patch)))
|
|
(should (= (plist-get report :patch-count) 1)))
|
|
(should (equal (with-current-buffer buffer
|
|
(substring-no-properties (buffer-string)))
|
|
(substring-no-properties before)))
|
|
(ebox-selector-update-buffer buffer "#secret"
|
|
:visibility 'visible)
|
|
(let ((report (ebox-test--buffer-update-report)))
|
|
(should (memq (plist-get report :strategy)
|
|
'(span-patch owner-rerender paint-patch)))
|
|
(should (= (plist-get report :patch-count) 1)))
|
|
(should (equal-including-properties
|
|
(with-current-buffer buffer (buffer-string))
|
|
before))))
|
|
(kill-buffer buffer))))
|
|
|
|
(ert-deftest ebox-parent-border-color-patch-never-recolors-descendants ()
|
|
"A parent border-color paint patch touches only the owner's chrome.
|
|
Border surfaces belong to each box: a full render never propagates an
|
|
ancestor border color into a descendant's border runs, so the cheap
|
|
face patch must match that exactly (issue014)."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((make-tree
|
|
(lambda (parent-color)
|
|
(ebox-test-box
|
|
:id 'parent :width (list 400)
|
|
:border (list 1 'solid parent-color)
|
|
:padding '(1 (12))
|
|
(ebox-test-column
|
|
(ebox-test-box :id 'child (ebox-test-text "CHILD") :width (list 200)
|
|
:border '(1 solid "#00AA00"))))))
|
|
(visual-runs
|
|
(lambda (buffer role)
|
|
(with-current-buffer buffer
|
|
(let (runs (position (point-min)))
|
|
(while (< position (point-max))
|
|
(when (get-text-property position role)
|
|
(push (list (line-number-at-pos position)
|
|
(get-text-property position role)
|
|
(ebox-test--face-visual-attributes
|
|
(get-text-property position 'face)))
|
|
runs))
|
|
(setq position
|
|
(or (next-single-property-change position role)
|
|
(point-max))))
|
|
(nreverse runs)))))
|
|
(patched (generate-new-buffer " *i14-patched*"))
|
|
(reference (generate-new-buffer " *i14-reference*")))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-render-to-buffer patched (funcall make-tree "#AA0000"))
|
|
(ebox-selector-update-buffer patched "#parent"
|
|
:border-color "#0000AA")
|
|
(should (eq (plist-get (ebox-test--buffer-update-report) :strategy)
|
|
'paint-patch))
|
|
(ebox-test--reset-runtime-state)
|
|
(ebox-render-to-buffer reference (funcall make-tree "#0000AA"))
|
|
(dolist (role '(ebox-bl ebox-br ebox-bt ebox-bb))
|
|
(ert-info ((format "border role %S" role))
|
|
(should (equal
|
|
(mapcar (lambda (run) (cons (car run) (caddr run)))
|
|
(funcall visual-runs patched role))
|
|
(mapcar (lambda (run) (cons (car run) (caddr run)))
|
|
(funcall visual-runs reference role)))))))
|
|
(kill-buffer patched)
|
|
(kill-buffer reference))))
|
|
|
|
(ert-deftest ebox-typed-commits-equal-fresh-runtime-render ()
|
|
"Typed content, geometry, structure, and overflow commits stay exact."
|
|
(dolist
|
|
(case
|
|
'(((box :key root "Before")
|
|
(box :key root "After") "After")
|
|
((flex :key root :width (240)
|
|
(box :key item :width (80) "Item"))
|
|
(flex :key root :width (240)
|
|
(box :key item :width (120) "Item")) "Item")
|
|
((column :key root (box :key first "A"))
|
|
(column :key root
|
|
(box :key first "A") (box :key second "B")) "B")
|
|
((box :key root :height 1 :overflow visible "A\nB")
|
|
(box :key root :height 1 :overflow hidden "A\nB") "A")))
|
|
(let ((buffer (generate-new-buffer " *ebox-typed-fresh-equality*")))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-render-to-buffer buffer (ebox-build (nth 0 case)))
|
|
(let ((report (ebox-commit buffer (ebox-build (nth 1 case)))))
|
|
(should (plist-get report :runtime-published))
|
|
(with-current-buffer buffer
|
|
(should (string-match-p (nth 2 case) (buffer-string)))
|
|
(should
|
|
(ebox-test--buffer-propertized-matches-runtime-render-p)))))
|
|
(when (buffer-live-p buffer) (kill-buffer buffer))))))
|
|
|
|
(ert-deftest ebox-native-stable-full-commit-prepares-style-certificate ()
|
|
"A stable full-source commit certifies styles before native eligibility."
|
|
(ebox-test--reset-runtime-state)
|
|
(let* ((buffer (generate-new-buffer " *ebox-native-style-certificate*"))
|
|
(before
|
|
(ebox-build
|
|
'(column :key root
|
|
(box :key target :id target :width (52) :padding (0 1)
|
|
:color "#0F172A" :bgcolor "#F8FAFC" "Before")
|
|
(box :key peer :id peer :width (52) :padding (0 1)
|
|
:color "#0F172A" :bgcolor "#F8FAFC" "Peer"))))
|
|
(after
|
|
(ebox-build
|
|
'(column :key root
|
|
(box :key target :id target :width (52) :padding (0 1)
|
|
:color "#0F172A" :bgcolor "#F8FAFC" "UPDATED\ndetail")
|
|
(box :key peer :id peer :width (52) :padding (0 1)
|
|
:color "#0F172A" :bgcolor "#F8FAFC" "Peer")))))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-render-to-buffer buffer before)
|
|
;; The core suite disables the optional module. Mark the otherwise
|
|
;; ordinary committed fixture as a prior confirmed native frame so
|
|
;; this test deterministically exercises the update eligibility
|
|
;; boundary without depending on a local dylib.
|
|
(plist-put (ebox--buffer-render-state buffer)
|
|
:projection-kind 'native-frame)
|
|
(let* ((peer-before
|
|
(car (ebox-selector-query-buffer buffer "#peer")))
|
|
(peer-node-id (plist-get peer-before :node-id))
|
|
(peer-region-id (plist-get peer-before :region-id))
|
|
(style-before
|
|
(plist-get (ebox--buffer-runtime-node buffer peer-node-id)
|
|
:ebox-computed-style))
|
|
(plan (symbol-function 'ebox-native-commit-plan-eligible-p))
|
|
prepared-seen report
|
|
(_commit
|
|
(cl-letf
|
|
(((symbol-function 'ebox-native-commit-plan-eligible-p)
|
|
(lambda (old-state candidate-state prepared)
|
|
(setq prepared-seen prepared)
|
|
(funcall plan old-state candidate-state prepared))))
|
|
(setq report (ebox-commit buffer after))))
|
|
(peer-after (car (ebox-selector-query-buffer buffer "#peer")))
|
|
(style-after
|
|
(plist-get (ebox--buffer-runtime-node buffer peer-node-id)
|
|
:ebox-computed-style))
|
|
(values (and style-after
|
|
(ecss-computed-style-values style-after))))
|
|
(should style-before)
|
|
(should (plist-get prepared-seen :styles-prepared-p))
|
|
(should (plist-get prepared-seen
|
|
:computed-participation-validated-p))
|
|
(should (eq (plist-get report :strategy) 'span-patch))
|
|
(should (= (plist-get peer-after :node-id) peer-node-id))
|
|
(should (= (plist-get peer-after :region-id) peer-region-id))
|
|
(should style-after)
|
|
(should (equal (plist-get values 'ebox/color) "#0F172A"))
|
|
(should (equal (plist-get values 'ebox/background-color)
|
|
"#F8FAFC"))
|
|
(with-current-buffer buffer
|
|
(should
|
|
(ebox-test--buffer-propertized-matches-runtime-render-p)))))
|
|
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-typed-commit-preserves-unaffected-source-range ()
|
|
"A keyed peer keeps identity and bounds across a sibling-only commit."
|
|
(let* ((buffer (generate-new-buffer " *ebox-typed-peer-range*"))
|
|
(before-root
|
|
(ebox-build
|
|
'(column :key root
|
|
(box :key target :id target "Before")
|
|
(box :key peer :id peer "Peer"))))
|
|
(after-root
|
|
(ebox-build
|
|
'(column :key root
|
|
(box :key target :id target "After!")
|
|
(box :key peer :id peer "Peer")))))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-render-to-buffer buffer before-root)
|
|
(let* ((before (car (ebox-selector-query-buffer buffer "#peer")))
|
|
(before-node (plist-get before :node-id))
|
|
(before-region (plist-get before :region-id))
|
|
(before-bounds
|
|
(ebox-surface-region-bounds buffer before-region)))
|
|
(ebox-commit buffer after-root)
|
|
(let ((after (car (ebox-selector-query-buffer buffer "#peer"))))
|
|
(should (= (plist-get after :node-id) before-node))
|
|
(should (= (plist-get after :region-id) before-region))
|
|
(should (equal (ebox-surface-region-bounds
|
|
buffer before-region)
|
|
before-bounds)))))
|
|
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-typed-scroll-window-stays-equal-to-runtime-render ()
|
|
"Scroll and viewport updates preserve visible text properties exactly."
|
|
(let ((buffer (generate-new-buffer " *ebox-typed-scroll-equality*")))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-render-to-buffer
|
|
buffer
|
|
(ebox-build
|
|
'(column :id scroller :width (viewport) :height 3
|
|
:overflow scroll
|
|
(box "A") (box "B") (box "C") (box "D") (box "E"))))
|
|
(with-current-buffer buffer
|
|
(goto-char (point-min))
|
|
(ebox-scroll-down 1)
|
|
(should
|
|
(ebox-test--buffer-propertized-matches-runtime-render-p)))
|
|
(ebox-rerender-buffer-with-context buffer 320 3)
|
|
(with-current-buffer buffer
|
|
(should
|
|
(ebox-test--buffer-propertized-matches-runtime-render-p))))
|
|
(when (buffer-live-p buffer) (kill-buffer buffer)))))
|
|
|
|
(ert-deftest ebox-typed-local-updates-keep-publication-scoped ()
|
|
"Paint and geometry updates keep an unrelated keyed peer outside full-root work."
|
|
(dolist (update '((:background-color "#DBEAFE") (:width (120))))
|
|
(let ((buffer (generate-new-buffer " *ebox-typed-scoped-update*")))
|
|
(unwind-protect
|
|
(progn
|
|
(ebox-render-to-buffer
|
|
buffer
|
|
(ebox-build
|
|
'(row :width (260)
|
|
(box :id target :width (100) "Target")
|
|
(box :id peer :width (100) "Peer"))))
|
|
(let* ((target (ebox-region-resolve buffer "target"))
|
|
(peer (car (ebox-selector-query-buffer buffer "#peer")))
|
|
(peer-node-id (plist-get peer :node-id)))
|
|
(apply #'ebox-region-update target update)
|
|
(let ((report (ebox-buffer-update-report buffer))
|
|
(peer-after
|
|
(car (ebox-selector-query-buffer buffer "#peer"))))
|
|
(should-not (plist-get report :full-rerender))
|
|
(should-not (plist-get report :tp-full-root))
|
|
(should (> (or (plist-get report :tp-scope-count) 0) 0))
|
|
(should (= (plist-get peer-after :node-id) peer-node-id)))))
|
|
(when (buffer-live-p buffer) (kill-buffer buffer))))))
|
|
|
|
(ert-deftest ebox-scroll-span-index-scans-property-runs-once ()
|
|
"Index all regions in one pass while preserving single-region semantics."
|
|
(let* ((line (apply #'concat
|
|
(cl-loop for id from 1 to 32
|
|
collect (propertize "x" 'ebox-content id
|
|
'ebox-content-owners '(999)))))
|
|
(lines (list line
|
|
(concat (propertize "a" 'ebox-content 7)
|
|
"gap" (propertize "b" 'ebox-content 7))
|
|
"plain"))
|
|
(expected (make-hash-table :test 'equal))
|
|
(next (symbol-function 'ebox--string-next-region-property-change))
|
|
(calls 0) actual)
|
|
;; The established query is an independent oracle for compatibility and
|
|
;; disconnected hulls, including rejection of a foreign ancestor owner.
|
|
(cl-loop for text in lines for number from 0 do
|
|
(dolist (id (ebox--scroll-line-region-ids text))
|
|
(when-let* ((span (ebox--scroll-line-region-span
|
|
text (ebox--region-id-set (list id)))))
|
|
(puthash id (append (gethash id expected)
|
|
(list (cons number span))) expected))))
|
|
(cl-letf (((symbol-function 'ebox--string-next-region-property-change)
|
|
(lambda (&rest args)
|
|
(cl-incf calls)
|
|
(apply next args))))
|
|
(setq actual (ebox--scroll-build-region-line-span-index lines)))
|
|
(should (= (hash-table-count actual) (hash-table-count expected)))
|
|
(maphash (lambda (id spans) (should (equal (gethash id actual) spans))) expected)
|
|
(should-not (gethash 999 actual))
|
|
(should (= calls 36))))
|
|
|
|
;;; ebox-core-render-tests.el ends here
|