Merge pull request #1916 from Freika/tests/map

Tests/map
This commit is contained in:
Evgenii Burmakin 2025-11-07 10:35:28 +01:00 committed by GitHub
commit 888e48ccf2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3435 additions and 4491 deletions

1
.gitignore vendored
View file

@ -84,3 +84,4 @@ node_modules/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/e2e/temp/

View file

@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
# [UNRELEASED]
## Added
- Selection tool on the map now can select points that user can delete in bulk. #433
## Fixed
- Taiwan flag is now shown on its own instead of in combination with China flag.
## Changed
- Removed useless system tests and cover map functionality with Playwright e2e tests instead.
# [0.34.2] - 2025-10-31 # [0.34.2] - 2025-10-31
## Fixed ## Fixed

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::PointsController < ApiController class Api::V1::PointsController < ApiController
before_action :authenticate_active_api_user!, only: %i[create update destroy] before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy]
before_action :validate_points_limit, only: %i[create] before_action :validate_points_limit, only: %i[create]
def index def index
@ -45,6 +45,16 @@ class Api::V1::PointsController < ApiController
render json: { message: 'Point deleted successfully' } render json: { message: 'Point deleted successfully' }
end end
def bulk_destroy
point_ids = bulk_destroy_params[:point_ids]
render json: { error: 'No points selected' }, status: :unprocessable_entity and return if point_ids.blank?
deleted_count = current_api_user.points.where(id: point_ids).destroy_all.count
render json: { message: 'Points were successfully destroyed', count: deleted_count }, status: :ok
end
private private
def point_params def point_params
@ -55,6 +65,10 @@ class Api::V1::PointsController < ApiController
params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {}) params.permit(locations: [:type, { geometry: {}, properties: {} }], batch: {})
end end
def bulk_destroy_params
params.permit(point_ids: [])
end
def point_serializer def point_serializer
params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer params[:slim] == 'true' ? Api::SlimPointSerializer : Api::PointSerializer
end end

View file

@ -3,13 +3,14 @@
module CountryFlagHelper module CountryFlagHelper
def country_flag(country_name) def country_flag(country_name)
country_code = country_to_code(country_name) country_code = country_to_code(country_name)
return "" unless country_code return '' unless country_code
country_code = 'TW' if country_code == 'CN-TW'
# Convert country code to regional indicator symbols (flag emoji) # Convert country code to regional indicator symbols (flag emoji)
country_code.upcase.each_char.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join country_code.upcase.each_char.map { |c| (c.ord + 127_397).chr(Encoding::UTF_8) }.join
end end
private private
def country_to_code(country_name) def country_to_code(country_name)

View file

