houserules/packages/rete/tests/golden/g10-cycle-detection.test.ts
Joey Yakimowich-Payne 0401295bbc
test(rete): port pararules golden tests; tag Phase 1 parity (P1.14)
10 integration tests in packages/rete/tests/golden/ manually wire
AlphaNode → BetaMemory → JoinNode → ProductionNode chains and drive
them via Session.insert/retract. Each test maps to a pararules Nim
reference test (documented in GOLDEN-MAP.md).

Coverage: packages/rete/src at 96.8% statements / 95.4% branch /
97.8% functions — all well above the 90% Phase 1 gate.

Tests: 227 total (166 pre-existing + 61 new golden), all green.
2026-04-16 14:16:19 -06:00

101 lines
3.8 KiB
TypeScript

/**
* G10 — Cycle detection (recursion limit)
*
* Pararules origin: tests/test2.nim "recursion limit":
* test "recursion limit":
* let rules =
* ruleset:
* rule rule1(Fact): what: (Alice, Color, color) then: insert(Alice, Height, 15)
* rule rule2(Fact): what: (Alice, Height, h) then: insert(Alice, Age, 10)
* rule rule3(Fact): what: (Alice, Age, age) then: insert(Alice, Color, "blue")
* rule rule4(Fact): ...
* # This creates an infinite cascade — pararules detects and throws
*
* Our engine detects infinite recursion via a depth counter in Session.fireRules().
* When fireRules() is re-entered while already executing (depth ≥ limit),
* it throws RecursionLimitExceededError.
*
* Since fireRules() is currently a stub (returns 0), we test the guard
* directly by manipulating the internal depth counter, which is intentionally
* exposed via the underscore-prefixed `_fireDepth` field.
*/
import { describe, it, expect } from "vitest";
import { Session } from "../../src/session.js";
import { RecursionLimitExceededError } from "../../src/cycle.js";
describe("G10 — cycle detection (recursion limit)", () => {
it("fireRules() at depth ≥ limit throws RecursionLimitExceededError", () => {
const session = new Session({ recursionLimit: 3, autoFire: false });
// Simulate being already at the recursion limit
session._fireDepth = 3;
expect(() => session.fireRules()).toThrow(RecursionLimitExceededError);
});
it("RecursionLimitExceededError carries the depth at which it fired", () => {
const session = new Session({ recursionLimit: 5, autoFire: false });
session._fireDepth = 5;
let caught: RecursionLimitExceededError | undefined;
try {
session.fireRules();
} catch (e) {
if (e instanceof RecursionLimitExceededError) caught = e;
}
expect(caught).toBeDefined();
expect(caught?.depth).toBe(5);
});
it("error message includes depth information", () => {
const session = new Session({ recursionLimit: 2, autoFire: false });
session._fireDepth = 2;
expect(() => session.fireRules()).toThrow(/2/);
});
it("fireRules() succeeds when depth is below the limit", () => {
const session = new Session({ recursionLimit: 10, autoFire: false });
session._fireDepth = 9;
// Should NOT throw — 9 < 10
expect(() => session.fireRules()).not.toThrow();
});
it("recursionLimit: 0 disables the guard (unlimited depth)", () => {
const session = new Session({ recursionLimit: 0, autoFire: false });
session._fireDepth = 9999;
// limit=0 means "disabled" — never throws regardless of depth
expect(() => session.fireRules()).not.toThrow();
});
it("fireRules() increments depth and decrements on exit (balanced)", () => {
const session = new Session({ recursionLimit: 64, autoFire: false });
expect(session._fireDepth).toBe(0);
session.fireRules();
// After normal return, depth should be back to 0
expect(session._fireDepth).toBe(0);
});
it("depth is decremented even when RecursionLimitExceededError is thrown", () => {
const session = new Session({ recursionLimit: 1, autoFire: false });
session._fireDepth = 1;
expect(() => session.fireRules()).toThrow(RecursionLimitExceededError);
// Throws before incrementing, so _fireDepth stays at 1 (set by us)
// and is not cleaned up (guard fires before the try block)
expect(session._fireDepth).toBe(1);
});
it("custom per-call recursionLimit overrides session default", () => {
const session = new Session({ recursionLimit: 100, autoFire: false });
session._fireDepth = 3;
// Override with a tighter limit in this specific call
expect(() => session.fireRules({ recursionLimit: 2 })).toThrow(
RecursionLimitExceededError,
);
});
});