Files
enduro-trails/tests/unit/sheet_popup.test.js
claude-bot 39348f6781
All checks were successful
CI / lint (push) Successful in 4s
CI / test (push) Successful in 10s
CI / build (push) Successful in 3s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 9s
CI / build (pull_request) Successful in 2s
fix(ui): terrain-popup закрывается при открытии bottom-sheet (ET-014)
При открытии любого .bottom-sheet через openSheet() теперь принудительно
скрывается #terrain-popup и снимается .active с #terrain-toggle. Это
устраняет z-index конфликт (popup z=500 над sheet z=400) и убирает
anti-pattern «два меню одновременно» на desktop без правки CSS-стека
(marker-dialog z=500, search-panel, ruler-info — без регрессий).

Реализация — Вариант A из ADR-019: helper closeTerrainPopup() + один
вызов первой строкой в openSheet() после null-check. Для других sheets
(sheet-route, sheet-recon, sheet-scenic, sheet-link, sheet-gpx) вызов
безопасный no-op, REQ-F-06 выполняется автоматически.

Тесты:
- tests/unit/sheet_popup.test.js — 8 behavioral JS unit-тестов
  (TC-U-02, REQ-F-04, REQ-F-06 + ребра closeTerrainPopup).
- tests/unit/test_sheet_popup.py — pytest-обёртка: статические проверки
  app.js (порядок вызовов в openSheet, маркеры блока), охранные тесты
  что z-stack не тронут и что gps_tracks.js/index.html не правились.

Refs: ET-014
ADR: docs/work-items/ET-014/06-adr/ADR-019-terrain-popup-yields-to-sheet.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 11:20:49 +00:00

