// 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 } from 'vitest'; import { decidePopoverPlacement, computePopoverStyle, POPOVER_WIDTH, POPOVER_MAX_HEIGHT, POPOVER_PIN_OFFSET, POPOVER_VIEWPORT_MARGIN, } from './popoverPlacement'; const VIEWPORT = { width: 1600, height: 900 }; describe('decidePopoverPlacement', () => { describe('vertical', () => { it('places BELOW when there is plenty of room below the pin', () => { // Pin near the top → spaceBelow >> POPOVER_MAX_HEIGHT const p = decidePopoverPlacement(800, 50, VIEWPORT); expect(p.placeAbove).toBe(false); }); it('places ABOVE when the pin is at the bottom (no room below for the popover)', () => { const p = decidePopoverPlacement(800, VIEWPORT.height - 30, VIEWPORT); expect(p.placeAbove).toBe(true); }); it('falls back to whichever side has more room when neither fits the full max-height', () => { // Tiny viewport so neither side can fit POPOVER_MAX_HEIGHT const tinyViewport = { width: 1600, height: 300 }; // Pin closer to the bottom → spaceAbove > spaceBelow → above wins const lower = decidePopoverPlacement(800, 220, tinyViewport); expect(lower.placeAbove).toBe(true); // Pin closer to the top → spaceBelow > spaceAbove → below wins const upper = decidePopoverPlacement(800, 80, tinyViewport); expect(upper.placeAbove).toBe(false); }); }); describe('horizontal', () => { it('aligns START when the pin is in the LEFT third of the viewport', () => { const p = decidePopoverPlacement(300, 400, VIEWPORT); expect(p.horizontalAlign).toBe('start'); }); it('aligns CENTER when the pin is in the MIDDLE third of the viewport', () => { const p = decidePopoverPlacement(800, 400, VIEWPORT); expect(p.horizontalAlign).toBe('center'); }); it('aligns END when the pin is in the RIGHT third of the viewport', () => { const p = decidePopoverPlacement(1300, 400, VIEWPORT); expect(p.horizontalAlign).toBe('end'); }); }); }); describe('computePopoverStyle', () => { it('uses `top` when placing BELOW the pin', () => { const style = computePopoverStyle({ screenX: 800, screenY: 100, placeAbove: false, horizontalAlign: 'center', }, VIEWPORT); expect(style.position).toBe('fixed'); expect(style.top).toBe(100 + POPOVER_PIN_OFFSET); expect(style.bottom).toBeUndefined(); }); it('uses `bottom` (not `top`) when placing ABOVE the pin so the bottom edge anchors at the pin', () => { const style = computePopoverStyle({ screenX: 800, screenY: 800, placeAbove: true, horizontalAlign: 'center', }, VIEWPORT); expect(style.bottom).toBe(VIEWPORT.height - 800 + POPOVER_PIN_OFFSET); expect(style.top).toBeUndefined(); }); it('horizontal alignment START offsets the popover to the right of the pin', () => { const style = computePopoverStyle({ screenX: 200, screenY: 400, placeAbove: false, horizontalAlign: 'start', }, VIEWPORT); // start: left edge ≈ pin.x - POPOVER_PIN_OFFSET expect(style.left).toBe(200 - POPOVER_PIN_OFFSET); }); it('horizontal alignment END puts the popover to the left of the pin', () => { const style = computePopoverStyle({ screenX: 1400, screenY: 400, placeAbove: false, horizontalAlign: 'end', }, VIEWPORT); // end: left edge = pin.x - POPOVER_WIDTH + POPOVER_PIN_OFFSET expect(style.left).toBe(1400 - POPOVER_WIDTH + POPOVER_PIN_OFFSET); }); it('horizontal alignment CENTER centres the popover on the pin', () => { const style = computePopoverStyle({ screenX: 800, screenY: 400, placeAbove: false, horizontalAlign: 'center', }, VIEWPORT); expect(style.left).toBe(800 - POPOVER_WIDTH / 2); }); it('clamps the popover to the LEFT viewport edge when the requested left would go negative', () => { const style = computePopoverStyle({ screenX: 5, screenY: 400, placeAbove: false, horizontalAlign: 'center', }, VIEWPORT); expect(style.left).toBe(POPOVER_VIEWPORT_MARGIN); }); it('clamps the popover to the RIGHT viewport edge when the requested left would overflow', () => { const style = computePopoverStyle({ screenX: VIEWPORT.width - 5, screenY: 400, placeAbove: false, horizontalAlign: 'center', }, VIEWPORT); expect(style.left).toBe(VIEWPORT.width - POPOVER_WIDTH - POPOVER_VIEWPORT_MARGIN); }); it('end-to-end: a pin in the BOTTOM-RIGHT corner produces an above + end placement that stays in viewport', () => { const placement = decidePopoverPlacement(VIEWPORT.width - 50, VIEWPORT.height - 50, VIEWPORT); expect(placement.placeAbove).toBe(true); expect(placement.horizontalAlign).toBe('end'); const style = computePopoverStyle({ screenX: VIEWPORT.width - 50, screenY: VIEWPORT.height - 50, ...placement, }, VIEWPORT); // Above placement → uses bottom (not top) expect(style.bottom).toBeDefined(); // End placement clamps inside the right edge const left = style.left as number; expect(left).toBeGreaterThanOrEqual(POPOVER_VIEWPORT_MARGIN); expect(left + POPOVER_WIDTH).toBeLessThanOrEqual(VIEWPORT.width - POPOVER_VIEWPORT_MARGIN); }); it('end-to-end: a pin in the TOP-LEFT corner produces a below + start placement that stays in viewport', () => { const placement = decidePopoverPlacement(40, 40, VIEWPORT); expect(placement.placeAbove).toBe(false); expect(placement.horizontalAlign).toBe('start'); const style = computePopoverStyle({ screenX: 40, screenY: 40, ...placement, }, VIEWPORT); expect(style.top).toBe(40 + POPOVER_PIN_OFFSET); const left = style.left as number; expect(left).toBeGreaterThanOrEqual(POPOVER_VIEWPORT_MARGIN); expect(left + POPOVER_WIDTH).toBeLessThanOrEqual(VIEWPORT.width - POPOVER_VIEWPORT_MARGIN); }); it('exports placement constants for downstream consumers', () => { expect(POPOVER_WIDTH).toBe(340); expect(POPOVER_MAX_HEIGHT).toBe(480); }); }); describe('detached-window viewport handling', () => { // Regression: in a detached popup the main-window viewport is // irrelevant — the component must pass the popup's viewport so // the above/below decision reflects the pin's actual on-screen // position. These tests feed an explicit viewport to mimic the // popup-window dimensions. it('same pin Y is "BELOW" in a tall popup but "ABOVE" in a short popup', () => { const pinY = 600; const tall = decidePopoverPlacement(800, pinY, { width: 1600, height: 1400 }); expect(tall.placeAbove).toBe(false); const short = decidePopoverPlacement(800, pinY, { width: 1600, height: 700 }); expect(short.placeAbove).toBe(true); }); it('computes `bottom` anchor using the explicit viewport height, not main-window height', () => { const popupViewport = { width: 1200, height: 600 }; const style = computePopoverStyle({ screenX: 800, screenY: 550, placeAbove: true, horizontalAlign: 'center', }, popupViewport); // `bottom` must use the popup height (600), not whatever // the default (main window) viewport is. expect(style.bottom).toBe(popupViewport.height - 550 + POPOVER_PIN_OFFSET); }); it('horizontal clamp uses the explicit viewport width', () => { const popupViewport = { width: 900, height: 600 }; const style = computePopoverStyle({ screenX: popupViewport.width - 10, screenY: 300, placeAbove: false, horizontalAlign: 'center', }, popupViewport); expect(style.left).toBe(popupViewport.width - POPOVER_WIDTH - POPOVER_VIEWPORT_MARGIN); }); });