Skip to content

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éoccupationPropriétaire
Graphe d'étapes du wizard : quelles étapes existent, ordre, atteignabilité, skipcomposables/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 étapela vue (views/TravelBooking.vue) + composables/transition.ts, useOverlayRoute
Règles de validation métier réutilisablesutils/regle-validators.ts
Auth / refresh token, choix client HTTPservices/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

  1. Créer src/views/MaVue.vue avec defineOptions({ name: 'MaVue' }) (PascalCase, identique au composant — sert au keep-alive).
  2. Importer dans src/router/index.ts et l'ajouter au bon groupe (publicRoutes, authRoutes, profileRoutes, travelRoutes).
  3. Renseigner meta au minimum :
    • requiresAuth: true si protégée
    • bottomNavigation: true si on garde la nav en bas (ajoute un inset de 63px)
    • needsLoading: true si la vue charge des données async (overlay tant que setPageLoading(false) n'est pas appelé)
    • backButtonOverlay: { icon: 'back' | 'close', scrollspy?: true, href?: '/' } selon le besoin
    • keepAlive: 'MaVue' si on veut garder l'instance entre nav (PascalCase = nom du composant)
  4. Si la vue fait du fetch : appeler setPageLoading(false) dans le onMounted quand la data arrive.
  5. 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
Endpoint public (pas d'auth)publicApi.* dans services/api.ts via publicFetch ou cachedPublicFetch
Endpoint d'authauthApi.* (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-types quand le type existe (alias api_X_Y as Y recommandé).
  • 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 — publicFetch et apiClient throw déjà ApiError.
  • Passer le body en objet brut, jamais pré-JSON.stringify un body contenant des Date : la stringification fige les dates en ISO UTC (Date.toJSON) avant que la frontière (serializeWireDates dans publicFetch/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 », ADR 0016).

Ajouter un store Pinia

ts
// 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.ts si plusieurs stores l'importent (évite les cycles).
  • Ne jamais importer @/router dans un store qui peut être consommé par le router lui-même → utiliser une injection style setBackButtonRouter (voir main.ts).

Ajouter une étape au wizard de réservation

  1. Créer le composant dans components/travel-booking/TBStepXxx.vue.
  2. Étendre l'enum d'étape dans composables/booking-constructor/progression.ts et insérer la position dans la séquence. Donner au STEP_NAMES une valeur kebab-case URL-friendly (elle sert de segment :stepName).
  3. Si l'étape est conditionnelle (peut être skippée) : ajouter la logique de skip dans progression.ts et veiller à ce que rawActiveStepIndex (brut) reste cohérent avec activeStepIndex (visible).
  4. Le miroir step↔URL de TravelBooking.vue (watchers param→step / step→param) mappe l'étape sur le segment de chemin :stepName (/reservation/:slug/:stepName) — push en avant, replace en latéral (voir ADR 0010 + 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 du stepTransition local (delta d'index), pas de setTransition (supprimé).
  5. Hooker la validation sur les computed du bookingConstructor (*Valid refs) — 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.

ts
import { useOverlayRoute } from '@/composables/useOverlayRoute.ts'
const overlay = useOverlayRoute('comments') // nom = valeur du query ?overlay=
  • overlay.isOpen.value → le v-if de 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 prop toggleFunction/close).
  • Le back natif / navigateur ferme l'overlay tout seul (le param disparaît → isOpen bascule). Imbrication OK (liste → formulaire) : chaque open() 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 un isOpen en égalité stricte. Pour un nom indexé, inliner open()/close() dans le host (router.push du nom calculé + markProgrammaticNav() + router.back(), en miroir de useOverlayRoute.close()). Si un second overlay paramétré apparaît, extraire un useOverlayRouteFamily(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 de overlay. Ne pas garder le parent par un isOpen exact (il se démonterait — et l'enfant avec) : garder le v-if sur un ref local de visibilité, et fermer via un watch(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 flag committed côté host et n'effectuer l'annulation dans le watch que lorsqu'il est false. (Détail : docs/back-navigation.md → « Parameterized overlay + nested child ».)

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.

ts
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-modal via le router injecté (setModalRouter dans main.ts, même pattern que setBackButtonRouter) — le back natif ferme le modal.
  • Un seul modal système à la fois : un showModal pendant qu'un est ouvert le remplace (l'ancienne promesse résout dismissed, l'entrée d'historique est réutilisée) — dédoublonnage naturel des échecs concurrents.
  • showLoginModal(message?) rend components/LoginForm.vue (extrait de Login.vue) et résout true sur reconnexion. Câblé dans api-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 useOverlayRoute ci-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.

