From c2bd1317edba6580a30928710a1768272ff0875f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 21 Sep 2026 14:32:07 +0200 Subject: [PATCH] refactor(frontend): remplace la section alertes du dashboard par app-alert-feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le dashboard ne charge plus les alertes lui-même : le widget porte le flux, ses filtres, ses états et son rafraîchissement. Disparaissent la liste rouge quelle que soit la sévérité, le bandeau d'erreur dédié et la copie locale de la table sévérité vers ton, désormais dans alert-presentation.ts. Le spec passe par un helper setup() qui fournit aussi les services du widget enfant. --- .../src/app/features/dashboard/dashboard.html | 25 +- .../src/app/features/dashboard/dashboard.scss | 23 +- .../app/features/dashboard/dashboard.spec.ts | 266 ++++++------------ .../src/app/features/dashboard/dashboard.ts | 34 +-- 4 files changed, 94 insertions(+), 254 deletions(-) diff --git a/apps/frontend/src/app/features/dashboard/dashboard.html b/apps/frontend/src/app/features/dashboard/dashboard.html index f9a3fb2..3d52c11 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.html +++ b/apps/frontend/src/app/features/dashboard/dashboard.html @@ -27,9 +27,6 @@ @if (statsError(); as message) { } - @if (alertsError(); as message) { - - } @if (predictionsError(); as message) { } @@ -68,19 +65,9 @@ } - @if (alerts().length > 0) { -
-

Alertes actives

- -
- } +
+ +
@if (predictions().length > 0) {
@@ -93,9 +80,9 @@ @if (prediction.status === 'available') { {{ prediction.predicted_value | number: '1.0-1' }} kWh - {{ prediction.target_at | date: "dd/MM 'à' HH:mm" }} + {{ + prediction.target_at | date: "dd/MM 'à' HH:mm" + }} } @else { {{ diff --git a/apps/frontend/src/app/features/dashboard/dashboard.scss b/apps/frontend/src/app/features/dashboard/dashboard.scss index 9c89f56..af0dd39 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.scss +++ b/apps/frontend/src/app/features/dashboard/dashboard.scss @@ -108,27 +108,8 @@ h2 { margin-bottom: 2.5rem; } -.alerts-list { - list-style: none; - 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; +.alerts-section { + margin-bottom: 2.5rem; } .predictions-list { diff --git a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts index 910b0f6..a3c52d7 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.spec.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.spec.ts @@ -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; + predictions?: { getPredictions: ReturnType }; + auth?: Record; + } = {}, +) { + 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,41 @@ 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']); -}); - - 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([]), - ], - }); - - const fixture = TestBed.createComponent(Dashboard); - 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(authMock.clearSession).toHaveBeenCalled(); + expect(navigateSpy).toHaveBeenCalledWith(['/login']); }); 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'); diff --git a/apps/frontend/src/app/features/dashboard/dashboard.ts b/apps/frontend/src/app/features/dashboard/dashboard.ts index 2ba20c0..3d0b422 100644 --- a/apps/frontend/src/app/features/dashboard/dashboard.ts +++ b/apps/frontend/src/app/features/dashboard/dashboard.ts @@ -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,13 +21,6 @@ const REFRESH_INTERVAL_MS = 10000; const UNAVAILABLE_MESSAGE = 'Données indisponibles, les valeurs affichées datent du dernier relevé.'; -const TON_PAR_SEVERITE: Record = { - 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. @@ -47,6 +39,7 @@ const TON_PAR_STATUT_PREDICTION: Record = { RouterLink, ConsumptionGauge, SiteLoadChart, + AlertFeed, Card, EvAlert, Badge, @@ -58,32 +51,21 @@ const TON_PAR_STATUT_PREDICTION: Record = { }) 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(null); - alerts = signal([]); predictions = signal([]); // 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. + // efface silencieusement un message d'échec des prévisions après 10s au plus, sans retry ni + // indication pour l'utilisateur que la section correspondante est restée vide. statsError = signal(null); - alertsError = signal(null); predictionsError = signal(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 +81,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,10 +93,6 @@ export class Dashboard implements OnInit { }); } - badgeToneForSeverity(severity: AlertSeverity): BadgeTone { - return TON_PAR_SEVERITE[severity]; - } - badgeToneForPredictionStatus(status: PredictionStatus): BadgeTone { return TON_PAR_STATUT_PREDICTION[status]; }