fix(frontend): aligne le modèle Alert et AlertsService sur le contrat /alerts

Le modèle front portait un alert_id texte, des value/threshold non nullables et
ignorait metric et prediction_id, alors que l'API sérialise un entier, des
flottants nullables et ces deux champs. Toute comparaison avec l'alert_id d'une
recommandation échouait silencieusement.

- alert.model.ts : alert_id number, value/threshold/metric/prediction_id nullables
- alerts.service.ts : getAlerts(filters) pose site_id et severity en HttpParams,
  les deux filtres que l'API accepte
- alerts.fixture.ts : réaligné sur le contrat (ids entiers, horodatages UTC,
  champs nuls sur outage/sensor, un cas spike)
- alert-presentation.ts : tons, libellés et unités partagés ; low passe en
  neutre, le vert se lisait comme un état sain
This commit is contained in:
Johan LEROY
2026-09-21 14:25:30 +02:00
parent 3c378c177f
commit 91752a2b0b
6 changed files with 177 additions and 51 deletions
@@ -2,53 +2,63 @@ import { Alert } from '../../shared/models/alert.model';
export const ALERTS_FIXTURE: Alert[] = [ export const ALERTS_FIXTURE: Alert[] = [
{ {
alert_id: 'ALR-SITE002-1718458320', alert_id: 5,
timestamp: '2026-09-15T11:12:00',
site_id: 'SITE002', site_id: 'SITE002',
timestamp: '2026-09-15T11:12:00Z',
type: 'threshold',
severity: 'critical', severity: 'critical',
type: 'outage', message: 'Puissance appelée 812.5 kW au-dessus de la capacité du site (720.0 kW)',
message: 'Risque de surcharge sur Usine Lyon Vénissieux',
value: 812.5, value: 812.5,
threshold: 720.0, threshold: 720.0,
metric: 'consumption_kw',
prediction_id: null,
}, },
{ {
alert_id: 'ALR-SITE003-1718458321', alert_id: 4,
timestamp: '2026-09-15T11:05:00',
site_id: 'SITE003', site_id: 'SITE003',
timestamp: '2026-09-15T11:05:00Z',
type: 'outage',
severity: 'critical', severity: 'critical',
type: 'sensor', message: 'Aucune lecture depuis 5:00:00 (dernière lecture : 2026-09-15T06:05:00+00:00)',
message: 'Perte réseau totale sur Data Center Marseille', value: null,
value: 0, threshold: null,
threshold: 0, metric: null,
prediction_id: null,
}, },
{ {
alert_id: 'ALR-SITE005-1718458322', alert_id: 3,
timestamp: '2026-09-15T10:47:00',
site_id: 'SITE005', site_id: 'SITE005',
timestamp: '2026-09-15T10:47:00Z',
type: 'spike',
severity: 'high', severity: 'high',
type: 'threshold', message: 'Variation brutale entre deux lectures consécutives (260.0 kW -> 410.0 kW)',
message: 'Usine Toulouse approche de son seuil de capacité',
value: 410.0, value: 410.0,
threshold: 480.0, threshold: 260.0,
metric: 'consumption_kw',
prediction_id: null,
}, },
{ {
alert_id: 'ALR-SITE006-1718458323', alert_id: 2,
timestamp: '2026-09-15T10:30:00',
site_id: 'SITE006', site_id: 'SITE006',
severity: 'medium', timestamp: '2026-09-15T10:30:00Z',
type: 'sensor', type: 'sensor',
message: 'Capteur de température défaillant sur Bureau Lille', severity: 'medium',
value: 0, message: 'Qualité de mesure degraded (capteur hors ligne, valeur nulle)',
threshold: 0, value: null,
threshold: null,
metric: null,
prediction_id: null,
}, },
{ {
alert_id: 'ALR-SITE004-1718458324', alert_id: 1,
timestamp: '2026-09-15T09:58:00',
site_id: 'SITE004', site_id: 'SITE004',
severity: 'low', timestamp: '2026-09-15T09:58:00Z',
type: 'anomaly', 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, value: 62.0,
threshold: 55.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 { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AlertsService } from './alerts.service'; import { AlertsService } from './alerts.service';
import { environment } from '../../../environments/environment'; 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', () => { describe('AlertsService', () => {
let service: AlertsService; let service: AlertsService;
@@ -18,26 +32,35 @@ describe('AlertsService', () => {
afterEach(() => httpMock.verify()); afterEach(() => httpMock.verify());
it("appelle le bon endpoint et retourne un tableau d'alertes", () => { it("appelle le bon endpoint sans paramètre et retourne un tableau d'alertes", () => {
let result: unknown; let result: Alert[] = [];
service.getAlerts().subscribe((r) => (result = r)); service.getAlerts().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/alerts`); const req = httpMock.expectOne(
expect(req.request.method).toBe('GET'); (r) => r.url === `${environment.apiUrl}/alerts` && r.method === 'GET',
);
expect(req.request.params.keys()).toEqual([]);
req.flush([ALERT_API]);
req.flush([ expect(result.length).toBe(1);
{ expect(result[0].alert_id).toBe(1);
alert_id: 'ALR-TEST-1', expect(result[0].prediction_id).toBeNull();
timestamp: '2026-09-15T12:00:00', });
site_id: 'SITE001',
severity: 'high',
type: 'threshold',
message: 'Test',
value: 100,
threshold: 90,
},
]);
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 { Service, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from '../../../environments/environment'; 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() @Service()
export class AlertsService { export class AlertsService {
private http = inject(HttpClient); private http = inject(HttpClient);
getAlerts() { getAlerts(filters: AlertFilters = {}) {
return this.http.get<Alert[]>(`${environment.apiUrl}/alerts`); 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,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 AlertSeverity = 'low' | 'medium' | 'high' | 'critical';
export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor'; export type AlertType = 'spike' | 'threshold' | 'anomaly' | 'outage' | 'sensor';
export type AlertMetric = 'consumption_kw' | 'consumption_kwh';
export interface Alert { export interface Alert {
alert_id: string; alert_id: number;
timestamp: string;
site_id: string; site_id: string;
severity: AlertSeverity; timestamp: string;
type: AlertType; type: AlertType;
severity: AlertSeverity;
message: string; message: string;
value: number; value: number | null;
threshold: number; threshold: number | null;
metric: AlertMetric | null;
prediction_id: number | null;
} }