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) => (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), ) }) })