teqo/tests/e2e/campaignNearestMunicipality.e2e.spec.ts
Francisco Ignácio Fontoura Solla 5ace1a04b2 simplify(B14): corrige o mecanismo da fronteira, estado unsupported e duplicatas
Terceira rodada de /simplify sobre o B14, com dois enunciados errados corrigidos:

- A propriedade da fronteira compartilhada NAO vem da ordem do `.find()`, e sim
  da convencao meio-aberta do ray casting (`point.lng < crossingLng`): a aresta
  pertence ao poligono cujo interior fica a leste (ao norte, se horizontal). Tres
  comentarios diziam o contrario, e o do unit test dizia "oeste" enquanto
  afirmava `east`.
- O plano dizia que o `Promise.all` esconde o decode da malha atras do fix de
  GPS. Nao esconde: o decode roda no topo do modulo, quando o chunk executa. O
  paralelo continua certo por outro motivo (decode ~2 ms; sequencial jogaria os
  42 KB no caminho critico quando o fix vem do cache).

Simplificacoes: `unsupported` virou estado proprio, entao `failureCopy` deixa de
ser anulavel e caem duas leituras defensivas inalcancaveis; "Voce esta em " era
escrito duas vezes (frase sr-only + JSX), o que anulava justamente a invariante
que o comentario alegava; `zoneCityHrefs` virou `Partial` porque a busca falha
para os 416 municipios sem zona e o guard existia sem o tipo exigir;
`MunicipalityForIbgeIndex` era duplicata campo-a-campo de `AccessibleMunicipality`;
`kmFormatter` duplicava `oneDecimalFormatter`; `RecentlyVisitedCard` carregava
tres props que nenhum caller passa (dez condicionais mortas); e o bbox de feature
estava escrito em dois specs (agora `tests/helpers/featureBounds.ts`, compartilhado
por vitest e Playwright).

Copy: fora da Bahia sem nada por perto dizia a mesma coisa em duas frases e nunca
dizia que voce estava fora do estado. Agora o headline afirma o fato e o detalhe
reusa "Nenhum municipio da sua carteira por perto.".

Tambem limpa um achado de knip herdado do main (`MunicipalityPoliticalTrendViewModel`
exportado sem uso) e registra no plano o caminho afiado do debito de payload RSC
mais dois adiados com gatilho.
2026-07-26 18:03:44 -03:00

221 lines
8.9 KiB
TypeScript

import type { Locator, Page } from '@playwright/test'
import { loadMunicipalityGeometryModule } from '../../src/lib/bahiaGeometries.js'
import type { BahiaMunicipalityFeature } from '../../src/lib/bahiaGeometriesTypes.js'
import { getMunicipalityCatalogEntry } from '../../src/lib/municipalityCatalog.js'
import {
featureCentroid,
featureContainsPoint,
haversineKm,
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, Salvador degrades to its zone list instead of guessing a
* ZE, 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 }
/**
* 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 password = fixtures.value('senha')
const email = `${fixtures.value('geo-advisor')}@example.com`
const advisor = await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Assessor Geo'),
email,
password,
role: 'advisor',
},
depth: 0,
})
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 is sent to the zone list, never to a guessed ZE', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('geo-coordinator')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Geo'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
await page.context().grantPermissions(['geolocation'])
// A desk fix comes from the network, not the GPS: wide enough to be wrong about
// the município, so the card must hedge.
await page.context().setGeolocation({
latitude: SALVADOR_CENTRE.lat,
longitude: SALVADOR_CENTRE.lng,
accuracy: 25_000,
})
await campaign.login(page, email, password)
const card = geoCard(page)
await expect(visibleText(card, 'dividida por zona eleitoral')).toBeVisible()
await expect(card.getByText('Localização aproximada')).toBeVisible()
await card.getByRole('link', { name: 'Ver zonas de Salvador' }).click()
await expect(page).toHaveURL(/\/campanha\/municipios\?.*q=Salvador/)
// Exact: "Salvador — ZE 1" is otherwise a prefix of ZE 12 and ZE 17.
await expect(page.getByRole('link', { name: 'Salvador — ZE 1', exact: true })).toBeVisible()
})
test('a refused permission leaves an actionable card instead of a spinner', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const password = fixtures.value('senha')
const email = `${fixtures.value('geo-blocked')}@example.com`
await campaign.payload.create({
collection: 'campaignUser',
data: {
name: fixtures.value('Coordenadora Bloqueada'),
email,
password,
role: 'coordinator',
},
depth: 0,
})
// 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)
})
})