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>
2026-09-28 01:11:41 -04:00
|
|
|
// 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.
|
2026-09-27 19:01:39 -04:00
|
|
|
//
|
|
|
|
|
// 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
|
|
|
|
|
// resolves to the right numbers and draws nothing would pass every assertion in
|
|
|
|
|
// arthur.flow.freeze-test: a blank canvas under a perfectly correct transport is
|
|
|
|
|
// the bug class unit tests miss, and it has already happened once here.
|
|
|
|
|
//
|
|
|
|
|
// No dependencies, deliberately. Playwright is not installed and CDP needs
|
|
|
|
|
// nothing: `node --experimental-websocket` has a global WebSocket and
|
|
|
|
|
// `--headless=new --remote-debugging-port=N` is the whole of the other side.
|
|
|
|
|
//
|
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>
2026-09-28 01:11:41 -04:00
|
|
|
// 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
|
2026-09-27 19:01:39 -04:00
|
|
|
//
|
|
|
|
|
// 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.
|
|
|
|
|
|
|
|
|
|
import { spawn } from 'node:child_process';
|
|
|
|
|
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
|
|
|
import { mkdtempSync } from 'node:fs';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join, dirname } from 'node:path';
|
|
|
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
|
|
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
|
|
const OUT = join(HERE, 'out');
|
|
|
|
|
const URL_ = process.env.ARTHUR_URL ?? 'http://localhost:8778/index.html';
|
|
|
|
|
const PORT = 9333;
|
|
|
|
|
const CHROME = process.env.CHROME ??
|
|
|
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
|
|
|
|
|
|
|
|
|
// The palette's background, from domain/palette. A pixel of this colour is
|
|
|
|
|
// nothing drawn, and every check below is a count of pixels that are not it.
|
|
|
|
|
const BG = [0x12, 0x14, 0x1c];
|
|
|
|
|
|
2026-09-27 22:44:16 -04:00
|
|
|
// :mouth-dark, from domain/palette, packed the way PROBE packs a tone. It is the
|
|
|
|
|
// mouth INTERIOR's colour and nothing else on the stage carries it, so its
|
|
|
|
|
// presence is the whole of "the mouth is open" without needing to know where the
|
|
|
|
|
// mouth is. A whole-canvas tone COUNT cannot answer that any more: step 7 put
|
|
|
|
|
// eyes, brows, an iris, a pupil and teeth on the same stage, so every frame has
|
|
|
|
|
// several tones whatever the mouth is doing.
|
|
|
|
|
const MOUTH_DARK = (0x24 << 16) | (0x16 << 8) | 0x1a;
|
|
|
|
|
|
2026-09-27 19:01:39 -04:00
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
|
|
|
|
|
|
let failures = 0;
|
|
|
|
|
function check(ok, label, detail = '') {
|
|
|
|
|
console.log(`${ok ? ' ok ' : ' FAIL'} ${label}${detail ? ` — ${detail}` : ''}`);
|
|
|
|
|
if (!ok) failures++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// the CDP connection
|
|
|
|
|
|
|
|
|
|
async function connect() {
|
|
|
|
|
const profile = mkdtempSync(join(tmpdir(), 'arthur-chrome-'));
|
|
|
|
|
const chrome = spawn(CHROME, [
|
|
|
|
|
'--headless=new',
|
|
|
|
|
`--remote-debugging-port=${PORT}`,
|
|
|
|
|
`--user-data-dir=${profile}`,
|
|
|
|
|
// The take plays against audio.wav, and the clock IS the audio element, so
|
|
|
|
|
// without these the frame never advances and "plays back" cannot be checked
|
|
|
|
|
// at all — the failure would look like a broken rAF loop.
|
|
|
|
|
'--autoplay-policy=no-user-gesture-required',
|
|
|
|
|
'--mute-audio',
|
|
|
|
|
'--no-first-run',
|
|
|
|
|
'--no-default-browser-check',
|
|
|
|
|
'--disable-gpu',
|
|
|
|
|
'--window-size=1200,900',
|
|
|
|
|
URL_,
|
|
|
|
|
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
|
|
|
chrome.stderr.on('data', () => {});
|
|
|
|
|
|
|
|
|
|
let wsUrl = null;
|
|
|
|
|
for (let i = 0; i < 100 && !wsUrl; i++) {
|
|
|
|
|
await sleep(100);
|
|
|
|
|
try {
|
|
|
|
|
const targets = await fetch(`http://127.0.0.1:${PORT}/json/list`).then((r) => r.json());
|
|
|
|
|
wsUrl = targets.find((t) => t.type === 'page' && t.url.includes('index.html'))
|
|
|
|
|
?.webSocketDebuggerUrl;
|
|
|
|
|
} catch { /* not up yet */ }
|
|
|
|
|
}
|
|
|
|
|
if (!wsUrl) throw new Error(`Chrome never offered a page target on ${PORT}`);
|
|
|
|
|
|
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
|
|
|
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
|
|
|
|
|
|
|
|
|
let id = 0;
|
|
|
|
|
const pending = new Map();
|
|
|
|
|
const logs = [];
|
|
|
|
|
ws.onmessage = (ev) => {
|
|
|
|
|
const msg = JSON.parse(ev.data);
|
|
|
|
|
if (msg.id && pending.has(msg.id)) {
|
|
|
|
|
const { res, rej } = pending.get(msg.id);
|
|
|
|
|
pending.delete(msg.id);
|
|
|
|
|
msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result);
|
|
|
|
|
} else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
|
|
|
|
|
logs.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
|
|
|
|
|
} else if (msg.method === 'Runtime.exceptionThrown') {
|
|
|
|
|
logs.push(msg.params.exceptionDetails.text + ' ' +
|
|
|
|
|
(msg.params.exceptionDetails.exception?.description ?? ''));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
const send = (method, params = {}) =>
|
|
|
|
|
new Promise((res, rej) => {
|
|
|
|
|
const n = ++id;
|
|
|
|
|
pending.set(n, { res, rej });
|
|
|
|
|
ws.send(JSON.stringify({ id: n, method, params }));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await send('Runtime.enable');
|
|
|
|
|
await send('Page.enable');
|
|
|
|
|
return {
|
|
|
|
|
send, logs,
|
|
|
|
|
async eval(expr) {
|
|
|
|
|
const r = await this.send('Runtime.evaluate', {
|
|
|
|
|
expression: expr, awaitPromise: true, returnByValue: true,
|
|
|
|
|
});
|
|
|
|
|
if (r.exceptionDetails) {
|
|
|
|
|
throw new Error(r.exceptionDetails.exception?.description ??
|
|
|
|
|
r.exceptionDetails.text);
|
|
|
|
|
}
|
|
|
|
|
return r.result.value;
|
|
|
|
|
},
|
|
|
|
|
async shot(name) {
|
|
|
|
|
const r = await this.send('Page.captureScreenshot', { format: 'png' });
|
|
|
|
|
writeFileSync(join(OUT, `${name}.png`), Buffer.from(r.data, 'base64'));
|
|
|
|
|
},
|
2026-09-27 22:44:16 -04:00
|
|
|
// Teardown must not be able to fail the run. `chrome.kill()` returns as soon
|
|
|
|
|
// as the signal is sent, so Chrome is often still writing its profile when
|
|
|
|
|
// rmSync walks it, and macOS then throws ENOTEMPTY on a directory that
|
|
|
|
|
// reappeared mid-delete. That throw used to propagate out of main's `finally`
|
|
|
|
|
// and past the summary line, so the suite exited 2 and printed no verdict
|
|
|
|
|
// whether it had passed or failed — which is how a stale assertion sat here
|
|
|
|
|
// unnoticed. A leaked temp profile is worth less than a truthful exit code.
|
|
|
|
|
close() {
|
|
|
|
|
ws.close();
|
|
|
|
|
chrome.kill();
|
|
|
|
|
try {
|
|
|
|
|
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.log(` note could not remove the temp profile — ${e.code ?? e.message}`);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-09-27 19:01:39 -04:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// what we ask the page
|
|
|
|
|
//
|
|
|
|
|
// Every probe reads the canvas's own pixels rather than a screenshot: the CSS
|
|
|
|
|
// scales the stage up by 2 with image-rendering:pixelated, so a screenshot is
|
|
|
|
|
// four pixels per raster pixel and is for looking at, not for counting.
|
|
|
|
|
|
|
|
|
|
const PROBE = `(() => {
|
|
|
|
|
const c = document.querySelector('canvas.stage');
|
|
|
|
|
if (!c) return null;
|
|
|
|
|
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
|
|
|
|
|
const bg = [${BG.join(',')}];
|
|
|
|
|
let drawn = 0;
|
|
|
|
|
const tones = new Set();
|
|
|
|
|
let cx = 0, cy = 0;
|
|
|
|
|
for (let i = 0; i < d.length; i += 4) {
|
|
|
|
|
if (d[i] === bg[0] && d[i+1] === bg[1] && d[i+2] === bg[2]) continue;
|
|
|
|
|
drawn++;
|
|
|
|
|
tones.add((d[i] << 16) | (d[i+1] << 8) | d[i+2]);
|
|
|
|
|
const p = i / 4;
|
|
|
|
|
cx += p % c.width; cy += Math.floor(p / c.width);
|
|
|
|
|
}
|
|
|
|
|
// A cheap 32-bit hash of the whole buffer: two frames with the same drawn
|
|
|
|
|
// count can still be different pictures, and "the mouth moved" is a question
|
|
|
|
|
// about the picture.
|
|
|
|
|
let h = 2166136261;
|
|
|
|
|
for (let i = 0; i < d.length; i += 4) { h ^= d[i] + d[i+1] * 31 + d[i+2] * 131; h = Math.imul(h, 16777619); }
|
|
|
|
|
return {
|
2026-09-27 22:44:16 -04:00
|
|
|
w: c.width, h: c.height, drawn, tones: [...tones].length, toneSet: [...tones],
|
2026-09-27 19:01:39 -04:00
|
|
|
cx: drawn ? cx / drawn : null, cy: drawn ? cy / drawn : null,
|
|
|
|
|
hash: h >>> 0,
|
|
|
|
|
frame: document.querySelector('.readout span')?.textContent ?? '',
|
|
|
|
|
scene: [...document.querySelectorAll('.transport .row button')]
|
|
|
|
|
.filter((b) => b.classList.contains('on')).map((b) => b.textContent),
|
|
|
|
|
};
|
|
|
|
|
})()`;
|
|
|
|
|
|
|
|
|
|
const SEEK = (f) => `(() => {
|
|
|
|
|
const el = document.querySelector('input.scrub');
|
|
|
|
|
// React installs its own value setter on the element, so assigning .value
|
|
|
|
|
// directly updates the DOM and not React's idea of it, and onChange never
|
|
|
|
|
// fires. The prototype-level setter plus a bubbling 'input' event is what
|
|
|
|
|
// React's synthetic onChange actually listens for.
|
|
|
|
|
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
|
|
|
|
|
set.call(el, '${f}');
|
|
|
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
|
return el.value;
|
|
|
|
|
})()`;
|
|
|
|
|
|
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>
2026-09-28 01:11:41 -04:00
|
|
|
// 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(' | ')`;
|
|
|
|
|
|
2026-09-27 19:01:39 -04:00
|
|
|
const CLICK = (label) => `(() => {
|
|
|
|
|
const b = [...document.querySelectorAll('.transport button')]
|
|
|
|
|
.find((b) => b.textContent.trim() === ${JSON.stringify(label)});
|
|
|
|
|
if (!b) return false;
|
|
|
|
|
b.click();
|
|
|
|
|
return true;
|
|
|
|
|
})()`;
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async function main() {
|
|
|
|
|
rmSync(OUT, { recursive: true, force: true });
|
|
|
|
|
mkdirSync(OUT, { recursive: true });
|
|
|
|
|
|
|
|
|
|
const page = await connect();
|
|
|
|
|
try {
|
|
|
|
|
// Mounted, and painting. Polled rather than waited on a fixed delay: the
|
|
|
|
|
// canvas :ref fires after the loop starts, so there genuinely is a window in
|
|
|
|
|
// which the page is up and the canvas is blank.
|
|
|
|
|
let probe = null;
|
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
|
|
|
probe = await page.eval(PROBE);
|
|
|
|
|
if (probe && probe.drawn > 0) break;
|
|
|
|
|
await sleep(100);
|
|
|
|
|
}
|
|
|
|
|
if (!probe) throw new Error('no canvas.stage on the page — is `shadow-cljs watch app` running?');
|
|
|
|
|
|
|
|
|
|
console.log(`\ncanvas ${probe.w}x${probe.h}, scene ${JSON.stringify(probe.scene)}`);
|
|
|
|
|
|
|
|
|
|
check(probe.scene.includes('take'), 'the take is the scene that opens');
|
|
|
|
|
check(probe.w === 320 && probe.h === 200, 'the canvas is the stage size',
|
|
|
|
|
`${probe.w}x${probe.h}`);
|
|
|
|
|
check(probe.drawn > 200, 'the first frame is not blank', `${probe.drawn} px drawn`);
|
|
|
|
|
|
|
|
|
|
// --- it is a mouth: two tones, one inside the other ---
|
|
|
|
|
//
|
|
|
|
|
// The three-layer structure is what makes a flat shape read as an opening
|
2026-09-27 22:44:16 -04:00
|
|
|
// rather than a blob, so the interior being its OWN tone is the check that
|
|
|
|
|
// this is a mouth and not one polygon. Asked of :mouth-dark specifically
|
|
|
|
|
// rather than of the tone count: the count is now dominated by the eyes and
|
|
|
|
|
// brows, which are on the stage whatever the mouth is doing.
|
2026-09-27 19:01:39 -04:00
|
|
|
await page.eval(SEEK(10)); // beat 1: wide open
|
|
|
|
|
await sleep(120);
|
|
|
|
|
const open = await page.eval(PROBE);
|
|
|
|
|
await page.shot('take-open');
|
2026-09-27 22:44:16 -04:00
|
|
|
check(open.toneSet.includes(MOUTH_DARK),
|
|
|
|
|
'an open mouth draws an outline and an interior',
|
|
|
|
|
`${open.tones} tones, interior ${open.toneSet.includes(MOUTH_DARK) ? 'present' : 'MISSING'}`);
|
2026-09-27 19:01:39 -04:00
|
|
|
|
|
|
|
|
await page.eval(SEEK(28)); // beat 3: shut
|
|
|
|
|
await sleep(120);
|
|
|
|
|
const shut = await page.eval(PROBE);
|
|
|
|
|
await page.shot('take-shut');
|
2026-09-27 22:44:16 -04:00
|
|
|
check(!shut.toneSet.includes(MOUTH_DARK), 'a shut mouth draws the outline alone',
|
|
|
|
|
`${shut.tones} tones, interior ${shut.toneSet.includes(MOUTH_DARK) ? 'STILL DRAWN' : 'gone'}`);
|
2026-09-27 19:01:39 -04:00
|
|
|
check(open.hash !== shut.hash, 'the open and the shut mouth are different pictures');
|
|
|
|
|
check(open.drawn > shut.drawn, 'the open mouth covers more of the stage',
|
|
|
|
|
`${open.drawn} vs ${shut.drawn} px`);
|
|
|
|
|
|
|
|
|
|
// --- it moves under the head, and the head is a channel ---
|
|
|
|
|
const filmed = [];
|
|
|
|
|
for (const f of [0, 40, 80, 120, 160, 200]) {
|
|
|
|
|
await page.eval(SEEK(f));
|
|
|
|
|
await sleep(120);
|
|
|
|
|
filmed.push(await page.eval(PROBE));
|
|
|
|
|
}
|
|
|
|
|
check(new Set(filmed.map((p) => p.hash)).size === filmed.length,
|
|
|
|
|
'every sampled frame is a different picture');
|
|
|
|
|
const xs = filmed.map((p) => p.cx);
|
|
|
|
|
check(Math.max(...xs) - Math.min(...xs) > 8,
|
|
|
|
|
'as filmed, the head carries the mouth across the stage',
|
|
|
|
|
`centroid x spans ${(Math.max(...xs) - Math.min(...xs)).toFixed(1)} px`);
|
|
|
|
|
|
|
|
|
|
check(await page.eval(CLICK('locked')), 'the locked take is selectable');
|
|
|
|
|
await sleep(200);
|
|
|
|
|
const locked = [];
|
|
|
|
|
for (const f of [0, 40, 80, 120, 160, 200]) {
|
|
|
|
|
await page.eval(SEEK(f));
|
|
|
|
|
await sleep(120);
|
|
|
|
|
locked.push(await page.eval(PROBE));
|
|
|
|
|
}
|
|
|
|
|
await page.shot('take-locked');
|
|
|
|
|
const lxs = locked.map((p) => p.cx);
|
|
|
|
|
check(locked.every((p) => p.drawn > 200), 'the locked take draws too');
|
|
|
|
|
// The same blocks with one node's channels written differently: the mouth
|
|
|
|
|
// still articulates, and the head no longer wanders. That is the claim
|
|
|
|
|
// "stabilisation is a channel, not a mode", by eye.
|
|
|
|
|
check(Math.max(...lxs) - Math.min(...lxs) < (Math.max(...xs) - Math.min(...xs)) / 2,
|
|
|
|
|
'locked, the head holds still while the mouth still articulates',
|
|
|
|
|
`centroid x spans ${(Math.max(...lxs) - Math.min(...lxs)).toFixed(1)} px`);
|
|
|
|
|
check(new Set(locked.map((p) => p.hash)).size > 3,
|
|
|
|
|
'and it is still a performance, not a still frame');
|
|
|
|
|
|
|
|
|
|
// --- it PLAYS, against the audio clock ---
|
|
|
|
|
check(await page.eval(CLICK('take')), 'back to the take');
|
|
|
|
|
await sleep(150);
|
|
|
|
|
await page.eval(SEEK(0));
|
|
|
|
|
await sleep(150);
|
|
|
|
|
check(await page.eval(CLICK('play')), 'play is clickable');
|
|
|
|
|
const during = [];
|
|
|
|
|
for (let i = 0; i < 8; i++) { await sleep(180); during.push(await page.eval(PROBE)); }
|
|
|
|
|
await page.eval(CLICK('pause'));
|
|
|
|
|
// The readout is "frame 12 / 229", so the first run of digits is the
|
|
|
|
|
// playhead. Parsed rather than reached for in app-db on purpose: what the
|
|
|
|
|
// page SHOWS is what a person would check, and the readout agreeing with the
|
|
|
|
|
// picture is half of what the transport is for.
|
|
|
|
|
const nums = during.map((p) => parseInt((p.frame.match(/\d+/) ?? [NaN])[0], 10));
|
|
|
|
|
check(nums[nums.length - 1] > nums[0] + 5, 'the playhead advances under the clock',
|
|
|
|
|
`frame ${nums[0]} -> ${nums[nums.length - 1]}`);
|
|
|
|
|
check(new Set(during.map((p) => p.hash)).size > 4,
|
|
|
|
|
'and the picture changes while it runs',
|
|
|
|
|
`${new Set(during.map((p) => p.hash)).size} distinct of ${during.length}`);
|
|
|
|
|
await page.shot('take-playing');
|
|
|
|
|
|
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>
2026-09-28 01:11:41 -04:00
|
|
|
// --- 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));
|
|
|
|
|
|
2026-09-27 19:01:39 -04:00
|
|
|
check(page.logs.length === 0, 'no errors on the console',
|
|
|
|
|
page.logs.slice(0, 3).join(' | '));
|
|
|
|
|
} finally {
|
|
|
|
|
page.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`\n${failures ? `${failures} FAILED` : 'all checks passed'}` +
|
|
|
|
|
` — screenshots in test/browser/out/\n`);
|
|
|
|
|
process.exit(failures ? 1 : 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main().catch((e) => { console.error(e); process.exit(2); });
|