feat: validate complete canonical FlexConfig schema

This commit is contained in:
Kinneyzhang 2026-08-26 19:43:14 +08:00
parent 31838e4e02
commit 0a552293f0
2 changed files with 188 additions and 23 deletions

View File

@ -86,6 +86,34 @@
:gap :row-gap :column-gap)
"Properties that belong only to flex container layout.")
(defconst ebox--flex-config-prop-keys
'(:flex-direction :flex-wrap :justify-content
:align-items :align-content :row-gap :column-gap)
"Canonical property order stored in a typed FlexConfig.")
(defconst ebox--flex-direction-values
'(row row-reverse column column-reverse)
"Supported canonical FlexConfig direction values.")
(defconst ebox--flex-wrap-values
'(nowrap wrap wrap-reverse)
"Supported canonical FlexConfig wrapping values.")
(defconst ebox--flex-justify-content-values
'(flex-start flex-end center space-between space-around space-evenly
start end left right normal)
"Supported canonical FlexConfig main-axis alignment values.")
(defconst ebox--flex-align-items-values
'(stretch flex-start flex-end center baseline normal
start end self-start self-end)
"Supported canonical FlexConfig item alignment values.")
(defconst ebox--flex-align-content-values
'(stretch flex-start flex-end center space-between space-around space-evenly
baseline normal start end)
"Supported canonical FlexConfig line alignment values.")
(defconst ebox--flex-container-neutral-box-prop-keys
'(:width :height :box-sizing)
"Flex box properties that do not create visible wrapping by themselves.")
@ -267,6 +295,27 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
(cons (nth 0 gap) (nth 1 gap)))
(t (cons gap gap))))
(defun ebox--flex-enum-value (value default allowed property)
"Return VALUE or DEFAULT after checking ALLOWED values for PROPERTY."
(let ((resolved (or value default)))
(unless (memq resolved allowed)
(error "Ebox FlexConfig %S has invalid value: %S" property resolved))
resolved))
(defun ebox--flex-row-gap-value (value)
"Return canonical nonnegative line count for FlexConfig row gap VALUE."
(unless (or (null value)
(numberp value)
(and (consp value)
(numberp (car value))
(null (cdr value))))
(error "Ebox FlexConfig :row-gap is invalid: %S" value))
(or (ebox--flex-line-value value 0) 0))
(defun ebox--flex-column-gap-value (value)
"Return canonical nonnegative pixels for FlexConfig column gap VALUE."
(or (ebox--nonnegative-horizontal-size-pixels value 0) 0))
(defun ebox--flex-normalize-config-props (props)
"Normalize FlexConfig PROPS to explicit layout-owned fields."
(let* ((flow (plist-get props :flex-flow))
@ -280,23 +329,41 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
(memq value '(nowrap wrap wrap-reverse)))
flow-values))
(gap-pair (ebox--flex-gap-pair (plist-get props :gap)))
(direction (or (plist-get props :flex-direction)
flow-direction
'row))
(wrap (or (plist-get props :flex-wrap)
flow-wrap
'nowrap)))
(direction
(ebox--flex-enum-value
(or (plist-get props :flex-direction) flow-direction)
'row ebox--flex-direction-values :flex-direction))
(wrap
(ebox--flex-enum-value
(or (plist-get props :flex-wrap) flow-wrap)
'nowrap ebox--flex-wrap-values :flex-wrap))
(justify
(ebox--flex-enum-value
(plist-get props :justify-content) 'flex-start
ebox--flex-justify-content-values :justify-content))
(align-items
(ebox--flex-enum-value
(plist-get props :align-items) 'stretch
ebox--flex-align-items-values :align-items))
(align-content
(ebox--flex-enum-value
(plist-get props :align-content) 'stretch
ebox--flex-align-content-values :align-content))
(row-gap
(if (plist-member props :row-gap)
(plist-get props :row-gap)
(car gap-pair)))
(column-gap
(if (plist-member props :column-gap)
(plist-get props :column-gap)
(cdr gap-pair))))
(list :flex-direction direction
:flex-wrap wrap
:justify-content (or (plist-get props :justify-content) 'flex-start)
:align-items (or (plist-get props :align-items) 'stretch)
:align-content (or (plist-get props :align-content) 'stretch)
:row-gap (if (plist-member props :row-gap)
(plist-get props :row-gap)
(car gap-pair))
:column-gap (if (plist-member props :column-gap)
(plist-get props :column-gap)
(cdr gap-pair)))))
:justify-content justify
:align-items align-items
:align-content align-content
:row-gap (ebox--flex-row-gap-value row-gap)
:column-gap (ebox--flex-column-gap-value column-gap))))
(defun ebox--flex-normalize-container-props (props)
"Normalize legacy flex container PROPS to config plus frame constraints."
@ -306,18 +373,59 @@ ALLOWED-KEYWORDS is the CSS keyword set valid for the property being parsed."
(defconst ebox--flex-default-config-props
(ebox--flex-normalize-config-props nil)
"Validated property set for the first default FlexConfig slice.")
"Computed default property set for FlexConfig.")
(defun ebox--flex-validate-config-input (plist)
"Return canonical FlexConfig input PLIST after surface validation."
(unless (and (proper-list-p plist) (zerop (% (length plist) 2)))
(error "Ebox FlexConfig properties must be an even plist: %S" plist))
(let ((seen (make-hash-table :test 'eq)))
(cl-loop for (key _value) on plist by #'cddr
unless (memq key ebox--flex-config-prop-keys)
do (error "Ebox FlexConfig does not accept %S" key)
do (when (gethash key seen)
(error "Duplicate Ebox FlexConfig property: %S" key))
do (puthash key t seen)))
plist)
(defun ebox-flex-layout-config-props-p (props)
"Return non-nil when PROPS are the validated default FlexConfig."
(equal props ebox--flex-default-config-props))
"Return non-nil when PROPS are canonical validated FlexConfig data."
(and (proper-list-p props)
(zerop (% (length props) 2))
(let ((cursor props)
(keys ebox--flex-config-prop-keys)
valid)
(setq valid t)
(while (and valid keys)
(setq valid (eq (car cursor) (car keys))
cursor (cddr cursor)
keys (cdr keys)))
(and valid (null cursor)))
(memq (plist-get props :flex-direction)
ebox--flex-direction-values)
(memq (plist-get props :flex-wrap)
ebox--flex-wrap-values)
(memq (plist-get props :justify-content)
ebox--flex-justify-content-values)
(memq (plist-get props :align-items)
ebox--flex-align-items-values)
(memq (plist-get props :align-content)
ebox--flex-align-content-values)
(let ((row-gap (plist-get props :row-gap))
(column-gap (plist-get props :column-gap)))
(and (integerp row-gap) (>= row-gap 0)
(numberp column-gap) (>= column-gap 0)))))
;;;###autoload
(defun ebox-flex-layout-create ()
"Return the canonical Flex layout config with computed defaults."
(defun ebox-flex-layout-create (&rest plist)
"Return a typed FlexConfig from canonical evaluated PLIST.
PLIST accepts direction, wrap, alignment, and row/column gap longhands.
Author shorthands such as `:flex-flow' and `:gap' are normalized before this
programmatic port and are therefore rejected here."
(ebox--flex-validate-config-input plist)
(ebox-layout-config--create
:kind 'flex
:props (copy-sequence ebox--flex-default-config-props)))
:props (ebox--flex-normalize-config-props plist)))
(defun ebox--flex-axis (props)
"Return main axis for normalized flex PROPS."

View File

@ -229,7 +229,7 @@
(equal (ebox-layout-config-props layout)
'(:flex-direction row :flex-wrap nowrap
:justify-content flex-start :align-items stretch
:align-content stretch :row-gap nil :column-gap nil)))
: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))
@ -257,7 +257,7 @@
(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 nil :column-gap nil))
: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)))
@ -289,6 +289,63 @@
(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-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-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-flex-projects-one-box-frame-constraint ()
"Typed and legacy Flex should derive the same content size from BoxFrame."
(dolist (sizing '(border-box content-box))