mirror of
https://github.com/penpot/penpot.git
synced 2026-08-06 12:58:55 +00:00
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
834 lines
42 KiB
Clojure
834 lines
42 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 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")))
|