/** * rigor/node-manager.test.js — js-rigor property tests for NodeManager. * * NodeManager owns the three index structures that map the graph: * - nodes: Map * - nodeIdByKey: Map * - keyByNodeId: Map * * Properties verified: * - Inverse maps: getNodeKey(getNodeId(key)) === key, getNodeId(getNodeKey(id)) === id * - Idempotence: addNode(key, ...) twice returns the same nodeId * - Size invariant: |nodes| === |nodeIdByKey| === |keyByNodeId| * - Monotonic nextNodeId: nextNodeId is strictly increasing across distinct addNode calls * - removeNode cleans all three indexes * - updateNodeData merges data into existing node * - clearNodes resets all three indexes and nextNodeId */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { NodeManager } from '../../src/core/NodeManager.js'; const NODE_TYPES = ['user', 'document', 'group', 'role', 'project']; /** * Build a minimal arbiter stub that satisfies NodeManager's surface. * Records relationManager.removeRelation calls for inspection. */ function makeArbiter() { const arbiter = { nodeIdByKey: new Map(), keyByNodeId: new Map(), nodes: new Map(), nextNodeId: 0, relations: [], removedRelations: [], embeddingManager: null, similarityManager: null, dependencyIndex: null, decisionCache: null, relationManager: { removeRelation(srcKey, rel, dstKey) { arbiter.removedRelations.push({ srcKey, rel, dstKey }); // Cascade: drop the matching entries from arbiter.relations for (let i = arbiter.relations.length - 1; i >= 0; i--) { const r = arbiter.relations[i]; const srcId = arbiter.nodeIdByKey.get(srcKey); const dstId = arbiter.nodeIdByKey.get(dstKey); if (r.src === srcId && r.dst === dstId && r.rel === rel) { arbiter.relations.splice(i, 1); } } } } }; return arbiter; } function makeManager() { const arbiter = makeArbiter(); const manager = new NodeManager(arbiter); return { manager, arbiter }; } describe('NodeManager index invariants (rigor)', () => { it('inverse maps: getNodeKey(getNodeId(key)) === key and back', async () => { async function check(key, type) { const { manager, arbiter } = makeManager(); manager.addNode(key, type); const nodeId = arbiter.nodeIdByKey.get(key); if (nodeId === undefined) throw new Error(`addNode failed for key=${key}`); const backKey = manager.getNodeKey(nodeId); if (backKey !== key) { throw new Error(`round-trip mismatch: ${key} → ${nodeId} → ${backKey}`); } const backId = manager.getNodeId(key); if (backId !== nodeId) { throw new Error(`getNodeId(${key}) = ${backId}, expected ${nodeId}`); } return { nodeId, key }; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.string(1, 30), rigor.gen.enum(NODE_TYPES) ) ) ], rigor.crucible([ rigor.invariant('inverse-maps', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'inverse-maps'); assert.ok(inv); assert.equal(inv.passed, true, `inverse-map property violated in ${inv.failureCount} cases`); }); it('addNode is idempotent for the same key', async () => { async function check(key, type) { const { manager, arbiter } = makeManager(); const id1 = manager.addNode(key, type); const id2 = manager.addNode(key, type); // duplicate if (id1 !== id2) { throw new Error(`addNode not idempotent: ${id1} vs ${id2} for key=${key}`); } // Only one entry in nodes if (manager.getNodeCount() !== 1) { throw new Error(`expected 1 node, got ${manager.getNodeCount()}`); } // nextNodeId should NOT have advanced for the duplicate if (arbiter.nextNodeId !== 1) { throw new Error(`expected nextNodeId=1 after idempotent add, got ${arbiter.nextNodeId}`); } return id1; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.string(1, 30), rigor.gen.enum(NODE_TYPES) ) ) ], rigor.crucible([ rigor.invariant('addNode-idempotent', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addNode-idempotent'); assert.ok(inv); assert.equal(inv.passed, true, `addNode idempotence violated in ${inv.failureCount} cases`); }); it('size invariant: |nodes| === |nodeIdByKey| === |keyByNodeId| after every mutation', async () => { async function check(operations) { // Each operation is a tuple [op, ...args]. Op codes: // 0: addNode(key, type) // 1: removeNode(key) // 2: clearNodes() const { manager } = makeManager(); for (const op of operations) { if (op[0] === 0) manager.addNode(op[1], op[2]); else if (op[0] === 1) manager.removeNode(op[1]); else if (op[0] === 2) manager.clearNodes(); const n1 = manager.getAllNodes().length; const n2 = manager.getAllNodeKeys().length; const n3 = manager.arbiter.keyByNodeId.size; if (n1 !== n2 || n2 !== n3) { throw new Error( `size mismatch after op=${JSON.stringify(op)}: ` + `nodes=${n1} nodeIdByKey=${n2} keyByNodeId=${n3}` ); } } return true; } // Build a generator that produces a sequence of operations. const opGen = rigor.gen.array( rigor.gen.oneOf([ rigor.gen.tuple(rigor.gen.constant(0), rigor.gen.string(1, 10), rigor.gen.enum(NODE_TYPES)), rigor.gen.tuple(rigor.gen.constant(1), rigor.gen.string(1, 10)), rigor.gen.tuple(rigor.gen.constant(2)) ]), 1, 8 ); const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args(opGen) ) ], rigor.crucible([ rigor.invariant('size-invariant', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 1500 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'size-invariant'); assert.ok(inv); assert.equal(inv.passed, true, `size invariant violated in ${inv.failureCount} cases`); }); it('nextNodeId advances monotonically across distinct addNode calls', async () => { async function check(keys, types) { const { manager, arbiter } = makeManager(); if (keys.length !== types.length) return; // skip ill-formed const ids = []; for (let i = 0; i < keys.length; i++) { ids.push(manager.addNode(keys[i], types[i])); } const uniqueKeys = new Set(keys); // nextNodeId should equal uniqueKeys.size after all adds // (idempotence ensures duplicates don't bump nextNodeId) if (arbiter.nextNodeId !== uniqueKeys.size) { throw new Error( `expected nextNodeId=${uniqueKeys.size}, got ${arbiter.nextNodeId}` ); } // IDs returned should be unique across unique keys const uniqueIds = new Set(ids); if (uniqueIds.size !== uniqueKeys.size) { throw new Error( `expected ${uniqueKeys.size} unique IDs, got ${uniqueIds.size}` ); } // Each unique key should map to a non-decreasing ID const seen = new Map(); for (let i = 0; i < keys.length; i++) { const id = arbiter.nodeIdByKey.get(keys[i]); if (seen.has(keys[i])) { if (seen.get(keys[i]) !== id) { throw new Error(`key ${keys[i]} mapped to different IDs`); } } else { seen.set(keys[i], id); // ID must equal current nextNodeId - 1 at time of first insertion if (id !== seen.size - 1) { throw new Error(`unexpected id ${id} for new key ${keys[i]}`); } } } return true; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.array(rigor.gen.string(1, 8), 1, 5), rigor.gen.array(rigor.gen.enum(NODE_TYPES), 1, 5) ) ) ], rigor.crucible([ rigor.invariant('monotonic-ids', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'monotonic-ids'); assert.ok(inv); assert.equal(inv.passed, true, `monotonic id assignment violated in ${inv.failureCount} cases`); }); it('removeNode removes from all three indexes', async () => { async function check(addKeys, removeKey) { const { manager, arbiter } = makeManager(); for (const k of addKeys) manager.addNode(k, 'user'); if (!arbiter.nodeIdByKey.has(removeKey)) { // removeKey not in our adds; skip return true; } const removedId = arbiter.nodeIdByKey.get(removeKey); const result = manager.removeNode(removeKey); if (result !== true) { throw new Error(`removeNode returned ${result}, expected true`); } if (manager.getNode(removedId) !== undefined) { throw new Error(`nodes still has entry for ${removeKey}`); } if (arbiter.nodeIdByKey.has(removeKey)) { throw new Error(`nodeIdByKey still has ${removeKey}`); } if (arbiter.keyByNodeId.has(removedId)) { throw new Error(`keyByNodeId still has id ${removedId}`); } return true; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.array(rigor.gen.string(1, 8), 1, 5), rigor.gen.string(1, 8) ) ) ], rigor.crucible([ rigor.invariant('removeNode-cleanup', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'removeNode-cleanup'); assert.ok(inv); assert.equal(inv.passed, true, `removeNode cleanup violated in ${inv.failureCount} cases`); }); it('clearNodes resets all state', async () => { async function check(addKeys) { const { manager, arbiter } = makeManager(); for (const k of addKeys) manager.addNode(k, 'user'); manager.clearNodes(); if (manager.getNodeCount() !== 0) { throw new Error(`getNodeCount=${manager.getNodeCount()} after clear, expected 0`); } if (manager.getAllNodeKeys().length !== 0) { throw new Error(`getAllNodeKeys non-empty after clear`); } if (arbiter.keyByNodeId.size !== 0) { throw new Error(`keyByNodeId non-empty after clear`); } if (arbiter.nextNodeId !== 0) { throw new Error(`nextNodeId=${arbiter.nextNodeId} after clear, expected 0`); } return true; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.array(rigor.gen.string(1, 8), 1, 5) ) ) ], rigor.crucible([ rigor.invariant('clearNodes-resets', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 500 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'clearNodes-resets'); assert.ok(inv); assert.equal(inv.passed, true, `clearNodes reset violated in ${inv.failureCount} cases`); }); it('updateNodeData merges into existing node', async () => { async function check(initialData, updateData) { const { manager, arbiter } = makeManager(); manager.addNode('user:1', 'user', initialData); const ok = manager.updateNodeData('user:1', updateData); if (!ok) throw new Error(`updateNodeData returned false`); const node = arbiter.nodes.get(arbiter.nodeIdByKey.get('user:1')); for (const [k, v] of Object.entries(updateData)) { if (node.data[k] !== v) { throw new Error(`data.${k} = ${node.data[k]}, expected ${v}`); } } // initialData fields not in updateData should still be present for (const k of Object.keys(initialData)) { if (!(k in updateData)) { if (node.data[k] !== initialData[k]) { throw new Error(`data.${k} was clobbered: ${node.data[k]} vs initial ${initialData[k]}`); } } } // updateNodeData should mark the node stale if (!node.stale) throw new Error('node should be stale after updateNodeData'); return true; } // Use small object shapes that rigor can generate const initialGen = rigor.gen.object({ role: rigor.gen.enum(['admin', 'user', 'guest']), age: rigor.gen.int(0, 100) }); const updateGen = rigor.gen.object({ role: rigor.gen.enum(['admin', 'user', 'guest']), // can override email: rigor.gen.string(1, 30) // adds new key }); const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args(initialGen, updateGen) ) ], rigor.crucible([ rigor.invariant('updateNodeData-merges', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 800 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'updateNodeData-merges'); assert.ok(inv); assert.equal(inv.passed, true, `updateNodeData merge violated in ${inv.failureCount} cases`); }); });