add more tests. Push code coverage to codecov

This commit is contained in:
Sudhanshu Gautam 2021-01-13 18:41:12 +05:30
parent d6283eca43
commit 7d24fcfd4b
15 changed files with 382 additions and 51 deletions

View file

@ -38,4 +38,7 @@ jobs:
run: yarn lint run: yarn lint
- name: Test 🔧 - name: Test 🔧
run: yarn test run: yarn test -- --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1

View file

@ -0,0 +1,60 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
import React from 'react';
import { fireEvent, render, RenderResult, waitFor } from '@testing-library/react';
import Header from './Header';
const mockChangeLanguage = jest.fn();
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
changeLanguage: mockChangeLanguage,
language: undefined,
},
}),
}));
describe('Header', () => {
it('opens the language menu list when the button is clicked', async () => {
const { getByTestId } = render(<Header />);
const languageMenuToggle = getByTestId('language-menu-toggle');
expect(languageMenuToggle).toBeInTheDocument();
fireEvent.click(languageMenuToggle);
expect(getByTestId('locale-en')).toBeInTheDocument();
});
describe('with open language menu', () => {
let component: RenderResult | null;
beforeEach(() => {
component = render(<Header />);
const languageMenuToggle = component.getByTestId('language-menu-toggle');
fireEvent.click(languageMenuToggle);
});
it('changes the language when an item is clicked', () => {
fireEvent.click(component!.getByTestId('locale-en'));
expect(mockChangeLanguage).toBeCalledWith('en');
});
it('closes the menu with escape', async () => {
fireEvent.keyDown(component!.getByTestId('language-menu'), { key: 'Escape' });
await waitFor(() =>
expect(
component!.container.querySelector('[data-testid="locale-en"]')
).not.toBeInTheDocument()
);
});
});
});

View file

