# 001 — HistoMétéo MVP — Spécification Technique v8

> **Itération** : v8 — intègre le feedback Reviewer (`feedback-to-architect-001-v7.md`) + nouvelle demande fonctionnelle « URL Rewriting (URLs propres) ».

---

## 0) Contract

- **Source of truth** : ce document (`001-histometeo-mvp.tech.v8.md`)
- **Functional integrity** : aucun critère d'acceptation de `001-histometeo-mvp.md` ne peut être modifié, ignoré ou réinterprété.
- **Scope** : fichiers et dossiers autorisés à créer/modifier :
  - `src/main.py` — nouvelles routes SEO, route de résolution slug, redirections 301
  - `src/commune_service.py` — ajout résolution slug → commune canonique, génération de slugs
  - `src/config.py` — constante regex slug, pattern routes
  - `public/app.js` — changement de `updateURL()` et `readValidURLParams()` / `loadFromURL()` pour les URLs propres
  - `public/index.html` — ajout balise `<link rel="canonical">` dynamique
  - `tests/test_api.py` — tests des nouvelles routes SEO et redirections
  - `tests/test_commune_service.py` — tests résolution slug
- **Forbidden changes** :
  - `src/weather_service.py` — aucune modification (le service météo continue à recevoir lat/lon, inchangé)
  - `src/cache.py` — aucune modification
  - `docs/` — aucune modification des specs existantes
  - `.github/` — aucune modification du workflow agent
  - `Dockerfile`, `pyproject.toml` — pas de modification
  - `public/style.css` — pas de modification cette itération
  - `public/assets/` — pas de modification
- **Invariants** (tous hérités et préservés) :
  - INV-1 : Aucune donnée utilisateur stockée (ni serveur, ni client)
  - INV-2 : Heures en fuseau `Europe/Paris`
  - INV-3 : Période maximale 31 jours
  - INV-4 : Aucune clé API
  - INV-5 : Interface en français avec accents (`HistoMétéo`)
  - INV-6 : ~~Page unique, URL reflète l'état via query parameters~~ → **Remplacé par INV-6b** : Page unique, URL reflète l'état via des routes propres (path segments). Les query parameters techniques (lat, lon, dept) ne sont plus exposés dans l'URL publique.
  - INV-7 : Aucune injection HTML — `textContent`, `createElement`, `replaceChildren()` uniquement, jamais `innerHTML`
  - INV-8 : Le flux recherche simple fonctionne indépendamment du mode comparaison
- **Done when** :
  - Les 11 critères d'acceptation originaux (AC1–AC11) restent vérifiables
  - Toutes les fonctionnalités v2–v7 restent opérationnelles
  - L'URL `/meteo/{slug}/{start}/{end}` affiche les résultats météo pour la commune résolue
  - L'URL `/comparaison/{slug1}/vs/{slug2}/{start}/{end}` affiche les résultats de comparaison
  - Les anciennes URLs avec query parameters redirigent en 301 vers les URLs propres
  - La balise `<link rel="canonical">` est présente et reflète l'URL propre courante
  - Les slugs sont générés correctement (minuscules, sans accents, tirets)
  - Les communes homonymes sont désambiguïsées par le code département
  - **Tous** les tests backend passent (anciens + nouveaux)
  - L'application se lance via `uvicorn src.main:app` ou `docker compose up`

---

## 1) Objectif technique

Remplacer le système d'URLs basé sur des query parameters techniques (`?commune=...&lat=...&lon=...`) par des URLs propres basées sur des segments de chemin (`/meteo/{slug}/{start}/{end}`), avec résolution slug → commune côté backend. Maintenir la rétrocompatibilité via des redirections 301 depuis les anciennes URLs.

---

## 2) Analyse du brief

### Besoins principaux

