ebox/tests/ebox-dsl-tests.el

1340 lines
61 KiB
EmacsLisp

;;; ebox-dsl-tests.el --- Ebox DSL tests -*- lexical-binding: t; -*-
(require 'ert)
(load-file (expand-file-name "../ebox.el" (file-name-directory (or load-file-name buffer-file-name))))
(load-file (expand-file-name "ebox-fixtures.el"
(file-name-directory (or load-file-name
buffer-file-name))))
(defun ebox-dsl-test--constructor-input (tag plist)
"Return `(BUILDER . PLIST)' for typed constructor TAG and PLIST."
(let* ((style-keys
(cl-loop for (key _value) on plist by #'cddr
when (and (not (memq key
'(:value :layout :children :outer
:source-handle :owned-facts)))
(ebox-style--property key))
collect key))
(style-props
(cl-loop for (key value) on plist by #'cddr
when (memq key style-keys) append (list key value)))
(declarations
(and style-props (ebox-style-compile-form tag style-props)))
(plist
(cl-loop for (key value) on plist by #'cddr
unless (memq key style-keys) append (list key value)))
(values
(cl-loop for (key value) on plist by #'cddr
when (eq key :source-handle)
collect value))
(builder (ebox-source-builder-create)))
(when (plist-member plist :children)
(setq plist
(plist-put
(copy-sequence plist) :children
(mapcar (lambda (child)
(ebox-test--normalize-child builder child))
(plist-get plist :children)))))
(when (<= (length values) 1)
(let* ((provided (car values))
(identity
(if (ebox-source-handle-p provided)
(ebox-source-handle-id provided)
provided))
(handle
(ebox-source-builder-bind
builder :identity identity :declarations declarations
:provenance '(:adapter ebox-dsl-test))))
(setq plist (plist-put (copy-sequence plist) :source-handle handle))))
(setq plist
(append plist
(list :owned-facts
(ebox-canonical-facts-from-declarations
tag declarations))))
(cons builder plist)))
(defun ebox-dsl-test--text-create (&rest plist)
"Create a canonical Text input with an explicit source boundary."
(let* ((input (ebox-dsl-test--constructor-input 'text plist))
(builder (car input))
(node (apply #'ebox-text-create (cdr input)))
(index (ebox-source-builder-finish builder)))
(ebox-canonical-input-create (list node) index)))
(defun ebox-dsl-test--box-create (&rest plist)
"Create a canonical Box input with an explicit source boundary."
(let* ((layout (plist-get plist :layout))
(tag (if (and (ebox-layout-config-p layout)
(not (eq (ebox-layout-config-kind layout) 'normal)))
(ebox-layout-config-kind layout)
'box))
(input (ebox-dsl-test--constructor-input tag plist))
(builder (car input))
(node
(let ((ebox-canonical--source-builder builder))
(apply #'ebox-box-create (cdr input))))
(index (ebox-source-builder-finish builder)))
(ebox-canonical-input-create (list node) index)))
(defun ebox-dsl-test--revise-source (input &rest facts)
"Return INPUT with its root source immutably rebound by FACTS."
(let* ((node (ebox-test-root input))
(handle (ebox-node-source-handle node))
(index (ebox-test-source-index input))
(binding (apply #'ebox-source-index-rebind index handle facts)))
(plist-put node :ebox-source-handle (cdr binding))
(ebox-canonical-input-create (list node) (car binding))))
(defun ebox-dsl-test--plain (input)
"Render canonical INPUT and strip text properties."
(substring-no-properties (ebox-render input)))
(defun ebox-dsl-test--root (input)
"Return INPUT's canonical root node."
(ebox-canonical-input--single-root input "Ebox DSL test"))
(defun ebox-dsl-test--line-widths (input)
"Return rendered pixel widths for canonical INPUT line by line."
(mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render input))))
(defun ebox-dsl-test--display-line-widths (input)
"Return rendered display pixel widths for canonical INPUT line by line."
(mapcar #'ebox--string-pixel-width (ebox-string-lines (ebox-render input))))
(defun ebox-dsl-test--tree-count (node predicate)
"Count nodes below NODE for which PREDICATE returns non-nil."
(when (ebox-canonical-input-p node)
(setq node (ebox-dsl-test--root node)))
(if (or (stringp node) (not (listp node)))
0
(+ (if (funcall predicate node) 1 0)
(cl-loop for child in (ebox-tree-node-children node)
sum (ebox-dsl-test--tree-count child predicate)))))
(ert-deftest ebox-style-expands-ebox-aliases-to-canonical-longhands ()
"Approved aliases and shorthands should produce canonical longhands."
(let ((style (ebox-style-compute
(list :bgcolor "#ffffff"
:padding '(1 2)
:margin-left 3
:border "#333333"))))
(should (equal (plist-get style :background-color) "#ffffff"))
(should (= (plist-get style :padding-block-start) 1))
(should (= (plist-get style :padding-inline-end) 2))
(should (= (plist-get style :padding-block-end) 1))
(should (= (plist-get style :padding-inline-start) 2))
(should (= (plist-get style :margin-left) 3))
(should (equal (plist-get style :border-color) "#333333"))))
(ert-deftest ebox-style-classifies-incremental-effects ()
"Style metadata should classify paint and layout effects."
(should (eq (ebox-style-dirty-kind :background-color) 'paint))
(should (eq (ebox-style-dirty-kind :border-color) 'paint))
(should (eq (ebox-style-dirty-kind :padding-inline-start) 'geometry))
(should (eq (ebox-style-dirty-kind :width) 'geometry))
(should (eq (ebox-style-dirty-kind :outer) 'structure))
(should-not (ebox-style-dirty-kind :layout))
(should-not (ebox-style-dirty-kind :display)))
(ert-deftest ebox-typed-form-projects-outer-and-layout-to-display ()
"A typed Box form should project its two axes to one display pair."
(let ((node (ebox-build '(flex :outer inline "A"))))
(should (equal (ebox--computed-display (ebox-dsl-test--root node))
'(inline flex)))))
(ert-deftest ebox-inline-outer-participates-in-the-cascade ()
"An inline outer declaration should outrank a matching stylesheet rule."
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule ".card" '(:outer block))
(let ((node (ebox-dsl-test--root
(ebox-build '(box :class "card" :outer inline "A")))))
(should (equal (ebox--computed-display node) '(inline flow))))))
(ert-deftest ebox-style-unrelated-facts-preserve-typed-layout-config ()
"Unrelated computed facts must not replace direct typed layout values."
(dolist (case '((flex
(flex :flex-direction column :column-gap (6) "A"))
(grid
(grid :grid-template-columns ((40) (60))
:column-gap (4) "A"))))
(let* ((kind (car case))
(input (ebox-build (cadr case)))
(node (ebox-dsl-test--root input))
(before (copy-tree (plist-get node :ebox-layout-config)))
(style
(ebox-style-compute-subject
(ecss-subject-create :type (symbol-name kind))
(ebox-style-compile-declarations '(:color "#123456")))))
(ebox-style-apply-computed node style)
(should (equal (plist-get node :ebox-layout-config) before)))))
(ert-deftest ebox-style-strict-cache-does-not-hide-unknown-properties ()
"Strict declaration validation should remain exact after an empty cache hit."
(ebox-style-compile-declarations nil t)
(should-error
(ebox-style-compile-declarations '(:not-an-ebox-property 1) t)
:type 'error))
(ert-deftest ebox-style-aliases-share-one-canonical-property-id ()
"A concise alias should disappear before canonical declarations."
(should
(equal (ebox-style-compile-declarations '(:bgcolor "red") t)
(ebox-style-compile-declarations '(:background-color "red") t))))
(ert-deftest ebox-canonical-property-vocabulary-is-the-only-author-path ()
"The frozen G4b vocabulary should have one canonical spelling and owner."
(dolist (entry '((:font-size . ebox/font-size)
(:font-style . ebox/font-style)
(:text-decoration-line . ebox/text-decoration-line)
(:text-decoration-color . ebox/text-decoration-color)
(:text-decoration-style . ebox/text-decoration-style)
(:item-gap . ebox/item-gap)
(:cross-align . ebox/cross-align)
(:justify-self . ebox/justify-self)))
(should (eq (ebox-style-schema-id (car entry)) (cdr entry))))
(should (eq (ebox-style-schema-id :bgcolor)
(ebox-style-schema-id :background-color)))
(should (eq (ebox-style-schema-id :font-slant)
(ebox-style-schema-id :font-style)))
(dolist (entry '((:padding-top . ebox/padding-top)
(:padding-right . ebox/padding-right)
(:padding-bottom . ebox/padding-bottom)
(:padding-left . ebox/padding-left)
(:margin-top . ebox/margin-top)
(:margin-right . ebox/margin-right)
(:margin-bottom . ebox/margin-bottom)
(:margin-left . ebox/margin-left)))
(should (eq (ebox-style-schema-id (car entry)) (cdr entry))))
(should-not (eq (ebox-style-schema-id :padding-top)
(ebox-style-schema-id :padding-block-start)))
(should-not (eq (ebox-style-schema-id :margin-left)
(ebox-style-schema-id :margin-inline-start)))
(dolist (name '(:font :font-height :border-bottom-p
:grid-row-gap :grid-column-gap :vertical-align
:display :layout :content :surface-properties :face
:padding-top-height :padding-right-pixel
:padding-bottom-height :padding-left-pixel
:margin-top-height :margin-right-pixel
:margin-bottom-height :margin-left-pixel
:border-right-pixel :border-left-pixel))
(should-not (ebox-style-schema-id name)))
(dolist (name '(:border-top-p :grid-column-span :grid-row-span))
(should (plist-get (ebox-style-property name) :shorthand))))
(ert-deftest ebox-normal-text-align-is-not-a-layout-wide-property ()
"Only the Normal Box form owns text alignment author input."
(should (ebox-build '(box :text-align center "A")))
(dolist (tag '(row column flex grid))
(should-error
(ebox-build (list tag :text-align 'center "A"))
:type 'error)))
(ert-deftest ebox-edge-projection-uses-one-ecss-winner-order ()
"Logical and physical edges conflict locally and cascade across sources."
(should-error
(ebox-style-compile-declarations
'(:padding-left (4) :padding-inline-start (8)) t)
:type 'ecss-invalid-declaration)
(let* ((base (ebox-style-compile-declarations '(:padding (0 (8))) t))
(override
(ebox-style-compile-declarations '(:padding-left (24)) t))
(style
(ebox-style-compute-subject
(ecss-subject-create :type "box")
(ebox-style-merge-declarations base override))))
(should (equal (plist-get (ebox-style--box-values style) :padding-left)
'(24))))
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule ".card" '(:margin-inline-start (2)))
(ebox-style-add-rule "#target" '(:margin-left (6)))
(let ((style
(ebox-style-compute-subject
(ecss-subject-create
:type "box" :id "target" :classes '("card"))
nil)))
(should (equal (plist-get (ebox-style--box-values style) :margin-left)
'(6))))))
(ert-deftest ebox-style-rejects-canonical-and-alias-duplicates ()
"One declaration source should not spell one property twice."
(should-error
(ebox-style-compile-declarations
'(:bgcolor "red" :background-color "blue") t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:background-color "red" :bgcolor "blue") t)
:type 'error))
(ert-deftest ebox-author-declaration-is-one-atomic-normalization-group ()
"One author group rejects overlaps while later cascade groups may override."
(dolist (declarations
'((:padding 1 :padding-left 2)
(:border "red" :border-top none)
(:gap 1 :row-gap 2)
(:flex-flow (row wrap) :flex-wrap nowrap)
(:flex (1 1 auto) :flex-grow 2)
(:border-top-p t :border-top-style none)))
(should-error
(ebox-style-compile-declarations declarations t)
:type 'ecss-invalid-declaration))
(should
(equal
(ebox-style-merge-declarations
(ebox-style-compile-declarations '(:gap 1) t)
(ebox-style-compile-declarations '(:row-gap 2) t))
'(ebox/row-gap 2 ebox/column-gap 1))))
(ert-deftest ebox-style-property-registry-rejects-alias-collisions ()
"An alias should never shadow another canonical property."
(should-error
(ecss-schema-package-create
'ebox
(mapcar
#'ebox-style--schema-definition
'((:name :first :id ebox/first :aliases (:second)
:group paint :dirty-kind paint :signature paint)
(:name :second :id ebox/second
:group paint :dirty-kind paint :signature paint))))
:type 'ecss-invalid-property-schema)
(should (eq 'ebox/background-color
(ecss-schema-set-canonical-id ebox-style-schemas :bgcolor)))
(should (eq :background-color
(plist-get
(ecss-schema-set-property-metadata
ebox-style-schemas :bgcolor)
:name)))
(let ((metadata (ebox-style-property :bgcolor)))
(setf (plist-get metadata :name) :damaged)
(setcar (plist-get metadata :contexts) 'damaged)
(should (eq :background-color
(plist-get (ebox-style-property :bgcolor) :name)))
(should (equal '(text box)
(plist-get (ebox-style-property :bgcolor) :contexts)))))
(ert-deftest ebox-style-border-present-is-a-typed-shorthand-not-an-alias ()
"The concise border boolean should expand to canonical width and style."
(should
(equal (ebox-style-compile-declarations '(:border-top-p t) t)
'(ebox/border-top-width 1 ebox/border-top-style solid)))
(should
(equal (ebox-style-compile-declarations '(:border-top-p nil) t)
'(ebox/border-top-width 0 ebox/border-top-style none)))
(should (eq (ebox-style-canonical-name :border-top-p) :border-top-p)))
(ert-deftest ebox-border-width-unit-is-always-integer-pixels ()
"Border width should not reuse Box edge column/pixel wrapper grammar."
(should
(equal
(ebox-style-compile-declarations '(:border (1 solid "#687386")) t)
'(ebox/border-top-width 1 ebox/border-top-style solid
ebox/border-top-color "#687386"
ebox/border-right-width 1 ebox/border-right-style solid
ebox/border-right-color "#687386"
ebox/border-bottom-width 1 ebox/border-bottom-style solid
ebox/border-bottom-color "#687386"
ebox/border-left-width 1 ebox/border-left-style solid
ebox/border-left-color "#687386")))
(should-error
(ebox-style-compile-declarations
'(:border ((1) solid "#687386")) t)
:type 'error))
(ert-deftest ebox-border-engine-projection-keeps-exact-pixels ()
"Border projection must not reinterpret pixels as character columns."
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 7)))
(should
(equal
(ebox-style--expand-engine-plist
'(:border-left-width 1 :border-left-style solid
:border-right-width 2 :border-right-style solid
:padding-left 1 :margin-right (3)))
'(:border-left-pixel 1 :border-left-style solid
:border-right-pixel 2 :border-right-style solid
:padding-left-pixel 7 :margin-right-pixel 3))))
(should
(equal
(ebox-style--expand-engine-plist
'(:border-left-width 2 :border-left-style none))
'(:border-left-pixel 0 :border-left-style none)))
(should
(equal
(ebox-style--expand-engine-plist
'(:border-top-width 2 :border-top-style solid))
'(:border-top-pixel 2 :border-top-style solid))))
(ert-deftest ebox-style-declaration-cache-expands-shorthand-once ()
"A cached declaration should not expand its shorthand again."
(let ((calls 0)
(original (symbol-function 'ebox-style--padding-shorthand)))
(clrhash ebox-style--declaration-cache)
(cl-letf (((symbol-function 'ebox-style--padding-shorthand)
(lambda (value)
(cl-incf calls)
(funcall original value))))
(ebox-style-compile-declarations '(:padding 1) t)
(ebox-style-compile-declarations '(:padding 1) t))
(should (= calls 1))))
(ert-deftest ebox-style-border-present-rejects-owned-longhand-conflicts ()
"One source should not mix the boolean border shorthand with its outputs."
(should-error
(ebox-style-compile-declarations
'(:border-top-p t :border-top-width 2) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-top-style dashed :border-top-p t) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-top-p t :border-top (2 dashed "red")) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-top-p t :border-width 2) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-style dashed :border-top-p t) t)
:type 'error)
(should-error
(ebox-style-compile-declarations
'(:border-bottom-p t :border (1 solid "red")) t)
:type 'error))
(ert-deftest ebox-canonical-text-constructor-is-renderable-and-typed ()
"A canonical TextNode should retain its kind, source, and string value."
(let* ((input (ebox-dsl-test--text-create
:value "Hello" :source-handle 'source-text))
(node (ebox-dsl-test--root input)))
(should (ebox-text-node-p node))
(should-not (ebox-box-node-p node))
(should (eq (ebox-source-handle-id (ebox-node-source-handle node))
'source-text))
(should (string= (ebox-text-node-value node) "Hello"))
(should (string= (ebox-dsl-test--plain input) "Hello"))
(should-not (ebox-tree-node-visible-overflow-p node))))
(ert-deftest ebox-canonical-text-constructor-rejects-non-text-shapes ()
"A canonical TextNode should have exactly one string payload."
(should-error (ebox-dsl-test--text-create :value 7) :type 'error)
(should-error (ebox-dsl-test--text-create :value "A" :content "B") :type 'error)
(should-error (ebox-dsl-test--text-create :value "A" :children nil) :type 'error)
(let* ((builder (ebox-source-builder-create))
(handle (ebox-source-builder-bind builder :declarations nil)))
(should-error
(ebox-text-create
:value "A" :source-handle handle
:owned-facts (ebox-canonical-facts-from-declarations 'text nil)
:color "red")
:type 'error))
(should-error (ebox-text-create :value "A") :type 'error)
(should-error
(ebox-text-create :value "A" :source-handle 'raw-source)
:type 'error)
(should-error (ebox-build '(text :wrap-mode none "A")) :type 'error)
(should-error
(ebox-dsl-test--text-create :value "A" :value "B")
:type 'error)
(should-error
(ebox-dsl-test--text-create :value "A" :source-handle 'a :source-handle 'b)
:type 'error))
(ert-deftest ebox-canonical-box-uses-registered-runtime-defaults ()
"A Box should materialize the definite defaults consumed by layout."
(let* ((input (ebox-dsl-test--box-create
:layout (ebox-normal-layout-create) :children nil))
(box (ebox-dsl-test--root input)))
(should (eq (ebox-get box :overflow) 'scroll))
(dolist (property '(:padding-left-pixel :padding-right-pixel
:padding-top-height :padding-bottom-height
:margin-left-pixel :margin-right-pixel
:margin-top-height :margin-bottom-height
:border-left-pixel :border-right-pixel))
(should (zerop (ebox-get box property))))))
(ert-deftest ebox-render-cache-signature-ignores-derived-layout-proofs ()
"A render must not invalidate its own canonical node cache key."
(let* ((node (ebox-dsl-test--root
(ebox-build '(box :width '(80) "Stable"))))
(before
(let ((ebox--render-cache-signature-cache
(make-hash-table :test 'eq)))
(ebox--render-cache-node-signature node))))
(plist-put node :ebox-content-width-exact-p t)
(plist-put node :ebox-content-layout-complete-p t)
(let ((after
(let ((ebox--render-cache-signature-cache
(make-hash-table :test 'eq)))
(ebox--render-cache-node-signature node))))
(should (equal-including-properties before after)))))
(ert-deftest ebox-canonical-box-materializes-inherited-foreground-once ()
"A canonical Text should be the sole foreground paint owner under Box."
(let* ((rendered (ebox-render (ebox-build '(box :color "#123456" "A"))))
(face (get-text-property 0 'face rendered)))
(should (equal face '(:foreground "#123456")))))
(ert-deftest ebox-box-owns-wrap-policy-consumed-by-text ()
"Box declarations should become inherited Text measurement input."
(let ((buffer (generate-new-buffer " *ebox-wrap-owner*")))
(unwind-protect
(progn
(ebox-render-to-buffer
buffer (ebox-build '(box :wrap-mode none "Page 1 / 3")))
(let* ((root (plist-get (ebox--buffer-render-state buffer)
:root-node))
(text (car (ebox-box-node-children root))))
(should (eq (ebox-style-node-specified-value
root :wrap-mode nil
(plist-get (ebox--buffer-render-state buffer)
:source-index))
'none))
(should (eq (plist-get root :wrap-mode) 'none))
(should (eq (plist-get text :wrap-mode) 'none))))
(kill-buffer buffer))))
(ert-deftest ebox-box-wrap-modes-preserve-word-char-kp-and-none ()
"Keep every established Box wrapping algorithm behind one property."
(dolist (mode '(word char kp none))
(let ((node (ebox-build `(box :wrap-mode ,mode "A B"))))
(should (eq (ebox-style-node-specified-value node :wrap-mode) mode))))
(should-error (ebox-build '(box :wrap-mode arbitrary "A")) :type 'error)
(let* ((width (ebox--string-pixel-width "A"))
(char-box
(ebox-dsl-test--box-create
:layout (ebox-normal-layout-create)
:children (list (ebox-dsl-test--text-create :value "AB"))
:width (list width) :wrap-mode 'char))
(none-box
(ebox-dsl-test--box-create
:layout (ebox-normal-layout-create)
:children (list (ebox-dsl-test--text-create :value "AB"))
:width (list width) :wrap-mode 'none)))
(should (= (length (ebox-string-lines (ebox-render char-box))) 2))
(should (= (length (ebox-string-lines (ebox-render none-box))) 1)))
(let (justify-args)
(cl-letf (((symbol-function 'require)
(lambda (feature &optional _filename _noerror)
(eq feature 'ekp)))
((symbol-function 'ekp-pixel-justify)
(lambda (text width)
(setq justify-args (list text width))
text)))
(ebox-render
(ebox-dsl-test--box-create
:layout (ebox-normal-layout-create)
:children (list (ebox-dsl-test--text-create :value "paragraph"))
:width '(100) :wrap-mode 'kp)))
(should (equal justify-args '("paragraph" 100)))))
(ert-deftest ebox-canonical-normal-box-renders-inline-runs-and-blocks ()
"Normal should retain typed children and honor inline/block boundaries."
(let* ((text-input
(ebox-dsl-test--text-create :value "A" :source-handle 'source-text))
(text (ebox-dsl-test--root text-input))
(second-text-input (ebox-dsl-test--text-create :value "B"))
(second-text (ebox-dsl-test--root second-text-input))
(inline-input
(ebox-dsl-test--box-create
:layout (ebox-normal-layout-create) :outer 'inline
:width '(100)
:children (list (ebox-dsl-test--text-create :value "A B"))))
(inline (ebox-dsl-test--root inline-input))
(block-input
(ebox-dsl-test--box-create
:layout (ebox-normal-layout-create)
:children (list (ebox-dsl-test--text-create :value "C"))))
(block (ebox-dsl-test--root block-input))
(tail-input (ebox-dsl-test--text-create :value "D"))
(tail (ebox-dsl-test--root tail-input))
(layout (ebox-normal-layout-create))
(box-input
(ebox-dsl-test--box-create
:layout layout
:children (list text-input second-text-input inline-input
block-input tail-input)
:source-handle 'source-box
:width '(50) :overflow 'visible))
(box (ebox-dsl-test--root box-input))
(rendered (ebox-render box-input))
(rendered-lines (ebox-string-lines rendered))
(lines (mapcar (lambda (line)
(string-trim-right
(substring-no-properties line)))
rendered-lines)))
(should (ebox-box-node-p box))
(should-not (ebox-text-node-p box))
(should (eq (ebox-source-handle-id (ebox-node-source-handle box))
'source-box))
(should (eq (ebox-layout-config-kind
(ebox-box-node-layout box))
'normal))
(should (equal (ebox-box-node-children box)
(list text second-text inline block tail)))
(should (equal (ebox--computed-display text) '(inline flow)))
(should (equal lines '("AB" "A B" "C" "D")))
(should (>= (ebox--string-pixel-width (nth 1 rendered-lines)) 100))
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(let* ((revised-text
(ebox-dsl-test--revise-source
text-input :class "fixed-inline-text"))
(revised-box
(ebox-dsl-test--box-create
:layout layout
:children (list revised-text second-text-input inline-input
block-input tail-input)
:source-handle 'source-box
:width '(50) :overflow 'visible)))
(ebox-style-add-rule ".fixed-inline-text" (list :outer 'block))
(should-error (ebox-render revised-box) :type 'error)))
(cl-labels
((normal-lines
(value &optional width)
(mapcar #'substring-no-properties
(ebox-string-lines
(ebox-render
(apply #'ebox-dsl-test--box-create
:layout (ebox-normal-layout-create)
:children (list (ebox-dsl-test--text-create :value value))
(when width
(list :width (list width)
:overflow 'visible))))))))
(dolist (case '(("A\n\nB" ("A" "" "B"))
("\nA" ("" "A"))
("A\n" ("A" ""))
("\n" ("" ""))))
(should (equal (mapcar #'string-trim-right
(normal-lines (car case)))
(cadr case))))
(should (equal (normal-lines " A") '(" A")))
(should (equal (normal-lines " ") '(" ")))
(should
(equal (mapcar #'string-trim-right
(normal-lines
"abcdef x" (* 5 (ebox--string-pixel-width "a"))))
'("abcde" "f x")))
(should
(= (length
(normal-lines
(make-string 10 ?\s)
(* 5 (ebox--string-pixel-width " "))))
2))
(should (= (length (normal-lines "word" 0)) 1))
(should (= (length (normal-lines " " 0)) 1))
(let* ((inline-zero
(ebox-dsl-test--box-create
:layout (ebox-normal-layout-create) :outer 'inline
:width '(10)
:children (list (ebox-dsl-test--text-create :value "I"))))
(zero-parent
(ebox-dsl-test--box-create
:layout (ebox-normal-layout-create) :width '(0)
:overflow 'visible :children (list inline-zero))))
(should (= (ebox-string-height (ebox-render zero-parent)) 1))))))
(ert-deftest ebox-canonical-row-box-retains-and-renders-typed-children ()
"A Row Box should retain one Box identity around all typed children."
(let* ((left-input
(ebox-dsl-test--text-create :value "A" :source-handle 'left))
(right-input
(ebox-dsl-test--text-create :value "B\nB\nB" :source-handle 'right))
(left (ebox-dsl-test--root left-input))
(right (ebox-dsl-test--root right-input))
(box-input
(ebox-dsl-test--box-create
:layout (ebox-row-layout-create :item-gap 5 :cross-align 'center)
:children (list left-input right-input)
:source-handle 'row))
(box (ebox-dsl-test--root box-input)))
(should (ebox-box-node-p box))
(should (eq (ebox-source-handle-id (ebox-node-source-handle box))
'row))
(should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'row))
(should (equal (ebox-layout-config-props (ebox-box-node-layout box))
'(:item-gap 5 :cross-align center)))
(should (equal (ebox-box-node-children box) (list left right)))
(should (eq (ebox-tree-node-children box)
(ebox-box-node-children box)))
(should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 3))
(should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 3))
(should-not (plist-member box :ebox-content-node))
(should (equal (ebox--computed-display box) '(block row)))
(let* ((rendered (ebox-render box-input))
(lines (ebox-string-lines rendered))
(middle (nth 1 lines)))
(should-not (string-match-p "A" (substring-no-properties (car lines))))
(should (string-match-p "A.*B" (substring-no-properties middle)))
(should (= (ebox--string-pixel-width middle)
(+ (ebox--string-pixel-width "A") 5
(ebox--string-pixel-width "B")))))))
(ert-deftest ebox-canonical-column-box-retains-and-renders-typed-children ()
"A Column Box should retain one Box identity around all typed children."
(let* ((top-input (ebox-dsl-test--text-create :value "A"))
(bottom-input (ebox-dsl-test--text-create :value "BBB"))
(top (ebox-dsl-test--root top-input))
(bottom (ebox-dsl-test--root bottom-input))
(box-input
(ebox-dsl-test--box-create
:layout (ebox-column-layout-create :item-gap 1 :cross-align 'end)
:children (list top-input bottom-input)))
(box (ebox-dsl-test--root box-input)))
(should (ebox-box-node-p box))
(should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'column))
(should (equal (ebox-layout-config-props (ebox-box-node-layout box))
'(:item-gap 1 :cross-align end)))
(should (equal (ebox-box-node-children box) (list top bottom)))
(should (eq (ebox-tree-node-children box)
(ebox-box-node-children box)))
(should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 3))
(should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 3))
(should-not (plist-member box :ebox-content-node))
(should (equal (ebox--computed-display box) '(block column)))
(should (= (ebox-string-height (ebox-render box-input)) 3))
(let ((lines (ebox-string-lines (ebox-render box-input))))
(should (string-prefix-p " " (substring-no-properties (car lines))))
(should (= (ebox--string-pixel-width (car lines))
(ebox--string-pixel-width (nth 2 lines)))))))
(ert-deftest ebox-canonical-simple-axis-config-validates-closed-schema ()
"Row/Column configs should distinguish defaults from invalid explicit values."
(dolist (constructor '(ebox-row-layout-create ebox-column-layout-create))
(should (equal (ebox-layout-config-props (funcall constructor))
'(:item-gap 0 :cross-align stretch)))
(dolist (plist '((:item-gap nil) (:item-gap -1)
(:cross-align nil) (:cross-align bogus)
(:gap 1) (:item-gap 1 :item-gap 2)))
(should-error (apply constructor plist) :type 'error))))
(ert-deftest ebox-canonical-flex-box-uses-one-box-identity-and-child-list ()
"A FlexConfig should select the Box algorithm without a Flex runtime node."
(let* ((left (ebox-dsl-test--text-create :value "A"))
(right (ebox-dsl-test--text-create :value "B"))
(layout (ebox-flex-layout-create))
(input (ebox-dsl-test--box-create :layout layout
:children (list left right)
:width '(120)))
(box (ebox-dsl-test--root input))
(rendered (ebox-render input)))
(should
(equal (ebox-layout-config-props layout)
'(:flex-direction row :flex-wrap nowrap
:justify-content flex-start :align-items stretch
:align-content stretch :row-gap 0 :column-gap 0)))
(should-not (plist-member (ebox-layout-config-props layout) :width))
(should-not (plist-member (ebox-layout-config-props layout) :height))
(should (ebox-box-node-p box))
(should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'flex))
(should (eq (ebox-tree-node-children box)
(ebox-box-node-children box)))
(should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 3))
(should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 3))
(should
(= (ebox-dsl-test--tree-count
box (lambda (node) (eq (plist-get node :ebox-type) 'flex)))
0))
(should-not (plist-member box :ebox-content-node))
(should (equal (ebox--computed-display box) '(block flex)))
(should (string= (string-trim-right
(substring-no-properties rendered))
"AB"))
(should (= (ebox--string-pixel-width rendered) 120))))
(ert-deftest ebox-canonical-flex-revalidates-the-typed-config-boundary ()
"Mutated or extra FlexConfig properties should fail before rendering."
(let ((invalid-direction (ebox-flex-layout-create))
(extra-frame-prop (ebox-flex-layout-create))
(cross-kind-props (ebox-flex-layout-create)))
(setf (ebox-layout-config-props invalid-direction)
'(:flex-direction bogus :flex-wrap nowrap
:justify-content flex-start :align-items stretch
:align-content stretch :row-gap 0 :column-gap 0))
(setf (ebox-layout-config-props extra-frame-prop)
(append (ebox-layout-config-props extra-frame-prop)
'(:width 999)))
(setf (ebox-layout-config-kind cross-kind-props) 'row)
(should-error
(ebox-dsl-test--box-create :layout invalid-direction :children nil)
:type 'error)
(should-error
(ebox-dsl-test--box-create :layout extra-frame-prop :children nil)
:type 'error)
(should-error
(ebox-dsl-test--box-create :layout cross-kind-props :children nil)
:type 'error)))
(ert-deftest ebox-canonical-box-detaches-validated-layout-config ()
"Caller and accessor mutations should not alter a constructed Box."
(let* ((source-layout (ebox-flex-layout-create))
(input (ebox-dsl-test--box-create
:layout source-layout
:children (list (ebox-dsl-test--text-create :value "A")
(ebox-dsl-test--text-create :value "B"))))
(box (ebox-dsl-test--root input))
(exposed-layout (ebox-box-node-layout box)))
(should-not (fboundp 'ebox-layout-config-validator))
(setf (ebox-layout-config-props source-layout) '(:flex-direction bogus))
(setf (ebox-layout-config-props exposed-layout) '(:width 999))
(should (string= (string-trim-right
(substring-no-properties (ebox-render input)))
"AB"))
(should (equal (ebox-layout-config-props (ebox-box-node-layout box))
ebox--flex-default-config-props))))
(ert-deftest ebox-flex-layout-create-normalizes-the-complete-config-schema ()
"The typed Flex constructor should emit one canonical closed property set."
(let ((layout
(ebox-flex-layout-create
:flex-direction 'column-reverse
:flex-wrap 'wrap
:justify-content 'space-between
:align-items 'center
:align-content 'end
:row-gap '(2)
:column-gap '(8))))
(should
(equal (ebox-layout-config-props layout)
'(:flex-direction column-reverse :flex-wrap wrap
:justify-content space-between :align-items center
:align-content end :row-gap 2 :column-gap 8)))
(should (ebox-flex-layout-config-props-p
(ebox-layout-config-props layout)))
(should (ebox-box-node-p
(ebox-dsl-test--root
(ebox-dsl-test--box-create :layout layout :children nil))))))
(ert-deftest ebox-flex-layout-create-validates-values-and-rejects-author-sugar ()
"FlexConfig should accept its value domain and reject sugar or foreign props."
(dolist (direction ebox--flex-direction-values)
(should (ebox-flex-layout-create :flex-direction direction)))
(dolist (wrap ebox--flex-wrap-values)
(should (ebox-flex-layout-create :flex-wrap wrap)))
(dolist (justify ebox--flex-justify-content-values)
(should (ebox-flex-layout-create :justify-content justify)))
(dolist (align ebox--flex-align-items-values)
(should (ebox-flex-layout-create :align-items align)))
(dolist (align ebox--flex-align-content-values)
(should (ebox-flex-layout-create :align-content align)))
(dolist (plist '((:flex-direction bogus)
(:flex-wrap reverse)
(:justify-content bogus)
(:align-items bogus)
(:align-content bogus)
(:row-gap -1)
(:column-gap (-1))
(:flex-flow (row wrap))
(:gap 1)
(:width 100)
(:flex-direction row :flex-direction column)))
(should-error (apply #'ebox-flex-layout-create plist) :type 'error)))
(ert-deftest ebox-flex-config-validation-does-not-reparse-canonical-pixels ()
"Boundary validation should preserve physical pixels under non-unit fonts."
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8)))
(let* ((layout (ebox-flex-layout-create :column-gap '(8)))
(box (ebox-dsl-test--root
(ebox-dsl-test--box-create
:layout layout :children nil))))
(should (= (plist-get (ebox-layout-config-props layout) :column-gap) 8))
(should (= (plist-get
(ebox-layout-config-props (ebox-box-node-layout box))
:column-gap)
8)))))
(ert-deftest ebox-canonical-grid-box-uses-one-box-identity-and-child-list ()
"A GridConfig should select the Box algorithm without a Grid runtime node."
(let* ((layout (ebox-grid-layout-create))
(input (ebox-dsl-test--box-create
:layout layout
:children (list (ebox-dsl-test--text-create :value "A")
(ebox-dsl-test--text-create :value "B"))
:width '(120)))
(box (ebox-dsl-test--root input))
(rendered (ebox-render input)))
(should (equal (ebox-layout-config-props layout)
ebox--grid-default-config-props))
(should-not (plist-member (ebox-layout-config-props layout) :width))
(should-not (plist-member (ebox-layout-config-props layout) :height))
(should (ebox-box-node-p box))
(should (eq (ebox-layout-config-kind (ebox-box-node-layout box)) 'grid))
(should (eq (ebox-tree-node-children box)
(ebox-box-node-children box)))
(should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 3))
(should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 3))
(should
(= (ebox-dsl-test--tree-count
box (lambda (node) (eq (plist-get node :ebox-type) 'grid)))
0))
(should-not (plist-member box :ebox-content-node))
(should (equal (ebox--computed-display box) '(block grid)))
(should (string=
(replace-regexp-in-string
"[[:space:]]" "" (substring-no-properties rendered))
"AB"))
(should (equal (mapcar #'ebox--string-pixel-width
(ebox-string-lines rendered))
'(120 120 120)))))
(ert-deftest ebox-canonical-grid-consumes-direct-child-placement ()
"Grid placement should stay on child Boxes without grid-item wrappers."
(let* ((later
(ebox-dsl-test--box-create :layout (ebox-normal-layout-create)
:children (list (ebox-dsl-test--text-create :value "A"))
:grid-row 2))
(earlier
(ebox-dsl-test--box-create :layout (ebox-normal-layout-create)
:children (list (ebox-dsl-test--text-create :value "B"))
:grid-row 1))
(box (ebox-dsl-test--box-create :layout (ebox-grid-layout-create)
:children (list later earlier)))
(rendered (substring-no-properties (ebox-render box))))
(should (string=
(replace-regexp-in-string "[[:space:]]" "" rendered)
"BA"))
(should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 5))
(should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 5))))
(ert-deftest ebox-canonical-participation-validates-the-final-parent-context ()
"Participation should be legal only under its matching direct parent layout."
(cl-labels
((child (&rest props)
(apply #'ebox-dsl-test--box-create
:layout (ebox-normal-layout-create)
:children (list (ebox-dsl-test--text-create :value "A"))
props))
(parent (layout child)
(ebox-dsl-test--box-create :layout layout :children (list child))))
(should (stringp
(ebox-render
(parent (ebox-flex-layout-create) (child :flex-grow 1)))))
(should (stringp
(ebox-render
(parent (ebox-grid-layout-create) (child :grid-row 1)))))
(should-error
(ebox-render (child :flex-grow 1)) :type 'error)
(should-error
(ebox-render
(parent (ebox-normal-layout-create) (child :flex-grow 1)))
:type 'error)
(should-error
(ebox-render
(parent (ebox-flex-layout-create) (child :grid-row 1)))
:type 'error)
(should-error
(ebox-render
(parent (ebox-grid-layout-create) (child :flex-grow 1)))
:type 'error)
(let ((text (ebox-dsl-test--text-create :value "A")))
(plist-put (ebox-dsl-test--root text) :flex-grow 1)
(should-error
(ebox-render (parent (ebox-flex-layout-create) text)) :type 'error))
;; The final candidate is authoritative after ECSS projection too.
(let ((ebox-style-stylesheet (ecss-stylesheet-create)))
(ebox-style-add-rule "box" (list :flex-grow 1))
(should-error
(ebox-render
(parent (ebox-normal-layout-create) (child)))
:type 'error))
;; Detached Flex geometry retains its final parent during computed style.
(let ((ebox-style-stylesheet (ecss-stylesheet-create))
(detached (child)))
(setq detached
(ebox-dsl-test--revise-source
detached :class "detached-flex-item"))
(ebox-style-add-rule ".detached-flex-item" (list :flex-grow 1))
(let ((ebox--render-root-parent-kind 'flex))
(should (stringp (ebox-render detached))))
;; The dynamic context is exact and cannot authorize a later root.
(should-error (ebox-render detached) :type 'error))
(let ((ebox-style-stylesheet (ecss-stylesheet-create))
(detached (child)))
(setq detached
(ebox-dsl-test--revise-source
detached :class "detached-flex-item"))
(ebox-style-add-rule ".detached-flex-item" (list :grid-row 1))
(let ((ebox--render-root-parent-kind 'flex))
(should-error (ebox-render detached) :type 'error)))
;; Internal layout and parent participation are orthogonal facts.
(should
(stringp
(ebox-render
(parent
(ebox-grid-layout-create)
(ebox-dsl-test--box-create
:layout (ebox-flex-layout-create)
:children (list (ebox-dsl-test--text-create :value "A"))
:grid-row 1)))))))
(ert-deftest ebox-canonical-grid-revalidates-the-typed-config-boundary ()
"GridConfig must reject frame properties and cross-kind property sets."
(let ((extra-frame-prop (ebox-grid-layout-create))
(cross-kind-props (ebox-grid-layout-create)))
(setf (ebox-layout-config-props extra-frame-prop)
(append (ebox-layout-config-props extra-frame-prop)
'(:width 999)))
(setf (ebox-layout-config-kind cross-kind-props) 'column)
(should-error
(ebox-dsl-test--box-create :layout extra-frame-prop :children nil)
:type 'error)
(should-error
(ebox-dsl-test--box-create :layout cross-kind-props :children nil)
:type 'error)))
(ert-deftest ebox-grid-layout-create-normalizes-the-complete-config-schema ()
"The typed Grid constructor should emit one canonical closed property set."
(let* ((layout
(ebox-grid-layout-create
:grid-template-columns '((20) (fr 1))
:grid-template-rows '(1 auto)
:grid-auto-columns '((30))
:grid-auto-rows '(2)
:grid-auto-flow 'column
:row-gap '(2) :column-gap '(8)
:justify-items 'center :align-items 'end
:justify-content 'space-between :align-content 'center))
(props (ebox-layout-config-props layout)))
(should (equal (plist-get props :grid-template-columns)
'((:kind fixed :size 20) (:kind fr :factor 1))))
(should (equal (plist-get props :grid-template-rows)
'((:kind fixed :size 1) (:kind auto :factor 0))))
(should (equal (plist-get props :grid-auto-columns)
'(:kind fixed :size 30)))
(should (equal (plist-get props :grid-auto-rows)
'(:kind fixed :size 2)))
(should (= (plist-get props :row-gap) 2))
(should (= (plist-get props :column-gap) 8))
(should (ebox-grid-layout-config-props-p props))
(should (ebox-box-node-p
(ebox-dsl-test--root
(ebox-dsl-test--box-create :layout layout :children nil))))))
(ert-deftest ebox-grid-layout-create-validates-values-and-rejects-author-sugar ()
"GridConfig should accept its value domain and reject sugar or foreign props."
(dolist (flow '(row column))
(should (ebox-grid-layout-create :grid-auto-flow flow)))
(dolist (value ebox--grid-justify-items-values)
(should (ebox-grid-layout-create :justify-items value)))
(dolist (value ebox--grid-align-items-values)
(should (ebox-grid-layout-create :align-items value)))
(dolist (value ebox--grid-content-alignment-values)
(should (ebox-grid-layout-create :justify-content value))
(should (ebox-grid-layout-create :align-content value)))
(dolist (value '(min-content max-content))
(should (ebox-grid-layout-create :grid-template-columns (list value)))
(should (ebox-grid-layout-create :grid-template-rows (list value)))
(should (ebox-grid-layout-create :grid-auto-columns value))
(should (ebox-grid-layout-create :grid-auto-rows value)))
(should
(ebox-grid-layout-create
:grid-template-columns '((repeat 2 (auto (fr 1))))))
(dolist (plist '((:grid-auto-flow diagonal)
(:grid-template-columns (bogus))
(:grid-template-rows (bogus))
(:grid-template-columns (0fr))
(:grid-template-columns (0.0fr))
(:grid-template-columns (1fr))
(:grid-template-columns (fr 1))
(:grid-template-columns min-content)
(:grid-template-columns ((fr 0)))
(:grid-template-columns ((minmax (fr 1) auto)))
(:grid-template-columns
((minmax (minmax (10) (20)) auto)))
(:grid-template-columns ((repeat 0 auto)))
(:grid-auto-columns ((repeat 2 auto)))
(:grid-auto-columns ((10) (20)))
(:row-gap -1)
(:column-gap (-1))
(:gap 1)
(:grid-row-gap 1)
(:width 100)
(:align-items bogus)
(:grid-auto-flow row :grid-auto-flow column)))
(should-error (apply #'ebox-grid-layout-create plist) :type 'error)))
(ert-deftest ebox-grid-config-validation-does-not-reparse-canonical-pixels ()
"Grid boundary validation should preserve physical pixels under GUI fonts."
(cl-letf (((symbol-function 'ebox--space-pixel-width) (lambda () 8)))
(let* ((layout
(ebox-grid-layout-create
:grid-template-columns '((20)) :column-gap '(8)))
(box (ebox-dsl-test--root
(ebox-dsl-test--box-create :layout layout :children nil)))
(props (ebox-layout-config-props (ebox-box-node-layout box))))
(should (= (plist-get props :column-gap) 8))
(should (= (plist-get (car (plist-get props :grid-template-columns))
:size)
20)))))
(ert-deftest ebox-grid-config-boundary-rejects-forged-minmax-roles ()
"Trusted Grid validation should enforce minmax roles after mutation."
(let ((fr-minimum (ebox-grid-layout-create))
(nested-minimum (ebox-grid-layout-create)))
(setf (ebox-layout-config-props fr-minimum)
(plist-put (ebox-layout-config-props fr-minimum)
:grid-template-columns
'((:kind minmax
:min (:kind fr :factor 1)
:max (:kind auto :factor 0)))))
(setf (ebox-layout-config-props nested-minimum)
(plist-put (ebox-layout-config-props nested-minimum)
:grid-template-columns
'((:kind minmax
:min (:kind minmax
:min (:kind fixed :size 1)
:max (:kind fixed :size 2))
:max (:kind auto :factor 0)))))
(should-error
(ebox-dsl-test--box-create :layout fr-minimum :children nil) :type 'error)
(should-error
(ebox-dsl-test--box-create :layout nested-minimum :children nil) :type 'error)))
(ert-deftest ebox-canonical-flex-consumes-direct-child-participation ()
"Flex participation should stay on child Boxes without item wrappers."
(let* ((left-text (ebox-dsl-test--text-create :value "A"))
(right-text (ebox-dsl-test--text-create :value "B"))
(left (ebox-dsl-test--box-create :layout (ebox-normal-layout-create)
:children (list left-text)
:order 2))
(right (ebox-dsl-test--box-create :layout (ebox-normal-layout-create)
:children (list right-text)
:order 1))
(box (ebox-dsl-test--box-create :layout (ebox-flex-layout-create)
:children (list left right)))
(rendered (substring-no-properties (ebox-render box))))
(should (string= (string-trim-right rendered) "BA"))
(should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 5))
(should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 5))
(should
(= (ebox-dsl-test--tree-count
box (lambda (node) (eq (plist-get node :ebox-type) 'flex-item)))
0))))
(ert-deftest ebox-canonical-row-and-column-never-collapse-single-child-boxes ()
"A one-child Row or Column should preserve its canonical Box boundary."
(let* ((child-input (ebox-dsl-test--text-create :value "A"))
(child (ebox-dsl-test--root child-input))
(row-input
(ebox-dsl-test--box-create :layout (ebox-row-layout-create)
:children (list child-input)))
(column-input
(ebox-dsl-test--box-create :layout (ebox-column-layout-create)
:children (list child-input)))
(row (ebox-dsl-test--root row-input))
(column (ebox-dsl-test--root column-input)))
(should (ebox-box-node-p row))
(should (ebox-box-node-p column))
(should-not (eq row child))
(should-not (eq column child))
(should (equal (ebox-tree-node-children row) (list child)))
(should (equal (ebox-tree-node-children column) (list child)))
(should (= (ebox-dsl-test--tree-count row (lambda (_node) t)) 2))
(should (= (ebox-dsl-test--tree-count column (lambda (_node) t)) 2))
(should (string= (ebox-dsl-test--plain row-input) "A"))
(should (string= (ebox-dsl-test--plain column-input) "A"))))
(ert-deftest ebox-canonical-empty-layout-boxes-have-no-runtime-child ()
"Empty layout Boxes should not synthesize layout identities."
(dolist (layout (list (ebox-row-layout-create)
(ebox-column-layout-create)
(ebox-flex-layout-create)
(ebox-grid-layout-create)))
(let* ((input (ebox-dsl-test--box-create
:layout layout :children nil))
(box (ebox-dsl-test--root input)))
(should (ebox-box-node-p box))
(should-not (ebox-tree-node-children box))
(should (= (ebox-dsl-test--tree-count box (lambda (_node) t)) 1))
(should (= (ebox-dsl-test--tree-count box #'ebox-node-kind) 1))
(should (string= (ebox-dsl-test--plain input) "")))))
(ert-deftest ebox-canonical-box-copy-keeps-one-authoritative-child-list ()
"A copied Box should expose exactly its copied runtime children."
(let* ((left-input (ebox-dsl-test--text-create :value "A"))
(right-input (ebox-dsl-test--text-create :value "B"))
(left (ebox-dsl-test--root left-input))
(right (ebox-dsl-test--root right-input))
(source-input
(ebox-dsl-test--box-create :layout (ebox-row-layout-create)
:children (list left-input right-input)))
(source (ebox-dsl-test--root source-input))
(copy (ebox-tree-copy-node-structure source))
(copied-children (ebox-tree-node-children copy)))
(should (eq copied-children (ebox-box-node-children copy)))
(should-not (eq copied-children (ebox-box-node-children source)))
(should-not (eq (car copied-children) left))
(should-not (eq (cadr copied-children) right))
(should (= (ebox-dsl-test--tree-count copy (lambda (_node) t)) 3))
(should (= (ebox-dsl-test--tree-count copy #'ebox-node-kind) 3))))
(ert-deftest ebox-canonical-box-rejects-paint-only-properties ()
"Canonical Box geometry should not duplicate paint-only state."
(let* ((builder (ebox-source-builder-create))
(handle (ebox-source-builder-bind builder :declarations nil)))
(should-error
(ebox-box-create
:layout (ebox-normal-layout-create) :children nil
:source-handle handle
:owned-facts (ebox-canonical-facts-from-declarations 'box nil)
:bgcolor "red")
:type 'error)))
(ert-deftest ebox-build-projects-source-style-without-polluting-canonical-geometry ()
"Track the G4 boundary: author paint must leave canonical Box geometry."
:expected-result :failed
(let ((node (ebox-dsl-test--root
(ebox-build '(box :background-color "red" "Paint")))))
(should-not (ebox-get node :bgcolor))))
(ert-deftest ebox-canonical-box-requires-unique-reserved-fields ()
"Typed Box reserved fields should never be missing or duplicated."
(let ((normal (ebox-normal-layout-create)))
(should-error (ebox-dsl-test--box-create :children nil) :type 'error)
(should-error
(ebox-dsl-test--box-create :layout normal :layout normal :children nil)
:type 'error)
(should-error
(ebox-dsl-test--box-create :layout normal :children nil :children nil)
:type 'error)
(should-error
(ebox-dsl-test--box-create :layout normal :outer nil :children nil)
:type 'error)))
(ert-deftest ebox-build-normalizes-the-complete-author-grammar ()
"Every public author form should lower to one canonical Text/Box model."
(dolist (case
'(("plain" text nil)
((text "styled") text nil)
((box) box normal)
((row "A" "B") box row)
((column "A" "B") box column)
((flex "A" "B") box flex)
((grid "A" "B") box grid)))
(let* ((form (nth 0 case))
(kind (nth 1 case))
(layout-kind (nth 2 case))
(node (ebox-dsl-test--root (ebox-build form))))
(should (eq (ebox-node-kind node) kind))
(should (ebox-source-handle-p (ebox-node-source-handle node)))
(should (symbolp
(ebox-source-handle-id (ebox-node-source-handle node))))
(should-not
(eq (ebox-source-handle-id (ebox-node-source-handle node))
(intern-soft
(symbol-name
(ebox-source-handle-id
(ebox-node-source-handle node))))))
(when layout-kind
(should
(eq (ebox-layout-config-kind (ebox-box-node-layout node))
layout-kind)))))
(let ((inline-row (ebox-build '(row :outer inline "A" "B"))))
(should (equal (ebox--computed-display (ebox-dsl-test--root inline-row))
'(inline row)))))
(ert-deftest ebox-build-gives-equal-forms-distinct-source-identities ()
"Structurally equal author forms must not share opaque source identity."
(let* ((node (ebox-dsl-test--root
(ebox-build '(row (box "same") (box "same")))))
(children (ebox-box-node-children node)))
(should-not
(equal
(ebox-source-handle-id
(ebox-node-source-handle (car children)))
(ebox-source-handle-id
(ebox-node-source-handle (cadr children)))))))
(ert-deftest ebox-build-validates-form-owned-config-and-direct-participation ()
"Layout config belongs to its form; item roles belong to direct child Boxes."
(let* ((flex
(ebox-build
'(flex :width '(300) :gap '(1 (5))
(box :flex 1 "A")
"B")))
(flex-child (car (ebox-box-node-children
(ebox-dsl-test--root flex))))
(grid
(ebox-build
'(grid :grid-template-columns '((20) (20))
(box :grid-column '(1 :span 2) "Header"))))
(grid-child (car (ebox-box-node-children
(ebox-dsl-test--root grid)))))
(should (= (plist-get flex-child :flex-grow) 1))
(should (equal (plist-get grid-child :grid-column) '(1 :span 2)))
(should (string-match-p "A.*B"
(substring-no-properties (ebox-render flex))))
(should (string-match-p "Header"
(substring-no-properties (ebox-render grid)))))
(dolist (form
'((box :flex-direction row)
(row :gap 1)
(column :grid-template-columns ((10)))
(flex :grid-template-columns ((10)))
(grid :flex-direction row)))
(should-error (ebox-build form) :type 'error))
(should-error
(ebox-render (ebox-build '(box :flex-grow 1 "wrong parent")))
:type 'error))
(ert-deftest ebox-build-rejects-removed-and-private-author-syntax ()
"The author boundary should have one grammar and no runtime passthrough."
(dolist (form
'((ebox "A")
(spacer :width (20))
(item (box "A"))
(grid-item (box "A"))
(box :content "A")
(box :layout flex "A")
(box :display (block flex) "A")
(box :ebox-type box "A")
(box :ebox-content-node nil)
(text)
(text "A" "B")
(text (box "A"))
(text :width (10) "A")
(row :item-gap -1)
(unknown "A")))
(should-error (ebox-build form) :type 'error))
(should-error
(ebox-build (ebox-test-box (ebox-test-text "raw runtime node")))
:type 'error)
(should-error
(ebox-build
(list 'box :color (tp-computed (lambda () "#112233")) "dynamic"))
:type 'error)
(should-error
(ebox-build
(list 'box :color
(tp-paint-slot-create '(:foreground "#112233")) "slot"))
:type 'error))
(ert-deftest ebox-build-rejects-explicit-nil-outside-boolean-grammar ()
"Internal nil initial values must not widen the public author grammar."
(dolist (form
'((text :color nil "A")
(text :font-size nil "A")
(grid :grid-template-columns nil "A")
(grid :grid-template-rows nil "A")
(grid :grid-auto-columns nil "A")
(grid :grid-auto-rows nil "A")))
(should-error (ebox-build form) :type 'error))
(should (ebox-build '(box :border-top-p nil "A"))))
(ert-deftest ebox-source-metadata-uses-the-frozen-public-grammar ()
"Key, class, and id should remain source facts with closed value domains."
(should
(ebox-build
'(box :key stable :id 42 :class (card selected) "Valid")))
(dolist (form '((box :key nil "bad")
(box :key (not stable) "bad")
(box :id nil "bad")
(box :id 1.5 "bad")
(box :class (card 2) "bad")
(box :class (card . selected) "bad")))
(should-error (ebox-build form) :type 'error)))
(provide 'ebox-dsl-tests)
;;; ebox-dsl-tests.el ends here