260 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* ET-014 — поведенческие unit-тесты для closeTerrainPopup() и openSheet().
*
* Покрывают TC-U-01..TC-U-02 (часть) из docs/work-items/ET-014/04-test-plan.yaml,
* а также проверяют логику ADR-019: при открытии любого bottom-sheet
* `#terrain-popup` принудительно закрывается, а `#terrain-toggle` теряет
* класс `.active`. Поведение базируется на JS-функциях из блока ET-014 в
* src/web/app.js (между маркерами `// >>> ET-014 sheet-popup yield block`
* и `// <<< ET-014 sheet-popup yield block <<<`).
*
* Запуск: `node --test tests/unit/sheet_popup.test.js`
* (в CI оборачивается pytest-тестом tests/unit/test_sheet_popup.py).
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const APP_JS = path.join(__dirname, '..', '..', 'src', 'web', 'app.js');
/**
* Извлекает ET-014-блок из app.js и собирает из него модуль, подставляя
* переданные зависимости (window, document, closeTerrainOnOutside,
* closeSheet). Стиль повторяет загрузчик ET-007 (base_layer.test.js).
*/
function loadEt014Module(deps) {
const src = fs.readFileSync(APP_JS, 'utf8');
const m = src.match(
/\/\/ >>> ET-014 sheet-popup yield block[^\n]*\n([\s\S]*?)\/\/ <<< ET-014 sheet-popup yield block/
);
assert.ok(m, 'ET-014-блок не найден в app.js (маркеры отсутствуют)');
const factory = new Function(
'window', 'document', 'closeTerrainOnOutside', 'closeSheet',
m[1] + '\nreturn { closeTerrainPopup, openSheet };'
);
return factory(
deps.window,
deps.document,
deps.closeTerrainOnOutside || (() => {}),
deps.closeSheet || (() => {}),
);
}
/**
* Готовит мок-DOM: #terrain-popup, #terrain-toggle, #sheet-backdrop,
* а также произвольный набор bottom-sheets. Каждый bottom-sheet имеет
* classList с методами add/remove/contains и querySelectorAll-совместимый
* матчинг по селектору '.bottom-sheet.open' (через document.querySelectorAll).
*/
function makeEnv({
popupVisible = false,
toggleActive = false,
sheets = [], // [{ id, open }]
backdropVisible = false,
} = {}) {
const popup = {
style: { display: popupVisible ? 'block' : 'none' },
};
const _toggleClasses = new Set(['map-btn']);
if (toggleActive) _toggleClasses.add('active');
const toggle = {
classList: {
_classes: _toggleClasses,
add(c) { this._classes.add(c); },
remove(c) { this._classes.delete(c); },
contains(c) { return this._classes.has(c); },
},
};
const _backdropClasses = new Set();
if (backdropVisible) _backdropClasses.add('visible');
const backdrop = {
classList: {
_classes: _backdropClasses,
add(c) { this._classes.add(c); },
remove(c) { this._classes.delete(c); },
contains(c) { return this._classes.has(c); },
},
};
// Bottom-sheets с classList API.
const sheetEls = sheets.map(({ id, open }) => {
const _classes = new Set(['bottom-sheet']);
if (open) _classes.add('open');
return {
id,
classList: {
_classes,
add(c) { this._classes.add(c); },
remove(c) { this._classes.delete(c); },
contains(c) { return this._classes.has(c); },
},
};
});
const docCalls = {
removeEventListener: [],
};
const document = {
getElementById(id) {
if (id === 'terrain-popup') return popup;
if (id === 'terrain-toggle') return toggle;
if (id === 'sheet-backdrop') return backdrop;
const s = sheetEls.find((e) => e.id === id);
return s || null;
},
querySelectorAll(selector) {
if (selector === '.bottom-sheet.open') {
return sheetEls.filter((s) => s.classList.contains('open'));
}
return [];
},
removeEventListener(type, fn) {
docCalls.removeEventListener.push([type, fn]);
},
addEventListener() { /* not used by closeTerrainPopup */ },
};
return { document, popup, toggle, backdrop, sheetEls, docCalls };
}
// ─── TC-U-02 (часть А): popup закрывается при открытии sheet ────────────
test('TC-U-02: openSheet() закрывает открытый terrain-popup и снимает .active', () => {
const env = makeEnv({
popupVisible: true,
toggleActive: true,
sheets: [{ id: 'sheet-gps-filters', open: false }],
});
const mod = loadEt014Module({ document: env.document });
mod.openSheet('sheet-gps-filters');
assert.equal(env.popup.style.display, 'none', 'popup должен быть скрыт');
assert.ok(!env.toggle.classList.contains('active'),
'кнопка #terrain-toggle должна потерять класс active');
});
// ─── REQ-F-04 / AC-06: повторное открытие стабильно ─────────────────────
test('REQ-F-04: повторный openSheet() — sheet остаётся open, без артефактов', () => {
const env = makeEnv({
popupVisible: false,
sheets: [{ id: 'sheet-gps-filters', open: false }],
});
const mod = loadEt014Module({ document: env.document });
mod.openSheet('sheet-gps-filters');
const sheet = env.sheetEls.find((s) => s.id === 'sheet-gps-filters');
assert.ok(sheet.classList.contains('open'), 'sheet должен иметь класс open');
assert.ok(env.backdrop.classList.contains('visible'),
'backdrop должен быть видим');
// Повторный вызов — sheet остаётся открытым, никаких регрессий.
mod.openSheet('sheet-gps-filters');
assert.ok(sheet.classList.contains('open'), 'sheet всё ещё open');
assert.ok(env.backdrop.classList.contains('visible'),
'backdrop всё ещё visible');
});
// ─── REQ-F-06: другие sheets — popup-helper тоже срабатывает (но no-op) ─
test('REQ-F-06: openSheet() для других sheets тоже зовёт closeTerrainPopup', () => {
// Popup закрыт изначально — closeTerrainPopup должна быть no-op.
const env = makeEnv({
popupVisible: false,
sheets: [
{ id: 'sheet-route', open: false },
{ id: 'sheet-recon', open: false },
],
});
const mod = loadEt014Module({ document: env.document });
mod.openSheet('sheet-route');
const sheet = env.sheetEls.find((s) => s.id === 'sheet-route');
assert.ok(sheet.classList.contains('open'));
assert.equal(env.popup.style.display, 'none', 'popup остаётся скрытым');
assert.ok(!env.toggle.classList.contains('active'),
'active не появляется (popup и не был открыт)');
});
// ─── closeTerrainPopup — no-op если popup уже скрыт ─────────────────────
test('closeTerrainPopup: no-op если popup уже скрыт', () => {
const env = makeEnv({ popupVisible: false });
const mod = loadEt014Module({ document: env.document });
mod.closeTerrainPopup();
assert.equal(env.popup.style.display, 'none');
// removeEventListener не должен вызываться (нечего отписывать).
assert.equal(env.docCalls.removeEventListener.length, 0,
'removeEventListener не должен вызываться при закрытом popup');
});
// ─── closeTerrainPopup: отписывает closeTerrainOnOutside ────────────────
test('closeTerrainPopup: при открытом popup отписывает click-listener', () => {
const env = makeEnv({ popupVisible: true, toggleActive: true });
const dummyHandler = function closeTerrainOnOutside() {};
const mod = loadEt014Module({
document: env.document,
closeTerrainOnOutside: dummyHandler,
});
mod.closeTerrainPopup();
assert.equal(env.popup.style.display, 'none');
assert.ok(!env.toggle.classList.contains('active'));
assert.equal(env.docCalls.removeEventListener.length, 1,
'removeEventListener должен быть вызван 1 раз');
assert.equal(env.docCalls.removeEventListener[0][0], 'click');
assert.equal(env.docCalls.removeEventListener[0][1], dummyHandler);
});
// ─── closeTerrainPopup: безопасен при отсутствии #terrain-popup ─────────
test('closeTerrainPopup: безопасен если #terrain-popup отсутствует', () => {
const env = makeEnv({ popupVisible: false });
// Перекроем getElementById чтобы вернуть null для terrain-popup.
const origGet = env.document.getElementById.bind(env.document);
env.document.getElementById = (id) => (id === 'terrain-popup' ? null : origGet(id));
const mod = loadEt014Module({ document: env.document });
assert.doesNotThrow(() => mod.closeTerrainPopup());
});
// ─── openSheet: ранний выход если sheet не найден (без побочных эффектов) ─
test('openSheet: ранний выход если sheet не найден (popup не трогается)', () => {
const env = makeEnv({ popupVisible: true, toggleActive: true });
const mod = loadEt014Module({ document: env.document });
mod.openSheet('does-not-exist');
// popup остаётся открытым: helper вызывается ПОСЛЕ null-check на sheet.
assert.equal(env.popup.style.display, 'block',
'popup должен остаться открытым, если sheet не найден');
assert.ok(env.toggle.classList.contains('active'));
});
// ─── REQ-F-01: закрытие конкурирующих sheets продолжает работать ────────
test('openSheet: закрывает другие открытые sheets (через closeSheet)', () => {
const env = makeEnv({
sheets: [
{ id: 'sheet-route', open: true },
{ id: 'sheet-gps-filters', open: false },
],
});
const closeSheetCalls = [];
const mod = loadEt014Module({
document: env.document,
closeSheet: (id) => closeSheetCalls.push(id),
});
mod.openSheet('sheet-gps-filters');
assert.deepEqual(closeSheetCalls, ['sheet-route'],
'closeSheet должен быть вызван для sheet-route');
const target = env.sheetEls.find((s) => s.id === 'sheet-gps-filters');
assert.ok(target.classList.contains('open'));
});