@ -148,6 +148,10 @@ export default class extends Controller {
if (this.currentPopup) { if (this.currentPopup) {
this.map.closePopup(this.currentPopup); this.map.closePopup(this.currentPopup);
this.currentPopup = null; this.currentPopup = null;
} else {
console.warn('No currentPopup reference found');
// Fallback: try to close any open popup
this.map.closePopup();
} }
} }
@ -263,7 +267,10 @@ export default class extends Controller {
} }
if (cancelButton) { if (cancelButton) {
cancelButton.addEventListener('click', () => { cancelButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.exitAddVisitMode(this.addVisitButton); this.exitAddVisitMode(this.addVisitButton);
}); });
} }
@ -346,8 +353,6 @@ export default class extends Controller {
} }
addCreatedVisitToMap(visitData, latitude, longitude) { addCreatedVisitToMap(visitData, latitude, longitude) {
console.log('Adding newly created visit to map immediately', { latitude, longitude, visitData });
const mapsController = document.querySelector('[data-controller*="maps"]'); const mapsController = document.querySelector('[data-controller*="maps"]');
if (!mapsController) { if (!mapsController) {
console.log('Could not find maps controller element'); console.log('Could not find maps controller element');
@ -357,6 +362,7 @@ export default class extends Controller {
const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps'); const stimulusController = this.application.getControllerForElementAndIdentifier(mapsController, 'maps');
if (!stimulusController || !stimulusController.visitsManager) { if (!stimulusController || !stimulusController.visitsManager) {
console.log('Could not find maps controller or visits manager'); console.log('Could not find maps controller or visits manager');
return; return;
} }
@ -376,16 +382,10 @@ export default class extends Controller {
// Add the circle to the confirmed visits layer // Add the circle to the confirmed visits layer
visitsManager.confirmedVisitCircles.addLayer(circle); visitsManager.confirmedVisitCircles.addLayer(circle);
console.log('✅ Added newly created confirmed visit circle to layer');
console.log('Confirmed visits layer info:', {
layerCount: visitsManager.confirmedVisitCircles.getLayers().length,
isOnMap: this.map.hasLayer(visitsManager.confirmedVisitCircles)
});
// Make sure the layer is visible on the map // Make sure the layer is visible on the map
if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) { if (!this.map.hasLayer(visitsManager.confirmedVisitCircles)) {
this.map.addLayer(visitsManager.confirmedVisitCircles); this.map.addLayer(visitsManager.confirmedVisitCircles);
console.log('✅ Added confirmed visits layer to map');
} }
// Check if the layer control has the confirmed visits layer enabled // Check if the layer control has the confirmed visits layer enabled
@ -411,9 +411,7 @@ export default class extends Controller {
inputs.forEach(input => { inputs.forEach(input => {
const label = input.nextElementSibling; const label = input.nextElementSibling;
if (label && label.textContent.trim().includes('Confirmed Visits')) { if (label && label.textContent.trim().includes('Confirmed Visits')) {
console.log('Found Confirmed Visits checkbox, current state:', input.checked);
if (!input.checked) { if (!input.checked) {
console.log('Enabling Confirmed Visits layer via checkbox');
input.checked = true; input.checked = true;
input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true }));
} }

View file

@ -29,7 +29,7 @@ export default class extends Controller {
if (this.isUploading) { if (this.isUploading) {
// If still uploading, prevent submission // If still uploading, prevent submission
event.preventDefault() event.preventDefault()
console.log("Form submission prevented during upload")
return return
} }
@ -41,7 +41,7 @@ export default class extends Controller {
const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]') const signedIds = this.element.querySelectorAll('input[name="import[files][]"][type="hidden"]')
if (signedIds.length === 0) { if (signedIds.length === 0) {
event.preventDefault() event.preventDefault()
console.log("No files uploaded yet")
alert("Please select and upload files first") alert("Please select and upload files first")
} else { } else {
console.log(`Submitting form with ${signedIds.length} uploaded files`) console.log(`Submitting form with ${signedIds.length} uploaded files`)
@ -78,7 +78,6 @@ export default class extends Controller {
} }
} }
console.log(`Uploading ${files.length} files`)
this.isUploading = true this.isUploading = true
// Disable submit button during upload // Disable submit button during upload
@ -124,8 +123,6 @@ export default class extends Controller {
// Add the progress wrapper AFTER the file input field but BEFORE the submit button // Add the progress wrapper AFTER the file input field but BEFORE the submit button
this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget) this.submitTarget.parentNode.insertBefore(progressWrapper, this.submitTarget)
console.log("Progress bar created and inserted before submit button")
let uploadCount = 0 let uploadCount = 0
const totalFiles = files.length const totalFiles = files.length
@ -137,17 +134,13 @@ export default class extends Controller {
}); });
Array.from(files).forEach(file => { Array.from(files).forEach(file => {
console.log(`Starting upload for ${file.name}`)
const upload = new DirectUpload(file, this.urlValue, this) const upload = new DirectUpload(file, this.urlValue, this)
upload.create((error, blob) => { upload.create((error, blob) => {
uploadCount++ uploadCount++
if (error) { if (error) {
console.error("Error uploading file:", error)
// Show error to user using flash
showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`) showFlashMessage('error', `Error uploading ${file.name}: ${error.message || 'Unknown error'}`)
} else { } else {
console.log(`Successfully uploaded ${file.name} with ID: ${blob.signed_id}`)
// Create a hidden field with the correct name // Create a hidden field with the correct name
const hiddenField = document.createElement("input") const hiddenField = document.createElement("input")
@ -155,8 +148,6 @@ export default class extends Controller {
hiddenField.setAttribute("name", "import[files][]") hiddenField.setAttribute("name", "import[files][]")
hiddenField.setAttribute("value", blob.signed_id) hiddenField.setAttribute("value", blob.signed_id)
this.element.appendChild(hiddenField) this.element.appendChild(hiddenField)
console.log("Added hidden field with signed ID:", blob.signed_id)
} }
// Enable submit button when all uploads are complete // Enable submit button when all uploads are complete
@ -186,8 +177,6 @@ export default class extends Controller {
} }
} }
this.isUploading = false this.isUploading = false
console.log("All uploads completed")
console.log(`Ready to submit with ${successfulUploads} files`)
} }
}) })
}) })

View file

@ -208,7 +208,7 @@ export default class extends BaseController {
this.addInfoToggleButton(); this.addInfoToggleButton();
// Initialize the visits manager // Initialize the visits manager
this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme); this.visitsManager = new VisitsManager(this.map, this.apiKey, this.userTheme, this);
// Expose visits manager globally for location search integration // Expose visits manager globally for location search integration
window.visitsManager = this.visitsManager; window.visitsManager = this.visitsManager;
@ -712,6 +712,9 @@ export default class extends BaseController {
if (this.map.hasLayer(this.fogOverlay)) { if (this.map.hasLayer(this.fogOverlay)) {
this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold); this.updateFog(this.markers, this.clearFogRadius, this.fogLineThreshold);
} }
// Show success message
showFlashMessage('notice', 'Point deleted successfully');
}) })
.catch(error => { .catch(error => {
console.error('There was a problem with the delete request:', error); console.error('There was a problem with the delete request:', error);

View file

@ -1,14 +1,16 @@
import L from "leaflet"; import L from "leaflet";
import { showFlashMessage } from "./helpers"; import { showFlashMessage } from "./helpers";
import { createPolylinesLayer } from "./polylines";
/** /**
* Manages visits functionality including displaying, fetching, and interacting with visits * Manages visits functionality including displaying, fetching, and interacting with visits
*/ */
export class VisitsManager { export class VisitsManager {
constructor(map, apiKey, userTheme = 'dark') { constructor(map, apiKey, userTheme = 'dark', mapsController = null) {
this.map = map; this.map = map;
this.apiKey = apiKey; this.apiKey = apiKey;
this.userTheme = userTheme; this.userTheme = userTheme;
this.mapsController = mapsController;
// Create custom panes for different visit types // Create custom panes for different visit types
// Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700 // Leaflet default panes: tilePane=200, overlayPane=400, shadowPane=500, markerPane=600, tooltipPane=650, popupPane=700
@ -218,15 +220,20 @@ export class VisitsManager {
// Set selection as active to ensure date summary is displayed // Set selection as active to ensure date summary is displayed
this.isSelectionActive = true; this.isSelectionActive = true;
this.displayVisits(visits); // Make sure the drawer is open FIRST, before displaying visits
// Make sure the drawer is open
if (!this.drawerOpen) { if (!this.drawerOpen) {
this.toggleDrawer(); this.toggleDrawer();
} }
// Add cancel selection button to the drawer // Now display visits in the drawer
this.addSelectionCancelButton(); this.displayVisits(visits);
// Add cancel selection button to the drawer AFTER displayVisits
// This needs to be after because displayVisits sets innerHTML which would wipe out the buttons
// Use setTimeout to ensure DOM has fully updated
setTimeout(() => {
this.addSelectionCancelButton();
}, 0);
} catch (error) { } catch (error) {
console.error('Error fetching visits in selection:', error); console.error('Error fetching visits in selection:', error);
@ -387,19 +394,214 @@ export class VisitsManager {
* Adds a cancel button to the drawer to clear the selection * Adds a cancel button to the drawer to clear the selection
*/ */
addSelectionCancelButton() { addSelectionCancelButton() {
console.log('addSelectionCancelButton: Called');
const container = document.getElementById('visits-list'); const container = document.getElementById('visits-list');
if (!container) return; if (!container) {
console.error('addSelectionCancelButton: visits-list container not found');
return;
}
console.log('addSelectionCancelButton: Container found');
// Add cancel button at the top of the drawer if it doesn't exist // Remove any existing button container first to avoid duplicates
if (!document.getElementById('cancel-selection-button')) { const existingButtonContainer = document.getElementById('selection-button-container');
const cancelButton = document.createElement('button'); if (existingButtonContainer) {
cancelButton.id = 'cancel-selection-button'; console.log('addSelectionCancelButton: Removing existing button container');
cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full'; existingButtonContainer.remove();
cancelButton.textContent = 'Cancel Area Selection'; }
cancelButton.onclick = () => this.clearSelection();
// Insert at the beginning of the container // Create a button container
container.insertBefore(cancelButton, container.firstChild); const buttonContainer = document.createElement('div');
buttonContainer.className = 'flex gap-2 mb-4';
buttonContainer.id = 'selection-button-container';
// Cancel button
const cancelButton = document.createElement('button');
cancelButton.id = 'cancel-selection-button';
cancelButton.className = 'btn btn-sm btn-warning flex-1';
cancelButton.textContent = 'Cancel Selection';
cancelButton.onclick = () => this.clearSelection();
// Delete all selected points button
const deleteButton = document.createElement('button');
deleteButton.id = 'delete-selection-button';
deleteButton.className = 'btn btn-sm btn-error flex-1';
deleteButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="inline mr-1"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>Delete Points';
deleteButton.onclick = () => this.deleteSelectedPoints();
// Add count badge if we have selected points
if (this.selectedPoints && this.selectedPoints.length > 0) {
const badge = document.createElement('span');
badge.className = 'badge badge-sm ml-1';
badge.textContent = this.selectedPoints.length;
deleteButton.appendChild(badge);
console.log(`addSelectionCancelButton: Added badge with ${this.selectedPoints.length} points`);
} else {
console.warn('addSelectionCancelButton: No selected points, selectedPoints =', this.selectedPoints);
}
buttonContainer.appendChild(cancelButton);
buttonContainer.appendChild(deleteButton);
// Insert at the beginning of the container
container.insertBefore(buttonContainer, container.firstChild);
console.log('addSelectionCancelButton: Buttons inserted into DOM');
// Verify buttons are in DOM
setTimeout(() => {
const verifyDelete = document.getElementById('delete-selection-button');
const verifyCancel = document.getElementById('cancel-selection-button');
console.log('addSelectionCancelButton: Verification - Delete button exists:', !!verifyDelete);
console.log('addSelectionCancelButton: Verification - Cancel button exists:', !!verifyCancel);
}, 100);
}
/**
* Deletes all points in the current selection
*/
async deleteSelectedPoints() {
if (!this.selectedPoints || this.selectedPoints.length === 0) {
showFlashMessage('warning', 'No points selected');
return;
}
const pointCount = this.selectedPoints.length;
const confirmed = confirm(
`⚠️ WARNING: This will permanently delete ${pointCount} point${pointCount > 1 ? 's' : ''} from your location history.\n\n` +
`This action cannot be undone!\n\n` +
`Are you sure you want to continue?`
);
if (!confirmed) return;
try {
// Get point IDs from the selected points
// Debug: log the structure of selected points
console.log('Selected points sample:', this.selectedPoints[0]);
// Points format: [lat, lng, ?, ?, timestamp, ?, id, country, ?]
// ID is at index 6 based on the marker array structure
const pointIds = this.selectedPoints
.map(point => point[6]) // ID is at index 6
.filter(id => id != null && id !== '');
console.log('Point IDs to delete:', pointIds);
if (pointIds.length === 0) {
showFlashMessage('error', 'No valid point IDs found');
return;
}
// Call the bulk delete API
const response = await fetch('/api/v1/points/bulk_destroy', {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
},
body: JSON.stringify({ point_ids: pointIds })
});
if (!response.ok) {
const errorText = await response.text();
console.error('Response error:', response.status, errorText);
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.log('Delete result:', result);
// Check if any points were actually deleted
if (result.count === 0) {
showFlashMessage('warning', 'No points were deleted. They may have already been removed.');
this.clearSelection();
return;
}
// Show success message
showFlashMessage('notice', `Successfully deleted ${result.count} point${result.count > 1 ? 's' : ''}`);
// Remove deleted points from the map
pointIds.forEach(id => {
this.mapsController.removeMarker(id);
});
// Update the polylines layer
this.updatePolylinesAfterDeletion();
// Update heatmap with remaining markers
if (this.mapsController.heatmapLayer) {
this.mapsController.heatmapLayer.setLatLngs(
this.mapsController.markers.map(marker => [marker[0], marker[1], 0.2])
);
}
// Update fog if enabled
if (this.mapsController.fogOverlay && this.mapsController.map.hasLayer(this.mapsController.fogOverlay)) {
this.mapsController.updateFog(
this.mapsController.markers,
this.mapsController.clearFogRadius,
this.mapsController.fogLineThreshold
);
}
// Clear selection
this.clearSelection();
} catch (error) {
console.error('Error deleting points:', error);
showFlashMessage('error', 'Failed to delete points. Please try again.');
}
}
/**
* Updates polylines layer after deletion (similar to single point deletion)
*/
updatePolylinesAfterDeletion() {
let wasPolyLayerVisible = false;
// Check if polylines layer was visible
if (this.mapsController.polylinesLayer) {
if (this.mapsController.map.hasLayer(this.mapsController.polylinesLayer)) {
wasPolyLayerVisible = true;
}
this.mapsController.map.removeLayer(this.mapsController.polylinesLayer);
}
// Create new polylines layer with updated markers
this.mapsController.polylinesLayer = createPolylinesLayer(
this.mapsController.markers,
this.mapsController.map,
this.mapsController.timezone,
this.mapsController.routeOpacity,
this.mapsController.userSettings,
this.mapsController.distanceUnit
);
// Re-add to map if it was visible, otherwise ensure it's removed
if (wasPolyLayerVisible) {
this.mapsController.polylinesLayer.addTo(this.mapsController.map);
} else {
this.mapsController.map.removeLayer(this.mapsController.polylinesLayer);
}
// Update layer control
if (this.mapsController.layerControl) {
this.mapsController.map.removeControl(this.mapsController.layerControl);
const controlsLayer = {
Points: this.mapsController.markersLayer || L.layerGroup(),
Routes: this.mapsController.polylinesLayer || L.layerGroup(),
Heatmap: this.mapsController.heatmapLayer || L.layerGroup(),
"Fog of War": this.mapsController.fogOverlay,
"Scratch map": this.mapsController.scratchLayerManager?.getLayer() || L.layerGroup(),
Areas: this.mapsController.areasLayer || L.layerGroup(),
Photos: this.mapsController.photoMarkers || L.layerGroup()
};
this.mapsController.layerControl = L.control.layers(
this.mapsController.baseMaps(),
controlsLayer
).addTo(this.mapsController.map);
} }
} }
@ -430,7 +632,8 @@ export class VisitsManager {
}); });
// Update the drawer content if it's being opened - but don't fetch visits automatically // Update the drawer content if it's being opened - but don't fetch visits automatically
if (this.drawerOpen) { // Only show the "no data" message if there's no selection active
if (this.drawerOpen && !this.isSelectionActive) {
const container = document.getElementById('visits-list'); const container = document.getElementById('visits-list');
if (container) { if (container) {
container.innerHTML = ` container.innerHTML = `

View file

@ -124,7 +124,11 @@ Rails.application.routes.draw do
get 'suggestions' get 'suggestions'
end end
end end
resources :points, only: %i[index create update destroy] resources :points, only: %i[index create update destroy] do
collection do
delete :bulk_destroy
end
end
resources :visits, only: %i[index create update destroy] do resources :visits, only: %i[index create update destroy] do
get 'possible_places', to: 'visits/possible_places#index', on: :member get 'possible_places', to: 'visits/possible_places#index', on: :member
collection do collection do

View file

@ -2,10 +2,12 @@
class AddUtmParametersToUsers < ActiveRecord::Migration[8.0] class AddUtmParametersToUsers < ActiveRecord::Migration[8.0]
def change def change
add_column :users, :utm_source, :string safety_assured do
add_column :users, :utm_medium, :string add_column :users, :utm_source, :string
add_column :users, :utm_campaign, :string add_column :users, :utm_medium, :string
add_column :users, :utm_term, :string add_column :users, :utm_campaign, :string
add_column :users, :utm_content, :string add_column :users, :utm_term, :string
add_column :users, :utm_content, :string
end
end end
end end

115
e2e/README.md Normal file
View file

@ -0,0 +1,115 @@
# E2E Tests
End-to-end tests for Dawarich using Playwright.
## Running Tests
```bash
# Run all tests
npx playwright test
# Run specific test file
npx playwright test e2e/map/map-controls.spec.js
# Run tests in headed mode (watch browser)
npx playwright test --headed
# Run tests in debug mode
npx playwright test --debug
# Run tests sequentially (avoid parallel issues)
npx playwright test --workers=1
```
## 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 (62 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)
- `map-visits.spec.js` - Confirmed visit interactions and management (5 tests)
- `map-suggested-visits.spec.js` - Suggested visit interactions (confirm/decline) (6 tests)
- `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)
\* Some side panel tests may be skipped if demo data doesn't contain visits
## Helper Functions
### 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
### 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
### Selection Helpers (`helpers/selection.js`)
- `drawSelectionRectangle(page, options)` - Draw selection on map
- `enableSelectionMode(page)` - Enable area selection tool
## Common Patterns
### Basic Test Template
```javascript
import { test, expect } from '@playwright/test';
import { navigateToMap } from '../helpers/navigation.js';
import { waitForMap } from '../helpers/map.js';
test('my test', async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
// Your test logic
});
```
### Testing Map Layers
```javascript
import { enableLayer } from '../helpers/map.js';
await enableLayer(page, 'Routes');
await enableLayer(page, 'Heatmap');
```
## Debugging
### View Test Artifacts
```bash
# Open HTML report
npx playwright show-report
# Screenshots and videos are in:
test-results/
```
### Common Issues
- **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
## CI/CD
Tests run with:
- 1 worker (sequential)
- 2 retries on failure
- Screenshots/videos on failure
- JUnit XML reports
See `playwright.config.js` for full configuration.

84
e2e/helpers/map.js Normal file
View file

@ -0,0 +1,84 @@
/**
* Map helper functions for Playwright tests
*/
/**
* Wait for Leaflet map to be fully initialized
* @param {Page} page - Playwright page object
*/
export async function waitForMap(page) {
await page.waitForFunction(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container && container._leaflet_id !== undefined;
}, { timeout: 10000 });
}
/**
* Enable a map layer by name
* @param {Page} page - Playwright page object
* @param {string} layerName - Name of the layer to enable (e.g., "Routes", "Heatmap")
*/
export async function enableLayer(page, layerName) {
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const checkbox = page.locator(`.leaflet-control-layers-overlays label:has-text("${layerName}") input[type="checkbox"]`);
const isChecked = await checkbox.isChecked();
if (!isChecked) {
await checkbox.check();
await page.waitForTimeout(1000);
}
}
/**
* Click on the first confirmed visit circle on the map
* @param {Page} page - Playwright page object
* @returns {Promise<boolean>} - True if a visit was clicked, false otherwise
*/
export async function clickConfirmedVisit(page) {
return await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
const layers = controller.visitsManager.confirmedVisitCircles._layers;
const firstVisit = Object.values(layers)[0];
if (firstVisit) {
firstVisit.fire('click');
return true;
}
}
return false;
});
}
/**
* Click on the first suggested visit circle on the map
* @param {Page} page - Playwright page object
* @returns {Promise<boolean>} - True if a visit was clicked, false otherwise
*/
export async function clickSuggestedVisit(page) {
return await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
const layers = controller.visitsManager.suggestedVisitCircles._layers;
const firstVisit = Object.values(layers)[0];
if (firstVisit) {
firstVisit.fire('click');
return true;
}
}
return false;
});
}
/**
* Get current map zoom level
* @param {Page} page - Playwright page object
* @returns {Promise<number|null>} - Current zoom level or null
*/
export async function getMapZoom(page) {
return await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.map?.getZoom() || null;
});
}

45
e2e/helpers/navigation.js Normal file
View file

@ -0,0 +1,45 @@
/**
* Navigation and UI helper functions for Playwright tests
*/
/**
* Close the onboarding modal if it's open
* @param {Page} page - Playwright page object
*/
export async function closeOnboardingModal(page) {
const onboardingModal = page.locator('#getting_started');
const isModalOpen = await onboardingModal.evaluate((dialog) => dialog.open).catch(() => false);
if (isModalOpen) {
await page.locator('#getting_started button.btn-primary').click();
await page.waitForTimeout(500);
}
}
/**
* Navigate to the map page and close onboarding modal
* @param {Page} page - Playwright page object
*/
export async function navigateToMap(page) {
await page.goto('/map');
await closeOnboardingModal(page);
}
/**
* Navigate to a specific date range on the map
* @param {Page} page - Playwright page object
* @param {string} startDate - Start date in format 'YYYY-MM-DDTHH:mm'
* @param {string} endDate - End date in format 'YYYY-MM-DDTHH:mm'
*/
export async function navigateToDate(page, startDate, endDate) {
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
await startInput.clear();
await startInput.fill(startDate);
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
await endInput.clear();
await endInput.fill(endDate);
await page.click('input[type="submit"][value="Search"]');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
}

64
e2e/helpers/selection.js Normal file
View file

@ -0,0 +1,64 @@
/**
* Selection and drawing helper functions for Playwright tests
*/
/**
* Enable selection mode by clicking the selection tool button
* @param {Page} page - Playwright page object
*/
export async function enableSelectionMode(page) {
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
}
/**
* Draw a selection rectangle on the map
* @param {Page} page - Playwright page object
* @param {Object} options - Drawing options
* @param {number} options.startX - Start X position (0-1 as fraction of width, default: 0.2)
* @param {number} options.startY - Start Y position (0-1 as fraction of height, default: 0.2)
* @param {number} options.endX - End X position (0-1 as fraction of width, default: 0.8)
* @param {number} options.endY - End Y position (0-1 as fraction of height, default: 0.8)
* @param {number} options.steps - Number of steps for smooth drag (default: 10)
*/
export async function drawSelectionRectangle(page, options = {}) {
const {
startX = 0.2,
startY = 0.2,
endX = 0.8,
endY = 0.8,
steps = 10
} = options;
// Click area selection tool
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Get map container bounding box
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Calculate absolute positions
const absStartX = bbox.x + bbox.width * startX;
const absStartY = bbox.y + bbox.height * startY;
const absEndX = bbox.x + bbox.width * endX;
const absEndY = bbox.y + bbox.height * endY;
// Draw rectangle
await page.mouse.move(absStartX, absStartY);
await page.mouse.down();
await page.mouse.move(absEndX, absEndY, { steps });
await page.mouse.up();
// Wait for API calls and drawer animations
await page.waitForTimeout(2000);
// Wait for drawer to open (it should open automatically after selection)
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
// Wait for delete button to appear in the drawer (indicates selection is complete)
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
await page.waitForTimeout(500); // Brief wait for UI to stabilize
}

View file

@ -1,134 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Test to verify the refactored LiveMapHandler class works correctly
*/
test.describe('LiveMapHandler Refactoring', () => {
let page;
let context;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
// Sign in
await page.goto('/users/sign_in');
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
await page.fill('input[name="user[password]"]', 'password');
await page.click('input[type="submit"][value="Log in"]');
await page.waitForURL('/map', { timeout: 10000 });
});
test.afterAll(async () => {
await page.close();
await context.close();
});
test('should have LiveMapHandler class imported and available', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Check if LiveMapHandler is available in the code
const hasLiveMapHandler = await page.evaluate(() => {
// Check if the LiveMapHandler class exists in the bundled JavaScript
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
const allJavaScript = scripts.join(' ');
const hasLiveMapHandlerClass = allJavaScript.includes('LiveMapHandler') ||
allJavaScript.includes('live_map_handler');
const hasAppendPointDelegation = allJavaScript.includes('liveMapHandler.appendPoint') ||
allJavaScript.includes('this.liveMapHandler');
return {
hasLiveMapHandlerClass,
hasAppendPointDelegation,
totalJSSize: allJavaScript.length,
scriptCount: scripts.length
};
});
console.log('LiveMapHandler availability:', hasLiveMapHandler);
// The test is informational - we verify the refactoring is present in source
expect(hasLiveMapHandler.scriptCount).toBeGreaterThan(0);
});
test('should have proper delegation in maps controller', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Verify the controller structure
const controllerAnalysis = await page.evaluate(() => {
const mapElement = document.querySelector('#map');
const controllers = mapElement?._stimulus_controllers;
const mapController = controllers?.find(c => c.identifier === 'maps');
if (mapController) {
const hasAppendPoint = typeof mapController.appendPoint === 'function';
const methodSource = hasAppendPoint ? mapController.appendPoint.toString() : '';
return {
hasController: true,
hasAppendPoint,
// Check if appendPoint delegates to LiveMapHandler
usesDelegation: methodSource.includes('liveMapHandler') || methodSource.includes('LiveMapHandler'),
methodLength: methodSource.length,
isSimpleMethod: methodSource.length < 500 // Should be much smaller now
};
}
return {
hasController: false,
message: 'Controller not found in test environment'
};
});
console.log('Controller delegation analysis:', controllerAnalysis);
// Test passes either way since we've implemented the refactoring
if (controllerAnalysis.hasController) {
// If controller exists, verify it's using delegation
expect(controllerAnalysis.hasAppendPoint).toBe(true);
// The new appendPoint method should be much smaller (delegation only)
expect(controllerAnalysis.isSimpleMethod).toBe(true);
} else {
// Controller not found - this is the current test environment limitation
console.log('Controller not accessible in test, but refactoring implemented in source');
}
expect(true).toBe(true); // Test always passes as verification
});
test('should maintain backward compatibility', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Verify basic map functionality still works
const mapFunctionality = await page.evaluate(() => {
return {
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
hasMapElement: !!document.querySelector('#map'),
hasApiKey: !!document.querySelector('#map')?.dataset?.api_key,
leafletElementCount: document.querySelectorAll('[class*="leaflet"]').length,
hasDataController: document.querySelector('#map')?.hasAttribute('data-controller')
};
});
console.log('Map functionality check:', mapFunctionality);
// Verify all core functionality remains intact
expect(mapFunctionality.hasLeafletContainer).toBe(true);
expect(mapFunctionality.hasMapElement).toBe(true);
expect(mapFunctionality.hasApiKey).toBe(true);
expect(mapFunctionality.hasDataController).toBe(true);
expect(mapFunctionality.leafletElementCount).toBeGreaterThan(10);
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,260 @@
import { test, expect } from '@playwright/test';
import { navigateToMap } from '../helpers/navigation.js';
import { waitForMap } from '../helpers/map.js';
/**
* Helper to wait for add visit controller to be fully initialized
*/
async function waitForAddVisitController(page) {
await page.waitForTimeout(2000); // Wait for controller to connect and attach handlers
}
test.describe('Add Visit Control', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
await waitForAddVisitController(page);
});
test('should show add visit button control', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
await expect(addVisitButton).toBeVisible();
});
test('should enable add visit mode when clicked', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
await addVisitButton.click();
await page.waitForTimeout(1000);
// Verify flash message appears
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Click on the map")');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Verify cursor changed to crosshair
const cursor = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor;
});
expect(cursor).toBe('crosshair');
// Verify button has active state (background color applied)
const hasActiveStyle = await addVisitButton.evaluate((el) => {
return el.style.backgroundColor !== '';
});
expect(hasActiveStyle).toBe(true);
});
test('should open popup form when map is clicked', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
await addVisitButton.click();
await page.waitForTimeout(500);
// Click on map - use bottom left corner which is less likely to have points
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
await page.waitForTimeout(1000);
// Verify popup is visible
const popup = page.locator('.leaflet-popup');
await expect(popup).toBeVisible({ timeout: 10000 });
// Verify popup contains the add visit form
await expect(popup.locator('h3:has-text("Add New Visit")')).toBeVisible();
// Verify marker appears (📍 emoji with class add-visit-marker)
const marker = page.locator('.add-visit-marker');
await expect(marker).toBeVisible();
});
test('should display correct form content in popup', async ({ page }) => {
// Enable mode and click map
await page.locator('.add-visit-button').click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
await page.waitForTimeout(1000);
// Verify popup content has all required elements
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent.locator('h3:has-text("Add New Visit")')).toBeVisible();
await expect(popupContent.locator('input#visit-name')).toBeVisible();
await expect(popupContent.locator('input#visit-start')).toBeVisible();
await expect(popupContent.locator('input#visit-end')).toBeVisible();
await expect(popupContent.locator('button:has-text("Create Visit")')).toBeVisible();
await expect(popupContent.locator('button:has-text("Cancel")')).toBeVisible();
// Verify name field has focus
const nameFieldFocused = await page.evaluate(() => {
return document.activeElement?.id === 'visit-name';
});
expect(nameFieldFocused).toBe(true);
// Verify start and end time have default values
const startValue = await page.locator('input#visit-start').inputValue();
const endValue = await page.locator('input#visit-end').inputValue();
expect(startValue).toBeTruthy();
expect(endValue).toBeTruthy();
});
test('should hide popup and remove marker when cancel is clicked', async ({ page }) => {
// Enable mode and click map
await page.locator('.add-visit-button').click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
await page.waitForTimeout(1000);
// Verify popup and marker exist
await expect(page.locator('.leaflet-popup')).toBeVisible();
await expect(page.locator('.add-visit-marker')).toBeVisible();
// Click cancel button
await page.locator('#cancel-visit').click();
await page.waitForTimeout(500);
// Verify popup is hidden
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
// Verify marker is removed
const markerCount = await page.locator('.add-visit-marker').count();
expect(markerCount).toBe(0);
// Verify cursor is reset to default
const cursor = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor;
});
expect(cursor).toBe('');
// Verify mode was exited (cursor should be reset)
const cursorReset = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor === '';
});
expect(cursorReset).toBe(true);
});
test('should create visit and show marker on map when submitted', async ({ page }) => {
// Get initial confirmed visit count
const initialCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
}
return 0;
});
// Enable mode and click map
await page.locator('.add-visit-button').click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
await page.mouse.click(bbox.x + bbox.width * 0.2, bbox.y + bbox.height * 0.8);
await page.waitForTimeout(1000);
// Fill form with unique visit name
const visitName = `E2E Test Visit ${Date.now()}`;
await page.locator('#visit-name').fill(visitName);
// Submit form
await page.locator('button:has-text("Create Visit")').click();
await page.waitForTimeout(2000);
// Verify success message
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("created successfully")');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Verify popup is closed
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
// Verify confirmed visit marker count increased
const finalCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
}
return 0;
});
expect(finalCount).toBeGreaterThan(initialCount);
});
test('should disable add visit mode when clicked second time', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
// First click - enable mode
await addVisitButton.click();
await page.waitForTimeout(500);
// Verify mode is enabled
const cursorEnabled = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor === 'crosshair';
});
expect(cursorEnabled).toBe(true);
// Second click - disable mode
await addVisitButton.click();
await page.waitForTimeout(500);
// Verify cursor is reset
const cursorDisabled = await page.evaluate(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container?.style.cursor;
});
expect(cursorDisabled).toBe('');
// Verify mode was exited by checking if we can click map without creating marker
const isAddingVisit = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'add-visit');
return controller?.isAddingVisit === true;
});
expect(isAddingVisit).toBe(false);
});
test('should ensure only one visit popup is open at a time', async ({ page }) => {
const addVisitButton = page.locator('.add-visit-button');
await addVisitButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Click first location on map
await page.mouse.click(bbox.x + bbox.width * 0.3, bbox.y + bbox.height * 0.3);
await page.waitForTimeout(500);
// Verify first popup exists
let popupCount = await page.locator('.leaflet-popup').count();
expect(popupCount).toBe(1);
// Get the content of first popup to verify it exists
const firstPopupContent = await page.locator('.leaflet-popup-content input#visit-name').count();
expect(firstPopupContent).toBe(1);
// Click second location on map
await page.mouse.click(bbox.x + bbox.width * 0.7, bbox.y + bbox.height * 0.7);
await page.waitForTimeout(500);
// Verify still only one popup exists (old one was closed, new one opened)
popupCount = await page.locator('.leaflet-popup').count();
expect(popupCount).toBe(1);
// Verify the popup contains the add visit form (not some other popup)
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent.locator('h3:has-text("Add New Visit")')).toBeVisible();
await expect(popupContent.locator('input#visit-name')).toBeVisible();
// Verify only one marker exists
const markerCount = await page.locator('.add-visit-marker').count();
expect(markerCount).toBe(1);
});
});

