feat: écran de fin de partie + tests unitaires client et E2E Playwright
- Fix: UIScene n'était jamais lancée (active: false, jamais scene.launch)
donc l'event 'game-over' du serveur n'était jamais affiché — la partie
semblait ne pas avoir de fin. Remplacé par un panneau Vue dans GameView
(vainqueur, classement trié, bouton Quitter via vue-router). UIScene.js
supprimée (code mort).
- Extraction de client/src/utils/hudUtils.js (constantes ATTRS/couleurs et
gaugePercent) depuis GameView.vue pour les rendre testables isolément.
- Tests unitaires client (Vitest) : store Pinia (game.js) et hudUtils
(24 tests, client/test/unit/).
- Tests d'intégration E2E (Playwright, e2e/) avec de vrais clients
Socket.io et rendu Phaser réel dans Chromium :
* game-flow.spec.js : lobby → démarrage → main de 4 cartes → résolution
* item-shop.spec.js : boutique, bouton désactivé si jauge insuffisante,
achat retire l'objet pour tous + effet appliqué + affiché sur la table
* game-over.spec.js : partie complète (12 tours) → panneau de fin
webServer démarre automatiquement client+serveur ; ajout d'une route
GET /health côté Express pour permettre à Playwright de détecter un
serveur déjà démarré.
- README : documentation des 3 niveaux de tests (unitaires serveur,
unitaires client, intégration E2E) et de la nouvelle arborescence.
This commit is contained in:
1 parent
6a1c800253
commit
11cba0467b
20 files changed
+3447
-100
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
Generated
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "regnum-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "regnum-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "regnum-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Configuration Playwright pour les tests d'intégration E2E de Regnum.
|
||||
*
|
||||
* `webServer` démarre automatiquement le serveur Node/Socket.io ET le
|
||||
* serveur de dev Vite du client avant de lancer les tests (et les arrête à
|
||||
* la fin), pour que la suite soit reproductible avec une seule commande
|
||||
* (`npm test` depuis e2e/) sans dépendre de processus déjà lancés à la main.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: false, // les tests partagent le même serveur de jeu (rooms par test, mais évite la contention)
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
reporter: [['list']],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure'
|
||||
},
|
||||
|
||||
webServer: [
|
||||
{
|
||||
command: 'npm run dev',
|
||||
cwd: '../server',
|
||||
url: 'http://localhost:3000/health',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000
|
||||
},
|
||||
{
|
||||
command: 'npm run dev -- --port 5173 --strictPort',
|
||||
cwd: '../client',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 30_000
|
||||
}
|
||||
],
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] }
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { installPhaserDebugHook, createRoom, joinRoom, startGame, playCard, getGameScene } from './helpers.js'
|
||||
|
||||
/**
|
||||
* Tests d'intégration E2E : parcours de base d'une partie (2 joueurs réels
|
||||
* via de vrais WebSockets Socket.io + rendu Phaser réel dans un navigateur),
|
||||
* du lobby jusqu'à la résolution d'un tour.
|
||||
*/
|
||||
test.describe('Parcours de base d\'une partie', () => {
|
||||
test('deux joueurs peuvent créer/rejoindre une salle et démarrer la partie', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext()
|
||||
const ctx2 = await browser.newContext()
|
||||
const p1 = await ctx1.newPage()
|
||||
const p2 = await ctx2.newPage()
|
||||
await installPhaserDebugHook(p1)
|
||||
await installPhaserDebugHook(p2)
|
||||
|
||||
const roomId = await createRoom(p1, 'Alice')
|
||||
await joinRoom(p2, 'Bob', roomId)
|
||||
|
||||
await expect(p1.locator('text=Bob')).toBeVisible()
|
||||
await expect(p2.locator('text=Alice')).toBeVisible()
|
||||
|
||||
await startGame(p1)
|
||||
await expect(p1).toHaveURL(new RegExp(`/game/${roomId}`))
|
||||
await expect(p2).toHaveURL(new RegExp(`/game/${roomId}`))
|
||||
|
||||
await ctx1.close()
|
||||
await ctx2.close()
|
||||
})
|
||||
|
||||
test('chaque joueur reçoit une main de 4 cartes au démarrage', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext()
|
||||
const ctx2 = await browser.newContext()
|
||||
const p1 = await ctx1.newPage()
|
||||
const p2 = await ctx2.newPage()
|
||||
await installPhaserDebugHook(p1)
|
||||
await installPhaserDebugHook(p2)
|
||||
|
||||
const roomId = await createRoom(p1, 'Alice')
|
||||
await joinRoom(p2, 'Bob', roomId)
|
||||
await startGame(p1)
|
||||
await p1.waitForTimeout(2000)
|
||||
|
||||
const handLen1 = await p1.evaluate(() => {
|
||||
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
|
||||
return game.scene.getScene('GameScene').handCards.length
|
||||
})
|
||||
const handLen2 = await p2.evaluate(() => {
|
||||
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
|
||||
return game.scene.getScene('GameScene').handCards.length
|
||||
})
|
||||
|
||||
expect(handLen1).toBe(4)
|
||||
expect(handLen2).toBe(4)
|
||||
|
||||
await ctx1.close()
|
||||
await ctx2.close()
|
||||
})
|
||||
|
||||
test('jouer une carte fait passer en phase "waiting" puis résout le tour une fois les deux joueurs joués', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext()
|
||||
const ctx2 = await browser.newContext()
|
||||
const p1 = await ctx1.newPage()
|
||||
const p2 = await ctx2.newPage()
|
||||
await installPhaserDebugHook(p1)
|
||||
await installPhaserDebugHook(p2)
|
||||
|
||||
const roomId = await createRoom(p1, 'Alice')
|
||||
await joinRoom(p2, 'Bob', roomId)
|
||||
await startGame(p1)
|
||||
await p1.waitForTimeout(2000)
|
||||
|
||||
await playCard(p1)
|
||||
await expect(p1.locator('.phase-label')).toHaveText(/attente/)
|
||||
|
||||
await playCard(p2)
|
||||
// Résolution automatique une fois les deux joueurs joués
|
||||
await p1.waitForTimeout(1000)
|
||||
await expect(p1.locator('.phase-label')).toHaveText(/Résolution/)
|
||||
|
||||
// Après le délai serveur de résolution (3.5s), le tour suivant démarre
|
||||
await p1.waitForTimeout(4000)
|
||||
const turnAfter = await p1.evaluate(() => {
|
||||
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
|
||||
return game.scene.getScene('GameScene').store.currentTurn
|
||||
})
|
||||
expect(turnAfter).toBe(2)
|
||||
|
||||
await ctx1.close()
|
||||
await ctx2.close()
|
||||
})
|
||||
|
||||
test('les gains d\'un tour respectent la règle du voisin de gauche (aucune erreur JS pendant la résolution)', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext()
|
||||
const ctx2 = await browser.newContext()
|
||||
const p1 = await ctx1.newPage()
|
||||
const p2 = await ctx2.newPage()
|
||||
const errors = []
|
||||
p1.on('pageerror', e => errors.push(e.message))
|
||||
p2.on('pageerror', e => errors.push(e.message))
|
||||
await installPhaserDebugHook(p1)
|
||||
await installPhaserDebugHook(p2)
|
||||
|
||||
const roomId = await createRoom(p1, 'Alice')
|
||||
await joinRoom(p2, 'Bob', roomId)
|
||||
await startGame(p1)
|
||||
await p1.waitForTimeout(2000)
|
||||
|
||||
await playCard(p1, 'war')
|
||||
await playCard(p2, 'war')
|
||||
await p1.waitForTimeout(1500)
|
||||
|
||||
// Vérifier que le panneau de résolution affiche bien les 2 cartes jouées
|
||||
const resolveObjectsCount = await p1.evaluate(() => {
|
||||
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
|
||||
return game.scene.getScene('GameScene').resolveObjects.length
|
||||
})
|
||||
expect(resolveObjectsCount).toBeGreaterThan(0)
|
||||
expect(errors).toEqual([])
|
||||
|
||||
await ctx1.close()
|
||||
await ctx2.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { installPhaserDebugHook, createRoom, joinRoom, startGame, playFullTurn } from './helpers.js'
|
||||
|
||||
/**
|
||||
* Test d'intégration E2E : partie complète jusqu'au bout (12 tours), affichage
|
||||
* du panneau de fin de partie (vainqueur + classement) et bouton Quitter.
|
||||
*
|
||||
* Ce test couvre la régression corrigée : le serveur émettait bien l'event
|
||||
* 'game-over' après le 12e tour, mais rien côté client ne l'écoutait ni ne
|
||||
* l'affichait — la partie "n'avait pas de fin" du point de vue du joueur.
|
||||
*/
|
||||
test.describe('Fin de partie', () => {
|
||||
test('après 12 tours, le panneau de fin affiche un vainqueur, un classement, et un bouton Quitter fonctionnel', async ({ browser }) => {
|
||||
test.setTimeout(120_000) // 12 tours × ~4.5s de résolution serveur + marge
|
||||
|
||||
const ctx1 = await browser.newContext()
|
||||
const ctx2 = await browser.newContext()
|
||||
const p1 = await ctx1.newPage()
|
||||
const p2 = await ctx2.newPage()
|
||||
const errors = []
|
||||
p1.on('pageerror', e => errors.push(e.message))
|
||||
p2.on('pageerror', e => errors.push(e.message))
|
||||
await installPhaserDebugHook(p1)
|
||||
await installPhaserDebugHook(p2)
|
||||
|
||||
const roomId = await createRoom(p1, 'Alice')
|
||||
await joinRoom(p2, 'Bob', roomId)
|
||||
await startGame(p1)
|
||||
await p1.waitForTimeout(2000)
|
||||
|
||||
// Jouer les 12 tours. playCard (via playFullTurn) gère déjà le cas d'une
|
||||
// main vide (retourne sans rien faire) pour les tours de fin de partie où
|
||||
// le nombre de cartes en main diminue.
|
||||
for (let t = 0; t < 12; t++) {
|
||||
await playFullTurn([p1, p2])
|
||||
}
|
||||
|
||||
// Laisser le temps au serveur d'émettre 'game-over' après le dernier tour
|
||||
await p1.waitForTimeout(1000)
|
||||
|
||||
await expect(p1.locator('.game-over-overlay')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(p2.locator('.game-over-overlay')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Un vainqueur est annoncé
|
||||
const winnerText = await p1.locator('.game-over-winner').textContent()
|
||||
expect(winnerText).toMatch(/Alice|Bob/)
|
||||
|
||||
// Le classement liste bien les 2 joueurs
|
||||
const rankingCount = await p1.locator('.ranking-item').count()
|
||||
expect(rankingCount).toBe(2)
|
||||
|
||||
// Le bouton Quitter ramène à l'accueil
|
||||
await p1.click('.quit-btn')
|
||||
await expect(p1).toHaveURL('/')
|
||||
|
||||
expect(errors).toEqual([])
|
||||
|
||||
await ctx1.close()
|
||||
await ctx2.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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 meilleure carte pour l'attribut donné (ou la première carte si aucun
|
||||
* attribut n'est précisé), en lisant 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).
|
||||
*/
|
||||
export async function playCard(page, attr = null) {
|
||||
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 cardPos = await page.evaluate((attr) => {
|
||||
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
|
||||
const scene = game.scene.getScene('GameScene')
|
||||
let idx = 0
|
||||
if (attr) {
|
||||
let bestVal = -1
|
||||
scene.handCards.forEach((c, i) => {
|
||||
if (c.cardData[attr] > bestVal) { bestVal = c.cardData[attr]; idx = i }
|
||||
})
|
||||
}
|
||||
const b = scene.handCards[idx].getBounds()
|
||||
return { x: b.centerX, y: b.centerY }
|
||||
}, attr)
|
||||
|
||||
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 { x: b.centerX, y: b.centerY }
|
||||
})
|
||||
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é).
|
||||
*/
|
||||
export async function playFullTurn(pages, attrsByPage = []) {
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
await playCard(pages[i], attrsByPage[i] ?? null)
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
installPhaserDebugHook, createRoom, joinRoom, startGame,
|
||||
playCard, playFullTurn, getShopItemIds, getMyGauges, buyShopItem, hoverShopItem
|
||||
} from './helpers.js'
|
||||
|
||||
/**
|
||||
* Tests d'intégration E2E : boutique d'objets (achat, effets permanents,
|
||||
* disparition de la boutique une fois acheté, retrait bloqué si jauge
|
||||
* insuffisante).
|
||||
*/
|
||||
test.describe('Boutique d\'objets', () => {
|
||||
test('la boutique affiche les 4 objets au démarrage de la partie', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext()
|
||||
const ctx2 = await browser.newContext()
|
||||
const p1 = await ctx1.newPage()
|
||||
const p2 = await ctx2.newPage()
|
||||
await installPhaserDebugHook(p1)
|
||||
await installPhaserDebugHook(p2)
|
||||
|
||||
const roomId = await createRoom(p1, 'Alice')
|
||||
await joinRoom(p2, 'Bob', roomId)
|
||||
await startGame(p1)
|
||||
await p1.waitForTimeout(2000)
|
||||
|
||||
const ids = await getShopItemIds(p1)
|
||||
expect(ids.sort()).toEqual(['medkit', 'plow', 'royal_seal', 'trebuchet'].sort())
|
||||
|
||||
await ctx1.close()
|
||||
await ctx2.close()
|
||||
})
|
||||
|
||||
test('le bouton ACHETER n\'est pas interactif tant que la jauge est insuffisante', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext()
|
||||
const ctx2 = await browser.newContext()
|
||||
const p1 = await ctx1.newPage()
|
||||
const p2 = await ctx2.newPage()
|
||||
await installPhaserDebugHook(p1)
|
||||
await installPhaserDebugHook(p2)
|
||||
|
||||
const roomId = await createRoom(p1, 'Alice')
|
||||
await joinRoom(p2, 'Bob', roomId)
|
||||
await startGame(p1)
|
||||
await p1.waitForTimeout(2000)
|
||||
|
||||
// Jauge à 0 en tout début de partie : le bouton ACHETER du Trébuchet ne
|
||||
// doit pas être interactif (hoverShopItem retourne null dans ce cas).
|
||||
const btnCenter = await hoverShopItem(p1, 'trebuchet')
|
||||
expect(btnCenter).toBeNull()
|
||||
|
||||
await ctx1.close()
|
||||
await ctx2.close()
|
||||
})
|
||||
|
||||
test('acheter un objet le retire de la boutique pour TOUS les joueurs, et applique son effet permanent', async ({ browser }) => {
|
||||
const ctx1 = await browser.newContext()
|
||||
const ctx2 = await browser.newContext()
|
||||
const p1 = await ctx1.newPage()
|
||||
const p2 = await ctx2.newPage()
|
||||
await installPhaserDebugHook(p1)
|
||||
await installPhaserDebugHook(p2)
|
||||
|
||||
const roomId = await createRoom(p1, 'Alice')
|
||||
await joinRoom(p2, 'Bob', roomId)
|
||||
await startGame(p1)
|
||||
await p1.waitForTimeout(2000)
|
||||
|
||||
// Accumuler assez de guerre pour P1 en jouant sa meilleure carte de guerre
|
||||
// à chaque tour, pendant que P2 joue sur un autre attribut pour ne pas
|
||||
// rivaliser sur la jauge visée.
|
||||
let gauges = await getMyGauges(p1)
|
||||
let attempts = 0
|
||||
while (gauges.war < 3 && attempts < 6) {
|
||||
await playFullTurn([p1, p2], ['war', 'health'])
|
||||
gauges = await getMyGauges(p1)
|
||||
attempts++
|
||||
}
|
||||
expect(gauges.war).toBeGreaterThanOrEqual(3)
|
||||
|
||||
const idsBefore = await getShopItemIds(p1)
|
||||
expect(idsBefore).toContain('trebuchet')
|
||||
|
||||
const bought = await buyShopItem(p1, 'trebuchet')
|
||||
expect(bought).toBe(true)
|
||||
|
||||
// Jouer le tour pour déclencher la résolution de l'achat en attente
|
||||
await playFullTurn([p1, p2], ['war', 'health'])
|
||||
|
||||
const idsAfterP1 = await getShopItemIds(p1)
|
||||
const idsAfterP2 = await getShopItemIds(p2)
|
||||
expect(idsAfterP1).not.toContain('trebuchet')
|
||||
expect(idsAfterP2).not.toContain('trebuchet')
|
||||
expect(idsAfterP1.length).toBe(3)
|
||||
|
||||
// Vérifier que l'objet apparaît dans "mes objets" (posés à côté de ma main)
|
||||
const myItemsCount = await p1.evaluate(() => {
|
||||
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
|
||||
return game.scene.getScene('GameScene').myItemTokens.length
|
||||
})
|
||||
expect(myItemsCount).toBe(1)
|
||||
|
||||
// Et sous le slot de P1 depuis la perspective de P2 (adversaire)
|
||||
const opponentItemsCount = await p2.evaluate(() => {
|
||||
const game = window.__ALL_GAMES__[window.__ALL_GAMES__.length - 1]
|
||||
return game.scene.getScene('GameScene').opponentItemTokens.length
|
||||
})
|
||||
expect(opponentItemsCount).toBe(1)
|
||||
|
||||
await ctx1.close()
|
||||
await ctx2.close()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user