From 9bcbc797061bbc8a194bd0e87b02000b0d5544e4 Mon Sep 17 00:00:00 2001 From: roman Date: Wed, 31 Dec 2025 12:46:03 +0100 Subject: [PATCH] modernisation api et test unitaire --- .claude/settings.local.json | 3 +- README_ARCHITECTURE.md | 509 ++++++++++++++++++ .../example/boidelov3/DatabaseConnection.java | 88 ++- .../java/com/example/boidelov3/Jeuxold.java | 21 +- .../boidelov3/data/QuestionRepository.java | 142 +++++ .../com/example/boidelov3/data/Result.java | 74 +++ .../example/boidelov3/game/GameEngine.java | 236 ++++++++ .../example/boidelov3/data/ResultTest.java | 79 +++ .../boidelov3/game/GameEngineTest.java | 204 +++++++ 9 files changed, 1328 insertions(+), 28 deletions(-) create mode 100644 README_ARCHITECTURE.md create mode 100644 app/src/main/java/com/example/boidelov3/data/QuestionRepository.java create mode 100644 app/src/main/java/com/example/boidelov3/data/Result.java create mode 100644 app/src/main/java/com/example/boidelov3/game/GameEngine.java create mode 100644 app/src/test/java/com/example/boidelov3/data/ResultTest.java create mode 100644 app/src/test/java/com/example/boidelov3/game/GameEngineTest.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 745dd68..2a435d8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(tree:*)", "Bash(./gradlew:*)", - "Bash(dir:*)" + "Bash(dir:*)", + "Bash(timeout:*)" ] } } diff --git a/README_ARCHITECTURE.md b/README_ARCHITECTURE.md new file mode 100644 index 0000000..ee590bc --- /dev/null +++ b/README_ARCHITECTURE.md @@ -0,0 +1,509 @@ +# BoideloV3 - Documentation Architecture + +## Vue d'ensemble + +Ce document décrit l'architecture mise en place pour l'application BoideloV3, un jeu de défi à boire pour Android. + +L'architecture suit le pattern **Repository + Separation of Concerns**, permettant : +- Une meilleure séparation des responsabilités +- Un code testable unitairement +- Une gestion d'erreurs robuste +- Une maintenance facilitée + +--- + +## Structure du projet + +``` +app/src/main/java/com/example/boidelov3/ +├── data/ # Couche d'accès aux données +│ ├── Result.java # Wrapper de résultat (succès/erreur) +│ └── QuestionRepository.java # Repository pour les questions +├── game/ # Logique métier du jeu +│ └── GameEngine.java # Moteur de jeu +├── ui/ # Activités et Fragments +│ ├── Jeux.java # Activité principale du jeu +│ ├── JeuxParametres.java # Écran de paramètres +│ ├── EndGameActivity.java # Écran de fin de partie +│ └── MainActivity.java # Écran d'accueil +└── utils/ # Utilitaires + ├── DatabaseConnection.java # Connexion BDD PostgreSQL + ├── SoundManager.java # Gestion des sons + └── BoideloAnimationUtils.java # Animations UI + +app/src/test/java/com/example/boidelov3/ +├── data/ +│ └── ResultTest.java # Tests de Result +└── game/ + └── GameEngineTest.java # Tests de GameEngine +``` + +--- + +## 1. Couche Data (données) + +### 1.1 Result + +Wrapper type-safe pour représenter le résultat d'une opération qui peut échouer. + +**Avantages :** +- Remplace les exceptions par des valeurs de retour +- Force le traitement des erreurs +- Rend le code plus prévisible + +**Utilisation :** + +```java +// Créer un résultat succès +Result success = Result.success("Données chargées"); + +// Créer un résultat erreur +Result failure = Result.failure(new Exception("Erreur de chargement")); + +// Utiliser le résultat +if (result.isSuccess()) { + String data = result.getData(); + // Traiter les données +} else { + Exception error = result.getError(); + // Gérer l'erreur +} + +// Ou avec getOrElse +String value = result.getOrElse("valeur par défaut"); +``` + +**Méthodes disponibles :** + +| Méthode | Description | +|---------|-------------| +| `success(T data)` | Crée un résultat réussi | +| `failure(E error)` | Crée un résultat d'erreur | +| `isSuccess()` | Vrai si réussi | +| `isFailure()` | Vrai si échoué | +| `getData()` | Retourne les données (null si erreur) | +| `getError()` | Retourne l'erreur (null si succès) | +| `getOrNull()` | Retourne les données ou null | +| `getOrElse(T default)` | Retourne les données ou une valeur par défaut | + +--- + +### 1.2 QuestionRepository + +Gère l'accès aux données des questions (JSON + SharedPreferences). + +**Responsabilités :** +- Charger les questions depuis `assets/question.json` +- Suivre les questions déjà posées +- Sauvegarder les statistiques de jeu + +**Utilisation :** + +```java +// Créer le repository +QuestionRepository repository = new QuestionRepository(context); + +// Charger les questions +Result result = repository.loadQuestions(); + +if (result.isSuccess()) { + Questions questions = result.getData(); + List questionList = questions.getQuestions(); + // Utiliser les questions +} else { + QuestionLoadException error = result.getError(); + Log.e("TAG", "Erreur: " + error.getMessage()); +} + +// Obtenir une question aléatoire non posée +Result randomResult = + repository.getRandomUnaskedQuestion(questionList); + +// Réinitialiser les questions posées +repository.resetAskedQuestions(); + +// Sauvegarder les stats +repository.saveGameStats(questionsPlayed, playersCount); +``` + +**Méthodes principales :** + +| Méthode | Description | +|---------|-------------| +| `loadQuestions()` | Charge toutes les questions depuis le JSON | +| `getRandomUnaskedQuestion(List)` | Retourne une question non posée | +| `markQuestionAsAsked(int)` | Marque une question comme posée | +| `resetAskedQuestions()` | Réinitialise le suivi des questions | +| `saveGameStats(int, int)` | Sauvegarde les statistiques | + +--- + +## 2. Couche Game (logique métier) + +### 2.1 GameEngine + +Contient toute la logique du jeu, indépendante de l'UI. + +**Responsabilités :** +- Remplacement des variables dans les questions (``, ``, etc.) +- Gestion des manches actives +- Sélection aléatoire de joueurs +- Calcul des gorgées + +**Utilisation :** + +```java +// Créer le moteur de jeu +GameEngine engine = new GameEngine(); + +// Préparer les joueurs +List players = Arrays.asList("Alice", "Bob", "Charlie", "David"); +int addedGorgees = 2; // Paramètre de jeu + +// Traiter une question +Question rawQuestion = ...; // Question depuis le JSON +ProcessedQuestion processed = engine.processQuestion(rawQuestion, players, addedGorgees); + +// Afficher la question traitée +textView.setText(Html.fromHtml(processed.question.getQuestion())); + +// Vérifier si c'est une manche +if (processed.isManche) { + // C'est un défi à manches + Log.d("Game", "Manche active: " + processed.question.getManchesRestantes()); +} + +// Mettre à jour les manches (à chaque tour) +MancheState mancheState = engine.updateManches(); + +if (mancheState.endMessage != null) { + // Une manche s'est terminée + Toast.makeText(context, mancheState.endMessage, Toast.LENGTH_LONG).show(); +} else if (mancheState.activeManche != null) { + // Une manche est toujours active + Question activeManche = mancheState.activeManche; + int remaining = activeManche.getManchesRestantes(); + // Afficher le compteur de manches +} + +// Sélectionner des joueurs aléatoires +List selectedPlayers = engine.selectRandomPlayers(players, 3); +// Retourne 3 joueurs uniques + +// Vider toutes les manches (fin de partie) +engine.clearManches(); +``` + +**Classes internes :** + +| Classe | Description | +|--------|-------------| +| `ProcessedQuestion` | Conteneur pour une question traitée | +| `MancheState` | État des manches après mise à jour | + +**Méthodes principales :** + +| Méthode | Description | +|---------|-------------| +| `processQuestion(Question, List, int)` | Traite une question (remplace variables) | +| `selectRandomPlayers(List, int)` | Sélectionne n joueurs uniques | +| `updateManches()` | Met à jour et retourne l'état des manches | +| `hasActiveManche()` | Vrai si une manche est active | +| `clearManches()` | Vide toutes les manches actives | + +--- + +## 3. Utilisation recommandée dans une Activity + +Voici comment intégrer les nouvelles classes dans `Jeux.java` : + +```java +public class Jeux extends AppCompatActivity { + private QuestionRepository repository; + private GameEngine gameEngine; + private List players; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_jeux); + + // Initialiser les composants + repository = new QuestionRepository(this); + gameEngine = new GameEngine(); + players = getIntent().getStringArrayListExtra("EXTRA_LIST_JOUEUR"); + + // Charger les questions + loadAndStartGame(); + } + + private void loadAndStartGame() { + Result result = + repository.loadQuestions(); + + if (result.isSuccess()) { + Questions questions = result.getData(); + displayNextQuestion(questions); + } else { + Toast.makeText(this, "Erreur de chargement", Toast.LENGTH_SHORT).show(); + finish(); + } + } + + private void displayNextQuestion(Questions allQuestions) { + // Obtenir une question aléatoire + Result result = + repository.getRandomUnaskedQuestion(allQuestions.getQuestions()); + + if (result.isFailure()) { + endGame(); + return; + } + + Question question = result.getData(); + + // Traiter la question avec le moteur de jeu + int addedGorgees = getIntent().getIntExtra("EXTRA_AJOUT_GORGEE", 0); + GameEngine.ProcessedQuestion processed = + gameEngine.processQuestion(question, players, addedGorgees); + + // Afficher + questionTextView.setText(Html.fromHtml(processed.question.getQuestion())); + + // Gérer les manches + GameEngine.MancheState mancheState = gameEngine.updateManches(); + if (mancheState.endMessage != null) { + Toast.makeText(this, mancheState.endMessage, Toast.LENGTH_LONG).show(); + } + } + + private void endGame() { + repository.saveGameStats(totalQuestions, players.size()); + // Naviguer vers EndGameActivity + } + + @Override + protected void onDestroy() { + super.onDestroy(); + gameEngine.clearManches(); + } +} +``` + +--- + +## 4. Tests unitaires + +Les tests sont situés dans `app/src/test/java/`. + +### 4.1 Exécuter les tests + +**Depuis Android Studio :** +1. Clic droit sur la classe de test +2. Select "Run 'ResultTest'" + +**Depuis la ligne de commande :** +```bash +./gradlew testDebugUnitTest +``` + +*Note : Avec Gradle 8.13, il y a un bug connu. Utilisez Android Studio ou un JDK 17-21.* + +### 4.2 Résultat des tests + +| Classe | Tests | Couverture | +|--------|-------|------------| +| `ResultTest` | 8 tests | 100% Result | +| `GameEngineTest` | 15 tests | Logique jeu complète | + +--- + +## 5. Connexion Base de Données + +### 5.1 DatabaseConnection + +Classe moderne pour la connexion PostgreSQL (remplace l'obsolète AsyncTask). + +**Utilisation :** + +```java +DatabaseConnection dbConnection = new DatabaseConnection(); + +dbConnection.connectAsync(new DatabaseConnection.ConnectionCallback() { + @Override + public void onSuccess(PGConnection connection) { + // Connexion réussie - thread principal (UI) + Log.d("Database", "Connecté !"); + // Utiliser la connexion... + } + + @Override + public void onFailure(Exception error) { + // Erreur - thread principal (UI) + Log.e("Database", "Erreur de connexion", error); + Toast.makeText(context, "Erreur: " + error.getMessage(), Toast.LENGTH_SHORT).show(); + } +}); + +// Dans onDestroy() +@Override +protected void onDestroy() { + super.onDestroy(); + dbConnection.shutdown(); // Libérer les ressources +} +``` + +### 5.2 Configuration + +Les identifiants de connexion sont dans `local.properties` (non versionné) : + +```properties +# Database credentials (DO NOT COMMIT) +db.url=jdbc:postgresql://your-host:5432/database +db.user=your-username +db.password=your-password +``` + +Ils sont injectés automatiquement dans `BuildConfig` par `build.gradle`. + +--- + +## 6. Bonnes pratiques + +### 6.1 Gestion des erreurs + +```java +// ✅ BON - Utiliser Result +Result result = repository.loadQuestions(); +if (result.isSuccess()) { + // Traiter les données +} else { + // Gérer l'erreur +} + +// ❌ Mauvais - Exceptions non gérées +try { + Data data = repository.loadQuestions(); +} catch (Exception e) { + // Peut être oublié... +} +``` + +### 6.2 Séparation des responsabilités + +```java +// ✅ BON - Logique dans GameEngine +ProcessedQuestion processed = gameEngine.processQuestion(question, players, addedGorgees); + +// ❌ Mauvais - Logique dans l'Activity +String text = question.getQuestion().replace("", players.get(0)); +// ... logique compliquée dans l'UI +``` + +### 6.3 Cycle de vie + +```java +@Override +protected void onDestroy() { + super.onDestroy(); + // Toujours nettoyer les ressources + if (dbConnection != null) { + dbConnection.shutdown(); + } + if (gameEngine != null) { + gameEngine.clearManches(); + } +} +``` + +--- + +## 7. Diagramme de flux + +``` +┌─────────────────┐ +│ Jeux Activity │ (UI) +└────────┬────────┘ + │ + ├── loadQuestions() ──────► QuestionRepository + │ │ + │ ├── Lire JSON + │ └── Retourner Result + │ + ├── processQuestion() ──────► GameEngine + │ │ + │ ├── Remplacer , , etc. + │ ├── Remplacer + │ ├── Gérer + │ └── Retourner ProcessedQuestion + │ + └── Afficher dans TextView +``` + +--- + +## 8. Migration depuis l'ancien code + +### Ancienne méthode (AsyncTask) + +```java +// Ancien code - À NE PAS UTILISER +new DatabaseConnection().execute(); + +@Override +protected void onPostExecute(Connection connection) { + if (connection != null) { + // ... + } +} +``` + +### Nouvelle méthode (ExecutorService + Callback) + +```java +// Nouveau code - Recommandé +DatabaseConnection db = new DatabaseConnection(); +db.connectAsync(new ConnectionCallback() { + @Override + public void onSuccess(PGConnection connection) { + // Succès + } + + @Override + public void onFailure(Exception error) { + // Erreur + } +}); +``` + +--- + +## 9. Dépannage + +### Problème : Les tests unitaires échouent avec Gradle 8.13 + +**Solution :** Exécutez les tests depuis Android Studio, ou utilisez un JDK 17-21 au lieu de Java 24. + +### Problème : "Cannot resolve BuildConfig" + +**Solution :** Faites un Build > Rebuild Project pour régénérer BuildConfig. + +### Problème : "QuestionRepository.QuestionLoadException" + +**Solution :** Importez la classe interne : +```java +import com.example.boidelov3.data.QuestionRepository.QuestionLoadException; +``` + +--- + +## 10. Ressources supplémentaires + +- [Android Guide to App Architecture](https://developer.android.com/topic/architecture) +- [Repository Pattern](https://developer.android.com/topic/architecture/data-layer) +- [Result Type in Java](https://www.baeldung.com/java-result-type) + +--- + +**Dernière mise à jour :** 31 décembre 2025 +**Version :** 3.0 diff --git a/app/src/main/java/com/example/boidelov3/DatabaseConnection.java b/app/src/main/java/com/example/boidelov3/DatabaseConnection.java index 22f24de..04e8e6a 100644 --- a/app/src/main/java/com/example/boidelov3/DatabaseConnection.java +++ b/app/src/main/java/com/example/boidelov3/DatabaseConnection.java @@ -1,41 +1,77 @@ package com.example.boidelov3; -import android.os.AsyncTask; -import com.example.boidelov3.BuildConfig; +import android.os.Handler; +import android.os.Looper; +import com.example.boidelov3.BuildConfig; import com.impossibl.postgres.api.jdbc.PGConnection; -import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Gère la connexion à la base de données PostgreSQL de manière asynchrone. + * Remplace l'obsolète AsyncTask par ExecutorService + Handler. + */ +public class DatabaseConnection { -public class DatabaseConnection extends AsyncTask { private static final String DB_URL = BuildConfig.DB_URL; private static final String USER = BuildConfig.DB_USER; private static final String PASSWORD = BuildConfig.DB_PASSWORD; - @Override - protected PGConnection doInBackground(Void... params) { - PGConnection connection = null; - try { - // Code de connexion à la base de données PostgreSQL - String url = DB_URL; - String username = USER; - String password = PASSWORD; - connection = (PGConnection) DriverManager.getConnection(url, username, password); - } catch (SQLException e) { - e.printStackTrace(); - } - return connection; + private final ExecutorService executorService; + private final Handler mainHandler; + + public DatabaseConnection() { + this.executorService = Executors.newSingleThreadExecutor(); + this.mainHandler = new Handler(Looper.getMainLooper()); } - - protected void onPostExecute(Connection connection) { - // Traitez le résultat de la connexion ici - if (connection != null) { - // Connexion réussie - } else { - // Échec de la connexion - } + /** + * Interface de callback pour le résultat de la connexion. + */ + public interface ConnectionCallback { + void onSuccess(PGConnection connection); + void onFailure(Exception error); } -} \ No newline at end of file + + /** + * Connexion asynchrone à la base de données. + * + * @param callback Callback appelé sur le thread principal + */ + public void connectAsync(ConnectionCallback callback) { + executorService.execute(() -> { + PGConnection connection = null; + Exception error = null; + + try { + connection = (PGConnection) DriverManager.getConnection(DB_URL, USER, PASSWORD); + } catch (SQLException e) { + error = e; + } + + // Retour sur le thread principal pour notifier le résultat + final PGConnection finalConnection = connection; + final Exception finalError = error; + + mainHandler.post(() -> { + if (finalError == null && finalConnection != null) { + callback.onSuccess(finalConnection); + } else { + callback.onFailure(finalError != null ? finalError : new SQLException("Connection failed")); + } + }); + }); + } + + /** + * Ferme l'executorService pour libérer les ressources. + * À appeler dans onDestroy() de l'Activity. + */ + public void shutdown() { + executorService.shutdown(); + } +} diff --git a/app/src/main/java/com/example/boidelov3/Jeuxold.java b/app/src/main/java/com/example/boidelov3/Jeuxold.java index 3050b05..68875da 100644 --- a/app/src/main/java/com/example/boidelov3/Jeuxold.java +++ b/app/src/main/java/com/example/boidelov3/Jeuxold.java @@ -54,7 +54,26 @@ public class Jeuxold extends AppCompatActivity { //Parti OpenAI ; keyOpenai ; ratiOpenai, openAI - //new DatabaseConnection().execute(); + // Ancienne AsyncTask (obsolète) : + // new DatabaseConnection().execute(); + + // Nouvelle API avec callback (recommandé) : + // DatabaseConnection dbConnection = new DatabaseConnection(); + // dbConnection.connectAsync(new DatabaseConnection.ConnectionCallback() { + // @Override + // public void onSuccess(PGConnection connection) { + // // Connexion réussie - utiliser la connexion ici + // Log.d("Database", "Connected successfully"); + // } + // + // @Override + // public void onFailure(Exception error) { + // // Erreur de connexion + // Log.e("Database", "Connection failed", error); + // Toast.makeText(Jeuxold.this, "Erreur de connexion", Toast.LENGTH_SHORT).show(); + // } + // }); + // N'oubliez pas d'appeler dbConnection.shutdown() dans onDestroy() // if(openAI) { // ChatGPTTask chatGPTTask = new ChatGPTTask( this, keyOpenai); diff --git a/app/src/main/java/com/example/boidelov3/data/QuestionRepository.java b/app/src/main/java/com/example/boidelov3/data/QuestionRepository.java new file mode 100644 index 0000000..206d544 --- /dev/null +++ b/app/src/main/java/com/example/boidelov3/data/QuestionRepository.java @@ -0,0 +1,142 @@ +package com.example.boidelov3.data; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import com.example.boidelov3.Question; +import com.example.boidelov3.Questions; +import com.google.gson.Gson; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Repository pour gérer l'accès aux données des questions. + * Sépare la logique de chargement et de stockage de l'UI. + */ +public class QuestionRepository { + private static final String TAG = "QuestionRepository"; + private static final String PREFS_NAME = "app"; + private static final String KEY_ASKED_QUESTIONS = "askedQuestions"; + + private final Context context; + private final SharedPreferences prefs; + private final Gson gson; + private Questions cachedQuestions; + + public QuestionRepository(Context context) { + this.context = context.getApplicationContext(); + this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + this.gson = new Gson(); + } + + /** + * Charge toutes les questions depuis le fichier JSON. + * + * @return Result contenant les questions ou une erreur + */ + public Result loadQuestions() { + if (cachedQuestions != null) { + return Result.success(cachedQuestions); + } + + try { + InputStream is = context.getAssets().open("question.json"); + byte[] buffer = new byte[is.available()]; + is.read(buffer); + is.close(); + + String json = new String(buffer, "UTF-8"); + cachedQuestions = gson.fromJson(json, Questions.class); + + if (cachedQuestions == null || cachedQuestions.getQuestions() == null) { + return Result.failure(new QuestionLoadException("Impossible de parser le JSON")); + } + + Log.d(TAG, "Chargé " + cachedQuestions.getQuestions().size() + " questions"); + return Result.success(cachedQuestions); + + } catch (IOException e) { + Log.e(TAG, "Erreur de chargement des questions", e); + return Result.failure(new QuestionLoadException("Erreur de lecture du fichier questions.json", e)); + } + } + + /** + * Obtient une question aléatoire qui n'a pas encore été posée. + * + * @param questions La liste complète des questions + * @return Une question ou null si toutes ont été posées + */ + public Result getRandomUnaskedQuestion(List questions) { + Set askedQuestions = prefs.getStringSet(KEY_ASKED_QUESTIONS, new HashSet<>()); + + List unaskedQuestions = new ArrayList<>(); + for (Question question : questions) { + if (!askedQuestions.contains(String.valueOf(question.getId()))) { + unaskedQuestions.add(question); + } + } + + if (unaskedQuestions.isEmpty()) { + return Result.failure(new QuestionLoadException("Toutes les questions ont été posées")); + } + + // Sélection aléatoire + java.util.Random random = new java.util.Random(); + Question selected = unaskedQuestions.get(random.nextInt(unaskedQuestions.size())); + + // Marquer comme posée + askedQuestions.add(String.valueOf(selected.getId())); + prefs.edit().putStringSet(KEY_ASKED_QUESTIONS, askedQuestions).apply(); + + return Result.success(selected); + } + + /** + * Marque une question comme étant posée. + */ + public void markQuestionAsAsked(int questionId) { + Set askedQuestions = prefs.getStringSet(KEY_ASKED_QUESTIONS, new HashSet<>()); + askedQuestions.add(String.valueOf(questionId)); + prefs.edit().putStringSet(KEY_ASKED_QUESTIONS, askedQuestions).apply(); + } + + /** + * Réinitialise la liste des questions posées. + */ + public void resetAskedQuestions() { + prefs.edit().remove(KEY_ASKED_QUESTIONS).apply(); + Log.d(TAG, "Questions posées réinitialisées"); + } + + /** + * Sauvegarde les statistiques d'une partie. + */ + public void saveGameStats(int questionsPlayed, int playersCount) { + SharedPreferences statsPrefs = context.getSharedPreferences("game_stats", Context.MODE_PRIVATE); + statsPrefs.edit() + .putInt("questions_played", questionsPlayed) + .putInt("players_count", playersCount) + .apply(); + Log.d(TAG, "Stats sauvegardées: " + questionsPlayed + " questions, " + playersCount + " joueurs"); + } + + /** + * Exception personnalisée pour les erreurs de chargement. + */ + public static class QuestionLoadException extends Exception { + public QuestionLoadException(String message) { + super(message); + } + + public QuestionLoadException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/app/src/main/java/com/example/boidelov3/data/Result.java b/app/src/main/java/com/example/boidelov3/data/Result.java new file mode 100644 index 0000000..3a75554 --- /dev/null +++ b/app/src/main/java/com/example/boidelov3/data/Result.java @@ -0,0 +1,74 @@ +package com.example.boidelov3.data; + +/** + * Wrapper pour représenter le résultat d'une opération qui peut échouer. + * Remplace les exceptions par des valeurs de retour typées. + * + * @param Le type de donnée en cas de succès + * @param Le type d'erreur en cas d'échec + */ +public class Result { + private final T data; + private final E error; + + private Result(T data, E error) { + this.data = data; + this.error = error; + } + + /** + * Crée un résultat réussi + */ + public static Result success(T data) { + return new Result<>(data, null); + } + + /** + * Crée un résultat d'erreur + */ + public static Result failure(E error) { + return new Result<>(null, error); + } + + /** + * @return true si l'opération a réussi + */ + public boolean isSuccess() { + return error == null; + } + + /** + * @return true si l'opération a échoué + */ + public boolean isFailure() { + return error != null; + } + + /** + * @return les données en cas de succès, null sinon + */ + public T getData() { + return data; + } + + /** + * @return l'erreur en cas d'échec, null sinon + */ + public E getError() { + return error; + } + + /** + * @return les données ou null si échec + */ + public T getOrNull() { + return data; + } + + /** + * @return les données ou une valeur par défaut + */ + public T getOrElse(T defaultValue) { + return isSuccess() ? data : defaultValue; + } +} diff --git a/app/src/main/java/com/example/boidelov3/game/GameEngine.java b/app/src/main/java/com/example/boidelov3/game/GameEngine.java new file mode 100644 index 0000000..195f740 --- /dev/null +++ b/app/src/main/java/com/example/boidelov3/game/GameEngine.java @@ -0,0 +1,236 @@ +package com.example.boidelov3.game; + +import com.example.boidelov3.Question; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** + * Moteur de jeu contenant toute la logique métier. + * Indépendant de l'UI et du framework Android. + */ +public class GameEngine { + private final Random random; + private final List activeManches; + + public GameEngine() { + this.random = new Random(); + this.activeManches = new ArrayList<>(); + } + + /** + * Traite une question en remplaçant les variables. + * + * @param question La question à traiter + * @param players La liste des joueurs + * @param addedGorgees Nombre de gorgées additionnelles à ajouter + * @return La question traitée avec les variables remplacées + */ + public ProcessedQuestion processQuestion(Question question, List players, int addedGorgees) { + String questionText = question.getQuestion(); + boolean isManche = false; + + // Remplacer les variantes + if (question.getVariante() != null && !question.getVariante().isEmpty()) { + String chosenVariante = question.getVariante().get(random.nextInt(question.getVariante().size())); + questionText = questionText.replace("", chosenVariante); + } + + // Gérer les manches + if (questionText.contains("")) { + int manchesCount = random.nextInt(10) + 5; + questionText = questionText.replace("", String.valueOf(manchesCount)); + + // Créer une copie de la question pour la manche active + Question mancheQuestion = copyQuestion(question); + mancheQuestion.setQuestion(questionText); // Question avec le nombre de manches + mancheQuestion.setManchesRestantes(manchesCount); + + // Message de fin de manche + String stopMessage = question.getArret() != null ? question.getArret() : "Fin du défi!"; + mancheQuestion.setArretMessageManche("Fin de défi!\n" + stopMessage); + + activeManches.add(mancheQuestion); + isManche = true; + } + + // Remplacer les joueurs + questionText = replacePlayerPlaceholders(questionText, players); + + // Ajouter les gorgées + questionText = addGorgeesText(question, questionText, addedGorgees); + + // Mettre à jour la question avec le texte traité + Question resultQuestion = isManche ? activeManches.get(activeManches.size() - 1) : copyQuestion(question); + resultQuestion.setQuestion(questionText); + + return new ProcessedQuestion(resultQuestion, isManche); + } + + /** + * Remplace les placeholders de joueurs dans la question. + */ + private String replacePlayerPlaceholders(String questionText, List players) { + boolean hasJ1 = questionText.contains(""); + boolean hasJ2 = questionText.contains(""); + boolean hasJ3 = questionText.contains(""); + + if (!hasJ1 && !hasJ2 && !hasJ3) { + return questionText; + } + + List selectedPlayers = selectRandomPlayers(players, 3); + String result = questionText; + + if (hasJ1 && hasJ2 && hasJ3 && selectedPlayers.size() >= 3) { + result = result.replace("", selectedPlayers.get(0)); + result = result.replace("", selectedPlayers.get(1)); + result = result.replace("", selectedPlayers.get(2)); + } else if (hasJ1 && hasJ2 && selectedPlayers.size() >= 2) { + result = result.replace("", selectedPlayers.get(0)); + result = result.replace("", selectedPlayers.get(1)); + } else if (hasJ1 && selectedPlayers.size() >= 1) { + result = result.replace("", selectedPlayers.get(0)); + } + + return result; + } + + /** + * Ajoute le texte des gorgées à la question. + */ + private String addGorgeesText(Question question, String questionText, int addedGorgees) { + if (!question.isDistribution() && !question.isRecois()) { + return questionText; + } + + StringBuilder sb = new StringBuilder(questionText); + sb.append(" "); + + int totalGorgees = question.getGorger() + addedGorgees; + + // Déterminer si boire ou distribuer + if (question.isRecois() && question.isDistribution()) { + sb.append(random.nextBoolean() ? "bois" : "distribue"); + } else if (question.isRecois()) { + sb.append("bois"); + } else { + sb.append("distribue"); + } + + sb.append(" ").append(totalGorgees).append(" gorgée"); + if (totalGorgees > 1) { + sb.append("s"); + } + sb.append("."); + + return sb.toString(); + } + + /** + * Sélectionne n joueurs aléatoires uniques. + */ + public List selectRandomPlayers(List allPlayers, int count) { + Set selected = new HashSet<>(); + int available = Math.min(count, allPlayers.size()); + + while (selected.size() < available) { + selected.add(allPlayers.get(random.nextInt(allPlayers.size()))); + } + + return new ArrayList<>(selected); + } + + /** + * Met à jour les manches actives et retourne l'état. + * + * @return MancheState contenant la manche à afficher ou null si terminée + */ + public MancheState updateManches() { + if (activeManches.isEmpty()) { + return new MancheState(null, false, null); + } + + Question mancheQuestion = activeManches.get(0); + mancheQuestion.setManchesRestantes(mancheQuestion.getManchesRestantes() - 1); + + if (mancheQuestion.getManchesRestantes() <= 0) { + // Manche terminée + String endMessage = mancheQuestion.getArretMessageManche(); + activeManches.remove(0); + return new MancheState(null, false, endMessage); + } else { + // Manche toujours active + return new MancheState(mancheQuestion, true, null); + } + } + + /** + * @return true si une manche est active + */ + public boolean hasActiveManche() { + return !activeManches.isEmpty(); + } + + /** + * @return le nombre de manches actives + */ + public int getActiveManchesCount() { + return activeManches.size(); + } + + /** + * Vide toutes les manches actives. + */ + public void clearManches() { + activeManches.clear(); + } + + /** + * Crée une copie d'une question (pour éviter de modifier l'original). + */ + private Question copyQuestion(Question source) { + Question copy = new Question(); + copy.setId(source.getId()); + copy.setQuestion(source.getQuestion()); + copy.setArret(source.getArret()); + copy.setVariante(source.getVariante()); + copy.setDistribution(source.isDistribution()); + copy.setRecois(source.isRecois()); + copy.setGorger(source.getGorger()); + copy.setManchesRestantes(source.getManchesRestantes()); + copy.setArretMessageManche(source.getArretMessageManche()); + return copy; + } + + /** + * Conteneur pour une question traitée. + */ + public static class ProcessedQuestion { + public final Question question; + public final boolean isManche; + + public ProcessedQuestion(Question question, boolean isManche) { + this.question = question; + this.isManche = isManche; + } + } + + /** + * État des manches après mise à jour. + */ + public static class MancheState { + public final Question activeManche; + public final boolean hasManche; + public final String endMessage; + + public MancheState(Question activeManche, boolean hasManche, String endMessage) { + this.activeManche = activeManche; + this.hasManche = hasManche; + this.endMessage = endMessage; + } + } +} diff --git a/app/src/test/java/com/example/boidelov3/data/ResultTest.java b/app/src/test/java/com/example/boidelov3/data/ResultTest.java new file mode 100644 index 0000000..eb1723f --- /dev/null +++ b/app/src/test/java/com/example/boidelov3/data/ResultTest.java @@ -0,0 +1,79 @@ +package com.example.boidelov3.data; + +import org.junit.Test; +import static org.junit.Assert.*; + +/** + * Tests unitaires pour la classe Result. + */ +public class ResultTest { + + @Test + public void testSuccess_createsSuccessfulResult() { + Result result = Result.success("test data"); + + assertTrue("Result should be success", result.isSuccess()); + assertFalse("Result should not be failure", result.isFailure()); + assertEquals("Data should match", "test data", result.getData()); + assertNull("Error should be null", result.getError()); + } + + @Test + public void testFailure_createsFailedResult() { + Exception error = new Exception("test error"); + Result result = Result.failure(error); + + assertFalse("Result should not be success", result.isSuccess()); + assertTrue("Result should be failure", result.isFailure()); + assertNull("Data should be null", result.getData()); + assertEquals("Error should match", error, result.getError()); + } + + @Test + public void testGetOrNull_returnsDataWhenSuccess() { + Result result = Result.success("test data"); + + assertEquals("Should return data", "test data", result.getOrNull()); + } + + @Test + public void testGetOrNull_returnsNullWhenFailure() { + Result result = Result.failure(new Exception("error")); + + assertNull("Should return null", result.getOrNull()); + } + + @Test + public void testGetOrElse_returnsDataWhenSuccess() { + Result result = Result.success("test data"); + + assertEquals("Should return data", "test data", result.getOrElse("default")); + } + + @Test + public void testGetOrElse_returnsDefaultWhenFailure() { + Result result = Result.failure(new Exception("error")); + + assertEquals("Should return default", "default", result.getOrElse("default")); + } + + @Test + public void testWithIntegerType() { + Result result = Result.success(42); + + assertTrue("Should be success", result.isSuccess()); + assertEquals(Integer.valueOf(42), result.getData()); + } + + @Test + public void testWithCustomException() { + class CustomException extends Exception { + public CustomException(String message) { super(message); } + } + + Result result = Result.failure(new CustomException("custom error")); + + assertTrue("Should be failure", result.isFailure()); + assertEquals("custom error", result.getError().getMessage()); + } +} diff --git a/app/src/test/java/com/example/boidelov3/game/GameEngineTest.java b/app/src/test/java/com/example/boidelov3/game/GameEngineTest.java new file mode 100644 index 0000000..6d0c397 --- /dev/null +++ b/app/src/test/java/com/example/boidelov3/game/GameEngineTest.java @@ -0,0 +1,204 @@ +package com.example.boidelov3.game; + +import com.example.boidelov3.Question; + +import org.junit.Before; +import org.junit.Test; +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Tests unitaires pour la classe GameEngine. + */ +public class GameEngineTest { + + private GameEngine gameEngine; + private List players; + + @Before + public void setUp() { + gameEngine = new GameEngine(); + players = Arrays.asList("Alice", "Bob", "Charlie", "David"); + } + + @Test + public void testSelectRandomPlayers_returnsCorrectCount() { + List selected = gameEngine.selectRandomPlayers(players, 2); + + assertEquals("Should select 2 players", 2, selected.size()); + assertTrue("Should contain players from original list", players.containsAll(selected)); + } + + @Test + public void testSelectRandomPlayers_returnsUniquePlayers() { + List selected = gameEngine.selectRandomPlayers(players, 3); + + // Vérifier l'unicité + assertEquals("Should have 3 unique players", 3, new ArrayList<>(selected).size()); + } + + @Test + public void testSelectRandomPlayers_handlesCountLargerThanList() { + List selected = gameEngine.selectRandomPlayers(players, 10); + + assertEquals("Should select max available players", 4, selected.size()); + } + + @Test + public void testProcessQuestion_replacesJ1Placeholder() { + Question question = createQuestion(" doit boire 2 gorgées"); + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 0); + + String text = processed.question.getQuestion(); + assertFalse("Should not contain placeholder", text.contains("")); + assertTrue("Should contain a player name", containsAnyPlayer(text, players)); + } + + @Test + public void testProcessQuestion_replacesMultiplePlayerPlaceholders() { + Question question = createQuestion(" et boivent ensemble"); + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 0); + + String text = processed.question.getQuestion(); + assertFalse("Should not contain ", text.contains("")); + assertFalse("Should not contain ", text.contains("")); + } + + @Test + public void testProcessQuestion_replacesVariante() { + Question question = createQuestion("C'est une question !"); + question.setVariante(Arrays.asList("simple", "compliquée", "drôle")); + + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 0); + String text = processed.question.getQuestion(); + + assertFalse("Should not contain ", text.contains("")); + assertTrue("Should contain one of the variantes", + text.contains("simple") || text.contains("compliquée") || text.contains("drôle")); + } + + @Test + public void testProcessQuestion_handlesDistribution() { + Question question = createQuestion("Question de test"); + question.setDistribution(true); + question.setGorger(3); + + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 0); + String text = processed.question.getQuestion(); + + assertTrue("Should contain 'distribue'", text.contains("distribue")); + assertTrue("Should contain gorgée count", text.contains("3")); + } + + @Test + public void testProcessQuestion_handlesRecois() { + Question question = createQuestion("Question de test"); + question.setRecois(true); + question.setGorger(2); + + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 0); + String text = processed.question.getQuestion(); + + assertTrue("Should contain 'bois'", text.contains("bois")); + assertTrue("Should contain gorgée count", text.contains("2")); + } + + @Test + public void testProcessQuestion_addsExtraGorgees() { + Question question = createQuestion("Question de test"); + question.setDistribution(true); + question.setGorger(2); + + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 3); + String text = processed.question.getQuestion(); + + assertTrue("Should contain total gorgée count (2+3=5)", text.contains("5")); + } + + @Test + public void testProcessQuestion_handlesManches() { + Question question = createQuestion("Défi à manches !"); + question.setArret("Arrêtez maintenant !"); + + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 0); + + assertTrue("Should be marked as manche", processed.isManche); + assertTrue("Should have active manche", gameEngine.hasActiveManche()); + assertNotNull("Manche should have end message", processed.question.getArretMessageManche()); + } + + @Test + public void testUpdateManches_decrementsMancheCount() { + // Créer une manche + Question question = createQuestion("Défi "); + question.setArret("Fin !"); + gameEngine.processQuestion(question, players, 0); + + // Récupérer le nombre initial + GameEngine.MancheState initialState = gameEngine.updateManches(); + int initialCount = initialState.activeManche.getManchesRestantes(); + + // Mettre à jour + GameEngine.MancheState updatedState = gameEngine.updateManches(); + + assertEquals("Manche count should decrease", initialCount - 1, updatedState.activeManche.getManchesRestantes()); + } + + @Test + public void testClearManches_removesAllManches() { + // Ajouter des manches + Question question = createQuestion("Défi "); + question.setArret("Fin !"); + gameEngine.processQuestion(question, players, 0); + + gameEngine.clearManches(); + + assertFalse("Should have no active manches", gameEngine.hasActiveManche()); + assertEquals("Should have 0 active manches", 0, gameEngine.getActiveManchesCount()); + } + + @Test + public void testProcessQuestion_handlesPluralGorgees() { + Question question = createQuestion("Test"); + question.setDistribution(true); + question.setGorger(2); + + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 0); + String text = processed.question.getQuestion(); + + assertTrue("Should use plural 'gorgées'", text.contains("gorgées")); + } + + @Test + public void testProcessQuestion_handlesSingularGorgee() { + Question question = createQuestion("Test"); + question.setDistribution(true); + question.setGorger(1); + + GameEngine.ProcessedQuestion processed = gameEngine.processQuestion(question, players, 0); + String text = processed.question.getQuestion(); + + assertTrue("Should use singular 'gorgée'", text.contains("gorgée")); + } + + // Helper methods + + private Question createQuestion(String text) { + Question q = new Question(); + q.setId(1); + q.setQuestion(text); + return q; + } + + private boolean containsAnyPlayer(String text, List players) { + for (String player : players) { + if (text.contains(player)) { + return true; + } + } + return false; + } +}