Files
core/tests/rigor/node-manager.test.js
T

400 lines
14 KiB
JavaScript
Raw Normal View History

/**
* rigor/node-manager.test.js — js-rigor property tests for NodeManager.
*
* NodeManager owns the three index structures that map the graph:
* - nodes: Map<nodeId, { key, type, data, ... }>
* - nodeIdByKey: Map<key, nodeId>
* - keyByNodeId: Map<nodeId, key>
*
* 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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'node-manager-inverse-maps', 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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'node-manager-add-idempotent', 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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'node-manager-size-invariant', 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 { skipped: true }; // 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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'node-manager-monotonic-ids', 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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'node-manager-remove-cleanup', 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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'node-manager-clear-resets', 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', ({ actual }) => actual !== undefined)
])
2026-08-02 16:39:36 -07:00
).run({ seed: 'node-manager-update-merge', 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`);
});
});