penpot/common/test/common_tests/logic/comp_sync_test.cljc
alonso.torres 37c6fb0e36 🐛 Fix component sync, reset and update main
Fixes in the sync of copies from their mains, in reset and in update main
(Phase 3c).

- Undo a token sync to the tokens a copy shape had.

  The token sync of a copy shape wrote, as its undo, the shape's tokens read
  with an empty map as default, so undoing the sync of a main's token gave a
  copy shape that had none an empty :applied-tokens map instead of no map
  (F55). The undo now writes the value the shape had.

  Case AY no longer marks its round trip as a known failure; both golden
  masters record the round trip without failures in the two variants that
  hit it.

- Reorder a copy's children with the fewest moves and exact undo.

  When a main reordered its children, sync wrote a move for every copy child
  the walk found out of place, from its position in the copy's original list
  to its main's index:
  - once one child was out of place, every following pair was too, so a main
    with 1,000 children that moved its first one to the end wrote 999 moves
    per copy, quadratic in the children (F85);
  - :mov-objects inserts before the index of the list that still holds the
    shape, so a move to a later place landed one short, and the undo, which
    used the original position, did not put the child back: undoing a
    reorder in a main left its copies reordered (F41).

  The walk now syncs the pairs, removals and additions first, and then moves
  the copy's children, as those changes left them, into their main shapes'
  order: the longest run already in order stays, and each other child moves
  right after the one before it in the main's order. Each move is computed
  on the order the moves before it left, so its index is exact, and its undo
  puts the child back after its old neighbour.

  The update main walk keeps its moves: the copy's order differs from the
  main's only after F41's undo, as the UI never reorders a copy.

  comp-sync-test: a reorder in a six-child main moves each displaced copy
  child once, gives the main's order, and undoes exactly. Cases F, AG and AO
  no longer mark their round trip as a known failure; both golden masters
  record it without failures.

- Remap a main's grid cells after the update main pairs children.

  An update main from a copy of a grid main pushes the copy's cells into the
  main, with each copy child mapped to its main shape. The cells were
  remapped before the children walk, when a swapped copy child still
  referred to the main child it replaces; the walk then removed that child
  and added a new main child for the swapped one. The main's cell kept the
  removed child's id and the new child was in no cell (F49); the next sync
  of the copy took that broken cell back.

  The cells are now remapped after the walk: each copy child goes with the
  main shape the walk added for it, or else with the one it refers to.

  Case AK no longer pins F49: with a swap, the main's third cell holds the
  new main child, the main takes the copy's cells as they are (the swap had
  made the first one manual), and the copy keeps its child in the third
  cell. Both golden masters record that.

- Add update main's new main shapes and their undo in the main.

  When update main pushes a copy child the main lacks (a swapped child), it
  adds a copy of it to the main (F102):
  - the refit of the groups around it was a :reg-objects that named the
    component, which reaches only a deleted component's stored shapes, so a
    group in the main kept its old bounds;
  - the undo deleted the new shapes from the copy's page, so when the main
    sat on another page the undo left them in the main.

  The new shapes, the refit of their parents and the undo of both now go to
  the main's container; undoing deletes the new shapes and refits the
  parents again.

  The case AM setup and the new test fit their groups to their children, as
  the editor does; an unfitted group's size is one the refit cannot give
  back on undo. Both golden masters record case AM with the fitted groups.

  comp-sync-test: an update main from a copy on one page into a main on
  another fits the group around the pushed copy, and undo gives the main
  back.

- Sync flips with the rest of the geometry.

  A flip changes a shape's transform, points and selrect, which sync and
  touch as geometry, and toggles :flip-x or :flip-y, which were not in
  sync-attrs: when a main flipped a child, its copies took the flipped
  transform but kept their old flip flags, which the renderers and the
  placement of swapped shapes read (F38). A flip on a copy touched geometry
  through the transform all the same.

  :flip-x and :flip-y now sync and touch as :geometry-group.

  comp-sync-test: a copy follows its main's flip, flags and transform,
  untouched.

