Skip to content

Commit 94c7ebf

Browse files
Merge pull request OpenCircuits#1082 from OpenCircuits/frontend-testing
Frontend testing (React)
2 parents 9d521f1 + b5e90fb commit 94c7ebf

File tree

19 files changed

+423
-15
lines changed

19 files changed

+423
-15
lines changed

.github/workflows/ci_tests.yml

+19-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
yarn build:jsdocs
3939
yarn build:docs
4040
41-
test:
41+
test-app:
4242
runs-on: ubuntu-latest
4343
steps:
4444
- uses: actions/checkout@v2
@@ -53,6 +53,24 @@ jobs:
5353
- name: Run tests (App) [TypeScript]
5454
run: yarn test --ci app
5555

56+
test-frontend:
57+
runs-on: ubuntu-latest
58+
steps:
59+
- uses: actions/checkout@v2
60+
61+
- name: Use Node.js 16.x
62+
uses: actions/setup-node@v1
63+
with:
64+
node-version: 16.x
65+
66+
- run: yarn
67+
68+
- name: Run tests (Shared) [TypeScript]
69+
run: yarn test --ci shared
70+
71+
- name: Run tests (Digital) [TypeScript]
72+
run: yarn test --ci digital
73+
5674
lint:
5775
runs-on: ubuntu-latest
5876
steps:

