@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.
- package/data/overrides.json +25 -0
- package/package.json +32 -0
- package/scripts/generate-fixtures.js +242 -0
- package/src/api/client.js +117 -0
- package/src/api/types.js +80 -0
- package/src/engine/attributes.js +525 -0
- package/src/engine/boons.js +156 -0
- package/src/engine/combos.js +103 -0
- package/src/engine/constants.js +298 -0
- package/src/engine/graph.js +24 -0
- package/src/engine/index.js +82 -0
- package/src/engine/modifiers.js +204 -0
- package/src/engine/overrides.js +13 -0
- package/src/engine/tooltips.js +59 -0
- package/src/facts/match.js +134 -0
- package/src/facts/merge.js +45 -0
- package/src/facts/normalize.js +27 -0
- package/src/index.js +60 -0
- package/src/wiki/cache.js +103 -0
- package/src/wiki/client.js +230 -0
- package/src/wiki/parser.js +599 -0
- package/src/wiki/relations.js +55 -0
- package/src/wiki/resolver.js +352 -0
- package/tests/api-client.test.js +138 -0
- package/tests/cache.test.js +108 -0
- package/tests/engine/attributes.test.js +252 -0
- package/tests/engine/boons.test.js +129 -0
- package/tests/engine/combos.test.js +76 -0
- package/tests/engine/constants.test.js +576 -0
- package/tests/engine/fixtures/berserker-thief.json +61 -0
- package/tests/engine/fixtures/berserker-warrior.json +113 -0
- package/tests/engine/fixtures/celestial-firebrand-wvw.json +94 -0
- package/tests/engine/fixtures/harrier-druid.json +119 -0
- package/tests/engine/fixtures/viper-mirage.json +104 -0
- package/tests/engine/graph.test.js +30 -0
- package/tests/engine/integration.test.js +111 -0
- package/tests/engine/modifiers.test.js +473 -0
- package/tests/engine/overrides.test.js +70 -0
- package/tests/engine/snapshot.test.js +53 -0
- package/tests/engine/test-utils.js +20 -0
- package/tests/engine/tooltips.test.js +62 -0
- package/tests/fixtures/capture.js +160 -0
- package/tests/fixtures/fixtures.json +839 -0
- package/tests/integration.test.js +100 -0
- package/tests/match.test.js +176 -0
- package/tests/merge.test.js +128 -0
- package/tests/normalize.test.js +78 -0
- package/tests/parser.test.js +506 -0
- package/tests/real-data.test.js +296 -0
- package/tests/relations.test.js +80 -0
- package/tests/resolver.test.js +721 -0
- package/tests/validate-live.js +191 -0
- package/tests/wiki-client.test.js +468 -0
- package/tests/wiki-integration.test.js +177 -0
- package/tests/wiki-live-validation.test.js +61 -0
- 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
|
+
});
|