/** * rigor/relational-comparator-router.test.js — js-rigor property tests for * RelationalComparatorRouter._isQualitativeRule. * * The router dispatches to either the numeric or qualitative implementation * based on the rule config. Properties: * - rule.qualitative === true → qualitative (regardless of other fields) * - any operand scaleName → qualitative * - any operand decaySteps/baseBlurSteps (defined, non-null) → qualitative * - rule.marginSteps (defined, non-null) → qualitative * - otherwise → numeric * * getImplementationType is a pure pass-through to _isQualitativeRule. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { RelationalComparatorRouter } from '../../src/authorization/rules/RelationalComparatorRouter.js'; const SCALE_NAMES = ['five-point', 'ternary', 'ordinal-7']; /** * Build a stub arbiter that satisfies the rule's constructor. * Neither _evaluateRule is called by these tests, but the constructor * stores references that the rule may touch on dispatch. */ function makeStubArbiter() { return { nodeIdByKey: new Map(), keyByNodeId: new Map(), relations: [], nodes: new Map() }; } function makeRouter() { // _evaluateRule is what we test; pass a stub arbiter so the // constructor doesn't crash. We never call _evaluateRule in these // tests — we test _isQualitativeRule and getImplementationType // directly. return new RelationalComparatorRouter(makeStubArbiter(), {}); } describe('RelationalComparatorRouter._isQualitativeRule (rigor)', () => { it('rule.qualitative=true always wins', async () => { async function check(rule) { const router = makeRouter(); const result = router._isQualitativeRule(rule); if (!result) { throw new Error( `expected qualitative=true to win, got numeric. rule=${JSON.stringify(rule)}` ); } return result; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ qualitative: rigor.gen.constant(true), // random other fields that should not matter left: rigor.gen.option(rigor.gen.object({ scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES)) })), right: rigor.gen.option(rigor.gen.object({ scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES)) })), marginSteps: rigor.gen.option(rigor.gen.int(0, 5)), fallbackBehavior: rigor.gen.option(rigor.gen.enum(['allow', 'deny'])) }) ) ) ], rigor.crucible([ rigor.invariant('qualitative-wins', ({ 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 === 'qualitative-wins'); assert.ok(inv); assert.equal(inv.passed, true, `qualitative=true did not win in ${inv.failureCount} cases`); }); it('scaleName on either operand triggers qualitative', async () => { async function check(operand, side) { const router = makeRouter(); const rule = { left: side === 'left' ? operand : {}, right: side === 'right' ? operand : {} }; const result = router._isQualitativeRule(rule); if (!result) { throw new Error( `expected scaleName on ${side} to trigger qualitative, got numeric. ` + `operand=${JSON.stringify(operand)}` ); } return result; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ scaleName: rigor.gen.enum(SCALE_NAMES) }), rigor.gen.enum(['left', 'right']) ) ) ], rigor.crucible([ rigor.invariant('scaleName-triggers', ({ 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 === 'scaleName-triggers'); assert.ok(inv); assert.equal(inv.passed, true, `scaleName did not trigger qualitative in ${inv.failureCount} cases`); }); it('decaySteps/baseBlurSteps on either operand triggers qualitative', async () => { async function check(operand, side) { const router = makeRouter(); const rule = { left: side === 'left' ? operand : {}, right: side === 'right' ? operand : {} }; const result = router._isQualitativeRule(rule); if (!result) { throw new Error( `expected ${JSON.stringify(Object.keys(operand))} on ${side} to trigger qualitative` ); } return result; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.oneOf([ rigor.gen.object({ decaySteps: rigor.gen.int(0, 10) }), rigor.gen.object({ baseBlurSteps: rigor.gen.int(0, 10) }) ]), rigor.gen.enum(['left', 'right']) ) ) ], rigor.crucible([ rigor.invariant('decay-blur-triggers', ({ 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 === 'decay-blur-triggers'); assert.ok(inv); assert.equal(inv.passed, true, `decaySteps/baseBlurSteps did not trigger qualitative in ${inv.failureCount} cases`); }); it('marginSteps triggers qualitative (when defined and non-null)', async () => { async function check(marginSteps) { const router = makeRouter(); const rule = { marginSteps }; const result = router._isQualitativeRule(rule); if (marginSteps === undefined || marginSteps === null) { // marginSteps not present — should not trigger if (result) throw new Error(`marginSteps=${marginSteps} should not trigger qualitative`); } else { if (!result) throw new Error(`marginSteps=${marginSteps} should trigger qualitative`); } return result; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.option(rigor.gen.int(0, 10)) ) ) ], rigor.crucible([ rigor.invariant('marginSteps-correct', ({ 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 === 'marginSteps-correct'); assert.ok(inv); assert.equal(inv.passed, true, `marginSteps handling was wrong in ${inv.failureCount} cases`); }); it('plain numeric rule (no qualitative flags) routes to numeric', async () => { async function check(leftValue, rightValue, comparator) { const router = makeRouter(); const rule = { left: { rule: { type: 'direct' }, extractValue: true }, right: { rule: { type: 'direct' }, extractValue: true }, comparator, leftValue, // injected for testing only — production ignores rightValue }; const result = router._isQualitativeRule(rule); if (result) { throw new Error( `expected numeric, got qualitative. rule=${JSON.stringify(rule)}` ); } return result; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.int(-1000, 1000), rigor.gen.int(-1000, 1000), rigor.gen.enum(['>', '>=', '<', '<=', '==', '!=']) ) ) ], rigor.crucible([ rigor.invariant('plain-numeric', ({ 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 === 'plain-numeric'); assert.ok(inv); assert.equal(inv.passed, true, `plain numeric rule routed to qualitative in ${inv.failureCount} cases`); }); it('getImplementationType matches _isQualitativeRule', async () => { async function check(rule) { const router = makeRouter(); const fromPrivate = router._isQualitativeRule(rule); const fromPublic = router.getImplementationType(rule); const expected = fromPrivate ? 'qualitative' : 'numeric'; if (fromPublic !== expected) { throw new Error( `getImplementationType=${fromPublic} but _isQualitativeRule=${fromPrivate}` ); } return fromPublic; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( rigor.gen.object({ qualitative: rigor.gen.option(rigor.gen.boolean()), left: rigor.gen.option(rigor.gen.object({ scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES)), decaySteps: rigor.gen.option(rigor.gen.int(0, 10)) })), right: rigor.gen.option(rigor.gen.object({ scaleName: rigor.gen.option(rigor.gen.enum(SCALE_NAMES)), decaySteps: rigor.gen.option(rigor.gen.int(0, 10)) })), marginSteps: rigor.gen.option(rigor.gen.int(0, 10)) }) ) ) ], rigor.crucible([ rigor.invariant('getImplType-consistent', ({ 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 === 'getImplType-consistent'); assert.ok(inv); assert.equal(inv.passed, true, `getImplementationType disagreed with _isQualitativeRule in ${inv.failureCount} cases`); }); it('_hasValidQualitativeProperty: undefined/null → false, anything else → true', async () => { async function check(value) { const router = makeRouter(); const result = router._hasValidQualitativeProperty(value); if (value === undefined || value === null) { if (result) throw new Error(`_hasValidQualitativeProperty(${value}) should be false`); } else { if (!result) throw new Error(`_hasValidQualitativeProperty(${value}) should be true`); } return result; } const report = await rigor.campaign( [ rigor.fn('check', check, rigor.args( // Exclude undefined/null from explicit inputs — the // generator treats them as "absent". The campaign will // also exercise the absent path via undefined when the // record is shrunk to {}. rigor.gen.option( rigor.gen.oneOf([ rigor.gen.int(), rigor.gen.float(), rigor.gen.boolean(), rigor.gen.string(), rigor.gen.constant(0), rigor.gen.constant(''), rigor.gen.constant(false) ]) ) ) ) ], rigor.crucible([ rigor.invariant('hasValidProperty', ({ error, errorMessage }) => !error && !errorMessage) ]) ).run({ effort: 1000 , artifacts: { dir: '', persist: 'never' }}); if (process.env.TEST_DEBUG === '1') console.log(report.toTAP()); const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'hasValidProperty'); assert.ok(inv); assert.equal(inv.passed, true, `_hasValidQualitativeProperty contract violated in ${inv.failureCount} cases`); }); });