mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
1992 lines
88 KiB
TypeScript
1992 lines
88 KiB
TypeScript
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),
|
||
)
|
||
})
|
||
})
|