From 087c01535d065f8919ef3ba674034d8b7c59cf5e Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 30 Jul 2025 00:41:30 +0200 Subject: [PATCH] Add Playwright tests for map functionality. --- CHANGELOG.md | 9 +- app/javascript/controllers/maps_controller.js | 19 +- app/javascript/maps/fog_of_war.js | 55 +- e2e/map.spec.js | 819 ++++++++++++++++++ playwright.config.js | 51 ++ 5 files changed, 938 insertions(+), 15 deletions(-) create mode 100644 e2e/map.spec.js create mode 100644 playwright.config.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 434cb80e..981ae4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -# [0.30.6] - 2025-07-27 +# [0.30.7] - 2025-07-30 + +## Fixed + +- Fog of war is now working correctly. #1583 + + +# [0.30.6] - 2025-07-29 ## Changed diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 984ea671..d5483aa1 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -160,7 +160,7 @@ export default class extends BaseController { this.tracksLayer = L.layerGroup(); // Create a proper Leaflet layer for fog - this.fogOverlay = createFogOverlay(); + this.fogOverlay = new (createFogOverlay())(); // Create custom pane for areas this.map.createPane('areasPane'); @@ -201,7 +201,7 @@ export default class extends BaseController { Routes: this.polylinesLayer, Tracks: this.tracksLayer, Heatmap: this.heatmapLayer, - "Fog of War": new this.fogOverlay(), + "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayer, Areas: this.areasLayer, Photos: this.photoMarkers, @@ -514,6 +514,12 @@ export default class extends BaseController { if (this.drawControl && !this.map.hasControl && !this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { this.map.addControl(this.drawControl); } + } else if (event.name === 'Fog of War') { + // Enable fog of war when layer is added + this.fogOverlay = event.layer; + if (this.markers && this.markers.length > 0) { + this.updateFog(this.markers, this.clearFogRadius, this.fogLinethreshold); + } } // Manage pane visibility when layers are manually toggled @@ -533,6 +539,9 @@ export default class extends BaseController { if (this.drawControl && this.map._controlCorners.topleft.querySelector('.leaflet-draw')) { this.map.removeControl(this.drawControl); } + } else if (event.name === 'Fog of War') { + // Fog canvas will be automatically removed by the layer's onRemove method + this.fogOverlay = null; } }); } @@ -606,7 +615,7 @@ export default class extends BaseController { Points: this.markersLayer || L.layerGroup(), Routes: this.polylinesLayer || L.layerGroup(), Heatmap: this.heatmapLayer || L.layerGroup(), - "Fog of War": new this.fogOverlay(), + "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayer || L.layerGroup(), Areas: this.areasLayer || L.layerGroup(), Photos: this.photoMarkers || L.layerGroup() @@ -1008,7 +1017,7 @@ export default class extends BaseController { Routes: this.polylinesLayer || L.layerGroup(), Tracks: this.tracksLayer || L.layerGroup(), Heatmap: this.heatmapLayer || L.heatLayer([]), - "Fog of War": new this.fogOverlay(), + "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayer || L.layerGroup(), Areas: this.areasLayer || L.layerGroup(), Photos: this.photoMarkers || L.layerGroup() @@ -1840,7 +1849,7 @@ export default class extends BaseController { Routes: this.polylinesLayer || L.layerGroup(), Tracks: this.tracksLayer || L.layerGroup(), Heatmap: this.heatmapLayer || L.heatLayer([]), - "Fog of War": new this.fogOverlay(), + "Fog of War": this.fogOverlay, "Scratch map": this.scratchLayer || L.layerGroup(), Areas: this.areasLayer || L.layerGroup(), Photos: this.photoMarkers || L.layerGroup(), diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js index b715c5f0..49252f95 100644 --- a/app/javascript/maps/fog_of_war.js +++ b/app/javascript/maps/fog_of_war.js @@ -104,24 +104,61 @@ function getMetersPerPixel(latitude, zoom) { export function createFogOverlay() { return L.Layer.extend({ - onAdd: (map) => { + onAdd: function(map) { + this._map = map; + + // Initialize the fog canvas initializeFogCanvas(map); + // Get the map controller to access markers and settings + const mapElement = document.getElementById('map'); + if (mapElement && mapElement._stimulus_controllers) { + const controller = mapElement._stimulus_controllers.find(c => c.identifier === 'maps'); + if (controller) { + this._controller = controller; + + // Draw initial fog if we have markers + if (controller.markers && controller.markers.length > 0) { + drawFogCanvas(map, controller.markers, controller.clearFogRadius, controller.fogLinethreshold); + } + } + } + // Add resize event handlers to update fog size - map.on('resize', () => { - // Set canvas size to match map container - const mapSize = map.getSize(); - fog.width = mapSize.x; - fog.height = mapSize.y; - }); + this._onResize = () => { + const fog = document.getElementById('fog'); + if (fog) { + const mapSize = map.getSize(); + fog.width = mapSize.x; + fog.height = mapSize.y; + + // Redraw fog after resize + if (this._controller && this._controller.markers) { + drawFogCanvas(map, this._controller.markers, this._controller.clearFogRadius, this._controller.fogLinethreshold); + } + } + }; + + map.on('resize', this._onResize); }, - onRemove: (map) => { + + onRemove: function(map) { const fog = document.getElementById('fog'); if (fog) { fog.remove(); } + // Clean up event listener - map.off('resize'); + if (this._onResize) { + map.off('resize', this._onResize); + } + }, + + // Method to update fog when markers change + updateFog: function(markers, clearFogRadius, fogLinethreshold) { + if (this._map) { + drawFogCanvas(this._map, markers, clearFogRadius, fogLinethreshold); + } } }); } diff --git a/e2e/map.spec.js b/e2e/map.spec.js new file mode 100644 index 00000000..90ad62b9 --- /dev/null +++ b/e2e/map.spec.js @@ -0,0 +1,819 @@ +import { test, expect } from '@playwright/test'; + +/** + * Map functionality tests based on MAP_FUNCTIONALITY.md + * These tests cover the core features of the /map page + */ + +test.describe('Map Functionality', () => { + let page; + let context; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + // Sign in once for all tests + 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"]'); + + // Wait for redirect to map page + await page.waitForURL('/map', { timeout: 10000 }); + await page.waitForSelector('#map', { timeout: 10000 }); + await page.waitForSelector('.leaflet-container', { timeout: 10000 }); + }); + + test.afterAll(async () => { + await page.close(); + await context.close(); + }); + + test.beforeEach(async () => { + // Just navigate to map page (already authenticated) + await page.goto('/map'); + await page.waitForSelector('#map', { timeout: 10000 }); + await page.waitForSelector('.leaflet-container', { timeout: 10000 }); + }); + + test.describe('Core Map Display', () => { + test('should load the map page successfully', async () => { + await expect(page).toHaveTitle(/Map/); + await expect(page.locator('#map')).toBeVisible(); + await expect(page.locator('.leaflet-container')).toBeVisible(); + }); + + test('should display Leaflet map with default tiles', async () => { + // Check that the Leaflet map container is present + await expect(page.locator('.leaflet-container')).toBeVisible(); + + // Check for tile layers (using a more specific selector) + await expect(page.locator('.leaflet-pane.leaflet-tile-pane')).toBeAttached(); + + // Check for map controls + await expect(page.locator('.leaflet-control-zoom')).toBeVisible(); + await expect(page.locator('.leaflet-control-layers')).toBeVisible(); + }); + + test('should have scale control visible', async () => { + await expect(page.locator('.leaflet-control-scale')).toBeVisible(); + }); + + test('should display stats control with distance and points', async () => { + await expect(page.locator('.leaflet-control-stats')).toBeVisible(); + + const statsText = await page.locator('.leaflet-control-stats').textContent(); + expect(statsText).toMatch(/\d+\s+(km|mi)\s+\|\s+\d+\s+points/); + }); + }); + + test.describe('Date and Time Navigation', () => { + test('should display date navigation controls', async () => { + // Check for date inputs + await expect(page.locator('input#start_at')).toBeVisible(); + await expect(page.locator('input#end_at')).toBeVisible(); + + // Check for navigation arrows + await expect(page.locator('a:has-text("◀️")')).toBeVisible(); + await expect(page.locator('a:has-text("▶️")')).toBeVisible(); + + // Check for quick access buttons + await expect(page.locator('a:has-text("Today")')).toBeVisible(); + await expect(page.locator('a:has-text("Last 7 days")')).toBeVisible(); + await expect(page.locator('a:has-text("Last month")')).toBeVisible(); + }); + + test('should allow changing date range', async () => { + const startDateInput = page.locator('input#start_at'); + + // Change start date + const newStartDate = '2024-01-01T00:00'; + await startDateInput.fill(newStartDate); + + // Submit the form + await page.locator('input[type="submit"][value="Search"]').click(); + + // Wait for page to load + await page.waitForLoadState('networkidle'); + + // Check that URL parameters were updated + const url = page.url(); + expect(url).toContain('start_at='); + }); + + test('should navigate to today when clicking Today button', async () => { + await page.locator('a:has-text("Today")').click(); + await page.waitForLoadState('networkidle'); + + const url = page.url(); + // Allow for timezone differences by checking for current date or next day + const today = new Date().toISOString().split('T')[0]; + const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + expect(url.includes(today) || url.includes(tomorrow)).toBe(true); + }); + }); + + test.describe('Map Layer Controls', () => { + test('should have layer control panel', async () => { + const layerControl = page.locator('.leaflet-control-layers'); + await expect(layerControl).toBeVisible(); + + // Click to expand if collapsed + await layerControl.click(); + + // Check for base layer options + await expect(page.locator('.leaflet-control-layers-base')).toBeVisible(); + + // Check for overlay options + await expect(page.locator('.leaflet-control-layers-overlays')).toBeVisible(); + }); + + test('should allow toggling overlay layers', async () => { + const layerControl = page.locator('.leaflet-control-layers'); + await layerControl.click(); + + // Find the Points layer checkbox specifically + const pointsCheckbox = page.locator('.leaflet-control-layers-overlays').locator('label:has-text("Points")').locator('input'); + + // Get initial state + const initialState = await pointsCheckbox.isChecked(); + + if (initialState) { + // If points are initially visible, verify they exist, then hide them + const initialPointsCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); + + // Toggle off + await pointsCheckbox.click(); + await page.waitForTimeout(500); + + // Verify points are hidden + const afterHideCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); + expect(afterHideCount).toBe(0); + + // Toggle back on + await pointsCheckbox.click(); + await page.waitForTimeout(500); + + // Verify points are visible again + const afterShowCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); + expect(afterShowCount).toBe(initialPointsCount); + } else { + // If points are initially hidden, show them first + await pointsCheckbox.click(); + await page.waitForTimeout(500); + + // Verify points are now visible + const pointsCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); + expect(pointsCount).toBeGreaterThan(0); + + // Toggle back off + await pointsCheckbox.click(); + await page.waitForTimeout(500); + + // Verify points are hidden again + const finalCount = await page.locator('.leaflet-marker-pane .leaflet-marker-icon').count(); + expect(finalCount).toBe(0); + } + + // Ensure checkbox state matches what we expect + const finalState = await pointsCheckbox.isChecked(); + expect(finalState).toBe(initialState); + }); + + test('should switch between base map layers', async () => { + const layerControl = page.locator('.leaflet-control-layers'); + await layerControl.click(); + + // Find base layer radio buttons + const baseLayerRadios = page.locator('.leaflet-control-layers-base input[type="radio"]'); + const secondRadio = baseLayerRadios.nth(1); + + if (await secondRadio.isVisible()) { + await secondRadio.check(); + await page.waitForTimeout(1000); // Wait for tiles to load + + await expect(secondRadio).toBeChecked(); + } + }); + }); + + test.describe('Settings Panel', () => { + test('should open and close settings panel', async () => { + // Find and click settings button (gear icon) + const settingsButton = page.locator('.map-settings-button'); + await expect(settingsButton).toBeVisible(); + + await settingsButton.click(); + + // Check that settings panel is visible + await expect(page.locator('.leaflet-settings-panel')).toBeVisible(); + await expect(page.locator('#settings-form')).toBeVisible(); + + // Close settings panel + await settingsButton.click(); + + // Settings panel should be hidden + await expect(page.locator('.leaflet-settings-panel')).not.toBeVisible(); + }); + + test('should allow adjusting route opacity', async () => { + // First ensure routes are visible + const layerControl = page.locator('.leaflet-control-layers'); + await layerControl.click(); + + const routesCheckbox = page.locator('.leaflet-control-layers-overlays').locator('label:has-text("Routes")').locator('input'); + if (await routesCheckbox.isVisible() && !(await routesCheckbox.isChecked())) { + await routesCheckbox.check(); + await page.waitForTimeout(2000); + } + + // Check if routes exist before testing opacity + const routesExist = await page.locator('.leaflet-overlay-pane svg path').count() > 0; + + if (routesExist) { + // Get initial opacity of routes before changing + const initialOpacity = await page.locator('.leaflet-overlay-pane svg path').first().evaluate(el => { + return window.getComputedStyle(el).opacity; + }); + + const settingsButton = page.locator('.map-settings-button'); + await settingsButton.click(); + + const opacityInput = page.locator('#route-opacity'); + await expect(opacityInput).toBeVisible(); + + // Change opacity value to 30% + await opacityInput.fill('30'); + + // Submit settings + await page.locator('#settings-form button[type="submit"]').click(); + + // Wait for settings to be applied + await page.waitForTimeout(2000); + + // Check that the route opacity actually changed + const newOpacity = await page.locator('.leaflet-overlay-pane svg path').first().evaluate(el => { + return window.getComputedStyle(el).opacity; + }); + + // The new opacity should be approximately 0.3 (30%) + const numericOpacity = parseFloat(newOpacity); + expect(numericOpacity).toBeCloseTo(0.3, 1); + expect(numericOpacity).not.toBe(parseFloat(initialOpacity)); + } else { + // If no routes exist, just verify the settings can be changed + const settingsButton = page.locator('.map-settings-button'); + await settingsButton.click(); + + const opacityInput = page.locator('#route-opacity'); + await expect(opacityInput).toBeVisible(); + + await opacityInput.fill('30'); + await page.locator('#settings-form button[type="submit"]').click(); + await page.waitForTimeout(1000); + + // Verify the setting was persisted by reopening panel + await settingsButton.click(); + await expect(page.locator('#route-opacity')).toHaveValue('30'); + } + }); + + test('should allow configuring fog of war settings', async () => { + const settingsButton = page.locator('.map-settings-button'); + await settingsButton.click(); + + const fogRadiusInput = page.locator('#fog_of_war_meters'); + await expect(fogRadiusInput).toBeVisible(); + + // Change values + await fogRadiusInput.fill('100'); + + const fogThresholdInput = page.locator('#fog_of_war_threshold'); + await expect(fogThresholdInput).toBeVisible(); + + await fogThresholdInput.fill('120'); + + // Verify values were set + await expect(fogRadiusInput).toHaveValue('100'); + await expect(fogThresholdInput).toHaveValue('120'); + + // Submit settings + await page.locator('#settings-form button[type="submit"]').click(); + await page.waitForTimeout(1000); + + // Verify settings were applied by reopening panel and checking values + await settingsButton.click(); + await expect(page.locator('#fog_of_war_meters')).toHaveValue('100'); + await expect(page.locator('#fog_of_war_threshold')).toHaveValue('120'); + }); + + test('should enable fog of war and verify it works', async () => { + // First, enable the Fog of War layer + const layerControl = page.locator('.leaflet-control-layers'); + await layerControl.click(); + + // Wait for layer control to be fully expanded + await page.waitForTimeout(500); + + // Find and enable the Fog of War layer checkbox + // Try multiple approaches to find the Fog of War checkbox + let fogCheckbox = page.locator('.leaflet-control-layers-overlays').locator('label:has-text("Fog of War")').locator('input'); + + // Alternative approach if first one doesn't work + if (!(await fogCheckbox.isVisible())) { + fogCheckbox = page.locator('.leaflet-control-layers-overlays').locator('input').filter({ + has: page.locator(':text("Fog of War")') + }); + } + + // Another fallback approach + if (!(await fogCheckbox.isVisible())) { + // Look for any checkbox followed by text containing "Fog of War" + const allCheckboxes = page.locator('.leaflet-control-layers-overlays input[type="checkbox"]'); + const count = await allCheckboxes.count(); + for (let i = 0; i < count; i++) { + const checkbox = allCheckboxes.nth(i); + const nextSibling = checkbox.locator('+ span'); + if (await nextSibling.isVisible() && (await nextSibling.textContent())?.includes('Fog of War')) { + fogCheckbox = checkbox; + break; + } + } + } + + if (await fogCheckbox.isVisible()) { + // Check initial state + const initiallyChecked = await fogCheckbox.isChecked(); + + // Enable fog of war if not already enabled + if (!initiallyChecked) { + await fogCheckbox.check(); + await page.waitForTimeout(2000); // Wait for fog canvas to be created + } + + // Verify that fog canvas is created and attached to the map + await expect(page.locator('#fog')).toBeAttached(); + + // Verify the fog canvas has the correct properties + const fogCanvas = page.locator('#fog'); + await expect(fogCanvas).toHaveAttribute('id', 'fog'); + + // Check that the canvas has non-zero dimensions (indicating it's been sized) + const canvasBox = await fogCanvas.boundingBox(); + expect(canvasBox?.width).toBeGreaterThan(0); + expect(canvasBox?.height).toBeGreaterThan(0); + + // Verify canvas styling indicates it's positioned correctly + const canvasStyle = await fogCanvas.evaluate(el => { + const style = window.getComputedStyle(el); + return { + position: style.position, + zIndex: style.zIndex, + pointerEvents: style.pointerEvents + }; + }); + + expect(canvasStyle.position).toBe('absolute'); + expect(canvasStyle.zIndex).toBe('400'); + expect(canvasStyle.pointerEvents).toBe('none'); + + // Test disabling fog of war + await fogCheckbox.uncheck(); + await page.waitForTimeout(1000); + + // Fog canvas should be removed when layer is disabled + await expect(page.locator('#fog')).not.toBeAttached(); + + // Re-enable to test toggle functionality + await fogCheckbox.check(); + await page.waitForTimeout(1000); + + // Should be back + await expect(page.locator('#fog')).toBeAttached(); + } else { + // If fog layer checkbox is not found, skip fog testing but verify layer control works + await expect(page.locator('.leaflet-control-layers-overlays')).toBeVisible(); + } + }); + + test('should toggle points rendering mode', async () => { + const settingsButton = page.locator('.map-settings-button'); + await settingsButton.click(); + + const rawModeRadio = page.locator('#raw'); + const simplifiedModeRadio = page.locator('#simplified'); + + await expect(rawModeRadio).toBeVisible(); + await expect(simplifiedModeRadio).toBeVisible(); + + // Get initial mode + const initiallyRaw = await rawModeRadio.isChecked(); + + // Test toggling between modes + if (initiallyRaw) { + // Switch to simplified mode + await simplifiedModeRadio.check(); + await expect(simplifiedModeRadio).toBeChecked(); + await expect(rawModeRadio).not.toBeChecked(); + } else { + // Switch to raw mode + await rawModeRadio.check(); + await expect(rawModeRadio).toBeChecked(); + await expect(simplifiedModeRadio).not.toBeChecked(); + } + + // Submit settings + await page.locator('#settings-form button[type="submit"]').click(); + await page.waitForTimeout(1000); + + // Verify settings were applied by reopening panel and checking selection persisted + await settingsButton.click(); + if (initiallyRaw) { + await expect(page.locator('#simplified')).toBeChecked(); + } else { + await expect(page.locator('#raw')).toBeChecked(); + } + }); + }); + + test.describe('Calendar Panel', () => { + test('should open and close calendar panel', async () => { + // Find and click calendar button + const calendarButton = page.locator('.toggle-panel-button'); + await expect(calendarButton).toBeVisible(); + await expect(calendarButton).toHaveText('📅'); + + // Get initial panel state (should be hidden) + const panel = page.locator('.leaflet-right-panel'); + const initiallyVisible = await panel.isVisible(); + + await calendarButton.click(); + await page.waitForTimeout(1000); // Wait for panel animation + + // Check that calendar panel state changed + await expect(panel).toBeAttached(); + const afterClickVisible = await panel.isVisible(); + expect(afterClickVisible).not.toBe(initiallyVisible); + + // Close panel + await calendarButton.click(); + await page.waitForTimeout(500); + + // Panel should return to initial state + const finalVisible = await panel.isVisible(); + expect(finalVisible).toBe(initiallyVisible); + }); + + test('should display year selection and months grid', async () => { + const calendarButton = page.locator('.toggle-panel-button'); + await calendarButton.click(); + await page.waitForTimeout(1000); // Wait for panel animation + + // Verify panel is now visible + const panel = page.locator('.leaflet-right-panel'); + await expect(panel).toBeVisible(); + + // Check year selector - may be hidden but attached + await expect(page.locator('#year-select')).toBeAttached(); + + // Check months grid - may be hidden but attached + await expect(page.locator('#months-grid')).toBeAttached(); + + // Check that there are month buttons + const monthButtons = page.locator('#months-grid a.btn'); + const monthCount = await monthButtons.count(); + expect(monthCount).toBeGreaterThan(0); + expect(monthCount).toBeLessThanOrEqual(12); // Should not exceed 12 months + + // Check whole year link - may be hidden but attached + await expect(page.locator('#whole-year-link')).toBeAttached(); + + // Verify at least one month button is clickable + if (monthCount > 0) { + const firstMonth = monthButtons.first(); + await expect(firstMonth).toHaveAttribute('href'); + } + }); + + test('should display visited cities section', async () => { + const calendarButton = page.locator('.toggle-panel-button'); + await calendarButton.click(); + await page.waitForTimeout(1000); // Wait for panel animation + + // Verify panel is open + await expect(page.locator('.leaflet-right-panel')).toBeVisible(); + + // Check visited cities container + const citiesContainer = page.locator('#visited-cities-container'); + await expect(citiesContainer).toBeAttached(); + + // Check visited cities list + const citiesList = page.locator('#visited-cities-list'); + await expect(citiesList).toBeAttached(); + + // The cities list might be empty or populated depending on test data + // At minimum, verify the structure is there for cities to be displayed + const listExists = await citiesList.isVisible(); + if (listExists) { + // If list is visible, it should be a proper container for city data + expect(await citiesList.getAttribute('id')).toBe('visited-cities-list'); + } + }); + }); + + test.describe('Visits System', () => { + test('should have visits drawer button', async () => { + const visitsButton = page.locator('.drawer-button'); + await expect(visitsButton).toBeVisible(); + }); + + test('should open and close visits drawer', async () => { + const visitsButton = page.locator('.drawer-button'); + await visitsButton.click(); + + // Check that visits drawer opens + await expect(page.locator('#visits-drawer')).toBeVisible(); + await expect(page.locator('#visits-list')).toBeVisible(); + + // Close drawer + await visitsButton.click(); + + // Drawer should slide closed (but element might still be in DOM) + await page.waitForTimeout(500); + }); + + test('should have area selection tool button', async () => { + const selectionButton = page.locator('#selection-tool-button'); + await expect(selectionButton).toBeVisible(); + await expect(selectionButton).toHaveText('⚓️'); + }); + + test('should activate selection mode', async () => { + const selectionButton = page.locator('#selection-tool-button'); + await selectionButton.click(); + + // Button should become active + await expect(selectionButton).toHaveClass(/active/); + + // Click again to deactivate + await selectionButton.click(); + + // Button should no longer be active + await expect(selectionButton).not.toHaveClass(/active/); + }); + }); + + test.describe('Interactive Map Elements', () => { + test('should allow map dragging and zooming', async () => { + const mapContainer = page.locator('.leaflet-container'); + + // Get initial zoom level + const initialZoomButton = page.locator('.leaflet-control-zoom-in'); + await expect(initialZoomButton).toBeVisible(); + + // Zoom in + await initialZoomButton.click(); + await page.waitForTimeout(500); + + // Zoom out + const zoomOutButton = page.locator('.leaflet-control-zoom-out'); + await zoomOutButton.click(); + await page.waitForTimeout(500); + + // Test map dragging + await mapContainer.hover(); + await page.mouse.down(); + await page.mouse.move(100, 100); + await page.mouse.up(); + await page.waitForTimeout(300); + }); + + test('should display markers if data is available', async () => { + // Check if there are any markers on the map + const markers = page.locator('.leaflet-marker-pane .leaflet-marker-icon'); + + // If markers exist, test their functionality + if (await markers.first().isVisible()) { + await expect(markers.first()).toBeVisible(); + + // Test marker click (should open popup) + await markers.first().click(); + await page.waitForTimeout(500); + + // Check if popup appeared + const popup = page.locator('.leaflet-popup'); + await expect(popup).toBeVisible(); + } + }); + + test('should display routes/polylines if data is available', async () => { + // Check if there are any polylines on the map + const polylines = page.locator('.leaflet-overlay-pane svg path'); + + if (await polylines.first().isVisible()) { + await expect(polylines.first()).toBeVisible(); + + // Test polyline hover + await polylines.first().hover(); + await page.waitForTimeout(500); + } + }); + }); + + test.describe('Areas Management', () => { + test('should have draw control when areas layer is active', async () => { + // Open layer control + const layerControl = page.locator('.leaflet-control-layers'); + await layerControl.click(); + + // Find and enable Areas layer + const areasCheckbox = page.locator('.leaflet-control-layers-overlays').locator('input').filter({ hasText: /Areas/ }).first(); + + if (await areasCheckbox.isVisible()) { + await areasCheckbox.check(); + + // Check for draw control + await expect(page.locator('.leaflet-draw')).toBeVisible(); + + // Check for circle draw tool + await expect(page.locator('.leaflet-draw-draw-circle')).toBeVisible(); + } + }); + }); + + test.describe('Performance and Loading', () => { + test('should load within reasonable time', async () => { + const startTime = Date.now(); + + await page.goto('/map'); + await page.waitForSelector('.leaflet-container', { timeout: 15000 }); + + const loadTime = Date.now() - startTime; + expect(loadTime).toBeLessThan(15000); // Should load within 15 seconds + }); + + test('should handle network errors gracefully', async () => { + // Should still show the page structure even if tiles don't load + await expect(page.locator('#map')).toBeVisible(); + + // Test with offline network after initial load + await page.context().setOffline(true); + + // Page should still be functional even when offline + await expect(page.locator('.leaflet-container')).toBeVisible(); + + // Restore network + await page.context().setOffline(false); + }); + }); + + test.describe('Responsive Design', () => { + test('should adapt to mobile viewport', async () => { + // Set mobile viewport + await page.setViewportSize({ width: 375, height: 667 }); + + await page.goto('/map'); + await page.waitForSelector('.leaflet-container'); + + // Map should still be visible and functional + await expect(page.locator('.leaflet-container')).toBeVisible(); + await expect(page.locator('.leaflet-control-zoom')).toBeVisible(); + + // Date controls should be responsive + await expect(page.locator('input#start_at')).toBeVisible(); + await expect(page.locator('input#end_at')).toBeVisible(); + }); + + test('should work on tablet viewport', async () => { + // Set tablet viewport + await page.setViewportSize({ width: 768, height: 1024 }); + + await page.goto('/map'); + await page.waitForSelector('.leaflet-container'); + + await expect(page.locator('.leaflet-container')).toBeVisible(); + await expect(page.locator('.leaflet-control-layers')).toBeVisible(); + }); + }); + + test.describe('Accessibility', () => { + test('should have proper accessibility attributes', async () => { + // Check for map container accessibility + const mapContainer = page.locator('#map'); + await expect(mapContainer).toHaveAttribute('data-controller', 'maps points'); + + // Check form labels + await expect(page.locator('label[for="start_at"]')).toBeVisible(); + await expect(page.locator('label[for="end_at"]')).toBeVisible(); + + // Check button accessibility + const searchButton = page.locator('input[type="submit"][value="Search"]'); + await expect(searchButton).toBeVisible(); + }); + + test('should support keyboard navigation', async () => { + // Test tab navigation through form elements + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + + // Should be able to focus on interactive elements + const focusedElement = page.locator(':focus'); + await expect(focusedElement).toBeVisible(); + }); + }); + + test.describe('Data Integration', () => { + test('should handle empty data state', async () => { + // Navigate to a date range with no data + await page.goto('/map?start_at=1990-01-01T00:00&end_at=1990-01-02T00:00'); + await page.waitForSelector('.leaflet-container'); + + // Map should still load + await expect(page.locator('.leaflet-container')).toBeVisible(); + + // Stats should show zero + const statsControl = page.locator('.leaflet-control-stats'); + if (await statsControl.isVisible()) { + const statsText = await statsControl.textContent(); + expect(statsText).toContain('0'); + } + }); + + test('should update URL parameters when navigating', async () => { + const initialUrl = page.url(); + + // Click on a navigation arrow + await page.locator('a:has-text("▶️")').click(); + await page.waitForLoadState('networkidle'); + + const newUrl = page.url(); + expect(newUrl).not.toBe(initialUrl); + expect(newUrl).toContain('start_at='); + expect(newUrl).toContain('end_at='); + }); + }); + + test.describe('Error Handling', () => { + test('should display error messages for invalid date ranges', async () => { + // Get initial URL to compare after invalid date submission + const initialUrl = page.url(); + + // Try to set end date before start date + await page.locator('input#start_at').fill('2024-12-31T23:59'); + await page.locator('input#end_at').fill('2024-01-01T00:00'); + + await page.locator('input[type="submit"][value="Search"]').click(); + await page.waitForLoadState('networkidle'); + + // Should handle gracefully (either show error or correct the dates) + await expect(page.locator('.leaflet-container')).toBeVisible(); + + // Verify that either: + // 1. An error message is shown, OR + // 2. The dates were automatically corrected, OR + // 3. The URL reflects the corrected date range + const finalUrl = page.url(); + const hasErrorMessage = await page.locator('.alert, .error, [class*="error"]').count() > 0; + const urlChanged = finalUrl !== initialUrl; + + // At least one of these should be true - either error shown or dates handled + expect(hasErrorMessage || urlChanged).toBe(true); + }); + + test('should handle JavaScript errors gracefully', async () => { + // Listen for console errors + const consoleErrors = []; + page.on('console', message => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); + + await page.goto('/map'); + await page.waitForSelector('.leaflet-container'); + + // Map should still function despite any minor JS errors + await expect(page.locator('.leaflet-container')).toBeVisible(); + + // Critical functionality should work + const layerControl = page.locator('.leaflet-control-layers'); + await expect(layerControl).toBeVisible(); + + // Settings button should be functional + const settingsButton = page.locator('.map-settings-button'); + await expect(settingsButton).toBeVisible(); + + // Calendar button should be functional + const calendarButton = page.locator('.toggle-panel-button'); + await expect(calendarButton).toBeVisible(); + + // Test that a basic interaction still works + await layerControl.click(); + await expect(page.locator('.leaflet-control-layers-list')).toBeVisible(); + }); + }); +}); \ No newline at end of file diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 00000000..8057408f --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,51 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ['html'], + ['junit', { outputFile: 'test-results/results.xml' }] + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.BASE_URL || 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + + /* Record video on failure */ + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'RAILS_ENV=test rails server -p 3000', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); \ No newline at end of file