View file

@ -0,0 +1,380 @@
import { test, expect } from '@playwright/test';
import { drawSelectionRectangle } from '../helpers/selection.js';
import { navigateToDate, closeOnboardingModal } from '../helpers/navigation.js';
import { waitForMap, enableLayer } from '../helpers/map.js';
test.describe('Bulk Delete Points', () => {
test.beforeEach(async ({ page }) => {
// Navigate to map page
await page.goto('/map', {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Wait for map to be initialized
await waitForMap(page);
// Close onboarding modal if present
await closeOnboardingModal(page);
// Navigate to a date with points (October 13, 2024)
await navigateToDate(page, '2024-10-13T00:00', '2024-10-13T23:59');
// Enable Points layer
await enableLayer(page, 'Points');
});
test('should show area selection tool button', async ({ page }) => {
// Check that area selection button exists
const selectionButton = page.locator('#selection-tool-button');
await expect(selectionButton).toBeVisible();
});
test('should enable selection mode when area tool is clicked', async ({ page }) => {
// Click area selection button
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Verify selection mode is active
const isSelectionActive = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.selectionMode === true;
});
expect(isSelectionActive).toBe(true);
});
test('should select points in drawn area and show delete button', async ({ page }) => {
await drawSelectionRectangle(page);
// Check that delete button appears
const deleteButton = page.locator('#delete-selection-button');
await expect(deleteButton).toBeVisible({ timeout: 10000 });
// Check button has text "Delete Points"
await expect(deleteButton).toContainText('Delete Points');
});
test('should show point count badge on delete button', async ({ page }) => {
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Check for badge with count
const badge = page.locator('#delete-selection-button .badge');
await expect(badge).toBeVisible();
// Badge should contain a number
const badgeText = await badge.textContent();
expect(parseInt(badgeText)).toBeGreaterThan(0);
});
test('should show cancel button alongside delete button', async ({ page }) => {
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Check both buttons exist
const cancelButton = page.locator('#cancel-selection-button');
const deleteButton = page.locator('#delete-selection-button');
await expect(cancelButton).toBeVisible();
await expect(deleteButton).toBeVisible();
await expect(cancelButton).toContainText('Cancel');
});
test('should cancel selection when cancel button is clicked', async ({ page }) => {
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Click cancel button
const cancelButton = page.locator('#cancel-selection-button');
await cancelButton.click();
await page.waitForTimeout(500);
// Verify buttons are gone
await expect(cancelButton).not.toBeVisible();
await expect(page.locator('#delete-selection-button')).not.toBeVisible();
// Verify selection is cleared
const isSelectionActive = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === false;
});
expect(isSelectionActive).toBe(true);
});
test('should show confirmation dialog when delete button is clicked', async ({ page }) => {
// Set up dialog handler
let dialogMessage = '';
page.on('dialog', async dialog => {
dialogMessage = dialog.message();
await dialog.dismiss(); // Dismiss to prevent actual deletion
});
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Click delete button
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(500);
// Verify confirmation dialog appeared with warning
expect(dialogMessage).toContain('WARNING');
expect(dialogMessage).toContain('permanently delete');
expect(dialogMessage).toContain('cannot be undone');
});
test('should delete points and show success message when confirmed', async ({ page }) => {
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Get initial point count
const initialPointCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.markers?.length || 0;
});
await drawSelectionRectangle(page);
await page.waitForTimeout(1000);
// Click delete button
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000); // Wait for deletion to complete
// Check for success flash message with specific text
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Successfully deleted")');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
const messageText = await flashMessage.textContent();
expect(messageText).toMatch(/Successfully deleted \d+ point/);
// Verify point count decreased
const finalPointCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.markers?.length || 0;
});
expect(finalPointCount).toBeLessThan(initialPointCount);
});
test('should preserve Routes layer disabled state after deletion', async ({ page }) => {
// Ensure Routes layer is disabled
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
const isRoutesChecked = await routesCheckbox.isChecked();
if (isRoutesChecked) {
await routesCheckbox.uncheck();
await page.waitForTimeout(500);
}
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Perform deletion using same selection logic as helper
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Use larger selection area to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(2000);
// Wait for drawer and button to appear
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify Routes layer is still disabled
const isRoutesLayerVisible = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.map?.hasLayer(controller?.polylinesLayer);
});
expect(isRoutesLayerVisible).toBe(false);
});
test('should preserve Routes layer enabled state after deletion', async ({ page }) => {
// Enable Routes layer
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
const isRoutesChecked = await routesCheckbox.isChecked();
if (!isRoutesChecked) {
await routesCheckbox.check();
await page.waitForTimeout(1000);
}
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Perform deletion using same selection logic as helper
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Use larger selection area to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(2000);
// Wait for drawer and button to appear
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify Routes layer is still enabled
const isRoutesLayerVisible = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.map?.hasLayer(controller?.polylinesLayer);
});
expect(isRoutesLayerVisible).toBe(true);
});
test('should update heatmap after bulk deletion', async ({ page }) => {
// Enable Heatmap layer
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const heatmapCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Heatmap") input[type="checkbox"]');
const isHeatmapChecked = await heatmapCheckbox.isChecked();
if (!isHeatmapChecked) {
await heatmapCheckbox.check();
await page.waitForTimeout(1000);
}
// Get initial heatmap data count
const initialHeatmapCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.heatmapLayer?._latlngs?.length || 0;
});
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Perform deletion using same selection logic as helper
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Use larger selection area to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(2000);
// Wait for drawer and button to appear
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify heatmap was updated
const finalHeatmapCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.heatmapLayer?._latlngs?.length || 0;
});
expect(finalHeatmapCount).toBeLessThan(initialHeatmapCount);
});
test('should clear selection after successful deletion', async ({ page }) => {
// Set up dialog handler to accept deletion
page.on('dialog', async dialog => {
await dialog.accept();
});
// Perform deletion using same selection logic as helper
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Use larger selection area to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
await page.waitForTimeout(2000);
// Wait for drawer and button to appear
await page.waitForSelector('#visits-drawer.open', { timeout: 15000 });
await page.waitForSelector('#delete-selection-button', { timeout: 15000 });
const deleteButton = page.locator('#delete-selection-button');
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify selection is cleared
const isSelectionActive = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === false &&
controller?.visitsManager?.selectedPoints?.length === 0;
});
expect(isSelectionActive).toBe(true);
// Verify buttons are removed
await expect(page.locator('#cancel-selection-button')).not.toBeVisible();
await expect(page.locator('#delete-selection-button')).not.toBeVisible();
});
});

View file

@ -0,0 +1,308 @@
import { test, expect } from '@playwright/test';
import { closeOnboardingModal } from '../helpers/navigation.js';
/**
* Calendar Panel Tests
*
* Tests for the calendar panel control that allows users to navigate between
* different years and months. The panel is opened via the "Toggle Panel" button
* in the top-right corner of the map.
*/
test.describe('Calendar Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/map');
await closeOnboardingModal(page);
// Wait for map to be fully loaded
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
await page.waitForTimeout(2000); // Wait for all controls to be initialized
});
/**
* Helper function to find and click the calendar toggle button
*/
async function clickCalendarButton(page) {
// The calendar button is the "Toggle Panel" button with a calendar icon
// It's the third button in the top-right control stack (after Select Area and Add Visit)
const calendarButton = await page.locator('button.toggle-panel-button').first();
await expect(calendarButton).toBeVisible({ timeout: 5000 });
await calendarButton.click();
await page.waitForTimeout(500); // Wait for panel animation
}
/**
* Helper function to check if panel is visible
*/
async function isPanelVisible(page) {
const panel = page.locator('.leaflet-right-panel');
const isVisible = await panel.isVisible().catch(() => false);
if (!isVisible) return false;
const displayStyle = await panel.evaluate(el => el.style.display);
return displayStyle !== 'none';
}
test('should open calendar panel on first click', async ({ page }) => {
// Verify panel is not visible initially
const initiallyVisible = await isPanelVisible(page);
expect(initiallyVisible).toBe(false);
// Click calendar button
await clickCalendarButton(page);
// Verify panel is now visible
const panelVisible = await isPanelVisible(page);
expect(panelVisible).toBe(true);
// Verify panel contains expected elements
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible();
const monthsGrid = page.locator('#months-grid');
await expect(monthsGrid).toBeVisible();
// Verify "Whole year" link is present
const wholeYearLink = page.locator('#whole-year-link');
await expect(wholeYearLink).toBeVisible();
});
test('should close calendar panel on second click', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
await page.waitForTimeout(300);
// Verify panel is visible
let panelVisible = await isPanelVisible(page);
expect(panelVisible).toBe(true);
// Click button again to close
await clickCalendarButton(page);
await page.waitForTimeout(300);
// Verify panel is hidden
panelVisible = await isPanelVisible(page);
expect(panelVisible).toBe(false);
});
test('should allow year selection', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
// Wait for year select to be populated (it loads from API)
await page.waitForTimeout(2000);
const yearSelect = page.locator('#year-select');
await expect(yearSelect).toBeVisible();
// Get available years
const options = await yearSelect.locator('option:not([disabled])').all();
// Should have at least one year available
expect(options.length).toBeGreaterThan(0);
// Select the first available year
const firstYearOption = options[0];
const yearValue = await firstYearOption.getAttribute('value');
await yearSelect.selectOption(yearValue);
// Verify year was selected
const selectedValue = await yearSelect.inputValue();
expect(selectedValue).toBe(yearValue);
});
test('should navigate to month when clicking month button', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
// Wait for months to load
await page.waitForTimeout(3000);
// Select year 2024 (which has October data in demo)
const yearSelect = page.locator('#year-select');
await yearSelect.selectOption('2024');
await page.waitForTimeout(500);
// Find October button (demo data has October 2024)
const octoberButton = page.locator('#months-grid a[data-month-name="Oct"]');
await expect(octoberButton).toBeVisible({ timeout: 5000 });
// Verify October is enabled (not disabled)
const isDisabled = await octoberButton.evaluate(el => el.classList.contains('disabled'));
expect(isDisabled).toBe(false);
// Verify button is clickable
const pointerEvents = await octoberButton.evaluate(el => el.style.pointerEvents);
expect(pointerEvents).not.toBe('none');
// Get the expected href before clicking
const expectedHref = await octoberButton.getAttribute('href');
expect(expectedHref).toBeTruthy();
const decodedHref = decodeURIComponent(expectedHref);
expect(decodedHref).toContain('map?');
expect(decodedHref).toContain('start_at=2024-10-01T00:00');
expect(decodedHref).toContain('end_at=2024-10-31T23:59');
// Click the month button and wait for navigation
await Promise.all([
page.waitForURL('**/map**', { timeout: 10000 }),
octoberButton.click()
]);
// Wait for page to settle
await page.waitForLoadState('networkidle', { timeout: 10000 });
// Verify we navigated to the map page
expect(page.url()).toContain('/map');
// Verify map loaded with data
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
});
test('should navigate to whole year when clicking "Whole year" button', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
// Wait for panel to load
await page.waitForTimeout(2000);
const wholeYearLink = page.locator('#whole-year-link');
await expect(wholeYearLink).toBeVisible();
// Get the href and decode it
const href = await wholeYearLink.getAttribute('href');
expect(href).toBeTruthy();
const decodedHref = decodeURIComponent(href);
expect(decodedHref).toContain('map?');
expect(decodedHref).toContain('start_at=');
expect(decodedHref).toContain('end_at=');
// Href should contain full year dates (01-01 to 12-31)
expect(decodedHref).toContain('-01-01T00:00');
expect(decodedHref).toContain('-12-31T23:59');
// Store the expected year from the href
const yearMatch = decodedHref.match(/(\d{4})-01-01/);
expect(yearMatch).toBeTruthy();
const expectedYear = yearMatch[1];
// Click the link and wait for navigation
await Promise.all([
page.waitForURL('**/map**', { timeout: 10000 }),
wholeYearLink.click()
]);
// Wait for page to settle
await page.waitForLoadState('networkidle', { timeout: 10000 });
// Verify we navigated to the map page
expect(page.url()).toContain('/map');
// The URL parameters might be processed differently (e.g., stripped by Turbo or redirected)
// Instead of checking URL, verify the panel updates to show the whole year is selected
// by checking the year in the select dropdown
const panelVisible = await isPanelVisible(page);
if (!panelVisible) {
// Panel might have closed on navigation, reopen it
await clickCalendarButton(page);
await page.waitForTimeout(1000);
}
const yearSelect = page.locator('#year-select');
const selectedYear = await yearSelect.inputValue();
expect(selectedYear).toBe(expectedYear);
});
test('should update month buttons when year is changed', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
// Wait for data to load
await page.waitForTimeout(2000);
const yearSelect = page.locator('#year-select');
// Get available years
const options = await yearSelect.locator('option:not([disabled])').all();
if (options.length < 2) {
console.log('Test skipped: Less than 2 years available');
test.skip();
return;
}
// Select first year and capture month states
const firstYearOption = options[0];
const firstYear = await firstYearOption.getAttribute('value');
await yearSelect.selectOption(firstYear);
await page.waitForTimeout(500);
// Get enabled months for first year
const firstYearMonths = await page.locator('#months-grid a:not(.disabled)').count();
// Select second year
const secondYearOption = options[1];
const secondYear = await secondYearOption.getAttribute('value');
await yearSelect.selectOption(secondYear);
await page.waitForTimeout(500);
// Get enabled months for second year
const secondYearMonths = await page.locator('#months-grid a:not(.disabled)').count();
// Months should be different (unless both years have same tracked months)
// At minimum, verify that month buttons are updated (content changed from loading dots)
const monthButtons = await page.locator('#months-grid a').all();
for (const button of monthButtons) {
const buttonText = await button.textContent();
// Should not contain loading dots anymore
expect(buttonText).not.toContain('loading');
}
});
test('should highlight active month based on current URL parameters', async ({ page }) => {
// Navigate to a specific month first
await page.goto('/map?start_at=2024-10-01T00:00&end_at=2024-10-31T23:59');
await closeOnboardingModal(page);
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
await page.waitForTimeout(2000);
// Open calendar panel
await clickCalendarButton(page);
await page.waitForTimeout(2000);
// Find October button (month index 9, displayed as "Oct")
const octoberButton = page.locator('#months-grid a[data-month-name="Oct"]');
await expect(octoberButton).toBeVisible();
// Verify October is marked as active
const hasActiveClass = await octoberButton.evaluate(el =>
el.classList.contains('btn-active')
);
expect(hasActiveClass).toBe(true);
});
test('should show visited cities section in panel', async ({ page }) => {
// Open panel
await clickCalendarButton(page);
await page.waitForTimeout(2000);
// Verify visited cities section is present
const visitedCitiesContainer = page.locator('#visited-cities-container');
await expect(visitedCitiesContainer).toBeVisible();
const visitedCitiesTitle = visitedCitiesContainer.locator('h3');
await expect(visitedCitiesTitle).toHaveText('Visited cities');
const visitedCitiesList = page.locator('#visited-cities-list');
await expect(visitedCitiesList).toBeVisible();
// List should eventually load (either with cities or "No places visited")
await page.waitForTimeout(2000);
const listContent = await visitedCitiesList.textContent();
expect(listContent.length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,157 @@
import { test, expect } from '@playwright/test';
import { navigateToMap, closeOnboardingModal, navigateToDate } from '../helpers/navigation.js';
import { waitForMap, getMapZoom } from '../helpers/map.js';
test.describe('Map Page', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
});
test('should load map container and display map with controls', async ({ page }) => {
await expect(page.locator('#map')).toBeVisible();
await waitForMap(page);
// Verify zoom controls are present
await expect(page.locator('.leaflet-control-zoom')).toBeVisible();
// Verify custom map controls are present (from map_controls.js)
await expect(page.locator('.add-visit-button')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.toggle-panel-button')).toBeVisible();
await expect(page.locator('.drawer-button')).toBeVisible();
await expect(page.locator('#selection-tool-button')).toBeVisible();
});
test('should zoom in when clicking zoom in button', async ({ page }) => {
await waitForMap(page);
const initialZoom = await getMapZoom(page);
await page.locator('.leaflet-control-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 waitForMap(page);
const initialZoom = await getMapZoom(page);
await page.locator('.leaflet-control-zoom-out').click();
await page.waitForTimeout(500);
const newZoom = await getMapZoom(page);
expect(newZoom).toBeLessThan(initialZoom);
});
test('should switch between map tile layers', async ({ page }) => {
await waitForMap(page);
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const getSelectedLayer = () => page.evaluate(() => {
const radio = document.querySelector('.leaflet-control-layers-base input[type="radio"]:checked');
return radio ? radio.nextSibling.textContent.trim() : null;
});
const initialLayer = await getSelectedLayer();
await page.locator('.leaflet-control-layers-base input[type="radio"]:not(:checked)').first().click();
await page.waitForTimeout(500);
const newLayer = await getSelectedLayer();
expect(newLayer).not.toBe(initialLayer);
});
test('should navigate to specific date and display points layer', async ({ page }) => {
// Wait for map to be ready
await page.waitForFunction(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container && container._leaflet_id !== undefined;
}, { timeout: 10000 });
// Navigate to date 13.10.2024
// First, need to expand the date controls on mobile (if collapsed)
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
if (!isPanelVisible) {
await toggleButton.click();
await page.waitForTimeout(300);
}
// Clear and fill in the start date/time input (midnight)
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
await startInput.clear();
await startInput.fill('2024-10-13T00:00');
// Clear and fill in the end date/time input (end of day)
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
await endInput.clear();
await endInput.fill('2024-10-13T23:59');
// Click the Search button to submit
await page.click('input[type="submit"][value="Search"]');
// Wait for page navigation and map reload
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000); // Wait for map to reinitialize
// Close onboarding modal if it appears after navigation
await closeOnboardingModal(page);
// Open layer control to enable points
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
// Enable points layer if not already enabled
const pointsCheckbox = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]').first();
const isChecked = await pointsCheckbox.isChecked();
if (!isChecked) {
await pointsCheckbox.check();
await page.waitForTimeout(1000); // Wait for points to render
}
// Verify points are visible on the map
const layerInfo = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (!controller) {
return { error: 'Controller not found' };
}
const result = {
hasMarkersLayer: !!controller.markersLayer,
markersCount: 0,
hasPolylinesLayer: !!controller.polylinesLayer,
polylinesCount: 0,
hasTracksLayer: !!controller.tracksLayer,
tracksCount: 0,
};
// Check markers layer
if (controller.markersLayer && controller.markersLayer._layers) {
result.markersCount = Object.keys(controller.markersLayer._layers).length;
}
// Check polylines layer
if (controller.polylinesLayer && controller.polylinesLayer._layers) {
result.polylinesCount = Object.keys(controller.polylinesLayer._layers).length;
}
// Check tracks layer
if (controller.tracksLayer && controller.tracksLayer._layers) {
result.tracksCount = Object.keys(controller.tracksLayer._layers).length;
}
return result;
});
// Verify that at least one layer has data
const hasData = layerInfo.markersCount > 0 ||
layerInfo.polylinesCount > 0 ||
layerInfo.tracksCount > 0;
expect(hasData).toBe(true);
});
});

184
e2e/map/map-layers.spec.js Normal file
View file

@ -0,0 +1,184 @@
import { test, expect } from '@playwright/test';
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
import { waitForMap, enableLayer } from '../helpers/map.js';
test.describe('Map Layers', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
});
test('should enable Routes layer and display routes', async ({ page }) => {
// Wait for map to be ready
await page.waitForFunction(() => {
const container = document.querySelector('#map [data-maps-target="container"]');
return container && container._leaflet_id !== undefined;
}, { timeout: 10000 });
// Navigate to date with data
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
if (!isPanelVisible) {
await toggleButton.click();
await page.waitForTimeout(300);
}
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
await startInput.clear();
await startInput.fill('2024-10-13T00:00');
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
await endInput.clear();
await endInput.fill('2024-10-13T23:59');
await page.click('input[type="submit"][value="Search"]');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Close onboarding modal if present
await closeOnboardingModal(page);
// Open layer control and enable Routes
await page.locator('.leaflet-control-layers').hover();
await page.waitForTimeout(300);
const routesCheckbox = page.locator('.leaflet-control-layers-overlays label:has-text("Routes") input[type="checkbox"]');
const isChecked = await routesCheckbox.isChecked();
if (!isChecked) {
await routesCheckbox.check();
await page.waitForTimeout(1000);
}
// Verify routes are visible
const hasRoutes = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.polylinesLayer && controller.polylinesLayer._layers) {
return Object.keys(controller.polylinesLayer._layers).length > 0;
}
return false;
});
expect(hasRoutes).toBe(true);
});
test('should enable Heatmap layer and display heatmap', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Heatmap');
const hasHeatmap = await page.locator('.leaflet-heatmap-layer').isVisible();
expect(hasHeatmap).toBe(true);
});
test('should enable Fog of War layer and display fog', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Fog of War');
const hasFog = await page.evaluate(() => {
const fogCanvas = document.getElementById('fog');
return fogCanvas && fogCanvas instanceof HTMLCanvasElement;
});
expect(hasFog).toBe(true);
});
test('should enable Areas layer and display areas', async ({ page }) => {
await waitForMap(page);
const hasAreasLayer = await page.evaluate(() => {
const mapElement = document.querySelector('#map');
const app = window.Stimulus;
const controller = app?.getControllerForElementAndIdentifier(mapElement, 'maps');
return controller?.areasLayer !== null && controller?.areasLayer !== undefined;
});
expect(hasAreasLayer).toBe(true);
});
test('should enable Suggested Visits layer', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Suggested Visits');
const hasSuggestedVisits = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.visitCircles !== null &&
controller?.visitsManager?.visitCircles !== undefined;
});
expect(hasSuggestedVisits).toBe(true);
});
test('should enable Confirmed Visits layer', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Confirmed Visits');
const hasConfirmedVisits = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.confirmedVisitCircles !== null &&
controller?.visitsManager?.confirmedVisitCircles !== undefined;
});
expect(hasConfirmedVisits).toBe(true);
});
test('should enable Scratch Map layer and display visited countries', async ({ page }) => {
await waitForMap(page);
await enableLayer(page, 'Scratch Map');
// Wait a bit for the layer to load country borders
await page.waitForTimeout(2000);
// Verify scratch layer exists and has been initialized
const hasScratchLayer = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
// Check if scratchLayerManager exists
if (!controller?.scratchLayerManager) return false;
// Check if scratch layer was created
const scratchLayer = controller.scratchLayerManager.getLayer();
return scratchLayer !== null && scratchLayer !== undefined;
});
expect(hasScratchLayer).toBe(true);
});
test('should remember enabled layers across page reloads', async ({ page }) => {
await waitForMap(page);
// Enable multiple layers
await enableLayer(page, 'Points');
await enableLayer(page, 'Routes');
await enableLayer(page, 'Heatmap');
await page.waitForTimeout(500);
// Get current layer states
const getLayerStates = () => page.evaluate(() => {
const layers = {};
document.querySelectorAll('.leaflet-control-layers-overlays input[type="checkbox"]').forEach(checkbox => {
const label = checkbox.parentElement.textContent.trim();
layers[label] = checkbox.checked;
});
return layers;
});
const layersBeforeReload = await getLayerStates();
// Reload the page
await page.reload();
await closeOnboardingModal(page);
await waitForMap(page);
await page.waitForTimeout(1000); // Wait for layers to restore
// Get layer states after reload
const layersAfterReload = await getLayerStates();
// Verify Points, Routes, and Heatmap are still enabled
expect(layersAfterReload['Points']).toBe(true);
expect(layersAfterReload['Routes']).toBe(true);
expect(layersAfterReload['Heatmap']).toBe(true);
// Verify layer states match before and after
expect(layersAfterReload).toEqual(layersBeforeReload);
});
});

141
e2e/map/map-points.spec.js Normal file
View file

@ -0,0 +1,141 @@
import { test, expect } from '@playwright/test';
import { navigateToMap } from '../helpers/navigation.js';
import { waitForMap, enableLayer } from '../helpers/map.js';
test.describe('Point Interactions', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
await enableLayer(page, 'Points');
await page.waitForTimeout(1500);
// Pan map to ensure a marker is in viewport
await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.markers && controller.markers.length > 0) {
const firstMarker = controller.markers[0];
controller.map.setView([firstMarker[0], firstMarker[1]], 14);
}
});
await page.waitForTimeout(1000);
});
test('should have draggable markers on the map', async ({ page }) => {
// Verify markers have draggable class
const marker = page.locator('.leaflet-marker-icon').first();
await expect(marker).toBeVisible();
// Check if marker has draggable class
const isDraggable = await marker.evaluate((el) => {
return el.classList.contains('leaflet-marker-draggable');
});
expect(isDraggable).toBe(true);
// Verify marker position can be retrieved (required for drag operations)
const box = await marker.boundingBox();
expect(box).not.toBeNull();
expect(box.x).toBeGreaterThan(0);
expect(box.y).toBeGreaterThan(0);
});
test('should open popup when clicking a point', async ({ page }) => {
// Click on a marker with force to ensure interaction
const marker = page.locator('.leaflet-marker-icon').first();
await marker.click({ force: true });
await page.waitForTimeout(500);
// Verify popup is visible
const popup = page.locator('.leaflet-popup');
await expect(popup).toBeVisible();
});
test('should display correct popup content with point data', async ({ page }) => {
// Click on a marker
const marker = page.locator('.leaflet-marker-icon').first();
await marker.click({ force: true });
await page.waitForTimeout(500);
// Get popup content
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent).toBeVisible();
const content = await popupContent.textContent();
// Verify all required fields are present
expect(content).toContain('Timestamp:');
expect(content).toContain('Latitude:');
expect(content).toContain('Longitude:');
expect(content).toContain('Altitude:');
expect(content).toContain('Speed:');
expect(content).toContain('Battery:');
expect(content).toContain('Id:');
});
test('should delete a point and redraw route', async ({ page }) => {
// Enable Routes layer to verify route redraw
await enableLayer(page, 'Routes');
await page.waitForTimeout(1000);
// Count initial markers and get point ID
const initialData = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
const markerCount = controller?.markersLayer ? Object.keys(controller.markersLayer._layers).length : 0;
const polylineCount = controller?.polylinesLayer ? Object.keys(controller.polylinesLayer._layers).length : 0;
return { markerCount, polylineCount };
});
// Click on a marker to open popup
const marker = page.locator('.leaflet-marker-icon').first();
await marker.click({ force: true });
await page.waitForTimeout(500);
// Verify popup opened
await expect(page.locator('.leaflet-popup')).toBeVisible();
// Get the point ID from popup before deleting
const pointId = await page.locator('.leaflet-popup-content').evaluate((content) => {
const match = content.textContent.match(/Id:\s*(\d+)/);
return match ? match[1] : null;
});
expect(pointId).not.toBeNull();
// Find delete button (might be a link or button with "Delete" text)
const deleteButton = page.locator('.leaflet-popup-content a:has-text("Delete"), .leaflet-popup-content button:has-text("Delete")').first();
const hasDeleteButton = await deleteButton.count() > 0;
if (hasDeleteButton) {
// Handle confirmation dialog
page.once('dialog', dialog => {
expect(dialog.message()).toContain('delete');
dialog.accept();
});
await deleteButton.click();
await page.waitForTimeout(2000); // Wait for deletion to complete
// Verify marker count decreased
const finalData = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
const markerCount = controller?.markersLayer ? Object.keys(controller.markersLayer._layers).length : 0;
const polylineCount = controller?.polylinesLayer ? Object.keys(controller.polylinesLayer._layers).length : 0;
return { markerCount, polylineCount };
});
// Verify at least one marker was removed
expect(finalData.markerCount).toBeLessThan(initialData.markerCount);
// Verify routes still exist (they should be redrawn)
expect(finalData.polylineCount).toBeGreaterThanOrEqual(0);
// Verify success flash message appears
const flashMessage = page.locator('#flash-messages [role="alert"]').filter({ hasText: /deleted successfully/i });
await expect(flashMessage).toBeVisible({ timeout: 5000 });
} else {
// If no delete button, just verify the test setup worked
console.log('No delete button found in popup - this might be expected based on permissions');
}
});
});

View file

@ -0,0 +1,166 @@
import { test, expect } from '@playwright/test';
import { navigateToMap } from '../helpers/navigation.js';
import { waitForMap } from '../helpers/map.js';
test.describe('Selection Tool', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
});
test('should enable selection mode when clicked', async ({ page }) => {
// Click selection tool button
const selectionButton = page.locator('#selection-tool-button');
await expect(selectionButton).toBeVisible();
await selectionButton.click();
await page.waitForTimeout(500);
// Verify selection mode is enabled (flash message appears)
const flashMessage = page.locator('#flash-messages [role="alert"]:has-text("Selection mode enabled")');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Verify selection mode is active in controller
const isSelectionActive = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === true;
});
expect(isSelectionActive).toBe(true);
// Verify button has active class
const hasActiveClass = await selectionButton.evaluate((el) => {
return el.classList.contains('active');
});
expect(hasActiveClass).toBe(true);
// Verify map dragging is disabled (required for selection to work)
const isDraggingDisabled = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return !controller?.map?.dragging?.enabled();
});
expect(isDraggingDisabled).toBe(true);
});
test('should disable selection mode when clicked second time', async ({ page }) => {
const selectionButton = page.locator('#selection-tool-button');
// First click - enable selection mode
await selectionButton.click();
await page.waitForTimeout(500);
// Verify selection mode is enabled
const isEnabledAfterFirstClick = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === true;
});
expect(isEnabledAfterFirstClick).toBe(true);
// Second click - disable selection mode
await selectionButton.click();
await page.waitForTimeout(500);
// Verify selection mode is disabled
const isDisabledAfterSecondClick = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.isSelectionActive === false;
});
expect(isDisabledAfterSecondClick).toBe(true);
// Verify no selection rectangle exists
const hasSelectionRect = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.visitsManager?.selectionRect !== null;
});
expect(hasSelectionRect).toBe(false);
// Verify button no longer has active class
const hasActiveClass = await selectionButton.evaluate((el) => {
return el.classList.contains('active');
});
expect(hasActiveClass).toBe(false);
// Verify map dragging is re-enabled
const isDraggingEnabled = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return controller?.map?.dragging?.enabled();
});
expect(isDraggingEnabled).toBe(true);
});
test('should show info message about dragging to select area', async ({ page }) => {
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Verify informational flash message about dragging
const flashMessage = page.locator('#flash-messages [role="alert"]');
const messageText = await flashMessage.textContent();
expect(messageText).toContain('Click and drag');
});
test('should open side panel when selection is complete', async ({ page }) => {
// Navigate to a date with known data (October 13, 2024 - same as bulk delete tests)
const startInput = page.locator('input[type="datetime-local"][name="start_at"]');
await startInput.clear();
await startInput.fill('2024-10-13T00:00');
const endInput = page.locator('input[type="datetime-local"][name="end_at"]');
await endInput.clear();
await endInput.fill('2024-10-13T23:59');
await page.click('input[type="submit"][value="Search"]');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Verify drawer is initially closed
const drawerInitiallyClosed = await page.evaluate(() => {
const drawer = document.getElementById('visits-drawer');
return !drawer?.classList.contains('open');
});
expect(drawerInitiallyClosed).toBe(true);
// Enable selection mode
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Draw a selection rectangle on the map
const mapContainer = page.locator('#map [data-maps-target="container"]');
const bbox = await mapContainer.boundingBox();
// Draw rectangle covering most of the map to ensure we select points
const startX = bbox.x + bbox.width * 0.2;
const startY = bbox.y + bbox.height * 0.2;
const endX = bbox.x + bbox.width * 0.8;
const endY = bbox.y + bbox.height * 0.8;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
// Wait for drawer to open
await page.waitForTimeout(2000);
// Verify drawer is now open
const drawerOpen = await page.evaluate(() => {
const drawer = document.getElementById('visits-drawer');
return drawer?.classList.contains('open');
});
expect(drawerOpen).toBe(true);
// Verify drawer shows either selection data or cancel button (indicates selection is active)
const hasCancelButton = await page.locator('#cancel-selection-button').isVisible();
expect(hasCancelButton).toBe(true);
});
});

View file

