Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,10 @@ jobs:
tags: ${{ format('{0}/{1}:{2}', steps.login-ecr.outputs.registry, steps.ref.outputs.repo-name, steps.ref.outputs.image-tag) }}
push: true
provenance: false
cache-from: type=gha,scope=${{ github.workflow }}
cache-to: type=gha,mode=max,scope=${{ github.workflow }},ignore-error=true
# GHA cache (type=gha) disabled: restoring the mode=max cache fills the
# runner disk and the build fails with "no space left on device" during
# COPY. Restore via registry cache (type=registry to a dedicated mutable
# ECR repo) if build times become a problem.

deploy:
name: Deploy to Kubernetes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import userEvent from '@testing-library/user-event';

import { makeStore } from 'redux/store';
import AnnotateClustersTool from 'components/data-exploration/cell-sets-tool/AnnotateClustersTool';
import { runCassiaAnnotation } from 'redux/actions/cellSets';
import createTestComponentFactory from '__test__/test-utils/testComponentFactory';
import fake from '__test__/test-utils/constants';

jest.mock('redux/actions/cellSets', () => ({
__esModule: true,
runCellSetsAnnotation: jest.fn(() => () => {}),
runCassiaAnnotation: jest.fn(() => () => {}),
}));

const experimentId = fake.EXPERIMENT_ID;
const AnnotateClustersToolFactory = createTestComponentFactory(
AnnotateClustersTool,
{ experimentId, onRunAnnotation: jest.fn() },
);

const renderTool = () => render(
<Provider store={makeStore()}>
{AnnotateClustersToolFactory()}
</Provider>,
);

