Skip to content

Commit

Permalink
Merge pull request hms-dbmi-cellenics#960 from biomage-org/embedding-…
Browse files Browse the repository at this point in the history
…multi-view

Support multi view for continuous embedding plot
  • Loading branch information
StefanBabukov authored Dec 15, 2023
2 parents 23bae23 + 24ec48f commit bad9571
Show file tree
Hide file tree
Showing 28 changed files with 503 additions and 607 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,13 @@ const initialState = {
};
const store = mockStore(initialState);
let component;

const method = 'UMAP';
const {
plotData,
cellSetLegendsData,
} = generateData(cellSets, config.selectedSample, config.selectedCellSet, embeddingData);

const spec = generateSpec(config, plotData, cellSetLegendsData);
const spec = generateSpec(config, method, plotData, cellSetLegendsData);

const testPlot = () => mount(
<Provider store={store}>
Expand Down
192 changes: 83 additions & 109 deletions src/__test__/components/plots/MultiViewGrid.test.jsx
Original file line number Diff line number Diff line change
@@ -1,133 +1,107 @@
/* eslint-disable no-shadow */
import React from 'react';
import {
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import _ from 'lodash';
import { Provider } from 'react-redux';

import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import MultiViewGrid from 'components/plots/MultiViewGrid';
import { generateMultiViewGridPlotUuid } from 'utils/generateCustomPlotUuid';

import { makeStore } from 'redux/store';
import { updatePlotConfig } from 'redux/actions/componentConfig';
import loadConditionalComponentConfig from 'redux/actions/componentConfig/loadConditionalComponentConfig';
import { arrayMoveImmutable } from 'utils/arrayUtils';

import fake from '__test__/test-utils/constants';
import mockAPI, {
generateDefaultMockAPIResponses,
} from '__test__/test-utils/mockAPI';

const experimentId = fake.EXPERIMENT_ID;
const plotUuid = 'ViolinMain';
const plotType = 'violin';
const multiViewType = 'multiView';
const multiViewUuid = 'multiView-ViolinMain';

const mockRenderPlot = jest.fn((plotUuid) => (<>{plotUuid}</>));
const mockUpdateAllWithChanges = jest.fn(() => {});

const plotUuids = [0, 1, 2].map((index) => generateMultiViewGridPlotUuid(plotUuid, index));

const defaultResponses = _.merge(
generateDefaultMockAPIResponses(experimentId),
);

const renderMultiView = (store) => {
render(
<Provider store={store}>
<MultiViewGrid
renderPlot={mockRenderPlot}
multiViewUuid={multiViewUuid}
updateAllWithChanges={mockUpdateAllWithChanges}
/>
</Provider>,
);
};
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';

enableFetchMocks();
// Mock the named exports of the action creators
jest.mock('redux/actions/componentConfig/loadConditionalComponentConfig', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({ type: 'MOCK_LOAD_CONDITIONAL_COMPONENT_CONFIG' }),
}));

let store = null;
jest.mock('redux/actions/componentConfig/savePlotConfig', () => ({
savePlotConfig: jest.fn().mockReturnValue({ type: 'MOCK_SAVE_PLOT_CONFIG' }),
}));

const loadComponent = async (componentUuid, type, skipAPI, customConfig) => {
store.dispatch(
loadConditionalComponentConfig(experimentId, componentUuid, type, skipAPI, customConfig),
);
};
const mockStore = configureMockStore([thunk]);

