diff --git a/docs/guides/javascript/react/testing.md b/docs/guides/javascript/react/testing.md index 302a79739..374c72a6f 100644 --- a/docs/guides/javascript/react/testing.md +++ b/docs/guides/javascript/react/testing.md @@ -43,34 +43,53 @@ npm test -- --coverage Test files must match the glob `**/esm/tests/**/*.test.{ts,tsx}`. Place them alongside the source they test: -``` -public -└── lib - └── js - └── esm - ├── src - │ └── output - │ └── ExampleComponent.tsx - └── tests - └── output - └── ExampleComponent.test.ts -``` +import FileTree from '@site/src/components/FileTree'; + + The same convention applies to plugin components: -``` -public -└── mod - └── forum - └── js - └── esm - ├── src - │ └── output - │ └── ExampleComponent.tsx - └── tests - └── output - └── ExampleComponent.test.ts -``` + ## Writing a test @@ -88,7 +107,47 @@ describe('getString', () => { }); ``` -## Mocking AMD modules +## Mocking + +Mocking of class and module dependencies of your unit under test is strongly encouraged. In some cases it is mandatory. + +Some functionality, such as the ability to mock strings and AMD modules, is provided as part of the Moodle core and these mocks are reset between tests. + +You should not need to clean mocks up manually. Each test starts with a fresh state. + +You can clear up any additional test state within your test file using: + +- `beforeEach()` +- `afterEach()` + +See the [Jest Setup and Teardown](https://jestjs.io/docs/setup-teardown) documentation for further information. + +### Mocking strings + +Because of the way in which Moodle's string module fetches strings using a web service, all strings are mocked to a standard value of: + +``` +[identifier, component] +``` + +For cases where your tests expect a specific string value, you can mock values using the global `mockString` method: + +```typescript +describe('@moodle/lms/mod_example/Example', () => { + beforeEach(() => { + mockString('dofabuluousthings', 'more_example', 'Do something fabulous!!'); + }); + it('Renders an Example component', () => { + await act(async() => { + render(); + }); + + expect(screen.getByText('Do something fabulous!!!')).toBeInTheDocument(); + }); +}); +``` + +### Mocking AMD modules AMD modules (anything loaded via `requirejs`) **cannot** run inside Jest. The Jest module system and the AMD loader are completely separate environments, so `requirejs`, `M`, jQuery, and other Moodle globals are not available. @@ -111,9 +170,9 @@ describe('my component', () => { }); ``` -:::note +:::danger[Unmocked AMD modules] -If code under test calls `requireAsync` or `requireManyAsync` with a module that has not been registered via `mockAmdModule`, the test will throw: +If code under test calls `requireAsync` or `requireManyAsync` with a module that has not been registered using `mockAmdModule` then the test will throw an error: ``` Error: Unexpected call to requireAsync with module name: core/notification @@ -123,37 +182,40 @@ This is intentional: missing mocks produce a hard failure rather than silent wro ::: -### Registrations reset between tests - -Mocks and test fixtures, such as the AMD module map, and the string map, are cleared between each test using the `afterEach` notation. - -You do not need to clean up manually. Each test starts with a fresh state. - -You can clear up any additional test state within your test file using: +## Handling redirects -- `beforeEach()` -- `afterEach()` +If your code causes the page to redirect, then it must use the `@moodle/lms/core/location` module's `redirect` method, for example: -See the [Jest Setup and Teardown](https://jestjs.io/docs/setup-teardown) documentation for further information. +```typescript +import {redirect} from '@moodle/lms/core/location'; -## Mocking language strings +export default function () { + redirect('https://example.com'); +} +``` -`mockString(identifier, component, resolved)` registers a resolved value for a specific `(identifier, component)` pair. This delegates to the default `core/str` mock that is already registered in `.jest/globalSetup.ts`. +Moodle automatically mocks the `redirect` function and allows you to specify the expected value before calling your method using the `expectRedirect()` helper: ```typescript -mockString('submit', 'core', 'Submit'); -mockString('cancel', 'core', 'Cancel'); +import Example from '@moodle/lms/mod_example/Example'; + +describe('@moodle/lms/mod_example/Example', () => { + it('Redirects to the user documentation', () => { + expectRedirect({urlContains: 'example.com'}); -await expect(getString('submit', 'core')).resolves.toBe('Submit'); + Example(); + }); +}); ``` -For any string that was not registered, the default mock returns `[identifier, component]`: +The `expectRedirect` method accepts: -```typescript -await expect(getString('other', 'core')).resolves.toBe('[other, core]'); -``` +- a `url` parameter with an exact matching URL; or +- a `urlContains` parameter with a partial match. + +If a redirect occurs without an `expectRedirect()` call, an Error will be thrown. -This default is useful for snapshot tests and assertions that only care whether a string key was requested, not its exact value. +Expected redirects are reset between tests. ## Module path aliases diff --git a/src/components/FileTree/index.tsx b/src/components/FileTree/index.tsx new file mode 100644 index 000000000..4a240d774 --- /dev/null +++ b/src/components/FileTree/index.tsx @@ -0,0 +1,87 @@ +/** + * Copyright (c) Moodle Pty Ltd. + * + * Moodle is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Moodle is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Moodle. If not, see . + */ + +import React from 'react'; +import CodeBlock from '@theme/CodeBlock'; + +function parseObjectTree(tree: DirectoryStructure, prefix = ''): string { + const keys = Object.keys(tree); + let result = ''; + + keys.forEach((key, index) => { + const isLast = index === keys.length - 1; + const pointer = isLast ? '└── ' : '├── '; + const nextPrefix = prefix + (isLast ? ' ' : '│ '); + + const value = tree[key]; + + let comment = ''; + let children: DirectoryStructure | undefined; + + // Determine the format at runtime + if (value !== null && typeof value === 'object') { + if ('_comment' in value || '_children' in value) { + // It's using the expanded object format + const expanded = value as ExpandedItem; + comment = expanded._comment ? ` # ${expanded._comment}` : ''; // eslint-disable-line no-underscore-dangle + children = expanded._children; // eslint-disable-line no-underscore-dangle + } else { + // It's using the simple nested directory format + children = value as DirectoryStructure; + } + } else if (typeof value === 'string') { + // Inline shorthand string comment support: "filename": "comment string" + comment = ` # ${value}`; + } + + // 1. Append the node line + result += `${prefix}${pointer}${key}${comment}\n`; + + // 2. Recurse into children if they exist + if (children) { + result += parseObjectTree(children, nextPrefix); + } + }); + + return result; +} + +type FileValue = string | null; + +export interface ExpandedItem { + _comment?: string; + _children?: DirectoryStructure; +} + +// A node can now be a primitive file, a simple nested directory, or an expanded item +export type TreeItem = FileValue | DirectoryStructure | ExpandedItem; + +export interface DirectoryStructure { + [nodeName: string]: TreeItem; +} + +export type FileTreeProps = { + structure: DirectoryStructure; +}; + +export default function FileTree({ structure }: FileTreeProps): JSX.Element { + return ( + + {parseObjectTree(structure)} + + ); +}