✨ Test what copy texts keep through sync and switches

Add a battery of tests for text overrides in copies.
- A short notation for text content in tests (thtx): paragraphs of
  runs with short style names, read back with equal runs joined,
  edits in the notation, set-text and set-content through the change
  path, and the override rule for texts (override-units), which says
  which overrides a copy must hold against its main.
- Sync matrix: every whole-text copy edit against every main edit,
  plus the cards of the worked examples, the "Hello world" table, a
  token on a copy, typographies and a library color sync. Where the
  code differs from decisions (k) to (o), the result is pinned
  against F72 to F80.
- Switch matrix: copy edits against variants that differ in letters,
  size, color, a new line or a bolded word; F76, F77, F79 and F81
  pinned.
- Composable case BA: EditText also edits fills, font family and a
  whole text; the copy and the main change different styles, then
  reset or update main, in both runners; F72 pinned.
- Editor output: the content the WASM and v1 editors commit when a
  copy text is typed into, retyped, split with Enter and has a word
  bolded, recorded in the workspace and replayed on a copy. Typing
  sets only the text groups and a style change no text override;
  F79 pinned on both editors' content.
This commit is contained in:
alonso.torres 2026-10-13 19:56:33 +02:00
parent d4ac987705
commit 6c1afe825f
14 changed files with 1734 additions and 23 deletions

View File

@ -160,9 +160,13 @@ Two pure runners (no store, JVM + CLJS):
- Case assertions a known bug breaks use `tm/known-failure bug expected pinned actual`: a
strict expected failure at assertion level (passes only while `actual` is the pinned wrong
value). The undo-check marks cover only round-trip failures.
- Text: `n/edit-text` (`:text`/`:font-size` of the first span) with `n/text-edit-commit`, which
includes the editor's automatic rename when the name was generated from the old content;
setup `simple-text-component-with-copy` (text named after its content).
- Text: `n/edit-text` with `n/text-edit-commit`, which includes the editor's automatic rename
when the name was generated from the old content. Edits: `:text`/`:font-size` of the first
span, `:fills`/`:font-family` of all of the text, `:content` (the whole text in the
notation of `app.common.test-helpers.texts`, alias `thtx`; read back with
`(n/text-span-value shape :content)`). Setups `simple-text-component-with-copy` (text named
after its content) and `two-style-text-component-with-copy` ("Price: " + bold "$10",
written as the editors write it).
- The interpreter unwraps nested choices (`(tm/optional (tm/one-of …))`); `(optional
(in-sequence …))` is still not flattened.
- Depth sweeps: `depth-sweep` in `cases.cljc`, a one-of over skip,
@ -203,7 +207,7 @@ Two pure runners (no store, JVM + CLJS):
interpreter): the path is cleaned and a main root's component is renamed too. Case letters B..Z, then AA..;
the sweeps (K: depth × edit-precedence; L: swaps; M: variant switches; N: rotated-instance
geometry, on the #10109 fix branch until merged; O: switch of an overridden copy, mains
agreeing or not; Q: detach at depth 0..2, nested copies stay linked; R: update-main at depth 0..2 with two copies; S: delete/restore with a copy on another page; T: duplicate a copy at depth 0..2; U: move a copy into a main with a copy; V: new edit after undo empties redo; W: `(n/undo 3)` over nested cascades; X: undo before the edit's sync, frontend only; AJ: F43, one undo after a nested sync reverts only the reflow, frontend only; Y: text override × main text change, per subgroup; Z: main child move, frontend only; AA..AE: swap outcomes asserted on values, not slot storage, so they survive the Phase 2b swap model; AF: grid cell move vs a swapped copy child, setup `grid-component-with-copy`; AG/AH/AI: ports of the plugin's slot-alignment cases, setup `component-with-sibling-instances`, check `setup/misaligned-sub-heads`; AK: update main into a grid main, F49 pinned; AL: update main renames the component (`change-property` takes `:name`); AM: update main of a swap inside a group, setup `grouped-component-with-copy`, F16 pinned; AN: flex child margin override through main edit, reset and update main, setup `flex-component-with-copy`; `change-property` takes `:margin`, which the interpreter sends as `dwsl/update-layout-child`; AO: a copy dropped into a grid cell of a main (`nest-copy` with `:cell`, sent as `dwt/move-shapes-to-frame`), F41 pinned; AP: a library main's middle child removal reaches a copy in another file (in one file, deleting a main child deletes the copies' children itself, so sync never meets the copy child), setup `cross-file-component-with-many-children`; AQ: main change reaches a copy nested in a sub-board, setup `component-nested-in-a-sub-board`; AR: variant property edits keep a member's copies as they are, then a switch finds its member by the edited properties; AS: an added variant joins the set and is a switch target; AT..AX, frontend only: transform into a variant, combine, rename a variant (F6 pinned: the transaction keeps the container commit's undo group, the copies' sync takes the main renames'), rename in layers + remove empty properties + member error, the shortcuts that add members; AY: a border radius token on a main reaches untouched copies and its removal too, F55 pinned; AZ: a nested copy pasted out of its main keeps the main's overrides) are the flagship pattern — read them before
agreeing or not; Q: detach at depth 0..2, nested copies stay linked; R: update-main at depth 0..2 with two copies; S: delete/restore with a copy on another page; T: duplicate a copy at depth 0..2; U: move a copy into a main with a copy; V: new edit after undo empties redo; W: `(n/undo 3)` over nested cascades; X: undo before the edit's sync, frontend only; AJ: F43, one undo after a nested sync reverts only the reflow, frontend only; Y: text override × main text change, per subgroup; Z: main child move, frontend only; AA..AE: swap outcomes asserted on values, not slot storage, so they survive the Phase 2b swap model; AF: grid cell move vs a swapped copy child, setup `grid-component-with-copy`; AG/AH/AI: ports of the plugin's slot-alignment cases, setup `component-with-sibling-instances`, check `setup/misaligned-sub-heads`; AK: update main into a grid main, F49 pinned; AL: update main renames the component (`change-property` takes `:name`); AM: update main of a swap inside a group, setup `grouped-component-with-copy`, F16 pinned; AN: flex child margin override through main edit, reset and update main, setup `flex-component-with-copy`; `change-property` takes `:margin`, which the interpreter sends as `dwsl/update-layout-child`; AO: a copy dropped into a grid cell of a main (`nest-copy` with `:cell`, sent as `dwt/move-shapes-to-frame`), F41 pinned; AP: a library main's middle child removal reaches a copy in another file (in one file, deleting a main child deletes the copies' children itself, so sync never meets the copy child), setup `cross-file-component-with-many-children`; AQ: main change reaches a copy nested in a sub-board, setup `component-nested-in-a-sub-board`; AR: variant property edits keep a member's copies as they are, then a switch finds its member by the edited properties; AS: an added variant joins the set and is a switch target; AT..AX, frontend only: transform into a variant, combine, rename a variant (F6 pinned: the transaction keeps the container commit's undo group, the copies' sync takes the main renames'), rename in layers + remove empty properties + member error, the shortcuts that add members; AY: a border radius token on a main reaches untouched copies and its removal too, F55 pinned; AZ: a nested copy pasted out of its main keeps the main's overrides; BA: a copy text's style override against a main style change, then reset or update main, F72 pinned) are the flagship pattern — read them before
writing a new sweep.
**Scenario lineage model** (behind the sweeps): scenario ops track named component lineages as

View File