| Besoin                                                     | Source                | Complexité | Impact                   |
| ---------------------------------------------------------- | --------------------- | ---------- | ------------------------ |
| Routes SEO `/meteo/{slug}/{start}/{end}`                   | Demande fonctionnelle | Moyenne    | SEO, lisibilité, partage |
| Routes SEO `/comparaison/{slug1}/vs/{slug2}/{start}/{end}` | Demande fonctionnelle | Moyenne    | SEO comparaison          |
| Résolution slug → commune (backend)                        | Demande fonctionnelle | Moyenne    | Architecture clé         |
| Redirections 301 anciennes URLs                            | Demande fonctionnelle | Faible     | Rétrocompatibilité       |
| URL canonique `<link rel="canonical">`                     | Demande fonctionnelle | Faible     | SEO                      |
| Mise à jour frontend (navigation + restauration)           | Demande fonctionnelle | Moyenne    | Intégration              |

### Contraintes

- L'API geo.api.gouv.fr ne supporte pas la recherche par slug : la résolution doit passer par une recherche `nom` puis correspondance.
- Certaines communes françaises partagent le même nom (ex : « Sainte-Marie » dans 6 départements). La désambiguïsation est obligatoire.
- Le backend ne possède pas de base locale de communes — toute résolution passe par l'API geo.
- Le frontend doit générer les slugs de manière identique au backend pour construire les URLs sans aller-retour.

### Risques

| #   | Risque                                                 | Mitigation                                                                                                                                                        |
| --- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | Slug ambigu (communes homonymes)                       | Suffixe `-{dept}` ajouté systématiquement quand l'API retourne des homonymes. Format par défaut : `{nom-slug}` simple ; format désambiguïsé : `{nom-slug}-{dept}` |
| 2   | Résolution slug lente (appel API geo à chaque requête) | Cache dédié slug → commune avec TTL 24h (réutilise le `TTLCache` existant)                                                                                        |
| 3   | Régression de la navigation (popstate, refresh)        | Tests manuels de non-régression. Le fallback query params → redirect 301 assure la transition                                                                     |

---

## 3) Design minimal proposé

### 3.1 Génération des slugs

**Algorithme** (identique backend Python + frontend JS) :

1. Convertir en minuscules
2. Supprimer les accents (NFD → strip combining marks)
3. Remplacer les apostrophes et espaces par des tirets
4. Supprimer tout caractère non `[a-z0-9-]`
5. Réduire les tirets multiples en un seul
6. Supprimer les tirets en début/fin

**Exemples** :

| Entrée          | Slug              |
| --------------- | ----------------- |
| Digne-les-Bains | `digne-les-bains` |
| L'Haÿ-les-Roses | `lhay-les-roses`  |
| Aix-en-Provence | `aix-en-provence` |
| Saint-Étienne   | `saint-etienne`   |

### 3.2 Stratégie de désambiguïsation des homonymes

Les slugs sont **toujours simples par défaut** (sans département). Le suffixe département n'est nécessaire que pour la résolution backend quand le slug seul est ambigu.

**Règle** : le frontend construit toujours l'URL avec le slug **suffixé du département** : `{slug}-{dept}`. Cela évite toute ambiguïté et rend la résolution backend déterministe.

**Exemples d'URLs** :

| Commune         | Département | URL                                               |
| --------------- | ----------- | ------------------------------------------------- |
| Digne-les-Bains | 04          | `/meteo/digne-les-bains-04/2026-03-05/2026-03-11` |
| Gap             | 05          | `/meteo/gap-05/2026-03-05/2026-03-11`             |
| Sainte-Marie    | 972         | `/meteo/sainte-marie-972/2026-03-05/2026-03-11`   |
| Paris           | 75          | `/meteo/paris-75/2026-03-05/2026-03-11`           |

> **Justification** : toujours inclure le département est plus simple à implémenter, plus robuste (pas besoin de savoir si un nom est unique ou pas), et reste lisible. Le suffixe `-04`, `-75` est court et ne nuit pas à la lisibilité. Cela élimine toute ambiguïté sans complexité conditionnelle.