- Reset a nested copy of a flex board keeping its children.

  Sync gives each child of a copy of a flex board the flex child attributes
  (margins, sizing, z-index, ...) of the main child it refers to. It looked
  that child up in the copy head's own component; for a nested copy that is
  not where its children's mains are (they are the children of the nested
  copy in the outer main), so the lookup gave nil, and a reset, which copies
  every attribute, wrote nil into all of them (F103). A child with no main
  was written nil the same way.

  The lookup now uses the container the walk reads the main shapes from,
  where every child's :shape-ref points, and a child with no main shape is
  left as it is.

  comp-reset-test: resetting a nested copy of a flex board keeps its child's
  margin (with a grid board too, whose children F15 leaves).

- Sync layout child attributes of the children of grid mains.

  Sync and update main copy the layout child attributes of a board's
  children (margins, sizing limits, z-index, absolute position, align-self)
  from the shapes they pair with, but only when the main was a flex board:
  the children of a grid main never got them, so a margin set on a grid
  main's child never reached its copies (F15). Grid boards now go through
  the same step, renamed update-layout-children.

  comp-sync-test: a copy follows the margin its grid main gives a child.
  comp-reset-test checks the reset of a nested grid copy keeps it too.

- Clear remote-synced when a nested copy is reset.

  A reset makes a copy equal to its near main, but the reset of a nested
  head marked every shape below the copy root :remote-synced, the mark of a
  shape last synced from a remote main. Update main reads that mark: it
  gives the main the copy's overrides only for shapes without it, and
  otherwise clears the main's touched groups. So after resetting a nested
  copy, an update main from the top copy cleared the overrides of the nested
  copy in the main, which the next sync from its own component then
  overwrote (F13).

  A reset now clears the mark, as the shapes follow their near main.

  comp-reset-test: after a reset of a nested copy, an update main from the
  top copy keeps the fill override of the nested copy in the main. Both
  golden masters record case AB without the marks.

- Check that a nested copy keeps its constraints.

  A copy of one component dropped into another's main, whose constraints the
  outer main then sets, keeps them, in that main and in its copies, when the
  inner component's main is edited: the setting is an override of the nested
  copy. The test guards it; the reset of the constraints a drop writes,
  which looked like a loss (F45), changes nothing a user sees, as nil means
  the same left/top.

- Undo a library typography sync to the text's measured layout.

  The library color and typography syncs rewrite a text's content and clear
  its :position-data, so the text is measured again. Their undo put back the
  old content but cleared the position data too, so an undone sync left the
  text unmeasured (F90). The undo now writes back the position data the text
  had.

  library-asset-sync-test: undoing a typography sync gives back the font
  size and the position data.

- File the copy's relinks of an update main with the copy's file.

  Update main from a copy of a library component writes some changes in the
  library (the main) and some in the copy's file; the frontend commits each
  part to its file, telling them apart by the changes of the copy shapes the
  walk visited (F9). A swapped copy child is not visited: update main adds a
  main shape for it and relinks the copy shapes to it, and those relinks,
  changes of the copy, went with the library's changes. The shapes it
  relinks now count as the copy's.

  comp-sync-test: pushing a swap from a copy of a library component keeps
  every change of the copy's shapes in the local part.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  16.9 / 0.48
  edit-through-a-chain-of-dependents   68.9 /  33.0 / 0.48
  library-update-into-copies           83.1 /  40.9 / 0.49
  main-edit-among-many-shapes           4.8 /   2.3 / 0.48
  main-edit-into-10-copies              1.8 /   0.8 / 0.45
  main-edit-into-100-copies            15.4 /   7.8 / 0.51
  main-edit-into-1000-copies          153.1 /  82.2 / 0.54
  variant-switch-into-copies           32.1 /  18.2 / 0.57

AI-assisted-by: claude-opus-5-5
2026-10-16 11:05:21 +02:00

