Foundry v13 deprecated renderChatMessage in favor of
renderChatMessageHTML (which passes an HTMLElement, not a jQuery
wrapper). Subscribing to the deprecated hook re-emits Foundry's
compatibility warning on every chat render in worlds still running
v13.351 (the foundry-hooks-lib module's tests run against such a
world).
v0.3.0 already narrowed scope to Foundry v14 only (HOOK_CONTRACT.md
section 9), but the registered hook set still included
renderChatMessage as a legacy fallback. There is no Foundry v14
hook by that name, so the entry was dead weight — and worse, any
v13.351 world running the v14-only library would still see the
deprecation warning every chat render.
Changes:
- registered-hooks.js: replace renderChatMessage entry with
renderChatMessageHTML. Update arg shape (HTML passes HTMLElement,
not jQuery). Add comment explaining the deprecation.
- README.md / HOOK_CONTRACT.md section 6: list renderChatMessageHTML
instead of renderChatMessage.
- tests/verify-hooks-lib.mjs: update stub arg shape from
[{id}, {}, {}] to [{id}, {}] (v14 signature).
Verification:
- node tests/verify-hooks-lib.mjs: 546/546 (unchanged)
- node tests/perf.mjs: 6/6, median 0.0003ms/fire (well under
the 0.1ms budget in HOOK_CONTRACT.md section 7)
- node --check on all scripts + tests: clean
Push: Gitea only.
Note: battle-focus's own main.js line 144 still has a
Hooks.on('renderChatMessage', ...) listener for its 'Open in
Journal' button wiring. That listener fires the deprecation warning
on the user's console. Fixing it is a battle-focus change, out of
scope for this turn (hooks-lib only).
534 lines
22 KiB
JavaScript
534 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 (v14 only; v13's renderChatLog
|
|
// was supported in v0.2.0 but is dropped in v0.3.0 per the plan's
|
|
// v14-only scope).
|
|
assert(
|
|
"E.2: renderChatInput (v14) is in installed raw hooks",
|
|
listInstalledRawHooks().includes("renderChatInput")
|
|
);
|
|
assert(
|
|
"E.2: renderChatLog (v13) is NOT in installed raw hooks",
|
|
!listInstalledRawHooks().includes("renderChatLog")
|
|
);
|
|
|
|
// E.3: v14 fire produces envelope.hook === "renderChatInput".
|
|
let chatInputSeen = [];
|
|
const uE3 = subscribe("renderChatInput", (env) => chatInputSeen.push(env.hook));
|
|
Hooks.callAll("renderChatInput", { id: "m1" }, {}, "u1");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
assertEq("E.3: v14 renderChatInput produces envelope.hook='renderChatInput'", chatInputSeen, ["renderChatInput"]);
|
|
uE3();
|
|
|
|
// E.4: arg normalization. v14 combatRound shape: (combat, updateData, updateOptions).
|
|
// v0.3.0 is v14-only; the v13 round-num position is no longer
|
|
// exercised. round number comes from updateOptions.round.
|
|
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.4: 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 === "renderChatMessageHTML") return [{ id: "m1" }, {}];
|
|
if (rawName === "renderChatInput") 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;
|
|
}); |