Initial commit: Canteen Asset Geolocation Tool v2
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user