### 3.3 Format des URLs

**Recherche simple** :

```
/meteo/{commune-slug}-{dept}/{start}/{end}
```

**Comparaison** :

```
/comparaison/{commune1-slug}-{dept1}/vs/{commune2-slug}-{dept2}/{start}/{end}
```

**Contraintes sur les segments** :

- `{commune-slug}-{dept}` : slug alphanumérique + tirets, terminé par `-` + code département (2 ou 3 chiffres)
- `{start}`, `{end}` : format ISO `YYYY-MM-DD`
- `/vs/` : séparateur fixe pour la comparaison

### 3.4 Nouvelles routes backend (FastAPI)

#### Route résolution slug → commune

```
GET /api/resolve/{slug_with_dept}
```

**Logique** :

1. Extraire le code département du slug : derniers caractères après le dernier tiret, si c'est un nombre de 2-3 chiffres → `dept`. Le reste → `name_slug`.
2. Appeler `commune_service.search_communes(name)` avec `name` = `name_slug` (tirets → espaces) pour obtenir les candidats.
3. Parmi les résultats, trouver la commune dont le `codeDepartement == dept` **et** dont le slug généré du `nom` correspond à `name_slug`.
4. Retourner `{ nom, departement, latitude, longitude, slug }`.
5. Si aucune correspondance → retourner 404.

**Cache** : le résultat est mis en cache (slug → commune) avec TTL 24h.

#### Routes pages SEO (servent le HTML)

```
GET /meteo/{slug_dept}/{start}/{end}
GET /comparaison/{slug1_dept}/vs/{slug2_dept}/{start}/{end}
```

**Logique** : ces routes servent `index.html` (le même fichier statique). Le frontend, au chargement, lit le `pathname` pour déterminer le contexte, résout les communes via `/api/resolve/{slug}`, puis lance la recherche météo.

#### Route de redirection 301 (anciennes URLs)

```
GET /?commune=...&dept=...&lat=...&lon=...&start=...&end=...
```

**Logique** : si la route `/` reçoit des query parameters `commune` + `start` + `end`, le backend :

1. Génère le slug à partir de `commune` et `dept`
2. Construit l'URL propre
3. Retourne une `RedirectResponse(status_code=301)` vers l'URL propre

