// 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 & { equipmentId: string }>, edges: Record & { equipmentId: string; node1: string; node2: string }> = {}, ): MetadataIndex => { const nodesByEquipmentId = new Map(); const nodesBySvgId = new Map(); 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(); 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { // 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 = { 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 = { 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 => ({ ...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 = { ...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 = { 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 = { 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([]); }); });