diff --git a/.serena/memories/frontend/composable-component-tests.md b/.serena/memories/frontend/composable-component-tests.md index 7025c0017b..ac69a9cac3 100644 --- a/.serena/memories/frontend/composable-component-tests.md +++ b/.serena/memories/frontend/composable-component-tests.md @@ -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 diff --git a/common/src/app/common/test_helpers/composable/comp/cases.cljc b/common/src/app/common/test_helpers/composable/comp/cases.cljc index bc39ce5e68..182d9eb152 100644 --- a/common/src/app/common/test_helpers/composable/comp/cases.cljc +++ b/common/src/app/common/test_helpers/composable/comp/cases.cljc @@ -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 ;; diff --git a/common/src/app/common/test_helpers/composable/comp/nodes.cljc b/common/src/app/common/test_helpers/composable/comp/nodes.cljc index 8374294141..ec0a2e3906 100644 --- a/common/src/app/common/test_helpers/composable/comp/nodes.cljc +++ b/common/src/app/common/test_helpers/composable/comp/nodes.cljc @@ -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 diff --git a/common/src/app/common/test_helpers/composable/comp/setups.cljc b/common/src/app/common/test_helpers/composable/comp/setups.cljc index 5ddbbbb5b1..c10b93c643 100644 --- a/common/src/app/common/test_helpers/composable/comp/setups.cljc +++ b/common/src/app/common/test_helpers/composable/comp/setups.cljc @@ -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." diff --git a/common/src/app/common/test_helpers/texts.cljc b/common/src/app/common/test_helpers/texts.cljc new file mode 100644 index 0000000000..2bf5915074 --- /dev/null +++ b/common/src/app/common/test_helpers/texts.cljc @@ -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)))) diff --git a/common/test/common_tests/logic/composable_sync_test.cljc b/common/test/common_tests/logic/composable_sync_test.cljc index af72c74d4a..38c61e76b0 100644 --- a/common/test/common_tests/logic/composable_sync_test.cljc +++ b/common/test/common_tests/logic/composable_sync_test.cljc @@ -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))) diff --git a/common/test/common_tests/logic/library_asset_sync_test.cljc b/common/test/common_tests/logic/library_asset_sync_test.cljc index 854cfcde09..ea67a1f96d 100644 --- a/common/test/common_tests/logic/library_asset_sync_test.cljc +++ b/common/test/common_tests/logic/library_asset_sync_test.cljc @@ -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)))) diff --git a/common/test/common_tests/logic/text_editor_output_test.cljc b/common/test/common_tests/logic/text_editor_output_test.cljc new file mode 100644 index 0000000000..1c60717412 --- /dev/null +++ b/common/test/common_tests/logic/text_editor_output_test.cljc @@ -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))))) diff --git a/common/test/common_tests/logic/text_notation_test.cljc b/common/test/common_tests/logic/text_notation_test.cljc new file mode 100644 index 0000000000..d38c925dec --- /dev/null +++ b/common/test/common_tests/logic/text_notation_test.cljc @@ -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)))))) diff --git a/common/test/common_tests/logic/text_override_rule_test.cljc b/common/test/common_tests/logic/text_override_rule_test.cljc new file mode 100644 index 0000000000..bde87e5b66 --- /dev/null +++ b/common/test/common_tests/logic/text_override_rule_test.cljc @@ -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}]]]))))) diff --git a/common/test/common_tests/logic/text_override_switch_test.cljc b/common/test/common_tests/logic/text_override_switch_test.cljc new file mode 100644 index 0000000000..738682cf0b --- /dev/null +++ b/common/test/common_tests/logic/text_override_switch_test.cljc @@ -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)))) diff --git a/common/test/common_tests/logic/text_override_sync_test.cljc b/common/test/common_tests/logic/text_override_sync_test.cljc new file mode 100644 index 0000000000..37ae9b4d30 --- /dev/null +++ b/common/test/common_tests/logic/text_override_sync_test.cljc @@ -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}]]])))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index d8fd66fc5d..73916b7d6c 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -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 diff --git a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs index ece9a39a76..1df6636d34 100644 --- a/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs +++ b/frontend/test/frontend_tests/composable_tests/comp/sync_test.cljs @@ -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))))