diff --git a/collection-seeding/sources/covid_pango_lineages.py b/collection-seeding/sources/covid_pango_lineages.py index 0f54b7ed9..cbc9a64e9 100644 --- a/collection-seeding/sources/covid_pango_lineages.py +++ b/collection-seeding/sources/covid_pango_lineages.py @@ -34,10 +34,13 @@ def get_collections(self) -> list[Collection]: def _build_collection(self, entry: dict) -> Collection: lineage: str = entry["lineage"] - parent: str = entry.get("parent") or "—" + raw_parent: str = entry.get("parent") or "" + parent: str = raw_parent or "—" clade: str = entry.get("nextstrainClade") or "—" date: str = entry.get("designationDate") or "unknown" + parent_clause = f" ({raw_parent})" if raw_parent else "" + nuc_subs = [s for s in entry.get("nucSubstitutions", []) if s] aa_subs = [s for s in entry.get("aaSubstitutions", []) if s] nuc_subs_new = [s for s in entry.get("nucSubstitutionsNew", []) if s] @@ -47,21 +50,25 @@ def _build_collection(self, entry: dict) -> Collection: { "type": "filterObject", "name": "Nucleotide substitutions", + "description": "All nucleotide substitutions that define this lineage.", "filterObject": {"nucleotideMutations": nuc_subs}, }, { "type": "filterObject", "name": "Amino acid substitutions", + "description": "All amino acid substitutions that define this lineage.", "filterObject": {"aminoAcidMutations": aa_subs}, }, { "type": "filterObject", "name": "New nucleotide substitutions", + "description": f"Nucleotide substitutions not present in the parent lineage{parent_clause}.", "filterObject": {"nucleotideMutations": nuc_subs_new}, }, { "type": "filterObject", "name": "New amino acid substitutions", + "description": f"Amino acid substitutions not present in the parent lineage{parent_clause}.", "filterObject": {"aminoAcidMutations": aa_subs_new}, }, ] diff --git a/collection-seeding/tests/test_influenza_resistance_mutations.py b/collection-seeding/tests/test_influenza_resistance_mutations.py index 55b11dcf7..116e85645 100644 --- a/collection-seeding/tests/test_influenza_resistance_mutations.py +++ b/collection-seeding/tests/test_influenza_resistance_mutations.py @@ -306,7 +306,6 @@ def test_mutations_prefixed_with_na(): assert aa.startswith("NA:"), f"Expected NA: prefix, got {aa!r}" - # --- PA inhibitor (Baloxavir) collections ---------------------------------- diff --git a/collection-seeding/tests/test_pango_lineages.py b/collection-seeding/tests/test_pango_lineages.py index dcb06b9a0..523c1a6f5 100644 --- a/collection-seeding/tests/test_pango_lineages.py +++ b/collection-seeding/tests/test_pango_lineages.py @@ -77,6 +77,43 @@ def test_build_collection_always_four_variants(): assert len(col["variants"]) == 4 +def test_build_collection_variant_descriptions_with_parent(): + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) + variants = col["variants"] + assert ( + variants[0]["description"] + == "All nucleotide substitutions that define this lineage." + ) + assert ( + variants[1]["description"] + == "All amino acid substitutions that define this lineage." + ) + assert ( + variants[2]["description"] + == "Nucleotide substitutions not present in the parent lineage (BA)." + ) + assert ( + variants[3]["description"] + == "Amino acid substitutions not present in the parent lineage (BA)." + ) + + +def test_build_collection_variant_descriptions_unknown_parent(): + col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["XBB"]) + variants = col["variants"] + # No parent clause (and no dangling "(—)") when the parent is unknown + assert ( + variants[2]["description"] + == "Nucleotide substitutions not present in the parent lineage." + ) + assert ( + variants[3]["description"] + == "Amino acid substitutions not present in the parent lineage." + ) + assert "—" not in variants[2]["description"] + assert "—" not in variants[3]["description"] + + def test_build_collection_variant_names(): col = CovidPangoLineagesSource()._build_collection(SAMPLE_DATA["BA.2"]) names = [v["name"] for v in col["variants"]] diff --git a/website/playwright.config.ts b/website/playwright.config.ts index c4d83a565..84c0d6f30 100644 --- a/website/playwright.config.ts +++ b/website/playwright.config.ts @@ -18,6 +18,9 @@ export default defineConfig({ trace: 'retain-on-failure', screenshot: 'only-on-failure', }, + expect: { + timeout: 15_000, + }, projects: [ // API integration tests (browser-independent) diff --git a/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx b/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx index 139c6148b..dfc1cebc2 100644 --- a/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx +++ b/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { CollectionsList } from './CollectionsList.tsx'; @@ -15,9 +16,14 @@ import { usePageState } from '../usePageState.ts'; export type CovidSingleVariantReactPageProps = { organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; -export const CovidSingleVariantReactPage: FC = ({ organismsConfig, isStaging }) => { +export const CovidSingleVariantReactPage: FC = ({ + organismsConfig, + isStaging, + mutationAnnotations, +}) => { const organismViewKey: OrganismViewKey = 'covid.singleVariantView'; const view = useMemo(() => new Routing(organismsConfig).getOrganismView(organismViewKey), [organismsConfig]); @@ -49,6 +55,7 @@ export const CovidSingleVariantReactPage: FC = view={view} downloadLinks={downloadLinks} organismsConfig={organismsConfig} + mutationAnnotations={mutationAnnotations} filters={ <> , typeof Organisms.covid>; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericAnalyseSingleVariantReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${singleVariantViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -57,6 +60,7 @@ export const GenericAnalyseSingleVariantReactPage: FC @@ -21,6 +24,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${singleVariantView organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro b/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro index e6216e72e..dd94158bf 100644 --- a/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro +++ b/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro @@ -2,6 +2,7 @@ import { GenericCompareSideBySideReactPage } from './GenericCompareSideBySideReactPage'; import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareSideBySideViewKey } from '../../../views/viewKeys'; @@ -15,6 +16,8 @@ const { organism, hideMutationComponents } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareSideBySideViewKey}`); const organismsConfig = getDashboardsConfig().dashboards.organisms; + +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- @@ -23,6 +26,7 @@ const organismsConfig = getDashboardsConfig().dashboards.organisms; hideMutationComponents={hideMutationComponents} organismsConfig={organismsConfig} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx b/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx index 9d58f2243..13d11342f 100644 --- a/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx +++ b/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo, useState, useEffect } from 'react'; import { GenericCompareSideBySideDataDisplay } from './GenericCompareSideBySideDataDisplay.tsx'; @@ -15,6 +16,7 @@ export type GenericCompareSideBySideReactPageProps = { hideMutationComponents?: boolean; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareSideBySideReactPage: FC = ({ @@ -22,6 +24,7 @@ export const GenericCompareSideBySideReactPage: FC { const organismViewKey = `${organism}.${compareSideBySideViewKey}` satisfies OrganismViewKey; @@ -45,6 +48,7 @@ export const GenericCompareSideBySideReactPage: FC
diff --git a/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro b/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro index 650f35d58..96868e346 100644 --- a/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro +++ b/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro @@ -2,6 +2,7 @@ import { GenericCompareToBaselineReactPage } from './GenericCompareToBaselineReactPage'; import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareToBaselineViewKey } from '../../../views/viewKeys'; @@ -13,6 +14,8 @@ interface Props { const { organism } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareToBaselineViewKey}`); + +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- @@ -20,6 +23,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${compareToBaseline organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx b/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx index c750149bd..9beb450b7 100644 --- a/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx +++ b/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericCompareToBaselineDataDisplay } from './GenericCompareToBaselineDataDisplay'; @@ -13,12 +14,14 @@ export type GenericCompareToBaselineReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareToBaselineReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${compareToBaselineViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -44,6 +47,7 @@ export const GenericCompareToBaselineReactPage: FC @@ -20,6 +23,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${compareVariantsVi organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx b/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx index 737f16139..3c16ff38d 100644 --- a/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx +++ b/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericCompareVariantsDataDisplay } from './GenericCompareVariantsDataDisplay'; @@ -13,12 +14,14 @@ export type GenericCompareVariantsReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareVariantsReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${compareVariantsViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -44,6 +47,7 @@ export const GenericCompareVariantsReactPage: FC @@ -20,6 +23,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${sequencingEfforts organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx b/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx index 2f7eb91d3..f46bf3b14 100644 --- a/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx +++ b/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericSequencingEffortsDataDisplay } from './GenericSequencingEffortsDataDisplay'; @@ -12,12 +13,14 @@ export type GenericSequencingEffortsReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericSequencingEffortsReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${sequencingEffortsViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -42,6 +45,7 @@ export const GenericSequencingEffortsReactPage: FC = {}; setConfigs.forEach((setConfig, i) => { const filterVariants = collections[i].variants.filter((v) => v.type === 'filterObject'); - const allMutations = filterVariants.flatMap((v) => v.filterObject.aminoAcidMutations ?? []); - - displayMutationsBySet[setConfig.name] = allMutations; - - mutationAnnotations.push({ - name: setConfig.name, - symbol: setConfig.annotationSymbol, - description: setConfig.description, - aminoAcidMutations: filterVariants.flatMap((variant) => - (variant.filterObject.aminoAcidMutations ?? []).map((mutation) => ({ - mutation, - name: variant.name, - })), - ), - }); + displayMutationsBySet[setConfig.name] = filterVariants.flatMap((v) => v.filterObject.aminoAcidMutations ?? []); }); return { mutationAnnotations, displayMutationsBySet }; diff --git a/website/src/components/views/wasap/useWasapPageData.spec.ts b/website/src/components/views/wasap/useWasapPageData.spec.ts index f0ea12411..214e2e108 100644 --- a/website/src/components/views/wasap/useWasapPageData.spec.ts +++ b/website/src/components/views/wasap/useWasapPageData.spec.ts @@ -2,7 +2,7 @@ import dayjs from 'dayjs'; import { http } from 'msw'; import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { fetchWasapPageData, getLapisFilterForTimeFrame } from './useWasapPageData.ts'; +import { fetchWasapPageData, getLapisFilterForTimeFrame, WasapValidationError } from './useWasapPageData.ts'; import { EXCLUDE_SET_NAME, SEQUENCE_TYPE, @@ -79,7 +79,7 @@ describe('fetchWasapPageData', () => { {}, { mode: WASAP_ANALYSIS_MODE.manual, sequenceType: SEQUENCE_TYPE.nucleotide, mutations: [] }, ), - ).rejects.toThrow("Cannot fetch data, 'manual' mode is not enabled."); + ).rejects.toThrow(WasapValidationError); }); }); @@ -310,7 +310,7 @@ describe('fetchWasapPageData', () => { timeFrame: VARIANT_TIME_FRAME.all, }, ), - ).rejects.toThrow("Cannot fetch data, 'variant' mode is not enabled."); + ).rejects.toThrow(WasapValidationError); }); }); @@ -411,7 +411,7 @@ describe('fetchWasapPageData', () => { {}, { mode: WASAP_ANALYSIS_MODE.untracked, sequenceType: SEQUENCE_TYPE.nucleotide }, ), - ).rejects.toThrow("Cannot fetch data, 'untracked' mode is not enabled."); + ).rejects.toThrow(WasapValidationError); }); }); @@ -558,7 +558,7 @@ describe('fetchWasapPageData', () => { {}, { mode: WASAP_ANALYSIS_MODE.covSpectrumCollection, collectionId: 42 }, ), - ).rejects.toThrow("Cannot fetch data, 'covSpectrumCollection' mode is not enabled."); + ).rejects.toThrow(WasapValidationError); }); test('throws when no collection is selected', async () => { @@ -568,7 +568,7 @@ describe('fetchWasapPageData', () => { {}, { mode: WASAP_ANALYSIS_MODE.covSpectrumCollection, collectionId: undefined }, ), - ).rejects.toThrow('No collection selected'); + ).rejects.toThrow(WasapValidationError); }); }); @@ -938,13 +938,13 @@ describe('fetchWasapPageData', () => { await expect( fetchWasapPageData(disabledConfig, {}, { mode: WASAP_ANALYSIS_MODE.collection, collectionId: 1 }), - ).rejects.toThrow("Cannot fetch data, 'collection' mode is not enabled."); + ).rejects.toThrow(WasapValidationError); }); test('throws when no collection is selected', async () => { await expect( fetchWasapPageData(config, {}, { mode: WASAP_ANALYSIS_MODE.collection, collectionId: undefined }), - ).rejects.toThrow('No collection selected'); + ).rejects.toThrow(WasapValidationError); }); }); }); diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index 62c7bfb6d..a2aa86dc4 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -24,6 +24,13 @@ import { getLineageFields } from '../../../types/Collection'; import type { FilterObject, Variant } from '../../../types/Collection'; import { validateGenomeOnly } from '../../../util/siloExpressionUtils'; +export class WasapValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'WasapValidationError'; + } +} + /** * Hook that fetches and returns `WasapPageData` for the W-ASAP page, * depending on the analysis mode and analysis mode settings. @@ -38,6 +45,7 @@ export function useWasapPageData( return useQuery({ queryKey: ['wasap', analysis, resistanceMutationsBySet], queryFn: () => fetchWasapPageData(config, resistanceMutationsBySet, analysis), + retry: (failureCount, error) => !(error instanceof WasapValidationError) && failureCount < 3, }); } @@ -64,7 +72,7 @@ export async function fetchWasapPageData( function fetchManualModeData(config: WasapPageConfig, analysis: WasapManualFilter): WasapMutationsData { if (!config.manualAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'manual' mode is not enabled."); + throw new WasapValidationError("Cannot fetch data, 'manual' mode is not enabled."); } return { type: 'mutations', @@ -77,7 +85,7 @@ async function fetchVariantModeData( analysis: WasapVariantFilter, ): Promise { if (!config.variantAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'variant' mode is not enabled."); + throw new WasapValidationError("Cannot fetch data, 'variant' mode is not enabled."); } switch (analysis.signatureType) { case 'computed': @@ -92,7 +100,7 @@ async function fetchVariantComputedModeData( analysis: WasapVariantFilter, ): Promise { if (!config.variantAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'variant' mode is not enabled."); + throw new WasapValidationError("Cannot fetch data, 'variant' mode is not enabled."); } const mutationsWithScore = await getMutationsForVariant( config.clinicalLapis.lapisBaseUrl, @@ -124,10 +132,10 @@ async function fetchVariantPredefinedModeData( analysis: WasapVariantFilter, ): Promise { if (!config.variantAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'variant' mode is not enabled."); + throw new WasapValidationError("Cannot fetch data, 'variant' mode is not enabled."); } if (analysis.collectionId === undefined) { - throw new Error('No collection selected for predefined variant mode.'); + throw new WasapValidationError('No collection selected for predefined variant mode.'); } const collection = await getBackendServiceForClientside().getCollection({ id: String(analysis.collectionId) }); @@ -141,10 +149,12 @@ async function fetchVariantPredefinedModeData( const variant = collection.variants.find((v) => v.name === variantName); if (!variant) { - throw new Error(`Variant "${variantName}" not found in collection ${collection.id}.`); + throw new WasapValidationError(`Variant "${variantName}" not found in collection ${collection.id}.`); } if (variant.type !== 'filterObject') { - throw new Error(`Variant "${variantName}" in collection ${collection.id} is not a filterObject variant.`); + throw new WasapValidationError( + `Variant "${variantName}" in collection ${collection.id} is not a filterObject variant.`, + ); } const mutations = @@ -199,7 +209,7 @@ async function fetchUntrackedModeData( analysis: WasapUntrackedFilter, ): Promise { if (!config.untrackedAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'untracked' mode is not enabled."); + throw new WasapValidationError("Cannot fetch data, 'untracked' mode is not enabled."); } const variantsToExclude = analysis.excludeSet === 'custom' @@ -240,10 +250,10 @@ async function fetchCovSpectrumCollectionModeData( analysis: WasapCovSpectrumCollectionFilter, ): Promise { if (!config.covSpectrumCollectionAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'covSpectrumCollection' mode is not enabled."); + throw new WasapValidationError("Cannot fetch data, 'covSpectrumCollection' mode is not enabled."); } if (analysis.collectionId === undefined) { - throw Error('No collection selected'); + throw new WasapValidationError('No collection selected'); } const collection = await getCollection(config.collectionsApiBaseUrl, analysis.collectionId); @@ -270,10 +280,10 @@ async function fetchCollectionModeData( analysis: WasapCollectionFilter, ): Promise { if (!config.collectionAnalysisModeEnabled) { - throw Error("Cannot fetch data, 'collection' mode is not enabled."); + throw new WasapValidationError("Cannot fetch data, 'collection' mode is not enabled."); } if (analysis.collectionId === undefined) { - throw Error('No collection selected'); + throw new WasapValidationError('No collection selected'); } const collection = await getBackendServiceForClientside().getCollection({ id: String(analysis.collectionId) }); diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 74575de67..d4e05c7f0 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -1,6 +1,7 @@ import type { DateRangeOption, SequenceType, TemporalGranularity } from '@genspectrum/dashboard-components/util'; import type { Organism } from '../../../types/Organism.ts'; +import type { ResistanceMutationCollectionConfig } from '../../../util/resistanceMutations'; export const SEQUENCE_TYPE = { nucleotide: 'nucleotide', @@ -305,13 +306,3 @@ export type WasapFilter = { base: WasapBaseFilter; analysis: WasapAnalysisFilter; }; - -/** - * Resistance mutations defined in a collection, which is specified via the collection ID. - */ -export type ResistanceMutationCollectionConfig = { - collectionId: number; - name: string; - description: string; - annotationSymbol: string; -}; diff --git a/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx b/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx index ba77e9674..1e7cba6ee 100644 --- a/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx +++ b/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import type { FC, PropsWithChildren } from 'react'; import { type DownloadLink } from './AccessionsDownloadButton.tsx'; @@ -10,12 +11,14 @@ export type OrganismViewPageLayoutProps = PropsWithChildren<{ view: View>; downloadLinks: DownloadLink[]; lapisUrl: string; + mutationAnnotations?: MutationAnnotations; }>; export const OrganismViewPageLayout: FC = ({ view, downloadLinks, lapisUrl, + mutationAnnotations, children, }) => { return ( @@ -26,7 +29,10 @@ export const OrganismViewPageLayout: FC = ({ lapisUrl={lapisUrl} accessionDownloadFields={view.organismConstants.accessionDownloadFields} > - + {children} diff --git a/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx b/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx index ceebf3a44..ca2b33c96 100644 --- a/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx +++ b/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import type { FC, ReactNode } from 'react'; import { type DownloadLink } from './AccessionsDownloadButton.tsx'; @@ -13,6 +14,7 @@ export type SingleVariantOrganismPageLayoutProps = { organismsConfig: OrganismsConfig; filters: ReactNode; dataDisplay: ReactNode; + mutationAnnotations?: MutationAnnotations; }; export const SingleVariantOrganismPageLayout: FC = ({ @@ -21,12 +23,14 @@ export const SingleVariantOrganismPageLayout: FC { return (
{filters}
diff --git a/website/src/pages/covid/single-variant.astro b/website/src/pages/covid/single-variant.astro index fa95aaefb..1cc751cdf 100644 --- a/website/src/pages/covid/single-variant.astro +++ b/website/src/pages/covid/single-variant.astro @@ -2,15 +2,19 @@ import { CovidSingleVariantReactPage } from '../../components/views/analyzeSingleVariant/CovidSingleVariantReactPage'; import { isStaging, getDashboardsConfig } from '../../config'; import BaseLayout from '../../layouts/base/BaseLayout.astro'; +import { fetchOrganismMutationAnnotations } from '../../util/fetchOrganismMutationAnnotations'; import { ServerSide } from '../../views/serverSideRouting'; const view = ServerSide.routing.getOrganismView('covid.singleVariantView'); + +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, 'covid'); --- diff --git a/website/src/types/dbIdSpace.ts b/website/src/types/dbIdSpace.ts index be1828a16..019943a03 100644 --- a/website/src/types/dbIdSpace.ts +++ b/website/src/types/dbIdSpace.ts @@ -6,6 +6,17 @@ export const dbIdSpaces = { export type DbIdSpace = (typeof dbIdSpaces)[keyof typeof dbIdSpaces]; +export function byEnv(env: DbIdSpace, vars: { prod: T; staging: T; local: T }): T { + switch (env) { + case dbIdSpaces.prod: + return vars.prod; + case dbIdSpaces.staging: + return vars.staging; + case dbIdSpaces.local: + return vars.local; + } +} + export function getDbIdSpace(): DbIdSpace { const envValue = process.env.DB_ID_SPACE ?? import.meta.env.DB_ID_SPACE; if (envValue) { diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index d11cde4c6..ec1928521 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -1,19 +1,12 @@ import type { MutationAnnotation } from '@genspectrum/dashboard-components/util'; -import { getDbIdSpace, dbIdSpaces, type DbIdSpace } from './dbIdSpace'; -import type { ResistanceMutationCollectionConfig } from '../components/views/wasap/wasapPageConfig'; +import { byEnv, dbIdSpaces, getDbIdSpace, type DbIdSpace } from './dbIdSpace'; import { VARIANT_TIME_FRAME, type WasapPageConfig } from '../components/views/wasap/wasapPageConfig'; - -function byEnv(env: DbIdSpace, vars: { prod: T; staging: T; local: T }): T { - switch (env) { - case dbIdSpaces.prod: - return vars.prod; - case dbIdSpaces.staging: - return vars.staging; - case dbIdSpaces.local: - return vars.local; - } -} +import { + buildCovidResistanceMutationCollections, + buildRsvAResistanceMutationCollections, + buildRsvBResistanceMutationCollections, +} from '../util/resistanceMutations'; export const wastewaterOrganisms = { covid: 'covid', @@ -45,29 +38,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordStanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - { - collectionId: byEnv(env, { prod: 5, staging: 2, local: 2 }), - name: 'RdRp', - annotationSymbol: 'r', - description: - 'SARS-CoV-2 RNA-dependent RNA polymerase (RdRP) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - { - collectionId: byEnv(env, { prod: 6, staging: 3, local: 3 }), - name: 'Spike', - annotationSymbol: 's', - description: - 'SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildCovidResistanceMutationCollections(env), lapisBaseUrl: 'https://lapis.wasap.genspectrum.org/covid', samplingDateField: 'samplingDate', locationNameField: 'locationName', @@ -157,22 +128,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordViralZone.', - }, - { - collectionId: byEnv(env, { prod: 4984, staging: 5, local: 5 }), - name: 'Palivizumab', - annotationSymbol: 'p', - description: - 'RSV-A F protein resistance mutations against Palivizumab as per ViralZone.', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildRsvAResistanceMutationCollections(env), filterDefaults: { manual: { mode: 'manual', @@ -233,22 +189,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordViralZone.', - }, - { - collectionId: byEnv(env, { prod: 4986, staging: 7, local: 7 }), - name: 'Palivizumab', - annotationSymbol: 'p', - description: - 'RSV-B F protein resistance mutations against Palivizumab as per ViralZone.', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildRsvBResistanceMutationCollections(env), filterDefaults: { manual: { mode: 'manual', diff --git a/website/src/util/fetchOrganismMutationAnnotations.ts b/website/src/util/fetchOrganismMutationAnnotations.ts new file mode 100644 index 000000000..51beee493 --- /dev/null +++ b/website/src/util/fetchOrganismMutationAnnotations.ts @@ -0,0 +1,30 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; + +import { BackendService } from '../backendApi/backendService.ts'; +import { getBackendHost } from '../config'; +import { getInstanceLogger } from '../logger.ts'; +import { getErrorLogMessage } from './getErrorLogMessage'; +import { fetchMutationAnnotationsFromCollections } from './resistanceMutations'; +import { getDbIdSpace } from '../types/dbIdSpace'; +import type { OrganismConstants } from '../views/OrganismConstants'; + +const logger = getInstanceLogger('fetchOrganismMutationAnnotations'); + +export async function fetchOrganismMutationAnnotations( + constants: OrganismConstants, + organism: string, +): Promise { + const buildCollections = constants.buildResistanceMutationCollections; + if (!buildCollections) { + return undefined; + } + try { + return await fetchMutationAnnotationsFromCollections( + buildCollections(getDbIdSpace()), + new BackendService(getBackendHost()), + ); + } catch (error) { + logger.error(`Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`); + return undefined; + } +} diff --git a/website/src/util/resistanceMutations.spec.ts b/website/src/util/resistanceMutations.spec.ts new file mode 100644 index 000000000..01549c8b4 --- /dev/null +++ b/website/src/util/resistanceMutations.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMutationAnnotations } from './resistanceMutations.ts'; +import type { Collection } from '../types/Collection.ts'; + +describe('buildMutationAnnotations', () => { + it('returns empty array for empty inputs', () => { + expect(buildMutationAnnotations([], [])).toEqual([]); + }); + + it('maps annotationSymbol to symbol and preserves name and description', () => { + const config = { collectionId: 1, name: 'Spike', description: 'spike desc', annotationSymbol: 's' }; + const collection = { + id: 1, + name: 'Spike', + variants: [ + { + type: 'filterObject', + name: 'v1', + filterObject: { aminoAcidMutations: ['S:E484K'] }, + }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.name).toBe('Spike'); + expect(annotation.symbol).toBe('s'); + expect(annotation.description).toBe('spike desc'); + }); + + it('excludes query-type variants', () => { + const config = { collectionId: 1, name: 'Set', description: '', annotationSymbol: 'x' }; + const collection = { + id: 1, + name: 'Set', + variants: [ + { type: 'query', name: 'ignored', countQuery: 'q' }, + { type: 'filterObject', name: 'included', filterObject: { aminoAcidMutations: ['S:E484K'] } }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.aminoAcidMutations).toEqual([{ mutation: 'S:E484K', name: 'included' }]); + }); + + it('treats missing aminoAcidMutations as empty', () => { + const config = { collectionId: 1, name: 'Set', description: '', annotationSymbol: 'x' }; + const collection = { + id: 1, + name: 'Set', + variants: [ + { type: 'filterObject', name: 'no-mutations', filterObject: {} }, + { type: 'filterObject', name: 'has-mutation', filterObject: { aminoAcidMutations: ['S:N501Y'] } }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.aminoAcidMutations).toEqual([{ mutation: 'S:N501Y', name: 'has-mutation' }]); + }); + + it('builds one annotation per config, preserving order', () => { + const configs = [ + { collectionId: 1, name: '3CLpro', description: 'protease', annotationSymbol: 'c' }, + { collectionId: 2, name: 'Spike', description: 'spike', annotationSymbol: 's' }, + ]; + const collections = [ + { id: 1, name: '3CLpro', variants: [] } as unknown as Collection, + { id: 2, name: 'Spike', variants: [] } as unknown as Collection, + ]; + + const annotations = buildMutationAnnotations(configs, collections); + + expect(annotations).toHaveLength(2); + expect(annotations[0].name).toBe('3CLpro'); + expect(annotations[1].name).toBe('Spike'); + }); +}); diff --git a/website/src/util/resistanceMutations.ts b/website/src/util/resistanceMutations.ts new file mode 100644 index 000000000..e937ba611 --- /dev/null +++ b/website/src/util/resistanceMutations.ts @@ -0,0 +1,106 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; + +import type { BackendService } from '../backendApi/backendService'; +import type { Collection } from '../types/Collection'; +import { byEnv, type DbIdSpace } from '../types/dbIdSpace'; + +export type ResistanceMutationCollectionConfig = { + collectionId: number; + name: string; + description: string; + annotationSymbol: string; +}; + +export function buildMutationAnnotations( + setConfigs: ResistanceMutationCollectionConfig[], + collections: Collection[], +): MutationAnnotations { + return setConfigs.map((setConfig, i) => { + const filterVariants = collections[i].variants.filter((v) => v.type === 'filterObject'); + return { + name: setConfig.name, + symbol: setConfig.annotationSymbol, + description: setConfig.description, + aminoAcidMutations: filterVariants.flatMap((variant) => + (variant.filterObject.aminoAcidMutations ?? []).map((mutation) => ({ + mutation, + name: variant.name, + })), + ), + }; + }); +} + +export async function fetchMutationAnnotationsFromCollections( + configs: ResistanceMutationCollectionConfig[], + backendService: BackendService, +): Promise { + const collections = await Promise.all( + configs.map((c) => backendService.getCollection({ id: String(c.collectionId) })), + ); + return buildMutationAnnotations(configs, collections); +} + +export function buildCovidResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4, staging: 1, local: 1 }), + name: '3CLpro', + annotationSymbol: 'c', + description: + 'SARS-CoV-2 3C-like protease (3CLpro, or Mpro for Main protease) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + { + collectionId: byEnv(env, { prod: 5, staging: 2, local: 2 }), + name: 'RdRp', + annotationSymbol: 'r', + description: + 'SARS-CoV-2 RNA-dependent RNA polymerase (RdRP) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + { + collectionId: byEnv(env, { prod: 6, staging: 3, local: 3 }), + name: 'Spike', + annotationSymbol: 's', + description: + 'SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + ]; +} + +export function buildRsvAResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4983, staging: 4, local: 4 }), + name: 'Nirsevimab', + annotationSymbol: 'n', + description: + 'RSV-A F protein resistance mutations against Nirsevimab as per ViralZone.', + }, + { + collectionId: byEnv(env, { prod: 4984, staging: 5, local: 5 }), + name: 'Palivizumab', + annotationSymbol: 'p', + description: + 'RSV-A F protein resistance mutations against Palivizumab as per ViralZone.', + }, + ]; +} + +export function buildRsvBResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4985, staging: 6, local: 6 }), + name: 'Nirsevimab', + annotationSymbol: 'n', + description: + 'RSV-B F protein resistance mutations against Nirsevimab as per ViralZone.', + }, + { + collectionId: byEnv(env, { prod: 4986, staging: 7, local: 7 }), + name: 'Palivizumab', + annotationSymbol: 'p', + description: + 'RSV-B F protein resistance mutations against Palivizumab as per ViralZone.', + }, + ]; +} diff --git a/website/src/views/OrganismConstants.ts b/website/src/views/OrganismConstants.ts index b6c349517..6458932e1 100644 --- a/website/src/views/OrganismConstants.ts +++ b/website/src/views/OrganismConstants.ts @@ -15,6 +15,8 @@ import type { BaselineFilterConfig } from '../components/pageStateSelectors/Base import type { LineageFilterConfig } from '../components/pageStateSelectors/LineageFilterInput.tsx'; import type { Organism } from '../types/Organism.ts'; import type { DataOrigin } from '../types/dataOrigins.ts'; +import type { DbIdSpace } from '../types/dbIdSpace.ts'; +import type { ResistanceMutationCollectionConfig } from '../util/resistanceMutations.ts'; type AggregatedVisualizations = { sequencingEfforts: GsAggregatedConfig[]; @@ -36,6 +38,7 @@ export interface OrganismConstants { readonly lineageFilters: LineageFilterConfig[]; readonly predefinedVariants?: VariantFilter[]; readonly mutationAnnotations?: MutationAnnotation[]; + readonly buildResistanceMutationCollections?: (env: DbIdSpace) => ResistanceMutationCollectionConfig[]; } export const ComponentHeight = { diff --git a/website/src/views/covid.ts b/website/src/views/covid.ts index be5df708e..af030249d 100644 --- a/website/src/views/covid.ts +++ b/website/src/views/covid.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation, views } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets, views } from '@genspectrum/dashboard-components/util'; import { getIntegerFromSearch, @@ -29,19 +29,20 @@ import { makeDatasetAndVariantData, } from './View.ts'; import { compareSideBySideViewConstants, singleVariantViewConstants } from './ViewConstants.ts'; +import { buildCovidResistanceMutationCollections } from '../util/resistanceMutations'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import { type PageStateHandler } from './pageStateHandlers/PageStateHandler.ts'; +import { SingleVariantPageStateHandler } from './pageStateHandlers/SingleVariantPageStateHandler.ts'; import { setSearchFromDateFilters } from './pageStateHandlers/dateFilterFromToUrl.ts'; +import { setSearchFromLocationFilters } from './pageStateHandlers/locationFilterFromToUrl.ts'; +import { setSearchFromTextFilters } from './pageStateHandlers/textFilterFromToUrl.ts'; +import { advancedQueryUrlParam } from '../components/genspectrum/advancedQueryUrlParamConstants.ts'; +import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import type { LineageFilterConfig } from '../components/pageStateSelectors/LineageFilterInput.tsx'; import { Organisms } from '../types/Organism.ts'; import { type DataOrigin, dataOrigins } from '../types/dataOrigins.ts'; -import { SingleVariantPageStateHandler } from './pageStateHandlers/SingleVariantPageStateHandler.ts'; -import { setSearchFromLocationFilters } from './pageStateHandlers/locationFilterFromToUrl.ts'; -import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { ALL_TIMES_LABEL, defaultDateRangeOption } from '../util/defaultDateRangeOption.ts'; import { formatUrl } from '../util/formatUrl.ts'; -import { setSearchFromTextFilters } from './pageStateHandlers/textFilterFromToUrl.ts'; -import { advancedQueryUrlParam } from '../components/genspectrum/advancedQueryUrlParamConstants.ts'; const earliestDate = '2020-01-06'; const hostField = 'host'; @@ -127,7 +128,7 @@ class CovidConstants implements OrganismConstants { public readonly additionalFilters: Record | undefined; public readonly dataOrigins: DataOrigin[] = [dataOrigins.nextstrain]; public readonly accessionDownloadFields = ['strain']; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildCovidResistanceMutationCollections; public get aggregatedVisualizations() { const hosts = getHostsAggregatedVisualization(this); diff --git a/website/src/views/mpox.ts b/website/src/views/mpox.ts index 7fb713f4f..5d8d1879b 100644 --- a/website/src/views/mpox.ts +++ b/website/src/views/mpox.ts @@ -37,7 +37,7 @@ import { ALL_TIMES_LABEL, defaultDateRangeOption } from '../util/defaultDateRang const earliestDate = '1960-01-01'; -const LINEAGE_FIELD_NAME = 'lineage'; +const LINEAGE_FIELD_NAME = 'outbreakLineage'; const CLADE_FIELD_NAME = 'clade'; class MpoxConstants implements OrganismConstants { @@ -48,7 +48,7 @@ class MpoxConstants implements OrganismConstants { public readonly lineageFilters: LineageFilterConfig[] = [ { lapisField: LINEAGE_FIELD_NAME, - placeholderText: 'Lineage', + placeholderText: 'Outbreak & Lineage', filterType: 'lineage' as const, }, { @@ -88,10 +88,10 @@ class MpoxConstants implements OrganismConstants { public readonly accessionDownloadFields = PATHOPLEXUS_ACCESSION_DOWNLOAD_FIELDS; public readonly predefinedVariants = [ { - lineages: { [LINEAGE_FIELD_NAME]: 'F.1' }, + lineages: { [LINEAGE_FIELD_NAME]: 'sh2017/F.1' }, }, { - lineages: { [LINEAGE_FIELD_NAME]: 'F.2' }, + lineages: { [LINEAGE_FIELD_NAME]: 'sh2017/F.2' }, }, { lineages: { [CLADE_FIELD_NAME]: 'Ia' }, @@ -102,7 +102,7 @@ class MpoxConstants implements OrganismConstants { public get aggregatedVisualizations() { return getPathoplexusSequencingEffortsAggregatedVisualizations(this, { sublineages: { - label: 'Sub-Lineages', + label: 'Outbreak & Lineage', fields: [LINEAGE_FIELD_NAME, CLADE_FIELD_NAME], }, }); @@ -142,12 +142,12 @@ export class MpoxCompareSideBySideView extends BaseView< const defaultPageState = makeCompareSideBySideData(defaultDatasetFilter, [ { lineages: { - [LINEAGE_FIELD_NAME]: 'F.1', + [LINEAGE_FIELD_NAME]: 'sh2017/F.1', }, }, { lineages: { - [LINEAGE_FIELD_NAME]: 'F.2', + [LINEAGE_FIELD_NAME]: 'sh2017/F.2', }, }, ]); diff --git a/website/src/views/rsvA.ts b/website/src/views/rsvA.ts index 74bf2b7d9..8fd9600fd 100644 --- a/website/src/views/rsvA.ts +++ b/website/src/views/rsvA.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets } from '@genspectrum/dashboard-components/util'; import { type CompareSideBySideData, @@ -34,6 +34,7 @@ import { dataOrigins } from '../types/dataOrigins.ts'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { fineGrainedDefaultDateRangeOptions } from '../util/defaultDateRangeOption.ts'; +import { buildRsvAResistanceMutationCollections } from '../util/resistanceMutations'; const earliestDate = '1956-01-01'; @@ -74,7 +75,7 @@ class RsvAConstants implements OrganismConstants { lineages: { [LINEAGE_FIELD_NAME]: 'A.D.1' }, }, ]; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildRsvAResistanceMutationCollections; public get aggregatedVisualizations() { return getPathoplexusSequencingEffortsAggregatedVisualizations(this, { diff --git a/website/src/views/rsvB.ts b/website/src/views/rsvB.ts index e2819b913..fc35a6d86 100644 --- a/website/src/views/rsvB.ts +++ b/website/src/views/rsvB.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets } from '@genspectrum/dashboard-components/util'; import { type CompareSideBySideData, @@ -34,6 +34,7 @@ import { dataOrigins } from '../types/dataOrigins.ts'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { fineGrainedDefaultDateRangeOptions } from '../util/defaultDateRangeOption.ts'; +import { buildRsvBResistanceMutationCollections } from '../util/resistanceMutations'; const earliestDate = '1956-01-01'; @@ -74,7 +75,7 @@ class RsvBConstants implements OrganismConstants { lineages: { [LINEAGE_FIELD_NAME]: 'B.D.4.1.1' }, }, ]; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildRsvBResistanceMutationCollections; public get aggregatedVisualizations() { return getPathoplexusSequencingEffortsAggregatedVisualizations(this, { diff --git a/website/tests/ViewPage.ts b/website/tests/ViewPage.ts index 27aaaf547..04126017f 100644 --- a/website/tests/ViewPage.ts +++ b/website/tests/ViewPage.ts @@ -30,8 +30,20 @@ export abstract class ViewPage { public async fillLineageField(locator: Locator, lineage: string) { await locator.fill(lineage); - const selectedLineage = this.page.getByRole('listbox').getByRole('option', { name: lineage, exact: false }); - await selectedLineage.first().click(); + + const option = this.page.getByRole('listbox').getByRole('option', { name: lineage, exact: false }).first(); + + // Filling the field opens the autocomplete menu, but each filter field fetches its options + // from LAPIS independently and a re-render triggered by another field settling can close the + // menu again. When that happens the option never appears and a plain click would wait out the + // whole test timeout. Retry "ensure the menu is open, then click the option" together so a + // transient close cannot wedge the test. + await expect(async () => { + if (!(await option.isVisible())) { + await locator.click(); // re-open the menu + } + await option.click({ timeout: 2_000 }); + }).toPass(); } public async fillMutationField(locator: Locator, mutation: string) { diff --git a/website/tests/helpers/organisms.ts b/website/tests/helpers/organisms.ts index 8f0c16a4b..4900cddbd 100644 --- a/website/tests/helpers/organisms.ts +++ b/website/tests/helpers/organisms.ts @@ -17,7 +17,7 @@ export const organismOptions = { [Organisms.westNile]: { lineage: '1A', lineageFieldPlaceholder: 'Lineage', gene: 'NS1' }, [Organisms.rsvA]: { lineage: 'A.D.5.2', lineageFieldPlaceholder: 'Lineage', gene: 'NS1' }, [Organisms.rsvB]: { lineage: 'B.D.E.1', lineageFieldPlaceholder: 'Lineage', gene: 'NS1' }, - [Organisms.mpox]: { lineage: 'F.1', lineageFieldPlaceholder: 'Lineage', gene: 'OPG001' }, + [Organisms.mpox]: { lineage: 'sh2017/F.1', lineageFieldPlaceholder: 'Outbreak & Lineage', gene: 'OPG001' }, [Organisms.ebolaSudan]: { mutation: 'G5902T', gene: 'NP' }, [Organisms.ebolaZaire]: { mutation: 'T18365C', gene: 'NP' }, [Organisms.cchf]: { mutation: 'M:G3565A', gene: 'RdRp' }, diff --git a/website/tests/resistanceMutationAnnotations.spec.ts b/website/tests/resistanceMutationAnnotations.spec.ts new file mode 100644 index 000000000..fc94b3dfe --- /dev/null +++ b/website/tests/resistanceMutationAnnotations.spec.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; + +import { test } from './e2e.fixture.ts'; +import { organismOptions } from './helpers/organisms.ts'; +import { Organisms } from '../src/types/Organism.ts'; + +test.describe('Resistance mutation annotations on regular dashboards', () => { + test.setTimeout(60_000); + + test('COVID single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.covid]; + await singleVariantPage.goto(Organisms.covid); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); + + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(panel.getByText('3CLpro')).toBeVisible(); + await expect(panel.getByText('RdRp')).toBeVisible(); + await expect(panel.getByText('Spike')).toBeVisible(); + }); + + test('RSV-A single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.rsvA]; + await singleVariantPage.goto(Organisms.rsvA); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); + + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(panel.getByText('Nirsevimab')).toBeVisible(); + await expect(panel.getByText('Palivizumab')).toBeVisible(); + }); + + test('RSV-B single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.rsvB]; + await singleVariantPage.goto(Organisms.rsvB); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); + + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(panel.getByText('Nirsevimab')).toBeVisible(); + await expect(panel.getByText('Palivizumab')).toBeVisible(); + }); +});