import React from 'react' import { describe, it, expect, beforeEach } from 'vitest' import { http, HttpResponse } from 'msw' import { render, screen, fireEvent, waitFor, within, act } from '../../../tests/helpers/render' import { server } from '../../../tests/helpers/msw/server' import { useHelpStore } from '../../store/helpStore' import HelpPanel from './HelpPanel' import HelpButton from './HelpButton' import HelpAnchor from './HelpAnchor' /** * The dialog end to end against the real registry and the English catalogue: * open it from the navbar button, read the overview, open a guide, search, * close it again. The wiki index and search are served by msw. */ const initial = useHelpStore.getState() beforeEach(() => { useHelpStore.setState(initial, true) server.use( http.get('/api/help/index', () => HttpResponse.json({ sections: [{ title: 'Planning', pages: [{ slug: 'Creating-a-Trip', title: 'Creating a Trip' }, { slug: 'My-Trips-Dashboard', title: 'My Trips Dashboard' }] }] }), ), http.get('/api/help/search', ({ request }) => { const q = new URL(request.url).searchParams.get('q') ?? '' if (q === 'zzqx') return HttpResponse.json({ hits: [] }) return HttpResponse.json({ hits: [{ slug: 'Calendar-Feeds', title: 'Calendar Feeds', section: 'Planning', anchor: 'turning-a-feed-on', heading: 'Turning a feed on', snippet: 'Click Enable calendar subscription…', score: 9 }] }) }), ) }) function mount(): void { render( <> , ) } describe('HelpPanel', () => { it('stays out of the DOM until opened, then shows the screen overview', () => { mount() expect(screen.queryByRole('dialog')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) const dialog = screen.getByRole('dialog', { name: 'Help' }) expect(dialog).toBeInTheDocument() expect(screen.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeInTheDocument() // Guides appear in the navigation and as cards. expect(screen.getAllByRole('button', { name: /Create a trip/ }).length).toBeGreaterThanOrEqual(2) expect(screen.getByRole('button', { name: 'Help for this screen' })).toHaveAttribute('aria-pressed', 'true') }) it('opens a guide with its steps, result and tips, and goes back to the overview', async () => { mount() fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) fireEvent.click(screen.getAllByRole('button', { name: /Create a trip/ })[0]) expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument() expect(screen.getByText(/Click Create New Trip\./)).toBeInTheDocument() expect(screen.getByText(/shown in the boarding pass at the top/)).toBeInTheDocument() expect(screen.getByText(/Dates can be changed later/)).toBeInTheDocument() // A picture for every step. expect(screen.getAllByRole('button', { name: /Step \d of/ })).toHaveLength(5) // Doc link resolves its title from the index once that has loaded. await waitFor(() => expect(screen.getAllByText('Creating a Trip').length).toBeGreaterThan(0)) // Related guide opens in place (the last match is the one under "Related"; the nav lists it too). const related = screen.getAllByRole('button', { name: 'Set a cover photo' }) fireEvent.click(related[related.length - 1]) expect(screen.getByRole('heading', { level: 1, name: 'Set a cover photo' })).toBeInTheDocument() // The overview entry in the nav returns home. fireEvent.click(screen.getByRole('button', { name: 'Dashboard' })) expect(screen.getByRole('heading', { level: 1, name: 'Dashboard' })).toBeInTheDocument() }) it('enlarges a step picture in the lightbox and closes it on click', () => { mount() fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) fireEvent.click(screen.getAllByRole('button', { name: /Edit a trip/ })[0]) fireEvent.click(screen.getAllByRole('button', { name: /Step 1 of/ })[0]) const box = screen.getByRole('dialog', { name: /Step 1 of/ }) expect(box.querySelector('img')).toHaveAttribute('src', '/help-media/edit-trip/step-1.webp') fireEvent.click(box) expect(screen.queryByRole('dialog', { name: /Step 1 of/ })).toBeNull() }) it('searches guides locally and the docs on the server', async () => { mount() fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) const box = screen.getByRole('searchbox') fireEvent.change(box, { target: { value: 'calendar' } }) // Local guide match right away… expect(screen.getAllByRole('button', { name: /Subscribe to all trips in your calendar/ }).length).toBeGreaterThan(0) // …and the doc hit once the server answers. await waitFor(() => expect(screen.getByText('Calendar Feeds · Turning a feed on')).toBeInTheDocument()) expect(screen.getByRole('link', { name: /Calendar Feeds · Turning a feed on/ })).toHaveAttribute('href', '/help/Calendar-Feeds#turning-a-feed-on') }) it('finds a guide by its words in any order, stop words and accents aside, title first', () => { mount() fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) const box = screen.getByRole('searchbox') fireEvent.change(box, { target: { value: 'how do I create trip' } }) // The result cards sit in the main pane's "Guides" list, the nav lists guides too. const first = () => screen.getByRole('main').querySelector('ul li button')! expect(first()).toHaveAccessibleName(/Create a trip/) fireEvent.change(box, { target: { value: 'Trip Créate' } }) expect(first()).toHaveAccessibleName(/Create a trip/) }) it('searches from inside a guide too, and returns to the guide once the box is cleared', () => { mount() fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) fireEvent.click(screen.getAllByRole('button', { name: /Create a trip/ })[0]) expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument() const box = screen.getByRole('searchbox') fireEvent.change(box, { target: { value: 'calendar' } }) expect(screen.queryByRole('heading', { level: 1, name: 'Create a trip' })).toBeNull() expect(screen.getAllByRole('button', { name: /Subscribe to all trips in your calendar/ }).length).toBeGreaterThan(0) fireEvent.change(box, { target: { value: '' } }) expect(screen.getByRole('heading', { level: 1, name: 'Create a trip' })).toBeInTheDocument() }) it('lists the sub-screens of a screen open on its overview and in the nav, and offers the way into the screen', () => { render( <> , ) fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) // In view straight away: each sub-screen holds guides of its own, and a fold hid them. fireEvent.click(screen.getByRole('button', { name: /Appearance.*guides/ })) expect(useHelpStore.getState().browseId).toBe('settings-appearance') expect(screen.getByRole('heading', { level: 1, name: 'Appearance' })).toBeInTheDocument() // On a tab, the nav lists its siblings, open, with the shown one marked, and the header leads into the tab. // (The overview entry is also named "Appearance"; the sibling rows are the ones under "Sub-screens".) const nav = screen.getByRole('navigation', { name: 'Help' }) expect(within(nav).getByRole('button', { name: /Sub-screens/ })).toHaveAttribute('aria-expanded', 'true') const rows = within(nav).getAllByRole('button', { name: 'Appearance' }) expect(rows.some(b => b.getAttribute('aria-current') === 'page')).toBe(true) expect(within(nav).getByRole('button', { name: 'Map' })).toBeInTheDocument() expect(screen.getByRole('link', { name: 'Go to Appearance' })).toHaveAttribute('href', '/settings?tab=appearance') }) it('links each docs page once, however many of its sections the screen names', () => { render( <> , ) fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) const hrefs = screen.getAllByRole('link').map(a => a.getAttribute('href') ?? '').filter(h => h.startsWith('/help/')) const pages = hrefs.map(h => h.split('#')[0]) expect(pages.length).toBeGreaterThan(0) expect(new Set(pages).size).toBe(pages.length) }) it('says so when nothing matches', async () => { mount() fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzqx' } }) await waitFor(() => expect(screen.getByText('Nothing found for “zzqx”.')).toBeInTheDocument()) }) it('closes on the close button, on the backdrop and on Escape', async () => { mount() const open = () => fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) open() fireEvent.click(screen.getByRole('button', { name: 'Close help' })) expect(useHelpStore.getState().open).toBe(false) await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) open() fireEvent.click(screen.getByRole('dialog').parentElement!) expect(useHelpStore.getState().open).toBe(false) await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) open() fireEvent.keyDown(window, { key: 'Escape' }) expect(useHelpStore.getState().open).toBe(false) }) it('leaves Escape to a modal the reader opened while following a guide', () => { mount() fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) const modal = document.createElement('div') modal.className = 'trek-modal-backdrop' document.body.appendChild(modal) fireEvent.keyDown(window, { key: 'Escape' }) expect(useHelpStore.getState().open).toBe(true) modal.remove() }) it('shows the empty state on a screen without help', () => { render( <> , ) fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) expect(screen.getAllByText('No guide for this screen yet.').length).toBeGreaterThan(0) }) it('clears the screen when its anchor unmounts', () => { const { unmount } = render() expect(useHelpStore.getState().contextId).toBe('dashboard') act(() => unmount()) expect(useHelpStore.getState().contextId).toBeNull() }) it('folds sub-screens under their screen in the switcher, one group open at a time', () => { render( <> , ) fireEvent.click(screen.getByRole('button', { name: 'Help for this screen' })) // The trigger names the trail, and the list opens with the journal's group unfolded. const trigger = screen.getByRole('button', { name: /Journey › Journal/ }) fireEvent.click(trigger) const list = screen.getByRole('listbox', { name: 'Screens' }) expect(list).toBeInTheDocument() expect(screen.getByRole('option', { name: /Journal/ })).toHaveAttribute('aria-selected', 'true') expect(screen.getByRole('option', { name: /Studio/ })).toBeInTheDocument() // Folding the group hides the sub-screens; the top-level screens stay. The // toggle is looked up inside its own row: every screen with sub-screens has // one, and the trip has as many as the plan has columns, panels and tabs. const journeyFold = () => within(screen.getByRole('option', { name: /^Journey/ })).getByRole('button', { name: /sub-screens/ }) fireEvent.click(journeyFold()) expect(screen.queryByRole('option', { name: /Studio/ })).toBeNull() expect(screen.getByRole('option', { name: /Atlas/ })).toBeInTheDocument() // Picking a sub-screen browses to it; the breadcrumb on the overview shows the trail. fireEvent.click(journeyFold()) fireEvent.click(screen.getByRole('option', { name: /Studio/ }).querySelector('button')!) expect(useHelpStore.getState().browseId).toBe('journey-studio') expect(screen.getByRole('heading', { level: 1, name: 'Studio' })).toBeInTheDocument() }) it('does not clear a screen another anchor has already taken over', () => { const { unmount } = render() act(() => useHelpStore.getState().setContext('settings')) act(() => unmount()) expect(useHelpStore.getState().contextId).toBe('settings') }) })