@ -80,6 +80,7 @@
[app.common.test-helpers.composable.comp.setups :as setup]
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.texts :as thtx]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
[app.common.types.shape-tree :as ctst]
@ -694,6 +695,70 @@
(t/is (= #{:content-group (subgroup (:edit edited))}
(:touched copy))))))}))
(defn- text-restyle
"The restyle of a text in the notation that the `:fills` or `:font-family`
text edit `node` makes."
[node]
(let [{:keys [edit value]} node]
(case edit
:fills #(thtx/restyle % :color (some (fn [[k v]] (when (= v (:fill-color (first value))) k))
thtx/colors))
:font-family #(thtx/restyle % :family value))))
(defn text-style-override-against-main-style-change
"Case BA. A main text in two styles (\"Price: \" and a bold \"$10\") and
its copy. The copy changes the fill or the font family of all of its text,
then the main changes one of them and syncs, then optionally the copy is
reset or updates the main. Each style is an override of its own (decision
(k)): after the sync the copy keeps its style and takes the main's other
one. A reset makes the copy equal to the main and clears its overrides.
An update main gives the main the copy's style, keeps the main's other
one, and clears the copy's overrides."
[]
(let [copy-edit (tm/one-of [(n/edit-text :copy-instance :fills [{:fill-color "#FF0000" :fill-opacity 1}])
(n/edit-text :copy-instance :font-family "Roboto")])
main-edit (tm/one-of [(n/edit-text :main-instance :fills [{:fill-color "#0000FF" :fill-opacity 1}])
(n/edit-text :main-instance :font-family "Inter")])
reset (n/reset :copy-root)
push (n/update-main :copy-root)]
{:setup setup/two-style-text-component-with-copy
:operation (tm/in-sequence [copy-edit main-edit (tm/optional (tm/one-of [reset push]))])
:asserter (fn [s]
(let [copy-node (tm/get-choice s copy-edit)
main-node (tm/get-choice s main-edit)
same? (= (:edit copy-node) (:edit main-node))
main-text ((text-restyle main-node) setup/two-style-text)
merged ((text-restyle copy-node) main-text)
as-it-was ((text-restyle copy-node) setup/two-style-text)
copy (setup/copy-instance s)
main (setup/main-instance s)
text-of #(n/text-span-value % :content)]
(cond
(tm/applied? s reset)
(do
(t/is (= (thtx/normalize main-text) (text-of copy)))
(t/is (= (thtx/normalize main-text) (text-of main)))
(t/is (empty? (:touched copy))))
(tm/applied? s push)
(do
(if same?
(t/is (= (thtx/normalize merged) (text-of main)))
;; the copy pushes all of its styles, since all
;; of them stayed as the copy left them
(tm/known-failure "F72" (thtx/normalize merged) (thtx/normalize as-it-was)
(text-of main)))
(t/is (= (text-of main) (text-of copy)))
(t/is (empty? (:touched copy))))
:else
(do
(t/is (= (thtx/normalize main-text) (text-of main)))
(if same?
(t/is (= (thtx/normalize merged) (text-of copy)))
(tm/known-failure "F72" (thtx/normalize merged) (thtx/normalize as-it-was)
(text-of copy)))))))}))
;; ---------------------------------------------------------------------------
;; Swap outcomes
;;

View File

@ -29,6 +29,7 @@
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.texts :as thtx]
[app.common.test-helpers.tokens :as tht]
[app.common.types.component :as ctk]
[app.common.types.components-list :as ctkl]
@ -168,32 +169,62 @@
;; ---------------------------------------------------------------------------
;; edit-text — edit the content of a text shape
;;
;; An edit of the characters (`:text`) or font size (`:font-size`) of a text's
;; first span, as the text editor commits it when the edit ends: a new
;; An edit of a text as the text editor commits it when the edit ends: a new
;; `:content`, and a new name when the text still has the name generated from
;; its old content. The frontend interpreter commits it through
;; its old content. The edit is one of
;; :text the characters of the first span;
;; :font-size the font size of the first span;
;; :fills the fills of all of the text (select all, then a color);
;; :font-family the font family of all of the text;
;; :content the whole text, written in the notation of
;; `app.common.test-helpers.texts`.
;; The frontend interpreter commits it through
;; `v2-update-text-shape-content`, as the editor does on blur.
;; ---------------------------------------------------------------------------
(def ^:private first-span [:content :children 0 :children 0 :children 0])
(def ^:private text-edits #{:text :font-size :fills :font-family :content})
(defn- generated-name
"The name the text editor gives a text after its `content`."
[content]
(txt/generate-shape-name (txt/content->text content)))
(defn- restyle-all
[content attrs]
(txt/transform-nodes (some-fn txt/is-text-node? txt/is-paragraph-node?) #(merge % attrs) content))
(defn text-edit-commit
"What the text editor commits when the `edit` (`:text` or `:font-size`) of
the first span of `shape` becomes `value`: `:content`, and `:name` when the
editor renames the shape after its text, which it does when the shape still
has the name generated from its old content. Public so the frontend
interpreter commits the same edit."
"What the text editor commits when the `edit` (see `text-edits`) of
`shape` becomes `value`: `:content`, and `:name` when the editor renames
the shape after its text, which it does when the shape still has the name
generated from its old content. Public so the frontend interpreter commits
the same edit."
[shape edit value]
(when-not (contains? #{:text :font-size} edit)
(when-not (contains? text-edits edit)
(throw (ex-info (str "text-edit-commit: unsupported edit " (pr-str edit)
" (supported: :text, :font-size)")
" (supported: " (pr-str text-edits) ")")
{:edit edit})))
(let [content (assoc-in (:content shape) (conj (subvec first-span 1) edit) value)]
(let [content (case edit
(:text :font-size)
(assoc-in (:content shape) (conj (subvec first-span 1) edit) value)
:fills
(restyle-all (:content shape) {:fills value})
:font-family
(txt/transform-nodes (some-fn txt/is-text-node? txt/is-paragraph-node?)
#(assoc %
:font-family value
:font-id (get thtx/font-ids value)
:font-variant-id (thtx/variant-id value
(d/parse-integer (:font-weight %))
(= "italic" (:font-style %))))
(:content shape))
:content
(thtx/content value))]
(cond-> {:content content}
(= (:name shape) (generated-name (:content shape)))
(assoc :name (generated-name content)))))
@ -204,9 +235,12 @@
(merge shape (text-edit-commit shape edit value)))
(defn text-span-value
"The `edit` (`:text` or `:font-size`) of `shape`'s first span."
"The `edit` (see `text-edits`) of `shape`: of its first span, or for
`:content` its whole text in the notation of `app.common.test-helpers.texts`."
[shape edit]
(get-in shape (conj first-span edit)))
(if (= edit :content)
(thtx/view (:content shape))
(get-in shape (conj first-span edit))))
(defrecord EditText [target edit value]
IComponentOperation
@ -229,9 +263,9 @@
(tm/record-application this {:target target :edit edit :value value})))))
(defn edit-text
"Constructor for the text edit operation: set the `edit` (`:text` or
`:font-size`) of the first span of the text bound to `target` to `value`.
On the frontend it dispatches the real `v2-update-text-shape-content`."
"Constructor for the text edit operation: set the `edit` (see
`text-edits`) of the text bound to `target` to `value`. On the frontend it
dispatches the real `v2-update-text-shape-content`."
[target edit value]
(tm/assign-id (->EditText target edit value)))
@ -345,9 +379,11 @@
(extend-type EditText
IPropertyCheck
(applied-property [node] (:edit node))
(applied-value [node] (:value node))
(applied-value [node]
(cond-> (:value node)
(= :content (:edit node)) (thtx/normalize)))
(has-property-of [node shape]
(= (:value node) (text-span-value shape (:edit node)))))
(= (applied-value node) (text-span-value shape (:edit node)))))
;; ===========================================================================
;; Scenario building blocks

View File

