diff --git a/client/package.json b/client/package.json
index 72a630f6..e3853b17 100644
--- a/client/package.json
+++ b/client/package.json
@@ -108,5 +108,12 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "jest": {
+ "collectCoverageFrom": [
+ "src/**/*.{ts,tsx}",
+ "!src/index.tsx",
+ "!src/store.ts"
+ ]
}
}
diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx
new file mode 100644
index 00000000..7c2b763d
--- /dev/null
+++ b/client/src/App.test.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import App from './App';
+import { renderWithRouterRedux } from './testUtils/reduxRender';
+
+describe('', () => {
+ it('renders without crashing', () => {
+ renderWithRouterRedux();
+ });
+});
diff --git a/client/src/actions/actions.test.ts b/client/src/actions/actions.test.ts
index 8d55ffb2..f8801be0 100644
--- a/client/src/actions/actions.test.ts
+++ b/client/src/actions/actions.test.ts
@@ -1,9 +1,17 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
import { rest } from 'msw';
-import { fetchUser } from '.';
+import { fetchUser, setReview, updateDraftId, updateDrafts, updateReadingList, updateReviews } from '.';
import { server } from '../mocks/server';
-import { blankUser } from '../templates';
-import { FETCH_USER } from './actionTypes';
+import { blankPaper, blankReview, blankUser } from '../templates';
+import {
+ FETCH_USER,
+ SET_REVIEW,
+ UPDATE_DRAFTS,
+ UPDATE_DRAFT_ID,
+ UPDATE_READING_LIST,
+ UPDATE_REVIEWS,
+} from './actionTypes';
describe('redux actions', () => {
describe('fetchUser', () => {
@@ -25,4 +33,25 @@ describe('redux actions', () => {
expect(dispatchMock).toHaveBeenCalledWith({ payload: blankUser, type: FETCH_USER });
});
});
+
+ // These are all really simple, but we're chasing that sweet sweet test coverage
+ describe('other action creators', () => {
+ const scenarios: {
+ creator: any;
+ arg: any;
+ expectedType: any;
+ }[] = [
+ { creator: setReview, arg: blankReview, expectedType: SET_REVIEW },
+ { creator: updateDraftId, arg: 'Mongo ID', expectedType: UPDATE_DRAFT_ID },
+ { creator: updateDrafts, arg: [blankReview], expectedType: UPDATE_DRAFTS },
+ { creator: updateReadingList, arg: [blankPaper], expectedType: UPDATE_READING_LIST },
+ { creator: updateReviews, arg: [blankReview], expectedType: UPDATE_REVIEWS },
+ ];
+
+ scenarios.forEach(({ creator, arg, expectedType }) => {
+ it(`returns the right type for ${creator.name}`, () => {
+ expect(creator(arg).type).toBe(expectedType);
+ });
+ });
+ });
});
diff --git a/client/src/components/DraftPage/DraftPage-redux.tsx b/client/src/components/DraftPage/DraftPage-redux.tsx
index d97e9183..bb4176d0 100644
--- a/client/src/components/DraftPage/DraftPage-redux.tsx
+++ b/client/src/components/DraftPage/DraftPage-redux.tsx
@@ -1,10 +1,11 @@
import React from 'react';
-import { useProtected } from '../../hooks/useProtected';
+import { useProtected } from '../../hooks';
import Drafts from '../Drafts';
function DraftPageRedux(): JSX.Element {
useProtected();
+
return (
diff --git a/client/src/components/Home/Home-redux.tsx b/client/src/components/Home/Home-redux.tsx
index c36da6be..617a095e 100644
--- a/client/src/components/Home/Home-redux.tsx
+++ b/client/src/components/Home/Home-redux.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { useProtected } from '../../hooks/useProtected';
+import { useProtected } from '../../hooks';
import HomeContainer from './Home-container';
export default function HomeRedux(): JSX.Element {
diff --git a/client/src/components/Login/Login.tsx b/client/src/components/Login/Login.tsx
index a06c3642..46f23836 100644
--- a/client/src/components/Login/Login.tsx
+++ b/client/src/components/Login/Login.tsx
@@ -6,7 +6,7 @@ import { CalendarOutlined, TeamOutlined } from '@ant-design/icons';
import { Redirect } from 'react-router-dom';
import LazyHero from 'react-lazy-hero';
import { Location } from 'history';
-import GoogleButton from 'react-google-button';
+import GoogleButton from 'react-google-button/dist/react-google-button';
import './Login.scss';
import { RootState } from '../../reducers';
import { User } from '../../types';
diff --git a/client/src/components/utils.test.tsx b/client/src/components/utils.test.tsx
new file mode 100644
index 00000000..f52756ef
--- /dev/null
+++ b/client/src/components/utils.test.tsx
@@ -0,0 +1,214 @@
+/* eslint-disable no-console */
+/* eslint-disable no-return-assign */
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import {
+ getTagColor,
+ HSLString,
+ isDOI,
+ removeMiddleAuthors,
+ renderCommaSepList,
+ shortenAuthors,
+ shortenTableString,
+ wrapMarkdownWithMath,
+} from './utils';
+
+describe('utils', () => {
+ describe('renderCommaSepList', () => {
+ it('renders nothing when nothing is passed in', () => {
+ expect(renderCommaSepList([])).toEqual([]);
+ });
+
+ it('renders one item without a comma separator', () => {
+ const output = renderCommaSepList(['Shrek']);
+ render(
{output}
);
+ expect(screen.getByText(/Shrek/)).toBeDefined();
+ });
+
+ it('renders two items without a comma separator and an "and"', () => {
+ const output = renderCommaSepList(['Shrek', 'Donkey']);
+ render(
{output}
);
+ expect(screen.getByText('Shrek')).toBeDefined();
+ expect(screen.getByText('and Donkey')).toBeDefined();
+ });
+
+ it('renders 3 or more items with comma separation', () => {
+ const output = renderCommaSepList(['Shrek', 'Donkey', 'Fiona', 'Father Time']);
+ render(
{output}
);
+ expect(screen.getByText(/Shrek,/)).toBeDefined();
+ expect(screen.getByText(/Donkey,/)).toBeDefined();
+ expect(screen.getByText(/Fiona/)).toBeDefined();
+ expect(screen.getByText('and Father Time')).toBeDefined();
+ });
+ });
+
+ describe('removeMiddleAuthors', () => {
+ const scenarios: {
+ description: string;
+ authorList: string[];
+ numKeepEitherEnd: number;
+ expectedResult: string[];
+ }[] = [
+ {
+ description: 'with zero authors',
+ authorList: [],
+ numKeepEitherEnd: 0,
+ expectedResult: [],
+ },
+ {
+ description: 'with few authors, keeping many on each end',
+ authorList: ['Piranesi', 'The Other'],
+ numKeepEitherEnd: 100,
+ expectedResult: ['Piranesi', 'The Other'],
+ },
+ {
+ description: 'with many authors, keep 0 on either end',
+ authorList: ['Piranesi', 'The Other'],
+ numKeepEitherEnd: 0,
+ expectedResult: ['Piranesi', 'The Other'],
+ },
+ {
+ description: 'with many authors, keep 2 on either end',
+ authorList: ['Piranesi', 'The Other', 'The Biscuit Box Man', 'Sixteen', "Sylvia D'Agostino"],
+ numKeepEitherEnd: 2,
+ expectedResult: ['Piranesi', 'The Other', '...', 'Sixteen', "Sylvia D'Agostino"],
+ },
+ ];
+
+ scenarios.forEach(({ description, authorList, numKeepEitherEnd, expectedResult }) => {
+ it(`returns the correct result ${description}`, () => {
+ expect(removeMiddleAuthors(authorList, numKeepEitherEnd)).toEqual(expectedResult);
+ });
+ });
+ });
+
+ describe('shortenAuthors', () => {
+ it('returns NAText when a single blank author is provided', () => {
+ const output = shortenAuthors(['']);
+ render(output as JSX.Element);
+ expect(screen.getByText('N/A')).toBeDefined();
+ });
+
+ it('returns NAText when no authors are provided', () => {
+ const output = shortenAuthors([]);
+ render(output as JSX.Element);
+ expect(screen.getByText('N/A')).toBeDefined();
+ });
+
+ const scenarios: {
+ description: string;
+ authors: string[];
+ expectedOutput: string;
+ }[] = [
+ {
+ description: 'with 2 authors',
+ authors: ['Stephen Curry', 'Klay Thompson'],
+ expectedOutput: 'Curry and Thompson',
+ },
+ {
+ description: 'with 1 author',
+ authors: ['Stephen Curry'],
+ expectedOutput: 'Stephen Curry',
+ },
+ {
+ description: 'with 3 authors',
+ authors: ['Stephen Curry', 'Klay Thompson', 'Billy the Shooter'],
+ expectedOutput: 'Curry et al.',
+ },
+ ];
+
+ scenarios.forEach(({ description, authors, expectedOutput }) => {
+ it(`returns the right string ${description}`, () => {
+ expect(shortenAuthors(authors)).toEqual(expectedOutput);
+ });
+ });
+ });
+
+ describe('shortenTableString', () => {
+ const scenarios: {
+ description: string;
+ inString: string;
+ cutoff: number;
+ expectedText: string;
+ }[] = [
+ {
+ description: 'with no string',
+ inString: '',
+ cutoff: -5,
+ expectedText: 'N/A',
+ },
+ {
+ // TODO EM: is this desired?
+ description: 'with a string and a negative cutoff',
+ inString: 'testing one two',
+ cutoff: -5,
+ expectedText: 'testing one two',
+ },
+ {
+ description: 'with a string and a small cutoff',
+ inString: 'testing one two',
+ cutoff: 5,
+ expectedText: 'testi...',
+ },
+ {
+ description: 'with a string and a huge cutoff',
+ inString: 'testing one two',
+ cutoff: 500,
+ expectedText: 'testing one two',
+ },
+ ];
+
+ scenarios.forEach(({ description, inString, cutoff, expectedText }) => {
+ it(`renders the correct element ${description}`, () => {
+ render(shortenTableString(inString, cutoff));
+ expect(screen.getByText(expectedText)).toBeDefined();
+ });
+ });
+ });
+
+ describe('getTagColor', () => {
+ it('returns the same color for the same tag', () => {
+ const tag = 'tag';
+ expect(getTagColor(tag)).toEqual(getTagColor(tag));
+ });
+ });
+
+ describe.skip('getReviewStats', () => {
+ // TODO: once review data deserialization is figured out, write this test
+ });
+
+ describe('isDOI', () => {
+ it('recognizes DOIs starting with 10', () => {
+ expect(isDOI('10.1.1')).toBeTruthy();
+ });
+
+ it('recognizes DOIs with doi.org', () => {
+ expect(isDOI(' doi.org ')).toBeTruthy();
+ });
+
+ it('cannot be fooled by mere mortals', () => {
+ expect(isDOI(' doI.org ')).toBeFalsy();
+ expect(isDOI('10 .1.1')).toBeFalsy();
+ });
+ });
+
+ describe('wrapMarkdownWithMath', () => {
+ // This is silly, but the react-katex library throws an ugly warning: https://github.com/talyssonoc/react-katex/issues/59
+ // so we'll just suppress it :)
+ const originalConsoleWarn = console.warn;
+ beforeAll(() => (console.warn = jest.fn()));
+ afterAll(() => (console.warn = originalConsoleWarn));
+
+ it('does not alter non-math string', () => {
+ const testString = 'The Year I Named the Constellations';
+ render(wrapMarkdownWithMath(testString));
+ expect(screen.getByText(testString)).toBeDefined();
+ });
+
+ it('renders math strings with markdown', () => {
+ render(wrapMarkdownWithMath('$\\sum_0^\\infty$'));
+ expect(screen.getAllByText(/∑/)).toBeDefined();
+ expect(screen.getAllByText(/∞/)).toBeDefined();
+ });
+ });
+});
diff --git a/client/src/components/utils.tsx b/client/src/components/utils.tsx
index 12eb2ba7..a6d9d9b4 100644
--- a/client/src/components/utils.tsx
+++ b/client/src/components/utils.tsx
@@ -46,7 +46,7 @@ export const renderCommaSepList = (items: string[]): JSX.Element[] =>
export const removeMiddleAuthors = (authorList: string[], numKeepEitherEnd: number): string[] => {
const numAuthors = authorList.length;
const numKeepTotal = numKeepEitherEnd * 2;
- if (numAuthors <= numKeepTotal) {
+ if (numAuthors <= numKeepTotal || numKeepTotal === 0) {
return authorList;
}
@@ -60,6 +60,8 @@ export const removeMiddleAuthors = (authorList: string[], numKeepEitherEnd: numb
};
export const shortenAuthors = (authors: string[]): JSX.Element | string => {
+ if (!authors.length) return
;
+
let authorString = '';
if (authors.length === 2) {
@@ -78,6 +80,10 @@ export const shortenTableString = (str: string, cutoff: number): JSX.Element =>
return
;
}
+ if (cutoff <= 0) {
+ return
{str};
+ }
+
return
{str.length >= cutoff ? `${str.substring(0, cutoff)}...` : str};
};
diff --git a/client/src/declarations.d.ts b/client/src/declarations.d.ts
index e9fba6ca..3a77722c 100644
--- a/client/src/declarations.d.ts
+++ b/client/src/declarations.d.ts
@@ -1,2 +1,3 @@
declare module 'react-lazy-hero';
declare module 'react-katex';
+declare module 'react-google-button/dist/react-google-button';
diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts
new file mode 100644
index 00000000..e579db75
--- /dev/null
+++ b/client/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useIsMounted';
+export * from './useProtected';
diff --git a/client/src/hooks/useIsMounted/index.ts b/client/src/hooks/useIsMounted/index.ts
new file mode 100644
index 00000000..29f8dda1
--- /dev/null
+++ b/client/src/hooks/useIsMounted/index.ts
@@ -0,0 +1 @@
+export * from './useIsMounted';
diff --git a/client/src/hooks/useIsMounted/useIsMounted.test.tsx b/client/src/hooks/useIsMounted/useIsMounted.test.tsx
new file mode 100644
index 00000000..b5caae81
--- /dev/null
+++ b/client/src/hooks/useIsMounted/useIsMounted.test.tsx
@@ -0,0 +1,16 @@
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+import { useIsMounted } from '.';
+
+// Because you can't run a hook outside of a react component, we'll make a small test case:
+function TestComponent(): JSX.Element {
+ const isMounted = useIsMounted();
+ return
{isMounted().toString()}
;
+}
+
+xdescribe('useIsMounted', () => {
+ it('returns false before the component is mounted', async () => {
+ render(
);
+ expect(screen.getByText(/true/)).toBeDefined();
+ });
+});
diff --git a/client/src/hooks.ts b/client/src/hooks/useIsMounted/useIsMounted.ts
similarity index 88%
rename from client/src/hooks.ts
rename to client/src/hooks/useIsMounted/useIsMounted.ts
index bcbdf973..c50b31f7 100644
--- a/client/src/hooks.ts
+++ b/client/src/hooks/useIsMounted/useIsMounted.ts
@@ -1,4 +1,4 @@
-import { useCallback, useRef, useEffect } from 'react';
+import { useRef, useEffect, useCallback } from 'react';
export const useIsMounted = (): (() => boolean) => {
const mountedRef = useRef(false);