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