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