Add Aniimo guides and link game cards to toolbox checklists
Some checks failed
Deploy Sokko G / deploy (push) Failing after 3s

This commit is contained in:
Shinuwa 2026-09-23 20:05:19 +02:00
parent f0797ccf14
commit 13434f72f6
278 changed files with 10025 additions and 57 deletions

View file

@ -69,7 +69,7 @@ Le Nebula Design System expose ses primitives publiques avec une classe de famil
Règles :
- la classe décrit uniquement la famille : `nds-button`, `nds-icon-button`, `nds-input`, `nds-select`, `nds-select-trigger`, `nds-time`, `nds-textarea`, `nds-inline-input`, `nds-switch`, `nds-radio-button-group`, `nds-checkbox`, `nds-quantity-stepper`, `nds-list-row`, `nds-large-icon-picker-trigger`, `nds-event-surface`, `nds-scrollbox` ;
- la classe décrit uniquement la famille : `nds-button`, `nds-icon-button`, `nds-input`, `nds-range`, `nds-select`, `nds-select-trigger`, `nds-time`, `nds-textarea`, `nds-inline-input`, `nds-switch`, `nds-radio-button-group`, `nds-checkbox`, `nds-quantity-stepper`, `nds-list-row`, `nds-large-icon-picker-trigger`, `nds-event-surface`, `nds-scrollbox` ;
- les patterns transverses utilisent aussi une classe de famille : `nds-panel`, `nds-card`, `nds-card-cover`, `nds-card-body`, `nds-card-actions`, `nds-tool-panel`, `nds-toolbar`, `nds-section-heading`, `nds-page-hero`, `nds-badge`, `nds-chip`, `nds-empty-state`, `nds-inline-notice`, `nds-progress`, `nds-filter-panel`, `nds-reorder-indicator`, `nds-modal`, `nds-modal-backdrop`, `nds-modal-footer`, `nds-drawer-panel`, `nds-drawer-resize-handle`, `nds-popover`, `nds-toast-stack`, `nds-toast` ;
- les variations passent par attributs : `data-size`, `data-variant`, `data-edge`, `data-border`, `data-axis` ;
- la page `/design-system` affiche aussi `data-state` comme axe de comparaison visuelle des états ;
@ -108,7 +108,7 @@ Les surfaces NDS couvrent les patterns transverses, pas les composants metier co
- `nds-filter-panel` : panneau sticky ou lateral de filtres ; les options utilisent `nds-chip`.
- `nds-empty-state` : etat vide court, avec action optionnelle.
- `nds-inline-notice` : feedback compact inline, avec `data-variant="info|success|warning|danger"`.
- `nds-progress` : rail de progression ; le remplissage est le premier `span` enfant ou `.nds-progress-bar`.
- `nds-progress` : rail de progression (taille `md`/défaut : 8 px, `sm` : 4 px, via `data-size`) ; le remplissage est le premier `span` enfant ou `.nds-progress-bar`.
- `nds-modal`, `nds-drawer-panel`, `nds-popover`, `nds-toast` : surfaces d'overlay. Le comportement React reste gere par les composants existants.
Les classes metier existantes peuvent coexister avec ces classes pendant la migration. Quand une classe metier reste necessaire, elle doit porter le layout, la grille ou le positionnement, pas recreer les fonds, bordures, hovers et focus partages.
@ -784,6 +784,52 @@ Date picker et menus compacts :
- les couleurs de fond des events restent métier et ne sont pas des variants NDS ;
- les fichiers d'outils peuvent conserver les dimensions de grille, le placement du menu et les états métier `disabled`.
### Curseurs de niveau
- `input[type="range"].nds-range` expose un curseur natif réutilisable, avec `min`, `max`, `step`, `value` et `disabled` standards ;
- tailles : `md` (44 px, poignée 20 px) et `sm` (36 px, poignée 18 px) ;
- associer un label explicite et une valeur visible ; conserver les interactions clavier natives ;
- rail violet, poignée bordée de doré, hover et focus issus des tokens NDS ;
- la page Aniimo affiche tous les curseurs avec `min="0"`, `max="4"`, `step="1"` : 0 retire la capacité des critères, 1–4 fixe son niveau minimum indépendant ;
- les règles de filtrage restent dans le composant métier ; le catalogue `/design-system` montre un exemple interactif et l'état désactivé.
### Cartes Aniimo et variantes
- la carte d'entrée du guide utilise `games.json > images.aniimosCover` : le portrait d’Emberpup, recadré sur sa tête dans la couverture commune de 120 px ; des marges internes latérales de 15 % dézooment le portrait sans modifier le cadre ; une description courte conserve le gabarit des autres guides ;
- MHW et Aniimo partagent les mixins `flip-card-inner`, `flip-card-indicator`, `flip-card-indicator-hover` et `flip-card-face-hover` de `_surfaces.scss` : rotation de 420 ms, surfaces et indicateurs identiques ;
- les contrôles de retournement Aniimo sont dans chaque face et tournent avec elle ; le focus clavier rejoint le contrôle de la nouvelle face ;
- afficher les portraits dans un cadre compact 3 / 4 plafonné à 250 px de hauteur, avec `object-fit: cover` et cadrage centré ; les fichiers source restent inchangés ;
- les deux variantes de Petit Esprit de Feu utilisent `object-position: center 20%` pour descendre légèrement le personnage dans son cadre ;
- la grille utilise un minimum de 220 px pour afficher cinq colonnes à 1920 px, puis réduit le nombre de colonnes sur les petits écrans ; limiter les cartes à 280 px de largeur ; le recto fixe leur hauteur avec le portrait et un pied de 96 px, indépendamment des noms et des variantes ;
- le nom de forme revient à la ligne au recto, dans le pied fixe de 96 px ;
- le recto affiche uniquement les icônes d'éléments, centrées dans le pied de carte, avec texte alternatif et infobulle ;
- les onglets affichent des initiales ou une abréviation de trois lettres pour les formes à un seul mot (PLA, PRA, PRI) ; le nom complet reste accessible via `Tooltip`, au survol et au focus ;
- placer les onglets au-dessus du contrôle de retournement, avec navigation clavier ; une grille de colonnes égales affiche les sept formes sur une ligne sans couper les libellés ;
- la forme Prismana est la dernière entrée des variantes dans le JSON ; son onglet reçoit un léger fond et un soulignement arc-en-ciel statiques, sans masquer la sélection ou le focus ;
- conserver la face affichée lorsqu'une autre variante est sélectionnée ;
- le verso répartit les éléments et le rôle sur deux colonnes séparées par un trait vertical très discret, centré dans un espacement de 20 px (10 px de chaque côté), avant les compétences ;
- les badges d'élément et de rôle restent sur une ligne dans leur colonne : icône fixe, padding compact et points de suspension sur les libellés trop longs, avec texte complet au survol ;
- espacer de 24 px les sections Éléments/Rôle, Compétences de logis et Statistiques au verso ;
- le verso ajoute les six statistiques et leur total sous les compétences : barres `nds-progress[data-size="sm"]`, valeur numérique alignée à droite, échelle commune égale au maximum du catalogue complet (130 actuellement), indépendante des filtres ;
- le compteur met en évidence les Aniimos ou les variantes selon le mode de copie ; l’infobulle du bouton précise le contenu copié ;
- le verso occupe exactement la même surface que le recto ; garder son en-tête fixe et faire défiler les détails dans une zone NDS accessible au clavier ; masquer la face inactive aux technologies d'assistance ;
- réduire l'animation selon `prefers-reduced-motion` ;
- les filtres se cumulent par famille et sont évalués sur chaque variante avant regroupement ;
- les catégories Rôles, Éléments et Compétences de logis partagent un défilement unique via `filter-options`, comme MHW ; des séparateurs discrets délimitent les catégories et leurs en-têtes permettent de les replier sans désactiver leurs sélections ;
- rôles et éléments sont sur deux colonnes de chips `sm` ; les compétences élémentaires précèdent les autres sans séparation interne, avec icône, texte, curseur et valeur sur une ligne ; un seuil à 0 reste visible mais ne participe pas aux opérations ET/OU ;
- garder le nombre de critères sélectionnés visible dans l'en-tête, même replié.
### Liaison des guides aux checklists
Les guides à cartes partagent un panneau `guide-checklist-controls` dans la toolbox
latérale du jeu, au-dessus de ses outils : panneau NDS, select avec label, bouton de dissociation et switch
« Masquer les cochés ». Le label et le sélecteur occupent toute la largeur ; les actions sont centrées et reviennent à la ligne sur mobile. Le panneau ne concerne que le guide à cartes actuellement ouvert.
L'état terminé désature et atténue uniquement les portraits (ou les icônes de
catégorie Diablo), avec une coche accessible « Coché ». Les textes restent
lisibles et les indicateurs absolus ne modifient pas la taille des cartes.
Dans les cartes retournables, les indicateurs appartiennent à chaque face.
## 18. Scrollbars
Les scrollbars utilisent `scrollbar-control` depuis `website/src/styles/design-system/_controls.scss`.
@ -836,3 +882,5 @@ Minimum attendu :
- alertes navigateur pour confirmations ou creation ;
- gros blocs explicatifs visibles dans l'app ;
- scroll horizontal sur les tableaux de donnees critiques.
Les chips `nds-chip[data-size="sm"]` utilisent une hauteur minimale de 30 px, un padding de 4 × 8 px, une police `xs` et des icônes/images de 16 px. Le catalogue interactif expose cette taille à côté de la taille par défaut.

