fix(participation): repair process timeline
This commit is contained in:
@@ -15,3 +15,86 @@ test.describe('Frontend', () => {
|
||||
await expect(heading).toHaveText('Payload Website Template')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Participation process timeline', () => {
|
||||
for (const viewport of [
|
||||
{ width: 1440, height: 900 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 390, height: 844 },
|
||||
]) {
|
||||
test(`keeps all steps reachable at ${viewport.width}px`, async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
await page.goto('http://localhost:3000/teilnahme#schritte')
|
||||
|
||||
const timeline = page.locator('#schritte')
|
||||
const eligibility = page.locator('#voraussetzungen')
|
||||
const steps = timeline.locator('[data-testid^="timeline-step-"]')
|
||||
|
||||
await expect(timeline).toBeVisible()
|
||||
if (viewport.width < 768) {
|
||||
await expect(timeline.locator('h3')).toHaveCount(4)
|
||||
} else {
|
||||
await expect(steps).toHaveCount(4)
|
||||
const nextButton = timeline.getByRole('button', { name: 'Nächster Schritt', exact: true })
|
||||
const visibleTrackRatio = async (index: number) => steps.nth(index).evaluate((card) => {
|
||||
const track = card.closest('[data-testid="timeline-track"]')
|
||||
if (!track) return 0
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
const visibleWidth = Math.max(
|
||||
0,
|
||||
Math.min(cardRect.right, trackRect.right) - Math.max(cardRect.left, trackRect.left),
|
||||
)
|
||||
return visibleWidth / cardRect.width
|
||||
})
|
||||
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await expect.poll(() => visibleTrackRatio(index)).toBeGreaterThan(0.55)
|
||||
if (index < 3) {
|
||||
await nextButton.click()
|
||||
await expect(timeline.locator('[data-testid="timeline-progress-label"]')).toHaveText(
|
||||
`${String(index + 2).padStart(2, '0')} / 04`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const cardsOverlap = await steps.evaluateAll((cards) => cards.some((card, index) => {
|
||||
const nextCard = cards[index + 1]
|
||||
return nextCard ? card.getBoundingClientRect().right > nextCard.getBoundingClientRect().left : false
|
||||
}))
|
||||
expect(cardsOverlap).toBe(false)
|
||||
expect(await timeline.evaluate((element) => element.getBoundingClientRect().height)).toBeLessThan(900)
|
||||
}
|
||||
|
||||
const sectionGap = await page.evaluate(() => {
|
||||
const timelineElement = document.querySelector('#schritte')
|
||||
const eligibilityElement = document.querySelector('#voraussetzungen')
|
||||
if (!timelineElement || !eligibilityElement) return Number.POSITIVE_INFINITY
|
||||
return eligibilityElement.getBoundingClientRect().top - timelineElement.getBoundingClientRect().bottom
|
||||
})
|
||||
expect(Math.abs(sectionGap)).toBeLessThanOrEqual(2)
|
||||
})
|
||||
}
|
||||
|
||||
test('uses a fully readable non-animated reduced-motion interaction', async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await page.setViewportSize({ width: 1024, height: 768 })
|
||||
await page.goto('http://localhost:3000/teilnahme#schritte')
|
||||
|
||||
const timeline = page.locator('#schritte')
|
||||
await timeline.getByRole('button', { name: 'Nächster Schritt', exact: true }).click()
|
||||
|
||||
await expect(timeline.locator('[data-testid="timeline-progress-label"]')).toHaveText('02 / 04')
|
||||
await expect.poll(() => timeline.locator('[data-testid="timeline-step-2"]').evaluate((card) => {
|
||||
const track = card.closest('[data-testid="timeline-track"]')
|
||||
if (!track) return 0
|
||||
const cardRect = card.getBoundingClientRect()
|
||||
const trackRect = track.getBoundingClientRect()
|
||||
return Math.max(0, Math.min(cardRect.right, trackRect.right) - Math.max(cardRect.left, trackRect.left)) / cardRect.width
|
||||
})).toBeGreaterThan(0.55)
|
||||
expect(await timeline.locator('[data-testid="timeline-track"]').evaluate((element) => (
|
||||
getComputedStyle(element).scrollBehavior
|
||||
))).toBe('auto')
|
||||
})
|
||||
})
|
||||
|
||||
91
tests/int/participation-timeline.int.spec.tsx
Normal file
91
tests/int/participation-timeline.int.spec.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WegZurAuszeichnungSection from '@/spa/components/WegZurAuszeichnungSection'
|
||||
import {
|
||||
getActiveTimelineStep,
|
||||
getTimelineScrollBehavior,
|
||||
getTimelineStepScrollLeft,
|
||||
} from '@/spa/components/ui/process-timeline'
|
||||
|
||||
vi.mock('@/spa/hooks/useIsMobile', () => ({
|
||||
useIsMobile: () => false,
|
||||
}))
|
||||
|
||||
let reduceMotion = false
|
||||
|
||||
describe('participation timeline', () => {
|
||||
beforeEach(() => {
|
||||
reduceMotion = false
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
addEventListener: vi.fn(),
|
||||
matches: query === '(prefers-reduced-motion: reduce)' && reduceMotion,
|
||||
media: query,
|
||||
removeEventListener: vi.fn(),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('maps the real horizontal scroll range to deterministic steps', () => {
|
||||
expect(getActiveTimelineStep(0, 600, 4)).toBe(0)
|
||||
expect(getActiveTimelineStep(200, 600, 4)).toBe(1)
|
||||
expect(getActiveTimelineStep(400, 600, 4)).toBe(2)
|
||||
expect(getActiveTimelineStep(600, 600, 4)).toBe(3)
|
||||
expect(getTimelineStepScrollLeft(2, 600, 4)).toBe(400)
|
||||
expect(getTimelineStepScrollLeft(99, 600, 4)).toBe(600)
|
||||
})
|
||||
|
||||
it('updates progress from native track scroll state', () => {
|
||||
render(<WegZurAuszeichnungSection />)
|
||||
const track = screen.getByTestId('timeline-track')
|
||||
Object.defineProperties(track, {
|
||||
clientWidth: { configurable: true, value: 400 },
|
||||
scrollLeft: { configurable: true, value: 400, writable: true },
|
||||
scrollWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
|
||||
fireEvent.scroll(track)
|
||||
|
||||
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('03 / 04')
|
||||
expect(screen.getByTestId('timeline-progress').style.width).toBe('75%')
|
||||
expect(screen.getAllByTestId(/timeline-step-/)).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('uses immediate scrolling in reduced-motion mode while keeping controls operable', () => {
|
||||
reduceMotion = true
|
||||
render(<WegZurAuszeichnungSection />)
|
||||
const track = screen.getByTestId('timeline-track')
|
||||
const scrollTo = vi.fn()
|
||||
Object.defineProperties(track, {
|
||||
clientWidth: { configurable: true, value: 400 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
scrollWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Nächster Schritt' }))
|
||||
|
||||
expect(getTimelineScrollBehavior(true)).toBe('auto')
|
||||
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'auto', left: 200 })
|
||||
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('02 / 04')
|
||||
})
|
||||
|
||||
it('supports direct keyboard navigation across the track', () => {
|
||||
render(<WegZurAuszeichnungSection />)
|
||||
const track = screen.getByTestId('timeline-track')
|
||||
const scrollTo = vi.fn()
|
||||
Object.defineProperties(track, {
|
||||
clientWidth: { configurable: true, value: 400 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
scrollWidth: { configurable: true, value: 1000 },
|
||||
})
|
||||
|
||||
fireEvent.keyDown(track, { key: 'End' })
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ behavior: 'smooth', left: 600 })
|
||||
expect(screen.getByTestId('timeline-progress-label').textContent).toContain('04 / 04')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user