import type { Locator, Page } from '@playwright/test' import { loadMunicipalityGeometryModule, loadMunicipalityZoneGeometryModule, } from '../../src/lib/bahiaGeometries.js' import type { BahiaMunicipalityFeature } from '../../src/lib/bahiaGeometriesTypes.js' import { getMunicipalityCatalogEntry, municipalityCatalog, } from '../../src/lib/municipalityCatalog.js' import { featureCentroid, haversineKm, resolveNearbyMunicipality, type GeoPoint, } from '../../src/lib/municipalityProximity.js' import { interiorPointOf } from '../helpers/featureBounds.js' import { expect, test } from './fixtures/campaignE2EFixtures.js' /** * B14 — the geo shortcut on the staff dashboard. What these tests protect is the * contract the field depends on: a granted permission resolves the município the * user is standing in (including a Salvador ZE when the zone mesh matches), * a position outside the portfolio says so, and a refused permission leaves * a card the user can act on instead of a spinner. */ const SALVADOR_CENTRE: GeoPoint = { lat: -12.973, lng: -38.5121 } const salvadorZoneAtCentre = async () => { const geometry = await loadMunicipalityGeometryModule() const zoneGeometry = await loadMunicipalityZoneGeometryModule() const accessible = municipalityCatalog .filter((entry) => entry.city === 'Salvador') .map((entry) => ({ slug: entry.slug, name: entry.name, ibgeCode: entry.ibgeCode })) const resolution = resolveNearbyMunicipality({ point: SALVADOR_CENTRE, geometry, zoneGeometry, accessible, }) if (resolution.kind !== 'inScope' || resolution.match !== 'zoneContainment') { throw new Error('Expected Salvador centre to resolve to an in-scope zone for the e2e contract.') } return resolution.municipality } /** * The card mirrors its headline in an sr-only live region (so the outcome is * announced), which means every headline matches twice. `.last()` is the visible * copy — DOM order puts the live region first. */ const visibleText = (scope: Locator, text: string) => scope.getByText(text).last() /** Assertions belong to the card, not to "somewhere on the dashboard". */ const geoCard = (page: Page) => page.locator('[data-slot="card"]').filter({ hasText: 'Onde estou' }) /** * Farthest and nearest other município from a given one, by centroid. The fixture * hands out an arbitrary município, so the two fallback positions have to be * derived from it — a fixed city would be inside the 150 km suggestion radius on * some runs and outside it on others. */ const otherMunicipalitiesByDistance = ( features: readonly BahiaMunicipalityFeature[], origin: BahiaMunicipalityFeature, ): { nearest: BahiaMunicipalityFeature; farthest: BahiaMunicipalityFeature } => { const from = featureCentroid(origin) const ranked = features .filter((feature) => feature.properties.codarea !== origin.properties.codarea) .map((feature) => ({ feature, distanceKm: haversineKm(from, featureCentroid(feature)) })) .sort((left, right) => left.distanceKm - right.distanceKm) return { nearest: ranked[0]!.feature, farthest: ranked[ranked.length - 1]!.feature } } /** Whole-municipality unit (not a Salvador zone), so containment maps to one slug. */ const claimWholeMunicipality = async ( claim: () => Promise<{ id: number; name: string; slug: string }>, ) => { for (let attempt = 0; attempt < 30; attempt += 1) { const municipality = await claim() if (getMunicipalityCatalogEntry(municipality.slug)?.kind === 'municipio') return municipality } throw new Error('Could not claim a whole-municipality unit for the geo shortcut test.') } /* * Serial with a wider timeout: all three tests land on the staff dashboard (map + * 435 municípios), and simultaneous cold hits on the dev server time out the * login navigation before anything about this card is exercised. The advisor test * renders that dashboard four times, which does not fit the default 30s in dev. */ test.describe.configure({ mode: 'serial', timeout: 150_000 }) test.describe('Município mais próximo', () => { test('advisor in his own município opens it, and is told when he is outside the portfolio', async ({ campaign, page, }) => { const { fixtures } = campaign const advisor = await fixtures.createCampaignUser('advisor', { name: fixtures.value('Assessor Geo'), }) const password = advisor.password const email = advisor.email! const municipality = await claimWholeMunicipality(() => fixtures.claimMunicipality()) await campaign.payload.update({ collection: 'municipality', id: municipality.id, data: { advisors: [advisor.id] }, depth: 0, }) fixtures.touchMunicipality(municipality.id) const { features, getMunicipalityFeature } = await loadMunicipalityGeometryModule() const portfolio = getMunicipalityFeature( getMunicipalityCatalogEntry(municipality.slug)!.ibgeCode, )! const { nearest, farthest } = otherMunicipalitiesByDistance(features, portfolio) const inside = interiorPointOf(portfolio) await page.context().grantPermissions(['geolocation']) await page.context().setGeolocation({ latitude: inside.lat, longitude: inside.lng }) await campaign.login(page, email, password) await page.goto(`${campaign.baseURL}/campanha/quadro`) const card = geoCard(page) await expect(visibleText(card, `Você está em ${municipality.name}`)).toBeVisible() await expect(card.getByRole('link', { name: `Abrir ${municipality.name}` })).toBeVisible() // A device-grade fix carries no caveat. await expect(card.getByText('Localização aproximada')).toHaveCount(0) // Next door but off his portfolio: named where he is, offered what he can open. const nextDoor = interiorPointOf(nearest) await page.context().setGeolocation({ latitude: nextDoor.lat, longitude: nextDoor.lng }) await page.reload() await expect(visibleText(card, 'fora da sua carteira')).toBeVisible() await expect(card.getByText(`Mais próximo na sua carteira: ${municipality.name}`)).toBeVisible() await expect(card.getByRole('link', { name: `Abrir ${municipality.name}` })).toBeVisible() // Across the state: suggesting the portfolio would be noise, not a shortcut. const farAway = interiorPointOf(farthest) await page.context().setGeolocation({ latitude: farAway.lat, longitude: farAway.lng }) await page.reload() await expect(visibleText(card, 'fora da sua carteira')).toBeVisible() await expect(card.getByText('Nenhum município da sua carteira por perto.')).toBeVisible() await expect(card.getByRole('link', { name: /^Abrir/ })).toHaveCount(0) }) test('coordinator in Salvador opens the matching zone directly', async ({ campaign, page }) => { const zone = await salvadorZoneAtCentre() const { fixtures } = campaign const coordinator = await fixtures.createCampaignUser('coordinator', { name: fixtures.value('Coordenadora Geo'), }) const password = coordinator.password const email = coordinator.email! await page.context().grantPermissions(['geolocation']) await page.context().setGeolocation({ latitude: SALVADOR_CENTRE.lat, longitude: SALVADOR_CENTRE.lng, }) await campaign.login(page, email, password) await page.goto(`${campaign.baseURL}/campanha/quadro`) const card = geoCard(page) await expect(visibleText(card, zone.name)).toBeVisible() await card.getByRole('link', { name: `Abrir ${zone.name}` }).click() await expect(page).toHaveURL( new RegExp(`/campanha/municipios/${zone.slug.replace(/-/g, '\\-')}$`), ) }) test('a refused permission leaves an actionable card instead of a spinner', async ({ campaign, page, }) => { const { fixtures } = campaign const blockedUser = await fixtures.createCampaignUser('coordinator', { name: fixtures.value('Coordenadora Bloqueada'), }) const password = blockedUser.password const email = blockedUser.email! // No grantPermissions: Chromium refuses the position, the same as a user tapping "Block". await campaign.login(page, email, password) await page.goto(`${campaign.baseURL}/campanha/quadro`) const card = geoCard(page) await expect(visibleText(card, 'Localização bloqueada neste navegador')).toBeVisible() await expect(card.getByRole('button', { name: 'Tentar de novo' })).toBeEnabled() await expect(card.getByText('Localizando…')).toHaveCount(0) }) })