Files
enduro-trails/docs/work-items/ET-008/12-review.md
claude-bot 37190049db
Some checks failed
CI / lint (push) Failing after 3s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped
review(ET-008): findings - 1xP0, 4xP1, 3xP2, 4xP3
2026-06-01 12:36:30 +00:00

543 lines
26 KiB
Markdown
Raw Permalink 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.
---
type: code-review
work_item_id: ET-008
title: "Review: GPS-треки с публичных платформ на карте"
version: 2
status: REQUEST_CHANGES
created_at: 2026-06-01
updated_at: 2026-06-01
authors:
- "agent:reviewer"
reviewed_branch: feature/ET-008-gps
base_branch: main
reviewed_commits:
- 0060003 "feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ"
- 3734b98 "feat(ET-008): GPS tracks pipeline, API, frontend layer"
verdict: REQUEST_CHANGES
findings_summary:
P0: 1
P1: 4
P2: 3
P3: 4
changelog:
- "v1 (2026-06-01): REQUEST_CHANGES — на ветке отсутствовал код."
- "v2 (2026-06-01): код появился (~7700 LOC). Проведено code-review против ТЗ v2, AC v2 и ADR-005..011."
---
# Code Review — ET-008 (v2)
## Verdict: **REQUEST_CHANGES**
На ветке появилась реализация (commits `0060003`, `3734b98`): backend
пакет `src/api/gps_tracks/`, pipeline `scripts/gps_collect.py`, фронт
`src/web/gps_tracks.js`, миграция, YAML-конфиги, docker-compose сервис
`gps-collector`, тесты, fixtures. Архитектурно сборка следует ADR-005…008
и REQ-F-01…F-03. Однако обнаружено **одно блокирующее (P0) расхождение**
с ТЗ, ломающее основной сценарий просмотра на детальном zoom, и
несколько P1-несоответствий контракту API.
## Что проверено
1.`docs/work-items/ET-008/02-trz.md` v2 (draft) — REQ-F-01…F-20, REQ-NF-01…NF-07.
2.`docs/work-items/ET-008/03-acceptance-criteria.md` v2 (draft) — AC-01…AC-17.
3.`docs/work-items/ET-008/06-adr/` — ADR-005…011 (5 accepted, 2 proposed/blocking).
4.`CLAUDE.md` — конвенции, фазы, правила для агентов.
5. ✅ Git diff `main...feature/ET-008-gps` — 53 файла, +7705/-1218.
6. ✅ Прочитан исходник backend (config.py, db.py, dedup.py, endpoint.py,
mvt.py, models.py, sources/{base,osm,enduro_russia,ttrails}.py).
7. ✅ Прочитан исходник frontend (gps_tracks.js целиком, изменения в
app.js, app.css, index.html).
8. ✅ Прочитана миграция, scripts/gps_collect.py, оба YAML, requirements.txt,
docker-compose.yml.
9. ✅ Прочитаны 4 тест-файла (dedup, endpoint, mvt, sources/osm) и fixtures.
## Findings
### F-01 [P0]: GeoJSON-слой полностью скрывается из-за рассогласования имён properties
**Severity:** P0 (blocker — нарушено REQ-F-13/F-14, AC-04, AC-08; основной
визуальный сценарий слоя на z ≥ 12 не работает).
**Где:**
- `src/api/gps_tracks/endpoint.py`, `_row_to_geojson_feature()` (стр. 5184)
- `src/web/gps_tracks.js`, `applyGpsFilter()` (стр. 246267) и
`_buildColorExpression()` (стр. 7188)
**Что обнаружено:**
GeoJSON endpoint отдаёт в `properties` поля `activity_type` и `sources`
(массив):
```python
"properties": {
...
"activity_type": row["activity_type"],
...
"sources": sources, # list
"external_urls": ext_urls,
...
}
```
MVT-эндпойнт (правильно по ТЗ §4.3) отдаёт `activity` (скаляр) и `source`
(скаляр первой sources):
```python
props = {
...
"activity": row["activity_type"] or "other",
"source": first_source,
"sources": sources_str, # comma-string
...
}
```
Клиентский фильтр в `applyGpsFilter()` использует **только** имена из
MVT-схемы:
```js
const filter = ['all',
['in', ['get', 'activity'], ['literal', activities]],
['in', ['get', 'source'], ['literal', sources]]
];
map.setFilter(window.gpsTracksLayer.layerGeoId, filter);
```
Для feature из GeoJSON-source-а `get('activity')` и `get('source')`
возвращают `null``['in', null, ['literal', […]]]` = `false` → **все
features фильтруются из показа**. То же касается `line-color`:
`_buildColorExpression('source')` matches по `['get', 'source']`
GeoJSON-features попадают в fallback `'#808080'`.
**Воспроизведение:**
1. Включить чекбокс «Публичные треки».
2. Увеличить zoom до 12+. (`_syncGpsLayersVisibility` делает
`gps-tracks-layer-geo` видимым и скрывает MVT-слой.)
3. На карте — ни одного публичного трека, хотя `/api/gps-tracks?bbox=…`
отдаёт >0 features (`returned > 0`, `total_in_bbox > 0`). Toast
«Показаны N треков из M…» возможен, но карта пустая.
**Ссылка на правило:** Reviewer severity «не реализовано требование ТЗ» → P0.
Затронуты:
- REQ-F-14 (фильтры мгновенно действуют через setFilter).
- REQ-F-17 (стили `gps-tracks-layer` с paint `match`).
- AC-04 Scenario «Поля feature.properties» — `feature.properties` обязан
содержать `length_km`, см. также F-02 ниже.
- AC-08 «Фильтрация по активности»: «на карте отображаются только enduro
и moto треки» — невозможно, т.к. на z ≥ 12 ВСЕ треки отфильтрованы.
**Что починить:**
Унифицировать contract. Один из двух вариантов:
- (рекомендуется) В `_row_to_geojson_feature` добавить дублирующие
поля `activity` (= `activity_type`) и `source` (= `sources[0]`) —
не ломая существующих потребителей. Параллельно проверить попап:
`_renderTrackPopupHtml` уже читает `props.activity_type || props.activity`
— менять не нужно.
- Либо переписать `applyGpsFilter`/`_buildColorExpression` так, чтобы они
ветвились по `['has', 'activity']` vs `['get', 'activity_type']` + для
source использовать `['in', 'osm', ['get', 'sources']]` через
index-of / contains (MapLibre expressions поддерживают `in` с массивом
правой части).
Тест, который должен поймать это (отсутствует):
`tests/web/gps_tracks.test.js` — feature `{activity_type:'enduro'}` после
`applyGpsFilter` остаётся видимой. Добавить в test-plan.
---
### F-02 [P1]: GeoJSON `feature.properties` отдаёт `length_m`, ТЗ требует `length_km`
**Severity:** P1 (must-fix — нарушение контракта API, описанного в
REQ-F-10 и AC-04; UI-попап показывает «—»).
**Где:** `src/api/gps_tracks/endpoint.py`, `_row_to_geojson_feature()`
(стр. 5184).
**ТЗ REQ-F-10** (ст. 280305):
```json
"properties": {
"name": "...",
"activity_type": "...",
"user": "...",
"created_at": "...",
"length_km": 47.3,
"sources": [...],
"external_urls": [...]
}
```
**AC-04 Scenario «Поля feature.properties»:**
> Then каждая feature содержит: name, activity_type, user, created_at,
> length_km, sources (array), external_urls (array)
**Имеется в коде:**
```python
"length_m": row["length_m"],
# и нет ни одного "length_km"
```
**Последствие:** В `_renderTrackPopupHtml` (`gps_tracks.js` стр. 325):
```js
const lengthKm = typeof props.length_km === 'number' ? props.length_km.toFixed(1) : '—';
```
для GeoJSON-feature (z ≥ 12) `props.length_km` всегда `undefined` → в
попапе постоянно «📏 — км». MVT-features (z 8…11) показывают правильно,
т.к. MVT-builder уже считает `length_km` (`mvt.py` стр. 148).
**Что починить:** добавить в properties `length_km` (как минимум).
Поле `length_m` оставить, если используется. Аналогично уточнить
`points_count` и `created_at` для popup (см. также F-04).
---
### F-03 [P1]: REQ-F-04 не реализован полностью — все OSM-треки сохраняются как `activity_type='other'`
**Severity:** P1 (must-fix — функциональный пробел; пользователь не
сможет осмысленно использовать фильтр по активности, который явно
закреплён в AC-08).
**Где:** `src/api/gps_tracks/sources/osm.py`, `_parse_gpx_trackpoints()`
(стр. 154284).
**ТЗ REQ-F-04** (ст. 119144):
> Для треков с gpx_id — дополнительный запрос
> `GET /api/0.6/gpx/<id>` для метаданных (name, description, tags, user,
> timestamp). Этот запрос делаем отложенно в batch: накопить 100 id →
> запросить. Тип активности — из tags GPX-трека (`Tag: enduro/moto/mtb/hike/...`).
**Реализовано:**
- batch-запрос метаданных НЕ сделан;
- `name`, `description`, `tags`, `user` всегда `None`/`[]`;
- `activity_type` явно зашит как `"other"`:
```python
track = TrackInsert(..., activity_type="other", ...)
```
- константа `OsmParser.MAPPING` (стр. 2539) объявлена, но `map_activity`
не вызывается — мёртвый код.
**Последствие:** в БД ВСЕ треки от OSM (единственного включённого
источника) попадают как `activity_type='other'`. Фильтр по активности
теряет смысл — пользователь видит только «Другое». AC-08 Scenario
«Фильтрация по активности» не может пройти на реальных данных.
**Что починить:** реализовать batch-fetch на `/api/0.6/gpx/<id>` по
накоплению ID, как описано в ТЗ. Использовать `self.map_activity(...)`
для tags. Если решено отложить — оформить ADR/коммент с явной отметкой
«частичная реализация REQ-F-04, follow-up tracked в …» и понизить
ожидания AC-08 в ТЗ (но это работа аналитика, не разработчика).
---
### F-04 [P1]: Health endpoint не соответствует REQ-F-12 / AC-06
**Severity:** P1 (must-fix — нарушение контракта; AC-06 явно перечисляет
обязательные поля, которых нет).
**Где:** `src/api/gps_tracks/endpoint.py`, `gps_health()` (стр. 196232).
**ТЗ REQ-F-12 / AC-06** требуют поля:
| Поле | Тип |
| ------------------ | ------ |
| `db_path` | str |
| `db_size_mb` | float |
| `tracks_total` | int |
| `tracks_by_source` | dict |
| `tracks_by_activity` | dict |
| `last_pipeline_run` | object с полями started/finished/regions/sources_ok/sources_error |
| `tile_cache_size` | int |
**Имеется:**
```python
return {
"status": "ok",
"db_path": db_path,
"total_tracks": total_tracks, # должно быть tracks_total
"by_activity": by_activity, # должно быть tracks_by_activity
"recent_pipeline_runs": recent_runs, # должно быть last_pipeline_run (объект)
}
```
Отсутствуют: `db_size_mb`, `tracks_by_source`, `tile_cache_size`.
Переименованы: `tracks_total → total_tracks`, `tracks_by_activity →
by_activity`, `last_pipeline_run → recent_pipeline_runs` (массив, не
объект).
Также `recent_pipeline_runs` отдаёт «10 последних запусков», а ТЗ
требует ОДИН последний (агрегированно). Это влияет на UI/админский
view.
**Что починить:** привести JSON-схему к контракту. Минимум — добавить
`tracks_by_source` (вычислить по `sources_json` агрегацией в Python или
JSON_EACH в SQL), `db_size_mb` (через `os.path.getsize`), `tile_cache_size`
(через `len(_gps_tile_cache)` из `mvt.py`), `last_pipeline_run` объект
(берём первую строку из `pipeline_runs ORDER BY started_at DESC`,
агрегируем `sources_ok`/`sources_error` по последнему region).
Tests `test_i40_health_endpoint` сейчас закреплены на текущей неправильной
схеме — их тоже придётся обновить.
---
### F-05 [P1]: Z-order ET-006 не зафиксирован — `gps-tracks-layer` может оказаться выше `gpx-layer-*`
**Severity:** P1 (must-fix — нарушение REQ-F-17 paint requirements §7.1
и AC-10 Scenario «личный трек визуально выше публичных»).
**Где:** `src/web/gps_tracks.js`, `_findGpsInsertPosition()` (стр. 191196).
**ТЗ §7.1:**
> На карте оба видны параллельно; z-order:
> `gps-tracks-layer` < `gpx-layer-*` (личные треки выше).
**AC-10 Scenario «Совместимость с ET-006»:**
> Then оба видны параллельно
> And личный трек визуально выше публичных
**Имеется:**
```js
function _findGpsInsertPosition(map) {
const style = map.getStyle && map.getStyle();
if (!style || !style.layers) return undefined;
const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
return routeLayer ? routeLayer.id : undefined;
}
```
Функция ищет только `route-*`. Если `gpx-layer-*` уже добавлен в стиль
(ET-006), но route-line ещё нет, gps-tracks-layer добавится **в конец**
(`before = undefined` → addLayer без beforeId → поверх всего стиля),
**в том числе поверх gpx-layer**. Это нарушает обязательное правило
из §7.1 / AC-10.
**Что починить:**
```js
function _findGpsInsertPosition(map) {
const style = map.getStyle && map.getStyle();
if (!style || !style.layers) return undefined;
// Приоритет beforeId: gpx-layer-* (ET-006), затем route-* (если нет gpx).
const gpxLayer = style.layers.find(l => l.id.startsWith('gpx-layer'));
if (gpxLayer) return gpxLayer.id;
const routeLayer = style.layers.find(l => l.id === 'route-line' || l.id.startsWith('route-'));
return routeLayer ? routeLayer.id : undefined;
}
```
Соответствующий unit-тест добавить в `tests/web/gps_tracks.test.js`
(в test-plan он есть как WEB-INTEG).
---
### F-06 [P2]: Валидация bbox по площади отсутствует (REQ-NF-01)
**Severity:** P2 (should-fix).
**Где:** `src/api/gps_tracks/endpoint.py`, `_parse_bbox()` (стр. 1748).
**ТЗ REQ-NF-01:**
> Bbox-параметр валидируется (диапазон координат, площадь).
**Имеется:** валидация диапазона `[-180,180]/[-90,90]`, проверка
`west<east`/`south<north`. Площадь не проверяется.
**Что починить:** добавить max площадь bbox (например, 10° × 10° = 100°²)
для предотвращения «штормового» сканирования БД.
---
### F-07 [P2]: Дефолт `gps-tracks-sources` в localStorage включает отключённые источники
**Severity:** P2.
**Где:** `src/web/gps_tracks.js`, `window.gpsTracksLayer.filters.sources`
(стр. 55).
```js
sources: ['osm', 'enduro_russia', 'ttrails'],
```
При том, что `config/gps_sources.yaml` явно держит `enduro_russia` и
`ttrails` в `enabled: false` (соответствует ADR-010/011 status=proposed).
ТЗ REQ-F-15 говорит дефолт = «все enabled». Не блокирует, но создаёт
галки в UI для источников, по которым в БД ничего нет.
**Что починить:** инициализировать sources списком из
`/api/gps-tracks/health.tracks_by_source.keys()` при отсутствии
localStorage-ключа. Это естественно подтянется после F-04.
---
### F-08 [P2]: «LRU-кэш» в `mvt.py` — на самом деле FIFO
**Severity:** P2.
**Где:** `src/api/gps_tracks/mvt.py`, `_gps_tile_cache` (стр. 1224).
```python
def set_gps_cached_tile(z, x, y, data):
if len(_gps_tile_cache) >= _GPS_TILE_CACHE_MAX:
_gps_tile_cache.pop(next(iter(_gps_tile_cache))) # FIFO, не LRU
_gps_tile_cache[(z, x, y)] = data
```
ТЗ REQ-NF-04: «LRU-кэш в памяти процесса FastAPI: 1024 записи». Текущая
реализация — FIFO. Для тайлов это особо ухудшает: часто запрашиваемый
тайл, попавший в кэш первым, будет вытеснен раньше, чем редкий тайл,
попавший позже.
**Что починить:** `from functools import lru_cache` нельзя из-за
mutable invalidation; использовать `collections.OrderedDict` с
`move_to_end` при чтении, либо `cachetools.LRUCache`. Совместимая идея —
сделать `get_gps_cached_tile`:
```python
def get_gps_cached_tile(z, x, y):
key = (z, x, y)
if key in _gps_tile_cache:
_gps_tile_cache.move_to_end(key) # OrderedDict
return _gps_tile_cache[key]
return None
```
---
### F-09 [P3]: Мёртвое поле конфигурации `save_user_field`
**Severity:** P3 (nice-to-have).
**Где:** `config/gps_sources.yaml` — поле задано (`save_user_field:
true|false`); ни один модуль его не читает. AC-16 Scenario «Pipeline не
сохраняет запрещённые поля» рассчитывает, что это поле уважается.
**Что починить:** в `OsmParser`/upsert обрабатывать `save_user_field=false`
→ `user=None`. Сейчас OSM всё равно ставит `user=None` (см. F-03), но
поле должно работать как контракт для будущих источников.
---
### F-10 [P3]: Лишний импорт `pytest_asyncio` в `tests/api/test_gps_tracks_endpoint.py`
**Severity:** P3.
`import pytest_asyncio` есть, но `@pytest_asyncio.fixture` нигде не
используется (только `@pytest.mark.asyncio`). Не блокирует, но в чистом
коде убирается.
---
### F-11 [P3]: `MockRow(dict)` в `tests/api/test_gps_tracks_mvt.py`
**Severity:** P3.
Тесты используют `class MockRow(dict)` как замену `sqlite3.Row`. Работает
для текущего кода, но `sqlite3.Row` не поддерживает `__contains__` так
же, как dict. Если в `mvt.py` появится `if "x" in row:`, тесты
разойдутся с продом. Безопаснее использовать `sqlite3.Row` напрямую через
`open_db + INSERT + fetchone()`.
---
### F-12 [P3]: Лишняя проверка `"source_priority" in existing.keys()` в `db.py`
**Severity:** P3.
`src/api/gps_tracks/db.py` стр. 116:
```python
existing_priority = existing["source_priority"] if "source_priority" in existing.keys() else 999
```
Колонка `source_priority` объявлена в миграции (`migrations/gps_tracks_001_init.sql`
ст. 22) с `NOT NULL DEFAULT 999`. Проверка избыточна (read-protection
осталась от какой-то ранней итерации). Лучше убрать — иначе создаётся
впечатление, что колонка опциональна.
---
## Соответствие ADR
| ADR | Status | Соблюдение в коде | Замечания |
|-----|--------|-------------------|-----------|
| ADR-005 storage-schema | accepted | ✅ соблюдено | таблица `tracks` (+`source_priority`), индексы и `pipeline_runs` совпадают |
| ADR-006 dedup-algorithm | accepted | ✅ соблюдено | `compute_dedup_key` 1-в-1; покрыт unit-тестами U-10…U-14 |
| ADR-007 pipeline-architecture | accepted | ✅ соблюдено | сервис `gps-collector` с `profiles:["batch"]`, license-guard в `_check_license_adr` |
| ADR-008 tile-vs-geojson | accepted | ⚠️ частично | переключение по zoom есть; но contract фич GeoJSON vs MVT расходится (см. F-01) |
| ADR-009 osm-licensing | accepted | ✅ соблюдено | attribution в source, рабочий парсер |
| ADR-010 enduro-russia (proposed) | proposed | ✅ соблюдено | parser — заглушка, `enabled: false`, license-guard сработает |
| ADR-011 ttrails (proposed) | proposed | ✅ соблюдено | то же |
License-guard в `scripts/gps_collect.py` `_check_license_adr()` корректно
читает YAML front-matter ADR. Покрытия unit-тестом нет — рекомендую
добавить (`test_pipeline_skips_unaccepted_source`).
## Тесты — оценка
| Тест-файл | Покрытие test-plan | Качество |
|-----------|-------------------|----------|
| `test_gps_tracks_dedup.py` | U-10…U-14 | ✅ хорошо |
| `test_gps_tracks_mvt.py` | U-50…U-52 | ✅ адекватно (см. F-11) |
| `test_gps_tracks_endpoint.py` | I-20…I-23, I-30…I-31, I-40 | ⚠️ AC-06 не покрыт корректно — тест зафиксирован на неверной схеме (F-04) |
| `test_gps_tracks_sources_osm.py` | U-42…U-44 | ✅ defusedxml проверен фикстурой xxe-payload.gpx |
**Не покрыто тестами:**
- `tests/web/gps_tracks.test.js` — заявлен в ТЗ §5 ст. 787 и
`04-test-plan.yaml`, ОТСУТСТВУЕТ. Был бы первой защитой от F-01.
- pipeline (`scripts/gps_collect.py`) — нет ни одного теста на
`_check_license_adr`, `_collect_source_for_region`, dry-run, exit-code.
AC-02 Scenarios «Падение одного источника не валит остальные» и
«Dry-run» по факту не верифицированы.
- bbox area-валидация — отсутствует и в коде (F-06), и в тестах.
## Замечания, не доходящие до P-finding
- `_simplify_coords` в `mvt.py` дублирует `simplify_coords` из `src/api/main.py`.
Не критично, но напрашивается общая утилита.
- `src/api/main.py` ст. 1720: `GPS_TRACKS_DB_PATH` вычисляется **до**
импорта shapely/fastapi, в строгом смысле это «нечистый» импорт-time
side-effect. Не блок.
- В `endpoint.py` `init_db` вызывается на каждый запрос (`_get_conn`).
Это означает, что `executescript` выполняется на каждый запрос. SQL
использует `IF NOT EXISTS`, так что функционально ок, но это лишний
I/O. Рекомендую инициализировать БД один раз при `create_gps_router`.
- `src/api/main.py` ст. 1255: `app.include_router(gps_router)` хорошо
встроено перед `StaticFiles` mount — порядок правильный.
## Воспроизведение P0 для разработчика
```bash
# 1. Запустить с тестовой БД (см. test_i20_geojson_basic):
pytest tests/api/test_gps_tracks_endpoint.py::test_i20_geojson_basic -q
# 2. Вытащить feature.properties — наблюдать "activity_type", "sources" (list),
# отсутствие "length_km".
# 3. Открыть DevTools в браузере на dev-стенде, проверить:
window._map.queryRenderedFeatures({layers:['gps-tracks-layer-geo']})
# → пустой массив на z >= 12, потому что setFilter с ['get','activity'] всё скрыл.
# 4. Временный обход для отладки:
window._map.setFilter('gps-tracks-layer-geo', null)
# → треки появятся, но серые (line-color fallback) — что подтверждает F-01.
```
## Рекомендация для CI
- `pytest tests/api/` сейчас зелёный, потому что:
- `test_i40_health_endpoint` фиксирует текущую неправильную схему
(F-04) → нужно поправить вместе с фиксом;
- frontend-тесты отсутствуют (F-01 не отлавливается).
- `make lint` ожидаемо проходит.
- Перед закрытием задачи CI должен прогонять и frontend-тесты
(`tests/web/gps_tracks.test.js`), которые на данный момент не написаны.
- Не закрывать ET-008 в Plane как Done.
## Итог
**REQUEST_CHANGES.** Минимальный объём правок:
1. F-01 (P0) — унифицировать имена properties между MVT и GeoJSON, +
web-тест на `applyGpsFilter`.
2. F-02 (P1) — добавить `length_km` в GeoJSON.
3. F-03 (P1) — реализовать batch-fetch `/api/0.6/gpx/<id>` и mapping
`activity_type` (или явная декларация частичной реализации REQ-F-04).
4. F-04 (P1) — выровнять `gps_health()` под REQ-F-12 / AC-06.
5. F-05 (P1) — корректный `beforeId` в `_findGpsInsertPosition`.
После исправлений P0/P1 — повторить ревью; P2/P3 могут пройти отдельным
PR follow-up.