Files
penpot/common/test/common_tests/undo_stack_test.cljc
2026-03-24 18:57:08 +01:00

446 lines
15 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns common-tests.undo-stack-test
(:require
[app.common.data.undo-stack :as sut]
[clojure.test :as t]))
;; --- make-stack ---
(t/deftest make-stack-creates-empty-stack
(let [stack (sut/make-stack)]
(t/is (= -1 (:index stack)))
(t/is (= [] (:items stack)))))
(t/deftest make-stack-returns-nil-on-peek
(t/is (nil? (sut/peek (sut/make-stack)))))
(t/deftest make-stack-size-is-zero
(t/is (= 0 (sut/size (sut/make-stack)))))
;; --- peek ---
(t/deftest peek-empty-stack
(t/is (nil? (sut/peek (sut/make-stack)))))
(t/deftest peek-after-append
(let [stack (-> (sut/make-stack)
(sut/append :a))]
(t/is (= :a (sut/peek stack)))))
(t/deftest peek-multiple-items
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c))]
(t/is (= :c (sut/peek stack)))))
(t/deftest peek-after-undo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo))]
(t/is (= :a (sut/peek stack)))))
;; --- append ---
(t/deftest append-to-nil-stack
(t/is (nil? (sut/append nil :a))))
(t/deftest append-single-item
(let [stack (-> (sut/make-stack)
(sut/append :a))]
(t/is (= 0 (:index stack)))
(t/is (= [:a] (:items stack)))))
(t/deftest append-multiple-items
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c))]
(t/is (= 2 (:index stack)))
(t/is (= [:a :b :c] (:items stack)))))
(t/deftest append-duplicate-at-current-index-ignored
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :a))]
(t/is (= 0 (:index stack)))
(t/is (= [:a] (:items stack)))))
(t/deftest append-duplicate-after-other-operations
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo)
(sut/append :a))]
;; appending :a when current is :a should be a no-op
(t/is (= 0 (:index stack)))
(t/is (= [:a :b] (:items stack)))))
(t/deftest append-same-value-at-different-positions-allowed
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :a))]
(t/is (= 2 (:index stack)))
(t/is (= [:a :b :a] (:items stack)))))
(t/deftest append-nil-value-returns-unchanged
;; appending nil when peek is nil returns stack unchanged
(let [stack (sut/make-stack)
result (sut/append stack nil)]
(t/is (= stack result))))
(t/deftest append-complex-values
(let [v1 {:id 1 :name "shape"}
v2 {:id 2 :name "rect"}
stack (-> (sut/make-stack)
(sut/append v1)
(sut/append v2))]
(t/is (= 1 (:index stack)))
(t/is (= v2 (sut/peek stack)))))
;; --- append truncates redo history ---
(t/deftest append-truncates-redo-history-at-index-greater-than-zero
;; Truncation only happens when index > 0
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/append :d)
(sut/undo) ;; index -> 2, peek :c
(sut/append :e))] ;; index > 0, truncates :d
(t/is (= 3 (:index stack)))
(t/is (= [:a :b :c :e] (:items stack)))
(t/is (= :e (sut/peek stack)))))
(t/deftest append-at-index-zero-does-not-truncate
;; When index is 0, append just adds to end without truncation
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/undo) ;; index -> 0
(sut/append :d))]
;; index 0, no truncation: items become [:a :b :c :d]
(t/is (= 1 (:index stack)))
(t/is (= [:a :b :c :d] (:items stack)))
(t/is (= :b (sut/peek stack)))))
(t/deftest append-truncates-multiple-redo-items
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/append :d)
(sut/append :e)
(sut/undo) ;; index -> 3, peek :d
(sut/undo) ;; index -> 2, peek :c
(sut/append :x))]
(t/is (= 3 (:index stack)))
(t/is (= [:a :b :c :x] (:items stack)))))
;; --- append respects MAX-UNDO-SIZE ---
(t/deftest append-max-undo-size-boundary
(let [;; Fill stack to MAX-UNDO-SIZE items
stack (reduce (fn [s i] (sut/append s (str "item-" i)))
(sut/make-stack)
(range sut/MAX-UNDO-SIZE))]
(t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack)))
(t/is (= sut/MAX-UNDO-SIZE (count (:items stack))))
(t/is (= "item-0" (first (:items stack))))
(t/is (= (str "item-" (dec sut/MAX-UNDO-SIZE)) (sut/peek stack)))))
(t/deftest append-exceeds-max-undo-size-removes-oldest
(let [;; Fill stack to MAX-UNDO-SIZE + 1
stack (reduce (fn [s i] (sut/append s (str "item-" i)))
(sut/make-stack)
(range (inc sut/MAX-UNDO-SIZE)))]
(t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack)))
(t/is (= sut/MAX-UNDO-SIZE (count (:items stack))))
;; Oldest item was removed
(t/is (= "item-1" (first (:items stack))))
(t/is (= (str "item-" sut/MAX-UNDO-SIZE) (sut/peek stack)))))
(t/deftest append-far-exceeds-max-undo-size
(let [;; Fill stack to MAX-UNDO-SIZE + 10
stack (reduce (fn [s i] (sut/append s (str "item-" i)))
(sut/make-stack)
(range (+ sut/MAX-UNDO-SIZE 10)))]
(t/is (= (dec sut/MAX-UNDO-SIZE) (:index stack)))
(t/is (= sut/MAX-UNDO-SIZE (count (:items stack))))
(t/is (= "item-10" (first (:items stack))))))
;; --- fixup ---
(t/deftest fixup-updates-current-item
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/fixup :a-updated))]
(t/is (= :a-updated (sut/peek stack)))
(t/is (= 0 (:index stack)))))
(t/deftest fixup-at-middle-of-stack
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/fixup :b-updated))]
(t/is (= :b-updated (sut/peek stack)))
(t/is (= [:a :b-updated :c] (:items stack)))))
(t/deftest fixup-preserves-index
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo) ;; index -> 0
(sut/fixup :a-new))]
(t/is (= 0 (:index stack)))
(t/is (= :a-new (sut/peek stack)))))
;; --- undo ---
(t/deftest undo-single-item
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/undo))]
(t/is (= 0 (:index stack)))
(t/is (= :a (sut/peek stack)))))
(t/deftest undo-clamps-to-zero
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/undo)
(sut/undo))]
(t/is (= 0 (:index stack)))
(t/is (= :a (sut/peek stack)))))
(t/deftest undo-multiple-steps
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo)
(sut/undo))]
(t/is (= 0 (:index stack)))
(t/is (= :a (sut/peek stack)))))
(t/deftest undo-on-empty-stack
;; undo on empty stack clamps index to 0 (from -1, dec gives -2, max with 0 gives 0)
(let [stack (-> (sut/make-stack)
(sut/undo))]
(t/is (= 0 (:index stack)))
(t/is (nil? (sut/peek stack)))))
(t/deftest undo-preserves-items
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo))]
(t/is (= [:a :b] (:items stack)))))
;; --- redo ---
(t/deftest redo-after-undo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo)
(sut/redo))]
(t/is (= 1 (:index stack)))
(t/is (= :b (sut/peek stack)))))
(t/deftest redo-at-end-of-stack-no-op
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/redo))]
(t/is (= 1 (:index stack)))
(t/is (= :b (sut/peek stack)))))
(t/deftest redo-multiple-steps
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/undo) ;; index -> 0
(sut/redo) ;; index -> 1
(sut/redo))] ;; index -> 2
(t/is (= 2 (:index stack)))
(t/is (= :c (sut/peek stack)))))
(t/deftest redo-on-empty-stack
(let [stack (-> (sut/make-stack)
(sut/redo))]
(t/is (= -1 (:index stack)))))
(t/deftest redo-not-available-after-truncating-append
;; When index > 0, append truncates redo history
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/append :x) ;; truncates :c, items [:a :b :x]
(sut/redo))]
;; Redo should not work since redo history was truncated
(t/is (= 2 (:index stack)))
(t/is (= :x (sut/peek stack)))))
(t/deftest redo-after-append-at-index-zero
;; When index is 0, append does not truncate, so old items remain
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo) ;; index -> 1
(sut/undo) ;; index -> 0
(sut/append :x) ;; index 0, no truncation: [:a :b :c :x]
(sut/redo))]
;; redo goes from index 1 to 2, which is :c
(t/is (= 2 (:index stack)))
(t/is (= :c (sut/peek stack)))))
;; --- size ---
(t/deftest size-empty-stack
(t/is (= 0 (sut/size (sut/make-stack)))))
(t/deftest size-after-appends
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c))]
(t/is (= 3 (sut/size stack)))))
(t/deftest size-unchanged-after-undo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo))]
;; size returns (inc index), not count of items
(t/is (= 2 (sut/size stack)))))
(t/deftest size-is-undo-position
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/undo)
(sut/undo))]
(t/is (= 1 (sut/size stack)))))
;; --- undo/redo round-trip ---
(t/deftest undo-redo-round-trip
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c))]
(t/is (= :c (sut/peek stack)))
(let [s (sut/undo stack)]
(t/is (= :b (sut/peek s)))
(let [s (sut/undo s)]
(t/is (= :a (sut/peek s)))
(let [s (sut/undo s)]
;; At index 0, undo should clamp
(t/is (= :a (sut/peek s)))
(t/is (= 0 (:index s)))
(let [s (sut/redo s)]
(t/is (= :b (sut/peek s)))
(let [s (sut/redo s)]
(t/is (= :c (sut/peek s)))
(let [s (sut/redo s)]
;; At end, redo should be no-op
(t/is (= :c (sut/peek s)))))))))))
;; --- mixed operations ---
(t/deftest append-after-undo-then-redo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/undo)
(sut/undo)
(sut/redo)
(sut/append :c))]
(t/is (= 2 (:index stack)))
(t/is (= [:a :b :c] (:items stack)))
(t/is (= :c (sut/peek stack)))))
(t/deftest fixup-then-append
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/fixup :a-fixed)
(sut/append :b))]
(t/is (= [:a-fixed :b] (:items stack)))
(t/is (= :b (sut/peek stack)))))
(t/deftest append-identical-different-objects
(let [m1 {:x 1}
m2 {:x 1}
stack (-> (sut/make-stack)
(sut/append m1)
(sut/append m2))]
;; Maps are equal, so second append should be a no-op
(t/is (= 0 (:index stack)))
(t/is (= [m1] (:items stack)))))
(t/deftest append-maps-not-equal
(let [m1 {:x 1}
m2 {:x 2}
stack (-> (sut/make-stack)
(sut/append m1)
(sut/append m2))]
(t/is (= 1 (:index stack)))
(t/is (= [m1 m2] (:items stack)))))
(t/deftest sequential-fixup-and-undo
(let [stack (-> (sut/make-stack)
(sut/append {:id 1 :val "old"})
(sut/append {:id 2 :val "old"})
(sut/fixup {:id 2 :val "new"})
(sut/undo)
(sut/fixup {:id 1 :val "updated"}))]
(t/is (= {:id 1 :val "updated"} (sut/peek stack)))
(t/is (= [{:id 1 :val "updated"} {:id 2 :val "new"}] (:items stack)))))
(t/deftest append-undo-append-undo-cycle
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c) ;; index 2
(sut/undo) ;; index 1, peek :b
(sut/append :d) ;; index 1 > 0, truncates :c, items [:a :b :d]
(sut/append :e) ;; items [:a :b :d :e]
(sut/undo) ;; index 2, peek :d
(sut/undo))] ;; index 1, peek :b
(t/is (= 1 (:index stack)))
(t/is (= :b (sut/peek stack)))
(t/is (= [:a :b :d :e] (:items stack)))))
(t/deftest size-grows-then-shrinks-with-undo
(let [stack (-> (sut/make-stack)
(sut/append :a)
(sut/append :b)
(sut/append :c)
(sut/append :d))]
(t/is (= 4 (sut/size stack)))
(let [s (sut/undo stack)]
(t/is (= 3 (sut/size s)))
(let [s (sut/undo s)]
(t/is (= 2 (sut/size s)))
(let [s (sut/redo s)]
(t/is (= 3 (sut/size s))))))))