fix(frontend): traite la revue de phyri0s sur la vue détail d'un site
Quatre points portant sur le code de cette PR : - L'échec de chargement laissait à l'écran le site précédemment affiché sous le bandeau d'erreur : `reportUnavailable()` vide désormais site, mesure et historique, pour qu'on ne lise pas les chiffres de A en croyant regarder B. - L'historique était tracé à rebours : l'API trie en timestamp décroissant (`ReadingRepository.list_history`), le graphique rétablit la chronologie. - Une consommation `null` (panne capteur) alimentait la jauge avec un 0, indiscernable d'un site qui ne consomme rien : la jauge n'est plus montée dans ce cas, la raison de l'absence est affichée à la place. Une consommation réellement mesurée à 0 continue d'afficher la jauge. - `getSite` et `getCurrent` ne dépendent pas l'un de l'autre : `forkJoin` économise un aller-retour en série à chaque ouverture de la page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d3e402ca4
commit
33807e3038
@@ -36,13 +36,18 @@
|
||||
<section class="overview">
|
||||
<ev-card class="card card--gauge">
|
||||
<span class="card__label">Consommation vs capacité</span>
|
||||
<app-consumption-gauge
|
||||
[consumption]="current()?.consumption_kw ?? 0"
|
||||
[capacity]="s.capacity_kw ?? 0"
|
||||
/>
|
||||
<span class="card__value">
|
||||
{{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW
|
||||
</span>
|
||||
@let consumption = consumptionKw();
|
||||
@if (consumption !== null) {
|
||||
<app-consumption-gauge [consumption]="consumption" [capacity]="s.capacity_kw ?? 0" />
|
||||
<span class="card__value">
|
||||
{{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW
|
||||
</span>
|
||||
} @else {
|
||||
<p class="card__unavailable">
|
||||
Consommation indisponible
|
||||
<span class="metric__reason">({{ consumptionReason() }})</span>
|
||||
</p>
|
||||
}
|
||||
</ev-card>
|
||||
|
||||
<ev-card class="metrics-card">
|
||||
|
||||
@@ -74,6 +74,11 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card__unavailable {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -116,6 +116,86 @@ describe('SiteDetail', () => {
|
||||
expect(fixture.componentInstance.site()).toBeNull();
|
||||
});
|
||||
|
||||
it('efface les données du site précédent quand le chargement du suivant échoue', () => {
|
||||
const getSite = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(of(SITE))
|
||||
.mockReturnValueOnce(throwError(() => new Error('404')));
|
||||
const { fixture, paramMap } = setup(
|
||||
'SITE001',
|
||||
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
|
||||
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
|
||||
|
||||
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
||||
expect(fixture.componentInstance.site()).toBeNull();
|
||||
expect(fixture.componentInstance.current()).toBeNull();
|
||||
expect(fixture.componentInstance.history()).toEqual([]);
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Site 1');
|
||||
});
|
||||
|
||||
it('interroge le site et sa mesure courante en parallèle', () => {
|
||||
const getSite = vi.fn().mockReturnValue(of(SITE));
|
||||
const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET));
|
||||
const { fixture } = setup(
|
||||
'SITE001',
|
||||
{ getSite, getCurrent },
|
||||
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getSite).toHaveBeenCalledWith('SITE001');
|
||||
expect(getCurrent).toHaveBeenCalledWith('SITE001');
|
||||
});
|
||||
|
||||
it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => {
|
||||
const sansConsommation = {
|
||||
...CURRENT_COMPLET,
|
||||
consumption_kw: null,
|
||||
null_reasons: ['consumption_sensor_failure'],
|
||||
data_quality: 'partial' as const,
|
||||
};
|
||||
const { fixture } = setup(
|
||||
'SITE001',
|
||||
{
|
||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
||||
getCurrent: vi.fn().mockReturnValue(of(sansConsommation)),
|
||||
},
|
||||
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.consumptionKw()).toBeNull();
|
||||
expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne');
|
||||
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull();
|
||||
expect(fixture.nativeElement.textContent).toContain('Consommation indisponible');
|
||||
});
|
||||
|
||||
it('trace la jauge pour une consommation nulle réellement mesurée', () => {
|
||||
const { fixture } = setup(
|
||||
'SITE001',
|
||||
{
|
||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
||||
getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })),
|
||||
},
|
||||
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
|
||||
);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW');
|
||||
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible');
|
||||
});
|
||||
|
||||
it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => {
|
||||
const partielle = {
|
||||
...CURRENT_COMPLET,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { catchError, EMPTY, filter, map, Observable, of, switchMap } from 'rxjs';
|
||||
import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs';
|
||||
import { SitesService } from '../../../core/services/sites.service';
|
||||
import { ReadingsService } from '../../../core/services/readings.service';
|
||||
import { Site } from '../../../shared/models/site.model';
|
||||
@@ -52,8 +52,14 @@ interface MetricDef {
|
||||
format: (value: number) => string;
|
||||
}
|
||||
|
||||
const CONSUMPTION_DEF: MetricDef = {
|
||||
key: 'consumption_kw',
|
||||
label: 'Consommation',
|
||||
format: (v) => `${v.toFixed(1)} kW`,
|
||||
};
|
||||
|
||||
const METRIC_DEFS: MetricDef[] = [
|
||||
{ key: 'consumption_kw', label: 'Consommation', format: (v) => `${v.toFixed(1)} kW` },
|
||||
CONSUMPTION_DEF,
|
||||
{ key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` },
|
||||
{ key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` },
|
||||
{ key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) },
|
||||
@@ -111,6 +117,15 @@ export class SiteDetail {
|
||||
|
||||
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
||||
|
||||
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
||||
|
||||
consumptionLabel = computed(() => {
|
||||
const kw = this.consumptionKw();
|
||||
return kw != null ? CONSUMPTION_DEF.format(kw) : null;
|
||||
});
|
||||
|
||||
consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current()));
|
||||
|
||||
qualityLabel = computed(() => {
|
||||
const quality = this.current()?.data_quality;
|
||||
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
|
||||
@@ -156,10 +171,10 @@ export class SiteDetail {
|
||||
}
|
||||
|
||||
private load(siteId: string) {
|
||||
return this.sitesService.getSite(siteId).pipe(
|
||||
switchMap((site) =>
|
||||
this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))),
|
||||
),
|
||||
return forkJoin({
|
||||
site: this.sitesService.getSite(siteId),
|
||||
current: this.sitesService.getCurrent(siteId),
|
||||
}).pipe(
|
||||
switchMap(({ site, current }) =>
|
||||
this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))),
|
||||
),
|
||||
@@ -186,8 +201,13 @@ export class SiteDetail {
|
||||
return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue';
|
||||
}
|
||||
|
||||
// Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé
|
||||
// sous le bandeau et laisse lire les chiffres de A en croyant regarder B.
|
||||
private reportUnavailable(): Observable<never> {
|
||||
this.error.set(UNAVAILABLE_MESSAGE);
|
||||
this.site.set(null);
|
||||
this.current.set(null);
|
||||
this.history.set([]);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -9,15 +9,21 @@ vi.mock('chart.js', () => {
|
||||
static register = vi.fn();
|
||||
update = vi.fn();
|
||||
destroy = vi.fn();
|
||||
data = { datasets: [{}] };
|
||||
constructor() {
|
||||
data: { labels?: unknown[]; datasets: Record<string, unknown>[] } = { datasets: [{}] };
|
||||
constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) {
|
||||
if (config?.data) {
|
||||
this.data = config.data;
|
||||
}
|
||||
ChartMock.instances.push(this);
|
||||
}
|
||||
}
|
||||
return { Chart: ChartMock, registerables: [] };
|
||||
});
|
||||
|
||||
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
|
||||
type ChartDouble = {
|
||||
destroy: ReturnType<typeof vi.fn>;
|
||||
data: { labels?: unknown[]; datasets: Record<string, unknown>[] };
|
||||
};
|
||||
|
||||
function lastChart(): ChartDouble | undefined {
|
||||
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
||||
@@ -66,6 +72,21 @@ describe('ReadingHistoryChart', () => {
|
||||
expect(() => fixture.detectChanges()).not.toThrow();
|
||||
});
|
||||
|
||||
it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => {
|
||||
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
||||
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
||||
// L'API trie en timestamp décroissant : le composant doit rétablir la chronologie.
|
||||
fixture.componentRef.setInput('readings', [
|
||||
{ ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 },
|
||||
{ ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const chart = lastChart();
|
||||
expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']);
|
||||
expect(chart?.data.datasets[0]['data']).toEqual([42, 60]);
|
||||
});
|
||||
|
||||
it('détruit le graphique quand le composant est détruit', () => {
|
||||
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
||||
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
||||
|
||||
+25
-10
@@ -20,8 +20,23 @@ const QUALITY_COLORS: Record<ReadingDataQuality, string> = {
|
||||
};
|
||||
const UNKNOWN_QUALITY_COLOR = '#9ca3af';
|
||||
|
||||
function pointColors(readings: Reading[]): string[] {
|
||||
return readings.map((r) => (r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR));
|
||||
interface ChartSeries {
|
||||
labels: string[];
|
||||
values: number[];
|
||||
colors: string[];
|
||||
}
|
||||
|
||||
// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history
|
||||
// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours.
|
||||
function toSeries(readings: Reading[]): ChartSeries {
|
||||
const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
return {
|
||||
labels: ordered.map((r) => r.timestamp),
|
||||
values: ordered.map((r) => r.consumption_kw ?? 0),
|
||||
colors: ordered.map((r) =>
|
||||
r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -38,27 +53,27 @@ export class ReadingHistoryChart implements AfterViewInit, OnDestroy {
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const readings = this.readings();
|
||||
const series = toSeries(this.readings());
|
||||
if (this.chart) {
|
||||
this.chart.data.labels = readings.map((r) => r.timestamp);
|
||||
this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0);
|
||||
this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings);
|
||||
this.chart.data.labels = series.labels;
|
||||
this.chart.data.datasets[0].data = series.values;
|
||||
this.chart.data.datasets[0].pointBackgroundColor = series.colors;
|
||||
this.chart.update('none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const readings = this.readings();
|
||||
const series = toSeries(this.readings());
|
||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: readings.map((r) => r.timestamp),
|
||||
labels: series.labels,
|
||||
datasets: [
|
||||
{
|
||||
data: readings.map((r) => r.consumption_kw ?? 0),
|
||||
data: series.values,
|
||||
borderColor: '#3b82f6',
|
||||
pointBackgroundColor: pointColors(readings),
|
||||
pointBackgroundColor: series.colors,
|
||||
tension: 0.25,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user