Patterns récurrents
Comment on fait concrètement les choses dans ce repo. Reproduire ces patterns avant d'en introduire un nouveau ; toute déviation justifie un ADR.
Qui possède quoi (à lire avant de coder)
Avant de placer une logique, demande-toi quelle couche la possède — pas « où est-ce le plus simple à brancher ». La règle générale : les vues mirroitent de l'état vers l'URL / l'historique / les transitions ; elles ne dérivent pas de règles métier ou de navigation. Si une vue se met à lire une condition de domaine (un skip, une éligibilité, une tranche de prix, un seuil d'âge…), cette connaissance appartient presque toujours à un store ou un composable, qui doit l'exposer comme primitive nommée.
| Préoccupation | Propriétaire |
|---|---|
| Graphe d'étapes du wizard : quelles étapes existent, ordre, atteignabilité, skip | composables/booking-constructor/progression.ts |
| State du wizard (booking, passagers, rooms, sièges, pricing, waitingList) | stores/bookingConstructor.ts (source unique) |
| Miroir URL/historique des étapes & overlays, direction des transitions, scroll par étape | la vue (views/TravelBooking.vue) + composables/transition.ts, useOverlayRoute |
| Règles de validation métier réutilisables | utils/regle-validators.ts |
| Auth / refresh token, choix client HTTP | services/api-client.ts, stores/auth.ts |
| Invalidation des réponses en vol au logout (epoch de session) | services/session-epoch.ts (bump : stores/customer.ts logout() ; garde avant toute écriture post-await d'état de session — cf. business-rules.md → « Epoch de session ») |
| Référentiel produit (hébergements, assurances, véhicules) & dérivés (age tiers, optimizer) | stores/catalog.ts, utils/* |
Symptôme de mauvaise couche : une vue (ou un composant) qui réimplémente une condition que le store/composable connaît déjà. Exemple concret (corrigé par ADR 0011) : le miroir d'historique du wizard avait besoin de savoir si SEAT_SELECTION est auto-skippée (noVehiclePlan). La vue connaissait déjà ce flag (un garde inline) — il était tentant d'étendre ce garde pour compter les entrées d'historique. La bonne réponse : la progression possède la navigabilité des étapes et l'expose (navigableStepNamesBetween) ; la vue ne fait que pousser/dépiler ce qu'on lui rend. Quand tu hésites, nomme la primitive dans le propriétaire plutôt que de re-dériver dans le consommateur.
Ajouter une vue + une route
- Créer
src/views/MaVue.vueavecdefineOptions({ name: 'MaVue' })(PascalCase, identique au composant — sert aukeep-alive). - Importer dans
src/router/index.tset l'ajouter au bon groupe (publicRoutes,authRoutes,profileRoutes,travelRoutes). - Renseigner
metaau minimum :requiresAuth: truesi protégéebottomNavigation: truesi on garde la nav en bas (ajoute un inset de 63px)needsLoading: truesi la vue charge des données async (overlay tant quesetPageLoading(false)n'est pas appelé)backButtonOverlay: { icon: 'back' | 'close', scrollspy?: true, href?: '/' }selon le besoinkeepAlive: 'MaVue'si on veut garder l'instance entre nav (PascalCase = nom du composant)
- Si la vue fait du fetch : appeler
setPageLoading(false)dans leonMountedquand la data arrive. - Un seul élément racine dans le
<template>(voir gotcha ci-dessous) — non négociable pour une vue routée.
Gotcha : une vue routée DOIT avoir un seul élément racine
Toutes les vues sont rendues dans le <transition mode="out-in"> de BuchardApp.vue. Une <transition> ne sait animer qu'un seul élément racine : il lui faut un élément concret pour (a) poser les classes *-enter/*-leave et (b) écouter son transitionend.
Si une vue a plusieurs nœuds racine (un fragment), la transition n'a aucun élément à suivre : en mode out-in, le after-leave de la page sortante ne se déclenche jamais, donc la page entrante n'est jamais montée et #router-view se vide sur navigation. Le symptôme est trompeur — c'est la page que l'on quitte qui est en cause, pas celle vers laquelle on va (c'est ainsi qu'un fragment dans Account s'est manifesté en « /logout ne s'affiche plus » ; un rechargement direct marchait car il n'y a alors pas de sortie à terminer).
Comptent comme nœuds racine multiples (= fragment, à éviter) :
- plusieurs éléments frères au premier niveau (ex.
<PullToRefresh/>+<div>) ; - un commentaire HTML à côté de l'élément racine — en dev, Vue conserve les commentaires de template comme vnodes commentaires, donc
<!-- … --> <div>= deux racines. (En prod les commentaires sont supprimés → le bug ne reproduit qu'en dev, encore plus déroutant.) Garder tout commentaire à l'intérieur de la racine.
Vue 3 autorise les fragments en général — c'est pourquoi la règle est ciblée sur les vues et non imposée par ESLint sur tout le repo. Un garde-fou de build l'applique : build/vite-plugin-single-root-views.ts scanne src/views/*.vue et fait échouer vite build (avertit en dev) si une vue a plus d'une racine.
Ajouter un endpoint API
Selon l'auth :
| Cas | Où |
|---|---|
| Endpoint public (pas d'auth) | publicApi.* dans services/api.ts via publicFetch ou cachedPublicFetch |
| Endpoint d'auth | authApi.* (même fichier) |
| Endpoint protégé (JWT) | protectedApi.*, doit passer par apiClient (services/api-client.ts) pour le refresh transparent |
| Endpoint paiement (origin séparée) | paymentApi.* (utilise VITE_PAYMENT_API_ENDPOINT) |
Toujours :
- Typer le retour avec
@spektrum/horizon-typesquand le type existe (aliasapi_X_Y as Yrecommandé). - Pour les GET cacheables, passer par
cachedPublicFetch(url, cacheOptions)— TTL défaut 5 min, surcharge possible ({ ttl: 60 * 1000 }). - Pour les errors, ne pas wrapper soi-même —
publicFetchetapiClientthrow déjàApiError. - Passer le body en objet brut, jamais pré-
JSON.stringifyun body contenant desDate: la stringification fige les dates en ISO UTC (Date.toJSON) avant que la frontière (serializeWireDatesdanspublicFetch/apiClient) ne puisse les émettre locales et sans fuseau — off-by-one garanti sur les dates-only côté backend (cf.business-rules.md→ « Dates sortantes », ADR0016).
Ajouter un store Pinia
// stores/myStore.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useMyStore = defineStore(
'my-store', // id kebab-case
() => {
const value = ref(0)
const doubled = computed(() => value.value * 2)
function inc() { value.value++ }
return { value, doubled, inc }
},
{
persist: true, // opt-in si on veut survivre au cold start
},
)- Préférer la Composition API setup-style (cohérent avec le repo).
- Re-exporter depuis
stores/index.tssi plusieurs stores l'importent (évite les cycles). - Ne jamais importer
@/routerdans un store qui peut être consommé par le router lui-même → utiliser une injection stylesetBackButtonRouter(voirmain.ts).
Ajouter une étape au wizard de réservation
- Créer le composant dans
components/travel-booking/TBStepXxx.vue. - Étendre l'enum d'étape dans
composables/booking-constructor/progression.tset insérer la position dans la séquence. Donner auSTEP_NAMESune valeur kebab-case URL-friendly (elle sert de segment:stepName). - Si l'étape est conditionnelle (peut être skippée) : ajouter la logique de skip dans
progression.tset veiller à ce querawActiveStepIndex(brut) reste cohérent avecactiveStepIndex(visible). - Le miroir step↔URL de
TravelBooking.vue(watchersparam→step/step→param) mappe l'étape sur le segment de chemin:stepName(/reservation/:slug/:stepName) —pushen avant,replaceen latéral (voir ADR0010+docs/back-navigation.md). Chaque étape avant = une entrée d'historique réelle ; pas d'enregistrement back-button (le geste natif marche tout seul). La direction du slide d'étape vient dustepTransitionlocal (delta d'index), pas desetTransition(supprimé). - Hooker la validation sur les computed du
bookingConstructor(*Validrefs) — pas de state local d'étape.
Ajouter un overlay (modal / sheet / overlay-page)
D'abord : overlay ou sous-page ? Un écran plein écran qui est en fait une sous-page (sa propre vue, son bouton retour) → fais-en une route réelle, pas un overlay : il emprunte alors la transition de page du shell et hérite de sa garde iOS (pas de double-animation au geste natif). C'est le cas des sous-pages voyage /voyage/:slug/{itinerary,departures,comments} (cf. ADR 0012). Réserve useOverlayRoute aux overlays transitoires (modale, sheet, fenêtre par-dessus une page qui reste montée).
Les overlays sont pilotés par l'historique via ?overlay=<name> — jamais un ref local (sinon le geste de retour natif ne le ferme pas). Voir docs/back-navigation.md et ADR 0009.
import { useOverlayRoute } from '@/composables/useOverlayRoute.ts'
const overlay = useOverlayRoute('comments') // nom = valeur du query ?overlay=overlay.isOpen.value→ lev-ifde l'overlay (et la<Transition>qui l'enveloppe).overlay.open()sur le déclencheur (bouton) ;overlay.close()passé au bouton de fermeture de l'overlay (<BackButton @back="overlay.close" />ou proptoggleFunction/close).- Le back natif / navigateur ferme l'overlay tout seul (le param disparaît →
isOpenbascule). Imbrication OK (liste → formulaire) : chaqueopen()empile une entrée. - Payload (quel passager, quel booking) : refs locales du host posées juste avant
open()— seule la visibilité vit dans l'URL. - Collision de nom : si le slot/scope de la page expose déjà une variable du même nom (ex.
comments), suffixer la const (commentsOverlay).
Cas particuliers (overlay paramétré + enfant imbriqué)
Deux écarts à la recette ci-dessus, illustrés par la fenêtre multi-chambre de TBStepAccommodationPassengersInformation (?overlay=accommodation-<n>) :
- Nom paramétré (
accommodation-<n>) :useOverlayRoute(name)prend un nom fixe et expose unisOpenen égalité stricte. Pour un nom indexé, inlineropen()/close()dans le host (router.pushdu nom calculé +markProgrammaticNav()+router.back(), en miroir deuseOverlayRoute.close()). Si un second overlay paramétré apparaît, extraire unuseOverlayRouteFamily(prefix). - Overlay enfant imbriqué : si l'overlay contient un autre overlay (ex. la fenêtre passager embarque
passenger-select), l'ouverture de l'enfant remplace la valeur deoverlay. Ne pas garder le parent par unisOpenexact (il se démonterait — et l'enfant avec) : garder lev-ifsur un ref local de visibilité, et fermer via unwatch(route.query.overlay)quand le param devient totalement nul (sortie de tout le sous-arbre). Pour distinguer « valider/garder » de « annuler/rollback » sur un back natif (qui ne passe par aucun handler du composant), porter un flagcommittedcôté host et n'effectuer l'annulation dans le watch que lorsqu'il estfalse. (Détail :docs/back-navigation.md→ « Parameterized overlay + nested child ».)
Modal système global (showModal / showLoginModal, cf. ADR 0018)
Pour ouvrir un modal depuis du code hors composant (handler d'erreur, api-client), ne pas utiliser useOverlayRoute (exige un setup()) : passer par le service modal composables/modal.ts — singleton module-scope (comme usePageLoading) + host unique SystemModal.vue monté dans BuchardApp.vue.
import { showModal } from '@/composables/modal'
const choice = await showModal({
title: 'Erreur',
message: 'Impossible de charger les données.',
buttons: [{ id: 'retry', label: 'Réessayer', primary: true }], // défaut : un bouton OK
}) // → id du bouton tapé, ou MODAL_DISMISSED (backdrop / ✕ / back natif)- Reste ADR 0009-compliant : le service pousse
?overlay=system-modalvia le router injecté (setModalRouterdansmain.ts, même pattern quesetBackButtonRouter) — le back natif ferme le modal. - Un seul modal système à la fois : un
showModalpendant qu'un est ouvert le remplace (l'ancienne promesse résoutdismissed, l'entrée d'historique est réutilisée) — dédoublonnage naturel des échecs concurrents. showLoginModal(message?)rendcomponents/LoginForm.vue(extrait deLogin.vue) et résouttruesur reconnexion. Câblé dansapi-client.sessionExpired()(cf.business-rules.md→ « Session expirée »).- Ne pas s'en servir pour un overlay qui appartient à une page (payload contextuel, imbrication) — la recette
useOverlayRouteci-dessus reste la norme.
Bouton « retour » de page
Pas de pile de callbacks : un bouton retour appelle goBack() (= markBackNavigation() + markProgrammaticNav() + router.back()) de stores/backButton.ts. La direction BACKWARD du slide est dérivée du delta de history.state.position par resolvePageTransition (router afterEach) — plus de setTransition (voir ADR 0010). BackButtonFloating (piloté par meta.backButtonOverlay) appelle déjà goBack() ; un <BackButton> sans listener @back y retombe. Pour une nav terminale (post-login, post-paiement) qui ne doit pas être back-reachable, utiliser router.replace (delta de position nul → slide NONE).
Ajouter un formulaire validé
Stack : Regle + form-inputs/ValidatedField.vue.
// useRegle localisé (messages FR par défaut) — PAS `@regle/core`
import { useRegle } from '@/utils/regle-validators.ts'
import { required, email } from '@regle/rules'
const form = reactive({ email: '' })
const { r$ } = useRegle(form, { email: { required, email } })- Importer
useRegledepuis@/utils/regle-validators(jamais@regle/core), ouuseScopedRegleItem/useCollectScopeRecorddepuiscomposables/regle-scoped-config.tspour les formulaires scoped. Les deux passent par la config globale (messages FR). - Règles métier réutilisables :
utils/regle-validators.ts. - Téléphones :
v-phone-inputavecconstants/country-codes.ts. - Dates :
form-inputs/DateInput.vue(jamais d'input natif).
Messages d'erreur en français par défaut (cf. ADR 0015)
Regle livre ses messages built-in en anglais. Une config globale (defineRegleConfig dans utils/regle-validators.ts) les surcharge en français pour les règles built-in (required, requiredIf, email, minLength, sameAs, date) ; elle est branchée sur le scoped via createScopedUseRegle({ customUseRegle }). Conséquences :
- Une règle brute (
{ email: { required, email } }) affiche du français sanswithMessage. LesxxxDecoratedderegle-validators.tsdeviennent redondants (conservés pour l'instant). - Un
withMessage(...)local au champ gagne toujours sur le défaut global — pour un message spécifique, décore le champ comme avant. - Traduire une nouvelle règle built-in = ajouter une entrée à la map
rulesdedefineRegleConfig, rien d'autre. - Typage : le raccourci
{ required: true }n'est plus accepté par ceuseRegle— utiliser la règlerequired.
Bouton « valider » guidant : pseudo-disabled + $validate()
Un bouton de soumission ne doit pas utiliser l'attribut natif :disabled quand le formulaire est invalide : un bouton désactivé n'émet aucun click, donc on ne peut rien faire au tap. À la place :
- garder le bouton cliquable et lui appliquer la classe
pseudo-disabled(opacité 0.5) tant que c'est invalide ; - au clic : si valide → soumettre / avancer ; sinon →
await r$.$validate()pour passer les champs en$dirtyet faire apparaître les erreurs viaValidatedField(dontimmediateErroraffiche$error). Optionnel : scroller sur le premier.is-invalid.
$validate() renvoie { valid }. Exemples : TravelBookingFooter.vue (liste d'attente, puis « Suivant » des étapes passagers), TBPassengerInfoForm.vue (« Valider »).
Cas du bouton distant du formulaire : quand le bouton (footer partagé) n'a pas accès à l'instance r$ du formulaire, passer par un signal sur le store — bookingConstructor expose validationRequestNonce + requestValidation() ; le footer bumpe le nonce, le formulaire monté le watch et lance $validate(). Un seul TBPassengerInfoForm est monté à la fois.
Gérer les erreurs
try {
await protectedApi.bookings.create(booking)
} catch (e) {
if (e instanceof ApiError) {
showToast(`Erreur ${e.status} : ${e.message}`)
// ou logique métier selon e.status
} else {
showToast('Erreur inattendue')
console.error(e)
}
}- Ne pas
console.logbrut en prod — préfixer ([feat],[router], etc.) pour faciliter le filtrageadb logcat/ Xcode console. error-handler.tsexposeshowToast(basé sur@capacitor/toast).
Émettre de l'analytics produit (cf. ADR 0019)
L'analytics PostHog est strictement opt-in et passe exclusivement par la façade services/analytics.ts (captureEvent, identifyUser, resetUser, captureError) — des wrappers no-op tant que initAnalytics() n'a pas tourné, sûrs à appeler partout. Règles :
- Jamais d'import direct de
posthog-jsailleurs que dansanalytics.ts(grep -rn "posthog-js" src/ne doit matcher que la façade). - Ne jamais appeler
initAnalytics()/disableAnalytics()soi-même : ces deux appels appartiennent aux actions du store de consentement (stores/analyticsConsent.ts—accept()boote + identifie,refuse()coupe la capture en session viaposthog.opt_out_capturing()) et au bloc cold-start demain.ts(gated surisAccepted). Toute UI de consentement (modal de démarrage, radios « Confidentialité » d'Autres) passe paraccept()/refuse(), jamais par la façade directement.posthog.init()émet une requête remote-config dès l'appel — l'init différée est la seule garantie « zéro collecte avant consentement » ;initAnalytics()lève aussi un opt-out SDK résiduel quand le consentement est ré-accordé (cf.business-rules.md→ « Consentement analytics modifiable »). - Émettre un event = un appel
captureEvent('nom_snake_case', { props })au point d'interaction (voir les call sites existants :travel_viewed,booking_started,booking_payment_initiated, …). Avant consentement (ou après refus), l'appel est silencieusement perdu — c'est voulu, ne pas bufferiser. - Les erreurs passent déjà par les funnels (
handleError/logError, handler Vue demain.ts,router.onError), qui appellentcaptureError(err, { source, … })→posthog.captureException(cf. ADR0020) — n'appellecaptureErrorà la main que pour un chemin qui n'y passe pas. Les funnels classifient la sévérité : une session expirée (isSessionExpiredError,services/api-error.ts) part en$exception_level: 'warning'(props spreadées après celles du SDK parcaptureException, donc elles écrasent le'error'par défaut), et un échec de credentials au login (400/404) n'est pas capturé du tout (skip au call site,stores/auth.tslogin()) — cf.business-rules.md→ « Session expirée ». Cette classification vit danserror-handler.ts/ au call site, jamais dansanalytics.ts(façade leaf, aucun import desrc/). - Les métadonnées globales sont des super properties, registered une fois dans la façade :
initAnalytics()appelleposthog.register()juste après l'init avecenvironment(import.meta.env.MODE, normalisédevelopment→dev) puis, en fire-and-forget, avecapp_version(version du bundle Capgo actif viaCapacitorUpdater.current(), fallback natif pour le bundle builtin) etnative_version. Le SDK les fusionne dans chaque event et les persiste — ne jamais les ré-ajouter auxpropsd'uncaptureEvent. Les infos device ($os,$os_version,$device_type,$browser) sont autocapturées par posthog-js (modèle exact non disponible sur iOS — pas de plugin@capacitor/device, choix assumé). - Aucune PII dans ce qui part :
identifyUser(id)est id-only (jamais nom/email — la résolution de l'UUID client reste dans Horizon) ; l'autocapture est masquée (mask_all_text/mask_all_element_attributes) et le hookbefore_sendscrub les emails de toute string sortante. Dans les props d'uncaptureEvent, ne mettre que des ids/compteurs/montants/slugs — jamais nom, email, téléphone ou texte saisi (cf.business-rules.md→ « Analytics pseudonymisée »). - Ne jamais retirer
disable_compression: truede l'init : le patch fetch/XHR de CapacitorHttp ne sait pas sérialiser les bodies Blob gzip de posthog-js — sur device, chaque event mourrait enserializationErrornatif (« error 0 ») sans jamais partir (cf. ADR0019, amendement juillet 2026). - En e2e, PostHog n'existe pas (token vidé dans
.env.e2e+ consentement'refused'auto-seedé par la fixture) : toute requêteposthog.comferait échouer le teardownmockBackend.
Styling
- Composer avec les utilités Bootstrap (
d-flex gap-3,p-2,text-center). - Pour le custom : SCSS dans un
<style scoped lang="scss">du composant, ou override global danssrc/assets/scss/. - Ne pas réimporter
bootstrapou ses variables dans un composant — c'est centralisé dansmain.scss. - Bootstrap Icons :
<i class="bi bi-arrow-left"></i>directement. - Drapeaux pays :
flag-icons(classesfi fi-ch, etc.).
Safe-area / insets : classes utilitaires .pt-safe* / .pb-safe* (cf. ADR 0013)
Pour dégager le contenu de l'encoche / du home indicator et des chrome fixes (bottom-nav, header/footer wizard, barre « Réserver »), ne pas construire de padding inline en JS. Les insets vivent dans des variables CSS sur :root (--safe-* posées par initSafeArea(), --ui-* posées par setAdditional{Top,Bottom}Inset()), consommées par les classes de src/assets/scss/_safe-area.scss :
| Classe | Padding | Ancien équivalent |
|---|---|---|
.pt-safe | --safe-top (+ extra) | topPadding('X') |
.pt-safe-ui | --safe-top + --ui-top (+ extra) | topPadding('X', true) |
.pb-safe-ui | --safe-bottom + --ui-bottom (+ extra) | bottomPadding('X') / (…, true) |
.pb-safe | --safe-bottom (+ extra) | bottomPadding('X', false) |
Mêmes insets, en margin-* (variables extra dédiées --mt-extra / --mb-extra, cumulables avec les classes de padding ci-dessus) — utile pour écarter un élément sans lui ajouter de zone cliquable/de fond supplémentaire (ce que ferait le padding) :
| Classe | Margin |
|---|---|
.mt-safe | --safe-top (+ extra) |
.mt-safe-ui | --safe-top + --ui-top (+ extra) |
.mb-safe-ui | --safe-bottom + --ui-bottom (+ extra) |
.mb-safe | --safe-bottom (+ extra) |
Le padding/margin « extra » propre à la page passe par une variable inline, pas par une fonction :
<div class="… pt-safe pb-safe-ui" style="--pt-extra: 144px; --pb-extra: 1.5em">
<div class="… mb-safe" style="--mb-extra: 1em">- Pas d'extra → juste la classe, pas de
style. Extra composé →--pb-extra: calc(87.2px + 1.5rem). - Positionnement (pas padding) : lire
var(--safe-left)/var(--safe-top)directement (cf.BackButtonFloating.vue). - Les classes sont
!important(pour battre les.p-*de Bootstrap) et_safe-area.scssest importé après Bootstrap dansmain.scss— ne pas changer cet ordre. - Un chrome qui se mesure lui-même appelle
setAdditional{Top,Bottom}Inset(height); ça met à jour--ui-*(et le refuiInsets, encore lu en JS paruseScrollPositionTrigger). --ui-bottomest un slot partagé (bottom-nav, footer wizard, barre « Réserver » d'un détail) : « dernier écrivain gagne ». Deux règles pour éviter les valeurs périmées :- La baseline appartient au guard
uidu router (0/63à chaque vraie nav de page). Un chrome ne réinitialise pas son inset à0suronUnmounted(il ne se démonte que lors d'une vraie nav, donc le guard repose déjà la baseline) — sinon son teardown, qui peut finir après la réactivation de la page de destination, écrase l'inset que celle-ci vient de poser.BottomNavigationetTravelBookingFootersuivent cette règle. - Une page
<keep-alive>qui possède un chrome bas (TravelDetails,BookingDetails) doit ré-affirmer sa hauteur dansonActivated: lewatch/watchEffectsur le template ref ne se redéclenche pas au retour (le ref ne change pas), donc l'inset resterait à la baseline0posée par le guard à l'entrée.
- La baseline appartient au guard
- L'élément mesuré (
refpassé àsetAdditional{Top,Bottom}Inset) ne doit JAMAIS porter sa propre classe.pb-safe/.pt-safe(ni contenir un enfant avec.mb-safe/.mt-safe) — sinon sonclientHeightinclut déjà l'inset device, et--ui-bottom/--ui-topse retrouve avec le safe-inset baked in. N'importe quelle page consommatrice via.pb-safe-ui/.mb-safe-ui(qui fait--safe-bottom + --ui-bottom) compte alors l'inset device deux fois → un gap visible entre le contenu scrollable et le chrome fixe. Toujours séparer en deux éléments : un wrapper non mesuré qui portefixed-bottom/fixed-top+.pb-safe/.pt-safe(le dégagement device), et un enfant mesuré (leref) qui ne porte que le style visuel du chrome (padding, bordure, layout) — cf.BottomNavigation.vue(refsur#bottomBar, pas sur le.fixed-bottom.pb-safeenglobant) etTravelBookingFooter.vue(ref="bar"sur.footer-wrap, pas sur.tb-footer.pb-safe). (Bug corrigé une première fois surClubBuchard.vue,TravelDetails.vue,BookingDetails.vue, qui mesuraient l'élément portant directement — ou dont un enfant portait — le safe-inset.)
Tests
- Unit (Vitest) :
src/utils/__tests__/*.test.ts(utilitaires purs : formatters, optimizer, age tiers, room keys),src/stores/__tests__/*.test.ts(stores Pinia),src/services/__tests__/(api-cache) etsrc/types/__tests__/(helpers runtime, ex.BidirectionalMap). - E2E (Playwright) :
e2e/specs/*.spec.ts, backend entièrement mocké (cf. ADR0015et « Écrire un test e2e » ci-dessous). Lancenpm run dev:e2e(:4173) en parallèle ; en CI, build--mode e2e+ preview. - Pas de test sur les composants Vue à ce jour — ne pas en imposer sans discussion.
- Couverture unitaire scopée au code unit-testable :
vitest.config.tslimite la couverture àsrc/**/*.tsmoins une liste d'exclusions explicites (bootmain.ts,router/, composables couplés DOM/historique/Capacitor, données pures, fichiers type-only, barrels de ré-export) — ce code-là est exercé par la suite e2e, pas par des unit tests qui ne feraient qu'asserter des mocks. Le pourcentage mesure donc « la couverture du code qu'on veut unit-tester ». Deux règles : ne pas retirer une exclusion sans écrire les tests correspondants, et ne jamais exclure du code à logique testable (ex.types/travelBooking.ts— la progression du wizard — reste inclus bien que rangé soustypes/).
Tester un store Pinia
On teste la logique réelle du store avec une vraie Pinia — pas @pinia/testing (qui stube les actions). Recette (un fichier = un store, mocks par fichier, cf. src/stores/__tests__/) :
setActivePinia(createPinia())dansbeforeEach→ store frais et isolé ; instancieruseXxxStore()après.vi.mock('@/services/api', …)pour la frontière réseau etvi.mock('@/services/error-handler', …)pour neutraliserhandleError/logError(toast Capacitor + bruit console) et asserter leur appel. Fournir dans la factory tous les exports importés par le graphe du store (ex.publicApietauthApisi l'auth est touchée).- Persistance = no-op gratuit :
pinia-plugin-persistedstaten'est branché que dansmain.ts. UncreatePinia()nu n'active donc pas la persistance ;superjsonPersistence()reste un simple objet de config → rien à mocker. - Stores couplés : la mutation de
bookingsdéclenche unwatchEffect(fetchTravelOccurrences)qui appelle le travel store. Tout test qui charge le bookings store (comments,customer,bookings) mocke@/stores/travels(fetchTravelOccurrence→undefined) pour rester hors-ligne. - Fixtures : petites factories minimales castées (
{ … } as Booking), modèle danssrc/utils/__tests__/booking-transformer.test.ts. - Lancer en one-shot avec
vitest run(le scripttest:unitest en watch).
Écrire un test e2e (Playwright, backend mocké — cf. ADR 0015)
Version condensée — la doc complète vit dans
.claude/docs/tests/(e2e-overview.mdarchitecture & lecture,e2e-world.mddataset & factories,e2e-writing-tests.mdrecettes détaillées,e2e-coverage.mdcouverture).
Toujours importer { test, expect } depuis e2e/support/fixtures (jamais @playwright/test directement) : la fixture mockBackend est auto — chaque test tourne derrière le mock, et son teardown échoue si une requête a atteint une origine factice sans handler (mock gap = échec loud, pas silencieux).
- Monde par défaut : la fixture
worldexpose le dataset happy-path (e2e/support/world.ts— trois voyages, un catalog, un customer). Lire ses entités pour les assertions ; muterworldou ré-enregistrer un endpoint surmockBackendAVANTpage.goto(les enregistrements tardifs gagnent). Après le premier chargement, le cache LRU in-page retient les GET publics : overrider ensuite exigepage.reload(). - Factories (
e2e/fixtures/) : builders typés à overrides partiels (makeTravel({ … })). Dates toujours relatives (daysFromNow(30)), jamais en dur. Les enums horizon-types s'importent depuise2e/fixtures/enums.tsuniquement (le dist du package n'est pas chargeable par Node — cf.DETTE_TECHNIQUE.md). - Session connectée :
await seedAuth()avantpage.goto— seedeauth(JSON brut, JWT avecexpfutur) etcustomer(sérialisé avec le vrai superjson pour que lesDaterevivent : le wizard préremplit le passager 1 depuis ce customer). - Assertions de contrat :
mockBackend.captured('POST /api/bookings')/await mockBackend.waitForCaptured(…)renvoient les requêtes enregistrées (postDataJSON,query) — c'est le canal pour vérifier les payloads envoyés au backend (ex.birthdateenyyyy-MM-dd). - Sélecteurs :
getByRole/texte français d'abord (la copy fait partie du contrat) ;data-testidquand le sélecteur se couplerait à la structure DOM Bootstrap. Testids kebab-case, posés dans les templates uniquement. - Structure : specs verbeux et granulaires —
test.describepar écran,test.steppar action utilisateur (pas detest.stepimbriqués, lint playwright). - Saferpay : mock d'
initialize+page.waitForEvent('popup')(le popup est routé par le context), puispage.goto('/booking/confirmation?bookingId=…')pour simuler le retour deep-link. - Lancer localement :
npm run test:e2e -- --project=mobile-chrome(rapide) ou sans filtre pour les deux devices ;--uipour le mode interactif ; anti-flake :--repeat-each=2.
Commits & branches
- Conventional Commits stricts (hook
commit-msg). Petits commits fréquents. - Branches feature à partir de
main. - Release :
npx @spektrum/release-util@latest release --env staging|prod(voirRELEASING.md+ mémoireproject_release_util). - Pas de Squash merge (le job CI
release-taggrep le titrechore(release): X.Y.Z).
Composables : conventions de nommage
- Préfixe
use*(useBCPassengers,useScrollRestoration,useSafeArea). - Pas d'effet de bord à l'import (uniquement à l'appel).
- Si un composable a besoin d'une dépendance contextuelle (router, store), l'injecter en argument ou via une factory (cf.
setBackButtonRouter).
Capacitor : appeler une API native
import { Toast } from '@capacitor/toast'
await Toast.show({ text: 'OK' })- Toujours
await(sinon comportements asynchrones imprévisibles). - Tester aussi en dev web — la plupart des plugins ont un fallback graceful, sauf ceux qui nécessitent réellement le natif (file viewer, deep linking, share). Pour ceux-là, fallback explicite ou skip.

