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
Generated
+2308
-1
File diff suppressed because it is too large.
Load diff
+9
-4
@@ -5,17 +5,22 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"phaser": "^3.80.1",
|
||||
"pinia": "^2.1.0",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0"
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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 { BootScene } from './scenes/BootScene'
|
||||
import { GameScene } from './scenes/GameScene'
|
||||
import { UIScene } from './scenes/UIScene'
|
||||
|
||||
export function createGame(containerId, socket, store, roomId) {
|
||||
const config = {
|
||||
@@ -10,7 +9,7 @@ export function createGame(containerId, socket, store, roomId) {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - 60,
|
||||
backgroundColor: '#0d0500',
|
||||
scene: [BootScene, GameScene, UIScene],
|
||||
scene: [BootScene, GameScene],
|
||||
scale: {
|
||||
mode: Phaser.Scale.RESIZE,
|
||||
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 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) {
|
||||
playerName.value = name
|
||||
}
|
||||
@@ -50,12 +57,14 @@ export const useGameStore = defineStore('game', () => {
|
||||
pendingBuyItemId.value = null
|
||||
availableItems.value = []
|
||||
firstPlayerName.value = null
|
||||
gameOverResults.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
playerName, players, currentTurn, maxTurns,
|
||||
phase, hand, myTeamIndex, selectedCard,
|
||||
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 -->
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import { useSocket, leaveRoom } from '@/services/socket'
|
||||
import { createGame } from '@/game/GameFactory'
|
||||
import { ATTRS, ATTR_ICON, ATTR_COLOR, TEAM_CSS, PHASE_LABEL, gaugePercent } from '@/utils/hudUtils'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const gameStore = useGameStore()
|
||||
const socket = useSocket()
|
||||
const phaserContainer = ref(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: '🔵' }
|
||||
const attrColor = {
|
||||
food: '#f0c030',
|
||||
war: '#dd2222',
|
||||
health: '#22aa44',
|
||||
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))
|
||||
}
|
||||
// Alias locaux (casse historique du template) pointant vers le module utilitaire partagé
|
||||
const attrIcon = ATTR_ICON
|
||||
const attrColor = ATTR_COLOR
|
||||
const teamCss = TEAM_CSS
|
||||
const phaseLabel = PHASE_LABEL
|
||||
|
||||
onMounted(() => {
|
||||
phaserGame = createGame('phaser-container', socket, gameStore, route.params.roomId)
|
||||
@@ -96,6 +109,14 @@ onMounted(() => {
|
||||
socket.on('game-state', (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(() => {
|
||||
@@ -103,6 +124,11 @@ onUnmounted(() => {
|
||||
gameStore.reset()
|
||||
if (phaserGame) phaserGame.destroy(true)
|
||||
})
|
||||
|
||||
/** Quitte la partie et retourne à l'accueil (déclenche leaveRoom via onUnmounted). */
|
||||
function quitGame() {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -214,4 +240,100 @@ onUnmounted(() => {
|
||||
|
||||
/* ── Canvas ─────────────────────────────────────────────────── */
|
||||
#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>
|
||||
@@ -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/**']
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user