Ajouter une fonctionnalité¶
Ce guide montre comment greffer un nouvel écran/parcours en respectant l'architecture (cf. Architecture). Le fil conducteur : on crée un paquet feature autonome, on le câble à l'injection, puis on le branche à la navigation sans casser les frontières.
Le meilleur point de départ : copier une feature voisine
Une feature simple comme bibliotheque/ ou diagnostic/ est un bon gabarit. Calquez sa
structure, les tests suivront le même moule.
Cette numérotation décrit une ANATOMIE, pas une chronologie
Les huit sections ci-dessous disent de quoi une feature est faite, dans l'ordre où on la lit. Elles ne disent pas dans quel ordre on l'écrit.
Dans le temps, le test vient en premier : le cycle de travail est rouge, vert, refactor, à chaque comportement. Jusqu'à #3505 cette page numérotait « Tester » en 8 sur 8, ce qui se lisait comme une consigne d'écrire les tests en dernier - et c'en était une. La section 8 rassemble donc les outils et niveaux de test, pas le moment où l'on s'en sert.
Avant le premier test : ce que le produit doit FAIRE
Une fonctionnalité neuve change ce que le produit fait, donc l'étape 2b de l'ouverture de
chantier s'applique : la capacité se décrit dans la
spécification vivante avant d'être écrite, par /instruire puis
/proposer. Les tâches du changement deviennent alors les lots du chantier, et se cochent dans
les commits qui les réalisent.
Une capacité est ici la capacité métier au sens de
l'ADR 0014, nommée <paquet>/<geste> :
passage/emport-d-une-nuit. Son grain est le geste, pas l'écran - une capacité traverse
volontiers l'écran et la ligne de commande.
Cette page décrit ensuite comment on l'écrit. La spécification dit quoi, et elle survit à la fermeture de l'issue.
1. Créer le paquet et ses 4 couches¶
Sous src/main/java/fr/univ_amu/iut/, créez mafeature/ avec les 4 sous-paquets. Chacun a une
règle stricte (vérifiée par ArchUnit, cf. Architecture) :
mafeature/
├── model/ ← entités (records) + services + model/dao/ (SQLite) : AUCUN JavaFX
├── viewmodel/ ← état observable (javafx.beans.property) : pas de javafx.scene/fxml/stage
├── view/ ← Controller + MaFeature.fxml + mafeature.css : ne touche jamais la base
└── di/ ← MaFeatureModule (Guice) : assemble la feature
2. Le modèle (model/)¶
- Une entité en
record(immuable), p. ex.record Truc(Long id, String nom). - Un DAO en
PreparedStatementhéritant du patron des autres*/model/dao/(pas d'ORM). - Un service qui orchestre les DAO et porte la logique métier.
- Si le schéma change : ajoutez une migration
src/main/resources/db/migration/V0x__ma_table.sql(numéro suivant). Elle s'applique automatiquement au démarrage.
Frontière
Rien ici n'importe JavaFX : le test model_sans_javafx y veille.
3. Le ViewModel (viewmodel/)¶
Expose l'état en propriétés observables et la logique de présentation :
public class MaFeatureViewModel {
private final ObservableList<Truc> trucs = FXCollections.observableArrayList();
private final StringProperty message = new SimpleStringProperty("");
public MaFeatureViewModel(ServiceMaFeature service) { /* ... */ }
public ObservableList<Truc> trucs() { return trucs; }
public StringProperty messageProperty() { return message; }
}
Frontière
Uniquement javafx.beans ici : pas de javafx.scene/fxml/stage (test viewmodel_sans_javafx_ui).
4. La vue (view/)¶
- Un
MaFeature.fxml+ unMaFeatureControllerqui se lie au ViewModel (binding) et ne touche jamais la base directement (testview_sans_jdbc). - Le controller est injecté : il reçoit son ViewModel par constructeur
@Inject.
Où placer les .fxml / .css
À côté du controller, dans src/main/java/.../view/ (pas dans src/main/resources). Le
pom.xml copie les fichiers non-Java de src/main/java dans target/classes au même chemin de
paquetage. Seules les ressources partagées (migrations db/migration, thème) vivent dans
src/main/resources.
public class MaFeatureController {
@FXML private TableView<Truc> table;
@Inject public MaFeatureController(MaFeatureViewModel vm) { this.vm = vm; }
@FXML private void initialize() { table.setItems(vm.trucs()); /* bindings... */ }
}
Tables : densité uniforme et colonnes configurables
Deux aides du socle
commun.view
rendent une table cohérente avec le reste de l'application :
TableDonnees.uniformiser(table)(ouuniformiserNavigable) : densité,placeholderet habillage communs (#690).GestionnaireColonnes.installer(table, menu, colonnes): offre « Colonnes… » (panneau masquer / réordonner façon Notion) au clic droit de la table et dans unMenuButton☰ « outils ». Décrivez les colonnes aveccolonnesParDefaut(table)(en-tête = libellé, colonne de tête = identité verrouillée) ou uneList<GestionnaireColonnes.Colonne>à la main quand l'identité est ailleurs (ex. Qualification) ou que les en-têtes sont des icônes.
Une action de clic droit propre à la vue (ex. « Fiche de l'espèce ») se compose :
installer(table, menu, colonnes, itemAction) la place avant « Colonnes… », sans l'écraser. Une
vue à plusieurs tables mais un seul ☰ (ex. Analyse : espèces/carrés/observations) câble
chaque table par installerClicDroit(table, colonnes, …) et fait pointer le ☰ vers la table active
via GestionnaireColonnes.ouvrir(...).
Actions de ligne et double-clic (#1792) : une table de données offre aussi les gestes sous le
curseur, dans un ordre stable d'un écran à l'autre (action principale, actions secondaires,
Validation ▸, Copier ▸, puis « Colonnes… » toujours en dernier). Le socle fournit
DoubleClicLigne.installer(table, action) (qui pose du même coup la sélection au clic droit, sans
casser une sélection multiple), MenuLigne.item(libelle, table, action),
MenuCopier.creer(table, Entree…) et ActionVigieChiroPassage.item(…). Le double-clic reste le
miroir de l'action principale du menu, et toute action qu'il déclenche doit rendre compte
quand elle n'aboutit pas - un geste sans état visible ne peut pas être muet :
voir ADR 0021 et la section
Actions de ligne d'une table.
Persistance (#994) : pour retenir la disposition par écran (ordre + visibilité restaurés à la
réouverture), remplacez installer par installerEtPersister(table, menu, colonnes, depotColonnes,
feature, cle, …) (le controller injecte DepotDispositionColonnes). Pour que les vues mémorisées
(#623) capturent aussi les colonnes, passez un AdaptateurColonnes à GestionnaireVues :
GestionnaireColonnes.adaptateurMonoTable(cle, table, colonnes) pour une table, ou un adaptateur à
plusieurs entrées de map pour une vue multi-tables.
Tables exploratoires : la barre de filtres du socle
Si votre table explore un corpus (par opposition au détail d'un seul objet), elle prend la barre « à la Notion » plutôt qu'un filtre écrit à la main. Six écrans la portent ; en écrire un septième à la main serait la duplication que le chantier #3092 a supprimée.
Cinq gestes, dans cet ordre :
- le ViewModel expose deux listes -
trucs()(tout) ettrucsFiltres()(uneFilteredList) - plus sonFiltres<Truc>; - la table se pose sur une
SortedListpar-dessus la liste filtrée, comparateur lié à celui de la table. UneFilteredListposée nue est non modifiable :TableViewrenonce alors à trier et vide sonsortOrderen silence ; - un catalogue
CriteresMaFeaturedéclare les critères (CritereListe,CritereBooleen,CritereLieu) ; les clés partagées viennent deClesCriteres, les clés propres restent chez vous (ADR 3096) ; - un collaborateur
FiltresVuesMaFeatureassemble barre, vues mémorisées et mémoire de session - le contrôleur lui passe les nœuds du FXML regroupés, ce qui le garde sous le plafondGodClass; - le FXML gagne la barre et le bandeau de retour : sans lui, la mémoire de session remettrait des filtres amputés en silence (ADR 3093).
Deux règles à ne pas rater, parce qu'elles ne font rougir aucun test :
- le domaine d'une puce se calcule sur
filtres().saufLui(CLE), jamais sur la liste affichée - sinon la puce s'auto-effondre sur la valeur déjà cochée (ADR 3095) ; - si l'écran porte un verdict ou un résumé sur l'ensemble, il se calcule sur la liste non filtrée (ADR 3092).
Enfin, le décompte des critères s'ancre dans la fiche d'écran par une balise
<!--inv:criteres-mafeature-->N<!--/inv-->, et la clé s'ajoute à DocumentationAJourTest.
5. Le module Guice (di/) + l'auto-découverte¶
Un module qui publie service/VM, hérité de ModuleDeFeature (le DSL du socle) :
public class MaFeatureModule extends ModuleDeFeature {
@Override public Fonctionnalite fonctionnalite() { // identité + feature-flag (obligatoire)
return new Fonctionnalite("mafeature", "Ma fonctionnalité", Categorie.OPTIONNELLE);
}
@Override protected void configure() {
activite(ActiviteMaFeature.class); // carte d'accueil (optionnel)
// indicateur(...), ongletReglages(...), actionMenu(...) au besoin
}
@Provides MaFeatureViewModel vm(ServiceMaFeature s) { return new MaFeatureViewModel(s); }
}
On ne touche PAS RacineInjecteur : les modules de feature sont auto-découverts
(ServiceLoader<ModuleDeFeature>, cf. Injection). Déclarez
MaFeatureModule comme service dans les deux listes (gardées synchronisées par
DecouverteModulesTest) :
src/main/resources/META-INF/services/fr.univ_amu.iut.commun.di.ModuleDeFeature(une ligne : le FQN du module) : chemin classpath (tests, fat-jar) ;module-info.java: ajoutez le module auprovides fr.univ_amu.iut.commun.di.ModuleDeFeature with …, chemin module-path (javafx:run).
Contribuer aux points d'extension
Une feature peut aussi ajouter un compteur d'accueil (indicateur(...)), un onglet de
réglages (ongletReglages(...), cf. OngletReglages + DescripteurReglage) et une entrée de
menu principal (☰) (actionMenu(...), cf. ActionMenu) : toujours sans toucher le socle.
6. Brancher la navigation (inversion de dépendance)¶
Pour qu'un autre écran ouvre le vôtre sans dépendre de votre view, suivez le patron
Ouvrir* (cf. Architecture) :
- Publier le contrat dans le socle
commun/view/OuvrirMaFeature.java: - L'implémenter dans
mafeature/view/NavigationMaFeature.java(charge le FXML via lacontrollerFactoryGuice, appellecontroleur.ouvrirSur(...), puisnavigateur.empiler(...)). CalquezNavigationPassage. - Le binder dans
MaFeatureModule:bind(OuvrirMaFeature.class).to(NavigationMaFeature.class);. - L'écran appelant injecte
OuvrirMaFeatureet appelleouvrir(...).
Entrée depuis l'accueil ?
Si votre écran est une activité d'accueil (carte sur la page d'accueil), publiez une
ActiviteAccueil (cf. les Activite* existantes) : le MainController peuple les cartes
automatiquement.
Données modifiées par une sous-activité ?
Si votre écran affiche des données qu'un écran ouvert par-dessus peut changer, implémentez
RafraichirAuRetour
sur le controller : le Navigateur le recharge au retour.
Données modifiées pendant qu'on les regarde ?
C'est une autre question, et elle a une autre réponse. Un import, une synchronisation, une
restauration écrivent sans que l'utilisateur ait quitté l'écran : le retour ne se produit jamais.
Déclarez alors
SuitLaRevision
et implémentez rafraichirDepuisLaDonnee(). Le Navigateur s'occupe de l'abonnement et de sa
restitution, cf. Patrons › Le signal de mutation.
Les deux contrats se déclarent ensemble : ils ne couvrent pas les mêmes écritures. Et si votre écran écrit, la règle vaut aussi dans l'autre sens : tu écris, tu signales.
Un Bindings.create…Binding ?
Sa liste de dépendances doit énoncer tout ce que son calcul lit, méthodes appelées comprises. Rien ne le vérifie : un binding incomplet affiche juste tant qu'une autre propriété change au bon moment. Un cliquet compte les sites pour qu'un nouveau soit vu, cf. Patrons › Un binding déclare tout ce qu'il lit.
Un bouton grisé, un contrôle masqué, un libellé calculé ?
Ne les posez pas au montage. Un setDisable(!x) ou un setVisible(x) lu dans initialize()
fige la valeur de cet instant, et l'écran restant vivant dans la pile du Navigateur, aucune
reconstruction ne la reprendra. Faites porter l'état par une propriété, et liez le contrôle
(disableProperty().bind(...), VisibiliteGeree.lier(...)), cf.
Patrons › Un état de contrôle se lie.
Trois exceptions légitimes, où le fait ne peut pas changer en cours de session : ce qui dérive d'un drapeau de fonctionnalité, d'une ressource embarquée, ou ce qui vit dans une modale rebâtie à chaque ouverture.
Et si le libellé de navigation de votre écran dérive de la donnée, il se relibelle : cf. Navigation › Un libellé dérivé de la donnée.
Développer une feature derrière un flag¶
Le champ Categorie de fonctionnalite() pilote la désactivabilité de la feature (cf.
Injection › Feature-flags) :
OPTIONNELLE: feature autonome, active par défaut, que l'utilisateur peut couper depuis l'onglet « Fonctionnalités » des Réglages.EXPERIMENTALE: feature inactive par défaut. C'est le mode trunk-based : on merge une feature en cours de dev surmainsans l'exposer, puis on l'active à la demande (-Dvigiechiro.features.<id>=onen dev/CI, ou l'interrupteur des Réglages) jusqu'à ce qu'elle passeOPTIONNELLE.COEUR: le défaut sûr pour une feature dont un autre écran dépend (voir l'avertissement).
Une feuille n'est désactivable que si son contrat Ouvrir* est neutralisé
Si un autre écran ouvre le vôtre via un Ouvrir* injecté non optionnel, couper votre
feature casserait cet écran : elle doit rester COEUR. Pour la rendre réellement désactivable,
neutralisez son contrat chez le consommateur :
- déclarez le contrat en
OptionalBinderavec un défaut inerte (newOptionalBinder(binder(), OuvrirMaFeature.class).setDefault().toInstance(id -> {})), votre module faisant.setBinding().to(NavigationMaFeature.class); - côté écran appelant, injectez
Optional<OuvrirMaFeature>et masquez le point d'entrée (bouton/onglet) quand il est absent ; - passez la
CategorieàOPTIONNELLE/EXPERIMENTALEet ajoutez le cas au test « désactiver la feature laisse l'injecteur constructible » (DecouverteModulesTest).
import-vigiechiro est la feature de référence (déjà pleinement optionnelle).
7. Ajouter un aperçu (capture d'écran)¶
Les écrans documentés ont un aperçu PNG régénéré en CI. Pour le vôtre :
- Écrivez
mafeature/outils/CaptureMaFeature.javasur le patron desCapture*existants : il rend la vue hors-écran viaApercuFx(Headless Platform), sur une base SQLite jetable seedée. - Ajoutez la classe à
.github/assets/capture_screenshots.pyet l'aperçu au manifeste.github/assets/captures.manifest. - Le workflow « Aperçus des vues » régénère les PNG à chaque push sur
main.
Écran avec écoute audio ?
Réutilisez
SonDemo
(WAV de synthèse) +
AttenteAudio
pour afficher un spectrogramme réel dans la capture.
8. Les niveaux de test (à écrire AVANT le code qu'ils couvrent)¶
Chaque puce ci-dessous répond à « avec quoi je teste ceci ? ». Le quand est réglé ailleurs et ne dépend pas du niveau : le test s'écrit d'abord et on le voit rouge, sinon il faudra réintroduire le défaut à la main pour savoir ce qu'il couvre vraiment (cycle d'une issue).
- ViewModel / service : tests unitaires (JUnit 5 + AssertJ), Mockito pour les dépendances.
- Vue : test d'intégration TestFX (headless) qui charge le FXML et vérifie les bindings.
- Geste : si votre écran porte une action qui écrase ou supprime quelque chose, elle doit être
cliquée dans un test, et son refus aussi. Cela suppose que ses dialogues passent par les
ports du socle (
Confirmateur,Notificateur,SelecteurFichier,DemandeurDeChoix: cf. Patrons). UnshowAndWait()en dur - alerte ou sélecteur de fichier - fige le test : le geste redevient intestable, et vous ne saurez que son bouton existe. - Formulaire : si votre écran demande une saisie (créer, modifier, paramétrer), ce n'est pas
un dialogue - c'est une vue. Faites-en une modale (FXML + controller + ViewModel + une entrée
ouvrirModale*sur votre façade de navigation), commeModalePoint,ModaleSiteouModaleSelection. UnDialog<T>bâti à la main rend le geste injouable, sa validation intestable, et sa capture de documentation impossible (il faudrait la reconstruire à la main - et elle dériverait). - Architecture : rien à écrire,
ArchitectureTestcouvre vos frontières automatiquement. - Parcours complet : un test
fr.univ_amu.iut.e2e.*si votre écran s'inscrit dans un flux.
Détails et pièges dans Tests et qualité.
Conventions de code et de commit¶
Code :
- formatage Spotless / Palantir Java Format (le hook pre-commit s'en charge ; sinon
./mvnw spotless:apply) ; - doc-comments Markdown
///(JEP 467), pas de/** */HTML, gardé parConventionsDEcritureTest; - noms de classes en français, sans accents dans les identifiants (
Navigateur,Passage,EtapeNavigation…), gardé parConventionsDEcritureTest; - pas de tiret cadratin : tiret simple, deux-points ou virgule. La règle vaut partout (doc,
commentaires, chaînes affichées, styles, scripts, ateliers), et un garde la fait respecter
(ADR 2843). Le glyphe reste permis là
où il est la donnée plutôt qu'une ponctuation : la valeur absente (
Formats.VALEUR_ABSENTE), un libellé de l'application cité entre guillemets français, une classe de caractères d'analyseur.
Commits : Conventional Commits en français, le
scope étant le nom de la feature ou du domaine (feat(passage): …). Le type pilote la version
publiée (feat: mineure, fix: patch, BREAKING CHANGE: majeure ; cf.
CI/CD et release). Petits commits logiques (un par préoccupation) ; toujours
créer un commit plutôt qu'amender.
Le flux complet de contribution
Le parcours fork → branche → PR (reviewer automatique, identité git à vérifier) est décrit dans CONTRIBUTING.md.
Checklist avant la PR¶
- Les 4 couches respectent leurs frontières (
./mvnw test→ArchitectureTestvert). - Module de feature déclaré comme service
ModuleDeFeaturedans les deux listesServiceLoader(auto-découverte, cf. Injection) - pas dansRacineInjecteur- et l'app démarre (./mvnw javafx:run). - Navigation branchée par contrat
Ouvrir*si ouverte depuis un autre écran. - Capture + manifeste si l'écran est documenté.
- Tests verts, le cliquet 4617 vert -
scripts/batterie.py --lanceproduit le rapport PMD quand le diff porte du.java(#5405) ; à la main seulement hors de la porte - et la couverture tenue par./mvnw -B verify -Djacoco.haltOnFailure=true. - Si le chantier porte un changement OpenSpec, la tâche réalisée est cochée dans les commits du travail, et le corps de la demande la nomme.
- Commits en Conventional Commits (cf. CONTRIBUTING.md).