diff --git a/apps/frontend/src/app/core/services/recommendations.service.spec.ts b/apps/frontend/src/app/core/services/recommendations.service.spec.ts new file mode 100644 index 0000000..4252757 --- /dev/null +++ b/apps/frontend/src/app/core/services/recommendations.service.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing'; +import { RecommendationsService } from './recommendations.service'; +import { environment } from '../../../environments/environment'; +import { Recommendation } from '../../shared/models/recommendation.model'; + +const RECOMMANDATION_API: Recommendation = { + recommendation_id: 1, + alert_id: 1, + action: 'Vérifier la consommation', + explanation: 'Pic détecté', + rule_reference: 'spike-v1', + created_at: '2024-01-01T00:00:00Z', +}; + +describe('RecommendationsService', () => { + let service: RecommendationsService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(RecommendationsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('liste les recommandations depuis le bon endpoint', () => { + let result: Recommendation[] = []; + service.getRecommendations().subscribe((r) => (result = r)); + + const req = httpMock.expectOne(`${environment.apiUrl}/recommendations`); + expect(req.request.method).toBe('GET'); + req.flush([RECOMMANDATION_API]); + + expect(result.length).toBe(1); + expect(result[0].alert_id).toBe(1); + }); + + it('décrit une recommandation par son identifiant', () => { + service.getRecommendation(42).subscribe(); + + const req = httpMock.expectOne(`${environment.apiUrl}/recommendations/42`); + expect(req.request.method).toBe('GET'); + req.flush({ ...RECOMMANDATION_API, recommendation_id: 42 }); + }); + + it('déclenche la génération en POST avec le site en paramètre de requête', () => { + let result: unknown; + service.generate('SITE001').subscribe((r) => (result = r)); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST', + ); + expect(req.request.params.get('site_id')).toBe('SITE001'); + expect(req.request.body).toBeNull(); + req.flush({ alerts_examined: 2, recommendations_created: 3, already_present: 1 }); + + expect(result).toEqual({ alerts_examined: 2, recommendations_created: 3, already_present: 1 }); + }); + + it('génère pour tout le parc quand aucun site n’est donné', () => { + service.generate().subscribe(); + + const req = httpMock.expectOne( + (r) => r.url === `${environment.apiUrl}/recommendations/generate` && r.method === 'POST', + ); + expect(req.request.params.has('site_id')).toBe(false); + req.flush({ alerts_examined: 0, recommendations_created: 0, already_present: 0 }); + }); +}); diff --git a/apps/frontend/src/app/core/services/recommendations.service.ts b/apps/frontend/src/app/core/services/recommendations.service.ts new file mode 100644 index 0000000..7a51844 --- /dev/null +++ b/apps/frontend/src/app/core/services/recommendations.service.ts @@ -0,0 +1,34 @@ +import { Service, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { environment } from '../../../environments/environment'; +import { + Recommendation, + RecommendationGenerationReport, +} from '../../shared/models/recommendation.model'; + +@Service() +export class RecommendationsService { + private http = inject(HttpClient); + + getRecommendations() { + return this.http.get(`${environment.apiUrl}/recommendations`); + } + + getRecommendation(recommendationId: number) { + return this.http.get( + `${environment.apiUrl}/recommendations/${recommendationId}`, + ); + } + + generate(siteId?: string) { + let params = new HttpParams(); + if (siteId) { + params = params.set('site_id', siteId); + } + return this.http.post( + `${environment.apiUrl}/recommendations/generate`, + null, + { params }, + ); + } +} diff --git a/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts b/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts new file mode 100644 index 0000000..63b8556 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation-presentation.spec.ts @@ -0,0 +1,23 @@ +import { libelleRegle, tonRegle } from './recommendation-presentation'; + +describe('recommendation-presentation', () => { + it('traduit les sept règles connues du moteur', () => { + expect(libelleRegle('spike-delestage-v1')).toBe('Délestage'); + expect(libelleRegle('threshold-reduction-v1')).toBe('Réduction de puissance'); + expect(libelleRegle('outage-secours-v1')).toBe('Alimentation de secours'); + expect(libelleRegle('sensor-maintenance-v1')).toBe('Maintenance capteur'); + expect(libelleRegle('anomaly-verification-v1')).toBe('Vérification'); + expect(libelleRegle('escalade-astreinte-v1')).toBe('Escalade astreinte'); + expect(libelleRegle('contrat-puissance-v1')).toBe('Contrat de puissance'); + }); + + it('affiche telle quelle une référence de règle inconnue', () => { + expect(libelleRegle('spike-delestage-v2')).toBe('spike-delestage-v2'); + }); + + it("réserve le ton critique à l'escalade vers l'astreinte", () => { + expect(tonRegle('escalade-astreinte-v1')).toBe('critical'); + expect(tonRegle('spike-delestage-v1')).toBe('neutral'); + expect(tonRegle('inconnue-v9')).toBe('neutral'); + }); +}); diff --git a/apps/frontend/src/app/shared/models/recommendation-presentation.ts b/apps/frontend/src/app/shared/models/recommendation-presentation.ts new file mode 100644 index 0000000..f52d294 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation-presentation.ts @@ -0,0 +1,23 @@ +import { BadgeTone } from '../components/ui/badge/badge'; + +// Contrainte : une règle dont le sens change reçoit un suffixe -v2 côté backend (ADR 0006) ; +// une référence inconnue s'affiche donc telle quelle plutôt que de casser la vue. +const LIBELLE_PAR_REGLE: Record = { + 'spike-delestage-v1': 'Délestage', + 'threshold-reduction-v1': 'Réduction de puissance', + 'outage-secours-v1': 'Alimentation de secours', + 'sensor-maintenance-v1': 'Maintenance capteur', + 'anomaly-verification-v1': 'Vérification', + 'escalade-astreinte-v1': 'Escalade astreinte', + 'contrat-puissance-v1': 'Contrat de puissance', +}; + +const REGLE_ESCALADE = 'escalade-astreinte-v1'; + +export function libelleRegle(reference: string): string { + return LIBELLE_PAR_REGLE[reference] ?? reference; +} + +export function tonRegle(reference: string): BadgeTone { + return reference === REGLE_ESCALADE ? 'critical' : 'neutral'; +} diff --git a/apps/frontend/src/app/shared/models/recommendation.model.ts b/apps/frontend/src/app/shared/models/recommendation.model.ts new file mode 100644 index 0000000..c017b72 --- /dev/null +++ b/apps/frontend/src/app/shared/models/recommendation.model.ts @@ -0,0 +1,14 @@ +export interface Recommendation { + recommendation_id: number; + alert_id: number; + action: string; + explanation: string; + rule_reference: string; + created_at: string; +} + +export interface RecommendationGenerationReport { + alerts_examined: number; + recommendations_created: number; + already_present: number; +}