ebox/tests/ebox-flex-tests.el
Kinneyzhang 79f5bc23d1 feat: add CSS sizing and native text interaction capabilities
Normalize size units and intrinsic sizing across Elisp and native layout. Add help, pointer, hover-style and keymap support with reusable interaction adapters.

Keep content updates local, preserve scroll caches and hover borders, and avoid rebuilding retained plans and ownership metadata for stable geometry.

Validation: make check and native-rust-tests passed; targeted native interaction and scroll publication regressions passed.
2026-09-09 22:25:18 +08:00

1866 lines
88 KiB
EmacsLisp

;;; ebox-flex-tests.el --- Ebox flex layout tests -*- lexical-binding: t; -*-
(require 'cl-lib)
(require 'ert)
(load-file (expand-file-name "../ebox.el" (file-name-directory (or load-file-name buffer-file-name))))
(require 'ebox-native-reflow)
(defun ebox-flex-test--render (node)
"Render NODE once for flex tests."
(ebox-render node))
(ert-deftest ebox-flex-resolved-constraints-preserve-pixels-and-unbounded-width ()
"Resolved constraints do not reapply author units or ambient defaults."
(let ((ebox-viewport-width 300)
(config (ebox-layout-config-props (ebox-flex-layout-create))))
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
(should (= (ebox--string-max-pixel-width
(ebox--render-flex-children
config '(:width 50 :height nil) '("X")))
50))
(should (= (ebox--string-max-pixel-width
(ebox--render-flex-children
config '(:width nil :height nil) '("X")))
(ebox--string-pixel-width "X")))
;; The private legacy adapter still supplies its omitted-width default.
(should (= (ebox--string-max-pixel-width
(ebox--render-flex
(list :ebox-type 'flex :props config :children '("X"))))
300)))))
(defun ebox-flex-test--intrinsic-toolbar (color &optional grow)
"Return a nested max-content Flex using COLOR and optional GROW."
(ebox-test-flex :width '(240) :flex-wrap 'wrap
(ebox-test-column :width '(40) :flex-grow (if grow 0 1)
(ebox-test-text "Title"))
(ebox-test-flex :id "toolbar" :width 'max-content
:padding '(0 (3)) :margin '(0 (4))
:column-gap '(2) :justify-content 'space-between
:flex-grow (if grow 1 0) :bgcolor color
(ebox-test-box :width '(20) (ebox-test-text "A"))
(ebox-test-box :width '(30) (ebox-test-text "B")))))
(ert-deftest ebox-flex-nested-max-content-survives-paint-and-fresh-render ()
"A paint change preserves the same single-line intrinsic Flex geometry."
(let* ((ebox-viewport-width 240)
(layout (ebox-flex-test--intrinsic-toolbar "white"))
(buffer (generate-new-buffer " *ebox-intrinsic-toolbar*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer layout)
(dolist (color '("white" "black" "white"))
(ebox-region-update (ebox-region-resolve buffer "toolbar")
:bgcolor color)
(let ((retained (with-current-buffer buffer (buffer-string)))
(fresh (ebox-render
(ebox-flex-test--intrinsic-toolbar color))))
(dolist (rendered (list retained fresh))
(should (= (ebox-string-height rendered) 1))
(should (= (ebox--string-max-pixel-width rendered) 240))
;; 174px title + 4px margin + 3px padding, counted once.
(should (= (ebox--string-pixel-width
(substring rendered 0 (string-match "A" rendered)))
181)))
(should (equal (substring-no-properties retained)
(substring-no-properties fresh))))))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest ebox-flex-max-content-respects-parent-allocation ()
"Intrinsic measurement does not discard a later definite Flex allocation."
(let* ((ebox-viewport-width 240)
(rendered (ebox-render
(ebox-flex-test--intrinsic-toolbar "white" t))))
(should (= (ebox-string-height rendered) 1))
(should (= (ebox--string-max-pixel-width rendered) 240))
(should (= (ebox--string-pixel-width
(substring rendered 0 (string-match "A" rendered)))
47))
(should (= (ebox--string-pixel-width
(substring rendered 0 (string-match "B" rendered)))
203))))
(ert-deftest ebox-flex-unbounded-constraint-reaches-nested-auto-flex ()
"An auto Flex inside max-content must not recover the outer viewport."
(let* ((ebox-viewport-width 300)
(layout
(ebox-test-flex :width 'max-content
(ebox-test-flex
(ebox-test-box :width '(20) (ebox-test-text "A"))
(ebox-test-box :width '(30) (ebox-test-text "B"))))))
(should (= (ebox--string-max-pixel-width (ebox-render layout)) 50))))
(ert-deftest ebox-flex-padded-lazy-children-use-content-viewport ()
"Lazy responsive Flex items use the same content viewport as full layout."
(let* ((ebox-viewport-width 300)
(ebox-viewport-height 5)
(layout
(ebox-test-column :width '(viewport) :height '(viewport-height)
(ebox-test-flex :width 'stretch :padding '(0 (10))
:flex-wrap 'wrap
(ebox-test-flex :width 'stretch :wrap-mode 'none
(ebox-test-box :width '(20) (ebox-test-text "A"))
(ebox-test-box :width '(30) (ebox-test-text "B"))))))
(_ (ebox-region-ids (ebox-test-root layout)))
(eager (let ((ebox--scroll-window-render-disabled t))
(ebox-render layout)))
(original (symbol-function 'ebox--render-flex-window-lines))
(lazy-renders 0)
lazy)
(cl-letf (((symbol-function 'ebox--render-flex-window-lines)
(lambda (node limit)
(let ((result (funcall original node limit)))
(when result (cl-incf lazy-renders))
result))))
(setq lazy (ebox-render layout)))
(should (> lazy-renders 0))
(should (= (ebox--string-max-pixel-width eager) 300))
(should (= (ebox--string-max-pixel-width lazy) 300))
(should (equal-including-properties lazy eager))))
(ert-deftest ebox-flex-normalized-gap-keeps-pixels-on-wide-fonts ()
"Flex gaps use the zero advance and preserve explicit pixel units."
(cl-letf (((symbol-function 'ebox--size-character-width) (lambda (&optional _) 7)))
(dolist (case '(((ch 2) . 14) ((px 2) . 2)))
(let* ((layout
(ebox-test-flex :width '(100) :column-gap (car case)
(ebox-test-text "A")
(ebox-test-text "B")))
(rendered (ebox-render layout))
(left (string-match "A" rendered))
(right (string-match "B" rendered)))
(should left)
(should right)
(should (= (ebox--string-pixel-width
(substring rendered (1+ left) right))
(cdr case)))
(should
(= (plist-get
(ebox-native-reflow--compile-flex-inner
(ebox-layout-config-props
(ebox-box-node-layout (ebox-test-root layout))) nil)
:column-gap)
(cdr case)))))))
(ert-deftest ebox-flex-panels-in-padded-viewport-column-match-eager-layout ()
"Lazy Column layout must preserve nested panel geometry and borders."
(dolist (padding '(0 (1 2)))
(dolist (viewport-width '(140 96 140))
(let* ((ebox-viewport-width viewport-width)
(ebox-viewport-height 30)
(layout
(ebox-test-column
:width '(viewport) :height '(viewport-height)
:padding padding :bgcolor "white"
(ebox-test-flex :width 'stretch :flex-wrap 'wrap :gap '(1 2)
(ebox-test-column
:flex-basis '(20) :flex-shrink 0
:padding '(1 2) :border "gray"
(ebox-test-text "Sidebar"))
(ebox-test-column
:flex-grow 1 :flex-basis '(48) :min-width 0
:padding '(1 2) :border "gray"
(ebox-test-text "Main"))
(ebox-test-column
:flex-grow 1 :flex-basis '(28) :min-width 0
(ebox-test-column :padding '(1 2) :border "gray"
(ebox-test-text "Details") (ebox-test-text "Body"))
(ebox-test-column :padding '(1 2) :border "gray"
(ebox-test-text "Service") (ebox-test-text "Ready"))))))
(_ (ebox-region-ids (ebox-test-root layout)))
(eager (let ((ebox--scroll-window-render-disabled t))
(ebox-render layout)))
(lazy (ebox-render layout)))
(should (equal (substring-no-properties lazy)
(substring-no-properties eager)))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines lazy))
(mapcar #'ebox--string-pixel-width
(ebox-string-lines eager))))
(should (equal-including-properties lazy eager))))))
(ert-deftest ebox-flex-viewport-column-wraps-text-and-preserves-box-overflow ()
"A lazy Column wraps raw Text but keeps a definite child's spatial output."
(let* ((ebox-viewport-width 20)
(ebox-viewport-height 10)
(layout
(ebox-test-column
:width '(viewport) :height '(viewport-height) :padding '(0 (2))
(ebox-test-text "one two three four five six")
(ebox-test-box :width '(30) :wrap-mode 'none :border "blue"
(ebox-test-text "wide child"))))
(_ (ebox-region-ids (ebox-test-root layout)))
(eager (let ((ebox--scroll-window-render-disabled t))
(ebox-render layout)))
(lazy (ebox-render layout)))
(should (equal-including-properties lazy eager))))
(ert-deftest ebox-flex-padded-scroll-producers-retain-containing-viewport ()
"Lazy extension and full materialization keep the initial content width."
(let* ((ebox-viewport-width 100)
(ebox-viewport-height 4)
(ebox-scroll-lazy-prefix-lookahead-lines 0)
(ebox-wheel-scroll-step 0)
(ebox-scroll-lazy-idle-prefetch-delay 999)
(layout
(apply #'ebox-test-column
(append
'(:id "padded-scroll" :width (viewport)
:height (viewport-height) :padding (0 (2)))
(cl-loop for index below 30
collect
(ebox-test-column :border "gray"
(ebox-test-text (format "row %02d" index)))))))
(buffer (generate-new-buffer " *ebox-padded-scroll*"))
root-id)
(unwind-protect
(progn
(ebox-render-to-buffer buffer layout)
(setq root-id
(plist-get (car (ebox-selector-query-buffer
buffer "#padded-scroll")) :region-id))
(let* ((state (ebox-scroll-state root-id))
(prefix (plist-get state :render-content-prefix))
(materialize (plist-get state :materialize-content-lines)))
(should prefix)
(should materialize)
(let* ((extended
(ebox--scroll-state-ensure-prefix-lines root-id state 12 t))
(partial-lines (plist-get extended :content-lines))
(complete
(ebox--scroll-state-materialize-lines root-id extended))
(full-lines (plist-get complete :content-lines)))
(should (= (length partial-lines) 12))
(should (= (length full-lines) 30))
(dolist (line (append partial-lines full-lines))
(should (= (ebox--string-pixel-width line) 96)))
(should (cl-every #'identity
(cl-mapcar
(lambda (partial full)
(equal-including-properties
(ebox--strip-paint-origins!
(copy-sequence partial))
(ebox--strip-paint-origins!
(copy-sequence full))))
partial-lines (seq-take full-lines 12)))))))
(when root-id (ebox--scroll-cancel-idle-prefetch root-id))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(defun ebox-flex-test--plain-lines (node)
"Return rendered NODE as plain lines."
(ebox-string-lines (substring-no-properties (ebox-flex-test--render node))))
(defun ebox-flex-test--line-widths (node)
"Return rendered NODE pixel widths line by line."
(mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-flex-test--render node))))
(defun ebox-flex-test--region-property-width (string region-id property)
"Return max PROPERTY span width for REGION-ID in STRING."
(let ((pos 0)
(len (length string))
(max-width 0))
(while (< pos len)
(if (equal (get-text-property pos property string) region-id)
(let ((end (or (next-single-property-change
pos property string len)
len)))
(setq max-width
(max max-width
(string-pixel-width (substring string pos end))))
(setq pos end))
(setq pos (or (next-single-property-change
pos 'ebox-content string len)
len))))
max-width))
(defun ebox-flex-test--region-content-width (string region-id)
"Return max content span width for REGION-ID in STRING."
(ebox-flex-test--region-property-width string region-id 'ebox-content))
(defun ebox-flex-test--region-owner-width (string region-id)
"Return max rendered owner span width for REGION-ID in STRING."
(let ((pos 0)
(len (length string))
(current-width 0)
(max-width 0))
(while (< pos len)
(let* ((owners (get-text-property pos 'ebox-content-owners string))
(end (or (next-single-property-change
pos 'ebox-content-owners string len)
len)))
(if (memq region-id owners)
(setq current-width
(+ current-width
(string-pixel-width (substring string pos end))))
(setq max-width (max max-width current-width)
current-width 0))
(setq pos end)))
(max max-width current-width)))
(defun ebox-flex-test--content-widths (input)
"Return max content widths for INPUT's direct material children."
(let ((node (ebox-test-root input)))
(let* ((ids (mapcar (lambda (child)
(car (ebox-region-ids child)))
(ebox-box-node-children node)))
(rendered (ebox-flex-test--render input)))
(mapcar (lambda (id)
(ebox-flex-test--region-owner-width rendered id))
ids))))
(defun ebox-flex-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-flex-test--face-has-key-value-p item key value))
face))
(t nil)))
(defun ebox-flex-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-flex-test--property-values (string property)
"Return distinct values for PROPERTY in STRING."
(let ((pos 0)
(len (length string))
values)
(while (< pos len)
(when-let* ((value (get-text-property pos property string)))
(unless (memq value values)
(setq values (append values (list value)))))
(setq pos (1+ pos)))
values))
(ert-deftest ebox-flex-batch-line-sizing-preserves-fallback-semantics ()
"Batch line sizing should preserve the scalar Flexbox algorithm."
(let* ((line
(list
(list :base 80 :hypothetical 80 :min-main 0 :max-main nil
:props '(:flex-grow 1 :flex-shrink 1))
(list :base 80 :hypothetical 80 :min-main 0 :max-main nil
:props '(:flex-grow 2 :flex-shrink 1))))
(expected
(mapcar (lambda (item) (plist-get item :target))
(ebox--flex-size-line line 200 10)))
(actual
(mapcar (lambda (item) (plist-get item :target))
(car (ebox--flex-size-lines (list line) 200 10)))))
(should (equal actual expected))))
(ert-deftest ebox-flex-module-preserves-item-participation ()
"Extracted flex code should preserve item participation semantics."
(let* ((node (ebox-build
'(flex :width (px 240)
(box :flex-grow 1 :width (px 40) "Grow")
(box :width (px 40) "Fixed"))))
(rendered (ebox-render node)))
(should (string-match-p "Grow" rendered))
(should (string-match-p "Fixed" rendered))))
(ert-deftest ebox-flex-measures-direct-text-without-box-geometry ()
"A canonical Text child should participate as text, not a geometry Box."
(let* ((node (ebox-build '(flex :width (px 120) "Direct text")))
(rendered (ebox-render node)))
(should (string-match-p "Direct text"
(substring-no-properties rendered)))
(should (= (ebox--string-max-pixel-width rendered) 120))))
(ert-deftest ebox-flex-item-clears-disappeared-ecss-props-on-region-update ()
"A region update should clear removed rule props but retain inline item props."
(let* ((ebox-style-stylesheet (ecss-stylesheet-create))
(layout
(ebox-test-flex
(ebox-test-box :id "rule" :class 'grow
(ebox-test-text "Rule") :width '(40))
(ebox-test-box :id "inline" (ebox-test-text "Inline") :width '(40)
:flex-grow 2)))
(buffer (generate-new-buffer " *ebox-flex-style-reset*")))
(unwind-protect
(progn
(ebox-style-add-rule ".grow" '(:flex-grow 1) :layer 'layout)
(ebox-render-to-buffer buffer layout)
(let ((items (plist-get (plist-get (ebox--buffer-render-state buffer)
:root-node)
:children)))
(should (= (ebox-get (car items) :flex-grow) 1))
(should (= (ebox-get (cadr items) :flex-grow) 2)))
(ebox-style-reset-rules)
(ebox-region-update (ebox-region-resolve buffer "rule")
:width '(px 41))
(let ((items (plist-get (plist-get (ebox--buffer-render-state buffer)
:root-node)
:children)))
(should-not (ebox-get (car items) :flex-grow))
(should (= (ebox-get (cadr items) :flex-grow) 2))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-flex-accepts-flex-props-on-direct-child-box ()
"Flex item participation props may live directly on child boxes."
(let* ((layout (ebox-test-flex
:width 300
(ebox-test-box (ebox-test-text "A") :flex-grow 1 :flex-basis '(80))
(ebox-test-box (ebox-test-text "B") :flex-grow 1 :flex-basis '(80))))
(rendered (let ((ebox-viewport-width 300))
(ebox-render layout)))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))))
(should (cl-every (lambda (width) (<= width 300)) widths))
(should (string-match-p "A" (substring-no-properties rendered)))
(should (string-match-p "B" (substring-no-properties rendered)))))
(ert-deftest ebox-flex-grows-items-to-fill-row-container ()
"Flex grow should distribute extra row width by grow factors."
(let* ((layout
(ebox-test-flex :width '(300)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(80))
:flex-grow 1 :flex-basis '(80))
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(80))
:flex-grow 2 :flex-basis '(80))))
(widths (ebox-flex-test--content-widths layout)))
(should (= (length widths) 2))
(should (= (apply #'+ widths) 300))
(should (> (cadr widths) (car widths)))
(should (equal (ebox-flex-test--line-widths layout) '(300)))))
(ert-deftest ebox-flex-margin-box-target-subtracts-inline-margins-for-border-box ()
"A row flex target should include a border-box item's inline margins."
(let ((layout
(ebox-test-flex :width '(200) :align-items 'flex-start
(ebox-test-box (ebox-test-text "A")
:width '(40)
:box-sizing 'border-box
:margin-left '(20)
:margin-right '(20)
:flex-grow 1))))
(should (equal (ebox-flex-test--line-widths layout) '(200)))))
(ert-deftest ebox-flex-margin-box-target-subtracts-block-margins-for-border-box ()
"A column flex target should include a border-box item's block margins."
(let ((layout
(ebox-test-flex :width '(80) :height 6
:flex-direction 'column
:align-items 'flex-start
(ebox-test-box (ebox-test-text "A")
:width '(40)
:height 1
:box-sizing 'border-box
:margin-top 1
:margin-bottom 1
:flex-grow 1))))
(should (= (ebox-string-height (ebox-flex-test--render layout)) 6))))
(ert-deftest ebox-flex-margin-box-target-preserves-content-box-outer-sides ()
"Content-box flex targets should subtract padding, borders, and margins."
(let ((layout
(ebox-test-flex :width '(200) :align-items 'flex-start
(ebox-test-box (ebox-test-text "A")
:width '(40)
:box-sizing 'content-box
:padding-left '(10)
:padding-right '(10)
:border-left '(5)
:border-right '(5)
:margin-left '(20)
:margin-right '(20)
:flex-grow 1))))
(should (equal (ebox-flex-test--line-widths layout) '(200)))))
(ert-deftest ebox-flex-shrinks-items-by-shrink-factors ()
"Flex shrink should remove more width from larger shrink factors."
(let* ((layout
(ebox-test-flex :width '(150)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(100))
:flex-shrink 1 :flex-basis '(100))
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(100))
:flex-shrink 3 :flex-basis '(100))))
(widths (ebox-flex-test--content-widths layout)))
(should (= (length widths) 2))
(should (= (apply #'+ widths) 150))
(should (> (car widths) (cadr widths)))))
(ert-deftest ebox-flex-basis-supports-intrinsic-keywords ()
"Flex basis intrinsic keywords should use content-derived sizes."
(let* ((content "aa bbbb")
(max-content (ebox--string-pixel-width content))
(min-content (ebox--string-pixel-width "bbbb"))
(layout
(ebox-test-flex :width '(300)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text content) :width '(160) :wrap-mode 'word)
:flex-basis 'max-content)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text content) :width '(160) :wrap-mode 'word)
:flex-basis 'min-content)))
(widths (ebox-flex-test--content-widths layout)))
(should (equal widths (list max-content min-content)))))
(ert-deftest ebox-flex-definite-basis-does-not-materialize-fallback ()
"A definite basis must not materialize a subtree for an unused fallback."
(dolist (basis '((px 0) (px 40) (ch 40)))
(let* ((input
(ebox-test-box
(ebox-test-column
(ebox-test-text "nested content")
(ebox-test-text "second line"))))
(source (ebox-test-root input))
(ebox--surface-materialization-active t)
(expected (ebox--size-pixels basis source 'width))
(original-max (symbol-function 'ebox--content-max-pixel))
(original-render (symbol-function 'ebox--render-layout))
(max-calls 0)
(materializations 0))
(cl-letf (((symbol-function 'ebox--content-max-pixel)
(lambda (box)
(cl-incf max-calls)
(funcall original-max box)))
((symbol-function 'ebox--render-layout)
(lambda (node)
(cl-incf materializations)
(funcall original-render node))))
(should (= expected (ebox--flex-basis-main source "" 'row basis))))
(should (zerop max-calls))
(should (zerop materializations)))))
(ert-deftest ebox-flex-basis-retains-required-intrinsic-fallback ()
"Viewport-like basis values measure only when their viewport is absent."
(dolist (viewport '(nil 120))
(dolist (basis '((vw 100) stretch))
(let* ((content "natural content")
(source (ebox-test-root (ebox-test-box (ebox-test-text content))))
(ebox--surface-materialization-active t)
(ebox-viewport-width viewport)
(original-max (symbol-function 'ebox--content-max-pixel))
(max-calls 0))
(cl-letf (((symbol-function 'ebox--content-max-pixel)
(lambda (box)
(cl-incf max-calls)
(funcall original-max box))))
(should (= (or viewport (ebox--string-pixel-width content))
(ebox--flex-basis-main source "" 'row basis))))
(should (= max-calls (if viewport 0 1)))))))
(ert-deftest ebox-flex-lazy-basis-fallback-preserves-public-dsl-output ()
"Public Flex layout retains complete output when unused probes disappear."
(let* ((input
(ebox-build
'(flex :width (px 140) :flex-wrap wrap :gap ((lh 1) (px 4))
(box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
:wrap-mode word :padding ((lh 1) (px 2))
:color "#123456" :bgcolor "#ddeeff"
(column
(text "alpha beta gamma delta epsilon")
(text "second line")))
(box :flex-basis (px 20) :min-width (ch 0) :wrap-mode word
(text "peer")))))
(original-basis (symbol-function 'ebox--flex-basis-main))
(fast (ebox-render input))
eager)
;; Reintroduce only the eager fallback's materialization, using the same
;; public input and the unchanged resolver for the reference output.
(cl-letf (((symbol-function 'ebox--flex-basis-main)
(lambda (source rendered axis basis)
(when (and (eq axis 'row)
(ebox--flex-box-source-p source)
(ebox--flex-fixed-basis-value-p basis))
(ebox--content-max-pixel source))
(funcall original-basis source rendered axis basis))))
(setq eager (ebox-render input)))
(should (equal-including-properties fast eager))))
(ert-deftest ebox-flex-grow-redistributes-space-after-max-width-clamp ()
"A max-width-clamped item should freeze and leave space for flexible siblings."
(let* ((layout
(ebox-test-flex :width '(200)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(60) :max-width '(80))
:flex-grow 1 :flex-basis '(60))
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(60))
:flex-grow 1 :flex-basis '(60))))
(widths (ebox-flex-test--content-widths layout)))
(should (equal widths '(80 120)))
(should (equal (ebox-flex-test--line-widths layout) '(200)))))
(ert-deftest ebox-flex-grow-starts-from-flex-base-before-min-width-clamp ()
"Grow distribution should start from flex base sizes, not hypothetical sizes."
(let* ((layout
(ebox-test-flex :width '(200)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(50) :min-width '(80))
:flex-grow 1 :flex-basis '(50))
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(50))
:flex-grow 1 :flex-basis '(50))))
(widths (ebox-flex-test--content-widths layout)))
(should (equal widths '(100 100)))
(should (equal (ebox-flex-test--line-widths layout) '(200)))))
(ert-deftest ebox-flex-grow-factors-below-one-partially-fill-free-space ()
"Grow factors whose sum is below 1 should leave some free space unclaimed."
(let* ((layout
(ebox-test-flex :width '(300)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(50))
:flex-grow 0.25)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(50))
:flex-grow 0.25)))
(widths (ebox-flex-test--content-widths layout)))
(should (equal widths '(100 100)))
(should (equal (ebox-flex-test--line-widths layout) '(300)))))
(ert-deftest ebox-flex-shrink-redistributes-space-after-min-width-clamp ()
"A min-width-clamped item should freeze and push shrinkage to siblings."
(let* ((layout
(ebox-test-flex :width '(150)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(100) :min-width '(80))
:flex-shrink 1 :flex-basis '(100))
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(100))
:flex-shrink 1 :flex-basis '(100))))
(widths (ebox-flex-test--content-widths layout)))
(should (equal widths '(80 70)))
(should (equal (ebox-flex-test--line-widths layout) '(150)))))
(ert-deftest ebox-flex-shrink-starts-from-flex-base-before-max-width-clamp ()
"Shrink distribution should start from flex base sizes, not hypothetical sizes."
(let* ((layout
(ebox-test-flex :width '(150)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(100) :max-width '(80)
:wrap-mode 'word)
:flex-shrink 1 :flex-basis '(100))
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(100) :wrap-mode 'word)
:flex-shrink 1 :flex-basis '(100))))
(widths (ebox-flex-test--content-widths layout)))
(should (equal widths '(75 75)))
(should (equal (ebox-flex-test--line-widths layout) '(150)))))
(ert-deftest ebox-flex-shrink-factors-below-one-partially-shrink-overflow ()
"Shrink factors whose sum is below 1 should leave some overflow unclaimed."
(let* ((layout
(ebox-test-flex :width '(150)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(100))
:flex-shrink 0.25)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(100))
:flex-shrink 0.25)))
(widths (ebox-flex-test--content-widths layout)))
(should (equal widths '(87 88)))
(should (equal (ebox-flex-test--line-widths layout) '(175)))))
(ert-deftest ebox-flex-properties-reject-negative-values ()
"Flex width, basis, grow, and shrink factors should reject negatives."
(should-error
(ebox-flex-test--render
(ebox-test-flex :width '(-1)
(ebox-test-box (ebox-test-text "A") :width '(20))))
:type 'error)
(should-error
(ebox-flex-test--render
(ebox-test-flex
(ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(20))
:flex-grow -1)))
:type 'error)
(should-error
(ebox-flex-test--render
(ebox-test-flex
(ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(20))
:flex-shrink -1)))
:type 'error)
(should-error
(ebox-flex-test--render
(ebox-test-flex
(ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(20))
:flex-basis '(-1))))
:type 'error))
(ert-deftest ebox-flex-wraps-rows-with-column-gap ()
"Row wrap should create new flex lines using column gaps."
(let* ((layout
(ebox-test-flex :width '(210) :flex-wrap 'wrap :column-gap '(10)
(ebox-test-box (ebox-test-text "A") :width '(100))
(ebox-test-box (ebox-test-text "B") :width '(100))
(ebox-test-box (ebox-test-text "C") :width '(100))))
(lines (ebox-flex-test--plain-lines layout)))
(should (= (length lines) 2))
(should (string-match-p "A.*B" (car lines)))
(should (string-match-p "C" (cadr lines)))
(should (equal (ebox-flex-test--line-widths layout) '(210 210)))))
(ert-deftest ebox-flex-row-wrap-measures-column-items-at-natural-width ()
"Column flex items should not stretch to the full row before line breaking."
(let* ((layout
(ebox-build
'(flex :flex-flow (row wrap) :column-gap (px 12) :width (px 800)
(column
(box :width (px 220) "one")
(box :width (px 42) "A"))
(column
(box :width (px 220) "two")
(box :width (px 42) "B"))
(column
(box :width (px 220) "three")
(box :width (px 42) "C")))))
(lines (ebox-flex-test--plain-lines layout)))
(should (= (length lines) 2))
(should (string-match-p "one.*two.*three" (car lines)))
(should (string-match-p "A.*B.*C" (cadr lines)))))
(ert-deftest ebox-flex-rerenders-composite-item-for-assigned-viewport ()
"Composite flex items should re-render internals for final item width."
(let* ((layout
(ebox-build
'(flex :flex-flow (row wrap) :column-gap (px 12) :width (px 800)
(column
(box :id "title" :max-width (px 293)
:padding ((lh 0) (px 10)) :border "#5E7F6A"
:text-align center "wrap")
(flex :id "body" :max-width (px 293) :padding ((lh 0) (px 8))
:border-right "#5E7F6A"
:border-bottom "#5E7F6A"
:border-left "#5E7F6A"
:flex-wrap wrap :column-gap (px 8)
(box :flex-shrink 0 :width (px 118) "A 118")
(box :flex-shrink 0 :width (px 118) "B 118")
(box :flex-shrink 0 :width (px 118) "C 118"))))))
(buffer (generate-new-buffer " *ebox-flex-viewport*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer layout)
(let* ((rendered (with-current-buffer buffer (buffer-string)))
(title-id (plist-get
(car (ebox-selector-query-buffer buffer "#title"))
:region-id))
(body-id (plist-get
(car (ebox-selector-query-buffer buffer "#body"))
:region-id))
(title-width (ebox-flex-test--region-property-width
rendered title-id 'ebox-bt))
(body-width (ebox-flex-test--region-property-width
rendered body-id 'ebox-bb)))
(should (> title-width 200))
(should (> body-width 200))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-flex-wrapper-bounds-preformatted-child-layout ()
"Flex container boxes should not let child layout overflow widen them."
(let* ((ebox-viewport-width 500)
(layout
(ebox-build
'(flex :flex-flow (row wrap) :row-gap (lh 1) :column-gap (px 12)
:padding ((lh 0) (px 12))
:border-right "#6F6A95"
:border-bottom "#6F6A95"
:border-left "#6F6A95"
:bgcolor "#FBF9FF"
(column
(box :max-width (px 440)
:padding ((lh 0) (px 10)) :border "#6F6A95"
:text-align center ":flex-flow (row wrap)")
(flex :max-width (px 440) :padding ((lh 0) (px 8))
:border-right "#6F6A95"
:border-bottom "#6F6A95"
:border-left "#6F6A95"
:flex-flow (row wrap) :gap ((lh 1) (px 12))
(box :width (px 170) "A")
(box :width (px 170) "B")
(box :width (px 170) "C wraps")))
(column
(box :max-width (px 440)
:padding ((lh 0) (px 10)) :border "#6F6A95"
:text-align center ":flex-flow (column wrap)")
(flex :max-width (px 440) :padding ((lh 0) (px 8))
:border-right "#6F6A95"
:border-bottom "#6F6A95"
:border-left "#6F6A95"
:flex-flow (column wrap) :gap ((lh 1) (px 12))
(box :width (px 140) "A")
(box :width (px 140) "B")
(box :width (px 140) "C new column"))))))
(rendered (ebox-flex-test--render layout))
(line-widths (ebox-flex-test--line-widths layout))
(border-widths
(cl-loop for id in (ebox-flex-test--property-values rendered
'ebox-bb)
collect (ebox-flex-test--region-property-width
rendered id 'ebox-bb))))
(should (cl-every (lambda (width) (<= width ebox-viewport-width))
line-widths))
(should (cl-some (lambda (width) (> width 400)) border-widths))))
(ert-deftest ebox-flex-wraps-with-max-width-clamped-hypothetical-size ()
"Line collection should use max-width-clamped hypothetical main sizes."
(let* ((layout
(ebox-test-flex :width '(210) :flex-wrap 'wrap :column-gap '(10)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(200) :max-width '(100))
:flex-basis '(200))
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "B") :width '(200) :max-width '(100))
:flex-basis '(200))))
(lines (ebox-flex-test--plain-lines layout))
(widths (ebox-flex-test--content-widths layout)))
(should (= (length lines) 1))
(should (equal widths '(100 100)))
(should (equal (ebox-flex-test--line-widths layout) '(210)))))
(ert-deftest ebox-flex-omitted-width-stretches-to-viewport-when-bound ()
"A row flex container without :width should use the current viewport."
(let* ((ebox-viewport-width 240)
(layout
(ebox-test-flex :flex-wrap 'wrap :column-gap '(10)
(ebox-test-box (ebox-test-text "A") :width '(100))
(ebox-test-box (ebox-test-text "B") :width '(100))
(ebox-test-box (ebox-test-text "C") :width '(100)))))
(should (equal (ebox-flex-test--line-widths layout) '(240 240)))))
(ert-deftest ebox-flex-stretch-width-is-independent-of-container-wrapper ()
"Stretch width should use the viewport with or without a neutral wrapper."
(let* ((ebox-viewport-width 200)
(plain
(ebox-test-flex :width 'stretch
(ebox-test-box (ebox-test-text "A") :width '(40))))
(padded
(ebox-test-flex :width 'stretch :padding 0
(ebox-test-box (ebox-test-text "A") :width '(40)))))
(should (equal (mapcar #'ebox-flex-test--line-widths
(list plain padded))
'((200) (200))))))
(ert-deftest ebox-flex-indefinite-width-keeps-natural-size-across-wrapper ()
"Indefinite width should stay natural with or without a neutral wrapper."
(let* ((ebox-viewport-width nil)
(cases
(mapcar
(lambda (width)
(let ((plain
(ebox-test-flex :width width
(ebox-test-box (ebox-test-text "A") :width '(40))))
(padded
(ebox-test-flex :width width :padding 0
(ebox-test-box (ebox-test-text "A") :width '(40)))))
(list width plain padded)))
'(stretch auto)))
(results
(mapcar
(lambda (case)
(list (car case)
(car (ebox-flex-test--line-widths (nth 1 case)))
(car (ebox-flex-test--line-widths (nth 2 case)))))
cases)))
(should (equal results
'((stretch 40 40)
(auto 40 40))))))
(ert-deftest ebox-flex-omitted-wrapper-width-uses-viewport-content-box ()
"A styled flex container should lay out inside its viewport-sized box."
(let* ((ebox-viewport-width 240)
(layout
(ebox-test-flex :padding-inline '(20)
:border-left '(10)
:border-right '(10)
:justify-content 'space-between
(ebox-test-box (ebox-test-text "A") :width '(40))
(ebox-test-box (ebox-test-text "B") :width '(40))))
(rendered (ebox-flex-test--render layout))
(display-widths
(cl-loop for index below (length rendered)
for display = (get-text-property index 'display rendered)
when (and (consp display)
(eq (car display) 'space))
collect (car (plist-get (cdr display) :width)))))
(should (equal (ebox-flex-test--line-widths layout) '(240)))
(should (member 100 display-widths))))
(ert-deftest ebox-flex-viewport-wrapper-width-uses-content-box ()
"A viewport-sized styled flex container should use its content box for lines."
(let* ((ebox-viewport-width 240)
(layout
(ebox-test-flex :width '(viewport)
:padding-inline '(20)
:border-left '(10)
:border-right '(10)
:flex-wrap 'wrap
(ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(180)))
(ebox-test-flex-item (ebox-test-box (ebox-test-text "B") :width '(180))))))
(should (cl-every (lambda (width) (<= width ebox-viewport-width))
(ebox-flex-test--line-widths layout)))))
(ert-deftest ebox-flex-auto-height-is-independent-of-container-wrapper ()
"Auto height should preserve natural child height across wrapper branches."
(let* ((plain
(ebox-test-flex :width '(80) :height 'auto
(ebox-test-box (ebox-test-text "A\nB") :width '(40))))
(padded
(ebox-test-flex :width '(80) :height 'auto :padding 0
(ebox-test-box (ebox-test-text "A\nB") :width '(40))))
(results
(mapcar
(lambda (layout)
(condition-case err
(ebox-string-height (ebox-flex-test--render layout))
(error err)))
(list plain padded))))
(should (equal results '(2 2)))))
(ert-deftest ebox-flex-reuses-measured-non-box-child-render ()
"Flex should not fully rerender a measured non-box child during one render."
(let* ((child (ebox-test-column
:key 'measured-child
(ebox-test-box (ebox-test-text "A") :width '(100))
(ebox-test-box (ebox-test-text "B") :width '(100))))
(layout (ebox-test-flex :width '(240) :flex-wrap 'wrap
child
(ebox-test-column
(ebox-test-box (ebox-test-text "C") :width '(100))
(ebox-test-box (ebox-test-text "D") :width '(100)))))
(target-handle
(ebox-node-source-handle (ebox-test-root child)))
(calls 0)
(original-render
(symbol-function 'ebox--flex-render-source-for-measurement)))
(cl-letf (((symbol-function 'ebox--flex-render-source-for-measurement)
(lambda (node viewport)
(when (eq (ebox-node-source-handle node) target-handle)
(cl-incf calls))
(funcall original-render node viewport))))
(ebox-render layout))
(should (= calls 1))))
(ert-deftest ebox-flex-reuses-natural-non-box-render-before-cross-padding ()
"Final cross padding should reuse a naturally sized non-box render."
(let* ((source (ebox-test-column
:key 'natural-child
(ebox-test-box (ebox-test-text "A") :width '(80))
(ebox-test-box (ebox-test-text "A2") :width '(80))))
(item (ebox-test-flex-item
source :flex-grow 1 :flex-basis '(80)))
(layout
(ebox-test-flex :width '(220)
item
(ebox-test-box (ebox-test-text "B") :width '(80) :height 4)))
(target-handle
(ebox-node-source-handle (ebox-test-root item)))
(calls 0)
(original (symbol-function 'ebox--render-node))
rendered)
(cl-letf (((symbol-function 'ebox--render-node)
(lambda (node &optional source-index)
(when (eq (ebox-node-source-handle node) target-handle)
(cl-incf calls))
(funcall original node source-index))))
(setq rendered (ebox-render layout)))
(should (= (ebox-string-height rendered) 4))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))
'(220 220 220 220)))
(should (= calls 2))))
(defun ebox-flex-test--observe-sized-box (input &optional force-final-render)
"Render INPUT and observe its first child's actual Box materializations.
When FORCE-FINAL-RENDER is non-nil, discard only the child's natural sized
entry before final allocation, providing the original rerendering reference."
(let* ((source (car (ebox-tree-layout-children (ebox-test-root input))))
(handle (ebox-node-source-handle source))
(render-box (symbol-function 'ebox--render-box))
(render-sized (symbol-function 'ebox--flex-render-sized-entry))
allocation observations final-source scroll-state rendered)
(cl-letf (((symbol-function 'ebox--render-box)
(lambda (box)
(when (and allocation
(eq (ebox-node-source-handle box) handle))
(push
(list :allocation allocation
:width (ebox--flex-definite-property
box 'row (ebox-get box :width))
:height (ebox--flex-definite-property
box 'column (ebox-get box :height))
:constraints
(ebox--render-cache-plist-signature box)
:context
(list ebox-viewport-width ebox-viewport-height
ebox--intrinsic-layout-measurement
ebox--inline-auto-width-intrinsic-p
ebox--containing-wrap-mode
ebox--box-content-cache-purpose
ebox--render-region-id
ebox--render-root-parent-kind
ebox--scroll-window-render-disabled))
observations))
(funcall render-box box)))
((symbol-function 'ebox--flex-render-sized-entry)
(lambda (item axis main cross align)
(let* ((target (eq (ebox-node-source-handle
(plist-get item :source))
handle))
(previous-allocation allocation))
(when (and target cross force-final-render)
(setq item (copy-sequence item))
(plist-put item :render-cache nil))
(setq allocation (and target (list axis main cross align)))
(unwind-protect
(prog1 (funcall render-sized item axis main cross align)
(when (and target cross)
(let ((region-id
(ebox-get (plist-get item :source) :region-id)))
(setq final-source
(ebox-node-source-handle
(gethash region-id ebox--region-box-table))
scroll-state (ebox-scroll-state region-id)))))
(setq allocation previous-allocation))))))
(setq rendered (ebox-render input)))
(list :rendered rendered :observations (nreverse observations)
:source-handle final-source :scroll-state scroll-state)))
(ert-deftest ebox-flex-identical-cross-materializes-box-once ()
"An already assigned numeric cross size must not materialize a Box twice."
(let* ((input
(ebox-build
'(flex :width (px 120) :flex-wrap wrap
(box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
:height (lh 2) :wrap-mode word
(text "short")))))
(observed (ebox-flex-test--observe-sized-box input))
(calls (plist-get observed :observations))
(first (car calls)))
(should (= (ebox-string-height (plist-get observed :rendered)) 2))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines (plist-get observed :rendered)))
'(120 120)))
(should (= (plist-get first :width) 120))
(should (= (plist-get first :height) 2))
;; Before the fix both actual Box renders have identical complete node
;; declarations and dynamic contexts; only the caller's allocation differs.
(dolist (call (cdr calls))
(should (equal (plist-get first :constraints)
(plist-get call :constraints)))
(should (equal (plist-get first :context)
(plist-get call :context))))
(ert-info ((format "Actual Box allocations: %S"
(mapcar (lambda (call) (plist-get call :allocation))
calls)))
(should (= (length calls) 1)))))
(ert-deftest ebox-flex-identical-cross-preserves-properties-and-source ()
"Reused row/column output retains chrome, text properties, and source ids."
(dolist (axis '(row column))
(dolist (sizing '(border-box content-box))
(let* ((ebox-viewport-width nil)
(row-p (eq axis 'row))
(label (propertize "alpha beta" 'help-echo "retained leaf"
'mouse-face 'highlight))
(input
(ebox-build
`(flex ,@(if row-p '(:width (px 140)) '(:height (lh 10)))
:flex-direction ,axis :flex-wrap wrap
(box :flex-basis ,(if row-p '(px 40) '(lh 2)) :flex-grow 1
:min-width (ch 0) :wrap-mode word :overflow hidden
,@(if row-p
(list :height (list 'lh (if (eq sizing 'border-box) 6 2)))
'(:width (px 60)))
:box-sizing ,sizing :padding ((lh 1) (px 2)) :margin ((lh 1) (px 3))
:border ((px 1) solid "#123456")
:color "#123456" :bgcolor "#ddeeff"
(column :width ,(if row-p '(px 100) '(px 40))
(text ,label) (text "second"))))))
(source (car (ebox-tree-layout-children (ebox-test-root input))))
(handle (ebox-node-source-handle source))
(fast (ebox-flex-test--observe-sized-box input))
(reference (ebox-flex-test--observe-sized-box input t))
(rendered (plist-get fast :rendered)))
(ert-info ((format "axis=%S sizing=%S" axis sizing))
(should (= (length (plist-get fast :observations)) 1))
(should (= (length (plist-get reference :observations)) 2))
(should (equal-including-properties
rendered (plist-get reference :rendered)))
(should (eq (plist-get fast :source-handle) handle))
(should (eq (plist-get reference :source-handle) handle))
(let ((position (string-match "alpha" rendered)))
(should position)
(should (equal (get-text-property position 'help-echo rendered)
"retained leaf"))
(should (eq (get-text-property position 'mouse-face rendered)
'highlight))
(should (get-text-property position 'face rendered))))))))
(ert-deftest ebox-flex-identical-cross-keeps-changing-constraints ()
"Automatic, relative, clamped, and enlarged cross constraints rerender."
(dolist (case '(nil
(:height (vh 100))
(:height (lh 1) :overflow visible)
(:height (lh 2) :peer-height 4)
(:height (lh 4) :max-height (lh 2))
(:height (lh 2) :min-height (lh 3))))
(let* ((ebox-viewport-height 2)
(peer-height (plist-get case :peer-height))
(input
(ebox-build
`(flex :width (px 120) :flex-wrap wrap
(box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
:wrap-mode word
,@(ebox--plist-remove-keys case '(:peer-height))
(text "A\nB"))
,@(when peer-height
`((box :width (px 20) :height (lh ,peer-height) "peer"))))))
(observed (ebox-flex-test--observe-sized-box input)))
(ert-info ((format "Cross constraint: %S" case))
(should (= (length (plist-get observed :observations)) 2))
(should (equal-including-properties
(plist-get observed :rendered)
(plist-get (ebox-flex-test--observe-sized-box input t)
:rendered)))))))
(ert-deftest ebox-flex-identical-cross-keeps-dependent-column-viewport ()
"A numeric cross declaration alone cannot override viewport dependence."
(let* ((ebox-viewport-width nil)
(input
(ebox-build
'(flex :height (lh 4) :flex-direction column :flex-wrap wrap
(box :width (px 60) :flex-basis (lh 2) :flex-grow 1 :overflow hidden
(box :width (vw 100) (text "responsive"))))))
(observed (ebox-flex-test--observe-sized-box input))
(calls (plist-get observed :observations)))
(should (= (length calls) 2))
(should (equal (mapcar (lambda (call) (plist-get call :width)) calls)
'(60 60)))
(should (equal (mapcar (lambda (call) (car (plist-get call :context))) calls)
'(nil 60)))
(should (equal-including-properties
(plist-get observed :rendered)
(plist-get (ebox-flex-test--observe-sized-box input t)
:rendered)))))
(ert-deftest ebox-flex-identical-cross-retains-scroll-state ()
"A reused fixed-height Box retains the already rendered scroll geometry."
(let* ((input
(ebox-build
'(flex :width (px 120) :flex-wrap wrap
(box :flex-basis (px 40) :flex-grow 1 :min-width (ch 0)
:height (lh 2) :wrap-mode word :overflow scroll
(column (text "A") (text "B") (text "C") (text "D"))))))
(fast (ebox-flex-test--observe-sized-box input))
(reference (ebox-flex-test--observe-sized-box input t))
(state (plist-get fast :scroll-state))
(old-state (plist-get reference :scroll-state)))
(should (= (length (plist-get fast :observations)) 1))
(should (= (length (plist-get reference :observations)) 2))
(should (equal-including-properties
(plist-get fast :rendered) (plist-get reference :rendered)))
(should state)
(should (= (plist-get state :content-height) 2))
(should (= (ebox-get (plist-get state :box) :width) 120))
(should (= (ebox--flex-definite-property
(plist-get state :box) 'column
(ebox-get (plist-get state :box) :height)) 2))
(dolist (property '(:scroll-offset :content-height
:content-lines-complete-p))
(should (equal (plist-get state property) (plist-get old-state property))))
(should (equal-including-properties
(ebox-lines-join (plist-get state :content-lines))
(ebox-lines-join (plist-get old-state :content-lines))))))
(ert-deftest ebox-flex-natural-reuse-preserves-stretched-visible-overflow-ownership ()
"Stretch sizing should absorb visible overflow into the final box region."
(let* ((box (ebox-test-box (ebox-test-text "A\nB") :width '(80) :height 1
:overflow 'visible :flex-grow 1))
(region-id (car (ebox-region-ids (ebox-test-root box))))
(rendered (ebox-render (ebox-test-flex :width '(120) box)))
(lines (ebox-string-lines rendered)))
(should (= (length lines) 2))
(should (= (ebox-flex-test--region-owner-width
(nth 1 lines) region-id)
120))))
(ert-deftest ebox-flex-stretch-padding-retains-child-allocation-owner ()
"Cross-axis blank lines remain part of the stretched child fragment."
(let* ((child (ebox-test-box (ebox-test-text "A") :width '(80)))
(region-id (car (ebox-region-ids (ebox-test-root child))))
(layout
(ebox-test-flex :width '(120) :height 3 :align-items 'stretch
child))
(lines (ebox-string-lines (ebox-render layout))))
(should (= (length lines) 3))
(dolist (line lines)
(should
(text-property-not-all
0 (length line) 'ebox-content-owners nil line))
(should
(cl-some
(lambda (position)
(memq region-id
(get-text-property position 'ebox-content-owners line)))
(number-sequence 0 (1- (length line))))))))
(ert-deftest ebox-flex-natural-reuse-preserves-stretched-scroll-state ()
"Stretch sizing should publish the final box geometry to scroll state."
(let* ((ebox-viewport-width 120)
(ebox-viewport-height 2)
(layout
(ebox-build
'(flex :width (px 120)
(box :id "root" :width (px 80) :height (vh 100)
:overflow scroll :flex-grow 1
(column :width (px 80)
(box :height (lh 1) "A")
(box :height (lh 1) "B")
(box :height (lh 1) "C")
(box :height (lh 1) "D"))))))
(buffer (generate-new-buffer " *ebox-flex-scroll-state*")))
(unwind-protect
(progn
(ebox-render-to-buffer buffer layout)
(let* ((match (car (ebox-selector-query-buffer buffer "#root")))
(state (ebox-scroll-state (plist-get match :region-id))))
(should (equal (ebox-get (plist-get state :box) :height) 2))
(should-not (plist-get state :content-lines-complete-p))
(should-not (plist-get state :render-content-prefix))))
(when (buffer-live-p buffer)
(kill-buffer buffer)))))
(ert-deftest ebox-flex-natural-row-padding-keeps-overflow-line-local ()
"Visible overflow should widen only the line that actually overflows."
(let* ((viewport-child
(ebox-test-box (ebox-test-text "A") :width '(% 100)
:box-sizing 'content-box
:overflow 'visible
:padding-left '(10) :padding-right '(10)))
(source
(ebox-test-column viewport-child
(ebox-test-box (ebox-test-text "A2") :width '(20))))
(layout
(ebox-test-flex :width '(240)
(ebox-test-flex-item source :flex-basis '(20))
(ebox-test-box (ebox-test-text "B") :width '(20) :height 4)))
(widths
(mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-render layout)))))
(should (= (length widths) 4))
(should (= (car widths) 260))
(should (equal (cdr widths) '(240 240 240)))))
(ert-deftest ebox-flex-composite-auto-row-respects-assigned-width ()
"A composite auto-width row must not duplicate its Flex item viewport."
(let* ((layout
(ebox-test-flex :width '(120)
(ebox-test-row
(ebox-test-box (ebox-test-text "Left") :border '(1 solid))
(ebox-test-box (ebox-test-text "Right") :border '(1 solid)))))
(widths (mapcar #'ebox--string-pixel-width
(ebox-string-lines (ebox-render layout)))))
(should (cl-every (lambda (width) (<= width 120)) widths))))
(ert-deftest ebox-flex-sized-persistent-hit-populates-pass-local-cache ()
"Persistent sized renders should become reusable in the current pass."
(let* ((source-input (ebox-test-box (ebox-test-text "A") :width '(80)))
(source (ebox-test-root source-input))
(ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
(ebox--surface-materialization-active t)
(first (car (ebox--flex-collect-items (list source) 'row 220)))
(main (plist-get first :target))
(cache-key (list 'row main nil 'flex-start)))
(ebox--flex-render-sized-entry first 'row main nil 'flex-start)
(should (assoc cache-key (plist-get first :render-cache)))
(let ((second (car (ebox--flex-collect-items (list source) 'row 220))))
(should-not (assoc cache-key (plist-get second :render-cache)))
(ebox--flex-render-sized-entry second 'row main nil 'flex-start)
(should (assoc cache-key (plist-get second :render-cache))))))
(defun ebox-flex-test--face-signature (string)
"Return a printed list of every face value carried by STRING."
(let ((faces nil)
(pos 0)
(len (length string)))
(while (< pos len)
(push (get-text-property pos 'face string) faces)
(setq pos (or (next-single-property-change pos 'face string) len)))
(format "%S" (nreverse faces))))
(ert-deftest ebox-flex-measurement-miss-probes-cache-once ()
"A flex measurement miss should compute one exact cache probe."
(let* ((source-input
(ebox-test-box (ebox-test-text "Measured once") :width '(80)))
(source (ebox-test-root source-input))
(props '(:flex-basis auto))
(ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
(ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq))
(ebox--surface-materialization-active t)
(signature-calls 0)
(lookup-calls 0)
(original-signature
(symbol-function 'ebox--flex-render-cache-signature))
(original-lookup (symbol-function 'ebox--render-cache-lookup)))
(cl-letf (((symbol-function 'ebox--flex-render-cache-signature)
(lambda (node)
(cl-incf signature-calls)
(funcall original-signature node)))
((symbol-function 'ebox--render-cache-lookup)
(lambda (cache-key signature &rest arguments)
(when (and (consp cache-key)
(eq (nth 1 cache-key) 'flex-measure))
(cl-incf lookup-calls))
(apply original-lookup cache-key signature arguments))))
(ebox--flex-item-measurement source props 'row 120))
(should (= signature-calls 1))
(should (= lookup-calls 1))))
(ert-deftest ebox-flex-signature-prepares-identities-once-before-memoization ()
"Cold and warm signatures stabilize nested identities through one owner."
(let (observed)
(dolist (region-memo-p '(nil t))
(let* ((root
(ebox-test-root
(ebox-test-flex :width '(120)
(ebox-test-box :width '(40) (ebox-test-text "left"))
(ebox-test-column
(ebox-test-text "right")
(ebox-test-box (ebox-test-text "nested"))))))
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
(ebox--node-region-ids-cache
(and region-memo-p (make-hash-table :test 'eq)))
(runtime-ids (symbol-function 'ebox--runtime-node-ids))
(region-ids (symbol-function 'ebox--node-all-region-ids))
(body-signature (symbol-function 'ebox--render-cache-node-body-signature))
(runtime-calls 0) (region-calls 0) (body-calls 0)
nodes identity-snapshots signatures memoized-body)
(cl-labels ((walk (node)
(push node nodes)
(mapc #'walk (ebox-tree-node-children node))))
(walk root))
(dolist (node nodes)
(should-not (plist-get node :node-id))
(should-not (plist-get node :region-id)))
(cl-letf
(((symbol-function 'ebox--runtime-node-ids)
(lambda (node)
;; Recursive child entries are real work, not extra root probes.
(when (eq node root) (cl-incf runtime-calls))
(funcall runtime-ids node)))
((symbol-function 'ebox--node-all-region-ids)
(lambda (node)
(when (eq node root) (cl-incf region-calls))
(funcall region-ids node)))
((symbol-function 'ebox--render-cache-node-body-signature)
(lambda (node)
(when (eq node root)
(cl-incf body-calls)
;; Inspect before the real memoizing helper can read or write
;; its cache; the first cached body must include every id.
(push
(mapcar
(lambda (child)
(should (integerp (plist-get child :node-id)))
(when (eq (plist-get child :ebox-type) 'box)
(should (integerp (plist-get child :region-id))))
(list (plist-get child :node-id)
(plist-get child :region-id)))
nodes)
identity-snapshots))
(funcall body-signature node))))
(dolist (phase '(cold warm))
(setq runtime-calls 0 region-calls 0 body-calls 0)
(when (eq phase 'warm)
(should (eq memoized-body
(gethash root ebox--render-cache-signature-cache))))
(push (ebox--flex-render-cache-signature root) signatures)
(push (list region-memo-p phase runtime-calls region-calls body-calls)
observed)
(if (eq phase 'cold)
(setq memoized-body
(gethash root ebox--render-cache-signature-cache))
(should (eq memoized-body
(gethash root ebox--render-cache-signature-cache))))))
(should memoized-body)
(should (equal (car signatures) (cadr signatures)))
(should (equal (car signatures) (ebox--render-cache-node-signature root)))
(should (equal (car identity-snapshots) (cadr identity-snapshots)))
(when region-memo-p
(should (gethash root ebox--node-region-ids-cache)))))
(setq observed (nreverse observed))
(message "Flex signature root entries (region-memo phase runtime region body): %S"
observed)
(should (equal observed '((nil cold 1 1 1) (nil warm 1 1 1)
(t cold 1 1 1) (t warm 1 1 1))))))
(ert-deftest ebox-flex-reuses-direct-column-natural-measurement ()
"A direct Column's nil-viewport natural size should survive parent resize."
(let* ((source-input
(ebox-test-column
(ebox-test-box (ebox-test-text "A") :width '(80))
(ebox-test-box (ebox-test-text "B") :width '(80))))
(source (ebox-test-root source-input))
(props '(:flex-basis auto))
(ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--render-cache-signature-cache (make-hash-table :test 'eq))
(ebox--viewport-dependent-subtree-cache (make-hash-table :test 'eq))
(ebox--surface-materialization-active t)
(renders 0)
(original
(symbol-function 'ebox--flex-render-source-for-measurement)))
(cl-letf (((symbol-function 'ebox--flex-render-source-for-measurement)
(lambda (&rest arguments)
(cl-incf renders)
(apply original arguments))))
(ebox--flex-item-measurement source props 'row 320)
(ebox--flex-item-measurement source props 'row 640))
(should (= renders 1))))
(ert-deftest ebox-flex-fixed-basis-wrapped-box-skips-intrinsic-render ()
"Known flex metrics should not render a wrapped fixed-basis box twice."
(let* ((source
(ebox-test-box
(ebox-test-text "A responsive wrapped child")
:width '(viewport) :min-width 0 :height 4 :wrap-mode 'word))
(item
(ebox-test-flex-item
source :flex-grow 1 :flex-shrink 1 :flex-basis '(80)))
(layout (ebox-test-flex :width '(240) :height 4 item))
(ebox-viewport-width 240)
(measurement-renders 0)
fast slow)
(let ((original
(symbol-function 'ebox--flex-render-source-for-measurement)))
(cl-letf (((symbol-function 'ebox--flex-render-source-for-measurement)
(lambda (&rest arguments)
(cl-incf measurement-renders)
(apply original arguments))))
(setq fast (ebox-render layout))))
(should (zerop measurement-renders))
(cl-letf (((symbol-function 'ebox--flex-unrendered-measurement)
(lambda (&rest _) nil)))
(setq slow (ebox-render layout)))
(should (equal-including-properties fast slow))))
(ert-deftest ebox-flex-fixed-basis-nowrap-box-keeps-intrinsic-measurement ()
"No-wrap auto minimums should retain their intrinsic render measurement."
(let* ((source-input
(ebox-test-box (ebox-test-text "UNBREAKABLE") :min-width 0 :height 1
:wrap-mode 'none))
(source (ebox-test-root source-input))
(item-input (ebox-test-flex-item source-input :flex-basis '(40)))
(props
(ebox--flex-item-props (ebox-test-root item-input))))
(should-not (ebox--flex-unrendered-measurement source props 'row))))
(ert-deftest ebox-flex-explicit-zero-min-width-allows-no-wrap-shrink ()
"An explicit zero minimum lets a no-wrap card shrink in its Flex line."
(let* ((source-input
(ebox-test-box (ebox-test-text "A long no-wrap card title")
:min-width 0 :wrap-mode 'none))
(source (ebox-test-root source-input))
(rendered (ebox-render source-input)))
(should (zerop (ebox--flex-box-min-main source rendered 'row)))))
(ert-deftest ebox-flex-bounded-auto-basis-keeps-intrinsic-measurement ()
"Clipping must not replace an auto-basis item's intrinsic contribution."
(let* ((source-input
(ebox-test-box (ebox-test-text "UNBREAKABLE") :width '(120) :height 2
:min-width 0 :wrap-mode 'none :overflow 'hidden))
(source (ebox-test-root source-input))
(props (ebox--flex-item-props source)))
(should (eq (plist-get props :flex-basis) 'auto))
(should-not (ebox--flex-unrendered-measurement source props 'row))))
(ert-deftest ebox-flex-column-natural-cache-respects-final-cross-viewport ()
"Column flex must render composite items in the final cross viewport."
(let* ((viewport-child
(ebox-test-box (ebox-test-text "VP") :width '(% 100)))
(source-input
(ebox-test-column
viewport-child
(ebox-test-box (ebox-test-text "F") :width '(100))))
(source (ebox-test-root source-input))
(region-id
(car (ebox-region-ids (ebox-test-root viewport-child))))
(layout
(ebox-test-flex :height 4
:flex-direction 'column
:align-items 'stretch
(ebox-test-flex-item source-input :flex-grow 1 :flex-basis '(lh 2))))
(rendered (ebox-render layout)))
(should (ebox-box-node-p source))
(should (eq (ebox-layout-config-kind (ebox-box-node-layout source))
'column))
(should (= (ebox-flex-test--region-owner-width rendered region-id)
100))))
(ert-deftest ebox-flex-nowrap-explicit-cross-skips-natural-cross-measure ()
"Nowrap containers with fixed cross size should not measure natural cross."
(let* ((layout
(ebox-test-flex :width '(220) :height 4 :flex-wrap 'nowrap
(ebox-test-box (ebox-test-text "A") :width '(80))
(ebox-test-box (ebox-test-text "B") :width '(80))))
(calls 0)
(original (symbol-function 'ebox--flex-line-cross)))
(cl-letf (((symbol-function 'ebox--flex-line-cross)
(lambda (&rest args)
(cl-incf calls)
(apply original args))))
(ebox-render layout))
(should (= calls 0))))
(ert-deftest ebox-flex-aligns-items-on-row-cross-axis ()
"Align-items center should move short row items within the line height."
(let* ((layout
(ebox-test-flex :width '(180) :height 3 :align-items 'center
(ebox-test-box (ebox-test-text "A") :width '(80) :height 1)
(ebox-test-box (ebox-test-text "B\nB\nB") :width '(80) :height 3)))
(lines (ebox-flex-test--plain-lines layout)))
(should (= (length lines) 3))
(should (= (cl-position-if (lambda (line)
(string-match-p "A" line))
lines)
1))))
(ert-deftest ebox-flex-honors-order-and-reverse-directions ()
"Order and reverse directions should change visual item order."
(let* ((ordered
(ebox-test-flex
(ebox-test-flex-item (ebox-test-box (ebox-test-text "A") :width '(40)) :order 2)
(ebox-test-flex-item (ebox-test-box (ebox-test-text "B") :width '(40)) :order 1)))
(column-reverse
(ebox-test-flex :flex-direction 'column-reverse
(ebox-test-box (ebox-test-text "A") :width '(40))
(ebox-test-box (ebox-test-text "B") :width '(40)))))
(should (string-match-p "B.*A"
(car (ebox-flex-test--plain-lines ordered))))
(should (string-match-p "B"
(car (ebox-flex-test--plain-lines column-reverse))))))
(ert-deftest ebox-flex-align-self-overrides-align-items ()
"Align-self should override the container cross-axis alignment."
(let* ((layout
(ebox-test-flex :width '(180) :height 3 :align-items 'flex-start
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(80) :height 1)
:align-self 'flex-end)
(ebox-test-box (ebox-test-text "B\nB\nB") :width '(80) :height 3)))
(lines (ebox-flex-test--plain-lines layout)))
(should (= (length lines) 3))
(should (= (cl-position-if (lambda (line)
(string-match-p "A" line))
lines)
2))))
(ert-deftest ebox-flex-wrap-single-line-align-content-keeps-natural-cross-size ()
"A wrapped one-line container should still use multi-line align-content rules."
(let* ((layout
(ebox-test-flex :width '(180) :height 4
:flex-wrap 'wrap
:align-content 'flex-start
:align-items 'center
(ebox-test-box (ebox-test-text "A") :width '(60) :height 1)))
(lines (ebox-flex-test--plain-lines layout)))
(should (= (length lines) 4))
(should (= (cl-position-if (lambda (line)
(string-match-p "A" line))
lines)
0))))
(ert-deftest ebox-flex-applies-container-box-properties ()
"Flex container visual box properties should render around the flex layout."
(let* ((layout
(ebox-test-flex :width '(160) :box-sizing 'border-box
:padding '(1 (8))
:border '(1 "#ff0000")
:bgcolor "#101010"
(ebox-test-box (ebox-test-text "A") :width '(60))))
(rendered (ebox-flex-test--render layout))
(left-face (ebox-flex-test--face-at-property rendered 'ebox-bl))
(right-face (ebox-flex-test--face-at-property rendered 'ebox-br))
(top-face (ebox-flex-test--face-at-property rendered 'ebox-bt))
(bottom-face (ebox-flex-test--face-at-property rendered 'ebox-bb))
(content-face (ebox-flex-test--face-at-property rendered 'ebox-content)))
(should (equal (ebox-flex-test--line-widths layout) '(160 160 160)))
(should (ebox-flex-test--face-has-key-value-p
left-face :background "#ff0000"))
(should (ebox-flex-test--face-has-key-value-p
right-face :background "#ff0000"))
(should (ebox-flex-test--face-has-key-value-p top-face :overline "#ff0000"))
(should (ebox-flex-test--face-has-key-value-p
bottom-face
:underline
'(:position t :color "#ff0000")))
(should (ebox-flex-test--face-has-key-value-p
content-face :background "#101010"))))
(ert-deftest ebox-flex-container-box-preserves-blank-child-bottom-border ()
"A visual flex container should preserve child bottom borders on blank lines."
(let* ((child (ebox-test-box (ebox-test-text "A")
:width '(80)
:height 2
:padding '(0 (6))
:border '(1 "#00aa00")
:flex-basis '(80)))
(child-id (car (ebox-region-ids (ebox-test-root child))))
(layout (ebox-test-flex :width '(140)
:padding '(1 (4))
:border '(1 "#444444")
child))
(rendered (ebox-flex-test--render layout)))
(should (memq child-id
(ebox-flex-test--property-values rendered 'ebox-bb)))))
(ert-deftest ebox-flex-container-box-wrapper-preserves-rendered-layout ()
"A flex container box wrapper should not rewrap the rendered flex layout."
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(when (eq feature 'ekp) t)))
((symbol-function 'ekp-pixel-justify)
(lambda (text _width)
(if (string-match-p "\n" text)
"REWRAPPED"
text))))
(let* ((layout
(ebox-test-flex :width '(260) :height 3 :border "gray"
:flex-flow '(row wrap)
:justify-content 'space-between
(ebox-test-box (ebox-test-text "sample box two")
:height 3
:box-sizing 'border-box
:padding '(0 (8))
:border '(1 solid)
:wrap-mode 'none)
(ebox-test-box (ebox-test-text "sample box three")
:height 3
:box-sizing 'border-box
:padding '(0 (8))
:border '(1 solid)
:wrap-mode 'none)))
(plain (substring-no-properties (ebox-flex-test--render layout))))
(should-not (string-match-p "REWRAPPED" plain))
(should (string-match-p "sample box two" plain))
(should (string-match-p "sample box three" plain)))))
(ert-deftest ebox-flex-item-applies-box-properties-with-flex-metadata ()
"An item wrapper should keep flex metadata and apply visual box properties."
(let* ((layout
(ebox-test-flex :width '(180)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "A") :width '(60))
:flex 1
:box-sizing 'border-box
:padding '(0 (6))
:border '(1 "#00aa00"))))
(rendered (ebox-flex-test--render layout))
(left-face (ebox-flex-test--face-at-property rendered 'ebox-bl))
(right-face (ebox-flex-test--face-at-property rendered 'ebox-br))
(top-face (ebox-flex-test--face-at-property rendered 'ebox-bt))
(bottom-face (ebox-flex-test--face-at-property rendered 'ebox-bb)))
(should (equal (ebox-flex-test--line-widths layout) '(180)))
(should (string-match-p "A" (substring-no-properties rendered)))
(should (ebox-flex-test--face-has-key-value-p
left-face :background "#00aa00"))
(should (ebox-flex-test--face-has-key-value-p
right-face :background "#00aa00"))
(should (ebox-flex-test--face-has-key-value-p top-face :overline "#00aa00"))
(should (ebox-flex-test--face-has-key-value-p
bottom-face
:underline
'(:position t :color "#00aa00")))))
(ert-deftest ebox-flex-item-wrapper-preserves-preformatted-layout ()
"An item wrapper with box properties should not rewrap rendered children."
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(when (eq feature 'ekp) t)))
((symbol-function 'ekp-pixel-justify)
(lambda (text _width)
(if (string-match-p "\n" text)
"REWRAPPED"
text))))
(let* ((layout
(ebox-test-flex :width '(220)
(ebox-test-flex-item
(ebox-test-column
(ebox-test-box (ebox-test-text "Top") :width '(80))
(ebox-test-box (ebox-test-text "Bottom") :width '(80)))
:width '(180)
:box-sizing 'border-box
:border '(1 solid))))
(plain (substring-no-properties (ebox-flex-test--render layout))))
(should-not (string-match-p "REWRAPPED" plain))
(should (string-match-p "Top" plain))
(should (string-match-p "Bottom" plain)))))
(ert-deftest ebox-flex-neutral-size-properties-keep-child-identity ()
"Width, height, and box-sizing alone should not wrap a flex container."
(dolist (props '((:width (120))
(:height 2)
(:box-sizing border-box)))
(let* ((left (ebox-test-box (ebox-test-text "A") :width '(40)))
(right (ebox-test-box (ebox-test-text "B") :width '(40)))
(layout
(apply #'ebox-test-flex
(append props (list left right)))))
(should (equal (ebox-box-node-children (ebox-test-root layout))
(mapcar #'ebox-test-root (list left right))))
(should (string-match-p "A.*B"
(substring-no-properties
(ebox-flex-test--render layout)))))))
(ert-deftest ebox-flex-box-sizing-alone-keeps-child-region-ids ()
"Box-sizing by itself should not wrap a flex container."
(let* ((left (ebox-test-box (ebox-test-text "A") :width '(40)))
(right (ebox-test-box (ebox-test-text "B") :width '(40)))
(layout
(ebox-test-flex :width '(120) :box-sizing 'border-box left right)))
(should (equal (ebox-box-node-children (ebox-test-root layout))
(mapcar #'ebox-test-root (list left right))))
(should (equal (ebox-flex-test--line-widths layout) '(120)))))
(ert-deftest ebox-flex-direct-child-combines-frame-and-participation ()
"One child Box should own both frame and Flex participation facts."
(let* ((left (ebox-test-box (ebox-test-text "A") :width '(60)
:flex-grow 1 :flex-basis '(60)
:box-sizing 'border-box :border '(1 solid)))
(right (ebox-test-box (ebox-test-text "B") :width '(40)))
(layout (ebox-test-flex :width '(160) left right))
(left-id (car (ebox-region-ids (ebox-test-root left))))
(right-id (car (ebox-region-ids (ebox-test-root right))))
(rendered (ebox-flex-test--render layout))
(left-width
(ebox-flex-test--region-owner-width rendered left-id))
(right-width
(ebox-flex-test--region-owner-width rendered right-id)))
(should (equal (ebox-box-node-children (ebox-test-root layout))
(mapcar #'ebox-test-root (list left right))))
(should (> left-width 60))
(should (> left-width right-width))))
(ert-deftest ebox-flex-wraps-before-shrinking-no-wrap-item-below-minimum ()
"A no-wrap flex item should keep its rendered minimum width when wrapping."
(let* ((layout
(ebox-test-flex :width '(160)
:flex-flow '(row wrap)
:column-gap '(10)
(ebox-test-flex-item
(ebox-test-box (ebox-test-text "wide no-wrap label")
:width '(150)
:box-sizing 'border-box
:overflow 'visible :min-width 'auto
:wrap-mode 'none)
:flex 1)
(ebox-test-box (ebox-test-text "B") :width '(120))))
(lines (ebox-flex-test--plain-lines layout)))
(should (= (length lines) 2))
(should (cl-every (lambda (width) (<= width 160))
(ebox-flex-test--line-widths layout)))
(should (string-match-p "wide no-wrap label" (car lines)))
(should (string-match-p "B" (cadr lines)))))
(ert-deftest ebox-flex-supports-standard-value-matrix ()
"Every supported flex value should render without falling out of the model."
(dolist (direction '(row row-reverse column column-reverse))
(should (stringp
(ebox-render
(ebox-test-flex :width '(220) :height 4 :flex-direction direction
(ebox-test-box (ebox-test-text "A") :width '(60) :height 1)
(ebox-test-box (ebox-test-text "B") :width '(60) :height 1))))))
(dolist (wrap '(nowrap wrap wrap-reverse))
(should (stringp
(ebox-render
(ebox-test-flex :width '(130) :height 4 :flex-wrap wrap
(ebox-test-box (ebox-test-text "A") :width '(60) :height 1)
(ebox-test-box (ebox-test-text "B") :width '(60) :height 1)
(ebox-test-box (ebox-test-text "C") :width '(60) :height 1))))))
(dolist (justify '(normal start end flex-start flex-end center stretch
space-between space-around space-evenly))
(should (stringp
(ebox-render
(ebox-test-flex :width '(220) :justify-content justify
(ebox-test-box (ebox-test-text "A") :width '(60))
(ebox-test-box (ebox-test-text "B") :width '(60)))))))
(dolist (align '(normal start end stretch flex-start flex-end center baseline))
(should (stringp
(ebox-render
(ebox-test-flex :width '(220) :height 4
:align-items align
(ebox-test-box (ebox-test-text "A") :width '(60) :height 1)
(ebox-test-box (ebox-test-text "B") :width '(60) :height 1))))))
(dolist (align '(normal start end stretch flex-start flex-end center
space-between space-around space-evenly))
(should (stringp
(ebox-render
(ebox-test-flex :width '(130) :height 5 :flex-wrap 'wrap
:align-content align
(ebox-test-box (ebox-test-text "A") :width '(60) :height 1)
(ebox-test-box (ebox-test-text "B") :width '(60) :height 1)
(ebox-test-box (ebox-test-text "C") :width '(60) :height 1)))))))
(ert-deftest ebox-flex-allocation-content-keeps-warm-child-scroll-clears ()
"Distinct allocation copies retain a warm child's A-to-B-to-A clears."
(let* ((child (ebox-test-root
(ebox-test-box
(ebox-test-text (propertize "first\nsecond" 'help-echo "kept"))
:width '(80) :height 3 :overflow 'scroll)))
(source (ebox-test-root
(ebox-test-box (ebox-test-text "parent") :width 'stretch)))
(ebox--surface-materialization-active t)
(ebox--render-cache-table (make-hash-table :test 'equal))
(ebox--box-content-render-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--scroll-idle-prefetch-timers (make-hash-table :test 'equal))
(ebox--render-cache-scroll-state-region-ids (make-hash-table :test 'equal))
;; Normal bounded eviction removes the old child entry after B.
(ebox-render-cache-max-entries 1)
(warm (ebox--render-with-cache child))
(region-id (plist-get child :region-id))
(calls 0) first-admitted actual final-state)
(should (plist-member (ebox--render-cache-probe child) :rendered))
(should-not (gethash region-id ebox--scroll-global-state))
(cl-labels ((render-parent (width renderer)
(ebox--render-box-content-cached
(ebox--flex-copy-node-for-size source 'row width nil t)
renderer))
(render-a ()
(cl-incf calls)
(ebox--render-with-cache child)))
(should (equal-including-properties
warm (render-parent 120 #'render-a)))
(setq first-admitted (and (gethash source ebox--box-content-render-cache) t))
(render-parent
150
(lambda ()
(ebox--render-with-cache
(ebox--flex-copy-node-for-size child 'row 80 1 t))))
(should (gethash region-id ebox--scroll-global-state))
(should-not (plist-member (ebox--render-cache-probe child) :rendered))
(setq actual (render-parent 120 #'render-a)
final-state (gethash region-id ebox--scroll-global-state))
(let ((ebox--box-content-render-cache nil))
(should (equal-including-properties actual (ebox--render-with-cache child)))))
(should (equal-including-properties warm actual))
(should-not final-state)
(should-not first-admitted)
(should (= calls 2))))
(provide 'ebox-flex-tests)
;;; ebox-flex-tests.el ends here