407 lines
17 KiB
JavaScript
407 lines
17 KiB
JavaScript
|
|
/**
|
||
|
|
* rigor/relation-manager.test.js — js-rigor property tests for RelationManager.
|
||
|
|
*
|
||
|
|
* RelationManager owns relation lifecycle (addRelation, removeRelation) and
|
||
|
|
* exposes getDirectRelation. Properties verified using a real Arbiter (no
|
||
|
|
* mocking — the manager surface is small and Arbiter construction is cheap):
|
||
|
|
*
|
||
|
|
* - addRelation(src, rel, dst) is idempotent (returns relationIndex, but the
|
||
|
|
* second call routes to _modifyRelation and updates the existing entry)
|
||
|
|
* - getDirectRelation(src, rel, dst) returns null for unknown tuples
|
||
|
|
* - getDirectRelation returns the live relation object after addRelation
|
||
|
|
* - removeRelation clears all five indexes (GraphIndices + arbiter.relations)
|
||
|
|
* - Cross-check: arbiter.indices.getDirectRelation === relationManager.getDirectRelation
|
||
|
|
* - _relationKeys Set size matches arbiter.relations length
|
||
|
|
* - _relationKeyToIndex points at the correct index in arbiter.relations
|
||
|
|
* - getDirectRelation after removeRelation returns null
|
||
|
|
*/
|
||
|
|
import { describe, it } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { rigor } from '@rigor/core';
|
||
|
|
import { Arbiter } from '../../src/index.js';
|
||
|
|
|
||
|
|
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build an Arbiter pre-populated with the given relations. Each entry is
|
||
|
|
* {src, rel, dst, possibility}.
|
||
|
|
*/
|
||
|
|
function makeArbiter(initialRelations = []) {
|
||
|
|
const arbiter = new Arbiter();
|
||
|
|
const keyIds = new Map();
|
||
|
|
let nextId = 0;
|
||
|
|
for (const r of initialRelations) {
|
||
|
|
const srcKey = `u${r.src}`;
|
||
|
|
const dstKey = `d${r.dst}`;
|
||
|
|
if (!arbiter.nodeIdByKey.has(srcKey)) arbiter.addNode(srcKey, 'user');
|
||
|
|
if (!arbiter.nodeIdByKey.has(dstKey)) arbiter.addNode(dstKey, 'doc');
|
||
|
|
arbiter.addRelation(srcKey, r.rel, dstKey, { possibility: r.possibility ?? 1.0 });
|
||
|
|
}
|
||
|
|
return arbiter;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('RelationManager.addRelation/removeRelation (rigor)', () => {
|
||
|
|
it('addRelation then getDirectRelation returns the live relation object', async () => {
|
||
|
|
async function check(src, rel, dst, possibility) {
|
||
|
|
const arbiter = makeArbiter();
|
||
|
|
arbiter.addNode(`u${src}`, 'user');
|
||
|
|
arbiter.addNode(`d${dst}`, 'doc');
|
||
|
|
const srcId = arbiter.nodeIdByKey.get(`u${src}`);
|
||
|
|
const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
|
||
|
|
arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility });
|
||
|
|
const result = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
|
||
|
|
if (!result) {
|
||
|
|
throw new Error(`expected getDirectRelation(${srcId},${rel},${dstId}) to return relation, got null`);
|
||
|
|
}
|
||
|
|
if (result.possibility !== possibility) {
|
||
|
|
throw new Error(`possibility=${result.possibility}, expected ${possibility}`);
|
||
|
|
}
|
||
|
|
if (result.rel !== rel) {
|
||
|
|
throw new Error(`rel=${result.rel}, expected ${rel}`);
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.int(0, 20),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.int(0, 20),
|
||
|
|
rigor.gen.float({ min: 0.01, max: 1 })
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('add-and-get', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 800 });
|
||
|
|
|
||
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-and-get');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `addRelation→getDirectRelation contract violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('addRelation is idempotent: re-add with same (src,rel,dst) updates in place, not adds new', async () => {
|
||
|
|
async function check(src, rel, dst, possibility1, possibility2) {
|
||
|
|
const arbiter = makeArbiter();
|
||
|
|
arbiter.addNode(`u${src}`, 'user');
|
||
|
|
arbiter.addNode(`d${dst}`, 'doc');
|
||
|
|
const srcId = arbiter.nodeIdByKey.get(`u${src}`);
|
||
|
|
const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
|
||
|
|
arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: possibility1 });
|
||
|
|
const initialLen = arbiter.relations.length;
|
||
|
|
|
||
|
|
// Re-add with different possibility
|
||
|
|
arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: possibility2 });
|
||
|
|
|
||
|
|
// Length should not have grown (duplicate → modify, not insert)
|
||
|
|
if (arbiter.relations.length !== initialLen) {
|
||
|
|
throw new Error(`relations array grew from ${initialLen} to ${arbiter.relations.length} after duplicate add`);
|
||
|
|
}
|
||
|
|
// _relationKeys Set should have only one entry for this (src, rel, dst)
|
||
|
|
if (arbiter.relationManager._relationKeys.size !== 1) {
|
||
|
|
throw new Error(`_relationKeys.size=${arbiter.relationManager._relationKeys.size}, expected 1`);
|
||
|
|
}
|
||
|
|
// The new possibility should be reflected in getDirectRelation
|
||
|
|
const result = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
|
||
|
|
if (!result) {
|
||
|
|
throw new Error(`getDirectRelation returned null after duplicate add`);
|
||
|
|
}
|
||
|
|
if (result.possibility !== possibility2) {
|
||
|
|
throw new Error(`possibility=${result.possibility}, expected ${possibility2} (the second add)`);
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.int(0, 20),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.int(0, 20),
|
||
|
|
rigor.gen.float({ min: 0.01, max: 1 }),
|
||
|
|
rigor.gen.float({ min: 0.01, max: 1 })
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('addRelation-idempotent', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 800 });
|
||
|
|
|
||
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'addRelation-idempotent');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `addRelation idempotence violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('removeRelation clears all five indexes and returns getDirectRelation to null', async () => {
|
||
|
|
async function check(src, rel, dst) {
|
||
|
|
const arbiter = makeArbiter();
|
||
|
|
arbiter.addNode(`u${src}`, 'user');
|
||
|
|
arbiter.addNode(`d${dst}`, 'doc');
|
||
|
|
const srcId = arbiter.nodeIdByKey.get(`u${src}`);
|
||
|
|
const dstId = arbiter.nodeIdByKey.get(`d${dst}`);
|
||
|
|
arbiter.addRelation(`u${src}`, rel, `d${dst}`, { possibility: 0.5 });
|
||
|
|
// Verify pre-conditions
|
||
|
|
if (!arbiter.relationManager.getDirectRelation(srcId, rel, dstId)) {
|
||
|
|
throw new Error('pre-condition failed: relation not present after add');
|
||
|
|
}
|
||
|
|
|
||
|
|
arbiter.relationManager.removeRelation(`u${src}`, rel, `d${dst}`);
|
||
|
|
|
||
|
|
// After remove, getDirectRelation should be null
|
||
|
|
const after = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
|
||
|
|
if (after !== null && after !== undefined) {
|
||
|
|
throw new Error(`expected null after remove, got ${JSON.stringify(after)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// arbiter.indices should also be clear
|
||
|
|
const fromIdx = arbiter.indices.getDirectRelation(srcId, rel, dstId);
|
||
|
|
if (fromIdx !== undefined) {
|
||
|
|
throw new Error(`arbiter.indices still has entry after remove: ${JSON.stringify(fromIdx)}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// _relationKeys should not have this tuple
|
||
|
|
const key = arbiter.relationManager._makeRelationKey(srcId, rel, dstId);
|
||
|
|
if (arbiter.relationManager._relationKeys.has(key)) {
|
||
|
|
throw new Error(`_relationKeys still has ${key} after remove`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// byName should not have this relation
|
||
|
|
const byName = arbiter.indices.getRelationsByName(rel);
|
||
|
|
if (byName.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
|
||
|
|
throw new Error(`getRelationsByName still contains removed relation`);
|
||
|
|
}
|
||
|
|
const bySrc = arbiter.indices.getRelationsFromSrc(srcId, rel);
|
||
|
|
if (bySrc.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
|
||
|
|
throw new Error(`getRelationsFromSrc still contains removed relation`);
|
||
|
|
}
|
||
|
|
const byDst = arbiter.indices.getRelationsToDst(dstId, rel);
|
||
|
|
if (byDst.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
|
||
|
|
throw new Error(`getRelationsToDst still contains removed relation`);
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.int(0, 20),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.int(0, 20)
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('remove-clears-indexes', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 800 });
|
||
|
|
|
||
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'remove-clears-indexes');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `removeRelation index cleanup violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('index coherence: every relation in arbiter.relations is queryable through every index', async () => {
|
||
|
|
async function check(relations) {
|
||
|
|
// Cap input size to keep the test fast
|
||
|
|
if (relations.length > 5) return null;
|
||
|
|
const arbiter = makeArbiter();
|
||
|
|
const seenKeys = new Set();
|
||
|
|
for (const r of relations) {
|
||
|
|
const srcKey = `u${r.src}`;
|
||
|
|
const dstKey = `d${r.dst}`;
|
||
|
|
if (!arbiter.nodeIdByKey.has(srcKey)) arbiter.addNode(srcKey, 'user');
|
||
|
|
if (!arbiter.nodeIdByKey.has(dstKey)) arbiter.addNode(dstKey, 'doc');
|
||
|
|
const k = `${r.src}|${r.rel}|${r.dst}`;
|
||
|
|
if (seenKeys.has(k)) continue; // skip duplicates — relation manager would modify
|
||
|
|
seenKeys.add(k);
|
||
|
|
arbiter.addRelation(srcKey, r.rel, dstKey, { possibility: r.possibility });
|
||
|
|
}
|
||
|
|
|
||
|
|
// For each unique (src, rel, dst) in the input, verify all indexes agree
|
||
|
|
for (const k of seenKeys) {
|
||
|
|
const [src, rel, dst] = k.split('|');
|
||
|
|
const srcKey = `u${src}`;
|
||
|
|
const dstKey = `d${dst}`;
|
||
|
|
const srcId = arbiter.nodeIdByKey.get(srcKey);
|
||
|
|
const dstId = arbiter.nodeIdByKey.get(dstKey);
|
||
|
|
|
||
|
|
// 1. arbiter.indices.getDirectRelation
|
||
|
|
const fromIdx = arbiter.indices.getDirectRelation(srcId, rel, dstId);
|
||
|
|
if (!fromIdx) throw new Error(`arbiter.indices.getDirectRelation(${k}) returned null`);
|
||
|
|
|
||
|
|
// 2. relationManager.getDirectRelation (should agree)
|
||
|
|
const fromMgr = arbiter.relationManager.getDirectRelation(srcId, rel, dstId);
|
||
|
|
if (fromMgr !== fromIdx) {
|
||
|
|
throw new Error(`relationManager.getDirectRelation !== arbiter.indices.getDirectRelation for ${k}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. byName must contain this relation
|
||
|
|
const byName = arbiter.indices.getRelationsByName(rel);
|
||
|
|
if (!byName.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
|
||
|
|
throw new Error(`getRelationsByName(${rel}) missing ${k}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. bySrc must contain this relation
|
||
|
|
const bySrc = arbiter.indices.getRelationsFromSrc(srcId, rel);
|
||
|
|
if (!bySrc.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
|
||
|
|
throw new Error(`getRelationsFromSrc(${srcId},${rel}) missing ${k}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. byDst must contain this relation
|
||
|
|
const byDst = arbiter.indices.getRelationsToDst(dstId, rel);
|
||
|
|
if (!byDst.some(r => r.src === srcId && r.dst === dstId && r.rel === rel)) {
|
||
|
|
throw new Error(`getRelationsToDst(${dstId},${rel}) missing ${k}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Cross-consistency: _relationKeys.size matches arbiter.relations.length
|
||
|
|
// (modulo dedup — we passed unique keys above)
|
||
|
|
if (arbiter.relationManager._relationKeys.size !== arbiter.relations.length) {
|
||
|
|
throw new Error(
|
||
|
|
`_relationKeys.size=${arbiter.relationManager._relationKeys.size} !== ` +
|
||
|
|
`arbiter.relations.length=${arbiter.relations.length}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const relGen = rigor.gen.array(
|
||
|
|
rigor.gen.tuple(
|
||
|
|
rigor.gen.int(0, 4),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.int(0, 4),
|
||
|
|
rigor.gen.float({ min: 0.01, max: 1 })
|
||
|
|
).map(([src, rel, dst, p]) => ({ src, rel, dst, possibility: p })),
|
||
|
|
1, 5
|
||
|
|
);
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check, rigor.args(relGen))],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('index-coherence', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 1500 });
|
||
|
|
|
||
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'index-coherence');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `index coherence violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('add-then-remove sequence: state matches initial (no leaks)', async () => {
|
||
|
|
async function check(operations) {
|
||
|
|
const arbiter = makeArbiter();
|
||
|
|
// Each operation: [opCode, src, rel, dst]
|
||
|
|
// 0 = addRelation, 1 = removeRelation
|
||
|
|
for (const op of operations) {
|
||
|
|
if (op[0] === 0) {
|
||
|
|
const [, src, rel, dst] = op;
|
||
|
|
if (!arbiter.nodeIdByKey.has(`u${src}`)) arbiter.addNode(`u${src}`, 'user');
|
||
|
|
if (!arbiter.nodeIdByKey.has(`d${dst}`)) arbiter.addNode(`d${dst}`, 'doc');
|
||
|
|
arbiter.addRelation(`u${src}`, rel, `d${dst}`);
|
||
|
|
} else if (op[0] === 1) {
|
||
|
|
const [, src, rel, dst] = op;
|
||
|
|
if (!arbiter.nodeIdByKey.has(`u${src}`)) continue; // node not added yet
|
||
|
|
if (!arbiter.nodeIdByKey.has(`d${dst}`)) continue;
|
||
|
|
arbiter.relationManager.removeRelation(`u${src}`, rel, `d${dst}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify: for every key in _relationKeys, arbiter.relations has it
|
||
|
|
// AND arbiter.indices.getDirectRelation returns it
|
||
|
|
// _relationKeys keys are formatted as "srcId|relationId|dstId" where
|
||
|
|
// relationId is an internal id, not the relation name. Build a reverse map.
|
||
|
|
const relIdToName = new Map();
|
||
|
|
for (const [name, id] of arbiter.relationManager._relationNameToId) {
|
||
|
|
relIdToName.set(id, name);
|
||
|
|
}
|
||
|
|
for (const key of arbiter.relationManager._relationKeys) {
|
||
|
|
const [srcIdStr, relationIdStr, dstIdStr] = key.split('|');
|
||
|
|
const srcId = parseInt(srcIdStr, 10);
|
||
|
|
const dstId = parseInt(dstIdStr, 10);
|
||
|
|
const relId = parseInt(relationIdStr, 10);
|
||
|
|
const relName = relIdToName.get(relId);
|
||
|
|
if (!relName) {
|
||
|
|
throw new Error(`cannot resolve relationId ${relId} to name (keyManager state: ${JSON.stringify([...relIdToName])})`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const idx = arbiter.indices.getDirectRelation(srcId, relName, dstId);
|
||
|
|
if (!idx) {
|
||
|
|
throw new Error(`_relationKeys has ${key} but indices.getDirectRelation returns null`);
|
||
|
|
}
|
||
|
|
// The relation should also be findable in arbiter.relations
|
||
|
|
const found = arbiter.relations.some(r => r.src === srcId && r.dst === dstId && r.rel === relName);
|
||
|
|
if (!found) {
|
||
|
|
throw new Error(`_relationKeys has ${key} but arbiter.relations does not`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify: for every relation in arbiter.relations, _relationKeys has it
|
||
|
|
for (const r of arbiter.relations) {
|
||
|
|
const key = arbiter.relationManager._makeRelationKey(r.src, r.rel, r.dst);
|
||
|
|
if (!arbiter.relationManager._relationKeys.has(key)) {
|
||
|
|
throw new Error(`arbiter.relations has ${r.src}|${r.rel}|${r.dst} but _relationKeys missing`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const opGen = rigor.gen.array(
|
||
|
|
rigor.gen.oneOf([
|
||
|
|
rigor.gen.tuple(rigor.gen.constant(0), rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4)),
|
||
|
|
rigor.gen.tuple(rigor.gen.constant(1), rigor.gen.int(0, 4), rigor.gen.enum(RELATIONS), rigor.gen.int(0, 4))
|
||
|
|
]),
|
||
|
|
1, 8
|
||
|
|
);
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check, rigor.args(opGen))],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('add-remove-roundtrip', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 1500 });
|
||
|
|
|
||
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'add-remove-roundtrip');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `add-then-remove sequence violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('getDirectRelation returns null for unknown (src, rel, dst)', async () => {
|
||
|
|
async function check(src, rel, dst) {
|
||
|
|
const arbiter = makeArbiter();
|
||
|
|
// No relations added; any query should return null/undefined
|
||
|
|
// Note: src/dst are arbitrary ints because we never added those nodes,
|
||
|
|
// so the relation can't exist by definition.
|
||
|
|
const result = arbiter.relationManager.getDirectRelation(src, rel, dst);
|
||
|
|
if (result !== null && result !== undefined) {
|
||
|
|
throw new Error(`expected null/undefined for unknown relation, got ${JSON.stringify(result)}`);
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
const report = await rigor.campaign(
|
||
|
|
[rigor.fn('check', check,
|
||
|
|
rigor.args(
|
||
|
|
rigor.gen.int(0, 1000),
|
||
|
|
rigor.gen.enum(RELATIONS),
|
||
|
|
rigor.gen.int(0, 1000)
|
||
|
|
)
|
||
|
|
)],
|
||
|
|
rigor.crucible([
|
||
|
|
rigor.invariant('getDirectRelation-unknown', ({ error, errorMessage }) => !error && !errorMessage)
|
||
|
|
])
|
||
|
|
).run({ effort: 500 });
|
||
|
|
|
||
|
|
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
|
||
|
|
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'getDirectRelation-unknown');
|
||
|
|
assert.ok(inv);
|
||
|
|
assert.equal(inv.passed, true, `getDirectRelation-unknown contract violated in ${inv.failureCount} cases`);
|
||
|
|
});
|
||
|
|
});
|