Co-Study4Grid / frontend /src /utils /svg /actionPinData.test.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
6.42 kB
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
import { describe, expect, it } from 'vitest';
import {
computeActionSeverity,
curveMidpoint,
fanOutColocatedPins,
formatPinLabel,
formatPinTitle,
severityFill,
severityFillDimmed,
severityFillHighlighted,
type ActionPinInfo,
} from './actionPinData';
import type { ActionDetail } from '../../types';
const baseDetail = (over: Partial<ActionDetail> = {}): ActionDetail => ({
description_unitaire: 'desc',
max_rho: 0.5,
max_rho_line: 'LINE_X',
is_rho_reduction: false,
is_islanded: false,
non_convergence: null,
action_topology: null,
load_shedding_details: null,
curtailment_details: null,
...over,
} as unknown as ActionDetail);
describe('computeActionSeverity', () => {
it('returns grey for divergent actions', () => {
expect(computeActionSeverity(baseDetail({ non_convergence: 'LF diverged' }), 0.95)).toBe('grey');
});
it('returns grey for islanding actions', () => {
expect(computeActionSeverity(baseDetail({ is_islanded: true }), 0.95)).toBe('grey');
});
it('returns red when max_rho is missing and no reduction', () => {
expect(computeActionSeverity(baseDetail({ max_rho: null, is_rho_reduction: false }), 0.95)).toBe('red');
});
it('returns green when max_rho is missing but rho is reduced', () => {
expect(computeActionSeverity(baseDetail({ max_rho: null, is_rho_reduction: true }), 0.95)).toBe('green');
});
it('returns red when max_rho exceeds monitoringFactor', () => {
expect(computeActionSeverity(baseDetail({ max_rho: 1.05 }), 0.95)).toBe('red');
});
it('returns orange inside the 5%-below-threshold band', () => {
expect(computeActionSeverity(baseDetail({ max_rho: 0.92 }), 0.95)).toBe('orange');
});
it('returns green comfortably below the threshold', () => {
expect(computeActionSeverity(baseDetail({ max_rho: 0.5 }), 0.95)).toBe('green');
});
});
describe('severity palettes', () => {
it('covers all four severities with three palettes', () => {
(['green', 'orange', 'red', 'grey'] as const).forEach(sev => {
expect(severityFill[sev]).toMatch(/^#[0-9a-f]{6}$/i);
expect(severityFillDimmed[sev]).toMatch(/^#[0-9a-f]{6}$/i);
expect(severityFillHighlighted[sev]).toMatch(/^#[0-9a-f]{6}$/i);
});
});
});
describe('formatPinLabel', () => {
it('formats max_rho as an integer percentage', () => {
expect(formatPinLabel(baseDetail({ max_rho: 0.876 }))).toBe('88%');
});
it('returns DIV on non-convergence', () => {
expect(formatPinLabel(baseDetail({ max_rho: null, non_convergence: 'x' }))).toBe('DIV');
});
it('returns ISL on islanding', () => {
expect(formatPinLabel(baseDetail({ max_rho: null, is_islanded: true }))).toBe('ISL');
});
it('returns em-dash when nothing is known', () => {
expect(formatPinLabel(baseDetail({ max_rho: null }))).toBe('\u2014');
});
});
describe('formatPinTitle', () => {
it('joins id + description + loading line', () => {
const t = formatPinTitle('act_X', baseDetail({ max_rho: 0.876, max_rho_line: 'LINE_Z' }));
expect(t).toBe('act_X \u2014 desc \u2014 max loading 87.6% on LINE_Z');
});
it('omits empty max_rho fragment when missing', () => {
const t = formatPinTitle('act_X', baseDetail({ max_rho: null, non_convergence: null, is_islanded: false }));
expect(t).toBe('act_X \u2014 desc');
});
it('reports non-convergence explicitly', () => {
const t = formatPinTitle('act_X', baseDetail({ max_rho: null, non_convergence: 'AC diverged' }));
expect(t).toContain('load-flow divergent');
});
it('reports islanding explicitly', () => {
const t = formatPinTitle('act_X', baseDetail({ max_rho: null, is_islanded: true }));
expect(t).toContain('islanding');
});
});
describe('fanOutColocatedPins', () => {
const makePin = (id: string, x: number, y: number): ActionPinInfo => ({
id, x, y, severity: 'green', label: '', title: '',
});
it('leaves solo pins untouched', () => {
const pins = [makePin('a', 10, 20)];
fanOutColocatedPins(pins);
expect(pins[0]).toEqual({ id: 'a', x: 10, y: 20, severity: 'green', label: '', title: '' });
});
it('spreads colocated pins around a shared centre', () => {
const pins = [makePin('a', 100, 100), makePin('b', 100, 100), makePin('c', 100, 100)];
fanOutColocatedPins(pins, 30);
// All three pins moved to different positions.
const positions = new Set(pins.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`));
expect(positions.size).toBe(3);
// Each remains at radius ~30 from the original centre.
pins.forEach(p => {
const r = Math.hypot(p.x - 100, p.y - 100);
expect(r).toBeCloseTo(30, 1);
});
});
it('does not fan out pins at distinct positions', () => {
const pins = [makePin('a', 0, 0), makePin('b', 1000, 1000)];
const before = pins.map(p => ({ ...p }));
fanOutColocatedPins(pins);
expect(pins).toEqual(before);
});
});
describe('curveMidpoint', () => {
it('puts the midpoint on the perpendicular bisector between p1 and p2', () => {
const p1 = { x: 0, y: 0 };
const p2 = { x: 100, y: 0 };
const { midX, midY } = curveMidpoint(p1, p2, 0.3);
expect(midX).toBe(50);
// offsetFraction * dist * perp direction = 0.3 * 100 in Y direction.
// At t=0.5: midY = 2*0.5*0.5 * ctrlY + 0.25*p1.y + 0.25*p2.y = 0.5 * ctrlY.
// ctrlY = (-dy/dist)*dist*0.3 + midpointY = 0 + (100)*0.3 = 30 → midY = 0.5 * 30 = 15.
expect(midY).toBeCloseTo(15, 5);
});
it('returns valid midpoint for coincident p1 and p2 (degenerate)', () => {
const p = { x: 42, y: 7 };
const result = curveMidpoint(p, p);
expect(Number.isFinite(result.midX)).toBe(true);
expect(Number.isFinite(result.midY)).toBe(true);
});
});