Files
hooks-lib/tests/_archive_v0.1.0_verify-hooks-lib.mjs
Kaysser Kayyali 7f0d1bbff1 v0.2.0 — generic Foundry hook facade (rip-and-replace of v0.1.0)
v0.1.0 shipped as a curated-event catalog (18 hand-written handlers
+ system adapters + an encounter.js stub). Wrong shape. v0.2.0 is a
RIP-AND-REPLACE per HOOK_CONTRACT.md (docs/HOOK_CONTRACT.md):
generic Foundry hook facade with NO domain interpretation.

## What's new

scripts/internal/ — new modular structure:
- registered-hooks.js: the §6 hook set (~60 raw Foundry hooks,
  envelope name, sync/async mode)
- envelope.js: buildEnvelope + sync/async dispatch (§8)
- subscribers.js: subscribe/subscribeMany/subscribeAll/unsubscribeAll
  primitives (§3) + error containment (§3.5)
- adapter-registry.js: registerSystemAdapter + semver-range matching
  (§5) + ready-time evaluation
- semver.js: inline semver matcher (no external dep)
- anti-corruption.js: hook rename normalization (§9) + arg shape
  fixes (§10) + combatInactive synthesis from updateCombat
- lifecycle.js: init/ready/unregisterModule hooks (§4)

scripts/main.js — Foundry entry point. Registers the public API on
mod.api; init installs Foundry hooks; ready evaluates adapters;
unregisterModule cleans up.

## Tests

- tests/verify-hooks-lib.mjs — 554 assertions in 0.34s (under the
  2s budget). Sections A-G of tests/PLAN.md:
  - A: envelope shape (every registered hook produces exactly
    {ts, hook, args})
  - B: subscriber API (single, batch, all, atomic, error path)
  - C: error containment (throwing consumer doesn't break chain)
  - D: lifecycle (install/uninstall, adapter eval, world change)
  - E: anti-corruption (renderChatLog→renderChatInput,
    combatInactive synthesis, combatRound arg normalization)
  - F: semver matcher
  - G: adapter loading (validation, dedup, factory failure)
- tests/perf.mjs — 6 assertions. Median 0.0003ms/fire
  (333x under the 0.1ms budget). Heap delta 2.8MB across 10k fires.
- tests/test-helpers.mjs — Foundry stub (Hooks, game, ui).

## Archive

scripts/_archive/v0.1.0/ — v0.1.0 catalog moved here for git
history. The 18 handlers, system adapters, and encounter.js stub
all live there but are not part of the v0.2.0 module.

tests/_archive_v0.1.0_*.mjs — v0.1.0 test files renamed with
prefix to avoid colliding with v0.2.0 files.

## Manifest

- module.json: bumped to 0.2.0; download URL points at the new zip.
- package.json: bumped to 0.2.0; added test:perf script.
- README.md: rewritten for v0.2.0.

Push: Gitea only.
2026-06-20 03:01:22 -04:00

216 lines
7.4 KiB
JavaScript

// hax-hooks-lib — verify-hooks-lib.mjs
//
// Smoke test for the registry + systems loader. Runs without Foundry.
//
// What's verified:
// 1. loadSystems({currentSystemId: "anything"}) returns the core
// adapter and nothing else (no dnd5e).
// 2. loadSystems({currentSystemId: "dnd5e"}) returns core + dnd5e.
// 3. registerAllEvents wires every event handler into Hooks.on for
// the declared hook name.
// 4. When a Foundry hook fires, the registered handler returns a
// normalized event object, and onEvent is invoked exactly once
// with that object.
// 5. registerEvent rejects event defs missing id/hook/handler.
// 6. Handlers that return null do NOT trigger onEvent.
//
// Full E2E (the real story) is battle-focus's
// tests/verify-battle-focus-v5.mjs, which loads hooks-lib as a
// Foundry module and runs 220+ assertions against the event stream.
import { installStubs, resetStubs, getRegisteredHooks, clearCallLog } from "./test-helpers.mjs";
import { loadSystems } from "../scripts/systems/loader.js";
import { registerAllEvents, registerEvent, $ctx } from "../scripts/events/registry.js";
const ASSERTIONS = [];
function assert(name, cond, extra = "") {
ASSERTIONS.push({ name, pass: !!cond, extra });
if (cond) {
console.log(`${name}`);
} else {
console.log(`${name} ${extra}`);
}
}
function assertThrows(name, fn, msgIncludes) {
let threw = null;
try {
fn();
} catch (e) {
threw = e;
}
const ok = !!threw && (!msgIncludes || (threw.message ?? "").includes(msgIncludes));
ASSERTIONS.push({ name, pass: ok });
if (ok) {
console.log(`${name}`);
} else {
console.log(`${name} expected throw containing "${msgIncludes}", got ${threw ? threw.message : "no throw"}`);
}
}
async function main() {
console.log("--- hax-hooks-lib v0.1.0 smoke test ---");
// ----- Section 1: loadSystems filtering -----
installStubs();
console.log("[1] loadSystems filtering");
const noneSystem = await loadSystems({
currentSystemId: "drawings-only",
systemVersion: "0.0.0",
});
assert(
"loadSystems for unknown system returns core only",
noneSystem.length === 1 && noneSystem[0].id === "core",
`got ${noneSystem.map((s) => s.id).join(",")}`,
);
const dnd5eSystems = await loadSystems({
currentSystemId: "dnd5e",
systemVersion: "5.2.5",
});
assert(
"loadSystems for dnd5e returns core + dnd5e",
dnd5eSystems.length === 2 && dnd5eSystems.map((s) => s.id).sort().join(",") === "core,dnd5e",
`got ${dnd5eSystems.map((s) => s.id).join(",")}`,
);
assert(
"core adapter has at least 15 events",
noneSystem[0].events.length >= 15,
`got ${noneSystem[0].events.length}`,
);
assert(
"dnd5e adapter has 2 events (attack-roll, damage-roll)",
dnd5eSystems.find((s) => s.id === "dnd5e").events.length === 2,
);
// ----- Section 2: registerAllEvents wires hooks -----
resetStubs();
installStubs();
clearCallLog();
console.log("[2] registerAllEvents wiring");
const seenEvents = [];
const onEvent = async (ev) => {
seenEvents.push(ev);
};
const registered = registerAllEvents(dnd5eSystems, onEvent);
assert(
"registerAllEvents returns one def per event",
registered.length === dnd5eSystems.reduce((n, s) => n + s.events.length, 0),
`expected ${dnd5eSystems.reduce((n, s) => n + s.events.length, 0)}, got ${registered.length}`,
);
// After registration, every event's hook name should have at least
// one handler in the Hooks map.
const missingHook = registered.find((r) => (getRegisteredHooks().get(r.hook) ?? []).length === 0);
assert(
"every event def's hook has at least one Hooks.on registration",
!missingHook,
missingHook ? `missing: ${missingHook.id}` : "",
);
assert(
"every event def has _registered === true after registerAllEvents",
registered.every((r) => r._registered === true),
);
// ----- Section 3: firing a Foundry hook returns event, onEvent fires -----
console.log("[3] end-to-end handler invocation");
// Find a hook that's simple to fire and check. combatStart is the
// canonical "open journal page" event and its handler only reads
// combat, scene, and the active ctx.
const startEv = registered.find((r) => r.hook === "combatStart");
assert("combatStart event registered", !!startEv);
// Synthesize a minimal combat object.
const fakeCombat = {
id: "c-test-1",
scene: { name: "Test Scene" },
round: 1,
};
// Fire the combatStart hook. The registry's safe wrapper sets _activeCtx,
// invokes the handler, gets a result, and calls onEvent.
const handlerFns = getRegisteredHooks().get("combatStart") ?? [];
assert("combatStart has exactly one handler registered", handlerFns.length === 1, `got ${handlerFns.length}`);
// The handler is async; await its returned promise.
const returned = await handlerFns[0](fakeCombat, { active: true });
assert(
"combatStart handler returns an event with kind='combat-start'",
returned && returned.kind === "combat-start",
`got ${JSON.stringify(returned)}`,
);
assert(
"onEvent was called with the combat-start event",
seenEvents.length === 1 && seenEvents[0].kind === "combat-start",
`seenEvents=${JSON.stringify(seenEvents)}`,
);
assert(
"combat-start event has combatId from the synthetic combat",
seenEvents[0]?.combatId === "c-test-1",
`got combatId=${seenEvents[0]?.combatId}`,
);
assert(
"combat-start event has scene name as combatName",
seenEvents[0]?.combatName === "Test Scene",
`got combatName=${seenEvents[0]?.combatName}`,
);
// ----- Section 4: null return → onEvent NOT called -----
console.log("[4] null returns suppress onEvent");
// preUpdateItem is a side-effect-only handler that returns null.
const preUpdateItem = registered.find((r) => r.hook === "preUpdateItem");
assert("preUpdateItem event registered", !!preUpdateItem);
const seenBefore = seenEvents.length;
const preHandlers = getRegisteredHooks().get("preUpdateItem") ?? [];
await preHandlers[0]({ id: "i1", type: "weapon", parent: null }, {}, {}, "u1");
assert(
"preUpdateItem returning null does NOT call onEvent",
seenEvents.length === seenBefore,
`seenEvents grew from ${seenBefore} to ${seenEvents.length}`,
);
// ----- Section 5: $ctx() is null outside a handler -----
console.log("[5] $ctx outside a handler");
assert(
"$ctx() returns null when called outside any handler",
$ctx() === null,
);
// ----- Section 6: registerEvent validation -----
console.log("[6] registerEvent input validation");
assertThrows(
"registerEvent rejects missing id",
() => registerEvent({ hook: "x", handler: () => null }),
"missing id",
);
assertThrows(
"registerEvent rejects missing hook",
() => registerEvent({ id: "x", handler: () => null }),
"missing hook",
);
assertThrows(
"registerEvent rejects missing handler",
() => registerEvent({ id: "x", hook: "y" }),
"missing handler",
);
assertThrows(
"registerEvent error message names the module id",
() => registerEvent({ hook: "x", handler: () => null }),
"[hax-hooks-lib]",
);
// ----- Summary -----
const passed = ASSERTIONS.filter((a) => a.pass).length;
const total = ASSERTIONS.length;
console.log(`\n--- ${passed}/${total} assertions passed ---`);
if (passed !== total) {
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("[verify-hooks-lib] uncaught:", e);
process.exitCode = 1;
});