503 lines
22 KiB
Python
503 lines
22 KiB
Python
"""
|
|
Map frontend smoke tests — HTML structure, pin markers, popups,
|
|
geofence layer rendering, GPS controls, heatmap toggle.
|
|
|
|
Validates frontend code structure via grep-style analysis of
|
|
the single-page HTML/JS source, plus API endpoint smoke tests
|
|
for the backing map data routes.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# ── path setup ─────────────────────────────────────────────────────────
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
STATIC_DIR = PROJECT_ROOT / "static"
|
|
INDEX_HTML = STATIC_DIR / "index.html"
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
os.environ["CANTEEN_SKIP_AUTH"] = "1"
|
|
|
|
|
|
# ── helpers ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _read_source() -> str:
|
|
"""Read the full frontend source (HTML + inline JS)."""
|
|
return INDEX_HTML.read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def source() -> str:
|
|
"""Module-scoped: read index.html once."""
|
|
return _read_source()
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""FastAPI TestClient with isolated temp DB."""
|
|
import importlib
|
|
|
|
fd, path = tempfile.mkstemp(suffix=".db", prefix="map_smoke_")
|
|
os.close(fd)
|
|
os.environ["CANTEEN_DB_PATH"] = path
|
|
|
|
for mod in list(sys.modules.keys()):
|
|
if mod == "server" or mod.startswith("server."):
|
|
del sys.modules[mod]
|
|
|
|
import server
|
|
importlib.invalidate_caches()
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
with TestClient(server.app) as tc:
|
|
yield tc
|
|
|
|
for suffix in ("", "-shm", "-wal", "-journal"):
|
|
p = Path(path + suffix)
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 1. MAP TAB HTML STRUCTURE
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestMapTabHTML:
|
|
"""Verify the map tab's HTML skeleton exists and has expected elements."""
|
|
|
|
def test_map_container_exists(self, source):
|
|
"""The Leaflet map container #mapContainer must be present."""
|
|
assert 'id="mapContainer"' in source, "Map container div missing"
|
|
|
|
def test_map_tab_panel_exists(self, source):
|
|
"""The map tab panel #tabMap must exist."""
|
|
assert 'id="tabMap"' in source, "Map tab panel missing"
|
|
assert 'class="tab-panel"' in source, "tab-panel class missing"
|
|
|
|
def test_pins_chip_exists(self, source):
|
|
"""The Pins toggle chip must be in the map controls."""
|
|
assert 'id="chipPins"' in source, "Pins chip missing"
|
|
assert "Pins" in source, "Pins label not found"
|
|
|
|
def test_heatmap_chip_exists(self, source):
|
|
"""The Heatmap toggle chip must be in the map controls."""
|
|
assert 'id="chipHeat"' in source, "Heatmap chip missing"
|
|
assert "Heatmap" in source, "Heatmap label not found"
|
|
|
|
def test_geofence_chip_exists(self, source):
|
|
"""The Add Geofence chip must be in the map controls."""
|
|
assert 'id="chipGeo"' in source, "Geofence chip missing"
|
|
assert "Geofence" in source, "Geofence label not found"
|
|
|
|
def test_my_location_chip_exists(self, source):
|
|
"""The My Location (GPS center) chip must be in the map controls."""
|
|
assert "centerOnGPS()" in source, "My Location handler missing"
|
|
assert "My Location" in source, "My Location label not found"
|
|
|
|
def test_map_controls_bar_exists(self, source):
|
|
"""The map controls bar wrapping the chips."""
|
|
assert 'class="map-controls"' in source, "map-controls bar missing"
|
|
|
|
def test_geofence_panel_exists(self, source):
|
|
"""The geofence list panel must exist below the map."""
|
|
assert 'id="geofencePanel"' in source, "Geofence panel missing"
|
|
|
|
def test_geofence_list_container_exists(self, source):
|
|
"""The geofence list container for rendered items."""
|
|
assert 'id="geofenceList"' in source, "Geofence list container missing"
|
|
|
|
def test_geofence_count_label_exists(self, source):
|
|
"""The geofence count label (e.g. '3 zones')."""
|
|
assert 'id="gfCount"' in source, "Geofence count element missing"
|
|
|
|
def test_geofence_color_picker_exists(self, source):
|
|
"""Color picker row for drawn geofences."""
|
|
assert 'id="geofenceColorRow"' in source, "Geofence color row missing"
|
|
assert 'id="geofenceColor"' in source, "Geofence color input missing"
|
|
|
|
def test_save_geofence_button_exists(self, source):
|
|
"""Save Geofence button must call saveDrawnGeofence()."""
|
|
assert "saveDrawnGeofence()" in source, "Save geofence handler missing"
|
|
|
|
def test_cancel_geofence_button_exists(self, source):
|
|
"""Cancel Geofence button must call cancelGeofenceDraw()."""
|
|
assert "cancelGeofenceDraw()" in source, "Cancel geofence handler missing"
|
|
|
|
def test_visit_tracker_exists(self, source):
|
|
"""Auto-visit tracker div for GPS proximity tracking."""
|
|
assert 'id="visitTracker"' in source, "Visit tracker missing"
|
|
|
|
def test_map_leaflet_dependency_loaded(self, source):
|
|
"""Leaflet JS must be loaded via CDN."""
|
|
assert "leaflet.js" in source, "Leaflet JS not loaded"
|
|
assert "leaflet.css" in source, "Leaflet CSS not loaded"
|
|
|
|
def test_leaflet_draw_loaded(self, source):
|
|
"""Leaflet Draw plugin must be loaded for geofence drawing."""
|
|
assert "leaflet-draw" in source or "leaflet.draw" in source, \
|
|
"Leaflet Draw plugin not loaded"
|
|
|
|
def test_leaflet_heat_loaded(self, source):
|
|
"""Leaflet Heat plugin must be loaded for heatmap."""
|
|
assert "leaflet-heat" in source or "leaflet.heat" in source, \
|
|
"Leaflet Heat plugin not loaded"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 2. PIN MARKERS
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestPinMarkers:
|
|
"""Verify pin marker functions and icon construction exist in source."""
|
|
|
|
def test_add_asset_marker_function_exists(self, source):
|
|
"""addAssetMarker() must be defined."""
|
|
assert "function addAssetMarker" in source, \
|
|
"addAssetMarker function missing"
|
|
|
|
def test_clear_asset_markers_function_exists(self, source):
|
|
"""clearAssetMarkers() must be defined."""
|
|
assert "function clearAssetMarkers" in source, \
|
|
"clearAssetMarkers function missing"
|
|
|
|
def test_load_asset_pins_function_exists(self, source):
|
|
"""loadAssetPins() must be defined."""
|
|
assert "function loadAssetPins" in source, \
|
|
"loadAssetPins function missing"
|
|
# Must call the assets API
|
|
assert "api('/api/assets?limit=1000')" in source, \
|
|
"loadAssetPins does not call bulk assets endpoint"
|
|
|
|
def test_toggle_pins_function_exists(self, source):
|
|
"""togglePins() must be defined."""
|
|
assert "function togglePins" in source, \
|
|
"togglePins function missing"
|
|
|
|
def test_marker_uses_divicon(self, source):
|
|
"""Pins use Leaflet DivIcon for colored circle + emoji."""
|
|
assert "L.divIcon" in source, "L.divIcon not used (must use DivIcon for pins)"
|
|
|
|
def test_marker_emoji_per_category(self, source):
|
|
"""Each category maps to an emoji for the pin icon."""
|
|
assert "Furniture" in source, "Furniture category mapping missing"
|
|
assert "Appliances" in source, "Appliances category mapping missing"
|
|
assert "Equipment" in source, "Equipment category mapping missing"
|
|
assert "CAT_MARKER_EMOJI" in source, "Category emoji mapping missing"
|
|
|
|
def test_marker_color_per_category(self, source):
|
|
"""Each category maps to a color for the pin."""
|
|
assert "CAT_COLORS" in source, "Category color mapping missing"
|
|
|
|
def test_asset_marker_added_to_map(self, source):
|
|
"""addAssetMarker calls marker.addTo(map)."""
|
|
assert ".addTo(map)" in source or "addTo(map)" in source, \
|
|
"Marker not added to map"
|
|
|
|
def test_pin_filters_null_coordinates(self, source):
|
|
"""Only assets with lat != null and lng != null get pins."""
|
|
assert "latitude != null" in source or "latitude != None" in source, \
|
|
"Null coordinate filter missing in pin loading"
|
|
assert "longitude != null" in source or "longitude != None" in source, \
|
|
"Null longitude filter missing in pin loading"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 3. POPUP CONTENTS
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestPopupContents:
|
|
"""Verify asset and geofence popup bindings in source."""
|
|
|
|
def test_asset_popup_binds_name(self, source):
|
|
"""Asset popup includes asset name."""
|
|
assert "bindPopup" in source, "bindPopup call missing for asset markers"
|
|
# The popup template should include asset.name
|
|
assert "asset.name" in source, "Asset name not referenced in popup"
|
|
|
|
def test_asset_popup_includes_category(self, source):
|
|
"""Asset popup includes category."""
|
|
assert "asset.category" in source, "Asset category not referenced"
|
|
|
|
def test_asset_popup_includes_status(self, source):
|
|
"""Asset popup includes status with color coding."""
|
|
assert "asset.status" in source, "Asset status not referenced"
|
|
|
|
def test_asset_popup_includes_directions_link(self, source):
|
|
"""Asset popup includes Google Maps directions link."""
|
|
assert "google.com/maps/dir" in source, \
|
|
"Google Maps directions link not found in popup"
|
|
|
|
def test_asset_popup_includes_details_button(self, source):
|
|
"""Asset popup includes a button to view full asset details."""
|
|
assert "viewAsset(" in source, "viewAsset() call not found in popup"
|
|
|
|
def test_geofence_popup_binds_name(self, source):
|
|
"""Geofence popup includes geofence name."""
|
|
assert "gf.name" in source, "Geofence name not referenced in popup"
|
|
|
|
def test_geofence_popup_has_edit_button(self, source):
|
|
"""Geofence popup includes Edit button."""
|
|
assert "editGeofence" in source, "editGeofence not referenced"
|
|
|
|
def test_geofence_popup_has_delete_button(self, source):
|
|
"""Geofence popup includes Delete button."""
|
|
assert "deleteGeofence" in source, "deleteGeofence not referenced"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 4. GEOFFENCE LAYER RENDERING
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestGeofenceRendering:
|
|
"""Verify geofence layer rendering and management code."""
|
|
|
|
def test_load_geofences_function_exists(self, source):
|
|
"""loadGeofences() must be defined."""
|
|
assert "function loadGeofences" in source, \
|
|
"loadGeofences function missing"
|
|
|
|
def test_render_geofence_list_function_exists(self, source):
|
|
"""renderGeofenceList() must be defined."""
|
|
assert "function renderGeofenceList" in source, \
|
|
"renderGeofenceList function missing"
|
|
|
|
def test_toggle_geofence_draw_function_exists(self, source):
|
|
"""toggleGeofenceDraw() must be defined."""
|
|
assert "function toggleGeofenceDraw" in source, \
|
|
"toggleGeofenceDraw function missing"
|
|
|
|
def test_save_drawn_geofence_function_exists(self, source):
|
|
"""saveDrawnGeofence() must be defined."""
|
|
assert "function saveDrawnGeofence" in source, \
|
|
"saveDrawnGeofence function missing"
|
|
|
|
def test_delete_geofence_function_exists(self, source):
|
|
"""deleteGeofence() must be defined."""
|
|
assert "function deleteGeofence" in source, \
|
|
"deleteGeofence function missing"
|
|
|
|
def test_geofences_rendered_as_polygons(self, source):
|
|
"""Geofences are rendered as Leaflet L.polygon()."""
|
|
assert "L.polygon" in source, "L.polygon not used for geofences"
|
|
|
|
def test_geofences_have_fill_opacity(self, source):
|
|
"""Polygons have semi-transparent fill (fillOpacity)."""
|
|
assert "fillOpacity" in source, "fillOpacity not set on geofence polygons"
|
|
|
|
def test_geofences_use_color_from_data(self, source):
|
|
"""Polygon color comes from geofence.color or default #3388ff."""
|
|
assert "gf.color || '#3388ff'" in source or "gf.color" in source, \
|
|
"Geofence color from data not used"
|
|
|
|
def test_geofence_empty_state_rendered(self, source):
|
|
"""Empty state message when no geofences exist."""
|
|
assert "No geofences yet" in source, "Empty geofence state message missing"
|
|
|
|
def test_geofence_list_shows_color_swatch(self, source):
|
|
"""Each geofence item shows a color swatch."""
|
|
assert "gf-color" in source, "Geofence color swatch class missing"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 5. GPS CONTROLS
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestGPSControls:
|
|
"""Verify GPS initialization, centering, and visit tracking code."""
|
|
|
|
def test_init_gps_function_exists(self, source):
|
|
"""initGPS() must be defined."""
|
|
assert "function initGPS" in source, "initGPS function missing"
|
|
|
|
def test_center_on_gps_function_exists(self, source):
|
|
"""centerOnGPS() must be defined."""
|
|
assert "function centerOnGPS" in source, \
|
|
"centerOnGPS function missing"
|
|
|
|
def test_gps_badge_element_exists(self, source):
|
|
"""GPS badge in the header must exist."""
|
|
assert 'id="gpsBadge"' in source, "GPS badge element missing"
|
|
|
|
def test_geolocation_api_used(self, source):
|
|
"""navigator.geolocation must be called."""
|
|
assert "navigator.geolocation" in source or "geolocation" in source, \
|
|
"Geolocation API not used"
|
|
|
|
def test_gps_error_handling_exists(self, source):
|
|
"""GPS errors are handled (watchPosition error callback)."""
|
|
assert "watchPosition" in source, "watchPosition not called for GPS tracking"
|
|
|
|
def test_user_location_marker_created(self, source):
|
|
"""centerOnGPS creates a circleMarker for user position."""
|
|
assert "L.circleMarker" in source, \
|
|
"L.circleMarker not used for GPS position indicator"
|
|
|
|
def test_map_center_falls_back_to_default(self, source):
|
|
"""Map center falls back to default lat/lng when GPS unavailable."""
|
|
assert "40.7128" in source, "Default lat fallback missing"
|
|
assert "-74.006" in source or "-74.0060" in source, \
|
|
"Default lng fallback missing"
|
|
|
|
def test_gps_fallback_zoom_level(self, source):
|
|
"""Zoom level differs when GPS is available vs fallback."""
|
|
# Should reference gpsLat to decide zoom
|
|
assert "gpsLat" in source, "gpsLat not referenced for zoom decision"
|
|
|
|
def test_start_visit_tracking_function_exists(self, source):
|
|
"""startVisitTracking() must be defined for auto-visit logging."""
|
|
assert "function startVisitTracking" in source, \
|
|
"startVisitTracking function missing"
|
|
|
|
def test_haversine_distance_function_exists(self, source):
|
|
"""Haversine formula must be implemented for proximity checks."""
|
|
assert "function haversineM" in source, \
|
|
"haversineM (distance) function missing"
|
|
|
|
def test_visit_threshold_defined(self, source):
|
|
"""VISIT_THRESHOLD_M must be defined."""
|
|
assert "VISIT_THRESHOLD_M" in source, "Visit threshold constant missing"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 6. HEATMAP TOGGLE
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestHeatmapToggle:
|
|
"""Verify heatmap toggle and data loading code."""
|
|
|
|
def test_toggle_heatmap_function_exists(self, source):
|
|
"""toggleHeatmap() must be defined."""
|
|
assert "function toggleHeatmap" in source, \
|
|
"toggleHeatmap function missing"
|
|
|
|
def test_load_heatmap_data_function_exists(self, source):
|
|
"""loadHeatmapData() must be defined."""
|
|
assert "function loadHeatmapData" in source, \
|
|
"loadHeatmapData function missing"
|
|
|
|
def test_heatmap_uses_visit_stats_api(self, source):
|
|
"""Heatmap data comes from /api/visits/stats."""
|
|
assert "api('/api/visits/stats')" in source, \
|
|
"Heatmap does not call visits/stats API"
|
|
|
|
def test_heatmap_layer_initialized(self, source):
|
|
"""A heatLayer variable must be declared."""
|
|
assert "heatLayer" in source, "heatLayer variable missing"
|
|
|
|
def test_heat_visible_toggle_state(self, source):
|
|
"""heatVisible boolean toggle state must exist."""
|
|
assert "heatVisible" in source, "heatVisible state variable missing"
|
|
|
|
def test_heatmap_chip_toggles_class(self, source):
|
|
"""Heatmap chip gets 'heat-on' class when active."""
|
|
assert "heat-on" in source, "heat-on class toggle missing"
|
|
|
|
def test_heatmap_uses_leaflet_heat_layer(self, source):
|
|
"""Heatmap uses L.heatLayer (leaflet-heat plugin)."""
|
|
assert "L.heatLayer" in source or "heatLayer" in source, \
|
|
"Leaflet heat layer function not referenced"
|
|
|
|
def test_heatmap_has_fallback_circle_markers(self, source):
|
|
"""If L.heatLayer unavailable, falls back to circle markers."""
|
|
assert "L.circleMarker" in source, \
|
|
"Heatmap fallback via circleMarker missing"
|
|
|
|
def test_heatmap_gradient_defined(self, source):
|
|
"""Heat gradient colors must be defined (green→yellow→red)."""
|
|
assert "#4ade80" in source and "#f87171" in source, \
|
|
"Heatmap gradient colors not found"
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 7. MAP API ENDPOINT SMOKE TESTS (curl-style)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
class TestMapAPIEndpoints:
|
|
"""Verify the backing API endpoints return expected status codes."""
|
|
|
|
# ── geofences ──
|
|
|
|
def test_get_geofences_empty(self, client):
|
|
"""GET /api/geofences returns 200 and empty list."""
|
|
r = client.get("/api/geofences")
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_post_geofence_returns_201(self, client):
|
|
"""POST /api/geofences creates with 201."""
|
|
r = client.post("/api/geofences", json={
|
|
"name": "Smoke Zone",
|
|
"points": [
|
|
{"lat": 40, "lng": -74}, {"lat": 40, "lng": -73},
|
|
{"lat": 41, "lng": -73}, {"lat": 41, "lng": -74},
|
|
],
|
|
"color": "#ff0000",
|
|
})
|
|
assert r.status_code == 201
|
|
assert r.json()["name"] == "Smoke Zone"
|
|
|
|
def test_geofence_check_endpoint_exists(self, client):
|
|
"""POST /api/geofences/check returns 200."""
|
|
r = client.post("/api/geofences/check", json={"lat": 0, "lng": 0})
|
|
assert r.status_code == 200
|
|
|
|
# ── proximity ──
|
|
|
|
def test_proximity_endpoint_returns_200(self, client):
|
|
"""GET /api/proximity returns 200."""
|
|
r = client.get("/api/proximity?lat=28.3852&lng=-81.5639&radius_km=5")
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_proximity_default_radius(self, client):
|
|
"""GET /api/proximity without radius defaults to 1km."""
|
|
r = client.get("/api/proximity?lat=0&lng=0")
|
|
assert r.status_code == 200
|
|
|
|
# ── visits ──
|
|
|
|
def test_visits_stats_endpoint_returns_200(self, client):
|
|
"""GET /api/visits/stats returns 200 (heatmap data source)."""
|
|
r = client.get("/api/visits/stats")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert "visits_per_asset" in data
|
|
|
|
def test_visits_endpoint_get_returns_200(self, client):
|
|
"""GET /api/visits returns 200."""
|
|
r = client.get("/api/visits")
|
|
assert r.status_code == 200
|
|
assert isinstance(r.json(), list)
|
|
|
|
# ── assets with coordinates ──
|
|
|
|
def test_assets_api_returns_coordinates(self, client):
|
|
"""GET /api/assets includes lat/lng fields."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "MAP-TEST",
|
|
"name": "Map Asset",
|
|
"latitude": 40.7128,
|
|
"longitude": -74.006,
|
|
})
|
|
r = client.get("/api/assets?limit=1000")
|
|
assert r.status_code == 200
|
|
assets = r.json()
|
|
assert len(assets) == 1
|
|
assert assets[0]["latitude"] == 40.7128
|
|
assert assets[0]["longitude"] == -74.006
|