Add Aniimo guides and link game cards to toolbox checklists
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s
This commit is contained in:
parent
f0797ccf14
commit
13434f72f6
278 changed files with 10025 additions and 57 deletions
114
tests/aniimo.test.mjs
Normal file
114
tests/aniimo.test.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Rôle : vérifie les données Aniimo et le filtrage des variantes avant regroupement.
|
||||
import { readFile, access } from "node:fs/promises";
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createAniimoFilters, filterAniimoVariants, groupAniimoVariants, prepareAniimoData } from "../website/src/features/games/aniimo/utils.js";
|
||||
import { loadAniimoData } from "../website/src/features/games/loaders.js";
|
||||
import { validateAniimoData } from "./helpers/data-validation.mjs";
|
||||
|
||||
const readJson = async (path) => JSON.parse(await readFile(`website/public/data/${path}`, "utf8"));
|
||||
const [aniimos, fr, en] = await Promise.all([readJson("aniimo/aniimos.json"), readJson("aniimo/i18n/fr.json"), readJson("aniimo/i18n/en.json")]);
|
||||
const data = prepareAniimoData(aniimos, fr, en);
|
||||
|
||||
test("Aniimo attributes have valid stats and matching totals", () => {
|
||||
validateAniimoData(aniimos, [fr, en]);
|
||||
const invalid = structuredClone(aniimos[0]);
|
||||
invalid.variations[0].attributes.total++;
|
||||
assert.throws(() => validateAniimoData([invalid], [fr, en]), /Stats total/);
|
||||
invalid.variations[0].attributes.stats.hp = -1;
|
||||
assert.throws(() => validateAniimoData([invalid], [fr, en]), /nonnegative integers/);
|
||||
});
|
||||
const filter = (overrides, variants = data.variants) => filterAniimoVariants(variants, { ...createAniimoFilters(), ...overrides });
|
||||
|
||||
test("Aniimo data has valid variants, translations, portraits and icons", async () => {
|
||||
validateAniimoData(aniimos, [fr, en]);
|
||||
const game = (await readJson("games.json")).games.find((game) => game.id === "aniimo");
|
||||
assert.ok(game);
|
||||
assert.equal(game.images.aniimosCover, "/static/img/aniimo/emberpup__basic_form.png");
|
||||
const assets = new Set([game.images.cardCover, game.images.heroBg, game.images.aniimosCover, ...data.variants.map((variant) => variant.image)]);
|
||||
for (const option of Object.values(data.filterOptions).flat()) assets.add(`/static/img/aniimo/${option}.png`);
|
||||
await Promise.all([...assets].map((asset) => access(`website/public${asset}`)));
|
||||
for (const variant of data.variants) {
|
||||
const png = await readFile(`website/public${variant.image}`);
|
||||
assert.equal(png.readUInt32BE(16), 512);
|
||||
assert.equal(png.readUInt32BE(20), 840);
|
||||
}
|
||||
});
|
||||
|
||||
test("validation rejects duplicate forms, missing translations and invalid levels", () => {
|
||||
const entry = structuredClone(aniimos[0]);
|
||||
entry.variations.push(entry.variations[0]);
|
||||
assert.throws(() => validateAniimoData([entry], [fr, en]), /Duplicate form/);
|
||||
assert.throws(() => validateAniimoData(aniimos, [{}]), /must be a string/);
|
||||
for (const level of [0, 5, 1.5, "3"]) {
|
||||
const invalid = structuredClone(aniimos[0]);
|
||||
invalid.variations[0].homeland_abilities[0].level = level;
|
||||
assert.throws(() => validateAniimoData([invalid], [fr, en]), /integer between 1 and 4/);
|
||||
}
|
||||
});
|
||||
|
||||
test("name searches accept French, English, accents and case", () => {
|
||||
assert.deepEqual(filter({ name: " LOUFEUTEAU " }).map((v) => v.key), filter({ name: "Emberpup" }).map((v) => v.key));
|
||||
assert.ok(filter({ name: "celestia" }).some((variant) => variant.name === "celestis"));
|
||||
assert.ok(filter({ name: "pomoeuf" }).some((variant) => variant.name === "pomegg"));
|
||||
assert.deepEqual(filter({ name: "inexistant-xyz" }), []);
|
||||
});
|
||||
|
||||
test("Prismana remains the final variation in data and grouped results", () => {
|
||||
for (const group of groupAniimoVariants(data.variants)) {
|
||||
if (group.some((variant) => variant.form === "prismana_form")) assert.equal(group.at(-1).form, "prismana_form");
|
||||
}
|
||||
const entry = structuredClone(aniimos.find((aniimo) => aniimo.variations.length > 1 && aniimo.variations.at(-1).form === "prismana_form"));
|
||||
entry.variations.unshift(entry.variations.pop());
|
||||
assert.throws(() => validateAniimoData([entry], [fr, en]), /Prismana must be last/);
|
||||
});
|
||||
|
||||
const fixture = prepareAniimoData([{
|
||||
id: "001", name: "example", elements: ["rock", "water"], role: "support",
|
||||
variations: [
|
||||
{ form: "rock_form", elements: ["rock"], role: "dps", homeland_abilities: [{ type: "carry", level: 3 }] },
|
||||
{ form: "water_form", elements: ["water"], role: "heal", homeland_abilities: [{ type: "crafting", level: 2 }] },
|
||||
{ form: "both_form", elements: ["rock", "water"], role: "dps", homeland_abilities: [{ type: "carry", level: 4 }, { type: "crafting", level: 1 }] }
|
||||
]
|
||||
}], {}, {}).variants;
|
||||
|
||||
test("element AND never borrows elements or roles from the parent or sibling variants", () => {
|
||||
assert.deepEqual(filter({ elements: ["rock", "water"] }, fixture).map((v) => v.form), ["both_form"]);
|
||||
assert.equal(filter({ elements: ["rock", "water"], elementsLogic: "or" }, fixture).length, 3);
|
||||
assert.equal(filter({ roles: ["support"] }, fixture).length, 0);
|
||||
assert.equal(filter({ roles: ["dps", "heal"] }, fixture).length, 3);
|
||||
});
|
||||
|
||||
test("ability thresholds remain independent, inclusive and constrained to a single variant", () => {
|
||||
assert.equal(filter({ abilities: { carry: 3, crafting: 2 } }, fixture).length, 0);
|
||||
assert.equal(filter({ abilities: { carry: 3, crafting: 2 }, abilitiesLogic: "or" }, fixture).length, 3);
|
||||
assert.deepEqual(filter({ abilities: { carry: 4, crafting: 2 }, abilitiesLogic: "or" }, fixture).map((v) => v.form), ["water_form", "both_form"]);
|
||||
assert.deepEqual(filter({ abilities: { carry: 3, crafting: 1 } }, fixture).map((v) => v.form), ["both_form"]);
|
||||
assert.equal(filter({ elements: ["rock"], roles: ["heal"], abilitiesLogic: "or", abilities: { carry: 1, crafting: 1 } }, fixture).length, 0);
|
||||
});
|
||||
|
||||
test("grouping preserves order and contains only variants that pass all filters", () => {
|
||||
const visible = filter({ elements: ["water"] }, fixture);
|
||||
const groups = groupAniimoVariants(visible);
|
||||
assert.equal(groups.length, 1);
|
||||
assert.deepEqual(groups[0].map((v) => v.form), ["water_form", "both_form"]);
|
||||
assert.deepEqual(groupAniimoVariants([]), []);
|
||||
assert.equal(groupAniimoVariants(data.variants).length, aniimos.length);
|
||||
});
|
||||
|
||||
test("loader passes cancellation to all requests and reports failed responses", async (context) => {
|
||||
const calls = [];
|
||||
const controller = new AbortController();
|
||||
const bodies = [aniimos, fr, en];
|
||||
context.mock.method(globalThis, "fetch", async (url, options) => {
|
||||
calls.push({ url, options });
|
||||
const body = bodies[calls.length - 1];
|
||||
return { ok: true, json: async () => body };
|
||||
});
|
||||
const result = await loadAniimoData(controller.signal);
|
||||
assert.equal(result.variants.length, data.variants.length);
|
||||
assert.equal(calls.length, 3);
|
||||
assert.ok(calls.every((call) => call.options.signal === controller.signal));
|
||||
globalThis.fetch.mock.mockImplementation(async () => ({ ok: false }));
|
||||
await assert.rejects(loadAniimoData(controller.signal), /indisponibles/);
|
||||
});
|
||||
54
tests/guide-checklist.test.mjs
Normal file
54
tests/guide-checklist.test.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Rôle : vérifie les correspondances des guides et la validité des associations de session.
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { aniimoChecklistEntries, matchGuideChecklist, normalizeGuideLabel, validGuideAssociations } from "../website/src/features/games/guideChecklist.js";
|
||||
|
||||
const variants = [{ key: "1:base", name: "emberpup", form: "base" }, { key: "1:mountain", name: "emberpup", form: "mountain" }];
|
||||
const entries = aniimoChecklistEntries(variants, { emberpup: "Loufeuteau", base: "Forme de base", mountain: "Montagnes" }, { emberpup: "Emberpup", base: "Basic form", mountain: "Mountain form" });
|
||||
|
||||
test("full names normalize accents, case and whitespace without approximate matching", () => {
|
||||
assert.equal(normalizeGuideLabel(" ŒUF Écarlate \n"), "oeuf ecarlate");
|
||||
const result = matchGuideChecklist([{ key: "egg", labels: ["Œuf écarlate"] }], { items: [
|
||||
{ label: "OEUF ECARLATE", qtyCurrent: 1 }, { label: "Œuf", qtyCurrent: 0 }
|
||||
] });
|
||||
assert.equal(result.count, 1);
|
||||
assert.ok(result.completed.has("egg"));
|
||||
assert.equal(matchGuideChecklist(entries, { items: [{ label: "Loufeu", qtyCurrent: 1 }] }).count, 0);
|
||||
});
|
||||
|
||||
test("species completion covers forms, but a specific French or English row takes priority", () => {
|
||||
const data = { sections: [{ items: [
|
||||
{ label: "EMBERPUP", qtyCurrent: 1 },
|
||||
{ label: "Loufeuteau — forme de base", qtyCurrent: 0 }
|
||||
] }] };
|
||||
const result = matchGuideChecklist(entries, data);
|
||||
assert.equal(result.count, 2);
|
||||
assert.deepEqual([...result.completed], ["1:mountain"]);
|
||||
const english = matchGuideChecklist(entries, { items: [{ label: "Emberpup — Basic form", qtyCurrent: 1 }] });
|
||||
assert.equal(english.count, 1);
|
||||
assert.deepEqual([...english.completed], ["1:base"]);
|
||||
});
|
||||
|
||||
test("all duplicate lines must reach their own target, including translated aliases", () => {
|
||||
const data = { items: [
|
||||
{ label: "Loufeuteau", qtyTarget: 3, qtyCurrent: 2 },
|
||||
{ label: "Emberpup", qtyCurrent: 1 },
|
||||
{ label: "Unrelated", qtyCurrent: 1 }
|
||||
] };
|
||||
assert.equal(matchGuideChecklist(entries, data).completed.size, 0);
|
||||
data.items[0].qtyCurrent = 3;
|
||||
assert.equal(matchGuideChecklist(entries, data).completed.size, 2);
|
||||
data.items.push({ label: "Loufeuteau" });
|
||||
assert.equal(matchGuideChecklist(entries, data).completed.size, 0);
|
||||
assert.equal(matchGuideChecklist(entries, {}).count, 0);
|
||||
});
|
||||
|
||||
test("session associations require the current linked toolbox and an existing checklist", () => {
|
||||
const toolbox = { id: "tb", modules: [{ id: "capture", type: "checklist" }, { id: "notes", type: "notepad" }] };
|
||||
const association = { toolboxId: "tb", moduleId: "capture", hideCompleted: true };
|
||||
const state = { "aniimo/aniimos": association, "mhwilds/monsters": { ...association, moduleId: "notes" }, unknown: association };
|
||||
assert.deepEqual(validGuideAssociations(state, [toolbox], { aniimo: "tb", mhwilds: "tb" }), { "aniimo/aniimos": association });
|
||||
assert.deepEqual(validGuideAssociations(state, [toolbox], { aniimo: "other" }), {});
|
||||
assert.deepEqual(validGuideAssociations(state, [], { aniimo: "tb" }), {});
|
||||
assert.deepEqual(validGuideAssociations({ "aniimo/aniimos": null }, [toolbox], { aniimo: "tb" }), {});
|
||||
});
|
||||
|
|
@ -5,6 +5,48 @@ const SNAKE_CASE_RE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/;
|
|||
const PHYSICAL_HITZONE_KEYS = ["cut", "blunt", "ammo"];
|
||||
const ELEMENTAL_HITZONE_KEYS = ["fire", "water", "thunder", "ice", "dragon"];
|
||||
|
||||
export function validateAniimoData(aniimos, dictionaries) {
|
||||
assert.ok(Array.isArray(aniimos) && aniimos.length > 0, "Aniimo data must contain entries");
|
||||
const ids = new Set();
|
||||
const names = new Set();
|
||||
function translated(key) {
|
||||
assert.match(key, SNAKE_CASE_RE);
|
||||
for (const dictionary of dictionaries) assertNonEmptyString(dictionary, key);
|
||||
}
|
||||
for (const aniimo of aniimos) {
|
||||
assert.match(aniimo.id, /^\d+$/);
|
||||
assert.ok(!ids.has(aniimo.id), `Duplicate Aniimo id: ${aniimo.id}`);
|
||||
assert.ok(!names.has(aniimo.name), `Duplicate Aniimo name: ${aniimo.name}`);
|
||||
ids.add(aniimo.id);
|
||||
names.add(aniimo.name);
|
||||
translated(aniimo.name);
|
||||
assert.ok(Array.isArray(aniimo.variations) && aniimo.variations.length > 0);
|
||||
const forms = new Set();
|
||||
for (const variation of aniimo.variations) {
|
||||
const stats = variation.attributes?.stats;
|
||||
assert.deepEqual(Object.keys(stats || {}).sort(), ["atk", "break", "hp", "m_def", "p_def", "regen"], "Aniimo must have six stats");
|
||||
assert.ok(Object.values(stats).every((value) => Number.isInteger(value) && value >= 0), "Stats must be nonnegative integers");
|
||||
assert.equal(variation.attributes.total, Object.values(stats).reduce((sum, value) => sum + value, 0), "Stats total must equal their sum");
|
||||
assert.ok(!forms.has(variation.form), `Duplicate form: ${aniimo.name}/${variation.form}`);
|
||||
forms.add(variation.form);
|
||||
translated(variation.form);
|
||||
translated(variation.role);
|
||||
assertStringArray(variation, "elements");
|
||||
assert.equal(new Set(variation.elements).size, variation.elements.length);
|
||||
variation.elements.forEach(translated);
|
||||
assert.ok(Array.isArray(variation.homeland_abilities));
|
||||
const types = new Set();
|
||||
for (const ability of variation.homeland_abilities) {
|
||||
translated(ability.type);
|
||||
assert.ok(!types.has(ability.type), `Duplicate ability: ${ability.type}`);
|
||||
types.add(ability.type);
|
||||
assert.ok(Number.isInteger(ability.level) && ability.level >= 1 && ability.level <= 4, "Ability level must be an integer between 1 and 4");
|
||||
}
|
||||
}
|
||||
if (forms.has("prismana_form")) assert.equal(aniimo.variations.at(-1).form, "prismana_form", `Prismana must be last: ${aniimo.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function valueAt(object, path) {
|
||||
return path.split(".").reduce((value, key) => value?.[key], object);
|
||||
}
|
||||
|
|
@ -47,6 +89,53 @@ export function validateSiteContent(site) {
|
|||
"gamesPage.description",
|
||||
"gamesPage.emptyTitle",
|
||||
"gamesPage.emptyText",
|
||||
"guideChecklist.label",
|
||||
"guideChecklist.untitled",
|
||||
"guideChecklist.none",
|
||||
"guideChecklist.linkToolbox",
|
||||
"guideChecklist.noCompatible",
|
||||
"guideChecklist.noMatches",
|
||||
"guideChecklist.matches",
|
||||
"guideChecklist.unlink",
|
||||
"guideChecklist.hideCompleted",
|
||||
"guideChecklist.completed",
|
||||
"aniimo.copyChecklist",
|
||||
"aniimo.copyVariantsChecklist",
|
||||
"aniimo.statsTitle",
|
||||
"aniimo.statsTotal",
|
||||
"aniimo.stats.hp",
|
||||
"aniimo.stats.atk",
|
||||
"aniimo.stats.p_def",
|
||||
"aniimo.stats.m_def",
|
||||
"aniimo.stats.break",
|
||||
"aniimo.stats.regen",
|
||||
"aniimo.title",
|
||||
"aniimo.description",
|
||||
"aniimo.homeDescription",
|
||||
"aniimo.filters",
|
||||
"aniimo.name",
|
||||
"aniimo.searchPlaceholder",
|
||||
"aniimo.elements",
|
||||
"aniimo.roles",
|
||||
"aniimo.role",
|
||||
"aniimo.abilities",
|
||||
"aniimo.minimum",
|
||||
"aniimo.abilityLevelsHint",
|
||||
"aniimo.abilityDisabled",
|
||||
"aniimo.level",
|
||||
"aniimo.match",
|
||||
"aniimo.and",
|
||||
"aniimo.or",
|
||||
"aniimo.reset",
|
||||
"aniimo.groupVariants",
|
||||
"aniimo.variants",
|
||||
"aniimo.results",
|
||||
"aniimo.emptyTitle",
|
||||
"aniimo.emptyText",
|
||||
"aniimo.loading",
|
||||
"aniimo.error",
|
||||
"aniimo.showFront",
|
||||
"aniimo.showBack",
|
||||
"library.eyebrow",
|
||||
"library.title",
|
||||
"library.description",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue