penpot/common/test/common_tests/logic/comp_reset_test.cljc
alonso.torres a8f20d40b6 🐛 Keep parent orientation when resetting a nested copy
Resetting a selected nested copy must compose its parent placement even
when that parent remains a component copy.

Red regression reproduced; reset suite passes 16 tests / 63 assertions.

Benchmark vs Phase 1e: main 10/100/1000 0.51/0.64/0.65; levels 0.50;
chain 0.57; library 0.53; many shapes 0.47; switch 0.59. No flags.

AI-assisted-by: GPT-6
2026-10-30 09:50:08 +01:00

653 lines
31 KiB
Clojure
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;; 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-reset-test
(:require
[app.common.files.changes-builder :as pcb]
[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.composable.comp.nodes :as n]
[app.common.test-helpers.composable.comp.runner :as r]
[app.common.test-helpers.composable.comp.setups :as setup]
[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.normalize :as thn]
[app.common.test-helpers.shapes :as ths]
[app.common.types.container :as ctn]
[app.common.types.modifiers :as ctm]
[app.common.types.shape.layout :as ctl]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(t/deftest reset-a-nested-grid-keeps-the-near-mains-manual-cell
(let [file (-> (thf/sample-file :file1)
(tho/add-grid-frame :inner-root)
(tho/add-rect :inner-child :parent-label :inner-root)
(tho/place-in-grid-columns :inner-root 2 {1 :inner-child})
(thc/make-component :inner :inner-root)
(tho/add-frame :outer-root)
(thc/instantiate-component :inner :nested :parent-label :outer-root
:children-labels [:nested-child])
(thc/make-component :outer :outer-root)
(thc/instantiate-component :outer :copy
:children-labels [:copy-nested :copy-child]))
initial (tm/make-situation file)
moved (-> initial (r/step (n/move-to-cell :nested :nested-child 1 2)) r/settle)
reset (-> moved (r/step (n/reset :copy-nested)) r/settle)
column (fn [s]
(let [head (tm/resolve-shape s :copy-nested)]
(:column (ctl/get-cell-by-shape-id head (first (:shapes head))))))]
(t/is (= 2 (column moved)))
(t/is (= 2 (column reset)))
(t/is (nil? (:touched (tm/resolve-shape reset :copy-nested))))))
(t/deftest reset-a-nested-copy-through-a-deleted-outer-main
(let [initial (setup/nested-component-with-copy)
edited (-> initial
(r/step (n/change-property :copy-instance :opacity 0.4)) r/settle)
deleted (-> edited (r/step (n/delete-component :main2-root)) r/settle)
reset (-> deleted (r/step (n/reset :copy-instance)) r/settle)
shape (tm/role-shape reset :copy-instance)
undone (r/step reset (n/undo))
redone (r/step undone (n/redo))]
(t/is (= (:opacity (tm/role-shape initial :main-instance)) (:opacity shape)))
(t/is (nil? (:touched shape)))
(t/is (= (thn/normalize-file (tm/file deleted)) (thn/normalize-file (tm/file undone))))
(t/is (= (thn/normalize-file (tm/file reset)) (thn/normalize-file (tm/file redone))))))
(t/deftest test-reset-after-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)
copy-root (ths/get-shape file :copy-root)
copy-child (ths/get-shape file :copy-child)
;; ==== Action
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id copy-child)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
file-mdf (thf/apply-changes file changes)
page-mdf (thf/current-page file-mdf)
changes (cll/generate-reset-component (pcb/empty-changes)
file-mdf
{(:id file-mdf) file-mdf}
page-mdf
(:id copy-root))
file' (thf/apply-changes file changes)
;; ==== 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') "#abcdef"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child') nil))))
(t/deftest test-reset-from-library
(let [;; ==== Setup
library (-> (thf/sample-file :library :is-shared true)
(tho/add-simple-component :component1 :main-root :main-child
: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 file)
copy-root (ths/get-shape file :copy-root)
copy-child (ths/get-shape file :copy-child)
;; ==== Action
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id copy-child)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
file-mdf (thf/apply-changes file changes)
page-mdf (thf/current-page file-mdf)
changes (cll/generate-reset-component (pcb/empty-changes)
file-mdf
{(:id file-mdf) file-mdf
(:id library) library}
page-mdf
(:id copy-root))
file' (thf/apply-changes file changes)
;; ==== 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') "#abcdef"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child') nil))))
(t/deftest test-reset-after-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)
copy-root (ths/get-shape file :copy-root)
;; ==== Action
;; IMPORTANT: as modifying copies structure is now forbidden, this action
;; will not have any effect, and so the parent shape won't also be touched.
changes (cls/generate-relocate (-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page)))
(thi/id :copy-root) ; parent-id
0 ; to-index
#{(thi/id :free-shape)}) ; ids
file-mdf (thf/apply-changes file changes)
page-mdf (thf/current-page file-mdf)
changes (cll/generate-reset-component (pcb/empty-changes)
file-mdf
{(:id file-mdf) file-mdf}
page-mdf
(:id copy-root))
file' (thf/apply-changes file changes)
;; ==== 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 (some? copy-child'))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child') nil))))
(t/deftest test-reset-after-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)
copy-root (ths/get-shape file :copy-root)
copy-child (ths/get-shape file :copy-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 changes]
(cls/generate-delete-shapes (pcb/empty-changes)
file
page
(:objects page)
#{(:id copy-child)}
{})
file-mdf (thf/apply-changes file changes)
page-mdf (thf/current-page file-mdf)
changes (cll/generate-reset-component (pcb/empty-changes)
file-mdf
{(:id file-mdf) file-mdf}
page-mdf
(:id copy-root))
file' (thf/apply-changes file changes)
;; ==== 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 (some? copy-child'))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child') nil))))
(t/deftest test-reset-after-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-child]})
(ths/add-sample-shape :free-shape))
page (thf/current-page file)
copy-root (ths/get-shape file :copy-root)
copy-child1 (ths/get-shape file :copy-child)
;; ==== Action
;; IMPORTANT: as modifying copies structure is now forbidden, this action
;; will not have any effect, and so the parent shape won't also be touched.
changes (cls/generate-relocate (-> (pcb/empty-changes nil)
(pcb/with-page-id (:id page))
(pcb/with-objects (:objects page)))
(thi/id :copy-root) ; parent-id
2 ; to-index
#{(:id copy-child1)}) ; ids
file-mdf (thf/apply-changes file changes)
page-mdf (thf/current-page file-mdf)
changes (cll/generate-reset-component (pcb/empty-changes)
file-mdf
{(:id file-mdf) file-mdf}
page-mdf
(:id copy-root))
file' (thf/apply-changes file changes)
;; ==== 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 (some? copy-child'))
(t/is (= (:touched copy-root') nil))
(t/is (= (:touched copy-child') nil))))
(t/deftest test-reset-after-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)
copy2-root (ths/get-shape file :copy2-root)
;; ==== Action
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id copy2-root)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
file-mdf (thf/apply-changes file changes)
page-mdf (thf/current-page file-mdf)
changes (cll/generate-reset-component (pcb/empty-changes)
file-mdf
{(:id file-mdf) file-mdf}
page-mdf
(:id copy2-root))
file' (thf/apply-changes file changes)
;; ==== 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') "#abcdef"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy2-root') nil))))
(t/deftest test-reset-after-changing-lower
(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)
copy2-root (ths/get-shape file :copy2-root)
copy2-child (ths/get-shape file :copy2-child)
;; ==== Action
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id copy2-child)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
file-mdf (thf/apply-changes file changes)
page-mdf (thf/current-page file-mdf)
changes (cll/generate-reset-component (pcb/empty-changes)
file-mdf
{(:id file-mdf) file-mdf}
page-mdf
(:id copy2-root))
file' (thf/apply-changes file changes)
;; ==== 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') "#FFFFFF"))
(t/is (= (:fill-opacity fill') 1))
(t/is (= (:touched copy2-root') nil))
(t/is (= (:touched copy2-child') nil))))
(t/deftest test-reset-with-propagation-updates-copies
;; When a nested copy inside a main component has an override and we
;; reset it passing a propagate-fn, the reset must be propagated to
;; all copies of that component so they reflect the canonical color.
(let [;; ==== Setup
file
(-> (thf/sample-file :file1)
;; component1: main1-root / main1-child (fill "#aabbcc")
;; component2: main2-root contains nested-head (instance of component1)
;; copy2-root: copy of component2
(tho/add-nested-component-with-copy
:component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:copy2-root
:main1-child-params {:fills (ths/sample-fills-color :fill-color "#aabbcc")}
:copy2-root-params {:children-labels [:copy2-nested-head]}))
propagate-fn (fn [f]
(-> f
(tho/propagate-component-changes :component1)
(tho/propagate-component-changes :component2)))
;; ==== Action – override the nested-head color, then reset it with propagation
file'
(-> file
(tho/update-bottom-color :nested-head "#fabada" :propagate-fn propagate-fn)
(tho/reset-overrides (ths/get-shape file :nested-head) :propagate-fn propagate-fn))
;; ==== Get
copy2-bottom-color (tho/bottom-fill-color file' :copy2-root)]
;; ==== Check
;; After reset + propagation the copy should mirror the canonical color
(t/is (= copy2-bottom-color "#aabbcc"))))
(t/deftest test-reset-nested-component-from-different-library-preserves-children
;; Regression test for the cross-library find-main-container bug:
;; avatar-img lives in library1, avatar (which nests avatar-img) lives in library2,
;; and card (which nests avatar) lives in the current file.
;; Resetting overrides on avatar used to delete avatar-img because find-main-container
;; only searched within the single library argument (library2) and could not find
;; the avatar-img component which belongs to library1.
(let [;; ==== Setup
;; library1: avatar-img-component (simple component with a child rect)
library1
(-> (thf/sample-file :library1 :is-shared true)
(tho/add-simple-component :avatar-img-component
:avatar-img-main
:avatar-img-child
:child-params {:fills (ths/sample-fills-color :fill-color "#abcdef")}))
;; library2: avatar-component (nests avatar-img from library1)
library2
(-> (thf/sample-file :library2 :is-shared true)
(tho/add-frame :avatar-main)
(thc/instantiate-component :avatar-img-component
:avatar-img-nested-head
:library library1
:parent-label :avatar-main
:children-labels [:avatar-img-nested-child])
(thc/make-component :avatar-component :avatar-main))
;; file: card-component (nests avatar from library2) + a copy of card
file
(-> (thf/sample-file :file)
(tho/add-frame :card-main)
(thc/instantiate-component :avatar-component
:avatar-in-card-main
:library library2
:parent-label :card-main
:children-labels [:avatar-img-in-card-main])
(thc/make-component :card-component :card-main)
(thc/instantiate-component :card-component
:card-copy
:children-labels [:avatar-in-card-copy]))
page (thf/current-page file)
avatar-in-card-copy (ths/get-shape file :avatar-in-card-copy)
;; ==== Action: override a fill on the avatar copy, then reset
changes
(cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(:id avatar-in-card-copy)}
(fn [shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#fabada")))
(:objects page)
{})
file-mdf (thf/apply-changes file changes)
container-mdf (ctn/get-container (:data file-mdf) :page (:id page))
changes
(cll/generate-reset-component (pcb/empty-changes)
file-mdf
{(:id file-mdf) file-mdf
(:id library1) library1
(:id library2) library2}
container-mdf
(:id avatar-in-card-copy))
file' (thf/apply-changes file-mdf changes :validate? false)
;; ==== Get
avatar-in-card-copy' (ths/get-shape file' :avatar-in-card-copy)
;; avatar-img nested head must still exist inside avatar-in-card-copy after reset
avatar-img-copy-id (first (:shapes avatar-in-card-copy'))
avatar-img-copy' (when avatar-img-copy-id
(ths/get-shape-by-id file' avatar-img-copy-id))]
;; ==== Check
(t/is (some? avatar-in-card-copy') "avatar copy must still exist after reset")
(t/is (some? avatar-img-copy-id) "avatar-img copy must not be deleted from avatar's children")
(t/is (some? avatar-img-copy') "avatar-img copy shape must be present in objects")))
(t/deftest test-reset-without-propagation-does-not-update-copies
;; This is the regression test for the misplaced-parenthesis bug: when
;; propagate-fn is NOT passed to reset-overrides the copies of the component
;; must still hold the overridden value because the component sync never ran.
(let [;; ==== Setup
file
(-> (thf/sample-file :file1)
(tho/add-nested-component-with-copy
:component1 :main1-root :main1-child
:component2 :main2-root :nested-head
:copy2-root
:main1-child-params {:fills (ths/sample-fills-color :fill-color "#aabbcc")}
:copy2-root-params {:children-labels [:copy2-nested-head]}))
propagate-fn (fn [f]
(-> f
(tho/propagate-component-changes :component1)
(tho/propagate-component-changes :component2)))
;; ==== Action – override the nested-head color, then reset WITHOUT propagation
file'
(-> file
(tho/update-bottom-color :nested-head "#fabada" :propagate-fn propagate-fn)
;; Reset without propagate-fn: the component definition is updated but
;; the change is never pushed to the copy.
(tho/reset-overrides (ths/get-shape file :nested-head)))
;; ==== Get
copy2-bottom-color (tho/bottom-fill-color file' :copy2-root)]
;; ==== Check
;; Without propagation the copy still reflects the overridden color
(t/is (= copy2-bottom-color "#fabada"))))
(defn- margin-of
[file label]
(:layout-item-margin (ths/get-shape file label)))
(defn- nested-layout-board
"component1, a board with a `layout` whose child has the margin `margin`;
component2, which nests a copy of it; a copy of component2."
[layout margin]
(let [child {:parent-label :main1-root
:layout-item-margin margin
:layout-item-margin-type :simple}]
(-> (thf/sample-file :file1)
(cond-> (= layout :flex)
(-> (tho/add-frame :main1-root {:layout :flex :layout-flex-dir :row})
(tho/add-rect :main1-child child)))
(cond-> (= layout :grid)
(-> (tho/add-grid-frame :main1-root)
(tho/add-rect :main1-child child)
(tho/place-in-grid-columns :main1-root 1 {1 :main1-child})))
(thc/make-component :component1 :main1-root)
(tho/add-frame :main2-root)
(thc/instantiate-component :component1 :nested-head :parent-label :main2-root)
(thc/make-component :component2 :main2-root)
(thc/instantiate-component :component2 :copy2-root
:children-labels [:copy-nested-head :copy-nested-child]))))
(t/deftest test-reset-of-a-nested-copy-of-a-layout-board
;; a copy of component2 resets its nested copy of component1
(doseq [layout [:flex :grid]]
(let [margin {:m1 7 :m2 7 :m3 7 :m4 7}
file (nested-layout-board layout margin)
page (thf/current-page file)
changes (cll/generate-reset-component (pcb/empty-changes)
file
{(:id file) file}
(ctn/make-container page :page)
(thi/id :copy-nested-head))
file' (thf/apply-changes file changes)]
(t/testing (name layout)
(t/is (= margin (margin-of file :copy-nested-child)))
(t/is (= margin (margin-of file' :copy-nested-child)))))))
(t/deftest test-update-main-after-a-reset-of-a-nested-copy-keeps-the-main-overrides
;; component2's main overrides the fill of its nested copy of component1;
;; a copy of component2 resets its nested copy, then pushes to the main
(let [file (-> (thf/sample-file :file1)
(tho/add-nested-component :component1 :main1-root :main1-child
:component2 :main2-root :nested-head)
(thc/instantiate-component :component2 :copy2-root
:children-labels [:copy-nested-head :copy-nested-child]))
page (thf/current-page file)
nested (first (:shapes (ths/get-shape file :nested-head)))
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{nested}
#(assoc % :fills (ths/sample-fills-color :fill-color "#ff0000"))
(:objects page)
{})
file (thf/apply-changes file changes)
page (thf/current-page file)
reset (cll/generate-reset-component (pcb/empty-changes) file {(:id file) file}
(ctn/make-container page :page)
(thi/id :copy-nested-head))
file (thf/apply-changes file reset)
page (thf/current-page file)
container (ctn/make-container page :page)
update (-> (pcb/empty-changes)
(pcb/with-container container)
(cll/generate-sync-shape-inverse {(:id file) file} container (thi/id :copy2-root)))
file' (thf/apply-changes file update)]
(t/is (= #{:fill-group} (:touched (ths/get-shape-by-id file nested))))
(t/is (= #{:fill-group} (:touched (ths/get-shape-by-id file' nested))))))
(t/deftest reset-restores-the-main-rotation-and-undo-restores-the-override
(let [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 40 :height 30}
:copy-root-params {:children-labels [:copy-child]}))
page (thf/current-page file)
root (ths/get-shape file :copy-root)
rotated (thf/apply-changes
file (n/transform-changes page root
(ctm/rotation-modifiers root (gsh/shape->center root) 45)))
page (thf/current-page rotated)
changes (cll/generate-reset-component
(pcb/empty-changes) rotated {(:id rotated) rotated}
(ctn/make-container page :page) (thi/id :copy-root))
reset (thf/apply-changes rotated changes)
undone (thf/apply-changes reset {:redo-changes (:undo-changes changes)})]
(t/is (= 0 (:rotation (ths/get-shape reset :copy-root))))
(t/is (= 0 (:rotation (ths/get-shape reset :copy-child))))
(t/is (nil? (:touched (ths/get-shape reset :copy-root))))
(t/is (= (thn/normalize-file rotated) (thn/normalize-file undone)))))
(t/deftest reset-a-nested-copy-keeps-its-rotated-parent-placement
(let [initial (-> (setup/nested-component-with-copy)
(r/step (n/rotate :copy-root 45)) r/settle)
head (tm/role-shape initial :copy-instance)
child (tm/shape-by-id initial (first (:shapes head)))
reset (-> initial (r/step (n/reset :copy-instance)) r/settle)
after (tm/shape-by-id reset (:id child))]
(t/is (gsh/close-attrs? :points (:points child) (:points after) 0.001))
(t/is (= (:rotation child) (:rotation after)))
(t/is (nil? (:touched (tm/role-shape reset :copy-instance))))))