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
@@ -13,15 +13,20 @@ Jeu de cartes en ligne multijoueur (2-4 joueurs). Collecte des points en jouant
|
|||||||
```
|
```
|
||||||
regnum/
|
regnum/
|
||||||
├── client/ # Vue 3 + Phaser 3
|
├── client/ # Vue 3 + Phaser 3
|
||||||
│ └── src/
|
│ ├── src/
|
||||||
│ ├── views/ # HomeView, LobbyView, GameView
|
│ │ ├── views/ # HomeView, LobbyView, GameView
|
||||||
│ ├── game/ # Scènes Phaser (Boot, Game, UI) + objets
|
│ │ ├── game/ # Scènes Phaser (Boot, Game) + objets + items
|
||||||
│ ├── stores/ # Pinia store
|
│ │ ├── stores/ # Pinia store
|
||||||
│ └── services/# Socket.io client
|
│ │ ├── services/# Socket.io client
|
||||||
└── server/ # Node.js
|
│ │ └── utils/ # Fonctions pures partagées (HUD, etc.)
|
||||||
└── src/
|
│ └── test/unit/ # Tests unitaires Vitest (store, utils)
|
||||||
├── game/ # Room, RoomManager, cards
|
├── server/ # Node.js
|
||||||
└── index.js # Serveur Express + Socket.io
|
│ ├── src/
|
||||||
|
│ │ ├── game/ # Room, RoomManager, cards, items
|
||||||
|
│ │ └── index.js # Serveur Express + Socket.io
|
||||||
|
│ └── test/game/ # Tests unitaires node:test (moteur de jeu)
|
||||||
|
└── e2e/ # Tests d'intégration Playwright (client+serveur réels)
|
||||||
|
└── tests/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lancement
|
## Lancement
|
||||||
@@ -44,12 +49,38 @@ Ouvrir `http://localhost:5173`
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
Tests unitaires du moteur de jeu (serveur), avec le test runner natif de Node.js :
|
Le projet a 3 niveaux de tests, à exécuter indépendamment :
|
||||||
|
|
||||||
|
### 1. Tests unitaires — serveur (moteur de jeu)
|
||||||
|
|
||||||
|
Logique pure de `Room.js` / `cards.js` / `items.js` (règles, gains, achats, effets permanents), avec le test runner natif de Node.js :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
npm test # exécution unique
|
npm test # exécution unique
|
||||||
npm run test:watch # mode watch
|
npm run test:watch # mode watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Tests unitaires — client (store, utilitaires)
|
||||||
|
|
||||||
|
Store Pinia et fonctions pures du HUD (`hudUtils.js`), avec Vitest :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client
|
||||||
|
npm run test:unit # exécution unique
|
||||||
|
npm run test:unit:watch # mode watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Tests d'intégration E2E (client + serveur réels)
|
||||||
|
|
||||||
|
Parcours complets (créer/rejoindre une salle, jouer des tours, acheter un objet, fin de partie) pilotés dans un vrai navigateur Chromium via Playwright, avec de vrais clients Socket.io. `webServer` dans `e2e/playwright.config.js` démarre automatiquement le serveur et le client si besoin.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd e2e
|
||||||
|
npm install # première fois seulement
|
||||||
|
npx playwright install chromium # première fois seulement, si besoin
|
||||||
|
npm test # exécution unique
|
||||||
|
npm run test:ui # mode interactif (debug)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Règles
|
## Règles
|
||||||
|
|||||||
Generated
+2308
-1
File diff suppressed because it is too large.
Load diff
+9
-4
@@ -5,17 +5,22 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"phaser": "^3.80.1",
|
"phaser": "^3.80.1",
|
||||||
|
"pinia": "^2.1.0",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0"
|
||||||
"pinia": "^2.1.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.0",
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
"vite": "^5.2.0"
|
"@vue/test-utils": "^2.4.11",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Phaser from 'phaser'
|
import Phaser from 'phaser'
|
||||||
import { BootScene } from './scenes/BootScene'
|
import { BootScene } from './scenes/BootScene'
|
||||||
import { GameScene } from './scenes/GameScene'
|
import { GameScene } from './scenes/GameScene'
|
||||||
import { UIScene } from './scenes/UIScene'
|
|
||||||
|
|
||||||
export function createGame(containerId, socket, store, roomId) {
|
export function createGame(containerId, socket, store, roomId) {
|
||||||
const config = {
|
const config = {
|
||||||
@@ -10,7 +9,7 @@ export function createGame(containerId, socket, store, roomId) {
|
|||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight - 60,
|
height: window.innerHeight - 60,
|
||||||
backgroundColor: '#0d0500',
|
backgroundColor: '#0d0500',
|
||||||
scene: [BootScene, GameScene, UIScene],
|
scene: [BootScene, GameScene],
|
||||||
scale: {
|
scale: {
|
||||||
mode: Phaser.Scale.RESIZE,
|
mode: Phaser.Scale.RESIZE,
|
||||||
autoCenter: Phaser.Scale.CENTER_BOTH
|
autoCenter: Phaser.Scale.CENTER_BOTH
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import Phaser from 'phaser'
|
|
||||||
|
|
||||||
// Scène superposée pour les effets UI (notifications, fin de partie)
|
|
||||||
export class UIScene extends Phaser.Scene {
|
|
||||||
constructor() {
|
|
||||||
super({ key: 'UIScene', active: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
create() {
|
|
||||||
this.socket = this.registry.get('socket')
|
|
||||||
|
|
||||||
this.socket.on('game-over', (results) => {
|
|
||||||
this.showEndScreen(results)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
showEndScreen(results) {
|
|
||||||
const { width, height } = this.scale
|
|
||||||
|
|
||||||
// Fond semi-transparent
|
|
||||||
this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.85)
|
|
||||||
|
|
||||||
this.add.text(width / 2, height / 2 - 150, 'FIN DE PARTIE', {
|
|
||||||
fontSize: '40px',
|
|
||||||
color: '#d4a732',
|
|
||||||
fontFamily: 'Georgia'
|
|
||||||
}).setOrigin(0.5)
|
|
||||||
|
|
||||||
const sorted = [...results].sort((a, b) => b.score - a.score)
|
|
||||||
sorted.forEach((p, i) => {
|
|
||||||
const y = height / 2 - 80 + i * 50
|
|
||||||
const medal = i === 0 ? '👑 ' : `${i + 1}. `
|
|
||||||
this.add.text(width / 2, y, `${medal}${p.name} — ${p.score} pts`, {
|
|
||||||
fontSize: '22px',
|
|
||||||
color: i === 0 ? '#d4a732' : '#f5e6c8',
|
|
||||||
fontFamily: 'Georgia'
|
|
||||||
}).setOrigin(0.5)
|
|
||||||
})
|
|
||||||
|
|
||||||
const btn = this.add.text(width / 2, height / 2 + 150, '[ RETOUR AU MENU ]', {
|
|
||||||
fontSize: '18px',
|
|
||||||
color: '#1a0a00',
|
|
||||||
backgroundColor: '#d4a732',
|
|
||||||
padding: { x: 20, y: 10 },
|
|
||||||
fontFamily: 'Georgia'
|
|
||||||
}).setOrigin(0.5).setInteractive({ cursor: 'pointer' })
|
|
||||||
|
|
||||||
btn.on('pointerdown', () => {
|
|
||||||
window.location.href = '/'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -17,6 +17,13 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
const availableItems = ref([]) // ids des objets encore en stock (non achetés)
|
const availableItems = ref([]) // ids des objets encore en stock (non achetés)
|
||||||
const firstPlayerName = ref(null) // nom du joueur prioritaire pour l'achat ce tour
|
const firstPlayerName = ref(null) // nom du joueur prioritaire pour l'achat ce tour
|
||||||
|
|
||||||
|
// ── Fin de partie ────────────────────────────────────────────────────────────
|
||||||
|
const gameOverResults = ref(null) // liste des joueurs (avec score) une fois la partie terminée, sinon null
|
||||||
|
|
||||||
|
function setGameOver(results) {
|
||||||
|
gameOverResults.value = results
|
||||||
|
}
|
||||||
|
|
||||||
function setPlayer(name) {
|
function setPlayer(name) {
|
||||||
playerName.value = name
|
playerName.value = name
|
||||||
}
|
}
|
||||||
@@ -50,12 +57,14 @@ export const useGameStore = defineStore('game', () => {
|
|||||||
pendingBuyItemId.value = null
|
pendingBuyItemId.value = null
|
||||||
availableItems.value = []
|
availableItems.value = []
|
||||||
firstPlayerName.value = null
|
firstPlayerName.value = null
|
||||||
|
gameOverResults.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playerName, players, currentTurn, maxTurns,
|
playerName, players, currentTurn, maxTurns,
|
||||||
phase, hand, myTeamIndex, selectedCard,
|
phase, hand, myTeamIndex, selectedCard,
|
||||||
myItems, pendingBuyItemId, availableItems, firstPlayerName,
|
myItems, pendingBuyItemId, availableItems, firstPlayerName,
|
||||||
setPlayer, updateState, selectCard, clearSelection, reset
|
gameOverResults,
|
||||||
|
setPlayer, updateState, selectCard, clearSelection, reset, setGameOver
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Constantes et fonctions pures utilisées par le HUD (GameView.vue), extraites
|
||||||
|
* dans un module séparé pour pouvoir être testées unitairement sans monter
|
||||||
|
* le composant Vue complet (qui dépend de Phaser/Socket.io/vue-router).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ATTRS = ['food', 'war', 'health', 'diplo']
|
||||||
|
|
||||||
|
export const ATTR_ICON = { food: '🟡', war: '🔴', health: '🟢', diplo: '🔵' }
|
||||||
|
|
||||||
|
export const ATTR_COLOR = {
|
||||||
|
food: '#f0c030',
|
||||||
|
war: '#dd2222',
|
||||||
|
health: '#22aa44',
|
||||||
|
diplo: '#3399ee'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TEAM_CSS = {
|
||||||
|
0: '#cc2200',
|
||||||
|
1: '#1155cc',
|
||||||
|
2: '#1a7a2e',
|
||||||
|
3: '#b8860b'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PHASE_LABEL = {
|
||||||
|
selecting: 'Sélectionnez une carte',
|
||||||
|
waiting: 'En attente des autres...',
|
||||||
|
resolve: 'Résolution du tour...',
|
||||||
|
end: 'Partie terminée !'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit la valeur d'une jauge en pourcentage pour la barre de progression
|
||||||
|
* du HUD, en la bornant à [0, 100] par rapport au maximum théorique
|
||||||
|
* (valeur d'attribut max par carte × nombre de tours).
|
||||||
|
*/
|
||||||
|
export function gaugePercent(val, maxAttrPerTurn = 6, maxTurns = 12) {
|
||||||
|
const max = maxAttrPerTurn * maxTurns
|
||||||
|
if (max <= 0) return 0
|
||||||
|
return Math.min(100, Math.max(0, Math.round((val / max) * 100)))
|
||||||
|
}
|
||||||
+150
-28
@@ -46,49 +46,62 @@
|
|||||||
|
|
||||||
<!-- Canvas Phaser -->
|
<!-- Canvas Phaser -->
|
||||||
<div id="phaser-container" ref="phaserContainer"></div>
|
<div id="phaser-container" ref="phaserContainer"></div>
|
||||||
|
|
||||||
|
<!-- Panneau de fin de partie -->
|
||||||
|
<div v-if="gameStore.gameOverResults" class="game-over-overlay">
|
||||||
|
<div class="game-over-panel">
|
||||||
|
<h1 class="game-over-title">🏆 Partie terminée</h1>
|
||||||
|
<p class="game-over-winner">
|
||||||
|
<strong :style="{ color: teamCss[winner?.teamIndex] }">{{ winner?.name }}</strong>
|
||||||
|
remporte la partie !
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="ranking-list">
|
||||||
|
<li
|
||||||
|
v-for="(player, i) in ranking"
|
||||||
|
:key="i"
|
||||||
|
class="ranking-item"
|
||||||
|
:class="{ 'ranking-item--winner': i === 0 }"
|
||||||
|
>
|
||||||
|
<span class="ranking-rank">{{ i === 0 ? '👑' : `${i + 1}.` }}</span>
|
||||||
|
<span class="ranking-name" :style="{ color: teamCss[player.teamIndex] }">{{ player.name }}</span>
|
||||||
|
<span class="ranking-score">{{ player.score ?? 0 }} pts</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<button class="quit-btn" @click="quitGame">Quitter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useGameStore } from '@/stores/game'
|
import { useGameStore } from '@/stores/game'
|
||||||
import { useSocket, leaveRoom } from '@/services/socket'
|
import { useSocket, leaveRoom } from '@/services/socket'
|
||||||
import { createGame } from '@/game/GameFactory'
|
import { createGame } from '@/game/GameFactory'
|
||||||
|
import { ATTRS, ATTR_ICON, ATTR_COLOR, TEAM_CSS, PHASE_LABEL, gaugePercent } from '@/utils/hudUtils'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const gameStore = useGameStore()
|
const gameStore = useGameStore()
|
||||||
const socket = useSocket()
|
const socket = useSocket()
|
||||||
const phaserContainer = ref(null)
|
const phaserContainer = ref(null)
|
||||||
let phaserGame = null
|
let phaserGame = null
|
||||||
|
|
||||||
const ATTRS = ['food', 'war', 'health', 'diplo']
|
// Classement trié par score décroissant, calculé à partir des résultats
|
||||||
|
// envoyés par le serveur à la fin de la partie (event 'game-over').
|
||||||
|
const ranking = computed(() =>
|
||||||
|
[...(gameStore.gameOverResults ?? [])].sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||||
|
)
|
||||||
|
const winner = computed(() => ranking.value[0] ?? null)
|
||||||
|
|
||||||
const attrIcon = { food: '🟡', war: '🔴', health: '🟢', diplo: '🔵' }
|
// Alias locaux (casse historique du template) pointant vers le module utilitaire partagé
|
||||||
const attrColor = {
|
const attrIcon = ATTR_ICON
|
||||||
food: '#f0c030',
|
const attrColor = ATTR_COLOR
|
||||||
war: '#dd2222',
|
const teamCss = TEAM_CSS
|
||||||
health: '#22aa44',
|
const phaseLabel = PHASE_LABEL
|
||||||
diplo: '#3399ee'
|
|
||||||
}
|
|
||||||
const teamCss = {
|
|
||||||
0: '#cc2200',
|
|
||||||
1: '#1155cc',
|
|
||||||
2: '#1a7a2e',
|
|
||||||
3: '#b8860b'
|
|
||||||
}
|
|
||||||
const phaseLabel = {
|
|
||||||
selecting: 'Sélectionnez une carte',
|
|
||||||
waiting: 'En attente des autres...',
|
|
||||||
resolve: 'Résolution du tour...',
|
|
||||||
end: 'Partie terminée !'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Max théorique d'une jauge pour la barre (sum max attr * maxTurns)
|
|
||||||
function gaugePercent(val) {
|
|
||||||
const max = 6 * 12 // valeur max théorique d'une jauge
|
|
||||||
return Math.min(100, Math.round((val / max) * 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
phaserGame = createGame('phaser-container', socket, gameStore, route.params.roomId)
|
phaserGame = createGame('phaser-container', socket, gameStore, route.params.roomId)
|
||||||
@@ -96,6 +109,14 @@ onMounted(() => {
|
|||||||
socket.on('game-state', (state) => {
|
socket.on('game-state', (state) => {
|
||||||
gameStore.updateState(state)
|
gameStore.updateState(state)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Le serveur envoie la liste des joueurs (avec score final) une fois les
|
||||||
|
// 12 tours écoulés (ou tous les decks épuisés) — sans cet écouteur, la
|
||||||
|
// partie se terminait silencieusement côté serveur mais rien n'était
|
||||||
|
// jamais affiché au joueur (pas de vainqueur, pas de moyen de quitter).
|
||||||
|
socket.on('game-over', (results) => {
|
||||||
|
gameStore.setGameOver(results)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -103,6 +124,11 @@ onUnmounted(() => {
|
|||||||
gameStore.reset()
|
gameStore.reset()
|
||||||
if (phaserGame) phaserGame.destroy(true)
|
if (phaserGame) phaserGame.destroy(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Quitte la partie et retourne à l'accueil (déclenche leaveRoom via onUnmounted). */
|
||||||
|
function quitGame() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -214,4 +240,100 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* ── Canvas ─────────────────────────────────────────────────── */
|
/* ── Canvas ─────────────────────────────────────────────────── */
|
||||||
#phaser-container { flex: 1; overflow: hidden; }
|
#phaser-container { flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── Panneau de fin de partie ───────────────────────────────── */
|
||||||
|
.game-over-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-panel {
|
||||||
|
background: #1a0a00;
|
||||||
|
border: 2px solid #d4a732;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 48px;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 90vw;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 40px rgba(212, 167, 50, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-title {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #d4a732;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-over-winner {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #f5e6c8;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #2a1500;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-item--winner {
|
||||||
|
background: #3a2400;
|
||||||
|
border: 1px solid #d4a732;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-rank {
|
||||||
|
width: 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-name {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ranking-score {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f5e6c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-btn {
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a0a00;
|
||||||
|
background: #d4a732;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quit-btn:hover {
|
||||||
|
background: #e8bc4a;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useGameStore } from '../../src/stores/game'
|
||||||
|
|
||||||
|
describe('stores/game (Pinia)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a un état initial vide/par défaut', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
expect(store.playerName).toBe('')
|
||||||
|
expect(store.players).toEqual([])
|
||||||
|
expect(store.currentTurn).toBe(0)
|
||||||
|
expect(store.maxTurns).toBe(12)
|
||||||
|
expect(store.phase).toBe('idle')
|
||||||
|
expect(store.hand).toEqual([])
|
||||||
|
expect(store.myTeamIndex).toBe(0)
|
||||||
|
expect(store.selectedCard).toBeNull()
|
||||||
|
expect(store.myItems).toEqual([])
|
||||||
|
expect(store.pendingBuyItemId).toBeNull()
|
||||||
|
expect(store.availableItems).toEqual([])
|
||||||
|
expect(store.firstPlayerName).toBeNull()
|
||||||
|
expect(store.gameOverResults).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setPlayer met à jour playerName', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
store.setPlayer('Alice')
|
||||||
|
expect(store.playerName).toBe('Alice')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateState', () => {
|
||||||
|
it('met à jour tous les champs de base depuis un game-state serveur', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
store.updateState({
|
||||||
|
players: [{ name: 'Alice', score: 5 }],
|
||||||
|
currentTurn: 3,
|
||||||
|
maxTurns: 12,
|
||||||
|
phase: 'selecting',
|
||||||
|
hand: [{ id: 'queen' }],
|
||||||
|
myTeamIndex: 1
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.players).toEqual([{ name: 'Alice', score: 5 }])
|
||||||
|
expect(store.currentTurn).toBe(3)
|
||||||
|
expect(store.maxTurns).toBe(12)
|
||||||
|
expect(store.phase).toBe('selecting')
|
||||||
|
expect(store.hand).toEqual([{ id: 'queen' }])
|
||||||
|
expect(store.myTeamIndex).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retombe sur les valeurs par défaut si les champs sont absents (sauf hand/items qui restent inchangés)', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
store.updateState({})
|
||||||
|
|
||||||
|
expect(store.players).toEqual([])
|
||||||
|
expect(store.currentTurn).toBe(0)
|
||||||
|
expect(store.maxTurns).toBe(12)
|
||||||
|
expect(store.phase).toBe('idle')
|
||||||
|
expect(store.myTeamIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne touche PAS à hand si state.hand est undefined (évite d\'effacer la main en cours)', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
store.updateState({ hand: [{ id: 'prince' }] })
|
||||||
|
store.updateState({ phase: 'waiting' }) // pas de champ hand cette fois
|
||||||
|
|
||||||
|
expect(store.hand).toEqual([{ id: 'prince' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('met à jour les champs liés aux objets uniquement s\'ils sont fournis', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
store.updateState({
|
||||||
|
myItems: ['trebuchet'],
|
||||||
|
pendingBuyItemId: 'plow',
|
||||||
|
availableItems: ['medkit', 'royal_seal'],
|
||||||
|
firstPlayerName: 'Bob'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.myItems).toEqual(['trebuchet'])
|
||||||
|
expect(store.pendingBuyItemId).toBe('plow')
|
||||||
|
expect(store.availableItems).toEqual(['medkit', 'royal_seal'])
|
||||||
|
expect(store.firstPlayerName).toBe('Bob')
|
||||||
|
|
||||||
|
// Un second appel sans ces champs ne doit pas les réinitialiser
|
||||||
|
store.updateState({ phase: 'resolve' })
|
||||||
|
expect(store.myItems).toEqual(['trebuchet'])
|
||||||
|
expect(store.pendingBuyItemId).toBe('plow')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepte explicitement pendingBuyItemId=null (achat annulé/résolu) sans le confondre avec "absent"', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
store.updateState({ pendingBuyItemId: 'plow' })
|
||||||
|
store.updateState({ pendingBuyItemId: null })
|
||||||
|
expect(store.pendingBuyItemId).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectCard / clearSelection', () => {
|
||||||
|
it('selectCard définit la carte sélectionnée', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
const card = { id: 'knight', war: 5 }
|
||||||
|
store.selectCard(card)
|
||||||
|
expect(store.selectedCard).toEqual(card)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clearSelection remet selectedCard à null', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
store.selectCard({ id: 'knight' })
|
||||||
|
store.clearSelection()
|
||||||
|
expect(store.selectedCard).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('setGameOver', () => {
|
||||||
|
it('définit gameOverResults avec les résultats fournis', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
const results = [{ name: 'Alice', score: 42 }, { name: 'Bob', score: 30 }]
|
||||||
|
store.setGameOver(results)
|
||||||
|
expect(store.gameOverResults).toEqual(results)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('remet tout l\'état à ses valeurs par défaut', () => {
|
||||||
|
const store = useGameStore()
|
||||||
|
store.setPlayer('Alice')
|
||||||
|
store.updateState({
|
||||||
|
players: [{ name: 'Alice' }],
|
||||||
|
currentTurn: 5,
|
||||||
|
phase: 'resolve',
|
||||||
|
hand: [{ id: 'queen' }],
|
||||||
|
myTeamIndex: 2,
|
||||||
|
myItems: ['trebuchet'],
|
||||||
|
pendingBuyItemId: 'plow',
|
||||||
|
availableItems: ['medkit'],
|
||||||
|
firstPlayerName: 'Bob'
|
||||||
|
})
|
||||||
|
store.selectCard({ id: 'queen' })
|
||||||
|
store.setGameOver([{ name: 'Alice', score: 10 }])
|
||||||
|
|
||||||
|
store.reset()
|
||||||
|
|
||||||
|
expect(store.players).toEqual([])
|
||||||
|
expect(store.currentTurn).toBe(0)
|
||||||
|
expect(store.maxTurns).toBe(12)
|
||||||
|
expect(store.phase).toBe('idle')
|
||||||
|
expect(store.hand).toEqual([])
|
||||||
|
expect(store.myTeamIndex).toBe(0)
|
||||||
|
expect(store.selectedCard).toBeNull()
|
||||||
|
expect(store.myItems).toEqual([])
|
||||||
|
expect(store.pendingBuyItemId).toBeNull()
|
||||||
|
expect(store.availableItems).toEqual([])
|
||||||
|
expect(store.firstPlayerName).toBeNull()
|
||||||
|
expect(store.gameOverResults).toBeNull()
|
||||||
|
// playerName n'est PAS réinitialisé par reset() (comportement existant :
|
||||||
|
// le nom du joueur est conservé pour rejouer une partie sans le re-saisir)
|
||||||
|
expect(store.playerName).toBe('Alice')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ATTRS, ATTR_ICON, ATTR_COLOR, TEAM_CSS, PHASE_LABEL, gaugePercent } from '../../src/utils/hudUtils'
|
||||||
|
|
||||||
|
describe('utils/hudUtils', () => {
|
||||||
|
describe('constantes', () => {
|
||||||
|
it('ATTRS contient les 4 attributs du jeu', () => {
|
||||||
|
expect(ATTRS).toEqual(['food', 'war', 'health', 'diplo'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ATTR_ICON a une icône pour chaque attribut', () => {
|
||||||
|
ATTRS.forEach(attr => expect(ATTR_ICON[attr]).toBeTruthy())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ATTR_COLOR a une couleur pour chaque attribut', () => {
|
||||||
|
ATTRS.forEach(attr => expect(ATTR_COLOR[attr]).toMatch(/^#[0-9a-f]{6}$/i))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('TEAM_CSS couvre les 4 emplacements d\'équipe possibles (0 à 3, max 4 joueurs)', () => {
|
||||||
|
expect(Object.keys(TEAM_CSS)).toEqual(['0', '1', '2', '3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PHASE_LABEL couvre toutes les phases de jeu émises par le serveur', () => {
|
||||||
|
expect(Object.keys(PHASE_LABEL).sort()).toEqual(
|
||||||
|
['end', 'resolve', 'selecting', 'waiting'].sort()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gaugePercent', () => {
|
||||||
|
it('retourne 0 pour une valeur de 0', () => {
|
||||||
|
expect(gaugePercent(0)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retourne 100 pour la valeur maximale théorique (6 * 12 = 72)', () => {
|
||||||
|
expect(gaugePercent(72)).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calcule un pourcentage proportionnel pour une valeur intermédiaire', () => {
|
||||||
|
expect(gaugePercent(36)).toBe(50) // 36/72 = 50%
|
||||||
|
})
|
||||||
|
|
||||||
|
it('borne à 100 même si la valeur dépasse le maximum théorique', () => {
|
||||||
|
expect(gaugePercent(200)).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne retourne jamais de pourcentage négatif pour une valeur négative', () => {
|
||||||
|
expect(gaugePercent(-10)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepte des paramètres maxAttrPerTurn/maxTurns personnalisés', () => {
|
||||||
|
expect(gaugePercent(5, 5, 10)).toBe(10) // 5 / (5*10) = 10%
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ne divise pas par zéro si max=0', () => {
|
||||||
|
expect(gaugePercent(5, 0, 12)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arrondit au pourcentage entier le plus proche', () => {
|
||||||
|
// 10/72 = 13.888...% → arrondi à 14
|
||||||
|
expect(gaugePercent(10)).toBe(14)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
// Exclure les tests d'intégration/E2E Playwright (dossier séparé,
|
||||||
|
// exécutés via `npx playwright test`, pas via Vitest).
|
||||||
|
exclude: ['node_modules/**', 'e2e/**']
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,13 @@ const app = express()
|
|||||||
app.use(cors())
|
app.use(cors())
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
|
// Route de santé minimale : utilisée notamment par le healthcheck HTTP de
|
||||||
|
// Playwright (config e2e/playwright.config.js, webServer.url) pour détecter
|
||||||
|
// qu'un serveur est déjà démarré et éviter un conflit de port (EADDRINUSE)
|
||||||
|
// en local. Sans route HTTP définie, toute requête renvoyait 404, que
|
||||||
|
// Playwright ne considère pas comme "serveur prêt".
|
||||||
|
app.get('/health', (req, res) => res.status(200).json({ status: 'ok' }))
|
||||||
|
|
||||||
const server = http.createServer(app)
|
const server = http.createServer(app)
|
||||||
const io = new Server(server, {
|
const io = new Server(server, {
|
||||||
cors: { origin: '*', methods: ['GET', 'POST'] }
|
cors: { origin: '*', methods: ['GET', 'POST'] }
|
||||||
|
|||||||
Reference in new issue
Block a user