feat: graph T° interactif + widget iframe + MF auth + E2E Playwright
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Graph T° (TemperatureChartInteractive.astro) : - Onglets 24 h / 7 j / 30 j (toggle JS, séries serialisées au SSR) - Hover vertical line + tooltip valeurs - Overlay normales mois en pointillé (TX orange, TN bleu) - Onglet 24 h dispo seulement si l'API MF a répondu (best-effort) Météo France OAuth2 (lib/meteofrance-auth.ts + observations.ts) : - client_credentials avec refresh auto, cache token Valkey - Fallback METEOFRANCE_STATIC_TOKEN pour debug - /synop endpoint pour 24h horaires par station SYNOP du dept - Mapping dept → station SYNOP la plus proche (src/data/stations-synop.json) - En attente de creds : SDK skip silencieusement, l'onglet 24h n'apparaît pas Widget iframe (/embed/dept/[code] + /embed doc) : - Layout minimal sans header/footer global - Réutilisable via iframe avec une ligne - Page /embed avec snippet copier-coller + aperçu live Tests E2E Playwright (tests/e2e/) : - home (carte 96 paths, tooltip dept, navigation) - api (health, vigilance, vigilance/dept) - departement (tabs période, DROM notice, 404) - static pages (a-propos, mentions, dependances, soutenir, conseils, embed) - embed widget (rendu minimal, headers X-Frame OK) - 20+ tests, run via pnpm test:e2e (live) ou test:e2e:local Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9cfd4f8385
commit
2c4d91ce2f
20 changed files with 922 additions and 467 deletions
|
|
@ -16,3 +16,11 @@ VIGILANCE_CACHE_TTL=900
|
|||
# Umami analytics (optionnel — laisser vide pour désactiver)
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_SRC=https://analytics.nocleus.com/script.js
|
||||
|
||||
# Météo France API (OAuth2 sur portail-api.meteofrance.fr)
|
||||
# Choisir l'un OU l'autre :
|
||||
# OAuth2 (recommandé prod, refresh auto) :
|
||||
METEOFRANCE_CLIENT_ID=
|
||||
METEOFRANCE_CLIENT_SECRET=
|
||||
# Static token (test seulement, expire 1h) :
|
||||
METEOFRANCE_STATIC_TOKEN=
|
||||
|
|
|
|||
|
|
@ -14,3 +14,7 @@ UMAMI_SRC=https://analytics.nocleus.com/script.js
|
|||
|
||||
# GlitchTip (optionnel — si vide, pas d'envoi Sentry)
|
||||
SENTRY_DSN={{ pass://Infra/Info Canicule — secrets/SENTRY_DSN }}
|
||||
|
||||
# Météo France API (portail-api.meteofrance.fr) — OAuth2 client_credentials pour hourly observations
|
||||
METEOFRANCE_CLIENT_ID={{ pass://Infra/Météo France API/client_id }}
|
||||
METEOFRANCE_CLIENT_SECRET={{ pass://Infra/Météo France API/client_secret }}
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -8,3 +8,7 @@ dist
|
|||
.vscode/*.local
|
||||
.idea
|
||||
data-sources/
|
||||
*:Zone.Identifier
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
|
|
|||
|
|
@ -1,460 +0,0 @@
|
|||
{
|
||||
"openapi": "3.0.1",
|
||||
"info": {
|
||||
"title": "DonneesPubliquesObservation",
|
||||
"version": "1"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://public-api.meteofrance.fr/public/DPObs/v1"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"default": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "Produits Obs",
|
||||
"description": "Services de téléchargement des données d'observation à la date demandée."
|
||||
},
|
||||
{
|
||||
"name": "Produits Synop",
|
||||
"description": "Services de téléchargement des messages internationaux d’observation en surface, SYNOP, à la date demandée."
|
||||
},
|
||||
{
|
||||
"name": "Produits Bouees",
|
||||
"description": "ProduitBouees - Service de téléchargement des données d'observations météorologiques effectuées par les bouées Météo-France, à la date demandée"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/station/infrahoraire-6m": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Produits Obs"
|
||||
],
|
||||
"summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d'observation pour tous les paramètres disponibles, à la fréquence 6 minutes, pour une station, à la date la plus proche de la date demandée.",
|
||||
"description": "Renvoie tous les paramètres disponibles pour la station demandée et pour :\n- la date/heure la plus proche de la date demandée selon les données disponibles.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id_station",
|
||||
"in": "query",
|
||||
"description": "Identifiant de la station (nomenclature : 8 chiffres selon DDCCCNNN = insee de la commune (DD département, CCC n° de la commune dans le département et NNN n° de la station dans la commune)",
|
||||
"required": true,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"description": "Date demandée (au format ISO 8601 avec TZ UTC : AAAA-MM-JJThh:mm:ssZ).\n\nPar défaut = date courante",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "Format de retour des données (JSON ou CSV ou GEOJSON)",
|
||||
"required": true,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"csv",
|
||||
"geojson"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Si une liste vide est renvoyée : station absente ou inexistante\n \nSinon : OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Contrôle de paramètres en erreur"
|
||||
},
|
||||
"404": {
|
||||
"description": "Jeu de données inexistant"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"default": []
|
||||
}
|
||||
],
|
||||
"x-auth-type": "Application & Application User",
|
||||
"x-throttling-tier": "Unlimited"
|
||||
}
|
||||
},
|
||||
"/station/horaire": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Produits Obs"
|
||||
],
|
||||
"summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d’observation pour tous les paramètres disponibles, à la fréquence horaire, pour une station, à la date la plus proche de la date demandée.",
|
||||
"description": "Renvoie tous les paramètres disponibles pour la station demandée et pour :\n- la date/heure la plus proche de la date demandée selon les données disponibles.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id_station",
|
||||
"in": "query",
|
||||
"description": "Identifiant de la station (nomenclature : 8 chiffres selon DDCCCNNN = insee de la commune (DD département, CCC n° de la commune dans le département et NNN n° de la station dans la commune)",
|
||||
"required": true,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"in": "query",
|
||||
"description": "Date demandée (au format ISO 8601 avec TZ UTC : AAAA-MM-JJThh:mm:ssZ).\n\nPar défaut = date courante",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "Format de retour des données (JSON ou CSV ou GEOJSON)",
|
||||
"required": true,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"csv",
|
||||
"geojson"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Si une liste vide est renvoyée : station absente ou inexistante\n \nSinon : OK"
|
||||
},
|
||||
"400": {
|
||||
"description": "Contrôle de paramètres en erreur"
|
||||
},
|
||||
"404": {
|
||||
"description": "Jeu de données inexistant"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"default": []
|
||||
}
|
||||
],
|
||||
"x-auth-type": "Application & Application User",
|
||||
"x-throttling-tier": "Unlimited"
|
||||
}
|
||||
},
|
||||
"/liste-stations": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Produits Obs"
|
||||
],
|
||||
"summary": "Télécharger le fichier TEXTE (CSV) de la liste des stations d'observation.",
|
||||
"description": "Renvoie la liste des stations.",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"404": {
|
||||
"description": "La liste est indisponible"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"default": []
|
||||
}
|
||||
],
|
||||
"x-auth-type": "Application & Application User",
|
||||
"x-throttling-tier": "Unlimited"
|
||||
}
|
||||
},
|
||||
"/liste-stations-synop": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Produits Synop"
|
||||
],
|
||||
"summary": "Télécharger le fichier TEXTE de la liste des stations d'observation.",
|
||||
"description": "Renvoie la liste des stations.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "Format de retour des données (JSON ou CSV ou GEOJSON)",
|
||||
"required": true,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"csv",
|
||||
"geojson"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"404": {
|
||||
"description": "La liste est indisponible"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"default": []
|
||||
}
|
||||
],
|
||||
"x-auth-type": "Application & Application User",
|
||||
"x-throttling-tier": "Unlimited"
|
||||
}
|
||||
},
|
||||
"/synop": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Produits Synop"
|
||||
],
|
||||
"summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données SYNOP pour une station, à la date demandée.",
|
||||
"description": "Renvoie tous les paramètres disponibles pour la station et pour la date/heure demandées.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "Format de retour des données (JSON ou CSV ou GEOJSON)",
|
||||
"required": true,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"csv",
|
||||
"geojson"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id_station",
|
||||
"in": "query",
|
||||
"description": "Identifiant(s) de la station (séparés par des , par exemple id_station=07002,07003)",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date_debut",
|
||||
"in": "query",
|
||||
"description": "Date de début d'observation au format AAAA-MM-JJTHH:mm:00Z.",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date_fin",
|
||||
"in": "query",
|
||||
"description": "Date de fin d'observation au format AAAA-MM-JJTHH:mm:00Z.",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK (liste vide si pas de donnée correspondant à la requête)"
|
||||
},
|
||||
"400": {
|
||||
"description": "Contrôle de paramètres en erreur"
|
||||
},
|
||||
"401": {
|
||||
"description": "Non autorisé - informations d'identification non valides"
|
||||
},
|
||||
"403": {
|
||||
"description": "Accès interdit"
|
||||
},
|
||||
"404": {
|
||||
"description": "Jeu de données inexistant et/ou jeu de données indisponible"
|
||||
},
|
||||
"500": {
|
||||
"description": "Erreur interne au serveur"
|
||||
},
|
||||
"502": {
|
||||
"description": "Erreur de passerelle"
|
||||
},
|
||||
"503": {
|
||||
"description": "Service indisponible"
|
||||
},
|
||||
"504": {
|
||||
"description": "Temps trop long"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"default": []
|
||||
}
|
||||
],
|
||||
"x-auth-type": "Application & Application User",
|
||||
"x-throttling-tier": "Unlimited"
|
||||
}
|
||||
},
|
||||
"/liste-bouees": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Produits Bouees"
|
||||
],
|
||||
"summary": "Télécharger le fichier TEXTE de la liste des stations d'observation.",
|
||||
"description": "Renvoie la liste des stations et bouées.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"csv",
|
||||
"geojson"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok"
|
||||
},
|
||||
"404": {
|
||||
"description": "La liste est indisponible"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"default": []
|
||||
}
|
||||
],
|
||||
"x-auth-type": "Application & Application User",
|
||||
"x-throttling-tier": "Unlimited"
|
||||
}
|
||||
},
|
||||
"/bouees": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Produits Bouees"
|
||||
],
|
||||
"summary": "Télécharger le fichier TEXTE (CSV ou JSON ou GEOJSON) des données d'observations pour une station, à la date demandée.",
|
||||
"description": "Renvoie tous les paramètres disponibles pour la station à la date heure demandée.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "format",
|
||||
"in": "query",
|
||||
"description": "Format de retour des données (JSON ou CSV ou GEOJSON)",
|
||||
"required": true,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"json",
|
||||
"csv",
|
||||
"geojson"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date_debut",
|
||||
"in": "query",
|
||||
"description": "Date de début d'observation au format AAAA-MM-JJTHH:mm:00Z",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date_fin",
|
||||
"in": "query",
|
||||
"description": "Date de fin d'observation au format AAAA-MM-JJTHH:mm:00Z",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id_bouees",
|
||||
"in": "query",
|
||||
"description": "Identifiant(s) de la station (séparés par des , par exemple id_station=6100001,6100002)",
|
||||
"required": false,
|
||||
"style": "form",
|
||||
"explode": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ok (liste vide si aucune donnée ne correspondent à la requête)"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"default": []
|
||||
}
|
||||
],
|
||||
"x-auth-type": "Application & Application User",
|
||||
"x-throttling-tier": "Unlimited"
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"default": {
|
||||
"type": "oauth2",
|
||||
"flows": {
|
||||
"implicit": {
|
||||
"authorizationUrl": "https://public-api.meteofrance.fr/authorize",
|
||||
"scopes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
|
@ -11,7 +11,9 @@
|
|||
"start": "node ./dist/server/entry.mjs",
|
||||
"astro": "astro",
|
||||
"check": "astro check",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "E2E_BASE_URL=http://localhost:4321 playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.2.2",
|
||||
|
|
@ -25,6 +27,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/node": "^22.10.5",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.7.2"
|
||||
|
|
|
|||
25
playwright.config.ts
Normal file
25
playwright.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
// Tests E2E pour info-canicule.
|
||||
// Par défaut : target la prod live (https://info-canicule.nocleus.com).
|
||||
// Pour tester un dev server local : E2E_BASE_URL=http://localhost:4321 pnpm test:e2e
|
||||
|
||||
const BASE = process.env.E2E_BASE_URL ?? 'https://info-canicule.nocleus.com';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 30_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: [['list'], process.env.CI ? ['github'] : ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: BASE,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
|
|
@ -36,6 +36,9 @@ importers:
|
|||
'@astrojs/check':
|
||||
specifier: ^0.9.4
|
||||
version: 0.9.9(prettier@3.8.3)(typescript@5.9.3)
|
||||
'@playwright/test':
|
||||
specifier: ^1.60.0
|
||||
version: 1.60.0
|
||||
'@types/node':
|
||||
specifier: ^22.10.5
|
||||
version: 22.19.19
|
||||
|
|
@ -863,6 +866,11 @@ packages:
|
|||
'@oslojs/encoding@1.1.0':
|
||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@prisma/instrumentation@7.6.0':
|
||||
resolution: {integrity: sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -1681,6 +1689,11 @@ packages:
|
|||
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
|
|
@ -2216,6 +2229,16 @@ packages:
|
|||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
playwright-core@1.60.0:
|
||||
resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.60.0:
|
||||
resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss-import@15.1.0:
|
||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -3738,6 +3761,10 @@ snapshots:
|
|||
|
||||
'@oslojs/encoding@1.1.0': {}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
dependencies:
|
||||
playwright: 1.60.0
|
||||
|
||||
'@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
|
@ -4658,6 +4685,9 @@ snapshots:
|
|||
|
||||
fresh@2.0.0: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
|
|
@ -5394,6 +5424,14 @@ snapshots:
|
|||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
playwright-core@1.60.0: {}
|
||||
|
||||
playwright@1.60.0:
|
||||
dependencies:
|
||||
playwright-core: 1.60.0
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.15):
|
||||
dependencies:
|
||||
postcss: 8.5.15
|
||||
|
|
|
|||
264
src/components/TemperatureChartInteractive.astro
Normal file
264
src/components/TemperatureChartInteractive.astro
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
---
|
||||
import type { DayObservation } from '../lib/climato';
|
||||
import type { HourlyObservation, HourlySeries } from '../lib/observations';
|
||||
import type { MonthNormale } from '../lib/normales';
|
||||
|
||||
interface Props {
|
||||
hourly?: HourlySeries | null; // 24h
|
||||
days7: DayObservation[];
|
||||
days30: DayObservation[];
|
||||
normale: MonthNormale | null;
|
||||
stationLabel?: string | null;
|
||||
}
|
||||
|
||||
const { hourly, days7, days30, normale, stationLabel } = Astro.props;
|
||||
|
||||
// Sérialiser les 3 séries pour le JS client (toggle + hover).
|
||||
const serialize = {
|
||||
hourly: hourly?.observations.map((o) => ({ t: o.time, tx: o.t, tn: null })) ?? [],
|
||||
days7: days7.map((d) => ({ t: d.date, tx: d.tx, tn: d.tn })),
|
||||
days30: days30.map((d) => ({ t: d.date, tx: d.tx, tn: d.tn })),
|
||||
normale: normale ? { tx: normale.tx, tn: normale.tn } : null,
|
||||
};
|
||||
|
||||
const hasHourly = (hourly?.observations.length ?? 0) > 0;
|
||||
const defaultPeriod = hasHourly ? '24h' : '7j';
|
||||
---
|
||||
|
||||
<figure class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<figcaption class="text-sm font-semibold text-slate-700">
|
||||
Températures
|
||||
{stationLabel && <span class="font-normal text-slate-500">— station {stationLabel}</span>}
|
||||
</figcaption>
|
||||
<div role="tablist" aria-label="Période" class="inline-flex rounded border border-slate-200">
|
||||
{hasHourly && (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-period="24h"
|
||||
aria-selected={defaultPeriod === '24h'}
|
||||
class="period-tab border-r border-slate-200 px-3 py-1 text-xs font-medium aria-selected:bg-canicule-600 aria-selected:text-white"
|
||||
>
|
||||
24 h
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-period="7j"
|
||||
aria-selected={defaultPeriod === '7j'}
|
||||
class="period-tab border-r border-slate-200 px-3 py-1 text-xs font-medium aria-selected:bg-canicule-600 aria-selected:text-white"
|
||||
>
|
||||
7 jours
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-period="30j"
|
||||
aria-selected={false}
|
||||
class="period-tab px-3 py-1 text-xs font-medium aria-selected:bg-canicule-600 aria-selected:text-white"
|
||||
>
|
||||
30 jours
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chart-container" class="relative mt-3" data-default-period={defaultPeriod}>
|
||||
<svg id="chart-svg" viewBox="0 0 800 280" class="h-auto w-full" role="img" aria-label="Graphique de températures"></svg>
|
||||
<div
|
||||
id="chart-tooltip"
|
||||
class="pointer-events-none absolute hidden rounded border border-slate-300 bg-white px-2 py-1 text-xs shadow-md"
|
||||
role="tooltip"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">
|
||||
<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-3 rounded bg-canicule-600"></span> T° max (jour) / T° (heure)</span>
|
||||
<span class="inline-flex items-center gap-1"><span class="inline-block h-2 w-3 rounded bg-blue-500"></span> T° min (jour)</span>
|
||||
{normale && (
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="inline-block h-px w-4 border-t-2 border-dashed border-slate-400"></span>
|
||||
Normale mois (1991-2020) : TX <span class="font-mono">{normale.tx}°C</span>, TN <span class="font-mono">{normale.tn}°C</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<script type="application/json" id="chart-data" set:html={JSON.stringify(serialize)} />
|
||||
|
||||
<script is:inline>
|
||||
(function () {
|
||||
const root = document.getElementById('chart-container');
|
||||
const svg = document.getElementById('chart-svg');
|
||||
const tip = document.getElementById('chart-tooltip');
|
||||
const dataEl = document.getElementById('chart-data');
|
||||
if (!root || !svg || !tip || !dataEl) return;
|
||||
|
||||
const data = JSON.parse(dataEl.textContent || '{}');
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||
const W = 800, H = 280, PAD_L = 36, PAD_R = 12, PAD_T = 16, PAD_B = 28;
|
||||
const innerW = W - PAD_L - PAD_R;
|
||||
const innerH = H - PAD_T - PAD_B;
|
||||
|
||||
function fmtTime(iso, period) {
|
||||
const d = new Date(iso);
|
||||
if (period === '24h') return d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' });
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
function render(period) {
|
||||
const series = data[period === '24h' ? 'hourly' : period === '7j' ? 'days7' : 'days30'];
|
||||
if (!series || series.length === 0) {
|
||||
svg.innerHTML = '<text x="400" y="140" text-anchor="middle" fill="#94a3b8">Aucune donnée disponible</text>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Y range : inclure normales si dispo
|
||||
const txVals = series.map((p) => p.tx).filter((v) => v !== null);
|
||||
const tnVals = series.map((p) => p.tn).filter((v) => v !== null);
|
||||
const allVals = [...txVals, ...tnVals];
|
||||
if (data.normale?.tx !== null && data.normale?.tx !== undefined) allVals.push(data.normale.tx);
|
||||
if (data.normale?.tn !== null && data.normale?.tn !== undefined) allVals.push(data.normale.tn);
|
||||
if (allVals.length === 0) return;
|
||||
const yMin = Math.floor(Math.min(...allVals) - 1);
|
||||
const yMax = Math.ceil(Math.max(...allVals) + 1);
|
||||
const yRange = Math.max(1, yMax - yMin);
|
||||
|
||||
const n = series.length;
|
||||
const xAt = (i) => PAD_L + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
|
||||
const yAt = (v) => PAD_T + innerH - ((v - yMin) / yRange) * innerH;
|
||||
|
||||
let html = '';
|
||||
|
||||
// Y axis labels (5 ticks)
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const v = yMin + (yRange * i) / 4;
|
||||
const y = yAt(v);
|
||||
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${y}" y2="${y}" stroke="#f1f5f9" stroke-width="1"/>`;
|
||||
html += `<text x="${PAD_L - 4}" y="${y + 3}" font-size="10" fill="#64748b" text-anchor="end">${Math.round(v)}°</text>`;
|
||||
}
|
||||
|
||||
// X axis labels (5 ticks)
|
||||
const xTicks = Math.min(5, n);
|
||||
for (let k = 0; k < xTicks; k++) {
|
||||
const i = Math.round((k / (xTicks - 1)) * (n - 1));
|
||||
const x = xAt(i);
|
||||
html += `<text x="${x}" y="${H - PAD_B + 14}" font-size="10" fill="#64748b" text-anchor="middle">${fmtTime(series[i].t, period)}</text>`;
|
||||
}
|
||||
|
||||
// Axes
|
||||
html += `<line x1="${PAD_L}" x2="${PAD_L}" y1="${PAD_T}" y2="${H - PAD_B}" stroke="#cbd5e1"/>`;
|
||||
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${H - PAD_B}" y2="${H - PAD_B}" stroke="#cbd5e1"/>`;
|
||||
|
||||
// Normales horizontales
|
||||
if (data.normale) {
|
||||
if (typeof data.normale.tx === 'number') {
|
||||
const y = yAt(data.normale.tx);
|
||||
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${y}" y2="${y}" stroke="#ea580c" stroke-width="1" stroke-dasharray="4,3" opacity="0.6"/>`;
|
||||
}
|
||||
if (typeof data.normale.tn === 'number') {
|
||||
const y = yAt(data.normale.tn);
|
||||
html += `<line x1="${PAD_L}" x2="${W - PAD_R}" y1="${y}" y2="${y}" stroke="#3b82f6" stroke-width="1" stroke-dasharray="4,3" opacity="0.6"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
// TX line
|
||||
const txPath = series
|
||||
.map((p, i) => p.tx !== null ? `${i === 0 ? 'M' : 'L'}${xAt(i).toFixed(1)},${yAt(p.tx).toFixed(1)}` : null)
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
if (txPath) html += `<path d="${txPath}" fill="none" stroke="#ea580c" stroke-width="2"/>`;
|
||||
|
||||
// TN line
|
||||
const tnPath = series
|
||||
.map((p, i) => p.tn !== null ? `${i === 0 ? 'M' : 'L'}${xAt(i).toFixed(1)},${yAt(p.tn).toFixed(1)}` : null)
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
if (tnPath) html += `<path d="${tnPath}" fill="none" stroke="#3b82f6" stroke-width="2"/>`;
|
||||
|
||||
// Hover overlay : invisible vertical bars to capture mouseover, indexed via data-i
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = xAt(i);
|
||||
const bw = innerW / Math.max(1, n - 1);
|
||||
html += `<rect x="${x - bw / 2}" y="${PAD_T}" width="${bw}" height="${innerH}" fill="transparent" data-i="${i}" class="chart-hit" />`;
|
||||
}
|
||||
|
||||
// Active point markers (filled by JS at hover)
|
||||
html += `<line id="hover-vline" x1="0" y1="${PAD_T}" x2="0" y2="${H - PAD_B}" stroke="#475569" stroke-width="1" stroke-dasharray="2,2" style="display:none"/>`;
|
||||
html += `<circle id="hover-tx" cx="0" cy="0" r="4" fill="#ea580c" stroke="white" stroke-width="2" style="display:none"/>`;
|
||||
html += `<circle id="hover-tn" cx="0" cy="0" r="4" fill="#3b82f6" stroke="white" stroke-width="2" style="display:none"/>`;
|
||||
|
||||
svg.innerHTML = html;
|
||||
|
||||
const vline = svg.querySelector('#hover-vline');
|
||||
const dotTx = svg.querySelector('#hover-tx');
|
||||
const dotTn = svg.querySelector('#hover-tn');
|
||||
|
||||
function showAt(i, evt) {
|
||||
const p = series[i];
|
||||
if (!p) return;
|
||||
const x = xAt(i);
|
||||
vline.setAttribute('x1', x);
|
||||
vline.setAttribute('x2', x);
|
||||
vline.style.display = 'block';
|
||||
if (p.tx !== null) {
|
||||
dotTx.setAttribute('cx', x);
|
||||
dotTx.setAttribute('cy', yAt(p.tx));
|
||||
dotTx.style.display = 'block';
|
||||
} else { dotTx.style.display = 'none'; }
|
||||
if (p.tn !== null) {
|
||||
dotTn.setAttribute('cx', x);
|
||||
dotTn.setAttribute('cy', yAt(p.tn));
|
||||
dotTn.style.display = 'block';
|
||||
} else { dotTn.style.display = 'none'; }
|
||||
|
||||
const lines = [fmtTime(p.t, period)];
|
||||
if (p.tx !== null) lines.push(`<span class="text-canicule-700">T${period === '24h' ? '°' : 'X'} ${p.tx}°C</span>`);
|
||||
if (p.tn !== null) lines.push(`<span class="text-blue-700">TN ${p.tn}°C</span>`);
|
||||
tip.innerHTML = lines.join('<br>');
|
||||
tip.classList.remove('hidden');
|
||||
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const margin = 10;
|
||||
let left = (evt.clientX - rootRect.left) + margin;
|
||||
let top = (evt.clientY - rootRect.top) + margin;
|
||||
if (left + tip.offsetWidth > rootRect.width - 5) left = (evt.clientX - rootRect.left) - tip.offsetWidth - margin;
|
||||
if (top + tip.offsetHeight > rootRect.height - 5) top = (evt.clientY - rootRect.top) - tip.offsetHeight - margin;
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = top + 'px';
|
||||
}
|
||||
|
||||
function hide() {
|
||||
vline.style.display = 'none';
|
||||
dotTx.style.display = 'none';
|
||||
dotTn.style.display = 'none';
|
||||
tip.classList.add('hidden');
|
||||
}
|
||||
|
||||
svg.querySelectorAll('.chart-hit').forEach((el) => {
|
||||
el.addEventListener('mouseenter', (e) => showAt(parseInt(el.getAttribute('data-i'), 10), e));
|
||||
el.addEventListener('mousemove', (e) => showAt(parseInt(el.getAttribute('data-i'), 10), e));
|
||||
});
|
||||
svg.addEventListener('mouseleave', hide);
|
||||
}
|
||||
|
||||
// Tab handling
|
||||
document.querySelectorAll('.period-tab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.period-tab').forEach((b) => b.setAttribute('aria-selected', 'false'));
|
||||
btn.setAttribute('aria-selected', 'true');
|
||||
render(btn.getAttribute('data-period'));
|
||||
});
|
||||
});
|
||||
|
||||
render(root.getAttribute('data-default-period') || '7j');
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.period-tab[aria-selected='true'] {
|
||||
background-color: #ea580c;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
21
src/layouts/Embed.astro
Normal file
21
src/layouts/Embed.astro
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="bg-transparent text-sm">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
89
src/lib/meteofrance-auth.ts
Normal file
89
src/lib/meteofrance-auth.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// OAuth2 client_credentials pour l'API Météo France (portail-api.meteofrance.fr).
|
||||
// Le token retourné dure ~1h, on le cache en mémoire process + Valkey jusqu'à 30 min avant expiration.
|
||||
//
|
||||
// Configuration via env :
|
||||
// METEOFRANCE_CLIENT_ID + METEOFRANCE_CLIENT_SECRET → mode OAuth2 (refresh auto)
|
||||
// METEOFRANCE_STATIC_TOKEN → fallback dev/test (1h)
|
||||
//
|
||||
// Le mode OAuth2 est obligatoire en prod ; le static_token est pour debug local.
|
||||
|
||||
import { cacheGet, cacheSet } from './cache';
|
||||
|
||||
const TOKEN_URL = 'https://portail-api.meteofrance.fr/token';
|
||||
const CACHE_KEY = 'mf:access_token';
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
let inMemory: CachedToken | null = null;
|
||||
|
||||
async function fetchFreshToken(): Promise<CachedToken> {
|
||||
const clientId = process.env.METEOFRANCE_CLIENT_ID;
|
||||
const clientSecret = process.env.METEOFRANCE_CLIENT_SECRET;
|
||||
const staticToken = process.env.METEOFRANCE_STATIC_TOKEN;
|
||||
|
||||
if (clientId && clientSecret) {
|
||||
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
const body = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`MF token fetch failed: ${res.status} ${txt.slice(0, 200)}`);
|
||||
}
|
||||
const json = (await res.json()) as { access_token: string; expires_in: number };
|
||||
const expiresAtMs = Date.now() + (json.expires_in - 120) * 1000; // marge 2 min
|
||||
return { token: json.access_token, expiresAtMs };
|
||||
}
|
||||
|
||||
if (staticToken) {
|
||||
// Pas de moyen de décoder l'expiration sans parser le JWT ; on suppose 1h (à durcir si besoin).
|
||||
// Si le token est expiré, l'API retournera 401 et le caller pourra invalidate().
|
||||
return { token: staticToken, expiresAtMs: Date.now() + 50 * 60 * 1000 };
|
||||
}
|
||||
|
||||
throw new Error('No Météo France credentials in env (need METEOFRANCE_CLIENT_ID+SECRET or METEOFRANCE_STATIC_TOKEN)');
|
||||
}
|
||||
|
||||
export async function getAccessToken(forceRefresh = false): Promise<string> {
|
||||
if (!forceRefresh && inMemory && Date.now() < inMemory.expiresAtMs) {
|
||||
return inMemory.token;
|
||||
}
|
||||
if (!forceRefresh) {
|
||||
const cached = await cacheGet<CachedToken>(CACHE_KEY);
|
||||
if (cached && Date.now() < cached.expiresAtMs) {
|
||||
inMemory = cached;
|
||||
return cached.token;
|
||||
}
|
||||
}
|
||||
const fresh = await fetchFreshToken();
|
||||
inMemory = fresh;
|
||||
const ttl = Math.max(60, Math.floor((fresh.expiresAtMs - Date.now()) / 1000));
|
||||
await cacheSet(CACHE_KEY, fresh, ttl);
|
||||
return fresh.token;
|
||||
}
|
||||
|
||||
export async function fetchMF(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const base = 'https://public-api.meteofrance.fr';
|
||||
let token = await getAccessToken();
|
||||
let res = await fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
token = await getAccessToken(true);
|
||||
res = await fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
98
src/lib/observations.ts
Normal file
98
src/lib/observations.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Observations horaires Météo France (SYNOP) par département.
|
||||
// Pour chaque dept, on utilise la station SYNOP la plus proche du centroïde
|
||||
// (pré-calculé dans src/data/stations-synop.json).
|
||||
|
||||
import { fetchMF } from './meteofrance-auth';
|
||||
import { cacheOrFetch } from './cache';
|
||||
import stationsMap from '../data/stations-synop.json';
|
||||
|
||||
type StationsMap = Record<string, { stationId: string; name: string; distKm: number } | undefined>;
|
||||
const STATIONS = stationsMap as StationsMap;
|
||||
|
||||
export interface HourlyObservation {
|
||||
time: string; // ISO 8601 UTC
|
||||
t: number | null; // T° en °C (converti depuis Kelvin)
|
||||
td: number | null; // T° point de rosée °C
|
||||
u: number | null; // humidité %
|
||||
ff: number | null; // vent moyen m/s
|
||||
rr1: number | null; // précipitations 1h mm
|
||||
pres: number | null; // pression mer hPa
|
||||
}
|
||||
|
||||
export interface HourlySeries {
|
||||
dept: string;
|
||||
stationId: string;
|
||||
stationName: string;
|
||||
distKm: number;
|
||||
fetchedAt: string;
|
||||
observations: HourlyObservation[]; // sorted by time asc
|
||||
}
|
||||
|
||||
function K2C(k: number | null | undefined): number | null {
|
||||
if (typeof k !== 'number' || !Number.isFinite(k)) return null;
|
||||
return +(k - 273.15).toFixed(1);
|
||||
}
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
interface MFSynopRow {
|
||||
validity_time?: string;
|
||||
t?: number;
|
||||
td?: number;
|
||||
u?: number;
|
||||
ff?: number;
|
||||
rr1?: number;
|
||||
pmer?: number;
|
||||
pres?: number;
|
||||
}
|
||||
|
||||
export function stationForDepartement(dept: string): { stationId: string; name: string; distKm: number } | null {
|
||||
return STATIONS[dept] ?? null;
|
||||
}
|
||||
|
||||
export async function getHourlyForDepartement(dept: string, hours = 24): Promise<HourlySeries | null> {
|
||||
const meta = stationForDepartement(dept);
|
||||
if (!meta) return null;
|
||||
|
||||
const cacheKey = `mf:hourly:${dept}:${hours}`;
|
||||
// Cache 30 min — les obs SYNOP sont publiées toutes les 3h (parfois 1h), 30 min est un bon compromis.
|
||||
return cacheOrFetch(cacheKey, 30 * 60, async () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - hours * 3600 * 1000);
|
||||
const dateDebut = start.toISOString().split('.')[0] + 'Z';
|
||||
const dateFin = now.toISOString().split('.')[0] + 'Z';
|
||||
const path = `/public/DPObs/v1/synop?format=json&id_station=${encodeURIComponent(meta.stationId)}&date_debut=${encodeURIComponent(dateDebut)}&date_fin=${encodeURIComponent(dateFin)}`;
|
||||
const res = await fetchMF(path);
|
||||
if (!res.ok) {
|
||||
throw new Error(`MF synop ${dept}: ${res.status}`);
|
||||
}
|
||||
const rows = (await res.json()) as MFSynopRow[];
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error('MF synop response not an array');
|
||||
}
|
||||
|
||||
const observations: HourlyObservation[] = rows
|
||||
.filter((r) => r.validity_time)
|
||||
.map((r) => ({
|
||||
time: r.validity_time!,
|
||||
t: K2C(num(r.t)),
|
||||
td: K2C(num(r.td)),
|
||||
u: num(r.u),
|
||||
ff: num(r.ff),
|
||||
rr1: num(r.rr1),
|
||||
pres: typeof r.pmer === 'number' ? +(r.pmer / 100).toFixed(1) : (typeof r.pres === 'number' ? +(r.pres / 100).toFixed(1) : null),
|
||||
}))
|
||||
.sort((a, b) => (a.time < b.time ? -1 : 1));
|
||||
|
||||
return {
|
||||
dept,
|
||||
stationId: meta.stationId,
|
||||
stationName: meta.name,
|
||||
distKm: meta.distKm,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
observations,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -7,8 +7,9 @@ import { getDepartement, isDrom } from '../../lib/departements';
|
|||
import { PHENOMENA, COLOR_LABEL } from '../../lib/phenomena';
|
||||
import { ADVICE, EMERGENCY_NUMBERS } from '../../lib/advice';
|
||||
import { getClimatoForDepartement } from '../../lib/climato';
|
||||
import { computeAnomaly } from '../../lib/normales';
|
||||
import ClimatoChart from '../../components/ClimatoChart.astro';
|
||||
import { computeAnomaly, normaleForMonth } from '../../lib/normales';
|
||||
import { getHourlyForDepartement } from '../../lib/observations';
|
||||
import TemperatureChartInteractive from '../../components/TemperatureChartInteractive.astro';
|
||||
import AnomalyBadge from '../../components/AnomalyBadge.astro';
|
||||
|
||||
export const prerender = false;
|
||||
|
|
@ -34,17 +35,32 @@ if (!drom) {
|
|||
|
||||
let climato = null;
|
||||
let anomaly = null;
|
||||
let hourly = null;
|
||||
let normale = null;
|
||||
if (!drom) {
|
||||
try {
|
||||
climato = await getClimatoForDepartement(dept.code);
|
||||
if (climato?.days?.length) {
|
||||
anomaly = await computeAnomaly(dept.code, climato.days);
|
||||
// Mois représentatif = mois du dernier jour climato dispo
|
||||
const lastDate = climato.days[climato.days.length - 1].date;
|
||||
normale = await normaleForMonth(dept.code, parseInt(lastDate.slice(5, 7), 10));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('climato fetch failed for', dept.code, (e as Error).message);
|
||||
}
|
||||
// Hourly est best-effort : si MF API down ou pas de creds, on n'affiche juste pas l'onglet 24h
|
||||
try {
|
||||
hourly = await getHourlyForDepartement(dept.code, 24);
|
||||
} catch (e) {
|
||||
console.warn('hourly fetch failed for', dept.code, (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const last7 = climato?.days?.slice(-7) ?? [];
|
||||
const last30 = climato?.days?.slice(-30) ?? [];
|
||||
const stationLabel = hourly ? `${hourly.stationName} (${hourly.distKm} km)` : null;
|
||||
|
||||
const today = snapshot ? alertsForDepartement(snapshot, dept.code, 'J') : [];
|
||||
const tomorrow = snapshot ? alertsForDepartement(snapshot, dept.code, 'J1') : [];
|
||||
const highest = today[0];
|
||||
|
|
@ -158,11 +174,17 @@ const adviceFor = highest && ADVICE[highest.phenomenonId];
|
|||
<AnomalyBadge anomaly={anomaly} />
|
||||
</div>
|
||||
)}
|
||||
<ClimatoChart days={climato.days} />
|
||||
<TemperatureChartInteractive
|
||||
hourly={hourly}
|
||||
days7={last7}
|
||||
days30={last30}
|
||||
normale={normale}
|
||||
stationLabel={stationLabel}
|
||||
/>
|
||||
<p class="mt-2 text-xs text-slate-500">
|
||||
Source : Météo France — données climatologiques de base quotidiennes, agrégées par moyenne
|
||||
sur toutes les stations du département. Donnée brute, contrôle qualité Météo France.
|
||||
Normales calculées sur 1991-2020 (référence WMO).
|
||||
Sources : Météo France — observations SYNOP horaires (onglet 24 h) et données
|
||||
climatologiques de base quotidiennes (7 j / 30 j), agrégées par moyenne sur les
|
||||
stations du département. Normales calculées sur 1991-2020 (référence WMO).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
79
src/pages/embed/dept/[code].astro
Normal file
79
src/pages/embed/dept/[code].astro
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
import Embed from '../../../layouts/Embed.astro';
|
||||
import VigilanceChip from '../../../components/VigilanceChip.astro';
|
||||
import { getVigilanceSnapshot, alertsForDepartement, maxColorByDepartement } from '../../../lib/vigilance';
|
||||
import { getDepartement, isDrom } from '../../../lib/departements';
|
||||
import { COLORS } from '../../../lib/phenomena';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { code } = Astro.params;
|
||||
const dept = code ? getDepartement(code.toUpperCase()) : undefined;
|
||||
if (!dept) {
|
||||
return new Response('not found', { status: 404 });
|
||||
}
|
||||
|
||||
let alerts = [] as Awaited<ReturnType<typeof alertsForDepartement>>;
|
||||
let maxColor = 1;
|
||||
let error: string | null = null;
|
||||
if (!isDrom(dept.code)) {
|
||||
try {
|
||||
const snap = await getVigilanceSnapshot();
|
||||
alerts = alertsForDepartement(snap, dept.code, 'J').sort((a, b) => b.colorId - a.colorId);
|
||||
maxColor = maxColorByDepartement(snap, 'J').get(dept.code) ?? 1;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const color = COLORS[maxColor as 1 | 2 | 3 | 4];
|
||||
const siteUrl = `https://info-canicule.nocleus.com/departement/${dept.code}`;
|
||||
---
|
||||
|
||||
<Embed title={`Vigilance ${dept.name} — Info Canicule`}>
|
||||
<article class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
|
||||
<header class="flex items-center justify-between gap-3 border-b border-slate-100 pb-2">
|
||||
<div>
|
||||
<h1 class="text-base font-semibold text-slate-900">{dept.name}</h1>
|
||||
<p class="text-xs text-slate-500">Vigilance Météo France · dept {dept.code}</p>
|
||||
</div>
|
||||
<div
|
||||
class="rounded px-2 py-1 text-xs font-semibold"
|
||||
style={`background:${color.hex}; color:${maxColor >= 3 ? '#fff' : '#1e293b'}`}
|
||||
>
|
||||
{color.name.toUpperCase()}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<p class="mt-2 text-xs text-red-700">Données indisponibles.</p>
|
||||
)}
|
||||
|
||||
{isDrom(dept.code) && (
|
||||
<p class="mt-2 text-xs text-amber-800">
|
||||
Vigilance Outre-mer non couverte par cette source.
|
||||
<a href="https://vigilance.meteofrance.fr/" rel="noopener" class="text-canicule-700">Source officielle ↗</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && !isDrom(dept.code) && alerts.length === 0 && (
|
||||
<p class="mt-2 text-xs text-green-700">Aucune vigilance particulière aujourd'hui.</p>
|
||||
)}
|
||||
|
||||
{alerts.length > 0 && (
|
||||
<ul class="mt-2 space-y-1">
|
||||
{alerts.map((a) => (
|
||||
<li>
|
||||
<VigilanceChip colorId={a.colorId} phenomenonId={a.phenomenonId} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<footer class="mt-3 border-t border-slate-100 pt-2 text-right text-xs">
|
||||
<a href={siteUrl} target="_top" rel="noopener" class="text-canicule-700">
|
||||
Détail sur info-canicule.nocleus.com →
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
</Embed>
|
||||
94
src/pages/embed/index.astro
Normal file
94
src/pages/embed/index.astro
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { DEPARTEMENTS } from '../../lib/departements';
|
||||
|
||||
export const prerender = false;
|
||||
const sample = DEPARTEMENTS.find((d) => d.code === '13')!;
|
||||
const baseUrl = 'https://info-canicule.nocleus.com';
|
||||
const snippet = `<iframe
|
||||
src="${baseUrl}/embed/dept/${sample.code}"
|
||||
width="380" height="260"
|
||||
style="border:0; max-width:100%"
|
||||
loading="lazy"
|
||||
title="Vigilance ${sample.name} — Info Canicule"
|
||||
></iframe>`;
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Widget intégrable — Info Canicule"
|
||||
description="Intégrez la Vigilance Météo France de n'importe quel département sur votre site en une ligne d'iframe."
|
||||
>
|
||||
<section class="bg-gradient-to-b from-canicule-50 to-white">
|
||||
<div class="container-tight py-10">
|
||||
<h1 class="text-3xl font-bold sm:text-4xl">Widget intégrable</h1>
|
||||
<p class="mt-2 max-w-2xl text-slate-600">
|
||||
Intégrez la Vigilance Météo France de n'importe quel département sur votre site en une
|
||||
ligne d'iframe. Gratuit, libre de réutilisation (Licence Ouverte 2.0).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="container-tight py-8 space-y-8">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Aperçu live ({sample.name})</h2>
|
||||
<div class="mt-3 inline-block rounded border border-slate-200 bg-slate-100 p-4">
|
||||
<iframe
|
||||
src={`/embed/dept/${sample.code}`}
|
||||
width="380"
|
||||
height="260"
|
||||
style="border:0; max-width:100%; background:transparent"
|
||||
loading="lazy"
|
||||
title={`Vigilance ${sample.name}`}
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Code à copier-coller</h2>
|
||||
<pre class="mt-3 overflow-x-auto rounded bg-slate-900 p-4 text-xs text-slate-100"><code>{snippet}</code></pre>
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
Remplace <code class="rounded bg-slate-100 px-1">{sample.code}</code> par le code de ton
|
||||
département (01-95, 2A, 2B, 971-976). La liste complète :
|
||||
<a href="/conseils">page conseils</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Personnalisation</h2>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-sm text-slate-700">
|
||||
<li>
|
||||
<strong>Dimensions</strong> : ajustables via <code>width</code> et <code>height</code>.
|
||||
Le widget s'adapte aux petits écrans grâce à <code>max-width:100%</code>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Fond transparent</strong> : pose-le sur n'importe quelle couleur de page.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rafraîchissement</strong> : la page se met à jour automatiquement à chaque
|
||||
rechargement (cache 15 min côté serveur).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">API JSON brute (alternative)</h2>
|
||||
<p class="mt-2 text-sm text-slate-700">
|
||||
Si tu préfères afficher la Vigilance avec ton propre design, l'endpoint
|
||||
<code class="rounded bg-slate-100 px-1">/api/vigilance/dept/[code]</code>
|
||||
renvoie un JSON CORS *, prêt à parser :
|
||||
</p>
|
||||
<pre class="mt-2 overflow-x-auto rounded bg-slate-900 p-4 text-xs text-slate-100"><code>fetch('{baseUrl}/api/vigilance/dept/{sample.code}')
|
||||
.then(r => r.json())
|
||||
.then(data => console.log(data.today)); // [{ phenomenon, color, ... }]</code></pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Conditions de réutilisation</h2>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-sm text-slate-700">
|
||||
<li>Données sous <a href="https://www.etalab.gouv.fr/licence-ouverte-open-licence/" rel="noopener">Licence Ouverte 2.0</a> (mention obligatoire de la source : Météo France).</li>
|
||||
<li>Ce service ne remplace pas la source officielle. En cas d'urgence, suivre <a href="https://vigilance.meteofrance.fr/" rel="noopener">vigilance.meteofrance.fr</a> et appeler le 112.</li>
|
||||
<li>Pas de garantie de disponibilité. Pour un usage critique, contractualiser directement avec Météo France.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</Base>
|
||||
37
tests/e2e/api.spec.ts
Normal file
37
tests/e2e/api.spec.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('API', () => {
|
||||
test('/api/health retourne status ok et cache true', async ({ request }) => {
|
||||
const res = await request.get('/api/health');
|
||||
expect(res.status()).toBeLessThan(600); // accepte 200 ou 503 selon Valkey
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('status');
|
||||
expect(body).toHaveProperty('cache');
|
||||
expect(body).toHaveProperty('time');
|
||||
});
|
||||
|
||||
test('/api/vigilance retourne snapshot JSON valide CORS *', async ({ request }) => {
|
||||
const res = await request.get('/api/vigilance');
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['access-control-allow-origin']).toBe('*');
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('fetchedAt');
|
||||
expect(body).toHaveProperty('alerts');
|
||||
expect(Array.isArray(body.alerts)).toBe(true);
|
||||
});
|
||||
|
||||
test('/api/vigilance/dept/13 retourne enrichi avec labels', async ({ request }) => {
|
||||
const res = await request.get('/api/vigilance/dept/13');
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.departement.code).toBe('13');
|
||||
expect(body.departement.name).toBe('Bouches-du-Rhône');
|
||||
expect(body).toHaveProperty('today');
|
||||
expect(body).toHaveProperty('tomorrow');
|
||||
});
|
||||
|
||||
test('/api/vigilance/dept/999 retourne 404', async ({ request }) => {
|
||||
const res = await request.get('/api/vigilance/dept/999');
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
31
tests/e2e/departement.spec.ts
Normal file
31
tests/e2e/departement.spec.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Page département', () => {
|
||||
test('/departement/75 (Paris) — métropole', async ({ page }) => {
|
||||
await page.goto('/departement/75');
|
||||
await expect(page.getByRole('heading', { name: 'Paris', exact: true })).toBeVisible();
|
||||
// Graph interactif avec onglets période
|
||||
await expect(page.getByRole('tab', { name: '7 jours' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: '30 jours' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Tabs période changent activeselected', async ({ page }) => {
|
||||
await page.goto('/departement/75');
|
||||
const tab7 = page.getByRole('tab', { name: '7 jours' });
|
||||
const tab30 = page.getByRole('tab', { name: '30 jours' });
|
||||
await tab30.click();
|
||||
await expect(tab30).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(tab7).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
test('/departement/971 (Guadeloupe) — DROM affiche notice', async ({ page }) => {
|
||||
await page.goto('/departement/971');
|
||||
await expect(page.getByRole('heading', { name: 'Guadeloupe' })).toBeVisible();
|
||||
await expect(page.getByText(/Vigilance Outre-mer non couverte/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('Page dept invalide retourne 404', async ({ page }) => {
|
||||
const res = await page.goto('/departement/999');
|
||||
expect(res?.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
24
tests/e2e/embed.spec.ts
Normal file
24
tests/e2e/embed.spec.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Widget embed', () => {
|
||||
test('/embed/dept/75 charge avec Paris', async ({ page }) => {
|
||||
await page.goto('/embed/dept/75');
|
||||
await expect(page.locator('h1')).toHaveText('Paris');
|
||||
// Embed minimal : pas de header global Info Canicule
|
||||
await expect(page.getByRole('link', { name: 'Soutenir' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('/embed/dept/971 — DROM notice', async ({ page }) => {
|
||||
await page.goto('/embed/dept/971');
|
||||
await expect(page.locator('h1')).toHaveText('Guadeloupe');
|
||||
await expect(page.getByText(/Outre-mer non couverte/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('embed servi avec X-Frame-Options non bloquant', async ({ request }) => {
|
||||
const res = await request.get('/embed/dept/75');
|
||||
expect(res.status()).toBe(200);
|
||||
const xfo = res.headers()['x-frame-options'];
|
||||
// Soit absent (autorisé), soit SAMEORIGIN (pas DENY)
|
||||
expect(xfo === undefined || xfo.toUpperCase() !== 'DENY').toBe(true);
|
||||
});
|
||||
});
|
||||
35
tests/e2e/home.spec.ts
Normal file
35
tests/e2e/home.spec.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Home', () => {
|
||||
test('charge avec carte SVG 96 départements', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/Info Canicule/);
|
||||
const paths = page.locator('#france-map path[data-code]');
|
||||
await expect(paths).toHaveCount(96);
|
||||
});
|
||||
|
||||
test('navigation principale visible', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('link', { name: /Carte/i }).first()).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Conseils/i }).first()).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: /Soutenir/i }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('section "Départements en alerte" si alertes du jour', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Soit la section existe avec alertes, soit pas du tout (jour calme) — ne casse pas si vide
|
||||
const section = page.getByRole('heading', { name: /Départements en alerte/i });
|
||||
if (await section.count() > 0) {
|
||||
await expect(section).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('tooltip carte au hover', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const tooltip = page.locator('#map-tooltip');
|
||||
await expect(tooltip).toBeHidden();
|
||||
await page.locator('#france-map path[data-code="75"]').hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip).toContainText('Paris');
|
||||
});
|
||||
});
|
||||
39
tests/e2e/static-pages.spec.ts
Normal file
39
tests/e2e/static-pages.spec.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const PAGES = [
|
||||
{ url: '/a-propos', title: /À propos/i },
|
||||
{ url: '/mentions-legales', title: /Mentions légales/i },
|
||||
{ url: '/dependances', title: /Dépendances/i },
|
||||
{ url: '/soutenir', title: /Soutenir/i },
|
||||
{ url: '/conseils', title: /Conseils officiels/i },
|
||||
{ url: '/conseils/registre-canicule', title: /Registre canicule/i },
|
||||
{ url: '/embed', title: /Widget intégrable/i },
|
||||
{ url: '/robots.txt', text: /Sitemap:/ },
|
||||
{ url: '/sitemap-departements.xml', text: /<urlset/ },
|
||||
];
|
||||
|
||||
for (const p of PAGES) {
|
||||
test(`page ${p.url} répond 200`, async ({ request, page }) => {
|
||||
if ('text' in p && p.text) {
|
||||
const res = await request.get(p.url);
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.text();
|
||||
expect(body).toMatch(p.text);
|
||||
} else if ('title' in p && p.title) {
|
||||
await page.goto(p.url);
|
||||
await expect(page.locator('h1').first()).toHaveText(p.title);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('Ko-fi link sur /soutenir', async ({ page }) => {
|
||||
await page.goto('/soutenir');
|
||||
const link = page.getByRole('link', { name: /Soutenir sur Ko-fi/i }).first();
|
||||
await expect(link).toHaveAttribute('href', /ko-fi\.com\/daelwizhit/);
|
||||
});
|
||||
|
||||
test('Mentions légales — distinction Nocleus/perso', async ({ page }) => {
|
||||
await page.goto('/mentions-legales');
|
||||
await expect(page.getByText(/micro-entreprise/i)).toBeVisible();
|
||||
await expect(page.getByText(/non lucratif/i).first()).toBeVisible();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue