;;; tp-render-tests.el --- ERT regression tests for tp-render.el -*- lexical-binding: t -*- ;;; Commentary: ;; Regression tests for confirmed bugs fixed in the reactive-render ;; module (tp-render.el, with supporting fixes in tp-reactive.el). ;; Each section is tagged with the canonical bug id it guards against. ;;; Code: (require 'ert) (require 'tp) ;; Reactive test variables must be dynamically bound so watcher and ;; compute machinery can see them through `symbol-value'. (defvar tp-rt-b9-var nil) (defvar tp-rt-b10-data nil) (defvar tp-rt-b10-full nil) (defvar tp-rt-b11-face nil) (defvar tp-rt-b11b-color nil) (defvar tp-rt-b12-color nil) (defvar tp-rt-b13-text nil) (defvar tp-rt-b13b-text nil) (defvar tp-rt-b14-flag nil) (defvar tp-rt-b14-inv nil) (defvar tp-rt-b14b-init nil) (defvar tp-rt-b16-data nil) (defvar tp-rt-b16-comp nil) (defvar tp-rt-b16-count 0) (defvar tp-rt-b17-color nil) (defvar tp-rt-b17-text nil) (defvar tp-rt-b17b-color nil) (defvar tp-rt-b17b-echo nil) (defvar tp-rt-b18-text nil) (defvar tp-rt-b19-amount nil) (defvar tp-rt-b19s-amount nil) (defvar tp-rt-r1-color nil) (defvar tp-rt-r1b-color nil) (defvar tp-rt-r1c-color nil) (defvar tp-rt-r1d-color nil) (defvar tp-rt-r2-text nil) (defvar tp-rt-r2m-text nil) (defvar tp-rt-r2n-text nil) (defvar tp-rt-r2s-text nil) (defvar tp-rt-r3a-color nil) (defvar tp-rt-r3b-color nil) (defvar tp-rt-r3c-color nil) (defvar tp-rt-a02-old-color nil) (defvar tp-rt-a02-new-color nil) (defvar tp-rt-a11-data nil) (defvar tp-rt-a11-computed nil) (defvar tp-rt-a11-watched nil) (defvar tp-rt-a11-text nil) (defmacro tp-rt-with-cleanup (layers vars &rest body) "Run BODY, then undefine LAYERS and reset VARS to nil (teardown)." (declare (indent 2)) `(unwind-protect (progn ,@body) ,@(mapcar (lambda (l) `(tp-undefine-layer ',l)) layers) ,@(mapcar (lambda (v) `(setq ,v nil)) vars) (setq tp-reactive-observer-errors nil))) ;;; B9: sub-region tp-text on a string must splice, not replace the whole string (ert-deftest tp-render-test-tp-text-string-region-keeps-rest () "Region-form tp-text on a string keeps the text outside the region." (let ((result (tp-set 0 1 '(tp-text "X") (copy-sequence "abc")))) (should (equal result "Xbc")) (should (equal (get-text-property 0 'tp-text result) "X")) ;; The preserved suffix must not receive the layer's props (should (null (get-text-property 1 'tp-text result))) (should (null (get-text-property 2 'tp-text result))))) (ert-deftest tp-render-test-tp-text-string-mid-region-splices () "A mid-string tp-text region splices prefix + replacement + suffix." (let ((result (tp-set 1 2 '(face bold tp-text "XY") (copy-sequence "abc")))) (should (equal result "aXYc")) ;; Props only on the replaced span [1, 3) (should (null (get-text-property 0 'face result))) (should (eq (get-text-property 1 'face result) 'bold)) (should (eq (get-text-property 2 'face result) 'bold)) (should (null (get-text-property 3 'face result))))) (ert-deftest tp-render-test-tp-text-string-region-preserves-outside-props () "Splicing keeps the original string's properties outside the region." (let* ((source (propertize "abc" 'face 'italic 'my-prop 1)) (result (tp-set 1 2 '(tp-text "X") source))) (should (equal result "aXc")) ;; Prefix and suffix keep their original props (should (eq (get-text-property 0 'face result) 'italic)) (should (eq (get-text-property 2 'face result) 'italic)) ;; Replaced span preserves non-conflicting props (tp-set preserves) (should (eq (get-text-property 1 'my-prop result) 1)))) (ert-deftest tp-render-test-tp-text-whole-string-still-replaces () "Whole-string form still returns just the replacement (legacy semantics)." (let ((result (tp-set "2" 'face '(:background "green") 'tp-text "6"))) (should (equal result "6")) (should (equal (get-text-property 0 'face result) '(:background "green"))) (should (equal (get-text-property 0 'tp-text result) "6")))) ;;; TP-A01 / TP-A03: initial tp-text application uses per-run properties (ert-deftest tp-render-test-initial-tp-text-keeps-string-property-runs () "Initial string tp-text application does not smear position-zero props." (let* ((payload (concat (propertize "AB" 'face 'bold) (propertize "CD" 'face 'italic))) (result (tp-set "xxxx" 'tp-text payload))) (should (eq (get-text-property 0 'face result) 'bold)) (should (eq (get-text-property 2 'face result) 'italic)))) (ert-deftest tp-render-test-initial-tp-text-keeps-buffer-property-runs () "Initial buffer tp-text application preserves every embedded prop run." (let ((payload (concat (propertize "AB" 'face 'bold) (propertize "CD" 'face 'italic)))) (with-temp-buffer (insert "xxxx") (tp-set 1 5 (list 'tp-text payload)) (should (eq (get-text-property 1 'face) 'bold)) (should (eq (get-text-property 3 'face) 'italic))))) (ert-deftest tp-render-test-tp-text-explicit-nil-overrides-embedded-value () "A caller-provided nil remains present and wins over embedded props." (let* ((payload (propertize "X" 'custom 'embedded)) (result (tp-set "x" 'tp-text payload 'custom nil))) (should (equal (tp-member 0 'custom result) '(custom nil))) (with-temp-buffer (insert "x") (tp-set 1 2 (list 'tp-text payload 'custom nil)) (should (equal (tp-member 1 'custom) '(custom nil)))))) (ert-deftest tp-render-test-same-text-reset-removes-old-properties () "A same-text tp-reset still replaces the complete property set." (with-temp-buffer (insert (propertize "AB" 'help-echo "old" 'face 'italic)) (tp-reset 1 3 '(tp-text "AB" face bold)) (should-not (plist-member (text-properties-at 1) 'help-echo)) (should (eq (get-text-property 1 'face) 'bold))) (let* ((source (propertize "AB" 'help-echo "old" 'face 'italic)) (result (tp-reset source 'tp-text "AB" 'face 'bold))) (should-not (plist-member (text-properties-at 0 result) 'help-echo)) (should (eq (get-text-property 0 'face result) 'bold)))) (ert-deftest tp-render-test-same-text-add-merges-existing-face () "A same-text tp-add keeps add semantics while applying per-run props." (with-temp-buffer (insert (propertize "AB" 'face 'italic)) (tp-add 1 3 '(tp-text "AB" face bold)) (should (equal (get-text-property 1 'face) '(bold italic)))) (let* ((source (propertize "AB" 'face 'italic)) (result (tp-add source 'tp-text "AB" 'face 'bold))) (should (equal (get-text-property 0 'face result) '(bold italic)))) (let* ((source (propertize "AB" 'face 'italic)) (result (tp-add source 'tp-text nil 'face 'bold))) (should (equal (get-text-property 0 'face result) '(bold italic))))) ;;; TP-A02: layer redefinition refreshes with full old/new ownership (ert-deftest tp-render-test-static-redefinition-refreshes-managed-region () "A simple static redefinition refreshes an already-mounted layer." (tp-rt-with-cleanup (tp-rt-a02-static) () (define-tp tp-rt-a02-static () '(face bold help-echo "old")) (with-temp-buffer (insert "Hello") (tp-push-layer 1 6 'tp-rt-a02-static) (define-tp tp-rt-a02-static () '(face italic)) (should (eq (get-text-property 1 'face) 'italic)) (should-not (plist-member (text-properties-at 1) 'help-echo))))) (ert-deftest tp-render-test-redefinition-preserves-external-value () "A value changed after mounting is not deleted as stale layer output." (tp-rt-with-cleanup (tp-rt-a02-external) () (define-tp tp-rt-a02-external () '(face bold help-echo "owned")) (with-temp-buffer (insert "Hello") (tp-push-layer 1 6 'tp-rt-a02-external) (put-text-property 1 6 'help-echo "external") (define-tp tp-rt-a02-external () '(face italic)) (should (eq (get-text-property 1 'face) 'italic)) (should (equal (get-text-property 1 'help-echo) "external"))))) (ert-deftest tp-render-test-reactive-redefinition-removes-old-owned-keys () "Reactive redefinition removes keys and nested face data it no longer owns." (tp-rt-with-cleanup (tp-rt-a02-reactive) (tp-rt-a02-old-color tp-rt-a02-new-color) (setq tp-rt-a02-old-color "red" tp-rt-a02-new-color "blue") (define-tp tp-rt-a02-reactive () :props '(face (:foreground $tp-rt-a02-old-color) help-echo "old")) (with-temp-buffer (insert "Hello") (tp-push-layer 1 6 'tp-rt-a02-reactive) (define-tp tp-rt-a02-reactive () :props '(face (:background $tp-rt-a02-new-color))) (let ((face (get-text-property 1 'face))) (should (equal (plist-get face :background) "blue")) (should-not (plist-member face :foreground))) (should-not (plist-member (text-properties-at 1) 'help-echo))))) ;;; TP-A11: business computations fail; observers are isolated and recorded (ert-deftest tp-render-test-transform-error-propagates () "Transform failures and non-string results both propagate." (tp-rt-with-cleanup (tp-rt-a11-transform tp-rt-a11-nonstring) (tp-rt-a11-text) (setq tp-rt-a11-text "raw") (define-tp tp-rt-a11-transform () :props '(tp-text $tp-rt-a11-text) :transform (lambda (_text) (error "transform failed"))) (should-error (tp-set "old" 'tp-rt-a11-transform) :type 'error) (define-tp tp-rt-a11-nonstring () :props '(tp-text $tp-rt-a11-text) :transform (lambda (_text) 42)) (should-error (tp-set "old" 'tp-rt-a11-nonstring) :type 'error))) (ert-deftest tp-render-test-compute-errors-propagate () "Initial and update-time compute failures reach the caller." (tp-rt-with-cleanup (tp-rt-a11-initial tp-rt-a11-update) (tp-rt-a11-data tp-rt-a11-computed) (should-error (define-tp tp-rt-a11-initial () :props '(help-echo $tp-rt-a11-computed) :compute '((tp-rt-a11-computed (lambda () (error "initial compute failed"))))) :type 'error) (setq tp-rt-a11-data "ok") (define-tp tp-rt-a11-update () :props '(help-echo $tp-rt-a11-computed) :data '(tp-rt-a11-data) :compute '((tp-rt-a11-computed (lambda () (if (equal tp-rt-a11-data "ok") "ready" (error "update compute failed")))))) (should-error (setq tp-rt-a11-data "fail") :type 'error))) (ert-deftest tp-render-test-watcher-error-is-recorded-and-update-continues () "Watcher failures are isolated, queryable, and do not block rendering." (tp-rt-with-cleanup (tp-rt-a11-watcher) (tp-rt-a11-watched) (setq tp-rt-a11-watched "red") (when (boundp 'tp-reactive-observer-errors) (set 'tp-reactive-observer-errors nil)) (define-tp tp-rt-a11-watcher () :props '(face (:foreground $tp-rt-a11-watched)) :watch '((tp-rt-a11-watched (lambda (_new _old _layer) (error "watcher failed"))))) (with-temp-buffer (insert "x") (tp-push-layer 1 2 'tp-rt-a11-watcher) (setq tp-rt-a11-watched "blue") (should (equal (plist-get (tp-at 1 'face) :foreground) "blue")) (should (boundp 'tp-reactive-observer-errors)) (let ((failure (car (symbol-value 'tp-reactive-observer-errors)))) (should (eq (plist-get failure :kind) 'watcher)) (should (eq (plist-get failure :layer) 'tp-rt-a11-watcher)) (should (eq (plist-get failure :symbol) 'tp-rt-a11-watched)) (should (eq (car (plist-get failure :condition)) 'error)))))) ;;; B10: computed-variable path must not clobber sibling static attributes (ert-deftest tp-render-test-computed-update-keeps-static-siblings () "A computed update deep-merges, keeping static nested attributes." (tp-rt-with-cleanup (tp-rt-b10-layer) (tp-rt-b10-data tp-rt-b10-full) (setq tp-rt-b10-data "red") (define-tp tp-rt-b10-layer () :props '(face (:foreground $tp-rt-b10-full :background "green")) :data '(tp-rt-b10-data) :compute '((tp-rt-b10-full (lambda () (concat "col-" tp-rt-b10-data))))) (setq tp-rt-b10-data "blue") (let ((face (plist-get (cdr (assoc 'tp-rt-b10-layer tp-layer-alist)) 'face))) (should (equal (plist-get face :foreground) "col-blue")) ;; The sibling static attribute must survive the update (should (equal (plist-get face :background) "green"))))) ;;; B11: reactive refresh replaces the layer's own keys instead of accumulating (ert-deftest tp-render-test-reactive-refresh-replaces-face () "Changing a symbol-valued face variable replaces the face, not stacks it." (tp-rt-with-cleanup (tp-rt-b11-layer) (tp-rt-b11-face) (setq tp-rt-b11-face 'bold) (define-tp tp-rt-b11-layer () '(face $tp-rt-b11-face)) (with-temp-buffer (insert "Hello") (tp-set 1 6 'tp-rt-b11-layer) (should (eq (get-text-property 1 'face) 'bold)) (setq tp-rt-b11-face 'italic) ;; Must be italic alone, not (italic bold) (should (eq (get-text-property 1 'face) 'italic))))) (ert-deftest tp-render-test-reactive-refresh-keeps-unrelated-props () "Reactive refresh leaves property keys the layer does not own alone." (tp-rt-with-cleanup (tp-rt-b11b-layer) (tp-rt-b11b-color) (setq tp-rt-b11b-color "red") (define-tp tp-rt-b11b-layer () '(face (:foreground $tp-rt-b11b-color))) (with-temp-buffer (insert "Hello") (tp-set 1 6 'tp-rt-b11b-layer) (put-text-property 1 6 'help-echo "keep me") (setq tp-rt-b11b-color "green") (should (equal (plist-get (get-text-property 1 'face) :foreground) "green")) (should (equal (get-text-property 1 'help-echo) "keep me"))))) ;;; B12: setq-local must not leak into the global layer definition (ert-deftest tp-render-test-setq-local-does-not-touch-global-def () "A buffer-local change re-renders the buffer but keeps the global def." (tp-rt-with-cleanup (tp-rt-b12-layer) () (setq-default tp-rt-b12-color "red") (define-tp tp-rt-b12-layer () '(face (:foreground $tp-rt-b12-color))) (let ((buf-a (generate-new-buffer " tp-rt-b12-a")) (buf-b (generate-new-buffer " tp-rt-b12-b"))) (unwind-protect (progn (with-current-buffer buf-a (insert "Hello") (tp-set 1 6 'tp-rt-b12-layer)) (with-current-buffer buf-b (insert "Hello") (tp-set 1 6 'tp-rt-b12-layer)) (with-current-buffer buf-a (setq-local tp-rt-b12-color "purple")) ;; Buffer A is re-rendered with its local value (with-current-buffer buf-a (should (equal (plist-get (get-text-property 1 'face) :foreground) "purple"))) ;; The GLOBAL definition must not absorb the local value (should (equal (plist-get (plist-get (cdr (assoc 'tp-rt-b12-layer tp-layer-alist)) 'face) :foreground) "red")) (should (equal (default-value 'tp-rt-b12-color) "red")) ;; Other buffers keep rendering the global value (with-current-buffer buf-b (should (equal (plist-get (get-text-property 1 'face) :foreground) "red")))) (kill-buffer buf-a) (kill-buffer buf-b) (setq-default tp-rt-b12-color nil))))) ;;; B13: reactive tp-text replacement preserves unrelated properties (ert-deftest tp-render-test-reactive-text-update-preserves-other-props () "Replacing reactive text keeps properties other layers put on the region." (tp-rt-with-cleanup (tp-rt-b13-layer) (tp-rt-b13-text) (setq tp-rt-b13-text "aaa") (define-tp tp-rt-b13-layer () '(face bold tp-text $tp-rt-b13-text)) (with-temp-buffer (insert "Hello") (tp-set 1 6 'tp-rt-b13-layer) (put-text-property 1 3 'my-other-prop 42) (setq tp-rt-b13-text "bbb") (should (equal (buffer-substring-no-properties (point-min) (point-max)) "bbb")) ;; The unrelated property survives the text replacement (should (eq (get-text-property 1 'my-other-prop) 42)) ;; The layer's own props are still applied (should (eq (get-text-property 1 'face) 'bold))))) (ert-deftest tp-render-test-reactive-text-same-text-preserves-other-props () "A same-text properties-only update keeps unrelated properties too." (tp-rt-with-cleanup (tp-rt-b13b-layer) (tp-rt-b13b-text) (setq tp-rt-b13b-text "emacs") (define-tp tp-rt-b13b-layer () '(tp-text $tp-rt-b13b-text)) (with-temp-buffer (insert "emacs") (tp-set 1 6 'tp-rt-b13b-layer) (put-text-property 1 6 'my-other-prop 'yes) ;; Same text, new embedded properties (setq tp-rt-b13b-text (propertize "emacs" 'face 'bold)) (should (eq (get-text-property 1 'face) 'bold)) (should (eq (get-text-property 1 'my-other-prop) 'yes))))) ;;; B14: computed values of nil must propagate (ert-deftest tp-render-test-computed-nil-propagates-on-update () "A compute function returning nil updates the variable and the layer." (tp-rt-with-cleanup (tp-rt-b14-layer) (tp-rt-b14-flag tp-rt-b14-inv) (setq tp-rt-b14-flag t) (define-tp tp-rt-b14-layer () :props '(invisible $tp-rt-b14-inv) :data '(tp-rt-b14-flag) :compute '((tp-rt-b14-inv (lambda () tp-rt-b14-flag)))) (should (eq tp-rt-b14-inv t)) (setq tp-rt-b14-flag nil) ;; nil is a legitimate computed value, not an error sentinel (should (eq tp-rt-b14-inv nil)) (should (eq (plist-get (cdr (assoc 'tp-rt-b14-layer tp-layer-alist)) 'invisible) nil)))) (ert-deftest tp-render-test-computed-nil-applies-initially () "An initial computed value of nil overwrites a stale non-nil value." (tp-rt-with-cleanup (tp-rt-b14b-layer) (tp-rt-b14b-init) (setq tp-rt-b14b-init 'stale) (define-tp tp-rt-b14b-layer () :props '(invisible $tp-rt-b14b-init) :compute '((tp-rt-b14b-init (lambda () nil)))) (should (eq tp-rt-b14b-init nil)))) ;;; B16: no watcher recursion from nested variable writes (ert-deftest tp-render-test-compute-runs-once-per-change () "One data change runs each compute function exactly once (no recursion)." (tp-rt-with-cleanup (tp-rt-b16-layer) (tp-rt-b16-data tp-rt-b16-comp) (setq tp-rt-b16-data "a" tp-rt-b16-count 0) (define-tp tp-rt-b16-layer () :props '(help-echo $tp-rt-b16-comp) :data '(tp-rt-b16-data) :compute '((tp-rt-b16-comp (lambda () (setq tp-rt-b16-count (1+ tp-rt-b16-count)) (concat tp-rt-b16-data "!"))))) (with-temp-buffer (insert "Hello") (tp-set 1 6 'tp-rt-b16-layer) (setq tp-rt-b16-count 0) (setq tp-rt-b16-data "b") ;; The nested (set comp ...) must queue its re-render, not re-enter ;; the compute machinery. (should (= tp-rt-b16-count 1)) ;; The nested change's re-render still lands in the buffer (should (equal tp-rt-b16-comp "b!")) (should (equal (get-text-property 1 'help-echo) "b!"))))) ;;; B17: batched entries must union WHERE and the tp-text-affected flag (ert-deftest tp-render-test-batch-tp-text-flag-is-sticky () "A tp-text change deferred after a non-tp-text change still replaces text." (tp-rt-with-cleanup (tp-rt-b17-layer) (tp-rt-b17-color tp-rt-b17-text) (setq tp-rt-b17-color "red" tp-rt-b17-text "one") (define-tp tp-rt-b17-layer () '(face (:foreground $tp-rt-b17-color) tp-text $tp-rt-b17-text)) (with-temp-buffer (insert "one") (tp-set 1 4 'tp-rt-b17-layer) (tp-with-batch-updates (setq tp-rt-b17-color "blue") ; first change: no tp-text (setq tp-rt-b17-text "two")) ; second change: tp-text affected (should (equal (buffer-substring-no-properties (point-min) (point-max)) "two")) (should (equal (plist-get (get-text-property 1 'face) :foreground) "blue"))))) (ert-deftest tp-render-test-batch-where-widens-to-all-buffers () "A global change after a buffer-local one must reach other buffers." (tp-rt-with-cleanup (tp-rt-b17b-layer) () (setq-default tp-rt-b17b-color "red") (setq-default tp-rt-b17b-echo "old") (define-tp tp-rt-b17b-layer () '(face (:foreground $tp-rt-b17b-color) help-echo $tp-rt-b17b-echo)) (let ((buf-a (generate-new-buffer " tp-rt-b17b-a")) (buf-b (generate-new-buffer " tp-rt-b17b-b"))) (unwind-protect (progn (with-current-buffer buf-a (insert "Hello") (tp-set 1 6 'tp-rt-b17b-layer)) (with-current-buffer buf-b (insert "Hello") (tp-set 1 6 'tp-rt-b17b-layer)) (with-current-buffer buf-a (tp-with-batch-updates (setq-local tp-rt-b17b-color "blue") ; WHERE = buf-a (setq tp-rt-b17b-echo "new"))) ; WHERE = global ;; The global change must not be trapped in buf-a's WHERE (with-current-buffer buf-b (should (equal (get-text-property 1 'help-echo) "new")) (should (equal (plist-get (get-text-property 1 'face) :foreground) "red"))) ;; buf-a gets both, with its local color honored (with-current-buffer buf-a (should (equal (get-text-property 1 'help-echo) "new")) (should (equal (plist-get (get-text-property 1 'face) :foreground) "blue")))) (kill-buffer buf-a) (kill-buffer buf-b) (setq-default tp-rt-b17b-color nil) (setq-default tp-rt-b17b-echo nil))))) ;;; B18: multi-interval reactive strings keep per-interval styling (ert-deftest tp-render-test-reactive-text-keeps-per-interval-props () "A propertized reactive string renders each interval's own props." (tp-rt-with-cleanup (tp-rt-b18-layer) (tp-rt-b18-text) (setq tp-rt-b18-text "init") (define-tp tp-rt-b18-layer () '(tp-text $tp-rt-b18-text)) (with-temp-buffer (insert "init") (tp-set 1 5 'tp-rt-b18-layer) (setq tp-rt-b18-text (concat (propertize "AB" 'face 'bold) (propertize "CD" 'face 'italic))) (should (equal (buffer-substring-no-properties (point-min) (point-max)) "ABCD")) ;; Position-0 props must not smear over the whole region (should (eq (get-text-property 1 'face) 'bold)) (should (eq (get-text-property 2 'face) 'bold)) (should (eq (get-text-property 3 'face) 'italic)) (should (eq (get-text-property 4 'face) 'italic))))) ;;; B19: :transform applies on the initial nil-tp-text render too (ert-deftest tp-render-test-transform-applies-on-initial-render () "First render of a nil tp-text layer shows the transformed text." (tp-rt-with-cleanup (tp-rt-b19-layer) (tp-rt-b19-amount) (setq tp-rt-b19-amount nil) (define-tp tp-rt-b19-layer () :props '(face bold tp-text $tp-rt-b19-amount) :transform (lambda (s) (concat "$" s))) (with-temp-buffer (insert "5.00") (tp-set 1 5 'tp-rt-b19-layer) ;; Initial rendering must match later reactive renderings (should (equal (buffer-substring-no-properties (point-min) (point-max)) "$5.00")) ;; The model (variable and tp-text prop) keeps the raw value (should (equal tp-rt-b19-amount "5.00")) (should (equal (get-text-property 1 'tp-text) "5.00")) (should (eq (get-text-property 1 'face) 'bold)) ;; And a later update stays consistent (setq tp-rt-b19-amount "6.00") (should (equal (buffer-substring-no-properties (point-min) (point-max)) "$6.00"))))) (ert-deftest tp-render-test-transform-applies-on-initial-string-render () "String form of a nil tp-text layer also shows the transformed text." (tp-rt-with-cleanup (tp-rt-b19s-layer) (tp-rt-b19s-amount) (setq tp-rt-b19s-amount nil) (define-tp tp-rt-b19s-layer () :props '(face bold tp-text $tp-rt-b19s-amount) :transform (lambda (s) (concat "$" s))) (let ((result (tp-set "5.00" 'tp-rt-b19s-layer))) (should (equal result "$5.00")) ;; Model keeps the raw value (should (equal tp-rt-b19s-amount "5.00")) (should (equal (get-text-property 0 'tp-text result) "5.00")) (should (eq (get-text-property 0 'face result) 'bold))))) ;;; R1 (0.3.0): reactive buffer registry replaces the buffer-list scan (ert-deftest tp-render-test-registry-update-visits-only-registered () "A reactive update walks only registered buffers, not `buffer-list'." (tp-rt-with-cleanup (tp-rt-r1-layer) (tp-rt-r1-color) (setq tp-rt-r1-color "red") (define-tp tp-rt-r1-layer () '(face (:foreground $tp-rt-r1-color))) (let ((buf-a (generate-new-buffer " tp-rt-r1-a")) (buf-b (generate-new-buffer " tp-rt-r1-b")) (visited nil)) (unwind-protect (progn (with-current-buffer buf-a (insert "Hello") (tp-set 1 6 'tp-rt-r1-layer)) (with-current-buffer buf-b (insert "Hello")) ;; Applying through tp-ops registered the buffer (should (equal (tp-reactive-layer-buffers 'tp-rt-r1-layer) (list buf-a))) ;; Count per-buffer visits of the update walk (let ((orig (symbol-function 'tp--render-visit-buffer))) (cl-letf (((symbol-function 'tp--render-visit-buffer) (lambda (buf fn) (push buf visited) (funcall orig buf fn)))) (setq tp-rt-r1-color "blue"))) ;; Only the registered buffer was visited (should (equal visited (list buf-a))) (with-current-buffer buf-a (should (equal (plist-get (get-text-property 1 'face) :foreground) "blue")))) (kill-buffer buf-a) (kill-buffer buf-b))))) (ert-deftest tp-render-test-registry-prunes-on-kill-buffer () "Killing a buffer removes it from the layer-buffer registry." (tp-rt-with-cleanup (tp-rt-r1b-layer) (tp-rt-r1b-color) (setq tp-rt-r1b-color "red") (define-tp tp-rt-r1b-layer () '(face (:foreground $tp-rt-r1b-color))) (let ((buf (generate-new-buffer " tp-rt-r1b"))) (unwind-protect (progn (with-current-buffer buf (insert "Hello") (tp-set 1 6 'tp-rt-r1b-layer)) (should (equal (tp-reactive-layer-buffers 'tp-rt-r1b-layer) (list buf))) (kill-buffer buf) ;; The kill-buffer hook pruned the raw registry entry ... (should-not (memq buf (gethash 'tp-rt-r1b-layer tp--layer-buffers))) ;; ... and the accessor answers "known: none", NOT `unknown'. (should (null (tp-reactive-layer-buffers 'tp-rt-r1b-layer))) (should-not (eq (tp-reactive-layer-buffers 'tp-rt-r1b-layer) 'unknown))) (when (buffer-live-p buf) (kill-buffer buf)))))) (ert-deftest tp-render-test-registry-unknown-full-scan-learns () "An `unknown' layer falls back to a full scan and learns its buffers." (tp-rt-with-cleanup (tp-rt-r1c-layer) (tp-rt-r1c-color) (setq tp-rt-r1c-color "red") (define-tp tp-rt-r1c-layer () '(face (:foreground $tp-rt-r1c-color))) (let ((buf (generate-new-buffer " tp-rt-r1c"))) (unwind-protect (progn (with-current-buffer buf (insert "Hello") (tp-set 1 6 'tp-rt-r1c-layer)) ;; Simulate a buffer that got the layer outside the ;; registering paths: erase the registry knowledge. (remhash 'tp-rt-r1c-layer tp--layer-buffers) (should (eq (tp-reactive-layer-buffers 'tp-rt-r1c-layer) 'unknown)) ;; The update still reaches the buffer (conservative fallback) (setq tp-rt-r1c-color "blue") (with-current-buffer buf (should (equal (plist-get (get-text-property 1 'face) :foreground) "blue"))) ;; ... and the scan registered the buffer it found (learning) (should (equal (tp-reactive-layer-buffers 'tp-rt-r1c-layer) (list buf)))) (kill-buffer buf))))) (ert-deftest tp-render-test-track-buffer-closes-string-insert-gap () "`tp-reactive-track-buffer' registers a buffer filled by string insert." (tp-rt-with-cleanup (tp-rt-r1d-layer) (tp-rt-r1d-color) (setq tp-rt-r1d-color "red") (define-tp tp-rt-r1d-layer () '(face (:foreground $tp-rt-r1d-color))) (let ((buf-a (generate-new-buffer " tp-rt-r1d-a")) (buf-b (generate-new-buffer " tp-rt-r1d-b"))) (unwind-protect (progn (with-current-buffer buf-a (insert "Hello") (tp-set 1 6 'tp-rt-r1d-layer)) ;; Inserting an already-propertized STRING bypasses the ;; registering buffer operations. (let ((s (tp-set "Hi" 'tp-rt-r1d-layer))) (with-current-buffer buf-b (insert s))) (should-not (memq buf-b (tp-reactive-layer-buffers 'tp-rt-r1d-layer))) ;; The layer is known, so buf-b is NOT updated (the gap) ... (setq tp-rt-r1d-color "blue") (with-current-buffer buf-b (should (equal (plist-get (get-text-property 1 'face) :foreground) "red"))) ;; ... until tp-reactive-track-buffer closes it. (should (equal (with-current-buffer buf-b (tp-reactive-track-buffer)) '(tp-rt-r1d-layer))) (should (memq buf-b (tp-reactive-layer-buffers 'tp-rt-r1d-layer))) (setq tp-rt-r1d-color "green") (with-current-buffer buf-b (should (equal (plist-get (get-text-property 1 'face) :foreground) "green"))) (with-current-buffer buf-a (should (equal (plist-get (get-text-property 1 'face) :foreground) "green")))) (kill-buffer buf-a) (kill-buffer buf-b))))) ;;; R2 (0.3.0): minimal-diff tp-text replacement (ert-deftest tp-render-test-minimal-diff-point-in-prefix-stays () "Point in the common prefix survives a reactive text edit unmoved." (tp-rt-with-cleanup (tp-rt-r2-layer) (tp-rt-r2-text) (setq tp-rt-r2-text "abcdef") (define-tp tp-rt-r2-layer () '(tp-text $tp-rt-r2-text)) (with-temp-buffer (insert "abcdef") (tp-set 1 7 'tp-rt-r2-layer) (goto-char 2) ; inside the common prefix "ab" (setq tp-rt-r2-text "abXYef") (should (equal (buffer-substring-no-properties (point-min) (point-max)) "abXYef")) (should (= (point) 2))))) (ert-deftest tp-render-test-minimal-diff-point-in-suffix-stays () "Point in the common suffix stays glued to its character." (tp-rt-with-cleanup (tp-rt-r2-layer) (tp-rt-r2-text) (setq tp-rt-r2-text "abcdef") (define-tp tp-rt-r2-layer () '(tp-text $tp-rt-r2-text)) (with-temp-buffer (insert "abcdef") (tp-set 1 7 'tp-rt-r2-layer) (goto-char 6) ; on the "f" of the suffix "ef" ;; Same-length edit: point must not move at all (setq tp-rt-r2-text "abXYef") (should (= (point) 6)) (should (eq (char-after) ?f)) ;; Length-changing edit: point stays glued to its character (setq tp-rt-r2-text "abXYZWef") (should (= (point) 8)) (should (eq (char-after) ?f))))) (ert-deftest tp-render-test-minimal-diff-point-inside-diff-clamps () "Point inside the differing span ends up at the edit start." (tp-rt-with-cleanup (tp-rt-r2-layer) (tp-rt-r2-text) (setq tp-rt-r2-text "abcdef") (define-tp tp-rt-r2-layer () '(tp-text $tp-rt-r2-text)) (with-temp-buffer (insert "abcdef") (tp-set 1 7 'tp-rt-r2-layer) (goto-char 4) ; on "d", inside the "cd" -> "XY" span (setq tp-rt-r2-text "abXYef") (should (= (point) 3))))) (ert-deftest tp-render-test-minimal-diff-markers-survive () "Markers in the unchanged prefix and suffix survive a text update." (tp-rt-with-cleanup (tp-rt-r2m-layer) (tp-rt-r2m-text) (setq tp-rt-r2m-text "abcdef") (define-tp tp-rt-r2m-layer () '(tp-text $tp-rt-r2m-text)) (with-temp-buffer (insert "abcdef") (tp-set 1 7 'tp-rt-r2m-layer) (let ((m-prefix (copy-marker 2)) ; on "b" (m-suffix (copy-marker 6))) ; on "f" (setq tp-rt-r2m-text "abXYZef") ; "cd" -> "XYZ", one char longer (should (equal (buffer-substring-no-properties (point-min) (point-max)) "abXYZef")) (should (= (marker-position m-prefix) 2)) (should (eq (char-after m-prefix) ?b)) (should (= (marker-position m-suffix) 7)) (should (eq (char-after m-suffix) ?f)) (set-marker m-prefix nil) (set-marker m-suffix nil))))) ;;; TXT-1: the suffix-boundary marker must track its character (defun tp-rt--txt1-marker-after-edit (old new marker-offset) "Run a minimal-diff replacement of OLD by NEW with a boundary marker. Insert \"HEAD \" OLD \" TAIL\" in a temp buffer, tag OLD with a tp-name, put an insertion-type-nil marker at OLD's start plus MARKER-OFFSET, replace via `tp--replace-reactive-text-in-buffer' and return (MARKER-POSITION CHAR-AT-MARKER ORIGINAL-CHAR)." (with-temp-buffer (insert "HEAD ") (let ((m-start (point))) (insert old " TAIL") (put-text-property m-start (+ m-start (length old)) 'tp-name 'tp-rt-txt1-layer) (let* ((mpos (+ m-start marker-offset)) (mchar (char-after mpos)) (mk (copy-marker mpos))) (tp--replace-reactive-text-in-buffer 'tp-rt-txt1-layer new nil) (prog1 (list (marker-position mk) (char-after mk) mchar) (set-marker mk nil)))))) (ert-deftest tp-render-test-minimal-diff-suffix-start-marker-tracks () "A marker on the FIRST character of the preserved suffix tracks it. TXT-1: delete-then-insert collapsed such a marker onto the edit start, stranding it before the inserted text; insert-then-delete shifts it right with its character. Grow, same-length (the clearest docstring violation) and shrink edits are all covered." ;; Grow: "0" -> "42"; marker on the space before "items" (offset 8). (pcase-let ((`(,pos ,got ,want) (tp-rt--txt1-marker-after-edit "count: 0 items" "count: 42 items" 8))) (should (eq got want)) (should (= pos 15))) ; 14 shifted right by 1 ;; Same length: "0" -> "9"; the marker's correct position is ;; numerically unchanged. (pcase-let ((`(,pos ,got ,want) (tp-rt--txt1-marker-after-edit "count: 0 items" "count: 9 items" 8))) (should (eq got want)) (should (= pos 14))) ;; Shrink: "42" -> "0". (pcase-let ((`(,pos ,got ,want) (tp-rt--txt1-marker-after-edit "count: 42 items" "count: 0 items" 9))) (should (eq got want)) (should (= pos 14)))) (ert-deftest tp-render-test-minimal-diff-deleted-char-marker-at-edit-end () "A marker whose character was deleted ends at the END of the edit. The documented side effect of inserting before deleting; previously such markers collapsed to the edit start. Either way they stay inside the replacement span." ;; "100" -> "42": marker on the middle "0" (strictly inside the ;; edited span) ends after the inserted "42". (pcase-let ((`(,pos ,_got ,_want) (tp-rt--txt1-marker-after-edit "count: 100 items" "count: 42 items" 8))) ;; Edit span starts at buffer position 13 ("100"), insert "42": ;; the marker lands at the end of the inserted text. (should (= pos 15)))) (ert-deftest tp-render-test-minimal-diff-suffix-marker-real-path () "The suffix-start marker tracks through a real setq-driven update." (tp-rt-with-cleanup (tp-rt-r2s-layer) (tp-rt-r2s-text) (setq tp-rt-r2s-text "count: 0 items") (define-tp tp-rt-r2s-layer () '(tp-text $tp-rt-r2s-text)) (with-temp-buffer (insert "count: 0 items") (tp-set 1 15 'tp-rt-r2s-layer) (let ((m (copy-marker 9))) ; the space before "items" (setq tp-rt-r2s-text "count: 42 items") (should (equal (buffer-substring-no-properties (point-min) (point-max)) "count: 42 items")) (should (eq (char-after m) ?\s)) (should (= (marker-position m) 10)) (set-marker m nil))))) (ert-deftest tp-render-test-minimal-diff-identical-update-is-noop () "An identical-text reactive replacement leaves the buffer unmodified." (tp-rt-with-cleanup (tp-rt-r2n-layer) (tp-rt-r2n-text) (setq tp-rt-r2n-text "emacs") (define-tp tp-rt-r2n-layer () '(face bold tp-text $tp-rt-r2n-text)) (with-temp-buffer (insert "emacs") (tp-set 1 6 'tp-rt-r2n-layer) (set-buffer-modified-p nil) (save-excursion (tp--replace-reactive-text-in-buffer 'tp-rt-r2n-layer "emacs" (tp-layer-props 'tp-rt-r2n-layer t))) ;; No text edit and no property churn: the flag must stay clear (should-not (buffer-modified-p)) (should (equal (buffer-substring-no-properties (point-min) (point-max)) "emacs")) (should (eq (get-text-property 1 'face) 'bold))))) ;;; ARCH-4: the pending queue must survive neither reset nor errors (defvar tp-rt-a4-face nil) (defvar tp-rt-a4-color nil) (ert-deftest tp-render-test-reactive-reset-clears-pending-queue () "tp-reactive-reset drops queued batch re-renders (ARCH-4). Stranded entries would otherwise survive the reset and replay against freshly (re)defined layers on the next flush." (unwind-protect (progn (tp--queue-batch-update 'tp-rt-a4-ghost 'tp-rt-a4-ghost-var nil nil) (should tp--batch-update-pending) (tp-reactive-reset) (should (null tp--batch-update-pending))) (setq tp--batch-update-pending nil))) (ert-deftest tp-render-test-error-escaping-update-flushes-nested-queue () "An error escaping a re-render cannot strand nested queued updates. A modification hook that writes a second reactive variable and then signals used to strand the nested entry in the global queue - the flush tail sat outside any unwind-protect. The flush now runs as the update unwinds, so the nested variable's re-render still lands and the queue is drained (ARCH-4)." (setq tp-rt-a4-face 'bold tp-rt-a4-color "red") (unwind-protect (progn (define-tp tp-rt-a4-layer-a () '(face $tp-rt-a4-face)) (define-tp tp-rt-a4-layer-b () '(face (:foreground $tp-rt-a4-color))) (with-temp-buffer (insert "Hello world") (tp-set 1 6 'tp-rt-a4-layer-a) (tp-set 7 12 'tp-rt-a4-layer-b) (let ((armed t)) (add-hook 'before-change-functions (lambda (_beg _end) (when armed (setq armed nil) ;; Nested reactive write from within the ;; re-render: goes to the global queue. (setq tp-rt-a4-color "green") (error "boom from modification hook"))) nil t) (should-error (setq tp-rt-a4-face 'italic)) ;; The nested entry was flushed on the way out, not ;; stranded... (should (null tp--batch-update-pending)) ;; ...and its re-render landed despite the error. (should (equal (get-text-property 7 'face) '(:foreground "green")))))) (tp-undefine-layer 'tp-rt-a4-layer-a) (tp-undefine-layer 'tp-rt-a4-layer-b) (setq tp-rt-a4-face nil tp-rt-a4-color nil tp--batch-update-pending nil))) ;;; R3 (0.3.0): anonymous-layer garbage collection (ert-deftest tp-render-test-gc-collects-unreferenced-anonymous-layer () "GC collects an anonymous layer whose only buffer was killed." (setq tp-rt-r3a-color "red") (let ((buf (generate-new-buffer " tp-rt-r3a")) (name nil)) (unwind-protect (progn (with-current-buffer buf (insert "Hello") (tp-set 1 6 '(face (:foreground $tp-rt-r3a-color))) (setq name (get-text-property 1 'tp-name))) (should name) (should (assoc name tp-layer-alist)) (kill-buffer buf) (should (memq name (tp-gc-anonymous-layers))) (should-not (assoc name tp-layer-alist)) (should-not (rassq name tp--anonymous-layer-registry))) (when (buffer-live-p buf) (kill-buffer buf)) (when (and name (assoc name tp-layer-alist)) (tp-undefine-layer name)) (setq tp-rt-r3a-color nil)))) (ert-deftest tp-render-test-gc-keeps-layer-still-displayed () "GC keeps an anonymous layer that a live buffer still shows." (setq tp-rt-r3b-color "red") (let ((buf (generate-new-buffer " tp-rt-r3b")) (name nil)) (unwind-protect (progn (with-current-buffer buf (insert "Hello") (tp-set 1 6 '(face (:foreground $tp-rt-r3b-color))) (setq name (get-text-property 1 'tp-name))) (should name) (should-not (memq name (tp-gc-anonymous-layers))) (should (assoc name tp-layer-alist))) (kill-buffer buf) (when (and name (assoc name tp-layer-alist)) (tp-undefine-layer name)) (setq tp-rt-r3b-color nil)))) (ert-deftest tp-render-test-gc-keeps-unknown-registry-layer () "GC keeps an anonymous layer whose registry state is `unknown'." (setq tp-rt-r3c-color "red") (let* ((s (tp-set "Hello" '(face (:foreground $tp-rt-r3c-color)))) (name (get-text-property 0 'tp-name s))) (unwind-protect (progn (should name) ;; Applied to a string only: the registry knows nothing (should (eq (tp-reactive-layer-buffers name) 'unknown)) (should-not (memq name (tp-gc-anonymous-layers))) (should (assoc name tp-layer-alist))) (when (and name (assoc name tp-layer-alist)) (tp-undefine-layer name)) (setq tp-rt-r3c-color nil)))) ;;; GC-1: buried and hidden layers are ALIVE for GC and track-buffer (defvar tp-rt-gc1-color nil) (defvar tp-rt-gc1b-color nil) (defvar tp-rt-gc1c-color nil) (ert-deftest tp-render-test-gc-keeps-layer-buried-under-push () "GC keeps an anonymous layer buried below a pushed top layer. The buried layer's tp-name lives inside `tp-layers' storage, not as a direct property; the stack-aware liveness scan must still see it, and reactivity must survive a later pop (GC-1)." (setq tp-rt-gc1-color "blue") (let ((buf (generate-new-buffer " tp-rt-gc1")) (name nil)) (unwind-protect (progn (define-tp tp-rt-gc1-top () '(face bold)) (with-current-buffer buf (insert "0123456789") (tp-set 1 6 '(face (:foreground $tp-rt-gc1-color))) (setq name (get-text-property 1 'tp-name)) (should name) (tp-push-layer 1 6 'tp-rt-gc1-top) ;; Now buried: direct tp-name is the pushed top's. (should (eq (get-text-property 1 'tp-name) 'tp-rt-gc1-top)) ;; The buffer is live and still holds the layer: GC must ;; keep it. (should-not (memq name (tp-gc-anonymous-layers))) (should (assoc name tp-layer-alist)) ;; Reactivity survives: pop and update. (tp-pop-layer 1 6) (setq tp-rt-gc1-color "red") (should (equal (get-text-property 1 'face) '(:foreground "red"))))) (kill-buffer buf) (when (and name (assoc name tp-layer-alist)) (tp-undefine-layer name)) (tp-undefine-layer 'tp-rt-gc1-top) (setq tp-rt-gc1-color nil)))) (ert-deftest tp-render-test-gc-keeps-hidden-layer () "GC keeps an anonymous layer hidden via tp-hide-layer. An all-hidden run carries no direct tp-name at all; the layer lives only inside `tp-layers' storage yet is queryable and re-showable, so GC must not collect it and show+setq must still re-render (GC-1, XM-02)." (setq tp-rt-gc1b-color "green") (let ((buf (generate-new-buffer " tp-rt-gc1b")) (name nil)) (unwind-protect (with-current-buffer buf (insert "abcdefghij") (tp-set 1 6 '(face (:foreground $tp-rt-gc1b-color))) (setq name (get-text-property 1 'tp-name)) (should name) (tp-hide-layer 1 6 name) (should-not (get-text-property 1 'tp-name)) ;; Live buffer still holds the hidden layer: keep it. (should-not (memq name (tp-gc-anonymous-layers))) (should (assoc name tp-layer-alist)) ;; Show and update: reactivity must be intact. (tp-show-layer 1 6 name) (setq tp-rt-gc1b-color "purple") (should (equal (get-text-property 1 'face) '(:foreground "purple")))) (kill-buffer buf) (when (and name (assoc name tp-layer-alist)) (tp-undefine-layer name)) (setq tp-rt-gc1b-color nil)))) (ert-deftest tp-render-test-track-buffer-finds-buried-and-hidden-layers () "tp-reactive-track-buffer registers layers buried or hidden in storage. A propertized string carrying a stacked (buried) layer and an all-hidden string are inserted into a fresh buffer; the track scan must register every layer name, not just the rendered top ones \(GC-1, XM-04)." (setq tp-rt-gc1c-color "gold") (let ((buf (generate-new-buffer " tp-rt-gc1c")) (name nil)) (unwind-protect (progn (define-tp tp-rt-gc1c-top () '(face bold)) (define-tp tp-rt-gc1c-hidden () '(face italic)) (let ((s (with-temp-buffer (insert "trackme") (tp-set 1 6 '(face (:foreground $tp-rt-gc1c-color))) (setq name (get-text-property 1 'tp-name)) (tp-push-layer 1 6 'tp-rt-gc1c-top) (buffer-string))) (h (let ((h (copy-sequence " hideme"))) (tp-push-layer h 'tp-rt-gc1c-hidden) (tp-hide-layer h 'tp-rt-gc1c-hidden) h))) (with-current-buffer buf (insert s) (insert h) (let ((found (tp-reactive-track-buffer))) ;; Rendered top, buried layer, and all-hidden layer. (should (memq 'tp-rt-gc1c-top found)) (should (memq name found)) (should (memq 'tp-rt-gc1c-hidden found))) (should (memq buf (tp-reactive-layer-buffers name))) (should (memq buf (tp-reactive-layer-buffers 'tp-rt-gc1c-hidden)))))) (kill-buffer buf) (when (and name (assoc name tp-layer-alist)) (tp-undefine-layer name)) (tp-undefine-layer 'tp-rt-gc1c-top) (tp-undefine-layer 'tp-rt-gc1c-hidden) (setq tp-rt-gc1c-color nil)))) (provide 'tp-render-tests) ;;; tp-render-tests.el ends here