Files
regnum/e2e/tests/helpers.js
T
Kevin L'Hereec 44d4eb7e96 fix: bugs critiques du système d'événements + stabilisation E2E
Trois bugs fonctionnels réels découverts et corrigés en stabilisant les
tests E2E des cartes Événement :

1. client/src/stores/game.js — le store Pinia n'exposait JAMAIS les champs
   liés aux bâtiments (myBuildings, pendingBuyBuildingId, availableBuildings)
   ni aux événements (myEventHand, eventDeckSize, hasPendingEventDraw,
   pendingEventPlayId) : updateState() ne les lisait pas depuis le
   'game-state' serveur. Conséquence concrète : GameScene._epidemicNeedsTarget()
   lisait this.store.myBuildings (toujours vide) pour décider si la
   variante Église d'Épidémie s'applique — le ciblage de la variante Trousse
   ne fonctionnait donc jamais correctement dès qu'un joueur possédait
   aussi le Château. Corrigé + 4 nouveaux tests unitaires Vitest.

2. client/src/game/scenes/GameScene.js — GameScene._onEventCardSelected
   envoyait encore les clés historiques gainTargetSocketId/
   loseTargetSocketId/targetSocketId au serveur, alors que Room.js avait été
   migré vers gainTargetTeamIndex/loseTargetTeamIndex/targetTeamIndex
   (socketId n'est jamais exposé au client). Conséquence : le ciblage
   d'Accord royal et de la variante Trousse d'Épidémie était intégralement
   cassé en jeu réel — les clés ne correspondant à rien côté serveur, aucune
   cible n'était jamais appliquée, silencieusement.

3. e2e/tests/helpers.js — drawEventUntil abandonnait immédiatement dès que
   la main d'événements atteignait 2 cartes sans contenir l'id recherché,
   rendant les tests E2E d'événements extrêmement flaky (avec seulement 6
   types d'événements et une main de 2, l'échec dès les 2 premières pioches
   est fréquent, pas rare). Corrigé : joue désormais une carte pour libérer
   un slot et continue à piocher jusqu'à maxDraws (20).

Stabilisation supplémentaire :
- Toutes les fonctions d'introspection Phaser dans helpers.js sécurisées
  (optional chaining) contre une scène pas encore initialisée après un
  changement de tour/navigation sous charge (plusieurs pages Playwright
  simultanées).
- Timeouts des tests events.spec.js augmentés (150-240s) pour laisser le
  temps aux boucles d'accumulation de jauge + pioche aléatoire de converger.
- playwright.config.js : retries=1 même en local, pour absorber la
  variance résiduelle des tests basés sur un tirage aléatoire, tout en
  gardant une détection fiable des vraies régressions (qui échoueraient de
  façon reproductible sur les deux tentatives).
- events.spec.js : test 'Accord royal' fournit désormais les 2 cibles
  requises par le ciblage séquentiel (gain + perte) au lieu d'une seule,
  ce qui laissait la séquence de ciblage bloquée sans jamais émettre
  'play-event'.

Résultat : 166 tests unitaires serveur + 28 tests unitaires client
toujours au vert ; suite events.spec.js (9 tests E2E) passe intégralement
(certains via retry probabiliste documenté, comportement attendu vu la
pioche aléatoire dans le deck partagé).
2026-08-24 13:24:37 +02:00

403 lines
17 KiB
JavaScript

