Serve the document from a Django backend, split into three tiers
Step 9. The tier split was the work; Django was the easy half.
Tier 1 — the authored scene — is the document, and it is addressed as
independently versioned leaves rather than saved whole, so one vertex drag
cannot clobber a collaborator's keying. `domain/leaf` is the document as
path -> value; `domain/wire` puts it on the wire as transit, because JSON
has neither integer map keys nor keywords and a save would quietly turn
`{0 v}` into `{"0" v}`.
Tier 2 — the dense channel blocks — is content-addressed by a hash over
every input, with the detector version inside every key through the
analysis the block descriptor names. `flow/address`'s `block-knobs` is the
invalidation table, and `address-test` does not trust it: it re-freezes the
take once per knob and asserts the biconditional, that a block's bytes
changed if and only if its key changed. That found `brow-pos` not depending
on `contour-avg` — the brow ring is smoothed, the raise is not.
Tier 3 — frames and audio — is served by the hash of its bytes out of the
same store. A manifest now names frames and carries a URL for each, so the
frame layout stopped being a shared secret between a shell script and a
ClojureScript namespace, and the `?v=` cache-buster went with it: a blob's
name is the hash of its contents, so a stale copy is not a thing that can
happen. The synthetic take's `audio.wav` moved to `static/arthur/` — an
asset the project owns, not an extraction that churns.
The server verifies rather than trusting a name it was handed: it
recomputes every key from the descriptor stored beside it, refuses an
analysis that declares no detector version, and refuses a document naming
blocks it does not hold. It hashes the descriptor TEXT, because JS prints
an integral double as `1` and Python as `1.0`, and a scheme where both ends
re-render the numbers disagrees on the first parameter that happens to be
whole.
Two loose ends from step 8 closed on the way. `pack` no longer takes a
`(track, frame)` predicate whose call sites each re-derived a feature from
an index — every track names the feature it follows, which deleted five
hand-maintained mappings. And `:dev-http` is gone: Django serves the page,
shadow-cljs only builds into the staticfiles tree.
227 CLJS tests, 31 Django tests, green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
b6517f837a
commit
9cd5243983
61 changed files with 4694 additions and 269 deletions
|
|
@ -1,5 +1,7 @@
|
|||
// Drives a real Chrome at the running dev server and checks that the frozen
|
||||
// take is a MOVING MOUTH on a canvas.
|
||||
// Drives a real Chrome at the running server and checks two things that no
|
||||
// assertion in cljs.test can: that the frozen take is a MOVING MOUTH on a canvas,
|
||||
// and that a document which has been through the server comes back as the same
|
||||
// picture.
|
||||
//
|
||||
// This exists because port-plan step 5 is the first step whose done-criterion is
|
||||
// a picture, and a picture cannot be asserted from cljs.test. A take that
|
||||
|
|
@ -11,10 +13,13 @@
|
|||
// nothing: `node --experimental-websocket` has a global WebSocket and
|
||||
// `--headless=new --remote-debugging-port=N` is the whole of the other side.
|
||||
//
|
||||
// cd frontend
|
||||
// mise exec -- npx shadow-cljs compile app
|
||||
// mise exec -- npx shadow-cljs watch app # or `server`, for :dev-http
|
||||
// mise exec -- node --experimental-websocket test/browser/take.mjs
|
||||
// Since step 9 the page is Django's, so the suite needs the backend up rather than
|
||||
// shadow-cljs's `:dev-http`, which is gone. ARTHUR_URL is unchanged because the
|
||||
// port is unchanged — 8778 was never 8777, which is still the old JS tool's.
|
||||
//
|
||||
// mise exec -- python manage.py runserver 8778 # from the REPO ROOT
|
||||
// cd frontend && mise exec -- npx shadow-cljs watch app
|
||||
// cd frontend && mise exec -- node --experimental-websocket test/browser/take.mjs
|
||||
//
|
||||
// Writes a PNG per sampled frame into test/browser/out/ so that "it drew
|
||||
// something" can be checked by eye as well as by pixel count.
|
||||
|
|
@ -198,6 +203,11 @@ const SEEK = (f) => `(() => {
|
|||
return el.value;
|
||||
})()`;
|
||||
|
||||
// Everything the page has to say about loading, saving and opening. Read off the
|
||||
// page rather than out of app-db, for the same reason the playhead is: what the
|
||||
// page SHOWS is what a person would check.
|
||||
const STATUS = `[...document.querySelectorAll('.load-status')].map((d) => d.textContent).join(' | ')`;
|
||||
|
||||
const CLICK = (label) => `(() => {
|
||||
const b = [...document.querySelectorAll('.transport button')]
|
||||
.find((b) => b.textContent.trim() === ${JSON.stringify(label)});
|
||||
|
|
@ -312,6 +322,71 @@ async function main() {
|
|||
`${new Set(during.map((p) => p.hash)).size} distinct of ${during.length}`);
|
||||
await page.shot('take-playing');
|
||||
|
||||
// --- it round-trips through the server ---
|
||||
//
|
||||
// THE DONE CRITERION of port-plan step 9, end to end: tier 1 over HTTP, tier 2
|
||||
// as content-addressed blocks, and the same frames on the far side. The ops are
|
||||
// compared frame for frame in arthur.domain.project-test, which is the strict
|
||||
// version of this; what only a browser can check is that the whole path — the
|
||||
// CSRF header, the block upload, the leaf write, the reload, the typed arrays
|
||||
// rebuilt out of base64 — draws the same pixels at the end of it.
|
||||
async function statusMatching(pattern, tries = 120) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const text = await page.eval(STATUS);
|
||||
if (pattern.test(text)) return text;
|
||||
await sleep(250);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function sample(frames) {
|
||||
const out = [];
|
||||
for (const f of frames) {
|
||||
await page.eval(SEEK(f));
|
||||
await sleep(120);
|
||||
out.push(await page.eval(PROBE));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const FRAMES = [0, 10, 28, 80, 160];
|
||||
check(await page.eval(CLICK('take')), 'back to the take, for the round trip');
|
||||
await sleep(150);
|
||||
const sent = await sample(FRAMES);
|
||||
|
||||
check(await page.eval(CLICK('save')), 'save is clickable');
|
||||
const saved = await statusMatching(/saved r\d+/);
|
||||
check(saved !== null, 'the document saves', saved ?? (await page.eval(STATUS)));
|
||||
// Eleven blocks the first time. The COUNT is not asserted — that is a fact
|
||||
// about the freeze, not about saving — but that some went up is.
|
||||
check(/· [1-9]\d* blocks?/.test(saved ?? ''), 'and its tier 2 went with it', saved ?? '');
|
||||
|
||||
// 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)));
|
||||
|
||||
check(await page.eval(CLICK('open')), 'open is clickable');
|
||||
const opened = await statusMatching(/opened /);
|
||||
check(opened !== null, 'the project opens', opened ?? (await page.eval(STATUS)));
|
||||
|
||||
const back = await sample(FRAMES);
|
||||
await page.shot('take-round-trip');
|
||||
check(back.every((p) => p.drawn > 200), 'the reopened take draws',
|
||||
back.map((p) => p.drawn).join(','));
|
||||
check(FRAMES.every((f, i) => sent[i].hash === back[i].hash),
|
||||
'every sampled frame is the same picture after the round trip',
|
||||
FRAMES.filter((f, i) => sent[i].hash !== back[i].hash).join(',') || 'all identical');
|
||||
check(back[1].toneSet.includes(MOUTH_DARK),
|
||||
'and the open mouth still has an interior on the far side');
|
||||
// The reopened clip is not one of the built-ins: this is the document that came
|
||||
// back from the server, not the one that was in the page all along.
|
||||
check(!back[0].scene.includes('take'), 'the picture is the reopened document',
|
||||
JSON.stringify(back[0].scene));
|
||||
|
||||
check(page.logs.length === 0, 'no errors on the console',
|
||||
page.logs.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue