User callout: 'Hax' is Kaysser's nickname. The module id should not use it. Rename the Foundry module id from 'hax-hooks-lib' to 'foundry-hooks-lib'. Gitea repo name stays as 'hooks-lib' (kept for the user-facing URL); the Gitea manifest URL is unchanged. **Scope of rename:** - module.json: id, title, version (0.2.0 -> 0.3.0), download URL - package.json: name - README.md, HOOK_CONTRACT.md, LICENSE: branding text - All 6 production JS files: MODULE_ID constant + comments - 4 active test files: console.log strings + test descriptions - Rename of release zips in git: hooks-lib-X.Y.Z.zip -> foundry-hooks-lib-X.Y.Z.zip (preserves the v0.1.0 and v0.2.0 zips as historical artifacts; the v0.3.0 zip is the new release artifact) - .gitignore: glob + un-ignore lines updated to match **Out of scope (deliberate):** - Gitea repo name 'kaykayyali/hooks-lib' stays. Per the user's direction, only the module id is renamed; the Gitea URL path is preserved for the existing 'url', 'manifest', 'download' fields. - scripts/_archive/v0.1.0/*: historical v0.1.0 code is left as-is. Those files tested 'hax-hooks-lib v0.1.0'; rewriting the history would be misleading. - tests/_archive_v0.1.0_*.mjs: same reason, left untouched. - .hermes/plans/* session-historian plans that reference 'Hax's Tools split': session artifact, not a release asset. **Verification:** 554/554 smoke assertions pass, 6/6 perf assertions pass, median 0.0004ms/fire (well under 0.1ms budget). No logic change; rename is string-only. **Consumer action required:** battle-focus and its-achievable both declare 'relationships.requires' pointing to 'hax-hooks-lib'. The next commits on those repos will update their relationships to 'foundry-hooks-lib' + bump their versions. Foundry instances with v0.2.0 of the old id installed will need to be reinstalled as v0.3.0 of the new id.
539 lines
22 KiB
JavaScript
539 lines
22 KiB
JavaScript
// tests/verify-hooks-lib.mjs — v0.3.0
|
|
//
|
|
// Smoke test for the generic Foundry hook facade. Implements
|
|
// tests/PLAN.md sections A-G (envelope shape, subscriber API, error
|
|
// containment, lifecycle, anti-corruption, adapter loading, perf).
|
|
// Runs in <2s without a live Foundry.
|
|
|
|
import {
|
|
installStubs,
|
|
resetStubs,
|
|
getListeners,
|
|
getAllCallLog,
|
|
clearCallLog,
|
|
setGameVersion,
|
|
setGameSystem,
|
|
} from "./test-helpers.mjs";
|
|
|
|
// We import the library's internal modules directly so we can drive
|
|
// them without going through the Foundry init/ready lifecycle. The
|
|
// public main.js (which calls install() in init) is tested via
|
|
// section D's lifecycle tests below.
|
|
|
|
import { HOOK_REGISTRY, REGISTERED_HOOKS, SYNTHESIZED_ENVELOPES } from "../scripts/internal/registered-hooks.js";
|
|
import { buildEnvelope } from "../scripts/internal/envelope.js";
|
|
import {
|
|
subscribe,
|
|
subscribeMany,
|
|
subscribeAll,
|
|
unsubscribeAll,
|
|
listSubscribedHooks,
|
|
} from "../scripts/internal/subscribers.js";
|
|
import {
|
|
registerSystemAdapter,
|
|
evaluateAtReady,
|
|
listActiveAdapters,
|
|
listFailedAdapters,
|
|
reset as resetAdapters,
|
|
} from "../scripts/internal/adapter-registry.js";
|
|
import { install, uninstall, evaluateAdaptersAtReady, listInstalledRawHooks } from "../scripts/internal/lifecycle.js";
|
|
import { matchRange } from "../scripts/internal/semver.js";
|
|
|
|
// Import main.js for its side-effect of registering Foundry hooks in
|
|
// Foundry's init. In the smoke test we don't run Foundry's init, but
|
|
// importing the file ensures the module's top-level syntax is valid.
|
|
// The actual lifecycle is driven via lifecycle.js in the tests below.
|
|
// (main.js's Hooks.once("init", ...) and Hooks.once("ready", ...)
|
|
// only fire when Foundry itself drives the init/ready cycle.)
|
|
|
|
const ASSERTIONS = [];
|
|
function assert(name, cond, extra = "") {
|
|
ASSERTIONS.push({ name, pass: !!cond, extra });
|
|
if (cond) {
|
|
console.log(` ✓ ${name}`);
|
|
} else {
|
|
console.log(` ✗ ${name} ${extra}`);
|
|
}
|
|
}
|
|
|
|
function assertEq(name, actual, expected) {
|
|
const ok = JSON.stringify(actual) === JSON.stringify(expected);
|
|
ASSERTIONS.push({ name, pass: ok, extra: ok ? "" : `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` });
|
|
if (ok) console.log(` ✓ ${name}`);
|
|
else console.log(` ✗ ${name} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
|
}
|
|
|
|
async function mainTest() {
|
|
console.log("--- foundry-hooks-lib v0.3.0 smoke test ---");
|
|
|
|
// ----- Section F.1 — semver range matcher (foundation for G) -----
|
|
console.log("[F.1] semver range matcher");
|
|
assert("matchRange exact", matchRange("1.2.3", "1.2.3"));
|
|
assert("matchRange >= with equal", matchRange("5.2.5", ">=5.2.0"));
|
|
assert("matchRange >= with greater", matchRange("6.0.0", ">=5.2.0"));
|
|
assert("matchRange >= with lesser", !matchRange("5.1.9", ">=5.2.0"));
|
|
assert("matchRange < with lesser", matchRange("5.2.99", "<5.3.0"));
|
|
assert("matchRange < with equal", !matchRange("5.3.0", "<5.3.0"));
|
|
assert("matchRange AND range", matchRange("5.2.5", ">=5.2.0 <5.3.0"));
|
|
assert("matchRange AND range misses lower", !matchRange("5.1.0", ">=5.2.0 <5.3.0"));
|
|
assert("matchRange AND range misses upper", !matchRange("5.3.0", ">=5.2.0 <5.3.0"));
|
|
assert("matchRange * matches anything", matchRange("0.0.0", "*"));
|
|
assert("matchRange undefined matches anything", matchRange("1.0.0", undefined));
|
|
assert("matchRange strips leading v", matchRange("v5.2.5", ">=5.2.0"));
|
|
assert("matchRange throws on bad version", (() => { try { matchRange("not-a-version", ">=1.0.0"); return false; } catch { return true; } })());
|
|
assert("matchRange throws on bad range op", (() => { try { matchRange("1.0.0", ">>1.0.0"); return false; } catch { return true; } })());
|
|
|
|
// ----- Section A — Envelope shape -----
|
|
installStubs();
|
|
console.log("[A] Envelope shape");
|
|
for (const entry of HOOK_REGISTRY) {
|
|
if (entry.synthesized) continue; // synthesized envelopes don't have a primary build path
|
|
for (const rawName of entry.raw) {
|
|
// Build a synthetic fire.
|
|
const args = syntheticArgsFor(rawName);
|
|
const result = buildEnvelope(rawName, args);
|
|
assert(`A.${entry.envelope}.${rawName}: buildEnvelope returns result`, result !== null);
|
|
if (!result) continue;
|
|
assert(`A.${entry.envelope}.${rawName}: envelope is an object`, typeof result.envelope === "object" && result.envelope !== null);
|
|
const env = result.envelope;
|
|
assert(`A.${entry.envelope}.${rawName}: envelope.ts is number >= 0`, typeof env.ts === "number" && env.ts >= 0);
|
|
assert(`A.${entry.envelope}.${rawName}: envelope.hook is string`, typeof env.hook === "string");
|
|
assertEq(`A.${entry.envelope}.${rawName}: envelope.hook is "${entry.envelope}"`, env.hook, entry.envelope);
|
|
assert(`A.${entry.envelope}.${rawName}: envelope.args is array`, Array.isArray(env.args));
|
|
// No extra fields.
|
|
const keys = Object.keys(env).sort();
|
|
assertEq(`A.${entry.envelope}.${rawName}: envelope has exactly {ts, hook, args}`, keys, ["args", "hook", "ts"]);
|
|
}
|
|
}
|
|
|
|
// ----- Section B — Subscriber API -----
|
|
resetStubs();
|
|
installStubs();
|
|
unsubscribeAll();
|
|
console.log("[B] Subscriber API");
|
|
|
|
// B.1 subscribe(hookName, fn) basic.
|
|
uninstall();
|
|
install();
|
|
unsubscribeAll();
|
|
clearCallLog();
|
|
let received = null;
|
|
const u1 = subscribe("updateActor", (env) => { received = env; });
|
|
Hooks.callAll("updateActor", { id: "a1" }, { name: "Bob" }, {}, "u1");
|
|
// Async dispatch — wait for microtask.
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert("B.1: subscribe receives envelope on async dispatch", received && received.hook === "updateActor");
|
|
u1();
|
|
|
|
// B.2 unsubscribe removes.
|
|
received = null;
|
|
const u2 = subscribe("updateActor", () => { received = "should-not-fire"; });
|
|
u2();
|
|
Hooks.callAll("updateActor", { id: "a2" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert("B.2: unsubscribed callback does NOT fire", received === null);
|
|
|
|
// B.3 stacking: order preserved.
|
|
const seen = [];
|
|
const u3a = subscribe("createCombatant", () => seen.push("a"));
|
|
const u3b = subscribe("createCombatant", () => seen.push("b"));
|
|
Hooks.callAll("createCombatant", { id: "c1" }, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("B.3: multiple subscribers fire in registration order", seen, ["a", "b"]);
|
|
u3a();
|
|
u3b();
|
|
|
|
// B.4 subscribe with unregistered hook throws.
|
|
let threw = null;
|
|
try { subscribe("not-a-real-hook", () => {}); } catch (e) { threw = e; }
|
|
assert("B.4: subscribe with unregistered hook throws TypeError", threw instanceof TypeError);
|
|
|
|
// B.5 subscribeMany atomic.
|
|
unsubscribeAll();
|
|
const seen5 = [];
|
|
let threw5 = null;
|
|
try {
|
|
subscribeMany({
|
|
updateActor: (env) => seen5.push(env.hook),
|
|
"not-a-real-hook": () => seen5.push("never"),
|
|
});
|
|
} catch (e) { threw5 = e; }
|
|
assert("B.5: subscribeMany with bad name throws", threw5 instanceof TypeError);
|
|
// Atomicity: bad-name batch should not register the good name.
|
|
Hooks.callAll("updateActor", { id: "a3" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("B.5: subscribeMany is atomic — nothing was registered", seen5, []);
|
|
|
|
// B.5b subscribeMany happy path.
|
|
const seen5b = [];
|
|
const u5 = subscribeMany({
|
|
updateActor: (env) => seen5b.push("ua:" + env.hook),
|
|
createToken: (env) => seen5b.push("ct:" + env.hook),
|
|
});
|
|
Hooks.callAll("updateActor", { id: "a4" }, {}, {}, "u1");
|
|
Hooks.callAll("createToken", { id: "t1" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("B.5b: subscribeMany dispatches both hooks", seen5b.sort(), ["ct:createToken", "ua:updateActor"]);
|
|
u5();
|
|
Hooks.callAll("updateActor", { id: "a5" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("B.5b: subscribeMany unsubscribe removes both", seen5b.sort(), ["ct:createToken", "ua:updateActor"]);
|
|
|
|
// B.6 subscribeAll.
|
|
unsubscribeAll();
|
|
const seen6 = [];
|
|
const u6 = subscribeAll((env) => seen6.push(env.hook));
|
|
Hooks.callAll("updateActor", { id: "a6" }, {}, {}, "u1");
|
|
Hooks.callAll("createToken", { id: "t2" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("B.6: subscribeAll receives every envelope", seen6.sort(), ["createToken", "updateActor"]);
|
|
u6();
|
|
Hooks.callAll("updateActor", { id: "a7" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("B.6: subscribeAll unsubscribe removes", seen6.sort(), ["createToken", "updateActor"]);
|
|
|
|
// B.7 unsubscribeAll.
|
|
unsubscribeAll();
|
|
const seen7 = [];
|
|
subscribe("updateActor", () => seen7.push("x"));
|
|
subscribe("createToken", () => seen7.push("y"));
|
|
unsubscribeAll();
|
|
Hooks.callAll("updateActor", { id: "a8" }, {}, {}, "u1");
|
|
Hooks.callAll("createToken", { id: "t3" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("B.7: unsubscribeAll purges everything", seen7, []);
|
|
|
|
// ----- Section C — Error containment -----
|
|
uninstall();
|
|
install();
|
|
unsubscribeAll();
|
|
console.log("[C] Error containment");
|
|
const seenC = [];
|
|
subscribe("updateActor", () => { throw new Error("boom-1"); });
|
|
subscribe("updateActor", (env) => seenC.push(env.hook));
|
|
const consoleErrorCalls = [];
|
|
const origConsoleError = console.error;
|
|
console.error = (...args) => consoleErrorCalls.push(args);
|
|
try {
|
|
Hooks.callAll("updateActor", { id: "a9" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
} finally {
|
|
console.error = origConsoleError;
|
|
}
|
|
assertEq("C: second callback still fires after first throws", seenC, ["updateActor"]);
|
|
assert(
|
|
"C: error logged via console.error with [foundry-hooks-lib] prefix and hook name",
|
|
consoleErrorCalls.length > 0 &&
|
|
consoleErrorCalls.some((args) =>
|
|
String(args[0]).includes("[foundry-hooks-lib]") &&
|
|
String(args[0]).includes("updateActor")
|
|
)
|
|
);
|
|
|
|
// ----- Section D — Lifecycle -----
|
|
uninstall();
|
|
install();
|
|
console.log("[D] Lifecycle");
|
|
// D.1: install registers Foundry hooks.
|
|
const installed = listInstalledRawHooks();
|
|
assert(
|
|
"D.1: install registers many raw Foundry hooks",
|
|
installed.length >= 50,
|
|
`got ${installed.length}`
|
|
);
|
|
assert(
|
|
"D.1: install registers updateActor",
|
|
installed.includes("updateActor")
|
|
);
|
|
assert(
|
|
"D.1: install registers combatStart",
|
|
installed.includes("combatStart")
|
|
);
|
|
// Idempotent: install() again does not double-register.
|
|
install();
|
|
assertEq("D.1: install is idempotent", listInstalledRawHooks().length, installed.length);
|
|
|
|
// D.2: ready evaluates adapters. With a dnd5e 5.2.5 system, a
|
|
// registered dnd5e adapter whose range covers 5.2.5 should load.
|
|
setGameSystem({ id: "dnd5e", version: "5.2.5" });
|
|
setGameVersion("13.351.0");
|
|
let factoryCalled = false;
|
|
resetAdapters();
|
|
registerSystemAdapter({
|
|
id: "test-dnd5e",
|
|
moduleId: "test-dnd5e",
|
|
system: { id: "dnd5e", versions: ">=5.2.0 <5.3.0" },
|
|
foundryVersions: ">=13 <15",
|
|
factory: () => {
|
|
factoryCalled = true;
|
|
return [{ name: "test.event", register: () => {} }];
|
|
},
|
|
});
|
|
evaluateAdaptersAtReady();
|
|
assert("D.2: matching adapter is loaded", factoryCalled);
|
|
assertEq("D.2: active adapters = 1", listActiveAdapters().length, 1);
|
|
|
|
// D.3: non-matching system silently skipped.
|
|
resetAdapters();
|
|
registerSystemAdapter({
|
|
id: "test-pf2e",
|
|
moduleId: "test-pf2e",
|
|
system: { id: "pf2e", versions: ">=4.0.0" },
|
|
foundryVersions: ">=13 <15",
|
|
factory: () => { factoryCalled = true; return []; },
|
|
});
|
|
factoryCalled = false;
|
|
evaluateAdaptersAtReady();
|
|
assert("D.3: non-matching system silently skipped (factory NOT called)", !factoryCalled);
|
|
|
|
// D.4: version mismatch logs + skips.
|
|
resetAdapters();
|
|
factoryCalled = false;
|
|
registerSystemAdapter({
|
|
id: "test-dnd5e-old",
|
|
moduleId: "test-dnd5e-old",
|
|
system: { id: "dnd5e", versions: ">=5.1.0 <5.2.0" },
|
|
foundryVersions: ">=13 <15",
|
|
factory: () => { factoryCalled = true; return []; },
|
|
});
|
|
const consoleWarnCalls = [];
|
|
const origConsoleWarn = console.warn;
|
|
console.warn = (...args) => consoleWarnCalls.push(args);
|
|
try {
|
|
evaluateAdaptersAtReady();
|
|
} finally {
|
|
console.warn = origConsoleWarn;
|
|
}
|
|
assert("D.4: version-mismatched adapter skipped (factory NOT called)", !factoryCalled);
|
|
assert(
|
|
"D.4: warning logged naming the adapter and version",
|
|
consoleWarnCalls.some((args) =>
|
|
String(args.join(" ")).includes("test-dnd5e-old") &&
|
|
String(args.join(" ")).includes("5.2.5") &&
|
|
String(args.join(" ")).includes("5.1.0")
|
|
)
|
|
);
|
|
|
|
// D.5: throwing factory is contained.
|
|
resetAdapters();
|
|
registerSystemAdapter({
|
|
id: "test-throws",
|
|
moduleId: "test-throws",
|
|
system: { id: "dnd5e", versions: "*" },
|
|
foundryVersions: "*",
|
|
factory: () => { throw new Error("factory boom"); },
|
|
});
|
|
registerSystemAdapter({
|
|
id: "test-ok",
|
|
moduleId: "test-ok",
|
|
system: { id: "dnd5e", versions: "*" },
|
|
foundryVersions: "*",
|
|
factory: () => [],
|
|
});
|
|
const consoleErrorCalls2 = [];
|
|
console.error = (...args) => consoleErrorCalls2.push(args);
|
|
try {
|
|
evaluateAdaptersAtReady();
|
|
} finally {
|
|
console.error = origConsoleError;
|
|
}
|
|
assertEq("D.5: failing adapter marked failed", listFailedAdapters().length, 1);
|
|
assertEq("D.5: failing adapter did NOT become active", listFailedAdapters().includes("test-throws"), true);
|
|
// OK adapter should still have loaded.
|
|
const activeAfter = listActiveAdapters();
|
|
assertEq("D.5: ok adapter still loads despite sibling failure", activeAfter.length, 1);
|
|
assertEq("D.5: ok adapter is the surviving one", activeAfter[0].id, "test-ok");
|
|
|
|
// D.6: uninstall removes all Foundry listeners.
|
|
uninstall();
|
|
assertEq("D.6: uninstall removes all registered listeners", listInstalledRawHooks().length, 0);
|
|
// Re-fire a Foundry hook — should produce NO envelopes.
|
|
unsubscribeAll();
|
|
let receivedD6 = null;
|
|
subscribe("updateActor", (env) => { receivedD6 = env; });
|
|
Hooks.callAll("updateActor", { id: "after-uninstall" }, {}, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert("D.6: after uninstall, Foundry fires produce no envelopes", receivedD6 === null);
|
|
|
|
// ----- Section E — Anti-corruption -----
|
|
install();
|
|
console.log("[E] Anti-corruption");
|
|
// E.1: combatInactive synthesized from updateCombat(active→false).
|
|
let inactiveSeen = null;
|
|
const uE1 = subscribe("combatInactive", (env) => { inactiveSeen = env; });
|
|
const fakeCombat = { id: "c1", active: true };
|
|
Hooks.callAll("updateCombat", fakeCombat, { active: false }, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert("E.1: combatInactive synthesized from updateCombat active→false", inactiveSeen !== null);
|
|
assertEq("E.1: combatInactive.args[0] is the combat", inactiveSeen?.args?.[0]?.id, "c1");
|
|
uE1();
|
|
|
|
// E.1b: updateCombat with active=true should NOT synthesize.
|
|
let inactiveSeenB = null;
|
|
const uE1b = subscribe("combatInactive", (env) => { inactiveSeenB = env; });
|
|
Hooks.callAll("updateCombat", fakeCombat, { active: true }, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert("E.1b: updateCombat active=true does NOT synthesize combatInactive", inactiveSeenB === null);
|
|
uE1b();
|
|
|
|
// E.1c: updateCombat with no `active` change should NOT synthesize.
|
|
let inactiveSeenC = null;
|
|
const uE1c = subscribe("combatInactive", (env) => { inactiveSeenC = env; });
|
|
Hooks.callAll("updateCombat", fakeCombat, { round: 2 }, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assert("E.1c: updateCombat round-change does NOT synthesize combatInactive", inactiveSeenC === null);
|
|
uE1c();
|
|
|
|
// E.2: renderChatInput is registered for BOTH v13 (renderChatLog) and v14 names.
|
|
assert(
|
|
"E.2: renderChatLog (v13) is in installed raw hooks",
|
|
listInstalledRawHooks().includes("renderChatLog")
|
|
);
|
|
assert(
|
|
"E.2: renderChatInput (v14) is in installed raw hooks",
|
|
listInstalledRawHooks().includes("renderChatInput")
|
|
);
|
|
|
|
// E.3: both produce envelope.hook === "renderChatInput".
|
|
let chatInputSeen = [];
|
|
const uE3 = subscribe("renderChatInput", (env) => chatInputSeen.push(env.hook));
|
|
Hooks.callAll("renderChatInput", { id: "m1" }, {}, "u1");
|
|
Hooks.callAll("renderChatLog", { id: "m2" }, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("E.3: both v13 and v14 chat hooks produce envelope.hook='renderChatInput'", chatInputSeen, ["renderChatInput", "renderChatInput"]);
|
|
uE3();
|
|
|
|
// E.4: arg normalization. combatRound v13 shape: (combat, updateData, roundNum).
|
|
// v14 shape: (combat, updateData, updateOptions). Normalized to 4 args.
|
|
let roundArgsV13 = null;
|
|
const uE4a = subscribe("combatRound", (env) => { roundArgsV13 = env.args; });
|
|
Hooks.callAll("combatRound", { id: "c1" }, {}, 3, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("E.4a: combatRound v13 shape normalizes round num to args[2]", roundArgsV13?.[2], 3);
|
|
uE4a();
|
|
|
|
let roundArgsV14 = null;
|
|
const uE4b = subscribe("combatRound", (env) => { roundArgsV14 = env.args; });
|
|
Hooks.callAll("combatRound", { id: "c1" }, {}, { round: 5 }, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("E.4b: combatRound v14 shape normalizes round num to args[2]", roundArgsV14?.[2], 5);
|
|
uE4b();
|
|
|
|
// ----- Section G — System adapter loading deeper -----
|
|
unsubscribeAll();
|
|
resetAdapters();
|
|
console.log("[G] Adapter loading (deeper)");
|
|
// G.1: invalid manifest throws synchronously.
|
|
let threwG1 = null;
|
|
try { registerSystemAdapter(null); } catch (e) { threwG1 = e; }
|
|
assert("G.1: null manifest throws TypeError", threwG1 instanceof TypeError);
|
|
|
|
// G.2: manifest with no factory throws.
|
|
let threwG2 = null;
|
|
try { registerSystemAdapter({ id: "x", moduleId: "x", system: { id: "dnd5e" } }); } catch (e) { threwG2 = e; }
|
|
assert("G.2: manifest without factory throws TypeError", threwG2 instanceof TypeError);
|
|
|
|
// G.3: factory returning non-array fails the adapter.
|
|
registerSystemAdapter({
|
|
id: "test-non-array",
|
|
moduleId: "test-non-array",
|
|
system: { id: "dnd5e", versions: "*" },
|
|
foundryVersions: "*",
|
|
factory: () => "not-an-array",
|
|
});
|
|
console.error = () => {};
|
|
evaluateAtReady({ id: "dnd5e", version: "5.2.5" }, "13.351.0");
|
|
assert("G.3: factory returning non-array marks adapter failed", listFailedAdapters().includes("test-non-array"));
|
|
assert("G.3: factory returning non-array does NOT make adapter active", !listActiveAdapters().some((m) => m.id === "test-non-array"));
|
|
|
|
// G.4: duplicate id is a no-op with warning.
|
|
resetAdapters();
|
|
const consoleWarnG4 = [];
|
|
console.warn = (...args) => consoleWarnG4.push(args);
|
|
registerSystemAdapter({
|
|
id: "dup", moduleId: "dup",
|
|
system: { id: "dnd5e", versions: "*" }, foundryVersions: "*",
|
|
factory: () => [],
|
|
});
|
|
registerSystemAdapter({
|
|
id: "dup", moduleId: "dup-2",
|
|
system: { id: "dnd5e", versions: "*" }, foundryVersions: "*",
|
|
factory: () => [],
|
|
});
|
|
console.warn = origConsoleWarn;
|
|
assert(
|
|
"G.4: duplicate id registration is a no-op with warning",
|
|
consoleWarnG4.some((args) => String(args[0]).includes("dup") && String(args[0]).includes("already registered"))
|
|
);
|
|
|
|
// ----- Section D.7 — World change idempotency -----
|
|
console.log("[D.7] World change idempotency");
|
|
resetAdapters();
|
|
let callsD7 = 0;
|
|
registerSystemAdapter({
|
|
id: "d7", moduleId: "d7",
|
|
system: { id: "dnd5e", versions: "*" }, foundryVersions: "*",
|
|
factory: () => { callsD7++; return []; },
|
|
});
|
|
console.error = () => {};
|
|
evaluateAtReady({ id: "dnd5e", version: "5.2.5" }, "13.351.0");
|
|
evaluateAtReady({ id: "dnd5e", version: "5.2.5" }, "13.351.0");
|
|
// Re-evaluation calls factory again. This is the documented behavior
|
|
// in §4.2 — adapters must be idempotent.
|
|
// v0.3.0 implementation re-evaluates on each ready. Adapters must
|
|
// handle this. We assert that the factory IS called twice (re-eval
|
|
// happened), since the adapter protocol requires idempotency.
|
|
assert("D.7: re-evaluation calls factory (adapter must be idempotent)", callsD7 === 2);
|
|
|
|
// ----- Summary -----
|
|
console.error = origConsoleError;
|
|
console.warn = origConsoleWarn;
|
|
|
|
const passed = ASSERTIONS.filter((a) => a.pass).length;
|
|
const total = ASSERTIONS.length;
|
|
console.log(`\n--- ${passed}/${total} assertions passed ---`);
|
|
if (passed !== total) {
|
|
console.log("\nFailed assertions:");
|
|
for (const a of ASSERTIONS.filter((x) => !x.pass)) {
|
|
console.log(` ✗ ${a.name} ${a.extra}`);
|
|
}
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
// Helper: build plausible synthetic args for any registered raw hook.
|
|
function syntheticArgsFor(rawName) {
|
|
if (rawName === "combatStart") return [{ id: "c1", scene: { name: "S" } }, {}];
|
|
if (rawName === "combatEnd") return [{ id: "c1" }];
|
|
if (rawName === "combatTurn") return [{ id: "c1" }, {}, {}];
|
|
if (rawName === "combatRound") return [{ id: "c1" }, {}, 1];
|
|
if (rawName === "updateCombat") return [{ id: "c1", active: true }, { round: 2 }];
|
|
if (rawName.endsWith("Actor") || rawName.endsWith("Token") || rawName.endsWith("Item") || rawName.endsWith("Scene") || rawName.endsWith("JournalEntry") || rawName.endsWith("ActiveEffect") || rawName.endsWith("Combat") || rawName.endsWith("Combatant")) {
|
|
return [{ id: "d1" }, { name: "x" }, {}, "u1"];
|
|
}
|
|
if (rawName === "pauseGame") return [false];
|
|
if (rawName === "canvasInit") return [{}];
|
|
if (rawName === "canvasReady") return [{}];
|
|
if (rawName === "canvasPan") return [{}, { x: 0, y: 0, scale: 1 }];
|
|
if (rawName === "controlToken" || rawName === "hoverToken") return [{}, true];
|
|
if (rawName === "targetToken") return [{}, {}, true];
|
|
if (rawName === "lightingRefresh" || rawName === "sightRefresh") return [{}];
|
|
if (rawName === "collapseSidebar" || rawName === "collapseSceneNavigation") return [{}, true];
|
|
if (rawName === "changeSidebarTab") return [{}];
|
|
if (rawName === "getSceneControlButtons") return [[]];
|
|
if (rawName === "renderChatMessage") return [{ id: "m1" }, {}, {}];
|
|
if (rawName === "renderChatInput" || rawName === "renderChatLog") return [{}, {}, {}];
|
|
if (rawName === "renderJournalPageSheet") return [{}, {}, {}];
|
|
if (rawName === "initializePointSourceShaders") return [{}];
|
|
if (rawName === "rtcSettingsChanged") return [{}, {}];
|
|
if (rawName === "dnd5e.rollAttackV2" || rawName === "dnd5e.rollDamageV2") return [[{ total: 15 }], { subject: {} }];
|
|
if (rawName === "init" || rawName === "setup" || rawName === "ready") return [];
|
|
if (rawName === "createChatMessage") return [{ id: "m1" }, {}, "u1"];
|
|
return [];
|
|
}
|
|
|
|
mainTest().catch((e) => {
|
|
console.error("[verify-hooks-lib] uncaught:", e);
|
|
console.error(e.stack);
|
|
process.exitCode = 1;
|
|
}); |