@ -0,0 +1,570 @@
import { test, expect } from '@playwright/test';
import { closeOnboardingModal, navigateToDate } from '../helpers/navigation.js';
import { drawSelectionRectangle } from '../helpers/selection.js';
/**
* Side Panel (Visits Drawer) Tests
*
* Tests for the side panel that displays visits when selection tool is used.
* The panel can be toggled via the drawer button and shows suggested/confirmed visits
* with options to confirm, decline, or merge them.
*/
test.describe('Side Panel', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/map');
await closeOnboardingModal(page);
// Wait for map to be fully loaded
await page.waitForSelector('.leaflet-container', { state: 'visible', timeout: 10000 });
await page.waitForTimeout(2000);
// Navigate to October 2024 (has demo data)
await navigateToDate(page, '2024-10-01T00:00', '2024-10-31T23:59');
await page.waitForTimeout(2000);
});
/**
* Helper function to click the drawer button
*/
async function clickDrawerButton(page) {
const drawerButton = page.locator('.drawer-button');
await expect(drawerButton).toBeVisible({ timeout: 5000 });
await drawerButton.click();
await page.waitForTimeout(500); // Wait for drawer animation
}
/**
* Helper function to check if drawer is open
*/
async function isDrawerOpen(page) {
const drawer = page.locator('#visits-drawer');
const exists = await drawer.count() > 0;
if (!exists) return false;
const hasOpenClass = await drawer.evaluate(el => el.classList.contains('open'));
return hasOpenClass;
}
/**
* Helper function to perform selection and wait for visits to load
* This is a simplified version that doesn't use the shared helper
* because we need custom waiting logic for the drawer
*/
async function selectAreaWithVisits(page) {
// First, enable Suggested Visits layer to ensure visits are loaded
const layersButton = page.locator('.leaflet-control-layers-toggle');
await layersButton.click();
await page.waitForTimeout(500);
// Enable "Suggested Visits" layer
const suggestedVisitsCheckbox = page.locator('input[type="checkbox"]').filter({
has: page.locator(':scope ~ span', { hasText: 'Suggested Visits' })
});
const isChecked = await suggestedVisitsCheckbox.isChecked();
if (!isChecked) {
await suggestedVisitsCheckbox.check();
await page.waitForTimeout(1000);
}
// Close layers control
await layersButton.click();
await page.waitForTimeout(500);
// Enable selection mode
const selectionButton = page.locator('#selection-tool-button');
await selectionButton.click();
await page.waitForTimeout(500);
// Get map bounds for drawing selection
const map = page.locator('.leaflet-container');
const mapBox = await map.boundingBox();
// Calculate coordinates for drawing a large selection area
// Make it much wider to catch visits - use most of the map area
const startX = mapBox.x + 100;
const startY = mapBox.y + 100;
const endX = mapBox.x + mapBox.width - 400; // Leave room for drawer on right
const endY = mapBox.y + mapBox.height - 100;
// Draw selection rectangle
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
// Wait for drawer to be created and opened
await page.waitForSelector('#visits-drawer.open', { timeout: 10000 });
await page.waitForTimeout(3000); // Wait longer for visits API response
}
test('should open and close drawer panel via button click', async ({ page }) => {
// Verify drawer is initially closed
const initiallyOpen = await isDrawerOpen(page);
expect(initiallyOpen).toBe(false);
// Click to open
await clickDrawerButton(page);
// Verify drawer is now open
let drawerOpen = await isDrawerOpen(page);
expect(drawerOpen).toBe(true);
// Verify drawer content is visible
const drawerContent = page.locator('#visits-drawer .drawer');
await expect(drawerContent).toBeVisible();
// Click to close
await clickDrawerButton(page);
// Verify drawer is now closed
drawerOpen = await isDrawerOpen(page);
expect(drawerOpen).toBe(false);
});
test('should show visits in panel after selection', async ({ page }) => {
await selectAreaWithVisits(page);
// Verify drawer is open
const drawerOpen = await isDrawerOpen(page);
expect(drawerOpen).toBe(true);
// Verify visits list container exists
const visitsList = page.locator('#visits-list');
await expect(visitsList).toBeVisible();
// Wait for API response - check if we have visit items or "no visits" message
await page.waitForTimeout(2000);
// Check what content is actually shown
const visitItems = page.locator('.visit-item');
const visitCount = await visitItems.count();
const noVisitsMessage = page.locator('#visits-list p.text-gray-500');
const hasNoVisitsMessage = await noVisitsMessage.count() > 0;
// Either we have visits OR we have a "no visits" message (not "Loading...")
if (visitCount > 0) {
// We have visits - verify the title shows count
const drawerTitle = page.locator('#visits-drawer .drawer h2');
const titleText = await drawerTitle.textContent();
expect(titleText).toMatch(/\d+ visits? found/);
} else {
// No visits found - verify we show the appropriate message
// Should NOT still be showing "Loading visits..."
const messageText = await noVisitsMessage.textContent();
expect(messageText).not.toContain('Loading visits');
expect(messageText).toContain('No visits');
}
});
test('should display visit details in panel', async ({ page }) => {
await selectAreaWithVisits(page);
// Check if we have any visits
const visitCount = await page.locator('.visit-item').count();
if (visitCount === 0) {
console.log('Test skipped: No visits available in test data');
test.skip();
return;
}
// Get first visit item
const firstVisit = page.locator('.visit-item').first();
await expect(firstVisit).toBeVisible();
// Verify visit has required information
const visitName = firstVisit.locator('.font-semibold');
await expect(visitName).toBeVisible();
const nameText = await visitName.textContent();
expect(nameText.length).toBeGreaterThan(0);
// Verify time information is present
const timeInfo = firstVisit.locator('.text-sm.text-gray-600');
await expect(timeInfo).toBeVisible();
// Check if this is a suggested visit (has confirm/decline buttons)
const hasSuggestedButtons = await firstVisit.locator('.confirm-visit').count() > 0;
if (hasSuggestedButtons) {
// For suggested visits, verify action buttons are present
const confirmButton = firstVisit.locator('.confirm-visit');
const declineButton = firstVisit.locator('.decline-visit');
await expect(confirmButton).toBeVisible();
await expect(declineButton).toBeVisible();
expect(await confirmButton.textContent()).toBe('Confirm');
expect(await declineButton.textContent()).toBe('Decline');
}
});
test('should confirm individual suggested visit from panel', async ({ page }) => {
await selectAreaWithVisits(page);
// Find a suggested visit (one with confirm/decline buttons)
const suggestedVisit = page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }).first();
// Check if any suggested visits exist
const suggestedCount = await page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') }).count();
if (suggestedCount === 0) {
console.log('Test skipped: No suggested visits available');
test.skip();
return;
}
await expect(suggestedVisit).toBeVisible();
// Verify it has the suggested visit styling (dashed border)
const hasDashedBorder = await suggestedVisit.evaluate(el =>
el.classList.contains('border-dashed')
);
expect(hasDashedBorder).toBe(true);
// Get initial count of visits
const initialVisitCount = await page.locator('.visit-item').count();
// Click confirm button
const confirmButton = suggestedVisit.locator('.confirm-visit');
await confirmButton.click();
// Wait for API call and UI update
await page.waitForTimeout(2000);
// Verify flash message appears
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// The visit should still be in the list but without confirm/decline buttons
// Or the count might decrease if it was removed from suggested visits
const finalVisitCount = await page.locator('.visit-item').count();
expect(finalVisitCount).toBeLessThanOrEqual(initialVisitCount);
});
test('should decline individual suggested visit from panel', async ({ page }) => {
await selectAreaWithVisits(page);
// Find a suggested visit
const suggestedVisit = page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }).first();
const suggestedCount = await page.locator('.visit-item').filter({ has: page.locator('.decline-visit') }).count();
if (suggestedCount === 0) {
console.log('Test skipped: No suggested visits available');
test.skip();
return;
}
await expect(suggestedVisit).toBeVisible();
// Get initial count
const initialVisitCount = await page.locator('.visit-item').count();
// Click decline button
const declineButton = suggestedVisit.locator('.decline-visit');
await declineButton.click();
// Wait for API call and UI update
await page.waitForTimeout(2000);
// Verify flash message
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Visit should be removed from the list
const finalVisitCount = await page.locator('.visit-item').count();
expect(finalVisitCount).toBeLessThan(initialVisitCount);
});
test('should show checkboxes on hover for mass selection', async ({ page }) => {
await selectAreaWithVisits(page);
// Check if we have any visits
const visitCount = await page.locator('.visit-item').count();
if (visitCount === 0) {
console.log('Test skipped: No visits available in test data');
test.skip();
return;
}
const firstVisit = page.locator('.visit-item').first();
await expect(firstVisit).toBeVisible();
// Initially, checkbox should be hidden
const checkboxContainer = firstVisit.locator('.visit-checkbox-container');
let opacity = await checkboxContainer.evaluate(el => el.style.opacity);
expect(opacity === '0' || opacity === '').toBe(true);
// Hover over the visit item
await firstVisit.hover();
await page.waitForTimeout(300);
// Checkbox should now be visible
opacity = await checkboxContainer.evaluate(el => el.style.opacity);
expect(opacity).toBe('1');
// Checkbox should be clickable
const pointerEvents = await checkboxContainer.evaluate(el => el.style.pointerEvents);
expect(pointerEvents).toBe('auto');
});
test('should select multiple visits and show bulk action buttons', async ({ page }) => {
await selectAreaWithVisits(page);
// Verify we have at least 2 visits
const visitCount = await page.locator('.visit-item').count();
if (visitCount < 2) {
console.log('Test skipped: Need at least 2 visits');
test.skip();
return;
}
// Select first visit by hovering and clicking checkbox
const firstVisit = page.locator('.visit-item').first();
await firstVisit.hover();
await page.waitForTimeout(300);
const firstCheckbox = firstVisit.locator('.visit-checkbox');
await firstCheckbox.click();
await page.waitForTimeout(500);
// Select second visit
const secondVisit = page.locator('.visit-item').nth(1);
await secondVisit.hover();
await page.waitForTimeout(300);
const secondCheckbox = secondVisit.locator('.visit-checkbox');
await secondCheckbox.click();
await page.waitForTimeout(500);
// Verify bulk action buttons appear
const bulkActionsContainer = page.locator('.visit-bulk-actions');
await expect(bulkActionsContainer).toBeVisible();
// Verify all three action buttons are present
const mergeButton = bulkActionsContainer.locator('button').filter({ hasText: 'Merge' });
const confirmButton = bulkActionsContainer.locator('button').filter({ hasText: 'Confirm' });
const declineButton = bulkActionsContainer.locator('button').filter({ hasText: 'Decline' });
await expect(mergeButton).toBeVisible();
await expect(confirmButton).toBeVisible();
await expect(declineButton).toBeVisible();
// Verify selection count text
const selectionText = bulkActionsContainer.locator('.text-sm.text-center');
const selectionTextContent = await selectionText.textContent();
expect(selectionTextContent).toContain('2 visits selected');
// Verify cancel button exists
const cancelButton = bulkActionsContainer.locator('button').filter({ hasText: 'Cancel Selection' });
await expect(cancelButton).toBeVisible();
});
test('should cancel mass selection', async ({ page }) => {
await selectAreaWithVisits(page);
const visitCount = await page.locator('.visit-item').count();
if (visitCount < 2) {
console.log('Test skipped: Need at least 2 visits');
test.skip();
return;
}
// Select two visits
const firstVisit = page.locator('.visit-item').first();
await firstVisit.hover();
await page.waitForTimeout(300);
await firstVisit.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
const secondVisit = page.locator('.visit-item').nth(1);
await secondVisit.hover();
await page.waitForTimeout(300);
await secondVisit.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
// Verify bulk actions are visible
const bulkActions = page.locator('.visit-bulk-actions');
await expect(bulkActions).toBeVisible();
// Click cancel button
const cancelButton = bulkActions.locator('button').filter({ hasText: 'Cancel Selection' });
await cancelButton.click();
await page.waitForTimeout(500);
// Verify bulk actions are removed
await expect(bulkActions).not.toBeVisible();
// Verify checkboxes are unchecked
const checkedCheckboxes = await page.locator('.visit-checkbox:checked').count();
expect(checkedCheckboxes).toBe(0);
});
test('should mass confirm multiple visits', async ({ page }) => {
await selectAreaWithVisits(page);
// Find suggested visits (those with confirm buttons)
const suggestedVisits = page.locator('.visit-item').filter({ has: page.locator('.confirm-visit') });
const suggestedCount = await suggestedVisits.count();
if (suggestedCount < 2) {
console.log('Test skipped: Need at least 2 suggested visits');
test.skip();
return;
}
// Get initial count
const initialVisitCount = await page.locator('.visit-item').count();
// Select first two suggested visits
const firstSuggested = suggestedVisits.first();
await firstSuggested.hover();
await page.waitForTimeout(300);
await firstSuggested.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
const secondSuggested = suggestedVisits.nth(1);
await secondSuggested.hover();
await page.waitForTimeout(300);
await secondSuggested.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
// Click mass confirm button
const bulkActions = page.locator('.visit-bulk-actions');
const confirmButton = bulkActions.locator('button').filter({ hasText: 'Confirm' });
await confirmButton.click();
// Wait for API call
await page.waitForTimeout(2000);
// Verify flash message
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// The visits might be removed or updated in the list
// At minimum, bulk actions should be removed
const bulkActionsVisible = await bulkActions.isVisible().catch(() => false);
expect(bulkActionsVisible).toBe(false);
});
test('should mass decline multiple visits', async ({ page }) => {
await selectAreaWithVisits(page);
const suggestedVisits = page.locator('.visit-item').filter({ has: page.locator('.decline-visit') });
const suggestedCount = await suggestedVisits.count();
if (suggestedCount < 2) {
console.log('Test skipped: Need at least 2 suggested visits');
test.skip();
return;
}
// Get initial count
const initialVisitCount = await page.locator('.visit-item').count();
// Select two visits
const firstSuggested = suggestedVisits.first();
await firstSuggested.hover();
await page.waitForTimeout(300);
await firstSuggested.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
const secondSuggested = suggestedVisits.nth(1);
await secondSuggested.hover();
await page.waitForTimeout(300);
await secondSuggested.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
// Click mass decline button
const bulkActions = page.locator('.visit-bulk-actions');
const declineButton = bulkActions.locator('button').filter({ hasText: 'Decline' });
await declineButton.click();
// Wait for API call
await page.waitForTimeout(2000);
// Verify flash message
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// Visits should be removed from the list
const finalVisitCount = await page.locator('.visit-item').count();
expect(finalVisitCount).toBeLessThan(initialVisitCount);
});
test('should mass merge multiple visits', async ({ page }) => {
await selectAreaWithVisits(page);
const visitCount = await page.locator('.visit-item').count();
if (visitCount < 2) {
console.log('Test skipped: Need at least 2 visits');
test.skip();
return;
}
// Select two visits
const firstVisit = page.locator('.visit-item').first();
await firstVisit.hover();
await page.waitForTimeout(300);
await firstVisit.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
const secondVisit = page.locator('.visit-item').nth(1);
await secondVisit.hover();
await page.waitForTimeout(300);
await secondVisit.locator('.visit-checkbox').click();
await page.waitForTimeout(500);
// Click merge button
const bulkActions = page.locator('.visit-bulk-actions');
const mergeButton = bulkActions.locator('button').filter({ hasText: 'Merge' });
await mergeButton.click();
// Wait for API call
await page.waitForTimeout(2000);
// Verify flash message appears
const flashMessage = page.locator('.flash-message');
await expect(flashMessage).toBeVisible({ timeout: 5000 });
// After merge, the visits should be combined into one
// So final count should be less than initial
const finalVisitCount = await page.locator('.visit-item').count();
expect(finalVisitCount).toBeLessThan(visitCount);
});
test('should shift controls when panel opens and shift back when closed', async ({ page }) => {
// Get initial position of a control element (layer control)
const layerControl = page.locator('.leaflet-control-layers');
await expect(layerControl).toBeVisible();
// Check if controls have the shifted class initially (should not)
const initiallyShifted = await layerControl.evaluate(el =>
el.classList.contains('controls-shifted')
);
expect(initiallyShifted).toBe(false);
// Open the drawer
await clickDrawerButton(page);
await page.waitForTimeout(500);
// Verify controls now have the shifted class
const shiftedAfterOpen = await layerControl.evaluate(el =>
el.classList.contains('controls-shifted')
);
expect(shiftedAfterOpen).toBe(true);
// Close the drawer
await clickDrawerButton(page);
await page.waitForTimeout(500);
// Verify controls no longer have the shifted class
const shiftedAfterClose = await layerControl.evaluate(el =>
el.classList.contains('controls-shifted')
);
expect(shiftedAfterClose).toBe(false);
});
});

View file

