- Deleted scripts/hud.js, scripts/event-translation.js, styles/hud.css, templates/hud.html. - Removed mod.api.getHud/openHud/closeHud/buildHudUpdatePayload exports. - Removed hudPosition setting (moved to combat-hud-hub). - Added hub integration: at ready, if combat-hud-hub is installed, register a 'pinned-achievements' section. The chatBubble listener now also pushes entries into that section via hub.api.pushFeedEntry. - Soft-dep on combat-hud-hub (optional); popover still works without the hub. - Tests: 57/57 passing covering sections A-G (rule engine, catalog, awarding, hooks wiring, hub integration, graceful degradation).
208 lines
6.4 KiB
JavaScript
208 lines
6.4 KiB
JavaScript
// tests/test-helpers.mjs — its-achievable v0.3.0
|
|
//
|
|
// Foundry stub for the no-Foundry smoke test. Installs globalThis.Hooks,
|
|
// game, ui, FormApplication, and game.modules so the moved code can be
|
|
// imported without Foundry.
|
|
|
|
import { performance } from "node:perf_hooks";
|
|
|
|
const _listeners = new Map();
|
|
const _once = new WeakMap();
|
|
const _callLog = [];
|
|
|
|
// FormApplication stub: enough surface for `CustomAchievementsApp` to
|
|
// extend. The smoke test doesn't open the form, so constructor +
|
|
// defaultOptions + render are no-ops.
|
|
class StubFormApplication {
|
|
constructor(...args) {
|
|
StubFormApplication._lastInstance = this;
|
|
this._args = args;
|
|
}
|
|
render(force) { return this; }
|
|
close() { return this; }
|
|
}
|
|
StubFormApplication.defaultOptions = { id: "stub-form", template: "", width: 600 };
|
|
StubFormApplication._lastInstance = null;
|
|
|
|
const StubHandlebarsApplicationMixin = (Base) => class extends Base {
|
|
static PARTS = {};
|
|
};
|
|
|
|
export function installStubs(opts = {}) {
|
|
resetStubs();
|
|
const { withHooksLib = true, withBattleFocus = true, withHudHub = true, systemId = "dnd5e", systemVersion = "5.2.5", foundryVersion = "13.351.0" } = opts;
|
|
globalThis.Hooks = {
|
|
on(name, fn) {
|
|
_listeners.set(name, [...(_listeners.get(name) ?? []), fn]);
|
|
},
|
|
once(name, fn) {
|
|
_listeners.set(name, [...(_listeners.get(name) ?? []), fn]);
|
|
_once.set(fn, { hookName: name });
|
|
},
|
|
off(name, fn) {
|
|
const list = _listeners.get(name);
|
|
if (!list) return;
|
|
const next = list.filter((f) => f !== fn);
|
|
if (next.length === 0) _listeners.delete(name);
|
|
else _listeners.set(name, next);
|
|
_once.delete(fn);
|
|
},
|
|
callAll(name, ...args) {
|
|
_callLog.push({ name, args, ts: performance.now() });
|
|
const list = _listeners.get(name);
|
|
if (!list) return;
|
|
const snapshot = [...list];
|
|
for (const fn of snapshot) {
|
|
if (_once.has(fn)) this.off(name, fn);
|
|
try {
|
|
fn(...args);
|
|
} catch (e) {
|
|
console.error(`[stubs] Hooks.callAll(${name}) handler threw:`, e);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
// settings store (in-memory)
|
|
const _settings = new Map();
|
|
const settingsApi = {
|
|
get(moduleId, key) {
|
|
return _settings.get(`${moduleId}.${key}`);
|
|
},
|
|
set: async (moduleId, key, value) => {
|
|
_settings.set(`${moduleId}.${key}`, value);
|
|
},
|
|
register(moduleId, key, def) {
|
|
if (!_settings.has(`${moduleId}.${key}`)) {
|
|
_settings.set(`${moduleId}.${key}`, def.default);
|
|
}
|
|
},
|
|
};
|
|
// modules store
|
|
const _modules = new Map();
|
|
if (withHooksLib) {
|
|
const _hooksLibSubscribers = new Map();
|
|
const _hooksLibOnce = new WeakMap();
|
|
_modules.set("foundry-hooks-lib", {
|
|
id: "foundry-hooks-lib",
|
|
active: true,
|
|
api: {
|
|
MODULE_ID: "foundry-hooks-lib",
|
|
version: "0.2.0",
|
|
REGISTERED_HOOKS: ["combatStart", "combatEnd", "updateActor", "createToken", "dnd5e.rollAttackV2", "dnd5e.rollDamageV2", "preUpdateActor", "updateToken", "preUpdateToken"],
|
|
subscribe(hookName, fn) {
|
|
_hooksLibSubscribers.set(hookName, [...(_hooksLibSubscribers.get(hookName) ?? []), fn]);
|
|
return () => {
|
|
const list = _hooksLibSubscribers.get(hookName);
|
|
if (!list) return;
|
|
const next = list.filter((f) => f !== fn);
|
|
if (next.length === 0) _hooksLibSubscribers.delete(hookName);
|
|
else _hooksLibSubscribers.set(hookName, next);
|
|
};
|
|
},
|
|
subscribeMany(map) {
|
|
const unsubs = [];
|
|
for (const [name, fn] of Object.entries(map)) {
|
|
unsubs.push(this.subscribe(name, fn));
|
|
}
|
|
return () => { for (const u of unsubs) u(); };
|
|
},
|
|
_fireForTest(hookName, ...args) {
|
|
const list = _hooksLibSubscribers.get(hookName);
|
|
if (!list) return;
|
|
for (const fn of list) {
|
|
try { fn({ ts: Date.now(), hook: hookName, args }); } catch (e) {
|
|
console.error(`[stubs] hooksLib ${hookName} handler threw:`, e);
|
|
}
|
|
}
|
|
},
|
|
_hasSubscribersFor: (hookName) => _hooksLibSubscribers.has(hookName),
|
|
},
|
|
});
|
|
}
|
|
if (withBattleFocus) {
|
|
const _enc = {
|
|
id: "enc-test",
|
|
startedAt: Date.now() - 30000,
|
|
round: 1,
|
|
turn: 0,
|
|
currentTurn: { name: "Bard", tokenId: "t1", portrait: "" },
|
|
combatants: new Map(),
|
|
isActive: () => true,
|
|
buildStats: () => ({ kills: 0, dmg: 0, crits: 0, hits: 0, attacks: 0 }),
|
|
};
|
|
_modules.set("battle-focus", {
|
|
id: "battle-focus",
|
|
active: true,
|
|
api: {
|
|
MODULE_ID: "battle-focus",
|
|
getActiveEncounter: () => _enc,
|
|
getEncounter: () => _enc,
|
|
},
|
|
});
|
|
}
|
|
if (withHudHub) {
|
|
const _sections = new Map();
|
|
const _feed = new Map();
|
|
_modules.set("combat-hud-hub", {
|
|
id: "combat-hud-hub",
|
|
active: true,
|
|
api: {
|
|
MODULE_ID: "combat-hud-hub",
|
|
version: "0.2.0",
|
|
addSection(spec) {
|
|
_sections.set(spec.id, spec);
|
|
if (!_feed.has(spec.id)) _feed.set(spec.id, []);
|
|
},
|
|
removeSection(id) {
|
|
_sections.delete(id);
|
|
_feed.delete(id);
|
|
},
|
|
listSections() {
|
|
return Array.from(_sections.values()).map(s => ({
|
|
id: s.id, label: s.label, render: s.render,
|
|
}));
|
|
},
|
|
pushFeedEntry(sectionId, entry) {
|
|
if (!_feed.has(sectionId)) _feed.set(sectionId, []);
|
|
_feed.get(sectionId).push(entry);
|
|
},
|
|
getFeed(sectionId) {
|
|
return _feed.get(sectionId) ?? [];
|
|
},
|
|
getHud() { return null; },
|
|
openHud() {},
|
|
closeHud() {},
|
|
},
|
|
});
|
|
}
|
|
globalThis.game = {
|
|
version: foundryVersion,
|
|
system: { id: systemId, version: systemVersion },
|
|
modules: _modules,
|
|
settings: settingsApi,
|
|
user: null,
|
|
ready: true,
|
|
actors: {
|
|
_store: new Map(),
|
|
get(id) { return this._store.get(id) ?? null; },
|
|
getName(name) { for (const a of this._store.values()) if (a?.name === name) return a; return null; },
|
|
},
|
|
};
|
|
globalThis.ui = {
|
|
notifications: { info: () => {}, warn: () => {}, error: () => {} },
|
|
chat: [],
|
|
};
|
|
globalThis.FormApplication = StubFormApplication;
|
|
globalThis.mergeObject = (a, b) => ({ ...a, ...b });
|
|
globalThis.foundry = undefined;
|
|
globalThis.canvas = undefined;
|
|
}
|
|
|
|
export function resetStubs() {
|
|
_listeners.clear();
|
|
_callLog.length = 0;
|
|
}
|
|
|
|
export function getCallLog() {
|
|
return [..._callLog];
|
|
} |