View file

@ -52,6 +52,9 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier le bouton toolbox et le panneau latéral.
- Vérifier le breadcrumb.
- Vérifier les textes avec retours à la ligne.
- Pour les guides à cartes, vérifier le sélecteur de checklist dans le panneau latéral de la toolbox liée, les listes partielles et la mise à jour immédiate depuis le panneau latéral. Les guides ne doivent jamais modifier la progression.
- Vérifier le changement de checklist, la dissociation, la restauration de session, la suppression d'un module et le changement de toolbox. La réinitialisation des filtres conserve la liaison ; une liste devenue sans correspondance reste sélectionnée avec un message.
- Pour Aniimo, vérifier la copie des espèces ou des formes selon l'affichage, les noms français/anglais, la priorité des formes et le masquage avant regroupement. Compteurs et copie doivent correspondre aux résultats affichés.
## Listes de jeu

View file

@ -208,6 +208,38 @@ Stockage compact :
## Outil Checklist
### Association temporaire aux guides
Les pages Aniimo, monstres MHW, faune MHW et affixes Diablo peuvent refléter une
checklist de la toolbox actuellement associée au jeu. Le sélecteur, la dissociation
et le masquage se trouvent dans le panneau latéral de cette toolbox, au-dessus des outils. Cette liaison ne modifie
ni les données de checklist ni les liens persistants jeu/toolbox.
`sessionStorage["sokkog:guide-checklists"]` contient un objet indexé par
`aniimo/aniimos`, `mhwilds/monsters`, `mhwilds/endemic` ou `diablo4/affixes`.
Chaque entrée contient `{ toolboxId, moduleId, hideCompleted }`. Aucune liaison
n'est choisie automatiquement. La navigation et le rechargement la conservent ;
la dissociation, la suppression du module ou le changement de toolbox la retirent.
Sans accès au stockage de session, l'état reste en mémoire dans l'application.
Ces associations ne sont pas exportées et ne se synchronisent pas entre onglets.
La reconnaissance compare les libellés complets sans tenir compte de la casse,
des accents ni des espaces répétés. Une checklist partielle suffit, avec au moins
une correspondance dans toutes les données du guide, indépendamment des filtres.
Les lignes étrangères sont ignorées. Une correspondance est terminée lorsque
toutes ses lignes atteignent leur propre `qtyTarget` ; les doublons ne sont pas
additionnés. Renommer une ligne peut supprimer sa correspondance. Une liste encore
sélectionnée mais sans correspondance reste liée, avec un message explicite.
La copie Aniimo utilise `Nom:1` en affichage regroupé et `Nom — Forme:1` en
affichage séparé. Les noms et formes français et anglais sont reconnus. Une ligne
sans forme couvre toutes les variantes ; une ligne spécifique à une forme prend
priorité sur la ligne générale. Le masquage des cochés précède le regroupement ;
le style d'une carte groupée dépend de la variante active. La copie et les
compteurs portent sur les résultats effectivement affichés.
### Données persistantes
Type : `checklist`
Format simple, sans catégorie :

344
e2e/aniimo.spec.js Normal file
View file

