Backend:
- Миграция gps_tracks_001_init.sql: таблицы tracks + pipeline_runs
- Пакет src/api/gps_tracks/: models, db (WAL+upsert с dedup), dedup
(bbox+length+date bucket-hash), mvt (LRU-кэш 1024 тайла), endpoint
(GET /api/gps-tracks, GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt,
GET /api/gps-tracks/health, POST /api/gps-tracks/cache/clear), config
- Парсеры: osm (split_bbox, haversine, defusedxml XXE-защита),
enduro_russia + ttrails — заглушки (ADR-010/011 proposed, блокированы)
- Licensing guard: pipeline проверяет status ADR-файла до запуска источника
- scripts/gps_collect.py: CLI с --region/--source/--dry-run/--gc
Frontend:
- src/web/gps_tracks.js: двухрежимный слой (MVT z≤11, GeoJSON z≥12),
debounced fetch + AbortController, фильтры активности/источника,
цветовая палитра by-source/by-activity, halo на спутнике, popup трека,
restorePublicTracksState(), localStorage persistence
- index.html: чекбокс «Публичные треки» в terrain-popup, #sheet-gps-filters
- app.css: .terrain-link-btn, .gps-filter-grid, .track-popup
- app.js: вызов restorePublicTracksState() в rebuildMapOverlays(),
applyGpsHaloVisibility() в applyBaseLayer()
Конфиги:
- config/gps_sources.yaml: osm (enabled), enduro_russia/ttrails (disabled)
- config/gps_regions.yaml: ЦФО+Чувашия (enabled), Кавказ (disabled)
Docker:
- gps-collector service с profiles: [batch]
Тесты: 48 новых тестов (unit + integration), 125/125 pass
Refs: ET-008
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
217 lines
8.2 KiB
Python
217 lines
8.2 KiB
Python
"""Unit тесты для дедупликации GPS-треков (ET-008).
|
||
|
||
U-10: два трека с одинаковым bbox+length+date → один ключ
|
||
U-11: разные даты → разные ключи
|
||
U-12: bbox-округление до 0.01°
|
||
U-13: merge sources при upsert
|
||
U-14: merge external_urls
|
||
"""
|
||
import json
|
||
import pytest
|
||
|
||
from src.api.gps_tracks.dedup import compute_dedup_key
|
||
from src.api.gps_tracks.db import open_db, init_db, upsert_track
|
||
from src.api.gps_tracks.models import TrackInsert
|
||
|
||
|
||
def _make_track(
|
||
external_id="T1",
|
||
source_id="osm",
|
||
length_m=5000.0,
|
||
created_at="2024-05-12T10:00:00Z",
|
||
min_lon=37.61,
|
||
min_lat=55.75,
|
||
max_lon=37.62,
|
||
max_lat=55.76,
|
||
external_url=None,
|
||
name=None,
|
||
source_priority=50,
|
||
) -> TrackInsert:
|
||
"""Хелпер для создания TrackInsert с тестовой WKB геометрией."""
|
||
from shapely.geometry import LineString
|
||
from shapely import wkb
|
||
|
||
coords = [(min_lon, min_lat), (max_lon, max_lat)]
|
||
geom_wkb = wkb.dumps(LineString(coords))
|
||
|
||
return TrackInsert(
|
||
external_id=external_id,
|
||
source_id=source_id,
|
||
external_url=external_url,
|
||
name=name,
|
||
description=None,
|
||
activity_type="other",
|
||
user=None,
|
||
created_at=created_at,
|
||
length_m=length_m,
|
||
points_count=2,
|
||
geom_wkb=geom_wkb,
|
||
min_lon=min_lon,
|
||
min_lat=min_lat,
|
||
max_lon=max_lon,
|
||
max_lat=max_lat,
|
||
tags=[],
|
||
source_priority=source_priority,
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def db(tmp_path):
|
||
"""Создаёт изолированную БД в tmp_path."""
|
||
db_path = str(tmp_path / "test.sqlite")
|
||
conn = open_db(db_path)
|
||
init_db(conn)
|
||
yield conn
|
||
conn.close()
|
||
|
||
|
||
# ─── U-10: одинаковый bbox+length+date → один ключ ───────────────────────────
|
||
|
||
def test_u10_same_key_for_same_track():
|
||
"""U-10: два трека с одинаковым bbox+length+date дают одинаковый ключ."""
|
||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||
|
||
key1 = compute_dedup_key(bounds, meta)
|
||
key2 = compute_dedup_key(bounds, meta)
|
||
|
||
assert key1 == key2
|
||
|
||
|
||
# ─── U-11: разные даты → разные ключи ────────────────────────────────────────
|
||
|
||
def test_u11_different_dates_give_different_keys():
|
||
"""U-11: треки с разными датами дают разные ключи."""
|
||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||
|
||
key1 = compute_dedup_key(bounds, {"length_m": 5000.0, "created_at": "2024-05-12"})
|
||
key2 = compute_dedup_key(bounds, {"length_m": 5000.0, "created_at": "2024-05-13"})
|
||
|
||
assert key1 != key2
|
||
|
||
|
||
# ─── U-12: bbox-округление до 0.01° ─────────────────────────────────────────
|
||
|
||
def test_u12_bbox_rounding_to_2_decimals():
|
||
"""U-12: bbox округляется до 0.01°, незначительные отличия игнорируются."""
|
||
# Оба варианта округляются к (37.61, 55.75, 37.62, 55.76)
|
||
# Используем значения в середине диапазона, гарантированно округляемые одинаково
|
||
bounds1 = (37.6111, 55.7512, 37.6192, 55.7563)
|
||
bounds2 = (37.6144, 55.7533, 37.6188, 55.7571)
|
||
|
||
meta = {"length_m": 5000.0, "created_at": "2024-05-12"}
|
||
|
||
key1 = compute_dedup_key(bounds1, meta)
|
||
key2 = compute_dedup_key(bounds2, meta)
|
||
|
||
# Оба bbox округляются к (37.61, 55.75, 37.62, 55.76) — ключи одинаковы
|
||
assert key1 == key2
|
||
|
||
|
||
def test_u12_significantly_different_bbox_gives_different_key():
|
||
"""U-12: существенно разные bbox дают разные ключи."""
|
||
bounds1 = (37.61, 55.75, 37.62, 55.76)
|
||
bounds2 = (38.00, 56.00, 38.10, 56.10)
|
||
|
||
meta = {"length_m": 5000.0, "created_at": "2024-05-12"}
|
||
|
||
key1 = compute_dedup_key(bounds1, meta)
|
||
key2 = compute_dedup_key(bounds2, meta)
|
||
|
||
assert key1 != key2
|
||
|
||
|
||
# ─── U-13: merge sources при upsert ──────────────────────────────────────────
|
||
|
||
def test_u13_merge_sources_on_upsert(db):
|
||
"""U-13: при upsert с тем же dedup_key sources мержатся (union без дублей)."""
|
||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||
dedup_key = compute_dedup_key(bounds, meta)
|
||
|
||
# Первая вставка — от osm
|
||
track1 = _make_track(external_id="T1", source_id="osm", source_priority=50)
|
||
result1 = upsert_track(db, track1, dedup_key, source_priority=50)
|
||
assert result1 == "inserted"
|
||
|
||
# Вторая вставка — от другого источника с тем же dedup_key
|
||
track2 = _make_track(external_id="T2", source_id="enduro_russia", source_priority=10)
|
||
result2 = upsert_track(db, track2, dedup_key, source_priority=10)
|
||
assert result2 == "updated"
|
||
|
||
# Проверяем merged sources
|
||
cur = db.cursor()
|
||
cur.execute("SELECT sources_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||
row = cur.fetchone()
|
||
sources = json.loads(row["sources_json"])
|
||
|
||
assert "osm" in sources
|
||
assert "enduro_russia" in sources
|
||
assert len(sources) == 2 # без дублей
|
||
|
||
|
||
def test_u13_no_duplicate_sources_on_repeated_upsert(db):
|
||
"""U-13: повторный upsert от того же источника не создаёт дублей в sources."""
|
||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||
dedup_key = compute_dedup_key(bounds, meta)
|
||
|
||
track = _make_track(external_id="T1", source_id="osm")
|
||
upsert_track(db, track, dedup_key, source_priority=50)
|
||
upsert_track(db, track, dedup_key, source_priority=50)
|
||
upsert_track(db, track, dedup_key, source_priority=50)
|
||
|
||
cur = db.cursor()
|
||
cur.execute("SELECT sources_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||
row = cur.fetchone()
|
||
sources = json.loads(row["sources_json"])
|
||
|
||
assert sources.count("osm") == 1
|
||
|
||
|
||
# ─── U-14: merge external_urls ───────────────────────────────────────────────
|
||
|
||
def test_u14_merge_external_urls_on_upsert(db):
|
||
"""U-14: external_urls мержатся без дублей при upsert."""
|
||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||
dedup_key = compute_dedup_key(bounds, meta)
|
||
|
||
url1 = "https://www.openstreetmap.org/user/alice/traces/12345"
|
||
url2 = "https://enduro-russia.ru/track/99"
|
||
|
||
track1 = _make_track(external_id="T1", source_id="osm", external_url=url1)
|
||
upsert_track(db, track1, dedup_key, source_priority=50)
|
||
|
||
track2 = _make_track(external_id="T2", source_id="enduro_russia", external_url=url2)
|
||
upsert_track(db, track2, dedup_key, source_priority=10)
|
||
|
||
cur = db.cursor()
|
||
cur.execute("SELECT external_urls_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||
row = cur.fetchone()
|
||
urls = json.loads(row["external_urls_json"])
|
||
|
||
assert url1 in urls
|
||
assert url2 in urls
|
||
assert len(urls) == 2
|
||
|
||
|
||
def test_u14_no_duplicate_urls_on_repeated_upsert(db):
|
||
"""U-14: повторный upsert с тем же URL не дублирует его."""
|
||
bounds = (37.61, 55.75, 37.62, 55.76)
|
||
meta = {"length_m": 5000.0, "created_at": "2024-05-12T10:00:00Z"}
|
||
dedup_key = compute_dedup_key(bounds, meta)
|
||
|
||
url = "https://www.openstreetmap.org/user/alice/traces/12345"
|
||
|
||
track = _make_track(external_id="T1", source_id="osm", external_url=url)
|
||
upsert_track(db, track, dedup_key, source_priority=50)
|
||
upsert_track(db, track, dedup_key, source_priority=50)
|
||
|
||
cur = db.cursor()
|
||
cur.execute("SELECT external_urls_json FROM tracks WHERE dedup_key = ?", (dedup_key,))
|
||
row = cur.fetchone()
|
||
urls = json.loads(row["external_urls_json"])
|
||
|
||
assert urls.count(url) == 1
|