diff --git a/docs/development.md b/docs/development.md index 6a55401..e217e88 100644 --- a/docs/development.md +++ b/docs/development.md @@ -86,7 +86,15 @@ npm install npm run dev ``` -Open **http://localhost:8080** in a browser. The mock server (`server/index.js`) handles all REST endpoints and WebSocket, with scenes stored in memory for the duration of the process. Auth is skipped entirely when no environment variables are configured. +Open **http://localhost:8080** in a browser. The mock server (`server/index.js`) handles all REST endpoints and WebSocket, with scenes stored in memory for the duration of the process. + +`npm run dev` expects the auth environment variables (`GITHUB_CLIENT_ID`, `SESSION_SECRET`, `TOKEN_SECRET`, `ALLOWED_GITHUB_USERS`); without them every request redirects to the login. For local work without that setup use: + +```bash +npm run dev:no-auth +``` + +The graph editor (mock-only, see the [rework plan](lightwitch-rework-plan.md)'s M8 section) is served from the same process at **http://localhost:8080/graphs.html**. > Requires Node.js. diff --git a/docs/lightwitch-decisions.md b/docs/lightwitch-decisions.md index 7ffaf4d..c49bc57 100644 --- a/docs/lightwitch-decisions.md +++ b/docs/lightwitch-decisions.md @@ -65,7 +65,7 @@ in `Config.h`. minimal types** (no includes from `src/config`); adapters translate at the boundary. Add `[env:native]` plus unit tests with the first engine commit. This is less an option than a discipline commitment — without it, §3 (queue, -scheduler, topological sort, joints) is not reliably developable. The one +scheduler, topological sort, adapters) is not reliably developable. The one thing to decide: does the team accept native tests as a PR gate in CI? **Resolution (2026-07-24):** deprioritized. No native environment and no @@ -101,7 +101,7 @@ the user enables them), `off` (ignore graphs from mesh/group entirely). ### D5 · Canonical graph schema: language & v1 scope ☑️ -**Decided: English canonical keys** (`nodes`, `edges`, `requires`), matching +**Decided: English canonical keys** (`nodes`, `links`, `requires`), matching the rest of the repo; German terms appear only as editor labels. This mirrors the concept's own §5.5 split between display language and stored form. The v1 scope additionally includes: the edge list as sketched, the `v` field, the diff --git a/docs/lightwitch-rework-plan.md b/docs/lightwitch-rework-plan.md index e997f97..e11c1d5 100644 --- a/docs/lightwitch-rework-plan.md +++ b/docs/lightwitch-rework-plan.md @@ -1,10 +1,28 @@ # LightWitch rework plan Derived from the LightWitch system concept v1.0 and the resolved decisions in -[lightwitch-decisions.md](lightwitch-decisions.md) (D1–D7). Terminology follows -the repo: **node** (not "Stein"), **graph**, **stage node**, **role**, -**group**. All identifiers, schema keys, docs, and UI strings are English; -German appears only as optional editor labels later. +[lightwitch-decisions.md](lightwitch-decisions.md) (D1–D7). + +## Vocabulary + +One word per thing, used identically in schema keys, code identifiers, docs, +and UI strings — there is no separate user-facing vocabulary and no +translation layer, so a term that reads well in the editor must also be the +term in the code. + +| Term | Meaning | +|---|---| +| `graph` | One named, activatable program: nodes plus their links. Stored as one JSON file. | +| `node` | One building block of logic — a typed unit with ports. Never "Stein"/"stone"/"block". | +| `port` | A connection point on a node. Input ports sit on the node's left edge, output ports on its right; flow runs left to right. Say "input port"/"output port" when the direction matters. Never "pin" — that means a GPIO pin everywhere else in this project. | +| `link` | Two ports joined. Arises from adjacency (neighboring columns, row distance ≤ 1), not from a drawn line — so never "wire", "edge", or "connection" (the latter means a network connection everywhere else). | +| `adapter` | A link between two different signal types, carrying a conversion rule. Which conversions exist is the adapter matrix. | +| `signal type` | What flows through a port: event, switch, value, color. Shape is the primary code, not colour. | +| `chip` | The editable default value shown on a free input port. | +| `arc` | The small curve in the gutter drawn for a link whose two ports sit one row apart — the only line segment in the system. | +| `rail` | A named portal that links distant nodes without adjacency. | +| `dock` (verb) | To join two ports by placing their nodes next to each other. | +| `stage node`, `role`, `group` | As used elsewhere in this repo. | Scope guardrails (from D3/D7): no native/desktop tooling, no measurement suite, no mesh protocol rework. Every milestone ships a usable feature on the @@ -173,7 +191,7 @@ frozen as of now: bug fixes only, no new trigger types, no new features. "nodes": [ {"id": 1, "type": "button", "role": "button:main", "col": 1, "row": 1, "cfg": {}} ], - "edges": [[1, "pressed", 2, "start"]], + "links": [[1, "pressed", 2, "start"]], "notes": [{"col": 1, "row": 5, "text": "registration"}] } ``` @@ -230,8 +248,8 @@ notice. ### M4 · Value layer + stage node -Value-layer nodes (`lfo` via LUT, `math`, `hold`, `hsv-mix`, joints/type -adapters on edges per concept §5.3) and the **stage node**: binds to a light +Value-layer nodes (`lfo` via LUT, `math`, `hold`, `hsv-mix`, adapters/type +adapters on links per concept §5.3) and the **stage node**: binds to a light role, claims the light through the M1 arbitration, drives its `PatternRunner` with a channel-driven pattern (intensity, stimulus, movement, limit, color — scenes read the channels they know). Existing patterns stay available as @@ -275,7 +293,7 @@ ignites its fire scene. ### M8 · Dock editor (visual) -The concept's dock UI (columns, docking, joints, chain view) on top of the +The concept's dock UI (columns, docking, adapters, chain view) on top of the by-then-proven schema. Explicitly last: the JSON editor (M3) carries all functionality until here, and dock details (elbow tolerance, zoom) are decided after first editor tests. @@ -291,6 +309,20 @@ editor tests" this milestone's open dock details wait on. Engine wiring, authoritative validation, the full palette, and the remaining dock feature set stay here in M8; nothing runs a saved graph until M2. +Two decisions settled while building it: + +- **Fan-out runs through rows.** An output port may feed several neighbors, + but only as many as it has instance rows — the same `+` row mechanism that + lets an input collect several sources. The number of receivers is therefore + visible in the node's height, which keeps "position is the program" intact. + An unbounded output with no rows was the alternative and was rejected for + hiding that count. +- **The adapter matrix is provisional.** The four conversions currently + offered (value→switch, switch→value, value→color, event→switch) are a + working set for the prototype, not the authoritative table — that one lives + in system concept §5.3, which is not in this repo yet and replaces these + when it lands. + *Touches:* `data/index.html` (likely split into a second page/bundle — decide when sizing it). diff --git a/eslint.config.js b/eslint.config.js index 76abf1c..a2e19bc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,4 +32,18 @@ export default [ 'no-var': 'error', }, }, + { + files: ['server/graphs.html'], + plugins: { html }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: globals.browser, + }, + rules: { + eqeqeq: 'error', + 'prefer-const': 'error', + 'no-var': 'error', + }, + }, ]; diff --git a/server/graphs-core.js b/server/graphs-core.js new file mode 100644 index 0000000..214bbc4 --- /dev/null +++ b/server/graphs-core.js @@ -0,0 +1,303 @@ +// Pure, DOM-free core of the dock editor (interaction concept v1, §11: the +// DockResolver as pure functions over the grid model) — shared between the +// editor page (loaded as an ES module) and node:test unit tests. Mock-only +// for now; the M2 engine adopts this registry as its node registry, which is +// what keeps editor and engine from drifting. + +// Wire/port types (concept §1.3): shape is the primary code. +// event ▶ · switch ■ · value ● · color ◉ +export const SIGNAL_SHAPES = { event: '▶', switch: '■', value: '●', color: '◉' }; + +// Node registry: every node type with its fixed connector rows (PORT-01). +// Each row may carry one input port (left edge of the node) and/or one output +// port (right edge) — flow runs left to right, so a node's outputs meet the +// inputs of the node in the next column. Row order is fixed (nodes are +// static). `repeat: true` marks a +// repeatable connector (MULTI-01): the node can grow extra rows for it — +// repeatable inputs collect several sources, repeatable outputs feed several +// neighbors (this is the concept's fan-out mechanism). Repeatable ports must +// sit alone on their registry row so instance rows can be inserted below. +// Port shapes are still speculative until the M2 engine pins them down. +export const NODE_TYPES = { + 'button': { cat: 'in', rows: [{ out: { id: 'pressed', type: 'event', repeat: true } }] }, + 'timer': { cat: 'in', rows: [{ in: { id: 'start', type: 'event' }, out: { id: 'elapsed', type: 'event' } }, + { in: { id: 'stop', type: 'event' } }] }, + 'mesh-receive': { cat: 'in', rows: [{ out: { id: 'payload', type: 'value', repeat: true } }] }, + 'constant': { cat: 'in', rows: [{ out: { id: 'value', type: 'value', repeat: true } }] }, + 'compare': { cat: 'logic', rows: [{ in: { id: 'value', type: 'value' }, out: { id: 'isTrue', type: 'switch' } }, + { out: { id: 'isFalse', type: 'switch' } }] }, + 'range': { cat: 'logic', rows: [{ in: { id: 'value', type: 'value' }, out: { id: 'inRange', type: 'switch' } }, + { out: { id: 'outOfRange', type: 'switch' } }] }, + 'gate': { cat: 'logic', rows: [{ in: { id: 'value', type: 'value' }, out: { id: 'value', type: 'value' } }, + { in: { id: 'open', type: 'switch' } }] }, + 'wait': { cat: 'logic', rows: [{ in: { id: 'start', type: 'event' }, out: { id: 'done', type: 'event' } }] }, + 'action': { cat: 'out', rows: [{ in: { id: 'trigger', type: 'event', repeat: true } }] }, + 'mesh-send': { cat: 'out', rows: [{ in: { id: 'value', type: 'value', repeat: true } }] }, + 'log': { cat: 'out', rows: [{ in: { id: 'value', type: 'value', repeat: true } }] }, +}; + +export const CATEGORIES = { in: 'Sources', logic: 'Logic', out: 'Sinks' }; + +// Merge rules for multi-row value inputs (MULTI-02); event rows are always OR. +export const MULTI_RULES = ['max', 'sum', 'mean', 'last']; + +// Adapter matrix (DOCK-04): which type conversions dock as a two-colored adapter// port, with which default rule. PROVISIONAL — the authoritative matrix lives +// in the system concept §5.3, which is not in this repo yet; replace this +// table when it lands. Key: `${outType}>${inType}`. +export const ADAPTER_MATRIX = { + 'value>switch': 'threshold 50%', + 'switch>value': '0 / 100%', + 'value>color': 'color ramp', + 'event>switch': 'pulse', +}; + +// Default chip values for free in-ports (PORT-03): percent for values, +// on/off for switches; event ports carry no chip. +export function defaultChip(type) { + if (type === 'value') return 50; + if (type === 'switch') return 0; + return null; +} + +// ── Geometry ───────────────────────────────────────────────────────────────── + +// Extra instance rows of a repeatable port: node.rep = { 'in:trigger': 1, … }. +export function extraRows(node, dir, portId) { + return (node.rep && node.rep[`${dir}:${portId}`]) || 0; +} + +// A node occupies rows [row .. row + height - 1]: header + connector rows, +// where repeatable connectors contribute 1 + their extra rows. +export function nodeHeight(node) { + const type = typeof node === 'string' ? node : node.type; + const t = NODE_TYPES[type]; + if (!t) return 1; + let h = 1; + for (const r of t.rows) { + let rows = 1; + if (typeof node === 'object') { + if (r.in && r.in.repeat) rows += extraRows(node, 'in', r.in.id); + if (r.out && r.out.repeat) rows += extraRows(node, 'out', r.out.id); + } + h += rows; + } + return h; +} + +// All port instances of a node with their global grid rows: +// [{dir, id, type, repeat, inst, row}] — inst 0 is the base row (MULTI-01). +export function portsOf(node) { + const t = NODE_TYPES[node.type]; + if (!t) return []; + const out = []; + let row = node.row + 1; + for (const r of t.rows) { + const emit = (port, dir) => { + const count = 1 + (port.repeat ? extraRows(node, dir, port.id) : 0); + for (let inst = 0; inst < count; inst++) + out.push({ dir, id: port.id, type: port.type, repeat: !!port.repeat, inst, row: row + inst }); + return count; + }; + let used = 1; + if (r.in) used = Math.max(used, emit(r.in, 'in')); + if (r.out) used = Math.max(used, emit(r.out, 'out')); + row += used; + } + return out; +} + +export function overlaps(a, b) { + if (a.col !== b.col) return false; + const aEnd = a.row + nodeHeight(a) - 1; + const bEnd = b.row + nodeHeight(b) - 1; + return a.row <= bEnd && b.row <= aEnd; +} + +// True if `node` can sit at (col,row) without overlapping any other node. +export function placementFree(doc, node, col, row) { + const probe = { ...node, col, row }; + return !doc.nodes.some(o => o.id !== node.id && overlaps(probe, o)); +} + +// ── Compatibility & candidate search (DOCK-03/04) ──────────────────────────── + +// null = incompatible; 'direct' = same shape; otherwise the adapter rule name. +export function compat(outType, inType) { + if (outType === inType) return 'direct'; + return ADAPTER_MATRIX[`${outType}>${inType}`] || null; +} + +// How many links already use a given (node, dir, port). +export function portLoad(doc, nodeId, dir, portId) { + return doc.links.filter(e => dir === 'out' + ? e[0] === nodeId && e[1] === portId + : e[2] === nodeId && e[3] === portId).length; +} + +// A port's connection capacity = its instance-row count. +export function portCapacity(node, dir, portId) { + const t = NODE_TYPES[node.type]; + if (!t) return 0; + for (const r of t.rows) { + const p = dir === 'in' ? r.in : r.out; + if (p && p.id === portId) return 1 + (p.repeat ? extraRows(node, dir, portId) : 0); + } + return 0; +} + +// Candidate search for a node hypothetically placed at (col,row): for each +// of its port instances, compatible counter-port instances in the adjacent +// column with a row distance ≤ 1. Priority: exact row > ±1 · direct > adapter +// (DOCK-03). Returns candidates sorted best-first. +export function dockCandidates(doc, node, col, row) { + const probe = { ...node, col, row }; + const found = []; + for (const my of portsOf(probe)) { + const otherCol = my.dir === 'out' ? col + 1 : col - 1; + for (const other of doc.nodes) { + if (other.id === node.id || other.col !== otherCol) continue; + for (const their of portsOf(other)) { + if (their.dir === my.dir) continue; + const dRow = Math.abs(my.row - their.row); + if (dRow > 1) continue; + const rule = my.dir === 'out' ? compat(my.type, their.type) : compat(their.type, my.type); + if (!rule) continue; + const link = my.dir === 'out' + ? [probe.id, my.id, other.id, their.id] + : [other.id, their.id, probe.id, my.id]; + found.push({ link, rule, dRow, myPort: my, theirPort: their, other }); + } + } + } + found.sort((a, b) => + (a.dRow - b.dRow) || + ((a.rule === 'direct' ? 0 : 1) - (b.rule === 'direct' ? 0 : 1))); + return found; +} + +// True if an exact-row, adjacent-column port pairing exists that is NOT +// compatible — the transient red "does not dock" state (DOCK-04). +export function hasRejectedDock(doc, node, col, row) { + const probe = { ...node, col, row }; + for (const my of portsOf(probe)) { + const otherCol = my.dir === 'out' ? col + 1 : col - 1; + for (const other of doc.nodes) { + if (other.id === node.id || other.col !== otherCol) continue; + for (const their of portsOf(other)) { + if (their.dir === my.dir || their.row !== my.row) continue; + const rule = my.dir === 'out' ? compat(my.type, their.type) : compat(their.type, my.type); + if (!rule) return true; + } + } + } + return false; +} + +// ── Link maintenance (INV-01, DOCK-05/07) ──────────────────────────────────── + +// A link is geometrically valid when some instance pairing of its two ports +// sits in adjacent columns with row distance ≤ 1 and the types dock. Rails +// (P1) will add the second legal representation per INV-01. +export function linkValid(doc, link) { + const from = doc.nodes.find(n => n.id === link[0]); + const to = doc.nodes.find(n => n.id === link[2]); + if (!from || !to) return false; + if (to.col !== from.col + 1) return false; + const outs = portsOf(from).filter(p => p.dir === 'out' && p.id === link[1]); + const ins = portsOf(to).filter(p => p.dir === 'in' && p.id === link[3]); + if (!outs.length || !ins.length) return false; + if (!compat(outs[0].type, ins[0].type)) return false; + return outs.some(o => ins.some(i => Math.abs(o.row - i.row) <= 1)); +} + +// The adapter rule a link docks with ('direct' or a ADAPTER_MATRIX rule). +export function linkRule(doc, link) { + const from = doc.nodes.find(n => n.id === link[0]); + const to = doc.nodes.find(n => n.id === link[2]); + if (!from || !to) return null; + const outPort = portsOf(from).find(p => p.dir === 'out' && p.id === link[1]); + const inPort = portsOf(to).find(p => p.dir === 'in' && p.id === link[3]); + if (!outPort || !inPort) return null; + return compat(outPort.type, inPort.type); +} + +// After a node moved (or was added): drop links that no longer hold +// geometrically, then dock every port of the moved node with free capacity +// to its best candidates. Mutates doc.links; returns {dropped, added}. +export function settleLinks(doc, movedId) { + const before = doc.links.length; + doc.links = doc.links.filter(e => linkValid(doc, e)); + const dropped = before - doc.links.length; + + const node = doc.nodes.find(n => n.id === movedId); + let added = 0; + if (node) { + for (const cand of dockCandidates(doc, node, node.col, node.row)) { + const [fromId, outId, toId, inId] = cand.link; + if (doc.links.some(e => e[0] === fromId && e[1] === outId && e[2] === toId && e[3] === inId)) + continue; + const fromNode = doc.nodes.find(n => n.id === fromId); + const toNode = doc.nodes.find(n => n.id === toId); + if (portLoad(doc, fromId, 'out', outId) >= portCapacity(fromNode, 'out', outId)) continue; + if (portLoad(doc, toId, 'in', inId) >= portCapacity(toNode, 'in', inId)) continue; + doc.links.push(cand.link); + added++; + } + } + return { dropped, added }; +} + +// ── Multi-row helpers (MULTI-01/02) ────────────────────────────────────────── + +// Grow/shrink a repeatable port by one row. Shrinking below the number of +// connected links (or below one row) is refused. Returns true on change. +export function setPortRows(doc, node, dir, portId, delta) { + const cap = portCapacity(node, dir, portId); + if (!cap) return false; + const t = NODE_TYPES[node.type]; + const port = t.rows.map(r => (dir === 'in' ? r.in : r.out)).find(p => p && p.id === portId); + if (!port || !port.repeat) return false; + const next = cap + delta; + if (next < 1 || next < portLoad(doc, node.id, dir, portId)) return false; + node.rep = node.rep || {}; + node.rep[`${dir}:${portId}`] = next - 1; + // Growing/shrinking may break nodes below — the caller re-settles; here we + // only refuse a shrink that would orphan connected rows (checked above). + return true; +} + +// The merge rule of a multi-row value input (MULTI-02), default 'max'. +export function multiRule(node, portId) { + return (node.cfg && node.cfg.rules && node.cfg.rules[portId]) || MULTI_RULES[0]; +} + +// ── Document validation (VAL-02, structural) ───────────────────────────────── + +export function validateDoc(doc) { + const errors = []; + if (doc.v !== 1) errors.push('schema version must be 1'); + if (!Array.isArray(doc.nodes)) errors.push('nodes must be an array'); + if (!Array.isArray(doc.links)) errors.push('links must be an array'); + if (errors.length) return errors; + const ids = new Set(); + for (const n of doc.nodes) { + if (ids.has(n.id)) errors.push(`duplicate node id ${n.id}`); + ids.add(n.id); + if (!NODE_TYPES[n.type]) errors.push(`unknown node type "${n.type}"`); + } + for (const n of doc.nodes) + for (const o of doc.nodes) + if (n.id < o.id && overlaps(n, o)) errors.push(`nodes #${n.id} and #${o.id} overlap`); + for (const e of doc.links) + if (!linkValid(doc, e)) errors.push(`link ${JSON.stringify(e)} is not dockable`); + for (const n of doc.nodes) { + if (!NODE_TYPES[n.type]) continue; + for (const dir of ['in', 'out']) + for (const r of NODE_TYPES[n.type].rows) { + const p = dir === 'in' ? r.in : r.out; + if (p && portLoad(doc, n.id, dir, p.id) > portCapacity(n, dir, p.id)) + errors.push(`port ${n.id}:${dir}:${p.id} exceeds its row capacity`); + } + } + return errors; +} diff --git a/server/graphs-core.test.js b/server/graphs-core.test.js new file mode 100644 index 0000000..a7c22f1 --- /dev/null +++ b/server/graphs-core.test.js @@ -0,0 +1,176 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + NODE_TYPES, compat, dockCandidates, hasRejectedDock, settleLinks, + linkValid, linkRule, nodeHeight, placementFree, validateDoc, + portCapacity, portLoad, setPortRows, portsOf, +} from './graphs-core.js'; + +const doc = (nodes, links = []) => ({ v: 1, name: 't', nodes, links, notes: [] }); + +describe('registry & geometry', () => { + test('node height = header + connector rows, repeat rows grow it', () => { + assert.equal(nodeHeight('button'), 2); + assert.equal(nodeHeight('timer'), 3); + assert.equal(nodeHeight('range'), 3); + const grown = { type: 'action', rep: { 'in:trigger': 2 } }; + assert.equal(nodeHeight(grown), 4); + }); + + test('portsOf emits one instance per repeat row with consecutive rows', () => { + const n = { id: 1, type: 'action', col: 1, row: 1, rep: { 'in:trigger': 1 } }; + const ports = portsOf(n).filter(p => p.id === 'trigger'); + assert.equal(ports.length, 2); + assert.deepEqual(ports.map(p => p.row), [3, 4].map(r => r - 1)); // rows 2 and 3 + }); + + test('placement rejects overlap in the same column only', () => { + const d = doc([{ id: 1, type: 'timer', col: 2, row: 1 }]); + const probe = { id: 2, type: 'button' }; + assert.equal(placementFree(d, probe, 2, 2), false); // rows 2-3 vs timer 1-3 + assert.equal(placementFree(d, probe, 2, 4), true); + assert.equal(placementFree(d, probe, 3, 2), true); // other column + }); +}); + +describe('compatibility (DOCK-04)', () => { + test('same shape docks directly, defined pairs dock as adapter, others not', () => { + assert.equal(compat('event', 'event'), 'direct'); + assert.equal(compat('value', 'switch'), 'threshold 50%'); + assert.equal(compat('event', 'color'), null); + }); +}); + +describe('candidate search (DOCK-03)', () => { + test('finds an exact-row direct candidate first', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, // pressed out at row 2 + { id: 2, type: 'wait', col: 2, row: 1 }, // start in at row 2 + ]); + const c = dockCandidates(d, d.nodes[0], 1, 1); + assert.ok(c.length >= 1); + assert.deepEqual(c[0].link, [1, 'pressed', 2, 'start']); + assert.equal(c[0].dRow, 0); + assert.equal(c[0].rule, 'direct'); + }); + + test('±1 row still qualifies, larger distances do not', () => { + const near = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 2, row: 2 }, // start at row 3 → dRow 1 + ]); + assert.equal(dockCandidates(near, near.nodes[0], 1, 1)[0].dRow, 1); + const far = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 2, row: 3 }, // start at row 5 → dRow 3 + ]); + assert.equal(dockCandidates(far, far.nodes[0], 1, 1).length, 0); + }); + + test('incompatible exact-row pairing reports the rejected state', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, // pressed (event) out at row 2 + { id: 2, type: 'mesh-send', col: 2, row: 1 }, // value (value) in at row 2 + ]); + assert.equal(hasRejectedDock(d, d.nodes[0], 1, 1), true); + assert.equal(dockCandidates(d, d.nodes[0], 1, 1).length, 0); + }); +}); + +describe('link settling (DOCK-05/07, INV-01)', () => { + test('moving a node next to a partner docks it; moving away undocks', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 4, row: 1 }, + ]); + assert.deepEqual(settleLinks(d, 2), { dropped: 0, added: 0 }); + d.nodes[1].col = 2; + const r = settleLinks(d, 2); + assert.equal(r.added, 1); + assert.deepEqual(d.links, [[1, 'pressed', 2, 'start']]); + assert.equal(linkRule(d, d.links[0]), 'direct'); + d.nodes[1].col = 4; + assert.equal(settleLinks(d, 2).dropped, 1); + assert.deepEqual(d.links, []); + }); + + test('an adapter link validates and reports its rule', () => { + const d = doc([ + { id: 1, type: 'timer', col: 1, row: 1 }, // elapsed out at row 2 + { id: 2, type: 'gate', col: 2, row: 0 }, // open in at row 2 + ], [[1, 'elapsed', 2, 'open']]); + assert.equal(linkValid(d, d.links[0]), true); + assert.equal(linkRule(d, d.links[0]), 'pulse'); + }); +}); + +describe('multi rows (MULTI-01/02)', () => { + test('capacity gates docking; growing a row unlocks a second source', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, // pressed out at row 2 + { id: 2, type: 'wait', col: 1, row: 3 }, // done out at row 4 + { id: 3, type: 'action', col: 2, row: 1 }, // trigger in at row 2, capacity 1 + ], [[1, 'pressed', 3, 'trigger']]); + // Full: wait.done (row 4) is too far anyway, move wait so done sits at row 3. + d.nodes[1].row = 2; // done at row 3 → dRow 1 to trigger + assert.equal(settleLinks(d, 2).added, 0); // capacity 1 already used + assert.equal(setPortRows(d, d.nodes[2], 'in', 'trigger', +1), true); + assert.equal(portCapacity(d.nodes[2], 'in', 'trigger'), 2); + const r = settleLinks(d, 2); // trigger row 3 now exists + assert.equal(r.added, 1); + assert.equal(portLoad(d, 3, 'in', 'trigger'), 2); + }); + + test('shrinking below the connected load is refused', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 3, type: 'action', col: 2, row: 1, rep: { 'in:trigger': 1 } }, + ], [[1, 'pressed', 3, 'trigger']]); + assert.equal(setPortRows(d, d.nodes[1], 'in', 'trigger', -1), true); // load 1, min 1 ok + assert.equal(setPortRows(d, d.nodes[1], 'in', 'trigger', -1), false); // below 1 row + }); + + test('a repeatable output fans out to several neighbors', () => { + const d = doc([ + { id: 1, type: 'constant', col: 1, row: 1, rep: { 'out:value': 1 } }, // value rows 2+3 + { id: 2, type: 'log', col: 2, row: 1 }, // value in at row 2 + { id: 3, type: 'log', col: 2, row: 3 }, // value in at row 4 (±1 of value row 3) + ]); + const r = settleLinks(d, 1); + assert.equal(r.added, 2); + assert.equal(portLoad(d, 1, 'out', 'value'), 2); + assert.deepEqual(validateDoc(d), []); + }); +}); + +describe('validateDoc (VAL-02)', () => { + test('accepts a consistent document and flags overlap + undockable links', () => { + const good = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 2, row: 1 }, + ], [[1, 'pressed', 2, 'start']]); + assert.deepEqual(validateDoc(good), []); + + const bad = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'timer', col: 1, row: 2 }, + ], [[1, 'pressed', 2, 'start']]); + const errors = validateDoc(bad); + assert.ok(errors.some(e => e.includes('overlap'))); + assert.ok(errors.some(e => e.includes('not dockable'))); + }); + + test('flags links beyond a port\'s row capacity', () => { + const d = doc([ + { id: 1, type: 'button', col: 1, row: 1 }, + { id: 2, type: 'wait', col: 1, row: 3 }, + { id: 3, type: 'action', col: 2, row: 1 }, + ], [[1, 'pressed', 3, 'trigger'], [2, 'done', 3, 'trigger']]); + d.nodes[1].row = 2; // done at row 3, within reach of trigger row 2 + assert.ok(validateDoc(d).some(e => e.includes('exceeds its row capacity'))); + }); + + test('every registry entry produces a positive height', () => { + for (const t of Object.keys(NODE_TYPES)) assert.ok(nodeHeight(t) >= 2, t); + }); +}); diff --git a/server/graphs.html b/server/graphs.html new file mode 100644 index 0000000..150fd81 --- /dev/null +++ b/server/graphs.html @@ -0,0 +1,1095 @@ + + + + + +LightWitch — Graph Editor + + + + +
+ + + + + + + + +
+ +
+ +
🗑 Drop here to delete
+
+
+
Flow runs left to right — inputs sit on a node's left edge, + outputs on its right. Drag a node next to another so their ports meet.
+
+
+
+ +
+
+
+
→ swipe to see more columns
+
+ + + + diff --git a/server/index.js b/server/index.js index 5166aee..281144a 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ import { dirname, join } from 'path'; import express from 'express'; import { WebSocketServer } from 'ws'; import { authRouter, requireAuth } from './auth.js'; +import { validateDoc as validateGraphDoc } from './graphs-core.js'; const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'data'); @@ -1192,6 +1193,86 @@ app.post('/api/update/apply-pr', (req, res) => { _simulateFlash(newVersion, () => { MOCK_CONFIG.prTrack = tag; }); }); +// ── Graphs (mock-only, issue #464) ────────────────────────────────────────── +// The dock-editor shell and its /api/graphs storage exist only in this mock +// for now — the real endpoints and execution arrive with the M2 graph engine +// (GraphManager, SceneManager-style). Schema v1 per the rework plan; col/row +// on nodes and the notes array are editor-only metadata, and connections are +// defined by adjacency (interaction concept: docking, no drawn wires) — the +// shared core in graphs-core.js validates that geometry on PUT. The editor +// page lives in server/ (not data/) so it never ships to devices — LittleFS +// is nearly full (#377); it moves into the device UI with M8. +const GRAPH_NAME_RE = /^[a-z0-9-]{1,32}$/i; + +// Seeds are adjacency-consistent: every link's pins sit in neighboring +// columns within ±1 row (see graphs-core.js). buzzergame also seeds a +// multi-row input (action.trigger with one extra row) fed by two sources. +const mockGraphs = new Map([ + ['buzzergame', { + v: 1, name: 'buzzergame', active: true, requires: ['button:main', 'stage:main'], + nodes: [ + { id: 1, type: 'button', role: 'button:main', col: 1, row: 1, cfg: {} }, + { id: 2, type: 'wait', col: 2, row: 1, cfg: { ms: 2000 } }, + { id: 3, type: 'action', col: 3, row: 1, cfg: { action: 'SceneNext' }, rep: { 'in:trigger': 1 } }, + { id: 4, type: 'timer', col: 2, row: 3, cfg: { ms: 10000 } }, + ], + links: [[1, 'pressed', 2, 'start'], [2, 'done', 3, 'trigger'], [4, 'elapsed', 3, 'trigger']], + notes: [{ col: 1, row: 5, text: 'registration' }], + }], + ['nightlight', { + v: 1, name: 'nightlight', active: false, requires: [], + nodes: [ + { id: 1, type: 'mesh-receive', col: 1, row: 2, cfg: { eventType: 'buzz.press' } }, + { id: 2, type: 'range', col: 2, row: 2, cfg: { min: 1, max: 3 } }, + { id: 3, type: 'gate', col: 3, row: 2, cfg: {} }, + { id: 4, type: 'log', col: 4, row: 2, cfg: {} }, + { id: 5, type: 'constant', col: 1, row: 6, cfg: { defaults: { value: 80 } } }, + { id: 6, type: 'gate', col: 2, row: 5, cfg: {} }, + { id: 7, type: 'log', col: 3, row: 5, cfg: {} }, + ], + links: [ + [1, 'payload', 2, 'value'], + [2, 'outOfRange', 3, 'open'], + [3, 'value', 4, 'value'], + [5, 'value', 6, 'open'], // value>switch — docks as a adapter (threshold) + [6, 'value', 7, 'value'], + ], + notes: [{ col: 4, row: 5, text: 'adapter demo: value → switch' }], + }], +]); + +const serverDir = dirname(fileURLToPath(import.meta.url)); +app.get('/graphs.html', (_req, res) => res.sendFile(join(serverDir, 'graphs.html'))); +app.get('/graphs-core.js', (_req, res) => res.sendFile(join(serverDir, 'graphs-core.js'))); + +app.get('/api/graphs', (_req, res) => res.json({ + graphs: [...mockGraphs.values()].map(({ name, active }) => ({ name, active })), +})); + +app.get('/api/graphs/:name', (req, res) => { + const graph = mockGraphs.get(req.params.name); + if (!graph) return res.status(404).json({ error: 'not found' }); + res.json(graph); +}); + +// Validation via the shared editor core (VAL-02: applying with errors is not +// allowed) — the M2 engine's load/validate/compile stays authoritative later. +app.put('/api/graphs/:name', (req, res) => { + const name = req.params.name; + if (!GRAPH_NAME_RE.test(name)) return res.status(400).json({ error: 'invalid name' }); + const doc = req.body || {}; + if (doc.name !== name) return res.status(400).json({ error: 'invalid graph document' }); + const errors = validateGraphDoc(doc); + if (errors.length) return res.status(400).json({ error: errors[0] }); + mockGraphs.set(name, doc); + res.json({ ok: true }); +}); + +app.delete('/api/graphs/:name', (req, res) => { + if (!mockGraphs.delete(req.params.name)) return res.status(404).json({ error: 'not found' }); + res.json({ ok: true }); +}); + app.get('/*path', (_req, res) => res.sendFile(join(DATA_DIR, 'index.html'))); const server = http.createServer(app); diff --git a/server/index.test.js b/server/index.test.js index 9a6ed20..34a1914 100644 --- a/server/index.test.js +++ b/server/index.test.js @@ -184,6 +184,62 @@ describe('POST /api/lights/update', () => { }); }); +describe('/api/graphs (mock-only, issue #464)', () => { + const putGraph = (name, doc) => fetch(`${baseUrl}/api/graphs/${name}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(doc), + }); + + test('lists the seeded graphs', async () => { + const res = await fetch(`${baseUrl}/api/graphs`); + assert.equal(res.status, 200); + const { graphs } = await res.json(); + assert.ok(graphs.some(g => g.name === 'buzzergame' && g.active === true)); + assert.ok(graphs.some(g => g.name === 'nightlight')); + }); + + test('returns a full schema-v1 document', async () => { + const res = await fetch(`${baseUrl}/api/graphs/buzzergame`); + assert.equal(res.status, 200); + const doc = await res.json(); + assert.equal(doc.v, 1); + assert.equal(doc.nodes.length, 4); + assert.deepEqual(doc.links[0], [1, 'pressed', 2, 'start']); + assert.equal(doc.notes[0].text, 'registration'); + // The seeded multi-row input carries two sources (MULTI-01). + assert.equal(doc.links.filter(e => e[2] === 3 && e[3] === 'trigger').length, 2); + }); + + test('404 for an unknown graph', async () => { + assert.equal((await fetch(`${baseUrl}/api/graphs/nope`)).status, 404); + }); + + test('PUT stores a document and it round-trips', async () => { + const doc = { + v: 1, name: 'testgraph', active: false, requires: [], + nodes: [{ id: 1, type: 'button', col: 2, row: 3, cfg: {} }], + links: [], notes: [{ col: 1, row: 1, text: 'hi' }], + }; + assert.equal((await putGraph('testgraph', doc)).status, 200); + const stored = await (await fetch(`${baseUrl}/api/graphs/testgraph`)).json(); + assert.deepEqual(stored, doc); + }); + + test('PUT rejects an invalid name and a structurally invalid document', async () => { + const ok = { v: 1, name: 'bad name', nodes: [], links: [] }; + assert.equal((await putGraph('bad%20name', { ...ok })).status, 400); + assert.equal((await putGraph('valid', { name: 'valid', nodes: [], links: [] })).status, 400); + assert.equal((await putGraph('valid', { v: 1, name: 'other', nodes: [], links: [] })).status, 400); + }); + + test('DELETE removes a graph', async () => { + await putGraph('doomed', { v: 1, name: 'doomed', nodes: [], links: [], notes: [] }); + assert.equal((await fetch(`${baseUrl}/api/graphs/doomed`, { method: 'DELETE' })).status, 200); + assert.equal((await fetch(`${baseUrl}/api/graphs/doomed`)).status, 404); + }); +}); + describe('GET /api/sounds', () => { test('returns the configured sound outputs', async () => { const res = await fetch(`${baseUrl}/api/sounds`);