Accélérer la boucle de feedback

Comme dit en introduction, tu vas vite te rendre compte que tester ce que tu viens de modifier peut être très lent à tester manuellement. Voici quelques astuces pour accélerer cette boucle de feedback.

Tests manuels dans l'interface graphique

Note: ces ajouts ne sont pas demandés dans les consignes et si ces ajouts sont un problème pour les enseignant·es du cours, il suffit de les retirer à la fin du projet juste avant le rendu. Même s'il ne sont pas rendus, leur usage temporaire récompense largement leur développement. Il se peut que les membres de l'équipe spécialisée dans l'UI et Qt aient fini leur travail, cela peut être un bon moyen de continuer sur l'UI pour aider le reste de l'équipe.

Slider

L'ajout le plus utile dans l'interface est d'implémenter un slider qui permet d'avancer et reculer librement dans la timeline.

ui-slider.png

La classe QSlider peut t'aider pour cette tâche.

Ouverture rapide de fichiers

Pour éviter de constamment devoir passer par le sélecteur de fichier de timeline ou d'état pour tester le comportement de différentes timelines, tu as besoin d'un moyen de les ouvrir plus rapidement. Une idée est d'ajouter une liste de tous les fichiers *.tlin et *.stat du projet pour facilement les ouvrir.

ui-timelines-list.png

Les classes QDirIterator, QStringListModel, et QStringList peuvent t'aider pour cette tâche.

Mettre en place des tests unitaires

Le framework Catch2 est proposé ici mais GoogleTest ou d'autres options peuvent tout à fait faire l'affaire.

Tu connais un meilleur framework que Catch2 à documenter ici ? C'est volontiers !

Stratégie

Les tests unitaires testent une fonction ou une méthode, souvent qui ne fait pas grand chose mais qui n'est pas trivial à implémenter. Les tests peuvent être très court à écrire pour tester de multiple situations. On ne teste que des éléments de calculs et de logique, et non des éléments graphique. Qt et l'interface graphique ne sont pas concernés par ces tests unitaires.

Configurer Catch2

Selon la documentation d'intégration de Catch2 avec CMake, il est possible d'intégrer Catch2 via son repository Git directement. Il suffit de copier coller ce snippet en bas du CMakeLists.txt. Il faut adapter la liste des fichiers définis pour TEST_SOURCES afin d'inclure tous les fichiers qui décrivent les tests (ici tests/RobotTest.cpp) et les fichiers .cpp des classes qui sont testées. Tout le reste de l'application n'a pas besoin d'être inclus, il n'y pas de Qt à inclure. Cela va générer un nouveau fichier binaire tests dont le main() a été défini par Catch2.

# Catch2 integration for unit tests
set(TEST_SOURCES tests/RobotTest.cpp Particle.h Robot.cpp)

include(FetchContent)

FetchContent_Declare(
  Catch2
  GIT_REPOSITORY https://github.com/catchorg/Catch2.git
  GIT_TAG v3.12.0)

FetchContent_MakeAvailable(Catch2)

add_executable(tests ${TEST_SOURCES})
target_link_libraries(tests PRIVATE Catch2::Catch2WithMain)

Ensuite crée un fichier pour écrire ton test (par exemple comme proposé tests/RobotTest.cpp). Une idée est de faire un fichier de test par classe testée. En voici un exemple concret pour faciliter la compréhension.

Dans notre code, nous avions ajouté une méthode Robot.canEat() qui permettait de savoir si le robot était capable de manger une particule. Dit autrement, cela vérifie si la bouche du robot touche la particule ou pas. Différents cas sont à vérifier pour s'assurer que ce code fonctionne complètement.

canEat avait la signature suivante.

bool RobotInfo::canEat(const ParticleInfo &particle) const;

Note: ceci n'est qu'un exemple, rien ne t'oblige à implémenter une méthode similaire ou utiliser la même approche.

