Files
evidence-dsl/tests/DSLRuntimeTyping.test.js
T
John Dvorak 9111c4b20d
CI / publish (push) Successful in 9s
CI / test (push) Successful in 19s
feat: duration seconds; required fields; type-validated insertions/updates/retrievals
- Duration literals now accept s/m/h/d/w (was m/h/d/w): 'BEHAVES { ttl 30s }'
  is 30s, and 'within 30s' temporal expressions parse.
- Definition fields are REQUIRED by default ('field: type'); 'field: type?'
  marks a field optional. addNode enforces presence on insert, getSchema
  exposes per-field requiredness, and updateNodeData still validates the
  provided fields' types.
- Provider-returned edges are validated against the fact's declared typing:
  a value-carrying fact must return { value, possibility } with a value of the
  declared type (bare-number shorthand is rejected); a non-value fact must not
  carry a value; and every possibility must lie in [0, 1]. Violations throw a
  clear provider-authoring error instead of silently injecting malformed edges.

Tests: DSLRuntimeTyping (duration units, required-field enforcement, schema
requiredness, value-type + shape + possibility validation).
2026-08-03 16:00:49 -07:00

97 lines
4.7 KiB
JavaScript

/**
* tests/DSLRuntimeTyping.test.js — duration seconds, required fields, and
* type validation of insertions / updates / provider retrievals.
*
* - Duration literals now accept s/m/h/d/w: `BEHAVES { ttl 30s }` is 30s.
* - Definition fields are REQUIRED by default (`field: type`); `field: type?`
* marks a field optional. addNode enforces presence on insert.
* - Provider-returned edges are validated against the fact's declared typing:
* a value-carrying fact must return { value, possibility } with a value of
* the declared type, and possibilities must lie in [0, 1].
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { Arbiter } from '@arbiter/core';
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
describe('DSLRuntime typing', () => {
it('accepts seconds/minutes/hours/days/weeks in duration literals', async () => {
const dsl = `
definition Employee { id: string? }
definition Doc { id: string? }
fact *a(user: Employee, amount: number) BEHAVES { ttl 30s }
fact *b(user: Employee, amount: number) BEHAVES { ttl 2m }
fact *c(user: Employee, amount: number) BEHAVES { ttl 1h }
fact *d(user: Employee, amount: number) BEHAVES { ttl 3d }
fact *e(user: Employee, amount: number) BEHAVES { ttl 1w }
`;
const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-units');
assert.equal(rt.relations.get('a').ttlMs, 30_000);
assert.equal(rt.relations.get('b').ttlMs, 120_000);
assert.equal(rt.relations.get('c').ttlMs, 3_600_000);
assert.equal(rt.relations.get('d').ttlMs, 259_200_000);
assert.equal(rt.relations.get('e').ttlMs, 604_800_000);
});
it('enforces required definition fields on node insert', () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string level: number active: boolean? }
`, 'rt-req');
// id and level are required (no `?`); active is optional.
assert.throws(() => rt.addNode('u:1', 'Employee', { level: 3 }), /missing required field 'Employee.id'/);
assert.throws(() => rt.addNode('u:2', 'Employee', { id: 'u:2' }), /missing required field 'Employee.level'/);
rt.addNode('u:3', 'Employee', { id: 'u:3', level: 5 }); // both required, no active -> ok
rt.addNode('u:4', 'Employee', { id: 'u:4', level: 5, active: true });
});
it('exposes requiredness in the schema snapshot', () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string level: number? }
`, 'rt-schema-req');
const employee = rt.getSchema().types.find(t => t.name === 'Employee');
assert.equal(employee.fields.find(f => f.name === 'id').required, true);
assert.equal(employee.fields.find(f => f.name === 'level').required, false);
});
it('validates a provider-returned value against the declared value type', async () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
definition Doc { id: string? }
fact *balance(user: Employee, amount: number)
evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) }
`, 'rt-valuetype');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
rt.registerFact('balance', async () => ({ possibility: 1.0, value: 'high' }));
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /must be number/);
});
it('requires a value for a value-carrying fact (no bare-number shorthand)', async () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
definition Doc { id: string? }
fact *balance(user: Employee, amount: number)
evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) }
`, 'rt-valshape');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
rt.registerFact('balance', async () => 0.9);
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /value-carrying fact/);
rt.registerFact('balance', async () => ({ possibility: 1.0 })); // missing value
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /must supply a 'value'/);
});
it('rejects a provider-returned possibility outside [0, 1]', async () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
definition Doc { id: string? }
fact *owns(user: Employee, doc: Doc)
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
`, 'rt-poss');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
rt.registerFact('owns', async () => ({ possibility: 2.0 }));
await assert.rejects(() => rt.check('u:1', 'can_read', 'doc:9'), /invalid possibility/);
});
});