✨ Test the file and container helpers and the library events

The component helpers of types/file and types/container and the
library events of the workspace had the lowest coverage left.
- common-tests.types.file-test: the export detach, the reuse of the
  library page, the original ref of a swap, deleted components and
  used-in?. Pinned: F52 (a library copy nested in a local component
  loses its ref in every copy), F56 (the original ref of a swap is nil
  when the mains are on different pages) and F57 (library color refs
  inside :fills survive the export detach).
- common-tests.types.container-test: the placement rules for moves
  and pastes (copies, mains, variant containers, deleted components,
  component loops) and small helper branches. A plain shape moved
  into its own child passes the rules (F58); relocate-shapes drops it
  first.
- frontend-tests.data.workspace-libraries-test: the events on several
  shapes at once, each one undo entry; update of a library main; a
  blank rename; placing with an id ref.
- workspace-library-updates-test: how a file learns that a library
  changed, and the sync dialog with its update and dismiss, with the
  RPC mocked.
- workspace-library-links-test: linking, unlinking and sharing.
This commit is contained in:
alonso.torres 2026-10-10 18:30:55 +02:00
parent 1ffda32211
commit 20062c003b
7 changed files with 1129 additions and 0 deletions

View File

@ -96,6 +96,7 @@
[common-tests.types.color-test]
[common-tests.types.components-test]
[common-tests.types.container-test]
[common-tests.types.file-test]
[common-tests.types.fill-test]
[common-tests.types.font-test]
[common-tests.types.modifiers-test]
@ -203,6 +204,7 @@
'common-tests.types.color-test
'common-tests.types.components-test
'common-tests.types.container-test
'common-tests.types.file-test
'common-tests.types.fill-test
'common-tests.types.font-test
'common-tests.types.modifiers-test

View File