La macro TEST_CASE permet de regrouper des tests sous un label [RobotInfo] et y mettre un titre canEat(). La macro SECTION permet de définir un test unitaire avec un titre. Les macros CHECK ou REQUIRE permettent de vérifier des expressions booléennes.

#include "../Robot.h"
#include "../Particle.h"

#include <catch2/catch_test_macros.hpp>

TEST_CASE("canEat()", "[RobotInfo]") {
    // A robot at left and a particle at right, touching together, with robot mouth touching in front the particle
    RobotInfo r = (struct RobotInfo) {
            .id = 2,
            .position = {.x = 100.0, .y = 100.0},
            .radius = 20.0,
            .angle = 0.0,
            .captureAngle = 10.0,
            .leftSpeed = 0.0,
            .rightSpeed = 0.0,
            .score = 0};
    ParticleInfo p;
    p.id = 1;
    p.radius = 20;
    p.position = {.x = 140, .y = 100};

    SECTION("It can eat a particle horizontally at the middle of the mouth") {
        CHECK(r.canEat(p));
    }

    SECTION("It can eat a particle horizontally at the left or right limit of the mouth") {
        r.angle = 5;// turned right from 5 degrees (10/2 = 5)
        CHECK(r.canEat(p));
        r.angle = 355;// turned left from 5 degrees (10/2 = 5)
        CHECK(r.canEat(p));
    }

    // D'autres SECTION pour d'autres situations
}

La situation créée ici consiste en un robot à gauche et une particule à droite, les deux se touchant. La bouche étant large de 10 degrés et touchant à l'horizontale, pour vérifier que le robot peut toujours la manger en touchant avec un des deux coins de la bouche, il suffit de faire tourner le robot de plus ou moins 5 degrés.

La situation défini sous le TEST_CASE correspond à cette visualisation faite avec Geogebra. C'est le point de départ de chaque test, il n'y a pas besoin de l'initialiser à chaque fois, les modifications faites dans un test n'affecte pas les autres tests !

geogebra-test-example-situation.png

Geogebra peut aider à créer visuellement des situations plus compliquées que des alignements horizonzales ou verticales simples à calculer de tête si le robot touche une particule.

Par exemple, si on testait avec une particule plus petite et sur une position manuellement choisie comme ici. On voit clairement l'intersection dans Geogebra et en prenant les coordonnées du centre de la particule (B = (86.46, 73.26)). Grâce à l'outil de mesure d'angle on voit que l'angle du robot est de 244.95 et que le centre de la particule.

geogebra-more-complex-situation.png

Pour décrire cette situation en code, il suffit de repartir de la situation de départ (donc le robot ne se déplace pas mais change d'angle, la particule change de position et de rayon).

    SECTION("It can eat a particle that is below at left") {
        // Situation created directly in Geogebra
        r.angle = 244.95;
        p.radius = 10;
        p.position = {.x = 86.46, .y = 73.26};
        CHECK(r.canEat(p));
    }

Catch2 possède d'autres macros comme REQUIRE_FALSE, CHECK_FALSE, REQUIRE_NOTHROW, ... documentées sur cette page. La différence entre CHECK et REQUIRE est également expliqué.

Lancer les tests

Les tests peuvent être compilés et lancés via la target tests dans l'IDE. Il est possible qu'il existe une intégration Catch2 dans l'IDE avec ou sans extension, pour voir le résultat directement dans l'IDE plutôt que le terminal.

Macros d'aide

Lors des calculs en valeur flottantes (valeurs à virgule), il y aura toujours de la perte d'information durant les calculs, les valeurs ne seront jamais exactes. Ainsi, nous ne pouvons pas vérifier par exemple que CHECK(point1.distanceTo(point2) == 5.23562) car la valeur ne sera jamais exactement égal. Pour garder une petite marge d'erreur (définie dans ROUNDING_MARGIN), une simple macro peut faciliter les assertions: ASSERT_AROUND(point1.distanceTo(point2), 5.23562).

#define ROUNDING_MARGIN 0.001
#define ASSERT_AROUND(given, expected)               \
    CHECK((given) > ((expected) - ROUNDING_MARGIN)); \
    CHECK((given) < ((expected) + ROUNDING_MARGIN));

Les instructions répétées régulièrement dans le code des tests peuvent également bénéficier de macros dédiées. En voici un autre exemple avec la vérification de l'avancement du robot la droite ou la gauche et s'il tourne sur lui-même.

#define CHECK_BOT_TURN_LEFT(r) CHECK(r.rightSpeed > 0)
#define CHECK_BOT_TURN_RIGHT(r) CHECK(r.rightSpeed < 0)
#define CHECK_TURN_ON_ITSELF(r) CHECK(r.rightSpeed == -r.leftSpeed)

En espérant que ces exemples peuvent taider ou te permettre de voir quelles autres macros pourraient t'être utile dans ton contexte !

Autres exemples de tests

Avec une fonction rotateDuringInterval qui permet d'activer les moteurs à la bonne vitesse et dans le bon sens (horaire ou anti-horaire) pour atteindre un angle donné. Des contraintes sont passées pour éviter d'aller plus rapidement qu'autorisé.

   RobotInfo r = (struct RobotInfo){
      .id           = 2,
      .position     = {.x = 100.0, .y = 100.0},
      .radius       = 20.0,
      .angle        = 20.0,
      .captureAngle = 10.0,
      .leftSpeed    = 0.0,
      .rightSpeed   = 0.0,
      .score        = 0
   };
   Constraints constraints;
   constraints.commandTimeInterval = 1.0;
   constraints.maxBackwardSpeed    = 20;
   constraints.maxForwardSpeed      = 20;

   SECTION("It rotates at right for a small angle (20° to 40°)") {
      r.angle     = 20;
      r.leftSpeed = 6.98;
      r.rotateDuringInterval(40, constraints);
      CHECK_BOT_TURN_RIGHT(r);
      CHECK_TURN_ON_ITSELF(r);
      ASSERT_AROUND(r.leftSpeed, 6.981); // 2*radius*pi*angle/360/1 = 6.981
   }

Note: je ne suis plus sûr de comprendre pourquoi l'angle est dans ce sens (pourquoi on tourne à droite pour aller de 20 à 40 degrés) mais l'idée reste valide, à adapter selon la signification des angles et selon les consignes.

Lancer le CLI pour toutes les pairs de état+contrainte

Pour tester rapidement dans différentes situations la stratégie, il est utile de pouvoir lancer le CLI sur toutes les combinaisons de *.stat et *.constraint d'un seul coup sans devoir lancer le CLI des dizaines de fois.

Le mieux serait d'avoir un bouton dans l'interface graphique pour le faire qui pourrait s'appeler "Générer toutes les timelines". Le code ne va pas appeler directement le CLI mais utilise directement le point d'entrée du code de génération des timelines. Il suffit de lister tous les fichiers d'état et contraintes présents dans le dossier du projet et de générer des fichiers timeline avec un nom contenant les noms des états et contraintes associées.

Si cela est plus simple, un petit script qui constitue ces pairs et les passe au CLI peut aussi faire l'affaire. Voici un exemple de script Bash, à adapter à ta situation (ta structure de fichiers et au nom des timelines générées).

#!/usr/bin/bash

# To adapt to your folder structure
CLIPATH=./build/CLI

# Generate *.tlin for all *.stat and *.constraints files inside a folder.
for path in $(find .. -name "*.stat"); do
    echo "Generating timeline for $path"

    for constraint in $(find .. -name "*.constraints" | sort -r | grep -v "fastcmd"); do
        $CLIPATH $path $constraint || continue
        constraintName=$(basename $constraint .constraints)
        finalName="${path%.*}_${constraintName}.tlin"
        echo "Generating " $finalName
        # Our CLI is generating a timeline name with the state file name and the .tlin extension,
        # so we need to rename the statename.tlin to statename_constraintname.tlin
        mv "${path%.*}.tlin" $finalName
    done
    printf "DONE\n\n"
done