@ -19,7 +19,8 @@
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]))
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.texts :as thtx]))
;; The fill the labeled setups give the main child.
(def labeled-fill "#abcdef")
@ -122,6 +123,28 @@
:main-root :main-root
:copy-root :copy-root})))
(def two-style-text
"The text of `two-style-text-component-with-copy`, in the notation of
`app.common.test-helpers.texts`."
[[["Price: "] ["$10" {:weight 700}]]])
(defn two-style-text-component-with-copy
"As `simple-text-component-with-copy`, with the text \"Price: $10\" in two
styles (`two-style-text`), written as the editors write it."
[]
(let [file (-> (thf/sample-file :file1)
(tho/add-frame-with-text :main-root :main-text "Price: $10"
:frame-params {:width 100 :height 100}
:child-params {:name "Price: $10"
:content (thtx/content two-style-text)})
(thc/make-component :component1 :main-root)
(thc/instantiate-component :component1 :copy-root
:children-labels [:copy-text]))]
(tm/make-situation file {:main-instance :main-text
:copy-instance :copy-text
:main-root :main-root
:copy-root :copy-root})))
(defn- label-copy-children!
"Register each label of `labels` (main child label -> copy child label) on
the child of the copy `copy-label` that references that main child."

View File

@ -0,0 +1,542 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns app.common.test-helpers.texts
"Text content in a short notation, for tests of text overrides in copies.
A text is a vector of paragraphs, optionally led by a map of root styles
(`{:valign \"bottom\"}`). A paragraph is a vector of runs, optionally led
by a map of paragraph styles (`:align`, `:direction`, `:line-height`). A
run is `[letters]` or `[letters styles]`, where styles use the short names
below and list only what differs from `base-style`:
[[[\"Price: \"] [\"$10\" {:weight 700}]]]
[[{:align \"center\"} [\"Free shipping\" {:size 18}]]]
Run styles: `:family` (a key of `font-ids`), `:weight`, `:italic`, `:size`,
`:spacing` (letter spacing), `:case` (text transform), `:decoration`,
`:color` (a key of `colors`) and `:typography` (the label of a typography
of the file `:file1`). Any text attribute can also be given by its own
key, for values the short names cannot say.
`content` builds what the DOM editor writes (`from_dom`): every run
carries every run style, fills included, and every paragraph carries its
paragraph styles plus the styles of its first run. What the WASM and v1
editors write is in `text-editor-output-test`. `view` reads content back into the
notation and joins adjacent runs with equal styles, so tests compare what
a text shows, not how it is split into nodes."
(:require
[app.common.data :as d]
[app.common.files.changes-builder :as pcb]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.types.text :as txt]
[clojure.string :as str]))
(def font-ids
"Font family name -> font id, for the families the notation knows."
{"sourcesanspro" "sourcesanspro"
"Inter" "gfont-inter"
"Roboto" "gfont-roboto"})
(def colors
{:black "#000000"
:red "#FF0000"
:blue "#0000FF"
:purple "#7B3FD1"})
(def base-style
"The run styles of a text with no styles given."
{:font-id "sourcesanspro"
:font-family "sourcesanspro"
:font-variant-id "regular"
:font-size "14"
:font-weight "400"
:font-style "normal"
:letter-spacing "0"
:text-transform "none"
:text-decoration "none"
:fills [{:fill-color "#000000" :fill-opacity 1}]})
(def base-paragraph-style
{:text-align "left"
:text-direction "ltr"
:line-height "1.2"})
(def base-root-style
{:vertical-align "top"})
(def ^:private named-weights
"Weight -> the name the variant ids of `sourcesanspro` give it."
{400 "regular" 700 "bold" 900 "black"})
(defn variant-id
"The id of the variant of `family` with `weight` (a number), italic or not."
[family weight italic?]
(let [weight (if (or (= weight 400) (= family "sourcesanspro"))
(get named-weights weight (str weight))
(str weight))]
(cond
(and (= weight "regular") italic?) "italic"
italic? (str weight "italic")
:else weight)))
(defn- ->number
[s]
(cond
(number? s) s
(re-matches #"-?\d+" s) (d/parse-integer s)
:else (d/parse-double s)))
(defn- raw-attrs
"The keys of `style` that are text attributes given by their own name.
`:line-height` is a short name too, and is read as one."
[style]
(select-keys style (disj txt/text-all-attrs :line-height)))
(defn- run-attrs
[{:keys [family weight italic size spacing case decoration color typography] :as style}]
(let [weight (or weight 400)]
(cond-> (assoc base-style
:font-weight (str weight)
:font-style (if italic "italic" "normal")
:font-variant-id (variant-id (or family "sourcesanspro") weight italic))
family (assoc :font-family family :font-id (get font-ids family))
size (assoc :font-size (str size))
spacing (assoc :letter-spacing (str spacing))
case (assoc :text-transform case)
decoration (assoc :text-decoration decoration)
color (assoc :fills [{:fill-color (get colors color) :fill-opacity 1}])
typography (assoc :typography-ref-id (thi/id typography)
:typography-ref-file (thi/id :file1))
:always (merge (raw-attrs style)))))
(defn- paragraph-attrs
[{:keys [align direction line-height] :as style}]
(cond-> base-paragraph-style
align (assoc :text-align align)
direction (assoc :text-direction direction)
line-height (assoc :line-height (str line-height))
:always (merge (raw-attrs style))))
(defn- split-styles
"Split a vector into its leading styles map (or nil) and the rest."
[v]
(if (map? (first v))
[(first v) (rest v)]
[nil v]))
(defn content
"Build the text content written as `text` in the notation."
[text]
(let [[root-style paragraphs] (split-styles text)
paragraph (fn [p]
(let [[style runs] (split-styles p)
spans (mapv (fn [[letters style]]
(assoc (run-attrs style) :text letters))
runs)]
(merge (dissoc (first spans) :text)
(paragraph-attrs style)
{:type "paragraph" :children spans})))]
{:type "root"
:vertical-align (:valign root-style "top")
:children [{:type "paragraph-set"
:children (mapv paragraph paragraphs)}]}))
(defn- style-diff
"The short-name styles of `node` that differ from `base-style`. A value the
short names cannot say shows under its own attribute."
[node]
(let [{:keys [font-id font-family font-variant-id font-size font-weight font-style
letter-spacing text-transform text-decoration fills
typography-ref-id typography-ref-file]} node
weight (->number font-weight)
italic? (= font-style "italic")
color (when (and (= 1 (count fills))
(= 1 (:fill-opacity (first fills)))
(= #{:fill-color :fill-opacity} (set (keys (first fills)))))
(some (fn [[k v]] (when (= v (:fill-color (first fills))) k)) colors))
;; spans written by `txt/change-text` also carry the paragraph styles
node (cond-> node
(= (:text-align node) "left") (dissoc :text-align)
(= (:text-direction node) "ltr") (dissoc :text-direction))
known #{:text :type :key :children :line-height
:font-id :font-family :font-variant-id :font-size :font-weight :font-style
:letter-spacing :text-transform :text-decoration :fills
:typography-ref-id :typography-ref-file}]
(cond-> {}
(not= font-family "sourcesanspro")
(assoc :family font-family)
(not= font-id (get font-ids font-family))
(assoc :font-id font-id)
(not= weight 400)
(assoc :weight weight)
italic?
(assoc :italic true)
(not (#{"normal" "italic"} font-style))
(assoc :font-style font-style)
(not= font-variant-id (variant-id font-family weight italic?))
(assoc :font-variant-id font-variant-id)
(not= font-size "14")
(assoc :size (->number font-size))
(not= letter-spacing "0")
(assoc :spacing (->number letter-spacing))
(not= text-transform "none")
(assoc :case text-transform)
(not= text-decoration "none")
(assoc :decoration text-decoration)
(and (some? color) (not= color :black))
(assoc :color color)
(nil? color)
(assoc :fills fills)
(some? typography-ref-id)
(assoc :typography (thi/label typography-ref-id))
(and (some? typography-ref-id) (not= typography-ref-file (thi/id :file1)))
(assoc :typography-ref-file typography-ref-file)
:always
(merge (apply dissoc node known)))))
(defn- paragraph-diff
"The paragraph styles of `paragraph` in the notation. A paragraph without
`:line-height` (as the WASM editor writes them) takes its first run's."
[paragraph]
(let [{:keys [text-align text-direction]} paragraph
line-height (or (:line-height paragraph)
(:line-height (first (:children paragraph))))]
(cond-> {}
(not= text-align "left") (assoc :align text-align)
(not= text-direction "ltr") (assoc :direction text-direction)
(not= line-height "1.2") (assoc :line-height (->number line-height)))))
(defn- join-runs
[runs]
(reduce (fn [acc [letters style :as run]]
(let [[prev-letters prev-style] (peek acc)]
(if (and (some? prev-letters) (= prev-style style))
(conj (pop acc) [(str prev-letters letters) style])
(conj acc run))))
[]
runs))
(defn- view-run
[[letters style]]
(if (empty? style) [letters] [letters style]))
(defn view
"Read `content` back into the notation."
[content]
(let [root-diff (when (not= (:vertical-align content "top") "top")
{:valign (:vertical-align content)})
paragraph (fn [p]
(let [runs (->> (:children p)
(map (fn [node] [(:text node) (style-diff node)]))
(join-runs))
runs (if (> (count runs) 1)
(remove (comp empty? first) runs)
runs)
style (paragraph-diff p)]
(cond->> (mapv view-run runs)
(seq style) (into [style]))))
paragraphs (mapv paragraph (txt/node-seq txt/is-paragraph-node? content))]
(cond->> paragraphs
(some? root-diff) (into [root-diff]))))
(defn normalize
"`text` as `view` gives it back once built: equal styles joined, base
styles left out."
[text]
(view (content text)))
(defn text-of
"The text of the shape `label` in `file`, in the notation."
[file label]
(view (:content (ths/get-shape file label))))
(defn set-content
"Replace the content of the text shape `label` with `content`, through the
production change path, as an editor commit does."
[file label content]
(let [page (thf/current-page file)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id label)}
#(assoc % :content content)
(:objects page)
{})]
(thf/apply-changes file changes)))
(defn set-text
"Replace the content of the text shape `label` with `text` in the notation,
as `set-content` does."
[file label text]
(set-content file label (content text)))
(defn add-text-component-with-copy
"Add the component :component1, whose main :main-root holds the text
:main-child written as `text`, and its copy :copy-root, whose text is
:copy-child."
[file text]
(-> file
(tho/add-frame-with-text :main-root :main-child ""
:child-params {:content (content text)})
(thc/make-component :component1 :main-root)
(thc/instantiate-component :component1 :copy-root {:children-labels [:copy-child]})))
;; ---- Edits of a text in the notation
(def typography-styles
"The short-name styles a typography sets: on runs, and `:line-height` on
paragraphs."
#{:family :weight :italic :size :spacing :case :line-height})
(defn- update-paragraphs
"Apply `f` to each paragraph of `text` as `[styles runs]`; `f` returns the
same pair."
[text f]
(let [[root-style paragraphs] (split-styles text)
paragraphs (mapv (fn [p]
(let [[style runs] (f (split-styles p))]
(cond->> (vec runs)
(seq style) (into [style]))))
paragraphs)]
(cond->> paragraphs
(seq root-style) (into [root-style]))))
(defn- detach-typography
"Drop the typography of runs when `k` is a style the typography sets, as
the design panel does before changing it."
[runs k]
(if (contains? typography-styles k)
(map (fn [[letters style]] [letters (dissoc style :typography)]) runs)
runs))
(defn restyle
"Set the run style `k` to `v` on every run of `text`."
[text k v]
(update-paragraphs text
(fn [[style runs]]
[style (map (fn [[letters run-style]] [letters (assoc run-style k v)])
(detach-typography runs k))])))
(defn restyle-paragraphs
"Set the paragraph style `k` to `v` on every paragraph of `text`."
[text k v]
(update-paragraphs text
(fn [[style runs]]
[(assoc style k v) (detach-typography runs k)])))
(defn set-valign
[text v]
(let [[root-style paragraphs] (split-styles text)]
(into [(assoc root-style :valign v)] paragraphs)))
(defn set-letters
"Replace the letters of `text` with `letters`, a line break per paragraph,
all in the styles of its first paragraph and first run."
[text letters]
(let [[root-style paragraphs] (split-styles text)
[p-style runs] (split-styles (first paragraphs))
[_ run-style] (first runs)
paragraphs (mapv (fn [line]
(cond->> [[line run-style]]
(seq p-style) (into [p-style])))
(str/split letters #"\n" -1))]
(cond->> paragraphs
(seq root-style) (into [root-style]))))
(defn apply-typography
"Apply the typography `label`, whose styles are `styles` (see
`typography-styles`), to all of `text`."
[text label styles]
(update-paragraphs text
(fn [[style runs]]
[(cond-> style
(contains? styles :line-height) (assoc :line-height (:line-height styles)))
(map (fn [[letters run-style]]
[letters (merge run-style
(dissoc styles :line-height)
{:typography label})])
runs)])))
;; ---- The override rule for texts
;;
;; Which overrides a copy text must hold, given its content and its near
;; main's: "different means touched" (`mem:common/component-sync-contract`)
;; for the text model of decisions (k)-(o). The letters (with line breaks)
;; are compared whole; each style unit is compared per letter, against the
;; main's style placed onto the copy's letters as sync places it:
;; 1. a unit with one value across the main applies to every letter;
;; 2. a letter both texts share, in a stretch of at least
;; `min-shared-stretch` equal letters found by a letter diff, takes the
;; main's value on that letter;
;; 3. any other letter takes the value of the letter before it, across line
;; breaks; letters at the start take the first shared letter after them;
;; with nothing shared, the main's first letter.
;; Paragraph units go by paragraph: a paragraph takes the unit of the main's
;; paragraph that holds its first shared letter, else follows the paragraph
;; before it. Vertical alignment compares the roots.
(def min-shared-stretch 3)
(def run-units
"Style unit -> the run attributes it covers."
{:family [:font-family :font-id]
:weight [:font-weight :font-style :font-variant-id]
:size [:font-size]
:spacing [:letter-spacing]
:decoration [:text-decoration]
:case [:text-transform]
:fills [:fills]
:typography [:typography-ref-id :typography-ref-file]})
(def paragraph-units
{:align [:text-align]
:direction [:text-direction]
:line-height [:line-height]})
(defn- letters-of
"The letters of `content`, one entry per letter with its paragraph index
and run node; line breaks are `\\n` entries with no run."
[content]
(let [paragraphs (vec (txt/node-seq txt/is-paragraph-node? content))]
(vec
(mapcat (fn [i p]
(concat (when (pos? i) [{:letter \newline :paragraph i}])
(for [node (:children p)
c (:text node)]
{:letter c :paragraph i :node node})))
(range)
paragraphs))))
(defn- lcs-pairs
"The pairs [i j] of equal letters of `a` and `b` along a longest common
subsequence, in order."
[a b]
(let [n (count a) m (count b)
table (reduce (fn [t [i j]]
(assoc t [i j]
(if (= (nth a i) (nth b j))
(inc (get t [(inc i) (inc j)] 0))
(max (get t [(inc i) j] 0) (get t [i (inc j)] 0)))))
{}
(for [i (range (dec n) -1 -1) j (range (dec m) -1 -1)] [i j]))]
(loop [i 0 j 0 acc []]
(cond
(or (>= i n) (>= j m)) acc
(= (nth a i) (nth b j)) (recur (inc i) (inc j) (conj acc [i j]))
(>= (get table [(inc i) j] 0) (get table [i (inc j)] 0)) (recur (inc i) j acc)
:else (recur i (inc j) acc)))))
(defn shared-letters
"Index in `b` -> index in `a` of the letters the strings share, in
stretches of at least `min-shared-stretch` consecutive letters."
[a b]
(let [stretches (->> (lcs-pairs (vec a) (vec b))
(reduce (fn [acc [i j :as pair]]
(let [[pi pj] (peek (peek acc))]
(if (and pi (= i (inc pi)) (= j (inc pj)))
(conj (pop acc) (conj (peek acc) pair))
(conj acc [pair]))))
[]))]
(into {}
(comp (filter #(>= (count %) min-shared-stretch))
cat
(map (fn [[i j]] [j i])))
stretches)))
(defn- unit-value
[node attrs]
(let [v (select-keys node attrs)]
;; a default the editors leave out is the same as the default written
(cond-> v
(and (contains? (set attrs) :fills) (nil? (:fills v))) (assoc :fills nil))))
(defn- place-values
"The values of the target's letters, from `source-values` (one per source
letter, nil for line breaks) by rules 1-3, given `shared` (target index ->
source index) and the target's line breaks `breaks?` (per letter)."
[source-values shared breaks?]
(let [values (remove nil? source-values)
n (count breaks?)]
(if (<= (count (distinct values)) 1)
(mapv (fn [b] (when-not b (first values))) breaks?)
(let [direct (mapv (fn [i] (when-not (nth breaks? i)
(some->> (get shared i) (nth source-values))))
(range n))
first-shared (some (fn [i] (when (some? (nth direct i)) (nth direct i))) (range n))
fallback (or first-shared (first values))]
(first
(reduce (fn [[acc prev] i]
(cond
(nth breaks? i) [(conj acc nil) prev]
(some? (nth direct i)) [(conj acc (nth direct i)) (nth direct i)]
:else (let [v (or prev fallback)] [(conj acc v) v])))
[[] nil]
(range n)))))))
(defn override-units
"The overrides a copy whose content is `copy` must hold against its near
main's content `main`: `:letters`, `:valign`, and the keys of `run-units`
and `paragraph-units` whose values differ from the main's placed onto the
copy's letters."
[main copy]
(let [ml (letters-of main)
cl (letters-of copy)
shared (shared-letters (map :letter ml) (map :letter cl))
breaks? (mapv #(nil? (:node %)) cl)
main-ps (vec (txt/node-seq txt/is-paragraph-node? main))
copy-ps (vec (txt/node-seq txt/is-paragraph-node? copy))
run-diff (for [[unit attrs] run-units
:let [source (mapv #(some-> (:node %) (unit-value attrs)) ml)
placed (place-values source shared breaks?)
actual (mapv #(some-> (:node %) (unit-value attrs)) cl)]
:when (not= placed actual)]
unit)
;; a paragraph's first shared letter names the main paragraph it
;; follows
para-of (fn [i]
(some (fn [[k {:keys [paragraph]}]]
(when (= paragraph i)
(some->> (get shared k) (nth ml) :paragraph)))
(map-indexed vector cl)))
para-diff (for [[unit attrs] paragraph-units
:let [values (mapv #(unit-value % attrs) main-ps)
placed (if (<= (count (distinct values)) 1)
(vec (repeat (count copy-ps) (first values)))
(first
(reduce (fn [[acc prev] i]
(let [v (or (some->> (para-of i) (nth values))
prev
(first values))]
[(conj acc v) v]))
[[] nil]
(range (count copy-ps)))))
actual (mapv #(unit-value % attrs) copy-ps)]
:when (not= placed actual)]
unit)]
(cond-> (into #{} (concat run-diff para-diff))
(not= (txt/content->text main) (txt/content->text copy))
(conj :letters)
(not= (:vertical-align main "top") (:vertical-align copy "top"))
(conj :valign))))

View File

@ -101,6 +101,9 @@
(t/deftest case-y-text-override-merges-with-main-text-change
(check (cases/text-override-merges-with-main-text-change)))
(t/deftest case-ba-text-style-override-against-main-style-change
(check (cases/text-style-override-against-main-style-change)))
(t/deftest case-aa-copy-swap-survives-main-changes
(check (cases/copy-swap-survives-main-changes)))

View File

@ -145,3 +145,10 @@
(sync-file :components (thi/id :component1)))]
;; the touched text attributes block it
(tm/known-failure "F3" "40" "24" (font-size-of (ths/get-shape file' :copy-child)))))
(t/deftest library-typography-sync-writes-only-text-styles
;; the sync merges the whole typography into each node, its own
;; :modified-at included
(let [file' (change-library-typography (component-using-typography) "24")
node (get-in (ths/get-shape file' :main-child) [:content :children 0 :children 0 :children 0])]
(tm/known-failure "F80" false true (contains? node :modified-at))))

View File

@ -0,0 +1,175 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.text-editor-output-test
"What the text editors commit when a copy text is edited, recorded in the
workspace: the WASM editor (WebGL renderer) and the v1 editor (SVG
renderer). The DOM editor is only on together with the WASM one, which
the viewport prefers, so no session runs it. Each fixture is the
`:content` of one commit, built key for key as the editor wrote it.
Replaying the commits on a copy gives the touched groups the workspace
showed, less `:name-group`, which the automatic rename of the frontend
adds (F27)."
(:require
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.texts :as thtx]
[app.common.types.text :as txt]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
;; ---- Fixtures
(def ^:private run-style
"The run styles both editors write for a text with no styles given."
{:line-height "1.2" :font-style "normal" :typography-ref-id nil :text-transform "none"
:font-id "sourcesanspro" :font-size "14" :font-weight "400" :typography-ref-file nil
:font-variant-id "regular" :text-decoration "none" :letter-spacing "0"
:fills [{:fill-color "#000000" :fill-opacity 1}] :font-family "sourcesanspro"})
(def ^:private bold {:font-weight "700" :font-variant-id "bold"})
(def ^:private direction {:text-align "left" :text-direction "ltr"})
(defn- run
[text & [style]]
(merge run-style style {:text text}))
(defn- paragraph
"A paragraph as the WASM editor writes it: direction only, the line height
stays on the runs."
[& runs]
(assoc direction :type "paragraph" :children (vec runs)))
(defn- keyed
"`paragraph` once the v1 editor has opened it."
[paragraph key]
(assoc paragraph :key key))
(defn- styled-paragraph
"A paragraph as the v1 editor writes it when it restyles or adds one: the
styles of its first run on the paragraph too."
[key & runs]
(merge run-style direction {:key key :type "paragraph" :children (vec runs)}))
(defn- text
[& paragraphs]
{:type "root"
:children [{:type "paragraph-set" :children (vec paragraphs)}]
:vertical-align "top"})
(def ^:private main-text
"\"Hello world\" as the WASM editor writes a new text: the main of every copy below."
(text (paragraph (run "Hello world"))))
(def ^:private v1-new-text
"\"Hello world\" as the v1 editor writes a new text."
{:type "root"
:children [{:type "paragraph-set"
:children [(merge run-style direction
{:key "7aab0" :type "paragraph"
:children [(merge (dissoc run-style :typography-ref-id :typography-ref-file)
direction
{:text "Hello world"})]})]}]})
(defn- keystrokes
"The text after each key typed of `typed`, `f` building it from the
letters typed so far: the WASM editor commits on every key."
[typed f]
(map #(f (subs typed 0 %)) (range 1 (inc (count typed)))))
(def ^:private edits
"Edit -> editor -> the contents it committed, in order."
{:type-in-place
{:wasm (repeat 2 (text (paragraph (run "Hello world!"))))
:v1 [(text (keyed (paragraph (run "Hello world!")) "2dp2k"))]}
:select-all-and-type
{:wasm (concat (keystrokes "Buy now" #(text (paragraph (run %))))
[(text (paragraph (run "Buy now")))])
:v1 [(text (keyed (paragraph (run "Buy now")) "eid7b"))]}
:enter-and-type
{:wasm (concat [(text (paragraph (run "Hello world")) (paragraph (run "")))]
(keystrokes "Ends Sunday" #(text (paragraph (run "Hello world")) (paragraph (run %))))
[(text (paragraph (run "Hello world")) (paragraph (run "Ends Sunday")))])
:v1 [(text (-> (paragraph (run "Hello world")) (keyed "53n8g") (assoc :fills []))
(styled-paragraph "dtt74" (merge (run "Ends Sunday") direction)))]}
:bold-a-word
{:wasm (repeat 2 (text (paragraph (run "Hello ") (run "world" bold))))
:v1 [(text (keyed (paragraph (run "Hello world")) "3qtf9"))
(text (styled-paragraph "3qtf9" (run "Hello ") (run "world" bold)))]}})
(def ^:private shown
"Edit -> the text it leaves, in the notation."
{:type-in-place [[["Hello world!"]]]
:select-all-and-type [[["Buy now"]]]
:enter-and-type [[["Hello world"]] [["Ends Sunday"]]]
:bold-a-word [[["Hello "] ["world" {:weight 700}]]]})
(def ^:private touched
"Edit -> editor -> the touched groups of the copy text after it."
{:type-in-place {:wasm #{:content-group :text-content-text}
:v1 #{:content-group :text-content-text}}
:select-all-and-type {:wasm #{:content-group :text-content-text}
:v1 #{:content-group :text-content-text}}
;; the WASM editor commits the new paragraph and then each key in it
:enter-and-type {:wasm #{:content-group :text-content-text :text-content-structure}
:v1 #{:content-group :text-content-structure}}
;; the v1 editor also writes the run styles on the paragraph
:bold-a-word {:wasm #{:content-group :text-content-structure}
:v1 #{:content-group :text-content-attribute :text-content-structure}}})
(defn- copy-text-after
"The copy text of a main written as `main`, once the copy gets `commits`."
[main commits]
(let [file (-> (thf/sample-file :file1)
(tho/add-frame-with-text :main-root :main-child ""
:child-params {:content main})
(thc/make-component :component1 :main-root)
(thc/instantiate-component :component1 :copy-root
{:children-labels [:copy-child]}))]
(ths/get-shape (reduce #(thtx/set-content %1 :copy-child %2) file commits)
:copy-child)))
;; ---- Tests
(t/deftest the-notation-reads-what-the-editors-write
(t/is (= [[["Hello world"]]] (thtx/view main-text)))
(t/is (= [[["Hello world"]]] (thtx/view v1-new-text)))
(doseq [[edit by-editor] edits
[editor commits] by-editor]
(t/testing (str edit " in the " editor " editor")
(t/is (= (shown edit) (thtx/view (last commits)))))))
(t/deftest each-edit-touches-these-groups-of-a-copy
(doseq [[edit by-editor] edits
[editor commits] by-editor]
(t/testing (str edit " in the " editor " editor")
(let [copy (copy-text-after main-text commits)]
(t/is (= (shown edit) (thtx/view (:content copy))))
(t/is (= (get-in touched [edit editor]) (:touched copy)))
(case edit
(:type-in-place :select-all-and-type)
(t/is (not (contains? (:touched copy) :text-content-attribute))
"typing sets no style override")
:bold-a-word
(t/is (not (contains? (:touched copy) :text-content-text))
"a style change sets no text override")
nil)))))
(t/deftest a-text-in-one-style-from-either-editor-is-uniform
;; WASM: the paragraph has a direction its runs lack; v1: the paragraph has
;; typography refs (nil) its runs lack
(doseq [content [main-text v1-new-text]]
(tm/known-failure "F79" true false
(txt/equal-attrs? content (txt/get-first-paragraph-text-attrs content)))))

View File

@ -0,0 +1,84 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.text-notation-test
"The text notation of `app.common.test-helpers.texts`."
(:require
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.texts :as thtx]
[app.common.types.text :as txt]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(t/deftest content-is-written-as-the-editors-write-it
(let [content (thtx/content [[["Price: "] ["$10" {:weight 700 :color :red}]]])
paragraph (first (txt/node-seq txt/is-paragraph-node? content))
[run1 run2] (txt/node-seq txt/is-text-node? content)]
(t/testing "every run carries every run style, fills included"
(t/is (= (assoc thtx/base-style :text "Price: ") run1))
(t/is (= (assoc thtx/base-style
:text "$10"
:font-weight "700"
:font-variant-id "bold"
:fills [{:fill-color "#FF0000" :fill-opacity 1}])
run2)))
(t/testing "a paragraph carries its styles and those of its first run"
(t/is (= (merge thtx/base-style thtx/base-paragraph-style)
(dissoc paragraph :type :children))))
(t/testing "the root carries the vertical alignment"
(t/is (= "top" (:vertical-align content))))))
(t/deftest view-reads-content-back-into-the-notation
(thi/new-id! :file1)
(thi/new-id! :body)
(doseq [text [[[["Hello world"]]]
[[["Price: "] ["$10" {:weight 700}]]]
[[["Add to cart" {:family "Roboto" :weight 600 :italic true :size 18 :spacing 2
:case "uppercase" :decoration "underline" :color :blue}]]]
[{:valign "bottom"}
[{:align "center" :direction "rtl" :line-height 1.5} ["Summer " {:weight 700}] ["sale"]]
[["Ends Sunday"]]]
[[["Read the guide" {:typography :body}]]]
[[["odd" {:font-variant-id "bold"}]]]]]
(t/is (= text (thtx/normalize text)))))
(t/deftest view-joins-runs-with-equal-styles
(t/is (= [[["Hello world"]]]
(thtx/normalize [[["Hello"] [" "] ["world"]]])))
(t/is (= [[["Hello"]] [[""]]]
(thtx/normalize [[["Hello"] [""]] [[""]]]))))
(t/deftest edits-of-a-text-in-the-notation
(thi/new-id! :file1)
(thi/new-id! :body)
(let [typed (thtx/apply-typography [[["Read"]]] :body {:family "Roboto" :size 16 :line-height 1.6})]
(t/is (= [[{:line-height 1.6} ["Read" {:family "Roboto" :size 16 :typography :body}]]]
typed))
(t/testing "changing a style the typography sets drops the typography"
(t/is (= [[{:line-height 1.6} ["Read" {:family "Roboto" :size 20}]]]
(thtx/restyle typed :size 20)))
(t/is (= [[{:line-height 2} ["Read" {:family "Roboto" :size 16}]]]
(thtx/restyle-paragraphs typed :line-height 2))))
(t/testing "changing another style keeps it"
(t/is (= [[{:line-height 1.6} ["Read" {:family "Roboto" :size 16 :typography :body :color :red}]]]
(thtx/restyle typed :color :red)))))
(t/is (= [[{:align "center"} ["Buy now" {:weight 700}]]
[{:align "center"} ["Ends Sunday" {:weight 700}]]]
(thtx/set-letters [[{:align "center"} ["Add " {:weight 700}] ["to cart"]]]
"Buy now\nEnds Sunday")))
(t/is (= [{:valign "bottom"} [["Hello"]]]
(thtx/set-valign [[["Hello"]]] "bottom"))))
(t/deftest set-text-commits-the-content-through-the-change-path
(let [file (-> (thf/sample-file :file1)
(thtx/add-text-component-with-copy [[["Hello world"]]])
(thtx/set-text :copy-child [[["Hello world" {:color :red}]]]))]
(t/is (= [[["Hello world" {:color :red}]]] (thtx/text-of file :copy-child)))
(t/is (= #{:content-group :text-content-attribute}
(:touched (ths/get-shape file :copy-child))))))

View File

@ -0,0 +1,138 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.text-override-rule-test
"The override rule for texts (`thtx/override-units`): which overrides a
copy text must hold against its main."
(:require
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.texts :as thtx]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(defn- overrides
[main copy]
(thtx/override-units (thtx/content main) (thtx/content copy)))
(t/deftest shared-letters-come-in-stretches-of-three
(t/is (= {0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 10 10}
(thtx/shared-letters "Hello world" "Hello world!!!")))
;; "llo world" is shared; the lone "H" is not
(t/is (= (zipmap (range 2 11) (range 2 11))
(thtx/shared-letters "Hello world" "Hallo world")))
(t/is (= {} (thtx/shared-letters "OK" "Ok")))
(t/is (= {} (thtx/shared-letters "Price: $10" "Sale"))))
(def ^:private price [[["Price: "] ["$10" {:weight 700}]]])
(t/deftest an-edit-overrides-only-what-it-changes
(t/testing "typing at the end changes the letters only"
(t/is (= #{:letters} (overrides [[["Hello world"]]] [[["Hello world!!!"]]]))))
(t/testing "retyping all of a two-style text changes the letters only"
(t/is (= #{:letters} (overrides price [[["Sale"]]]))))
(t/testing "a new line in the style of the letter before it changes the letters only"
(t/is (= #{:letters}
(overrides [[["Summer " {:weight 700}] ["sale"]]]
[[["Summer " {:weight 700}] ["sale"]] [["Ends Sunday"]]]))))
(t/testing "bolding a word changes the weight only"
(t/is (= #{:weight} (overrides [[["Hello world"]]] [[["Hello" {:weight 700}] [" world"]]]))))
(t/testing "each style is a unit of its own"
(doseq [[unit copy] {:fills [[["Hello world" {:color :red}]]]
:family [[["Hello world" {:family "Roboto"}]]]
:size [[["Hello world" {:size 18}]]]
:spacing [[["Hello world" {:spacing 2}]]]
:decoration [[["Hello world" {:decoration "underline"}]]]
:case [[["Hello world" {:case "uppercase"}]]]
:align [[{:align "center"} ["Hello world"]]]
:direction [[{:direction "rtl"} ["Hello world"]]]
:line-height [[{:line-height 1.5} ["Hello world"]]]
:valign [{:valign "bottom"} [["Hello world"]]]}]
(t/is (= #{unit} (overrides [[["Hello world"]]] copy)) (str unit))))
(t/testing "a typography sets each unit it changes, and its reference"
(thi/new-id! :file1)
(thi/new-id! :heading)
(t/is (= #{:family :weight :size :spacing :case :line-height :typography}
(overrides [[["Hello world"]]]
(thtx/apply-typography [[["Hello world"]]] :heading
{:family "Inter" :weight 700 :size 24 :spacing 1
:case "uppercase" :line-height 1.4}))))))
(t/deftest the-expected-copies-hold-exactly-their-overrides
;; The copies `text-override-sync-test` expects after a sync, against the
;; main they synced from: each must hold the overrides of its own edits
;; and nothing else.
(doseq [[title main copy expected]
[["bold word, main blue"
[[["Hello world" {:color :blue}]]]
[[["Hello" {:weight 700 :color :blue}] [" world" {:color :blue}]]]
#{:weight}]
["bold word, main letters"
[[["Hello there"]]]
[[["Hello" {:weight 700}] [" there"]]]
#{:weight}]
["bold word, main replaces it"
[[["Goodbye world"]]]
[[["Goodbye world"]]]
#{}]
["bold word, main changes one letter"
[[["Hallo world"]]]
[[["Hallo" {:weight 700}] [" world"]]]
#{:weight}]
["bold letter of a short label"
[[["Ok"]]]
[[["Ok"]]]
#{}]
["retyped price, main family"
[[["Price: " {:family "Roboto"}] ["$10" {:family "Roboto" :weight 700}]]]
[[["Sale" {:family "Roboto"}]]]
#{:letters}]
["new price, main bolds Price"
[[["Price" {:weight 700}] [": "] ["$10" {:weight 700}]]]
[[["Price" {:weight 700}] [": "] ["$20" {:weight 700}]]]
#{:letters}]
["added letters, main colors a word"
[[["Hello "] ["world" {:color :red}]]]
[[["Hello "] ["world!!!" {:color :red}]]]
#{:letters}]
["new line, main family"
[[["Summer " {:family "Roboto" :weight 700}] ["sale" {:family "Roboto"}]]]
[[["Summer " {:family "Roboto" :weight 700}] ["sale" {:family "Roboto"}]]
[["Ends Sunday" {:family "Roboto"}]]]
#{:letters}]
["across runs, main colors the price"
[[["Price: "] ["$10" {:weight 700 :color :red}]]]
[[["Cost: "] ["$10" {:weight 700 :color :red}]]]
#{:letters}]
["worked example, main colors a word"
[[["Hello "] ["world" {:color :red}]]]
[[["Hello" {:weight 700}] [" "] ["world!!!" {:color :red}]]]
#{:letters :weight}]
["worked example, main centers"
[[{:align "center"} ["Hello world"]]]
[[{:align "center"} ["Hello" {:weight 700}] [" world!!!"]]]
#{:letters :weight}]
["worked example, main light"
[[["Hello world" {:weight 300}]]]
[[["Hello" {:weight 700}] [" world!!!"]]]
#{:letters :weight}]]]
(t/is (= expected (overrides main copy)) title)))
(t/deftest wrong-sync-results-show-as-overrides
;; What today's sync leaves (pinned in `text-override-sync-test`) differs
;; from the main where the copy has no override: a scanner finds it.
(t/testing "F78: the bold lands on the wrong letters"
(t/is (= #{:letters :weight}
(overrides [[["Price" {:weight 700}] [": "] ["$10" {:weight 700}]]]
[[["Price: " {:weight 700}] ["$20"]]]))))
(t/testing "F78: the main's red never reaches the shared word"
(t/is (= #{:letters :fills}
(overrides [[["Hello "] ["world" {:color :red}]]]
[[["Hello world!!!"]]]))))
(t/testing "F72: a recolored copy keeps the old size"
(t/is (= #{:fills :size}
(overrides [[["Add to cart" {:size 18}]]]
[[["Add to cart" {:color :red}]]])))))

View File

@ -0,0 +1,140 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.text-override-switch-test
"What a copy text holds after a variant switch that keeps overrides
(`mem:common/component-sync-contract`). Each override (the letters, and
each style) is carried only when the copy has it and the old and new
variants agree on it; everything else comes from the new variant. Texts
are written in the notation of `app.common.test-helpers.texts`."
(:require
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.texts :as thtx]
[app.common.test-helpers.variants :as thv]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(defn- switch-text
"The copy's text when a variant set has the members `old` and `new`, a
copy of `old` writes `copy-text` and switches to `new`, keeping its
overrides."
[old copy-text new]
(let [file (-> (thf/sample-file :file1)
(thv/add-variant-with-text :v01 :c01 :m01 :c02 :m02 :t01 :t02 "" ""
:text1-params {:content (thtx/content old)}
:text2-params {:content (thtx/content new)})
(thc/instantiate-component :c01 :copy :children-labels [:copy-t])
(thtx/set-text :copy-t copy-text)
(tho/swap-component-in-shape :copy :c02 {:new-shape-label :copy-2
:keep-touched? true}))
copy-t (first (:shapes (ths/get-shape file :copy-2)))]
(thtx/view (:content (ths/get-shape-by-id file copy-t)))))
;; ---- Whole-text copy edits against pairs of variants
(def ^:private small [[["Add to cart"]]])
(def ^:private copy-edits
"Copy edit -> the overrides it makes, each as the edit that writes it."
{:letters {:letters #(thtx/set-letters % "Buy now")}
:new-paragraph {:letters #(thtx/set-letters % "Add to cart\nEnds Sunday")}
:color {:color #(thtx/restyle % :color :red)}
:family {:family #(thtx/restyle % :family "Roboto")}
:size {:size #(thtx/restyle % :size 18)}
:weight {:weight #(thtx/restyle % :weight 700)}
:align {:align #(thtx/restyle-paragraphs % :align "center")}
:valign {:valign #(thtx/set-valign % "bottom")}
:letters-and-color {:letters #(thtx/set-letters % "Buy now")
:color #(thtx/restyle % :color :red)}})
(def ^:private variants
"Second variant -> [its text, the overrides the two variants disagree on]."
{:same [small #{}]
:letters [[[["Add to bag"]]] #{:letters}]
:size [[[["Add to cart" {:size 24}]]] #{:size}]
:color [[[["Add to cart" {:color :blue}]]] #{:color}]
:new-line [[[["Add to cart"]] [["Free shipping"]]] #{:letters}]
:bold-word [[[["Add to "] ["cart" {:weight 700}]]] #{:weight}]})
(def ^:private known-failures
"Copy edit -> [bug, how the copy ends, the variants it fails against].
`:variant`: the copy takes the new variant's text whole; a text in the
notation: the copy ends with that text."
{:letters [["F81" :variant #{:bold-word}]]
:new-paragraph [["F79" :variant #{:size :color}]
["F81" :variant #{:bold-word}]]
:color [["F77" :variant #{:size}]
["F79" :variant #{:new-line}]
["F81" :variant #{:bold-word}]]
:family [["F77" :variant #{:size :color}]
["F79" :variant #{:new-line}]
["F81" :variant #{:bold-word}]]
:size [["F77" :variant #{:color}]
["F79" :variant #{:new-line}]
["F81" :variant #{:bold-word}]]
:weight [["F77" :variant #{:size :color}]
["F79" :variant #{:new-line}]]
:letters-and-color [["F77" [[["Buy now" {:size 24}]]] #{:size}]
["F79" :variant #{:new-line}]
["F81" :variant #{:bold-word}]]
:align [["F76" :variant (disj (set (keys variants)) :same)]]
:valign [["F76" :variant (disj (set (keys variants)) :same)]]})
(defn- known-failure
[copy-edit variant]
(some (fn [[bug ends variants]]
(when (contains? variants variant) [bug ends]))
(get known-failures copy-edit)))
(t/deftest whole-text-copy-edits-across-a-switch
(doseq [[copy-title overrides] copy-edits
[variant-title [new disagree]] variants]
(t/testing (str "copy " copy-title ", variant " variant-title)
(let [copy-text (reduce (fn [text f] (f text)) small (vals overrides))
carried (remove (comp disagree key) overrides)
expected (thtx/normalize (reduce (fn [text [_ f]] (f text)) new carried))
actual (switch-text small copy-text new)]
(if-let [[bug ends] (known-failure copy-title variant-title)]
(tm/known-failure bug expected
(thtx/normalize (if (= ends :variant) new ends))
actual)
(t/is (= expected actual)))))))
;; ---- Cards
(t/deftest relabeled-copy-keeps-its-label-in-a-variant-with-a-bolder-word
;; both variants have the same letters, so the label stays; the weights
;; come from the new variant, and "Buy now" shares no letters with it, so
;; all of it takes the weight of its first letter
(let [old [[["Add to cart" {:weight 600}]]]
new [[["Add to " {:weight 600}] ["cart" {:weight 700}]]]]
(tm/known-failure "F81"
[[["Buy now" {:weight 600}]]]
new
(switch-text old [[["Buy now" {:weight 600}]]] new))))
(t/deftest recolored-copy-keeps-its-color-from-small-to-large
(let [old [[["Add to cart" {:weight 600}]]]
new [[["Add to cart" {:weight 600 :size 24}]]]]
(tm/known-failure "F77"
[[["Add to cart" {:weight 600 :size 24 :color :red}]]]
new
(switch-text old [[["Add to cart" {:weight 600 :color :red}]]] new))))
(t/deftest copy-that-bolds-a-word-keeps-it-from-small-to-large
(let [old [[["Hello world"]]]
new [[["Hello world" {:size 24}]]]]
(tm/known-failure "F77"
[[["Hello" {:weight 700 :size 24}] [" world" {:size 24}]]]
new
(switch-text old [[["Hello" {:weight 700}] [" world"]]] new))))

View File

@ -0,0 +1,481 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns common-tests.logic.text-override-sync-test
"What a copy text holds after its main changes and syncs, when the copy
has its own letters or styles (`mem:common/component-sync-contract`):
- letters and line breaks are one override, and each style another;
- a copy's style override covers the whole text;
- the main's styles go onto letters that differ from its own by three
rules: a style with one value across the main's text applies to every
letter; otherwise letters both texts share (a stretch of at least 3)
take the main's style; any other letter takes the style of the letter
before it, or at the start of the text, of the first shared letter
after it, or with nothing shared, of the main's first letter.
Texts are written in the notation of `app.common.test-helpers.texts`."
(:require
[app.common.files.changes-builder :as pcb]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.composable.core :as tm]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.texts :as thtx]
[app.common.types.text :as txt]
[app.common.types.typographies-list :as cttl]
[clojure.test :as t]))
(t/use-fixtures :each thi/test-fixture)
(def ^:private heading
{:family "Inter" :weight 700 :size 24 :spacing 1 :case "uppercase" :line-height 1.4})
(def ^:private body
{:family "Roboto" :weight 300 :size 16 :spacing 0.5 :case "capitalize" :line-height 1.6})
(defn- sample-file
"A file with the typographies :heading and :body."
[]
(-> (thf/sample-file :file1)
(ths/add-sample-typography :heading)
(ths/add-sample-typography :body)))
(defn- sync-text
"The copy's text when the main starts as `main`, the copy writes
`copy-text` (nil for no edit), then the main writes `main-text` and syncs."
[main copy-text main-text]
(-> (sample-file)
(thtx/add-text-component-with-copy main)
(cond-> (some? copy-text) (thtx/set-text :copy-child copy-text))
(thtx/set-text :main-child main-text)
(tho/propagate-component-changes :component1)
(thtx/text-of :copy-child)))
;; ---- Whole-text edits: every copy edit against every main edit
(def ^:private base [[["Hello world"]]])
(def ^:private copy-edits
{:letters #(thtx/set-letters % "Buy now")
:new-paragraph #(thtx/set-letters % "Hello world\nEnds Sunday")
:color #(thtx/restyle % :color :red)
:family #(thtx/restyle % :family "Roboto")
:size #(thtx/restyle % :size 18)
:weight #(thtx/restyle % :weight 700)
:spacing #(thtx/restyle % :spacing 2)
:decoration #(thtx/restyle % :decoration "underline")
:case #(thtx/restyle % :case "uppercase")
:line-height #(thtx/restyle-paragraphs % :line-height 1.5)
:align #(thtx/restyle-paragraphs % :align "center")
:direction #(thtx/restyle-paragraphs % :direction "rtl")
:valign #(thtx/set-valign % "bottom")
:letters-and-color #(-> % (thtx/set-letters "Buy now") (thtx/restyle :color :red))
:typography #(thtx/apply-typography % :heading heading)})
(def ^:private main-edits
{:letters #(thtx/set-letters % "Add to bag")
:new-paragraph #(thtx/set-letters % "Hello world\nSecond line")
:color #(thtx/restyle % :color :blue)
:family #(thtx/restyle % :family "Inter")
:size #(thtx/restyle % :size 24)
:weight #(thtx/restyle % :weight 300)
:spacing #(thtx/restyle % :spacing 1)
:decoration #(thtx/restyle % :decoration "line-through")
:case #(thtx/restyle % :case "lowercase")
:line-height #(thtx/restyle-paragraphs % :line-height 2)
:align #(thtx/restyle-paragraphs % :align "right")
:direction #(thtx/restyle-paragraphs % :direction "rtl")
:valign #(thtx/set-valign % "center")
:typography #(thtx/apply-typography % :body body)})
(def ^:private style-edits
#{:color :family :size :weight :spacing :decoration :case :line-height
:align :direction :valign :typography})
(def ^:private known-failures
"Copy edit -> [bug, how the copy ends, the main edits it fails against].
`:as-it-was`: the sync leaves the copy as the copy edit left it.
`:main`: the copy takes the main's text whole."
(let [one-style (fn [own]
[["F72" :as-it-was (disj style-edits own)]
["F79" :as-it-was #{:new-paragraph}]])]
{:color (one-style :color)
:family (one-style :family)
:size (one-style :size)
:weight (one-style :weight)
:spacing (one-style :spacing)
:decoration (one-style :decoration)
:case (one-style :case)
:line-height (one-style :line-height)
:typography [["F72" :as-it-was #{:color :decoration :align :direction :valign}]
["F79" :as-it-was #{:new-paragraph}]]
:new-paragraph [["F79" :as-it-was style-edits]]
:letters-and-color [["F73" :as-it-was (disj style-edits :color)]]
:align [["F76" :main (set (keys main-edits))]]
:direction [["F76" :main (disj (set (keys main-edits)) :direction)]]
:valign [["F76" :main (set (keys main-edits))]]}))
(defn- known-failure
[copy-edit main-edit]
(some (fn [[bug ends main-edits]]
(when (contains? main-edits main-edit) [bug ends]))
(get known-failures copy-edit)))
(t/deftest whole-text-copy-edits-against-main-edits
;; Whole-text edits commute with the rules: the copy keeps what it
;; edited and takes the rest from the main, which is its edit applied
;; over the main's new text.
(doseq [[copy-title copy-edit] copy-edits
[main-title main-edit] main-edits]
(t/testing (str "copy " copy-title ", main " main-title)
(let [actual (sync-text base (copy-edit base) (main-edit base))
expected (thtx/normalize (copy-edit (main-edit base)))]
(if-let [[bug ends] (known-failure copy-title main-title)]
(tm/known-failure bug expected
(thtx/normalize (case ends
:as-it-was (copy-edit base)
:main (main-edit base)))
actual)
(t/is (= expected actual)))))))
;; ---- One style no longer blocks the others
(t/deftest recolored-copy-takes-the-main-font-and-size
(let [main [[["Add to cart" {:weight 600}]]]
copy [[["Add to cart" {:weight 600 :color :red}]]]
main' [[["Add to cart" {:weight 600 :family "Roboto" :size 18}]]]
actual (sync-text main copy main')]
(tm/known-failure "F72"
[[["Add to cart" {:family "Roboto" :weight 600 :size 18 :color :red}]]]
copy
actual)))
(t/deftest relabeled-and-recolored-copy-takes-the-main-size
(let [main [[["Add to cart" {:weight 600}]]]
copy [[["Buy now" {:weight 600 :color :red}]]]
main' [[["Add to cart" {:weight 600 :size 18}]]]
actual (sync-text main copy main')]
(tm/known-failure "F73"
[[["Buy now" {:weight 600 :size 18 :color :red}]]]
copy
actual)))
(t/deftest centered-copy-stays-centered
(let [main [[["Free shipping"]]]
copy [[{:align "center"} ["Free shipping"]]]
main' [[["Free shipping" {:size 18}]]]
actual (sync-text main copy main')]
(tm/known-failure "F76"
[[{:align "center"} ["Free shipping" {:size 18}]]]
main'
actual)))
(t/deftest copy-with-a-font-size-token-takes-the-main-color
(let [main [[["Add to cart" {:weight 600}]]]
file (-> (sample-file)
(thtx/add-text-component-with-copy main))
page (thf/current-page file)
;; applying a token writes its value into the content and records it
file (thf/apply-changes
file
(cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id :copy-child)}
#(assoc %
:content (thtx/content [[["Add to cart" {:weight 600 :size 16}]]])
:applied-tokens {:font-size "size.md"})
(:objects page)
{}))
file' (-> file
(thtx/set-text :main-child [[["Add to cart" {:weight 600 :color :blue}]]])
(tho/propagate-component-changes :component1))]
(tm/known-failure "F72"
[[["Add to cart" {:weight 600 :size 16 :color :blue}]]]
[[["Add to cart" {:weight 600 :size 16}]]]
(thtx/text-of file' :copy-child))))
;; ---- Bolding a word
(def ^:private hello-bold [[["Hello" {:weight 700}] [" world"]]])
(t/deftest copy-that-bolds-a-word-takes-the-main-color
(tm/known-failure "F74"
[[["Hello" {:weight 700 :color :blue}] [" world" {:color :blue}]]]
hello-bold
(sync-text [[["Hello world"]]] hello-bold [[["Hello world" {:color :blue}]]])))
(t/deftest copy-that-bolds-a-word-takes-the-main-letters
;; "Hello" is shared, so it stays bold; " there" follows the space
(tm/known-failure "F74"
[[["Hello" {:weight 700}] [" there"]]]
hello-bold
(sync-text [[["Hello world"]]] hello-bold [[["Hello there"]]])))
(t/deftest copy-that-bolds-a-word-takes-the-main-family
(tm/known-failure "F74"
[[["Hello" {:family "Roboto" :weight 700}] [" world" {:family "Roboto"}]]]
hello-bold
(sync-text [[["Hello world"]]] hello-bold [[["Hello world" {:family "Roboto"}]]])))
(t/deftest copy-that-bolds-a-word-the-main-replaces-loses-the-bold
;; "Goodbye" shares no stretch of 3 letters with "Hello", so it takes the
;; style of the first shared letter after it, the regular space
(tm/known-failure "F74"
[[["Goodbye world"]]]
hello-bold
(sync-text [[["Hello world"]]] hello-bold [[["Goodbye world"]]])))
(t/deftest copy-that-bolds-a-word-the-main-changes-by-one-letter
;; "llo world" is shared; "Ha" at the start takes the bold "l" after it
(tm/known-failure "F74"
[[["Hallo" {:weight 700}] [" world"]]]
hello-bold
(sync-text [[["Hello world"]]] hello-bold [[["Hallo world"]]])))
(t/deftest copy-that-bolds-a-letter-of-a-short-label
;; a lone shared letter carries nothing: with no stretch of 3 shared, all
;; letters take the style of the copy's first letter
(let [copy [[["O"] ["K" {:weight 700}]]]]
(tm/known-failure "F74"
[[["Ok"]]]
copy
(sync-text [[["OK"]]] copy [[["Ok"]]]))))
(t/deftest copy-that-bolds-a-word-keeps-its-weights-when-the-main-is-light
;; an override covers the whole text, so "world" stays regular
(t/is (= hello-bold
(sync-text [[["Hello world"]]] hello-bold [[["Hello world" {:weight 300}]]]))))
;; ---- Copies with their own letters
(def ^:private price [[["Price: "] ["$10" {:weight 700}]]])
(t/deftest retyped-two-style-copy-takes-the-main-family
;; the family has one value across the main, so it applies everywhere;
;; "Sale" shares nothing with "Price: $10" and takes the main's first
;; letter's weight
(tm/known-failure "F74"
[[["Sale" {:family "Roboto"}]]]
[[["Sale"]]]
(sync-text price [[["Sale"]]]
[[["Price: " {:family "Roboto"}] ["$10" {:family "Roboto" :weight 700}]]])))
(t/deftest copy-that-removes-a-run-takes-the-main-color
(let [copy [[["$10" {:weight 700}]]]]
(tm/known-failure "F74"
[[["$10" {:weight 700 :color :blue}]]]
copy
(sync-text price copy
[[["Price: " {:color :blue}] ["$10" {:weight 700 :color :blue}]]]))))
(t/deftest copy-with-new-letters-in-a-run-takes-the-main-family
(t/is (= [[["Price: " {:family "Roboto"}] ["$20" {:family "Roboto" :weight 700}]]]
(sync-text price [[["Price: "] ["$20" {:weight 700}]]]
[[["Price: " {:family "Roboto"}] ["$10" {:family "Roboto" :weight 700}]]]))))
(t/deftest copy-with-new-letters-across-runs-takes-a-partial-color
;; ": $10" is shared, so "$10" takes the red; "Cost" at the start takes
;; the black of ":"
(t/is (= [[["Cost: "] ["$10" {:weight 700 :color :red}]]]
(sync-text price [[["Cost: "] ["$10" {:weight 700}]]]
[[["Price: "] ["$10" {:weight 700 :color :red}]]]))))
(t/deftest copy-with-added-letters-takes-a-partial-color
;; "world" is shared, so it turns red; "!!!" follows the "d" before it
(tm/known-failure "F78"
[[["Hello "] ["world!!!" {:color :red}]]]
[[["Hello world!!!"]]]
(sync-text [[["Hello world"]]] [[["Hello world!!!"]]]
[[["Hello "] ["world" {:color :red}]]])))
(t/deftest copy-with-a-new-price-takes-the-main-bold-on-the-right-letters
;; "Price: $" is shared; "20" follows the bold "$"
(tm/known-failure "F78"
[[["Price" {:weight 700}] [": "] ["$20" {:weight 700}]]]
[[["Price: " {:weight 700}] ["$20"]]]
(sync-text price [[["Price: "] ["$20" {:weight 700}]]]
[[["Price" {:weight 700}] [": "] ["$10" {:weight 700}]]])))
(t/deftest copy-that-adds-a-line-to-a-two-style-title-takes-the-main-family
;; the new line follows the regular "sale" before it, across the break
(let [main [[["Summer " {:weight 700}] ["sale"]]]
copy [[["Summer " {:weight 700}] ["sale"]]
[["Ends Sunday"]]]]
(tm/known-failure "F74"
[[["Summer " {:family "Roboto" :weight 700}] ["sale" {:family "Roboto"}]]
[["Ends Sunday" {:family "Roboto"}]]]
copy
(sync-text main copy
[[["Summer " {:family "Roboto" :weight 700}] ["sale" {:family "Roboto"}]]]))))
(t/deftest copy-with-a-new-line-in-content-without-fills-takes-the-main-color
;; `txt/change-text` writes nodes with no :fills, and the merge only
;; writes the keys the copy's nodes have
(let [file (-> (sample-file)
(tho/add-frame-with-text :main-root :main-child "hello world")
(thc/make-component :component1 :main-root)
(thc/instantiate-component :component1 :copy-root {:children-labels [:copy-child]}))
page (thf/current-page file)
edit (fn [file label f]
(thf/apply-changes
file
(cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id label)} f (:objects page) {})))
blue [{:fill-color "#0000FF" :fill-opacity 1}]
file' (-> file
(edit :copy-child (fn [shape]
(update shape :content txt/change-text "hello world\nsecond line")))
(edit :main-child (fn [shape]
(update shape :content
#(txt/transform-nodes txt/is-content-node?
(fn [node] (assoc node :fills blue))
%))))
(tho/propagate-component-changes :component1))]
(tm/known-failure "F75"
[[["hello world" {:color :blue}]] [["second line" {:color :blue}]]]
[[["hello world" {:fills nil}]] [["second line" {:fills nil}]]]
(thtx/text-of file' :copy-child))))
;; ---- Typographies and library colors
(t/deftest copy-that-resizes-a-typography-text-takes-the-main-color
;; the size comes from the copy, so the typography no longer describes
;; the run and goes, also when the copy's content kept it
(let [main (thtx/apply-typography [[["Read the guide"]]] :body body)
copy (thtx/restyle main :size 20)
kept (thtx/apply-typography [[["Read the guide"]]] :body (assoc body :size 20))
main' (thtx/restyle main :color :blue)]
(t/testing "detached in the panel before resizing"
(tm/known-failure "F72"
(thtx/normalize (thtx/restyle copy :color :blue))
(thtx/normalize copy)
(sync-text main copy main')))
(t/testing "resized keeping the typography (plugin API)"
(tm/known-failure "F72"
(thtx/normalize (thtx/restyle copy :color :blue))
(thtx/normalize kept)
(sync-text main kept main')))))
(t/deftest copy-that-detached-a-typography-takes-a-library-change-through-its-main
(let [body-attrs {:font-id "sourcesanspro" :font-family "sourcesanspro" :font-variant-id "regular"
:font-size "14" :font-weight "400" :font-style "normal"
:line-height "1.2" :letter-spacing "0" :text-transform "none"}
file (-> (thf/sample-file :file1)
(ths/add-sample-typography :body body-attrs)
(thtx/add-text-component-with-copy [[["Read the guide" {:typography :body}]]])
(thtx/set-text :copy-child [[["Read the guide" {:size 16}]]])
(update :data cttl/update-typography (thi/id :body)
assoc :font-family "Roboto" :font-id "gfont-roboto"))
sync (fn [file asset-type asset-id]
(thf/apply-changes
file
(cll/generate-sync-file-changes (pcb/empty-changes) nil asset-type (:id file)
asset-id (:id file) {(:id file) file} (:id file))))
file' (-> file
(sync :typographies (thi/id :body))
(sync :components (thi/id :component1)))]
(tm/known-failure "F72"
[[["Read the guide" {:family "Roboto" :size 16}]]]
[[["Read the guide" {:size 16}]]]
(thtx/text-of file' :copy-child))))
(t/deftest untouched-copy-with-a-library-color-takes-the-main-size
;; the library color sync writes the copy's runs; it is derived, so the
;; main's later size change must still arrive
(let [brand (fn [color] [{:fill-color color :fill-opacity 1
:fill-color-ref-id (thi/id :brand)
:fill-color-ref-file (thi/id :file1)}])
sync (fn [file asset-type asset-id]
(thf/apply-changes
file
(cll/generate-sync-file-changes (pcb/empty-changes) nil asset-type (:id file)
asset-id (:id file) {(:id file) file} (:id file))))
file (-> (thf/sample-file :file1)
(ths/add-sample-library-color :brand :color "#0000FF"))
file' (-> file
(thtx/add-text-component-with-copy [[["New arrivals" {:weight 600 :fills (brand "#0000FF")}]]])
(assoc-in [:data :colors (thi/id :brand) :color] "#7B3FD1")
(sync :colors (thi/id :brand))
(thtx/set-text :main-child [[["New arrivals" {:weight 600 :size 18 :fills (brand "#7B3FD1")}]]])
(sync :components (thi/id :component1)))]
(tm/known-failure "F3"
[[["New arrivals" {:weight 600 :size 18 :fills (brand "#7B3FD1")}]]]
[[["New arrivals" {:weight 600 :fills (brand "#7B3FD1")}]]]
(thtx/text-of file' :copy-child))))
;; ---- The worked example: a copy that bolds "Hello" and adds "!!!"
(def ^:private worked-copy [[["Hello" {:weight 700}] [" world!!!"]]])
(def ^:private worked-example
"Main edit of \"Hello world\" -> what the copy should hold."
[["colors all of it blue"
[[["Hello world" {:color :blue}]]]
[[["Hello" {:weight 700 :color :blue}] [" world!!!" {:color :blue}]]]]
["colors \"world\" red"
[[["Hello "] ["world" {:color :red}]]]
[[["Hello" {:weight 700}] [" "] ["world!!!" {:color :red}]]]]
["changes the size"
[[["Hello world" {:size 18}]]]
[[["Hello" {:weight 700 :size 18}] [" world!!!" {:size 18}]]]]
["centers it"
[[{:align "center"} ["Hello world"]]]
[[{:align "center"} ["Hello" {:weight 700}] [" world!!!"]]]]
["changes the letters to \"Hello there\""
[[["Hello there"]]]
worked-copy]
["changes the letters to \"Goodbye world\""
[[["Goodbye world"]]]
worked-copy]
["makes \"world\" light"
[[["Hello "] ["world" {:weight 300}]]]
worked-copy]
["makes all of it light"
[[["Hello world" {:weight 300}]]]
worked-copy]
["changes the family"
[[["Hello world" {:family "Roboto"}]]]
[[["Hello" {:family "Roboto" :weight 700}] [" world!!!" {:family "Roboto"}]]]]
["applies a typography"
(thtx/apply-typography [[["Hello world"]]] :heading heading)
;; the weight is the copy's, so the typography goes
[[{:line-height 1.4}
["Hello" {:family "Inter" :weight 700 :size 24 :spacing 1 :case "uppercase"}]
[" world!!!" {:family "Inter" :size 24 :spacing 1 :case "uppercase"}]]]]])
(t/deftest copy-that-bolds-a-word-and-adds-letters-against-main-edits
(doseq [[title main' expected] worked-example]
(t/testing (str "the main " title)
(let [actual (sync-text [[["Hello world"]]] worked-copy main')
expected (thtx/normalize expected)]
(if (= expected (thtx/normalize worked-copy))
(t/is (= expected actual))
(tm/known-failure "F73" expected (thtx/normalize worked-copy) actual))))))
;; ---- The same as today
(t/deftest relabeled-copy-takes-the-main-size
(t/is (= [[["Buy now" {:weight 600 :size 18}]]]
(sync-text [[["Add to cart" {:weight 600}]]]
[[["Buy now" {:weight 600}]]]
[[["Add to cart" {:weight 600 :size 18}]]]))))
(t/deftest relabeled-copy-keeps-its-letters-when-the-main-relabels
(t/is (= [[["Buy now" {:weight 600}]]]
(sync-text [[["Add to cart" {:weight 600}]]]
[[["Buy now" {:weight 600}]]]
[[["Add to bag" {:weight 600}]]]))))

View File

@ -73,6 +73,11 @@
[common-tests.logic.swap-and-reset-test]
[common-tests.logic.swap-as-override-test]
[common-tests.logic.swap-keeps-id-test]
[common-tests.logic.text-editor-output-test]
[common-tests.logic.text-notation-test]
[common-tests.logic.text-override-rule-test]
[common-tests.logic.text-override-switch-test]
[common-tests.logic.text-override-sync-test]
[common-tests.logic.text-sync-test]
[common-tests.logic.text-touched-test]
[common-tests.logic.token-apply-test]
@ -181,6 +186,11 @@
'common-tests.logic.swap-and-reset-test
'common-tests.logic.swap-as-override-test
'common-tests.logic.swap-keeps-id-test
'common-tests.logic.text-editor-output-test
'common-tests.logic.text-notation-test
'common-tests.logic.text-override-rule-test
'common-tests.logic.text-override-switch-test
'common-tests.logic.text-override-sync-test
'common-tests.logic.text-sync-test
'common-tests.logic.text-touched-test
'common-tests.logic.token-apply-test

View File

@ -81,6 +81,9 @@
(t/deftest case-y-text-override-merges-with-main-text-change
(t/async done (ftm/check done (cases/text-override-merges-with-main-text-change))))
(t/deftest case-ba-text-style-override-against-main-style-change
(t/async done (ftm/check done (cases/text-style-override-against-main-style-change))))
(t/deftest case-n-geometry-sync-with-rotated-instances
(t/async done (ftm/check done (cases/geometry-sync-with-rotated-instances))))