mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 05:56:53 +00:00
278 lines
10 KiB
TypeScript
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('pl-[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',
|
|
'',
|
|
'',
|
|
'',
|
|
'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()
|
|
})
|
|
})
|