Co-Study4Grid / frontend /src /utils /popoverPlacement.test.ts
github-actions[bot]
Deploy 7688ef1
13d4e44
Raw
History Blame Contribute Delete
9.02 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
// 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);
});
});