Files
TREK/client/src/components/SystemNotices/ReleaseNoticeModal.test.tsx
T
Maurice 85b5756e89 fix(client): the audit round over the 4.3.1 release
Ride minutes and late marks follow an edited timetable instead of the routing
that fetched them; a landing day's join band reads as the ride it is rather
than a nine hour drive of no length; the refuel offer is there on the road
out of a terminal or a pick-up desk; the phone's stage map draws the ride;
the share page numbers its stops the way the planner does; the PDF lists a
day the way the day plan lists it, from both shells; a poster-less clip draws
its play badge everywhere, not only in the editor; the hero title has room
for its descenders and the list card keeps its badge clear; the release
notice keeps its close control in reach when it stacks; a link chip's actions
are thumb-sized and its delete asks first; the Google retry line and the
Google-only switch appear only where Google answers.
2026-09-22 13:30:12 +02:00

224 lines
9.4 KiB
TypeScript

// FE-RN-001 to FE-RN-016
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { ReleaseNoticeModal } from './ReleaseNoticeModal'
import type { SystemNoticeDTO } from '../../store/systemNoticeStore'
/** A notice shaped like the release-notes registry entry, with the pieces a test needs to vary. */
function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
return {
id: 'release-notes',
display: 'modal',
severity: 'info',
titleKey: 'rel.headline',
bodyKey: 'rel.intro',
dismissible: true,
desktopOnly: true,
cta: { kind: 'link', labelKey: 'rel.bmc', href: 'https://buymeacoffee.com/mauriceboe' },
secondaryCta: { kind: 'link', labelKey: 'rel.kofi', href: 'https://ko-fi.com/mauriceboe' },
release: {
version: '4.3.0',
eyebrowKey: 'rel.eyebrow',
headlineKey: 'rel.headline',
introKey: 'rel.intro',
featuresLabelKey: 'rel.features.label',
features: [
{ iconName: 'Database', visual: 'places-api', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
{ iconName: 'Route', visual: 'roadtrip', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' },
{ iconName: 'MapPin', visual: 'dawarich', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' },
],
note: {
eyebrowKey: 'rel.note.eyebrow',
titleKey: 'rel.note.title',
bodyKey: 'rel.note.body',
promiseLabelKey: 'rel.promise.label',
promiseLeadKey: 'rel.promise.lead',
promiseTextKey: 'rel.promise.text',
bodyAfterKey: 'rel.note.after',
closingKey: 'rel.note.closing',
},
supportLeadKey: 'rel.support.lead',
supportTextKey: 'rel.support',
},
...overrides,
} as SystemNoticeDTO
}
function renderModal(notice = releaseNotice(), handlers: Partial<{
onDismiss: () => void; onCTA: () => void; onSecondaryCTA: () => void
}> = {}) {
const onDismiss = handlers.onDismiss ?? vi.fn()
const onCTA = handlers.onCTA ?? vi.fn()
const onSecondaryCTA = handlers.onSecondaryCTA ?? vi.fn()
const view = render(
<ReleaseNoticeModal
notice={notice}
visible
onDismiss={onDismiss}
onCTA={onCTA}
onSecondaryCTA={onSecondaryCTA}
/>
)
return { ...view, onDismiss, onCTA, onSecondaryCTA }
}
describe('ReleaseNoticeModal', () => {
it('FE-RN-001: renders nothing for a notice without a release block', () => {
const { container } = renderModal(releaseNotice({ release: undefined }))
expect(container.querySelector('.rn-overlay')).toBeNull()
})
it('FE-RN-002: shows the version as a plain figure, not a translation key', () => {
renderModal()
expect(screen.getByText('4.3.0')).toBeInTheDocument()
})
it('FE-RN-003: renders one card per feature, each with its drawing', () => {
renderModal()
expect(screen.getByText('rel.f1.title')).toBeInTheDocument()
expect(document.querySelectorAll('.rn-feature')).toHaveLength(3)
expect(document.querySelector('.rn-vis-places')).not.toBeNull()
expect(document.querySelector('.rn-vis-road')).not.toBeNull()
expect(document.querySelector('.rn-vis-trail')).not.toBeNull()
})
it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => {
const n = releaseNotice()
// The translation stub echoes the key, so drive the split through a real value.
n.release!.note.bodyKey = 'first paragraph\n\nsecond paragraph'
renderModal(n)
expect(screen.getByText('first paragraph')).toBeInTheDocument()
expect(screen.getByText('second paragraph')).toBeInTheDocument()
})
it('FE-RN-005: omits the foot when the release has neither a footnote nor notes', () => {
renderModal()
expect(document.querySelector('.rn-release-foot')).toBeNull()
})
it('FE-RN-006: shows the footnote and the notes link when both are present', () => {
const n = releaseNotice()
n.release!.footnoteKey = 'rel.footnote'
n.release!.notes = { labelKey: 'rel.notes', href: 'https://example.test/notes' }
renderModal(n)
expect(screen.getByText('rel.footnote')).toBeInTheDocument()
const link = screen.getByRole('link', { name: /rel\.notes/ })
expect(link).toHaveAttribute('href', 'https://example.test/notes')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('FE-RN-007: dismisses from the close button and from the backdrop', async () => {
const user = userEvent.setup()
const onDismiss = vi.fn()
renderModal(releaseNotice(), { onDismiss })
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(onDismiss).toHaveBeenCalledTimes(1)
await user.click(document.querySelector('.rn-overlay')!)
expect(onDismiss).toHaveBeenCalledTimes(2)
})
it('FE-RN-008: a click inside the panel does not dismiss', async () => {
const user = userEvent.setup()
const onDismiss = vi.fn()
renderModal(releaseNotice(), { onDismiss })
await user.click(screen.getByText('rel.note.title'))
expect(onDismiss).not.toHaveBeenCalled()
})
it('FE-RN-009: a non-dismissible notice has no close button and an inert backdrop', async () => {
const user = userEvent.setup()
const onDismiss = vi.fn()
renderModal(releaseNotice({ dismissible: false }), { onDismiss })
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
await user.click(document.querySelector('.rn-overlay')!)
expect(onDismiss).not.toHaveBeenCalled()
})
it('FE-RN-010: wires each support button to its own handler', async () => {
const user = userEvent.setup()
const onCTA = vi.fn()
const onSecondaryCTA = vi.fn()
renderModal(releaseNotice(), { onCTA, onSecondaryCTA })
await user.click(screen.getByRole('button', { name: /rel\.bmc/ }))
expect(onCTA).toHaveBeenCalledTimes(1)
expect(onSecondaryCTA).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: /rel\.kofi/ }))
expect(onSecondaryCTA).toHaveBeenCalledTimes(1)
})
it('FE-RN-011: labels the dialog with the headline and describes it with the intro', () => {
renderModal()
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).toHaveAttribute('aria-labelledby', 'notice-title-release-notes')
expect(dialog).toHaveAttribute('aria-describedby', 'notice-body-release-notes')
expect(document.getElementById('notice-title-release-notes')).toHaveTextContent('rel.headline')
expect(document.getElementById('notice-body-release-notes')).toHaveTextContent('rel.intro')
})
it('FE-RN-012: sets the promise and the support text with a bold lead', () => {
renderModal()
expect(screen.getByText('rel.promise.lead').tagName).toBe('B')
expect(screen.getByText('rel.support.lead').tagName).toBe('B')
expect(screen.getByText('rel.promise.text')).toBeInTheDocument()
})
it('FE-RN-013: the features aside is optional', () => {
renderModal()
expect(document.querySelector('.rn-features-aside')).toBeNull()
const n = releaseNotice()
n.release!.featuresAsideKey = 'rel.features.aside'
renderModal(n)
expect(screen.getByText('rel.features.aside')).toBeInTheDocument()
})
it('FE-RN-014: a card whose drawing this client does not know falls back to its icon', () => {
const n = releaseNotice()
n.release!.features = [
{ iconName: 'Route', visual: 'from-a-newer-server', titleKey: 'rel.x.title', bodyKey: 'rel.x.body' },
{ iconName: 'NoSuchIcon', titleKey: 'rel.y.title', bodyKey: 'rel.y.body' },
]
renderModal(n)
expect(document.querySelectorAll('.rn-vis-icon')).toHaveLength(2)
expect(document.querySelectorAll('.rn-vis-icon svg')).toHaveLength(2)
})
it('FE-RN-015: a wide card spans the row with its drawing beside the text, the others stay upright', () => {
const n = releaseNotice()
n.release!.features = [
...n.release!.features,
{ iconName: 'FolderSync', visual: 'docsync', layout: 'wide', titleKey: 'rel.f4.title', bodyKey: 'rel.f4.body' },
]
renderModal(n)
const cards = document.querySelectorAll('.rn-feature')
expect(cards).toHaveLength(4)
expect(cards[3].classList.contains('rn-feature-wide')).toBe(true)
expect([...cards].slice(0, 3).some(c => c.classList.contains('rn-feature-wide'))).toBe(false)
// The document sync drawing: the wordmark without its API pill, and the five stores.
const wide = cards[3]
expect(wide.querySelector('.rn-vis-docs')).not.toBeNull()
expect(wide.querySelectorAll('.rn-vis-docs-store')).toHaveLength(5)
expect(wide.querySelector('.trek-mark .pill-bg')).toBeNull()
// The places card keeps the pill: that one is about the index.
expect(cards[0].querySelector('.trek-mark .pill-bg')).not.toBeNull()
})
it('FE-RN-016: the close button belongs to the panel, not the note', () => {
// Below 1080px the panel scrolls and the note is its second screen, so the
// stylesheet sticks the button to the panel's own top edge. That only works
// while it is a child of .rn-panel; put it back in the note and it scrolls a
// whole release half out of reach with every CSS assertion still green.
renderModal(releaseNotice())
const close = screen.getByRole('button', { name: 'Close' })
expect(close.parentElement?.classList.contains('rn-panel')).toBe(true)
expect(close.closest('.rn-note')).toBeNull()
})
})