Co-Study4Grid / frontend /src /utils /svg /overflowPinPayload.test.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
40 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.
// SPDX-License-Identifier: MPL-2.0
import { describe, expect, it } from 'vitest';
import type {
ActionDetail,
ActionOverviewFilters,
MetadataIndex,
NodeMeta,
EdgeMeta,
} from '../../types';
import {
buildOverflowPinPayload,
buildOverflowUnsimulatedPinPayload,
} from './overflowPinPayload';
import { DEFAULT_ACTION_OVERVIEW_FILTERS } from '../actionTypes';
const makeMetaIndex = (
nodes: Record<string, Partial<NodeMeta> & { equipmentId: string }>,
edges: Record<string, Partial<EdgeMeta> & { equipmentId: string; node1: string; node2: string }> = {},
): MetadataIndex => {
const nodesByEquipmentId = new Map<string, NodeMeta>();
const nodesBySvgId = new Map<string, NodeMeta>();
for (const [eqId, n] of Object.entries(nodes)) {
const node: NodeMeta = {
equipmentId: eqId,
svgId: n.svgId ?? `svg-${eqId}`,
x: n.x ?? 0,
y: n.y ?? 0,
...n,
} as NodeMeta;
nodesByEquipmentId.set(eqId, node);
nodesBySvgId.set(node.svgId, node);
}
const edgesByEquipmentId = new Map<string, EdgeMeta>();
for (const [eqId, e] of Object.entries(edges)) {
const edge: EdgeMeta = {
equipmentId: eqId,
svgId: e.svgId ?? `svg-edge-${eqId}`,
node1: e.node1,
node2: e.node2,
...e,
} as EdgeMeta;
edgesByEquipmentId.set(eqId, edge);
}
return {
nodesByEquipmentId,
nodesBySvgId,
edgesByEquipmentId,
// Unused by the payload builder; cast to satisfy the type.
} as unknown as MetadataIndex;
};
describe('buildOverflowPinPayload', () => {
it('returns [] when actions or metaIndex are missing', () => {
const meta = makeMetaIndex({ V1: { equipmentId: 'V1' } });
expect(buildOverflowPinPayload(null, meta, {}, 0.95, new Set(), new Set())).toEqual([]);
expect(buildOverflowPinPayload({}, null, {}, 0.95, new Set(), new Set())).toEqual([]);
});
it('anchors a load-shedding action on the substation of its target VL', () => {
const actions: Record<string, ActionDetail> = {
ls1: {
description_unitaire: 'Délestage',
max_rho: 0.92,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 50 }],
} as unknown as ActionDetail,
};
const meta = makeMetaIndex({ V1: { equipmentId: 'V1' } });
const result = buildOverflowPinPayload(
actions, meta, { V1: 'SUB_A' }, 0.95, new Set(), new Set(),
);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
actionId: 'ls1',
substation: 'SUB_A',
severity: 'orange', // 0.92 > (0.95 - 0.05)
});
expect(result[0].label).toBe('92%');
});
it('marks selected and rejected pins via the id sets', () => {
const actions: Record<string, ActionDetail> = {
a1: {
description_unitaire: '',
max_rho: 0.5,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
a2: {
description_unitaire: '',
max_rho: 0.5,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
};
const meta = makeMetaIndex({ V1: { equipmentId: 'V1' } });
const out = buildOverflowPinPayload(
actions, meta, { V1: 'SUB_A' }, 0.95,
new Set(['a1']), new Set(['a2']),
);
const byId = Object.fromEntries(out.map(p => [p.actionId, p]));
expect(byId.a1.isSelected).toBe(true);
expect(byId.a1.isRejected).toBe(false);
expect(byId.a2.isSelected).toBe(false);
expect(byId.a2.isRejected).toBe(true);
});
it('falls back to a line endpoint when no description match exists', () => {
const actions: Record<string, ActionDetail> = {
disco_LINE_AB: {
description_unitaire: 'Action targets line LINE_AB',
max_rho: 0.4,
} as unknown as ActionDetail,
};
const meta = makeMetaIndex(
{ V1: { equipmentId: 'V1' }, V2: { equipmentId: 'V2' } },
{ LINE_AB: { equipmentId: 'LINE_AB', node1: 'svg-V1', node2: 'svg-V2' } },
);
const out = buildOverflowPinPayload(
actions, meta, { V1: 'SUB_A', V2: 'SUB_B' }, 0.95,
new Set(), new Set(),
);
expect(out).toHaveLength(1);
// Either substation is acceptable depending on iteration order.
expect(['SUB_A', 'SUB_B']).toContain(out[0].substation);
});
it('still emits a pin when the substation is not in the overflowSubstations set, carrying the VL ids as fallback anchors', () => {
// The pin builder used to drop pins whose substation was not
// listed in the overflow graph, but graphs keyed by voltage
// level (the recommender's default for small grids) made that
// heuristic incorrect. The overlay JS now does the real
// gating: it iterates substation + nodeCandidates and skips
// any pin whose anchor never resolves to a graph node.
const actions: Record<string, ActionDetail> = {
a1: {
description_unitaire: '',
max_rho: 0.5,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
};
const meta = makeMetaIndex({ V1: { equipmentId: 'V1' } });
const out = buildOverflowPinPayload(
actions, meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(),
new Set(['SUB_B']), // SUB_A not in overflow graph
);
expect(out).toHaveLength(1);
expect(out[0].substation).toBe('SUB_A');
// VL id should be carried as a fallback so the overlay JS can
// anchor on the VL when the graph turns out to be VL-keyed.
expect(out[0].nodeCandidates).toContain('V1');
});
it('populates nodeCandidates with the action target VLs', () => {
// Graphs keyed by voltage level need to receive VL ids as
// fallback anchors so the overlay can locate the right node.
const actions: Record<string, ActionDetail> = {
ls1: {
description_unitaire: '',
max_rho: 0.5,
load_shedding_details: [{ voltage_level_id: 'V_TARGET', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
};
const meta = makeMetaIndex({ V_TARGET: { equipmentId: 'V_TARGET' } });
const out = buildOverflowPinPayload(
actions, meta, { V_TARGET: 'SUB_A' }, 0.95,
new Set(), new Set(),
);
expect(out).toHaveLength(1);
expect(out[0].substation).toBe('SUB_A');
expect(out[0].nodeCandidates).toContain('V_TARGET');
});
it('emits "DIV" / "ISL" labels and grey severity for non-numeric outcomes', () => {
const actions: Record<string, ActionDetail> = {
div: {
description_unitaire: '',
non_convergence: true,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
isl: {
description_unitaire: '',
is_islanded: true,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
};
const meta = makeMetaIndex({ V1: { equipmentId: 'V1' } });
const out = buildOverflowPinPayload(
actions, meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(),
);
const byId = Object.fromEntries(out.map(p => [p.actionId, p]));
expect(byId.div.label).toBe('DIV');
expect(byId.div.severity).toBe('grey');
expect(byId.isl.label).toBe('ISL');
expect(byId.isl.severity).toBe('grey');
});
});
// ---------------------------------------------------------------------
// Anchor priority — mirrors ``resolveActionAnchor`` in actionPinData.
// Each test below pins down one rung of the priority ladder.
// ---------------------------------------------------------------------
describe('buildOverflowPinPayload — anchor priority', () => {
it('load-shedding action does NOT carry max_rho_line in lineNames (early return)', () => {
// Without the early-return, every load-shedding action would
// inherit max_rho_line as an edge anchor and the pin would
// land on a random line midpoint instead of its target VL.
const actions: Record<string, ActionDetail> = {
ls1: {
description_unitaire: '',
max_rho: 0.5,
max_rho_line: 'LINE_AB',
load_shedding_details: [{
voltage_level_id: 'V_LOAD', load_id: 'L', mw_shed: 1,
}],
} as unknown as ActionDetail,
};
const meta = makeMetaIndex(
{ V_LOAD: { equipmentId: 'V_LOAD' }, V1: { equipmentId: 'V1' }, V2: { equipmentId: 'V2' } },
{ LINE_AB: { equipmentId: 'LINE_AB', node1: 'svg-V1', node2: 'svg-V2' } },
);
const out = buildOverflowPinPayload(
actions, meta, { V_LOAD: 'SUB_LOAD' }, 0.95,
new Set(), new Set(),
);
expect(out).toHaveLength(1);
expect(out[0].lineNames).toEqual([]);
expect(out[0].substation).toBe('SUB_LOAD');
});
it('curtailment action does NOT carry max_rho_line in lineNames (early return)', () => {
const actions: Record<string, ActionDetail> = {
rc1: {
description_unitaire: '',
max_rho: 0.5,
max_rho_line: 'LINE_AB',
curtailment_details: [{
voltage_level_id: 'V_GEN', generator_id: 'G', curtailed_mw: 50,
}],
} as unknown as ActionDetail,
};
const meta = makeMetaIndex(
{ V_GEN: { equipmentId: 'V_GEN' }, V1: { equipmentId: 'V1' }, V2: { equipmentId: 'V2' } },
{ LINE_AB: { equipmentId: 'LINE_AB', node1: 'svg-V1', node2: 'svg-V2' } },
);
const out = buildOverflowPinPayload(
actions, meta, { V_GEN: 'SUB_GEN' }, 0.95,
new Set(), new Set(),
);
expect(out).toHaveLength(1);
expect(out[0].lineNames).toEqual([]);
expect(out[0].substation).toBe('SUB_GEN');
});
it('VL-target action (coupler / node-merging) prefers VL anchor over max_rho_line fallback', () => {
// Coupler-style actions have an explicit VL target via
// description / id parsing AND ``max_rho_line`` set on the
// ``ActionDetail``. Without the priority split, lineNames
// would carry max_rho_line and the pin would jump to a
// random line midpoint. The contract: when VL targets exist,
// lineNames stays empty.
const actions: Record<string, ActionDetail> = {
VLA_coupling: {
description_unitaire: 'COUPL fermeture du poste VLA',
max_rho: 0.5,
max_rho_line: 'LINE_AB',
} as unknown as ActionDetail,
};
const meta = makeMetaIndex(
{ VLA: { equipmentId: 'VLA' }, V1: { equipmentId: 'V1' }, V2: { equipmentId: 'V2' } },
{ LINE_AB: { equipmentId: 'LINE_AB', node1: 'svg-V1', node2: 'svg-V2' } },
);
const out = buildOverflowPinPayload(
actions, meta, { VLA: 'SUB_A' }, 0.95,
new Set(), new Set(),
);
expect(out).toHaveLength(1);
expect(out[0].lineNames).toEqual([]);
// VLA shows up either as the substation, in nodeCandidates,
// or both — depends on whether the substation is in the
// overflow set.
const allNodeAnchors = [out[0].substation, ...(out[0].nodeCandidates ?? [])];
expect(allNodeAnchors.includes('VLA') || allNodeAnchors.includes('SUB_A')).toBe(true);
});
it('branch action with explicit line target emits primary lineNames', () => {
// Disco / reco actions that name a line via the description
// should pin at the edge midpoint — primary lineNames flag.
const actions: Record<string, ActionDetail> = {
disco_LINE_AB: {
description_unitaire: 'Open line LINE_AB',
max_rho: 0.4,
} as unknown as ActionDetail,
};
const meta = makeMetaIndex(
{ V1: { equipmentId: 'V1' }, V2: { equipmentId: 'V2' } },
{ LINE_AB: { equipmentId: 'LINE_AB', node1: 'svg-V1', node2: 'svg-V2' } },
);
const out = buildOverflowPinPayload(
actions, meta, { V1: 'SUB_A', V2: 'SUB_B' }, 0.95,
new Set(), new Set(),
);
expect(out).toHaveLength(1);
expect(out[0].lineNames).toContain('LINE_AB');
});
it('combined-action key (id1+id2) emits an isCombined descriptor with both halves', () => {
// A simulated combined pair has its own ``actions`` entry
// whose key carries ``+``. The overflow overlay places the
// combined pin at the midpoint of a curve drawn between the
// two unitary pins, so the descriptor must carry the ids of
// both halves and skip the per-edge anchor logic.
const actions: Record<string, ActionDetail> = {
ls_a: {
description_unitaire: 'load shedding A',
max_rho: 0.4,
load_shedding_details: [
{ load_name: 'L1', voltage_level_id: 'VLA', shedded_mw: 10 },
],
} as unknown as ActionDetail,
ls_b: {
description_unitaire: 'load shedding B',
max_rho: 0.5,
load_shedding_details: [
{ load_name: 'L2', voltage_level_id: 'VLB', shedded_mw: 12 },
],
} as unknown as ActionDetail,
'ls_a+ls_b': {
description_unitaire: 'combined LS',
max_rho: 0.7,
} as unknown as ActionDetail,
};
const meta = makeMetaIndex(
{ VLA: { equipmentId: 'VLA' }, VLB: { equipmentId: 'VLB' } },
{},
);
const out = buildOverflowPinPayload(
actions, meta, { VLA: 'SUB_A', VLB: 'SUB_B' }, 0.95,
new Set(), new Set(),
);
const combined = out.filter(p => p.isCombined);
expect(combined).toHaveLength(1);
expect(combined[0].action1Id).toBe('ls_a');
expect(combined[0].action2Id).toBe('ls_b');
expect(combined[0].actionId).toBe('ls_a+ls_b');
// Unitary halves must also be in the payload — the overlay
// draws the connector between THEIR positions.
expect(out.some(p => p.actionId === 'ls_a' && !p.isCombined)).toBe(true);
expect(out.some(p => p.actionId === 'ls_b' && !p.isCombined)).toBe(true);
});
it('combined-action descriptor is dropped when one half does not resolve', () => {
const actions: Record<string, ActionDetail> = {
// Only ls_a resolves — ls_b has no anchor (no metadata for VLB).
ls_a: {
description_unitaire: 'load shedding A',
max_rho: 0.4,
load_shedding_details: [
{ load_name: 'L1', voltage_level_id: 'VLA', shedded_mw: 10 },
],
} as unknown as ActionDetail,
'ls_a+ls_b': {
description_unitaire: 'combined LS',
max_rho: 0.7,
} as unknown as ActionDetail,
};
const meta = makeMetaIndex({ VLA: { equipmentId: 'VLA' } }, {});
const out = buildOverflowPinPayload(
actions, meta, { VLA: 'SUB_A' }, 0.95,
new Set(), new Set(),
);
expect(out.some(p => p.isCombined)).toBe(false);
});
it('keeps a passing combined pin even when one constituent fails the threshold (dimmed, not dropped)', () => {
// Mirrors the user-reported bug: ``ls_b`` is above
// ``Max loading`` so the unitary chip would normally hide
// it, but the combined pair ``ls_a+ls_b`` is below the
// threshold. The Action Overview pipeline (the
// ``protectedIds`` set in
// ActionOverviewDiagram.tsx:332) emits the combined pin
// AND keeps the failing constituent visible with
// ``dimmedByFilter: true``.
const actions: Record<string, ActionDetail> = {
ls_a: {
description_unitaire: 'load shedding A',
max_rho: 0.40,
load_shedding_details: [
{ load_name: 'L1', voltage_level_id: 'VLA', shedded_mw: 10 },
],
} as unknown as ActionDetail,
ls_b: {
description_unitaire: 'load shedding B',
max_rho: 1.20,
load_shedding_details: [
{ load_name: 'L2', voltage_level_id: 'VLB', shedded_mw: 12 },
],
} as unknown as ActionDetail,
'ls_a+ls_b': {
description_unitaire: 'combined LS',
max_rho: 0.70,
} as unknown as ActionDetail,
};
const meta = makeMetaIndex(
{ VLA: { equipmentId: 'VLA' }, VLB: { equipmentId: 'VLB' } },
{},
);
const filters: ActionOverviewFilters = {
...DEFAULT_ACTION_OVERVIEW_FILTERS,
threshold: 1.0,
};
const out = buildOverflowPinPayload(
actions, meta, { VLA: 'SUB_A', VLB: 'SUB_B' }, 0.95,
new Set(), new Set(), undefined, filters,
);
const byId = new Map(out.map(p => [p.actionId, p]));
expect(byId.get('ls_a+ls_b')?.isCombined).toBe(true);
const aPin = byId.get('ls_a');
expect(aPin).toBeDefined();
expect(aPin?.dimmedByFilter).toBeFalsy();
const bPin = byId.get('ls_b');
expect(bPin).toBeDefined();
expect(bPin?.dimmedByFilter).toBe(true);
});
it('drops both combined pin AND failing constituents when the combined itself fails the filter', () => {
const actions: Record<string, ActionDetail> = {
ls_a: {
description_unitaire: 'load shedding A',
max_rho: 0.40,
load_shedding_details: [
{ load_name: 'L1', voltage_level_id: 'VLA', shedded_mw: 10 },
],
} as unknown as ActionDetail,
ls_b: {
description_unitaire: 'load shedding B',
max_rho: 1.20,
load_shedding_details: [
{ load_name: 'L2', voltage_level_id: 'VLB', shedded_mw: 12 },
],
} as unknown as ActionDetail,
'ls_a+ls_b': {
description_unitaire: 'combined LS',
max_rho: 1.30,
} as unknown as ActionDetail,
};
const meta = makeMetaIndex(
{ VLA: { equipmentId: 'VLA' }, VLB: { equipmentId: 'VLB' } },
{},
);
const filters: ActionOverviewFilters = {
...DEFAULT_ACTION_OVERVIEW_FILTERS,
threshold: 1.0,
};
const out = buildOverflowPinPayload(
actions, meta, { VLA: 'SUB_A', VLB: 'SUB_B' }, 0.95,
new Set(), new Set(), undefined, filters,
);
const ids = out.map(p => p.actionId);
expect(ids).toContain('ls_a');
expect(ids).not.toContain('ls_b');
expect(ids).not.toContain('ls_a+ls_b');
});
});
// ---------------------------------------------------------------------
// Action-overview filter integration. ``buildOverflowPinPayload`` must
// drop pins identically to ``ActionOverviewDiagram`` so the three
// surfaces (Action Feed / overview NAD / overflow graph) stay in
// lock-step.
// ---------------------------------------------------------------------
describe('buildOverflowPinPayload — overviewFilters', () => {
const baseFilters = (
overrides: Partial<ActionOverviewFilters> = {},
): ActionOverviewFilters => ({
...DEFAULT_ACTION_OVERVIEW_FILTERS,
...overrides,
categories: { ...DEFAULT_ACTION_OVERVIEW_FILTERS.categories,
...(overrides.categories ?? {}) },
});
const makeActions = () => ({
// green ≈ low rho. ID includes ``load_shedding`` so the
// type classifier resolves to the 'ls' bucket.
load_shedding_green: {
description_unitaire: '', max_rho: 0.5,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
// orange ≈ slightly above monitoring factor
load_shedding_orange: {
description_unitaire: '', max_rho: 0.92,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
// red ≈ above 1.0
load_shedding_red: {
description_unitaire: '', max_rho: 1.4,
load_shedding_details: [{ voltage_level_id: 'V1', load_id: 'L', mw_shed: 1 }],
} as unknown as ActionDetail,
});
const meta = makeMetaIndex({ V1: { equipmentId: 'V1' } });
it('keeps every pin when every category is enabled and threshold is high', () => {
const out = buildOverflowPinPayload(
makeActions(), meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(),
undefined, baseFilters({ threshold: 3.0 }),
);
expect(new Set(out.map(p => p.actionId))).toEqual(
new Set(['load_shedding_green', 'load_shedding_orange', 'load_shedding_red']),
);
});
it('drops red pins when the red category is disabled', () => {
const out = buildOverflowPinPayload(
makeActions(), meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(),
undefined,
baseFilters({
categories: {
green: true, orange: true, red: false, grey: true,
},
threshold: 3.0,
}),
);
expect(out.map(p => p.actionId).includes('load_shedding_red')).toBe(false);
});
it('drops pins above the max-loading threshold', () => {
const out = buildOverflowPinPayload(
makeActions(), meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(),
undefined,
baseFilters({ threshold: 1.0 }),
);
// red1 has max_rho=1.4 > 1.0 → dropped.
expect(out.map(p => p.actionId).includes('load_shedding_red')).toBe(false);
expect(out.map(p => p.actionId).includes('load_shedding_green')).toBe(true);
});
it('action-type chip drops actions of other types (LS-only filter)', () => {
// Mix a load_shedding action with a disco action; ask for
// LS-only and verify the disco pin is dropped.
const actions: Record<string, ActionDetail> = {
...makeActions(),
disco_LINE: {
description_unitaire: 'open line ouverture LINE_AB',
max_rho: 0.5,
} as unknown as ActionDetail,
};
const out = buildOverflowPinPayload(
actions, meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(),
undefined,
baseFilters({ actionType: 'ls', threshold: 3.0 }),
);
const ids = new Set(out.map(p => p.actionId));
expect(ids.has('load_shedding_green')).toBe(true);
expect(ids.has('disco_LINE')).toBe(false);
});
it('combined category + threshold + action-type filters apply together', () => {
const out = buildOverflowPinPayload(
makeActions(), meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(),
undefined,
baseFilters({
categories: {
green: false, orange: true, red: false, grey: true,
},
threshold: 1.0,
actionType: 'ls',
}),
);
// green1 dropped (category off), red1 dropped (above
// threshold); only orange1 survives.
expect(out.map(p => p.actionId)).toEqual(['load_shedding_orange']);
});
it('omitting overviewFilters preserves the legacy "every resolvable pin" behaviour', () => {
// Tests / standalone callers that don't pass filters must
// continue to receive every pin the metadata can resolve.
const out = buildOverflowPinPayload(
makeActions(), meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(),
);
expect(out).toHaveLength(3);
});
});
// ---------------------------------------------------------------------
// Combined-only filter — the new "Combined only" pin chip on both the
// Action Overview NAD and the Overflow Analysis iframe must drop every
// unitary / un-simulated pin that is NOT a constituent of a passing
// combined pin, and dim the surviving constituents so they read as
// context for the pair glyph.
// ---------------------------------------------------------------------
describe('buildOverflowPinPayload — showCombinedOnly', () => {
const makeCombinedFixture = () => {
const actions: Record<string, ActionDetail> = {
ls_a: {
description_unitaire: 'load shedding A',
max_rho: 0.40,
load_shedding_details: [
{ load_name: 'L1', voltage_level_id: 'VLA', shedded_mw: 10 },
],
} as unknown as ActionDetail,
ls_b: {
description_unitaire: 'load shedding B',
max_rho: 0.50,
load_shedding_details: [
{ load_name: 'L2', voltage_level_id: 'VLB', shedded_mw: 12 },
],
} as unknown as ActionDetail,
ls_solo: {
description_unitaire: 'load shedding solo',
max_rho: 0.60,
load_shedding_details: [
{ load_name: 'L3', voltage_level_id: 'VLC', shedded_mw: 15 },
],
} as unknown as ActionDetail,
'ls_a+ls_b': {
description_unitaire: 'combined LS',
max_rho: 0.70,
} as unknown as ActionDetail,
};
const meta = makeMetaIndex({
VLA: { equipmentId: 'VLA' },
VLB: { equipmentId: 'VLB' },
VLC: { equipmentId: 'VLC' },
});
const subs = { VLA: 'SUB_A', VLB: 'SUB_B', VLC: 'SUB_C' };
return { actions, meta, subs };
};
it('drops every unitary pin that is NOT a combined-pair constituent', () => {
const { actions, meta, subs } = makeCombinedFixture();
const filters: ActionOverviewFilters = {
...DEFAULT_ACTION_OVERVIEW_FILTERS,
threshold: 3.0,
showCombinedOnly: true,
};
const out = buildOverflowPinPayload(
actions, meta, subs, 0.95, new Set(), new Set(), undefined, filters,
);
const ids = new Set(out.map(p => p.actionId));
// Solo pin disappears; constituents stay (dimmed); pair stays.
expect(ids.has('ls_solo')).toBe(false);
expect(ids.has('ls_a')).toBe(true);
expect(ids.has('ls_b')).toBe(true);
expect(ids.has('ls_a+ls_b')).toBe(true);
});
it('marks the surviving constituents with dimmedByFilter so the iframe renders them as context', () => {
const { actions, meta, subs } = makeCombinedFixture();
const filters: ActionOverviewFilters = {
...DEFAULT_ACTION_OVERVIEW_FILTERS,
threshold: 3.0,
showCombinedOnly: true,
};
const out = buildOverflowPinPayload(
actions, meta, subs, 0.95, new Set(), new Set(), undefined, filters,
);
const byId = new Map(out.map(p => [p.actionId, p]));
expect(byId.get('ls_a')?.dimmedByFilter).toBe(true);
expect(byId.get('ls_b')?.dimmedByFilter).toBe(true);
// Combined pin itself is NOT dimmed — it's the focus of the mode.
expect(byId.get('ls_a+ls_b')?.dimmedByFilter).toBeFalsy();
expect(byId.get('ls_a+ls_b')?.isCombined).toBe(true);
});
it('returns no unitary pins at all when no combined pair exists', () => {
// showCombinedOnly with zero pairs ⇒ empty unitary output.
// The payload may still carry the un-paired actions only if
// a passing combined pair references them; otherwise drop.
const actions: Record<string, ActionDetail> = {
solo1: {
description_unitaire: '',
max_rho: 0.5,
load_shedding_details: [
{ load_name: 'L1', voltage_level_id: 'V1', shedded_mw: 10 },
],
} as unknown as ActionDetail,
solo2: {
description_unitaire: '',
max_rho: 0.5,
load_shedding_details: [
{ load_name: 'L2', voltage_level_id: 'V1', shedded_mw: 11 },
],
} as unknown as ActionDetail,
};
const meta = makeMetaIndex({ V1: { equipmentId: 'V1' } });
const filters: ActionOverviewFilters = {
...DEFAULT_ACTION_OVERVIEW_FILTERS,
threshold: 3.0,
showCombinedOnly: true,
};
const out = buildOverflowPinPayload(
actions, meta, { V1: 'SUB_A' }, 0.95,
new Set(), new Set(), undefined, filters,
);
expect(out).toEqual([]);
});
it('drops the combined pin too when its own severity / threshold filter fails', () => {
// showCombinedOnly only touches the unitary pins — combined
// pins still go through their own filter, so a pair that
// fails its severity / threshold check disappears entirely
// (and so do its constituents, since nothing protects them).
const { actions, meta, subs } = makeCombinedFixture();
// Bump the pair max_rho above the threshold.
actions['ls_a+ls_b'] = {
...actions['ls_a+ls_b'],
max_rho: 1.30,
} as ActionDetail;
const filters: ActionOverviewFilters = {
...DEFAULT_ACTION_OVERVIEW_FILTERS,
threshold: 1.0,
showCombinedOnly: true,
};
const out = buildOverflowPinPayload(
actions, meta, subs, 0.95, new Set(), new Set(), undefined, filters,
);
expect(out).toEqual([]);
});
it('does not touch the payload when showCombinedOnly is false (default behaviour preserved)', () => {
const { actions, meta, subs } = makeCombinedFixture();
const filters: ActionOverviewFilters = {
...DEFAULT_ACTION_OVERVIEW_FILTERS,
threshold: 3.0,
showCombinedOnly: false,
};
const out = buildOverflowPinPayload(
actions, meta, subs, 0.95, new Set(), new Set(), undefined, filters,
);
const ids = new Set(out.map(p => p.actionId));
// Solo pin survives at full strength alongside the pair.
expect(ids.has('ls_solo')).toBe(true);
expect(out.find(p => p.actionId === 'ls_solo')?.dimmedByFilter).toBeFalsy();
expect(out.find(p => p.actionId === 'ls_a')?.dimmedByFilter).toBeFalsy();
});
});
// ---------------------------------------------------------------------
// buildOverflowUnsimulatedPinPayload — Show-unsimulated path. Mirrors
// the Action-Overview ``buildUnsimulatedActionPins`` contract.
// ---------------------------------------------------------------------
describe('buildOverflowUnsimulatedPinPayload', () => {
const meta = makeMetaIndex(
{ V1: { equipmentId: 'V1' }, V2: { equipmentId: 'V2' } },
{ LINE_AB: { equipmentId: 'LINE_AB', node1: 'svg-V1', node2: 'svg-V2' } },
);
it('emits a dimmed grey pin with "?" label and the unsimulated flag', () => {
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1'],
new Set(),
meta,
{ V1: 'SUB_A' },
);
expect(out).toHaveLength(1);
expect(out[0]).toMatchObject({
actionId: 'load_shedding_V1',
severity: 'grey',
label: '?',
unsimulated: true,
isSelected: false,
isRejected: false,
});
});
it('skips ids already present in simulatedIds (no double pinning)', () => {
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1', 'disco_LINE_AB'],
new Set(['load_shedding_V1']),
meta,
{ V1: 'SUB_A', V2: 'SUB_B' },
);
expect(out.map(p => p.actionId)).toEqual(['disco_LINE_AB']);
});
it('returns [] when no metadata index is available', () => {
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1'], new Set(), null, { V1: 'SUB_A' },
);
expect(out).toEqual([]);
});
it('returns [] when no scored ids are passed', () => {
const out = buildOverflowUnsimulatedPinPayload(
[], new Set(), meta, { V1: 'SUB_A' },
);
expect(out).toEqual([]);
});
it('dedupes repeated ids in the input list', () => {
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1', 'load_shedding_V1', 'load_shedding_V1'],
new Set(),
meta,
{ V1: 'SUB_A' },
);
expect(out).toHaveLength(1);
});
it('still resolves anchors via the same priority order as simulated pins', () => {
// disco line action → expects line in lineNames (edge-midpoint
// anchor), same as in buildOverflowPinPayload.
const out = buildOverflowUnsimulatedPinPayload(
['disco_LINE_AB'],
new Set(),
meta,
{ V1: 'SUB_A', V2: 'SUB_B' },
);
expect(out).toHaveLength(1);
expect(out[0].lineNames).toContain('LINE_AB');
});
it('emits a multi-line title carrying score / rank / MW-start when scoreInfo is supplied', () => {
// Mirrors the Action Overview's hover tooltip exactly so an
// operator hovering an unsimulated pin in the overflow
// graph sees the same triage info (id + score + rank +
// MW-start) as on the Action Overview NAD.
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1'],
new Set(),
meta,
{ V1: 'SUB_A' },
{
load_shedding_V1: {
type: 'load_shedding',
score: 0.42,
mwStart: 91.6,
tapStart: null,
rankInType: 3,
countInType: 8,
maxScoreInType: 1.5,
},
},
);
expect(out).toHaveLength(1);
const title = out[0].title ?? '';
expect(title).toContain('load_shedding_V1');
expect(title).toContain('not yet simulated');
expect(title).toContain('Type: load_shedding');
expect(title).toContain('Score: 0.42');
expect(title).toContain('rank 3 of 8');
expect(title).toContain('MW start: 91.6');
});
it('falls back to a single-line generic title when scoreInfo is absent', () => {
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1'],
new Set(),
meta,
{ V1: 'SUB_A' },
);
expect(out[0].title).toBe(
'load_shedding_V1 — not yet simulated (double-click to run)',
);
});
it('action-type chip drops unsimulated ids of other types (LS-only filter)', () => {
// Regression for the Overflow Analysis pin filter bug: the
// ACTION PINS FILTERS chip row (DISCO / RECO / LS / OPEN /
// CLOSE / PST) must filter unsimulated pins the same way the
// Action Overview's `unsimulatedPins` memo does — otherwise
// the operator sees pins for action types they explicitly
// hid. Fixed by passing `overviewFilters` through to the
// builder.
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1', 'disco_LINE_AB'],
new Set(),
meta,
{ V1: 'SUB_A', V2: 'SUB_B' },
undefined,
undefined,
{
categories: { green: true, orange: true, red: true, grey: true },
threshold: 200,
actionType: 'ls',
showUnsimulated: true,
},
);
expect(out.map(p => p.actionId)).toEqual(['load_shedding_V1']);
});
it('action-type chip prefers scoreInfo.type over id-based heuristics', () => {
// When the score-info supplies a type, that wins over the
// id-based `classifyActionType` fallback — same precedence
// ActionOverviewDiagram applies. Use a load_shedding score-
// type on an id whose tokens ("ambiguous_id_V1") would not
// otherwise classify as `ls`.
const out = buildOverflowUnsimulatedPinPayload(
['ambiguous_id_V1'],
new Set(),
meta,
{ V1: 'SUB_A' },
{
ambiguous_id_V1: {
type: 'load_shedding',
score: 0.5,
mwStart: 10,
tapStart: null,
rankInType: 1,
countInType: 1,
maxScoreInType: 0.5,
},
},
undefined,
{
categories: { green: true, orange: true, red: true, grey: true },
threshold: 200,
actionType: 'ls',
showUnsimulated: true,
},
);
expect(out).toHaveLength(1);
expect(out[0].actionId).toBe('ambiguous_id_V1');
});
it('action-type chip set to "all" keeps every unsimulated pin (no filter)', () => {
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1', 'disco_LINE_AB'],
new Set(),
meta,
{ V1: 'SUB_A', V2: 'SUB_B' },
undefined,
undefined,
{
categories: { green: true, orange: true, red: true, grey: true },
threshold: 200,
actionType: 'all',
showUnsimulated: true,
},
);
expect(out.map(p => p.actionId).sort()).toEqual(
['disco_LINE_AB', 'load_shedding_V1'],
);
});
it('returns [] when showCombinedOnly is on (un-simulated actions can never be in a pair)', () => {
const out = buildOverflowUnsimulatedPinPayload(
['load_shedding_V1', 'disco_LINE_AB'],
new Set(),
meta,
{ V1: 'SUB_A', V2: 'SUB_B' },
undefined,
undefined,
{
...DEFAULT_ACTION_OVERVIEW_FILTERS,
showUnsimulated: true,
showCombinedOnly: true,
},
);
expect(out).toEqual([]);
});
});