@axiapps/gw2-data 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/data/overrides.json +25 -0
  2. package/package.json +32 -0
  3. package/scripts/generate-fixtures.js +242 -0
  4. package/src/api/client.js +117 -0
  5. package/src/api/types.js +80 -0
  6. package/src/engine/attributes.js +525 -0
  7. package/src/engine/boons.js +156 -0
  8. package/src/engine/combos.js +103 -0
  9. package/src/engine/constants.js +298 -0
  10. package/src/engine/graph.js +24 -0
  11. package/src/engine/index.js +82 -0
  12. package/src/engine/modifiers.js +204 -0
  13. package/src/engine/overrides.js +13 -0
  14. package/src/engine/tooltips.js +59 -0
  15. package/src/facts/match.js +134 -0
  16. package/src/facts/merge.js +45 -0
  17. package/src/facts/normalize.js +27 -0
  18. package/src/index.js +60 -0
  19. package/src/wiki/cache.js +103 -0
  20. package/src/wiki/client.js +230 -0
  21. package/src/wiki/parser.js +599 -0
  22. package/src/wiki/relations.js +55 -0
  23. package/src/wiki/resolver.js +352 -0
  24. package/tests/api-client.test.js +138 -0
  25. package/tests/cache.test.js +108 -0
  26. package/tests/engine/attributes.test.js +252 -0
  27. package/tests/engine/boons.test.js +129 -0
  28. package/tests/engine/combos.test.js +76 -0
  29. package/tests/engine/constants.test.js +576 -0
  30. package/tests/engine/fixtures/berserker-thief.json +61 -0
  31. package/tests/engine/fixtures/berserker-warrior.json +113 -0
  32. package/tests/engine/fixtures/celestial-firebrand-wvw.json +94 -0
  33. package/tests/engine/fixtures/harrier-druid.json +119 -0
  34. package/tests/engine/fixtures/viper-mirage.json +104 -0
  35. package/tests/engine/graph.test.js +30 -0
  36. package/tests/engine/integration.test.js +111 -0
  37. package/tests/engine/modifiers.test.js +473 -0
  38. package/tests/engine/overrides.test.js +70 -0
  39. package/tests/engine/snapshot.test.js +53 -0
  40. package/tests/engine/test-utils.js +20 -0
  41. package/tests/engine/tooltips.test.js +62 -0
  42. package/tests/fixtures/capture.js +160 -0
  43. package/tests/fixtures/fixtures.json +839 -0
  44. package/tests/integration.test.js +100 -0
  45. package/tests/match.test.js +176 -0
  46. package/tests/merge.test.js +128 -0
  47. package/tests/normalize.test.js +78 -0
  48. package/tests/parser.test.js +506 -0
  49. package/tests/real-data.test.js +296 -0
  50. package/tests/relations.test.js +80 -0
  51. package/tests/resolver.test.js +721 -0
  52. package/tests/validate-live.js +191 -0
  53. package/tests/wiki-client.test.js +468 -0
  54. package/tests/wiki-integration.test.js +177 -0
  55. package/tests/wiki-live-validation.test.js +61 -0
  56. package/tests/wiki-snapshots.test.js +166 -0
