All checks were successful
При открытии любого .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>
260 lines
10 KiB
JavaScript
260 lines
10 KiB
JavaScript
'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'));
|
||
});
|