Created
September 2, 2026 17:21
-
-
Save d12frosted/d04000068719c1930fb41e3f36982b5a to your computer and use it in GitHub Desktop.
textui vs vui-layout.el conformance: same inputs through both layout engines, diffed (yibie/textui#1)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| textui 5a8cdd2 vs vui-layout.el, Emacs 31.0.50 | |
| category cases mismatches | |
| flex: partition at mins 1 0 | |
| flex: partition: oversized first child still gets a row 1 0 | |
| flex: grow: remainder to earlier growers 1 0 | |
| flex: grow: weighted 1 0 | |
| flex: grow: no growers leave surplus unassigned 1 0 | |
| flex: shrink by capacity 1 0 | |
| flex: shrink: minima that do not fit split the row 1 0 | |
| flex: min above natural raises the occupied width 1 0 | |
| flex: lone oversized ordinary block clamps 1 0 | |
| flex: lone oversized rigid block keeps natural 1 0 | |
| flex: rigid block never shrinks in a shared row 1 0 | |
| flex: zero total 1 0 | |
| flex: zero gap 1 0 | |
| flex: zero-width children 1 0 | |
| grid: 7 cells in 40, gap 1, columns 4, min 8 1 0 | |
| grid: 7 cells in 25, gap 1, columns 4, min 8 1 0 | |
| grid: 7 cells in 10, gap 1, columns 4, min 8 1 0 | |
| grid: 5 cells in 31, gap 2, columns 3, min 5 1 0 | |
| grid: 3 cells in 20, gap 1, columns 2, min 1 1 0 | |
| grid: 1 cells in 0, gap 1, columns 3, min 4 1 0 | |
| grid: 0 cells in 20, gap 1, columns 3, min 4 1 0 | |
| domain: grid without :min-column-width 1 1 | |
| domain: grid min-width 0 with gap 0 1 1 | |
| compose: overflow widens the column 1 0 | |
| compose: wide glyphs 1 0 | |
| fuzz: proportional shares 2000 0 | |
| fuzz: flex placements 5000 0 | |
| fuzz: grid placements 2000 0 | |
| fuzz: row composition 1000 0 | |
| fuzz: textui elements through make-spec 1000 0 | |
| == domain: grid without :min-column-width | |
| input: (5 20 1 3 nil) | |
| textui: (:error "Wrong type argument: number-or-marker-p, nil") | |
| vui: ((:row 0 :column 0 :width 6) (:row 0 :column 1 :width 6) (:row 0 :column 2 :width 6) (:row 1 :column 0 :width 6) (:row 1 :column 1 :width 6)) | |
| == domain: grid min-width 0 with gap 0 | |
| input: (5 20 0 3 0) | |
| textui: (:error "Arithmetic error") | |
| vui: ((:row 0 :column 0 :width 7) (:row 0 :column 1 :width 7) (:row 0 :column 2 :width 6) (:row 1 :column 0 :width 7) (:row 1 :column 1 :width 7)) | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ;;; layout-conformance.el --- textui vs vui-layout geometry comparison -*- lexical-binding: t; -*- | |
| ;; Feeds the same width constraints through textui's layout engine | |
| ;; (partition, allocate, shares, grid) and through vui-layout.el, and | |
| ;; diffs the resulting geometry. Also compares the two row composers | |
| ;; on rendered blocks. This is the conformance set proposed in | |
| ;; yibie/textui#1, plus seeded fuzzing over the same input space. | |
| ;; | |
| ;; Run: | |
| ;; | |
| ;; emacs -Q --batch -L <vui.el checkout> -L <textui checkout> \ | |
| ;; -l layout-conformance.el | |
| ;; | |
| ;; Exit code is the number of categories with mismatches (0 = full | |
| ;; conformance); "domain:" categories document inputs one side rejects | |
| ;; and are not counted. A summary table goes to stdout; the first mismatches | |
| ;; per category are printed with their inputs. | |
| ;; | |
| ;; Spec mapping (vui -> textui): | |
| ;; | |
| ;; :natural N, :min M -> :start max(N, M) (M defaults to 0) | |
| ;; :minimum M (default: :minimum start) | |
| ;; :grow G -> :grow G | |
| ;; :rigid t -> :kind :native (:minimum start) | |
| ;; otherwise -> :kind :text | |
| ;; | |
| ;; textui derives :start as max(declared-or-natural, :min-width) and | |
| ;; :minimum as :min-width or :start; vui-layout raises :natural to an | |
| ;; explicit :min the same way, so both engines occupy at least the | |
| ;; minimum. The element-level fuzz below checks that derivation | |
| ;; through `textui--make-spec' itself. | |
| ;;; Code: | |
| (require 'cl-lib) | |
| (require 'vui-layout) | |
| (require 'textui) | |
| ;;;; Deterministic PRNG (same sequence on every Emacs) | |
| (defvar lc--seed 20260902) | |
| (defun lc-random (n) | |
| "Return a pseudo-random integer in [0, N)." | |
| (setq lc--seed (mod (+ (* lc--seed 1103515245) 12345) 2147483648)) | |
| (mod (/ lc--seed 65536) n)) | |
| (defun lc-pick (list) | |
| "Return a random element of LIST." | |
| (nth (lc-random (length list)) list)) | |
| ;;;; Spec conversion | |
| (defun lc-textui-child (spec) | |
| "Convert a vui-layout SPEC into a textui internal child plist." | |
| (let* ((min (plist-get spec :min)) | |
| (start (max (or (plist-get spec :natural) 0) (or min 0))) | |
| (rigid (plist-get spec :rigid)) | |
| (minimum (if rigid start (or min start)))) | |
| (list :kind (if rigid :native :text) | |
| :start start | |
| :minimum minimum | |
| :grow (or (plist-get spec :grow) 0)))) | |
| (defun lc-vui-spec-from-textui (child) | |
| "Convert a textui internal CHILD plist (from `textui--make-spec') to a vui spec." | |
| (list :natural (plist-get child :start) | |
| :min (plist-get child :minimum) | |
| :grow (plist-get child :grow) | |
| :rigid (eq (plist-get child :kind) :native))) | |
| ;;;; Engine drivers | |
| (defun lc-textui-solve (specs total gap) | |
| "Placements from textui's partition + allocate for vui SPECS." | |
| (let ((children (mapcar #'lc-textui-child specs)) | |
| (row-index 0) | |
| placements) | |
| (dolist (row (textui--partition-row children total gap)) | |
| (let ((column 0)) | |
| (dolist (width (textui--allocate-row row total gap)) | |
| (push (list :row row-index :column column :width width) placements) | |
| (setq column (1+ column)))) | |
| (setq row-index (1+ row-index))) | |
| (nreverse placements))) | |
| (defun lc-textui-grid (count total gap columns min-width) | |
| "Placements from textui's grid math." | |
| (let* ((element (list :columns columns :min-column-width min-width :gap gap)) | |
| (column-count (textui--grid-column-count element total)) | |
| (available (max 0 (- total (* gap (1- column-count))))) | |
| (tracks (textui--proportional-shares available (make-list column-count 1))) | |
| (row-index 0) | |
| placements) | |
| (dolist (row (textui--partition-fixed (number-sequence 1 count) column-count)) | |
| (let ((column 0)) | |
| (dolist (_ row) | |
| (push (list :row row-index :column column :width (nth column tracks)) | |
| placements) | |
| (setq column (1+ column)))) | |
| (setq row-index (1+ row-index))) | |
| (nreverse placements))) | |
| (defun lc-textui-compose (blocks widths gap) | |
| "Row composition as `textui--render-row-line-block' does it after rendering." | |
| (let ((actual (cl-mapcar (lambda (block assigned) | |
| (max assigned (textui--block-width block))) | |
| blocks widths))) | |
| (textui--compose-row-blocks blocks actual gap))) | |
| ;;;; Comparison bookkeeping | |
| (defvar lc--results nil "Alist of (CATEGORY CASES MISMATCHES . EXAMPLES).") | |
| (defun lc-safe (thunk) | |
| "Call THUNK; return its value or (:error MESSAGE)." | |
| (condition-case err | |
| (funcall thunk) | |
| (error (list :error (error-message-string err))))) | |
| (defun lc-compare (category input textui-thunk vui-thunk) | |
| "Record one comparison under CATEGORY for INPUT." | |
| (let* ((entry (or (assoc category lc--results) | |
| (car (push (list category 0 0) lc--results)))) | |
| (a (lc-safe textui-thunk)) | |
| (b (lc-safe vui-thunk))) | |
| (setcar (cdr entry) (1+ (cadr entry))) | |
| (unless (equal a b) | |
| (setcar (cddr entry) (1+ (caddr entry))) | |
| (when (< (length (cdddr entry)) 3) | |
| (setcdr (cddr entry) | |
| (append (cdddr entry) | |
| (list (list :input input :textui a :vui b)))))))) | |
| ;;;; Named conformance cases (yibie/textui#1) | |
| (defun lc-flex-case (name specs total gap) | |
| (lc-compare (format "flex: %s" name) | |
| (list :specs specs :total total :gap gap) | |
| (lambda () (lc-textui-solve specs total gap)) | |
| (lambda () (vui-layout-solve specs total gap)))) | |
| (defun lc-named-cases () | |
| ;; Source-order partitioning at minimum widths. | |
| (lc-flex-case "partition at mins" | |
| '((:natural 12 :min 8) (:natural 12 :min 8) (:natural 12 :min 8)) | |
| 20 1) | |
| (lc-flex-case "partition: oversized first child still gets a row" | |
| '((:natural 30 :min 25) (:natural 5)) | |
| 20 1) | |
| ;; Proportional grow with integer remainder distribution. | |
| (lc-flex-case "grow: remainder to earlier growers" | |
| '((:natural 4 :grow 1) (:natural 4 :grow 1) (:natural 4 :grow 1)) | |
| 22 1) | |
| (lc-flex-case "grow: weighted" | |
| '((:natural 5 :grow 2) (:natural 5 :grow 1) (:natural 5)) | |
| 30 1) | |
| (lc-flex-case "grow: no growers leave surplus unassigned" | |
| '((:natural 5) (:natural 5)) | |
| 30 1) | |
| ;; Proportional shrink by natural - minimum capacity. | |
| (lc-flex-case "shrink by capacity" | |
| '((:natural 20 :min 10) (:natural 20 :min 16) (:natural 20 :min 20)) | |
| 49 2) | |
| ;; Minima that do not fit together split the row instead; a deficit | |
| ;; beyond capacity is therefore only ever a lone child (clamp case). | |
| (lc-flex-case "shrink: minima that do not fit split the row" | |
| '((:natural 20 :min 18) (:natural 20 :min 18)) | |
| 30 1) | |
| ;; A minimum above the natural width raises the occupied width | |
| ;; (textui: start = max(natural, min-width); vui-layout: same). | |
| (lc-flex-case "min above natural raises the occupied width" | |
| '((:natural 3 :min 6) (:natural 2 :grow 1)) | |
| 11 1) | |
| ;; One oversized ordinary block versus one native/rigid block. | |
| (lc-flex-case "lone oversized ordinary block clamps" | |
| '((:natural 20 :min 20)) | |
| 8 1) | |
| (lc-flex-case "lone oversized rigid block keeps natural" | |
| '((:natural 20 :rigid t)) | |
| 8 1) | |
| (lc-flex-case "rigid block never shrinks in a shared row" | |
| '((:natural 10 :rigid t) (:natural 20 :min 5)) | |
| 20 1) | |
| ;; Degenerate widths. | |
| (lc-flex-case "zero total" '((:natural 3) (:natural 4)) 0 1) | |
| (lc-flex-case "zero gap" '((:natural 3 :grow 1) (:natural 4 :grow 1)) 20 0) | |
| (lc-flex-case "zero-width children" '((:natural 0 :grow 1) (:natural 0)) 7 1) | |
| ;; Grid: column count falls, uneven tracks, incomplete last row. | |
| (dolist (case '((7 40 1 4 8) (7 25 1 4 8) (7 10 1 4 8) (5 31 2 3 5) | |
| (3 20 1 2 1) (1 0 1 3 4) (0 20 1 3 4))) | |
| (pcase-let ((`(,count ,total ,gap ,columns ,min-width) case)) | |
| (lc-compare (format "grid: %d cells in %d, gap %d, columns %d, min %d" | |
| count total gap columns min-width) | |
| case | |
| (lambda () (lc-textui-grid count total gap columns min-width)) | |
| (lambda () (vui-layout-grid count total gap columns min-width))))) | |
| ;; Input-domain differences: textui validates :min-column-width as a | |
| ;; positive integer at the element level, so these never reach its | |
| ;; engine; vui-layout accepts them. Reported, not counted. | |
| (lc-compare "domain: grid without :min-column-width" | |
| '(5 20 1 3 nil) | |
| (lambda () (lc-textui-grid 5 20 1 3 nil)) | |
| (lambda () (vui-layout-grid 5 20 1 3 nil))) | |
| (lc-compare "domain: grid min-width 0 with gap 0" | |
| '(5 20 0 3 0) | |
| (lambda () (lc-textui-grid 5 20 0 3 0)) | |
| (lambda () (vui-layout-grid 5 20 0 3 0))) | |
| ;; Composition: unbreakable content wider than its assignment. | |
| (lc-compare "compose: overflow widens the column" | |
| nil | |
| (lambda () (lc-textui-compose '(("short" "a much longer line") ("x" "y" "z")) | |
| '(5 3) 1)) | |
| (lambda () (vui-layout-compose '(("short" "a much longer line") ("x" "y" "z")) | |
| '(5 3) 1))) | |
| (lc-compare "compose: wide glyphs" | |
| nil | |
| (lambda () (lc-textui-compose '(("漢字" "a") ("b")) '(3 2) 2)) | |
| (lambda () (vui-layout-compose '(("漢字" "a") ("b")) '(3 2) 2)))) | |
| ;;;; Fuzzing | |
| (defun lc-random-spec () | |
| (let* ((natural (lc-random 31)) | |
| (rigid (= (lc-random 6) 0)) | |
| (min (pcase (lc-random 3) | |
| (0 nil) | |
| (1 (lc-random (1+ natural))) | |
| (_ (lc-random 31)))) ; may exceed natural: raise rule | |
| (grow (lc-pick '(0 0 0 1 1 2 3 0.5 1.5)))) | |
| (append (list :natural natural) | |
| (when min (list :min min)) | |
| (when (/= grow 0) (list :grow grow)) | |
| (when rigid (list :rigid t))))) | |
| (defun lc-fuzz-flex (n) | |
| (dotimes (_ n) | |
| (let ((specs (cl-loop repeat (1+ (lc-random 8)) collect (lc-random-spec))) | |
| (total (lc-random 80)) | |
| (gap (lc-random 4))) | |
| (lc-compare "fuzz: flex placements" | |
| (list :specs specs :total total :gap gap) | |
| (lambda () (lc-textui-solve specs total gap)) | |
| (lambda () (vui-layout-solve specs total gap)))))) | |
| (defun lc-fuzz-shares (n) | |
| (dotimes (_ n) | |
| (let* ((count (1+ (lc-random 6))) | |
| (amount (lc-random 50)) | |
| (weights (cl-loop repeat count collect (lc-pick '(0 1 1 2 3 0.5 2.5)))) | |
| (limits (when (= (lc-random 2) 0) | |
| (cl-loop repeat count collect (lc-random 20))))) | |
| (lc-compare "fuzz: proportional shares" | |
| (list :amount amount :weights weights :limits limits) | |
| (lambda () (textui--proportional-shares amount weights limits)) | |
| (lambda () (vui-layout-shares amount weights limits)))))) | |
| (defun lc-fuzz-grid (n) | |
| (dotimes (_ n) | |
| (let ((count (lc-random 12)) | |
| (total (lc-random 80)) | |
| (gap (lc-random 4)) | |
| (columns (1+ (lc-random 6))) | |
| (min-width (1+ (lc-random 15)))) | |
| (lc-compare "fuzz: grid placements" | |
| (list :count count :total total :gap gap | |
| :columns columns :min-width min-width) | |
| (lambda () (lc-textui-grid count total gap columns min-width)) | |
| (lambda () (vui-layout-grid count total gap columns min-width)))))) | |
| (defun lc-random-line () | |
| (let ((chars '("a" "b" "c" " " "漢" "字" "-"))) | |
| (mapconcat (lambda (_) (lc-pick chars)) (make-list (lc-random 9) nil) ""))) | |
| (defun lc-fuzz-compose (n) | |
| (dotimes (_ n) | |
| (let* ((count (1+ (lc-random 4))) | |
| (blocks (cl-loop repeat count | |
| collect (cl-loop repeat (lc-random 4) collect (lc-random-line)))) | |
| (widths (cl-loop repeat count collect (lc-random 10))) | |
| (gap (lc-random 3))) | |
| (lc-compare "fuzz: row composition" | |
| (list :blocks blocks :widths widths :gap gap) | |
| (lambda () (lc-textui-compose blocks widths gap)) | |
| (lambda () (vui-layout-compose blocks widths gap)))))) | |
| (defun lc-random-text-element () | |
| "A textui :text element with random public layout options." | |
| (let* ((value (mapconcat #'identity | |
| (cl-loop repeat (1+ (lc-random 3)) collect (lc-random-line)) | |
| "\n")) | |
| (layout (append (when (= (lc-random 3) 0) (list :width (1+ (lc-random 20)))) | |
| (when (= (lc-random 2) 0) (list :min-width (1+ (lc-random 15)))) | |
| (when (= (lc-random 2) 0) (list :grow (lc-pick '(1 2 0.5))))))) | |
| (append (list :type :text :value value) | |
| (when layout (list :layout layout))))) | |
| (defun lc-vui-spec-from-element (element) | |
| "Derive a vui spec from a textui :text ELEMENT by the documented rule." | |
| (let* ((layout (plist-get element :layout)) | |
| (lines (split-string (plist-get element :value) "\n")) | |
| (measured (apply #'max 0 (mapcar #'string-width lines))) | |
| (declared (plist-get layout :width)) | |
| (min-width (plist-get layout :min-width)) | |
| (natural (max (or declared measured) (or min-width 0)))) | |
| (append (list :natural natural) | |
| (when min-width (list :min min-width)) | |
| (when (plist-get layout :grow) (list :grow (plist-get layout :grow)))))) | |
| (defun lc-fuzz-elements (n) | |
| "Check the element-to-spec derivation against `textui--make-spec' itself." | |
| (dotimes (_ n) | |
| (let* ((elements (cl-loop repeat (1+ (lc-random 6)) collect (lc-random-text-element))) | |
| (total (lc-random 80)) | |
| (gap (lc-random 4)) | |
| (textui--next-layout-id nil)) | |
| (lc-compare "fuzz: textui elements through make-spec" | |
| (list :elements elements :total total :gap gap) | |
| (lambda () | |
| (let ((children (mapcar #'textui--make-spec elements)) | |
| (row-index 0) | |
| placements) | |
| (dolist (row (textui--partition-row children total gap)) | |
| (let ((column 0)) | |
| (dolist (width (textui--allocate-row row total gap)) | |
| (push (list :row row-index :column column :width width) | |
| placements) | |
| (setq column (1+ column)))) | |
| (setq row-index (1+ row-index))) | |
| (nreverse placements))) | |
| (lambda () | |
| (vui-layout-solve (mapcar #'lc-vui-spec-from-element elements) | |
| total gap)))))) | |
| ;;;; Report | |
| (defun lc-report () | |
| (let ((failed 0)) | |
| (princ (format "textui %s vs vui-layout.el, Emacs %s\n\n" | |
| (or (getenv "TEXTUI_SHA") "?") emacs-version)) | |
| (princ (format "%-58s %6s %10s\n" "category" "cases" "mismatches")) | |
| (dolist (entry (reverse lc--results)) | |
| (princ (format "%-58s %6d %10d\n" (car entry) (cadr entry) (caddr entry))) | |
| (when (and (> (caddr entry) 0) | |
| (not (string-prefix-p "domain:" (car entry)))) | |
| (setq failed (1+ failed)))) | |
| (princ "\n") | |
| (dolist (entry (reverse lc--results)) | |
| (when (cdddr entry) | |
| (princ (format "== %s\n" (car entry))) | |
| (dolist (example (cdddr entry)) | |
| (princ (format "input: %S\ntextui: %S\nvui: %S\n\n" | |
| (plist-get example :input) | |
| (plist-get example :textui) | |
| (plist-get example :vui)))))) | |
| failed)) | |
| (defun lc-run () | |
| (setq lc--results nil) | |
| (lc-named-cases) | |
| (lc-fuzz-shares 2000) | |
| (lc-fuzz-flex 5000) | |
| (lc-fuzz-grid 2000) | |
| (lc-fuzz-compose 1000) | |
| (lc-fuzz-elements 1000) | |
| (lc-report)) | |
| (when (and noninteractive (not (getenv "LC_NO_RUN"))) | |
| (kill-emacs (lc-run))) | |
| ;;; layout-conformance.el ends here |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment