Files
portal/app/tests/Frontend/installMenuRepositionFix.spec.ts
T
Дмитрий c78b69fcaf feat(fe): подключить installMenuRepositionFix при запуске SPA
Также: привести resizeSpy в тесте к EventListener (тип-чистота vue-tsc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:23:55 +03:00

105 lines
3.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { installMenuRepositionFix } from '../../resources/js/utils/menuRepositionFix';
// Ручной requestAnimationFrame: кадры прогоняем детерминированно.
let rafQueue: FrameRequestCallback[] = [];
function flushFrames(n: number): void {
for (let i = 0; i < n; i++) {
const batch = rafQueue;
rafQueue = [];
batch.forEach((cb) => cb(0));
}
}
// Дать MutationObserver (микротаска jsdom) сработать.
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
function makeMenu(): HTMLElement {
const menu = document.createElement('div');
menu.className = 'v-overlay v-menu';
const content = document.createElement('div');
content.className = 'v-overlay__content';
menu.appendChild(content);
return menu;
}
let teardown: (() => void) | undefined;
let rectWidth = 200;
let rectLeft = 100;
let origRect: typeof HTMLElement.prototype.getBoundingClientRect;
let resizeSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
rafQueue = [];
rectWidth = 200;
rectLeft = 100;
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafQueue.push(cb);
return rafQueue.length;
});
origRect = HTMLElement.prototype.getBoundingClientRect;
HTMLElement.prototype.getBoundingClientRect = function (): DOMRect {
return {
width: rectWidth,
height: 10,
left: rectLeft,
top: 0,
right: rectLeft + rectWidth,
bottom: 10,
x: rectLeft,
y: 0,
toJSON: () => ({}),
} as DOMRect;
};
resizeSpy = vi.fn();
window.addEventListener('resize', resizeSpy as unknown as EventListener);
});
afterEach(() => {
teardown?.();
teardown = undefined;
HTMLElement.prototype.getBoundingClientRect = origRect;
window.removeEventListener('resize', resizeSpy as unknown as EventListener);
vi.unstubAllGlobals();
document.body.innerHTML = '';
});
describe('installMenuRepositionFix', () => {
it('при появлении меню стабилизирует позицию и шлёт один resize', async () => {
teardown = installMenuRepositionFix();
document.body.appendChild(makeMenu());
await tick();
flushFrames(6);
expect(resizeSpy).toHaveBeenCalledTimes(1);
});
it('на посторонний узел не реагирует', async () => {
teardown = installMenuRepositionFix();
const other = document.createElement('div');
other.className = 'some-card';
document.body.appendChild(other);
await tick();
flushFrames(6);
expect(resizeSpy).not.toHaveBeenCalled();
});
it('идемпотентна: двойная установка не даёт двойной resize', async () => {
teardown = installMenuRepositionFix();
installMenuRepositionFix(); // второй вызов — noop
document.body.appendChild(makeMenu());
await tick();
flushFrames(6);
expect(resizeSpy).toHaveBeenCalledTimes(1);
});
it('предохранитель: если геометрия не устаканилась — не виснет и не шлёт resize', async () => {
rectWidth = 0; // контент «нулевой» → условие стабилизации не выполняется
teardown = installMenuRepositionFix();
document.body.appendChild(makeMenu());
await tick();
flushFrames(120); // больше предохранителя (90 кадров)
expect(resizeSpy).not.toHaveBeenCalled();
expect(rafQueue.length).toBe(0); // цикл остановился, не зациклился
});
});