;;; 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))) (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--box-extents ebox--buffer-render-state-table ebox--scroll-global-state ebox--render-root-cache-table-table ebox--render-cache-entry-side-effects-table ebox--rendered-root-metadata-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)) (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))) (ert-deftest ebox-scroll-window-lookahead-accepts-root-reflow-override () "Root reflow should be able to defer all hidden lookahead work." (let ((ebox-scroll-lazy-prefix-lookahead-lines 8) (ebox-wheel-scroll-step 16) (ebox--scroll-window-initial-lookahead-lines-override 0)) (should (= (ebox--scroll-window-initial-lookahead-lines) 0)))) (ert-deftest ebox-root-reflow-lookahead-keeps-flex-rows-demand-driven () "Root reflow should not cross an explicit flex item's atomic row eagerly." (ebox-test--reset-runtime-state) (let (plain-buffer flex-buffer) (unwind-protect (progn (setq plain-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-plain-lookahead*") (ebox-create :content "plain" :width 120))) (setq flex-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-flex-lookahead*") (ebox-flex :width '(120) (ebox-flex-item (ebox-create :content "item" :width '(40)))))) (should (= (ebox--root-reflow-scroll-lookahead-lines-for-buffer plain-buffer) ebox--root-reflow-scroll-lookahead-lines)) (should (= (ebox--root-reflow-scroll-lookahead-lines-for-buffer flex-buffer) 0))) (dolist (buffer (list plain-buffer flex-buffer)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)))))) (ert-deftest ebox-root-reflow-lookahead-keeps-large-runtimes-demand-driven () "Root reflow should not eagerly cross costly rows in a large runtime." (ebox-test--reset-runtime-state) (let ((buffer (generate-new-buffer " *ebox-large-lookahead*")) (node-table (make-hash-table :test 'equal)) (selector-type-table (make-hash-table :test 'eq))) (unwind-protect (progn (dotimes (index 512) (puthash index t node-table)) (puthash buffer (list :node-table node-table :selector-type-table selector-type-table) ebox--buffer-render-state-table) (should (= (ebox--root-reflow-scroll-lookahead-lines-for-buffer buffer) 0))) (remhash buffer ebox--buffer-render-state-table) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-exact-scroll-prefix-skips-completion-sentinel () "Interactive prefix extension should request only the visible line bound." (ebox-test--reset-runtime-state) (let* ((region-id 'exact-prefix) (all-lines '("zero" "one" "two" "three" "four")) requested-limits (render-prefix (lambda (_state _region-id limit) (push limit requested-limits) (list :content-lines (seq-take all-lines limit) :rendered-content-lines (seq-take all-lines limit) :content-height 2 :complete nil))) (state (list :content-lines (seq-take all-lines 3) :rendered-content-lines (seq-take all-lines 3) :content-height 2 :render-content-prefix render-prefix))) (let ((ebox-scroll-lazy-prefix-lookahead-lines 0)) (ebox--scroll-state-ensure-prefix-lines region-id state 4 t)) (should (equal requested-limits '(4))) (setq requested-limits nil state (list :content-lines (seq-take all-lines 3) :rendered-content-lines (seq-take all-lines 3) :content-height 2 :render-content-prefix render-prefix)) (let ((ebox-scroll-lazy-prefix-lookahead-lines 0)) (ebox--scroll-state-ensure-prefix-lines region-id state 4)) (should (equal requested-limits '(5))))) (ert-deftest ebox-scroll-lightweight-line-bounds-extend-incrementally () "Lazy line bounds should extend without rebuilding prior prefix entries." (ebox-test--reset-runtime-state) (let* ((line-a (propertize "a" 'ebox-content 11)) (line-b (propertize "b" 'ebox-content 12)) (line-c (propertize "c" 'ebox-content 11)) (ebox--defer-scroll-content-index t) (state (ebox--scroll-state-set-lines nil (list line-a line-b))) (index (plist-get state :region-line-bounds-index))) (should (equal (gethash 11 index) '(0 . 0))) (should (equal (gethash 12 index) '(1 . 1))) (setq state (ebox--scroll-state-set-lines state (list line-a line-b line-c))) (should (eq index (plist-get state :region-line-bounds-index))) (should (equal (gethash 11 index) '(0 . 2))) (should (equal (gethash 12 index) '(1 . 1))) (should (plist-get state :region-line-span-index-deferred)))) (ert-deftest ebox-scroll-prefix-publishes-private-line-with-suffix-paint () "A cached hidden source line should not rebuild the rendered prefix." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "" :width 20 :height 2)) (content-lines '("zero" "one" "two" "three")) (state (list :content-lines (seq-take content-lines 3) :rendered-content-lines (seq-take content-lines 3) :content-height 2)) (source-renders 0) rendered-requests (original-rendered-lines (symbol-function 'ebox--scroll-rendered-content-lines)) (renderer (ebox--scroll-window-prefix-renderer box '(:stack ignored) 20 2 nil content-lines))) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (&rest _) (cl-incf source-renders) (error "Private cached line should satisfy the prefix"))) ((symbol-function 'ebox--scroll-rendered-content-lines) (lambda (&rest args) (push (list (length (cadr args)) (nth 3 args)) rendered-requests) (apply original-rendered-lines args)))) (let ((result (funcall renderer state 'private-prefix 4))) (should (= source-renders 0)) (should (equal rendered-requests '((1 3)))) (should (= (length (plist-get result :content-lines)) 4)) (should (= (length (plist-get result :rendered-content-lines)) 4)))))) (defun ebox-test--face-has-key-value-p (face key value) "Return non-nil when FACE contains KEY with VALUE." (cond ((null face) nil) ((and (listp face) (keywordp (car face))) (equal (plist-get face key) value)) ((listp face) (cl-some (lambda (item) (ebox-test--face-has-key-value-p item key value)) face)) (t nil))) (defun ebox-test--face-has-key-p (face key) "Return non-nil when FACE contains KEY." (cond ((null face) nil) ((and (listp face) (keywordp (car face))) (plist-member face key)) ((listp face) (cl-some (lambda (item) (ebox-test--face-has-key-p item key)) face)) (t nil))) (defun ebox-test--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-rerenders (&rest body) "Run BODY and return the number of full buffer rerenders it triggered." (declare (indent 0)) `(let ((full-rerenders 0) (original-full-rerender (symbol-function 'ebox--full-rerender-buffer))) (cl-letf (((symbol-function 'ebox--full-rerender-buffer) (lambda (&rest args) (cl-incf full-rerenders) (apply original-full-rerender args)))) ,@body) full-rerenders)) (defconst ebox-test--region-update-paint-cases '((:color "#123456" "#123456") (:bgcolor "#FDE68A" "#FDE68A") (:border-left-color "#111111" "#111111") (:border-right-color "#222222" "#222222") (:border-top-color "#333333" "#333333") (:border-bottom-color "#444444" "#444444") (:border-top-p t t) (:border-bottom-p t t)) "Paint-only box longhand update cases: (PROPERTY VALUE EXPECTED).") (defconst ebox-test--region-update-geometry-cases '((:box-sizing content-box content-box owner-rerender) (:width (120) 120 owner-rerender) (:min-width (100) 100 owner-rerender) (:max-width (140) 140 owner-rerender) (:height 3 3 owner-rerender) (:min-height 3 3 owner-rerender) (:max-height 1 1 owner-rerender) (:padding-left-pixel 9 9 span-patch) (:padding-right-pixel 10 10 span-patch) (:padding-top-height 1 1 span-patch) (:padding-bottom-height 1 1 span-patch) (:margin-left-pixel 7 7 owner-rerender) (:margin-right-pixel 8 8 owner-rerender) (:margin-top-height 1 1 owner-rerender) (:margin-bottom-height 1 1 owner-rerender) (:border-left-pixel 5 5 span-patch) (:border-right-pixel 6 6 span-patch) (:text-align center center span-patch) (:vertical-align middle center span-patch) (:overflow hidden hidden owner-rerender) (:wrap-mode char char span-patch) (:scroll-offset 1 1 span-patch) (:visibility hidden hidden span-patch)) "Geometry box longhand update cases: (PROPERTY VALUE EXPECTED STRATEGY).") (defun ebox-test--region-update-case-properties (cases) "Return the update property names covered by CASES." (mapcar #'car cases)) (ert-deftest ebox-root-background-covers-transparent-layout-wrappers () "A root canvas background should remain under transparent child wrappers." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 240) (ebox-viewport-height 6) (layout (ebox-build '(box :width (viewport) :height (viewport-height) :bgcolor "#151A23" (column (box :width (viewport) :height 1 :bgcolor "#202938" "surface") (box :width (viewport) :height 1 "transparent") (box :width (viewport) :height 1 :bgcolor "#29364A" "surface-two"))))) (lines (ebox-string-lines (ebox-render layout)))) (should (= (length lines) 6)) (dolist (line lines) (should (ebox-test--face-has-key-value-p (get-text-property 0 'face line) :background "#151A23"))) (should (ebox-test--face-has-key-value-p (get-text-property 0 'face (nth 0 lines)) :background "#202938")) (should (ebox-test--face-has-key-value-p (get-text-property 0 'face (nth 2 lines)) :background "#29364A")))) (ert-deftest ebox-region-ids-assigns-stable-ids-before-render () (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "L" :width 40)) (right (ebox-create :content "R" :width 50)) (layout (ebox-concat left right)) (ids-before (ebox-region-ids layout))) (should (equal ids-before '(1 2))) (should (equal (ebox-region-ids layout) ids-before)) (should (equal (ebox-region-ids (ebox-render layout)) ids-before)))) (ert-deftest ebox-box-extent-template-excludes-interleaved-concat-boxes () "Prepared extents should omit boxes split by a neighboring concat column." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "A\nB" :width 60)) (right (ebox-create :content "R1\nR2\nR3" :width 80)) (rendered (ebox-render (ebox-concat left right))) (template (ebox--build-box-extent-template rendered))) (should-not (gethash (plist-get left :region-id) template)) (should-not (gethash (plist-get right :region-id) template)))) (ert-deftest ebox-live-mount-uses-runtime-copy-and-leaves-source-stable () "First live mount should copy SOURCE while preserving stable source ids." (ebox-test--reset-runtime-state) (let* ((source (ebox-build '(box :id "root" :content "before\nhidden" :width (120) :color "#111111" :height 1 :overflow scroll))) (buffer (generate-new-buffer " *ebox-live-copy-source*"))) (unwind-protect (progn (should-not (plist-member source :region-id)) (ebox-render-to-buffer buffer source) (let* ((runtime-root (ebox--buffer-root-node buffer)) (source-ids (ebox-region-ids source)) (region-id (car source-ids)) (runtime-box (gethash region-id (ebox--buffer-region-box-table buffer)))) (should source-ids) (should (equal (ebox-region-ids source) source-ids)) (should-not (eq runtime-root source)) (should (eq runtime-root runtime-box)) (should (eq (gethash region-id ebox--region-box-table) runtime-box)) (let ((ebox--region-update-buffer-hint buffer)) (ebox-region-update region-id :content "after\nhidden" :color "#222222" :width '(140) :scroll-offset 1)) (should (equal (ebox-region-ids source) source-ids)) (should (equal (ebox-get source :content) "before\nhidden")) (should (equal (ebox-get source :color) "#111111")) (should (equal (ebox-get source :width) 120)) (should (equal (ebox-get source :scroll-offset) 0)) (should (equal (ebox-get runtime-box :content) "after\nhidden")) (should (equal (ebox-get runtime-box :color) "#222222")) (should (equal (ebox-get runtime-box :width) 140)) (should (equal (ebox-get runtime-box :scroll-offset) 1)) (should (= (plist-get (ebox--scroll-get-state region-id) :scroll-offset) 1)) (with-current-buffer buffer (should (string-match-p "hidden" (buffer-string)))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-live-mount-rejects-second-owner-before-target-cleanup () "Second live mount of retained ids should fail without touching target state." (ebox-test--reset-runtime-state) (let* ((source (ebox-build '(box :id "root" :content "owned\nhidden" :width (120) :height 1 :overflow scroll))) (_source-ids (ebox-region-ids source)) (retained-copy (copy-tree source)) (missing-target-name (generate-new-buffer-name " *ebox-live-missing*")) owner-buffer target-buffer owner-state owner-root owner-region-id owner-scroll-state owner-region-box owner-extents owner-timer target-state target-text) (unwind-protect (progn (setq owner-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-live-owner*") source)) (setq target-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-live-target*") (ebox-create :content "target stays" :width 120))) (setq owner-state (ebox--buffer-render-state owner-buffer) owner-root (plist-get owner-state :root-node) owner-region-id (car (ebox-region-ids source)) owner-scroll-state (ebox--scroll-get-state owner-region-id) owner-region-box (gethash owner-region-id ebox--region-box-table) owner-extents (gethash owner-region-id ebox--box-extents) target-state (ebox--buffer-render-state target-buffer)) (setq owner-timer (run-with-timer 3600 nil #'ignore)) (puthash owner-region-id owner-timer ebox--scroll-idle-prefetch-timers) (with-current-buffer target-buffer (setq target-text (buffer-string))) (dolist (root (list source retained-copy)) (should-error (ebox-render-to-buffer target-buffer root) :type 'user-error) (should (eq (ebox--buffer-render-state owner-buffer) owner-state)) (should (eq (plist-get owner-state :root-node) owner-root)) (should (eq (ebox--scroll-get-state owner-region-id) owner-scroll-state)) (should (eq (gethash owner-region-id ebox--region-box-table) owner-region-box)) (should (eq (gethash owner-region-id ebox--box-extents) owner-extents)) (should (eq (gethash owner-region-id ebox--scroll-idle-prefetch-timers) owner-timer)) (should (eq (ebox--buffer-render-state target-buffer) target-state)) (with-current-buffer target-buffer (should (equal-including-properties (buffer-string) target-text))))) (should-error (ebox-render-to-buffer missing-target-name retained-copy) :type 'user-error) (should-not (get-buffer missing-target-name)) (dolist (buffer (list owner-buffer target-buffer)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))) (when (timerp owner-timer) (cancel-timer owner-timer)) (when-let ((buffer (get-buffer missing-target-name))) (kill-buffer buffer))))) (ert-deftest ebox-live-mount-releases-old-source-after-target-replacement () "Replacing a target root should release the old source ids for reuse." (ebox-test--reset-runtime-state) (let* ((old-source (ebox-create :content "old" :width 120)) (new-source (ebox-create :content "new" :width 120)) primary-buffer secondary-buffer) (unwind-protect (progn (setq primary-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-live-primary*") old-source)) (ebox-render-to-buffer primary-buffer new-source) (setq secondary-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-live-secondary*") old-source)) (kill-buffer secondary-buffer) (setq secondary-buffer nil) (setq secondary-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-live-secondary*") old-source)) (should (eq (ebox--region-buffer (car (ebox-region-ids old-source))) secondary-buffer))) (dolist (buffer (list primary-buffer secondary-buffer)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)))))) (ert-deftest ebox-region-update-hint-uses-buffer-owned-runtime-box () "Hinted direct updates should use the buffer-owned box table over globals." (ebox-test--reset-runtime-state) (let* ((source (ebox-build '(box :id "target" :content "before" :width (120) :color "#111111"))) (buffer (generate-new-buffer " *ebox-hinted-runtime-box*"))) (unwind-protect (progn (ebox-render-to-buffer buffer source) (let* ((region-id (ebox-test--selector-region-id buffer "#target")) (runtime-box (gethash region-id ebox--region-box-table)) (poison (ebox-create :content "poison" :width 120 :color "#ff0000"))) (puthash region-id poison ebox--region-box-table) (let ((ebox--region-update-buffer-hint buffer)) (ebox-region-update region-id :content "after")) (should (equal (ebox-get runtime-box :content) "after")) (should (equal (ebox-get poison :content) "poison")) (should (eq (gethash region-id ebox--region-box-table) runtime-box)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-node-ids-cover-layout-and-box-nodes () "Every runtime node should have a stable internal node id." (ebox-test--reset-runtime-state) (let* ((layout (ebox-stack (ebox-create :content "Top" :width 60) (ebox-concat (ebox-create :content "Left" :width 40) (ebox-create :content "Right" :width 40)))) (ids-before (ebox--runtime-node-ids layout))) (should (= (length ids-before) 5)) (should (= (length (delete-dups (copy-sequence ids-before))) 5)) (ebox-render layout) (should (equal (ebox--runtime-node-ids layout) ids-before)))) (ert-deftest ebox-wrapper-child-nodes-are-in-runtime-index () "A wrapper box's child layout should remain visible in runtime state." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column :padding 1 (box :content "One") (box :content "Two")))) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node)) (let ((ids (ebox--runtime-node-ids node))) (should (> (length ids) 1)) (dolist (node-id ids) (should (ebox--buffer-runtime-node buffer node-id))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-region-render-owner-index-maps-boxes-and-flex-wrapper () "Region lookup should map each box to its smallest renderable owner." (ebox-test--reset-runtime-state) (let* ((plain (ebox-create :content "Plain" :width '(80))) (flex-child (ebox-create :content "Flex child" :width '(80))) (flex (ebox-flex :width '(120) :padding 1 flex-child)) (flex-wrapper (plist-get flex :box)) (layout (ebox-column plain flex))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (table (ebox--buffer-region-node-table buffer)) (plain-region-id (ebox--ensure-region-id plain)) (wrapper-region-id (ebox--ensure-region-id flex-wrapper)) (child-region-id (ebox--ensure-region-id flex-child)) (plain-node-id (ebox--ensure-node-id plain)) (flex-node-id (ebox--ensure-node-id flex)) (wrapper-node-id (ebox--ensure-node-id flex-wrapper)) (child-node-id (ebox--ensure-node-id flex-child)) (runtime-plain (ebox--buffer-runtime-node buffer plain-node-id)) (runtime-flex (ebox--buffer-runtime-node buffer flex-node-id)) (runtime-child (ebox--buffer-runtime-node buffer child-node-id))) (should (hash-table-p table)) (should (equal (gethash plain-region-id table) plain-node-id)) (should (equal (gethash wrapper-region-id table) flex-node-id)) (should-not (equal (gethash wrapper-region-id table) wrapper-node-id)) (should (equal (gethash child-region-id table) child-node-id)) (should (eq (ebox--buffer-region-render-owner-node buffer plain-region-id) runtime-plain)) (should (eq (ebox--buffer-region-render-owner-node buffer wrapper-region-id) runtime-flex)) (should (eq (ebox--buffer-region-render-owner-node buffer child-region-id) runtime-child)))))) (ert-deftest ebox-region-render-owner-index-keeps-earlier-duplicate-box () "A later flex wrapper should not replace an earlier duplicate region owner." (ebox-test--reset-runtime-state) (let* ((region-id 9001) (plain (ebox-create :content "First" :width '(80))) (flex (ebox-flex :width '(120) (ebox-create :content "Later" :width '(80)))) (wrapper (plist-get flex :box)) (layout (ebox-column plain flex))) (ebox-put plain :region-id region-id) (ebox-put wrapper :region-id region-id) (let* ((index (ebox--runtime-index layout)) (table (plist-get index :region-node-table)) (expected (car (ebox--node-path-to-region layout region-id)))) (should (eq expected plain)) (should (equal (gethash region-id table) (ebox--ensure-node-id expected)))))) (ert-deftest ebox-region-render-owner-index-keeps-outer-flex-duplicate () "Nested duplicate flex wrappers should retain recursive first-match lookup." (ebox-test--reset-runtime-state) (let* ((region-id 9002) (inner (ebox-flex :width '(100) :padding 1 (ebox-create :content "Inner" :width '(80)))) (outer (ebox-flex :width '(140) :padding 1 inner)) (inner-wrapper (plist-get inner :box)) (outer-wrapper (plist-get outer :box))) (ebox-put inner-wrapper :region-id region-id) (ebox-put outer-wrapper :region-id region-id) (let* ((index (ebox--runtime-index outer)) (table (plist-get index :region-node-table)) (expected (car (ebox--node-path-to-region outer region-id)))) (should (eq expected outer)) (should (equal (gethash region-id table) (ebox--ensure-node-id expected)))))) (ert-deftest ebox-runtime-index-region-box-table-matches-complete-render () "Runtime indexing should collect the box table with render overwrite order." (ebox-test--reset-runtime-state) (let* ((shared-region-id 9003) (inner (ebox-create :content "Inner" :width '(60))) (outer (ebox-create :content "" :width '(90))) (flex-child (ebox-create :content "Flex child" :width '(50))) (flex (ebox-flex :width '(120) :padding 1 flex-child)) (layout (ebox-column outer flex)) (render-table (make-hash-table :test 'equal))) (plist-put outer :ebox-content-node inner) ;; Complete rendering records parents after their child content. A ;; duplicated legacy id therefore resolves to OUTER in the box table. (ebox-put inner :region-id shared-region-id) (ebox-put outer :region-id shared-region-id) (let* ((index (ebox--runtime-index layout t)) (index-table (plist-get index :region-box-table)) (ebox--region-box-table render-table) (ebox--scroll-global-state (make-hash-table :test 'equal))) (ebox-render layout) (should (= (hash-table-count index-table) (hash-table-count render-table))) (maphash (lambda (region-id box) (should (eq (gethash region-id index-table) box))) render-table) (should (eq (gethash shared-region-id index-table) outer)) (should (eq (gethash (ebox--ensure-region-id (plist-get flex :box)) index-table) (plist-get flex :box)))))) (ert-deftest ebox-declarative-candidate-reuses-indexed-region-box-table () "Candidate preparation should not rewalk the tree to collect source boxes." (ebox-test--reset-runtime-state) (let ((root (ebox-column (ebox-create :key 'first :content "First" :width '(80)) (ebox-create :key 'second :content "Second" :width '(80)))) (next-root (ebox-column (ebox-create :key 'first :content "Changed" :width '(80)) (ebox-create :key 'second :content "Second" :width '(80))))) (ebox-test--with-rendered-buffer root (let* ((buffer (current-buffer)) (old-state (ebox--buffer-render-state buffer)) (recache-calls 0) prepared candidate-state) (cl-letf (((symbol-function 'ebox--flex-recache-source-boxes) (lambda (&rest _) (cl-incf recache-calls)))) (setq prepared (ebox-incremental--prepare-declarative-root buffer old-state next-root)) (setq candidate-state (ebox-incremental--candidate-state old-state (plist-get prepared :root) (plist-get prepared :index) prepared))) (should (= recache-calls 0)) (should (eq (plist-get candidate-state :region-box-table) (plist-get (plist-get prepared :index) :region-box-table))) (should (= (hash-table-count (plist-get candidate-state :region-box-table)) 2)))))) (ert-deftest ebox-region-render-owner-index-falls-back-for-legacy-state () "A runtime state without the new table should retain recursive lookup." (ebox-test--reset-runtime-state) (let* ((child (ebox-create :content "Flex child" :width '(80))) (flex (ebox-flex :width '(120) :padding 1 child)) (wrapper (plist-get flex :box))) (ebox-test--with-rendered-buffer flex (let* ((buffer (current-buffer)) (state (ebox--buffer-render-state buffer)) (table (ebox--buffer-region-node-table buffer)) (region-id (ebox--ensure-region-id wrapper)) (expected-node-id (ebox--buffer-region-render-owner-node-id buffer region-id)) (expected-node (ebox--buffer-region-render-owner-node buffer region-id)) (original-path (symbol-function 'ebox--node-path-to-region)) (recursive-calls 0)) (unwind-protect (progn (plist-put state :region-node-table nil) (cl-letf (((symbol-function 'ebox--node-path-to-region) (lambda (&rest args) (cl-incf recursive-calls) (apply original-path args)))) (should (equal (ebox--buffer-region-render-owner-node-id buffer region-id) expected-node-id)) (should (eq (ebox--buffer-region-render-owner-node buffer region-id) expected-node))) (should (> recursive-calls 0))) (plist-put state :region-node-table table)))))) (ert-deftest ebox-runtime-node-id-survives-viewport-rerender () "Viewport-only rerender should not assign new runtime node ids." (ebox-test--reset-runtime-state) (let* ((layout (ebox-flex :flex-wrap 'wrap (ebox-create :content "A" :width '(100)) (ebox-create :content "B" :width '(100)))) (ids-before nil) buffer) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (setq ids-before (ebox--runtime-node-ids layout)) (let ((ebox-viewport-width 160)) (ebox--rerender-buffer-preserving-runtime buffer)) (should (equal (ebox--runtime-node-ids layout) ids-before))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-runtime-reconcile-preserves-keyed-child-id () "Keyed children should keep runtime ids across structural rebuilds." (ebox-test--reset-runtime-state) (let* ((old (ebox--runtime-tree (ebox-column (ebox-create :key 'a :content "A" :width 40) (ebox-create :key 'b :content "B" :width 40)))) (old-b-id (ebox--runtime-node-id-by-key old 'b)) (new (ebox--reconcile-runtime-tree old (ebox-column (ebox-create :key 'x :content "X" :width 40) (ebox-create :key 'b :content "B2" :width 40))))) (should (equal (ebox--runtime-node-id-by-key new 'b) old-b-id)))) (ert-deftest ebox-tree-helpers-see-wrapper-child-nodes () "Tree helpers should see wrapper children without rendering static content." (let* ((node (ebox-build '(column :padding 1 (box :content "Child" :width (120))))) (children (ebox-tree-children node))) (should children) (should (plist-get (car children) :node-id)))) (ert-deftest ebox-tree-shallow-copy-replaces-box-content-node () "A box content-node replacement should leave other values shared." (let* ((old-content (list :ebox-type 'box :content "old")) (equal-content (copy-tree old-content)) (new-content (list :ebox-type 'box :content "new")) (wrong-content (list :ebox-type 'box :content "wrong")) (metadata (list :owner 'host)) (node (list :ebox-type 'box :ebox-content-node old-content :metadata metadata)) (copy (ebox-tree-copy-with-direct-child-replacements node (list (cons equal-content wrong-content) (cons old-content new-content))))) (should-not (eq copy node)) (should (eq (plist-get copy :ebox-content-node) new-content)) (should (eq (plist-get copy :metadata) metadata)) (should (eq (plist-get node :ebox-content-node) old-content)))) (ert-deftest ebox-tree-shallow-copy-replaces-concat-children-and-aliases () "A concat copy should update flat children and both legacy aliases." (let* ((left (list :ebox-type 'box :content "left")) (right (list :ebox-type 'box :content "right")) (new-left (list :ebox-type 'box :content "new-left")) (children (list left right)) (node (list :ebox-type 'concat :children children :left left :right right)) (copy (ebox-tree-copy-with-direct-child-replacements node (list (cons left new-left)))) (copy-children (plist-get copy :children))) (should-not (eq copy node)) (should-not (eq copy-children children)) (should (eq (car copy-children) new-left)) (should (eq (cadr copy-children) right)) (should (eq (plist-get copy :left) new-left)) (should (eq (plist-get copy :right) right)))) (ert-deftest ebox-tree-shallow-copy-replaces-stack-children-and-aliases () "A stack copy should update flat children and both legacy aliases." (let* ((top (list :ebox-type 'box :content "top")) (bottom (list :ebox-type 'box :content "bottom")) (new-bottom (list :ebox-type 'box :content "new-bottom")) (children (list top bottom)) (node (list :ebox-type 'stack :children children :top top :bottom bottom)) (copy (ebox-tree-copy-with-direct-child-replacements node (list (cons bottom new-bottom)))) (copy-children (plist-get copy :children))) (should-not (eq copy node)) (should-not (eq copy-children children)) (should (eq (car copy-children) top)) (should (eq (cadr copy-children) new-bottom)) (should (eq (plist-get copy :top) top)) (should (eq (plist-get copy :bottom) new-bottom)))) (ert-deftest ebox-tree-shallow-copy-replaces-flex-box-and-children-once () "One flex copy should apply multiple replacements and share one sibling." (let* ((box (list :ebox-type 'box :content "wrapper")) (first (list :ebox-type 'box :content "first")) (untouched (list :ebox-type 'box :content "untouched")) (last (list :ebox-type 'box :content "last")) (new-box (list :ebox-type 'box :content "new-wrapper")) (new-first (list :ebox-type 'box :content "new-first")) (new-last (list :ebox-type 'box :content "new-last")) (children (list first untouched last)) (node (list :ebox-type 'flex :box box :children children)) (copy (ebox-tree-copy-with-direct-child-replacements node (list (cons box new-box) (cons first new-first) (cons last new-last)))) (copy-children (plist-get copy :children))) (should-not (eq copy node)) (should (eq (plist-get copy :box) new-box)) (should (eq (nth 0 copy-children) new-first)) (should (eq (nth 1 copy-children) untouched)) (should (eq (nth 2 copy-children) new-last)) (should (eq (plist-get node :box) box)) (should (eq (nth 0 children) first)) (should (eq (nth 1 children) untouched)) (should (eq (nth 2 children) last)))) (ert-deftest ebox-tree-shallow-copy-replaces-flex-item-node () "A flex-item copy should replace its wrapped node by identity." (let* ((old-child (list :ebox-type 'box :content "old")) (new-child (list :ebox-type 'box :content "new")) (props (list :flex-grow 1)) (node (list :ebox-type 'flex-item :node old-child :props props)) (copy (ebox-tree-copy-with-direct-child-replacements node (list (cons old-child new-child))))) (should-not (eq copy node)) (should (eq (plist-get copy :node) new-child)) (should (eq (plist-get copy :props) props)))) (ert-deftest ebox-tree-shallow-copy-without-replacements-shares-values () "A zero-replacement copy should share every nested value." (let* ((first (list :ebox-type 'box :content "first")) (second (list :ebox-type 'box :content "second")) (children (list first second)) (node (list :ebox-type 'concat :children children)) (copy (ebox-tree-copy-with-direct-child-replacements node nil))) (should-not (eq copy node)) (should (eq (plist-get copy :children) children)) (should (eq (car (plist-get copy :children)) first)) (should (eq (cadr (plist-get copy :children)) second)))) (ert-deftest ebox-fragment-signature-excludes-backend-spans () "Fragment signatures should describe layout facts, not backend markers." (let ((fragment (ebox-fragment-create :node-id "n1" :region-ids '("r1") :size '(120 . 24) :line-signature '(("Child")) :buffer-span '(1 . 8)))) (should (equal (ebox-fragment-layout-signature fragment) '(:node-id "n1" :region-ids ("r1") :size (120 . 24) :line-signature (("Child"))))))) (ert-deftest ebox-cache-registry-records-scope-deps-and-lifetime () "Cache specs should make ownership, scope, dependencies, and lifetime explicit." (unwind-protect (let ((spec (ebox-cache-register :name 'computed-style :owner 'ebox-style :scope 'node :key-deps '(specified-style class-version theme-version) :invalidates '(style class theme) :lifetime 'buffer-runtime :value-owner 'ebox-style))) (should (eq (ebox-cache-spec-name spec) 'computed-style)) (should (eq (ebox-cache-spec-owner spec) 'ebox-style)) (should (eq (ebox-cache-spec-scope spec) 'node)) (should (equal (ebox-cache-spec-invalidates spec) '(style class theme))) (should (eq (ebox-cache-spec-lifetime spec) 'buffer-runtime))) (ebox-cache-reset-default-registry))) (ert-deftest ebox-cache-invalidates-by-dirty-kind () "Dirty facts should map to the cache specs they invalidate." (unwind-protect (progn (ebox-cache-clear-registry) (ebox-cache-register :name 'render-body :owner 'ebox-buffer-backend :scope 'buffer :key-deps '(fragment-signature paint-signature backend-display-signature) :invalidates '(paint geometry structure backend-display) :lifetime 'buffer-runtime :value-owner 'ebox-buffer-backend) (ebox-cache-register :name 'layout-fragment :owner 'ebox-layout :scope 'formatting-context :key-deps '(node-id computed-style-signature child-signatures available-size) :invalidates '(geometry structure viewport) :lifetime 'buffer-runtime :value-owner 'ebox-layout) (should (equal (sort (ebox-cache-invalidated-spec-names '(paint)) #'string<) '(render-body))) (should (equal (sort (ebox-cache-invalidated-spec-names '(geometry)) #'string<) '(layout-fragment render-body)))) (ebox-cache-reset-default-registry))) (ert-deftest ebox-cache-events-appear-in-update-report () "Incremental update reports should expose cache invalidation facts." (ebox-test--reset-runtime-state) (let* ((node (ebox-create :content "Cache" :border-color "#111111")) (id (car (ebox-region-ids node)))) (ebox-test--with-rendered-buffer node (ebox-region-update id :border-color "#222222") (let ((report (ebox-test--buffer-update-report))) (should (plist-member report :cache-invalidated)) (should (plist-member report :cache-hit-count)) (should (plist-member report :cache-miss-count)))))) (ert-deftest ebox-render-cache-retains-distinct-viewport-widths () "Returning to a warm width should not evict its prior rendered value." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-cache-report-buffer nil) (width-a '(display-a 720 42 body)) (width-b '(display-a 900 42 body))) (ebox--render-cache-store 'responsive width-a 'rendered-a) (ebox--render-cache-store 'responsive width-b 'rendered-b) (should (eq (ebox--render-cache-lookup 'responsive width-a) 'rendered-a)) (should (eq (ebox--render-cache-lookup 'responsive width-b) 'rendered-b)))) (ert-deftest ebox-render-cache-retains-distinct-box-width-signatures () "Ordinary box-width variants should coexist under one cache key." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-cache-report-buffer nil) (width-a '(display-a nil 42 (:width 720))) (width-b '(display-a nil 42 (:width 900)))) (ebox--render-cache-store 'responsive width-a 'rendered-a) (ebox--render-cache-store 'responsive width-b 'rendered-b) (should (eq (ebox--render-cache-lookup 'responsive width-a) 'rendered-a)) (should (eq (ebox--render-cache-lookup 'responsive width-b) 'rendered-b)))) (ert-deftest ebox-render-cache-distinguishes-content-text-properties () "Equal characters with different properties must not share rendered output." (let* ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-cache-report-buffer nil) (normal (list 'display-a nil 42 (list :content (propertize "Same" 'face 'default)))) (selected (list 'display-a nil 42 (list :content (propertize "Same" 'face 'highlight))))) (ebox--render-cache-store 'cell normal 'rendered-normal) (should-not (ebox--render-cache-lookup 'cell selected)) (ebox--render-cache-store 'cell selected 'rendered-selected) (should (eq (ebox--render-cache-lookup 'cell selected) 'rendered-selected)))) (ert-deftest ebox-render-cache-evicts-oldest-entry-at-capacity () "A full cache should retain newer width variants without unbounded growth." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 2) (ebox-cache-report-buffer nil)) (ebox--render-cache-store 'responsive 'width-a 'rendered-a) (ebox--render-cache-store 'responsive 'width-b 'rendered-b) (ebox--render-cache-store 'responsive 'width-c 'rendered-c) (should (= (hash-table-count ebox--render-cache-table) 2)) (should-not (ebox--render-cache-lookup 'responsive 'width-a)) (should (eq (ebox--render-cache-lookup 'responsive 'width-b) 'rendered-b)) (should (eq (ebox--render-cache-lookup 'responsive 'width-c) 'rendered-c)))) (ert-deftest ebox-render-cache-evicts-oldest-entry-at-byte-budget () "Large rendered values should be bounded even below the entry limit." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 16) (ebox-render-cache-max-bytes 400) (ebox-cache-report-buffer nil)) (ebox--render-cache-store 'responsive 'width-a "aa") (ebox--render-cache-store 'responsive 'width-b "bb") (should-not (ebox--render-cache-lookup 'responsive 'width-a)) (should (equal (ebox--render-cache-lookup 'responsive 'width-b) "bb")) (should (= (hash-table-count ebox--render-cache-table) 1)))) (ert-deftest ebox-render-cache-does-not-retain-one-oversized-value () "One value above the byte budget should be returned but not retained." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 16) (ebox-render-cache-max-bytes 128) (ebox-cache-report-buffer nil)) (should (equal (ebox--render-cache-store 'responsive 'width-a "oversized") "oversized")) (should (= (hash-table-count ebox--render-cache-table) 0)) (should-not (ebox--render-cache-lookup 'responsive 'width-a)))) (ert-deftest ebox-render-cache-oversized-store-preserves-healthy-entries () "One oversized store must not purge unrelated retained entries." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 16) (ebox-render-cache-max-bytes 1024) (ebox-cache-report-buffer nil)) (ebox--render-cache-store 'responsive 'width-a "aa") (ebox--render-cache-store 'responsive 'width-b "bb") (should (equal (ebox--render-cache-store 'responsive 'width-oversized (make-string 64 ?x)) (make-string 64 ?x))) (should-not (ebox--render-cache-lookup 'responsive 'width-oversized)) (should (equal (ebox--render-cache-lookup 'responsive 'width-a) "aa")) (should (equal (ebox--render-cache-lookup 'responsive 'width-b) "bb")) (should (= (hash-table-count ebox--render-cache-table) 2)))) (ert-deftest ebox-render-cache-oversized-update-releases-only-that-entry () "Growing one entry above the budget must release only that entry." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 16) (ebox-render-cache-max-bytes 1024) (ebox-cache-report-buffer nil)) (ebox--render-cache-store 'responsive 'width-a "aa") (ebox--render-cache-store 'responsive 'width-b "bb") (ebox--render-cache-store 'responsive 'width-b (make-string 64 ?x)) (should-not (ebox--render-cache-lookup 'responsive 'width-b)) (should (equal (ebox--render-cache-lookup 'responsive 'width-a) "aa")) (should (= (hash-table-count ebox--render-cache-table) 1)))) (ert-deftest ebox-render-cache-retains-distinct-sxhash-collisions () "Distinct signatures with one bounded hash must retain separate entries." (let* ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 16) (ebox-cache-report-buffer nil) (signature-a '(same (same (same (same "a"))))) (signature-b '(same (same (same (same "b")))))) (should-not (equal signature-a signature-b)) (should (= (sxhash-equal signature-a) (sxhash-equal signature-b))) (ebox--render-cache-store 'page signature-a 'rendered-a) (ebox--render-cache-store 'page signature-b 'rendered-b) (should (= (hash-table-count ebox--render-cache-table) 2)) (should (eq (ebox--render-cache-lookup 'page signature-a) 'rendered-a)) (should (eq (ebox--render-cache-lookup 'page signature-b) 'rendered-b)))) (ert-deftest ebox-render-cache-byte-budget-includes-scroll-side-effects () "Scroll-state metadata must not bypass the retained-byte budget." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 16) (ebox-render-cache-max-bytes 1024) (ebox-cache-report-buffer nil) (side-effects (list :scroll-actions (list (list 'set 'scroll (list :content-lines (list (make-string 4096 ?x))))) :stateful-p t :restorable-p t))) (should (equal (ebox--render-cache-store 'responsive 'width-a "x" side-effects) "x")) (should (= (hash-table-count ebox--render-cache-table) 0)) (should-not (ebox--render-cache-lookup 'responsive 'width-a)))) (ert-deftest ebox-render-cache-reuses-portable-scroll-state-in-candidate-tree () "Portable scroll templates rebind complete state to the candidate box." (ebox-test--reset-runtime-state) (let* ((buffer (generate-new-buffer " *ebox-portable-scroll-cache*")) (old-box (ebox-create :content "old" :scroll-offset 0)) (node-id (ebox--ensure-node-id old-box)) (candidate-box (ebox-create :content "candidate" :scroll-offset 0)) (node-table (make-hash-table :test 'eql)) (template (list :box old-box :content-lines '("alpha" "beta") :rendered-content-lines '("alpha") :content-height 1 :scroll-offset 0)) (actions (list (list 'set 'scroll template))) (entry (cons 'signature "rendered")) (metadata (list :scroll-actions actions :stateful-p t :portable-p t :restorable-p t)) (ebox--render-cache-scroll-state-restorable-p nil)) (plist-put candidate-box :node-id node-id) (puthash node-id candidate-box node-table) (puthash buffer (list :node-table node-table) ebox--buffer-render-state-table) (puthash entry metadata ebox--render-cache-entry-side-effects-table) (unwind-protect (with-current-buffer buffer (should (ebox--scroll-cache-actions-portable-p actions)) (should (ebox--render-cache-entry-reusable-p entry)) (ebox--render-cache-replay-entry-side-effects entry) (let ((state (ebox--scroll-get-state 'scroll))) (should (eq (plist-get state :box) candidate-box)) (should (equal (plist-get state :content-lines) '("alpha" "beta"))) (should (equal (plist-get state :rendered-content-lines) '("alpha"))))) (ebox--scroll-clear-state 'scroll) (remhash entry ebox--render-cache-entry-side-effects-table) (remhash buffer ebox--buffer-render-state-table) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-render-cache-rejects-nonportable-candidate-scroll-state () "Candidate publication must not replay closures owned by the old tree." (let* ((prefix-renderer (lambda (_count) '("deferred"))) (materializer (lambda () '("materialized"))) (actions (list (list 'set 'scroll (list :box (ebox-create :content "old" :scroll-offset 0) :content-lines '("alpha") :render-content-prefix prefix-renderer :materialize-content-lines materializer)))) (ebox--render-cache-scroll-state-restorable-p nil) (entry (cons 'signature "rendered")) (metadata (list :scroll-actions actions :stateful-p t :portable-p nil :restorable-p t))) (puthash entry metadata ebox--render-cache-entry-side-effects-table) (unwind-protect (progn (should-not (ebox--scroll-cache-actions-portable-p actions)) (should-not (ebox--render-cache-entry-reusable-p entry))) (remhash entry ebox--render-cache-entry-side-effects-table)))) (ert-deftest ebox-render-cache-ring-recovers-after-clrhash () "An exceptional cache clear should not leave stale eviction metadata." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 2) (ebox-cache-report-buffer nil)) (ebox--render-cache-store 'responsive 'width-a 'rendered-a) (ebox--render-cache-store 'responsive 'width-b 'rendered-b) (clrhash ebox--render-cache-table) (ebox--render-cache-store 'responsive 'width-c 'rendered-c) (ebox--render-cache-store 'responsive 'width-d 'rendered-d) (ebox--render-cache-store 'responsive 'width-e 'rendered-e) (should (= (hash-table-count ebox--render-cache-table) 2)) (should-not (ebox--render-cache-lookup 'responsive 'width-c)) (should (eq (ebox--render-cache-lookup 'responsive 'width-d) 'rendered-d)) (should (eq (ebox--render-cache-lookup 'responsive 'width-e) 'rendered-e)))) (ert-deftest ebox-render-cache-ring-retains-newest-entries-when-limit-shrinks () "Lowering the cache limit should retain the newest insertion-order entries." (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox-render-cache-max-entries 3) (ebox-cache-report-buffer nil)) (ebox--render-cache-store 'responsive 'width-a 'rendered-a) (ebox--render-cache-store 'responsive 'width-b 'rendered-b) (ebox--render-cache-store 'responsive 'width-c 'rendered-c) (setq ebox-render-cache-max-entries 2) (ebox--render-cache-store 'responsive 'width-d 'rendered-d) (should (= (hash-table-count ebox--render-cache-table) 2)) (should-not (ebox--render-cache-lookup 'responsive 'width-a)) (should-not (ebox--render-cache-lookup 'responsive 'width-b)) (should (eq (ebox--render-cache-lookup 'responsive 'width-c) 'rendered-c)) (should (eq (ebox--render-cache-lookup 'responsive 'width-d) 'rendered-d)))) (ert-deftest ebox-buffer-render-cache-stays-bounded-across-unique-widths () "Continuous resize should not retain an unbounded set of rendered widths." (ebox-test--reset-runtime-state) (let* ((ebox-render-cache-max-entries 32) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil) (layout (ebox-build `(box :id "root" :width (viewport) (flex :width (viewport) :gap 1 ,@(cl-loop for index below 8 collect `(box :id ,(format "card-%d" index) :content ,(format "card %d" index) :width 72 :height 1)))))) buffer) (unwind-protect (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-cache-bound*") layout)) (cl-loop for width from 241 to 280 do (ebox-rerender-buffer-with-context buffer width)) (let ((cache (ebox--buffer-render-cache buffer))) (should (<= (hash-table-count cache) ebox-render-cache-max-entries))) (with-current-buffer buffer (should (ebox-test--buffer-visually-matches-runtime-render-p)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-root-owner-rerender-reuses-a-returning-width-output () "Returning to a cached root width should not rebuild the rendered owner." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) (box :id "scroll" :width (viewport) :height 2 :overflow scroll :border t :content "cached root owner\nline two\nline three")))) buffer root-id (render-box-count 0) (role-refresh-count 0) (scroll-refresh-count 0) (original-role-refresh (symbol-function 'ebox-buffer-refresh-region-role-spans)) (original-scroll-refresh (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) (original-render-box (symbol-function 'ebox--render-box))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-root-cache*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (with-current-buffer buffer (ebox-region-update root-id :width '(260)) (ebox-region-update root-id :width '(280))) (cl-letf (((symbol-function 'ebox--render-box) (lambda (&rest args) (cl-incf render-box-count) (apply original-render-box args))) ((symbol-function 'ebox-buffer-refresh-region-role-spans) (lambda (&rest args) (cl-incf role-refresh-count) (apply original-role-refresh args))) ((symbol-function 'ebox--refresh-buffer-scroll-content-markers) (lambda (&rest args) (cl-incf scroll-refresh-count) (apply original-scroll-refresh args)))) (with-current-buffer buffer (ebox-region-update root-id :width '(260)))) (should (= render-box-count 0)) (should (= role-refresh-count 0)) (should (= scroll-refresh-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-viewport-root-cache-keeps-one-manual-resize-round-trip () "A maintained resize sweep should keep its exact widths for the return leg." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) (flex :width (viewport) :gap 1 (box :content "alpha" :width 72 :height 1) (box :content "beta" :width 72 :height 1))))) (widths '(488 552 616 680 728 664 600 536 472 424)) buffer (render-box-count 0) (original-render-box (symbol-function 'ebox--render-box))) (unwind-protect (let ((ebox-viewport-width 440) (ebox-viewport-height 8) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-resize-round-trip*") layout)) (dolist (width widths) (ebox-rerender-buffer-with-context buffer width 8)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (&rest args) (cl-incf render-box-count) (apply original-render-box args)))) (ebox-rerender-buffer-with-context buffer (car widths) 8)) (should (= render-box-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-returning-width-cache-restores-lazy-scroll-context () "A cached subtree must restore lazy scroll state for its render context." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(column :id "viewport" :width (viewport) (box :id "scroll" :width (viewport) :height 4 :overflow scroll (column :width (viewport) ,@(cl-loop for index below 32 collect `(box :content ,(format "cached row %02d" index) :width (viewport) :height 1))))))) buffer scroll-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 6) ;; Evict the first complete root so the return leg renders the ;; root while retaining its generic cached scroll subtree. (ebox-render-root-cache-max-entries 1) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-scroll-cache-context*") layout)) (setq scroll-id (ebox-test--selector-region-id buffer "#scroll")) (ebox-rerender-buffer-with-context buffer 320 6) (ebox-rerender-buffer-with-context buffer 240 6) (let ((state (ebox--scroll-get-state scroll-id))) (should state) (should (= (ebox--string-pixel-width (car (plist-get state :rendered-content-lines))) 240)) (dotimes (_ 16) (setq state (ebox--scroll-state-ensure-prefix-lines scroll-id state (1+ (length (plist-get state :content-lines))) t)))) (with-current-buffer buffer (dotimes (_ 6) (should (= (ebox--scroll-region-by scroll-id 1) 1))) (let ((scrolled (buffer-string))) (ebox-buffer-apply-root-rerender buffer) (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-reflow-cache-prewarm-caches-the-complete-root-output () "A predicted viewport should publish a root render reusable by its reflow." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) (box :content "predicted root owner" :width (viewport) :height 1)))) buffer (render-box-count 0) (original-render-box (symbol-function 'ebox--render-box))) (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-root-prewarm*") layout)) (ebox--cancel-buffer-runtime-prewarm buffer) (let* ((state (ebox--buffer-render-state buffer)) (revision (plist-get state :runtime-revision))) (should (ebox--prewarm-buffer-render-cache-at-context buffer state revision 240 260 4))) (cl-letf (((symbol-function 'ebox--render-box) (lambda (&rest args) (cl-incf render-box-count) (apply original-render-box args)))) (ebox-rerender-buffer-with-context buffer 260 4)) (should (= render-box-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-cold-ordinary-root-reflow-skips-complete-templates () "An ordinary visible cache miss should leave complete scans to prewarming." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) (box :content "cold root metadata" :height 1)))) buffer (template-build-count 0) (original-template-builder (symbol-function 'ebox-buffer--build-region-role-span-template))) (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-cold-root-metadata*") layout)) (let ((root-id (ebox-test--selector-region-id buffer "#root"))) (cl-letf (((symbol-function 'ebox-buffer--build-region-role-span-template) (lambda (&rest arguments) (cl-incf template-build-count) (apply original-template-builder arguments)))) (let ((ebox--region-update-buffer-hint buffer)) (with-current-buffer buffer (ebox-region-update root-id :width '(260)))) (should (= template-build-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-region-role-template-coalesces-unrelated-properties () "Role templates should inspect present properties and preserve full spans." (let ((rendered (copy-sequence "abcd"))) (add-text-properties 0 4 '(ebox-content 7) rendered) (add-text-properties 1 2 '(face bold) rendered) (add-text-properties 2 4 '(ebox-pl 8) rendered) (let ((template (ebox-buffer--build-region-role-span-template rendered))) (should (equal (gethash (list 7 'content) template) '((1 . 5)))) (should (equal (gethash (list 8 'pl) template) '((3 . 5))))))) (ert-deftest ebox-prepared-root-render-is-exact-one-shot-fast-path () "An exact prepared root should bypass layout once and never affect children." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) (box :id "child" :content "prepared" :height 1)))) buffer) (unwind-protect (let ((ebox-viewport-width 900) (ebox-viewport-height 4) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prepared-root*") layout)) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) (child (ebox--buffer-region-render-owner-node buffer (ebox-test--selector-region-id buffer "#child"))) (rendered (propertize "prepared-root" 'face 'bold)) (prepared (list :rendered rendered :render-state state :node-id (ebox--ensure-node-id root) :runtime-revision 0 :display-signature (with-current-buffer buffer (ebox-display-signature)) :kind 'root-width :region-id (ebox-test--selector-region-id buffer "#root") :root-width 240 :viewport-width 900 :viewport-height 4))) (with-current-buffer buffer (ebox--with-buffer-render-context buffer (let ((ebox--prepared-root-render prepared)) ;; A local subtree lookup must not inspect or consume the ;; root-only handoff. (ebox--render-with-cache child) (should-not (plist-get prepared :consumed-p)) (cl-letf (((symbol-function 'ebox-render) (lambda (&rest _) (ert-fail "Exact prepared root reran layout")))) (should (eq (ebox--render-with-cache root t) rendered))) (should (plist-get prepared :consumed-p)) (should-not ebox--prepared-root-render)))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-prepared-root-render-rejects-stale-runtime () "A stale prepared root should fall back without entering later updates." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) :content "old"))) buffer (fallback-count 0)) (unwind-protect (let ((ebox-viewport-width 900) (ebox-viewport-height 4) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-stale-prepared-root*") layout)) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) (prepared (list :rendered "stale" :render-state state :node-id (ebox--ensure-node-id root) :runtime-revision 1 :display-signature (with-current-buffer buffer (ebox-display-signature)) :kind 'root-width :region-id (ebox-test--selector-region-id buffer "#root") :root-width 240 :viewport-width 900 :viewport-height 4))) (with-current-buffer buffer (ebox--with-buffer-render-context buffer (let ((ebox--prepared-root-render prepared) (ebox--render-cache-table (make-hash-table :test 'equal))) (cl-letf (((symbol-function 'ebox-render) (lambda (&rest _) (cl-incf fallback-count) "fresh"))) (should (equal (ebox--render-with-cache root t) "fresh"))) (should (= fallback-count 1)) (should-not (plist-get prepared :consumed-p)) (should-not ebox--prepared-root-render)))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-prepared-animation-restores-complete-final-runtime () "Display-only root frames should defer indexes until one complete final frame." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) (column (box :id "a" :content "alpha" :height 1) (box :id "b" :content "beta" :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-animation*") layout)) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) (root-node-id (ebox--ensure-node-id root)) (root-id (ebox-test--selector-region-id buffer "#root")) (initial (with-current-buffer buffer (buffer-string))) (render-at (lambda (width) (let* ((isolated (copy-tree root)) (box (ebox--root-region-box isolated root-id)) (ebox-viewport-width 900) (ebox-viewport-height 4) (ebox--render-runtime-revision (plist-get state :runtime-revision)) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) (ebox--region-box-table (make-hash-table :test 'equal)) (ebox--scroll-global-state (make-hash-table :test 'equal))) (ebox-put box :width width) (ebox-render isolated)))) (full-260 (funcall render-at 260)) (light-260 (copy-sequence full-260)) (prepared-260 nil)) (remove-list-of-text-properties 0 (length light-260) '(ebox-content-idx ebox-content ebox-content-owners ebox-content-owner ebox-bb ebox-br ebox-pr ebox-pb ebox-pl ebox-bl ebox-bt ebox-pt ebox-scroll-window) light-260) (setq prepared-260 (list :rendered light-260 :render-state state :node-id root-node-id :runtime-revision 0 :display-signature (with-current-buffer buffer (ebox-display-signature)) :kind 'root-width :region-id root-id :root-width 260 :viewport-width 900 :viewport-height 4)) (with-current-buffer buffer (let ((ebox--prepared-root-render prepared-260) (ebox--prepared-root-defer-index-refresh t) (ebox--region-update-buffer-hint buffer)) (ebox-region-update root-id :width '(260)))) (should (plist-get prepared-260 :consumed-p)) (should (eq (plist-get (ebox-test--buffer-update-report) :snapshot-refresh) 'prepared-animation)) (should (eq (plist-get (ebox-test--buffer-update-report) :index-refresh) 'defer-extents)) (should-not (ebox--buffer-region-role-span-table buffer)) (let* ((snapshot (gethash root-node-id (ebox--ensure-buffer-layout-snapshots buffer))) (spans (plist-get snapshot :buffer-spans))) (should (= (caar spans) 1)) (should (= (cdr (car (last spans))) (with-current-buffer buffer (point-max))))) (let ((prepared-240 (list :rendered initial :render-state state :node-id root-node-id :runtime-revision (plist-get state :runtime-revision) :display-signature (with-current-buffer buffer (ebox-display-signature)) :kind 'root-width :region-id root-id :root-width 240 :viewport-width 900 :viewport-height 4))) (with-current-buffer buffer (let ((ebox--prepared-root-render prepared-240) (ebox--prepared-root-defer-index-refresh nil) (ebox--region-update-buffer-hint buffer)) (ebox-region-update root-id :width '(240)))) (should (plist-get prepared-240 :consumed-p))) (with-current-buffer buffer (should (equal-including-properties (buffer-string) initial)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-complete-root-output-cache-retains-only-recent-widths () "Full root outputs should not crowd long-lived subtree cache entries." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) (box :content "bounded root output" :width (viewport))))) (ebox-render-root-cache-max-entries 2) (ebox-render-root-cache-max-bytes (* 8 1024 1024)) 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-bounded-root-cache*") layout)) (dolist (width '(260 280 300)) (ebox-rerender-buffer-with-context buffer width 4)) (let* ((render-cache (ebox--buffer-render-cache buffer)) (root-cache (gethash render-cache ebox--render-root-cache-table-table))) (should (hash-table-p root-cache)) (should (= (hash-table-count root-cache) 2)) (should-not (eq root-cache render-cache)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-root-width-cache-prewarm-is-isolated-and-reusable () "Predicted ordinary root width output should stay isolated until update." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) (box :id "scroll" :width (viewport) :height 2 :overflow scroll :border t :content "predicted ordinary root\nline two\nline three")))) buffer root-id (render-box-count 0) (role-refresh-count 0) (scroll-refresh-count 0) (original-render-box (symbol-function 'ebox--render-box)) (original-role-refresh (symbol-function 'ebox-buffer-refresh-region-role-spans)) (original-scroll-refresh (symbol-function 'ebox--refresh-buffer-scroll-content-markers))) (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-root-width-prewarm*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--buffer-render-state buffer)) (revision (plist-get state :runtime-revision)) (root-before (copy-tree (plist-get state :root-node))) (text-before (with-current-buffer buffer (buffer-string)))) (should (ebox--prewarm-buffer-render-cache-at-root-width buffer state revision root-id 240 260)) (should (equal (plist-get state :root-node) root-before)) (should (equal-including-properties (with-current-buffer buffer (buffer-string)) text-before))) (cl-letf (((symbol-function 'ebox--render-box) (lambda (&rest args) (cl-incf render-box-count) (apply original-render-box args))) ((symbol-function 'ebox-buffer-refresh-region-role-spans) (lambda (&rest args) (cl-incf role-refresh-count) (apply original-role-refresh args))) ((symbol-function 'ebox--refresh-buffer-scroll-content-markers) (lambda (&rest args) (cl-incf scroll-refresh-count) (apply original-scroll-refresh args)))) (with-current-buffer buffer (ebox-region-update root-id :width '(260)))) (should (= render-box-count 0)) (should (= role-refresh-count 0)) (should (= scroll-refresh-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-root-width-prewarm-reuses-confirmed-isolated-scratch () "Consecutive root predictions should reuse one confirmed isolated tree." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) (box :content "reusable isolated tree" :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-reflow-scratch*") layout)) (let* ((state (ebox--buffer-render-state buffer)) (root-id (ebox-test--selector-region-id buffer "#root")) (revision (plist-get state :runtime-revision)) live-root scratch scratch-tree) (setq live-root (plist-get state :root-node)) (should (ebox--prewarm-buffer-render-cache-at-root-width buffer state revision root-id 240 260)) (setq scratch (plist-get state :reflow-prewarm-scratch) scratch-tree (plist-get scratch :tree)) (should scratch) (should-not (eq scratch-tree live-root)) (should (= (ebox--literal-root-pixel-width (ebox--root-region-box scratch-tree root-id)) 260)) (should (= (ebox--literal-root-pixel-width (ebox--root-region-box live-root root-id)) 240)) (let ((ebox--region-update-buffer-hint buffer)) (with-current-buffer buffer (ebox-region-update root-id :width '(260)))) (should (eq scratch (plist-get state :reflow-prewarm-scratch))) (should (= (plist-get scratch :live-revision) 1)) (should-not (plist-get scratch :predicted-revision)) (should (ebox--prewarm-buffer-render-cache-at-root-width buffer state 1 root-id 260 280)) (should (eq scratch-tree (plist-get (plist-get state :reflow-prewarm-scratch) :tree))) (should (= (ebox--literal-root-pixel-width (ebox--root-region-box live-root root-id)) 260)) (should (= (ebox--literal-root-pixel-width (ebox--root-region-box scratch-tree root-id)) 280)) (with-current-buffer buffer (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-root-width-prewarm-discards-mismatched-scratch () "A visible root width other than the prediction should discard its scratch." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) (box :content "discard stale prediction" :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-stale-scratch*") layout)) (let* ((state (ebox--buffer-render-state buffer)) (root-id (ebox-test--selector-region-id buffer "#root"))) (should (ebox--prewarm-buffer-render-cache-at-root-width buffer state 0 root-id 240 260)) (should (plist-get state :reflow-prewarm-scratch)) (let ((ebox--region-update-buffer-hint buffer)) (with-current-buffer buffer (ebox-region-update root-id :width '(280)))) (should-not (plist-get state :reflow-prewarm-scratch)) (with-current-buffer buffer (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-viewport-prewarm-reuses-live-isolated-scratch () "Viewport predictions should reuse one nonmutating isolated live tree." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) (box :content "viewport scratch" :width (viewport) :height 1)))) buffer) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 4) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm nil)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-viewport-scratch*") layout)) (let* ((state (ebox--buffer-render-state buffer)) (live-root (plist-get state :root-node)) scratch tree) (should (ebox--prewarm-buffer-render-cache-at-context buffer state 0 240 260 4)) (setq scratch (plist-get state :reflow-prewarm-scratch) tree (plist-get scratch :tree)) (should scratch) (should-not (eq tree live-root)) (should (ebox--prewarm-buffer-render-cache-at-context buffer state 0 240 280 4)) (should (eq tree (plist-get (plist-get state :reflow-prewarm-scratch) :tree))) (should (eq live-root (plist-get state :root-node))) (should-not (plist-get scratch :predicted-revision)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-root-width-cache-prewarm-yields-to-user-input () "Ordinary root prediction should run only after an idle interval." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) (box :content "scheduled ordinary root" :height 1)))) buffer scheduled-delay scheduled-repeat scheduled-function scheduled-arguments) (unwind-protect (let ((ebox-viewport-width 900) (ebox-viewport-height 4) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm t) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-root-schedule*") layout)) (let ((root-id (ebox-test--selector-region-id buffer "#root"))) (cl-letf (((symbol-function 'run-at-time) (lambda (&rest _) (ert-fail "Resize prediction must yield to input"))) ((symbol-function 'run-with-idle-timer) (lambda (delay repeat function &rest arguments) (setq scheduled-delay delay scheduled-repeat repeat scheduled-function function scheduled-arguments arguments) 'scheduled-reflow-timer))) (ebox--schedule-buffer-root-width-cache-prewarm buffer root-id 240 260))) (should (= scheduled-delay ebox-runtime-idle-reflow-cache-prewarm-delay)) (should-not scheduled-repeat) (should (eq scheduled-function #'ebox--run-root-width-cache-prewarm)) (should (equal (car scheduled-arguments) buffer)) (should (eq (gethash buffer ebox--reflow-cache-prewarm-timers) 'scheduled-reflow-timer))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-scheduled-reflow-prewarms-use-offscreen-gc-pressure () "Both predicted reflow paths should move GC pressure off visible frames." (let ((prewarm-count 0) (gc-count 0) observed) (cl-letf (((symbol-function 'ebox--prewarm-buffer-render-cache-at-root-width) (lambda (&rest _) (cl-incf prewarm-count) (push (list gc-cons-threshold gc-cons-percentage ebox-render-gc-cons-threshold ebox-render-gc-cons-percentage) observed) t)) ((symbol-function 'ebox--prewarm-buffer-render-cache-at-context) (lambda (&rest _) (cl-incf prewarm-count) (push (list gc-cons-threshold gc-cons-percentage ebox-render-gc-cons-threshold ebox-render-gc-cons-percentage) observed) t)) ((symbol-function 'garbage-collect) (lambda () (cl-incf gc-count)))) (let ((ebox-reflow-cache-prewarm-gc-cons-threshold 123456) (ebox-reflow-cache-prewarm-gc-cons-percentage 0.2)) (ebox--run-root-width-cache-prewarm nil nil 0 'root 300 315) (ebox--run-reflow-cache-prewarm nil nil 0 300 315 20))) (should (= prewarm-count 2)) (should (= gc-count 0)) (should (equal observed '((123456 0.2 123456 0.2) (123456 0.2 123456 0.2)))))) (ert-deftest ebox-reflow-prewarm-gc-adapts-to-measured-layout-cost () "Automatic prewarm GC should follow measured allocation and layout time." (let ((ebox--physical-memory-bytes (* 16 1024 1024 1024)) (ebox-reflow-cache-prewarm-gc-auto-frame-budget 0.2) (ebox-reflow-cache-prewarm-gc-auto-min-threshold (* 64 1024 1024)) (ebox-reflow-cache-prewarm-gc-auto-max-threshold (* 1024 1024 1024)) (ebox-reflow-cache-prewarm-gc-auto-target-layouts 24)) (should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation (* 28 1024 1024) 0.16) (* 672 1024 1024))) (should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation (* 8 1024 1024) 0.08) (* 128 1024 1024))) (should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation (* 2 1024 1024) 0.02) (* 64 1024 1024))) (should (= (ebox--reflow-cache-prewarm-auto-threshold-for-allocation (* 64 1024 1024) 0.21) (* 1024 1024 1024))) (should (= (ebox--memory-use-counts-estimated-bytes '(0 0 0 0 0 0 0) '(1 1 1 1 1 1 1)) 161)) (let ((state (list :runtime-revision 0))) (ebox--record-reflow-cache-prewarm-cost state 0.16 0 (* 512 1024 1024) (* 28 1024 1024)) (should (= (plist-get state :reflow-prewarm-gc-threshold) (* 672 1024 1024))) (ebox--record-reflow-cache-prewarm-cost state 0.5 1 (* 672 1024 1024) (* 28 1024 1024)) (should (= (plist-get state :reflow-prewarm-gc-threshold) (* 672 1024 1024))) (should (= (plist-get state :reflow-prewarm-layout-seconds) 0.16))))) (ert-deftest ebox-buffer-backend-maps-paint-style-to-text-properties () "Paint mapping should be a backend operation, not a style-model operation." (let* ((style (ebox-style-compute (list :color "#111111" :background-color "#eeeeee" :border-color "#333333"))) (props (ebox-buffer-paint-text-properties style 'content))) (should props) (should (plist-member props 'face)))) (ert-deftest ebox-buffer-backend-does-not-map-layout-only-properties () "Layout-only properties should not produce text properties directly." (let* ((style (ebox-style-compute (list :width '(120) :padding '(1 2)))) (props (ebox-buffer-paint-text-properties style 'content))) (should-not (memq :width props)) (should-not (memq :padding-inline-start props)))) (ert-deftest ebox-add-content-owner-ignores-paint-only-segment-changes () "Owner stacking should segment only when owner metadata changes." (let ((line (copy-sequence "abcdefgh")) (stack-lookups 0) (original-stack-at (symbol-function 'ebox--content-owner-stack-at))) (add-text-properties 0 4 '(ebox-content-owner child-a) line) (add-text-properties 4 8 '(ebox-content-owner child-b) line) (add-text-properties 1 2 '(face (:foreground "#111111")) line) (add-text-properties 2 3 '(display (space :width (3))) line) (add-text-properties 5 6 '(face (:background "#eeeeee")) line) (cl-letf (((symbol-function 'ebox--content-owner-stack-at) (lambda (&rest args) (cl-incf stack-lookups) (apply original-stack-at args)))) (let ((owned (ebox--add-content-owner line 'root))) (should (= stack-lookups 2)) (should (equal '(child-a root) (get-text-property 0 'ebox-content-owners owned))) (should (equal '(child-b root) (get-text-property 4 'ebox-content-owners owned))))))) (ert-deftest ebox-add-content-owner-scans-complete-owner-stacks-once () "Complete owner stacks should not require a parallel owner-property scan." (let ((line (copy-sequence "abcdefgh")) (property-scans 0) (original-next (symbol-function 'next-single-property-change))) (add-text-properties 0 4 '(ebox-content-owner child-a ebox-content-owners (child-a)) line) (add-text-properties 4 8 '(ebox-content-owner child-b ebox-content-owners (child-b)) line) (cl-letf (((symbol-function 'next-single-property-change) (lambda (position property &rest args) (when (memq property '(ebox-content-owner ebox-content-owners)) (cl-incf property-scans)) (apply original-next position property args)))) (let ((owned (ebox--add-content-owner line 'root))) (should (= property-scans 2)) (should (equal '(child-a root) (get-text-property 0 'ebox-content-owners owned))) (should (equal '(child-b root) (get-text-property 4 'ebox-content-owners owned))))))) (ert-deftest ebox-add-content-owners-merges-all-owners-in-one-scan () "Adding several owners should scan each original owner run only once." (let ((line (copy-sequence "abcdefgh")) (property-scans 0) (original-next (symbol-function 'next-single-property-change))) (add-text-properties 0 4 '(ebox-content-owner child-a) line) (add-text-properties 4 8 '(ebox-content-owner child-b) line) (cl-letf (((symbol-function 'next-single-property-change) (lambda (position property &rest args) (when (memq property '(ebox-content-owner ebox-content-owners)) (cl-incf property-scans)) (apply original-next position property args)))) (let ((owned (ebox--add-content-owners line '(middle root)))) (should (= property-scans 4)) (should (eq (get-text-property 0 'ebox-content-owner owned) 'root)) (should (equal '(child-a middle root) (get-text-property 0 'ebox-content-owners owned))) (should (equal '(child-b middle root) (get-text-property 4 'ebox-content-owners owned))))))) (ert-deftest ebox-propertize-content-line-can-own-private-line-in-place () "A private split line should accept wrapper ownership without another copy." (let* ((source (propertize "content" 'ebox-content 'child 'ebox-content-owner 'child 'ebox-content-owners '(child))) (source-before (copy-sequence source)) (private-line (copy-sequence source)) (regular (ebox--propertize-content-line source 'wrapper 0 "fallback")) (owned (let ((ebox--propertize-private-content-line-p t)) (ebox--propertize-content-line private-line 'wrapper 0 "fallback")))) (should-not (eq regular source)) (should (equal-including-properties source source-before)) (should (eq owned private-line)) (should (eq (get-text-property 0 'ebox-content-owner owned) 'wrapper)) (should (equal (get-text-property 0 'ebox-content-owners owned) '(child wrapper))))) (ert-deftest ebox-scroll-rendered-content-lines-own-color-copy-in-place () "Colored scroll lines should add ownership to the color copy in place." (let* ((box (ebox-create :width 120 :color "#112233" :bgcolor "#F4F5F6")) (region-id 901) (source (propertize "child" 'ebox-content 77 'ebox-content-owner 77 'ebox-content-owners '(77))) (source-before (copy-sequence source)) (filler (ebox-pixel-space (ebox--content-pixel box))) (expected (ebox--propertize-colors (ebox--propertize-content-line source region-id 4 filler) (ebox-get box :color) (ebox-get box :bgcolor))) (copying-owner-count 0) (original-copying-owner (symbol-function 'ebox--add-content-owner)) actual) (cl-letf (((symbol-function 'ebox--add-content-owner) (lambda (&rest args) (cl-incf copying-owner-count) (apply original-copying-owner args)))) (setq actual (car (ebox--scroll-rendered-content-lines box (list source) region-id 4)))) (should (equal-including-properties source source-before)) (should (equal-including-properties actual expected)) (should (= copying-owner-count 0)))) (ert-deftest ebox-box-render-coalesces-foreground-background-pass () "Foreground and background should not each copy the rendered box lines." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "paint" :width 12 :height 1 :color "#112233" :bgcolor "#ddeeff")) (reference (ebox-render box)) (original-color (symbol-function 'ebox--propertize-color)) (original-bgcolor (symbol-function 'ebox--propertize-bgcolor)) (color-calls 0) (bgcolor-calls 0) actual) (cl-letf (((symbol-function 'ebox--propertize-color) (lambda (&rest args) (cl-incf color-calls) (apply original-color args))) ((symbol-function 'ebox--propertize-bgcolor) (lambda (&rest args) (cl-incf bgcolor-calls) (apply original-bgcolor args)))) (setq actual (ebox-render box))) (should (= color-calls 0)) (should (= bgcolor-calls 0)) (should (equal-including-properties actual reference)))) (ert-deftest ebox-incremental-update-scheduler-batches-multiple-style-changes () "Scheduler seed should coalesce multiple property changes into one flush." (let* ((node (ebox-create :content "Batch" :border-color "#111111")) (id (car (ebox-region-ids node)))) (ebox-test--with-rendered-buffer node (ebox-incremental-begin-batch (current-buffer)) (ebox-region-update id :border-color "#222222") (ebox-region-update id :background-color "#eeeeee") (should (= (ebox-incremental-pending-count (current-buffer)) 2)) (ebox-incremental-flush (current-buffer)) (let ((report (ebox-test--buffer-update-report))) (should (= (plist-get report :flush-count) 1)) (should (= (plist-get report :patch-count) 1)) (should (member 'paint (plist-get report :dirty-kinds))))))) (ert-deftest ebox-explicit-batch-keeps-stronger-same-owner-operation () "Same-owner paint and geometry must preserve the stronger operation." (dolist (order '((paint geometry) (geometry paint))) (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Batch width" :width '(160) :color "black")) (id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((buffer (current-buffer))) (ebox-incremental-begin-batch buffer) (dolist (operation order) (pcase operation ('paint (ebox-region-update id :color "red")) ('geometry (ebox-region-update id :width '(80))))) (ebox-incremental-flush buffer)) (let ((report (ebox-test--buffer-update-report))) (let ((runtime-box (gethash id (ebox--buffer-region-box-table (current-buffer))))) (should (equal (ebox-get box :width) 160)) (should (= (ebox-get runtime-box :width) 80))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should-not (eq (plist-get report :strategy) 'paint-patch))))))) (ert-deftest ebox-explicit-batch-keeps-descendant-paint-after-ancestor-paint () "Ancestor and child paint must execute general-to-specific in any input order." (dolist (order '((parent child) (child parent))) (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id parent :width (120) :color "black" (box :id child :content "Child" :width (120) :color "black"))))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (parent-id (ebox-test--selector-region-id buffer "#parent")) (child-id (ebox-test--selector-region-id buffer "#child"))) (ebox-incremental-begin-batch buffer) (dolist (operation order) (pcase operation ('parent (ebox-region-update parent-id :color "red")) ('child (ebox-region-update child-id :color "blue")))) (ebox-incremental-flush buffer)) (goto-char (point-min)) (search-forward "Child") (let ((face (get-text-property (- (point) (length "Child")) 'face))) (should (ebox-test--face-has-key-value-p face :foreground "blue"))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-explicit-batch-paints-before-descendant-text-change () "Ancestor paint must run before a descendant text-changing operation." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id parent :width (160) :color "black" (box :id child :content "Child" :width (120) :color "blue"))))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (parent-id (ebox-test--selector-region-id buffer "#parent")) (child-id (ebox-test--selector-region-id buffer "#child"))) (ebox-incremental-begin-batch buffer) (ebox-region-update parent-id :color "red") (ebox-region-update child-id :width '(80)) (ebox-incremental-flush buffer)) (goto-char (point-min)) (search-forward "Child") (let ((face (get-text-property (- (point) (length "Child")) 'face))) (should (ebox-test--face-has-key-value-p face :foreground "blue"))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (defun ebox-test--multi-row-batch-flex-layout () "Return a wrapped flex fixture with targets on separate rows." (ebox-build '(flex :id matrix :width (500) :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) :border "#999999" (column :width (210) :border "#999999" (box :content "Title A" :width (190)) (box :id target-a :content "Target A" :width (160) :height 3 :padding (0 (8)) :border "#999999" :text-align right) (box :content "Metric A" :width (190) :height 2)) (column :width (210) :border "#999999" (box :content "Peer A" :width (190)) (box :content "Same first row" :width (190) :height 3) (box :content "Metric B" :width (190) :height 2)) (column :width (210) :border "#999999" (box :content "Title B" :width (190)) (box :id target-b :content "Target B" :width (160) :height 3 :padding (0 (8)) :border "#999999" :text-align right) (box :content "Metric C" :width (190) :height 2)) (column :width (210) :border "#999999" (box :content "Peer B" :width (190)) (box :content "Same second row" :width (190) :height 3) (box :content "Metric D" :width (190) :height 2))))) (ert-deftest ebox-explicit-batch-multiple-flex-rows-skip-line-fast-path () "Dirty descendants on separate flex rows must rerender in either order." (dolist (order '((target-a target-b) (target-b target-a))) (ebox-test--reset-runtime-state) (let ((layout (ebox-test--multi-row-batch-flex-layout))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (target-a-id (ebox-test--selector-region-id buffer "#target-a")) (target-b-id (ebox-test--selector-region-id buffer "#target-b"))) (ebox-incremental-begin-batch buffer) (dolist (target order) (ebox-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-region-update target-a-id :width '(210) :min-width '(150) :max-width '(260))) ('paint (ebox-region-update target-b-id :color "blue")))) (ebox-incremental-flush buffer)) (goto-char (point-min)) (search-forward "Target B") (let* ((start (- (point) (length "Target B"))) (face (get-text-property start 'face)) (report (ebox-test--buffer-update-report))) (should (ebox-test--face-has-key-value-p face :foreground "blue")) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should-not (plist-get report :flex-line-rerender))))))) (ert-deftest ebox-layout-module-renders-row-and-column-through-facade () "Facade rendering should still use extracted layout owners." (let ((rendered (ebox-render (ebox-build '(column (row (box :content "A" :width (40)) (box :content "B" :width (40)))))))) (should (string-match-p "A" rendered)) (should (string-match-p "B" rendered)))) (ert-deftest ebox-layout-snapshot-details-are-computed-lazily () "Initial render should store cheap snapshots and complete spans on demand." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "L" :width 40 :border t)) (right (ebox-create :content "R" :width 40 :border t)) (layout (ebox-concat left right)) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (let* ((root-id (plist-get layout :node-id)) (snapshot (ebox--layout-snapshot buffer root-id))) (should snapshot) (should (eq (plist-get snapshot :type) 'concat)) (should-not (plist-member snapshot :line-signature)) (should-not (plist-member snapshot :buffer-spans)) (let ((detailed (ebox--ensure-layout-snapshot-details buffer root-id))) (should (plist-get detailed :line-signature)) (should (plist-get detailed :buffer-spans)) (should (plist-member (ebox--layout-snapshot buffer root-id) :line-signature))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-layout-snapshot-details-include-footprint-signatures () "Detailed snapshots should expose explicit L2 footprint signatures." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Footprint" :width '(120) :height 1 :border t)) (node-id (ebox--ensure-node-id box))) (ebox-test--with-rendered-buffer box (let* ((snapshot (ebox--ensure-layout-snapshot-details (current-buffer) node-id)) (span-footprint (plist-get snapshot :span-footprint-signature)) (external-footprint (plist-get snapshot :external-footprint-signature)) (parent-slot (plist-get snapshot :parent-slot-signature)) (role-topology (plist-get snapshot :role-topology-signature)) (overflow (plist-get snapshot :overflow-signature))) (should span-footprint) (should external-footprint) (should parent-slot) (should role-topology) (should overflow) (should (= (plist-get span-footprint :span-count) 1)) (should (equal (plist-get span-footprint :line-pixel-widths) (plist-get snapshot :line-signature))) (should (= (plist-get external-footprint :block-lines) 1)) (should (= (plist-get external-footprint :max-line-pixel-width) (car (plist-get snapshot :line-signature)))) (should (= (plist-get parent-slot :slot-count) 1)) (should (memq 'content (plist-get role-topology :roles))) (should-not (plist-get overflow :visible)))))) (ert-deftest ebox-role-topology-signature-uses-published-span-index () "Role topology should read the published index without per-role span copies." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Indexed" :width '(120) :border t)) (region-id (ebox--ensure-region-id box))) (ebox-test--with-rendered-buffer box (cl-letf (((symbol-function 'ebox--region-find) (lambda (&rest _arguments) (ert-fail "role topology rescanned region spans")))) (let ((roles (plist-get (ebox--role-topology-signature (list region-id)) :roles))) (should (memq 'content roles)) (should (memq 'content-owner roles))))))) (ert-deftest ebox-replace-line-spans-returns-new-spans-for-contiguous-lines () "Backend span replacement should return updated spans for every path." (with-temp-buffer (insert "one\ntwo\n") (let* ((spans (save-excursion (goto-char (point-min)) (list (cons (line-beginning-position) (line-end-position)) (progn (forward-line 1) (cons (line-beginning-position) (line-end-position)))))) (new-spans (ebox--replace-line-spans spans "alpha\nbeta\ngamma"))) (should (equal (buffer-string) "alpha\nbeta\ngamma\n")) (should (equal (mapcar (lambda (span) (buffer-substring-no-properties (car span) (cdr span))) new-spans) '("alpha" "beta" "gamma")))))) (ert-deftest ebox-resolve-line-anchor-falls-back-when-line-is-gone () "Window-state anchors should resolve in bounded time after line replacement." (with-temp-buffer (insert "before\n") (let ((anchor (ebox--line-anchor-at (point-min)))) (erase-buffer) (insert "after\n") (should (= (ebox--resolve-line-anchor anchor) (point-min)))))) (ert-deftest ebox-line-anchor-occurrence-tracks-duplicate-plain-lines () "Unowned duplicate lines keep resolving to the captured occurrence." (with-temp-buffer (insert "dup\nother\ndup\ndup\ntail\n") (goto-char (point-min)) (forward-line 3) (forward-char 1) (let ((anchor (ebox--line-anchor-at (point)))) (should (= (plist-get anchor :occurrence) 2)) (erase-buffer) (insert "prefix\ndup\nother\ndup\nmiddle\ndup\ntail\n") (goto-char (ebox--resolve-line-anchor anchor)) (should (= (line-number-at-pos) 6)) (should (= (current-column) 1))))) (ert-deftest ebox-line-anchor-occurrence-treats-line-text-literally () "Occurrence counting must not read regexp syntax or partial matches." (with-temp-buffer (insert "a.*b [x]\naXb x\nxa.*b [x]y\na.*b [x]\n\n\ntext\n\n") ;; "aXb x" satisfies "a.*b [x]" read as a regexp, and the superstring ;; line contains it verbatim; only the literal full-line copy counts. (goto-char (point-min)) (forward-line 3) (should (= (plist-get (ebox--line-anchor-at (point)) :occurrence) 1)) ;; An empty anchor line counts only preceding empty lines and terminates. (goto-char (point-max)) (forward-line -1) (should (= (plist-get (ebox--line-anchor-at (point)) :occurrence) 2)))) (ert-deftest ebox-layout-snapshot-capture-skips-line-ownership-scan () "Initial snapshot capture should not scan line ownership for every node." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-column (cl-loop for index below 8 collect (ebox-flex :border "#64748B" :bgcolor "#F8FAFC" :flex-flow '(row wrap) (ebox-create :content (format "A%d" index) :width 60 :bgcolor "#DBEAFE") (ebox-create :content (format "B%d" index) :width 60 :bgcolor "#DCFCE7") (ebox-create :content (format "C%d" index) :width 60 :bgcolor "#FEF3C7"))))) (calls 0) (original (symbol-function 'ebox--pos-region-ids)) buffer) (unwind-protect (progn (let ((ebox-viewport-width 900)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (ebox--clear-layout-snapshots buffer) (cl-letf (((symbol-function 'ebox--pos-region-ids) (lambda (&rest args) (cl-incf calls) (apply original args)))) (ebox--refresh-buffer-layout-snapshots buffer)) (should (= calls 0))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-stack-render-flattens-before-width-measurement () "Column rendering should not repeatedly measure already stacked strings." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-column (cl-loop for index below 12 collect (ebox-create :content (format "Row %02d" index) :width (+ 40 (* index 2)))))) (multiline-width-calls 0) (original (symbol-function 'ebox--string-pixel-width))) (cl-letf (((symbol-function 'ebox--string-pixel-width) (lambda (string) (when (and (stringp string) (string-match-p "\n" string)) (cl-incf multiline-width-calls)) (funcall original string)))) (ebox-render layout)) (should (= multiline-width-calls 0)))) (ert-deftest ebox-lines-concat-horizontal-splits-each-input-once () "Horizontal line concatenation should avoid split-pad-resplit work." (ebox-test--reset-runtime-state) (let ((split-count 0) (original (symbol-function 'ebox-string-lines)) result) (cl-letf (((symbol-function 'ebox-string-lines) (lambda (&rest args) (cl-incf split-count) (apply original args)))) (setq result (ebox--lines-concat-horizontal "A\nB\nC" "1\n2" "x"))) (should (string= (substring-no-properties result) "A1x\nB2 \nC ")) (should (= split-count 3)))) (ert-deftest ebox-layout-snapshot-table-is-created-when-missing () "Snapshot storage should tolerate render states created before snapshots." (ebox-test--reset-runtime-state) (let ((buffer (generate-new-buffer " *ebox-test*")) (node-id "legacy-node")) (unwind-protect (progn (puthash buffer (list :root-node nil :viewport-width 80) ebox--buffer-render-state-table) (ebox--put-layout-snapshot buffer node-id (list :node-id node-id)) (should (equal (plist-get (ebox--layout-snapshot buffer node-id) :node-id) node-id))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-layout-snapshot-records-display-style-and-wrapper-children () "Snapshots should describe display, style, and child ids for wrapper boxes." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column :padding 1 :bgcolor "linen" (box :content "Child A" :width (120)) (box :content "Child B" :width (120))))) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node)) (let* ((snapshot (ebox--layout-snapshot buffer (plist-get node :node-id))) (child-ids (plist-get snapshot :child-ids)) (child-snapshot (ebox--layout-snapshot buffer (car child-ids)))) (should (equal (plist-get snapshot :display) '(block flow))) (should (equal (plist-get child-snapshot :display) '(block column))) (should (plist-get snapshot :style-signature)) (should child-ids) (should (= (length child-ids) 1)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-dirty-set-classifies-paint-only-change () "A color-only change should produce paint dirty, not geometry dirty." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Paint" :width 80 :border t :border-color "red")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((old (ebox--current-layout-snapshots (current-buffer)))) (ebox-region-update region-id :border-color "blue") (let ((dirty (ebox--diff-layout-snapshots old (ebox--current-layout-snapshots (current-buffer))))) (should dirty) (should (eq (plist-get (car dirty) :dirty-kind) 'paint))))))) (ert-deftest ebox-dirty-set-classifies-viewport-wrap-change () "Viewport wrapping should produce geometry or placement dirty." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 240) buffer layout) (setq layout (ebox-flex :flex-wrap 'wrap (ebox-create :content "A" :width '(120)) (ebox-create :content "B" :width '(120)))) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (let ((old (ebox--current-layout-snapshots buffer t))) (ebox-rerender-buffer-with-context buffer 130) (let ((dirty (ebox--diff-layout-snapshots old (ebox--current-layout-snapshots buffer t)))) (should (cl-some (lambda (entry) (memq (plist-get entry :dirty-kind) '(geometry placement structure))) dirty))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-dirty-set-classifies-wrapper-child-viewport-geometry () "Viewport reflow under a wrapper should produce geometry dirty, not no-op." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column :padding 1 (box :content "Text that wraps differently when narrow" :wrap-mode word)))) buffer) (unwind-protect (progn (setq buffer (let ((ebox-viewport-width 520)) (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node))) (ebox-rerender-buffer-with-context buffer 36) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'viewport)) (should (member 'geometry (plist-get report :dirty-kinds))) (should-not (eq (plist-get report :strategy) 'no-op)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-patch-set-paints-without-rendered-text-replace () "Paint-only dirty entries should produce paint-patch ops." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Paint" :width 80 :border t :border-color "red")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((replace-count 0) (original-replace (symbol-function 'ebox--replace-line-spans))) (cl-letf (((symbol-function 'ebox--replace-line-spans) (lambda (&rest args) (cl-incf replace-count) (apply original-replace args)))) (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update region-id :border-color "blue")))) (should (= full-rerenders 0)))) (should (= replace-count 0))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (eq (plist-get report :full-rerender) nil)))))) (ert-deftest ebox-flex-wrapper-style-update-uses-paint-patch () "Flex wrapper style updates should patch faces without replacing spans." (ebox-test--reset-runtime-state) (let* ((layout (ebox-flex :border "#5E7F6A" :bgcolor "#F8FCF8" (ebox-create :content "A" :width 60 :bgcolor "#DCEBDD"))) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((replace-count 0) (original-replace (symbol-function 'ebox--replace-line-spans))) (cl-letf (((symbol-function 'ebox--replace-line-spans) (lambda (&rest args) (cl-incf replace-count) (apply original-replace args)))) (ebox-region-update region-id :bgcolor "#FDE68A")) (should (= replace-count 0))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should-not (plist-get report :full-rerender)) (should (equal (plist-get report :patch-ops) '(paint-patch))))))) (ert-deftest ebox-patch-set-promotes-interleaved-row-child-to-row-owner () "Interleaved row children should promote to their common render owner." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "Left" :width 60)) (right (ebox-create :content "Right" :width 60)) (layout (ebox-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-region-update left-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :owner-type) '(concat row))) (should-not (eq (plist-get report :owner-type) 'box)))))) (ert-deftest ebox-viewport-resize-patches-single-dependent-box () "Viewport resize should patch the smallest dependent owner when possible." (ebox-test--reset-runtime-state) (let* ((layout (ebox-column (ebox-create :content "Fixed" :width '(120) :border t) (ebox-create :content "Fluid" :width 'stretch :border t))) buffer ids-before) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (setq ids-before (ebox-region-ids layout)) (let ((returned (ebox-rerender-buffer-with-context buffer 260))) (should (equal returned (ebox-buffer-update-report buffer)))) (should (equal (ebox-region-ids layout) ids-before)) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender root-rerender))) (should (plist-get report :dirty-count)) (should (= (plist-get report :dirty-count) 1)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-report-describes-constraint-change () "Viewport resize reports should use the shared constraint-change model." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 240) buffer layout) (setq layout (ebox-flex :flex-wrap 'wrap (ebox-create :content "A" :width '(120)) (ebox-create :content "B" :width '(120)))) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (ebox-rerender-buffer-with-context buffer 160) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'viewport)) (should (eq (plist-get report :constraint-owner-type) 'viewport)) (should (equal (plist-get report :constraint-owner-id) 'viewport)) (should (equal (plist-get report :constraint-root-node-id) (plist-get layout :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 () "Column children should be independent owners when viewport resize rewraps them." (ebox-test--reset-runtime-state) (let* ((top (ebox-create :content "Top text should wrap into more lines when narrow." :wrap-mode 'word)) (bottom (ebox-create :content "Bottom text should also survive after top grows." :wrap-mode 'word)) (layout (ebox-column top bottom)) buffer) (unwind-protect (progn (let ((ebox-viewport-width 260)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (ebox-rerender-buffer-with-context buffer 90) (let ((report (ebox-test--buffer-update-report)) (plain (with-current-buffer buffer (buffer-substring-no-properties (point-min) (point-max))))) (should (equal (plist-get report :patch-ops) '(owner-rerender owner-rerender))) (should (= (plist-get report :patch-count) 2)) (should-not (member (plist-get layout :node-id) (plist-get report :owner-ids))) (should (member (plist-get top :node-id) (plist-get report :owner-ids))) (should (member (plist-get bottom :node-id) (plist-get report :owner-ids))) (should (string-match-p "Top text" plain)) (should (string-match-p "Bottom text" plain)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-noops-without-viewport-deps () "Viewport resize should not rerender when no node depends on viewport width." (ebox-test--reset-runtime-state) (let ((layout (ebox-create :content "Fixed" :width 80)) buffer) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (ebox-rerender-buffer-with-context buffer 160) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'no-op)) (should (= (plist-get report :dirty-count) 0)) (should (= (plist-get report :patch-count) 0)) (should-not (plist-get report :full-rerender)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-region-width-update-report-describes-constraint-change () "Ordinary box geometry updates should use the shared constraint-change model." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "Left" :width 60)) (right (ebox-create :content "Right" :width 60)) (layout (ebox-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-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) (plist-get left :node-id))) (should (equal (plist-get report :dirty-kinds) '(geometry))))))) (ert-deftest ebox-root-region-width-reflow-invalidates-derived-indexes () "A root box width change should use viewport-equivalent index invalidation." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (240) :content "ordinary root width"))) (root-id (car (ebox-region-ids layout))) (snapshot-refreshes 0) (extent-refreshes 0) (region-enumerations 0) (scroll-probes 0) (visible-index-probes 0) (scroll-state-syncs 0) (generic-patch-plans 0) (original-snapshots (symbol-function 'ebox--refresh-buffer-layout-snapshots)) (original-extents (symbol-function 'ebox--refresh-buffer-box-extents)) (original-region-ids (symbol-function 'ebox--node-all-region-ids)) (original-lazy (symbol-function 'ebox--try-lazy-scroll-deferred-patch)) (original-hidden (symbol-function 'ebox--try-hidden-scroll-state-span-patch)) (original-hidden-root (symbol-function 'ebox--try-hidden-scroll-root-fallback)) (original-scroll-state (symbol-function 'ebox--try-scroll-state-span-patch)) (original-visible-indexes (symbol-function 'ebox--ensure-visible-node-runtime-indexes)) (original-scroll-state-sync (symbol-function 'ebox--sync-scroll-state-lines-for-node)) (original-patch-plan (symbol-function 'ebox--patch-set-from-dirty-set))) (ebox-test--with-rendered-buffer layout (cl-letf (((symbol-function 'ebox--node-all-region-ids) (lambda (&rest args) (cl-incf region-enumerations) (apply original-region-ids args)))) (let ((change (ebox--region-constraint-change (current-buffer) root-id 'geometry '(:width)))) (should-not (plist-get (car (plist-get change :dirty-set)) :region-ids)))) (should (= region-enumerations 0)) (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) (lambda (&rest args) (cl-incf snapshot-refreshes) (apply original-snapshots args))) ((symbol-function 'ebox--refresh-buffer-box-extents) (lambda (&rest args) (cl-incf extent-refreshes) (apply original-extents args))) ((symbol-function 'ebox--try-lazy-scroll-deferred-patch) (lambda (&rest args) (cl-incf scroll-probes) (apply original-lazy args))) ((symbol-function 'ebox--try-hidden-scroll-state-span-patch) (lambda (&rest args) (cl-incf scroll-probes) (apply original-hidden args))) ((symbol-function 'ebox--try-hidden-scroll-root-fallback) (lambda (&rest args) (cl-incf scroll-probes) (apply original-hidden-root args))) ((symbol-function 'ebox--try-scroll-state-span-patch) (lambda (&rest args) (cl-incf scroll-probes) (apply original-scroll-state args))) ((symbol-function 'ebox--ensure-visible-node-runtime-indexes) (lambda (&rest args) (cl-incf visible-index-probes) (apply original-visible-indexes args))) ((symbol-function 'ebox--sync-scroll-state-lines-for-node) (lambda (&rest args) (cl-incf scroll-state-syncs) (apply original-scroll-state-sync args))) ((symbol-function 'ebox--patch-set-from-dirty-set) (lambda (&rest args) (cl-incf generic-patch-plans) (apply original-patch-plan args)))) (ebox-region-update root-id :width '(160))) (should (= snapshot-refreshes 0)) (should (= extent-refreshes 0)) (should (= scroll-probes 0)) (should (= visible-index-probes 0)) (should (= scroll-state-syncs 0)) (should (= generic-patch-plans 0)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'region)) (should (eq (plist-get report :snapshot-refresh) 'invalidate-details)) (should (eq (plist-get report :index-refresh) 'invalidate-extents)))))) (ert-deftest ebox-root-region-width-reflow-rebinds-lazy-content-viewport () "A root width update should constrain lazy descendants like viewport resize." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 240) (ebox-viewport-height 2) (layout (ebox-build '(box :id "root" :width (240) :height (viewport-height) :overflow scroll (column (box :content "first" :width (viewport)) (box :content "second" :width (viewport)) (box :content "third" :width (viewport)))))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-region-update root-id :width '(160)) (let ((widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (buffer-string))))) (should widths) (should (= (apply #'max widths) 160)))))) (ert-deftest ebox-region-width-update-promotes-parent-layout-owner () "Child geometry changes should patch the parent layout that reflows siblings." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "Left" :width 60)) (right (ebox-create :content "Right" :width 60)) (layout (ebox-row left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-region-update left-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-owner-type) 'box)) (should (eq (plist-get report :owner-type) 'concat)) (should (equal (plist-get report :patch-ops) '(owner-rerender)))) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "Left" plain)) (should (string-match-p "Right" plain)))))) (ert-deftest ebox-wrapper-child-width-update-promotes-to-wrapper-context () "Child geometry inside a wrapper should promote to the wrapper formatting context." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column :padding 1 (row (box :content "Left" :width (60)) (box :content "Right" :width (60)))))) (ids (ebox-region-ids node)) (left-id (nth 1 ids))) (ebox-test--with-rendered-buffer node (ebox-region-update left-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'region)) (should (memq 'owner-rerender (plist-get report :patch-ops)))) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "Left" plain)) (should (string-match-p "Right" plain)))))) (ert-deftest ebox-patch-set-uses-wrapper-owner-for-contained-layout-change () "Contained layout changes should patch a real owner instead of vanishing." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column (box :content "Stable header" :width (180)) (column :padding 1 (box :content "Child text wraps under the wrapper viewport" :wrap-mode word))))) buffer) (unwind-protect (progn (setq buffer (let ((ebox-viewport-width 520)) (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node))) (ebox-rerender-buffer-with-context buffer 36) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(owner-rerender root-rerender))) (should (plist-get report :owner-ids)) (should-not (equal (plist-get report :patch-ops) '(paint-patch))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-patch-set-skips-dirty-nodes-covered-by-owner () "Viewport patch planning should not repeatedly inspect covered dirty nodes." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-row (cl-loop for index below 24 collect (ebox-create :content (format "Fluid %02d" index) :border t)))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 640)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let* ((root (ebox--buffer-root-node buffer)) (dirty (ebox--viewport-dirty-set root)) (detail-calls 0) (original (symbol-function 'ebox--ensure-layout-snapshot-details)) patches) (should (> (length dirty) 10)) (cl-letf (((symbol-function 'ebox--ensure-layout-snapshot-details) (lambda (&rest args) (cl-incf detail-calls) (apply original args)))) (setq patches (ebox--patch-set-from-dirty-set dirty buffer))) (should (= (length patches) 1)) (should (eq (plist-get (car patches) :op) 'owner-rerender)) (should (<= detail-calls 4)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-patch-set-coalesces-large-column-coverage () "Large local owner sets should coalesce when they cover most root lines." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-column (cl-loop for index below 12 collect (ebox-create :content (format "Fluid section %02d" index) :border t)))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 640)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let* ((root (ebox--buffer-root-node buffer)) (dirty (ebox--viewport-dirty-set root)) (patches (ebox--patch-set-from-dirty-set dirty buffer)) (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 layout :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-coalesces-large-dirty-set-before-promotion () "Large viewport dirty sets should choose root before local owner promotion." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-column (cl-loop for index below 12 collect (ebox-create :content (format "Fluid section %02d" index) :border t)))) (promote-count 0) (dirty-entry-count 0) (original (symbol-function 'ebox--promote-to-patchable-owner)) (original-dirty-entry (symbol-function 'ebox--dirty-entry)) 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--root-owner-patch-set buffer dirty)) (source-ids (ebox--dirty-provenance-node-ids (plist-get (car patches) :dirty)))) (should (> (length dirty) 8)) (should (= (length source-ids) (length dirty))) (dolist (entry dirty) (should (member (plist-get entry :node-id) source-ids)))) (cl-letf (((symbol-function 'ebox--promote-to-patchable-owner) (lambda (&rest args) (cl-incf promote-count) (apply original args))) ((symbol-function 'ebox--dirty-entry) (lambda (&rest args) (cl-incf dirty-entry-count) (apply original-dirty-entry args)))) (ebox-rerender-buffer-with-context buffer 320)) (let ((report (ebox-test--buffer-update-report))) (should (= promote-count 0)) (should (= dirty-entry-count 1)) (should (= (plist-get report :dirty-count) 1)) (should (= (plist-get report :patch-count) 1)) (should (equal (plist-get report :owner-ids) (list (plist-get layout :node-id)))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-root-owner-allows-line-count-change () "Root owner rerender should patch even when viewport wrapping changes height." (ebox-test--reset-runtime-state) (let* ((content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") (layout (apply #'ebox-column (cl-loop for _index below 12 collect (ebox-create :content content :width 'viewport :wrap-mode 'word :border t)))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 120)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let ((wide-height (with-current-buffer buffer (count-lines (point-min) (point-max))))) (ebox-rerender-buffer-with-context buffer 20) (let ((report (ebox-test--buffer-update-report)) (narrow-height (with-current-buffer buffer (count-lines (point-min) (point-max))))) (should (> narrow-height wide-height)) (should (eq (plist-get report :strategy) 'owner-rerender)) (should-not (plist-get report :root-rerender))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-root-owner-rerender-skips-partial-slot-probe () "Whole-line root owner rerenders should not run partial-slot probing." (ebox-test--reset-runtime-state) (let* ((content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") (layout (apply #'ebox-column (cl-loop for _index below 12 collect (ebox-create :content content :width 'viewport :wrap-mode 'word :border t)))) (slot-probe-count 0) (original (symbol-function 'ebox-buffer--rendered-in-existing-slots)) buffer) (unwind-protect (progn (let ((ebox-viewport-width 120)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (cl-letf (((symbol-function 'ebox-buffer--rendered-in-existing-slots) (lambda (&rest args) (cl-incf slot-probe-count) (apply original args)))) (ebox-rerender-buffer-with-context buffer 20)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list (plist-get layout :node-id))))) (should (= slot-probe-count 0))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-owner-rerender-renders-in-target-buffer () "Owner rerender should compute display signatures in the target buffer." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-width 240)) (ebox-build '(box :content "Viewport text wraps inside the target buffer" :width (viewport) :wrap-mode word)))) (buffer nil) (other nil) (render-buffers nil) (original (symbol-function 'ebox-render))) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node))) (setq other (generate-new-buffer " *ebox-other*")) (with-current-buffer other (cl-letf (((symbol-function 'ebox-render) (lambda (&rest args) (push (current-buffer) render-buffers) (apply original args)))) (ebox-rerender-buffer-with-context buffer 120))) (should render-buffers) (should (cl-every (lambda (seen-buffer) (eq seen-buffer buffer)) render-buffers))) (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-selects-target-buffer () "Buffer render contexts should evaluate render-sensitive work in target buffers." (ebox-test--reset-runtime-state) (let* ((node (ebox-create :content "context target")) (buffer nil) (other nil) seen-buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node)) (setq other (generate-new-buffer " *ebox-other*")) (with-current-buffer other (ebox--with-buffer-render-context buffer (setq seen-buffer (current-buffer)))) (should (eq seen-buffer buffer))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)) (when (and other (buffer-live-p other)) (kill-buffer other))))) (ert-deftest ebox-buffer-render-context-preserves-caller-state-binding () "Render context internals should not capture a caller's STATE variable." (ebox-test--reset-runtime-state) (let* ((node (ebox-create :content "context state")) (buffer nil) (state 'caller-scroll-state) seen-state) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node)) (ebox--with-buffer-render-context buffer (setq seen-state state)) (should (eq seen-state 'caller-scroll-state))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-incremental-rerender-uses-render-gc-threshold () "Incremental rerenders should run inside the Ebox render GC threshold." (ebox-test--reset-runtime-state) (let* ((content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu") (layout (ebox-create :content content :width 'viewport :wrap-mode 'word)) (target-threshold (+ gc-cons-threshold 1000000)) (observed-threshold nil) (original (symbol-function 'ebox--execute-patch-set)) buffer) (unwind-protect (progn (let ((ebox-viewport-width 120)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let ((ebox-render-gc-cons-threshold target-threshold)) (cl-letf (((symbol-function 'ebox--execute-patch-set) (lambda (&rest args) (setq observed-threshold gc-cons-threshold) (apply original args)))) (ebox-rerender-buffer-with-context buffer 20))) (should observed-threshold) (should (>= observed-threshold target-threshold))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-deferred-render-gc-uses-interactive-burst-threshold () "Interactive render bursts should stay bounded and collect when settled." (let ((gc-cons-threshold 1000) (gc-cons-percentage 0.1) (ebox-deferred-render-gc-cons-threshold 2000) (ebox-render-gc-cons-percentage 0.6) (ebox--deferred-render-gc-state nil) (ebox--deferred-render-gc-timer nil) (gc-count 0)) (unwind-protect (cl-letf (((symbol-function 'garbage-collect) (lambda () (cl-incf gc-count)))) (ebox--deferred-render-gc-enter) (should (= gc-cons-threshold 2000)) (should (= gc-cons-percentage 0.6)) (ebox--deferred-render-gc-restore) (should (= gc-cons-threshold 1000)) (should (= gc-cons-percentage 0.1)) (should (= gc-count 1)) (ebox--deferred-render-gc-restore) (should (= gc-count 1))) (when (timerp ebox--deferred-render-gc-timer) (cancel-timer ebox--deferred-render-gc-timer))))) (ert-deftest ebox-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-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-visible-root-width-reflows-defer-gc-only-for-root () "Visible root width reflows should defer GC without affecting local updates." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) (box :id "child" :content "responsive child")))) (buffer nil) root-id child-id (enter-count 0) (restore-count 0)) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (setq root-id (ebox-test--selector-region-id buffer "#root")) (setq child-id (ebox-test--selector-region-id buffer "#child")) (cl-letf (((symbol-function 'ebox--buffer-visible-in-graphic-frame-p) (lambda (_buffer) t)) ((symbol-function 'ebox--deferred-render-gc-enter) (lambda () (cl-incf enter-count))) ((symbol-function 'ebox--deferred-render-gc-schedule-restore) (lambda () (cl-incf restore-count)))) (ebox-rerender-buffer-with-context buffer 160) (should (= enter-count 1)) (should (= restore-count 1)) (with-current-buffer buffer (ebox-region-update child-id :bgcolor "#223344")) (should (= enter-count 1)) (should (= restore-count 1)) (with-current-buffer buffer (ebox-region-update root-id :width 180)) (should (= enter-count 2)) (should (= restore-count 2)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-patch-planning-skips-line-signatures () "Owner rerender planning should not compute expensive line signatures." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-column (cl-loop for section below 8 collect (apply #'ebox-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (cl-loop for index below 4 collect (ebox-create :content (format "S%d-%d" section index) :width 80 :height 1)))))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 360)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let* ((root (ebox--buffer-root-node buffer)) (dirty (ebox--viewport-dirty-set root)) (line-signature-count 0) (original (symbol-function 'ebox--spans-line-signature))) (should dirty) (cl-letf (((symbol-function 'ebox--spans-line-signature) (lambda (&rest args) (cl-incf line-signature-count) (apply original args)))) (ebox--patch-set-from-dirty-set dirty buffer)) (should (= line-signature-count 0)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-patch-planning-builds-one-shared-line-index () "Multiple owner rerender planning should share one buffer line index." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-column (cl-loop for section below 8 collect (apply #'ebox-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (cl-loop for index below 4 collect (ebox-create :content (format "S%d-%d" section index) :width 80 :height 1)))))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 360)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let* ((root (ebox--buffer-root-node buffer)) (dirty (ebox--viewport-dirty-set root)) (index-build-count 0) (original (symbol-function 'ebox--build-region-line-index))) (should dirty) (cl-letf (((symbol-function 'ebox--build-region-line-index) (lambda (&rest args) (cl-incf index-build-count) (apply original args)))) (ebox--patch-set-from-dirty-set dirty buffer)) (should (= index-build-count 1)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-single-geometry-planning-skips-full-line-index-without-role-table () "Single geometry updates should not build a full line index after role table invalidation." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id "root" :width (480) (box :id "target" :content "mixed padding" :width (220) :height 3 :box-sizing border-box :padding (0 (8)) :border "#999999") (box :content "peer" :width (220) :height 3 :border "#999999")))) (index-build-count 0)) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) (original (symbol-function 'ebox--build-region-line-index))) (ebox--set-buffer-region-role-span-table (current-buffer) nil) (cl-letf (((symbol-function 'ebox--build-region-line-index) (lambda (&rest args) (cl-incf index-build-count) (apply original args)))) (ebox-region-update region-id :padding (list 1 '(18) 1 '(18)))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scoped-index-refresh)) (should (= index-build-count 0)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-region-set-line-spans-fallback-uses-live-extents () "Fallback span planning should scan only live extents when indexes are missing." (ebox-test--reset-runtime-state) (let* ((before (cl-loop for index below 80 collect `(box :content ,(format "before %02d" index) :height 1))) (after (cl-loop for index below 80 collect `(box :content ,(format "after %02d" index) :height 1))) (layout (ebox-build `(column :id "root" :width (480) ,@before (box :id "target" :content "target" :width (220) :height 1 :box-sizing border-box :padding (0 (8)) :border "#999999") ,@after))) (outside-scan-count 0)) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) (extents (ebox--live-box-extents region-id)) range-start range-end (original (symbol-function 'ebox--pos-region-ids))) (should extents) (save-excursion (goto-char (marker-position (car extents))) (setq range-start (line-beginning-position)) (goto-char (max (point-min) (1- (marker-position (cdr extents))))) (setq range-end (line-end-position))) (ebox--set-buffer-region-role-span-table (current-buffer) nil) (cl-letf (((symbol-function 'ebox--pos-region-ids) (lambda (pos) (unless (and (>= pos range-start) (< pos range-end)) (cl-incf outside-scan-count)) (funcall original pos)))) (ebox--region-set-line-spans (list region-id))) (should (= outside-scan-count 0)))))) (ert-deftest ebox-box-extents-register-from-property-segments () "Box extent registration should avoid per-character region-id scans." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Segment extent" :padding '(0 (8)) :border t)) (region-id (car (ebox-region-ids box)))) (with-temp-buffer (insert (ebox-render box)) (let ((ebox--box-extents-segment-scan-min-chars 0)) (cl-letf (((symbol-function 'ebox--pos-region-ids) (lambda (&rest _args) (error "unexpected per-character region scan")))) (ebox--register-box-extents-in-range (point-min) (point-max)))) (should (ebox--live-box-extents region-id))))) (ert-deftest ebox-box-extents-small-range-uses-position-scan () "Small extent refreshes should avoid segment scan overhead." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Small extent" :border t)) (region-id (car (ebox-region-ids box)))) (with-temp-buffer (insert (ebox-render box)) (let ((ebox--box-extents-segment-scan-min-chars most-positive-fixnum)) (cl-letf (((symbol-function 'ebox--region-property-segments-in-range) (lambda (&rest _args) (error "unexpected segment scan")))) (ebox--register-box-extents-in-range (point-min) (point-max)))) (should (ebox--live-box-extents region-id))))) (ert-deftest ebox-foreign-region-range-scan-ignores-newline-only-ownership () "Foreign ownership on separator newlines should not interleave extents." (with-temp-buffer (insert (propertize "\n" 'ebox-content 'foreign)) (should-not (ebox--foreign-region-in-range-p (point-min) (point-max) 'owner)))) (ert-deftest ebox-foreign-region-range-scan-checks-visible-text-after-newline () "Interval skipping should still detect visible foreign text after newlines." (with-temp-buffer (insert (propertize "\nvisible" 'ebox-content 'foreign)) (should (ebox--foreign-region-in-range-p (point-min) (point-max) 'owner)))) (ert-deftest ebox-scoped-box-extent-refresh-preserves-ancestor-extents () "Scoped index refresh should not shrink an ancestor to its child span." (ebox-test--reset-runtime-state) (with-temp-buffer (let ((root-id 'extent-root) (child-id 'extent-child) child-span) (insert (propertize "root-before\n" 'ebox-content-owners (list root-id))) (let ((start (point))) (insert (propertize "child\n" 'ebox-content child-id 'ebox-content-owners (list child-id root-id))) (setq child-span (cons start (point)))) (insert (propertize "root-after\n" 'ebox-content-owners (list root-id))) ;; Exercise both the short position scan and the long-range segment ;; scan. Both still inspect every owner for interleaving; the allow-set ;; restricts only which extent records may be published. (dolist (threshold (list most-positive-fixnum 0)) (let ((ebox--box-extents-segment-scan-min-chars threshold)) (ebox--clear-buffer-extents (current-buffer)) (ebox--set-box-extents root-id (point-min) (point-max)) (ebox--set-box-extents child-id (car child-span) (cdr child-span)) (let* ((root-extents (gethash root-id ebox--box-extents)) (root-span (cons (marker-position (car root-extents)) (marker-position (cdr root-extents))))) (ebox--refresh-box-extents-for-region-ids-in-spans (list child-id) (list child-span)) (should (eq root-extents (gethash root-id ebox--box-extents))) (should (equal root-span (cons (marker-position (car root-extents)) (marker-position (cdr root-extents))))) (let ((refreshed-child (gethash child-id ebox--box-extents))) (should refreshed-child) (should (equal child-span (cons (marker-position (car refreshed-child)) (marker-position (cdr refreshed-child)))))))))))) (ert-deftest ebox-scoped-role-refresh-records-wrapper-only-active-segment () "Rendered scoped refresh should rebuild visible wrapper-only extents." (ebox-test--reset-runtime-state) (with-temp-buffer (let* ((wrapper-id 'wrapper-only) (rendered (propertize "owned" 'ebox-content-owners (list wrapper-id)))) (insert rendered) (let ((spans (list (cons (point-min) (point-max))))) (puthash (current-buffer) (list :region-role-span-table (make-hash-table :test 'equal)) ebox--buffer-render-state-table) (let* ((refresh (ebox-buffer-refresh-region-role-spans-for-region-ids (current-buffer) (list wrapper-id) spans nil (list (cons rendered spans)))) (active-segments (plist-get refresh :active-segments)) (segments (gethash wrapper-id active-segments))) (should segments) (should (equal (sort (copy-sequence segments) (lambda (left right) (< (car left) (car right)))) spans)) (ebox--refresh-box-extents-from-role-spans (list wrapper-id) spans active-segments) (let ((extents (gethash wrapper-id ebox--box-extents))) (should extents) (should (equal (cons (marker-position (car extents)) (marker-position (cdr extents))) (car spans))))))))) (ert-deftest ebox-scoped-role-refresh-keeps-wrapper-only-interleaved-omitted () "Active wrapper-only segments should still omit interleaved extents." (ebox-test--reset-runtime-state) (with-temp-buffer (let* ((wrapper-id 'interleaved-wrapper) (foreign-id 'foreign-child) (rendered (concat (propertize "aa" 'ebox-content-owners (list wrapper-id)) (propertize "X" 'ebox-content foreign-id) (propertize "bb" 'ebox-content-owners (list wrapper-id))))) (insert rendered) (let ((spans (list (cons (point-min) (point-max))))) (puthash (current-buffer) (list :region-role-span-table (make-hash-table :test 'equal)) ebox--buffer-render-state-table) (let* ((refresh (ebox-buffer-refresh-region-role-spans-for-region-ids (current-buffer) (list wrapper-id) spans nil (list (cons rendered spans)))) (active-segments (plist-get refresh :active-segments))) (should (= (length (gethash wrapper-id active-segments)) 2)) (ebox--refresh-box-extents-from-role-spans (list wrapper-id) spans active-segments) (should-not (gethash wrapper-id ebox--box-extents))))))) (ert-deftest ebox-scoped-runtime-index-refresh-clears-empty-active-extents () "Scoped refresh should clear broad ids even when no active segment remains." (ebox-test--reset-runtime-state) (with-temp-buffer (insert "stale") (let ((stale-id 'stale-owner) (active-segments (make-hash-table :test 'equal))) (puthash (current-buffer) (list :region-role-span-table (make-hash-table :test 'equal)) ebox--buffer-render-state-table) (ebox--set-box-extents stale-id (point-min) (point-max)) (cl-letf (((symbol-function 'ebox-buffer-refresh-region-role-spans-for-region-ids) (lambda (&rest _args) (list :active-segments active-segments)))) (ebox--refresh-runtime-indexes-for-patch-results (current-buffer) nil 'span-patch nil (list :region-ids (list stale-id) :new-spans (list (cons (point-min) (point-max))) :old-spans nil :removed-region-ids nil))) (should-not (gethash stale-id ebox--box-extents))))) (ert-deftest ebox-scoped-role-span-refresh-rebuilds-on-translation-failure () "Ambiguous scoped refresh should rebuild a legacy integer role-span table." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id "root" :width (480) (box :id "target" :content "target" :width (220) :height 1 :border "#999999") (box :id "peer" :content "peer" :width (220) :height 1 :border "#999999"))))) (ebox-test--with-rendered-buffer layout (let* ((target-id (ebox-test--selector-region-id (current-buffer) "#target")) (peer-id (ebox-test--selector-region-id (current-buffer) "#peer")) (target-spans (gethash (ebox-buffer--region-role-key target-id 'content) (ebox-buffer--region-role-span-table))) (peer-key (ebox-buffer--region-role-key peer-id 'content)) (legacy-table (make-hash-table :test 'equal)) (rebuild-count 0) (original (symbol-function 'ebox-buffer--build-region-role-span-table))) (should target-spans) (should (gethash peer-key (ebox-buffer--region-role-span-table))) (maphash (lambda (key spans) (when (consp key) (puthash key (mapcar (lambda (span) (cons (marker-position (car span)) (marker-position (cdr span)))) spans) legacy-table))) (ebox-buffer--region-role-span-table)) (ebox--set-buffer-region-role-span-table (current-buffer) legacy-table) (cl-letf (((symbol-function 'ebox-buffer--build-region-role-span-table) (lambda (&rest args) (cl-incf rebuild-count) (apply original args)))) (ebox-buffer-refresh-region-role-spans-for-region-ids (current-buffer) (list target-id) target-spans (append target-spans target-spans))) (should (= rebuild-count 1)) (should (ebox-buffer--region-role-span-table)) (should (gethash peer-key (ebox-buffer--region-role-span-table))))))) (ert-deftest ebox-role-span-translation-keeps-left-adjacent-half-open-span () "A role span ending at replacement start stays on its untouched left side." (let ((replacements '((:old-start 20 :old-end 30 :new-start 20 :new-end 24 :delta -6)))) (should (equal (ebox-buffer--translate-role-span-through-replacements '(10 . 20) replacements) '(10 . 20))) (should-not (ebox-buffer--translate-role-span-through-replacements '(20 . 25) replacements)) (should-not (ebox-buffer--translate-role-span-through-replacements '(15 . 30) replacements)))) (ert-deftest ebox-role-span-translation-index-preserves-enclosing-deltas () "Indexed translation should preserve spans enclosing multiple replacements." (let ((replacements '((:old-start 20 :old-end 30 :new-start 20 :new-end 24 :delta -6) (:old-start 50 :old-end 55 :new-start 44 :new-end 57 :delta 8)))) (should (equal (ebox-buffer--translate-role-span-through-replacements '(10 . 70) replacements) '(10 . 72))) (should (equal (ebox-buffer--translate-role-span-through-replacements '(30 . 50) replacements) '(24 . 44))) (should-not (ebox-buffer--translate-role-span-through-replacements '(30 . 55) replacements)))) (ert-deftest ebox-marker-role-span-refresh-skips-table-translation () "Marker-backed role-span tables should not translate every stored span." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id "root" :width (480) (box :id "target" :content "target" :width (220) :height 1 :border "#999999") (box :id "peer" :content "peer" :width (220) :height 1 :border "#999999"))))) (ebox-test--with-rendered-buffer layout (let* ((target-id (ebox-test--selector-region-id (current-buffer) "#target")) (peer-id (ebox-test--selector-region-id (current-buffer) "#peer")) (target-spans (gethash (ebox-buffer--region-role-key target-id 'content) (ebox-buffer--region-role-span-table))) (peer-key (ebox-buffer--region-role-key peer-id 'content)) (translate-count 0) (rebuild-count 0)) (should target-spans) (should (markerp (caar target-spans))) (cl-letf (((symbol-function 'ebox-buffer--translate-region-role-span-table) (lambda (&rest _args) (cl-incf translate-count) (error "marker-backed table should not translate"))) ((symbol-function 'ebox-buffer--build-region-role-span-table) (lambda (&rest _args) (cl-incf rebuild-count) (error "marker-backed table should not rebuild")))) (ebox-buffer-refresh-region-role-spans-for-region-ids (current-buffer) (list target-id) target-spans (append target-spans target-spans))) (should (= translate-count 0)) (should (= rebuild-count 0)) (should (gethash peer-key (ebox-buffer--region-role-span-table))))))) (ert-deftest ebox-scoped-role-span-refresh-translates-enclosing-owner-spans () "Scoped role-span refresh should keep enclosing owner spans without rebuilding." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id "root" :width (480) (box :id "target" :content "target" :width (220) :height 1 :border "#999999"))))) (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")) (target-spans (gethash (ebox-buffer--region-role-key target-id 'content) (ebox-buffer--region-role-span-table))) (root-owner-key (ebox-buffer--region-role-key root-id 'content-owner)) (root-owner-spans (gethash root-owner-key (ebox-buffer--region-role-span-table))) (rebuild-count 0) (original (symbol-function 'ebox-buffer--build-region-role-span-table))) (should target-spans) (should root-owner-spans) (cl-letf (((symbol-function 'ebox-buffer--build-region-role-span-table) (lambda (&rest args) (cl-incf rebuild-count) (apply original args)))) (ebox-buffer-refresh-region-role-spans-for-region-ids (current-buffer) (list target-id) target-spans target-spans)) (should (= rebuild-count 0)) (should (equal root-owner-spans (gethash root-owner-key (ebox-buffer--region-role-span-table)))))))) (ert-deftest ebox-viewport-root-owner-spans-skip-region-scan () "Root owner spans should use full buffer lines, not region-property scans." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-row (cl-loop for section below 8 collect (apply #'ebox-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (cl-loop for index below 4 collect (ebox-create :content (format "S%d-%d" section index) :width 80 :height 1)))))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 360)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let* ((root (ebox--buffer-root-node buffer)) (dirty (ebox--viewport-dirty-set root)) (region-scan-count 0) (original (symbol-function 'ebox--region-set-line-spans))) (should dirty) (cl-letf (((symbol-function 'ebox--region-set-line-spans) (lambda (&rest args) (cl-incf region-scan-count) (apply original args)))) (ebox--patch-set-from-dirty-set dirty buffer)) (should (= region-scan-count 0)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-reuses-static-flex-item-renders () "Static flex item renders should be reused across viewport rerenders." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-flex :flex-wrap 'wrap (cl-loop for index below 12 collect (ebox-create :content (format "Item %02d" index) :width 80 :height 1)))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 420)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (ebox-rerender-buffer-with-context buffer 360) (let ((box-render-count 0) (original (symbol-function 'ebox--render-box))) (cl-letf (((symbol-function 'ebox--render-box) (lambda (&rest args) (cl-incf box-render-count) (apply original args)))) (ebox-rerender-buffer-with-context buffer 500)) (should (<= box-render-count 3)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-reuses-sized-flex-item-renders () "Flex item renders with stable computed sizes should be reused." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-flex :flex-wrap 'wrap :height 4 :align-items 'stretch (cl-loop for index below 10 collect (ebox-create :content (format "Stretch %02d" index) :width 80)))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 420)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (ebox-rerender-buffer-with-context buffer 360) (let ((box-render-count 0) (original (symbol-function 'ebox--render-box))) (cl-letf (((symbol-function 'ebox--render-box) (lambda (&rest args) (cl-incf box-render-count) (apply original args)))) (ebox-rerender-buffer-with-context buffer 500)) (should (<= box-render-count 6)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-reuses-flex-item-measurements () "Stable flex item intrinsic measurements should be reused across resizes." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-flex :flex-wrap 'wrap (cl-loop for index below 12 collect (ebox-create :content (format "Measured %02d" index) :width 80 :height 1)))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 420)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (ebox-rerender-buffer-with-context buffer 360) (let ((main-count 0) (min-count 0) (original-main (symbol-function 'ebox--flex-string-main)) (original-min (symbol-function 'ebox--content-min-pixel))) (cl-letf (((symbol-function 'ebox--flex-string-main) (lambda (&rest args) (cl-incf main-count) (apply original-main args))) ((symbol-function 'ebox--content-min-pixel) (lambda (&rest args) (cl-incf min-count) (apply original-min args)))) (ebox-rerender-buffer-with-context buffer 500)) (should (<= main-count 3)) (should (<= min-count 3)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-reuses-static-composite-subtrees () "Viewport-dependent flex sections should reuse static composite item sources." (ebox-test--reset-runtime-state) (let* ((inner-flexes (cl-loop for index below 6 collect (ebox-flex :width 180 :height 3 :flex-flow '(row wrap) :column-gap '(8) (ebox-create :content (format "A%d" index) :width 48 :height 1) (ebox-create :content (format "B%d" index) :width 48 :height 1)))) (layout (apply #'ebox-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (mapcar (lambda (inner) (ebox-column (ebox-create :content "Static card" :width 180 :height 1) inner)) inner-flexes))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 320)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (ebox-rerender-buffer-with-context buffer 360) (let ((static-flex-renders 0) (original (symbol-function 'ebox--render-flex))) (cl-letf (((symbol-function 'ebox--render-flex) (lambda (node) (when (memq node inner-flexes) (cl-incf static-flex-renders)) (funcall original node)))) (ebox-rerender-buffer-with-context buffer 420)) (should (= static-flex-renders 0)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-flex-window-reuses-cached-composite-measurements () "Flex prefix layout should reuse warm measurements for fixed composites." (ebox-test--reset-runtime-state) (let* ((layout (apply #'ebox-flex :flex-flow '(row wrap) :column-gap '(8) (cl-loop for index below 6 collect (ebox-column (ebox-create :content (format "Card %d" index) :width 96 :height 1) (ebox-create :content "Body" :width 96 :height 2))))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 320)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let* ((root (ebox--buffer-root-node buffer)) (child (car (plist-get root :children)))) (ebox--with-buffer-render-context buffer (should (ebox--flex-window-cheap-item child 'row 0))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-render-cacheability-uses-boolean-viewport-dependency-cache () "Render cacheability should not enumerate viewport-dependent node ids." (ebox-test--reset-runtime-state) (let* ((layout (ebox-column (ebox-create :content "Static" :width 120) (ebox-create :content "Also static" :width 120))) (calls 0) (original (symbol-function 'ebox--viewport-dependent-node-ids))) (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq))) (cl-letf (((symbol-function 'ebox--viewport-dependent-node-ids) (lambda (&rest args) (cl-incf calls) (apply original args)))) (should (ebox--render-cacheable-node-p layout)) (should (ebox--render-cacheable-node-p layout))) (should (= calls 0))))) (ert-deftest ebox-render-cacheability-rejects-viewport-dependent-wrapper-child () "Wrapper boxes with viewport-dependent child layout should not reuse static render cache." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column :padding 1 (box :content "Auto child follows viewport" :wrap-mode word))))) (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq))) (should-not (ebox--render-cacheable-node-p node))))) (ert-deftest ebox-render-cacheability-allows-definite-width-wrapper-child () "Definite-width boxes bound descendant viewport-dependent layout." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column :width (210) :padding 1 (box :content "Child wraps inside the card" :wrap-mode word))))) (let ((ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq))) (should (ebox--viewport-dependent-subtree-p node)) (should (ebox--render-cacheable-node-p node))))) (ert-deftest ebox-viewport-rerender-reuses-buffer-viewport-dependencies () "Viewport rerenders should reuse the buffer's viewport dependency list." (ebox-test--reset-runtime-state) (let* ((layout (ebox-flex :flex-wrap 'wrap (ebox-create :content "A" :width '(120)) (ebox-create :content "B" :width '(120)))) (calls 0) (axis-scans 0) (original (symbol-function 'ebox--viewport-dependent-node-ids)) (original-axes (symbol-function 'ebox--viewport-dependent-node-id-axes)) buffer) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (cl-letf (((symbol-function 'ebox--viewport-dependent-node-id-axes) (lambda (&rest args) (cl-incf axis-scans) (apply original-axes args)))) (ebox-rerender-buffer-with-context buffer 220)) (should (= axis-scans 0)) (cl-letf (((symbol-function 'ebox--viewport-dependent-node-ids) (lambda (&rest args) (cl-incf calls) (apply original args)))) (ebox-rerender-buffer-with-context buffer 200)) (should (= calls 0))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-region-width-update-invalidates-viewport-dependencies () "Width updates can change which boxes participate in viewport resize." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Resizable" :width 80)) (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-rerender-buffer-with-context (current-buffer) 260) (ebox-region-update box-id :width '(viewport)) (ebox-rerender-buffer-with-context (current-buffer) 200) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'viewport)) (should (= (plist-get report :dirty-count) 1)))))) (ert-deftest ebox-viewport-dependencies-include-wrapper-child-layout () "Viewport dependency discovery should see lazy child layout under wrapper boxes." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column :padding 1 (box :content "Auto child follows the preview width" :wrap-mode word)))) (child (car (ebox--node-children node))) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) (should ids) (should (member (plist-get node :node-id) ids)) (should (member (plist-get child :node-id) ids)))) (ert-deftest ebox-viewport-dependencies-stop-at-definite-width-wrapper () "Definite wrappers should contain descendant viewport dependencies." (ebox-test--reset-runtime-state) (let* ((node (ebox-build '(column :width (210) :padding 1 (box :content "Auto child wraps inside the fixed card" :wrap-mode word)))) (child (car (ebox--node-children node))) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) (should-not (member (plist-get node :node-id) ids)) (should-not (member (plist-get child :node-id) ids)))) (ert-deftest ebox-viewport-height-dependency-crosses-definite-width-wrapper () "Definite width should not contain a descendant viewport-height dependency." (ebox-test--reset-runtime-state) (let* ((viewport-width 120) (node (let ((ebox-viewport-width viewport-width) (ebox-viewport-height 2)) (ebox-build '(column :width (120) (box :id child :content "One\nTwo\nThree\nFour" :height (viewport-height) :overflow scroll))))) buffer) (unwind-protect (progn (let ((ebox-viewport-width viewport-width) (ebox-viewport-height 2)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node))) (with-current-buffer buffer (should (= (length (ebox-string-lines (buffer-substring-no-properties (point-min) (point-max)))) 2))) (let ((report (ebox-rerender-buffer-with-context buffer viewport-width 4))) (should-not (eq (plist-get report :strategy) 'no-op))) (with-current-buffer buffer (should (= (length (ebox-string-lines (buffer-substring-no-properties (point-min) (point-max)))) 4)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-dependency-axes-classify-both-dimensions () "A node may depend on width, height, or both viewport axes." (ebox-test--reset-runtime-state) (let* ((width-only (ebox-create :content "Width" :width '(viewport))) (height-only (ebox-create :content "Height" :width '(120) :height '(viewport-height))) (both (ebox-create :content "Both" :width '(viewport) :height '(viewport-height))) (layout (ebox-column width-only height-only both))) (let ((ebox-viewport-width 360) (ebox-viewport-height 12) (ebox--viewport-dependent-node-ids-cache (make-hash-table :test 'eq)) (ebox--viewport-height-dependent-subtree-cache (make-hash-table :test 'eq))) (ebox--runtime-node-ids layout) (pcase-let ((`(,width-ids . ,height-ids) (ebox--viewport-dependent-node-id-axes layout))) (should (member (plist-get width-only :node-id) width-ids)) (should-not (member (plist-get width-only :node-id) height-ids)) (should-not (member (plist-get height-only :node-id) width-ids)) (should (member (plist-get height-only :node-id) height-ids)) (should (member (plist-get both :node-id) width-ids)) (should (member (plist-get both :node-id) height-ids)))))) (ert-deftest ebox-viewport-width-rerender-skips-height-only-root () "Width-only viewport rerender should not dirty height-only roots." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-height 4)) (ebox-build '(column :width (120) :height (viewport-height) (box :content "Static" :width (120)))))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 240) (ebox-viewport-height 4)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node))) (let ((report (ebox-rerender-buffer-with-context buffer 300))) (should (eq (plist-get report :strategy) 'no-op)) (should (= (plist-get report :dirty-count) 0)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-dependencies-keep-only-vertical-axis-under-fixed-width () "Fixed width should retain vertical dependencies without exposing width-only ones." (ebox-test--reset-runtime-state) (let* ((width-only (ebox-create :content "Width" :width '(viewport))) (ordinary-height-nodes (cl-loop for (property value) in '((:height (viewport-height)) (:min-height (- (viewport-height) 1)) (:max-height (+ (viewport-height) 1))) collect (apply #'ebox-create :content "Ordinary" (list property value)))) (flex-height-nodes (cl-loop for (property value) in '((:height (viewport-height)) (:min-height (- (viewport-height) 1)) (:max-height (+ (viewport-height) 1))) collect (apply #'ebox-flex (append (list property value) (list (ebox-create :content "Flex")))))) (layout (apply #'ebox-column (append (list width-only) ordinary-height-nodes flex-height-nodes))) (node (ebox-create :width '(210) :wrap-mode nil :ebox-content-node layout)) (ids (let ((ebox-viewport-width 360) (ebox-viewport-height 12)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) (should-not (member (plist-get width-only :node-id) ids)) (dolist (vertical-node (append ordinary-height-nodes flex-height-nodes)) (should (member (plist-get vertical-node :node-id) ids))))) (ert-deftest ebox-viewport-dependencies-include-direct-flex-basis () "Viewport dependency discovery should include direct child flex-basis props." (ebox-test--reset-runtime-state) (let* ((child (ebox-create :content "A" :width 80 :flex-basis '(viewport))) (node (ebox-flex :width 300 child (ebox-create :content "B" :width 80))) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) (should (member (plist-get child :node-id) ids)))) (ert-deftest ebox-viewport-dependencies-ignore-flex-item-wrapper-auto-width () "Flex item wrappers should not be dirty only because their own width is auto." (ebox-test--reset-runtime-state) (let* ((child (ebox-create :content "A" :width 80)) (item (ebox-flex-item child)) (node (ebox-flex :width 240 item)) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) (should-not (member (plist-get item :node-id) ids)) (should-not (member (plist-get child :node-id) ids)))) (ert-deftest ebox-viewport-dependencies-include-flex-item-viewport-basis () "Flex item wrappers should stay dirty when their flex-basis uses viewport." (ebox-test--reset-runtime-state) (let* ((child (ebox-create :content "A" :width 80)) (item (ebox-flex-item child :flex-basis '(viewport))) (node (ebox-flex :width 240 item)) (ids (let ((ebox-viewport-width 360)) (ebox--runtime-node-ids node) (ebox--viewport-dependent-node-ids node)))) (should (member (plist-get item :node-id) ids)))) (ert-deftest ebox-buffer-viewport-dependency-cache-invalidates-after-display-style-change () "Changing width/display-sensitive style should invalidate viewport dependency cache." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Resizable" :width 80)) (box-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-rerender-buffer-with-context (current-buffer) 320) (ebox-region-update box-id :width '(viewport)) (let ((state (ebox--buffer-render-state (current-buffer)))) (should-not (plist-get state :viewport-dependent-node-ids-ready))) (ebox-rerender-buffer-with-context (current-buffer) 240) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :constraint-source) 'viewport)) (should (= (plist-get report :dirty-count) 1)))))) (ert-deftest ebox-initial-render-primes-static-composite-subtree-cache () "The first viewport resize should reuse cache populated by initial render." (ebox-test--reset-runtime-state) (let* ((inner-flexes (cl-loop for index below 6 collect (ebox-flex :width 180 :height 3 :flex-flow '(row wrap) :column-gap '(8) (ebox-create :content (format "A%d" index) :width 48 :height 1) (ebox-create :content (format "B%d" index) :width 48 :height 1)))) (layout (apply #'ebox-flex :flex-flow '(row wrap) :row-gap 1 :column-gap '(8) (mapcar (lambda (inner) (ebox-column (ebox-create :content "Static card" :width 180 :height 1) inner)) inner-flexes))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 320)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (let ((static-flex-renders 0) (original (symbol-function 'ebox--render-flex))) (cl-letf (((symbol-function 'ebox--render-flex) (lambda (node) (when (memq node inner-flexes) (cl-incf static-flex-renders)) (funcall original node)))) (ebox-rerender-buffer-with-context buffer 420)) (should (= static-flex-renders 0)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-resize-invalidates-snapshot-details-lazily () "Viewport resize should not recapture every lightweight snapshot eagerly." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(column :width (viewport) ,@(cl-loop for idx below 24 collect `(box :id ,(format "resize-snapshot-%02d" idx) :content ,(format "row %02d" idx) :width (viewport) :height 1))))) (node-count (length (ebox--runtime-node-ids layout))) buffer (snapshot-count 0) (original (symbol-function 'ebox--node-layout-snapshot))) (unwind-protect (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (cl-letf (((symbol-function 'ebox--node-layout-snapshot) (lambda (&rest args) (cl-incf snapshot-count) (apply original args)))) (ebox-rerender-buffer-with-context buffer 320)) (should (< snapshot-count node-count))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-resize-invalidates-extents-lazily () "Viewport resize should not rescan extents before a later update needs them." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(column :width (viewport) ,@(cl-loop for idx below 24 collect `(box :id ,(format "resize-extents-%02d" idx) :content ,(format "row %02d" idx) :width (viewport) :height 1))))) buffer target-id (register-count 0) (original-register (symbol-function 'ebox--register-box-extents-in-range))) (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-extents-12")) (cl-letf (((symbol-function 'ebox--register-box-extents-in-range) (lambda (&rest args) (cl-incf register-count) (apply original-register args)))) (ebox-rerender-buffer-with-context buffer 320)) (should (= register-count 0)) (with-current-buffer buffer (ebox-region-update target-id :content "updated after resize") (should (string-match-p "updated after resize" (buffer-substring-no-properties (point-min) (point-max)))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-dependent-node-ids-are-render-pass-memoized () "Viewport dependency discovery should not recurse twice for the same node." (ebox-test--reset-runtime-state) (let* ((layout (ebox-flex :width 240 (ebox-column (ebox-create :content "A" :width 80) (ebox-create :content "B" :width 80)))) (node-count (length (ebox--runtime-node-ids layout))) (width-check-count 0) (height-check-count 0) (original-width (symbol-function 'ebox--node-direct-viewport-width-dependent-p)) (original-height (symbol-function 'ebox--node-direct-viewport-height-dependent-p)) (ebox--viewport-dependent-node-ids-cache (make-hash-table :test 'eq))) (cl-letf (((symbol-function 'ebox--node-direct-viewport-width-dependent-p) (lambda (&rest args) (cl-incf width-check-count) (apply original-width args))) ((symbol-function 'ebox--node-direct-viewport-height-dependent-p) (lambda (&rest args) (cl-incf height-check-count) (apply original-height args)))) (ebox--viewport-dependent-node-ids layout) (ebox--viewport-dependent-node-ids layout)) (should (= width-check-count node-count)) (should (= height-check-count node-count)))) (ert-deftest ebox-render-cache-node-signature-is-render-pass-memoized () "Render cache signatures should not recurse twice for the same node." (ebox-test--reset-runtime-state) (let* ((layout (ebox-flex :width 240 (ebox-column (ebox-create :content "A" :width 80) (ebox-create :content "B" :width 80)))) (node-count (length (ebox--runtime-node-ids layout))) (signature-count 0) (original (symbol-function 'ebox--render-cache-plist-signature)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq))) (cl-letf (((symbol-function 'ebox--render-cache-plist-signature) (lambda (&rest args) (cl-incf signature-count) (apply original args)))) (ebox--render-cache-node-signature layout) (ebox--render-cache-node-signature layout)) (should (= signature-count node-count)))) (ert-deftest ebox-render-cache-value-signature-preserves-dotted-pairs () "Native values such as sparse keymaps may contain dotted pairs." (let ((value '(keymap (mouse-1 . ebox-test-activate) (13 . ebox-test-activate)))) (should (equal (ebox--render-cache-value-signature value) value)))) (ert-deftest ebox-render-cache-signature-includes-rendered-region-identity () "Cached render strings should never cross distinct region identities." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "Stable" :width 80)) (right (copy-tree left))) (plist-put left :node-id 1) (plist-put right :node-id 1) (plist-put left :region-id 11) (plist-put right :region-id 12) (should-not (equal (ebox--render-cache-node-signature left) (ebox--render-cache-node-signature right))))) (ert-deftest ebox-render-transaction-snapshots-display-signature () "One render transaction should sample its display signature once." (ebox-test--reset-runtime-state) (let ((node (ebox-create :content "A" :width 80)) (calls 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () (list 'display (cl-incf calls))))) (ebox--with-validated-display-cache (ebox--render-cache-node-signature node) (ebox--render-cache-node-signature node))) (should (= calls 1)))) (ert-deftest ebox-render-transaction-nesting-reuses-display-signature () "A nested render transaction should reuse the outer display snapshot." (ebox-test--reset-runtime-state) (let ((node (ebox-create :content "A" :width 80)) (calls 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () (list 'display (cl-incf calls))))) (ebox--with-validated-display-cache (ebox--with-validated-display-cache (ebox--render-cache-node-signature node)))) (should (= calls 1)))) (ert-deftest ebox-render-transactions-refresh-display-signature () "Separate render transactions should sample display state independently." (ebox-test--reset-runtime-state) (let ((node (ebox-create :content "A" :width 80)) (calls 0) signatures) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () (list 'display (cl-incf calls))))) (dotimes (_ 2) (push (ebox--with-validated-display-cache (car (ebox--render-cache-node-signature node))) signatures))) (should (= calls 2)) (should-not (equal (car signatures) (cadr signatures))))) (ert-deftest ebox-current-display-signature-is-live-outside-transaction () "The internal accessor should sample live state outside a transaction." (ebox-test--reset-runtime-state) (let ((calls 0) signatures) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () (list 'display (cl-incf calls))))) (setq signatures (list (ebox--current-display-signature) (ebox--current-display-signature)))) (should (= calls 2)) (should-not (equal (car signatures) (cadr signatures))))) (ert-deftest ebox-public-display-signature-stays-live-in-render-transaction () "The public display signature should bypass a render transaction snapshot." (ebox-test--reset-runtime-state) (let ((calls 0) signatures) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () (list 'display (cl-incf calls))))) (setq signatures (ebox--with-validated-display-cache (list (ebox-display-signature) (ebox-display-signature))))) (should (= calls 3)) (should-not (equal (car signatures) (cadr signatures))))) (ert-deftest ebox-clear-cache-samples-live-display-in-render-transaction () "Clearing caches should store live display state inside a transaction." (ebox-test--reset-runtime-state) (let ((calls 0)) (puthash '(stale) 1 ebox--string-pixel-width-cache) (aset ebox--string-pixel-width-cache-ring 0 '(stale)) (setq ebox--string-pixel-width-cache-ring-index 1) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () (list 'display (cl-incf calls))))) (ebox--with-validated-display-cache (ebox-clear-cache))) (should (= calls 2)) (should (= (hash-table-count ebox--string-pixel-width-cache) 0)) (should (= ebox--string-pixel-width-cache-ring-index 0)) (should (equal ebox--display-signature-cache '(display 2))))) (ert-deftest ebox-render-cache-signature-keeps-viewport-axes-with-snapshot () "Display snapshots should not collapse width or height cache dimensions." (ebox-test--reset-runtime-state) (let ((node (ebox-create :content "A" :width '(viewport) :height '(viewport-height))) signatures) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () '(display stable)))) (ebox--with-validated-display-cache (dolist (viewport '((80 10) (90 10) (90 11))) (let ((ebox-viewport-width (car viewport)) (ebox-viewport-height (cadr viewport))) (push (ebox--render-cache-node-signature node) signatures))))) (setq signatures (nreverse signatures)) (should (equal (mapcar #'car signatures) '((display stable) (display stable) (display stable)))) (should-not (equal (nth 0 signatures) (nth 1 signatures))) (should-not (equal (nth 1 signatures) (nth 2 signatures))))) (ert-deftest ebox-render-cache-signature-ignores-unrelated-viewport-height () "Width-only subtree signatures should survive unrelated height changes." (ebox-test--reset-runtime-state) (let* ((node (ebox-column (ebox-create :content "A" :width '(viewport)) (ebox-create :content "B" :width 80))) (left (let ((ebox-viewport-width 320) (ebox-viewport-height 10)) (ebox--render-cache-node-signature node))) (right (let ((ebox-viewport-width 320) (ebox-viewport-height 11)) (ebox--render-cache-node-signature node)))) (should (equal left right)))) (ert-deftest ebox-buffer-height-dependency-cache-invalidates-mutated-path () "Persistent height dependencies should survive siblings and track mutations." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) (box :id "child" :content "child" :width (80))))) 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)) (let* ((state (ebox--buffer-render-state buffer)) (cache (plist-get state :viewport-height-dependent-subtree-cache)) (child-id (plist-get (car (ebox-selector-query-buffer buffer "#child")) :node-id)) (child (ebox--buffer-runtime-node buffer child-id)) (missing (make-symbol "missing"))) (ebox--with-buffer-render-context buffer (should-not (ebox--viewport-height-dependent-subtree-p child))) (should-not (eq (gethash child cache missing) missing)) (ebox-selector-update-buffer buffer "#root" :width '(260)) (should-not (eq (gethash child cache missing) missing)) (ebox-selector-update-buffer buffer "#child" :height '(viewport-height)) (should (eq (gethash child cache missing) missing)) (let ((low (let ((ebox-viewport-height 4)) (ebox--render-cache-node-signature child))) (high (let ((ebox-viewport-height 5)) (ebox--render-cache-node-signature child)))) (should-not (equal low high))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-buffer-render-signatures-persist-across-transactions () "Unchanged runtime body signatures should survive buffer render transactions." (ebox-test--reset-runtime-state) (let* ((layout (ebox-flex :width 240 (ebox-column (ebox-create :content "A" :width 80) (ebox-create :content "B" :width 80)))) (node-count (length (ebox--runtime-node-ids layout))) (signature-count 0) (original (symbol-function 'ebox--render-cache-plist-signature)) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (when-let ((cache (plist-get (ebox--buffer-render-state buffer) :render-signature-cache))) (clrhash cache)) (cl-letf (((symbol-function 'ebox--render-cache-plist-signature) (lambda (&rest args) (cl-incf signature-count) (apply original args)))) (ebox--with-buffer-render-context buffer (ebox--render-cache-node-body-signature layout)) (ebox--with-buffer-render-context buffer (ebox--render-cache-node-body-signature layout))) (should (= signature-count node-count))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-buffer-render-signature-invalidates-mutated-path () "Runtime mutation should not reuse a stale ancestor body signature." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "Before" :width 80)) (right (ebox-create :content "Stable" :width 80)) (layout (ebox-column left right)) (left-region-id (car (ebox-region-ids left))) old-root-signature new-root-signature buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (let ((runtime-root (ebox--buffer-root-node buffer))) (setq old-root-signature (ebox--with-buffer-render-context buffer (ebox--render-cache-node-body-signature runtime-root)))) (with-current-buffer buffer (ebox-region-update left-region-id :content "After") (should (equal (ebox-get left :content) "Before")) (setq new-root-signature (ebox--with-buffer-render-context buffer (ebox--render-cache-node-body-signature (ebox--buffer-root-node buffer)))) (should-not (equal old-root-signature new-root-signature)) (should (string-match-p "After" (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-buffer-render-signature-invalidation-retains-sibling () "Invalidating one runtime path should retain unaffected sibling signatures." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "Left" :width 80)) (right (ebox-create :content "Right" :width 80)) (layout (ebox-column left right)) (left-region-id (car (ebox-region-ids left))) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (let* ((root-id (ebox--ensure-node-id layout)) (left-node-id (ebox--ensure-node-id left)) (right-node-id (ebox--ensure-node-id right)) (runtime-root (ebox--buffer-runtime-node buffer root-id)) (runtime-left (ebox--buffer-runtime-node buffer left-node-id)) (runtime-right (ebox--buffer-runtime-node buffer right-node-id))) (ebox--with-buffer-render-context buffer (ebox--render-cache-node-body-signature runtime-root)) (let* ((cache (ebox--buffer-render-signature-cache buffer)) (root-signature (gethash runtime-root cache)) (left-signature (gethash runtime-left cache)) (right-signature (gethash runtime-right cache))) (with-current-buffer buffer (ebox-region-update left-region-id :content "Changed")) (should (equal (ebox-get left :content) "Left")) (should-not (eq root-signature (gethash runtime-root cache))) (should-not (eq left-signature (gethash runtime-left cache))) (should (eq right-signature (gethash runtime-right cache))) (should root-signature) (should left-signature) (should right-signature) (with-current-buffer buffer (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-buffer-render-signature-invalidates-direct-scroll-path () "Direct runtime scrolling should invalidate the changed node-to-root path." (ebox-test--reset-runtime-state) (let* ((scroll-box (ebox-build '(box :id "scroll" :height 2 :overflow scroll (column (box :content "row-1" :height 1) (box :content "row-2" :height 1) (box :content "row-3" :height 1))))) (stable (ebox-create :content "Stable" :height 1)) (layout (ebox-column scroll-box stable)) (scroll-region-id (car (ebox-region-ids scroll-box))) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (let* ((root-id (ebox--ensure-node-id layout)) (scroll-node-id (ebox--ensure-node-id scroll-box)) (stable-node-id (ebox--ensure-node-id stable)) (runtime-root (ebox--buffer-runtime-node buffer root-id)) (runtime-scroll (ebox--buffer-runtime-node buffer scroll-node-id)) (runtime-stable (ebox--buffer-runtime-node buffer stable-node-id))) (ebox--with-buffer-render-context buffer (ebox--render-cache-node-body-signature runtime-root)) (let* ((cache (ebox--buffer-render-signature-cache buffer)) (root-signature (gethash runtime-root cache)) (scroll-signature (gethash runtime-scroll cache)) (stable-signature (gethash runtime-stable cache))) (with-current-buffer buffer (should (ebox--scroll-region-by scroll-region-id 1))) (should (equal (ebox-get scroll-box :scroll-offset) 0)) (should-not (gethash runtime-root cache)) (should-not (gethash runtime-scroll cache)) (should (eq stable-signature (gethash runtime-stable cache))) (should root-signature) (should scroll-signature) (should stable-signature) (should (= (plist-get (ebox--scroll-get-state scroll-region-id) :scroll-offset) 1))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-render-cache-signature-ignores-viewport-for-static-column () "Static column signatures should not churn only because outer viewport changed." (ebox-test--reset-runtime-state) (let ((node (ebox-column (ebox-create :content "A" :width 80) (ebox-create :content "B" :width 80)))) (ebox--runtime-node-ids node) (let ((left (let ((ebox-viewport-width 320)) (ebox--render-cache-node-signature node))) (right (let ((ebox-viewport-width 640)) (ebox--render-cache-node-signature node)))) (should (equal left right))))) (ert-deftest ebox-render-cache-plist-signature-avoids-append-churn () "Render cache plist signatures should be built linearly." (ebox-test--reset-runtime-state) (let ((node (ebox-create :content "Signature" :width 80 :border t)) (original-append (symbol-function 'append)) (append-calls 0)) (cl-letf (((symbol-function 'append) (lambda (&rest args) (cl-incf append-calls) (apply original-append args)))) (ebox--render-cache-plist-signature node)) (should (= append-calls 0)))) (ert-deftest ebox-preformatted-exact-content-skips-justify-measurement () "Exact-width preformatted content should not be remeasured for left justify." (ebox-test--reset-runtime-state) (let* ((content (ebox-lines-join (list (ebox-pixel-space 80) (ebox-pixel-space 80)))) (box (ebox-create :content content :width 80 :wrap-mode nil)) (measure-count 0) (original (symbol-function 'ebox--string-pixel-width))) (plist-put box :ebox-content-width-exact-p t) (cl-letf (((symbol-function 'ebox--string-pixel-width) (lambda (&rest args) (cl-incf measure-count) (apply original args)))) (ebox--format-content box)) (should (= measure-count 0)))) (ert-deftest ebox-uniform-stack-content-skips-parent-justify-measurement () "A target-width stack should not be measured again by its parent box." (ebox-test--reset-runtime-state) (let* ((child (ebox-column (ebox-create :content "A" :width '(viewport)) (ebox-create :content "B" :width '(viewport)))) (parent (ebox-create :width '(viewport) :wrap-mode nil :ebox-content-node child)) (justify-count 0) (original (symbol-function 'ebox--lines-justify))) (let ((ebox-viewport-width 120)) (cl-letf (((symbol-function 'ebox--lines-justify) (lambda (&rest args) (cl-incf justify-count) (apply original args)))) (ebox-render parent))) ;; The two leaf boxes still justify their own text. The parent does not ;; add a third full-stack pass. (should (= justify-count 2)))) (ert-deftest ebox-uniform-content-skips-noop-word-and-char-formatting () "Exact uniform lines should bypass proven no-op word/char formatting." (ebox-test--reset-runtime-state) (dolist (wrap-mode '(word char)) (let* ((box (ebox-create :content "" :width '(120) :wrap-mode wrap-mode :text-align 'left)) (pixel-width (ebox--content-pixel box)) (line (propertize (ebox-pixel-space pixel-width) 'face '(:foreground "#123456"))) (content (concat line "\n" line)) (wrap-count 0) (justify-count 0) (original-wrap (symbol-function 'ebox--wrap-text)) (original-justify (symbol-function 'ebox--lines-justify))) (ebox--record-rendered-uniform-width content pixel-width) (cl-letf (((symbol-function 'ebox--wrap-text) (lambda (&rest arguments) (cl-incf wrap-count) (apply original-wrap arguments))) ((symbol-function 'ebox--lines-justify) (lambda (&rest arguments) (cl-incf justify-count) (apply original-justify arguments)))) (should (eq (ebox--format-content-string box content) content))) (should (= wrap-count 0)) (should (= justify-count 0))))) (ert-deftest ebox-uniform-content-keeps-semantic-formatting-paths () "KP, non-left alignment and width mismatch must retain formatting." (ebox-test--reset-runtime-state) (dolist (case '((kp left 120) (word center 120) (char left 119))) (let* ((box (ebox-create :content "" :width '(120) :wrap-mode (nth 0 case) :text-align (nth 1 case))) (content (ebox-pixel-space 120)) (wrap-count 0) (original-wrap (symbol-function 'ebox--wrap-text))) (ebox--record-rendered-uniform-width content (nth 2 case)) (cl-letf (((symbol-function 'ebox--wrap-text) (lambda (&rest arguments) (cl-incf wrap-count) (apply original-wrap arguments)))) (ebox--format-content-string box content)) (should (> wrap-count 0))))) (ert-deftest ebox-transparent-preformatted-box-fast-path-is-exact () "Transparent exact-width wrappers should match the generic box pipeline." (ebox-test--reset-runtime-state) (let* ((child-a (ebox-create :content "A" :width '(viewport) :border t :region-id 'child-a)) (child-b (ebox-create :content "B" :width '(viewport) :border t :region-id 'child-b)) (parent (ebox-create :width '(viewport) :wrap-mode nil :region-id 'parent :ebox-content-node (ebox-column child-a child-b))) generic fast) (let ((ebox-viewport-width 120) (ebox--transparent-preformatted-box-fast-path-disabled t)) (setq generic (ebox-render parent))) (clrhash ebox--rendered-uniform-width-table) (let ((ebox-viewport-width 120)) (setq fast (ebox-render parent))) (should (equal-including-properties fast generic)))) (ert-deftest ebox-flat-preformatted-box-fast-path-is-exact () "One-pass exact wrappers should preserve generic chrome and ownership." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "" :width '(140) :wrap-mode nil :region-id 'wrapper :padding-left-pixel 7 :padding-right-pixel 5 :padding-top-height 1 :padding-bottom-height 2 :border-left-pixel 2 :border-left-color "#AA0000" :border-right-pixel 3 :border-right-color "#00AA00" :border-top-p t :border-top-color "#0000AA" :border-bottom-p t :border-bottom-color "#AA00AA" :color "#111111" :bgcolor "#EEEEEE")) (content-width (ebox--content-pixel box)) (full-line (ebox-pixel-space content-width)) (content (concat full-line "\n\n" full-line)) (fast-path-calls 0) (original (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)) generic fast) (plist-put box :content content) (plist-put box :ebox-content-width-exact-p t) (let ((ebox--flat-preformatted-box-fast-path-disabled t)) (setq generic (ebox-render box))) (clrhash ebox--region-box-table) (clrhash ebox--scroll-global-state) (cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines) (lambda (&rest args) (cl-incf fast-path-calls) (apply original args)))) (setq fast (ebox-render box))) (should (= fast-path-calls 1)) (should (equal-including-properties fast generic)))) (ert-deftest ebox-flat-preformatted-fixed-hidden-box-fast-path-is-exact () "Exact fixed-height hidden wrappers should preserve generic output." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "" :width '(140) :height 6 :box-sizing 'border-box :overflow 'hidden :wrap-mode nil :region-id 'fixed-wrapper :padding-left-pixel 7 :padding-right-pixel 5 :padding-top-height 1 :padding-bottom-height 2 :border-left-pixel 2 :border-left-color "#AA0000" :border-right-pixel 3 :border-right-color "#00AA00" :border-top-p t :border-top-color "#0000AA" :border-bottom-p t :border-bottom-color "#AA00AA" :color "#111111" :bgcolor "#EEEEEE")) (content-width (ebox--content-pixel box)) (full-line (ebox-pixel-space content-width)) (content (concat full-line "\n\n" full-line)) (fast-path-calls 0) (original (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines)) generic fast) (plist-put box :content content) (plist-put box :ebox-content-width-exact-p t) (let ((ebox--flat-preformatted-box-fast-path-disabled t)) (setq generic (ebox-render box))) (clrhash ebox--region-box-table) (clrhash ebox--scroll-global-state) (cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines) (lambda (&rest arguments) (cl-incf fast-path-calls) (apply original arguments)))) (setq fast (ebox-render box))) (should (= fast-path-calls 1)) (should (eq (gethash 'fixed-wrapper ebox--region-box-table) box)) (should-not (gethash 'fixed-wrapper ebox--scroll-global-state)) (should (equal-including-properties fast generic)))) (ert-deftest ebox-flat-preformatted-fixed-box-rejects-layout-work () "Fixed wrappers should stay generic when clipping or alignment is required." (ebox-test--reset-runtime-state) (dolist (case '(("two\nlines" hidden top) ("one\ntwo\nthree\nfour" hidden top) ("one\ntwo\nthree" scroll top) ("one\ntwo\nthree" hidden center))) (let* ((box (ebox-create :content (nth 0 case) :width '(120) :height 5 :box-sizing 'border-box :overflow (nth 1 case) :vertical-align (nth 2 case) :wrap-mode nil)) (calls 0) (original (symbol-function 'ebox--window-render-flat-wrapper-chunk-lines))) (plist-put box :ebox-content-width-exact-p t) (cl-letf (((symbol-function 'ebox--window-render-flat-wrapper-chunk-lines) (lambda (&rest arguments) (cl-incf calls) (apply original arguments)))) (ebox-render box)) (should (= calls 0))))) (ert-deftest ebox-scroll-span-template-indexes-only-live-scroll-owners () "Prepared root metadata should not scan or retain non-scroll owners." (ebox-test--reset-runtime-state) (let* ((direct (propertize "AA" 'ebox-content 'direct-scroll 'ebox-content-idx 3)) (ordinary (propertize "BB" 'ebox-content 'ordinary 'ebox-content-idx 0)) (owned (concat (propertize "C" 'ebox-content 'child 'ebox-content-idx 0 'ebox-content-owner 'owner-scroll 'face '(:foreground "red")) (propertize "C" 'ebox-content 'child 'ebox-content-idx 0 'ebox-content-owner 'owner-scroll 'face '(:foreground "blue")))) (rendered (concat direct ordinary "\n" owned)) template) (puthash 'direct-scroll (list :scroll-offset 0) ebox--scroll-global-state) (puthash 'owner-scroll (list :scroll-offset 0) ebox--scroll-global-state) (setq template (ebox--build-scroll-content-span-template rendered)) (should (equal (gethash 'direct-scroll template) '((3 0 . 2)))) (should (equal (gethash 'owner-scroll template) '((0 5 . 7)))) (should-not (gethash 'ordinary template)) (should-not (gethash 'child template)))) (ert-deftest ebox-overflowing-stack-content-still-uses-parent-formatting () "An overflowing child stack must not publish uniform parent-width metadata." (ebox-test--reset-runtime-state) (let* ((child (ebox-column (ebox-create :content "A" :width 140) (ebox-create :content "B" :width 140))) (parent (ebox-create :width 120 :wrap-mode nil :ebox-content-node child)) (justify-count 0) (original (symbol-function 'ebox--lines-justify)) rendered) (let ((ebox-viewport-width 120)) (cl-letf (((symbol-function 'ebox--lines-justify) (lambda (&rest args) (cl-incf justify-count) (apply original args)))) (setq rendered (ebox-render parent)))) (should (= justify-count 3)) (should (= (ebox--string-max-pixel-width rendered) 140)))) (ert-deftest ebox-render-caches-lazy-box-content-within-render-pass () "A lazy child layout should be rendered once per box/context in one pass." (ebox-test--reset-runtime-state) (let* ((child (ebox-column (ebox-create :content "A" :width 80) (ebox-create :content "B" :width 80))) (parent (ebox-create :width 'max-content :padding 2)) (render-count 0) (original (symbol-function 'ebox--render-node-as-box-content))) (plist-put parent :ebox-content-node child) (cl-letf (((symbol-function 'ebox--render-node-as-box-content) (lambda (&rest args) (cl-incf render-count) (apply original args)))) (ebox-render parent)) (should (= render-count 1)))) (ert-deftest ebox-buffer-update-report-includes-dirty-and-patch-summary () "Update reports should show dirty and patch-set summaries." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Report" :width 80 :border t :border-color "red")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :border-color "blue") (let ((report (ebox-test--buffer-update-report))) (should (plist-member report :dirty-count)) (should (plist-member report :patch-count)) (should (plist-member report :patch-ops)) (should (equal (plist-get report :dirty-keys) '(:border-top-color :border-right-color :border-bottom-color :border-left-color))))))) (ert-deftest ebox-buffer-update-report-is-buffer-local () "Updating one rendered buffer should not replace another buffer's report." (ebox-test--reset-runtime-state) (let* ((first-box (ebox-create :content "First" :width '(80))) (second-box (ebox-create :content "Second" :width '(80))) (first-id (car (ebox-region-ids first-box))) first-buffer second-buffer) (unwind-protect (progn (setq first-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-report-first*") first-box)) (setq second-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-report-second*") second-box)) (ebox-test--set-buffer-update-report second-buffer '(:strategy second-prior :owner-ids (second))) (with-current-buffer first-buffer (let ((report (ebox-region-update first-id :content "First!"))) (should (equal report (ebox-buffer-update-report first-buffer))) (should-not (equal report (ebox-buffer-update-report second-buffer))) (should (equal (ebox-buffer-update-report second-buffer) '(:strategy second-prior :owner-ids (second))))))) (dolist (buffer (list first-buffer second-buffer)) (when (buffer-live-p buffer) (kill-buffer buffer)))))) (ert-deftest ebox-buffer-update-report-returns-defensive-copy () "The public accessor should not return mutable render-state storage." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Copy" :width '(80))) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-report-copy*") box)) (ebox-test--set-buffer-update-report buffer '(:strategy paint-patch :owner-ids (1 2))) (let ((report (ebox-buffer-update-report buffer))) (should (equal report '(:strategy paint-patch :owner-ids (1 2)))) (plist-put report :strategy 'mutated) (setcar (plist-get report :owner-ids) 99)) (should (equal (ebox-buffer-update-report buffer) '(:strategy paint-patch :owner-ids (1 2))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-buffer-update-report-user-errors-without-rendered-buffer () "The public accessor should reject missing, dead, and unrendered buffers." (ebox-test--reset-runtime-state) (let ((dead (generate-new-buffer " *ebox-report-dead*")) (unrendered (generate-new-buffer " *ebox-report-unrendered*"))) (unwind-protect (progn (kill-buffer dead) (should-error (ebox-buffer-update-report nil) :type 'user-error) (should-error (ebox-buffer-update-report dead) :type 'user-error) (should-error (ebox-buffer-update-report unrendered) :type 'user-error)) (when (buffer-live-p unrendered) (kill-buffer unrendered))))) (ert-deftest ebox-region-update-return-matches-buffer-update-report () "A direct region update should return the report stored on its buffer." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Return" :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((report (ebox-region-update region-id :color "#222222"))) (should (equal report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)))))) (ert-deftest ebox-region-update-no-op-replaces-stale-buffer-report () "A direct unchanged update should publish its own no-op report." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "No-op" :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((prior '(:strategy paint-patch :owner-ids (prior)))) (ebox-test--set-buffer-update-report (current-buffer) prior) (let ((report (ebox-region-update region-id :color "#111111"))) (should (eq (plist-get report :strategy) 'no-op)) (should (= (plist-get report :dirty-count) 0)) (should (= (plist-get report :patch-count) 0)) (should (equal report (ebox-test--buffer-update-report))) (should-not (equal report prior))))))) (ert-deftest ebox-batched-region-update-keeps-prior-report-until-flush () "Batched region updates should return nil and publish only on flush." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Batch" :width '(80) :color "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((buffer (current-buffer)) (prior '(:strategy prior :owner-ids (prior)))) (ebox-test--set-buffer-update-report buffer prior) (ebox-incremental-begin-batch buffer) (should-not (ebox-region-update region-id :color "#333333")) (should (equal (ebox-buffer-update-report buffer) prior)) (let ((flush-report (ebox-incremental-flush buffer))) (should flush-report) (should (equal flush-report (ebox-buffer-update-report buffer))) (should (eq (plist-get flush-report :strategy) 'paint-patch))))))) (ert-deftest ebox-batch-flush-stores-report-on-flushed-buffer-only () "Empty and non-empty flushes should update only the target buffer report." (ebox-test--reset-runtime-state) (let* ((first-box (ebox-create :content "First" :width '(80) :color "#111111")) (second-box (ebox-create :content "Second" :width '(80) :color "#111111")) (second-id (car (ebox-region-ids second-box))) first-buffer second-buffer) (unwind-protect (progn (setq first-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-flush-first*") first-box)) (setq second-buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-flush-second*") second-box)) (ebox-test--set-buffer-update-report first-buffer '(:strategy first-prior)) (ebox-test--set-buffer-update-report second-buffer '(:strategy second-prior)) (ebox-incremental-begin-batch first-buffer) (let ((first-report (ebox-incremental-flush first-buffer))) (should (eq (plist-get first-report :strategy) 'no-op)) (should (equal (ebox-buffer-update-report first-buffer) first-report)) (should (equal (ebox-buffer-update-report second-buffer) '(:strategy second-prior))) (with-current-buffer second-buffer (ebox-incremental-begin-batch second-buffer) (should-not (ebox-region-update second-id :color "#444444"))) (let ((second-report (ebox-incremental-flush second-buffer))) (should (eq (plist-get second-report :strategy) 'paint-patch)) (should (equal (ebox-buffer-update-report second-buffer) second-report)) (should (equal (ebox-buffer-update-report first-buffer) first-report))))) (dolist (buffer (list first-buffer second-buffer)) (when (buffer-live-p buffer) (kill-buffer buffer)))))) (ert-deftest ebox-render-to-buffer-does-not-switch-current-buffer () (ebox-test--reset-runtime-state) (let ((origin (current-buffer)) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") (ebox-create :content "Hello" :width 40))) (should (eq (current-buffer) origin)) (should (buffer-live-p buffer)) (with-current-buffer buffer (should ebox-buffer-mode) (should buffer-read-only))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-render-to-buffer-keeps-existing-local-map-as-parent () (ebox-test--reset-runtime-state) (let* ((buffer-name (generate-new-buffer-name " *ebox-test*")) (buffer (get-buffer-create buffer-name)) (parent-map (make-sparse-keymap))) (define-key parent-map (kbd "x") #'ignore) (unwind-protect (with-current-buffer buffer (use-local-map parent-map) (ebox-render-to-buffer buffer-name (ebox-create :content "Hello" :width 40)) (should (eq (current-local-map) parent-map)) (should (eq (lookup-key (current-local-map) (kbd "x")) #'ignore))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-render-to-buffer-defers-extents-and-snapshots () "Initial buffer render should leave expensive indexes to lazy accessors." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(column :id "root" :width (viewport) ,@(cl-loop for idx below 12 collect `(box :id ,(format "lazy-initial-%02d" idx) :content ,(format "row %02d" idx) :width (viewport) :height 1))))) buffer (register-count 0) (snapshot-count 0) (runtime-node-ids-count 0) (original-register (symbol-function 'ebox--register-box-extents-in-range)) (original-snapshot (symbol-function 'ebox--node-layout-snapshot)) (original-runtime-node-ids (symbol-function 'ebox--runtime-node-ids))) (unwind-protect (let ((ebox-viewport-width 240)) (cl-letf (((symbol-function 'ebox--register-box-extents-in-range) (lambda (&rest args) (cl-incf register-count) (apply original-register args))) ((symbol-function 'ebox--node-layout-snapshot) (lambda (&rest args) (cl-incf snapshot-count) (apply original-snapshot args))) ((symbol-function 'ebox--runtime-node-ids) (lambda (&rest args) (cl-incf runtime-node-ids-count) (apply original-runtime-node-ids args)))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (should (= register-count 0)) (should (= snapshot-count 0)) (should (= runtime-node-ids-count 0)) (let* ((root-node-id (plist-get layout :node-id)) (target-id (ebox-test--selector-region-id buffer "#lazy-initial-06"))) (should (ebox--layout-snapshot buffer root-node-id)) (should (ebox--live-box-extents target-id)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-region-buffer-ignores-killed-render-buffers () "Dead render buffers should not break later incremental lookups." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Dead" :width 40)) (region-id (car (ebox-region-ids box))) (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") box))) (kill-buffer buffer) (should-not (ebox--region-buffer region-id)) (should-not (ebox--buffer-render-state buffer)))) (ert-deftest ebox-buffer-kill-clears-region-runtime-caches () "Killing an ebox buffer should clear region-scoped runtime cache entries." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :width 80 :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) (buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") box))) (should (gethash region-id ebox--region-box-table)) (should (ebox--scroll-get-state region-id)) (kill-buffer buffer) (should-not (gethash region-id ebox--region-box-table)) (should-not (ebox--scroll-get-state region-id)))) (ert-deftest ebox-buffer-kill-discards-buffer-owned-runtime-state () "Killing an Ebox buffer should discard every buffer-keyed runtime entry." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Lifecycle" :color "#111111")) (region-id (car (ebox-region-ids box))) buffer) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") box)) ;; A non-batched update creates a real cache report. (ebox-region-update region-id :color "#222222") ;; A later update remains pending in the explicit batch. (ebox-incremental-begin-batch buffer) (ebox-region-update region-id :background-color "#eeeeee") (should (gethash buffer ebox--buffer-render-state-table)) (should (gethash buffer ebox-cache--buffer-report-table)) (should (gethash buffer ebox-incremental--batch-table)) (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-error (ebox-buffer-update-report buffer) :type 'user-error)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)) (when buffer (remhash buffer ebox--buffer-render-state-table) (remhash buffer ebox-cache--buffer-report-table) (remhash buffer ebox-incremental--batch-table))))) (ert-deftest ebox-concat-content-update-preserves-neighbor-box () (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "A\nB" :width 60)) (right (ebox-create :content "R1\nR2\nR3" :width 80)) (layout (ebox-concat left right)) (ids (ebox-region-ids layout)) (left-id (nth 0 ids)) (right-id (nth 1 ids))) (ebox-test--with-rendered-buffer layout (should-not (ebox--live-box-extents left-id)) (ebox-region-update left-id :content "AA\nBB\nCC") (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "CC" plain)) (should (string-match-p "R3" plain))) (should (equal (ebox-region-ids layout) ids)) (should (gethash right-id ebox--region-box-table))))) (ert-deftest ebox-geometry-update-rerenders-root-once-safely () (ebox-test--reset-runtime-state) (let* ((top (ebox-create :content "Top" :width 60)) (left (ebox-create :content "L" :width 40)) (right (ebox-create :content "R" :width 40)) (layout (ebox-stack top (ebox-concat left right))) (ids (ebox-region-ids layout)) (left-id (nth 1 ids))) (ebox-test--with-rendered-buffer layout (ebox-region-update left-id :width '(120)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "Top" plain)) (should (string-match-p "L" plain)) (should (string-match-p "R" plain))) (should (equal (ebox-region-ids layout) ids))))) (ert-deftest ebox-border-color-update-patches-without-full-root-rerender () "A pure border color change should patch owned spans, not rebuild the root." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "L" :width 40 :border t :border-color "red")) (right (ebox-create :content "R" :width 40 :border t :border-color "green")) (layout (ebox-concat left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update left-id :border-color "blue")))) (should (= full-rerenders 0))) (let ((border-face (get-text-property (point-min) 'face))) (should (ebox-test--face-has-key-value-p border-face :overline "blue"))) (should (string-match-p "R" (buffer-substring-no-properties (point-min) (point-max))))))) (ert-deftest ebox-flex-row-border-color-update-patches-region-role () "Paint-only updates inside flex rows should not fall back to root rerender." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(flex :flex-flow (row wrap) :column-gap (12) :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" (column (box :id left :content "Left" :width (120) :padding (0 (8)) :border "#D97757") (box :content "Body" :width (120) :padding (0 (8)) :border "#D97757" :border-top-p nil)) (column (box :id right :content "Right" :width (120) :padding (0 (8)) :border "#5E7F6A") (box :content "Body" :width (120) :padding (0 (8)) :border "#5E7F6A" :border-top-p nil))))) (left-id (plist-get (car (ebox-selector-query-all layout "#left")) :region-id))) (ebox-test--with-rendered-buffer layout (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update left-id :border-bottom-color "blue")))) (should (= full-rerenders 0))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should-not (plist-get report :root-rerender))) (let (bottom-face) (save-excursion (goto-char (point-min)) (while (and (not bottom-face) (< (point) (point-max))) (when (equal (get-text-property (point) 'ebox-bb) left-id) (setq bottom-face (get-text-property (point) 'face))) (forward-char 1))) (should (ebox-test--face-has-key-value-p bottom-face :underline '(:position t :color "blue"))))))) (ert-deftest ebox-paint-update-synthesizes-missing-horizontal-border-surface () "Adding a missing top/bottom paint surface should patch from region anchors." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(flex :flex-flow (row wrap) :column-gap (12) :padding (0 (12)) :border "#D97757" :bgcolor "#FFF8F2" (column (box :id left :content "Left" :width (120) :padding (0 (8)) :border "#D97757" :border-bottom-p nil) (box :content "Body" :width (120) :padding (0 (8)) :border "#D97757" :border-top-p nil)) (column (box :content "Right" :width (120) :padding (0 (8)) :border "#5E7F6A" :border-bottom-p nil) (box :content "Body" :width (120) :padding (0 (8)) :border "#5E7F6A" :border-top-p nil))))) (left-id (plist-get (car (ebox-selector-query-all layout "#left")) :region-id))) (ebox-test--with-rendered-buffer layout (should-not (text-property-search-forward 'ebox-bb left-id t)) (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update left-id :border-bottom-p t :border-bottom-color "blue")))) (should (= full-rerenders 0))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should-not (plist-get report :root-rerender))) (let (bottom-face) (save-excursion (goto-char (point-min)) (while (and (not bottom-face) (< (point) (point-max))) (when (equal (get-text-property (point) 'ebox-bb) left-id) (setq bottom-face (get-text-property (point) 'face))) (forward-char 1))) (should (ebox-test--face-has-key-value-p bottom-face :underline '(:position t :color "blue"))))))) (ert-deftest ebox-horizontal-border-removal-preserves-other-border-colors () "Removing top/bottom border surfaces must not corrupt other border faces." (ebox-test--reset-runtime-state) (dolist (case '((:border-top-p bt (bb bl br)) (:border-bottom-p bb (bt bl br)))) (let* ((property (nth 0 case)) (removed-role (nth 1 case)) (kept-roles (nth 2 case)) (layout (ebox-create :content "Border" :width 80 :padding '(0 (8)) :border "#D97757")) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (cl-labels ((face-at-role (role) (let ((prop (alist-get role ebox-region-types)) face) (save-excursion (goto-char (point-min)) (while (and (not face) (< (point) (point-max))) (when (equal (get-text-property (point) prop) region-id) (setq face (get-text-property (point) 'face))) (forward-char 1))) face))) (ebox-region-update region-id property nil) (should-not (face-at-role removed-role)) (dolist (role kept-roles) (should (ebox-test--border-face-has-color-p (face-at-role role) role "#D97757")))))))) (ert-deftest ebox-side-border-color-patch-toggles-default-paint-exactly () "Side color patches should match default and explicit full-render paint." (ebox-test--reset-runtime-state) (let* ((layout (ebox-create :content "Border" :width 80 :border-left-pixel 2 :border-left-color "#AA0000" :border-right-pixel 2 :border-right-color "#AA0000")) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-region-update region-id :border-left-color nil :border-right-color nil) (dolist (role '(bl br)) (let ((face (ebox-test--face-at-property (buffer-string) (alist-get role ebox-region-types)))) (should (ebox-test--face-has-key-value-p face :inverse-video t)) (should-not (ebox-test--face-has-key-p face :background)))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (ebox-region-update region-id :border-left-color "#00AA00" :border-right-color "#00AA00") (dolist (role '(bl br)) (let ((face (ebox-test--face-at-property (buffer-string) (alist-get role ebox-region-types)))) (should-not (ebox-test--face-has-key-p face :inverse-video)) (should (ebox-test--face-has-key-value-p face :background "#00AA00")))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-region-update-reuses-buffer-render-viewport-context () "Incremental rerender should use the viewport from the original buffer render." (ebox-test--reset-runtime-state) (let* ((layout (ebox-flex :padding '(0 4) :border "#64748B" (ebox-create :content "Flex Child" :width '(120) :border t))) (region-id (car (ebox--node-all-region-ids layout))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (with-current-buffer buffer (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update region-id :border-color "#2563EB")))) (should (= full-rerenders 0))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (eq (plist-get report :owner-type) 'flex)) (should (equal (plist-get report :patch-ops) '(paint-patch)))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-rerender-buffer-with-context-resolves-runtime-viewport () "Runtime rerender should resolve viewport-sized boxes with the new context." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-width 240)) (ebox-build '(box :content "Viewport" :width (viewport) :border t)))) buffer) (unwind-protect (progn (should (eq (plist-get node :width) 'viewport)) (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node))) (with-current-buffer buffer (should (= (ebox--string-pixel-width (buffer-substring (point-min) (line-end-position))) 240))) (ebox-rerender-buffer-with-context buffer 160) (with-current-buffer buffer (should (= (ebox--string-pixel-width (buffer-substring (point-min) (line-end-position))) 160)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-rerender-buffer-with-context-resolves-runtime-viewport-height () "Runtime rerender should resolve viewport-height boxes with the new context." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-height 2)) (ebox-build '(box :content "One\nTwo\nThree\nFour" :width (120) :height (viewport-height) :overflow scroll)))) buffer) (unwind-protect (progn (let ((ebox-viewport-height 2)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node))) (with-current-buffer buffer (should (= (length (ebox-string-lines (buffer-substring-no-properties (point-min) (point-max)))) 2))) (ebox-rerender-buffer-with-context buffer 120 3) (with-current-buffer buffer (should (= (length (ebox-string-lines (buffer-substring-no-properties (point-min) (point-max)))) 3)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-rerender-buffer-with-context-rebuilds-nested-scroll-extents () "Viewport-height rerenders should keep fixed rails outside the scroll box." (ebox-test--reset-runtime-state) (let* ((node (let ((ebox-viewport-width 120) (ebox-viewport-height 12)) (ebox-build '(column :id "outer" :width (viewport) :height (viewport-height) (box :id "top" :content "" :height 2) (box :id "root" :height (- (viewport-height) 4) :overflow scroll (column (box :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1) (box :content "D" :height 1) (box :content "E" :height 1) (box :content "F" :height 1) (box :content "G" :height 1) (box :content "H" :height 1) (box :content "I" :height 1) (box :content "J" :height 1))) (box :id "bottom" :content "" :height 2))))) buffer) (unwind-protect (progn (let ((ebox-viewport-width 120) (ebox-viewport-height 12)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") node))) (ebox-rerender-buffer-with-context buffer 120 20) (with-current-buffer buffer (let* ((root-id (plist-get (car (ebox-selector-query-buffer buffer "#root")) :region-id)) (top-id (plist-get (car (ebox-selector-query-buffer buffer "#top")) :region-id)) (extents (ebox--live-box-extents root-id))) (should extents) (should (= (length (ebox-string-lines (buffer-substring-no-properties (point-min) (point-max)))) 20)) (should (= (line-number-at-pos (car extents)) 3)) (should (= (count-lines (car extents) (cdr extents)) 16)) (save-excursion (goto-char (car extents)) (should (member root-id (get-text-property (point) 'ebox-content-owners))) (should-not (member top-id (get-text-property (point) 'ebox-content-owners))))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-stable-content-update-patches-without-full-root-rerender () "A content update with unchanged rendered geometry should stay local." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "Alpha" :width 80 :height 1)) (right (ebox-create :content "Right" :width 80 :height 1)) (layout (ebox-concat left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update left-id :content "Beta")))) (should (= full-rerenders 0))) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "Beta" plain)) (should-not (string-match-p "Alpha" plain)) (should (string-match-p "Right" plain)))))) (ert-deftest ebox-nested-stable-content-update-patches-leaf-owner () "Stable content updates inside nested layouts should not patch the root." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(flex :flex-flow (row wrap) :column-gap (12) :padding (0 (12)) :border "#999999" (column :width (210) :border "#999999" (box :content "Title" :width (190)) (box :id target :content "before\nsame" :width (190) :height 3 :padding (0 (8)) :border "#999999") (box :content "Metric" :width (190))) (column :width (210) :border "#999999" (box :content "Other" :width (190)) (box :content "Still here" :width (190)))))) (target (car (ebox-selector-query-all layout "#target"))) (target-id (plist-get target :region-id)) (target-node-id (plist-get target :node-id))) (ebox-test--with-rendered-buffer layout (let ((snapshot (ebox--ensure-layout-snapshot-spans (current-buffer) target-node-id))) (should snapshot) (should (plist-get snapshot :buffer-spans))) (ebox-region-update target-id :content "after\nsame") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :owner-ids) (list target-node-id))) (should (< (plist-get report :span-count) 10)) (should-not (plist-get report :full-rerender))) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "after" plain)) (should-not (string-match-p "before" plain)) (should (string-match-p "Still here" plain)))))) (ert-deftest ebox-flex-contained-geometry-update-does-not-promote-to-root () "Stable flex-contained geometry changes should stop before root." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(column :id root :width (520) :padding (1 (18)) (flex :id matrix :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) :border "#999999" (column :width (210) :border "#999999" (box :content "Title" :width (190)) (box :id target :content "width target\nsame height" :width (160) :height 3 :padding (0 (8)) :border "#999999") (box :content "Metric" :width (190))) (column :width (210) :border "#999999" (box :content "Other" :width (190)) (box :content "Still here" :width (190))))))) (target (car (ebox-selector-query-all layout "#target"))) (target-id (plist-get target :region-id))) (ebox-test--with-rendered-buffer layout (let ((root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox-region-update target-id :width '(190)) (let ((report (ebox-test--buffer-update-report))) (should-not (plist-get report :full-rerender)) (should-not (member root-id (plist-get report :owner-ids))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender))) (should (< (plist-get report :span-count) 40))))))) (defun ebox-test--nested-width-containment-layout (&optional height) "Return a nested flex width-update fixture with optional fixed HEIGHT." (let* ((target (ebox-create :content "width target\nready to grow" :width '(190) :height 3 :padding '(0 (10)) :border "#999999")) (owner (apply #'ebox-flex (append (list :width '(300) :padding '(0 (10)) :border "#999999" :justify-content 'center :align-items 'center) (when height (list :height height)) (list target)))) (card (ebox-column (ebox-create :content "Width" :width '(300) :height 2) owner (ebox-create :content "The target stays in this card" :width '(300) :height 3) (ebox-create :content "Metric" :width '(300) :height 4))) (peer (ebox-column (ebox-create :content "Peer" :width '(300) :height 2) (ebox-create :content "Stable peer" :width '(300) :height 5) (ebox-create :content "Footer" :width '(300) :height 7))) (layout (ebox-flex :width '(720) :flex-flow '(row wrap) :column-gap '(12) card peer))) (list :layout layout :target target :owner owner))) (ert-deftest ebox-fixed-flex-context-contains-child-width-update () "Fixed flex contexts should patch their five lines, not the whole card." (ebox-test--reset-runtime-state) (let* ((fixture (ebox-test--nested-width-containment-layout 5)) (layout (plist-get fixture :layout)) (target (plist-get fixture :target)) (owner (plist-get fixture :owner)) (target-region-id (car (ebox-region-ids target))) (owner-node-id (ebox--ensure-node-id owner))) (ebox-test--with-rendered-buffer layout (ebox-region-update target-region-id :width '(280)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'flex)) (should (= (plist-get report :span-count) 5)) (should-not (plist-get report :root-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-contained-partial-flex-content-change-preserves-fixed-slot () "A fixed partial flex should update without disturbing adjacent siblings." (ebox-test--reset-runtime-state) (let* ((mark (ebox-create :key 'mark :host-ref 'mark :content "○ OFF" :width 'max-content :overflow 'hidden)) (label (ebox-create :key 'label :content " Power off" :host-ref 'label :width 'max-content :overflow 'hidden)) (switch (ebox-create :key 'switch :host-ref 'switch :width 'max-content :wrap-mode nil :overflow 'hidden :ebox-content-node (ebox-row mark label))) (owner (ebox-flex :width '(496) :min-width '(496) :max-width '(496) :height 1 :min-height 1 :max-height 1 :overflow 'hidden :bgcolor "#F7F8FA" :wrap-mode nil :flex-flow '(column nowrap) switch)) (caption (ebox-create :content "switch" :width '(170) :min-width '(170) :max-width '(170) :height 1 :min-height 1 :max-height 1)) (outer (ebox-flex :width '(680) :min-width '(680) :max-width '(680) :height 1 :min-height 1 :max-height 1 :overflow 'hidden :bgcolor "#F7F8FA" :wrap-mode nil :column-gap '(14) :flex-flow '(row nowrap) caption owner)) (footer (ebox-create :content "FOOTER" :width '(680) :height 1)) (layout (ebox-column outer footer)) (mark-node-id (ebox--ensure-node-id mark)) (label-node-id (ebox--ensure-node-id label)) (owner-node-id (ebox--ensure-node-id owner)) (caption-region-id (car (ebox-region-ids caption))) (footer-region-id (car (ebox-region-ids footer)))) (plist-put owner :key 'owner) (plist-put owner :host-ref 'owner) (plist-put outer :key 'outer) (plist-put outer :host-ref 'outer) (ebox-test--with-rendered-buffer layout (should (ebox--contained-partial-flex-owner-p (current-buffer) owner-node-id)) (let ((candidate (ebox-candidate-begin (current-buffer)))) (ebox-candidate-replace candidate mark-node-id (ebox-create :key 'mark :host-ref 'mark :content " ON ● " :width 'max-content :overflow 'hidden)) (ebox-candidate-replace candidate label-node-id (ebox-create :key 'label :host-ref 'label :content " Much longer power on state" :width 'max-content :overflow 'hidden)) (ebox-commit (current-buffer) candidate)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'flex)) (should (plist-get report :slot-preserving)) (should (= (plist-get report :span-count) 1)) (should-not (memq caption-region-id (plist-get report :affected-region-ids))) (should-not (memq footer-region-id (plist-get report :affected-region-ids))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) (ert-deftest ebox-contained-partial-flex-structure-change-preserves-fixed-slot () "A fixed partial flex should own bounded descendant structure changes." (ebox-test--reset-runtime-state) (let* ((input (ebox-create :key 'input :content "query" :overflow 'hidden)) (content (ebox-create :key 'content :host-ref 'content :width 'max-content :overflow 'hidden :ebox-content-node (ebox-column input))) (owner (ebox-flex :width '(180) :height 2 :overflow 'hidden :flex-flow '(column nowrap) content)) (caption (ebox-create :content "search" :width '(80) :height 2)) (outer (ebox-row caption owner)) (footer (ebox-create :content "FOOTER" :width '(260) :height 1)) (layout (ebox-column outer footer)) (content-node-id (ebox--ensure-node-id content)) (owner-node-id (ebox--ensure-node-id owner)) (caption-region-id (car (ebox-region-ids caption))) (footer-region-id (car (ebox-region-ids footer)))) (plist-put owner :key 'owner) (plist-put owner :host-ref 'owner) (ebox-test--with-rendered-buffer layout (let ((candidate (ebox-candidate-begin (current-buffer)))) (ebox-candidate-replace candidate content-node-id (ebox-create :key 'content :host-ref 'content :width 'max-content :overflow 'hidden :ebox-content-node (ebox-column (ebox-create :key 'input :content "query" :overflow 'hidden) (ebox-create :key 'option :content "Apple" :overflow 'hidden)))) (ebox-commit (current-buffer) candidate)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'flex)) (should (plist-get report :slot-preserving)) (should (= (plist-get report :span-count) 2)) (should-not (memq caption-region-id (plist-get report :affected-region-ids))) (should-not (memq footer-region-id (plist-get report :affected-region-ids))) (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) (ert-deftest ebox-contained-flex-publication-span-rejects-foreign-sibling () "A declared slot must not expand through a sibling's direct content." (ebox-test--reset-runtime-state) (let* ((target (ebox-create :content "A" :overflow 'hidden)) (owner (ebox-flex :width '(180) :height 1 :overflow 'hidden :flex-flow '(column nowrap) target)) (sibling (ebox-create :content "RIGHT" :width '(80) :height 1)) (layout (ebox-flex :width '(260) :height 1 :flex-flow '(row nowrap) owner sibling)) (target-region-id (car (ebox-region-ids target))) (owner-node-id (ebox--ensure-node-id owner)) (sibling-region-id (car (ebox-region-ids sibling)))) (ebox-test--with-rendered-buffer layout (let* ((snapshot (ebox--ensure-layout-snapshot-spans (current-buffer) owner-node-id)) (spans (plist-get snapshot :buffer-spans)) (expanded (ebox-buffer--contained-flex-publication-spans (current-buffer) owner-node-id owner spans)) (foreign-position (1- (cdar expanded)))) (should (ebox--contained-partial-flex-owner-p (current-buffer) owner-node-id)) (let ((inhibit-read-only t)) (put-text-property foreign-position (1+ foreign-position) 'ebox-content sibling-region-id) (unwind-protect (should-not (ebox-buffer--contained-flex-publication-spans (current-buffer) owner-node-id owner spans)) (put-text-property foreign-position (1+ foreign-position) 'ebox-content nil)))) (ebox-region-update target-region-id :content "This content stays inside its parent's proven fallback") (let ((report (ebox-test--buffer-update-report))) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (ebox-test--buffer-propertized-matches-runtime-render-p)))))) (ert-deftest ebox-auto-height-flex-context-keeps-ancestor-width-fallback () "Auto-height flex contexts must not contain descendant width reflow." (ebox-test--reset-runtime-state) (let* ((fixture (ebox-test--nested-width-containment-layout)) (layout (plist-get fixture :layout)) (target (plist-get fixture :target)) (owner (plist-get fixture :owner)) (target-region-id (car (ebox-region-ids target))) (owner-node-id (ebox--ensure-node-id owner))) (ebox-test--with-rendered-buffer layout (should-not (ebox--definite-containment-formatting-context-p (current-buffer) owner-node-id)) (ebox-region-update target-region-id :width '(280)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should-not (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'stack)) (should-not (plist-get report :root-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-intrinsic-constrained-flex-context-keeps-ancestor-fallback () "Intrinsic min/max constraints must disable definite containment." (dolist (max-width '(min-content (fit-content 260))) (ebox-test--reset-runtime-state) (let* ((fixture (ebox-test--nested-width-containment-layout 5)) (layout (plist-get fixture :layout)) (target (plist-get fixture :target)) (owner (plist-get fixture :owner)) (owner-box (plist-get owner :box)) (target-region-id (car (ebox-region-ids target))) (owner-node-id (ebox--ensure-node-id owner))) (ebox-put owner-box :max-width max-width) (ebox-test--with-rendered-buffer layout (should-not (ebox--definite-containment-formatting-context-p (current-buffer) owner-node-id)) (ebox-region-update target-region-id :width '(280)) (let ((report (ebox-test--buffer-update-report))) (should-not (equal (plist-get report :owner-ids) (list owner-node-id))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-visible-overflow-flex-context-keeps-ancestor-fallback () "A fixed context must not contain an unowned visible-overflow suffix." (ebox-test--reset-runtime-state) (let* ((fixture (ebox-test--nested-width-containment-layout 5)) (layout (plist-get fixture :layout)) (target (plist-get fixture :target)) (owner (plist-get fixture :owner)) (target-region-id (car (ebox-region-ids target))) (owner-node-id (ebox--ensure-node-id owner))) (ebox-put target :content "line 1\nline 2\nline 3\nline 4") (ebox-put target :height 2) (ebox-put target :overflow 'visible) (ebox-test--with-rendered-buffer layout (should-not (ebox--definite-containment-formatting-context-p (current-buffer) owner-node-id)) (ebox-region-update target-region-id :width '(280)) (let ((report (ebox-test--buffer-update-report))) (should-not (equal (plist-get report :owner-ids) (list owner-node-id))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-visible-overflow-flex-wrapper-disables-width-containment () "A visible-overflow flex wrapper must not contain child width changes." (ebox-test--reset-runtime-state) (let* ((target (ebox-create :content "child" :width '(190) :height 5 :min-height 5)) (owner (ebox-flex :width '(300) :height 2 :overflow 'visible :align-items 'flex-start target)) (layout (ebox-stack owner (ebox-create :content "footer" :width '(300) :height 2))) (owner-node-id (ebox--ensure-node-id owner))) (ebox-test--with-rendered-buffer layout (should (= (ebox-string-height (ebox-render owner)) 5)) (should-not (ebox--definite-containment-formatting-context-p (current-buffer) owner-node-id))))) (ert-deftest ebox-fixed-flex-context-contains-child-block-size-update () "A fixed flex should contain a child block-size change in its five lines." (ebox-test--reset-runtime-state) (let* ((fixture (ebox-test--nested-width-containment-layout 5)) (layout (plist-get fixture :layout)) (target (plist-get fixture :target)) (target-region-id (car (ebox-region-ids target))) (owner-node-id (ebox--ensure-node-id (plist-get fixture :owner)))) (ebox-test--with-rendered-buffer layout (let ((root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox-region-update target-region-id :height 4) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :owner-ids) (list owner-node-id))) (should (eq (plist-get report :owner-type) 'flex)) (should (= (plist-get report :span-count) 5)) (should-not (member root-id (plist-get report :owner-ids)))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-fixed-flex-own-width-change-skips-containment-span-probe () "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))) (span-patch-calls 0) (original-span-patch (symbol-function 'ebox-buffer-apply-span-patch))) (ebox-test--with-rendered-buffer layout (cl-letf (((symbol-function 'ebox-buffer-apply-span-patch) (lambda (&rest args) (cl-incf span-patch-calls) (apply original-span-patch args)))) (ebox-region-update owner-region-id :width '(320))) (should (= span-patch-calls 0)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-flex-main-footprint-change-skips-flex-span-probe () "Flex main footprint changes should not render a large flex owner twice." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(column :id root :width (520) :padding (1 (18)) (flex :id matrix :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) :border "#999999" (column :width (210) :border "#999999" (box :content "Title" :width (190)) (box :id target :content "width target\nsame height" :width (160) :height 3 :padding (0 (8)) :border "#999999") (box :content "Metric" :width (190))) (column :width (210) :border "#999999" (box :content "Other" :width (190)) (box :content "Still here" :width (190))))))) (target (car (ebox-selector-query-all layout "#target"))) (target-id (plist-get target :region-id)) (span-patch-calls 0) (original-span-patch (symbol-function 'ebox-buffer-apply-span-patch))) (ebox-test--with-rendered-buffer layout (cl-letf (((symbol-function 'ebox-buffer-apply-span-patch) (lambda (&rest args) (cl-incf span-patch-calls) (apply original-span-patch args)))) (ebox-region-update target-id :width '(210) :min-width '(150) :max-width '(260))) (let ((report (ebox-test--buffer-update-report))) (should (= span-patch-calls 0)) (should (eq (plist-get report :strategy) 'owner-rerender)) (should-not (plist-get report :root-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (defun ebox-test--flex-line-width-owner-layout (&optional count width) "Return a wide flex layout whose first row has a nested width target." (let* ((count (or count 12)) (resolved-width (if (eq width :auto) nil (or width '(2600)))) (target (ebox-create :id "target" :content "width target\nmoves sibling layout" :width '(160) :height 3 :padding '(0 (8)) :border "#999999")) (first (ebox-column (ebox-create :content "Title" :width '(190)) target (ebox-create :content "Metric" :width '(190) :height 4)))) (apply #'ebox-flex (append (list :flex-flow '(row wrap) :column-gap '(12) :row-gap 1 :padding '(0 (12)) :border "#999999") (when resolved-width (list :width resolved-width)) (list first) (cl-loop for index from 1 below count collect (ebox-column (ebox-create :content (format "Title %02d" index) :width '(190)) (ebox-create :content "Peer\nsame row" :width '(190) :height 3 :padding '(0 (8)) :border "#999999") (ebox-create :content "Metric" :width '(190) :height 4))))))) (ert-deftest ebox-flex-line-rerender-report-exposes-fast-path () "Reports should expose when an owner rerender only patched one flex row." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--flex-line-width-owner-layout))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) (ebox-region-update region-id :width '(210) :min-width '(150) :max-width '(260)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :owner-type) 'flex)) (should (plist-get report :flex-line-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-flex-line-rerender-skips-auto-width-owner () "Auto-width flex owners should fall back instead of slicing a moving suffix." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--flex-line-width-owner-layout 12 :auto))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) (ebox-region-update region-id :width '(210) :min-width '(150) :max-width '(260)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :owner-type) 'flex)) (should-not (plist-get report :flex-line-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-flex-line-rerender-avoids-all-child-span-snapshots () "Flex row rerender should locate and render one row without all-child fanout." (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 (ebox--ensure-node-id (car (last (ebox--node-children layout))))) (span-snapshot-calls 0) (measure-calls 0) (original (symbol-function 'ebox--ensure-layout-snapshot-spans)) (original-measure (symbol-function 'ebox--flex-measure-source))) (cl-letf (((symbol-function 'ebox--ensure-layout-snapshot-spans) (lambda (&rest args) (cl-incf span-snapshot-calls) (apply original args))) ((symbol-function 'ebox--flex-measure-source) (lambda (&rest args) (cl-incf measure-calls) (apply original-measure args)))) (ebox-region-update region-id :width '(210) :min-width '(150) :max-width '(260))) (let ((report (ebox-test--buffer-update-report))) (should (plist-get report :flex-line-rerender)) (should (<= span-snapshot-calls 20)) (should (< measure-calls 16)) (should (plist-get (ebox--ensure-layout-snapshot-spans (current-buffer) unrelated-child-id) :buffer-spans)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (defun ebox-test--dynamic-update-row-layout () "Return a small flex-row layout shaped like the dynamic update matrix." (ebox-build '(column :id root :width (1200) :padding (1 (18)) (flex :id matrix :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) :border "#999999" (column :width (210) :border "#999999" (box :content "Reference\nshort" :width (190)) (box :id scroll :content "line 1\nline 2\nline 3\nline 4" :width (190) :height 2 :padding (0 (8)) :border "#999999" :overflow scroll) (box :content "Metric" :width (190) :height 4)) (column :width (210) :border "#999999" (box :content "Height\nowner" :width (190)) (box :id height :content "height target\nexpands block" :width (190) :height 2 :padding (0 (8)) :border "#999999") (box :content "Metric" :width (190) :height 4)) (column :width (210) :border "#999999" (box :content "Margin\nslot stable" :width (190)) (box :id margin :content "margin target\nparent placement" :width (170) :height 3 :padding (0 (8)) :border "#999999") (box :content "Metric" :width (190) :height 4)) (column :width (210) :border "#999999" (box :content "Overflow\nowner" :width (190)) (box :id overflow :content "overflow target\nextra line\nanother line" :width (190) :height 2 :padding (0 (8)) :border "#999999" :overflow scroll) (box :content "Metric" :width (190) :height 4)) (column :width (210) :border "#999999" (box :content "Tall peer\nkeeps row slot" :width (190)) (box :content "tall\npeer\nsets\nline\ncross" :width (190) :height 5 :padding (0 (8)) :border "#999999") (box :content "Metric" :width (190) :height 4)))))) (defun ebox-test--dynamic-update-tight-row-layout () "Return a flex-row layout whose target growth exceeds its old item slot." (ebox-build '(column :id root :width (720) (box :content "Header" :width (720)) (column :id panel :width (720) :border "#999999" (flex :id matrix :flex-flow (row wrap) :column-gap (12) :row-gap 1 :padding (0 (12)) :border "#999999" (column :width (210) :border "#999999" (box :content "Height\nowner" :width (190)) (box :id height :content "height target\nexpands block" :width (190) :height 2 :padding (0 (8)) :border "#999999") (box :content "Metric" :width (190) :height 2)) (column :width (210) :border "#999999" (box :content "Peer" :width (190)) (box :content "peer\nsame row" :width (190) :height 2 :padding (0 (8)) :border "#999999") (box :content "Metric" :width (190) :height 2))))))) (defun ebox-test--selector-region-id (buffer selector) "Return the first editable region id matching SELECTOR in BUFFER." (plist-get (car (ebox-selector-query-buffer buffer selector)) :region-id)) (defun ebox-test--visual-line-signature (line) "Return LINE's visible glyph positions and total pixel width." (let ((pixel 0) glyphs) (cl-loop for index below (length line) for next = (1+ index) for char = (aref line index) for width = (ebox--substring-pixel-width line index next) do (progn (unless (eq char ?\s) (push (cons pixel char) glyphs)) (setq pixel (+ pixel width)))) (list :width pixel :glyphs (nreverse glyphs)))) (defun ebox-test--rendered-visually-equal-p (actual expected) "Return non-nil when ACTUAL and EXPECTED have the same visible layout." (let ((actual-lines (ebox-string-lines actual)) (expected-lines (ebox-string-lines expected)) (ok t)) (setq ok (= (length actual-lines) (length expected-lines))) (while (and ok actual-lines expected-lines) (setq ok (equal (ebox-test--visual-line-signature (car actual-lines)) (ebox-test--visual-line-signature (car expected-lines))) actual-lines (cdr actual-lines) expected-lines (cdr expected-lines))) ok)) (defun ebox-test--buffer-visually-matches-runtime-render-p () "Return non-nil when current buffer visually equals its runtime root render." (let* ((ebox--scroll-global-state (make-hash-table :test 'equal)) (ebox--region-box-table (make-hash-table :test 'equal)) (expected (ebox--with-buffer-render-context (current-buffer) (ebox-render (ebox--buffer-root-node (current-buffer)))))) (ebox-test--rendered-visually-equal-p (buffer-substring (point-min) (point-max)) expected))) (defun ebox-test--buffer-propertized-matches-runtime-render-p () "Return non-nil when the current buffer exactly matches a runtime render." (let* ((ebox--scroll-global-state (make-hash-table :test 'equal)) (ebox--region-box-table (make-hash-table :test 'equal)) (expected (ebox--with-buffer-render-context (current-buffer) (ebox-render (ebox--buffer-root-node (current-buffer)))))) (equal-including-properties (buffer-substring (point-min) (point-max)) expected))) (ert-deftest ebox-scroll-offset-update-uses-scroll-fast-path () "Programmatic scroll-offset updates should replace visible lines without render." (ebox-test--reset-runtime-state) (let* ((layout (ebox-test--dynamic-update-row-layout)) render-calls) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#scroll")) (original-render (symbol-function 'ebox-render))) (setq render-calls 0) (cl-letf (((symbol-function 'ebox-render) (lambda (&rest args) (cl-incf render-calls) (apply original-render args)))) (ebox-region-update region-id :scroll-offset 1)) (let ((report (ebox-test--buffer-update-report)) (plain (buffer-substring-no-properties (point-min) (point-max)))) (should (= render-calls 0)) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-fast-path)) (should (string-match-p "line 2" plain)) (should-not (string-match-p "line 1" plain))))))) (ert-deftest ebox-dynamic-update-hot-paths-use-region-owner-index () "Paint, geometry, scroll, and batch updates should avoid region tree walks." (ebox-test--reset-runtime-state) (let* ((paint (ebox-create :content "Paint" :width '(80) :color "#111111")) (geometry (ebox-create :content "Geometry" :width '(80))) (scroll (ebox-create :content "line 1\nline 2\nline 3" :width '(80) :height 1 :overflow 'scroll)) (batch (ebox-create :content "Batch" :width '(80) :color "#111111")) (flex (ebox-flex :width '(120) :padding 1 :color "#111111" (ebox-create :content "Flex" :width '(80)))) (flex-wrapper (plist-get flex :box)) (paint-id (ebox--ensure-region-id paint)) (geometry-id (ebox--ensure-region-id geometry)) (scroll-id (ebox--ensure-region-id scroll)) (batch-id (ebox--ensure-region-id batch)) (flex-wrapper-id (ebox--ensure-region-id flex-wrapper)) (layout (ebox-column paint geometry scroll batch flex))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (region-node-table (ebox--buffer-region-node-table buffer)) (original-path (symbol-function 'ebox--node-path-to-region)) (recursive-calls 0)) (cl-letf (((symbol-function 'ebox--node-path-to-region) (lambda (&rest args) (cl-incf recursive-calls) (apply original-path args)))) (ebox-region-update paint-id :color "#DC2626") (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'paint-patch)) (should (= recursive-calls 0)) (ebox-region-update geometry-id :width '(100)) (should (memq :width (plist-get (ebox-test--buffer-update-report) :dirty-keys))) (should (= recursive-calls 0)) (ebox-region-update scroll-id :scroll-offset 1) (should (plist-get (ebox-test--buffer-update-report) :scroll-fast-path)) (should (= recursive-calls 0)) (ebox-region-update flex-wrapper-id :color "#7C3AED") (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'paint-patch)) (should (equal (car (plist-get (ebox-test--buffer-update-report) :owner-ids)) (ebox--ensure-node-id flex))) (should (= recursive-calls 0)) (ebox-incremental-begin-batch buffer) (ebox-region-update batch-id :color "#2563EB") (ebox-incremental-flush buffer) (should (= (plist-get (ebox-test--buffer-update-report) :flush-count) 1)) (should (= recursive-calls 0)) (ebox--refresh-buffer-layout-snapshots buffer) (should (= recursive-calls 0))) (should (eq (ebox--buffer-region-node-table buffer) region-node-table)))))) (ert-deftest ebox-scroll-offset-fast-path-passes-old-offset () "Programmatic scroll updates should preserve the old offset for sliding." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--dynamic-update-row-layout)) seen-old-offset (forced-window-updates 0)) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#scroll"))) (cl-letf (((symbol-function 'ebox--scroll-replace-rendered-content-window) (lambda (_state _region-id old-offset) (setq seen-old-offset old-offset) (list (cons (point-min) (point-min))))) ((symbol-function 'force-window-update) (lambda (&optional _object) (cl-incf forced-window-updates)))) (ebox-region-update region-id :scroll-offset 1)) (should (= seen-old-offset 0)) (should (= forced-window-updates 0)))))) (ert-deftest ebox-scroll-offset-fast-path-uses-content-markers () "Scroll updates should replace cached visible content spans directly." (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) "#scroll")) (state (ebox--scroll-get-state region-id)) (scan-replace-count 0) (original-replace (symbol-function 'ebox--region-replace-line))) (should (plist-get state :content-span-markers)) (cl-letf (((symbol-function 'ebox--region-replace-line) (lambda (&rest args) (cl-incf scan-replace-count) (apply original-replace args)))) (ebox-region-update region-id :scroll-offset 1)) (let ((report (ebox-test--buffer-update-report))) (should (plist-get report :scroll-fast-path)) (should (= scan-replace-count 0))))))) (ert-deftest ebox-scroll-offset-fast-path-preserves-line-display-properties () "Scroll updates must not spread one alignment space across the new line." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id scroll :content "第 1 行\n第 2 行\n第 3 行\n第 4 行" :width (230) :height 2 :padding (0 (10)) :border "#9CA3AF" :bgcolor "#F8FAFC" :color "#3D3A35" :text-align center :overflow scroll :scroll-offset 0)))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#scroll")) forced-buffer (nondestructive-replacements 0) (original-replace-region-contents (symbol-function 'replace-region-contents))) (cl-letf (((symbol-function 'replace-region-contents) (lambda (&rest args) (cl-incf nondestructive-replacements) (apply original-replace-region-contents args))) ((symbol-function 'force-window-update) (lambda (&optional object) (setq forced-buffer object)))) (ebox-region-update region-id :scroll-offset 2)) (should (plist-get (ebox-test--buffer-update-report) :scroll-fast-path)) (should (> nondestructive-replacements 0)) (should-not forced-buffer) (should-not (cl-loop for pos from (point-min) below (point-max) thereis (and (not (eq (char-after pos) ?\s)) (get-text-property pos 'display)))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-scroll-offset-fast-path-requires-stable-marker-lengths () "Scroll marker patches should not shift later buffer positions." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id scroll :content "x\nsubstantially longer visible line" :width (180) :height 1 :overflow scroll :scroll-offset 0)))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#scroll")) (state (ebox--scroll-get-state region-id)) (lines (plist-get state :content-lines))) (should (not (= (length (nth 0 lines)) (length (nth 1 lines))))) (ebox-region-update region-id :scroll-offset 1) (let ((report (ebox-test--buffer-update-report))) (should-not (plist-get report :scroll-fast-path)) (let ((state (ebox-scroll-state region-id))) (should (= (plist-get state :scroll-offset) 1)) (should-not (plist-get state :lazy-scroll-prefix-dirty)) (should-not (plist-get state :lazy-scroll-window-refresh-required))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-nested-scroll-offset-transaction-replays-outer-cache () "Nested scroll updates should atomically survive outer cache replay." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id root :width (180) :height 2 :overflow scroll (column (box :id inner :width (180) :height 2 :overflow scroll :content "inner 0\ninner 1\ninner 2\ninner 3") (box :content "outer 0\nouter 1" :height 2))))) root-id inner-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root") inner-id (ebox-test--selector-region-id (current-buffer) "#inner")) (ebox-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-content-window-declines-horizontal-gaps () "Bulk scroll replacement should leave padded line gaps to marker fallback." (ebox-test--reset-runtime-state) (with-temp-buffer (let ((region-id 7001) starts ends state (marker-replacements 0)) (dolist (entry '(("AA" "x" "PP\n") ("AA" "y" "PP"))) (insert (car entry)) (push (point) starts) (insert (propertize (cadr entry) 'ebox-content region-id 'ebox-content-idx (1- (length starts)))) (push (point) ends) (insert (caddr entry))) (setq starts (nreverse starts) ends (nreverse ends)) (let ((markers (apply #'vector (cl-mapcar (lambda (start end) (cons (copy-marker start nil) (copy-marker end t))) starts ends)))) (setq state (list :region-id region-id :buffer (current-buffer) :content-span-markers markers)) (unwind-protect (progn (puthash region-id state ebox--scroll-global-state) (should-not (ebox--scroll-content-window state 2)) (let ((before-size (buffer-size)) (original (symbol-function 'ebox--scroll-replace-content-marker))) (cl-letf (((symbol-function 'ebox--scroll-replace-content-marker) (lambda (&rest args) (cl-incf marker-replacements) (apply original args)))) (should (ebox--scroll-update-content region-id '("m" "n")))) (should (= marker-replacements 2)) (should (= (buffer-size) before-size)) (should (equal (buffer-substring-no-properties (point-min) (point-max)) "AAmPP\nAAnPP")))) (remhash region-id ebox--scroll-global-state) (dotimes (idx (length markers)) (let ((span (aref markers idx))) (set-marker (car span) nil) (set-marker (cdr span) nil)))))))) (ert-deftest ebox-scroll-child-layout-window-patches-without-rerender () "Scroll boxes made from child layouts should patch the fixed viewport." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :id "a" :content "A" :height 1) (box :id "b" :content "B" :height 1) (box :id "c" :content "C" :height 1) (box :id "d" :content "D" :height 1))))) (root-id (car (ebox-region-ids layout))) rerender-called) (ebox-test--with-rendered-buffer layout (let* ((state (ebox--scroll-get-state root-id)) (markers (ebox--scroll-content-marker-spans (plist-get state :content-span-markers)))) (should (= (plist-get state :content-height) 2)) (should (= (length markers) 2))) (cl-letf (((symbol-function 'ebox--rerender-box) (lambda (&rest _args) (setq rerender-called t) nil)) ((symbol-function 'ebox--scroll-replace-rendered-window) (lambda (&rest _args) (error "unexpected rendered-window fallback")))) (should (ebox--scroll-region-by root-id 1))) (should-not rerender-called) (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)))))) (ert-deftest ebox-scroll-child-layout-window-keeps-viewport-padding-and-border () "Fast scroll window patches should keep scroll box padding and border." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 4 :box-sizing border-box :overflow scroll :padding-top 1 :padding-bottom 1 :border-top-p t :border-top-color "red" :border-bottom-p t :border-bottom-color "blue" (column (box :id "a" :content "A" :height 1) (box :id "b" :content "B" :height 1) (box :id "c" :content "C" :height 1) (box :id "d" :content "D" :height 1))))) (root-id (car (ebox-region-ids layout))) rerender-called) (ebox-test--with-rendered-buffer layout (should (plist-get (ebox--scroll-get-state root-id) :rendered-content-lines)) (cl-letf (((symbol-function 'ebox--rerender-box) (lambda (&rest _args) (setq rerender-called t) nil))) (should (ebox--scroll-region-by root-id 1))) (should-not rerender-called) (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-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-not (plist-get report :root-rerender)) (should-not (member root-id (plist-get report :owner-ids))) (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-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-not (plist-get report :root-rerender)) (should-not (member root-id (plist-get report :owner-ids))) (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-region-update height-id :height 4 :min-height 3 :max-height 6 :padding-top 1 :padding-bottom 1) (ebox-region-update region-id :margin (list 0 '(8) 0 '(8)) :margin-inline '((8) (8)) :margin-block '(0 0) :margin-top 0 :margin-right '(8) :margin-bottom 0 :margin-left '(8)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :owner-type) 'box)) (should (plist-get report :slot-preserving)) (should-not (plist-get report :root-rerender)) (should-not (member root-id (plist-get report :owner-ids))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-border-box-padding-impact-stays-internal-fragment () "Fixed border-box padding changes should remain an internal fragment impact." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (480) (flex :id row :flex-flow (row nowrap) :column-gap (12) (box :id padding :content "padding target" :width (190) :height 3 :box-sizing border-box :padding (0 (8)) :border "#999999") (box :content "peer" :width (190) :height 3 :border "#999999")))))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#padding"))) (ebox-region-update region-id :padding-left (list 24)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (member 'internal-fragment (plist-get report :impact-vector))) (should-not (member 'parent-layout (plist-get report :impact-vector))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-border-box-padding-shorthand-uses-one-local-span-patch () "Mixed block/inline padding shorthand should not promote to a root rerender." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (480) (flex :id row :flex-flow (row nowrap) :column-gap (12) (box :id padding :content "padding target" :width (190) :height 4 :box-sizing border-box :padding (0 (8)) :border "#999999") (box :content "peer" :width (190) :height 4 :border "#999999")))))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#padding"))) (node-id (ebox--ensure-node-id (plist-get match :node))) (region-id (plist-get match :region-id)) (mixed-entry (list :node-id node-id :dirty-kind 'geometry :changed-keys '(:padding-top-height :padding-right-pixel :padding-bottom-height :padding-left-pixel)))) (should (ebox--dirty-entry-border-box-internal-span-p (current-buffer) mixed-entry)) (ebox-region-update region-id :padding '(2 (20) 0 (8))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :patch-ops) '(span-patch))) (should-not (plist-get report :root-rerender)) (should (member 'internal-fragment (plist-get report :impact-vector))) (should (member 'external-footprint-proof (plist-get report :impact-vector))) (should-not (member 'parent-layout (plist-get report :impact-vector))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-border-box-padding-span-proof-rejects-overfull-height () "Border-box padding cannot claim a fixed footprint without a content line." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id padding :content "padding target" :width (190) :height 4 :box-sizing border-box :padding 2 :border "#999999")))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#padding"))) (node-id (ebox--ensure-node-id (plist-get match :node)))) (let ((entry (list :node-id node-id :dirty-kind 'geometry :changed-keys '(:padding-top-height :padding-right-pixel :padding-bottom-height :padding-left-pixel)))) (should-not (ebox--dirty-entry-border-box-internal-span-p (current-buffer) entry))))))) (ert-deftest ebox-overflow-impact-keeps-conservative-owner-rerender () "Overflow changes should stay conservative until clipping proof exists." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--dynamic-update-row-layout))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#overflow"))) (ebox-region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (member 'clipping (plist-get report :impact-vector))) (should (member 'external-footprint (plist-get report :impact-vector))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-owner-rerender-preserves-missing-wrapper-context () "Nested owner rerenders should retain outer paint and surface metadata." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--dynamic-update-row-layout))) (ebox-put layout :color "#1F2328") (ebox-put layout :bgcolor "#FAF7F0") (ebox-put layout :surface-properties '(ebox-test-role outer-wrapper)) (ebox-put (plist-get (car (ebox--node-children layout)) :box) :bgcolor "#FBF9FF") (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#overflow"))) (ebox-region-update region-id :overflow 'visible) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'owner-rerender)) (goto-char (point-min)) (search-forward "overflow target") (let ((face (get-text-property (match-beginning 0) 'face))) (should (ebox-test--face-has-key-value-p face :foreground "#1F2328")) (should (ebox-test--face-has-key-value-p face :background "#FBF9FF")) (should (eq (get-text-property (match-beginning 0) 'ebox-test-role) 'outer-wrapper))))))) (ert-deftest ebox-owner-rerender-preserves-descendant-region-ownership () "Recursive owner rerenders should keep each descendant's stable region id." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "ownership-parent" :width (240) :padding 1 (box :id "ownership-child" :content "child ownership" :width (120) :height 2 :border "#2563EB"))))) (ebox-test--with-rendered-buffer layout (let ((parent-id (ebox-test--selector-region-id (current-buffer) "#ownership-parent")) (child-id (ebox-test--selector-region-id (current-buffer) "#ownership-child"))) (should (text-property-any (point-min) (point-max) 'ebox-bl child-id)) ;; This test targets recursive rendering ownership. Supply the ;; contiguous owner extent directly so scanner eligibility is an ;; independent concern. (ebox--set-box-extents parent-id (point-min) (point-max)) (ebox--rerender-box parent-id) (should (text-property-any (point-min) (point-max) 'ebox-bl child-id)) (should (text-property-any (point-min) (point-max) 'ebox-content child-id)) (should-not (text-property-any (point-min) (point-max) 'ebox-bl parent-id)))))) (ert-deftest ebox-visible-scroll-content-update-preserves-wrapper-paint () "Visible cached content patches should preserve nested wrapper paint." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "root" :height 1 :overflow scroll (column (box :id "wrapper" :height 1 :overflow scroll :bgcolor "#E2E8F0" (column (box :id "target" :content "old" :width (80) :height 1 :border "#64748B" :bgcolor "#1D4ED8") (box :content "inner tail" :height 1))) (box :content "outer tail" :height 1)))))) (ebox-test--with-rendered-buffer layout (let ((root-id (ebox-test--selector-region-id (current-buffer) "#root")) (wrapper-id (ebox-test--selector-region-id (current-buffer) "#wrapper")) (target-id (ebox-test--selector-region-id (current-buffer) "#target")) (owner-set-scans 0)) (should (plist-get (ebox-scroll-state root-id) :rendered-content-lines)) (should (plist-get (ebox-scroll-state wrapper-id) :rendered-content-lines)) (let ((original (symbol-function 'ebox--string-content-owner-set))) (cl-letf (((symbol-function 'ebox--string-content-owner-set) (lambda (&rest args) (cl-incf owner-set-scans) (apply original args)))) (ebox-region-update target-id :content "new"))) (let* ((report (ebox-test--buffer-update-report)) (border-pos (text-property-any (point-min) (point-max) 'ebox-bl target-id)) (content-pos (text-property-any (point-min) (point-max) 'ebox-content target-id)) (border-face (and border-pos (get-text-property border-pos 'face))) (content-face (and content-pos (get-text-property content-pos 'face))) (content-face-specs (if (and (listp content-face) (keywordp (car content-face))) (list content-face) content-face)) (effective-content-background (cl-loop for spec in content-face-specs when (and (listp spec) (plist-member spec :background)) return (plist-get spec :background)))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)) (should (plist-get report :scroll-rendered-range-patch)) (should (= (plist-get report :span-count) 2)) ;; This is a nested span patch, not a complete root replacement. ;; The one owner scan is required to preserve wrapper paint. (should (= owner-set-scans 1)) (should border-pos) (should content-pos) (should (ebox-test--face-has-key-value-p border-face :background "#E2E8F0")) (should (member wrapper-id (get-text-property border-pos 'ebox-content-owners))) (should (ebox-test--face-has-key-value-p content-face :background "#E2E8F0")) (should (equal effective-content-background "#1D4ED8")) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-contiguous-replacement-preserves-common-wrapper-paint () "Contiguous fallback strings should retain common wrapper paint." (ebox-test--reset-runtime-state) (let* ((wrapper (ebox-create :content "" :color "#334155" :bgcolor "#E2E8F0")) (wrapper-id (car (ebox-region-ids wrapper)))) (puthash wrapper-id wrapper ebox--region-box-table) (with-temp-buffer (insert (propertize "old-a" 'ebox-content-owners (list wrapper-id))) (insert "\n") (insert (propertize "old-b" 'ebox-content-owners (list wrapper-id))) (insert "\n") (let* ((spans (save-excursion (goto-char (point-min)) (list (cons (line-beginning-position) (line-end-position)) (progn (forward-line 1) (cons (line-beginning-position) (line-end-position)))))) (new-spans (ebox--replace-line-spans spans "new-a\nnew-b\nnew-c"))) (should (equal (buffer-string) "new-a\nnew-b\nnew-c\n")) (should (= (length new-spans) 3)) (dolist (text '("new-a" "new-b" "new-c")) (goto-char (point-min)) (search-forward text) (let* ((pos (match-beginning 0)) (face (get-text-property pos 'face))) (should (equal (get-text-property pos 'ebox-content-owners) (list wrapper-id))) (should (ebox-test--face-has-key-value-p face :foreground "#334155")) (should (ebox-test--face-has-key-value-p face :background "#E2E8F0")))))))) (ert-deftest ebox-contiguous-replacement-rejects-noncommon-wrapper-paint () "Contiguous fallback should not spread paint across ownerless spans." (ebox-test--reset-runtime-state) (let* ((wrapper (ebox-create :content "" :color "#334155" :bgcolor "#E2E8F0")) (wrapper-id (car (ebox-region-ids wrapper)))) (puthash wrapper-id wrapper ebox--region-box-table) (with-temp-buffer (insert (propertize "owned" 'ebox-content-owners (list wrapper-id))) (insert "\nownerless\n") (let* ((spans (save-excursion (goto-char (point-min)) (list (cons (line-beginning-position) (line-end-position)) (progn (forward-line 1) (cons (line-beginning-position) (line-end-position)))))) (new-spans (ebox--replace-line-spans spans "new-a\nnew-b\nnew-c"))) (should (= (length new-spans) 3)) (goto-char (point-min)) (search-forward "new-c") (let ((pos (match-beginning 0))) (should-not (get-text-property pos 'ebox-content-owner)) (should-not (get-text-property pos 'ebox-content-owners)) (should-not (get-text-property pos 'face))))))) (ert-deftest ebox-replacement-boundary-scans-owner-runs-inside-long-span () "A long replacement span should classify owner runs below its boundary." (with-temp-buffer (insert (propertize "aaabbbccc" 'ebox-content-owners '(outer))) (let ((start (point-min)) (end (point-max))) (add-text-properties (+ start 3) (+ start 6) '(ebox-content-owners (old-leaf old-boundary outer))) (add-text-properties (+ start 6) end '(ebox-content-owners (other-leaf other-boundary outer))) (let ((owners (ebox-buffer--owners-inside-replacement-boundary (list (cons start end)) '(old-boundary)))) (should (member 'old-leaf owners)) (should (member 'old-boundary owners)) (should-not (member 'outer owners)) (should-not (member 'other-leaf owners)) (should-not (member 'other-boundary owners)))))) (ert-deftest ebox-contiguous-wrapper-preservation-keeps-horizontal-borders () "Contiguous line replacement should retain wrapper top and bottom borders." (ebox-test--reset-runtime-state) (with-temp-buffer (let* ((owner 'outer-wrapper) (ebox--region-box-table (make-hash-table :test 'equal)) first-start first-end second-start second-end) (puthash owner '(:border-top-color "#ff0000" :border-bottom-color "#0000ff") ebox--region-box-table) (setq first-start (point)) (insert (propertize "old top" 'ebox-content-owners (list owner) 'ebox-bt owner)) (setq first-end (point)) (insert "\n") (setq second-start (point)) (insert (propertize "old bottom" 'ebox-content-owners (list owner) 'ebox-bb owner)) (setq second-end (point)) (let* ((result (ebox--string-with-preserved-wrapper-owners (list (cons first-start first-end) (cons second-start second-end)) "new top\nnew middle\nnew bottom")) (middle-start (string-match "new middle" result)) (bottom-start (string-match "new bottom" result))) (should (equal (get-text-property 0 'ebox-bt result) owner)) (should-not (get-text-property middle-start 'ebox-bb result)) (should (equal (get-text-property bottom-start 'ebox-bb result) owner)) (should (equal (get-text-property middle-start 'ebox-content-owners result) (list owner))) (should (ebox-test--face-has-key-value-p (get-text-property 0 'face result) :overline "#ff0000")) (should (ebox-test--face-has-key-value-p (get-text-property bottom-start 'face result) :underline '(:position t :color "#0000ff"))))))) (ert-deftest ebox-contiguous-wrapper-expansion-splits-horizontal-borders () "Expanding one old line should keep top and bottom paint on opposite edges." (ebox-test--reset-runtime-state) (with-temp-buffer (let* ((owner 'single-line-wrapper) (ebox--region-box-table (make-hash-table :test 'equal)) (start (point))) (puthash owner '(:border-top-color "#ff0000" :border-bottom-color "#0000ff") ebox--region-box-table) (insert (propertize "old" 'ebox-content-owners (list owner) 'ebox-bt owner 'ebox-bb owner)) (let* ((result (ebox--string-with-preserved-wrapper-owners (list (cons start (point))) "new top\nnew bottom")) (bottom-start (string-match "new bottom" result)) (top-face (get-text-property 0 'face result)) (bottom-face (get-text-property bottom-start 'face result))) (should (equal (get-text-property 0 'ebox-bt result) owner)) (should-not (get-text-property 0 'ebox-bb result)) (should (equal (get-text-property bottom-start 'ebox-bb result) owner)) (should-not (get-text-property bottom-start 'ebox-bt result)) (should (ebox-test--face-has-key-p top-face :overline)) (should-not (ebox-test--face-has-key-p top-face :underline)) (should (ebox-test--face-has-key-p bottom-face :underline)) (should-not (ebox-test--face-has-key-p bottom-face :overline)))))) (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-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-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))) (full-snapshot-refresh-count 0) (original-refresh (symbol-function 'ebox--refresh-buffer-layout-snapshots))) (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) (lambda (&rest args) (cl-incf full-snapshot-refresh-count) (apply original-refresh args)))) (ebox-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 (plist-get report :scoped-index-refresh)) (should (= full-snapshot-refresh-count 0)) (let* ((node (ebox--buffer-region-render-owner-node (current-buffer) region-id)) (snapshot (ebox--ensure-layout-snapshot-spans (current-buffer) (ebox--ensure-node-id node)))) (should (ebox--layout-snapshot-spans-p snapshot)) (should (eq (plist-get (plist-get snapshot :style-signature) :overflow) 'visible))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-overflow-reversal-removes-visible-suffix () "Clipping a visible target should replace the ancestor-owned old suffix." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (760) (flex :id matrix :width (600) :height 5 :flex-flow (row nowrap) (box :id card :width (300) (box :id overflow :content "overflow target\nextra line\nanother line" :width (280) :height 2 :overflow scroll)) (box :id peer :content "P\nQ\nR\nS\nT" :width (300) :height 5)))))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#overflow")) (root-id (ebox--buffer-root-node-id (current-buffer))) coverage-owner-id) (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update region-id :overflow 'visible) (let ((report (ebox-test--buffer-update-report))) (setq coverage-owner-id (car (plist-get report :owner-ids))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should coverage-owner-id) (should-not (equal coverage-owner-id root-id)) (should (eq (plist-get report :owner-type) 'flex)) (should (= (plist-get report :span-count) 5)) (should-not (plist-get report :root-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p))) (ebox--clear-layout-snapshots (current-buffer)) (ebox-region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report)) (plain (buffer-substring-no-properties (point-min) (point-max)))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list coverage-owner-id))) (should (= (plist-get report :span-count) 5)) (should-not (plist-get report :root-rerender)) (should-not (string-match-p "another line" plain)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (should (= full-rerenders 0))))))) (ert-deftest ebox-overflow-reversal-skips-layout-only-ancestor () "Old visible suffix coverage should skip ancestors without wrapper ownership." (ebox-test--reset-runtime-state) (let* ((target (ebox-create :content "A\nB\nC" :height 1 :overflow 'visible)) (region-id (car (ebox-region-ids target))) (inner (ebox-stack target (ebox-create :content "TAIL"))) (layout (ebox-stack inner (ebox-create :content "ROOT TAIL"))) (inner-id (ebox--ensure-node-id inner)) (root-id (ebox--ensure-node-id layout))) (ebox-test--with-rendered-buffer layout (ebox--clear-layout-snapshots (current-buffer)) (ebox-region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report)) (plain (buffer-substring-no-properties (point-min) (point-max)))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should-not (member inner-id (plist-get report :owner-ids))) (should (equal (plist-get report :owner-ids) (list root-id))) (should-not (string-match-p "[BC]" plain)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-overflow-reversal-skips-visible-wrapper-ancestor () "A visible wrapper cannot cover its own old unowned overflow suffix." (ebox-test--reset-runtime-state) (let* ((target (ebox-create :content "A\nB\nC" :height 1 :overflow 'visible)) (region-id (car (ebox-region-ids target))) (wrapper (ebox-create :ebox-content-node target :width '(100) :height 1 :overflow 'visible)) (layout (ebox-stack wrapper (ebox-create :content "TAIL"))) (wrapper-id (ebox--ensure-node-id wrapper)) (root-id (ebox--ensure-node-id layout))) (ebox-test--with-rendered-buffer layout (ebox--clear-layout-snapshots (current-buffer)) (ebox-region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report)) (plain (buffer-substring-no-properties (point-min) (point-max)))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should-not (member wrapper-id (plist-get report :owner-ids))) (should (equal (plist-get report :owner-ids) (list root-id))) (should-not (string-match-p "[BC]" plain)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-overflow-coverage-crosses-enclosing-visible-wrapper () "Coverage should climb above an enclosing visible wrapper's old footprint." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (100) (box :id outer :width (100) :height 2 :overflow visible (box :id inner :width (100) (box :id target :content "A\nB\nC" :height 1 :overflow visible))) (box :content "TAIL"))))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) (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)) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox--clear-layout-snapshots (current-buffer)) (ebox-region-update region-id :overflow 'hidden) (let ((report (ebox-test--buffer-update-report))) (should (eq (ebox-get (ebox-fragment-style-source-node target-node) :overflow) 'hidden)) (should (eq (ebox-get (ebox-fragment-style-source-node outer-node) :overflow) 'visible)) (should (equal (plist-get report :owner-ids) (list root-id))) (should-not (plist-get report :flex-line-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-overflow-coverage-root-flex-replaces-whole-footprint () "Root flex fallback should bypass line rerender for visible overflow changes." (dolist (transition '((scroll visible) (visible hidden))) (ebox-test--reset-runtime-state) (let* ((old-overflow (car transition)) (new-overflow (cadr transition)) (layout (ebox-build `(flex :id root :width (200) (box :id target :content "A\nB\nC" :width (80) :height 1 :overflow ,old-overflow) (box :id peer :content "P" :width (80) :height 1))))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) (target-node (ebox--buffer-region-render-owner-node (current-buffer) region-id)) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox--clear-layout-snapshots (current-buffer)) (ebox-region-update region-id :overflow new-overflow) (let ((report (ebox-test--buffer-update-report))) (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-not (plist-get report :flex-line-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))))) (ert-deftest ebox-overflow-coverage-promotes-partial-flex-item () "Visible overflow changes should not preserve a flex item's stale cross size." (dolist (transition '((scroll visible) (visible hidden))) (ebox-test--reset-runtime-state) (let* ((old-overflow (car transition)) (new-overflow (cadr transition)) (layout (ebox-build `(flex :id root :width (220) (box :id card :width (100) (box :id target :content "A\nB\nC" :width (80) :height 1 :overflow ,old-overflow)) (box :id peer :content "P" :width (80) :height 1))))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) (target-node (ebox--buffer-region-render-owner-node (current-buffer) region-id)) (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-region-update region-id :overflow new-overflow) (let ((report (ebox-test--buffer-update-report))) (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-not (plist-get report :flex-line-rerender)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))))) (ert-deftest ebox-overflow-coverage-batch-preserves-old-style () "A batch should carry pre-mutation overflow into coverage planning." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (100) (box :id wrapper :width (100) (column (box :id target :content "A\nB\nC" :height 1 :overflow visible) (box :content "TAIL"))) (box :content "ROOT TAIL"))))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) (target-node (ebox--buffer-region-render-owner-node (current-buffer) region-id)) (wrapper-id (plist-get (car (ebox-selector-query-buffer (current-buffer) "#wrapper")) :node-id))) (ebox--clear-layout-snapshots (current-buffer)) (ebox-incremental-begin-batch (current-buffer)) (ebox-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))) (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 wrapper-id))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-overflow-coverage-multi-owner-batch-normalizes-scoped-snapshots () "Multiple owner replacements normalize shifted spans before scoped refresh." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (760) (flex :id top-matrix :width (600) :height 5 :flex-flow (row nowrap) (box :id top-card :width (300) (box :id top-target :content "top A\nsubstantially longer top overflow\ntop C" :width (280) :height 2 :overflow scroll)) (box :content "top P\ntop Q\ntop R\ntop S\ntop T" :width (300) :height 5)) (flex :id bottom-matrix :width (600) :height 5 :flex-flow (row nowrap) (box :id bottom-card :width (300) (box :id bottom-target :content "bottom A\nbottom overflow\nbottom C" :width (280) :height 2 :overflow scroll)) (box :content "bottom P\nbottom Q\nbottom R\nbottom S\nbottom T" :width (300) :height 5)))))) (ebox-test--with-rendered-buffer layout (let ((top-region-id (ebox-test--selector-region-id (current-buffer) "#top-target")) (bottom-region-id (ebox-test--selector-region-id (current-buffer) "#bottom-target")) (full-snapshot-refresh-count 0) (original-refresh (symbol-function 'ebox--refresh-buffer-layout-snapshots))) (ebox-incremental-begin-batch (current-buffer)) (ebox-region-update top-region-id :overflow 'visible) (ebox-region-update bottom-region-id :overflow 'visible) (cl-letf (((symbol-function 'ebox--refresh-buffer-layout-snapshots) (lambda (&rest args) (cl-incf full-snapshot-refresh-count) (apply original-refresh args)))) (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 (plist-get report :scoped-index-refresh)) (should (= full-snapshot-refresh-count 0)) (ebox--set-buffer-region-role-span-table (current-buffer) nil) (ebox-buffer-refresh-region-role-spans (current-buffer)) (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-region-update bottom-region-id :overflow 'hidden) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-span-patch-refreshes-descendant-snapshots () "Ancestor span patches should keep descendant buffer span snapshots current." (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)) (dense-index-builds 0) (eager-descendant-span-refreshes 0) (original-index-builder (symbol-function 'ebox--build-region-line-index-for-spans)) (original-descendant-refresh (symbol-function 'ebox--capture-layout-snapshot-spans-subtree))) (cl-letf (((symbol-function 'ebox--build-region-line-index-for-spans) (lambda (&rest args) (cl-incf dense-index-builds) (apply original-index-builder args))) ((symbol-function 'ebox--capture-layout-snapshot-spans-subtree) (lambda (&rest args) (cl-incf eager-descendant-span-refreshes) (apply original-descendant-refresh args)))) (ebox-region-update region-id :margin (list 0 '(8) 0 '(8)) :margin-inline '((8) (8)) :margin-block '(0 0) :margin-top 0 :margin-right '(8) :margin-bottom 0 :margin-left '(8))) (let* ((report (ebox-test--buffer-update-report)) (snapshot (ebox--ensure-layout-snapshot-spans (current-buffer) node-id)) (snapshot-spans (plist-get snapshot :buffer-spans)) (actual-spans (ebox--region-set-line-spans (list region-id)))) (should (eq (plist-get report :strategy) 'span-patch)) (should (= dense-index-builds 0)) (should (= eager-descendant-span-refreshes 0)) (should (equal snapshot-spans actual-spans)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-sequential-sibling-span-patches-invalidate-stale-coordinates () "Text-changing sibling patches should not reuse translated snapshot spans." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (120) (box :id a :content "A" :width (120) :height 1) (box :id b :content "B" :width (120) :height 1))))) (ebox-test--with-rendered-buffer layout (let* ((a-handle (car (ebox-selector-query-buffer (current-buffer) "#a"))) (b-handle (car (ebox-selector-query-buffer (current-buffer) "#b"))) (a-region-id (plist-get a-handle :region-id)) (b-region-id (plist-get b-handle :region-id)) (b-node-id (plist-get b-handle :node-id)) sibling-snapshot-current-p reports) (ebox-region-update b-region-id :content "BB") (push (ebox-test--buffer-update-report) reports) (ebox-region-update a-region-id :content "AAAA") (push (ebox-test--buffer-update-report) reports) (let* ((snapshot (ebox--layout-snapshot (current-buffer) b-node-id)) (snapshot-spans (plist-get snapshot :buffer-spans)) (actual-spans (ebox--region-set-line-spans (list b-region-id)))) (setq sibling-snapshot-current-p (or (not (plist-member snapshot :buffer-spans)) (equal snapshot-spans actual-spans)))) (ebox-region-update b-region-id :content "CCC") (push (ebox-test--buffer-update-report) reports) (dolist (report reports) (should (eq (plist-get report :strategy) 'span-patch))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should sibling-snapshot-current-p))))) (ert-deftest ebox-flex-direct-item-inline-margin-rerenders-flex-owner () "Direct flex item margin changes should patch the flex line owner." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(flex :id matrix :width (260) :flex-flow (row nowrap) :column-gap (0) (box :id target :content "A" :width (80) :height 1 :border "#999999") (box :id peer :content "B" :width (80) :height 1 :border "#999999"))))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) (ebox-region-update region-id :margin-left '(30) :margin-right '(30)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :owner-type) 'flex)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-flex-child-width-expansion-reflows-ancestor-after-span-fallback () "Failed promoted span patches must fall back to a layout-safe ancestor." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(flex :id matrix :width (680) :flex-flow (row wrap) :column-gap (12) :row-gap 1 (column :width (210) :border "#999999" (box :content "Title A" :width (190)) (box :id target :content "width target\nmoves sibling layout" :width (160) :height 3 :padding (0 (8)) :border "#999999")) (column :width (210) :border "#999999" (box :content "Title B" :width (190)) (box :content "Still here" :width (190))) (column :width (210) :border "#999999" (box :content "Title C" :width (190)) (box :content "Third card" :width (190))))))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#target"))) (region-id (plist-get match :region-id))) (ebox-region-update region-id :width '(250)) (let* ((patched (buffer-string)) (full (ebox--with-buffer-render-context (current-buffer) (ebox-render (ebox--buffer-root-node (current-buffer)))))) (should (string= (substring-no-properties patched) (substring-no-properties full)))))))) (ert-deftest ebox-flex-shrunk-definite-item-content-update-keeps-current-slot () "A definite 520x4 item may update inside its published shrunk slot." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 500) (layout (ebox-build '(flex :id root :width (500) :height 4 :flex-flow (row nowrap) :align-items flex-start (box :id fixed :content "peer" :width (320) :height 4 :flex-grow 0 :flex-shrink 0) (box :id target :content "READY" :width (520) :height 4 :overflow hidden :wrap-mode word))))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#target"))) (region-id (plist-get match :region-id)) (node-id (plist-get match :node-id)) (node (ebox--buffer-runtime-node (current-buffer) node-id)) (snapshot (ebox--ensure-layout-snapshot-details (current-buffer) node-id)) (old-widths (plist-get (plist-get snapshot :span-footprint-signature) :line-pixel-widths))) (should (= (length old-widths) 4)) (should (< (apply #'max old-widths) 520)) ;; Flex measurement may have rendered a temporary sized copy. Keep ;; this regression focused on the current-slot update contract. (puthash region-id node ebox--region-box-table) (ebox-region-update region-id :content "DONE") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :owner-ids) (list node-id))) (should-not (plist-get report :full-rerender))) (should (string-match-p "DONE" (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-flex-auto-size-content-update-refuses-current-slot () "Auto-sized content must reflow instead of being forced into its old slot." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 300) (layout (ebox-build '(flex :id root :width (300) :height 1 :flex-flow (row nowrap) :align-items flex-start (box :id target :content "A" :width auto :height 1 :overflow hidden :wrap-mode word) (box :id peer :content "P" :width (40) :height 1 :flex-grow 0 :flex-shrink 0))))) (ebox-test--with-rendered-buffer layout (let* ((match (car (ebox-selector-query-buffer (current-buffer) "#target"))) (region-id (plist-get match :region-id)) (node-id (plist-get match :node-id)) (node (ebox--buffer-runtime-node (current-buffer) node-id)) (snapshot (ebox--ensure-layout-snapshot-details (current-buffer) node-id))) (puthash region-id node ebox--region-box-table) (should-not (ebox--render-node-in-current-flex-slot (current-buffer) node-id snapshot '(:content))) (ebox-region-update region-id :content "HELLO") (let ((report (ebox-test--buffer-update-report))) (should-not (and (eq (plist-get report :strategy) 'span-patch) (equal (plist-get report :owner-ids) (list node-id))))) (should (string-match-p "HELLO" (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-buffer-update-report-describes-span-patch () "The update report should expose the actual minimal patch strategy." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Trace" :width 80 :height 1)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :content "Patch") (let ((report (ebox-test--buffer-update-report))) (should (equal (plist-get report :region-id) region-id)) (should (eq (plist-get report :strategy) 'span-patch)) (should (eq (plist-get report :owner-type) 'box)) (should (eq (plist-get report :full-rerender) nil)) (should (> (plist-get report :span-count) 0)) (should (equal (plist-get report :old-signature) (plist-get report :new-signature))) (should (plist-get report :old-span-footprint-signature)) (should (plist-get report :old-external-footprint-signature)) (should (equal (plist-get report :old-span-footprint-signature) (plist-get report :new-span-footprint-signature))) (should (equal (plist-get report :old-external-footprint-signature) (plist-get report :new-external-footprint-signature))))))) (ert-deftest ebox-buffer-update-report-describes-owner-rerender-fallback () "The update report should expose geometry owner rerender fallback." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "L" :width 40)) (right (ebox-create :content "R" :width 40)) (layout (ebox-concat left right)) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-region-update region-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (equal (plist-get report :region-id) region-id)) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (eq (plist-get report :owner-type) 'concat)) (should (eq (plist-get report :full-rerender) nil)))))) (ert-deftest ebox-region-update-stable-geometry-uses-span-patch () "Geometry updates with stable span footprint should use the L2 planner path." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Align" :width '(120) :height 1 :text-align 'left)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :text-align 'center) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :patch-ops) '(span-patch))) (should (equal (plist-get report :dirty-kinds) '(geometry))) (should (eq (plist-get report :owner-type) 'box)) (should-not (plist-get report :full-rerender)))))) (ert-deftest ebox-region-update-parent-layout-change-keeps-owner-rerender () "Geometry updates that move siblings should not be forced into span-patch." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "L" :width '(40))) (right (ebox-create :content "R" :width '(40))) (layout (ebox-concat left right)) (region-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-region-update region-id :width '(120)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :patch-ops) '(owner-rerender))) (should (eq (plist-get report :owner-type) 'concat)) (should-not (plist-get report :full-rerender)))))) (ert-deftest ebox-region-update-inline-child-stable-geometry-uses-span-patch () "Stable internal changes inside an inline child should not rerender siblings." (ebox-test--reset-runtime-state) (let* ((left (ebox-create :content "Left" :width '(80) :height 1 :text-align 'left)) (right (ebox-create :content "Right" :width '(80) :height 1)) (layout (ebox-concat left right)) (left-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (ebox-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 (plist-get report :old-parent-slot-signature)) (should (equal (plist-get report :old-parent-slot-signature) (plist-get report :new-parent-slot-signature))) (should-not (plist-get report :full-rerender))) (should (string-match-p "Right" (buffer-substring-no-properties (point-min) (point-max))))))) (ert-deftest ebox-region-update-border-box-inline-change-uses-span-patch () "Border-box left/right spacing can be internal when outer width is fixed." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Pad" :width '(120) :height 1 :box-sizing 'border-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :padding-left-pixel 8) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (equal (plist-get report :patch-ops) '(span-patch))) (should-not (plist-get report :full-rerender)))))) (ert-deftest ebox-region-update-content-box-inline-change-rerenders-owner () "Content-box left/right spacing changes the parent-facing outer width." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Pad" :width '(120) :height 1 :box-sizing 'content-box)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :padding-left-pixel 8) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :patch-ops) '(owner-rerender))) (should-not (plist-get report :full-rerender)))))) (ert-deftest ebox-flex-item-box-sizing-update-rerenders-contained-flex () "Changing a flex item's box model should rerun its parent's placement." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (760) (flex :id outer :width (600) :height 5 :flex-flow (row nowrap) (box :id card :width (300) (flex :id inner :width (300) :height 5 :padding (0 (10)) :border "#6F6A95" :justify-content center :align-items center (box :id target :content "A\nB" :width (230) :height 3 :padding (0 (10)) :border "#999999" :box-sizing content-box))) (box :id peer :content "P\nQ\nR\nS\nT" :width (300) :height 5)))))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) (target-node (ebox--buffer-region-render-owner-node (current-buffer) region-id)) (flex-id (ebox--runtime-parent-id (current-buffer) (ebox--ensure-node-id target-node))) (root-id (ebox--buffer-root-node-id (current-buffer)))) (ebox-region-update region-id :box-sizing 'border-box) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list flex-id))) (should (eq (plist-get report :owner-type) 'flex)) (should (= (plist-get report :span-count) 5)) (should (plist-get report :slot-preserving)) (should-not (member root-id (plist-get report :owner-ids))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-flex-item-block-size-updates-use-fixed-containment-owner () "Block-size updates should skip flex-slot proof below fixed containment." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id root :width (760) (flex :id outer :width (600) :height 5 :flex-flow (row nowrap) (box :id card :width (300) (flex :id inner :width (300) :height 5 :padding (0 (10)) :border "#6F6A95" :justify-content center :align-items center (box :id target :content "A\nB\nC\nD" :width (230) :height 2 :overflow scroll :padding (0 (10)) :border "#999999"))) (box :id peer :content "P\nQ\nR\nS\nT" :width (300) :height 5)))))) (ebox-test--with-rendered-buffer layout (let* ((region-id (ebox-test--selector-region-id (current-buffer) "#target")) (target-node (ebox--buffer-region-render-owner-node (current-buffer) region-id)) (flex-id (ebox--runtime-parent-id (current-buffer) (ebox--ensure-node-id target-node))) (root-id (ebox--buffer-root-node-id (current-buffer))) (slot-proof-count 0) (original-slot-proof (symbol-function 'ebox--flex-item-slot-footprint-safe-p))) (cl-letf (((symbol-function 'ebox--flex-item-slot-footprint-safe-p) (lambda (&rest args) (cl-incf slot-proof-count) (apply original-slot-proof args)))) (dolist (change '((:height 4) (:min-height 3) (:max-height 1))) (ebox-region-update region-id (car change) (cadr change)) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender))) (should (equal (plist-get report :owner-ids) (list flex-id))) (should (eq (plist-get report :owner-type) 'flex)) (should (= (plist-get report :span-count) 5)) (should-not (member root-id (plist-get report :owner-ids))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (should (= slot-proof-count 0)))))) (ert-deftest ebox-region-update-span-candidate-falls-back-on-wrap-change () "A span candidate should degrade to owner rerender when footprint changes." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "abcdefghijklmnopqrstuvwxyz" :width '(120) :box-sizing 'border-box :wrap-mode 'char)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :padding-left-pixel 100) (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-matrix-covers-all-box-longhands () "The update tests should classify every non-content box longhand." (let ((covered (append (ebox-test--region-update-case-properties ebox-test--region-update-paint-cases) (ebox-test--region-update-case-properties ebox-test--region-update-geometry-cases)))) (should-not (cl-set-difference ebox--region-update-longhand-props covered :test #'eq)) (should-not (cl-set-difference covered ebox--region-update-longhand-props :test #'eq)) (should-not (cl-set-difference ebox--paint-style-signature-keys (ebox-test--region-update-case-properties ebox-test--region-update-paint-cases) :test #'eq)))) (ert-deftest ebox-region-update-all-paint-longhands-use-paint-patch () "Every paint longhand should patch text properties without replacing spans." (dolist (case ebox-test--region-update-paint-cases) (pcase-let ((`(,property ,value ,expected) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Paint" :width '(120) :height 1)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((replace-count 0) (original-replace (symbol-function 'ebox--replace-line-spans))) (cl-letf (((symbol-function 'ebox--replace-line-spans) (lambda (&rest args) (cl-incf replace-count) (apply original-replace args)))) (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update region-id property value)))) (should (= full-rerenders 0)))) (should (= replace-count 0))) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) (should (eq (gethash region-id ebox--region-box-table) runtime-box)) (should-not (eq runtime-box box)) (should (equal (ebox-get runtime-box property) expected))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (equal (plist-get report :patch-ops) '(paint-patch))) (should (equal (plist-get report :dirty-kinds) '(paint))) (should-not (plist-get report :full-rerender))))))))) (ert-deftest ebox-region-update-all-geometry-longhands-use-minimal-strategy () "Every geometry longhand should use its expected minimal non-root strategy." (dolist (case ebox-test--region-update-geometry-cases) (pcase-let ((`(,property ,value ,expected ,strategy) case)) (ert-info ((format "property %S" property)) (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Alpha\nBeta\nGamma\nDelta" :width '(160) :height 2 :overflow 'scroll :wrap-mode 'word)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update region-id property value)))) (should (= full-rerenders 0))) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) (should (eq (gethash region-id ebox--region-box-table) runtime-box)) (should-not (eq runtime-box box)) (should (equal (ebox-get runtime-box property) expected))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) strategy)) (should (equal (plist-get report :patch-ops) (list strategy))) (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-bgcolor-update-mutates-box-and-keeps-content () (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Color" :width 60 :bgcolor "#111111")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :bgcolor "#222222") (should (equal (ebox-get box :bgcolor) "#111111")) (should (equal (ebox-get (gethash region-id (ebox--buffer-region-box-table (current-buffer))) :bgcolor) "#222222")) (should (string-match-p "Color" (buffer-substring-no-properties (point-min) (point-max))))))) (ert-deftest ebox-background-color-creation-matches-bgcolor-updates () "Background color spellings should share one engine longhand and paint path." (ebox-test--reset-runtime-state) (let* ((background-box (ebox-create :content "Color" :width 60 :background-color "#111111")) (bgcolor-box (ebox-create :content "Color" :width 60 :bgcolor "#111111")) (background-face (ebox-test--first-content-face (ebox-render background-box))) (bgcolor-face (ebox-test--first-content-face (ebox-render bgcolor-box)))) (should (equal (ebox-get background-box :bgcolor) "#111111")) (should (equal (ebox-get bgcolor-box :bgcolor) "#111111")) (should-not (plist-member background-box :background-color)) (should (equal background-face bgcolor-face)) (should (ebox-test--face-has-key-value-p background-face :background "#111111")) (let ((region-id (car (ebox-region-ids background-box)))) (ebox-test--with-rendered-buffer background-box (ebox-region-update region-id :background-color "#222222") (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) (should (equal (ebox-get background-box :bgcolor) "#111111")) (should (equal (ebox-get runtime-box :bgcolor) "#222222")) (should-not (plist-member runtime-box :background-color))) (should-not (plist-member background-box :background-color)) (should (ebox-test--face-has-key-value-p (ebox-test--first-content-face (buffer-string)) :background "#222222")) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (equal (plist-get report :patch-ops) '(paint-patch))) (should (equal (plist-get report :dirty-kinds) '(paint))) (should-not (plist-get report :full-rerender))))))) (ert-deftest ebox-vertical-align-middle-alias-matches-center () "Creation and updates should normalize middle to center before rendering." (ebox-test--reset-runtime-state) (let ((middle (ebox-create :content "X" :height 3 :vertical-align 'middle))) (should (equal (ebox--expand-property :vertical-align 'middle) '(:vertical-align center))) (should (eq (ebox-get middle :vertical-align) 'center)) (should (= (cl-position-if (lambda (line) (string-match-p "X" (substring-no-properties line))) (ebox-string-lines (ebox-render middle))) 1)) (let* ((updated (ebox-create :content "X" :height 3 :vertical-align 'top)) (region-id (car (ebox-region-ids updated)))) (ebox-test--with-rendered-buffer updated (goto-char (point-min)) (search-forward "X") (should (= (line-number-at-pos (match-beginning 0)) 1)) (ebox-region-update region-id :vertical-align 'middle) (should (eq (ebox-get updated :vertical-align) 'top)) (should (eq (ebox-get (gethash region-id (ebox--buffer-region-box-table (current-buffer))) :vertical-align) 'center)) (goto-char (point-min)) (search-forward "X") (should (= (line-number-at-pos (match-beginning 0)) 2)) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'span-patch)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-update-reuses-shorthand-property-expansion () (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Box" :width 60)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :padding '(1 2) :border '(2 "#abcdef") :margin-inline 3) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) (should (= (ebox-get box :padding-top-height) 0)) (should (= (ebox-get runtime-box :padding-top-height) 1)) (should (= (ebox-get runtime-box :padding-right-pixel) (* 2 (ebox--space-pixel-width)))) (should (= (ebox-get runtime-box :border-left-pixel) (* 2 (ebox--space-pixel-width)))) (should (equal (ebox-get runtime-box :border-right-color) "#abcdef")) (should (= (ebox-get runtime-box :margin-left-pixel) (* 3 (ebox--space-pixel-width)))))))) (ert-deftest ebox-region-update-adds-absent-shorthand-properties-incrementally () "Shorthand updates should locate the same box and patch without root rebuilds." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Absent properties" :width '(160))) (region-id (car (ebox-region-ids box))) (ids-before (ebox-region-ids box))) (ebox-test--with-rendered-buffer box (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update region-id :padding '(1 2) :border '(1 "#abcdef") :margin-inline 3)))) (should (= full-rerenders 0))) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) (should (eq (gethash region-id ebox--region-box-table) runtime-box)) (should-not (eq runtime-box box)) (should (equal (ebox-region-ids box) ids-before)) (should (= (ebox-get box :padding-top-height) 0)) (should (= (ebox-get box :padding-right-pixel) 0)) (should (= (ebox-get box :border-left-pixel) 0)) (should (null (ebox-get box :border-right-color))) (should (= (ebox-get box :margin-left-pixel) 0)) (should (= (ebox-get runtime-box :padding-top-height) 1)) (should (= (ebox-get runtime-box :padding-right-pixel) (* 2 (ebox--space-pixel-width)))) (should (= (ebox-get runtime-box :border-left-pixel) (ebox--space-pixel-width))) (should (equal (ebox-get runtime-box :border-right-color) "#abcdef")) (should (= (ebox-get runtime-box :margin-left-pixel) (* 3 (ebox--space-pixel-width))))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :patch-ops) '(owner-rerender))) (should-not (plist-get report :full-rerender))) (let ((replace-count 0) (original-replace (symbol-function 'ebox--replace-line-spans))) (cl-letf (((symbol-function 'ebox--replace-line-spans) (lambda (&rest args) (cl-incf replace-count) (apply original-replace args)))) (let ((full-rerenders (ebox-test--count-full-rerenders (ebox-region-update region-id :background-color "#fed7aa")))) (should (= full-rerenders 0)))) (should (= replace-count 0))) (should (null (ebox-get box :bgcolor))) (should (equal (ebox-get (gethash region-id (ebox--buffer-region-box-table (current-buffer))) :bgcolor) "#fed7aa")) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (equal (plist-get report :patch-ops) '(paint-patch))) (should-not (plist-get report :full-rerender)))))) (ert-deftest ebox-color-and-visibility-are-real-and-display-is-ignored () (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Paint" :color "#eeeeee" :display 'none :visibility 'hidden)) (region-id (car (ebox-region-ids box)))) (should (equal (ebox-get box :color) "#eeeeee")) (should (equal (ebox--computed-display box) '(block flow))) (should (eq (ebox-get box :visibility) 'hidden)) (ebox-test--with-rendered-buffer box ;; Hidden at mount: the text is present but carries no face ink. (let ((position (string-match "Paint" (buffer-substring-no-properties (point-min) (point-max))))) (should position) (should-not (get-text-property (+ (point-min) position) 'face))) (ebox-region-update region-id :color "#123456" :display 'block :visibility 'visible) (let ((runtime-box (gethash region-id (ebox--buffer-region-box-table (current-buffer))))) (should (equal (ebox-get box :color) "#eeeeee")) (should (equal (ebox-get runtime-box :color) "#123456")) (should (eq (ebox-get runtime-box :visibility) 'visible))) (should (equal (ebox--computed-display box) '(block flow))) (should (eq (ebox-get box :visibility) 'hidden)) (let ((position (string-match "Paint" (buffer-substring-no-properties (point-min) (point-max))))) (should position) (should (get-text-property (+ (point-min) position) 'face)))))) (ert-deftest ebox-row-column-and-spacer-compose-layouts () (ebox-test--reset-runtime-state) (let* ((a (ebox-create :content "A" :width 40)) (b (ebox-create :content "B" :width 40)) (c (ebox-create :content "C" :width 40)) (row (ebox-row a nil b c)) (column (ebox-column a b nil c)) (spacer (ebox-spacer :width 20 :height 2))) (should (equal (ebox-region-ids row) '(1 2 3))) (should (equal (ebox-region-ids column) '(1 2 3))) (should (eq (plist-get spacer :ebox-type) 'box)) (should (string-match-p "A.*B.*C" (substring-no-properties (ebox-render row)))) (should (string-match-p "A[^\n]*\nB[^\n]*\nC" (substring-no-properties (ebox-render column)))))) (ert-deftest ebox-row-and-column-store-children-as-flat-containers () "Row and column should be one container node with direct children." (ebox-test--reset-runtime-state) (let* ((children (cl-loop for label in '("A" "B" "C" "D") collect (ebox-create :content label :width 40))) (row (apply #'ebox-row children)) (column (apply #'ebox-column children))) (should (= (length (ebox--node-children row)) 4)) (should (= (length (ebox--node-children column)) 4)) (should (= (length (ebox--runtime-node-ids row)) 5)) (should (= (length (ebox--runtime-node-ids column)) 5)))) (ert-deftest ebox-overflow-hidden-clips-extra-content-lines () (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :height 1 :overflow 'hidden)) (plain (substring-no-properties (ebox-render box)))) (should (string-match-p "A" plain)) (should-not (string-match-p "B" plain)) (should-not (string-match-p "C" plain)) (should (= (ebox-string-height plain) 1)))) (ert-deftest ebox-overflow-visible-appends-extra-content-lines () (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :height 1 :overflow 'visible)) (plain (substring-no-properties (ebox-render box)))) (should (string-match-p "A" plain)) (should (string-match-p "B" plain)) (should (string-match-p "C" plain)) (should (= (ebox-string-height plain) 3)))) (defun ebox-test--assert-visible-overflow-paint (rendered foreground background) "Assert RENDERED keeps visible overflow painted but unowned." (let* ((lines (ebox-string-lines rendered)) (body (car lines)) (overflow-lines (cdr lines)) (ownership-properties (append '(ebox-content-idx ebox-content-owners) (mapcar #'cdr ebox-region-types))) (body-pos (cl-position ?A body))) (should (= (length lines) 3)) (should (integerp body-pos)) (let ((body-face (get-text-property body-pos 'face body))) (should (ebox-test--face-has-key-value-p body-face :foreground foreground)) (should (ebox-test--face-has-key-value-p body-face :background background))) (cl-loop for line in overflow-lines for glyph in '(?B ?C) do (let ((glyph-pos (cl-position glyph line))) (should (integerp glyph-pos)) (should (ebox-test--face-has-key-value-p (get-text-property glyph-pos 'face line) :foreground foreground))) (dolist (offset-pos (list 0 (1- (length line)))) (should (get-text-property offset-pos 'display line)) (should-not (ebox-test--face-has-key-p (get-text-property offset-pos 'face line) :foreground))) (dotimes (pos (length line)) (should-not (ebox-test--face-has-key-p (get-text-property pos 'face line) :background)) (dolist (property ownership-properties) (should-not (get-text-property pos property line))))))) (ert-deftest ebox-overflow-visible-paints-foreground-without-ownership () "Visible overflow should inherit only the outer box foreground." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :width '(40) :height 1 :overflow 'visible :color "#abcdef" :bgcolor "#123456" :padding-inline '(6) :margin-inline '(4)))) (ebox-test--assert-visible-overflow-paint (ebox-render box) "#abcdef" "#123456"))) (ert-deftest ebox-region-update-refreshes-visible-overflow-foreground () "Color updates should refresh visible overflow without owning its suffix." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :width '(40) :height 1 :overflow 'visible :color "#abcdef" :bgcolor "#123456" :padding-inline '(6) :margin-inline '(4))) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :color "#fedcba") (ebox-test--assert-visible-overflow-paint (buffer-string) "#fedcba" "#123456")))) (ert-deftest ebox-owner-rerender-keeps-visible-overflow-unowned () "Owner rerenders should not restore box ownership to visible overflow." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :width '(40) :height 1 :overflow 'scroll :color "#abcdef" :bgcolor "#123456")) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (ebox-region-update region-id :overflow 'visible) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'owner-rerender)) (goto-char (point-min)) (search-forward "C") (let* ((pos (match-beginning 0)) (face (get-text-property pos 'face))) (should (ebox-test--face-has-key-value-p face :foreground "#abcdef")) (should-not (ebox-test--face-has-key-value-p face :background "#123456")) (should-not (get-text-property pos 'ebox-content-owner)) (should-not (get-text-property pos 'ebox-content-owners)))))) (ert-deftest ebox-overflow-scroll-keeps-window-height-and-state () (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "A" plain)) (should-not (string-match-p "B" plain)) (should (= (ebox-string-height plain) 1))) (should (equal (plist-get (ebox--scroll-get-state region-id) :content-height) 1)) (goto-char (point-min)) (ebox-scroll-down 1) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "B" plain)) (should-not (string-match-p "A" plain)) (should (= (ebox-string-height plain) 1)))))) (ert-deftest ebox-scroll-falls-back-when-region-cannot-scroll () "Scroll commands should let the buffer move when the box has no scroll work." (ebox-test--reset-runtime-state) (with-temp-buffer (insert (ebox-render (ebox-create :content "short" :height 1))) (insert "\n") (dotimes (index 40) (insert (format "tail-%02d\n" index))) (goto-char (point-min)) (should (get-text-property (point) 'ebox-content)) (let (fallback-called) (cl-letf (((symbol-function 'scroll-up) (lambda (&optional _arg) (setq fallback-called t)))) (ebox-scroll-down 1)) (should fallback-called)))) (ert-deftest ebox-scroll-bubbles-from-child-to-scrollable-ancestor () "Key scrolling over a child box should scroll the nearest scrollable ancestor." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :id "child" :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1))))) (ids (ebox-region-ids layout)) (root-id (car ids))) (ebox-test--with-rendered-buffer layout (goto-char (point-min)) (search-forward "A") (backward-char) (should-not (equal (get-text-property (point) 'ebox-content) root-id)) (let (fallback-called) (cl-letf (((symbol-function 'scroll-up) (lambda (&optional _arg) (setq fallback-called t)))) (ebox-scroll-down 1)) (should-not fallback-called)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "B" plain)) (should (string-match-p "C" plain)))))) (ert-deftest ebox-wheel-scroll-falls-back-to-default-mwheel () "Mouse wheel fallback should preserve Emacs' normal scroll behavior." (ebox-test--reset-runtime-state) (let (mwheel-args scroll-up-called) (cl-letf (((symbol-function 'mwheel-scroll) (lambda (&rest args) (setq mwheel-args args))) ((symbol-function 'scroll-up) (lambda (&optional _arg) (setq scroll-up-called t)))) (ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg)) (should (equal mwheel-args '(fake-wheel-event fake-arg))) (should-not scroll-up-called))) (ert-deftest ebox-wheel-scroll-falls-back-for-non-scrollable-region () "Mouse wheel over a normal box should keep default buffer scrolling." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "short" :height 1)) (region-id (car (ebox-region-ids box))) mwheel-args) (ebox-test--with-rendered-buffer box (cl-letf (((symbol-function 'ebox--wheel-region-ids) (lambda (_event) (list region-id))) ((symbol-function 'mwheel-scroll) (lambda (&rest args) (setq mwheel-args args)))) (ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg)) (should (equal mwheel-args '(fake-wheel-event fake-arg)))))) (ert-deftest ebox-wheel-scroll-consumes-scrollable-region () "Mouse wheel should still scroll an overflow='scroll box when it can." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) mwheel-called) (ebox-test--with-rendered-buffer box (cl-letf (((symbol-function 'ebox--wheel-region-ids) (lambda (_event) (list region-id))) ((symbol-function 'mwheel-scroll) (lambda (&rest _args) (setq mwheel-called t)))) (ebox--wheel-scroll 'fake-wheel-event 1)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "B" plain)) (should-not (string-match-p "A" plain))) (should-not mwheel-called)))) (ert-deftest ebox-wheel-scroll-bubbles-from-child-to-scrollable-ancestor () "Mouse wheel scrolling over a child box should scroll its scrollable ancestor." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :id "child" :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1))))) (ids (ebox-region-ids layout)) (root-id (car ids)) (child-id (cadr ids)) mwheel-called) (ebox-test--with-rendered-buffer layout (cl-letf (((symbol-function 'ebox--wheel-region-ids) (lambda (_event) (list child-id root-id))) ((symbol-function 'mwheel-scroll) (lambda (&rest _args) (setq mwheel-called t)))) (ebox--wheel-scroll 'fake-wheel-event 1)) (should-not mwheel-called) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "B" plain)) (should (string-match-p "C" plain)))))) (ert-deftest ebox-scroll-state-is-created-only-for-scrollable-boxes () "Non-overflowing boxes should not inflate the global scroll hit-test table." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(column (box :id "short-a" :content "A" :height 1) (box :id "short-b" :content "B" :height 1) (box :id "root" :height 1 :overflow scroll (column (box :content "C" :height 1) (box :content "D" :height 1)))))) root-id short-a-id short-b-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (setq short-a-id (ebox-test--selector-region-id (current-buffer) "#short-a")) (setq short-b-id (ebox-test--selector-region-id (current-buffer) "#short-b")) (should-not (ebox--scroll-get-state short-a-id)) (should-not (ebox--scroll-get-state short-b-id)) (should (ebox--scroll-get-state root-id))))) (ert-deftest ebox-scroll-hit-test-uses-owner-stack-before-extents-scan () "Wheel hit testing should avoid a table-wide extents scan when owners suffice." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 1 :overflow scroll (column (box :id "child" :content "A" :height 1) (box :content "B" :height 1))))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (goto-char (point-min)) (cl-letf (((symbol-function 'ebox--scroll-region-ids-in-extents-at-pos) (lambda (&rest _args) (error "unexpected extents scan")))) (should (member root-id (ebox--scroll-region-ids-at-pos (point)))))))) (ert-deftest ebox-scroll-rendered-content-window-avoids-rerender-path () "Complex scroll boxes should patch visible rendered lines without rerendering." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 3 :overflow scroll (column (box :content "row-1" :height 1) (box :content "row-2" :height 1) (box :content "row-3" :height 1) (box :content "row-4" :height 1) (box :content "row-5" :height 1))))) root-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (let ((state (ebox--scroll-get-state root-id))) (should (plist-get state :rendered-content-lines)) (should (ebox--scroll-chrome-free-rendered-window-p (plist-get state :box)))) (cl-letf (((symbol-function 'ebox--scroll-replace-rendered-window) (lambda (&rest _args) (error "unexpected rendered-window fallback"))) ((symbol-function 'ebox--render-scroll-state-window) (lambda (&rest _args) (error "unexpected scroll-state render")))) (should (ebox--scroll-region-by root-id 2))) (let ((state (ebox--scroll-get-state root-id)) (plain (buffer-substring-no-properties (point-min) (point-max)))) (should (= (plist-get state :scroll-offset) 2)) (should (eq (plist-get state :buffer) (current-buffer))) (should (plist-get state :rendered-window-start-marker)) (should (plist-get state :rendered-window-end-marker)) (should (= (plist-get state :rendered-window-line-count) (plist-get state :content-height))) (should-not (plist-get state :content-span-markers)) (should-not (plist-get state :content-window-region-id-set)) (should (string-match-p "row-3" plain)) (should (string-match-p "row-5" plain)) (should-not (string-match-p "row-1" plain)))))) (ert-deftest ebox-chrome-free-scroll-render-reuses-propertized-prefix-lines () "A chrome-free scroll box should not propertize its visible prefix twice." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "row-1\nrow-2\nrow-3" :width 120 :height 2 :overflow 'scroll :color "#112233" :bgcolor "#ddeeff")) (region-id (ebox--ensure-region-id box)) (reference (ebox-create :content "row-1\nrow-2\nrow-3" :width 120 :height 2 :overflow 'hidden :color "#112233" :bgcolor "#ddeeff")) (original (symbol-function 'ebox--propertize-content-line)) (root-propertize-count 0) actual) (plist-put reference :region-id region-id) (cl-letf (((symbol-function 'ebox--propertize-content-line) (lambda (line owner idx fallback) (when (equal owner region-id) (cl-incf root-propertize-count)) (funcall original line owner idx fallback)))) (setq actual (ebox-render box))) (should (= root-propertize-count 3)) (should-not (text-property-not-all 0 (length actual) 'ebox-scroll-window region-id actual)) (let ((actual-without-window-marker (copy-sequence actual))) (remove-text-properties 0 (length actual-without-window-marker) '(ebox-scroll-window nil) actual-without-window-marker) (should (equal-including-properties actual-without-window-marker (ebox-render reference)))))) (ert-deftest ebox-underfilled-chrome-free-scroll-fuses-line-finishing-passes () "A complete top-aligned underfill should keep exact output in one line pass." (ebox-test--reset-runtime-state) (let* ((content (concat (propertize "alpha" 'face '(:weight bold) 'help-echo "rich") "\n \nomega")) (box (ebox-create :content content :width 140 :height 5 :overflow 'scroll :vertical-align 'top :color "#112233" :bgcolor "#ddeeff")) (region-id (ebox--ensure-region-id box)) (reference (ebox-create :content content :width 140 :height 5 :overflow 'hidden :vertical-align 'top :color "#112233" :bgcolor "#ddeeff")) (expected (progn (plist-put reference :region-id region-id) (ebox-render reference))) (original-align (symbol-function 'ebox--lines-align-vertical)) (original-maplines (symbol-function 'ebox--maplines)) (align-calls 0) (mapline-calls 0) actual) (cl-letf (((symbol-function 'ebox--lines-align-vertical) (lambda (&rest args) (cl-incf align-calls) (apply original-align args))) ((symbol-function 'ebox--maplines) (lambda (&rest args) (cl-incf mapline-calls) (apply original-maplines args)))) (setq actual (ebox-render box))) (should (= align-calls 0)) ;; Formatting may still justify the source once; finishing must not add ;; the former blank-normalization and color map passes. (should (<= mapline-calls 1)) (should (equal-including-properties actual expected)) (should-not (ebox--scroll-get-state region-id)))) (ert-deftest ebox-scroll-rendered-content-window-builds-spans-linearly () "Rendered viewport replacement should not rescan from the start per line." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 3 :overflow scroll (column (box :content "row-1" :height 1) (box :content "row-2" :height 1) (box :content "row-3" :height 1) (box :content "row-4" :height 1) (box :content "row-5" :height 1) (box :content "row-6" :height 1))))) root-id (forward-line-count 0) (original-forward-line (symbol-function 'forward-line))) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (let ((state (ebox--scroll-get-state root-id))) (plist-put state :scroll-offset 2) (when-let ((box (plist-get state :box))) (ebox-put box :scroll-offset 2)) (cl-letf (((symbol-function 'forward-line) (lambda (&optional n) (cl-incf forward-line-count) (funcall original-forward-line n)))) (should (ebox--scroll-replace-rendered-content-window state root-id))) (should (<= forward-line-count (1+ (plist-get state :content-height)))))))) (ert-deftest ebox-scroll-rendered-content-window-slides-small-deltas () "Small scroll deltas should mutate only entering/leaving rendered lines." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 3 :overflow scroll (column (box :content "row-1111" :height 1) (box :content "row-2222" :height 1) (box :content "row-3333" :height 1) (box :content "row-4444" :height 1) (box :content "row-5555" :height 1))))) root-id deleted-bytes (original-delete-region (symbol-function 'delete-region))) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (let* ((extents (ebox--live-box-extents root-id)) (full-window-bytes (- (marker-position (cdr extents)) (marker-position (car extents))))) (cl-letf (((symbol-function 'delete-region) (lambda (start end) (push (- end start) deleted-bytes) (funcall original-delete-region start end)))) (should (ebox--scroll-region-by root-id 1))) (should deleted-bytes) (should (< (apply #'+ deleted-bytes) full-window-bytes)))))) (ert-deftest ebox-scroll-window-slide-preserves-wrapper-properties () "Entering cached lines should keep scroll and outer-wrapper ownership." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "outer" :color "#112233" :bgcolor "#ddeeff" (box :id "scroll" :height 3 :overflow scroll (column (box :content "row-1111" :height 1) (box :content "row-2222" :height 1) (box :content "row-3333" :height 1) (box :content "row-4444" :height 1) (box :content "row-5555" :height 1)))))) outer-id scroll-id) (ebox-test--with-rendered-buffer layout (setq outer-id (ebox-test--selector-region-id (current-buffer) "#outer") scroll-id (ebox-test--selector-region-id (current-buffer) "#scroll")) (should (= 1 (ebox--scroll-region-by scroll-id 1))) (let* ((state (ebox--scroll-get-state scroll-id)) (extents (ebox--scroll-state-window-extents scroll-id state)) (start (marker-position (car extents))) (spans (ebox--scroll-line-spans-in-window start 3)) (slid (buffer-string))) (should (ebox--scroll-state-window-markers-healthy-p (current-buffer) scroll-id state)) (dolist (span spans) (should-not (text-property-not-all (car span) (cdr span) 'ebox-scroll-window scroll-id)) (should (member outer-id (ebox--span-wrapper-owners span)))) (ebox-buffer-apply-root-rerender (current-buffer)) (should (equal-including-properties slid (buffer-string))))))) (ert-deftest ebox-scroll-window-slide-preserves-ancestor-horizontal-borders () "Scrolling inside a bordered ancestor should preserve slot border paint." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "outer" :border-top-p t :border-top-color "red" :border-bottom-p t :border-bottom-color "blue" (box :id "scroll" :height 3 :overflow scroll (column (box :content "row-1111" :height 1) (box :content "row-2222" :height 1) (box :content "row-3333" :height 1) (box :content "row-4444" :height 1) (box :content "row-5555" :height 1)))))) scroll-id) (ebox-test--with-rendered-buffer layout (setq scroll-id (ebox-test--selector-region-id (current-buffer) "#scroll")) (let ((root-rerenders 0) (original-root-rerender (symbol-function 'ebox-buffer-apply-root-rerender))) (cl-letf (((symbol-function 'ebox-buffer-apply-root-rerender) (lambda (buffer) (cl-incf root-rerenders) (funcall original-root-rerender buffer)))) (should (= 1 (ebox--scroll-region-by scroll-id 1)))) (should (= root-rerenders 1))) (let ((scrolled (buffer-string))) (ebox-buffer-apply-root-rerender (current-buffer)) (should (equal-including-properties scrolled (buffer-string))))))) (ert-deftest ebox-scroll-rendered-content-window-uses-slide-in-gui () "GUI rendered viewport scrolling should try bounded line sliding." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 3 :overflow scroll (column (box :content "row-1111" :height 1) (box :content "row-2222" :height 1) (box :content "row-3333" :height 1) (box :content "row-4444" :height 1) (box :content "row-5555" :height 1))))) root-id slide-called) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (let ((noninteractive nil)) (cl-letf (((symbol-function 'display-graphic-p) (lambda (&optional _display) t)) ((symbol-function 'ebox--scroll-replace-rendered-content-window-slide) (lambda (&rest _args) (setq slide-called t) nil))) (should (ebox--scroll-region-by root-id 1)))) (should slide-called)))) (ert-deftest ebox-lazy-scroll-rendered-window-uses-preformatted-lines () "Lazy scroll windows should not rebuild visible content lines per tick." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (content-window-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (original-content-window (symbol-function 'ebox--scroll-replace-content-window))) (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))) (cl-letf (((symbol-function 'ebox--scroll-replace-content-window) (lambda (&rest args) (cl-incf content-window-count) (apply original-content-window args)))) (should (ebox--scroll-region-by root-id 8))) (should (= content-window-count 0)) (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-extents () "Viewport resize must preserve lazy scroll's fixed viewport fast path." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(column :id "viewport" :width (viewport) :height (viewport-height) (box :content "" :width (viewport) :height 1) (box :id "root" :width (viewport) :height (- (viewport-height) 2) :overflow scroll (column :width (viewport) :padding (1 (8)) ,@(cl-loop for idx below 80 collect `(box :content ,(format "row %02d" idx) :width (viewport) :height 1))) (box :content "" :width (viewport) :height 1))))) buffer root-id (materialize-count 0) (rerender-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)) (extents (ebox--live-box-extents root-id)) (extent-lines (and extents (count-lines (marker-position (car extents)) (marker-position (cdr extents)))))) (should extents) (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)) :extent-lines extent-lines))) (should (= extent-lines (plist-get state :content-height)))) (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))) (let ((original-rerender (symbol-function 'ebox--scroll-rerender-region))) (cl-letf (((symbol-function 'ebox--scroll-rerender-region) (lambda (&rest args) (cl-incf rerender-count) (apply original-rerender args)))) (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 (= materialize-count 0)) (should (= rerender-count 0)))) (when root-id (ebox--smooth-scroll-stop root-id)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-offset-viewport-resize-stays-lazy () "Viewport resize after scrolling should not materialize all hidden content." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(column :id "viewport" :width (viewport) :height (viewport-height) (box :content "" :width (viewport) :height 1) (box :id "root" :width (viewport) :height (- (viewport-height) 2) :overflow scroll (column :width (viewport) :padding (1 (8)) ,@(cl-loop for idx below 80 collect `(box :content ,(format "offset row %02d" idx) :width (viewport) :height 1))) (box :content "" :width (viewport) :height 1))))) buffer root-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 12) (ebox-scroll-lazy-idle-prefetch-lines 0) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (with-current-buffer buffer (should (ebox--scroll-region-by root-id 16))) (ebox-rerender-buffer-with-context buffer 320 14) (let ((state (ebox--scroll-get-state root-id))) (should (= (plist-get state :scroll-offset) 16)) (should (plist-get state :render-content-prefix)) (should (plist-get state :materialize-content-lines)) (should (< (length (plist-get state :content-lines)) 80)) (should (>= (length (plist-get state :content-lines)) (+ (plist-get state :scroll-offset) (plist-get state :content-height)))))) (when root-id (ebox--smooth-scroll-stop root-id)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-intrinsic-measurement-skips-window-source () "Flex intrinsic measurement should not render lazy scroll window content." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (- (viewport-height) 2) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :content ,(format "measurement row %02d" idx) :width (viewport) :height 1)))))) (calls 0) rendered) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (&rest _args) (cl-incf calls) (list :lines nil :complete nil :cache nil)))) (let ((ebox-viewport-width 240) (ebox-viewport-height 12) (ebox--intrinsic-layout-measurement t)) (setq rendered (ebox-render layout))) (should (= calls 0)) (should (= (ebox-string-height rendered) 10)) (let ((ebox-viewport-width 240) (ebox-viewport-height 12) (ebox--intrinsic-layout-measurement nil)) (ebox-render layout)) (should (= calls 1))))) (ert-deftest ebox-lazy-scroll-sync-prefix-does-not-require-idle-prefetch () "Interactive scrolling should extend a lazy prefix even with idle prefetch off." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (- (viewport-height) 10) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :content ,(format "sync row %02d" idx) :width (viewport) :height 1)))))) buffer root-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 20) (ebox-scroll-step 4) (ebox-scroll-lazy-idle-prefetch-lines 0) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((before (ebox--scroll-get-state root-id)) (initial-lines (length (plist-get before :content-lines))) (content-height (plist-get before :content-height)) (boundary-offset (- initial-lines content-height))) (should (> boundary-offset 0)) (with-current-buffer buffer (should (= boundary-offset (ebox--scroll-region-by root-id boundary-offset))))) (with-current-buffer buffer (should (= 4 (ebox--scroll-region-by root-id 4)))) (let ((after (ebox--scroll-get-state root-id))) (should (> (plist-get after :scroll-offset) 4)) (should (>= (length (plist-get after :content-lines)) (+ (plist-get after :scroll-offset) (plist-get after :content-height)))) (should-not (plist-get after :content-lines-complete-p)))) (when root-id (ebox--smooth-scroll-stop root-id)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-gui-cache-miss-returns-pending () "GUI scroll cache misses should not synchronously render missing content." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (- (viewport-height) 10) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :content ,(format "gui row %02d" idx) :width (viewport) :height 1)))))) buffer root-id (prefix-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 20) (ebox-scroll-step 4) (ebox-scroll-lazy-idle-prefetch-lines 0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (noninteractive nil)) (cl-letf (((symbol-function 'display-graphic-p) (lambda (&optional _display) t))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix)) (initial-lines (length (plist-get state :content-lines))) (content-height (plist-get state :content-height)) (boundary-offset (- initial-lines content-height))) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (puthash root-id state ebox--scroll-global-state) (with-current-buffer buffer (should (= boundary-offset (ebox--scroll-region-by root-id boundary-offset))) (should (eq 'pending (ebox--scroll-region-by root-id 4))))) (should (= prefix-count 0)))) (when root-id (ebox--smooth-scroll-stop root-id)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-explicit-budget-syncs-prefix-in-gui () "A caller-provided prefix budget should synchronously extend GUI lazy scrolls." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (- (viewport-height) 10) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :content ,(format "budget row %02d" idx) :width (viewport) :height 1)))))) buffer root-id (prefix-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 20) (ebox-scroll-step 4) (ebox-scroll-lazy-idle-prefetch-lines 0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (noninteractive nil)) (cl-letf (((symbol-function 'display-graphic-p) (lambda (&optional _display) t))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix)) (initial-lines (length (plist-get state :content-lines))) (content-height (plist-get state :content-height)) (boundary-offset (- initial-lines content-height))) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (puthash root-id state ebox--scroll-global-state) (with-current-buffer buffer (should (= boundary-offset (ebox--scroll-region-by root-id boundary-offset))) (should (= 4 (ebox--scroll-region-by root-id 4 4))))) (should (= prefix-count 1)))) (when root-id (ebox--smooth-scroll-stop root-id)) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-scroll-rendered-window-sliding-is-used-in-gui () "Graphical frames should use bounded line sliding for scroll windows." (let ((noninteractive nil)) (cl-letf (((symbol-function 'display-graphic-p) (lambda (&optional _display) t))) (should (ebox--scroll-rendered-window-slide-preferred-p))))) (ert-deftest ebox-lazy-scroll-prefix-reuses-viewport-dependent-flex-leaves () "Lazy prefix extension should not rerender prior viewport flex leaves." (ebox-test--reset-runtime-state) (let* ((flex-section (lambda (label) `(flex :width (viewport) :flex-flow (row wrap) ,@(cl-loop for idx below 4 collect `(box :content ,(format "%s-%d" label idx) :width (110) :height 1))))) (layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,(funcall flex-section "a") (spacer :height 1) ,(funcall flex-section "b"))))) (buffer nil) root-id (flex-render-count 0) (original-render-flex (symbol-function 'ebox--render-flex))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-wheel-scroll-step 0) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (with-current-buffer buffer (setq root-id (ebox-test--selector-region-id buffer "#root")) (let ((state (ebox--scroll-get-state root-id))) (should (= (length (plist-get state :content-lines)) 3)) (should (plist-get state :render-content-prefix))) (cl-letf (((symbol-function 'ebox--render-flex) (lambda (&rest args) (cl-incf flex-render-count) (apply original-render-flex args)))) (should (ebox--scroll-region-by root-id 3))) (should (<= flex-render-count 1)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-flex-prefix-renders-only-needed-rows () "Lazy scroll prefixes should not render hidden row-wrap flex rows." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) (box :id "head" :content "head" :width (viewport) :height 1) (flex :width (viewport) :flex-flow (row wrap) :row-gap 0 :column-gap 0 ,@(cl-loop for idx below 12 collect `(box :id ,(format "item-%02d" idx) :content ,(format "item %02d" idx) :width (100) :height 2))))))) (buffer nil) (hidden-render-count 0) (original-render-box (symbol-function 'ebox--render-box))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 1) (ebox-wheel-scroll-step 0) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) (when-let ((id (and (listp box) (ebox-get box :id)))) (when (and (string-prefix-p "item-" id) (>= (string-to-number (substring id (length "item-"))) 4)) (cl-incf hidden-render-count))) (funcall original-render-box box)))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (should (= hidden-render-count 0))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-flex-window-lines-match-full-render-prefix () "Partial flex window rendering should match the full render prefix." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" :border-top-p nil :bgcolor "#FFF8F2" ,@(cl-loop for idx below 6 collect `(box :id ,(format "prefix-item-%02d" idx) :content ,(format "prefix %02d" idx) :width (100) :height 2))))) (limit 4) (full-lines (ebox-string-lines (ebox-render node))) (window (ebox--render-flex-window-lines node limit))) (should-not (plist-get window :complete)) (should (equal (mapcar #'substring-no-properties (seq-take full-lines limit)) (mapcar #'substring-no-properties (plist-get window :lines)))))) (ert-deftest ebox-flex-window-shrinks-definite-wrapping-items () "Window prefix rendering must not freeze shrinkable definite widths." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :width (160) :flex-flow (row wrap) (box :id "shrink-a" :content "alpha beta gamma delta" :width (200) :height 2) (box :id "shrink-b" :content "second row content" :width (160) :height 2)))) (limit 2) (full-lines (ebox-string-lines (ebox-render node))) (window (ebox--render-flex-window-lines node limit))) ;; A nil window means the full-render fallback takes over, which is ;; also correct. Produced window lines must match the full ;; renderer's prefix geometry instead of freezing the declared ;; 200px width inside the 160px container. (when window (should (equal (mapcar #'ebox--string-pixel-width (seq-take full-lines limit)) (mapcar #'ebox--string-pixel-width (plist-get window :lines))))))) (ert-deftest ebox-flex-window-char-wrapper-falls-back-to-full-prefix () "Char-wrapped flex prefixes should use the full-render fallback." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :width (100) :wrap-mode char :flex-flow (row wrap) :row-gap 0 :column-gap 0 ,@(cl-loop for char across "ABCD" collect `(box :content ,(char-to-string char) :width (150) :height 1))))) (full-lines (ebox-string-lines (ebox-render node))) (expected-signatures (mapcar #'ebox-test--visual-line-signature (seq-take full-lines 3))) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) (first (ebox--render-stack-window-lines node 1)) (extended (ebox--render-stack-window-lines node 3 (plist-get first :cache))) (producer (ebox--render-node-window-lines node 3)) (actual-signatures (mapcar #'ebox-test--visual-line-signature (plist-get extended :lines)))) (should (equal expected-signatures actual-signatures)) (should-not producer))) (ert-deftest ebox-flex-window-aligned-wrapper-falls-back-to-full-prefix () "Non-left-aligned flex prefixes should use the full-render fallback." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :width (100) :text-align center :flex-flow (row wrap) :row-gap 0 :column-gap 0 ,@(cl-loop for char across "ABCDEF" collect `(box :content ,(char-to-string char) :width (50) :height 1))))) (full-lines (ebox-string-lines (ebox-render node))) (expected-signatures (mapcar #'ebox-test--visual-line-signature (seq-take full-lines 3))) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) (first (ebox--render-stack-window-lines node 1)) (extended (ebox--render-stack-window-lines node 3 (plist-get first :cache))) (producer (ebox--render-node-window-lines node 3)) (actual-signatures (mapcar #'ebox-test--visual-line-signature (plist-get extended :lines)))) (should (equal expected-signatures actual-signatures)) (should-not producer))) (ert-deftest ebox-flex-window-lines-use-chunk-wrapper-for-first-prefix () "A seedless flex prefix should avoid the generic full wrapper renderer." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :id "flex-window-wrapper" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" :border-top-p nil :bgcolor "#FFF8F2" ,@(cl-loop for idx below 8 collect `(box :content ,(format "wrapper %02d" idx) :width (100) :height 2))))) (full-lines (ebox-string-lines (ebox-render node))) (full-wrapper-count 0) (chunk-wrapper-count 0) (multiline-horizontal-concat-count 0) (original-full-wrapper (symbol-function 'ebox--flex-window-render-box-lines)) (original-chunk-wrapper (symbol-function 'ebox--flex-window-render-wrapper-chunk-lines)) (original-horizontal-concat (symbol-function 'ebox--lines-concat-horizontal))) (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) (lambda (&rest args) (cl-incf full-wrapper-count) (apply original-full-wrapper args))) ((symbol-function 'ebox--flex-window-render-wrapper-chunk-lines) (lambda (&rest args) (cl-incf chunk-wrapper-count) (apply original-chunk-wrapper args))) ((symbol-function 'ebox--lines-concat-horizontal) (lambda (&rest args) (cl-incf multiline-horizontal-concat-count) (apply original-horizontal-concat args)))) (let ((window (ebox--render-flex-window-lines node 4))) (should window) (should-not (plist-get window :complete)) (should (= (length (plist-get window :lines)) 4)) (should (equal-including-properties (ebox-lines-join (seq-take full-lines 4)) (ebox-lines-join (plist-get window :lines)))))) (should (= full-wrapper-count 0)) (should (= chunk-wrapper-count 1)) (should (= multiline-horizontal-concat-count 0)))) (ert-deftest ebox-flex-window-lines-use-chunk-wrapper-for-first-completion () "A compatible seedless completion should avoid the generic full wrapper." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :id "flex-window-complete-wrapper" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" :border-top-p nil :bgcolor "#FFF8F2" ,@(cl-loop for idx below 4 collect `(box :content ,(format "complete %02d" idx) :width (100) :height 2))))) (full (ebox-render node)) (full-wrapper-count 0) (chunk-wrapper-count 0) (original-full-wrapper (symbol-function 'ebox--flex-window-render-box-lines)) (original-chunk-wrapper (symbol-function 'ebox--flex-window-render-wrapper-chunk-lines))) (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) (lambda (&rest args) (cl-incf full-wrapper-count) (apply original-full-wrapper args))) ((symbol-function 'ebox--flex-window-render-wrapper-chunk-lines) (lambda (&rest args) (cl-incf chunk-wrapper-count) (apply original-chunk-wrapper args)))) (let ((window (ebox--render-flex-window-lines node 8))) (should window) (should (plist-get window :complete)) (should (equal-including-properties full (ebox-lines-join (plist-get window :lines)))))) (should (= full-wrapper-count 0)) (should (= chunk-wrapper-count 1)))) (ert-deftest ebox-flex-flat-wrapper-owns-color-copy-in-place () "Flat flex wrappers should decorate one private line copy per source line." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :width 120 :padding-left 8 :padding-right 8 :border-left 1 :border-right 1 :border-color "#D97757" :bgcolor "#FFF8F2")) (source (propertize "child" 'ebox-content 77 'ebox-content-owner 77 'ebox-content-owners '(77))) (source-before (copy-sequence source)) (expected (ebox--flex-window-render-wrapper-chunk-lines box (list source) 0 t)) (copying-owner-count 0) (original-copying-owner (symbol-function 'ebox--add-content-owner)) actual) (cl-letf (((symbol-function 'ebox--add-content-owner) (lambda (&rest args) (cl-incf copying-owner-count) (apply original-copying-owner args)))) (setq actual (ebox--flex-window-render-wrapper-chunk-lines box (list source) 0 t))) (should (equal-including-properties source source-before)) (should (equal-including-properties (ebox-lines-join actual) (ebox-lines-join expected))) (should (= copying-owner-count 0)))) (ert-deftest ebox-flex-window-lines-reuses-render-cache () "Repeated flex window renders should reuse the render cache." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 ,@(cl-loop for idx below 8 collect `(box :id ,(format "cache-item-%02d" idx) :content ,(format "cache %02d" idx) :width (100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) (row-render-count 0) (original-render-row-line (symbol-function 'ebox--flex-render-row-line))) (cl-letf (((symbol-function 'ebox--flex-render-row-line) (lambda (&rest args) (cl-incf row-render-count) (apply original-render-row-line args)))) (should (ebox--render-flex-window-lines node 4)) (let ((after-first row-render-count)) (should (> after-first 0)) (should (ebox--render-flex-window-lines node 4)) (should (= row-render-count after-first)))))) (ert-deftest ebox-flex-window-lines-reuses-node-cache-signature () "One flex window render should not repeatedly compute the node signature." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 ,@(cl-loop for idx below 8 collect `(box :id ,(format "sig-item-%02d" idx) :content ,(format "sig %02d" idx) :width (100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache (make-hash-table :test 'eq)) (signature-count 0) (original-signature (symbol-function 'ebox--render-cache-node-signature))) (cl-letf (((symbol-function 'ebox--render-cache-node-signature) (lambda (&rest args) (when (eq (car args) node) (cl-incf signature-count)) (apply original-signature args)))) (should (ebox--render-flex-window-lines node 4))) (should (<= signature-count 2)))) (ert-deftest ebox-flex-window-prefix-extension-reuses-wrapper-lines () "Extending a flex prefix should not rerender prior wrapper lines." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :id "flex-prefix-wrapper" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" :border-top-p nil :bgcolor "#FFF8F2" ,@(cl-loop for idx below 8 collect `(box :content ,(format "extend %02d" idx) :width (100) :height 2))))) (full-lines (mapcar #'substring-no-properties (ebox-string-lines (ebox-render node)))) (ebox--render-cache-table (make-hash-table :test 'equal)) (generic-wrapper-render-count 0) (original-render-box (symbol-function 'ebox--render-box))) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) (when (equal (and (listp box) (ebox-get box :id)) "flex-prefix-wrapper") (cl-incf generic-wrapper-render-count)) (funcall original-render-box box)))) (should (ebox--render-flex-window-lines node 4)) (should (= generic-wrapper-render-count 0)) (let ((window (ebox--render-flex-window-lines node 8))) (should window) (should (= generic-wrapper-render-count 0)) (should (equal (seq-take full-lines 8) (mapcar #'substring-no-properties (plist-get window :lines)))))))) (ert-deftest ebox-flex-window-prefix-extension-joins-only-new-pieces () "Extending a flex prefix should not rejoin the whole cached prefix." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" :border-top-p nil :bgcolor "#FFF8F2" ,@(cl-loop for idx below 16 collect `(box :content ,(format "piece %02d" idx) :width (100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) join-piece-counts (original-join-pieces (symbol-function 'ebox--flex-window-lines-from-pieces))) (cl-letf (((symbol-function 'ebox--flex-window-lines-from-pieces) (lambda (pieces) (push (length pieces) join-piece-counts) (funcall original-join-pieces pieces)))) (should (ebox--render-flex-window-lines node 4)) (setq join-piece-counts nil) (should (ebox--render-flex-window-lines node 8))) (should join-piece-counts) (should-not (cl-some (lambda (count) (> count 4)) join-piece-counts)))) (ert-deftest ebox-flex-window-complete-prefix-defers-bottom-wrapper () "A complete flex source should not full-render bottom chrome before LIMIT." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :id "flex-complete-prefix" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8) 2 (8)) :margin-bottom 2 :border "#D97757" :border-top-p nil :bgcolor "#FFF8F2" ,@(cl-loop for idx below 2 collect `(box :content ,(format "complete %02d" idx) :width (100) :height 8))))) (limit 4) (full-lines (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))) (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) (lambda (node content complete limit) (when complete (cl-incf complete-wrapper-count)) (funcall original-render-box-lines node content complete limit)))) (let ((window (ebox--render-flex-window-lines node limit))) (should window) (should-not (plist-get window :complete)) (should (= complete-wrapper-count 0)) (should (equal (seq-take full-lines limit) (mapcar #'substring-no-properties (plist-get window :lines)))))))) (ert-deftest ebox-flex-window-complete-extension-appends-bottom-border () "Completing a cached flex prefix should append bottom border without full render." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :id "flex-complete-border" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" :border-top-p nil :bgcolor "#FFF8F2" ,@(cl-loop for idx below 4 collect `(box :content ,(format "border %02d" idx) :width (100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) (full-lines (mapcar #'substring-no-properties (ebox-string-lines (ebox-render node)))) (complete-wrapper-count 0) (original-render-box-lines (symbol-function 'ebox--flex-window-render-box-lines))) (should (ebox--render-flex-window-lines node 4)) (cl-letf (((symbol-function 'ebox--flex-window-render-box-lines) (lambda (node content complete limit) (when complete (cl-incf complete-wrapper-count)) (funcall original-render-box-lines node content complete limit)))) (let ((window (ebox--render-flex-window-lines node 8))) (should window) (should (plist-get window :complete)) (should (= complete-wrapper-count 0)) (should (equal (seq-take full-lines 8) (mapcar #'substring-no-properties (plist-get window :lines)))))))) (ert-deftest ebox-flex-window-failed-completion-keeps-prefix-cache-atomic () "A failed flex completion should not publish half-appended cache lists." (ebox-test--reset-runtime-state) (let* ((node (ebox-build `(flex :id "flex-atomic-prefix" :width (240) :flex-flow (row wrap) :row-gap 1 :column-gap 0 :padding (0 (8)) :border "#D97757" :border-top-p nil ,@(cl-loop for idx below 4 collect `(box :content ,(format "atomic %02d" idx) :width (100) :height 2))))) (ebox--render-cache-table (make-hash-table :test 'equal)) (context (ebox--flex-window-cache-context node))) (should (ebox--render-flex-window-lines node 4)) (let* ((before (ebox--flex-window-prefix-cache-lookup node context)) (before-lines (plist-get before :lines)) (before-content-lines (plist-get before :content-lines)) (before-line-count (plist-get before :line-count)) (before-content-count (plist-get before :content-line-count))) (cl-letf (((symbol-function 'ebox--flex-window-complete-tail-line) (lambda (&rest _) (error "injected flex completion failure")))) (should-error (ebox--render-flex-window-lines node 8))) (let ((after (ebox--flex-window-prefix-cache-lookup node context))) (should (eq before-lines (plist-get after :lines))) (should (eq before-content-lines (plist-get after :content-lines))) (should (= before-line-count (length before-lines))) (should (= before-content-count (length before-content-lines))) (should (= (plist-get after :line-count) (length (plist-get after :lines)))) (should (= (plist-get after :content-line-count) (length (plist-get after :content-lines))))) (should (plist-get (ebox--render-flex-window-lines node 8) :complete))))) (ert-deftest ebox-stack-window-completion-reconciles-declared-prefix-tail () "A completed leaf should refresh declared chrome on its cached tail." (ebox-test--reset-runtime-state) (let* ((type 'ebox-test-completing-window) (leaf (list :ebox-type type)) (stack (list :ebox-type 'stack :children (list leaf))) (plain-tail (propertize "tail" 'face '(:foreground "red"))) (complete-tail (ebox--propertize-underline (copy-sequence plain-tail) "blue"))) (unwind-protect (progn (ebox--register-window-line-renderer type (lambda (_node limit) (if (< limit 3) (list :lines (list "head" plain-tail) :complete nil) (list :lines (list "head" complete-tail) :complete t :completion-prefix-tail-rewrite-count 1)))) (let* ((prefix (ebox--render-stack-window-lines stack 2)) (completed (ebox--render-stack-window-lines stack 3 (plist-get prefix :cache))) (tail (cadr (plist-get completed :lines))) (face (get-text-property 0 'face tail)) (delta (plist-get completed :prefix-delta))) (should (plist-get completed :complete)) (should (= (length (plist-get completed :lines)) 2)) (should (plist-get delta :exact-p)) (should (= (plist-get delta :base-count) 2)) (should (equal (plist-get delta :rewrite-range) '(1 . 2))) (should (ebox-test--face-has-key-value-p face :underline '(:position t :color "blue"))))) (remhash type ebox--window-line-renderer-table)))) (ert-deftest ebox-stack-window-invalid-completion-rewrite-rebuilds-prefix () "Invalid completion metadata should discard a potentially stale prefix." (ebox-test--reset-runtime-state) (dolist (metadata '(3 -1 invalid missing)) (let* ((type 'ebox-test-invalid-completing-window) (leaf (list :ebox-type type)) (stack (list :ebox-type 'stack :children (list leaf))) (plain-tail "tail") (complete-tail (ebox--propertize-underline "tail" "blue")) (calls 0)) (unwind-protect (progn (ebox--register-window-line-renderer type (lambda (_node limit) (cl-incf calls) (if (< limit 3) (list :lines (list "head" plain-tail) :complete nil) (append (list :lines (list "head" complete-tail) :complete t) (unless (eq metadata 'missing) (list :completion-prefix-tail-rewrite-count metadata)))))) (let* ((prefix (ebox--render-stack-window-lines stack 2)) (completed (ebox--render-stack-window-lines stack 3 (plist-get prefix :cache))) (tail (cadr (plist-get completed :lines))) (face (get-text-property 0 'face tail)) (delta (plist-get completed :prefix-delta))) (should (= calls 3)) (should-not (plist-get delta :exact-p)) (should (ebox-test--face-has-key-value-p face :underline '(:position t :color "blue"))))) (remhash type ebox--window-line-renderer-table))))) (ert-deftest ebox-flex-window-completion-tail-metadata-is-border-specific () "Only an existing bottom-border line should request a prefix rewrite." (ebox-test--reset-runtime-state) (should (= 1 (ebox--flex-window-completion-prefix-tail-rewrite-count (list :box (ebox-create :border-bottom-p t :padding-bottom 0))))) (should (= 1 (ebox--flex-window-completion-prefix-tail-rewrite-count (list :box (ebox-create :border-bottom-p t :padding-bottom 0 :margin-bottom 2))))) (should (= 0 (ebox--flex-window-completion-prefix-tail-rewrite-count (list :box (ebox-create :border-bottom-p t :padding-bottom 2))))) (should (= 0 (ebox--flex-window-completion-prefix-tail-rewrite-count (list :box (ebox-create :border-bottom-p nil :padding-bottom 0)))))) (ert-deftest ebox-flex-window-cache-rejects-short-incomplete-exact-hit () "An underfilled exact-limit cache must resume from its prefix seed." (ebox-test--reset-runtime-state) (let* ((lines (cl-loop repeat 112 collect "line")) (entry (list :lines lines :line-count 112 :line-tail (last lines) :complete nil)) looked-up-keys) (cl-letf (((symbol-function 'ebox--render-cache-lookup) (lambda (key _signature) (push key looked-up-keys) (cond ((equal key '(7 flex-window prefix)) entry) ((equal key '(7 flex-window 118)) entry))))) (should-not (ebox--flex-window-cache-lookup :test-node 118 '(:node-id 7 :signature test-signature)))) (should (member '(7 flex-window 118) looked-up-keys)))) (ert-deftest ebox-byte-compiled-scroll-window-binds-wrapper-viewport () "Compiled lazy rendering must dynamically bind the wrapper content width." (ebox-test--reset-runtime-state) (let* ((wrapper (ebox-create :content "" :width '(278) :wrap-mode nil)) (source (list :stack :test-stack :wrapper wrapper)) observed-viewport) (cl-letf (((symbol-function 'ebox--render-stack-window-lines) (lambda (&rest _args) (setq observed-viewport ebox-viewport-width) (throw 'viewport-observed nil)))) (let ((ebox-viewport-width 900)) (catch 'viewport-observed (ebox--render-scroll-window-source source 1)))) (should (= observed-viewport 278)))) (ert-deftest ebox-scroll-window-wrapper-applies-declared-prefix-rewrite () "Wrapper prefix reuse should rerender only a declared changed range." (ebox-test--reset-runtime-state) (let* ((wrapper (ebox-create :content "" :width 20 :wrap-mode nil)) (source (list :stack :test-stack :wrapper wrapper)) (plain-tail "tail") (complete-tail (ebox--propertize-underline "tail" "blue")) (stack-lines (list plain-tail)) (calls 0) (second-call-line-count 0) (original-chunk-renderer (symbol-function 'ebox--scroll-window-render-wrapper-chunk-lines))) (cl-letf (((symbol-function 'ebox--render-stack-window-lines) (lambda (&rest _args) (cl-incf calls) (if (= calls 1) (list :lines stack-lines :line-count 1 :line-tail stack-lines :complete nil :cache :first :prefix-delta '(:exact-p nil :base-count 0 :rewrite-range nil :rewrite-lines nil :append-lines nil)) (setcar stack-lines complete-tail) (list :lines stack-lines :line-count 1 :line-tail stack-lines :complete nil :cache :second :prefix-delta (list :exact-p t :base-count 1 :rewrite-range '(0 . 1) :rewrite-lines (list complete-tail) :append-lines nil))))) ((symbol-function 'ebox--scroll-window-render-wrapper-chunk-lines) (lambda (&rest args) (when (= calls 2) (cl-incf second-call-line-count (length (cadr args)))) (apply original-chunk-renderer args))) ((symbol-function 'equal-including-properties) (lambda (&rest _) (ert-fail "wrapper reuse scanned the whole prefix")))) (let* ((prefix (ebox--render-scroll-window-source source 1)) (extended (ebox--render-scroll-window-source source 2 (plist-get prefix :cache))) (line (car (plist-get extended :lines))) (face (get-text-property 0 'face line)) (delta (plist-get extended :prefix-delta))) (should (= calls 2)) (should (= second-call-line-count 1)) (should (plist-get delta :exact-p)) (should (= (plist-get delta :base-count) 1)) (should (equal (plist-get delta :rewrite-range) '(0 . 1))) (should (ebox-test--face-has-key-value-p face :underline '(:position t :color "blue"))))))) (ert-deftest ebox-scroll-window-wrapper-maps-top-chrome-rewrite-ranges () "Wrapper reuse should include top chrome only when source line zero changes." (ebox-test--reset-runtime-state) (let ((wrapper (ebox-create :content "" :width 20 :wrap-mode nil :margin-top 1 :padding-top 2))) (cl-labels ((mapped-range (source-range) (let* ((source (list :stack :test-stack :wrapper wrapper)) (source-lines (list "head" "body")) (calls 0)) (cl-letf (((symbol-function 'ebox--render-stack-window-lines) (lambda (&rest _) (cl-incf calls) (when (= calls 2) (setcar (nthcdr (car source-range) source-lines) "changed")) (list :lines source-lines :line-count 2 :line-tail (last source-lines) :complete nil :cache calls :prefix-delta (if (= calls 1) '(:exact-p nil :base-count 0 :rewrite-range nil :rewrite-lines nil :append-lines nil) (list :exact-p t :base-count 2 :rewrite-range source-range :rewrite-lines (list "changed") :append-lines nil)))))) (let* ((prefix (ebox--render-scroll-window-source source 2)) (extended (ebox--render-scroll-window-source source 3 (plist-get prefix :cache)))) (plist-get (plist-get extended :prefix-delta) :rewrite-range)))))) (should (equal (mapped-range '(0 . 1)) '(0 . 4))) (should (equal (mapped-range '(1 . 2)) '(4 . 5)))))) (ert-deftest ebox-scroll-prefix-renderer-applies-equal-length-prefix-rewrite () "Equal-length completion should rerender only its declared changed line." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "" :width 20 :height 1 :wrap-mode nil)) (plain-lines '("head" "body" "tail")) (source-lines (copy-sequence plain-lines)) (complete-tail (ebox--propertize-underline "tail" "blue")) (renderer (ebox--scroll-window-prefix-renderer box :test-source 20 4)) (rendered-line-count 0) (original-renderer (symbol-function 'ebox--scroll-rendered-content-lines))) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (_source limit &optional _cache) (when (> limit 3) (setcar (nthcdr 2 source-lines) complete-tail)) (list :lines source-lines :line-count 3 :line-tail (last source-lines) :cache nil :complete (> limit 3) :content-width-exact-p t :rendered-lines nil :prefix-delta (if (= limit 3) '(:exact-p nil :base-count 0 :rewrite-range nil :rewrite-lines nil :append-lines nil) (list :exact-p t :base-count 3 :rewrite-range '(2 . 3) :rewrite-lines (list complete-tail) :append-lines nil))))) ((symbol-function 'ebox--scroll-window-formatted-lines) (lambda (_box window) (plist-get window :lines))) ((symbol-function 'ebox--scroll-window-source-lines-exact-p) (lambda (&rest _) t)) ((symbol-function 'ebox--scroll-rendered-content-lines) (lambda (&rest args) (cl-incf rendered-line-count (length (cadr args))) (apply original-renderer args))) ((symbol-function 'equal-including-properties) (lambda (&rest _) (ert-fail "rendered prefix reuse scanned all old lines")))) (let* ((prefix (funcall renderer nil 1 3)) (_ (setq rendered-line-count 0)) (completed (funcall renderer prefix 1 4)) (lines (plist-get completed :rendered-content-lines)) (line (nth 2 lines)) (face (get-text-property 0 'face line))) (should (plist-get completed :complete)) (should (= (length lines) 3)) (should (= rendered-line-count 1)) (should (ebox-test--face-has-key-value-p face :underline '(:position t :color "blue"))))))) (ert-deftest ebox-scroll-prefix-projects-wrapper-top-chrome-delta () "A published prefix should reuse a source cache with hidden top chrome." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "" :width 20 :height 1 :wrap-mode nil)) (wrapper (ebox-create :content "" :width 20 :wrap-mode nil :padding-top 2 :margin-top 1)) (source (list :stack :test-stack :wrapper wrapper)) stack-lines stack-tail (stack-count 0) (calls 0) (rendered-line-count 0) (original-renderer (symbol-function 'ebox--scroll-rendered-content-lines)) (renderer (ebox--scroll-window-prefix-renderer box source 20 4))) (cl-letf (((symbol-function 'ebox--render-stack-window-lines) (lambda (_stack limit &optional _cache) (cl-incf calls) (let ((base stack-count) appended-lines) (while (< stack-count limit) (let ((cell (list (format "row-%d" stack-count)))) (if stack-tail (setcdr stack-tail cell) (setq stack-lines cell)) (setq stack-tail cell) (push (car cell) appended-lines) (cl-incf stack-count))) (setq appended-lines (nreverse appended-lines)) (list :lines stack-lines :line-count stack-count :line-tail stack-tail :complete nil :cache (list :call calls) :prefix-delta (if (= calls 1) '(:exact-p nil :base-count 0 :rewrite-range nil :rewrite-lines nil :append-lines nil) (list :exact-p t :base-count base :rewrite-range nil :rewrite-lines nil :append-lines appended-lines)))))) ((symbol-function 'ebox--scroll-window-formatted-lines) (lambda (_box window) (plist-get window :lines))) ((symbol-function 'ebox--scroll-window-source-lines-exact-p) (lambda (&rest _) t)) ((symbol-function 'ebox--scroll-rendered-content-lines) (lambda (&rest args) (cl-incf rendered-line-count (length (cadr args))) (apply original-renderer args))) ((symbol-function 'equal-including-properties) (lambda (&rest _) (ert-fail "top-chrome reuse scanned the whole prefix")))) (let* ((prefix (funcall renderer nil 1 6)) (prefix-count (length (plist-get prefix :content-lines))) (_ (setq rendered-line-count 0)) (extended (funcall renderer prefix 1 7))) (should (= calls 2)) (should (= prefix-count 6)) (should (= (length (plist-get extended :content-lines)) 7)) (should (= rendered-line-count 1)))))) (ert-deftest ebox-scroll-prefix-exact-extension-scans-only-appended-lines () "A long exact prefix should retain its cons cells and scan only its delta." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "" :width 20 :height 1 :wrap-mode nil)) (source-lines (cl-loop for idx below 1000 collect (format "line-%d" idx))) (source-tail (last source-lines)) (source-count 1000) (calls 0) scan-sizes record-scans (original-seq-take (symbol-function 'seq-take)) (renderer (ebox--scroll-window-prefix-renderer box :test-source 20 4))) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (_source limit &optional _cache) (cl-incf calls) (let ((base source-count) appended-lines) (while (< source-count limit) (let ((cell (list (format "line-%d" source-count)))) (setcdr source-tail cell) (setq source-tail cell) (push (car cell) appended-lines) (cl-incf source-count))) (setq appended-lines (nreverse appended-lines)) (list :lines source-lines :line-count source-count :line-tail source-tail :cache :same :complete nil :content-width-exact-p t :rendered-lines nil :prefix-delta (if (= calls 1) '(:exact-p nil :base-count 0 :rewrite-range nil :rewrite-lines nil :append-lines nil) (list :exact-p t :base-count base :rewrite-range nil :rewrite-lines nil :append-lines appended-lines)))))) ((symbol-function 'ebox--scroll-window-formatted-lines) (lambda (_box window) (plist-get window :lines))) ((symbol-function 'ebox--scroll-window-source-lines-exact-p) (lambda (&rest _) t)) ((symbol-function 'ebox--scroll-rendered-content-lines) (lambda (_box lines _region-id _start-index) lines)) ((symbol-function 'seq-take) (lambda (sequence count) (when record-scans (push (length sequence) scan-sizes)) (funcall original-seq-take sequence count)))) (let* ((prefix (funcall renderer nil 1 1000)) (content-head (plist-get prefix :content-lines))) (setq record-scans t) (let ((extended (funcall renderer prefix 1 1001))) (should (eq content-head (plist-get extended :content-lines))) (should (= (length (plist-get extended :content-lines)) 1001)) (should scan-sizes) (should (<= (apply #'max scan-sizes) 1))))))) (ert-deftest ebox-stack-window-completion-rewrite-uses-delta-payload () "A long leaf completion should not scan its full prefix for one rewrite." (ebox-test--reset-runtime-state) (let* ((type 'ebox-test-long-completing-window) (leaf (list :ebox-type type)) (stack (list :ebox-type 'stack :children (list leaf))) (leaf-lines (cl-loop for idx below 1000 collect (format "line-%d" idx))) (leaf-tail (last leaf-lines)) (complete-tail (ebox--propertize-underline "line-999" "blue")) (original-length (symbol-function 'length)) (original-nthcdr (symbol-function 'nthcdr)) (original-seq-subseq (symbol-function 'seq-subseq)) record-scans (full-length-count 0) (full-nthcdr-count 0) (full-subseq-count 0)) (unwind-protect (progn (ebox--register-window-line-renderer type (lambda (_node limit) (if (<= limit 1000) (list :lines leaf-lines :line-count 1000 :line-tail leaf-tail :complete nil) (setcar leaf-tail complete-tail) (list :lines leaf-lines :line-count 1000 :line-tail leaf-tail :complete t :completion-prefix-tail-rewrite-count 1 :prefix-delta (list :exact-p t :base-count 1000 :rewrite-range '(999 . 1000) :rewrite-lines (list complete-tail) :append-lines nil))))) (let ((prefix (ebox--render-stack-window-lines stack 1000))) (cl-letf (((symbol-function 'length) (lambda (sequence) (when (and record-scans (eq sequence leaf-lines)) (cl-incf full-length-count)) (funcall original-length sequence))) ((symbol-function 'nthcdr) (lambda (count sequence) (when (and record-scans (eq sequence leaf-lines)) (cl-incf full-nthcdr-count)) (funcall original-nthcdr count sequence))) ((symbol-function 'seq-subseq) (lambda (sequence start &optional end) (when (and record-scans (eq sequence leaf-lines)) (cl-incf full-subseq-count)) (funcall original-seq-subseq sequence start end)))) (setq record-scans t) (let ((completed (ebox--render-stack-window-lines stack 1001 (plist-get prefix :cache)))) (setq record-scans nil) (should (plist-get completed :complete)) (should (eq (car (plist-get completed :line-tail)) complete-tail))))) (should (= full-length-count 0)) (should (= full-nthcdr-count 0)) (should (= full-subseq-count 0))) (remhash type ebox--window-line-renderer-table)))) (ert-deftest ebox-scroll-prefix-renderer-failure-keeps-published-pair-atomic () "A failed rendered delta should preserve both published prefix lists." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "" :width 20 :height 1 :wrap-mode nil)) (source-lines (list "line-0" "line-1" "line-2")) (source-tail (last source-lines)) (calls 0) cache-arguments fail-render (original-renderer (symbol-function 'ebox--scroll-rendered-content-lines)) (ebox--render-cache-table (make-hash-table :test 'equal)) (renderer (ebox--scroll-window-prefix-renderer box :test-source 20 4))) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (_source _limit &optional cache) (push cache cache-arguments) (cl-incf calls) (cond ((= calls 1) (list :lines source-lines :line-count 3 :line-tail source-tail :cache :first :complete nil :content-width-exact-p t :rendered-lines source-lines :prefix-delta '(:exact-p nil :base-count 0 :rewrite-range nil :rewrite-lines nil :append-lines nil))) ((= calls 2) (let ((cell (list "line-3"))) (setcdr source-tail cell) (setq source-tail cell)) (list :lines source-lines :line-count 4 :line-tail source-tail :cache :second :complete nil :content-width-exact-p t :rendered-lines source-lines :prefix-delta '(:exact-p t :base-count 3 :rewrite-range nil :rewrite-lines nil :append-lines ("line-3")))) (t (let ((fresh (list "line-0" "line-1" "line-2" "line-3"))) (list :lines fresh :line-count 4 :line-tail (last fresh) :cache :recovered :complete nil :content-width-exact-p t :rendered-lines fresh :prefix-delta '(:exact-p nil :base-count 0 :rewrite-range nil :rewrite-lines nil :append-lines nil))))))) ((symbol-function 'ebox--scroll-window-formatted-lines) (lambda (_box window) (plist-get window :lines))) ((symbol-function 'ebox--scroll-window-source-lines-exact-p) (lambda (&rest _) t)) ((symbol-function 'ebox--scroll-rendered-content-lines) (lambda (&rest args) (if (and fail-render (= (nth 3 args) 3)) (error "injected rendered-prefix failure") (apply original-renderer args))))) (let* ((prefix (funcall renderer nil 1 3)) (content-lines (plist-get prefix :content-lines)) (rendered-lines (plist-get prefix :rendered-content-lines))) (puthash :poison t ebox--render-cache-table) (setq fail-render t) (should-error (funcall renderer prefix 1 4)) (should (= (length content-lines) 3)) (should (= (length rendered-lines) 3)) (should-not (cdr (last content-lines))) (should-not (cdr (last rendered-lines))) (should (= (hash-table-count ebox--render-cache-table) 0)) (setq fail-render nil) (let ((recovered (funcall renderer prefix 1 4))) (should (= (length (plist-get recovered :content-lines)) 4)) (should (= (length (plist-get recovered :rendered-content-lines)) 4)))) (setq cache-arguments (nreverse cache-arguments)) (should (equal cache-arguments '(nil :first nil)))))) (ert-deftest ebox-stack-window-leaf-lines-uses-registered-producer () "Lazy stack scrolling should use generic window-line producers." (ebox-test--reset-runtime-state) (let ((node (list :ebox-type 'test-window-producer)) (call-count 0) result) (unwind-protect (progn (ebox--register-window-line-renderer 'test-window-producer (lambda (_node limit) (cl-incf call-count) (list :lines (seq-take '("aa" "bb" "cc") limit) :complete nil))) (setq result (ebox--render-stack-window-leaf-lines node 2 0)) (should (= call-count 1)) (should-not (plist-get result :complete)) (should (equal '("aa" "bb") (mapcar #'substring-no-properties (plist-get result :lines))))) (remhash 'test-window-producer ebox--window-line-renderer-table)))) (ert-deftest ebox-scroll-prefix-yielding-prewarm-converges-after-zero-line-slice () "A prewarm-only prefix slice should yield once, then publish exact content." (ebox-test--reset-runtime-state) (let* ((node (list :ebox-type 'test-yielding-prewarm)) (box (ebox-create :content "" :width 20 :height 1 :wrap-mode nil :overflow 'scroll)) (renderer (ebox--scroll-window-prefix-renderer box (list :stack node) 20 1)) (prewarm-count 0) (render-count 0)) (unwind-protect (progn (ebox--register-window-line-prewarmer 'test-yielding-prewarm (lambda (_node _target-width) (cl-incf prewarm-count) (= prewarm-count 1))) (ebox--register-window-line-renderer 'test-yielding-prewarm (lambda (_node _limit) (cl-incf render-count) (list :lines '("ready") :line-count 1 :line-tail '("ready") :complete t))) (let* ((ebox--scroll-window-yielding-prewarm-p t) (ebox--render-cache-table (make-hash-table :test 'equal)) (first (funcall renderer nil "yield-region" 1)) (second (funcall renderer first "yield-region" 1)) (complete (funcall renderer second "yield-region" 2))) (should (= prewarm-count 2)) (should (= render-count 1)) (should-not (plist-get first :content-lines)) (should-not (plist-get first :complete)) (should (equal '("ready") (mapcar #'substring-no-properties (plist-get second :content-lines)))) (should-not (plist-get second :complete)) (should (plist-get complete :complete)) (should (equal '("ready") (mapcar #'substring-no-properties (plist-get complete :content-lines)))))) (remhash 'test-yielding-prewarm ebox--window-line-prewarmer-table) (remhash 'test-yielding-prewarm ebox--window-line-renderer-table)))) (ert-deftest ebox-scroll-stack-window-defers-hidden-leaf-render () "Large stack-backed scroll boxes should render hidden rows only on demand." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) :padding (1 0) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (hidden-render-count 0) (original-render-box (symbol-function 'ebox--render-box))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) (when (equal (ebox-tree-node-id box) "row-30") (cl-incf hidden-render-count)) (funcall original-render-box box)))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (should (= hidden-render-count 0)) (with-current-buffer buffer (should (string-match-p "row 00" (buffer-substring-no-properties (point-min) (point-max)))) (should-not (string-match-p "row 30" (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox--scroll-region-by root-id 31)) (should (= hidden-render-count 0)) (should (gethash root-id ebox--scroll-idle-prefetch-timers)) (ebox--scroll-idle-prefetch root-id) (should (> hidden-render-count 0)) (let* ((state (ebox--scroll-get-state root-id)) (delta (- 30 (or (plist-get state :scroll-offset) 0)))) (when (> delta 0) (should (ebox--scroll-region-by root-id delta)))) (should (string-match-p "row 30" (buffer-substring-no-properties (point-min) (point-max))))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-incremental-render-covers-wheel-drain () "Incremental lazy scroll renders should cover one smooth wheel drain." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "budget-row-%02d" idx) :content ,(format "budget row %02d" idx) :height 1)))))) (limits nil) (original-render-source (symbol-function 'ebox--render-scroll-window-source))) (let ((ebox-viewport-width 240) (ebox-viewport-height 6) (ebox--defer-scroll-content-index t) (ebox-wheel-smooth-scroll t) (ebox-wheel-scroll-step 16) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-scroll-lazy-prefix-lookahead-lines 8)) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (source limit &optional cache) (push limit limits) (funcall original-render-source source limit cache)))) (ebox-render layout))) (should (equal (nreverse limits) '(23))))) (ert-deftest ebox-lazy-scroll-small-step-extends-prefix-only () "Small lazy scroll steps should not materialize the whole source." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (hidden-render-count 0) (original-render-box (symbol-function 'ebox--render-box))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) (when (equal (ebox-tree-node-id box) "row-30") (cl-incf hidden-render-count)) (funcall original-render-box box)))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (should (= hidden-render-count 0)) (with-current-buffer buffer (should (ebox--scroll-region-by root-id 1)) (should (= hidden-render-count 0)) (let ((state (ebox--scroll-get-state root-id))) (should (= (plist-get state :scroll-offset) 1)) (should (< (length (plist-get state :content-lines)) 40)) (should (plist-get state :materialize-content-lines))) (should (string-match-p "row 01" (buffer-substring-no-properties (point-min) (point-max)))) (should-not (string-match-p "row 30" (buffer-substring-no-properties (point-min) (point-max))))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-large-cache-miss-schedules-prefetch () "Large lazy scroll misses should schedule prefetch without inline render." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "cache-row-%02d" idx) :content ,(format "cache row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0) (materialize-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-wheel-scroll-step 0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix)) (materialize (plist-get state :materialize-content-lines))) (should (= (length (plist-get state :content-lines)) 3)) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (plist-put state :materialize-content-lines (lambda (&rest args) (cl-incf materialize-count) (apply materialize args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (should (ebox--scroll-region-by root-id 30)) (should (= prefix-count 0)) (should (= materialize-count 0)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (should (< (length (plist-get (ebox--scroll-get-state root-id) :content-lines)) 40)) (should (gethash root-id ebox--scroll-idle-prefetch-timers)) (should-not (string-match-p "cache row 30" (buffer-substring-no-properties (point-min) (point-max)))))) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-smooth-scroll-cache-miss-renders-bounded-prefix () "Smooth scroll cache misses should keep moving with bounded prefix render." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "smooth-cache-row-%02d" idx) :content ,(format "smooth cache row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0) (materialize-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-wheel-scroll-step 0) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-wheel-smooth-scroll-interval 60.0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix)) (materialize (plist-get state :materialize-content-lines))) (should (= (length (plist-get state :content-lines)) 3)) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (plist-put state :materialize-content-lines (lambda (&rest args) (cl-incf materialize-count) (apply materialize args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 16))) (should (= prefix-count 1)) (should (= materialize-count 0)) (should (> (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (let ((entry (gethash root-id ebox--smooth-scroll-state-table))) (should entry) (should (> (or (plist-get entry :pending) 0) 0)) (should-not (plist-get entry :waiting-prefetch)))) (ebox--smooth-scroll-stop root-id) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-smooth-scroll-cache-miss-continues-with-bounded-prefix () "Smooth scroll cache misses should extend a bounded prefix without stalling." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "smooth-inline-row-%02d" idx) :content ,(format "smooth inline row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0) (materialize-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-wheel-scroll-step 0) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-wheel-smooth-scroll-interval 60.0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix)) (materialize (plist-get state :materialize-content-lines))) (should (= (length (plist-get state :content-lines)) 3)) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (plist-put state :materialize-content-lines (lambda (&rest args) (cl-incf materialize-count) (apply materialize args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 16)) (ebox--smooth-scroll-tick root-id)) (should (> prefix-count 0)) (should (= materialize-count 0)) (should (> (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (let ((entry (gethash root-id ebox--smooth-scroll-state-table))) (should entry) (should-not (plist-get entry :waiting-prefetch)))) (ebox--smooth-scroll-stop root-id) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-smooth-scroll-large-pending-uses-base-tick-at-prefix-boundary () "Large smooth pending input should not render a large lazy prefix at once." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 200 collect `(box :id ,(format "smooth-large-row-%03d" idx) :content ,(format "smooth large row %03d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0) requested-lines) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-wheel-scroll-step 0) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-wheel-smooth-scroll-target-ticks 8) (ebox-wheel-smooth-scroll-interval 60.0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix))) (should (= (length (plist-get state :content-lines)) 3)) (plist-put state :render-content-prefix (lambda (state region-id required-lines) (cl-incf prefix-count) (setq requested-lines required-lines) (funcall render-prefix state region-id required-lines))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 1000))) (should (= prefix-count 1)) (should (<= requested-lines 8)) (should (<= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) ebox-wheel-smooth-scroll-lines-per-tick)) (let ((entry (gethash root-id ebox--smooth-scroll-state-table))) (should entry) (should-not (plist-get entry :waiting-prefetch)))) (ebox--smooth-scroll-stop root-id) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-smooth-scroll-cache-miss-defers-prefetch-while-active () "Idle prefetch should not compete with an active smooth scroll timer." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "smooth-defer-row-%02d" idx) :content ,(format "smooth defer row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-wheel-scroll-step 0) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-wheel-smooth-scroll-interval 60.0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix))) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 16))) (should (gethash root-id ebox--smooth-scroll-state-table)) (let ((prefix-after-start prefix-count)) (should (> prefix-after-start 0)) (ebox--scroll-idle-prefetch root-id) (should (= prefix-count prefix-after-start)) (should (gethash root-id ebox--scroll-idle-prefetch-timers)) (ebox--smooth-scroll-stop root-id) (ebox--scroll-idle-prefetch root-id) (should (> prefix-count prefix-after-start)))) (ebox--smooth-scroll-stop root-id) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-smooth-scroll-cache-miss-never-enters-prefetch-wait () "Smooth scroll should not pause at a lazy prefix boundary." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "smooth-resume-row-%02d" idx) :content ,(format "smooth resume row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-wheel-scroll-step 0) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-wheel-smooth-scroll-interval 60.0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix))) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 16))) (should (> prefix-count 0)) (let ((offset-before (plist-get (ebox--scroll-get-state root-id) :scroll-offset))) (ebox--smooth-scroll-tick root-id) (should (> (plist-get (ebox--scroll-get-state root-id) :scroll-offset) offset-before))) (let ((entry (gethash root-id ebox--smooth-scroll-state-table))) (should entry) (should-not (plist-get entry :waiting-prefetch)) (should (timerp (plist-get entry :timer))))) (ebox--smooth-scroll-stop root-id) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-repeated-small-steps-keep-marker-window () "Repeated lazy scroll steps should keep marker windows valid." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (hidden-render-count 0) (materialize-count 0) (original-render-box (symbol-function 'ebox--render-box))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) (when (equal (ebox-tree-node-id box) "row-30") (cl-incf hidden-render-count)) (funcall original-render-box box)))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (materialize (plist-get state :materialize-content-lines))) (plist-put state :materialize-content-lines (lambda (&rest args) (cl-incf materialize-count) (apply materialize args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (cl-loop repeat 8 do (should (ebox--scroll-region-by root-id 1))) (should (= hidden-render-count 0)) (should (= materialize-count 0)) (let ((state (ebox--scroll-get-state root-id))) (should (= (plist-get state :scroll-offset) 8)) (should (< (length (plist-get state :content-lines)) 40)) (should (plist-get state :materialize-content-lines))) (should (string-match-p "row 08" (buffer-substring-no-properties (point-min) (point-max)))) (should-not (string-match-p "row 30" (buffer-substring-no-properties (point-min) (point-max))))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-repeated-small-steps-prefetch-prefix () "Repeated lazy scroll steps should reuse a prefetched lazy prefix." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix))) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (cl-loop repeat 8 do (should (ebox--scroll-region-by root-id 1))) (should (= prefix-count 0)) (let ((state (ebox--scroll-get-state root-id))) (should (= (plist-get state :scroll-offset) 8)) (should (plist-get state :render-content-prefix)) (should (<= (+ (plist-get state :scroll-offset) (plist-get state :content-height) 1) (length (plist-get state :content-lines))))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-idle-prefetch-extends-prefix-cache () "Idle prefetch should fill lazy scroll lines before wheel ticks need them." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-idle-prefetch-lines 16)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (before-lines (length (plist-get state :content-lines))) (render-prefix (plist-get state :render-content-prefix))) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (puthash root-id state ebox--scroll-global-state) (ebox--scroll-idle-prefetch root-id) (let ((prefetched (ebox--scroll-get-state root-id))) (should (> (length (plist-get prefetched :content-lines)) before-lines)) (should (< (length (plist-get prefetched :content-lines)) 80)) (should (= prefix-count 1)))) (with-current-buffer buffer (cl-loop repeat 8 do (should (ebox--scroll-region-by root-id 1)))) (should (= prefix-count 1))) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-prefix-reuses-display-measurement-cache () "Lazy prefix slices should retain the initial render's display cache." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :content ,(format "repeated row %02d" idx) :height 1)))))) (buffer nil) root-id caches) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0) (original (symbol-function 'ebox--render-scroll-window-source))) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (&rest arguments) (push ebox--render-string-pixel-width-cache caches) (apply original arguments)))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prefix-cache*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (before (length (plist-get state :content-lines)))) (ebox--scroll-state-ensure-prefix-lines root-id state (1+ before) t))) (should (>= (length caches) 2)) (should (car caches)) (should (cl-every (lambda (cache) (eq cache (car caches))) caches))) (ebox--scroll-cancel-idle-prefetch root-id) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-idle-prefetch-covers-visible-window () "Idle prefetch should warm beyond the current visible scroll window." (ebox-test--reset-runtime-state) (let ((region-id "scroll") requested-lines) (puthash region-id (list :scroll-offset 0 :content-height 10 :content-lines (make-list 3 "") :render-content-prefix (lambda (_state _region-id target-lines) (setq requested-lines target-lines) (list :content-lines (make-list target-lines "") :content-height 10 :complete nil))) ebox--scroll-global-state) (let ((ebox-scroll-lazy-idle-prefetch-lines 5) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (ebox--scroll-idle-prefetch region-id)) (should requested-lines) (should (>= requested-lines 15)))) (ert-deftest ebox-lazy-scroll-idle-prefetch-is-sliced () "Idle prefetch should extend lazy prefixes in bounded foreground slices." (ebox-test--reset-runtime-state) (let ((region-id "scroll") requested-lines) (puthash region-id (list :scroll-offset 0 :content-height 10 :content-lines (make-list 3 "") :render-content-prefix (lambda (_state _region-id target-lines) (setq requested-lines target-lines) (list :content-lines (make-list target-lines "") :content-height 10 :complete nil))) ebox--scroll-global-state) (let ((ebox-scroll-lazy-idle-prefetch-lines 64) (ebox-scroll-lazy-idle-prefetch-slice-lines 4) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox--scroll-allow-noninteractive-prefetch t)) (ebox--scroll-idle-prefetch region-id)) ;; Three cached lines plus the configured four-line slice. Completion is ;; probed on a later turn instead of exceeding this callback's budget. (should (= requested-lines 7)) (should (gethash region-id ebox--scroll-idle-prefetch-timers)) (ebox--scroll-cancel-idle-prefetch region-id))) (ert-deftest ebox-lazy-scroll-idle-prefetch-bounds-atomic-item-work () "Item-heavy buffers should request only one new line per idle render turn." (ebox-test--reset-runtime-state) (let ((region-id "scroll") buffer requested-lines) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-item-prefetch*") (ebox-build '(flex :width (240) (item (box :content "atomic" :width (120))))))) (puthash region-id (list :buffer buffer :scroll-offset 0 :content-height 10 :content-lines (make-list 3 "") :render-content-prefix (lambda (_state _region-id target-lines) (setq requested-lines target-lines) (list :content-lines (make-list target-lines "") :content-height 10 :complete nil))) ebox--scroll-global-state) (let ((ebox-scroll-lazy-idle-prefetch-lines 64) (ebox-scroll-lazy-idle-prefetch-slice-lines 16) (ebox-scroll-lazy-prefix-lookahead-lines 13) (ebox--scroll-allow-noninteractive-prefetch t)) (ebox--scroll-idle-prefetch region-id)) ;; Three cached lines + one atomic requested line. The completion ;; probe must not silently double a latency-sensitive slice. (should (= requested-lines 4))) (ebox--scroll-cancel-idle-prefetch region-id) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-idle-prefetch-bounds-large-runtime-work () "Large runtimes should request only one new line per idle render turn." (ebox-test--reset-runtime-state) (let ((region-id "scroll") (buffer (generate-new-buffer " *ebox-large-prefetch*")) (node-table (make-hash-table :test 'equal)) (selector-type-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 :selector-type-table selector-type-table) ebox--buffer-render-state-table) (puthash region-id (list :buffer buffer :scroll-offset 0 :content-height 10 :content-lines (make-list 3 "") :render-content-prefix (lambda (_state _region-id target-lines) (setq requested-lines target-lines) (list :content-lines (make-list target-lines "") :content-height 10 :complete nil))) ebox--scroll-global-state) (let ((ebox-scroll-lazy-idle-prefetch-lines 64) (ebox-scroll-lazy-idle-prefetch-slice-lines 16) (ebox-scroll-lazy-prefix-lookahead-lines 13) (ebox--scroll-allow-noninteractive-prefetch t)) (ebox--scroll-idle-prefetch region-id)) ;; Three cached lines + one atomic requested line. The completion ;; probe must not silently double a latency-sensitive slice. (should (= requested-lines 4))) (ebox--scroll-cancel-idle-prefetch region-id) (remhash buffer ebox--buffer-render-state-table) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-idle-prefetch-defers-visible-render-gc () "Visible idle prefix slices should not combine atomic rendering with GC." (ebox-test--reset-runtime-state) (let* ((region-id "visible-prefix-gc") (buffer (generate-new-buffer " *ebox-visible-prefix-gc*")) (state (list :buffer buffer :scroll-offset 0 :content-height 1 :content-lines '("row 0") :render-content-prefix (lambda (input-state _region-id target-lines &optional _exact) (setq input-state (plist-put input-state :content-lines (make-list target-lines "row"))) (plist-put input-state :content-lines-complete-p t)))) (gc-enters 0) (gc-restores 0)) (unwind-protect (let ((ebox-scroll-lazy-idle-prefetch-lines 1) (ebox-scroll-lazy-idle-prefetch-slice-lines 1) (ebox--scroll-allow-noninteractive-prefetch t)) (puthash region-id state ebox--scroll-global-state) (cl-letf (((symbol-function 'get-buffer-window-list) (lambda (&rest _) (list (selected-window)))) ((symbol-function 'display-graphic-p) (lambda (&rest _) t)) ((symbol-function 'ebox--deferred-render-gc-enter) (lambda () (cl-incf gc-enters))) ((symbol-function 'ebox--deferred-render-gc-schedule-restore) (lambda () (cl-incf gc-restores))) ((symbol-function 'ebox--schedule-buffer-runtime-prewarm) (lambda (&rest _) nil))) (ebox--scroll-idle-prefetch region-id)) (should (= gc-enters 1)) (should (= gc-restores 1))) (ebox--scroll-cancel-idle-prefetch region-id) (remhash region-id ebox--scroll-global-state) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-prefetch-yields-to-input-between-slices () "Background prefix work should use idle time and rearm beyond current idle." (ebox-test--reset-runtime-state) (let ((region-id "scroll") scheduled-delay scheduled-function) (puthash region-id (list :content-height 2 :content-lines '("row 0") :render-content-prefix (lambda (_state _region-id target-lines) (list :content-lines (make-list target-lines "row") :content-height 2 :complete nil))) ebox--scroll-global-state) (let ((ebox-scroll-lazy-idle-prefetch-lines 8) (ebox-scroll-lazy-idle-prefetch-slice-lines 1) (ebox-scroll-lazy-idle-prefetch-delay 0.15) (ebox--scroll-allow-noninteractive-prefetch t)) (cl-letf (((symbol-function 'current-idle-time) (lambda () (seconds-to-time 0.4))) ((symbol-function 'run-at-time) (lambda (&rest _) (ert-fail "Background scroll work must yield to input"))) ((symbol-function 'run-with-idle-timer) (lambda (delay _repeat function &rest _arguments) (setq scheduled-delay delay scheduled-function function) 'scheduled-scroll-timer))) (ebox--scroll-idle-prefetch region-id))) (should (< (abs (- scheduled-delay 0.55)) 0.000001)) (should (eq scheduled-function #'ebox--scroll-idle-prefetch)) (should (eq (gethash region-id ebox--scroll-idle-prefetch-timers) 'scheduled-scroll-timer)) (remhash region-id ebox--scroll-idle-prefetch-timers))) (ert-deftest ebox-lazy-scroll-idle-prefetch-rearms-after-scroll () "Successful lazy scroll steps should schedule the next generic prefetch." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (ebox--scroll-cancel-idle-prefetch root-id) (with-current-buffer buffer (should (ebox--scroll-region-by root-id 1))) (should (gethash root-id ebox--scroll-idle-prefetch-timers))) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-idle-prefetch-pauses-during-smooth-scroll () "Smooth scroll should prefetch only after the active animation stops." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-idle-prefetch-delay 999) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-wheel-smooth-scroll-target-ticks 4) (ebox-wheel-smooth-scroll-interval 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (should (gethash root-id ebox--scroll-idle-prefetch-timers)) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 16))) (should (gethash root-id ebox--smooth-scroll-state-table)) (should-not (gethash root-id ebox--scroll-idle-prefetch-timers)) (ebox--smooth-scroll-stop root-id) (should (gethash root-id ebox--scroll-idle-prefetch-timers))) (ebox--scroll-cancel-idle-prefetch root-id) (ebox--smooth-scroll-stop root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-idle-prefetch-pauses-for-resize-animation () "A resize animation should own idle time until its final frame settles." (ebox-test--reset-runtime-state) (let* ((buffer (generate-new-buffer " *ebox-resize-prefetch*")) (region-id "scroll") scheduled) (unwind-protect (progn (puthash region-id (list :buffer buffer :content-height 2 :content-lines '("row 0") :render-content-prefix #'ignore :content-lines-complete-p nil) ebox--scroll-global-state) (cl-letf (((symbol-function 'run-with-idle-timer) (lambda (&rest _) (setq scheduled 'scroll-timer)))) (ebox--scroll-inhibit-idle-prefetch-for-buffer buffer) (ebox--scroll-schedule-idle-prefetch region-id 0) (should-not scheduled) (ebox--scroll-resume-idle-prefetch-for-buffer buffer) (should (eq scheduled 'scroll-timer)))) (remhash region-id ebox--scroll-global-state) (remhash buffer ebox--scroll-idle-prefetch-inhibited-buffers) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-prefix-shares-display-cache-scope () "Each lazy prefix generation should share one display cache scope." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 20 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (validate-count 0) (original-validate (symbol-function 'ebox--validate-display-cache))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 1) (ebox-wheel-scroll-step 0) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let ((state (ebox--scroll-get-state root-id))) (should (= (length (plist-get state :content-lines)) 2)) (should (plist-get state :render-content-prefix))) (cl-letf (((symbol-function 'ebox--validate-display-cache) (lambda (&rest args) (cl-incf validate-count) (apply original-validate args)))) (with-current-buffer buffer (should (ebox--scroll-region-by root-id 2))) (should (= validate-count 1)) (should (gethash root-id ebox--scroll-idle-prefetch-timers)) (ebox--scroll-idle-prefetch root-id)) (should (= validate-count 2))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-prefix-reuses-exact-source-lines () "Exact-width lazy scroll source lines should not be reformatted." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 20 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (format-count 0) (original-format (symbol-function 'ebox--format-content-string))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 1) (ebox-wheel-scroll-step 0) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (cl-letf (((symbol-function 'ebox--format-content-string) (lambda (&rest args) (when (equal (and (listp (car args)) (ebox-get (car args) :id)) "root") (cl-incf format-count)) (apply original-format args)))) (with-current-buffer buffer (should (ebox--scroll-region-by root-id 2)))) (should (= format-count 0))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (defun ebox-test--complete-lazy-scroll-batch-layout () "Return a complete five-row lazy scroll cache fixture." (ebox-build '(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) (box :content "row 00" :height 1) (box :id "target" :content "target row" :height 1 :color "#111111") (box :id "second-target" :content "second target row" :height 1 :color "#111111") (box :content "row 03" :height 1) (box :content "row 04" :height 1))))) (defun ebox-test--text-has-foreground-p (string text color) "Return non-nil when TEXT in STRING has foreground COLOR." (let ((pos (string-match (regexp-quote text) (substring-no-properties string)))) (and pos (ebox-test--face-has-key-value-p (get-text-property pos 'face string) :foreground color)))) (defun ebox-test--lines-have-text-foreground-p (lines text color) "Return non-nil when one of LINES paints TEXT with foreground COLOR." (cl-some (lambda (line) (ebox-test--text-has-foreground-p line text color)) lines)) (defun ebox-test--lines-have-text-p (lines text) "Return non-nil when one of LINES contains TEXT." (and (cl-some (lambda (line) (string-match-p (regexp-quote text) (substring-no-properties line))) lines) t)) (ert-deftest ebox-lazy-scroll-prefix-extension-keeps-runtime-updates () "Extending a lazy prefix should not replay producer caches from before an update." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 12 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-wheel-scroll-step 0) (ebox-wheel-smooth-scroll nil)) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (root-id (ebox-test--selector-region-id buffer "#root")) (target-id (ebox-test--selector-region-id buffer "#row-01")) (state (ebox-scroll-state root-id))) (should-not (plist-get state :content-lines-complete-p)) (ebox-region-update target-id :content "updated row 01") (setq state (ebox-scroll-state root-id)) (should (ebox-test--lines-have-text-p (plist-get state :content-lines) "updated row 01")) (setq state (ebox--scroll-state-ensure-prefix-lines root-id state 8)) (should (ebox-test--lines-have-text-p (plist-get state :content-lines) "updated row 01")))))) (ert-deftest ebox-lazy-scroll-prefix-does-not-publish-unflushed-batch () "Extending a lazy prefix should preserve committed content until batch flush." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 12 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-wheel-scroll-step 0) (ebox-wheel-smooth-scroll nil)) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (root-id (ebox-test--selector-region-id buffer "#root")) (target-id (ebox-test--selector-region-id buffer "#row-01")) (initial-revision (plist-get (ebox--buffer-render-state buffer) :runtime-revision))) (ebox-test--set-buffer-update-report buffer '(:strategy span-patch)) (ebox-incremental-begin-batch buffer) (ebox-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 (= -2 (ebox--scroll-region-by root-id -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) (1+ initial-revision))) (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-region-update target-id :color "#DC2626") (ebox-incremental-flush buffer) (let* ((runtime (gethash target-id ebox--region-box-table)) (visible-before (ebox-test--text-has-foreground-p (buffer-string) "target row" "#DC2626")) (state (ebox-scroll-state root-id)) (content-cache (ebox-test--lines-have-text-foreground-p (plist-get state :content-lines) "target row" "#DC2626")) (rendered-cache (ebox-test--lines-have-text-foreground-p (plist-get state :rendered-content-lines) "target row" "#DC2626"))) (should (= 2 (ebox--scroll-region-by root-id 2))) (should (= -2 (ebox--scroll-region-by root-id -2))) (should (equal (list (ebox-get runtime :color) visible-before content-cache rendered-cache (ebox-test--text-has-foreground-p (buffer-string) "target row" "#DC2626")) '("#DC2626" t t t t)))))))) (ert-deftest ebox-explicit-batch-synchronizes-mixed-scroll-cache-sources () "A mixed batch should synchronize each final runtime node exactly once." (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")) (sync-count 0) (original-sync (symbol-function 'ebox--sync-scroll-state-lines-for-node))) (should (plist-get (ebox-scroll-state root-id) :content-lines-complete-p)) (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) (lambda (&rest args) (cl-incf sync-count) (apply original-sync args)))) (ebox-incremental-begin-batch buffer) (ebox-region-update target-id :content "batched target row") (ebox-region-update target-id :color "#2563EB") (ebox-incremental-flush buffer)) (let* ((runtime (gethash target-id ebox--region-box-table)) (visible-before (buffer-string)) (state (ebox-scroll-state root-id)) (content-lines (plist-get state :content-lines)) (rendered-lines (plist-get state :rendered-content-lines))) (should (= 2 (ebox--scroll-region-by root-id 2))) (should (= -2 (ebox--scroll-region-by root-id -2))) (let ((visible-after (buffer-string))) (should (equal (list (ebox-get runtime :content) (ebox-get runtime :color) (and (string-match-p "batched target row" (substring-no-properties visible-before)) t) (ebox-test--text-has-foreground-p visible-before "batched target row" "#2563EB") (ebox-test--lines-have-text-p content-lines "batched target row") (ebox-test--lines-have-text-foreground-p content-lines "batched target row" "#2563EB") (ebox-test--lines-have-text-p rendered-lines "batched target row") (ebox-test--lines-have-text-foreground-p rendered-lines "batched target row" "#2563EB") (and (string-match-p "batched target row" (substring-no-properties visible-after)) t) (ebox-test--text-has-foreground-p visible-after "batched target row" "#2563EB") sync-count) '("batched target row" "#2563EB" t t t t t t t t 1))))))))) (ert-deftest ebox-explicit-batch-synchronizes-distinct-scroll-cache-nodes () "A batch flush should synchronize each final node from any current buffer." (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")) calls (original-sync (symbol-function 'ebox--sync-scroll-state-lines-for-node))) (should (plist-get (ebox-scroll-state root-id) :content-lines-complete-p)) (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) (lambda (sync-buffer region-id node changed-keys report) (push (list sync-buffer region-id (ebox-tree-node-id node) changed-keys) calls) (funcall original-sync sync-buffer region-id node changed-keys report)))) (ebox-incremental-begin-batch buffer) (with-temp-buffer (ebox-region-update target-id :color "#DC2626") (ebox-region-update second-id :color "#2563EB") (ebox-incremental-flush buffer))) (should (= (length calls) 2)) (should (cl-every (lambda (call) (eq (car call) buffer)) calls)) (should (equal (sort (mapcar (lambda (call) (nth 2 call)) calls) #'string-lessp) '("second-target" "target"))) (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-scroll-sync-hook-skips-unsuccessful-flushes () "Empty, no-op, and failed batch flushes should not publish sync work." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--complete-lazy-scroll-batch-layout))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (target-id (ebox-test--selector-region-id buffer "#target")) (hook-count 0) (ebox-incremental--after-successful-batch-flush-hook (list (lambda (&rest _args) (cl-incf hook-count))))) (ebox-incremental-begin-batch buffer) (should (eq (plist-get (ebox-incremental-flush buffer) :strategy) 'no-op)) (should (= hook-count 0)) (ebox-incremental-begin-batch buffer) (should (ebox-incremental-record-region-change buffer target-id 'paint '(:color))) (cl-letf (((symbol-function 'ebox--execute-constraint-change) (lambda (&rest _args) (ebox--update-report target-id 'no-op)))) (should (eq (plist-get (ebox-incremental-flush buffer) :strategy) 'no-op))) (should (= hook-count 0)) (ebox-incremental-begin-batch buffer) (should (ebox-incremental-record-region-change buffer target-id 'paint '(:color))) (cl-letf (((symbol-function 'ebox--execute-constraint-change) (lambda (&rest _args) (error "expected flush failure")))) (should-error (ebox-incremental-flush buffer) :type 'error)) (should (= hook-count 0)))))) (ert-deftest ebox-explicit-batch-flush-retries-after-quit () "A quit during a batch flush must keep the recorded changes retryable." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Batch retry" :color "black")) (id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (let ((buffer (current-buffer))) (ebox-incremental-begin-batch buffer) (ebox-region-update id :color "red") (should (= (ebox-incremental-pending-count buffer) 1)) (cl-letf (((symbol-function 'ebox--execute-constraint-change) (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-paint-sync-does-not-rerender-node () "Paint updates inside scroll caches should patch cached faces without render." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 32 collect `(box :id ,(cond ((= idx 1) "target") ((= idx 31) "hidden-target") (t (format "row-%02d" idx))) :content ,(if (= idx 1) "target row" (format "row %02d" idx)) :height 1 :color "#111111")))))) (buffer nil) target-id root-id (render-count 0) (index-build-count 0) (fallback-count 0) (face-in-lines-p (lambda (lines color) (cl-some (lambda (line) (let* ((plain (substring-no-properties line)) (pos (string-match "target row" plain))) (and pos (ebox-test--face-has-key-value-p (get-text-property pos 'face line) :foreground color)))) lines))) (original-render (symbol-function 'ebox-render)) (original-index-build (symbol-function 'ebox--scroll-build-region-line-span-index)) (original-fallback (symbol-function 'ebox--sync-scroll-state-lines-for-node-fallback))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 3) (ebox-scroll-lazy-idle-prefetch-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq target-id (ebox-test--selector-region-id buffer "#target")) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let ((state (ebox-scroll-state root-id))) (should (plist-get state :rendered-region-line-span-index-deferred)) (should-not (plist-get state :rendered-region-line-span-index)) (cl-letf (((symbol-function 'ebox-render) (lambda (&rest args) (cl-incf render-count) (apply original-render args))) ((symbol-function 'ebox--scroll-build-region-line-span-index) (lambda (&rest args) (cl-incf index-build-count) (apply original-index-build args))) ((symbol-function 'ebox--sync-scroll-state-lines-for-node-fallback) (lambda (&rest args) (cl-incf fallback-count) (apply original-fallback args)))) (ebox-region-update target-id :color "#DC2626") (setq state (ebox-scroll-state root-id)) (should (plist-get state :rendered-region-line-span-index-deferred)) (should-not (plist-get state :rendered-region-line-span-index)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (plist-get report :scroll-state-paint-sync)) (should (plist-get report :scroll-state-index-preserved))) (let ((lines (plist-get state :content-lines))) (should-not (cl-some (lambda (line) (string-match-p "row 31" (substring-no-properties line))) lines)) (dolist (key '(:content-lines :rendered-content-lines)) (should (funcall face-in-lines-p (plist-get state key) "#DC2626")))) (ebox-region-update target-id :border-left-color "#F59E0B") (should (= fallback-count 0)) (ebox-selector-update-buffer buffer "#hidden-target" :color "#2563EB") (setq state (ebox-scroll-state root-id)) (should (plist-get state :lazy-scroll-prefix-dirty)) (ebox-region-update target-id :color "#16A34A") (setq state (ebox-scroll-state root-id)) (should (plist-get state :lazy-scroll-prefix-dirty)) (should (plist-get state :rendered-region-line-span-index-deferred)) (should-not (plist-get state :rendered-region-line-span-index)) (dolist (key '(:content-lines :rendered-content-lines)) (should (funcall face-in-lines-p (plist-get state key) "#16A34A"))) (should (= fallback-count 0)) (should (= render-count 0)) (should (= index-build-count 0))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-prefix-appends-rendered-content-lines () "Lazy prefix extension should render only newly appended visible lines." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :id ,(format "rendered-tail-row-%02d" idx) :content ,(format "rendered tail row %02d" idx) :height 1)))))) (buffer nil) root-id rendered-line-counts (original-rendered-lines (symbol-function 'ebox--scroll-rendered-content-lines))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-wheel-scroll-step 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (cl-letf (((symbol-function 'ebox--scroll-rendered-content-lines) (lambda (&rest args) (push (list (length (cadr args)) (nth 3 args)) rendered-line-counts) (apply original-rendered-lines args)))) (dotimes (_ 2) (let* ((state (ebox--scroll-get-state root-id)) (current-lines (length (plist-get state :content-lines)))) (ebox--scroll-state-ensure-prefix-lines root-id state (+ current-lines 4))))) (should (cl-some (lambda (entry) (and (<= (car entry) 5) (integerp (cadr entry)) (> (cadr entry) 0))) rendered-line-counts)) (dolist (entry rendered-line-counts) (should (<= (car entry) 8)))) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-prefix-avoids-full-prefix-joins () "Lazy prefix extension should not join the full cached prefix string." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :id ,(format "join-row-%02d" idx) :content ,(format "join row %02d" idx) :height 1)))))) (buffer nil) root-id join-sizes (original-join (symbol-function 'ebox-lines-join))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-wheel-scroll-step 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (cl-letf (((symbol-function 'ebox-lines-join) (lambda (&rest args) (push (length (car args)) join-sizes) (apply original-join args)))) (let* ((state (ebox--scroll-get-state root-id)) (current-lines (length (plist-get state :content-lines)))) (ebox--scroll-state-ensure-prefix-lines root-id state (+ current-lines 4)))) (should-not (cl-some (lambda (size) (> size 5)) join-sizes))) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-prefix-reuses-signature-cache () "Lazy prefix extension should not recompute node signatures each slice." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll))) (source-node (ebox-build `(column :width (viewport) (flex :id "signature-flex" :width (viewport) :flex-flow (row wrap) :row-gap 1 :column-gap (8) :padding (0 (12)) :border "#D97757" :border-top-p nil ,@(cl-loop for idx below 24 collect `(box :content ,(format "signature %02d" idx) :width (100) :height 2)))))) (source (list :stack source-node)) (renderer (ebox--scroll-window-prefix-renderer layout source 240 2 nil nil)) signature-caches (original-node-signature (symbol-function 'ebox--render-cache-node-signature))) (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox--render-cache-table (make-hash-table :test 'equal)) (ebox--render-cache-signature-cache nil)) (cl-letf (((symbol-function 'ebox--render-cache-node-signature) (lambda (node) (when (and (listp node) ebox--render-cache-signature-cache) (push ebox--render-cache-signature-cache signature-caches)) (funcall original-node-signature node)))) (funcall renderer (list :rendered-content-lines nil) "signature-region" 4) (funcall renderer (list :rendered-content-lines nil) "signature-region" 8)) (should (> (length signature-caches) 1)) (let ((first-cache (car signature-caches))) (dolist (cache (cdr signature-caches)) (should (eq cache first-cache))))))) (ert-deftest ebox-lazy-scroll-prefix-appends-generic-wrapper-lines () "Lazy prefix extension should append wrapper chrome without rerendering it." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) :box-sizing border-box :padding (1 (8)) :border "#555555" :bgcolor "#FAFAFA" :color "#111111" ,@(cl-loop for idx below 20 collect `(box :id ,(format "wrapped-row-%02d" idx) :content ,(format "wrapped row %02d" idx) :height 1)))))) (buffer nil) root-id (wrapper-render-count 0) (original-render (symbol-function 'ebox-render))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 1) (ebox-wheel-scroll-step 0) (ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-idle-prefetch-lines 3) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (with-current-buffer buffer (let ((state (ebox--scroll-get-state root-id))) (should (= (length (plist-get state :content-lines)) 2)) (should (plist-get state :render-content-prefix))) (cl-letf (((symbol-function 'ebox-render) (lambda (node) (when (and (listp node) (eq (plist-get node :ebox-type) 'box) (plist-get node :ebox-content-width-exact-p) (null (plist-get node :ebox-content-node))) (cl-incf wrapper-render-count)) (funcall original-render node)))) (ebox--scroll-idle-prefetch root-id)) (should (= wrapper-render-count 0)) (should (> (length (plist-get (ebox--scroll-get-state root-id) :content-lines)) 2)))) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-first-smooth-wheel-uses-prefetched-prefix () "The first smooth wheel input should only replace visible lines." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-wheel-smooth-scroll-interval 60.0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix))) (should (<= (+ (plist-get state :content-height) ebox-wheel-scroll-step) (length (plist-get state :content-lines)))) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 16)) (while (gethash root-id ebox--smooth-scroll-state-table) (ebox--smooth-scroll-tick root-id)) (should (string-match-p "row 16" (buffer-substring-no-properties (point-min) (point-max)))))) (should (= prefix-count 0)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 16))) (ebox--smooth-scroll-stop root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)))) (ert-deftest ebox-lazy-scroll-incremental-smooth-wheel-uses-prefetched-prefix () "Post-resize lazy scroll should not extend prefixes inside smooth ticks." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "resize-row-%02d" idx) :content ,(format "resize row %02d" idx) :height 1)))))) (buffer nil) root-id (prefix-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 6) (ebox--defer-scroll-content-index t) (ebox-wheel-smooth-scroll t) (ebox-wheel-scroll-step 16) (ebox-wheel-smooth-scroll-lines-per-tick 4) (ebox-wheel-smooth-scroll-interval 60.0) (ebox-scroll-lazy-idle-prefetch-lines 0) (ebox--scroll-allow-noninteractive-prefetch nil) (ebox-scroll-lazy-prefix-lookahead-lines 8)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (render-prefix (plist-get state :render-content-prefix))) (plist-put state :render-content-prefix (lambda (&rest args) (cl-incf prefix-count) (apply render-prefix args))) (puthash root-id state ebox--scroll-global-state)) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 16)) (while (gethash root-id ebox--smooth-scroll-state-table) (ebox--smooth-scroll-tick root-id)) (should (string-match-p "resize row 16" (buffer-substring-no-properties (point-min) (point-max)))))) (should (= prefix-count 0)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 16))) (ebox--smooth-scroll-stop root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)))) (ert-deftest ebox-viewport-rerender-schedules-urgent-scroll-prefetch () "Viewport rerender should warm lazy scroll prefixes before fast scrolling." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 80 collect `(box :id ,(format "urgent-row-%02d" idx) :content ,(format "urgent row %02d" idx) :height 1)))))) (buffer nil) root-id delays (original-schedule (symbol-function 'ebox--scroll-schedule-idle-prefetch))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 4) (ebox-scroll-lazy-idle-prefetch-delay 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (ebox--scroll-cancel-idle-prefetch root-id) (cl-letf (((symbol-function 'ebox--scroll-schedule-idle-prefetch) (lambda (region-id &optional delay) (push delay delays) (funcall original-schedule region-id delay)))) (ebox-rerender-buffer-with-context buffer 300 4)) (should (member 0 delays))) (ebox--scroll-cancel-idle-prefetch root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-smooth-scroll-defers-gc-during-animation () "Smooth scrolling should keep render GC settings until the animation stops." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (old-threshold gc-cons-threshold) (old-percentage gc-cons-percentage) (target-threshold 2000)) (unwind-protect (let ((gc-cons-threshold 1000) (ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-render-gc-cons-threshold target-threshold) (ebox-deferred-render-gc-cons-threshold target-threshold) (ebox-render-gc-cons-percentage 0.9) (ebox-wheel-smooth-scroll-lines-per-tick 1) (ebox-wheel-smooth-scroll-interval 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (with-current-buffer buffer (should (ebox--smooth-scroll-region-by root-id 4))) (should (>= gc-cons-threshold target-threshold)) (should (>= gc-cons-percentage 0.9)) (should ebox--deferred-render-gc-state) (ebox--smooth-scroll-stop root-id) (should (timerp ebox--deferred-render-gc-timer))) (ebox--smooth-scroll-stop root-id) (when (timerp ebox--deferred-render-gc-timer) (cancel-timer ebox--deferred-render-gc-timer)) (setq ebox--deferred-render-gc-timer nil) (setq ebox--deferred-render-gc-state nil) (setq gc-cons-threshold old-threshold) (setq gc-cons-percentage old-percentage) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (defun ebox-test--visible-foreground (face) "Return the highest-priority :foreground carried by FACE." (cond ((null face) nil) ((and (listp face) (keywordp (car face))) (plist-get face :foreground)) ((listp face) (cl-some #'ebox-test--visible-foreground face)) (t nil))) (ert-deftest ebox-ancestor-paint-patch-preserves-descendant-own-paint () "A parent paint-patch must not clobber a child's own declared paint." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id parent :width (120) :color "black" (column :width (120) (box :id child :content "Child" :width (120) :color "blue") (box :id plain :content "Plain" :width (120))))))) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (parent-id (ebox-test--selector-region-id buffer "#parent"))) (ebox-region-update parent-id :color "red") (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'paint-patch)) (goto-char (point-min)) (search-forward "Child") ;; The child's own declaration stays the highest-priority paint. (should (equal (ebox-test--visible-foreground (get-text-property (match-beginning 0) 'face)) "blue")) (goto-char (point-min)) (search-forward "Plain") ;; A non-declaring sibling inherits the new parent paint. (should (equal (ebox-test--visible-foreground (get-text-property (match-beginning 0) 'face)) "red")))))) (ert-deftest ebox-intrinsic-measurement-preserves-scroll-state () "Intrinsic measurement must not reset live scroll offsets or state." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 20 collect `(box :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 4)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (with-current-buffer buffer (should (ebox--scroll-region-by root-id 5 48))) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 5)) (let ((box (gethash root-id ebox--region-box-table))) (should box) ;; Flex ancestors measure live subtrees with the intrinsic ;; flag bound; the measurement render must be side-effect ;; free for the published runtime. (let ((ebox--intrinsic-layout-measurement t)) (ebox-render box)) (should (equal (ebox-get box :scroll-offset) 5))) (let ((state (ebox--scroll-get-state root-id))) (should state) (should (= (plist-get state :scroll-offset) 5)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-viewport-rerender-retries-after-quit () "A quit during viewport reflow must not wedge later same-size reflows." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) :content "viewport reflow retry"))) (buffer nil)) (unwind-protect (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (cl-letf (((symbol-function 'ebox--execute-constraint-change) (lambda (&rest _) (signal 'quit nil)))) (condition-case nil (ebox-rerender-buffer-with-context buffer 320) (quit nil))) ;; The interrupted reflow published nothing, so the stored ;; dimensions must still match the published render. (should (equal (plist-get (ebox--buffer-render-state buffer) :viewport-width) 240)) ;; The same-size retry must reflow instead of no-op. (let ((report (ebox-rerender-buffer-with-context buffer 320))) (should report) (should-not (eq (plist-get report :strategy) 'no-op))) (should (equal (plist-get (ebox--buffer-render-state buffer) :viewport-width) 320))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-smooth-scroll-multi-event-restores-gc-depth () "Coalesced wheel events must not leak the deferred GC lease depth." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (old-threshold gc-cons-threshold) (old-percentage gc-cons-percentage)) (unwind-protect (let ((gc-cons-threshold 1000) (ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-deferred-render-gc-cons-threshold 2000) (ebox-render-gc-cons-percentage 0.9) (ebox-wheel-smooth-scroll-lines-per-tick 1) (ebox-wheel-smooth-scroll-interval 999)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (with-current-buffer buffer ;; One trackpad flick delivers many wheel events that ;; coalesce into a single running animation. (dotimes (_ 5) (should (ebox--smooth-scroll-region-by root-id 4)))) (should (= ebox--deferred-render-gc-depth 1)) (ebox--smooth-scroll-stop root-id) (should (= ebox--deferred-render-gc-depth 0)) (should (timerp ebox--deferred-render-gc-timer))) (when root-id (ebox--smooth-scroll-stop root-id)) (when (timerp ebox--deferred-render-gc-timer) (cancel-timer ebox--deferred-render-gc-timer)) (setq ebox--deferred-render-gc-timer nil) (setq ebox--deferred-render-gc-state nil) (setq ebox--deferred-render-gc-depth 0) (setq gc-cons-threshold old-threshold) (setq gc-cons-percentage old-percentage) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-smooth-scroll-clamps-without-full-materialize () "Smooth wheel clamp should extend lazy scroll prefixes without full render." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id (hidden-render-count 0) (original-render-box (symbol-function 'ebox--render-box))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2)) (cl-letf (((symbol-function 'ebox--render-box) (lambda (box) (when (equal (ebox-tree-node-id box) "row-30") (cl-incf hidden-render-count)) (funcall original-render-box box)))) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) (ebox-wheel-smooth-scroll-interval 60.0)) (should (ebox--smooth-scroll-region-by root-id 2)) (should (= hidden-render-count 0)) (let ((state (ebox--scroll-get-state root-id))) (should (= (plist-get state :scroll-offset) 1)) (should (< (length (plist-get state :content-lines)) 40)) (should (plist-get state :materialize-content-lines)))))) (ebox--smooth-scroll-stop root-id) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-hidden-child-update-does-not-materialize () "Hidden updates inside lazy scroll windows should stay deferred." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 32 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id target-id next-target-id (materialize-count 0) (owner-rerenders 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-buffer-apply-owner-rerender) (lambda (&rest _args) (cl-incf owner-rerenders) nil)) ((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 (= owner-rerenders 0)) (should (= line-span-scans 0)) (should (= membership-builds 1)) (should (plist-get (ebox--scroll-get-state root-id) :content-region-id-set)) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :lazy-scroll-deferred-patch))) (should-not (string-match-p "new row 30" (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox--scroll-region-by root-id 30)) (should (= materialize-count 0)) (let ((state (ebox--scroll-get-state root-id))) (should-not (plist-get state :content-lines-complete-p)) (should (gethash root-id ebox--scroll-idle-prefetch-timers))) (ebox--scroll-idle-prefetch root-id) (let* ((state (ebox--scroll-get-state root-id)) (delta (- 30 (or (plist-get state :scroll-offset) 0)))) (when (> delta 0) (should (ebox--scroll-region-by root-id delta)))) (let ((state (ebox--scroll-get-state root-id))) (should (plist-get state :content-lines-complete-p)) (should-not (plist-get state :materialize-content-lines))) (should (string-match-p "new row 30" (buffer-substring-no-properties (point-min) (point-max)))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-prefix-extension-keeps-live-window-settled () "A clean offscreen prefix extension should not stall the live window." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for index below 40 collect `(box :content ,(format "row %02d" index) :width (300) :height 1)))))) root-id (ebox-viewport-width 300) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (let* ((state (ebox-scroll-state root-id)) (required-lines (+ 5 (length (plist-get state :content-lines))))) (should-not (plist-get state :content-lines-complete-p)) (setq state (ebox--scroll-state-ensure-prefix-lines root-id state required-lines t)) (should-not (plist-get state :lazy-scroll-window-refresh-required))) (should (= (ebox--scroll-region-by root-id 1 1) 1)) (should-not (plist-get (ebox-scroll-state root-id) :lazy-scroll-window-refresh-required)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-lazy-scroll-prefix-refreshes-without-offset-motion () "A visibly changed prefix should publish atomically without offset motion." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for index below 40 collect `(box :content ,(format "row %02d" index) :width (300) :height 1)))))) root-id (ebox-viewport-width 300) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (let* ((state (ebox-scroll-state root-id)) (render-prefix (plist-get state :render-content-prefix)) (required-lines (+ 5 (length (plist-get state :content-lines))))) (plist-put state :render-content-prefix (lambda (source-state region-id limit) (let* ((rendered (funcall render-prefix source-state region-id limit)) (lines (copy-sequence (plist-get rendered :rendered-content-lines))) (first (copy-sequence (car lines)))) (when (> (length first) 0) (put-text-property 0 1 'ebox-test-generation t first)) (setf (car lines) first) (plist-put rendered :rendered-content-lines lines) rendered))) (ebox--scroll-state-ensure-prefix-lines root-id state required-lines t) (should (plist-get (ebox-scroll-state root-id) :lazy-scroll-window-refresh-required))) (should (eq (ebox--scroll-region-by root-id 0) 'refreshed)) (should (= (plist-get (ebox-scroll-state root-id) :scroll-offset) 0)) (should-not (plist-get (ebox-scroll-state root-id) :lazy-scroll-window-refresh-required)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-visible-geometry-slot-miss-refreshes-lazy-window-atomically () "A visible slot miss should publish rebuilt lazy lines before returning." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) (flex :width (300) :height 5 :padding (0 (10)) :border "#6F6A95" :justify-content center :align-items center (box :id "target" :content "width target\nexpands" :width (190) :height 3 :padding (0 (10)) :border "#9CA3AF" :text-align center)) ,@(cl-loop for index below 40 collect `(box :content ,(format "row %02d" index) :width (300) :height 1)))))) root-id target-id (ebox-viewport-width 300) (ebox-viewport-height 5) (ebox-scroll-lazy-prefix-lookahead-lines 12) (rendered-lookups 0) (original-rendered-lookup (symbol-function 'ebox--scroll-hot-rendered-line-spans))) (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))) (cl-letf (((symbol-function 'ebox--scroll-hot-rendered-line-spans) (lambda (&rest args) (cl-incf rendered-lookups) (apply original-rendered-lookup args)))) (ebox-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 (= rendered-lookups 0)) (should-not (plist-get state :lazy-scroll-prefix-dirty)) (should-not (plist-get state :lazy-scroll-window-refresh-required)) (should (ebox-test--buffer-visually-matches-runtime-render-p))) ;; Later movement must start from the already-published cache generation. (should (= (ebox--scroll-region-by root-id 1 1) 1)) (should-not (plist-get (ebox-scroll-state root-id) :lazy-scroll-prefix-dirty)) (should-not (plist-get (ebox-scroll-state root-id) :lazy-scroll-window-refresh-required)) (should (= (ebox--scroll-region-by root-id -1 1) -1)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-lazy-scroll-cached-hidden-child-update-patches-prefix () "Hidden updates should patch cached lazy prefix lines before deferring." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) buffer root-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (with-current-buffer buffer (setq root-id (ebox-test--selector-region-id buffer "#root")) (should (ebox--scroll-region-by root-id 30 32)) (should (string-match-p "row 30" (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox--scroll-region-by root-id -30)) (should-not (string-match-p "new row 30" (buffer-substring-no-properties (point-min) (point-max)))) (ebox-selector-update-buffer buffer "#row-30" :content "new row 30") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should-not (plist-get report :lazy-scroll-deferred-patch)) (should (plist-get report :scroll-state-patch))) (should (ebox--scroll-region-by root-id 30)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "new row 30" plain)) (should-not (string-match-p "^row 30$" plain))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-deferred-update-dirties-future-prefix () "Deferred hidden updates should be visible when lazy prefix reaches them." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) buffer root-id (materialize-count 0)) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (with-current-buffer buffer (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox--scroll-get-state root-id)) (materialize (plist-get state :materialize-content-lines))) (plist-put state :materialize-content-lines (lambda (&rest args) (cl-incf materialize-count) (apply materialize args))) (puthash root-id state ebox--scroll-global-state)) (ebox-selector-update-buffer buffer "#row-30" :content "new row 30") (let ((report (ebox-test--buffer-update-report))) (should (plist-get report :lazy-scroll-deferred-patch))) (should (= materialize-count 0)) (cl-loop repeat 30 do (should (ebox--scroll-region-by root-id 1 1))) (should (= materialize-count 0)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "new row 30" plain)) (should-not (string-match-p "^row 30$" plain))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-dirty-prefix-rebinds-signature-cache () "Dirty lazy prefixes should rebind the render signature cache before rendering." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "" :width 10)) (renderer (ebox--scroll-window-prefix-renderer box :test-source 120 4)) first-cache second-cache (render-count 0)) (cl-letf (((symbol-function 'ebox--render-scroll-window-source) (lambda (&rest _args) (cl-incf render-count) (if first-cache (setq second-cache ebox--render-cache-signature-cache) (setq first-cache ebox--render-cache-signature-cache)) (list :lines (list (format "line %d" render-count)) :cache nil :complete nil :content-width-exact-p t :rendered-lines (list (format "line %d" render-count))))) ((symbol-function 'ebox--scroll-window-formatted-lines) (lambda (_box window) (plist-get window :lines))) ((symbol-function 'ebox--scroll-rendered-content-lines) (lambda (_box lines _region-id _start-index) lines))) (funcall renderer nil 1 1) (puthash :stale t first-cache) (funcall renderer '(:lazy-scroll-prefix-dirty t) 1 1) (should (= render-count 2)) (should-not (eq first-cache second-cache)) (should-not (gethash :stale second-cache))))) (ert-deftest ebox-visible-scroll-child-content-paint-patches-scroll-lines () "Visible content plus paint updates should patch scroll lines directly." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 8 collect `(box :id ,(format "metric-%02d" idx) :content "待运行" :width (120) :height 2)))))) buffer root-id) (unwind-protect (let ((ebox-viewport-width 260) (ebox-viewport-height 4) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (with-current-buffer buffer (setq root-id (ebox-test--selector-region-id buffer "#root")) (ebox-selector-update-buffer buffer "#metric-01" :content "结果:通过" :color "#B91C1C") (let* ((report (ebox-test--buffer-update-report)) (state (ebox--scroll-get-state root-id)) (plain (mapconcat #'substring-no-properties (plist-get state :content-lines) "\n"))) (should (plist-get report :scroll-state-patch)) (should (string-match-p "结果:通过" plain)) (should-not (string-match-p "metric-01.*待运行" plain))) (should (ebox--scroll-region-by root-id 1 1)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "结果:通过" plain))) (should (ebox-test--buffer-visually-matches-runtime-render-p)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-scroll-state-span-patch-key-policy-requires-content-for-paint () "Paint keys should only enter scroll-state patch with content changes." (should (ebox--scroll-state-span-patch-keys-p '(:content))) (should (ebox--scroll-state-span-patch-keys-p '(:content :color))) (should (ebox--scroll-state-span-patch-keys-p '(:content :bgcolor :border-top-color))) (should (ebox--scroll-state-span-patch-keys-p '(:text-align))) (should-not (ebox--scroll-state-span-patch-keys-p '(:color))) (should-not (ebox--scroll-state-span-patch-keys-p '(:bgcolor :border-top-color)))) (ert-deftest ebox-scroll-state-span-patch-ignores-foreign-runtime-states () "One buffer update must not materialize foreign or unrelated ownerless state." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :id "target" :content "before" :height 1) (box :content "tail" :height 1))))) (foreign-buffer (generate-new-buffer " *ebox-foreign-scroll*")) (foreign-id :foreign-scroll-state) (ownerless-id :ownerless-scroll-state) (foreign-materializations 0) (ownerless-materializations 0)) (unwind-protect (ebox-test--with-rendered-buffer layout (let ((target-id (ebox-test--selector-region-id (current-buffer) "#target"))) (puthash foreign-id (list :buffer foreign-buffer :content-lines '("foreign prefix") :materialize-content-lines (lambda (_state _region-id) (cl-incf foreign-materializations) '(:content-lines ("foreign complete") :rendered-content-lines ("foreign complete") :content-height 1))) ebox--scroll-global-state) (puthash ownerless-id (list :content-lines '("ownerless prefix") :materialize-content-lines (lambda (_state _region-id) (cl-incf ownerless-materializations) '(:content-lines ("ownerless complete") :rendered-content-lines ("ownerless complete") :content-height 1))) ebox--scroll-global-state) (ebox-region-update target-id :content "after") (should (= foreign-materializations 0)) (should (= ownerless-materializations 0)) (should (string-match-p "after" (buffer-substring-no-properties (point-min) (point-max)))))) (remhash foreign-id ebox--scroll-global-state) (remhash ownerless-id ebox--scroll-global-state) (when (buffer-live-p foreign-buffer) (kill-buffer foreign-buffer))))) (ert-deftest ebox-visible-scroll-child-sync-publishes-window-markers () "Post-patch scroll line sync must publish one current marker generation." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 8 collect `(box :id ,(format "metric-%02d" idx) :content "待运行" :width (120) :height 2)))))) buffer root-id) (unwind-protect (let ((ebox-viewport-width 260) (ebox-viewport-height 4) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (with-current-buffer buffer (setq root-id (ebox-test--selector-region-id buffer "#root")) (ebox-selector-update-buffer buffer "#metric-01" :padding-left (list 8) :color "#B91C1C") (let* ((report (ebox-test--buffer-update-report)) (state (ebox--scroll-get-state root-id))) (should (plist-get report :scroll-state-sync)) (should-not (plist-get report :scroll-state-window-markers-invalidated)) (should (> (or (plist-get report :scroll-state-window-marker-refresh-count) 0) 0)) (should (plist-get state :content-span-markers)) (should (plist-get state :content-window-region-id-set)) (should (ebox--scroll-state-window-markers-healthy-p buffer root-id state))) (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-published-window-markers-after-paint-update () "Paint updates and later scrolling should share one current marker window." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 24 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) buffer root-id) (unwind-protect (let ((ebox-viewport-width 260) (ebox-viewport-height 4) (ebox-scroll-lazy-prefix-lookahead-lines 0)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (with-current-buffer buffer (setq root-id (ebox-test--selector-region-id buffer "#root")) (should (plist-get (ebox-scroll-state root-id) :content-span-markers)) (should (plist-get (ebox-scroll-state root-id) :rendered-window-start-marker)) (ebox-selector-update-buffer buffer "#row-01" :color "#B91C1C") (let ((report (ebox-test--buffer-update-report)) (state (ebox-scroll-state root-id))) (should (plist-get report :scroll-state-paint-sync)) (should-not (plist-get report :scroll-state-window-markers-invalidated)) (should (plist-get state :content-span-markers)) (should (ebox--scroll-state-window-markers-healthy-p buffer root-id state))) (should (plist-get (ebox-scroll-state root-id) :rendered-window-start-marker)) (cl-loop repeat 6 do (should (ebox--scroll-region-by root-id 1 1)) (should (plist-get (ebox-scroll-state root-id) :rendered-window-start-marker)) (should (ebox--scroll-state-window-markers-healthy-p buffer root-id (ebox-scroll-state root-id))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-chromed-scroll-owner-rerender-publishes-current-window () "A scoped chromed owner rerender must publish its cache and live markers." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(column :id "doc" :width (240) (box :id "scroll" :width (180) :height 3 :overflow scroll :border-top-p t (column (box :id "target" :content "alpha" :width (100) :height 1) (box :content "tail one" :height 1) (box :content "tail two" :height 1) (box :content "tail three" :height 1)))))) (fallback-count 0)) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (scroll-id (ebox-test--selector-region-id buffer "#scroll")) (target-id (ebox-test--selector-region-id buffer "#target")) (scroll-node-id (ebox--ensure-node-id (plist-get (ebox-scroll-state scroll-id) :box))) (original-fallback (symbol-function 'ebox--sync-scroll-state-lines-for-node-fallback))) (cl-letf (((symbol-function 'ebox--patch-set-from-dirty-set) (lambda (dirty-set _buffer) (list (ebox--patch-op 'owner-rerender scroll-node-id :dirty (car dirty-set))))) ((symbol-function 'ebox--sync-scroll-state-lines-for-node-fallback) (lambda (&rest args) (cl-incf fallback-count) (apply original-fallback args)))) (ebox-region-update target-id :height 2)) (let* ((report (ebox-test--buffer-update-report)) (state (ebox-scroll-state scroll-id)) (spans (ebox--scroll-content-marker-spans (plist-get state :content-span-markers)))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (plist-get report :scroll-state-patch)) (should (plist-get report :scroll-state-owner-publication)) (should (= (or fallback-count 0) 0)) (should-not (plist-get state :lazy-scroll-prefix-dirty)) (should-not (plist-get state :lazy-scroll-window-refresh-required)) (should (= (length spans) (plist-get state :content-height))) (should (cl-every (lambda (span) (and (marker-buffer (car span)) (marker-buffer (cdr span)))) spans)) (should (ebox--scroll-state-window-markers-healthy-p buffer scroll-id state)) (should (ebox-test--buffer-visually-matches-runtime-render-p))) (should (= (ebox--scroll-region-by scroll-id 1 48) 1)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-owner-rerender-without-scroll-skips-scroll-publication () "A scroll-free root reflow should not scan or publish scroll marker state." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(column :width (viewport) (box :content "resize" :width (viewport))))) buffer (containment-count 0) (publication-count 0) (original-containment (symbol-function 'ebox--scroll-state-region-ids-containing-node-ids)) (original-publication (symbol-function 'ebox--publish-scoped-owner-scroll-windows))) (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))) ((symbol-function 'ebox--publish-scoped-owner-scroll-windows) (lambda (&rest arguments) (cl-incf publication-count) (apply original-publication arguments)))) (ebox-rerender-buffer-with-context buffer 260 4)) (should (= containment-count 0)) (should (= publication-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-nested-scroll-fallback-publishes-one-marker-generation () "Nested fallback sync must rebuild markers once without zero-delta scrolls." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "outer" :width (180) :height 3 :overflow scroll (column (box :id "inner" :width (160) :height 2 :overflow scroll (column (box :id "target" :content "old" :height 1) (box :content "inner 1" :height 1) (box :content "inner 2" :height 1))) (box :content "outer 1" :height 1) (box :content "outer 2" :height 1))))) (marker-refresh-count 0) (zero-delta-scroll-count 0)) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (outer-id (ebox-test--selector-region-id buffer "#outer")) (inner-id (ebox-test--selector-region-id buffer "#inner")) (target-id (ebox-test--selector-region-id buffer "#target")) (target-node (ebox--buffer-region-render-owner-node buffer target-id))) (ebox-region-update target-id :content "new") (should (ebox-test--buffer-visually-matches-runtime-render-p)) (dolist (scroll-id (list outer-id inner-id)) (let ((state (ebox-scroll-state scroll-id))) (plist-put state :lazy-scroll-window-refresh-required t) (puthash scroll-id state ebox--scroll-global-state))) (let ((visible-before (buffer-substring (point-min) (point-max))) (original-refresh (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) (original-scroll (symbol-function 'ebox--scroll-region-by))) (cl-letf (((symbol-function 'ebox--refresh-buffer-scroll-content-markers) (lambda (&rest args) (cl-incf marker-refresh-count) (apply original-refresh args))) ((symbol-function 'ebox--scroll-region-by) (lambda (region-id delta &rest args) (when (zerop delta) (cl-incf zero-delta-scroll-count)) (apply original-scroll region-id delta args)))) (ebox--sync-scroll-state-lines-for-node-fallback buffer target-id target-node '(:content) (ebox-test--buffer-update-report))) (should (= (or marker-refresh-count 0) 1)) (should (= (or zero-delta-scroll-count 0) 0)) (should (equal-including-properties visible-before (buffer-substring (point-min) (point-max))))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (dolist (scroll-id (list outer-id inner-id)) (let ((state (ebox-scroll-state scroll-id))) (should-not (plist-get state :lazy-scroll-window-refresh-required)) (should (ebox--scroll-state-window-extents scroll-id state)) (should (ebox--scroll-state-window-markers-healthy-p buffer scroll-id state)))))))) (ert-deftest ebox-root-scroll-owner-rerender-publishes-current-window () "Root scroll rerenders must publish both plain and chromed windows." (dolist (chromed '(nil t)) (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (180) :height 3 :overflow scroll :wrap-mode nil ,@(when chromed '(:border-top-p t)) (column (box :content "row 0" :height 1) (box :content "row 1" :height 1) (box :content "row 2" :height 1) (box :content "row 3" :height 1))))) (fallback-count 0)) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (root-id (ebox-test--selector-region-id buffer "#root")) (root-node-id (ebox--buffer-root-node-id buffer)) (original-fallback (symbol-function 'ebox--sync-scroll-state-lines-for-node-fallback))) (cl-letf (((symbol-function 'ebox--try-scroll-state-span-patch) (lambda (&rest _args) nil)) ((symbol-function 'ebox--patch-set-from-dirty-set) (lambda (dirty-set _buffer) (list (ebox--patch-op 'owner-rerender root-node-id :dirty (car dirty-set))))) ((symbol-function 'ebox--sync-scroll-state-lines-for-node-fallback) (lambda (&rest args) (cl-incf fallback-count) (apply original-fallback args)))) (ebox-region-update root-id :wrap-mode 'char)) (let ((report (ebox-test--buffer-update-report)) (state (ebox-scroll-state root-id))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (equal (plist-get report :owner-ids) (list root-node-id))) (should (plist-get report :scroll-state-patch)) (should (plist-get report :scroll-state-owner-publication)) (should (= (or fallback-count 0) 0)) (should-not (plist-get state :lazy-scroll-window-refresh-required)) (should (ebox--scroll-state-window-markers-healthy-p buffer root-id state)) (should (ebox-test--buffer-visually-matches-runtime-render-p))) (should (= (ebox--scroll-region-by root-id 1 48) 1)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-lazy-scroll-hidden-geometry-update-defers-rerender () "Hidden geometry changes in lazy scroll windows should not rerender roots." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 32 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :width (120) :height 1)))))) (buffer nil) target-id (owner-rerenders 0) (root-rerenders 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 target-id (ebox-test--selector-region-id buffer "#row-30")) (should target-id) (cl-letf (((symbol-function 'ebox-buffer-apply-owner-rerender) (lambda (&rest _args) (cl-incf owner-rerenders) nil)) ((symbol-function 'ebox-buffer-apply-root-rerender) (lambda (&rest _args) (cl-incf root-rerenders) nil))) (ebox-selector-update-buffer buffer "#row-30" :width '(180))) (let ((report (ebox-test--buffer-update-report))) (should (= owner-rerenders 0)) (should (= root-rerenders 0)) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :lazy-scroll-deferred-patch))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-lazy-scroll-hidden-paint-update-keeps-paint-path () "Paint-only updates should not pay lazy scroll geometry deferral costs." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) ,@(cl-loop for idx below 32 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :color "#111111" :height 1)))))) (buffer nil) target-id) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 2)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (with-current-buffer buffer (setq target-id (ebox-test--selector-region-id buffer "#row-30")) (should target-id) (ebox-selector-update-buffer buffer "#row-30" :color "#cc0000") (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'paint-patch)) (should-not (plist-get report :lazy-scroll-deferred-patch))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-scroll-rendered-content-patches-child-range-only () "Hidden child updates should patch the rendered scroll-line range." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :id "a" :content "A" :height 1) (box :id "target" :content "old" :height 1 :padding (0 (8)) :border "#999999") (box :id "c" :content "C" :height 1))))) root-id target-id (full-window-renders 0)) (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")) (cl-letf (((symbol-function 'ebox--render-scroll-state-window) (lambda (&rest _args) (cl-incf full-window-renders) nil))) (ebox-region-update target-id :content "new")) (let ((report (ebox-test--buffer-update-report))) (should (plist-get report :scroll-rendered-range-patch)) (should (= full-window-renders 0))) (should (ebox--scroll-region-by root-id 1)) (should (string-match-p "new" (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (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-scroll-state-builds-deferred-rendered-index-on-demand () "Deferred rendered line indexes should be built only when queried." (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 (ebox--scroll-rendered-line-spans-for-region-ids state '(1))) (should (= build-count 1)) (should (plist-get state :rendered-region-line-span-index)) (should-not (plist-get state :rendered-region-line-span-index-deferred)))))) (ert-deftest ebox-reflow-cache-prewarm-uses-first-valid-width-delta () "Predictive warming should start from the first valid resize velocity." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) :height 2 (box :content "steady" :width (viewport) :height 1)))) buffer scheduled-delay scheduled-repeat scheduled-function scheduled-arguments) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 4) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm t) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (cl-letf (((symbol-function 'run-at-time) (lambda (&rest _) (ert-fail "Resize prediction must yield to input"))) ((symbol-function 'run-with-idle-timer) (lambda (delay repeat function &rest arguments) (setq scheduled-delay delay scheduled-repeat repeat scheduled-function function scheduled-arguments arguments) 'scheduled-reflow-timer))) (ebox-rerender-buffer-with-context buffer 260 4)) (should (= scheduled-delay ebox-runtime-idle-reflow-cache-prewarm-delay)) (should-not scheduled-repeat) (should (eq scheduled-function #'ebox--run-reflow-cache-prewarm)) (should (equal (car scheduled-arguments) buffer)) (should (eq (gethash buffer ebox--reflow-cache-prewarm-timers) 'scheduled-reflow-timer)) (should (= (plist-get (ebox--buffer-render-state buffer) :last-viewport-width-delta) 20))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-reflow-cache-prewarm-isolates-published-runtime-state () "Predicted renders should publish only persistent render-cache entries." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (flex :width (viewport) :gap 1 ,@(cl-loop for index below 8 collect `(box :id ,(format "card-%d" index) :content ,(format "card %d" index) :width 72 :height 1)))))) buffer) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 4) (ebox-runtime-idle-prewarm nil)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (ebox--cancel-buffer-runtime-prewarm buffer) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) (root-before (copy-tree root)) (text-before (with-current-buffer buffer (buffer-string))) (cache (plist-get state :render-cache)) (cache-count (hash-table-count cache)) (signature-count (hash-table-count (plist-get state :render-signature-cache))) (root-id (ebox-test--selector-region-id buffer "#root")) (scroll-before (copy-tree (ebox--scroll-get-state root-id))) (region-table-count (hash-table-count ebox--region-box-table))) (ebox-test--set-buffer-update-report buffer 'keep-this-report) (should (ebox--prewarm-buffer-render-cache-at-context buffer state 0 240 280 4)) (should (> (hash-table-count cache) cache-count)) (should (= (hash-table-count (plist-get state :render-signature-cache)) signature-count)) (should (equal root root-before)) (should (equal-including-properties (with-current-buffer buffer (buffer-string)) text-before)) (should (= (plist-get state :viewport-width) 240)) (should (equal (ebox--scroll-get-state root-id) scroll-before)) (should (= (hash-table-count ebox--region-box-table) region-table-count)) (should (eq (ebox-buffer-update-report buffer) 'keep-this-report)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-reflow-cache-prewarm-rejects-stale-runtime-revision () "A stale predicted render should exit before touching render caches." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) :content "stale"))) buffer (render-count 0) (original-render (symbol-function 'ebox-render))) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 4) (ebox-runtime-idle-prewarm nil)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (ebox--cancel-buffer-runtime-prewarm buffer) (let* ((state (ebox--buffer-render-state buffer)) (cache (plist-get state :render-cache)) (cache-count (hash-table-count cache))) (cl-letf (((symbol-function 'ebox-render) (lambda (&rest args) (cl-incf render-count) (apply original-render args)))) (should-not (ebox--prewarm-buffer-render-cache-at-context buffer state 1 240 280 4))) (should (= render-count 0)) (should (= (hash-table-count cache) cache-count)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-local-update-cancels-without-scheduling-reflow-prewarm () "Single-property updates should cancel stale predictions and schedule none." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) (box :id "target" :content "old" :width 80)))) buffer) (unwind-protect (let ((ebox-viewport-width 240) (ebox-viewport-height 4) (ebox-runtime-idle-prewarm nil) (ebox-runtime-idle-reflow-cache-prewarm t) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (ebox-rerender-buffer-with-context buffer 260 4) (ebox-rerender-buffer-with-context buffer 280 4) (should (gethash buffer ebox--reflow-cache-prewarm-timers)) (ebox-selector-update-buffer buffer "#target" :content "new") (should-not (gethash buffer ebox--reflow-cache-prewarm-timers)) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'span-patch))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-prepares-shared-update-state () "Idle prewarm should prepare shared update state without materializing scrolls." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height 2 :overflow scroll (column ,@(cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) root-id expected-viewport-ids (materialize-count 0)) (unwind-protect (let ((ebox-viewport-width 900) (ebox-scroll-lazy-idle-prefetch-lines 0) (ebox-runtime-idle-prewarm-slice-size 10000) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (setq expected-viewport-ids (ebox--viewport-dependent-node-ids layout)) (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-runtime-idle-prewarm-does-not-wait-for-prefix-prefetch () "Prewarm should index current scroll lines while lazy prefix work is active." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :id "target" :content "target" :height 1) (box :id "row-1" :content "row 1" :height 1) (box :id "row-2" :content "row 2" :height 1))))) (buffer nil) root-id) (unwind-protect (let ((ebox-runtime-idle-prewarm-slice-size 10000) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (puthash root-id t ebox--scroll-idle-prefetch-timers) (ebox--runtime-prewarm-drain buffer) (let ((state (ebox-scroll-state root-id))) (should (plist-get state :region-line-span-index)) (should (plist-get state :rendered-region-line-span-index)))) (remhash root-id ebox--scroll-idle-prefetch-timers) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-takes-priority-over-prefix-prefetch () "Lazy prefix warming should yield while shared runtime prewarming is active." (ebox-test--reset-runtime-state) (let ((buffer nil) root-id (prefix-render-count 0) exact-prefix scheduled-delay) (unwind-protect (let ((ebox-scroll-lazy-idle-prefetch-lines 8) (ebox--scroll-allow-noninteractive-prefetch t) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :content "row 0" :height 1) (box :content "row 1" :height 1)))))) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let* ((state (ebox-scroll-state root-id)) (job (gethash buffer ebox--runtime-prewarm-jobs))) (setq state (plist-put state :render-content-prefix t)) (setq state (plist-put state :content-lines-complete-p nil)) (setq state (plist-put state :buffer buffer)) (puthash root-id state ebox--scroll-global-state) (cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines) (lambda (&rest _args) (cl-incf prefix-render-count) state)) ((symbol-function 'ebox--scroll-schedule-idle-prefetch) (lambda (_region-id &optional delay) (setq scheduled-delay delay)))) (ebox--scroll-idle-prefetch root-id)) (should (= prefix-render-count 0)) (should (eq (gethash buffer ebox--runtime-prewarm-jobs) job)) (should (equal scheduled-delay ebox-runtime-idle-prewarm-prefix-resume-delay)))) (ebox--scroll-cancel-idle-prefetch root-id) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-does-not-delay-cache-miss-prefetch () "Urgent cache-miss prefetch should bypass shared prewarm priority." (ebox-test--reset-runtime-state) (let ((buffer nil) root-id (prefix-render-count 0) scheduled-delay) (unwind-protect (let ((ebox-scroll-lazy-idle-prefetch-lines 8) (ebox--scroll-allow-noninteractive-prefetch t) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :content "row 0" :height 1) (box :content "row 1" :height 1)))))) (setq root-id (ebox-test--selector-region-id buffer "#root")) (let ((state (ebox-scroll-state root-id))) (setq state (plist-put state :render-content-prefix t)) (setq state (plist-put state :content-lines-complete-p nil)) (setq state (plist-put state :cache-miss-prefetch-target-lines 8)) (setq state (plist-put state :buffer buffer)) (puthash root-id state ebox--scroll-global-state) (cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines) (lambda (_region-id input-state target-lines &optional exact) (cl-incf prefix-render-count) (setq exact-prefix exact) (setq input-state (plist-put input-state :content-lines (make-list target-lines (ebox-pixel-space 1)))) (plist-put input-state :content-lines-complete-p t))) ((symbol-function 'ebox--scroll-schedule-idle-prefetch) (lambda (_region-id &optional delay) (setq scheduled-delay delay))) ((symbol-function 'ebox--schedule-buffer-runtime-prewarm) (lambda (&rest _args) nil))) (ebox--scroll-idle-prefetch root-id))) (should (= prefix-render-count 1)) (should exact-prefix) (should-not (equal scheduled-delay ebox-runtime-idle-prewarm-prefix-resume-delay))) (ebox--scroll-cancel-idle-prefetch root-id) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-idle-prefix-slices-do-not-restart-runtime-prewarm () "Consecutive prefix slices should not starve behind repeated tree prewarms." (ebox-test--reset-runtime-state) (let* ((buffer (generate-new-buffer " *ebox-prefix-slice-test*")) (region-id "prefix-slice-scroll") (state (list :buffer buffer :scroll-offset 0 :content-height 2 :content-lines '("row 0" "row 1") :render-content-prefix t :content-lines-complete-p nil)) (runtime-prewarm-count 0) (prefix-reschedule-count 0)) (unwind-protect (let ((ebox-scroll-lazy-idle-prefetch-lines 8) (ebox-scroll-lazy-idle-prefetch-slice-lines 1)) (puthash region-id state ebox--scroll-global-state) (cl-letf (((symbol-function 'ebox--scroll-state-ensure-prefix-lines) (lambda (_region-id input-state _target-lines &optional _exact) (plist-put input-state :content-lines (append (plist-get input-state :content-lines) '("row 2"))) (puthash region-id input-state ebox--scroll-global-state) input-state)) ((symbol-function 'ebox--scroll-schedule-idle-prefetch) (lambda (&rest _args) (cl-incf prefix-reschedule-count))) ((symbol-function 'ebox--schedule-buffer-runtime-prewarm) (lambda (&rest _args) (cl-incf runtime-prewarm-count)))) (ebox--scroll-idle-prefetch region-id)) (should (= prefix-reschedule-count 1)) (should (= runtime-prewarm-count 0))) (remhash region-id ebox--scroll-global-state) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-yields-between-slices () "Each prewarm slice should leave an idle interval for redisplay." (ebox-test--reset-runtime-state) (let ((buffer nil) scheduled-delay) (unwind-protect (let ((ebox-runtime-idle-prewarm-delay 0.1) (ebox-runtime-idle-prewarm-slice-size 1) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(box :height 2 :overflow scroll (column (box :content "row 0" :height 1) (box :content "row 1" :height 1) (box :content "row 2" :height 1)))))) (cl-letf (((symbol-function 'current-idle-time) (lambda () (seconds-to-time 0.4))) ((symbol-function 'ebox--runtime-prewarm-schedule-timer) (lambda (_buffer &optional delay) (setq scheduled-delay delay)))) (ebox--runtime-prewarm-timer buffer)) (should (equal scheduled-delay 0.5))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-removes-first-scroll-style-index-build () "A prewarmed runtime should reuse scroll indexes on its first style update." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) :height 2 :overflow scroll (column (box :id "target" :content "old" :height 1) (box :id "head-1" :content "head 1" :height 1) (box :id "head-2" :content "head 2" :height 1) (box :id "head-3" :content "head 3" :height 1))))) (buffer nil) root-id target-id (build-count 0) (scan-count 0) (full-snapshot-refresh-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 target-id (ebox-test--selector-region-id buffer "#target")) (ebox--runtime-prewarm-drain buffer) (let ((original-build (symbol-function 'ebox--scroll-build-region-line-span-index)) (original-scan (symbol-function 'ebox--scroll-state-region-line-spans)) (original-refresh (symbol-function 'ebox--refresh-buffer-layout-snapshots))) (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) (lambda (&rest args) (cl-incf build-count) (apply original-build args))) ((symbol-function 'ebox--scroll-state-region-line-spans) (lambda (&rest args) (cl-incf scan-count) (apply original-scan args))) ((symbol-function 'ebox--refresh-buffer-layout-snapshots) (lambda (&rest args) (cl-incf full-snapshot-refresh-count) (apply original-refresh args)))) (with-current-buffer buffer (ebox-region-update target-id :content "new")))) (should (= build-count 0)) (should (= scan-count 0)) (should (= full-snapshot-refresh-count 0)) (let ((state (ebox-scroll-state root-id))) (should-not (plist-get state :region-line-span-index)) (should (plist-get state :region-line-span-index-deferred))) (should (gethash buffer ebox--runtime-prewarm-jobs)) (should (plist-get (ebox-test--buffer-update-report) :scroll-state-index))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-discards-stale-runtime-revisions () "A runtime mutation should invalidate an older sliced prewarm job." (ebox-test--reset-runtime-state) (let ((buffer nil)) (unwind-protect (let ((ebox-runtime-idle-prewarm-slice-size 1) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(column (box :id "a" :content "A") (box :id "b" :content "B"))))) (should (gethash buffer ebox--runtime-prewarm-jobs)) (ebox--bump-buffer-runtime-revision buffer) (should-not (ebox--runtime-prewarm-step buffer)) (should-not (gethash buffer ebox--runtime-prewarm-jobs))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-cancels-before-update-errors () "A failing mutation should not leave an old prewarm job publishable." (ebox-test--reset-runtime-state) (let ((buffer nil)) (unwind-protect (let ((ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(box :id "target" :content "old")))) (should (gethash buffer ebox--runtime-prewarm-jobs)) (let ((target-id (ebox-test--selector-region-id buffer "#target"))) (should-error (cl-letf (((symbol-function 'ebox-incremental-record-region-change) (lambda (&rest _args) (error "forced update failure")))) (with-current-buffer buffer (ebox-region-update target-id :color "#DC2626"))))) (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-cancels-before-rerender-errors () "A failing viewport rerender should discard old prewarm work." (ebox-test--reset-runtime-state) (let ((buffer nil)) (unwind-protect (let ((ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(box :id "target" :content "old")))) (should (gethash buffer ebox--runtime-prewarm-jobs)) (should-error (cl-letf (((symbol-function 'ebox-incremental-rerender-buffer-with-context) (lambda (&rest _args) (error "forced rerender failure")))) (ebox-rerender-buffer-with-context buffer 360))) (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-reschedules-after-batched-updates () "A successful explicit batch should schedule shared-state prewarming." (ebox-test--reset-runtime-state) (let ((buffer nil)) (unwind-protect (let ((ebox-runtime-idle-prewarm-slice-size 10000) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(column (box :id "target" :content "old") (box :id "sibling" :content "same"))))) (ebox--runtime-prewarm-drain buffer) (should-not (gethash buffer ebox--runtime-prewarm-jobs)) (let ((target-id (ebox-test--selector-region-id buffer "#target"))) (ebox-incremental-begin-batch buffer) (with-current-buffer buffer (ebox-region-update target-id :color "#DC2626") (ebox-incremental-flush buffer))) (let ((job (gethash buffer ebox--runtime-prewarm-jobs)) (state (ebox--buffer-render-state buffer))) (should job) (should (= (plist-get job :runtime-revision) (plist-get state :runtime-revision))))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-cancels-during-open-batch () "An unflushed batch mutation should cancel stale shared-state prewarming." (ebox-test--reset-runtime-state) (let ((buffer nil)) (unwind-protect (let ((ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(box :id "target" :content "old")))) (should (gethash buffer ebox--runtime-prewarm-jobs)) (let ((target-id (ebox-test--selector-region-id buffer "#target"))) (ebox-incremental-begin-batch buffer) (with-current-buffer buffer (ebox-region-update target-id :padding-left 9))) (should (= (ebox-incremental-pending-count buffer) 1)) (should-not (gethash buffer ebox--runtime-prewarm-jobs)) (should-not (gethash buffer ebox--runtime-prewarm-timers))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-avoids-unbounded-tree-scans () "Scheduling and draining prewarm should not scan all nodes in one callback." (ebox-test--reset-runtime-state) (let ((buffer nil) (node-table-scan-count 0) (viewport-scan-count 0)) (unwind-protect (let ((ebox--runtime-prewarm-allow-noninteractive t) (ebox-runtime-idle-prewarm-slice-size 1)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(column (box :content "row 0") (box :content "row 1") (box :content "row 2"))))) (ebox--cancel-buffer-runtime-prewarm buffer) (let ((node-table (ebox--buffer-node-table buffer)) (original-maphash (symbol-function 'maphash))) (cl-letf (((symbol-function 'maphash) (lambda (function table) (when (eq table node-table) (cl-incf node-table-scan-count)) (funcall original-maphash function table)))) (ebox--schedule-buffer-runtime-prewarm buffer))) (let ((original-viewport (symbol-function 'ebox--buffer-viewport-dependent-node-ids))) (cl-letf (((symbol-function 'ebox--buffer-viewport-dependent-node-ids) (lambda (&rest args) (cl-incf viewport-scan-count) (apply original-viewport args)))) (ebox--runtime-prewarm-drain buffer))) (should (= node-table-scan-count 0)) (should (= viewport-scan-count 0))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-preserves-viewport-dependency-semantics () "Sliced dependency discovery should match the canonical tree algorithm." (ebox-test--reset-runtime-state) (let ((buffer nil)) (unwind-protect (let ((ebox-viewport-width 360) (ebox-viewport-height 12) (ebox-runtime-idle-prewarm-slice-size 1) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") (ebox-build '(box :width (210) (column (box :id "width-only" :width (viewport)) (box :id "height-only" :height (viewport-height)) (flex :id "flex-height" :min-height (- (viewport-height) 1) (box :content "Flex"))))))) (let* ((state (ebox--buffer-render-state buffer)) (root (plist-get state :root-node)) (expected-axes (let ((ebox--viewport-dependent-node-ids-cache (make-hash-table :test 'eq)) (ebox--viewport-height-dependent-subtree-cache (make-hash-table :test 'eq))) (ebox--viewport-dependent-node-id-axes root))) (expected (delete-dups (copy-sequence (append (car expected-axes) (cdr expected-axes))))) (width-only-node-id (plist-get (car (ebox-selector-query-buffer buffer "#width-only")) :node-id)) (height-only-node-id (plist-get (car (ebox-selector-query-buffer buffer "#height-only")) :node-id))) (ebox--runtime-prewarm-drain buffer) (should (equal (plist-get state :viewport-dependent-node-id-axes) expected-axes)) (should (equal (plist-get state :viewport-dependent-node-ids) expected)) (pcase-let ((`(,width-ids . ,height-ids) (plist-get state :viewport-dependent-node-id-axes))) (should-not (member width-only-node-id width-ids)) (should-not (member width-only-node-id height-ids)) (should-not (member height-only-node-id width-ids)) (should (member height-only-node-id height-ids))) (should (plist-get state :viewport-dependent-node-ids-ready)))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-runtime-idle-prewarm-scroll-slices-use-list-cursors () "Late cached-line slices should not restart traversal from the list head." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :height 2 :overflow scroll (column ,@(cl-loop for idx below 40 collect `(box :content ,(format "row %02d" idx) :height 1)))))) (buffer nil) (nth-count 0) (original-nth (symbol-function 'nth))) (unwind-protect (let ((ebox-scroll-lazy-idle-prefetch-lines 0) (ebox-runtime-idle-prewarm-slice-size 3) (ebox--runtime-prewarm-allow-noninteractive t)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-prewarm-test*") layout)) (cl-letf (((symbol-function 'nth) (lambda (&rest args) (cl-incf nth-count) (apply original-nth args)))) (ebox--runtime-prewarm-drain buffer)) (should (= nth-count 0))) (when (buffer-live-p buffer) (kill-buffer buffer))))) (ert-deftest ebox-owner-rerender-defers-scroll-content-index () "Owner rerender should not rebuild full hidden scroll content indexes." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height 2 :overflow scroll (column ,@(cl-loop for idx below 24 collect `(box :content ,(format "row %02d alpha beta gamma" idx) :wrap-mode word)))))) (buffer nil) (build-count 0) (original (symbol-function 'ebox--scroll-build-region-line-span-index))) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) (lambda (&rest args) (cl-incf build-count) (apply original args)))) (ebox-rerender-buffer-with-context buffer 160)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'owner-rerender)) (should (= build-count 0)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-root-owner-viewport-rerender-uses-lightweight-scroll-window () "Root viewport rerender should defer broad indexes and keep scrolling." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height 3 :overflow scroll (column ,@(cl-loop for idx below 16 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d alpha beta gamma" idx) :wrap-mode word)))))) (buffer nil) root-id (hidden-prewarm-region-id "hidden-prewarm-scroll-state") (full-marker-refreshes 0) (full-role-refreshes 0) (full-property-scans 0) (original-refresh (symbol-function 'ebox--refresh-buffer-scroll-content-markers)) (original-role-refresh (symbol-function 'ebox-buffer-refresh-region-role-spans)) (original-property-scan (symbol-function 'ebox--refresh-box-extents-for-region-ids-in-spans))) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (setq root-id (ebox-test--selector-region-id buffer "#root")) (puthash hidden-prewarm-region-id t (ebox--buffer-region-id-set buffer)) (puthash hidden-prewarm-region-id (list :box (ebox-create :content "hidden" :height 2 :overflow 'scroll) :content-height 2) ebox--scroll-global-state) (cl-letf (((symbol-function 'ebox--refresh-buffer-scroll-content-markers) (lambda (&rest args) (cl-incf full-marker-refreshes) (apply original-refresh args))) ((symbol-function 'ebox-buffer-refresh-region-role-spans) (lambda (&rest args) (cl-incf full-role-refreshes) (apply original-role-refresh args))) ((symbol-function 'ebox--refresh-box-extents-for-region-ids-in-spans) (lambda (&rest args) (cl-incf full-property-scans) (apply original-property-scan args)))) (ebox-rerender-buffer-with-context buffer 160) (ebox-rerender-buffer-with-context buffer 200)) (should (= full-marker-refreshes 0)) (should (= full-role-refreshes 0)) (should (= full-property-scans 0)) (should-not (ebox--buffer-region-role-span-table buffer)) (let ((state (ebox--scroll-get-state root-id))) (should-not (plist-get state :content-span-markers)) (should (plist-get state :rendered-window-start-marker)) (should (plist-get state :rendered-window-end-marker))) (with-current-buffer buffer (ebox-region-update root-id :scroll-offset 1) (should (plist-get (ebox-test--buffer-update-report) :scroll-fast-path))) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (cl-letf (((symbol-function 'ebox-buffer-refresh-region-role-spans) (lambda (&rest args) (cl-incf full-role-refreshes) (apply original-role-refresh args)))) (with-current-buffer buffer (ebox-region-update root-id :bgcolor "#223344"))) (should (= full-role-refreshes 1)) (should (hash-table-p (ebox--buffer-region-role-span-table buffer))) (with-current-buffer buffer (should (ebox-test--buffer-visually-matches-runtime-render-p)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer)) (remhash hidden-prewarm-region-id ebox--scroll-global-state)))) (ert-deftest ebox-root-owner-chromed-scroll-rerender-uses-prepared-indexes () "A visible chromed scroll box should install prepared complete indexes." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height 3 :overflow scroll :border-top-p t (column ,@(cl-loop for idx below 8 collect `(box :content ,(format "row %02d" idx))))))) (buffer nil) root-id (full-marker-refreshes 0) (original-refresh (symbol-function 'ebox--refresh-buffer-scroll-content-markers))) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (setq root-id (ebox-test--selector-region-id buffer "#root")) (cl-letf (((symbol-function 'ebox--refresh-buffer-scroll-content-markers) (lambda (&rest args) (cl-incf full-marker-refreshes) (apply original-refresh args)))) (ebox-rerender-buffer-with-context buffer 160)) (should (= full-marker-refreshes 0)) (should (gethash ebox-buffer--role-span-table-template-key (ebox--buffer-region-role-span-table buffer))) (should (vectorp (plist-get (ebox--scroll-get-state root-id) :content-span-markers))) (with-current-buffer buffer (should (ebox-test--buffer-visually-matches-runtime-render-p)))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-root-owner-rerender-publishes-property-aware-patches () "A root owner rerender should publish only its property-aware delta." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) :content "responsive root\nsecond line\nthird line"))) (buffer nil) (owner-set-scans 0) (root-patch-publications 0) (original-owner-set (symbol-function 'ebox--string-content-owner-set)) (original-replace (symbol-function 'ebox-buffer--replace-root-with-property-patches))) (unwind-protect (progn (let ((ebox-viewport-width 240)) (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout))) (cl-letf (((symbol-function 'ebox--string-content-owner-set) (lambda (&rest args) (cl-incf owner-set-scans) (apply original-owner-set args))) ((symbol-function 'ebox-buffer--replace-root-with-property-patches) (lambda (spans rendered) (cl-incf root-patch-publications) (funcall original-replace spans rendered)))) (ebox-rerender-buffer-with-context buffer 160)) (should (= owner-set-scans 0)) (should (= root-patch-publications 1)) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'owner-rerender)) (should (eq (plist-get (ebox-test--buffer-update-report) :publication-scope) 'minimal-spans)) (should (plist-get (ebox-test--buffer-update-report) :published-buffer-spans)) (let ((expected (let ((ebox-viewport-width 160)) (ebox-render layout)))) (with-current-buffer buffer (should (equal-including-properties (buffer-substring (point-min) (point-max)) expected))))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-span-patch-defers-scroll-content-index () "Span patches should not rebuild full hidden scroll content indexes." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width 160 :height 2 :overflow scroll (column ,@(cl-loop for idx below 24 collect `(box :content ,(format "row %02d alpha beta gamma" idx) :wrap-mode word)))))) (buffer nil) root-id (build-count 0) (original (symbol-function 'ebox--scroll-build-region-line-span-index))) (unwind-protect (progn (setq buffer (ebox-render-to-buffer (generate-new-buffer-name " *ebox-test*") layout)) (setq root-id (ebox-test--selector-region-id buffer "#root")) (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) (lambda (&rest args) (cl-incf build-count) (apply original args)))) (should (ebox-buffer-apply-span-patch buffer (list :owner-id root-id :dirty (list :changed-keys '(:width)))))) (should (= build-count 0))) (when (and buffer (buffer-live-p buffer)) (kill-buffer buffer))))) (ert-deftest ebox-hidden-scroll-child-content-update-patches-scroll-state () "Hidden scroll children should update cached scroll lines without rerendering." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 1 :overflow scroll (column (box :content "visible" :height 1) (box :id "target" :content "old" :height 1) (box :content "tail" :height 1))))) root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (setq target-id (ebox-test--selector-region-id (current-buffer) "#target")) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "visible" plain)) (should-not (string-match-p "old" plain))) (let ((owner-rerenders 0)) (cl-letf (((symbol-function 'ebox-buffer-apply-owner-rerender) (lambda (&rest _args) (cl-incf owner-rerenders) nil))) (ebox-region-update target-id :content "new") (should (= owner-rerenders 0)))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)) (should-not (plist-get report :full-rerender))) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "visible" plain)) (should-not (string-match-p "new" plain))) (should (ebox--scroll-region-by root-id 1)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "new" plain)) (should-not (string-match-p "old" plain)))))) (ert-deftest ebox-visible-lazy-scroll-child-update-does-not-defer () "Lazy scroll children in the current visible range should patch immediately." (ebox-test--reset-runtime-state) (let* ((children (cl-loop for idx below 40 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1))) (layout (ebox-build `(box :id "root" :height 2 :overflow scroll (column ,@children)))) root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (should (ebox--scroll-region-by root-id 30)) (setq target-id (ebox-test--selector-region-id (current-buffer) "#row-30")) (should target-id) (should (string-match-p "row 30" (buffer-substring-no-properties (point-min) (point-max)))) (cl-letf (((symbol-function 'ebox--region-ids-visible-in-buffer-p) (lambda (&rest _args) nil))) (ebox-region-update target-id :content "new row 30")) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender root-rerender))) (should-not (plist-get report :lazy-scroll-deferred-patch))) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "new row 30" plain)))))) (ert-deftest ebox-visible-empty-scroll-child-content-update-settles-window () "An empty fixed box in a visible scene must not be mistaken for hidden." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build `(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) (flex :id "scene" :width (viewport) :height 4 :align-items center :justify-content center (box :id "target" :content "SEED" :width (180) :height 3 :overflow hidden :bgcolor "#DDEAE4")) ,@(cl-loop for index below 40 collect `(box :content ,(format "row %02d" index) :height 1)))))) (ebox-viewport-width 300) (ebox-viewport-height 4) (ebox-scroll-lazy-prefix-lookahead-lines 12) root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root") target-id (ebox-test--selector-region-id (current-buffer) "#target")) (should (plist-get (ebox-scroll-state root-id) :render-content-prefix)) (ebox-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-region-update target-id :content "NOW VISIBLE") (let ((report (ebox-test--buffer-update-report)) (state (ebox-scroll-state root-id))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)) (should-not (member (ebox--buffer-root-node-id (current-buffer)) (plist-get report :owner-ids))) (should-not (plist-get report :lazy-scroll-deferred-patch)) (should-not (plist-get state :lazy-scroll-prefix-dirty)) (should-not (plist-get state :lazy-scroll-window-refresh-required))) (should (string-match-p "NOW VISIBLE" (buffer-substring-no-properties (point-min) (point-max)))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-lazy-scroll-ignores-updates-to-sibling-status-box () "A sibling update must not materialize an unrelated lazy scroll state." (ebox-test--reset-runtime-state) (let* ((rows (cl-loop for index below 40 collect `(box :id ,(format "row-%02d" index) :content ,(format "row %02d" index) :height 1))) (layout (ebox-build `(column :width (viewport) (box :id "rail" :content "ready" :width (viewport) :height 1) (box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column ,@rows))))) (ebox-viewport-width 240) (ebox-viewport-height 3) (ebox-scroll-lazy-prefix-lookahead-lines 0) rail-id root-id materialize-count) (ebox-test--with-rendered-buffer layout (setq rail-id (ebox-test--selector-region-id (current-buffer) "#rail") root-id (ebox-test--selector-region-id (current-buffer) "#root")) (let* ((state (ebox-scroll-state root-id)) (materialize (plist-get state :materialize-content-lines))) (should materialize) (should-not (plist-get state :content-lines-complete-p)) (should (plist-get state :rendered-content-lines)) (plist-put state :materialize-content-lines (lambda (&rest arguments) (cl-incf materialize-count) (apply materialize arguments))) (puthash root-id state ebox--scroll-global-state)) (ebox-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-visible-scroll-marker-window-detects-owner-stack () "Visible marker checks should recognize owner-stack-only regions." (ebox-test--reset-runtime-state) (with-temp-buffer (let* ((region-id 4242) (line (propertize "owned visible line" 'ebox-content-owners (list region-id))) start end state) (insert line) (setq start (copy-marker (point-min) nil)) (setq end (copy-marker (point-max) t)) (setq state (list :content-span-markers (vector (cons start end)))) (should (ebox--scroll-state-region-visible-in-window-p state (list region-id))) (set-marker start nil) (set-marker end nil)))) (ert-deftest ebox-scroll-marker-window-visible-set-is-authoritative () "A cached visible region set should avoid marker text fallback scans." (ebox-test--reset-runtime-state) (with-temp-buffer (let* ((region-id 4343) (line (propertize "owned visible line" 'ebox-content-owners (list region-id))) (empty-visible-set (make-hash-table :test 'equal)) start end state (scans 0)) (insert line) (setq start (copy-marker (point-min) nil)) (setq end (copy-marker (point-max) t)) (setq state (list :content-window-region-id-set empty-visible-set :content-span-markers (vector (cons start end)))) (let ((original (symbol-function 'ebox--pos-region-ids))) (cl-letf (((symbol-function 'ebox--pos-region-ids) (lambda (&rest args) (cl-incf scans) (apply original args)))) (should-not (ebox--scroll-state-region-visible-in-window-p state (list region-id))))) (should (= scans 0)) (set-marker start nil) (set-marker end nil)))) (ert-deftest ebox-scroll-updates-rendered-window-extents-when-window-moves () "Scrolling should keep lightweight rendered window extents current." (ebox-test--reset-runtime-state) (let* ((children (cl-loop for idx below 20 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1))) (layout (ebox-build `(box :id "root" :height 2 :overflow scroll (column ,@children)))) root-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (should (plist-get (ebox-scroll-state root-id) :content-window-region-id-set)) (should (ebox--scroll-region-by root-id 1)) (let ((state (ebox-scroll-state root-id))) (should (plist-get state :rendered-window-start-marker)) (should (plist-get state :rendered-window-end-marker)) (should-not (plist-get state :content-span-markers)) (should-not (plist-get state :content-window-region-id-set)) (should (ebox-test--buffer-visually-matches-runtime-render-p)))))) (ert-deftest ebox-scroll-reveal-indexes-visible-child-paint () "A child revealed by scrolling should paint the live buffer immediately." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "root" :width (180) :height 2 :overflow scroll (column (box :id "row-0" :content "row 0" :height 1) (box :id "row-1" :content "row 1" :height 1) (box :id "row-2" :content "row 2" :height 1) (box :id "target" :content "target" :height 1) (box :id "row-4" :content "row 4" :height 1))))) root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (setq target-id (ebox-test--selector-region-id (current-buffer) "#target")) (should (ebox--scroll-region-by root-id 3 48)) (ebox-region-update target-id :color "#abcdef") (should (ebox--region-find target-id 'content)) (let* ((report (ebox-test--buffer-update-report)) (span (car (ebox--region-find target-id 'content))) (face (and span (get-text-property (car span) 'face)))) (should (eq (plist-get report :strategy) 'paint-patch)) (should (> (or (plist-get report :span-count) 0) 0)) (should (ebox-test--face-has-key-value-p face :foreground "#abcdef"))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox--scroll-region-by root-id -3 48)) (should (ebox--scroll-region-by root-id 3 48)) (let* ((span (car (ebox--region-find target-id 'content))) (face (and span (get-text-property (car span) 'face)))) (should (ebox-test--face-has-key-value-p face :foreground "#abcdef"))) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-scroll-reveal-keeps-visible-geometry-indexed () "Geometry updates on a revealed child should not fall back to broad scans." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "root" :width (180) :height 2 :overflow scroll (column (box :content "row 0" :height 1) (box :content "row 1" :height 1) (box :content "row 2" :height 1) (box :id "target" :content "target" :width (120) :height 1 :box-sizing border-box :padding-left (2)))))) root-id target-id (span-scans 0) (full-extent-refreshes 0)) (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)) (let ((original-scan (symbol-function 'ebox--region-set-line-spans-by-scan)) (original-extents (symbol-function 'ebox--refresh-buffer-box-extents))) (cl-letf (((symbol-function 'ebox--region-set-line-spans-by-scan) (lambda (&rest args) (cl-incf span-scans) (apply original-scan args))) ((symbol-function 'ebox--refresh-buffer-box-extents) (lambda (&rest args) (cl-incf full-extent-refreshes) (apply original-extents args)))) (ebox-region-update target-id :padding-left (list 12)))) (should (= span-scans 0)) (should (= full-extent-refreshes 0)) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'span-patch)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-scroll-role-markers-do-not-paint-a-displaced-row () "Scroll-displaced marker spans should not paint their replacement row." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "root" :height 3 :overflow scroll (column (box :content "row 0" :height 1) (box :content "row 1" :height 1) (box :id "target" :content "target" :height 1) (box :id "row-3" :content "row 3" :height 1) (box :content "row 4" :height 1))))) root-id target-id row-3-id (full-role-refreshes 0) (property-scans 0)) (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")) (setq row-3-id (ebox-test--selector-region-id (current-buffer) "#row-3")) (let ((stale-role-table (ebox-buffer--region-role-span-table))) (should (hash-table-p stale-role-table)) (should (ebox--scroll-region-by root-id 1 48)) (should-not (ebox-buffer--region-role-span-table)) (ebox--set-buffer-region-role-span-table (current-buffer) stale-role-table) (should (eq (ebox--region-ids-role-table-status stale-role-table (list target-id)) 'stale)) (should-not (ebox--region-ids-visible-via-role-table-p (list target-id))) (let ((original-refresh (symbol-function 'ebox-buffer-refresh-region-role-spans)) (original-scan (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) (cl-letf (((symbol-function 'ebox-buffer-refresh-region-role-spans) (lambda (&rest args) (cl-incf full-role-refreshes) (apply original-refresh args))) ((symbol-function 'ebox--region-ids-visible-by-property-scan-p) (lambda (&rest args) (cl-incf property-scans) (apply original-scan args)))) (ebox-region-update target-id :color "#abcdef")))) (let* ((target-pos (text-property-any (point-min) (point-max) 'ebox-content target-id)) (row-3-pos (text-property-any (point-min) (point-max) 'ebox-content row-3-id)) (target-face (and target-pos (get-text-property target-pos 'face))) (row-3-face (and row-3-pos (get-text-property row-3-pos 'face)))) (should (= full-role-refreshes 1)) (should (= property-scans 0)) (should (ebox-test--face-has-key-value-p target-face :foreground "#abcdef")) (should-not (ebox-test--face-has-key-value-p row-3-face :foreground "#abcdef")))))) (ert-deftest ebox-partially-stale-role-spans-rebuild-before-paint () "A current target span must not hide a stale span on a neighboring line." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(column :id "root" :width (180) (box :id "target" :content "target" :height 1) (box :id "neighbor" :content "neighbor" :height 1)))) (full-role-refreshes 0) (property-scans 0)) (ebox-test--with-rendered-buffer layout (let* ((target-id (ebox-test--selector-region-id (current-buffer) "#target")) (neighbor-id (ebox-test--selector-region-id (current-buffer) "#neighbor")) (table (ebox-buffer--region-role-span-table)) (target-key (ebox-buffer--region-role-key target-id 'content)) (neighbor-key (ebox-buffer--region-role-key neighbor-id 'content)) (valid-span (car (gethash target-key table))) (displaced-span (car (gethash neighbor-key table)))) (should valid-span) (should displaced-span) (should-not (= (line-number-at-pos (marker-position (car valid-span))) (line-number-at-pos (marker-position (car displaced-span))))) (puthash target-key (list valid-span displaced-span) table) (should (ebox-buffer--current-region-role-span-p valid-span target-id 'content)) (should-not (ebox-buffer--current-region-role-span-p displaced-span target-id 'content)) (should (ebox--region-ids-visible-via-role-table-p (list target-id))) (should (eq (ebox--region-ids-role-table-status table (list target-id)) 'stale)) (let ((original-refresh (symbol-function 'ebox-buffer-refresh-region-role-spans)) (original-scan (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) (cl-letf (((symbol-function 'ebox-buffer-refresh-region-role-spans) (lambda (&rest args) (cl-incf full-role-refreshes) (apply original-refresh args))) ((symbol-function 'ebox--region-ids-visible-by-property-scan-p) (lambda (&rest args) (cl-incf property-scans) (apply original-scan args)))) (ebox-region-update target-id :color "#abcdef"))) (let* ((target-pos (text-property-any (point-min) (point-max) 'ebox-content target-id)) (neighbor-pos (text-property-any (point-min) (point-max) 'ebox-content neighbor-id)) (target-face (and target-pos (get-text-property target-pos 'face))) (neighbor-face (and neighbor-pos (get-text-property neighbor-pos 'face)))) (should (= full-role-refreshes 1)) (should (= property-scans 0)) (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'paint-patch)) (should (ebox-test--face-has-key-value-p target-face :foreground "#abcdef")) (should-not (ebox-test--face-has-key-value-p neighbor-face :foreground "#abcdef"))))))) (ert-deftest ebox-scroll-hidden-update-keeps-the-visible-window () "Updating a scrolled-away node should not reuse its stale buffer spans." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "root" :width (180) :height 2 :overflow scroll (column (box :id "target" :content "target" :width (120) :height 1 :box-sizing border-box :padding-left (2)) (box :content "row 1" :height 1) (box :content "row 2" :height 1) (box :content "row 3" :height 1) (box :content "row 4" :height 1))))) root-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (setq target-id (ebox-test--selector-region-id (current-buffer) "#target")) (should (ebox--scroll-region-by root-id 3 48)) (let ((visible-before (buffer-substring-no-properties (point-min) (point-max)))) (ebox-region-update target-id :padding-left (list 12)) (should (equal visible-before (buffer-substring-no-properties (point-min) (point-max))))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)) (should (= (plist-get report :patch-count) 1))) (should-not (text-property-any (point-min) (point-max) 'ebox-content target-id)) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (let ((visible-before (buffer-substring-no-properties (point-min) (point-max)))) (ebox-region-update target-id :width (list 400)) (should (equal visible-before (buffer-substring-no-properties (point-min) (point-max))))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'root-rerender)) (should (plist-get report :root-rerender)) (should (plist-get report :scroll-state-patch))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox--scroll-region-by root-id -3 48)) (should (text-property-any (point-min) (point-max) 'ebox-content target-id)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-nested-scroll-hidden-geometry-replays-both-caches () "A hidden inner-scroll update should survive inner and outer replay." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "root" :width (180) :height 1 :overflow scroll (column (box :id "wrapper" :width (180) :height 1 :overflow scroll (column (box :id "target" :content "target" :width (120) :height 1 :box-sizing border-box :padding-left (2)) (box :content "inner tail" :height 1))) (box :content "outer tail" :height 1))))) root-id wrapper-id target-id) (ebox-test--with-rendered-buffer layout (setq root-id (ebox-test--selector-region-id (current-buffer) "#root")) (setq wrapper-id (ebox-test--selector-region-id (current-buffer) "#wrapper")) (setq target-id (ebox-test--selector-region-id (current-buffer) "#target")) (should (ebox--scroll-region-by wrapper-id 1 48)) (should-not (text-property-any (point-min) (point-max) 'ebox-content target-id)) (let ((visible-before (buffer-substring-no-properties (point-min) (point-max)))) (ebox-region-update target-id :padding-left (list 12)) (should (equal visible-before (buffer-substring-no-properties (point-min) (point-max))))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'root-rerender)) (should (plist-get report :root-rerender)) (should (plist-get report :scroll-state-patch))) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox--scroll-region-by root-id 1 48)) (should (ebox--scroll-region-by root-id -1 48)) (should-not (text-property-any (point-min) (point-max) 'ebox-content target-id)) (should (ebox-test--buffer-visually-matches-runtime-render-p)) (should (ebox--scroll-region-by wrapper-id -1 48)) (should (text-property-any (point-min) (point-max) 'ebox-content target-id)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))) (ert-deftest ebox-scroll-hot-rendered-lookup-uses-content-line-hints () "A deferred rendered index should inspect target lines, not the full prefix." (ebox-test--reset-runtime-state) (let* ((content-lines (cl-loop for index below 100 collect (propertize (format "row %d" index) 'ebox-content index))) (rendered-lines (mapcar #'copy-sequence content-lines)) (state (let ((ebox--defer-scroll-content-index t)) (ebox--scroll-state-set-lines nil content-lines rendered-lines))) (content-index (ebox--scroll-build-region-line-span-index content-lines)) (full-builds 0) (full-scans 0) (membership-builds 0) (hint-scans 0)) (plist-put state :region-line-span-index content-index) (plist-put state :region-line-span-index-deferred nil) (let ((original-build (symbol-function 'ebox--scroll-build-region-line-span-index)) (original-scan (symbol-function 'ebox--scroll-state-region-line-spans)) (original-membership-build (symbol-function 'ebox--scroll-build-content-region-id-set)) (original-hint-scan (symbol-function 'ebox--scroll-line-spans-at-index-hints))) (cl-letf (((symbol-function 'ebox--scroll-build-region-line-span-index) (lambda (&rest args) (cl-incf full-builds) (apply original-build args))) ((symbol-function 'ebox--scroll-state-region-line-spans) (lambda (&rest args) (cl-incf full-scans) (apply original-scan args))) ((symbol-function 'ebox--scroll-build-content-region-id-set) (lambda (&rest args) (cl-incf membership-builds) (apply original-membership-build args))) ((symbol-function 'ebox--scroll-line-spans-at-index-hints) (lambda (&rest args) (cl-incf hint-scans) (apply original-hint-scan args)))) (let* ((content-spans (ebox--scroll-hot-content-line-spans state '(70))) (rendered-spans (ebox--scroll-hot-rendered-line-spans state '(70) content-spans))) (should (= (caar content-spans) 70)) (should (= (caar rendered-spans) 70))))) (should (= full-builds 0)) (should (= full-scans 0)) (should (= membership-builds 0)) (should (= hint-scans 0)) (should-not (plist-get state :rendered-region-line-span-index)) (should (plist-get state :rendered-region-line-span-index-deferred)))) (ert-deftest ebox-hidden-scroll-child-content-update-reuses-prebuilt-index () "Hidden scroll child updates should reuse a prebuilt line index." (ebox-test--reset-runtime-state) (let* ((children (cl-loop for idx below 80 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1))) (layout (ebox-build `(box :id "root" :height 2 :overflow scroll (column ,@children)))) root-id target-id (scan-calls 0) (build-index-calls 0)) (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) "#row-70")) (setq next-target-id (ebox-test--selector-region-id (current-buffer) "#row-71")) (let ((original (symbol-function 'ebox--scroll-state-region-line-spans)) (original-build (symbol-function 'ebox--scroll-build-region-line-span-index))) (cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans) (lambda (&rest args) (cl-incf scan-calls) (apply original args))) ((symbol-function 'ebox--scroll-build-region-line-span-index) (lambda (&rest args) (cl-incf build-index-calls) (apply original-build args)))) (ebox-region-update target-id :content "new 70"))) (should (= scan-calls 0)) (should (= build-index-calls 0)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)) (should (plist-get report :scroll-state-index))) (let ((original (symbol-function 'ebox--scroll-state-region-line-spans)) (original-build (symbol-function 'ebox--scroll-build-region-line-span-index))) (cl-letf (((symbol-function 'ebox--scroll-state-region-line-spans) (lambda (&rest args) (cl-incf scan-calls) (apply original args))) ((symbol-function 'ebox--scroll-build-region-line-span-index) (lambda (&rest args) (cl-incf build-index-calls) (apply original-build args)))) (ebox-region-update next-target-id :content "alt 71"))) (should (= scan-calls 0)) (should (= build-index-calls 0)) (should (ebox--scroll-region-by root-id 70)) (let ((visible (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "new 70" visible)) (should (string-match-p "alt 71" visible)))))) (ert-deftest ebox-hidden-scroll-child-visibility-check-skips-live-extents () "Hidden scroll child updates should not force buffer-wide live extent scans." (ebox-test--reset-runtime-state) (let* ((children (cl-loop for idx below 80 collect `(box :id ,(format "row-%02d" idx) :content ,(format "row %02d" idx) :height 1))) (layout (ebox-build `(box :id "root" :height 2 :overflow scroll (column ,@children)))) target-id (live-extents-calls 0) (property-scan-calls 0) (original (symbol-function 'ebox--live-box-extents)) (original-property-scan (symbol-function 'ebox--region-ids-visible-by-property-scan-p))) (ebox-test--with-rendered-buffer layout (setq target-id (ebox-test--selector-region-id (current-buffer) "#row-70")) (should (hash-table-p (ebox-buffer--region-role-span-table))) (cl-letf (((symbol-function 'ebox--live-box-extents) (lambda (&rest args) (cl-incf live-extents-calls) (apply original args))) ((symbol-function 'ebox--region-ids-visible-by-property-scan-p) (lambda (&rest args) (cl-incf property-scan-calls) (apply original-property-scan args)))) (ebox-region-update target-id :content "new 70")) (should (= live-extents-calls 0)) (should (= property-scan-calls 0)) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scroll-state-patch)))))) (ert-deftest ebox-small-geometry-span-patch-refreshes-indexes-scoped () "Small geometry span patches should not rebuild all buffer runtime indexes." (ebox-test--reset-runtime-state) (let ((layout (ebox-test--dynamic-update-row-layout)) (full-extents 0) (full-roles 0) (full-scroll 0) (scoped-property-scans 0)) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#margin")) (original-scoped-scan (symbol-function 'ebox--refresh-box-extents-for-region-ids-in-spans))) (cl-letf (((symbol-function 'ebox--refresh-buffer-box-extents) (lambda (&rest _args) (cl-incf full-extents) nil)) ((symbol-function 'ebox-buffer-refresh-region-role-spans) (lambda (&rest _args) (cl-incf full-roles) nil)) ((symbol-function 'ebox--refresh-buffer-scroll-content-markers) (lambda (&rest _args) (cl-incf full-scroll) nil)) ((symbol-function 'ebox--refresh-box-extents-for-region-ids-in-spans) (lambda (&rest args) (cl-incf scoped-property-scans) (apply original-scoped-scan args)))) (ebox-region-update region-id :margin-left (list 20))) (let ((report (ebox-test--buffer-update-report))) (should (eq (plist-get report :strategy) 'span-patch)) (should (plist-get report :scoped-index-refresh)) (should (= full-extents 0)) (should (= full-roles 0)) (should (= full-scroll 0)) (should (= scoped-property-scans 0)) (should (ebox-test--buffer-visually-matches-runtime-render-p))))))) (ert-deftest ebox-wheel-scroll-uses-buffer-scroll-region-without-point () "Wheel events from fringe-like areas should still scroll the ebox page." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 2 :overflow scroll (column (box :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1))))) (root-id (car (ebox-region-ids layout))) mwheel-called) (ebox-test--with-rendered-buffer layout (switch-to-buffer (current-buffer)) (cl-letf (((symbol-function 'event-start) (lambda (_event) (list (selected-window) 'mode-line '(0 . 0) 0 nil nil nil nil nil nil))) ((symbol-function 'mwheel-scroll) (lambda (&rest _args) (setq mwheel-called t)))) (ebox--wheel-scroll 'fake-wheel-event 1)) (should-not mwheel-called) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "B" plain)) (should (string-match-p "C" plain)))))) (ert-deftest ebox-scroll-first-region-ignores-zero-consumption () "A zero-line scroll result should not consume the scroll event." (let (visited) (cl-letf (((symbol-function 'ebox--scroll-region-by) (lambda (region-id _delta &optional _prefix-budget-lines) (push region-id visited) (if (eq region-id 'stalled) 0 1)))) (should (ebox--scroll-first-region-by '(stalled active) 1))) (should (equal (nreverse visited) '(stalled active))))) (ert-deftest ebox-scroll-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 0)) (should-not (ebox--scroll-progress-p nil))) (ert-deftest ebox-smooth-wheel-scroll-moves-immediately-then-drains () "Smooth wheel scrolling should respond immediately and drain pending lines." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 1 :overflow scroll (column (box :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1))))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) (ebox-wheel-smooth-scroll-interval 60.0)) (should (ebox--smooth-scroll-region-by root-id 2)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (ebox--smooth-scroll-tick root-id) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 2)) (should-not (gethash root-id ebox--smooth-scroll-state-table)))))) (ert-deftest ebox-smooth-wheel-scroll-coalesces-while-timer-active () "Further wheel input should queue behind the active timer, not edit at once." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 1 :overflow scroll (column (box :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1) (box :content "D" :height 1))))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) (ebox-wheel-smooth-scroll-interval 60.0)) (should (ebox--smooth-scroll-region-by root-id 2)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (should (gethash root-id ebox--smooth-scroll-state-table)) (should (ebox--smooth-scroll-region-by root-id 2)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (while (gethash root-id ebox--smooth-scroll-state-table) (ebox--smooth-scroll-tick root-id)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 3)))))) (ert-deftest ebox-smooth-wheel-scroll-clamps-pending-to-range () "Large smooth wheel input should not leave an oversized timer backlog." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 1 :overflow scroll (column (box :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1) (box :content "D" :height 1))))) (root-id (car (ebox-region-ids layout)))) (ebox-test--with-rendered-buffer layout (let ((ebox-wheel-smooth-scroll-lines-per-tick 1) (ebox-wheel-smooth-scroll-interval 60.0)) (should (ebox--smooth-scroll-region-by root-id 1000)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (should (<= (plist-get (gethash root-id ebox--smooth-scroll-state-table) :pending) 2)) (while (gethash root-id ebox--smooth-scroll-state-table) (ebox--smooth-scroll-tick root-id)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 3)))))) (ert-deftest ebox-page-scroll-bubbles-from-child-to-scrollable-ancestor () "Page scrolling over a child box should scroll the nearest scrollable ancestor." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height 3 :overflow scroll (column (box :id "child" :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1) (box :content "D" :height 1) (box :content "E" :height 1) (box :content "F" :height 1))))) (root-id (car (ebox-region-ids layout))) fallback-called) (ebox-test--with-rendered-buffer layout (goto-char (point-min)) (search-forward "A") (backward-char) (cl-letf (((symbol-function 'window-body-height) (lambda (&optional _window _pixelwise) 5)) ((symbol-function 'scroll-up-command) (lambda (&optional _arg) (setq fallback-called t)))) (ebox-scroll-page-down)) (should-not fallback-called) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 3)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "D" plain)) (should (string-match-p "F" plain)) (should-not (string-match-p "A" plain)))))) (ert-deftest ebox-scroll-rerender-preserves-viewport-height-context () "Scroll rerender fallback should keep viewport-height boxes clipped." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :height (viewport-height) :overflow scroll (column (box :id "child" :content "A" :height 1) (box :content "B" :height 1) (box :content "C" :height 1) (box :content "D" :height 1))))) (root-id (car (ebox-region-ids layout)))) (let ((ebox-viewport-height 2)) (ebox-test--with-rendered-buffer layout (should (= (plist-get (ebox--scroll-get-state root-id) :content-height) 2)) (goto-char (point-min)) (search-forward "A") (backward-char) (ebox-scroll-down 1) (should (= (plist-get (ebox--scroll-get-state root-id) :content-height) 2)) (should (= (plist-get (ebox--scroll-get-state root-id) :scroll-offset) 1)) (let ((plain (buffer-substring-no-properties (point-min) (point-max)))) (should (string-match-p "B" plain)) (should (string-match-p "C" plain)) (should-not (string-match-p "A" plain))))))) (ert-deftest ebox-wheel-scroll-falls-back-at-scroll-boundary () "Mouse wheel should bubble to the buffer when inner scroll is at its edge." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC" :height 1 :overflow 'scroll)) (region-id (car (ebox-region-ids box))) mwheel-args) (ebox-test--with-rendered-buffer box (should (ebox--scroll-region-by region-id 3)) (cl-letf (((symbol-function 'ebox--wheel-region-ids) (lambda (_event) (list region-id))) ((symbol-function 'mwheel-scroll) (lambda (&rest args) (setq mwheel-args args)))) (ebox--wheel-scroll 'fake-wheel-event 1 'fake-arg)) (should (equal mwheel-args '(fake-wheel-event fake-arg)))))) (ert-deftest ebox-scroll-map-keeps-wheel-on-event-aware-handler () "Mouse wheel bindings should use event-aware handlers, not line fallbacks." (should (eq (lookup-key ebox-scroll-map [wheel-down]) #'ebox-wheel-scroll-down)) (should (eq (lookup-key ebox-scroll-map [wheel-up]) #'ebox-wheel-scroll-up)) (should (eq (lookup-key ebox-scroll-map [mouse-5]) #'ebox-wheel-scroll-down)) (should (eq (lookup-key ebox-scroll-map [mouse-4]) #'ebox-wheel-scroll-up)) (should (eq (lookup-key ebox-scroll-map [right-fringe wheel-down]) #'ebox-wheel-scroll-down)) (should (eq (lookup-key ebox-scroll-map [right-fringe wheel-up]) #'ebox-wheel-scroll-up)) (should (eq (lookup-key ebox-scroll-map [mode-line wheel-down]) #'ebox-wheel-scroll-down)) (should (eq (lookup-key ebox-scroll-map [mode-line wheel-up]) #'ebox-wheel-scroll-up)) (should (eq (lookup-key ebox-scroll-map [vertical-scroll-bar wheel-down]) #'ebox-wheel-scroll-down)) (should (eq (lookup-key ebox-scroll-map [vertical-scroll-bar wheel-up]) #'ebox-wheel-scroll-up)) (should (eq (lookup-key ebox-scroll-map [remap scroll-up-command]) #'ebox-scroll-page-down)) (should (eq (lookup-key ebox-scroll-map [remap scroll-down-command]) #'ebox-scroll-page-up)) (should (eq (lookup-key ebox-scroll-map (kbd "C-v")) #'ebox-scroll-page-down)) (should (eq (lookup-key ebox-scroll-map (kbd "")) #'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 "")) #'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 "")) #'ebox-scroll-page-down)) (should (eq (key-binding (kbd "")) #'ebox-scroll-page-up))) (unless was-enabled (pixel-scroll-precision-mode -1))))) (ert-deftest ebox-border-box-max-height-includes-vertical-padding () (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A\nB\nC\nD" :box-sizing 'border-box :max-height 3 :padding '(1 0) :overflow 'hidden)) (plain (substring-no-properties (ebox-render box)))) (should (= (ebox-string-height plain) 3)) (should-not (string-match-p "C" plain)) (should-not (string-match-p "D" plain)))) (ert-deftest ebox-content-box-min-height-wins-when-min-exceeds-max () "Content-box height conflicts should use the minimum as the used height." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A" :box-sizing 'content-box :min-height 5 :max-height 3 :padding '(1 0) :overflow 'hidden)) (rendered (ebox-render box))) (should (= (ebox--content-height box 1) 5)) (should (= (ebox-string-height rendered) 7)))) (ert-deftest ebox-border-box-min-height-wins-when-min-exceeds-max () "Border-box height conflicts should use the minimum as the outer height." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "A" :box-sizing 'border-box :min-height 5 :max-height 3 :padding '(1 0) :overflow 'hidden)) (rendered (ebox-render box))) (should (= (ebox--content-height box 1) 3)) (should (= (ebox-string-height rendered) 5)))) (ert-deftest ebox-border-box-max-width-includes-horizontal-padding-and-border () (ebox-test--reset-runtime-state) (let ((box (ebox-create :content "abc abc abc abc abc abc abc abc abc abc" :box-sizing 'border-box :max-width '(100) :padding-inline '(20) :border-left '(10) :border-right '(10) :overflow 'hidden))) (should (<= (ebox--total-pixel box) 100)))) (ert-deftest ebox-auto-width-without-viewport-uses-max-content-across-all-lines () "Omitted :width should fall back to the widest unwrapped content line." (ebox-test--reset-runtime-state) (let* ((content "short\nmuch wider line") (expected (apply #'max (mapcar #'ebox--string-pixel-width (ebox-string-lines content)))) (box (ebox-create :content content)) (rendered (ebox-render box)) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))) (should (= (ebox--content-pixel box) expected)) (should (cl-every (lambda (width) (= width expected)) widths)))) (ert-deftest ebox-auto-width-stretches-to-viewport-when-available () "Omitted :width and :width auto should fill the viewport margin box." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 240)) (dolist (width-value '(nil auto)) (let ((box (apply #'ebox-create (append (list :content "short line wraps into viewport" :padding-inline '(20) :border-left '(10) :border-right '(10) :margin-inline '(5)) (when width-value (list :width width-value)))))) (should (= (ebox--total-pixel box) 240)))))) (ert-deftest ebox-explicit-width-can-overflow-containing-block () "A definite width remains definite even when the containing block is narrower." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 120) (content "alpha beta gamma delta") (box (ebox-create :content content :width '(220) :wrap-mode 'word)) (rendered (ebox-render box)) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))) (should (cl-some (lambda (width) (> width ebox-viewport-width)) widths)) (should (cl-every (lambda (width) (= width 220)) widths)))) (ert-deftest ebox-column-wrapper-width-reflows-auto-child-layout () "A column wrapper width should contain auto-sized children." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 1000) (node (ebox-build '(column :width (120) (box :content "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega" :wrap-mode word)))) (rendered (ebox-render node)) (plain (substring-no-properties rendered)) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))) (should (cl-every (lambda (width) (<= width 120)) widths)) (dolist (word '("alpha" "beta" "gamma" "delta" "epsilon" "theta" "omega")) (should (string-match-p word plain))) (should (> (ebox-string-height rendered) 1)))) (ert-deftest ebox-wrapper-child-layout-keeps-pixel-width-with-wide-font () "Engine pixel widths must not be reparsed as public character widths." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 1000)) (cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7))) (let* ((node (ebox-build '(column :width (400) (box :content "X")))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths '(400))))))) (ert-deftest ebox-column-wrapper-width-reflows-auto-flex-layout () "A column wrapper width should become the viewport for nested auto flex." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 1000) (node (ebox-build '(column :width (240) (flex :flex-wrap wrap :column-gap (10) (box :content "A" :width (100)) (box :content "B" :width (100)) (box :content "C" :width (100)))))) (rendered (ebox-render node)) (plain-lines (ebox-string-lines (substring-no-properties rendered))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines rendered)))) (should (cl-every (lambda (width) (<= width 240)) widths)) (should (string-match-p "A.*B" (car plain-lines))) (should (cl-some (lambda (line) (string-match-p "C" line)) (cdr plain-lines))))) (ert-deftest ebox-column-containing-block-does-not-expand-to-overflow-child () "An overflowing definite child should not widen unrelated column rows." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 200) (node (ebox-column (ebox-create :content "Auto row") (ebox-create :content "Fixed row" :width '(300)))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths '(200 300))))) (ert-deftest ebox-width-keywords-resolve-intrinsic-content-sizes () "auto, min-content, max-content, and none should keep CSS-like meanings." (ebox-test--reset-runtime-state) (let* ((content "aa bbbb") (max-content (ebox--string-pixel-width content)) (min-content (ebox--string-pixel-width "bbbb"))) (should (= (ebox--content-pixel (ebox-create :content content :width 'auto)) max-content)) (should (= (ebox--content-pixel (ebox-create :content content :width 'max-content)) max-content)) (should (= (ebox--content-pixel (ebox-create :content content :width 'min-content)) min-content)) (should (= (ebox--content-pixel (ebox-create :content content :width '(80) :max-width 'none)) 80)) (should-error (ebox-create :content content :width 'none) :type 'error))) (ert-deftest ebox-max-content-wrapper-measures-composite-child-intrinsically () "A max-content wrapper should not stretch its composite child to the viewport." (ebox-test--reset-runtime-state) (let* ((ebox-viewport-width 480) (lines '("Compact" "Natural width")) (expected (apply #'max (mapcar #'ebox--string-pixel-width lines))) (node (ebox-create :width 'max-content :ebox-content-node (apply #'ebox-column (mapcar (lambda (line) (ebox-create :content line)) lines)))) (widths (mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render node))))) (should (equal widths (make-list (length lines) expected))))) (ert-deftest ebox-definite-width-skips-unused-max-content-measurement () "A definite preferred width should not evaluate its unused auto fallback." (ebox-test--reset-runtime-state) (let ((box (ebox-create :content "measurement must stay cold" :width '(80))) (measurements 0) (original (symbol-function 'ebox--content-max-pixel))) (cl-letf (((symbol-function 'ebox--content-max-pixel) (lambda (candidate) (cl-incf measurements) (funcall original candidate)))) (should (= (ebox--content-pixel box) 80)) (should (= measurements 0))))) (ert-deftest ebox-definite-width-still-resolves-intrinsic-constraints () "Intrinsic min/max constraints must still measure definite-width content." (ebox-test--reset-runtime-state) (let* ((content "intrinsic constraint") (expected (ebox--string-pixel-width content)) (box (ebox-create :content content :width '(20) :min-width 'max-content)) (measurements 0) (original (symbol-function 'ebox--content-max-pixel))) (cl-letf (((symbol-function 'ebox--content-max-pixel) (lambda (candidate) (cl-incf measurements) (funcall original candidate)))) (should (= (ebox--content-pixel box) expected)) (should (= measurements 1))))) (ert-deftest ebox-auto-width-keeps-max-content-fallback-without-viewport () "An unresolved auto width should retain its intrinsic max-content fallback." (ebox-test--reset-runtime-state) (let ((box (ebox-create :content "auto fallback")) (measurements 0) (original (symbol-function 'ebox--content-max-pixel))) (cl-letf (((symbol-function 'ebox--content-max-pixel) (lambda (candidate) (cl-incf measurements) (funcall original candidate)))) (should (> (ebox--content-pixel box) 0)) (should (= measurements 1))))) (ert-deftest ebox-fit-content-width-clamps-between-min-and-max-content () "fit-content() should clamp by min-content and max-content." (ebox-test--reset-runtime-state) (let* ((content "aa widest") (min-content (ebox--string-pixel-width "widest")) (max-content (ebox--string-pixel-width content)) (between (+ min-content (/ (- max-content min-content) 2)))) (should (< min-content between)) (should (< between max-content)) (should (= (ebox--content-pixel (ebox-create :content content :width (list 'fit-content (list between)))) between)) (should (= (ebox--content-pixel (ebox-create :content content :width (list 'fit-content (list 1)))) min-content)) (should (= (ebox--content-pixel (ebox-create :content content :width (list 'fit-content (list (+ max-content 50))))) max-content)))) (ert-deftest ebox-fit-content-keyword-uses-stretch-when-viewport-is-definite () "The fit-content keyword should clamp stretch between intrinsic sizes." (ebox-test--reset-runtime-state) (let* ((content "aa widest") (min-content (ebox--string-pixel-width "widest")) (max-content (ebox--string-pixel-width content)) (stretch (+ min-content (/ (- max-content min-content) 2))) (ebox-viewport-width stretch)) (should (= (ebox--content-pixel (ebox-create :content content :width 'fit-content)) stretch)))) (ert-deftest ebox-min-and-max-width-keywords-constrain-preferred-width () "Intrinsic keywords should work on min-width and max-width too." (ebox-test--reset-runtime-state) (let* ((content "aa widest") (min-content (ebox--string-pixel-width "widest")) (max-content (ebox--string-pixel-width content)) (between (+ min-content (/ (- max-content min-content) 2)))) (should (= (ebox--content-pixel (ebox-create :content content :width '(1) :min-width 'max-content)) max-content)) (should (= (ebox--content-pixel (ebox-create :content content :width (list (+ max-content 50)) :max-width 'min-content)) min-content)) (should (= (ebox--content-pixel (ebox-create :content content :width '(1) :min-width (list 'fit-content (list between)))) between)) (should (= (ebox--content-pixel (ebox-create :content content :width (list (+ max-content 50)) :max-width (list 'fit-content (list between)))) between)))) (ert-deftest ebox-stretch-and-contain-width-fill-viewport-margin-box () "stretch and contain should resolve against the current viewport." (ebox-test--reset-runtime-state) (let ((ebox-viewport-width 240)) (dolist (keyword '(stretch contain)) (let ((box (ebox-create :content "x" :width keyword :padding-inline '(20) :border-left '(10) :border-right '(10) :margin-inline '(5)))) (should (= (ebox--total-pixel box) 240)))))) (ert-deftest ebox-max-width-clamps-used-width-before-content-minimum () "A max-width smaller than one glyph should constrain used width." (ebox-test--reset-runtime-state) (cl-letf (((symbol-function 'ebox--string-pixel-width) (lambda (_string) 60)) ((symbol-function 'ebox--max-char-pixel) (lambda (_string) 60))) (let ((box (ebox-create :content "W" :max-width '(20) :wrap-mode 'char))) (should (= (ebox--content-pixel box) 20)) (should (= (ebox--total-pixel box) 20))))) (ert-deftest ebox-min-width-wins-when-min-width-exceeds-max-width () "CSS width constraints make min-width override max-width conflicts." (ebox-test--reset-runtime-state) (let ((box (ebox-create :content "short" :width '(40) :min-width '(120) :max-width '(80)))) (should (= (ebox--content-pixel box) 120)) (should (= (ebox--total-pixel box) 120)))) (ert-deftest ebox-width-properties-reject-negative-values () "Width, min-width, and max-width should reject negative lengths." (ebox-test--reset-runtime-state) (should-error (ebox-create :content "bad" :width '(-1)) :type 'error) (should-error (ebox-create :content "bad" :min-width '(-1)) :type 'error) (should-error (ebox-create :content "bad" :max-width '(-1)) :type 'error)) (ert-deftest ebox-edge-properties-reject-negative-values () "Public edge properties and engine longhands should reject negatives." (ebox-test--reset-runtime-state) (dolist (props '((:padding -1) (:padding-left-pixel -1) (:margin -1) (:margin-top-height -1) (:border-width -1) (:border-top-width -1) (:border-left-pixel -1))) (should-error (apply #'ebox-create (append '(:content "bad") props)) :type 'error))) (ert-deftest ebox-line-min-content-skips-discarded-space-measurements () "Literal spaces split runs and plain ASCII reuses exact character widths." (let ((line (copy-sequence "fi x")) (ebox--display-cache-validated t) (ebox--char-width-cache (make-hash-table :test 'eql)) measured) (puthash ?i 4 ebox--char-width-cache) (add-text-properties 0 1 '(face (:height 1.2)) line) (add-text-properties 3 4 '(display (raise 0.1)) line) (cl-letf (((symbol-function 'ebox--string-pixel-width) (lambda (string) (push (substring-no-properties string) measured) (pcase (substring-no-properties string) ("f" 3) ("x" 2) (" " 99) (_ 99))))) ;; Per-grapheme addition remains intentional: measuring the whole "fi" ;; could produce a non-additive ligature width and change the contract. (should (= (ebox--line-min-content-pixel line) 7)) (should (equal (nreverse measured) '("f" "x")))))) (ert-deftest ebox-region-update-valid-paint-and-invalid-edge-is-atomic () "Invalid geometry should fail before a valid sibling property mutates." (ebox-test--reset-runtime-state) (let* ((box (ebox-create :content "Atomic" :width '(120) :bgcolor "#111111" :padding-left-pixel 2)) (region-id (car (ebox-region-ids box)))) (ebox-test--with-rendered-buffer box (should-error (ebox-region-update region-id :bgcolor "#222222" :padding-left-pixel -1) :type 'error) (should (equal (ebox-get box :bgcolor) "#111111")) (should (= (ebox-get box :padding-left-pixel) 2))))) (ert-deftest ebox-region-update-no-op-skips-rollback-snapshot () "A legal no-op should return before discovering rollback scope." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "root" :width (240) :height 1 :overflow scroll (box :id "target" :content "same" :width (120)))))) (ebox-test--with-rendered-buffer layout (let ((target-id (ebox-test--selector-region-id (current-buffer) "#target"))) (cl-letf (((symbol-function 'ebox--node-all-region-ids) (lambda (&rest _) (ert-fail "no-op discovered subtree rollback scope"))) ((symbol-function 'ebox--scroll-state-region-ids-containing-node-ids) (lambda (&rest _) (ert-fail "no-op discovered containing scroll scope")))) (let ((report (ebox-region-update target-id :content "same"))) (should (eq (plist-get report :strategy) 'no-op)) (should (= (plist-get report :dirty-count) 0)))))))) (ert-deftest ebox-region-update-rollback-snapshot-keeps-scroll-owner-box-by-identity () "A child rollback snapshot should retain its scroll owner's box identity." (ebox-test--reset-runtime-state) (let ((layout (ebox-build '(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) (box :id "target" :content "paint" :width (120) :color "#111111") (box :content "tail" :width (120))))))) (let ((ebox-viewport-width 240) (ebox-viewport-height 1)) (ebox-test--with-rendered-buffer layout (let* ((root-id (ebox-test--selector-region-id (current-buffer) "#root")) (target-id (ebox-test--selector-region-id (current-buffer) "#target")) (scroll-box (plist-get (ebox--scroll-get-state root-id) :box)) (original-copy-tree (symbol-function 'copy-tree))) (should scroll-box) (cl-letf (((symbol-function 'copy-tree) (lambda (tree &optional vectors-and-records) (when (eq tree scroll-box) (ert-fail "deep-copied containing scroll owner box")) (funcall original-copy-tree tree vectors-and-records)))) (ebox-region-update target-id :color "#222222")) (should (equal (ebox-get (gethash target-id ebox--region-box-table) :color) "#222222"))))))) (ert-deftest ebox-region-update-rolls-back-when-rerender-fails () "A failed local rerender must leave model, buffer, and report unchanged." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "target" :content "before" :width (120)))) (report-before 'keep-report)) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (region-id (ebox-test--selector-region-id buffer "#target")) (box (gethash region-id ebox--region-box-table)) (box-before (copy-tree box)) (text-before (buffer-string))) (ebox-test--set-buffer-update-report buffer report-before) (cl-letf (((symbol-function 'ebox--rerender-region-for-dirty-kind) (lambda (&rest _) (error "Injected rerender failure")))) (should-error (ebox-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-region-update-rolls-back-after-scroll-sync-fails () "A post-publication scroll sync failure must roll back all published state." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "root" :width (viewport) :height (viewport-height) :overflow scroll (column :width (viewport) (box :id "target" :content "before" :width (120) :height 1) (box :content "tail" :width (120) :height 1))))) (report-before 'keep-scroll-report)) (let ((ebox-viewport-width 260) (ebox-viewport-height 2) (ebox-scroll-lazy-prefix-lookahead-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")) (target-box (gethash target-id ebox--region-box-table)) (target-box-before (copy-tree target-box)) (scroll-state-before (copy-tree (ebox--scroll-get-state root-id))) (runtime-state (ebox--buffer-render-state buffer)) (runtime-root-before (copy-tree (plist-get runtime-state :root-node))) (runtime-revision-before (plist-get runtime-state :runtime-revision)) (text-before (buffer-string))) (ebox-test--set-buffer-update-report buffer report-before) (cl-letf (((symbol-function 'ebox--sync-scroll-state-lines-for-node) (lambda (&rest _) (error "Injected scroll sync failure")))) (should-error (ebox-region-update target-id :content "after") :type 'error)) (should (equal target-box target-box-before)) (should (equal (ebox--scroll-get-state root-id) scroll-state-before)) (should (equal-including-properties (buffer-string) text-before)) (should (eq (ebox--buffer-render-state buffer) runtime-state)) (should (equal (plist-get runtime-state :root-node) runtime-root-before)) (should (equal (plist-get runtime-state :runtime-revision) runtime-revision-before)) (should (eq (ebox-buffer-update-report buffer) report-before))))))) (ert-deftest ebox-region-update-rolls-back-new-scroll-state-after-post-failure () "Rollback must tombstone scroll state created during a failed update." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "target" :content "before\nhidden" :width (120) :height 1 :overflow visible))) (report-before 'keep-new-scroll-report)) (ebox-test--with-rendered-buffer layout (let* ((buffer (current-buffer)) (target-id (ebox-test--selector-region-id buffer "#target")) (target-box (gethash target-id ebox--region-box-table)) (target-box-before (copy-tree target-box)) (runtime-state (ebox--buffer-render-state buffer)) (runtime-root-before (copy-tree (plist-get runtime-state :root-node))) (runtime-revision-before (plist-get runtime-state :runtime-revision)) (text-before (buffer-string))) (should-not (ebox--scroll-get-state target-id)) (ebox-test--set-buffer-update-report buffer report-before) (cl-letf (((symbol-function 'ebox--schedule-buffer-runtime-prewarm) (lambda (&rest _) (error "Injected post-publication failure")))) (should-error (ebox-region-update target-id :overflow 'scroll) :type 'error)) (should-not (ebox--scroll-get-state target-id)) (should (equal target-box target-box-before)) (should (equal-including-properties (buffer-string) text-before)) (should (eq (ebox--buffer-render-state buffer) runtime-state)) (should (equal (plist-get runtime-state :root-node) runtime-root-before)) (should (equal (plist-get runtime-state :runtime-revision) runtime-revision-before)) (should (eq (ebox-buffer-update-report buffer) report-before)))))) (ert-deftest ebox-region-update-success-path-uses-local-transaction-snapshot () "Successful local updates must not take full-buffer or global-table snapshots." (ebox-test--reset-runtime-state) (let* ((layout (ebox-build '(box :id "target" :content "before" :width (120)))) (buffer-string-calls 0) (hash-key-calls 0) (copy-hash-table-calls 0) (original-buffer-string (symbol-function 'buffer-string)) (original-hash-keys (symbol-function 'ebox-incremental--hash-keys)) (original-copy-hash-table (symbol-function 'copy-hash-table))) (ebox-test--with-rendered-buffer layout (let ((region-id (ebox-test--selector-region-id (current-buffer) "#target"))) (cl-letf (((symbol-function 'buffer-string) (lambda () (cl-incf buffer-string-calls) (funcall original-buffer-string))) ((symbol-function 'ebox-incremental--hash-keys) (lambda (&rest args) (cl-incf hash-key-calls) (apply original-hash-keys args))) ((symbol-function 'copy-hash-table) (lambda (&rest args) (cl-incf copy-hash-table-calls) (apply original-copy-hash-table args)))) (ebox-region-update region-id :content "after")) (should (string-match-p "after" (buffer-substring-no-properties (point-min) (point-max)))) (should (= buffer-string-calls 0)) (should (= hash-key-calls 0)) (should (= copy-hash-table-calls 0)))))) (ert-deftest ebox-cjk-wraps-by-character-width-in-word-mode () (ebox-test--reset-runtime-state) (let* ((content "你好世界") (width (* 2 (ebox--string-pixel-width "你"))) (lines (ebox-string-lines (substring-no-properties (ebox-render (ebox-create :content content :width (list width) :wrap-mode 'word)))))) (should (= (length lines) 2)) (should (string-prefix-p "你好" (nth 0 lines))) (should (string-prefix-p "世界" (nth 1 lines))) (dolist (line lines) (should (<= (ebox--string-pixel-width line) width))))) (ert-deftest ebox-wrap-keeps-combining-mark-with-base-character () (ebox-test--reset-runtime-state) (let* ((cluster "é") (text (concat cluster cluster)) (width (ebox--string-pixel-width cluster))) (should (equal (mapcar #'substring-no-properties (ebox--wrap-line text width 'char)) (list cluster cluster))))) (ert-deftest ebox-word-wrap-skips-overflowing-separator-at-line-break () (ebox-test--reset-runtime-state) (let* ((text "normal behaves stretch-like for auto cross size in this DSL.") (width (ebox--string-pixel-width "stretch-like for")) (lines (ebox--wrap-line text width 'word))) (dolist (line lines) (should (<= (ebox--string-pixel-width line) width))) (should-not (cl-some (lambda (line) (string-prefix-p " " line)) lines)))) (ert-deftest ebox-wrap-keeps-emoji-zwj-and-regional-indicator-clusters () (ebox-test--reset-runtime-state) (let ((family "👨‍👩‍👧‍👦") (china "🇨🇳") (usa "🇺🇸")) (should (equal (mapcar #'substring-no-properties (ebox--wrap-line (concat family family) (ebox--string-pixel-width family) 'char)) (list family family))) (should (equal (mapcar #'substring-no-properties (ebox--wrap-line (concat china usa) (ebox--string-pixel-width china) 'char)) (list china usa))))) (ert-deftest ebox-kp-wrap-mode-invokes-ekp-justification () (ebox-test--reset-runtime-state) (let (require-called justify-args) (cl-letf (((symbol-function 'require) (lambda (feature &optional _filename _noerror) (when (eq feature 'ekp) (setq require-called t)) t)) ((symbol-function 'ekp-pixel-justify) (lambda (text width) (setq justify-args (list text width)) text))) (ebox-render (ebox-create :content "abc" :width '(100) :wrap-mode 'kp))) (should require-called) (should (equal justify-args (list "abc" 100))))) (ert-deftest ebox-kp-wrap-mode-respects-text-align () "KP-wrapped lines should honor the box's horizontal text alignment." (ebox-test--reset-runtime-state) (cl-letf (((symbol-function 'require) (lambda (feature &optional _filename _noerror) (when (eq feature 'ekp) t))) ((symbol-function 'ekp-pixel-justify) (lambda (&rest _) "abc\nx"))) (dolist (align '(center right)) (let* ((box (ebox-create :content "ignored" :width '(100) :wrap-mode 'kp :text-align align)) (width (ebox--content-pixel box)) (rendered (ebox-render box)) (line (car (ebox-string-lines rendered))) (expected (ebox--pixel-reach "abc" width align))) (should (equal (get-text-property 0 'display line) (get-text-property 0 'display expected))))))) (ert-deftest ebox-kp-wrap-preserves-content-width-for-ragged-lines () (ebox-test--reset-runtime-state) (cl-letf (((symbol-function 'require) (lambda (feature &optional _filename _noerror) (when (eq feature 'ekp) t))) ((symbol-function 'ekp-pixel-justify) (lambda (_text width) (concat (ebox-pixel-space width) "\nshort")))) (let* ((rendered (ebox-render (ebox-create :content "ignored" :width '(100) :wrap-mode 'kp))) (widths (mapcar #'ebox-string-pixel-width (ebox-string-lines rendered)))) (should (equal widths '(100 100)))))) (ert-deftest ebox-content-face-keeps-foreground-and-background () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render (ebox-create :content "Face" :width 40 :color "#abcdef" :bgcolor "#123456"))) (face (ebox-test--first-content-face rendered))) (should (ebox-test--face-has-key-value-p face :foreground "#abcdef")) (should (ebox-test--face-has-key-value-p face :background "#123456")))) (ert-deftest ebox-border-faces-keep-side-specific-properties () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render (ebox-create :content "Border" :width 60 :padding '(1 1) :border '(1 "#ff0000")))) (left-face (ebox-test--face-at-property rendered 'ebox-bl)) (right-face (ebox-test--face-at-property rendered 'ebox-br)) (top-face (ebox-test--face-at-property rendered 'ebox-bt)) (bottom-face (ebox-test--face-at-property rendered 'ebox-bb))) (should-not (ebox-test--face-has-key-p left-face :inverse-video)) (should (ebox-test--face-has-key-value-p left-face :background "#ff0000")) (should-not (ebox-test--face-has-key-p right-face :inverse-video)) (should (ebox-test--face-has-key-value-p right-face :background "#ff0000")) (should (ebox-test--face-has-key-value-p top-face :overline "#ff0000")) (should (ebox-test--face-has-key-value-p bottom-face :underline '(:position t :color "#ff0000"))))) (ert-deftest ebox-border-faces-omit-unspecified-frame-colors () (ebox-test--reset-runtime-state) (let ((original-frame-parameter (symbol-function 'frame-parameter))) (dolist (sentinel '(unspecified unspecified-fg unspecified-bg "unspecified" "unspecified-fg" "unspecified-bg")) (cl-letf (((symbol-function 'frame-parameter) (lambda (frame parameter) (if (eq parameter 'foreground-color) sentinel (funcall original-frame-parameter frame parameter))))) (let* ((rendered (ebox-render (ebox-create :content "Border" :width 60 :padding '(1 1) :border t))) (left-face (ebox-test--face-at-property rendered 'ebox-bl)) (right-face (ebox-test--face-at-property rendered 'ebox-br)) (top-face (ebox-test--face-at-property rendered 'ebox-bt)) (bottom-face (ebox-test--face-at-property rendered 'ebox-bb))) (should (ebox-test--face-has-key-value-p left-face :inverse-video t)) (should (ebox-test--face-has-key-value-p right-face :inverse-video t)) (should (ebox-test--face-has-key-value-p top-face :overline t)) (should (ebox-test--face-has-key-value-p bottom-face :underline '(:position t)))))))) (ert-deftest ebox-horizontal-borders-cover-side-border-corners () (ebox-test--reset-runtime-state) (require 'face-remap) (let* ((text-scale-mode-amount 2) (rendered (ebox-render (ebox-create :content "Border" :width 120 :padding '(0 (8)) :border '(1 "#ff0000")))) (lines (ebox-string-lines rendered)) (top (car lines)) (bottom (car (last lines))) (top-left (get-text-property 0 'face top)) (top-right (get-text-property (1- (length top)) 'face top)) (bottom-left (get-text-property 0 'face bottom)) (bottom-right (get-text-property (1- (length bottom)) 'face bottom))) (dolist (face (list top-left top-right)) (should (ebox-test--face-has-key-value-p face :background "#ff0000")) (should (ebox-test--face-has-key-value-p face :overline "#ff0000"))) (dolist (face (list bottom-left bottom-right)) (should (ebox-test--face-has-key-value-p face :background "#ff0000")) (should (ebox-test--face-has-key-value-p face :underline '(:position t :color "#ff0000")))))) (ert-deftest ebox-horizontal-borders-cover-side-border-corners-when-shrunk () (ebox-test--reset-runtime-state) (require 'face-remap) (let* ((text-scale-mode-amount -2) (rendered (ebox-render (ebox-create :content "Border" :width 120 :padding '(0 (8)) :border '(1 "#ff0000")))) (lines (ebox-string-lines rendered)) (top (car lines)) (bottom (car (last lines))) (top-left (get-text-property 0 'face top)) (top-right (get-text-property (1- (length top)) 'face top)) (bottom-left (get-text-property 0 'face bottom)) (bottom-right (get-text-property (1- (length bottom)) 'face bottom))) (dolist (face (list top-left top-right)) (should (ebox-test--face-has-key-value-p face :background "#ff0000")) (should (ebox-test--face-has-key-value-p face :overline "#ff0000"))) (dolist (face (list bottom-left bottom-right)) (should (ebox-test--face-has-key-value-p face :background "#ff0000")) (should (ebox-test--face-has-key-value-p face :underline '(:position t :color "#ff0000")))))) (ert-deftest ebox-horizontal-borders-do-not-cover-horizontal-margins () (ebox-test--reset-runtime-state) (let* ((rendered (ebox-render (ebox-create :content "Border" :width 80 :margin '(0 (8) 0 (8)) :border '(1 "#ff0000")))) (lines (ebox-string-lines rendered)) (top (car lines)) (bottom (car (last lines))) (top-left-margin (get-text-property 0 'face top)) (top-left-border (get-text-property 1 'face top)) (top-right-border (get-text-property (- (length top) 2) 'face top)) (top-right-margin (get-text-property (1- (length top)) 'face top)) (bottom-left-margin (get-text-property 0 'face bottom)) (bottom-left-border (get-text-property 1 'face bottom)) (bottom-right-border (get-text-property (- (length bottom) 2) 'face bottom)) (bottom-right-margin (get-text-property (1- (length bottom)) 'face bottom))) (dolist (face (list top-left-border top-right-border)) (should (ebox-test--face-has-key-value-p face :background "#ff0000")) (should (ebox-test--face-has-key-value-p face :overline "#ff0000"))) (dolist (face (list bottom-left-border bottom-right-border)) (should (ebox-test--face-has-key-value-p face :background "#ff0000")) (should (ebox-test--face-has-key-value-p face :underline '(:position t :color "#ff0000")))) (dolist (face (list top-left-margin top-right-margin)) (should-not (ebox-test--face-has-key-p face :overline))) (dolist (face (list bottom-left-margin bottom-right-margin)) (should-not (ebox-test--face-has-key-p face :underline))))) (ert-deftest ebox-pixel-cache-invalidates-when-display-signature-changes () (ebox-test--reset-runtime-state) (cl-letf (((symbol-function 'ebox--display-signature) (let ((signature 'first)) (lambda () signature)))) (clrhash ebox--char-width-cache) (ebox--clear-string-pixel-width-cache) (setq ebox--space-pixel-cache nil) (setq ebox--display-signature-cache nil) (ebox--char-pixel-width ?A) (should (= (hash-table-count ebox--char-width-cache) 1)) (should (> (hash-table-count ebox--string-pixel-width-cache) 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () 'second))) (ebox--ensure-display-cache-current) (should (= (hash-table-count ebox--char-width-cache) 0)) (should (= (hash-table-count ebox--string-pixel-width-cache) 0)) (should-not ebox--space-pixel-cache) (should (equal ebox--display-signature-cache 'second))))) (ert-deftest ebox-display-signature-includes-face-remapping () (ebox-test--reset-runtime-state) (let ((plain (ebox--display-signature)) (face-remapping-alist '((default (:height 1.4))))) (should-not (equal plain (ebox--display-signature))))) (ert-deftest ebox-pixel-width-respects-text-scale-with-fixed-spaces () (ebox-test--reset-runtime-state) (require 'face-remap) (let* ((plain (ebox--string-pixel-width "AAAA")) (fixed (ebox-pixel-space 30)) (text-scale-mode-amount 2) (scaled (ebox--string-pixel-width "AAAA")) (scaled-with-fixed (ebox--string-pixel-width (concat "AAAA" fixed)))) (should (> scaled plain)) (should (= scaled-with-fixed (+ scaled 30))))) (ert-deftest ebox-pixel-width-caches-plain-lines-during-render-context () "Repeated plain text width checks should share the render-local cache." (ebox-test--reset-runtime-state) (let ((ebox--display-cache-validated t) (ebox--render-string-pixel-width-cache (make-hash-table :test 'equal)) (ebox--string-pixel-width-cache (make-hash-table :test 'equal)) (ebox--string-pixel-width-cache-ring (make-vector ebox--string-pixel-width-cache-max-entries nil)) (ebox--string-pixel-width-cache-ring-index 0) (calls 0)) (cl-letf (((symbol-function 'string-pixel-width) (lambda (string) (cl-incf calls) (length string)))) (should (= (ebox--string-pixel-width "cached") 6)) (should (= (ebox--string-pixel-width "cached") 6)) (should (= calls 1))))) (ert-deftest ebox-pixel-width-reuses-plain-lines-across-render-transactions () "Stable display transactions should share plain string measurements." (ebox-test--reset-runtime-state) (let ((ebox--display-signature-cache 'stable) (probe (format "cross-render-%S" (make-symbol "probe"))) (calls 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () 'stable)) ((symbol-function 'string-pixel-width) (lambda (string) (cl-incf calls) (length string)))) (dotimes (_ 2) (ebox--with-validated-display-cache (ebox--string-pixel-width probe))) (should (= calls 1))))) (ert-deftest ebox-pixel-width-reuses-face-only-lines-with-face-isolation () "Display-scoped measurements should reuse equal faces without collisions." (ebox-test--reset-runtime-state) (let ((ebox--display-signature-cache 'stable) (bold (propertize "face-cache" 'face 'bold)) (italic (propertize "face-cache" 'face 'italic)) (calls 0)) (cl-letf (((symbol-function 'ebox--display-signature) (lambda () 'stable)) ((symbol-function 'string-pixel-width) (lambda (string) (cl-incf calls) (if (eq (get-text-property 0 'face string) 'bold) 10 20)))) (should (= (ebox--with-validated-display-cache (ebox--string-pixel-width bold)) 10)) (should (= (ebox--with-validated-display-cache (ebox--string-pixel-width bold)) 10)) (should (= (ebox--with-validated-display-cache (ebox--string-pixel-width italic)) 20)) (should (= calls 2))))) (ert-deftest ebox-persistent-pixel-width-cache-stays-bounded () "Display-scoped reusable string widths should evict at their entry cap." (ebox-test--reset-runtime-state) (let ((ebox--display-cache-validated t) (ebox--render-string-pixel-width-cache nil) (ebox--string-pixel-width-cache (make-hash-table :test 'equal)) (ebox--string-pixel-width-cache-ring (make-vector ebox--string-pixel-width-cache-max-entries nil)) (ebox--string-pixel-width-cache-ring-index 0)) (cl-letf (((symbol-function 'string-pixel-width) (lambda (string) (length string)))) (dotimes (index (+ ebox--string-pixel-width-cache-max-entries 17)) (ebox--string-pixel-width (format "measurement-%d" index)))) (should (= (hash-table-count ebox--string-pixel-width-cache) ebox--string-pixel-width-cache-max-entries)))) (ert-deftest ebox-string-max-pixel-width-works-without-render-cache () "Multiline max-width measurement should not require a render-local cache." (ebox-test--reset-runtime-state) (let ((ebox--display-cache-validated t) (ebox--render-string-max-pixel-width-cache nil)) (cl-letf (((symbol-function 'ebox--string-pixel-width) (lambda (line) (length (substring-no-properties line))))) (should (= (ebox--string-max-pixel-width "wide\nn") 4))))) (ert-deftest ebox-flex-string-width-caches-rendered-strings () "Repeated flex width checks should reuse render-local rendered string widths." (ebox-test--reset-runtime-state) (let ((ebox--display-cache-validated t) (ebox--render-string-pixel-width-cache (make-hash-table :test 'equal)) (ebox--render-string-max-pixel-width-cache (make-hash-table :test 'eq)) (rendered (concat (propertize "wide" 'face 'bold) "\n" (propertize "narrow" 'display '(space :width (2))))) (calls 0)) (cl-letf (((symbol-function 'ebox--string-pixel-width) (lambda (line) (cl-incf calls) (length (substring-no-properties line))))) (should (= (ebox--flex-string-width rendered) 6)) (should (= (ebox--flex-string-width rendered) 6)) (should (= calls 2))))) (ert-deftest ebox-flex-recache-skips-visited-source-subtrees () "Repeated source recache calls should not walk the same subtree twice." (ebox-test--reset-runtime-state) (let* ((source (ebox-column (ebox-create :content "A") (ebox-create :content "B"))) (ebox--render-recached-source-node-cache (make-hash-table :test 'eq)) (original-puthash (symbol-function 'puthash)) (region-table-writes 0)) (ebox-region-ids source) (cl-letf (((symbol-function 'puthash) (lambda (key value table) (when (eq table ebox--region-box-table) (cl-incf region-table-writes)) (funcall original-puthash key value table)))) (ebox--flex-recache-source-boxes source) (ebox--flex-recache-source-boxes source)) (should (= region-table-writes 2)))) (ert-deftest ebox-face-height-width-detection-handles-shrinking-text () (ebox-test--reset-runtime-state) (let ((ebox--face-height-width-cache nil) (factor 0.7)) (cl-letf (((symbol-function 'string-pixel-width) (lambda (string) (if (get-text-property 0 'face string) 70 101)))) (should (ebox--face-height-affects-width-p factor)) (should (= (ebox--scaled-string-pixel-width "probe" factor) 70))))) (ert-deftest ebox-buffer-property-patches-split-disjoint-property-changes () "Root publication should touch only disjoint property-aware changes." (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) (new (concat (propertize "a" 'face 'italic) "b" (propertize "c" 'display '(space :width (12))) "d")) (reordered (propertize "x" 'alpha 1 'beta '(2))) (same (propertize "x" 'beta '(2) 'alpha 1))) (should (equal (ebox-buffer--minimal-property-patches old new) '((:old-start 0 :old-end 1 :new-start 0 :new-end 1) (:old-start 2 :old-end 3 :new-start 2 :new-end 3)))) (should-not (ebox-buffer--minimal-property-patches reordered same)))) (ert-deftest ebox-buffer-property-patches-trim-length-change-boundaries () "A length-changing publication should retain its exact common edges." (should (equal (ebox-buffer--minimal-property-patches "abXYcd" "abZcd") '((:old-start 2 :old-end 4 :new-start 2 :new-end 3))))) (ert-deftest ebox-buffer-property-patches-preserve-internal-common-runs () "Text edits should not absorb unchanged runs inside a length change." (should (equal (ebox-buffer--minimal-property-patches "abXcdYef" "abZcdWQef") '((:old-start 2 :old-end 3 :new-start 2 :new-end 3) (:old-start 5 :old-end 6 :new-start 5 :new-end 7))))) (ert-deftest ebox-buffer-property-patches-align-equal-length-moves () "Equal-length text shifts should retain aligned unchanged characters." (should (equal (ebox-buffer--minimal-property-patches "abc" "bca") '((:old-start 0 :old-end 1 :new-start 0 :new-end 0) (:old-start 3 :old-end 3 :new-start 2 :new-end 3))))) (ert-deftest ebox-buffer-myers-runs-preserve-shortest-edit-distance () "Compressed alignment runs should retain the exact Myers edit distance." (cl-labels ((strings-of-length (length) (if (zerop length) '("") (cl-loop for prefix in (strings-of-length (1- length)) append (list (concat prefix "a") (concat prefix "b"))))) (reference-distance (old new) (let ((memo (make-hash-table :test 'equal))) (cl-labels ((visit (old-position new-position) (or (gethash (cons old-position new-position) memo) (let ((distance (cond ((= old-position (length old)) (- (length new) new-position)) ((= new-position (length new)) (- (length old) old-position)) ((= (aref old old-position) (aref new new-position)) (visit (1+ old-position) (1+ new-position))) (t (1+ (min (visit (1+ old-position) new-position) (visit old-position (1+ new-position)))))))) (puthash (cons old-position new-position) distance memo) distance)))) (visit 0 0))))) (let ((strings (cl-loop for length from 0 to 4 append (strings-of-length length)))) (dolist (old strings) (dolist (new strings) (let ((old-position 0) (new-position 0) (edits 0)) (dolist (run (ebox-buffer--myers-text-matches old new)) (let ((run-old (nth 0 run)) (run-new (nth 1 run)) (run-length (nth 2 run))) (cl-incf edits (- run-old old-position)) (cl-incf edits (- run-new new-position)) (should (equal (substring old run-old (+ run-old run-length)) (substring new run-new (+ run-new run-length)))) (setq old-position (+ run-old run-length) new-position (+ run-new run-length)))) (cl-incf edits (- (length old) old-position)) (cl-incf edits (- (length new) new-position)) (should (= edits (reference-distance old new))))))))) (ert-deftest ebox-buffer-root-property-patches-publish-only-changed-spans () "Applying root patches should preserve exact output and report actual writes." (with-temp-buffer (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) (new (concat (propertize "a" 'face 'italic) "b" (propertize "c" 'display '(space :width (12))) "d"))) (insert old) (let ((result (ebox-buffer--replace-root-with-property-patches '((1 . 5)) new))) (should (equal-including-properties (buffer-string) new)) (should (equal (plist-get result :published-buffer-spans) '((1 . 2) (3 . 4)))) (should-not (plist-get result :full-root-required-p)) (should (equal (plist-get result :new-buffer-spans) '((1 . 5)))))))) (ert-deftest ebox-buffer-rust-root-patches-bypass-full-string-diff () "Rust-origin patches should publish directly without a complete target string." (with-temp-buffer (let* ((old (concat (propertize "a" 'face 'bold) "bcd")) (replacement-a (propertize "a" 'face 'italic)) (replacement-c (propertize "c" 'display '(space :width (12)))) (native-patch (list :native-patch t :patch-origin 'rust :base-character-count 4 :target-character-count 4 :target-line-count 1 :patches (list (list :old-start 0 :old-end 1 :new-start 0 :new-end 1 :replacement replacement-a) (list :old-start 2 :old-end 3 :new-start 2 :new-end 3 :replacement replacement-c))))) (insert old) (cl-letf (((symbol-function 'ebox-buffer--minimal-property-patches) (lambda (&rest _) (ert-fail "Rust patch path rebuilt the complete diff")))) (let ((result (ebox-buffer--apply-root-property-patches '((1 . 5)) native-patch))) (should (equal-including-properties (buffer-string) (concat replacement-a "b" replacement-c "d"))) (should (equal (plist-get result :published-buffer-spans) '((1 . 2) (3 . 4)))) (should (eq (plist-get result :patch-origin) 'rust)) (should (= (plist-get result :full-frame-bytes) 0))))))) (ert-deftest ebox-visibility-hidden-suppresses-ink-and-keeps-footprint () "A hidden box renders blank with identical text, lengths, and metadata." (let* ((make-box (lambda (visibility) (ebox-create :content "Hello" :width (list 90) :border t :border-color "#AA3311" :bgcolor "#223344" :color "#FFFFFF" :padding '(0 1) :visibility visibility))) (visible (ebox-render (funcall make-box 'visible))) (hidden (ebox-render (funcall make-box 'hidden)))) (should (equal (substring-no-properties visible) (substring-no-properties hidden))) (should (equal (mapcar #'length (ebox-string-lines visible)) (mapcar #'length (ebox-string-lines hidden)))) ;; No character carries a face, every character renders under a ;; display spec, and each line's blank cover is measured to the ;; visible line's exact pixel width. (dotimes (index (length hidden)) (unless (eq (aref hidden index) ?\n) (should-not (get-text-property index 'face hidden)) (should (get-text-property index 'display hidden)))) (cl-loop for visible-line in (ebox-string-lines visible) for hidden-line in (ebox-string-lines hidden) do (should (= (ebox-string-pixel-width visible-line) (ebox-string-pixel-width hidden-line)))) ;; Region metadata still identifies the same owner surfaces at the ;; content characters. (let ((position (string-match "Hello" (substring-no-properties visible)))) (should position) (should (get-text-property position 'ebox-content-owner visible)) (should (get-text-property position 'ebox-content-owner hidden))))) (ert-deftest ebox-visibility-toggle-is-local-and-restores-exactly () "Hiding and showing one region never widens beyond its owner. The buffer text never changes; showing again restores the visible publication with properties intact." (let* ((buffer (generate-new-buffer " *ebox-visibility*")) (root (ebox-create :width (list 300) :ebox-content-node (ebox-column (ebox-create :content "Header line" :id 'header :width (list 300)) (ebox-create :content "Secret content" :id 'secret :width (list 300) :border t :bgcolor "#224466" :color "#FFFFFF") (ebox-create :content "Footer line" :id 'footer :width (list 300)))))) (unwind-protect (progn (ebox-render-to-buffer buffer root) (let ((before (with-current-buffer buffer (buffer-string)))) (ebox-selector-update-buffer buffer "#secret" :visibility 'hidden) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender paint-patch))) (should (= (plist-get report :patch-count) 1))) (should (equal (with-current-buffer buffer (substring-no-properties (buffer-string))) (substring-no-properties before))) (ebox-selector-update-buffer buffer "#secret" :visibility 'visible) (let ((report (ebox-test--buffer-update-report))) (should (memq (plist-get report :strategy) '(span-patch owner-rerender paint-patch))) (should (= (plist-get report :patch-count) 1))) (should (equal-including-properties (with-current-buffer buffer (buffer-string)) before)))) (kill-buffer buffer)))) (ert-deftest ebox-parent-border-color-patch-never-recolors-descendants () "A parent border-color paint patch touches only the owner's chrome. Border surfaces belong to each box: a full render never propagates an ancestor border color into a descendant's border runs, so the cheap face patch must match that exactly (issue014)." (ebox-test--reset-runtime-state) (let* ((make-tree (lambda (parent-color) (ebox-create :id 'parent :width (list 400) :border (list '(4) 'solid parent-color) :padding '(1 (12)) :ebox-content-node (ebox-column (ebox-create :id 'child :content "CHILD" :width (list 200) :border '((4) solid "#00AA00")))))) (visual-runs (lambda (buffer role) (with-current-buffer buffer (let (runs (position (point-min))) (while (< position (point-max)) (when (get-text-property position role) (push (list (line-number-at-pos position) (get-text-property position role) (ebox--face-visual-attributes (get-text-property position 'face))) runs)) (setq position (or (next-single-property-change position role) (point-max)))) (nreverse runs))))) (patched (generate-new-buffer " *i14-patched*")) (reference (generate-new-buffer " *i14-reference*"))) (unwind-protect (progn (ebox-render-to-buffer patched (funcall make-tree "#AA0000")) (ebox-selector-update-buffer patched "#parent" :border-color "#0000AA") (should (eq (plist-get (ebox-test--buffer-update-report) :strategy) 'paint-patch)) (ebox-test--reset-runtime-state) (ebox-render-to-buffer reference (funcall make-tree "#0000AA")) (dolist (role '(ebox-bl ebox-br ebox-bt ebox-bb)) (ert-info ((format "border role %S" role)) (should (equal (mapcar (lambda (run) (cons (car run) (caddr run))) (funcall visual-runs patched role)) (mapcar (lambda (run) (cons (car run) (caddr run))) (funcall visual-runs reference role))))))) (kill-buffer patched) (kill-buffer reference)))) ;;; ebox-core-render-tests.el ends here