Point-in-polygon on the zone mesh opens the accessible ZE directly, with nearest-zone fallback within 30 km before the filtered list.
222 lines
8.9 KiB
TypeScript
222 lines
8.9 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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
})
|
|
})
|