@ -6,11 +6,22 @@
(ns common-tests.types.container-test
(:require
[app.common.geom.point :as gpt]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.variants :as thv]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.container :as ctc]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
;; ---------------------------------------------------------------------------
;; Helpers
;; ---------------------------------------------------------------------------
@ -154,3 +165,208 @@
shape-b (make-shape id-b :parent-id id-a)
objects (objects-map shape-a shape-b)]
(t/is (nil? (ctc/find-component-main objects shape-a false))))))
;; ---------------------------------------------------------------------------
;; Placement rules: where shapes may go when moved or pasted
;; ---------------------------------------------------------------------------
(defn- placement-file
"A board with a frame, a group and a rect inside; components :c1 and :c2,
a copy of :c1; and two variant containers, :v01 with mains :vm1 and :vm2,
:v02 with mains :vm3 and :vm4."
[]
(-> (thf/sample-file :file)
(tho/add-frame :board)
(tho/add-frame :inner :parent-label :board)
(tho/add-group :group :parent-label :board)
(tho/add-rect :rect :parent-label :board)
(tho/add-simple-component :c1 :main1 :main1-child)
(tho/add-simple-component :c2 :main2 :main2-child)
(thc/instantiate-component :c1 :copy1 :children-labels [:copy1-child])
(thv/add-variant :v01 :vc1 :vm1 :vc2 :vm2)
(thv/add-variant :v02 :vc3 :vm3 :vc4 :vm4)))
(defn- page-objects
[file]
(:objects (thf/current-page file)))
(defn- label
[id]
(if (= id uuid/zero) :root (thi/label id)))
(defn- place
"The labels of the parent and the frame that the shapes `labels` go to
when moved, or pasted with `pasting?`, into `target`."
[file target labels & {:keys [pasting? libraries objects]}]
(let [objects (or objects (page-objects file))
children (map #(ths/get-shape file %) labels)]
(mapv label (ctc/find-valid-parent-and-frame-ids
(thi/id target) objects children pasting?
(or libraries {(:id file) file})))))
(defn- delete-component
[file component-label]
(assoc-in file [:data :components (thi/id component-label) :deleted] true))
(t/deftest placement-of-plain-shapes
(let [file (placement-file)]
(t/is (= [:board :board] (place file :board [:main1])))
(t/testing "inside a group the frame is the group's frame"
(t/is (= [:group :board] (place file :group [:rect]))))
(t/testing "the three-argument arity moves, without libraries"
(t/is (= [:inner :inner]
(mapv label (ctc/find-valid-parent-and-frame-ids
(thi/id :inner) (page-objects file)
[(ths/get-shape file :rect)])))))))
(t/deftest placement-inside-a-copy
(let [file (placement-file)]
(t/testing "nothing goes into a copy or a shape inside it"
(t/is (= [:root :root] (place file :copy1 [:rect])))
(t/is (= [:root :root] (place file :copy1-child [:rect]))))
(t/testing "a shape may stay in the copy it already is in"
(t/is (= [:copy1 :copy1] (place file :copy1 [:copy1-child]))))
(t/testing "but a pasted one is a new shape"
(t/is (= [:root :root] (place file :copy1 [:copy1-child] :pasting? true))))))
(t/deftest placement-of-a-main
(let [file (placement-file)]
(t/testing "a main is not moved into another main, nor inside one"
(t/is (= [:root :root] (place file :main2 [:main1])))
(t/is (= [:root :root] (place file :main2-child [:main1]))))
(t/testing "a plain shape may go into a main"
(t/is (= [:main2 :main2] (place file :main2 [:rect]))))
(t/testing "a pasted main becomes a copy, so it may go into a main"
(t/is (= [:main2 :main2] (place file :main2 [:main1] :pasting? true))))
(t/testing "unless its component is deleted: then it is pasted as a main"
(let [libraries {(:id file) (delete-component file :c1)}]
(t/is (= [:root :root] (place file :main2 [:main1] :pasting? true :libraries libraries)))))
(t/testing "a cut main, no longer in the page, follows the same rule"
(let [objects (dissoc (page-objects file) (thi/id :main1) (thi/id :main1-child))
libraries {(:id file) (delete-component file :c1)}]
(t/is (= [:main2 :main2] (place file :main2 [:main1] :pasting? true :objects objects)))
(t/is (= [:root :root] (place file :main2 [:main1] :pasting? true
:objects objects :libraries libraries)))))))
(t/deftest placement-of-a-variant-container
(let [file (placement-file)]
(t/is (= [:board :board] (place file :board [:v01])))
(t/testing "not inside another variant container"
(t/is (= [:root :root] (place file :v02 [:v01]))))
(t/testing "not inside a main, even pasted"
(t/is (= [:root :root] (place file :main2 [:v01] :pasting? true))))))
(t/deftest placement-inside-a-variant-container
(let [file (placement-file)]
(t/testing "only mains go into a variant container"
(t/is (= [:root :root] (place file :v01 [:rect])))
(t/is (= [:v01 :v01] (place file :v01 [:main1]))))
(t/testing "a pasted variant does not go inside a sibling variant"
(t/is (= [:v01 :v01] (place file :vm2 [:vm1] :pasting? true))))
(t/testing "but it may go inside a variant of another container"
(t/is (= [:vm3 :vm3] (place file :vm3 [:vm1] :pasting? true))))))
(t/deftest placement-that-makes-a-component-loop
(let [file (placement-file)]
(t/testing "a copy does not go inside its own main"
(t/is (= [:root :root] (place file :main1 [:copy1] :pasting? true)))
(t/is (= [:main2 :main2] (place file :main2 [:copy1] :pasting? true))))
(t/testing "a main does not go inside itself, even pasted"
(t/is (= [:root :root] (place file :main1-child [:main1])))
(t/is (= [:root :root] (place file :main1-child [:main1] :pasting? true))))
(t/testing "a plain shape inside its own child is let through (`relocate-shapes` drops it)"
(t/is (= [:inner :inner] (place file :inner [:board]))))))
(t/deftest placement-with-a-validation-cache
(let [file (placement-file)
objects (page-objects file)
children [(ths/get-shape file :main1)]
libs {(:id file) file}
cache (ctc/parent-validation-cache objects children libs)]
(t/is (= [:root :root]
(mapv label (ctc/find-valid-parent-and-frame-ids
(thi/id :main2) objects children false libs cache))))
(t/is (= [:board :board]
(mapv label (ctc/find-valid-parent-and-frame-ids
(thi/id :board) objects children false libs cache))))))
(t/deftest valid-shape-for-component
(let [file (placement-file)
objects (page-objects file)
valid? #(ctc/valid-shape-for-component? objects (ths/get-shape file %))]
(t/is (valid? :rect))
(t/is (valid? :board))
(t/testing "a main can't be inside another main"
(t/is (not (valid? :main1-child))))
(t/testing "a main can't hold another main"
(t/is (not (valid? :v01))))
(t/testing "a main can't be inside a copy"
(t/is (not (valid? :copy1-child))))))
(t/deftest first-valid-parent
(let [file (placement-file)
objects (page-objects file)
first-valid #(label (:id (ctc/get-first-valid-parent objects (thi/id %))))]
(t/is (= :rect (first-valid :rect)))
(t/is (= :vm1 (first-valid :vm1)))
(t/testing "copies and variant containers are skipped"
(t/is (= :root (first-valid :copy1-child)))
(t/is (= :root (first-valid :v01))))))
;; ---------------------------------------------------------------------------
;; Heads, containers and instances
;; ---------------------------------------------------------------------------
(t/deftest head-shape-of-nothing-and-of-the-root
(let [objects (page-objects (placement-file))]
(t/is (nil? (ctc/get-head-shape objects nil)))
(t/is (nil? (ctc/get-head-shape objects (get objects uuid/zero))))
(t/is (= [] (ctc/get-child-heads objects (uuid/next))))))
(t/deftest children-in-instance-stop-at-a-nested-copy
(let [file (-> (thf/sample-file :file)
(tho/add-nested-component :c1 :main1 :main1-child
:c2 :main2 :nested-head))
objects (page-objects file)
labels #(set (map (comp label :id) (ctc/get-children-in-instance objects (thi/id %))))]
(t/is (= #{:main1 :main1-child} (labels :main1)))
(t/is (= #{:main2} (labels :main2)))))
(t/deftest container-of-a-component
(let [file (placement-file)
container (ctc/get-container (:data file) :component (thi/id :c1))]
(t/is (ctc/component? container))
(t/is (not (ctc/page? container)))
(t/is (= (thi/id :main1) (:main-instance-id container)))))
(t/deftest container-root
(let [root-id (uuid/next)
root (assoc (make-shape root-id) :parent-id nil)
child (make-shape (uuid/next) :parent-id root-id)]
(t/is (= root (ctc/get-container-root {:objects (objects-map root child)})))))
(t/deftest swap-keep-attrs-keep-auto-sizing-of-a-layout
(let [shape (make-shape (uuid/next)
:type :frame
:layout :flex
:layout-item-h-sizing :auto
:layout-item-v-sizing :auto
:layout-item-margin {:m1 10})
fixed (assoc shape
:layout-item-h-sizing :fix
:layout-item-v-sizing :fix)]
(t/is (= [:auto :auto nil]
((juxt :layout-item-h-sizing :layout-item-v-sizing :layout-item-margin)
(ctc/remove-swap-keep-attrs shape))))
(t/is (= [nil nil]
((juxt :layout-item-h-sizing :layout-item-v-sizing)
(ctc/remove-swap-keep-attrs fixed))))))
(t/deftest component-instance-with-default-options
(let [file (placement-file)
component (ctkl/get-component (:data file) (thi/id :c1))
[root shapes] (ctc/make-component-instance (thf/current-page file) component
(:data file) (gpt/point 0 0))]
(t/is (= (thi/id :c1) (:component-id root)))
(t/is (not (ctk/main-instance? root)))
(t/is (= 2 (count shapes)))))

View File

@ -0,0 +1,293 @@
;; 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 SUBSIDIARY SL
(ns common-tests.types.file-test
"Component helpers of `app.common.types.file`: the export detach of
external references, the library page that absorbed components go to,
the original ref of a swapped shape, and the lookups on deleted
components."
(:require
[app.common.files.validate :as cfv]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.types.shape-tree :as ctst]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(def ^:private link-attrs
[:component-id :component-file :shape-ref :component-root])
(defn- links
[file label]
(select-keys (ths/get-shape file label) link-attrs))
(defn- validation-errors
[file]
(set (map :code (cfv/validate-file file {}))))
(defn- library-with-component
[]
(-> (thf/sample-file :library :is-shared true)
(tho/add-simple-component :lib-component :lib-main :lib-main-child)))
;; ----- Export detach
(t/deftest detach-external-references-drops-library-links
(let [library (library-with-component)
file (-> (thf/sample-file :file)
(tho/add-simple-component-with-copy :component :main :main-child
:copy
:copy-root-params {:children-labels [:copy-child]})
(thc/instantiate-component :lib-component :lib-copy
:library library
:children-labels [:lib-copy-child]))
file' (ctf/detach-external-references file (:id file))]
(t/testing "a copy of a library component becomes plain shapes"
(t/is (= {} (links file' :lib-copy)))
(t/is (= {} (links file' :lib-copy-child))))
(t/testing "a copy of a local component keeps its links"
(t/is (= (links file :copy) (links file' :copy)))
(t/is (= (links file :copy-child) (links file' :copy-child))))
(t/is (= #{} (validation-errors file')))))
(t/deftest detach-external-references-keeps-a-nested-library-copy-in-place
(let [library (library-with-component)
file (-> (thf/sample-file :file)
(tho/add-frame :main {:name "Frame2"})
(thc/instantiate-component :lib-component :nested-head
:library library
:parent-label :main)
(thc/make-component :component :main)
(thc/instantiate-component :component :copy
:children-labels [:copy-nested-head]))
file' (ctf/detach-external-references file (:id file))]
(t/testing "the nested library copy in the main becomes plain shapes"
(t/is (= {} (links file' :nested-head))))
(t/testing "its copy keeps the ref to the main's shape, which is local"
(tm/known-failure "F52"
{:shape-ref (thi/id :nested-head)}
{}
(links file' :copy-nested-head)))
(t/testing "the validation does not see the lost ref"
(t/is (= #{} (validation-errors file'))))))
;; Fill and typography refs on the shape and on the text nodes themselves
;; (the older format), next to a fill ref inside `:fills`.
(defn- file-with-color-refs
[library-id]
(let [file (thf/sample-file :file)]
(-> file
(ths/add-sample-shape :rect
:type :rect
:fill-color-ref-id (thi/id :color)
:fill-color-ref-file library-id
:stroke-color-ref-id (thi/id :color)
:stroke-color-ref-file library-id
:fills [{:fill-color "#abcdef"
:fill-opacity 1
:fill-color-ref-id (thi/id :color)
:fill-color-ref-file library-id}])
(ths/add-sample-shape :text
:type :text
:content {:type "root"
:children [{:type "paragraph-set"
:children [{:type "paragraph"
:children [{:text "library"
:fill-color-ref-id (thi/id :color)
:fill-color-ref-file library-id
:typography-ref-id (thi/id :typography)
:typography-ref-file library-id}
{:text "local"
:typography-ref-id (thi/id :typography)
:typography-ref-file (:id file)}]}]}]}))))
(defn- text-leaves
[file]
(->> (get-in (ths/get-shape file :text) [:content :children 0 :children 0 :children])
(mapv #(select-keys % [:fill-color-ref-file :typography-ref-file]))))
(t/deftest detach-external-references-drops-library-color-refs
(let [library-id (thi/new-id! :library)
file (file-with-color-refs library-id)
file' (ctf/detach-external-references file (:id file))
rect' (ths/get-shape file' :rect)]
(t/is (= {} (select-keys rect' [:fill-color-ref-file :stroke-color-ref-file])))
(t/is (= [{} {:typography-ref-file (:id file)}] (text-leaves file')))
(t/testing "a fill ref inside `:fills`"
(tm/known-failure "F57" nil library-id (-> rect' :fills first :fill-color-ref-file)))))
;; ----- Asset usage
(t/deftest component-used-in-a-file
(let [library (library-with-component)
file (-> (thf/sample-file :file)
(thc/instantiate-component :lib-component :lib-copy :library library))
unused (-> (thf/sample-file :other)
(tho/add-rect :rect))
lib-c (thc/get-component library :lib-component)]
(t/is (ctf/used-in? (:data file) (:id library) lib-c :component))
(t/is (not (ctf/used-in? (:data unused) (:id library) lib-c :component)))))
;; ----- Library page for absorbed components
(t/deftest absorbed-components-reuse-the-library-page
(let [library1 (-> (thf/sample-file :library1 :is-shared true)
(tho/add-simple-component :component1 :main1 :main1-child))
library2 (-> (thf/sample-file :library2 :is-shared true)
(tho/add-simple-component :component2 :main2 :main2-child))
file (-> (thf/sample-file :file)
(thc/instantiate-component :component1 :copy1 :library library1)
(thc/instantiate-component :component2 :copy2 :library library2))
file' (ctf/update-file-data file
#(-> %
(ctf/absorb-assets (:data library1))
(ctf/absorb-assets (:data library2))))
data' (ctf/file-data file')
pages' (ctpl/pages-seq data')
roots (->> (ctpl/get-page data' (:id (second pages')))
(ctn/shapes-seq)
(filter ctk/main-instance?)
(sort-by :y))]
(t/is (= ["Page 1" "Main components"] (map :name pages')))
(t/is (= 2 (count roots)))
(t/testing "the second component goes below the first, a grid gap apart"
(let [[first-root second-root] roots]
(t/is (<= (+ (:y first-root) (:height first-root) 50) (:y second-root)))))
(t/is (= #{} (validation-errors file')))))
;; ----- Original ref of a swapped shape
(defn- swap-at-second-level
"Swaps, in a copy of :c3, the copy of :c1 nested two levels down
(:c3 > :c2 > :c1) for :c4. Returns the file with the new shape as
:swapped."
[file]
(let [copy-c2 (first (:shapes (ths/get-shape file :copy3)))
copy-c1 (first (:shapes (ths/get-shape-by-id file copy-c2)))]
(thi/set-id! :copy-c1 copy-c1)
(-> file
(tho/add-simple-component :c4 :main4 :main4-child)
(tho/swap-component-in-shape :copy-c1 :c4 :new-shape-label :swapped))))
(defn- ref-id-for-swapped
[file]
(ctf/find-ref-id-for-swapped (ths/get-shape file :swapped)
(thf/current-page file)
{(:id file) file}))
(t/deftest ref-id-for-swapped-goes-up-the-ref-chain
(let [file (-> (thf/sample-file :file)
(tho/add-two-levels-nested-component-with-copy
:c1 :main1 :main1-child
:c2 :main2 :nested-head1
:c3 :main3 :nested-head2 :nested-subhead2
:copy3)
(swap-at-second-level))]
(t/is (= (thi/id :nested-head1) (ctk/get-swap-slot (ths/get-shape file :swapped))))
(t/is (= (thi/id :nested-subhead2) (ref-id-for-swapped file)))))
(t/deftest ref-id-for-swapped-with-mains-on-other-pages
(let [file (-> (thf/sample-file :file :page-label :page1)
(tho/add-nested-component :c1 :main1 :main1-child
:c2 :main2 :nested-head1)
(thf/add-sample-page :page2)
(tho/add-frame :main3 {:name "Frame3"})
(thc/instantiate-component :c2 :nested-head2
:parent-label :main3
:children-labels [:nested-subhead2])
(thc/make-component :c3 :main3)
(thc/instantiate-component :c3 :copy3)
(swap-at-second-level))]
(t/is (= (thi/id :nested-head1) (ctk/get-swap-slot (ths/get-shape file :swapped))))
(tm/known-failure "F56" (thi/id :nested-subhead2) nil (ref-id-for-swapped file))))
;; ----- Deleted components
(defn- file-with-deleted-component
[]
(let [file (-> (thf/sample-file :file)
(tho/add-simple-component :component :main :main-child))]
(ctf/update-file-data file #(ctf/delete-component % (thi/id :component) false nil))))
(t/deftest lookups-on-a-deleted-component
(let [file (file-with-deleted-component)
data (ctf/file-data file)
component (thc/get-component file :component :include-deleted? true)]
(t/is (:deleted component))
(t/is (= (thi/id :main) (:id (ctf/get-component-root data component))))
(t/is (= #{(thi/id :main) (thi/id :main-child)}
(set (map :id (ctf/get-component-shapes data component)))))
(t/testing "a shape of the component, with the component as its container"
(let [child (ctf/get-component-shape data component (thi/id :main-child)
:with-context? true)]
(t/is (= (thi/id :main-child) (:id child)))
(t/is (= :component (:type (:container (meta child)))))))
(t/testing "loading the objects again leaves the component as it is"
(t/is (= component (ctf/load-component-objects data component))))))
;; ----- Containers and ref chains
(t/deftest update-container-of-a-component
(let [file (file-with-deleted-component)
component (thc/get-component file :component :include-deleted? true)
container (ctn/make-container component :component)
data' (ctf/update-container (ctf/file-data file) container
#(assoc % :name "Renamed"))]
(t/is (= "Renamed" (:name (thc/get-component {:data data'} :component
:include-deleted? true))))))
(t/deftest update-objects-tree-updates-and-removes
(let [file (-> (thf/sample-file :file)
(tho/add-frame-with-child :frame :child)
(tho/add-rect :rect))
container (ctn/make-container (thf/current-page file) :page)
result (ctf/update-objects-tree
container
(fn [shape]
(condp = (:id shape)
(thi/id :frame) {:result :update
:updated-shape (assoc shape :name "Updated")}
(thi/id :rect) {:result :remove}
{:result :keep})))]
(t/is (= "Updated" (:name (ctst/get-shape result (thi/id :frame)))))
(t/is (some? (ctst/get-shape result (thi/id :child))))
(t/is (nil? (ctst/get-shape result (thi/id :rect))))
(t/testing "an unknown result"
(t/is (thrown? #?(:clj Exception :cljs :default)
(ctf/update-objects-tree container (constantly {:result :other})))))))
(t/deftest ref-chain-without-the-target
(let [file (-> (thf/sample-file :file)
(tho/add-simple-component-with-copy :component :main :main-child
:copy
:copy-root-params {:children-labels [:copy-child]}))
page (thf/current-page file)
libraries {(:id file) file}
chain (ctf/get-ref-chain-until-target-ref page libraries
(ths/get-shape file :copy-child)
(ths/get-shape file :copy))]
(t/testing "the chain runs to the main and stops there"
(t/is (= [(thi/id :copy-child) (thi/id :main-child)] (map :id chain))))))
(t/deftest near-match-with-context
(let [file (-> (thf/sample-file :file)
(tho/add-simple-component-with-copy :component :main :main-child
:copy
:copy-root-params {:children-labels [:copy-child]}))
match (ctf/find-near-match file (thf/current-page file) {(:id file) file}
(ths/get-shape file :copy-child)
:with-context? true)]
(t/is (= (thi/id :main-child) (:id match)))
(t/is (some? (:container (meta match))))))

View File

@ -0,0 +1,220 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.data.workspace-libraries-test
"The library events that act on several shapes or components at once, each
as one undo entry, and the branches of the single-shape events that the
composable cases do not reach: creating or placing a component with an
id ref, a blank rename and updating a main that lives in a linked
library."
(:require
[app.common.data :as d]
[app.common.geom.point :as gpt]
[app.common.test-helpers.components :as cthc]
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.main.data.workspace.libraries :as dwl]
[app.main.data.workspace.selection :as dws]
[app.main.data.workspace.shapes :as dwsh]
[app.main.data.workspace.undo :as dwu]
[app.main.store :as st]
[cljs.test :as t :include-macros true]
[frontend-tests.composable-tests.interpreter :as ftm]))
(t/use-fixtures :each ftm/fixture)
(def ^:private main-color "#111111")
(def ^:private override "#222222")
(defn- two-components
"Components :c1 and :c2, two copies of :c1 (:copy-a, :copy-b) and two plain
rects."
[]
(-> (cthf/sample-file :file1)
(ctho/add-simple-component :c1 :main1 :main1-child
:child-params {:fills (cths/sample-fills-color :fill-color main-color)})
(ctho/add-simple-component :c2 :main2 :main2-child)
(cthc/instantiate-component :c1 :copy-a :children-labels [:copy-a-child])
(cthc/instantiate-component :c1 :copy-b :children-labels [:copy-b-child])
(ctho/add-rect :rect-a)
(ctho/add-rect :rect-b)))
(defn- install-file!
[file]
(ftm/install! (tm/make-situation file)))
(defn- shape
[label]
(cths/get-shape (ftm/current-file) label))
(defn- shape-by-id
[id]
(cths/get-shape-by-id (ftm/current-file) id))
(defn- data
[]
(:data (ftm/current-file)))
(defn- undo-entries
[]
(count (get-in @st/state [:workspace-undo :items])))
(defn- step
"Run `events` and wait until their work drains."
[& events]
(ftm/await-step (vec events)))
(defn- select
[& labels]
(dws/select-shapes (into (d/ordered-set) (map cthi/id) labels)))
(defn- linked?
[label]
(some? (:component-id (shape label))))
(defn- fill
[label]
(-> (shape label) :fills first :fill-color))
(defn- set-fill
[& labels]
(dwsh/update-shapes (into #{} (map cthi/id) labels)
#(assoc % :fills (cths/sample-fills-color :fill-color override))))
(defn- component-count
[]
(count (ctkl/components-seq (data))))
(t/deftest ^:async detach-components-is-one-undo-entry
(install-file! (two-components))
(let [before (undo-entries)]
(t/is (= :settled (await (step (dwl/detach-components [(cthi/id :copy-a) (cthi/id :copy-b)])))))
(t/is (not (linked? :copy-a)))
(t/is (not (linked? :copy-b)))
(t/is (= (inc before) (undo-entries)))
(await (step dwu/undo))
(t/is (linked? :copy-a))
(t/is (linked? :copy-b))))
(t/deftest ^:async detach-selected-components
(install-file! (two-components))
(t/testing "nothing happens when a selected shape is inside a copy"
(await (step (select :copy-a :copy-b-child) dwl/detach-selected-components))
(t/is (linked? :copy-a))
(t/is (some? (:shape-ref (shape :copy-b-child)))))
(t/testing "the selected copies are detached"
(await (step (select :copy-a :copy-b) dwl/detach-selected-components))
(t/is (not (linked? :copy-a)))
(t/is (not (linked? :copy-b)))))
(t/deftest ^:async reset-components-is-one-undo-entry
(install-file! (two-components))
(await (step (set-fill :copy-a-child :copy-b-child)))
(t/is (= override (fill :copy-a-child)))
(let [before (undo-entries)]
(await (step (dwl/reset-components [(cthi/id :copy-a) (cthi/id :copy-b)])))
(t/is (= main-color (fill :copy-a-child)))
(t/is (= main-color (fill :copy-b-child)))
(t/is (= (inc before) (undo-entries)))
(await (step dwu/undo))
(t/is (= override (fill :copy-a-child)))
(t/is (= override (fill :copy-b-child)))))
(defn- copies-of
[component-label]
(->> (vals (:objects (cthf/current-page (ftm/current-file))))
(filter #(and (ctk/instance-head? %)
(not (ctk/main-instance? %))
(= (cthi/id component-label) (:component-id %))))))
(t/deftest ^:async multi-swap-is-one-undo-entry
(install-file! (two-components))
(let [before (undo-entries)]
(await (step (dwl/component-multi-swap [(shape :copy-a) (shape :copy-b)]
(:id (ftm/current-file))
(cthi/id :c2))))
(t/is (= 0 (count (copies-of :c1))))
(t/is (= 2 (count (copies-of :c2))))
(t/is (= (inc before) (undo-entries)))
(await (step dwu/undo))
(t/is (= 2 (count (copies-of :c1))))
(t/is (= 0 (count (copies-of :c2))))))
(t/deftest ^:async add-multiple-components-is-one-undo-entry
(install-file! (two-components))
(let [before (undo-entries)]
(await (step (select :rect-a :rect-b) (dwl/add-multiple-components)))
(t/is (= 4 (component-count)))
(t/is (= (inc before) (undo-entries)))
(await (step dwu/undo))
(t/is (= 2 (component-count)))))
(t/deftest ^:async add-multiple-components-skips-copies
(install-file! (two-components))
(await (step (select :rect-a :copy-a-child) (dwl/add-multiple-components)))
(t/is (= 2 (component-count))))
(t/deftest ^:async add-component-sets-the-id-ref
(install-file! (two-components))
(let [id-ref (atom nil)]
(await (step (dwl/add-component id-ref [(cthi/id :rect-a)])))
(t/is (some? (ctkl/get-component (data) @id-ref)))))
(t/deftest ^:async duplicate-component-makes-a-new-main
(install-file! (two-components))
(await (step (dwl/duplicate-component (:id (ftm/current-file)) (cthi/id :c1))))
(let [new-component (->> (ctkl/components-seq (data))
(remove #(#{(cthi/id :c1) (cthi/id :c2)} (:id %)))
first)]
(t/is (= 3 (component-count)))
(t/is (some? (shape-by-id (:main-instance-id new-component))))))
(t/deftest ^:async restore-components-is-one-undo-entry
(install-file! (two-components))
(let [file-id (:id (ftm/current-file))]
(await (step (dwl/delete-component {:id (cthi/id :c1)})
(dwl/delete-component {:id (cthi/id :c2)})))
(t/is (= 0 (component-count)))
(let [before (undo-entries)]
(await (step (dwl/restore-components {(cthi/id :c1) file-id
(cthi/id :c2) file-id})))
(t/is (= 2 (component-count)))
(t/is (= (inc before) (undo-entries))))))
(t/deftest ^:async update-component-of-a-library-main
(let [library (-> (cthf/sample-file :library :is-shared true)
(ctho/add-simple-component :lib-c :lib-main :lib-main-child))
file (-> (cthf/sample-file :file1)
(cthc/instantiate-component :lib-c :copy
:library library
:children-labels [:copy-child]))]
(ftm/install! (-> (tm/make-situation file)
(tm/with-aux-files {(:id library) library})))
(await (step (set-fill :copy-child)))
(await (step (dwl/update-component (cthi/id :copy))))
(let [library' (get-in @st/state [:files (:id library)])]
(t/is (= override (-> (cths/get-shape library' :lib-main-child) :fills first :fill-color))))
(t/testing "a shape that is not a head is left as it is"
(await (step (dwl/update-component (cthi/id :copy-child))))
(t/is (= override (fill :copy-child))))))
(t/deftest ^:async rename-component-to-a-blank-name
(install-file! (two-components))
(let [before (:name (ctkl/get-component (data) (cthi/id :c1)))]
(await (step (dwl/rename-component (cthi/id :c1) " ")))
(t/is (= before (:name (ctkl/get-component (data) (cthi/id :c1)))))))
(t/deftest ^:async instantiate-component-sets-the-id-ref
(install-file! (two-components))
(let [id-ref (atom nil)]
(await (step (dwl/instantiate-component (:id (ftm/current-file)) (cthi/id :c2)
(gpt/point 0 0) {:id-ref id-ref :origin "plugin"})))
(t/is (= (cthi/id :c2) (:component-id (shape-by-id @id-ref))))))

View File

@ -0,0 +1,189 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.data.workspace-library-links-test
"Linking a library to the file and unlinking it, with the libraries it
brings along, and publishing the file as a shared library. The server is
mocked."
(:require
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.variants :as cthv]
[app.main.data.workspace.libraries :as dwl]
[app.main.repo :as rp]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[frontend-tests.helpers.state :as ths]
[potok.v2.core :as ptk]))
(t/use-fixtures :each {:before cthi/reset-idmap!})
(defn- rpc
"A mock of `rp/cmd!` that answers each command from `responses`
(command -> value, or fn of the params returning an observable), and
records the calls in `calls`."
[calls responses]
(mock/stub
(fn [cmd params & _]
(swap! calls conj [cmd params])
(let [response (get responses cmd)]
(if (fn? response)
(response params)
(rx/of response))))))
(defn- commands
[calls]
(set (map first @calls)))
(defn- merge-state
"An event that merges `m` into the state, for what the workspace loads
before these events run."
[m]
(ptk/reify ::merge-state
ptk/UpdateEvent
(update [_ state]
(merge-with merge state m))))
(defn- files
[]
{:file (cthf/sample-file :file1)
:library (-> (cthf/sample-file :library :is-shared true)
(ctho/add-simple-component :lib-c :lib-main :lib-main-child))
:dependency (-> (cthf/sample-file :dependency :is-shared true)
(ctho/add-simple-component :dep-c :dep-main :dep-main-child))})
(defn- linked-to
[state file-id]
(->> (vals (:files state))
(filter #(= file-id (:library-of %)))
(map :id)
set))
(t/deftest link-loads-the-library-and-its-dependencies
(t/async
done
(let [{:keys [file library dependency]} (files)
by-id (into {} (map (juxt :id identity)) [library dependency])
calls (atom [])]
(mock/with-mocks
{rp/cmd! (rpc calls {:link-file-to-library [(:id dependency)]
:get-file #(rx/of (get by-id (:id %)))
:get-file-object-thumbnails {"thumbnail-id" "thumbnail-uri"}
:get-library-usage {:used-in 3}})}
(fn [inner-done]
(ths/run-store
(ths/setup-store file) inner-done
[(merge-state {:shared-files {(:id library) {:library-summary {:variants [{} {}]}}}})
(dwl/link-file-to-library (:id file) (:id library))]
(fn [state]
(t/is (= #{(:id library) (:id dependency)} (linked-to state (:id file))))
(t/is (= "thumbnail-uri" (get-in state [:thumbnails "thumbnail-id" :uri])))
(t/testing "a library with variants asks how much it is used"
(t/is (contains? (commands calls) :get-library-usage))))))
done))))
(t/deftest link-skips-libraries-already-loaded
(t/async
done
(let [{:keys [file library dependency]} (files)
calls (atom [])]
(mock/with-mocks
{rp/cmd! (rpc calls {:link-file-to-library [(:id dependency)]
:get-file #(rx/of library)
:get-file-object-thumbnails {}})}
(fn [inner-done]
(ths/run-store
(ths/setup-store file {:libraries [dependency]}) inner-done
[(dwl/link-file-to-library (:id file) (:id library))]
(fn [_]
(t/is (= [(:id library)]
(->> @calls
(filter #(= :get-file (first %)))
(map (comp :id second)))))
(t/is (not (contains? (commands calls) :get-library-usage))))))
done))))
(t/deftest link-that-would-make-a-loop
(t/async
done
(let [{:keys [file library]} (files)
calls (atom [])]
(mock/with-mocks
{rp/cmd! (rpc calls {:link-file-to-library
(fn [_] (rx/throw (ex-info "loop" {:code :circular-library-reference})))})}
(fn [inner-done]
(ths/run-store
(ths/setup-store file) inner-done
[(dwl/link-file-to-library (:id file) (:id library))]
(fn [state]
(t/is (= :error (get-in state [:notification :level])))
(t/is (empty? (linked-to state (:id file)))))))
done))))
(t/deftest unlink-drops-the-library-and-what-it-brought
(t/async
done
(let [{:keys [file library dependency]} (files)
calls (atom [])]
(mock/with-mocks
{rp/cmd! (rpc calls {:unlink-file-from-library nil
:get-file-libraries []})}
(fn [inner-done]
(ths/run-store
(ths/setup-store file) inner-done
[(dwl/libraries-fetched (:id file) [library dependency])
(dwl/unlink-file-from-library (:id file) (:id library))]
(fn [state]
(t/is (empty? (linked-to state (:id file))))
(t/is (some? (get-in state [:files (:id file)]))))))
done))))
(t/deftest cleanup-keeps-the-libraries-still-linked
(t/async
done
(let [{:keys [file library dependency]} (files)]
(ths/run-store
(ths/setup-store file) done
[(dwl/libraries-fetched (:id file) [library dependency])
(dwl/cleanup-unlinked-libraries (:id file) [library])]
(fn [state]
(t/is (= #{(:id library)} (linked-to state (:id file)))))))))
(defn- share
[file shared? check]
(fn [done]
(let [calls (atom [])]
(mock/with-mocks
{rp/cmd! (rpc calls {:set-file-shared nil})}
(fn [inner-done]
(ths/run-store
(ths/setup-store file) inner-done
[(dwl/set-file-shared (:id file) shared?)]
#(check % @calls)))
done))))
(t/deftest share-a-file-with-variants
(t/async
done
(let [file (-> (cthf/sample-file :file1)
(cthv/add-variant :v01 :c01 :m01 :c02 :m02))]
((share file true
(fn [state calls]
(t/is (true? (get-in state [:files (:id file) :is-shared])))
(t/is (= [[:set-file-shared {:id (:id file) :is-shared true}]] calls))))
done))))
(t/deftest unshare-a-file
(t/async
done
(let [file (cthf/sample-file :file1)]
((share file false
(fn [state calls]
(t/is (false? (get-in state [:files (:id file) :is-shared])))
(t/is (= [[:set-file-shared {:id (:id file) :is-shared false}]] calls))))
done))))

View File

@ -0,0 +1,203 @@
;; 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 SUBSIDIARY SL
(ns frontend-tests.data.workspace-library-updates-test
"How a file learns that a linked library changed and offers to sync: the
library change pushed by the server, the check of which libraries need a
sync, the sync dialog and its update and dismiss actions, and the sync
itself from an external library."
(:require
[app.common.test-helpers.components :as cthc]
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.compositions :as ctho]
[app.common.test-helpers.files :as cthf]
[app.common.test-helpers.ids-map :as cthi]
[app.common.test-helpers.shapes :as cths]
[app.common.types.file :as ctf]
[app.main.data.workspace.libraries :as dwl]
[app.main.repo :as rp]
[app.main.store :as st]
[cljs.test :as t :include-macros true]
[frontend-tests.composable-tests.interpreter :as ftm]
[frontend-tests.helpers.mock :as mock]
[potok.v2.core :as ptk]))
(t/use-fixtures :each ftm/fixture)
(def ^:private old-color "#111111")
(def ^:private new-color "#222222")
(def ^:private synced-at (js/Date. 1000))
(def ^:private component-changed-at (js/Date. 2000))
(def ^:private library-changed-at (js/Date. 3000))
(defn- library-and-file
"A library whose component :lib-c changed after the file last synced
with it, and a file with a copy of :lib-c. With `changed?`, the main
child got a fill after the copy was made, so a sync changes the copy."
[changed?]
(let [library (-> (cthf/sample-file :library :is-shared true)
(ctho/add-simple-component :lib-c :lib-main :lib-main-child
:child-params {:fills (cths/sample-fills-color
:fill-color old-color)})
;; `ctkl/update-component` would stamp the current date
(ctf/update-file-data
#(assoc-in % [:components (cthi/id :lib-c) :modified-at] component-changed-at))
(assoc :synced-at synced-at
:modified-at library-changed-at))
file (-> (cthf/sample-file :file1)
(cthc/instantiate-component :lib-c :copy
:library library
:children-labels [:copy-child])
(ctho/add-rect :rect))]
[(cond-> library
changed?
(cths/update-shape :lib-main-child :fills (cths/sample-fills-color :fill-color new-color)))
file]))
(defn- install-with-library!
[changed?]
(let [[library file] (library-and-file changed?)]
(ftm/install! (-> (tm/make-situation file)
(tm/with-aux-files {(:id library) library})))
library))
(defn- live-library
[]
(get-in @st/state [:files (cthi/id :library)]))
(defn- copy-fill
[]
(-> (cths/get-shape (ftm/current-file) :copy-child) :fills first :fill-color))
(defn- sleep
[ms]
(js/Promise. (fn [resolve] (js/setTimeout resolve ms))))
(defn- await-until
"Wait up to a second for `ready?`."
[ready?]
(ftm/await-step [] 1000 ready?))
(defn- notification
[]
(:notification @st/state))
(defn- rpc-commands
[]
(set (map :cmd @mock/rpc-calls)))
;; ----- Which libraries need a sync
(t/deftest assets-need-sync
(let [[library file] (library-and-file true)
data (:data file)]
(t/testing "a library changed after the last sync, with a used component changed too"
(t/is (= [{:shape-id (cthi/id :copy) :asset-id (cthi/id :lib-c)
:asset-type :component :container-id (cthf/current-page-id file)}]
(vec (dwl/assets-need-sync library data)))))
(t/testing "the file itself"
(t/is (nil? (dwl/assets-need-sync library (assoc data :id (:id library))))))
(t/testing "a library not changed since the last sync"
(t/is (nil? (dwl/assets-need-sync (assoc library :synced-at (js/Date. 4000)) data))))
(t/testing "changes before the date the user dismissed the updates"
(t/is (nil? (dwl/assets-need-sync library data (js/Date. 4000)))))
(t/testing "a used component that did not change"
(t/is (empty? (dwl/assets-need-sync (assoc library :synced-at (js/Date. 2500)) data))))))
;; ----- The sync dialog
(defn- sync-dialog-shown?
[]
(= :sync-dialog (:tag (notification))))
(t/deftest ^:async sync-dialog-updates-the-copies
(await
(mock/with-mocks*
{rp/cmd! mock/rpc-cmd-mock}
(install-with-library! true)
(t/is (= :settled (await (ftm/await-step [(dwl/notify-sync-file)] 1000 sync-dialog-shown?))))
((-> (notification) :accept :callback))
(await (await-until #(= new-color (copy-fill))))
(t/is (= new-color (copy-fill)))
(t/is (not= :visible (:status (notification))))
(t/is (not= synced-at (:synced-at (live-library))))
(t/is (contains? (rpc-commands) :update-file-library-sync-status)))))
(t/deftest ^:async sync-dialog-dismiss-ignores-the-changes
(await
(mock/with-mocks*
{rp/cmd! mock/rpc-cmd-mock}
(install-with-library! true)
(await (ftm/await-step [(dwl/notify-sync-file)] 1000 sync-dialog-shown?))
((-> (notification) :cancel :callback))
(t/is (some? (:ignore-sync-until (ftm/current-file))))
(t/is (contains? (rpc-commands) :ignore-file-library-sync-status))
(t/is (= old-color (copy-fill)))
(t/testing "no dialog for the same changes afterwards"
(st/emit! (dwl/notify-sync-file))
(await (sleep 50))
(t/is (not (sync-dialog-shown?)))))))
(t/deftest ^:async no-sync-dialog-when-the-sync-changes-nothing
(install-with-library! false)
(st/emit! (dwl/notify-sync-file))
(await (sleep 50))
(t/is (not (sync-dialog-shown?))))
(defn- view-only
[]
(ptk/reify ::view-only
ptk/UpdateEvent
(update [_ state]
(assoc-in state [:permissions :can-edit] false))))
(t/deftest ^:async no-sync-dialog-without-edit-permission
(install-with-library! true)
(st/emit! (view-only) (dwl/notify-sync-file))
(await (sleep 50))
(t/is (not (sync-dialog-shown?))))
;; ----- Changes pushed by the server
(defn- rename-change
[library]
{:type :mod-obj
:page-id (cthf/current-page-id library)
:id (cthi/id :lib-main-child)
:operations [{:type :set :attr :name :val "Renamed"}]})
(t/deftest ^:async library-change-from-the-server
(await
(mock/with-mocks*
{dwl/fetch-library-thumbnails (mock/stub (fn [_] (ptk/data-event ::no-thumbnails)))}
(let [library (install-with-library! true)]
(await (ftm/await-step [(dwl/ext-library-changed (:id library) (js/Date. 5000) 7
[(rename-change library)])]))
(let [library' (live-library)]
(t/is (= 7 (:revn library')))
(t/is (= (js/Date. 5000) (:modified-at library')))
(t/is (= "Renamed" (:name (cths/get-shape library' :lib-main-child)))))))))
;; ----- Sync from an external library
(t/deftest ^:async sync-from-an-external-library
(await
(mock/with-mocks*
{rp/cmd! mock/rpc-cmd-mock}
(let [library (install-with-library! true)]
(await (ftm/await-step [(dwl/set-updating-library true)
(dwl/sync-file (:id (ftm/current-file)) (:id library))]))
(t/is (= new-color (copy-fill)))
(t/is (not (:updating-library @st/state)))
(t/is (not= synced-at (:synced-at (live-library))))
(t/is (contains? (rpc-commands) :update-file-library-sync-status))))))
(t/deftest ^:async set-updating-library
(await (ftm/await-step [(dwl/set-updating-library true)]))
(t/is (true? (:updating-library @st/state)))
(await (ftm/await-step [(dwl/set-updating-library false)]))
(t/is (nil? (:updating-library @st/state))))

View File

@ -32,6 +32,9 @@
[frontend-tests.data.workspace-context-menu-test]
[frontend-tests.data.workspace-edit-watchers-test]
[frontend-tests.data.workspace-interactions-test]
[frontend-tests.data.workspace-libraries-test]
[frontend-tests.data.workspace-library-links-test]
[frontend-tests.data.workspace-library-updates-test]
[frontend-tests.data.workspace-mcp-test]
[frontend-tests.data.workspace-media-test]
[frontend-tests.data.workspace-pages-test]
@ -194,6 +197,9 @@
'frontend-tests.data.workspace-context-menu-test
'frontend-tests.data.workspace-edit-watchers-test
'frontend-tests.data.workspace-interactions-test
'frontend-tests.data.workspace-libraries-test
'frontend-tests.data.workspace-library-links-test
'frontend-tests.data.workspace-library-updates-test
'frontend-tests.data.workspace-mcp-test
'frontend-tests.data.workspace-media-test
'frontend-tests.data.workspace-pages-test