489 lines
25 KiB
TypeScript
489 lines
25 KiB
TypeScript
import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js'
|
||
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
|
||
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
|
||
import { dayLabelFor, ptBrMonthNames } from './helpers/agendaPeriodLabels.js'
|
||
|
||
test.describe('Atividades — registro-fundação', () => {
|
||
test.setTimeout(90_000)
|
||
|
||
test('cria compromisso com demandas vinculadas e exibe os sinais no município', async ({
|
||
campaign,
|
||
page,
|
||
}) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator', {
|
||
name: fixtures.value('Coordenadora Geral'),
|
||
})
|
||
const password = coordinator.password
|
||
const municipality = await fixtures.claimMunicipality()
|
||
const activityTitle = fixtures.value('Giro territorial')
|
||
const transportDemand = fixtures.value('Transporte para equipe')
|
||
const materialDemand = fixtures.value('Material de rua')
|
||
|
||
await campaign.login(page, coordinator.email!, password)
|
||
await page.goto(`${campaign.baseURL}/campanha/atividades/nova`)
|
||
|
||
await page.getByLabel('Título *').fill(activityTitle)
|
||
await page.getByLabel('Início *').fill('2026-08-15T10:00')
|
||
await page.getByLabel('Município *').selectOption(String(municipality.id))
|
||
|
||
// C90 — the unified polymorphic selector replaces the old Contact
|
||
// responsável + assessores/liderança fields.
|
||
await expect(page.getByRole('button', { name: /Responsáveis: Nenhum/ })).toBeVisible()
|
||
await expect(page.getByText('Assessores responsáveis')).toHaveCount(0)
|
||
|
||
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
|
||
await page.getByLabel('Título da demanda 1').fill(transportDemand)
|
||
await page.getByLabel('Tipo da demanda 1').selectOption('transporte')
|
||
await page.getByRole('button', { name: 'Adicionar demanda' }).click()
|
||
await page.getByLabel('Título da demanda 2').fill(materialDemand)
|
||
await page.getByLabel('Tipo da demanda 2').selectOption('material')
|
||
|
||
await page.getByRole('button', { name: 'Criar atividade' }).click()
|
||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/)
|
||
await expect(campaignPageChrome(page, activityTitle)).toBeVisible({
|
||
timeout: 15_000,
|
||
})
|
||
await expect(page.getByRole('link', { name: transportDemand })).toBeVisible()
|
||
await expect(page.getByRole('link', { name: materialDemand })).toBeVisible()
|
||
|
||
await page.getByRole('link', { name: 'Adicionar demanda' }).click()
|
||
await expect(page.locator('input[name="activityId"]')).toHaveValue(/\d+/)
|
||
await expect(page.getByLabel('Atividade relacionada')).toContainText(activityTitle)
|
||
await expect(page.getByLabel('Município')).toHaveValue(String(municipality.id))
|
||
|
||
await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=updates`)
|
||
// Unified C87 surface: body + polarity + urgent + adversary signal, no
|
||
// legacy kind/signal-type navigation.
|
||
await expect(page.getByLabel('Texto da atualização')).toBeVisible()
|
||
await expect(page.getByLabel('Polaridade')).toBeVisible()
|
||
await expect(page.getByLabel('Sinalizar como urgente')).toBeVisible()
|
||
await expect(page.getByLabel('Sinalizar adversário')).toBeVisible()
|
||
await expect(page.getByLabel('Tipo', { exact: true })).toHaveCount(0)
|
||
})
|
||
})
|
||
|
||
test.describe('Agenda — calendário operacional', () => {
|
||
test.setTimeout(90_000)
|
||
|
||
test('abre a semana, combina filtros e navega para o detalhe', async ({ campaign, page }) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||
const municipality = await fixtures.claimMunicipality()
|
||
const title = fixtures.value('Comício na agenda')
|
||
const civilDate = formatBahiaCivilDate(new Date())
|
||
const startAt = parseBahiaDateTimeInput(`${civilDate}T10:00`)
|
||
if (!startAt) throw new Error('Falha ao montar horário da fixture de agenda.')
|
||
|
||
const activity = await fixtures.payload.create({
|
||
collection: 'activity',
|
||
data: hookFilledCreateData<'activity'>({
|
||
title,
|
||
tags: ['Comício'],
|
||
status: 'confirmado',
|
||
deputyPresent: true,
|
||
startAt,
|
||
municipality: municipality.id,
|
||
}),
|
||
depth: 0,
|
||
})
|
||
|
||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||
|
||
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
|
||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||
// C95 — the four view-mode buttons moved out of the FullCalendar toolbar
|
||
// into the header selector; the calendar toolbar keeps only prev/today/next.
|
||
await expect(page.getByRole('tab', { name: /semana|mês|lista/i })).toHaveCount(0)
|
||
const viewSelector = page.getByRole('button', { name: 'Modo de visualização: Semana' })
|
||
await expect(viewSelector).toBeVisible()
|
||
|
||
const eventLink = page.getByRole('link', { name: new RegExp(title) })
|
||
const eventBox = await eventLink.boundingBox()
|
||
if (!eventBox) throw new Error('O compromisso não expôs área arrastável.')
|
||
await page.mouse.move(eventBox.x + eventBox.width / 2, eventBox.y + eventBox.height / 2)
|
||
await page.mouse.down()
|
||
await page.mouse.move(eventBox.x + eventBox.width / 2, eventBox.y + eventBox.height / 2 + 60, {
|
||
steps: 20,
|
||
})
|
||
await page.mouse.up()
|
||
await expect(page.getByText('Horário atualizado.')).toBeVisible()
|
||
await expect
|
||
.poll(async () => {
|
||
const persisted = await fixtures.payload.findByID({
|
||
collection: 'activity',
|
||
id: activity.id,
|
||
depth: 0,
|
||
})
|
||
return persisted.startAt
|
||
})
|
||
.not.toBe(startAt)
|
||
|
||
const omniboxInput = page.getByRole('combobox', { name: 'Filtrar agenda' })
|
||
|
||
await omniboxInput.fill(municipality.name!)
|
||
await page.getByRole('option', { name: municipality.name, exact: true }).click()
|
||
|
||
await omniboxInput.fill('Comício')
|
||
await page.getByRole('option', { name: 'Comício', exact: true }).click()
|
||
|
||
await omniboxInput.fill('Deputado')
|
||
await page.getByRole('option', { name: 'Deputado presente', exact: true }).click()
|
||
await expect(page.locator('button[aria-label="Remover Deputado presente"]')).toBeVisible()
|
||
|
||
await expect(page).toHaveURL(
|
||
`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}&deputyPresent=1&tag=Com%C3%ADcio`,
|
||
{ timeout: 20_000 },
|
||
)
|
||
await expect(page.getByText(title, { exact: true })).toBeVisible()
|
||
|
||
// B167: when the viewport shrinks to mobile, a chat that was open on the
|
||
// desktop panel migrates to the open full-screen drawer — which would cover
|
||
// the agenda. Close it first so this mobile-layout step is unobstructed.
|
||
await page
|
||
.getByRole('button', { name: 'Fechar', exact: true })
|
||
.filter({ visible: true })
|
||
.click()
|
||
await page.setViewportSize({ width: 390, height: 844 })
|
||
// C95 — the view-mode control lives in the app top bar on mobile too; the
|
||
// narrow fallback (no `view` param) shows "Dia" after the resize.
|
||
const mobileViewSelector = page
|
||
.getByRole('button', { name: /Modo de visualização/ })
|
||
.filter({ visible: true })
|
||
await expect(mobileViewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia')
|
||
await mobileViewSelector.click()
|
||
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
|
||
await expect(page).toHaveURL(/view=list/)
|
||
await expect(page.getByText(title, { exact: true })).toBeVisible()
|
||
|
||
// C95 — "Limpar" clears the recorte (filters), never the view mode.
|
||
await page.getByRole('button', { name: 'Limpar', exact: true }).click()
|
||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`)
|
||
expect(
|
||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||
).toBe(true)
|
||
|
||
await page.getByText(title, { exact: true }).click()
|
||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/)
|
||
await expect(campaignPageChrome(page, title)).toBeVisible()
|
||
})
|
||
|
||
test('cria compromisso inline no slot clicado, sem sair da agenda', async ({
|
||
campaign,
|
||
page,
|
||
}) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||
const municipality = await fixtures.claimMunicipality()
|
||
const title = fixtures.value('Café inline na agenda')
|
||
|
||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||
|
||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||
// FullCalendar renders the time axis lazily and re-lays out on mount: the
|
||
// grid first paints empty and re-renders (replacing its gridcells) when the
|
||
// events land. Wait for the load to finish before interacting, or the
|
||
// locator's element is replaced mid-action ("Element is not attached").
|
||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||
await expect(slotLocator).toBeVisible()
|
||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||
// all-day day fills over the time grid; real clicks still land on the slot
|
||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||
// check refuses them, so the click is forced on the slot lane itself.
|
||
await slotLocator.scrollIntoViewIfNeeded()
|
||
await slotLocator.click({ force: true })
|
||
|
||
// The click no longer navigates: the inline overlay opens at the slot.
|
||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||
// C97 — the native datetime-local is gone; the trigger buttons show the
|
||
// civil 24h label (slot = 14:00–14:30 Bahia, whatever the browser locale).
|
||
const startTrigger = page.getByLabel('Início *')
|
||
await expect(startTrigger).toBeVisible()
|
||
const [startLabel, endLabel] = await Promise.all([
|
||
startTrigger.textContent(),
|
||
page.getByLabel('Término').textContent(),
|
||
])
|
||
expect(startLabel).toMatch(/\d{2}\/\d{2}\/\d{4} às 14:00/)
|
||
expect(endLabel).toMatch(/\d{2}\/\d{2}\/\d{4} às 14:30/)
|
||
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
|
||
|
||
await page.getByLabel('Título *').fill(title)
|
||
|
||
// The acceptance center: pick a different time through the shadcn picker
|
||
// (calendar keeps the day; the step selects change hour/minute) and the
|
||
// saved event lands at the chosen hour. Both fields move together so the
|
||
// "Término posterior ao Início" validation stays satisfied. After
|
||
// selectOption the focus sits in the native select, where Escape never
|
||
// reaches Radix — re-clicking the selected day moves focus into the
|
||
// calendar, then Escape closes only the picker (never the overlay).
|
||
await startTrigger.click()
|
||
// Scope to the live picker: a just-closed popover lingers in the DOM for
|
||
// its 100 ms close animation, so a bare combobox query can match twice.
|
||
const openPicker = () => page.locator('[data-slot="popover-content"][data-state="open"]').last()
|
||
const hourSelect = openPicker().getByRole('combobox', { name: 'Hora' })
|
||
await expect(hourSelect).toBeVisible()
|
||
await hourSelect.selectOption('15')
|
||
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4} às 15:00/)
|
||
await openPicker().locator('[data-selected-single="true"]').click()
|
||
await page.keyboard.press('Escape')
|
||
await page.getByLabel('Término').click()
|
||
const endHourSelect = openPicker().getByRole('combobox', { name: 'Hora' })
|
||
await expect(endHourSelect).toBeVisible()
|
||
await endHourSelect.selectOption('16')
|
||
// The minute keeps its prefill (30) — only the hour changed.
|
||
await expect(page.getByLabel('Término')).toHaveText(/\d{2}\/\d{2}\/\d{4} às 16:30/)
|
||
await openPicker().locator('[data-selected-single="true"]').click()
|
||
await page.keyboard.press('Escape')
|
||
|
||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||
|
||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||
// The saved event landed at the chosen times (24h chip, no meridiem).
|
||
await expect(page.getByText('15:00 – 16:30', { exact: true })).toBeVisible()
|
||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||
})
|
||
|
||
test('cria compromisso "Todo o dia" no slot, sem horário e com faixa no calendário (C104)', async ({
|
||
campaign,
|
||
page,
|
||
}) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||
const municipality = await fixtures.claimMunicipality()
|
||
const title = fixtures.value('Giro de dia inteiro')
|
||
|
||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||
|
||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||
await expect(slotLocator).toBeVisible()
|
||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||
// all-day day fills over the time grid; real clicks still land on the slot
|
||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||
// check refuses them, so the click is forced on the slot lane itself.
|
||
await slotLocator.scrollIntoViewIfNeeded()
|
||
await slotLocator.click({ force: true })
|
||
|
||
const startTrigger = page.getByLabel('Início *')
|
||
await expect(startTrigger).toBeVisible()
|
||
await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4} às 14:00/)
|
||
|
||
// C104 — toggling "Todo o dia" hides the times: both triggers show only
|
||
// the civil date, and the picker no longer offers Hora/Minuto selects.
|
||
await page.getByLabel('Todo o dia').click()
|
||
await expect(startTrigger).toHaveText(/^\d{2}\/\d{2}\/\d{4}$/)
|
||
await expect(page.getByLabel('Término')).toHaveText(/^\d{2}\/\d{2}\/\d{4}$/)
|
||
await startTrigger.click()
|
||
await expect(
|
||
page.locator('[data-slot="popover-content"][data-state="open"]').last().getByRole('combobox'),
|
||
).toHaveCount(0)
|
||
await page.keyboard.press('Escape')
|
||
|
||
// Multi-day: the end picker lands on a later day of the same month.
|
||
await page.getByLabel('Término').click()
|
||
const endPicker = page.locator('[data-slot="popover-content"][data-state="open"]').last()
|
||
await endPicker.getByRole('button', { name: /17 de agosto de 2026/ }).click()
|
||
await page.keyboard.press('Escape')
|
||
await expect(page.getByLabel('Término')).toHaveText(/17\/08\/2026/)
|
||
|
||
await page.getByLabel('Título *').fill(title)
|
||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||
|
||
// The band lands without a time chip: the all-day event carries no
|
||
// "14:00 – 14:30" text (the timed events render it; allDay:true does not).
|
||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||
await expect(page.getByText(/\d{2}:\d{2} – \d{2}:\d{2}/)).toHaveCount(0)
|
||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||
|
||
// The detail page preserves the all-day choice: date-only labels.
|
||
await page.getByText(title, { exact: true }).first().click()
|
||
await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/)
|
||
await expect(page.getByText(/^\d{2}\/\d{2}\/\d{4}$/).first()).toBeVisible()
|
||
await expect(page.getByText(/\d{2}\/\d{2}\/\d{4} às /)).toHaveCount(0)
|
||
})
|
||
|
||
test('"Mais detalhes" pré-preenche o formulário completo com o rascunho', async ({
|
||
campaign,
|
||
page,
|
||
}) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||
const municipality = await fixtures.claimMunicipality()
|
||
const title = fixtures.value('Detalhes inline da agenda')
|
||
|
||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||
|
||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||
// FullCalendar renders the time axis lazily and re-lays out on mount: the
|
||
// grid first paints empty and re-renders (replacing its gridcells) when the
|
||
// events land. Wait for the load to finish before interacting, or the
|
||
// locator's element is replaced mid-action ("Element is not attached").
|
||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||
await expect(slotLocator).toBeVisible()
|
||
// C104 — with the all-day lane enabled, the v7 classic theme paints the
|
||
// all-day day fills over the time grid; real clicks still land on the slot
|
||
// (FullCalendar resolves by data-time), but Playwright's actionability
|
||
// check refuses them, so the click is forced on the slot lane itself.
|
||
await slotLocator.scrollIntoViewIfNeeded()
|
||
await slotLocator.click({ force: true })
|
||
|
||
await expect(page.getByLabel('Título *')).toBeVisible()
|
||
await page.getByLabel('Título *').fill(title)
|
||
await page.getByRole('link', { name: 'Mais detalhes' }).click()
|
||
|
||
await expect(page).toHaveURL(/\/campanha\/atividades\/nova\?startAt=/)
|
||
await expect(page.getByLabel('Título *')).toHaveValue(title)
|
||
await expect(page.getByLabel('Município *')).toHaveValue(String(municipality.id))
|
||
const [startValue, endValue] = await Promise.all([
|
||
page.getByLabel('Início *').inputValue(),
|
||
page.getByLabel('Término').inputValue(),
|
||
])
|
||
const [startAt, endAt] = [startValue, endValue].map(parseBahiaDateTimeInput)
|
||
expect(startAt).not.toBeNull()
|
||
expect(endAt).not.toBeNull()
|
||
if (!startAt || !endAt) throw new Error('O formulário não recebeu o intervalo do slot.')
|
||
expect(new Date(endAt).getTime() - new Date(startAt).getTime()).toBe(1_800_000)
|
||
})
|
||
|
||
test('C95 — seletor de vista no header troca o modo, persiste e vence o resize', async ({
|
||
campaign,
|
||
page,
|
||
}) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||
|
||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||
|
||
const viewSelector = page
|
||
.getByRole('button', { name: /Modo de visualização/ })
|
||
.filter({ visible: true })
|
||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Semana')
|
||
|
||
// Desktop: switch to month; the URL gains `view=month` and the calendar
|
||
// actually renders the month grid (not just the selector label). The
|
||
// FullCalendar v7 classic theme hashes its view classes, so the stable
|
||
// signal is the "Hoje" button's aria-label ("Este mês") plus a grid.
|
||
// (`.first()`: FC keeps stale view grids in the DOM while transitioning.)
|
||
await viewSelector.click()
|
||
await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click()
|
||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`)
|
||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
|
||
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
|
||
await expect(page.getByRole('grid').first()).toBeVisible()
|
||
|
||
// Persists across a reload (screen state lives beside the filter, on the URL).
|
||
await page.reload()
|
||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=month`)
|
||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
|
||
await expect(page.getByRole('button', { name: 'Este mês' })).toBeVisible({ timeout: 15_000 })
|
||
|
||
// The explicit choice wins over the responsive narrow fallback: shrinking
|
||
// the viewport must NOT push the calendar back to day/week.
|
||
// B167: a chat that opened by itself on the desktop panel (RRP settle)
|
||
// migrates to the full-screen mobile drawer and covers the page — close it
|
||
// first so the top bar selector stays reachable in the a11y tree.
|
||
await page
|
||
.getByRole('button', { name: 'Fechar', exact: true })
|
||
.filter({ visible: true })
|
||
.click()
|
||
await page.setViewportSize({ width: 390, height: 844 })
|
||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Mês')
|
||
// C101 — the mobile calendar drops the FullCalendar toolbar: the period
|
||
// context lives in the app header instead (the current month's name).
|
||
await expect(page.getByRole('button', { name: 'Este mês' })).toHaveCount(0, {
|
||
timeout: 15_000,
|
||
})
|
||
const todayCivil = formatBahiaCivilDate(new Date())
|
||
const monthLabel = ptBrMonthNames[Number(todayCivil.slice(5, 7)) - 1] ?? ''
|
||
await expect(campaignPageChrome(page, monthLabel)).toBeVisible()
|
||
|
||
// The mobile top bar selector still switches modes. The list view drops
|
||
// the calendar grid and the header goes back to the plain "Agenda" (the
|
||
// dates live in the list body).
|
||
await viewSelector.click()
|
||
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
|
||
await expect(page).toHaveURL(`${campaign.baseURL}/campanha/agenda?view=list`)
|
||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Lista')
|
||
await expect(page.getByRole('button', { name: 'Este mês' })).toHaveCount(0, {
|
||
timeout: 15_000,
|
||
})
|
||
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible()
|
||
await expect(page.getByRole('grid')).toHaveCount(0, { timeout: 15_000 })
|
||
|
||
// A navigation that drops the `view` param returns the calendar to the
|
||
// responsive default instead of keeping an orphan view the selector no
|
||
// longer claims (sidebar link / back-forward). Still on the 390px
|
||
// viewport, so the narrow default is day (grid back, no toolbar: the
|
||
// header shows today's period instead of a "Hoje" button).
|
||
await page.goto(`${campaign.baseURL}/campanha/agenda`)
|
||
await expect(viewSelector).toHaveAttribute('aria-label', 'Modo de visualização: Dia')
|
||
const todayLabel = dayLabelFor(formatBahiaCivilDate(new Date()))
|
||
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
|
||
await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toHaveCount(0)
|
||
await expect(page.getByRole('grid').first()).toBeVisible()
|
||
})
|
||
})
|
||
|
||
test.describe('Atividades — agenda mobile (C103)', () => {
|
||
test.setTimeout(90_000)
|
||
test.use({ viewport: { width: 390, height: 844 } })
|
||
|
||
test('cria compromisso inline pelo sheet do topo, rodapé fixo e seletor em bottom sheet', async ({
|
||
campaign,
|
||
page,
|
||
}) => {
|
||
const { fixtures } = campaign
|
||
const coordinator = await fixtures.createCampaignUser('coordinator')
|
||
const municipality = await fixtures.claimMunicipality()
|
||
const title = fixtures.value('Café mobile na agenda')
|
||
|
||
await campaign.login(page, coordinator.email!, coordinator.password)
|
||
await page.goto(`${campaign.baseURL}/campanha/agenda?municipality=${municipality.id}`)
|
||
|
||
// FullCalendar renders the grid lazily and re-lays out on mount: wait for
|
||
// the load to finish before interacting (same pattern as the desktop test).
|
||
await expect(page.getByText('Carregando compromissos…')).toHaveCount(0, { timeout: 15_000 })
|
||
const slotLocator = page.locator('[data-time="14:00:00"]:visible').last()
|
||
await expect(slotLocator).toBeVisible()
|
||
// Click the time slot itself — C104's all-day lane sits above the timed
|
||
// grid, so the old day-cell nth() offset no longer lands on the slot.
|
||
// `force` bypasses the timed-row content overlay; the FullCalendar grid
|
||
// still receives the click and resolves the 14:00 date from the position.
|
||
await slotLocator.scrollIntoViewIfNeeded()
|
||
await slotLocator.click({ force: true })
|
||
|
||
// C103 — the sheet opens from the TOP, hugging the usable viewport edge.
|
||
const sheet = page.getByRole('dialog', { name: /Nova atividade/ })
|
||
await expect(sheet).toBeVisible()
|
||
const sheetBox = await sheet.boundingBox()
|
||
if (!sheetBox) throw new Error('O sheet não expôs bounding box.')
|
||
expect(sheetBox.y).toBeLessThanOrEqual(1)
|
||
|
||
// Label-less form: the placeholder gives context, the sr-only label keeps
|
||
// the field addressable and accessible.
|
||
await page.getByLabel('Título *').fill(title)
|
||
await expect(page.getByLabel('Município *')).toHaveValue(municipality.name)
|
||
|
||
// The footer is FIXED: "Salvar" is visible without scrolling on a small
|
||
// viewport — the acceptance center of C103.
|
||
await expect(page.getByRole('button', { name: 'Salvar' })).toBeVisible()
|
||
|
||
// Date/time opens as a nested bottom sheet (no popover) with "Pronto".
|
||
await page.getByLabel('Início *').click()
|
||
const picker = page.getByRole('dialog', { name: /Início —/ })
|
||
await expect(picker).toBeVisible()
|
||
await expect(picker.getByRole('combobox', { name: 'Hora' })).toBeVisible()
|
||
await picker.getByRole('button', { name: 'Pronto' }).click()
|
||
await expect(picker).toBeHidden()
|
||
|
||
await page.getByRole('button', { name: 'Salvar' }).click()
|
||
await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 })
|
||
await expect(page.getByText('14:00 – 14:30', { exact: true })).toBeVisible()
|
||
await expect(page).toHaveURL(new RegExp('/campanha/agenda.*'))
|
||
})
|
||
})
|