mirror of
https://github.com/penpot/penpot.git
synced 2026-07-28 17:06:21 +00:00
🐛 Fix text content repair and remove blast-radius containment (#10731)
Repair text shapes with empty/broken content at all three levels (root, paragraph-set, paragraph) in migration 0025 to prevent workspace update failures. Handle all corner cases: nil/empty/non- vector children, non-map items, wrong types. Remove geometry-only validation skip in changes.cljc so all shapes are validated. AI-assisted-by: qwen3.7-plus
This commit is contained in:
parent
535ccfb930
commit
7eb188b077
@ -1874,6 +1874,110 @@
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(defmethod migrate-data "0025-repair-empty-text-content"
|
||||
;; Repair text shapes whose :content tree has empty/missing :children
|
||||
;; at any of the three levels:
|
||||
;; Level 1: root with no paragraph-set
|
||||
;; Level 2: paragraph-set with no paragraph
|
||||
;; Level 3: paragraph with no span
|
||||
;; Such shapes fail the backend `validate-shape` schema and would also
|
||||
;; break the v2 editor's `cljs->dom` roundtrip. Re-seed the canonical
|
||||
;; root -> paragraph-set -> paragraph -> span tree, preserving the
|
||||
;; original root-level attributes (e.g. :vertical-align) when present.
|
||||
;; Idempotent on healthy content.
|
||||
[data _]
|
||||
(let [default-span {:text "" :fills types.text/default-text-fills}
|
||||
default-paragraph {:type "paragraph" :children [default-span]}
|
||||
default-paragraph-set {:type "paragraph-set" :children [default-paragraph]}
|
||||
|
||||
;; Level 3: repair paragraph with empty/missing children
|
||||
repair-span (fn [span]
|
||||
(if (and (map? span)
|
||||
(string? (:text span)))
|
||||
span
|
||||
default-span))
|
||||
|
||||
repair-paragraph (fn [paragraph]
|
||||
(if (and (map? paragraph)
|
||||
(= "paragraph" (:type paragraph)))
|
||||
(cond
|
||||
;; Children is nil or empty vector - seed with default span
|
||||
(or (nil? (:children paragraph))
|
||||
(and (vector? (:children paragraph))
|
||||
(empty? (:children paragraph))))
|
||||
(assoc paragraph :children [default-span])
|
||||
|
||||
;; Children is a vector - repair any invalid spans
|
||||
(vector? (:children paragraph))
|
||||
(update paragraph :children
|
||||
(fn [children]
|
||||
(mapv repair-span children)))
|
||||
|
||||
;; Children is not a vector - replace with default
|
||||
:else
|
||||
(assoc paragraph :children [default-span]))
|
||||
default-paragraph))
|
||||
|
||||
;; Level 2: repair paragraph-set with empty/missing children
|
||||
repair-paragraph-set (fn [paragraph-set]
|
||||
(if (and (map? paragraph-set)
|
||||
(= "paragraph-set" (:type paragraph-set)))
|
||||
(cond
|
||||
;; Children is nil or empty vector - seed with default paragraph
|
||||
(or (nil? (:children paragraph-set))
|
||||
(and (vector? (:children paragraph-set))
|
||||
(empty? (:children paragraph-set))))
|
||||
(assoc paragraph-set :children [default-paragraph])
|
||||
|
||||
;; Children is a vector - repair any invalid paragraphs
|
||||
(vector? (:children paragraph-set))
|
||||
(update paragraph-set :children
|
||||
(fn [children]
|
||||
(mapv repair-paragraph children)))
|
||||
|
||||
;; Children is not a vector - replace with default
|
||||
:else
|
||||
(assoc paragraph-set :children [default-paragraph]))
|
||||
default-paragraph-set))
|
||||
|
||||
;; Repair content at all levels, handling all edge cases
|
||||
repair-content (fn [content]
|
||||
(cond
|
||||
;; Content is not a valid root map - create default
|
||||
;; Preserve root-level attrs if content is a map
|
||||
(or (nil? content)
|
||||
(not (map? content))
|
||||
(not= "root" (:type content)))
|
||||
(merge types.text/default-root-attrs
|
||||
{:type "root"
|
||||
:children [default-paragraph-set]}
|
||||
(when (map? content)
|
||||
(select-keys content types.text/root-attrs)))
|
||||
|
||||
;; Content is a valid root - repair all levels
|
||||
:else
|
||||
(let [children (if (and (vector? (:children content))
|
||||
(seq (:children content)))
|
||||
(:children content)
|
||||
[default-paragraph-set])]
|
||||
(merge types.text/default-root-attrs
|
||||
{:type "root"}
|
||||
(select-keys content types.text/root-attrs)
|
||||
{:children (mapv repair-paragraph-set children)}))))
|
||||
|
||||
;; Simplified gatekeeper - just check if it's a text shape
|
||||
fix-shape (fn [shape]
|
||||
(if (cfh/text-shape? shape)
|
||||
(update shape :content repair-content)
|
||||
shape))
|
||||
|
||||
update-container (fn [container]
|
||||
(d/update-when container :objects d/update-vals fix-shape))]
|
||||
|
||||
(-> data
|
||||
(update :pages-index d/update-vals update-container)
|
||||
(d/update-when :components d/update-vals update-container))))
|
||||
|
||||
(def available-migrations
|
||||
(into (d/ordered-set)
|
||||
["legacy-2"
|
||||
@ -1955,4 +2059,5 @@
|
||||
"0021-fix-shape-svg-attrs"
|
||||
"0022-normalize-component-root-and-resync"
|
||||
"0023-repair-token-themes-with-inexistent-sets"
|
||||
"0024b-fix-stroke-cap-placement"]))
|
||||
"0024b-fix-stroke-cap-placement"
|
||||
"0025-repair-empty-text-content"]))
|
||||
|
||||
@ -20,44 +20,42 @@
|
||||
[:type [:= "root"]]
|
||||
[:key {:optional true} :string]
|
||||
[:children
|
||||
{:optional true}
|
||||
[:maybe
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:type [:= "paragraph-set"]]
|
||||
[:key {:optional true} :string]
|
||||
[:children
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:type [:= "paragraph"]]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]
|
||||
[:children
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:text :string]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]]])
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:type [:= "paragraph-set"]]
|
||||
[:key {:optional true} :string]
|
||||
[:children
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:type [:= "paragraph"]]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]
|
||||
[:children
|
||||
[:vector {:min 1 :gen/max 2 :gen/min 1}
|
||||
[:map
|
||||
[:text :string]
|
||||
[:key {:optional true} :string]
|
||||
[:fills {:optional true}
|
||||
[:maybe schema:fills]]
|
||||
[:font-family {:optional true} ::sm/text]
|
||||
[:font-size {:optional true} ::sm/text]
|
||||
[:font-style {:optional true} ::sm/text]
|
||||
[:font-weight {:optional true} ::sm/text]
|
||||
[:direction {:optional true} ::sm/text]
|
||||
[:text-decoration {:optional true} ::sm/text]
|
||||
[:text-transform {:optional true} ::sm/text]
|
||||
[:typography-ref-id {:optional true} [:maybe ::sm/uuid]]
|
||||
[:typography-ref-file {:optional true} [:maybe ::sm/uuid]]]]]]]]]]]])
|
||||
|
||||
(def valid-content?
|
||||
(sm/lazy-validator schema:content))
|
||||
|
||||
@ -912,3 +912,4 @@
|
||||
(nil? (get-in result2 [:pages-index page-id :default-grids])))))
|
||||
|
||||
{:num 1000})))
|
||||
|
||||
|
||||
833
common/test/common_tests/files_migrations_0025_test.cljc
Normal file
833
common/test/common_tests/files_migrations_0025_test.cljc
Normal file
@ -0,0 +1,833 @@
|
||||
;; 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 INC Sucursal en España SL
|
||||
|
||||
(ns common-tests.files-migrations-0025-test
|
||||
(:require
|
||||
[app.common.files.migrations :as cfm]
|
||||
[app.common.types.shape :as cts]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
;; 0025-repair-empty-text-content
|
||||
;; Text shapes whose :content is a root with an empty/missing :children
|
||||
;; vector used to slip past the schema (children was optional). With the
|
||||
;; schema tightening those shapes must be repaired on next load.
|
||||
(defn- make-text-shape-with-content
|
||||
"Build a text shape with arbitrary content structure"
|
||||
[shape-id content]
|
||||
(-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content content)))
|
||||
|
||||
(defn- make-broken-text-shape
|
||||
"Build a fully-initialised text shape with a broken :content and the
|
||||
supplied root-level attrs overlaid on it."
|
||||
[shape-id root-attrs]
|
||||
(-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content (merge {:type "root"}
|
||||
(when (seq root-attrs) root-attrs)
|
||||
{:children []}))))
|
||||
|
||||
(t/deftest migration-0025-repair-empty-text-content-empty-children
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-broken-text-shape shape-id {:vertical-align "top"})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "root" (:type content)) "root type preserved")
|
||||
(t/is (vector? (:children content)) "children is now a vector")
|
||||
(t/is (= 1 (count (:children content))) "exactly one paragraph-set seeded")
|
||||
(t/is (= "paragraph-set" (get-in content [:children 0 :type])))
|
||||
(t/is (pos? (count (get-in content [:children 0 :children])))
|
||||
"paragraph-set has at least one paragraph")
|
||||
(t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text]))
|
||||
"seeded span has empty text")
|
||||
(t/is (= "top" (:vertical-align content))
|
||||
"preserves pre-existing :vertical-align")))
|
||||
|
||||
(t/deftest migration-0025-repair-empty-text-content-missing-children
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
;; A text shape whose :content has no :children key at all.
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {:type "root"
|
||||
:vertical-align "center"}))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (vector? (:children content)) "missing children becomes a vector")
|
||||
(t/is (pos? (count (:children content))) "missing children gets a paragraph-set")
|
||||
(t/is (= "center" (:vertical-align content))
|
||||
"preserves pre-existing :vertical-align")))
|
||||
|
||||
(t/deftest migration-0025-repair-empty-text-content-no-content
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
;; A text shape with no :content at all. Should be repaired with default content.
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (map? content) "content is now a map")
|
||||
(t/is (= "root" (:type content)) "content has root type")
|
||||
(t/is (vector? (:children content)) "children is a vector")
|
||||
(t/is (pos? (count (:children content))) "has at least one paragraph-set")))
|
||||
|
||||
(t/deftest migration-0025-repair-empty-text-content-idempotent
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
;; A healthy text shape with a proper paragraph-set/paragraph/
|
||||
;; span tree. The migration must leave it untouched.
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {:type "root"
|
||||
:vertical-align "top"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "hello"}]}]}]}))}}}}
|
||||
original (get-in data [:pages-index page-id :objects shape-id])
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape' (get-in data' [:pages-index page-id :objects shape-id])]
|
||||
|
||||
(t/is (cts/valid-shape? original) "baseline shape is valid")
|
||||
(t/is (= original shape') "healthy content is unchanged")))
|
||||
|
||||
(t/deftest migration-0025-repair-empty-text-content-component
|
||||
;; The migration also walks :components, so a broken text inside a
|
||||
;; component is also repaired.
|
||||
(let [shape-id (uuid/next)
|
||||
comp-id (uuid/next)
|
||||
data {:components
|
||||
{comp-id
|
||||
{:objects
|
||||
{shape-id (make-broken-text-shape shape-id nil)}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:components comp-id :objects shape-id])]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired component shape is valid")
|
||||
(t/is (pos? (count (get-in shape [:content :children])))
|
||||
"children vector is no longer empty")))
|
||||
|
||||
(t/deftest migration-0025-repair-empty-text-content-level2
|
||||
;; Level 2: paragraph-set with empty/missing children
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {:type "root"
|
||||
:vertical-align "top"
|
||||
:children [{:type "paragraph-set"
|
||||
:children []}]}))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "paragraph-set" (get-in content [:children 0 :type])) "paragraph-set preserved")
|
||||
(t/is (pos? (count (get-in content [:children 0 :children])))
|
||||
"paragraph-set now has at least one paragraph")
|
||||
(t/is (= "paragraph" (get-in content [:children 0 :children 0 :type]))
|
||||
"seeded child is a paragraph")
|
||||
(t/is (= "top" (:vertical-align content))
|
||||
"preserves pre-existing :vertical-align")))
|
||||
|
||||
(t/deftest migration-0025-repair-empty-text-content-level3
|
||||
;; Level 3: paragraph with empty/missing children
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {:type "root"
|
||||
:vertical-align "top"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children []}]}]}))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "paragraph preserved")
|
||||
(t/is (pos? (count (get-in content [:children 0 :children 0 :children])))
|
||||
"paragraph now has at least one span")
|
||||
(t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text]))
|
||||
"seeded span has empty text")
|
||||
(t/is (= "top" (:vertical-align content))
|
||||
"preserves pre-existing :vertical-align")))
|
||||
|
||||
(t/deftest migration-0025-repair-empty-text-content-mixed-levels
|
||||
;; Valid level 1, but broken at levels 2 and 3 in different paragraph-sets
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {:type "root"
|
||||
:vertical-align "top"
|
||||
:children [{:type "paragraph-set"
|
||||
:children []}
|
||||
{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children []}]}]}))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= 2 (count (:children content))) "both paragraph-sets preserved")
|
||||
;; First paragraph-set had empty children (level 2 broken)
|
||||
(t/is (pos? (count (get-in content [:children 0 :children])))
|
||||
"first paragraph-set now has paragraphs")
|
||||
;; Second paragraph-set had a paragraph with empty children (level 3 broken)
|
||||
(t/is (pos? (count (get-in content [:children 1 :children 0 :children])))
|
||||
"second paragraph's paragraph now has spans")))
|
||||
|
||||
;; ============================================================================
|
||||
;; Category A: Shape-level guards (fix-shape)
|
||||
;; ============================================================================
|
||||
|
||||
(t/deftest migration-0025-non-text-shape-untouched
|
||||
;; A: Non-text shape should not be processed
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (cts/setup-shape {:id shape-id :type :rect :x 0 :y 0})}}}}
|
||||
original (get-in data [:pages-index page-id :objects shape-id])
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape' (get-in data' [:pages-index page-id :objects shape-id])]
|
||||
|
||||
(t/is (= original shape') "non-text shape is unchanged")))
|
||||
|
||||
(t/deftest migration-0025-text-shape-non-map-content-repaired
|
||||
;; A: Text shape with non-map content should be repaired
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content "not a map"))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (map? content) "content is now a map")
|
||||
(t/is (= "root" (:type content)) "content has root type")
|
||||
(t/is (vector? (:children content)) "children is a vector")
|
||||
(t/is (pos? (count (:children content))) "has at least one paragraph-set")))
|
||||
|
||||
(t/deftest migration-0025-text-shape-wrong-root-type-repaired
|
||||
;; A: Text shape with content :type not "root" should be repaired, preserving root-level attrs
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {:type "paragraph"
|
||||
:vertical-align "center"
|
||||
:children []}))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "root" (:type content)) "type is now root")
|
||||
(t/is (= "center" (:vertical-align content)) "root-level attrs preserved")
|
||||
(t/is (vector? (:children content)) "children is a vector")
|
||||
(t/is (pos? (count (:children content))) "has at least one paragraph-set")))
|
||||
|
||||
(t/deftest migration-0025-text-shape-nil-content
|
||||
;; I: Text shape with :content nil should be repaired with default content
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content nil))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (map? content) "content is now a map")
|
||||
(t/is (= "root" (:type content)) "content has root type")
|
||||
(t/is (vector? (:children content)) "children is a vector")
|
||||
(t/is (pos? (count (:children content))) "has at least one paragraph-set")))
|
||||
|
||||
(t/deftest migration-0025-text-shape-empty-map-content
|
||||
;; I: Text shape with :content {} (empty map) should be repaired with default content
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {}))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "root" (:type content)) "type is now root")
|
||||
(t/is (vector? (:children content)) "children is a vector")
|
||||
(t/is (pos? (count (:children content))) "has at least one paragraph-set")))
|
||||
|
||||
(t/deftest migration-0025-text-shape-wrong-type-with-root-attrs
|
||||
;; I: Text shape with wrong type but valid root-level attrs should preserve attrs
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {:type "paragraph"
|
||||
:vertical-align "bottom"
|
||||
:children []}))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "root" (:type content)) "type is now root")
|
||||
(t/is (= "bottom" (:vertical-align content)) "root-level attrs preserved")
|
||||
(t/is (vector? (:children content)) "children is a vector")
|
||||
(t/is (pos? (count (:children content))) "has at least one paragraph-set")))
|
||||
|
||||
(t/deftest migration-0025-text-shape-partial-salvage-paragraphs-under-root
|
||||
;; K: Root has children but they're paragraphs (not paragraph-sets) - should preserve level 1 attrs
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (-> (cts/setup-shape {:id shape-id :type :text :x 0 :y 0 :grow-type :auto-width})
|
||||
(assoc :content {:type "root"
|
||||
:vertical-align "top"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "hello"}]}]}))}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "root" (:type content)) "root type preserved")
|
||||
(t/is (= "top" (:vertical-align content)) "root-level attrs preserved")
|
||||
(t/is (= 1 (count (:children content))) "has one paragraph-set")
|
||||
(t/is (= "paragraph-set" (get-in content [:children 0 :type])) "child is paragraph-set")
|
||||
(t/is (pos? (count (get-in content [:children 0 :children]))) "paragraph-set has paragraphs")))
|
||||
|
||||
;; ============================================================================
|
||||
;; Category B: Level 1 (root) variants
|
||||
;; ============================================================================
|
||||
|
||||
(t/deftest migration-0025-root-non-vector-children-map
|
||||
;; B: Root with non-vector children (map) - GAP: should repair
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:vertical-align "top"
|
||||
:children {:invalid "map"}})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "root" (:type content)) "root type preserved")
|
||||
(t/is (vector? (:children content)) "children is now a vector")
|
||||
(t/is (= "top" (:vertical-align content)) "preserves vertical-align")))
|
||||
|
||||
(t/deftest migration-0025-root-non-vector-children-string
|
||||
;; B: Root with non-vector children (string) - GAP: should repair
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:vertical-align "center"
|
||||
:children "not a vector"})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (vector? (:children content)) "children is now a vector")
|
||||
(t/is (= "center" (:vertical-align content)) "preserves vertical-align")))
|
||||
|
||||
;; ============================================================================
|
||||
;; Category C: Level 2 (paragraph-set) variants
|
||||
;; ============================================================================
|
||||
|
||||
(t/deftest migration-0025-paragraph-set-nil-children
|
||||
;; C: Paragraph-set with nil children key
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (vector? (get-in content [:children 0 :children])) "children is a vector")
|
||||
(t/is (pos? (count (get-in content [:children 0 :children]))) "has at least one paragraph")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-set-non-vector-children-map
|
||||
;; C: Paragraph-set with non-vector children (map)
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children {:invalid "map"}}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (vector? (get-in content [:children 0 :children])) "children is now a vector")
|
||||
(t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "seeded with default paragraph")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-set-non-vector-children-string
|
||||
;; C: Paragraph-set with non-vector children (string)
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children "not a vector"}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (vector? (get-in content [:children 0 :children])) "children is now a vector")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-set-item-not-map
|
||||
;; C: Paragraph-set with non-map item in children vector
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children ["not a map"]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "non-map item replaced with default paragraph")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-set-mixed-valid-nil-non-map
|
||||
;; C: Paragraph-set with mix of valid paragraphs, nil, and non-map items
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "ok"}]}
|
||||
nil
|
||||
"not-a-map"
|
||||
{:type "paragraph"
|
||||
:children [{:text "also ok"}]}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
paragraphs (get-in shape [:content :children 0 :children])]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= 4 (count paragraphs)) "all items preserved as paragraphs")
|
||||
(t/is (= "paragraph" (:type (nth paragraphs 0))) "valid paragraph preserved")
|
||||
(t/is (= "ok" (:text (get-in (nth paragraphs 0) [:children 0]))) "valid span text preserved")
|
||||
(t/is (= "paragraph" (:type (nth paragraphs 1))) "nil replaced with default paragraph")
|
||||
(t/is (= "paragraph" (:type (nth paragraphs 2))) "non-map replaced with default paragraph")
|
||||
(t/is (= "paragraph" (:type (nth paragraphs 3))) "valid paragraph preserved")
|
||||
(t/is (= "also ok" (:text (get-in (nth paragraphs 3) [:children 0]))) "valid span text preserved")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-set-wrong-type
|
||||
;; C: Paragraph-set with wrong :type
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph"
|
||||
:children []}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "paragraph-set" (get-in content [:children 0 :type])) "wrong type replaced with default paragraph-set")))
|
||||
|
||||
;; ============================================================================
|
||||
;; Category D: Level 3 (paragraph) variants
|
||||
;; ============================================================================
|
||||
|
||||
(t/deftest migration-0025-paragraph-nil-children
|
||||
;; D: Paragraph with nil children key
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is a vector")
|
||||
(t/is (pos? (count (get-in content [:children 0 :children 0 :children]))) "has at least one span")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-non-vector-children-map
|
||||
;; D: Paragraph with non-vector children (map) - GAP: should repair
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children {:invalid "map"}}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is now a vector")
|
||||
(t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) "seeded with default span")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-non-vector-children-string
|
||||
;; D: Paragraph with non-vector children (string) - GAP: should repair
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children "not a vector"}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (vector? (get-in content [:children 0 :children 0 :children])) "paragraph children is now a vector")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-item-not-map
|
||||
;; D: Paragraph with non-map item in children vector
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children ["not a map"]}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "" (get-in content [:children 0 :children 0 :children 0 :text])) "non-map item replaced with default span")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-wrong-type
|
||||
;; D: Paragraph with wrong :type
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "span"
|
||||
:text "hello"}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "paragraph" (get-in content [:children 0 :children 0 :type])) "wrong type replaced with default paragraph")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-valid-spans-preserved
|
||||
;; D: Paragraph with valid spans should be preserved
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "hello"}
|
||||
{:text "world"}]}]}]})}}}}
|
||||
original-spans (get-in data [:pages-index page-id :objects shape-id :content :children 0 :children 0 :children])
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= 2 (count (get-in content [:children 0 :children 0 :children]))) "both spans preserved")
|
||||
(t/is (= original-spans (get-in content [:children 0 :children 0 :children])) "spans unchanged")))
|
||||
|
||||
;; ============================================================================
|
||||
;; Category E: Preservation tests
|
||||
;; ============================================================================
|
||||
|
||||
(t/deftest migration-0025-root-attrs-preserved-level2-repair
|
||||
;; E: Root-level attrs preserved when level 2 repaired
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:vertical-align "bottom"
|
||||
:children [{:type "paragraph-set"
|
||||
:children []}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "bottom" (:vertical-align content)) "root attrs preserved during level 2 repair")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-set-attrs-preserved
|
||||
;; E: Paragraph-set attrs preserved when repaired at level 2
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:custom-attr "preserve-me"
|
||||
:children []}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "preserve-me" (get-in content [:children 0 :custom-attr])) "paragraph-set attrs preserved")))
|
||||
|
||||
(t/deftest migration-0025-paragraph-attrs-preserved
|
||||
;; E: Paragraph attrs preserved when repaired at level 3
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:text-align "center"
|
||||
:children []}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= "center" (get-in content [:children 0 :children 0 :text-align])) "paragraph attrs preserved")))
|
||||
|
||||
;; ============================================================================
|
||||
;; Category F: Multi-item tests
|
||||
;; ============================================================================
|
||||
|
||||
(t/deftest migration-0025-multiple-paragraphs-mixed-valid-broken
|
||||
;; F: Multiple paragraphs within one paragraph-set, mix of valid and broken
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "valid"}]}
|
||||
{:type "paragraph"
|
||||
:children []}
|
||||
{:type "paragraph"
|
||||
:children [{:text "also valid"}]}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= 3 (count (get-in content [:children 0 :children]))) "all three paragraphs preserved")
|
||||
(t/is (= "valid" (get-in content [:children 0 :children 0 :children 0 :text])) "first paragraph preserved")
|
||||
(t/is (= "" (get-in content [:children 0 :children 1 :children 0 :text])) "second paragraph repaired")
|
||||
(t/is (= "also valid" (get-in content [:children 0 :children 2 :children 0 :text])) "third paragraph preserved")))
|
||||
|
||||
(t/deftest migration-0025-multiple-spans-all-preserved
|
||||
;; F: Multiple spans within one paragraph (all should be preserved)
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "span1"}
|
||||
{:text "span2"}
|
||||
{:text "span3"}]}]}]})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape (get-in data' [:pages-index page-id :objects shape-id])
|
||||
content (:content shape)]
|
||||
|
||||
(t/is (cts/valid-shape? shape) "repaired shape is valid")
|
||||
(t/is (= 3 (count (get-in content [:children 0 :children 0 :children]))) "all spans preserved")
|
||||
(t/is (= "span1" (get-in content [:children 0 :children 0 :children 0 :text])))
|
||||
(t/is (= "span2" (get-in content [:children 0 :children 0 :children 1 :text])))
|
||||
(t/is (= "span3" (get-in content [:children 0 :children 0 :children 2 :text])))))
|
||||
|
||||
;; ============================================================================
|
||||
;; Category G: Container coverage
|
||||
;; ============================================================================
|
||||
|
||||
(t/deftest migration-0025-multiple-pages-broken-shapes
|
||||
;; G: Multiple pages, each with broken shapes
|
||||
(let [shape-id-1 (uuid/next)
|
||||
shape-id-2 (uuid/next)
|
||||
page-id-1 (uuid/next)
|
||||
page-id-2 (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id-1
|
||||
{:objects
|
||||
{shape-id-1 (make-text-shape-with-content
|
||||
shape-id-1
|
||||
{:type "root" :children []})}}
|
||||
page-id-2
|
||||
{:objects
|
||||
{shape-id-2 (make-text-shape-with-content
|
||||
shape-id-2
|
||||
{:type "root" :children []})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
shape-1 (get-in data' [:pages-index page-id-1 :objects shape-id-1])
|
||||
shape-2 (get-in data' [:pages-index page-id-2 :objects shape-id-2])]
|
||||
|
||||
(t/is (cts/valid-shape? shape-1) "first page shape is valid")
|
||||
(t/is (cts/valid-shape? shape-2) "second page shape is valid")
|
||||
(t/is (pos? (count (get-in shape-1 [:content :children]))) "first page shape repaired")
|
||||
(t/is (pos? (count (get-in shape-2 [:content :children]))) "second page shape repaired")))
|
||||
|
||||
(t/deftest migration-0025-container-without-objects
|
||||
;; G: Container without :objects key should not crash
|
||||
(let [page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id {}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")]
|
||||
|
||||
(t/is (= data data') "container without objects is unchanged")))
|
||||
|
||||
;; ============================================================================
|
||||
;; Category H: Idempotency
|
||||
;; ============================================================================
|
||||
|
||||
(t/deftest migration-0025-already-repaired-unchanged
|
||||
;; H: Already-repaired content unchanged (run migration twice)
|
||||
(let [shape-id (uuid/next)
|
||||
page-id (uuid/next)
|
||||
data {:pages-index
|
||||
{page-id
|
||||
{:objects
|
||||
{shape-id (make-text-shape-with-content
|
||||
shape-id
|
||||
{:type "root" :children []})}}}}
|
||||
data' (cfm/migrate-data data "0025-repair-empty-text-content")
|
||||
data'' (cfm/migrate-data data' "0025-repair-empty-text-content")
|
||||
shape' (get-in data' [:pages-index page-id :objects shape-id])
|
||||
shape'' (get-in data'' [:pages-index page-id :objects shape-id])]
|
||||
|
||||
(t/is (cts/valid-shape? shape') "first repair produces valid shape")
|
||||
(t/is (cts/valid-shape? shape'') "second repair produces valid shape")
|
||||
(t/is (= shape' shape'') "migration is idempotent")))
|
||||
@ -8,7 +8,6 @@
|
||||
(:require
|
||||
[app.common.data :as d]
|
||||
[app.common.files.migrations :as cfm]
|
||||
[app.common.pprint :as pp]
|
||||
[app.common.types.file :as ctf]
|
||||
[app.common.uuid :as uuid]
|
||||
[clojure.test :as t]))
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
[common-tests.data-test]
|
||||
[common-tests.files-builder-test]
|
||||
[common-tests.files-changes-test]
|
||||
[common-tests.files-migrations-0025-test]
|
||||
[common-tests.files-migrations-test]
|
||||
[common-tests.files.shapes-builder-test]
|
||||
[common-tests.files.validate-test]
|
||||
@ -92,6 +93,7 @@
|
||||
'common-tests.data-test
|
||||
'common-tests.files-changes-test
|
||||
'common-tests.files-builder-test
|
||||
'common-tests.files-migrations-0025-test
|
||||
'common-tests.files-migrations-test
|
||||
'common-tests.files.validate-test
|
||||
'common-tests.geom-align-test
|
||||
|
||||
@ -39,6 +39,7 @@
|
||||
[app.render-wasm.api :as wasm.api]
|
||||
[app.render-wasm.text-editor :as wasm.text-editor]
|
||||
[app.util.text-editor :as ted]
|
||||
[app.util.text.content :as tc]
|
||||
[app.util.text.content.styles :as styles]
|
||||
[app.util.timers :as ts]
|
||||
[beicon.v2.core :as rx]
|
||||
@ -61,6 +62,24 @@
|
||||
|
||||
;; -- Content helpers
|
||||
|
||||
(defn ensure-valid-text-content
|
||||
"Repair structurally incomplete text :content to a canonical
|
||||
root -> paragraph-set -> paragraph -> span tree. Returns the
|
||||
content unchanged when it is already well-formed.
|
||||
|
||||
A `nil` content, a root with no :children, or a root with an empty
|
||||
:children vector all fail the backend `validate-shape` schema
|
||||
(children must contain at least one paragraph-set). This helper
|
||||
is the defensive normalizer used by content-commit paths."
|
||||
[content]
|
||||
(if (and (map? content)
|
||||
(= "root" (:type content))
|
||||
(or (nil? (:children content))
|
||||
(empty? (:children content))))
|
||||
(let [base (tc/v2-default-text-content)]
|
||||
(d/txt-merge base (select-keys content txt/root-attrs)))
|
||||
content))
|
||||
|
||||
(defn- v2-content-has-text?
|
||||
[content]
|
||||
(boolean
|
||||
@ -426,7 +445,14 @@
|
||||
(fn [shape]
|
||||
(if (some? (:content shape))
|
||||
(txt/update-text-content shape txt/is-root-node? d/txt-merge attrs)
|
||||
(assoc shape :content (d/txt-merge {:type "root"} attrs))))
|
||||
;; Shape has no :content yet (e.g. a brand-new text
|
||||
;; shape that has never been edited). Seed the
|
||||
;; canonical root/paragraph-set/paragraph/span tree
|
||||
;; before applying the new root attrs; the
|
||||
;; `validate-shape` schema requires :children to
|
||||
;; contain at least one paragraph-set.
|
||||
(assoc shape :content
|
||||
(d/txt-merge (tc/v2-default-text-content) attrs))))
|
||||
|
||||
shape-ids
|
||||
(cond (cfh/text-shape? shape) [id]
|
||||
@ -1078,126 +1104,134 @@
|
||||
(defn v2-update-text-shape-content
|
||||
[id content & {:keys [update-name? name finalize? save-undo? original-content]
|
||||
:or {update-name? false name nil finalize? false save-undo? true original-content nil}}]
|
||||
(ptk/reify ::v2-update-text-shape-content
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(let [;; v3 editor always passes :finalize? from keyword opts; when absent
|
||||
;; that binds nil and :or defaults do not apply — coerce so undo flags
|
||||
;; stay strict booleans for changes-builder schema validation.
|
||||
finalize? (boolean finalize?)
|
||||
objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
new-shape? (contains? (:workspace-new-text-shapes state) id)
|
||||
prev-content (:content shape)
|
||||
has-prev-content? (not (nil? (:prev-content shape)))
|
||||
;; For existing shapes, capture geometry at session start once so
|
||||
;; finalize can build a single undo entry. Stored in workspace state,
|
||||
;; not in the shape, to avoid persisting session-only data.
|
||||
session-start-geom (or (get-in state [:workspace-text-session-geom id])
|
||||
(select-keys shape [:selrect :points :width :height]))
|
||||
content-has-text? (v2-content-has-text? content)
|
||||
prev-content-has-text? (v2-content-has-text? prev-content)
|
||||
;; Only measure/resize the shape on finalize. While the user is
|
||||
;; actively typing, the WASM editor already renders the growing text
|
||||
;; (and the editor overlay measures it live), so a per-keystroke
|
||||
;; resize is redundant and, going through the interactive-transform
|
||||
;; modifier machinery, made auto-width typing very laggy.
|
||||
new-size (when (and finalize? (not= :fixed (:grow-type shape)))
|
||||
(dwwt/get-wasm-text-new-size shape content))
|
||||
;; New shapes: single undo on finalize only (no per-keystroke undo)
|
||||
effective-save-undo? (if new-shape? finalize? save-undo?)
|
||||
effective-stack-undo? (and new-shape? finalize?)
|
||||
;; No save-undo on first update when finalizing: either build-finalize
|
||||
;; holds undo (non-new), or we delete empty text and only delete-shapes
|
||||
;; should record undo.
|
||||
finalize-save-undo-first?
|
||||
(if (and finalize? (or (not new-shape?) (not content-has-text?)))
|
||||
false
|
||||
effective-save-undo?)]
|
||||
;; Defensive: ensure the content we are about to commit has the
|
||||
;; canonical root/paragraph-set/paragraph/span tree. The v2 editor
|
||||
;; always produces well-formed content from `dom->cljs`, but legacy
|
||||
;; producers (or programmatic API calls) can still pass a root with
|
||||
;; empty :children, which would fail the backend `validate-shape`
|
||||
;; schema.
|
||||
(let [content (ensure-valid-text-content content)
|
||||
original-content (ensure-valid-text-content original-content)]
|
||||
(ptk/reify ::v2-update-text-shape-content
|
||||
ptk/WatchEvent
|
||||
(watch [it state _]
|
||||
(if (features/active-feature? state "render-wasm/v1")
|
||||
(let [;; v3 editor always passes :finalize? from keyword opts; when absent
|
||||
;; that binds nil and :or defaults do not apply — coerce so undo flags
|
||||
;; stay strict booleans for changes-builder schema validation.
|
||||
finalize? (boolean finalize?)
|
||||
objects (dsh/lookup-page-objects state)
|
||||
shape (get objects id)
|
||||
new-shape? (contains? (:workspace-new-text-shapes state) id)
|
||||
prev-content (:content shape)
|
||||
has-prev-content? (not (nil? (:prev-content shape)))
|
||||
;; For existing shapes, capture geometry at session start once so
|
||||
;; finalize can build a single undo entry. Stored in workspace state,
|
||||
;; not in the shape, to avoid persisting session-only data.
|
||||
session-start-geom (or (get-in state [:workspace-text-session-geom id])
|
||||
(select-keys shape [:selrect :points :width :height]))
|
||||
content-has-text? (v2-content-has-text? content)
|
||||
prev-content-has-text? (v2-content-has-text? prev-content)
|
||||
;; Only measure/resize the shape on finalize. While the user is
|
||||
;; actively typing, the WASM editor already renders the growing text
|
||||
;; (and the editor overlay measures it live), so a per-keystroke
|
||||
;; resize is redundant and, going through the interactive-transform
|
||||
;; modifier machinery, made auto-width typing very laggy.
|
||||
new-size (when (and finalize? (not= :fixed (:grow-type shape)))
|
||||
(dwwt/get-wasm-text-new-size shape content))
|
||||
;; New shapes: single undo on finalize only (no per-keystroke undo)
|
||||
effective-save-undo? (if new-shape? finalize? save-undo?)
|
||||
effective-stack-undo? (and new-shape? finalize?)
|
||||
;; No save-undo on first update when finalizing: either build-finalize
|
||||
;; holds undo (non-new), or we delete empty text and only delete-shapes
|
||||
;; should record undo.
|
||||
finalize-save-undo-first?
|
||||
(if (and finalize? (or (not new-shape?) (not content-has-text?)))
|
||||
false
|
||||
effective-save-undo?)]
|
||||
|
||||
(rx/concat
|
||||
(rx/of
|
||||
;; Store session-start geometry in workspace state once for existing shapes
|
||||
(when (and (not new-shape?)
|
||||
(nil? (get-in state [:workspace-text-session-geom id])))
|
||||
(fn [s] (assoc-in s [:workspace-text-session-geom id] session-start-geom)))
|
||||
(dwsh/update-shapes
|
||||
[id]
|
||||
(fn [shape]
|
||||
(-> shape
|
||||
(assoc :content content)
|
||||
(cond-> (and (not new-shape?)
|
||||
content-has-text?
|
||||
has-prev-content?)
|
||||
(dissoc :prev-content))
|
||||
(rx/concat
|
||||
(rx/of
|
||||
;; Store session-start geometry in workspace state once for existing shapes
|
||||
(when (and (not new-shape?)
|
||||
(nil? (get-in state [:workspace-text-session-geom id])))
|
||||
(fn [s] (assoc-in s [:workspace-text-session-geom id] session-start-geom)))
|
||||
(dwsh/update-shapes
|
||||
[id]
|
||||
(fn [shape]
|
||||
(-> shape
|
||||
(assoc :content content)
|
||||
(cond-> (and (not new-shape?)
|
||||
content-has-text?
|
||||
has-prev-content?)
|
||||
(dissoc :prev-content))
|
||||
|
||||
(cond-> (and (not new-shape?)
|
||||
prev-content-has-text?
|
||||
(not content-has-text?)
|
||||
(not finalize?))
|
||||
(assoc :prev-content prev-content))
|
||||
(cond-> (and (not new-shape?)
|
||||
prev-content-has-text?
|
||||
(not content-has-text?)
|
||||
(not finalize?))
|
||||
(assoc :prev-content prev-content))
|
||||
|
||||
(cond-> (and update-name? (some? name))
|
||||
(assoc :name name))))
|
||||
{:save-undo? finalize-save-undo-first?
|
||||
:stack-undo? effective-stack-undo?
|
||||
:undo-group (when new-shape? id)})
|
||||
(cond-> (and update-name? (some? name))
|
||||
(assoc :name name))))
|
||||
{:save-undo? finalize-save-undo-first?
|
||||
:stack-undo? effective-stack-undo?
|
||||
:undo-group (when new-shape? id)})
|
||||
|
||||
;; `new-size` is only computed on finalize (see above), so this commits
|
||||
;; the final auto-width/auto-height geometry via `apply-wasm-modifiers`
|
||||
;; like other transform flows (flex parents, sidebar width, etc.).
|
||||
(when (some? new-size)
|
||||
(when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
|
||||
(dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)}))))
|
||||
;; `new-size` is only computed on finalize (see above), so this commits
|
||||
;; the final auto-width/auto-height geometry via `apply-wasm-modifiers`
|
||||
;; like other transform flows (flex parents, sidebar width, etc.).
|
||||
(when (some? new-size)
|
||||
(when-let [modifiers (dwwt/resize-wasm-text-modifiers shape content)]
|
||||
(dwm/apply-wasm-modifiers modifiers {:undo-group (when new-shape? id)}))))
|
||||
|
||||
(when finalize?
|
||||
(rx/concat
|
||||
(if (and (not content-has-text?) (some? id))
|
||||
(when finalize?
|
||||
(rx/concat
|
||||
(if (and (not content-has-text?) (some? id))
|
||||
(rx/concat
|
||||
(if (and (some? original-content) (v2-content-has-text? original-content))
|
||||
(rx/of
|
||||
(dwsh/update-shapes
|
||||
[id]
|
||||
(fn [s] (-> s (assoc :content original-content) (dissoc :prev-content)))
|
||||
{:save-undo? false}))
|
||||
(rx/empty))
|
||||
(rx/of (dws/deselect-shape id)
|
||||
(dwsh/delete-shapes #{id})))
|
||||
(rx/empty))
|
||||
(rx/concat
|
||||
(if (and (some? original-content) (v2-content-has-text? original-content))
|
||||
(if content-has-text?
|
||||
(rx/of
|
||||
(dwsh/update-shapes
|
||||
[id]
|
||||
(fn [s] (-> s (assoc :content original-content) (dissoc :prev-content)))
|
||||
{:save-undo? false}))
|
||||
(dch/commit-changes
|
||||
(build-finalize-commit-changes it state id
|
||||
{:new-shape? new-shape?
|
||||
:content-has-text? content-has-text?
|
||||
:content content
|
||||
:original-content original-content
|
||||
:update-name? update-name?
|
||||
:name name})))
|
||||
(rx/empty))
|
||||
(rx/of (dws/deselect-shape id)
|
||||
(dwsh/delete-shapes #{id})))
|
||||
(rx/empty))
|
||||
(rx/concat
|
||||
(if content-has-text?
|
||||
(rx/of
|
||||
(dch/commit-changes
|
||||
(build-finalize-commit-changes it state id
|
||||
{:new-shape? new-shape?
|
||||
:content-has-text? content-has-text?
|
||||
:content content
|
||||
:original-content original-content
|
||||
:update-name? update-name?
|
||||
:name name})))
|
||||
(rx/empty))
|
||||
(rx/of (dwt/finish-transform)
|
||||
(fn [state]
|
||||
(-> state
|
||||
(update :workspace-new-text-shapes disj id)
|
||||
(update :workspace-text-session-geom (fnil dissoc {}) id)))))))))
|
||||
(rx/of (dwt/finish-transform)
|
||||
(fn [state]
|
||||
(-> state
|
||||
(update :workspace-new-text-shapes disj id)
|
||||
(update :workspace-text-session-geom (fnil dissoc {}) id)))))))))
|
||||
|
||||
(let [modifiers (get-in state [:workspace-text-modifier id])
|
||||
new-shape? (contains? (:workspace-new-text-shapes state) id)]
|
||||
(rx/of
|
||||
(dwsh/update-shapes [id]
|
||||
(fn [shape]
|
||||
(let [{:keys [width height position-data]} modifiers]
|
||||
(-> shape
|
||||
(assoc :content content)
|
||||
(cond-> position-data
|
||||
(assoc :position-data position-data))
|
||||
(cond-> (and update-name? (some? name))
|
||||
(assoc :name name))
|
||||
(cond-> (or (some? width) (some? height))
|
||||
(gsh/transform-shape (ctm/change-size shape width height))))))
|
||||
{:undo-group (when new-shape? id)})))))))
|
||||
(let [modifiers (get-in state [:workspace-text-modifier id])
|
||||
new-shape? (contains? (:workspace-new-text-shapes state) id)]
|
||||
(rx/of
|
||||
(dwsh/update-shapes [id]
|
||||
(fn [shape]
|
||||
(let [{:keys [width height position-data]} modifiers]
|
||||
(-> shape
|
||||
(assoc :content content)
|
||||
(cond-> position-data
|
||||
(assoc :position-data position-data))
|
||||
(cond-> (and update-name? (some? name))
|
||||
(assoc :name name))
|
||||
(cond-> (or (some? width) (some? height))
|
||||
(gsh/transform-shape (ctm/change-size shape width height))))))
|
||||
{:undo-group (when new-shape? id)}))))))))
|
||||
|
||||
(defn replace-layer-names-in-shapes
|
||||
[ids search replacement]
|
||||
|
||||
@ -407,3 +407,51 @@
|
||||
result (vth/fix-position shape')]
|
||||
(t/is (some? result))
|
||||
(t/is (some? (:selrect result))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Tests: ensure-valid-text-content
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(t/deftest ensure-valid-text-content-empty-children-repaired
|
||||
(t/testing "root with empty :children vector is repaired to canonical tree"
|
||||
(let [broken {:type "root"
|
||||
:vertical-align "top"
|
||||
:children []}
|
||||
fixed (dwt/ensure-valid-text-content broken)]
|
||||
(t/is (= "root" (:type fixed)))
|
||||
(t/is (vector? (:children fixed)))
|
||||
(t/is (= 1 (count (:children fixed)))
|
||||
"exactly one paragraph-set is seeded")
|
||||
(t/is (= "paragraph-set" (get-in fixed [:children 0 :type])))
|
||||
(t/is (pos? (count (get-in fixed [:children 0 :children])))
|
||||
"paragraph-set has at least one paragraph")
|
||||
(t/is (= "" (get-in fixed [:children 0 :children 0 :children 0 :text]))
|
||||
"seeded span has empty text")
|
||||
(t/is (= "top" (:vertical-align fixed))
|
||||
"preserves the original :vertical-align"))))
|
||||
|
||||
(t/deftest ensure-valid-text-content-missing-children-repaired
|
||||
(t/testing "root with no :children key is repaired to canonical tree"
|
||||
(let [broken {:type "root" :vertical-align "center"}
|
||||
fixed (dwt/ensure-valid-text-content broken)]
|
||||
(t/is (vector? (:children fixed)))
|
||||
(t/is (pos? (count (:children fixed))))
|
||||
(t/is (= "center" (:vertical-align fixed))))))
|
||||
|
||||
(t/deftest ensure-valid-text-content-healthy-tree-unchanged
|
||||
(t/testing "a well-formed content is returned unchanged"
|
||||
(let [healthy {:type "root"
|
||||
:children [{:type "paragraph-set"
|
||||
:children [{:type "paragraph"
|
||||
:children [{:text "hello"}]}]}]}
|
||||
fixed (dwt/ensure-valid-text-content healthy)]
|
||||
(t/is (= healthy fixed)))))
|
||||
|
||||
(t/deftest ensure-valid-text-content-nil-unchanged
|
||||
(t/testing "nil content is returned unchanged (no repair)"
|
||||
(t/is (nil? (dwt/ensure-valid-text-content nil)))))
|
||||
|
||||
(t/deftest ensure-valid-text-content-non-root-unchanged
|
||||
(t/testing "a non-root content (e.g. paragraph) is left alone"
|
||||
(let [node {:type "paragraph" :children []}]
|
||||
(t/is (= node (dwt/ensure-valid-text-content node))))))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user