mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
Update v2 e2e tests structure
This commit is contained in:
parent
e8392ee4f7
commit
1955ef371c
32 changed files with 1973 additions and 2156 deletions
File diff suppressed because one or more lines are too long
|
|
@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
|
|||
|
||||
def index
|
||||
render json: {
|
||||
settings: current_api_user.safe_settings,
|
||||
settings: current_api_user.settings,
|
||||
status: 'success'
|
||||
}, status: :ok
|
||||
end
|
||||
|
|
@ -31,9 +31,7 @@ class Api::V1::SettingsController < ApiController
|
|||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
|
||||
:maps_v2_style, :maps_v2_heatmap, :maps_v2_visits, :maps_v2_photos,
|
||||
:maps_v2_areas, :maps_v2_tracks, :maps_v2_fog, :maps_v2_scratch,
|
||||
:maps_v2_clustering, :maps_v2_cluster_radius,
|
||||
:maps_v2_style,
|
||||
enabled_map_layers: []
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -160,7 +160,9 @@ export class LayerManager {
|
|||
|
||||
_addRoutesLayer(routesGeoJSON) {
|
||||
if (!this.layers.routesLayer) {
|
||||
this.layers.routesLayer = new RoutesLayer(this.map)
|
||||
this.layers.routesLayer = new RoutesLayer(this.map, {
|
||||
visible: this.settings.routesVisible !== false // Default true unless explicitly false
|
||||
})
|
||||
this.layers.routesLayer.add(routesGeoJSON)
|
||||
} else {
|
||||
this.layers.routesLayer.update(routesGeoJSON)
|
||||
|
|
@ -205,7 +207,9 @@ export class LayerManager {
|
|||
|
||||
_addPointsLayer(pointsGeoJSON) {
|
||||
if (!this.layers.pointsLayer) {
|
||||
this.layers.pointsLayer = new PointsLayer(this.map)
|
||||
this.layers.pointsLayer = new PointsLayer(this.map, {
|
||||
visible: this.settings.pointsVisible !== false // Default true unless explicitly false
|
||||
})
|
||||
this.layers.pointsLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
this.layers.pointsLayer.update(pointsGeoJSON)
|
||||
|
|
|
|||
|
|
@ -24,7 +24,30 @@ export default class extends Controller {
|
|||
endDate: String
|
||||
}
|
||||
|
||||
static targets = ['container', 'loading', 'loadingText', 'monthSelect', 'clusterToggle', 'settingsPanel', 'visitsSearch']
|
||||
static targets = [
|
||||
'container',
|
||||
'loading',
|
||||
'loadingText',
|
||||
'monthSelect',
|
||||
'clusterToggle',
|
||||
'settingsPanel',
|
||||
'visitsSearch',
|
||||
'routeOpacityRange',
|
||||
'fogRadiusValue',
|
||||
'fogThresholdValue',
|
||||
'metersBetweenValue',
|
||||
'minutesBetweenValue',
|
||||
// Layer toggles
|
||||
'pointsToggle',
|
||||
'routesToggle',
|
||||
'heatmapToggle',
|
||||
'visitsToggle',
|
||||
'photosToggle',
|
||||
'areasToggle',
|
||||
// 'tracksToggle',
|
||||
'fogToggle',
|
||||
'scratchToggle'
|
||||
]
|
||||
|
||||
async connect() {
|
||||
this.cleanup = new CleanupHelper()
|
||||
|
|
@ -35,6 +58,9 @@ export default class extends Controller {
|
|||
// Sync settings from backend (will fall back to localStorage if needed)
|
||||
await this.loadSettings()
|
||||
|
||||
// Sync toggle states with loaded settings
|
||||
this.syncToggleStates()
|
||||
|
||||
await this.initializeMap()
|
||||
this.initializeAPI()
|
||||
|
||||
|
|
@ -66,6 +92,90 @@ export default class extends Controller {
|
|||
console.log('[Maps V2] Settings loaded:', this.settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync UI controls with loaded settings
|
||||
*/
|
||||
syncToggleStates() {
|
||||
// Sync layer toggles
|
||||
const toggleMap = {
|
||||
pointsToggle: 'pointsVisible',
|
||||
routesToggle: 'routesVisible',
|
||||
heatmapToggle: 'heatmapEnabled',
|
||||
visitsToggle: 'visitsEnabled',
|
||||
photosToggle: 'photosEnabled',
|
||||
areasToggle: 'areasEnabled',
|
||||
// tracksToggle: 'tracksEnabled',
|
||||
fogToggle: 'fogEnabled',
|
||||
scratchToggle: 'scratchEnabled'
|
||||
}
|
||||
|
||||
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
|
||||
const target = `${targetName}Target`
|
||||
if (this[target]) {
|
||||
this[target].checked = this.settings[settingKey]
|
||||
}
|
||||
})
|
||||
|
||||
// Sync route opacity slider
|
||||
if (this.hasRouteOpacityRangeTarget) {
|
||||
this.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
||||
}
|
||||
|
||||
// Sync map style dropdown
|
||||
const mapStyleSelect = this.element.querySelector('select[name="mapStyle"]')
|
||||
if (mapStyleSelect) {
|
||||
mapStyleSelect.value = this.settings.mapStyle || 'light'
|
||||
}
|
||||
|
||||
// Sync fog of war settings
|
||||
const fogRadiusInput = this.element.querySelector('input[name="fogOfWarRadius"]')
|
||||
if (fogRadiusInput) {
|
||||
fogRadiusInput.value = this.settings.fogOfWarRadius || 1000
|
||||
if (this.hasFogRadiusValueTarget) {
|
||||
this.fogRadiusValueTarget.textContent = `${fogRadiusInput.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
const fogThresholdInput = this.element.querySelector('input[name="fogOfWarThreshold"]')
|
||||
if (fogThresholdInput) {
|
||||
fogThresholdInput.value = this.settings.fogOfWarThreshold || 1
|
||||
if (this.hasFogThresholdValueTarget) {
|
||||
this.fogThresholdValueTarget.textContent = fogThresholdInput.value
|
||||
}
|
||||
}
|
||||
|
||||
// Sync route generation settings
|
||||
const metersBetweenInput = this.element.querySelector('input[name="metersBetweenRoutes"]')
|
||||
if (metersBetweenInput) {
|
||||
metersBetweenInput.value = this.settings.metersBetweenRoutes || 500
|
||||
if (this.hasMetersBetweenValueTarget) {
|
||||
this.metersBetweenValueTarget.textContent = `${metersBetweenInput.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
const minutesBetweenInput = this.element.querySelector('input[name="minutesBetweenRoutes"]')
|
||||
if (minutesBetweenInput) {
|
||||
minutesBetweenInput.value = this.settings.minutesBetweenRoutes || 60
|
||||
if (this.hasMinutesBetweenValueTarget) {
|
||||
this.minutesBetweenValueTarget.textContent = `${minutesBetweenInput.value}min`
|
||||
}
|
||||
}
|
||||
|
||||
// Sync points rendering mode radio buttons
|
||||
const pointsRenderingRadios = this.element.querySelectorAll('input[name="pointsRenderingMode"]')
|
||||
pointsRenderingRadios.forEach(radio => {
|
||||
radio.checked = radio.value === (this.settings.pointsRenderingMode || 'raw')
|
||||
})
|
||||
|
||||
// Sync speed-colored routes toggle
|
||||
const speedColoredRoutesToggle = this.element.querySelector('input[name="speedColoredRoutes"]')
|
||||
if (speedColoredRoutesToggle) {
|
||||
speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false
|
||||
}
|
||||
|
||||
console.log('[Maps V2] UI controls synced with settings')
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MapLibre map
|
||||
*/
|
||||
|
|
@ -213,46 +323,59 @@ export default class extends Controller {
|
|||
* Toggle layer visibility
|
||||
*/
|
||||
toggleLayer(event) {
|
||||
const button = event.currentTarget
|
||||
const layerName = button.dataset.layer
|
||||
const element = event.currentTarget
|
||||
const layerName = element.dataset.layer || event.params?.layer
|
||||
|
||||
const visible = this.layerManager.toggleLayer(layerName)
|
||||
if (visible === null) return
|
||||
|
||||
// Update button style
|
||||
if (visible) {
|
||||
button.classList.add('btn-primary')
|
||||
button.classList.remove('btn-outline')
|
||||
} else {
|
||||
button.classList.remove('btn-primary')
|
||||
button.classList.add('btn-outline')
|
||||
// Update button style (for button-based toggles)
|
||||
if (element.tagName === 'BUTTON') {
|
||||
if (visible) {
|
||||
element.classList.add('btn-primary')
|
||||
element.classList.remove('btn-outline')
|
||||
} else {
|
||||
element.classList.remove('btn-primary')
|
||||
element.classList.add('btn-outline')
|
||||
}
|
||||
}
|
||||
|
||||
// Update checkbox state (for checkbox-based toggles)
|
||||
if (element.tagName === 'INPUT' && element.type === 'checkbox') {
|
||||
element.checked = visible
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle point clustering
|
||||
* Toggle points layer visibility
|
||||
*/
|
||||
toggleClustering(event) {
|
||||
togglePoints(event) {
|
||||
const element = event.currentTarget
|
||||
const visible = element.checked
|
||||
|
||||
const pointsLayer = this.layerManager.getLayer('points')
|
||||
if (!pointsLayer) return
|
||||
|
||||
const button = event.currentTarget
|
||||
|
||||
// Toggle clustering state
|
||||
const newClusteringState = !pointsLayer.clusteringEnabled
|
||||
pointsLayer.toggleClustering(newClusteringState)
|
||||
|
||||
// Update button style to reflect state
|
||||
if (newClusteringState) {
|
||||
button.classList.add('btn-primary')
|
||||
button.classList.remove('btn-outline')
|
||||
} else {
|
||||
button.classList.remove('btn-primary')
|
||||
button.classList.add('btn-outline')
|
||||
if (pointsLayer) {
|
||||
pointsLayer.toggle(visible)
|
||||
}
|
||||
|
||||
// Save setting
|
||||
SettingsManager.updateSetting('clustering', newClusteringState)
|
||||
SettingsManager.updateSetting('pointsVisible', visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle routes layer visibility
|
||||
*/
|
||||
toggleRoutes(event) {
|
||||
const element = event.currentTarget
|
||||
const visible = element.checked
|
||||
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer) {
|
||||
routesLayer.toggle(visible)
|
||||
}
|
||||
|
||||
// Save setting
|
||||
SettingsManager.updateSetting('routesVisible', visible)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -312,6 +435,119 @@ export default class extends Controller {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route opacity in real-time
|
||||
*/
|
||||
updateRouteOpacity(event) {
|
||||
const opacity = parseInt(event.target.value) / 100
|
||||
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer && this.map.getLayer('routes')) {
|
||||
this.map.setPaintProperty('routes', 'line-opacity', opacity)
|
||||
}
|
||||
|
||||
// Save setting
|
||||
SettingsManager.updateSetting('routeOpacity', opacity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fog radius display value
|
||||
*/
|
||||
updateFogRadiusDisplay(event) {
|
||||
if (this.hasFogRadiusValueTarget) {
|
||||
this.fogRadiusValueTarget.textContent = `${event.target.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update fog threshold display value
|
||||
*/
|
||||
updateFogThresholdDisplay(event) {
|
||||
if (this.hasFogThresholdValueTarget) {
|
||||
this.fogThresholdValueTarget.textContent = event.target.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update meters between routes display value
|
||||
*/
|
||||
updateMetersBetweenDisplay(event) {
|
||||
if (this.hasMetersBetweenValueTarget) {
|
||||
this.metersBetweenValueTarget.textContent = `${event.target.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update minutes between routes display value
|
||||
*/
|
||||
updateMinutesBetweenDisplay(event) {
|
||||
if (this.hasMinutesBetweenValueTarget) {
|
||||
this.minutesBetweenValueTarget.textContent = `${event.target.value}min`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update advanced settings from form submission
|
||||
*/
|
||||
async updateAdvancedSettings(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const formData = new FormData(event.target)
|
||||
const settings = {
|
||||
routeOpacity: parseFloat(formData.get('routeOpacity')) / 100,
|
||||
fogOfWarRadius: parseInt(formData.get('fogOfWarRadius')),
|
||||
fogOfWarThreshold: parseInt(formData.get('fogOfWarThreshold')),
|
||||
metersBetweenRoutes: parseInt(formData.get('metersBetweenRoutes')),
|
||||
minutesBetweenRoutes: parseInt(formData.get('minutesBetweenRoutes')),
|
||||
pointsRenderingMode: formData.get('pointsRenderingMode'),
|
||||
speedColoredRoutes: formData.get('speedColoredRoutes') === 'on'
|
||||
}
|
||||
|
||||
// Apply settings to current map
|
||||
await this.applySettingsToMap(settings)
|
||||
|
||||
// Save to backend and localStorage
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await SettingsManager.updateSetting(key, value)
|
||||
}
|
||||
|
||||
Toast.success('Settings updated successfully')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings to map without reload
|
||||
*/
|
||||
async applySettingsToMap(settings) {
|
||||
// Update route opacity
|
||||
if (settings.routeOpacity !== undefined) {
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer && this.map.getLayer('routes')) {
|
||||
this.map.setPaintProperty('routes', 'line-opacity', settings.routeOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
// Update fog of war settings
|
||||
if (settings.fogOfWarRadius !== undefined || settings.fogOfWarThreshold !== undefined) {
|
||||
const fogLayer = this.layerManager.getLayer('fog')
|
||||
if (fogLayer) {
|
||||
if (settings.fogOfWarRadius) {
|
||||
fogLayer.clearRadius = settings.fogOfWarRadius
|
||||
}
|
||||
// Redraw fog layer
|
||||
if (fogLayer.visible) {
|
||||
await fogLayer.update(fogLayer.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For settings that require data reload (points rendering mode, speed-colored routes, etc)
|
||||
// we need to reload the map data
|
||||
if (settings.pointsRenderingMode || settings.speedColoredRoutes !== undefined) {
|
||||
Toast.info('Reloading map data with new settings...')
|
||||
await this.loadMapData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visits layer
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Maps V2 Settings Persistence
|
||||
|
||||
Maps V2 now persists user settings across sessions and devices using a hybrid approach with backend API storage and localStorage fallback.
|
||||
Maps V2 persists user settings across sessions and devices using a hybrid approach with backend API storage and localStorage fallback. **Settings are shared with Maps V1** for seamless migration.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -10,6 +10,7 @@ Maps V2 now persists user settings across sessions and devices using a hybrid ap
|
|||
- Settings stored in User's `settings` JSONB column
|
||||
- Syncs across all devices/browsers
|
||||
- Requires authentication via API key
|
||||
- **Compatible with v1 map settings**
|
||||
|
||||
2. **Fallback: localStorage**
|
||||
- Instant save/load without network
|
||||
|
|
@ -18,22 +19,27 @@ Maps V2 now persists user settings across sessions and devices using a hybrid ap
|
|||
|
||||
## Settings Stored
|
||||
|
||||
All Maps V2 user preferences are persisted:
|
||||
Maps V2 shares layer visibility settings with v1 using the `enabled_map_layers` array:
|
||||
|
||||
| Frontend Setting | Backend Key | Type | Default |
|
||||
|-----------------|-------------|------|---------|
|
||||
| `mapStyle` | `maps_v2_style` | string | `'light'` |
|
||||
| `clustering` | `maps_v2_clustering` | boolean | `true` |
|
||||
| `clusterRadius` | `maps_v2_cluster_radius` | number | `50` |
|
||||
| `heatmapEnabled` | `maps_v2_heatmap` | boolean | `false` |
|
||||
| `pointsVisible` | `maps_v2_points` | boolean | `true` |
|
||||
| `routesVisible` | `maps_v2_routes` | boolean | `true` |
|
||||
| `visitsEnabled` | `maps_v2_visits` | boolean | `false` |
|
||||
| `photosEnabled` | `maps_v2_photos` | boolean | `false` |
|
||||
| `areasEnabled` | `maps_v2_areas` | boolean | `false` |
|
||||
| `tracksEnabled` | `maps_v2_tracks` | boolean | `false` |
|
||||
| `fogEnabled` | `maps_v2_fog` | boolean | `false` |
|
||||
| `scratchEnabled` | `maps_v2_scratch` | boolean | `false` |
|
||||
| `enabledMapLayers` | `enabled_map_layers` | array | `['Points', 'Routes']` |
|
||||
|
||||
### Layer Names
|
||||
|
||||
The `enabled_map_layers` array contains layer names as strings:
|
||||
- `'Points'` - Individual location points
|
||||
- `'Routes'` - Connected route lines
|
||||
- `'Heatmap'` - Density heatmap
|
||||
- `'Visits'` - Detected area visits
|
||||
- `'Photos'` - Geotagged photos
|
||||
- `'Areas'` - Defined areas
|
||||
- `'Tracks'` - Saved tracks
|
||||
- `'Fog of War'` - Explored areas
|
||||
- `'Scratch map'` - Scratched countries
|
||||
|
||||
Internally, v2 converts these to boolean flags (e.g., `pointsVisible`, `routesVisible`) for easier state management, but always saves back to the shared array format.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
|
@ -58,18 +64,52 @@ All Maps V2 user preferences are persisted:
|
|||
### Update Flow
|
||||
|
||||
```
|
||||
User changes setting (e.g., enables heatmap)
|
||||
User toggles Heatmap layer
|
||||
↓
|
||||
SettingsManager.updateSetting('heatmapEnabled', true)
|
||||
↓
|
||||
Convert booleans → array: ['Points', 'Routes', 'Heatmap']
|
||||
↓
|
||||
┌──────────────────┬──────────────────┐
|
||||
│ Save to │ Save to │
|
||||
│ localStorage │ Backend API │
|
||||
│ (instant) │ (async) │
|
||||
└──────────────────┴──────────────────┘
|
||||
↓ ↓
|
||||
UI updates Backend stores
|
||||
immediately (non-blocking)
|
||||
UI updates Backend stores:
|
||||
immediately { enabled_map_layers: [...] }
|
||||
```
|
||||
|
||||
### Format Conversion
|
||||
|
||||
v2 internally uses boolean flags for state management but saves/loads using v1's array format:
|
||||
|
||||
**Loading (Array → Booleans)**:
|
||||
```javascript
|
||||
// Backend returns
|
||||
{ enabled_map_layers: ['Points', 'Routes', 'Heatmap'] }
|
||||
|
||||
// Converted to
|
||||
{
|
||||
pointsVisible: true,
|
||||
routesVisible: true,
|
||||
heatmapEnabled: true,
|
||||
visitsEnabled: false,
|
||||
// ... etc
|
||||
}
|
||||
```
|
||||
|
||||
**Saving (Booleans → Array)**:
|
||||
```javascript
|
||||
// v2 state
|
||||
{
|
||||
pointsVisible: true,
|
||||
routesVisible: false,
|
||||
heatmapEnabled: true
|
||||
}
|
||||
|
||||
// Saved as
|
||||
{ enabled_map_layers: ['Points', 'Heatmap'] }
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
|
@ -242,16 +282,14 @@ Existing users with localStorage settings will seamlessly migrate:
|
|||
Settings stored in `users.settings` JSONB column:
|
||||
|
||||
```sql
|
||||
-- Example user settings
|
||||
-- Example user settings (shared between v1 and v2)
|
||||
{
|
||||
"maps_v2_style": "dark",
|
||||
"maps_v2_heatmap": true,
|
||||
"maps_v2_clustering": true,
|
||||
"maps_v2_cluster_radius": 50,
|
||||
// ... other Maps V2 settings
|
||||
// ... Maps V1 settings (coexist)
|
||||
"preferred_map_layer": "Light",
|
||||
"enabled_map_layers": ["Routes", "Heatmap"]
|
||||
"enabled_map_layers": ["Points", "Routes", "Heatmap", "Visits"],
|
||||
// ... other settings shared by both versions
|
||||
"preferred_map_layer": "OpenStreetMap",
|
||||
"fog_of_war_meters": "100",
|
||||
"route_opacity": 60
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Points layer with toggleable clustering
|
||||
* Points layer for displaying individual location points
|
||||
*/
|
||||
export class PointsLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'points', ...options })
|
||||
this.clusterRadius = options.clusterRadius || 50
|
||||
this.clusterMaxZoom = options.clusterMaxZoom || 14
|
||||
this.clusteringEnabled = options.clustering !== false // Default to enabled
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
|
|
@ -17,63 +14,17 @@ export class PointsLayer extends BaseLayer {
|
|||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
},
|
||||
cluster: this.clusteringEnabled,
|
||||
clusterMaxZoom: this.clusterMaxZoom,
|
||||
clusterRadius: this.clusterRadius
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Cluster circles
|
||||
{
|
||||
id: `${this.id}-clusters`,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
'#51bbd6', 10,
|
||||
'#f1f075', 50,
|
||||
'#f28cb1', 100,
|
||||
'#ff6b6b'
|
||||
],
|
||||
'circle-radius': [
|
||||
'step',
|
||||
['get', 'point_count'],
|
||||
20, 10,
|
||||
30, 50,
|
||||
40, 100,
|
||||
50
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Cluster count labels
|
||||
{
|
||||
id: `${this.id}-count`,
|
||||
type: 'symbol',
|
||||
source: this.sourceId,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
|
||||
'text-size': 12
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff'
|
||||
}
|
||||
},
|
||||
|
||||
// Individual points
|
||||
{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-color': '#3b82f6',
|
||||
'circle-radius': 6,
|
||||
|
|
@ -83,56 +34,4 @@ export class PointsLayer extends BaseLayer {
|
|||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle clustering on/off
|
||||
* @param {boolean} enabled - Whether to enable clustering
|
||||
*/
|
||||
toggleClustering(enabled) {
|
||||
if (!this.data) {
|
||||
console.warn('Cannot toggle clustering: no data loaded')
|
||||
return
|
||||
}
|
||||
|
||||
this.clusteringEnabled = enabled
|
||||
|
||||
// Need to recreate the source with new clustering setting
|
||||
// MapLibre doesn't support changing cluster setting dynamically
|
||||
// So we remove and re-add the source
|
||||
const currentData = this.data
|
||||
const wasVisible = this.visible
|
||||
|
||||
// Remove all layers first
|
||||
this.getLayerIds().forEach(layerId => {
|
||||
if (this.map.getLayer(layerId)) {
|
||||
this.map.removeLayer(layerId)
|
||||
}
|
||||
})
|
||||
|
||||
// Remove source
|
||||
if (this.map.getSource(this.sourceId)) {
|
||||
this.map.removeSource(this.sourceId)
|
||||
}
|
||||
|
||||
// Re-add source with new clustering setting
|
||||
this.map.addSource(this.sourceId, this.getSourceConfig())
|
||||
|
||||
// Re-add layers
|
||||
const layers = this.getLayerConfigs()
|
||||
layers.forEach(layerConfig => {
|
||||
this.map.addLayer(layerConfig)
|
||||
})
|
||||
|
||||
// Restore visibility state
|
||||
this.visible = wasVisible
|
||||
this.setVisibility(wasVisible)
|
||||
|
||||
// Update data
|
||||
this.data = currentData
|
||||
const source = this.map.getSource(this.sourceId)
|
||||
if (source && source.setData) {
|
||||
source.setData(currentData)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,31 +7,34 @@ const STORAGE_KEY = 'dawarich-maps-v2-settings'
|
|||
|
||||
const DEFAULT_SETTINGS = {
|
||||
mapStyle: 'light',
|
||||
clustering: true,
|
||||
clusterRadius: 50,
|
||||
heatmapEnabled: false,
|
||||
pointsVisible: true,
|
||||
routesVisible: true,
|
||||
visitsEnabled: false,
|
||||
photosEnabled: false,
|
||||
areasEnabled: false,
|
||||
tracksEnabled: false,
|
||||
fogEnabled: false,
|
||||
scratchEnabled: false
|
||||
enabledMapLayers: ['Points', 'Routes'], // Compatible with v1 map
|
||||
// Advanced settings
|
||||
routeOpacity: 1.0,
|
||||
fogOfWarRadius: 1000,
|
||||
fogOfWarThreshold: 1,
|
||||
metersBetweenRoutes: 500,
|
||||
minutesBetweenRoutes: 60,
|
||||
pointsRenderingMode: 'raw',
|
||||
speedColoredRoutes: false
|
||||
}
|
||||
|
||||
// Mapping between v2 layer names and v1 layer names in enabled_map_layers array
|
||||
const LAYER_NAME_MAP = {
|
||||
'Points': 'pointsVisible',
|
||||
'Routes': 'routesVisible',
|
||||
'Heatmap': 'heatmapEnabled',
|
||||
'Visits': 'visitsEnabled',
|
||||
'Photos': 'photosEnabled',
|
||||
'Areas': 'areasEnabled',
|
||||
'Tracks': 'tracksEnabled',
|
||||
'Fog of War': 'fogEnabled',
|
||||
'Scratch map': 'scratchEnabled'
|
||||
}
|
||||
|
||||
// Mapping between frontend settings and backend API keys
|
||||
const BACKEND_SETTINGS_MAP = {
|
||||
mapStyle: 'maps_v2_style',
|
||||
heatmapEnabled: 'maps_v2_heatmap',
|
||||
visitsEnabled: 'maps_v2_visits',
|
||||
photosEnabled: 'maps_v2_photos',
|
||||
areasEnabled: 'maps_v2_areas',
|
||||
tracksEnabled: 'maps_v2_tracks',
|
||||
fogEnabled: 'maps_v2_fog',
|
||||
scratchEnabled: 'maps_v2_scratch',
|
||||
clustering: 'maps_v2_clustering',
|
||||
clusterRadius: 'maps_v2_cluster_radius'
|
||||
enabledMapLayers: 'enabled_map_layers'
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
|
|
@ -47,18 +50,55 @@ export class SettingsManager {
|
|||
|
||||
/**
|
||||
* Get all settings (localStorage first, then merge with defaults)
|
||||
* Converts enabled_map_layers array to individual boolean flags
|
||||
* @returns {Object} Settings object
|
||||
*/
|
||||
static getSettings() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS
|
||||
const settings = stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS
|
||||
|
||||
// Convert enabled_map_layers array to individual boolean flags
|
||||
return this._expandLayerSettings(settings)
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert enabled_map_layers array to individual boolean flags
|
||||
* @param {Object} settings - Settings with enabledMapLayers array
|
||||
* @returns {Object} Settings with individual layer booleans
|
||||
*/
|
||||
static _expandLayerSettings(settings) {
|
||||
const enabledLayers = settings.enabledMapLayers || []
|
||||
|
||||
// Set boolean flags based on array contents
|
||||
Object.entries(LAYER_NAME_MAP).forEach(([layerName, settingKey]) => {
|
||||
settings[settingKey] = enabledLayers.includes(layerName)
|
||||
})
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert individual boolean flags to enabled_map_layers array
|
||||
* @param {Object} settings - Settings with individual layer booleans
|
||||
* @returns {Array} Array of enabled layer names
|
||||
*/
|
||||
static _collapseLayerSettings(settings) {
|
||||
const enabledLayers = []
|
||||
|
||||
Object.entries(LAYER_NAME_MAP).forEach(([layerName, settingKey]) => {
|
||||
if (settings[settingKey] === true) {
|
||||
enabledLayers.push(layerName)
|
||||
}
|
||||
})
|
||||
|
||||
return enabledLayers
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from backend API
|
||||
* @returns {Promise<Object>} Settings object from backend
|
||||
|
|
@ -92,11 +132,21 @@ export class SettingsManager {
|
|||
}
|
||||
})
|
||||
|
||||
// Merge with defaults and save to localStorage
|
||||
// Merge with defaults, but prioritize backend's enabled_map_layers completely
|
||||
const mergedSettings = { ...DEFAULT_SETTINGS, ...frontendSettings }
|
||||
this.saveToLocalStorage(mergedSettings)
|
||||
|
||||
return mergedSettings
|
||||
// If backend has enabled_map_layers, use it as-is (don't merge with defaults)
|
||||
if (backendSettings.enabled_map_layers) {
|
||||
mergedSettings.enabledMapLayers = backendSettings.enabled_map_layers
|
||||
}
|
||||
|
||||
// Convert enabled_map_layers array to individual boolean flags
|
||||
const expandedSettings = this._expandLayerSettings(mergedSettings)
|
||||
|
||||
// Save to localStorage
|
||||
this.saveToLocalStorage(expandedSettings)
|
||||
|
||||
return expandedSettings
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to load from backend:', error)
|
||||
return null
|
||||
|
|
@ -127,10 +177,16 @@ export class SettingsManager {
|
|||
}
|
||||
|
||||
try {
|
||||
// Convert individual layer booleans to enabled_map_layers array
|
||||
const enabledMapLayers = this._collapseLayerSettings(settings)
|
||||
|
||||
// Convert frontend settings to backend format
|
||||
const backendSettings = {}
|
||||
Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => {
|
||||
if (frontendKey in settings) {
|
||||
if (frontendKey === 'enabledMapLayers') {
|
||||
// Use the collapsed array
|
||||
backendSettings[backendKey] = enabledMapLayers
|
||||
} else if (frontendKey in settings) {
|
||||
backendSettings[backendKey] = settings[frontendKey]
|
||||
}
|
||||
})
|
||||
|
|
@ -148,7 +204,7 @@ export class SettingsManager {
|
|||
throw new Error(`Failed to save settings: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('[Settings] Saved to backend successfully')
|
||||
console.log('[Settings] Saved to backend successfully:', backendSettings)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to save to backend:', error)
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@
|
|||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
checked
|
||||
data-layer="points" />
|
||||
data-maps-v2-target="pointsToggle"
|
||||
data-action="change->maps-v2#togglePoints" />
|
||||
<span class="label-text font-medium">Points</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show individual location points</p>
|
||||
|
|
@ -87,8 +87,8 @@
|
|||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
checked
|
||||
data-layer="routes" />
|
||||
data-maps-v2-target="routesToggle"
|
||||
data-action="change->maps-v2#toggleRoutes" />
|
||||
<span class="label-text font-medium">Routes</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show connected route lines</p>
|
||||
|
|
@ -101,6 +101,7 @@
|
|||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps-v2-target="heatmapToggle"
|
||||
data-action="change->maps-v2#toggleHeatmap" />
|
||||
<span class="label-text font-medium">Heatmap</span>
|
||||
</label>
|
||||
|
|
@ -114,6 +115,7 @@
|
|||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps-v2-target="visitsToggle"
|
||||
data-action="change->maps-v2#toggleVisits" />
|
||||
<span class="label-text font-medium">Visits</span>
|
||||
</label>
|
||||
|
|
@ -128,7 +130,7 @@
|
|||
class="input input-sm input-bordered w-full"
|
||||
data-action="input->maps-v2#searchVisits" />
|
||||
|
||||
<select class="select select-sm select-bordered w-full"
|
||||
<select class="select select-bordered w-full"
|
||||
data-action="change->maps-v2#filterVisits">
|
||||
<option value="all">All Visits</option>
|
||||
<option value="confirmed">Confirmed Only</option>
|
||||
|
|
@ -143,6 +145,7 @@
|
|||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps-v2-target="photosToggle"
|
||||
data-action="change->maps-v2#togglePhotos" />
|
||||
<span class="label-text font-medium">Photos</span>
|
||||
</label>
|
||||
|
|
@ -156,6 +159,7 @@
|
|||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps-v2-target="areasToggle"
|
||||
data-action="change->maps-v2#toggleAreas" />
|
||||
<span class="label-text font-medium">Areas</span>
|
||||
</label>
|
||||
|
|
@ -165,23 +169,25 @@
|
|||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Tracks Layer -->
|
||||
<div class="form-control">
|
||||
<%# <div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps-v2-target="tracksToggle"
|
||||
data-action="change->maps-v2#toggleTracks" />
|
||||
<span class="label-text font-medium">Tracks</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show saved tracks</p>
|
||||
</div>
|
||||
</div> %>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
<%# <div class="divider my-2"></div> %>
|
||||
|
||||
<!-- Fog of War Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps-v2-target="fogToggle"
|
||||
data-action="change->maps-v2#toggleFog" />
|
||||
<span class="label-text font-medium">Fog of War</span>
|
||||
</label>
|
||||
|
|
@ -195,23 +201,26 @@
|
|||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps-v2-target="scratchToggle"
|
||||
data-action="change->maps-v2#toggleScratch" />
|
||||
<span class="label-text font-medium">Scratch Map</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show scratched countries</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-content" data-tab-content="settings" data-map-panel-target="tabContent">
|
||||
<div class="space-y-6">
|
||||
<form data-action="submit->maps-v2#updateAdvancedSettings" class="space-y-4">
|
||||
<!-- Map Style -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Map Style</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full"
|
||||
name="mapStyle"
|
||||
data-action="change->maps-v2#updateMapStyle">
|
||||
<option value="light" selected>Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
|
|
@ -221,21 +230,160 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Clustering -->
|
||||
<!-- Route Opacity -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Route Opacity</span>
|
||||
<span class="label-text-alt">%</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="routeOpacity"
|
||||
min="10"
|
||||
max="100"
|
||||
step="10"
|
||||
value="100"
|
||||
class="range range-primary range-sm"
|
||||
data-maps-v2-target="routeOpacityRange"
|
||||
data-action="input->maps-v2#updateRouteOpacity" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>10%</span>
|
||||
<span>50%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Fog of War Settings -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Fog of War Radius</span>
|
||||
<span class="label-text-alt" data-maps-v2-target="fogRadiusValue">1000m</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="fogOfWarRadius"
|
||||
min="5"
|
||||
max="2000"
|
||||
step="5"
|
||||
value="1000"
|
||||
class="range range-primary range-sm"
|
||||
data-action="input->maps-v2#updateFogRadiusDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>5m</span>
|
||||
<span>1000m</span>
|
||||
<span>2000m</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">Clear radius around visited points</p>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Fog of War Threshold</span>
|
||||
<span class="label-text-alt" data-maps-v2-target="fogThresholdValue">1</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="fogOfWarThreshold"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
value="1"
|
||||
class="range range-primary range-sm"
|
||||
data-action="input->maps-v2#updateFogThresholdDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>1</span>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">Minimum points to clear fog</p>
|
||||
</div>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Route Generation Settings -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Meters Between Routes</span>
|
||||
<span class="label-text-alt" data-maps-v2-target="metersBetweenValue">500m</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="metersBetweenRoutes"
|
||||
min="100"
|
||||
max="5000"
|
||||
step="100"
|
||||
value="500"
|
||||
class="range range-primary range-sm"
|
||||
data-action="input->maps-v2#updateMetersBetweenDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>100m</span>
|
||||
<span>2500m</span>
|
||||
<span>5000m</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">Distance threshold for route splitting</p>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Minutes Between Routes</span>
|
||||
<span class="label-text-alt" data-maps-v2-target="minutesBetweenValue">60min</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="minutesBetweenRoutes"
|
||||
min="1"
|
||||
max="180"
|
||||
step="1"
|
||||
value="60"
|
||||
class="range range-primary range-sm"
|
||||
data-action="input->maps-v2#updateMinutesBetweenDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>1min</span>
|
||||
<span>90min</span>
|
||||
<span>180min</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">Time threshold for route splitting</p>
|
||||
</div>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Points Rendering Mode -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Points Rendering Mode</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="label cursor-pointer justify-start gap-3 py-1">
|
||||
<input type="radio"
|
||||
name="pointsRenderingMode"
|
||||
value="raw"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked />
|
||||
<span class="label-text">Raw (all points)</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3 py-1">
|
||||
<input type="radio"
|
||||
name="pointsRenderingMode"
|
||||
value="simplified"
|
||||
class="radio radio-primary radio-sm" />
|
||||
<span class="label-text">Simplified (reduced points)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Speed-Colored Routes -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
checked
|
||||
data-action="change->maps-v2#toggleClustering" />
|
||||
<span class="label-text font-medium">Point Clustering</span>
|
||||
name="speedColoredRoutes"
|
||||
class="toggle toggle-primary" />
|
||||
<span class="label-text font-medium">Speed-Colored Routes</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 mt-1">Group nearby points together</p>
|
||||
<p class="text-sm text-base-content/60 mt-1">Color routes by speed</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Live Mode -->
|
||||
<div class="form-control">
|
||||
|
|
@ -249,10 +397,20 @@
|
|||
<p class="text-sm text-base-content/60 mt-1">Show new points in real-time</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-2"></div>
|
||||
|
||||
<!-- Update Button -->
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
|
||||
<polyline points="12 2 12 12 14.5 14.5"></polyline>
|
||||
</svg>
|
||||
Apply Settings
|
||||
</button>
|
||||
|
||||
<!-- Reset Settings -->
|
||||
<button class="btn btn-outline btn-block"
|
||||
<button type="button"
|
||||
class="btn btn-outline btn-block"
|
||||
data-action="click->maps-v2#resetSettings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"></path>
|
||||
|
|
@ -262,7 +420,7 @@
|
|||
</svg>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -272,8 +430,8 @@
|
|||
.map-control-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -420px; /* Hidden by default */
|
||||
width: 420px;
|
||||
right: -480px; /* Hidden by default */
|
||||
width: 480px;
|
||||
height: 100%;
|
||||
background: oklch(var(--b1));
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
|
|
@ -297,6 +455,7 @@
|
|||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
|
|
@ -347,6 +506,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
|
|
@ -356,6 +516,7 @@
|
|||
padding: 20px 24px;
|
||||
border-bottom: 1px solid oklch(var(--bc) / 0.1);
|
||||
background: oklch(var(--b1));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
|
|
@ -398,11 +559,156 @@
|
|||
background: oklch(var(--bc) / 0.3);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
/* Toggle Focus State - Remove all focus indicators */
|
||||
.toggle:focus,
|
||||
.toggle:focus-visible,
|
||||
.toggle:focus-within {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: inherit !important;
|
||||
}
|
||||
|
||||
/* Override DaisyUI toggle focus styles */
|
||||
.toggle:focus-visible:checked,
|
||||
.toggle:checked:focus,
|
||||
.toggle:checked:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure no outline on the toggle container */
|
||||
.form-control .toggle:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Prevent indeterminate visual state on toggles */
|
||||
.toggle:indeterminate {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ensure smooth toggle transitions without intermediate states */
|
||||
.toggle {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle:checked {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Remove any active/pressed state that might cause intermediate appearance */
|
||||
.toggle:active,
|
||||
.toggle:active:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Responsive Breakpoints */
|
||||
|
||||
/* Large tablets and smaller desktops (1024px - 1280px) */
|
||||
@media (max-width: 1280px) {
|
||||
.map-control-panel {
|
||||
width: 420px;
|
||||
right: -420px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablets (768px - 1024px) */
|
||||
@media (max-width: 1024px) {
|
||||
.map-control-panel {
|
||||
width: 380px;
|
||||
right: -380px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small tablets and large phones (640px - 768px) */
|
||||
@media (max-width: 768px) {
|
||||
.map-control-panel {
|
||||
width: 90%;
|
||||
right: -90%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile phones (< 640px) */
|
||||
@media (max-width: 640px) {
|
||||
.map-control-panel {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
width: 56px;
|
||||
padding: 12px 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Reduce spacing on mobile */
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.space-y-6 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small phones (< 375px) */
|
||||
@media (max-width: 375px) {
|
||||
.panel-tabs {
|
||||
width: 52px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -21,35 +21,11 @@
|
|||
<span class="indicator-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Layer Controls (top-left corner) -->
|
||||
<div class="absolute top-4 left-4 z-10 flex flex-col gap-2">
|
||||
<button data-action="click->maps-v2#toggleLayer"
|
||||
data-layer="points"
|
||||
class="btn btn-sm btn-primary">
|
||||
<%= icon 'map-pin' %>
|
||||
<span class="ml-1">Points</span>
|
||||
</button>
|
||||
|
||||
<button data-action="click->maps-v2#toggleLayer"
|
||||
data-layer="routes"
|
||||
class="btn btn-sm btn-primary">
|
||||
<%= icon 'route' %>
|
||||
<span class="ml-1">Routes</span>
|
||||
</button>
|
||||
|
||||
<!-- Cluster toggle -->
|
||||
<button data-action="click->maps-v2#toggleClustering"
|
||||
data-maps-v2-target="clusterToggle"
|
||||
class="btn btn-sm btn-primary"
|
||||
title="Toggle point clustering">
|
||||
<%= icon 'grid2x2' %>
|
||||
<span class="ml-1">Cluster</span>
|
||||
</button>
|
||||
|
||||
<!-- Settings button -->
|
||||
<!-- Settings button (top-left corner) -->
|
||||
<div class="absolute top-4 left-4 z-10">
|
||||
<button data-action="click->maps-v2#toggleSettings"
|
||||
class="btn btn-sm btn-primary"
|
||||
title="Settings">
|
||||
title="Open map settings">
|
||||
<%= icon 'square-pen' %>
|
||||
<span class="ml-1">Settings</span>
|
||||
</button>
|
||||
|
|
|
|||
210
e2e/README.md
210
e2e/README.md
|
|
@ -8,8 +8,14 @@ End-to-end tests for Dawarich using Playwright.
|
|||
# Run all tests
|
||||
npx playwright test
|
||||
|
||||
# Run V1 map tests (Leaflet-based)
|
||||
npx playwright test e2e/map/
|
||||
|
||||
# Run V2 map tests (MapLibre-based)
|
||||
npx playwright test e2e/v2/map/
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test e2e/map/map-controls.spec.js
|
||||
npx playwright test e2e/v2/map/settings.spec.js
|
||||
|
||||
# Run tests in headed mode (watch browser)
|
||||
npx playwright test --headed
|
||||
|
|
@ -27,11 +33,73 @@ npx playwright test --grep-invert @destructive
|
|||
npx playwright test --grep @destructive
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── setup/ # Test setup and authentication
|
||||
├── helpers/ # Shared helper functions
|
||||
├── map/ # V1 Map tests (Leaflet) - 81 tests
|
||||
├── v2/ # V2 Map tests (MapLibre) - 52 tests
|
||||
│ ├── helpers/ # V2-specific helpers
|
||||
│ ├── map/ # V2 core map tests
|
||||
│ │ └── layers/ # V2 layer-specific tests
|
||||
│ └── realtime/ # V2 real-time features
|
||||
└── temp/ # Playwright artifacts (screenshots, videos)
|
||||
```
|
||||
|
||||
## V1 Map Tests (Leaflet-based) - 81 tests
|
||||
|
||||
**Map Tests**
|
||||
- `map-controls.spec.js` - Basic map controls, zoom, tile layers (5 tests)
|
||||
- `map-layers.spec.js` - Layer toggles: Routes, Heatmap, Fog, etc. (8 tests)
|
||||
- `map-points.spec.js` - Point interactions and deletion (4 tests, 1 destructive)
|
||||
- `map-visits.spec.js` - Confirmed visit interactions and management (5 tests, 3 destructive)
|
||||
- `map-suggested-visits.spec.js` - Suggested visit interactions (6 tests, 3 destructive)
|
||||
- `map-add-visit.spec.js` - Add visit control and form (8 tests)
|
||||
- `map-selection-tool.spec.js` - Selection tool functionality (4 tests)
|
||||
- `map-calendar-panel.spec.js` - Calendar panel navigation (9 tests)
|
||||
- `map-side-panel.spec.js` - Side panel (visits drawer) functionality (13 tests)*
|
||||
- `map-bulk-delete.spec.js` - Bulk point deletion (12 tests, all destructive)
|
||||
- `map-places-creation.spec.js` - Creating new places on map (9 tests, 2 destructive)
|
||||
- `map-places-layers.spec.js` - Places layer visibility and filtering (10 tests)
|
||||
|
||||
\* Some side panel tests may be skipped if demo data doesn't contain visits
|
||||
|
||||
## V2 Map Tests (MapLibre-based) - 52 tests
|
||||
|
||||
**Organized by feature domain:**
|
||||
|
||||
### Core Map Tests
|
||||
- `v2/map/core.spec.js` - Map initialization, lifecycle, loading states (8 tests)
|
||||
- `v2/map/navigation.spec.js` - Zoom controls, date picker navigation (4 tests)
|
||||
- `v2/map/interactions.spec.js` - Point clicks, hover effects, popups (2 tests)
|
||||
- `v2/map/settings.spec.js` - Settings panel, layer toggles, persistence (10 tests)
|
||||
- `v2/map/performance.spec.js` - Load time benchmarks, efficiency (2 tests)
|
||||
|
||||
### Layer Tests
|
||||
- `v2/map/layers/points.spec.js` - Points display, GeoJSON data (3 tests)
|
||||
- `v2/map/layers/routes.spec.js` - Routes geometry, styling, ordering (8 tests)
|
||||
- `v2/map/layers/heatmap.spec.js` - Heatmap creation, toggle, persistence (3 tests)
|
||||
- `v2/map/layers/visits.spec.js` - Visits layer toggle and display (2 tests)
|
||||
- `v2/map/layers/photos.spec.js` - Photos layer toggle and display (2 tests)
|
||||
- `v2/map/layers/areas.spec.js` - Areas layer toggle and display (2 tests)
|
||||
- `v2/map/layers/advanced.spec.js` - Fog of war, scratch map (3 tests)
|
||||
|
||||
### Real-time Features
|
||||
- `v2/realtime/family.spec.js` - Family tracking, ActionCable (2 tests, skipped)
|
||||
|
||||
### V2 Test Organization Benefits
|
||||
- ✅ **Feature-based hierarchy** - Clear organization by domain
|
||||
- ✅ **Zero duplication** - All settings tests consolidated
|
||||
- ✅ **Easy to navigate** - Obvious file naming
|
||||
- ✅ **Better maintainability** - One feature = one file
|
||||
|
||||
## Test Tags
|
||||
|
||||
Tests are tagged to enable selective execution:
|
||||
|
||||
- **@destructive** (22 tests) - Tests that delete or modify data:
|
||||
- **@destructive** (22 tests in V1) - Tests that delete or modify data:
|
||||
- Bulk delete operations (12 tests)
|
||||
- Point deletion (1 test)
|
||||
- Visit modification/deletion (3 tests)
|
||||
|
|
@ -51,47 +119,34 @@ npx playwright test --grep @destructive
|
|||
npx playwright test e2e/map/map-bulk-delete.spec.js
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── setup/ # Test setup and authentication
|
||||
├── helpers/ # Shared helper functions
|
||||
├── map/ # Map-related tests (40 tests total)
|
||||
└── temp/ # Playwright artifacts (screenshots, videos)
|
||||
```
|
||||
|
||||
### Test Files
|
||||
|
||||
**Map Tests (81 tests)**
|
||||
- `map-controls.spec.js` - Basic map controls, zoom, tile layers (5 tests)
|
||||
- `map-layers.spec.js` - Layer toggles: Routes, Heatmap, Fog, etc. (8 tests)
|
||||
- `map-points.spec.js` - Point interactions and deletion (4 tests, 1 destructive)
|
||||
- `map-visits.spec.js` - Confirmed visit interactions and management (5 tests, 3 destructive)
|
||||
- `map-suggested-visits.spec.js` - Suggested visit interactions (6 tests, 3 destructive)
|
||||
- `map-add-visit.spec.js` - Add visit control and form (8 tests)
|
||||
- `map-selection-tool.spec.js` - Selection tool functionality (4 tests)
|
||||
- `map-calendar-panel.spec.js` - Calendar panel navigation (9 tests)
|
||||
- `map-side-panel.spec.js` - Side panel (visits drawer) functionality (13 tests)*
|
||||
- `map-bulk-delete.spec.js` - Bulk point deletion (12 tests, all destructive)
|
||||
- `map-places-creation.spec.js` - Creating new places on map (9 tests, 2 destructive)
|
||||
- `map-places-layers.spec.js` - Places layer visibility and filtering (10 tests)
|
||||
|
||||
\* Some side panel tests may be skipped if demo data doesn't contain visits
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### Map Helpers (`helpers/map.js`)
|
||||
### V1 Map Helpers (`helpers/map.js`)
|
||||
- `waitForMap(page)` - Wait for Leaflet map initialization
|
||||
- `enableLayer(page, layerName)` - Enable a map layer by name
|
||||
- `clickConfirmedVisit(page)` - Click first confirmed visit circle
|
||||
- `clickSuggestedVisit(page)` - Click first suggested visit circle
|
||||
- `getMapZoom(page)` - Get current map zoom level
|
||||
|
||||
### V2 Map Helpers (`v2/helpers/setup.js`)
|
||||
- `navigateToMapsV2(page)` - Navigate to MapLibre map
|
||||
- `navigateToMapsV2WithDate(page, startDate, endDate)` - Navigate with date range
|
||||
- `waitForMapLibre(page)` - Wait for MapLibre initialization
|
||||
- `waitForLoadingComplete(page)` - Wait for data loading
|
||||
- `hasMapInstance(page)` - Check if map is initialized
|
||||
- `getMapZoom(page)` - Get current zoom level
|
||||
- `getMapCenter(page)` - Get map center coordinates
|
||||
- `hasLayer(page, layerId)` - Check if layer exists
|
||||
- `getLayerVisibility(page, layerId)` - Get layer visibility state
|
||||
- `getPointsSourceData(page)` - Get points source data
|
||||
- `getRoutesSourceData(page)` - Get routes source data
|
||||
- `clickMapAt(page, x, y)` - Click at specific coordinates
|
||||
- `hasPopup(page)` - Check if popup is visible
|
||||
|
||||
### Navigation Helpers (`helpers/navigation.js`)
|
||||
- `closeOnboardingModal(page)` - Close getting started modal
|
||||
- `navigateToDate(page, startDate, endDate)` - Navigate to specific date range
|
||||
- `navigateToMap(page)` - Navigate to map page with setup
|
||||
- `navigateToMap(page)` - Navigate to V1 map with setup
|
||||
|
||||
### Selection Helpers (`helpers/selection.js`)
|
||||
- `drawSelectionRectangle(page, options)` - Draw selection on map
|
||||
|
|
@ -99,7 +154,7 @@ e2e/
|
|||
|
||||
## Common Patterns
|
||||
|
||||
### Basic Test Template
|
||||
### V1 Basic Test Template (Leaflet)
|
||||
```javascript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { navigateToMap } from '../helpers/navigation.js';
|
||||
|
|
@ -112,12 +167,60 @@ test('my test', async ({ page }) => {
|
|||
});
|
||||
```
|
||||
|
||||
### Testing Map Layers
|
||||
### V2 Basic Test Template (MapLibre)
|
||||
```javascript
|
||||
import { enableLayer } from '../helpers/map.js';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { closeOnboardingModal } from '../../helpers/navigation.js';
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete
|
||||
} from '../helpers/setup.js';
|
||||
|
||||
await enableLayer(page, 'Routes');
|
||||
await enableLayer(page, 'Heatmap');
|
||||
test.describe('My Feature', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page);
|
||||
await closeOnboardingModal(page);
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
test('my test', async ({ page }) => {
|
||||
// Your test logic
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### V2 Testing Layer Visibility
|
||||
```javascript
|
||||
import { getLayerVisibility } from '../helpers/setup.js';
|
||||
|
||||
// Check if layer is visible
|
||||
const isVisible = await getLayerVisibility(page, 'points');
|
||||
expect(isVisible).toBe(true);
|
||||
|
||||
// Wait for layer to exist
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
return controller?.map?.getLayer('routes') !== undefined;
|
||||
}, { timeout: 5000 });
|
||||
```
|
||||
|
||||
### V2 Testing Settings Panel
|
||||
```javascript
|
||||
// Open settings
|
||||
await page.click('button[title="Open map settings"]');
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// Switch to layers tab
|
||||
await page.click('button[data-tab="layers"]');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Check toggle state
|
||||
const toggle = page.locator('label:has-text("Points")').first().locator('input.toggle');
|
||||
const isChecked = await toggle.isChecked();
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
|
@ -132,10 +235,25 @@ test-results/
|
|||
```
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### V1 Tests
|
||||
- **Flaky tests**: Run with `--workers=1` to avoid parallel interference
|
||||
- **Timeout errors**: Increase timeout in test or use `page.waitForTimeout()`
|
||||
- **Map not loading**: Ensure `waitForMap()` is called after navigation
|
||||
|
||||
#### V2 Tests
|
||||
- **Layer not ready**: Use `page.waitForFunction()` to wait for layer existence
|
||||
- **Settings panel timing**: Add `waitForTimeout()` after opening/closing
|
||||
- **Parallel test failures**: Some tests pass individually but fail in parallel - run with `--workers=3` or `--workers=1`
|
||||
- **Source data not available**: Wait for source to be defined before accessing data
|
||||
|
||||
### V2 Test Tips
|
||||
1. Always wait for MapLibre to initialize with `waitForMapLibre(page)`
|
||||
2. Wait for data loading with `waitForLoadingComplete(page)`
|
||||
3. Add layer existence checks before testing layer properties
|
||||
4. Use proper waits for settings panel animations
|
||||
5. Consider timing when testing layer toggles
|
||||
|
||||
## CI/CD
|
||||
|
||||
Tests run with:
|
||||
|
|
@ -146,6 +264,20 @@ Tests run with:
|
|||
|
||||
See `playwright.config.js` for full configuration.
|
||||
|
||||
## Important considerations
|
||||
## Important Considerations
|
||||
|
||||
- We're using Rails 8 with Turbo, which might not cause full page reloads.
|
||||
- We're using Rails 8 with Turbo, which might not cause full page reloads
|
||||
- V2 map uses MapLibre GL JS with Stimulus controllers
|
||||
- V2 settings are persisted to localStorage
|
||||
- V2 layer visibility is based on user settings (no hardcoded defaults)
|
||||
- Some V2 layers (routes, heatmap) are created dynamically based on data
|
||||
|
||||
## Test Migration Notes
|
||||
|
||||
V2 tests were refactored from phase-based to feature-based organization:
|
||||
- **Before**: 9 phase files, 96 tests (many duplicates)
|
||||
- **After**: 13 feature files, 52 focused tests (zero duplication)
|
||||
- **Code reduction**: 56% (2,314 lines → 1,018 lines)
|
||||
- **Pass rate**: 94% (49/52 tests passing, 1 flaky, 2 skipped)
|
||||
|
||||
See `E2E_REFACTORING_SUCCESS.md` for complete migration details.
|
||||
|
|
|
|||
|
|
@ -126,21 +126,22 @@ export async function getMapCenter(page) {
|
|||
export async function getPointsSourceData(page) {
|
||||
return await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
if (!element) return { hasSource: false, featureCount: 0 };
|
||||
if (!element) return { hasSource: false, featureCount: 0, features: [] };
|
||||
|
||||
const app = window.Stimulus || window.Application;
|
||||
if (!app) return { hasSource: false, featureCount: 0 };
|
||||
if (!app) return { hasSource: false, featureCount: 0, features: [] };
|
||||
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
if (!controller?.map) return { hasSource: false, featureCount: 0 };
|
||||
if (!controller?.map) return { hasSource: false, featureCount: 0, features: [] };
|
||||
|
||||
const source = controller.map.getSource('points-source');
|
||||
if (!source) return { hasSource: false, featureCount: 0 };
|
||||
if (!source) return { hasSource: false, featureCount: 0, features: [] };
|
||||
|
||||
const data = source._data;
|
||||
return {
|
||||
hasSource: true,
|
||||
featureCount: data?.features?.length || 0
|
||||
featureCount: data?.features?.length || 0,
|
||||
features: data?.features || []
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
137
e2e/v2/map/core.spec.js
Normal file
137
e2e/v2/map/core.spec.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../helpers/navigation.js'
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
navigateToMapsV2WithDate,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete,
|
||||
hasMapInstance,
|
||||
getMapZoom,
|
||||
getMapCenter
|
||||
} from '../helpers/setup.js'
|
||||
|
||||
test.describe('Map Core', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
})
|
||||
|
||||
test.describe('Initialization', () => {
|
||||
test('loads map container', async ({ page }) => {
|
||||
const mapContainer = page.locator('[data-maps-v2-target="container"]')
|
||||
await expect(mapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('initializes MapLibre instance', async ({ page }) => {
|
||||
await waitForMapLibre(page)
|
||||
|
||||
const canvas = page.locator('.maplibregl-canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const hasMap = await hasMapInstance(page)
|
||||
expect(hasMap).toBe(true)
|
||||
})
|
||||
|
||||
test('has valid initial center and zoom', async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const center = await getMapCenter(page)
|
||||
const zoom = await getMapZoom(page)
|
||||
|
||||
expect(center).not.toBeNull()
|
||||
expect(center.lng).toBeGreaterThan(-180)
|
||||
expect(center.lng).toBeLessThan(180)
|
||||
expect(center.lat).toBeGreaterThan(-90)
|
||||
expect(center.lat).toBeLessThan(90)
|
||||
|
||||
expect(zoom).toBeGreaterThan(0)
|
||||
expect(zoom).toBeLessThan(20)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Loading States', () => {
|
||||
test('shows loading indicator during data fetch', async ({ page }) => {
|
||||
const loading = page.locator('[data-maps-v2-target="loading"]')
|
||||
|
||||
const navigationPromise = page.reload({ waitUntil: 'domcontentloaded' })
|
||||
|
||||
const loadingVisible = await loading.evaluate((el) => !el.classList.contains('hidden'))
|
||||
.catch(() => false)
|
||||
|
||||
await navigationPromise
|
||||
await closeOnboardingModal(page)
|
||||
|
||||
await waitForLoadingComplete(page)
|
||||
await expect(loading).toHaveClass(/hidden/)
|
||||
})
|
||||
|
||||
test('handles empty data gracefully', async ({ page }) => {
|
||||
await navigateToMapsV2WithDate(page, '2020-01-01T00:00', '2020-01-01T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const hasMap = await hasMapInstance(page)
|
||||
expect(hasMap).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Data Bounds', () => {
|
||||
test('fits map bounds to loaded data', async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const zoom = await getMapZoom(page)
|
||||
expect(zoom).toBeGreaterThan(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Lifecycle', () => {
|
||||
test('cleans up and reinitializes on navigation', async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await waitForLoadingComplete(page)
|
||||
|
||||
// Navigate away
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Navigate back
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
|
||||
await waitForMapLibre(page)
|
||||
const hasMap = await hasMapInstance(page)
|
||||
expect(hasMap).toBe(true)
|
||||
})
|
||||
|
||||
test('reloads data when changing date range', async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await waitForLoadingComplete(page)
|
||||
|
||||
const startInput = page.locator('input[type="datetime-local"][name="start_at"]')
|
||||
const initialStartDate = await startInput.inputValue()
|
||||
|
||||
await navigateToMapsV2WithDate(page, '2024-10-14T00:00', '2024-10-14T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
|
||||
const newStartDate = await startInput.inputValue()
|
||||
expect(newStartDate).not.toBe(initialStartDate)
|
||||
|
||||
const hasMap = await hasMapInstance(page)
|
||||
expect(hasMap).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
64
e2e/v2/map/interactions.spec.js
Normal file
64
e2e/v2/map/interactions.spec.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../helpers/navigation.js'
|
||||
import {
|
||||
navigateToMapsV2WithDate,
|
||||
waitForLoadingComplete,
|
||||
clickMapAt,
|
||||
hasPopup
|
||||
} from '../helpers/setup.js'
|
||||
|
||||
test.describe('Map Interactions', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(500)
|
||||
})
|
||||
|
||||
test.describe('Point Clicks', () => {
|
||||
test('shows popup when clicking on point', async ({ page }) => {
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Try clicking at different positions to find a point
|
||||
const positions = [
|
||||
{ x: 400, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
{ x: 600, y: 400 },
|
||||
{ x: 350, y: 250 }
|
||||
]
|
||||
|
||||
let popupFound = false
|
||||
for (const pos of positions) {
|
||||
try {
|
||||
await clickMapAt(page, pos.x, pos.y)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
if (await hasPopup(page)) {
|
||||
popupFound = true
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
// Click might fail if map is still loading
|
||||
console.log(`Click at ${pos.x},${pos.y} failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (popupFound) {
|
||||
const popup = page.locator('.maplibregl-popup')
|
||||
await expect(popup).toBeVisible()
|
||||
|
||||
const popupContent = page.locator('.point-popup')
|
||||
await expect(popupContent).toBeVisible()
|
||||
} else {
|
||||
console.log('No point clicked (points might be clustered or sparse)')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Hover Effects', () => {
|
||||
test('map container is interactive', async ({ page }) => {
|
||||
const mapContainer = page.locator('[data-maps-v2-target="container"]')
|
||||
await expect(mapContainer).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
54
e2e/v2/map/layers/advanced.spec.js
Normal file
54
e2e/v2/map/layers/advanced.spec.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../../helpers/navigation.js'
|
||||
|
||||
test.describe('Advanced Layers', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/maps_v2')
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('dawarich-maps-v2-settings')
|
||||
})
|
||||
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await page.waitForTimeout(2000)
|
||||
})
|
||||
|
||||
test.describe('Fog of War', () => {
|
||||
test('fog layer is disabled by default', async ({ page }) => {
|
||||
const fogEnabled = await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem('dawarich-maps-v2-settings') || '{}')
|
||||
return settings.fogEnabled
|
||||
})
|
||||
|
||||
expect(fogEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
test('can toggle fog layer', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const fogToggle = page.locator('label:has-text("Fog of War")').first().locator('input.toggle')
|
||||
await fogToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
expect(await fogToggle.isChecked()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Scratch Map', () => {
|
||||
test('can toggle scratch map layer', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const scratchToggle = page.locator('label:has-text("Scratch map")').first().locator('input.toggle')
|
||||
await scratchToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
expect(await scratchToggle.isChecked()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
39
e2e/v2/map/layers/areas.spec.js
Normal file
39
e2e/v2/map/layers/areas.spec.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../../helpers/navigation.js'
|
||||
import { navigateToMapsV2, waitForMapLibre, waitForLoadingComplete } from '../../helpers/setup.js'
|
||||
|
||||
test.describe('Areas Layer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
})
|
||||
|
||||
test.describe('Toggle', () => {
|
||||
test('areas layer toggle exists', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const areasToggle = page.locator('label:has-text("Areas")').first().locator('input.toggle')
|
||||
await expect(areasToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('can toggle areas layer', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const areasToggle = page.locator('label:has-text("Areas")').first().locator('input.toggle')
|
||||
await areasToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const isChecked = await areasToggle.isChecked()
|
||||
expect(isChecked).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
80
e2e/v2/map/layers/heatmap.spec.js
Normal file
80
e2e/v2/map/layers/heatmap.spec.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../../helpers/navigation.js'
|
||||
|
||||
test.describe('Heatmap Layer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await page.waitForTimeout(2000)
|
||||
})
|
||||
|
||||
test.describe('Creation', () => {
|
||||
test('heatmap layer can be enabled', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const heatmapLabel = page.locator('label:has-text("Heatmap")').first()
|
||||
const heatmapToggle = heatmapLabel.locator('input.toggle')
|
||||
await heatmapToggle.check()
|
||||
|
||||
// Wait for heatmap layer to be created
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('heatmap') !== undefined
|
||||
}, { timeout: 3000 }).catch(() => false)
|
||||
|
||||
const hasHeatmap = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('heatmap') !== undefined
|
||||
})
|
||||
|
||||
expect(hasHeatmap).toBe(true)
|
||||
})
|
||||
|
||||
test('heatmap can be toggled', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(500)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const heatmapToggle = page.locator('label:has-text("Heatmap")').first().locator('input.toggle')
|
||||
|
||||
await heatmapToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
expect(await heatmapToggle.isChecked()).toBe(true)
|
||||
|
||||
await heatmapToggle.uncheck()
|
||||
await page.waitForTimeout(500)
|
||||
expect(await heatmapToggle.isChecked()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Persistence', () => {
|
||||
test('heatmap setting persists', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(500)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const heatmapToggle = page.locator('label:has-text("Heatmap")').first().locator('input.toggle')
|
||||
await heatmapToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const settings = await page.evaluate(() => {
|
||||
return localStorage.getItem('dawarich-maps-v2-settings')
|
||||
})
|
||||
|
||||
const parsed = JSON.parse(settings)
|
||||
expect(parsed.heatmapEnabled).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
39
e2e/v2/map/layers/photos.spec.js
Normal file
39
e2e/v2/map/layers/photos.spec.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../../helpers/navigation.js'
|
||||
import { navigateToMapsV2, waitForMapLibre, waitForLoadingComplete } from '../../helpers/setup.js'
|
||||
|
||||
test.describe('Photos Layer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
})
|
||||
|
||||
test.describe('Toggle', () => {
|
||||
test('photos layer toggle exists', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const photosToggle = page.locator('label:has-text("Photos")').first().locator('input.toggle')
|
||||
await expect(photosToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('can toggle photos layer', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const photosToggle = page.locator('label:has-text("Photos")').first().locator('input.toggle')
|
||||
await photosToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const isChecked = await photosToggle.isChecked()
|
||||
expect(isChecked).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
71
e2e/v2/map/layers/points.spec.js
Normal file
71
e2e/v2/map/layers/points.spec.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../../helpers/navigation.js'
|
||||
import {
|
||||
navigateToMapsV2WithDate,
|
||||
waitForLoadingComplete,
|
||||
hasLayer,
|
||||
getPointsSourceData
|
||||
} from '../../helpers/setup.js'
|
||||
|
||||
test.describe('Points Layer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
})
|
||||
|
||||
test.describe('Display', () => {
|
||||
test('displays points layer', async ({ page }) => {
|
||||
// Wait for points layer to be added
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('points') !== undefined
|
||||
}, { timeout: 10000 }).catch(() => false)
|
||||
|
||||
const hasPoints = await hasLayer(page, 'points')
|
||||
expect(hasPoints).toBe(true)
|
||||
})
|
||||
|
||||
test('loads and displays point data', async ({ page }) => {
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getSource('points-source') !== undefined
|
||||
}, { timeout: 15000 }).catch(() => false)
|
||||
|
||||
const sourceData = await getPointsSourceData(page)
|
||||
expect(sourceData.hasSource).toBe(true)
|
||||
expect(sourceData.featureCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Data Source', () => {
|
||||
test('points source contains valid GeoJSON features', async ({ page }) => {
|
||||
// Wait for source to be added
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getSource('points-source') !== undefined
|
||||
}, { timeout: 10000 }).catch(() => false)
|
||||
|
||||
const sourceData = await getPointsSourceData(page)
|
||||
|
||||
expect(sourceData.hasSource).toBe(true)
|
||||
expect(sourceData.features).toBeDefined()
|
||||
expect(Array.isArray(sourceData.features)).toBe(true)
|
||||
|
||||
if (sourceData.features.length > 0) {
|
||||
const firstFeature = sourceData.features[0]
|
||||
expect(firstFeature.type).toBe('Feature')
|
||||
expect(firstFeature.geometry).toBeDefined()
|
||||
expect(firstFeature.geometry.type).toBe('Point')
|
||||
expect(firstFeature.geometry.coordinates).toHaveLength(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
186
e2e/v2/map/layers/routes.spec.js
Normal file
186
e2e/v2/map/layers/routes.spec.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../../helpers/navigation.js'
|
||||
import {
|
||||
navigateToMapsV2WithDate,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete,
|
||||
hasLayer,
|
||||
getLayerVisibility,
|
||||
getRoutesSourceData
|
||||
} from '../../helpers/setup.js'
|
||||
|
||||
test.describe('Routes Layer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
})
|
||||
|
||||
test.describe('Layer Existence', () => {
|
||||
test('routes layer exists on map', async ({ page }) => {
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('routes') !== undefined
|
||||
}, { timeout: 10000 }).catch(() => false)
|
||||
|
||||
const hasRoutesLayer = await hasLayer(page, 'routes')
|
||||
expect(hasRoutesLayer).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Data Source', () => {
|
||||
test('routes source has data', async ({ page }) => {
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getSource('routes-source') !== undefined
|
||||
}, { timeout: 20000 })
|
||||
|
||||
const { hasSource, featureCount } = await getRoutesSourceData(page)
|
||||
|
||||
expect(hasSource).toBe(true)
|
||||
expect(featureCount).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('routes have LineString geometry', async ({ page }) => {
|
||||
const { features } = await getRoutesSourceData(page)
|
||||
|
||||
if (features.length > 0) {
|
||||
features.forEach(feature => {
|
||||
expect(feature.geometry.type).toBe('LineString')
|
||||
expect(feature.geometry.coordinates.length).toBeGreaterThan(1)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('routes have distance properties', async ({ page }) => {
|
||||
const { features } = await getRoutesSourceData(page)
|
||||
|
||||
if (features.length > 0) {
|
||||
features.forEach(feature => {
|
||||
expect(feature.properties).toHaveProperty('distance')
|
||||
expect(typeof feature.properties.distance).toBe('number')
|
||||
expect(feature.properties.distance).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('routes connect points chronologically', async ({ page }) => {
|
||||
const { features } = await getRoutesSourceData(page)
|
||||
|
||||
if (features.length > 0) {
|
||||
features.forEach(feature => {
|
||||
expect(feature.properties).toHaveProperty('startTime')
|
||||
expect(feature.properties).toHaveProperty('endTime')
|
||||
expect(feature.properties.endTime).toBeGreaterThanOrEqual(feature.properties.startTime)
|
||||
expect(feature.properties).toHaveProperty('pointCount')
|
||||
expect(feature.properties.pointCount).toBeGreaterThan(1)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Styling', () => {
|
||||
test('routes have solid color (not speed-based)', async ({ page }) => {
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('routes') !== undefined
|
||||
}, { timeout: 20000 })
|
||||
|
||||
const routeLayerInfo = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
if (!element) return null
|
||||
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return null
|
||||
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
if (!controller?.map) return null
|
||||
|
||||
const layer = controller.map.getLayer('routes')
|
||||
if (!layer) return null
|
||||
|
||||
const lineColor = controller.map.getPaintProperty('routes', 'line-color')
|
||||
|
||||
return {
|
||||
exists: !!lineColor,
|
||||
isArray: Array.isArray(lineColor),
|
||||
value: lineColor
|
||||
}
|
||||
})
|
||||
|
||||
expect(routeLayerInfo).toBeTruthy()
|
||||
expect(routeLayerInfo.exists).toBe(true)
|
||||
expect(routeLayerInfo.isArray).toBe(false)
|
||||
expect(routeLayerInfo.value).toBe('#f97316')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Layer Order', () => {
|
||||
test('routes layer renders below points layer', async ({ page }) => {
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('routes') !== undefined &&
|
||||
controller?.map?.getLayer('points') !== undefined
|
||||
}, { timeout: 10000 })
|
||||
|
||||
const layerOrder = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
if (!element) return null
|
||||
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return null
|
||||
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
if (!controller?.map) return null
|
||||
|
||||
const style = controller.map.getStyle()
|
||||
const layers = style.layers || []
|
||||
|
||||
const routesIndex = layers.findIndex(l => l.id === 'routes')
|
||||
const pointsIndex = layers.findIndex(l => l.id === 'points')
|
||||
|
||||
return { routesIndex, pointsIndex }
|
||||
})
|
||||
|
||||
expect(layerOrder).toBeTruthy()
|
||||
if (layerOrder.routesIndex >= 0 && layerOrder.pointsIndex >= 0) {
|
||||
expect(layerOrder.routesIndex).toBeLessThan(layerOrder.pointsIndex)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Persistence', () => {
|
||||
test('date navigation preserves routes layer', async ({ page }) => {
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const initialRoutes = await hasLayer(page, 'routes')
|
||||
expect(initialRoutes).toBe(true)
|
||||
|
||||
await navigateToMapsV2WithDate(page, '2025-10-16T00:00', '2025-10-16T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
const hasRoutesLayer = await hasLayer(page, 'routes')
|
||||
expect(hasRoutesLayer).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
39
e2e/v2/map/layers/visits.spec.js
Normal file
39
e2e/v2/map/layers/visits.spec.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../../helpers/navigation.js'
|
||||
import { navigateToMapsV2, waitForMapLibre, waitForLoadingComplete } from '../../helpers/setup.js'
|
||||
|
||||
test.describe('Visits Layer', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
})
|
||||
|
||||
test.describe('Toggle', () => {
|
||||
test('visits layer toggle exists', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const visitsToggle = page.locator('label:has-text("Visits")').first().locator('input.toggle')
|
||||
await expect(visitsToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('can toggle visits layer', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const visitsToggle = page.locator('label:has-text("Visits")').first().locator('input.toggle')
|
||||
await visitsToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const isChecked = await visitsToggle.isChecked()
|
||||
expect(isChecked).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
66
e2e/v2/map/navigation.spec.js
Normal file
66
e2e/v2/map/navigation.spec.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../helpers/navigation.js'
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
waitForMapLibre,
|
||||
getMapZoom
|
||||
} from '../helpers/setup.js'
|
||||
|
||||
test.describe('Map Navigation', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
})
|
||||
|
||||
test.describe('Controls', () => {
|
||||
test('displays navigation controls', async ({ page }) => {
|
||||
await waitForMapLibre(page)
|
||||
|
||||
const navControls = page.locator('.maplibregl-ctrl-top-right')
|
||||
await expect(navControls).toBeVisible()
|
||||
|
||||
const zoomIn = page.locator('.maplibregl-ctrl-zoom-in')
|
||||
const zoomOut = page.locator('.maplibregl-ctrl-zoom-out')
|
||||
await expect(zoomIn).toBeVisible()
|
||||
await expect(zoomOut).toBeVisible()
|
||||
})
|
||||
|
||||
test('zooms in when clicking zoom in button', async ({ page }) => {
|
||||
await waitForMapLibre(page)
|
||||
|
||||
const initialZoom = await getMapZoom(page)
|
||||
await page.locator('.maplibregl-ctrl-zoom-in').click()
|
||||
await page.waitForTimeout(500)
|
||||
const newZoom = await getMapZoom(page)
|
||||
|
||||
expect(newZoom).toBeGreaterThan(initialZoom)
|
||||
})
|
||||
|
||||
test('zooms out when clicking zoom out button', async ({ page }) => {
|
||||
await waitForMapLibre(page)
|
||||
|
||||
// First zoom in to ensure we can zoom out
|
||||
await page.locator('.maplibregl-ctrl-zoom-in').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const initialZoom = await getMapZoom(page)
|
||||
await page.locator('.maplibregl-ctrl-zoom-out').click()
|
||||
await page.waitForTimeout(500)
|
||||
const newZoom = await getMapZoom(page)
|
||||
|
||||
expect(newZoom).toBeLessThan(initialZoom)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Date Picker', () => {
|
||||
test('displays date navigation inputs', async ({ page }) => {
|
||||
const startInput = page.locator('input[type="datetime-local"][name="start_at"]')
|
||||
const endInput = page.locator('input[type="datetime-local"][name="end_at"]')
|
||||
const searchButton = page.locator('input[type="submit"][value="Search"]')
|
||||
|
||||
await expect(startInput).toBeVisible()
|
||||
await expect(endInput).toBeVisible()
|
||||
await expect(searchButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
34
e2e/v2/map/performance.spec.js
Normal file
34
e2e/v2/map/performance.spec.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../helpers/navigation.js'
|
||||
import { waitForMapLibre, waitForLoadingComplete } from '../helpers/setup.js'
|
||||
|
||||
test.describe('Map Performance', () => {
|
||||
test('map loads within acceptable time', async ({ page }) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
|
||||
const loadTime = Date.now() - startTime
|
||||
console.log(`Map loaded in ${loadTime}ms`)
|
||||
|
||||
// Should load in less than 15 seconds (including modal, map init, data fetch)
|
||||
expect(loadTime).toBeLessThan(15000)
|
||||
})
|
||||
|
||||
test('handles large datasets efficiently', async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-01T00:00&end_at=2025-10-31T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
|
||||
const startTime = Date.now()
|
||||
await waitForLoadingComplete(page)
|
||||
const loadTime = Date.now() - startTime
|
||||
|
||||
console.log(`Large dataset loaded in ${loadTime}ms`)
|
||||
|
||||
// Should still complete reasonably quickly
|
||||
expect(loadTime).toBeLessThan(15000)
|
||||
})
|
||||
})
|
||||
202
e2e/v2/map/settings.spec.js
Normal file
202
e2e/v2/map/settings.spec.js
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../helpers/navigation.js'
|
||||
import { getLayerVisibility } from '../helpers/setup.js'
|
||||
|
||||
test.describe('Map Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
await page.waitForTimeout(2000)
|
||||
})
|
||||
|
||||
test.describe('Settings Panel', () => {
|
||||
test('opens and closes settings panel', async ({ page }) => {
|
||||
const settingsButton = page.locator('button[title="Open map settings"]')
|
||||
await settingsButton.waitFor({ state: 'visible', timeout: 5000 })
|
||||
await settingsButton.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const panel = page.locator('[data-maps-v2-target="settingsPanel"]')
|
||||
await expect(panel).toHaveClass(/open/)
|
||||
|
||||
const closeButton = page.locator('button[title="Close panel"]')
|
||||
await closeButton.click()
|
||||
await page.waitForTimeout(500)
|
||||
await expect(panel).not.toHaveClass(/open/)
|
||||
})
|
||||
|
||||
test('displays layer controls in settings', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const pointsToggle = page.locator('label:has-text("Points")').first().locator('input.toggle')
|
||||
const routesToggle = page.locator('label:has-text("Routes")').first().locator('input.toggle')
|
||||
|
||||
await expect(pointsToggle).toBeVisible()
|
||||
await expect(routesToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('has tabs for different settings sections', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const searchTab = page.locator('button[data-tab="search"]')
|
||||
const layersTab = page.locator('button[data-tab="layers"]')
|
||||
const settingsTab = page.locator('button[data-tab="settings"]')
|
||||
|
||||
await expect(searchTab).toBeVisible()
|
||||
await expect(layersTab).toBeVisible()
|
||||
await expect(settingsTab).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Layer Toggles', () => {
|
||||
test('points layer visibility matches toggle state', async ({ page }) => {
|
||||
// Wait for points layer to exist
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('points') !== undefined
|
||||
}, { timeout: 5000 }).catch(() => false)
|
||||
|
||||
const isVisible = await getLayerVisibility(page, 'points')
|
||||
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const pointsToggle = page.locator('label:has-text("Points")').first().locator('input.toggle')
|
||||
const toggleState = await pointsToggle.isChecked()
|
||||
|
||||
expect(isVisible).toBe(toggleState)
|
||||
})
|
||||
|
||||
test('routes layer visibility matches toggle state', async ({ page }) => {
|
||||
// Wait for routes layer to exist
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('routes') !== undefined
|
||||
}, { timeout: 5000 }).catch(() => false)
|
||||
|
||||
const isVisible = await getLayerVisibility(page, 'routes')
|
||||
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const routesToggle = page.locator('label:has-text("Routes")').first().locator('input.toggle')
|
||||
const toggleState = await routesToggle.isChecked()
|
||||
|
||||
expect(isVisible).toBe(toggleState)
|
||||
})
|
||||
|
||||
test('can toggle points layer', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const pointsLabel = page.locator('label:has-text("Points")').first()
|
||||
const pointsToggle = pointsLabel.locator('input.toggle')
|
||||
|
||||
const initialState = await pointsToggle.isChecked()
|
||||
|
||||
await pointsLabel.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const newState = await pointsToggle.isChecked()
|
||||
expect(newState).toBe(!initialState)
|
||||
|
||||
await pointsLabel.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const finalState = await pointsToggle.isChecked()
|
||||
expect(finalState).toBe(initialState)
|
||||
})
|
||||
|
||||
test('can toggle routes layer', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const routesLabel = page.locator('label:has-text("Routes")').first()
|
||||
const routesToggle = routesLabel.locator('input.toggle')
|
||||
|
||||
const initialState = await routesToggle.isChecked()
|
||||
|
||||
await routesLabel.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const newState = await routesToggle.isChecked()
|
||||
expect(newState).toBe(!initialState)
|
||||
})
|
||||
|
||||
test('multiple layers can be toggled simultaneously', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const pointsToggle = page.locator('label:has-text("Points")').first().locator('input.toggle')
|
||||
const routesToggle = page.locator('label:has-text("Routes")').first().locator('input.toggle')
|
||||
|
||||
if (!(await pointsToggle.isChecked())) {
|
||||
await pointsToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
if (!(await routesToggle.isChecked())) {
|
||||
await routesToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
const pointsVisible = await getLayerVisibility(page, 'points')
|
||||
const routesVisible = await getLayerVisibility(page, 'routes')
|
||||
|
||||
expect(pointsVisible).toBe(true)
|
||||
expect(routesVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings Persistence', () => {
|
||||
test('layer toggle state persists in localStorage', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const pointsToggle = page.locator('label:has-text("Points")').first().locator('input.toggle')
|
||||
const initialState = await pointsToggle.isChecked()
|
||||
|
||||
const settings = await page.evaluate(() => {
|
||||
return localStorage.getItem('dawarich-maps-v2-settings')
|
||||
})
|
||||
|
||||
expect(settings).toBeTruthy()
|
||||
|
||||
const parsed = JSON.parse(settings)
|
||||
expect(parsed).toHaveProperty('pointsVisible')
|
||||
expect(parsed.pointsVisible).toBe(initialState)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Advanced Settings', () => {
|
||||
test('displays advanced settings options', async ({ page }) => {
|
||||
await page.click('button[title="Open map settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
await page.click('button[data-tab="settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const panel = page.locator('[data-tab-content="settings"]')
|
||||
await expect(panel).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,314 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { closeOnboardingModal } from '../helpers/navigation.js';
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
navigateToMapsV2WithDate,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete,
|
||||
hasMapInstance,
|
||||
getMapZoom,
|
||||
getMapCenter,
|
||||
getPointsSourceData,
|
||||
hasLayer,
|
||||
clickMapAt,
|
||||
hasPopup
|
||||
} from './helpers/setup.js';
|
||||
|
||||
test.describe('Phase 1: MVP - Basic Map with Points', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to Maps V2 page
|
||||
await navigateToMapsV2(page);
|
||||
await closeOnboardingModal(page);
|
||||
});
|
||||
|
||||
test('should load map container', async ({ page }) => {
|
||||
const mapContainer = page.locator('[data-maps-v2-target="container"]');
|
||||
await expect(mapContainer).toBeVisible();
|
||||
});
|
||||
|
||||
test('should initialize MapLibre map', async ({ page }) => {
|
||||
// Wait for map to load
|
||||
await waitForMapLibre(page);
|
||||
|
||||
// Verify MapLibre canvas is present
|
||||
const canvas = page.locator('.maplibregl-canvas');
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
// Verify map instance exists
|
||||
const hasMap = await hasMapInstance(page);
|
||||
expect(hasMap).toBe(true);
|
||||
});
|
||||
|
||||
test('should display navigation controls', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
|
||||
// Verify navigation controls are present
|
||||
const navControls = page.locator('.maplibregl-ctrl-top-right');
|
||||
await expect(navControls).toBeVisible();
|
||||
|
||||
// Verify zoom controls
|
||||
const zoomIn = page.locator('.maplibregl-ctrl-zoom-in');
|
||||
const zoomOut = page.locator('.maplibregl-ctrl-zoom-out');
|
||||
await expect(zoomIn).toBeVisible();
|
||||
await expect(zoomOut).toBeVisible();
|
||||
});
|
||||
|
||||
test('should display date navigation', async ({ page }) => {
|
||||
// Verify date inputs are present
|
||||
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
|
||||
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
|
||||
const searchButton = page.locator('input[type="submit"][value="Search"]');
|
||||
|
||||
await expect(startInput).toBeVisible();
|
||||
await expect(endInput).toBeVisible();
|
||||
await expect(searchButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show loading indicator during data fetch', async ({ page }) => {
|
||||
const loading = page.locator('[data-maps-v2-target="loading"]');
|
||||
|
||||
// Start navigation without waiting
|
||||
const navigationPromise = page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Check that loading appears (it should be visible during data fetch)
|
||||
// We wait up to 1 second for it to appear - if data loads too fast, we skip this check
|
||||
const loadingVisible = await loading.evaluate((el) => !el.classList.contains('hidden'))
|
||||
.catch(() => false);
|
||||
|
||||
// Wait for navigation to complete
|
||||
await navigationPromise;
|
||||
await closeOnboardingModal(page);
|
||||
|
||||
// Wait for loading to hide
|
||||
await waitForLoadingComplete(page);
|
||||
await expect(loading).toHaveClass(/hidden/);
|
||||
});
|
||||
|
||||
test('should load and display points on map', async ({ page }) => {
|
||||
// Navigate to specific date with known data (same as existing map tests)
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59');
|
||||
|
||||
// navigateToMapsV2WithDate already waits for loading to complete
|
||||
// Wait for style to load and layers to be added
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
return controller?.map?.getSource('points-source') !== undefined;
|
||||
}, { timeout: 15000 }).catch(() => {
|
||||
console.log('Timeout waiting for points source');
|
||||
return false;
|
||||
});
|
||||
|
||||
// Check if points source exists and has data
|
||||
const sourceData = await getPointsSourceData(page);
|
||||
expect(sourceData.hasSource).toBe(true);
|
||||
expect(sourceData.featureCount).toBeGreaterThan(0);
|
||||
|
||||
console.log(`Loaded ${sourceData.featureCount} points on map`);
|
||||
});
|
||||
|
||||
test('should display points layers (clusters, counts, individual points)', async ({ page }) => {
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Check for all three point layers
|
||||
const hasClusters = await hasLayer(page, 'points-clusters');
|
||||
const hasCount = await hasLayer(page, 'points-count');
|
||||
const hasPoints = await hasLayer(page, 'points');
|
||||
|
||||
expect(hasClusters).toBe(true);
|
||||
expect(hasCount).toBe(true);
|
||||
expect(hasPoints).toBe(true);
|
||||
});
|
||||
|
||||
test('should zoom in when clicking zoom in button', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
|
||||
const initialZoom = await getMapZoom(page);
|
||||
await page.locator('.maplibregl-ctrl-zoom-in').click();
|
||||
await page.waitForTimeout(500);
|
||||
const newZoom = await getMapZoom(page);
|
||||
|
||||
expect(newZoom).toBeGreaterThan(initialZoom);
|
||||
});
|
||||
|
||||
test('should zoom out when clicking zoom out button', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
|
||||
// First zoom in to make sure we can zoom out
|
||||
await page.locator('.maplibregl-ctrl-zoom-in').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const initialZoom = await getMapZoom(page);
|
||||
await page.locator('.maplibregl-ctrl-zoom-out').click();
|
||||
await page.waitForTimeout(500);
|
||||
const newZoom = await getMapZoom(page);
|
||||
|
||||
expect(newZoom).toBeLessThan(initialZoom);
|
||||
});
|
||||
|
||||
test('should fit map bounds to data', async ({ page }) => {
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59');
|
||||
|
||||
// navigateToMapsV2WithDate already waits for loading
|
||||
// Give a bit more time for fitBounds to complete
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Get map zoom level (should be > 2 if fitBounds worked)
|
||||
const zoom = await getMapZoom(page);
|
||||
expect(zoom).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
test('should show popup when clicking on point', async ({ page }) => {
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Wait a bit for points to render
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Try clicking at different positions to find a point
|
||||
const positions = [
|
||||
{ x: 400, y: 300 },
|
||||
{ x: 500, y: 300 },
|
||||
{ x: 600, y: 400 },
|
||||
{ x: 350, y: 250 }
|
||||
];
|
||||
|
||||
let popupFound = false;
|
||||
for (const pos of positions) {
|
||||
try {
|
||||
await clickMapAt(page, pos.x, pos.y);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
if (await hasPopup(page)) {
|
||||
popupFound = true;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Click might fail if map is still loading or covered
|
||||
console.log(`Click at ${pos.x},${pos.y} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a popup, verify its content
|
||||
if (popupFound) {
|
||||
const popup = page.locator('.maplibregl-popup');
|
||||
await expect(popup).toBeVisible();
|
||||
|
||||
// Verify popup has point information
|
||||
const popupContent = page.locator('.point-popup');
|
||||
await expect(popupContent).toBeVisible();
|
||||
|
||||
console.log('Successfully clicked a point and showed popup');
|
||||
} else {
|
||||
console.log('No point clicked (might be expected if points are clustered or sparse)');
|
||||
// Don't fail the test - points might be clustered or not at exact positions
|
||||
}
|
||||
});
|
||||
|
||||
test('should change cursor on hover over points', async ({ page }) => {
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Check if cursor changes when hovering over map
|
||||
// Note: This is a basic check; actual cursor change happens on point hover
|
||||
const mapContainer = page.locator('[data-maps-v2-target="container"]');
|
||||
await expect(mapContainer).toBeVisible();
|
||||
});
|
||||
|
||||
test('should reload data when changing date range', async ({ page }) => {
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59');
|
||||
await closeOnboardingModal(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Verify initial data loaded
|
||||
const initialData = await getPointsSourceData(page);
|
||||
expect(initialData.hasSource).toBe(true);
|
||||
const initialCount = initialData.featureCount;
|
||||
|
||||
// Get initial date inputs
|
||||
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
|
||||
const initialStartDate = await startInput.inputValue();
|
||||
|
||||
// Change date range - with Turbo this might not cause full page reload
|
||||
await navigateToMapsV2WithDate(page, '2024-10-14T00:00', '2024-10-14T23:59');
|
||||
await closeOnboardingModal(page);
|
||||
|
||||
// Wait for map to reload/reinitialize
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Verify date input changed (proving form submission worked)
|
||||
const newStartDate = await startInput.inputValue();
|
||||
expect(newStartDate).not.toBe(initialStartDate);
|
||||
|
||||
// Verify map still works
|
||||
const hasMap = await hasMapInstance(page);
|
||||
expect(hasMap).toBe(true);
|
||||
|
||||
console.log(`Date changed from ${initialStartDate} to ${newStartDate}`);
|
||||
});
|
||||
|
||||
test('should handle empty data gracefully', async ({ page }) => {
|
||||
// Navigate to a date range with likely no data
|
||||
await navigateToMapsV2WithDate(page, '2020-01-01T00:00', '2020-01-01T23:59');
|
||||
await closeOnboardingModal(page);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitForLoadingComplete(page);
|
||||
await page.waitForTimeout(500); // Give sources time to initialize
|
||||
|
||||
// Map should still work with empty data
|
||||
const hasMap = await hasMapInstance(page);
|
||||
expect(hasMap).toBe(true);
|
||||
|
||||
// Check if source exists - it may or may not depending on timing
|
||||
const sourceData = await getPointsSourceData(page);
|
||||
// If source exists, it should have 0 features for this date range
|
||||
if (sourceData.hasSource) {
|
||||
expect(sourceData.featureCount).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('should have valid map center and zoom', async ({ page }) => {
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
const center = await getMapCenter(page);
|
||||
const zoom = await getMapZoom(page);
|
||||
|
||||
// Verify valid coordinates
|
||||
expect(center).not.toBeNull();
|
||||
expect(center.lng).toBeGreaterThan(-180);
|
||||
expect(center.lng).toBeLessThan(180);
|
||||
expect(center.lat).toBeGreaterThan(-90);
|
||||
expect(center.lat).toBeLessThan(90);
|
||||
|
||||
// Verify valid zoom level
|
||||
expect(zoom).toBeGreaterThan(0);
|
||||
expect(zoom).toBeLessThan(20);
|
||||
|
||||
console.log(`Map center: ${center.lat}, ${center.lng}, zoom: ${zoom}`);
|
||||
});
|
||||
|
||||
test('should cleanup map on disconnect', async ({ page }) => {
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59');
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Navigate away
|
||||
await page.goto('/');
|
||||
|
||||
// Wait a bit for cleanup
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Navigate back
|
||||
await navigateToMapsV2(page);
|
||||
await closeOnboardingModal(page);
|
||||
|
||||
// Map should reinitialize properly
|
||||
await waitForMapLibre(page);
|
||||
const hasMap = await hasMapInstance(page);
|
||||
expect(hasMap).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { closeOnboardingModal } from '../helpers/navigation.js';
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
navigateToMapsV2WithDate,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete,
|
||||
hasLayer,
|
||||
getLayerVisibility,
|
||||
getRoutesSourceData
|
||||
} from './helpers/setup.js';
|
||||
|
||||
test.describe('Phase 2: Routes + Layer Controls', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate directly with URL parameters to date range with data
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59');
|
||||
await closeOnboardingModal(page);
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
// Give extra time for routes layer to be added after points (needs time for style.load event)
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test('routes layer exists on map', async ({ page }) => {
|
||||
// Wait for routes layer to be added (it's added after points layer)
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
if (!element) return false;
|
||||
const app = window.Stimulus || window.Application;
|
||||
if (!app) return false;
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
return controller?.map?.getLayer('routes') !== undefined;
|
||||
}, { timeout: 10000 }).catch(() => false);
|
||||
|
||||
// Check if routes layer exists
|
||||
const hasRoutesLayer = await hasLayer(page, 'routes');
|
||||
expect(hasRoutesLayer).toBe(true);
|
||||
});
|
||||
|
||||
test('routes source has data', async ({ page }) => {
|
||||
// Wait for routes layer to be added with longer timeout
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
if (!element) return false;
|
||||
const app = window.Stimulus || window.Application;
|
||||
if (!app) return false;
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
return controller?.map?.getSource('routes-source') !== undefined;
|
||||
}, { timeout: 20000 });
|
||||
|
||||
const { hasSource, featureCount } = await getRoutesSourceData(page);
|
||||
|
||||
expect(hasSource).toBe(true);
|
||||
// Should have at least one route if there are points
|
||||
expect(featureCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('routes have LineString geometry', async ({ page }) => {
|
||||
const { features } = await getRoutesSourceData(page);
|
||||
|
||||
if (features.length > 0) {
|
||||
features.forEach(feature => {
|
||||
expect(feature.geometry.type).toBe('LineString');
|
||||
expect(feature.geometry.coordinates.length).toBeGreaterThan(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('routes have distance properties', async ({ page }) => {
|
||||
const { features } = await getRoutesSourceData(page);
|
||||
|
||||
if (features.length > 0) {
|
||||
features.forEach(feature => {
|
||||
expect(feature.properties).toHaveProperty('distance');
|
||||
expect(typeof feature.properties.distance).toBe('number');
|
||||
expect(feature.properties.distance).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('routes have solid color (not speed-based)', async ({ page }) => {
|
||||
// Wait for routes layer to be added with longer timeout
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
if (!element) return false;
|
||||
const app = window.Stimulus || window.Application;
|
||||
if (!app) return false;
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
return controller?.map?.getLayer('routes') !== undefined;
|
||||
}, { timeout: 20000 });
|
||||
|
||||
const routeLayerInfo = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
if (!element) return null;
|
||||
|
||||
const app = window.Stimulus || window.Application;
|
||||
if (!app) return null;
|
||||
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
if (!controller?.map) return null;
|
||||
|
||||
const layer = controller.map.getLayer('routes');
|
||||
if (!layer) return null;
|
||||
|
||||
// Get paint property using MapLibre's getPaintProperty method
|
||||
const lineColor = controller.map.getPaintProperty('routes', 'line-color');
|
||||
|
||||
return {
|
||||
exists: !!lineColor,
|
||||
isArray: Array.isArray(lineColor),
|
||||
value: lineColor
|
||||
};
|
||||
});
|
||||
|
||||
expect(routeLayerInfo).toBeTruthy();
|
||||
expect(routeLayerInfo.exists).toBe(true);
|
||||
// Should NOT be a speed-based interpolation array
|
||||
expect(routeLayerInfo.isArray).toBe(false);
|
||||
// Should be orange color
|
||||
expect(routeLayerInfo.value).toBe('#f97316');
|
||||
});
|
||||
|
||||
test('layer controls are visible', async ({ page }) => {
|
||||
const pointsButton = page.locator('button[data-layer="points"]');
|
||||
const routesButton = page.locator('button[data-layer="routes"]');
|
||||
|
||||
await expect(pointsButton).toBeVisible();
|
||||
await expect(routesButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('points layer starts visible', async ({ page }) => {
|
||||
const isVisible = await getLayerVisibility(page, 'points');
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
test('routes layer starts visible', async ({ page }) => {
|
||||
const isVisible = await getLayerVisibility(page, 'routes');
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
test('can toggle points layer off and on', async ({ page }) => {
|
||||
const pointsButton = page.locator('button[data-layer="points"]');
|
||||
|
||||
// Initially visible
|
||||
let isVisible = await getLayerVisibility(page, 'points');
|
||||
expect(isVisible).toBe(true);
|
||||
|
||||
// Toggle off and wait for visibility to change
|
||||
await pointsButton.click();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
const visibility = controller?.map?.getLayoutProperty('points', 'visibility');
|
||||
return visibility === 'none';
|
||||
}, { timeout: 2000 }).catch(() => {});
|
||||
|
||||
isVisible = await getLayerVisibility(page, 'points');
|
||||
expect(isVisible).toBe(false);
|
||||
|
||||
// Toggle back on
|
||||
await pointsButton.click();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
const visibility = controller?.map?.getLayoutProperty('points', 'visibility');
|
||||
return visibility === 'visible' || visibility === undefined;
|
||||
}, { timeout: 2000 }).catch(() => {});
|
||||
|
||||
isVisible = await getLayerVisibility(page, 'points');
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
test('can toggle routes layer off and on', async ({ page }) => {
|
||||
// Wait for routes layer to exist first
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
return controller?.map?.getLayer('routes') !== undefined;
|
||||
}, { timeout: 10000 }).catch(() => false);
|
||||
|
||||
const routesButton = page.locator('button[data-layer="routes"]');
|
||||
|
||||
// Initially visible
|
||||
let isVisible = await getLayerVisibility(page, 'routes');
|
||||
expect(isVisible).toBe(true);
|
||||
|
||||
// Toggle off and wait for visibility to change
|
||||
await routesButton.click();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
const visibility = controller?.map?.getLayoutProperty('routes', 'visibility');
|
||||
return visibility === 'none';
|
||||
}, { timeout: 2000 }).catch(() => {});
|
||||
|
||||
isVisible = await getLayerVisibility(page, 'routes');
|
||||
expect(isVisible).toBe(false);
|
||||
|
||||
// Toggle back on
|
||||
await routesButton.click();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
const visibility = controller?.map?.getLayoutProperty('routes', 'visibility');
|
||||
return visibility === 'visible' || visibility === undefined;
|
||||
}, { timeout: 2000 }).catch(() => {});
|
||||
|
||||
isVisible = await getLayerVisibility(page, 'routes');
|
||||
expect(isVisible).toBe(true);
|
||||
});
|
||||
|
||||
test('layer toggle button styles change with visibility', async ({ page }) => {
|
||||
const pointsButton = page.locator('button[data-layer="points"]');
|
||||
|
||||
// Initially should have btn-primary
|
||||
await expect(pointsButton).toHaveClass(/btn-primary/);
|
||||
|
||||
// Click to toggle off
|
||||
await pointsButton.click();
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Should now have btn-outline
|
||||
await expect(pointsButton).toHaveClass(/btn-outline/);
|
||||
|
||||
// Click to toggle back on
|
||||
await pointsButton.click();
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Should have btn-primary again
|
||||
await expect(pointsButton).toHaveClass(/btn-primary/);
|
||||
});
|
||||
|
||||
test('both layers can be visible simultaneously', async ({ page }) => {
|
||||
const pointsVisible = await getLayerVisibility(page, 'points');
|
||||
const routesVisible = await getLayerVisibility(page, 'routes');
|
||||
|
||||
expect(pointsVisible).toBe(true);
|
||||
expect(routesVisible).toBe(true);
|
||||
});
|
||||
|
||||
test('both layers can be hidden simultaneously', async ({ page }) => {
|
||||
const pointsButton = page.locator('button[data-layer="points"]');
|
||||
const routesButton = page.locator('button[data-layer="routes"]');
|
||||
|
||||
// Toggle points off and wait
|
||||
await pointsButton.click();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
const visibility = controller?.map?.getLayoutProperty('points', 'visibility');
|
||||
return visibility === 'none';
|
||||
}, { timeout: 2000 }).catch(() => {});
|
||||
|
||||
// Toggle routes off and wait
|
||||
await routesButton.click();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
const visibility = controller?.map?.getLayoutProperty('routes', 'visibility');
|
||||
return visibility === 'none';
|
||||
}, { timeout: 2000 }).catch(() => {});
|
||||
|
||||
const pointsVisible = await getLayerVisibility(page, 'points');
|
||||
const routesVisible = await getLayerVisibility(page, 'routes');
|
||||
|
||||
expect(pointsVisible).toBe(false);
|
||||
expect(routesVisible).toBe(false);
|
||||
});
|
||||
|
||||
test('date navigation preserves routes layer', async ({ page }) => {
|
||||
// Wait for routes layer to be added first
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify routes exist initially
|
||||
const initialRoutes = await hasLayer(page, 'routes');
|
||||
expect(initialRoutes).toBe(true);
|
||||
|
||||
// Navigate to a different date with known data (Oct 16 instead of Oct 15)
|
||||
await navigateToMapsV2WithDate(page, '2025-10-16T00:00', '2025-10-16T23:59');
|
||||
await closeOnboardingModal(page);
|
||||
|
||||
// Wait for map to fully reload
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Verify routes layer still exists after navigation
|
||||
const hasRoutesLayer = await hasLayer(page, 'routes');
|
||||
expect(hasRoutesLayer).toBe(true);
|
||||
});
|
||||
|
||||
test('routes connect points chronologically', async ({ page }) => {
|
||||
const { features } = await getRoutesSourceData(page);
|
||||
|
||||
if (features.length > 0) {
|
||||
features.forEach(feature => {
|
||||
// Each route should have start and end times
|
||||
expect(feature.properties).toHaveProperty('startTime');
|
||||
expect(feature.properties).toHaveProperty('endTime');
|
||||
|
||||
// End time should be after start time
|
||||
expect(feature.properties.endTime).toBeGreaterThanOrEqual(feature.properties.startTime);
|
||||
|
||||
// Should have point count
|
||||
expect(feature.properties).toHaveProperty('pointCount');
|
||||
expect(feature.properties.pointCount).toBeGreaterThan(1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('routes layer renders below points layer', async ({ page }) => {
|
||||
// Wait for both layers to exist
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
const app = window.Stimulus || window.Application;
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
return controller?.map?.getLayer('routes') !== undefined &&
|
||||
controller?.map?.getLayer('points') !== undefined;
|
||||
}, { timeout: 10000 });
|
||||
|
||||
// Get layer order - routes should be added before points
|
||||
const layerOrder = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]');
|
||||
if (!element) return null;
|
||||
|
||||
const app = window.Stimulus || window.Application;
|
||||
if (!app) return null;
|
||||
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2');
|
||||
if (!controller?.map) return null;
|
||||
|
||||
const style = controller.map.getStyle();
|
||||
const layers = style.layers || [];
|
||||
|
||||
const routesIndex = layers.findIndex(l => l.id === 'routes');
|
||||
const pointsIndex = layers.findIndex(l => l.id === 'points');
|
||||
|
||||
return { routesIndex, pointsIndex };
|
||||
});
|
||||
|
||||
expect(layerOrder).toBeTruthy();
|
||||
// Routes should come before points in layer order (lower index = rendered first/below)
|
||||
if (layerOrder.routesIndex >= 0 && layerOrder.pointsIndex >= 0) {
|
||||
expect(layerOrder.routesIndex).toBeLessThan(layerOrder.pointsIndex);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { navigateToMapsV2, navigateToMapsV2WithDate, waitForMapLibre, waitForLoadingComplete } from './helpers/setup'
|
||||
import { closeOnboardingModal } from '../helpers/navigation'
|
||||
|
||||
test.describe('Phase 3: Heatmap + Settings', () => {
|
||||
// Use serial mode to avoid overwhelming the system with parallel requests
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate with a date that has data
|
||||
await page.goto('/maps_v2?start_at=2025-10-15T00:00&end_at=2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
|
||||
// Wait for map with retry logic
|
||||
try {
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
} catch (error) {
|
||||
console.log('Map loading timeout, waiting and retrying...')
|
||||
await page.waitForTimeout(2000)
|
||||
// Try one more time
|
||||
await waitForLoadingComplete(page).catch(() => {
|
||||
console.log('Second attempt also timed out, continuing anyway...')
|
||||
})
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000) // Give layers time to initialize
|
||||
})
|
||||
|
||||
test.describe('Heatmap Layer', () => {
|
||||
test('heatmap layer can be created', async ({ page }) => {
|
||||
// Heatmap layer might not exist by default, but should be creatable
|
||||
// Open settings panel
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Switch to Layers tab
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Find and toggle heatmap using DaisyUI toggle
|
||||
const heatmapLabel = page.locator('label:has-text("Heatmap")').first()
|
||||
const heatmapToggle = heatmapLabel.locator('input.toggle')
|
||||
await heatmapToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check if heatmap layer now exists
|
||||
const hasHeatmap = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
if (!element) return false
|
||||
const app = window.Stimulus || window.Application
|
||||
if (!app) return false
|
||||
const controller = app.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('heatmap') !== undefined
|
||||
})
|
||||
|
||||
expect(hasHeatmap).toBe(true)
|
||||
})
|
||||
|
||||
test('heatmap can be toggled', async ({ page }) => {
|
||||
// Open settings panel
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Switch to Layers tab
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Toggle heatmap on - find toggle by its label text
|
||||
const heatmapLabel = page.locator('label:has-text("Heatmap")').first()
|
||||
const heatmapToggle = heatmapLabel.locator('input.toggle')
|
||||
await heatmapToggle.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
const visibility = controller?.map?.getLayoutProperty('heatmap', 'visibility')
|
||||
return visibility === 'visible' || visibility === undefined
|
||||
})
|
||||
|
||||
expect(isVisible).toBe(true)
|
||||
})
|
||||
|
||||
test('heatmap setting persists', async ({ page }) => {
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Switch to Layers tab
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const heatmapToggle = page.locator('label:has-text("Heatmap")').first().locator('input.toggle')
|
||||
await heatmapToggle.check()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Check localStorage
|
||||
const savedSetting = await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem('dawarich-maps-v2-settings') || '{}')
|
||||
return settings.heatmapEnabled
|
||||
})
|
||||
|
||||
expect(savedSetting).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings Panel', () => {
|
||||
test('settings panel opens and closes', async ({ page }) => {
|
||||
const settingsBtn = page.locator('button[title="Settings"]')
|
||||
await settingsBtn.click()
|
||||
|
||||
// Wait for panel to open (animation takes 300ms)
|
||||
const panel = page.locator('.map-control-panel')
|
||||
await page.waitForTimeout(400)
|
||||
await expect(panel).toHaveClass(/open/)
|
||||
|
||||
// Close the panel using the close button
|
||||
await page.click('.panel-header button[title="Close panel"]')
|
||||
|
||||
// Wait for panel close animation
|
||||
await page.waitForTimeout(400)
|
||||
await expect(panel).not.toHaveClass(/open/)
|
||||
})
|
||||
|
||||
test('tab switching works', async ({ page }) => {
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Check default tab is Search
|
||||
const searchTab = page.locator('[data-tab-content="search"]')
|
||||
await expect(searchTab).toHaveClass(/active/)
|
||||
|
||||
// Switch to Layers tab
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const layersTab = page.locator('[data-tab-content="layers"]')
|
||||
await expect(layersTab).toHaveClass(/active/)
|
||||
await expect(searchTab).not.toHaveClass(/active/)
|
||||
|
||||
// Switch to Settings tab
|
||||
await page.click('button[data-tab="settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const settingsTab = page.locator('[data-tab-content="settings"]')
|
||||
await expect(settingsTab).toHaveClass(/active/)
|
||||
await expect(layersTab).not.toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test('map style can be changed', async ({ page }) => {
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Switch to Settings tab
|
||||
await page.click('button[data-tab="settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const styleSelect = page.locator('select.select-bordered').first()
|
||||
await styleSelect.selectOption('dark')
|
||||
|
||||
// Wait for style to load
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
const savedStyle = await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem('dawarich-maps-v2-settings') || '{}')
|
||||
return settings.mapStyle
|
||||
})
|
||||
|
||||
expect(savedStyle).toBe('dark')
|
||||
})
|
||||
|
||||
test('settings persist across page loads', async ({ page }) => {
|
||||
// Change a setting
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Switch to Layers tab
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const heatmapToggle = page.locator('label:has-text("Heatmap")').first().locator('input.toggle')
|
||||
await heatmapToggle.check()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Reload page
|
||||
await page.reload()
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
|
||||
// Check if setting persisted
|
||||
const savedSetting = await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem('dawarich-maps-v2-settings') || '{}')
|
||||
return settings.heatmapEnabled
|
||||
})
|
||||
|
||||
expect(savedSetting).toBe(true)
|
||||
})
|
||||
|
||||
test('reset to defaults works', async ({ page }) => {
|
||||
// Change settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Switch to Settings tab
|
||||
await page.click('button[data-tab="settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await page.locator('select.select-bordered').first().selectOption('dark')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Switch to Layers tab to enable heatmap
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const heatmapToggle = page.locator('label:has-text("Heatmap")').first().locator('input.toggle')
|
||||
await heatmapToggle.check()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Switch back to Settings tab
|
||||
await page.click('button[data-tab="settings"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Setup dialog handler before clicking reset
|
||||
page.on('dialog', dialog => dialog.accept())
|
||||
|
||||
// Reset - this will reload the page
|
||||
await page.click('.btn-outline:has-text("Reset to Defaults")')
|
||||
|
||||
// Wait for page reload
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
|
||||
// Check defaults restored (localStorage should be empty)
|
||||
const settings = await page.evaluate(() => {
|
||||
const stored = localStorage.getItem('dawarich-maps-v2-settings')
|
||||
return stored ? JSON.parse(stored) : null
|
||||
})
|
||||
|
||||
// After reset, localStorage should be null or empty
|
||||
expect(settings).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Regression Tests', () => {
|
||||
test('points layer still works', async ({ page }) => {
|
||||
// Wait for points source to be available
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getSource('points-source') !== undefined
|
||||
}, { timeout: 10000 })
|
||||
|
||||
const hasPoints = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
const source = controller?.map?.getSource('points-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
})
|
||||
|
||||
expect(hasPoints).toBe(true)
|
||||
})
|
||||
|
||||
test('routes layer still works', async ({ page }) => {
|
||||
// Wait for routes source to be available
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getSource('routes-source') !== undefined
|
||||
}, { timeout: 10000 })
|
||||
|
||||
const hasRoutes = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
const source = controller?.map?.getSource('routes-source')
|
||||
return source && source._data?.features?.length > 0
|
||||
})
|
||||
|
||||
expect(hasRoutes).toBe(true)
|
||||
})
|
||||
|
||||
test('layer toggle still works', async ({ page }) => {
|
||||
// Just verify settings panel has layer toggles
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Switch to Layers tab
|
||||
await page.click('button[data-tab="layers"]')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Check that settings panel is open
|
||||
const settingsPanel = page.locator('.map-control-panel.open')
|
||||
await expect(settingsPanel).toBeVisible()
|
||||
|
||||
// Check that DaisyUI toggles exist (any layer toggle)
|
||||
const toggles = page.locator('input.toggle')
|
||||
const count = await toggles.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../helpers/navigation'
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete,
|
||||
hasLayer
|
||||
} from './helpers/setup'
|
||||
|
||||
test.describe('Phase 4: Visits + Photos', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
})
|
||||
|
||||
test.describe('Visits Layer', () => {
|
||||
test('visits layer toggle exists', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const visitsToggle = page.locator('label.setting-checkbox:has-text("Show Visits")')
|
||||
await expect(visitsToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('can toggle visits layer in settings', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Toggle visits
|
||||
const visitsCheckbox = page.locator('label.setting-checkbox:has-text("Show Visits")').locator('input[type="checkbox"]')
|
||||
await visitsCheckbox.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify checkbox is checked
|
||||
const isChecked = await visitsCheckbox.isChecked()
|
||||
expect(isChecked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Photos Layer', () => {
|
||||
test('photos layer toggle exists', async ({ page }) => {
|
||||
// Photos now use HTML markers, not MapLibre layers
|
||||
// Just check the settings toggle exists
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const photosToggle = page.locator('label.setting-checkbox:has-text("Show Photos")')
|
||||
await expect(photosToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('photos layer starts hidden', async ({ page }) => {
|
||||
// Photos use HTML markers - check if they are hidden
|
||||
const photoMarkers = page.locator('.photo-marker')
|
||||
const count = await photoMarkers.count()
|
||||
|
||||
if (count > 0) {
|
||||
// If markers exist, check they're hidden
|
||||
const firstMarker = photoMarkers.first()
|
||||
const isHidden = await firstMarker.evaluate(el =>
|
||||
el.parentElement.style.display === 'none'
|
||||
)
|
||||
expect(isHidden).toBe(true)
|
||||
} else {
|
||||
// If no markers, that's also fine (no photos in test data)
|
||||
expect(count).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
test('can toggle photos layer in settings', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Toggle photos
|
||||
const photosCheckbox = page.locator('label.setting-checkbox:has-text("Show Photos")').locator('input[type="checkbox"]')
|
||||
await photosCheckbox.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify checkbox is checked
|
||||
const isChecked = await photosCheckbox.isChecked()
|
||||
expect(isChecked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Visits Search', () => {
|
||||
test('visits search input exists', async ({ page }) => {
|
||||
// Just check the search input exists in DOM
|
||||
const searchInput = page.locator('#visits-search')
|
||||
await expect(searchInput).toBeAttached()
|
||||
})
|
||||
|
||||
test('can search visits', async ({ page }) => {
|
||||
// Open settings and enable visits
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const visitsCheckbox = page.locator('label.setting-checkbox:has-text("Show Visits")').locator('input[type="checkbox"]')
|
||||
await visitsCheckbox.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Wait for search input to be visible
|
||||
const searchInput = page.locator('#visits-search')
|
||||
await expect(searchInput).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Search
|
||||
await searchInput.fill('test')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify search was applied (filter should have run)
|
||||
const searchValue = await searchInput.inputValue()
|
||||
expect(searchValue).toBe('test')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Regression Tests', () => {
|
||||
test('all previous layers still work', async ({ page }) => {
|
||||
// Just verify the settings panel opens
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Check settings panel is open
|
||||
const settingsPanel = page.locator('.settings-panel.open')
|
||||
await expect(settingsPanel).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../helpers/navigation'
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete,
|
||||
hasLayer
|
||||
} from './helpers/setup'
|
||||
|
||||
test.describe('Phase 5: Areas + Drawing Tools', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
})
|
||||
|
||||
test.describe('Areas Layer', () => {
|
||||
test.skip('areas layer exists on map (requires test data)', async ({ page }) => {
|
||||
// NOTE: This test requires areas to be created in the test database
|
||||
// Layer is only added when areas data is available
|
||||
const hasAreasLayer = await hasLayer(page, 'areas-fill')
|
||||
expect(hasAreasLayer).toBe(true)
|
||||
})
|
||||
|
||||
test('areas layer starts hidden', async ({ page }) => {
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
const visibility = controller?.map?.getLayoutProperty('areas-fill', 'visibility')
|
||||
return visibility === 'visible'
|
||||
})
|
||||
|
||||
expect(isVisible).toBe(false)
|
||||
})
|
||||
|
||||
test('can toggle areas layer in settings', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Toggle areas
|
||||
const areasCheckbox = page.locator('label.setting-checkbox:has-text("Show Areas")').locator('input[type="checkbox"]')
|
||||
await areasCheckbox.check()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Check visibility
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
const visibility = controller?.map?.getLayoutProperty('areas-fill', 'visibility')
|
||||
return visibility === 'visible' || visibility === undefined
|
||||
})
|
||||
|
||||
expect(isVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Tracks Layer', () => {
|
||||
test.skip('tracks layer exists on map (requires backend API)', async ({ page }) => {
|
||||
// NOTE: Tracks API endpoint (/api/v1/tracks) doesn't exist yet
|
||||
// This is a future enhancement
|
||||
const hasTracksLayer = await hasLayer(page, 'tracks')
|
||||
expect(hasTracksLayer).toBe(true)
|
||||
})
|
||||
|
||||
test('tracks layer starts hidden', async ({ page }) => {
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
const visibility = controller?.map?.getLayoutProperty('tracks', 'visibility')
|
||||
return visibility === 'visible'
|
||||
})
|
||||
|
||||
expect(isVisible).toBe(false)
|
||||
})
|
||||
|
||||
test('can toggle tracks layer in settings', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Toggle tracks
|
||||
const tracksCheckbox = page.locator('label.setting-checkbox:has-text("Show Tracks")').locator('input[type="checkbox"]')
|
||||
await tracksCheckbox.check()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Check visibility
|
||||
const isVisible = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
const visibility = controller?.map?.getLayoutProperty('tracks', 'visibility')
|
||||
return visibility === 'visible' || visibility === undefined
|
||||
})
|
||||
|
||||
expect(isVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Layer Order', () => {
|
||||
test.skip('areas render below tracks (requires both layers with data)', async ({ page }) => {
|
||||
const layerOrder = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
const layers = controller?.map?.getStyle()?.layers || []
|
||||
|
||||
const areasIndex = layers.findIndex(l => l.id === 'areas-fill')
|
||||
const tracksIndex = layers.findIndex(l => l.id === 'tracks')
|
||||
|
||||
return { areasIndex, tracksIndex }
|
||||
})
|
||||
|
||||
// Areas should render before (below) tracks
|
||||
expect(layerOrder.areasIndex).toBeLessThan(layerOrder.tracksIndex)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Regression Tests', () => {
|
||||
test('all previous layers still work', async ({ page }) => {
|
||||
// Check that map loads successfully
|
||||
const hasMap = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return !!controller?.map
|
||||
})
|
||||
expect(hasMap).toBe(true)
|
||||
})
|
||||
|
||||
test('settings panel has all toggles', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Check all toggles exist
|
||||
const toggles = [
|
||||
'Show Heatmap',
|
||||
'Show Visits',
|
||||
'Show Photos',
|
||||
'Show Areas',
|
||||
'Show Tracks'
|
||||
]
|
||||
|
||||
for (const toggleText of toggles) {
|
||||
const toggle = page.locator(`label.setting-checkbox:has-text("${toggleText}")`)
|
||||
await expect(toggle).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../helpers/navigation'
|
||||
import {
|
||||
navigateToMapsV2WithDate,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete
|
||||
} from './helpers/setup'
|
||||
|
||||
test.describe('Phase 6: Advanced Features (Fog + Scratch + Toast)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Clear settings BEFORE navigation to ensure clean state
|
||||
await page.goto('/maps_v2')
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('maps_v2_settings')
|
||||
})
|
||||
|
||||
// Now navigate to a date range with data
|
||||
await navigateToMapsV2WithDate(page, '2025-10-15T00:00', '2025-10-15T23:59')
|
||||
await closeOnboardingModal(page)
|
||||
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
await page.waitForTimeout(1500)
|
||||
})
|
||||
|
||||
test.describe('Fog of War Layer', () => {
|
||||
test('fog layer is disabled by default in settings', async ({ page }) => {
|
||||
// Check that fog is disabled in settings by default
|
||||
const fogEnabled = await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem('maps_v2_settings') || '{}')
|
||||
return settings.fogEnabled
|
||||
})
|
||||
|
||||
// undefined or false both mean disabled
|
||||
expect(fogEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
test('can toggle fog layer in settings', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Toggle fog
|
||||
const fogCheckbox = page.locator('label.setting-checkbox:has-text("Show Fog of War")').locator('input[type="checkbox"]')
|
||||
await fogCheckbox.check()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Check if visible
|
||||
const fogCanvas = await page.locator('.fog-canvas')
|
||||
await fogCanvas.waitFor({ state: 'attached', timeout: 5000 })
|
||||
const isVisible = await fogCanvas.evaluate(el => el.style.display !== 'none')
|
||||
expect(isVisible).toBe(true)
|
||||
})
|
||||
|
||||
// Note: Fog canvas is created lazily, so we test it through the toggle test above
|
||||
})
|
||||
|
||||
test.describe('Scratch Map Layer', () => {
|
||||
test('scratch layer settings toggle exists', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const scratchToggle = page.locator('label.setting-checkbox:has-text("Show Scratch Map")')
|
||||
await expect(scratchToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('can toggle scratch map in settings', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Toggle scratch map
|
||||
const scratchCheckbox = page.locator('label.setting-checkbox:has-text("Show Scratch Map")').locator('input[type="checkbox"]')
|
||||
await scratchCheckbox.check()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Just verify it doesn't crash - layer may be empty
|
||||
const isChecked = await scratchCheckbox.isChecked()
|
||||
expect(isChecked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Photos Layer', () => {
|
||||
test('photos layer settings toggle exists', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const photosToggle = page.locator('label.setting-checkbox:has-text("Show Photos")')
|
||||
await expect(photosToggle).toBeVisible()
|
||||
})
|
||||
|
||||
test('can toggle photos layer in settings', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Toggle photos
|
||||
const photosCheckbox = page.locator('label.setting-checkbox:has-text("Show Photos")').locator('input[type="checkbox"]')
|
||||
await photosCheckbox.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify it's checked
|
||||
const isChecked = await photosCheckbox.isChecked()
|
||||
expect(isChecked).toBe(true)
|
||||
})
|
||||
|
||||
test('photo markers appear when photos layer is enabled', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Enable photos layer
|
||||
const photosCheckbox = page.locator('label.setting-checkbox:has-text("Show Photos")').locator('input[type="checkbox"]')
|
||||
await photosCheckbox.check()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Check for photo markers (they might not exist if no photos in test data)
|
||||
const photoMarkers = page.locator('.photo-marker')
|
||||
const markerCount = await photoMarkers.count()
|
||||
|
||||
// Just verify the test doesn't crash - markers may be 0 if no photos exist
|
||||
expect(markerCount).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Toast Notifications', () => {
|
||||
test('toast container is initialized', async ({ page }) => {
|
||||
// Toast container should exist after page load
|
||||
const toastContainer = page.locator('.toast-container')
|
||||
await expect(toastContainer).toBeAttached()
|
||||
})
|
||||
|
||||
test.skip('success toast appears on data load', async ({ page }) => {
|
||||
// This test is flaky because toast may disappear quickly
|
||||
// Just verifying toast system is initialized above
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings Panel', () => {
|
||||
test('all layer toggles are present', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
const toggles = [
|
||||
'Show Heatmap',
|
||||
'Show Visits',
|
||||
'Show Photos',
|
||||
'Show Areas',
|
||||
'Show Tracks',
|
||||
'Show Fog of War',
|
||||
'Show Scratch Map'
|
||||
]
|
||||
|
||||
for (const toggleText of toggles) {
|
||||
const toggle = page.locator(`label.setting-checkbox:has-text("${toggleText}")`)
|
||||
await expect(toggle).toBeVisible()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Regression Tests', () => {
|
||||
test.skip('all previous features still work (z-index overlay issue)', async ({ page }) => {
|
||||
// Just verify page loads and no JavaScript errors
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Close settings by clicking the close button (×)
|
||||
await page.click('.settings-panel .close-btn')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
expect(errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('fog and scratch work alongside other layers', async ({ page }) => {
|
||||
// Open settings
|
||||
await page.click('button[title="Settings"]')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// Enable multiple layers
|
||||
const heatmapCheckbox = page.locator('label.setting-checkbox:has-text("Show Heatmap")').locator('input[type="checkbox"]')
|
||||
await heatmapCheckbox.check()
|
||||
|
||||
const fogCheckbox = page.locator('label.setting-checkbox:has-text("Show Fog of War")').locator('input[type="checkbox"]')
|
||||
await fogCheckbox.check()
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verify both are enabled
|
||||
expect(await heatmapCheckbox.isChecked()).toBe(true)
|
||||
expect(await fogCheckbox.isChecked()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
waitForMapLibre,
|
||||
hasLayer
|
||||
} from './helpers/setup.js'
|
||||
|
||||
test.describe('Phase 7: Real-time + Family', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await waitForMapLibre(page)
|
||||
})
|
||||
|
||||
// Note: Phase 7 realtime controller is currently disabled pending initialization fix
|
||||
// These tests are kept for when the controller is re-enabled
|
||||
test.skip('family layer exists', async ({ page }) => {
|
||||
const hasFamilyLayer = await hasLayer(page, 'family')
|
||||
expect(hasFamilyLayer).toBe(true)
|
||||
})
|
||||
|
||||
test.skip('connection indicator shows', async ({ page }) => {
|
||||
const indicator = page.locator('.connection-indicator')
|
||||
await expect(indicator).toBeVisible()
|
||||
})
|
||||
|
||||
test.skip('connection indicator shows state', async ({ page }) => {
|
||||
// Wait for connection to be established
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
const indicator = page.locator('.connection-indicator')
|
||||
await expect(indicator).toBeVisible()
|
||||
|
||||
// Should have either 'connected' or 'disconnected' class
|
||||
const classes = await indicator.getAttribute('class')
|
||||
const hasState = classes.includes('connected') || classes.includes('disconnected')
|
||||
expect(hasState).toBe(true)
|
||||
})
|
||||
|
||||
test.skip('family layer has required sub-layers', async ({ page }) => {
|
||||
const familyExists = await hasLayer(page, 'family')
|
||||
const labelsExists = await hasLayer(page, 'family-labels')
|
||||
const pulseExists = await hasLayer(page, 'family-pulse')
|
||||
|
||||
expect(familyExists).toBe(true)
|
||||
expect(labelsExists).toBe(true)
|
||||
expect(pulseExists).toBe(true)
|
||||
})
|
||||
|
||||
// Regression tests are covered by earlier phase test files (phase-1 through phase-6)
|
||||
// These are skipped here to avoid duplication
|
||||
test.describe.skip('Regression Tests', () => {
|
||||
test('all previous features still work', async ({ page }) => {
|
||||
const layers = [
|
||||
'points', 'routes', 'heatmap',
|
||||
'visits', 'photos', 'areas-fill',
|
||||
'tracks', 'fog-scratch'
|
||||
]
|
||||
|
||||
for (const layer of layers) {
|
||||
const exists = await hasLayer(page, layer)
|
||||
expect(exists).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('settings panel still works', async ({ page }) => {
|
||||
// Click settings button
|
||||
await page.click('button:has-text("Settings")')
|
||||
|
||||
// Wait for panel to appear
|
||||
await page.waitForSelector('[data-maps-v2-target="settingsPanel"]')
|
||||
|
||||
// Check if panel is visible
|
||||
const panel = page.locator('[data-maps-v2-target="settingsPanel"]')
|
||||
await expect(panel).toBeVisible()
|
||||
})
|
||||
|
||||
test('layer toggles still work', async ({ page }) => {
|
||||
// Toggle points layer
|
||||
await page.click('button:has-text("Points")')
|
||||
|
||||
// Wait a bit for layer to update
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Layer should still exist but visibility might change
|
||||
const pointsExists = await page.evaluate(() => {
|
||||
const map = window.mapInstance
|
||||
return map?.getLayer('points') !== undefined
|
||||
})
|
||||
|
||||
expect(pointsExists).toBe(true)
|
||||
})
|
||||
|
||||
test('map interactions still work', async ({ page }) => {
|
||||
// Test zoom
|
||||
const initialZoom = await page.evaluate(() => window.mapInstance?.getZoom())
|
||||
|
||||
// Click zoom in button
|
||||
await page.click('.maplibregl-ctrl-zoom-in')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const newZoom = await page.evaluate(() => window.mapInstance?.getZoom())
|
||||
expect(newZoom).toBeGreaterThan(initialZoom)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe.skip('ActionCable Integration', () => {
|
||||
test('realtime controller is connected', async ({ page }) => {
|
||||
// Check if realtime controller is initialized
|
||||
const hasRealtimeController = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller*="realtime"]')
|
||||
return element !== null
|
||||
})
|
||||
|
||||
expect(hasRealtimeController).toBe(true)
|
||||
})
|
||||
|
||||
test('connection indicator updates class based on connection', async ({ page }) => {
|
||||
// Get initial state
|
||||
const indicator = page.locator('.connection-indicator')
|
||||
const initialClass = await indicator.getAttribute('class')
|
||||
|
||||
// Should have a connection state class
|
||||
const hasConnectionState =
|
||||
initialClass.includes('connected') ||
|
||||
initialClass.includes('disconnected')
|
||||
|
||||
expect(hasConnectionState).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe.skip('Family Layer Functionality', () => {
|
||||
test('family layer can be updated programmatically', async ({ page }) => {
|
||||
// Test family layer update method exists
|
||||
const result = await page.evaluate(() => {
|
||||
const controller = window.mapInstance?._container?.closest('[data-controller*="maps-v2"]')?._stimulus?.getControllerForElementAndIdentifier
|
||||
|
||||
// Access the familyLayer through the map controller
|
||||
return typeof window.mapInstance?._container !== 'undefined'
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test('family layer handles empty state', async ({ page }) => {
|
||||
// Family layer should exist with no features initially
|
||||
const familyLayerData = await page.evaluate(() => {
|
||||
const map = window.mapInstance
|
||||
const source = map?.getSource('family-source')
|
||||
return source?._data || null
|
||||
})
|
||||
|
||||
expect(familyLayerData).toBeTruthy()
|
||||
expect(familyLayerData.type).toBe('FeatureCollection')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Performance', () => {
|
||||
test.skip('page loads within acceptable time', async ({ page }) => {
|
||||
const startTime = Date.now()
|
||||
await page.goto('/maps_v2')
|
||||
await waitForMapLibre(page)
|
||||
const loadTime = Date.now() - startTime
|
||||
|
||||
// Should load within 10 seconds
|
||||
expect(loadTime).toBeLessThan(10000)
|
||||
})
|
||||
|
||||
test.skip('real-time updates do not cause memory leaks', async ({ page }) => {
|
||||
// Get initial memory usage
|
||||
const metrics1 = await page.evaluate(() => {
|
||||
if (performance.memory) {
|
||||
return performance.memory.usedJSHeapSize
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
if (metrics1 === null) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
|
||||
// Wait a bit
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Get memory usage again
|
||||
const metrics2 = await page.evaluate(() => {
|
||||
return performance.memory.usedJSHeapSize
|
||||
})
|
||||
|
||||
// Memory should not increase dramatically (allow for 50MB variance)
|
||||
const memoryIncrease = metrics2 - metrics1
|
||||
expect(memoryIncrease).toBeLessThan(50 * 1024 * 1024)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
import { closeOnboardingModal } from '../helpers/navigation.js';
|
||||
import {
|
||||
navigateToMapsV2,
|
||||
waitForMapLibre,
|
||||
waitForLoadingComplete,
|
||||
hasMapInstance,
|
||||
getPointsSourceData,
|
||||
hasLayer
|
||||
} from './helpers/setup.js';
|
||||
|
||||
test.describe('Phase 8: Performance Optimization & Production Polish', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page);
|
||||
await closeOnboardingModal(page);
|
||||
});
|
||||
|
||||
test('map loads within reasonable time', async ({ page }) => {
|
||||
// Note: beforeEach already navigates and waits, so this just verifies
|
||||
// that the map is ready after the beforeEach hook
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Verify map is functional
|
||||
const hasMap = await hasMapInstance(page);
|
||||
expect(hasMap).toBe(true);
|
||||
});
|
||||
|
||||
test('handles dataset loading', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
const pointsData = await getPointsSourceData(page);
|
||||
const pointCount = pointsData?.featureCount || 0;
|
||||
|
||||
console.log(`Loaded ${pointCount} points`);
|
||||
expect(pointCount).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('all core layers are present', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Check that core layers exist
|
||||
const coreLayers = [
|
||||
'points',
|
||||
'routes',
|
||||
'heatmap',
|
||||
'visits',
|
||||
'areas-fill',
|
||||
'tracks',
|
||||
'family'
|
||||
];
|
||||
|
||||
for (const layerName of coreLayers) {
|
||||
const exists = await hasLayer(page, layerName);
|
||||
expect(exists).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('no memory leaks after layer toggling', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
const initialMemory = await page.evaluate(() => {
|
||||
return performance.memory?.usedJSHeapSize;
|
||||
});
|
||||
|
||||
// Toggle points layer multiple times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const pointsToggle = page.locator('button[data-action*="toggleLayer"][data-layer="points"]');
|
||||
if (await pointsToggle.count() > 0) {
|
||||
await pointsToggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
await pointsToggle.click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
const finalMemory = await page.evaluate(() => {
|
||||
return performance.memory?.usedJSHeapSize;
|
||||
});
|
||||
|
||||
if (initialMemory && finalMemory) {
|
||||
const memoryGrowth = finalMemory - initialMemory;
|
||||
const growthPercentage = (memoryGrowth / initialMemory) * 100;
|
||||
|
||||
console.log(`Memory growth: ${growthPercentage.toFixed(2)}%`);
|
||||
|
||||
// Memory shouldn't grow more than 50% (conservative threshold)
|
||||
expect(growthPercentage).toBeLessThan(50);
|
||||
}
|
||||
});
|
||||
|
||||
test('progressive loading shows progress indicator', async ({ page }) => {
|
||||
await page.goto('/maps_v2');
|
||||
await closeOnboardingModal(page);
|
||||
|
||||
// Wait for loading indicator to appear (might be very quick)
|
||||
const loading = page.locator('[data-maps-v2-target="loading"]');
|
||||
|
||||
// Try to catch the loading state, but don't fail if it's too fast
|
||||
const isLoading = await loading.isVisible().catch(() => false);
|
||||
|
||||
if (isLoading) {
|
||||
// Should show loading text
|
||||
const loadingText = page.locator('[data-maps-v2-target="loadingText"]');
|
||||
if (await loadingText.count() > 0) {
|
||||
const text = await loadingText.textContent();
|
||||
expect(text).toContain('Loading');
|
||||
}
|
||||
}
|
||||
|
||||
// Should finish loading
|
||||
await waitForLoadingComplete(page);
|
||||
});
|
||||
|
||||
test('lazy loading: fog layer not loaded initially', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Check that fog layer is not loaded yet (lazy loaded on demand)
|
||||
const fogLayerLoaded = await page.evaluate(() => {
|
||||
const controller = window.mapsV2Controller;
|
||||
return controller?.fogLayer !== undefined && controller?.fogLayer !== null;
|
||||
});
|
||||
|
||||
// Fog should only be loaded if it was enabled in settings
|
||||
console.log('Fog layer loaded:', fogLayerLoaded);
|
||||
});
|
||||
|
||||
test('lazy loading: scratch layer not loaded initially', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Check that scratch layer is not loaded yet (lazy loaded on demand)
|
||||
const scratchLayerLoaded = await page.evaluate(() => {
|
||||
const controller = window.mapsV2Controller;
|
||||
return controller?.scratchLayer !== undefined && controller?.scratchLayer !== null;
|
||||
});
|
||||
|
||||
// Scratch should only be loaded if it was enabled in settings
|
||||
console.log('Scratch layer loaded:', scratchLayerLoaded);
|
||||
});
|
||||
|
||||
test('performance monitor logs on disconnect', async ({ page }) => {
|
||||
// Set up console listener BEFORE navigation
|
||||
const consoleMessages = [];
|
||||
page.on('console', msg => {
|
||||
consoleMessages.push({
|
||||
type: msg.type(),
|
||||
text: msg.text()
|
||||
});
|
||||
});
|
||||
|
||||
// Now load the page
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Navigate away to trigger disconnect
|
||||
await page.goto('/');
|
||||
|
||||
// Wait for disconnect to happen
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check if performance metrics were logged
|
||||
const hasPerformanceLog = consoleMessages.some(msg =>
|
||||
msg.text.includes('[Performance]') ||
|
||||
msg.text.includes('Performance Report') ||
|
||||
msg.text.includes('Map data loaded in')
|
||||
);
|
||||
|
||||
console.log('Console messages sample:', consoleMessages.slice(-10).map(m => m.text));
|
||||
console.log('Has performance log:', hasPerformanceLog);
|
||||
|
||||
// This test is informational - performance logging is a nice-to-have
|
||||
// Don't fail if it's not found
|
||||
expect(hasPerformanceLog || true).toBe(true);
|
||||
});
|
||||
|
||||
test.describe('Regression Tests', () => {
|
||||
test('all features work after optimization', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Test that map interaction still works
|
||||
const hasMap = await hasMapInstance(page);
|
||||
expect(hasMap).toBe(true);
|
||||
|
||||
// Test that data loaded
|
||||
const pointsData = await getPointsSourceData(page);
|
||||
expect(pointsData).toBeTruthy();
|
||||
|
||||
// Test that layers are present
|
||||
const hasPointsLayer = await hasLayer(page, 'points');
|
||||
expect(hasPointsLayer).toBe(true);
|
||||
});
|
||||
|
||||
test('month selector still works', async ({ page }) => {
|
||||
await waitForMapLibre(page);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Find month selector
|
||||
const monthSelect = page.locator('[data-maps-v2-target="monthSelect"]');
|
||||
if (await monthSelect.count() > 0) {
|
||||
// Change month
|
||||
await monthSelect.selectOption({ index: 1 });
|
||||
|
||||
// Wait for reload (with longer timeout)
|
||||
await page.waitForTimeout(500);
|
||||
await waitForLoadingComplete(page);
|
||||
|
||||
// Verify map still works
|
||||
const hasMap = await hasMapInstance(page);
|
||||
expect(hasMap).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
34
e2e/v2/realtime/family.spec.js
Normal file
34
e2e/v2/realtime/family.spec.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { closeOnboardingModal } from '../../helpers/navigation.js'
|
||||
import { navigateToMapsV2, waitForMapLibre, waitForLoadingComplete } from '../helpers/setup.js'
|
||||
|
||||
test.describe('Realtime Family Tracking', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await navigateToMapsV2(page)
|
||||
await closeOnboardingModal(page)
|
||||
await waitForMapLibre(page)
|
||||
await waitForLoadingComplete(page)
|
||||
})
|
||||
|
||||
test.describe('Family Layer', () => {
|
||||
test.skip('family layer exists but is hidden by default', async ({ page }) => {
|
||||
// Family layer is created but hidden until ActionCable data arrives
|
||||
const layerExists = await page.evaluate(() => {
|
||||
const element = document.querySelector('[data-controller="maps-v2"]')
|
||||
const app = window.Stimulus || window.Application
|
||||
const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2')
|
||||
return controller?.map?.getLayer('family') !== undefined
|
||||
})
|
||||
|
||||
// Test requires family setup
|
||||
expect(layerExists).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('ActionCable Connection', () => {
|
||||
test.skip('establishes ActionCable connection for family tracking', async ({ page }) => {
|
||||
// This test requires ActionCable setup and family configuration
|
||||
// Skip for now as it needs backend family data
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue