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,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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user