@ -0,0 +1,296 @@
import { test, expect } from '@playwright/test';
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
import { waitForMap, enableLayer, clickSuggestedVisit } from '../helpers/map.js';
test.describe('Suggested Visit Interactions', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
// Navigate to a date range that includes visits (last month to now)
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
if (!isPanelVisible) {
await toggleButton.click();
await page.waitForTimeout(300);
}
// Set date range to last month
await page.click('a:has-text("Last month")');
await page.waitForTimeout(2000);
await closeOnboardingModal(page);
await waitForMap(page);
await enableLayer(page, 'Suggested Visits');
await page.waitForTimeout(2000);
// Pan map to ensure a visit marker is in viewport
await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles) {
const layers = controller.visitsManager.suggestedVisitCircles._layers;
const firstVisit = Object.values(layers)[0];
if (firstVisit && firstVisit._latlng) {
controller.map.setView(firstVisit._latlng, 14);
}
}
});
await page.waitForTimeout(1000);
});
test('should click on a suggested visit and open popup', async ({ page }) => {
// Debug: Check what visit circles exist
const allCircles = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
const layers = controller.visitsManager.suggestedVisitCircles._layers;
return {
count: Object.keys(layers).length,
hasLayers: Object.keys(layers).length > 0
};
}
return { count: 0, hasLayers: false };
});
// If we have visits in the layer but can't find DOM elements, use coordinates
if (!allCircles.hasLayers) {
console.log('No suggested visits found - skipping test');
return;
}
// Click on the visit using map coordinates
const visitClicked = await clickSuggestedVisit(page);
if (!visitClicked) {
console.log('Could not click suggested visit - skipping test');
return;
}
await page.waitForTimeout(500);
// Verify popup is visible
const popup = page.locator('.leaflet-popup');
await expect(popup).toBeVisible();
});
test('should display correct content in suggested visit popup', async ({ page }) => {
// Click visit programmatically
const visitClicked = await clickSuggestedVisit(page);
if (!visitClicked) {
console.log('No suggested visits found - skipping test');
return;
}
await page.waitForTimeout(500);
// Get popup content
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent).toBeVisible();
const content = await popupContent.textContent();
// Verify visit information is present
expect(content).toMatch(/Visit|Place|Duration|Started|Ended|Suggested/i);
});
test('should confirm suggested visit', async ({ page }) => {
// Click visit programmatically
const visitClicked = await clickSuggestedVisit(page);
if (!visitClicked) {
console.log('No suggested visits found - skipping test');
return;
}
await page.waitForTimeout(500);
// Look for confirm button in popup
const confirmButton = page.locator('.leaflet-popup-content button:has-text("Confirm")').first();
const hasConfirmButton = await confirmButton.count() > 0;
if (!hasConfirmButton) {
console.log('No confirm button found - skipping test');
return;
}
// Get initial counts for both suggested and confirmed visits
const initialCounts = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return {
suggested: controller?.visitsManager?.suggestedVisitCircles?._layers
? Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length
: 0,
confirmed: controller?.visitsManager?.confirmedVisitCircles?._layers
? Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length
: 0
};
});
// Click confirm button
await confirmButton.click();
await page.waitForTimeout(1500);
// Verify the marker changed from yellow to green (suggested to confirmed)
const finalCounts = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
return {
suggested: controller?.visitsManager?.suggestedVisitCircles?._layers
? Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length
: 0,
confirmed: controller?.visitsManager?.confirmedVisitCircles?._layers
? Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length
: 0
};
});
// Verify suggested visit count decreased
expect(finalCounts.suggested).toBeLessThan(initialCounts.suggested);
// Verify confirmed visit count increased (marker changed from yellow to green)
expect(finalCounts.confirmed).toBeGreaterThan(initialCounts.confirmed);
// Verify popup is closed after confirmation
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
});
test('should decline suggested visit', async ({ page }) => {
// Click visit programmatically
const visitClicked = await clickSuggestedVisit(page);
if (!visitClicked) {
console.log('No suggested visits found - skipping test');
return;
}
await page.waitForTimeout(500);
// Look for decline button in popup
const declineButton = page.locator('.leaflet-popup-content button:has-text("Decline")').first();
const hasDeclineButton = await declineButton.count() > 0;
if (!hasDeclineButton) {
console.log('No decline button found - skipping test');
return;
}
// Get initial suggested visit count
const initialCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
}
return 0;
});
// Verify popup is visible before decline
await expect(page.locator('.leaflet-popup')).toBeVisible();
// Click decline button
await declineButton.click();
await page.waitForTimeout(1500);
// Verify popup is removed from map
const popupVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
expect(popupVisible).toBe(false);
// Verify marker is removed from map (suggested visit count decreased)
const finalCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
}
return 0;
});
expect(finalCount).toBeLessThan(initialCount);
// Verify the yellow marker is no longer visible on the map
const yellowMarkerCount = await page.locator('.leaflet-interactive[stroke="#f59e0b"]').count();
expect(yellowMarkerCount).toBeLessThan(initialCount);
});
test('should change place in dropdown for suggested visit', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#f59e0b"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No suggested visits found - skipping test');
return;
}
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Look for place dropdown/select in popup
const placeSelect = page.locator('.leaflet-popup-content select, .leaflet-popup-content [role="combobox"]').first();
const hasPlaceDropdown = await placeSelect.count() > 0;
if (!hasPlaceDropdown) {
console.log('No place dropdown found - skipping test');
return;
}
// Select a different option
await placeSelect.selectOption({ index: 1 });
await page.waitForTimeout(300);
// Verify the selection changed
const newValue = await placeSelect.inputValue();
expect(newValue).toBeTruthy();
});
test('should delete suggested visit from map', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#f59e0b"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No suggested visits found - skipping test');
return;
}
// Count initial visits
const initialVisitCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
}
return 0;
});
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Find delete button
const deleteButton = page.locator('.leaflet-popup-content button:has-text("Delete"), .leaflet-popup-content a:has-text("Delete")').first();
const hasDeleteButton = await deleteButton.count() > 0;
if (!hasDeleteButton) {
console.log('No delete button found - skipping test');
return;
}
// Handle confirmation dialog
page.once('dialog', dialog => {
expect(dialog.message()).toMatch(/delete|remove/i);
dialog.accept();
});
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify visit count decreased
const finalVisitCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.suggestedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.suggestedVisitCircles._layers).length;
}
return 0;
});
expect(finalVisitCount).toBeLessThan(initialVisitCount);
});
});

232
e2e/map/map-visits.spec.js Normal file
View file

@ -0,0 +1,232 @@
import { test, expect } from '@playwright/test';
import { navigateToMap, closeOnboardingModal } from '../helpers/navigation.js';
import { waitForMap, enableLayer, clickConfirmedVisit } from '../helpers/map.js';
test.describe('Visit Interactions', () => {
test.beforeEach(async ({ page }) => {
await navigateToMap(page);
await waitForMap(page);
// Navigate to a date range that includes visits (last month to now)
const toggleButton = page.locator('button[data-action*="map-controls#toggle"]');
const isPanelVisible = await page.locator('[data-map-controls-target="panel"]').isVisible();
if (!isPanelVisible) {
await toggleButton.click();
await page.waitForTimeout(300);
}
// Set date range to last month
await page.click('a:has-text("Last month")');
await page.waitForTimeout(2000);
await closeOnboardingModal(page);
await waitForMap(page);
await enableLayer(page, 'Confirmed Visits');
await page.waitForTimeout(2000);
// Pan map to ensure a visit marker is in viewport
await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles) {
const layers = controller.visitsManager.confirmedVisitCircles._layers;
const firstVisit = Object.values(layers)[0];
if (firstVisit && firstVisit._latlng) {
controller.map.setView(firstVisit._latlng, 14);
}
}
});
await page.waitForTimeout(1000);
});
test('should click on a confirmed visit and open popup', async ({ page }) => {
// Debug: Check what visit circles exist
const allCircles = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
const layers = controller.visitsManager.confirmedVisitCircles._layers;
return {
count: Object.keys(layers).length,
hasLayers: Object.keys(layers).length > 0
};
}
return { count: 0, hasLayers: false };
});
// If we have visits in the layer but can't find DOM elements, use coordinates
if (!allCircles.hasLayers) {
console.log('No confirmed visits found - skipping test');
return;
}
// Click on the visit using map coordinates
const visitClicked = await clickConfirmedVisit(page);
if (!visitClicked) {
console.log('Could not click visit - skipping test');
return;
}
await page.waitForTimeout(500);
// Verify popup is visible
const popup = page.locator('.leaflet-popup');
await expect(popup).toBeVisible();
});
test('should display correct content in confirmed visit popup', async ({ page }) => {
// Click visit programmatically
const visitClicked = await clickConfirmedVisit(page);
if (!visitClicked) {
console.log('No confirmed visits found - skipping test');
return;
}
await page.waitForTimeout(500);
// Get popup content
const popupContent = page.locator('.leaflet-popup-content');
await expect(popupContent).toBeVisible();
const content = await popupContent.textContent();
// Verify visit information is present
expect(content).toMatch(/Visit|Place|Duration|Started|Ended/i);
});
test('should change place in dropdown and save', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No confirmed visits found - skipping test');
return;
}
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Look for place dropdown/select in popup
const placeSelect = page.locator('.leaflet-popup-content select, .leaflet-popup-content [role="combobox"]').first();
const hasPlaceDropdown = await placeSelect.count() > 0;
if (!hasPlaceDropdown) {
console.log('No place dropdown found - skipping test');
return;
}
// Get current value
const initialValue = await placeSelect.inputValue().catch(() => null);
// Select a different option
await placeSelect.selectOption({ index: 1 });
await page.waitForTimeout(300);
// Find and click save button
const saveButton = page.locator('.leaflet-popup-content button:has-text("Save"), .leaflet-popup-content input[type="submit"]').first();
const hasSaveButton = await saveButton.count() > 0;
if (hasSaveButton) {
await saveButton.click();
await page.waitForTimeout(1000);
// Verify success message or popup closes
const popupStillVisible = await page.locator('.leaflet-popup').isVisible().catch(() => false);
// Either popup closes or stays open with updated content
expect(popupStillVisible === false || popupStillVisible === true).toBe(true);
}
});
test('should change visit name and save', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No confirmed visits found - skipping test');
return;
}
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Look for name input field
const nameInput = page.locator('.leaflet-popup-content input[type="text"]').first();
const hasNameInput = await nameInput.count() > 0;
if (!hasNameInput) {
console.log('No name input found - skipping test');
return;
}
// Change the name
const newName = `Test Visit ${Date.now()}`;
await nameInput.fill(newName);
await page.waitForTimeout(300);
// Find and click save button
const saveButton = page.locator('.leaflet-popup-content button:has-text("Save"), .leaflet-popup-content input[type="submit"]').first();
const hasSaveButton = await saveButton.count() > 0;
if (hasSaveButton) {
await saveButton.click();
await page.waitForTimeout(1000);
// Verify flash message or popup closes
const flashOrClose = await page.locator('#flash-messages [role="alert"]').isVisible({ timeout: 2000 }).catch(() => false);
expect(flashOrClose === true || flashOrClose === false).toBe(true);
}
});
test('should delete confirmed visit from map', async ({ page }) => {
const visitCircle = page.locator('.leaflet-interactive[stroke="#10b981"]').first();
const hasVisits = await visitCircle.count() > 0;
if (!hasVisits) {
console.log('No confirmed visits found - skipping test');
return;
}
// Count initial visits
const initialVisitCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
}
return 0;
});
await visitCircle.click({ force: true });
await page.waitForTimeout(500);
// Find delete button
const deleteButton = page.locator('.leaflet-popup-content button:has-text("Delete"), .leaflet-popup-content a:has-text("Delete")').first();
const hasDeleteButton = await deleteButton.count() > 0;
if (!hasDeleteButton) {
console.log('No delete button found - skipping test');
return;
}
// Handle confirmation dialog
page.once('dialog', dialog => {
expect(dialog.message()).toMatch(/delete|remove/i);
dialog.accept();
});
await deleteButton.click();
await page.waitForTimeout(2000);
// Verify visit count decreased
const finalVisitCount = await page.evaluate(() => {
const controller = window.Stimulus?.controllers.find(c => c.identifier === 'maps');
if (controller?.visitsManager?.confirmedVisitCircles?._layers) {
return Object.keys(controller.visitsManager.confirmedVisitCircles._layers).length;
}
return 0;
});
expect(finalVisitCount).toBeLessThan(initialVisitCount);
});
});

View file

@ -1,180 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Test to verify the marker factory refactoring is memory-safe
* and maintains consistent marker creation across different use cases
*/
test.describe('Marker Factory Refactoring', () => {
let page;
let context;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
// Sign in
await page.goto('/users/sign_in');
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
await page.fill('input[name="user[password]"]', 'password');
await page.click('input[type="submit"][value="Log in"]');
await page.waitForURL('/map', { timeout: 10000 });
});
test.afterAll(async () => {
await page.close();
await context.close();
});
test('should have marker factory available in bundled code', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Check if marker factory functions are available in the bundled code
const factoryAnalysis = await page.evaluate(() => {
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
const allJavaScript = scripts.join(' ');
return {
hasMarkerFactory: allJavaScript.includes('marker_factory') || allJavaScript.includes('MarkerFactory'),
hasCreateLiveMarker: allJavaScript.includes('createLiveMarker'),
hasCreateInteractiveMarker: allJavaScript.includes('createInteractiveMarker'),
hasCreateStandardIcon: allJavaScript.includes('createStandardIcon'),
totalJSSize: allJavaScript.length,
scriptCount: scripts.length
};
});
console.log('Marker factory analysis:', factoryAnalysis);
// The refactoring should be present (though may not be detectable in bundled JS)
expect(factoryAnalysis.scriptCount).toBeGreaterThan(0);
expect(factoryAnalysis.totalJSSize).toBeGreaterThan(1000);
});
test('should maintain consistent marker styling across use cases', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Check for consistent marker styling in the DOM
const markerConsistency = await page.evaluate(() => {
// Look for custom-div-icon markers (our standard marker style)
const customMarkers = document.querySelectorAll('.custom-div-icon');
const markerStyles = Array.from(customMarkers).map(marker => {
const innerDiv = marker.querySelector('div');
return {
hasInnerDiv: !!innerDiv,
backgroundColor: innerDiv?.style.backgroundColor || 'none',
borderRadius: innerDiv?.style.borderRadius || 'none',
width: innerDiv?.style.width || 'none',
height: innerDiv?.style.height || 'none'
};
});
// Check if all markers have consistent styling
const hasConsistentStyling = markerStyles.every(style =>
style.hasInnerDiv &&
style.borderRadius === '50%' &&
(style.backgroundColor === 'blue' || style.backgroundColor === 'orange') &&
style.width === style.height // Should be square
);
return {
totalCustomMarkers: customMarkers.length,
markerStyles: markerStyles.slice(0, 3), // Show first 3 for debugging
hasConsistentStyling,
allMarkersCount: document.querySelectorAll('.leaflet-marker-icon').length
};
});
console.log('Marker consistency analysis:', markerConsistency);
// Verify consistent styling if markers are present
if (markerConsistency.totalCustomMarkers > 0) {
expect(markerConsistency.hasConsistentStyling).toBe(true);
}
// Test always passes as we've verified implementation
expect(true).toBe(true);
});
test('should have memory-safe marker creation patterns', async () => {
// Navigate to map
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Monitor basic memory patterns
const memoryInfo = await page.evaluate(() => {
const memory = window.performance.memory;
return {
usedJSHeapSize: memory?.usedJSHeapSize || 0,
totalJSHeapSize: memory?.totalJSHeapSize || 0,
jsHeapSizeLimit: memory?.jsHeapSizeLimit || 0,
memoryAvailable: !!memory
};
});
console.log('Memory info:', memoryInfo);
// Verify memory monitoring is available and reasonable
if (memoryInfo.memoryAvailable) {
expect(memoryInfo.usedJSHeapSize).toBeGreaterThan(0);
expect(memoryInfo.usedJSHeapSize).toBeLessThan(memoryInfo.totalJSHeapSize);
}
// Check for memory-safe patterns in the code structure
const codeSafetyAnalysis = await page.evaluate(() => {
return {
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
hasMapElement: !!document.querySelector('#map'),
leafletLayerCount: document.querySelectorAll('.leaflet-layer').length,
markerPaneElements: document.querySelectorAll('.leaflet-marker-pane').length,
totalLeafletElements: document.querySelectorAll('[class*="leaflet"]').length
};
});
console.log('Code safety analysis:', codeSafetyAnalysis);
// Verify basic structure is sound
expect(codeSafetyAnalysis.hasLeafletContainer).toBe(true);
expect(codeSafetyAnalysis.hasMapElement).toBe(true);
expect(codeSafetyAnalysis.totalLeafletElements).toBeGreaterThan(10);
});
test('should demonstrate marker factory benefits', async () => {
// This test documents the benefits of the marker factory refactoring
console.log('=== MARKER FACTORY REFACTORING BENEFITS ===');
console.log('');
console.log('1. ✅ CODE REUSE:');
console.log(' - Single source of truth for marker styling');
console.log(' - Consistent divIcon creation across all use cases');
console.log(' - Reduced code duplication between markers.js and live_map_handler.js');
console.log('');
console.log('2. ✅ MEMORY SAFETY:');
console.log(' - createLiveMarker(): Lightweight markers for live streaming');
console.log(' - createInteractiveMarker(): Full-featured markers for static display');
console.log(' - createStandardIcon(): Shared icon factory prevents object duplication');
console.log('');
console.log('3. ✅ MAINTENANCE:');
console.log(' - Centralized marker logic in marker_factory.js');
console.log(' - Easy to update styling across entire application');
console.log(' - Clear separation between live and interactive marker features');
console.log('');
console.log('4. ✅ PERFORMANCE:');
console.log(' - Live markers skip expensive drag handlers and popups');
console.log(' - Interactive markers include full feature set only when needed');
console.log(' - No shared object references that could cause memory leaks');
console.log('');
console.log('=== REFACTORING COMPLETE ===');
// Test always passes - this is documentation
expect(true).toBe(true);
});
});

View file

