import { render, screen, fireEvent, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import React from 'react'; import type { MapControl } from './map-control'; vi.doMock('../google-maps-loader', () => ({ useGoogleMapsLoader: () => ({ isLoaded: true, loadError: undefined, load: async () => {} }), })); const MUNICIPALITIES = [ { id: 'poa', position: { lat: -30.03, lng: -51.22 }, title: 'Porto Alegre', data: { uf: 'RS' } }, { id: 'cxs', position: { lat: -29.17, lng: -51.18 }, title: 'Caxias do Sul', data: { uf: 'RS' } }, { id: 'flo', position: { lat: -27.6, lng: -48.55 }, title: 'Florianópolis', data: { uf: 'SC' } }, ]; describe('Map — territorial analysis', () => { let Map: any; const polygonInstances: any[] = []; const circleInstances: any[] = []; let heatmapOptions: any = null; const mapInstance = { addListener: vi.fn(() => ({ remove: vi.fn() })) }; beforeEach(async () => { vi.clearAllMocks(); polygonInstances.length = 0; circleInstances.length = 0; heatmapOptions = null; const module = await import('./map'); Map = module.Map; const shape = (bucket: any[]) => vi.fn(function (options: any) { const instance = { options, setMap: vi.fn(), setOptions: vi.fn(function (next: any) { instance.options = next; }), addListener: vi.fn(() => ({ remove: vi.fn() })), }; bucket.push(instance); return instance; }); /* * jsdom never upgrades the gmp-map custom element, so `innerMap` — the * google.maps.Map the geometry, choropleth and heatmap hooks attach to — * would never appear. Faking it is what makes those layers testable at all. */ Object.defineProperty(HTMLElement.prototype, 'innerMap', { configurable: true, get() { return this.tagName === 'GMP-MAP' ? mapInstance : undefined; }, }); global.google = { maps: { Map: vi.fn(function () { return mapInstance; }), InfoWindow: vi.fn(function () { return { open: vi.fn(), close: vi.fn(), setContent: vi.fn(), addListener: vi.fn(() => ({ remove: vi.fn() })), }; }), Circle: shape(circleInstances), Polygon: shape(polygonInstances), LatLng: vi.fn(function (lat: number, lng: number) { return { lat, lng }; }), LatLngBounds: vi.fn(function () { return { extend: vi.fn() }; }), Data: vi.fn(function () { return { setMap: vi.fn(), addGeoJson: vi.fn(), setStyle: vi.fn(), forEach: vi.fn(), remove: vi.fn(), revertStyle: vi.fn(), overrideStyle: vi.fn(), addListener: vi.fn(() => ({ remove: vi.fn() })), }; }), visualization: { HeatmapLayer: vi.fn(function (options: any) { heatmapOptions = options; return { setMap: vi.fn(), setOptions: vi.fn() }; }), }, event: { addListener: vi.fn(() => ({ remove: vi.fn() })), addListenerOnce: vi.fn(() => ({ remove: vi.fn() })), }, marker: { AdvancedMarkerElement: vi.fn() }, }, } as any; }); afterEach(() => { delete (HTMLElement.prototype as any).innerMap; delete (global as any).google; vi.resetModules(); }); /* --- C1 / E2 ------------------------------------------------------------ */ describe('multiple geometries', () => { it('renders many polygons and circles at once', () => { render( ); expect(polygonInstances).toHaveLength(2); expect(circleInstances).toHaveLength(3); }); it('renders the singular polygon alongside the plural ones', () => { render( ); // Backwards compatibility: both must draw. expect(polygonInstances).toHaveLength(2); }); it('multiplies feature opacity by its layer', () => { render( ); expect(polygonInstances[0].options.fillOpacity).toBeCloseTo(0.25, 5); }); it('takes zIndex from the layer when the feature declares none', () => { render( ); expect(polygonInstances[0].options.zIndex).toBe(42); }); it('hides geometry belonging to a hidden layer', () => { render( ); expect(circleInstances[0].options.visible).toBe(false); }); }); /* --- E1 ----------------------------------------------------------------- */ it('hides markers belonging to a hidden layer', () => { render( ); expect(screen.queryByRole('button', { name: 'Porto Alegre' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Caxias do Sul' })).toBeInTheDocument(); }); /* --- D1 ----------------------------------------------------------------- */ describe('heatmap', () => { it('builds the layer from the supplied points', () => { render( ); expect(heatmapOptions.data).toHaveLength(2); expect(heatmapOptions.radius).toBe(28); }); it('prepends a transparent stop to the token gradient', () => { render( ); // Google reads index 0 as the color of "no density"; an opaque first stop // would flood the whole viewport. expect(heatmapOptions.gradient[0]).toBe('rgba(0, 0, 0, 0)'); }); }); /* --- B3 / J3 ------------------------------------------------------------ */ describe('clustering and the privacy floor', () => { it('collapses grouped records into one badged marker', () => { render( m.data.uf }} /> ); // Two states -> two markers, not three. expect(screen.getAllByRole('button')).toHaveLength(2); expect(screen.getByLabelText('2')).toBeInTheDocument(); }); it('never plots a group smaller than minAggregation on its own', () => { render( m.data.uf }} minAggregation={2} /> ); // 'SC' has a single record and must be folded in. expect(screen.getAllByRole('button')).toHaveLength(1); }); }); /* --- B4 ----------------------------------------------------------------- */ it('draws a cartographic caption beside the marker', () => { render( ); const caption = document.querySelector('[data-map-caption]'); expect(caption).toHaveTextContent('Porto Alegre'); // The marker's accessible name already carries it; announcing twice is noise. expect(caption).toHaveAttribute('aria-hidden', 'true'); }); /* --- F3 ----------------------------------------------------------------- */ describe('popup', () => { it('opens on activation and closes again', () => { render( Detalhes da unidade

} }]} /> ); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Porto Alegre' })); expect(screen.getByRole('dialog')).toHaveTextContent('Detalhes da unidade'); fireEvent.click(screen.getByRole('button', { name: 'Close' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('honours the declared width', () => { render( x

, width: 420 } }]} /> ); fireEvent.click(screen.getByRole('button', { name: 'Porto Alegre' })); expect(screen.getByRole('dialog')).toHaveStyle({ width: '420px' }); }); it('reports actions back to the parent', () => { const onPopupAction = vi.fn(); render( Ver na tabela ), }, }, ]} /> ); fireEvent.click(screen.getByRole('button', { name: 'Porto Alegre' })); fireEvent.click(screen.getByRole('button', { name: 'Ver na tabela' })); expect(onPopupAction).toHaveBeenCalledWith('poa', 'scroll-to-row'); }); it('can be driven imperatively without changing what ref means', () => { const control = React.createRef(); const container = React.createRef(); render( Detalhes

} }]} /> ); // `ref` must still be the container element for 4.x consumers. expect(container.current).toBeInstanceOf(HTMLDivElement); expect(control.current!.getOpenPopupId()).toBeNull(); // Imperative calls sit outside React's event system, so the state update // has to be flushed explicitly. act(() => control.current!.openPopup('poa')); expect(screen.getByRole('dialog')).toBeInTheDocument(); act(() => control.current!.closePopup()); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); }); /* --- C4 ----------------------------------------------------------------- */ it('reports the classes the choropleth painted', () => { const onChoroplethBands = vi.fn(); render( ({ type: 'Feature' as const, id: `f${i}`, properties: { criticidade }, geometry: { type: 'Polygon', coordinates: [ [ [0, 0], [1, 0], [1, 1], [0, 0], ], ], }, })), }} choropleth={{ field: 'criticidade', steps: 3, method: 'equal-interval', scaleTokens: ['--chart-2', '--destructive'], }} /> ); const bands = onChoroplethBands.mock.calls.at(-1)![0]; expect(bands).toHaveLength(3); expect(bands[0].label).toMatch(/–/); }); });