linting/.imports.cjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function addPath(pathGroupsIn, pattern) {
3838
const appDirectories = ["core", "analog", "digital"];
3939
const appSubDirectories = ["utils", "actions", "tools", "rendering", "models"];
4040
const siteDirectories = ["shared", "analog", "digital", "landing"];
41-
const siteSubDirectories = ["utils", "api", "state", "components", "containers"];
41+
const siteSubDirectories = ["utils", "api", "state", "components", "containers", "tests"];
4242
const pathGroups = [
4343
{"pattern": "react", "group": "external"},
4444
{"pattern": "{**,**/,,./,../,*}{C,c}onstants{**,/**,,*}", "group": "external", "position": "after"},

linting/.jestRules.cjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ module.exports = {
55
},
66
"overrides": [
77
{
8-
"files": ["**.test.ts"],
8+
"files": ["**.test.ts?(x)"],
99
"rules": {
1010
"jest/unbound-method": "error", // Typescript version disabled in .ts.js
1111
}

linting/.sonarjs.cjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ module.exports = {
77
],
88
"overrides": [
99
{
10-
"files": ["**.test.ts"],
10+
"files": ["**.test.ts?(x)"],
1111
"rules": {
1212
// Verbosity can be nice in tests, so this check isn't necessary there
1313
"sonarjs/no-identical-functions": "off",

linting/.ts.cjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ module.exports = {
88
],
99
"overrides": [
1010
{
11-
"files": ["**.test.ts"],
11+
"files": ["**.test.ts?(x)"],
1212
"rules": {
1313
"@typescript-eslint/unbound-method": "off", // Jest has its own version
1414
}

package.json

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
"@babel/preset-env": "^7.18.6",
2525
"@babel/preset-react": "^7.18.6",
2626
"@pmmmwh/react-refresh-webpack-plugin": "0.5.7",
27+
"@testing-library/jest-dom": "^5.16.4",
28+
"@testing-library/react": "^13.3.0",
29+
"@testing-library/user-event": "^14.2.1",
2730
"@types/jest": "^28.1.4",
2831
"@types/node": "^18.0.0",
2932
"@types/prompts": "^2.0.14",

scripts/build.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ process.env.BABEL_ENV = "production";
1717
process.env.NODE_ENV = "production";
1818

1919

20-
const DIRS = getDirs(true, false);
20+
const DIRS = getDirs(true, false, false);
2121
const DIR_MAP = Object.fromEntries(DIRS.map(d => [d.value, d]));
2222

2323

scripts/start.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function StartClient(dir: string, project: string, open: boolean) {
4444
.boolean("open")
4545
.argv;
4646

47-
const dirs = getDirs(true, false);
47+
const dirs = getDirs(true, false, false);
4848

4949
// Prompt for project type
5050
const { value } = await prompts({

scripts/test.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ process.env.BABEL_ENV = "test";
1919
process.env.NODE_ENV = "test";
2020

2121

22-
const DIRS = getDirs(true, true);
22+
const DIRS = getDirs(true, true, true);
2323
const DIR_MAP = Object.fromEntries(DIRS.map(d => [d.value, d]));
2424

2525
async function LaunchTest(args: Arguments, dir: string, flags: Record<string, unknown>) {
@@ -30,6 +30,10 @@ async function LaunchTest(args: Arguments, dir: string, flags: Record<string, un
3030
"preset": "ts-jest",
3131
"testEnvironment": "jsdom",
3232
"moduleNameMapper": getAliases(path.resolve(process.cwd(), dir), "jest"),
33+
34+
"transform": {
35+
"^.+\\.scss$": path.resolve("build/scripts/test/scssTransform.js"),
36+
},
3337
}),
3438
}, [dir]);
3539
}
@@ -91,7 +95,8 @@ async function LaunchTest(args: Arguments, dir: string, flags: Record<string, un
9195
console.log(chalk.yellow("Skipping disabled directory,", chalk.underline(dir)));
9296
continue;
9397
}
94-
const testDir = dir === "app" ? "src/app" : `src/site/pages/${dir}`;
98+
const testDir = (dir === "app" || dir === "site/shared")
99+
? `src/${dir}` : `src/site/pages/${dir}`;
95100
flags.coverageDirectory = `${process.cwd()}/coverage/${testDir}`;
96101

97102
const { results: result } = await LaunchTest(argv, testDir, flags);

scripts/test/scssTransform.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default {
2+
process() {
3+
return {
4+
code: "module.exports = {};",
5+
}
6+
},
7+
getCacheKey() {
8+
// The output is always the same.
9+
return "scssTransform";
10+
},
11+
};

scripts/utils/getDirs.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import type {Choice} from "prompts";
99
*
1010
* @param includeServer Whether or not to include the `server` folder.
1111
* @param includeApp Whether or not to include the `app` folder.
12+
* @param includeShared Whether or not to include the `site/shared` folder.
1213
* @returns The directories of the format for presentation using `prompts`.
1314
*/
14-
export default function getDirs(includeServer: boolean, includeApp: boolean): Choice[] {
15+
export default function getDirs(includeServer: boolean, includeApp: boolean, includeShared: boolean): Choice[] {
1516
const pagesDir = "src/site/pages";
1617
const dirs = readdirSync(pagesDir, { withFileTypes: true });
1718

@@ -37,6 +38,9 @@ export default function getDirs(includeServer: boolean, includeApp: boolean): Ch
3738
...(includeApp ? [{ // Add in app directory
3839
title: "App", description: "The application logic for OpenCircuits", value: "app",
3940
}] : []),
41+
...(includeShared ? [{ // Add in the site/shared directory
42+
title: "Shared", description: "The shared site code for OpenCircuits", value: "site/shared",
43+
}] : []),
4044
...pageDirs,
4145
];
4246
}

src/site/pages/digital/src/containers/ExprToCircuitPopup/DropdownOption.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type Props<T> = {
1212
}
1313
export const DropdownOption = <T extends string>({ id, option, options, setOption, text }: Props<T>) => (<>
1414
<br />
15-
<label>{text}</label>
15+
<label htmlFor={id}>{text}</label>
1616
<select id={id} value={option}
1717
onChange={e => setOption(e.target.value as T)}
1818
onBlur={e => setOption(e.target.value as T)}>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import "@testing-library/jest-dom";
2+
import {act, render, screen} from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import {Provider} from "react-redux";
5+
import {applyMiddleware, createStore} from "redux";
6+
import thunk, {ThunkMiddleware} from "redux-thunk";
7+
8+
import {Setup} from "test/helpers/Setup";
9+
10+
import {LED, ORGate, Switch} from "digital/models/ioobjects";
11+
12+
import {OpenHeaderPopup} from "shared/state/Header";
13+
14+
import "shared/tests/helpers/Extensions";
15+
import {PressToggle} from "shared/tests/helpers/PressToggle";
16+
17+
import {AppState} from "site/digital/state";
18+
19+
import {AllActions} from "site/digital/state/actions";
20+
import {reducers} from "site/digital/state/reducers";
21+
22+
import {ExprToCircuitPopup} from "site/digital/containers/ExprToCircuitPopup";
23+
24+
25+
// beforeAll and beforeEach can be used to avoid duplicating store/render code, but is not recommended
26+
// see: https://testing-library.com/docs/user-event/intro
27+
describe("Main Popup", () => {
28+
const info = Setup();
29+
const store = createStore(reducers, applyMiddleware(thunk as ThunkMiddleware<AppState, AllActions>));
30+
const user = userEvent.setup();
31+
32+
beforeEach(() => {
33+
render(<Provider store={store}><ExprToCircuitPopup mainInfo={info} /></Provider>);
34+
act(() => { store.dispatch(OpenHeaderPopup("expr_to_circuit")) });
35+
});
36+
37+
afterEach(() => {
38+
info.designer.reset();
39+
});
40+
41+
test("Popup Created with default states", () => {
42+
// Check header and button states
43+
expect(screen.getByText("Digital Expression To Circuit Generator")).toBeVisible();
44+
expect(screen.getByText("Cancel")).toBeVisible();
45+
expect(screen.getByText("Generate")).toBeVisible();
46+
expect(screen.getByText("Generate")).toBeDisabled();
47+
48+
// Check format options
49+
expect(/Programming 1/).toBeToggledOn();
50+
expect(/Custom/).toBeToggledOff();
51+
expect(screen.queryByText(/Custom AND/)).toBeNull();
52+
53+
// Check toggle switches
54+
expect(/Place labels for inputs/).toBeToggledOff();
55+
expect(/Generate into IC/).toBeToggledOff();
56+
expect(screen.queryByText(/Connect Clocks/)).toBeNull();
57+
58+
// Check dropdowns
59+
const inputOptions = screen.getByLabelText(/Input Component/).querySelectorAll("option");
60+
const switchInputOption = [...inputOptions].find((input) => input.text === "Switch");
61+
expect(switchInputOption?.selected).toBeTruthy();
62+
const outputOptions = screen.getByLabelText(/Output Component/).querySelectorAll("option");
63+
const ledOutputOption = [...outputOptions].find((input) => input.text === "LED");
64+
expect(ledOutputOption?.selected).toBeTruthy();
65+
66+
// Text input is empty
67+
const input = screen.getByRole<HTMLInputElement>("textbox");
68+
expect(input.value).toBe("");
69+
});
70+
71+
test("Cancel Button Cancels", async () => {
72+
await user.type(screen.getByRole("textbox"), "a | b");
73+
74+
await user.click(screen.getByText("Cancel"));
75+
expect(screen.getByText("Cancel")).not.toBeVisible();
76+
expect(screen.getByText("Digital Expression To Circuit Generator")).not.toBeVisible();
77+
78+
// Reopen and requery in case reference changed
79+
act(() => { store.dispatch(OpenHeaderPopup("expr_to_circuit")) });
80+
expect((screen.getByRole<HTMLInputElement>("textbox")).value).toBe("");
81+
});
82+
83+
test("Generate Button", async () => {
84+
// Enter the expression and generate
85+
await user.type(screen.getByRole("textbox"), "a | b");
86+
expect(screen.getByText("Generate")).toBeEnabled();
87+
await user.click(screen.getByText("Generate"));
88+
expect(screen.getByText("Digital Expression To Circuit Generator")).not.toBeVisible();
89+
90+
// Check that the components are placed and connected
91+
const components = info.designer.getObjects();
92+
expect(components).toHaveLength(4);
93+
const inputA = components.find(component => component instanceof Switch
94+
&& component.getName() === "a") as Switch;
95+
const inputB = components.find(component => component instanceof Switch
96+
&& component.getName() === "b") as Switch;
97+
const orGate = components.find(component => component instanceof ORGate) as ORGate;
98+
const led = components.find(component => component instanceof LED) as LED;
99+
expect(inputA).toBeDefined();
100+
expect(inputB).toBeDefined();
101+
expect(orGate).toBeDefined();
102+
expect(led).toBeDefined();
103+
expect(led.isOn()).toBeFalsy();
104+
inputA.click();
105+
expect(led.isOn()).toBeTruthy();
106+
inputA.click();
107+
inputB.click();
108+
expect(led.isOn()).toBeTruthy();
109+
110+
// Reopen and requery in case reference changed
111+
act(() => { store.dispatch(OpenHeaderPopup("expr_to_circuit")) });
112+
expect((screen.getByRole<HTMLInputElement>("textbox")).value).toBe("");
113+
});
114+
115+
test("Custom format settings appear", async () => {
116+
await PressToggle("Custom", user);
117+
expect("Custom").toBeToggledOn();
118+
expect(screen.queryByText(/Custom AND/)).toBeVisible();
119+
});
120+
121+
test("Conditions for options to appear", async () => {
122+
await user.selectOptions(screen.getByLabelText(/Output Component/), "Oscilloscope");
123+
expect(screen.queryByText(/Generate into IC/)).toBeNull();
124+
expect(screen.queryByText(/Connect Clocks/)).toBeNull();
125+
126+
await user.selectOptions(screen.getByLabelText(/Input Component/), "Clock");
127+
expect(/Connect Clocks/).toBeToggledOff();
128+
129+
await user.selectOptions(screen.getByLabelText(/Input Component/), "Switch");
130+
expect(screen.queryByText(/Connect Clocks/)).toBeNull();
131+
});
132+
});

src/site/pages/digital/tsconfig.json

+3-2
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
"Vector": ["../../../app/core/utils/math/Vector"],
77
"math/*": ["../../../app/core/utils/math/*"],
88
"core/*": ["../../../app/core/*"],
9+
"site/digital/*": ["./src/*"],
910
"digital/*": ["../../../app/digital/*"],
11+
"test/helpers/*": ["../../../app/tests/helpers/*"],
1012
"shared/*": ["../../shared/*"],
11-
"site/digital/*": ["./src/*"],
1213
}
1314
},
14-
"include": ["src", "../../shared/utils/types"],
15+
"include": ["src", "../../shared/utils/types", "tests"],
1516
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import "@testing-library/jest-dom";
2+
import {Matcher, render, screen} from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
5+
import {ButtonToggle} from "shared/components/ButtonToggle";
6+
7+
8+
/**
9+
* Gets the ButtonToggle or SwitchToggle image elements associated with the provided text.
10+
* The pressed image will be returned first and the unpressed second.
11+
*
12+
* @param id The id of the text to search for.
13+
* @returns An array containing the pressed and unpressed images, respectively.
14+
*/
15+
function GetToggles(id: Matcher): [HTMLImageElement, HTMLImageElement] {
16+
const buttons = screen.getByText(id).parentNode!.querySelectorAll("img");
17+
return buttons[0].src.includes("Down")
18+
? [buttons[0], buttons[1]]
19+
: [buttons[1], buttons[0]];
20+
}
21+
22+
describe("Button Toggle", () => {
23+
test("Button on", () => {
24+
render(<ButtonToggle text="test" isOn />);
25+
const [buttonOn, buttonOff] = GetToggles("test");
26+
expect(buttonOn).toBeVisible();
27+
expect(buttonOff).not.toBeVisible();
28+
});
29+
test("Button off", () => {
30+
render(<ButtonToggle text="test" isOn={false} />);
31+
const [buttonOn, buttonOff] = GetToggles("test");
32+
expect(buttonOn).not.toBeVisible();
33+
expect(buttonOff).toBeVisible();
34+
});
35+
test("onChange", async () => {
36+
let testBoolean = false;
37+
const user = userEvent.setup();
38+
render(<ButtonToggle text="test" isOn={testBoolean} onChange={() => testBoolean = !testBoolean} />);
39+
const [buttonOn, buttonOff] = GetToggles("test");
40+
expect(buttonOn).not.toBeVisible();
41+
expect(buttonOff).toBeVisible();
42+
await user.click(buttonOff);
43+
expect(testBoolean).toBeTruthy();
44+
await user.click(buttonOn);
45+
expect(testBoolean).toBeFalsy();
46+
});
47+
});

0 commit comments

Comments
 (0)