diff --git a/src/lib/coalesce.js b/src/lib/coalesce.js new file mode 100644 index 00000000..c63712e6 --- /dev/null +++ b/src/lib/coalesce.js @@ -0,0 +1,74 @@ +// R29 S2 — A CHANGE SIGNAL THAT CANNOT RUN PER STORE TICK. Imports NOTHING (a leaf, +// vitest-covered). +// +// `api.flow.onChange` / `api.game.onChange` / `api.peerVars.onChange` exist so a module +// toolbox can stop polling. The trap the plan wrote down: a handler that runs on every +// store tick and then reads the graph is NOT cheaper than polling — one bulk edit is +// dozens of flowGraphs writes, and edits ARRIVING from a peer are one message (one +// task) each, thirty of them inside a few milliseconds. So the seam coalesces to ONE +// FRAME: however many ticks land before the next frame, the handler runs once. A +// microtask was tried first and MEASURED insufficient — it folds a local burst but not +// thirty arriving messages (+30 handler calls for 30 edits). And because a frame never +// comes in a background tab, a timer races it: a module's logic must not stall because +// the window lost focus. Outside a browser (the unit tests) it falls back to a microtask. +// +// Svelte's subscribe also calls back SYNCHRONOUSLY with the current value; that is not +// a change, so it is swallowed. And a flush already queued when the subscriber is torn +// down must not run — a disabled module's handler firing once more is exactly the +// "dead code still acting" shape the teardown journal exists to prevent. + +/** the timer that stands in for a frame a hidden tab never paints */ +export const FRAME_FALLBACK_MS = 100; + +/** run `cb` once, on the next frame or after FRAME_FALLBACK_MS, whichever comes first + * @param {() => void} cb */ +export function nextFrame(cb) { + if (typeof requestAnimationFrame !== 'function') { + queueMicrotask(cb); + return; + } + let done = false; + const run = () => { + if (done) return; + done = true; + clearTimeout(timer); + cb(); + }; + const timer = setTimeout(run, FRAME_FALLBACK_MS); + requestAnimationFrame(run); +} + +/** + * Subscribe `fn` to every store in `stores`, coalesced: at most one call per burst. + * @param {{subscribe: (cb: (v: any) => void) => (() => void)}[]} stores + * @param {() => void} fn + * @param {(cb: () => void) => void} [schedule] defaults to `nextFrame` + * @returns {() => void} unsubscribe + */ +export function coalescedSubscribe(stores, fn, schedule) { + const later = schedule ?? nextFrame; + let live = true; + let queued = false; + let arming = true; + const flush = () => { + queued = false; + if (!live) return; + try { + fn(); + } catch (error) { + // a module's handler must never take a core store write down with it + console.warn('[module onChange] handler threw', error); + } + }; + const poke = () => { + if (arming || !live || queued) return; + queued = true; + later(flush); + }; + const offs = stores.map((s) => s.subscribe(poke)); + arming = false; + return () => { + live = false; + for (const off of offs) off(); + }; +} diff --git a/src/lib/flowGraphs.js b/src/lib/flowGraphs.js index ecb1f469..1c57807c 100644 --- a/src/lib/flowGraphs.js +++ b/src/lib/flowGraphs.js @@ -206,9 +206,12 @@ registerHistoryKind('flowgraph', (entry, state) => { /** * Record an undoable flow-node mutation. * op 'create'/'delete' take {nodes, edges} (serialized); op 'data' takes - * {items: [{id, before, after}]} of node-data patches. + * {items: [{id, before, after, graphId?}]} of node-data patches. An item's own + * `graphId` overrides the entry's (R29 S3: a module's group edit spans one graph per + * object, and it is still ONE undo step). `moduleId` attributes a module's write. * @param {{op: 'create'|'delete'|'data', graphId: string, nodes?: any[], - * edges?: any[], items?: {id: string, before: any, after: any}[]}} info + * edges?: any[], items?: {id: string, before: any, after: any, graphId?: string}[], + * moduleId?: string}} info */ export function recordFlowNodesEntry(info) { recordEntry({ kind: 'flownodes', ...info, before: 'before', after: 'after' }); @@ -220,10 +223,15 @@ registerHistoryKind('flownodes', (entry, state) => { const peer = get(peers); const graphId = entry.graphId; if (entry.op === 'data') { - for (const item of entry.items ?? []) { + // undo walks the items BACKWARDS: a batch that writes one node twice recorded the + // second item's `before` AFTER the first write, so only the reverse order lands on + // the first item's `before` last + const items = entry.items ?? []; + for (const item of undoing ? [...items].reverse() : items) { const data = undoing ? item.before : item.after; - updateFlowNodeData(item.id, data, graphId); - if (peer) peer.send({ type: 'nodedata', id: item.id, data, graphId }); + const gid = item.graphId ?? graphId; + updateFlowNodeData(item.id, data, gid); + if (peer) peer.send({ type: 'nodedata', id: item.id, data, graphId: gid }); } return true; } diff --git a/src/lib/flowLayout.js b/src/lib/flowLayout.js new file mode 100644 index 00000000..9659b26d --- /dev/null +++ b/src/lib/flowLayout.js @@ -0,0 +1,62 @@ +// R29 S1 — WHERE A BUILT GRAPH LANDS. Imports NOTHING (a leaf, vitest-covered). +// +// Anything that authors nodes on the user's behalf — a module recipe through +// `api.flow.freeRegion`, the HUD editor's action bindings — has to ask one question: +// "where can I put a block of nodes without landing on top of what is already there?" +// `gameRecipes` answered it once (below everything), `hudActions` answered it again +// (right of everything), and every module would have written a third copy. The rule +// lives HERE now and nowhere else. +// +// It is deliberately DETERMINISTIC and cheap: a pure function of the node positions, +// which are replicated, so two peers asking about the same graph get the same point. +// Node SIZE is an estimate — a serialized node does not reliably carry its measured +// box — and every card in the editor is `w-[150px]`, so width is known and height is +// the one guess (measured when the node happens to carry it). + +/** every flow card is `w-[150px]` */ +export const NODE_W = 150; +/** a typical card's height when the node carries no measured box */ +export const NODE_H = 150; +/** clearance between the existing graph and the new block */ +export const GAP_Y = 40; +export const GAP_X = 70; +/** where the first block of an EMPTY graph goes */ +export const MARGIN = 40; + +/** @param {any} n */ +function heightOf(n) { + const h = Number(n?.measured?.height ?? n?.height); + return Number.isFinite(h) && h > 0 ? h : NODE_H; +} + +/** + * A top-left point for a `w` x `h` block that overlaps nothing in `nodes`. + * + * `side: 'below'` (the default, the recipe rule): left-aligned with the graph's + * leftmost node, one gap under its lowest card. `side: 'right'` (the HUD bindings + * rule): one gap past the rightmost card, level with the top — with the empty graph + * read as a phantom card at x 0, which is what `hudActions` always did. + * + * Nothing is REJECTED: below/right of everything always has room, so `w`/`h` never + * change the answer today — they are part of the contract so a smarter packer can + * honour them without changing a caller. + * @param {any[]} nodes `{position: {x, y}}` (or `{x, y}`) records + * @param {{w?: number, h?: number, side?: 'below'|'right'}} [opts] + * @returns {{x: number, y: number, w: number, h: number}} + */ +export function freeRegion(nodes, opts = {}) { + const w = Math.max(0, Number(opts.w) || 0); + const h = Math.max(0, Number(opts.h) || 0); + const list = (Array.isArray(nodes) ? nodes : []).filter(Boolean); + const xOf = (/** @type {any} */ n) => Number(n.position?.x ?? n.x) || 0; + const yOf = (/** @type {any} */ n) => Number(n.position?.y ?? n.y) || 0; + if (opts.side === 'right') { + const right = list.reduce((max, n) => Math.max(max, xOf(n)), 0) + NODE_W + GAP_X; + const top = list.length ? Math.min(...list.map(yOf)) : MARGIN; + return { x: right, y: top, w, h }; + } + if (!list.length) return { x: MARGIN, y: MARGIN, w, h }; + const left = Math.min(...list.map(xOf)); + const bottom = Math.max(...list.map((n) => yOf(n) + heightOf(n))); + return { x: left, y: bottom + GAP_Y, w, h }; +} diff --git a/src/lib/hudActions.js b/src/lib/hudActions.js index 6c8bb3ae..5579f1c1 100644 --- a/src/lib/hudActions.js +++ b/src/lib/hudActions.js @@ -36,6 +36,8 @@ import { isInteractiveKind, isValuedKind } from './hudKinds'; // reaches nodesHandler/flowGraphs — the history family — so moduleSDK cannot import it; // the leaf is where both sides can meet, the moduleToolboxes rule) import { moduleHudActionList } from './moduleHudKinds'; +// R29 S1: a leaf (imports nothing) — the one placement rule, shared with api.flow.freeRegion +import { freeRegion } from './flowLayout'; /** The HUD node types that READ an element (a display binding), by element kind. */ // 21-E7.6: the PACK kinds map onto the SAME four display nodes rather than earning nodes @@ -454,8 +456,9 @@ export function addBinding(elementId, actionKey) { /** @type {any[]} */ const createdEdges = []; - // where to put them: past whatever is furthest right, in a column per binding - const baseX = nodes.reduce((max, n) => Math.max(max, Number(n.position?.x) || 0), 0) + 220; + // where to put them: past whatever is furthest right, in a column per binding (R29 S1: + // the rule is flowLayout's one copy now, shared with api.flow.freeRegion) + const baseX = freeRegion(nodes, { side: 'right' }).x; const baseY = 40 + bindingsFor(elementId).length * 150; if (action.role === 'value') { diff --git a/src/lib/moduleSDK.js b/src/lib/moduleSDK.js index 7ae128b5..c81f3631 100644 --- a/src/lib/moduleSDK.js +++ b/src/lib/moduleSDK.js @@ -4,7 +4,7 @@ import * as THREE from 'three'; import { writable, get } from 'svelte/store'; import { globalScene, objectsGroup, selectedObject, selectedObjects, globalCamera, isVRMode, isLocked } from '../stores/sceneStore'; import { peers, showToast, modulesOpen, userdata } from '../stores/appStore'; -import { syncedAnimations, flowGraphs, flowValues, allNodes, findNodeAnyGraph, SCENE_GRAPH } from '../stores/flowStore'; +import { syncedAnimations, flowGraphs, flowValues, flowTriggers, allNodes, findNodeAnyGraph, SCENE_GRAPH } from '../stores/flowStore'; import { customGeometryBuilders } from './customGeometries'; // A1: moduleNodeIO imports NOTHING, so a static edge to it closes no cycle import { @@ -32,8 +32,11 @@ import { // R3a: all three are LEAVES (svelte stores only — gameState/peerVars say so in their own // headers; nodesHandler reaches only flowStore + appStore), so none of these edges can // close the history cycle. flowGraphs/nodeCatalog are NOT leaves and stay primed below. -import { roundCutoff, roundUnderway, gameVar, setGameVar } from './gameState'; -import { setPeerVar, myPeerVar, leaderboardRows } from './peerVars'; +import { roundCutoff, roundUnderway, gameVar, setGameVar, gameState } from './gameState'; +import { setPeerVar, myPeerVar, leaderboardRows, peerVarsMine, peerVarsRemote } from './peerVars'; +// R29 S1/S2: both leaves import NOTHING, so neither edge can close a cycle +import { freeRegion as freeRegionIn } from './flowLayout'; +import { coalescedSubscribe } from './coalesce'; import { createFlowNode, createFlowEdge, serializeNode, serializeEdge, setNodeData as sendNodeData } from './nodesHandler'; import { APP_VERSION } from './version.js'; import { ndcFromClient } from './canvasRect'; @@ -256,6 +259,35 @@ function makeApi(moduleId, moduleName = moduleId) { const disposals = (moduleDisposals[moduleId] ??= []); /** record an undo thunk deactivateModule runs at teardown (A2) @param {() => void} fn */ const onDispose = (fn) => disposals.push(fn); + /** A value frozen for the undo stack, so a module mutating its patch object later + * cannot rewrite history. @param {any} v */ + const frozen = (v) => { + try { + return structuredClone(v); + } catch { + return v; + } + }; + /** One node-data write through the replicated `nodedata` path; returns the undo + * item (the patched keys' previous values) or null when no node has the id. + * @param {string} id @param {Record} patch */ + const writeNodeData = (id, patch) => { + const found = findNodeAnyGraph((n) => n.id === id); + if (!found) return null; + const after = frozen(patch && typeof patch === 'object' ? patch : {}); + /** @type {Record} */ + const before = {}; + for (const key of Object.keys(after)) before[key] = frozen(found.node.data?.[key]); + sendNodeData(id, patch && typeof patch === 'object' ? patch : {}, found.graphId); + return { id, graphId: found.graphId, before, after }; + }; + /** ONE `flownodes` data entry for these writes (a no-op until flowGraphs is primed, + * which it is long before a module's UI can be pressed). + * @param {{id: string, graphId: string, before: any, after: any}[]} items */ + const recordNodeData = (items) => { + if (!items.length) return; + flowGraphsRef?.recordFlowNodesEntry({ op: 'data', graphId: items[0].graphId, items, moduleId }); + }; /** input scopes this module still holds — released at teardown * @type {Set<'keys'|'locomotion'>} */ const claimedScopes = new Set(); @@ -965,6 +997,15 @@ function makeApi(moduleId, moduleName = moduleId) { * @param {string} name @param {number} value */ setVar(name, value) { setGameVar(name, value); + }, + /** R29 S2: `fn()` runs after the game singleton changes (state, round, a + * variable) — COALESCED, at most once per frame however many writes land, never per + * store tick; torn down with the module (or earlier, by calling what it returns). + * Read what you need inside it. @param {() => void} fn @returns {() => void} off */ + onChange(fn) { + const off = coalescedSubscribe([gameState], fn); + onDispose(off); + return off; } }, /** @@ -987,6 +1028,14 @@ function makeApi(moduleId, moduleName = moduleId) { * @param {{order?: 'desc'|'asc'}=} opts */ all(name, opts) { return leaderboardRows(name, opts); + }, + /** R29 S2: `fn()` runs after ANY peer's row changes (mine or a remote one) — + * coalesced to one call per frame, torn down with the module or by the returned + * `off`. @param {() => void} fn @returns {() => void} off */ + onChange(fn) { + const off = coalescedSubscribe([peerVarsMine, peerVarsRemote], fn); + onDispose(off); + return off; } }, /** @@ -997,16 +1046,55 @@ function makeApi(moduleId, moduleName = moduleId) { */ flow: { /** Every node (optionally one type) as plain snapshots: - * `{id, type, graphId, data}` — graphId 'scene' or the owner object's uuid. - * @param {string=} type @returns {any[]} */ + * `{id, type, graphId, x, y, data}` — graphId 'scene' or the owner object's uuid; + * x/y the node's position in its graph (R29 S1, read-only — move a node in the + * editor, never by writing these). @param {string=} type @returns {any[]} */ nodes(type) { const out = []; for (const n of allNodes()) { if (type && n.type !== type) continue; - out.push({ id: n.id, type: n.type, graphId: n.__graph ?? SCENE_GRAPH, data: { ...(n.data ?? {}) } }); + out.push({ + id: n.id, + type: n.type, + graphId: n.__graph ?? SCENE_GRAPH, + x: Number(n.position?.x) || 0, + y: Number(n.position?.y) || 0, + data: { ...(n.data ?? {}) } + }); } return out; }, + /** + * R29 S1: where a `w` x `h` block of new nodes can land without covering what is + * already in the graph — left-aligned under its lowest card (an empty graph gets + * a margin from the origin). Pass the result's x/y to `addNodes`; ask again before + * each block, since the answer moves as the graph grows. The rule is core's one + * copy (`flowLayout.freeRegion`), shared with the HUD editor's bindings. + * @param {{w?: number, h?: number, graphId?: string}=} opts + * @returns {{x: number, y: number, w: number, h: number}} + */ + freeRegion(opts) { + const graphId = opts?.graphId ?? SCENE_GRAPH; + const nodes = get(flowGraphs)?.[graphId]?.nodes ?? []; + return freeRegionIn(nodes, { w: opts?.w, h: opts?.h }); + }, + /** + * R29 S2: `fn()` runs after the flow graphs change — a node or edge created, + * deleted, moved or re-parameterised, on this peer or arriving from one — or + * after a node FIRES (the trigger log, which is what a latch, a counter or your + * own `triggerStamp` read derives from, so a manager listing collected state + * needs it as much as it needs the structure). It is + * COALESCED to one frame: however many store writes a gesture or a stream of + * arriving edits makes, the handler runs once, after them. Live VALUES (`nodeValue`) tick every frame + * and deliberately do not fire it. Torn down with the module, or earlier by + * calling the returned `off` (a toolbox that mounts and unmounts). + * @param {() => void} fn @returns {() => void} off + */ + onChange(fn) { + const off = coalescedSubscribe([flowGraphs, flowTriggers], fn); + onDispose(off); + return off; + }, /** Every edge, graph-tagged: `{id, source, target, sourceHandle, targetHandle, * graphId}`. @returns {any[]} */ edges() { @@ -1037,14 +1125,35 @@ function makeApi(moduleId, moduleName = moduleId) { return flowRuntimeRef?.nodeTriggerStamp?.(id) ?? null; }, /** Replicated node-data MERGE (the editor's own `nodedata` path — same message, - * same merge). The manager toolbox's inline param edit. @param {string} id + * same merge) that is ALSO one undo step: a `flownodes` data entry holding the + * patched keys' previous values, attributed to this module (R29 S3 — before it, + * a module's write left the stack untouched and the next Ctrl+Z undid whatever + * came before). The manager toolbox's inline param edit. @param {string} id * @param {Record} patch @returns {boolean} found */ setNodeData(id, patch) { - const found = findNodeAnyGraph((n) => n.id === id); - if (!found) return false; - sendNodeData(id, patch ?? {}, found.graphId); + const item = writeNodeData(id, patch); + if (!item) return false; + recordNodeData([item]); return true; }, + /** + * Many node-data writes as ONE undo step (R29 S3): a toolbox's group edit over + * sixty collectibles is one Ctrl+Z, not sixty and not none. Each item is + * validated exactly as `setNodeData` (an unknown id is skipped) and may live in + * any graph. The wire is the ordinary per-node `nodedata` — there is no batched + * type, so a peer on any build converges; the measured cost is ~0.1 ms/node. + * @param {{id: string, patch: Record}[]} list + * @returns {number} how many nodes were written + */ + setNodesData(list) { + const items = []; + for (const entry of Array.isArray(list) ? list : []) { + const item = entry && writeNodeData(entry.id, entry.patch); + if (item) items.push(item); + } + recordNodeData(items); + return items.length; + }, /** * Create nodes (and edges) the way the editor does: replicated `nodecreate`/ * `edgecreate` per item plus ONE `flownodes` undo entry for the batch — so what a diff --git a/tests/e2e/sdk-game-seams.test.cjs b/tests/e2e/sdk-game-seams.test.cjs index 2581414b..4a979ef9 100644 --- a/tests/e2e/sdk-game-seams.test.cjs +++ b/tests/e2e/sdk-game-seams.test.cjs @@ -247,6 +247,102 @@ h.run(async () => { ); h.check(dataOnB === true, 'setNodeData replicated the perRound patch (the nodedata path)'); + // ===================================================================== + // 4b. R29 S3: a module's setNodeData is ONE undo step, attributed to the module + // ===================================================================== + const dataOf = (peer, id) => + peer.page.evaluate((i) => { + const n = window.__seams.api.flow.nodes().find((x) => x.id === i); + return n ? { perRound: n.data.perRound, tag: n.data.tag ?? null } : null; + }, id); + const depthOf = (peer) => + peer.page.evaluate(() => { + let v; + window.__stores.history.undoStack.subscribe((x) => (v = x))(); + return v.length; + }); + const depth0 = await depthOf(A); + await A.page.evaluate((id) => window.__seams.api.flow.setNodeData(id, { perRound: false, tag: 's3' }), evId); + await A.page.waitForTimeout(600); + const top = await A.page.evaluate(() => { + let v; + window.__stores.history.undoStack.subscribe((x) => (v = x))(); + const e = v[v.length - 1]; + return { depth: v.length, kind: e?.kind, op: e?.op, moduleId: e?.moduleId, items: e?.items?.length }; + }); + h.check(top.depth === depth0 + 1 && top.kind === 'flownodes' && top.op === 'data', `setNodeData records ONE flownodes data entry (${depth0} -> ${top.depth}, ${top.kind}/${top.op})`); + h.check(top.moduleId === 'seams', `the entry is attributed to the module (${top.moduleId})`); + const edited = [await dataOf(A, evId), await dataOf(B, evId)]; + h.check(edited.every((d) => d && d.perRound === false && d.tag === 's3'), `premise: the write landed on both peers (${JSON.stringify(edited)})`); + await A.page.evaluate(() => window.__stores.history.undo()); + await A.page.waitForTimeout(800); + const undone = [await dataOf(A, evId), await dataOf(B, evId)]; + h.check(undone.every((d) => d && d.perRound === true && !d.tag), `one undo restores the node's previous data, on BOTH peers (${JSON.stringify(undone)})`); + await A.page.evaluate(() => window.__stores.history.redo()); + await A.page.waitForTimeout(800); + const redone = [await dataOf(A, evId), await dataOf(B, evId)]; + h.check(redone.every((d) => d && d.perRound === false && d.tag === 's3'), `and one redo re-applies it everywhere (${JSON.stringify(redone)})`); + await A.page.evaluate((id) => window.__seams.api.flow.setNodeData(id, { perRound: true }), evId); + await A.page.waitForTimeout(400); + const missing = await A.page.evaluate(() => window.__seams.api.flow.setNodeData('no-such-node', { x: 1 })); + h.check(missing === false, 'an unknown id still returns false (and records nothing)'); + + // ===================================================================== + // 4c. R29 S3: setNodesData — a group edit across graphs is ONE undo step + // ===================================================================== + const obox = await makeBox(A); + await A.page.waitForTimeout(600); + const grpScene = await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ nodes: Array.from({ length: 6 }, (_, i) => ({ type: 'seamvalue', x: 700 + i * 20, y: 700, data: {} })) }) + ); + const grpObj = await A.page.evaluate( + (g) => window.__seams.api.flow.addNodes({ graphId: g, nodes: Array.from({ length: 6 }, (_, i) => ({ type: 'seamvalue', x: 60 + i * 20, y: 60, data: {} })) }), + obox + ); + await A.page.waitForTimeout(900); + const grp = [...grpScene, ...grpObj]; + const tagsOf = (peer) => + peer.page.evaluate((ids) => { + const nodes = window.__seams.api.flow.nodes(); + return ids.map((i) => nodes.find((n) => n.id === i)?.data?.tag ?? null); + }, grp); + const gDepth0 = await depthOf(A); + const written = await A.page.evaluate( + (ids) => + window.__seams.api.flow.setNodesData([ + ...ids.map((id) => ({ id, patch: { tag: 'grp' } })), + { id: 'no-such-node', patch: { tag: 'x' } }, + null, + // the SAME node twice in one batch: undo must still land on its original value + { id: ids[0], patch: { tag: 'twice' } } + ]), + grp + ); + await A.page.waitForTimeout(900); + const gTop = await A.page.evaluate(() => { + let v; + window.__stores.history.undoStack.subscribe((x) => (v = x))(); + const e = v[v.length - 1]; + return { depth: v.length, items: e?.items?.length, graphs: [...new Set((e?.items ?? []).map((i) => i.graphId))].length, moduleId: e?.moduleId }; + }); + h.check(written === 13, `setNodesData writes every known node, skips the unknown and the null (${written})`); + h.check(gTop.depth === gDepth0 + 1 && gTop.items === 13, `and records ONE entry for the whole batch (${gDepth0} -> ${gTop.depth}, ${gTop.items} items)`); + h.check(gTop.graphs === 2 && gTop.moduleId === 'seams', `the one entry spans both graphs, attributed (${gTop.graphs} graphs, ${gTop.moduleId})`); + const gAfter = [await tagsOf(A), await tagsOf(B)]; + const wantAfter = JSON.stringify(['twice', ...grp.slice(1).map(() => 'grp')]); + h.check(gAfter.every((t) => JSON.stringify(t) === wantAfter), `the batch replicated over the ordinary nodedata path (${JSON.stringify(gAfter[1])})`); + await A.page.evaluate(() => window.__stores.history.undo()); + await A.page.waitForTimeout(900); + const gUndo = [await tagsOf(A), await tagsOf(B)]; + h.check(gUndo.every((t) => t.every((x) => x === null)), `ONE undo restores all twelve, on both peers, incl. the node written twice (${JSON.stringify(gUndo[1])})`); + await A.page.evaluate(() => window.__stores.history.redo()); + await A.page.waitForTimeout(900); + const gRedo = [await tagsOf(A), await tagsOf(B)]; + h.check(gRedo.every((t) => JSON.stringify(t) === wantAfter), `and one redo re-applies the batch in order (${JSON.stringify(gRedo[0])})`); + const emptyWrite = await A.page.evaluate(() => window.__seams.api.flow.setNodesData([])); + const gDepth2 = await depthOf(A); + h.check(emptyWrite === 0 && gDepth2 === gTop.depth, `an empty batch writes nothing and records nothing (${emptyWrite}, depth ${gDepth2})`); + // ===================================================================== // 5. api.flow.addNodes — one undo entry, canonical edge ids, spec defaults // ===================================================================== @@ -349,6 +445,243 @@ h.run(async () => { 'selectedUuids reads the SET, so a deselect empties it (never the sticky primary)' ); + // ===================================================================== + // 7b. R29 S1 — node POSITIONS come back, and api.flow.freeRegion keeps two + // recipes in a row off each other and off the user's own nodes + // ===================================================================== + await wipe([A, B]); + const placed = await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ nodes: [{ type: 'seamvalue', x: 123, y: 456, data: {} }] }) + ); + await A.page.waitForTimeout(700); + const snap = await Promise.all( + [A, B].map((p) => p.page.evaluate((id) => window.__seams.api.flow.nodes().find((n) => n.id === id), placed[0])) + ); + h.check( + snap.every((n) => n && n.x === 123 && n.y === 456), + `api.flow.nodes() snapshots carry x/y, on both peers (${JSON.stringify(snap.map((n) => n && [n.x, n.y]))})` + ); + // a "user" node dragged somewhere a fixed-row layout would collide with + await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ nodes: [{ type: 'seamvalue', x: 60, y: 700, data: {} }] }) + ); + /** run one two-node recipe at whatever freeRegion answers; return its ids */ + const recipe = (peer) => + peer.page.evaluate(() => { + const api = window.__seams.api; + const at = api.flow.freeRegion({ w: 370, h: 150 }); + return api.flow.addNodes({ + nodes: [ + { type: 'seamvalue', x: at.x, y: at.y, data: {} }, + { type: 'seamcollect', x: at.x + 220, y: at.y, data: {} } + ], + edges: [{ from: 1, to: 0 }] + }); + }); + const r1 = await recipe(A); + const r2 = await recipe(A); + await A.page.waitForTimeout(600); + /** every pair of 150x150 cards that overlap, over the scene graph */ + const overlapsOf = (peer) => + peer.page.evaluate(() => { + const ns = window.__seams.api.flow.nodes().filter((n) => n.graphId === 'scene'); + const hits = []; + for (let i = 0; i < ns.length; i++) + for (let j = i + 1; j < ns.length; j++) { + const a = ns[i], b = ns[j]; + if (a.x < b.x + 150 && b.x < a.x + 150 && a.y < b.y + 150 && b.y < a.y + 150) hits.push([a.id, b.id]); + } + return { n: ns.length, hits }; + }); + const ov = await overlapsOf(A); + h.check(r1.length === 2 && r2.length === 2 && ov.n === 6, `premise: two recipes built beside two nodes (${ov.n} nodes)`); + h.check(ov.hits.length === 0, `two recipes in a row land on nothing — no overlapping cards (${JSON.stringify(ov.hits)})`); + const ys = await A.page.evaluate( + (ids) => ids.map((id) => window.__seams.api.flow.nodes().find((n) => n.id === id)?.y), + [r1[0], r2[0]] + ); + h.check(ys[0] > 700 && ys[1] > ys[0], `each block lands BELOW everything before it (${JSON.stringify(ys)})`); + // the detector is live: a recipe at a CONSTANT point (what freeRegion used to be + // hand-rolled as) is caught — proves the check above cannot pass vacuously + await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ nodes: [{ type: 'seamvalue', x: 60, y: 700, data: {} }] }) + ); + const ovBad = await overlapsOf(A); + h.check(ovBad.hits.length > 0, `and a constant-placed block IS detected as an overlap (${ovBad.hits.length})`); + const scoped = await A.page.evaluate(() => window.__seams.api.flow.freeRegion({ graphId: 'no-such-graph' })); + h.check(scoped.x === 40 && scoped.y === 40, `an empty/unknown graph answers the margin (${JSON.stringify(scoped)})`); + + // ===================================================================== + // 7c. R29 S2 — onChange: fires on a node edit, a game-state change and a peer-var + // write; COALESCED (one call per burst, nothing while idle); torn down in §8 + // ===================================================================== + await wipe([A, B]); + for (const p of [A, B]) + await p.page.evaluate(() => { + const api = window.__seams.api; + const c = (window.__seamCounts = { flow: 0, game: 0, peer: 0 }); + api.flow.onChange(() => c.flow++); + api.game.onChange(() => c.game++); + api.peerVars.onChange(() => c.peer++); + // a toolbox-style subscriber that unmounts early: the returned off must stop it + c.early = 0; + const off = api.flow.onChange(() => c.early++); + window.__seamEarlyOff = off; + }); + const counts = (peer) => peer.page.evaluate(() => ({ ...window.__seamCounts })); + const frames = (peer, n) => + peer.page.evaluate( + (n) => new Promise((r) => { + let i = 0; + const step = () => (++i >= n ? r(i) : requestAnimationFrame(step)); + requestAnimationFrame(step); + }), + n + ); + // idle: sixty frames with the flow runtime ticking, a clock running, nothing edited + await A.page.evaluate(() => window.__seams.api.flow.addNodes({ nodes: [{ type: 'time', x: 60, y: 60, data: {} }] })); + await A.page.waitForTimeout(800); + const idle0 = await counts(A); + await frames(A, 60); + const idle1 = await counts(A); + h.check( + idle1.flow === idle0.flow && idle1.game === idle0.game && idle1.peer === idle0.peer, + `NOT once per frame: 60 idle frames fire nothing (${JSON.stringify(idle0)} -> ${JSON.stringify(idle1)})` + ); + // a node edit fires it, locally AND on the peer the edit arrives at + const bumpIds = await A.page.evaluate(() => + window.__seams.api.flow.addNodes({ + nodes: Array.from({ length: 30 }, (_, i) => ({ type: 'seamvalue', x: 400 + i * 10, y: 60, data: {} })) + }) + ); + await A.page.waitForTimeout(900); + const e0 = [await counts(A), await counts(B)]; + // ONE burst: thirty node edits in one synchronous gesture (a toolbox bulk edit) + const ticks = await A.page.evaluate((ids) => { + const s = window.__stores; + let n = 0; + const off = s.flowGraphs.subscribe(() => n++); + n = 0; + for (const id of ids) window.__seams.api.flow.setNodeData(id, { tag: 'bulk' }); + off(); + return n; + }, bumpIds); + await A.page.waitForTimeout(900); + const e1 = [await counts(A), await counts(B)]; + h.check(ticks >= 30, `premise: the bulk edit is ${ticks} store ticks`); + h.check(e1[0].flow - e0[0].flow === 1, `a ${ticks}-tick bulk edit runs the flow handler ONCE (${e1[0].flow - e0[0].flow})`); + h.check(e1[1].flow > e0[1].flow, `and the peer's handler fires as the edits ARRIVE (+${e1[1].flow - e0[1].flow})`); + h.check(e1[0].early - e0[0].early === 1, `premise: the early subscriber saw the burst too (+${e1[0].early - e0[0].early})`); + await A.page.evaluate(() => window.__seamEarlyOff()); + await A.page.evaluate((id) => window.__seams.api.flow.setNodeData(id, { tag: 'after-off' }), bumpIds[0]); + await A.page.waitForTimeout(400); + const e2 = await counts(A); + h.check(e2.early === e1[0].early && e2.flow === e1[0].flow + 1, `the returned off() stops ONE subscriber and leaves the rest (${e2.early}, flow +${e2.flow - e1[0].flow})`); + h.check(e1[1].flow - e0[1].flow < ticks, `arriving edits are coalesced too, not one per message (+${e1[1].flow - e0[1].flow} for ${ticks})`); + // a node FIRING is a flow change too (the trigger log) — what a collected-state list needs + const f0 = await counts(A); + await A.page.evaluate(() => window.__seams.api.fireNodeTrigger('seamvalue', undefined, { replicate: false })); + await A.page.waitForTimeout(400); + const f1 = await counts(A); + h.check(f1.flow - f0.flow === 1, `a node firing runs the flow handler once (+${f1.flow - f0.flow})`); + // game state: a transition fires it on both peers; a variable burst is one call + const g0c = await counts(A); + await gstate(A, 'playing'); + await A.page.waitForTimeout(700); + const g1c = [await counts(A), await counts(B)]; + h.check(g1c[0].game > g0c.game, `a game-state change fires api.game.onChange (+${g1c[0].game - g0c.game})`); + h.check(g1c[1].game > 0, `and on the peer, from the replicated singleton (${g1c[1].game})`); + await A.page.evaluate(() => { + for (let i = 0; i < 20; i++) window.__seams.api.game.setVar('burst', i); + }); + await A.page.waitForTimeout(400); + const g2c = await counts(A); + h.check(g2c.game - g1c[0].game === 1, `twenty setVar calls in one burst = ONE game handler call (${g2c.game - g1c[0].game})`); + await gstate(A, 'menu'); + // peer vars: my write fires mine; the peer's write fires mine as it arrives + const p0 = [await counts(A), await counts(B)]; + await A.page.evaluate(() => window.__seams.api.peerVars.setMine('laps', 9)); + await A.page.waitForTimeout(900); + const p1 = [await counts(A), await counts(B)]; + h.check(p1[0].peer > p0[0].peer, `a peer-var write fires my handler (+${p1[0].peer - p0[0].peer})`); + h.check(p1[1].peer > p0[1].peer, `and the OTHER peer's, as the row arrives (+${p1[1].peer - p0[1].peer})`); + + // ===================================================================== + // 7b. R29 S3 — THE MEASURED CASE: a sixty-node group edit, one real Ctrl+Z, a late joiner + // ===================================================================== + // sixty nodes spread over the scene graph and two object graphs (a collectible group is + // one object graph per member), created in THREE calls — so the entry right under the + // edit is a CREATION, the one sdk-polish's measurement watched Ctrl+Z take away + await setPlay(A, null); + await gstate(A, 'menu'); + const g60a = await makeBox(A); + const g60b = await makeBox(A); + await A.page.waitForTimeout(600); + const sixty = []; + for (const graphId of [undefined, g60a, g60b]) + sixty.push( + ...(await A.page.evaluate( + (g) => + window.__seams.api.flow.addNodes({ + ...(g ? { graphId: g } : {}), + nodes: Array.from({ length: 20 }, (_, i) => ({ type: 'seamvalue', x: 900 + i * 12, y: 900, data: { tag: 'orig' } })) + }), + graphId ?? null + )) + ); + await A.page.waitForTimeout(1200); + h.check(sixty.length === 60, `premise: sixty nodes across three graphs (${sixty.length})`); + const sixtyOf = (peer) => + peer.page.evaluate((ids) => { + const nodes = window.__stores.allNodes(); + const got = ids.map((i) => nodes.find((n) => n.id === i)); + return { present: got.filter(Boolean).length, tags: [...new Set(got.map((n) => n?.data?.tag ?? null))] }; + }, sixty); + const s60 = await A.page.evaluate( + (ids) => { + const t0 = performance.now(); + const n = window.__seams.api.flow.setNodesData(ids.map((id) => ({ id, patch: { tag: 'bulk60' } }))); + return { n, ms: performance.now() - t0 }; + }, + sixty + ); + h.check(s60.n === 60, `one setNodesData call writes all sixty (${s60.n} in ${s60.ms.toFixed(1)} ms)`); + await h.eventually( + () => sixtyOf(B), + (r) => r.present === 60 && r.tags.length === 1 && r.tags[0] === 'bulk60', + 'all sixty land on the peer' + ); + // a REAL Ctrl+Z, the user's gesture — nothing focused that could swallow it + await A.page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur()); + await A.page.keyboard.press('Control+z'); + await A.page.waitForTimeout(900); + const z1 = [await sixtyOf(A), await sixtyOf(B)]; + h.check( + z1.every((r) => r.present === 60 && r.tags.length === 1 && r.tags[0] === 'orig'), + `ONE Ctrl+Z restores all sixty on both peers — and the nodes are still there, the creation was NOT undone (${JSON.stringify(z1)})` + ); + await A.page.evaluate(() => window.__stores.history.redo()); + await A.page.waitForTimeout(900); + // a LATE JOINER takes the edited values from the ordinary full-state reply + const C = await h.setupPage(browser, 'C'); + await C.page.waitForFunction(() => !!window.__stores?.allNodes, { timeout: 30000 }); + await h.connect(C, A); + await h.eventually( + () => sixtyOf(C), + (r) => r.present === 60 && r.tags.length === 1 && r.tags[0] === 'bulk60', + 'a late joiner has all sixty edited values', + 20000 + ); + // and an undo made AFTER it joined reaches it too (the entry replays per-node nodedata) + await A.page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur()); + await A.page.keyboard.press('Control+z'); + await h.eventually( + () => sixtyOf(C), + (r) => r.present === 60 && r.tags.length === 1 && r.tags[0] === 'orig', + 'and a later undo reverts it on the joiner as well' + ); + await C.page.close(); + // ===================================================================== // 8. the debug line + the action catalog seams, and their teardown // ===================================================================== @@ -376,6 +709,20 @@ h.run(async () => { }); h.check(!afterOff.lines.includes('seams: line-alive'), 'deactivate removes the debug line (journal)'); h.check(!afterOff.offered.includes('mod-seams-showseam'), 'and the catalog entry'); + // R29 S2: the three onChange subscriptions went with the journal + const off0 = await counts(A); + await A.page.evaluate(() => { + const s = window.__stores; + s.gameState.setGameVar('after', 1); + s.peerVars.setPeerVar('after', 1); + s.flowGraphs.update((g) => ({ ...g })); + }); + await A.page.waitForTimeout(400); + const off1 = await counts(A); + h.check( + JSON.stringify(off0) === JSON.stringify(off1), + `deactivate unsubscribes every onChange (${JSON.stringify(off0)} -> ${JSON.stringify(off1)})` + ); await h.finish(browser); }); diff --git a/tests/unit/coalesce.test.js b/tests/unit/coalesce.test.js new file mode 100644 index 00000000..19412f65 --- /dev/null +++ b/tests/unit/coalesce.test.js @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { coalescedSubscribe, nextFrame, FRAME_FALLBACK_MS } from '../../src/lib/coalesce.js'; + +// R29 S2. The debounce behind api.flow/game/peerVars.onChange. A minimal svelte-shaped +// store (subscribe calls back synchronously with the current value, as svelte's does). +/** @param {any} [value] */ +function store(value = 0) { + /** @type {Set<(v: any) => void>} */ + const subs = new Set(); + return { + /** @param {any} v */ + set(v) { + value = v; + for (const s of subs) s(value); + }, + /** @param {(v: any) => void} cb */ + subscribe(cb) { + subs.add(cb); + cb(value); + return () => subs.delete(cb); + }, + get count() { + return subs.size; + } + }; +} +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe('coalescedSubscribe', () => { + it('the synchronous subscribe callback is not a change', async () => { + let calls = 0; + coalescedSubscribe([store()], () => calls++); + await tick(); + expect(calls).toBe(0); + }); + it('a burst of sixty writes runs the handler ONCE, after the burst', async () => { + const s = store(); + let calls = 0; + let seen = -1; + coalescedSubscribe([s], () => { + calls++; + seen = 59; + }); + for (let i = 0; i < 60; i++) s.set(i); + expect(calls).toBe(0); // not inside the burst + await tick(); + expect(calls).toBe(1); + expect(seen).toBe(59); + }); + it('writes to SEVERAL stores in one burst still coalesce to one call', async () => { + const a = store(), b = store(); + let calls = 0; + coalescedSubscribe([a, b], () => calls++); + a.set(1); b.set(1); a.set(2); + await tick(); + expect(calls).toBe(1); + }); + it('separate bursts are separate calls', async () => { + const s = store(); + let calls = 0; + coalescedSubscribe([s], () => calls++); + s.set(1); + await tick(); + s.set(2); + await tick(); + expect(calls).toBe(2); + }); + it('teardown unsubscribes, and a flush already queued does not run', async () => { + const s = store(); + let calls = 0; + const off = coalescedSubscribe([s], () => calls++); + expect(s.count).toBe(1); + s.set(1); + off(); + expect(s.count).toBe(0); + await tick(); + expect(calls).toBe(0); + s.set(2); + await tick(); + expect(calls).toBe(0); + }); + it('a throwing handler does not break the next burst', async () => { + const s = store(); + let calls = 0; + const warn = console.warn; + console.warn = () => {}; + coalescedSubscribe([s], () => { + calls++; + throw new Error('module bug'); + }); + s.set(1); + await tick(); + s.set(2); + await tick(); + console.warn = warn; + expect(calls).toBe(2); + }); +}); + +describe('nextFrame', () => { + it('runs once on the frame, and the fallback timer does not run it again', async () => { + /** @type {any[]} */ + const frames = []; + /** @type {any} */ (globalThis).requestAnimationFrame = (/** @type {any} */ cb) => frames.push(cb); + let calls = 0; + nextFrame(() => calls++); + expect(calls).toBe(0); + frames.shift()(); + await new Promise((r) => setTimeout(r, FRAME_FALLBACK_MS + 30)); + expect(calls).toBe(1); + delete (/** @type {any} */ (globalThis)).requestAnimationFrame; + }); + it('a tab that never paints still gets its call (the timer races the frame)', async () => { + /** @type {any} */ (globalThis).requestAnimationFrame = () => {}; + let calls = 0; + nextFrame(() => calls++); + await new Promise((r) => setTimeout(r, FRAME_FALLBACK_MS + 30)); + expect(calls).toBe(1); + delete (/** @type {any} */ (globalThis)).requestAnimationFrame; + }); +}); diff --git a/tests/unit/flowLayout.test.js b/tests/unit/flowLayout.test.js new file mode 100644 index 00000000..377c8683 --- /dev/null +++ b/tests/unit/flowLayout.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { freeRegion, NODE_W, NODE_H, GAP_X, GAP_Y, MARGIN } from '../../src/lib/flowLayout.js'; + +// R29 S1. The one "where does a built block of nodes land" rule — api.flow.freeRegion and +// the HUD editor's bindings both call it, so it is pinned here with no browser. + +/** does a w x h block at p overlap any NODE_W x NODE_H card? + * @param {any[]} nodes @param {{x: number, y: number}} p @param {number} w @param {number} h */ +const overlaps = (nodes, p, w, h) => + nodes.some((/** @type {any} */ n) => { + const x = n.position.x, y = n.position.y; + return p.x < x + NODE_W && x < p.x + w && p.y < y + NODE_H && y < p.y + h; + }); +/** @param {number} x @param {number} y */ +const at = (x, y) => ({ position: { x, y } }); + +describe('freeRegion', () => { + it('an empty graph gets the margin', () => { + expect(freeRegion([], { w: 300, h: 200 })).toEqual({ x: MARGIN, y: MARGIN, w: 300, h: 200 }); + }); + it('below: left-aligned, one gap under the lowest card', () => { + const nodes = [at(60, 40), at(280, 400), at(-20, 120)]; + const p = freeRegion(nodes); + expect(p.x).toBe(-20); + expect(p.y).toBe(400 + NODE_H + GAP_Y); + }); + it('a measured height is honoured over the estimate', () => { + const p = freeRegion([{ position: { x: 0, y: 0 }, measured: { height: 400 } }]); + expect(p.y).toBe(400 + GAP_Y); + }); + it('accepts flat {x, y} snapshots (what api.flow.nodes returns)', () => { + expect(freeRegion([{ x: 10, y: 20 }]).y).toBe(20 + NODE_H + GAP_Y); + }); + it('two blocks placed in a row never overlap each other or the graph', () => { + const graph = [at(60, 40), at(280, 40)]; + const first = freeRegion(graph, { w: 370, h: 150 }); + expect(overlaps(graph, first, 370, 150)).toBe(false); + graph.push(at(first.x, first.y), at(first.x + 220, first.y)); + const second = freeRegion(graph, { w: 370, h: 150 }); + expect(overlaps(graph, second, 370, 150)).toBe(false); + expect(second.y).toBeGreaterThan(first.y); + }); + it("right: the HUD bindings' rule, byte-identical to what hudActions always computed", () => { + const nodes = [at(100, 50), at(640, 90)]; + const legacy = nodes.reduce((max, n) => Math.max(max, n.position.x), 0) + 220; + expect(freeRegion(nodes, { side: 'right' }).x).toBe(legacy); + expect(NODE_W + GAP_X).toBe(220); + // the empty graph was a phantom card at 0, so the first binding sits at 220 + expect(freeRegion([], { side: 'right' }).x).toBe(220); + }); + it('garbage in is ignored, not thrown', () => { + expect(freeRegion(/** @type {any} */ (null)).x).toBe(MARGIN); + expect(freeRegion([null, at(0, 0)]).y).toBe(NODE_H + GAP_Y); + }); +});