Si les query parameters sont absents ou incomplets, la route `/` sert `index.html` normalement (page d'accueil).

### 3.5 Modifications frontend (`app.js`)

#### Nouvelle fonction `generateSlug(name)`

Algorithme identique à la version Python (section 3.1). Fonction pure, sans appel réseau.

#### Nouvelle fonction `buildSeoUrl(commune, commune2, start, end)`

Construit l'URL propre à partir des données communes sélectionnées :

```js
function buildSeoUrl(commune, commune2, start, end) {
  const slug1 = generateSlug(commune.nom) + "-" + commune.departement;
  if (commune2) {
    const slug2 = generateSlug(commune2.nom) + "-" + commune2.departement;
    return `/comparaison/${slug1}/vs/${slug2}/${start}/${end}`;
  }
  return `/meteo/${slug1}/${start}/${end}`;
}
```

#### Modification de `updateURL()`

Remplacer la logique actuelle (query parameters) par :

```js
function updateURL(commune, commune2, start, end) {
  const path = buildSeoUrl(commune, commune2, start, end);
  history.replaceState(null, "", path);
  updateCanonicalLink(path);
}
```

**Signature simplifiée** : plus besoin de `dept`, `lat`, `lon` en arguments car le slug est calculé à partir de `commune.nom` et `commune.departement`, et les coordonnées ne sont plus dans l'URL.

#### Modification de `loadFromURL()`

La fonction doit gérer deux cas :

1. **URL propre** (pathname = `/meteo/...` ou `/comparaison/...`) : parser le pathname, extraire les slugs et dates, appeler `/api/resolve/{slug}` pour obtenir les données communes, puis lancer `performSearch()`.
2. **Anciennes URLs avec query params** : ce cas est géré côté backend par la redirection 301. Si le frontend reçoit encore des query params (cas edge : navigation JS sans rechargement), convertir en URL propre via `history.replaceState()`.

**Parsing du pathname** :

```
/meteo/{slug_dept}/{start}/{end}
  → segments[1] = "meteo", segments[2] = slug_dept, segments[3] = start, segments[4] = end

/comparaison/{slug1_dept}/vs/{slug2_dept}/{start}/{end}
  → segments[1] = "comparaison", segments[2] = slug1_dept, segments[4] = slug2_dept, segments[5] = start, segments[6] = end
```

#### Modification de `performSearch()`

L'appel `updateURL(...)` est modifié pour passer les objets communes :

```js
updateURL(
  selectedCommune,
  comparisonMode ? selectedCommune2 : null,
  dateStart.value,
  dateEnd.value,
);
```

#### Ajout de `updateCanonicalLink(path)`

Met à jour (ou crée) la balise `<link rel="canonical">` dans le `<head>` :

```js
function updateCanonicalLink(path) {
  let link = document.querySelector('link[rel="canonical"]');
  if (!link) {
    link = document.createElement("link");
    link.setAttribute("rel", "canonical");
    document.head.appendChild(link);
  }
  link.setAttribute("href", window.location.origin + path);
}
```

### 3.6 Résolution slug côté backend (`commune_service.py`)

**Nouvelle méthode** : `CommuneService.resolve_slug(slug_with_dept: str) -> dict | None`

**Logique** :

1. Parser le slug : regex `^(.+)-(\d{2,3})$` → `name_slug`, `dept_code`
2. Convertir `name_slug` : remplacer tirets par espaces, capitaliser (approximation pour la recherche geo API)
3. Appeler `self.search_communes(name_approximation)`
4. Parmi les résultats, trouver celui où `codeDepartement == dept_code` **et** `generate_slug(nom) == name_slug`
5. Si trouvé → retourner la commune normalisée + le slug
6. Si aucun → retourner `None`

**Cache dédié** : `self.slug_cache: TTLCache` — clé = slug complet, valeur = commune normalisée. TTL = 24h.

**Nouvelle fonction statique** : `CommuneService.generate_slug(name: str) -> str`

Algorithme Python :

```python
import unicodedata
import re

@staticmethod
def generate_slug(name: str) -> str:
    s = name.lower()
    s = unicodedata.normalize("NFD", s)
    s = re.sub(r"[\u0300-\u036f]", "", s)      # strip accents
    s = re.sub(r"[''ʼ\s]+", "-", s)             # apostrophes/spaces → dash
    s = re.sub(r"[^a-z0-9-]", "", s)            # keep only alphanum + dash
    s = re.sub(r"-{2,}", "-", s)                # collapse dashes
    s = s.strip("-")
    return s
```

### 3.7 Ordre de priorité des routes FastAPI

Les routes FastAPI sont évaluées dans l'ordre de déclaration. L'ordre **doit** être :

1. `GET /api/communes` — API existante
2. `GET /api/weather` — API existante
3. `GET /api/resolve/{slug}` — nouvelle API
4. `GET /meteo/{slug}/{start}/{end}` — route SEO simple
5. `GET /comparaison/{slug1}/vs/{slug2}/{start}/{end}` — route SEO comparaison
6. `GET /` — page d'accueil (avec logique de redirection 301 si query params)
7. `app.mount("/", StaticFiles(...))` — fichiers statiques (CSS, JS, assets)

**Important** : les routes SEO (4, 5) **ne doivent pas** servir les fichiers statiques. Elles doivent retourner le contenu de `index.html` via `FileResponse`. Le mount `StaticFiles` reste en dernier pour servir les assets.

### 3.8 Gestion du `<title>` et des meta tags

La balise `<title>` et la meta description sont déjà mises à jour dynamiquement via `updateMetaDescription()`. Ce comportement reste inchangé. Le seul ajout est la balise `<link rel="canonical">` gérée par `updateCanonicalLink()`.

---

## 4) Plan d'implémentation

### Étape 1 — Backend : Fonction `generate_slug` + méthode `resolve_slug`

**Fichiers** : `src/commune_service.py`, `src/config.py`

- Ajouter `generate_slug(name: str) -> str` comme méthode statique de `CommuneService`
- Ajouter `resolve_slug(slug_with_dept: str) -> dict | None` comme méthode async de `CommuneService`
- Ajouter un `slug_cache: TTLCache` dans `__init__` avec le même TTL que `COMMUNE_CACHE_TTL_SECONDS`

**Testable** : appeler `generate_slug("Digne-les-Bains")` retourne `"digne-les-bains"`. Appeler `resolve_slug("digne-les-bains-04")` retourne `{"nom": "Digne-les-Bains", "departement": "04", "latitude": ..., "longitude": ...}`.

---

### Étape 2 — Backend : Nouvelles routes FastAPI

**Fichier** : `src/main.py`

- Ajouter `GET /api/resolve/{slug}` — résolution slug → commune (JSON)
- Ajouter `GET /meteo/{slug}/{start}/{end}` — sert `index.html`
- Ajouter `GET /comparaison/{slug1}/vs/{slug2}/{start}/{end}` — sert `index.html`
- Modifier `GET /` — si query params `commune` + `start` + `end` présents → rediriger 301 vers URL propre. Sinon → servir `index.html`.
- **Respecter l'ordre de déclaration** des routes (section 3.7)

**Testable** : `GET /meteo/paris-75/2024-01-15/2024-01-15` retourne le HTML de `index.html`. `GET /?commune=Paris&dept=75&lat=48.86&lon=2.35&start=2024-01-15&end=2024-01-15` retourne 301 vers `/meteo/paris-75/2024-01-15/2024-01-15`.

---

### Étape 3 — Frontend : Fonction `generateSlug` + `buildSeoUrl`

**Fichier** : `public/app.js`

- Ajouter `generateSlug(name)` — même algorithme que Python
- Ajouter `buildSeoUrl(commune, commune2, start, end)` — construit l'URL propre
- Ajouter `updateCanonicalLink(path)` — gère la balise canonical

**Testable** : `generateSlug("L'Haÿ-les-Roses")` retourne `"lhay-les-roses"`.

---

### Étape 4 — Frontend : Refactoring `updateURL` + `performSearch`

**Fichier** : `public/app.js`

- Modifier `updateURL()` : remplacer la logique query params par appel à `buildSeoUrl()` + `history.replaceState()`
- Modifier les appels à `updateURL()` dans `performSearch()` pour passer les objets communes complets
- Appeler `updateCanonicalLink()` dans `updateURL()`

**Testable** : après une recherche, l'URL du navigateur est `/meteo/paris-75/2024-01-15/2024-01-15` (pas de query params).

---

### Étape 5 — Frontend : Refactoring `loadFromURL`

**Fichier** : `public/app.js`

- Modifier `loadFromURL()` pour parser le pathname (`/meteo/...` ou `/comparaison/...`)
- Si pathname reconnu → extraire slugs + dates → appeler `/api/resolve/{slug}` pour chaque slug → remplir les champs du formulaire → lancer `performSearch()`
- Si pathname = `/` avec query params → ce cas est géré par le backend (redirect 301 avant que le JS ne s'exécute), donc aucune action JS nécessaire pour ce cas.
- Si pathname = `/` sans params → page d'accueil vierge (comportement existant).

**Testable** : accéder directement à `/meteo/gap-05/2024-01-15/2024-01-15` charge la page, résout « Gap (05) », affiche les résultats.

---

### Étape 6 — HTML : Balise canonical statique

**Fichier** : `public/index.html`

- Ajouter `<link rel="canonical" href="/">` dans le `<head>` (valeur par défaut, sera mis à jour dynamiquement par JS).

**Testable** : inspecter le `<head>` → la balise `<link rel="canonical">` est présente.

---

### Étape 7 — Tests backend

**Fichiers** : `tests/test_api.py`, `tests/test_commune_service.py`

Nouveaux tests :

| Test                              | Fichier                   | Assertion                                                                                                                           |
| --------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `test_generate_slug_basic`        | `test_commune_service.py` | `generate_slug("Digne-les-Bains") == "digne-les-bains"`                                                                             |
| `test_generate_slug_accents`      | `test_commune_service.py` | `generate_slug("Saint-Étienne") == "saint-etienne"`                                                                                 |
| `test_generate_slug_apostrophe`   | `test_commune_service.py` | `generate_slug("L'Haÿ-les-Roses") == "lhay-les-roses"`                                                                              |
| `test_resolve_slug_ok`            | `test_commune_service.py` | `resolve_slug("paris-75")` retourne commune Paris dept 75                                                                           |
| `test_resolve_slug_not_found`     | `test_commune_service.py` | `resolve_slug("xyz-99")` retourne `None`                                                                                            |
| `test_resolve_api_route`          | `test_api.py`             | `GET /api/resolve/paris-75` → 200, `nom == "Paris"`                                                                                 |
| `test_resolve_api_route_404`      | `test_api.py`             | `GET /api/resolve/xyz-99` → 404                                                                                                     |
| `test_seo_route_meteo`            | `test_api.py`             | `GET /meteo/paris-75/2024-01-15/2024-01-15` → 200, content-type HTML                                                                |
| `test_seo_route_comparaison`      | `test_api.py`             | `GET /comparaison/paris-75/vs/lyon-69/2024-01-15/2024-01-15` → 200, HTML                                                            |
| `test_legacy_redirect_simple`     | `test_api.py`             | `GET /?commune=Paris&dept=75&lat=48.86&lon=2.35&start=2024-01-15&end=2024-01-15` → 301 vers `/meteo/paris-75/2024-01-15/2024-01-15` |
| `test_legacy_redirect_comparison` | `test_api.py`             | `GET /?commune=Paris&dept=75&...&commune2=Lyon&dept2=69&...` → 301 vers `/comparaison/paris-75/vs/lyon-69/...`                      |
| `test_homepage_no_redirect`       | `test_api.py`             | `GET /` sans query params → 200, HTML                                                                                               |

**Testable** : `pytest tests/` — tous les tests passent.

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Ordre des routes FastAPI** : les routes sont matchées dans l'ordre de déclaration. Si `StaticFiles` est monté avant les routes SEO, les routes `/meteo/...` seront interceptées par le mount statique (404). Le mount `StaticFiles` doit rester **en dernier**.

2. **`FileResponse` vs `HTMLResponse`** : les routes SEO doivent servir le fichier `index.html` physique via `FileResponse(path_to_index_html)`. Ne pas utiliser `HTMLResponse` avec le contenu en dur.

3. **Cohérence slug Python/JS** : l'algorithme de slug doit être **strictement identique** dans les deux langages. Tester avec les mêmes cas (accents, apostrophes, tirets multiples). Si un écart existe, l'URL construite par le frontend ne correspondra pas à la route backend.

4. **Regex de parsing du slug** : le pattern `^(.+)-(\d{2,3})$` est greedy sur le premier groupe. Pour un slug comme `saint-denis-93`, il capturera correctement `saint-denis` + `93`. Mais pour `saint-denis-de-pile-33`, il capturera `saint-denis-de-pile` + `33`. Ceci est le comportement souhaité.

5. **Redirections 301 et boucles** : la route `/` avec query params redirige vers `/meteo/...`. Vérifier que `/meteo/...` ne redirige pas à nouveau vers `/`. Pas de boucle possible car les routes SEO servent directement le HTML.

6. **`history.replaceState` vs `history.pushState`** : continuer à utiliser `replaceState` dans `updateURL()` (comportement existant). Le changement d'URL initial lors de la soumission du formulaire doit utiliser `pushState` pour permettre le retour arrière navigateur. Ce point est une amélioration optionnelle — pour la v8, `replaceState` suffit et maintient la cohérence avec le comportement existant.

### Zones de dérive

- **Ne pas créer de base locale de communes**. La résolution passe toujours par l'API geo.api.gouv.fr (cachée en mémoire). Une base locale est hors scope V1.
- **Ne pas implémenter de redirections sans département** (ex : `/meteo/paris/...` → `/meteo/paris-75/...`). C'est un cas V2. En V1, toutes les URLs contiennent le département.
- **Ne pas ajouter de routes « par jour unique »** (`/meteo/paris-75/2024-01-15`) — c'est explicitement V2 dans la demande fonctionnelle.
- **Ne pas ajouter de pages éditoriales SEO** (contenu statique par commune) — V2.
- **Ne pas modifier `weather_service.py`** — il continue à recevoir lat/lon comme avant.

### Décisions explicitement interdites

- Ajouter une dépendance externe pour la génération de slugs (ex : `python-slugify`). L'algorithme est suffisamment simple pour être implémenté en ~6 lignes.
- Stocker les coordonnées dans l'URL (même en fragment `#`).
- Exposer le code INSEE dans l'URL publique.
- Modifier le format de réponse de `/api/communes` ou `/api/weather`.

---

## 6) Stratégie de tests

### Tests unitaires — `generate_slug`

| Entrée              | Attendu             |
| ------------------- | ------------------- |
| `"Paris"`           | `"paris"`           |
| `"Digne-les-Bains"` | `"digne-les-bains"` |
| `"L'Haÿ-les-Roses"` | `"lhay-les-roses"`  |
| `"Saint-Étienne"`   | `"saint-etienne"`   |
| `"Aix-en-Provence"` | `"aix-en-provence"` |
| `"Noisy-le-Grand"`  | `"noisy-le-grand"`  |
| `""` (vide)         | `""`                |

### Tests unitaires — `resolve_slug`

| Entrée                               | Attendu                                                              |
| ------------------------------------ | -------------------------------------------------------------------- |
| `"paris-75"`                         | `{ nom: "Paris", departement: "75", latitude: ..., longitude: ... }` |
| `"digne-les-bains-04"`               | `{ nom: "Digne-les-Bains", departement: "04", ... }`                 |
| `"xyz-99"`                           | `None` (pas de commune trouvée)                                      |
| `"invalidslug"` (pas de département) | `None`                                                               |

### Tests intégration — Routes API

| Route                                                                            | Attendu                                       |
| -------------------------------------------------------------------------------- | --------------------------------------------- |
| `GET /api/resolve/paris-75`                                                      | 200 + JSON commune                            |
| `GET /api/resolve/xyz-99`                                                        | 404                                           |
| `GET /meteo/paris-75/2024-01-15/2024-01-15`                                      | 200 + HTML                                    |
| `GET /comparaison/paris-75/vs/lyon-69/2024-01-15/2024-01-15`                     | 200 + HTML                                    |
| `GET /?commune=Paris&dept=75&lat=48.86&lon=2.35&start=2024-01-15&end=2024-01-15` | 301 → `/meteo/paris-75/2024-01-15/2024-01-15` |
| `GET /` (sans params)                                                            | 200 + HTML                                    |

### Tests manuels frontend

| #   | Scénario                                   | Résultat attendu                                                  |
| --- | ------------------------------------------ | ----------------------------------------------------------------- |
| T1  | Recherche simple Paris, 15-16 jan 2024     | URL = `/meteo/paris-75/2024-01-15/2024-01-16`, résultats affichés |
| T2  | Comparaison Paris vs Lyon                  | URL = `/comparaison/paris-75/vs/lyon-69/2024-01-15/2024-01-16`    |
| T3  | Copier/coller URL T1 dans un nouvel onglet | La page charge, résout Paris, affiche les résultats               |
| T4  | Copier/coller URL T2 dans un nouvel onglet | La page charge, résout Paris + Lyon, affiche la comparaison       |
| T5  | Ancienne URL avec query params (copiée)    | Redirection 301 vers l'URL propre                                 |
| T6  | Page d'accueil `/`                         | Formulaire vierge, pas de redirection                             |
| T7  | Navigation période précédente/suivante     | URL se met à jour avec les nouvelles dates                        |
| T8  | `<link rel="canonical">`                   | Présente et correcte après chaque recherche                       |
| T9  | Commune avec accents (Saint-Étienne)       | URL = `/meteo/saint-etienne-42/...`, résolution OK                |
| T10 | Commune avec apostrophe (L'Haÿ-les-Roses)  | URL = `/meteo/lhay-les-roses-94/...`, résolution OK               |

### Régression

| #   | Scénario                                               | Résultat attendu                |
| --- | ------------------------------------------------------ | ------------------------------- |
| R1  | Autocomplete commune                                   | Fonctionne identiquement        |
| R2  | Validation période > 31 jours                          | Erreur affichée                 |
| R3  | Validation date future                                 | Erreur affichée                 |
| R4  | Toggle « Tout déplier/replier » (simple + comparaison) | Fonctionne (fix C4 v7 préservé) |
| R5  | Mobile 360px                                           | Lisible                         |
| R6  | Graphiques Chart.js                                    | Affichés correctement           |

---

## 7) Risques techniques

| #   | Risque                                                                 | Mitigation                                                                                                                                                                                                                                                                           |
| --- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1   | **Résolution slug échoue** (API geo indisponible)                      | Le cache slug atténue le problème pour les communes déjà résolues. En cas de miss cache + API down, retourner 502 avec message explicite. Le frontend affiche l'erreur.                                                                                                              |
| 2   | **Désynchronisation algorithme slug JS/Python**                        | Fournir les mêmes cas de test pour les deux implémentations. Tester croisé : le slug généré par le frontend pour « Saint-Étienne » doit être identique à celui du backend.                                                                                                           |
| 3   | **Conflit de routes** (`/meteo/style.css` intercepté par la route SEO) | La route SEO ne match que le pattern `/{slug}/{YYYY-MM-DD}/{YYYY-MM-DD}`. Un path comme `/meteo/style.css` ne match pas car `style.css` n'est pas une date ISO. Utiliser un path parameter avec regex validation côté FastAPI : `start: str = Path(pattern=r"^\d{4}-\d{2}-\d{2}$")`. |

---

## Annexe — Notes non bloquantes (informatives)

### Reports des itérations précédentes

- **R21** — Les fichiers `public/assets/logo-histometeo.png` et `public/assets/favicon.png` ne sont toujours pas committés. `git add public/assets/` au prochain commit.
- **R22** — Les `DeprecationWarning` des dépendances sur Python 3.14+ persistent. Un upgrade de `pytest-asyncio` / `fastapi` les résoudra.
- **R23** — Les changements v6+v7 sont dans le working tree. Envisager un commit v6 + v7 séparé avant le commit v8.

### V2 (hors scope, pour mémoire)

Les éléments suivants sont **explicitement hors scope v8** mais documentés pour le futur :

- Routes sans suffixe département pour les communes à nom unique
- Redirections intelligentes slug sans département → slug avec département
- Pages SEO éditoriales par commune
- Routes climat (`/climat/...`)
- Routes par jour unique (`/meteo/{slug}/{date}`)
- Gestion avancée des homonymes (slugs avec nom de département en toutes lettres)
