From 0bf724f0c091bee239ade8fb5fa47acd24632de0 Mon Sep 17 00:00:00 2001 From: Ulf Gebhardt Date: Thu, 2 Apr 2026 20:59:49 +0200 Subject: [PATCH] fix(webapp): fix & improve map substantially (#9481) --- backend/src/db/seed.ts | 114 ++- webapp/components/Map/MapStylesButtons.vue | 56 -- webapp/jest.config.js | 2 +- webapp/locales/de.json | 4 +- webapp/locales/en.json | 4 +- webapp/locales/es.json | 4 +- webapp/locales/fr.json | 4 +- webapp/locales/it.json | 4 +- webapp/locales/nl.json | 4 +- webapp/locales/pl.json | 4 +- webapp/locales/pt.json | 4 +- webapp/locales/ru.json | 4 +- webapp/locales/sq.json | 4 +- webapp/locales/uk.json | 4 +- webapp/pages/map.spec.js | 1038 ++++++++++++++++++-- webapp/pages/map.vue | 592 +++++++++-- 16 files changed, 1607 insertions(+), 239 deletions(-) delete mode 100644 webapp/components/Map/MapStylesButtons.vue diff --git a/backend/src/db/seed.ts b/backend/src/db/seed.ts index 88873f200..11a4960eb 100644 --- a/backend/src/db/seed.ts +++ b/backend/src/db/seed.ts @@ -1301,13 +1301,123 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }) // eslint-disable-next-line no-console - console.log('seed', 'users additional') + console.log('seed', 'users additional with map locations around Zwingenberg') + + // Region Hessen (Mapbox-compatible hierarchy: place -> region -> country) + const Hessen = await Factory.build('location', { + id: 'region.8967011281068080', + name: 'Hessen', + type: 'region', + lng: 8.6528, + lat: 50.6521, + nameDE: 'Hessen', + nameEN: 'Hesse', + nameES: 'Hesse', + nameFR: 'Hesse', + nameIT: 'Assia', + namePT: 'Hessen', + nameNL: 'Hessen', + namePL: 'Hesja', + nameRU: 'Гессен', + }) + await Hessen.relateTo(Germany, 'isIn') + + // 50 villages around Zwingenberg (64673), Zwingenberg excluded + // Mapbox-compatible: type 'place', realistic IDs + const zwingenbergVillages = [ + // Bergstraße (west) + { id: 'place.8652241', name: 'Alsbach-Hähnlein', lat: 49.7389, lng: 8.6331 }, + { id: 'place.8652242', name: 'Bickenbach', lat: 49.7567, lng: 8.6178 }, + { id: 'place.8652243', name: 'Seeheim-Jugenheim', lat: 49.7631, lng: 8.6506 }, + { id: 'place.8652244', name: 'Bensheim', lat: 49.6812, lng: 8.6167 }, + { id: 'place.8652245', name: 'Auerbach', lat: 49.7053, lng: 8.6389 }, + { id: 'place.8652246', name: 'Heppenheim', lat: 49.6428, lng: 8.6392 }, + { id: 'place.8652247', name: 'Lorsch', lat: 49.6539, lng: 8.5678 }, + { id: 'place.8652248', name: 'Einhausen', lat: 49.6775, lng: 8.5578 }, + { id: 'place.8652249', name: 'Gernsheim', lat: 49.7528, lng: 8.4906 }, + { id: 'place.8652250', name: 'Pfungstadt', lat: 49.8056, lng: 8.6042 }, + // Odenwald (east) + { id: 'place.8652251', name: 'Reichenbach', lat: 49.725, lng: 8.67 }, + { id: 'place.8652252', name: 'Lautertal', lat: 49.7253, lng: 8.6914 }, + { id: 'place.8652253', name: 'Lindenfels', lat: 49.6836, lng: 8.7781 }, + { id: 'place.8652254', name: 'Modautal', lat: 49.7736, lng: 8.7258 }, + { id: 'place.8652255', name: 'Mühltal', lat: 49.8003, lng: 8.6917 }, + { id: 'place.8652256', name: 'Ober-Ramstadt', lat: 49.8306, lng: 8.7486 }, + { id: 'place.8652257', name: 'Reinheim', lat: 49.8289, lng: 8.8356 }, + { id: 'place.8652258', name: 'Groß-Bieberau', lat: 49.7906, lng: 8.8281 }, + { id: 'place.8652259', name: 'Fränkisch-Crumbach', lat: 49.745, lng: 8.8444 }, + { id: 'place.8652260', name: 'Brensbach', lat: 49.7742, lng: 8.8819 }, + // Ried (west/southwest) + { id: 'place.8652261', name: 'Bürstadt', lat: 49.6433, lng: 8.4506 }, + { id: 'place.8652262', name: 'Lampertheim', lat: 49.5978, lng: 8.47 }, + { id: 'place.8652263', name: 'Biblis', lat: 49.6878, lng: 8.4531 }, + { id: 'place.8652264', name: 'Groß-Rohrheim', lat: 49.7228, lng: 8.4822 }, + { id: 'place.8652265', name: 'Riedstadt', lat: 49.835, lng: 8.4944 }, + { id: 'place.8652266', name: 'Stockstadt am Rhein', lat: 49.8094, lng: 8.4656 }, + { id: 'place.8652267', name: 'Biebesheim', lat: 49.7806, lng: 8.4672 }, + { id: 'place.8652268', name: 'Trebur', lat: 49.9211, lng: 8.4081 }, + { id: 'place.8652269', name: 'Nauheim', lat: 49.9456, lng: 8.4494 }, + { id: 'place.8652270', name: 'Griesheim', lat: 49.8619, lng: 8.5722 }, + // Darmstadt area (north) + { id: 'place.8652271', name: 'Roßdorf', lat: 49.8572, lng: 8.7578 }, + { id: 'place.8652272', name: 'Messel', lat: 49.9333, lng: 8.75 }, + { id: 'place.8652273', name: 'Eppertshausen', lat: 49.95, lng: 8.85 }, + { id: 'place.8652274', name: 'Münster', lat: 49.9253, lng: 8.8653 }, + { id: 'place.8652275', name: 'Dieburg', lat: 49.8983, lng: 8.8467 }, + { id: 'place.8652276', name: 'Babenhausen', lat: 49.965, lng: 8.9511 }, + { id: 'place.8652277', name: 'Schaafheim', lat: 49.9244, lng: 8.9703 }, + { id: 'place.8652278', name: 'Groß-Umstadt', lat: 49.8667, lng: 8.9333 }, + { id: 'place.8652279', name: 'Otzberg', lat: 49.82, lng: 8.91 }, + { id: 'place.8652280', name: 'Höchst im Odenwald', lat: 49.7994, lng: 8.9986 }, + // Further south + { id: 'place.8652281', name: 'Mörlenbach', lat: 49.5969, lng: 8.7378 }, + { id: 'place.8652282', name: 'Rimbach', lat: 49.6256, lng: 8.7611 }, + { id: 'place.8652283', name: 'Fürth', lat: 49.6522, lng: 8.7789 }, + { id: 'place.8652284', name: 'Grasellenbach', lat: 49.6353, lng: 8.8531 }, + { id: 'place.8652285', name: 'Wald-Michelbach', lat: 49.57, lng: 8.83 }, + { id: 'place.8652286', name: 'Abtsteinach', lat: 49.5536, lng: 8.78 }, + { id: 'place.8652287', name: 'Gorxheimertal', lat: 49.5322, lng: 8.7322 }, + { id: 'place.8652288', name: 'Viernheim', lat: 49.5403, lng: 8.5783 }, + { id: 'place.8652289', name: 'Weinheim', lat: 49.5489, lng: 8.6639 }, + { id: 'place.8652290', name: 'Hemsbach', lat: 49.59, lng: 8.65 }, + ] + + // Create village location nodes (one per village, shared by all users in that village) + const villageLocationNodes: (typeof Hamburg)[] = [] + for (const village of zwingenbergVillages) { + const location = await Factory.build('location', { + id: village.id, + name: village.name, + type: 'place', + lng: village.lng, + lat: village.lat, + nameDE: village.name, + nameEN: village.name, + nameES: village.name, + nameFR: village.name, + nameIT: village.name, + namePT: village.name, + nameNL: village.name, + namePL: village.name, + nameRU: village.name, + }) + await location.relateTo(Hessen, 'isIn') + villageLocationNodes.push(location) + } + + // Create 1000 additional users with locations assigned during creation // eslint-disable-next-line @typescript-eslint/no-explicit-any const additionalUsers: any[] = [] for (let i = 0; i < 1000; i++) { + if (i % 100 === 0) { + // eslint-disable-next-line no-console + console.log('seed', `additional users ${i}/1000`) + } const user = await Factory.build('user') await jennyRostock.relateTo(user, 'following') await user.relateTo(jennyRostock, 'following') + // Assign village location (round-robin across 50 villages = ~20 users per village) + await user.relateTo(villageLocationNodes[i % villageLocationNodes.length], 'isIn') additionalUsers.push(user) const userObj = await user.toJson() @@ -1321,6 +1431,8 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl'] }, }) } + // eslint-disable-next-line no-console + console.log('seed', 'additional users 1000/1000 done') // Jenny's first 99 additional users all redeemed code ABCDEF // eslint-disable-next-line no-console diff --git a/webapp/components/Map/MapStylesButtons.vue b/webapp/components/Map/MapStylesButtons.vue deleted file mode 100644 index a6b55d24e..000000000 --- a/webapp/components/Map/MapStylesButtons.vue +++ /dev/null @@ -1,56 +0,0 @@ - - - - - diff --git a/webapp/jest.config.js b/webapp/jest.config.js index 491b1999b..9bfb65e38 100644 --- a/webapp/jest.config.js +++ b/webapp/jest.config.js @@ -18,7 +18,7 @@ module.exports = { ], coverageThreshold: { global: { - lines: 86, + lines: 87, }, }, coverageProvider: 'v8', diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 33d1cb0d5..09875ac82 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -754,6 +754,7 @@ "event": "Veranstaltung", "group": "Gruppe", "theUser": "Meine Position", + "title": "Legende", "user": "Nutzer" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Dunkel", "outdoors": "Landschaft", "satellite": "Satellit", - "streets": "Straßen" + "streets": "Straßen", + "title": "Kartenstil" } }, "modals": { diff --git a/webapp/locales/en.json b/webapp/locales/en.json index d3b035780..02224468a 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -754,6 +754,7 @@ "event": "Event", "group": "Group", "theUser": "My position", + "title": "Legend", "user": "User" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Dark", "outdoors": "Outdoors", "satellite": "Satellite", - "streets": "Streets" + "streets": "Streets", + "title": "Map style" } }, "modals": { diff --git a/webapp/locales/es.json b/webapp/locales/es.json index 6e15ea36d..62932aea6 100644 --- a/webapp/locales/es.json +++ b/webapp/locales/es.json @@ -754,6 +754,7 @@ "event": "Evento", "group": "Grupo", "theUser": "Mi posición", + "title": "Leyenda", "user": "Usuario" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Oscuro", "outdoors": "Exterior", "satellite": "Satélite", - "streets": "Calles" + "streets": "Calles", + "title": "Estilo de mapa" } }, "modals": { diff --git a/webapp/locales/fr.json b/webapp/locales/fr.json index 430a2478c..42bf16bb1 100644 --- a/webapp/locales/fr.json +++ b/webapp/locales/fr.json @@ -754,6 +754,7 @@ "event": "Événement", "group": "Groupe", "theUser": "Ma position", + "title": "Légende", "user": "Utilisateur" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Sombre", "outdoors": "Plein air", "satellite": "Satellite", - "streets": "Rues" + "streets": "Rues", + "title": "Style de carte" } }, "modals": { diff --git a/webapp/locales/it.json b/webapp/locales/it.json index b16aa2b09..f95eb8df2 100644 --- a/webapp/locales/it.json +++ b/webapp/locales/it.json @@ -754,6 +754,7 @@ "event": "Evento", "group": "Gruppo", "theUser": "La mia posizione", + "title": "Legenda", "user": "Utente" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Scuro", "outdoors": "All'aperto", "satellite": "Satellite", - "streets": "Strade" + "streets": "Strade", + "title": "Stile mappa" } }, "modals": { diff --git a/webapp/locales/nl.json b/webapp/locales/nl.json index cba61891b..276675294 100644 --- a/webapp/locales/nl.json +++ b/webapp/locales/nl.json @@ -754,6 +754,7 @@ "event": "Evenement", "group": "Groep", "theUser": "Mijn positie", + "title": "Legenda", "user": "Gebruiker" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Donker", "outdoors": "Buiten", "satellite": "Satelliet", - "streets": "Straten" + "streets": "Straten", + "title": "Kaartstijl" } }, "modals": { diff --git a/webapp/locales/pl.json b/webapp/locales/pl.json index 421aeb837..5689f14d2 100644 --- a/webapp/locales/pl.json +++ b/webapp/locales/pl.json @@ -754,6 +754,7 @@ "event": "Wydarzenie", "group": "Grupa", "theUser": "Moja pozycja", + "title": "Legenda", "user": "Użytkownik" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Ciemny", "outdoors": "Na zewnątrz", "satellite": "Satelita", - "streets": "Ulice" + "streets": "Ulice", + "title": "Styl mapy" } }, "modals": { diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index a3798a909..a6c4f55f7 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -754,6 +754,7 @@ "event": "Evento", "group": "Grupo", "theUser": "Minha posição", + "title": "Legenda", "user": "Usuário" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Escuro", "outdoors": "Ao ar livre", "satellite": "Satélite", - "streets": "Ruas" + "streets": "Ruas", + "title": "Estilo do mapa" } }, "modals": { diff --git a/webapp/locales/ru.json b/webapp/locales/ru.json index ed3cd42cb..29a3cd7b9 100644 --- a/webapp/locales/ru.json +++ b/webapp/locales/ru.json @@ -754,6 +754,7 @@ "event": "Событие", "group": "Группа", "theUser": "Моя позиция", + "title": "Легенда", "user": "Пользователь" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Тёмная", "outdoors": "Природа", "satellite": "Спутник", - "streets": "Улицы" + "streets": "Улицы", + "title": "Стиль карты" } }, "modals": { diff --git a/webapp/locales/sq.json b/webapp/locales/sq.json index ddaf2ff30..446d32091 100644 --- a/webapp/locales/sq.json +++ b/webapp/locales/sq.json @@ -754,6 +754,7 @@ "event": "Ngjarje", "group": "Grup", "theUser": "Pozicioni im", + "title": "Legjenda", "user": "Përdorues" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "E errët", "outdoors": "Jashtë", "satellite": "Satelit", - "streets": "Rrugë" + "streets": "Rrugë", + "title": "Stili i hartës" } }, "modals": { diff --git a/webapp/locales/uk.json b/webapp/locales/uk.json index e4c917a64..fcb900940 100644 --- a/webapp/locales/uk.json +++ b/webapp/locales/uk.json @@ -754,6 +754,7 @@ "event": "Подія", "group": "Група", "theUser": "Моя позиція", + "title": "Легенда", "user": "Користувач" }, "markerTypes": { @@ -767,7 +768,8 @@ "dark": "Темна", "outdoors": "На відкритому повітрі", "satellite": "Супутник", - "streets": "Вулиці" + "streets": "Вулиці", + "title": "Стиль карти" } }, "modals": { diff --git a/webapp/pages/map.spec.js b/webapp/pages/map.spec.js index a0ad95605..91db84b30 100644 --- a/webapp/pages/map.spec.js +++ b/webapp/pages/map.spec.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line no-unused-vars import mapboxgl from 'mapbox-gl' import { mount } from '@vue/test-utils' import VueMeta from 'vue-meta' @@ -11,7 +10,16 @@ jest.mock('@mapbox/mapbox-gl-geocoder', () => { }) jest.mock('mapbox-gl', () => { + const popupInstance = { + isOpen: jest.fn(() => false), + remove: jest.fn(), + setLngLat: jest.fn(() => popupInstance), + setHTML: jest.fn(() => popupInstance), + setDOMContent: jest.fn(() => popupInstance), + addTo: jest.fn(() => popupInstance), + } return { + accessToken: null, GeolocateControl: jest.fn(), Map: jest.fn(() => ({ addControl: jest.fn(), @@ -19,20 +27,8 @@ jest.mock('mapbox-gl', () => { remove: jest.fn(), })), NavigationControl: jest.fn(), - Popup: jest.fn(() => { - return { - isOpen: jest.fn(), - setLngLat: jest.fn(() => { - return { - setHTML: jest.fn(() => { - return { - addTo: jest.fn(), - } - }), - } - }), - } - }), + Popup: jest.fn(() => popupInstance), + __popupInstance: popupInstance, } }) @@ -45,125 +41,1027 @@ const mapOnMock = jest.fn((key, ...args) => { onEventMocks[key] = args[args.length - 1] }) const mapAddControlMock = jest.fn() +const mapAddSourceMock = jest.fn() +const mapAddLayerMock = jest.fn() +const mapAddImageMock = jest.fn() +const mapSetStyleMock = jest.fn() +const mapSetLayoutPropertyMock = jest.fn() +const mapFlyToMock = jest.fn() +const containerClickHandlers = [] +const mapGetContainerMock = jest.fn(() => ({ + addEventListener: jest.fn((event, handler) => { + if (event === 'click') containerClickHandlers.push(handler) + }), +})) +const mapQueryRenderedFeaturesMock = jest.fn(() => []) + +const mapLoadImageMock = jest.fn((url, callback) => { + callback(null, 'image-data') +}) + +const mapGetStyleMock = jest.fn(() => ({ + layers: [ + { id: 'some-label', layout: {} }, + { id: 'water-fill', layout: {} }, + ], +})) const mapMock = { on: mapOnMock, addControl: mapAddControlMock, - loadImage: jest.fn(), - getCanvas: jest.fn(() => { - return { - style: { - cursor: 'pointer', - }, - } - }), + addSource: mapAddSourceMock, + addLayer: mapAddLayerMock, + addImage: mapAddImageMock, + loadImage: mapLoadImageMock, + setStyle: mapSetStyleMock, + setLayoutProperty: mapSetLayoutPropertyMock, + flyTo: mapFlyToMock, + getContainer: mapGetContainerMock, + queryRenderedFeatures: mapQueryRenderedFeaturesMock, + getStyle: mapGetStyleMock, + getCanvas: jest.fn(() => ({ + style: { cursor: '' }, + })), } const stubs = { - 'client-only': true, - 'mgl-map': true, + 'client-only': { + template: '
', + }, + 'mgl-map': { + template: '
', + }, MglFullscreenControl: true, MglNavigationControl: true, MglGeolocateControl: true, MglScaleControl: true, + empty: true, } +const currentUser = { + id: 'u1', + slug: 'peter', + name: 'Peter Lustig', + about: 'Some about text', +} + +const userLocation = { + id: 'loc1', + name: 'Berlin', + lng: 13.38333, + lat: 52.51667, +} + +const otherUsers = [ + { + id: 'u2', + slug: 'bob', + name: 'Bob', + about: 'Builder', + location: { id: 'loc2', name: 'Hamburg', lng: 10.0, lat: 53.55 }, + }, + { + id: 'u3', + slug: 'jenny', + name: 'Jenny', + about: null, + location: { id: 'loc3', name: 'Paris', lng: 2.35183, lat: 48.85658 }, + }, +] + +const groups = [ + { + id: 'g1', + slug: 'journalism', + name: 'Investigative Journalism', + about: 'Investigating things', + location: { id: 'loc2', name: 'Hamburg', lng: 10.0, lat: 53.55 }, + }, +] + +const posts = [ + { + id: 'e1', + slug: 'kindergeburtstag', + title: 'Kindergeburtstag', + content: '

Fun event

', + postType: 'Event', + eventLocation: { id: 'loc4', name: 'Stuttgart', lng: 9.17702, lat: 48.78232 }, + }, +] + describe('map', () => { let wrapper let mocks + let store beforeEach(() => { + jest.clearAllMocks() MapboxGeocoder.mockClear() + containerClickHandlers.length = 0 + + // Reset popup mock + mapboxgl.__popupInstance.isOpen.mockReturnValue(false) + mocks = { $t: (t) => t, + $i18n: { locale: () => 'en' }, $env: { MAPBOX_TOKEN: 'MY_MAPBOX_TOKEN', }, $toast: { error: jest.fn(), }, + $apollo: { + query: jest.fn().mockResolvedValue({ + data: { User: [{ location: null }] }, + }), + }, + $filters: { + removeHtml: jest.fn((html) => html.replace(/<[^>]*>/g, '')), + }, } + + store = new Vuex.Store({ + getters: { 'auth/user': () => currentUser }, + }) }) - describe('mount', () => { - const Wrapper = () => { - const store = new Vuex.Store({ getters: { 'auth/user': () => false } }) - return mount(Map, { - mocks, - localVue, - stubs, - store, - }) - } + const createWrapper = () => { + return mount(Map, { + mocks, + localVue, + stubs, + store, + }) + } + describe('without MAPBOX_TOKEN', () => { beforeEach(() => { - wrapper = Wrapper() + mocks.$env.MAPBOX_TOKEN = '' + wrapper = createWrapper() }) - it('renders', () => { - expect(wrapper.element.tagName).toBe('DIV') + it('shows empty alert', () => { + expect(wrapper.find('empty-stub').exists()).toBe(true) + }) + + it('does not render map', () => { + expect(wrapper.find('.mgl-map-stub').exists()).toBe(false) + }) + }) + + describe('with MAPBOX_TOKEN', () => { + beforeEach(() => { + wrapper = createWrapper() + }) + + it('renders map page', () => { + expect(wrapper.find('.map-page').exists()).toBe(true) }) it('has correct content', () => { expect(wrapper.vm.$metaInfo.title).toBe('map.pageTitle') }) - describe('trigger map load', () => { - beforeEach(async () => { - await wrapper.find('mgl-map-stub').vm.$emit('load', { map: mapMock }) + it('renders legend with all marker types', () => { + const items = wrapper.findAll('.map-legend-item') + expect(items.length).toBe(4) + }) + + it('legend is closed by default on mobile', () => { + expect(wrapper.vm.legendOpen).toBe(false) + }) + + describe('legend toggle', () => { + it('toggles legendOpen on click', async () => { + const toggle = wrapper.find('.map-legend-toggle') + await toggle.trigger('click') + expect(wrapper.vm.legendOpen).toBe(true) + await toggle.trigger('click') + expect(wrapper.vm.legendOpen).toBe(false) }) - it('initializes on style load', () => { + it('shows arrow up when closed', () => { + expect(wrapper.find('.map-legend-arrow').text()).toBe('▲') + }) + + it('shows arrow down when open', async () => { + await wrapper.find('.map-legend-toggle').trigger('click') + expect(wrapper.find('.map-legend-arrow').text()).toBe('▼') + }) + }) + + describe('computed properties', () => { + it('mapCenter returns default center without user location', () => { + expect(wrapper.vm.mapCenter).toEqual([10.452764, 51.165707]) + }) + + it('mapZoom returns 4 without user location', () => { + expect(wrapper.vm.mapZoom).toBe(4) + }) + + it('mapCenter returns user coordinates when available', async () => { + await wrapper.setData({ currentUserCoordinates: [13.38, 52.52] }) + expect(wrapper.vm.mapCenter).toEqual([13.38, 52.52]) + }) + + it('mapZoom returns 10 when user has location', async () => { + await wrapper.setData({ currentUserCoordinates: [13.38, 52.52] }) + expect(wrapper.vm.mapZoom).toBe(10) + }) + + it('availableStyles has 4 styles with titles', () => { + const styles = wrapper.vm.availableStyles + expect(Object.keys(styles)).toEqual(['outdoors', 'streets', 'satellite', 'dark']) + expect(styles.outdoors.title).toBe('map.styles.outdoors') + }) + + it('mapOptions uses outdoors style by default', () => { + expect(wrapper.vm.mapOptions.style).toContain('outdoors') + }) + + it('mapOptions uses activeStyle when set', async () => { + await wrapper.setData({ activeStyle: 'mapbox://custom' }) + expect(wrapper.vm.mapOptions.style).toBe('mapbox://custom') + }) + + it('isPreparedForMarkers is false initially', () => { + expect(wrapper.vm.isPreparedForMarkers).toBe(false) + }) + }) + + describe('updateMapPosition', () => { + it('sets top from navbar height', () => { + const navbar = document.createElement('div') + navbar.id = 'navbar' + Object.defineProperty(navbar, 'offsetHeight', { value: 60 }) + document.body.appendChild(navbar) + wrapper.vm.updateMapPosition() + expect(wrapper.vm.$el.style.top).toBe('60px') + document.body.removeChild(navbar) + }) + + it('sets bottom to 0 when footer is hidden', () => { + wrapper.vm.updateMapPosition() + expect(wrapper.vm.$el.style.bottom).toBe('0px') + }) + + it('sets bottom from footer height when visible', () => { + const footer = document.createElement('div') + footer.id = 'footer' + Object.defineProperty(footer, 'offsetHeight', { value: 40 }) + document.body.appendChild(footer) + wrapper.vm.updateMapPosition() + expect(wrapper.vm.$el.style.bottom).toBe('40px') + document.body.removeChild(footer) + }) + }) + + describe('onMapLoad', () => { + beforeEach(async () => { + wrapper.vm.onMapLoad({ map: mapMock }) + }) + + it('registers style.load event', () => { expect(mapOnMock).toHaveBeenCalledWith('style.load', expect.any(Function)) }) - it('initializes on mouseenter', () => { + it('registers mouseenter event on markers layer', () => { expect(mapOnMock).toHaveBeenCalledWith('mouseenter', 'markers', expect.any(Function)) }) - it('initializes on mouseleave', () => { + it('registers mouseleave event on markers layer', () => { expect(mapOnMock).toHaveBeenCalledWith('mouseleave', 'markers', expect.any(Function)) }) - it('calls add map control', () => { + it('registers click event on markers layer', () => { + expect(mapOnMock).toHaveBeenCalledWith('click', 'markers', expect.any(Function)) + }) + + it('adds geocoder control', () => { + expect(MapboxGeocoder).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: 'MY_MAPBOX_TOKEN', + marker: false, + }), + ) expect(mapAddControlMock).toHaveBeenCalled() }) - describe('trigger style load event', () => { - let spy - beforeEach(() => { - spy = jest.spyOn(wrapper.vm, 'loadMarkersIconsAndAddMarkers') - onEventMocks['style.load']() - }) + it('adds style switcher control', () => { + // style switcher is the second addControl call (after geocoder) + const styleSwitcherCall = mapAddControlMock.mock.calls.find( + (call) => call[1] === 'top-right' && call[0].onAdd, + ) + expect(styleSwitcherCall).toBeTruthy() + }) - it('calls loadMarkersIconsAndAddMarkers', () => { - expect(spy).toHaveBeenCalled() + it('creates popup', () => { + expect(mapboxgl.Popup).toHaveBeenCalledWith({ + closeButton: true, + closeOnClick: true, + maxWidth: '300px', }) }) - describe('trigger mouse enter event', () => { + it('calls loadMarkersIconsAndAddMarkers', () => { + expect(mapLoadImageMock).toHaveBeenCalled() + }) + + describe('style.load event', () => { + it('reloads marker icons', () => { + mapLoadImageMock.mockClear() + mapAddImageMock.mockClear() + onEventMocks['style.load']() + expect(mapLoadImageMock).toHaveBeenCalledTimes(4) + }) + + it('re-adds source and layer after style change when markers exist', async () => { + // Prepare marker data so addMarkersOnCheckPrepared adds source/layer + await wrapper.setData({ + users: otherUsers, + groups, + posts, + currentUserCoordinates: null, + currentUserLocation: null, + }) + wrapper.vm.markers.isGeoJSON = true + wrapper.vm.markers.isSourceAndLayerAdded = true + + mapAddSourceMock.mockClear() + mapAddLayerMock.mockClear() + + onEventMocks['style.load']() + + // loadMarkersIconsAndAddMarkers uses Promise.all().then() — flush microtasks + await wrapper.vm.$nextTick() + + // After style.load, isSourceAndLayerAdded is reset and icons reload, + // then addMarkersOnCheckPrepared re-adds source and layer + expect(wrapper.vm.markers.isSourceAndLayerAdded).toBe(true) + expect(mapAddSourceMock).toHaveBeenCalledWith('markers', expect.any(Object)) + expect(mapAddLayerMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'markers', type: 'symbol' }), + ) + }) + }) + + describe('style switcher control', () => { + let container + beforeEach(() => { - onEventMocks.mouseenter({ - features: [ - { - geometry: { - coordinates: [100, 200], - }, - properties: { - type: 'user', - }, - }, - ], - lngLat: { - lng: 100, - lat: 200, + const styleSwitcherCall = mapAddControlMock.mock.calls.find( + (call) => call[1] === 'top-right' && call[0].onAdd, + ) + container = styleSwitcherCall[0].onAdd() + }) + + it('creates container with correct class', () => { + expect(container.className).toBe('mapboxgl-ctrl map-style-switcher') + }) + + it('has a toggle button', () => { + const toggle = container.querySelector('.map-style-switcher-toggle') + expect(toggle).toBeTruthy() + expect(toggle.querySelector('svg')).toBeTruthy() + }) + + it('has a popover with 4 style buttons', () => { + const buttons = container.querySelectorAll('.map-style-popover-btn') + expect(buttons.length).toBe(4) + }) + + it('marks active style', () => { + const active = container.querySelector('.map-style-popover-btn--active') + expect(active).toBeTruthy() + }) + + it('toggle opens popover', () => { + const toggle = container.querySelector('.map-style-switcher-toggle') + const popover = container.querySelector('.map-style-popover') + toggle.click() + expect(popover.classList.contains('map-style-popover--open')).toBe(true) + }) + + it('toggle closes popover on second click', () => { + const toggle = container.querySelector('.map-style-switcher-toggle') + const popover = container.querySelector('.map-style-popover') + toggle.click() + toggle.click() + expect(popover.classList.contains('map-style-popover--open')).toBe(false) + }) + + it('clicking on map closes popover', () => { + const popover = container.querySelector('.map-style-popover') + popover.classList.add('map-style-popover--open') + containerClickHandlers.forEach((handler) => handler()) + expect(popover.classList.contains('map-style-popover--open')).toBe(false) + }) + + it('clicking style button sets correct style and closes popover', () => { + const buttons = container.querySelectorAll('.map-style-popover-btn') + const popover = container.querySelector('.map-style-popover') + popover.classList.add('map-style-popover--open') + // Click "streets" button (index 1) + buttons[1].click() + expect(mapSetStyleMock).toHaveBeenCalledWith(wrapper.vm.availableStyles.streets.url) + expect(wrapper.vm.activeStyle).toBe(wrapper.vm.availableStyles.streets.url) + expect(popover.classList.contains('map-style-popover--open')).toBe(false) + expect(buttons[1].classList.contains('map-style-popover-btn--active')).toBe(true) + // Previous active button should no longer be active + expect(buttons[0].classList.contains('map-style-popover-btn--active')).toBe(false) + }) + }) + + describe('mouseenter event', () => { + const features = [ + { + geometry: { coordinates: [10.0, 53.55] }, + properties: { + type: 'user', + slug: 'bob', + id: 'u2', + name: 'Bob', + locationName: 'Hamburg', + description: 'Builder', }, + }, + ] + + const getPopupDOM = () => mapboxgl.__popupInstance.setDOMContent.mock.calls[0][0] + + it('shows popup when features found', () => { + mapQueryRenderedFeaturesMock.mockReturnValueOnce(features) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + expect(mapboxgl.__popupInstance.setLngLat).toHaveBeenCalled() + expect(mapboxgl.__popupInstance.setDOMContent).toHaveBeenCalled() + expect(mapboxgl.__popupInstance.addTo).toHaveBeenCalledWith(mapMock) + }) + + it('does not show popup when no features', () => { + mapQueryRenderedFeaturesMock.mockReturnValueOnce([]) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + expect(mapboxgl.__popupInstance.setLngLat).not.toHaveBeenCalled() + }) + + it('popup includes location name header', () => { + mapQueryRenderedFeaturesMock.mockReturnValueOnce(features) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + const dom = getPopupDOM() + const header = dom.querySelector('.map-popup-header') + expect(header).toBeTruthy() + expect(header.textContent).toBe('Hamburg') + }) + + it('popup includes user name and profile link', () => { + mapQueryRenderedFeaturesMock.mockReturnValueOnce(features) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + const dom = getPopupDOM() + expect(dom.textContent).toContain('Bob') + const link = dom.querySelector('a') + expect(link.textContent).toBe('@bob') + expect(link.getAttribute('href')).toBe('/profile/u2/bob') + expect(link.getAttribute('rel')).toBe('noopener noreferrer') + }) + + it('popup includes description when present', () => { + mapQueryRenderedFeaturesMock.mockReturnValueOnce(features) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + const dom = getPopupDOM() + expect(dom.textContent).toContain('Builder') + }) + + it('popup shows multiple features separated by hr', () => { + const multiFeatures = [ + ...features, + { + geometry: { coordinates: [10.0, 53.55] }, + properties: { + type: 'group', + slug: 'journalism', + id: 'g1', + name: 'Journalism', + locationName: 'Hamburg', + description: '', + }, + }, + ] + mapQueryRenderedFeaturesMock.mockReturnValueOnce(multiFeatures) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + const dom = getPopupDOM() + expect(dom.querySelectorAll('hr').length).toBe(1) + const links = dom.querySelectorAll('a') + expect(links[1].textContent).toBe('&journalism') + expect(links[1].getAttribute('href')).toBe('/groups/g1/journalism') + }) + + it('clears pending leave timeout', () => { + jest.useFakeTimers() + wrapper.vm.popupOnLeaveTimeoutId = setTimeout(() => {}, 3000) + mapQueryRenderedFeaturesMock.mockReturnValueOnce(features) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + expect(wrapper.vm.popupOnLeaveTimeoutId).toBeNull() + jest.useRealTimers() + }) + + it('removes existing popup before showing new one', () => { + mapboxgl.__popupInstance.isOpen.mockReturnValueOnce(true) + mapQueryRenderedFeaturesMock.mockReturnValueOnce(features) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + expect(mapboxgl.__popupInstance.remove).toHaveBeenCalled() + }) + + it('adjusts coordinates for wrapped map', () => { + const wrappedFeatures = [ + { + geometry: { coordinates: [370.0, 53.55] }, + properties: { + type: 'user', + slug: 'bob', + id: 'u2', + name: 'Bob', + locationName: 'Hamburg', + description: '', + }, + }, + ] + mapQueryRenderedFeaturesMock.mockReturnValueOnce(wrappedFeatures) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + const coords = mapboxgl.__popupInstance.setLngLat.mock.calls[0][0] + expect(coords[0]).toBe(10.0) + }) + }) + + describe('mouseleave event', () => { + it('sets timeout to remove popup when open', () => { + jest.useFakeTimers() + mapboxgl.__popupInstance.isOpen.mockReturnValueOnce(true) + onEventMocks.mouseleave() + expect(wrapper.vm.popupOnLeaveTimeoutId).toBeTruthy() + jest.advanceTimersByTime(3000) + expect(mapboxgl.__popupInstance.remove).toHaveBeenCalled() + jest.useRealTimers() + }) + + it('does nothing when popup is not open', () => { + mapboxgl.__popupInstance.isOpen.mockReturnValueOnce(false) + onEventMocks.mouseleave() + expect(wrapper.vm.popupOnLeaveTimeoutId).toBeFalsy() + }) + }) + + describe('click event on markers', () => { + const features = [ + { + geometry: { coordinates: [10.0, 53.55] }, + properties: { + type: 'user', + slug: 'bob', + id: 'u2', + name: 'Bob', + locationName: 'Hamburg', + description: '', + }, + }, + ] + + it('shows popup and stops propagation', () => { + const stopPropagation = jest.fn() + mapQueryRenderedFeaturesMock.mockReturnValueOnce(features) + onEventMocks.click({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + originalEvent: { stopPropagation }, + }) + expect(mapboxgl.__popupInstance.setLngLat).toHaveBeenCalled() + expect(stopPropagation).toHaveBeenCalled() + }) + + it('does nothing when no features found', () => { + mapQueryRenderedFeaturesMock.mockReturnValueOnce([]) + onEventMocks.click({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + originalEvent: { stopPropagation: jest.fn() }, + }) + expect(mapboxgl.__popupInstance.setLngLat).not.toHaveBeenCalled() + }) + }) + + describe('popup content for different marker types', () => { + const getPopupDOMForType = () => mapboxgl.__popupInstance.setDOMContent.mock.calls[0][0] + + it('generates correct link for event type', () => { + const eventFeatures = [ + { + geometry: { coordinates: [9.17, 48.78] }, + properties: { + type: 'event', + slug: 'party', + id: 'e1', + name: 'Party', + locationName: 'Stuttgart', + description: '', + }, + }, + ] + mapQueryRenderedFeaturesMock.mockReturnValueOnce(eventFeatures) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 9.17, lat: 48.78 }, + }) + const dom = getPopupDOMForType() + const link = dom.querySelector('a') + expect(link.getAttribute('href')).toBe('/post/e1/party') + expect(link.textContent).toBe('party') + }) + + it('generates correct link for theUser type', () => { + const userFeatures = [ + { + geometry: { coordinates: [13.38, 52.52] }, + properties: { + type: 'theUser', + slug: 'peter', + id: 'u1', + name: 'Peter', + locationName: 'Berlin', + description: '', + }, + }, + ] + mapQueryRenderedFeaturesMock.mockReturnValueOnce(userFeatures) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 13.38, lat: 52.52 }, + }) + const dom = getPopupDOMForType() + const link = dom.querySelector('a') + expect(link.getAttribute('href')).toBe('/profile/u1/peter') + expect(link.textContent).toBe('@peter') + }) + + it('omits location header when locationName is empty', () => { + const features = [ + { + geometry: { coordinates: [10.0, 53.55] }, + properties: { + type: 'user', + slug: 'bob', + id: 'u2', + name: 'Bob', + locationName: '', + description: '', + }, + }, + ] + mapQueryRenderedFeaturesMock.mockReturnValueOnce(features) + onEventMocks.mouseenter({ + point: { x: 100, y: 200 }, + lngLat: { lng: 10.0, lat: 53.55 }, + }) + const dom = getPopupDOMForType() + expect(dom.querySelector('.map-popup-header')).toBeNull() + }) + }) + + describe('loadMarkersIconsAndAddMarkers', () => { + it('loads all 4 marker icon images', () => { + expect(mapLoadImageMock).toHaveBeenCalledTimes(4) + expect(mapLoadImageMock).toHaveBeenCalledWith( + 'img/mapbox/marker-icons/mapbox-marker-icon-20px-orange.png', + expect.any(Function), + ) + }) + + it('adds images to map', () => { + expect(mapAddImageMock).toHaveBeenCalledTimes(4) + expect(mapAddImageMock).toHaveBeenCalledWith('marker-orange', 'image-data') + expect(mapAddImageMock).toHaveBeenCalledWith('marker-green', 'image-data') + }) + + it('sets isImagesLoaded to true', () => { + expect(wrapper.vm.markers.isImagesLoaded).toBe(true) + }) + + it('calls language to set label layers', () => { + expect(mapSetLayoutPropertyMock).toHaveBeenCalledWith('some-label', 'text-field', [ + 'get', + 'name', + ]) + }) + + it('does not set layout on non-label layers', () => { + expect(mapSetLayoutPropertyMock).not.toHaveBeenCalledWith( + 'water-fill', + expect.anything(), + expect.anything(), + ) + }) + }) + + describe('setStyle', () => { + it('sets map style and activeStyle', () => { + wrapper.vm.setStyle('mapbox://styles/mapbox/dark-v10') + expect(mapSetStyleMock).toHaveBeenCalledWith('mapbox://styles/mapbox/dark-v10') + expect(wrapper.vm.activeStyle).toBe('mapbox://styles/mapbox/dark-v10') + }) + }) + + describe('addMarkersOnCheckPrepared with data', () => { + beforeEach(async () => { + await wrapper.setData({ + users: otherUsers, + groups, + posts, + currentUserCoordinates: [13.38333, 52.51667], + currentUserLocation: userLocation, + }) + wrapper.vm.addMarkersOnCheckPrepared() + }) + + it('creates geoJSON features for users', () => { + const userFeatures = wrapper.vm.markers.geoJSON.filter( + (f) => f.properties.type === 'user', + ) + expect(userFeatures.length).toBe(2) + }) + + it('excludes current user from user features', () => { + const currentUserFeature = wrapper.vm.markers.geoJSON.find( + (f) => f.properties.type === 'user' && f.properties.id === 'u1', + ) + expect(currentUserFeature).toBeUndefined() + }) + + it('creates geoJSON feature for current user', () => { + const theUserFeature = wrapper.vm.markers.geoJSON.find( + (f) => f.properties.type === 'theUser', + ) + expect(theUserFeature).toBeTruthy() + expect(theUserFeature.properties.iconRotate).toBe(45.0) + expect(theUserFeature.properties.locationName).toBe('Berlin') + }) + + it('creates geoJSON features for groups', () => { + const groupFeatures = wrapper.vm.markers.geoJSON.filter( + (f) => f.properties.type === 'group', + ) + expect(groupFeatures.length).toBe(1) + expect(groupFeatures[0].properties.locationName).toBe('Hamburg') + }) + + it('creates geoJSON features for events', () => { + const eventFeatures = wrapper.vm.markers.geoJSON.filter( + (f) => f.properties.type === 'event', + ) + expect(eventFeatures.length).toBe(1) + expect(eventFeatures[0].properties.locationName).toBe('Stuttgart') + }) + + it('adds source and layer to map', () => { + expect(mapAddSourceMock).toHaveBeenCalledWith( + 'markers', + expect.objectContaining({ type: 'geojson' }), + ) + expect(mapAddLayerMock).toHaveBeenCalledWith( + expect.objectContaining({ id: 'markers', type: 'symbol' }), + ) + }) + + it('calls flyTo', () => { + expect(mapFlyToMock).toHaveBeenCalledWith({ + center: [13.38333, 52.51667], + zoom: 10, }) }) - it('works without errors and warnings', () => { - expect(true).toBe(true) + it('sets isGeoJSON and isSourceAndLayerAdded to true', () => { + expect(wrapper.vm.markers.isGeoJSON).toBe(true) + expect(wrapper.vm.markers.isSourceAndLayerAdded).toBe(true) }) + + describe('coordinate nudging for overlapping markers', () => { + it('nudges markers of different types at same coordinates', () => { + // User Bob and Group are both at Hamburg (10.0, 53.55) + const bobFeature = wrapper.vm.markers.geoJSON.find((f) => f.properties.id === 'u2') + const groupFeature = wrapper.vm.markers.geoJSON.find((f) => f.properties.id === 'g1') + // They should have different lng coordinates after nudging + expect(bobFeature.geometry.coordinates[0]).not.toBe( + groupFeature.geometry.coordinates[0], + ) + // But same lat + expect(bobFeature.geometry.coordinates[1]).toBe(groupFeature.geometry.coordinates[1]) + }) + + it('does not nudge markers of the same type at the same location', async () => { + // Reset geoJSON and flags to re-run with custom data + wrapper.vm.markers.geoJSON = [] + wrapper.vm.markers.isGeoJSON = false + wrapper.vm.markers.isSourceAndLayerAdded = true + wrapper.vm.markers.isFlyToCenter = true + + const sameLocationUsers = [ + { + id: 'u10', + slug: 'alice', + name: 'Alice', + about: null, + location: { id: 'loc2', name: 'Hamburg', lng: 10.0, lat: 53.55 }, + }, + { + id: 'u11', + slug: 'charlie', + name: 'Charlie', + about: null, + location: { id: 'loc2', name: 'Hamburg', lng: 10.0, lat: 53.55 }, + }, + ] + await wrapper.setData({ + users: sameLocationUsers, + groups: [], + posts: [], + currentUserCoordinates: null, + currentUserLocation: null, + }) + wrapper.vm.addMarkersOnCheckPrepared() + + const userFeatures = wrapper.vm.markers.geoJSON.filter( + (f) => f.properties.type === 'user', + ) + expect(userFeatures.length).toBe(2) + // Same type at same location — coordinates must remain identical + expect(userFeatures[0].geometry.coordinates[0]).toBe( + userFeatures[1].geometry.coordinates[0], + ) + expect(userFeatures[0].geometry.coordinates[1]).toBe( + userFeatures[1].geometry.coordinates[1], + ) + }) + }) + }) + + describe('addMarkersOnCheckPrepared without current user coordinates', () => { + beforeEach(async () => { + await wrapper.setData({ + users: otherUsers, + groups, + posts, + currentUserCoordinates: null, + currentUserLocation: null, + }) + wrapper.vm.addMarkersOnCheckPrepared() + }) + + it('does not create theUser feature', () => { + const theUserFeature = wrapper.vm.markers.geoJSON.find( + (f) => f.properties.type === 'theUser', + ) + expect(theUserFeature).toBeUndefined() + }) + + it('flies to default center', () => { + expect(mapFlyToMock).toHaveBeenCalledWith({ + center: [10.452764, 51.165707], + zoom: 4, + }) + }) + }) + + describe('mapFlyToCenter', () => { + it('calls map.flyTo', () => { + mapFlyToMock.mockClear() + wrapper.vm.mapFlyToCenter() + expect(mapFlyToMock).toHaveBeenCalled() + }) + }) + }) + + describe('getUserLocation', () => { + it('returns location when user has one', async () => { + mocks.$apollo.query.mockResolvedValueOnce({ + data: { User: [{ location: userLocation }] }, + }) + const result = await wrapper.vm.getUserLocation('u1') + expect(result).toEqual(userLocation) + }) + + it('returns null when user has no location', async () => { + mocks.$apollo.query.mockResolvedValueOnce({ + data: { User: [{ location: null }] }, + }) + const result = await wrapper.vm.getUserLocation('u1') + expect(result).toBeNull() + }) + + it('returns null when no user found', async () => { + mocks.$apollo.query.mockResolvedValueOnce({ + data: { User: [] }, + }) + const result = await wrapper.vm.getUserLocation('u1') + expect(result).toBeNull() + }) + + it('shows toast error on failure', async () => { + mocks.$apollo.query.mockRejectedValueOnce(new Error('Network error')) + const result = await wrapper.vm.getUserLocation('u1') + expect(result).toBeNull() + expect(mocks.$toast.error).toHaveBeenCalledWith('Network error') + }) + }) + + describe('getCoordinates', () => { + it('returns [lng, lat] array', () => { + expect(wrapper.vm.getCoordinates({ lng: 10.0, lat: 53.55 })).toEqual([10.0, 53.55]) + }) + }) + + describe('isPreparedForMarkers watcher', () => { + it('calls addMarkersOnCheckPrepared when ready', async () => { + const spy = jest.spyOn(wrapper.vm, 'addMarkersOnCheckPrepared') + wrapper.vm.onMapLoad({ map: mapMock }) + await wrapper.setData({ + users: otherUsers, + groups, + posts, + }) + await wrapper.vm.$nextTick() + expect(spy).toHaveBeenCalled() + }) + }) + + describe('mounted with user location', () => { + it('sets currentUserCoordinates from location', async () => { + mocks.$apollo.query.mockResolvedValue({ + data: { User: [{ location: userLocation }] }, + }) + const w = createWrapper() + await w.vm.$nextTick() + await w.vm.$nextTick() + expect(w.vm.currentUserCoordinates).toEqual([13.38333, 52.51667]) + }) + }) + + describe('apollo mapData', () => { + it('query returns mapQuery', () => { + const queryFn = wrapper.vm.$options.apollo.mapData.query.bind(wrapper.vm) + expect(queryFn()).toBeTruthy() + }) + + it('variables returns correct filter', () => { + const variablesFn = wrapper.vm.$options.apollo.mapData.variables.bind(wrapper.vm) + const vars = variablesFn() + expect(vars.userFilter).toEqual({ hasLocation: true }) + expect(vars.groupHasLocation).toBe(true) + expect(vars.postFilter.postType_in).toEqual(['Event']) + expect(vars.postFilter.hasLocation).toBe(true) + }) + + it('update sets users, groups, posts and calls addMarkersOnCheckPrepared', () => { + const spy = jest.spyOn(wrapper.vm, 'addMarkersOnCheckPrepared') + const updateFn = wrapper.vm.$options.apollo.mapData.update.bind(wrapper.vm) + updateFn({ User: otherUsers, Group: groups, Post: posts }) + expect(wrapper.vm.users).toBe(otherUsers) + expect(wrapper.vm.groups).toBe(groups) + expect(wrapper.vm.posts).toBe(posts) + expect(spy).toHaveBeenCalled() + }) + }) + + describe('beforeDestroy', () => { + it('removes resize listener', () => { + const spy = jest.spyOn(window, 'removeEventListener') + wrapper.destroy() + expect(spy).toHaveBeenCalledWith('resize', wrapper.vm.updateMapPosition) + spy.mockRestore() }) }) }) diff --git a/webapp/pages/map.vue b/webapp/pages/map.vue index 4c8dd9ff7..391e9997f 100644 --- a/webapp/pages/map.vue +++ b/webapp/pages/map.vue @@ -1,29 +1,7 @@