@ -1,140 +0,0 @@
import { test, expect } from '@playwright/test';
/**
* Test to verify the Live Mode memory leak fix
* This test focuses on verifying the fix works by checking DOM elements
* and memory patterns rather than requiring full controller integration
*/
test.describe('Memory Leak Fix Verification', () => {
let page;
let context;
test.beforeAll(async ({ browser }) => {
context = await browser.newContext();
page = await context.newPage();
// Sign in
await page.goto('/users/sign_in');
await page.waitForSelector('input[name="user[email]"]', { timeout: 10000 });
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
await page.fill('input[name="user[password]"]', 'password');
await page.click('input[type="submit"][value="Log in"]');
await page.waitForURL('/map', { timeout: 10000 });
});
test.afterAll(async () => {
await page.close();
await context.close();
});
test('should load map page with memory leak fix implemented', async () => {
// Navigate to map with test data
await page.goto('/map?start_at=2025-06-04T00:00&end_at=2025-06-04T23:59');
await page.waitForSelector('#map', { timeout: 10000 });
await page.waitForSelector('.leaflet-container', { timeout: 10000 });
// Verify the updated appendPoint method exists and has the fix
const codeAnalysis = await page.evaluate(() => {
// Check if the maps controller exists and analyze its appendPoint method
const mapElement = document.querySelector('#map');
const controllers = mapElement?._stimulus_controllers;
const mapController = controllers?.find(c => c.identifier === 'maps');
if (mapController && mapController.appendPoint) {
const methodString = mapController.appendPoint.toString();
return {
hasController: true,
hasAppendPoint: true,
// Check for fixed patterns (absence of problematic code)
hasOldClearLayersPattern: methodString.includes('clearLayers()') && methodString.includes('L.layerGroup(this.markersArray)'),
hasOldPolylineRecreation: methodString.includes('createPolylinesLayer'),
// Check for new efficient patterns
hasIncrementalMarkerAdd: methodString.includes('this.markersLayer.addLayer(newMarker)'),
hasBoundedData: methodString.includes('> 1000'),
hasLastMarkerTracking: methodString.includes('this.lastMarkerRef'),
methodLength: methodString.length
};
}
return {
hasController: !!mapController,
hasAppendPoint: false,
controllerCount: controllers?.length || 0
};
});
console.log('Code analysis:', codeAnalysis);
// The test passes if either:
// 1. Controller is found and shows the fix is implemented
// 2. Controller is not found (which is the current issue) but the code exists in the file
if (codeAnalysis.hasController && codeAnalysis.hasAppendPoint) {
// If controller is found, verify the fix
expect(codeAnalysis.hasOldClearLayersPattern).toBe(false); // Old inefficient pattern should be gone
expect(codeAnalysis.hasIncrementalMarkerAdd).toBe(true); // New efficient pattern should exist
expect(codeAnalysis.hasBoundedData).toBe(true); // Should have bounded data structures
} else {
// Controller not found (expected based on previous tests), but we've implemented the fix
console.log('Controller not found in test environment, but fix has been implemented in code');
}
// Verify basic map functionality
const mapState = await page.evaluate(() => {
return {
hasLeafletContainer: !!document.querySelector('.leaflet-container'),
leafletElementCount: document.querySelectorAll('[class*="leaflet"]').length,
hasMapElement: !!document.querySelector('#map'),
mapHasDataController: document.querySelector('#map')?.hasAttribute('data-controller')
};
});
expect(mapState.hasLeafletContainer).toBe(true);
expect(mapState.hasMapElement).toBe(true);
expect(mapState.mapHasDataController).toBe(true);
expect(mapState.leafletElementCount).toBeGreaterThan(10); // Should have substantial Leaflet elements
});
test('should have memory-efficient appendPoint implementation in source code', async () => {
// This test verifies the fix exists in the actual source file
// by checking the current page's loaded JavaScript
const hasEfficientImplementation = await page.evaluate(() => {
// Try to access the source code through various means
const scripts = Array.from(document.querySelectorAll('script')).map(script => script.src || script.innerHTML);
const allJavaScript = scripts.join(' ');
// Check for key improvements (these should exist in the bundled JS)
const hasIncrementalAdd = allJavaScript.includes('addLayer(newMarker)');
const hasBoundedArrays = allJavaScript.includes('length > 1000');
const hasEfficientTracking = allJavaScript.includes('lastMarkerRef');
// Check that old inefficient patterns are not present together
const hasOldPattern = allJavaScript.includes('clearLayers()') &&
allJavaScript.includes('addLayer(L.layerGroup(this.markersArray))');
return {
hasIncrementalAdd,
hasBoundedArrays,
hasEfficientTracking,
hasOldPattern,
scriptCount: scripts.length,
totalJSSize: allJavaScript.length
};
});
console.log('Source code analysis:', hasEfficientImplementation);
// We expect the fix to be present in the bundled JavaScript
// Note: These might not be detected if the JS is minified/bundled differently
console.log('Memory leak fix has been implemented in maps_controller.js');
console.log('Key improvements:');
console.log('- Incremental marker addition instead of layer recreation');
console.log('- Bounded data structures (1000 point limit)');
console.log('- Efficient last marker tracking');
console.log('- Incremental polyline updates');
// Test passes regardless as we've verified the fix is in the source code
expect(true).toBe(true);
});
});

24
e2e/setup/auth.setup.js Normal file
View file

@ -0,0 +1,24 @@
import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/temp/.auth/user.json';
setup('authenticate', async ({ page }) => {
// Navigate to login page with more lenient waiting
await page.goto('/users/sign_in', {
waitUntil: 'domcontentloaded',
timeout: 30000
});
// Fill in credentials
await page.fill('input[name="user[email]"]', 'demo@dawarich.app');
await page.fill('input[name="user[password]"]', 'password');
// Click login button
await page.click('input[type="submit"][value="Log in"]');
// Wait for successful navigation
await page.waitForURL('/map', { timeout: 10000 });
// Save authentication state
await page.context().storageState({ path: authFile });
});

View file

@ -23,27 +23,42 @@ export default defineConfig({
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.BASE_URL || 'http://localhost:3000', baseURL: process.env.BASE_URL || 'http://localhost:3000',
/* Use European locale and timezone */
locale: 'en-GB',
timezoneId: 'Europe/Berlin',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
/* Take screenshot on failure */ /* Take screenshot on failure */
screenshot: 'only-on-failure', screenshot: 'only-on-failure',
/* Record video on failure */ /* Record video on failure */
video: 'retain-on-failure', video: 'retain-on-failure',
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
// Setup project - runs authentication before all tests
{
name: 'setup',
testMatch: /.*\/setup\/auth\.setup\.js/
},
{ {
name: 'chromium', name: 'chromium',
use: { ...devices['Desktop Chrome'] }, use: {
...devices['Desktop Chrome'],
// Use saved authentication state
storageState: 'e2e/temp/.auth/user.json'
},
dependencies: ['setup'],
}, },
], ],
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'RAILS_ENV=test rails server -p 3000', command: 'OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES RAILS_ENV=test rails server -p 3000',
url: 'http://localhost:3000', url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120 * 1000, timeout: 120 * 1000,

View file

@ -198,4 +198,113 @@ RSpec.describe 'Api::V1::Points', type: :request do
end end
end end
end end
describe 'DELETE /bulk_destroy' do
let(:point_ids) { points.first(5).map(&:id) }
it 'returns a successful response' do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: }
expect(response).to have_http_status(:ok)
end
it 'deletes multiple points' do
expect do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: }
end.to change { user.points.count }.by(-5)
end
it 'returns the count of deleted points' do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: }
json_response = JSON.parse(response.body)
expect(json_response['message']).to eq('Points were successfully destroyed')
expect(json_response['count']).to eq(5)
end
it 'only deletes points belonging to the current user' do
other_user = create(:user)
other_points = create_list(:point, 3, user: other_user)
all_point_ids = point_ids + other_points.map(&:id)
expect do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: all_point_ids }
end.to change { user.points.count }.by(-5)
.and change { other_user.points.count }.by(0)
end
context 'when no point_ids are provided' do
it 'returns success with zero count' do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: [] }
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['count']).to eq(0)
end
end
context 'when point_ids parameter is missing' do
it 'returns an error' do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}"
expect(response).to have_http_status(:unprocessable_entity)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('No points selected')
end
end
context 'when user is inactive' do
before do
user.update(status: :inactive, active_until: 1.day.ago)
end
it 'returns an unauthorized response' do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: }
expect(response).to have_http_status(:unauthorized)
end
it 'does not delete any points' do
expect do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: }
end.not_to(change { user.points.count })
end
end
context 'when deleting all user points' do
it 'successfully deletes all points' do
all_point_ids = points.map(&:id)
expect do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: all_point_ids }
end.to change { user.points.count }.from(15).to(0)
end
end
context 'when some point_ids do not exist' do
it 'deletes only existing points' do
non_existent_ids = [999_999, 888_888]
mixed_ids = point_ids + non_existent_ids
expect do
delete "/api/v1/points/bulk_destroy?api_key=#{user.api_key}",
params: { point_ids: mixed_ids }
end.to change { user.points.count }.by(-5)
json_response = JSON.parse(response.body)
expect(json_response['count']).to eq(5)
end
end
end
end end

View file

@ -1,128 +0,0 @@
# System Tests Documentation
## Map Interaction Tests
This directory contains comprehensive system tests for the map interaction functionality in Dawarich.
### Test Structure
The tests have been refactored to follow RSpec best practices using:
- **Helper modules** for reusable functionality
- **Shared examples** for common test patterns
- **Support files** for organization and maintainability
### Files Overview
#### Main Test File
- `map_interaction_spec.rb` - Main system test file covering all map functionality
#### Support Files
- `spec/support/system_helpers.rb` - Authentication and navigation helpers
- `spec/support/shared_examples/map_examples.rb` - Shared examples for common map functionality
- `spec/support/map_layer_helpers.rb` - Specialized helpers for layer testing
- `spec/support/polyline_popup_helpers.rb` - Helpers for testing polyline popup interactions
### Test Coverage
The system tests cover the following functionality:
#### Basic Map Functionality
- User authentication and map page access
- Leaflet map initialization and basic elements
- Map data loading and route display
#### Map Controls
- Zoom controls (zoom in/out functionality)
- Layer controls (base layer switching, overlay toggles)
- Settings panel (cog button open/close)
- Calendar panel (date navigation)
- Map statistics and scale display
- Map attributions
#### Polyline Popup Content
- **Route popup data validation** for both km and miles distance units
- Tests verify popup contains:
- **Start time** - formatted timestamp of route beginning
- **End time** - formatted timestamp of route end
- **Duration** - calculated time span of the route
- **Total Distance** - route distance in user's preferred unit (km/mi)
- **Current Speed** - speed data (always in km/h as per application logic)
#### Distance Unit Testing
- **Kilometers (km)** - Default distance unit testing
- **Miles (mi)** - Alternative distance unit testing
- Proper user settings configuration and validation
- Correct data attribute structure verification
### Key Features
#### Refactored Structure
- **DRY Principle**: Eliminated repetitive login code using shared helpers
- **Modular Design**: Separated concerns into focused helper modules
- **Reusable Components**: Shared examples for common test patterns
- **Maintainable Code**: Clear organization and documentation
#### Robust Testing Approach
- **DOM-based assertions** instead of brittle JavaScript interactions
- **Fallback strategies** for complex JavaScript interactions
- **Comprehensive validation** of user settings and data structures
- **Realistic test data** with proper GPS coordinates and timestamps
#### Performance Optimizations
- **Efficient database cleanup** without transactional fixtures
- **Targeted user creation** to avoid database conflicts
- **Optimized wait conditions** for dynamic content loading
### Test Results
- **Total Tests**: 19 examples
- **Success Rate**: 100% (19/19 passing, 0 failures)
- **Coverage**: 69.34% line coverage
- **Runtime**: ~2.5 minutes for full suite
### Technical Implementation
#### User Settings Structure
The tests properly handle the nested user settings structure:
```ruby
user_settings.dig('maps', 'distance_unit') # => 'km' or 'mi'
```
#### Polyline Popup Testing Strategy
Due to the complexity of triggering JavaScript hover events on canvas elements in headless browsers, the tests use a multi-layered approach:
1. **Primary**: JavaScript-based canvas hover simulation
2. **Secondary**: Direct polyline element interaction
3. **Fallback**: Map click interaction
4. **Validation**: Settings and data structure verification
Even when popup interaction cannot be triggered in the test environment, the tests still validate:
- User settings are correctly configured
- Map loads with proper data attributes
- Polylines are present and properly structured
- Distance units are correctly set for both km and miles
### Usage
Run all map interaction tests:
```bash
bundle exec rspec spec/system/map_interaction_spec.rb
```
Run specific test groups:
```bash
# Polyline popup tests only
bundle exec rspec spec/system/map_interaction_spec.rb -e "polyline popup content"
# Layer control tests only
bundle exec rspec spec/system/map_interaction_spec.rb -e "layer controls"
```
### Future Enhancements
The test suite is designed to be easily extensible for:
- Additional map interaction features
- New distance units or measurement systems
- Enhanced popup content validation
- More complex user interaction scenarios

View file

@ -1,44 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Authentication UI', type: :system do
let(:user) { create(:user, password: 'password123') }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
# Configure email for testing
ActionMailer::Base.default_options = { from: 'test@example.com' }
ActionMailer::Base.delivery_method = :test
ActionMailer::Base.perform_deliveries = true
ActionMailer::Base.deliveries.clear
end
describe 'Account UI' do
it 'shows the user email in the UI when signed in' do
sign_in_user(user)
expect(page).to have_current_path(map_path)
expect(page).to have_css('summary', text: user.email)
end
end
describe 'Self-hosted UI' do
context 'when self-hosted mode is enabled' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
stub_const('SELF_HOSTED', true)
end
it 'does not show registration links in the login UI' do
visit new_user_session_path
expect(page).not_to have_link('Register')
expect(page).not_to have_link('Sign up')
expect(page).not_to have_content('Register a new account')
end
end
end
end

View file

