mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 17:43:04 +00:00
B184 — Municípios mobile: lista sem moldura (omnibox sticky, X de limpar no input, salvar filtro no header, cards edge-to-edge)
1029 lines
45 KiB
TypeScript
1029 lines
45 KiB
TypeScript
import type { Page } from '@playwright/test'
|
||
|
||
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '../../src/lib/campaignConsentKeys.js'
|
||
import {
|
||
ensureLeasedConsent,
|
||
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
|
||
} from '../helpers/testDatabaseLease.js'
|
||
import {
|
||
campaignPageChrome,
|
||
checkRadixWhenHydrated,
|
||
expect,
|
||
expectPostResponse,
|
||
test,
|
||
} from './fixtures/campaignE2EFixtures.js'
|
||
import {
|
||
assertThreeColumnActionGrid,
|
||
collectActionBoundingBoxes,
|
||
} from './helpers/actionGridGeometry.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)
|
||
}
|
||
|
||
/**
|
||
* 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 coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenadora Geral'),
|
||
})
|
||
const password = coordinator.password
|
||
const advisor = await fixtures.createCampaignUser('advisor', {
|
||
name: fixtures.value('Assessor Regional'),
|
||
})
|
||
const municipality = await fixtures.claimMunicipality()
|
||
|
||
await campaign.login(page, coordinator.email!, password)
|
||
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')).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`)
|
||
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 coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador Combobox'),
|
||
})
|
||
const password = coordinator.password
|
||
const advisor = await fixtures.createCampaignUser('advisor', {
|
||
name: fixtures.value('Assessora Combobox'),
|
||
})
|
||
const municipality = await fixtures.claimMunicipality()
|
||
|
||
await campaign.login(page, coordinator.email!, password)
|
||
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 coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador Inline'),
|
||
})
|
||
const password = coordinator.password
|
||
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 campaign.login(page, coordinator.email!, password)
|
||
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 coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador Dobradinhas'),
|
||
})
|
||
const password = coordinator.password
|
||
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 campaign.login(page, coordinator.email!, password)
|
||
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 coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador Filtros B176'),
|
||
})
|
||
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 campaign.login(page, coordinator.email!, coordinator.password)
|
||
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 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.
|
||
await removeChip(`Dobradinha: ${deputyName} (BR176)`).click()
|
||
await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toHaveCount(0)
|
||
|
||
// Partido by acronym → the same single municipality (its only dobradinha).
|
||
await omnibox.fill('BR176')
|
||
await listbox.getByRole('option', { name: 'BR176', exact: true }).click()
|
||
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)
|
||
|
||
// Liderança by contact name → municipality B only.
|
||
await omnibox.fill(leadership.contactName)
|
||
await listbox.getByRole('option', { name: new RegExp(`^${leadership.contactName}$`) }).click()
|
||
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,
|
||
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.login(page, advisor.email!, advisor.password)
|
||
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 coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador Tendência'),
|
||
})
|
||
const password = coordinator.password
|
||
const municipality = await fixtures.claimMunicipality()
|
||
const note = fixtures.value('Vereador confirmou apoio local')
|
||
|
||
await campaign.login(page, coordinator.email!, password)
|
||
await showAllMunicipalityColumns(page, campaign.baseURL)
|
||
await page.goto(
|
||
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
|
||
)
|
||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||
|
||
const trendButton = page.getByRole('button', {
|
||
name: new RegExp(`^Editar tendência política em ${municipality.name}`),
|
||
})
|
||
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()
|
||
await trendButton.click()
|
||
const reopened = page.locator('[data-slot="popover-content"]')
|
||
await expect(reopened.getByLabel('Tendência', { exact: true })).toHaveValue('favoravel')
|
||
await expect(reopened.getByLabel('Justificativa')).toHaveValue(note)
|
||
})
|
||
|
||
test('coordinator registers an adversary-signal update from the list freshness cell', async ({
|
||
campaign,
|
||
page,
|
||
}) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador Sinal'),
|
||
})
|
||
const password = coordinator.password
|
||
const municipality = await fixtures.claimMunicipality()
|
||
|
||
await campaign.login(page, coordinator.email!, password)
|
||
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'),
|
||
phone: 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`)
|
||
await expect(page.getByRole('link', { name: contact.name })).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}`)
|
||
await page.getByLabel('Média', { exact: true }).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/contatos 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/contatos`)
|
||
await expect(campaignPageChrome(page, '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/contatos`)
|
||
|
||
const supporterName = fixtures.value('Apoiador Liderança')
|
||
const supporterPhone = fixtures.phone()
|
||
await page.getByLabel('Nome *').fill(supporterName)
|
||
await page.getByLabel('Celular *').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, page }) => {
|
||
const { fixtures } = campaign
|
||
const municipality = await fixtures.claimMunicipality()
|
||
|
||
const advisor = await fixtures.createCampaignUser('advisor', {
|
||
name: fixtures.value('Assessor Demandas'),
|
||
})
|
||
const password = advisor.password
|
||
await campaign.payload.update({
|
||
collection: 'municipality',
|
||
id: municipality.id,
|
||
data: { advisors: [advisor.id] },
|
||
depth: 0,
|
||
})
|
||
fixtures.touchMunicipality(municipality.id)
|
||
|
||
const demandTitle = fixtures.value('Carro de som')
|
||
|
||
await campaign.login(page, advisor.email!, password)
|
||
await page.goto(`${campaign.baseURL}/campanha/demandas/nova`)
|
||
await page.getByLabel('O que você precisa?').fill(demandTitle)
|
||
await page.getByLabel('Município').selectOption({ label: municipality.name })
|
||
await page.getByLabel('Detalhe a necessidade').fill('Precisamos para a caminhada de sábado.')
|
||
await page.getByRole('button', { name: 'Abrir demanda' }).click()
|
||
await expect(campaignPageChrome(page, demandTitle)).toBeVisible()
|
||
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 coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador Mobile'),
|
||
})
|
||
const password = coordinator.password
|
||
const municipality = await fixtures.claimMunicipality()
|
||
|
||
await campaign.login(page, coordinator.email!, password)
|
||
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)
|
||
|
||
await card.getByRole('button', { name: 'Editar tendência política' }).click()
|
||
// 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.
|
||
// The tap goes on a field LABEL inside the metrics grid on purpose — the
|
||
// first version of this card positioned each grid cell instead of each
|
||
// control, which lifted the labels and their padding above the overlay and
|
||
// made half the card a tap that neither edited nor navigated.
|
||
// `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 the label's point, the
|
||
// overlay receives it (as a user's finger would), and the card navigates.
|
||
await card.getByText('Tendência', { exact: true }).click({ force: true })
|
||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`)
|
||
await expect(campaignPageChrome(page, municipality.name)).toBeVisible()
|
||
})
|
||
})
|
||
|
||
/**
|
||
* 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,
|
||
}) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenadora B184'),
|
||
})
|
||
const password = coordinator.password
|
||
|
||
await campaign.login(page, coordinator.email!, password)
|
||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||
|
||
// 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()
|
||
})
|
||
})
|
||
|
||
/**
|
||
* 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 coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador FAB'),
|
||
})
|
||
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 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)
|
||
|
||
await fab.click()
|
||
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 }) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador B136'),
|
||
})
|
||
|
||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||
|
||
await page.getByRole('button', { name: 'Ações rápidas' }).click()
|
||
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
|
||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenador B126'),
|
||
})
|
||
|
||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||
await page.goto(`${campaign.baseURL}/campanha/municipios`)
|
||
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
|
||
|
||
await page.getByRole('button', { name: 'Ações rápidas' }).click()
|
||
const overlay = page.locator('#CampaignQuickActionsOverlay')
|
||
await expect(overlay).toBeVisible()
|
||
|
||
const registerSignal = overlay.getByRole('link', { name: 'Registrar atualização' })
|
||
await expect(registerSignal).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
|
||
await expect(overlay.getByRole('link', { name: 'Registrar atualização' })).toBeHidden()
|
||
|
||
// Overlay scopes search suggest — list page has no E11 SuggestionsPanel.
|
||
await expect(overlay.getByRole('region', { name: 'Sugestões' })).toBeVisible()
|
||
})
|
||
})
|