fix(frontend): detruit les graphiques avec leur composant

Chart.js conserve chaque instance dans un registre lie au canvas et lui
attache un observateur de redimensionnement. Sans destroy, tout survit a la
destruction du composant, et une re-creation sur le meme canvas echoue avec
"Canvas is already in use".

Les doubles de test gagnent destroy : TestBed detruit les fixtures apres
chaque test, un mock sans cette methode fait tomber les specs existantes.
This commit is contained in:
Johan LEROY
2026-09-16 09:20:17 +02:00
parent 7b9406965e
commit 0259f66b62
4 changed files with 90 additions and 8 deletions
@@ -1,16 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { Chart } from 'chart.js';
import { ConsumptionGauge } from './consumption-gauge';
vi.mock('chart.js', () => {
class ChartMock {
update = vi.fn();
data = { datasets: [{}] };
static instances: ChartMock[] = [];
static register = vi.fn();
update = vi.fn();
destroy = vi.fn();
data = { datasets: [{}] };
constructor() {
ChartMock.instances.push(this);
}
}
return { Chart: ChartMock, registerables: [] };
});
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
function lastChart(): ChartDouble | undefined {
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
}
describe('ConsumptionGauge', () => {
it('se crée sans erreur avec des entrées valides', () => {
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
@@ -31,4 +43,17 @@ describe('ConsumptionGauge', () => {
expect(() => fixture.detectChanges()).not.toThrow();
});
it('détruit le graphique quand le composant est détruit', () => {
TestBed.configureTestingModule({ imports: [ConsumptionGauge] });
const fixture = TestBed.createComponent(ConsumptionGauge);
fixture.componentRef.setInput('consumption', 300);
fixture.componentRef.setInput('capacity', 1000);
fixture.detectChanges();
const chart = lastChart();
fixture.destroy();
expect(chart?.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,12 @@
import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core';
import {
Component,
ElementRef,
ViewChild,
input,
effect,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
@@ -9,7 +17,7 @@ Chart.register(...registerables);
templateUrl: './consumption-gauge.html',
styleUrl: './consumption-gauge.scss',
})
export class ConsumptionGauge implements AfterViewInit {
export class ConsumptionGauge implements AfterViewInit, OnDestroy {
consumption = input.required<number>();
capacity = input.required<number>();
@@ -52,4 +60,8 @@ export class ConsumptionGauge implements AfterViewInit {
},
});
}
ngOnDestroy(): void {
this.chart?.destroy();
}
}
@@ -1,16 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import { Chart } from 'chart.js';
import { SiteLoadChart } from './site-load-chart';
vi.mock('chart.js', () => {
class ChartMock {
update = vi.fn();
data = { datasets: [{}] };
static instances: ChartMock[] = [];
static register = vi.fn();
update = vi.fn();
destroy = vi.fn();
data = { datasets: [{}] };
constructor() {
ChartMock.instances.push(this);
}
}
return { Chart: ChartMock, registerables: [] };
});
type ChartDouble = { destroy: ReturnType<typeof vi.fn> };
function lastChart(): ChartDouble | undefined {
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
}
describe('SiteLoadChart', () => {
it('se crée sans erreur avec une liste de sites valide', () => {
TestBed.configureTestingModule({ imports: [SiteLoadChart] });
@@ -35,4 +47,25 @@ describe('SiteLoadChart', () => {
expect(() => fixture.detectChanges()).not.toThrow();
});
it('détruit le graphique quand le composant est détruit', () => {
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();
const chart = lastChart();
fixture.destroy();
expect(chart?.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,12 @@
import { Component, ElementRef, ViewChild, input, effect, AfterViewInit } from '@angular/core';
import {
Component,
ElementRef,
ViewChild,
input,
effect,
AfterViewInit,
OnDestroy,
} from '@angular/core';
import { Chart, registerables } from 'chart.js';
import { SiteSummary } from '../../models/stats.model';
@@ -17,7 +25,7 @@ const QUALITY_COLORS: Record<SiteSummary['data_quality'], string> = {
templateUrl: './site-load-chart.html',
styleUrl: './site-load-chart.scss',
})
export class SiteLoadChart implements AfterViewInit {
export class SiteLoadChart implements AfterViewInit, OnDestroy {
sites = input.required<SiteSummary[]>();
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
@@ -59,4 +67,8 @@ export class SiteLoadChart implements AfterViewInit {
},
});
}
ngOnDestroy(): void {
this.chart?.destroy();
}
}