@ -37,6 +37,7 @@ const Header: FunctionComponent = () => {
<div style={{ flexGrow: 1 }} /> <div style={{ flexGrow: 1 }} />
<Box position="relative"> <Box position="relative">
<Button <Button
data-testid="language-menu-toggle"
aria-controls="language-menu" aria-controls="language-menu"
aria-haspopup="true" aria-haspopup="true"
color="secondary" color="secondary"
@ -51,12 +52,18 @@ const Header: FunctionComponent = () => {
</Button> </Button>
<Menu <Menu
id="language-menu" id="language-menu"
data-testid="language-menu"
open={showLanguageSwitch} open={showLanguageSwitch}
anchorEl={languageSwitchAnchorEl.current} anchorEl={languageSwitchAnchorEl.current}
onClose={() => toggleLanguageSwitch(false)} onClose={() => toggleLanguageSwitch(false)}
> >
{Object.keys(locales).map((l) => ( {Object.keys(locales).map((l) => (
<MenuItem key={l} value={l} onClick={() => handleLanguageChange(l)}> <MenuItem
key={l}
value={l}
onClick={() => handleLanguageChange(l)}
data-testid={`locale-${l}`}
>
<Checkbox size="small" checked={i18n.language === l} /> {t(locales[l])} <Checkbox size="small" checked={i18n.language === l} /> {t(locales[l])}
</MenuItem> </MenuItem>
))} ))}

View file

@ -38,18 +38,58 @@ const testProfile1: Profile = {
const testProfile2: Profile = { const testProfile2: Profile = {
build_at: '2020-12-08 13:51:01', build_at: '2020-12-08 13:51:01',
target: 'TEST_TARGET', target: 'TEST_TARGET_2',
version_code: 'TEST_VERSION_CODE', version_code: 'TEST_VERSION_CODE_2',
version_number: 'TEST_VERSION_NUMBER', version_number: 'TEST_VERSION_NUMBER_2',
id: 'TEST_ID', id: 'TEST_ID_2',
titles: [ titles: [
{ {
title: 'TEST_TITLE2', title: 'TEST_TITLE2',
}, },
], ],
images: [
{
name: 'factorytestimage',
type: 'factory',
sha256: 'sha256',
},
{
name: 'kerneltestimage',
type: 'kernel',
sha256: 'sha256',
},
{
name: 'roottestimage',
type: 'root',
sha256: 'sha256',
},
{
name: 'tftptestimage',
type: 'tftp',
sha256: 'sha256',
},
{
name: 'sdcardtestimage',
type: 'sdcard',
sha256: 'sha256',
},
{
name: 'randomtestimage',
type: 'random',
sha256: 'sha256',
},
],
}; };
const testProfiles = [testProfile1, testProfile2]; const testProfile3: Profile = {
build_at: '2020-12-08 13:51:01',
target: 'TEST_TARGET_3',
version_code: 'TEST_VERSION_CODE_3',
version_number: 'TEST_VERSION_NUMBER_3',
id: 'TEST_ID_3',
};
const testProfiles = [testProfile1, testProfile2, testProfile3];
jest.mock('react-i18next', () => ({ jest.mock('react-i18next', () => ({
useTranslation: () => ({ useTranslation: () => ({
@ -67,7 +107,7 @@ describe('Profile Details', () => {
mockAxios.reset(); mockAxios.reset();
}); });
it('renders the component, sends a get request and displays and shows the profile target', async () => { it('renders the component and sends a get request', async () => {
render( render(
<ProfileDetails <ProfileDetails
selectedProfile={{ selectedProfile={{
@ -123,11 +163,11 @@ describe('Profile Details', () => {
it('renders download links correctly', async () => { it('renders download links correctly', async () => {
for (const p of testProfiles) { for (const p of testProfiles) {
render( const { getAllByTestId } = render(
<ProfileDetails <ProfileDetails
selectedProfile={{ selectedProfile={{
id: 'TEST_ID1', id: p.id,
target: 'TEST_TARGET', target: p.target,
}} }}
selectedVersion={testVersion} selectedVersion={testVersion}
/> />
@ -136,7 +176,7 @@ describe('Profile Details', () => {
mockAxios.onGet().replyOnce(200, p); mockAxios.onGet().replyOnce(200, p);
await waitFor(() => { await waitFor(() => {
const downloadLinks = screen.getAllByTestId('download_link'); const downloadLinks = getAllByTestId('download_link');
let expectedItems = p.images?.map((i) => i.type) || []; let expectedItems = p.images?.map((i) => i.type) || [];
downloadLinks.forEach((downloadLink) => { downloadLinks.forEach((downloadLink) => {
expectedItems = expectedItems.filter((i) => i !== downloadLink.textContent); expectedItems = expectedItems.filter((i) => i !== downloadLink.textContent);

View file

@ -20,16 +20,13 @@ import { useTranslation } from 'react-i18next';
import { ProfilesEntity } from '../../../types/overview'; import { ProfilesEntity } from '../../../types/overview';
import { Profile, TitlesEntity } from '../../../types/profile'; import { Profile, TitlesEntity } from '../../../types/profile';
import config from '../../../config'; import config from '../../../config';
import { getTitle } from '../utils/title';
type Props = { type Props = {
selectedVersion: string; selectedVersion: string;
selectedProfile: ProfilesEntity; selectedProfile: ProfilesEntity;
}; };
const getTitle = (title: TitlesEntity) => {
return title.title || `${title.vendor} ${title.model}`;
};
const profilesData: { [key: string]: Profile } = {}; const profilesData: { [key: string]: Profile } = {};
const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedProfile }) => { const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedProfile }) => {
@ -75,11 +72,9 @@ const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedPro
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
if (selectedVersion && selectedProfile) {
getProfileData().then((_profileData) => { getProfileData().then((_profileData) => {
if (mounted && !isEqual(profile, _profileData)) setProfileData(_profileData); if (mounted && !isEqual(profile, _profileData)) setProfileData(_profileData);
}); });
}
return () => { return () => {
mounted = false; mounted = false;
@ -143,14 +138,10 @@ const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedPro
</Link> </Link>
); );
}) })
.reduce((acc: ReactNode, curr: ReactNode) => [ .reduce((acc: ReactNode, curr: ReactNode, i: number) => [
acc, acc,
// eslint-disable-next-line react/no-array-index-key // eslint-disable-next-line react/no-array-index-key
<Box <Box display="inline-block" marginRight={2} key={i} />,
display="inline-block"
marginRight={2}
key={(acc?.toString() ?? '') + (curr?.toString() ?? '')}
/>,
curr, curr,
])} ])}
</TableCell> </TableCell>
@ -177,7 +168,7 @@ const ProfileDetails: FunctionComponent<Props> = ({ selectedVersion, selectedPro
.replace('{target}', profile.target) .replace('{target}', profile.target)
.replace('{version}', profile.version_number)}/${i.name}`; .replace('{version}', profile.version_number)}/${i.name}`;
return ( return (
<TableRow key={downloadURL}> <TableRow key={downloadURL + i.type}>
<TableCell> <TableCell>
<Link href={downloadURL} target="_blank" data-testid="download_link"> <Link href={downloadURL} target="_blank" data-testid="download_link">
<Button endIcon={<CloudDownload />} variant="contained" color="primary"> <Button endIcon={<CloudDownload />} variant="contained" color="primary">

View file

@ -0,0 +1,118 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
import React from 'react';
import { fireEvent, render, waitFor, screen } from '@testing-library/react';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { Overview } from '../../../types/overview';
import ProfileSearch from './ProfileSearch';
const mockAxios = new MockAdapter(axios);
const testVersion = 'TEST_VERSION';
const testOverview1: Overview = {
image_url: 'TEST_IMAGE_URL',
release: 'TEST_RELEASE',
profiles: [
{
id: 'TEST_PROFILE_ID',
target: 'TEST_TARGET',
titles: [
{
title: 'TEST_TITLE1',
},
{
title: 'TEST_TITLE2',
},
{
vendor: 'TEST_VENDOR',
model: 'TEST_MODEL',
},
],
},
],
};
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
changeLanguage: (l: string) => {},
language: 'en',
},
}),
}));
describe('Search Field', () => {
const mockOnProfileChange = jest.fn();
const props = {
selectedVersion: testVersion,
onProfileChange: mockOnProfileChange,
};
afterEach(() => {
mockAxios.reset();
});
it('renders the component and sends a get request', async () => {
render(<ProfileSearch {...props} />);
mockAxios.onGet().replyOnce(200, testOverview1);
await waitFor(() => {
expect(mockAxios.history.get).toHaveLength(1);
});
});
it('renders autocomplete and selects right option', async () => {
const { getByTestId } = render(<ProfileSearch {...props} />);
mockAxios.onGet().replyOnce(200, testOverview1);
let autocomplete: HTMLElement | null;
await waitFor(() => {
autocomplete = getByTestId('search-autocomplete');
});
const input = autocomplete!.querySelector('input');
expect(input).toBeInTheDocument();
autocomplete!.focus();
fireEvent.change(input!, { target: { value: 'TESTVENMODE' } });
fireEvent.keyDown(autocomplete!, { key: 'ArrowDown' });
fireEvent.keyDown(autocomplete!, { key: 'Enter' });
await waitFor(() => {
expect(input!.value).toEqual('TEST_VENDOR TEST_MODEL');
});
});
it('clearing the autocomplete should not hide the last selected profile', async () => {
const { getByTestId } = render(<ProfileSearch {...props} />);
mockAxios.onGet().replyOnce(200, testOverview1);
let autocomplete: HTMLElement | null;
await waitFor(() => {
autocomplete = getByTestId('search-autocomplete');
});
const input = autocomplete!.querySelector('input');
expect(input).toBeInTheDocument();
autocomplete!.focus();
fireEvent.change(input!, { target: { value: 'TESTVENMODE' } });
fireEvent.keyPress(input!, { key: 'Enter' });
expect(mockOnProfileChange).toBeCalled();
mockOnProfileChange.mockReset();
const clearButton = autocomplete!.querySelector('.MuiAutocomplete-clearIndicator');
expect(clearButton).toBeInTheDocument();
fireEvent.click(clearButton!);
await waitFor(() => expect(mockOnProfileChange).not.toBeCalled());
});
});

View file

@ -7,6 +7,7 @@ import { matchSorter } from 'match-sorter';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Overview, ProfilesEntity } from '../../../types/overview'; import { Overview, ProfilesEntity } from '../../../types/overview';
import { getTitle } from '../utils/title';
type Props = { type Props = {
selectedVersion: string; selectedVersion: string;
@ -17,7 +18,7 @@ type SearchData = { value: ProfilesEntity; search: string; title: string };
const overviewData: { [key: string]: Overview } = {}; const overviewData: { [key: string]: Overview } = {};
const SearchField: FunctionComponent<Props> = ({ selectedVersion, onProfileChange }) => { const ProfileSearch: FunctionComponent<Props> = ({ selectedVersion, onProfileChange }) => {
const [searchData, setSearchData] = useState<SearchData[]>([]); const [searchData, setSearchData] = useState<SearchData[]>([]);
const [working, toggleWorking] = useState<boolean>(true); const [working, toggleWorking] = useState<boolean>(true);
const { t } = useTranslation(); const { t } = useTranslation();
@ -38,12 +39,13 @@ const SearchField: FunctionComponent<Props> = ({ selectedVersion, onProfileChang
toggleWorking(false); toggleWorking(false);
overview.profiles?.forEach((profile) => { overview.profiles.forEach((profile) => {
profile.titles?.forEach((title) => { profile.titles.forEach((titleEntity) => {
const title = getTitle(titleEntity);
searchDataArray.push({ searchDataArray.push({
value: profile, value: profile,
search: profile.id + title.title, search: profile.id + title,
title: title.title || `${title.vendor} ${title.model}`, title,
}); });
}); });
}); });
@ -58,8 +60,7 @@ const SearchField: FunctionComponent<Props> = ({ selectedVersion, onProfileChang
}, [getSearchData, searchData, selectedVersion]); }, [getSearchData, searchData, selectedVersion]);
const handleProfileSelect = (_: unknown, searchDataRow: SearchData | null) => { const handleProfileSelect = (_: unknown, searchDataRow: SearchData | null) => {
if (!searchDataRow) return; if (searchDataRow) onProfileChange(searchDataRow.value);
onProfileChange(searchDataRow.value);
}; };
const getOptionLabel = (option: SearchData) => option.title; const getOptionLabel = (option: SearchData) => option.title;
@ -85,6 +86,7 @@ const SearchField: FunctionComponent<Props> = ({ selectedVersion, onProfileChang
return ( return (
<Autocomplete <Autocomplete
data-testid="search-autocomplete"
options={searchData} options={searchData}
getOptionLabel={getOptionLabel} getOptionLabel={getOptionLabel}
renderInput={renderInput} renderInput={renderInput}
@ -94,4 +96,4 @@ const SearchField: FunctionComponent<Props> = ({ selectedVersion, onProfileChang
); );
}; };
export default SearchField; export default ProfileSearch;

View file

@ -0,0 +1,46 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import VersionSelector from './VersionSelector';
jest.mock('../../../config', () => ({
versions: { TEST_1: 'data/TEST_1', TEST_2: 'data/TEST_2' },
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
changeLanguage: (l: string) => {},
language: 'en',
},
}),
}));
describe('Search Field', () => {
const mockOnVersionChange = jest.fn();
const props = {
selectedVersion: 'TEST_1',
onVersionChange: mockOnVersionChange,
};
it('renders the component and sends a get request', async () => {
const { getByText, getByRole } = render(<VersionSelector {...props} />);
const select = await waitFor(() => getByRole('button'));
expect(select).toBeInTheDocument();
fireEvent.mouseDown(select);
const optionTest2 = await waitFor(() => getByText('TEST_2'));
expect(optionTest2).toBeInTheDocument();
fireEvent.click(optionTest2);
expect(mockOnVersionChange.mock.calls).toHaveLength(1);
});
});

View file

@ -26,6 +26,7 @@ const VersionSelector: FunctionComponent<Props> = ({ selectedVersion, onVersionC
labelId="version-select-label" labelId="version-select-label"
value={selectedVersion} value={selectedVersion}
onChange={handleVersionChange} onChange={handleVersionChange}
data-testid="version-select"
> >
{Object.keys(versions).map((version) => ( {Object.keys(versions).map((version) => (
<MenuItem value={version} key={version}> <MenuItem value={version} key={version}>

View file

@ -0,0 +1,31 @@
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import Home from './home';
import VersionSelector from './components/VersionSelector';
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (k: string) => k,
i18n: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
changeLanguage: (l: string) => {},
language: 'en',
},
}),
}));
describe('Home', () => {
it('renders the component and sends a get request', async () => {
const { getByTestId, findByRole } = render(<Home />);
const versionSelect = getByTestId('version-select');
fireEvent.change(versionSelect, { value: '1234' });
expect(versionSelect).toBeInTheDocument();
});
});

View file

@ -1,7 +1,7 @@
import React, { FunctionComponent, useState } from 'react'; import React, { FunctionComponent, useState } from 'react';
import { Container, Paper, Box, Typography, Grid } from '@material-ui/core'; import { Container, Paper, Box, Typography, Grid } from '@material-ui/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import SearchField from './components/SearchField'; import ProfileSearch from './components/ProfileSearch';
import VersionSelector from './components/VersionSelector'; import VersionSelector from './components/VersionSelector';
import ProfileDetails from './components/ProfileDetails'; import ProfileDetails from './components/ProfileDetails';
import config from '../../config'; import config from '../../config';
@ -12,14 +12,6 @@ const Home: FunctionComponent = () => {
const [selectedProfile, setSelectedProfile] = useState<ProfilesEntity | null>(); const [selectedProfile, setSelectedProfile] = useState<ProfilesEntity | null>();
const { t } = useTranslation(); const { t } = useTranslation();
const onVersionChange = (version: string) => {
setSelectedVersion(version);
};
const onProfileChange = (profile: ProfilesEntity) => {
setSelectedProfile(profile);
};
return ( return (
<Container> <Container>
<Box paddingY={4}> <Box paddingY={4}>
@ -37,12 +29,17 @@ const Home: FunctionComponent = () => {
</Box> </Box>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid item xs> <Grid item xs>
<SearchField selectedVersion={selectedVersion} onProfileChange={onProfileChange} /> <ProfileSearch
selectedVersion={selectedVersion}
onProfileChange={setSelectedProfile}
data-testid="profile-search"
/>
</Grid> </Grid>
<Grid item xs={3}> <Grid item xs={3}>
<VersionSelector <VersionSelector
data-testid="version-selector"
selectedVersion={selectedVersion} selectedVersion={selectedVersion}
onVersionChange={onVersionChange} onVersionChange={setSelectedVersion}
/> />
</Grid> </Grid>
</Grid> </Grid>

View file

@ -0,0 +1,29 @@
import { TitlesEntity } from '../../../types/overview';
import { getTitle } from './title';
describe('getTitle', () => {
it('returns the correct title', () => {
const data: {
title: TitlesEntity;
expected: string;
}[] = [
{
title: {
title: 'TEST',
},
expected: 'TEST',
},
{
title: {
vendor: 'TEST',
model: 'MODEL',
},
expected: 'TEST MODEL',
},
];
data.forEach((element) => {
expect(getTitle(element.title)).toStrictEqual(element.expected);
});
});
});

View file

@ -0,0 +1,6 @@
import { TitlesEntity } from '../../../types/overview';
export const getTitle = (title: TitlesEntity): string =>
title.title || `${title.vendor} ${title.model}`;
export default { getTitle };

View file

@ -1,12 +1,12 @@
export interface Overview { export interface Overview {
image_url: string; image_url: string;
profiles?: ProfilesEntity[] | null; profiles: ProfilesEntity[];
release: string; release: string;
} }
export interface ProfilesEntity { export interface ProfilesEntity {
id: string; id: string;
target: string; target: string;
titles?: TitlesEntity[] | null; titles: TitlesEntity[];
} }
export interface TitlesEntity { export interface TitlesEntity {
title?: string; title?: string;

View file

@ -5,10 +5,10 @@ export interface Profile {
device_packages?: string[] | null; device_packages?: string[] | null;
id: string; id: string;
image_prefix?: string; image_prefix?: string;
images?: ImagesEntity[] | null; images: ImagesEntity[];
metadata_version?: number; metadata_version?: number;
target: string; target: string;
titles?: TitlesEntity[] | null; titles: TitlesEntity[];
version_code: string; version_code: string;
version_number: string; version_number: string;
} }