From 9d3e402ca49c42213cefa16e38f93ca474444c9f Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 11:11:12 +0200 Subject: [PATCH] =?UTF-8?q?test(frontend):=20couvre=20la=20vue=20d=C3=A9ta?= =?UTF-8?q?il=20sur=20le=20contrat=20SiteCurrentResponse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le cas « site sans mesure » ne passe plus par une réponse nulle mais par `timestamp` à null : il ne doit alors pas interroger `/readings`, et la vue annonce l'absence de mesure au lieu de six métriques de cause inconnue. --- .../core/services/readings.service.spec.ts | 30 +----- .../app/core/services/sites.service.spec.ts | 27 +++++ .../sites/site-detail/site-detail.scss | 12 +++ .../sites/site-detail/site-detail.spec.ts | 101 ++++++++++++------ .../features/sites/site-detail/site-detail.ts | 2 +- 5 files changed, 110 insertions(+), 62 deletions(-) diff --git a/apps/frontend/src/app/core/services/readings.service.spec.ts b/apps/frontend/src/app/core/services/readings.service.spec.ts index 98a3177..2513f08 100644 --- a/apps/frontend/src/app/core/services/readings.service.spec.ts +++ b/apps/frontend/src/app/core/services/readings.service.spec.ts @@ -18,33 +18,11 @@ describe('ReadingsService', () => { afterEach(() => httpMock.verify()); - it("demande la dernière lecture connue du site, quel que soit son âge", () => { - let result: unknown; - service.getLatest('SITE001').subscribe((r) => (result = r)); - - const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); - expect(req.request.method).toBe('GET'); - - req.flush({ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }); - - expect((result as { reading_id: number }).reading_id).toBe(1); - }); - - it("retourne null quand le site n'a aucune lecture", () => { - let result: unknown; - service.getLatest('SITE001').subscribe((r) => (result = r)); - - const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); - req.flush(null); - - expect(result).toBeNull(); - }); - it("demande l'historique du site avec la fenêtre temporelle donnée", () => { let result: unknown; - service.getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z').subscribe( - (r) => (result = r), - ); + service + .getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z') + .subscribe((r) => (result = r)); const req = httpMock.expectOne( (r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET', @@ -58,7 +36,7 @@ describe('ReadingsService', () => { expect((result as unknown[]).length).toBe(1); }); - it("ne pose pas de paramètres start/end quand ils sont omis", () => { + it('ne pose pas de paramètres start/end quand ils sont omis', () => { service.getHistory('SITE001').subscribe(); const req = httpMock.expectOne( diff --git a/apps/frontend/src/app/core/services/sites.service.spec.ts b/apps/frontend/src/app/core/services/sites.service.spec.ts index 8eabeb2..7f475e7 100644 --- a/apps/frontend/src/app/core/services/sites.service.spec.ts +++ b/apps/frontend/src/app/core/services/sites.service.spec.ts @@ -57,4 +57,31 @@ describe('SitesService', () => { expect((result as { site_id: string }).site_id).toBe('SITE001'); }); + + it('appelle le bon endpoint et retourne la mesure courante du site', () => { + let result: unknown; + service.getCurrent('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`); + expect(req.request.method).toBe('GET'); + + req.flush({ + timestamp: '2026-09-17T10:00:00Z', + site_id: 'SITE001', + site_type: 'industriel', + consumption_kw: 120, + consumption_kwh: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: 22, + humidity_percent: 55, + null_reasons: ['electrical_sensor_failure'], + data_quality: 'partial', + }); + + expect((result as { null_reasons: string[] }).null_reasons).toEqual([ + 'electrical_sensor_failure', + ]); + }); }); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss index fbb17c6..78dd9ed 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.scss +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.scss @@ -28,11 +28,23 @@ color: var(--color-text-muted); } +.site-detail__badges { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + .banner-error { display: block; margin: 0 0 1.5rem; } +.banner-empty { + display: block; + margin: 0 0 1.5rem; +} + .overview { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts index 11e6c8f..3fdc269 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.spec.ts @@ -15,12 +15,39 @@ const SITE = { status: 'actif', }; -const READING_COMPLETE = { +const CURRENT_COMPLET = { + timestamp: '2026-09-17T10:00:00Z', + site_id: 'SITE001', + site_type: 'industriel', + consumption_kw: 120, + consumption_kwh: null, + voltage_v: 230, + current_a: 12, + power_factor: 0.95, + temperature_celsius: 22, + humidity_percent: 55, + null_reasons: [] as string[], + data_quality: 'good' as const, +}; + +const SANS_MESURE = { + ...CURRENT_COMPLET, + timestamp: null, + consumption_kw: null, + voltage_v: null, + current_a: null, + power_factor: null, + temperature_celsius: null, + humidity_percent: null, + data_quality: 'critical' as const, +}; + +const LECTURE = { reading_id: 1, site_id: 'SITE001', - timestamp: '2026-09-17T10:00:00Z', - source: 'api_current' as const, - consumption_kw: 120, + timestamp: '2026-09-17T09:00:00Z', + source: 'api_history' as const, + consumption_kw: 118, consumption_kwh: null, consumption_euros: null, voltage_v: 230, @@ -55,20 +82,20 @@ function setup( } describe('SiteDetail', () => { - it('charge le site, la dernière lecture et son historique au démarrage', () => { + it('charge le site, la mesure courante et son historique au démarrage', () => { const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([READING_COMPLETE])), + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, ); fixture.detectChanges(); expect(fixture.componentInstance.site()?.site_id).toBe('SITE001'); - expect(fixture.componentInstance.latestReading()?.consumption_kw).toBe(120); + expect(fixture.componentInstance.current()?.consumption_kw).toBe(120); expect(fixture.componentInstance.history().length).toBe(1); expect(fixture.componentInstance.error()).toBeNull(); }); @@ -76,11 +103,11 @@ describe('SiteDetail', () => { it("signale l'indisponibilité quand un des appels échoue", () => { const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))) }, { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([])), + getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), }, + { getHistory: vi.fn().mockReturnValue(of([])) }, ); fixture.detectChanges(); @@ -90,44 +117,41 @@ describe('SiteDetail', () => { }); it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => { - const readingPartielle = { - ...READING_COMPLETE, + const partielle = { + ...CURRENT_COMPLET, voltage_v: null, current_a: null, power_factor: null, null_reasons: ['electrical_sensor_failure'], + data_quality: 'partial' as const, }; const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, { - getLatest: vi.fn().mockReturnValue(of(readingPartielle)), - getHistory: vi.fn().mockReturnValue(of([readingPartielle])), + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(partielle)), }, + { getHistory: vi.fn().mockReturnValue(of([LECTURE])) }, ); fixture.detectChanges(); - const tension = fixture.componentInstance - .metrics() - .find((m) => m.key === 'voltage_v'); + const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v'); expect(tension?.value).toBeNull(); expect(tension?.reason).toBe('capteur électrique en panne'); - const html = fixture.nativeElement.textContent; - expect(html).toContain('Indisponible'); - expect(html).toContain('capteur électrique en panne'); + const texte = fixture.nativeElement.textContent; + expect(texte).toContain('Indisponible'); + expect(texte).toContain('capteur électrique en panne'); + expect(texte).toContain('Données partielles'); }); it('recharge les données quand le paramètre de route siteId change', () => { const getSite = vi.fn().mockReturnValue(of(SITE)); const { fixture, paramMap } = setup( 'SITE001', - { getSite }, - { - getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), - getHistory: vi.fn().mockReturnValue(of([])), - }, + { getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) }, + { getHistory: vi.fn().mockReturnValue(of([])) }, ); fixture.detectChanges(); @@ -141,8 +165,11 @@ describe('SiteDetail', () => { const getHistory = vi.fn().mockReturnValue(of([])); const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, - { getLatest: vi.fn().mockReturnValue(of(READING_COMPLETE)), getHistory }, + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)), + }, + { getHistory }, ); fixture.detectChanges(); @@ -154,17 +181,21 @@ describe('SiteDetail', () => { ); }); - it("ne fixe aucune fenêtre d'historique quand le site n'a aucune lecture", () => { + it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => { const getHistory = vi.fn().mockReturnValue(of([])); const { fixture } = setup( 'SITE001', - { getSite: vi.fn().mockReturnValue(of(SITE)) }, - { getLatest: vi.fn().mockReturnValue(of(null)), getHistory }, + { + getSite: vi.fn().mockReturnValue(of(SITE)), + getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)), + }, + { getHistory }, ); fixture.detectChanges(); - expect(getHistory).toHaveBeenCalledWith('SITE001', undefined, undefined); - expect(fixture.componentInstance.latestReading()).toBeNull(); + expect(getHistory).not.toHaveBeenCalled(); + expect(fixture.componentInstance.hasMeasurement()).toBe(false); + expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.'); }); }); diff --git a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts index 778ec8f..047614a 100644 --- a/apps/frontend/src/app/features/sites/site-detail/site-detail.ts +++ b/apps/frontend/src/app/features/sites/site-detail/site-detail.ts @@ -76,7 +76,7 @@ const LIBELLE_PAR_RAISON: Record = { consumption_sensor_failure: 'capteur de consommation en panne', electrical_sensor_failure: 'capteur électrique en panne', temperature_sensor_failure: 'capteur de température en panne', - humidity_sensor_failure: 'capteur d\'humidité en panne', + humidity_sensor_failure: "capteur d'humidité en panne", network_loss: 'perte réseau', };