@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,191 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Live validation script — fetches real GW2 API + wiki data and validates
|
|
6
|
+
* that our parser + merger produce correct results.
|
|
7
|
+
*
|
|
8
|
+
* This is NOT a test file. Run it manually to spot-check against live data:
|
|
9
|
+
*
|
|
10
|
+
* node packages/gw2-data/tests/validate-live.js
|
|
11
|
+
* node packages/gw2-data/tests/validate-live.js --ids 5489,30185
|
|
12
|
+
* node packages/gw2-data/tests/validate-live.js --type traits --ids 264,1510
|
|
13
|
+
*
|
|
14
|
+
* Exits with code 1 if any validation fails.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { WikiClient } = require("../src/wiki/client");
|
|
18
|
+
const { Gw2ApiClient } = require("../src/api/client");
|
|
19
|
+
const { MemoryCache } = require("../src/wiki/cache");
|
|
20
|
+
const { mergeFacts } = require("../src/facts/merge");
|
|
21
|
+
const { normalizeFactType } = require("../src/facts/normalize");
|
|
22
|
+
|
|
23
|
+
const GW2_API = "https://api.guildwars2.com/v2";
|
|
24
|
+
const WIKI_API = "https://wiki.guildwars2.com/api.php";
|
|
25
|
+
|
|
26
|
+
// Known split skills from the wiki's balance split category.
|
|
27
|
+
// These are good validation targets because they have WvW-specific values.
|
|
28
|
+
const DEFAULT_SKILL_IDS = [
|
|
29
|
+
5489, // Fireball
|
|
30
|
+
5536, // Arcane Blast
|
|
31
|
+
30185, // Berserk
|
|
32
|
+
5507, // Meteor Shower
|
|
33
|
+
5548, // Eruption
|
|
34
|
+
5569, // Water Blast
|
|
35
|
+
5503, // Glyph of Storms
|
|
36
|
+
10586, // Mending (Warrior)
|
|
37
|
+
5694, // Burning Retreat (Elementalist)
|
|
38
|
+
10574, // Bull's Charge (Warrior)
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const DEFAULT_TRAIT_IDS = [
|
|
42
|
+
264, // Burning Precision
|
|
43
|
+
1510, // Flow like Water
|
|
44
|
+
1502, // Swift Revenge
|
|
45
|
+
214, // Empowering Might
|
|
46
|
+
1925, // Stalwart Speed
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
function color(code, text) {
|
|
52
|
+
return `\x1b[${code}m${text}\x1b[0m`;
|
|
53
|
+
}
|
|
54
|
+
const green = (t) => color(32, t);
|
|
55
|
+
const red = (t) => color(31, t);
|
|
56
|
+
const yellow = (t) => color(33, t);
|
|
57
|
+
const dim = (t) => color(2, t);
|
|
58
|
+
|
|
59
|
+
// ── Main ────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
const args = process.argv.slice(2);
|
|
63
|
+
const typeArg = args.includes("--type") ? args[args.indexOf("--type") + 1] : "skills";
|
|
64
|
+
const idsArg = args.includes("--ids") ? args[args.indexOf("--ids") + 1] : null;
|
|
65
|
+
|
|
66
|
+
const ids = idsArg
|
|
67
|
+
? idsArg.split(",").map((s) => parseInt(s.trim(), 10))
|
|
68
|
+
: (typeArg === "traits" ? DEFAULT_TRAIT_IDS : DEFAULT_SKILL_IDS);
|
|
69
|
+
|
|
70
|
+
const endpoint = typeArg === "traits" ? "traits" : "skills";
|
|
71
|
+
|
|
72
|
+
const wikiClient = new WikiClient({
|
|
73
|
+
cache: new MemoryCache(),
|
|
74
|
+
fetch: globalThis.fetch,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const apiClient = new Gw2ApiClient({
|
|
78
|
+
cache: new MemoryCache(),
|
|
79
|
+
fetch: globalThis.fetch,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
console.log(`\nValidating ${ids.length} ${endpoint} against live GW2 data...\n`);
|
|
83
|
+
|
|
84
|
+
let passed = 0;
|
|
85
|
+
let failed = 0;
|
|
86
|
+
let skipped = 0;
|
|
87
|
+
|
|
88
|
+
for (const id of ids) {
|
|
89
|
+
process.stdout.write(` ${id} `);
|
|
90
|
+
|
|
91
|
+
// 1. Fetch API data
|
|
92
|
+
let apiData;
|
|
93
|
+
try {
|
|
94
|
+
const url = `${GW2_API}/${endpoint}/${id}?lang=en`;
|
|
95
|
+
apiData = await apiClient.fetchJson(url);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.log(yellow(`SKIP — API error: ${err.message}`));
|
|
98
|
+
skipped++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const name = apiData.name || `(${endpoint} ${id})`;
|
|
103
|
+
process.stdout.write(`${name.padEnd(30)} `);
|
|
104
|
+
|
|
105
|
+
// 2. Fetch wiki page
|
|
106
|
+
let wikitext;
|
|
107
|
+
try {
|
|
108
|
+
wikitext = await wikiClient.getWikitext(name);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.log(yellow(`SKIP — wiki error: ${err.message}`));
|
|
111
|
+
skipped++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!wikitext) {
|
|
116
|
+
console.log(yellow("SKIP — no wiki page"));
|
|
117
|
+
skipped++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 3. Parse wiki facts
|
|
122
|
+
const wikiResult = wikiClient.parseFacts(wikitext);
|
|
123
|
+
const apiFacts = apiData.facts || [];
|
|
124
|
+
|
|
125
|
+
// 4. Validate
|
|
126
|
+
const issues = [];
|
|
127
|
+
|
|
128
|
+
// Check: if wiki has a split, we should get split facts
|
|
129
|
+
if (wikiResult.splitGrouping?.wvwHasSplit && wikiResult.facts.length === 0) {
|
|
130
|
+
issues.push("split detected but no WvW facts parsed");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check: wiki fact types should all be recognized API types
|
|
134
|
+
for (const fact of wikiResult.facts) {
|
|
135
|
+
const normalized = normalizeFactType(fact.type);
|
|
136
|
+
if (!normalized) {
|
|
137
|
+
issues.push(`unknown fact type: ${fact.type}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check: merge should not crash
|
|
142
|
+
try {
|
|
143
|
+
const merged = mergeFacts(apiFacts, wikiResult.facts, { complete: false });
|
|
144
|
+
if (wikiResult.facts.length > 0 && merged.length === 0) {
|
|
145
|
+
issues.push("merge produced empty result from non-empty inputs");
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
issues.push(`merge crashed: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Check: damage coefficients should be reasonable (0 < coeff < 50)
|
|
152
|
+
for (const fact of wikiResult.facts) {
|
|
153
|
+
if (fact.type === "Damage" && fact.dmg_multiplier !== undefined) {
|
|
154
|
+
if (fact.dmg_multiplier < 0 || fact.dmg_multiplier > 50) {
|
|
155
|
+
issues.push(`suspicious damage coefficient: ${fact.dmg_multiplier}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check: durations should be non-negative
|
|
161
|
+
for (const fact of wikiResult.facts) {
|
|
162
|
+
if (fact.duration !== undefined && fact.duration < 0) {
|
|
163
|
+
issues.push(`negative duration: ${fact.duration}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (issues.length > 0) {
|
|
168
|
+
console.log(red("FAIL"));
|
|
169
|
+
for (const issue of issues) {
|
|
170
|
+
console.log(` ${red("✗")} ${issue}`);
|
|
171
|
+
}
|
|
172
|
+
failed++;
|
|
173
|
+
} else {
|
|
174
|
+
const splitLabel = wikiResult.splitGrouping?.wvwHasSplit ? "split" : "universal";
|
|
175
|
+
console.log(
|
|
176
|
+
green("OK") +
|
|
177
|
+
dim(` (${wikiResult.facts.length} wiki facts, ${apiFacts.length} API facts, ${splitLabel})`)
|
|
178
|
+
);
|
|
179
|
+
passed++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
console.log(`\n ${green(`${passed} passed`)}, ${failed > 0 ? red(`${failed} failed`) : `${failed} failed`}, ${skipped} skipped\n`);
|
|
184
|
+
|
|
185
|
+
if (failed > 0) process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
main().catch((err) => {
|
|
189
|
+
console.error(err);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
});
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { WikiClient } = require("../src/wiki/client");
|
|
4
|
+
const { MemoryCache } = require("../src/wiki/cache");
|
|
5
|
+
|
|
6
|
+
describe("WikiClient", () => {
|
|
7
|
+
let client;
|
|
8
|
+
let mockFetch;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
mockFetch = jest.fn();
|
|
12
|
+
client = new WikiClient({
|
|
13
|
+
cache: new MemoryCache(),
|
|
14
|
+
fetch: mockFetch,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("getWikitext", () => {
|
|
19
|
+
test("fetches and returns raw wikitext for a page", async () => {
|
|
20
|
+
mockFetch.mockResolvedValueOnce({
|
|
21
|
+
ok: true,
|
|
22
|
+
json: async () => ({
|
|
23
|
+
query: {
|
|
24
|
+
pages: {
|
|
25
|
+
"123": {
|
|
26
|
+
title: "Fireball",
|
|
27
|
+
revisions: [{ "*": "{{skill infobox\n| id = 5489\n}}" }],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const result = await client.getWikitext("Fireball");
|
|
35
|
+
expect(result).toBe("{{skill infobox\n| id = 5489\n}}");
|
|
36
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(mockFetch.mock.calls[0][0]).toContain("action=query");
|
|
38
|
+
expect(mockFetch.mock.calls[0][0]).toContain("rvprop=content");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns null for missing page", async () => {
|
|
42
|
+
mockFetch.mockResolvedValueOnce({
|
|
43
|
+
ok: true,
|
|
44
|
+
json: async () => ({
|
|
45
|
+
query: {
|
|
46
|
+
pages: {
|
|
47
|
+
"-1": { missing: true },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const result = await client.getWikitext("Nonexistent");
|
|
54
|
+
expect(result).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("caches wikitext on subsequent calls", async () => {
|
|
58
|
+
mockFetch.mockResolvedValueOnce({
|
|
59
|
+
ok: true,
|
|
60
|
+
json: async () => ({
|
|
61
|
+
query: {
|
|
62
|
+
pages: {
|
|
63
|
+
"123": {
|
|
64
|
+
title: "Fireball",
|
|
65
|
+
revisions: [{ "*": "wikitext content" }],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await client.getWikitext("Fireball");
|
|
73
|
+
const result2 = await client.getWikitext("Fireball");
|
|
74
|
+
expect(result2).toBe("wikitext content");
|
|
75
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("getWikitextBatch", () => {
|
|
80
|
+
test("fetches multiple pages in a single request", async () => {
|
|
81
|
+
mockFetch.mockResolvedValueOnce({
|
|
82
|
+
ok: true,
|
|
83
|
+
json: async () => ({
|
|
84
|
+
query: {
|
|
85
|
+
pages: {
|
|
86
|
+
"1": { title: "Fireball", revisions: [{ "*": "fireball wikitext" }] },
|
|
87
|
+
"2": { title: "Shelter", revisions: [{ "*": "shelter wikitext" }] },
|
|
88
|
+
"3": { title: "Moa Stance", revisions: [{ "*": "moa wikitext" }] },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = await client.getWikitextBatch(["Fireball", "Shelter", "Moa Stance"]);
|
|
95
|
+
expect(result.size).toBe(3);
|
|
96
|
+
expect(result.get("Fireball")).toBe("fireball wikitext");
|
|
97
|
+
expect(result.get("Shelter")).toBe("shelter wikitext");
|
|
98
|
+
expect(result.get("Moa Stance")).toBe("moa wikitext");
|
|
99
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("chunks at 50 titles per request", async () => {
|
|
103
|
+
// Generate 51 titles
|
|
104
|
+
const titles = Array.from({ length: 51 }, (_, i) => `Skill_${i}`);
|
|
105
|
+
|
|
106
|
+
const makeBatchResponse = (batch) => ({
|
|
107
|
+
ok: true,
|
|
108
|
+
json: async () => ({
|
|
109
|
+
query: {
|
|
110
|
+
pages: Object.fromEntries(
|
|
111
|
+
batch.map((t, i) => [String(i), { title: t, revisions: [{ "*": `${t} text` }] }])
|
|
112
|
+
),
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// First call: 50 titles, second call: 1 title
|
|
118
|
+
mockFetch
|
|
119
|
+
.mockResolvedValueOnce(makeBatchResponse(titles.slice(0, 50)))
|
|
120
|
+
.mockResolvedValueOnce(makeBatchResponse(titles.slice(50)));
|
|
121
|
+
|
|
122
|
+
const result = await client.getWikitextBatch(titles);
|
|
123
|
+
expect(result.size).toBe(51);
|
|
124
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
125
|
+
expect(result.get("Skill_0")).toBe("Skill_0 text");
|
|
126
|
+
expect(result.get("Skill_50")).toBe("Skill_50 text");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("uses cached entries without fetching", async () => {
|
|
130
|
+
// Pre-populate cache
|
|
131
|
+
client._cache.set("wikitext:Fireball", "cached fireball", 60000);
|
|
132
|
+
|
|
133
|
+
mockFetch.mockResolvedValueOnce({
|
|
134
|
+
ok: true,
|
|
135
|
+
json: async () => ({
|
|
136
|
+
query: {
|
|
137
|
+
pages: {
|
|
138
|
+
"1": { title: "Shelter", revisions: [{ "*": "shelter text" }] },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = await client.getWikitextBatch(["Fireball", "Shelter"]);
|
|
145
|
+
expect(result.get("Fireball")).toBe("cached fireball");
|
|
146
|
+
expect(result.get("Shelter")).toBe("shelter text");
|
|
147
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
148
|
+
// URL should only contain Shelter
|
|
149
|
+
expect(mockFetch.mock.calls[0][0]).toContain("Shelter");
|
|
150
|
+
expect(mockFetch.mock.calls[0][0]).not.toContain("Fireball");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("returns null for missing pages", async () => {
|
|
154
|
+
mockFetch.mockResolvedValueOnce({
|
|
155
|
+
ok: true,
|
|
156
|
+
json: async () => ({
|
|
157
|
+
query: {
|
|
158
|
+
pages: {
|
|
159
|
+
"-1": { title: "Nonexistent Skill", missing: true },
|
|
160
|
+
"1": { title: "Fireball", revisions: [{ "*": "fireball text" }] },
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = await client.getWikitextBatch(["Fireball", "Nonexistent Skill"]);
|
|
167
|
+
expect(result.get("Fireball")).toBe("fireball text");
|
|
168
|
+
expect(result.get("Nonexistent Skill")).toBe(null);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("handles failed HTTP response gracefully", async () => {
|
|
172
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
173
|
+
|
|
174
|
+
const result = await client.getWikitextBatch(["Fireball", "Shelter"]);
|
|
175
|
+
expect(result.get("Fireball")).toBe(null);
|
|
176
|
+
expect(result.get("Shelter")).toBe(null);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("returns empty map for empty input", async () => {
|
|
180
|
+
const result = await client.getWikitextBatch([]);
|
|
181
|
+
expect(result.size).toBe(0);
|
|
182
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("caches missing pages so subsequent batch calls skip them", async () => {
|
|
186
|
+
mockFetch.mockResolvedValueOnce({
|
|
187
|
+
ok: true,
|
|
188
|
+
json: async () => ({
|
|
189
|
+
query: {
|
|
190
|
+
pages: {
|
|
191
|
+
"-1": { title: "Missing Skill", missing: true },
|
|
192
|
+
"1": { title: "Fireball", revisions: [{ "*": "fireball text" }] },
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const result1 = await client.getWikitextBatch(["Fireball", "Missing Skill"]);
|
|
199
|
+
expect(result1.get("Missing Skill")).toBeNull();
|
|
200
|
+
expect(result1.get("Fireball")).toBe("fireball text");
|
|
201
|
+
|
|
202
|
+
// Second batch: "Missing Skill" should come from cache, only "Shelter" fetched
|
|
203
|
+
mockFetch.mockResolvedValueOnce({
|
|
204
|
+
ok: true,
|
|
205
|
+
json: async () => ({
|
|
206
|
+
query: {
|
|
207
|
+
pages: {
|
|
208
|
+
"1": { title: "Shelter", revisions: [{ "*": "shelter text" }] },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const result2 = await client.getWikitextBatch(["Missing Skill", "Shelter"]);
|
|
215
|
+
expect(result2.get("Missing Skill")).toBeNull();
|
|
216
|
+
expect(result2.get("Shelter")).toBe("shelter text");
|
|
217
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
218
|
+
// Second fetch should only contain Shelter, not Missing Skill
|
|
219
|
+
expect(mockFetch.mock.calls[1][0]).toContain("Shelter");
|
|
220
|
+
expect(mockFetch.mock.calls[1][0]).not.toContain("Missing");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("handles MediaWiki title normalization", async () => {
|
|
224
|
+
mockFetch.mockResolvedValueOnce({
|
|
225
|
+
ok: true,
|
|
226
|
+
json: async () => ({
|
|
227
|
+
query: {
|
|
228
|
+
normalized: [{ from: "fireball", to: "Fireball" }],
|
|
229
|
+
pages: {
|
|
230
|
+
"1": { title: "Fireball", revisions: [{ "*": "fireball text" }] },
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const result = await client.getWikitextBatch(["fireball"]);
|
|
237
|
+
expect(result.get("fireball")).toBe("fireball text");
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("getRecentChanges", () => {
|
|
242
|
+
test("returns list of recently changed page titles", async () => {
|
|
243
|
+
mockFetch.mockResolvedValueOnce({
|
|
244
|
+
ok: true,
|
|
245
|
+
json: async () => ({
|
|
246
|
+
query: {
|
|
247
|
+
recentchanges: [
|
|
248
|
+
{ title: "Fireball", timestamp: "2026-04-09T10:00:00Z" },
|
|
249
|
+
{ title: "Ice Spike", timestamp: "2026-04-09T09:00:00Z" },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const changes = await client.getRecentChanges("2026-04-08T00:00:00Z");
|
|
256
|
+
expect(changes).toEqual(["Fireball", "Ice Spike"]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("getWikitext", () => {
|
|
261
|
+
test("caches missing pages so subsequent calls skip the network", async () => {
|
|
262
|
+
mockFetch.mockResolvedValueOnce({
|
|
263
|
+
ok: true,
|
|
264
|
+
json: async () => ({
|
|
265
|
+
query: {
|
|
266
|
+
pages: {
|
|
267
|
+
"-1": { missing: true },
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const result1 = await client.getWikitext("Nonexistent");
|
|
274
|
+
expect(result1).toBeNull();
|
|
275
|
+
|
|
276
|
+
// Second call should NOT hit the network
|
|
277
|
+
const result2 = await client.getWikitext("Nonexistent");
|
|
278
|
+
expect(result2).toBeNull();
|
|
279
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("returns null on HTTP error", async () => {
|
|
283
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
284
|
+
const result = await client.getWikitext("Fireball");
|
|
285
|
+
expect(result).toBeNull();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("returns null when query.pages is missing", async () => {
|
|
289
|
+
mockFetch.mockResolvedValueOnce({
|
|
290
|
+
ok: true,
|
|
291
|
+
json: async () => ({ query: {} }),
|
|
292
|
+
});
|
|
293
|
+
const result = await client.getWikitext("Fireball");
|
|
294
|
+
expect(result).toBeNull();
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("getRecentChanges", () => {
|
|
299
|
+
test("returns empty array on HTTP error", async () => {
|
|
300
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
301
|
+
const result = await client.getRecentChanges("2026-04-08T00:00:00Z");
|
|
302
|
+
expect(result).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("deduplicates changed page titles", async () => {
|
|
306
|
+
mockFetch.mockResolvedValueOnce({
|
|
307
|
+
ok: true,
|
|
308
|
+
json: async () => ({
|
|
309
|
+
query: {
|
|
310
|
+
recentchanges: [
|
|
311
|
+
{ title: "Fireball", timestamp: "2026-04-09T10:00:00Z" },
|
|
312
|
+
{ title: "Fireball", timestamp: "2026-04-09T09:00:00Z" },
|
|
313
|
+
{ title: "Ice Spike", timestamp: "2026-04-09T08:00:00Z" },
|
|
314
|
+
],
|
|
315
|
+
},
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
const result = await client.getRecentChanges("2026-04-08T00:00:00Z");
|
|
319
|
+
expect(result).toEqual(["Fireball", "Ice Spike"]);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe("refresh", () => {
|
|
324
|
+
test("first call records timestamp and returns empty array", async () => {
|
|
325
|
+
const result = await client.refresh();
|
|
326
|
+
expect(result).toEqual([]);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("second call fetches recent changes and invalidates cache", async () => {
|
|
330
|
+
// First call sets the timestamp
|
|
331
|
+
await client.refresh();
|
|
332
|
+
|
|
333
|
+
// Pre-populate cache
|
|
334
|
+
const cache = client._cache;
|
|
335
|
+
cache.set("wikitext:Fireball", "old content", 60000);
|
|
336
|
+
cache.set("facts:Fireball", [{ type: "Damage" }], 60000);
|
|
337
|
+
|
|
338
|
+
mockFetch.mockResolvedValueOnce({
|
|
339
|
+
ok: true,
|
|
340
|
+
json: async () => ({
|
|
341
|
+
query: {
|
|
342
|
+
recentchanges: [
|
|
343
|
+
{ title: "Fireball", timestamp: "2026-04-09T10:00:00Z" },
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
}),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const changed = await client.refresh();
|
|
350
|
+
expect(changed).toEqual(["Fireball"]);
|
|
351
|
+
expect(cache.get("wikitext:Fireball")).toBeNull();
|
|
352
|
+
expect(cache.get("facts:Fireball")).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
describe("rate limiting", () => {
|
|
357
|
+
test("delays subsequent requests within rate limit window", async () => {
|
|
358
|
+
const response = {
|
|
359
|
+
ok: true,
|
|
360
|
+
json: async () => ({
|
|
361
|
+
query: {
|
|
362
|
+
pages: { "1": { title: "A", revisions: [{ "*": "a" }] } },
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
};
|
|
366
|
+
mockFetch.mockResolvedValue(response);
|
|
367
|
+
|
|
368
|
+
const start = Date.now();
|
|
369
|
+
await client.getWikitext("A");
|
|
370
|
+
await client.getWikitext("B"); // different key, forces second fetch
|
|
371
|
+
const elapsed = Date.now() - start;
|
|
372
|
+
|
|
373
|
+
// Should have waited ~200ms between requests
|
|
374
|
+
expect(elapsed).toBeGreaterThanOrEqual(150);
|
|
375
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("prefixSearch", () => {
|
|
380
|
+
test("returns matching page titles", async () => {
|
|
381
|
+
mockFetch.mockResolvedValueOnce({
|
|
382
|
+
ok: true,
|
|
383
|
+
json: async () => ({
|
|
384
|
+
query: {
|
|
385
|
+
prefixsearch: [
|
|
386
|
+
{ title: "Ring of Fire (elementalist skill)" },
|
|
387
|
+
{ title: "Ring of Fire (location)" },
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const results = await client.prefixSearch("Ring of Fire (");
|
|
394
|
+
expect(results).toEqual([
|
|
395
|
+
"Ring of Fire (elementalist skill)",
|
|
396
|
+
"Ring of Fire (location)",
|
|
397
|
+
]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("returns empty array when no matches", async () => {
|
|
401
|
+
mockFetch.mockResolvedValueOnce({
|
|
402
|
+
ok: true,
|
|
403
|
+
json: async () => ({
|
|
404
|
+
query: { prefixsearch: [] },
|
|
405
|
+
}),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
const results = await client.prefixSearch("Nonexistent (");
|
|
409
|
+
expect(results).toEqual([]);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("returns empty array on fetch failure", async () => {
|
|
413
|
+
mockFetch.mockResolvedValueOnce({ ok: false });
|
|
414
|
+
|
|
415
|
+
const results = await client.prefixSearch("Test (");
|
|
416
|
+
expect(results).toEqual([]);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
describe("parseFacts", () => {
|
|
421
|
+
test("parses wikitext into facts for a game mode", () => {
|
|
422
|
+
const wikitext = [
|
|
423
|
+
"{{skill infobox",
|
|
424
|
+
"| id = 5489",
|
|
425
|
+
"| split = pve, wvw, pvp",
|
|
426
|
+
"}}",
|
|
427
|
+
"{{skill fact|damage|0.8|game mode=wvw}}",
|
|
428
|
+
"{{skill fact|damage|1.2|game mode=pve}}",
|
|
429
|
+
"{{skill fact|recharge|25}}",
|
|
430
|
+
].join("\n");
|
|
431
|
+
|
|
432
|
+
const result = client.parseFacts(wikitext);
|
|
433
|
+
expect(result.facts.length).toBeGreaterThanOrEqual(2);
|
|
434
|
+
const damageFact = result.facts.find((f) => f.type === "Damage");
|
|
435
|
+
expect(damageFact.dmg_multiplier).toBe(0.8);
|
|
436
|
+
const rechargeFact = result.facts.find((f) => f.type === "Recharge");
|
|
437
|
+
expect(rechargeFact.value).toBe(25);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("falls back to parseInfoboxParams when no skill fact templates found", () => {
|
|
441
|
+
const wikitext = [
|
|
442
|
+
"{{skill infobox",
|
|
443
|
+
"| id = 1234",
|
|
444
|
+
"| split = pve, wvw, pvp",
|
|
445
|
+
"| recharge wvw = 30",
|
|
446
|
+
"}}",
|
|
447
|
+
].join("\n");
|
|
448
|
+
|
|
449
|
+
const result = client.parseFacts(wikitext);
|
|
450
|
+
expect(result.splitGrouping.wvwHasSplit).toBe(true);
|
|
451
|
+
const rechargeFact = result.facts.find((f) => f.type === "Recharge");
|
|
452
|
+
expect(rechargeFact).toBeTruthy();
|
|
453
|
+
expect(rechargeFact.value).toBe(30);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("returns null splitGrouping when no split field present", () => {
|
|
457
|
+
const wikitext = [
|
|
458
|
+
"{{skill infobox",
|
|
459
|
+
"| id = 1234",
|
|
460
|
+
"}}",
|
|
461
|
+
"{{skill fact|damage|1.0}}",
|
|
462
|
+
].join("\n");
|
|
463
|
+
|
|
464
|
+
const result = client.parseFacts(wikitext);
|
|
465
|
+
expect(result.splitGrouping).toBeNull();
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|