Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d535959fbe | ||
|
|
33807e3038 | ||
|
|
c914d41f33 | ||
|
|
d3047f53e8 | ||
|
|
ec63798807 | ||
|
|
1e168ef03e | ||
|
|
ddf7e17788 | ||
|
|
56e8f95729 | ||
|
|
91d435748c | ||
|
|
0b41580310 | ||
|
|
e3d436ea53 | ||
|
|
c453700b13 | ||
|
|
f427f8a8f3 | ||
|
|
7be8a44e89 | ||
|
|
2095ad6bd3 | ||
|
|
77feabcbad | ||
|
|
18a4be6e38 | ||
|
|
12fb8860d1 | ||
|
|
7c2936f2ef | ||
|
|
9d3e402ca4 | ||
|
|
7db57f162c | ||
|
|
9ccc603d88 | ||
|
|
e26948932e | ||
|
|
41acdb54b6 | ||
|
|
5d921a9b1e | ||
|
|
1cca4f130c |
@@ -51,30 +51,14 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
- run: npm ci
|
- name : Installation des dépendances (Front)
|
||||||
|
run: npm ci
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
- run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
- name : Lancement des tests et génénration du rapport de couverture (Front)
|
||||||
|
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend/lcov.info
|
path: apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|
||||||
sonarqube:
|
|
||||||
needs: [build, test]
|
|
||||||
name: SonarQube
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Download coverage
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-coverage
|
|
||||||
path: apps/frontend/coverage/frontend
|
|
||||||
- name: SonarQube Scan
|
|
||||||
uses: SonarSource/sonarqube-scan-action@v8
|
|
||||||
env:
|
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
name: SonarQube
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "apps/frontend/**"
|
||||||
|
- "apps/backend/**"
|
||||||
|
- ".github/workflows/sonarqube.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "apps/frontend/**"
|
||||||
|
- "apps/backend/**"
|
||||||
|
- ".github/workflows/sonarqube.yml"
|
||||||
|
|
||||||
|
|
||||||
|
# Build l'ensemble du projet, puis lance les tests
|
||||||
|
# Génère les rapports de couverture, puis lance l'analyse SonarQube
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-front:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
working-directory: apps/frontend
|
||||||
|
- run: npm run build
|
||||||
|
working-directory: apps/frontend
|
||||||
|
|
||||||
|
test-front:
|
||||||
|
needs: build-front
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
|
|
||||||
|
- name : Installation des dépendances (Front)
|
||||||
|
run: npm ci
|
||||||
|
working-directory: apps/frontend
|
||||||
|
|
||||||
|
- name : Lancement des tests et génénration du rapport de couverture (Front)
|
||||||
|
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
||||||
|
working-directory: apps/frontend
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|
||||||
|
build-back:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
|
- name: Installe l'interpréteur déclaré par .python-version
|
||||||
|
run: uv python install
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Synchronise les dépendances sans dévier du verrou
|
||||||
|
run: uv sync --all-groups --frozen
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Vérifie le formatage
|
||||||
|
run: uv run ruff format --check .
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Analyse statique
|
||||||
|
run: uv run ruff check --output-format=github .
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Typage
|
||||||
|
run: uv run mypy app
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
|
||||||
|
test-back:
|
||||||
|
needs: build-back
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Installe uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
cache-dependency-glob: apps/backend/uv.lock
|
||||||
|
|
||||||
|
- name : Lancement des tests et génénration du rapport de couverture (Back)
|
||||||
|
run: uv run pytest --cov-fail-under=85 --cov-report=xml
|
||||||
|
working-directory: apps/backend
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend-coverage
|
||||||
|
path: apps/backend/coverage.xml
|
||||||
|
|
||||||
|
sonarqube:
|
||||||
|
needs: [build-front, build-back, test-front, test-back]
|
||||||
|
name: SonarQube
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Téléchargement du rapport de couverture (Front)
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: apps/frontend/coverage/frontend
|
||||||
|
- name: Téléchargement du rapport de couverture (Back)
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend-coverage
|
||||||
|
path: apps/backend
|
||||||
|
- name: SonarQube Scan
|
||||||
|
uses: SonarSource/sonarqube-scan-action@v8
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
sonar.projectKey=ProjetPiscine_EnerVision
|
||||||
|
sonar.organization=groupe3-ener-vision
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
# Dossier contenant le code source
|
||||||
|
sonar.sources=apps/frontend/src,apps/backend/app
|
||||||
|
sonar.tests=apps/backend/tests
|
||||||
|
|
||||||
|
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||||
|
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||||
|
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
|
||||||
|
|
||||||
|
# Chemin vers le rapport de couverture de code
|
||||||
|
# Fichier généré par Vitest
|
||||||
|
# Chemin vers le rapport de couverture de code
|
||||||
|
# Fichier généré par Vitest
|
||||||
|
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||||
|
sonar.python.coverage.reportPaths=apps/backend/cov.info
|
||||||
@@ -21,8 +21,6 @@ export const routes: Routes = [
|
|||||||
path: 'sites/:siteId',
|
path: 'sites/:siteId',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then(
|
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
||||||
(m) => m.SiteDetailPlaceholder,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { ReadingsService } from './readings.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
describe('ReadingsService', () => {
|
||||||
|
let service: ReadingsService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(ReadingsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(
|
||||||
|
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
|
||||||
|
);
|
||||||
|
expect(req.request.params.get('site_id')).toBe('SITE001');
|
||||||
|
expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z');
|
||||||
|
expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z');
|
||||||
|
|
||||||
|
req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]);
|
||||||
|
|
||||||
|
expect((result as unknown[]).length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne pose pas de paramètres start/end quand ils sont omis', () => {
|
||||||
|
service.getHistory('SITE001').subscribe();
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(
|
||||||
|
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
|
||||||
|
);
|
||||||
|
expect(req.request.params.has('start')).toBe(false);
|
||||||
|
expect(req.request.params.has('end')).toBe(false);
|
||||||
|
|
||||||
|
req.flush([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import { Reading } from '../../shared/models/reading.model';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class ReadingsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getHistory(siteId: string, start?: string, end?: string) {
|
||||||
|
let params = new HttpParams().set('site_id', siteId);
|
||||||
|
if (start) {
|
||||||
|
params = params.set('start', start);
|
||||||
|
}
|
||||||
|
if (end) {
|
||||||
|
params = params.set('end', end);
|
||||||
|
}
|
||||||
|
return this.http.get<Reading[]>(`${environment.apiUrl}/readings`, { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,4 +38,50 @@ describe('SitesService', () => {
|
|||||||
|
|
||||||
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
|
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('appelle le bon endpoint et retourne un site', () => {
|
||||||
|
let result: unknown;
|
||||||
|
service.getSite('SITE001').subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
|
||||||
|
req.flush({
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Site 1',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Service, inject } from '@angular/core';
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Site } from '../../shared/models/site.model';
|
import { Site } from '../../shared/models/site.model';
|
||||||
|
import { SiteCurrent } from '../../shared/models/site-current.model';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SitesService {
|
export class SitesService {
|
||||||
@@ -10,4 +11,12 @@ export class SitesService {
|
|||||||
getSites() {
|
getSites() {
|
||||||
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
|
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSite(siteId: string) {
|
||||||
|
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrent(siteId: string) {
|
||||||
|
return this.http.get<SiteCurrent>(`${environment.apiUrl}/sites/${siteId}/current`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
<div class="site-detail-placeholder">
|
|
||||||
<nav class="ev-breadcrumb">
|
|
||||||
<a routerLink="/dashboard">Tableau de bord</a>
|
|
||||||
<span>/</span>
|
|
||||||
<a routerLink="/sites">Sites</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header class="site-detail-placeholder__header">
|
|
||||||
<a routerLink="/dashboard" class="ev-brand-link">
|
|
||||||
<ev-brand class="site-detail-placeholder__logo" />
|
|
||||||
</a>
|
|
||||||
<h1>Site {{ siteId() }}</h1>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ev-card>
|
|
||||||
<p>Le détail de ce site est à venir (voir issue #51).</p>
|
|
||||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
|
||||||
</ev-card>
|
|
||||||
</div>
|
|
||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
color: var(--color-text);
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-detail-placeholder__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.85rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-detail-placeholder__logo {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ev-card p {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
|
||||||
import { BehaviorSubject } from 'rxjs';
|
|
||||||
import { SiteDetailPlaceholder } from './site-detail-placeholder';
|
|
||||||
|
|
||||||
describe('SiteDetailPlaceholder', () => {
|
|
||||||
it("affiche l'identifiant du site depuis la route", () => {
|
|
||||||
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [SiteDetailPlaceholder],
|
|
||||||
providers: [
|
|
||||||
provideRouter([]),
|
|
||||||
{ provide: ActivatedRoute, useValue: { paramMap } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('SITE001');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => {
|
|
||||||
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [SiteDetailPlaceholder],
|
|
||||||
providers: [
|
|
||||||
provideRouter([]),
|
|
||||||
{ provide: ActivatedRoute, useValue: { paramMap } },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('SITE002');
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain('SITE001');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
||||||
import { map } from 'rxjs';
|
|
||||||
import { Card } from '../../../shared/components/ui/card/card';
|
|
||||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-site-detail-placeholder',
|
|
||||||
standalone: true,
|
|
||||||
imports: [RouterLink, Card, Brand],
|
|
||||||
templateUrl: './site-detail-placeholder.html',
|
|
||||||
styleUrl: './site-detail-placeholder.scss',
|
|
||||||
})
|
|
||||||
export class SiteDetailPlaceholder {
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
|
|
||||||
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId'))));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<div class="site-detail">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a routerLink="/sites">Sites</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="site-detail__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="site-detail__logo" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1>{{ site()?.site_name ?? siteId() }}</h1>
|
||||||
|
@if (site(); as s) {
|
||||||
|
<p class="site-detail__subtitle">
|
||||||
|
{{ s.site_type }} · {{ s.location || 'Localisation inconnue' }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="site-detail__badges">
|
||||||
|
@if (site(); as s) {
|
||||||
|
<ev-badge [tone]="badgeToneForStatus(s.status)">{{ s.status ?? '-' }}</ev-badge>
|
||||||
|
}
|
||||||
|
@if (hasMeasurement() && qualityLabel(); as label) {
|
||||||
|
<ev-badge [tone]="qualityTone()">{{ label }}</ev-badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (site(); as s) {
|
||||||
|
@if (hasMeasurement()) {
|
||||||
|
<section class="overview">
|
||||||
|
<ev-card class="card card--gauge">
|
||||||
|
<span class="card__label">Consommation vs capacité</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">
|
||||||
|
<span class="card__label">Mesure instantanée</span>
|
||||||
|
<dl class="metrics-grid">
|
||||||
|
@for (metric of metrics(); track metric.key) {
|
||||||
|
<div class="metric">
|
||||||
|
<dt>{{ metric.label }}</dt>
|
||||||
|
@if (metric.value !== null) {
|
||||||
|
<dd>{{ metric.value }}</dd>
|
||||||
|
} @else {
|
||||||
|
<dd class="metric__unavailable">
|
||||||
|
Indisponible
|
||||||
|
<span class="metric__reason">({{ metric.reason }})</span>
|
||||||
|
</dd>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</ev-card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (history().length > 0) {
|
||||||
|
<section class="chart-section">
|
||||||
|
<h2>Historique de consommation</h2>
|
||||||
|
<app-reading-history-chart [readings]="history()" />
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<ev-alert severity="warning" class="banner-empty">{{ noMeasurementMessage }}</ev-alert>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-detail__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-detail__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-detail__subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
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));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--gauge {
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__unavailable {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metrics-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric__unavailable {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric__reason {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { BehaviorSubject, of, throwError } from 'rxjs';
|
||||||
|
import { SiteDetail } from './site-detail';
|
||||||
|
import { SitesService } from '../../../core/services/sites.service';
|
||||||
|
import { ReadingsService } from '../../../core/services/readings.service';
|
||||||
|
|
||||||
|
const SITE = {
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Site 1',
|
||||||
|
site_type: 'industriel',
|
||||||
|
location: 'Nantes',
|
||||||
|
capacity_kw: 500,
|
||||||
|
status: 'actif',
|
||||||
|
};
|
||||||
|
|
||||||
|
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-17T09:00:00Z',
|
||||||
|
source: 'api_history' as const,
|
||||||
|
consumption_kw: 118,
|
||||||
|
consumption_kwh: null,
|
||||||
|
consumption_euros: null,
|
||||||
|
voltage_v: 230,
|
||||||
|
current_a: 12,
|
||||||
|
power_factor: 0.95,
|
||||||
|
temperature_celsius: 22,
|
||||||
|
humidity_percent: 55,
|
||||||
|
solar_irradiance_wm2: null,
|
||||||
|
is_working_hours: true,
|
||||||
|
data_quality: 'good' as const,
|
||||||
|
null_reasons: null,
|
||||||
|
imputed_values: null,
|
||||||
|
imputation_method: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(
|
||||||
|
siteId: string,
|
||||||
|
sitesMock: Partial<SitesService>,
|
||||||
|
readingsMock: Partial<ReadingsService>,
|
||||||
|
) {
|
||||||
|
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SiteDetail],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||||
|
{ provide: SitesService, useValue: sitesMock },
|
||||||
|
{ provide: ReadingsService, useValue: readingsMock },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SiteDetail', () => {
|
||||||
|
it('charge le site, la mesure courante et son historique au démarrage', () => {
|
||||||
|
const { fixture } = setup(
|
||||||
|
'SITE001',
|
||||||
|
{
|
||||||
|
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.current()?.consumption_kw).toBe(120);
|
||||||
|
expect(fixture.componentInstance.history().length).toBe(1);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signale l'indisponibilité quand un des appels échoue", () => {
|
||||||
|
const { fixture } = setup(
|
||||||
|
'SITE001',
|
||||||
|
{
|
||||||
|
getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
||||||
|
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
||||||
|
},
|
||||||
|
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.error()).not.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', () => {
|
||||||
|
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)),
|
||||||
|
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');
|
||||||
|
expect(tension?.value).toBeNull();
|
||||||
|
expect(tension?.reason).toBe('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, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
|
||||||
|
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getSite).toHaveBeenCalledWith('SITE002');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => {
|
||||||
|
const getHistory = vi.fn().mockReturnValue(of([]));
|
||||||
|
const { fixture } = setup(
|
||||||
|
'SITE001',
|
||||||
|
{
|
||||||
|
getSite: vi.fn().mockReturnValue(of(SITE)),
|
||||||
|
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
||||||
|
},
|
||||||
|
{ getHistory },
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getHistory).toHaveBeenCalledWith(
|
||||||
|
'SITE001',
|
||||||
|
'2026-09-16T10:00:00.000Z',
|
||||||
|
'2026-09-17T10:00:00Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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)),
|
||||||
|
getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)),
|
||||||
|
},
|
||||||
|
{ getHistory },
|
||||||
|
);
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(getHistory).not.toHaveBeenCalled();
|
||||||
|
expect(fixture.componentInstance.hasMeasurement()).toBe(false);
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
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, 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';
|
||||||
|
import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model';
|
||||||
|
import { SiteCurrent } from '../../../shared/models/site-current.model';
|
||||||
|
import { Card } from '../../../shared/components/ui/card/card';
|
||||||
|
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||||
|
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
||||||
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
|
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
||||||
|
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
||||||
|
|
||||||
|
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
||||||
|
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
||||||
|
const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const TON_PAR_STATUT: Record<string, BadgeTone> = {
|
||||||
|
actif: 'success',
|
||||||
|
maintenance: 'warning',
|
||||||
|
hors_service: 'danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TON_PAR_QUALITE: Record<ReadingDataQuality, BadgeTone> = {
|
||||||
|
good: 'success',
|
||||||
|
partial: 'warning',
|
||||||
|
degraded: 'danger',
|
||||||
|
critical: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIBELLE_PAR_QUALITE: Record<ReadingDataQuality, string> = {
|
||||||
|
good: 'Données complètes',
|
||||||
|
partial: 'Données partielles',
|
||||||
|
degraded: 'Données dégradées',
|
||||||
|
critical: 'Données critiques',
|
||||||
|
};
|
||||||
|
|
||||||
|
type MetricKey =
|
||||||
|
| 'consumption_kw'
|
||||||
|
| 'voltage_v'
|
||||||
|
| 'current_a'
|
||||||
|
| 'power_factor'
|
||||||
|
| 'temperature_celsius'
|
||||||
|
| 'humidity_percent';
|
||||||
|
|
||||||
|
interface MetricDef {
|
||||||
|
key: MetricKey;
|
||||||
|
label: string;
|
||||||
|
format: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONSUMPTION_DEF: MetricDef = {
|
||||||
|
key: 'consumption_kw',
|
||||||
|
label: 'Consommation',
|
||||||
|
format: (v) => `${v.toFixed(1)} kW`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const METRIC_DEFS: MetricDef[] = [
|
||||||
|
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) },
|
||||||
|
{ key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` },
|
||||||
|
{ key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ;
|
||||||
|
// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide.
|
||||||
|
const RAISONS_PAR_CHAMP: Record<MetricKey, string[]> = {
|
||||||
|
consumption_kw: ['consumption_sensor_failure', 'network_loss'],
|
||||||
|
voltage_v: ['electrical_sensor_failure', 'network_loss'],
|
||||||
|
current_a: ['electrical_sensor_failure', 'network_loss'],
|
||||||
|
power_factor: ['electrical_sensor_failure', 'network_loss'],
|
||||||
|
temperature_celsius: ['temperature_sensor_failure', 'network_loss'],
|
||||||
|
humidity_percent: ['humidity_sensor_failure', 'network_loss'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIBELLE_PAR_RAISON: Record<string, string> = {
|
||||||
|
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",
|
||||||
|
network_loss: 'perte réseau',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MetricView {
|
||||||
|
key: MetricKey;
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-site-detail',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
|
||||||
|
templateUrl: './site-detail.html',
|
||||||
|
styleUrl: './site-detail.scss',
|
||||||
|
})
|
||||||
|
export class SiteDetail {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private sitesService = inject(SitesService);
|
||||||
|
private readingsService = inject(ReadingsService);
|
||||||
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE;
|
||||||
|
|
||||||
|
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? '')));
|
||||||
|
|
||||||
|
site = signal<Site | null>(null);
|
||||||
|
current = signal<SiteCurrent | null>(null);
|
||||||
|
history = signal<Reading[]>([]);
|
||||||
|
error = signal<string | null>(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(() => {
|
||||||
|
const quality = this.current()?.data_quality;
|
||||||
|
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
qualityTone = computed<BadgeTone>(() => {
|
||||||
|
const quality = this.current()?.data_quality;
|
||||||
|
return quality ? TON_PAR_QUALITE[quality] : 'neutral';
|
||||||
|
});
|
||||||
|
|
||||||
|
metrics = computed<MetricView[]>(() => {
|
||||||
|
const current = this.current();
|
||||||
|
return METRIC_DEFS.map((def) => {
|
||||||
|
const valeur = current ? current[def.key] : null;
|
||||||
|
return {
|
||||||
|
key: def.key,
|
||||||
|
label: def.label,
|
||||||
|
value: valeur != null ? def.format(valeur) : null,
|
||||||
|
reason: valeur == null ? this.reasonFor(def.key, current) : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
toObservable(this.siteId)
|
||||||
|
.pipe(
|
||||||
|
filter((siteId): siteId is string => !!siteId),
|
||||||
|
// Piège : switchMap sur le flux externe annule le chargement en cours dès qu'un
|
||||||
|
// nouveau siteId arrive, sinon une réponse en retard peut écraser le site affiché.
|
||||||
|
switchMap((siteId) => this.load(siteId)),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe((result) => {
|
||||||
|
this.error.set(null);
|
||||||
|
this.site.set(result.site);
|
||||||
|
this.current.set(result.current);
|
||||||
|
this.history.set(result.history);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeToneForStatus(status: string | null): BadgeTone {
|
||||||
|
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(siteId: string) {
|
||||||
|
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 }))),
|
||||||
|
),
|
||||||
|
catchError(() => this.reportUnavailable()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadHistory(siteId: string, current: SiteCurrent): Observable<Reading[]> {
|
||||||
|
// Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la
|
||||||
|
// dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide.
|
||||||
|
const end = current.timestamp;
|
||||||
|
if (end === null) {
|
||||||
|
return of([]);
|
||||||
|
}
|
||||||
|
const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString();
|
||||||
|
return this.readingsService.getHistory(siteId, start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private reasonFor(field: MetricKey, current: SiteCurrent | null): string {
|
||||||
|
const raisons = RAISONS_PAR_CHAMP[field];
|
||||||
|
const trouvees = (current?.null_reasons ?? [])
|
||||||
|
.filter((raison) => raisons.includes(raison))
|
||||||
|
.map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
<canvas #canvas></canvas>
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 260px;
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { Chart } from 'chart.js';
|
||||||
|
import { ReadingHistoryChart } from './reading-history-chart';
|
||||||
|
|
||||||
|
vi.mock('chart.js', () => {
|
||||||
|
class ChartMock {
|
||||||
|
static instances: ChartMock[] = [];
|
||||||
|
static register = vi.fn();
|
||||||
|
update = vi.fn();
|
||||||
|
destroy = vi.fn();
|
||||||
|
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>;
|
||||||
|
data: { labels?: unknown[]; datasets: Record<string, unknown>[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
function lastChart(): ChartDouble | undefined {
|
||||||
|
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const READING = {
|
||||||
|
reading_id: 1,
|
||||||
|
site_id: 'S1',
|
||||||
|
timestamp: '2026-09-17T10:00:00Z',
|
||||||
|
source: 'api_history' as const,
|
||||||
|
consumption_kw: 42,
|
||||||
|
consumption_kwh: null,
|
||||||
|
consumption_euros: null,
|
||||||
|
voltage_v: null,
|
||||||
|
current_a: null,
|
||||||
|
power_factor: null,
|
||||||
|
temperature_celsius: null,
|
||||||
|
humidity_percent: null,
|
||||||
|
solar_irradiance_wm2: null,
|
||||||
|
is_working_hours: null,
|
||||||
|
data_quality: 'good' as const,
|
||||||
|
null_reasons: null,
|
||||||
|
imputed_values: null,
|
||||||
|
imputation_method: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ReadingHistoryChart', () => {
|
||||||
|
it('se crée sans erreur avec une liste de lectures valide', () => {
|
||||||
|
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
||||||
|
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
||||||
|
fixture.componentRef.setInput('readings', [READING]);
|
||||||
|
expect(() => fixture.detectChanges()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('met à jour le graphique quand les lectures changent après initialisation', () => {
|
||||||
|
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
||||||
|
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
||||||
|
fixture.componentRef.setInput('readings', [READING]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('readings', [
|
||||||
|
{ ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const },
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
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);
|
||||||
|
fixture.componentRef.setInput('readings', [READING]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const chart = lastChart();
|
||||||
|
fixture.destroy();
|
||||||
|
|
||||||
|
expect(chart?.destroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
ViewChild,
|
||||||
|
input,
|
||||||
|
effect,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
import { Reading, ReadingDataQuality } from '../../models/reading.model';
|
||||||
|
|
||||||
|
Chart.register(...registerables);
|
||||||
|
|
||||||
|
const QUALITY_COLORS: Record<ReadingDataQuality, string> = {
|
||||||
|
good: '#3b82f6',
|
||||||
|
partial: '#f9a825',
|
||||||
|
degraded: '#ef6c00',
|
||||||
|
critical: '#c62828',
|
||||||
|
};
|
||||||
|
const UNKNOWN_QUALITY_COLOR = '#9ca3af';
|
||||||
|
|
||||||
|
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({
|
||||||
|
selector: 'app-reading-history-chart',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './reading-history-chart.html',
|
||||||
|
styleUrl: './reading-history-chart.scss',
|
||||||
|
})
|
||||||
|
export class ReadingHistoryChart implements AfterViewInit, OnDestroy {
|
||||||
|
readings = input.required<Reading[]>();
|
||||||
|
|
||||||
|
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
||||||
|
private chart?: Chart<'line'>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const series = toSeries(this.readings());
|
||||||
|
if (this.chart) {
|
||||||
|
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 series = toSeries(this.readings());
|
||||||
|
this.chart = new Chart(this.canvasRef.nativeElement, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: series.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: series.values,
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
pointBackgroundColor: series.colors,
|
||||||
|
tension: 0.25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.chart?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export type ReadingSource = 'csv' | 'api_current' | 'api_history';
|
||||||
|
export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical';
|
||||||
|
|
||||||
|
export interface Reading {
|
||||||
|
reading_id: number;
|
||||||
|
site_id: string;
|
||||||
|
timestamp: string;
|
||||||
|
source: ReadingSource;
|
||||||
|
consumption_kw: number | null;
|
||||||
|
consumption_kwh: number | null;
|
||||||
|
consumption_euros: string | null;
|
||||||
|
voltage_v: number | null;
|
||||||
|
current_a: number | null;
|
||||||
|
power_factor: number | null;
|
||||||
|
temperature_celsius: number | null;
|
||||||
|
humidity_percent: number | null;
|
||||||
|
solar_irradiance_wm2: number | null;
|
||||||
|
is_working_hours: boolean | null;
|
||||||
|
data_quality: ReadingDataQuality | null;
|
||||||
|
null_reasons: string[] | null;
|
||||||
|
imputed_values: Record<string, unknown> | null;
|
||||||
|
imputation_method: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ReadingDataQuality } from './reading.model';
|
||||||
|
|
||||||
|
export interface SiteCurrent {
|
||||||
|
timestamp: string | null;
|
||||||
|
site_id: string;
|
||||||
|
site_type: string;
|
||||||
|
consumption_kw: number | null;
|
||||||
|
consumption_kwh: number | null;
|
||||||
|
voltage_v: number | null;
|
||||||
|
current_a: number | null;
|
||||||
|
power_factor: number | null;
|
||||||
|
temperature_celsius: number | null;
|
||||||
|
humidity_percent: number | null;
|
||||||
|
null_reasons: string[];
|
||||||
|
data_quality: ReadingDataQuality;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiUrl: '/api/v1',
|
apiUrl: '/api/v1',
|
||||||
useMockFixtures: true, // a passer a false une fois le backend prêt
|
useMockFixtures: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ sonar.projectKey=ProjetPiscine_EnerVision
|
|||||||
sonar.organization=groupe3-ener-vision
|
sonar.organization=groupe3-ener-vision
|
||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
sonar.sources=apps/frontend/src,apps/backend/app
|
# Dossier contenant le code source
|
||||||
|
sonar.sources=apps/frontend/src,apps/backend
|
||||||
|
# Dossier contenant les tests
|
||||||
|
sonar.tests=apps/frontend/src,apps/backend/tests
|
||||||
|
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
||||||
|
|
||||||
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
|
# Liste des fichiers et dossiers à exclure de l'analyse
|
||||||
|
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
||||||
|
|
||||||
|
# Chemin vers le rapport de couverture de code
|
||||||
|
# Fichier généré par Pytest
|
||||||
|
sonar.python.coverage.reportPaths=apps/backend/coverage.xml
|
||||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|||||||
Reference in New Issue
Block a user