Projects live at URLs, have owners, and are edited together live
A project is only ever at /p/<id>/<slug>; / is the index of the projects
you own or edit. Every project has an owner, who can name editors;
anyone with the link can view. Every edit saves itself, one request in
flight at a time, as a patch of the leaves that changed, and a websocket
(channels + daphne) carries presence and each committed write to
everyone else in the project. The first write to a leaf wins, and the
loser is told.
Undo is per person: a step undoes only if the leaves it touched still
hold what it left, so it never takes a collaborator's work with it.
Named snapshots replace saving, and restore as an ordinary write.
An empty symbol now survives the leaf round trip with `:nodes {}`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c17ee138f2
commit
6a53adb5e0
31 changed files with 1918 additions and 158 deletions
63
frontend/test/arthur/domain/history_test.cljs
Normal file
63
frontend/test/arthur/domain/history_test.cljs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
(ns arthur.domain.history-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.domain.history :as history]))
|
||||
|
||||
(def empty-doc {"t" 1})
|
||||
|
||||
(deftest undo-and-redo-walk-your-own-steps
|
||||
(let [made (assoc empty-doc "b" :shape)
|
||||
moved (assoc made "b" :moved)
|
||||
h (-> nil
|
||||
(history/record empty-doc made 0)
|
||||
(history/record made moved 5000))
|
||||
one (history/undo h moved)
|
||||
two (history/undo (:history one) (:leaves one))]
|
||||
(is (= made (:leaves one)))
|
||||
(is (= empty-doc (:leaves two)))
|
||||
(is (nil? (history/undo (:history two) (:leaves two))))
|
||||
(is (= made (:leaves (history/redo (:history two) (:leaves two)))))))
|
||||
|
||||
(deftest a-drag-is-one-step
|
||||
(let [h (reduce (fn [h [x t]] (history/record h {"v" (dec x)} {"v" x} t))
|
||||
nil [[1 0] [2 100] [3 200]])]
|
||||
(is (= 1 (count (:done h))))
|
||||
(is (= {"v" 0} (:leaves (history/undo h {"v" 3}))))))
|
||||
|
||||
(deftest their-write-between-two-of-mine-keeps-them-apart
|
||||
(let [h (-> nil
|
||||
(history/record {"fps" 30} {"fps" 12} 0)
|
||||
;; theirs lands: 12 -> 9, not recorded
|
||||
(history/record {"fps" 9} {"fps" 15} 300))]
|
||||
(is (= 2 (count (:done h))))
|
||||
(is (= {"fps" 9} (:leaves (history/undo h {"fps" 15}))))))
|
||||
|
||||
(deftest undo-never-takes-somebody-elses-work
|
||||
(testing "I make b; they edit it; I edit it; I undo twice"
|
||||
(let [made {"b" :shape}
|
||||
theirs {"b" :their-edit}
|
||||
mine {"b" :my-edit}
|
||||
h (-> nil
|
||||
(history/record {} made 0)
|
||||
;; their edit arrives as a remote write: not recorded
|
||||
(history/record theirs mine 5000))
|
||||
one (history/undo h mine)
|
||||
two (history/undo (:history one) (:leaves one))]
|
||||
(is (= theirs (:leaves one)) "my edit comes off, theirs is what is left")
|
||||
(is (:blocked two) "removing b would remove their edit, so it is refused")
|
||||
(is (empty? (:done (:history two))) "and the refused step is dropped"))))
|
||||
|
||||
(deftest a-new-edit-clears-redo
|
||||
(let [h (history/record nil {} {"a" 1} 0)
|
||||
u (history/undo h {"a" 1})
|
||||
h (history/record (:history u) (:leaves u) {"c" 1} 9000)]
|
||||
(is (nil? (history/redo h {"c" 1})))))
|
||||
|
||||
(deftest a-step-says-what-it-was
|
||||
(let [node "clip/u/symbol/main/node/b"
|
||||
pts "clip/u/symbol/main/channel/b/geom.pts"
|
||||
made {node {:id :b :name "shape 3"} pts :p}]
|
||||
(is (= "add shape 3" (history/label {} made [node pts])))
|
||||
(is (= "edit shape 3" (history/label made (assoc made pts :q) [pts])))
|
||||
(is (= "delete shape 3" (history/label made {} [node pts])))
|
||||
(is (= "project settings" (history/label {} {"clip/u/timing" {:fps 9}} ["clip/u/timing"])))
|
||||
(is (= ["add shape 3"] (:done (history/steps (history/record nil {} made 0)))))))
|
||||
|
|
@ -30,6 +30,12 @@
|
|||
(testing label
|
||||
(is (= c (leaf/clip :c1 (leaf/leaves :c1 c)))))))
|
||||
|
||||
(deftest an-empty-symbol-comes-back-a-symbol
|
||||
;; No node leaves, and still `:nodes {}`: nil there is what `symbol/nodes-of`
|
||||
;; refuses, so a saved blank document would not open.
|
||||
(is (= (get-in (clip/blank) [:symbols :main])
|
||||
(get-in (leaf/clip :c1 (leaf/leaves :c1 (clip/blank))) [:symbols :main]))))
|
||||
|
||||
(deftest the-leaves-are-the-paths-the-sync-design-names
|
||||
(let [ls (leaf/leaves :c7 @take/clip)]
|
||||
(is (contains? ls "clip/c7/timing"))
|
||||
|
|
|
|||
|
|
@ -303,10 +303,27 @@ async function main() {
|
|||
if (!probe) throw new Error('no canvas.stage on the page — ' +
|
||||
(page.logs.slice(0, 3).join(' | ') || 'is `shadow-cljs watch app` running?'));
|
||||
|
||||
console.log(`\ncanvas ${probe.w}x${probe.h}, ${probe.doc}`);
|
||||
// `/` is the index, and a project is only ever at its own address, so the
|
||||
// suite signs up, makes one, and opens it — the page's own navigation, so
|
||||
// this CDP session and its console stay attached.
|
||||
const home = await page.eval(`(async () => {
|
||||
const post = (url, body) => fetch(url, {method: 'POST', body: JSON.stringify(body),
|
||||
headers: {'Content-Type': 'application/json',
|
||||
'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)[1]}}).then(r => r.json());
|
||||
await post('/api/signup', {username: 'suite-' + Date.now().toString(36), password: 'password1'});
|
||||
const made = await post('/api/projects', {name: 'untitled'});
|
||||
arthur.events.collab.navigate_BANG_(arthur.events.collab.project_path(made.id, made.name));
|
||||
return location.pathname; })()`);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
probe = await page.eval(PROBE);
|
||||
if (/opened/.test(probe.doc)) break;
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
check(probe.drawn === 0 && /new document/.test(probe.doc),
|
||||
'the app opens on a blank document',
|
||||
console.log(`\ncanvas ${probe.w}x${probe.h}, ${probe.doc} at ${home}`);
|
||||
|
||||
check(probe.drawn === 0 && /opened untitled/.test(probe.doc),
|
||||
'a new project opens on a blank document',
|
||||
`${probe.drawn} px drawn — ${probe.doc}`);
|
||||
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
|
||||
`${probe.w}x${probe.h}`);
|
||||
|
|
@ -432,9 +449,10 @@ async function main() {
|
|||
await sleep(150);
|
||||
const sent = await sample(FRAMES);
|
||||
|
||||
check(await page.eval(CLICK('save')), 'save is clickable');
|
||||
// There is no save: every edit saves itself, and a built-in example opened
|
||||
// in a project becomes a project of its own at once.
|
||||
const saved = await statusMatching(/saved r\d+/);
|
||||
check(saved !== null, 'the document saves', saved ?? (await page.eval(STATUS)));
|
||||
check(saved !== null, 'the take becomes a saved project by itself', saved ?? (await page.eval(STATUS)));
|
||||
// Upload count may be zero when the content-addressed blocks already exist
|
||||
// on this server. The saved clip must still reference them.
|
||||
const savedBlocks = await page.eval(`(async () => {
|
||||
|
|
@ -445,14 +463,16 @@ async function main() {
|
|||
check(savedBlocks > 0, 'the saved clip references its tier 2 blocks',
|
||||
`${savedBlocks} blocks`);
|
||||
|
||||
// Again, unchanged. Content addressing means the second save uploads nothing
|
||||
// and rewrites nothing: this is the assertion that the keys are stable across
|
||||
// two independent freezes of the same take, and that an unchanged leaf keeps
|
||||
// its version rather than being rewritten.
|
||||
check(await page.eval(CLICK('save')), 'save is clickable again');
|
||||
const resaved = await statusMatching(/saved r\d+ · 0 leaves · 0 blocks/);
|
||||
check(resaved !== null, 'saving an unchanged document writes nothing',
|
||||
resaved ?? (await page.eval(STATUS)));
|
||||
// Unchanged, nothing is written: the project's seq stands still. This is
|
||||
// the assertion that the keys are stable and a clean document is clean.
|
||||
const seqOf = `(async () => {
|
||||
const id = location.pathname.split('/')[2];
|
||||
return (await fetch('/api/projects/' + id).then(r => r.json())).seq; })()`;
|
||||
const seqBefore = await page.eval(seqOf);
|
||||
await sleep(800);
|
||||
const seqAfter = await page.eval(seqOf);
|
||||
check(seqBefore === seqAfter, 'an unchanged document writes nothing',
|
||||
`seq ${seqBefore} -> ${seqAfter}`);
|
||||
|
||||
check(await fromMenu(MENU_PICK_NEWEST), 'open is clickable');
|
||||
const opened = await statusMatching(/opened /);
|
||||
|
|
@ -550,9 +570,8 @@ async function main() {
|
|||
.find(el => el.textContent.startsWith('key 8 → 16'));
|
||||
return label?.querySelector('select')?.value === 'linear';
|
||||
})()`), 'the second drawing gap can be set to tween');
|
||||
check(await page.eval(CLICK('save')), 'the painted document can be saved');
|
||||
check((await statusMatching(/saved r\d+ · \d+ leaves/)) !== null,
|
||||
'the painted shape is saved', await page.eval(STATUS));
|
||||
check((await statusMatching(/saved r\d+/)) !== null,
|
||||
'the painted shape saves by itself', await page.eval(STATUS));
|
||||
check(await fromMenu(MENU_PICK_NEWEST), 'the painted document can be reopened');
|
||||
check((await statusMatching(/opened /)) !== null,
|
||||
'the painted shape is reopened', await page.eval(STATUS));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue