Compare commits

..
Author SHA1 Message Date
Johan LEROY 7e504bee72 docs(frontend): documente la vue recommandations
Route, paramètres ?site= et ?alert=, jointure côté client et sa raison (pas de
site_id sur une recommandation, aucun filtre sur GET /recommendations,
/alerts non paginé), génération réservée aux admins, points d'entrée.
2026-09-21 14:41:56 +02:00
Johan LEROY 32155cbfc5 feat(frontend): section recommandations sur la vue détail d'un site
La vue détail pose app-recommendation-list restreint au site consulté (les
alertes sont demandées avec site_id) et renvoie vers la vue complète préfiltrée
sur ce site. Le spec fournit les deux services du composant enfant et vérifie le
filtre transmis.
2026-09-21 14:41:56 +02:00
Johan LEROY d16de84559 feat(frontend): vue /recommendations avec filtre site, focus ?alert= et génération admin
Page derrière authGuard, ouverte à tous les rôles : filtre site (.form-select,
présélectionné par ?site=), focus sur une alerte par ?alert= (entier strictement
positif, sinon ignoré), et pour les admins un bouton « Générer les
recommandations » qui appelle POST /recommendations/generate sur le site filtré,
affiche le bilan accordé en nombre et recharge la liste. Route ajoutée dans
app.routes.ts, lien « Recommandations » dans la navigation du tableau de bord.
2026-09-21 14:41:33 +02:00
Johan LEROY 90639b5618 feat(frontend): composant app-recommendation-list, jointure alertes/recommandations
Une recommandation ne porte que alert_id et GET /recommendations n'a aucun
filtre : le composant charge en parallèle les alertes (filtrées par site quand
`siteId` est fourni) et toutes les recommandations, puis les joint côté client
(joinByAlert, fonction pure testée à part) en groupes par alerte, du plus récent
au plus ancien. Chaque groupe montre le contexte de l'alerte (sévérité, type,
site, horodatage, message) puis ses actions avec l'explication et la règle.
L'input `alertId` réduit la vue à une alerte et la met en évidence ; `reload()`
rejoue les deux appels. Un échec de l'un des deux vide tout : une demi-jointure
tromperait.
2026-09-21 14:39:56 +02:00
Johan LEROY e147ea69bc feat(frontend): modèle et service Recommendations (liste, détail, génération)
Types alignés sur RecommendationResponse et RecommendationGenerationResponse
du backend. RecommendationsService couvre GET /recommendations,
GET /recommendations/{id} et POST /recommendations/generate?site_id= (réservé
admin côté API). recommendation-presentation.ts traduit les sept règles connues
du moteur et retombe sur la référence brute pour une règle inconnue, la
politique de renommage en -v2 de l'ADR 0006 l'impose.
2026-09-21 14:39:56 +02:00
Johan LEROY 83caa9006e chore(frontend): active strict, ajoute .form-select et documente le spec ciblé
Frontend / Audit des dépendances (push) Successful in 5s
SonarQube / build-back (push) Successful in 1m6s
Frontend / build (push) Successful in 9m47s
SonarQube / test-ml (push) Failing after 2m2s
SonarQube / build-front (push) Successful in 10m5s
SonarQube / test-back (push) Failing after 49s
Frontend / test (push) Failing after 5m5s
SonarQube / test-front (push) Failing after 5m3s
SonarQube / SonarQube (push) Skipped
- tsconfig.json : "strict": true, vérifié sans erreur sur app et specs
- _forms.scss : .form-select, select natif habillé comme .form-input avec un
  chevron, documenté dans le design système
- TESTING.md : commande pour jouer un seul fichier ou dossier de specs
2026-09-21 14:25:30 +02:00
Johan LEROY 91752a2b0b fix(frontend): aligne le modèle Alert et AlertsService sur le contrat /alerts
Le modèle front portait un alert_id texte, des value/threshold non nullables et
ignorait metric et prediction_id, alors que l'API sérialise un entier, des
flottants nullables et ces deux champs. Toute comparaison avec l'alert_id d'une
recommandation échouait silencieusement.

- alert.model.ts : alert_id number, value/threshold/metric/prediction_id nullables
- alerts.service.ts : getAlerts(filters) pose site_id et severity en HttpParams,
  les deux filtres que l'API accepte
- alerts.fixture.ts : réaligné sur le contrat (ids entiers, horodatages UTC,
  champs nuls sur outage/sensor, un cas spike)
- alert-presentation.ts : tons, libellés et unités partagés ; low passe en
  neutre, le vert se lisait comme un état sain
