Patterns et principes¶
L'architecture (cf. Architecture) applique des patrons de conception connus, chacun choisi pour une raison précise et pour faire respecter les principes SOLID ainsi que d'autres principes transverses (loi de Déméter, YAGNI, KISS, DRY… détaillés en fin de page, Au-delà de SOLID).
Chaque patron est présenté ainsi : le problème qu'il résout, la solution, comment il est utilisé ici (avec, selon les cas, un extrait et un lien vers le code), un diagramme quand il clarifie la structure ou le flux, et les principes qu'il sert.
Rappel SOLID
SRP responsabilité unique · OCP ouvert/fermé · LSP substitution de Liskov · ISP ségrégation des interfaces · DIP inversion des dépendances.
MVVM (Model-View-ViewModel)¶
Le problème. Mélanger affichage, logique de présentation et règles métier dans les controllers rend le code intestable (il faut une fenêtre) et non réutilisable (tout est lié à JavaFX).
La solution. Trois couches : le model (métier pur), le viewmodel (état observable +
logique de présentation), la view (FXML + controller) qui observe le viewmodel par data
binding. Le flux de dépendances va de la vue vers le modèle, jamais l'inverse.
Dans cette application. Chaque feature suit ce découpage. La vue ne fait que lier des contrôles à des propriétés ; elle ne calcule rien et ne touche pas la base.
classDiagram
class View {
Controller
FXML
}
class ViewModel {
Property
ObservableList
}
class Model {
services
DAO
}
View ..> ViewModel : se lie
ViewModel ..> Model : appelle
Principes. SRP (une responsabilité par couche), DIP (la vue dépend d'abstractions
observables, pas de logique concrète). Frontières garanties par ArchUnit (viewmodel_sans_javafx_ui,
view_sans_jdbc).
Objets-valeurs (records immuables)¶
Le problème. Des entités mutables (avec setters) se prêtent aux états incohérents, au partage accidentel d'une instance et aux bugs d'égalité (comparaison par référence).
La solution. Modéliser le domaine en record immuables : champs finaux, égalité par
valeur, aucun setter. Pour « modifier », on crée une nouvelle instance.
Dans cette application. Le domaine est quasi entièrement en records (plus de 250 : Passage, Site,
SequenceDEcoute, Observation…). Les DAO construisent ces records ligne par ligne via un
RowMapper, et les ViewModels les exposent dans des ObservableList.
Principes. Immuabilité (sûreté en lecture, raisonnement local) et SRP (l'entité ne porte que
ses données). Socle naturel du DAO et du RowMapper.
État observé (un statut distant n'est pas un statut du domaine)¶
Le problème. Un système distant expose un état (l'avancement d'un calcul, le verrouillage d'un site…). La tentation est de l'ajouter à l'énumération de statuts qu'on possède déjà : un seul enum, un seul stepper, tout le monde est content. Sauf que cet état ne nous appartient pas. Il change sans nous prévenir, il n'est pas forcément monotone, et le jour où il recule, notre statut ment.
La solution. Le garder distinct : une énumération à part, alimentée par lecture, jamais par une transition locale. Le statut du domaine continue de dire ce que nous avons fait ; l'état observé dit ce que l'autre en a fait. Et comme une lecture réseau coûte cher, on persiste le dernier relevé avec sa date : l'écran affiche alors un souvenir, en le disant.
Dans cette application. EtatTraitement (EPIC #1259) suit l'analyse Tadarida côté serveur (PLANIFIE →
EN_COURS → FINI/ERREUR/RETRY) sans étendre StatutWorkflow : une relance ramène FINI à
PLANIFIE, si bien qu'un statut local « Traité » deviendrait faux. DEPOSE reste terminal (« ma part
est faite »). Le dernier relevé est mis en cache (participation_traitement), et SuiviTraitement est
le point de relevé unique : il interroge et mémorise. Même partition que StatutPlateforme
(sites).
Le disque est un autre système que nous ne possédons pas (EPIC #1297). Les fichiers audio d'un
passage peuvent disparaître sans nous : purge volontaire, disque externe débranché, dossier déplacé.
« Archivé » n'est donc pas une valeur de StatutWorkflow mais un constat :
DisponibiliteAudio (COMPLETE / PARTIELLE / ABSENTE), produit par ServiceDisponibiliteAudio en
regardant le disque (un Files.list par dossier, pas un exists par fichier), mis en cache et
invalidé aux gestes qui le changent. Toute l'IHM se règle là-dessus : l'écoute se voile, l'audit
informe au lieu de crier, la réactivation s'offre.
Un geste déclaré est autre chose qu'un état observé. Le projet est passé des premiers au second : l'audio absent ne se déclare plus, il s'observe (ADR 0048) - l'utilisateur possède ses fichiers, et son absence n'est jamais une corruption.
Ce basculement a rendu les marqueurs inutiles l'un après l'autre. archived_at a disparu du code ;
originals_purged_at ne gouverne plus l'audit, qui ne contrôle plus du tout les bruts : ce sont
des copies optionnelles de ré-analyse (ADR 0036),
absentes de la plupart des nuits, donc leur absence est l'état normal - et un état normal reste
silencieux. Il n'y avait plus rien à distinguer : le disque et la base disent la même chose. Les deux
colonnes devenues mortes (archived_at, originals_purged_at) ont depuis été retirées du schéma
(V31, #2429).
Principes. SSOT (la source de vérité reste distante : on ne la copie pas, on la date), honnêteté de l'IHM (« dernier état connu le… » plutôt qu'une fraîcheur feinte) et KISS (pas de sondage : on relit à l'ouverture, à la demande, ou après une action).
Cascade de preuves (vérification graduée, refuser plutôt que se tromper)¶
Le problème. Rebrancher des fichiers retrouvés sur un passage archivé demande de répondre à : « ce WAV est-il bien celui-là ? ». Le nom ne prouve rien (deux nuits d'un même carré portent des noms voisins ; un fichier peut être renommé, tronqué, ré-encodé). Une empreinte cryptographique prouve tout, mais n'existe pas pour les passages antérieurs, ni pour un passage reconstruit depuis la plateforme (#1305) : exiger la preuve forte, c'est exclure exactement les cas où l'on en aurait le plus besoin. Et la faute à ne pas commettre est claire : rebrancher silencieusement le mauvais audio sur des observations, ce qui fabrique une donnée fausse et indétectable.
La solution. Une cascade de preuves de force décroissante, où chaque niveau tranche s'il le peut et passe la main sinon, et où le doute non levé est un refus, jamais un « probablement bon » :
- empreinte (SHA-256 des 64 premiers Kio,
Empreintes.empreinteCourte) : identité certaine, quand elle a été capturée ; - structure : la durée réelle lue dans l'en-tête WAV confrontée à celle qu'on a enregistrée (tolérance 0,15 s), et la taille en octets ;
- acoustique (
AnalyseAcoustique, filtre de Goertzel) : les cris des observations rapatriées sont-ils réellement présents, aux fréquences et aux instants annoncés ? C'est la preuve qui reste quand aucune autre n'existe, et c'est la plus parlante : elle valide l'audio contre les données qu'on s'apprête à y rebrancher.
Le verdict est un type scellé (VerdictIdentite = Acceptee(NiveauConfiance, preuves) /
Refusee(motif)) : l'appelant ne peut pas confondre « accepté avec certitude » et « accepté sur faisceau
d'indices », et le niveau de confiance minimal atteint remonte jusqu'au rapport, donc jusqu'à
l'utilisateur.
Dans cette application (#1309, consommé par #1302 et #1305). VerificationIdentiteAudio porte la cascade ;
ServiceReactivationPassage ne copie que les fichiers acceptés, laisse les divergents de côté et les
énumère ; un passage sans empreinte reste donc réactivable, mais par la preuve acoustique, pas par la
confiance dans un nom.
Corollaire : un fichier reconstruit est un candidat comme un autre (#1406). Quand l'utilisateur n'a gardé que ses bruts, les séquences sont régénérées (la transformation est déterministe, R11) puis soumises à la même cascade. Si le code de transformation n'a pas changé, l'empreinte de la tranche régénérée est celle capturée avant l'archivage → CERTITUDE ; s'il a changé, la cascade descend d'un cran au lieu d'accorder une confiance aveugle. C'est le point à retenir : la reproductibilité est une preuve, pas un prérequis - on ne se dispense jamais de vérifier au motif qu'on a fabriqué le fichier soi-même. Et un brut refusé ne régénère rien : recalculer à partir d'un fichier dont l'identité n'est pas établie, c'est fabriquer du faux.
Cas limite : le passage reconstruit (EPIC #1653). Un passage reconstruit depuis la plateforme
(#1305) n'a jamais eu d'empreinte : ni sur ses originaux (un placeholder …-reconstruit.wav tient
lieu d'inventaire, sans fréquence d'acquisition), ni sur ses séquences. La cascade y tomberait donc
directement sur l'acoustique - qui produit des faux négatifs sur des cris réels faibles. Mais l'audio
régénéré est, par construction, un extrait verbatim du brut désigné par l'utilisateur (la
transformation copie le PCM sans rééchantillonnage, prouvé octet à octet) : son identité tient à la
régénération elle-même, pas à une empreinte qu'on n'a pas. L'hydratation (HydratationDepuisBruts,
1650/#1682) l'accepte donc sur preuve structurelle (nom + durée, FORTE), et la concordance¶
acoustique y devient un indice non bloquant (IndiceAcoustique), jamais un veto. La chaîne :
InventaireBrutsSource (#1649, lit la Fe du log et énumère les bruts) → régénération → rebranchement
structurel → AdoptionOriginauxReconstruits (#1651, remplace le placeholder par les vrais originaux,
déclarés « purgés » puisque connus mais non stockés localement). Détail : AnalyseAcoustique mesure
désormais l'énergie de pointe sur une courte fenêtre glissée dans celle de l'observation (#1687) - la
moyenne sur toute la fenêtre diluait un cri de quelques ms noyé dans plusieurs secondes, d'où des faux
négatifs qui rendaient l'hydratation d'un vrai passage inopérante avant correction. Le pourquoi de ces
deux choix est consigné en ADR 0001
(identité structurelle) et ADR 0002 (énergie de pointe).
Cas limite : la nuit récupérée et pas encore hydratée (EPIC #2554). Une nuit rapatriée par la
synchro n'a ni audio ni séquences : il n'y a donc rien à confronter au dossier désigné, et la cascade
n'a pas de prise. C'est un état distinct du passage reconstruit, qui a ses séquences mais pas ses fichiers.
La réactivation le reconnaît et va d'abord chercher les observations (phase 0, HydratationSquelette,
2555) : ce sont elles qui apportent les noms et l'horodatage sur lesquels la cascade reconnaît¶
ensuite les fichiers de cette nuit dans une carte qui en contient plusieurs. La suite est le chemin ci-dessus, inchangé.
Deux points de conception s'y rattachent, consignés en
ADR 2554 : l'hydratation se
fait en place (un écran est ouvert sur cet identifiant, et un squelette porte peut-être des saisies
manuelles que la plateforme ignore), là où la reconstruction remplace la nuit ; et la source diffère selon
l'appelant : le repli sur la pagination donnees est justifié sur une nuit désignée, jamais sur le
balayage de compte de la synchro.
Principes. Fail-safe (ne pas pouvoir prouver = ne pas faire), honnêteté (dire avec quelle force on a conclu), et refus de la fausse alternative « preuve parfaite ou rien ».
Issue d'appel triée (le transport ne parle plus par silence)¶
Le problème. Un client HTTP qui « dégrade proprement » convertit tout échec en Optional.empty()
ou liste vide. C'est le bon réflexe pour un seul cas : « je ne suis pas connecté » (l'application
vit hors ligne). Pour les autres, c'est une perte d'information catastrophique : un 422 devient une
collection vide (l'import mort et muet de #1277, 4806 observations invisibles), un délai réseau
devient « aucun résultat », et une panne au milieu d'une pagination rend un préfixe silencieux
pire que le vide. L'appelant ne peut ni informer l'utilisateur, ni décider correctement.
La solution. Un type scellé qui rend l'issue exhaustive à la compilation :
ReponseApi<T> = Succes(valeur) / NonConnecte / Injoignable(cause) / Refuse(statut, corps).
Un switch qui oublie une branche ne compile pas : la famille de bugs #1277, c'est « un cas auquel
personne n'a pensé ». Le comportement commun vit dans les variantes par override (enOptionnel,
transformer, lireAvec, puis, echec), jamais par switch (this). Là où le silence reste le
comportement voulu, c'est l'appelant qui le choisit, explicitement : enOptionnel().
Dans cette application (#1284). TransportVigieChiro émet et trie ; ClientVigieChiro nomme les
endpoints ; PaginationEve est tout-ou-rien (l'issue de la page fautive - ou de son garde-fou atteint,
3046 - jamais un préfixe).¶
Conséquences : la modale de connexion distingue « jeton refusé (401) » de « plateforme injoignable » ;
l'import et le suivi du traitement disent pourquoi ; la garde anti-purge des rapprocheurs est
inchangée mais sa cause remonte au bandeau ; la garde anti-relance du dépôt devient fail-safe (état
illisible sans --forcer = pas de lancement) ; la vérification d'un dépôt hors ligne lève
« vérification impossible » au lieu d'un faux « tout manquant ». Le contrat live verrouille
max_results=1000 → Refuse(422) : la sonde qui aurait rendu #1277 bruyante par construction.
Principes. Honnêteté (une panne n'est pas une donnée), exhaustivité par le compilateur plutôt
que par la relecture, fail-safe (ne pas pouvoir prouver qu'une action destructrice est sûre = ne pas
la faire), et un vocabulaire unique des messages d'échec (ReponseApi.echec()).
Le verdict porte son message (résultat scellé, message par variante)¶
Le problème. Une opération à plusieurs issues renvoie souvent un rapport « à trous » :
(boolean succes, String motif, Rapport rapport), dont l'appelant doit deviner quels champs sont
renseignés dans quel cas. Chaque appelant re-tricote alors le même if : et chaque surface (IHM,
CLI) invente sa propre phrase pour dire la même chose. Les deux finissent par diverger.
La solution. Un type scellé dont chaque variante porte ce qui la caractérise, et sait le dire. Le message n'est pas dans l'appelant : il est dans le verdict.
public sealed interface ResultatReset {
int codeSortie(); // 0 fait · 2 refusé (distinct de 1, l'échec d'exécution)
String enClair(); // ce qu'il faut dire à l'utilisateur
record Refuse(String motif, BilanRecuperabilite bilan) implements ResultatReset { … }
record Fait(BilanSauvegarde sauvegarde, Path filet, int passagesReconstruits,
RapportAudit audit, List<String> aRetablir) implements ResultatReset { … }
}
L'IHM affiche enClair(), la CLI affiche enClair() et sort sur codeSortie(). Aucune des deux
ne traduit un état en phrase : la parité CLI ↔ IHM est structurelle, pas maintenue à la main.
Dans cette application. VerdictCarre (#733 : Concorde / Diverge / HorsGrille / Indisponible, dont
le message vide exprime le silence hors ligne) et ResultatReset (#1419). Même famille que
l'issue d'appel triée, appliquée aux
opérations locales plutôt qu'au transport : exhaustivité par le compilateur, comportement par
override et jamais par switch (this).
Refuser avant de détruire (l'ordre des garde-fous est la garantie)¶
Le problème. Une opération destructrice qui vérifie ses conditions au fil de l'eau laisse, au premier obstacle, un état à moitié détruit : le pire des deux mondes. Et l'utilisateur, lui, ne distingue plus « ça a refusé » de « ça a planté en route ».
La solution. Tous les refus avant la première écriture, et un refus qui le dit : rien n'a été modifié. L'ordre des étapes n'est pas une commodité de lecture, c'est la garantie.
Le reset guidé (#1419) en est le cas d'école :
- dire ce qu'on perdrait : une nuit dont l'audio n'est ni sur le disque ni sur le serveur est perdue pour de bon ; sans acceptation explicite, on s'arrête là ;
- vérifier que la plateforme répond, la base neuve se repeuple depuis le serveur : le détruire alors qu'il est injoignable laisserait un workspace vide. Aucune sauvegarde ne rendrait ça acceptable, et c'est le garde-fou décisif ;
- sauvegarder ; 4. base neuve ; 5. repeupler ; 6. auditer.
Le pendant, pour une écriture irréversible : le serveur d'abord, la base ensuite (#1418). Le message n'est écrit localement qu'après que le serveur l'a accepté. L'inverse laisserait, au moindre refus, un message que l'observateur croirait envoyé et que le validateur ne verrait jamais.
Corollaires.
- Une confirmation nomme ce qu'on perd : elle énumère les nuits, ou cite le texte qui va partir. Un « êtes-vous sûr ? » générique n'est pas un consentement : on ne consent qu'à ce qu'on a lu. C'est ce message-là que le test vérifie, pas le fait qu'un dialogue s'ouvre.
- Une écriture définitive mérite d'être désactivable.
discuter-validateur(#1418) est une fonctionnalité à part de la lecture du fil : couper l'écriture laisse la lecture intacte. Lire est sans conséquence ; écrire ne se retire pas. - Un refus a son propre code de sortie (
2), distinct du succès (0) et de l'échec d'exécution (1) : un script peut ainsi refuser d'enchaîner.
Un invariant, deux politiques de surface (l'unification d'un geste, #1656)¶
Quand un même geste métier vit sur plusieurs surfaces (IHM, CLI) et se met à diverger, on ne le recopie pas : on remonte la règle de fond à un seul endroit (le service), et chaque surface n'en porte qu'une présentation mince.
Le chantier « importer les observations d'un passage » (#1656) est le cas d'école : la même décision
« un seul jeu par passage » était réimplémentée cinq fois, dont deux qui plantaient sur la contrainte
UNIQUE. Après unification :
- la règle vit une seule fois, dans le noyau de service (
NoyauImportObservations) : hors remplacement, refuser avant l'INSERT (cf. « Refuser avant de détruire ») ; - la surface IHM la rend par une question (
DecisionRemplacementJeu: détecter → confirmer → remplacer | abandonner), partagée par les fronts de « Sons & validation » ; - la surface CLI la rend par un refus d'usage (
GardeJeuExistant, code2, « relancez avec--remplacer»), partagé par les commandes d'import.
Le test à se poser est celui de la sur-unification : fondre les deux présentations en une seule serait une erreur (un dialogue interactif et un code de sortie ne sont pas le même objet). Le bon découpage : une règle, deux adaptateurs. Une capacité présente d'un seul côté (ou rendue différemment de chaque côté) est une dette invisible ; la règle centralisée est la garantie qu'elles ne redivergeront pas.
Package-by-feature (tranches verticales)¶
Le problème. Une organisation par couche (controllers/, services/, dao/…) éparpille une
même fonctionnalité dans tout le projet : pour modifier un écran, on touche partout.
La solution. Regrouper le code par fonctionnalité : sites/, passage/… chacun contenant ses
4 couches. Une feature devient une tranche verticale autonome.
Dans cette application. Les 16 features sont des paquets autonomes ; le socle commun/ porte le partagé
(chrome, persistance, DI). On ouvre, modifie ou supprime une feature sans naviguer ailleurs.
Principes. Forte cohésion / faible couplage ; OCP à l'échelle du produit (ajouter une
feature ≈ ajouter un paquet, sans toucher aux autres : garanti par
pas_de_dependance_inter_feature_vers_la_vue).
Injection de dépendances + Composition Root¶
Le problème. Si chaque objet crée ses dépendances (new ServiceX()), le graphe est figé,
impossible à substituer en test, et le câblage est dispersé partout.
La solution. Les objets reçoivent leurs dépendances (constructeur), et un seul endroit, la Composition Root, assemble le graphe complet.
Dans cette application. RacineInjecteur
installe un socle explicite (CommunModule, PersistenceModule) puis auto-découvre les
modules de feature via ServiceLoader<ModuleDeFeature>, en ne gardant que ceux dont la fonctionnalité
est active. Ajouter une feature ne modifie donc pas ce fichier (cf. Injection).
Même les controllers FXML sont injectés (cf. Factory plus bas). En test, on substitue une base
jetable via Modules.override(RacineInjecteur.modules()), sans changer le code de production.
public static List<Module> modules() {
List<Module> modules = new ArrayList<>();
modules.add(new CommunModule()); // socle : toujours explicite
modules.add(new PersistenceModule());
// features : auto-découvertes, filtrées par les feature-flags
ServiceLoader.load(ModuleDeFeature.class).stream()
.map(ServiceLoader.Provider::get)
.filter(Fonctionnalites.filtreActives())
.forEach(modules::add);
return modules;
}
Détails et diagramme de séquence : Injection (Guice).
Principes. DIP (on dépend d'abstractions, le câblage est externalisé) et IoC (« ne nous appelez pas, nous vous appellerons » : le conteneur instancie).
Singleton (géré par le conteneur)¶
Le problème. Certaines ressources doivent être uniques dans toute l'application : une seule base, un seul service de navigation. Les multiplier créerait des incohérences (deux connexions, deux historiques).
La solution. Plutôt que le Singleton « maison » (constructeur privé + champ statique, difficile à
tester et à substituer), on délègue l'unicité au conteneur : @Singleton Guice.
Dans cette application. SourceDeDonnees, Navigateur, les Navigation* et la plupart des providers
de DAO et de services des features sont @Singleton (plus de 130 déclarations) : une seule instance par
injecteur, mais toujours injectée (donc remplaçable en test).
Principes. Évite l'état statique global tout en restant testable : l'unicité est une décision de câblage, pas une contrainte gravée dans la classe.
Separated Interface (contrats Ouvrir*)¶
Le problème. Si sites appelait directement passage.view.NavigationPassage, les features
seraient couplées entre elles : impossible de les faire évoluer indépendamment (et la règle
ArchUnit l'interdit).
La solution. Publier une interface dans le socle, l'implémenter dans la feature cible : l'appelant dépend de l'abstraction, jamais de l'implémentation. La dépendance est inversée.
Dans cette application. OuvrirPassage
(socle) est implémenté par
NavigationPassage
(feature passage) et bindé par PassageModule. sites injecte OuvrirPassage.
classDiagram
class OuvrirPassage {
<<interface>>
+ouvrir(Long, ContexteSite)
}
class NavigationPassage
SiteDetailController ..> OuvrirPassage : injecte
NavigationPassage ..|> OuvrirPassage : implémente
note for OuvrirPassage "dans le socle commun.view"
Principes. DIP (les deux features dépendent du contrat, pas l'une de l'autre) et OCP
(brancher une nouvelle implémentation sans modifier l'appelant). La liste de référence des contrats
Ouvrir* (10) est maintenue à un seul endroit : Navigation.
Facade (Navigation*)¶
Le problème. Ouvrir un écran demande plusieurs gestes : charger le FXML, brancher la
controllerFactory, ouvrir le controller sur son contexte, empiler dans le Navigateur. Répétés tels
quels chez chaque appelant, ils seraient verbeux et fragiles.
La solution. Une façade par feature expose une opération simple (ouvrir(...)) qui
orchestre ces gestes en interne.
Dans cette application. NavigationPassage
(et ses homologues Navigation*) implémente le contrat Ouvrir* en cachant le FXMLLoader et le
Navigateur : l'appelant ne voit qu'ouvrir(idPassage, contexte). Le Navigateur lui-même est une
façade sur la zone centrale du chrome + l'historique.
Principes. SRP (la mécanique d'ouverture est encapsulée) et faible couplage (l'appelant ignore FXML / Navigateur).
Plugin / Extension (Multibinder)¶
Le problème. L'accueil affiche une carte pour certaines features (et un compteur de tableau de
bord pour d'autres). Si le MainController connaissait chacune, ajouter une contribution l'obligerait
à se modifier à chaque fois.
La solution. Le socle déclare un Set<T> que les features intéressées alimentent (multibinding
Guice), sans que le socle connaisse les contributeurs. Il injecte l'ensemble et l'agrège.
Dans cette application. Quatre points d'extension suivent ce patron, chacun avec un helper du DSL
ModuleDeFeature : ActiviteAccueil (carte
d'accueil, activite(...)), IndicateurAccueil (compteur, indicateur(...)), OngletReglages
(onglet de l'écran Réglages, ongletReglages(...)) et ActionMenu (entrée du menu principal (☰), actionMenu(...)).
Le contrat est agnostique de JavaFX (dans commun/view), la feature ne fournit que des données
(un descripteur, un libellé…), et c'est le socle (MainController, EcranReglagesController,
ConstructeurMenuOutils) qui construit les widgets. Exemple : une bascule de menu déclare une
BooleanProperty liée à ReglagesReactifs ; le socle en fait une CheckMenuItem.
classDiagram
class MainController {
Set~ActiviteAccueil~ activites
}
class ActiviteAccueil {
<<interface>>
}
MainController o-- ActiviteAccueil : agrège
ActiviteAccueil <|.. ActiviteMesSites
ActiviteAccueil <|.. ActiviteImporterNuit
Principes. OCP par excellence : le chrome est fermé à la modification mais ouvert à l'extension (une nouvelle carte = un nouveau binding, zéro ligne touchée dans le socle).
Feature = plugin. Le patron va jusqu'au bout : les modules de feature sont eux-mêmes
auto-découverts par RacineInjecteur (ServiceLoader<ModuleDeFeature>, cf.
Injection). Une feature complète (DAO, services, carte,
compteur, réglages, entrée de menu) s'ajoute donc sans toucher une seule ligne du socle ni de la
racine de composition : juste un XxxModule extends ModuleDeFeature déclaré comme service.
Interfaces de rôle fines (ISP)¶
Le problème. Une grosse interface « écran » avec garde de sortie + fil d'Ariane + rafraîchissement + … forcerait chaque écran à tout implémenter, même ce qu'il n'utilise pas.
La solution. De petites interfaces optionnelles, à responsabilité unique, qu'un écran
implémente seulement si la capacité le concerne. Le Navigateur les détecte par instanceof.
Dans cette application.
| Interface (1 rôle) | Implémentée par les écrans qui… |
|---|---|
GardeQuitter |
ont une saisie non enregistrée |
EmplacementNavigation |
ont une place hiérarchique (fil d'Ariane) |
RafraichirAuRetour |
affichent des données mutables |
Un écran lecture seule n'implémente aucune des trois.
Principes. ISP (aucun écran n'est forcé d'implémenter ce qu'il n'utilise pas) et OCP (le Navigateur honore de nouvelles capacités sans connaître les écrans).
DAO (Data Access Object)¶
Le problème. Du SQL PreparedStatement mélangé à la logique métier ou à l'IHM est impossible à
tester, à réutiliser, et viole la séparation des couches.
La solution. Isoler l'accès aux données derrière des objets dédiés ; le reste du code ignore JDBC et dialogue avec des services.
Dans cette application. Chaque entité a son DAO dans */model/dao/. La règle ArchUnit view_sans_jdbc
interdit à l'IHM de toucher model.dao ou java.sql.
Principes. SRP (la persistance est une responsabilité à part) et DIP (le métier dépend d'abstractions de données, pas de l'API JDBC).
Lecture par lot (findPar…)¶
Le problème. Un écran qui compose N lignes en interrogeant la base pour chacune paie N requêtes là où une suffirait. Le coût croît avec l'inventaire de l'utilisateur : il est donc absent des jeux d'essai, et maximal chez celui qui a le plus de données. Sept chemins en souffraient (#4251, #4271,
4278, #4280, #4283, #4286, #4293).¶
La solution. Lire une fois pour tout le lot avant de boucler, et servir la boucle depuis une
Map indexée.
Dans cette application. Trois lectures groupées existent -
PointDao#findParSites,
PassageDao#findParPoints
et SequenceDao#findParIds - et rendent leur résultat déjà groupé. Quand la table ne porte qu'une
ligne par entité (journal, relevé, commune, rapprochement), un findAll() indexé en mémoire suffit :
c'est ce que fait ContexteAudit pour l'audit complet, et RegistrePassages côté CLI depuis l'origine.
Les lectures par identifiants passent par LotsDeParametres.decouper. SQLite refuse au-delà de
quelques centaines de paramètres liés : sans découpage, un inventaire un peu large ferait échouer
l'appel là où la boucle, elle, marchait. Un remède contre la lenteur ne doit pas introduire un défaut de
justesse.
Ce qui ne se lit pas par lot : les tables de volume (originaux, séquences d'écoute) restent lues par session. Une nuit en porte des milliers ; les charger d'un bloc échangerait un défaut de lenteur contre un défaut de mémoire.
Comment on le garde. En comptant des requêtes, jamais des millisecondes - un butoir en temps se
noie dans la variance de la machine. Deux formes dans le dépôt : un Mockito.spy sur le DAO
(verify(never()) sur la lecture unitaire, times(1) sur la lecture groupée), ou un compteur de
connexions comparé à deux tailles de jeu (RequetesDeLAuditTest). La seconde attrape les requêtes
où qu'elles vivent dans la pile d'appels, ce que la première ne voit pas.
Le pourquoi chiffré, et les trois façons de se tromper en mesurant, vivent dans Performance et benchmarks.
Principes. SRP (le DAO sait lire un lot, l'appelant sait composer) et le corollaire de l'ADR 0014 : la parité CLI ↔ IHM porte aussi sur la façon de lire, pas seulement sur les capacités.
Template Method (DaoGenerique)¶
Le problème. Tous les DAO réécriraient la même mécanique : ouvrir une connexion, exécuter, itérer
le ResultSet, fermer. Beaucoup de duplication.
La solution. Une classe de base fixe le squelette de l'algorithme (findAll, findById,
delete) et délègue les détails variables à des méthodes que les sous-classes remplissent.
Dans cette application. DaoGenerique<T, ID>
fournit les opérations communes ; un DAO concret donne seulement table(), colonneCle() et son
RowMapper.
classDiagram
class DaoGenerique {
<<abstract>>
+findAll()
+findById(id)
+delete(id)
#table()
#colonneCle()
#rowMapper()
}
class SiteDao
class PassageDao
DaoGenerique <|-- SiteDao
DaoGenerique <|-- PassageDao
Principes. DRY (la boucle ResultSet n'existe qu'une fois), OCP (un nouveau DAO étend
sans modifier la base) et LSP (tout DaoGenerique concret est substituable à l'abstraction).
Table latérale de présence (un fait booléen hors du record)¶
Le problème. Attacher un fait booléen à une entité centrale : « ce passage est opportuniste »,
« ce carré appartient à un tiers », « ce passage a un relevé de micro ». Le réflexe est d'ajouter une
colonne, donc une composante au record ; mais Passage est construit en plus d'une centaine
d'endroits (main et test confondus) et Site en plus de quatre-vingts. À ce volume, une
composante de plus propage un diff mécanique partout et ajoute un paramètre de même type qu'un voisin,
échangeable en silence (cf. l'EPIC arité #2483). Les mesures datées sont dans l'ADR ci-dessous ; on ne
les fige pas ici, où rien ne les garderait à jour.
La solution. Une table dont la clé primaire est la clé étrangère vers l'entité : la présence de la ligne porte le fait, son absence porte le cas courant.
CREATE TABLE passage_opportuniste (
passage_id INTEGER PRIMARY KEY REFERENCES passage(id) ON DELETE CASCADE
);
Le DAO étend DaoGenerique<Long, Long> (l'entité est la clé, seul le fait d'exister compte) et
expose une API d'intention plutôt que le CRUD : marquer / demarquer / definir(id, bool) /
estX(id), plus un tousLesIds() de lecture groupée. insert/update du contrat Dao n'ont
rien à écrire et délèguent au marquage idempotent (ON CONFLICT DO NOTHING).
La règle. Le tousLesIds() n'est pas un confort : dès qu'un service balaie plusieurs entités
(R4 sur les voisins d'un point, décompte du solde), il lit une fois l'ensemble marqué au lieu de
faire une requête par ligne. C'est la contrepartie du pattern : la lecture n'arrive plus « gratuitement »
avec l'entité, il faut la prévoir.
Quand ne pas l'employer. Pour une entité construite en quelques endroits, la colonne reste plus simple et plus lisible. Le critère est le volume de sites de construction croisé avec la proportion de lignes concernées : un fait vrai pour la majorité des lignes ne gagne rien à sortir du record. Voir l'ADR 2525.
Occurrences. V10 passage_equipment (matériel du micro), V34 passage_opportuniste, V35
site_tiers, V36 taxon_prioritaire (espèces à enjeu de conservation, #2353).
La dernière ajoute une raison que les trois autres n'avaient pas : sa liste vient d'une source
externe datée (le Plan National d'Actions Chiroptères 2016-2025) et sera remplacée quand le
plan le sera. Une table latérale se remplace d'un DELETE suivi d'un INSERT, sans toucher au
référentiel taxonomique qu'elle annote. Voir l'ADR 2353.
Strategy (RowMapper, génération de sélection)¶
Le problème. Une partie d'un algorithme varie (comment lire une ligne ? comment choisir des
séquences ?) alors que le reste est stable. Un if/else géant serait fragile et fermé.
La solution. Encapsuler la partie variable derrière une abstraction interchangeable, injectée ou passée au client.
Dans cette application. Deux usages :
RowMapper<T>(@FunctionalInterface) : « transformer une ligne en entité » varie par DAO (souvent une lambda) ; l'itération reste dansDaoGenerique.
GenerateurSelection:selectionner(sequences, methode, taille)choisit un sous-ensemble selon laMethodeSelection(répartition temporelle vs aléatoire vs manuel) : une règle pure, sans base ni IHM.
classDiagram
class RowMapper {
<<interface>>
+mapper(ResultSet) T
}
class DaoGenerique
DaoGenerique o-- RowMapper : utilise (stratégie)
Principes. OCP (ajouter une stratégie sans modifier l'appelant), SRP (chaque stratégie est une règle isolée, testable sans persistance ni IHM : objectif réutilisation O6).
Table de suivi par unité (socle commun)¶
Le problème. Trois opérations longues (génération d'archives #820, import par fichier #947, dépôt VigieChiro #983) doivent montrer l'avancement de chaque unité de travail (état coloré + barre), alimenté depuis des fils d'arrière-plan, parfois dans le désordre (travail parallèle).
La solution. Un socle en trois couches, spécialisé par feature :
commun.viewmodel:EtatUnite(en attente / en cours / terminée / échec),LigneSuivi(ligne observable extensible),SuiviLignes<L>(pilote générique, ciblage par numéro, tolérant aux événements inconnus ou dans le désordre) ;commun.view:TableSuivi(colonnes#/ spécifiques / Progression, rangées colorées.ligne-suivi.etat-…dansdesign.css) +CelluleProgressionUnite(barre vive ou icône + libellé, raison d'échec en infobulle) ;- côté feature : une interface d'événements métier (
SuiviArchives,SuiviFichiers,SuiviDepot, chacune avec sa varianteinerte()), un relais qui rejoue chaque événement sur le fil JavaFX (Platform.runLater), et une spécialisationSuiviLignesXxx extends SuiviLignes<LigneXxx>qui traduit les événements en mutations observables.
La règle. Toute nouvelle opération longue « par unité » réutilise ce socle : définir l'interface
d'événements (+ inerte()), la ligne et le pilote spécialisés, le relais fil JavaFX : jamais une
table ad hoc.
Écrivain ZIP généraliste (socle commun)¶
Le problème. Deux emballeurs ZIP coexistaient : CompacteurDepot (sémantique dépôt : nommage
-N.zip, plafond 700 Mo, découpage) et le besoin d'archives quelconques (export « observations +
sons » #2792, et tout export futur). Recopier la boucle putNextEntry/Files.copy disperserait les
mêmes exigences : mémoire bornée, annulation, pas d'archive partielle.
La solution. commun.model.EcrivainZip (#2792), statique et sans JavaFX : ecrire(destination,
entrées texte, entrées fichier, progression, jeton) renvoie la taille écrite. Deux natures d'entrées
(EntreeTexte pour un contenu déjà en mémoire - un CSV -, EntreeFichier copiée en flux), le
JetonAnnulation vérifié avant chaque
entrée, pendant la copie de chacune (cf. ci-dessous) et une dernière fois avant de conclure, la
progression émise entrée par entrée (« Archive : X / N · nom »), et l'archive partielle supprimée
sur échec comme sur annulation (patron ExtracteurZip, en miroir).
La règle. Toute nouvelle archive « assemblée » (un manifeste + des fichiers) passe par
EcrivainZip ; CompacteurDepot reste l'emballeur du dépôt (son nommage et son découpage sont un
contrat de la plateforme, pas une variante d'options). La structure d'archive de l'export des sons
(sous-dossier par session) est actée par l'ADR 2792.
Une copie longue s'interrompt en cours de route (socle commun)¶
Le problème. Un jeton d'annulation consulté entre deux unités de travail ne protège que si les
unités sont courtes. Deux boucles vérifiaient le jeton consciencieusement avant chaque entrée d'une
archive, puis recopiaient l'entrée d'un trait (transferTo à la décompression, Files.copy à
l'écriture). Sur une entrée de plusieurs gigaoctets - un cas ordinaire pour une nuit de terrain - le
bouton « Annuler » restait sans effet pendant toute la durée de la copie, et la barre de progression
figée sur le même « X / N fichiers ». Le trou était le même dans les deux sens, et corriger un seul
côté aurait laissé l'autre en arrière (#2733).
La solution. commun.model.CopieInterruptible.copier(source, destination, jeton, surPalier)
recopie bloc par bloc (64 Kio) : le jeton est consulté entre deux blocs, et les octets cumulés sont
notifiés tous les 4 Mio. La mémoire reste bornée comme avant (#104) : c'est le même flux, avec un
tampon explicite. Le palier compte dans les deux sens : assez rapproché pour donner signe de vie,
assez espacé pour ne pas inonder l'appelant, qui marshale chaque notification vers le fil JavaFX (une
tranche de nuit ordinaire n'atteint jamais le palier et ne coûte donc aucune notification de plus).
Elle ne ferme ni la source ni la destination : les deux appelants écrivent dans un flux d'archive qu'ils continuent d'alimenter. Et elle laisse la destination en l'état sur annulation : le nettoyage appartient à qui a commencé (temporaire d'extraction, archive partielle), et tous deux le faisaient déjà.
La règle. Toute recopie dont la durée dépend de la taille d'un fichier utilisateur passe par
CopieInterruptible : un transferTo ou un Files.copy dans une opération annulable est un bouton
« Annuler » qui ment. Le corollaire vaut pour la progression : un compteur « X / N unités » ne suffit
pas quand une unité peut durer des minutes.
Trois adoptions, trouvées l'une après l'autre. La décompression (#2733), l'écriture d'archive - son jumeau, trouvé le jour même parce que la doc affirmait que les deux se suivaient « en miroir » - puis la copie d'un enregistrement à l'import (#3221), repérée par la passe d'harmonisation du lot
2722. La troisième était la plus discrète et la plus fréquentée : une nuit s'importe bien plus souvent¶
qu'elle ne s'archive.
Et le corollaire qu'on oublie : ce qui vérifie doit s'interrompre aussi. La copie protégée relit
sa destination pour en comparer l'empreinte (garantie R9). Lire 5 Go pour un SHA-256 prend aussi
longtemps que les copier : rendre la copie annulable sans rendre sa vérification annulable ne réglerait
qu'à moitié - le bouton répondrait, puis l'application resterait sourde. D'où
Empreintes.sha256Hex(fichier, jeton).
Au passage, la copie protégée est passée de trois lectures complètes à deux :
Empreintes.enComptantLEmpreinte calcule l'empreinte de la source pendant la copie, au lieu de la
demander à sa propre lecture. La relecture de la destination, elle, reste : comparer les octets qu'on
vient d'écrire à eux-mêmes ne prouverait rien, alors que les relire du disque constate un disque plein
ou une écriture tronquée.
Un fichier s'écrit d'un seul coup (socle commun)¶
Le problème, deux fois. Un writeString interrompu laisse un fichier tronqué, et son lecteur
en tire une conclusion fausse : un connexion.json coupé se lit « non connecté » (une déconnexion
inexpliquée plutôt qu'une erreur), un manifeste de sauvegarde coupé fait refuser une sauvegarde par
ailleurs intacte.
Et pour un secret, écrire puis restreindre les permissions laisse en plus une fenêtre : entre
les deux appels, le fichier existe avec celles de l'umask, souvent 644. Elle se rouvre à chaque
création du fichier - donc à chaque reconnexion, puisque se déconnecter le supprime (#2735).
La solution. commun.model.EcritureAtomique écrit dans un temporaire du même dossier, puis le
déplace sur la cible par un ATOMIC_MOVE : un lecteur voit l'ancien contenu ou le nouveau, jamais un
fichier tronqué. Deux points d'entrée, et le choix dit ce qu'on écrit :
ecrire(cible, contenu)pour ce qui n'est pas un secret - le manifeste de sauvegarde, un export ;ecrireSecret(cible, contenu)pour un secret : le temporaire est créé d'emblée restreint au propriétaire, donc le secret n'existe à aucun instant dans un fichier plus permissif que lui.
Sur les JDK actuels, les deux donnent le même fichier, et c'est mesuré : Files.createTempFile
sans attribut crée déjà rw-------. Ce qui les distingue est la garantie, pas le résultat observé -
ecrireSecret l'exige et la garderait si le JDK changeait son défaut. C'est aussi pourquoi PIT ne peut
pas distinguer les deux chemins : équivalence par construction, pas couverture manquante.
La règle, et son garde. Toute écriture d'un secret sur disque passe par ecrireSecret.
SecretsEcritsProtegesTest l'exige en lisant le source : les classes porteuses de secret ne
doivent contenir aucun Files.write* ni newOutputStream. Ce garde est structurel par nécessité -
la fenêtre est un état intermédiaire, et après coup les deux façons d'écrire laissent exactement le
même fichier à 600. Aucun test d'état final ne les distingue ; sans le garde, un retour en arrière
ne ferait rougir personne.
Pour l'atomicité seule, il n'y a pas de garde équivalent, et c'est proportionné : un manifeste tronqué se fait refuser par la restauration, qui le valide déjà (#2726). Le défaut se voit ; celui d'un secret ne se voit pas.
Deux exports de sons, un seul comportement. L'export « observations + sons » (#2793) et l'export de la bibliothèque de sons de référence (P10) passent tous deux par cet écrivain, derrière la même modale annulable et le même bilan chiffré. Le second écrivait auparavant dans un dossier, de façon synchrone, en ignorant les fichiers absents : trois écarts qu'aucun test ne signalait, parce que rien n'obligeait deux gestes jumeaux à se ressembler. C'est le sens de l'harmonisation menée à la clôture de l'EPIC #2790 : le patron n'existe que si le second usage l'adopte.
Critère de filtre sur liste (socle commun.view)¶
Le problème. Chaque écran à barre de filtres « à la Notion » (#470/#537) écrivait son éditeur de puce en liste déroulante à choix unique. Une puce à valeur unique ne sait dire ni « ces trois carrés », ni « tout sauf les chiroptères » (#2615) ; et le critère « Lieu » de la vue audio (#2794) confronte la même liste de valeurs cochées à plusieurs champs d'une ligne (commune, carré, point).
La solution. CritereListe, fabrique de CritereFiltre sur une dimension textuelle, en trois
variantes : simple (liste déroulante, une valeur), multiple (cases à cocher, appartenance),
multipleParmi (cases à cocher, la ligne portant plusieurs valeurs candidates passe dès que
l'une est cochée - nom distinct, une surcharge de multiple aurait le même effacement). Sémantique
partagée : rien de coché n'écarte rien (une puce fraîchement posée ne vide pas la vue), les
valeurs offertes sont celles réellement présentes, calculées à l'ouverture de la puce, et l'état
se mémorise/rejoue par les vues sauvegardées sans travail spécifique (DescripteurCritere#valeurs
est déjà une liste).
La règle. Un nouveau critère sur dimension textuelle passe par CritereListe ; un éditeur écrit
à la main ne se justifie que pour un type d'éditeur nouveau (curseur, plage horaire).
Les quatre briques ajoutées par le chantier #3092¶
Le socle a gagné quatre composants partagés. Les recopier serait exactement la duplication que ce chantier a supprimée.
| Brique | Ce qu'elle porte |
|---|---|
ClesCriteres |
les clés partagées entre écrans, et le concept de chacune |
LibellesCriteres |
comment un critère se nomme à l'écran, à partir de sa clé |
CritereBooleen |
la puce sans éditeur, dont la seule présence filtre |
CritereLieu |
le critère géographique, dimensions en paramètre |
ValeursPresentes |
les valeurs distinctes, non nulles et triées d'une dimension |
Trois niveaux géographiques, pas quatre (ADR 3157) :
la commune, le carré et le point. Ce qui ressemblait à une quatrième dimension, le « site », est le
nom convivial du carré - monitoring_site porte les deux colonnes sur la même ligne - et les deux
tiennent dans une seule entrée, « 640380 · Vallon ». CritereLieu.carres et CritereLieu.points
écrivent cette règle une fois pour les quatre écrans ; l'écriture partagée vit en
commun.model.LieuQualifie, la ligne de commande devant la lire aussi (FiltresLieu), et un modèle
ne pouvant pas dépendre d'une vue.
Le carroyage répond dans les deux sens, et normalise son format à la lecture (chantier #4573).
Le référentiel embarqué carrenat.csv.gz (137 479 mailles, toute la métropole) donne le centroïde
d'un numéro, et donc aussi le numéro d'une position : le carré d'un point est la maille de centroïde
le plus proche, la partition par centre le plus proche d'un réseau de mailles carrées étant le
découpage lui-même. Ce sens inverse rend GET /grille_stoc/cercle inutile pour proposer un carré :
le calcul est local, donc hors ligne, et le portail garde la seule question qui le concerne, « ce
carré existe-t-il en Point Fixe ».
Le piège, et il a mordu deux fois : le référentiel comme la grille du portail amputent le zéro de
gauche des départements 01 à 09, alors que la règle R1 et le catalogue des sites imposent six
chiffres. 13 342 numéros sur 137 479 sont concernés. NumeroDeCarre.surSixChiffres rétablit la forme,
et CarroyageNational bâtit sa table dessus : une classe qui rendrait du rembourré tout en cherchant
du brut ne pourrait pas se redonner ce qu'elle vient de rendre. Un lecteur qui compare un numéro sans
passer par là obtient un faux négatif silencieux - c'était le cas de ControleCarreStoc (#4592) et de
l'emprise cartographique, qui retombait sur son repli pour ces 13 342 mailles.
Et le référentiel lui-même se garde (#4612). La source amont porte quatre mailles dont le numéro vaut 0 ; rembourrées, elles donneraient 000000 - un numéro qui n'existe pas mais qui a l'air d'en être un, et l'une couvrait 2 km de Paris intra-muros. Deux ont été rétablies sur l'autorité de GET /grille_stoc/cercle, deux retirées faute de contrepartie officielle. Le garde lit le fichier et non la table chargée : depuis que la lecture refuse une ligne non conforme, la table resterait propre pendant que le référentiel se dégrade, et un garde posé sur elle aurait l'air de tenir quelque chose sans rien tenir.
Sur une frontière, on nomme sans choisir. Deux centres sont à distance strictement égale au milieu d'un côté commun, quatre le sont à un coin - mesuré le 2026-08-27, 997,7 m et 1 412 m. « Le plus proche » n'y désigne rien, et la représentation par centres ne peut pas porter la convention d'intervalle semi-ouvert qui trancherait. L'appelant reçoit donc les candidats et laisse l'observateur décider : lui seul sait où était son micro.
La même règle, deux sources, un seul texte (#4671). La frontière se lit dans les deux sens : pour proposer un carré, les candidats indiscernables se nomment et aucun ne se choisit ; pour contrôler un carré déjà déclaré, il concorde s'il figure parmi eux - l'observateur a raison quel que soit celui des deux qu'il a écrit.
Le contrôle a deux appelants qui n'interrogent pas la même source : l'écran demande à la plateforme,
ajouter-point lit le carroyage embarqué. ConfrontationCarre porte le verdict pour les deux, et le
seuil avec lui. Deux copies auraient dérivé, et l'écran aurait fini par dire d'une position ce que la
ligne de commande en nie - c'est la raison qui avait déjà fait naître ConversionGeographique (#4673),
et elle se répète assez pour valoir règle.
Le seuil du contrôle vaut le double de celui de la proposition, et c'est délibéré : proposer un numéro faux et plausible se paie cher, tandis que taire un contrôle ne coûte qu'un contrôle.
Une dimension qui change d'écriture déclare de quel côté (ADR 3158). Une vue mémorise le texte coché : requalifier une entrée le rend introuvable. Le socle demande donc au critère ce que la valeur désigne, plutôt que de comparer des chaînes qu'il ne sait pas interpréter. Le piège, s'il fallait le refaire : chercher un segment n'importe où ne rattrape jamais un carré, puisque le point est qualifié par lui.
Clés et libellés sont deux préoccupations distinctes, et volontairement séparés.
ClesCriteres est un contrat : une clé y est le nom sous lequel une vue mémorisée se sérialise
(vue_sauvegardee), donc la changer rendrait caduques les vues enregistrées. Une clé propre à un seul
écran n'y a pas sa place. LibellesCriteres est de la présentation, et doit couvrir toutes les
clés : un écran qui rend compte d'un critère qu'il n'offre pas nomme précisément celles qu'il ne
connaît pas.
Un catalogue ne réécrit jamais une clé partagée en littéral : ClesCriteresTest le refuse. Sans
cette garde, un cinquième écran nommerait « lieux » ce que quatre autres nomment « lieu », ou deux
écrans nommeraient deux concepts « statut » - ce qui était arrivé.
Renommer une clé sans casser les vues déjà enregistrées passe par CritereFiltre.nomsHerites()
(défaut vide) : le critère déclare les noms qu'il a portés, et critereParNom les accepte. Aucune
migration de base. Ce n'est pas un fourre-tout : n'y mettre que des noms réellement portés, sinon
le compte rendu de restauration deviendrait muet sur de vraies clés inconnues.
Les domaines sont cascadés¶
Depuis #3095, la liste de valeurs d'une puce se calcule sur les lignes que les autres critères
laissent passer, via Filtres.saufLui(cle), et se recalcule à l'ouverture du menu.
Le piège : passer la FilteredList de l'écran fait s'auto-effondrer la puce. Cette liste est
déjà filtrée par tous les critères, y compris celui qu'on peuple : une fois « Aix » coché, le menu
n'offrirait plus qu'« Aix », et l'on ne pourrait jamais cocher une seconde commune. Un critère à
domaine consomme donc saufLui, jamais la liste affichée.
Une valeur cochée devenue impossible reste cochée et visible, marquée valeur-hors-jeu. La retirer
relâcherait le filtre en silence, et l'écran montrerait plus que ce qu'il annonce.
Les dimensions d'un critère se classent en trois familles, et le classement se justifie en commentaire à côté du câblage : facette (cascade), sélecteur (ne cascade pas, sous peine de retirer du menu ce vers quoi on veut naviguer), énumération fixe (sans objet). Le raisonnement complet est dans l'ADR 3095.
Une restauration rend toujours compte¶
GestionnaireFiltres.restaurer rend un ResteDeRestauration : les valeurs qu'aucun critère n'a su
replacer, et les critères absents du catalogue. Les deux causes sont distinguées parce qu'elles
n'appellent pas la même réaction - une valeur disparue tient aux données, un critère absent à l'écran.
Il existe trois chemins de restauration, et tous trois doivent lire ce retour : les vues sauvegardées, le transport d'un écran à l'autre (#476) et la mémoire de session (#484). En ignorer un laisse l'écran filtrer moins large qu'annoncé, sans rien dire.
Avant de filtrer, décider si la table s'explore
Une nouvelle TableView ne reçoit pas la barre de filtres par défaut, et pas non plus par
imitation de l'écran d'à côté. L'ADR 3479
distingue trois natures - exploratoire, analytique bornée, opérationnelle ou
transitoire - et seule la première reçoit le contrat complet. Équiper une table de suivi
d'import ajoute un catalogue de critères devant une liste qu'on regarde défiler.
Les filtres de model : deux rôles, une convention de nom¶
Des classes Filtres* vivent en model, hors du socle de vue. L'audit d'harmonisation de la clôture
de #3092 en a compté six et a montré qu'elles ne relèvent pas de trois idiomes, comme leur forme le
laissait croire, mais de deux rôles, et que seul leur nommage divergeait. Elles sont huit
aujourd'hui : le compte bouge, les deux rôles tiennent.
| Rôle | Forme | Classes | Consommé par |
|---|---|---|---|
| Prédicat composable | objet chaînable + accepte(ligne) |
FiltresMultisite |
un service et un catalogue d'écran |
| Application à une liste | parX(List, critère) → List |
FiltresActivite, FiltresRevue, FiltresLieu, FiltresProbabilite, FiltresSaison |
la ligne de commande |
La différence est légitime et se garde : un écran a besoin d'un Predicate à poser dans son
Filtres<T>, une commande a besoin d'une liste déjà réduite. Les convertir toutes à une seule forme
ferait porter à l'une le coût de l'autre.
Ce qui, lui, ne se justifiait pas, et a été aligné : deux classes portaient un nom singulier et
une méthode appliquer là où les autres portent un pluriel et un verbe qui dit sur quoi l'on
filtre. FiltreLieu.appliquer est devenu FiltresLieu.parLieu, FiltreProbabilite.appliquer est
devenu FiltresProbabilite.parSeuilMinimal. Un nom qui dit son critère se lit sans ouvrir la classe.
Une règle lue des deux côtés s'écrit dans model. FiltresLieu et FiltresSaison sont lues par
la ligne de commande et par un catalogue d'écran ; un catalogue de view qui garderait sa propre
copie finirait par diverger - c'est arrivé le jour même où #3219 a ajouté la recherche par nom de carré
à « Ma saison ».
La comparaison de texte insensible à la casse et aux accents vit dans
commun.model.NormalisationTexte.contient, et nulle part ailleurs. Six catalogues en avaient
re-déclaré une copie privée identique - dont deux écrites par ce chantier même. La méthode partagée
normalise elle-même l'aiguille et refuse une aiguille vide, là où les copies laissaient tout
passer.
La mémoire de session sépare les filtres du tri¶
MemoireFiltres (#3098) retient l'état d'un écran d'une visite à l'autre, en deux mémoires
distinctes :
installer(ecran, ancrage, gestionnaireFiltres, compteRendu)retient les filtres, un jeu par écran. L'ancrageest n'importe quel nœud : il sert d'horloge de sortie, pas de contenu ;memoriserTri(ecran, table)retient le tri, repéré par lefx:idde la table. Un écran en appelle autant que de tables, un écran sans table n'en appelle aucune.
Pourquoi deux. La première version en supposait une seule, un tri par écran. La réalité mesurée : « Sons & validation » a une table, « Carte & passages » une, « Espèces & observations » trois, et « Activité de la nuit » aucune - c'est un graphe. Une mémoire unique aurait confondu les trois tables de l'analyse et réclamé une table à un écran qui n'en a pas.
La restauration des filtres rend compte de ce qu'elle n'a pas su replacer : c'est le chemin le plus discret des trois, puisque personne n'a rien demandé (ADR 3093).
« Tout effacer » est un geste, pas un bouton¶
Les cinq écrans à barre nomment ce geste de la même façon et lui font faire la même chose : retirer les filtres, effacer le tri de la table, et oublier ce que la mémoire de session s'apprêtait à remettre. Sans le troisième volet, les filtres qu'on vient d'effacer reviennent à la réouverture, et le bouton paraît n'avoir pas pris.
Un écran sans table (« Activité de la nuit ») n'efface évidemment pas de tri, et son accessibleText
le dit : « Effacer tous les filtres », sans « et le tri ».
« Carte & passages » a porté le libellé « Réinitialiser » jusqu'à la clôture de #3092 - le geste y était pourtant déjà identique. C'est l'écran d'origine du geste, resté sous l'ancien nom quand il s'est généralisé : un cas d'école de divergence par ancienneté, qu'aucun test ne signale.
Les filtres existent aussi en ligne de commande¶
Un critère d'écran qui répond à une question métier a son équivalent en ligne de commande, à la même
sémantique (ADR 0014). L'état à la clôture des suites de #3092,
mesuré sur le binaire (<commande> --help) et non sur les sources - les options d'une commande
peuvent vivre dans un @Mixin, qu'un relevé par grep sur la classe ne voit pas :
| Écran | Commande jumelle | Parité |
|---|---|---|
| Sons & validation | lister-observations |
11 / 11 |
| Activité de la nuit | exporter-activite |
5 / 5 |
| Audit de cohérence | audit-coherence |
3 / 3 |
| Ma saison | solde-saison |
4 / 4 |
| Carte & passages | lister-passages |
7 / 7 |
| Espèces & observations | lister-especes, lister-carres |
5 / 5 |
Les six écrans sont à parité. Les deux dernières lignes valaient 0 / 7 et sans jumelle à la clôture de #3092 : une dette antérieure au chantier, rendue visible en confrontant les inventaires complets plutôt que des exemples, et soldée par ses suites (#3269).
Une réserve, que le tableau ne montre pas : le critère Lieu a trois dimensions à l'écran (commune, carré, point) et deux en ligne de commande, un code de point seul désignant autant de lieux qu'il y a de carrés. Écart assumé, porté par la clôture de #3151.
Lire l'inventaire dans les deux sens. La question habituelle - chaque critère de l'écran a-t-il
son option ? - rate par construction l'asymétrie inverse. C'est ainsi qu'on a trouvé
lister-observations --certitude, qui filtrait sans que l'écran offre la puce correspondante (#3336,
livrée : Sons & validation passe de 10 à 11 critères). Rien ne dit que ce soit la dernière.
Quand la règle est la même des deux côtés, elle s'écrit une fois dans model : FiltresLieu pour le
lieu, FiltresSaison pour la recherche et le « reste à faire ». Un catalogue de view qui garderait sa
propre copie finirait par diverger - c'est arrivé le jour même où #3219 a ajouté la recherche par nom de
carré.
Poser le socle sur un cinquième écran (#3100)¶
L'« Audit de cohérence » a rejoint les quatre tables exploratoires. Le travail utile a été d'installer
la barre, pas d'inventer quoi que ce soit : FiltresVuesAudit reprend trait pour trait le découpage de
FiltresVuesAudio et de FiltresVuesActivite (nœuds du FXML regroupés en objet-paramètre, gestionnaire
de filtres, vues, mémoire de session). Trois points valent d'être notés, parce qu'ils se reposeront au
sixième écran :
- Ses clés restent chez lui.
gravite,categorieetpassagene sont partagées avec aucun autre écran : elles vivent dansCriteresAudit, pas dansClesCriteres, dont le contrat ne porte que les clés réellement communes.categorien'a rien à voir avec legroupetaxonomique des autres écrans - c'est précisément la collision queClesCriteresexiste pour empêcher. - Un écran doit pouvoir rendre compte avant de recevoir des filtres. Celui-ci n'avait aucun bandeau
de retour. Le lui ajouter n'était pas de la décoration : sans lui, la mémoire de session aurait remis
des filtres amputés en silence. Un écran qu'on branche sur le socle gagne son
BandeauRetouren même temps que sa barre, jamais après. - Une valeur mémorisée y survit moins bien qu'ailleurs. Relancer l'audit renouvelle les constats : les passages offerts changent d'une visite à l'autre. Ce qui est l'exception sur les autres écrans est ici la règle, et c'est ce qui rend le compte rendu (#3093) indispensable plutôt que confortable.
Aucune vue par défaut, contrairement aux quatre autres. Une vue « Bloquants seulement » se dessine sur une distribution de gravités réelle, que la base de démonstration ne produit pas encore (#3169) : la proposer sans l'avoir vue à l'œuvre serait deviner ce que l'observateur regarde en premier.
Occupation d'un écran pendant un traitement long (socle commun)¶
Le problème. Un traitement lourd (agrégats, inspection de dossier, appel réseau) exécuté
synchrone sur le fil JavaFX fige l'IHM sans feedback ; un setCursor(WAIT) n'y suffit pas, fil
bloqué. Le patron correct (thread virtuel → travail → Platform.runLater) était recopié écran par
écran, avec le piège récurrent des mutations hors fil JavaFX (« clic figé »).
La solution. Deux briques de commun.view, à composer :
ExecuteurTache(interface@ImplementedBysynchrone) :executer(Supplier travail, Consumer succès, Consumer échec)exécute le travail hors du fil JavaFX puis applique résultat/erreur sur le fil JavaFX.ExecuteurTacheAsynchrone(thread virtuel +runLater) en production ;ExecuteurTacheSynchrone(défaut) rend les tests déterministes. Sœur d'ExecuteurFiche.IndicateurOccupation: superpose sur unStackPanehôte un voile + roue + libellé « … en cours » (enCoursProperty, styles.occupation-*dansdesign.css), et pilote unExecuteurTacheviaoccuper(libellé, travail, succès, échec). Le voile capte les clics le temps du traitement.OccupationChrome(#1215) : la déclinaison chrome entier pour les traitements du menu « ☰ » qui ne concernent aucun écran (sauvegarde / restauration de la base, purge des originaux) : voile sur la racine de la fenêtre, et opération critique (#906) posée le temps du travail (fermer l'application en pleine copie déclenche l'avertissement du socle). Installée par leMainController, consommée par injection dans lesActionMenu.SuiviProgression(#1597, #2642) / portSuiviOperation(#1622) : la déclinaison à barre de progression annulable, pour les opérations longues dont l'utilisateur veut voir l'avancement et pouvoir renoncer (reconstruction, réactivation avec ancrage, import des observations, connexion). Là oùIndicateurOccupationpose un voile opaque (« ça travaille »), elle dit où on en est (barre déterminée + libellé d'étape + ETA) et laisse annuler (bouton « Annuler » câblé sur le jeton).
Deux présentations, un seul socle. SuiviProgression porte le contenu et l'orchestration ; ses
sous-classes ne diffèrent que par l'endroit où le contenu paraît. DialogueProgression ouvre une
fenêtre ; PanneauProgression greffe le contenu dans une zone fournie par l'appelant. Le choix
appartient à l'appelant, parce que lui seul sait d'où il part : un geste lancé depuis une modale
prend le panneau, sinon l'utilisateur voit deux fenêtres pour un seul geste, alors que l'écran
d'origine a déjà la zone où il lui parle. Un geste lancé d'une barre d'outils prend la fenêtre. Quand
le panneau est en place, l'appelant grise ce qui n'a plus de sens - la saisie en cours, et le bouton
« Fermer », qui laisserait sinon le travail orphelin.
Elle
pilote le même ExecuteurTache (progression + annulation ci-dessous). Le port SuiviOperation rend le
geste testable sans fenêtre : un double synchrone exécute le travail sans ouvrir de Stage, si
bien que le déclenchement s'éprouve hors du fil JavaFX.
Opérations longues « riches » (#1252). Pour les traitements qui diffusent leur avancement ou
s'annulent, le socle étend ExecuteurTache sans toucher aux écrans déjà migrés :
- progression déterminée :
relaisProgression(application)fabrique leConsumer<Progression>à passer au service ; chaque point revient sur le fil JavaFX (immédiat en test). Pour tout autre événement de suivi (table par unité, cf. section précédente),surFilJavaFx()fournit l'Executordu fil JavaFX - les relais de suivi n'ont plus à recopierPlatform.runLater; - annulation coopérative : le jeton appartient à l'appelant (
commun.model.JetonAnnulation, câblé sur le bouton « Annuler » de l'écran). Deux styles au choix du travail :leverSiAnnule()lèveOperationAnnuleeException, que la surchargeexecuter(travail, succès, annule, échec)conclut par le callbackannule(jamais paréchec) ; ou bien le moteur litestAnnule()/jeton::estAnnuleet rend un bilan partiel honnête par le chemin de succès (patron du dépôt #1044 : jamais d'unité fantôme, la reprise ne renvoie que le manquant). Jamais d'interruption brutale de thread ; - désactivation d'un bouton pendant la tâche : pas d'API dédiée, un binding suffit -
bouton.disableProperty().bind(occupation.enCoursProperty())(patron posé par #1254 sur M-Audit). Plus jamais desetDisable(true/false)posé à la main autour de l'appel.
Testabilité de l'annulation en synchrone. L'exécuteur synchrone n'empêche pas de tester
l'annulation : le jeton appartenant à l'appelant, le test l'annule avant de déclencher
l'opération et vérifie l'arrêt propre au premier point de contrôle (callback annule, ni succès ni
échec) - c'est le contrat coopératif qui est testé, la simultanéité réelle relevant de l'E2E.
La règle. Toute opération longue d'un écran passe par le socle : IndicateurOccupation (voile) pour
les traitements brefs, SuiviProgression (fenêtre ou panneau intégré) quand l'avancement mérite d'être
montré et l'opération annulée : l'échec étant routé vers le filet d'erreurs de l'écran (#795), jamais un
Thread.ofVirtual() + runLater recopié à la main, y compris pour la progression et l'annulation
(surcharges ci-dessus). Le déport écran par
écran (EPIC #793 puis reliquat #1316) est terminé : plus aucun Thread.ofVirtual ne vit hors du
socle, tout nouvel écran naît avec ce patron.
Piège capture (#1278). Les outils de capture doivent lier les exécuteurs synchrones
(ModuleCaptureCommun) : ApercuFx snapshotte immédiatement, l'asynchrone de production capturerait
le voile « Chargement… » à la place du contenu. Le garde-fou CablageInjecteursCaptureTest casse la
CI si un injecteur de capture résout un exécuteur asynchrone.
Une modale de progression : suivre son contenu, et ne jamais se taire (socle commun)¶
Le problème. Une modale est dimensionnée à son ouverture, sur le contenu visible à cet instant. Tout ce qui paraît ensuite - une seconde barre de phase, un compte rendu de fin, un bandeau - agrandit la mise en page sans agrandir la fenêtre : le bas passe sous la ligne de flottaison. Chaque modale s'en était tirée pour son seul cas connu, si bien que la réactivation poussait toujours ses boutons hors de la fenêtre dès que la barre d'ancrage paraissait (#1931).
La solution. Modales.suivreLaCroissance(racine, revelations…) prend la racine et les propriétés dont
un changement fait paraître du contenu ; la fenêtre s'ajuste à chacune. À poser à côté de
Modales.fermerParEchap, qui répond à la même histoire (un comportement transverse que chaque modale
réinventait).
Ajuster, oui ; figer, non. L'implémentation appelle sizeToScene() et rien d'autre. Une version
qui gardait max(taille avant, taille après) pour « ne jamais rétrécir » a fait passer le Stage en
dimensionnement explicite : il cesse alors définitivement de s'ajuster à ses scènes suivantes. Sans
effet pour une modale que l'on jette après usage - mais le Stage du harnais TestFX est partagé par
toutes les classes de test d'un même fork, et il est resté figé à 600 px pour toutes les suivantes, dont
les noeuds tombaient « hors de la fenêtre » très loin de la cause (#1940).
Les phases sont des blocs. Quand une opération enchaîne plusieurs phases, chacune est un bloc : son nom
(.nom-de-phase) et son message sur une ligne, sa barre sur toute la largeur en dessous. Le nom, la
barre et le message sur une même ligne ont deux défauts : le message dispute sa largeur à la barre, si bien
que la phase au message le plus long se retrouve avec la barre la plus courte - or empiler deux barres,
c'est demander à l'oeil de les comparer ; et le nom redit le début du message au prix de la place prise à la
barre (#1935, #1946).
Et la modale ne se tait jamais. Un intervalle où le travail continue sans qu'aucune barre ne bouge est un défaut au même titre qu'une barre figée à 100 % : nommer chaque étape, et poser le libellé avant le geste qu'il annonce, jamais après. Raison d'être et cas vécu : ADR 0027.
Les dialogues d'une action sont des ports (socle commun)¶
Le problème. Un showAndWait() fige un test TestFX headless (piège connu depuis #798). Toute
action qui ouvre un dialogue est donc, littéralement, impossible à cliquer dans un test : le test
s'arrête sur la ligne du dialogue et n'en revient jamais. La conséquence a mis longtemps à être
nommée (#1405) :
On ne testait que le grisage des boutons. Jamais leur effet.
Et cela portait précisément sur les gestes qu'on veut couvrir : restaurer la base, supprimer un passage et sa nuit, réimporter par-dessus les validations de l'observateur. Tous irréversibles, tous non testés.
La solution. Rendre remplaçable chaque forme de dialogue. Quatre familles, bâties sur le même triplet - un contrat neutre, une implémentation réelle, un porteur injectable :
| port | ce qu'il demande | implémentation réelle | double en test |
|---|---|---|---|
Confirmateur (#1013) |
le oui/non | ConfirmationNavigation |
répond ce qu'on lui dit |
Notificateur (#1404) |
le compte rendu | NotificationDialogue |
capture ce qui a été dit |
SelecteurFichier (#1425) |
la désignation d'un fichier / dossier | SelecteurFichierJavaFx ou SelecteurFichierEnFenetre, par la fabrique Selecteurs (#5307) |
répond un chemin, ou rien (annulé) |
DemandeurDeChoix<T> (#1431) |
le choix parmi plusieurs options | ChoixDansListe ou ChoixParBoutons |
répond une option, ou rien (renoncé) |
Chaque écran détient une instance de chaque porteur qu'il utilise, champ final, exposée à ses
tests par un accesseur package-private. Ses collaborateurs (actions extraites, cartes, helpers)
reçoivent ces porteurs : ils n'en fabriquent pas. Quand un écran en accumule plusieurs, ils forment
une unité qu'on peut extraire (DialoguesAudio en porte trois).
« Annuler » n'est pas une option : c'est un renoncement¶
Le dernier port est né d'un dialogue qui semblait inclassable : « Enregistrer / Abandonner / Annuler », à la sortie du mode édition de la carte. Trois boutons, donc trois issues - et aucun contrat existant pour ça. Il aurait fallu inventer un port « à trois branches », taillé pour un seul écran.
C'était une erreur de lecture :
« Annuler » n'est pas une troisième décision. C'est le refus de décider. On reste en édition, rien n'est enregistré, rien n'est perdu.
Le dialogue n'a donc pas trois issues : il en a deux, plus la possibilité de renoncer - ce qui se
lit Optional.empty(), exactement comme un sélecteur de fichier qu'on ferme. Un seul contrat suffit, et
il sert aussi au choix d'une participation VigieChiro parmi une liste.
Corollaire à retenir : renoncer n'est pas abandonner. Les deux ferment le dialogue ; un seul détruit le travail de l'utilisateur. Un test doit les distinguer.
La présentation, elle, reste à l'appelant - car elle n'est légitimement pas la même :
ChoixDansListequand les options sont des données (on ignore combien de participations le compte contiendra) ;ChoixParBoutonsquand ce sont des décisions (enregistrer / abandonner) : deux décisions se lisent d'un coup d'œil, une liste déroulante y serait un recul.
Un formulaire n'est pas un dialogue : c'est une vue¶
Face aux Dialog<T> de saisie (créer un site, personnaliser une sélection d'écoute), la tentation était
d'ajouter un cinquième port, générique, rendant Optional<T>. Il ne fallait pas : l'application avait
déjà le bon patron, et il était testé.
Un formulaire est une vue : FXML + controller + ViewModel + une entrée
ouvrirModale*sur la façade de navigation.
Cinq modales le suivent (ModalePoint, ModaleSite, RattachementModale, ReconstructionModale,
ModaleSelection), toutes couvertes par un test TestFX. Les Dialog<T> bâtis à la main étaient les
intrus, et ils cumulaient trois défauts liés :
- le geste était injouable (
showAndWait) ; - la validation vivait dans la vue, donc n'était pas testable non plus (elle devient un binding observable du ViewModel, vérifiable sans IHM) ;
- leur capture de documentation était une réplique reconstruite à la main (
CaptureDialogues), faute de.fxml- et elle avait dérivé : elle affichait un protocole « Point fixe » là où la vraie valeur est « PointFixeStandard ». La doc mentait, et rien ne pouvait le signaler.
Le refus métier y gagne aussi : il s'affiche dans la modale, à côté du champ fautif, sans perdre la saisie - là où l'alerte d'après coup obligeait à tout ressaisir.
// Écran : un champ final par porteur, un accesseur par porteur.
private final ConfirmateurModifiable confirmateur = new ConfirmateurModifiable();
private final NotificateurModifiable notificateur = new NotificateurModifiable();
if (!confirmateur.confirmer("Supprimer ce site et ses points d'écoute ?")) {
return;
}
viewModel.supprimerSite();
notificateur.notifier(NiveauNotification.INFORMATION, "Site supprimé", "…");
// Test de vue : le geste devient cliquable, et vérifiable JUSQU'À SON EFFET.
controleur.confirmateur().definir(message -> true);
controleur.notificateur().definir((niveau, entete, message) -> annonces.add(entete));
robot.interact(() -> robot.lookup("#boutonSupprimer").queryButton().fire());
assertThat(sitesEnBase()).isEmpty(); // pas « un mock a été appelé » : la ligne a disparu
La règle. Jamais de new Alert(...), de FileChooser ni de DirectoryChooser dans un contrôleur
ou une action. Et surtout, la formulation générale - c'est elle qui compte, pas la liste des ports :
Une action ne devient testable que si tous ses dialogues sont remplaçables. Il suffit d'en oublier un pour que le geste reste hors de portée.
C'est ce qui avait échappé jusqu'à #1425 : le Confirmateur et le Notificateur ne suffisaient pas à
rendre la sauvegarde testable, parce qu'elle commence par un sélecteur natif. Le test s'arrêtait à
la première ligne. Deux ports sur trois, c'est zéro geste testable.
Deux pièges corollaires, tous deux rencontrés :
- Un porteur que rien n'expose est mort-né.
CartesPointsSitefabriquait son propreConfirmateurModifiablesans accesseur : le patron était là, mais aucun test ne pouvait le remplacer. Un porteur se partage depuis l'écran, il ne se recrée pas. -
Une surcharge « production / test » est un troisième idiome pour le même besoin.
audioavaitlancer(…)qui fabriquait le vrai dialogue etlancer(…, Confirmateur)pour les tests. Un écran, une paire de porteurs, partagée. -
Un port qu'on croit manquant peut être un port déjà là.
GestionnaireVuessemblait bloqué par sonTextInputDialog; il reçoit en fait son demandeur de nom par constructeur depuis toujours, et onze tests s'en servent avec un stub. Vérifier avant d'abstraire.
Ce qui reste en dur (et c'est légitime) : les implémentations des ports elles-mêmes
(ConfirmationNavigation, NotificationDialogue, ChoixDansListe, ChoixParBoutons), et le filet
global d'App.java (exceptions non capturées, #795) - le seul endroit où le dialogue est la
fonction.
SelecteurFichier a quitté cette liste (#5307). Il a deux implémentations - celle du système
et celle de l'application - et le choix entre elles dépend d'un réglage. Écrit en dur, ce choix
l'était douze fois, donc nulle part : la fabrique Selecteurs est le seul endroit où il se construit,
et scripts/adr/5307-designation-hors-fabrique.py tient un cliquet à zéro sur les autres.
Le port est donc devenu la couture de configuration en plus de celle de testabilité, et les deux
se superposent sans se remplacer : un écran garde toujours son SelecteurFichierModifiable en champ
final pour ses tests.
Le contre-exemple à connaître. Un refus prévenu par l'affordance n'a pas de notification à
tester - il n'arrive jamais. Sur M-Site-detail, « Supprimer » est grisé quand un point porte des
passages (#789), et JavaFX n'émet aucune action sur un bouton désactivé (Button.fire() est un
no-op). Le catch du refus métier reste comme garde défensive, mais c'est le grisage que le test
doit vérifier : on ne prévient pas après coup ce qu'on a déjà empêché.
Et ce qu'aucun test ne verra jamais. Trois défauts de ce chantier n'ont été trouvés qu'en regardant une capture : un libellé tronqué, un emoji qui ne se rend pas (#700), une réplique de dialogue qui avait dérivé du vrai écran. Un geste testé n'est pas un écran regardé - rendez la capture, et ouvrez-la.
Action groupée (ActionGroupee, socle commun)¶
Le problème. Rentrer de terrain avec six cartes SD, c'est six fois la même suite de gestes. Écrire un « mode lot » qui saurait téléverser produit une seconde implémentation du dépôt, dont une seule est maintenue.
La solution. Un moteur aveugle et des actions fines. MoteurTraitementGroupe ne connaît aucun
métier : il applique une ActionGroupee à une liste de CiblePassage et rend une IssueTraitement par
passage. Chaque action porte trois choses, et rien de plus :
Ce que porte une ActionGroupee |
Ce que cela sert |
|---|---|
libelle() |
l'entrée de menu, le titre du suivi, l'en-tête du compte rendu |
motifNonEligible(cible) |
l'annonce préalable : ce qui sera écarté, et pourquoi, avant de partir |
executer(cible, jeton) |
le geste lui-même, celui de la nuit unique |
L'éligibilité est locale et peu coûteuse : elle est consultée sur toute la sélection avant le premier geste, et vingt allers-retours réseau pour afficher une annonce seraient un défaut. Ce qui exige le réseau ressort donc en échec avec son motif, pas en écart.
Comment on en ajoute une. Implémenter ActionGroupee dans la feature qui possède le geste, la lier
par OptionalBinder sous son nom (action.<geste>), et l'entrée de menu apparaît. Feature désactivée,
l'entrée disparaît au lieu de rester grisée sans recours (ADR 0003) :
c'est le patron déjà suivi par « Compléter une nuit récupérée » et « Relever l'état des analyses ». Le
consommateur (MultisiteModule) déclare les optionnels vides, chaque feature propriétaire pose le sien.
Ce que le moteur garantit, et qu'on ne réécrit pas. Il est séquentiel : le plafond de parallélisme reste celui d'un passage. Il consulte le jeton entre deux passages, si bien que chaque nuit est soit avant, soit après, jamais entre les deux. Un échec n'arrête pas le lot. Et il ne formate pas les motifs d'échec : il applique la rédaction que la surface lui donne (ADR 2635). Le raisonnement complet et les alternatives écartées sont dans l'ADR 2357.
Deux surfaces, un seul moteur. L'écran orchestre avec les trois ports de dialogue ci-dessus
(Confirmateur pour annoncer, SuiviOperation pour exécuter, Notificateur pour rendre compte) ; la
commande traiter-passages réutilise les mêmes actions. Ce que la ligne de commande apporte n'est pas la
boucle, un terminal sachant boucler, mais l'écran d'éligibilité, que rien d'autre n'expose.
Écrans de données : densité, badge, filtres (socle design partagé)¶
Le problème. Les onze écrans sont nés à des moments différents, sans référentiel de design commun :
tables plus ou moins denses, statuts tantôt en texte coloré tantôt en pastille, chaque feature
recopiant son CSS. Le chantier #686 a unifié la famille « écrans de données » (audio, multisite,
analyse, fiche site, qualification) sur un socle commun/view.
La solution. Trois briques réutilisables, plus une feuille de style chargée par tous les écrans :
commun.view.TableDonnees:uniformiser(table)(etuniformiserNavigablepour une table qui répond au clavier) applique la classe CSStable-donnees(hauteur de ligne, padding, en-tête uniques). Un appel unique dans le contrôleur garantit la densité partagée ;commun.view.ColonneBadge:cellule(Function<S, String> classe)fabrique une cellule pastille dont la couleur est dérivée de la donnée de la ligne (jamais stockée). Les surchargesclasse(StatutWorkflow)/classe(Verdict)couvrent les types decommun.model;-
commun.view.design.css: jetons sémantiques (-badge-succes/avertissement/danger/info/neutre-*) et classes.badge-*, posée sur toute scène parHabillage(plus de CSS de statut recopié par feature).Le mécanisme est
Habillage, pas la déclaration du FXML. Cette page a longtemps écrit « chargée par tous les FXML » : c'était vrai à 23 exemplaires sur 24, et surtout ça désignait le mauvais endroit. L'ADR 3374 écarte explicitement la déclaration par FXML, qui « se serait défaite au onzième ». La plupart des vues la déclarent quand même, par héritage de leur écriture d'origine ; ce n'est pas ce qui la fait arriver.
Le piège d'architecture (mapping feature → classe CSS). ColonneBadge vit dans commun, qui ne
doit dépendre d'aucune feature (règle ArchitectureTest.features_sans_cycle). Le socle ne connaît donc
que les enums de commun.model. Pour un statut propre à une feature (Fraicheur côté sites,
StatutObservation côté validation…), le mapping statut → classe CSS reste côté feature, et l'on
passe cette fonction au générique cellule(Function) :
// sites : la vue mappe son enum
colStatut.setCellFactory(c -> ColonneBadge.cellule(LignePassage::statutClasseCss));
// audio : FormatLigneAudio.classeBadgeStatut(StatutObservation) -> "badge-observation-…"
col.statut().setCellFactory(c ->
ColonneBadge.cellule(ligne -> FormatLigneAudio.classeBadgeStatut(ligne.statut())));
Les classes CSS correspondantes (.badge-observation-*, .badge-frais/tiede/froid) vivent quand même
dans design.css : ce ne sont que des chaînes, aucune dépendance de code de commun vers la feature.
La règle. Une nouvelle table de données réutilise TableDonnees.uniformiser + ColonneBadge ;
jamais une densité ni une pastille ad hoc. Un statut de commun.model passe par ColonneBadge.classe ;
un statut de feature reçoit un classeBadge/classeCss côté feature (jamais une surcharge dans
commun, sous peine de cycle).
Ajouter une colonne : la question précède la donnée¶
Avant de poser une colonne, la question n'est pas « la donnée est-elle disponible ? » mais « a-t-elle un sens sur cette ligne-là ? » (ADR 2861).
Une donnée du point (sa commune, ses coordonnées, son enregistreur) ne s'affiche que sur une table dont une ligne porte un point. Sur une table qui agrège - la table des carrés, celle des espèces - la cellule devrait choisir parmi plusieurs valeurs, et un carré de 2 km chevauchant deux communes la ferait mentir d'autant plus discrètement qu'elle aurait l'air juste.
Le critère se vérifie mécaniquement : prendre la valeur de la première ligne du groupe donne-t-il la
même chose que prendre celle de n'importe quelle autre ? AgregationAnalyse pose ainsi le nom du
site sur une ligne de la table des carrés, en lisant la première observation - licite, puisque le
groupe est un carré. La commune, elle, varie à l'intérieur d'un carré.
Deux corollaires, appris en posant la même colonne sur trois tables :
- la marque d'absence est locale. Une valeur non résolue est un état normal, et ce qui s'affiche alors suit la convention de sa table (cellule vide ici, tiret là) plutôt qu'une règle imposée aux trois : ces tables ne se lisent pas côte à côte, et uniformiser aurait demandé de toucher des colonnes hors périmètre ;
- le contexte se masque quand il devient constant. Sur un écran dont la source peut cibler un seul
passage (
ColonnesAudio.adapterAuContexte), les colonnes qui décrivent ce passage disparaissent : elles porteraient la même valeur sur toutes les lignes. Une colonne ajoutée à ce groupe doit y être inscrite, sans quoi elle reste seule à s'afficher là où ses voisines s'effacent.
Confronter deux lectures d'une même donnée¶
Quand une donnée se dérive de deux sources qui peuvent se contredire, l'écart mérite d'être montré : il est invisible sinon. C'est le rôle de l'audit de cohérence, et le patron se répète (ADR 3168, premier cas : le département d'un point, lu par son carré et par sa commune).
Une classe par confrontation, collaboratrice de ServiceAuditCoherence comme BalayageDisque et
AuditEnLigne, branchée dans auditerTout(). Une catégorie s'ajoute à CategorieConstat et rejoint
d'elle-même les puces de filtre, construites sur values().
Trois règles, apprises sur le premier cas :
- la sévérité est un contrat de sortie, pas une couleur.
audit-coherencerend1dès qu'un constat est en erreur. Un écart dont le cas normal est d'exister doit donc êtreINFO, sinon la commande échoue sur une base saine et les scripts qui l'appellent cassent ; - montrer n'est pas trier. Si l'audit ne dispose pas de quoi départager l'écart légitime du suspect, il émet le même constat pour les deux et le dit. Un tri approximatif serait pire que pas de tri : il ferait croire les faux positifs déjà écartés, et les vrais cesseraient d'être lus ;
- s'abstenir plutôt qu'affirmer. Deux écritures qu'on ne sait pas comparer ne divergent pas. Un carré
corse porte
20là où l'INSEE écrit2A/2B: comparer les chaînes telles quelles ferait de chaque point corse une divergence.RegionsFrancaises.memeDepartementn'affirme que les écarts qu'elle sait démontrer.
Le patron vaut aussi hors des données du produit (#3667)¶
CorrespondanceRecetteTest confronte deux sources qui parlent du même sujet sans se connaître : le
script de recette, qui dit ce qu'un cas demande (*perceptif* ou non), et le code, qui dit ce
qu'un test prétend prouver (jugement). Elles peuvent se contredire, et la contradiction était
jusque-là invisible.
Deux règles s'y retrouvent à l'identique, ce qui est le signe qu'il s'agit bien du même patron :
- montrer n'est pas trier : un cas marqué perceptif ne rejoint jamais les assertés, quoi qu'en dise le code. La contradiction se signale, elle ne se résout pas en silence dans le sens qui arrange le compteur ;
- s'abstenir plutôt qu'affirmer : le garde ne réclame pas la couverture, il refuse qu'elle soit tacite. « Aucun test » reste une réponse recevable.
Une différence, en revanche : ici la confrontation ne rend pas un constat à l'utilisateur, elle rougit la CI. Un désaccord entre un document et le code n'a pas de destinataire dans le produit.
Où le constat vit. Sur la portée de ce qu'il décrit, pas sur celle de ce qui l'a déclenché. Un
écart de topologie (un point, un carré) sort de l'audit global ; l'audit ciblé d'une nuit
(auditerPassage) le répéterait à chaque passage du même point sans rien apprendre.
Surcharger la feuille de style d'une bibliothèque tierce¶
Le problème. Un composant embarqué apporte sa feuille de style, et elle peut être en dessous de
nos seuils. Mesuré sur la barre d'outils d'audio-view (#3462) : le texte des boutons était à
10,68:1, très au-dessus du seuil, mais leur surface à 1,17:1 contre le fond - un bouton qui
ne se voit pas comme un bouton. Éclaircir le texte, remède qu'on prend sans mesurer, n'aurait rien
réparé.
La règle (ADR 3462) :
- la surcharge vit dans
design.css, jamais dans une feuille par feature ; - elle monte en spécificité (
.audio-view .audio-view-toolbar .buttonet non.button), parce que la feuille tierce est chargée après la nôtre et gagnerait à égalité ; - elle porte un test de contraste qui mesure sur le rendu, pas sur les valeurs déclarées
(
ContrasteVueAudioTest). WCAG §1.4.11 accepte que le contraste vienne de la surface ou du contour : le test retientmax(surface, contour), sans quoi il refuserait une correction valide.
C'est un dépassement assumé de l'ADR 0046
(« une classe CSS se définit dans une seule feuille »), et son garde ne peut pas le voir :
DoublonsFeuillesDeStyleTest balaie src/main/java/fr/univ_amu/iut, or la feuille tierce vit dans un
jar. Sans l'ADR, ce dépassement ne laisserait aucune trace. Le remède de 0046 - désambiguïser les
noms - n'existe pas quand le nom appartient à une bibliothèque.
Icônes d'IHM : un pictogramme se pose, il ne s'écrit pas¶
Le problème. Les libellés portaient leurs pictogrammes en toutes lettres dans le text des
FXML : ♻ Réactivation, ☰, 📤 Exporter…. Un caractère dépend des polices installées sur la
machine : selon le système il tombe en rectangle vide, en noir et blanc, ou en emoji couleur pleine
taille qui déséquilibre la ligne. Il ne se teinte pas non plus avec le texte, donc il ne peut
suivre aucun état. #700 avait posé la règle ; l'usage littéral était revenu, jusqu'à 35 glyphes sur
17 vues au moment de #1933.
Le rendu est correct sur la machine qui écrit le code. C'est précisément pourquoi le défaut ne se signale pas tout seul : il se voit sur les aperçus régénérés en CI, où plusieurs pictogrammes du produit ne s'affichent pas du tout.
La solution. Un FontIcon (pack FontAwesome 5) dans le <graphic>
du nœud, et le texte reste du texte :
<Label text="Reconstruire un passage manquant" styleClass="titre-page">
<graphic><FontIcon iconLiteral="fas-cloud" styleClass="titre-page-icone"/></graphic>
</Label>
Où passe la frontière. Ce qui désigne une action ou un objet est une icône ; ce qui vit dans
une phrase reste un caractère. Mécaniquement, par bloc Unicode : les flèches (U+2190-U+21FF) et les
opérateurs mathématiques (U+2200-U+22FF) sont de la typographie (A → B, ≥ 1 mois), tout le reste
est un pictogramme. Un signe typographique seul sur un nœud retombe du côté icône : c'est un
bouton à icône qui n'a pas dit son nom. Le raisonnement complet est dans
ADR 0035.
Trois pièges.
-fx-text-fillne colore pas unFontIcon: c'est-fx-icon-color. En convertissant un caractère en icône dans un contrôle déjà stylé, la taille est conservée (uneFontIconsuit le-fx-font-sizehérité, au même titre que-fx-icon-size), mais la couleur retombe au noir. La substitution compile, les tests passent, et l'icône est noire au milieu d'un contrôle gris ardoise. Mesures et garde-fou dans ADR 0035.- Une icône se réévalue comme son libellé. Une entrée de menu dont le texte change d'état (« Se
connecter… » / « Vigie-Chiro : pseudo ») doit changer d'icône avec lui.
ConstructeurMenuOutilsréévalue les deux à chaquesetOnShowing, en réutilisant leFontIconen place. - Un
promptTextest une chaîne, il n'accueille pas de nœud. Une loupe dans un champ de recherche se pose à côté du champ, ou pas du tout.
La règle. Aucun pictogramme littéral dans un FXML : PictogrammesFxmlTest échoue dessus. La
CLI est hors sujet : une console ne rend pas de FontIcon, ⚠ y est le seul moyen d'écrire un
avertissement. Les libellés bâtis en Java (#1564) ont été repris depuis : les glyphes de
sévérité par #2036 puis #2188/#2221, qui les font dériver du type plutôt que de les écrire.
Un pictogramme d'état n'est pas une sévérité, mais il se pose pareil. La colonne « Écouté » de la
qualification rendait ecoutee() ? "✓" : "○", deux pictogrammes dans une chaîne, qui ne disent
pourtant aucune sévérité (ce ✓ veut dire « écouté », pas « succès »). MarqueurEcoute les pose par
un cellFactory, comme la colonne badge de verdict : la forme distingue les deux états (un CHECK
plein, un cercle creux), la couleur reste neutre. Choisir un glyphe différent de celui de la
sévérité est délibéré : réutiliser CHECK_CIRCLE aurait fait lire « succès » là où on dit « fait »
(#2237).
Actions de ligne d'une table : double-clic et menu contextuel (socle commun)¶
Le problème. Les neuf tables de l'application ont grandi séparément, et leurs gestes avaient divergé. Le menu contextuel se réduisait partout à « Colonnes… », sauf la table des espèces de l'Inventaire qui y ajoutait « Fiche de l'espèce » ; trois tables (dépôt du Lot, Audit, Importation) n'avaient aucun menu. Le double-clic ouvrait selon l'écran l'écoute, le passage, ou rien. La même action s'atteignait par deux gestes différents d'un écran à l'autre, et les actions de ligne vivaient en boutons ou dans le ☰, jamais sous le curseur. Le chantier #1792 a unifié tout cela.
La solution. Quatre briques de commun/view, composables avec le GestionnaireColonnes existant :
DoubleClicLigne.installer(table, action): pose unerowFactoryqui déclencheactionau double-clic sur une ligne remplie. Le même geste installe aussi la sélection au clic droit, qui cible la ligne survolée sans casser une sélection multiple en cours ;MenuLigne.item(libelle, table, action): unMenuItemlié à la sélection, désactivé quand elle est vide ;MenuCopier.creer(table, Entree...): le sous-menu « Copier ▸ », chaqueEntree(libelle, valeur)extrayant une chaîne de la ligne, déposée dans le presse-papier système (PressePapier) ;ActionVigieChiroPassage.item(table, idPassage): ouvre la page de la participation du passage de la ligne, désactivé avec son motif quand le passage n'est pas lié à la plateforme.
Les items se passent en varargs à GestionnaireColonnes.installerClicDroit /
installer / installerEtPersister, seul propriétaire des menus contextuels de production : il les
compose et referme toujours la liste par « Colonnes… ».
La grammaire du menu, stable d'un écran à l'autre. L'ordre porte du sens ; on ne le réarrange pas au gré des écrans :
Action principale de la ligne (miroir du double-clic)
Actions secondaires (Vigie-Chiro, auditer…)
──────────
Validation ▸ (sous-menu, si l'écran valide)
Copier ▸
──────────
Colonnes… (toujours en dernier)
Le double-clic est le miroir de l'action principale, jamais une action qu'on ne trouve nulle part ailleurs : il n'a aucune affordance propre, donc tout ce qu'il déclenche doit rester atteignable par un chemin visible.
Le piège (un geste sans état ne peut pas être muet). Un MenuItem montre son état avant le
clic : indisponible, il se grise et porte le motif dans son libellé (#789). Un double-clic n'a rien à
montrer avant le geste, donc son silence ne se distingue pas d'une panne - c'est exactement ce qui a
été remonté de l'usage réel (#1834, #1837). Une action ouverte au double-clic doit donc rendre
compte quand elle n'aboutit pas : ActionFicheEspece.ouvrir rend un booléen, et
ouvrirOuSignaler(espece, siAucuneFiche) route le motif vers le canal de l'écran. Voir
ADR 0021.
Le véhicule du motif. commun.viewmodel.RetourOperation (texte + commun.model.Severite) et
commun.view.BandeauRetour.installer(...) rendent ce retour dans un bandeau non modal -
véhicule par défaut de tout compte rendu, le modal étant réservé à l'irréversible
(ADR 0023) :
un double-clic est un geste courant et souvent accidentel, une boîte modale y serait pire que le
silence. Le style vit dans design.css sous .bandeau-retour, que tous les écrans chargent déjà.
La règle. Une nouvelle table de données pose DoubleClicLigne.installer sur son action principale,
compose son menu par le GestionnaireColonnes dans l'ordre ci-dessus, et donne à toute action ouverte
au double-clic un moyen de dire pourquoi elle n'a rien fait.
Rendre compte d'une opération¶
Le problème. « Message » est un mot commode qui recouvre trois choses différentes, et un écran qui les mélange finit par mentir. L'EPIC #1870 a migré onze écrans et en a trouvé les trois formes.
Les trois natures, et leur véhicule.
| Nature | Ce que c'est | Véhicule | Se ferme ? |
|---|---|---|---|
| État | ce qui est (« Passage déposé le… », « Cohérence : corrigez les contrôles ») | libellé permanent, adossé à ce qu'il décrit | non |
| Retour d'opération | ce qui vient de se passer, en une phrase bornée | RetourOperation + BandeauRetour ou LibelleRetour |
oui |
| Compte rendu textuel | ce qui vient de se passer, de manière extensible : des constats, leurs détails | CompteRendu + VueCompteRendu, dans sa propre zone |
non, il se remplace |
| Compte rendu chiffré | ce qui vient de se passer d'une opération lourde, en proportions | CompteRenduChiffre + PanneauCompteRendu, dans sa propre zone |
non, il se remplace |
| Travail en cours | ce qui se passe | barre de progression, IndicateurOccupation, barre de statut |
sans objet |
Le mot « compte rendu » a changé de sens avec l'ADR 0031. Il désignait ici ce qui s'appelle désormais retour d'opération. Le critère qui les sépare n'est pas la longueur actuelle mais l'extensibilité : un message qui concatène une partie variable est déjà un compte rendu, et un compte rendu n'a jamais sa place dans un bandeau - l'y loger revient à le tronquer.
Comment choisir. Si ce qu'il y a à dire peut grandir - une liste de refus, de fichiers, de passages - c'est un compte rendu. Si c'est une phrase dont la forme est connue d'avance, c'est un retour.
Et d'où vient le texte ? Le critère précédent regarde ce qu'on dit ; celui-ci regarde qui l'a écrit, et il est indépendant.
Un retour est borné par définition - c'est ce qui le sépare d'un compte rendu. Mais rien ne borne
un message d'exception venu du pilote SQLite ou d'une réponse serveur, et le bandeau ne tronque
pas : son libellé porte wrapText, donc un long message enroule et fait grandir le bandeau. Mesuré :
379 caractères le portent de 56 à 106 px, 625 à 186 px.
La règle est donc : ce qui vient d'ailleurs se borne à sa porte d'entrée, qui est aussi le seul
endroit où on peut l'enrichir. RetourOperation.erreur(Throwable) ajoute le geste attendu (ADR 2635)
puis borne ; erreur(String) laisse passer entier, parce que nous l'avons écrit.
Passer refus.getMessage() contourne les deux. C'est ce que faisaient dix-sept appels, dont
quatorze perdaient au passage le « où le régler ». MessageExterneBorneTest refuse désormais cette
forme, en lisant le source. Voir ADR 2802.
Un état et un compte rendu ne partagent jamais de propriété, et un compte rendu ne se déduit jamais d'un statut : le même statut est atteint en agissant et en ouvrant un écran déjà dans cet état, et seul le premier mérite d'être rapporté. Voir ADR 0028.
Les sévérités. Elles vivent dans commun.model.Severite - le modèle, pas la vue : un constat
d'audit qualifie sa gravité sans rien savoir de l'affichage (ADR 0038, amendée par #2159).
SUCCES quand l'opération a abouti. ERREUR quand elle a échoué ou a été refusée par un service.
AVERTISSEMENT quand elle a abouti mais que quelque chose mérite l'attention : une nuit déjà
importée qu'on réimporte quand même, un dossier mélangeant deux enregistreurs. INFO pour tout le
reste, et ce « reste » est plus large qu'il n'y paraît :
- un guidage : l'utilisateur a quelque chose à faire, rien n'est cassé (« saisissez des nombres ») ;
- une absence d'objet : « rien à relever », « traitement déjà lancé » - rien n'a raté ;
- un résultat partiel : relevé incomplet, dépôt interrompu. Annoncer un succès mentirait sur ce qui est acquis, annoncer une erreur nierait ce qui est passé.
La sévérité ne s'écrit jamais dans le texte. Le constructeur compact de RetourOperation refuse un
message ouvrant par ⚠ ✓ ✗. La vue la rend deux fois - couleur et icône - depuis la valeur, par
IconesSeverite, table unique partagée par le bandeau, le compte rendu et le libellé inline. Un glyphe
dans la chaîne la dirait une troisième fois sans garantie d'accord (ADR 0035 point 5).
Une confirmation transporte une structure, pas une chaîne. Le port Confirmateur accepte un
CompteRendu autant qu'un String (confirmer(CompteRendu), #2060) : la modale de confirmation rend
alors la structure de VueCompteRendu - un Label par détail, le retrait porté par le CSS - au lieu
d'aplatir une liste à puces dans une chaîne. Une puce aplatie perd son alignement dès qu'une ligne
dépasse la largeur du dialogue ; la structure ne le peut pas. Seule l'implémentation qui rend le
dialogue (ConfirmationNavigation) surcharge la méthode ; les stubs de test se contentent du repli
textuel par défaut.
L'exception : les surfaces qui n'ont que du texte. Un pictogramme se rend en icône là où un
composant le rend. Là où il n'y a que du texte - la CLI (ADR 0035
point 6), une info-bulle dont l'avertissement ouvre une ligne interne (Tooltip n'accepte qu'un
graphic, en tête), un message imposé à un port en String - le glyphe littéral reste, et c'est
assumé (#2036). Le signe distinctif : aucun Label ni FontIcon ne peut s'y poser. Ce n'est pas un
oubli, c'est la limite de la surface ; on l'écrit à l'endroit du glyphe pour que le prochain lecteur ne
le « corrige » pas.
Une barre de statut est neutre. Elle dit où l'on en est, pas si c'est bien ou mal : ses phrases s'écrivent sans marqueur, et ce qui doit alerter passe par un bandeau ou un encart (ADR 0039).
Le patron Messages<Ecran>. Quand un écran porte un état et un compte rendu, les deux vivent
dans une petite classe dédiée plutôt que dans le ViewModel : MessagesAudio, MessagesLot,
MessagesRattachement. Elle expose les propriétés en lecture et des méthodes qui nomment la
sévérité (succes, info, erreur, effacer). Le ViewModel garde sa responsabilité d'orchestrer,
et le plafond GodClass du portail qualité s'en trouve mieux.
Un collaborateur qui n'émet que des échecs peut rester agnostique et recevoir un Consumer<String>,
la sévérité se décidant au point de jonction. Un collaborateur qui émet plusieurs natures reçoit la
messagerie et choisit lui-même : la lui faire deviner ailleurs reviendrait à réinterpréter ses messages
d'après leur texte.
Le patron du flux exposé. Quand un enchaînement complet (préparation, travail hors fil,
restitutions par issue) pèse sur le ViewModel, il déménage dans un collaborateur que le VM construit
et expose par un accesseur : MultisiteViewModel.positionsEnAttente() (la file du drag carte),
AudioViewModel.exports() (FluxExportsAudio, #2793/#2794 : exports CSV et « observations +
sons »). La vue pilote le collaborateur, qui parle au service et restitue par la messagerie du VM ;
le VM garde l'orchestration d'écran, et le plafond GodClass cesse d'interdire les gestes riches.
Le piège à connaître. Un message de garde placé derrière un contrôle grisé sur la même condition
n'est jamais lu par personne. Cinq cas de ce genre existent dans l'application (#1970) : la garde et le
disableProperty().bind(…) testent le même prédicat. Aucun test ne le signale - c'est en essayant de
produire une capture du message qu'on s'en aperçoit.
Une opération lourde rend ses comptes en proportions¶
Le problème. Un import brasse plusieurs gigaoctets, un dépôt pousse des centaines de mégaoctets. À la fin, l'utilisateur pose trois questions, dont aucune n'appelle une liste : est-ce que ça s'est bien passé, qu'est-ce que ça m'a coûté, qu'est-ce que je fais maintenant. Une énumération de constats y répond en obligeant à lire, et lire ne donne pas les proportions.
Le composant. PanneauCompteRendu (commun.view) rend un CompteRenduChiffre
(commun.viewmodel) : un verdict chiffré en pastille, la ventilation d'un ensemble en barre
empilée, des volumes comparés à échelle commune, ce qui reste vrai, et l'action suivante en
pied. Il est présentationnel pur et n'appartient à aucune feature.
Comment une feature s'y branche. Elle traduit ce qu'elle a déjà produit, elle ne recalcule rien :
| Feature | Bilan déjà produit | Traduction |
|---|---|---|
| import | RapportImport + VolumesImport |
CompteRenduChiffreImport |
| réactivation | RapportReactivation |
CompteRenduChiffreReactivation |
| publication des corrections | BilanPublication |
CompteRenduChiffrePublication |
Ce que le type garantit, et qu'il ne faut donc pas re-vérifier à la main (ADR 2358) :
- la largeur d'un segment est liée à sa fraction : aucun endroit où poser une largeur à la main, donc aucun endroit où l'échelle puisse mentir ;
- une ventilation non exhaustive est refusée à la construction, en nommant le reliquat. Un « autres » silencieux masquerait exactement ce qu'on cherche ;
- les barres de volume partagent l'échelle de la plus grande : « lu » ne remplit pas toute la largeur quand « écrit » vaut davantage.
Trois pièges rencontrés, à ne pas refaire.
- La teinte
SECONDAIREporte le vert deRETENU: elle sert la seconde part d'un couple de même nature (bruts + séquences se lisent comme un tout). L'employer dans une ventilation où chaque part a un sens distinct fait lire un écart comme une réussite. - Chaque mention porte sa sévérité. Un triangle d'alerte devant « L'audio est de nouveau complet » apprend à ne plus regarder les alertes.
- La bande vit dans des largeurs très différentes (900 px sous l'écran d'import, ~560 px dans une
modale) : les libellés s'enroulent, la légende reflue, et ce qui assume de s'abréger le déclare
par
abregeable. C'est le garde-fou anti-troncature des captures qui l'a imposé : deux fois, dont une en intégration continue seulement, ses métriques de police différant de neuf pixels par entrée.
Quand ne PAS l'employer. Un bilan qui n'a rien à ventiler garde le compte rendu textuel : un passage reconstruit n'a pas subi de réactivation, et une barre « 0 sur 30 » y ferait croire à une tentative qui a échoué. Et là où une commande en ligne rend le même bilan, le textuel reste : un terminal ne dessine pas de barres.
Unit of Work (UniteDeTravail)¶
Le problème. Par défaut, chaque écriture DAO s'auto-commit. Mais « créer un passage et sa session » doit être atomique : si la 2ᵉ échoue, la 1ʳᵉ ne doit pas rester en base.
La solution. Regrouper les écritures dans une transaction : tout réussit (commit), ou tout est annulé (rollback).
Dans cette application. UniteDeTravail
exécute un bloc sur une seule connexion :
sequenceDiagram
participant S as Service
participant U as UniteDeTravail
participant DB as Connexion
S->>U: executer(co -> { écritures })
U->>DB: autoCommit(false)
U->>DB: écritures…
alt tout réussit
U->>DB: commit()
else exception
U->>DB: rollback()
end
Principes. SRP (la gestion transactionnelle est isolée des DAO) ; garantit l'intégrité O7.
Quand une seule transaction est impossible : compensation
UniteDeTravail regroupe des écritures sur une même connexion. Mais certaines opérations
franchissent une frontière qui ouvre sa propre transaction - typiquement la reconstruction d'un
passage (#1522), dont l'import des observations passe par un port qui gère sa transaction à lui (et
SQLite n'a qu'un seul écrivain). Une transaction unique est alors infaisable. On garde malgré tout la
règle « mieux vaut ne rien créer que créer à moitié » par compensation (saga) : si une étape
échoue, on défait ce qui précède - ici via les clés étrangères ON DELETE CASCADE (supprimer le
passage emporte sa session, ses séquences, ses observations, cf.
Modèle de données). Le résultat visible est celui d'une transaction : tout,
ou rien.
Matérialiser tard, consommer, libérer aussitôt¶
Le problème. Certaines étapes ont besoin de fichiers qui n'existent pas encore : les tranches régénérées d'un brut, les archives ZIP d'un dépôt. Tout produire d'abord, tout consommer ensuite est la lecture naturelle - et c'est celle qui fait exploser le disque, précisément sur les nuits volumineuses que ces opérations visent. Régénérer une nuit entière pour libérer de la place commencerait par doubler l'occupation qu'on cherchait à réduire.
La solution. Ne matérialiser qu'au moment de consommer, et libérer dès que la consommation est acquise. Le pic n'est plus la somme des ressources produites, mais le nombre de ressources vivantes à un instant donné - un nombre que l'on borne explicitement.
La libération n'est donc pas de l'hygiène : c'est elle qui borne le pic. C'est ce qui distingue ce
patron d'un simple try/finally de nettoyage.
Dans cette application. Le patron est écrit quatre fois, dans trois features :
| Où | Matérialise | Libère | Ce qui borne le pic |
|---|---|---|---|
DecoupageParallele |
un sous-dossier .tmp-decoupage/<i> par original |
après le nommage définitif | le Semaphore de la campagne |
ReactivationDepuisBruts |
DossierTemporaire.creer("vc-regen-…") |
en finally, après rebranchement |
un brut à la fois |
HydratationDepuisBruts |
un temporaire par brut, sur un fil d'ExecutionParallele |
en finally |
le Semaphore d'ExecutionParallele |
SourceArchivesRegenerables |
une archive ZIP à la résolution | après le commit DEPOSE |
la fenêtre 2, relayée par parallelismeMax() |
sequenceDiagram
participant M as Moteur
participant S as Source / temporaire
participant C as Consommateur
loop dans la limite de la borne
M->>S: matérialiser (tard, faillible)
S-->>M: chemin
M->>C: consommer
alt consommation prouvée
M->>S: libérer
else échec
Note over S: reste sur le disque,<br/>la reprise le retrouve
end
end
La libération suit la preuve, jamais la tentative. Dans le dépôt, source.liberer est appelé
après le commit qui marque l'unité DEPOSE, jamais avant : une coupure entre les deux laisserait une
unité ni en ligne ni sur le disque. La reprise la régénérerait, mais on aurait perdu la preuve de
l'envoi.
Principes. SRP (qui produit la ressource sait la libérer) ; borne le pic disque, objectif O3.
Deux politiques d'échec de libération, et c'est voulu
Trois des quatre occurrences avalent l'échec de libération : ne pas avoir pu rendre de la place
n'est pas une raison de faire échouer une opération par ailleurs réussie. Le reliquat sera repris
par « Libérer l'espace disque ». C'est le contrat que portent
DossierTemporaire.supprimer
(« efface au mieux ») et SourceDepot.liberer, formulé deux fois de façon indépendante.
DecoupageParallele échoue dur, et c'est la bonne asymétrie : son temporaire n'est pas un
reliquat mais une étape du pipeline. Le nommage définitif lit ce dossier
(ADR 0026) ; un temporaire
survivant fausserait la découpe suivante au lieu de simplement occuper de la place.
La règle : avaler si la ressource n'est qu'un coût, échouer si elle porte du sens.
Ce qui n'est pas ce patron : la ressource de session
ImportationViewModel extrait aussi un ZIP dans un temporaire, mais celui-ci est un champ qui
survit à plusieurs interactions, se libère sur des transitions d'écran et non à la fin d'une
consommation, et s'accompagne d'un filet anti-fuite balayant les résidus au démarrage.
C'est une ressource de session, gouvernée par le cycle de vie d'un écran. Lui appliquer ce patron-ci la libérerait sous les pieds de l'utilisateur. Un patron sans frontière se fait invoquer à tort.
Trois ADR décidaient déjà ce patron, chacune pour sa feature et sans se citer :
0026 (temporaire vidé après
chaque brut), 0032 (résolution tardive et faillible,
liberer no-op par défaut), 0033 (fenêtre bornée, la
libération suit la preuve). Cette section est ce qui les relie.
Observer (propriétés et binding JavaFX)¶
Le problème. Comment garder l'IHM synchronisée avec l'état sans que le modèle « pousse » vers des widgets qu'il ne devrait pas connaître ?
La solution. Le sujet (une Property / ObservableList) notifie ses observateurs au
changement ; la vue s'abonne par binding. Le sujet ignore qui l'observe.
Dans cette application. Le viewmodel expose des propriétés ; la vue s'y lie. Quand l'état change, l'IHM se met à jour toute seule : la vue observe, elle ne tire pas. C'est le moteur de MVVM.
sequenceDiagram
participant VM as ViewModel
participant P as Property
participant V as Vue
VM->>P: set(nouvelle valeur)
P-->>V: notifie (invalidation)
V->>V: met à jour le widget
Principes. Faible couplage View↔ViewModel et DIP (la vue dépend d'abstractions observables, pas de logique).
Le signal de mutation : « tu écris, tu signales »¶
Le problème. Un binding observe une Property. Il ne sait pas observer SQLite. Tant qu'une
écriture en base ne laisse aucune trace observable, un écran ne peut se rafraîchir qu'en se rappelant
lui-même, typiquement au retour de navigation - et il rate donc tout ce qui survient pendant qu'il est
affiché.
La solution. Un port que l'écriture appelle, et un compteur observable que l'écran lit.
| Pièce | Où | Rôle |
|---|---|---|
JournalMutations |
commun.model |
le port, sans JavaFX (model ne peut pas en dépendre) |
RevisionDonnees |
commun.viewmodel |
ReadOnlyLongProperty incrémentée à chaque signal |
La règle d'appel tient en trois mots : tu écris, tu signales. Après validation, jamais avant. Elle
a d'abord été énoncée autrement - une fois par opération métier - et cette version-là n'a pas tenu :
RapprochementSites crée les sites en boucle en appelant le même service qu'un ajout manuel, et ce
service ne peut pas savoir s'il sert un geste ou une synchronisation de deux cent cinquante. La
frontière d'une opération métier n'est pas visible depuis l'endroit qui écrit.
La rafale se règle donc chez le lecteur : RevisionDonnees ne poste pas de nouvelle avancée tant
que la précédente n'est pas appliquée. Deux cent cinquante signaux, un réveil. Un endroit, sous test,
au lieu d'une vigilance dans chaque appelant. Voir
ADR 3537.
Ce qui émet, et ce qui n'émet pas. Seules les mutations structurelles : celles qui peuvent
changer l'inventaire affiché (sites, points, passages, observations). Une validation, un verdict, une
disposition de colonnes ne changent aucun de ces nombres, et les annoncer ferait relire quatre
COUNT(*) pour un affichage identique.
Un grep ne suffit pas à trouver les écritures. Deux inventaires successifs en ont manqué :
CreationPassageArchive nomme son DAO sur une ligne et appelle insert sur la suivante ;
MoteurImport écrit le passage en SQL brut, hors de PassageDao. C'est le même constat que
l'ADR 3498 fait sur les commandes CLI :
ni le nom, ni le service appelé, ni l'analyse d'appels ne tranchent, et ils se trompent dans les deux
sens.
Côté lecteur, une méthode. Un écran qui veut suivre la donnée déclare
SuitLaRevision et n'a rien d'autre à faire :
public class SaisonController implements RafraichirAuRetour, SuitLaRevision {
@Override
public void rafraichirDepuisLaDonnee() {
// ce que l'écran fait déjà pour se recharger
}
}
Il ne connaît ni RevisionDonnees, ni écouteur, ni addListener : le Navigateur pose l'abonnement
quand l'étape entre dans l'historique et le rend quand elle en sort, au même endroit que
AuDepartEcran.
Pourquoi le cycle a quitté les écrans. Les cinq premiers l'ont porté eux-mêmes : un champ
ChangeListener, un addListener, un removeListener. Trois lignes, cinq fois, dont une seule
empêchait une fuite : RevisionDonnees est un singleton, un écran ne l'est pas, et un abonnement non
rendu survit à sa vue. Un cycle de vie qu'on peut oublier est un cycle de vie mal placé. Le
Navigateur porte déjà celui des quatre autres contrats ; celui-ci l'a rejoint.
L'abonnement se repère par la VUE, pas par l'étape. Une étape est un record remplaçable :
actualiserLibelleCourant (#1213) la remplace par sa jumelle relibellée, un setAll retire puis
replace l'accueil. Dans les deux cas l'écran n'a pas bougé, et ré-abonner ferait relire la base deux
fois pour un seul import (NavigateurTest#relibeller_ne_reabonne_pas).
Les deux mécanismes coexistent, et ce n'est pas une transition inachevée.
RafraichirAuRetour couvre les update (un
verdict, un dépôt, un statut de workflow), que le signal n'annonce pas ; le signal couvre les
insert / delete, que le retour ne voit pas quand ils surviennent sous les yeux de l'utilisateur.
Les cinq écrans qui rechargent au retour déclarent donc les deux.
Principes. DIP (le service dépend d'un port, pas de JavaFX) et Observer (l'émetteur ignore ses lecteurs).
L'état qu'on peut demander mais pas suivre¶
Le problème. Le signal de mutation couvre la base. Il ne couvre pas les états qui vivent
ailleurs : le jeton Vigie-Chiro est un fichier du workspace, et ClientVigieChiro#estConnecte()
comme StockageConnexion#estConnecte() se demandent - ils ne se surveillent pas.
Un écran qui ferme un geste faute de jeton (affordance #789, « empêcher plutôt qu'avertir ») n'avait donc aucun moyen d'apprendre qu'un jeton venait d'arriver. Le bouton « Récupérer depuis Vigie-Chiro » conseillait « connectez-vous depuis le menu principal » ; on suivait ce conseil, on revenait, et il répétait le même conseil. L'écran avait donné un conseil sans pouvoir voir qu'on l'avait suivi (#4205).
La solution. La même figure que ci-dessus, appliquée à un état plutôt qu'à un flux d'événements : un service qui n'est pas observable le devient par une couche mince au-dessus de lui.
| Pièce | Où | Rôle |
|---|---|---|
EtatConnexion |
commun.viewmodel |
le port, en lecture seule : ReadOnlyBooleanProperty |
RefletDuJeton |
connexion.viewmodel |
l'implémentation : un reflet de FournisseurToken, relu sur demande |
Trois différences avec le signal de mutation, et chacune vient de ce qu'on observe un état et non des événements :
- Un booléen, pas un compteur. Deux mutations successives doivent réveiller deux fois ; deux lectures qui rendent « connecté » ne sont pas deux nouvelles. Reposer la même valeur ne réveille personne, et c'est le comportement voulu.
- Un seul émetteur, nommé. « Tu écris, tu signales » n'a pas de sens ici : il n'y a qu'un endroit
dans l'IHM où le jeton stocké change, la modale de connexion. Elle s'abonne à sa propre
jetonEnregistreProperty, ce qui couvre d'un seul branchement les cinq chemins qui rafraîchissent (connexion, échec, refus, injoignable, déconnexion) - dont on en oublierait un à les recopier. - La valeur se lit au berceau.
RefletDuJetons'initialise depuisFournisseurTokenà la construction. Partir defalsegrisserait tous les gestes au démarrage d'une session déjà connectée, jusqu'à la première ouverture de la modale.
Côté lecteur, pas de contrat de navigation : l'écran s'abonne explicitement, parce que la dépendance mérite d'être lue dans son code.
etatConnexion.ifPresent(etat ->
etat.connecteProperty().addListener((obs, avant, apres) -> rafraichirDepuisLaDonnee()));
Optional chez les consommateurs, et non chez l'émetteur. La source de vérité
(FournisseurToken) n'est liée que par ConnexionModule : un injecteur partiel (outils de capture) n'a
aucun jeton, et « pas d'état de connexion » y est la réponse juste - un geste qui touche la plateforme
s'y ferme, ce qui est prudent. La modale de connexion, elle, exige le reflet : elle ne s'ouvre que
là où le module est chargé, et un ifPresent silencieux y rendrait l'application muette sur l'arrivée
du jeton sans que rien ne rougisse.
OptionalBinder veut un port, pas une classe concrète. newOptionalBinder(binder(), X.class)
suivi de setBinding().to(X.class) fait pointer la liaison sur elle-même, et Guice refuse de démarrer.
C'est la raison de la paire port/implémentation ici, et non un goût pour les interfaces.
Un état de contrôle se lie, il ne se photographie pas¶
Le problème. Un booléen est lu au moment où l'écran se construit, puis posé sur un contrôle :
setDisable, setVisible + setManaged, un String de libellé. Ce qui le rendait vrai peut
changer ; le contrôle, lui, ne rebouge plus.
C'est plus insidieux que l'écran qui ne voit pas arriver la donnée (ci-dessus), pour deux raisons :
l'instantané est juste à l'instant où il est pris, donc rien ne se voit au premier lancement ; et
les écrans restent vivants dans la pile du Navigateur, donc aucune reconstruction ne rattrape
la photo.
La règle. Ce qui se décide au montage, c'est le câblage ; ce qui varie, c'est la donnée.
Une propriété porte l'état, le contrôle s'y lie (disableProperty().bind(...),
VisibiliteGeree.lier(...)), et ce qui l'alimente est réinterrogé au bon moment.
Ne pas dériver l'état de ce qui est affiché. Avant « Appliquer », les chemins sélectionnés ne sont pas encore la configuration persistée : un bouton lié à eux s'allumerait sur une intention. Ce qui décide, c'est ce qui a été écrit (#3543).
Recalculer ne doit pas défaire un choix de l'utilisateur (ADR 3095).
Repeupler la liste d'un ComboBox par clear() puis addAll() remet sa valeur à null, et une
liaison bidirectionnelle propage ce null jusqu'au ViewModel : le filtre disparaît à chaque retour
sur l'écran. setAll remplace le contenu sans passer par la liste vide (#3544).
Les quatre formes, dont trois qu'un grep ne voit pas¶
Le chantier #3539 les a rencontrées une par une. C'est la forme qui les relie, pas le mot-clé :
| Forme | Ce qui la trahit |
|---|---|
| un booléen posé sur un contrôle au montage | setDisable / setVisible, visible au mot-clé |
| une sortie prématurée qui n'installe ni items ni liaison | rien : la méthode a l'air complète |
un String figé dans un record (le libellé d'une étape de navigation) |
rien |
| un rappel qui court-circuite le point d'entrée qui rafraîchit | rien |
L'inventaire complet des sites relevés, avec le verdict de chacun, vit dans #3545 : un inventaire se cite, il ne se recopie pas (ADR 3535).
Le critère de tri, quand on cherche un jumeau¶
La question n'est pas « y a-t-il un setVisible ? » mais :
le fait qui le rend vrai peut-il changer pendant la vie de l'application ?
Trois familles répondent non, et sont donc légitimes :
- ce qui dérive d'un drapeau de fonctionnalité (
Optional<Service>.isPresent()) : l'onglet des réglages annonce que la bascule prend effet au prochain démarrage ; - ce qui dérive d'une ressource embarquée dans l'artefact ;
- ce qui vit dans une modale, rebâtie à chaque ouverture.
Principes. Observer (le contrôle suit une propriété) et DIP pour la source qu'on réinterroge.
API fluente (le « builder » le plus proche)
Les liaisons s'écrivent souvent avec l'API fluente de JavaFX :
Bindings.when(cond).then(a).otherwise(b), Bindings.createStringBinding(...). C'est un
builder conditionnel fourni par la bibliothèque : pas un patron Builder que nous
implémentons. Le projet n'a d'ailleurs pas de Builder maison : les entités sont des record
immuables (cf. Objets-valeurs), qui rendraient un builder superflu.
Factory (controllerFactory)¶
Le problème. Par défaut, FXMLLoader crée les controllers avec new (constructeur vide) : ils ne
peuvent pas recevoir de dépendances injectées.
La solution. Fournir au loader une fabrique qui délègue la création à Guice.
Dans cette application. loader.setControllerFactory(injector::getInstance) : chaque controller est
instancié par le conteneur, donc reçoit ses ViewModels/services par constructeur (cf.
App).
Diagramme de séquence du bootstrap : Injection.
Principes. DIP (le controller ne construit pas ses dépendances) et IoC.
Machine à états (MoteurWorkflowPassage)¶
Le problème. Le statut d'un passage (Importé → … → Déposé) doit avancer dans l'ordre : on ne
doit ni sauter une étape (importer puis déposer) ni revenir en arrière. Disséminer ces règles dans les
services serait fragile.
La solution. Centraliser les transitions autorisées dans un objet dédié : depuis un état, une seule cible permise (le successeur immédiat).
Dans cette application. MoteurWorkflowPassage
porte l'ordre et expose suivant(...) / estTransitionAutorisee(...) / exigerTransitionAutorisee(...).
La logique est isolée de l'énum
StatutWorkflow
(simple porteur de libellés).
stateDiagram-v2
[*] --> IMPORTE
IMPORTE --> TRANSFORME
TRANSFORME --> VERIFIE
VERIFIE --> PRET_A_DEPOSER
PRET_A_DEPOSER --> DEPOSE
DEPOSE --> [*]
[*] --> RECUPERE
RECUPERE --> DEPOSE
Une entrée hors file (#2581). RECUPERE est le statut d'une nuit rapatriée de Vigie-Chiro : elle
n'a franchi aucune des étapes ci-dessus, elle est arrivée par une autre porte. Elle est donc hors de
ORDRE, et le moteur lui accorde une seule transition : RECUPERE → DEPOSE, quand la réactivation lui
rend son audio. suivant(RECUPERE) est vide : sa suite dépend d'un événement, pas d'une place dans la
file. Voir ADR 2581.
Le rang de tri n'est pas l'ordinal(). RECUPERE est déclaré en dernier dans l'énum, pour ne
pas décaler les comparaisons existantes (« au moins vérifié »). Trier sur ordinal() le rangerait donc
après « Déposé », par pur effet de bord de ce choix : StatutWorkflow.rangDeProgression() existe pour ça.
Principes. SRP (les règles de transition ne polluent ni l'énum ni les services) et un point de vérité unique pour l'avancement d'une nuit.
Synthèse : où vit chaque principe SOLID¶
| Principe | Incarné surtout par |
|---|---|
| SRP | MVVM (couches), DAO, UniteDeTravail, MoteurWorkflowPassage, Facade, objets-valeurs |
| OCP | Contrats Ouvrir*, Multibinder d'accueil, Template Method, Strategy |
| LSP | Sous-types de DaoGenerique substituables |
| ISP | Interfaces de rôle fines (GardeQuitter, RafraichirAuRetour, EmplacementNavigation) |
| DIP | Injection + Composition Root, contrats Ouvrir*, binding observable, Factory |
Au-delà de SOLID¶
SOLID n'est pas seul : l'architecture respecte aussi plusieurs principes transverses, eux aussi visibles dans le code.
Un binding déclare tout ce qu'il lit¶
Le problème. Bindings.createXBinding(calcul, dependances…) ne recalcule calcul que si l'une des
dependances s'invalide. Rien ne vérifie que calcul n'en lit pas d'autres : ni le compilateur, ni
PMD, ni un test. Un binding incomplet reste silencieux tant qu'une propriété correctement déclarée
change juste après celle qui manque.
C'est le mode de panne le plus discret des trois : l'affichage est juste, mais pour la mauvaise raison. Il ne cesse de l'être que le jour où l'ordre des écritures change.
La règle. La liste énonce tout ce que le calcul lit, y compris dans les méthodes qu'il appelle. C'est là que les trois défauts du chantier #3536 se cachaient : le calcul était une référence de méthode, et la lecture deux niveaux plus bas.
Une lambda qui lit un champ n'a pas une dépendance manquante, elle a une dépendance absente du modèle. Les deux se ressemblent à la lecture, et le remède diffère : rendre la valeur observable, puis la déclarer. Trois écrans y sont passés (#3548), un quatrième ensuite (#3752).
Attention aux valeurs constantes reposées. comptage.set(ComptageAudio.VIDE) sur une propriété
qui vaut déjà cette instance n'émet rien : ObjectPropertyBase.set compare par référence. Un chemin
d'erreur qui « réinitialise » peut donc n'invalider aucune de ses dépendances déclarées.
Ce qui garde la règle, et ce qui ne la garde pas. Vérifier statiquement « lu ⊆ déclaré » est hors de
portée (ADR 3547). Un cliquet compte les sites
à la place (DeclarationDesBindingsTest) : il ne vérifie aucune déclaration, il garantit qu'un nouveau
site ne peut pas entrer sans être vu. Quand il rougit, ouvrez le site ajouté, confrontez ce que son
calcul lit à ce qu'il déclare, puis ajustez le nombre - l'inverse retire au cliquet sa seule utilité
(ADR 3540).
Loi de Déméter (« ne parle qu'à tes amis proches »)¶
Un objet ne devrait appeler que les méthodes de lui-même, de ses paramètres, de ce qu'il
crée et de ses champs directs : pas de chaîne a.getB().getC().faire().
Ici. La vue se lie à vm.titreProperty() (un collaborateur direct), jamais à
vm.modele().site().nom(). Les contrats Ouvrir* reçoivent un ContexteSite / ContextePassage
(données passées en paramètre) plutôt que de fouiller dans l'écran appelant. Et view_sans_jdbc
interdit à la vue de « traverser » les couches jusqu'à la base.
YAGNI (« vous n'en aurez pas besoin »)¶
Ne pas construire de généricité spéculative.
Ici. Pas d'ORM (des DAO PreparedStatement directs) ; le workflow est une simple List ordonnée
(suivant() = index + 1), pas un moteur d'états générique ; DaoGenerique n'offre que les
opérations réellement communes (lecture/suppression), les insert/update n'étant écrits que là
où on en a besoin ; l'application étant mono-utilisateur, idUtilisateurCourant est simplement le
premier utilisateur (aucune machinerie d'authentification construite « au cas où »).
KISS (« reste simple »)¶
Ici. SQLite fichier (pas de serveur), tests headless en mémoire (pas de xvfb), capture par
Scene.snapshot() (pas d'orchestration lourde).
DRY (« ne te répète pas »)¶
Ici. DaoGenerique (Template Method) et RowMapper (Strategy) factorisent la boucle ResultSet
écrite une seule fois ; les sections communes de doc renvoient à une source unique.
Tell, Don't Ask¶
Demander à un objet d'agir, plutôt que de lire son état pour décider à sa place.
Ici. MoteurWorkflowPassage.exigerTransitionAutorisee(actuel, cible) vérifie et lève si la
transition est interdite, au lieu d'exposer l'ordre pour que chaque appelant le re-teste.
Composition plutôt qu'héritage¶
Ici. Le chrome compose des capacités via de petites interfaces optionnelles (ISP) détectées à
l'exécution, et l'injection compose le graphe d'objets : au lieu d'une hiérarchie de classes
profonde. (DaoGenerique reste un héritage assumé, limité au Template Method.)
Convention plutôt que configuration¶
Ici. Les .fxml/.css à côté de leur controller, les paquets de test en miroir de la
production, le captures.manifest, les noms Capture* / Navigation* / *Module : autant de
conventions qui évitent de la configuration.
Fail-fast¶
Ici. exigerTransitionAutorisee lève tôt ; Objects.requireNonNull(...) garde les
constructeurs ; DataAccessException remonte une erreur SQL sans la masquer.