diff --git a/website/eslint.config.js b/website/eslint.config.js index 6079dcbfc..83fb60c64 100644 --- a/website/eslint.config.js +++ b/website/eslint.config.js @@ -106,7 +106,9 @@ const disableFromReact = { export default tseslint.config( { - ignores: ['**/.astro/content.d.ts'], + ignores: ['**/.astro/content.d.ts', 'dist/**', 'node_modules/**'], + }, + { files: ['**/*.ts', '**/*.tsx'], extends: [ eslint.configs.recommended, diff --git a/website/routeMocker.ts b/website/routeMocker.ts index 16ab4702b..b0f1f9b06 100644 --- a/website/routeMocker.ts +++ b/website/routeMocker.ts @@ -5,6 +5,7 @@ import type { SetupServer } from 'msw/node'; import { expect } from 'vitest'; import { type OrganismsConfig } from './src/config'; +import type { CollectionRaw } from './src/covspectrum/types.ts'; import type { LapisInfo } from './src/lapis/getLastUpdatedDate.ts'; import type { ProblemDetail } from './src/types/ProblemDetail.ts'; import type { @@ -45,6 +46,26 @@ type MockCase = { requestParam?: Record; }; +export class CovSpectrumRouteMocker { + constructor(private workerOrServer: MSWWorkerOrServer) {} + + mockGetCollections(baseUrl: string, response: CollectionRaw[], statusCode = 200) { + this.workerOrServer.use( + http.get(`${baseUrl}/resource/collection`, () => { + return new Response(JSON.stringify(response), { status: statusCode }); + }), + ); + } + + mockGetCollection(baseUrl: string, id: number, response: CollectionRaw, statusCode = 200) { + this.workerOrServer.use( + http.get(`${baseUrl}/resource/collection/${id}`, () => { + return new Response(JSON.stringify(response), { status: statusCode }); + }), + ); + } +} + /** * Allows you to mock LAPIS API routes. * By default, the host is DUMMY_LAPIS_URL, so it's best if you use that as the lapisUrl in your tests. diff --git a/website/src/components/genspectrum/GsMutationsOverTime.tsx b/website/src/components/genspectrum/GsMutationsOverTime.tsx index 1047c940f..c3b93e6b1 100644 --- a/website/src/components/genspectrum/GsMutationsOverTime.tsx +++ b/website/src/components/genspectrum/GsMutationsOverTime.tsx @@ -1,9 +1,10 @@ -import type { - TemporalGranularity, - LapisFilter, - SequenceType, - MeanProportionInterval, - CustomColumn, +import { + type TemporalGranularity, + type LapisFilter, + type SequenceType, + type MeanProportionInterval, + type CustomColumn, + views, } from '@genspectrum/dashboard-components/util'; import { type FC } from 'react'; @@ -46,7 +47,7 @@ export const GsMutationsOverTime: FC = ({ height={height ? '100%' : undefined} lapisFilter={JSON.stringify(lapisFilter)} sequenceType={sequenceType} - views='["grid"]' + views={JSON.stringify([views.grid])} granularity={granularity} lapisDateField={lapisDateField} displayMutations={displayMutations ? JSON.stringify(displayMutations) : undefined} diff --git a/website/src/components/genspectrum/GsQueriesOverTime.tsx b/website/src/components/genspectrum/GsQueriesOverTime.tsx new file mode 100644 index 000000000..e13f3ae7f --- /dev/null +++ b/website/src/components/genspectrum/GsQueriesOverTime.tsx @@ -0,0 +1,60 @@ +import { + views, + type CustomColumn, + type LapisFilter, + type MeanProportionInterval, + type QueryDefinition, + type TemporalGranularity, +} from '@genspectrum/dashboard-components/util'; +import { type FC } from 'react'; + +import { ComponentWrapper } from '../ComponentWrapper'; + +export type GsQueriesOverTimeProps = { + collectionTitle?: string; + lapisFilter: LapisFilter; + queries: QueryDefinition[]; + granularity: TemporalGranularity; + lapisDateField: string; + height?: string; + pageSizes?: number[]; + hideGaps?: true; + initialMeanProportionInterval?: MeanProportionInterval; + customColumns?: CustomColumn[]; +}; + +export const GsQueriesOverTime: FC = ({ + collectionTitle, + lapisFilter, + queries, + granularity, + lapisDateField, + height, + pageSizes, + hideGaps, + initialMeanProportionInterval, + customColumns, +}) => { + return ( + + + + ); +}; diff --git a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx index 62d18f6de..1f5b5767a 100644 --- a/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx +++ b/website/src/components/pageStateSelectors/wasap/WasapPageStateSelector.tsx @@ -5,6 +5,7 @@ import { ApplyFilterButton } from '../ApplyFilterButton'; import { DynamicDateFilter } from '../DynamicDateFilter'; import { SelectorHeadline } from '../SelectorHeadline'; import { ExplorationModeInfo } from './InfoBlocks'; +import { CollectionAnalysisFilter } from './filters/CollectionAnalysisFilter'; import { ManualAnalysisFilter } from './filters/ManualAnalysisFilter'; import { ResistanceMutationsFilter } from './filters/ResistanceMutationsFilter'; import { UntrackedFilter } from './filters/UntrackedFilter'; @@ -35,12 +36,14 @@ export function WasapPageStateSelector({ initialBaseFilterState, initialAnalysisFilterState, setPageState, + isStaging, }: { config: WasapPageConfig; pageStateHandler: PageStateHandler; initialBaseFilterState: WasapBaseFilter; initialAnalysisFilterState: WasapAnalysisFilter; setPageState: Dispatch>; + isStaging: boolean; }) { const [baseFilterState, setBaseFilterState] = useState(initialBaseFilterState); @@ -54,6 +57,8 @@ export function WasapPageStateSelector({ setResistanceFilter, untrackedFilter, setUntrackedFilter, + collectionFilter, + setCollectionFilter, } = useAnalysisFilterStates(initialAnalysisFilterState, config); const [selectedAnalysisMode, setSelectedAnalysisMode] = useState(initialAnalysisFilterState.mode); @@ -71,6 +76,8 @@ export function WasapPageStateSelector({ return { base: baseFilterState, analysis: resistanceFilter! }; case 'untracked': return { base: baseFilterState, analysis: untrackedFilter! }; + case 'collection': + return { base: baseFilterState, analysis: collectionFilter! }; } /* eslint-enable @typescript-eslint/no-non-null-assertion */ } @@ -150,7 +157,7 @@ export function WasapPageStateSelector({ setSelectedAnalysisMode(e.target.value as WasapAnalysisMode); }} > - {enabledAnalysisModes(config).map((mode) => ( + {enabledAnalysisModes(config, isStaging).map((mode) => ( @@ -200,6 +207,17 @@ export function WasapPageStateSelector({ cladeLineageQueryResult={cladeLineageQueryResult} /> ); + case 'collection': + if (!config.collectionAnalysisModeEnabled || collectionFilter === undefined) { + throw Error("'collection' mode selected, but it isn't enabled."); + } + return ( + + ); } })()} @@ -222,6 +240,8 @@ function modeLabel(mode: WasapAnalysisMode): string { return 'Variant Explorer'; case 'untracked': return 'Untracked Mutations'; + case 'collection': + return 'Collection'; } } @@ -259,6 +279,13 @@ function useAnalysisFilterStates(initialFilter: WasapAnalysisFilter, config: Was ? config.filterDefaults.untracked : undefined, ); + const [collectionFilter, setCollectionFilter] = useState( + initialFilter.mode === 'collection' + ? initialFilter + : config.collectionAnalysisModeEnabled + ? config.filterDefaults.collection + : undefined, + ); return { manualFilter, @@ -269,5 +296,7 @@ function useAnalysisFilterStates(initialFilter: WasapAnalysisFilter, config: Was setResistanceFilter, untrackedFilter, setUntrackedFilter, + collectionFilter, + setCollectionFilter, }; } diff --git a/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.browser.spec.tsx b/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.browser.spec.tsx new file mode 100644 index 000000000..8f747e666 --- /dev/null +++ b/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.browser.spec.tsx @@ -0,0 +1,81 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { describe, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-react'; + +import { CollectionAnalysisFilter } from './CollectionAnalysisFilter'; +import { it } from '../../../../../test-extend'; +import type { CollectionRaw } from '../../../../covspectrum/types'; +import type { WasapCollectionFilter } from '../../../views/wasap/wasapPageConfig'; + +const DUMMY_COV_SPECTRUM_URL = 'https://cov-spectrum-dummy.com/api'; + +const mockCollections: CollectionRaw[] = [ + { + id: 42, + title: '3CLpro', + description: 'Test collection', + maintainers: 'Test', + email: 'test@example.com', + variants: [{ query: '{}', name: 'Variant 1', description: 'Desc 1', highlighted: false }], + }, + { + id: 99, + title: 'RdRp', + description: 'Another collection', + maintainers: 'Test', + email: 'test@example.com', + variants: [], + }, +]; + +const queryClient = new QueryClient(); + +describe('CollectionAnalysisFilter', () => { + const defaultPageState: WasapCollectionFilter = { + mode: 'collection', + collectionId: 42, + }; + + it('renders with initial collection set', async ({ routeMockers: { covSpectrum } }) => { + const mockSetPageState = vi.fn(); + + covSpectrum.mockGetCollections(DUMMY_COV_SPECTRUM_URL, mockCollections); + + const { getByRole } = render( + + + , + ); + + const select = getByRole('combobox'); + await expect.element(select).toHaveValue('42'); // collectionId from defaultPageState + }); + + it('calls setPageState when selecting a different collection', async ({ routeMockers: { covSpectrum } }) => { + const mockSetPageState = vi.fn(); + + covSpectrum.mockGetCollections(DUMMY_COV_SPECTRUM_URL, mockCollections); + + const { getByRole } = render( + + + , + ); + + const select = getByRole('combobox'); + await select.selectOptions('99'); // Select RdRp collection + + expect(mockSetPageState).toHaveBeenCalledWith({ + ...defaultPageState, + collectionId: 99, + }); + }); +}); diff --git a/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx b/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx new file mode 100644 index 000000000..d7048ac43 --- /dev/null +++ b/website/src/components/pageStateSelectors/wasap/filters/CollectionAnalysisFilter.tsx @@ -0,0 +1,52 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getCollections } from '../../../../covspectrum/getCollections'; +import type { WasapCollectionFilter } from '../../../views/wasap/wasapPageConfig'; +import { LabeledField } from '../utils/LabeledField'; + +export function CollectionAnalysisFilter({ + pageState, + setPageState, + collectionsApiBaseUrl, +}: { + pageState: WasapCollectionFilter; + setPageState: (newState: WasapCollectionFilter) => void; + collectionsApiBaseUrl: string; +}) { + const { + data: collections, + isPending, + isError, + } = useQuery({ + queryKey: ['collections', collectionsApiBaseUrl], + queryFn: () => getCollections(collectionsApiBaseUrl), + }); + + return ( + + {isPending ? ( +
Loading collections...
+ ) : isError ? ( +
Error loading collections
+ ) : ( + + )} +
+ ); +} diff --git a/website/src/components/views/analyzeSingleVariant/CollectionsList.tsx b/website/src/components/views/analyzeSingleVariant/CollectionsList.tsx index 4a6eb79fd..0daded19e 100644 --- a/website/src/components/views/analyzeSingleVariant/CollectionsList.tsx +++ b/website/src/components/views/analyzeSingleVariant/CollectionsList.tsx @@ -4,7 +4,7 @@ import { z } from 'zod'; import { getClientLogger } from '../../../clientLogger.ts'; import { getCollections } from '../../../covspectrum/getCollections.ts'; -import { type Collection, type CollectionVariant } from '../../../covspectrum/types.ts'; +import { type CollectionRaw, type CollectionVariantRaw } from '../../../covspectrum/types.ts'; import { type CovidVariantData } from '../../../views/covid.ts'; import { useErrorToast } from '../../ErrorReportInstruction.tsx'; @@ -46,7 +46,7 @@ export function CollectionsList({ initialCollectionId, pageState, setPageState } } type CollectionSelectorProps = { - collections: Collection[]; + collections: CollectionRaw[]; selectedId: number; onSelect: (index: number) => void; }; @@ -78,7 +78,7 @@ const querySchema = z.object({ }); type CollectionVariantListProps = { - collection: Collection; + collection: CollectionRaw; pageState: CovidVariantData; setPageState: Dispatch>; }; @@ -102,7 +102,7 @@ function CollectionVariantList({ collection, pageState, setPageState }: Collecti } type VariantLinkProps = { - variant: CollectionVariant; + variant: CollectionVariantRaw; collectionId: number; pageState: CovidVariantData; setPageState: Dispatch>; @@ -130,7 +130,7 @@ const logger = getClientLogger('CollectionList'); function useVariantLinkPageState( currentPageState: CovidVariantData, collectionId: number, - variant: CollectionVariant, + variant: CollectionVariantRaw, ): CovidVariantData | undefined { const { showErrorToast } = useErrorToast(logger); diff --git a/website/src/components/views/wasap/Wasap.astro b/website/src/components/views/wasap/Wasap.astro index 4535ed13f..c7f5274fd 100644 --- a/website/src/components/views/wasap/Wasap.astro +++ b/website/src/components/views/wasap/Wasap.astro @@ -1,5 +1,6 @@ --- import { WasapPage } from './WasapPage'; +import { isStaging } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; import { wastewaterOrganismConfigs, type WastewaterOrganismName } from '../../../types/wastewaterConfig'; @@ -9,8 +10,11 @@ type Props = { const { wastewaterOrganism } = Astro.props; const { name } = wastewaterOrganismConfigs[wastewaterOrganism]; +// TODO -- remove isStaging from here, and cascade removing it into the component, after +// the collections mode is enabled in prod https://github.com/GenSpectrum/dashboards/issues/1029 +const staging = isStaging(); --- - + diff --git a/website/src/components/views/wasap/WasapPage.tsx b/website/src/components/views/wasap/WasapPage.tsx index 296b85aed..0f90733f2 100644 --- a/website/src/components/views/wasap/WasapPage.tsx +++ b/website/src/components/views/wasap/WasapPage.tsx @@ -24,15 +24,17 @@ import { import { Loading } from '../../../util/Loading'; import { WasapPageStateHandler } from '../../../views/pageStateHandlers/WasapPageStateHandler'; import { GsMutationsOverTime } from '../../genspectrum/GsMutationsOverTime'; +import { GsQueriesOverTime } from '../../genspectrum/GsQueriesOverTime.tsx'; import { WasapPageStateSelector } from '../../pageStateSelectors/wasap/WasapPageStateSelector'; import { withQueryProvider } from '../../subscriptions/backendApi/withQueryProvider'; import { usePageState } from '../usePageState.ts'; export type WasapPageProps = { wastewaterOrganism: WastewaterOrganismName; + isStaging: boolean; }; -export const WasapPageInner: FC = ({ wastewaterOrganism }) => { +export const WasapPageInner: FC = ({ wastewaterOrganism, isStaging }) => { const config = wastewaterOrganismConfigs[wastewaterOrganism]; // initialize page state from the URL const pageStateHandler = useMemo(() => new WasapPageStateHandler(config), [config]); @@ -44,8 +46,6 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { // fetch which mutations should be analyzed const { data, isPending, isError } = useWasapPageData(config, analysis); - const displayMutations = data?.displayMutations; - const customColumns = data?.customColumns; let initialMeanProportionInterval: MeanProportionInterval = { min: 0.0, max: 1.0 }; if (analysis.mode === 'manual' && analysis.mutations === undefined) { @@ -97,40 +97,60 @@ export const WasapPageInner: FC = ({ wastewaterOrganism }) => { initialBaseFilterState={base} initialAnalysisFilterState={analysis} setPageState={setPageState} + isStaging={isStaging} /> {isError ? ( - There was an error fetching the mutations to display. + There was an error fetching the data to display. ) : isPending ? ( ) : (
- {displayMutations?.length === 0 ? ( - + {data.type === 'mutations' ? ( + <> + {data.displayMutations?.length === 0 ? ( + + ) : ( + + )} + {analysis.mode === 'variant' && config.variantAnalysisModeEnabled && ( + + )} + + ) : ( - +
+ +
)} - {analysis.mode === 'variant' && config.variantAnalysisModeEnabled && ( - - )} -
)} diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index e9a2531db..5f51e1eb3 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -2,7 +2,17 @@ import type { CustomColumn, LapisFilter } from '@genspectrum/dashboard-component import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import type { VariantTimeFrame, WasapAnalysisFilter, WasapPageConfig } from './wasapPageConfig'; +import type { + VariantTimeFrame, + WasapAnalysisFilter, + WasapCollectionFilter, + WasapManualFilter, + WasapPageConfig, + WasapResistanceFilter, + WasapUntrackedFilter, + WasapVariantFilter, +} from './wasapPageConfig'; +import { getCollection } from '../../../covspectrum/getCollection'; import { getCladeLineages } from '../../../lapis/getCladeLineages'; import { getMutations, getMutationsForVariant } from '../../../lapis/getMutations'; @@ -13,102 +23,157 @@ import { getMutations, getMutationsForVariant } from '../../../lapis/getMutation export function useWasapPageData(config: WasapPageConfig, analysis: WasapAnalysisFilter) { return useQuery({ queryKey: ['wasap', analysis], - queryFn: () => - fetchMutationSelection(config, analysis).then((data) => wasapPageDataFromMutationSelection(data)), + queryFn: () => fetchWasapPageData(config, analysis), }); } -type AllMutations = { - type: 'all'; -}; +async function fetchWasapPageData(config: WasapPageConfig, analysis: WasapAnalysisFilter): Promise { + switch (analysis.mode) { + case 'manual': + return fetchManualModeData(config, analysis); + case 'variant': + return fetchVariantModeData(config, analysis); + case 'resistance': + return fetchResistanceModeData(config, analysis); + case 'untracked': + return fetchUntrackedModeData(config, analysis); + case 'collection': + return fetchCollectionModeData(config, analysis); + } +} -type SelectedMutations = { - type: 'selected'; - mutations: string[]; -}; +function fetchManualModeData(config: WasapPageConfig, analysis: WasapManualFilter): WasapMutationsData { + if (!config.manualAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'manual' mode is not enabled."); + } + return { + type: 'mutations', + displayMutations: analysis.mutations, + }; +} -type SelectedWithJaccard = { - type: 'jaccard'; - mutationsWithScore: { mutation: string; jaccardIndex: number }[]; -}; +async function fetchVariantModeData( + config: WasapPageConfig, + analysis: WasapVariantFilter, +): Promise { + if (!config.variantAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'variant' mode is not enabled."); + } + const mutationsWithScore = await getMutationsForVariant( + config.clinicalLapis.lapisBaseUrl, + analysis.sequenceType, + { + [config.clinicalLapis.lineageField]: analysis.variant, + }, + analysis.minProportion, + analysis.minCount, + analysis.minJaccard, + getLapisFilterForTimeFrame(analysis.timeFrame, config.clinicalLapis.dateField), + ); + return { + type: 'mutations', + displayMutations: mutationsWithScore.map(({ mutation }) => mutation), + customColumns: [ + { + header: 'Jaccard index', + values: Object.fromEntries( + mutationsWithScore.map(({ mutation, jaccardIndex }) => [mutation, jaccardIndex.toPrecision(2)]), + ), + }, + ], + }; +} -type MutationSelection = AllMutations | SelectedMutations | SelectedWithJaccard; +function fetchResistanceModeData(config: WasapPageConfig, analysis: WasapResistanceFilter): WasapMutationsData { + if (!config.resistanceAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'resistance' mode is not enabled."); + } + return { + type: 'mutations', + displayMutations: + config.resistanceMutationSets.find((set) => set.name === analysis.resistanceSet)?.mutations ?? [], + }; +} -async function fetchMutationSelection( +async function fetchUntrackedModeData( config: WasapPageConfig, - analysis: WasapAnalysisFilter, -): Promise { - switch (analysis.mode) { - case 'manual': - if (!config.manualAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'manual' mode is not enabled."); - } - return analysis.mutations ? { type: 'selected', mutations: analysis.mutations } : { type: 'all' }; - case 'variant': - if (!config.variantAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'variant' mode is not enabled."); - } - if (!analysis.variant) { - return { type: 'selected', mutations: [] }; - } - return getMutationsForVariant( - config.clinicalLapis.lapisBaseUrl, - analysis.sequenceType, - { - [config.clinicalLapis.lineageField]: analysis.variant, - }, - analysis.minProportion, - analysis.minCount, - analysis.minJaccard, - getLapisFilterForTimeFrame(analysis.timeFrame, config.clinicalLapis.dateField), - ).then((r) => ({ type: 'jaccard', mutationsWithScore: r })); - case 'resistance': - if (!config.resistanceAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'resistance' mode is not enabled."); - } - return { - type: 'selected', - mutations: - config.resistanceMutationSets.find((set) => set.name === analysis.resistanceSet)?.mutations ?? [], - }; - case 'untracked': { - if (!config.untrackedAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'untracked' mode is not enabled."); - } - const variantsToExclude = - analysis.excludeSet === 'custom' - ? analysis.excludeVariants - : await getCladeLineages( - config.clinicalLapis.lapisBaseUrl, - config.clinicalLapis.cladeField, - config.clinicalLapis.lineageField, - true, - ).then((r) => Object.values(r)); - if (variantsToExclude === undefined) { - return { type: 'selected', mutations: [] }; - } - const [excludeMutations, allMuts] = await Promise.all([ - Promise.all( - variantsToExclude.map((variant) => - getMutations( - config.clinicalLapis.lapisBaseUrl, - analysis.sequenceType, - { - [config.clinicalLapis.lineageField]: variant, - }, - 0.8, - 9, - ), - ), - ).then((r) => r.flat()), - getMutations(config.lapisBaseUrl, analysis.sequenceType, undefined, 0.05, 5), - ]); - return { - type: 'selected', - mutations: allMuts.filter((m) => !excludeMutations.includes(m)), - }; - } + analysis: WasapUntrackedFilter, +): Promise { + if (!config.untrackedAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'untracked' mode is not enabled."); + } + const variantsToExclude = + analysis.excludeSet === 'custom' + ? analysis.excludeVariants + : await getCladeLineages( + config.clinicalLapis.lapisBaseUrl, + config.clinicalLapis.cladeField, + config.clinicalLapis.lineageField, + true, + ).then((r) => Object.values(r)); + if (variantsToExclude === undefined) { + return { type: 'mutations', displayMutations: [] }; } + const [excludeMutations, allMuts] = await Promise.all([ + Promise.all( + variantsToExclude.map((variant) => + getMutations( + config.clinicalLapis.lapisBaseUrl, + analysis.sequenceType, + { + [config.clinicalLapis.lineageField]: variant, + }, + 0.8, + 9, + ), + ), + ).then((r) => r.flat()), + getMutations(config.lapisBaseUrl, analysis.sequenceType, undefined, 0.05, 5), + ]); + return { + type: 'mutations', + displayMutations: allMuts.filter((m) => !excludeMutations.includes(m)), + }; +} + +async function fetchCollectionModeData( + config: WasapPageConfig, + analysis: WasapCollectionFilter, +): Promise { + if (!config.collectionAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'collection' mode is not enabled."); + } + if (!analysis.collectionId) { + throw Error('No collection selected'); + } + const collection = await getCollection(config.collectionsApiBaseUrl, analysis.collectionId); + const queries: { + displayLabel: string; + countQuery: string; + coverageQuery: string; + }[] = []; + + collection.variants.forEach((f) => { + if (f.query.type === 'variantQuery') { + // TODO - this way of generating a coverageQuery sort-of works, but is not production ready + // we need to to it with the LAPIS endpoint: https://github.com/GenSpectrum/dashboards/issues/1026 + const positions = (f.query.variantQuery.match(/\d+/g) ?? []).map(Number); + const coverageQuery = positions.map((p) => `!C${p}N`).join(' | '); + queries.push({ + displayLabel: f.name, + countQuery: f.query.variantQuery, + coverageQuery, + }); + } + }); + return { + type: 'collection', + collection: { + id: collection.id, + title: collection.title, + queries, + }, + }; } export function getLapisFilterForTimeFrame(timeFrame: VariantTimeFrame, dateFieldName: string): LapisFilter { @@ -132,44 +197,35 @@ export function getLapisFilterForTimeFrame(timeFrame: VariantTimeFrame, dateFiel } /** - * The W-ASAP page data consists of the mutations to display in the mutations-over-time component, + * The W-ASAP page data can be either mutations data or collection data. + */ +export type WasapPageData = WasapMutationsData | WasapCollectionData; + +/** + * Mutations data consists of the mutations to display in the mutations-over-time component, * and the additional custom columns that might optionally be displayed. * * If displayMutations is undefined, that means that all mutations should be displayed * (That is the default behaviour of the mutations-over-time component). */ -type WasapPageData = { +export type WasapMutationsData = { + type: 'mutations'; displayMutations?: string[]; customColumns?: CustomColumn[]; }; /** - * Turns the internal `MutationSelection` into the easier-to-work-with `WasapPageData`. + * Collection data consists of a collection with its variants. */ -function wasapPageDataFromMutationSelection(mutationSelection: MutationSelection | undefined): WasapPageData { - if (mutationSelection === undefined) { - return {}; - } - - switch (mutationSelection.type) { - case 'all': - return {}; - case 'selected': - return { displayMutations: mutationSelection.mutations }; - case 'jaccard': - return { - displayMutations: mutationSelection.mutationsWithScore.map(({ mutation }) => mutation), - customColumns: [ - { - header: 'Jaccard index', - values: Object.fromEntries( - mutationSelection.mutationsWithScore.map(({ mutation, jaccardIndex }) => [ - mutation, - jaccardIndex.toPrecision(2), - ]), - ), - }, - ], - }; - } -} +export type WasapCollectionData = { + type: 'collection'; + collection: { + id: number; + title: string; + queries: { + displayLabel: string; + countQuery: string; + coverageQuery: string; + }[]; + }; +}; diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 1ff438119..65705dda6 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -50,7 +50,8 @@ export type WasapPageConfigBase = { type AnalysisModeConfigs = ManualAnalysisModeConfig & VariantAnalysisModeConfig & ResistanceAnalysisModeConfig & - UntrackedAnalyisModeConfig; + UntrackedAnalyisModeConfig & + CollectionAnalysisModeConfig; type ManualAnalysisModeConfig = | { @@ -108,10 +109,22 @@ type UntrackedAnalyisModeConfig = }; }; +type CollectionAnalysisModeConfig = + | { + collectionAnalysisModeEnabled?: never; + } + | { + collectionAnalysisModeEnabled: true; + collectionsApiBaseUrl: string; + filterDefaults: { + collection: WasapCollectionFilter; + }; + }; + /** * Convenience function to get the list of enabled modes. */ -export function enabledAnalysisModes(config: WasapPageConfig): WasapAnalysisMode[] { +export function enabledAnalysisModes(config: WasapPageConfig, isStaging: boolean): WasapAnalysisMode[] { const result: WasapAnalysisMode[] = []; if (config.manualAnalysisModeEnabled) { result.push('manual'); @@ -125,6 +138,9 @@ export function enabledAnalysisModes(config: WasapPageConfig): WasapAnalysisMode if (config.untrackedAnalysisModeEnabled) { result.push('untracked'); } + if (config.collectionAnalysisModeEnabled && isStaging) { + result.push('collection'); + } return result; } @@ -137,7 +153,7 @@ export type LinkTemplate = { aminoAcidMutation: string; }; -export type WasapAnalysisMode = 'manual' | 'variant' | 'resistance' | 'untracked'; +export type WasapAnalysisMode = 'manual' | 'variant' | 'resistance' | 'untracked' | 'collection'; /** * Contains mode-independent settings, like the filter for location and date range. @@ -203,7 +219,17 @@ export type WasapUntrackedFilter = { excludeVariants?: string[]; }; -export type WasapAnalysisFilter = WasapManualFilter | WasapVariantFilter | WasapResistanceFilter | WasapUntrackedFilter; +export type WasapCollectionFilter = { + mode: 'collection'; + collectionId?: number; +}; + +export type WasapAnalysisFilter = + | WasapManualFilter + | WasapVariantFilter + | WasapResistanceFilter + | WasapUntrackedFilter + | WasapCollectionFilter; export type WasapFilter = { base: WasapBaseFilter; diff --git a/website/src/covspectrum/getCollection.spec.ts b/website/src/covspectrum/getCollection.spec.ts new file mode 100644 index 000000000..7483d7e5f --- /dev/null +++ b/website/src/covspectrum/getCollection.spec.ts @@ -0,0 +1,167 @@ +import { http } from 'msw'; +import { beforeEach, describe, expect, test } from 'vitest'; + +import { getCollection } from './getCollection.ts'; +import { type CollectionRaw } from './types.ts'; +import { astroApiRouteMocker, testServer } from '../../vitest.setup.ts'; + +const DUMMY_COV_SPECTRUM_URL = 'http://cov-spectrum.dummy/api/v2'; + +describe('getCollection', () => { + beforeEach(() => { + astroApiRouteMocker.mockLog(); + }); + + test('should successfully fetch a single collection by id', async () => { + const mockCollection: CollectionRaw = { + id: 1, + title: "Editor's choice", + description: 'A curated collection', + maintainers: 'Test Team', + email: 'test@example.com', + variants: [ + { + query: '{ "pangoLineage": "JN.1*" }', + name: 'JN.1*', + description: 'JN.1 variant', + highlighted: false, + }, + ], + }; + + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/1`, () => { + return Response.json(mockCollection); + }), + ); + + const result = await getCollection(DUMMY_COV_SPECTRUM_URL, 1); + + expect(result.id).equals(1); + expect(result.title).equals("Editor's choice"); + expect(result.variants).toHaveLength(1); + expect(result.variants[0].name).equals('JN.1*'); + }); + + test('should construct the correct endpoint URL', async () => { + const baseUrl = 'https://example.com/api/v2'; + const collectionId = 42; + let requestedUrl = ''; + + const mockCollection: CollectionRaw = { + id: collectionId, + title: 'Test Collection', + description: 'Test description', + maintainers: 'Tester', + email: 'test@test.com', + variants: [], + }; + + testServer.use( + http.get(`${baseUrl}/resource/collection/${collectionId}`, ({ request }) => { + requestedUrl = request.url; + return Response.json(mockCollection); + }), + ); + + await getCollection(baseUrl, collectionId); + + expect(requestedUrl).equals(`${baseUrl}/resource/collection/${collectionId}`); + }); + + test('should validate collection structure with all required fields', async () => { + const mockCollection: CollectionRaw = { + id: 2, + title: 'Full Collection', + description: 'Complete collection with all fields', + maintainers: 'Team A, Team B', + email: 'teams@example.com', + variants: [ + { + query: '{ "type": "detailedMutations", "aaMutations": ["S:L441R"] }', + name: 'S:L441R', + description: 'Spike mutation', + highlighted: true, + }, + { + query: '{ "type": "detailedMutations", "nextcladePangoLineage": "XEC*" }', + name: 'XEC*', + description: 'XEC lineage', + highlighted: false, + }, + ], + }; + + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/2`, () => { + return Response.json(mockCollection); + }), + ); + + const result = await getCollection(DUMMY_COV_SPECTRUM_URL, 2); + + expect(result.id).equals(2); + expect(result.title).equals('Full Collection'); + expect(result.description).equals('Complete collection with all fields'); + expect(result.maintainers).equals('Team A, Team B'); + expect(result.email).equals('teams@example.com'); + expect(result.variants).toHaveLength(2); + expect(result.variants[0].highlighted).equals(true); + expect(result.variants[1].highlighted).equals(false); + }); + + test('should throw error when request fails with 404', async () => { + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/999`, () => { + return new Response(null, { status: 404 }); + }), + ); + + await expect(getCollection(DUMMY_COV_SPECTRUM_URL, 999)).rejects.toThrow(/Failed to fetch collection 999/); + }); + + test('should throw error when request fails with 500', async () => { + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/1`, () => { + return new Response(null, { status: 500 }); + }), + ); + + await expect(getCollection(DUMMY_COV_SPECTRUM_URL, 1)).rejects.toThrow(/Failed to fetch collection 1/); + }); + + test('should throw error when response validation fails', async () => { + const invalidData = { + id: 'not-a-number', // Invalid: should be number + title: 'Collection', + description: 'Description', + maintainers: 'Maintainer', + email: 'email@example.com', + variants: [], + }; + + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/1`, () => { + return Response.json(invalidData); + }), + ); + + await expect(getCollection(DUMMY_COV_SPECTRUM_URL, 1)).rejects.toThrow(/Failed to parse collection 1 response/); + }); + + test('should throw error when missing required fields', async () => { + const invalidData = { + id: 1, + title: 'Collection', + // Missing description, maintainers, email, variants + }; + + testServer.use( + http.get(`${DUMMY_COV_SPECTRUM_URL}/resource/collection/1`, () => { + return Response.json(invalidData); + }), + ); + + await expect(getCollection(DUMMY_COV_SPECTRUM_URL, 1)).rejects.toThrow(/Failed to parse collection 1 response/); + }); +}); diff --git a/website/src/covspectrum/getCollection.ts b/website/src/covspectrum/getCollection.ts new file mode 100644 index 000000000..eb60a0c6d --- /dev/null +++ b/website/src/covspectrum/getCollection.ts @@ -0,0 +1,73 @@ +import axios from 'axios'; + +import { getClientLogger } from '../clientLogger.ts'; +import { collectionRawSchema, collectionVariantSchema, type Collection, type CollectionVariant } from './types.ts'; + +const logger = getClientLogger('getCollection'); + +/** + * Fetches a single variant collection by ID from the CoV-Spectrum API. + * + * @param covSpectrumApiBaseUrl The base URL of the CoV-Spectrum API (e.g., 'https://cov-spectrum.org/api/v2') + * @param id The ID of the collection to fetch + * @returns A promise that resolves to a Collection object + * @throws Error if the request fails or response validation fails + */ +export async function getCollection(covSpectrumApiBaseUrl: string, id: number): Promise { + const url = `${covSpectrumApiBaseUrl}/resource/collection/${id}`; + + let response; + try { + response = await axios.get(url); + } catch (error) { + const message = `Failed to fetch collection ${id}: ${JSON.stringify(error)}`; + logger.error(message); + throw new Error(message); + } + + const parsedResponse = collectionRawSchema.safeParse(response.data); + if (!parsedResponse.success) { + const message = `Failed to parse collection ${id} response: ${JSON.stringify(parsedResponse.error)} (was ${JSON.stringify(response.data)})`; + logger.error(message); + throw new Error(message); + } + + // Parse the query field in each variant from JSON string to object + const rawCollection = parsedResponse.data; + const parsedVariants: CollectionVariant[] = []; + + for (const variant of rawCollection.variants) { + let parsedQuery; + try { + parsedQuery = JSON.parse(variant.query); + } catch (error) { + const message = `Failed to parse query JSON for variant "${variant.name}" in collection ${id}: ${error}`; + logger.error(message); + throw new Error(message); + } + + // Add type discriminator based on the shape of the parsed query + const queryWithType = + 'variantQuery' in parsedQuery + ? { type: 'variantQuery' as const, ...parsedQuery } + : { type: 'detailedMutations' as const, ...parsedQuery }; + + const variantValidation = collectionVariantSchema.safeParse({ + ...variant, + query: queryWithType, + }); + + if (!variantValidation.success) { + const message = `Failed to validate parsed variant "${variant.name}" in collection ${id}: ${JSON.stringify(variantValidation.error)}`; + logger.error(message); + throw new Error(message); + } + + parsedVariants.push(variantValidation.data); + } + + return { + ...rawCollection, + variants: parsedVariants, + }; +} diff --git a/website/src/covspectrum/getCollections.spec.ts b/website/src/covspectrum/getCollections.spec.ts index 8aab7c996..4ce2c6e32 100644 --- a/website/src/covspectrum/getCollections.spec.ts +++ b/website/src/covspectrum/getCollections.spec.ts @@ -2,7 +2,7 @@ import { http } from 'msw'; import { beforeEach, describe, expect, test } from 'vitest'; import { getCollections } from './getCollections.ts'; -import { type Collection } from './types.ts'; +import { type CollectionRaw } from './types.ts'; import { astroApiRouteMocker, testServer } from '../../vitest.setup.ts'; const DUMMY_COV_SPECTRUM_URL = 'http://cov-spectrum.dummy/api/v2'; @@ -13,7 +13,7 @@ describe('getCollections', () => { }); test('should return collections sorted by id', async () => { - const mockCollections: Collection[] = [ + const mockCollections: CollectionRaw[] = [ { id: 3, title: 'Third Collection', @@ -114,7 +114,7 @@ describe('getCollections', () => { }); test('should validate collection structure with variants', async () => { - const mockCollections: Collection[] = [ + const mockCollections: CollectionRaw[] = [ { id: 1, title: 'Test Collection', diff --git a/website/src/covspectrum/getCollections.ts b/website/src/covspectrum/getCollections.ts index 1e9519920..43f8efb54 100644 --- a/website/src/covspectrum/getCollections.ts +++ b/website/src/covspectrum/getCollections.ts @@ -1,7 +1,7 @@ import axios from 'axios'; import { getClientLogger } from '../clientLogger.ts'; -import { collectionsResponseSchema, type Collection } from './types.ts'; +import { collectionsRawResponseSchema, type CollectionRaw } from './types.ts'; const logger = getClientLogger('getCollections'); @@ -13,7 +13,7 @@ const logger = getClientLogger('getCollections'); * @returns A promise that resolves to an array of Collection objects * @throws Error if the request fails or response validation fails */ -export async function getCollections(covSpectrumApiBaseUrl: string): Promise { +export async function getCollections(covSpectrumApiBaseUrl: string): Promise { const url = `${covSpectrumApiBaseUrl}/resource/collection`; let response; @@ -25,7 +25,7 @@ export async function getCollections(covSpectrumApiBaseUrl: string): Promise c1.id - c2.id); diff --git a/website/src/covspectrum/types.ts b/website/src/covspectrum/types.ts index a1af759d1..82e50d612 100644 --- a/website/src/covspectrum/types.ts +++ b/website/src/covspectrum/types.ts @@ -1,12 +1,48 @@ import { z } from 'zod'; -export const collectionVariantSchema = z.object({ +export const collectionVariantRawSchema = z.object({ query: z.string(), name: z.string(), description: z.string(), highlighted: z.boolean(), }); +export const collectionRawSchema = z.object({ + id: z.number(), + title: z.string(), + description: z.string(), + maintainers: z.string(), + email: z.string(), + variants: z.array(collectionVariantRawSchema), +}); + +export const collectionsRawResponseSchema = z.array(collectionRawSchema); + +export type CollectionVariantRaw = z.infer; +export type CollectionRaw = z.infer; + +export const variantQuerySchema = z.object({ + type: z.literal('variantQuery'), + variantQuery: z.string(), +}); + +export const detailedMutationsQuerySchema = z.object({ + type: z.literal('detailedMutations'), + pangoLineage: z.string().optional(), + nextcladePangoLineage: z.string().optional(), + nucMutations: z.array(z.string()).optional(), + aaMutations: z.array(z.string()).optional(), + nucInsertions: z.array(z.string()).optional(), + aaInsertions: z.array(z.string()).optional(), +}); + +export const collectionVariantSchema = z.object({ + query: z.discriminatedUnion('type', [variantQuerySchema, detailedMutationsQuerySchema]), + name: z.string(), + description: z.string(), + highlighted: z.boolean(), +}); + export const collectionSchema = z.object({ id: z.number(), title: z.string(), @@ -16,7 +52,5 @@ export const collectionSchema = z.object({ variants: z.array(collectionVariantSchema), }); -export const collectionsResponseSchema = z.array(collectionSchema); - export type CollectionVariant = z.infer; export type Collection = z.infer; diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index 1cbc8f9b4..487f250c2 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -27,6 +27,7 @@ export const wastewaterOrganismConfigs: Record { const handler = new WasapPageStateHandler(config); @@ -147,7 +161,9 @@ describe('WasapPageStateHandler', () => { expect(filter.base.locationName).toBe('Zürich (ZH)'); expect(filter.base.granularity).toBe('day'); expect(filter.analysis.mode).toBe('manual'); - expect(filter.analysis.sequenceType).toBe('nucleotide'); + if (filter.analysis.mode === 'manual') { + expect(filter.analysis.sequenceType).toBe('nucleotide'); + } const newUrl = handler.toUrl(filter); expect(newUrl).toBe(url); @@ -320,4 +336,69 @@ describe('WasapPageStateHandler', () => { expect(analysis2.excludeVariants).toEqual(analysis1.excludeVariants); }); }); + + describe('collection mode', () => { + const handlerWithCollection = new WasapPageStateHandler(configWithCollection); + + it('throws error when feature is disabled', () => { + const url = '/wastewater/covid?analysisMode=collection&collectionId=123&'; + expect(() => handler.parsePageStateFromUrl(new URL(`http://example.com${url}`))).toThrow( + "The 'collection' analysis mode is not enabled.", + ); + }); + + it('parses and encodes collection filter with collectionId', () => { + const url = + '/wastewater/covid?' + + 'locationName=Z%C3%BCrich+%28ZH%29&' + + 'granularity=day&' + + 'analysisMode=collection&' + + 'collectionId=123&'; + const filter = handlerWithCollection.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + expect(filter.analysis.mode).toBe('collection'); + const analysis = filter.analysis as WasapCollectionFilter; + expect(analysis.collectionId).toBe(123); + + const newUrl = handlerWithCollection.toUrl(filter); + expect(newUrl).toBe(url); + }); + + it('parses collection filter without collectionId', () => { + const url = '/wastewater/covid?analysisMode=collection&'; + const filter = handlerWithCollection.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + expect(filter.analysis.mode).toBe('collection'); + const analysis = filter.analysis as WasapCollectionFilter; + expect(analysis.collectionId).toBeUndefined(); + }); + + it('encodes collection filter omits undefined collectionId', () => { + const url = '/wastewater/covid?analysisMode=collection&'; + const filter = handlerWithCollection.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + const encodedUrl = handlerWithCollection.toUrl(filter); + expect(encodedUrl).not.toContain('collectionId'); + }); + + it('converts collectionId string to number', () => { + const url = '/wastewater/covid?analysisMode=collection&collectionId=456&'; + const filter = handlerWithCollection.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + + const analysis = filter.analysis as WasapCollectionFilter; + expect(typeof analysis.collectionId).toBe('number'); + expect(analysis.collectionId).toBe(456); + }); + + it('collection mode round-trip preserves collectionId', () => { + const url = '/wastewater/covid?analysisMode=collection&collectionId=789&'; + const filter1 = handlerWithCollection.parsePageStateFromUrl(new URL(`http://example.com${url}`)); + const url2 = handlerWithCollection.toUrl(filter1); + const filter2 = handlerWithCollection.parsePageStateFromUrl(new URL(`http://example.com${url2}`)); + + const analysis1 = filter1.analysis as WasapCollectionFilter; + const analysis2 = filter2.analysis as WasapCollectionFilter; + expect(analysis2.collectionId).toBe(analysis1.collectionId); + }); + }); }); diff --git a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts index d16b8e31f..a0a4ed6a0 100644 --- a/website/src/views/pageStateHandlers/WasapPageStateHandler.ts +++ b/website/src/views/pageStateHandlers/WasapPageStateHandler.ts @@ -35,7 +35,7 @@ export class WasapPageStateHandler implements PageStateHandler { const providedMode = texts.analysisMode as WasapAnalysisMode | undefined; // config provided defaults - const defaultMode = enabledAnalysisModes(this.config)[0]; + const defaultMode = enabledAnalysisModes(this.config, false)[0]; const mode = providedMode ?? defaultMode; @@ -91,6 +91,15 @@ export class WasapPageStateHandler implements PageStateHandler { excludeVariants: texts.excludeVariants?.split('|'), }; break; + case 'collection': + if (!this.config.collectionAnalysisModeEnabled) { + throw Error("The 'collection' analysis mode is not enabled."); + } + analysis = { + mode, + collectionId: texts.collectionId ? Number(texts.collectionId) : undefined, + }; + break; } const base: WasapBaseFilter = { @@ -142,6 +151,13 @@ export class WasapPageStateHandler implements PageStateHandler { setSearchFromString(search, 'excludeVariants', analysis.excludeVariants?.join('|')); } break; + case 'collection': + setSearchFromString( + search, + 'collectionId', + analysis.collectionId ? String(analysis.collectionId) : undefined, + ); + break; } return formatUrl(this.config.path, search); @@ -216,5 +232,9 @@ function generateWasapFilterConfig(pageConfig: WasapPageConfig): BaselineFilterC type: 'text', lapisField: 'excludeVariants', }, + { + type: 'text', + lapisField: 'collectionId', + }, ]; } diff --git a/website/test-extend.ts b/website/test-extend.ts index e8a4bc710..65c49f67f 100644 --- a/website/test-extend.ts +++ b/website/test-extend.ts @@ -2,7 +2,7 @@ import { setupWorker } from 'msw/browser'; import '@testing-library/jest-dom/vitest'; import { it as itBase } from 'vitest'; -import { AstroApiRouteMocker, BackendRouteMocker, LapisRouteMocker } from './routeMocker.ts'; +import { AstroApiRouteMocker, BackendRouteMocker, CovSpectrumRouteMocker, LapisRouteMocker } from './routeMocker.ts'; import setupDayjs from './src/util/setupDayjs.ts'; setupDayjs(); @@ -12,6 +12,7 @@ export const worker = setupWorker(); export const lapisRouteMocker = new LapisRouteMocker(worker); export const astroApiRouteMocker = new AstroApiRouteMocker(worker); export const backendRouteMocker = new BackendRouteMocker(worker); +export const covSpectrumRouteMocker = new CovSpectrumRouteMocker(worker); /** * Test extension to access the mocks. Import it: @@ -28,14 +29,24 @@ export const backendRouteMocker = new BackendRouteMocker(worker); * ... */ export const it = itBase.extend<{ - routeMockers: { lapis: LapisRouteMocker; astro: AstroApiRouteMocker; backend: BackendRouteMocker }; + routeMockers: { + lapis: LapisRouteMocker; + astro: AstroApiRouteMocker; + backend: BackendRouteMocker; + covSpectrum: CovSpectrumRouteMocker; + }; }>({ routeMockers: [ // eslint-disable-next-line no-empty-pattern -- vitest needs the 1st arg to be an object destructor async ({}, use) => { await worker.start({ onUnhandledRequest: 'error' }); - await use({ lapis: lapisRouteMocker, astro: astroApiRouteMocker, backend: backendRouteMocker }); + await use({ + lapis: lapisRouteMocker, + astro: astroApiRouteMocker, + backend: backendRouteMocker, + covSpectrum: covSpectrumRouteMocker, + }); worker.resetHandlers(); worker.stop(); diff --git a/website/vitest.setup.ts b/website/vitest.setup.ts index 329762296..dc1516d7a 100755 --- a/website/vitest.setup.ts +++ b/website/vitest.setup.ts @@ -2,7 +2,7 @@ import { setupServer } from 'msw/node'; import { afterAll, afterEach, beforeAll } from 'vitest'; import '@testing-library/jest-dom/vitest'; -import { AstroApiRouteMocker, BackendRouteMocker, LapisRouteMocker } from './routeMocker.ts'; +import { AstroApiRouteMocker, BackendRouteMocker, CovSpectrumRouteMocker, LapisRouteMocker } from './routeMocker.ts'; import setupDayjs from './src/util/setupDayjs.ts'; setupDayjs(); @@ -15,6 +15,8 @@ export const lapisRouteMocker = new LapisRouteMocker(testServer); export const backendRouteMocker = new BackendRouteMocker(testServer); +export const covSpectrumRouteMocker = new CovSpectrumRouteMocker(testServer); + beforeAll(() => testServer.listen({ onUnhandledRequest: 'warn' })); afterAll(() => testServer.close());