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]; }