teqo-backup/tests/e2e/campaignMunicipalities.e2e.spec.ts

1992 lines
88 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Page } from '@playwright/test'
import { getStatewideFederalTotals } from '../../src/lib/bahiaElectionAggregates.js'
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js'
import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js'
import { fallbackDemandTitle } from '../../src/lib/demandTitle.js'
import { formatElectionNumber } from '../../src/lib/electionFormat.js'
import { municipalityCatalog } from '../../src/lib/municipalityCatalog.js'
import {
computeVoteRankByYear,
DEFAULT_VOTE_RANK_YEAR,
} from '../../src/lib/municipalityVoteRank.js'
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
import {
ensureLeasedConsent,
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
} from '../helpers/testDatabaseLease.js'
import {
campaignPageChrome,
checkRadixWhenHydrated,
createCampaignOwnership,
expect,
expectPostResponse,
mintCampaignSession,
seedCampaignSession,
test,
waitForStreamSettled,
type CampaignE2EOwnership,
type CampaignSessionUser,
} from './fixtures/campaignE2EFixtures.js'
import {
assertThreeColumnActionGrid,
collectActionBoundingBoxes,
} from './helpers/actionGridGeometry.js'
import { civilDatePlusDays } from './helpers/agendaPeriodLabels.js'
const showAllMunicipalityColumns = (page: Page, url: string) =>
page.context().addCookies([
{
name: 'campaign_columns',
value: encodeURIComponent('municipios:__none__'),
url,
},
])
const municipalityContainer = (page: Page) =>
page.locator('[data-container="municipality-list"]').last()
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
// The button keeps a state suffix; the boundary prevents "São Félix" matching
// the longer municipality "São Félix do Coribe".
const visibleMunicipalityButton = (page: Page, name: string) =>
municipalityContainer(page)
.getByRole('button', { name: new RegExp(`^${escapeRegExp(name)}(?: —|$)`) })
.filter({ visible: true })
const ensureWideMunicipalityList = async (page: Page) => {
// The resizable panel hydrates independently from the RSC list and can
// change the container stage after a cell first becomes visible. These
// desktop cell journeys wait for the widest table stage; B158 exercises the
// rail transition itself in its dedicated spec.
await page.waitForLoadState('networkidle')
const container = municipalityContainer(page)
const width = await container.evaluate((element) => element.clientWidth)
const close = page.getByRole('button', { name: 'Fechar', exact: true }).filter({ visible: true })
// A collapsed Panel can leave its overflowing close button technically
// visible. Only click when the list itself proves the chat still consumes
// space; otherwise the click is a no-op against an already collapsed rail.
if (width < 78 * 16 && (await close.isVisible())) await close.click()
await expect
.poll(() => container.evaluate((element) => element.clientWidth))
.toBeGreaterThanOrEqual(78 * 16)
}
/**
* OPS36 — one shared staff session per worker (see the sibling comment in
* campaignHomeActions.e2e.spec.ts): a coordinator is created once and every
* test's fresh context receives its `campaign-token` cookie in `beforeEach`.
* Advisors are never shared (their portfolio must stay empty/fresh — OPS29,
* B176), and the mixed advisor→leader journey below keeps the browser logins.
* The group rows are cleaned up in `afterAll`.
*/
let sharedFixtures: CampaignE2EOwnership
let sharedCoordinator: CampaignSessionUser
let coordinatorToken: string
test.beforeAll(async () => {
sharedFixtures = await createCampaignOwnership()
sharedCoordinator = await sharedFixtures.createCampaignUser('coordinator', {
name: sharedFixtures.value('Coordenadora Compartilhada'),
})
coordinatorToken = await mintCampaignSession(sharedFixtures.payload, sharedCoordinator)
})
test.beforeEach(async ({ campaign, context }) => {
await seedCampaignSession(context, campaign.baseURL, coordinatorToken)
})
test.afterAll(async () => {
if (!sharedFixtures) return
await sharedFixtures.cleanup()
await sharedFixtures.expectNoOwnedRows()
})
/**
* Core municipality-model journeys per role: coordinator strategy editing, advisor
* scoping, staff declare/estimate privacy boundary, leader lockdown, and staff-only
* demand workflow.
*
* The shared registration consent is LEASED (`ensureLeasedConsent`), never owned
* through the fixture proxy — creating it there would let this file's cleanup
* delete the row a parallel spec is using.
*/
test.describe('Municípios — jornadas por papel', () => {
test.beforeEach(async ({ page }) => {
// B158 keys the table stages to content width and gives fresh profiles a
// compact default. These pre-B158 journeys exercise specific desktop
// cells, so make that prerequisite explicit instead of finding hidden
// copies in the always-mounted mobile-card tree.
await page.setViewportSize({ width: 2400, height: 1000 })
})
test('coordinator opens the municipalities list, edits strategy and assigns an advisor', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor Regional'),
})
const municipality = await fixtures.claimMunicipality()
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
// B127: primary filter entry is the omnibox; default sort is no longer a
// free-standing "Ordenado por …" line (only a non-default sort chip).
await expect(page.getByLabel('Filtrar municípios').first()).toBeVisible()
await expect(
page.getByRole('columnheader', { name: /Ordenar por Frescor do sinal/ }),
).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
await expect(campaignPageChrome(page, municipality.name)).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}/editar`)
// C106 — the editar page streams heavy content (detail view model +
// advisor options + stateDeputy options); the shell's `S:` copies must
// settle before the advisor checkbox option can be reliably checked.
await waitForStreamSettled(page)
await page.getByLabel(`${advisor.name} `, { exact: false }).check()
await page.getByRole('button', { name: 'Salvar assessores' }).click()
await expect(page.getByText('Assessores atualizados.')).toBeVisible()
await page.getByLabel('Pessimista', { exact: true }).fill('1000')
await page.getByLabel('Média', { exact: true }).fill('3000')
await page.getByLabel('Otimista', { exact: true }).fill('5000')
await page.getByRole('button', { name: 'Salvar votos estimados' }).click()
await expect(page.getByText('Votos estimados atualizados.')).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
// "Conta da cadeira" (E8) shows the saved scenario values through
// `MunicipalityListExpectedVotesControl` — a compact edit-in-place
// control whose only always-visible digits are the active scenario
// (`central`, "3.000" here). The full pessimistic/central/optimistic
// trio only becomes plain page text via its screen-reader summary
// (visually a hover-only preview otherwise), so assert against that
// unique, unambiguous string rather than a bare "5.000" substring —
// that also matches the (opacity-0 but DOM-visible) hover-preview span.
// `.nth(0)`: the cell's sr-only summary is first in DOM; the preview span
// duplicates the text when a row is hovered, making a bare getByText a
// strict-mode violation (observed flaky on CI).
await expect(page.getByText('Otimista: 5.000').nth(0)).toBeVisible()
// B145: assessoria left the detail hero; it still appears on the dossiê tab.
// `.first()`: transient RSC-pending duplication also duplicates this line
// (same strict-mode flake as the "Otimista" assert above).
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=dossie`)
// `.nth(0)`: same transient hydration duplicate the "Otimista: 5.000"
// assertion pins — the server-composed slot briefly renders twice.
await expect(page.getByText(`Assessoria: ${advisor.name}`).nth(0)).toBeVisible()
})
test('coordinator assigns an advisor from the list combobox with auto-save (B27)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora Combobox'),
})
const municipality = await fixtures.claimMunicipality()
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
// Name includes the município — a substring search can return neighbors
// (strict-mode flake when two "Editar assessores …" buttons match).
// A concurrent RSC navigation may briefly retain the previous segment.
// Scope the editor to the appended, active municipality container so a
// hidden copy cannot absorb Playwright's click retries until timeout.
const advisorsTrigger = visibleMunicipalityButton(
page,
`Editar assessores em ${municipality.name}`,
)
await expect(advisorsTrigger).toBeVisible()
await advisorsTrigger.click()
const advisorsPopover = page.locator('[data-slot="popover-content"]')
await expect(advisorsPopover).toBeVisible()
const searchNamePart = advisor.name.split(' ')[0]
const search = advisorsPopover.getByRole('combobox', { name: 'Buscar assessor' })
const option = advisorsPopover.getByRole('option', { name: advisor.name })
// No "Salvar" button in this popover: selecting the option auto-saves the
// delta and renders it as a removable chip immediately.
const chip = advisorsPopover.getByRole('button', { name: `Remover ${advisor.name}` })
// Opening this client-only popover already proves hydration. Wait for the
// write itself: the chip and trigger are optimistic and do not prove the
// assignment reached the database before the reload below.
await search.fill(searchNamePart)
await Promise.all([expectPostResponse(page, '/campanha/municipios/advisors'), option.click()])
await expect(chip).toBeVisible()
// The popover stays open after the write (auto-save, not submit+close).
await expect(advisorsPopover).toBeVisible()
// Trigger label flips from "sem assessor" only after the write lands —
// wait for that before Escape/reload so we do not race optimistic chips.
await expect(
page.getByRole('button', {
name: `Editar assessores em ${municipality.name} — ${advisor.name}`,
}),
).toBeVisible()
await page.keyboard.press('Escape')
// Persistence: reload and reopen to confirm the server, not just local
// state, now holds the assignment.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
await visibleMunicipalityButton(
page,
`Editar assessores em ${municipality.name} — ${advisor.name}`,
).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${advisor.name}`,
}),
).toBeVisible()
})
test('coordinator creates an advisor inline in the list combobox (B154)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
// Unique per run (uuid + counter), so no eligible account matches it — the
// combobox must offer the create option, not an existing advisor.
const newAdvisorName = fixtures.value('Novo Assessor')
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
const advisorsTrigger = visibleMunicipalityButton(
page,
`Editar assessores em ${municipality.name}`,
)
await advisorsTrigger.click()
const advisorsPopover = page.locator('[data-slot="popover-content"]')
await expect(advisorsPopover).toBeVisible()
const search = advisorsPopover.getByRole('combobox', { name: 'Buscar assessor' })
const createOption = advisorsPopover.getByRole('option', {
name: new RegExp(`^Criar assessor.*${newAdvisorName}`),
})
// The removable chip only exists once the response lands — the optimistic
// temp chip has no remove affordance. `count()` guard: once the create
// succeeded and registered the account, the create option is gone, so a
// retry must not try to click it again (it would time out) — skipping the
// click and waiting for the chip is what a post-success retry does.
const chip = advisorsPopover.getByRole('button', { name: `Remover ${newAdvisorName}` })
await expect(async () => {
await search.fill(newAdvisorName)
if (await createOption.count()) await createOption.click({ timeout: 1_000 })
await expect(chip).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 25_000 })
// Same popover, no reload: typing the name now lists the account instead of
// offering to create it again — the local bridge registered it.
await search.fill(newAdvisorName)
await expect(advisorsPopover.getByRole('option', { name: newAdvisorName })).toBeVisible()
await expect(advisorsPopover.getByRole('option', { name: /Criar assessor/ })).toHaveCount(0)
// The popover stays open after the write (auto-save, not submit+close).
await expect(advisorsPopover).toBeVisible()
await page.keyboard.press('Escape')
// Persistence: reload and reopen to confirm the server holds the account
// and the assignment, not just the local optimistic state.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
await visibleMunicipalityButton(
page,
`Editar assessores em ${municipality.name} — ${newAdvisorName}`,
).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${newAdvisorName}`,
}),
).toBeVisible()
})
test('coordinator creates a dobradinha inline in the list combobox (B157)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
// Unique per run (uuid + counter), so no catalog entry matches it — the
// combobox must offer the create option, not an existing dobradinha.
const deputyName = fixtures.value('Deputado Novo')
const party = 'PCdoB'
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
const deputiesTrigger = visibleMunicipalityButton(
page,
`Editar dobradinhas em ${municipality.name}`,
)
await deputiesTrigger.click()
const deputiesPopover = page.locator('[data-slot="popover-content"]')
await expect(deputiesPopover).toBeVisible()
const search = deputiesPopover.getByRole('combobox', { name: 'Buscar dobradinha' })
// "Cicrano (PCdoB)" → name "Cicrano", party "PCdoB" (B157 syntax).
const createOption = deputiesPopover.getByRole('option', {
name: new RegExp(`^Criar dobradinha.*${deputyName}`),
})
const chip = deputiesPopover.getByRole('button', {
name: `Remover ${deputyName} (${party})`,
})
// Same retry contract as B154: a click before hydration is a silent no-op,
// and once the create succeeded the option is gone (count() guard).
await expect(async () => {
await search.fill(`${deputyName} (${party})`)
if (await createOption.count()) await createOption.click({ timeout: 1_000 })
await expect(chip).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 25_000 })
// Same popover, no reload: typing the party now must NOT offer to create a
// duplicate — the search only lists ADDABLE items, so the assigned chip's
// own name never appears as an option; the "Criar …" suppression is what
// proves the RSC refresh brought the new deputy into the catalog.
await search.fill(party)
await expect(deputiesPopover.getByRole('option', { name: /Criar dobradinha/ })).toHaveCount(0)
await expect(chip).toBeVisible()
// The popover stays open after the write (auto-save, not submit+close).
await expect(deputiesPopover).toBeVisible()
await page.keyboard.press('Escape')
// Persistence: reload and reopen — the server holds both the deputy and
// the assignment, not just the local optimistic state.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${deputyName} (${party})`,
}),
).toBeVisible()
})
test('B176 — filters the list by Dobradinha, Liderança and Partido from the omnibox', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipalityA = await fixtures.claimMunicipality()
const municipalityB = await fixtures.claimMunicipality()
// A dobradinha linked to municipality A only, with a distinctive party so
// the Partido facet cannot collide with other parallel specs.
const deputyName = fixtures.value('Deputada B176')
const deputyContact = await fixtures.payload.create({
collection: 'contact',
data: { name: deputyName, state: 'BA' },
depth: 0,
})
const deputy = await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: deputyContact.id, party: 'BR176', slug: fixtures.value('deputado') },
depth: 0,
draft: false,
})
await fixtures.payload.update({
collection: 'municipality',
id: municipalityA.id,
data: { stateDeputies: [deputy.id] },
depth: 0,
})
fixtures.touchMunicipality(municipalityA.id)
// A leadership acting on municipality B only.
const leadership = await fixtures.createStaffLeadership({
namePrefix: 'Liderança B176',
municipalities: [municipalityB],
})
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
const omnibox = page.getByRole('combobox', { name: 'Filtrar municípios' })
const listbox = page.getByRole('listbox', { name: 'Filtrar municípios' })
const tableRows = municipalityContainer(page).locator('tbody tr')
// The omnibox chips live in the search form ABOVE the list container.
const removeChip = (label: string) => page.getByRole('button', { name: `Remover ${label}` })
// Dobradinha by contact name → chip + only municipality A remains.
await omnibox.fill(deputyName)
await listbox.getByRole('option', { name: new RegExp(`^${deputyName} \\(BR176\\)$`) }).click()
await waitForStreamSettled(page)
await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toBeVisible()
await expect(tableRows).toHaveCount(1)
await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible()
// Removing the chip restores the unfiltered 25-row page 1. The removal is
// a client `navigate` — poll the chip out and let the stream commit before
// touching the omnibox again (OPS83: the next option click raced this).
await removeChip(`Dobradinha: ${deputyName} (BR176)`).click()
await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toHaveCount(0)
await waitForStreamSettled(page)
// Partido by acronym → the same single municipality (its only dobradinha).
await omnibox.fill('BR176')
await listbox.getByRole('option', { name: 'BR176', exact: true }).click()
await waitForStreamSettled(page)
await expect(removeChip('Partido: BR176')).toBeVisible()
await expect(tableRows).toHaveCount(1)
await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible()
await removeChip('Partido: BR176').click()
await expect(removeChip('Partido: BR176')).toHaveCount(0)
await waitForStreamSettled(page)
// Liderança by contact name → municipality B only.
await omnibox.fill(leadership.contactName)
await listbox.getByRole('option', { name: new RegExp(`^${leadership.contactName}$`) }).click()
await waitForStreamSettled(page)
await expect(removeChip(`Liderança: ${leadership.contactName}`)).toBeVisible()
await expect(tableRows).toHaveCount(1)
await expect(page.getByRole('link', { name: municipalityB.name, exact: true })).toBeVisible()
})
test('B176 — advisor edits Dobradinhas inline within an administered municipality', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor B176'),
})
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { advisors: [advisor.id] },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
// A dobradinha linked to NO municipality — the full staff catalog must
// still offer it to the advisor in the cell (gate 2026-08-09).
const deputyName = fixtures.value('Deputado Disponível B176')
const contact = await fixtures.payload.create({
collection: 'contact',
data: { name: deputyName, state: 'BA', city: null },
depth: 0,
})
await fixtures.payload.create({
collection: 'stateDeputy',
data: { contact: contact.id, party: 'PSD', slug: fixtures.value('deputado') },
depth: 0,
draft: false,
})
await campaign.sessionFor(context, advisor)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
const popover = page.locator('[data-slot="popover-content"]')
await expect(popover).toBeVisible()
const search = popover.getByRole('combobox', { name: 'Buscar dobradinha' })
const chip = popover.getByRole('button', { name: `Remover ${deputyName} (PSD)` })
await expect(async () => {
await search.fill(deputyName)
// Register the write-waiter before the click: asserting the optimistic
// chip alone proves nothing about the database, and a reload racing the
// POST would capture the pre-write state (the B32+ auto-save race).
const saved = expectPostResponse(page, '/campanha/municipios')
await popover.getByRole('option', { name: new RegExp(`^${deputyName} \\(PSD\\)$`) }).click()
await expect(chip).toBeVisible({ timeout: 5_000 })
await saved
}).toPass({ timeout: 25_000 })
await page.keyboard.press('Escape')
// Persistence across reload: the server holds the assignment.
await page.reload()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
await expect(async () => {
await visibleMunicipalityButton(page, `Editar dobradinhas em ${municipality.name}`).click()
await expect(
page.locator('[data-slot="popover-content"]').getByRole('button', {
name: `Remover ${deputyName} (PSD)`,
}),
).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 25_000 })
})
test('coordinator sets trend status and justification with auto-save (B24)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
const note = fixtures.value('Vereador confirmou apoio local')
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
// The trend control renders in both the desktop cell and the always-mounted
// mobile-card tree (B158); `visibleMunicipalityButton` scopes to the list
// container and the visible copy after the wide stage settles, the way the
// sibling journeys do.
const trendButton = visibleMunicipalityButton(
page,
`Editar tendência política em ${municipality.name}`,
)
await expect(trendButton).toBeVisible()
await trendButton.click()
const trendPopover = page.locator('[data-slot="popover-content"]')
await expect(trendPopover).toBeVisible()
await expect(trendPopover.getByRole('button', { name: 'Salvar' })).toHaveCount(0)
await trendPopover.getByLabel('Justificativa').fill(note)
await trendPopover.getByLabel('Tendência', { exact: true }).selectOption('favoravel')
await Promise.all([
expectPostResponse(page, '/campanha/municipios/political-trend'),
trendPopover.getByLabel('Justificativa').blur(),
])
await expect(trendButton).toContainText('Favorável')
await page.keyboard.press('Escape')
await page.reload()
// The reload restarts the RSC stream and the B158 stage: the filtered row
// re-renders in waves (each one moves the button, so a click keeps waiting
// for stability) and hydration lags SSR paint (a pre-hydration click is a
// silent no-op, P3-C class). Re-settle the wide stage first, then click
// only while the popover is closed — the trigger TOGGLES, so a blind
// click-per-iteration would close an open popover forever. The reopened
// popover proves the persisted save (server round trip), not just the
// optimistic chip.
await ensureWideMunicipalityList(page)
const reopened = page.locator('[data-slot="popover-content"]')
await expect(async () => {
if (!(await reopened.first().isVisible())) await trendButton.click()
await expect(reopened.getByLabel('Tendência', { exact: true })).toHaveValue('favoravel', {
timeout: 2_000,
})
await expect(reopened.getByLabel('Justificativa')).toHaveValue(note)
}).toPass({ timeout: 15_000 })
})
test('coordinator registers an adversary-signal update from the list freshness cell', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await ensureWideMunicipalityList(page)
// Anchored: a bare template regex on a municipality name collides with
// prefix-shared names (Conde/Condeúba — 23/435 measured in the catalog),
// and `exact` no longer matches because the accessible name appends the
// current signal state ("— Sem sinal").
const signalTrigger = municipalityContainer(page)
.getByRole('button', {
name: new RegExp(`^Registrar atualização em ${municipality.name} —`),
})
.filter({ visible: true })
await expect(signalTrigger).toBeVisible()
await signalTrigger.click()
const signalForm = page.locator('[data-slot="popover-content"]')
await expect(signalForm).toBeVisible()
await signalForm
.getByRole('textbox', { name: 'Texto' })
.fill('Liderança local reportou visita adversária na feira.')
await signalForm.getByLabel('Polaridade').getByRole('radio', { name: 'Ruim' }).click()
await signalForm.getByLabel('Sinalizar adversário').check()
await Promise.all([
expectPostResponse(page, '/campanha/municipios'),
signalForm.getByRole('button', { name: 'Registrar atualização', exact: true }).click(),
])
// The toast is intentionally transient; the persisted freshness label is
// the stable success contract and must arrive after the server response.
await expect(
page.getByRole('button', {
name: `Registrar atualização em ${municipality.name} — hoje`,
}),
).toBeVisible()
})
test('advisor scopes municipalities; staff declare and estimate; leader is locked to contacts', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const administered = await fixtures.claimMunicipality()
const outside = await fixtures.claimMunicipality()
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessora'),
})
const password = advisor.password
await campaign.payload.update({
collection: 'municipality',
id: administered.id,
data: { advisors: [advisor.id] },
depth: 0,
})
fixtures.touchMunicipality(administered.id)
const leaderPhone = fixtures.phone()
const leaderAccount = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança'),
username: leaderPhone,
})
const contact = await campaign.payload.create({
collection: 'contact',
data: {
name: fixtures.value('Contato Liderança'),
phones: [{ value: leaderPhone }],
state: 'BA',
city: administered.name,
},
depth: 0,
})
const leadership = await campaign.payload.create({
collection: 'leadership',
data: {
contact: contact.id,
municipalities: [administered.id],
supportStatus: 'engajado',
user: leaderAccount.id,
},
depth: 0,
})
await ensureLeasedConsent(campaign.payload, {
consentKey: SUPPORTER_REGISTRATION_CONSENT_KEY,
leaseKey: SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
})
// Advisor scope: only the administered municipality shows up.
await campaign.login(page, advisor.email!, password)
await showAllMunicipalityColumns(page, campaign.baseURL)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(
page.getByRole('link', { name: administered.name, exact: true }).first(),
).toBeVisible()
await expect(page.getByRole('link', { name: outside.name, exact: true })).toHaveCount(0)
// Staff declare votes on behalf of the leadership.
await page.goto(`${campaign.baseURL}/campanha/municipios/${administered.slug}?tab=leaderships`)
// C106 — the dossiê tab streams a transient `S:` shell copy; under load the
// leadership-list chunk can land after `goto` settles, so let the stream
// commit before asserting the leadership link (same class as the `:115`
// editar checkbox and the C142 FAB). The generic `S:` gate alone is not
// enough (observed in the OPS83 verify run #15): poll until the specific
// link lands in the DOM, then assert visibility (`.first()` only disambiguates
// if a duplicate ever appears — the count poll is the real gate).
await waitForStreamSettled(page)
const leadershipLink = page.getByRole('link', { name: contact.name })
await expect.poll(() => leadershipLink.count(), { timeout: 15_000 }).toBeGreaterThan(0)
await expect(leadershipLink.first()).toBeVisible()
await page.getByLabel('Quantos votos a liderança traz neste município?').fill('250')
await page.getByRole('button', { name: 'Declarar' }).click()
await expect(page.getByText('Declaração de votos registrada.')).toBeVisible()
// Advisor records an internal estimate on the overview pledges panel.
await page.goto(`${campaign.baseURL}/campanha/municipios/${administered.slug}`)
// The overview pledges panel streams under load; the `Média` estimate
// field can land after `goto` settles (OPS83 run #16: fill timed out).
await waitForStreamSettled(page)
const estimateInput = page.getByLabel('Média', { exact: true })
await expect.poll(() => estimateInput.count(), { timeout: 20_000 }).toBeGreaterThan(0)
await estimateInput.fill('90')
await page.getByLabel('Justificativa').fill('Histórico da região indica menos.')
await page.getByRole('button', { name: 'Salvar estimativa' }).click()
await expect(page.getByText('Média: 90')).toBeVisible()
// Leader home is the blank Início (B43); the contact tool lives at
// /campanha/meus-contatos (C139) and municipalities redirect there.
// Each `createCampaignUser` call mints its own password — the advisor's
// `password` variable above does NOT unlock the leader account.
await campaign.login(page, leaderPhone, leaderAccount.password)
await page.goto(`${campaign.baseURL}/campanha/meus-contatos`)
await expect(campaignPageChrome(page, 'Meus contatos')).toBeVisible()
// The leader redirect aborts `goto`'s load event (ERR_ABORTED); the
// redirect itself is the assertion, and `toHaveURL` retries onto it.
await page.goto(`${campaign.baseURL}/campanha/municipios`).catch(() => {})
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/meus-contatos`)
const supporterName = fixtures.value('Apoiador Liderança')
const supporterPhone = fixtures.phone()
await page.getByLabel('Nome *').fill(supporterName)
await page.getByRole('textbox', { name: 'Celular 1' }).fill(supporterPhone)
// Radix checkbox: a pre-hydration click is a silent no-op (B13/B17 flake).
await checkRadixWhenHydrated(page, 'A pessoa autorizou o cadastro *')
await page.getByRole('button', { name: 'Cadastrar contato' }).click()
await expect(page.getByText(supporterName)).toBeVisible()
expect(leadership.id).toBeGreaterThan(0)
})
test('advisor opens a demand and decides it', async ({ campaign, context, page }) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor Demandas'),
})
await campaign.payload.update({
collection: 'municipality',
id: municipality.id,
data: { advisors: [advisor.id] },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
// B195: single free-text field; the title is derived server-side (fallback
// truncation without a DEEPSEEK_API_KEY in the e2e app).
const demandText = fixtures.value('Carro de som: precisamos para a caminhada de sábado.')
const demandTitle = fallbackDemandTitle(demandText)
await campaign.sessionFor(context, advisor)
await page.goto(`${campaign.baseURL}/campanha/demandas/nova`)
await page.getByLabel('Município').selectOption({ label: municipality.name })
await page.getByLabel('O que você precisa?').fill(demandText)
// The create action is a server action — not prewarmable — and its first
// dev-mode invocation can be aborted by the compile full-page reload, which
// leaves the form filled but never navigates. On the retry: if the demand
// already exists (the POST landed but the redirect was lost — `slug` is
// unique, a second submit would fail the constraint), go straight to its
// detail; otherwise re-submit against the now-warm action.
await expect(async () => {
if (page.url().includes('/campanha/demandas/nova')) {
const existing = await campaign.payload.find({
collection: 'campaignDemand',
where: { title: { equals: demandTitle } },
depth: 0,
limit: 1,
pagination: false,
})
if (existing.docs.length > 0) {
await page.goto(`${campaign.baseURL}/campanha/demandas/${existing.docs[0].slug}`)
} else {
await page.getByRole('button', { name: 'Abrir demanda' }).click()
}
}
await expect(campaignPageChrome(page, demandTitle)).toBeVisible({ timeout: 10_000 })
}).toPass({ timeout: 30_000 })
await expect(page.getByRole('button', { name: 'Aprovar' })).toBeVisible()
await page.goto(`${campaign.baseURL}/campanha/demandas`)
await page.getByRole('link', { name: demandTitle }).click()
await page.getByLabel('Nota da decisão').fill('Aprovado — retirar no comitê.')
await page.getByRole('button', { name: 'Aprovar' }).click()
await expect(page.getByText('Esta demanda já foi decidida.')).toBeVisible()
await expect(page.getByText('Aprovada', { exact: true })).toBeVisible()
})
})
/**
* B42: on the phone the list is cards, the quick-edit cells are Drawers, and the
* card itself is the way into the município — the "Abrir município" button is
* gone. The two gestures that can only fail together are pinned here: tapping a
* control must not navigate, and tapping anything else must.
*/
test.describe('Municípios — cards no celular (B42)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('opens the trend Drawer without navigating, and the card body opens the município', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
const listURL = `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`
await page.goto(listURL)
const card = page.locator('[data-view="mobile-cards"] article').first()
await expect(card).toBeVisible()
await expect(page.getByRole('link', { name: 'Abrir município' })).toHaveCount(0)
// Client-side Drawer state — a pre-hydration click is a silent no-op (the
// B13/B17 flake class; with the shared-session seed the card is often the
// worker's first load), so deliver the tap through a retry loop.
await expect(async () => {
await card.getByRole('button', { name: 'Editar tendência política' }).click({
timeout: 1_000,
})
await expect(page.getByRole('dialog', { name: 'Editar tendência' })).toBeVisible({
timeout: 5_000,
})
}).toPass({ timeout: 15_000 })
// B126 FAB overlay is closed by default; scope to the trend dialog.
const drawer = page.getByRole('dialog', { name: 'Editar tendência' })
await expect(drawer).toBeVisible()
await expect(drawer.getByText(municipality.name, { exact: true })).toBeVisible()
await expect(page).toHaveURL(listURL)
await drawer.getByRole('button', { name: 'Fechar' }).click()
await expect(drawer).toBeHidden()
// Anywhere outside a control is the município's own link: the click lands on
// the stretched `after:inset-0` overlay, which belongs to the card heading.
// B193 recomposed the card and every label became a trigger, so the tap goes
// on the card's top-left padding corner — the one area no control covers.
// `force`: Playwright's hit-target check retries forever here precisely
// BECAUSE the stretched overlay intercepts the pointer — which is the
// behavior under test. Force dispatches the tap at that point, the overlay
// receives it (as a user's finger would), and the card navigates.
await card.click({ position: { x: 6, y: 6 }, force: true })
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
await expect(campaignPageChrome(page, municipality.name)).toBeVisible()
})
/**
* C109: the custom-footer sheets ("Registrar atualização" here, the same
* mechanism as E14's "Registrar movimento") used to portal their footer from
* the cell into the shared Drawer. `createPortal` keeps the source context,
* which has no `Drawer.Root`, so base-ui's `DialogClose` threw and the sheet
* never rendered. The footer must open, submit, and close inside the shared
* Drawer.
*/
test('opens the update quick-edit sheet, registers, and closes via Cancelar (C109)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
const listURL = `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`
await page.goto(listURL)
const card = page.locator('[data-view="mobile-cards"] article').first()
await expect(card).toBeVisible()
// Pre-hydration click guard (B13/B17 class) — see the sibling comment in
// the B42 trend Drawer test.
await expect(async () => {
await card.getByRole('button', { name: /^Registrar atualização em/ }).click({
timeout: 1_000,
})
await expect(page.getByRole('dialog', { name: 'Registrar atualização' })).toBeVisible({
timeout: 5_000,
})
}).toPass({ timeout: 15_000 })
// The sheet's primary action lives in the custom footer — the two buttons
// the phone thumb expects, rendered inside the shared Drawer.
const sheet = page.getByRole('dialog', { name: 'Registrar atualização' })
await expect(sheet).toBeVisible()
await expect(
sheet.getByRole('button', { name: 'Registrar atualização', exact: true }),
).toBeVisible()
await expect(sheet.getByRole('button', { name: 'Cancelar' })).toBeVisible()
await expect(page).toHaveURL(listURL)
await sheet
.getByRole('textbox', { name: 'Texto' })
.fill('Sinal registrado pelo sheet mobile do card.')
await sheet.getByLabel('Polaridade').getByRole('radio', { name: 'Boa' }).click()
await Promise.all([
expectPostResponse(page, '/campanha/municipios'),
sheet.getByRole('button', { name: 'Registrar atualização', exact: true }).click(),
])
// The toast is intentionally transient; the persisted freshness label is
// the stable success contract and must arrive after the server response.
// B193 — once the município HAS an update, the card footer becomes the
// expandable "Última atualização" row (the register CTA lives inside the
// expansion), so the freshness contract is asserted on the footer itself.
const updateFooter = card.getByRole('button', { name: /^Última atualização/ })
await expect(updateFooter).toContainText('hoje')
await updateFooter.click()
// Reopen: the chrome is rebuilt fresh, and the custom footer comes back.
await card.getByRole('button', { name: /^Registrar atualização em/ }).click()
await expect(sheet).toBeVisible()
await sheet.getByRole('button', { name: 'Cancelar' }).click()
await expect(sheet).toBeHidden()
// E14's level sheet is the same custom-footer mechanism — the other caller
// that shares the crash. It must open and close without one too.
await card.getByRole('button', { name: /^Nível de envolvimento de/ }).click()
const levelSheet = page.getByRole('dialog', { name: 'Registrar nível de envolvimento' })
await expect(levelSheet).toBeVisible()
await expect(levelSheet.getByRole('button', { name: 'Registrar movimento' })).toBeVisible()
await levelSheet.getByRole('button', { name: 'Cancelar' }).click()
await expect(levelSheet).toBeHidden()
})
})
/**
* B184 (2026-08-09): on the phone the list-filter chrome is borderless and
* sticky under the top bar (the shared omnibox standard), the text "Limpar" is
* replaced by a circular X inside the field (chips OR a typed search), the
* save-recorte control anchors in the app top bar, and the municipality cards
* are edge-to-edge with a single separator line between them.
*/
test.describe('Municípios — filtro e cards sem moldura no celular (B184)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('omnibox borderless/sticky, X clears chips and search, save in the top bar, cards edge-to-edge', async ({
campaign,
page,
}) => {
await page.goto(`${campaign.baseURL}/campanha/municipios`)
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell;
// strict-mode locators below would match BOTH copies while it settles.
await waitForStreamSettled(page)
// The mobile standard hides the field label; the input keeps its name via
// aria-label, and the filter form is a borderless sticky bar.
await expect(page.getByText('Filtrar municípios', { exact: true })).toBeHidden()
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar municípios' })
const field = omniboxInput.locator('..')
const filterForm = omniboxInput.locator('xpath=ancestor::form[1]')
await expect(field).toHaveCSS('border-top-width', '0px')
await expect(filterForm).toHaveCSS('position', 'sticky')
await expect(filterForm).toHaveCSS('border-bottom-width', '1px')
// No filters, no search → no clear affordance at all.
await expect(page.getByRole('button', { name: 'Limpar', exact: true })).toHaveCount(0)
// A typed search alone shows the X; clicking it clears the draft, keeps
// the focus in the field and does not reopen the suggestion popover (the
// contract that broke the agenda e2e before the `setOpen(false)`).
await omniboxInput.fill('Feira de Santana')
const clearX = page.getByRole('button', { name: 'Limpar', exact: true })
await expect(clearX).toBeVisible()
await clearX.click()
await expect(omniboxInput).toHaveValue('')
await expect(omniboxInput).toBeFocused()
await expect(omniboxInput).toHaveAttribute('aria-expanded', 'false')
await expect(clearX).toHaveCount(0)
// Active chips also show the X, and clicking it drops the whole recorte.
await page.goto(`${campaign.baseURL}/campanha/municipios?region=Recôncavo`)
await expect(page.getByRole('button', { name: 'Remover Território: Recôncavo' })).toBeVisible()
await expect(clearX).toBeVisible()
await clearX.click()
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios`)
// The save-recorte control anchors in the app top bar on mobile — absent
// without a recorte; with one, the same naming popover saves and flips the
// icon's accessible name to the rename contract (SC 2.5.3).
const topBar = page.locator('[data-slot="campaign-mobile-top-bar"]')
const headerSave = topBar.getByRole('button', { name: 'Salvar filtro', exact: true })
await expect(headerSave).toHaveCount(0)
await page.goto(`${campaign.baseURL}/campanha/municipios?region=Recôncavo`)
await expect(headerSave).toBeVisible()
await headerSave.click()
await page.getByLabel('Nome do filtro').fill('Recorte B184')
await page.getByRole('button', { name: 'Salvar', exact: true }).click()
await expect(
topBar.getByRole('button', { name: 'Renomear o filtro salvo Recorte B184' }),
).toBeVisible()
// Cards are edge-to-edge with one horizontal line between them and none
// after the last one.
const cards = page.locator('[data-view="mobile-cards"] article')
await expect(cards.first()).toHaveCSS('border-top-width', '0px')
await expect(cards.first()).toHaveCSS('border-left-width', '0px')
await expect(cards.first()).toHaveCSS('border-bottom-width', '1px')
await expect(cards.last()).toHaveCSS('border-bottom-width', '0px')
// The desktop frame is untouched: back to a wide viewport the field has
// its border again, the label is visible and the text "Limpar" returns.
await page.setViewportSize({ width: 1280, height: 900 })
await expect(page.getByText('Filtrar municípios', { exact: true })).toBeVisible()
await expect(field).toHaveCSS('border-top-width', '1px')
await expect(page.getByRole('button', { name: 'Limpar', exact: true })).toBeVisible()
})
})
/**
* B193 (2026-08-10): the mobile card is a dense composition — scenario bar
* with the active marker, chips (classe/tendência/nível), three labelled
* avatar groups — where every data point is its own bottom-sheet trigger
* (edit-where-you-see), the priority município gets a right accent border,
* and the "Última atualização" footer expands to reveal the last update card
* (or becomes a direct register CTA when nothing was ever recorded).
*/
test.describe('Municípios — card denso mobile (B193)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('scenario bar opens the estimate sheet and reflects saved values', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
// `?q=` matches name PREFIXES ("Coribe" also returns "São Félix do
// Coribe"), so the card is anchored on the municipality's own name link.
const card = page
.locator('[data-view="mobile-cards"] article')
.filter({ has: page.getByRole('link', { name: municipality.name, exact: true }) })
.first()
await expect(card).toBeVisible()
// Pre-hydration click guard (B13/B17 class) — the sheet is client-side
// state and the card is often the worker's first load with the seeded
// session.
await expect(async () => {
await card.getByRole('button', { name: /Editar votos estimados/ }).click({ timeout: 1_000 })
await expect(page.getByRole('dialog', { name: 'Editar votos estimados' })).toBeVisible({
timeout: 5_000,
})
}).toPass({ timeout: 15_000 })
const drawer = page.getByRole('dialog', { name: 'Editar votos estimados' })
await expect(drawer).toBeVisible()
await drawer.getByLabel('Pessimista', { exact: true }).fill('7500')
await drawer.getByLabel('Média', { exact: true }).fill('8000')
await drawer.getByLabel('Otimista', { exact: true }).fill('10000')
// The autosave announces "Salvando…" through the trigger's own live region
// (next to the button, outside the drawer); closing flushes the save.
await drawer.getByRole('button', { name: 'Fechar' }).click()
await expect(drawer).toBeHidden()
// The strip is the live autosave value: the card shows all three
// scenarios after the save echoed back.
await expect(card.getByText('7.500')).toBeVisible()
await expect(card.getByText('8.000')).toBeVisible()
await expect(card.getByText('10.000')).toBeVisible()
await expect(page).toHaveURL(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
})
test('the Nível chip (label included) opens the level sheet', async ({ campaign, page }) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
// `?q=` matches name PREFIXES ("Coribe" also returns "São Félix do
// Coribe"), so the card is anchored on the municipality's own name link.
const card = page
.locator('[data-view="mobile-cards"] article')
.filter({ has: page.getByRole('link', { name: municipality.name, exact: true }) })
.first()
await expect(card).toBeVisible()
await card.getByText('Nível', { exact: true }).click()
const drawer = page.getByRole('dialog', { name: 'Registrar nível de envolvimento' })
await expect(drawer).toBeVisible()
await expect(drawer.getByText(municipality.name, { exact: true })).toBeVisible()
await expect(page).toHaveURL(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
})
test('priority município gets the right accent border (B193)', async ({ campaign, page }) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { priority: 'alta' },
depth: 0,
})
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
// `?q=` matches name PREFIXES ("Coribe" also returns "São Félix do
// Coribe"), so the card is anchored on the municipality's own name link.
const card = page
.locator('[data-view="mobile-cards"] article')
.filter({ has: page.getByRole('link', { name: municipality.name, exact: true }) })
.first()
await expect(card).toBeVisible()
await expect(card).toHaveCSS('border-right-width', '6px')
// The accent color is a token contract, not a literal: assert the utility
// class that paints it (`border-r-primary`) instead of the resolved color.
await expect(card).toHaveClass(/border-r-primary/)
})
test('footer expands to the last update card and offers register; empty footer is a direct CTA', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const withUpdate = await fixtures.claimMunicipality()
await fixtures.payload.create({
collection: 'municipalityUpdate',
data: {
municipality: withUpdate.id,
author: sharedCoordinator.id,
polarity: 'boa',
body: `Fato de campo B193 em ${withUpdate.name}`,
},
depth: 0,
})
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(withUpdate.name)}`,
)
// `?q=` matches name prefixes — anchor on the municipality's own name link.
const card = page
.locator('[data-view="mobile-cards"] article')
.filter({ has: page.getByRole('link', { name: withUpdate.name, exact: true }) })
.first()
await expect(card).toBeVisible()
// The footer button expands the card bottom and reveals the last update
// card (no surface change) plus the register CTA.
const footer = card.getByRole('button', { name: /Última atualização/ })
await expect(footer).toHaveAttribute('aria-expanded', 'false')
await footer.click()
await expect(footer).toHaveAttribute('aria-expanded', 'true')
await expect(card.getByText(`Fato de campo B193 em ${withUpdate.name}`)).toBeVisible()
await expect(card.getByText('Boa', { exact: true })).toBeVisible()
const registerCta = card.getByRole('button', { name: /Registrar atualização em/ })
await registerCta.click()
const drawer = page.getByRole('dialog', { name: 'Registrar atualização' })
await expect(drawer).toBeVisible()
await expect(drawer.getByText(withUpdate.name, { exact: true })).toBeVisible()
await drawer.getByRole('button', { name: 'Cancelar' }).click()
await expect(drawer).toBeHidden()
// A município with no update at all has no expandable footer: the footer
// IS the register CTA, without chevron or expansion.
const empty = await fixtures.claimMunicipality()
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(empty.name)}`)
const emptyCard = page
.locator('[data-view="mobile-cards"] article')
.filter({ has: page.getByRole('link', { name: empty.name, exact: true }) })
.first()
await expect(emptyCard).toBeVisible()
await expect(emptyCard.getByRole('button', { name: /Última atualização/ })).toHaveCount(0)
const emptyCta = emptyCard.getByRole('button', { name: /Registrar atualização em/ })
await emptyCta.click()
await expect(page.getByRole('dialog', { name: 'Registrar atualização' })).toBeVisible()
})
})
/**
* B196 (2026-08-10): the mobile list bar is a chassis standard for every
* list — glued to the app top bar with no gap (the sticky's containing block
* starts at the scrollport top via `:has()`), dense (~40px total, no focus
* ring below `md`), and the mobile card reads denser: 18px name with the
* territory glued, label-above chips (Classe/Tendência/Nível — gate B), and
* one overlapping avatar row per relation group (no wrap, no cap, no "…").
*/
test.describe('Municípios — barra colada e card denso (B196)', () => {
test.use({ viewport: { width: 390, height: 844 } })
const mobileTopBar = (page: Page) => page.locator('[data-slot="campaign-mobile-top-bar"]')
const contentScroll = (page: Page) => page.locator('[data-slot="campaign-content-scroll"]')
const headerBottom = async (topBar: ReturnType<typeof mobileTopBar>) =>
(await topBar.boundingBox())!.y + (await topBar.boundingBox())!.height
// `?q=` matches name PREFIXES, so every card is anchored on the
// municipality's own name link.
const cardFor = (page: Page, name: string) =>
page
.locator('[data-view="mobile-cards"] article')
.filter({ has: page.getByRole('link', { name, exact: true }) })
.first()
test('bar is glued to the header, ~45px tall, no focus ring, nothing passes above on scroll', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B196 Barra'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell.
await waitForStreamSettled(page)
const topBar = mobileTopBar(page)
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar municípios' })
const form = omniboxInput.locator('xpath=ancestor::form[1]')
const field = omniboxInput.locator('..')
await expect(form).toBeVisible()
// The bar's own `pt-1` applying proves the stylesheet reached the page —
// before that the form renders unstyled and measures ~79px (visible label).
await expect(form).toHaveCSS('padding-top', '4px')
// Glued: no gap between the app top bar and the filter bar.
await expect
.poll(async () => (await form.boundingBox())!.y, { timeout: 30_000 })
.toBe(await headerBottom(topBar))
// Taller (B200): the whole bar sits between 44 and 48px (40px field +
// pt-1 + border) — up from the B196 ~40px, without the B184 field height.
// One poll asserts the whole band so both bounds read the same frame.
await expect
.poll(
async () => {
const height = (await form.boundingBox())!.height
return height >= 44 && height <= 48
},
{ timeout: 30_000 },
)
.toBe(true)
// No focus ring: the field gains no shadow/border color on focus.
const shadowBefore = await field.evaluate((el) => getComputedStyle(el).boxShadow)
await omniboxInput.focus()
await expect
.poll(() => field.evaluate((el) => getComputedStyle(el).boxShadow))
.toBe(shadowBefore)
// First card glued to the bar: no vão between the bar's bottom edge and
// the first card's top edge (B200 — the form's `-mb-8` cancels the shell
// column's gap-8; `pb-0` only removes the 4px padding remnant). Bounded
// on both sides so the card can't tuck UNDER the opaque bar either.
const firstCard = page.locator('[data-view="mobile-cards"] article').first()
await expect(firstCard).toBeVisible()
const formBottom = (await form.boundingBox())!.y + (await form.boundingBox())!.height
await expect
.poll(
async () => {
const y = (await firstCard.boundingBox())!.y
return Math.abs(y - formBottom) <= 1
},
{ timeout: 30_000 },
)
.toBe(true)
// Scroll: the bar stays glued to the header — cards roll UNDER the
// opaque bar, never above it.
await contentScroll(page).evaluate((el) => el.scrollTo(0, el.scrollHeight))
await expect
.poll(async () => (await form.boundingBox())!.y, { timeout: 30_000 })
.toBe(await headerBottom(topBar))
await expect
.poll(async () => (await form.boundingBox())!.height, { timeout: 30_000 })
.toBeLessThanOrEqual(48)
})
test('name is 18px with the territory glued; Classe/Tendência/Nível chips are label-above blocks', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B196 Card'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
// C106 — dynamic pages stream a transient hidden `#S:*` copy of the shell;
// geometry assertions must wait it out or they measure the stream copy.
await waitForStreamSettled(page)
const card = cardFor(page, municipality.name)
await expect(card).toBeVisible()
// Smaller name (18px) with the territory glued right below it.
const heading = card.getByRole('heading', { level: 3 })
await expect(heading).toHaveCSS('font-size', '18px')
const headerGap = await card.evaluate((el) => {
const headingBox = el.querySelector('h3')!.getBoundingClientRect()
const territoryBox = el.querySelector('h3 + p')!.getBoundingClientRect()
return territoryBox.y - headingBox.y - headingBox.height
})
expect(headerGap).toBeLessThanOrEqual(6)
// Every chip is a label-above block: the value renders below the label
// (gate B). "Classe" debuts as a label; Tendência/Nível keep theirs.
// All three pairs are read in ONE layout snapshot (the dev server may
// reload a page mid-test under parallel workers, which would otherwise
// straddle two layouts).
for (const label of ['Classe', 'Tendência', 'Nível']) {
const chipLabel = card.getByText(label, { exact: true })
await expect(chipLabel).toBeVisible()
const { labelY, valueY } = await chipLabel.evaluate((el) => {
const sibling = el.nextElementSibling
return {
labelY: el.getBoundingClientRect().y,
valueY: sibling?.getBoundingClientRect().y ?? 0,
}
})
expect(valueY).toBeGreaterThan(labelY)
}
})
test('relation groups: one row per group, overlap grows with the count', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B196 Avatares'),
})
const fiveAdvisors = await Promise.all(
Array.from({ length: 5 }, (_, i) =>
fixtures.createCampaignUser('advisor', {
name: fixtures.value(`Assessora B196 ${i + 1}`),
}),
),
)
const twoAdvisors = await Promise.all(
Array.from({ length: 2 }, (_, i) =>
fixtures.createCampaignUser('advisor', {
name: fixtures.value(`Assessora B196 dois ${i + 1}`),
}),
),
)
const crowded = await fixtures.claimMunicipality()
const sparse = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: crowded.id,
data: { advisors: fiveAdvisors.map((advisor) => advisor.id) },
depth: 0,
})
await fixtures.payload.update({
collection: 'municipality',
id: sparse.id,
data: { advisors: twoAdvisors.map((advisor) => advisor.id) },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
// Five advisors: all on ONE row, each covering the start of the next —
// the overlap is proportional to the count (no wrap, no cap, no "…").
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(crowded.name)}`)
await waitForStreamSettled(page)
const crowdedCard = cardFor(page, crowded.name)
await expect(crowdedCard).toBeVisible()
// The completion footer proves the filtered list finished streaming — the
// `S:` settle alone can pass while the list still re-renders (measured:
// a mid-stream card can report a transient narrow layout). B196 flake
// class: under 4-worker load the footer chunk can land late, so poll with
// the same 30s budget as the geometry asserts below instead of the 10s
// expect default.
await expect
.poll(() => page.getByText('1 município encontrado').first().isVisible(), { timeout: 30_000 })
.toBe(true)
const crowdedRow = crowdedCard.locator('[data-view="relation-avatars"]').first()
await expect(crowdedRow.locator('.size-7')).toHaveCount(5)
// ONE layout snapshot per read, polled until the settled layout satisfies
// the whole contract: a mid-stream card can transiently report a narrow
// cell layout (measured) that no single geometry assertion should pin.
await expect
.poll(
async () => {
const geometry = await crowdedRow.evaluate((row) => {
// Coordinator path: row → flex-col group → trigger button → flex-1
// cell (the advisor fallback renders the stack without the button).
const groupColumn = row.parentElement!
const cell = groupColumn.parentElement!.parentElement!
const nextGroup = cell.nextElementSibling
return {
labelX: groupColumn.querySelector('span')!.getBoundingClientRect().x,
nextGroupX: nextGroup
? nextGroup.getBoundingClientRect().x
: Number.POSITIVE_INFINITY,
boxes: [...row.querySelectorAll('.size-7')].map((avatar) => {
const box = avatar.getBoundingClientRect()
return { x: box.x, width: box.width, y: box.y }
}),
}
})
// Same single row.
if (new Set(geometry.boxes.map((box) => Math.round(box.y))).size !== 1) return false
// The pile reads as a stack: each covers the next, and each avatar
// keeps a visible sliver (8px ≤ distance ≤ 20px = overlap 8..20).
for (let i = 1; i < geometry.boxes.length; i++) {
const distance = geometry.boxes[i]!.x - geometry.boxes[i - 1]!.x
if (distance < 8 || distance > 20) return false
}
// B200 — anchored LEFT: the first avatar starts at the label's axis.
if (Math.abs(geometry.boxes[0]!.x - geometry.labelX) > 2) return false
// Nothing clipped, nothing invades the neighbour: the last avatar
// ends before the next group's box (the old flex-1 row clipped it).
const last = geometry.boxes[geometry.boxes.length - 1]!
return last.x + last.width <= geometry.nextGroupX + 0.5
},
{ timeout: 30_000 },
)
.toBe(true)
// Two advisors: same single row, a slight deliberate overlap — a pile at
// the left, not one avatar in each corner (B200).
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(sparse.name)}`)
await waitForStreamSettled(page)
const sparseCard = cardFor(page, sparse.name)
await expect(sparseCard).toBeVisible()
// Same B196 stream-latency flake class as the `crowdedCard` footer above:
// poll the completion footer with the 30s geometry budget.
await expect
.poll(() => page.getByText('1 município encontrado').first().isVisible(), { timeout: 30_000 })
.toBe(true)
const sparseRow = sparseCard.locator('[data-view="relation-avatars"]').first()
await expect(sparseRow.locator('.size-7')).toHaveCount(2)
await expect
.poll(
async () => {
const geometry = await sparseRow.evaluate((row) => {
const groupColumn = row.parentElement!
return {
labelX: groupColumn.querySelector('span')!.getBoundingClientRect().x,
boxes: [...row.querySelectorAll('.size-7')].map((avatar) => {
const box = avatar.getBoundingClientRect()
return { x: box.x, width: box.width, y: box.y }
}),
}
})
return (
new Set(geometry.boxes.map((box) => Math.round(box.y))).size === 1 &&
geometry.boxes[1]!.x < geometry.boxes[0]!.x + geometry.boxes[0]!.width &&
Math.abs(geometry.boxes[0]!.x - geometry.labelX) <= 2
)
},
{ timeout: 30_000 },
)
.toBe(true)
})
test('chassis elsewhere: the actions row keeps its breathing room and the bar glues on scroll (atividades)', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B196 Ativ'),
})
const municipality = await fixtures.claimMunicipality()
// The default tab (`proximos`) only lists `confirmado` activities with
// `startAt >= now`, so the fixture start must be derived from "today" —
// a hardcoded date empties the list once it expires (deploy verify
// 2026-09-11: `scrollHeight - clientHeight` stuck at 0 on all retries).
const startAt = parseBahiaDateTimeInput(
`${civilDatePlusDays(formatBahiaCivilDate(new Date()), 7)}T13:00`,
)
if (!startAt) throw new Error('Falha ao montar horário da fixture de atividades.')
// Enough rows to make the scrollport scrollable once the top bar takes over.
for (let i = 0; i < 5; i++) {
await fixtures.payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>({
title: `Atividade B196 ${fixtures.value('x')}`,
status: 'confirmado',
startAt,
municipality: municipality.id,
responsible: [{ relationTo: 'campaignUser', value: coordinator.id }],
}),
depth: 0,
})
}
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/atividades`)
const topBar = mobileTopBar(page)
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar atividades' })
const form = omniboxInput.locator('xpath=ancestor::form[1]')
await expect(form).toBeVisible()
// On load the actions row (criar/planejar) breathes ABOVE the bar — the
// bar is not glued yet; it glues once the row scrolls away.
const headerBottomValue = await headerBottom(topBar)
expect((await form.boundingBox())!.y).toBeGreaterThan(headerBottomValue)
// The scrollport must actually scroll for the sticky to engage. Same B196
// stream-latency class as the sibling polls: the list chunk can land late
// under 4-worker load, so use the 30s budget instead of the 10s default.
const scrollport = contentScroll(page)
await expect
.poll(() => scrollport.evaluate((el) => el.scrollHeight - el.clientHeight), {
timeout: 30_000,
})
.toBeGreaterThan(0)
await scrollport.evaluate((el) => el.scrollTo(0, el.scrollHeight))
await expect.poll(async () => (await form.boundingBox())!.y).toBe(headerBottomValue)
})
})
test.describe('Municípios — encaixes dos cards (B200)', () => {
test.use({ viewport: { width: 390, height: 844 } })
const cardFor = (page: Page, name: string) =>
page
.locator('[data-view="mobile-cards"] article')
.filter({ has: page.getByRole('link', { name, exact: true }) })
.first()
test('bar taller (44–48px), first card glued, labels and values on the same left axis', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B200 Cards'),
})
const municipality = await fixtures.claimMunicipality()
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await waitForStreamSettled(page)
// The bar's own `pt-1` applying proves the stylesheet reached the page —
// before that the form renders unstyled and measures ~79px (visible label).
const form = page
.getByRole('combobox', { name: 'Filtrar municípios' })
.locator('xpath=ancestor::form[1]')
await expect(form).toHaveCSS('padding-top', '4px')
// The filter bar is visibly taller than the B196 ~40px bar.
await expect
.poll(async () => (await form.boundingBox())!.height, { timeout: 30_000 })
.toBeGreaterThanOrEqual(44)
await expect
.poll(async () => (await form.boundingBox())!.height, { timeout: 30_000 })
.toBeLessThanOrEqual(48)
// First card starts glued to the bar: no vão between them.
const card = cardFor(page, municipality.name)
await expect(card).toBeVisible()
const formBox = (await form.boundingBox())!
await expect
.poll(async () => (await card.boundingBox())!.y, { timeout: 30_000 })
.toBeLessThanOrEqual(formBox.y + formBox.height + 1)
// Territory glued to the name: B200 tightens the B196 ≤6px to ≤2px.
const headerGap = await card.evaluate((el) => {
const headingBox = el.querySelector('h3')!.getBoundingClientRect()
const territoryBox = el.querySelector('h3 + p')!.getBoundingClientRect()
return territoryBox.y - headingBox.y - headingBox.height
})
expect(headerGap).toBeLessThanOrEqual(2)
// Every chip label and its value start on the same left axis (single
// layout snapshot per pair — see the B196 note on dev reloads).
for (const label of ['Classe', 'Tendência', 'Nível']) {
const chipLabel = card.getByText(label, { exact: true })
const { labelX, valueX } = await chipLabel.evaluate((el) => {
const sibling = el.nextElementSibling
return {
labelX: el.getBoundingClientRect().x,
valueX: sibling?.getBoundingClientRect().x ?? 0,
}
})
expect(Math.abs(valueX - labelX)).toBeLessThanOrEqual(1)
}
// Relation groups: label and value (avatar pile or empty-state badge)
// share the same left axis.
for (const label of ['Assessores', 'Lideranças', 'Dobradinhas']) {
const groupLabel = card.getByText(label, { exact: true })
await expect(groupLabel).toBeVisible()
const { labelX, valueX } = await groupLabel.evaluate((el) => {
const sibling = el.nextElementSibling
return {
labelX: el.getBoundingClientRect().x,
valueX: sibling?.getBoundingClientRect().x ?? 0,
}
})
expect(Math.abs(valueX - labelX)).toBeLessThanOrEqual(2)
}
})
test('avatars: 5–6 all visible in the pile, nothing clipped, nothing invades the neighbour', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora B200 Pilha'),
})
const sixAdvisors = await Promise.all(
Array.from({ length: 6 }, (_, i) =>
fixtures.createCampaignUser('advisor', {
name: fixtures.value(`Assessora B200 seis ${i + 1}`),
}),
),
)
const crowded = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: crowded.id,
data: { advisors: sixAdvisors.map((advisor) => advisor.id) },
depth: 0,
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto(`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(crowded.name)}`)
await waitForStreamSettled(page)
const card = cardFor(page, crowded.name)
await expect(card).toBeVisible()
// The completion footer proves the filtered list finished streaming — the
// `S:` settle alone can pass while the list still re-renders (measured:
// a mid-stream card can report a transient narrow layout).
await expect(page.getByText('1 município encontrado')).toBeVisible()
const row = card.locator('[data-view="relation-avatars"]').first()
const avatars = row.locator('.size-7')
await expect(avatars).toHaveCount(6)
// ONE layout snapshot per read, polled until the settled layout satisfies
// the whole contract: a mid-stream card can transiently report a narrow
// cell layout (measured) that no single geometry assertion should pin.
await expect
.poll(
async () => {
const geometry = await row.evaluate((el) => {
// Coordinator path: row → flex-col group → trigger button → flex-1
// cell (the advisor fallback renders the stack without the button).
const groupColumn = el.parentElement!
const cell = groupColumn.parentElement!.parentElement!
const nextGroup = cell.nextElementSibling
return {
labelX: groupColumn.querySelector('span')!.getBoundingClientRect().x,
nextGroupX: nextGroup
? nextGroup.getBoundingClientRect().x
: Number.POSITIVE_INFINITY,
boxes: [...el.querySelectorAll('.size-7')].map((avatar) => {
const box = avatar.getBoundingClientRect()
return { x: box.x, width: box.width, y: box.y }
}),
}
})
// Same single row.
if (new Set(geometry.boxes.map((box) => Math.round(box.y))).size !== 1) return false
// The pile reads as a stack: each covers the next, and each avatar
// keeps a visible sliver (8px ≤ distance ≤ 20px = overlap 8..20).
for (let i = 1; i < geometry.boxes.length; i++) {
const distance = geometry.boxes[i]!.x - geometry.boxes[i - 1]!.x
if (distance < 8 || distance > 20) return false
}
// Anchored LEFT: the first avatar starts at the label's axis.
if (Math.abs(geometry.boxes[0]!.x - geometry.labelX) > 2) return false
// Nothing clipped, nothing invades the neighbour: the last avatar
// ends before the next group's box.
const last = geometry.boxes[geometry.boxes.length - 1]!
return last.x + last.width <= geometry.nextGroupX + 0.5
},
{ timeout: 30_000 },
)
.toBe(true)
})
})
/**
* B126: FAB quick-actions overlay — no persistent drawer; opens on demand.
*/
test.describe('Municípios — FAB ações rápidas mobile (B126)', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('FAB on load, overlay opens with search and actions, closes cleanly', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
const fab = page.getByRole('button', { name: 'Ações rápidas' })
await expect(fab).toBeVisible()
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
// Client-side overlay state — pre-hydration click guard (B13/B17 class);
// with the seeded session the list is often the worker's first load.
await expect(async () => {
await fab.click({ timeout: 1_000 })
await expect(page.locator('#CampaignQuickActionsOverlay')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
const search = overlay.getByLabel('Buscar na campanha')
await expect(search).toBeVisible()
await expect(search).toHaveAttribute('placeholder', 'Município, liderança, atividade…')
const firstAction = overlay.getByRole('link', { name: 'Ajustar votos' })
await expect(firstAction).toBeVisible()
const actionBox = await firstAction.boundingBox()
const searchBox = await search.boundingBox()
expect(actionBox).toBeTruthy()
expect(searchBox).toBeTruthy()
expect(actionBox!.y).toBeLessThan(searchBox!.y)
await page.keyboard.press('Escape')
await expect(overlay).toBeHidden()
await expect(fab).toBeVisible()
const scrollport = page.locator('[data-slot="campaign-content-scroll"]')
await scrollport.evaluate((el) => {
el.scrollTop = 80
el.dispatchEvent(new Event('scroll'))
})
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
})
})
/**
* B126: overlay search focus retracts actions; current entity excluded from suggest.
*/
test.describe('Municípios — FAB overlay polish (B126)', () => {
test.use({ viewport: { width: 390, height: 844 } })
const staffActionLabels = [
'Ajustar votos',
'Registrar atualização',
'Mudar tendência',
'Atualizar liderança',
'Registrar pedido',
'Ver esquecidos',
] as const
test('overlay actions use a 3×2 grid at mobile width (B136)', async ({ campaign, page }) => {
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
// Pre-hydration click guard (B13/B17 class) — same contract as the FAB
// on-load sibling.
await expect(async () => {
await page.getByRole('button', { name: 'Ações rápidas' }).click({ timeout: 1_000 })
await expect(page.locator('#CampaignQuickActionsOverlay')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
// The Drawer slides in over ~450ms (`duration-450`); `toBeVisible` fires
// mid-flight, so measuring right away reads items at different slide
// offsets and the 3×2 grouping falls apart. Wait for the entrance
// animation to settle before collecting geometry.
await overlay.evaluate(async (el) => {
await Promise.all(el.getAnimations().map((animation) => animation.finished))
})
const actionsRegion = overlay.getByLabel('Ações rápidas')
await expect(actionsRegion.locator('ul[data-layout="grid-3"]')).toBeVisible()
for (const label of staffActionLabels) {
await expect(
actionsRegion
.getByRole('link', { name: label, exact: true })
.or(actionsRegion.getByRole('button', { name: label, exact: true })),
).toBeVisible()
}
const boxes = await collectActionBoundingBoxes(actionsRegion, staffActionLabels)
assertThreeColumnActionGrid(boxes, 2)
})
test('overlay labels readable and search focus hides action strip', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
// The suggest region only renders with curated hits for unrestricted
// roles (B68), so pin one `alta` municipality — deterministic in any
// environment, not only where the projection was imported (OPS29).
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { priority: 'alta' },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
// Pre-hydration click guard (B13/B17 class) — same contract as the FAB
// on-load sibling.
await expect(async () => {
await page.getByRole('button', { name: 'Ações rápidas' }).click({ timeout: 1_000 })
await expect(page.locator('#CampaignQuickActionsOverlay')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
// The Drawer/Dialog slides in (~450ms); `toBeVisible` fires mid-flight and
// focusing the input during the entrance animation can drop the focus
// (and with it the suggest POST). Same settle wait as the B136 sibling.
await overlay.evaluate(async (el) => {
await Promise.all(el.getAnimations().map((animation) => animation.finished))
})
const registerSignal = overlay.getByRole('link', { name: 'Registrar atualização' })
await expect(registerSignal).toBeVisible()
const search = overlay.getByLabel('Buscar na campanha')
// Dev-mode: a sibling test's cold compile can full-page-reload this client
// mid-suggest and truncate the response stream after headers (the setup
// prewarm cannot cover the suggest server work). Blur+refocus re-fires the
// suggest against the now-warm route.
await expect(async () => {
const suggestResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/campanha/home-search') &&
resp.request().method() === 'POST' &&
resp.request().postDataJSON()?.mode === 'suggest',
{ timeout: 5_000 },
)
await search.blur()
await search.focus()
await suggestResponse
await expect(overlay.getByRole('link', { name: 'Registrar atualização' })).toBeHidden()
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toBeVisible({
timeout: 3_000,
})
}).toPass({ timeout: 30_000 })
})
test('overlay search without curated suggestions shows the honest empty state (OPS29)', async ({
campaign,
context,
page,
}) => {
const { fixtures } = campaign
// Advisor with no administered municipality: the suggest scope is the
// portfolio (empty), so the empty state is deterministic even if a
// parallel worker pins an `alta` municipality (B126 sibling) — the
// unrestricted coordinator case stays covered by the unit shell tests.
const advisor = await fixtures.createCampaignUser('advisor', {
name: fixtures.value('Assessor OPS29'),
})
await campaign.sessionFor(context, advisor)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
// Pre-hydration click guard (B13/B17 class) — same contract as the FAB
// on-load sibling.
await expect(async () => {
await page.getByRole('button', { name: 'Ações rápidas' }).click({ timeout: 1_000 })
await expect(page.locator('#CampaignQuickActionsOverlay')).toBeVisible({ timeout: 5_000 })
}).toPass({ timeout: 15_000 })
const overlay = page.locator('#CampaignQuickActionsOverlay')
await expect(overlay).toBeVisible()
const search = overlay.getByLabel('Buscar na campanha')
const suggestResponse = page.waitForResponse(
(resp) =>
resp.url().includes('/campanha/home-search') &&
resp.request().method() === 'POST' &&
resp.request().postDataJSON()?.mode === 'suggest',
)
await search.focus()
await suggestResponse
// The advisor has no portfolio yet (OPS29) — the suggest region must say
// so instead of rendering blank.
const results = overlay.getByRole('region', { name: 'Resultados da busca' })
await expect(results).toContainText('Nenhuma sugestão ainda', { timeout: 10000 })
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toHaveCount(0)
})
})
/**
* B202 — the compact totals line of the municipality list footer. The numbers
* are computed server-side over the WHOLE filtered slice; the browser asserts
* the reading, the scope and the B129 guardrail (never a line inside the table).
*/
test.describe('Municípios — totais do recorte (B202)', () => {
test('single-municipality recorte reads its 2022 votes and scenario sums in the footer', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const municipality = await fixtures.claimMunicipality()
await fixtures.payload.update({
collection: 'municipality',
id: municipality.id,
data: { expectedVotes: { pessimistic: 100, central: 200, optimistic: 300 } },
depth: 0,
})
fixtures.touchMunicipality(municipality.id)
await page.goto(`${campaign.baseURL}/campanha/municipios?slug=${municipality.slug}`)
await waitForStreamSettled(page)
const totals = page.locator('[data-slot="municipality-slice-totals"]')
await expect(totals).toBeVisible()
await expect(page.getByText('1 município encontrado')).toBeVisible()
const votes2022 =
computeVoteRankByYear(DEFAULT_VOTE_RANK_YEAR).get(municipality.slug)?.votes ?? 0
const text = ((await totals.textContent()) ?? '').replace(/\s+/g, ' ').trim()
expect(text).toContain(`2022 ${formatElectionNumber(votes2022)}`)
expect(text).toContain('Pess. 100')
expect(text).toContain('Média 200')
expect(text).toContain('Otim. 300')
expect(text).toContain('1 de 1 com expectativa')
expect(text).toContain('Pessimista: 100; Média: 200; Otimista: 300')
// B129 — the aggregate never becomes a line inside the table.
await expect(page.locator('table [data-slot="municipality-slice-totals"]')).toHaveCount(0)
})
test('unfiltered recorte totals every page, not only the visible 25', async ({
campaign,
page,
}) => {
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await waitForStreamSettled(page)
// The full catalog + the virtual city row span many pages; the summed 2022
// votes are the artifact's statewide total, which no single page produces.
const rowCount = municipalityCatalog.length + 1
await expect(page.getByText(`${rowCount} municípios encontrados`)).toBeVisible()
const totals = page.locator('[data-slot="municipality-slice-totals"]')
await expect(totals).toBeVisible()
await expect(totals).toContainText(
formatElectionNumber(getStatewideFederalTotals(DEFAULT_VOTE_RANK_YEAR).ownVotes),
)
})
})