penpot/frontend/test/frontend_tests/logic/path_lifecycle_test.cljs
2026-07-29 09:38:03 +02:00

543 lines
25 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 Sucursal en España SL
(ns frontend-tests.logic.path-lifecycle-test
(:require
[app.common.data.undo-stack :as u]
[app.common.geom.point :as gpt]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.shapes :as cths]
[app.common.types.path :as path]
[app.main.data.workspace.drawing.common :as dwdc]
[app.main.data.workspace.edition :as dwe]
[app.main.data.workspace.path.changes :as path.changes]
[app.main.data.workspace.path.common :as path.common]
[app.main.data.workspace.path.drawing :as path.drawing]
[app.main.data.workspace.path.edition :as path.edition]
[app.main.data.workspace.path.helpers :as path.helpers]
[app.main.data.workspace.path.selection :as path.selection]
[app.main.data.workspace.path.shortcuts :as path.shortcuts]
[app.main.data.workspace.path.state :as path.state]
[app.main.data.workspace.path.streams :as path.streams]
[app.main.data.workspace.path.tools :as path.tools]
[app.main.data.workspace.path.undo :as path.undo]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.state :as ths]
[frontend-tests.logic.path-test-helpers :as pth]
[potok.v2.core :as ptk]))
(t/deftest path-lifecycle-selectors-use-the-active-path
(let [id (random-uuid)
edit-state {:edit-mode :draw}
edit-path {id edit-state}
state {:workspace-local {:edition id
:edit-path edit-path}
:workspace-drawing {:object {:id id :type :path}}}]
(t/is (= edit-state (path.state/current-edit-state state)))
(t/is (= edit-state (path.state/current-edit-state edit-path id)))
(t/is (path.state/editing? state))
(t/is (path.state/editing? edit-path id))
(t/is (path.state/drawing? edit-state id :path {:id id :type :path}))))
(t/deftest path-drawing-selector-finds-new-paths
(let [id (random-uuid)
state {:workspace-local {:edition nil
:edit-path {id {}}}
:workspace-drawing {:tool :path
:object {:id id :type :path}}}]
(t/is (path.state/drawing? state))
(t/is (not (path.state/editing? state)))
(t/is (path.state/drawing? nil nil :path {:id id :type :path}))
(t/is (not (path.state/drawing? nil nil :curve {:id id :type :path})))))
(t/deftest clear-edition-mode-finishes-path-streams
(t/is (path.streams/finish-edition? (dwe/clear-edition-mode)))
(t/is (not (path.streams/finish-edition? :interrupt))))
(t/deftest clear-edition-mode-finishes-active-path-before-finalizing
(let [id (random-uuid)
event (dwe/clear-edition-mode)
state {:workspace-local {:edition id
:edit-path {id {:edit-mode :move}}}
:workspace-drawing {:object {:id id}}}
state' (ptk/update event state)
emissions (atom [])]
(->> (ptk/watch event state' nil)
(rx/subs! #(swap! emissions conj %)))
(t/is (nil? (get-in state' [:workspace-local :edition])))
(t/is (some? (get-in state' [:workspace-local :edit-path id])))
(t/is (= [::path.common/finish-path]
(mapv ptk/type @emissions)))))
(t/deftest clear-non-path-edition-does-not-emit-finish-path
(let [id (random-uuid)
event (dwe/clear-edition-mode)
state {:workspace-local {:edition id}}
state' (ptk/update event state)
emissions (atom [])]
(->> (ptk/watch event state' nil)
(rx/subs! #(swap! emissions conj %)))
(t/is (nil? (get-in state' [:workspace-local :edition])))
(t/is (empty? @emissions))))
(t/deftest restarting-draw-mode-finishes-pending-subpath
(let [file (pth/setup-rect-file)
shape (-> (cths/get-shape file :rect1)
(path/convert-to-path))
id (:id shape)
last-point (last (path/get-points (:content shape)))
state {:workspace-local
{:edition id
:edit-path
{id {:edit-mode :draw
:last-point last-point
:preview {:command :line-to
:params {:x 150 :y 150}}
:old-content (:content shape)}}}
:workspace-drawing {:object shape}}
stream (rx/subject)
emissions (atom [])]
(->> (ptk/watch (path.drawing/start-draw-mode*) state stream)
(rx/take 4)
(rx/subs! #(swap! emissions conj %)))
(rx/push! stream (ptk/data-event ::path.drawing/end-edition
{:restart? true}))
(t/is (= [::path.drawing/start-edition
::path.common/finish-path
::path.drawing/check-changed-content
::path.drawing/start-draw-mode*]
(mapv ptk/type @emissions)))
(let [state' (ptk/update (second @emissions) state)
state'' (ptk/update (path.drawing/preview-next-point
{:x 200 :y 200})
state')]
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))
(t/is (nil? (get-in state' [:workspace-local :edit-path id :preview])))
(t/is (= :move-to
(get-in state'' [:workspace-local :edit-path id :preview :command]))))))
(t/deftest escape-does-not-restart-edited-path-draw-loop
(let [id (random-uuid)
state {:workspace-local
{:edition id
:edit-path {id {:edit-mode :draw}}}}
stream (rx/subject)
emissions (atom [])]
(t/is (path.drawing/restart-draw-loop? (path.common/finish-path)))
(->> (ptk/watch (path.drawing/start-draw-mode*) state stream)
(rx/subs! #(swap! emissions conj %)))
(rx/push! stream (ptk/data-event ::path.drawing/end-edition
{:restart? false}))
(t/is (= [::path.drawing/start-edition]
(mapv ptk/type @emissions)))))
(defn- run-handle-drawing-end
"Runs the draw-ending flow and passes its events to `callback`."
[restart? callback]
(let [state (pth/drawing-path-state)
stream (rx/subject)
emissions (atom [])]
(->> (ptk/watch (path.drawing/handle-drawing) state stream)
(rx/subs! #(swap! emissions conj %)))
(rx/push! stream (ptk/data-event ::path.drawing/end-edition
{:restart? restart?}))
;; Wait for the asynchronous drawing-end event.
(js/setTimeout
(fn []
(let [end-event (last @emissions)
end-emissions (atom [])]
(->> (ptk/watch end-event state stream)
(rx/subs! #(swap! end-emissions conj %)))
(callback @end-emissions))))))
(t/deftest escape-ending-new-path-draw-does-not-reenter-edition
(t/async
done
(run-handle-drawing-end
false
(fn [emissions]
(t/is (= [::path.drawing/close-drawn-loops
::path.drawing/setup-frame
::dwdc/handle-finish-drawing
::dwe/clear-edition-mode]
(mapv ptk/type emissions)))
(done)))))
(t/deftest finishing-new-path-draw-reenters-edition
(t/async
done
(run-handle-drawing-end
true
(fn [emissions]
(t/is (= [::path.common/finish-path
::path.drawing/close-drawn-loops
::path.drawing/setup-frame
::dwdc/handle-finish-drawing
::path.drawing/start-created-path-edition]
(mapv ptk/type emissions)))
(done)))))
(t/deftest escape-with-pending-segment-cancels-it-and-keeps-drawing
(let [id (random-uuid)
state {:workspace-local
{:edition id
:edit-path {id {:edit-mode :draw
:last-point (gpt/point 10 10)
:preview {:command :line-to
:params {:x 20 :y 20}}}}}}
emissions (atom [])]
(->> (ptk/watch (path.shortcuts/esc-pressed) state nil)
(rx/subs! #(swap! emissions conj %)))
(t/is (= [::path.common/cancel-pending-segment]
(mapv ptk/type @emissions)))
(let [state' (ptk/update (first @emissions) state)]
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))
(t/is (nil? (get-in state' [:workspace-local :edit-path id :preview])))
(t/is (= :draw (get-in state' [:workspace-local :edit-path id :edit-mode]))))))
(t/deftest escape-while-creating-path-finishes-it-into-edition
(let [id (random-uuid)
state {:workspace-local
{:edit-path {id {:edit-mode :draw
:last-point (gpt/point 10 10)}}}
:workspace-drawing {:object {:id id :type :path}}}
emissions (atom [])]
(->> (ptk/watch (path.shortcuts/esc-pressed) state nil)
(rx/subs! #(swap! emissions conj %)))
;; Finishing creates the shape and clears its pending segment.
(t/is (= [::path.common/finish-path]
(mapv ptk/type @emissions)))))
(t/deftest escape-without-pending-segment-interrupts-edition
(let [id (random-uuid)
state {:workspace-local
{:edition id
:edit-path {id {:edit-mode :draw}}}}
emissions (atom [])]
(->> (ptk/watch (path.shortcuts/esc-pressed) state nil)
(rx/subs! #(swap! emissions conj %)))
(t/is (= [:interrupt] @emissions))))
(t/deftest editing-path-only-updates-drawing-copy
(t/async
done
(let [file (pth/setup-rect-file)
original-rect (cths/get-shape file :rect1)
id (:id original-rect)
delta (gpt/point 10 5)
store (ths/setup-store file)
events (conj (pth/start-path-edition-events id)
(pth/move-drawing-content delta))]
(ths/run-store
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
stored-shape (cths/get-shape file' :rect1)
drawing-copy (get-in new-state [:workspace-drawing :object])]
(t/is (= original-rect stored-shape))
(t/is (= :path (:type drawing-copy)))
(t/is (= (path/move-content
(:content (path/convert-to-path original-rect))
delta)
(:content drawing-copy)))))))))
(t/deftest unchanged-path-edition-preserves-simple-shape
(t/async
done
(let [file (pth/setup-rect-file)
original-rect (cths/get-shape file :rect1)
id (:id original-rect)
store (ths/setup-store file)
events (conj (pth/start-path-edition-events id) :interrupt)]
(ths/run-store
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
stored-shape (cths/get-shape file' :rect1)]
(t/is (= original-rect stored-shape))
(t/is (= :rect (:type stored-shape)))
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
(t/deftest changed-path-edition-is-persisted-when-finalized
(t/async
done
(let [file (pth/setup-rect-file)
original-rect (cths/get-shape file :rect1)
id (:id original-rect)
delta (gpt/point 10 5)
original-path (path/convert-to-path original-rect)
;; Persist the rectangle with an explicit close command.
changed-content (-> (:content original-path)
(path/move-content delta)
(path/close-loops))
expected-shape (-> original-path
(assoc :content changed-content)
(path/update-geometry))
store (ths/setup-store file)
events (into (pth/start-path-edition-events id)
[(pth/move-drawing-content delta)
:interrupt])]
(ths/run-store
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
stored-shape (cths/get-shape file' :rect1)]
(t/is (= expected-shape stored-shape))
(t/is (= :path (:type stored-shape)))
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
(t/deftest created-path-edition-cleans-drawing-state-on-exit
(t/async
done
(let [file (pth/setup-rect-file)
original-rect (cths/get-shape file :rect1)
id (:id original-rect)
store (ths/setup-store file)
events [(path.drawing/start-created-path-edition id)
:interrupt]]
(ths/run-store
store done events
(fn [new-state]
(let [file' (ths/get-file-from-state new-state)
stored-shape (cths/get-shape file' :rect1)]
(t/is (= original-rect stored-shape))
(t/is (nil? (get-in new-state [:workspace-local :edition])))
(t/is (nil? (get-in new-state [:workspace-local :edit-path id])))
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
(defn- with-dangling-subpath-start
[content]
(path/content (conj (vec content)
{:command :move-to
:params {:x 30 :y 40}})))
(t/deftest cancel-pending-segment-drops-dangling-subpath-start
(let [id (random-uuid)
content (pth/selectable-path-content)
state {:workspace-local
{:edition id
:edit-path {id {:edit-mode :draw
:last-point (gpt/point 30 40)
:preview {:command :line-to
:params {:x 50 :y 50}}}}}
:workspace-drawing
{:object {:id id
:type :path
:content (with-dangling-subpath-start content)}}}
state' (ptk/update (path.common/cancel-pending-segment) state)]
(t/is (= (vec content)
(vec (get-in state' [:workspace-drawing :object :content]))))
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))
(t/is (nil? (get-in state' [:workspace-local :edit-path id :preview])))))
(t/deftest finish-path-drops-dangling-subpath-start
(let [id (random-uuid)
content (pth/selectable-path-content)
state {:workspace-local
{:edition id
:edit-path {id {:edit-mode :draw
:last-point (gpt/point 30 40)}}}
:workspace-drawing
{:object {:id id
:type :path
:content (with-dangling-subpath-start content)}}}
state' (ptk/update (path.common/finish-path) state)]
(t/is (= (vec (path/close-subpaths content))
(vec (get-in state' [:workspace-drawing :object :content]))))
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))))
(t/deftest finalize-ignores-dangling-subpath-start
(let [file (pth/setup-rect-file)
rect (cths/get-shape file :rect1)
id (:id rect)
path-shape (path/convert-to-path rect)
old-content (:content path-shape)
state {:current-file-id (:id file)
:current-page-id (cthf/current-page-id file)
:files {(:id file) file}
:workspace-local
{:edition id
:edit-path {id {:old-content old-content}}}
:workspace-drawing
{:object (assoc path-shape
:content
(with-dangling-subpath-start old-content))}}
emissions (atom [])]
(->> (ptk/watch (path.changes/finalize-path-content id) state nil)
(rx/subs! #(swap! emissions conj %)))
(t/is (empty? @emissions))))
(t/deftest next-point-preview-is-suppressed-during-a-modifier-drag
(let [id (random-uuid)
content (path/content [{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 100 :y 0}}])
mk (fn [modifiers]
;; Draw mode keeps the path in the drawing object.
{:workspace-drawing {:object {:id id :type :path :content content}}
:workspace-local {:edition nil
:zoom 1
:edit-path {id {:edit-mode :draw
:last-point (gpt/point 100 0)
:content-modifiers modifiers}}}})
event (path.drawing/preview-next-point {:x 150 :y 40 :shift? false})
idle (ptk/update event (mk {}))
during (ptk/update event (mk {1 {:c1x 5 :c1y 5}}))]
;; no active drag: the next-point preview updates as usual
(t/is (some? (get-in idle [:workspace-local :edit-path id :preview])))
;; a placed handler is being dragged mid-draw: the preview must not move
(t/is (nil? (get-in during [:workspace-local :edit-path id :preview])))))
(t/deftest dragging-the-current-curve-forward-handle-while-drawing
(let [id (random-uuid)
content (path/content [{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:x 100 :y 0 :c1x 30 :c1y 0 :c2x 70 :c2y 0}}])
mk (fn []
;; Store the backward handle and transient forward handle.
{:workspace-drawing {:object {:id id :type :path :content content}}
:workspace-local {:edition nil
:zoom 1
:edit-path {id {:edit-mode :draw
:last-point (gpt/point 100 0)
:prev-handler (gpt/point 130 0)}}}})
drag (fn [alt?]
(ptk/update (path.drawing/drag-prev-handler
{:x 100 :y 50 :alt? alt? :shift? false})
(mk)))
finish (fn [state] (ptk/update (path.drawing/finish-drag) state))
c2-of (fn [state]
(-> (get-in state [:workspace-drawing :object :content])
(vec) (nth 1) :params (select-keys [:c2x :c2y])))]
(t/testing "no alt: the forward handle follows the pointer and the committed backward handle mirrors it"
(let [dragged (drag false)]
;; the forward handle tracks the pointer
(t/is (= (gpt/point 100 50)
(get-in dragged [:workspace-local :edit-path id :drag-handler])))
;; the stale transient forward handle is cleared so it is not double-rendered
(t/is (nil? (get-in dragged [:workspace-local :edit-path id :prev-handler])))
(let [committed (finish dragged)]
;; c2 = 2*node - forward = (100,-50)
(t/is (= {:c2x 100 :c2y -50} (c2-of committed)))
;; the new forward handle becomes the prev-handler
(t/is (= (gpt/point 100 50)
(get-in committed [:workspace-local :edit-path id :prev-handler]))))))
(t/testing "alt: the forward handle moves on its own, the backward handle stays put"
(let [committed (finish (drag true))]
(t/is (= {:c2x 70 :c2y 0} (c2-of committed)))))))
(t/deftest dragging-the-current-curve-backward-handle-while-drawing
;; Dragging the backward handle mirrors the transient forward handle.
(let [id (random-uuid)
content (path/content [{:command :move-to :params {:x 0 :y 0}}
{:command :curve-to
:params {:x 100 :y 0 :c1x 30 :c1y 0 :c2x 70 :c2y 0}}])
mk (fn []
{:workspace-drawing {:object {:id id :type :path :content content}}
:workspace-local {:edition nil
:zoom 1
:edit-path {id {:edit-mode :draw
:last-point (gpt/point 100 0)
:prev-handler (gpt/point 130 0)}}}})
;; Drag the backward handle to `(70, -40)`.
drag (fn [mode]
(ptk/update (path.edition/modify-selected-handlers
id [1 :c2] {} 0 -40 mode (= mode :smart))
(mk)))
prev-of (fn [state] (get-in state [:workspace-local :edit-path id :prev-handler]))]
(t/testing "smart (no modifier): the forward handle mirrors the angle, keeping its own length"
;; Keep the forward handle's length while mirroring its angle.
(t/is (= (gpt/point 118 24) (prev-of (drag :smart)))))
(t/testing "mirror (mod): the forward handle full-mirrors to equal length"
;; Mirror the forward handle around the node.
(t/is (= (gpt/point 130 40) (prev-of (drag :mirror)))))
(t/testing "independent (alt): the forward handle is left untouched"
(t/is (= (gpt/point 130 0) (prev-of (drag :independent)))))))
(t/deftest path-local-undo-redo-restores-content-and-clears-preview
(let [id (random-uuid)
content-a (path/content [{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 0}}])
content-b (path/content [{:command :move-to :params {:x 0 :y 0}}
{:command :line-to :params {:x 10 :y 5}}])
base (-> (pth/selectable-path-state id content-a path.helpers/empty-selection)
(assoc-in [:workspace-local :edit-path id :undo-stack] (u/make-stack)))
;; Capture both content states around a stale preview.
s1 (ptk/update (path.undo/add-undo-entry) base)
s2 (-> (path.state/set-content s1 content-b)
(assoc-in [:workspace-local :edit-path id :preview]
{:command :line-to :params {:x 99 :y 99}}))
s3 (ptk/update (path.undo/add-undo-entry) s2)
s4 (ptk/update (path.undo/undo-path) s3)
s5 (ptk/update (path.undo/redo-path) s4)]
(t/is (= content-b (path.state/get-path s3 :content)))
(t/is (= content-a (path.state/get-path s4 :content)))
;; Restoring an entry drops its render-only preview.
(t/is (nil? (get-in s4 [:workspace-local :edit-path id :preview])))
(t/is (= content-b (path.state/get-path s5 :content)))))
(t/deftest path-undo-entry-never-captures-the-transient-preview
(let [id (random-uuid)
state (-> (pth/selectable-path-state id (pth/selectable-path-content)
path.helpers/empty-selection)
(assoc-in [:workspace-local :edit-path id :undo-stack] (u/make-stack))
(assoc-in [:workspace-local :edit-path id :preview]
{:command :line-to :params {:x 99 :y 99}}))
state' (ptk/update (path.undo/add-undo-entry) state)
entry (u/peek (get-in state' [:workspace-local :edit-path id :undo-stack]))]
(t/is (some? entry))
(t/is (not (contains? entry :preview)))))
;; Tool operations through the full edition lifecycle.
(t/deftest tool-make-curve-persists-through-edition-lifecycle
(t/async
done
(let [file (pth/setup-rect-file)
rect (cths/get-shape file :rect1)
id (:id rect)
store (ths/setup-store file)
events (into (pth/start-path-edition-events id)
[(path.selection/select-node 1 false)
(path.tools/make-curve)
:interrupt])]
(ths/run-store
store done events
(fn [new-state]
(let [stored (cths/get-shape (ths/get-file-from-state new-state) :rect1)]
(t/is (= :path (:type stored)))
;; make-curve on a corner introduces at least one curve segment
(t/is (some #(= :curve-to (:command %)) (seq (:content stored))))
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
(t/deftest tool-remove-node-persists-through-edition-lifecycle
(t/async
done
(let [file (pth/setup-rect-file)
rect (cths/get-shape file :rect1)
id (:id rect)
orig-nodes (count (path/get-points (:content (path/convert-to-path rect))))
store (ths/setup-store file)
events (into (pth/start-path-edition-events id)
[(path.selection/select-node 1 false)
(path.tools/remove-node)
:interrupt])]
(ths/run-store
store done events
(fn [new-state]
(let [stored (cths/get-shape (ths/get-file-from-state new-state) :rect1)]
(t/is (= :path (:type stored)))
;; removing a node leaves fewer nodes than the converted rect had
(t/is (< (count (path/get-points (:content stored))) orig-nodes))
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))