--- type: trz work_item_id: ET-013 title: "ТЗ: Перепады высот на z9-z11 — zoom-aware paint для hillshade и TRI" version: 1 status: draft created_at: 2026-06-04 updated_at: 2026-06-04 authors: - "agent:analyst" related: - "PH-6.terrain" - "ET-007" --- # ТЗ — ET-013: Перепады высот на z9-z11 ## 1. Терминология - **Hillshade** — растровый слой теневого рельефа из `/terrain/hillshade/{z}/{x}/{y}.png`. MapLibre layer id — `terrain-hillshade`, source id — `terrain-hillshade-source`. - **TRI** («Перепады») — растровый слой Terrain Ruggedness Index из `/terrain/tri/{z}/{x}/{y}.png`. Layer id — `terrain-tri`, source id — `terrain-tri-source`. - **Zoom-tier paint** — MapLibre `interpolate`-выражение со stops по `['zoom']`, задаёт значение paint-property как функцию текущего зума. - **Raster paint properties** (MapLibre spec): - `raster-opacity` ∈ [0, 1] — прозрачность слоя. - `raster-contrast` ∈ [-1, 1] — усиление контраста PNG; 0 — без изменений, > 0 — усиление, < 0 — снижение. - `raster-resampling` ∈ `{'linear', 'nearest'}` — алгоритм масштабирования тайла на пиксели экрана. `'nearest'` даёт «пиксельные» резкие границы. - **UI-минзум hillshade** — порог в `updateHillshadeAvailability`, ниже которого чекбокс «Тени рельефа» disabled. Сейчас 10, после ET-013 — 9. ## 2. Архитектурные опоры ET-013 не вводит новых слоёв, источников, endpoint'ов. Используем: - `src/web/app.js`: - константа `TERRAIN_BASE_URL` (~2726) — без изменений. - `onTerrainCheckbox` (~2766) — без изменений сигнатуры; меняются параметры внутри вызовов `applyTerrainLayer`. - `applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom)` (~3316) — расширяется (см. REQ-F-04). - `updateHillshadeAvailability` (~3359) — порог `< 10` → `< 9`. - `restoreTerrainState` (~3379) — без изменений (вызывает onTerrainCheckbox). - `src/web/index.html`: - `#terrain-hillshade-hint` (строка 60) — текст «Зум 10+» → «Зум 9+». - `src/api/main.py:1240` (`terrain_tile`) — **без изменений**. ET-013 = **9 правок: 2 в HTML/text, 7 в одном JS-файле**. ## 3. Требования ### REQ-F-01 — Снизить UI-минзум hillshade до 9 Файл `src/web/app.js`, функция `updateHillshadeAvailability` (строка ~3368): ```js if (zoom < 10) { ``` заменить на ```js if (zoom < 9) { // ET-013: на z9 hillshade уже доступен ``` **Acceptance check.** При `window._map.setZoom(9)` чекбокс `#terrain-hillshade-cb` имеет `disabled === false` и hint `#terrain-hillshade-hint` имеет `display: 'none'`. ### REQ-F-02 — Снизить minzoom source `terrain-hillshade-source` до 9 Файл `src/web/app.js`, функция `onTerrainCheckbox` (строка ~2782). Заменить: ```js applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, 0.40, 10, 15); ``` на: ```js // ET-013: hillshade теперь доступен с z9; opacity и contrast — zoom-aware applyTerrainLayer('terrain-hillshade', TERRAIN_BASE_URL + '/hillshade/{z}/{x}/{y}.png', hillshadeChecked, HILLSHADE_PAINT, // см. REQ-F-04, REQ-F-05 9, 15); ``` **Acceptance check.** В DevTools после включения слоя: ```js window._map.getSource('terrain-hillshade-source').minzoom === 9 ``` ### REQ-F-03 — Снизить minzoom source `terrain-tri-source` остаётся 5 Файл `src/web/app.js`, строка ~2783. Менять только параметр opacity (см. REQ-F-08). minzoom/maxzoom не трогаем: ```js applyTerrainLayer('terrain-tri', TERRAIN_BASE_URL + '/tri/{z}/{x}/{y}.png', triChecked, TRI_PAINT, // см. REQ-F-04, REQ-F-08 5, 15); ``` ### REQ-F-04 — Расширить `applyTerrainLayer` для поддержки paint-объекта Файл `src/web/app.js`, функция `applyTerrainLayer` (строки ~3316-3357). Текущая сигнатура: ```js function applyTerrainLayer(id, tileUrl, enabled, opacity, minzoom, maxzoom) { ... paint: { 'raster-opacity': opacity, 'raster-resampling': 'linear' }, ... } ``` Новая сигнатура (обратно-совместимая): ```js /** * @param {string} id - id слоя. * @param {string} tileUrl - URL-шаблон тайлов. * @param {boolean} enabled - показывать ли слой. * @param {number|object} opacityOrPaint - либо число (старый контракт, * станет 'raster-opacity'), либо объект paint-properties целиком. * Если объект — должен содержать как минимум 'raster-opacity'. * @param {number} minzoom * @param {number} maxzoom */ function applyTerrainLayer(id, tileUrl, enabled, opacityOrPaint, minzoom, maxzoom) { const map = window._map; if (!map) return; const sourceId = id + '-source'; // ET-013: нормализация paint const paint = (typeof opacityOrPaint === 'number') ? { 'raster-opacity': opacityOrPaint, 'raster-resampling': 'linear' } : opacityOrPaint; if (enabled) { if (!map.getSource(sourceId)) { map.addSource(sourceId, { type: 'raster', tiles: [tileUrl], tileSize: 256, scheme: 'tms', minzoom: minzoom, maxzoom: maxzoom }); } if (!map.getLayer(id)) { const firstTrailLayer = map.getStyle().layers.find(l => l.id.startsWith('trails-') || l.id.startsWith('poi-') ); map.addLayer({ id: id, type: 'raster', source: sourceId, paint: paint, minzoom: minzoom, maxzoom: maxzoom }, firstTrailLayer ? firstTrailLayer.id : undefined); } } else { if (map.getLayer(id)) map.removeLayer(id); if (map.getSource(sourceId)) map.removeSource(sourceId); } } ``` **Acceptance check.** Unit-тест (см. REQ-F-13): - `applyTerrainLayer(id, url, true, 0.5, 8, 14)` — старый контракт работает. - `applyTerrainLayer(id, url, true, {'raster-opacity': 0.5, 'raster-contrast': 0.3, 'raster-resampling': 'nearest'}, 8, 14)` — paint применён как есть. ### REQ-F-05 — Hillshade `raster-opacity` zoom-aware Файл `src/web/app.js`, после определения `TERRAIN_BASE_URL` (после строки ~2726) добавить блок констант: ```js // ET-013: zoom-aware paint для слоёв рельефа. // Цель — компенсировать «потерю выразительности» перепадов на z9-z11. // Pre-z9 — hillshade не показывается (UI-минзум). На z9-z11 — максимальный // контраст и opacity, чтобы тени читались как на z8. К z12-z14 — возврат // к исходным значениям (тогда у пользователя есть другие способы // читать рельеф: подложка, грунтовки, POI). const HILLSHADE_PAINT = { 'raster-opacity': [ 'interpolate', ['linear'], ['zoom'], 9, 0.65, 10, 0.60, 11, 0.55, 12, 0.50, 14, 0.40 ], 'raster-contrast': [ 'interpolate', ['linear'], ['zoom'], 9, 0.40, 10, 0.35, 11, 0.30, 12, 0.15, 14, 0.00 ], 'raster-resampling': 'nearest' }; ``` Stops подобраны так: - z9-z11 — пик opacity (0.65→0.55) и contrast (0.40→0.30). Это компенсация: тени темнее и контрастнее. - z12-z14 — плавный возврат к исходному (opacity 0.40, contrast 0): на крупных зумах пользователь уже видит подложку детально и тени должны «уйти на второй план». - `'nearest'` resampling: подчёркивает 30-метровые границы SRTM, перепады выглядят резко. **Acceptance check.** ```js const layer = window._map.getLayer('terrain-hillshade'); const opacity = window._map.getPaintProperty('terrain-hillshade', 'raster-opacity'); Array.isArray(opacity) && opacity[0] === 'interpolate' // true ``` ### REQ-F-06 — Hillshade `raster-contrast` (внутри HILLSHADE_PAINT) См. REQ-F-05. Constants выносятся в HILLSHADE_PAINT, отдельной правки кода не нужно. ### REQ-F-07 — Hillshade `raster-resampling: 'nearest'` См. REQ-F-05. Часть HILLSHADE_PAINT. ### REQ-F-08 — TRI `raster-opacity` zoom-aware В том же блоке (после HILLSHADE_PAINT, до `function toggleTerrainPopup`): ```js const TRI_PAINT = { 'raster-opacity': [ 'interpolate', ['linear'], ['zoom'], 5, 0.55, 7, 0.65, 8, 0.70, // регрессия z8: текущее значение 9, 0.80, 10, 0.85, 11, 0.85, // пик на z9-z11 12, 0.75, 15, 0.70 ], 'raster-resampling': 'nearest' }; ``` Stops: - **z5-z7** — мягко (0.55-0.65), на «обзорных» зумах не глушим карту. - **z8** — 0.70 ровно как сейчас (регрессия). - **z9-z11** — пик 0.80-0.85 (целевое улучшение ET-013). - **z12-z15** — спад до 0.70-0.75. **Acceptance check.** ```js const opacity = window._map.getPaintProperty('terrain-tri', 'raster-opacity'); // На z8 — 0.70 ровно (регрессия). // На z10 — 0.85 ровно (целевое поведение). ``` ### REQ-F-09 — TRI `raster-resampling: 'nearest'` Часть TRI_PAINT, см. REQ-F-08. ### REQ-F-10 — Обновить UI-hint текст Файл `src/web/index.html`, строка ~60: ```html ``` заменить на ```html ``` ### REQ-F-11 — `updateHillshadeAvailability` использует новый порог См. REQ-F-01. Никаких других изменений в этой функции не нужно. ### REQ-F-12 — Сохранить контракт `onTerrainCheckbox` Сигнатура и логика persistence в `localStorage` (`terrain-hillshade`, `terrain-tri`) — без изменений. Кнопка `#terrain-toggle` `.active` переключается так же. ### REQ-F-13 — Unit-тесты paint-выражений Файл `tests/unit/test_terrain_paint.js` (новый; если JS-тесты раньше не было — настроить vitest/jest в `package.json` либо использовать существующий тест-раннер; альтернатива — Python-парсер JSON-выражений). Реализация в одной из двух форм: **Вариант A: JS unit-тест (jest/vitest)** ```js // tests/unit/test_terrain_paint.test.js import { HILLSHADE_PAINT, TRI_PAINT } from '../../src/web/terrain-paint.js'; // Если константы внутри app.js: либо вынести в отдельный модуль, // либо использовать AST-парсер. См. альтернативу B. describe('ET-013 terrain paint', () => { test('HILLSHADE_PAINT: raster-opacity is interpolate by zoom', () => { const op = HILLSHADE_PAINT['raster-opacity']; expect(op[0]).toBe('interpolate'); expect(op[1][0]).toBe('linear'); expect(op[2][0]).toBe('zoom'); // stops: ..., 9, 0.65, 10, 0.60, 11, 0.55, 12, 0.50, 14, 0.40 const stops = op.slice(3); expect(stops).toContain(9); expect(stops[stops.indexOf(9) + 1]).toBeCloseTo(0.65, 2); expect(stops[stops.indexOf(11) + 1]).toBeCloseTo(0.55, 2); expect(stops[stops.indexOf(14) + 1]).toBeCloseTo(0.40, 2); }); test('HILLSHADE_PAINT: raster-contrast peak at z9-z11', () => { const c = HILLSHADE_PAINT['raster-contrast']; expect(c[0]).toBe('interpolate'); const stops = c.slice(3); expect(stops[stops.indexOf(9) + 1]).toBeGreaterThanOrEqual(0.35); expect(stops[stops.indexOf(14) + 1]).toBeLessThanOrEqual(0.05); }); test('HILLSHADE_PAINT: resampling nearest', () => { expect(HILLSHADE_PAINT['raster-resampling']).toBe('nearest'); }); test('TRI_PAINT: z8 unchanged (regression)', () => { const op = TRI_PAINT['raster-opacity']; const stops = op.slice(3); expect(stops[stops.indexOf(8) + 1]).toBeCloseTo(0.70, 2); }); test('TRI_PAINT: peak at z9-z11', () => { const op = TRI_PAINT['raster-opacity']; const stops = op.slice(3); expect(stops[stops.indexOf(10) + 1]).toBeGreaterThanOrEqual(0.80); expect(stops[stops.indexOf(11) + 1]).toBeGreaterThanOrEqual(0.80); }); test('TRI_PAINT: resampling nearest', () => { expect(TRI_PAINT['raster-resampling']).toBe('nearest'); }); }); ``` **Вариант B: Python-парсер (если JS-тестов в проекте нет)** ```python # tests/unit/test_terrain_paint.py import re from pathlib import Path APP_JS = Path(__file__).parents[2] / 'src/web/app.js' def test_hillshade_paint_exists(): txt = APP_JS.read_text(encoding='utf-8') assert 'HILLSHADE_PAINT' in txt assert "'raster-opacity'" in txt assert "'raster-contrast'" in txt assert "'raster-resampling': 'nearest'" in txt def test_hillshade_opacity_stops(): """Сверяем stops по grep — недостаточно строго, но удержит регрессию.""" txt = APP_JS.read_text(encoding='utf-8') # ищем блок HILLSHADE_PAINT и проверяем stop'ы m = re.search(r"HILLSHADE_PAINT\s*=\s*\{(.+?)\};", txt, re.DOTALL) assert m, "HILLSHADE_PAINT not found" block = m.group(1) assert '9, 0.65' in block or '9, 0.65' in block assert '11, 0.55' in block assert '14, 0.40' in block def test_tri_opacity_regression_z8(): txt = APP_JS.read_text(encoding='utf-8') m = re.search(r"TRI_PAINT\s*=\s*\{(.+?)\};", txt, re.DOTALL) assert m block = m.group(1) assert '8, 0.70' in block or '8, 0.70' in block, "z8 opacity должна остаться 0.70" assert '10, 0.85' in block ``` **Решение по умолчанию для ET-013:** Вариант B (Python-парсер), т.к. в проекте JS-тестов не существует, а ставить vitest ради ET-013 — превышение scope. Опционально разработчик может выбрать Вариант A. ### REQ-F-14 — Регрессионные тесты Файл `tests/unit/test_terrain_paint.py` (тот же файл, что и REQ-F-13): - **UT-REG-01.** Проверить, что вызов `applyTerrainLayer` с числовым `opacity` (старый контракт) собирает paint `{raster-opacity: X, raster-resampling: 'linear'}` — на случай, если другой код (POI, halo, scenic) использует ту же функцию. На текущий момент `applyTerrainLayer` вызывается **только** внутри `onTerrainCheckbox` — но контракт должен оставаться обратно-совместимым. Реализация — статический grep по `src/web/`: ```python import re, glob def test_only_two_callers_of_applyterrainLayer(): pattern = re.compile(r'applyTerrainLayer\s*\(') total = 0 for f in glob.glob('src/web/*.js'): total += len(pattern.findall(open(f).read())) assert total >= 2 # минимум 2 вызова в onTerrainCheckbox ``` - **UT-REG-02.** `updateHillshadeAvailability` порог = 9 (grep по строке `zoom < 9`). ### REQ-F-15 — Integration smoke-тест: тайлы z9 доступны Файл `tests/integration/test_terrain_z9_tiles.py` (новый): - **IT-TILE-Z9-01.** При наличии `data/terrain/hillshade/9/` директории — запрос `GET /terrain/hillshade/9/308/158.png` возвращает 200, content-type `image/png`. Если директория не существует — тест **skipped** с пояснением. ```python import os, pytest from fastapi.testclient import TestClient from src.api.main import app TERRAIN_DIR = os.environ.get( 'TERRAIN_DIR', os.path.join(os.path.dirname(__file__), '../../data/terrain') ) client = TestClient(app) @pytest.mark.skipif( not os.path.isdir(os.path.join(TERRAIN_DIR, 'hillshade/9')), reason='hillshade z9 tiles not present in CI (PH-6 data not in repo)' ) def test_hillshade_z9_tile_returns_200(): # Любой существующий тайл из директории z9_dir = os.path.join(TERRAIN_DIR, 'hillshade/9') x = sorted(os.listdir(z9_dir))[0] y_file = sorted(os.listdir(os.path.join(z9_dir, x)))[0] y = y_file.replace('.png', '') r = client.get(f'/terrain/hillshade/9/{x}/{y}.png') assert r.status_code == 200 assert r.headers['content-type'] == 'image/png' def test_hillshade_invalid_zoom_404(): r = client.get('/terrain/hillshade/99/0/0.png') assert r.status_code == 404 ``` ### REQ-F-16 — UI-тесты Playwright См. `04b-ui-test-cases.md`. Ключевые проверки (полный список — там): - TC-UI-01-Z9: hillshade доступен на z9, hint скрыт. - TC-UI-02-Z8-REGR: на z8 TRI визуально как до ET-013. - TC-UI-03-Z9-Q: визуальная читаемость перепадов на z9 ≥ z8 (качественно). - TC-UI-04-Z10-Q: то же для z10. - TC-UI-05-Z11-Q: то же для z11. - TC-UI-06-Z14-Q: на z14 hillshade «нормальный», не перегретый. - TC-UI-07-Z9-MOBILE: мобильный viewport, hillshade видим на z9. - TC-UI-08-Z10-SAT-Q: совместимость со спутниковой подложкой. - TC-UI-09-Z10-DARK-Q: совместимость с тёмной темой. - TC-UI-10-PERSIST: localStorage `terrain-hillshade`/`terrain-tri` переживает перезагрузку, паттерн чекбоксов восстанавливается. ### REQ-F-17 — Persistence без миграции Ключи `localStorage`: - `terrain-hillshade` ('1' | '0') — без изменений. - `terrain-tri` ('1' | '0') — без изменений. После ET-013 пользователи с включённым hillshade при следующей загрузке на z9 увидят слой автоматически (раньше он был disabled). Это не миграция, а ожидаемое улучшение UX. ### REQ-F-18 — Не менять API контракт `GET /terrain/{layer}/{z}/{x}/{y}.png` — без изменений. Никаких новых query, headers, кодов ответа. `Cache-Control: immutable` сохраняется. ### REQ-F-19 — Не менять конфиги и стили - `src/web/style.json`, `src/web/style-dark.json` — без изменений. - `src/web/app.css` — без изменений (стили чекбоксов не меняются). - `config/*.yaml` — без изменений. ### REQ-F-20 — Деплой и валидация После merge в `main` и деплоя: 1. **Pre-merge sanity** (на test-среде до деплоя): ```bash curl -sI https://openclaw.mva154.duckdns.org/enduro/terrain/hillshade/9/308/158.png | head -1 ``` Ожидается `HTTP/1.1 200 OK`. Если 404 — задача останавливается, тайлы z9 нужно догенерировать в рамках PH-6 follow-up. 2. **Smoke в test-среде**: - Открыть карту, центр над Окой/югом Москвы (`[37.6, 54.5]`). - `window._map.setZoom(9)` — кнопка «Тени рельефа» активна. - Включить «Тени рельефа» и «Перепады». - Скриншот → визуальная приёмка по AC-03..AC-05. 3. **Зафиксировать в `14-deploy-log.md`**. ### REQ-F-21 — Документация В `docs/work-items/ET-013/` после Анализа: - `00-business-request.md` (есть) - `01-brd.md` - `02-trz.md` (этот файл) - `03-acceptance-criteria.md` - `04-test-plan.yaml` - `04b-ui-test-cases.md` После реализации: `12-review.md`, `13-test-report.md`, `14-deploy-log.md`. ADR опционально (см. BRD §6). ## 4. Не-функциональные требования ### NFR-01 — Производительность клиента - Добавление двух `interpolate`-выражений в paint не должно заметно увеличивать render time. MapLibre кэширует скомпилированные style-выражения; разница < 1 мс на frame. - `raster-resampling: 'nearest'` дешевле, чем `'linear'` (без bilinear-фильтрации) — на самом деле небольшое ускорение растеризации. ### NFR-02 — Производительность сервера Без изменений: endpoint `terrain_tile` отдаёт PNG из файловой системы с `Cache-Control: immutable`. ### NFR-03 — Сетевой трафик - При снижении UI-минзума hillshade с 10 до 9 пользователь может видеть слой на одной zoom-ступени раньше, что добавляет ~25-35% PNG-тайлов на типичную сессию активного зумирования с включённым hillshade. - Browser-кэш + nginx-кэш (`Cache-Control: max-age=31536000, immutable`) поглощают это после первого визита. - Регрессия `M-10`: рост ≤ 35%. ### NFR-04 — Совместимость - MapLibre 4.7.0 (см. `index.html:10`, `index.html:503`) поддерживает все используемые paint properties и `interpolate`-выражения. - Старые tab'ы (без обновления страницы) продолжают работать с прежним кодом до перезагрузки. ### NFR-05 — Безопасность Никаких изменений в auth / CSP / валидации. ### NFR-06 — Логирование Никаких новых лог-сообщений. `uvicorn.access` для `/terrain/*` работает как раньше. ### NFR-07 — Persistence `localStorage` — без миграции. Существующие ключи интерпретируются как раньше; включённый ранее hillshade автоматически появится на z9 при следующей загрузке. ## 5. План работ (для разработчика) 1. **Pre-implementation check**: проверить наличие тайлов z9-z11 на test-среде (REQ-F-20 §1). Если 404 — стоп, открыть PH-6 follow-up. 2. **Frontend constants**: добавить `HILLSHADE_PAINT` и `TRI_PAINT` (REQ-F-05, F-08) после `TERRAIN_BASE_URL`. 3. **Frontend `applyTerrainLayer`**: расширить сигнатуру (REQ-F-04). 4. **Frontend `onTerrainCheckbox`**: перевести вызовы на константы (REQ-F-02, F-03). 5. **Frontend `updateHillshadeAvailability`**: порог `< 10` → `< 9` (REQ-F-01, F-11). 6. **HTML hint**: «Зум 10+» → «Зум 9+» (REQ-F-10). 7. **Тесты**: `tests/unit/test_terrain_paint.py` (REQ-F-13, F-14). 8. **Integration smoke**: `tests/integration/test_terrain_z9_tiles.py` (REQ-F-15) — с `@pytest.mark.skipif` для CI без данных. 9. **`make lint` / `make test`** — должны пройти. 10. **Code review → merge → deploy в test**. 11. **Ручная валидация** (REQ-F-20 §2). 12. **Playwright UI-тесты** по `04b-ui-test-cases.md`. 13. **Запись в `13-test-report.md` и `14-deploy-log.md`**. ## 6. Открытые вопросы и решения по умолчанию | Вопрос | Решение по умолчанию | |---|---| | Стоит ли понижать UI-минзум hillshade ещё дальше (z8)? | **Нет.** На z8 hillshade-тайлы 256px покрывают ~150 км по широте — крупные тени становятся неразборчивым «шумом». TRI работает лучше. Если будущий BRD захочет — отдельная задача. | | Стоит ли использовать разные paint для тёмной темы (`theme-dark`)? | **Не в MVP.** Если AC-09 (TC-UI-09-Z10-DARK-Q) показывает «слой сливается с подложкой» — добавить ADR-0001 о theme-specific paint в follow-up. | | Стоит ли использовать разные paint для спутниковой подложки? | **Не в MVP.** Hillshade на спутнике пользователю обычно не нужен — он использует подложку как замену рельефу. Если AC-08 (TC-UI-08-Z10-SAT-Q) показывает «глушит подложку» — отдельная итерация. | | Стоит ли добавить `raster-saturation` для TRI? | **Не в MVP.** Сначала смотрим на эффект от `raster-opacity` + `'nearest'`. Если визуально недостаточно ярко — добавить второй раунд калибровки. | | Перегенерировать ли hillshade с z-factor 2.5 для z9-z14? | **Не сейчас.** Отдельная задача в случае, если frontend-калибровка ET-013 не решает проблему (вероятность по моей оценке — низкая). | | Менять ли `raster-resampling` динамически по зуму? | **Нет.** MapLibre не поддерживает `interpolate` для `raster-resampling`. Глобальное `'nearest'` для обоих слоёв — приемлемый компромисс (см. R-5). | | Подключить ли гипсометрию в UI? | **Out of scope.** Hypso тайлы есть, но UI-чекбокса нет. Отдельная задача. | | Делать ли paint-таблицы переменными окружения / config'ом? | **Нет.** Это калибровка, она живёт в коде и меняется коммитом. Конфигурируемость — преждевременная абстракция. | | Стоит ли добавлять `vitest`/`jest` ради JS-unit-тестов? | **Нет в ET-013.** Используем Python-парсер (Вариант B в REQ-F-13). |