diff --git a/scripts/generateOptions.ts b/scripts/generateOptions.ts index 4611d3f1..0201defd 100644 --- a/scripts/generateOptions.ts +++ b/scripts/generateOptions.ts @@ -57,7 +57,7 @@ let activityTypeOpts: Option[] = []; if (activityTypeValue && !activityTypeOpts.find(o => o.value === activityTypeValue)) { activityTypeOpts.push({ value: activityTypeValue, - label: titleCase(activityTypeValue) + label: titleCase(activityTypeValue)! }); } } diff --git a/src/assets/styles/main.css b/src/assets/styles/main.css index 5e64a62b..c7510846 100644 --- a/src/assets/styles/main.css +++ b/src/assets/styles/main.css @@ -44,8 +44,9 @@ border: 1px solid black; border-radius: 0.25em; padding: 0.5em; - opacity: 0.8; + opacity: 0.9; position: relative; top: 40px; right: 100px; + width: max-content; } diff --git a/src/composables/usePlotTooltips.ts b/src/composables/usePlotTooltips.ts index 4a45b6d9..7cb04c9f 100644 --- a/src/composables/usePlotTooltips.ts +++ b/src/composables/usePlotTooltips.ts @@ -1,39 +1,91 @@ import { useAppStore } from "@/stores/appStore"; import { useColorStore } from "@/stores/colorStore"; -import { Axis, type PointWithMetadata } from "@/types"; +import { useDataStore } from "@/stores/dataStore"; +import { Axis, SummaryTableColumn, type LineMetadata, type PointWithMetadata } from "@/types"; import { dimensionOptionLabel } from "@/utils/options"; import sentenceCase from "@/utils/sentenceCase"; export default () => { const colorStore = useColorStore(); const appStore = useAppStore(); + const dataStore = useDataStore(); + + const convertToScientificNotation = (num: number): string => { + if (!appStore.logScaleEnabled) { + return num.toFixed(2); + } + if (num === 0) return "0"; + const exponent = Math.floor(Math.log10(Math.abs(num))); + const coefficient = (num / Math.pow(10, exponent)).toFixed(2); + return `${coefficient} × 10${exponent}`; + }; + + // Find the summary table row whose values for the plot row and band + // dimensions (and column, if set) match the values of the tooltip point metadata + const getSummaryDataRow = (metadata: LineMetadata) => { + return dataStore.summaryTableData.find(d => { + return Object.entries(appStore.dimensions).every(([axis, dim]) => { + return !dim || d[dim] === metadata?.[axis as Axis]; + }); + }); + }; // Generate HTML for tooltips on ridgeline plot points. + // This callback is passed to skadi-chart, and is invoked when hovering over the chart. const tooltipCallback = (point: PointWithMetadata) => { if (!point.metadata) return ""; const { strokeColor } = colorStore.getColorsForLine(point.metadata) - const { colorDimension } = colorStore; + const { colorAxis, colorDimension } = colorStore; const { dimensions } = appStore; - const rowOptionLabel = dimensionOptionLabel(colorDimension, point.metadata[colorStore.colorAxis]); + const valueForColorDimension = point.metadata[colorAxis]; + + const colorDimensionLabel = dimensionOptionLabel(colorDimension, valueForColorDimension); + const rowOptionLabel = dimensionOptionLabel(dimensions.row, point.metadata[Axis.ROW]); const columnOptionLabel = dimensionOptionLabel(dimensions.column, point.metadata[Axis.COLUMN]); + const summaryDataRow = getSummaryDataRow(point.metadata); + + // NB regardless of whether log scale is enabled, the summary table data are provided in non-log scale. + const [mean, ciLower, ciUpper] = [ + SummaryTableColumn.MEAN, + SummaryTableColumn.CI_LOWER, + SummaryTableColumn.CI_UPPER, + ].map(col => summaryDataRow?.[col]); + + let ciLowerStr; + let ciUpperStr; + if (appStore.logScaleEnabled) { + ciUpperStr = convertToScientificNotation(ciUpper!); + ciLowerStr = convertToScientificNotation(ciLower!); + } else { + const includePositiveSign = ciLower! < 0; + const ciUpperSign = (includePositiveSign && ciUpper! > 0) ? '+' : ''; + ciUpperStr = `${ciUpperSign}${ciUpper?.toFixed(2)}`; + ciLowerStr = ciLower?.toFixed(2); + } + + const meanStr = appStore.logScaleEnabled ? convertToScientificNotation(mean!) : mean?.toFixed(2) + return `
-
- - - - ${sentenceCase(colorDimension)}: ${rowOptionLabel} - - ${dimensions.column ? ` - ${sentenceCase(dimensions.column)}: ${columnOptionLabel} - ` : ''} +
+ + + ${sentenceCase(colorDimension)}: ${colorDimensionLabel}
-

Tooltip content is TODO. VIMC-9196

+ ${dimensions.row !== colorDimension ? ` + ${sentenceCase(dimensions.row)}: ${rowOptionLabel} + ` : ''} + ${dimensions.column && dimensions.column !== colorDimension ? ` + ${sentenceCase(dimensions.column)}: ${columnOptionLabel} + ` : ''} +

+ Mean: ${meanStr}
+

- Will show the median/mean values and 95% confidence interval for the whole line. + 95% confidence interval: ${ciLowerStr}${ciUpperStr}

` } diff --git a/src/stores/dataStore.ts b/src/stores/dataStore.ts index b2be08ba..a27dea15 100644 --- a/src/stores/dataStore.ts +++ b/src/stores/dataStore.ts @@ -1,8 +1,9 @@ import { debounce } from "perfect-debounce"; -import { computed, ref, shallowRef, watch } from "vue"; +import { computed, ref, shallowRef, watch, type ShallowRef } from "vue"; import { defineStore } from "pinia"; import { useAppStore } from "@/stores/appStore"; -import { type HistDataRow, Dimension, LocResolution } from "@/types"; +import { type HistDataRow, type SummaryTableDataRow, Dimension, LocResolution } from "@/types"; +import { globalOption } from "@/utils/options"; export const dataDir = `./data/json` @@ -11,13 +12,14 @@ export const useDataStore = defineStore("data", () => { const fetchErrors = ref<{ e: Error, message: string }[]>([]); const histogramData = shallowRef([]); - const histogramDataCache: Record = {}; + const histogramCache: Record = {}; + const summaryTableData = shallowRef([]); + const summaryTableCache: Record = {}; const isLoading = ref(true); - const histogramDataPaths = computed(() => { - // When we are using multiple geographical resolutions, we need to load multiple data files, to be merged together later. + const constructFilenames = (dataType: "hist_counts" | "summary_table"): string[] => { return appStore.geographicalResolutions.map((geog) => { - const fileNameParts = ["hist_counts", appStore.burdenMetric, "disease"]; + const fileNameParts = [dataType, appStore.burdenMetric, "disease"]; // NB files containing 'global' data simply omit location from the file name (as they have no location stratification). if (geog === LocResolution.SUBREGION) { fileNameParts.push(LocResolution.SUBREGION); @@ -28,26 +30,33 @@ export const useDataStore = defineStore("data", () => { if (geog === LocResolution.COUNTRY) { fileNameParts.push(LocResolution.COUNTRY); } - if (appStore.logScaleEnabled) { + if (dataType === "hist_counts" && appStore.logScaleEnabled) { + // Log scale is not applicable for summary tables, so does not appear in the filenames. fileNameParts.push("log"); } - return `${fileNameParts.join("_")}.json` + return `${fileNameParts.join("_")}.json`; }); - }); + } - // Fetch and parse multiple JSONs, and merge together all data. - const loadDataFromPaths = async (paths: string[]) => { - isLoading.value = true; - fetchErrors.value = []; - await Promise.all(paths.map(async (path) => { - if (!histogramDataCache[path]) { + const histFilenames = computed(() => constructFilenames("hist_counts")); + const summaryTableFilenames = computed(() => constructFilenames("summary_table")); + + const loadData = async ( + filenames: string[], + cache: Record, + ref: ShallowRef, + ) => { + // When we are using multiple geographical resolutions, we load multiple data files, to be merged together later. + await Promise.all(filenames.map(async (filename) => { + if (!cache[filename]) { + const path = `${dataDir}/${filename}`; try { - const response = await fetch(`${dataDir}/${path}`); + const response = await fetch(path); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const rows = await response.json(); - histogramDataCache[path] = rows; + cache[filename] = rows; return rows; } catch (error) { fetchErrors.value.push({ e: error as Error, message: `Error loading data from path: ${path}. ${error}` }); @@ -55,32 +64,49 @@ export const useDataStore = defineStore("data", () => { } })); - histogramData.value = paths.flatMap((path) => histogramDataCache[path] || []).map((row) => { + // Merge data fetched from multiple files into one array. + ref.value = filenames.flatMap((filename) => { + return (cache[filename] || []) as T[]; + }).map((row) => { // Collapse all geographic columns into one 'location' column - if (row[LocResolution.COUNTRY]) { - row[Dimension.LOCATION] = row[LocResolution.COUNTRY]; - delete row[LocResolution.COUNTRY]; - } else if (row[LocResolution.SUBREGION]) { - row[Dimension.LOCATION] = row[LocResolution.SUBREGION]; - delete row[LocResolution.SUBREGION]; + const [country, subregion] = [row[LocResolution.COUNTRY], row[LocResolution.SUBREGION]]; + const newRow = { ...row }; + if (country) { + newRow[Dimension.LOCATION] = country; + delete newRow[LocResolution.COUNTRY]; + } else if (subregion) { + newRow[Dimension.LOCATION] = subregion; + delete newRow[LocResolution.SUBREGION]; + } else { + newRow[Dimension.LOCATION] = globalOption.value; } - return row; + return newRow; }); isLoading.value = false; }; - const doLoadData = debounce(async () => { - await loadDataFromPaths(histogramDataPaths.value); + const loadAllData = async () => { + isLoading.value = true; + fetchErrors.value = []; + await Promise.all([ + loadData(histFilenames.value, histogramCache, histogramData), + loadData(summaryTableFilenames.value, summaryTableCache, summaryTableData), + ]); + isLoading.value = false; + }; + + const debouncedLoadAllData = debounce(async () => { + await loadAllData(); }, 25) - watch(histogramDataPaths, async (_oldPaths, newPaths) => { - if (newPaths) { - doLoadData(); + watch([histFilenames, summaryTableFilenames], async (_oldPaths, newPaths) => { + if (newPaths.length) { + debouncedLoadAllData(); } else { - // This is the first time histDataPaths is calculated, so don't debounce. - await loadDataFromPaths(histogramDataPaths.value); + // This is the first time the filenames are computed, so don't debounce. + await loadAllData(); } }, { immediate: true }); - return { histogramData, fetchErrors, isLoading }; + return { fetchErrors, isLoading, histogramData, summaryTableData }; }); diff --git a/src/types.ts b/src/types.ts index 1b54eef7..78d741ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -24,14 +24,16 @@ export enum HistColumn { COUNTS = "Counts", } -type DataRow = Record; -export type SummaryTableDataRow = DataRow & { - [Dimension.DISEASE]: string; - [LocResolution.COUNTRY]?: string; - [LocResolution.SUBREGION]?: string; -}; -export type HistDataRow = DataRow & { +export enum SummaryTableColumn { + MEAN = "mean_value", + MEDIAN = "median_value", + CI_LOWER = "lower_95", + CI_UPPER = "upper_95", +} + +export type HistDataRow = { [Dimension.DISEASE]: string; + [Dimension.ACTIVITY_TYPE]?: string; [LocResolution.COUNTRY]?: string; [LocResolution.SUBREGION]?: string; [Dimension.LOCATION]?: string; @@ -39,6 +41,17 @@ export type HistDataRow = DataRow & { [HistColumn.UPPER_BOUND]: number; [HistColumn.COUNTS]: number; }; +export type SummaryTableDataRow = { + [Dimension.DISEASE]: string; + [Dimension.ACTIVITY_TYPE]?: string; + [LocResolution.COUNTRY]?: string; + [LocResolution.SUBREGION]?: string; + [Dimension.LOCATION]?: string; + [SummaryTableColumn.MEAN]: number; + [SummaryTableColumn.MEDIAN]: number; + [SummaryTableColumn.CI_LOWER]: number; + [SummaryTableColumn.CI_UPPER]: number; +} export type Option = { label: string; value: string }; diff --git a/src/utils/fileParse.ts b/src/utils/fileParse.ts index 2f9ecf2d..4082731a 100644 --- a/src/utils/fileParse.ts +++ b/src/utils/fileParse.ts @@ -3,7 +3,7 @@ import { globalOption } from "./options"; // Get a data row's category for some categorical axis. export const getDimensionCategoryValue = (dim: Dimension | null, dataRow: HistDataRow): string => { - const value = dataRow[dim ?? ""] as string; + const value = dataRow[dim as keyof HistDataRow] as string; if (dim === Dimension.LOCATION && !value) { // A missing column for the location dimension implies 'global' category. return globalOption.value; diff --git a/tests/unit/composables/usePlotTooltips.spec.ts b/tests/unit/composables/usePlotTooltips.spec.ts index e29006dd..020e6225 100644 --- a/tests/unit/composables/usePlotTooltips.spec.ts +++ b/tests/unit/composables/usePlotTooltips.spec.ts @@ -6,7 +6,8 @@ import { nextTick } from "vue"; import usePlotTooltips from '@/composables/usePlotTooltips'; import { useAppStore } from '@/stores/appStore'; import { useColorStore } from '@/stores/colorStore'; -import { Axis, Dimension, type PointWithMetadata } from '@/types'; +import { useDataStore } from '@/stores/dataStore'; +import { Axis, Dimension, SummaryTableColumn, type PointWithMetadata, type SummaryTableDataRow } from '@/types'; describe('usePlotTooltips', () => { beforeEach(() => { @@ -28,30 +29,30 @@ describe('usePlotTooltips', () => { const appStore = useAppStore(); const colorStore = useColorStore(); - // Set up store state for location-based coloring appStore.filters = { [Dimension.LOCATION]: ['AFG', 'global'], [Dimension.DISEASE]: ['Cholera'], }; expect(colorStore.colorDimension).toBe(Dimension.LOCATION); + expect(appStore.dimensions.row).toBe(Dimension.DISEASE); - // Set colors so colorStore has the mapping colorStore.setColors([afgPointMetadata, globalPointMetadata]); const { tooltipCallback } = usePlotTooltips(); const afgTooltip = tooltipCallback({ x: 1, y: 2, metadata: afgPointMetadata.metadata! }); - expect(afgTooltip).toContain('Location: Afghanistan'); + expect(afgTooltip).toContain('Location: Afghanistan'); expect(afgTooltip).toContain('style="color: #009d9a'); // teal50 - expect(afgTooltip).not.toContain('Disease'); + // Row dimension (disease) is shown because it's different from color dimension (location) + expect(afgTooltip).toContain('Disease: Cholera'); expect(afgTooltip).not.toContain('Activity type'); const globalTooltip = tooltipCallback({ x: 1, y: 2, metadata: globalPointMetadata.metadata! }); - expect(globalTooltip).toContain('Location: All 117 VIMC countries'); + expect(globalTooltip).toContain('Location: All 117 VIMC countries'); expect(globalTooltip).toContain('style="color: #6929c4'); // purple70 - expect(globalTooltip).not.toContain('Disease'); + expect(globalTooltip).toContain('Disease: Cholera'); expect(globalTooltip).not.toContain('Activity type'); }); @@ -62,12 +63,12 @@ describe('usePlotTooltips', () => { const appStore = useAppStore(); const colorStore = useColorStore(); - // Set up store state for disease-based coloring appStore.filters = { [Dimension.LOCATION]: ['AFG'], [Dimension.DISEASE]: ['Cholera', 'Measles'], }; expect(colorStore.colorDimension).toBe(Dimension.DISEASE); + expect(appStore.dimensions.row).toBe(Dimension.DISEASE); colorStore.setColors([choleraPointMetadata, measlesPointMetadata]); @@ -75,16 +76,18 @@ describe('usePlotTooltips', () => { const choleraTooltip = tooltipCallback({ x: 1, y: 2, metadata: choleraPointMetadata.metadata! }); - expect(choleraTooltip).toContain('Disease: Cholera'); + expect(choleraTooltip).toContain('Disease: Cholera'); expect(choleraTooltip).toContain('style="color: #6929c4'); // purple70 - expect(choleraTooltip).not.toContain('Location'); + // Row dimension (disease) is NOT shown separately because it's the same as color dimension + expect(choleraTooltip.match(/Disease:/g)?.length).toBe(1); expect(choleraTooltip).not.toContain('Activity type'); const measlesTooltip = tooltipCallback({ x: 1, y: 2, metadata: measlesPointMetadata.metadata! }); - expect(measlesTooltip).toContain('Disease: Measles'); + expect(measlesTooltip).toContain('Disease: Measles'); expect(measlesTooltip).toContain('style="color: #009d9a'); // teal50 - expect(measlesTooltip).not.toContain('Location'); + // Row dimension (disease) is NOT shown separately because it's the same as color dimension + expect(measlesTooltip.match(/Disease:/g)?.length).toBe(1); expect(measlesTooltip).not.toContain('Activity type'); }); @@ -99,30 +102,132 @@ describe('usePlotTooltips', () => { await nextTick(); expect(appStore.dimensions.column).toBe(Dimension.ACTIVITY_TYPE); - // Set up store state for disease-based coloring (single location) appStore.filters = { [Dimension.LOCATION]: ['AFG'], [Dimension.DISEASE]: ['Cholera'], [Dimension.ACTIVITY_TYPE]: ['routine', 'campaign'], }; expect(colorStore.colorDimension).toBe(Dimension.DISEASE); + expect(appStore.dimensions.row).toBe(Dimension.DISEASE); colorStore.setColors([routinePointMetadata, campaignPointMetadata]); const { tooltipCallback } = usePlotTooltips(); const routineTooltip = tooltipCallback({ x: 1, y: 2, metadata: routinePointMetadata.metadata! }); - expect(routineTooltip).toContain('Disease: Cholera'); - expect(routineTooltip).toContain('Activity type: Routine'); + expect(routineTooltip).toContain('Disease: Cholera'); + expect(routineTooltip).toContain('Activity type: Routine'); + // Row dimension (disease) is NOT shown separately because it's the same as color dimension + expect(routineTooltip.match(/Disease:/g)?.length).toBe(1); expect(routineTooltip).toContain('style="color: #6929c4'); // purple70 - expect(routineTooltip).not.toContain('Location'); const campaignTooltip = tooltipCallback({ x: 1, y: 2, metadata: campaignPointMetadata.metadata! }); - expect(campaignTooltip).toContain('Disease: Cholera'); - expect(campaignTooltip).toContain('Activity type: Campaign'); + expect(campaignTooltip).toContain('Disease: Cholera'); + expect(campaignTooltip).toContain('Activity type: Campaign'); + // Row dimension (disease) is NOT shown separately because it's the same as color dimension + expect(campaignTooltip.match(/Disease:/g)?.length).toBe(1); expect(campaignTooltip).toContain('style="color: #6929c4'); // purple70 - expect(campaignTooltip).not.toContain('Location'); }); + + it('displays summary data (mean, and 95% confidence interval) in tooltip (linear scale)', () => { + const afgPointMetadata = { metadata: { [Axis.WITHIN_BAND]: 'AFG', [Axis.ROW]: 'Cholera', [Axis.COLUMN]: '' } }; + + const appStore = useAppStore(); + const colorStore = useColorStore(); + const dataStore = useDataStore(); + + appStore.filters = { + [Dimension.LOCATION]: ['AFG'], + [Dimension.DISEASE]: ['Cholera'], + }; + appStore.logScaleEnabled = false; + colorStore.setColors([afgPointMetadata]); + dataStore.summaryTableData = [ + { + [Dimension.LOCATION]: 'AFG', + [Dimension.DISEASE]: 'Cholera', + [SummaryTableColumn.MEAN]: 1456.78, + [SummaryTableColumn.CI_LOWER]: 789.12, + [SummaryTableColumn.CI_UPPER]: 2345.67, + } as SummaryTableDataRow, + ]; + + const { tooltipCallback } = usePlotTooltips(); + + const tooltip = tooltipCallback({ x: 1, y: 2, metadata: afgPointMetadata.metadata! }); + + expect(tooltip).toContain('Mean: 1456.78'); + expect(tooltip).toContain('95% confidence interval:'); + expect(tooltip).toContain('789.12'); + expect(tooltip).toContain('2345.67'); + }); + + it('handles negative confidence interval values with appropriate sign (linear scale)', () => { + const afgPointMetadata = { metadata: { [Axis.WITHIN_BAND]: 'AFG', [Axis.ROW]: 'Cholera', [Axis.COLUMN]: '' } }; + + const appStore = useAppStore(); + const colorStore = useColorStore(); + const dataStore = useDataStore(); + + appStore.filters = { + [Dimension.LOCATION]: ['AFG'], + [Dimension.DISEASE]: ['Cholera'], + }; + appStore.logScaleEnabled = false; + colorStore.setColors([afgPointMetadata]); + // Set up summary table data with negative lower bound (crossing zero) + dataStore.summaryTableData = [ + { + [Dimension.LOCATION]: 'AFG', + [Dimension.DISEASE]: 'Cholera', + [SummaryTableColumn.MEAN]: 55.00, + [SummaryTableColumn.CI_LOWER]: -100.50, + [SummaryTableColumn.CI_UPPER]: 200.75, + } as SummaryTableDataRow, + ]; + + const { tooltipCallback } = usePlotTooltips(); + + const tooltip = tooltipCallback({ x: 1, y: 2, metadata: afgPointMetadata.metadata! }); + + expect(tooltip).toContain('Mean: 55.00'); + // Check negative lower bound and positive upper bound with + sign when interval crosses zero + expect(tooltip).toContain('-100.50'); + expect(tooltip).toContain('+200.75'); + }); + }); + + it('displays summary data (mean, and 95% confidence interval) in tooltip (log scale: scientific notation)', () => { + const afgPointMetadata = { metadata: { [Axis.WITHIN_BAND]: 'AFG', [Axis.ROW]: 'Cholera', [Axis.COLUMN]: '' } }; + + const appStore = useAppStore(); + const colorStore = useColorStore(); + const dataStore = useDataStore(); + + appStore.filters = { + [Dimension.LOCATION]: ['AFG'], + [Dimension.DISEASE]: ['Cholera'], + }; + appStore.logScaleEnabled = true; + colorStore.setColors([afgPointMetadata]); + dataStore.summaryTableData = [ + { + [Dimension.LOCATION]: 'AFG', + [Dimension.DISEASE]: 'Cholera', + [SummaryTableColumn.MEAN]: 1456.78, + [SummaryTableColumn.CI_LOWER]: 789.12, + [SummaryTableColumn.CI_UPPER]: 2345.67, + } as SummaryTableDataRow, + ]; + + const { tooltipCallback } = usePlotTooltips(); + + const tooltip = tooltipCallback({ x: 1, y: 2, metadata: afgPointMetadata.metadata! }); + + expect(tooltip).toContain('Mean: 1.46 × 103'); + expect(tooltip).toContain('95% confidence interval:'); + expect(tooltip).toContain('7.89 × 102'); + expect(tooltip).toContain('2.35 × 103'); }); }); diff --git a/tests/unit/stores/dataStore.spec.ts b/tests/unit/stores/dataStore.spec.ts index 451285a9..ca8abbff 100644 --- a/tests/unit/stores/dataStore.spec.ts +++ b/tests/unit/stores/dataStore.spec.ts @@ -11,13 +11,21 @@ import histCountsDalysDiseaseCountryLog from "@/../public/data/json/hist_counts_ import histCountsDalysDiseaseLog from "@/../public/data/json/hist_counts_dalys_disease_log.json"; import histCountsDeathsDiseaseSubregionActivityType from "@/../public/data/json/hist_counts_deaths_disease_subregion_activity_type.json"; import histCountsDeathsDiseaseActivityType from "@/../public/data/json/hist_counts_deaths_disease_activity_type.json"; +import summaryDeathsDisease from "@/../public/data/json/summary_table_deaths_disease.json"; +import summaryDalysDiseaseSubregionActivityType from "@/../public/data/json/summary_table_dalys_disease_subregion_activity_type.json"; +import summaryDalysDiseaseActivityType from "@/../public/data/json/summary_table_dalys_disease_activity_type.json"; +import summaryDeathsDiseaseSubregionActivityType from "@/../public/data/json/summary_table_deaths_disease_subregion_activity_type.json"; +import summaryDeathsDiseaseActivityType from "@/../public/data/json/summary_table_deaths_disease_activity_type.json"; +import summaryDalysDiseaseSubregion from "@/../public/data/json/summary_table_dalys_disease_subregion.json"; +import summaryDalysDiseaseCountry from "@/../public/data/json/summary_table_dalys_disease_country.json"; +import summaryDalysDisease from "@/../public/data/json/summary_table_dalys_disease.json"; import { BurdenMetric } from '@/types'; import { useAppStore } from '@/stores/appStore'; import { useDataStore } from '@/stores/dataStore'; import { http, HttpResponse } from 'msw'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -const expectLastNCallsToEqual = (spy: Mock, args: any[]) => { +const expectLastNCallsToContain = (spy: Mock, args: any[]) => { const calls = spy.mock.calls; expect(calls.slice(calls.length - args.length)).toEqual( expect.arrayContaining(args.map(a => expect.arrayContaining([a]))), @@ -34,27 +42,40 @@ describe('data store', () => { const appStore = useAppStore(); const dataStore = useDataStore(); expect(dataStore.histogramData).toEqual([]); + expect(dataStore.summaryTableData).toEqual([]); // Initial data - let expectedFetches = 1; + let expectedFetches = 2; await vi.waitFor(() => { expect(dataStore.isLoading).toBe(false); expect(dataStore.histogramData).toHaveLength(histCountsDeathsDiseaseLog.length); - expect(dataStore.histogramData[0]).toEqual({ + expect(dataStore.histogramData[0]).toEqual(expect.objectContaining({ disease: "Cholera", + location: "global", Counts: 1, - lower_bound: -2.434, - upper_bound: -2.422, - }); + lower_bound: expect.closeTo(-2.43), + upper_bound: expect.closeTo(-2.42), + })); + expect(dataStore.summaryTableData).toHaveLength(summaryDeathsDisease.length); + expect(dataStore.summaryTableData[0]).toEqual(expect.objectContaining({ + disease: "COVID-19", + lower_95: expect.closeTo(0.08), + upper_95: expect.closeTo(0.19), + mean_value: expect.closeTo(0.12), + median_value: expect.closeTo(0.12), + })); expect(fetchSpy).toBeCalledTimes(expectedFetches); - expectLastNCallsToEqual(fetchSpy, ["./data/json/hist_counts_deaths_disease_log.json"]); + expectLastNCallsToContain(fetchSpy, [ + "./data/json/hist_counts_deaths_disease_log.json", + "./data/json/summary_table_deaths_disease.json", + ]); }); // Change options: round 1 expect(appStore.exploreBy).toEqual("location"); expect(appStore.focus).toEqual("global"); appStore.focus = "Middle Africa"; - expectedFetches += 2; + expectedFetches += 4; appStore.burdenMetric = BurdenMetric.DALYS; appStore.logScaleEnabled = false; appStore.splitByActivityType = true; @@ -64,13 +85,22 @@ describe('data store', () => { expect(dataStore.histogramData).toHaveLength( histCountsDalysDiseaseSubregionActivityType.length + histCountsDalysDiseaseActivityType.length ); + expect(dataStore.summaryTableData).toHaveLength( + summaryDalysDiseaseSubregionActivityType.length + summaryDalysDiseaseActivityType.length + ); }); expect(fetchSpy).toBeCalledTimes(expectedFetches); - expectLastNCallsToEqual(fetchSpy, [ + expectLastNCallsToContain(fetchSpy, [ "./data/json/hist_counts_dalys_disease_subregion_activity_type.json", "./data/json/hist_counts_dalys_disease_activity_type.json", + "./data/json/summary_table_dalys_disease_subregion_activity_type.json", + "./data/json/summary_table_dalys_disease_activity_type.json", ]); + // Regression test: check that location columns include both global and subregional. + expect(dataStore.summaryTableData.map(r => r.location)).toEqual(expect.arrayContaining(["Middle Africa", "global"])); + expect(dataStore.histogramData.map(r => r.location)).toEqual(expect.arrayContaining(["Middle Africa", "global"])); + // Change options: round 2 appStore.exploreBy = "disease"; await vi.waitFor(() => { @@ -78,7 +108,7 @@ describe('data store', () => { expect(fetchSpy).toBeCalledTimes(expectedFetches); // No increment in expectedFetches due to cacheing. }); appStore.focus = "Measles"; - expectedFetches += 2; + expectedFetches += 4; appStore.burdenMetric = BurdenMetric.DEATHS; appStore.logScaleEnabled = false; appStore.splitByActivityType = true; @@ -88,11 +118,16 @@ describe('data store', () => { expect(dataStore.histogramData).toHaveLength( histCountsDeathsDiseaseSubregionActivityType.length + histCountsDeathsDiseaseActivityType.length ); + expect(dataStore.summaryTableData).toHaveLength( + summaryDeathsDiseaseSubregionActivityType.length + summaryDeathsDiseaseActivityType.length + ); }); expect(fetchSpy).toBeCalledTimes(expectedFetches); - expectLastNCallsToEqual(fetchSpy, [ + expectLastNCallsToContain(fetchSpy, [ "./data/json/hist_counts_deaths_disease_subregion_activity_type.json", "./data/json/hist_counts_deaths_disease_activity_type.json", + "./data/json/summary_table_deaths_disease_subregion_activity_type.json", + "./data/json/summary_table_deaths_disease_activity_type.json", ]); // Change options: round 3 @@ -102,7 +137,7 @@ describe('data store', () => { expect(fetchSpy).toBeCalledTimes(expectedFetches); // No increment in expectedFetches due to cacheing. }); appStore.focus = "AFG"; - expectedFetches += 3; + expectedFetches += 6; appStore.burdenMetric = BurdenMetric.DALYS; appStore.logScaleEnabled = true; appStore.splitByActivityType = false; @@ -112,12 +147,18 @@ describe('data store', () => { expect(dataStore.histogramData).toHaveLength( histCountsDalysDiseaseSubregionLog.length + histCountsDalysDiseaseCountryLog.length + histCountsDalysDiseaseLog.length ); + expect(dataStore.summaryTableData).toHaveLength( + summaryDalysDiseaseSubregion.length + summaryDalysDiseaseCountry.length + summaryDalysDisease.length + ); }, { timeout: 2500 }); expect(fetchSpy).toBeCalledTimes(expectedFetches); - expectLastNCallsToEqual(fetchSpy, [ + expectLastNCallsToContain(fetchSpy, [ "./data/json/hist_counts_dalys_disease_subregion_log.json", "./data/json/hist_counts_dalys_disease_country_log.json", "./data/json/hist_counts_dalys_disease_log.json", + "./data/json/summary_table_dalys_disease_subregion.json", + "./data/json/summary_table_dalys_disease_country.json", + "./data/json/summary_table_dalys_disease.json", ]); }) @@ -135,7 +176,7 @@ describe('data store', () => { await vi.waitFor(() => { expect(fetchSpy).toBeCalled(); expect(dataStore.fetchErrors).toEqual([expect.objectContaining( - { message: `Error loading data from path: hist_counts_deaths_disease_log.json. TypeError: Failed to fetch` } + { message: `Error loading data from path: ./data/json/hist_counts_deaths_disease_log.json. TypeError: Failed to fetch` } )]); }); @@ -156,7 +197,7 @@ describe('data store', () => { await vi.waitFor(() => { expect(fetchSpy).toBeCalled(); expect(dataStore.fetchErrors).toEqual([expect.objectContaining( - { message: `Error loading data from path: hist_counts_deaths_disease_log.json. Error: HTTP 404: Not Found` } + { message: `Error loading data from path: ./data/json/hist_counts_deaths_disease_log.json. Error: HTTP 404: Not Found` } )]); });