tp/tests/tp-binding-tests.el
Kinneyzhang 183f49ed3e feat(tp): replace scan reactivity with binding graph
Introduce exact signal and binding dependencies, transactional scheduling, rollback, cycle detection, scoped variable adapters, owner disposal, and scheduler counters. Keep the legacy scanner isolated until retained surfaces complete the cutover.\n\nVerified: 681 ERT tests, 92 doctests, shuffled ERT seed 20260806, and WERROR byte compilation.
2026-08-06 02:36:11 +08:00

316 lines
13 KiB
EmacsLisp

;;; tp-binding-tests.el --- Tests for TP binding graph -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Geekinney
;;; Commentary:
;; Contract tests for TP 1.0 signals, bindings, and transactions.
;;; Code:
(require 'ert)
(require 'tp-reactive)
(defvar tp-binding-test-variable nil
"Variable used by scoped signal adapter tests.")
(defmacro tp-binding-test--isolated (&rest body)
"Run BODY with an isolated reactive runtime."
(declare (indent 0) (debug t))
`(progn
(tp-reactive-reset)
(unwind-protect (progn ,@body)
(tp-reactive-reset))))
(ert-deftest tp-binding-test-signal-invalidates-only-direct-subscribers ()
"A sparse signal write never visits unrelated bindings."
(tp-binding-test--isolated
(let ((target (tp-signal-create 0))
(unrelated (tp-signal-create 0))
(target-calls 0)
(unrelated-calls 0))
(tp-with-transaction
(tp-bind 'target-owner '(test . value)
(lambda ()
(cl-incf target-calls)
(tp-signal-read target)))
(dotimes (index 9999)
(tp-bind (list 'unrelated-owner index) '(test . value)
(lambda ()
(cl-incf unrelated-calls)
(tp-signal-read unrelated)))))
(should (= target-calls 1))
(should (= unrelated-calls 9999))
(tp-reactive-reset-counters)
(cl-letf (((symbol-function 'buffer-list)
(lambda (&rest _) (error "Legacy scan")))
((symbol-function 'text-property-search-forward)
(lambda (&rest _) (error "Legacy scan")))
((symbol-function 'tp-reactive--buffer-layer-names)
(lambda (&rest _) (error "Legacy scan")))
((symbol-function 'tp-reactive-layer-buffers)
(lambda (&rest _) (error "Legacy scan"))))
(tp-signal-set target 1))
(should (= target-calls 2))
(should (= unrelated-calls 9999))
(should (equal (tp-reactive-counters)
'(:invalidated 1 :recomputed 1 :skipped 0
:subscription-added 0 :subscription-removed 0))))))
(ert-deftest tp-binding-test-conditional-dependencies-rewire ()
"A binding unsubscribes from the branch it no longer reads."
(tp-binding-test--isolated
(let ((enabled (tp-signal-create t))
(active (tp-signal-create 'active))
(disabled (tp-signal-create 'disabled))
(calls 0))
(tp-bind 'owner '(test . branch)
(lambda ()
(cl-incf calls)
(if (tp-signal-read enabled)
(tp-signal-read active)
(tp-signal-read disabled))))
(tp-signal-set enabled nil)
(should (= calls 2))
(tp-signal-set active 'ignored)
(should (= calls 2))
(tp-signal-set disabled 'changed)
(should (= calls 3)))))
(ert-deftest tp-binding-test-equal-signal-write-is-noop ()
"Setting an equal signal value does not dirty its binding."
(tp-binding-test--isolated
(let ((source (tp-signal-create '(1 2) :equality #'equal))
(calls 0))
(tp-bind 'owner '(test . value)
(lambda ()
(cl-incf calls)
(tp-signal-read source)))
(tp-reactive-reset-counters)
(tp-signal-set source (list 1 2))
(should (= calls 1))
(should (equal (tp-reactive-counters)
'(:invalidated 0 :recomputed 0 :skipped 0
:subscription-added 0 :subscription-removed 0))))))
(ert-deftest tp-binding-test-transaction-deduplicates-writes ()
"Repeated writes in one transaction recompute each binding once."
(tp-binding-test--isolated
(let ((source (tp-signal-create 0))
(calls 0))
(tp-bind 'owner '(test . value)
(lambda ()
(cl-incf calls)
(tp-signal-read source)))
(tp-reactive-reset-counters)
(tp-with-transaction
(dotimes (value 100)
(tp-signal-set source (1+ value))))
(should (= (tp-signal-peek source) 100))
(should (= calls 2))
(should (= (plist-get (tp-reactive-counters) :recomputed) 1)))))
(ert-deftest tp-binding-test-nested-write-queues-a-second-pass ()
"A compute write queues stabilization instead of recursing."
(tp-binding-test--isolated
(let ((source (tp-signal-create 0))
(calls 0))
(let ((binding
(tp-bind 'owner '(test . stabilizing)
(lambda ()
(cl-incf calls)
(let ((value (tp-signal-read source)))
(when (zerop value)
(tp-signal-set source 1))
value)))))
(should (= (tp-binding-read binding) 1))
(should (= (tp-signal-peek source) 1))
(should (= calls 2))))))
(ert-deftest tp-binding-test-chain-stops-at-equal-computed-value ()
"An equal intermediate value prevents downstream recomputation."
(tp-binding-test--isolated
(let* ((source (tp-signal-create 10))
(middle-calls 0)
(leaf-calls 0)
(middle
(tp-bind 'middle-owner '(test . quotient)
(lambda ()
(cl-incf middle-calls)
(/ (tp-signal-read source) 10))))
(_leaf
(tp-bind 'leaf-owner '(test . display)
(lambda ()
(cl-incf leaf-calls)
(format "%s" (tp-binding-read middle))))))
(tp-signal-set source 11)
(should (= middle-calls 2))
(should (= leaf-calls 1))
(tp-signal-set source 20)
(should (= middle-calls 3))
(should (= leaf-calls 2)))))
(ert-deftest tp-binding-test-owner-disposal-cleans-graph-edges ()
"Disposing an owner removes all incoming and outgoing subscriptions."
(tp-binding-test--isolated
(let* ((source (tp-signal-create 1))
(owner (list 'owner))
(base (tp-bind owner '(test . base)
(lambda () (tp-signal-read source))))
(derived (tp-bind owner '(test . derived)
(lambda () (1+ (tp-binding-read base))))))
(should (= (tp-signal-subscriber-count source) 1))
(should (= (tp-binding-subscriber-count base) 1))
(should (= (tp-binding-dependency-count derived) 1))
(tp-binding-dispose-owner owner)
(should (= (tp-signal-subscriber-count source) 0))
(should (= (tp-binding-subscriber-count base) 0))
(should-not (tp-binding-live-p base))
(should-not (tp-binding-live-p derived)))))
(ert-deftest tp-binding-test-failed-compute-rolls-back-values-and-dependencies ()
"A failed transaction restores signal, binding, and dependency state."
(tp-binding-test--isolated
(let* ((switch (tp-signal-create t))
(left (tp-signal-create 10))
(right (tp-signal-create 20))
(binding
(tp-bind 'owner '(test . branch)
(lambda ()
(if (tp-signal-read switch)
(tp-signal-read left)
(progn
(tp-signal-read right)
(error "Broken branch")))))))
(let ((counters-before (tp-reactive-counters)))
(should-error (tp-signal-set switch nil) :type 'error)
(should (equal (tp-reactive-counters) counters-before)))
(should (tp-signal-peek switch))
(should (= (tp-binding-read binding) 10))
(should (= (tp-signal-subscriber-count left) 1))
(should (= (tp-signal-subscriber-count right) 0))
(tp-signal-set left 11)
(should (= (tp-binding-read binding) 11)))))
(ert-deftest tp-binding-test-failed-new-binding-is-unregistered ()
"A failed initial compute invalidates and unregisters the new binding."
(tp-binding-test--isolated
(let (failed)
(should-error
(tp-bind 'owner '(test . failing)
(lambda ()
(setq failed tp--current-binding)
(error "Initial failure"))))
(should-not (tp-binding-live-p failed))
(let ((replacement
(tp-bind 'owner '(test . failing) (lambda () 42))))
(should-not (eq failed replacement))
(should (= (tp-binding-read replacement) 42))))))
(ert-deftest tp-binding-test-cycle-error-reports-binding-path ()
"A binding dependency cycle reports the keys in cycle order."
(tp-binding-test--isolated
(let ((switch (tp-signal-create nil)) first second)
(setq first
(tp-bind 'first-owner '(test . first)
(lambda ()
(if (tp-signal-read switch)
(tp-binding-read second)
1))))
(setq second
(tp-bind 'second-owner '(test . second)
(lambda () (1+ (tp-binding-read first)))))
(let ((failure
(should-error (tp-signal-set switch t)
:type 'tp-binding-cycle)))
(should
(equal (cadr failure)
'((test . first) (test . second) (test . first)))))
(should-not (tp-signal-peek switch))
(should (= (tp-binding-read first) 1))
(should (= (tp-binding-read second) 2)))))
(ert-deftest tp-binding-test-dirty-target-can-break-an-old-cycle-edge ()
"A dirty target rewires before cycle validation examines its old edges."
(tp-binding-test--isolated
(let ((first-mode (tp-signal-create nil))
(second-mode (tp-signal-create t))
first second)
(setq first
(tp-bind 'first-owner '(test . first)
(lambda ()
(if (tp-signal-read first-mode)
(tp-binding-read second)
1))))
(setq second
(tp-bind 'second-owner '(test . second)
(lambda ()
(if (tp-signal-read second-mode)
(tp-binding-read first)
2))))
(tp-with-transaction
(tp-signal-set second-mode nil)
(tp-signal-set first-mode t))
(should (= (tp-binding-read first) 2))
(should (= (tp-binding-read second) 2)))))
(ert-deftest tp-binding-test-buffer-signal-dies-with-its-scope ()
"Killing a buffer-local source detaches all subscriptions."
(tp-binding-test--isolated
(let* ((buffer (generate-new-buffer " *tp-binding-scope*"))
(signal (tp-signal-create 1 :scope buffer))
(binding
(tp-bind 'owner '(test . local)
(lambda () (tp-signal-read signal)))))
(should (= (tp-binding-dependency-count binding) 1))
(kill-buffer buffer)
(should-not (tp-signal-live-p signal))
(should (= (tp-binding-dependency-count binding) 0)))))
(ert-deftest tp-binding-test-global-signal-can-be-disposed-explicitly ()
"Explicit disposal releases a global signal's graph edges."
(tp-binding-test--isolated
(let* ((signal (tp-signal-create 1))
(binding
(tp-bind 'owner '(test . global)
(lambda () (tp-signal-read signal)))))
(tp-signal-dispose signal)
(should-not (tp-signal-live-p signal))
(should (= (tp-signal-subscriber-count signal) 0))
(should (= (tp-binding-dependency-count binding) 0)))))
(ert-deftest tp-binding-test-variable-adapter-separates-global-and-buffer-scope ()
"Variable adapters route global and buffer-local writes precisely."
(tp-binding-test--isolated
(let* ((symbol 'tp-binding-test-variable)
(buffer (generate-new-buffer " *tp-binding-variable*"))
(global-calls 0)
(local-calls 0))
(unwind-protect
(progn
(set symbol 1)
(with-current-buffer buffer
(set (make-local-variable symbol) 10))
(let ((global (tp-variable-signal symbol))
(local (tp-variable-signal symbol buffer)))
(tp-bind 'global-owner '(test . global)
(lambda ()
(cl-incf global-calls)
(tp-signal-read global)))
(tp-bind 'local-owner '(test . local)
(lambda ()
(cl-incf local-calls)
(tp-signal-read local)))
(set symbol 2)
(should (= global-calls 2))
(should (= local-calls 1))
(with-current-buffer buffer
(set symbol 11))
(should (= global-calls 2))
(should (= local-calls 2))))
(when (buffer-live-p buffer) (kill-buffer buffer))
(makunbound symbol)))))
(provide 'tp-binding-tests)
;;; tp-binding-tests.el ends here