pf2e-spellbook 1.0.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/tools/test.mjs ADDED
@@ -0,0 +1,536 @@
1
+ import { JSDOM, VirtualConsole } from "jsdom";
2
+ import fs from "node:fs";
3
+
4
+ const html = fs.readFileSync(new URL("../dist/index.html", import.meta.url), "utf8");
5
+
6
+ let pass = 0, fail = 0;
7
+ function ok(cond, msg) { (cond ? pass++ : fail++); console.log((cond ? " ✓ " : " ✗ FAIL ") + msg); }
8
+
9
+ const errors = [];
10
+ const vc = new VirtualConsole();
11
+ vc.on("jsdomError", e => errors.push(e.message));
12
+
13
+ const dom = new JSDOM(html, {
14
+ runScripts: "dangerously",
15
+ virtualConsole: vc,
16
+ url: "https://example.org/", // enables native localStorage
17
+ beforeParse(window) {
18
+ window.scrollTo = () => {};
19
+ window.confirm = () => true;
20
+ },
21
+ });
22
+ const w = dom.window;
23
+ const d = w.document;
24
+ const ev = (expr) => w.eval(expr); // read/write const/let globals
25
+
26
+ console.log("\n# load / no script errors");
27
+ ok(errors.length === 0, "no jsdom script errors" + (errors.length ? ": " + errors[0] : ""));
28
+ ok(ev("GENERATED_SPELLS.length") >= 1600, `spell data loaded (${ev("GENERATED_SPELLS.length")} entries)`);
29
+ ok(ev("Object.keys(SPELL_OVERRIDES).length") === 73, "73 overrides loaded");
30
+ ok(!d.getElementById("view-classpick").classList.contains("hide"), "class picker shown on first run");
31
+ ok(d.querySelector("nav.bottom").classList.contains("hide"), "nav hidden until a class is chosen");
32
+
33
+ console.log("\n# choose Cleric");
34
+ w.chooseClass("cleric");
35
+ ok(ev("state.classId") === "cleric", "state.classId = cleric");
36
+ ok(!d.getElementById("view-prepare").classList.contains("hide"), "lands on Prepare (no prep yet)");
37
+ ok(d.getElementById("charSub").textContent.includes("Divine"), "header shows Divine list");
38
+
39
+ console.log("\n# Cleric parity: DC / slots / font across levels");
40
+ const cases = [
41
+ { lvl: 1, dc: 16, slots: { 1: 2 }, font: 4 },
42
+ { lvl: 3, dc: 18, slots: { 1: 3, 2: 2 }, font: 4 },
43
+ { lvl: 5, dc: 20, slots: { 1: 3, 2: 3, 3: 2 }, font: 4 },
44
+ { lvl: 11, dc: 28, slots: { 1: 3, 2: 3, 3: 3, 4: 3, 5: 3, 6: 2 }, font: 4 }, // expert@7 (+4)
45
+ { lvl: 19, dc: 40, slots: { 1: 3, 2: 3, 3: 3, 4: 3, 5: 3, 6: 3, 7: 3, 8: 3, 9: 3, 10: 1 }, font: 4 }, // Cloistered: legendary@19 (+8) -> 10+19+8+3
46
+ ];
47
+ for (const c of cases) {
48
+ ev(`state.level=${c.lvl}; state.keyMod=3; state.fontMod=3;`);
49
+ ok(w.spellDC() === c.dc, `L${c.lvl} Spell DC = ${c.dc} (got ${w.spellDC()})`);
50
+ ok(JSON.stringify(w.classSlots(c.lvl)) === JSON.stringify(c.slots),
51
+ `L${c.lvl} slots ${JSON.stringify(c.slots)} (got ${JSON.stringify(w.classSlots(c.lvl))})`);
52
+ ok(w.fontCount() === c.font, `L${c.lvl} font count = ${c.font} (got ${w.fontCount()})`);
53
+ }
54
+
55
+ console.log("\n# spell index / tradition filter / overrides");
56
+ const divine = w.classSpells();
57
+ ok(divine.length > 200, `divine list large (${divine.length} spells)`);
58
+ ok(divine.every(s => s.traditions.includes("divine")), "every listed spell is divine");
59
+ ok(divine.every(s => s.type !== "focus"), "focus spells excluded from class list");
60
+ ok(w.findSpell("heal").curated === true, "Heal uses curated override text");
61
+ ok(w.findSpell("heal").description.startsWith("Channel vital energy"), "Heal override text applied");
62
+ ok(w.findSpell("daze") && w.findSpell("daze").rank === 0, "Daze is a rank-0 cantrip");
63
+
64
+ console.log("\n# Prepare -> Cast Today flow (level 3)");
65
+ ev("state.level=3");
66
+ w.go("prepare");
67
+ const cantripSels = [...d.querySelectorAll(".cantripSel")];
68
+ ok(cantripSels.length === 5, `5 cantrip pickers rendered (got ${cantripSels.length})`);
69
+ ["guidance", "shield", "stabilize", "divine-lance", "detect-magic"].forEach((slug, i) => { cantripSels[i].value = slug; });
70
+ const slotSels = [...d.querySelectorAll(".slotSel")];
71
+ ok(slotSels.length === 5, `5 slot pickers at L3 (3xR1 + 2xR2) (got ${slotSels.length})`);
72
+ slotSels.forEach(sel => {
73
+ const r = Number(sel.dataset.rank);
74
+ const opt = [...sel.options].find(o => o.value && w.findSpell(o.value) && w.findSpell(o.value).rank <= r);
75
+ sel.value = opt ? opt.value : "";
76
+ });
77
+ w.savePrep();
78
+ ok(ev("state.prepared.cantrips.length") === 5, "prepared 5 cantrips");
79
+ ok(ev("state.prepared.divineFont.fontCount") === 4, "divineFont saved with 4 free casts");
80
+ ok(!d.getElementById("view-today").classList.contains("hide"), "navigated to Today after prepare");
81
+
82
+ const today = d.getElementById("todayContent").innerHTML;
83
+ ok(/Divine Font/.test(today), "Today shows Divine Font section");
84
+ ok(/Cantrips/.test(today) && /Guidance/.test(today), "Today shows cantrips incl. Guidance");
85
+ ok((today.match(/castbtn/g) || []).length >= 5, "cast buttons present for slots + font");
86
+
87
+ console.log("\n# cast tracking + new day");
88
+ const fontRank = ev("state.prepared.divineFont.fontRank");
89
+ w.doCast("font:" + fontRank + ":0", 4);
90
+ ok(ev("state.cast['font:" + fontRank + ":0']") === 1, "casting a font slot increments spent");
91
+ w.newDay();
92
+ ok(ev("Object.keys(state.cast).length") === 0, "new day resets spent casts");
93
+
94
+ console.log("\n# Browse search");
95
+ w.go("browse");
96
+ ok(d.getElementById("browseTitle").textContent.includes("Divine"), "browse titled Divine");
97
+ d.getElementById("search").value = "heal";
98
+ w.renderBrowse();
99
+ ok(/browse-row/.test(d.getElementById("browseList").innerHTML), "search 'heal' renders compact rows");
100
+ ok(!/class="card /.test(d.getElementById("browseList").innerHTML), "full cards not rendered until expanded (perf)");
101
+ w.toggleBrowse("heal", d.querySelector(".browse-row-head"));
102
+ ok(/class="card /.test(d.getElementById("bc_heal").innerHTML), "tapping a row expands the full card");
103
+
104
+ console.log("\n# persistence round-trip");
105
+ const saved = JSON.parse(w.localStorage.getItem("pf2eSpellbook.v3"));
106
+ ok(saved && saved.characters[saved.activeId].classId === "cleric", "state persisted to localStorage (v3 library)");
107
+
108
+ console.log("\n# switch to Wizard (arcane)");
109
+ w.chooseClass("wizard");
110
+ ok(ev("state.classId") === "wizard", "switched to wizard");
111
+ ok(w.activeClass().keyAbility === "Intelligence", "wizard key ability = Int");
112
+ const arcane = w.classSpells();
113
+ ok(arcane.every(s => s.traditions.includes("arcane")), "wizard list is arcane");
114
+ ok(arcane.some(s => s.slug === "fireball"), "Fireball available to wizard");
115
+ ok(!w.hasFeature("divineFont"), "wizard has no Divine Font");
116
+ ok(w.findSpell("heal").traditions.includes("arcane") === false, "Heal is not on the arcane list");
117
+
118
+ console.log("\n# WIZARD: extra school slots + Drain Bonded Item");
119
+ w.chooseClass("wizard");
120
+ ev("state.level=5");
121
+ w.go("prepare");
122
+ ok(d.querySelectorAll(".extraSel").length === Object.keys(w.classSlots(5)).length,
123
+ `one 🎓 school slot per rank (${d.querySelectorAll(".extraSel").length})`);
124
+ [...d.querySelectorAll(".cantripSel")].forEach((s,i)=>{ const o=[...s.options].find(o=>o.value); if(o)s.value=o.value; });
125
+ [...d.querySelectorAll(".slotSel")].forEach(sel=>{ const r=+sel.dataset.rank; const o=[...sel.options].find(o=>o.value&&w.findSpell(o.value).rank<=r); if(o)sel.value=o.value; });
126
+ [...d.querySelectorAll(".extraSel")].forEach(sel=>{ const r=+sel.dataset.rank; const o=[...sel.options].find(o=>o.value&&w.findSpell(o.value).rank<=r); if(o)sel.value=o.value; });
127
+ w.savePrep();
128
+ ok(ev("state.prepared.extra.school && Object.keys(state.prepared.extra.school).length") >= 3, "school slots saved per rank");
129
+ const wToday = d.getElementById("todayContent").innerHTML;
130
+ ok(/Drain Bonded Item/.test(wToday), "Today shows Drain Bonded Item resource");
131
+ ok(/slottag/.test(wToday), "Today tags the 🎓 school slot");
132
+ w.doCast("resource:bonded", 1);
133
+ ok(ev("state.cast['resource:bonded']") === 1, "Drain Bonded Item tracks a use");
134
+
135
+ console.log("\n# WITCH: patron sets tradition");
136
+ w.chooseClass("witch");
137
+ ok(w.activeClass().keyAbility === "Intelligence", "witch key ability = Int");
138
+ w.setPatronTradition("primal");
139
+ ok(w.classSpells().every(s => s.traditions.includes("primal")), "witch list follows patron tradition (primal)");
140
+ w.setPatronTradition("occult");
141
+ ok(w.classSpells().some(s => s.slug === "phantom-pain") || w.classSpells().every(s=>s.traditions.includes("occult")), "patron tradition switches list to occult");
142
+
143
+ console.log("\n# SORCERER: bloodline tradition + spontaneous repertoire");
144
+ w.chooseClass("sorcerer");
145
+ ev("state.level=5; state.keyMod=4;");
146
+ ok(w.isSpontaneous(), "sorcerer is spontaneous");
147
+ w.setBloodline("draconic");
148
+ ok(w.activeTradition() === "arcane", "draconic bloodline -> arcane");
149
+ w.setBloodline("fey");
150
+ ok(w.activeTradition() === "primal", "fey bloodline -> primal");
151
+ w.setBloodline("angelic");
152
+ ok(w.activeTradition() === "divine", "angelic bloodline -> divine");
153
+ w.go("prepare");
154
+ ok(d.querySelectorAll("[data-reprank]").length >= 3, "repertoire groups rendered per rank (no fixed slot pickers)");
155
+ ok(d.querySelectorAll(".slotSel").length === 0, "spontaneous has no prepared slot pickers");
156
+ // fill cantrips
157
+ [...d.querySelectorAll(".cantripSel")].forEach((s)=>{ const o=[...s.options].find(o=>o.value); if(o)s.value=o.value; });
158
+ // add a known spell to each rank; mark the rank-1 spell as signature
159
+ [...d.querySelectorAll('[data-reprank]')].forEach(group=>{
160
+ const r = +group.dataset.reprank;
161
+ const sel = group.querySelector(".repSel");
162
+ const o = [...sel.options].find(o=>o.value && w.findSpell(o.value).rank <= r);
163
+ if(o) sel.value = o.value;
164
+ if(r===1){ const star=group.querySelector(".sigstar"); w.toggleSig(star); }
165
+ });
166
+ w.savePrep();
167
+ ok(ev("state.prepared.type") === "spontaneous", "saved a spontaneous repertoire");
168
+ ok(ev("Object.keys(state.prepared.repertoire).length") >= 3, "repertoire has spells across ranks");
169
+ ok(ev("state.prepared.repertoire['1'][0].sig") === true, "rank-1 spell flagged signature");
170
+
171
+ console.log("\n# SORCERER Cast Today: shared rank pools + signature heighten");
172
+ const sToday = d.getElementById("todayContent").innerHTML;
173
+ ok(/slots? left/.test(sToday), "Today shows per-rank slot pools");
174
+ ok(/heightened to rank/.test(sToday), "signature spell appears heightened in a higher pool");
175
+ const slots5 = w.classSlots(5);
176
+ const topRank = Math.max(...Object.keys(slots5).map(Number));
177
+ w.castPool(topRank, slots5[topRank]);
178
+ ok(ev(`state.cast['pool:${topRank}']`) === 1, "casting from a rank pool decrements it");
179
+ w.uncastPool(topRank);
180
+ ok(ev(`state.cast['pool:${topRank}']`) === 0, "undo refunds a pool slot");
181
+
182
+ console.log("\n# BARD / ORACLE fixed traditions");
183
+ w.chooseClass("bard");
184
+ ok(w.activeTradition() === "occult" && w.isSpontaneous(), "bard = occult spontaneous");
185
+ w.chooseClass("oracle");
186
+ ok(w.activeTradition() === "divine" && w.isSpontaneous(), "oracle = divine spontaneous");
187
+
188
+ console.log("\n# FOCUS SPELLS: Druid (pool + cast + refocus)");
189
+ w.chooseClass("druid");
190
+ ev("state.level=5");
191
+ ok(w.hasFocus(), "druid has focus spells");
192
+ ok(w.classFocusSpells().some(s => s.slug === "heal-animal"), "Heal Animal in druid focus list");
193
+ ok(w.focusRank() === 3, "focus rank at L5 = 3 (ceil 5/2)");
194
+ w.go("prepare");
195
+ ok(!!d.getElementById("inFocusPool"), "focus pool selector rendered in Prepare");
196
+ ok(d.querySelectorAll(".focusChk").length >= 1, "focus checklist rendered");
197
+ w.setFocusPool(2);
198
+ [...d.querySelectorAll(".cantripSel")].forEach(s=>{ const o=[...s.options].find(o=>o.value); if(o)s.value=o.value; });
199
+ [...d.querySelectorAll(".slotSel")].forEach(sel=>{ const r=+sel.dataset.rank; const o=[...sel.options].find(o=>o.value&&w.findSpell(o.value).rank<=r); if(o)sel.value=o.value; });
200
+ d.querySelector('.focusChk[value="heal-animal"]').checked = true;
201
+ d.querySelector('.focusChk[value="tempest-surge"]').checked = true;
202
+ w.savePrep();
203
+ ok(ev("state.prepared.focus.pool") === 2, "focus pool saved (2)");
204
+ ok(ev("state.prepared.focus.spells.length") === 2, "two focus spells saved");
205
+ const dToday = d.getElementById("todayContent").innerHTML;
206
+ ok(/Focus spells/.test(dToday) && /2\/2 points/.test(dToday), "Today shows focus pool 2/2");
207
+ ok(/heightened to rank 3/.test(dToday), "focus spell shows heighten to rank 3");
208
+ w.castFocus(2);
209
+ ok(ev("state.cast['focuspool']") === 1, "casting a focus spell spends a point");
210
+ w.castFocus(2); w.castFocus(2);
211
+ ok(ev("state.cast['focuspool']") === 2, "focus points cap at pool size");
212
+ w.refocus();
213
+ ok(ev("state.cast['focuspool']") === 0, "refocus restores all focus points");
214
+
215
+ console.log("\n# FOCUS CANTRIP: Bard composition is at-will");
216
+ w.chooseClass("bard");
217
+ ev("state.level=3");
218
+ ok(w.findSpell("courageous-anthem").focus === true && w.findSpell("courageous-anthem").rank === 0, "Courageous Anthem is a focus cantrip");
219
+ w.go("prepare");
220
+ [...d.querySelectorAll(".cantripSel")].forEach(s=>{ const o=[...s.options].find(o=>o.value); if(o)s.value=o.value; });
221
+ d.querySelector('.focusChk[value="courageous-anthem"]').checked = true;
222
+ w.savePrep();
223
+ ok(/at will/.test(d.getElementById("todayContent").innerHTML), "focus cantrip shows ∞ at will (no point cost)");
224
+
225
+ console.log("\n# BROWSE: focus chip");
226
+ w.chooseClass("druid");
227
+ w.go("browse");
228
+ w.setBrowseRank("focus");
229
+ ok(d.getElementById("browseTitle").textContent.includes("focus"), "browse focus mode titled focus spells");
230
+ ok(/browse-row/.test(d.getElementById("browseList").innerHTML), "focus browse renders rows");
231
+
232
+ console.log("\n# MULTI-CHARACTER + export / import");
233
+ const before = ev("Object.keys(library.characters).length");
234
+ w.newCharacter();
235
+ ok(ev("Object.keys(library.characters).length") === before + 1, "new character added to library");
236
+ ok(ev("state.classId") === null, "new character starts with no class");
237
+ ok(!d.getElementById("view-classpick").classList.contains("hide"), "new character opens the class picker");
238
+ w.chooseClass("wizard");
239
+ ev("state.name='Mordteero'; state.level=8;");
240
+ w.openMenu();
241
+ ok(/Mordteero/.test(d.getElementById("menuList").innerHTML), "menu lists the new character by name");
242
+ ok((d.getElementById("menuList").innerHTML.match(/classcard/g) || []).length >= 2, "menu shows multiple characters");
243
+ w.exportCharacter();
244
+ const code = d.getElementById("menuIO").value;
245
+ ok(code.startsWith("PF2E1:"), "export produces a PF2E1 code");
246
+
247
+ const ids = ev("Object.keys(library.characters)");
248
+ const firstId = ids[0];
249
+ w.switchCharacter(firstId);
250
+ ok(ev("state.id") === firstId, "switched to a different character");
251
+ ok(ev("state.name") !== "Mordteero", "active character actually changed");
252
+
253
+ const n1 = ev("Object.keys(library.characters).length");
254
+ w.openMenu();
255
+ d.getElementById("menuIO").value = code;
256
+ w.importCharacter();
257
+ ok(ev("Object.keys(library.characters).length") === n1 + 1, "import adds a new character");
258
+ ok(ev("state.name") === "Mordteero" && ev("state.classId") === "wizard" && ev("state.level") === 8,
259
+ "imported character loaded faithfully (wizard Mordteero L8)");
260
+
261
+ const delId = ev("state.id");
262
+ w.openMenu();
263
+ const n2 = ev("Object.keys(library.characters).length");
264
+ w.deleteCharacter(delId);
265
+ ok(ev("Object.keys(library.characters).length") === n2 - 1, "delete removes a character");
266
+ ok(ev("state.id") !== delId, "active moves off the deleted character");
267
+
268
+ console.log("\n# v2 -> v3 migration");
269
+ {
270
+ const dom2 = new JSDOM(html, { runScripts:"dangerously", virtualConsole: new VirtualConsole(),
271
+ url:"https://migrate.example/", beforeParse(win){ win.scrollTo=()=>{}; win.confirm=()=>true;
272
+ win.localStorage.setItem("pf2eSpellbook.v2", JSON.stringify({classId:"oracle",name:"Old Seer",level:6,keyMod:4})); } });
273
+ const w2=dom2.window;
274
+ ok(w2.eval("Object.keys(library.characters).length") === 1, "v2 save migrates to one v3 character");
275
+ ok(w2.eval("state.classId") === "oracle" && w2.eval("state.name") === "Old Seer", "migrated character keeps class & name");
276
+ }
277
+
278
+ console.log("\n# MAGUS (arcane half-caster, Int)");
279
+ w.chooseClass("magus");
280
+ ok(w.activeTradition() === "arcane" && w.activeClass().keyAbility === "Intelligence", "magus = arcane, Int spell DC");
281
+ ok(w.activeClass().casting === "prepared", "magus is prepared");
282
+ const magusSlots = { 1:"{\"1\":1}", 4:"{\"1\":2,\"2\":2}", 5:"{\"2\":2,\"3\":2}", 17:"{\"8\":2,\"9\":2}", 20:"{\"8\":2,\"9\":2}" };
283
+ for (const [lvl, exp] of Object.entries(magusSlots)) {
284
+ ok(JSON.stringify(w.classSlots(+lvl)) === exp, `magus slots L${lvl} = ${exp} (got ${JSON.stringify(w.classSlots(+lvl))})`);
285
+ }
286
+ ev("state.level=8; state.keyMod=4;"); const dc8 = w.spellDC();
287
+ ev("state.level=9;"); const dc9 = w.spellDC();
288
+ ok(dc8 === 24 && dc9 === 27, `magus DC jumps at L9 expert (L8=${dc8}->24, L9=${dc9}->27)`);
289
+ ok(w.hasFocus() && w.classFocusSpells().length > 0, "magus has conflux focus spells");
290
+
291
+ console.log("\n# SUMMONER (spontaneous half-caster, eidolon tradition)");
292
+ w.chooseClass("summoner");
293
+ ok(w.isSpontaneous() && w.activeClass().keyAbility === "Charisma", "summoner = spontaneous, Cha");
294
+ w.setPatronTradition("primal");
295
+ ok(w.activeTradition() === "primal", "eidolon tradition picker sets tradition (primal)");
296
+ ok(JSON.stringify(w.classSlots(7)) === "{\"3\":2,\"4\":2}", "summoner shares the partial slot table");
297
+ ok(w.hasFocus() && w.classFocusSpells().length > 0, "summoner has evolution focus spells");
298
+ w.go("prepare");
299
+ ok(!!d.querySelector("#inPatron") && /Eidolon/.test(d.getElementById("charExtra").textContent), "summoner shows the Eidolon's-tradition picker");
300
+ // regression: a partial caster's shed low ranks must not render phantom "known" rows (repertoire == slots)
301
+ ev("state.level=7"); w.go("prepare");
302
+ const sumRepGroups = [...d.querySelectorAll("[data-reprank]")];
303
+ const sumRepRows = d.querySelectorAll(".repSel").length;
304
+ ok(sumRepGroups.length === 2, `summoner L7 repertoire shows only its 2 slotted ranks (got ${sumRepGroups.length})`);
305
+ ok(sumRepRows === 4, `summoner L7 repertoire rows == its 4 slots, no phantom low ranks (got ${sumRepRows})`);
306
+ ok(sumRepGroups.every(g => +g.dataset.reprank >= 3), "summoner L7 repertoire has no rank-1/2 phantom groups");
307
+
308
+ console.log("\n# PSYCHIC (occult spontaneous, 2 slots/rank, full prof)");
309
+ w.chooseClass("psychic");
310
+ ok(w.activeTradition() === "occult" && w.isSpontaneous(), "psychic = occult spontaneous");
311
+ const psySlots = { 1:"{\"1\":1}", 4:"{\"1\":2,\"2\":2}", 5:"{\"1\":2,\"2\":2,\"3\":1}" };
312
+ for (const [lvl, exp] of Object.entries(psySlots)) {
313
+ ok(JSON.stringify(w.classSlots(+lvl)) === exp, `psychic slots L${lvl} = ${exp} (got ${JSON.stringify(w.classSlots(+lvl))})`);
314
+ }
315
+ ok(w.classSlots(19)[10] === 1 && w.classSlots(20)[10] === 1, "psychic reaches 10th rank (1 slot) at L19-20");
316
+ ev("state.level=19; state.keyMod=5;");
317
+ ok(w.spellDC() === 42, `psychic L19 DC = 42 (legendary; got ${w.spellDC()})`);
318
+ ok(w.hasFocus() && w.classFocusSpells().some(s => s.slug === "imaginary-weapon"), "psychic surfaces psi cantrips in the focus section (e.g. Imaginary Weapon)");
319
+
320
+ console.log("\n# ANIMIST (divine prepared full caster + apparitions)");
321
+ ok(ev('CLASS_ORDER.includes("animist")') && !!ev('CLASSES.animist'), "animist registered in the class picker");
322
+ w.chooseClass("animist");
323
+ ok(!w.isSpontaneous() && w.activeClass().keyAbility === "Wisdom", "animist = prepared, Wisdom");
324
+ ok(w.activeTradition() === "divine", "animist casts the divine tradition");
325
+ ok(w.classSlots(1)[1] === 2 && w.classSlots(19)[10] === 1, "animist uses the full-caster slot table (2× rank 1 at L1; 10th rank at L19)");
326
+ ev("state.level=19; state.keyMod=5;");
327
+ ok(w.spellDC() === 42, `animist L19 DC = 42 (legendary full caster; got ${w.spellDC()})`);
328
+ ok(w.hasFocus() && w.classFocusSpells().length > 0, `animist surfaces vessel/apparition focus spells (${w.classFocusSpells().length})`);
329
+ ev("state.level=5"); w.go("prepare");
330
+ ok(d.querySelectorAll(".slotSel").length > 0 && d.querySelectorAll(".repSel").length === 0, "animist prepares into slots, not a repertoire");
331
+
332
+ console.log("\n# class picker now lists all 11");
333
+ ok(ev("CLASS_ORDER.length") === 11, "class picker offers all 11 classes");
334
+
335
+ console.log("\n# LEGACY / REMASTER compatibility");
336
+ // every spell has a legacy array; aliases resolve to real spells
337
+ ok(ev("SPELLS.every(s => Array.isArray(s.legacy))"), "every spell has a legacy-names array");
338
+ ok(ev("Object.keys(LEGACY_ALIASES).every(slug => !!SPELL_BY_SLUG[slug])"), "every legacy alias points at a real spell");
339
+ ok(w.findSpell("force-barrage").legacy.includes("Magic Missile"), "Force Barrage knows its legacy name (Magic Missile)");
340
+ ok(w.findSpell("holy-light").legacy.includes("Searing Light"), "Holy Light knows it was Searing Light");
341
+ // auto-extraction from curated descriptions, independent of the manual map
342
+ ok(JSON.stringify(w.extractLegacyNames("Foo bar. (Remaster of Sound Burst)")) === JSON.stringify(["Sound Burst"]),
343
+ "extracts a (Remaster of X) note from description text");
344
+ ok(JSON.stringify(w.extractLegacyNames("(Remaster of Faerie Fire/Glitterdust)")) === JSON.stringify(["Faerie Fire","Glitterdust"]),
345
+ "extracts and splits multiple legacy names");
346
+ // search by legacy name finds the remaster spell, per tradition
347
+ w.chooseClass("wizard"); w.go("browse"); w.setBrowseRank("all");
348
+ d.getElementById("search").value = "magic missile"; w.renderBrowse();
349
+ ok(/Force Barrage/.test(d.getElementById("browseList").innerHTML), "wizard: searching 'Magic Missile' finds Force Barrage");
350
+ ok(/formerly/.test(d.getElementById("browseList").innerHTML), "browse shows a 'formerly …' note");
351
+ w.chooseClass("cleric"); w.go("browse"); w.setBrowseRank("all");
352
+ d.getElementById("search").value = "searing light"; w.renderBrowse();
353
+ ok(/Holy Light/.test(d.getElementById("browseList").innerHTML), "cleric: searching 'Searing Light' finds Holy Light");
354
+ d.getElementById("search").value = "comprehend languages"; w.renderBrowse();
355
+ ok(/Translate/.test(d.getElementById("browseList").innerHTML), "searching legacy 'Comprehend Languages' finds Translate");
356
+
357
+ console.log("\n# action glyph rendering");
358
+ ok(w.actionLabel("2") === "◆◆ 2 actions", "single action glyph");
359
+ ok(w.actionLabel("1 to 3").startsWith("◆–◆◆◆"), "1-to-3 action range glyph");
360
+ ok(w.actionLabel("1 or 2").startsWith("◆–◆◆"), "'1 or 2' renders as a glyph range");
361
+ ok(w.actionLabel("reaction") === "⤳ Reaction", "reaction glyph");
362
+ ok(w.actionLabel("10 minutes") === "🕑 10 minutes", "long casts show a clock");
363
+
364
+ console.log("\n# in-app data integrity guard");
365
+ ok(ev("new Set(SPELLS.map(s=>s.slug)).size === SPELLS.length"), "all spell slugs unique (no findSpell collisions)");
366
+ ok(ev("SPELLS.filter(s=>!s.focus && !s.ritual && (!s.traditions||!s.traditions.length)).length === 0"), "no castable (non-focus, non-ritual) spell is orphaned from every tradition");
367
+ ok(ev("SPELLS.every(s=>typeof s.description==='string' && s.description.length>0)"), "every spell has a non-empty description");
368
+ ok(ev("SPELLS.every(s=>s.rank>=0 && s.rank<=10)"), "every spell rank is 0–10");
369
+ ["arcane","divine","occult","primal"].forEach(t=>{
370
+ ok(ev(`SPELLS.filter(s=>!s.focus && s.traditions.includes('${t}')).length > 100`), `${t} list is populated`);
371
+ });
372
+
373
+
374
+ console.log("\n# COMPUTED HEIGHTENING (#1)");
375
+ ok(w.addDice("6d6","2d6",2) === "10d6", "addDice scales dice count (6d6 +2x2d6 = 10d6)");
376
+ ok(w.addDice("1d8","1d8",2) === "3d8", "addDice heal scaling");
377
+ ok(w.addDice("1d6+3","1d6",2) === "3d6+3", "addDice preserves flat modifier");
378
+ ok(w.addDice("1d8","1d6",2) === null, "addDice refuses mixed die sizes");
379
+ {
380
+ const fb = w.findSpell("fireball");
381
+ const sd = w.scaleDamage(fb, 5);
382
+ ok(sd && sd[0].formula === "10d6" && sd[0].type === "fire", "Fireball at rank 5 -> 10d6 fire");
383
+ ok(w.scaleDamage(fb, 3)[0].formula === "6d6", "Fireball at base rank 3 -> 6d6");
384
+ ok(/10d6 fire/.test(w.damageChipHTML(fb, 5)), "damage chip shows scaled dice");
385
+ }
386
+ ok(w.damageChipHTML(w.findSpell("bless"), 5) === "", "non-damage spell shows no damage chip");
387
+ w.chooseClass("cleric"); ev("state.level=3"); w.go("prepare");
388
+ [...d.querySelectorAll(".slotSel")].forEach(sel=>{ if(sel.dataset.rank==="2"){ sel.value="heal"; } else { const o=[...sel.options].find(o=>o.value); if(o) sel.value=o.value; } });
389
+ [...d.querySelectorAll(".cantripSel")].forEach(s=>{ const o=[...s.options].find(o=>o.value); if(o) s.value=o.value; });
390
+ w.savePrep();
391
+ ok(/✚ 2d8/.test(d.getElementById("todayContent").innerHTML), "Heal slotted at rank 2 shows ✚ 2d8 on Cast Today");
392
+
393
+
394
+ console.log("\n# SUSTAINED TRACKER (#2)");
395
+ w.chooseClass("cleric"); ev("state.level=5");
396
+ const sus = w.classSpells().find(s=>s.sustained && s.rank>=1 && s.rank<=2);
397
+ ok(!!sus, "found a sustained spell to test (" + (sus && sus.name) + ")");
398
+ w.go("prepare");
399
+ [...d.querySelectorAll(".slotSel")].forEach(sel=>{ const r=+sel.dataset.rank; if(r===sus.rank){ sel.value=sus.slug; } else { const o=[...sel.options].find(o=>o.value); if(o) sel.value=o.value; } });
400
+ [...d.querySelectorAll(".cantripSel")].forEach(x=>{ const o=[...x.options].find(o=>o.value); if(o) x.value=o.value; });
401
+ w.savePrep();
402
+ ok(/⏳ Sustain/.test(d.getElementById("todayContent").innerHTML), "sustained spell shows a Sustain button on Cast Today");
403
+ w.startSustain(sus.slug);
404
+ ok((ev("state.sustaining")||[]).includes(sus.slug), "startSustain adds the spell to the tracker");
405
+ ok(/Sustaining now/.test(d.getElementById("todayContent").innerHTML), "Sustaining-now bar appears");
406
+ w.endSustain(sus.slug);
407
+ ok(!(ev("state.sustaining")||[]).includes(sus.slug), "endSustain removes it");
408
+ w.startSustain(sus.slug); w.newDay();
409
+ ok((ev("state.sustaining")||[]).length === 0, "new day clears the sustained list");
410
+
411
+
412
+ console.log("\n# BROWSE FILTERS (#3)");
413
+ w.chooseClass("wizard"); w.go("browse"); w.setBrowseRank("all");
414
+ d.getElementById("search").value = "";
415
+ w.toggleBrowseFilters();
416
+ ok(!d.getElementById("browseFilters").classList.contains("hide"), "filters panel opens");
417
+ const browseCount = () => (d.getElementById("browseList").innerHTML.match(/browse-row rank/g) || []).length;
418
+ w.setBrowseSave("all"); w.setBrowseAction("all"); w.setBrowseTrait("");
419
+ const allN = browseCount();
420
+ w.setBrowseSave("will");
421
+ const willN = browseCount();
422
+ const expWill = w.classSpells().filter(s=>s.save && s.save.toLowerCase().includes("will")).length;
423
+ ok(willN > 0 && willN < allN, `Will-save filter narrows results (${willN} of ${allN})`);
424
+ ok(willN === expWill, `Will filter exact (${willN} === ${expWill})`);
425
+ w.setBrowseSave("none");
426
+ ok(browseCount() === w.classSpells().filter(s=>!s.save).length, "No-save filter exact");
427
+ w.setBrowseSave("all"); w.setBrowseAction("reaction");
428
+ const expReact = w.classSpells().filter(s=>w.actionMatch(s.actions,"reaction")).length;
429
+ ok(browseCount() === expReact && expReact > 0, "reaction filter matches reactions");
430
+ w.setBrowseAction("all"); w.setBrowseTrait("fire");
431
+ const fireN = browseCount();
432
+ ok(fireN > 0 && fireN < allN && fireN === w.classSpells().filter(s=>s.traits.some(t=>t.includes("fire"))).length, "trait filter 'fire' exact");
433
+ w.setBrowseTrait("");
434
+ ok(w.actionMatch("1 to 3","2") === true && w.actionMatch("1","2") === false, "actionMatch handles action ranges");
435
+
436
+
437
+ console.log("\n# CLERIC DOCTRINE (#9)");
438
+ w.chooseClass("cleric"); ev("state.level=9; state.keyMod=4;");
439
+ ev("state.doctrine='cloistered'"); const dcCl=w.spellDC();
440
+ ev("state.doctrine='warpriest'"); const dcWar=w.spellDC();
441
+ ok(dcCl===27 && dcWar===25, `doctrine changes L9 DC (cloistered ${dcCl}/27, warpriest ${dcWar}/25)`);
442
+ w.go("prepare");
443
+ ok(!!d.getElementById("inDoctrine"), "doctrine selector shown for cleric");
444
+ w.chooseClass("wizard"); w.go("prepare");
445
+ ok(!d.getElementById("inDoctrine"), "no doctrine selector for non-cleric");
446
+
447
+ console.log("\n# DATA STAMP (#5)");
448
+ ok(ev("typeof GENERATED_META")==="object" && ev("GENERATED_META.count")>1700, "GENERATED_META present with count");
449
+ ok(/Spell data \d{4}-\d\d-\d\d/.test(w.dataStampText()), "data stamp text is formatted");
450
+ w.openMenu();
451
+ ok(/Spell data/.test(d.getElementById("dataStamp").textContent), "menu shows the data stamp");
452
+
453
+ console.log("\n# ZOOM (#6)");
454
+ ok(!/user-scalable=no/.test(html) && !/maximum-scale/.test(html), "viewport allows pinch-zoom");
455
+
456
+ console.log("\n# A11Y + STORAGE (#7)");
457
+ w.chooseClass("sorcerer"); w.go("prepare");
458
+ const pd = d.getElementById("prepDynamic").innerHTML;
459
+ ok(/aria-label="toggle signature spell"/.test(pd), "signature buttons have aria-labels");
460
+ ok(/aria-label="remove spell"/.test(pd), "remove buttons have aria-labels");
461
+ ok(d.querySelector(".menubtn").getAttribute("aria-label")?.length>0, "header menu button has an aria-label");
462
+ {
463
+ const proto=Object.getPrototypeOf(w.localStorage); const orig=proto.setItem; let patched=true;
464
+ try{ proto.setItem=function(){ throw new Error("quota"); }; }catch(e){ patched=false; }
465
+ if(patched){ let threw=false; try{ w.saveState(); }catch(e){ threw=true; } proto.setItem=orig; ok(!threw,"saveState swallows storage errors (no crash when full)"); }
466
+ else ok(true,"storage patch unavailable (skipped)");
467
+ }
468
+
469
+
470
+ console.log("\n# FOCUS CHECKLIST + FILTER (#8)");
471
+ w.chooseClass("cleric"); ev("state.level=5"); w.go("prepare");
472
+ const focusTotal = d.querySelectorAll("#focusChecklist .fcheck").length;
473
+ ok(focusTotal > 50, `cleric focus checklist shows many domain spells (${focusTotal})`);
474
+ w.filterFocusChecklist("death");
475
+ const focusShown = [...d.querySelectorAll("#focusChecklist .fcheck")].filter(el=>!el.classList.contains("hide")).length;
476
+ ok(focusShown > 0 && focusShown < focusTotal, `filtering 'death' narrows the checklist (${focusShown} of ${focusTotal})`);
477
+ w.filterFocusChecklist("");
478
+ ok([...d.querySelectorAll("#focusChecklist .fcheck")].every(el=>!el.classList.contains("hide")), "clearing the filter shows all again");
479
+
480
+
481
+ console.log("\n# RITUALS BROWSER + INSTALL (#10)");
482
+ w.chooseClass("wizard"); w.go("browse");
483
+ ok(/📜 Rituals/.test(d.getElementById("rankChips").innerHTML), "Rituals chip present in Browse");
484
+ w.setBrowseRank("rituals");
485
+ d.getElementById("search").value=""; w.setBrowseSave("all"); w.setBrowseAction("all"); w.setBrowseTrait(""); w.renderBrowse();
486
+ ok(d.getElementById("browseTitle").textContent.includes("Ritual"), "browse titled Rituals");
487
+ const ritN=(d.getElementById("browseList").innerHTML.match(/browse-row rank/g)||[]).length;
488
+ ok(ritN>100, `rituals listed (${ritN})`);
489
+ {
490
+ const m=d.getElementById("browseList").innerHTML.match(/toggleBrowse\('([^']+)'/);
491
+ const slug=m[1]; w.toggleBrowse(slug, d.querySelector(".browse-row-head"));
492
+ ok(/ritualtag/.test(d.getElementById("bc_"+slug).innerHTML), "expanded ritual shows a Ritual tag");
493
+ }
494
+ w.setBrowseRank("all"); w.renderBrowse();
495
+ ok(!ev("classSpells().some(s=>s.ritual)"), "rituals excluded from normal class spell lists");
496
+
497
+ console.log("\n# PWA / OFFLINE (#18/#19)");
498
+ ok(typeof w.setupInstall === "function", "install setup function present");
499
+ ok(typeof w.downloadOffline === "function", "downloadOffline() present");
500
+ ok(typeof w.installApp === "function", "installApp() present");
501
+ ok(/rel="manifest"/.test(html), "manifest link in head (hosted PWA)");
502
+ ok(/rel="apple-touch-icon"/.test(html), "apple-touch-icon link in head");
503
+ // menu offline section + controls
504
+ w.openMenu();
505
+ ok(/Save offline copy/.test(d.getElementById("view-menu").innerHTML), "menu has a Save-offline button");
506
+ ok(!!d.getElementById("installBtn"), "install button present in menu");
507
+ ok(d.getElementById("installBtn").style.display === "none", "install button hidden until installable");
508
+ ok(/on this device only/i.test(d.getElementById("view-menu").innerHTML), "menu reassures data stays on-device");
509
+ // downloadOffline builds a Blob anchor and clicks without throwing (DOM-only path)
510
+ {
511
+ let clicked = false, downloadName = "";
512
+ const realCreate = d.createElement.bind(d);
513
+ d.createElement = (tag) => {
514
+ const el = realCreate(tag);
515
+ if (tag === "a") { const c = el.click.bind(el); el.click = () => { clicked = true; downloadName = el.download; c && 0; }; }
516
+ return el;
517
+ };
518
+ if (!w.URL.createObjectURL) w.URL.createObjectURL = () => "blob:test";
519
+ if (!w.URL.revokeObjectURL) w.URL.revokeObjectURL = () => {};
520
+ const realFetch = w.fetch; w.fetch = undefined; // force the DOM-serialise fallback
521
+ w.downloadOffline();
522
+ w.fetch = realFetch; d.createElement = realCreate;
523
+ ok(clicked, "downloadOffline() triggers a file download");
524
+ ok(downloadName === "pf2e-spellbook.html", "download named pf2e-spellbook.html");
525
+ }
526
+ // installApp with no captured prompt falls back to an instructional alert (no throw)
527
+ {
528
+ const realAlert = w.alert; let alerted = false;
529
+ w.alert = () => { alerted = true; };
530
+ w.installApp();
531
+ w.alert = realAlert;
532
+ ok(alerted, "installApp() explains manual install when no prompt is available");
533
+ }
534
+
535
+ console.log(`\n# RESULT: ${pass} passed, ${fail} failed`);
536
+ process.exit(fail ? 1 : 0);