ts
// 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 useRegle depuis @/utils/regle-validators (jamais @regle/core), ou useScopedRegleItem / useCollectScopeRecord depuis composables/regle-scoped-config.ts pour 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-input avec constants/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 sans withMessage. Les xxxDecorated de regle-validators.ts deviennent 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 rules de defineRegleConfig, rien d'autre.
  • Typage : le raccourci { required: true } n'est plus accepté par ce useRegle — utiliser la règle required.

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 $dirty et faire apparaître les erreurs via ValidatedField (dont immediateError affiche $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

ts
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.log brut en prod — préfixer ([feat], [router], etc.) pour faciliter le filtrage adb logcat / Xcode console.
  • error-handler.ts expose showToast (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-js ailleurs que dans analytics.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.tsaccept() boote + identifie, refuse() coupe la capture en session via posthog.opt_out_capturing()) et au bloc cold-start de main.ts (gated sur isAccepted). Toute UI de consentement (modal de démarrage, radios « Confidentialité » d'Autres) passe par accept()/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 de main.ts, router.onError), qui appellent captureError(err, { source, … })posthog.captureException (cf. ADR 0020) — n'appelle captureError à 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 par captureException, 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.ts login()) — cf. business-rules.md → « Session expirée ». Cette classification vit dans error-handler.ts / au call site, jamais dans analytics.ts (façade leaf, aucun import de src/).
  • Les métadonnées globales sont des super properties, registered une fois dans la façade : initAnalytics() appelle posthog.register() juste après l'init avec environment (import.meta.env.MODE, normalisé developmentdev) puis, en fire-and-forget, avec app_version (version du bundle Capgo actif via CapacitorUpdater.current(), fallback natif pour le bundle builtin) et native_version. Le SDK les fusionne dans chaque event et les persiste — ne jamais les ré-ajouter aux props d'un captureEvent. 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 hook before_send scrub les emails de toute string sortante. Dans les props d'un captureEvent, 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: true de 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 en serializationError natif (« error 0 ») sans jamais partir (cf. ADR 0019, amendement juillet 2026).
  • En e2e, PostHog n'existe pas (token vidé dans .env.e2e + consentement 'refused' auto-seedé par la fixture) : toute requête posthog.com ferait échouer le teardown mockBackend.

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 dans src/assets/scss/.
  • Ne pas réimporter bootstrap ou ses variables dans un composant — c'est centralisé dans main.scss.
  • Bootstrap Icons : <i class="bi bi-arrow-left"></i> directement.
  • Drapeaux pays : flag-icons (classes fi 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 :

ClassePaddingAncien é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) :

ClasseMargin
.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 :

vue
<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.scss est importé après Bootstrap dans main.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 ref uiInsets, encore lu en JS par useScrollPositionTrigger).
  • --ui-bottom est 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 ui du router (0 / 63 à chaque vraie nav de page). Un chrome ne réinitialise pas son inset à 0 sur onUnmounted (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. BottomNavigation et TravelBookingFooter suivent cette règle.
    • Une page <keep-alive> qui possède un chrome bas (TravelDetails, BookingDetails) doit ré-affirmer sa hauteur dans onActivated : le watch/watchEffect sur le template ref ne se redéclenche pas au retour (le ref ne change pas), donc l'inset resterait à la baseline 0 posée par le guard à l'entrée.
  • L'élément mesuré (ref passé à 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 son clientHeight inclut déjà l'inset device, et --ui-bottom/--ui-top se 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 porte fixed-bottom/fixed-top + .pb-safe/.pt-safe (le dégagement device), et un enfant mesuré (le ref) qui ne porte que le style visuel du chrome (padding, bordure, layout) — cf. BottomNavigation.vue (ref sur #bottomBar, pas sur le .fixed-bottom.pb-safe englobant) et TravelBookingFooter.vue (ref="bar" sur .footer-wrap, pas sur .tb-footer.pb-safe). (Bug corrigé une première fois sur ClubBuchard.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) et src/types/__tests__/ (helpers runtime, ex. BidirectionalMap).
  • E2E (Playwright) : e2e/specs/*.spec.ts, backend entièrement mocké (cf. ADR 0015 et « Écrire un test e2e » ci-dessous). Lance npm 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.ts limite la couverture à src/**/*.ts moins une liste d'exclusions explicites (boot main.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é sous types/).

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()) dans beforeEach → store frais et isolé ; instancier useXxxStore() après.
  • vi.mock('@/services/api', …) pour la frontière réseau et vi.mock('@/services/error-handler', …) pour neutraliser handleError/logError (toast Capacitor + bruit console) et asserter leur appel. Fournir dans la factory tous les exports importés par le graphe du store (ex. publicApi et authApi si l'auth est touchée).
  • Persistance = no-op gratuit : pinia-plugin-persistedstate n'est branché que dans main.ts. Un createPinia() nu n'active donc pas la persistance ; superjsonPersistence() reste un simple objet de config → rien à mocker.
  • Stores couplés : la mutation de bookings déclenche un watchEffect(fetchTravelOccurrences) qui appelle le travel store. Tout test qui charge le bookings store (comments, customer, bookings) mocke @/stores/travels (fetchTravelOccurrenceundefined) pour rester hors-ligne.
  • Fixtures : petites factories minimales castées ({ … } as Booking), modèle dans src/utils/__tests__/booking-transformer.test.ts.
  • Lancer en one-shot avec vitest run (le script test:unit est 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.md architecture & lecture, e2e-world.md dataset & factories, e2e-writing-tests.md recettes détaillées, e2e-coverage.md couverture).

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 world expose le dataset happy-path (e2e/support/world.ts — trois voyages, un catalog, un customer). Lire ses entités pour les assertions ; muter world ou ré-enregistrer un endpoint sur mockBackend AVANT page.goto (les enregistrements tardifs gagnent). Après le premier chargement, le cache LRU in-page retient les GET publics : overrider ensuite exige page.reload().
  • Factories (e2e/fixtures/) : builders typés à overrides partiels (makeTravel({ … })). Dates toujours relatives (daysFromNow(30)), jamais en dur. Les enums horizon-types s'importent depuis e2e/fixtures/enums.ts uniquement (le dist du package n'est pas chargeable par Node — cf. DETTE_TECHNIQUE.md).
  • Session connectée : await seedAuth() avant page.goto — seede auth (JSON brut, JWT avec exp futur) et customer (sérialisé avec le vrai superjson pour que les Date revivent : 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. birthdate en yyyy-MM-dd).
  • Sélecteurs : getByRole/texte français d'abord (la copy fait partie du contrat) ; data-testid quand le sélecteur se couplerait à la structure DOM Bootstrap. Testids kebab-case, posés dans les templates uniquement.
  • Structure : specs verbeux et granulaires — test.describe par écran, test.step par action utilisateur (pas de test.step imbriqués, lint playwright).
  • Saferpay : mock d'initialize + page.waitForEvent('popup') (le popup est routé par le context), puis page.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 ; --ui pour 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 (voir RELEASING.md + mémoire project_release_util).
  • Pas de Squash merge (le job CI release-tag grep le titre chore(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

ts
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.

Contributors

No contributors

Changelog

No recent changes