Refuse a symbol placed inside itself, and label instances by what they place

A paste put an instance of "bg" inside "bg" itself. It saved, loaded, and then
threw "symbol cycle in audio" out of nest/audio-tracks, because a container that
contains itself has no finite expansion.

place-symbol and ui/drag already refused that, each by asking
clip/contains-symbol?. Paste asks clip/problems instead, and problems did not
encode the invariant at all -- it checked missing symbols, pose tracks and audio
links, but never the placement graph. So the rule goes where every command is
already checked: paste, cut, duplicate, correction and the span ops all gate on
problems, so one rule covers them all.

Why it looked like a reasonable thing to do is the other half. place-symbol
copied the symbol's name onto the instance it made, and that copy went stale on
the next rename: the symbol read "bg" in its tab while an instance of it still
read "symbol-18", which is its id from before it was named. One object under two
names, with nothing on screen to connect them.

So instances are no longer given a name at creation, and clip/node-label reads
the symbol's name through on every render. :name on an instance now means only
what a person typed, which is what tells two instances of one symbol apart --
"8625 left" and "8625 right" of one "face" -- so an authored name still wins and
read-through is the fallback. The label logic was duplicated across four call
sites with three different fallback orders; location.cljs already read through
and the others did not. They now share one function.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-10-03 03:43:07 -04:00
parent 0d49db793b
commit 2460dce0a5
7 changed files with 130 additions and 27 deletions

View file

@ -235,6 +235,51 @@
(is (= (get-in c [:symbols :outer :nodes])
(get-in (leaf/clip "c" (leaf/leaves "c" c)) [:symbols :outer :nodes]))))))
(deftest an-instance-is-labelled-by-what-it-places
(let [c (-> (nested)
(assoc-in [:symbols :inner :name] "mouth"))
[id n] (first (get-in c [:symbols :outer :nodes]))]
(testing "placing does not copy the symbol's name onto the node"
(is (nil? (:name n))
"a cached name is what goes stale; there is nothing to go stale"))
(testing "so the label follows the symbol, including a later rename"
(is (= "mouth" (clip/node-label c id n)))
(is (= "jaw" (clip/node-label (assoc-in c [:symbols :inner :name] "jaw") id n))
"renaming the symbol renames every row that shows an instance of it"))
(testing "an unnamed symbol falls back to its id, not to the node's uuid"
(is (= "inner" (clip/node-label (update-in c [:symbols :inner] dissoc :name)
id n))))
(testing "but a name somebody typed on the instance still wins"
;; Two instances of one symbol are told apart only by this.
(is (= "left eye" (clip/node-label c id (assoc n :name "left eye")))))
(testing "and a node that places nothing is labelled by its own name or id"
(is (= "lid" (clip/node-label c :lid {:id :lid :kind :rect :name "lid"})))
(is (= "lid" (clip/node-label c :lid {:id :lid :kind :rect}))))))
(deftest a-cycle-is-a-problem-and-not-only-a-refusal
;; `place-symbol` and `ui/drag` refuse to MAKE one; this is the document being
;; asked whether it already has one, which is the question every other command
;; — paste above all — gets to ask by calling `clip/problems`.
(let [c (nested)
instance (fn [c host src]
(assoc-in c [:symbols host :nodes :loop]
{:id :loop :kind :instance :z "z" :parent nil
:span [0 10] :time {:mode :map :at 0 :rate 1}
:source {:symbol src}}))]
(testing "a symbol placed inside itself"
(is (= ["symbol :inner instance :loop places :inner, which is itself"]
(clip/problems (instance c :inner :inner)))))
(testing "a symbol placed inside something it already places"
;; Both edges of :outer -> :inner -> :outer close the loop, so both are
;; named: either one is a fair thing to undo.
(let [ps (clip/problems (instance c :inner :outer))]
(is (= 2 (count ps)))
(is (some #{"symbol :inner instance :loop places :outer, which contains it"} ps))
(is (some #(re-find #"^symbol :outer instance .* places :inner, which contains it$" %)
ps))))
(testing "and an instance that closes no loop is still fine"
(is (empty? (clip/problems (instance c :loose :inner)))))))
(deftest a-new-symbol-is-empty-and-placed-where-it-was-asked-for
(let [c (nested)
u #uuid "00000000-0000-4000-8000-000000000001"