Update v2 e2e tests structure

This commit is contained in:
Eugene Burmakin 2025-11-26 19:40:12 +01:00
parent e8392ee4f7
commit 1955ef371c
32 changed files with 1973 additions and 2156 deletions

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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)

View file

@ -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
*/

View file

@ -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
}
```

View file

@ -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)
}
}
}

View file

@ -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)

View file

@ -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>

View file

@ -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>

View file

@ -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.

View file

@ -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
View 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)
})
})
})

View 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()
})
})
})

View 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)
})
})
})

View 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)
})
})
})

View 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)
})
})
})

View 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)
})
})
})

View 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)
}
})
})
})

View 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)
})
})
})

View 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)
})
})
})

View 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()
})
})
})

View 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
View 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()
})
})
})

View file

@ -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);
});
});

View file

@ -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);
}
});
});

View file

@ -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)
})
})
})

View file

@ -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()
})
})
})

View file

@ -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()
}
})
})
})

View file

@ -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)
})
})
})

View file

@ -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)
})
})
})

View file

@ -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);
}
});
});
});

View 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
})
})
})