diff --git a/app/javascript/controllers/maps_v2_realtime_controller.js b/app/javascript/controllers/maps_v2_realtime_controller.js index 973d7ef7..5e560083 100644 --- a/app/javascript/controllers/maps_v2_realtime_controller.js +++ b/app/javascript/controllers/maps_v2_realtime_controller.js @@ -147,36 +147,62 @@ export default class extends Controller { /** * Handle new point + * Point data is broadcast as: [lat, lon, battery, altitude, timestamp, velocity, id, country_name] */ - handleNewPoint(point) { + handleNewPoint(pointData) { const mapsController = this.mapsV2Controller if (!mapsController) { console.warn('[Realtime Controller] Maps V2 controller not found') return } - // Add point to map - const pointsLayer = mapsController.pointsLayer - if (pointsLayer) { - const currentData = pointsLayer.data - const features = currentData.features || [] + console.log('[Realtime Controller] Received point data:', pointData) - features.push({ - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [point.longitude, point.latitude] - }, - properties: point - }) + // Parse point data from array format + const [lat, lon, battery, altitude, timestamp, velocity, id, countryName] = pointData - pointsLayer.update({ - type: 'FeatureCollection', - features - }) - - Toast.info('New location recorded') + // Get points layer from layer manager + const pointsLayer = mapsController.layerManager?.getLayer('points') + if (!pointsLayer) { + console.warn('[Realtime Controller] Points layer not found') + return } + + // Get current data + const currentData = pointsLayer.data || { type: 'FeatureCollection', features: [] } + const features = [...(currentData.features || [])] + + // Add new point + features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [parseFloat(lon), parseFloat(lat)] + }, + properties: { + id: parseInt(id), + latitude: parseFloat(lat), + longitude: parseFloat(lon), + battery: parseFloat(battery) || null, + altitude: parseFloat(altitude) || null, + timestamp: timestamp, + velocity: parseFloat(velocity) || null, + country_name: countryName || null + } + }) + + // Update layer with new data + pointsLayer.update({ + type: 'FeatureCollection', + features + }) + + console.log('[Realtime Controller] Added new point to map:', id) + + // Zoom to the new point + this.zoomToPoint(parseFloat(lon), parseFloat(lat)) + + Toast.info('New location recorded') } /** @@ -199,6 +225,29 @@ export default class extends Controller { Toast.info(notification.message || 'New notification') } + /** + * Zoom map to a specific point + */ + zoomToPoint(longitude, latitude) { + const mapsController = this.mapsV2Controller + if (!mapsController || !mapsController.map) { + console.warn('[Realtime Controller] Map not available for zooming') + return + } + + const map = mapsController.map + + // Fly to the new point with a smooth animation + map.flyTo({ + center: [longitude, latitude], + zoom: Math.max(map.getZoom(), 14), // Zoom to at least level 14, or keep current zoom if higher + duration: 2000, // 2 second animation + essential: true // This animation is considered essential with respect to prefers-reduced-motion + }) + + console.log('[Realtime Controller] Zoomed to point:', longitude, latitude) + } + /** * Update connection indicator */ diff --git a/app/views/maps_v2/index.html.erb b/app/views/maps_v2/index.html.erb index 4e9d5882..ce499d17 100644 --- a/app/views/maps_v2/index.html.erb +++ b/app/views/maps_v2/index.html.erb @@ -3,15 +3,12 @@ <%= render 'shared/map/date_navigation_v2', start_at: @start_at, end_at: @end_at %>
-
diff --git a/e2e/v2/realtime/live-mode.spec.js b/e2e/v2/realtime/live-mode.spec.js new file mode 100644 index 00000000..f81f62e9 --- /dev/null +++ b/e2e/v2/realtime/live-mode.spec.js @@ -0,0 +1,305 @@ +import { test, expect } from '@playwright/test' +import { closeOnboardingModal } from '../../helpers/navigation.js' +import { navigateToMapsV2, waitForMapLibre, waitForLoadingComplete } from '../helpers/setup.js' + +test.describe('Live Mode', () => { + test.beforeEach(async ({ page }) => { + await navigateToMapsV2(page) + await closeOnboardingModal(page) + await waitForMapLibre(page) + await waitForLoadingComplete(page) + await page.waitForTimeout(1000) + }) + + test.describe('Live Mode Toggle', () => { + test('should have live mode toggle in settings', async ({ page }) => { + // Open settings + await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click() + await page.waitForTimeout(300) + + // Click Settings tab + await page.locator('button[data-tab="settings"]').click() + await page.waitForTimeout(300) + + // Verify Live Mode toggle exists + const liveModeToggle = page.locator('[data-maps-v2-realtime-target="liveModeToggle"]') + await expect(liveModeToggle).toBeVisible() + + // Verify label text + const label = page.locator('label:has-text("Live Mode")') + await expect(label).toBeVisible() + + // Verify description text + const description = page.locator('text=Show new points in real-time') + await expect(description).toBeVisible() + }) + + test('should toggle live mode on and off', async ({ page }) => { + // Open settings + await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click() + await page.waitForTimeout(300) + await page.locator('button[data-tab="settings"]').click() + await page.waitForTimeout(300) + + const liveModeToggle = page.locator('[data-maps-v2-realtime-target="liveModeToggle"]') + + // Get initial state + const initialState = await liveModeToggle.isChecked() + + // Toggle it + await liveModeToggle.click() + await page.waitForTimeout(500) + + // Verify state changed + const newState = await liveModeToggle.isChecked() + expect(newState).toBe(!initialState) + + // Toggle back + await liveModeToggle.click() + await page.waitForTimeout(500) + + // Verify state reverted + const finalState = await liveModeToggle.isChecked() + expect(finalState).toBe(initialState) + }) + + test('should show toast notification when toggling live mode', async ({ page }) => { + // Open settings + await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click() + await page.waitForTimeout(300) + await page.locator('button[data-tab="settings"]').click() + await page.waitForTimeout(300) + + const liveModeToggle = page.locator('[data-maps-v2-realtime-target="liveModeToggle"]') + const initialState = await liveModeToggle.isChecked() + + // Toggle and watch for toast + await liveModeToggle.click() + + // Wait for toast to appear + const expectedMessage = initialState ? 'Live mode disabled' : 'Live mode enabled' + const toast = page.locator('.toast, [role="alert"]').filter({ hasText: expectedMessage }) + await expect(toast).toBeVisible({ timeout: 3000 }) + }) + }) + + test.describe('Realtime Controller', () => { + test('should initialize realtime controller when enabled', async ({ page }) => { + const realtimeControllerExists = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps-v2-realtime"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2-realtime') + return controller !== undefined + }) + + expect(realtimeControllerExists).toBe(true) + }) + + test('should have access to maps-v2 controller', async ({ page }) => { + const hasMapsController = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps-v2-realtime"]') + const app = window.Stimulus || window.Application + const realtimeController = app?.getControllerForElementAndIdentifier(element, 'maps-v2-realtime') + const mapsController = realtimeController?.mapsV2Controller + return mapsController !== undefined && mapsController.map !== undefined + }) + + expect(hasMapsController).toBe(true) + }) + + test('should initialize ActionCable channels', async ({ page }) => { + // Wait for channels to be set up + await page.waitForTimeout(2000) + + const channelsInitialized = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps-v2-realtime"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2-realtime') + return controller?.channels !== undefined + }) + + expect(channelsInitialized).toBe(true) + }) + }) + + test.describe('Connection Indicator', () => { + test('should have connection indicator element in DOM', async ({ page }) => { + // Connection indicator exists but is hidden by default + const indicator = page.locator('.connection-indicator') + + // Should exist in DOM + await expect(indicator).toHaveCount(1) + + // Should be hidden (not active) without real ActionCable connection + const isActive = await indicator.evaluate(el => el.classList.contains('active')) + expect(isActive).toBe(false) + }) + + test('should have connection status classes', async ({ page }) => { + const indicator = page.locator('.connection-indicator') + + // Should have disconnected class by default (before connection) + const hasDisconnectedClass = await indicator.evaluate(el => + el.classList.contains('disconnected') + ) + + expect(hasDisconnectedClass).toBe(true) + }) + + test.skip('should show connection indicator when ActionCable connects', async ({ page }) => { + // This test requires actual ActionCable connection + // The indicator becomes visible (.active class added) only when channels connect + + // Wait for connection + await page.waitForTimeout(3000) + + const indicator = page.locator('.connection-indicator') + + // Should be visible with active class + await expect(indicator).toHaveClass(/active/) + await expect(indicator).toBeVisible() + }) + + test.skip('should show appropriate connection text when active', async ({ page }) => { + // This test requires actual ActionCable connection + // The indicator text shows via CSS ::before pseudo-element + + // Wait for connection + await page.waitForTimeout(3000) + + const indicatorText = page.locator('.connection-indicator .indicator-text') + + // Should show either "Connected" or "Connecting..." + const text = await indicatorText.evaluate(el => { + return window.getComputedStyle(el, '::before').content.replace(/['"]/g, '') + }) + + expect(['Connected', 'Connecting...']).toContain(text) + }) + }) + + test.describe('Point Handling', () => { + test('should have handleNewPoint method', async ({ page }) => { + const hasMethod = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps-v2-realtime"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2-realtime') + return typeof controller?.handleNewPoint === 'function' + }) + + expect(hasMethod).toBe(true) + }) + + test('should have zoomToPoint method', async ({ page }) => { + const hasMethod = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps-v2-realtime"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2-realtime') + return typeof controller?.zoomToPoint === 'function' + }) + + expect(hasMethod).toBe(true) + }) + + test.skip('should add new point to map when received', async ({ page }) => { + // This test requires actual ActionCable broadcast + // Skipped as it needs backend point creation + + // Get initial point count + const initialCount = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps-v2"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2') + const pointsLayer = controller?.layerManager?.getLayer('points') + return pointsLayer?.data?.features?.length || 0 + }) + + // Simulate point broadcast (would need real backend) + // const newPoint = [52.5200, 13.4050, 85, 10, '2025-01-01T12:00:00Z', 5, 999, 'Germany'] + + // Wait for point to be added + // await page.waitForTimeout(1000) + + // Verify point was added + // const newCount = await page.evaluate(() => { ... }) + // expect(newCount).toBe(initialCount + 1) + }) + + test.skip('should zoom to new point location', async ({ page }) => { + // This test requires actual ActionCable broadcast + // Skipped as it needs backend point creation + + // Get initial map center + // Broadcast new point at specific location + // Verify map center changed to new point location + }) + }) + + test.describe('Live Mode State Persistence', () => { + test('should maintain live mode state after toggling', async ({ page }) => { + // Open settings + await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click() + await page.waitForTimeout(300) + await page.locator('button[data-tab="settings"]').click() + await page.waitForTimeout(300) + + const liveModeToggle = page.locator('[data-maps-v2-realtime-target="liveModeToggle"]') + + // Enable live mode + if (!await liveModeToggle.isChecked()) { + await liveModeToggle.click() + await page.waitForTimeout(500) + } + + // Verify it's enabled + expect(await liveModeToggle.isChecked()).toBe(true) + + // Close and reopen settings + await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click() + await page.waitForTimeout(300) + await page.locator('[data-action="click->maps-v2#toggleSettings"]').first().click() + await page.waitForTimeout(300) + await page.locator('button[data-tab="settings"]').click() + await page.waitForTimeout(300) + + // Should still be enabled + expect(await liveModeToggle.isChecked()).toBe(true) + }) + }) + + test.describe('Error Handling', () => { + test('should handle missing maps controller gracefully', async ({ page }) => { + // This is tested by the controller's defensive checks + const hasDefensiveChecks = await page.evaluate(() => { + const element = document.querySelector('[data-controller*="maps-v2-realtime"]') + const app = window.Stimulus || window.Application + const controller = app?.getControllerForElementAndIdentifier(element, 'maps-v2-realtime') + + // The controller should have the mapsV2Controller getter + return typeof controller?.mapsV2Controller !== 'undefined' + }) + + expect(hasDefensiveChecks).toBe(true) + }) + + test('should handle missing points layer gracefully', async ({ page }) => { + // Console errors should not crash the app + let consoleErrors = [] + page.on('console', msg => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()) + } + }) + + // Wait for initialization + await page.waitForTimeout(2000) + + // Should not have critical errors + const hasCriticalErrors = consoleErrors.some(err => + err.includes('TypeError') || err.includes('Cannot read') + ) + + expect(hasCriticalErrors).toBe(false) + }) + }) +})