teqo-backup/tests/e2e/campaignAgendaMobile.e2e.spec.ts
Francisco Solla 9cb8fef98d feat(C110+): preview do arrasto — indicadores ancorados ao frame, gradiente deduplicado, e2e do direction prev
- scene posicionado único (.activity-agenda-swipe-scene) envolve frame + dots/bars:
  percentuais resolvem contra o box do frame, não o content (chevron/label inclusos)
- width: 100% no scene (fix do /simplify: align-items flex-end do prev colapsava a 1px)
- --swipe-frame-columns deduplica o gradiente de 14.285% (2 call sites)
- unit test estrutural do preview (month/day/week/list, chevron por direção)
- e2e novo: direction prev com frame visível (pina o fix do colapso)
2026-08-10 20:38:04 -03:00

534 lines
22 KiB
TypeScript

import type { CDPSession, Locator, Page } from '@playwright/test'
import { formatBahiaCivilDate, parseBahiaDateTimeInput } from '../../src/lib/campaignTime.js'
import { hookFilledCreateData } from '../../src/utilities/hookFilledData.js'
import {
campaignPageChrome,
expect,
test,
type CampaignE2EFixture,
} from './fixtures/campaignE2EFixtures.js'
import {
civilDatePlusDays,
dayLabelFor,
ptBrMonthNames,
weekdayOf,
} from './helpers/agendaPeriodLabels.js'
const mobileHeaderTitle = (page: Page): Locator =>
page.locator('[data-slot="campaign-mobile-top-bar"] [data-slot="campaign-page-chrome-title"]')
/**
* C95 — troca a vista para Mês pelo seletor do header (menu no popover).
* Compartilhado pelos testes que navegam o mês com swipe ou teclado.
*/
const openMonthView = async (page: Page) => {
const viewSelector = page
.getByRole('button', { name: /Modo de visualização/ })
.filter({ visible: true })
await viewSelector.click()
await page.getByRole('menuitemradio', { name: 'Mês', exact: true }).click()
}
/**
* Horizontal swipe via the CDP touch pipeline (the same input path a real
* device takes — the only way to exercise touch gestures in Playwright).
* The swipe stays at the vertical middle of the calendar, clear of fixture
* events (which sit at 10:00).
*
* The 5 moves are paced (~25ms apart, ~125ms total): a burst without gaps is
* classified by Chromium's gesture recognizer as a FLICK, and a dominant
* horizontal flick is a browser navigation gesture (back/forward history),
* not a page drag — intermittently it took the "back to today" swipe to
* `/campanha`. Paced, it stays a drag the agenda consumes via its
* non-passive touchmove preventDefault, while still finishing far below
* FullCalendar's 650ms long-press (which would start a reschedule).
*
* C110 — `inspectAfterMove` pauses the gesture mid-drag (after the 12px
* claim) so a test can observe the live preview before the release decides.
*/
const dispatchTouchDrag = async (
cdp: CDPSession,
box: { x: number; y: number; width: number; height: number },
fromRatio: number,
toRatio: number,
{
moveDelayMs = 25,
inspectAfterMove,
}: { moveDelayMs?: number; inspectAfterMove?: () => Promise<void> } = {},
) => {
const y = Math.round(box.y + box.height * 0.5)
const x0 = Math.round(box.x + box.width * fromRatio)
const x1 = Math.round(box.x + box.width * toRatio)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: x0, y }] })
for (let i = 1; i <= 5; i += 1) {
const x = Math.round(x0 + ((x1 - x0) * i) / 5)
await cdp.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x, y }] })
await new Promise((resolve) => setTimeout(resolve, moveDelayMs))
if (i === 2) await inspectAfterMove?.()
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
const touchSwipe = async (
cdp: CDPSession,
box: { x: number; y: number; width: number; height: number },
fromRatio: number,
toRatio: number,
) => dispatchTouchDrag(cdp, box, fromRatio, toRatio)
const agendaSwipePreview = (page: Page) => page.locator('.activity-agenda-swipe-preview')
const tapAt = async (cdp: CDPSession, x: number, y: number) => {
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y }] })
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
}
/**
* Seeds agenda activities and logs the coordinator in. `rows` maps a civil
* date + hour to an activity title; `fixtures.value` keeps titles/slugs
* unique per test run — literal titles would collide on the unique slug
* between consecutive tests of this spec.
*/
const seedAgendaActivities = async (
campaign: CampaignE2EFixture,
page: Page,
rows: Array<[civilDate: string, hour: string, title: string]>,
): Promise<void> => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator')
const municipality = await fixtures.claimMunicipality()
for (const [civilDate, hour, rawTitle] of rows) {
const startAt = parseBahiaDateTimeInput(`${civilDate}T${hour}`)
if (!startAt) throw new Error('Falha ao montar horário da fixture de agenda.')
await fixtures.payload.create({
collection: 'activity',
data: hookFilledCreateData<'activity'>({
title: fixtures.value(rawTitle),
tags: ['Comício'],
status: 'confirmado',
startAt,
municipality: municipality.id,
}),
depth: 0,
})
}
await campaign.login(page, coordinator.email!, coordinator.password)
}
const seedTodayActivity = async (campaign: CampaignE2EFixture, page: Page): Promise<string> => {
const civilDate = formatBahiaCivilDate(new Date())
await seedAgendaActivities(campaign, page, [[civilDate, '10:00', 'Compromisso mobile nativo']])
return civilDate
}
test.describe('C101 — agenda mobile com cara de app nativo', () => {
test.setTimeout(90_000)
test('toolbar some, o título mostra o período e tap volta ao hoje', async ({
campaign,
page,
}) => {
const civilDate = await seedTodayActivity(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const cdp = await page.context().newCDPSession(page)
// C101 — o título do FullCalendar e os botões "< > Hoje" somem no mobile:
// o header do app mostra o contexto do período no lugar.
const todayLabel = dayLabelFor(civilDate)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('button', { name: 'Hoje', exact: true })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Dia Anterior' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Próximo Dia' })).toHaveCount(0)
// C101-ux — o tap no título tem affordance visual: o glyph (aria-hidden)
// sinaliza o controle sem depender do tooltip hover-only.
await expect(mobileHeaderTitle(page).locator('svg')).toBeVisible()
// Navegação por arrasto: esquerda → dia seguinte; direita → volta. O
// swipe direito começa longe da borda esquerda (x=0.4): um gesto que
// nasça na borda é do Chrome (histórico), não da agenda.
// `.first()`: FC mantém grids de vista antigos no DOM durante transições.
const agendaBox = await page.locator('.activity-agenda').first().boundingBox()
if (!agendaBox) throw new Error('Calendário não expôs área para o gesto de navegação.')
const nextCivil = civilDatePlusDays(civilDate, 1)
await touchSwipe(cdp, agendaBox, 0.8, 0.2)
await expect(campaignPageChrome(page, dayLabelFor(nextCivil))).toBeVisible({ timeout: 15_000 })
await touchSwipe(cdp, agendaBox, 0.4, 0.85)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
// O swipe não abre o inline create (dia tem uma coluna só: o hit do FC
// nunca sai do slot inicial e o dateClick precisa ser engolido).
await expect(page.getByRole('dialog')).toHaveCount(0)
// Tap no título do header volta ao hoje (o "Hoje" da toolbar não existe
// mais no mobile). Sai primeiro do hoje para o tap ter efeito.
await touchSwipe(cdp, agendaBox, 0.8, 0.2)
await expect(campaignPageChrome(page, dayLabelFor(nextCivil))).toBeVisible({
timeout: 15_000,
})
const titleBox = await mobileHeaderTitle(page).boundingBox()
if (!titleBox) throw new Error('Título do header não expôs área de toque.')
await tapAt(
cdp,
Math.round(titleBox.x + titleBox.width / 2),
Math.round(titleBox.y + titleBox.height / 2),
)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
// Contexto por vista: mês → nome do mês; swipe no mês → mês seguinte;
// lista → "Agenda".
await openMonthView(page)
const monthIndex = Number(civilDate.slice(5, 7)) - 1
await expect(campaignPageChrome(page, ptBrMonthNames[monthIndex] ?? '')).toBeVisible({
timeout: 15_000,
})
await touchSwipe(cdp, agendaBox, 0.8, 0.2)
await expect(campaignPageChrome(page, ptBrMonthNames[(monthIndex + 1) % 12] ?? '')).toBeVisible(
{ timeout: 15_000 },
)
const viewSelector = page
.getByRole('button', { name: /Modo de visualização/ })
.filter({ visible: true })
await viewSelector.click()
await page.getByRole('menuitemradio', { name: 'Lista', exact: true }).click()
await expect(campaignPageChrome(page, 'Agenda')).toBeVisible({ timeout: 15_000 })
// O strip edge-to-edge não pode criar overflow horizontal.
expect(
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
).toBe(true)
})
test('visão de dia abre na hora atual e o cabeçalho do dia fica fixo ao rolar', async ({
campaign,
page,
}) => {
const civilDate = await seedTodayActivity(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const dayLabel = dayLabelFor(civilDate)
await expect(campaignPageChrome(page, dayLabel)).toBeVisible({ timeout: 15_000 })
// C101 — o grid de dia vira um scroller próprio (height fixa no mobile):
// abre ancorado em 08:00 ("hoje é fixa em 08:00")...
// `.first()`: FC mantém grids de vista antigos no DOM durante transições.
await expect
.poll(
() =>
page.evaluate(() => {
const scroller = [
...document.querySelector('.activity-agenda')!.querySelectorAll('*'),
].find((el) => {
const style = getComputedStyle(el)
return (
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight &&
el.querySelector('[role="rowheader"]')
)
})
return scroller ? scroller.scrollTop : null
}),
{ timeout: 15_000 },
)
.toBeGreaterThan(0)
// ...e o cabeçalho "domingo 9" permanece no topo enquanto os horários
// rolam.
const colHeaderText = `${weekdayOf(civilDate)} ${Number(civilDate.slice(8, 10))}`
const headerTop = () =>
page.evaluate((text) => {
const cell = [
...document.querySelector('.activity-agenda')!.querySelectorAll('[role="columnheader"]'),
].find((el) => el.textContent?.includes(text))
return cell ? Math.round(cell.getBoundingClientRect().top) : null
}, colHeaderText)
const headerBefore = await headerTop()
expect(headerBefore).not.toBeNull()
const scrolledTo = await page.evaluate(() => {
const scroller = [...document.querySelector('.activity-agenda')!.querySelectorAll('*')].find(
(el) => {
const style = getComputedStyle(el)
return (
(style.overflowY === 'auto' || style.overflowY === 'scroll') &&
el.scrollHeight > el.clientHeight &&
el.querySelector('[role="rowheader"]')
)
},
)
if (!scroller) return false
scroller.scrollTop = 300
return scroller.scrollTop
})
expect(scrolledTo).toBeGreaterThan(0)
expect(await headerTop()).toBe(headerBefore)
})
test('o filtro vira strip edge-to-edge sem rótulo, colado na barra e no calendário', async ({
campaign,
page,
}) => {
const civilDate = await seedTodayActivity(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
// Espera o layout mobile assentar (B167): o painel de chat renderiza
// "desktop com chat aberto" no primeiro frame e rouba largura do
// conteúdo até a medição do viewport — o título do período no header só
// existe no layout mobile de verdade.
await expect(campaignPageChrome(page, dayLabelFor(civilDate))).toBeVisible({
timeout: 15_000,
})
// O rótulo some visualmente (sr-only: 0 de altura renderizada) mas
// continua sendo o nome acessível do campo.
const labelHeight = await page.evaluate(() => {
const label = document.querySelector('.activity-agenda-filter-strip label')
return label ? Math.round(label.getBoundingClientRect().height) : null
})
expect(labelHeight).not.toBeNull()
expect(labelHeight!).toBeLessThanOrEqual(1)
await expect(page.getByRole('combobox', { name: 'Filtrar agenda' })).toBeVisible()
// Edge-to-edge, encostada na barra do app e no limite superior do
// calendário, sticky enquanto navega. Tolerância de 1px para o
// arredondamento de subpixel (precedente actionGridGeometry).
const geometry = await page.evaluate(() => {
const strip = document.querySelector('.activity-agenda-filter-strip')
const topBar = document.querySelector('[data-slot="campaign-mobile-top-bar"]')
const calendar = document.querySelector('.activity-agenda-shell')
if (!strip || !topBar || !calendar) return null
const stripRect = strip.getBoundingClientRect()
return {
stripTop: Math.round(stripRect.top),
topBarBottom: Math.round(topBar.getBoundingClientRect().bottom),
stripBottom: Math.round(stripRect.bottom),
calendarTop: Math.round(calendar.getBoundingClientRect().top),
stripWidth: Math.round(stripRect.width),
windowWidth: window.innerWidth,
sticky: getComputedStyle(strip).position,
}
})
expect(geometry).not.toBeNull()
expect(Math.abs(geometry!.stripTop - geometry!.topBarBottom)).toBeLessThanOrEqual(1)
expect(Math.abs(geometry!.stripBottom - geometry!.calendarTop)).toBeLessThanOrEqual(1)
expect(geometry!.stripWidth).toBe(geometry!.windowWidth)
expect(geometry!.sticky).toBe('sticky')
})
test('teclado navega períodos com ArrowLeft/ArrowRight (região focável no mobile)', async ({
campaign,
page,
}) => {
const civilDate = await seedTodayActivity(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
// Espera a medição mobile assentar (o label do período só existe com o
// chrome mobile) antes de focar a região de teclado.
const todayLabel = dayLabelFor(civilDate)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
// C101-ux — o container do calendário é a região de teclado do mobile
// (a toolbar — e seus controles de teclado — sumiu): focável, com label
// próprio, mesmo quando o período não tem eventos focáveis.
const region = page.locator('.activity-agenda').first()
await expect(region).toHaveAttribute('role', 'group')
await expect(region).toHaveAttribute('aria-label', /setas mudam o período/)
await region.focus()
await expect(region).toBeFocused()
// ArrowRight → período seguinte; ArrowLeft → período anterior (mesma
// semântica do swipe: direita avança).
const nextCivil = civilDatePlusDays(civilDate, 1)
await page.keyboard.press('ArrowRight')
await expect(campaignPageChrome(page, dayLabelFor(nextCivil))).toBeVisible({
timeout: 15_000,
})
await page.keyboard.press('ArrowLeft')
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
// O foco permanece na região após navegar (a restauração do foco quando
// um nó do grid é destruído é pinada no unit do hook; aqui o contrato é
// que o teclado continua na região depois da troca).
await expect(region).toBeFocused()
// Mês: setas navegam meses conforme a vista (mesmo contrato de foco).
await openMonthView(page)
const monthIndex = Number(civilDate.slice(5, 7)) - 1
await expect(campaignPageChrome(page, ptBrMonthNames[monthIndex] ?? '')).toBeVisible({
timeout: 15_000,
})
await region.focus()
await page.keyboard.press('ArrowRight')
await expect(campaignPageChrome(page, ptBrMonthNames[(monthIndex + 1) % 12] ?? '')).toBeVisible(
{ timeout: 15_000 },
)
await expect(region).toBeFocused()
// Setas não criam inline create (o dateClick não dispara por teclado).
await expect(page.getByRole('dialog')).toHaveCount(0)
})
})
test.describe('C110 — feedback visual do arrasto (reveal do adjacente + commit/snap-back)', () => {
test.setTimeout(90_000)
const seedTodayAndTomorrowActivities = async (
campaign: CampaignE2EFixture,
page: Page,
): Promise<{ today: string; tomorrow: string }> => {
const today = formatBahiaCivilDate(new Date())
const tomorrow = civilDatePlusDays(today, 1)
await seedAgendaActivities(campaign, page, [
[today, '10:00', 'Compromisso mobile hoje'],
[tomorrow, '11:00', 'Compromisso mobile amanhã'],
])
return { today, tomorrow }
}
test('o grid segue o dedo, o período adjacente é revelado e o soltar decide (commit)', async ({
campaign,
page,
}) => {
const { today, tomorrow } = await seedTodayAndTomorrowActivities(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const todayLabel = dayLabelFor(today)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
const cdp = await page.context().newCDPSession(page)
const agendaBox = await page.locator('.activity-agenda').first().boundingBox()
if (!agendaBox) throw new Error('Calendário não expôs área para o gesto de navegação.')
// A meio do gesto (após o claim de 12px): o grid acompanhou o dedo e o
// painel do período adjacente está revelado na borda — chevron, rótulo do
// período seguinte e os eventos do adjacente entrando conforme carregam.
await dispatchTouchDrag(cdp, agendaBox, 0.8, 0.3, {
moveDelayMs: 40,
inspectAfterMove: async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('svg.activity-agenda-swipe-chevron')).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(tomorrow),
)
await expect
.poll(() => preview.locator('.activity-agenda-swipe-event').count(), {
timeout: 15_000,
})
.toBeGreaterThan(0)
// O grid deslocado: o transform vivo acompanhou o dedo.
const transform = await page.evaluate(
() => getComputedStyle(document.querySelector('.activity-agenda')!).transform,
)
expect(transform).not.toBe('none')
},
})
// Soltou acima do limiar: o período adjacente assume a tela e o preview some.
await expect(campaignPageChrome(page, dayLabelFor(tomorrow))).toBeVisible({
timeout: 15_000,
})
await expect(agendaSwipePreview(page)).toHaveCount(0)
// O commit não abre o inline create.
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('o arrasto para o período anterior também revela o quadro do grid (prev, C110+)', async ({
campaign,
page,
}) => {
const { today } = await seedTodayAndTomorrowActivities(campaign, page)
const yesterday = civilDatePlusDays(today, -1)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const todayLabel = dayLabelFor(today)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
const cdp = await page.context().newCDPSession(page)
const agendaBox = await page.locator('.activity-agenda').first().boundingBox()
if (!agendaBox) throw new Error('Calendário não expôs área para o gesto de navegação.')
// C110+ — pinha o direction `prev`: o content do preview alinha no fim
// (flex-end) e o scene precisa de largura explícita para não colapsar.
await dispatchTouchDrag(cdp, agendaBox, 0.3, 0.8, {
moveDelayMs: 40,
inspectAfterMove: async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-frame')).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(yesterday),
)
},
})
await expect(campaignPageChrome(page, dayLabelFor(yesterday))).toBeVisible({
timeout: 15_000,
})
await expect(agendaSwipePreview(page)).toHaveCount(0)
})
test('soltar abaixo do limiar volta ao período atual (snap-back) sem navegar', async ({
campaign,
page,
}) => {
const { today, tomorrow } = await seedTodayAndTomorrowActivities(campaign, page)
await page.setViewportSize({ width: 390, height: 844 })
await page.goto(`${campaign.baseURL}/campanha/agenda`)
const todayLabel = dayLabelFor(today)
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
const cdp = await page.context().newCDPSession(page)
const agendaBox = await page.locator('.activity-agenda').first().boundingBox()
if (!agendaBox) throw new Error('Calendário não expôs área para o gesto de navegação.')
// Arrasto de ~47px (0.9 → 0.78 em 390px): cruza o claim de 12px — o
// preview abre — mas o soltar fica abaixo dos 48px do limiar.
await dispatchTouchDrag(cdp, agendaBox, 0.9, 0.78, {
moveDelayMs: 40,
inspectAfterMove: async () => {
const preview = agendaSwipePreview(page)
await expect(preview).toBeVisible()
await expect(preview.locator('.activity-agenda-swipe-label')).toHaveText(
dayLabelFor(tomorrow),
)
},
})
// Snap-back: o título não muda e o grid voltou ao repouso.
await expect(campaignPageChrome(page, todayLabel)).toBeVisible({ timeout: 15_000 })
await expect(agendaSwipePreview(page)).toHaveCount(0)
await expect
.poll(
() =>
page.evaluate(
() => getComputedStyle(document.querySelector('.activity-agenda')!).transform,
),
{ timeout: 5_000 },
)
.toBe('none')
// Nenhum inline create após o snap-back.
await expect(page.getByRole('dialog')).toHaveCount(0)
})
})