describe('MultiViewGrid', () => {
beforeEach(async () => {
jest.clearAllMocks();

fetchMock.resetMocks();
fetchMock.mockIf(/.*/, mockAPI(defaultResponses));

store = makeStore();
let store;
const multiViewConfig = {
plotUuids: ['ViolinMain-0', 'ViolinMain-1', 'ViolinMain-2'],
nrows: 1,
ncols: 3,
};
let plotConfigs;
const experimentId = 'experiment1';
const plotUuid = 'ViolinMain';
const plotType = 'violin';
const multiViewUuid = 'multiView-violin';

beforeEach(() => {
store = mockStore({
componentConfig: {
[multiViewUuid]: { config: multiViewConfig },
},
genes: {
properties: {
views: {
[plotUuid]: { data: ['gene1'] },
},
},
expression: {
full: {
matrix: {
geneIsLoaded: () => true,
},
},
},
},
embeddings: {},
cellSets: { accessible: true },
});

const customMultiViewConfig = { nrows: 2, ncols: 2, plotUuids };
await loadComponent(multiViewUuid, multiViewType, true, customMultiViewConfig);
plotConfigs = {
'ViolinMain-0': { shownGene: 'gene1' },
'ViolinMain-1': { shownGene: 'gene2' },
'ViolinMain-2': { shownGene: 'gene3' },
};
// act(() => {

plotUuids.map(async (uuid) => {
await loadComponent(uuid, plotType, true);
});
// });
});

const renderComponent = () => {
render(
<Provider store={store}>
<MultiViewGrid
experimentId={experimentId}
renderPlot={(uuid) => <div>{uuid}</div>}
updateAllWithChanges={() => {}}
plotType={plotType}
plotUuid={plotUuid}
/>
</Provider>,
);
};
it('Renders itself and its children', async () => {
renderMultiView(store);

plotUuids.forEach((plotUuid) => {
expect(screen.getByText(plotUuid)).toBeInTheDocument();
await renderComponent();
await waitFor(() => {
multiViewConfig.plotUuids.forEach((uuid) => {
expect(screen.getByText(uuid)).toBeInTheDocument();
});
});
});

it('Adds plots to multi view', async () => {
renderMultiView(store);

await store.dispatch(updatePlotConfig(multiViewUuid, {
nrows: 2,
ncols: 2,
plotUuids: [...plotUuids, 'ViolinMain-3'],
}));

await loadComponent('ViolinMain-3', plotType, true);

await waitFor(() => expect(screen.getByText('ViolinMain-3')).toBeInTheDocument());

expect(mockRenderPlot).toHaveBeenCalledTimes(4);
});

it('Re-orders plots in multi view', async () => {
renderMultiView(store);

const multiViewContainer = document.getElementById('multiViewContainer');

expect(multiViewContainer.textContent).toBe(plotUuids.join(''));
await renderComponent();

const reorderedUuids = arrayMoveImmutable(plotUuids, 0, 2);
const reorderedUuids = ['ViolinMain-0', 'ViolinMain-1', 'ViolinMain-2'];
multiViewConfig.plotUuids = reorderedUuids;

await store.dispatch(updatePlotConfig(multiViewUuid, { plotUuids: reorderedUuids }));
await waitFor(() => expect(multiViewContainer.textContent).toBe(reorderedUuids.join('')));

expect(mockRenderPlot).toHaveBeenCalledTimes(3);
const multiViewContainer = document.getElementById('multiViewContainer');
expect(multiViewContainer.textContent).toBe(reorderedUuids.join(''));
});

it('Removes plots from multi view', async () => {
renderMultiView(store);

const multiViewContainer = document.getElementById('multiViewContainer');

expect(multiViewContainer.textContent).toBe(plotUuids.join(''));
await renderComponent();

const newUuids = plotUuids.slice(1);
const newUuids = multiViewConfig.plotUuids.slice(1);
multiViewConfig.plotUuids = newUuids;

await store.dispatch(updatePlotConfig(multiViewUuid, { plotUuids: newUuids }));
await waitFor(() => expect(multiViewContainer.textContent).toBe(newUuids.join('')));

expect(mockRenderPlot).toHaveBeenCalledTimes(3);
const multiViewContainer = document.getElementById('multiViewContainer');
expect(multiViewContainer.textContent).toBe('ViolinMain-0ViolinMain-1ViolinMain-2');
});
});

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ describe('Continuous embedding plot', () => {
it('Loads controls and elements', async () => {
await renderContinuousEmbeddingPage(storeState);

expect(screen.getByText(/Gene selection/i)).toBeInTheDocument();
expect(screen.getByText(/Select data/i)).toBeInTheDocument();
expect(screen.getByText(/Expression values/i)).toBeInTheDocument();
expect(screen.getByText(/Main schema/i)).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ const mockWorkerResponses = {
};

const experimentId = fake.EXPERIMENT_ID;
const plotUuid = 'ViolinMain';
const multiViewUuid = 'multiView-ViolinMain';
const plotUuid = 'ViolinMain-0';
const multiViewUuid = 'multiView-violin';

const customAPIResponses = {
[`/plots/${plotUuid}$`]: (req) => {
Expand Down Expand Up @@ -90,8 +90,8 @@ describe('ViolinIndex', () => {

it('Loads controls and elements', async () => {
await renderViolinPage(storeState);

expect(screen.getByText(/Gene selection/i)).toBeInTheDocument();

expect(screen.getByText(/View multiple plots/i)).toBeInTheDocument();
expect(screen.getByText(/Select data/i)).toBeInTheDocument();
expect(screen.getByText(/Data transformation/i)).toBeInTheDocument();
Expand All @@ -107,33 +107,6 @@ describe('ViolinIndex', () => {
expect(screen.getByRole('graphics-document', { name: 'Violin plot' })).toBeInTheDocument();
});

it('Changes the shown gene', async () => {
await renderViolinPage(storeState);

userEvent.click(screen.getByText(/Gene selection/i));

const searchBox = screen.getByRole('combobox', { name: 'SearchBar' });

userEvent.clear(searchBox);
userEvent.type(searchBox, 'cc');

const option = screen.getByTitle('Ccl5');

await act(async () => {
// the element has pointer-events set to 'none', skip check
// based on https://stackoverflow.com/questions/61080116
userEvent.click(option, undefined, { skipPointerEventsCheck: true });
});

userEvent.click(screen.getByText('Search'));

expect(searchBox.textContent).toBe('');

await waitFor(() => expect(screen.getByRole('graphics-document', { name: 'Violin plot' })).toBeInTheDocument());

expect(storeState.getState().componentConfig[plotUuid].config.shownGene).toBe('Ccl5');
});

it('Changes to raw expression', async () => {
await renderViolinPage(storeState);

Expand Down Expand Up @@ -171,7 +144,7 @@ describe('ViolinIndex', () => {
expect(_.isEqual([multiViewConfig.nrows, multiViewConfig.ncols], [2, 2])).toBe(true);

// New plot's config is correct
expect(storeState.getState().componentConfig['ViolinMain-0']).toMatchSnapshot('new-config');
expect(storeState.getState().componentConfig['ViolinMain-1']).toMatchSnapshot('new-config');
});

it('Adds new plot using same config as previous plot', async () => {
Expand Down Expand Up @@ -202,4 +175,30 @@ describe('ViolinIndex', () => {
// New plot's config contains normalised set to raw too
expect(storeState.getState().componentConfig['ViolinMain-0'].config.normalised).toEqual('raw');
});
it('Changes the shown gene', async () => {
await renderViolinPage(storeState);

userEvent.click(screen.getByText(/Gene selection/i));

const searchBox = screen.getAllByRole('combobox', { name: 'SearchBar' })[0];

userEvent.clear(searchBox);
userEvent.type(searchBox, 'cc');

const option = screen.getByTitle('Ccl5');

await act(async () => {
// the element has pointer-events set to 'none', skip check
// based on https://stackoverflow.com/questions/61080116
userEvent.click(option, undefined, { skipPointerEventsCheck: true });
});

userEvent.click(screen.getByText('Search'));

expect(searchBox.textContent).toBe('');

await waitFor(() => expect(screen.getByRole('graphics-document', { name: 'Violin plot' })).toBeInTheDocument());

expect(storeState.getState().componentConfig[plotUuid].config.shownGene).toBe('Ccl5');
});
});
Loading

0 comments on commit bad9571

Please sign in to comment.