penpot/common/test/common_tests/files_ref_paths_test.cljc
Álvaro Tejero Cantero 0cc2e97338
Merge develop into iop-vc-preview
Bring iop-vc-preview up to develop 43f3bda8b5, which carries the
final squash of penpot#12420 (93d36d04d8).

iop-vc-preview carried an earlier copy of penpot#12420 (42a2e33ffe
and a6c20b3e95). Every file the early or the final copy touches was
merged three-way with a6c20b3e95 as the base, so develop's side wins
for every penpot#12420 hunk and iop-vc-preview keeps only its own
deltas. Changes from other develop PRs (#12524, #12192, #12731,
#12686, #12442) are kept.

Adaptations to develop:
- files_update.clj: apply-changes returns the triple
  [file op-changes media-index] on the #12524 auto-repair path too,
  and a repaired branch save logs the repair changes ahead of the
  client's ones.
- files.clj: absorb-library-by-file takes develop's update-file! map.
- migrations.cljc: migrate keeps its target arity and develop's
  ordered :migrations set.
- climit.edn: the branch global limits declare a queue, as #12686's
  example-config-bounds-every-wait requires.
- Tests: the repair tests destructure the triple, the branch import
  test uses the #12192 export cfg keys, and the ref-paths test
  classifies :set-own-tokens-status.
- Dashboard comments and placeholder take develop's design-system
  icons around iop-vc-preview's review list; en.po and es.po keep the
  union of both sides.

AI-assisted-by: claude-opus-5-5
Signed-off-by: Álvaro Tejero Cantero <alvorithm@teje.ro>
2026-11-03 12:47:08 +01:00

682 lines
26 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.files-ref-paths-test
"Safety nets for the file and media references branching re-points.
Every uuid a schema declares is either a reference the relink rewrites
or a non-reference listed here, for three surfaces: shapes
(`cfh/shape-ref-paths`), the rest of file data (`bm/remap-refs`), and
change payloads (`bm/remap-changes`)."
(:require
[app.common.files.branch-merge :as bm]
[app.common.files.changes :as ch]
[app.common.files.helpers :as cfh]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.types.file :as ctf]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.set :as set]
[clojure.test :as t]
[malli.core :as m]))
;; Path steps beyond the `cfh/shape-ref-paths` language. They name
;; places a reference spec cannot reach today, so a uuid found under
;; one always fails classification until the language grows.
;; ::set every element of a set
;; ::keys every key of a map-of
;; ::vals every value of a map-of
(def ^:private uuid-types
#{::sm/uuid :uuid})
(defn- collapse-text-tree
"Replace each run of `:children :*` steps with `::cfh/tree`. The text
content schema spells its three levels out; the reference spec walks
them as one recursive tree."
[path]
(loop [in (seq path)
out []]
(if-let [[step & more] in]
(if (and (= :children step) (= :* (first more)))
(recur (next more) (if (= ::cfh/tree (peek out)) out (conj out ::cfh/tree)))
(recur more (conj out step)))
out)))
(defn- schema-uuid-paths
"All paths under `schema` whose value schema is a uuid. Throws on a
container type the walk does not know, so a new one cannot hide a
uuid. A ref met again inside its own subtree stops the walk there."
[schema]
(letfn [(walk [s path seen]
(let [form (when (sm/ref? s) (m/form s))]
(when-not (and form (contains? seen form))
(let [seen (cond-> seen form (conj form))
s (m/deref-all s)
type (m/type s)]
(cond
(contains? uuid-types type)
[path]
(= :map type)
(mapcat (fn [[k _ child]] (walk child (conj path k) seen))
(m/children s))
(= :multi type)
(mapcat (fn [[_ _ child]] (walk child path seen))
(m/children s))
(contains? #{:and :or :maybe} type)
(mapcat #(walk % path seen) (m/children s))
(contains? #{:vector :sequential} type)
(walk (first (m/children s)) (conj path :*) seen)
(= :set type)
(walk (first (m/children s)) (conj path ::set) seen)
(= :map-of type)
(let [[ks vs] (m/children s)]
(concat (walk ks (conj path ::keys) seen)
(walk vs (conj path ::vals) seen)))
(some m/schema? (m/children s))
(throw (ex-info "unhandled schema type in uuid inventory"
{:type type :path path}))
:else
nil)))))]
(->> (walk (sm/schema schema) [] #{})
(map collapse-text-tree)
(distinct)
(vec))))
(def ^:private inventory
(delay (schema-uuid-paths cts/schema:shape)))
(def ^:private non-reference-paths
"Uuid paths that name no file and no media row; relinking keeps them."
#{;; identity
[:id]
;; tree
[:parent-id]
[:frame-id]
[:page-id]
[:shapes :*]
;; component
[:component-id]
[:shape-ref]
[:variant-id]
;; asset: colour or typography id inside its library
[:fills :* :fill-color-ref-id]
[:strokes :* :stroke-color-ref-id]
[:shadow :* :color :ref-id]
[:grids :* :params :color :ref-id]
[:content ::cfh/tree :fills :* :fill-color-ref-id]
[:content ::cfh/tree :typography-ref-id]
[:position-data :* :fills :* :fill-color-ref-id]
;; effect
[:shadow :* :id]
[:blur :id]
[:background-blur :id]
;; prototype
[:interactions :* :destination]
[:interactions :* :position-relative-to]
;; layout
[:layout-grid-cells ::keys]
[:layout-grid-cells ::vals :id]
[:layout-grid-cells ::vals :shapes :*]})
(defn- unclassified-paths
[paths ref-specs]
(let [known (into non-reference-paths (map :path) ref-specs)]
(vec (remove known paths))))
(defn- stale-paths
[paths ref-specs]
(let [found (set paths)]
(->> ref-specs
(map :path)
(remove found)
(vec))))
(t/deftest every-schema-uuid-is-classified
(let [missing (unclassified-paths @inventory cfh/shape-ref-paths)]
(t/is (empty? missing)
(str "Unclassified uuid paths in the shape schema: " (pr-str missing)
". Add each path to `cfh/shape-ref-paths` if it names a file or"
" a media row, else to `non-reference-paths` in this test."))))
(t/deftest every-ref-path-is-in-schema
(let [stale (stale-paths @inventory cfh/shape-ref-paths)]
(t/is (empty? stale)
(str "`cfh/shape-ref-paths` entries absent from the shape schema: "
(pr-str stale)
". Fix the path, or move it to `cfh/legacy-shape-ref-paths`."))))
(t/deftest non-reference-paths-are-in-schema
(let [found (set @inventory)]
(t/is (empty? (remove found non-reference-paths)))))
;; Behaviour
(defn- locate
"Values at `path` in `x`, one per concrete location."
[x path]
(if-let [[step & more] (seq path)]
(cond
(= :* step)
(when (sequential? x) (mapcat #(locate % more) x))
(= ::set step)
(when (set? x) (mapcat #(locate % more) x))
(= ::keys step)
(when (map? x) (mapcat #(locate % more) (keys x)))
(= ::vals step)
(when (map? x) (mapcat #(locate % more) (vals x)))
(= ::cfh/tree step)
(when (map? x) (mapcat #(locate % more) (tree-seq map? :children x)))
:else
(when (map? x)
(when-some [v (get x step)] (locate v more))))
[x]))
(defn- plant
"Set a value from `next-id` at every location of `path` in `x`.
Missing maps and vectors on the way are created with one element,
so every shape type carries every reference; a non-map where the
path wants a map (a path shape's binary `:content`) is left alone."
[x path next-id]
(let [[step & more] path]
(cond
(= :* step)
(cond
(nil? x) [(plant nil more next-id)]
(vector? x) (mapv #(plant % more next-id) x)
:else x)
(= ::cfh/tree step)
(letfn [(node [n]
(let [n (plant n more next-id)]
(cond-> n
(vector? (:children n)) (update :children #(mapv node %)))))]
(if (or (nil? x) (map? x)) (node x) x))
(nil? x)
(plant {} path next-id)
(not (map? x))
x
(empty? more)
(assoc x step (next-id))
:else
(assoc x step (plant (get x step) more next-id)))))
(def ^:private all-ref-paths
(delay (into (mapv :path cfh/shape-ref-paths)
(map :path)
cfh/legacy-shape-ref-paths)))
(defn- uuids-at
[shape paths]
(into #{} (comp (mapcat #(locate shape %)) (filter uuid?)) paths))
(def ^:private generated-shapes
(delay
(let [gen (sg/generator cts/schema:shape)]
(mapv #(sg/generate gen {:seed % :size 10}) (range 60)))))
(t/deftest relink-shape-rewrites-refs-only
(let [ref-paths @all-ref-paths
planted (mapv (fn [shape]
(reduce #(plant %1 %2 uuid/next) shape ref-paths))
@generated-shapes)]
(t/testing "every ref path is exercised"
(let [hit (into #{}
(mapcat (fn [shape]
(filter #(some uuid? (locate shape %)) ref-paths)))
planted)]
(t/is (empty? (remove hit ref-paths)))))
(doseq [shape planted]
(let [scanned (into ref-paths @inventory)
old-refs (uuids-at shape ref-paths)
renames (into {}
(map (fn [id] [id (uuid/next)]))
(uuids-at shape scanned))
lookup #(get renames % %)
result (cfh/relink-shape shape lookup)]
(t/testing (str (:type shape) " " (:id shape))
(t/is (empty? (set/intersection old-refs (uuids-at result scanned)))
"no planted id remains")
(t/is (empty? (remove #(= (mapv lookup (locate shape %))
(vec (locate result %)))
ref-paths))
"ref paths not rewritten through lookup")
(t/is (empty? (remove #(= (vec (locate shape %))
(vec (locate result %)))
non-reference-paths))
"non-reference paths changed")
(let [same (identical? shape (cfh/relink-shape shape identity))]
(t/is same "identity lookup returns the input object")))))))
(t/deftest relink-objects-preserves-identity
(let [objects (into {} (map (juxt :id identity)) @generated-shapes)]
(t/is (identical? objects (cfh/relink-objects objects identity)))))
;; File data
;;
;; `bm/remap-refs` moves a whole document into another file's id space.
;; Shapes go through `cfh/relink-shape`, which the net above guards; this
;; net guards every other uuid of the document.
(defn- under?
[prefix path]
(and (<= (count prefix) (count path))
(= prefix (subvec path 0 (count prefix)))))
(def ^:private file-data-shape-subtrees
"Shape maps inside file data, classified by the shape net. Component
shapes are declared as plain `:map` (`ctn/schema:container`), so the
walk never reaches them; the page ones it does are skipped here."
[[:pages-index ::vals :objects ::vals]
[:components ::vals :objects ::vals]])
(def ^:private file-data-inventory
"Uuid paths of `ctf/schema:data` outside shapes. The schema omits the
`:media` map that `:add-media` changes fill, so its entries are walked
from `ctf/schema:media`."
(delay
(->> (concat (schema-uuid-paths ctf/schema:data)
(map #(into [:media] %)
(schema-uuid-paths [:map-of ::sm/uuid ctf/schema:media])))
(remove (fn [path] (some #(under? % path) file-data-shape-subtrees)))
(vec))))
(def ^:private file-data-ref-paths
"File data paths `bm/remap-refs` rewrites: each names a file or a media
object."
#{;; file whose tokens lib the document uses
[:tokens-source]
;; library colour image
[:colors ::vals :image :id]
;; page default grid colours (`cfh/relink-page`)
[:pages-index ::vals :default-grids :square :color :ref-file]
[:pages-index ::vals :default-grids :row :color :ref-file]
[:pages-index ::vals :default-grids :column :color :ref-file]
;; media objects: their id, and the file that owns the row
[:media ::keys]
[:media ::vals :id]
[:media ::vals :file-id]})
(def ^:private file-data-undeclared-ref-paths
"References `bm/remap-refs` rewrites that `ctf/schema:data` does not
declare, so the inventory cannot find them; behaviour checks them."
#{;; the document's own id
[:id]
;; legacy grid colour `:file-id` (`cfh/legacy-shape-ref-paths`), and
;; `:saved-grids`, where default grids lived before migration
;; `legacy-55`
[:pages-index ::vals :default-grids ::vals :color :file-id]
[:pages-index ::vals :options :saved-grids ::vals :color :ref-file]
[:pages-index ::vals :options :saved-grids ::vals :color :file-id]})
(def ^:private file-data-non-reference-paths
"File data uuid paths that name no file and no media object."
#{;; page identity and order
[:pages :*]
[:pages-index ::keys]
[:pages-index ::vals :id]
;; shape ids keying the page objects
[:pages-index ::vals :objects ::keys]
;; colour id inside its library
[:pages-index ::vals :default-grids :square :color :ref-id]
[:pages-index ::vals :default-grids :row :color :ref-id]
[:pages-index ::vals :default-grids :column :color :ref-id]
;; prototype flows, guides, and comment positions of a page
[:pages-index ::vals :flows ::keys]
[:pages-index ::vals :flows ::vals :id]
[:pages-index ::vals :flows ::vals :starting-frame]
[:pages-index ::vals :guides ::keys]
[:pages-index ::vals :guides ::vals :id]
[:pages-index ::vals :guides ::vals :frame-id]
[:pages-index ::vals :comment-thread-positions ::keys]
[:pages-index ::vals :comment-thread-positions ::vals :frame-id]
;; asset identity
[:colors ::keys]
[:colors ::vals :id]
[:components ::keys]
[:components ::vals :id]
[:components ::vals :objects ::keys]
[:typographies ::keys]
[:typographies ::vals :id]
;; storage objects behind a media object, not media objects
[:media ::vals :media-id]
[:media ::vals :thumbnail-id]})
(t/deftest every-file-data-uuid-is-classified
(let [known (set/union file-data-ref-paths file-data-non-reference-paths)
missing (vec (remove known @file-data-inventory))]
(t/is (empty? missing)
(str "Unclassified uuid paths in `ctf/schema:data`: " (pr-str missing)
". Add each path that names a file or a media object to"
" `file-data-ref-paths` in this test, and make `bm/remap-refs`"
" rewrite it; add any other path to"
" `file-data-non-reference-paths`."))))
(t/deftest file-data-classification-is-in-schema
(let [found (set @file-data-inventory)]
(t/is (empty? (remove found file-data-ref-paths))
"`file-data-ref-paths` entries absent from the file data schema")
(t/is (empty? (remove found file-data-non-reference-paths))
"`file-data-non-reference-paths` entries absent from the file data schema")
(t/is (empty? (filter found file-data-undeclared-ref-paths))
"`file-data-undeclared-ref-paths` entries the schema now declares: move them to `file-data-ref-paths`")))
(def ^:private file-data-shape-probes
"One reference per shape container, to show `bm/remap-refs` reaches
shapes on pages and in deleted components."
[[:pages-index ::vals :objects ::vals :component-file]
[:components ::vals :objects ::vals :fills :* :fill-image :id]])
(defn- file-data-fixture
"File data with a distinct uuid at every classified path."
[]
(let [id uuid/next
page-id (id)
shape-id (id)
comp-id (id)
cshape (id)
media-id (id)
color-id (id)
typo-id (id)
flow-id (id)
guide-id (id)
thread (id)
grid (fn [] {:color {:ref-id (id) :ref-file (id)}})]
{:id (id)
:tokens-source (id)
:pages [page-id]
:pages-index
{page-id {:id page-id
:objects {shape-id {:id shape-id :component-file (id)}}
:default-grids {:square (assoc-in (grid) [:color :file-id] (id))
:row (grid)
:column (grid)}
:options {:saved-grids {:square {:color {:ref-file (id)
:file-id (id)}}}}
:flows {flow-id {:id flow-id :starting-frame (id)}}
:guides {guide-id {:id guide-id :frame-id (id)}}
:comment-thread-positions {thread {:frame-id (id)}}}}
:colors {color-id {:id color-id :image {:id (id)}}}
:components {comp-id {:id comp-id
:objects {cshape {:id cshape
:fills [{:fill-image {:id (id)}}]}}}}
:typographies {typo-id {:id typo-id}}
:media {media-id {:id media-id
:file-id (id)
:media-id (id)
:thumbnail-id (id)}}}))
(defn- all-uuids
"Every uuid in `x`, map keys included."
[x]
(into #{} (filter uuid?) (tree-seq coll? seq x)))
(t/deftest remap-refs-rewrites-file-data-refs-only
(let [data (file-data-fixture)
ref-paths (concat file-data-ref-paths
file-data-undeclared-ref-paths
file-data-shape-probes)
;; every uuid is renamed, so a kept non-reference proves a skip
id-map (into {} (map (fn [id] [id (uuid/next)])) (all-uuids data))
lookup #(get id-map % %)
result (bm/remap-refs data id-map)]
(t/testing "the fixture holds a uuid at every classified path"
(t/is (empty? (remove #(some uuid? (locate data %))
(concat ref-paths file-data-non-reference-paths)))))
(doseq [path ref-paths]
(t/is (= (set (map lookup (locate data path)))
(set (locate result path)))
(str "reference not rewritten: " (pr-str path))))
(doseq [path file-data-non-reference-paths]
(t/is (= (set (locate data path))
(set (locate result path)))
(str "non-reference changed: " (pr-str path))))))
;; Change payloads
;;
;; `bm/remap-changes` moves a change vector into another file's id space.
;; Paths are `[type & path]`, one per `:multi` branch of `ch/schema:change`.
;; Subtrees another net guards, each through the relink it calls:
;; [:add-obj :obj] `cfh/relink-shape`: the shape net
;; [:add-page :page] `cfh/relink-page`: the shape net and the
;; default grids of the file data net; declared
;; `::sm/any`, so the walk never sees it
;; [:add-component :objects] `cfh/relink-objects`: the shape net;
;; [:mod-component :objects] undeclared, so the walk never sees them
;; [:mod-obj :operations] a `:set` value is any shape attr, declared
;; `::sm/any`: `remap-changes-relinks-mod-obj-set-values`
;; `change-subtree-probes` checks one reference under each.
(def ^:private change-shape-subtrees
[[:add-obj :obj]])
(def ^:private change-inventory
(delay
(let [schema (m/deref-all (sm/schema ch/schema:change))]
(->> (m/children schema)
(mapcat (fn [[type _ child]]
(map #(into [type] %) (schema-uuid-paths child))))
(remove (fn [path] (some #(under? % path) change-shape-subtrees)))
(vec)))))
(def ^:private change-ref-paths
"Change paths `bm/remap-changes` rewrites: each names a file or a media
object."
#{[:set-default-grid :params :color :ref-file]
[:add-color :color :image :id]
[:mod-color :color :image :id]
[:add-media :object :id]
[:add-media :object :file-id]
[:mod-media :object :id]
[:mod-media :object :file-id]
[:del-media :id]
[:set-tokens-source :file-id]
[:set-tokens-source :library-id]})
(def ^:private change-undeclared-ref-paths
"References `bm/remap-changes` rewrites that `ch/schema:change` does
not declare."
#{;; legacy grid colour `:file-id` (`cfh/legacy-shape-ref-paths`)
[:set-default-grid :params :color :file-id]})
(def ^:private change-subtree-probes
[[:add-obj :obj :component-file]
[:add-page :page :objects ::vals :fills :* :fill-image :id]
[:add-page :page :default-grids ::vals :color :ref-file]
[:add-component :objects ::vals :component-file]
[:mod-component :objects ::vals :component-file]])
(def ^:private change-non-reference-paths
"Change uuid paths that name no file and no media object."
#{;; entity identity: shape, page, component, colour, typography
[:add-obj :id]
[:mod-obj :id]
[:del-obj :id]
[:fix-obj :id]
[:add-page :id]
[:mod-page :id]
[:del-page :id]
[:mov-page :id]
[:add-component :id]
[:mod-component :id]
[:del-component :id]
[:restore-component :id]
[:purge-component :id]
[:add-color :color :id]
[:mod-color :color :id]
[:del-color :id]
[:add-typography :typography :id]
[:mod-typography :typography :id]
[:del-typography :id]
;; container and tree position
[:add-obj :page-id]
[:add-obj :component-id]
[:add-obj :frame-id]
[:add-obj :parent-id]
[:mod-obj :page-id]
[:mod-obj :component-id]
[:del-obj :page-id]
[:del-obj :component-id]
[:fix-obj :page-id]
[:fix-obj :component-id]
[:mov-objects :page-id]
[:mov-objects :component-id]
[:mov-objects :parent-id]
[:reorder-children :page-id]
[:reorder-children :component-id]
[:reorder-children :parent-id]
[:reg-objects :page-id]
[:reg-objects :component-id]
[:reg-objects :shapes :*]
[:set-default-grid :page-id]
[:restore-component :page-id]
;; component main instance and variant
[:add-component :main-instance-id]
[:add-component :main-instance-page]
[:add-component :variant-id]
[:mod-component :variant-id]
;; colour id inside its library
[:set-default-grid :params :color :ref-id]
;; guides, flows, comment positions, and plugin data targets
[:set-guide :page-id]
[:set-guide :id]
[:set-guide :params :id]
[:set-guide :params :frame-id]
[:set-flow :page-id]
[:set-flow :id]
[:set-flow :params :id]
[:set-flow :params :starting-frame]
[:set-comment-thread-position :comment-thread-id]
[:set-comment-thread-position :page-id]
[:set-comment-thread-position :frame-id]
[:set-plugin-data :object-id]
[:set-plugin-data :page-id]
;; storage objects behind a media object, not media objects
[:add-media :object :media-id]
[:add-media :object :thumbnail-id]
[:mod-media :object :media-id]
[:mod-media :object :thumbnail-id]
;; ids inside the tokens lib
[:set-token :set-id]
[:set-token :token-id]
[:set-token :attrs :id]
[:set-token-set :id]
[:set-token-set :attrs :id]
[:set-token-set :attrs :tokens ::vals :id]
[:set-token-theme :id]
[:set-token-theme :attrs :id]
[:set-tokens-status :theme-ids ::set]
[:set-tokens-status :set-ids ::set]
[:set-own-tokens-status :theme-ids ::set]
[:set-own-tokens-status :set-ids ::set]})
(t/deftest every-change-uuid-is-classified
(let [known (set/union change-ref-paths change-non-reference-paths)
missing (vec (remove known @change-inventory))]
(t/is (empty? missing)
(str "Unclassified uuid paths in `ch/schema:change`, as [type & path]: "
(pr-str missing)
". Add each path that names a file or a media object to"
" `change-ref-paths` in this test, and make `bm/remap-changes`"
" rewrite it for that change type; add any other path to"
" `change-non-reference-paths`."))))
(t/deftest change-classification-is-in-schema
(let [found (set @change-inventory)]
(t/is (empty? (remove found change-ref-paths))
"`change-ref-paths` entries absent from the change schema")
(t/is (empty? (remove found change-non-reference-paths))
"`change-non-reference-paths` entries absent from the change schema")
(t/is (empty? (filter found change-undeclared-ref-paths))
"`change-undeclared-ref-paths` entries the schema now declares: move them to `change-ref-paths`")))
(defn- build-at
"A minimal map holding `v` at `path`."
[path v]
(if-let [[step & more] (seq path)]
(cond
(= :* step) [(build-at more v)]
(= ::set step) #{(build-at more v)}
(= ::keys step) {v {}}
(= ::vals step) {:entry (build-at more v)}
:else {step (build-at more v)})
v))
(defn- remap-at
"Plant `old` at `[type & path]` in a change of that type, remap it with
`{old new}`, and return the values found at the path afterwards."
[[type & path] old new]
(let [change (assoc (build-at path old) :type type)
[out] (bm/remap-changes [change] {old new})]
(locate out path)))
(t/deftest remap-changes-rewrites-refs
(doseq [path (concat change-ref-paths
change-undeclared-ref-paths
change-subtree-probes)]
(let [old (uuid/next)
new (uuid/next)]
(t/is (= [new] (remap-at path old new))
(str "reference not rewritten: " (pr-str path))))))
(t/deftest remap-changes-keeps-non-references
(doseq [path change-non-reference-paths]
(let [old (uuid/next)]
(t/is (= [old] (remap-at path old (uuid/next)))
(str "non-reference changed: " (pr-str path))))))
(t/deftest remap-changes-relinks-mod-obj-set-values
;; `shape-set-ops` emits one `:set` per differing attr, so a reference
;; travels as the value of its top-level shape attr
(doseq [[attr paths] (group-by first @all-ref-paths)]
(let [shape (reduce #(plant %1 %2 uuid/next) {} paths)
id-map (into {} (map (fn [id] [id (uuid/next)])) (uuids-at shape paths))
change {:type :mod-obj
:id (uuid/next)
:page-id (uuid/next)
:operations [{:type :set :attr attr :val (get shape attr)}]}
[out] (bm/remap-changes [change] id-map)
result {attr (get-in out [:operations 0 :val])}]
(t/is (seq id-map) (str "nothing planted under " attr))
(doseq [path paths]
(t/is (= (mapv #(get id-map % %) (locate shape path))
(vec (locate result path)))
(str "`:set " attr "` value not relinked at " (pr-str path)))))))
(t/deftest remap-changes-relinks-mod-obj-assign-values
;; an `:assign` carries a partial shape: every ref path in one value
(let [paths @all-ref-paths
shape (reduce #(plant %1 %2 uuid/next) {} paths)
id-map (into {} (map (fn [id] [id (uuid/next)])) (uuids-at shape paths))
change {:type :mod-obj
:id (uuid/next)
:page-id (uuid/next)
:operations [{:type :assign :value shape}]}
[out] (bm/remap-changes [change] id-map)
result (get-in out [:operations 0 :value])]
(doseq [path paths]
(t/is (= (mapv #(get id-map % %) (locate shape path))
(vec (locate result path)))
(str "`:assign` value not relinked at " (pr-str path))))))