fix(frontend): maintient le rafraichissement du tableau de bord en cas d'erreur
Sans catchError, la premiere reponse en erreur terminait le flux du timer : le rafraichissement ne repartait jamais et l'ecran restait fige sur des chiffres perimes, sans rien signaler. Le catchError porte sur l'observable interne du switchMap. Place sur le flux externe il terminerait le timer tout autant. Un signal error alimente un bandeau, efface des qu'une reponse valide revient.
This commit is contained in:
@@ -4,6 +4,10 @@
|
||||
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
|
||||
</header>
|
||||
|
||||
@if (error(); as message) {
|
||||
<p class="banner-error" role="alert">{{ message }}</p>
|
||||
}
|
||||
|
||||
@if (stats(); as s) {
|
||||
<section class="overview">
|
||||
<div class="card card--gauge">
|
||||
|
||||
@@ -37,6 +37,16 @@ h2 {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.banner-error {
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--color-critical);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius);
|
||||
background: #fdecea;
|
||||
color: var(--color-critical);
|
||||
}
|
||||
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { vi } from 'vitest';
|
||||
import { of } from 'rxjs';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { Dashboard } from './dashboard';
|
||||
import { StatsService } from '../../core/services/stats.service';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
@@ -8,6 +8,7 @@ import { AlertsService } from '../../core/services/alerts.service';
|
||||
vi.mock('chart.js', () => {
|
||||
class ChartMock {
|
||||
update = vi.fn();
|
||||
destroy = vi.fn();
|
||||
data = { datasets: [{}] };
|
||||
static register = vi.fn();
|
||||
}
|
||||
@@ -15,6 +16,8 @@ vi.mock('chart.js', () => {
|
||||
});
|
||||
|
||||
describe('Dashboard', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('charge les stats et les alertes 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' }])) };
|
||||
@@ -37,5 +40,56 @@ describe('Dashboard', () => {
|
||||
expect(statsMock.getSummary).toHaveBeenCalled();
|
||||
expect(alertsMock.getAlerts).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.alerts().length).toBe(1);
|
||||
expect(fixture.componentInstance.error()).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 },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||
expect(fixture.componentInstance.stats()).toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(statsMock.getSummary).toHaveBeenCalledTimes(2);
|
||||
expect(fixture.componentInstance.stats()).not.toBeNull();
|
||||
expect(fixture.componentInstance.error()).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 },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Dashboard);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.alerts().length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { timer, switchMap } from 'rxjs';
|
||||
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
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 {StatsSummary} from '../../shared/models/stats.model';
|
||||
import {Alert} from '../../shared/models/alert.model';
|
||||
import { AlertsService } from '../../core/services/alerts.service';
|
||||
import { StatsSummary } from '../../shared/models/stats.model';
|
||||
import { Alert } from '../../shared/models/alert.model';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10000;
|
||||
const UNAVAILABLE_MESSAGE =
|
||||
'Données indisponibles, les valeurs affichées datent du dernier relevé.';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@@ -25,15 +27,31 @@ export class Dashboard implements OnInit {
|
||||
|
||||
stats = signal<StatsSummary | null>(null);
|
||||
alerts = signal<Alert[]>([]);
|
||||
error = signal<string | null>(null);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.alertsService.getAlerts().subscribe((alerts) => this.alerts.set(alerts));
|
||||
this.alertsService
|
||||
.getAlerts()
|
||||
.pipe(catchError(() => this.reportUnavailable()))
|
||||
.subscribe((alerts) => this.alerts.set(alerts));
|
||||
|
||||
// Piège : le catchError porte sur l'observable interne. Sur le flux externe il
|
||||
// terminerait le timer, et le rafraîchissement ne repartirait jamais.
|
||||
timer(0, REFRESH_INTERVAL_MS)
|
||||
.pipe(
|
||||
switchMap(() => this.statsService.getSummary()),
|
||||
switchMap(() =>
|
||||
this.statsService.getSummary().pipe(catchError(() => this.reportUnavailable()))
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe((stats) => this.stats.set(stats));
|
||||
.subscribe((stats) => {
|
||||
this.error.set(null);
|
||||
this.stats.set(stats);
|
||||
});
|
||||
}
|
||||
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user