1import { jest } from '@jest/globals'; 2import { render, screen } from '@testing-library/react'; 3import GithubSlugger from 'github-slugger'; 4import mockRouter from 'next-router-mock'; 5import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; 6import { u as node } from 'unist-builder'; 7import { visit } from 'unist-util-visit'; 8 9import { findActiveRoute, Navigation } from './Navigation'; 10import { NavigationNode } from './types'; 11 12import { HeadingManager } from '~/common/headingManager'; 13import { HeadingsContext } from '~/components/page-higher-order/withHeadingManager'; 14 15const prepareHeadingManager = () => { 16 const headingManager = new HeadingManager(new GithubSlugger(), { headings: [] }); 17 18 return headingManager; 19}; 20 21jest.mock('next/router', () => mockRouter); 22 23/** A set of navigation nodes to test with */ 24const nodes: NavigationNode[] = [ 25 node('section', { name: 'Get started' }, [ 26 node('page', { name: 'Introduction', href: '/introduction' }), 27 node('page', { name: 'Create a new app', href: '/introduction/create-new-app' }), 28 node('page', { name: 'Errors and debugging', href: '/introduction/not-that-great-tbh' }), 29 ]), 30 node('section', { name: 'Tutorial' }, [ 31 node('group', { name: 'First steps' }, [ 32 node('page', { name: 'Styling text', href: '/tutorial/first-steps/styling-text' }), 33 node('page', { name: 'Adding an image', href: '/tutorial/first-steps/adding-images' }), 34 node('page', { name: 'Creating a button', href: '/tutorial/creating-button' }), 35 ]), 36 node('group', { name: 'Building apps' }, [ 37 node('page', { name: 'Building for store', href: '/build/eas-build' }), 38 node('page', { name: 'Submitting to store', href: '/build/eas-submit' }), 39 ]), 40 node('group', { name: 'Parallel universe', hidden: true }, [ 41 node('page', { name: 'Create Flutter apps', href: '/parallel-universe/flutter' }), 42 node('page', { name: 'Create websites', href: '/parallel-universe/ionic' }), 43 node('page', { name: 'Create broken apps', href: '/parallel-universe/microsoft-uwp' }), 44 ]), 45 ]), 46]; 47 48describe(Navigation, () => { 49 it('renders pages', () => { 50 const section = getNode(nodes, { name: 'Get started' }); 51 render( 52 <MemoryRouterProvider> 53 <Navigation routes={children(section)} /> 54 </MemoryRouterProvider> 55 ); 56 // Get started -> 57 expect(screen.getByText('Introduction')).toBeInTheDocument(); 58 expect(screen.getByText('Create a new app')).toBeInTheDocument(); 59 expect(screen.getByText('Errors and debugging')).toBeInTheDocument(); 60 }); 61 62 it('renders pages inside groups', () => { 63 const section = getNode(nodes, { name: 'Tutorial' }); 64 render( 65 <MemoryRouterProvider> 66 <Navigation routes={children(section)} /> 67 </MemoryRouterProvider> 68 ); 69 // Tutorial -> 70 expect(screen.getByText('Building apps')).toBeInTheDocument(); 71 // Tutorial -> Building apps -> 72 expect(screen.getByText('Building for store')).toBeInTheDocument(); 73 expect(screen.getByText('Submitting to store')).toBeInTheDocument(); 74 }); 75 76 it('renders pages inside groups inside sections', () => { 77 const headingManager = prepareHeadingManager(); 78 render( 79 // Need context due to withHeadingManager in Collapsible, which enables anchor links 80 <HeadingsContext.Provider value={headingManager}> 81 <MemoryRouterProvider> 82 <Navigation routes={nodes} /> 83 </MemoryRouterProvider> 84 </HeadingsContext.Provider> 85 ); 86 // Get started -> 87 expect(screen.getByText('Introduction')).toBeInTheDocument(); 88 // Tutorial -> First steps -> 89 expect(screen.getByText('Adding an image')).toBeInTheDocument(); 90 // Tutorial -> Building apps -> 91 expect(screen.getByText('Submitting to store')).toBeInTheDocument(); 92 }); 93}); 94 95describe(findActiveRoute, () => { 96 it('finds active page in list', () => { 97 const section = getNode(nodes, { name: 'Get started' }); 98 expect(findActiveRoute(children(section), '/introduction/create-new-app')).toMatchObject({ 99 page: getNode(section, { name: 'Create a new app' }), 100 group: null, 101 section: null, 102 }); 103 }); 104 105 it('finds active page and group in list', () => { 106 const section = getNode(nodes, { name: 'Tutorial' }); 107 expect(findActiveRoute(children(section), '/build/eas-submit')).toMatchObject({ 108 page: getNode(section, { name: 'Submitting to store' }), 109 group: getNode(section, { name: 'Building apps' }), 110 section: null, 111 }); 112 }); 113 114 it('finds active page, group, and section in list', () => { 115 expect(findActiveRoute(nodes, '/tutorial/first-steps/styling-text')).toMatchObject({ 116 page: getNode(nodes, { name: 'Styling text' }), 117 group: getNode(nodes, { name: 'First steps' }), 118 section: getNode(nodes, { name: 'Tutorial' }), 119 }); 120 }); 121 122 it('skips hidden navigation node', () => { 123 expect(findActiveRoute(nodes, '/parallel-universe/microsoft-uwp')).toMatchObject({ 124 page: null, 125 group: null, 126 section: null, 127 }); 128 }); 129}); 130 131/** Helper function to find the first node that matches the predicate */ 132function getNode( 133 list: NavigationNode | NavigationNode[] | null, 134 predicate: Partial<NavigationNode> | ((node: NavigationNode) => boolean) 135): NavigationNode | null { 136 let result = null; 137 const tree = Array.isArray(list) ? node('root', list) : list || node('root'); 138 visit(tree, predicate as any, node => { 139 result = node; 140 }); 141 return result; 142} 143 144/** Helper function to pull children from the node, if any */ 145function children(node: NavigationNode | null) { 146 switch (node?.type) { 147 case 'section': 148 case 'group': 149 return node.children; 150 default: 151 return []; 152 } 153} 154