Skip to content

Commit

Permalink
Downloads tests and improvements (#1116)
Browse files Browse the repository at this point in the history
* Convert components to typescript and create a snapshot test

* More tests for downloads container
nukeop authored Dec 5, 2021
1 parent 86fd3b2 commit d64590e
Showing 16 changed files with 643 additions and 248 deletions.
1 change: 1 addition & 0 deletions packages/app/__mocks__/@nuclear/core.ts
Original file line number Diff line number Diff line change
@@ -26,6 +26,7 @@ module.exports = {
React: jest.fn(),
ReactDOM: jest.fn()
}),
setOption: jest.fn(),
getOption: () => '',
rest: {
LastFmApi: class {
14 changes: 7 additions & 7 deletions packages/app/app/actions/downloads.ts
Original file line number Diff line number Diff line change
@@ -15,13 +15,13 @@ export const DOWNLOAD_ERROR = 'DOWNLOAD_ERROR';
export const DOWNLOAD_REMOVED = 'DOWNLOAD_REMOVED';
export const CLEAR_FINISHED_DOWNLOADS = 'CLEAR_FINISHED_DOWNLOADS';

export const DownloadStatus = {
WAITING: 'Waiting',
STARTED: 'Started',
PAUSED: 'Paused',
FINISHED: 'Finished',
ERROR: 'Error'
};
export enum DownloadStatus {
WAITING = 'Waiting',
STARTED = 'Started',
PAUSED = 'Paused',
FINISHED = 'Finished',
ERROR = 'Error'
}

const changePropertyForItem = ({downloads, uuid, propertyName='status', value}) => {
const changedItem = _.find(downloads, (item) => item.track.uuid === uuid);
2 changes: 1 addition & 1 deletion packages/app/app/actions/settings.ts
Original file line number Diff line number Diff line change
@@ -20,7 +20,7 @@ export function setBooleanOption(option, state, fromMain?) {
};
}

export function setStringOption(option, state, fromMain) {
export function setStringOption(option, state, fromMain?) {
setOption(option, state);

return {
67 changes: 0 additions & 67 deletions packages/app/app/components/Downloads/DownloadsHeader/index.js

This file was deleted.

57 changes: 57 additions & 0 deletions packages/app/app/components/Downloads/DownloadsHeader/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React, { useCallback } from 'react';
import _ from 'lodash';
import {
Button,
Icon,
Segment
} from 'semantic-ui-react';
import { remote } from 'electron';
import { useTranslation } from 'react-i18next';

import { setStringOption } from '../../../actions/settings';
import styles from './styles.scss';

type DownloadsHeaderProps = {
directory: string;
setStringOption: typeof setStringOption;
};

const DownloadsHeader: React.FC<DownloadsHeaderProps> = ({
directory,
setStringOption
}) => {
const { t } = useTranslation('settings');
const setDirectory = useCallback(async () => {
const dialogResult = await remote.dialog.showOpenDialog({
properties: ['openDirectory']
});
if (!dialogResult.canceled && !_.isEmpty(dialogResult.filePaths)) {
setStringOption(
'downloads.dir',
_.head(dialogResult.filePaths)
);
}
}, [setStringOption]);

return (
<Segment className={styles.downloads_header}>
<span className={styles.label}>
{t('saving-in')}
<span className={styles.directory}>
{_.isEmpty(directory) ? remote.app.getPath('downloads') : directory}
</span>
</span>
<Button
icon
inverted
labelPosition='left'
onClick={setDirectory}
>
<Icon name='folder open' />
{t('downloads-dir-button')}
</Button>
</Segment>
);
};

export default DownloadsHeader;
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { Icon, Table } from 'semantic-ui-react';
import { Icon, SemanticICONS, Table } from 'semantic-ui-react';
import _ from 'lodash';

import { DownloadStatus } from '../../../actions/downloads';
import styles from './styles.scss';

const StatusIcon = props => {
switch (props.status) {
export type DownloadsItemProps = {
item: {
status: DownloadStatus;
completion: number;
track: {
uuid: string;
name: string;
artist: string | {
name: string;
};
}
};

resumeDownload: (id: string) => void;
pauseDownload: (id: string) => void;
removeDownload: (id: string) => void;
}

type StatusIconProps = Pick<DownloadsItemProps['item'], 'status'>;

const StatusIcon: React.FC<StatusIconProps> = ({ status }) => {
switch (status) {
case 'Waiting':
return <Icon name='hourglass start' />;
case 'Paused':
@@ -21,10 +41,11 @@ const StatusIcon = props => {
}
};

const renderAction = (name, callback) => (
const renderAction = (name: SemanticICONS, callback: React.MouseEventHandler) => (
<a
onClick={callback}
data-testid='download-action'
href='#'
onClick={callback}
>
<Icon fitted name={name} />
</a>
@@ -44,22 +65,8 @@ const ActionIcon = props => {
}
};

StatusIcon.propTypes = {
status: PropTypes.string.isRequired
};

ActionIcon.propTypes = {
item: PropTypes.PropTypes.shape({
status: PropTypes.string.isRequired,
track: PropTypes.PropTypes.shape({
uuid: PropTypes.string.isRequired
})
}),
resumeDownload: PropTypes.func.isRequired,
pauseDownload: PropTypes.func.isRequired
};

const DownloadsItem = ({
const DownloadsItem: React.FC<DownloadsItemProps> = ({
item,
resumeDownload,
pauseDownload,
@@ -84,27 +91,21 @@ const DownloadsItem = ({
{_.round(item.completion * 100, 0) + '%'}
</Table.Cell>
<Table.Cell className={styles.item_buttons}>
<ActionIcon resumeDownload={onResumeClick} pauseDownload={onPauseClick} item={item} />
<a href='#' onClick={onRemoveClick}>
<ActionIcon
resumeDownload={onResumeClick}
pauseDownload={onPauseClick}
item={item}
/>
<a
data-testid='remove-download'
href='#'
onClick={onRemoveClick}
>
<Icon fitted name='times' />
</a>
</Table.Cell>
</Table.Row>
);
};

DownloadsItem.propTypes = {
item: PropTypes.shape({

}),
resumeDownload: PropTypes.func.isRequired,
pauseDownload: PropTypes.func.isRequired
};

DownloadsItem.defaultProps = {
item: {},
pauseDownload: () => { },
resumeDownload: () => { }
};

export default DownloadsItem;
Original file line number Diff line number Diff line change
@@ -1,45 +1,51 @@
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { Button, Icon, Segment, Table } from 'semantic-ui-react';
import { useTranslation } from 'react-i18next';

import DownloadsItem from '../DownloadsItem';

import DownloadsItem, { DownloadsItemProps } from '../DownloadsItem';
import styles from './styles.scss';

const DownloadsList = ({
type DownloadsListProps = {
items: DownloadsItemProps['item'][];
clearFinishedTracks: React.MouseEventHandler;
resumeDownload: (id: string) => void;
pauseDownload: (id: string) => void;
removeDownload: (id: string) => void;
};

const DownloadsList: React.FC<DownloadsListProps> = ({
items,
clearFinishedTracks,
pauseDownload,
resumeDownload,
removeDownload
}) => {
const [sortAsc, setSort] = useState(true);
const [sortAsc, setSortAsc] = useState(true);
const { t } = useTranslation('downloads');

const onSort = () => {
const sortResult = items.sort((a, b) => (a.track.name.toLowerCase() > b.track.name.toLowerCase())
? sortAsc ? 1 : -1
: sortAsc ? -1 : 1
);
setSortAsc(!sortAsc);
return sortResult;
};

return (
<Segment inverted>
<Button primary onClick={clearFinishedTracks}>
<Icon name='trash'/>
<Icon name='trash' />
{t('clear')}
</Button>
<Table inverted className={styles.downloads_list}>
<Table.Header>
<Table.Row>
<Table.HeaderCell>{t('status')}</Table.HeaderCell>
<Table.HeaderCell onClick={() => {
if (sortAsc){
items.sort((a, b) => {
return a.track.name.toLowerCase() > b.track.name.toLowerCase();
});
setSort(false);
} else {
items.sort((a, b) => {
return a.track.name.toLowerCase() < b.track.name.toLowerCase();
});
setSort(true);
}
}
}>{t('name')} {
<Table.HeaderCell
onClick={onSort}
>
{t('name')} {
sortAsc ? <Icon name='caret up' /> : <Icon name='caret down' />
}
</Table.HeaderCell>
@@ -67,18 +73,4 @@ const DownloadsList = ({
);
};

DownloadsList.propTypes = {
items: PropTypes.array,
clearFinishedTracks: PropTypes.func,
pauseDownload: PropTypes.func.isRequired,
resumeDownload: PropTypes.func.isRequired
};

DownloadsList.defaultProps = {
items: [],
clearFinishedTracks: () => {},
pauseDownload: () => {},
resumeDownload: () => {}
};

export default DownloadsList;
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import FontAwesome from 'react-fontawesome';
import { useTranslation } from 'react-i18next';

import Header from '../Header';
import DownloadsList from './DownloadsList';
import DownloadsHeader from './DownloadsHeader';

import styles from './styles.scss';
import { useTranslation } from 'react-i18next';
import { DownloadsItemProps } from './DownloadsItem';
import { setStringOption } from '../../actions/settings';

const EmptyState = () => {
const EmptyState: React.FC = () => {
const { t } = useTranslation('downloads');

return (
@@ -21,7 +21,17 @@ const EmptyState = () => {
);
};

const Downloads = ({
type DownloadsProps = {
downloads: DownloadsItemProps['item'][];
downloadsDir: string;
setStringOption: typeof setStringOption;
clearFinishedTracks: React.MouseEventHandler;
pauseDownload: (id: string) => void;
resumeDownload: (id: string) => void;
removeDownload: (id: string) => void;
}

const Downloads: React.FC<DownloadsProps> = ({
downloads,
downloadsDir,
clearFinishedTracks,
@@ -55,22 +65,4 @@ const Downloads = ({
);
};

Downloads.propTypes = {
downloads: PropTypes.array,
downloadsDir: PropTypes.string,
clearFinishedTracks: PropTypes.func,
setStringOption: PropTypes.func,
resumeDownload: PropTypes.func.isRequired,
pauseDownload: PropTypes.func.isRequired
};

Downloads.defaultProps = {
downloads: [],
downloadsDir: '',
clearFinishedTracks: () => {},
setStringOption: () => {},
pauseDownload: () => {},
resumeDownload: () => {}
};

export default Downloads;
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { store as electronStore } from '@nuclear/core';
import { waitFor } from '@testing-library/dom';
import { buildStoreState } from '../../../test/storeBuilders';
import { mountedComponentFactory, setupI18Next } from '../../../test/testUtils';

const initialStoreState =
buildStoreState()
.withDownloads()
.withPlugins()
.withConnectivity()
.build();

describe('Downloads container', () => {
beforeAll(() => {
setupI18Next();
});

beforeEach(() => {
electronStore.set(
'downloads',
initialStoreState.downloads
);
});

it('should display downloads', () => {
const { component } = mountComponent();
expect(component.asFragment()).toMatchSnapshot();
});

it('should clear finished downloads', async () => {
const { component } = mountComponent();
await waitFor(() => component.getByText(/clear finished tracks/i).click());

expect(component.queryByText(/test artist 1 - finished track/i)).toBeNull();
});

it('should remove a track', async () => {
const { component } = mountComponent();
await waitFor(() => component.getAllByTestId(/remove-download/i)[0].click());

expect(component.queryByText(/test artist 1 - finished track/i)).toBeNull();
});

it('should pause a download in progress', async () => {
const { component, store } = mountComponent();
await waitFor(() => component.getAllByTestId(/download-action/i)[3].click());

const state = store.getState();
expect(state.downloads[3].status).toBe('Paused');
});

it('should resume a paused download', async () => {
const { component, store } = mountComponent();
await waitFor(() => component.getAllByTestId(/download-action/i)[2].click());

const state = store.getState();
expect(state.downloads[2].status).toBe('Waiting');
});

it('should retry a download with error', async () => {
const { component, store } = mountComponent();
await waitFor(() => component.getAllByTestId(/download-action/i)[1].click());

const state = store.getState();
expect(state.downloads[1].status).toBe('Waiting');
});

it('should set downloads dir', async () => {
const { component } = mountComponent();
await waitFor(() => component.getByText(/choose a directory.../i).click());

// eslint-disable-next-line @typescript-eslint/no-var-requires
const remote = require('electron').remote;
expect(remote.dialog.showOpenDialog).toHaveBeenCalledWith({
properties: ['openDirectory']
});
});

const mountComponent = mountedComponentFactory(
['/downloads'],
initialStoreState
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Downloads container should display downloads 1`] = `
<DocumentFragment>
<div
class="main_layout_container"
>
<div
class="downloads_container"
>
<div
class="ui segment downloads_header"
>
<span
class="label"
>
Saving in:
<span
class="directory"
/>
</span>
<button
class="ui icon inverted left labeled button"
>
<i
aria-hidden="true"
class="folder open icon"
/>
Choose a directory...
</button>
</div>
<div
class="header_container"
>
Downloads
</div>
<div
class="ui inverted segment"
>
<button
class="ui primary button"
>
<i
aria-hidden="true"
class="trash icon"
/>
Clear finished tracks
</button>
<table
class="ui inverted table downloads_list"
>
<thead
class=""
>
<tr
class=""
>
<th
class=""
>
Status
</th>
<th
class=""
>
Name
<i
aria-hidden="true"
class="caret up icon"
/>
</th>
<th
class=""
>
Completion
</th>
<th
class=""
/>
</tr>
</thead>
<tbody
class=""
>
<tr
class="downloads_item"
>
<td
class=""
>
<i
aria-hidden="true"
class="green checkmark icon"
/>
Finished
</td>
<td
class=""
>
test artist 1 - finished track
</td>
<td
class=""
>
100%
</td>
<td
class="item_buttons"
>
<a
data-testid="download-action"
href="#"
>
<i
aria-hidden="true"
class="redo fitted icon"
/>
</a>
<a
data-testid="remove-download"
href="#"
>
<i
aria-hidden="true"
class="times fitted icon"
/>
</a>
</td>
</tr>
<tr
class="downloads_item"
>
<td
class=""
>
<i
aria-hidden="true"
class="red times icon"
/>
Error
</td>
<td
class=""
>
test artist 2 - track with errorx
</td>
<td
class=""
>
10%
</td>
<td
class="item_buttons"
>
<a
data-testid="download-action"
href="#"
>
<i
aria-hidden="true"
class="redo fitted icon"
/>
</a>
<a
data-testid="remove-download"
href="#"
>
<i
aria-hidden="true"
class="times fitted icon"
/>
</a>
</td>
</tr>
<tr
class="downloads_item"
>
<td
class=""
>
<i
aria-hidden="true"
class="pause circle icon"
/>
Paused
</td>
<td
class=""
>
test artist 3 - paused track
</td>
<td
class=""
>
30%
</td>
<td
class="item_buttons"
>
<a
data-testid="download-action"
href="#"
>
<i
aria-hidden="true"
class="play fitted icon"
/>
</a>
<a
data-testid="remove-download"
href="#"
>
<i
aria-hidden="true"
class="times fitted icon"
/>
</a>
</td>
</tr>
<tr
class="downloads_item"
>
<td
class=""
>
<i
aria-hidden="true"
class="cloud download icon"
/>
Started
</td>
<td
class=""
>
test artist 4 - started track
</td>
<td
class=""
>
50%
</td>
<td
class="item_buttons"
>
<a
data-testid="download-action"
href="#"
>
<i
aria-hidden="true"
class="pause fitted icon"
/>
</a>
<a
data-testid="remove-download"
href="#"
>
<i
aria-hidden="true"
class="times fitted icon"
/>
</a>
</td>
</tr>
<tr
class="downloads_item"
>
<td
class=""
>
<i
aria-hidden="true"
class="hourglass start icon"
/>
Waiting
</td>
<td
class=""
>
test artist 5 - waiting track
</td>
<td
class=""
>
0%
</td>
<td
class="item_buttons"
>
<a
data-testid="download-action"
href="#"
>
<i
aria-hidden="true"
class="pause fitted icon"
/>
</a>
<a
data-testid="remove-download"
href="#"
>
<i
aria-hidden="true"
class="times fitted icon"
/>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</DocumentFragment>
`;
76 changes: 0 additions & 76 deletions packages/app/app/containers/DownloadsContainer/index.js

This file was deleted.

33 changes: 33 additions & 0 deletions packages/app/app/containers/DownloadsContainer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import _ from 'lodash';

import * as DownloadActions from '../../actions/downloads';
import * as SettingsActions from '../../actions/settings';
import { downloadsSelector } from '../../selectors/downloads';
import { settingsSelector } from '../../selectors/settings';
import Downloads from '../../components/Downloads';

const DownloadsContainer: React.FC = () => {
const dispatch = useDispatch();
const downloads = useSelector(downloadsSelector);
const settings = useSelector(settingsSelector);

useEffect(() => {
dispatch(DownloadActions.readDownloads());
}, [dispatch]);

return (
<Downloads
downloads={downloads}
downloadsDir={_.get(settings, 'downloads.dir')}
clearFinishedTracks={() => dispatch(DownloadActions.clearFinishedDownloads())}
pauseDownload={(uuid: string) => dispatch(DownloadActions.onDownloadPause(uuid))}
resumeDownload={(uuid: string) => dispatch(DownloadActions.onDownloadResume(uuid))}
removeDownload={(uuid: string) => dispatch(DownloadActions.onDownloadRemoved(uuid))}
setStringOption={(option, state, fromMain) => dispatch(SettingsActions.setStringOption(option, state, fromMain))}
/>
);
};

export default DownloadsContainer;
3 changes: 3 additions & 0 deletions packages/app/app/selectors/downloads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { RootState } from '../reducers';

export const downloadsSelector = (s: RootState) => s.downloads;
60 changes: 60 additions & 0 deletions packages/app/test/storeBuilders.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DownloadStatus } from '../app/actions/downloads';

type StoreStateBuilder = ReturnType<typeof buildStoreState>;
export const buildStoreState = () => {
@@ -11,6 +12,7 @@ export const buildStoreState = () => {
plugin: {},
playlists: {},
dashboard: {},
downloads: [],
favorites: {
tracks: [],
artists: [],
@@ -477,6 +479,64 @@ export const buildStoreState = () => {
};
return this as StoreStateBuilder;
},
withDownloads() {
state = {
...state,
downloads: [{
status: DownloadStatus.FINISHED,
completion: 1,
track: {
uuid: '1',
artist: {
name: 'test artist 1'
},
name: 'finished track'
}
}, {
status: DownloadStatus.ERROR,
completion: 0.1,
track: {
uuid: '2',
artist: {
name: 'test artist 2'
},
name: 'track with errorx'
}
}, {
status: DownloadStatus.PAUSED,
completion: 0.3,
track: {
uuid: '3',
artist: {
name: 'test artist 3'
},
name: 'paused track'
}
}, {
status: DownloadStatus.STARTED,
completion: 0.5,
track: {
uuid: '4',
artist: {
name: 'test artist 4'
},
name: 'started track'
}
}, {
status: DownloadStatus.WAITING,
completion: 0,
track: {
uuid: '5',
artist: {
name: 'test artist 5'
},
name: 'waiting track'
}
}]
};

return this as StoreStateBuilder;
},
withGithubContrib() {
state = {
...state,
2 changes: 1 addition & 1 deletion packages/app/test/testUtils.tsx
Original file line number Diff line number Diff line change
@@ -127,7 +127,7 @@ export const mountedNavbarFactory= (
defaultInitialStore
);
};
// { container: document.body }
export const mountedPlayQueueFactory= (
initialHistoryEntries: string[],
defaultInitialStore?: AnyProps
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
"lib": ["ESNext"],
"skipLibCheck": true,
"strict": true,
"sourceMap": false,
"sourceMap": true,
"strictPropertyInitialization": false,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,

0 comments on commit d64590e

Please sign in to comment.