Merge remote-tracking branch 'origin/dev' into feat/deploy-rec-prod
# Conflicts: # docs/architecture/00-vue-ensemble.md
This commit is contained in:
@@ -6,6 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Alert
|
||||
|
||||
# Douze colonnes par alerte, contre quatre pour une recommandation : le plafond asyncpg de
|
||||
# 32 767 parametres tombe a 2 730 lignes, d'ou un lot plus petit que `recommendation.py`.
|
||||
TAILLE_DE_LOT = 1000
|
||||
|
||||
|
||||
class AlertRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
@@ -44,12 +48,17 @@ class AlertRepository:
|
||||
}
|
||||
for alerte in alerts
|
||||
]
|
||||
requete = (
|
||||
insert(Alert)
|
||||
.values(valeurs)
|
||||
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
||||
.returning(Alert)
|
||||
)
|
||||
resultat = await self._session.execute(requete)
|
||||
creees: list[Alert] = []
|
||||
# Piège : asyncpg plafonne une requête à 32 767 paramètres. Une détection sur une fenêtre
|
||||
# chargée dépasse ce seuil, et l'`INSERT` d'un seul tenant échouerait.
|
||||
for debut in range(0, len(valeurs), TAILLE_DE_LOT):
|
||||
requete = (
|
||||
insert(Alert)
|
||||
.values(valeurs[debut : debut + TAILLE_DE_LOT])
|
||||
.on_conflict_do_nothing(constraint="uq_alert_source_reference")
|
||||
.returning(Alert)
|
||||
)
|
||||
resultat = await self._session.execute(requete)
|
||||
creees.extend(resultat.scalars().all())
|
||||
await self._session.flush()
|
||||
return resultat.scalars().all()
|
||||
return creees
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Alert
|
||||
from app.repositories import alert as module_alert
|
||||
from app.repositories.alert import AlertRepository
|
||||
from app.schemas.alert import AlertSeverity
|
||||
from tests.repositories.test_site import creer as creer_site
|
||||
@@ -146,3 +147,20 @@ async def test_create_many_does_nothing_for_an_empty_list(session: AsyncSession)
|
||||
creees = await depot.create_many([])
|
||||
|
||||
assert creees == []
|
||||
|
||||
|
||||
async def test_create_many_inserts_every_alert_across_several_batches(
|
||||
session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(module_alert, "TAILLE_DE_LOT", 2)
|
||||
site = await creer_site(session)
|
||||
depot = AlertRepository(session)
|
||||
a_inserer = [
|
||||
_alerte_a_inserer(site_id=site.site_id, source_alert_id=f"threshold:lot-{index}")
|
||||
for index in range(5)
|
||||
]
|
||||
|
||||
creees = await depot.create_many(a_inserer)
|
||||
await session.rollback()
|
||||
|
||||
assert len(creees) == 5
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -8,8 +8,10 @@ import { STATS_SUMMARY_FIXTURE } from '../mocks/stats-summary.fixture';
|
||||
describe('mockApiInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
let useMockFixturesInitial: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
useMockFixturesInitial = environment.useMockFixtures;
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([mockApiInterceptor])),
|
||||
@@ -21,7 +23,7 @@ describe('mockApiInterceptor', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.useMockFixtures = true;
|
||||
environment.useMockFixtures = useMockFixturesInitial;
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -27,87 +28,120 @@
|
||||
@if (statsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
@if (alertsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
@if (predictionsError(); as message) {
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@if (stats(); as s) {
|
||||
<section class="overview">
|
||||
<ev-card class="card card--gauge">
|
||||
<span class="card__label">Consommation vs capacité</span>
|
||||
<p class="dashboard__status">
|
||||
<span class="dashboard__pulse" aria-hidden="true"></span>
|
||||
Actualisé à {{ s.timestamp | date: 'HH:mm:ss' }} · {{ s.total_sites }} sites suivis
|
||||
</p>
|
||||
|
||||
<section class="overview" aria-label="Indicateurs du parc">
|
||||
<ev-card class="kpi kpi--gauge">
|
||||
<span class="kpi__label">Consommation vs capacité</span>
|
||||
<app-consumption-gauge
|
||||
[consumption]="s.total_consumption_kw"
|
||||
[capacity]="s.total_capacity_kw"
|
||||
/>
|
||||
<span class="card__value"
|
||||
>{{ s.total_consumption_kw | number: '1.0-1' }} /
|
||||
{{ s.total_capacity_kw | number }} kW</span
|
||||
<span class="kpi__value">
|
||||
{{ s.total_consumption_kw | number: '1.0-1' }}
|
||||
<small>/ {{ s.total_capacity_kw | number }} kW</small>
|
||||
</span>
|
||||
</ev-card>
|
||||
|
||||
<ev-card class="kpi">
|
||||
<span class="kpi__label">Charge moyenne du parc</span>
|
||||
<span class="kpi__value"
|
||||
>{{ s.average_load_percent | number: '1.0-0' }} <small>%</small></span
|
||||
>
|
||||
</ev-card>
|
||||
|
||||
<ev-card class="card">
|
||||
<span class="card__label">Charge moyenne du parc</span>
|
||||
<span class="card__value">{{ s.average_load_percent }} %</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar__fill" [style.width.%]="s.average_load_percent"></div>
|
||||
<div
|
||||
class="progress-bar"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="s.average_load_percent"
|
||||
>
|
||||
<div
|
||||
class="progress-bar__fill"
|
||||
[class]="'progress-bar__fill--' + loadTone(s.average_load_percent)"
|
||||
[style.width.%]="s.average_load_percent"
|
||||
></div>
|
||||
</div>
|
||||
<span class="kpi__hint">{{ loadHint(s.average_load_percent) }}</span>
|
||||
</ev-card>
|
||||
|
||||
<ev-card class="card">
|
||||
<span class="card__label">Sites suivis</span>
|
||||
<span class="card__value">{{ s.total_sites }}</span>
|
||||
<ev-card class="kpi">
|
||||
<span class="kpi__label">Sites suivis</span>
|
||||
<span class="kpi__value">{{ s.total_sites }}</span>
|
||||
<a routerLink="/sites" class="ev-link kpi__link">Voir la liste des sites</a>
|
||||
</ev-card>
|
||||
</section>
|
||||
|
||||
<section class="chart-section">
|
||||
<h2>Charge et alerte visuelle par site</h2>
|
||||
<app-site-load-chart [sites]="s.sites" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (alerts().length > 0) {
|
||||
<section class="alerts-section">
|
||||
<h2>Alertes actives</h2>
|
||||
<ul class="alerts-list">
|
||||
@for (alert of alerts(); track alert.alert_id) {
|
||||
<li class="alert-item">
|
||||
<ev-badge [tone]="badgeToneForSeverity(alert.severity)">{{ alert.severity }}</ev-badge>
|
||||
<span class="alert-item__message">{{ alert.message }}</span>
|
||||
</li>
|
||||
<div class="dashboard__grid">
|
||||
<div class="dashboard__main">
|
||||
@if (stats(); as s) {
|
||||
<section class="chart-section">
|
||||
<h2>Charge par site</h2>
|
||||
<ev-card class="chart-card">
|
||||
<app-site-load-chart [sites]="s.sites" />
|
||||
</ev-card>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="predictions-section">
|
||||
<h2>Prévisions de consommation</h2>
|
||||
@if (predictions().length > 0) {
|
||||
<ev-card class="ev-table-card">
|
||||
<table class="ev-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Prévision</th>
|
||||
<th>Échéance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (site of predictions(); track site.site_id) {
|
||||
<tr>
|
||||
<td>{{ site.site_name }}</td>
|
||||
@if (site.prediction; as prediction) {
|
||||
@if (prediction.status === 'available') {
|
||||
<td class="ev-table__number">
|
||||
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
||||
</td>
|
||||
<td>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</td>
|
||||
} @else {
|
||||
<td>
|
||||
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
||||
prediction.status === 'insufficient_data'
|
||||
? 'Historique insuffisant'
|
||||
: 'Erreur'
|
||||
}}</ev-badge>
|
||||
</td>
|
||||
<td class="ev-table__muted">-</td>
|
||||
}
|
||||
} @else {
|
||||
<td><ev-badge tone="neutral">Pas encore de prévision</ev-badge></td>
|
||||
<td class="ev-table__muted">-</td>
|
||||
}
|
||||
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</ev-card>
|
||||
} @else if (!predictionsError()) {
|
||||
<p class="dashboard__empty">Aucune prévision disponible pour le moment.</p>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@if (predictions().length > 0) {
|
||||
<section class="predictions-section">
|
||||
<h2>Prévisions de consommation</h2>
|
||||
<ul class="predictions-list">
|
||||
@for (site of predictions(); track site.site_id) {
|
||||
<li class="prediction-item">
|
||||
<span class="prediction-item__site">{{ site.site_name }}</span>
|
||||
@if (site.prediction; as prediction) {
|
||||
@if (prediction.status === 'available') {
|
||||
<span class="prediction-item__value">
|
||||
{{ prediction.predicted_value | number: '1.0-1' }} kWh
|
||||
<span class="prediction-item__target"
|
||||
>{{ prediction.target_at | date: "dd/MM 'à' HH:mm" }}</span
|
||||
>
|
||||
</span>
|
||||
} @else {
|
||||
<ev-badge [tone]="badgeToneForPredictionStatus(prediction.status)">{{
|
||||
prediction.status === 'insufficient_data' ? 'Historique insuffisant' : 'Erreur'
|
||||
}}</ev-badge>
|
||||
}
|
||||
} @else {
|
||||
<ev-badge tone="neutral">Pas encore de prévision</ev-badge>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
<aside class="dashboard__side" aria-label="Alertes actives">
|
||||
<app-alert-feed />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
|
||||
.dashboard__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.dashboard__brand {
|
||||
@@ -20,8 +22,9 @@
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,19 +34,69 @@
|
||||
|
||||
.dashboard__subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dashboard__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
gap: var(--space-2);
|
||||
|
||||
.ev-link {
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary-hover);
|
||||
font-size: var(--font-size-sm);
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dashboard__pulse {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
animation: pulse 2s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.45);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 8px rgba(22, 163, 74, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard__pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.banner-error {
|
||||
@@ -54,116 +107,124 @@ h2 {
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1.25rem;
|
||||
gap: 0.35rem;
|
||||
.kpi {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: var(--space-4);
|
||||
gap: var(--space-1);
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 3px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.card--gauge {
|
||||
.kpi--gauge {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card--link {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease;
|
||||
.kpi__label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
.kpi__value {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
small {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.card__label {
|
||||
font-size: 0.8rem;
|
||||
.kpi__hint {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card__value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
.kpi__link {
|
||||
margin-top: auto;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
height: 8px;
|
||||
margin: var(--space-1) 0;
|
||||
background: var(--color-border-light);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
background: var(--color-primary);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-success);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-bar__fill--warning {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.progress-bar__fill--danger {
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.dashboard__side {
|
||||
position: sticky;
|
||||
top: var(--space-3);
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 2.5rem;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.alerts-list {
|
||||
list-style: none;
|
||||
.chart-card {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.dashboard__empty {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-danger-bg);
|
||||
border: 1px solid var(--color-danger-border);
|
||||
}
|
||||
|
||||
.alert-item__message {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.predictions-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.prediction-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.prediction-item__site {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prediction-item__value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prediction-item__target {
|
||||
margin-left: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard__side {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { Observable, of, throwError } from 'rxjs';
|
||||
import { Router, provideRouter } from '@angular/router';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { SitesService } from '../../core/services/sites.service';
|
||||
import { PredictionsService } from '../../core/services/predictions.service';
|
||||
import {AuthService} from '../../core/services/auth.service';
|
||||
import {Router, provideRouter} from '@angular/router';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
|
||||
vi.mock('chart.js', () => {
|
||||
class ChartMock {
|
||||
@@ -18,66 +19,73 @@ vi.mock('chart.js', () => {
|
||||
return { Chart: ChartMock, registerables: [] };
|
||||
});
|
||||
|
||||
const STATS = { total_sites: 7, sites: [] };
|
||||
|
||||
function predictionsMock(sites: unknown[] = []) {
|
||||
return { getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })) };
|
||||
return {
|
||||
getPredictions: vi.fn().mockReturnValue(of({ timestamp: '2026-09-18T09:00:00Z', sites })),
|
||||
};
|
||||
}
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
stats?: Observable<unknown>;
|
||||
predictions?: { getPredictions: ReturnType<typeof vi.fn> };
|
||||
auth?: Record<string, unknown>;
|
||||
} = {},
|
||||
) {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(options.stats ?? of(STATS)) };
|
||||
const predictions = options.predictions ?? predictionsMock();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: { getAlerts: vi.fn().mockReturnValue(of([])) } },
|
||||
{ provide: SitesService, useValue: { getSites: vi.fn().mockReturnValue(of([])) } },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
...(options.auth ? [{ provide: AuthService, useValue: options.auth }] : []),
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
return { fixture: TestBed.createComponent(Dashboard), statsMock, predictions };
|
||||
}
|
||||
|
||||
describe('Dashboard', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('charge les stats, les alertes et les prévisions au démarrage', async () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([{ alert_id: 'A1' }])) };
|
||||
const predictions = predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
it('charge les stats et les prévisions au démarrage', async () => {
|
||||
const { fixture, statsMock, predictions } = setup({
|
||||
predictions: predictionsMock([{ site_id: 'SITE001', site_name: 'Test', prediction: null }]),
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
// laisse le timer(0, ...) se déclencher avant de vérifier
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
||||
expect(predictions.getPredictions).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||
expect(fixture.componentInstance.predictions().length).toBe(1);
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
expect(fixture.componentInstance.alertsError()).toBeNull();
|
||||
expect(fixture.componentInstance.predictionsError()).toBeNull();
|
||||
});
|
||||
|
||||
it('délègue les alertes au widget app-alert-feed', () => {
|
||||
const { fixture } = setup();
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('app-alert-feed')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("signale l'indisponibilité puis repart au rafraîchissement suivant", () => {
|
||||
vi.useFakeTimers();
|
||||
const statsMock = {
|
||||
getSummary: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||
.mockReturnValue(of({ total_sites: 7, sites: [] })),
|
||||
};
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
const { fixture, statsMock } = setup({
|
||||
stats: throwError(() => new Error('API injoignable')),
|
||||
});
|
||||
statsMock.getSummary
|
||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||
.mockReturnValue(of(STATS));
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
@@ -91,45 +99,11 @@ describe('Dashboard', () => {
|
||||
expect(fixture.componentInstance.statsError()).toBeNull();
|
||||
});
|
||||
|
||||
it("n'interrompt pas la page quand le chargement des alertes échoue", () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
||||
expect(fixture.componentInstance.alertsError()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("n'interrompt pas la page quand le chargement des prévisions échoue", () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const predictions = {
|
||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
const { fixture } = setup({
|
||||
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.predictions().length).toBe(0);
|
||||
@@ -138,25 +112,11 @@ describe('Dashboard', () => {
|
||||
|
||||
it("un rafraîchissement de stats n'efface pas une erreur de prévisions en attente", () => {
|
||||
vi.useFakeTimers();
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const predictions = {
|
||||
getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictions },
|
||||
provideRouter([]),
|
||||
],
|
||||
const { fixture } = setup({
|
||||
predictions: { getPredictions: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.predictionsError()).not.toBeNull();
|
||||
|
||||
// Plusieurs cycles de `timer(0, 10_000)` (stats) plus tard, l'erreur des prévisions doit
|
||||
@@ -168,109 +128,106 @@ describe('Dashboard', () => {
|
||||
});
|
||||
|
||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const authMock = {
|
||||
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||
clearSession: vi.fn(),
|
||||
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
||||
button.click();
|
||||
|
||||
expect(authMock.logout).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
const authMock = {
|
||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||
clearSession: vi.fn(),
|
||||
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
{ provide: AuthService, useValue: authMock },
|
||||
provideRouter([]),
|
||||
],
|
||||
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||
clearSession: vi.fn(),
|
||||
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||
};
|
||||
const { fixture } = setup({ auth: authMock });
|
||||
fixture.detectChanges();
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
fixture.nativeElement.querySelector('.logout-button').click();
|
||||
|
||||
expect(authMock.logout).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
||||
const authMock = {
|
||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||
clearSession: vi.fn(),
|
||||
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||
};
|
||||
const { fixture } = setup({ auth: authMock });
|
||||
fixture.detectChanges();
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
const router = TestBed.inject(Router);
|
||||
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
fixture.nativeElement.querySelector('.logout-button').click();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('.logout-button');
|
||||
button.click();
|
||||
expect(authMock.clearSession).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
expect(authMock.clearSession).toHaveBeenCalled();
|
||||
expect(navigateSpy).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
|
||||
it('distingue le ton des sévérités high et critical', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
it('affiche l’heure du dernier relevé et les indicateurs du parc', () => {
|
||||
vi.useFakeTimers();
|
||||
const { fixture } = setup({
|
||||
stats: of({
|
||||
timestamp: '2026-09-18T09:00:00Z',
|
||||
total_sites: 7,
|
||||
total_consumption_kw: 1234.5,
|
||||
total_capacity_kw: 5000,
|
||||
average_load_percent: 24.7,
|
||||
sites: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
vi.advanceTimersByTime(1);
|
||||
fixture.detectChanges();
|
||||
|
||||
const texte = fixture.nativeElement.textContent as string;
|
||||
expect(texte).toContain('Actualisé à');
|
||||
expect(texte).toContain('7 sites suivis');
|
||||
expect(texte).toContain('Marge confortable');
|
||||
expect(fixture.nativeElement.querySelector('.progress-bar__fill--success')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('présente les prévisions en tableau avec un lien vers chaque site', () => {
|
||||
const { fixture } = setup({
|
||||
predictions: predictionsMock([
|
||||
{
|
||||
site_id: 'SITE001',
|
||||
site_name: 'Usine Nantes',
|
||||
prediction: {
|
||||
target_at: '2026-09-18T10:00:00Z',
|
||||
target_metric: 'consumption_kwh',
|
||||
period_minutes: 60,
|
||||
predicted_value: 118.4,
|
||||
status: 'available',
|
||||
failure_reason: null,
|
||||
model_reference: 'lightgbm-v1',
|
||||
created_at: '2026-09-18T09:00:00Z',
|
||||
},
|
||||
},
|
||||
{ site_id: 'SITE002', site_name: 'Bureau Lille', prediction: null },
|
||||
]),
|
||||
});
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const table = fixture.nativeElement.querySelector('table.ev-table');
|
||||
expect(table).not.toBeNull();
|
||||
expect(table.textContent).toContain('118.4 kWh');
|
||||
expect(table.textContent).toContain('Pas encore de prévision');
|
||||
expect(fixture.nativeElement.querySelector('a[href="/sites/SITE001"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('colore la charge moyenne selon les seuils 70 % et 90 %', () => {
|
||||
const { fixture } = setup();
|
||||
const dashboard = fixture.componentInstance;
|
||||
|
||||
expect(dashboard.badgeToneForSeverity('low')).toBe('success');
|
||||
expect(dashboard.badgeToneForSeverity('medium')).toBe('warning');
|
||||
expect(dashboard.badgeToneForSeverity('high')).toBe('danger');
|
||||
expect(dashboard.badgeToneForSeverity('critical')).toBe('critical');
|
||||
expect(dashboard.badgeToneForSeverity('high')).not.toBe(
|
||||
dashboard.badgeToneForSeverity('critical'),
|
||||
);
|
||||
expect(dashboard.loadTone(69.9)).toBe('success');
|
||||
expect(dashboard.loadTone(70)).toBe('warning');
|
||||
expect(dashboard.loadTone(89.9)).toBe('warning');
|
||||
expect(dashboard.loadTone(90)).toBe('danger');
|
||||
expect(dashboard.loadHint(95)).toBe('Proche de la capacité du parc');
|
||||
});
|
||||
|
||||
it('distingue le ton des statuts de prévision', () => {
|
||||
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Dashboard],
|
||||
providers: [
|
||||
{ provide: StatsService, useValue: statsMock },
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: PredictionsService, useValue: predictionsMock() },
|
||||
provideRouter([]),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
const { fixture } = setup();
|
||||
const dashboard = fixture.componentInstance;
|
||||
|
||||
expect(dashboard.badgeToneForPredictionStatus('available')).toBe('success');
|
||||
|
||||
@@ -6,11 +6,10 @@ import { Router, RouterLink } from '@angular/router';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
|
||||
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { AlertFeed } from '../../shared/components/alert-feed/alert-feed';
|
||||
import { PredictionsService } from '../../core/services/predictions.service';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { StatsSummary } from '../../shared/models/stats.model';
|
||||
import { Alert, AlertSeverity } from '../../shared/models/alert.model';
|
||||
import { PredictionStatus, SitePredictionSummary } from '../../shared/models/prediction.model';
|
||||
import { Card } from '../../shared/components/ui/card/card';
|
||||
import { Alert as EvAlert } from '../../shared/components/ui/alert/alert';
|
||||
@@ -22,22 +21,19 @@ const REFRESH_INTERVAL_MS = 10000;
|
||||
const UNAVAILABLE_MESSAGE =
|
||||
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
||||
|
||||
const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
||||
low: 'success',
|
||||
medium: 'warning',
|
||||
high: 'danger',
|
||||
critical: 'critical',
|
||||
};
|
||||
|
||||
// `error` n'a pas de précédent dans les fixtures ou l'API à ce jour, mais figure dans le
|
||||
// domaine du schéma backend (`ck_prediction_status`) : mieux vaut une couleur définie que
|
||||
// tomber sur `undefined` si ce statut apparaît un jour.
|
||||
// `error` n'a pas encore de précédent côté API mais figure dans `ck_prediction_status` :
|
||||
// mieux vaut un ton défini que `undefined` le jour où ce statut apparaît.
|
||||
const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||
available: 'success',
|
||||
insufficient_data: 'warning',
|
||||
error: 'danger',
|
||||
};
|
||||
|
||||
const SEUIL_CHARGE_SOUTENUE = 70;
|
||||
const SEUIL_CHARGE_CRITIQUE = 90;
|
||||
|
||||
export type LoadTone = 'success' | 'warning' | 'danger';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
standalone: true,
|
||||
@@ -47,6 +43,7 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||
RouterLink,
|
||||
ConsumptionGauge,
|
||||
SiteLoadChart,
|
||||
AlertFeed,
|
||||
Card,
|
||||
EvAlert,
|
||||
Badge,
|
||||
@@ -58,32 +55,20 @@ const TON_PAR_STATUT_PREDICTION: Record<PredictionStatus, BadgeTone> = {
|
||||
})
|
||||
export class Dashboard implements OnInit {
|
||||
private statsService = inject(StatsService);
|
||||
private alertsService = inject(AlertsService);
|
||||
public auth = inject(AuthService);
|
||||
private predictionsService = inject(PredictionsService);
|
||||
private router = inject(Router);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
stats = signal<StatsSummary | null>(null);
|
||||
alerts = signal<Alert[]>([]);
|
||||
predictions = signal<SitePredictionSummary[]>([]);
|
||||
|
||||
// Un signal par flux, pas un seul `error` partagé : sinon le tick suivant de `timer` (stats)
|
||||
// efface silencieusement un message d'échec des prévisions ou des alertes après 10s au plus,
|
||||
// sans retry ni indication pour l'utilisateur que la section correspondante est restée vide.
|
||||
// Piège : un signal d'erreur par flux, sinon le tick suivant de `timer` (stats) efface en
|
||||
// silence l'échec des prévisions après 10 s au plus, sans retry ni indication à l'utilisateur.
|
||||
statsError = signal<string | null>(null);
|
||||
alertsError = signal<string | null>(null);
|
||||
predictionsError = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.alertsService
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => this.reportUnavailable(this.alertsError)))
|
||||
.subscribe((alerts) => {
|
||||
this.alertsError.set(null);
|
||||
this.alerts.set(alerts);
|
||||
});
|
||||
|
||||
// Les prévisions viennent d'un scoring hors ligne, pas d'un calcul à la demande : un seul
|
||||
// chargement au démarrage suffit, pas besoin du rafraîchissement périodique de `stats`.
|
||||
this.predictionsService
|
||||
@@ -99,7 +84,9 @@ export class Dashboard implements OnInit {
|
||||
timer(0, REFRESH_INTERVAL_MS)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||
this.statsService
|
||||
.getSummary()
|
||||
.pipe(catchError(() => this.reportUnavailable(this.statsError))),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
@@ -109,14 +96,28 @@ export class Dashboard implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
badgeToneForSeverity(severity: AlertSeverity): BadgeTone {
|
||||
return TON_PAR_SEVERITE[severity];
|
||||
}
|
||||
|
||||
badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone {
|
||||
return TON_PAR_STATUT_PREDICTION[status];
|
||||
}
|
||||
|
||||
loadTone(percent: number): LoadTone {
|
||||
if (percent >= SEUIL_CHARGE_CRITIQUE) {
|
||||
return 'danger';
|
||||
}
|
||||
return percent >= SEUIL_CHARGE_SOUTENUE ? 'warning' : 'success';
|
||||
}
|
||||
|
||||
loadHint(percent: number): string {
|
||||
switch (this.loadTone(percent)) {
|
||||
case 'danger':
|
||||
return 'Proche de la capacité du parc';
|
||||
case 'warning':
|
||||
return 'Charge soutenue';
|
||||
default:
|
||||
return 'Marge confortable';
|
||||
}
|
||||
}
|
||||
|
||||
onLogout(): void {
|
||||
this.auth.logout().subscribe({
|
||||
next: () => this.router.navigate(['/login']),
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
<ev-card class="table-card">
|
||||
<table class="sites-table">
|
||||
<ev-card class="ev-table-card">
|
||||
<table class="ev-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
@@ -36,7 +36,9 @@
|
||||
<td>{{ site.site_type }}</td>
|
||||
<td>{{ site.location || '-' }}</td>
|
||||
<td>{{ site.capacity_kw ?? '-' }}</td>
|
||||
<td><ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge></td>
|
||||
<td>
|
||||
<ev-badge [tone]="badgeToneForStatus(site.status)">{{ site.status ?? '-' }}</ev-badge>
|
||||
</td>
|
||||
<td><a [routerLink]="['/sites', site.site_id]" class="ev-link">Détail</a></td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -32,32 +32,3 @@
|
||||
display: block;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sites-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.85rem 1.25rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<section class="alert-feed">
|
||||
<header class="alert-feed__header">
|
||||
<div>
|
||||
<h2 class="alert-feed__title">Alertes actives</h2>
|
||||
@if (!loading() || alerts().length > 0) {
|
||||
<p class="alert-feed__count">
|
||||
{{ alerts().length }} {{ alerts().length > 1 ? 'alertes' : 'alerte' }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="alert-feed__filters">
|
||||
@if (!siteId()) {
|
||||
<label class="alert-feed__filter">
|
||||
<span class="form-label">Site</span>
|
||||
<select class="form-select" data-testid="site-filter" (change)="onSiteChange($event)">
|
||||
<option value="">Tous les sites</option>
|
||||
@for (site of sites(); track site.site_id) {
|
||||
<option [value]="site.site_id">{{ site.site_name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
<label class="alert-feed__filter">
|
||||
<span class="form-label">Sévérité</span>
|
||||
<select
|
||||
class="form-select"
|
||||
data-testid="severity-filter"
|
||||
(change)="onSeverityChange($event)"
|
||||
>
|
||||
<option value="">Toutes</option>
|
||||
@for (severite of severites; track severite) {
|
||||
<option [value]="severite">{{ severityLabel(severite) }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (error(); as message) {
|
||||
<ev-alert severity="danger" class="alert-feed__banner">{{ message }}</ev-alert>
|
||||
}
|
||||
|
||||
@if (loading() && alerts().length === 0 && !error()) {
|
||||
<p class="alert-feed__state" aria-live="polite">Chargement des alertes…</p>
|
||||
} @else if (alerts().length === 0 && !error()) {
|
||||
<ev-alert severity="success" class="alert-feed__banner"
|
||||
>Aucune alerte pour ces critères.</ev-alert
|
||||
>
|
||||
}
|
||||
|
||||
<ul class="alert-feed__list" [attr.aria-busy]="loading()">
|
||||
@for (alert of visibleAlerts(); track alert.alert_id) {
|
||||
<li class="alert-feed__item" [class]="'alert-feed__item--' + alert.severity">
|
||||
<span class="alert-feed__icon">
|
||||
<ev-icon [name]="alert.type" [label]="typeLabel(alert.type)" />
|
||||
</span>
|
||||
<div class="alert-feed__body">
|
||||
<div class="alert-feed__meta">
|
||||
<ev-badge [tone]="toneFor(alert.severity)">{{
|
||||
severityLabel(alert.severity)
|
||||
}}</ev-badge>
|
||||
<span class="alert-feed__type">{{ typeLabel(alert.type) }}</span>
|
||||
<span class="alert-feed__site">{{ siteName(alert.site_id) }}</span>
|
||||
<time class="alert-feed__time" [attr.datetime]="alert.timestamp">
|
||||
{{ alert.timestamp | date: 'dd/MM/yyyy HH:mm' }}
|
||||
</time>
|
||||
</div>
|
||||
<p class="alert-feed__message">{{ alert.message }}</p>
|
||||
@if (alert.value !== null) {
|
||||
<p class="alert-feed__values">
|
||||
<strong>{{ alert.value | number: '1.0-1' }} {{ unitFor(alert.metric) }}</strong>
|
||||
@if (alert.threshold !== null) {
|
||||
<span
|
||||
>seuil {{ alert.threshold | number: '1.0-1' }} {{ unitFor(alert.metric) }}</span
|
||||
>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@if (hiddenCount() > 0) {
|
||||
<ev-button
|
||||
variant="secondary"
|
||||
[fullWidth]="false"
|
||||
class="alert-feed__more"
|
||||
data-testid="show-more"
|
||||
(click)="showMore()"
|
||||
>
|
||||
Afficher plus ({{ hiddenCount() }} restantes)
|
||||
</ev-button>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,154 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.alert-feed__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.alert-feed__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert-feed__count {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.alert-feed__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.alert-feed__filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 10rem;
|
||||
|
||||
.form-label {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-feed__banner {
|
||||
display: block;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.alert-feed__state {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.alert-feed__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.alert-feed__item {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-left: 4px solid var(--color-border);
|
||||
}
|
||||
|
||||
.alert-feed__item--medium {
|
||||
border-left-color: var(--color-warning);
|
||||
background: var(--color-warning-bg);
|
||||
|
||||
.alert-feed__icon {
|
||||
color: var(--color-warning-text);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-feed__item--high {
|
||||
border-left-color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
|
||||
.alert-feed__icon {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-feed__item--critical {
|
||||
border-left-color: var(--color-critical);
|
||||
background: var(--color-danger-bg);
|
||||
|
||||
.alert-feed__icon {
|
||||
color: var(--color-critical);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-feed__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.alert-feed__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alert-feed__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.alert-feed__type {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.alert-feed__message {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.alert-feed__values {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
|
||||
strong {
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-feed__more {
|
||||
display: inline-block;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { AlertFeed } from './alert-feed';
|
||||
import { AlertsService } from '../../../core/services/alerts.service';
|
||||
import { SitesService } from '../../../core/services/sites.service';
|
||||
import { Alert } from '../../models/alert.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',
|
||||
},
|
||||
];
|
||||
|
||||
function alerte(surcharges: Partial<Alert> = {}): Alert {
|
||||
return {
|
||||
alert_id: 1,
|
||||
site_id: 'SITE001',
|
||||
timestamp: '2026-09-15T11:12:00Z',
|
||||
type: 'spike',
|
||||
severity: 'critical',
|
||||
message: 'Variation brutale entre deux lectures consécutives',
|
||||
value: 812.5,
|
||||
threshold: 400,
|
||||
metric: 'consumption_kw',
|
||||
prediction_id: null,
|
||||
...surcharges,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(
|
||||
alertsMock: { getAlerts: ReturnType<typeof vi.fn> },
|
||||
sitesMock: { getSites: ReturnType<typeof vi.fn> } = {
|
||||
getSites: vi.fn().mockReturnValue(of(SITES)),
|
||||
},
|
||||
) {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [AlertFeed],
|
||||
providers: [
|
||||
{ provide: AlertsService, useValue: alertsMock },
|
||||
{ provide: SitesService, useValue: sitesMock },
|
||||
],
|
||||
});
|
||||
return TestBed.createComponent(AlertFeed);
|
||||
}
|
||||
|
||||
function premierChargement(fixture: ComponentFixture<AlertFeed>) {
|
||||
fixture.detectChanges();
|
||||
vi.advanceTimersByTime(1);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function texte(fixture: ComponentFixture<AlertFeed>): string {
|
||||
return (fixture.nativeElement as HTMLElement).textContent ?? '';
|
||||
}
|
||||
|
||||
function choisir(fixture: ComponentFixture<AlertFeed>, testId: string, value: string) {
|
||||
const select = fixture.nativeElement.querySelector(
|
||||
`[data-testid="${testId}"]`,
|
||||
) as HTMLSelectElement;
|
||||
select.value = value;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
fixture.detectChanges();
|
||||
vi.advanceTimersByTime(1);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
describe('AlertFeed', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('charge les alertes au démarrage sans filtre et les affiche avec leur contexte', () => {
|
||||
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
|
||||
const fixture = setup({ getAlerts });
|
||||
|
||||
premierChargement(fixture);
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledTimes(1);
|
||||
expect(getAlerts.mock.calls[0][0]).toEqual({});
|
||||
const contenu = texte(fixture);
|
||||
expect(contenu).toContain('Usine Nantes');
|
||||
expect(contenu).toContain('Critique');
|
||||
expect(contenu).toContain('Pic de consommation');
|
||||
expect(contenu).toContain('15/09/2026');
|
||||
expect(contenu).toContain('812.5 kW');
|
||||
expect(contenu).toContain('seuil 400 kW');
|
||||
expect(fixture.nativeElement.querySelector('ev-icon svg')).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.alert-feed__item--critical')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('annonce le chargement avant la première réponse', () => {
|
||||
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.loading()).toBe(true);
|
||||
expect(texte(fixture)).toContain('Chargement des alertes');
|
||||
});
|
||||
|
||||
it("annonce l'absence d'alerte pour les critères choisis", () => {
|
||||
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of([])) });
|
||||
|
||||
premierChargement(fixture);
|
||||
|
||||
expect(texte(fixture)).toContain('Aucune alerte pour ces critères.');
|
||||
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(0);
|
||||
});
|
||||
|
||||
it('relance la requête avec la sévérité choisie et revient à la première page', () => {
|
||||
const getAlerts = vi.fn().mockReturnValue(of([alerte()]));
|
||||
const fixture = setup({ getAlerts });
|
||||
premierChargement(fixture);
|
||||
fixture.componentInstance.showMore();
|
||||
|
||||
choisir(fixture, 'severity-filter', 'high');
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||
expect(getAlerts.mock.calls[1][0]).toEqual({ severity: 'high' });
|
||||
expect(fixture.componentInstance.visibleCount()).toBe(10);
|
||||
});
|
||||
|
||||
it('relance la requête avec le site choisi dans le filtre', () => {
|
||||
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||
const fixture = setup({ getAlerts });
|
||||
premierChargement(fixture);
|
||||
|
||||
choisir(fixture, 'site-filter', 'SITE001');
|
||||
|
||||
expect(getAlerts.mock.calls[1][0]).toEqual({ site_id: 'SITE001' });
|
||||
});
|
||||
|
||||
it('masque le filtre site et force site_id quand le parent fixe le site', () => {
|
||||
const getAlerts = vi.fn().mockReturnValue(of([]));
|
||||
const fixture = setup({ getAlerts });
|
||||
fixture.componentRef.setInput('siteId', 'SITE001');
|
||||
|
||||
premierChargement(fixture);
|
||||
|
||||
expect(getAlerts.mock.calls[0][0]).toEqual({ site_id: 'SITE001' });
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="site-filter"]')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="severity-filter"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("signale l'indisponibilité en gardant la liste, puis repart au rafraîchissement suivant", () => {
|
||||
const getAlerts = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(of([alerte()]))
|
||||
.mockReturnValueOnce(throwError(() => new Error('API injoignable')))
|
||||
.mockReturnValue(of([alerte(), alerte({ alert_id: 2 })]));
|
||||
const fixture = setup({ getAlerts });
|
||||
premierChargement(fixture);
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||
expect(texte(fixture)).toContain('Alertes indisponibles');
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getAlerts).toHaveBeenCalledTimes(3);
|
||||
expect(fixture.componentInstance.error()).toBeNull();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(2);
|
||||
});
|
||||
|
||||
it('pagine côté client par dix et dévoile le reste à la demande', () => {
|
||||
const alertes = Array.from({ length: 25 }, (_, i) => alerte({ alert_id: i + 1 }));
|
||||
const fixture = setup({ getAlerts: vi.fn().mockReturnValue(of(alertes)) });
|
||||
premierChargement(fixture);
|
||||
|
||||
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(10);
|
||||
expect(texte(fixture)).toContain('Afficher plus (15 restantes)');
|
||||
|
||||
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(20);
|
||||
|
||||
fixture.nativeElement.querySelector('[data-testid="show-more"]').click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelectorAll('li').length).toBe(25);
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="show-more"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("replie sur l'identifiant quand le site est inconnu ou que la liste des sites échoue", () => {
|
||||
const fixture = setup(
|
||||
{ getAlerts: vi.fn().mockReturnValue(of([alerte({ site_id: 'SITE999' })])) },
|
||||
{ getSites: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) },
|
||||
);
|
||||
|
||||
premierChargement(fixture);
|
||||
|
||||
expect(texte(fixture)).toContain('SITE999');
|
||||
});
|
||||
|
||||
it("n'affiche pas de mesure pour une alerte sans valeur", () => {
|
||||
const fixture = setup({
|
||||
getAlerts: vi
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
of([alerte({ type: 'outage', value: null, threshold: null, metric: null })]),
|
||||
),
|
||||
});
|
||||
|
||||
premierChargement(fixture);
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.alert-feed__values')).toBeNull();
|
||||
expect(texte(fixture)).toContain('Coupure');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Component, DestroyRef, computed, inject, input, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { DatePipe, DecimalPipe } from '@angular/common';
|
||||
import { catchError, EMPTY, Observable, of, switchMap, tap, timer } from 'rxjs';
|
||||
import { AlertFilters, AlertsService } from '../../../core/services/alerts.service';
|
||||
import { SitesService } from '../../../core/services/sites.service';
|
||||
import { Alert, AlertMetric, AlertSeverity, AlertType } from '../../models/alert.model';
|
||||
import { Site } from '../../models/site.model';
|
||||
import {
|
||||
LIBELLE_PAR_SEVERITE,
|
||||
LIBELLE_PAR_TYPE,
|
||||
SEVERITES,
|
||||
TON_PAR_SEVERITE,
|
||||
UNITE_PAR_METRIQUE,
|
||||
} from '../../models/alert-presentation';
|
||||
import { Badge, BadgeTone } from '../ui/badge/badge';
|
||||
import { Button } from '../ui/button/button';
|
||||
import { Alert as EvAlert } from '../ui/alert/alert';
|
||||
import { Icon } from '../ui/icon/icon';
|
||||
|
||||
// Le DAG de détection tourne toutes les heures : une minute suffit largement pour suivre le flux.
|
||||
const REFRESH_INTERVAL_MS = 60_000;
|
||||
const PAGE_SIZE = 10;
|
||||
const UNAVAILABLE_MESSAGE = 'Alertes indisponibles, la liste affichée date du dernier chargement.';
|
||||
|
||||
@Component({
|
||||
selector: 'app-alert-feed',
|
||||
standalone: true,
|
||||
imports: [DatePipe, DecimalPipe, Badge, Button, EvAlert, Icon],
|
||||
templateUrl: './alert-feed.html',
|
||||
styleUrl: './alert-feed.scss',
|
||||
})
|
||||
export class AlertFeed {
|
||||
private alertsService = inject(AlertsService);
|
||||
private sitesService = inject(SitesService);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
siteId = input<string | null>(null);
|
||||
|
||||
readonly severites = SEVERITES;
|
||||
severity = signal<AlertSeverity | null>(null);
|
||||
siteFilter = signal<string | null>(null);
|
||||
|
||||
alerts = signal<Alert[]>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
visibleCount = signal(PAGE_SIZE);
|
||||
|
||||
sites = toSignal(this.sitesService.getSites().pipe(catchError(() => of([] as Site[]))), {
|
||||
initialValue: [] as Site[],
|
||||
});
|
||||
|
||||
private filters = computed<AlertFilters>(() => ({
|
||||
site_id: this.siteId() ?? this.siteFilter() ?? undefined,
|
||||
severity: this.severity() ?? undefined,
|
||||
}));
|
||||
|
||||
private siteNameById = computed(
|
||||
() => new Map(this.sites().map((site) => [site.site_id, site.site_name])),
|
||||
);
|
||||
|
||||
visibleAlerts = computed(() => this.alerts().slice(0, this.visibleCount()));
|
||||
hiddenCount = computed(() => Math.max(this.alerts().length - this.visibleCount(), 0));
|
||||
|
||||
constructor() {
|
||||
toObservable(this.filters)
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.loading.set(true);
|
||||
this.visibleCount.set(PAGE_SIZE);
|
||||
}),
|
||||
// Piège : catchError sur l'observable interne ; sur le flux externe il terminerait le
|
||||
// timer et le rafraîchissement ne repartirait jamais.
|
||||
switchMap((filters) =>
|
||||
timer(0, REFRESH_INTERVAL_MS).pipe(
|
||||
switchMap(() =>
|
||||
this.alertsService
|
||||
.getAlerts(filters)
|
||||
.pipe(catchError(() => this.reportUnavailable())),
|
||||
),
|
||||
),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((alerts) => {
|
||||
this.loading.set(false);
|
||||
this.error.set(null);
|
||||
this.alerts.set(alerts);
|
||||
});
|
||||
}
|
||||
|
||||
onSiteChange(event: Event): void {
|
||||
this.siteFilter.set((event.target as HTMLSelectElement).value || null);
|
||||
}
|
||||
|
||||
onSeverityChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
this.severity.set(value ? (value as AlertSeverity) : null);
|
||||
}
|
||||
|
||||
showMore(): void {
|
||||
this.visibleCount.update((count) => count + PAGE_SIZE);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
siteName(siteId: string): string {
|
||||
return this.siteNameById().get(siteId) ?? siteId;
|
||||
}
|
||||
|
||||
unitFor(metric: AlertMetric | null): string {
|
||||
return metric ? UNITE_PAR_METRIQUE[metric] : '';
|
||||
}
|
||||
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.loading.set(false);
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+244
@@ -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 @@
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
focusable="false"
|
||||
[attr.role]="label() ? 'img' : null"
|
||||
[attr.aria-label]="label()"
|
||||
[attr.aria-hidden]="label() ? null : 'true'"
|
||||
>
|
||||
@switch (name()) {
|
||||
@case ('spike') {
|
||||
<polyline points="3 17 8 12 11 15 21 5" />
|
||||
<polyline points="15 5 21 5 21 11" />
|
||||
}
|
||||
@case ('threshold') {
|
||||
<line x1="3" y1="10" x2="21" y2="10" stroke-dasharray="3 3" />
|
||||
<polyline points="4 19 9 13 13 15 20 6" />
|
||||
}
|
||||
@case ('anomaly') {
|
||||
<polyline points="3 12 7 12 9.5 6 12.5 18 15 12 21 12" />
|
||||
}
|
||||
@case ('outage') {
|
||||
<path d="M7.5 6.5a7 7 0 1 0 9 0" />
|
||||
<line x1="12" y1="3" x2="12" y2="11" />
|
||||
}
|
||||
@case ('sensor') {
|
||||
<circle cx="12" cy="13" r="2" />
|
||||
<path d="M8.5 9.5a5 5 0 0 0 0 7" />
|
||||
<path d="M15.5 9.5a5 5 0 0 1 0 7" />
|
||||
<path d="M5.5 6.5a9 9 0 0 0 0 13" />
|
||||
<path d="M18.5 6.5a9 9 0 0 1 0 13" />
|
||||
}
|
||||
}
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,12 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Icon, IconName } from './icon';
|
||||
|
||||
const NOMS: IconName[] = ['spike', 'threshold', 'anomaly', 'outage', 'sensor'];
|
||||
|
||||
function rendre(name: IconName, label: string | null = null) {
|
||||
const fixture = TestBed.createComponent(Icon);
|
||||
fixture.componentRef.setInput('name', name);
|
||||
fixture.componentRef.setInput('label', label);
|
||||
fixture.detectChanges();
|
||||
return fixture.nativeElement as HTMLElement;
|
||||
}
|
||||
|
||||
describe('Icon', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ imports: [Icon] });
|
||||
});
|
||||
|
||||
it('dessine un tracé distinct pour chacun des cinq types', () => {
|
||||
const traces = NOMS.map((name) => rendre(name).querySelector('svg')?.innerHTML.trim());
|
||||
|
||||
for (const trace of traces) {
|
||||
expect(trace).toBeTruthy();
|
||||
}
|
||||
expect(new Set(traces).size).toBe(NOMS.length);
|
||||
});
|
||||
|
||||
it('reste décoratif sans libellé', () => {
|
||||
const svg = rendre('spike').querySelector('svg');
|
||||
|
||||
expect(svg?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(svg?.hasAttribute('role')).toBe(false);
|
||||
});
|
||||
|
||||
it('expose un rôle image et un libellé accessible quand on lui en donne un', () => {
|
||||
const svg = rendre('outage', 'Coupure').querySelector('svg');
|
||||
|
||||
expect(svg?.getAttribute('role')).toBe('img');
|
||||
expect(svg?.getAttribute('aria-label')).toBe('Coupure');
|
||||
expect(svg?.hasAttribute('aria-hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('hérite de la couleur du parent via currentColor', () => {
|
||||
const svg = rendre('sensor').querySelector('svg');
|
||||
|
||||
expect(svg?.getAttribute('stroke')).toBe('currentColor');
|
||||
expect(svg?.getAttribute('fill')).toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
export type IconName = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
|
||||
|
||||
@Component({
|
||||
selector: 'ev-icon',
|
||||
standalone: true,
|
||||
templateUrl: './icon.html',
|
||||
styleUrl: './icon.scss',
|
||||
})
|
||||
export class Icon {
|
||||
name = input.required<IconName>();
|
||||
label = input<string | null>(null);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
@use 'styles/forms';
|
||||
@use 'styles/auth-page';
|
||||
@use 'styles/links';
|
||||
@use 'styles/tables';
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Piège : `ev-card` pose son padding sur `:host`, compilé en `[_nghost-…]` (même spécificité
|
||||
// qu'une classe) et injecté après la feuille globale ; il faut le sélecteur d'élément pour gagner.
|
||||
ev-card.ev-table-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ev-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.85rem 1.25rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover td {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.ev-table__number {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ev-table__muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -29,10 +29,17 @@
|
||||
|
||||
// Typo, rayons, ombre
|
||||
--font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.85rem;
|
||||
--font-size-md: 1rem;
|
||||
--font-size-lg: 1.25rem;
|
||||
--font-size-xl: 1.75rem;
|
||||
--font-size-2xl: 2.25rem;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-pill: 999px;
|
||||
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--shadow-card-hover: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
|
||||
// Espacements
|
||||
--space-1: 0.35rem;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
Reference in New Issue
Block a user