'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')); });