teqo-backup/tests/e2e/campaignNearestMunicipality.e2e.spec.ts
Francisco Ignácio Fontoura Solla 926972fec6 B43: blank Início, Quadro staff home, and Contatos for leaders.
Split dashboard and leader contacts into dedicated routes, update nav and gates, and fix revalidation paths after the route move.
2026-07-29 14:02:35 -03:00

225 lines
9.1 KiB
TypeScript

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,
featureContainsPoint,
haversineKm,
resolveNearbyMunicipality,
type GeoPoint,
} from '../../src/lib/municipalityProximity.js'
import { featureBounds } 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' })
/**
* Coarse grid scan for a point that is really inside the polygon — a centroid can
* fall outside a concave município, and the fixture hands out an arbitrary one.
*/
const interiorPointOf = (feature: BahiaMunicipalityFeature): GeoPoint => {
const { west, east, south, north } = featureBounds(feature)
const steps = 24
for (let row = 1; row < steps; row += 1) {
for (let column = 1; column < steps; column += 1) {
const point = {
lng: west + ((east - west) * column) / steps,
lat: south + ((north - south) * row) / steps,
}
if (featureContainsPoint(feature, point)) return point
}
}
throw new Error(`No interior point found for ${feature.properties.name}`)
}
/**
* 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)
})
})