827 lines
43 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 SUBSIDIARY SL
(ns common-tests.logic.comp-sync-test
(:require
[app.common.data :as d]
[app.common.files.changes :as ch]
[app.common.files.changes-builder :as pcb]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.geom.shapes :as gsh]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[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.types.component :as ctk]
[app.common.types.container :as ctn]
[app.common.types.file :as ctf]
[app.common.types.modifiers :as ctm]
[app.common.types.shape-tree :as ctst]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(t/deftest test-sync-when-changing-attribute
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-simple-component-with-copy :component1
:main-root
:main-child
:copy-root
:main-child-params {:fills (ths/sample-fills-color
:fill-color "#abcdef")}
:copy-root-params {:children-labels [:copy-child]}))
page (thf/current-page file)
main-child (ths/get-shape file :main-child)
;; ==== Action
changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id main-child)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
updated-file (thf/apply-changes file changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
copy-root' (ths/get-shape file' :copy-root)
copy-child' (ths/get-shape file' :copy-child)
fills' (:fills copy-child')
fill' (first fills')]
;; ==== Check
(t/is (some? copy-root'))
(t/is (some? copy-child'))
(t/is (= (count fills') 1))
(t/is (= (:fill-color fill') "#fabada"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child') nil))))
(t/deftest test-sync-when-changing-attribute-from-library
(let [;; ==== Setup
library (-> (thf/sample-file :file1)
(tho/add-simple-component :component1
:main-root
:main-child
:copy-root
:main-child-params {:fills (ths/sample-fills-color
:fill-color "#abcdef")}))
file (-> (thf/sample-file :file)
(thc/instantiate-component :component1 :copy-root
:library library
:children-labels [:copy-child]))
page (thf/current-page library)
main-child (ths/get-shape library :main-child)
;; ==== Action
changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id main-child)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
updated-library (thf/apply-changes library changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id file)
(thi/id :component1)
(:id updated-library)
{(:id updated-library) updated-library
(:id file) file}
(:id file))
file' (thf/apply-changes file changes2)
;; ==== Get
copy-root' (ths/get-shape file' :copy-root)
copy-child' (ths/get-shape file' :copy-child)
fills' (:fills copy-child')
fill' (first fills')]
;; ==== Check
(t/is (some? copy-root'))
(t/is (some? copy-child'))
(t/is (= (count fills') 1))
(t/is (= (:fill-color fill') "#fabada"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child') nil))))
(t/deftest test-sync-when-changing-attribute-preserve-touched
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-simple-component-with-copy :component1
:main-root
:main-child
:copy-root
:main-child-params {:fills (ths/sample-fills-color
:fill-color "#abcdef")}
:copy-root-params {:children-labels [:copy-child]}))
page (thf/current-page file)
main-child (ths/get-shape file :main-child)
copy-child (ths/get-shape file :copy-child)
;; ==== Action
changes1 (-> (pcb/empty-changes nil (:id page))
(cls/generate-update-shapes
#{(:id copy-child)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#aaaaaa")))
(:objects page)
{})
(cls/generate-update-shapes
#{(:id main-child)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{}))
updated-file (thf/apply-changes file changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
copy-root' (ths/get-shape file' :copy-root)
copy-child' (ths/get-shape file' :copy-child)
fills' (:fills copy-child')
fill' (first fills')]
;; ==== Check
(t/is (some? copy-root'))
(t/is (some? copy-child'))
(t/is (= (count fills') 1))
(t/is (= (:fill-color fill') "#aaaaaa"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child') #{:fill-group}))))
(t/deftest test-sync-when-adding-shape
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-simple-component-with-copy :component1
:main-root
:main-child
:copy-root
:copy-root-params {:children-labels [:copy-child]})
(ths/add-sample-shape :free-shape))
page (thf/current-page file)
;; ==== Action
changes1 (cls/generate-relocate (-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page)))
(thi/id :main-root) ; parent-id
0 ; to-index
#{(thi/id :free-shape)}) ; ids
updated-file (thf/apply-changes file changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
main-free-shape' (ths/get-shape file' :free-shape)
copy-root' (ths/get-shape file' :copy-root)
copy-new-child-id' (d/seek #(not= % (thi/id :copy-child)) (:shapes copy-root'))
copy-new-child' (ths/get-shape-by-id file' copy-new-child-id')]
;; ==== Check
(t/is (some? copy-root'))
(t/is (some? copy-new-child'))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-new-child') nil))
(t/is (ctst/parent-of? copy-root' copy-new-child'))
(t/is (ctk/is-main-of? main-free-shape' copy-new-child'))))
(t/deftest test-sync-when-deleting-shape
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-simple-component-with-copy :component1
:main-root
:main-child
:copy-root
:copy-root-params {:children-labels [:copy-child]}))
page (thf/current-page file)
main-child (ths/get-shape file :main-child)
;; ==== Action
;; IMPORTANT: as modifying copies structure is now forbidden, this action will not
;; delete the child shape, but hide it (thus setting the visibility group).
[_all-parents changes1]
(cls/generate-delete-shapes (pcb/empty-changes)
file
page
(:objects page)
#{(:id main-child)}
{})
updated-file (thf/apply-changes file changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
copy-root' (ths/get-shape file' :copy-root)
copy-child' (ths/get-shape file' :copy-child)]
;; ==== Check
(t/is (some? copy-root'))
(t/is (nil? copy-child'))
(t/is (= (:touched copy-root') nil))
(t/is (empty? (:shapes copy-root')))))
(t/deftest test-sync-when-moving-shape
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-component-with-many-children-and-copy :component1
:main-root
[:main-child1 :main-child2 :main-child3]
:copy-root
:copy-root-params {:children-labels [:copy-child1
:copy-child2
:copy-child3]})
(ths/add-sample-shape :free-shape))
page (thf/current-page file)
main-child1 (ths/get-shape file :main-child1)
;; ==== Action
changes1 (cls/generate-relocate (-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page)))
(thi/id :main-root) ; parent-id
2 ; to-index
#{(:id main-child1)}) ; ids
updated-file (thf/apply-changes file changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
copy-root' (ths/get-shape file' :copy-root)
copy-child1' (ths/get-shape file' :copy-child1)
copy-child2' (ths/get-shape file' :copy-child2)
copy-child3' (ths/get-shape file' :copy-child3)]
;; ==== Check
(t/is (some? copy-root'))
(t/is (some? copy-child1'))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child1') nil))
(t/is (= (:touched copy-child2') nil))
(t/is (= (:touched copy-child3') nil))
(t/is (= (second (:shapes copy-root')) (:id copy-child1')))
(t/is (= (first (:shapes copy-root')) (:id copy-child2')))
(t/is (= (nth (:shapes copy-root') 2) (:id copy-child3')))))
(defn- reorder-main-and-sync
"A main with six children, and its copy; the main moves the children
`ids` to `to-index`, then syncs. Returns `[file-before file-after
sync-changes]`."
[labels to-index]
(let [main-labels [:m1 :m2 :m3 :m4 :m5 :m6]
file (-> (thf/sample-file :file1)
(tho/add-component-with-many-children-and-copy
:component1 :main-root main-labels :copy-root
:copy-root-params {:children-labels [:c1 :c2 :c3 :c4 :c5 :c6]}))
page (thf/current-page file)
relocate (cls/generate-relocate (-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page)))
(thi/id :main-root)
to-index
(into #{} (map thi/id) labels))
moved (thf/apply-changes file relocate)
sync (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id moved)
(thi/id :component1)
(:id moved)
{(:id moved) moved}
(:id moved))]
[moved (thf/apply-changes moved sync) sync]))
(defn- copy-order
[file]
(mapv #(:shape-ref (ths/get-shape-by-id file %)) (:shapes (ths/get-shape file :copy-root))))
(defn- moves
[changes]
(count (filter #(= :mov-objects (:type %)) (:redo-changes changes))))
(t/deftest test-sync-of-a-reorder-moves-each-displaced-child-once
(doseq [[labels to-index displaced] [[[:m1] 6 1] ; first to the end
[[:m6] 0 1] ; last to the start
[[:m2] 5 1] ; one to a later place
[[:m5] 1 1] ; one to an earlier place
[[:m1 :m2] 6 2]]]
(let [[moved synced sync] (reorder-main-and-sync labels to-index)
main-order (:shapes (ths/get-shape moved :main-root))]
(t/testing (str labels " to " to-index)
(t/is (= main-order (copy-order synced)))
(t/is (= displaced (moves sync)))
(t/testing "undo puts the copy's children back"
(t/is (= (copy-order moved) (copy-order (thf/apply-undo-changes synced sync)))))))))
(t/deftest test-sync-when-changing-upper
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy :component1
:main1-root
:main1-child
:component2
:main2-root
:main2-nested-head
:copy2-root
:main2-root-params {:fills (ths/sample-fills-color
:fill-color "#abcdef")}))
page (thf/current-page file)
main2-root (ths/get-shape file :main2-root)
;; ==== Action
changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id main2-root)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
updated-file (thf/apply-changes file changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component2)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
copy2-root' (ths/get-shape file' :copy2-root)
fills' (:fills copy2-root')
fill' (first fills')]
;; ==== Check
(t/is (some? copy2-root'))
(t/is (= (count fills') 1))
(t/is (= (:fill-color fill') "#fabada"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy2-root') nil))))
(t/deftest test-sync-when-changing-lower-near
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy :component1
:main1-root
:main1-child
:component2
:main2-root
:main2-nested-head
:copy2-root
:copy2-root-params {:children-labels [:copy2-child]}))
page (thf/current-page file)
main2-nested-head (ths/get-shape file :main2-nested-head)
;; ==== Action
changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id main2-nested-head)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
updated-file (thf/apply-changes file changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component2)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
file' (thf/apply-changes updated-file changes2)
;; ==== Get
copy2-root' (ths/get-shape file' :copy2-root)
copy2-child' (ths/get-shape file' :copy2-child)
fills' (:fills copy2-child')
fill' (first fills')]
;; ==== Check
(t/is (some? copy2-root'))
(t/is (some? copy2-child'))
(t/is (= (count fills') 1))
(t/is (= (:fill-color fill') "#fabada"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy2-root') nil))
(t/is (= (:touched copy2-child') nil))))
(t/deftest test-sync-when-changing-lower-remote
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy :component1
:main1-root
:main1-child
:component2
:main2-root
:main2-nested-head
:copy2-root
:copy2-root-params {:children-labels [:copy2-child]}))
page (thf/current-page file)
main1-root (ths/get-shape file :main1-root)
;; ==== Action
changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id main1-root)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
updated-file (thf/apply-changes file changes1)
changes2 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))
synced-file (thf/apply-changes updated-file changes2)
changes3 (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id synced-file)
(thi/id :component2)
(:id synced-file)
{(:id synced-file) synced-file}
(:id synced-file))
file' (thf/apply-changes synced-file changes3)
;; ==== Get
copy2-root' (ths/get-shape file' :copy2-root)
copy2-child' (ths/get-shape file' :copy2-child)
fills' (:fills copy2-child')
fill' (first fills')]
;; ==== Check
(t/is (some? copy2-root'))
(t/is (some? copy2-child'))
(t/is (= (count fills') 1))
(t/is (= (:fill-color fill') "#fabada"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy2-root') nil))
(t/is (= (:touched copy2-child') nil))))
(t/deftest test-no-sync-changes-when-only-position-changes
;; Regression: the library sync dialog was shown even when a library component
;; was only moved (x/y changed). Position changes are normalised by
;; reposition-shape during sync and never propagate to copies, so
;; generate-sync-file-changes must return empty :redo-changes in this case.
(let [;; ==== Setup
;; Use integer width/height so that floating-point arithmetic of
;; move(+delta) followed by reposition(-delta) cancels out exactly.
file (-> (thf/sample-file :file1)
(tho/add-simple-component-with-copy :component1
:main-root
:main-child
:copy-root
:main-root-params {:width 100 :height 100}
:main-child-params {:width 50 :height 50}))
page (thf/current-page file)
main-root (ths/get-shape file :main-root)
main-child (ths/get-shape file :main-child)
;; ==== Action
;; Move the entire main component (root + child) by a non-zero integer delta.
;; This is a position-only change: no fills, strokes or other
;; attributes are modified.
delta (gpt/point 100 150)
changes1 (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id main-root) (:id main-child)}
(fn [shape] (gsh/move shape delta))
(:objects page)
{})
updated-file (thf/apply-changes file changes1)
;; Run the full sync to check whether any real redo-changes are produced.
;; The fixed frontend code filters out libraries whose sync produces no
;; :redo-changes before showing the "library updated" notification.
sync-changes (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id updated-file)
(thi/id :component1)
(:id updated-file)
{(:id updated-file) updated-file}
(:id updated-file))]
;; ==== Check
;; A position-only change in the main component must not propagate to copies
;; and therefore must produce no redo-changes.
(t/is (empty? (:redo-changes sync-changes)))))
(t/deftest test-update-main-from-a-copy-with-reordered-children
(let [;; ==== Setup
file (-> (thf/sample-file :file1)
(tho/add-component-with-many-children-and-copy :component1
:main-root
[:main-child1 :main-child2]
:copy-root
:copy-root-params {:children-labels [:copy-child1 :copy-child2]}))
page (thf/current-page file)
;; The UI never reorders the children of a copy, so the order is
;; set directly: the copy holds them in the reverse order of the main.
file (update-in file [:data :pages-index (:id page) :objects (thi/id :copy-root) :shapes]
(comp vec reverse))
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id :copy-child2)}
#(assoc % :hidden true)
(:objects page)
{})
file (thf/apply-changes file changes)
;; ==== Action
page (thf/current-page file)
container (ctn/make-container page :page)
changes (-> (pcb/empty-changes)
(pcb/with-container container)
(cll/generate-sync-shape-inverse {(:id file) file}
container
(thi/id :copy-root)))
file' (thf/apply-changes file changes)
;; ==== Get
main-root' (ths/get-shape file' :main-root)
main-child2' (ths/get-shape file' :main-child2)]
;; ==== Check
(t/is (true? (:hidden main-child2')))
(t/is (= [(thi/id :main-child2) (thi/id :main-child1)] (:shapes main-root')))))
(t/deftest test-update-main-of-a-swap-into-a-main-on-another-page
;; component2's main, on its own page, holds a copy of component1 inside a
;; group; its copy, on another page, swaps that copy for a bigger component3
;; and pushes it to the main
(let [file (-> (thf/sample-file :file1 :page-label :main-page)
(tho/add-simple-component :c1 :main1 :main1-child
:root-params {:width 10 :height 10}
:child-params {:width 10 :height 10})
(tho/add-simple-component :c3 :main3 :main3-child
:root-params {:width 80 :height 80}
:child-params {:width 80 :height 80})
(tho/add-frame :main2 {:width 200 :height 200})
(tho/add-group :group :parent-label :main2)
(thc/instantiate-component :c1 :nested :parent-label :group)
(tho/fit-group :group)
(thc/make-component :c2 :main2)
(thf/add-sample-page :copy-page)
(thc/instantiate-component :c2 :copy2 :children-labels [:copy-group :copy-nested])
(tho/swap-component-in-shape :copy-nested :c3 {:page-label :copy-page
:new-shape-label :swapped}))
page (thf/current-page file)
container (ctn/make-container page :page)
changes (-> (pcb/empty-changes)
(pcb/with-container container)
(cll/generate-sync-shape-inverse {(:id file) file} container (thi/id :copy2)))
file' (thf/apply-changes file changes)
group' (ths/get-shape file' :group :page-label :main-page)
pushed (ths/get-shape-by-id file' (first (:shapes group')) :page-label :main-page)]
(t/is (= (thi/id :c3) (:component-id pushed)))
(t/testing "the group takes the size of the pushed copy"
(t/is (== 80 (-> group' :selrect :width) (-> group' :selrect :height))))
(t/testing "undo gives the main back as it was"
(let [file'' (thf/apply-undo-changes file' changes)]
(t/is (= (:shapes (ths/get-shape file :group :page-label :main-page))
(:shapes (ths/get-shape file'' :group :page-label :main-page))))
(t/is (= (count (:objects (thf/get-page file :main-page)))
(count (:objects (thf/get-page file'' :main-page)))))))))
(t/deftest test-an-assign-on-a-main-changes-its-component
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component-with-copy :component1 :main-root :main-child :copy-root))
page (thf/current-page file)
change (fn [operation]
{:type :mod-obj
:id (thi/id :main-child)
:page-id (:id page)
:operations [operation]})]
(t/is (= #{(thi/id :component1)}
(ch/components-changed (:data file) (change {:type :set :attr :opacity :val 0.5}))))
(t/is (= #{(thi/id :component1)}
(ch/components-changed (:data file) (change {:type :assign :value {:opacity 0.5}}))))
(t/is (nil? (ch/components-changed (:data file) (change {:type :assign :value {:interactions []}}))))))
(t/deftest test-sync-of-a-flip
;; the main flips its child as the editor does: a transform that scales
;; it by -1, which toggles :flip-x
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component-with-copy :component1 :main-root :main-child :copy-root
:copy-root-params {:children-labels [:copy-child]}))
page (thf/current-page file)
flip (fn [shape]
(gsh/transform-shape shape (ctm/resize-modifiers (gpt/point -1 1) (gsh/shape->center shape))))
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id :main-child)}
flip
(:objects page)
{})
flipped (thf/apply-changes file changes)
sync (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id flipped)
(thi/id :component1)
(:id flipped)
{(:id flipped) flipped}
(:id flipped))
file' (thf/apply-changes flipped sync)]
(t/is (true? (:flip-x (ths/get-shape flipped :main-child))))
(t/is (true? (:flip-x (ths/get-shape file' :copy-child))))
(t/is (= (:transform (ths/get-shape file' :main-child))
(:transform (ths/get-shape file' :copy-child))))
(t/is (nil? (:touched (ths/get-shape file' :copy-child))))))
(t/deftest test-sync-of-a-grid-child-margin
(let [file (-> (thf/sample-file :file1)
(tho/add-grid-frame :main-root)
(tho/add-rect :main-child {:parent-label :main-root})
(tho/place-in-grid-columns :main-root 1 {1 :main-child})
(thc/make-component :component1 :main-root)
(thc/instantiate-component :component1 :copy-root :children-labels [:copy-child]))
page (thf/current-page file)
margin {:m1 9 :m2 9 :m3 9 :m4 9}
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id :main-child)}
#(assoc % :layout-item-margin margin :layout-item-margin-type :simple)
(:objects page)
{})
edited (thf/apply-changes file changes)
sync (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id edited)
(thi/id :component1)
(:id edited)
{(:id edited) edited}
(:id edited))
file' (thf/apply-changes edited sync)]
(t/is (= margin (:layout-item-margin (ths/get-shape file' :copy-child))))))
(t/deftest test-which-changes-of-a-main-mark-its-component-modified
(let [file (-> (thf/sample-file :file1)
(tho/add-simple-component :component1 :main-root :main-child)
(ths/update-shape :main-child :position-data [{:x 0 :y 0 :width 3 :height 4 :text "a"}])
(ctf/update-file-data #(assoc-in % [:components (thi/id :component1) :modified-at] :old)))
page (thf/current-page file)
apply-op (fn [operations]
(-> (thf/apply-changes file {:redo-changes [{:type :mod-obj
:id (thi/id :main-child)
:page-id (:id page)
:operations operations}]}
:validate? false)
(thc/get-component :component1)
:modified-at))
fills {:type :set :attr :fills :val (ths/sample-fills-color :fill-color "#ff0000")}
pos-data {:type :set :attr :position-data :val [{:x 1 :y 2 :width 3 :height 4 :text "a"}]}]
(t/is (not= :old (apply-op [fills])))
(t/testing "a re-measured text is not a change of the component"
(t/is (= :old (apply-op [pos-data]))))
(t/testing "any synced attribute of the change counts, not only the last"
(t/is (not= :old (apply-op [fills pos-data]))))))
(t/deftest test-an-edit-of-a-nested-component-keeps-the-placement-of-its-copies
;; a copy of button dropped into the card main, which then sets it to
;; stick right (an override of the nested copy); an edit of the button
;; main keeps it, in the card main and in its copy
(let [sync (fn [f component]
(thf/apply-changes f (cll/generate-sync-file-changes (pcb/empty-changes) nil :components
(:id f) (thi/id component) (:id f)
{(:id f) f} (:id f))))
file (-> (thf/sample-file :file1)
(tho/add-simple-component :button :button-main :button-rect)
(tho/add-frame :card-main {:width 300 :height 300})
(thc/make-component :card :card-main)
(thc/instantiate-component :card :card-copy)
(thc/instantiate-component :button :nested-button))
page (thf/current-page file)
file (-> file
(thf/apply-changes (cls/generate-relocate (-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page)))
(thi/id :card-main) 0 #{(thi/id :nested-button)}))
(ths/update-shape :nested-button :constraints-h :right)
(sync :card))
page (thf/current-page file)
edit (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) #{(thi/id :button-rect)}
#(assoc % :opacity 0.5) (:objects page) {})
file' (-> (thf/apply-changes file edit) (sync :button) (sync :card))
copy #(ths/get-shape-by-id % (first (:shapes (ths/get-shape % :card-copy))))
placing (juxt :constraints-h :constraints-v)]
(t/is (= [:right :top] (placing (copy file))))
(t/is (= [:right :top] (placing (ths/get-shape file' :nested-button))))
(t/is (= [:right :top] (placing (copy file'))))
(t/is (= 0.5 (:opacity (ths/get-shape-by-id file' (first (:shapes (copy file')))))))))
(t/deftest test-update-main-of-a-library-copy-files-the-copy-changes-as-local
;; a copy of a library component swaps its nested copy and pushes it to
;; the library main: the copy's own changes stay in the file
(let [library (-> (thf/sample-file :library :is-shared true :page-label :lib-page)
(tho/add-nested-component :c1 :main1 :main1-child :c2 :main2 :nested-head)
(tho/add-simple-component :c3 :main3 :main3-child))
file (-> (thf/sample-file :file1 :page-label :page1)
(thc/instantiate-component :c2 :copy2 :library library
:children-labels [:copy-nested]))
file (tho/swap-component-in-shape file :copy-nested :c3 {:library library
:new-shape-label :swapped})
page (thf/current-page file)
cont (ctn/make-container page :page)
changes (-> (pcb/empty-changes)
(pcb/with-container cont)
(cll/generate-sync-shape-inverse {(:id file) file (:id library) library}
cont (thi/id :copy2)))
[local _] (cll/split-local-changes changes)
objects (:objects page)
in-copy (into #{} (cfh/get-children-ids-with-self objects (thi/id :copy2)))]
(t/is (contains? (into #{} (map :id) (:redo-changes local)) (thi/id :swapped)))
(t/testing "every change of a shape of the copy is local"
(t/is (every? #(not (contains? in-copy (:id %)))
(:redo-changes (second (cll/split-local-changes changes))))))))