From 2473e3029aa64ed5764afc516f4ae6184a276783 Mon Sep 17 00:00:00 2001 From: Jeremy Asuncion Date: Tue, 24 Dec 2024 12:14:24 -0800 Subject: [PATCH] test: unit tests feature components (#1423) * DepositionFilterBanner tests * InfoLink tests * MLChallengeBanner tests * ObjectIdLink tests * mock css modules * extract MLChallenge mock utils * SurveyBanner tests * TablePageLayout tests * fix remix mock types * add comments --- .../components/InfoLink/InfoLink.test.tsx | 80 +++++++++ .../components/InfoLink/InfoLink.tsx | 13 +- .../DepositionFilterBanner.test.tsx | 83 +++++++++ .../app/components/DepositionFilterBanner.tsx | 2 +- .../data-portal/app/components/I18n.mock.tsx | 17 ++ .../data-portal/app/components/I18n.tsx | 5 +- .../MLChallenge/MLChallengeBanner.test.tsx | 90 ++++++++++ .../ObjectIdLink/ObjectIdLink.test.tsx | 49 ++++++ .../SurveyBanner/SurveyBanner.test.tsx | 71 ++++++++ .../components/SurveyBanner/SurveyBanner.tsx | 7 +- .../TablePageLayout/TablePageLayout.test.tsx | 166 ++++++++++++++++++ .../data-portal/app/components/Tabs.tsx | 5 +- .../app/mocks/LocalStorage.mock.ts | 30 ++++ .../data-portal/app/mocks/Remix.mock.ts | 62 +++++++ .../packages/data-portal/app/utils/mock.ts | 18 ++ frontend/packages/data-portal/jest.config.cjs | 1 + frontend/packages/data-portal/package.json | 1 + frontend/pnpm-lock.yaml | 14 ++ 18 files changed, 699 insertions(+), 15 deletions(-) create mode 100644 frontend/packages/data-portal/app/components/Dataset/SampleAndExperimentConditionsTable/components/InfoLink/InfoLink.test.tsx create mode 100644 frontend/packages/data-portal/app/components/DepositionFilterBanner.test.tsx create mode 100644 frontend/packages/data-portal/app/components/I18n.mock.tsx create mode 100644 frontend/packages/data-portal/app/components/MLChallenge/MLChallengeBanner.test.tsx create mode 100644 frontend/packages/data-portal/app/components/Run/AnnotationObjectTable/components/ObjectIdLink/ObjectIdLink.test.tsx create mode 100644 frontend/packages/data-portal/app/components/SurveyBanner/SurveyBanner.test.tsx create mode 100644 frontend/packages/data-portal/app/components/TablePageLayout/TablePageLayout.test.tsx create mode 100644 frontend/packages/data-portal/app/mocks/LocalStorage.mock.ts create mode 100644 frontend/packages/data-portal/app/mocks/Remix.mock.ts create mode 100644 frontend/packages/data-portal/app/utils/mock.ts diff --git a/frontend/packages/data-portal/app/components/Dataset/SampleAndExperimentConditionsTable/components/InfoLink/InfoLink.test.tsx b/frontend/packages/data-portal/app/components/Dataset/SampleAndExperimentConditionsTable/components/InfoLink/InfoLink.test.tsx new file mode 100644 index 000000000..e2ab90e55 --- /dev/null +++ b/frontend/packages/data-portal/app/components/Dataset/SampleAndExperimentConditionsTable/components/InfoLink/InfoLink.test.tsx @@ -0,0 +1,80 @@ +import { createRemixStub } from '@remix-run/testing' +import { render, screen } from '@testing-library/react' + +import { NCBI, OBO, WORMBASE } from 'app/constants/datasetInfoLinks' + +import { InfoLinkProps } from './InfoLink' + +async function renderInfoLink({ id, value }: InfoLinkProps) { + const { InfoLink } = await import('./InfoLink') + + function InfoLinkWrapper() { + return + } + + const InfoLinkStub = createRemixStub([ + { + path: '/', + Component: InfoLinkWrapper, + }, + ]) + + render() +} + +describe('', () => { + it('should render placeholder if no value', async () => { + await renderInfoLink({ id: 123 }) + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should render ncbi link', async () => { + const id = 123 + const value = 'value' + await renderInfoLink({ id, value }) + + const link = screen.queryByRole('link', { name: value }) + expect(link).toBeVisible() + expect(link).toHaveAttribute('href', `${NCBI}${id}`) + }) + + it('should render ncbi link with id prefix', async () => { + const rawId = 123 + const id = `NCBITaxon:${rawId}` + const value = 'value' + await renderInfoLink({ id, value }) + + const link = screen.queryByRole('link', { name: value }) + expect(link).toBeVisible() + expect(link).toHaveAttribute('href', `${NCBI}${rawId}`) + }) + + it('should render wormbase link', async () => { + const id = 'WBStrain12345678' + const value = 'value' + await renderInfoLink({ id, value }) + + const link = screen.queryByRole('link', { name: value }) + expect(link).toBeVisible() + expect(link).toHaveAttribute('href', `${WORMBASE}${id}`) + }) + + it('should render obo link', async () => { + const id = 'foobar:123' + const value = 'value' + await renderInfoLink({ id, value }) + + const link = screen.queryByRole('link', { name: value }) + expect(link).toBeVisible() + expect(link).toHaveAttribute('href', `${OBO}${id.replaceAll(':', '_')}`) + }) + + it('should not render link if no pattern match', async () => { + const id = 'someid123' + const value = 'value' + await renderInfoLink({ id, value }) + + expect(screen.queryByRole('link', { name: value })).not.toBeInTheDocument() + expect(screen.getByText(value)).toBeInTheDocument() + }) +}) diff --git a/frontend/packages/data-portal/app/components/Dataset/SampleAndExperimentConditionsTable/components/InfoLink/InfoLink.tsx b/frontend/packages/data-portal/app/components/Dataset/SampleAndExperimentConditionsTable/components/InfoLink/InfoLink.tsx index 647a3d8e8..271400c7a 100644 --- a/frontend/packages/data-portal/app/components/Dataset/SampleAndExperimentConditionsTable/components/InfoLink/InfoLink.tsx +++ b/frontend/packages/data-portal/app/components/Dataset/SampleAndExperimentConditionsTable/components/InfoLink/InfoLink.tsx @@ -8,13 +8,12 @@ import { WORMBASE_PATTERN, } from 'app/constants/datasetInfoLinks' -export function InfoLink({ - value, - id, -}: { +export interface InfoLinkProps { value?: string | null - id?: string | null -}) { + id?: number | string | null +} + +export function InfoLink({ value, id }: InfoLinkProps) { if (!value) { return -- } @@ -22,7 +21,7 @@ export function InfoLink({ if (id) { let link if (typeof id === 'number') { - link = `${NCBI}${id as number}` + link = `${NCBI}${id}` } else if (id.match(NCBI_ONTOLOGY_PATTERN)) { link = `${NCBI}${id.replace('NCBITaxon:', '')}` } else if (id.match(WORMBASE_PATTERN)) { diff --git a/frontend/packages/data-portal/app/components/DepositionFilterBanner.test.tsx b/frontend/packages/data-portal/app/components/DepositionFilterBanner.test.tsx new file mode 100644 index 000000000..7b2821261 --- /dev/null +++ b/frontend/packages/data-portal/app/components/DepositionFilterBanner.test.tsx @@ -0,0 +1,83 @@ +import { jest } from '@jest/globals' +import { createRemixStub } from '@remix-run/testing' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { MockI18n } from './I18n.mock' + +const MOCK_DEPOSITION = { + id: 123, + title: 'Title', +} + +jest.unstable_mockModule('app/components/I18n', () => ({ + __esModule: true, + I18n: MockI18n, +})) + +const setDepositionIdMock = jest.fn() + +jest.unstable_mockModule('app/hooks/useQueryParam', () => ({ + useQueryParam: jest.fn().mockReturnValue([null, setDepositionIdMock]), +})) + +const setPreviousSingleDatasetParamsMock = jest.fn() + +jest.unstable_mockModule('app/state/filterHistory', () => ({ + useDepositionHistory: jest.fn(() => ({ + previousSingleDepositionParams: 'object=foo', + })), + + useSingleDatasetFilterHistory: jest.fn(() => ({ + previousSingleDatasetParams: `object=foo&deposition-id=${MOCK_DEPOSITION.id}`, + setPreviousSingleDatasetParams: setPreviousSingleDatasetParamsMock, + })), +})) + +async function renderDepositionFilterBanner() { + const { DepositionFilterBanner } = await import('./DepositionFilterBanner') + + function DepositionFilterBannerWrapper() { + return ( + + ) + } + + const DepositionFilterBannerStub = createRemixStub([ + { + path: '/', + Component: DepositionFilterBannerWrapper, + }, + ]) + + render() +} + +describe('', () => { + it('should render deposition url', async () => { + await renderDepositionFilterBanner() + + const text = screen.getByText('onlyDisplayingRunsWithAnnotations') + expect(text).toHaveAttribute( + 'data-values', + JSON.stringify({ + ...MOCK_DEPOSITION, + url: `/depositions/${MOCK_DEPOSITION.id}?object=foo`, + }), + ) + }) + + it('should remove filter on click', async () => { + await renderDepositionFilterBanner() + + await userEvent.click(screen.getByRole('button', { name: 'removeFilter' })) + + expect(setDepositionIdMock).toHaveBeenCalledWith(null) + expect(setPreviousSingleDatasetParamsMock).toHaveBeenCalledWith( + 'object=foo', + ) + }) +}) diff --git a/frontend/packages/data-portal/app/components/DepositionFilterBanner.tsx b/frontend/packages/data-portal/app/components/DepositionFilterBanner.tsx index 725a2fe26..0c73dc577 100644 --- a/frontend/packages/data-portal/app/components/DepositionFilterBanner.tsx +++ b/frontend/packages/data-portal/app/components/DepositionFilterBanner.tsx @@ -38,7 +38,7 @@ export function DepositionFilterBanner({ i18nKey={labelI18n} values={{ ...deposition, - url: `/depositions/${deposition.id}${previousSingleDepositionParams}`, + url: `/depositions/${deposition.id}?${previousSingleDepositionParams}`, }} tOptions={{ interpolation: { escapeValue: false } }} /> diff --git a/frontend/packages/data-portal/app/components/I18n.mock.tsx b/frontend/packages/data-portal/app/components/I18n.mock.tsx new file mode 100644 index 000000000..ccfe5fc34 --- /dev/null +++ b/frontend/packages/data-portal/app/components/I18n.mock.tsx @@ -0,0 +1,17 @@ +import type { I18nProps } from './I18n' + +/** + * Mock I18n component for rendering span element with i18n key as the content. + * Any values are passed in the data-attribute prop in case values need to be + * tested. + */ +export function MockI18n({ i18nKey, values, linkProps }: I18nProps) { + return ( + + {i18nKey} + + ) +} diff --git a/frontend/packages/data-portal/app/components/I18n.tsx b/frontend/packages/data-portal/app/components/I18n.tsx index 3c475a55a..1e640547c 100644 --- a/frontend/packages/data-portal/app/components/I18n.tsx +++ b/frontend/packages/data-portal/app/components/I18n.tsx @@ -5,7 +5,8 @@ import type { I18nKeys } from 'app/types/i18n' import { Link, VariantLinkProps } from './Link' -interface Props extends Omit, 'ns' | 'i18nKey'> { +export interface I18nProps + extends Omit, 'ns' | 'i18nKey'> { i18nKey: I18nKeys linkProps?: Partial } @@ -14,7 +15,7 @@ interface Props extends Omit, 'ns' | 'i18nKey'> { * Wrapper over `Trans` component with strong typing support for i18n keys. It * also includes a few default components for rendering inline JSX. */ -export function I18n({ i18nKey, components, linkProps, ...props }: Props) { +export function I18n({ i18nKey, components, linkProps, ...props }: I18nProps) { return ( ) +} + +jest.unstable_mockModule('app/components/I18n', () => ({ I18n: MockI18n })) + +const remixMock = new RemixMock() +const localStorageMock = new LocalStorageMock() + +describe('', () => { + beforeEach(() => { + jest.useRealTimers() + localStorageMock.reset() + remixMock.reset() + }) + + const paths = ['/', '/browse-data/datasets', '/browse-data/depositions'] + + paths.forEach((pathname) => { + it(`should render on ${pathname}`, async () => { + remixMock.mockPathname(pathname) + await renderMlChallengeBanner() + expect(screen.queryByRole('banner')).toBeVisible() + }) + }) + + it('should not render on blocked pages', async () => { + remixMock.mockPathname('/competition') + await renderMlChallengeBanner() + expect(screen.queryByRole('banner')).not.toBeInTheDocument() + }) + + it('should render challenge began message', async () => { + setMockTime('2024-12-01') + + await renderMlChallengeBanner() + expect(screen.getByText('mlCompetitionHasBegun')).toBeVisible() + }) + + it('should render challenge ending message', async () => { + setMockTime('2025-01-30') + + await renderMlChallengeBanner() + expect(screen.getByText('mlCompetitionEnding')).toBeVisible() + }) + + it('should render challenge ended message', async () => { + setMockTime('2025-02-07') + + await renderMlChallengeBanner() + expect(screen.getByText('mlCompetitionEnded')).toBeVisible() + }) + + it('should not render banner if was dismissed', async () => { + setMockTime('2024-12-01') + localStorageMock.mockValue('mlCompetitionHasBegun') + + await renderMlChallengeBanner() + expect(screen.queryByRole('banner')).not.toBeInTheDocument() + }) + + it('should render banner if last dismissed was previous state', async () => { + setMockTime('2025-01-30') + localStorageMock.mockValue('mlCompetitionHasBegun') + + await renderMlChallengeBanner() + expect(screen.getByRole('banner')).toBeVisible() + }) + + it('should dismiss banner on click', async () => { + setMockTime('2024-12-01') + + await renderMlChallengeBanner() + await getMockUser().click(screen.getByRole('button')) + + expect(screen.queryByRole('banner')).not.toBeInTheDocument() + expect(localStorageMock.setValue).toHaveBeenCalledWith( + 'mlCompetitionHasBegun', + ) + }) +}) diff --git a/frontend/packages/data-portal/app/components/Run/AnnotationObjectTable/components/ObjectIdLink/ObjectIdLink.test.tsx b/frontend/packages/data-portal/app/components/Run/AnnotationObjectTable/components/ObjectIdLink/ObjectIdLink.test.tsx new file mode 100644 index 000000000..db9f78406 --- /dev/null +++ b/frontend/packages/data-portal/app/components/Run/AnnotationObjectTable/components/ObjectIdLink/ObjectIdLink.test.tsx @@ -0,0 +1,49 @@ +import { createRemixStub } from '@remix-run/testing' +import { render, screen } from '@testing-library/react' + +import { GO, UNIPROTKB } from 'app/constants/annotationObjectIdLinks' + +async function renderObjectIdLink(id: string) { + const { ObjectIdLink } = await import('./ObjectIdLink') + + function ObjectIdLinkWrapper() { + return + } + + const ObjectIdLinkStub = createRemixStub([ + { + path: '/', + Component: ObjectIdLinkWrapper, + }, + ]) + + render() +} + +describe('', () => { + it('should render go link', async () => { + const id = 'GO:123' + await renderObjectIdLink(id) + + expect(screen.getByRole('link')).toHaveAttribute('href', `${GO}${id}`) + }) + + it('should render UniProtKB link', async () => { + const rawId = 123 + const id = `UniProtKB:${rawId}` + await renderObjectIdLink(id) + + expect(screen.getByRole('link')).toHaveAttribute( + 'href', + `${UNIPROTKB}${rawId}`, + ) + }) + + it('should not render link if not matched', async () => { + const id = 'test-id-123' + await renderObjectIdLink(id) + + expect(screen.queryByRole('link')).not.toBeInTheDocument() + expect(screen.getByText(id)).toBeVisible() + }) +}) diff --git a/frontend/packages/data-portal/app/components/SurveyBanner/SurveyBanner.test.tsx b/frontend/packages/data-portal/app/components/SurveyBanner/SurveyBanner.test.tsx new file mode 100644 index 000000000..a8aee296c --- /dev/null +++ b/frontend/packages/data-portal/app/components/SurveyBanner/SurveyBanner.test.tsx @@ -0,0 +1,71 @@ +import { beforeEach, jest } from '@jest/globals' +import { render, screen } from '@testing-library/react' + +import { MockI18n } from 'app/components/I18n.mock' +import { LocalStorageMock } from 'app/mocks/LocalStorage.mock' +import { RemixMock } from 'app/mocks/Remix.mock' +import { getMockUser, setMockTime } from 'app/utils/mock' + +async function renderSurveyBanner() { + const { SurveyBanner } = await import('./SurveyBanner') + render() +} + +jest.unstable_mockModule('app/components/I18n', () => ({ I18n: MockI18n })) + +const remixMock = new RemixMock() +const localStorageMock = new LocalStorageMock() + +describe('', () => { + beforeEach(() => { + jest.useRealTimers() + remixMock.reset() + remixMock.mockPathname('/datasets/123') + localStorageMock.reset() + }) + + const pages = ['/datasets/123', '/runs/123', '/depositions/123'] + + pages.forEach((pathname) => { + it(`should render on ${pathname}`, async () => { + remixMock.mockPathname(pathname) + await renderSurveyBanner() + expect(screen.queryByRole('banner')).toBeVisible() + }) + }) + + it('should not render on blocked pages', async () => { + remixMock.mockPathname('/') + await renderSurveyBanner() + expect(screen.queryByRole('banner')).not.toBeInTheDocument() + }) + + it('should dismiss on click', async () => { + const time = '2024-12-01' + setMockTime(time) + + await renderSurveyBanner() + await getMockUser().click(screen.getByRole('button')) + + expect(screen.queryByRole('banner')).not.toBeInTheDocument() + expect(localStorageMock.setValue).toHaveBeenCalledWith( + new Date(time).toISOString(), + ) + }) + + it('should not render banner if previously dismissed', async () => { + setMockTime('2024-12-01') + localStorageMock.mockValue(new Date('2024-11-25').toISOString()) + + await renderSurveyBanner() + expect(screen.queryByRole('banner')).not.toBeInTheDocument() + }) + + it('should render banner if dismissed >= 2 weeks ago', async () => { + setMockTime('2024-12-01') + localStorageMock.mockValue(new Date('2024-10-30').toISOString()) + + await renderSurveyBanner() + expect(screen.queryByRole('banner')).toBeVisible() + }) +}) diff --git a/frontend/packages/data-portal/app/components/SurveyBanner/SurveyBanner.tsx b/frontend/packages/data-portal/app/components/SurveyBanner/SurveyBanner.tsx index df003e81c..673a0a616 100644 --- a/frontend/packages/data-portal/app/components/SurveyBanner/SurveyBanner.tsx +++ b/frontend/packages/data-portal/app/components/SurveyBanner/SurveyBanner.tsx @@ -42,14 +42,15 @@ export function SurveyBanner() { const location = useLocation() + if (!BANNER_ALLOWLIST.some((regex) => regex.test(location.pathname))) { + return null + } + return (
!regex.test(location.pathname)) && - 'screen-716:hidden', )} > ) +} + +jest.unstable_mockModule('app/components/I18n', () => ({ I18n: MockI18n })) + +const remixMock = new RemixMock() + +function getTabs(count: number): TableLayoutTab[] { + return Array.from({ length: count }, (_, i) => ({ + title: `Test Tab ${i}`, + filteredCount: 50, + totalCount: 50, + countLabel: 'objects', + table:
Test Table {i}
, + })) +} + +describe('', () => { + beforeEach(() => { + remixMock.reset() + }) + + it('should render no tabs if there is only one tab', async () => { + const tabs = getTabs(1) + await renderTablePageLayout(tabs) + expect(screen.queryByRole('tablist')).not.toBeInTheDocument() + }) + + it('should render tabs if > 1 tabs', async () => { + const tabs = getTabs(2) + await renderTablePageLayout(tabs) + expect(screen.getByRole('tablist')).toBeVisible() + }) + + it('should render active tab', async () => { + const tabs = getTabs(2) + const activeTab = tabs[1] + remixMock.mockSearchParams( + new URLSearchParams([[QueryParams.TableTab, activeTab.title]]), + ) + + await renderTablePageLayout(tabs) + expect(screen.getByText(activeTab.title, { selector: 'p' })).toBeVisible() + }) + + it('should render no total results if total count is 0', async () => { + const tabs = getTabs(1) + tabs[0].totalCount = 0 + tabs[0].noTotalResults =
no total results
+ + await renderTablePageLayout(tabs) + expect(screen.getByText('no total results')).toBeVisible() + }) + + it('should render no filter results if filtered count is 0', async () => { + const tabs = getTabs(1) + tabs[0].filteredCount = 0 + tabs[0].noFilteredResults =

no filter results

+ + await renderTablePageLayout(tabs) + expect(screen.getByText('no filter results')).toBeVisible() + }) + + it('should render pagination if greater than max page amount', async () => { + const tabs = getTabs(1) + + await renderTablePageLayout(tabs) + expect(screen.getByTestId(TestIds.Pagination)).toBeVisible() + }) + + it('should not render pagination if less than max page amount', async () => { + const tabs = getTabs(1) + tabs[0].totalCount = 10 + tabs[0].filteredCount = 10 + + await renderTablePageLayout(tabs) + expect(screen.queryByTestId(TestIds.Pagination)).not.toBeInTheDocument() + }) + + it('should remove page param if greater than max pages', async () => { + const tabs = getTabs(1) + remixMock.mockSearchParams(new URLSearchParams([['page', '100']])) + + await renderTablePageLayout(tabs) + expect(remixMock.setParams).toHaveBeenCalled() + expect(remixMock.getLastSetParams()?.toString()).toEqual('') + }) + + it('should open next page on click', async () => { + const tabs = getTabs(1) + await renderTablePageLayout(tabs) + await userEvent.click( + screen + .getByTestId(TestIds.Pagination) + .querySelector('[data-order=last]')!, + ) + + expect(remixMock.getLastSetParams()?.toString()).toEqual('page=2') + }) + + it('should disable previous page button on first page', async () => { + const tabs = getTabs(1) + await renderTablePageLayout(tabs) + + expect( + screen + .queryByTestId(TestIds.Pagination) + ?.querySelector('[data-order=first]'), + ).toHaveAttribute('disabled') + }) + + it('should open previous page on click', async () => { + const tabs = getTabs(1) + remixMock.mockSearchParams(new URLSearchParams([['page', '2']])) + await renderTablePageLayout(tabs) + + await userEvent.click( + screen + .getByTestId(TestIds.Pagination) + .querySelector('[data-order=first]')!, + ) + + expect(remixMock.getLastSetParams()?.toString()).toEqual('page=1') + }) + + it('should disable next button on last page', async () => { + const tabs = getTabs(1) + remixMock.mockSearchParams(new URLSearchParams([['page', '3']])) + await renderTablePageLayout(tabs) + + await userEvent.click( + screen + .getByTestId(TestIds.Pagination) + .querySelector('[data-order=first]')!, + ) + + expect( + screen + .queryByTestId(TestIds.Pagination) + ?.querySelector('[data-order=last]'), + ).toHaveAttribute('disabled') + }) + + it('should change page on click', async () => { + const tabs = getTabs(1) + await renderTablePageLayout(tabs) + await userEvent.click(screen.getByText('3', { selector: 'li' })) + + expect(remixMock.getLastSetParams()?.toString()).toEqual('page=3') + }) +}) diff --git a/frontend/packages/data-portal/app/components/Tabs.tsx b/frontend/packages/data-portal/app/components/Tabs.tsx index f3531fced..cbf2fd44b 100644 --- a/frontend/packages/data-portal/app/components/Tabs.tsx +++ b/frontend/packages/data-portal/app/components/Tabs.tsx @@ -1,5 +1,6 @@ -import Tab from '@mui/material/Tab' -import MUITabs, { TabsProps } from '@mui/material/Tabs' +// TODO path imports not working in unit tests +// eslint-disable-next-line cryoet-data-portal/no-root-mui-import +import { Tab, Tabs as MUITabs, TabsProps } from '@mui/material' import { ReactNode } from 'react' import { cns } from 'app/utils/cns' diff --git a/frontend/packages/data-portal/app/mocks/LocalStorage.mock.ts b/frontend/packages/data-portal/app/mocks/LocalStorage.mock.ts new file mode 100644 index 000000000..712ebd6d9 --- /dev/null +++ b/frontend/packages/data-portal/app/mocks/LocalStorage.mock.ts @@ -0,0 +1,30 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { jest } from '@jest/globals' + +export class LocalStorageMock { + setValue = jest.fn() + + useLocalStorageValue = jest.fn() + + constructor() { + jest.mock('@react-hookz/web', () => ({ + useLocalStorageValue: this.useLocalStorageValue, + })) + + this.reset() + } + + mockValue(value: string | null) { + this.useLocalStorageValue.mockReturnValue({ + set: this.setValue, + value, + }) + } + + reset() { + this.useLocalStorageValue.mockReturnValue({ + set: this.setValue, + value: null, + }) + } +} diff --git a/frontend/packages/data-portal/app/mocks/Remix.mock.ts b/frontend/packages/data-portal/app/mocks/Remix.mock.ts new file mode 100644 index 000000000..257acb5dc --- /dev/null +++ b/frontend/packages/data-portal/app/mocks/Remix.mock.ts @@ -0,0 +1,62 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { jest } from '@jest/globals' + +const DEFAULT_PATHNAME = '/' + +type SetURLSearchParamsValue = + | URLSearchParams + | ((prev: URLSearchParams) => URLSearchParams) + +export class RemixMock { + useLocation = jest.fn() + + useNavigation = jest.fn() + + setParams = jest.fn() + + useSearchParams = jest.fn() + + constructor() { + jest.mock('@remix-run/react', () => ({ + Link: jest.fn(), + useLocation: this.useLocation, + useNavigation: this.useNavigation, + useSearchParams: this.useSearchParams, + })) + + this.reset() + } + + mockPathname(pathname: string) { + this.useLocation.mockReturnValue({ pathname }) + } + + mockSearchParams(value: URLSearchParams) { + this.useSearchParams.mockReturnValue([value, this.setParams]) + } + + getLastSetParams(prevParams = new URLSearchParams()) { + const setParams = this.setParams.mock.lastCall?.[0] as + | SetURLSearchParamsValue + | undefined + + const params = + typeof setParams === 'function' ? setParams(prevParams) : setParams + + return params + } + + reset() { + this.useLocation.mockReturnValue({ pathname: DEFAULT_PATHNAME }) + + this.useNavigation.mockReturnValue({ + state: 'idle', + }) + + this.setParams = jest.fn() + this.useSearchParams.mockReturnValue([ + new URLSearchParams(), + this.setParams, + ]) + } +} diff --git a/frontend/packages/data-portal/app/utils/mock.ts b/frontend/packages/data-portal/app/utils/mock.ts new file mode 100644 index 000000000..ee323043d --- /dev/null +++ b/frontend/packages/data-portal/app/utils/mock.ts @@ -0,0 +1,18 @@ +/* eslint-disable import/no-extraneous-dependencies */ + +import { jest } from '@jest/globals' +import userEvent from '@testing-library/user-event' + +export function setMockTime(time: string) { + jest.useFakeTimers().setSystemTime(new Date(time)) +} + +/** + * Util for getting mock user without a delay. This is required for tests that + * use fake timers, otherwise the test will hang indefinitely: + * + * https://github.com/testing-library/user-event/issues/833#issuecomment-1013632841 + */ +export function getMockUser() { + return userEvent.setup({ delay: null }) +} diff --git a/frontend/packages/data-portal/jest.config.cjs b/frontend/packages/data-portal/jest.config.cjs index b16426c25..a731aab92 100644 --- a/frontend/packages/data-portal/jest.config.cjs +++ b/frontend/packages/data-portal/jest.config.cjs @@ -8,6 +8,7 @@ module.exports = { moduleNameMapper: { '^app/(.*)$': '/app/$1', '^(.*).png$': '/app/utils/fileMock.ts', + '^(.*).module.css$': 'identity-obj-proxy', }, transform: { diff --git a/frontend/packages/data-portal/package.json b/frontend/packages/data-portal/package.json index 7c4098a3f..98a8b38b6 100644 --- a/frontend/packages/data-portal/package.json +++ b/frontend/packages/data-portal/package.json @@ -122,6 +122,7 @@ "eslint": "^8.51.0", "eslint-config-cryoet-data-portal": "workspace:*", "eslint-plugin-cryoet-data-portal": "workspace:*", + "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "playwright": "^1.41.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4b80069ea..060818074 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -296,6 +296,9 @@ importers: eslint-plugin-cryoet-data-portal: specifier: workspace:* version: link:../eslint-plugin + identity-obj-proxy: + specifier: ^3.0.0 + version: 3.0.0 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@20.8.4)(ts-node@10.9.1) @@ -7212,6 +7215,10 @@ packages: engines: {node: '>=6'} dev: true + /harmony-reflect@1.6.2: + resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==} + dev: true + /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -7475,6 +7482,13 @@ packages: postcss: 8.4.31 dev: true + /identity-obj-proxy@3.0.0: + resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==} + engines: {node: '>=4'} + dependencies: + harmony-reflect: 1.6.2 + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}