2026-07-31 13:44:06 -07:00
|
|
|
/**
|
|
|
|
|
* 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([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('inverse-maps', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'node-manager-inverse-maps', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('addNode-idempotent', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'node-manager-add-idempotent', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('size-invariant', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'node-manager-size-invariant', effort: 1500 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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();
|
2026-08-03 13:26:42 -07:00
|
|
|
if (keys.length !== types.length) return { skipped: true }; // skip ill-formed
|
2026-07-31 13:44:06 -07:00
|
|
|
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([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('monotonic-ids', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'node-manager-monotonic-ids', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('removeNode-cleanup', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'node-manager-remove-cleanup', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('clearNodes-resets', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'node-manager-clear-resets', effort: 500 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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([
|
2026-08-03 13:26:42 -07:00
|
|
|
rigor.invariant('updateNodeData-merges', ({ actual }) => actual !== undefined)
|
2026-07-31 13:44:06 -07:00
|
|
|
])
|
2026-08-02 16:39:36 -07:00
|
|
|
).run({ seed: 'node-manager-update-merge', effort: 800 , artifacts: { dir: '', persist: 'never' }});
|
2026-07-31 13:44:06 -07:00
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
});
|
|
|
|
|
});
|