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">
|
<section class="overview">
|
||||||
<ev-card class="card card--gauge">
|
<ev-card class="card card--gauge">
|
||||||
<span class="card__label">Consommation vs capacité</span>
|
<span class="card__label">Consommation vs capacité</span>
|
||||||
<app-consumption-gauge
|
@let consumption = consumptionKw();
|
||||||
[consumption]="current()?.consumption_kw ?? 0"
|
@if (consumption !== null) {
|
||||||
[capacity]="s.capacity_kw ?? 0"
|
<app-consumption-gauge [consumption]="consumption" [capacity]="s.capacity_kw ?? 0" />
|
||||||
/>
|
<span class="card__value">
|
||||||
<span class="card__value">
|
{{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW
|
||||||
{{ current()?.consumption_kw ?? '-' }} / {{ s.capacity_kw ?? '-' }} kW
|
</span>
|
||||||
</span>
|
} @else {
|
||||||
|
<p class="card__unavailable">
|
||||||
|
Consommation indisponible
|
||||||
|
<span class="metric__reason">({{ consumptionReason() }})</span>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</ev-card>
|
</ev-card>
|
||||||
|
|
||||||
<ev-card class="metrics-card">
|
<ev-card class="metrics-card">
|
||||||
|
|||||||
@@ -74,6 +74,11 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card__unavailable {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.metrics-grid {
|
.metrics-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
|||||||
@@ -116,6 +116,86 @@ describe('SiteDetail', () => {
|
|||||||
expect(fixture.componentInstance.site()).toBeNull();
|
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', () => {
|
it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => {
|
||||||
const partielle = {
|
const partielle = {
|
||||||
...CURRENT_COMPLET,
|
...CURRENT_COMPLET,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
||||||
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
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 { SitesService } from '../../../core/services/sites.service';
|
||||||
import { ReadingsService } from '../../../core/services/readings.service';
|
import { ReadingsService } from '../../../core/services/readings.service';
|
||||||
import { Site } from '../../../shared/models/site.model';
|
import { Site } from '../../../shared/models/site.model';
|
||||||
@@ -52,8 +52,14 @@ interface MetricDef {
|
|||||||
format: (value: number) => string;
|
format: (value: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONSUMPTION_DEF: MetricDef = {
|
||||||
|
key: 'consumption_kw',
|
||||||
|
label: 'Consommation',
|
||||||
|
format: (v) => `${v.toFixed(1)} kW`,
|
||||||
|
};
|
||||||
|
|
||||||
const METRIC_DEFS: MetricDef[] = [
|
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: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` },
|
||||||
{ key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` },
|
{ key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` },
|
||||||
{ key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) },
|
{ key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) },
|
||||||
@@ -111,6 +117,15 @@ export class SiteDetail {
|
|||||||
|
|
||||||
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
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(() => {
|
qualityLabel = computed(() => {
|
||||||
const quality = this.current()?.data_quality;
|
const quality = this.current()?.data_quality;
|
||||||
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
|
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
|
||||||
@@ -156,10 +171,10 @@ export class SiteDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private load(siteId: string) {
|
private load(siteId: string) {
|
||||||
return this.sitesService.getSite(siteId).pipe(
|
return forkJoin({
|
||||||
switchMap((site) =>
|
site: this.sitesService.getSite(siteId),
|
||||||
this.sitesService.getCurrent(siteId).pipe(map((current) => ({ site, current }))),
|
current: this.sitesService.getCurrent(siteId),
|
||||||
),
|
}).pipe(
|
||||||
switchMap(({ site, current }) =>
|
switchMap(({ site, current }) =>
|
||||||
this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))),
|
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';
|
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> {
|
private reportUnavailable(): Observable<never> {
|
||||||
this.error.set(UNAVAILABLE_MESSAGE);
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
this.site.set(null);
|
||||||
|
this.current.set(null);
|
||||||
|
this.history.set([]);
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -9,15 +9,21 @@ vi.mock('chart.js', () => {
|
|||||||
static register = vi.fn();
|
static register = vi.fn();
|
||||||
update = vi.fn();
|
update = vi.fn();
|
||||||
destroy = vi.fn();
|
destroy = vi.fn();
|
||||||
data = { datasets: [{}] };
|
data: { labels?: unknown[]; datasets: Record<string, unknown>[] } = { datasets: [{}] };
|
||||||
constructor() {
|
constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) {
|
||||||
|
if (config?.data) {
|
||||||
|
this.data = config.data;
|
||||||
|
}
|
||||||
ChartMock.instances.push(this);
|
ChartMock.instances.push(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { Chart: ChartMock, registerables: [] };
|
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 {
|
function lastChart(): ChartDouble | undefined {
|
||||||
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
||||||
@@ -66,6 +72,21 @@ describe('ReadingHistoryChart', () => {
|
|||||||
expect(() => fixture.detectChanges()).not.toThrow();
|
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', () => {
|
it('détruit le graphique quand le composant est détruit', () => {
|
||||||
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
||||||
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
||||||
|
|||||||
+25
-10
@@ -20,8 +20,23 @@ const QUALITY_COLORS: Record<ReadingDataQuality, string> = {
|
|||||||
};
|
};
|
||||||
const UNKNOWN_QUALITY_COLOR = '#9ca3af';
|
const UNKNOWN_QUALITY_COLOR = '#9ca3af';
|
||||||
|
|
||||||
function pointColors(readings: Reading[]): string[] {
|
interface ChartSeries {
|
||||||
return readings.map((r) => (r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR));
|
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({
|
@Component({
|
||||||
@@ -38,27 +53,27 @@ export class ReadingHistoryChart implements AfterViewInit, OnDestroy {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const readings = this.readings();
|
const series = toSeries(this.readings());
|
||||||
if (this.chart) {
|
if (this.chart) {
|
||||||
this.chart.data.labels = readings.map((r) => r.timestamp);
|
this.chart.data.labels = series.labels;
|
||||||
this.chart.data.datasets[0].data = readings.map((r) => r.consumption_kw ?? 0);
|
this.chart.data.datasets[0].data = series.values;
|
||||||
this.chart.data.datasets[0].pointBackgroundColor = pointColors(readings);
|
this.chart.data.datasets[0].pointBackgroundColor = series.colors;
|
||||||
this.chart.update('none');
|
this.chart.update('none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
const readings = this.readings();
|
const series = toSeries(this.readings());
|
||||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
this.chart = new Chart(this.canvasRef.nativeElement, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: {
|
data: {
|
||||||
labels: readings.map((r) => r.timestamp),
|
labels: series.labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: readings.map((r) => r.consumption_kw ?? 0),
|
data: series.values,
|
||||||
borderColor: '#3b82f6',
|
borderColor: '#3b82f6',
|
||||||
pointBackgroundColor: pointColors(readings),
|
pointBackgroundColor: series.colors,
|
||||||
tension: 0.25,
|
tension: 0.25,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user