@ -1,923 +0,0 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Map Interaction', type: :system do
let(:user) { create(:user, password: 'password123') }
before do
# Stub the GitHub API call to avoid external dependencies
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
let!(:points) do
# Create a series of points that form a route
[
create(:point, user: user,
lonlat: 'POINT(13.404954 52.520008)',
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user,
lonlat: 'POINT(13.405954 52.521008)',
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user,
lonlat: 'POINT(13.406954 52.522008)',
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user,
lonlat: 'POINT(13.407954 52.523008)',
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
]
end
describe 'Map page interaction' do
context 'when user is signed in' do
include_context 'authenticated map user'
include_examples 'map basic functionality'
include_examples 'map controls'
end
context 'zoom functionality' do
include_context 'authenticated map user'
it 'allows zoom in and zoom out functionality' do
# Test zoom controls are clickable and functional
zoom_in_button = find('.leaflet-control-zoom-in')
zoom_out_button = find('.leaflet-control-zoom-out')
# Verify buttons are enabled and clickable
expect(zoom_in_button).to be_visible
expect(zoom_out_button).to be_visible
# Click zoom in button multiple times and verify it works
3.times do
zoom_in_button.click
sleep 0.5
end
# Click zoom out button multiple times and verify it works
3.times do
zoom_out_button.click
sleep 0.5
end
# Verify zoom controls are still present and functional
expect(page).to have_css('.leaflet-control-zoom-in')
expect(page).to have_css('.leaflet-control-zoom-out')
end
end
context 'settings panel' do
include_context 'authenticated map user'
it 'opens and closes settings panel with cog button' do
# Find and click the settings (cog) button - it's created dynamically by the controller
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
# Verify settings panel opens
expect(page).to have_css('.leaflet-settings-panel', visible: true)
# Click settings button again to close
settings_button.click
# Verify settings panel closes
expect(page).not_to have_css('.leaflet-settings-panel', visible: true)
end
end
context 'layer controls' do
include_context 'authenticated map user'
include_examples 'expandable layer control'
it 'allows changing map layers between OpenStreetMap and OpenTopo' do
expand_layer_control
test_base_layer_switching
collapse_layer_control
end
it 'allows enabling and disabling map layers' do
expand_layer_control
MapLayerHelpers::OVERLAY_LAYERS.each do |layer_name|
test_layer_toggle(layer_name)
end
end
end
context 'calendar panel' do
include_context 'authenticated map user'
it 'has functional calendar button' do
# Find the calendar button (📅 emoji button)
calendar_button = find('.toggle-panel-button', wait: 10)
# Verify button exists and has correct content
expect(calendar_button).to be_present
expect(calendar_button.text).to eq('📅')
# Verify button is clickable (doesn't raise errors)
expect { calendar_button.click }.not_to raise_error
sleep 1
# Try clicking again to test toggle functionality
expect { calendar_button.click }.not_to raise_error
sleep 1
# The calendar panel JavaScript interaction is complex and may not work
# reliably in headless test environment, but the button should be functional
puts 'Note: Calendar button is functional. Panel interaction may require manual testing.'
end
end
context 'map information display' do
include_context 'authenticated map user'
it 'displays map statistics and scale' do
# Check for stats control (distance and points count)
expect(page).to have_css('.leaflet-control-stats', wait: 10)
stats_text = find('.leaflet-control-stats').text
# Verify it contains distance and points information
expect(stats_text).to match(/\d+\.?\d*\s*(km|mi)/)
expect(stats_text).to match(/\d+\s*points/)
# Check for map scale control
expect(page).to have_css('.leaflet-control-scale')
expect(page).to have_css('.leaflet-control-scale-line')
end
it 'displays map attributions' do
# Check for attribution control
expect(page).to have_css('.leaflet-control-attribution')
# Verify attribution text is present
attribution_text = find('.leaflet-control-attribution').text
expect(attribution_text).not_to be_empty
# Common attribution text patterns
expect(attribution_text).to match(/©|&copy;|OpenStreetMap|contributors/i)
end
end
context 'polyline popup content' do
context 'with km distance unit' do
include_context 'authenticated map user'
it 'displays route popup with correct data in kilometers' do
# Verify the user has km as distance unit (default)
expect(user.safe_settings.distance_unit).to eq('km')
# Wait for polylines to load
expect(page).to have_css('.leaflet-overlay-pane', wait: 10)
sleep 2 # Allow polylines to fully render
# Verify that polylines are present and interactive
expect(page).to have_css('[data-maps-target="container"]')
# Check that the map has the correct user settings
map_element = find('#map')
user_settings = JSON.parse(map_element['data-user_settings'])
# The raw settings structure has distance_unit nested under maps
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
# Try to trigger polyline interaction and verify popup structure
popup_content = trigger_polyline_hover_and_get_popup
if popup_content
# Verify popup contains all required fields
expect(verify_popup_content_structure(popup_content, 'km')).to be true
# Extract and verify specific data
popup_data = extract_popup_data(popup_content)
# Verify start and end times are present and formatted
expect(popup_data[:start]).to be_present
expect(popup_data[:end]).to be_present
# Verify duration is present
expect(popup_data[:duration]).to be_present
# Verify total distance includes km unit
expect(popup_data[:total_distance]).to include('km')
# Verify current speed includes km/h unit
expect(popup_data[:current_speed]).to include('km/h')
else
# If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
puts 'Note: Polyline popup interaction could not be triggered in test environment'
end
end
end
context 'with miles distance unit' do
let(:user_with_miles) do
create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123')
end
let!(:points_for_miles_user) do
# Create a series of points that form a route for the miles user
[
create(:point, user: user_with_miles,
lonlat: 'POINT(13.404954 52.520008)',
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_miles,
lonlat: 'POINT(13.405954 52.521008)',
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_miles,
lonlat: 'POINT(13.406954 52.522008)',
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_miles,
lonlat: 'POINT(13.407954 52.523008)',
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
]
end
before do
# Reset session and sign in with the miles user
Capybara.reset_sessions!
sign_in_and_visit_map(user_with_miles)
end
it 'displays route popup with correct data in miles' do
# Verify the user has miles as distance unit
expect(user_with_miles.safe_settings.distance_unit).to eq('mi')
# Wait for polylines to load
expect(page).to have_css('.leaflet-overlay-pane', wait: 10)
sleep 2 # Allow polylines to fully render
# Verify that polylines are present and interactive
expect(page).to have_css('[data-maps-target="container"]')
# Check that the map has the correct user settings
map_element = find('#map')
user_settings = JSON.parse(map_element['data-user_settings'])
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
# Try to trigger polyline interaction and verify popup structure
popup_content = trigger_polyline_hover_and_get_popup
if popup_content
# Verify popup contains all required fields
expect(verify_popup_content_structure(popup_content, 'mi')).to be true
# Extract and verify specific data
popup_data = extract_popup_data(popup_content)
# Verify start and end times are present and formatted
expect(popup_data[:start]).to be_present
expect(popup_data[:end]).to be_present
# Verify duration is present
expect(popup_data[:duration]).to be_present
# Verify total distance includes miles unit
expect(popup_data[:total_distance]).to include('mi')
# Verify current speed is in mph for miles unit
expect(popup_data[:current_speed]).to include('mph')
else
# If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
puts 'Note: Polyline popup interaction could not be triggered in test environment'
end
end
end
end
context 'polyline popup content' do
context 'with km distance unit' do
let(:user_with_km) do
create(:user, settings: { 'maps' => { 'distance_unit' => 'km' } }, password: 'password123')
end
let!(:points_for_km_user) do
# Create a series of points that form a route for the km user
[
create(:point, user: user_with_km,
lonlat: 'POINT(13.404954 52.520008)',
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_km,
lonlat: 'POINT(13.405954 52.521008)',
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_km,
lonlat: 'POINT(13.406954 52.522008)',
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_km,
lonlat: 'POINT(13.407954 52.523008)',
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
]
end
before do
# Reset session and sign in with the km user
Capybara.reset_sessions!
sign_in_and_visit_map(user_with_km)
end
it 'displays route popup with correct data in kilometers' do
# Verify the user has km as distance unit
expect(user_with_km.safe_settings.distance_unit).to eq('km')
# Wait for polylines to load
expect(page).to have_css('.leaflet-overlay-pane', wait: 10)
sleep 2 # Allow polylines to fully render
# Verify that polylines are present and interactive
expect(page).to have_css('[data-maps-target="container"]')
# Check that the map has the correct user settings
map_element = find('#map')
user_settings = JSON.parse(map_element['data-user_settings'])
# The raw settings structure has distance_unit nested under maps
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
# Try to trigger polyline interaction and verify popup structure
popup_content = trigger_polyline_hover_and_get_popup
if popup_content
# Verify popup contains all required fields
expect(verify_popup_content_structure(popup_content, 'km')).to be true
# Extract and verify specific data
popup_data = extract_popup_data(popup_content)
# Verify start and end times are present and formatted
expect(popup_data[:start]).to be_present
expect(popup_data[:end]).to be_present
# Verify duration is present
expect(popup_data[:duration]).to be_present
# Verify total distance includes km unit
expect(popup_data[:total_distance]).to include('km')
# Verify current speed includes km/h unit
expect(popup_data[:current_speed]).to include('km/h')
else
# If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('km')
puts 'Note: Polyline popup interaction could not be triggered in test environment'
end
end
end
context 'with miles distance unit' do
let(:user_with_miles) do
create(:user, settings: { 'maps' => { 'distance_unit' => 'mi' } }, password: 'password123')
end
let!(:points_for_miles_user) do
# Create a series of points that form a route for the miles user
[
create(:point, user: user_with_miles,
lonlat: 'POINT(13.404954 52.520008)',
timestamp: 1.hour.ago.to_i, velocity: 10, battery: 80),
create(:point, user: user_with_miles,
lonlat: 'POINT(13.405954 52.521008)',
timestamp: 50.minutes.ago.to_i, velocity: 15, battery: 78),
create(:point, user: user_with_miles,
lonlat: 'POINT(13.406954 52.522008)',
timestamp: 40.minutes.ago.to_i, velocity: 12, battery: 76),
create(:point, user: user_with_miles,
lonlat: 'POINT(13.407954 52.523008)',
timestamp: 30.minutes.ago.to_i, velocity: 8, battery: 74)
]
end
before do
# Reset session and sign in with the miles user
Capybara.reset_sessions!
sign_in_and_visit_map(user_with_miles)
end
it 'displays route popup with correct data in miles' do
# Verify the user has miles as distance unit
expect(user_with_miles.safe_settings.distance_unit).to eq('mi')
# Wait for polylines to load
expect(page).to have_css('.leaflet-overlay-pane', wait: 10)
sleep 2 # Allow polylines to fully render
# Verify that polylines are present and interactive
expect(page).to have_css('[data-maps-target="container"]')
# Check that the map has the correct user settings
map_element = find('#map')
user_settings = JSON.parse(map_element['data-user_settings'])
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
# Try to trigger polyline interaction and verify popup structure
popup_content = trigger_polyline_hover_and_get_popup
if popup_content
# Verify popup contains all required fields
expect(verify_popup_content_structure(popup_content, 'mi')).to be true
# Extract and verify specific data
popup_data = extract_popup_data(popup_content)
# Verify start and end times are present and formatted
expect(popup_data[:start]).to be_present
expect(popup_data[:end]).to be_present
# Verify duration is present
expect(popup_data[:duration]).to be_present
# Verify total distance includes miles unit
expect(popup_data[:total_distance]).to include('mi')
# Verify current speed is in mph for miles unit
expect(popup_data[:current_speed]).to include('mph')
else
# If we can't trigger the popup, at least verify the setup is correct
expect(user_settings.dig('maps', 'distance_unit')).to eq('mi')
puts 'Note: Polyline popup interaction could not be triggered in test environment'
end
end
end
end
xcontext 'settings panel functionality' do
include_context 'authenticated map user'
it 'allows updating route opacity settings' do
# Open settings panel
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
expect(page).to have_css('.leaflet-settings-panel', visible: true)
# Find and update route opacity
within('.leaflet-settings-panel') do
opacity_input = find('#route-opacity')
expect(opacity_input.value).to eq('60') # Default value
# Change opacity to 80%
opacity_input.fill_in(with: '80')
# Submit the form
click_button 'Update'
end
# Wait for success flash message
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
end
it 'allows updating fog of war settings' do
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
within('.leaflet-settings-panel') do
# Update fog of war radius
fog_radius = find('#fog_of_war_meters')
fog_radius.fill_in(with: '100')
# Update fog threshold
fog_threshold = find('#fog_of_war_threshold')
fog_threshold.fill_in(with: '120')
click_button 'Update'
end
# Wait for success flash message
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
end
it 'allows updating route splitting settings' do
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
within('.leaflet-settings-panel') do
# Update meters between routes
meters_input = find('#meters_between_routes')
meters_input.fill_in(with: '750')
# Update minutes between routes
minutes_input = find('#minutes_between_routes')
minutes_input.fill_in(with: '45')
click_button 'Update'
end
# Wait for success flash message
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
end
it 'allows toggling points rendering mode' do
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
within('.leaflet-settings-panel') do
# Check current mode (should be 'raw' by default)
expect(find('#raw')).to be_checked
# Switch to simplified mode
choose('simplified')
click_button 'Update'
end
# Wait for success flash message
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
end
it 'allows toggling live map functionality' do
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
within('.leaflet-settings-panel') do
live_map_checkbox = find('#live_map_enabled')
initial_state = live_map_checkbox.checked?
# Toggle the checkbox
live_map_checkbox.click
click_button 'Update'
end
# Wait for success flash message
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
end
it 'allows toggling speed-colored routes' do
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
within('.leaflet-settings-panel') do
speed_colored_checkbox = find('#speed_colored_routes')
initial_state = speed_colored_checkbox.checked?
# Toggle speed-colored routes
speed_colored_checkbox.click
click_button 'Update'
end
# Wait for success flash message
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
end
it 'allows updating speed color scale' do
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
within('.leaflet-settings-panel') do
# Update speed color scale
scale_input = find('#speed_color_scale')
new_scale = '0:#ff0000|25:#ffff00|50:#00ff00|100:#0000ff'
scale_input.fill_in(with: new_scale)
click_button 'Update'
end
# Wait for success flash message
expect(page).to have_css('#flash-messages', text: 'Settings updated', wait: 10)
end
it 'opens and interacts with gradient editor modal' do
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
within('.leaflet-settings-panel') do
click_button 'Edit Scale'
end
# Verify modal opens
expect(page).to have_css('#gradient-editor-modal', wait: 5)
within('#gradient-editor-modal') do
expect(page).to have_content('Edit Speed Color Scale')
# Test adding a new row
click_button 'Add Row'
# Test canceling
click_button 'Cancel'
end
# Verify modal closes
expect(page).not_to have_css('#gradient-editor-modal')
end
end
context 'layer management' do
include_context 'authenticated map user'
include_examples 'expandable layer control'
it 'manages base layer switching' do
# Expand layer control
expand_layer_control
# Test switching between base layers
within('.leaflet-control-layers') do
# Should have OpenStreetMap selected by default
expect(page).to have_css('input[type="radio"]:checked')
# Try to switch to another base layer if available
radio_buttons = all('input[type="radio"]')
if radio_buttons.length > 1
# Click on a different base layer
radio_buttons.last.click
sleep 1 # Allow layer to load
end
end
collapse_layer_control
end
it 'manages overlay layer visibility' do
expand_layer_control
within('.leaflet-control-layers') do
# Test toggling overlay layers
checkboxes = all('input[type="checkbox"]')
checkboxes.each do |checkbox|
# Get the layer name from the label
layer_name = checkbox.find(:xpath, './following-sibling::span').text.strip
# Toggle the layer
initial_state = checkbox.checked?
checkbox.click
sleep 0.5
# Verify the layer state changed
expect(checkbox.checked?).to eq(!initial_state)
end
end
collapse_layer_control
end
it 'preserves layer states after settings updates' do
# Enable some layers first
expand_layer_control
# Remember initial layer states
layer_states = {}
within('.leaflet-control-layers') do
all('input[type="checkbox"]').each do |checkbox|
layer_name = checkbox.find(:xpath, './following-sibling::span').text.strip
layer_states[layer_name] = checkbox.checked?
# Enable the layer if not already enabled
checkbox.click unless checkbox.checked?
end
end
collapse_layer_control
# Update a setting
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
within('.leaflet-settings-panel') do
opacity_input = find('#route-opacity')
opacity_input.fill_in(with: '70')
click_button 'Update'
end
expect(page).to have_content('Settings updated', wait: 10)
# Verify layer control still works
expand_layer_control
expect(page).to have_css('.leaflet-control-layers-list')
collapse_layer_control
end
end
context 'calendar panel functionality' do
include_context 'authenticated map user'
it 'opens and displays calendar navigation' do
# Wait for the map controller to fully initialize and create the toggle button
expect(page).to have_css('#map', wait: 10)
expect(page).to have_css('.leaflet-container', wait: 10)
# Additional wait for the controller to finish initializing all controls
sleep 2
# Click calendar button
calendar_button = find('.toggle-panel-button', wait: 15)
expect(calendar_button).to be_visible
# Verify button is clickable
expect(calendar_button).not_to be_disabled
# For now, just verify the button exists and is functional
# The calendar panel functionality may need JavaScript debugging
# that's beyond the scope of system tests
expect(calendar_button.text).to eq('📅')
end
it 'allows year selection and month navigation' do
# This test is skipped due to calendar panel JavaScript interaction issues
# The calendar button exists but the panel doesn't open reliably in test environment
skip 'Calendar panel JavaScript interaction needs debugging'
end
it 'displays visited cities information' do
# This test is skipped due to calendar panel JavaScript interaction issues
# The calendar button exists but the panel doesn't open reliably in test environment
skip 'Calendar panel JavaScript interaction needs debugging'
end
xit 'persists panel state in localStorage' do
# Wait for the map controller to fully initialize and create the toggle button
# The button is created dynamically by the JavaScript controller
expect(page).to have_css('#map', wait: 10)
expect(page).to have_css('.leaflet-container', wait: 10)
# Additional wait for the controller to finish initializing all controls
# The toggle-panel-button is created by the addTogglePanelButton() method
# which is called after the map and all other controls are set up
sleep 2
# Now try to find the calendar button
calendar_button = nil
begin
calendar_button = find('.toggle-panel-button', wait: 15)
rescue Capybara::ElementNotFound
# If button still not found, check if map controller loaded properly
map_element = find('#map')
controller_data = map_element['data-controller']
# Log debug info for troubleshooting
puts "Map controller data: #{controller_data}"
puts "Map element classes: #{map_element[:class]}"
# Try one more time with extended wait
calendar_button = find('.toggle-panel-button', wait: 20)
end
# Verify button exists and is functional
expect(calendar_button).to be_present
calendar_button.click
# Wait for panel to appear
expect(page).to have_css('.leaflet-right-panel', visible: true, wait: 10)
# Close panel
calendar_button.click
# Wait for panel to disappear
expect(page).not_to have_css('.leaflet-right-panel', visible: true, wait: 10)
# Refresh page (user should still be signed in due to session)
page.refresh
expect(page).to have_css('#map', wait: 10)
expect(page).to have_css('.leaflet-container', wait: 10)
# Wait for controller to reinitialize after refresh
sleep 2
# Panel should remember its state (though this is hard to test reliably in system tests)
# At minimum, verify the panel can be toggled after refresh
calendar_button = find('.toggle-panel-button', wait: 15)
calendar_button.click
expect(page).to have_css('.leaflet-right-panel', wait: 10)
end
end
context 'point management' do
include_context 'authenticated map user'
xit 'displays point popups with delete functionality' do
# Wait for points to load
expect(page).to have_css('.leaflet-marker-pane', wait: 10)
# Try to find and click on a point marker
if page.has_css?('.leaflet-marker-icon')
first('.leaflet-marker-icon').click
sleep 1
# Should show popup with point information
if page.has_css?('.leaflet-popup-content')
popup_content = find('.leaflet-popup-content')
# Verify popup contains expected information
expect(popup_content).to have_content('Timestamp:')
expect(popup_content).to have_content('Latitude:')
expect(popup_content).to have_content('Longitude:')
expect(popup_content).to have_content('Speed:')
expect(popup_content).to have_content('Battery:')
# Should have delete link
expect(popup_content).to have_css('a.delete-point')
end
end
end
xit 'handles point deletion with confirmation' do
# This test would require mocking the confirmation dialog and API call
# For now, we'll just verify the delete link exists and has the right attributes
expect(page).to have_css('.leaflet-marker-pane', wait: 10)
if page.has_css?('.leaflet-marker-icon')
first('.leaflet-marker-icon').click
sleep 1
if page.has_css?('.leaflet-popup-content')
popup_content = find('.leaflet-popup-content')
if popup_content.has_css?('a.delete-point')
delete_link = popup_content.find('a.delete-point')
expect(delete_link['data-id']).to be_present
expect(delete_link.text).to eq('[Delete]')
end
end
end
end
end
context 'map initialization and error handling' do
include_context 'authenticated map user'
context 'with user having no points' do
let(:user_no_points) { create(:user, password: 'password123') }
before do
# Clear any existing session and sign in the new user
Capybara.reset_sessions!
sign_in_and_visit_map(user_no_points)
end
it 'handles empty markers array gracefully' do
# Map should still initialize
expect(page).to have_css('#map')
expect(page).to have_css('.leaflet-container')
# Should have default center
expect(page).to have_css('.leaflet-map-pane')
end
end
context 'with user having minimal settings' do
let(:user_minimal) { create(:user, settings: {}, password: 'password123') }
before do
# Clear any existing session and sign in the new user
Capybara.reset_sessions!
sign_in_and_visit_map(user_minimal)
end
it 'handles missing user settings gracefully' do
# Map should still work with defaults
expect(page).to have_css('#map')
expect(page).to have_css('.leaflet-container')
# Settings panel should work
settings_button = find('.map-settings-button', wait: 10)
settings_button.click
expect(page).to have_css('.leaflet-settings-panel')
end
end
it 'displays appropriate controls and attributions' do
# Verify essential map controls are present
expect(page).to have_css('.leaflet-control-zoom')
expect(page).to have_css('.leaflet-control-layers')
expect(page).to have_css('.leaflet-control-attribution')
expect(page).to have_css('.leaflet-control-scale')
expect(page).to have_css('.leaflet-control-stats')
# Verify custom controls (these are created dynamically by JavaScript)
expect(page).to have_css('.map-settings-button', wait: 10)
expect(page).to have_css('.toggle-panel-button', wait: 15)
end
end
context 'performance and memory management' do
include_context 'authenticated map user'
it 'properly cleans up on page navigation' do
# Navigate away and back to test cleanup
visit '/stats'
expect(page).to have_current_path('/stats')
# Navigate back to map
visit '/map'
expect(page).to have_css('#map')
expect(page).to have_css('.leaflet-container')
end
xit 'handles large datasets without crashing' do
# This test verifies the map can handle the existing dataset
# without JavaScript errors or timeouts
expect(page).to have_css('.leaflet-overlay-pane', wait: 15)
expect(page).to have_css('.leaflet-marker-pane', wait: 15)
# Try zooming and panning to test performance
zoom_in_button = find('.leaflet-control-zoom-in')
3.times do
zoom_in_button.click
sleep 0.3
end
# Map should still be responsive
expect(page).to have_css('.leaflet-container')
end
end
end
end