// 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 // This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study. import { describe, it, expect, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useOverflowIframe } from './useOverflowIframe'; import type { OverflowPin } from '../utils/svg/overflowPinPayload'; /** Window-like stub backed by a real EventTarget so we can dispatch * message events at it just like a popup window would receive them * from `iframe.contentWindow.parent.postMessage(...)` when the * overflow tab is detached into a secondary browser window. */ function makePopupWindow(): Window { const target = new EventTarget(); return Object.assign(target, { closed: false }) as unknown as Window; } describe('useOverflowIframe', () => { const noopArgs = { pdfUrl: '/results/pdf/foo.html' as string | null | undefined, overflowPinsEnabled: false, overflowPins: [] as ReadonlyArray, overviewFilters: undefined, }; it('handshake on the MAIN window flips overlayReady so pins broadcast', () => { const postMessage = vi.fn(); const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => useOverflowIframe({ ...noopArgs, overflowPinsEnabled: enabled, }), { initialProps: { enabled: false } }, ); // Simulate the iframe ref being attached. Only `contentWindow.postMessage` // is read, so a stub object satisfies the broadcast path. act(() => { (result.current.overflowIframeRef as { current: unknown }).current = { contentWindow: { postMessage }, }; }); // Handshake on main window — listener registered there. act(() => { window.dispatchEvent(new MessageEvent('message', { data: { type: 'cs4g:overlay-ready' }, })); }); // Toggle enabled to trigger the broadcast effect. rerender({ enabled: true }); expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: 'cs4g:pins', visible: true, pins: [] }), '*', ); }); it('listens on detachedWindow too so the handshake from a popup-mounted iframe is received (regression)', () => { // Regression: when the overflow tab is detached, the iframe's // `window.parent.postMessage(...)` lands on the popup window. // Without binding the listener there, overlay-ready was lost, // overlayReady stayed false and the pin-toggle did nothing // after a layout switch. const popup = makePopupWindow(); const postMessage = vi.fn(); const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => useOverflowIframe({ ...noopArgs, overflowPinsEnabled: enabled, detachedWindow: popup, }), { initialProps: { enabled: false } }, ); act(() => { (result.current.overflowIframeRef as { current: unknown }).current = { contentWindow: { postMessage }, }; }); // Handshake arrives on the popup window — main window's // listener would never see it, but the popup-bound listener // must. act(() => { popup.dispatchEvent(new MessageEvent('message', { data: { type: 'cs4g:overlay-ready' }, })); }); rerender({ enabled: true }); expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: 'cs4g:pins', visible: true }), '*', ); }); it('broadcasts cs4g:overflow-meta whenever overflowGraphTime changes after the handshake', () => { const postMessage = vi.fn(); const { result, rerender } = renderHook( ({ time }: { time: number | null }) => useOverflowIframe({ ...noopArgs, overflowGraphTime: time, }), { initialProps: { time: null as number | null } }, ); act(() => { (result.current.overflowIframeRef as { current: unknown }).current = { contentWindow: { postMessage }, }; }); act(() => { window.dispatchEvent(new MessageEvent('message', { data: { type: 'cs4g:overlay-ready' }, })); }); // After the handshake, the initial null meta is forwarded so the // iframe can wipe any prior subtitle. expect(postMessage).toHaveBeenCalledWith( { type: 'cs4g:overflow-meta', overflowGraphTime: null }, '*', ); // Update the time — the new value reaches the iframe. postMessage.mockClear(); rerender({ time: 3.42 }); expect(postMessage).toHaveBeenCalledWith( { type: 'cs4g:overflow-meta', overflowGraphTime: 3.42 }, '*', ); }); it('removes the popup-window listener on unmount and on detachedWindow change', () => { const popupA = makePopupWindow(); const popupB = makePopupWindow(); const removeA = vi.spyOn(popupA, 'removeEventListener'); const removeB = vi.spyOn(popupB, 'removeEventListener'); const { rerender, unmount } = renderHook( ({ detached }: { detached: Window | null }) => useOverflowIframe({ ...noopArgs, detachedWindow: detached, }), { initialProps: { detached: popupA as Window | null } }, ); // Switch from popupA to popupB — popupA's listener must come off. rerender({ detached: popupB }); expect(removeA).toHaveBeenCalledWith('message', expect.any(Function)); // Reattach (detached → null) — popupB's listener must come off. rerender({ detached: null }); expect(removeB).toHaveBeenCalledWith('message', expect.any(Function)); unmount(); }); });