Creation dashboard (graph chart.js) + tests

This commit is contained in:
valentin
2026-09-15 16:48:55 +02:00
parent 0ca429ff3d
commit cdef30736a
31 changed files with 851 additions and 364 deletions
@@ -0,0 +1 @@
<canvas #canvas></canvas>
@@ -0,0 +1,6 @@
:host {
display: block;
height: 200px;
width: 200px;
margin: 0 auto;
}
@@ -0,0 +1,34 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { ConsumptionGauge } from './consumption-gauge';
vi.mock('chart.js', () => {
class ChartMock {
update = vi.fn();
data = { datasets: [{}] };
static register = vi.fn();
}
return { Chart: ChartMock, registerables: [] };
});
describe('ConsumptionGauge', () => {
it('se crée sans erreur avec des entrées valides', () => {
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
const fixture = TestBed.createComponent(ConsumptionGauge);
fixture.componentRef.setInput('consumption', 300);
fixture.componentRef.setInput('capacity', 1000);
expect(() => fixture.detectChanges()).not.toThrow();
});
it('met à jour le graphique quand les valeurs changent après initialisation', () => {
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
const fixture = TestBed.createComponent(ConsumptionGauge);
fixture.componentRef.setInput('consumption', 300);
fixture.componentRef.setInput('capacity', 1000);
fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais
fixture.componentRef.setInput('consumption', 500);
fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini
expect(() => fixture.detectChanges()).not.toThrow();
});
});
@@ -0,0 +1,55 @@
import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
@Component({
selector: 'app-consumption-gauge',
standalone: true,
templateUrl: './consumption-gauge.html',
styleUrl: './consumption-gauge.scss',
})
export class ConsumptionGauge implements AfterViewInit {
consumption = input.required<number>();
capacity = input.required<number>();
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
private chart?: Chart;
constructor() {
effect(() => {
const used = this.consumption();
const remaining = Math.max(0, this.capacity() - used);
if (this.chart) {
this.chart.data.datasets[0].data = [used, remaining];
this.chart.update('none');
}
});
}
ngAfterViewInit(): void {
const used = this.consumption();
const remaining = Math.max(0, this.capacity() - used);
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'doughnut',
data: {
labels: ['Utilisé', 'Disponible'],
datasets: [
{
data: [used, remaining],
backgroundColor: ['#3b82f6', '#e5e7eb'],
borderWidth: 0,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '70%',
animation: { duration: 300 },
plugins: { legend: { display: false } },
},
});
}
}
@@ -0,0 +1 @@
<canvas #canvas></canvas>
@@ -0,0 +1,4 @@
:host {
display: block;
height: 260px;
}
@@ -0,0 +1,38 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { SiteLoadChart } from './site-load-chart';
vi.mock('chart.js', () => {
class ChartMock {
update = vi.fn();
data = { datasets: [{}] };
static register = vi.fn();
}
return { Chart: ChartMock, registerables: [] };
});
describe('SiteLoadChart', () => {
it('se crée sans erreur avec une liste de sites valide', () => {
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
const fixture = TestBed.createComponent(SiteLoadChart);
fixture.componentRef.setInput('sites', [
{ site_id: 'S1', site_name: 'Test', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' },
]);
expect(() => fixture.detectChanges()).not.toThrow();
});
it('met à jour le graphique quand les sites changent après initialisation', () => {
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
const fixture = TestBed.createComponent(SiteLoadChart);
fixture.componentRef.setInput('sites', [
{ site_id: 'S1', site_name: 'A', current_consumption_kw: 50, capacity_kw: 100, load_percent: 50, data_quality: 'good' },
]);
fixture.detectChanges(); // déclenche ngAfterViewInit, this.chart existe désormais
fixture.componentRef.setInput('sites', [
{ site_id: 'S2', site_name: 'B', current_consumption_kw: 80, capacity_kw: 100, load_percent: 80, data_quality: 'critical' },
]);
fixture.detectChanges(); // ré-exécute l'effect, cette fois avec this.chart défini
expect(() => fixture.detectChanges()).not.toThrow();
});
});
@@ -0,0 +1,62 @@
import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core';
import { Chart, registerables } from 'chart.js';
import { SiteSummary } from '../../models/stats.model';
Chart.register(...registerables);
const QUALITY_COLORS: Record<SiteSummary['data_quality'], string> = {
good: '#2e7d32',
partial: '#f9a825',
degraded: '#ef6c00',
critical: '#c62828',
};
@Component({
selector: 'app-site-load-chart',
standalone: true,
templateUrl: './site-load-chart.html',
styleUrl: './site-load-chart.scss',
})
export class SiteLoadChart implements AfterViewInit {
sites = input.required<SiteSummary[]>();
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
private chart?: Chart;
constructor() {
effect(() => {
const sites = this.sites();
if (this.chart) {
this.chart.data.labels = sites.map((s) => s.site_name);
this.chart.data.datasets[0].data = sites.map((s) => s.load_percent ?? 0);
this.chart.data.datasets[0].backgroundColor = sites.map((s) => QUALITY_COLORS[s.data_quality]);
this.chart.update('none');
}
});
}
ngAfterViewInit(): void {
const sites = this.sites();
this.chart = new Chart(this.canvasRef.nativeElement, {
type: 'bar',
data: {
labels: sites.map((s) => s.site_name),
datasets: [
{
data: sites.map((s) => s.load_percent ?? 0),
backgroundColor: sites.map((s) => QUALITY_COLORS[s.data_quality]),
borderRadius: 4,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, max: 100, title: { display: true, text: 'Charge (%)' } },
},
},
});
}
}