diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 8fa90003d5..2dcd8b65dd 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -49,6 +49,7 @@ [common-tests.types.shape-interactions-test] [common-tests.types.token-test] [common-tests.types.tokens-lib-test] + [common-tests.undo-stack-test] [common-tests.uuid-test])) #?(:cljs (enable-console-print!)) @@ -93,6 +94,7 @@ 'common-tests.svg-test 'common-tests.text-test 'common-tests.time-test + 'common-tests.undo-stack-test 'common-tests.types.absorb-assets-test 'common-tests.types.components-test 'common-tests.types.container-test diff --git a/common/test/common_tests/undo_stack_test.cljc b/common/test/common_tests/undo_stack_test.cljc new file mode 100644 index 0000000000..d7e7fd3f8e --- /dev/null +++ b/common/test/common_tests/undo_stack_test.cljc @@ -0,0 +1,445 @@ +;; 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))))))))