mirror of
https://github.com/penpot/penpot.git
synced 2026-11-02 16:10:42 +00:00
Skipping invalid child ids in migration 0033 was not enough: the ids stayed in :shapes, so the file then failed schema validation and still could not load. Migration 0033 now first parses string ids in :shapes into uuids and drops nil and ids that point to no shape. This also removes the duplicate that 0032 adds when a child is listed as a string. Files that already passed 0033 get it again: it is removed from their applied migrations before deploy. Relates to #12343 AI-assisted-by: claude-opus-5-5
850 lines
43 KiB
Clojure
850 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.files-migrations-test
|
|
(:require
|
|
[app.common.data :as d]
|
|
[app.common.files.migrations :as cfm]
|
|
[app.common.files.tokens :as cfo]
|
|
[app.common.geom.rect :as grc]
|
|
[app.common.test-helpers.components :as thk]
|
|
[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.file :as ctf]
|
|
[app.common.types.path :as path]
|
|
[app.common.types.shape :as cts]
|
|
[app.common.types.token :as ctt]
|
|
[app.common.uuid :as uuid]
|
|
[clojure.test :as t]))
|
|
|
|
(t/use-fixtures :each thi/test-fixture)
|
|
|
|
(defmethod cfm/migrate-data "test/1" [data _] (update data :sum inc))
|
|
(defmethod cfm/migrate-data "test/2" [data _] (update data :sum inc))
|
|
(defmethod cfm/migrate-data "test/3" [data _] (update data :sum inc))
|
|
|
|
(t/deftest generic-migration-subsystem-1
|
|
(let [migrations (into (d/ordered-set) ["test/1" "test/2" "test/3"])]
|
|
(with-redefs [cfm/available-migrations migrations
|
|
ctf/check-file-data identity]
|
|
(let [file {:data {:sum 1}
|
|
:id 1
|
|
:migrations (d/ordered-set "test/1")}
|
|
file' (cfm/migrate file nil)]
|
|
(t/is (= cfm/available-migrations (:migrations file')))
|
|
(t/is (= 3 (:sum (:data file'))))))))
|
|
|
|
(t/deftest migration-0024b-fix-stroke-cap-placement
|
|
(let [shape-id (uuid/next)
|
|
page-id (uuid/next)
|
|
data {:pages-index
|
|
{page-id
|
|
{:objects
|
|
{shape-id {:id shape-id
|
|
:type :path
|
|
:stroke-cap-start "round"
|
|
:stroke-cap-end "round"
|
|
:strokes [{:stroke-color "#000000"
|
|
:stroke-opacity 1
|
|
:stroke-style :svg
|
|
:stroke-width 2}
|
|
{:stroke-color "#000000"
|
|
:stroke-cap-start "round"
|
|
:stroke-cap-end "round"
|
|
:stroke-opacity 1
|
|
:stroke-style :svg
|
|
:stroke-width 2}]}}}}}
|
|
data' (cfm/migrate-data data "0024b-fix-stroke-cap-placement")]
|
|
|
|
(let [shape (get-in data' [:pages-index page-id :objects shape-id])]
|
|
(t/is (nil? (:stroke-cap-start shape)) "top-level cap removed")
|
|
(t/is (nil? (:stroke-cap-end shape)) "top-level cap removed")
|
|
(t/is (= :round (get-in shape [:strokes 0 :stroke-cap-start])) "cap moved into stroke")
|
|
(t/is (= :round (get-in shape [:strokes 0 :stroke-cap-end])) "cap moved into stroke")
|
|
(t/is (= :round (get-in shape [:strokes 1 :stroke-cap-start])) "correct cap type")
|
|
(t/is (= :round (get-in shape [:strokes 1 :stroke-cap-end])) "correct cap type"))))
|
|
|
|
(t/deftest migration-0024-fix-stroke-cap-no-strokes
|
|
(let [shape-id (uuid/next)
|
|
page-id (uuid/next)
|
|
data {:pages-index
|
|
{page-id
|
|
{:objects
|
|
{shape-id {:id shape-id
|
|
:type :path
|
|
:stroke-cap-start :round
|
|
:stroke-cap-end :round
|
|
:strokes []}}}}}
|
|
data' (cfm/migrate-data data "0024b-fix-stroke-cap-placement")]
|
|
|
|
(let [shape (get-in data' [:pages-index page-id :objects shape-id])]
|
|
(t/is (nil? (:stroke-cap-start shape)) "top-level cap removed even with no strokes")
|
|
(t/is (nil? (:stroke-cap-end shape)) "top-level cap removed even with no strokes"))))
|
|
|
|
(t/deftest migration-0028-normalizes-constrained-shape-values
|
|
(let [file-id (uuid/next)
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
shape (-> (cts/setup-shape {:id shape-id :type :frame})
|
|
(assoc :r1 -1
|
|
:r2 -2
|
|
:r3 -3
|
|
:r4 -4
|
|
:layout-gap {:row-gap -5 :column-gap -6}
|
|
:layout-padding {:p1 -7 :p2 -8 :p3 -9 :p4 -10}
|
|
:layout-grid-rows [{:type :fixed :value -11}]
|
|
:layout-grid-columns [{:type :percent :value -12}]
|
|
:layout-item-min-w -13
|
|
:layout-item-max-w -14
|
|
:layout-item-min-h -15
|
|
:layout-item-max-h -16
|
|
:strokes [{:stroke-color "#000000"
|
|
:stroke-width -17
|
|
:stroke-width-top -18
|
|
:stroke-width-right -19
|
|
:stroke-width-bottom -20
|
|
:stroke-width-left -21}]
|
|
:shadow [{:id nil
|
|
:style :drop-shadow
|
|
:offset-x 0
|
|
:offset-y 0
|
|
:blur -22
|
|
:spread 0
|
|
:hidden false
|
|
:color {:color "#000000" :opacity 1}}]
|
|
:blur {:id (uuid/next)
|
|
:type :layer-blur
|
|
:value -23
|
|
:hidden false}
|
|
:background-blur {:id (uuid/next)
|
|
:type :background-blur
|
|
:value -24
|
|
:hidden false}
|
|
:exports [{:type :png :scale 0 :suffix ""}]
|
|
:grids [{:type :square
|
|
:display true
|
|
:params {:size 0
|
|
:color {:color "#000000" :opacity 1}}}
|
|
{:type :column
|
|
:display true
|
|
:params {:size -25
|
|
:color {:color "#000000" :opacity 1}}}]))
|
|
data (-> (ctf/make-file-data file-id page-id)
|
|
(assoc-in [:pages-index page-id :objects shape-id] shape))]
|
|
|
|
(t/is (thrown? #?(:clj Exception :cljs js/Error)
|
|
(ctf/check-file-data data))
|
|
"new schemas reject legacy negative values")
|
|
|
|
(let [data' (cfm/migrate-data data "0028-normalize-constrained-values")
|
|
shape' (get-in data' [:pages-index page-id :objects shape-id])]
|
|
(t/is (= data' (ctf/check-file-data data')) "migrated file data passes the schema")
|
|
(t/is (every? zero? (map #(get shape' %) [:r1 :r2 :r3 :r4])) "corner radii clamped")
|
|
(t/is (= {:row-gap 0 :column-gap 0} (:layout-gap shape')) "layout gaps clamped")
|
|
(t/is (= {:p1 0 :p2 0 :p3 0 :p4 0} (:layout-padding shape')) "layout padding clamped")
|
|
(t/is (= [0] (mapv :value (:layout-grid-rows shape'))) "row tracks clamped")
|
|
(t/is (= [0] (mapv :value (:layout-grid-columns shape'))) "column tracks clamped")
|
|
(t/is (every? zero?
|
|
(map #(get shape' %)
|
|
[:layout-item-min-w :layout-item-max-w
|
|
:layout-item-min-h :layout-item-max-h]))
|
|
"layout item bounds clamped")
|
|
(t/is (every? zero?
|
|
(map (first (:strokes shape'))
|
|
[:stroke-width :stroke-width-top :stroke-width-right
|
|
:stroke-width-bottom :stroke-width-left]))
|
|
"stroke widths clamped")
|
|
(t/is (zero? (get-in shape' [:shadow 0 :blur])) "shadow blur clamped")
|
|
(t/is (zero? (get-in shape' [:blur :value])) "layer blur clamped")
|
|
(t/is (zero? (get-in shape' [:background-blur :value])) "background blur clamped")
|
|
(t/is (= 1 (get-in shape' [:exports 0 :scale])) "export scale reset to default")
|
|
(t/is (= 0.01 (get-in shape' [:grids 0 :params :size])) "square grid size clamped")
|
|
(t/is (= 1 (get-in shape' [:grids 1 :params :size])) "column grid count clamped"))))
|
|
|
|
(t/deftest migration-0028-normalizes-page-grids-and-variant-properties
|
|
(let [file-id (uuid/next)
|
|
page-id (uuid/next)
|
|
component-id (uuid/next)
|
|
long-name (apply str (repeat 61 "n"))
|
|
long-value (apply str (repeat 61 "v"))
|
|
data (-> (ctf/make-file-data file-id page-id)
|
|
(assoc-in [:pages-index page-id :default-grids]
|
|
{:square {:size -1
|
|
:color {:color "#000000" :opacity 1}}
|
|
:row {:size 0.5
|
|
:color {:color "#000000" :opacity 1}}
|
|
:column {:size -2
|
|
:color {:color "#000000" :opacity 1}}})
|
|
(assoc-in [:components component-id]
|
|
{:id component-id
|
|
:name "Variant"
|
|
:variant-properties [{:name long-name
|
|
:value long-value}]}))
|
|
data' (cfm/migrate-data data "0028-normalize-constrained-values")]
|
|
|
|
(t/is (= 0.01 (get-in data' [:pages-index page-id :default-grids :square :size]))
|
|
"default square grid size clamped")
|
|
(t/is (= 1 (get-in data' [:pages-index page-id :default-grids :row :size]))
|
|
"default row grid count reset")
|
|
(t/is (= 1 (get-in data' [:pages-index page-id :default-grids :column :size]))
|
|
"default column grid count reset")
|
|
(t/is (= 60 (count (get-in data' [:components component-id :variant-properties 0 :name])))
|
|
"variant property name truncated")
|
|
(t/is (= 60 (count (get-in data' [:components component-id :variant-properties 0 :value])))
|
|
"variant property value truncated")
|
|
(t/is (= data' (cfm/migrate-data data' "0028-normalize-constrained-values"))
|
|
"migration is idempotent")))
|
|
|
|
(t/deftest migration-0028-runs-through-file-migration
|
|
(let [migration-id "0028-normalize-constrained-values"
|
|
shape-id (uuid/next)
|
|
file (ctf/make-file {:name "Legacy constrained values"})
|
|
page-id (first (get-in file [:data :pages]))
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :r1 -1))
|
|
file (-> file
|
|
(assoc :migrations (disj cfm/available-migrations migration-id))
|
|
(assoc-in [:data :pages-index page-id :objects shape-id] shape))
|
|
file' (cfm/migrate-file file {})]
|
|
|
|
(t/is (cfm/need-migration? file) "new migration detected")
|
|
(t/is (not (cfm/need-migration? file')) "new migration recorded")
|
|
(t/is (contains? (:migrations file') migration-id) "migration id persisted")
|
|
(t/is (zero? (get-in file' [:data :pages-index page-id :objects shape-id :r1]))
|
|
"migration repaired file data before schema validation")))
|
|
|
|
(t/deftest migration-0029-moves-background-blur-out-of-blur
|
|
(let [migration-id "0029-move-background-blur-out-of-blur"
|
|
file-id (uuid/next)
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
blur-id (uuid/next)
|
|
blur {:id blur-id
|
|
:type :background-blur
|
|
:value 10
|
|
:hidden false}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :blur blur))
|
|
data (-> (ctf/make-file-data file-id page-id)
|
|
(assoc-in [:pages-index page-id :objects shape-id] shape))]
|
|
|
|
(t/is (thrown? #?(:clj Exception :cljs js/Error)
|
|
(ctf/check-file-data data))
|
|
"new schema rejects a background blur stored in :blur")
|
|
|
|
(let [data' (cfm/migrate-data data migration-id)
|
|
shape' (get-in data' [:pages-index page-id :objects shape-id])]
|
|
(t/is (nil? (:blur shape')) "mis-typed :blur removed")
|
|
(t/is (= blur (:background-blur shape'))
|
|
"background blur moved to its own attribute")
|
|
(t/is (= data' (ctf/check-file-data data'))
|
|
"migrated file data passes the schema")
|
|
(t/is (= data' (cfm/migrate-data data' migration-id))
|
|
"migration is idempotent"))))
|
|
|
|
(t/deftest migration-0029-keeps-existing-background-blur
|
|
(let [migration-id "0029-move-background-blur-out-of-blur"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
existing-bg {:id (uuid/next)
|
|
:type :background-blur
|
|
:value 5
|
|
:hidden false}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :blur {:id (uuid/next)
|
|
:type :background-blur
|
|
:value 10
|
|
:hidden false}
|
|
:background-blur existing-bg))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
shape' (get-in data' [:pages-index page-id :objects shape-id])]
|
|
|
|
(t/is (nil? (:blur shape')) "mis-typed :blur dropped")
|
|
(t/is (= existing-bg (:background-blur shape'))
|
|
"existing background blur preserved")))
|
|
|
|
(t/deftest migration-0029-leaves-layer-blur-untouched
|
|
(let [migration-id "0029-move-background-blur-out-of-blur"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
blur {:id (uuid/next)
|
|
:type :layer-blur
|
|
:value 8
|
|
:hidden false}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :blur blur))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
shape' (get-in data' [:pages-index page-id :objects shape-id])]
|
|
|
|
(t/is (= blur (:blur shape')) "layer blur untouched")
|
|
(t/is (nil? (:background-blur shape')) "no background blur created")))
|
|
|
|
(t/deftest migration-0029-migrates-component-blurs
|
|
(let [migration-id "0029-move-background-blur-out-of-blur"
|
|
component-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
blur {:id (uuid/next)
|
|
:type :background-blur
|
|
:value 12
|
|
:hidden false}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :blur blur))
|
|
data {:components {component-id {:objects {shape-id shape}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
shape' (get-in data' [:components component-id :objects shape-id])]
|
|
|
|
(t/is (nil? (:blur shape')) "component mis-typed :blur removed")
|
|
(t/is (= blur (:background-blur shape'))
|
|
"component background blur moved to its own attribute")))
|
|
|
|
(t/deftest migration-0029-runs-through-file-migration
|
|
(let [migration-id "0029-move-background-blur-out-of-blur"
|
|
shape-id (uuid/next)
|
|
blur-id (uuid/next)
|
|
blur {:id blur-id
|
|
:type :background-blur
|
|
:value 10
|
|
:hidden false}
|
|
file (ctf/make-file {:name "Legacy background blur"})
|
|
page-id (first (get-in file [:data :pages]))
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :blur blur))
|
|
file (-> file
|
|
(assoc :migrations (disj cfm/available-migrations migration-id))
|
|
(assoc-in [:data :pages-index page-id :objects shape-id] shape))
|
|
file' (cfm/migrate-file file {})]
|
|
|
|
(t/is (cfm/need-migration? file) "new migration detected")
|
|
(t/is (not (cfm/need-migration? file')) "new migration recorded")
|
|
(t/is (contains? (:migrations file') migration-id) "migration id persisted")
|
|
(t/is (= blur (get-in file' [:data :pages-index page-id :objects shape-id :background-blur]))
|
|
"background blur moved before schema validation")
|
|
(t/is (nil? (get-in file' [:data :pages-index page-id :objects shape-id :blur]))
|
|
"mis-typed :blur removed")))
|
|
|
|
(t/deftest migration-0030-removes-stroke-per-side-attr
|
|
(let [migration-id "0030-remove-stroke-per-side-attr"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
stroke {:stroke-width 2
|
|
:stroke-width-top 2
|
|
:stroke-width-right 4
|
|
:stroke-width-bottom 3
|
|
:stroke-width-left 6
|
|
:stroke-color "#000000"
|
|
:stroke-per-side true}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :strokes [stroke]))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
shape' (get-in data' [:pages-index page-id :objects shape-id])
|
|
stroke' (first (:strokes shape'))]
|
|
|
|
(t/is (nil? (:stroke-per-side stroke')) "obsolete :stroke-per-side removed")
|
|
(t/is (= (dissoc stroke :stroke-per-side) stroke')
|
|
"no other stroke attr changed")
|
|
(t/is (= [2 4 3 6] [(:stroke-width-top stroke')
|
|
(:stroke-width-right stroke')
|
|
(:stroke-width-bottom stroke')
|
|
(:stroke-width-left stroke')])
|
|
"per-side widths preserved")
|
|
(t/is (= data' (cfm/migrate-data data' migration-id))
|
|
"migration is idempotent")))
|
|
|
|
(t/deftest migration-0030-repairs-strokes-rejected-by-schema
|
|
(let [migration-id "0030-remove-stroke-per-side-attr"
|
|
file-id (uuid/next)
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
stroke {:stroke-width 1
|
|
:stroke-width-top 1
|
|
:stroke-width-right 1
|
|
:stroke-width-bottom 1
|
|
:stroke-width-left 1
|
|
:stroke-color "#000000"
|
|
:stroke-per-side true}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :strokes [stroke]))
|
|
data (-> (ctf/make-file-data file-id page-id)
|
|
(assoc-in [:pages-index page-id :objects shape-id] shape))]
|
|
|
|
(t/is (thrown? #?(:clj Exception :cljs js/Error)
|
|
(ctf/check-file-data data))
|
|
"new schema rejects a stroke carrying :stroke-per-side")
|
|
|
|
(let [data' (cfm/migrate-data data migration-id)]
|
|
(t/is (= data' (ctf/check-file-data data'))
|
|
"migrated data passes the schema"))))
|
|
|
|
(t/deftest migration-0030-repairs-component-strokes
|
|
(let [migration-id "0030-remove-stroke-per-side-attr"
|
|
component-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
stroke {:stroke-width 3
|
|
:stroke-width-top 3
|
|
:stroke-width-right 5
|
|
:stroke-width-bottom 3
|
|
:stroke-width-left 3
|
|
:stroke-color "#000000"
|
|
:stroke-per-side true}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :strokes [stroke]))
|
|
data {:components {component-id {:objects {shape-id shape}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
stroke' (get-in data' [:components component-id :objects shape-id :strokes 0])]
|
|
|
|
(t/is (nil? (:stroke-per-side stroke'))
|
|
"obsolete attr removed from a component stroke")
|
|
(t/is (= 5 (:stroke-width-right stroke'))
|
|
"component per-side width preserved")))
|
|
|
|
(t/deftest migration-0030-keeps-strokes-without-the-obsolete-attr
|
|
(let [migration-id "0030-remove-stroke-per-side-attr"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
stroke {:stroke-width 2
|
|
:stroke-width-top 2
|
|
:stroke-width-right 2
|
|
:stroke-width-bottom 2
|
|
:stroke-width-left 2}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :strokes [stroke]))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}]
|
|
|
|
(t/is (= data (cfm/migrate-data data migration-id))
|
|
"a stroke without the obsolete attr is left untouched")))
|
|
|
|
(t/deftest migration-0030-keeps-distinct-sides-when-the-attr-was-false
|
|
(let [migration-id "0030-remove-stroke-per-side-attr"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
;; The old toggle set the flag to false without equalizing the sides,
|
|
;; so the per-side values are the saved design intent and must survive.
|
|
stroke {:stroke-width 1
|
|
:stroke-width-top 1
|
|
:stroke-width-right 8
|
|
:stroke-width-bottom 1
|
|
:stroke-width-left 1
|
|
:stroke-color "#000000"
|
|
:stroke-per-side false}
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :strokes [stroke]))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}
|
|
stroke' (get-in (cfm/migrate-data data migration-id)
|
|
[:pages-index page-id :objects shape-id :strokes 0])]
|
|
|
|
(t/is (nil? (:stroke-per-side stroke')) "obsolete attr removed")
|
|
(t/is (= 8 (:stroke-width-right stroke'))
|
|
"distinct side width kept instead of being equalized")))
|
|
|
|
(t/deftest migration-0030-runs-through-file-migration
|
|
(let [migration-id "0030-remove-stroke-per-side-attr"
|
|
shape-id (uuid/next)
|
|
stroke {:stroke-width 5
|
|
:stroke-width-top 5
|
|
:stroke-width-right 5
|
|
:stroke-width-bottom 5
|
|
:stroke-width-left 5
|
|
:stroke-color "#000000"
|
|
:stroke-per-side true}
|
|
file (ctf/make-file {:name "Legacy per-side stroke"})
|
|
page-id (first (get-in file [:data :pages]))
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :strokes [stroke]))
|
|
file (-> file
|
|
(assoc :migrations (disj cfm/available-migrations migration-id))
|
|
(assoc-in [:data :pages-index page-id :objects shape-id] shape))
|
|
file' (cfm/migrate-file file {})
|
|
stroke' (get-in file' [:data :pages-index page-id :objects shape-id :strokes 0])]
|
|
|
|
(t/is (cfm/need-migration? file) "new migration detected")
|
|
(t/is (not (cfm/need-migration? file')) "new migration recorded")
|
|
(t/is (contains? (:migrations file') migration-id) "migration id persisted")
|
|
(t/is (nil? (:stroke-per-side stroke'))
|
|
"obsolete attr removed before schema validation")
|
|
(t/is (= 5 (:stroke-width stroke')) "stroke width preserved")))
|
|
|
|
(t/deftest migration-0031-legacy-applied-token-is-not-recognized-before-migration
|
|
;; Reproduces #12016: the editor reads only the per-side token attributes, so
|
|
;; a shape carrying the old global `:stroke-width` reference looks unapplied.
|
|
(let [shape (-> (cts/setup-shape {:id (uuid/next) :type :rect})
|
|
(assoc :applied-tokens {:stroke-width "30"}))]
|
|
(t/is (not (cfo/shapes-token-applied? {:name "30"} [shape]
|
|
ctt/stroke-width-keys))
|
|
"a legacy applied token is invisible to the token lookup")))
|
|
|
|
(t/deftest migration-0031-recycles-global-stroke-width-token
|
|
(let [migration-id "0031-migrate-stroke-width-token-attr"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :applied-tokens {:stroke-width "30"
|
|
:fill "red"}))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}
|
|
shape' (get-in (cfm/migrate-data data migration-id)
|
|
[:pages-index page-id :objects shape-id])
|
|
applied' (:applied-tokens shape')]
|
|
|
|
(t/is (nil? (:stroke-width applied')) "legacy key removed")
|
|
(t/is (= "30" (:stroke-width-top applied')))
|
|
(t/is (= "30" (:stroke-width-right applied')))
|
|
(t/is (= "30" (:stroke-width-bottom applied')))
|
|
(t/is (= "30" (:stroke-width-left applied')))
|
|
(t/is (= "red" (:fill applied')) "unrelated tokens preserved")
|
|
(t/is (cfo/shapes-token-applied? {:name "30"} [shape']
|
|
ctt/stroke-width-keys)
|
|
"the token is recognized after migration")))
|
|
|
|
(t/deftest migration-0031-keeps-side-token-that-was-already-set
|
|
(let [migration-id "0031-migrate-stroke-width-token-attr"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :applied-tokens {:stroke-width "30"
|
|
:stroke-width-top "10"}))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}
|
|
applied' (get-in (cfm/migrate-data data migration-id)
|
|
[:pages-index page-id :objects shape-id :applied-tokens])]
|
|
|
|
(t/is (= "10" (:stroke-width-top applied'))
|
|
"an explicit side reference is not overwritten")
|
|
(t/is (= "30" (:stroke-width-right applied')))))
|
|
|
|
(t/deftest migration-0031-repairs-component-shapes
|
|
(let [migration-id "0031-migrate-stroke-width-token-attr"
|
|
component-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :applied-tokens {:stroke-width "30"}))
|
|
data {:components {component-id {:objects {shape-id shape}}}}
|
|
applied' (get-in (cfm/migrate-data data migration-id)
|
|
[:components component-id :objects shape-id :applied-tokens])]
|
|
|
|
(t/is (nil? (:stroke-width applied')) "legacy key removed")
|
|
(t/is (= "30" (:stroke-width-left applied'))
|
|
"component side reference created")))
|
|
|
|
(t/deftest migration-0031-leaves-shapes-without-the-legacy-token
|
|
(let [migration-id "0031-migrate-stroke-width-token-attr"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :applied-tokens {:stroke-width-top "30"
|
|
:stroke-width-right "30"
|
|
:stroke-width-bottom "30"
|
|
:stroke-width-left "30"}))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}]
|
|
|
|
(t/is (= data (cfm/migrate-data data migration-id))
|
|
"a shape without the legacy token is left untouched")))
|
|
|
|
(t/deftest migration-0031-runs-through-file-migration
|
|
(let [migration-id "0031-migrate-stroke-width-token-attr"
|
|
shape-id (uuid/next)
|
|
file (ctf/make-file {:name "Legacy stroke width token"})
|
|
page-id (first (get-in file [:data :pages]))
|
|
shape (-> (cts/setup-shape {:id shape-id :type :rect})
|
|
(assoc :applied-tokens {:stroke-width "30"}))
|
|
file (-> file
|
|
(assoc :migrations (disj cfm/available-migrations migration-id))
|
|
(assoc-in [:data :pages-index page-id :objects shape-id] shape))
|
|
file' (cfm/migrate-file file {})
|
|
applied' (get-in file' [:data :pages-index page-id :objects shape-id :applied-tokens])]
|
|
|
|
(t/is (cfm/need-migration? file) "new migration detected")
|
|
(t/is (not (cfm/need-migration? file')) "new migration recorded")
|
|
(t/is (contains? (:migrations file') migration-id) "migration id persisted")
|
|
(t/is (= "30" (:stroke-width-top applied'))
|
|
"legacy token applied to the sides")))
|
|
|
|
(t/deftest migration-0032-adds-unlisted-children-to-their-parent
|
|
(let [migration-id "0032-fix-children-not-in-parent"
|
|
page-id (uuid/next)
|
|
parent-id (uuid/next)
|
|
listed-id (uuid/next)
|
|
unlisted1-id (uuid/next)
|
|
unlisted2-id (uuid/next)
|
|
orphan-id (uuid/next)
|
|
root (cts/setup-shape {:id uuid/zero :type :frame :shapes [parent-id orphan-id]})
|
|
parent (cts/setup-shape {:id parent-id :type :frame :parent-id uuid/zero
|
|
:shapes [listed-id]})
|
|
child (fn [id parent-id]
|
|
(cts/setup-shape {:id id :type :rect :parent-id parent-id}))
|
|
data {:pages-index
|
|
{page-id
|
|
{:objects {uuid/zero root
|
|
parent-id parent
|
|
listed-id (child listed-id parent-id)
|
|
unlisted1-id (child unlisted1-id parent-id)
|
|
unlisted2-id (child unlisted2-id parent-id)
|
|
orphan-id (child orphan-id (uuid/next))}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
objects' (get-in data' [:pages-index page-id :objects])
|
|
shapes' (get-in objects' [parent-id :shapes])]
|
|
|
|
(t/is (= listed-id (first shapes')) "already listed children keep their place")
|
|
(t/is (= #{listed-id unlisted1-id unlisted2-id} (set shapes'))
|
|
"unlisted children added to the parent")
|
|
(t/is (= 3 (count shapes')) "no duplicated children")
|
|
(t/is (= [parent-id orphan-id] (get-in objects' [uuid/zero :shapes]))
|
|
"root unchanged")
|
|
(t/is (= (get-in data [:pages-index page-id :objects orphan-id])
|
|
(get objects' orphan-id))
|
|
"shapes whose parent does not exist are left untouched")
|
|
(t/is (= data' (cfm/migrate-data data' migration-id))
|
|
"migration is idempotent")))
|
|
|
|
(t/deftest migration-0032-adds-unlisted-children-in-components
|
|
(let [migration-id "0032-fix-children-not-in-parent"
|
|
component-id (uuid/next)
|
|
main-id (uuid/next)
|
|
child-id (uuid/next)
|
|
data {:components
|
|
{component-id
|
|
{:objects {main-id (cts/setup-shape {:id main-id :type :frame :shapes []})
|
|
child-id (cts/setup-shape {:id child-id :type :rect
|
|
:parent-id main-id})}}}}
|
|
data' (cfm/migrate-data data migration-id)]
|
|
|
|
(t/is (= [child-id] (get-in data' [:components component-id :objects main-id :shapes])))))
|
|
|
|
(defn- make-path-shape
|
|
[shape-id]
|
|
(cts/setup-shape {:id shape-id
|
|
:type :path
|
|
:content [{:command :move-to :params {:x 10 :y 20}}
|
|
{:command :line-to :params {:x 110 :y 70}}]}))
|
|
|
|
(t/deftest migration-legacy-6-repairs-paths-without-points
|
|
(let [migration-id "legacy-6"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
shape (make-path-shape shape-id)]
|
|
|
|
(doseq [points [nil []]]
|
|
(let [data {:pages-index {page-id {:objects {shape-id (assoc shape :points points)}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
shape' (get-in data' [:pages-index page-id :objects shape-id])]
|
|
|
|
(t/is (= (:points shape) (:points shape'))
|
|
(str "points rebuilt from content when they were " (pr-str points)))
|
|
(t/is (= (:selrect shape) (:selrect shape'))
|
|
"selrect rebuilt from content")))))
|
|
|
|
(def ^:private contents-without-rect
|
|
"Path contents from old files whose points give no rect"
|
|
{"segments without params" [{:command :move-to} {:command :line-to}]
|
|
"only a close path" [{:command :close-path :params {}}]
|
|
"not finite coordinates" [{:command :move-to :params {:x ##NaN :y ##Inf}}
|
|
{:command :line-to :params {:x 10 :y 10}}]})
|
|
|
|
(t/deftest migration-legacy-6-repairs-paths-whose-content-has-no-rect
|
|
(let [migration-id "legacy-6"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)]
|
|
|
|
(doseq [[label content] contents-without-rect]
|
|
(let [shape (-> (make-path-shape shape-id)
|
|
(assoc :points [] :content content))
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
shape' (get-in data' [:pages-index page-id :objects shape-id])]
|
|
|
|
(t/is (grc/rect? (:selrect shape')) (str "selrect is a rect: " label))
|
|
(t/is (= 4 (count (:points shape'))) (str "points rebuilt: " label))))))
|
|
|
|
(t/deftest migration-0003-convert-path-content-drops-segments-without-coordinates
|
|
(let [migration-id "0003-convert-path-content-v2"
|
|
page-id (uuid/next)
|
|
shape-id (uuid/next)
|
|
content [{:command :move-to :params {:x 10 :y 20}}
|
|
{:command :line-to}
|
|
{:command :curve-to :params {:x 50 :y 50}}
|
|
{:command :line-to :params {:x 110 :y 70}}
|
|
{:command :close-path :params {}}]
|
|
shape (assoc (make-path-shape shape-id) :content content)
|
|
data {:pages-index {page-id {:objects {shape-id shape}}}}
|
|
data' (cfm/migrate-data data migration-id)
|
|
content' (get-in data' [:pages-index page-id :objects shape-id :content])]
|
|
|
|
(t/is (path/content? content'))
|
|
(t/is (= [:move-to :line-to :close-path] (mapv :command (vec content'))))))
|
|
|
|
(t/deftest migration-legacy-6-chain-with-content-without-rect
|
|
;; The file must load: every migration from legacy-6 on and the final
|
|
;; data validation must succeed.
|
|
(doseq [[label content] contents-without-rect]
|
|
(let [shape-id (uuid/next)
|
|
file (ctf/make-file {:name "Legacy path without rect"})
|
|
page-id (first (get-in file [:data :pages]))
|
|
shape (-> (make-path-shape shape-id)
|
|
(assoc :points [] :content content))
|
|
file (-> file
|
|
(dissoc :migrations)
|
|
(assoc :version 5)
|
|
(assoc-in [:data :pages-index page-id :objects shape-id] shape))
|
|
file' (cfm/migrate-file file (delay {}))]
|
|
|
|
(t/is (contains? (:migrations file') "legacy-6") (str "migrated: " label)))))
|
|
|
|
(t/deftest migration-legacy-6-runs-through-file-migration
|
|
;; A file saved before version 6 runs every migration from legacy-6 on,
|
|
;; and the whole chain must succeed for the file to load.
|
|
(let [migration-id "legacy-6"
|
|
shape-id (uuid/next)
|
|
file (ctf/make-file {:name "Legacy path without points"})
|
|
page-id (first (get-in file [:data :pages]))
|
|
shape (-> (make-path-shape shape-id)
|
|
(assoc :points []))
|
|
file (-> file
|
|
(dissoc :migrations)
|
|
(assoc :version 5)
|
|
(assoc-in [:data :pages-index page-id :objects shape-id] shape))
|
|
file' (cfm/migrate-file file (delay {}))]
|
|
|
|
(t/is (contains? (:migrations file') migration-id) "migration id persisted")
|
|
(t/is (= 4 (count (get-in file' [:data :pages-index page-id :objects shape-id :points])))
|
|
"path points repaired before schema validation")))
|
|
|
|
(t/deftest migration-0033-normalize-main-instance-diff-from-component
|
|
(let [migration-id "0033-normalize-main-instance-names"
|
|
file (-> (thf/sample-file :file1)
|
|
(tho/add-simple-component :component1 :root1 :child1
|
|
:component-params {:path "Group / Subgroup"
|
|
:name "Name"}
|
|
:root-params {:name "Group/Subgroup / Name"})
|
|
(tho/add-simple-component :component2 :root2 :child2
|
|
:component-params {:path "Group / Subgroup"
|
|
:name "Other Name"}
|
|
:root-params {:name "Group / Subgroup / Other Name"})
|
|
(ths/add-sample-shape :shape1 :name "Other Group/Other Subgroup / Other Name"))
|
|
|
|
data (ctf/file-data file)
|
|
data' (cfm/migrate-data data migration-id)
|
|
file' (assoc file :data data')
|
|
|
|
root1' (ths/get-shape file' :root1)
|
|
component1' (thk/get-component file' :component1)
|
|
root2' (ths/get-shape file' :root2)
|
|
component2' (thk/get-component file' :component2)
|
|
shape1' (ths/get-shape file' :shape1)]
|
|
|
|
(t/is (= "Group / Subgroup" (:path component1')))
|
|
(t/is (= "Name" (:name component1')))
|
|
(t/is (= "Group / Subgroup / Name" (:name root1')))
|
|
(t/is (= "Group / Subgroup" (:path component2')))
|
|
(t/is (= "Other Name" (:name component2')))
|
|
(t/is (= "Group / Subgroup / Other Name" (:name root2')))
|
|
(t/is (= "Other Group/Other Subgroup / Other Name" (:name shape1')))))
|
|
|
|
(t/deftest migration-0033-normalize-variant-instance-diff-from-container
|
|
(let [migration-id "0033-normalize-main-instance-names"
|
|
file (-> (thf/sample-file :file1)
|
|
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
|
(ths/update-shape :variant1 :name "Group / Subgroup / Component")
|
|
(ths/update-shape :root1 :name "Group / Subgroup / Component")
|
|
(ths/update-shape :root2 :name "Group / Subgroup/Component") ;; Bad path
|
|
(thk/update-component :component1 {:path "Group / Subgroup" :name "Component"})
|
|
(thk/update-component :component2 {:path "Group / Subgroup" :name "Component"})
|
|
(thk/instantiate-component :component2 :copy2)
|
|
(ths/update-shape :copy2 :name "Group / Subgroup/Component")) ;; Allowed name in copies
|
|
|
|
data (ctf/file-data file)
|
|
data' (cfm/migrate-data data migration-id)
|
|
file' (assoc file :data data')
|
|
|
|
variant1' (ths/get-shape file' :variant1)
|
|
root1' (ths/get-shape file' :root1)
|
|
component1' (thk/get-component file' :component1)
|
|
root2' (ths/get-shape file' :root2)
|
|
component2' (thk/get-component file' :component2)
|
|
copy2' (ths/get-shape file' :copy2)]
|
|
|
|
(t/is (= "Group / Subgroup / Component" (:name variant1')))
|
|
(t/is (= "Group / Subgroup / Component" (:name root1')))
|
|
(t/is (= "Group / Subgroup / Component" (:name root2')))
|
|
(t/is (= "Group / Subgroup" (:path component1')))
|
|
(t/is (= "Group / Subgroup" (:path component2')))
|
|
(t/is (= "Component" (:name component1')))
|
|
(t/is (= "Component" (:name component2')))
|
|
(t/is (= "Group / Subgroup/Component" (:name copy2')))))
|
|
|
|
(t/deftest migration-0033-normalize-variant-name-not-normalized
|
|
(let [migration-id "0033-normalize-main-instance-names"
|
|
file (-> (thf/sample-file :file1)
|
|
(thv/add-variant :variant1 :component1 :root1 :component2 :root2)
|
|
(ths/update-shape :variant1 :name "Group/Subgroup / Component")
|
|
(ths/update-shape :root1 :name "Group/Subgroup / Component")
|
|
(ths/update-shape :root2 :name "Group/Subgroup / Component") ;; Bad path
|
|
(thk/update-component :component1 {:path "Group / Subgroup" :name "Component"})
|
|
(thk/update-component :component2 {:path "Group / Subgroup" :name "Component"})
|
|
(thk/instantiate-component :component2 :copy2)
|
|
(ths/update-shape :copy2 :name "Group / Subgroup/Component")) ;; Allowed name in copies
|
|
|
|
data (ctf/file-data file)
|
|
data' (cfm/migrate-data data migration-id)
|
|
file' (assoc file :data data')
|
|
|
|
variant1' (ths/get-shape file' :variant1)
|
|
root1' (ths/get-shape file' :root1)
|
|
component1' (thk/get-component file' :component1)
|
|
root2' (ths/get-shape file' :root2)
|
|
component2' (thk/get-component file' :component2)
|
|
copy2' (ths/get-shape file' :copy2)]
|
|
|
|
(t/is (= "Group / Subgroup / Component" (:name variant1')))
|
|
(t/is (= "Group / Subgroup / Component" (:name root1')))
|
|
(t/is (= "Group / Subgroup / Component" (:name root2')))
|
|
(t/is (= "Group / Subgroup" (:path component1')))
|
|
(t/is (= "Group / Subgroup" (:path component2')))
|
|
(t/is (= "Component" (:name component1')))
|
|
(t/is (= "Component" (:name component2')))
|
|
(t/is (= "Group / Subgroup/Component" (:name copy2')))))
|
|
|
|
(t/deftest migration-0033-fixes-children-ids
|
|
;; Old files may list in :shapes child ids stored as strings, nil or
|
|
;; ids that point to no shape; strings become uuids, the rest goes.
|
|
(let [migration-id "0033-normalize-main-instance-names"
|
|
file (-> (thf/sample-file :file1)
|
|
(tho/add-frame-with-child :frame1 :child1)
|
|
(tho/add-simple-component :component1 :root1 :child2))
|
|
child1-id (thi/id :child1)
|
|
child2-id (thi/id :child2)
|
|
file (-> file
|
|
(ths/update-shape :frame1 :shapes
|
|
[nil "not-a-uuid" (str child1-id)
|
|
(uuid/next) child1-id])
|
|
(ths/update-shape :root1 :shapes [(str child2-id)]))
|
|
data' (cfm/migrate-data (ctf/file-data file) migration-id)
|
|
file' (assoc file :data data')]
|
|
|
|
(t/is (= [child1-id] (:shapes (ths/get-shape file' :frame1))))
|
|
(t/is (= [child2-id] (:shapes (ths/get-shape file' :root1))))
|
|
(t/is (= data' (cfm/migrate-data data' migration-id))
|
|
"migration is idempotent")))
|
|
|
|
(t/deftest migration-0033-fixes-children-ids-through-file-migration
|
|
;; 0032 adds the uuid of a child listed as a string, so the child is
|
|
;; listed twice before 0033; the file must still pass validation.
|
|
(let [file (-> (thf/sample-file :file1)
|
|
(tho/add-frame-with-child :frame1 :child1))
|
|
child1-id (thi/id :child1)
|
|
file (-> file
|
|
(ths/update-shape :frame1 :shapes [(str child1-id)])
|
|
(assoc :migrations
|
|
(disj cfm/available-migrations
|
|
"0032-fix-children-not-in-parent"
|
|
"0033-normalize-main-instance-names")))
|
|
file' (cfm/migrate-file file {})]
|
|
|
|
(t/is (not (cfm/need-migration? file')) "new migrations recorded")
|
|
(t/is (= [child1-id] (:shapes (ths/get-shape file' :frame1))))))
|