diff --git a/backend/services/cctv_pipeline.py b/backend/services/cctv_pipeline.py index a67e6b2..54e98e7 100644 --- a/backend/services/cctv_pipeline.py +++ b/backend/services/cctv_pipeline.py @@ -299,6 +299,9 @@ class BaseCCTVIngestor(ABC): pass def ingest(self): + # Ingestors may run independently (including from worker threads), so + # make sure a fresh checkout has both the directory and schema ready. + init_db() conn = sqlite3.connect(str(DB_PATH)) try: cameras = self.fetch_data() diff --git a/backend/services/layer_enable_refresh.py b/backend/services/layer_enable_refresh.py index 1fff1de..336be52 100644 --- a/backend/services/layer_enable_refresh.py +++ b/backend/services/layer_enable_refresh.py @@ -51,8 +51,15 @@ def _instant_fetch(key: str) -> None: def _slow_fetch(key: str) -> None: if key == "cctv": + from services.cctv_pipeline import get_camera_count, run_all_ingestors from services.fetchers.infrastructure import fetch_cctv + # A fresh checkout can have an empty SQLite catalog even though the + # public camera feeds are available. Seed it the first time CCTV is + # enabled; otherwise the layer stays ON with zero map records until a + # later scheduler cycle happens to run. + if get_camera_count() == 0: + run_all_ingestors() fetch_cctv() logger.info("CCTV loaded (layer enabled)") return diff --git a/backend/tests/test_layer_enable_refresh.py b/backend/tests/test_layer_enable_refresh.py index a2518e6..061093d 100644 --- a/backend/tests/test_layer_enable_refresh.py +++ b/backend/tests/test_layer_enable_refresh.py @@ -56,3 +56,35 @@ def test_refresh_cctv_runs_on_slow_executor(): assert slow_exec.submit.call_args[0][1] == ("cctv",) active_layers["cctv"] = before.get("cctv", False) + + +def test_cctv_enable_seeds_empty_catalog_before_loading(): + """An empty CCTV DB must trigger the public ingestors on first enable.""" + from services.layer_enable_refresh import _slow_fetch + + with ( + patch("services.cctv_pipeline.get_camera_count", return_value=0) as count, + patch("services.cctv_pipeline.run_all_ingestors") as seed, + patch("services.fetchers.infrastructure.fetch_cctv") as fetch_cctv, + ): + _slow_fetch("cctv") + + count.assert_called_once() + seed.assert_called_once() + fetch_cctv.assert_called_once() + + +def test_cctv_enable_reuses_nonempty_catalog(): + """A populated CCTV DB should stay fast and avoid re-seeding on enable.""" + from services.layer_enable_refresh import _slow_fetch + + with ( + patch("services.cctv_pipeline.get_camera_count", return_value=12) as count, + patch("services.cctv_pipeline.run_all_ingestors") as seed, + patch("services.fetchers.infrastructure.fetch_cctv") as fetch_cctv, + ): + _slow_fetch("cctv") + + count.assert_called_once() + seed.assert_not_called() + fetch_cctv.assert_called_once() diff --git a/frontend/src/__tests__/map/maplibreBehavior.test.ts b/frontend/src/__tests__/map/maplibreBehavior.test.ts index d0c9d31..3a8d617 100644 --- a/frontend/src/__tests__/map/maplibreBehavior.test.ts +++ b/frontend/src/__tests__/map/maplibreBehavior.test.ts @@ -88,6 +88,19 @@ describe('MaplibreViewer behavior — CCTV proxy wiring', () => { }); }); +describe('MaplibreViewer behavior — Telegram visibility fallback', () => { + const viewer = fs.readFileSync(path.join(COMP_DIR, 'MaplibreViewer.tsx'), 'utf-8'); + + it('keeps a visible MapLibre fallback while HTML pins are temporarily hidden', () => { + const telegramSection = viewer.slice( + viewer.indexOf('', viewer.indexOf(''.length, + ); + expect(telegramSection).toContain('minzoom={2}'); + expect(telegramSection).toContain("'circle-opacity': 0.65"); + }); +}); + // ─── Popup subscription isolation ───────────────────────────────────────── describe('MaplibreViewer behavior — popup components have no keyed subscriptions', () => { diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index 118ebd4..fd9c4f9 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -3856,7 +3856,7 @@ const MaplibreViewer = ({ ', ['get', 'post_count'], 1], 26, 22], ], 'circle-color': '#ef4444', - 'circle-stroke-width': 0, + 'circle-stroke-width': 1, 'circle-stroke-color': '#fca5a5', - 'circle-opacity': 0, + // Keep a visible MapLibre fallback while the HTML pins are + // temporarily suppressed during map interaction. + 'circle-opacity': 0.65, }} />