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 const mocks = vi.hoisted(() => ({ help: {} as Record })) 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 = {}) { return { page: null as HelpPageData | null, loading: false, pageError: false, query: '', setQuery, navOpen: false, setNavOpen, contentRef: { current: null } as React.RefObject, activeSlug: 'Home', filtered: SECTIONS, ...over, } } function setHelp(over: Record = {}) { mocks.help = buildHelp(over) as unknown as Record 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() 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() 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() 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() 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() 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() 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() 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() 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() // 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() 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() // "Planner" arrives as a 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() const anchor = screen.getByRole('link', { name: 'jump' }) expect(anchor).toHaveAttribute('href', '#trips') expect(anchor).not.toHaveAttribute('target') // An internal link becomes a router — still an , 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() 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() 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() const img = screen.getByRole('img', { name: 'alt only' }) expect(img.className).toContain('border-edge') expect(img.getAttribute('src')).toBeFalsy() }) })