@ -0,0 +1,344 @@
// Rôle : vérifie les parcours Aniimo, les variantes, les cartes et les seuils au clavier.
import { test, expect } from "@playwright/test";
const route = "/#/games/aniimo/aniimos";
const cards = (page) => page.locator(".aniimo-result");
const section = (page, name) => page.getByRole("group", { name, exact: true }).filter({ has: page.locator("legend") });
test("Aniimo matches MHW card motion, surfaces and filter controls", async ({ page }) => {
test.setTimeout(45000);
async function styles(locator, properties) {
return locator.evaluate((element, keys) => {
const css = globalThis.getComputedStyle(element);
return Object.fromEntries(keys.map((key) => [key, css.getPropertyValue(key)]));
}, properties);
}
const motionProperties = ["transition-duration", "transition-timing-function", "transform-style"];
const faceProperties = ["background-image", "border-radius", "backface-visibility"];
const indicatorProperties = ["width", "height", "background-color", "opacity"];
const filterProperties = ["background-color", "border-radius", "min-height", "padding"];
await page.goto("/#/games/mhwilds");
await expect(page.locator(".game-home-card").first()).toBeVisible();
const homeCard = await page.locator(".game-home-card").first().boundingBox();
await page.goto("/#/games/aniimo");
await expect(page.locator(".aniimo-home-card")).toBeVisible();
const aniimoHomeCard = await page.locator(".aniimo-home-card").boundingBox();
expect(aniimoHomeCard.width).toBeCloseTo(homeCard.width, 1);
expect(aniimoHomeCard.height).toBeCloseTo(homeCard.height, 1);
await page.goto("/#/games/mhwilds/monsters");
await expect(page.locator(".monster-card").first()).toBeVisible({ timeout: 15000 });
const motion = await styles(page.locator(".mhwilds-card-inner").first(), motionProperties);
const face = await styles(page.locator(".mhwilds-card-front").first(), faceProperties);
const indicator = await styles(page.locator(".mhwilds-flip-indicator").first(), indicatorProperties);
const filter = await styles(page.locator(".filter-logic"), filterProperties);
await page.goto(route);
const card = cards(page).first();
await expect(card).toBeVisible();
expect(await styles(card.locator(".aniimo-card-inner"), motionProperties)).toEqual(motion);
expect(await styles(card.locator(".aniimo-front"), faceProperties)).toEqual(face);
expect(await styles(card.locator(".aniimo-front .aniimo-flip"), indicatorProperties)).toEqual(indicator);
expect(await styles(page.locator(".filter-logic").first(), filterProperties)).toEqual(filter);
await expect(card.locator(".aniimo-face .aniimo-flip")).toHaveCount(2);
await expect(card.locator(".aniimo-card > .aniimo-flip")).toHaveCount(0);
await card.getByRole("button", { name: /Afficher les capacités/ }).press("Enter");
await expect(card.getByRole("button", { name: /Afficher le portrait/ })).toBeFocused();
await expect(card.locator(".aniimo-front")).toHaveAttribute("inert", "");
await card.getByRole("button", { name: /Afficher le portrait/ }).press("Enter");
await expect(card.getByRole("button", { name: /Afficher les capacités/ })).toBeFocused();
});
test("catalogue, overview and deferred Aniimo loading preserve filters across navigation", async ({ page }) => {
const requests = [];
page.on("request", (request) => { if (request.url().includes("/data/aniimo/")) requests.push(request.url()); });
await page.goto("/#/games");
await expect(page.getByRole("heading", { name: "Jeux disponibles" })).toBeVisible();
expect(requests).toHaveLength(0);
await page.locator('a.game-card-cover[href="#/games/aniimo"]').click();
await expect(page.getByRole("heading", { name: "Aniimo", exact: true })).toBeVisible();
const cover = page.locator('.aniimo-home-card img');
await expect(cover).toHaveAttribute("src", "/static/img/aniimo/emberpup__basic_form.png");
await expect(cover).toHaveCSS("object-fit", "cover");
await expect(cover).toHaveCSS("object-position", "50% 38%");
await expect(cover).toHaveCSS("height", "120px");
await page.locator('a[href="#/games/aniimo/aniimos"]').click();
await expect(cards(page)).toHaveCount(88);
await expect(page.getByRole("status")).toHaveText("88 / 88 Aniimos · 208 / 208 variantes");
const switchLabel = page.getByRole("button", { name: "Regrouper les variantes", exact: true }).locator("span:not(.ui-icon)");
expect(await switchLabel.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.getByRole("searchbox", { name: "Nom" }).fill("EMBERPUP");
await expect(cards(page)).toHaveCount(1);
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
await expect(cards(page)).toHaveCount(3);
await page.locator('.game-breadcrumb a[href="#/games/aniimo"]').click();
await page.locator('a[href="#/games/aniimo/aniimos"]').click();
await expect(cards(page)).toHaveCount(3);
await expect(page.getByRole("searchbox")).toHaveValue("EMBERPUP");
expect(requests).toHaveLength(3);
});
test("tabs, flip, matching variants and reset remain independent", async ({ page }) => {
await page.goto(route);
await page.getByRole("searchbox").fill("emberpup");
const card = cards(page).first();
await expect(card.getByRole("tab")).toHaveCount(3);
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
await card.getByRole("tab", { name: "Hautes-plaines", exact: true }).click();
await expect(card).toHaveAttribute("data-form", "highlands");
await expect(card.getByRole("button", { name: /Afficher le portrait/ })).toHaveAttribute("aria-pressed", "true");
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
await expect(card.locator(".aniimo-back .aniimo-attributes").first()).toContainText("Roche");
await card.getByRole("tab", { name: "Forme de base", exact: true }).focus();
await page.keyboard.press("ArrowRight");
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
await section(page, "Éléments").getByRole("checkbox", { name: "Roche", exact: true }).check();
await expect(card.getByRole("tab")).toHaveCount(0);
await expect(card).toHaveAttribute("data-form", "highlands");
await section(page, "Éléments").getByRole("checkbox", { name: "Eau", exact: true }).check();
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
await section(page, "Éléments").getByRole("button", { name: "OU", exact: true }).click();
await expect(cards(page)).toHaveCount(1);
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
await expect(cards(page)).toHaveCount(1);
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
await expect(cards(page)).toHaveCount(208);
await expect(page.getByRole("button", { name: "Regrouper les variantes", exact: true })).toHaveAttribute("aria-pressed", "false");
});
test("ability levels use independent native ranges and retain name constraints", async ({ page }) => {
await page.goto(route);
const abilities = section(page, "Compétences de logis");
const carry = page.getByRole("slider", { name: "Porter — Niveau minimum" });
const crafting = page.getByRole("slider", { name: "Artisanat — Niveau minimum" });
await expect(abilities.getByRole("checkbox")).toHaveCount(0);
await expect(carry).toHaveValue("0");
await expect(carry).toHaveAttribute("min", "0");
await carry.focus();
await carry.press("ArrowRight");
await carry.press("ArrowRight");
await carry.press("ArrowRight");
await expect(carry).toHaveValue("3");
await expect(crafting).toHaveValue("0");
await crafting.focus();
await crafting.press("ArrowRight");
await crafting.press("ArrowRight");
await expect(crafting).toHaveValue("2");
await expect(crafting).toHaveCSS("outline-style", "solid");
await abilities.getByRole("button", { name: "OU", exact: true }).click();
await expect(section(page, "Éléments").getByRole("button", { name: "ET", exact: true })).toHaveAttribute("aria-pressed", "true");
await carry.press("Home");
await crafting.press("Home");
await expect(carry).toHaveAttribute("aria-valuetext", "Sans filtre");
await expect(cards(page)).toHaveCount(88);
await page.getByRole("searchbox").fill("does-not-exist");
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
});
test("seven tabs support keyboard navigation and fall back after filtering", async ({ page }) => {
await page.goto(route);
await page.getByRole("searchbox").fill("iris");
const card = cards(page).filter({ has: page.getByRole("heading", { name: "Iris", exact: true }) });
await expect(card.getByRole("tab")).toHaveCount(7);
const plateau = card.getByRole("tab", { name: "Forme des plateaux", exact: true });
await expect(plateau).toHaveText("PLA");
await plateau.hover();
await expect(page.getByRole("tooltip", { name: "Forme des plateaux", exact: true })).toBeVisible();
await page.mouse.move(0, 0);
await plateau.focus();
await expect(page.getByRole("tooltip", { name: "Forme des plateaux", exact: true })).toBeVisible();
const firstTab = await card.getByRole("tab").first().boundingBox();
const secondTab = await card.getByRole("tab").nth(1).boundingBox();
expect(secondTab.x).toBeGreaterThanOrEqual(firstTab.x + firstTab.width);
expect(await card.getByRole("tab").nth(1).evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true);
await card.getByRole("tab").first().focus();
await page.keyboard.press("End");
await expect(card).toHaveAttribute("data-form", "prismana_form");
await page.keyboard.press("Home");
await expect(card).toHaveAttribute("data-form", "basic_form");
await card.getByRole("tab", { name: "Forme prismana", exact: true }).click();
await expect(card).toHaveAttribute("data-form", "prismana_form");
await section(page, "Éléments").getByRole("checkbox", { name: "Herbe", exact: true }).check();
await expect(card).toHaveAttribute("data-form", "prismana_form");
await section(page, "Éléments").getByRole("checkbox", { name: "Herbe", exact: true }).uncheck();
await page.getByRole("searchbox").fill("emberpup");
await expect(cards(page)).toHaveAttribute("data-form", "basic_form");
await section(page, "Éléments").getByRole("checkbox", { name: "Roche", exact: true }).check();
await expect(cards(page)).toHaveAttribute("data-form", "highlands");
await section(page, "Éléments").getByRole("checkbox", { name: "Roche", exact: true }).uncheck();
await expect(cards(page)).toHaveAttribute("data-form", "highlands");
});
for (const width of [1280, 390]) {
test(`uniform cards, visible variant tabs and scrollable backs at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto(route);
await expect(cards(page)).toHaveCount(88);
const sizes = await page.locator(".aniimo-card").evaluateAll((elements) => elements.map((element) => {
const { width, height } = element.getBoundingClientRect();
return { width, height };
}));
expect(Math.max(...sizes.map((size) => size.width)) - Math.min(...sizes.map((size) => size.width))).toBeLessThan(1);
expect(Math.max(...sizes.map((size) => size.height)) - Math.min(...sizes.map((size) => size.height))).toBeLessThan(1);
await expect(page.locator(".aniimo-front .aniimo-element-icons").first()).toHaveText("");
await expect(page.locator(".aniimo-front .aniimo-element-icons img").first()).toHaveAttribute("alt", "Feu");
await page.getByRole("searchbox").fill("emberpup");
const longFormCard = cards(page).first();
const baseHeight = (await longFormCard.boundingBox()).height;
await longFormCard.getByRole("tab", { name: "Forme des montagnes boisées", exact: true }).click();
const form = longFormCard.locator(".aniimo-front .aniimo-form");
await expect(form).toHaveCSS("white-space", "normal");
expect(await form.evaluate((element) => element.scrollHeight <= element.clientHeight)).toBe(true);
expect((await longFormCard.boundingBox()).height).toBeCloseTo(baseHeight, 1);
await longFormCard.getByRole("tab", { name: "Forme de base", exact: true }).click();
await page.getByRole("searchbox").fill("");
const icons = page.locator(".aniimo-front .aniimo-element-icons").first();
const rowBox = await icons.boundingBox();
const iconBox = await icons.locator("img").boundingBox();
expect(iconBox.x + iconBox.width / 2).toBeCloseTo(rowBox.x + rowBox.width / 2, 1);
await page.getByRole("searchbox").fill("iris");
for (const name of ["Iris", "Valsiris"]) {
const card = cards(page).filter({ has: page.getByRole("heading", { name, exact: true }) });
const tabs = card.getByRole("tablist");
await expect(card.getByRole("tab")).toHaveCount(7);
await expect(card.getByRole("tab").last()).toHaveAccessibleName("Forme prismana");
expect(await tabs.evaluate((element) => {
const box = element.getBoundingClientRect();
return element.scrollWidth <= element.clientWidth && [...element.children].every((tab) => {
const bounds = tab.getBoundingClientRect();
return bounds.left >= box.left && bounds.right <= box.right && tab.scrollWidth <= tab.clientWidth;
});
})).toBe(true);
await expect(card.getByRole("tab").last()).toHaveCSS("background-image", /linear-gradient/);
}
await page.getByRole("searchbox").fill("fenmane");
const card = cards(page).first();
const before = await card.locator(".aniimo-card").boundingBox();
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
await card.getByRole("tab", { name: "Forme prismana", exact: true }).click();
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
const after = await card.locator(".aniimo-card").boundingBox();
expect(after.height).toBeCloseTo(before.height, 1);
expect(after.width).toBeCloseTo(before.width, 1);
const details = card.locator(".aniimo-back-details");
await expect(details).toHaveCSS("overflow-y", "auto");
await expect(details.locator(".aniimo-abilities li")).toHaveCount(4);
if (width === 1280) {
expect(await details.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
await details.focus();
await details.press("End");
await expect.poll(() => details.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
}
await card.getByRole("button", { name: /Afficher le portrait/ }).click();
expect((await card.locator(".aniimo-card").boundingBox()).height).toBeCloseTo(before.height, 1);
});
}
test("portrait geometry, stable faces and mobile layout", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(route);
await page.getByRole("searchbox").fill("emberpup");
const card = cards(page).first();
await card.scrollIntoViewIfNeeded();
const portrait = card.locator(".aniimo-portrait img");
await expect(portrait).toHaveCSS("object-fit", "cover");
await expect(portrait).toHaveCSS("object-position", "50% 50%");
await expect(portrait).toHaveJSProperty("naturalWidth", 512);
const size = await portrait.boundingBox();
expect(size.height).toBeLessThanOrEqual(250);
expect(size.height).toBeGreaterThan(240);
const before = await card.boundingBox();
expect(before.height).toBeLessThan(520);
expect(before.width).toBeLessThanOrEqual(280);
await card.getByRole("button", { name: /Afficher les capacités/ }).press("Enter");
await expect(card.locator(".aniimo-back")).toHaveAttribute("aria-hidden", "false");
expect((await card.boundingBox()).height).toBeCloseTo(before.height, 1);
const summary = card.locator(".aniimo-back-summary > div");
const elementBox = await summary.nth(0).boundingBox();
const roleBox = await summary.nth(1).boundingBox();
expect(elementBox.y).toBeCloseTo(roleBox.y, 1);
expect(elementBox.x + elementBox.width).toBeLessThan(roleBox.x);
await expect(card.getByRole("progressbar")).toHaveCount(6);
await expect(card.getByRole("progressbar", { name: "PV", exact: true })).toHaveAttribute("aria-valuenow", "67");
await expect(card.getByRole("progressbar", { name: "PV", exact: true })).toHaveAttribute("aria-valuemax", "130");
await expect(card.getByRole("progressbar", { name: "PV", exact: true })).toHaveCSS("height", "4px");
await expect(card.locator(".aniimo-stat-value")).toHaveText(["67", "90", "60", "57", "40", "68"]);
await expect(card.locator(".aniimo-stat-total")).toHaveText("Total382");
expect(await card.locator(".aniimo-card-inner").evaluate((element) => Number.parseFloat(globalThis.getComputedStyle(element).transitionDuration))).toBeLessThan(0.001);
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
});
test("desktop grid shows five columns with the multiple icon and complete stats", async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto(route);
await expect(cards(page)).toHaveCount(88);
const columns = await page.locator(".aniimo-grid").evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length);
expect(columns).toBe(5);
await expect(page.locator(".ui-icon-multiple")).toHaveCSS("mask-image", /multiples\.svg/);
const card = cards(page).first();
await card.getByRole("button", { name: /Afficher les capacités/ }).click();
await card.locator(".aniimo-back-details").evaluate((element) => { element.scrollTop = element.scrollHeight; });
await expect(card.getByRole("progressbar")).toHaveCount(6);
await page.screenshot({ path: test.info().outputPath("aniimo-stats-desktop.png") });
});
test("filter categories share one scroll and retain their selections when collapsed", async ({ page }) => {
await page.goto(route);
const elements = section(page, "Éléments");
const roles = section(page, "Rôles");
const abilities = section(page, "Compétences de logis");
await expect(page.locator(".aniimo-filter-options")).toHaveCount(3);
for (const grid of await page.locator(".aniimo-chip-options").all()) {
expect(await grid.evaluate((element) => globalThis.getComputedStyle(element).gridTemplateColumns.split(" ").length)).toBe(2);
}
const groups = await page.locator(".aniimo-ability-filter").evaluateAll((rows) => rows.map((row) => row.dataset.abilityGroup));
expect(groups).toEqual([...groups].sort());
await expect(page.locator(".aniimo-filter-toggle").first()).toHaveText("Rôles");
await expect(roles.getByRole("checkbox", { name: "SOIN", exact: true })).toHaveCount(1);
await expect(roles.getByRole("checkbox", { name: "SOUTIEN", exact: true })).toHaveCount(1);
const elementScroll = elements.getByRole("region", { name: "Éléments", exact: true });
const scroll = page.locator(".aniimo-filter-sections");
await expect(scroll).toHaveCSS("overflow-y", "auto");
for (const options of await page.locator(".aniimo-filter-options").all()) {
await expect(options).toHaveCSS("overflow-y", "visible");
expect(await options.evaluate((element) => element.scrollHeight <= element.clientHeight)).toBe(true);
}
await scroll.evaluate((element) => { element.scrollTop = element.scrollHeight; });
expect(await scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
await elements.getByRole("checkbox", { name: "Roche", exact: true }).check();
const results = await page.getByRole("status").textContent();
const toggle = elements.getByRole("button", { name: "Éléments", exact: true });
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(elementScroll).toBeHidden();
await expect(toggle.locator(".aniimo-selected-count")).toHaveText("1");
await expect(page.getByRole("status")).toHaveText(results);
await expect(roles.getByRole("region")).toBeVisible();
await expect(abilities.getByRole("region")).toBeVisible();
await toggle.press("Enter");
await expect(elements.getByRole("checkbox", { name: "Roche", exact: true })).toBeChecked();
const slider = abilities.getByRole("slider", { name: "Porter — Niveau minimum" });
await slider.focus();
await slider.press("End");
await abilities.getByRole("button", { name: "Compétences de logis", exact: true }).click();
await expect(slider).toBeHidden();
await abilities.getByRole("button", { name: "Compétences de logis", exact: true }).click();
await expect(slider).toHaveValue("4");
});
test("pending and failed data requests show loading and error states", async ({ page }) => {
let releaseRequests;
const pending = new Promise((resolve) => { releaseRequests = resolve; });
await page.route("**/data/aniimo/aniimos.json", async (route) => {
await pending;
await route.fulfill({ status: 503, body: "unavailable" });
});
await page.goto(route);
await expect(page.getByRole("heading", { name: "Chargement des Aniimos…" })).toBeVisible();
releaseRequests();
await expect(page.getByRole("heading", { name: "Impossible de charger Aniimo" })).toBeVisible();
});

245
e2e/guide-checklist.spec.js Normal file
View file

@ -0,0 +1,245 @@
// Rôle : vérifie copie, liaison de session et progression des checklists dans les guides.
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
import { resetSokkoStorage } from "./helpers/storage.js";
import { importChecklistText } from "../website/src/features/toolboxes/modules/textImport/basic.js";
import { normalizeChecklistData } from "../website/src/features/toolboxes/storage/modules/checklist.js";
const route = "/#/games/aniimo/aniimos";
const sessionKey = "sokkog:guide-checklists";
const selector = (page) => page.getByRole("combobox", { name: "Checklist associée", exact: true });
const hiddenSwitch = (page) => page.getByRole("button", { name: "Masquer les cochés", exact: true });
const json = (path) => JSON.parse(readFileSync(`website/public/data/${path}`, "utf8"));
const row = (label, qtyCurrent = 0) => ({ id: label, label, qtyCurrent });
async function seed(page, gameId = "aniimo", lists = {
capture: [row("Loufeuteau")],
perfect: [row("Loufeuteau", 1)],
forms: [row("Loufeuteau — Forme de base", 1), row("Emberpup — Mountain Woods Form")],
unrelated: [row("Unrelated")]
}) {
await resetSokkoStorage(page);
await page.evaluate(async ({ gameId, lists }) => {
await new Promise((resolve, reject) => {
const request = globalThis.indexedDB.open("sokkog", 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction(["kv", "modules"], "readwrite");
tx.objectStore("kv").put([
{ id: "guide", name: "Guide toolbox", modules: Object.keys(lists).map((id) => ({ id, type: "checklist", title: id })), moduleColumns: 1 },
{ id: "other", name: "Other toolbox", modules: [{ id: "outside", type: "checklist", title: "Outside" }] }
], "toolboxes");
tx.objectStore("kv").put({ [gameId]: "guide" }, "links");
for (const [id, items] of Object.entries(lists)) tx.objectStore("modules").put({ key: `guide:${id}`, data: { items } });
tx.objectStore("modules").put({ key: "other:outside", data: { items: [{ id: "outside", label: "Loufeuteau", qtyCurrent: 1 }] } });
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => reject(tx.error);
};
request.onerror = () => reject(request.error);
});
}, { gameId, lists });
await page.reload();
}
async function readModule(page, moduleId) {
return page.evaluate(async (moduleId) => new Promise((resolve, reject) => {
const request = globalThis.indexedDB.open("sokkog", 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction("modules", "readonly");
const get = tx.objectStore("modules").get(`guide:${moduleId}`);
get.onsuccess = () => resolve(get.result.data);
tx.oncomplete = () => db.close();
tx.onerror = () => reject(tx.error);
};
}), moduleId);
}
async function openDrawer(page) {
const drawer = page.locator("#toolbox-drawer");
if (!await drawer.locator(".drawer-panel").isVisible()) await page.locator(".topbar-actions button").click();
await expect(drawer.locator(".drawer-panel")).toBeVisible();
return drawer;
}
async function closeDrawer(page) {
await page.locator("#toolbox-drawer").getByRole("button", { name: "Fermer", exact: true }).click();
}
async function choose(page, id) {
await openDrawer(page);
await selector(page).selectOption(id);
await closeDrawer(page);
}
async function toggleHidden(page) {
await openDrawer(page);
await hiddenSwitch(page).click();
await closeDrawer(page);
}
async function assertLink(page, id, hidden) {
await openDrawer(page);
await expect(selector(page)).toHaveValue(id);
if (id) await expect(hiddenSwitch(page)).toHaveAttribute("aria-pressed", String(hidden));
else await expect(hiddenSwitch(page)).toHaveCount(0);
await closeDrawer(page);
}
test("Aniimo copies visible species or forms and round-trips through checklist import", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await seed(page);
await page.goto(route);
await page.getByRole("searchbox").fill("emberpup");
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
const copy = page.locator(".results-copy-button");
await expect(copy).toHaveAccessibleName(/sans les variantes/);
await expect(page.locator('[data-copy-target="true"]')).toHaveText("1 / 88 Aniimos");
await copy.click();
await expect.poll(() => page.evaluate(() => globalThis.navigator.clipboard.readText())).toBe("Loufeuteau:1");
await page.getByRole("button", { name: "Regrouper les variantes", exact: true }).click();
await expect(copy).toHaveAccessibleName(/toutes les variantes/);
await expect(page.locator('[data-copy-target="true"]')).toHaveText("3 / 208 variantes");
await copy.click();
const text = await page.evaluate(() => globalThis.navigator.clipboard.readText());
const imported = importChecklistText(text, { uid: (prefix) => `${prefix}-${Math.random()}`, normalizeChecklistData });
expect(imported.items.map((item) => item.label)).toEqual(["Loufeuteau — Forme de base", "Loufeuteau — Forme des montagnes boisées", "Loufeuteau — Hautes-plaines"]);
expect(imported.items.every((item) => item.qtyTarget === 1 && item.qtyCurrent === 0)).toBe(true);
await choose(page, "forms");
await toggleHidden(page);
await copy.click();
expect(await page.evaluate(() => globalThis.navigator.clipboard.readText())).not.toContain("Forme de base");
await choose(page, "perfect");
await expect(copy).toBeDisabled();
await expect(page.getByRole("heading", { name: "Aucun résultat" })).toBeVisible();
});
for (const width of [1280, 390]) {
test(`drawer linking, live completion and session restoration at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await seed(page);
await page.goto(route);
await page.getByRole("searchbox").fill("emberpup");
const card = page.locator(".aniimo-result");
await openDrawer(page);
await expect(selector(page)).toHaveValue("");
await expect(selector(page).locator("option")).toHaveCount(4);
const before = await readModule(page, "forms");
await selector(page).selectOption("forms");
const panel = page.locator(".guide-checklist-controls");
const bounds = await panel.boundingBox();
const selectBounds = await selector(page).boundingBox();
expect(selectBounds.x + selectBounds.width / 2).toBeCloseTo(bounds.x + bounds.width / 2, 0);
await page.screenshot({ path: test.info().outputPath("linked-drawer.png") });
await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true");
await expect(card.locator(".aniimo-front .guide-checklist-mark")).toHaveAccessibleName("Coché");
await toggleHidden(page);
await expect(card.getByRole("tab")).toHaveCount(2);
await expect(card).toHaveAttribute("data-form", "mountain_woods_form");
expect(await readModule(page, "forms")).toEqual(before);
await choose(page, "capture");
await assertLink(page, "capture", true);
await expect(card.getByRole("tab")).toHaveCount(3);
const drawer = await openDrawer(page);
await drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) }).getByRole("checkbox", { name: "Terminer Loufeuteau", exact: true }).check();
await closeDrawer(page);
await expect(card).toHaveCount(0);
await page.reload();
await assertLink(page, "capture", true);
await page.getByRole("searchbox").fill("emberpup");
await expect(card).toHaveCount(0);
await openDrawer(page);
await hiddenSwitch(page).press("Space");
await closeDrawer(page);
await expect(card).toHaveAttribute("data-checklist-complete", "true");
await page.getByRole("button", { name: "Réinitialiser les filtres", exact: true }).click();
await openDrawer(page);
await expect(selector(page)).toHaveValue("capture");
await page.getByRole("button", { name: "Dissocier", exact: true }).click();
await expect(selector(page)).toHaveValue("");
await expect(hiddenSwitch(page)).toHaveCount(0);
await closeDrawer(page);
await expect(page.locator('[data-checklist-complete="true"]')).toHaveCount(0);
expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull();
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
});
}
for (const [gameId, category, cardClass, dataPath, dataKey] of [
["mhwilds", "monsters", ".monster-card", "mhwilds/monsters.json", "monsters"],
["mhwilds", "endemic", ".endemic-card", "mhwilds/endemic_life.json", "endemicLife"],
["diablo4", "affixes", ".diablo4-affix-card", "diablo4/affixes_types.json", "affixes"]
]) {
test(`${gameId}/${category} links from its drawer and hides completed cards`, async ({ page }) => {
const item = json(dataPath)[dataKey][0];
const label = gameId === "mhwilds" ? json("mhwilds/i18n/fr.json")[item.name] : item.label;
await seed(page, gameId, { progress: [row(label, 1)] });
await page.goto(`/#/games/${gameId}/${category}`);
await expect(page.locator(cardClass).first()).toBeVisible();
const count = await page.locator(cardClass).count();
await choose(page, "progress");
await expect(page.locator(`${cardClass}[data-checklist-complete="true"]`)).toHaveCount(1);
await toggleHidden(page);
await expect(page.locator(cardClass)).toHaveCount(count - 1);
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await assertLink(page, "progress", true);
await expect(page.locator(cardClass)).toHaveCount(count - 1);
expect(await page.evaluate(() => globalThis.document.documentElement.scrollWidth <= globalThis.innerWidth)).toBe(true);
});
}
test("renames retain a zero-match selection; changing toolbox clears the association", async ({ page }) => {
await seed(page);
await page.goto(route);
await choose(page, "capture");
const drawer = await openDrawer(page);
const checklist = drawer.locator('[data-module-type="checklist"]').filter({ has: page.getByRole("button", { name: "Retirer capture", exact: true }) });
await checklist.getByRole("button", { name: "Modifier Loufeuteau", exact: true }).click();
await checklist.getByRole("textbox", { name: "Libellé", exact: true }).fill("Unrelated now");
await checklist.getByRole("textbox", { name: "Libellé", exact: true }).press("Enter");
await expect(selector(page)).toHaveValue("capture");
await expect(page.getByText("Cette checklist ne contient plus de correspondance avec le guide.")).toBeVisible();
await drawer.getByRole("button", { name: "Lier une toolbox", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Lier une toolbox" });
await dialog.getByRole("combobox").selectOption("other");
await dialog.getByRole("button", { name: "Valider", exact: true }).click();
await expect(selector(page)).toHaveValue("");
await expect(selector(page).locator("option")).toHaveCount(2);
expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull();
});
test("deleting a selected checklist clears its link immediately", async ({ page }) => {
await seed(page);
await page.goto(route);
await choose(page, "capture");
await toggleHidden(page);
const drawer = await openDrawer(page);
await drawer.getByRole("button", { name: "Retirer capture", exact: true }).click();
await page.getByRole("dialog").getByRole("button", { name: "Retirer", exact: true }).click();
await expect(selector(page)).toHaveValue("");
await expect(hiddenSwitch(page)).toHaveCount(0);
await page.reload();
await assertLink(page, "", false);
expect(await page.evaluate((key) => globalThis.sessionStorage.getItem(key), sessionKey)).toBeNull();
});
test("unavailable session storage retains links across navigation; overview has no guide controls", async ({ page }) => {
await seed(page);
await page.addInitScript((key) => {
for (const method of ["getItem", "setItem", "removeItem"]) {
const original = globalThis.Storage.prototype[method];
globalThis.Storage.prototype[method] = function (name, ...args) {
if (name === key) throw new Error("Session storage unavailable");
return original.call(this, name, ...args);
};
}
}, sessionKey);
await page.reload();
await page.goto(route);
await choose(page, "perfect");
await toggleHidden(page);
await page.goto("/#/games/aniimo");
await openDrawer(page);
await expect(page.locator(".guide-checklist-controls")).toHaveCount(0);
await closeDrawer(page);
await page.goto(route);
await assertLink(page, "perfect", true);
});

View file

@ -2,6 +2,23 @@
import { test, expect } from "@playwright/test";
import { mountStyles, computed, expectPseudo } from "./fixtures/compiled-styles.js";
test("native ranges support public sizes, keyboard thresholds and disabled states", async ({ page }) => {
for (const [size, height] of [["md", 44], ["sm", 36]]) {
await mountStyles(page, `<label style="display:block;padding:24px;width:260px">Niveau minimum<input class="nds-range" data-size="${size}" type="range" min="1" max="4" step="1" value="1"></label>`);
const range = page.getByRole("slider", { name: "Niveau minimum" });
await expect(range).toHaveCSS("height", `${height}px`);
await range.focus();
await range.press("End");
await expect(range).toHaveValue("4");
await range.press("ArrowLeft");
await expect(range).toHaveValue("3");
await expect(range).toHaveCSS("outline-style", "solid");
await range.evaluate((element) => { element.disabled = true; });
await expect(range).toBeDisabled();
await expect(range).toHaveCSS("cursor", "not-allowed");
}
});
test("fields, selects and date triggers keep public sizes and keyboard focus", async ({ page }) => {
for (const [size, height, textareaHeight] of [["md", 44, 108], ["sm", 36, 88], ["xs", 32, 72]]) {
await mountStyles(page, `<div style="padding:24px;display:grid;gap:12px;width:300px">

View file

@ -48,12 +48,25 @@ test("badges, notices, progress and chips expose meaningful visual states", asyn
await expect(progress.locator("span")).toHaveCSS("width", "99px");
await progress.locator("span").evaluate((element) => { element.style.width = "100%"; });
await expect(progress.locator("span")).toHaveCSS("width", "198px");
await progress.evaluate((element) => { element.dataset.size = "sm"; });
await expect(progress).toHaveCSS("height", "4px");
}
const chip = page.locator(".nds-chip");
await expect(chip).toHaveCSS("background-color", "rgba(5, 7, 17, 0.32)");
await chip.locator("input").check();
await page.mouse.move(0, 0);
await expect(chip).toHaveCSS("background-color", "rgba(139, 92, 246, 0.14)");
await chip.evaluate((element) => {
element.dataset.size = "sm";
const image = globalThis.document.createElement("img");
image.src = "/static/icons/multiples.svg";
image.alt = "";
element.append(image);
});
await expect(chip).toHaveCSS("min-height", "30px");
await expect(chip).toHaveCSS("font-size", "12px");
await expect(chip.locator("img")).toHaveCSS("width", "16px");
await expect(chip.locator("img")).toHaveCSS("height", "16px");
});
for (const width of [1280, 390]) {

114
tests/aniimo.test.mjs Normal file
View 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/);
});

View 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" }), {});
});

View file

@ -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",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,140 @@
{
"bailite": "Bailite",
"baleetle": "Baleetle",
"basic_form": "Basic Form",
"bay_form": "Bay Form",
"beach_form": "Beach Form",
"besauce": "Besauce",
"blazen": "Blazen",
"bolty": "Bolty",
"bonesky": "Bonesky",
"bouldus": "Bouldus",
"break": "BREAK",
"budclaw": "Budclaw",
"budsquire": "Budsquire",
"bulbly": "Bulbly",
"carry": "Carry",
"celestis": "Celestis",
"chirpi": "Chirpi",
"climb": "Climb",
"cloudy_mist_form": "Cloudy Mist Form",
"cornet": "Cornet",
"cozite": "Cozite",
"crafting": "Crafting",
"cubbo": "Cubbo",
"dark": "dark",
"dash": "Dash",
"dazmand": "Dazmand",
"deluge_form": "Deluge Form",
"dewy": "Dewy",
"dig": "Dig",
"dps": "DPS",
"dreaple": "Dreaple",
"eklue": "Eklue",
"eko": "Eko",
"electric": "electric",
"emberpup": "Emberpup",
"enhanced_jump": "Enhanced Jump",
"erlath": "Erlath",
"fahloo": "Fahloo",
"fenmane": "Fenmane",
"fenrier": "Fenrier",
"fentuft": "Fentuft",
"fire": "fire",
"flamerion": "Flamerion",
"flameruff": "Flameruff",
"flight": "Flight",
"flutternym": "Flutternym",
"fly": "Fly",
"forest_form": "Forest Form",
"fragrancier": "Fragrancier",
"fulmintis": "Fulmintis",
"geoclaw": "Geoclaw",
"geodeback": "Geodeback",
"glacy": "Glacy",
"glynsera": "Glynsera",
"gracewing": "Gracewing",
"grappling_hook": "Grappling Hook",
"grass": "grass",
"grassland_form": "Grassland Form",
"grizbo": "Grizbo",
"heal": "Heal",
"helgon": "Helgon",
"helion": "Helion",
"helmut": "Helmut",
"helmwhelp": "Helmwhelp",
"highlands": "Highlands",
"holy": "holy",
"hummin": "Hummin",
"ice": "ice",
"ignitis": "Ignitis",
"infergon": "Infergon",
"inferlupa": "Inferlupa",
"iris": "Iris",
"irisal": "Irisal",
"irisalis": "Irisalis",
"jawling": "Jawling",
"lavazar": "Lavazar",
"leafy": "Leafy",
"leisure": "Leisure",
"lighting": "Lighting",
"little_fire_spirit": "Little Fire Spirit",
"luminelle": "Luminelle",
"lunara": "Lunara",
"magmarex": "Magmarex",
"melloblum": "Melloblum",
"minespine": "Minespine",
"mountain_form": "Mountain Form",
"mountain_woods_form": "Mountain Woods Form",
"mudflat_form": "Mudflat Form",
"nighttime_form": "Nighttime Form",
"nimbi": "Nimbi",
"obstacle_destruction": "Obstacle Destruction",
"panpanta": "Panpanta",
"pawney": "Pawney",
"pebbling": "Pebbling",
"perfumery": "Perfumery",
"piopiota": "Piopiota",
"plateau_form": "Plateau Form",
"pomawk": "Pomawk",
"pomegg": "Pomegg",
"popota": "Popota",
"pranky": "Pranky",
"prismana_form": "Prismana Form",
"regen": "REGEN",
"rock": "rock",
"rookey": "Rookey",
"rosetower_woods_form": "Rosetower Woods Form",
"scorchhowl": "Scorchhowl",
"sea_of_flowers_form": "Sea of Flowers Form",
"sheldon": "Sheldon",
"shelly": "Shelly",
"sherro": "Sherro",
"shrubclaw": "Shrubclaw",
"skippy": "Skippy",
"snowfield_form": "Snowfield Form",
"somniwing": "Somniwing",
"sparki": "Sparki",
"sprint": "Sprint",
"squarrel": "Squarrel",
"squashel": "Squashel",
"stealth": "Stealth",
"stellarys": "Stellarys",
"support": "Support",
"susuta": "Susuta",
"swim": "Swim",
"terrain_interaction": "Terrain Interaction",
"thornblade": "Thornblade",
"thunderstorm_form": "Thunderstorm Form",
"tromber": "Tromber",
"tubster": "Tubster",
"tuckin": "Tuckin",
"turbo": "Turbo",
"veilfloat": "Veilfloat",
"vision": "Vision",
"waleetle": "Waleetle",
"water": "water",
"wind": "wind",
"wisptis": "Wisptis",
"witchin": "Witchin"
}

View file

@ -0,0 +1,140 @@
{
"bailite": "Bastiroc",
"baleetle": "Balabée",
"basic_form": "Forme de base",
"bay_form": "Forme des baies",
"beach_form": "Forme des plages",
"besauce": "Pulsato",
"blazen": "Voltignasse",
"bolty": "Voltige",
"bonesky": "Osky",
"bouldus": "Boulabée",
"break": "RUPT.",
"budclaw": "Crabourgeon",
"budsquire": "Bourgeonnet",
"bulbly": "Bulbi",
"carry": "Porter",
"celestis": "Célestia",
"chirpi": "Cuicui",
"climb": "Grimper",
"cloudy_mist_form": "Forme des brumes nuageuses",
"cornet": "Clairbec",
"cozite": "Cabaroc",
"crafting": "Artisanat",
"cubbo": "Ourso",
"dark": "Ténèbres",
"dash": "Dash",
"dazmand": "Danzard",
"deluge_form": "Forme des déluges",
"dewy": "Plumiel",
"dig": "Creuser",
"dps": "DPS",
"dreaple": "Lainirique",
"eklue": "Ekolombo",
"eko": "Eko",
"electric": "Électrique",
"emberpup": "Loufeuteau",
"enhanced_jump": "Saut amélioré",
"erlath": "Fabulle",
"fahloo": "Falou",
"fenmane": "Fulgucrin",
"fenrier": "Glaivrier",
"fentuft": "Touffu",
"fire": "feu",
"flamerion": "Flamirage",
"flameruff": "Jappardent",
"flight": "Vol",
"flutternym": "Fluoretti",
"fly": "Voler",
"forest_form": "Forme des forêts",
"fragrancier": "Fragrancier",
"fulmintis": "Fulmi",
"geoclaw": "Cristocrabe",
"geodeback": "Géodosaure",
"glacy": "Givrotus",
"glynsera": "Molosabre",
"gracewing": "Voletti",
"grappling_hook": "Grappin",
"grass": "Herbe",
"grassland_form": "Forme des prairies",
"grizbo": "Grizzo",
"heal": "SOIN",
"helgon": "Cuiragon",
"helion": "Hélion",
"helmut": "Casquimou",
"helmwhelp": "Casquimord",
"highlands": "Hautes-plaines",
"holy": "Sacré",
"hummin": "Fredon",
"ice": "glace",
"ignitis": "Igniti",
"infergon": "Infergon",
"inferlupa": "Inferlupin",
"iris": "Iris",
"irisal": "Valsiris",
"irisalis": "Lumiris",
"jawling": "Machou",
"lavazar": "Lavasaure",
"leafy": "Voilotus",
"leisure": "Loisir",
"lighting": "Éclairage",
"little_fire_spirit": "Petit Esprit de Feu",
"luminelle": "Luminelle",
"lunara": "Lunara",
"magmarex": "Magmarex",
"melloblum": "Méliflore",
"minespine": "Cristorex",
"mountain_form": "Forme des montagnes",
"mountain_woods_form": "Forme des montagnes boisées",
"mudflat_form": "Forme des vasières",
"nighttime_form": "Nuit",
"nimbi": "Cumulaine",
"obstacle_destruction": "Destruction d'obstacles",
"panpanta": "Terriloutre",
"pawney": "Casquipreux",
"pebbling": "Silexodon",
"perfumery": "Parfumerie",
"piopiota": "Ondeloutre",
"plateau_form": "Forme des plateaux",
"pomawk": "Pomawk",
"pomegg": "Pomœuf",
"popota": "Bulloutre",
"pranky": "Filoutus",
"prismana_form": "Forme prismana",
"regen": "RÉGÉN.",
"rock": "Roche",
"rookey": "Casquillon",
"rosetower_woods_form": "Forme de tourbois",
"scorchhowl": "Hurlebrasier",
"sea_of_flowers_form": "Mer florale",
"sheldon": "Astor",
"shelly": "Astrid",
"sherro": "Astrophel",
"shrubclaw": "Crabiflore",
"skippy": "Tilotus",
"snowfield_form": "Forme des neiges",
"somniwing": "Vitti",
"sparki": "Farfafeu",
"sprint": "Sprint",
"squarrel": "Écurixe",
"squashel": "Écurouste",
"stealth": "Furtivité",
"stellarys": "Stellaria",
"support": "SOUTIEN",
"susuta": "Louloutre",
"swim": "Nager",
"terrain_interaction": "Interaction terrain",
"thornblade": "Rapiéronce",
"thunderstorm_form": "Forme des orages",
"tromber": "Trombec",
"tubster": "Tubabec",
"tuckin": "Nutrion",
"turbo": "Turbulaine",
"veilfloat": "Voilueur",
"vision": "Vision",
"waleetle": "Coléobée",
"water": "eau",
"wind": "Vent",
"wisptis": "Flammi",
"witchin": "Sorcelonde"
}

View file

@ -55,6 +55,27 @@
]
}
]
},
{
"id": "aniimo",
"title": "Aniimo",
"eyebrow": "Guide de jeu",
"images": {
"cardCover": "/static/img/games/aniimo/card-cover.png",
"heroBg": "/static/img/games/aniimo/hero-bg.png",
"aniimosCover": "/static/img/aniimo/emberpup__basic_form.png"
},
"summary": "Retrouvez les Aniimos et leurs variantes selon leurs éléments, leur rôle et leurs compétences de logis.",
"sections": [
{
"title": "Aniimos",
"items": [
"Rechercher un Aniimo",
"Comparer les variantes",
"Filtrer les capacités et leurs niveaux"
]
}
]
}
]
}

View file

@ -2718,5 +2718,51 @@
},
"emptyTitle": "Aucune toolbox",
"emptyText": "Créez une première toolbox pour stocker vos outils dans ce navigateur."
},
"guideChecklist": {
"label": "Checklist associée",
"untitled": "Checklist sans titre",
"none": "Aucune checklist",
"linkToolbox": "Associer une toolbox pour suivre une checklist",
"noCompatible": "Aucune checklist de cette toolbox ne correspond aux éléments du guide.",
"noMatches": "Cette checklist ne contient plus de correspondance avec le guide.",
"matches": "correspondances",
"unlink": "Dissocier",
"hideCompleted": "Masquer les cochés",
"completed": "Coché"
},
"aniimo": {
"title": "Aniimos",
"description": "Filtrez les variantes par nom, éléments, rôle et compétences de logis, puis retournez les cartes pour consulter leurs capacités.",
"homeDescription": "Recherche par nom, rôle, éléments et compétences de logis.",
"copyChecklist": "Copier les Aniimos visibles sans les variantes pour checklist",
"copyVariantsChecklist": "Copier toutes les variantes visibles pour checklist",
"statsTitle": "Statistiques",
"statsTotal": "Total",
"stats": { "hp": "PV", "atk": "Attaque", "p_def": "Déf. phys.", "m_def": "Déf. mag.", "break": "Rupture", "regen": "Régén." },
"filters": "Filtres",
"name": "Nom",
"searchPlaceholder": "Nom français ou anglais…",
"elements": "Éléments",
"roles": "Rôles",
"role": "Rôle",
"abilities": "Compétences de logis",
"minimum": "Niveau minimum",
"abilityLevelsHint": "0 : sans filtre · 1–4 : niveau minimum",
"abilityDisabled": "Sans filtre",
"level": "Niv.",
"match": "Correspondance",
"and": "ET",
"or": "OU",
"reset": "Réinitialiser les filtres",
"groupVariants": "Regrouper les variantes",
"variants": "Variantes",
"results": "Résultats Aniimo",
"emptyTitle": "Aucun résultat",
"emptyText": "Ajustez la recherche ou réinitialisez les filtres actifs.",
"loading": "Chargement des Aniimos…",
"error": "Impossible de charger Aniimo",
"showFront": "Afficher le portrait",
"showBack": "Afficher les capacités"
}
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3,23H13a1,1,0,0,0,1-1V10a1,1,0,0,0-1-1H3a1,1,0,0,0-1,1V22A1,1,0,0,0,3,23ZM4,11h8V21H4Zm12,7V7H7A1,1,0,0,1,7,5H17a1,1,0,0,1,1,1V18a1,1,0,0,1-2,0ZM22,2V15a1,1,0,0,1-2,0V3H11a1,1,0,0,1,0-2H21A1,1,0,0,1,22,2Z"/></svg>

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 578 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 513 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 620 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Some files were not shown because too many files have changed in this diff Show more