Files
TREK/client/src/pages/HelpPage.test.tsx
T
Maurice ba70ad28b8 fix(client): spacing and alignment follow the reading direction
Arabic runs the app right to left, but margins, paddings, insets, text
alignment, borders and corner radii were written for the left side, so
icons sat against the wrong edge of their labels, indents and badges
pointed the wrong way and amount columns aligned left. They now use the
logical forms (ms-, ps-, start-, text-start, marginInlineStart,
insetInlineEnd and their CSS equivalents), which are the very same sides
in a left to right language. Geometry stays physical on purpose: maps,
positions measured from the pointer or a bounding box, time axes,
sliders, transforms and print output.

Amounts drawn as several spans keep their order in a right to left row,
the time picker keeps hours before minutes, and two switches whose thumb
slid out of its track in Arabic now move the right way.

lint:rtl counts what is still physical per file against a baseline that
may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI
runs it, so new code reaches for the logical forms.
2026-10-07 22:33:58 +00:00

278 lines
10 KiB
TypeScript

import React from 'react'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '../../tests/helpers/render'
import type { HelpNavSection, HelpPageData } from '../api/client'
import HelpPage from './HelpPage'
// FE-PAGE-HELPUI-001 to FE-PAGE-HELPUI-014
//
// HelpPage is a wiring container over useHelp (covered in help/useHelp.test.ts):
// the hook is mocked here so every render branch — loading, error, markdown,
// search, mobile drawer — can be driven directly.
type HelpState = ReturnType<typeof buildHelp>
const mocks = vi.hoisted(() => ({ help: {} as Record<string, unknown> }))
vi.mock('./help/useHelp', () => ({ useHelp: () => mocks.help }))
vi.mock('../components/Layout/Navbar', () => ({
default: () => React.createElement('nav', { 'data-testid': 'navbar' }),
}))
const setQuery = vi.fn((_v: string) => {})
const setNavOpen = vi.fn((_v: boolean) => {})
const SECTIONS: HelpNavSection[] = [
{ title: 'Getting started', pages: [{ slug: 'Home', title: 'Home' }, { slug: 'Install', title: 'Install' }] },
{ title: '', pages: [{ slug: 'FAQ', title: 'FAQ' }] },
]
function buildHelp(over: Record<string, unknown> = {}) {
return {
page: null as HelpPageData | null,
loading: false,
pageError: false,
query: '',
setQuery,
navOpen: false,
setNavOpen,
contentRef: { current: null } as React.RefObject<HTMLDivElement | null>,
activeSlug: 'Home',
filtered: SECTIONS,
...over,
}
}
function setHelp(over: Record<string, unknown> = {}) {
mocks.help = buildHelp(over) as unknown as Record<string, unknown>
return mocks.help as unknown as HelpState
}
function page(markdown: string): HelpPageData {
return { slug: 'Home', title: 'Home', markdown }
}
beforeEach(() => {
setQuery.mockClear()
setNavOpen.mockClear()
setHelp()
})
describe('HelpPage', () => {
it('FE-PAGE-HELPUI-001: renders the desktop sidebar with section titles and page links', () => {
setHelp()
render(<HelpPage />)
expect(screen.getByText('Help & Docs')).toBeInTheDocument()
expect(screen.getByText('Getting started')).toBeInTheDocument()
// The section with an empty title renders no heading, only its pages.
expect(screen.getByRole('link', { name: 'FAQ' })).toHaveAttribute('href', '/help/FAQ')
expect(screen.getByRole('link', { name: 'Install' })).toHaveAttribute('href', '/help/Install')
})
it('FE-PAGE-HELPUI-002: marks the active page and indents the inactive ones', () => {
setHelp({ activeSlug: 'Install' })
render(<HelpPage />)
const active = screen.getByRole('link', { name: 'Install' })
expect(active.className).toContain('text-accent')
expect(active.querySelector('span')?.className).toBe('')
const inactive = screen.getByRole('link', { name: 'FAQ' })
expect(inactive.className).toContain('text-content-secondary')
expect(inactive.querySelector('span')?.className).toContain('ps-[18px]')
})
it('FE-PAGE-HELPUI-003: typing in the search box reports the query upwards', () => {
setHelp({ query: 'ins' })
render(<HelpPage />)
const input = screen.getByPlaceholderText('Search docs…')
expect(input).toHaveValue('ins')
fireEvent.change(input, { target: { value: 'inst' } })
expect(setQuery).toHaveBeenCalledWith('inst')
})
it('FE-PAGE-HELPUI-004: an empty result set shows the no-results hint instead of links', () => {
setHelp({ filtered: [], query: 'zzz' })
render(<HelpPage />)
expect(screen.getByText('No matching pages.')).toBeInTheDocument()
expect(screen.queryByRole('link')).toBeNull()
})
it('FE-PAGE-HELPUI-005: shows a spinner while the page loads', () => {
setHelp({ loading: true, page: page('# Ignored') })
render(<HelpPage />)
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Ignored' })).toBeNull()
})
it('FE-PAGE-HELPUI-006: a failed fetch shows the error block, not the stale page', () => {
setHelp({ pageError: true, page: page('# Stale') })
render(<HelpPage />)
expect(screen.getByText("Couldn't load this page")).toBeInTheDocument()
expect(screen.getByText(/fetched from the TREK wiki/)).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Stale' })).toBeNull()
})
it('FE-PAGE-HELPUI-007: renders nothing in the content area when no page is loaded', () => {
setHelp({ page: null })
render(<HelpPage />)
expect(document.querySelector('article.wiki-prose')).toBeNull()
expect(document.querySelector('.animate-spin')).toBeNull()
})
it('FE-PAGE-HELPUI-008: the mobile drawer opens from the contents button', () => {
setHelp()
render(<HelpPage />)
expect(screen.getAllByText('Help & Docs')).toHaveLength(1)
fireEvent.click(screen.getByRole('button', { name: 'Contents' }))
expect(setNavOpen).toHaveBeenCalledWith(true)
})
it('FE-PAGE-HELPUI-009: the open drawer closes on the backdrop and on its close button, but not on itself', () => {
setHelp({ navOpen: true })
render(<HelpPage />)
// Nav is rendered twice while the drawer is open (sidebar + drawer).
expect(screen.getAllByText('Help & Docs')).toHaveLength(2)
expect(screen.getAllByRole('link', { name: 'FAQ' })).toHaveLength(2)
const drawer = document.querySelector('.fixed.inset-0.z-\\[120\\]') as HTMLElement
const panel = drawer.querySelector('.shadow-xl') as HTMLElement
fireEvent.click(panel)
expect(setNavOpen).not.toHaveBeenCalled()
// The X button sits in the drawer header, before the nav.
const closeBtn = panel.querySelector('button') as HTMLButtonElement
fireEvent.click(closeBtn)
expect(setNavOpen).toHaveBeenCalledWith(false)
setNavOpen.mockClear()
fireEvent.click(drawer)
expect(setNavOpen).toHaveBeenCalledWith(false)
})
it('FE-PAGE-HELPUI-010: renders headings with GitHub-compatible anchor ids', () => {
setHelp({
page: page(
['# Getting Started!', '', '## Trips & Days', '', '### Sub Heading', '', '#### Deep Heading'].join('\n'),
),
})
render(<HelpPage />)
expect(screen.getByRole('heading', { level: 1 }).id).toBe('getting-started')
// "&" is punctuation and drops out; the surrounding spaces collapse into one hyphen.
expect(screen.getByRole('heading', { level: 2 }).id).toBe('trips-days')
// The sidebar section titles are h3s too, so match this one by its text.
expect(screen.getByRole('heading', { name: 'Sub Heading' }).id).toBe('sub-heading')
expect(screen.getByRole('heading', { level: 4 }).id).toBe('deep-heading')
})
it('FE-PAGE-HELPUI-011: a heading built from inline markup drops the non-text children from its id', () => {
setHelp({ page: page('## Trip **Planner** 2') })
render(<HelpPage />)
// "Planner" arrives as a <strong> element, so only the plain-text parts feed the slug.
expect(screen.getByRole('heading', { level: 2 }).id).toBe('trip-2')
expect(screen.getByText('Planner').tagName).toBe('STRONG')
})
it('FE-PAGE-HELPUI-012: routes internal links through the SPA router and opens external ones in a tab', () => {
setHelp({
page: page(
[
'[jump](#trips)',
'',
'[planner](/trips/1)',
'',
'[github](https://github.com/liketrek/TREK)',
].join('\n'),
),
})
render(<HelpPage />)
const anchor = screen.getByRole('link', { name: 'jump' })
expect(anchor).toHaveAttribute('href', '#trips')
expect(anchor).not.toHaveAttribute('target')
// An internal link becomes a router <Link> — still an <a href>, but without target.
expect(screen.getByRole('link', { name: 'planner' })).toHaveAttribute('href', '/trips/1')
const external = screen.getByRole('link', { name: 'github' })
expect(external).toHaveAttribute('target', '_blank')
expect(external).toHaveAttribute('rel', 'noopener noreferrer')
})
it('FE-PAGE-HELPUI-013: styles lists, quotes, images, rules and both code flavours', () => {
setHelp({
page: page(
[
'Intro text.',
'',
'- first',
'- second',
'',
'1. one',
'2. two',
'',
'> quoted line',
'',
'![screenshot](/img/a.png)',
'',
'Use `npm run dev` to start.',
'',
'```bash',
'npm test',
'```',
'',
'---',
].join('\n'),
),
})
render(<HelpPage />)
expect(screen.getByText('Intro text.').tagName).toBe('P')
expect(document.querySelector('ul.list-disc')).toBeInTheDocument()
expect(document.querySelector('ol.list-decimal')).toBeInTheDocument()
expect(screen.getByText('first').tagName).toBe('LI')
expect(screen.getByText('quoted line').closest('blockquote')).toBeInTheDocument()
expect(document.querySelector('hr')).toBeInTheDocument()
const img = screen.getByRole('img', { name: 'screenshot' })
expect(img.getAttribute('src')).toBe('/img/a.png')
expect(img).toHaveAttribute('loading', 'lazy')
// Inline code keeps the pill styling; a fenced block keeps its language class.
expect(screen.getByText('npm run dev').className).toContain('bg-surface-tertiary')
const block = screen.getByText('npm test')
expect(block.className).toContain('language-bash')
expect(block.closest('pre')?.className).toContain('overflow-x-auto')
})
it('FE-PAGE-HELPUI-014: renders GFM tables inside a horizontally scrollable wrapper', () => {
setHelp({
page: page(['| Key | Meaning |', '| --- | --- |', '| esc | close |'].join('\n')),
})
render(<HelpPage />)
expect(screen.getByRole('columnheader', { name: 'Key' })).toBeInTheDocument()
expect(screen.getByRole('cell', { name: 'close' })).toBeInTheDocument()
expect(screen.getByRole('table').parentElement?.className).toContain('overflow-x-auto')
})
it('FE-PAGE-HELPUI-015: an image without a target still renders with its alt text', () => {
setHelp({ page: page('![alt only]()') })
render(<HelpPage />)
const img = screen.getByRole('img', { name: 'alt only' })
expect(img.className).toContain('border-edge')
expect(img.getAttribute('src')).toBeFalsy()
})
})