Files
canteen-asset-tracker/tests/frontend/test_map_smoke.py
T

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