@axiapps/gw2-data 0.1.0 → 0.1.2
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/README.md +156 -0
- package/data/overrides.json +24 -0
- package/package.json +3 -1
- package/src/engine/attributes.js +102 -23
- package/src/engine/boons.js +19 -1
- package/src/engine/constants.js +27 -2
- package/src/engine/index.js +4 -4
- package/src/engine/modifiers.js +57 -21
- package/src/wiki/parser.js +17 -9
- package/scripts/generate-fixtures.js +0 -242
- package/tests/api-client.test.js +0 -138
- package/tests/cache.test.js +0 -108
- package/tests/engine/attributes.test.js +0 -252
- package/tests/engine/boons.test.js +0 -129
- package/tests/engine/combos.test.js +0 -76
- package/tests/engine/constants.test.js +0 -576
- package/tests/engine/fixtures/berserker-thief.json +0 -61
- package/tests/engine/fixtures/berserker-warrior.json +0 -113
- package/tests/engine/fixtures/celestial-firebrand-wvw.json +0 -94
- package/tests/engine/fixtures/harrier-druid.json +0 -119
- package/tests/engine/fixtures/viper-mirage.json +0 -104
- package/tests/engine/graph.test.js +0 -30
- package/tests/engine/integration.test.js +0 -111
- package/tests/engine/modifiers.test.js +0 -473
- package/tests/engine/overrides.test.js +0 -70
- package/tests/engine/snapshot.test.js +0 -53
- package/tests/engine/test-utils.js +0 -20
- package/tests/engine/tooltips.test.js +0 -62
- package/tests/fixtures/capture.js +0 -160
- package/tests/fixtures/fixtures.json +0 -839
- package/tests/integration.test.js +0 -100
- package/tests/match.test.js +0 -176
- package/tests/merge.test.js +0 -128
- package/tests/normalize.test.js +0 -78
- package/tests/parser.test.js +0 -506
- package/tests/real-data.test.js +0 -296
- package/tests/relations.test.js +0 -80
- package/tests/resolver.test.js +0 -721
- package/tests/validate-live.js +0 -191
- package/tests/wiki-client.test.js +0 -468
- package/tests/wiki-integration.test.js +0 -177
- package/tests/wiki-live-validation.test.js +0 -61
- package/tests/wiki-snapshots.test.js +0 -166
package/src/wiki/parser.js
CHANGED
|
@@ -11,7 +11,11 @@ const WIKI_ATTR_MAP = {
|
|
|
11
11
|
"Critical Damage": "CritDamage",
|
|
12
12
|
};
|
|
13
13
|
function normalizeAttr(name) {
|
|
14
|
-
|
|
14
|
+
if (!name) return name;
|
|
15
|
+
// Wiki templates inconsistently use lowercase ("power") or capitalized ("Power")
|
|
16
|
+
// attribute names. Title-case before lookup so both forms produce the API stat key.
|
|
17
|
+
const titleCased = name.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
18
|
+
return WIKI_ATTR_MAP[titleCased] || titleCased;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
// Fact types to silently ignore
|
|
@@ -61,14 +65,14 @@ const BOONS = new Set([
|
|
|
61
65
|
|
|
62
66
|
const CONDITIONS = new Set([
|
|
63
67
|
"bleeding",
|
|
64
|
-
"blind",
|
|
68
|
+
"blind", "blinded",
|
|
65
69
|
"burning",
|
|
66
|
-
"chilled",
|
|
70
|
+
"chill", "chilled",
|
|
67
71
|
"confusion",
|
|
68
|
-
"crippled",
|
|
72
|
+
"cripple", "crippled",
|
|
69
73
|
"fear",
|
|
70
|
-
"immobilize",
|
|
71
|
-
"poison",
|
|
74
|
+
"immobilize", "immobilized",
|
|
75
|
+
"poison", "poisoned",
|
|
72
76
|
"slow",
|
|
73
77
|
"taunt",
|
|
74
78
|
"torment",
|
|
@@ -199,9 +203,13 @@ function mapWikiFactToApiFact(factType, positional, params, isWvw, isUniversal)
|
|
|
199
203
|
const rawCoefficient = params.coefficient || positional[0] || "0";
|
|
200
204
|
const coefficient = parseFloat(stripWikiMarkup(rawCoefficient));
|
|
201
205
|
const hits = parseInt(stripWikiMarkup(params.hits) || "1", 10);
|
|
206
|
+
// `alt=` carries the conditional damage label (e.g. "Damage to Controlled Foes").
|
|
207
|
+
// Without this, the conditional variant collapses with the base "Damage" fact
|
|
208
|
+
// during dedup, hiding bonus-damage values from the tooltip.
|
|
209
|
+
const label = params.alt ? stripWikiMarkup(params.alt).trim() : "Damage";
|
|
202
210
|
return {
|
|
203
211
|
type: "Damage",
|
|
204
|
-
text:
|
|
212
|
+
text: label,
|
|
205
213
|
dmg_multiplier: coefficient,
|
|
206
214
|
hit_count: hits,
|
|
207
215
|
};
|
|
@@ -392,13 +400,13 @@ function mapWikiFactToApiFact(factType, positional, params, isWvw, isUniversal)
|
|
|
392
400
|
// Many wiki fact types carry a percentage value (e.g. "damage reduction|33").
|
|
393
401
|
// Without this, they fall through to generic Number and lose the % suffix.
|
|
394
402
|
if (PERCENT_TYPES.has(type)) {
|
|
395
|
-
const label = type.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
403
|
+
const label = params.alt ? stripWikiMarkup(params.alt).trim() : type.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
396
404
|
return { type: "Percent", text: label, percent: pos0Num() };
|
|
397
405
|
}
|
|
398
406
|
|
|
399
407
|
// ── Unknown but has a numeric value ────────────────────────────────
|
|
400
408
|
if (positional[0] && !isNaN(parseFloat(stripWikiMarkup(positional[0])))) {
|
|
401
|
-
const label = type.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
409
|
+
const label = params.alt ? stripWikiMarkup(params.alt).trim() : type.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
402
410
|
// Heuristic: fact type names containing "increase", "reduction", "chance",
|
|
403
411
|
// or "rate" are almost always percentages in GW2 wiki templates.
|
|
404
412
|
if (/(?:increase|reduction|chance|rate|cost|drain)$/.test(type)) {
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const path = require("path");
|
|
5
|
-
const { computeAttributes } = require("../src/engine/attributes");
|
|
6
|
-
const { hydrateCatalogs } = require("../tests/engine/test-utils");
|
|
7
|
-
|
|
8
|
-
const FIXTURE_DIR = path.join(__dirname, "../tests/engine/fixtures");
|
|
9
|
-
|
|
10
|
-
const fixtures = [
|
|
11
|
-
{
|
|
12
|
-
name: "Berserker Warrior",
|
|
13
|
-
description: "Heavy armor, 3-stat, full ascended, signets, Might+Fury assumed",
|
|
14
|
-
ctx: {
|
|
15
|
-
profession: "Warrior",
|
|
16
|
-
specializations: [
|
|
17
|
-
{ id: 4, majorChoices: { 1: 1444, 2: 1449, 3: 1437 } },
|
|
18
|
-
],
|
|
19
|
-
equipment: {
|
|
20
|
-
slots: {
|
|
21
|
-
head: "Berserker's", shoulders: "Berserker's", chest: "Berserker's",
|
|
22
|
-
gloves: "Berserker's", legs: "Berserker's", boots: "Berserker's",
|
|
23
|
-
mainhand1: "Berserker's", offhand1: "Berserker's",
|
|
24
|
-
back: "Berserker's", accessory1: "Berserker's", accessory2: "Berserker's",
|
|
25
|
-
amulet: "Berserker's", ring1: "Berserker's", ring2: "Berserker's",
|
|
26
|
-
},
|
|
27
|
-
weapons: { mainhand1: "greatsword" },
|
|
28
|
-
runes: {},
|
|
29
|
-
infusions: {},
|
|
30
|
-
enrichment: null,
|
|
31
|
-
food: null,
|
|
32
|
-
utility: null,
|
|
33
|
-
},
|
|
34
|
-
gameMode: "pve",
|
|
35
|
-
underwaterMode: false,
|
|
36
|
-
activeWeaponSet: 1,
|
|
37
|
-
skills: { healId: null, utilityIds: [9093], eliteId: null },
|
|
38
|
-
assumedBoons: { might: 25, fury: true },
|
|
39
|
-
sigilStacks: null,
|
|
40
|
-
},
|
|
41
|
-
catalogs: {
|
|
42
|
-
traits: [
|
|
43
|
-
{ id: 1444, facts: [{ type: "AttributeAdjust", target: "Power", value: 120 }] },
|
|
44
|
-
{ id: 1449, facts: [] },
|
|
45
|
-
{ id: 1437, facts: [] },
|
|
46
|
-
],
|
|
47
|
-
specializations: [{ id: 4, minorTraits: [] }],
|
|
48
|
-
skills: [],
|
|
49
|
-
runes: [],
|
|
50
|
-
foods: [],
|
|
51
|
-
utilities: [],
|
|
52
|
-
infusions: [],
|
|
53
|
-
enrichments: [],
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
name: "Viper Mirage",
|
|
58
|
-
description: "Medium armor, 4-stat, trait conversions, food + utility",
|
|
59
|
-
ctx: {
|
|
60
|
-
profession: "Mesmer",
|
|
61
|
-
specializations: [
|
|
62
|
-
{ id: 24, majorChoices: { 1: 700 } },
|
|
63
|
-
],
|
|
64
|
-
equipment: {
|
|
65
|
-
slots: {
|
|
66
|
-
head: "Viper's", shoulders: "Viper's", chest: "Viper's",
|
|
67
|
-
gloves: "Viper's", legs: "Viper's", boots: "Viper's",
|
|
68
|
-
mainhand1: "Viper's",
|
|
69
|
-
back: "Viper's", accessory1: "Viper's", accessory2: "Viper's",
|
|
70
|
-
amulet: "Viper's", ring1: "Viper's", ring2: "Viper's",
|
|
71
|
-
},
|
|
72
|
-
weapons: { mainhand1: "axe" },
|
|
73
|
-
runes: {},
|
|
74
|
-
infusions: {},
|
|
75
|
-
enrichment: null,
|
|
76
|
-
food: 91805,
|
|
77
|
-
utility: null,
|
|
78
|
-
},
|
|
79
|
-
gameMode: "pve",
|
|
80
|
-
underwaterMode: false,
|
|
81
|
-
activeWeaponSet: 1,
|
|
82
|
-
skills: { healId: null, utilityIds: [], eliteId: null },
|
|
83
|
-
assumedBoons: null,
|
|
84
|
-
sigilStacks: null,
|
|
85
|
-
},
|
|
86
|
-
catalogs: {
|
|
87
|
-
traits: [
|
|
88
|
-
{ id: 700, facts: [{ type: "BuffConversion", source: "Vitality", target: "ConditionDamage", percent: 10 }] },
|
|
89
|
-
],
|
|
90
|
-
specializations: [{ id: 24, minorTraits: [] }],
|
|
91
|
-
skills: [],
|
|
92
|
-
runes: [],
|
|
93
|
-
foods: [{ id: 91805, name: "Plate of Beef Rendang", buff: "+100 Expertise\n+70 Condition Damage" }],
|
|
94
|
-
utilities: [],
|
|
95
|
-
infusions: [],
|
|
96
|
-
enrichments: [],
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
name: "Celestial Firebrand WvW",
|
|
101
|
-
description: "Heavy armor, 9-stat, WvW Celestial exclusion, rune bonuses",
|
|
102
|
-
ctx: {
|
|
103
|
-
profession: "Guardian",
|
|
104
|
-
specializations: [],
|
|
105
|
-
equipment: {
|
|
106
|
-
slots: {
|
|
107
|
-
head: "Celestial", shoulders: "Celestial", chest: "Celestial",
|
|
108
|
-
gloves: "Celestial", legs: "Celestial", boots: "Celestial",
|
|
109
|
-
mainhand1: "Celestial",
|
|
110
|
-
back: "Celestial", accessory1: "Celestial", accessory2: "Celestial",
|
|
111
|
-
amulet: "Celestial", ring1: "Celestial", ring2: "Celestial",
|
|
112
|
-
},
|
|
113
|
-
weapons: { mainhand1: "axe" },
|
|
114
|
-
runes: {
|
|
115
|
-
head: 24836, shoulders: 24836, chest: 24836,
|
|
116
|
-
gloves: 24836, legs: 24836, boots: 24836,
|
|
117
|
-
},
|
|
118
|
-
infusions: {},
|
|
119
|
-
enrichment: null,
|
|
120
|
-
food: null,
|
|
121
|
-
utility: null,
|
|
122
|
-
},
|
|
123
|
-
gameMode: "wvw",
|
|
124
|
-
underwaterMode: false,
|
|
125
|
-
activeWeaponSet: 1,
|
|
126
|
-
skills: { healId: null, utilityIds: [], eliteId: null },
|
|
127
|
-
assumedBoons: null,
|
|
128
|
-
sigilStacks: null,
|
|
129
|
-
},
|
|
130
|
-
catalogs: {
|
|
131
|
-
traits: [],
|
|
132
|
-
specializations: [],
|
|
133
|
-
skills: [],
|
|
134
|
-
runes: [{ id: 24836, name: "Superior Rune of the Scholar", bonuses: ["+25 Power", "+35 Ferocity", "+50 Power", "+65 Ferocity", "+100 Power", "+125 Ferocity"] }],
|
|
135
|
-
foods: [],
|
|
136
|
-
utilities: [],
|
|
137
|
-
infusions: [],
|
|
138
|
-
enrichments: [],
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
name: "Harrier Druid",
|
|
143
|
-
description: "Medium armor, 3-stat healing, enrichment, infusions",
|
|
144
|
-
ctx: {
|
|
145
|
-
profession: "Ranger",
|
|
146
|
-
specializations: [],
|
|
147
|
-
equipment: {
|
|
148
|
-
slots: {
|
|
149
|
-
head: "Harrier's", shoulders: "Harrier's", chest: "Harrier's",
|
|
150
|
-
gloves: "Harrier's", legs: "Harrier's", boots: "Harrier's",
|
|
151
|
-
mainhand1: "Harrier's",
|
|
152
|
-
back: "Harrier's", accessory1: "Harrier's", accessory2: "Harrier's",
|
|
153
|
-
amulet: "Harrier's", ring1: "Harrier's", ring2: "Harrier's",
|
|
154
|
-
},
|
|
155
|
-
weapons: { mainhand1: "staff" },
|
|
156
|
-
runes: {},
|
|
157
|
-
infusions: {
|
|
158
|
-
head: [49432], shoulders: [49432], chest: [49432],
|
|
159
|
-
gloves: [49432], legs: [49432], boots: [49432],
|
|
160
|
-
},
|
|
161
|
-
enrichment: 78061,
|
|
162
|
-
food: null,
|
|
163
|
-
utility: null,
|
|
164
|
-
},
|
|
165
|
-
gameMode: "pve",
|
|
166
|
-
underwaterMode: false,
|
|
167
|
-
activeWeaponSet: 1,
|
|
168
|
-
skills: { healId: null, utilityIds: [], eliteId: null },
|
|
169
|
-
assumedBoons: null,
|
|
170
|
-
sigilStacks: null,
|
|
171
|
-
},
|
|
172
|
-
catalogs: {
|
|
173
|
-
traits: [],
|
|
174
|
-
specializations: [],
|
|
175
|
-
skills: [],
|
|
176
|
-
runes: [],
|
|
177
|
-
foods: [],
|
|
178
|
-
utilities: [],
|
|
179
|
-
infusions: [{ id: 49432, name: "+5 Healing Power Infusion", infixUpgrade: { attributes: [{ attribute: "Healing", modifier: 5 }] } }],
|
|
180
|
-
enrichments: [{ id: 78061, name: "+10 Concentration Enrichment", infixUpgrade: { attributes: [{ attribute: "BoonDuration", modifier: 10 }] } }],
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
{
|
|
184
|
-
name: "Berserker Thief",
|
|
185
|
-
description: "Medium armor, sparse gear (testing empty slots gracefully)",
|
|
186
|
-
ctx: {
|
|
187
|
-
profession: "Thief",
|
|
188
|
-
specializations: [],
|
|
189
|
-
equipment: {
|
|
190
|
-
slots: { chest: "Berserker's", legs: "Berserker's" },
|
|
191
|
-
weapons: {},
|
|
192
|
-
runes: {},
|
|
193
|
-
infusions: {},
|
|
194
|
-
enrichment: null,
|
|
195
|
-
food: null,
|
|
196
|
-
utility: null,
|
|
197
|
-
},
|
|
198
|
-
gameMode: "pve",
|
|
199
|
-
underwaterMode: false,
|
|
200
|
-
activeWeaponSet: 1,
|
|
201
|
-
skills: { healId: null, utilityIds: [], eliteId: null },
|
|
202
|
-
assumedBoons: null,
|
|
203
|
-
sigilStacks: null,
|
|
204
|
-
},
|
|
205
|
-
catalogs: {
|
|
206
|
-
traits: [],
|
|
207
|
-
specializations: [],
|
|
208
|
-
skills: [],
|
|
209
|
-
runes: [],
|
|
210
|
-
foods: [],
|
|
211
|
-
utilities: [],
|
|
212
|
-
infusions: [],
|
|
213
|
-
enrichments: [],
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
|
-
];
|
|
217
|
-
|
|
218
|
-
// Generate fixtures
|
|
219
|
-
if (!fs.existsSync(FIXTURE_DIR)) {
|
|
220
|
-
fs.mkdirSync(FIXTURE_DIR, { recursive: true });
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
for (const fixture of fixtures) {
|
|
224
|
-
const catalogs = hydrateCatalogs(fixture.catalogs);
|
|
225
|
-
const result = computeAttributes(fixture.ctx, catalogs);
|
|
226
|
-
const output = {
|
|
227
|
-
name: fixture.name,
|
|
228
|
-
description: fixture.description,
|
|
229
|
-
ctx: fixture.ctx,
|
|
230
|
-
catalogs: fixture.catalogs,
|
|
231
|
-
expected: {
|
|
232
|
-
total: result.total,
|
|
233
|
-
derived: result.derived,
|
|
234
|
-
},
|
|
235
|
-
};
|
|
236
|
-
const filename = fixture.name.toLowerCase().replace(/\s+/g, "-") + ".json";
|
|
237
|
-
const filepath = path.join(FIXTURE_DIR, filename);
|
|
238
|
-
fs.writeFileSync(filepath, JSON.stringify(output, null, 2) + "\n");
|
|
239
|
-
console.log(`Generated: ${filename}`);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
console.log("Done!");
|
package/tests/api-client.test.js
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
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
|
-
});
|
package/tests/cache.test.js
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
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
|
-
});
|