import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js' import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js' import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' import { civilDatePlusDays, dayLabelFor, ptBrMonthNames } from './helpers/agendaPeriodLabels.js' /** * Agenda prologue shared by every journey in this file: opens the agenda * filtered to one município, waits for the window load to land (FullCalendar * renders the grid lazily and re-lays out on mount — the grid first paints * empty and re-renders, replacing its gridcells, when the events land) and * clicks the 14:00 slot. `force` bypasses the C104 all-day lane, which paints * 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. The click targets TODAY's column: the time row is a single * element spanning the whole week, so a centered click lands on the middle * column (Thursday), and C104's end date (today + 1) would fall before the * start on Mondays and Tuesdays. */ const openAgendaSlot = async ({ campaign, page, municipalityId, }: { campaign: { baseURL: string } page: import('@playwright/test').Page /** C165 — omit to open the agenda unfiltered (the triage create). */ municipalityId?: number }) => { const agendaUrl = municipalityId ? `${campaign.baseURL}/campanha/agenda?municipality=${municipalityId}` : `${campaign.baseURL}/campanha/agenda` await page.goto(agendaUrl) 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() await slotLocator.scrollIntoViewIfNeeded() const slotBox = await slotLocator.boundingBox() const todayBox = await page .locator(`[data-date="${formatBahiaCivilDate(new Date())}"]`) .last() .boundingBox() if (!slotBox || !todayBox) throw new Error('agenda grid boxes unavailable') await slotLocator.click({ position: { x: todayBox.x - slotBox.x + todayBox.width / 2, y: slotBox.height / 2, }, force: true, }) } test.describe('Atividades — registro-fundação', () => { test.setTimeout(90_000) test('cria compromisso com demandas vinculadas pelo overlay 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) // C123 — the agenda overlay is the only create surface; /nova is gone. await openAgendaSlot({ campaign, page, municipalityId: municipality.id }) // The central modal (desktop) hosts every section of the old full form. const modal = page.getByRole('dialog', { name: 'Nova atividade' }) await expect(modal).toBeVisible() // C148 — encaixe desktop: largura generosa (não os 28rem que a base // `sm:max-w-md` impunha) e Início/Término lado a lado na mesma linha. const modalBox = await modal.boundingBox() expect(modalBox?.width ?? 0).toBeGreaterThan(600) const pairBoxes = await modal.evaluate((dialog) => { const start = dialog.querySelector('#overlay-startAt')?.getBoundingClientRect() const end = dialog.querySelector('#overlay-endAt')?.getBoundingClientRect() return start && end ? { startY: start.top, endY: end.top, startX: start.left, endX: end.left } : null }) if (!pairBoxes) throw new Error('overlay start/end boxes unavailable') expect(Math.abs(pairBoxes.startY - pairBoxes.endY)).toBeLessThan(4) expect(pairBoxes.endX).toBeGreaterThan(pairBoxes.startX) await page.getByLabel('Título *').fill(activityTitle) // The municipality filter prefills the field; the times come from the slot. await expect(page.getByLabel('Município (opcional)')).toHaveValue(municipality.name) // 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: 'Salvar', exact: true }).click() // Save never navigates: the event lands in the calendar and the URL stays. await expect(page.getByText(activityTitle, { exact: true })).toBeVisible({ timeout: 15_000 }) await expect(page).toHaveURL(new RegExp('/campanha/agenda.*')) // Clicking the event opens the edit overlay; the detail stays reachable // through the overlay's own "Ver detalhes" link. await page.getByText(activityTitle, { exact: true }).first().click() const editModal = page.getByRole('dialog', { name: 'Editar atividade' }) await expect(editModal).toBeVisible() await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 }) await editModal.getByRole('link', { name: 'Ver detalhes' }).click() await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/, { timeout: 30_000, }) 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+/) // `.first()`: transient RSC-pending duplication also copies the combobox // trigger (same strict-mode flake as cd469857; observed on loaded machines). await expect(page.getByLabel('Atividade relacionada').first()).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() // B203: fresh sessions start closed — close the chat only if it opened, // so the mobile drawer never covers the agenda on resize. const openChatClose = page .getByRole('button', { name: 'Fechar', exact: true }) .filter({ visible: true }) if ((await openChatClose.count()) > 0) await openChatClose.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) // C123 — clicking the event opens the edit overlay; the detail is reached // through the overlay's own link. await page.getByText(title, { exact: true }).click() const editModal = page.getByRole('dialog', { name: 'Editar atividade' }) await expect(editModal).toBeVisible() await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 }) await editModal.getByRole('link', { name: 'Ver detalhes' }).click() await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/, { timeout: 30_000, }) 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 openAgendaSlot({ campaign, page, municipalityId: municipality.id }) // The click no longer navigates: the central modal opens with the slot's // date and the split controls (date trigger + inline hour/minute). await expect(page).toHaveURL(new RegExp('/campanha/agenda.*')) const startTrigger = page.getByLabel('Início *') await expect(startTrigger).toBeVisible() const [startLabel, endLabel] = await Promise.all([ startTrigger.textContent(), page.getByRole('button', { name: 'Término', exact: true }).textContent(), ]) expect(startLabel).toMatch(/\d{2}\/\d{2}\/\d{4}/) expect(endLabel).toMatch(/\d{2}\/\d{2}\/\d{4}/) await expect(page.getByLabel('Município (opcional)')).toHaveValue(municipality.name) // C123 — the time selects are INLINE, visible without opening anything. await expect(page.getByLabel('Hora de Início')).toHaveValue('14') await expect(page.getByLabel('Minuto de Início')).toHaveValue('00') await expect(page.getByLabel('Hora de Término')).toHaveValue('14') await expect(page.getByLabel('Minuto de Término')).toHaveValue('30') await page.getByLabel('Título *').fill(title) // The acceptance center: pick a different time through the inline selects // and the saved event lands at the chosen hour. Both fields move together // so the "Término posterior ao Início" validation stays satisfied. await page.getByLabel('Hora de Início').selectOption('15') await expect(page.getByLabel('Hora de Início')).toHaveValue('15') await page.getByLabel('Hora de Término').selectOption('16') // The minute keeps its prefill (30) — only the hour changed. await expect(page.getByLabel('Minuto de Término')).toHaveValue('30') await page.getByRole('button', { name: 'Salvar', exact: true }).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 openAgendaSlot({ campaign, page, municipalityId: municipality.id }) const startTrigger = page.getByLabel('Início *') await expect(startTrigger).toBeVisible() await expect(startTrigger).toHaveText(/\d{2}\/\d{2}\/\d{4}/) // C123 — the time selects are inline; the slot window shows in them. await expect(page.getByLabel('Hora de Início')).toHaveValue('14') await expect(page.getByLabel('Minuto de Início')).toHaveValue('00') // C104 — toggling "Todo o dia" hides the times: both triggers show only // the civil date, the inline selects disappear 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.getByRole('button', { name: 'Término', exact: true })).toHaveText( /^\d{2}\/\d{2}\/\d{4}$/, ) await expect(page.getByLabel('Hora de Início')).toHaveCount(0) 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, always +1 from today: // the target stays inside the picker's visible grid (an inside day, or // day 1 of the next month as an outside day) — no hardcoded dates (C134). // The button label is date-fns pt-BR "PPPP" ("sexta-feira, 14 de agosto // de 2026"): day without leading zero, month in lowercase — the string // name matcher is case-insensitive, the day number is not. const [endYear, endMonth, endDay] = civilDatePlusDays( formatBahiaCivilDate(new Date()), 1, ).split('-') const endDayLabel = `${Number(endDay)} de ${ptBrMonthNames[Number(endMonth) - 1]} de ${endYear}` await page.getByRole('button', { name: 'Término', exact: true }).click() const endPicker = page.locator('[data-slot="popover-content"][data-state="open"]').last() await endPicker.getByRole('button', { name: endDayLabel }).click() await page.keyboard.press('Escape') await expect(page.getByRole('button', { name: 'Término', exact: true })).toHaveText( `${endDay}/${endMonth}/${endYear}`, ) await page.getByLabel('Título *').fill(title) await page.getByRole('button', { name: 'Salvar', exact: true }).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.*')) // C123 — clicking the event opens the edit overlay (no navigation); the // detail page stays reachable through the overlay link and preserves the // all-day choice: date-only labels. await page.getByText(title, { exact: true }).first().click() const editModal = page.getByRole('dialog', { name: 'Editar atividade' }) await expect(editModal).toBeVisible() await expect(editModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 }) await editModal.getByRole('link', { name: 'Ver detalhes' }).click() await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/, { timeout: 30_000, }) 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('edita o compromisso pelo overlay no clique do evento, sem navegar (C123)', async ({ campaign, page, }) => { const { fixtures } = campaign const coordinator = await fixtures.createCampaignUser('coordinator') const municipality = await fixtures.claimMunicipality() const title = fixtures.value('Compromisso editado no overlay') const newLocality = fixtures.value('Nova sede do comitê') await campaign.login(page, coordinator.email!, coordinator.password) await openAgendaSlot({ campaign, page, municipalityId: municipality.id }) // Create first through the same overlay. await expect(page.getByRole('dialog', { name: 'Nova atividade' })).toBeVisible() await page.getByLabel('Título *').fill(title) await page.getByRole('button', { name: 'Salvar', exact: true }).click() await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 }) // Clicking the event opens the EDIT overlay prefilled with the saved // values — no navigation, no /editar page. await page.getByText(title, { exact: true }).first().click() const editModal = page.getByRole('dialog', { name: 'Editar atividade' }) await expect(editModal).toBeVisible() await expect(page).toHaveURL(new RegExp('/campanha/agenda.*')) await expect(editModal.getByLabel('Título *')).toHaveValue(title, { timeout: 30_000 }) await expect(editModal.getByLabel('Município (opcional)')).toHaveValue(municipality.name) await expect(editModal.getByLabel('Hora de Início')).toHaveValue('14') await expect(editModal.getByLabel('Minuto de Término')).toHaveValue('30') // C123 — the edit overlay carries the id the old /editar page lost (C14 // regression): saving must actually persist. await editModal.getByLabel('Hora de Término').selectOption('15') await editModal.getByLabel('Local (opcional)').fill(newLocality) // C151 — the public-event intent is marked in the same save. await editModal.getByLabel('Evento público').click() await editModal.getByRole('button', { name: 'Salvar alterações' }).click() // Save closes the overlay, never navigates; the calendar reflects the edit. await expect(page.getByRole('dialog', { name: 'Editar atividade' })).toBeHidden() await expect(page).toHaveURL(new RegExp('/campanha/agenda.*')) await expect(page.getByText('14:00 – 15:30', { exact: true })).toBeVisible({ timeout: 15_000, }) await expect(page.getByText('14:00 – 14:30', { exact: true })).toHaveCount(0) // The detail page reflects the persisted edit. await page.getByText(title, { exact: true }).first().click() const detailModal = page.getByRole('dialog', { name: 'Editar atividade' }) await expect(detailModal.getByLabel('Título *')).toBeVisible({ timeout: 30_000 }) // C151 — the mark survives the save and comes back on reopening the overlay. await expect(detailModal.getByLabel('Evento público')).toHaveAttribute('aria-checked', 'true') await detailModal.getByRole('link', { name: 'Ver detalhes' }).click() await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+(?:\?tab=overview)?$/, { timeout: 30_000, }) // The location shows in the overview's own
(exact); the chrome // subtitle carries "município · local" as one string. await expect(page.getByText(newLocality, { exact: true })).toBeVisible() }) 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. // B203: fresh sessions start closed — close the chat only if it opened, // or the migrating drawer would cover the page on resize. const openChatClose = page .getByRole('button', { name: 'Fechar', exact: true }) .filter({ visible: true }) if ((await openChatClose.count()) > 0) await openChatClose.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('cria atividade sem município e a triagem mostra o badge "Sem município" (C165)', async ({ campaign, page, }) => { const { fixtures } = campaign const coordinator = await fixtures.createCampaignUser('coordinator') const title = fixtures.value('Compromisso sem município') await campaign.login(page, coordinator.email!, coordinator.password) // Sem filtro de município: o overlay nasce no estado "Sem município". await openAgendaSlot({ campaign, page }) const modal = page.getByRole('dialog', { name: 'Nova atividade' }) await expect(modal).toBeVisible() await page.getByLabel('Título *').fill(title) await expect(page.getByLabel('Município (opcional)')).toHaveValue('Sem município') await expect(page.getByText(/só coordenação e candidato veem/)).toBeVisible() await page.getByRole('button', { name: 'Salvar', exact: true }).click() await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 }) // A triagem: o filtro "Sem município" da omnibox lista o card com o badge. await page.goto(`${campaign.baseURL}/campanha/atividades?tab=todos&unscoped=1`) await expect(page.getByText(title, { exact: true })).toBeVisible({ timeout: 15_000 }) await expect(page.getByText('Sem município', { exact: true }).first()).toBeVisible() // Nativa (criada no Teqo), não importada: sem badge "Do Google". await expect(page.getByText('Do Google', { exact: true })).toHaveCount(0) await expect(page.getByText('Importado automaticamente')).toHaveCount(0) }) }) 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}`) await openAgendaSlot({ campaign, page, municipalityId: municipality.id }) // 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 (opcional)')).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() // C123 — the time selects are INLINE in the sheet row (no nested sheet for // the time); the calendar still opens as a nested bottom sheet with // "Pronto" when the date trigger is tapped. await expect(page.getByLabel('Hora de Início')).toHaveValue('14') await expect(page.getByLabel('Minuto de Início')).toHaveValue('00') await page.getByRole('button', { name: 'Início *', exact: true }).click() const picker = page.getByRole('dialog', { name: /Início —/ }) await expect(picker).toBeVisible() await expect(picker.getByRole('button', { name: 'Pronto' })).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.*')) }) })