@@ -0,0 +1,352 @@
1
+ "use strict";
2
+
3
+ const { parseAllTaggedFacts, parseSplitGrouping, parseInfoboxParams } = require("./parser");
4
+
5
+ /**
6
+ * Group tagged facts (with `_modes` arrays) into per-mode arrays.
7
+ * Strips `_modes` from output facts.
8
+ *
9
+ * @param {Object[]} taggedFacts - Facts with `_modes: string[]`
10
+ * @returns {{ pve: Object[], wvw: Object[], pvp: Object[] }}
11
+ */
12
+ function groupFactsByMode(taggedFacts) {
13
+ const pve = [];
14
+ const wvw = [];
15
+ const pvp = [];
16
+
17
+ for (const fact of taggedFacts) {
18
+ const modes = fact._modes || [];
19
+ // Clone fact without _modes
20
+ const { _modes, ...clean } = fact;
21
+
22
+ if (modes.includes("pve")) pve.push({ ...clean });
23
+ if (modes.includes("wvw")) wvw.push({ ...clean });
24
+ if (modes.includes("pvp")) pvp.push({ ...clean });
25
+ }
26
+
27
+ return { pve, wvw, pvp };
28
+ }
29
+
30
+ /**
31
+ * Parse infobox-level timing parameters (recharge, activation) by game mode.
32
+ * These live outside the facts templates: `| recharge = 25`, `| activation = 0.5`
33
+ *
34
+ * @param {string} wikitext
35
+ * @returns {{ recharge: {pve:number|null, wvw:number|null, pvp:number|null}, activation: {pve:number|null, wvw:number|null, pvp:number|null} }}
36
+ */
37
+ function parseInfoboxTimings(wikitext) {
38
+ const result = {
39
+ recharge: { pve: null, wvw: null, pvp: null },
40
+ activation: { pve: null, wvw: null, pvp: null },
41
+ };
42
+ for (const param of ["recharge", "activation"]) {
43
+ // Base value: `| recharge = 25` (applies to all modes as default)
44
+ const baseRe = new RegExp(`\\|\\s*${param}\\s*=\\s*([\\d.]+)`, "i");
45
+ const baseMatch = wikitext.match(baseRe);
46
+ const baseVal = baseMatch ? parseFloat(baseMatch[1]) : null;
47
+ if (baseVal != null && !isNaN(baseVal)) {
48
+ result[param].pve = baseVal;
49
+ result[param].wvw = baseVal;
50
+ result[param].pvp = baseVal;
51
+ }
52
+ // Mode-specific overrides: `| recharge wvw = 40`, `| recharge pvp = 40`
53
+ const modeRe = new RegExp(`\\|\\s*${param}\\s+(pve|wvw|pvp)\\s*=\\s*([\\d.]+)`, "gi");
54
+ let m;
55
+ while ((m = modeRe.exec(wikitext)) !== null) {
56
+ const mode = m[1].toLowerCase();
57
+ const val = parseFloat(m[2]);
58
+ if (!isNaN(val)) result[param][mode] = val;
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /**
65
+ * Parse wikitext and return facts separated by game mode.
66
+ *
67
+ * @param {string} wikitext
68
+ * @returns {{ pve: Object[], wvw: Object[], pvp: Object[], hasSplit: boolean, recharge: {pve:number|null, wvw:number|null, pvp:number|null}, activation: {pve:number|null, wvw:number|null, pvp:number|null} }}
69
+ */
70
+ function parseFactsByMode(wikitext) {
71
+ const { facts: taggedFacts, hasPveOnly } = parseAllTaggedFacts(wikitext);
72
+ const grouped = groupFactsByMode(taggedFacts);
73
+
74
+ // Check for split field
75
+ const splitMatch = wikitext.match(/\|\s*split\s*=\s*(.+)/i);
76
+ let splitGrouping = null;
77
+ let wvwGroupedWithPvp = false;
78
+
79
+ if (splitMatch) {
80
+ splitGrouping = parseSplitGrouping(splitMatch[1].trim());
81
+ wvwGroupedWithPvp = splitGrouping.wvwGroupedWithPvp;
82
+ }
83
+
84
+ // If no template-based WvW facts but a split exists, try infobox fallback
85
+ if (grouped.wvw.length === 0 && splitGrouping?.wvwHasSplit) {
86
+ const infoboxFacts = parseInfoboxParams(wikitext, wvwGroupedWithPvp);
87
+ grouped.wvw = infoboxFacts;
88
+ }
89
+
90
+ const hasSplit = hasPveOnly || (splitGrouping?.wvwHasSplit ?? false);
91
+ const timings = parseInfoboxTimings(wikitext);
92
+
93
+ return {
94
+ pve: grouped.pve,
95
+ wvw: grouped.wvw,
96
+ pvp: grouped.pvp,
97
+ hasSplit,
98
+ recharge: timings.recharge,
99
+ activation: timings.activation,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Detect whether wikitext is a disambiguation page.
105
+ * GW2 wiki uses {{disambig}} or {{disambiguation}} templates.
106
+ */
107
+ function isDisambiguation(wikitext) {
108
+ return /\{\{disambig(uation)?\s*(\||\}\})/i.test(wikitext);
109
+ }
110
+
111
+ /**
112
+ * Extract the GW2 API ID(s) from a {{Skill infobox}} or {{Trait infobox}} template.
113
+ * Returns an array of numeric IDs, or empty array if no matching infobox found.
114
+ *
115
+ * @param {string} wikitext
116
+ * @returns {number[]}
117
+ */
118
+ function extractInfoboxId(wikitext) {
119
+ // Match only Skill or Trait infoboxes (not Location, Weapon, NPC, etc.)
120
+ const infoboxMatch = wikitext.match(/\{\{(?:Skill|Trait) infobox\b/i);
121
+ if (!infoboxMatch) return [];
122
+
123
+ // Find the | id = ... line within the infobox
124
+ const idMatch = wikitext.match(/\|\s*id\s*=\s*([0-9,\s]+)/);
125
+ if (!idMatch) return [];
126
+
127
+ return idMatch[1]
128
+ .split(",")
129
+ .map((s) => parseInt(s.trim(), 10))
130
+ .filter((n) => !isNaN(n));
131
+ }
132
+
133
+ /**
134
+ * Batch-resolve wiki facts for multiple entities.
135
+ *
136
+ * Accepts a per-entity title map so that the caller (catalog) can provide
137
+ * disambiguated wiki titles for entities that share a display name.
138
+ * Internally deduplicates fetches by title — if multiple IDs point to the
139
+ * same wiki title, the page is fetched once and the parsed facts are shared.
140
+ *
141
+ * @param {import("./client").WikiClient} client
142
+ * @param {Map<number, string>} idToTitle - Map of entity ID to wiki title
143
+ * @param {object} [options]
144
+ * @param {string} [options.profession] - Profession name (e.g. "Warrior") for disambiguation retries
145
+ * @returns {Promise<Map<number, { pve: Object[], wvw: Object[]|null, pvp: Object[]|null, hasSplit: boolean }>>}
146
+ */
147
+ async function resolveEntityFacts(client, idToTitle, options = {}) {
148
+ const result = new Map();
149
+
150
+ if (idToTitle.size === 0) return result;
151
+
152
+ // Group by title → id[] for batch fetching (avoids duplicate requests)
153
+ const titleToIds = new Map();
154
+ for (const [id, title] of idToTitle) {
155
+ if (!titleToIds.has(title)) titleToIds.set(title, []);
156
+ titleToIds.get(title).push(id);
157
+ }
158
+
159
+ const titles = [...titleToIds.keys()];
160
+ const wikitextMap = await client.getWikitextBatch(titles);
161
+
162
+ // Collect disambiguation pages and name collisions for retry
163
+ const disambigRetries = new Map(); // alternative title → original title
164
+ const nameCollisionRetries = new Map(); // original title → id[] (wrong infobox type / wrong ID)
165
+ const profession = options.profession ? options.profession.toLowerCase() : null;
166
+
167
+ for (const [title, ids] of titleToIds) {
168
+ const wikitext = wikitextMap.get(title);
169
+ if (!wikitext) continue; // skip missing pages
170
+
171
+ // If this is a disambiguation page, queue a retry with profession-specific suffix
172
+ if (isDisambiguation(wikitext)) {
173
+ if (profession) {
174
+ disambigRetries.set(`${title} (${profession} skill)`, title);
175
+ }
176
+ continue;
177
+ }
178
+
179
+ // Check if page has a Skill or Trait infobox (right page type).
180
+ const hasSkillOrTraitInfobox = /\{\{(?:Skill|Trait) infobox\b/i.test(wikitext);
181
+ if (!hasSkillOrTraitInfobox) {
182
+ // Wrong page type (location, weapon, NPC, etc.) or no infobox.
183
+ // Check if page has parseable facts anyway (some skill pages lack formal infoboxes).
184
+ const parsed = parseFactsByMode(wikitext);
185
+ const hasTimings = parsed.recharge.pve != null || parsed.activation.pve != null;
186
+ const hasFacts = parsed.pve.length > 0 || parsed.wvw.length > 0 || parsed.pvp.length > 0 || hasTimings;
187
+ if (hasFacts) {
188
+ // Has facts but no infobox — accept it (existing behavior)
189
+ const factEntry = {
190
+ pve: parsed.pve,
191
+ wvw: parsed.hasSplit ? parsed.wvw : null,
192
+ pvp: parsed.hasSplit ? parsed.pvp : null,
193
+ hasSplit: parsed.hasSplit,
194
+ recharge: parsed.recharge,
195
+ activation: parsed.activation,
196
+ };
197
+ for (const id of ids) result.set(id, factEntry);
198
+ } else {
199
+ // No infobox AND no facts — likely a wrong page type, queue retry
200
+ nameCollisionRetries.set(title, ids);
201
+ }
202
+ continue;
203
+ }
204
+
205
+ // Has Skill/Trait infobox — correct page type, parse facts normally
206
+ const parsed = parseFactsByMode(wikitext);
207
+
208
+ const hasTimings = parsed.recharge.pve != null || parsed.activation.pve != null;
209
+
210
+ // Skip pages that exist but have no fact templates and no timings —
211
+ // keep API facts instead of replacing them with empty arrays.
212
+ if (parsed.pve.length === 0 && parsed.wvw.length === 0 && parsed.pvp.length === 0 && !hasTimings) continue;
213
+
214
+ const factEntry = {
215
+ pve: parsed.pve,
216
+ wvw: parsed.hasSplit ? parsed.wvw : null,
217
+ pvp: parsed.hasSplit ? parsed.pvp : null,
218
+ hasSplit: parsed.hasSplit,
219
+ recharge: parsed.recharge,
220
+ activation: parsed.activation,
221
+ };
222
+
223
+ // Apply facts to ALL IDs sharing this title. Many skills have multiple API IDs
224
+ // that map to a single wiki page (e.g. True Nature per legend, Deploy Jade Sphere
225
+ // per attunement). Giving all IDs the same facts is correct for these variants.
226
+ for (const id of ids) result.set(id, factEntry);
227
+
228
+ // If the page's infobox ID doesn't cover all requesting IDs, queue the unmatched
229
+ // ones for prefix search — they might be genuinely different entities (e.g. "Maul"
230
+ // the ranger greatsword vs "Maul" the pet skill). If prefix search finds a better
231
+ // page, it overwrites the initially-shared facts.
232
+ if (ids.length > 1) {
233
+ const infoboxIds = new Set(extractInfoboxId(wikitext));
234
+ if (infoboxIds.size > 0) {
235
+ const unmatched = ids.filter((id) => !infoboxIds.has(id));
236
+ if (unmatched.length > 0) {
237
+ const existing = nameCollisionRetries.get(title) || [];
238
+ nameCollisionRetries.set(title, [...existing, ...unmatched]);
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ // Retry disambiguation pages with profession-specific titles
245
+ if (disambigRetries.size > 0) {
246
+ const retryTitles = [...disambigRetries.keys()];
247
+ const retryMap = await client.getWikitextBatch(retryTitles);
248
+
249
+ for (const [retryTitle, originalTitle] of disambigRetries) {
250
+ const wikitext = retryMap.get(retryTitle);
251
+ if (!wikitext) continue;
252
+
253
+ const parsed = parseFactsByMode(wikitext);
254
+ const hasTimings = parsed.recharge.pve != null || parsed.activation.pve != null;
255
+ if (parsed.pve.length === 0 && parsed.wvw.length === 0 && parsed.pvp.length === 0 && !hasTimings) continue;
256
+
257
+ const ids = titleToIds.get(originalTitle);
258
+ const factEntry = {
259
+ pve: parsed.pve,
260
+ wvw: parsed.hasSplit ? parsed.wvw : null,
261
+ pvp: parsed.hasSplit ? parsed.pvp : null,
262
+ hasSplit: parsed.hasSplit,
263
+ recharge: parsed.recharge,
264
+ activation: parsed.activation,
265
+ };
266
+ for (const id of ids) result.set(id, factEntry);
267
+ }
268
+ }
269
+
270
+ // Retry name collision pages via prefix search for "Name (" variants.
271
+ // This handles both wrong-type pages (location instead of skill) and infobox ID
272
+ // mismatches (page is about a different entity with the same name).
273
+ if (nameCollisionRetries.size > 0) {
274
+ // Prefix-search all colliding titles in parallel to find candidate pages
275
+ const searchPromises = [];
276
+ for (const [title] of nameCollisionRetries) {
277
+ searchPromises.push(
278
+ client.prefixSearch(`${title} (`).then((titles) => ({ originalTitle: title, candidates: titles }))
279
+ );
280
+ }
281
+ const searchResults = await Promise.all(searchPromises);
282
+
283
+ // Collect all candidate titles for a single batch fetch.
284
+ // Accept any disambiguated page (not just "skill"/"trait" suffixes) — pet skill
285
+ // pages use family names like "Maul (porcine)". The infobox type/ID check after
286
+ // fetching is the real filter.
287
+ const candidateToOriginal = new Map(); // candidate title → original title
288
+ for (const { originalTitle, candidates } of searchResults) {
289
+ for (const candidate of candidates) {
290
+ if (candidate.includes("/")) continue; // exclude subpages
291
+ candidateToOriginal.set(candidate, originalTitle);
292
+ }
293
+ }
294
+
295
+ if (candidateToOriginal.size > 0) {
296
+ const candidateWikitext = await client.getWikitextBatch([...candidateToOriginal.keys()]);
297
+
298
+ // Group candidates by original title
299
+ const candidatesByOriginal = new Map(); // original title → [candidate titles]
300
+ for (const [candidate, original] of candidateToOriginal) {
301
+ if (!candidatesByOriginal.has(original)) candidatesByOriginal.set(original, []);
302
+ candidatesByOriginal.get(original).push(candidate);
303
+ }
304
+
305
+ for (const [originalTitle, candidates] of candidatesByOriginal) {
306
+ const unresolvedIds = new Set(nameCollisionRetries.get(originalTitle));
307
+
308
+ for (const candidate of candidates) {
309
+ if (unresolvedIds.size === 0) break;
310
+
311
+ const wikitext = candidateWikitext.get(candidate);
312
+ if (!wikitext) continue;
313
+
314
+ if (!/\{\{(?:Skill|Trait) infobox\b/i.test(wikitext)) continue;
315
+
316
+ const parsed = parseFactsByMode(wikitext);
317
+ const hasTimings = parsed.recharge.pve != null || parsed.activation.pve != null;
318
+ if (parsed.pve.length === 0 && parsed.wvw.length === 0 && parsed.pvp.length === 0 && !hasTimings) continue;
319
+
320
+ const factEntry = {
321
+ pve: parsed.pve,
322
+ wvw: parsed.hasSplit ? parsed.wvw : null,
323
+ pvp: parsed.hasSplit ? parsed.pvp : null,
324
+ hasSplit: parsed.hasSplit,
325
+ recharge: parsed.recharge,
326
+ activation: parsed.activation,
327
+ };
328
+
329
+ // Match candidate to specific requesting IDs by infobox ID
330
+ const infoboxIds = extractInfoboxId(wikitext);
331
+ const matched = infoboxIds.filter((id) => unresolvedIds.has(id));
332
+
333
+ if (matched.length > 0) {
334
+ for (const id of matched) {
335
+ result.set(id, factEntry);
336
+ unresolvedIds.delete(id);
337
+ }
338
+ } else if (infoboxIds.length === 0) {
339
+ // No infobox IDs extractable — apply to all remaining (legacy fallback)
340
+ for (const id of unresolvedIds) result.set(id, factEntry);
341
+ unresolvedIds.clear();
342
+ }
343
+ // If infobox IDs exist but don't match any requesting ID, skip this candidate
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ return result;
350
+ }
351
+
352
+ module.exports = { groupFactsByMode, parseFactsByMode, resolveEntityFacts, isDisambiguation, extractInfoboxId };
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+
3
+ const { Gw2ApiClient } = require("../src/api/client");
4
+ const { MemoryCache } = require("../src/wiki/cache");
5
+
6
+ describe("Gw2ApiClient", () => {
7
+ let client;
8
+ let mockFetch;
9
+
10
+ beforeEach(() => {
11
+ mockFetch = jest.fn();
12
+ client = new Gw2ApiClient({
13
+ cache: new MemoryCache(),
14
+ fetch: mockFetch,
15
+ });
16
+ });
17
+
18
+ describe("fetchJson", () => {
19
+ test("returns parsed JSON on success", async () => {
20
+ mockFetch.mockResolvedValueOnce({
21
+ ok: true,
22
+ json: async () => ({ name: "Fireball" }),
23
+ });
24
+ const result = await client.fetchJson("https://api.guildwars2.com/v2/skills/5489");
25
+ expect(result).toEqual({ name: "Fireball" });
26
+ });
27
+
28
+ test("retries on 429 with delay", async () => {
29
+ mockFetch
30
+ .mockResolvedValueOnce({ ok: false, status: 429, statusText: "Too Many Requests" })
31
+ .mockResolvedValueOnce({
32
+ ok: true,
33
+ json: async () => ({ name: "Fireball" }),
34
+ });
35
+ const result = await client.fetchJson("https://api.guildwars2.com/v2/skills/5489");
36
+ expect(result).toEqual({ name: "Fireball" });
37
+ expect(mockFetch).toHaveBeenCalledTimes(2);
38
+ });
39
+
40
+ test("throws after max retries", async () => {
41
+ mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: "Server Error" });
42
+ await expect(
43
+ client.fetchJson("https://api.guildwars2.com/v2/skills/5489")
44
+ ).rejects.toThrow("500");
45
+ });
46
+
47
+ test("throws immediately on 4xx (non-429) without retrying", async () => {
48
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: "Not Found" });
49
+ await expect(
50
+ client.fetchJson("https://api.guildwars2.com/v2/skills/99999")
51
+ ).rejects.toThrow("404");
52
+ expect(mockFetch).toHaveBeenCalledTimes(1);
53
+ });
54
+ });
55
+
56
+ describe("fetchByIds", () => {
57
+ test("fetches single chunk of IDs", async () => {
58
+ mockFetch.mockResolvedValueOnce({
59
+ ok: true,
60
+ json: async () => [
61
+ { id: 1, name: "Skill A" },
62
+ { id: 2, name: "Skill B" },
63
+ ],
64
+ });
65
+ const result = await client.fetchByIds("/v2/skills", [1, 2]);
66
+ expect(result).toHaveLength(2);
67
+ expect(result[0].name).toBe("Skill A");
68
+ });
69
+
70
+ test("chunks large ID lists into batches of 180", async () => {
71
+ const ids = Array.from({ length: 200 }, (_, i) => i + 1);
72
+ mockFetch
73
+ .mockResolvedValueOnce({
74
+ ok: true,
75
+ json: async () => ids.slice(0, 180).map((id) => ({ id })),
76
+ })
77
+ .mockResolvedValueOnce({
78
+ ok: true,
79
+ json: async () => ids.slice(180).map((id) => ({ id })),
80
+ });
81
+ const result = await client.fetchByIds("/v2/skills", ids);
82
+ expect(result).toHaveLength(200);
83
+ expect(mockFetch).toHaveBeenCalledTimes(2);
84
+ });
85
+
86
+ test("deduplicates IDs before fetching", async () => {
87
+ mockFetch.mockResolvedValueOnce({
88
+ ok: true,
89
+ json: async () => [{ id: 1, name: "Skill A" }],
90
+ });
91
+ const result = await client.fetchByIds("/v2/skills", [1, 1, 1]);
92
+ expect(result).toHaveLength(1);
93
+ expect(mockFetch).toHaveBeenCalledTimes(1);
94
+ expect(mockFetch.mock.calls[0][0]).toContain("ids=1");
95
+ expect(mockFetch.mock.calls[0][0]).not.toContain("ids=1,1");
96
+ });
97
+ });
98
+
99
+ describe("fetchCached", () => {
100
+ test("returns cached value on hit", async () => {
101
+ const cache = new MemoryCache();
102
+ cache.set("test-key", { cached: true }, 60000);
103
+ client = new Gw2ApiClient({ cache, fetch: mockFetch });
104
+
105
+ const result = await client.fetchCached("test-key", "https://example.com", 60000);
106
+ expect(result).toEqual({ cached: true });
107
+ expect(mockFetch).not.toHaveBeenCalled();
108
+ });
109
+
110
+ test("fetches and caches on miss", async () => {
111
+ mockFetch.mockResolvedValueOnce({
112
+ ok: true,
113
+ json: async () => ({ fresh: true }),
114
+ });
115
+ const result = await client.fetchCached("test-key", "https://example.com", 60000);
116
+ expect(result).toEqual({ fresh: true });
117
+ const result2 = await client.fetchCached("test-key", "https://example.com", 60000);
118
+ expect(result2).toEqual({ fresh: true });
119
+ expect(mockFetch).toHaveBeenCalledTimes(1);
120
+ });
121
+ });
122
+
123
+ describe("constructor defaults", () => {
124
+ test("defaults to MemoryCache when no cache provided", async () => {
125
+ const defaultClient = new Gw2ApiClient({ fetch: mockFetch });
126
+ mockFetch.mockResolvedValueOnce({
127
+ ok: true,
128
+ json: async () => ({ id: 1 }),
129
+ });
130
+ const result = await defaultClient.fetchCached("k", "https://example.com", 60000);
131
+ expect(result).toEqual({ id: 1 });
132
+ // Second call should hit cache
133
+ const result2 = await defaultClient.fetchCached("k", "https://example.com", 60000);
134
+ expect(result2).toEqual({ id: 1 });
135
+ expect(mockFetch).toHaveBeenCalledTimes(1);
136
+ });
137
+ });
138
+ });
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+
3
+ const { MemoryCache } = require("../src/wiki/cache");
4
+
5
+ describe("MemoryCache", () => {
6
+ let cache;
7
+
8
+ beforeEach(() => {
9
+ cache = new MemoryCache();
10
+ });
11
+
12
+ test("get returns null for missing key", () => {
13
+ expect(cache.get("missing")).toBeNull();
14
+ });
15
+
16
+ test("set and get round-trips a value", () => {
17
+ cache.set("key1", { data: "hello" }, 60000);
18
+ expect(cache.get("key1")).toEqual({ data: "hello" });
19
+ });
20
+
21
+ test("get returns null for expired entry", () => {
22
+ cache.set("key1", "value", 1); // 1ms TTL
23
+ // Advance past TTL
24
+ jest.useFakeTimers();
25
+ jest.advanceTimersByTime(10);
26
+ expect(cache.get("key1")).toBeNull();
27
+ jest.useRealTimers();
28
+ });
29
+
30
+ test("invalidate removes a specific key", () => {
31
+ cache.set("key1", "value1", 60000);
32
+ cache.set("key2", "value2", 60000);
33
+ cache.invalidate("key1");
34
+ expect(cache.get("key1")).toBeNull();
35
+ expect(cache.get("key2")).toBe("value2");
36
+ });
37
+
38
+ test("clear removes all entries", () => {
39
+ cache.set("key1", "value1", 60000);
40
+ cache.set("key2", "value2", 60000);
41
+ cache.clear();
42
+ expect(cache.get("key1")).toBeNull();
43
+ expect(cache.get("key2")).toBeNull();
44
+ });
45
+
46
+ test("has returns true for valid entry, false for missing/expired", () => {
47
+ cache.set("key1", "value", 60000);
48
+ expect(cache.has("key1")).toBe(true);
49
+ expect(cache.has("missing")).toBe(false);
50
+ });
51
+ });
52
+
53
+ const fs = require("node:fs/promises");
54
+ const path = require("node:path");
55
+ const os = require("node:os");
56
+ const { DiskCache } = require("../src/wiki/cache");
57
+
58
+ describe("DiskCache", () => {
59
+ let cache;
60
+ let tmpDir;
61
+
62
+ beforeEach(async () => {
63
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gw2-data-cache-"));
64
+ cache = new DiskCache(tmpDir);
65
+ });
66
+
67
+ afterEach(async () => {
68
+ await fs.rm(tmpDir, { recursive: true, force: true });
69
+ });
70
+
71
+ test("get returns null for missing key", async () => {
72
+ expect(await cache.get("missing")).toBeNull();
73
+ });
74
+
75
+ test("set and get round-trips a value", async () => {
76
+ await cache.set("key1", { data: "hello" }, 60000);
77
+ expect(await cache.get("key1")).toEqual({ data: "hello" });
78
+ });
79
+
80
+ test("get returns null for expired entry", async () => {
81
+ await cache.set("key1", "value", 1);
82
+ // Wait for TTL to expire
83
+ await new Promise((r) => setTimeout(r, 10));
84
+ expect(await cache.get("key1")).toBeNull();
85
+ });
86
+
87
+ test("invalidate removes a specific key", async () => {
88
+ await cache.set("key1", "value1", 60000);
89
+ await cache.set("key2", "value2", 60000);
90
+ await cache.invalidate("key1");
91
+ expect(await cache.get("key1")).toBeNull();
92
+ expect(await cache.get("key2")).toBe("value2");
93
+ });
94
+
95
+ test("clear removes all entries", async () => {
96
+ await cache.set("key1", "value1", 60000);
97
+ await cache.set("key2", "value2", 60000);
98
+ await cache.clear();
99
+ expect(await cache.get("key1")).toBeNull();
100
+ expect(await cache.get("key2")).toBeNull();
101
+ });
102
+
103
+ test("persists across instances", async () => {
104
+ await cache.set("key1", "value1", 60000);
105
+ const cache2 = new DiskCache(tmpDir);
106
+ expect(await cache2.get("key1")).toBe("value1");
107
+ });
108
+ });