2026-09-21 14:25:30 +02:00
Dorian 3c378c177f ci(ml,etl): analyse ml/ et etl/airflow dans SonarCloud avec un rapport de couverture ML 2026-09-21 14:12:38 +02:00
Dorian 2adfdf0eb0 fix(backend): supprime les vulnerabilites Sonar du Dockerfile et allege les tests d'exception 2026-09-21 14:08:04 +02:00
PhyriosandGitHub 44f3416ffe Merge branch 'main' into dev 2026-09-21 13:48:50 +02:00
Johan LEROYandGitHub 342128ccff Merge pull request #121 from ineszang/docs/livrables-ec03-ec06
docs(architecture,ml): vue CI/CD, ML-START.md et SAST Bandit
2026-09-21 13:45:46 +02:00
PhyriosandGitHub c3fd9327ea Definition des jalons 2026-09-14 11:30:01 +02:00
46 changed files with 1846 additions and 362 deletions
+38 -1
View File
@@ -5,11 +5,15 @@ on:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
pull_request:
paths:
- "apps/frontend/**"
- "apps/backend/**"
- "ml/**"
- "etl/airflow/**"
- ".github/workflows/sonarqube.yml"
@@ -108,8 +112,36 @@ jobs:
name: backend-coverage
path: apps/backend/coverage.xml
test-ml:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Installe uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: ml/uv.lock
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: ml
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --all-groups --frozen
working-directory: ml
- name: Lancement des tests et génération du rapport de couverture (ML)
run: uv run pytest --cov-report=xml
working-directory: ml
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: ml-coverage
path: ml/coverage.xml
sonarqube:
needs: [build-front, build-back, test-front, test-back]
needs: [build-front, build-back, test-front, test-back, test-ml]
name: SonarQube
runs-on: ubuntu-latest
steps:
@@ -126,6 +158,11 @@ jobs:
with:
name: backend-coverage
path: apps/backend
- name: Téléchargement du rapport de couverture (ML)
uses: actions/download-artifact@v4
with:
name: ml-coverage
path: ml
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v8
env:
+5 -4
View File
@@ -11,13 +11,14 @@ WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
uv sync --locked --no-install-project --no-dev --no-build
# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le
# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire
# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que
# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541).
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
FROM python:3.14-slim AS runtime
+29 -19
View File
@@ -112,8 +112,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches(
)
await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
async with savepoint:
await data_connection.execute(statement)
@@ -147,9 +149,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail(
}
values.update(changes)
statement = insert(Reading).values(**values)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
await data_connection.execute(insert(Reading).values(**values))
async with savepoint:
await data_connection.execute(statement)
async def test_prediction_requires_period_when_energy_is_predicted(
@@ -164,8 +169,10 @@ async def test_prediction_requires_period_when_energy_is_predicted(
model_reference="test-model/1",
)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
async with savepoint:
await data_connection.execute(statement)
@@ -212,21 +219,22 @@ async def test_alert_rejects_prediction_when_site_differs(
)
).scalar_one()
statement = insert(Alert).values(
source_alert_id=str(uuid4()),
site_id=other_site,
source="enervision",
timestamp=MOMENT,
type="spike",
severity="high",
message="Test",
prediction_id=prediction_id,
raw_data={},
)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
await data_connection.execute(
insert(Alert).values(
source_alert_id=str(uuid4()),
site_id=other_site,
source="enervision",
timestamp=MOMENT,
type="spike",
severity="high",
message="Test",
prediction_id=prediction_id,
raw_data={},
)
)
async with savepoint:
await data_connection.execute(statement)
async def test_recommendation_is_unique_when_alert_and_rule_match(
@@ -256,6 +264,8 @@ async def test_recommendation_is_unique_when_alert_and_rule_match(
)
await data_connection.execute(statement)
savepoint = data_connection.begin_nested()
with pytest.raises(IntegrityError):
async with data_connection.begin_nested():
async with savepoint:
await data_connection.execute(statement)
+245 -239
View File
@@ -1,239 +1,245 @@
import hashlib
import json
import pandas as pd
import pytest
from app.etl.historical_import import (
SOURCE_NAME,
build_reading_batch,
classify_quality,
compute_sha256,
load_metadata,
normalize_timestamps,
validate_source,
)
def make_metadata() -> dict:
return {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
def make_dataframe() -> pd.DataFrame:
return pd.DataFrame(
[
{
"timestamp": "2023-01-01 00:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 10.5,
"consumption_euros": 2.5,
"temperature_celsius": 20.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"hour": 0,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
{
"timestamp": "2023-01-01 01:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 11.0,
"consumption_euros": 2.7,
"temperature_celsius": 19.5,
"humidity_percent": 52.0,
"solar_irradiance_wm2": 0.0,
"hour": 1,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
]
)
def test_compute_sha256(tmp_path):
file_path = tmp_path / "dataset.csv"
content = b"hello-enervision"
file_path.write_bytes(content)
expected = hashlib.sha256(content).hexdigest()
assert compute_sha256(file_path) == expected
def test_load_metadata(tmp_path):
metadata_path = tmp_path / "metadata.json"
metadata = {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
metadata_path.write_text(
json.dumps(metadata),
encoding="utf-8",
)
assert load_metadata(metadata_path) == metadata
def test_validate_source_accepts_valid_dataset():
frame = make_dataframe()
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_missing_column():
frame = make_dataframe().drop(columns=["consumption_kwh"])
with pytest.raises(
ValueError,
match="Colonnes obligatoires absentes",
):
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_duplicates():
frame = make_dataframe()
frame.loc[1, "timestamp"] = frame.loc[
0,
"timestamp",
]
with pytest.raises(
ValueError,
match="doublons",
):
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_unknown_site():
frame = make_dataframe()
frame.loc[1, "site_id"] = "SITE999"
with pytest.raises(
ValueError,
match="Sites incohérents",
):
validate_source(
frame,
make_metadata(),
)
def test_normalize_timestamps_adds_timezone():
frame = make_dataframe()
normalized = normalize_timestamps(
frame,
"UTC",
)
assert normalized["timestamp"].dt.tz is not None
assert "_source_timestamp" in normalized.columns
def test_classify_quality_good():
row = make_dataframe().iloc[0].to_dict()
quality, reasons = classify_quality(row)
assert quality == "good"
assert reasons == []
def test_classify_quality_degraded_when_consumption_missing():
row = make_dataframe().iloc[0].to_dict()
row["consumption_kwh"] = None
quality, reasons = classify_quality(row)
assert quality == "degraded"
assert "missing:consumption_kwh" in reasons
def test_build_reading_batch_respects_database_contract():
frame = normalize_timestamps(
make_dataframe(),
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
assert len(rows) == 1
row = rows[0]
assert row["dataset_id"] == 3
# Important :
# contrainte ck_reading_dataset_source.
assert row["source"] == "csv"
assert SOURCE_NAME == "csv"
# Important :
# contrainte ck_reading_imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
assert row["data_quality"] == "good"
assert row["null_reasons"] == []
def test_build_reading_batch_keeps_missing_values():
frame = make_dataframe()
frame.loc[0, "temperature_celsius"] = None
frame = normalize_timestamps(
frame,
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
row = rows[0]
assert row["temperature_celsius"] is None
assert "missing:temperature_celsius" in row["null_reasons"]
# RAW ingestion : aucune imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
import hashlib
import json
import pandas as pd
import pytest
from app.etl.historical_import import (
SOURCE_NAME,
build_reading_batch,
classify_quality,
compute_sha256,
load_metadata,
normalize_timestamps,
validate_source,
)
def make_metadata() -> dict:
return {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
def make_dataframe() -> pd.DataFrame:
return pd.DataFrame(
[
{
"timestamp": "2023-01-01 00:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 10.5,
"consumption_euros": 2.5,
"temperature_celsius": 20.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"hour": 0,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
{
"timestamp": "2023-01-01 01:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 11.0,
"consumption_euros": 2.7,
"temperature_celsius": 19.5,
"humidity_percent": 52.0,
"solar_irradiance_wm2": 0.0,
"hour": 1,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
]
)
def test_compute_sha256(tmp_path):
file_path = tmp_path / "dataset.csv"
content = b"hello-enervision"
file_path.write_bytes(content)
expected = hashlib.sha256(content).hexdigest()
assert compute_sha256(file_path) == expected
def test_load_metadata(tmp_path):
metadata_path = tmp_path / "metadata.json"
metadata = {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
metadata_path.write_text(
json.dumps(metadata),
encoding="utf-8",
)
assert load_metadata(metadata_path) == metadata
def test_validate_source_accepts_valid_dataset():
frame = make_dataframe()
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_missing_column():
frame = make_dataframe().drop(columns=["consumption_kwh"])
metadata = make_metadata()
with pytest.raises(
ValueError,
match="Colonnes obligatoires absentes",
):
validate_source(
frame,
metadata,
)
def test_validate_source_rejects_duplicates():
frame = make_dataframe()
frame.loc[1, "timestamp"] = frame.loc[
0,
"timestamp",
]
metadata = make_metadata()
with pytest.raises(
ValueError,
match="doublons",
):
validate_source(
frame,
metadata,
)
def test_validate_source_rejects_unknown_site():
frame = make_dataframe()
frame.loc[1, "site_id"] = "SITE999"
metadata = make_metadata()
with pytest.raises(
ValueError,
match="Sites incohérents",
):
validate_source(
frame,
metadata,
)
def test_normalize_timestamps_adds_timezone():
frame = make_dataframe()
normalized = normalize_timestamps(
frame,
"UTC",
)
assert normalized["timestamp"].dt.tz is not None
assert "_source_timestamp" in normalized.columns
def test_classify_quality_good():
row = make_dataframe().iloc[0].to_dict()
quality, reasons = classify_quality(row)
assert quality == "good"
assert reasons == []
def test_classify_quality_degraded_when_consumption_missing():
row = make_dataframe().iloc[0].to_dict()
row["consumption_kwh"] = None
quality, reasons = classify_quality(row)
assert quality == "degraded"
assert "missing:consumption_kwh" in reasons
def test_build_reading_batch_respects_database_contract():
frame = normalize_timestamps(
make_dataframe(),
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
assert len(rows) == 1
row = rows[0]
assert row["dataset_id"] == 3
# Important :
# contrainte ck_reading_dataset_source.
assert row["source"] == "csv"
assert SOURCE_NAME == "csv"
# Important :
# contrainte ck_reading_imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
assert row["data_quality"] == "good"
assert row["null_reasons"] == []
def test_build_reading_batch_keeps_missing_values():
frame = make_dataframe()
frame.loc[0, "temperature_celsius"] = None
frame = normalize_timestamps(
frame,
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
row = rows[0]
assert row["temperature_celsius"] is None
assert "missing:temperature_celsius" in row["null_reasons"]
# RAW ingestion : aucune imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
@@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log(
) -> None:
await une_ligne(session)
requete = text(instruction)
with pytest.raises(DBAPIError, match="ajout seul"):
await session.execute(text(instruction))
await session.execute(requete)
await session.rollback()
@@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
user_agent=None,
)
empreinte = fingerprint_refresh(secret)
expiration = datetime.now(UTC) + DUREE
with pytest.raises(IntegrityError):
await depot.create(
user_id=compte,
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + DUREE,
token_hash=empreinte,
expires_at=expiration,
client_ip=None,
user_agent=None,
)
@@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
user_agent=None,
)
famille = uuid.uuid4()
empreinte = fingerprint_refresh(secret)
expiration = datetime.now(UTC) + DUREE
with pytest.raises(IntegrityError):
await depot.create(
user_id=compte,
family_id=uuid.uuid4(),
token_hash=fingerprint_refresh(secret),
expires_at=datetime.now(UTC) + DUREE,
family_id=famille,
token_hash=empreinte,
expires_at=expiration,
client_ip=None,
user_agent=None,
)
+5 -7
View File
@@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case(
) -> None:
saisie = adresse().upper()
requete = text(
"insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')"
)
with pytest.raises(IntegrityError):
await session.execute(
text(
"insert into app_user (email, password_hash, role) "
"values (:e, '$argon2id$x', 'lecteur')"
),
{"e": saisie},
)
await session.execute(requete, {"e": saisie})
await session.rollback()
+4 -6
View File
@@ -116,13 +116,11 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
async def test_list_history_raises_when_start_is_after_end() -> None:
service = ReadingService(readings=FakeRepository([]))
debut = datetime(2026, 9, 2, tzinfo=UTC)
fin = datetime(2026, 9, 1, tzinfo=UTC)
with pytest.raises(FenetreInverseeError):
await service.list_history(
start=datetime(2026, 9, 2, tzinfo=UTC),
end=datetime(2026, 9, 1, tzinfo=UTC),
limit=500,
offset=0,
)
await service.list_history(start=debut, end=fin, limit=500, offset=0)
async def test_list_history_raises_when_start_equals_end() -> None:
+3 -1
View File
@@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
if action == "set_active":
arguments["is_active"] = False
methode = getattr(attirail.service, action)
with pytest.raises(UserNotFoundError):
await getattr(attirail.service, action)(**arguments)
await methode(**arguments)
+6 -2
View File
@@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None:
def test_build_parser_requires_a_subcommand() -> None:
parser = cli.build_parser()
with pytest.raises(SystemExit):
cli.build_parser().parse_args([])
parser.parse_args([])
def test_build_parser_requires_an_email() -> None:
parser = cli.build_parser()
with pytest.raises(SystemExit):
cli.build_parser().parse_args(["create-admin"])
parser.parse_args(["create-admin"])
def test_read_password_generates_a_long_secret_when_asked(
+4 -1
View File
@@ -1,4 +1,4 @@
# Conventions de tests unitaires — Frontend
# Conventions de tests unitaires : Frontend
## Outil
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
@@ -83,3 +83,6 @@ describe('MonComposant', () => {
## Lancer les tests
- Développement (mode watch) : `npm test`
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
- Un fichier ou un dossier seulement :
`npx ng test --watch=false --coverage=false --include=src/app/core/services/alerts.service.spec.ts`
(répéter `--include` pour plusieurs cibles ; un dossier joue tous ses specs)
+28 -7
View File
@@ -1,21 +1,36 @@
import { Routes } from '@angular/router';
import {authGuard} from './core/guards/auth-guard';
import { authGuard } from './core/guards/auth-guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{ path: 'forgot-password', loadComponent: () => import('./features/auth/forgot-password/forgot-password').then(m => m.ForgotPassword) },
{ path: 'reset-password', loadComponent: () => import('./features/auth/reset-password/reset-password').then(m => m.ResetPassword) },
{
path: 'login',
loadComponent: () => import('./features/auth/login/login').then((m) => m.Login),
},
{
path: 'change-password',
loadComponent: () =>
import('./features/auth/change-password/change-password').then((m) => m.ChangePassword),
},
{
path: 'forgot-password',
loadComponent: () =>
import('./features/auth/forgot-password/forgot-password').then((m) => m.ForgotPassword),
},
{
path: 'reset-password',
loadComponent: () =>
import('./features/auth/reset-password/reset-password').then((m) => m.ResetPassword),
},
{
path: 'dashboard',
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
},
{
path: 'sites',
canActivate: [authGuard],
loadComponent: () => import('./features/sites/site-list/site-list').then(m => m.SiteList),
loadComponent: () => import('./features/sites/site-list/site-list').then((m) => m.SiteList),
},
{
path: 'sites/:siteId',
@@ -23,6 +38,12 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
},
{
path: 'recommendations',
canActivate: [authGuard],
loadComponent: () =>
import('./features/recommendations/recommendations').then((m) => m.RecommendationsView),
},
{
path: 'monitoring/sensors',
canActivate: [authGuard],
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [
{
alert_id: 'ALR-SITE002-1718458320',
timestamp: '2026-09-15T11:12:00',
alert_id: 5,
site_id: 'SITE002',
timestamp: '2026-09-15T11:12:00Z',
type: 'threshold',
severity: 'critical',
type: 'outage',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 'ALR-SITE003-1718458321',
timestamp: '2026-09-15T11:05:00',
alert_id: 4,
site_id: 'SITE003',
timestamp: '2026-09-15T11:05:00Z',
type: 'outage',
severity: 'critical',
type: 'sensor',
message: 'Perte réseau totale sur Data Center Marseille',
value: 0,
threshold: 0,
message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
},
{
alert_id: 'ALR-SITE005-1718458322',
timestamp: '2026-09-15T10:47:00',
alert_id: 3,
site_id: 'SITE005',
timestamp: '2026-09-15T10:47:00Z',
type: 'spike',
severity: 'high',
type: 'threshold',
message: 'Usine Toulouse approche de son seuil de capacité',
message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
value: 410.0,
threshold: 480.0,
threshold: 260.0,
metric: 'consumption_kw',
prediction_id: null,
},
{
alert_id: 'ALR-SITE006-1718458323',
timestamp: '2026-09-15T10:30:00',
alert_id: 2,
site_id: 'SITE006',
severity: 'medium',
timestamp: '2026-09-15T10:30:00Z',
type: 'sensor',
message: 'Capteur de température défaillant sur Bureau Lille',
value: 0,
threshold: 0,
severity: 'medium',
message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
value: null,
threshold: null,
metric: null,
prediction_id: null,
},
{
alert_id: 'ALR-SITE004-1718458324',
timestamp: '2026-09-15T09:58:00',
alert_id: 1,
site_id: 'SITE004',
severity: 'low',
timestamp: '2026-09-15T09:58:00Z',
type: 'anomaly',
message: 'Comportement de consommation inhabituel sur Bureau Bordeaux',
severity: 'low',
message: 'Écart de 13% entre la consommation mesurée (62.0 kWh) et la prévision (55.0 kWh)',
value: 62.0,
threshold: 55.0,
metric: 'consumption_kwh',
prediction_id: 42,
},
];
@@ -3,6 +3,20 @@ import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
const ALERT_API: Alert = {
alert_id: 1,
site_id: 'site-1',
timestamp: '2026-09-16T00:00:00Z',
type: 'threshold',
severity: 'high',
message: 'Dépassement du seuil configuré',
value: 812.5,
threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
};
describe('AlertsService', () => {
let service: AlertsService;
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
afterEach(() => httpMock.verify());
it("appelle le bon endpoint et retourne un tableau d'alertes", () => {
let result: unknown;
it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
let result: Alert[] = [];
service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`);
expect(req.request.method).toBe('GET');
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
);
expect(req.request.params.keys()).toEqual([]);
req.flush([ALERT_API]);
req.flush([
{
alert_id: 'ALR-TEST-1',
timestamp: '2026-09-15T12:00:00',
site_id: 'SITE001',
severity: 'high',
type: 'threshold',
message: 'Test',
value: 100,
threshold: 90,
},
]);
expect(result.length).toBe(1);
expect(result[0].alert_id).toBe(1);
expect(result[0].prediction_id).toBeNull();
});
expect((result as unknown[]).length).toBe(1);
it('transmet les filtres site_id et severity en paramètres de requête', () => {
service.getAlerts({ site_id: 'SITE001', severity: 'high' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.params.get('severity')).toBe('high');
req.flush([]);
});
it('ne pose pas de paramètre pour un filtre omis', () => {
service.getAlerts({ site_id: 'SITE001' }).subscribe();
const req = httpMock.expectOne((r) => r.url === `${environment.apiUrl}/alerts`);
expect(req.request.params.has('severity')).toBe(false);
req.flush([]);
});
});
@@ -1,13 +1,25 @@
import { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import { Alert } from '../../shared/models/alert.model';
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
export interface AlertFilters {
site_id?: string;
severity?: AlertSeverity;
}
@Service()
export class AlertsService {
private http = inject(HttpClient);
getAlerts() {
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`);
getAlerts(filters: AlertFilters = {}) {
let params = new HttpParams();
if (filters.site_id) {
params = params.set('site_id', filters.site_id);
}
if (filters.severity) {
params = params.set('severity', filters.severity);
}
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`, { params });
}
}
@@ -0,0 +1,74 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { RecommendationsService } from './recommendations.service';
import { environment } from '../../../environments/environment';
import { Recommendation } from '../../shared/models/recommendation.model';
const RECOMMANDATION_API: Recommendation = {
recommendation_id: 1,
alert_id: 1,
action: 'Vérifier la consommation',
explanation: 'Pic détecté',
rule_reference: 'spike-v1',
created_at: '2024-01-01T00:00:00Z',
};
describe('RecommendationsService', () => {
let service: RecommendationsService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(RecommendationsService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('liste les recommandations depuis le bon endpoint', () => {
let result: Recommendation[] = [];
service.getRecommendations().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`);
expect(req.request.method).toBe('GET');
req.flush([RECOMMANDATION_API]);
expect(result.length).toBe(1);
expect(result[0].alert_id).toBe(1);
});
it('décrit une recommandation par son identifiant', () => {
service.getRecommendation(42).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`);
expect(req.request.method).toBe('GET');
req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 });
});
it('déclenche la génération en POST avec le site en paramètre de requête', () => {
let result: unknown;
service.generate('SITE001').subscribe((r) => (result = r));
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
);
expect(req.request.params.get('site_id')).toBe('SITE001');
expect(req.request.body).toBeNull();
req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 });
});
it('génère pour tout le parc quand aucun site n’est donné', () => {
service.generate().subscribe();
const req = httpMock.expectOne(
(r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST',
);
expect(req.request.params.has('site_id')).toBe(false);
req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 });
});
});
@@ -0,0 +1,34 @@
import { Service, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment';
import {
Recommendation,
RecommendationGenerationReport,
} from '../../shared/models/recommendation.model';
@Service()
export class RecommendationsService {
private http = inject(HttpClient);
getRecommendations() {
return this.http.get<Recommendation[]>(`${environment.apiUrl}/recommendations`);
}
getRecommendation(recommendationId: number) {
return this.http.get<Recommendation>(
`${environment.apiUrl}/recommendations/${recommendationId}`,
);
}
generate(siteId?: string) {
let params = new HttpParams();
if (siteId) {
params = params.set('site_id', siteId);
}
return this.http.post<RecommendationGenerationReport>(
`${environment.apiUrl}/recommendations/generate`,
null,
{ params },
);
}
}
@@ -14,6 +14,7 @@
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
}
<a routerLink="/sites" class="ev-link">Voir les sites</a>
<a routerLink="/recommendations" class="ev-link">Recommandations</a>
<ev-button
class="logout-button"
variant="secondary"
@@ -0,0 +1,58 @@
<div class="recommendations">
<nav class="ev-breadcrumb">
<a routerLink="/dashboard">Tableau de bord</a>
</nav>
<header class="recommendations__header">
<a routerLink="/dashboard" class="ev-brand-link">
<ev-brand class="recommendations__logo" />
</a>
<div>
<h1>Recommandations</h1>
<p class="recommendations__subtitle">
Actions proposées par le moteur de règles à partir des alertes
</p>
</div>
</header>
<div class="recommendations__toolbar">
<label class="recommendations__filter">
<span class="form-label">Site</span>
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
<option value="" [selected]="!siteFilter()">Tous les sites</option>
@for (site of sites(); track site.site_id) {
<option [value]="site.site_id" [selected]="site.site_id === siteFilter()">
{{ site.site_name }}
</option>
}
</select>
</label>
@if (isAdmin()) {
<ev-button
variant="secondary"
[fullWidth]="false"
[disabled]="generating()"
data-testid="generate"
(click)="onGenerate()"
>
{{ generating() ? 'Génération en cours…' : 'Générer les recommandations' }}
</ev-button>
}
</div>
@if (generationReport(); as report) {
<ev-alert severity="success" class="recommendations__banner">{{ bilan(report) }}.</ev-alert>
}
@if (generationError(); as message) {
<ev-alert severity="danger" class="recommendations__banner">{{ message }}</ev-alert>
}
@if (alertId(); as id) {
<p class="recommendations__focus">
Alerte n° {{ id }} ·
<a routerLink="/recommendations" class="ev-link">Toutes les recommandations</a>
</p>
}
<app-recommendation-list [siteId]="siteFilter()" [alertId]="alertId()" [sites]="sites()" />
</div>
@@ -0,0 +1,59 @@
:host {
display: block;
color: var(--color-text);
padding: 2.5rem 2rem;
max-width: 1100px;
margin: 0 auto;
}
.recommendations__header {
display: flex;
align-items: center;
gap: 0.85rem;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.recommendations__logo {
font-size: 1.3rem;
}
.recommendations__subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted);
}
.recommendations__toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.recommendations__filter {
display: flex;
flex-direction: column;
min-width: 14rem;
.form-label {
margin-top: 0;
}
}
.recommendations__banner {
display: block;
margin-bottom: var(--space-3);
}
.recommendations__focus {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
color: var(--color-text-muted);
}
@@ -0,0 +1,169 @@
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { BehaviorSubject, of, throwError } from 'rxjs';
import { RecommendationsView, parseAlertId } from './recommendations';
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
import { SitesService } from '../../core/services/sites.service';
import { AlertsService } from '../../core/services/alerts.service';
import { RecommendationsService } from '../../core/services/recommendations.service';
import { AuthService } from '../../core/services/auth.service';
const SITES = [
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
];
const BILAN = { alerts_examined: 2, recommendations_created: 3, already_present: 1 };
function setup(options: { query?: Record<string, string>; role?: string } = {}) {
const query = options.query ?? {};
const queryParamMap = new BehaviorSubject(convertToParamMap(query));
const generate = vi.fn().mockReturnValue(of(BILAN));
const getRecommendations = vi.fn().mockReturnValue(of([]));
const getAlerts = vi.fn().mockReturnValue(of([]));
TestBed.configureTestingModule({
imports: [RecommendationsView],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: { queryParamMap, snapshot: { queryParamMap: convertToParamMap(query) } },
},
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of(SITES)) } },
{ provide: AlertsService, useValue: { getAlerts } },
{ provide: RecommendationsService, useValue: { getRecommendations, generate } },
{
provide: AuthService,
useValue: { principal: vi.fn().mockReturnValue({ role: options.role ?? 'lecteur' }) },
},
],
});
const fixture = TestBed.createComponent(RecommendationsView);
fixture.detectChanges();
fixture.detectChanges();
return { fixture, queryParamMap, generate, getRecommendations, getAlerts };
}
function listeEnfant(fixture: ReturnType<typeof setup>['fixture']): RecommendationList {
return fixture.debugElement.query(By.directive(RecommendationList)).componentInstance;
}
describe('parseAlertId', () => {
it("n'accepte qu'un entier strictement positif", () => {
expect(parseAlertId('12')).toBe(12);
expect(parseAlertId('0')).toBeNull();
expect(parseAlertId('-3')).toBeNull();
expect(parseAlertId('abc')).toBeNull();
expect(parseAlertId('12abc')).toBeNull();
expect(parseAlertId(null)).toBeNull();
});
});
describe('RecommendationsView', () => {
it("cible l'alerte donnée par ?alert= et la transmet à la liste", () => {
const { fixture } = setup({ query: { alert: '12' } });
expect(fixture.componentInstance.alertId()).toBe(12);
expect(listeEnfant(fixture).alertId()).toBe(12);
expect(fixture.nativeElement.textContent).toContain('Alerte n° 12');
expect(fixture.nativeElement.querySelector('a[href="/recommendations"]')).not.toBeNull();
});
it('ignore un paramètre alert invalide', () => {
const { fixture } = setup({ query: { alert: 'abc' } });
expect(fixture.componentInstance.alertId()).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Alerte n°');
});
it('applique le site donné par ?site= au filtre et à la liste', () => {
const { fixture, getAlerts } = setup({ query: { site: 'SITE001' } });
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
const option = fixture.nativeElement.querySelector(
'option[value="SITE001"]',
) as HTMLOptionElement;
expect(option.selected).toBe(true);
});
it('relance la liste sur le site choisi dans le filtre', () => {
const { fixture, getAlerts } = setup();
const select = fixture.nativeElement.querySelector(
'[data-testid="site-filter"]',
) as HTMLSelectElement;
select.value = 'SITE001';
select.dispatchEvent(new Event('change'));
fixture.detectChanges();
fixture.detectChanges();
expect(getAlerts).toHaveBeenLastCalledWith({ site_id: 'SITE001' });
expect(listeEnfant(fixture).siteId()).toBe('SITE001');
});
it('cache le bouton de génération aux lecteurs', () => {
const { fixture } = setup({ role: 'lecteur' });
expect(fixture.nativeElement.querySelector('[data-testid="generate"]')).toBeNull();
});
it('permet à un admin de générer pour le site filtré, affiche le bilan et recharge la liste', () => {
const { fixture, generate, getRecommendations } = setup({
role: 'admin',
query: { site: 'SITE001' },
});
fixture.nativeElement.querySelector('[data-testid="generate"]').click();
fixture.detectChanges();
fixture.detectChanges();
expect(generate).toHaveBeenCalledWith('SITE001');
expect(fixture.nativeElement.textContent).toContain(
'3 recommandations créées, 1 déjà présente, 2 alertes examinées.',
);
expect(getRecommendations).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.generating()).toBe(false);
});
it('génère pour tout le parc quand aucun site n’est filtré', () => {
const { fixture, generate } = setup({ role: 'admin' });
fixture.componentInstance.onGenerate();
expect(generate).toHaveBeenCalledWith(undefined);
});
it("signale l'échec de la génération sans casser la page", () => {
const { fixture, generate } = setup({ role: 'admin' });
generate.mockReturnValue(throwError(() => new Error('403')));
fixture.componentInstance.onGenerate();
fixture.detectChanges();
expect(fixture.componentInstance.generationError()).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain(
'La génération des recommandations a échoué',
);
expect(fixture.componentInstance.generating()).toBe(false);
});
it('accorde le bilan au singulier', () => {
const { fixture } = setup();
expect(
fixture.componentInstance.bilan({
alerts_examined: 1,
recommendations_created: 1,
already_present: 0,
}),
).toBe('1 recommandation créée, 0 déjà présente, 1 alerte examinée');
});
});
@@ -0,0 +1,85 @@
import { Component, computed, inject, signal, viewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { SitesService } from '../../core/services/sites.service';
import { RecommendationsService } from '../../core/services/recommendations.service';
import { AuthService } from '../../core/services/auth.service';
import { Site } from '../../shared/models/site.model';
import { RecommendationGenerationReport } from '../../shared/models/recommendation.model';
import { RecommendationList } from '../../shared/components/recommendation-list/recommendation-list';
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
import { Brand } from '../../shared/components/ui/brand/brand';
import { Button } from '../../shared/components/ui/button/button';
const GENERATION_FAILED_MESSAGE =
'La génération des recommandations a échoué, réessayez plus tard.';
export function parseAlertId(raw: string | null): number | null {
return raw !== null && /^[1-9]\d*$/.test(raw) ? Number(raw) : null;
}
function pluriel(nombre: number, singulier: string, plurielForme: string): string {
return `${nombre} ${nombre > 1 ? plurielForme : singulier}`;
}
@Component({
selector: 'app-recommendations',
standalone: true,
imports: [RouterLink, RecommendationList, EvAlert, Brand, Button],
templateUrl: './recommendations.html',
styleUrl: './recommendations.scss',
})
export class RecommendationsView {
private route = inject(ActivatedRoute);
private sitesService = inject(SitesService);
private recommendationsService = inject(RecommendationsService);
private auth = inject(AuthService);
alertId = toSignal(
this.route.queryParamMap.pipe(map((params) => parseAlertId(params.get('alert')))),
{ initialValue: null },
);
siteFilter = signal<string | null>(this.route.snapshot.queryParamMap.get('site'));
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
initialValue: [] as Site[],
});
list = viewChild.required(RecommendationList);
isAdmin = computed(() => this.auth.principal()?.role === 'admin');
generating = signal(false);
generationReport = signal<RecommendationGenerationReport | null>(null);
generationError = signal<string | null>(null);
onSiteChange(event: Event): void {
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
}
onGenerate(): void {
if (this.generating()) {
return;
}
this.generating.set(true);
this.generationError.set(null);
this.recommendationsService.generate(this.siteFilter() ?? undefined).subscribe({
next: (report) => {
this.generating.set(false);
this.generationReport.set(report);
this.list().reload();
},
error: () => {
this.generating.set(false);
this.generationError.set(GENERATION_FAILED_MESSAGE);
},
});
}
bilan(report: RecommendationGenerationReport): string {
return [
pluriel(report.recommendations_created, 'recommandation créée', 'recommandations créées'),
pluriel(report.already_present, 'déjà présente', 'déjà présentes'),
pluriel(report.alerts_examined, 'alerte examinée', 'alertes examinées'),
].join(', ');
}
}
@@ -81,5 +81,16 @@
}
}
<section class="recommendations-section">
<h2>Recommandations</h2>
<app-recommendation-list [siteId]="siteId() ?? null" [sites]="siteAsList()" />
<a
routerLink="/recommendations"
[queryParams]="{ site: siteId() }"
class="ev-link recommendations-section__link"
>Voir dans la vue recommandations</a
>
</section>
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
</div>
@@ -117,3 +117,12 @@ h2 {
.chart-section {
margin-bottom: 2rem;
}
.recommendations-section {
margin: 2.5rem 0 1.5rem;
}
.recommendations-section__link {
display: inline-block;
margin-top: 1rem;
}
@@ -5,6 +5,8 @@ import { BehaviorSubject, of, throwError } from 'rxjs';
import { SiteDetail } from './site-detail';
import { SitesService } from '../../../core/services/sites.service';
import { ReadingsService } from '../../../core/services/readings.service';
import { AlertsService } from '../../../core/services/alerts.service';
import { RecommendationsService } from '../../../core/services/recommendations.service';
const SITE = {
site_id: 'SITE001',
@@ -69,6 +71,7 @@ function setup(
readingsMock: Partial<ReadingsService>,
) {
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
const getAlerts = vi.fn().mockReturnValue(of([]));
TestBed.configureTestingModule({
imports: [SiteDetail],
providers: [
@@ -76,9 +79,14 @@ function setup(
{ provide: ActivatedRoute, useValue: { paramMap } },
{ provide: SitesService, useValue: sitesMock },
{ provide: ReadingsService, useValue: readingsMock },
{ provide: AlertsService, useValue: { getAlerts } },
{
provide: RecommendationsService,
useValue: { getRecommendations: vi.fn().mockReturnValue(of([])) },
},
],
});
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
return { fixture: TestBed.createComponent(SiteDetail), paramMap, getAlerts };
}
describe('SiteDetail', () => {
@@ -261,6 +269,27 @@ describe('SiteDetail', () => {
);
});
it('demande les recommandations du site consulté à travers ses alertes', () => {
const { fixture, getAlerts } = setup(
'SITE001',
{
getSite: vi.fn().mockReturnValue(of(SITE)),
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
},
{ getHistory: vi.fn().mockReturnValue(of([])) },
);
fixture.detectChanges();
fixture.detectChanges();
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
expect(fixture.nativeElement.querySelector('app-recommendation-list')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Recommandations');
expect(
fixture.nativeElement.querySelector('a[href="/recommendations?site=SITE001"]'),
).not.toBeNull();
});
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
const getHistory = vi.fn().mockReturnValue(of([]));
const { fixture } = setup(
@@ -13,6 +13,7 @@ import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
import { Brand } from '../../../shared/components/ui/brand/brand';
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
import { RecommendationList } from '../../../shared/components/recommendation-list/recommendation-list';
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
@@ -96,7 +97,16 @@ export interface MetricView {
@Component({
selector: 'app-site-detail',
standalone: true,
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
imports: [
RouterLink,
Card,
Alert,
Badge,
Brand,
ConsumptionGauge,
ReadingHistoryChart,
RecommendationList,
],
templateUrl: './site-detail.html',
styleUrl: './site-detail.scss',
})
@@ -117,6 +127,11 @@ export class SiteDetail {
hasMeasurement = computed(() => this.current()?.timestamp != null);
siteAsList = computed<Site[]>(() => {
const site = this.site();
return site ? [site] : [];
});
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
consumptionLabel = computed(() => {
@@ -0,0 +1,48 @@
@if (error(); as message) {
<ev-alert severity="danger" class="reco-list__banner">{{ message }}</ev-alert>
} @else if (loading() && !hasData()) {
<p class="reco-list__state" aria-live="polite">Chargement des recommandations…</p>
} @else if (visibleGroups().length === 0) {
<ev-alert severity="success" class="reco-list__banner">{{ emptyMessage() }}</ev-alert>
}
<div class="reco-list" [attr.aria-busy]="loading()">
@for (group of visibleGroups(); track group.alert.alert_id) {
<ev-card
class="reco-group"
[class.reco-group--focus]="group.alert.alert_id === alertId()"
[id]="'alerte-' + group.alert.alert_id"
>
<header class="reco-group__alert">
<div class="reco-group__meta">
<ev-badge [tone]="toneFor(group.alert.severity)">{{
severityLabel(group.alert.severity)
}}</ev-badge>
<span class="reco-group__type">{{ typeLabel(group.alert.type) }}</span>
@if (!siteId()) {
<a [routerLink]="['/sites', group.alert.site_id]" class="ev-link">{{
group.siteName
}}</a>
}
<time [attr.datetime]="group.alert.timestamp">{{
group.alert.timestamp | date: 'dd/MM/yyyy HH:mm'
}}</time>
</div>
<p class="reco-group__message">{{ group.alert.message }}</p>
</header>
<ol class="reco-group__items">
@for (reco of group.recommendations; track reco.recommendation_id) {
<li class="reco">
<div class="reco__head">
<strong class="reco__action">{{ reco.action }}</strong>
<ev-badge [tone]="ruleTone(reco.rule_reference)">{{
ruleLabel(reco.rule_reference)
}}</ev-badge>
</div>
<p class="reco__explanation">{{ reco.explanation }}</p>
</li>
}
</ol>
</ev-card>
}
</div>
@@ -0,0 +1,94 @@
:host {
display: block;
}
.reco-list__banner {
display: block;
margin-bottom: var(--space-3);
}
.reco-list__state {
margin: 0 0 var(--space-3);
font-size: 0.9rem;
color: var(--color-text-muted);
}
.reco-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.reco-group {
padding: var(--space-4);
gap: var(--space-3);
}
.reco-group--focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.reco-group__alert {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-light);
}
.reco-group__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
font-size: 0.8rem;
color: var(--color-text-muted);
}
.reco-group__type {
font-weight: 600;
color: var(--color-text);
}
.reco-group__message {
margin: 0;
font-size: 0.9rem;
}
.reco-group__items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.reco {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--color-bg);
border-left: 3px solid var(--color-primary);
}
.reco__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.reco__action {
font-size: 0.95rem;
}
.reco__explanation {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
@@ -0,0 +1,244 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { vi } from 'vitest';
import { NEVER, of, throwError } from 'rxjs';
import { RecommendationList, joinByAlert } from './recommendation-list';
import { AlertsService } from '../../../core/services/alerts.service';
import { RecommendationsService } from '../../../core/services/recommendations.service';
import { Alert } from '../../models/alert.model';
import { Recommendation } from '../../models/recommendation.model';
import { Site } from '../../models/site.model';
const SITES: Site[] = [
{
site_id: 'SITE001',
site_name: 'Usine Nantes',
site_type: 'industriel',
location: 'Nantes',
capacity_kw: 500,
status: 'actif',
},
{
site_id: 'SITE002',
site_name: 'Bureau Lille',
site_type: 'bureau',
location: 'Lille',
capacity_kw: 80,
status: 'actif',
},
];
function alerte(surcharges: Partial<Alert>): Alert {
return {
alert_id: 1,
site_id: 'SITE001',
timestamp: '2026-09-15T09:00:00Z',
type: 'threshold',
severity: 'high',
message: 'Puissance appelée au-dessus de la capacité du site',
value: 812.5,
threshold: 720,
metric: 'consumption_kw',
prediction_id: null,
...surcharges,
};
}
function reco(surcharges: Partial<Recommendation>): Recommendation {
return {
recommendation_id: 1,
alert_id: 1,
action: 'Ramener la puissance appelée sous le seuil contractuel',
explanation: 'Seuil de consommation dépassé sur le site SITE001.',
rule_reference: 'threshold-reduction-v1',
created_at: '2026-09-15T09:05:00Z',
...surcharges,
};
}
const ALERTES: Alert[] = [
alerte({ alert_id: 1, site_id: 'SITE001', timestamp: '2026-09-15T09:00:00Z' }),
alerte({
alert_id: 2,
site_id: 'SITE002',
timestamp: '2026-09-15T11:00:00Z',
severity: 'critical',
type: 'spike',
message: 'Variation brutale entre deux lectures consécutives',
}),
alerte({ alert_id: 3, site_id: 'SITE001', timestamp: '2026-09-15T10:00:00Z', severity: 'low' }),
];
const RECOMMANDATIONS: Recommendation[] = [
reco({
recommendation_id: 3,
alert_id: 2,
action: "Escalader à l'astreinte sous une heure",
rule_reference: 'escalade-astreinte-v1',
}),
reco({ recommendation_id: 1, alert_id: 1 }),
reco({
recommendation_id: 2,
alert_id: 2,
action: 'Délester les équipements non prioritaires sur le créneau du pic',
rule_reference: 'spike-delestage-v1',
}),
reco({ recommendation_id: 4, alert_id: 99, rule_reference: 'orpheline-v1' }),
];
function setup(
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
recosMock: { getRecommendations: ReturnType<typeof vi.fn> },
inputs: Record<string, unknown> = {},
) {
TestBed.configureTestingModule({
imports: [RecommendationList],
providers: [
provideRouter([]),
{ provide: AlertsService, useValue: alertsMock },
{ provide: RecommendationsService, useValue: recosMock },
],
});
const fixture = TestBed.createComponent(RecommendationList);
for (const [nom, valeur] of Object.entries(inputs)) {
fixture.componentRef.setInput(nom, valeur);
}
return fixture;
}
function rendre(fixture: ComponentFixture<RecommendationList>) {
fixture.detectChanges();
fixture.detectChanges();
}
function texte(fixture: ComponentFixture<RecommendationList>): string {
return (fixture.nativeElement as HTMLElement).textContent ?? '';
}
const recosOk = () => ({ getRecommendations: vi.fn().mockReturnValue(of(RECOMMANDATIONS)) });
describe('joinByAlert', () => {
it('groupe par alerte, du plus récent au plus ancien, recommandations par identifiant', () => {
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map([['SITE001', 'Usine Nantes']]));
expect(groupes.map((g) => g.alert.alert_id)).toEqual([2, 1]);
expect(groupes[0].recommendations.map((r) => r.recommendation_id)).toEqual([2, 3]);
expect(groupes[1].siteName).toBe('Usine Nantes');
expect(groupes[0].siteName).toBe('SITE002');
});
it('ignore les alertes sans recommandation et les recommandations orphelines', () => {
const groupes = joinByAlert(ALERTES, RECOMMANDATIONS, new Map());
expect(groupes.some((g) => g.alert.alert_id === 3)).toBe(false);
expect(groupes.flatMap((g) => g.recommendations).some((r) => r.alert_id === 99)).toBe(false);
});
});
describe('RecommendationList', () => {
it('charge alertes et recommandations puis affiche les groupes avec leur contexte', () => {
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
const fixture = setup({ getAlerts }, recosOk(), { sites: SITES });
rendre(fixture);
expect(getAlerts).toHaveBeenCalledWith({});
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(2);
const contenu = texte(fixture);
expect(contenu).toContain('Usine Nantes');
expect(contenu).toContain('Bureau Lille');
expect(contenu).toContain('Critique');
expect(contenu).toContain('Pic de consommation');
expect(contenu).toContain('Escalade astreinte');
expect(contenu).toContain('Délester les équipements');
expect(contenu).toContain('15/09/2026');
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE002"]')).not.toBeNull();
expect(fixture.componentInstance.total()).toBe(3);
expect(fixture.componentInstance.error()).toBeNull();
});
it('filtre les alertes du site côté API et masque le lien vers le site', () => {
const getAlerts = vi.fn().mockReturnValue(of(ALERTES.filter((a) => a.site_id === 'SITE001')));
const fixture = setup({ getAlerts }, recosOk(), { siteId: 'SITE001', sites: SITES });
rendre(fixture);
expect(getAlerts).toHaveBeenCalledWith({ site_id: 'SITE001' });
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(1);
expect(fixture.nativeElement.querySelector('a[href^="/sites/"]')).toBeNull();
});
it("ne garde que le groupe de l'alerte ciblée et le met en évidence", () => {
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
alertId: 2,
});
rendre(fixture);
const groupes = fixture.nativeElement.querySelectorAll('.reco-group');
expect(groupes.length).toBe(1);
expect(groupes[0].classList.contains('reco-group--focus')).toBe(true);
expect(groupes[0].id).toBe('alerte-2');
});
it("annonce l'absence de recommandation pour une alerte inconnue", () => {
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) }, recosOk(), {
alertId: 123,
});
rendre(fixture);
expect(texte(fixture)).toContain('Aucune recommandation pour cette alerte.');
});
it("annonce l'absence de recommandation pour le site consulté", () => {
const fixture = setup(
{ getAlerts: vi.fn().mockReturnValue(of([])) },
{ getRecommendations: vi.fn().mockReturnValue(of([])) },
{ siteId: 'SITE001' },
);
rendre(fixture);
expect(texte(fixture)).toContain('Aucune recommandation pour ce site.');
});
it("signale l'indisponibilité et n'affiche aucun groupe si un des deux appels échoue", () => {
const fixture = setup(
{ getAlerts: vi.fn().mockReturnValue(of(ALERTES)) },
{ getRecommendations: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
);
rendre(fixture);
expect(fixture.componentInstance.error()).not.toBeNull();
expect(fixture.componentInstance.groups()).toEqual([]);
expect(texte(fixture)).toContain('Recommandations indisponibles');
expect(fixture.nativeElement.querySelectorAll('.reco-group').length).toBe(0);
});
it('annonce le chargement tant que la réponse ne vient pas', () => {
const fixture = setup(
{ getAlerts: vi.fn().mockReturnValue(NEVER) },
{ getRecommendations: vi.fn().mockReturnValue(NEVER) },
);
rendre(fixture);
expect(fixture.componentInstance.loading()).toBe(true);
expect(texte(fixture)).toContain('Chargement des recommandations');
});
it('recharge les deux flux à la demande', () => {
const getAlerts = vi.fn().mockReturnValue(of(ALERTES));
const recos = recosOk();
const fixture = setup({ getAlerts }, recos);
rendre(fixture);
fixture.componentInstance.reload();
rendre(fixture);
expect(getAlerts).toHaveBeenCalledTimes(2);
expect(recos.getRecommendations).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,163 @@
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { DatePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { catchError, EMPTY, forkJoin, Observable, switchMap, tap } from 'rxjs';
import { AlertsService } from '../../../core/services/alerts.service';
import { RecommendationsService } from '../../../core/services/recommendations.service';
import { Alert, AlertSeverity, AlertType } from '../../models/alert.model';
import { Recommendation } from '../../models/recommendation.model';
import { Site } from '../../models/site.model';
import {
LIBELLE_PAR_SEVERITE,
LIBELLE_PAR_TYPE,
TON_PAR_SEVERITE,
} from '../../models/alert-presentation';
import { libelleRegle, tonRegle } from '../../models/recommendation-presentation';
import { Card } from '../ui/card/card';
import { Badge, BadgeTone } from '../ui/badge/badge';
import { Alert as EvAlert } from '../ui/alert/alert';
const UNAVAILABLE_MESSAGE = 'Recommandations indisponibles, réessayez plus tard.';
export interface RecommendedAlertView {
alert: Alert;
siteName: string;
recommendations: Recommendation[];
}
interface Chargement {
alerts: Alert[];
recommendations: Recommendation[];
}
// Pourquoi : une recommandation ne porte que alert_id, jamais site_id, et /recommendations n'a
// aucun filtre ; la jointure se fait ici, en O(alertes), acceptable à la taille du jeu de données.
export function joinByAlert(
alerts: Alert[],
recommendations: Recommendation[],
siteNames: Map<string, string>,
): RecommendedAlertView[] {
const parAlerte = new Map<number, Recommendation[]>();
for (const recommandation of recommendations) {
const liste = parAlerte.get(recommandation.alert_id) ?? [];
liste.push(recommandation);
parAlerte.set(recommandation.alert_id, liste);
}
return alerts
.filter((alert) => parAlerte.has(alert.alert_id))
.map((alert) => ({
alert,
siteName: siteNames.get(alert.site_id) ?? alert.site_id,
recommendations: [...(parAlerte.get(alert.alert_id) ?? [])].sort(
(a, b) => a.recommendation_id - b.recommendation_id,
),
}))
.sort((a, b) => Date.parse(b.alert.timestamp) - Date.parse(a.alert.timestamp));
}
@Component({
selector: 'app-recommendation-list',
standalone: true,
imports: [DatePipe, RouterLink, Card, Badge, EvAlert],
templateUrl: './recommendation-list.html',
styleUrl: './recommendation-list.scss',
})
export class RecommendationList {
private alertsService = inject(AlertsService);
private recommendationsService = inject(RecommendationsService);
private destroyRef = inject(DestroyRef);
siteId = input<string | null>(null);
alertId = input<number | null>(null);
sites = input<Site[]>([]);
private data = signal<Chargement | null>(null);
private reloadTick = signal(0);
loading = signal(true);
error = signal<string | null>(null);
private trigger = computed(() => ({ siteId: this.siteId(), tick: this.reloadTick() }));
private siteNameById = computed(
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
);
hasData = computed(() => this.data() !== null);
groups = computed<RecommendedAlertView[]>(() => {
const data = this.data();
return data ? joinByAlert(data.alerts, data.recommendations, this.siteNameById()) : [];
});
visibleGroups = computed(() => {
const alertId = this.alertId();
const groups = this.groups();
return alertId === null ? groups : groups.filter((group) => group.alert.alert_id === alertId);
});
total = computed(() =>
this.visibleGroups().reduce((somme, group) => somme + group.recommendations.length, 0),
);
emptyMessage = computed(() => {
if (this.alertId() !== null) {
return 'Aucune recommandation pour cette alerte.';
}
return this.siteId()
? 'Aucune recommandation pour ce site.'
: 'Aucune recommandation pour le moment.';
});
constructor() {
toObservable(this.trigger)
.pipe(
tap(() => this.loading.set(true)),
switchMap(({ siteId }) =>
forkJoin({
alerts: this.alertsService.getAlerts(siteId ? { site_id: siteId } : {}),
recommendations: this.recommendationsService.getRecommendations(),
}).pipe(catchError(() => this.reportUnavailable())),
),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((data) => {
this.loading.set(false);
this.error.set(null);
this.data.set(data);
});
}
reload(): void {
this.reloadTick.update((tick) => tick + 1);
}
toneFor(severity: AlertSeverity): BadgeTone {
return TON_PAR_SEVERITE[severity];
}
severityLabel(severity: AlertSeverity): string {
return LIBELLE_PAR_SEVERITE[severity];
}
typeLabel(type: AlertType): string {
return LIBELLE_PAR_TYPE[type];
}
ruleLabel(reference: string): string {
return libelleRegle(reference);
}
ruleTone(reference: string): BadgeTone {
return tonRegle(reference);
}
// Piège : vider les données avec l'erreur ; une demi-jointure (alertes sans recommandations,
// ou l'inverse) afficherait des groupes faux plutôt que rien.
private reportUnavailable(): Observable<never> {
this.loading.set(false);
this.error.set(UNAVAILABLE_MESSAGE);
this.data.set(null);
return EMPTY;
}
}
@@ -0,0 +1,37 @@
import {
LIBELLE_PAR_SEVERITE,
LIBELLE_PAR_TYPE,
SEVERITES,
TON_PAR_SEVERITE,
TYPES_ALERTE,
UNITE_PAR_METRIQUE,
} from './alert-presentation';
describe('alert-presentation', () => {
it('distingue le ton des sévérités high et critical', () => {
expect(TON_PAR_SEVERITE.high).toBe('danger');
expect(TON_PAR_SEVERITE.critical).toBe('critical');
expect(TON_PAR_SEVERITE.high).not.toBe(TON_PAR_SEVERITE.critical);
});
it("n'affiche pas une alerte faible avec le ton de succès", () => {
expect(TON_PAR_SEVERITE.low).toBe('neutral');
expect(TON_PAR_SEVERITE.medium).toBe('warning');
});
it('donne un libellé français à chaque sévérité et à chaque type', () => {
for (const severite of SEVERITES) {
expect(LIBELLE_PAR_SEVERITE[severite]).toBeTruthy();
}
for (const type of TYPES_ALERTE) {
expect(LIBELLE_PAR_TYPE[type]).toBeTruthy();
}
expect(SEVERITES.length).toBe(4);
expect(TYPES_ALERTE.length).toBe(5);
});
it('associe une unité à chaque métrique du contrat', () => {
expect(UNITE_PAR_METRIQUE.consumption_kw).toBe('kW');
expect(UNITE_PAR_METRIQUE.consumption_kwh).toBe('kWh');
});
});
@@ -0,0 +1,41 @@
import { BadgeTone } from '../components/ui/badge/badge';
import { AlertMetric, AlertSeverity, AlertType } from './alert.model';
// Pourquoi : `low` en neutre plutôt qu'en vert, une alerte faible reste une alerte ; le vert se
// lisait comme « tout va bien » à côté des rouges.
export const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
low: 'neutral',
medium: 'warning',
high: 'danger',
critical: 'critical',
};
export const LIBELLE_PAR_SEVERITE: Record<AlertSeverity, string> = {
low: 'Faible',
medium: 'Moyenne',
high: 'Élevée',
critical: 'Critique',
};
export const LIBELLE_PAR_TYPE: Record<AlertType, string> = {
spike: 'Pic de consommation',
threshold: 'Seuil dépassé',
anomaly: 'Anomalie',
outage: 'Coupure',
sensor: 'Capteur',
};
export const UNITE_PAR_METRIQUE: Record<AlertMetric, string> = {
consumption_kw: 'kW',
consumption_kwh: 'kWh',
};
export const SEVERITES: readonly AlertSeverity[] = ['low', 'medium', 'high', 'critical'];
export const TYPES_ALERTE: readonly AlertType[] = [
'spike',
'threshold',
'anomaly',
'outage',
'sensor',
];
@@ -1,13 +1,16 @@
export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
export interface Alert {
alert_id: string;
timestamp: string;
alert_id: number;
site_id: string;
severity: AlertSeverity;
timestamp: string;
type: AlertType;
severity: AlertSeverity;
message: string;
value: number;
threshold: number;
value: number | null;
threshold: number | null;
metric: AlertMetric | null;
prediction_id: number | null;
}
@@ -0,0 +1,23 @@
import { libelleRegle, tonRegle } from './recommendation-presentation';
describe('recommendation-presentation', () => {
it('traduit les sept règles connues du moteur', () => {
expect(libelleRegle('spike-delestage-v1')).toBe('Délestage');
expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance');
expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours');
expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur');
expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification');
expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte');
expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance');
});
it('affiche telle quelle une référence de règle inconnue', () => {
expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2');
});
it("réserve le ton critique à l'escalade vers l'astreinte", () => {
expect(tonRegle('escalade-astreinte-v1')).toBe('critical');
expect(tonRegle('spike-delestage-v1')).toBe('neutral');
expect(tonRegle('inconnue-v9')).toBe('neutral');
});
});
@@ -0,0 +1,23 @@
import { BadgeTone } from '../components/ui/badge/badge';
// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ;
// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue.
const LIBELLE_PAR_REGLE: Record<string, string> = {
'spike-delestage-v1': 'Délestage',
'threshold-reduction-v1': 'Réduction de puissance',
'outage-secours-v1': 'Alimentation de secours',
'sensor-maintenance-v1': 'Maintenance capteur',
'anomaly-verification-v1': 'Vérification',
'escalade-astreinte-v1': 'Escalade astreinte',
'contrat-puissance-v1': 'Contrat de puissance',
};
const REGLE_ESCALADE = 'escalade-astreinte-v1';
export function libelleRegle(reference: string): string {
return LIBELLE_PAR_REGLE[reference] ?? reference;
}
export function tonRegle(reference: string): BadgeTone {
return reference === REGLE_ESCALADE ? 'critical' : 'neutral';
}
@@ -0,0 +1,14 @@
export interface Recommendation {
recommendation_id: number;
alert_id: number;
action: string;
explanation: string;
rule_reference: string;
created_at: string;
}
export interface RecommendationGenerationReport {
alerts_examined: number;
recommendations_created: number;
already_present: number;
}
+15
View File
@@ -29,3 +29,18 @@
color: var(--color-disabled);
margin-top: 0.25rem;
}
// Piège : le chevron est un SVG en data URI, où aucun token CSS n'est lisible ; sa couleur
// reprend en dur la valeur de --color-text-muted.
.form-select {
@extend .form-input;
padding-right: 2.25rem;
color: var(--color-text);
background-color: var(--color-surface);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
background-size: 1rem;
appearance: none;
cursor: pointer;
}
+1
View File
@@ -3,6 +3,7 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
+23
View File
@@ -146,6 +146,29 @@ Compose.
Conventions et gabarits : [`apps/frontend/TESTING.md`](../../apps/frontend/TESTING.md).
## Recommandations
Statut : `Fait`. La vue `/recommendations` (`features/recommendations`, derrière `authGuard`, tous
rôles) présente les recommandations du moteur de règles groupées par alerte, du plus récent au plus
ancien, avec le contexte de l'alerte (sévérité, type, site, horodatage, message) puis chaque action,
son explication et la règle qui l'a produite.
- **Jointure côté client.** Une recommandation ne porte que `alert_id`, jamais `site_id`, et
`GET /recommendations` n'a aucun filtre. `app-recommendation-list` (`shared/components/`) charge
donc en parallèle `GET /alerts` (filtré par `site_id` quand un site est fixé) et
`GET /recommendations`, puis les joint par `alert_id` (`joinByAlert`, fonction pure testée à
part). Les recommandations dont l'alerte n'est pas dans le jeu chargé sont ignorées : c'est ainsi
que le filtre site s'applique. `/alerts` n'étant pas paginé, un seul appel suffit.
- **Paramètres d'URL.** `?site=<site_id>` présélectionne le filtre site ; `?alert=<alert_id>`
réduit la vue à une alerte et la met en évidence (entier strictement positif, sinon ignoré).
- **Génération.** Le bouton « Générer les recommandations » n'apparaît que pour le rôle `admin`
(`POST /recommendations/generate?site_id=`, réservé admin côté API) et affiche le bilan renvoyé
(créées, déjà présentes, alertes examinées) avant de recharger la liste. La voie normale reste le
DAG Airflow `alertes` ([ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md)).
- **Entrées.** Lien « Recommandations » dans l'en-tête du tableau de bord ; section
« Recommandations » sur la vue détail d'un site (liste restreinte au site, lien vers la vue
complète préfiltrée).
## Questions ouvertes
- **Gestion d'état** : les signaux suffisent aujourd'hui, la question se reposera quand plusieurs
@@ -24,12 +24,13 @@ seule fois dans `src/styles.scss`. Disponibles partout sans import supplémentai
| `--shadow-card` | Ombre portée des cartes |
| `--space-1` à `--space-5` | Échelle d'espacement (0.35rem à 2.5rem) |
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-hint`) sont dans
`apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>` natifs liés par `formControlName` : pas de
composant `ControlValueAccessor` dédié, le gain n'en vaut pas la complexité pour des formulaires
aussi simples que ceux de ce projet. Les erreurs de formulaire, elles, s'affichent via
`<ev-alert severity="danger">`, pas une classe dédiée.
Les classes de formulaire partagées (`.form-label`, `.form-input`, `.form-select`, `.form-hint`)
sont dans `apps/frontend/src/styles/_forms.scss`, importées globalement de la même façon. Elles
s'appliquent directement à des `<label>`/`<input>`/`<select>` natifs, liés par `formControlName` ou
par un simple `(change)` : pas de composant `ControlValueAccessor` dédié, le gain n'en vaut pas la
complexité pour des formulaires aussi simples que ceux de ce projet. `.form-select` habille un
`<select>` natif avec la bordure et le focus de `.form-input`, plus un chevron. Les erreurs de
formulaire, elles, s'affichent via `<ev-alert severity="danger">`, pas une classe dédiée.
La classe `.auth-page` (`apps/frontend/src/styles/_auth-page.scss`, importée globalement) porte
le fond dégradé et le centrage commun aux pages d'authentification (`login`, `change-password`,
+13 -4
View File
@@ -44,6 +44,7 @@ flowchart TB
subgraph sq["SonarQube · sonarqube.yml"]
sb1["build-front / test-front"]
sb2["build-back / test-back"]
sb3["test-ml"]
sscan["sonarqube<br/>quality gate SonarCloud"]
end
@@ -132,10 +133,18 @@ partie de la suite, et son taux n'aurait aucun sens face au seuil de 85 %.
## SonarCloud, et l'incident qui a immobilisé trois PR
Le workflow `sonarqube.yml` exécute quatre jobs de préparation (`build-front`, `test-front`,
`build-back`, `test-back`) qui produisent chacun un rapport de couverture en artefact, puis un
cinquième job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8` avec le secret
`SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
Le workflow `sonarqube.yml` exécute cinq jobs de préparation (`build-front`, `test-front`,
`build-back`, `test-back`, `test-ml`) dont les tests produisent chacun un rapport de couverture en
artefact, puis un dernier job qui les télécharge et lance `SonarSource/sonarqube-scan-action@v8`
avec le secret `SONAR_TOKEN`. Le périmètre est décrit par `sonar-project.properties` à la racine.
Le périmètre couvre `apps/frontend`, `apps/backend`, `ml/` et `etl/airflow` (les deux derniers
ajoutés après coup : ils n'étaient pas analysés, une PR qui ne touchait qu'eux ne lançait pas
Sonar). `ml/` publie `ml/coverage.xml` (`pytest-cov`, même mécanisme que le backend, sans seuil
propre : la gate porte sur le code neuf). `etl/airflow` est exclu de la **couverture**
(`sonar.coverage.exclusions`) : ses tests ne font que charger les DAGs, ils ne mesurent rien.
Piège : tout nouveau dossier de tests doit être déclaré dans `sonar.tests`, faute de quoi il est
compté comme code de production non couvert (cf. l'incident ci-dessous).
**L'incident, à raconter tel quel.** Les 18 et 19 septembre, trois PR (#103, #105, #107) sont
restées bloquées sur une quality gate rouge annonçant une couverture du code neuf à 0 %, alors que
+1 -1
View File
@@ -103,7 +103,7 @@ prevision (utile plus tard pour comparer prevision et realise, surveillance de d
uv run ruff check . # lint
uv run ruff format . # format
uv run mypy enervision_ml tests # typage strict
uv run pytest # tests
uv run pytest # tests + couverture (ml/coverage.xml avec --cov-report=xml, lu par Sonar)
```
Depuis la racine du monorepo, via le `Makefile` : `make install-ml`, `make ml-lint`,
+11 -1
View File
@@ -17,6 +17,7 @@ dev = [
"ruff>=0.16.7",
"mypy>=2.3.1",
"pytest>=9.1.1",
"pytest-cov>=7.1.0",
"pandas-stubs>=3.0.5.260914",
]
@@ -75,5 +76,14 @@ ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers -m 'not integration'"
addopts = "-q --strict-markers -m 'not integration' --cov=enervision_ml --cov-report=term-missing"
markers = ["integration: requiert une base PostgreSQL joignable"]
# Rapport lu par SonarCloud (`ml/coverage.xml`, cf. sonar-project.properties), meme mecanisme que
# apps/backend. Pas de seuil ici : celui de la quality gate porte sur le code nouveau.
[tool.coverage.run]
source = ["enervision_ml"]
branch = true
[tool.coverage.report]
show_missing = true
Generated
+55
View File
@@ -388,6 +388,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" },
]
[[package]]
name = "coverage"
version = "7.16.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/2d/c738872f477f5687152acae68635790387425d407ae37dd3d3a8a6692307/coverage-7.16.1.tar.gz", hash = "sha256:f83981779bcf9dfa06fa0a8d4cb43e0faec1706328ce07aa3e7b665b4ac0f210", size = 969651, upload-time = "2026-09-13T19:12:21.422Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/b4/2a7c793965bae9f067aabab793a44d7a2f3ee7fb16b01ce1976bbd4a0218/coverage-7.16.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc0b37fe6f5ce5f1ccc62ad4fa9b1ad201d8e9b6027fd5e0170877beee4b2d15", size = 223546, upload-time = "2026-09-13T19:10:06.019Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e2/633469076a2dbbea036cc15a268a3a5d6b2c7dd5d9a9567b2553dfc5ad61/coverage-7.16.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6618f481053b63fc6121faf8fc676bd9b7163c2a19d9e984a2e850002c28ab57", size = 223881, upload-time = "2026-09-13T19:10:08.246Z" },
{ url = "https://files.pythonhosted.org/packages/de/c3/f06150c13284569d53273b909f31222874276a595637b7852571dfeb2c18/coverage-7.16.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa02d561eb1d8d2f8ba43ba6e3cef4c6c402a3b632a9460fa329fcadcd5df6a3", size = 254919, upload-time = "2026-09-13T19:10:10.254Z" },
{ url = "https://files.pythonhosted.org/packages/d5/40/47e25b215ae18a29010c8e29be8782a6e04d18ba6224be2bf6cebfce6427/coverage-7.16.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc5354a124799f1f87b7637bbe6f18cd4bc66a1f37f6aa2b5db40f9adad531dc", size = 257428, upload-time = "2026-09-13T19:10:12.124Z" },
{ url = "https://files.pythonhosted.org/packages/27/4b/1e2a4267d14cbd12a8489364a9d40020233e6be836d929b363f0e77209e2/coverage-7.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34bafe9f4094315248573e6223e11af0ec1b25f9cbca43bf0e9a26a189ba2751", size = 258771, upload-time = "2026-09-13T19:10:14.031Z" },
{ url = "https://files.pythonhosted.org/packages/be/2e/9aa6146cea929fab9185bb2642ffef7f47520a6e5efe407f75f9b12f4cf0/coverage-7.16.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:29c4d3e32a3b5efa420a3dc627c7e570deb80ef997def52c7686a474f5edc7ab", size = 261086, upload-time = "2026-09-13T19:10:16.213Z" },
{ url = "https://files.pythonhosted.org/packages/13/3c/f9ad8bcd4fb3d21c9d20a16d6d6c6f999eee8f4498ed7659a3dbd2f4b74a/coverage-7.16.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2066c447fdd0bca39a9633a082d8ce67bf9a539a203b85059a364a405dc9fe9", size = 254895, upload-time = "2026-09-13T19:10:18.602Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d1/47eda9fd1eaeea39fa7b5b13a63b2bed92ab901841fb120b3f9f5e1dc30c/coverage-7.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd8ac10cd2458b3c6343aac082fb9bd0e3fa806cb2c4975f2280153474b88412", size = 256783, upload-time = "2026-09-13T19:10:20.778Z" },
{ url = "https://files.pythonhosted.org/packages/38/c3/565edf044877cb8cd3373c56885347ffc38f0edfd1f1679a487b208c19a8/coverage-7.16.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d8c54ec32e5c102b9241f75d88ae26538b53662868ca491736611db448d9c7a", size = 254742, upload-time = "2026-09-13T19:10:22.733Z" },
{ url = "https://files.pythonhosted.org/packages/fd/88/87d2b2aeaba719192b2089ff1c2cf89a06cf73a6d2e9f1f145626617700c/coverage-7.16.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6dd8dda3402a01a1a8fe8b753a282466f615128574a5590a9108acd07b1f8540", size = 259016, upload-time = "2026-09-13T19:10:24.769Z" },
{ url = "https://files.pythonhosted.org/packages/fc/1b/70813185b125768abdcf7899fec4d37edc2e5fc9b60c7045c8f4271ec757/coverage-7.16.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:79afa9726438912e5cddd1fe541815cea9763c92935f594835e4c432565b68a9", size = 254559, upload-time = "2026-09-13T19:10:26.781Z" },
{ url = "https://files.pythonhosted.org/packages/d8/fa/e7aa5af279aafda633a1ede8bfd7d6916b0c8b2082be86759e0b52e73a61/coverage-7.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3db3978211c3cead5437a80136ca0556bab8bc7828de15a762884b0598c41361", size = 256215, upload-time = "2026-09-13T19:10:28.714Z" },
{ url = "https://files.pythonhosted.org/packages/38/87/7a894fa4f8c6662d2b6a87a3436950e15b1fa56e01765c9d6634fb2cbeb8/coverage-7.16.1-cp314-cp314-win32.whl", hash = "sha256:49c39c7068a494f8eb427155f5682f44feee43f9b3107fd54b1e52465379c54b", size = 225719, upload-time = "2026-09-13T19:10:30.743Z" },
{ url = "https://files.pythonhosted.org/packages/8b/01/fa7193c8005fb85488f02b0e1cc3c05a233cf2640206dd978af447aeecbf/coverage-7.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:c510dad19552d912058e4c3e3cbec3fb155dbe8d0ce0ceb7e7dbf5c5822bae0b", size = 226208, upload-time = "2026-09-13T19:10:32.698Z" },
{ url = "https://files.pythonhosted.org/packages/da/5c/a08634c714924c3eaef811bb3576c044128aa5e7dfa86c75e52f0761849e/coverage-7.16.1-cp314-cp314-win_arm64.whl", hash = "sha256:b7d4d7e6dcaf33e85f1919f03346403bdcc27437c420a78835f3805bca0ab71f", size = 225633, upload-time = "2026-09-13T19:10:34.79Z" },
{ url = "https://files.pythonhosted.org/packages/43/df/ddb8a4c664046b1a0ee29c9c2d25b993e5dbc8fbde715df3694a64532781/coverage-7.16.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:3d0a3681c12d3e0bcdea3d9414b04087828d6c1a482802d6f7f42c37ed530152", size = 224281, upload-time = "2026-09-13T19:10:36.853Z" },
{ url = "https://files.pythonhosted.org/packages/e2/d0/9076e0c762d8afd91182e60a520fa5c92c4a334785eeb9fd6b8ef8fe7e3c/coverage-7.16.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f3b4469d3da3ecced775d1a8c9c5d9fc80f259e30b7b89f9fed0700d6035ecb", size = 224547, upload-time = "2026-09-13T19:10:39.359Z" },
{ url = "https://files.pythonhosted.org/packages/03/e5/9c59e64b6161704f35fe91549bb19b2bb355e95caf596c26a2065564807c/coverage-7.16.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c08ae35c1be2fe1ce4b4c628df5c6fc0dc9a87f8e5fe8e20238d249678984741", size = 265906, upload-time = "2026-09-13T19:10:41.434Z" },
{ url = "https://files.pythonhosted.org/packages/57/5a/13ccaffb77f766101bf6f38be9dba9e468b02cc92da4552a57877dbf1c1f/coverage-7.16.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ee71a38c54bb2676bbe762b8b0943a79ccb1c2fd6a52054f66e63eda392f8c1", size = 268023, upload-time = "2026-09-13T19:10:43.533Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a1/05cfcf01d3c7c922832698ad46e51d3441d820ce87a943014bb5cf5710dd/coverage-7.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76491917771f179f9772efe218c5ccc65950dbdb35f4439298d8a8dfc6ec1f72", size = 270442, upload-time = "2026-09-13T19:10:45.895Z" },
{ url = "https://files.pythonhosted.org/packages/72/15/a2f1544b8e3835d7b769f7dabcc9ac0283e0b646ef3344703ff8f18d83e6/coverage-7.16.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4aa0b0a6f81fa3deb211e643f6954e78b4376b62b9c218271236cfa757664e8", size = 271565, upload-time = "2026-09-13T19:10:48.123Z" },
{ url = "https://files.pythonhosted.org/packages/df/5b/963c2993a82bd313f298d663afe03e164b96ace4d9d4c7561740a559e13d/coverage-7.16.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:756ba2d96d073c5a2a55d67fa22784763710fadbe22c41adde2d9cfa4dd78a8c", size = 264959, upload-time = "2026-09-13T19:10:50.195Z" },
{ url = "https://files.pythonhosted.org/packages/12/59/5eba06d1943735d7cd61d46d8c8a20ffe8ddd2da06b3c94366078dadeb9b/coverage-7.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:99bf9ea435cefcefd220f8687c3ddbbf78dc2de0bd11b57c3ae9fbbdf8d5561a", size = 267897, upload-time = "2026-09-13T19:10:52.252Z" },
{ url = "https://files.pythonhosted.org/packages/bd/48/af6c30f6ea431bb9b83f9070d268a9cc4fc97490abd32080164177ea999f/coverage-7.16.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:35cbc81f937fc402971df45c897d2df2bfb2014efcd990360032aa0a651635da", size = 265504, upload-time = "2026-09-13T19:10:54.432Z" },
{ url = "https://files.pythonhosted.org/packages/80/f2/6e13852a8656d05fa83284567dd5a5b1e6d89bef79fe3effca2787159eab/coverage-7.16.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8fae08e85b334ac6ac886002b5041396a31bcf805225bbe19847627203da99e2", size = 269235, upload-time = "2026-09-13T19:10:56.563Z" },
{ url = "https://files.pythonhosted.org/packages/c2/32/b4fe465daa64ece674f83a750dfa4ba0fa3c5c74d6ef5dbb8dfce892cf0d/coverage-7.16.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:83362b64e215ef00b0ba33fcf13655ace6c9fdd144d5ad2ab59ac86c2daf166e", size = 264347, upload-time = "2026-09-13T19:10:58.634Z" },
{ url = "https://files.pythonhosted.org/packages/54/f3/88b5c0e4ca3994c6d5feb7b1bf4c9a62cee205553159184968426930a7b1/coverage-7.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:33300f2e140ccf26af3d8152e62bff71993f9310cfc63ba7a20940b0d246a0ae", size = 266660, upload-time = "2026-09-13T19:11:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/97/72/6eff5456d7ba7f1c4678af531c33f9d957cae3201bd229b056fd13a204a3/coverage-7.16.1-cp314-cp314t-win32.whl", hash = "sha256:5539304fdbb2cc144df684d35a33b81145334d23e1c2367b5a923d25107f70b2", size = 226026, upload-time = "2026-09-13T19:11:02.846Z" },
{ url = "https://files.pythonhosted.org/packages/8e/c8/6e5ae3d8d4d0f2c0078985bf4db55fafd90e8107b1bf91ee3547a13f5694/coverage-7.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:715dcb72c3280c428c3a20134b87e42c29acec9669136e899ab2de69ca86218d", size = 226862, upload-time = "2026-09-13T19:11:04.921Z" },
{ url = "https://files.pythonhosted.org/packages/be/c7/68f9f0734afc904a92b974b489545b6a15700f3b1c4bd36eae764561e661/coverage-7.16.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dac8b84c03e6029d272b8249c77018db83de59ca009a9adef7c144b4a62ee5e6", size = 226171, upload-time = "2026-09-13T19:11:06.969Z" },
{ url = "https://files.pythonhosted.org/packages/96/1a/d6d16babd0a5fe4c3fae40702158c570351694e74516d8d81b86c5637448/coverage-7.16.1-py3-none-any.whl", hash = "sha256:3d8bd4e58b6a5c2018d808f297905393c6c61da466a48c3f0596a76a4900ebe4", size = 215264, upload-time = "2026-09-13T19:12:18.895Z" },
]
[[package]]
name = "cryptography"
version = "50.0.1"
@@ -494,6 +533,7 @@ dev = [
{ name = "mypy" },
{ name = "pandas-stubs" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
@@ -512,6 +552,7 @@ dev = [
{ name = "mypy", specifier = ">=2.3.1" },
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "ruff", specifier = ">=0.16.7" },
]
@@ -1591,6 +1632,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
+6 -4
View File
@@ -3,15 +3,17 @@ sonar.organization=groupe3-ener-vision
sonar.sourceEncoding=UTF-8
# Dossier contenant le code source
sonar.sources=apps/frontend/src,apps/backend
sonar.sources=apps/frontend/src,apps/backend,ml,etl/airflow
# Dossier contenant les tests
sonar.tests=apps/frontend/src,apps/backend/tests
sonar.tests=apps/frontend/src,apps/backend/tests,ml/tests,etl/airflow/tests
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
# Liste des fichiers et dossiers à exclure de l'analyse
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
sonar.exclusions=.pytest_cache,.venv,.airflow_home,alembic,tests,ml/data/**,ml/models/**,ml/mlruns/**,ml/mlartifacts/**,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py,**/*.spec.ts
# Chemin vers le rapport de couverture de code
# Fichier généré par Pytest
sonar.python.coverage.reportPaths=apps/backend/coverage.xml
sonar.python.coverage.reportPaths=apps/backend/coverage.xml,ml/coverage.xml
# Les DAGs n'ont pas de couverture mesurable : leurs tests ne font que les charger (DagBag)
sonar.coverage.exclusions=etl/airflow/**
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info