describe('AnnotateClustersTool', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('defaults to CASSIA and shows the additional-context input', () => {
renderTool();

expect(screen.getByRole('radio', { name: 'CASSIA' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'ScType' })).not.toBeChecked();
// CASSIA-only free-text context box is present
expect(screen.getByText(/Additional context/i)).toBeInTheDocument();
});

it('sends the entered context to runCassiaAnnotation on confirm', async () => {
renderTool();

userEvent.type(screen.getByPlaceholderText(/Large Intestine/i), 'Large Intestine');
userEvent.type(screen.getByPlaceholderText(/e\.g\. Human/i), 'Human');
userEvent.type(
screen.getByPlaceholderText(/colorectal tumor/i),
'3 tumor, 2 normal',
);

userEvent.click(screen.getByRole('button', { name: /Compute/i }));

// Confirmation modal opens; it names Amazon Bedrock (appears in both the
// alert title and body, so assert the unique modal title).
await waitFor(() => {
expect(screen.getByText(/Confirm CASSIA annotation/i)).toBeInTheDocument();
});
expect(screen.getAllByText(/Amazon Bedrock/i).length).toBeGreaterThan(0);

userEvent.click(screen.getByRole('button', { name: /Continue/i }));

await waitFor(() => {
expect(runCassiaAnnotation).toHaveBeenCalledWith(
experimentId, 'Human', 'Large Intestine', '3 tumor, 2 normal',
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -723,14 +723,13 @@ describe('CellSetsTool', () => {
userEvent.click(deleteAnnotatedCellClassButton);
});

// get all the cell set groups
const numCellGroupsAfterDelete = screen.getAllByRole('img', { name: 'down' }).length;

await waitFor(() => {
// This test used to assert that "Annotated cell set" text is not found in "screen"
// in order to verify that deletion was successful.
// 4 Cell groups: louvain, custom cell sets, sample, Track_1 (metadata)
expect(numCellGroupsAfterDelete).toEqual(4);
// Deletion is optimistic, so the annotated cell class is gone immediately.
// Count the expand carets: of the remaining groups (louvain, custom cell
// sets, sample, Track_1) only the three non-empty ones show a "down" caret
// - the empty "Custom cell sets" group shows none.
const numCellGroupsAfterDelete = screen.getAllByRole('img', { name: 'down' }).length;
expect(numCellGroupsAfterDelete).toEqual(3);

expect(screen.queryByText('Annotated cell class')).toBeNull();

Expand Down Expand Up @@ -765,6 +764,10 @@ describe('AnnotateClustersTool', () => {

// Switch to tab
userEvent.click(annotateClustersTabTitle);

// CASSIA is the default method; these tests exercise the ScType select flow
// (dropdowns + direct dispatch), so switch to ScType first.
userEvent.click(screen.getByRole('radio', { name: 'ScType' }));
});

it('Renders correctly', async () => {
Expand Down
57 changes: 57 additions & 0 deletions src/__test__/redux/actions/cellSets/runCassiaAnnotation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';

import runCassiaAnnotation from 'redux/actions/cellSets/runCassiaAnnotation';
import fake from '__test__/test-utils/constants';
import fetchWork from 'utils/work/fetchWork';

jest.mock('utils/work/fetchWork', () => jest.fn(() => ({})));
jest.mock('utils/getTimeoutForWorkerTask', () => jest.fn(() => 100));

const mockStore = configureMockStore([thunk]);
const experimentId = fake.EXPERIMENT_ID;

const newStore = () => mockStore({
cellSets: { error: false, updatingClustering: false, loading: false },
});

describe('runCassiaAnnotation', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('sends name, species, tissue and additionalInfo to the worker', async () => {
await newStore().dispatch(
runCassiaAnnotation(experimentId, 'Human', 'Large Intestine', '3 tumor, 2 normal'),
);

expect(fetchWork).toHaveBeenCalledTimes(1);
const [calledExperimentId, body] = fetchWork.mock.calls[0];
expect(calledExperimentId).toEqual(experimentId);
expect(body).toEqual({
name: 'CASSIAAnnotate',
species: 'Human',
tissue: 'Large Intestine',
additionalInfo: '3 tumor, 2 normal',
});
});

it('defaults additionalInfo to an empty string when omitted', async () => {
await newStore().dispatch(
runCassiaAnnotation(experimentId, 'Human', 'Large Intestine'),
);

const [, body] = fetchWork.mock.calls[0];
expect(body.additionalInfo).toBe('');
});

it('does not dispatch a request when cell sets are already updating', async () => {
const store = mockStore({
cellSets: { error: false, updatingClustering: true, loading: true },
});

await store.dispatch(runCassiaAnnotation(experimentId, 'Human', 'Large Intestine'));

expect(fetchWork).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import cellClassDelete from 'redux/reducers/componentConfig/cellClassDelete';
import { CELL_CLASS_DELETE } from 'redux/actionTypes/cellSets';

const deleteAction = (key) => ({ type: CELL_CLASS_DELETE, payload: { key } });

describe('cellClassDelete (componentConfig)', () => {
it('removes the deleted cell class from heatmap groupedTracks', () => {
const state = {
interactiveHeatmap: {
config: { groupedTracks: ['louvain', 'CASSIA-Gut-Human-1', 'sample'] },
},
};

const newState = cellClassDelete(state, deleteAction('CASSIA-Gut-Human-1'));

expect(newState.interactiveHeatmap.config.groupedTracks).toEqual(['louvain', 'sample']);
});

it('leaves groupedTracks untouched when it does not contain the deleted key', () => {
const state = {
interactiveHeatmap: { config: { groupedTracks: ['louvain', 'sample'] } },
};

const newState = cellClassDelete(state, deleteAction('CASSIA-Gut-Human-1'));

expect(newState.interactiveHeatmap.config.groupedTracks).toEqual(['louvain', 'sample']);
});

// Regression: plots whose config has no groupedTracks used to throw
// "Cannot read properties of undefined (reading 'includes')".
it('does not throw for plot configs without groupedTracks', () => {
const state = {
embeddingCategoricalMain: { config: { selectedCellSet: 'louvain' } },
violinMain: { config: {} },
noConfigPlot: {},
};

expect(
() => cellClassDelete(state, deleteAction('CASSIA-Gut-Human-1')),
).not.toThrow();
});
});
20 changes: 13 additions & 7 deletions src/components/Loader.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import { BounceLoader } from 'react-spinners';
import { Typography, Spin } from 'antd';
import { Typography, Spin, Progress } from 'antd';
import useSWR from 'swr';
import React from 'react';
import { useSelector } from 'react-redux';
Expand Down Expand Up @@ -39,15 +39,21 @@ const slowLoad = () => (
</>
);

const fastLoad = (message) => (
const fastLoad = (message, percent) => (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
}}
>
<style>{spinnerStyles}</style>
<div style={{ padding: 25 }}>
<Spin size='large' className='loader-spinner' />
</div>
{typeof percent === 'number' ? (
<div style={{ width: 300, padding: 25 }}>
<Progress percent={percent} status='normal' strokeColor={colors.darkRed} />
</div>
) : (
<div style={{ padding: 25 }}>
<Spin size='large' className='loader-spinner' />
</div>
)}
<p style={{ textAlign: 'center' }}>
<Text>
{message || "We're getting your data ..."}
Expand All @@ -74,10 +80,10 @@ const Loader = ({ experimentId }) => {
}

if (workerInfo && workerInfo.userMessage) {
const { userMessage } = workerInfo;
const { userMessage, progressPercent } = workerInfo;
return (
<div>
{fastLoad(userMessage)}
{fastLoad(userMessage, progressPercent)}
</div>
);
}
Expand Down
Loading
Loading