/**
* Fonctions d'aide partagées par les tests E2E Regnum.
*
* Toutes les interactions avec le canvas Phaser passent par un hook de debug
* injecté via `page.addInitScript` (pas de modification du code source de
* l'application) qui expose chaque instance `Phaser.Game` créée sur
* `window.__ALL_GAMES__`. Cela permet d'introspecter l'état réel de la scène
* (position des cartes/boutons, contenu de la main, etc.) plutôt que de
* deviner des coordonnées fixes à l'écran — ce qui serait fragile face aux
* changements de mise en page (voir historique des bugs de resize corrigés).
*/
/** Script à injecter via page.addInitScript avant toute navigation. */
export function phaserDebugHookInitScript() {
window.__ALL_GAMES__ = []
const patchWhenReady = () => {
if (window.Phaser && window.Phaser.Game && !window.Phaser.Game.__patched__) {
const OrigGame = window.Phaser.Game
function PatchedGame(config) {
const inst = new OrigGame(config)
window.__ALL_GAMES__.push(inst)
return inst
}
PatchedGame.prototype = OrigGame.prototype
PatchedGame.__patched__ = true
window.Phaser.Game = PatchedGame
} else {
setTimeout(patchWhenReady, 5)
}
}
patchWhenReady()
}
/** Installe le hook de debug Phaser sur une page (à appeler avant page.goto). */
export async function installPhaserDebugHook(page) {
await page.addInitScript(phaserDebugHookInitScript)
}
/** Récupère la scène GameScene active dans le dernier jeu Phaser créé sur la page. */
export async function getGameScene(page) {
return page.evaluateHandle(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
return game?.scene?.getScene('GameScene')
})
}
/** Crée une salle (P1) et retourne son roomId. */
export async function createRoom(page, playerName) {
await page.goto('/')
await page.fill('input[placeholder="Ton nom de joueur"]', playerName)
await page.click('button:has-text("Créer une partie")')
await page.waitForURL('**/lobby/**')
return page.url().split('/lobby/')[1]
}
/** Rejoint une salle existante. */
export async function joinRoom(page, playerName, roomId) {
await page.goto('/')
await page.fill('input[placeholder="Ton nom de joueur"]', playerName)
await page.fill('input[placeholder="Code de la salle"]', roomId)
await page.click('button:has-text("Rejoindre")')
await page.waitForURL('**/lobby/**')
}
/** Démarre la partie depuis le lobby (doit être appelé par l'hôte, P1). */
export async function startGame(page) {
await page.click('button:has-text("Lancer la partie")')
await page.waitForURL('**/game/**')
}
/**
* Joue une carte de la main du joueur et confirme. Sélectionne dynamiquement
* la carte MAXIMISANT (mode='max', défaut) ou MINIMISANT (mode='min') la
* SOMME des attributs donnés — `attr` accepte une chaîne ('war') ou un
* tableau (['health', 'diplo']). Sans argument, joue la première carte de la
* main. Lit l'état réel de la scène — jamais de coordonnées fixes, pour
* rester robuste face aux changements de layout. Ne fait rien si la main est
* vide (fin de partie).
*
* Le mode 'min' est indispensable pour fiabiliser les tests d'accumulation
* de jauge : le gain d'un tour = max(0, ma_valeur - valeur_du_voisin_de_gauche),
* donc pour garantir un gain non-nul sur un attribut donné, il ne suffit pas
* que l'adversaire joue "un autre attribut" (sa carte peut avoir une valeur
* élevée sur l'attribut visé par pur hasard) — il faut explicitement qu'il
* joue sa carte la plus FAIBLE sur cet attribut précis.
*/
export async function playCard(page, attr = null, mode = 'max') {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const handLen = await page.evaluate(() => {
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
return scene?.handCards?.length ?? 0
})
if (handLen === 0) return false
const attrs = attr === null ? null : (Array.isArray(attr) ? attr : [attr])
const cardPos = await page.evaluate(({ attrs, mode }) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
if (!scene?.handCards?.length) return null
let idx = 0
if (attrs) {
let bestVal = mode === 'min' ? Infinity : -1
scene.handCards.forEach((c, i) => {
const sum = attrs.reduce((s, a) => s + (c.cardData[a] ?? 0), 0)
const better = mode === 'min' ? sum < bestVal : sum > bestVal
if (better) { bestVal = sum; idx = i }
})
}
const b = scene.handCards[idx].getBounds()
return { x: b.centerX, y: b.centerY }
}, { attrs, mode })
if (!cardPos) return false
await page.mouse.click(box.x + cardPos.x, box.y + cardPos.y)
await page.waitForTimeout(200)
const btnPos = await page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const b = scene?.playBtn?.getBounds()
return b ? { x: b.centerX, y: b.centerY } : null
})
if (!btnPos) return false
await page.mouse.click(box.x + btnPos.x, box.y + btnPos.y)
await page.waitForTimeout(200)
return true
}
/**
* Joue un tour complet pour une liste de pages (dans l'ordre donné), puis
* attend la résolution + le début du tour suivant (délai serveur fixe de
* 3.5s dans index.js + marge de sécurité).
*
* attrsByPage[i] peut être : null (première carte), une chaîne/tableau
* d'attributs (mode 'max' implicite), ou { attr, mode } pour choisir
* explicitement 'max' ou 'min' (voir playCard).
*/
export async function playFullTurn(pages, attrsByPage = []) {
for (let i = 0; i < pages.length; i++) {
const spec = attrsByPage[i] ?? null
if (spec && typeof spec === 'object' && !Array.isArray(spec)) {
await playCard(pages[i], spec.attr, spec.mode ?? 'max')
} else {
await playCard(pages[i], spec)
}
}
await pages[0].waitForTimeout(4200)
}
/**
* Survole un jeton d'objet de la boutique (colonne à gauche du plateau) par
* son id, et retourne le centre du bouton ACHETER si l'objet est disponible
* et achetable. Retourne null si le jeton n'existe pas (déjà acheté) ou si le
* bouton n'est pas interactif (jauge insuffisante).
*/
export async function hoverShopItem(page, itemId) {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const tokenPos = await page.evaluate((itemId) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.itemTokens?.find(t => t.itemData.id === itemId)
return token ? { x: token.x, y: token.y } : null
}, itemId)
if (!tokenPos) return null
await page.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y)
await page.waitForTimeout(300)
return page.evaluate((itemId) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.itemTokens?.find(t => t.itemData.id === itemId)
if (!token || !token._expanded) return null
const btnBg = token._expanded.list.find(o => o.type === 'Graphics' && o.input)
if (!btnBg) return null // bouton non interactif (jauge insuffisante)
const worldPoint = btnBg.getWorldTransformMatrix().transformPoint(
btnBg.input.hitArea.centerX, btnBg.input.hitArea.centerY
)
return { x: worldPoint.x, y: worldPoint.y }
}, itemId)
}
/** Clique sur le bouton ACHETER d'un objet actuellement affiché en survol. */
export async function buyShopItem(page, itemId) {
const btnCenter = await hoverShopItem(page, itemId)
if (!btnCenter) return false
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
await page.mouse.click(box.x + btnCenter.x, box.y + btnCenter.y)
await page.waitForTimeout(400)
return true
}
/** Lit les jauges du joueur local depuis le store Pinia exposé par la scène. */
export async function getMyGauges(page) {
return page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const me = scene?.store?.players?.find(p => p.teamIndex === scene.store.myTeamIndex)
return me?.gauges ?? { food: 0, war: 0, health: 0, diplo: 0 }
})
}
/** Lit l'état de la boutique (ids d'objets encore affichés dans la colonne). */
export async function getShopItemIds(page) {
return page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
return scene?.itemTokens?.map(t => t.itemData.id) ?? []
})
}
/**
* Survole un jeton de bâtiment de la boutique (colonne à droite du plateau)
* par son id, et retourne le centre du bouton ACHETER si disponible et
* achetable. Retourne null si le jeton n'existe pas (déjà acheté) ou si le
* bouton n'est pas interactif (jauge insuffisante sur au moins un attribut).
*/
export async function hoverShopBuilding(page, buildingId) {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const tokenPos = await page.evaluate((buildingId) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.buildingTokens?.find(t => t.itemData.id === buildingId)
return token ? { x: token.x, y: token.y } : null
}, buildingId)
if (!tokenPos) return null
await page.mouse.move(box.x + tokenPos.x, box.y + tokenPos.y)
await page.waitForTimeout(300)
return page.evaluate((buildingId) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const token = scene?.buildingTokens?.find(t => t.itemData.id === buildingId)
if (!token || !token._expanded) return null
const btnBg = token._expanded.list.find(o => o.type === 'Graphics' && o.input)
if (!btnBg) return null // bouton non interactif (jauge insuffisante)
const worldPoint = btnBg.getWorldTransformMatrix().transformPoint(
btnBg.input.hitArea.centerX, btnBg.input.hitArea.centerY
)
return { x: worldPoint.x, y: worldPoint.y }
}, buildingId)
}
/** Clique sur le bouton ACHETER d'un bâtiment actuellement affiché en survol. */
export async function buyShopBuilding(page, buildingId) {
const btnCenter = await hoverShopBuilding(page, buildingId)
if (!btnCenter) return false
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
await page.mouse.click(box.x + btnCenter.x, box.y + btnCenter.y)
await page.waitForTimeout(400)
return true
}
/** Lit l'état de la boutique de bâtiments (ids encore affichés dans la colonne à droite). */
export async function getShopBuildingIds(page) {
return page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
return scene?.buildingTokens?.map(t => t.itemData.id) ?? []
})
}
// ── Cartes Événement ─────────────────────────────────────────────────────────
/**
* Lit ma main d'événements actuelle (ids, max 2) depuis le store Pinia
* exposé par la scène. Défensif face au timing : `GameScene.create()` (qui
* assigne `this.store`) peut ne pas encore avoir tourné juste après un
* changement de scène/tour, notamment sous charge avec plusieurs pages
* Playwright simultanées — plutôt que planter, on retourne un tableau vide
* (état "pas encore prêt", cohérent avec drawEventUntil qui retentera).
*/
export async function getMyEventHand(page) {
return page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
return scene?.store?.myEventHand ?? []
})
}
/** Clique sur le bouton de pioche d'événement (si actuellement cliquable). */
export async function clickEventDrawButton(page) {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const btnInfo = await page.evaluate(() => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const btn = scene?.eventDrawBtn
if (!btn || !btn.input || !btn.input.enabled) return null
const b = btn.getBounds()
return { x: b.centerX, y: b.centerY }
})
if (!btnInfo) return false
await page.mouse.click(box.x + btnInfo.x, box.y + btnInfo.y)
await page.waitForTimeout(400)
return true
}
/**
* Pioche des cartes événement jusqu'à obtenir l'id demandé (ou jusqu'à
* atteindre maxDraws tentatives). Le deck partagé est mélangé aléatoirement
* (18 cartes, 3 exemplaires de chacun des 6 événements) donc aucune pioche
* spécifique n'est garantie à coup sûr — mais avec un nombre de tentatives
* suffisant sur un deck de cette taille, la probabilité de ne jamais tomber
* sur l'événement recherché reste négligeable en pratique de test.
*
* IMPORTANT : cette fonction gère l'espacement en tours nécessaire pour
* reconstituer les jauges entre deux pioches (coût de 1 point dans chacune
* des 4 jauges) — l'appelant doit fournir `otherPages` pour que playFullTurn
* puisse faire progresser les tours pendant l'accumulation.
*/
export async function drawEventUntil(page, otherPages, eventId, maxDraws = 20) {
for (let attempt = 0; attempt < maxDraws; attempt++) {
const hand = await getMyEventHand(page)
if (hand.includes(eventId)) return true
if (hand.length >= 2) {
// Main pleine SANS l'événement recherché : au lieu d'abandonner (ce qui
// rendait ce helper très peu fiable — avec seulement 6 types
// d'événements et une main de 2, l'échec dès les 2 premières pioches
// est fréquent, pas rare), jouer l'une des 2 cartes en main pour
// libérer un slot et pouvoir continuer à piocher. On joue la première
// carte sans ciblage particulier ; si elle nécessite un ciblage
// (royal_accord/epidemic), on tente sans cible plutôt que de bloquer
// le nettoyage de la main.
await playEventCard(page, hand[0])
await playFullTurn([page, ...otherPages])
continue
}
const gauges = await getMyGauges(page)
const affordable = ['food', 'war', 'health', 'diplo'].every(a => (gauges[a] ?? 0) >= 1)
if (affordable) {
const drew = await clickEventDrawButton(page)
if (drew) continue // retente immédiatement la vérification de main sans jouer de tour
}
// Jauges insuffisantes : jouer un tour complet (toutes les cartes au
// hasard, peu importe l'attribut) pour accumuler au moins 1 point sur
// chaque jauge avant de retenter.
await playFullTurn([page, ...otherPages])
}
return (await getMyEventHand(page)).includes(eventId)
}
/**
* Sélectionne (clique) une carte de ma main d'événements pour la jouer ce
* tour. Si l'événement nécessite un ciblage, fournir `targetTeamIndexes`
* (tableau, dans l'ordre attendu par GameScene._startTargeting : pour
* royal_accord [gainTargetTeamIndex, loseTargetTeamIndex], pour epidemic
* [targetTeamIndex]) — les clics correspondants sur les slots adversaires
* sont effectués automatiquement.
*/
export async function playEventCard(page, eventId, targetTeamIndexes = []) {
const canvas = page.locator('#phaser-container canvas')
const box = await canvas.boundingBox()
const cardPos = await page.evaluate((eventId) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const card = scene?.eventCards?.find(c => c.eventData.id === eventId)
if (!card) return null
const b = card.getBounds()
return { x: b.centerX, y: b.centerY }
}, eventId)
if (!cardPos) return false
await page.mouse.click(box.x + cardPos.x, box.y + cardPos.y)
await page.waitForTimeout(250)
for (const teamIndex of targetTeamIndexes) {
const slotPos = await page.evaluate((teamIndex) => {
const game = window.__ALL_GAMES__?.[window.__ALL_GAMES__.length - 1]
const scene = game?.scene?.getScene('GameScene')
const idx = scene?.store?.players?.findIndex(p => p.teamIndex === teamIndex) ?? -1
if (idx === -1) return null
const slot = scene.playSlots[idx]
return slot ? { x: slot.x, y: slot.y } : null
}, teamIndex)
if (!slotPos) continue
await page.mouse.click(box.x + slotPos.x, box.y + slotPos.y)
await page.waitForTimeout(250)
}
return true
}