Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use data attributes to 'sanity-test' the time series charts #99

Merged
merged 8 commits into from
Dec 19, 2024
14 changes: 8 additions & 6 deletions components/CostsPie.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
:id="chartContainerId"
ref="chartContainer"
:class="[props.hideTooltips ? hideTooltipsClassName : '']"
:data-test="JSON.stringify(costsData)"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe call this something a bit less generic - data-summary?

/>
</div>
</template>
Expand Down Expand Up @@ -36,7 +37,7 @@ const hideTooltipsClassName = "hide-tooltips";
const chartBackgroundColor = "transparent";
const chartBackgroundColorOnExporting = "white";
let chart: Highcharts.Chart;
let costsData: pieCost[] = [];
const costsData = ref<pieCost[]>([]); // This is only implemented as ref for testing purposes, via the data-test attribute.
const pieSize = computed(() => appStore.largeScreen ? 450 : 300);
const chartContainer = ref<HTMLElement | null>(null);

Expand Down Expand Up @@ -112,7 +113,7 @@ const chartLevelsOptions = (isDrillingDown: boolean = false): Array<Highcharts.P
const chartSeries = () => {
return {
type: "sunburst",
data: costsData, // Empty at initialisation, populated later
data: costsData.value, // Empty at initialisation, populated later
name: "Root",
allowDrillToNode: true,
borderRadius: 0,
Expand Down Expand Up @@ -210,7 +211,7 @@ const populateCostsDataIntoPie = () => {
if (!appStore.totalCost) {
return;
}
costsData = [{
const data = [{
id: appStore.totalCost.id,
parent: "",
name: appStore.getCostLabel(appStore.totalCost.id),
Expand All @@ -220,7 +221,7 @@ const populateCostsDataIntoPie = () => {
// so that earlier pieCostsColors are assigned to top-level children before
// the next level of children. (Using recursion changes the color assignment order.)
appStore.totalCost.children?.forEach((cost) => {
costsData.push({
data.push({
id: cost.id,
parent: appStore.totalCost!.id,
name: appStore.getCostLabel(cost.id),
Expand All @@ -233,15 +234,16 @@ const populateCostsDataIntoPie = () => {
appStore.totalCost.children?.forEach((cost) => {
// Omit sub-costs with a value of zero
cost.children?.filter(subCost => subCost.value !== 0)?.forEach((subCost) => {
costsData.push({
data.push({
id: subCost.id,
parent: cost.id,
name: appStore.getCostLabel(subCost.id),
value: subCost.value,
});
});
});
chart.series[0].setData(costsData);
costsData.value = data;
chart.series[0].setData(costsData.value);
};

watch(() => appStore.costsData, () => {
Expand Down
5 changes: 3 additions & 2 deletions components/TimeSeries.client.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
ref="chartContainer"
:class="`chart-container time-series ${props.hideTooltips ? 'hide-tooltips' : ''}`"
:style="{ zIndex, height: 'fit-content' }"
:data-test="JSON.stringify({ firstDataPoint: data[0], lastDataPoint: data[data.length - 1] })"
/>
</template>

Expand All @@ -13,9 +14,9 @@ import accessibilityInitialize from "highcharts/modules/accessibility";
import exportDataInitialize from "highcharts/modules/export-data";
import exportingInitialize from "highcharts/modules/exporting";
import offlineExportingInitialize from "highcharts/modules/offline-exporting";

import { debounce } from "perfect-debounce";
import type { DisplayInfo } from "~/types/apiResponseTypes";
import type { TimeSeriesDataPoint } from "~/types/dataTypes";
import { plotBandsColor, plotLinesColor, timeSeriesColors } from "./utils/highCharts";

const props = defineProps<{
Expand Down Expand Up @@ -58,7 +59,7 @@ const seriesMetadata = computed((): DisplayInfo | undefined => {
});

// Assign an x-position to y-values. Nth value corresponds to "N+1th day" of simulation.
const data = computed(() => {
const data = computed((): TimeSeriesDataPoint[] => {
return appStore.timeSeriesData![props.seriesId].map((value, index) => [index + 1, value]);
});

Expand Down
2 changes: 2 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,6 @@ export default defineNuxtConfig({
rApiBase: "", // https://nuxt.com/docs/getting-started/testing#registerendpoint
},
},

compatibilityDate: "2024-12-11",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm mainly adding this in to silence the warning about it that happens on starting up the dev server, on the theory that not doing so causes it to be added dynamically, which possibly triggers a full app re-load on start-up (as updating this config normally does), which would slow down the development experience.

Docs: https://nuxt.com/docs/api/nuxt-config#compatibilitydate

});
18 changes: 12 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"@babel/preset-typescript": "^7.26.0",
"@mockoon/cli": "^8.4.0",
"@pinia/testing": "^0.1.6",
"@playwright/test": "^1.46.1",
"@playwright/test": "^1.49.1",
"@testing-library/vue": "^8.1.0",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^22.7.5",
Expand Down
21 changes: 21 additions & 0 deletions tests/e2e/helpers/checkTimeSeriesDataPoints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Locator } from "playwright/test";
import type { TimeSeriesDataPoint } from "~/types/dataTypes";
import { checkValueIsInRange } from "./checkValueIsInRange";

export const checkTimeSeriesDataPoints = async (
locator: Locator,
expectedFirstDataPoint: TimeSeriesDataPoint,
expectedLastDataPoint: TimeSeriesDataPoint,
tolerance = 0.5,
) => {
const dataString = await locator.getAttribute("data-test");
const data = JSON.parse(dataString!);

const [firstX, firstY] = data.firstDataPoint;
const [lastX, lastY] = data.lastDataPoint;

checkValueIsInRange(firstX, expectedFirstDataPoint[0], tolerance);
checkValueIsInRange(firstY, expectedFirstDataPoint[1], tolerance);
checkValueIsInRange(lastX, expectedLastDataPoint[0], tolerance);
checkValueIsInRange(lastY, expectedLastDataPoint[1], tolerance);
};
6 changes: 6 additions & 0 deletions tests/e2e/helpers/checkValueIsInRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { expect } from "@playwright/test";

export const checkValueIsInRange = (value: number, expected: number, tolerance: number) => {
expect(value).toBeGreaterThanOrEqual(expected * (1 - tolerance));
expect(value).toBeLessThanOrEqual(expected * (1 + tolerance));
};
80 changes: 52 additions & 28 deletions tests/e2e/runAnalysis.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { expect, test } from "@playwright/test";
import selectParameterOption from "~/tests/e2e/helpers/selectParameterOption";
import waitForNewScenarioPage from "~/tests/e2e/helpers/waitForNewScenarioPage";
import checkRApiServer from "./helpers/checkRApiServer";
import { checkTimeSeriesDataPoints } from "./helpers/checkTimeSeriesDataPoints";
import { checkValueIsInRange } from "./helpers/checkValueIsInRange";

const parameterLabels = {
country: "Country",
Expand All @@ -19,16 +21,12 @@ test.beforeAll(async () => {
checkRApiServer();
});

// We are switching off visual testing until such a time as the underlying model code is stable enough that these
// screenshots are not constantly changing, and are not so brittle.
const useVisualScreenshotTesting = false;

const expectSelectParameterToHaveValueLabel = async (page: Page, parameterLabel: string, expectedValueLabel: string) => {
await expect(page.getByRole("combobox", { name: parameterLabel }).locator(".single-value"))
.toHaveText(expectedValueLabel);
};

test("Can request a scenario analysis run", async ({ page, baseURL, headless }) => {
test("Can request a scenario analysis run", async ({ page, baseURL }) => {
await waitForNewScenarioPage(page, baseURL);

await selectParameterOption(page, "pathogen", "SARS 2004");
Expand Down Expand Up @@ -92,30 +90,42 @@ test("Can request a scenario analysis run", async ({ page, baseURL, headless })
await expect(page.locator("#vaccinated-container .highcharts-yaxis-labels")).toBeVisible();
await expect(page.locator("#vaccinated-container").getByLabel("View chart menu, Chart")).toBeVisible();

const prevalence1DataStr = await page.locator("#prevalence-container").getAttribute("data-test");
const prevalence1Data = JSON.parse(prevalence1DataStr!);
const prevalenceTimeSeries1LastY = prevalence1Data.lastDataPoint[1];

await checkTimeSeriesDataPoints(page.locator("#prevalence-container"), [1, 331.0026], [600, 110_000]);
await checkTimeSeriesDataPoints(page.locator("#hospitalised-container"), [1, 0], [600, 55_000]);
await checkTimeSeriesDataPoints(page.locator("#dead-container"), [1, 0], [600, 800_000]);
await checkTimeSeriesDataPoints(page.locator("#vaccinated-container"), [1, 0], [600, 200_000_000]);

await expect(page.locator("#costsChartContainer rect").first()).toBeVisible();

// To regenerate these screenshots:
// 1. Insert a generous timeout so that screenshots are of the final chart, not the chart half-way through
// its initialization animation: `await page.waitForTimeout(10000);`
// 2. Delete the screenshots directory, ./<this-file-name>-snapshots
// 3. Run the tests with `npm run test:e2e` to regenerate the screenshots - tests will appear to fail the first time.
// Make sure to stop any local development server first so that Playwright runs its own server, in production mode, so that the
// Nuxt devtools are not present in the screenshots.
if (headless && useVisualScreenshotTesting) {
await expect(page.locator(".accordion-body").first()).toHaveScreenshot("first-time-series.png", { maxDiffPixelRatio: 0.04, timeout: 15000 });
await expect(page.locator(".accordion-body").nth(1)).toHaveScreenshot("second-time-series.png", { maxDiffPixelRatio: 0.04 });
await expect(page.locator(".accordion-body").nth(2)).toHaveScreenshot("third-time-series.png", { maxDiffPixelRatio: 0.04 });
await expect(page.locator(".accordion-body").nth(3)).toHaveScreenshot("fourth-time-series.png", { maxDiffPixelRatio: 0.04 });
await expect(page.locator("#costsChartContainer rect").first()).toHaveScreenshot("costs-pie.png", { maxDiffPixelRatio: 0.04 });

// Test re-sizing of the charts
await page.getByRole("button", { name: "Prevalence" }).click();
await expect(page.locator(".accordion-body").nth(2)).toHaveScreenshot("third-time-series-taller.png", { maxDiffPixelRatio: 0.04 });
await page.getByRole("button", { name: "Hospital demand" }).click();
await expect(page.locator(".accordion-body").nth(2)).toHaveScreenshot("third-time-series-tallest.png", { maxDiffPixelRatio: 0.04 });
} else {
console.warn("No screenshot comparison");
}
const costsPieDataStr = await page.locator("#costsChartContainer").getAttribute("data-test");
const costsPieData = JSON.parse(costsPieDataStr!);
expect(costsPieData).toHaveLength(12);

const expectedCostData = [
{ id: "total", parent: "", name: "Total", value: 16_000_000 },
{ id: "gdp", parent: "total", name: "GDP", value: 8_000_000 },
{ id: "education", parent: "total", name: "Education", value: 6_000_000 },
{ id: "life_years", parent: "total", name: "Life years", value: 2_250_000 },
{ id: "gdp_closures", parent: "gdp", name: "Closures", value: 8_000_000 },
{ id: "gdp_absences", parent: "gdp", name: "Absences", value: 50_000 },
{ id: "education_closures", parent: "education", name: "Closures", value: 6_000_000 },
{ id: "education_absences", parent: "education", name: "Absences", value: 100 },
{ id: "life_years_pre_school", parent: "life_years", name: "Preschool-age children", value: 250_000 },
{ id: "life_years_school_age", parent: "life_years", name: "School-age children", value: 1_200_000 },
{ id: "life_years_working_age", parent: "life_years", name: "Working-age adults", value: 600_000 },
{ id: "life_years_retirement_age", parent: "life_years", name: "Retirement-age adults", value: 220_000 },
];

expectedCostData.forEach((expectedCost) => {
const cost = costsPieData.find((cost: any) => cost.id === expectedCost.id);
expect(cost.name).toEqual(expectedCost.name);
expect(cost.parent).toBe(expectedCost.parent);
checkValueIsInRange(cost.value, expectedCost.value, 0.5);
});

// Run a second analysis with a different parameter, using the parameters form on the results page.
await page.getByRole("button", { name: "Parameters" }).first().click();
Expand Down Expand Up @@ -161,11 +171,25 @@ test("Can request a scenario analysis run", async ({ page, baseURL, headless })
const closeButton = page.getByLabel("Edit parameters").getByLabel("Close");
await closeButton.click();

// Test that the second analysis results page has charts of both types.
// Test that the second analysis results page has visible charts of both types.
await expect(page.locator("#prevalence-container")).toBeVisible({ timeout: 20000 });
await expect(page.locator("#prevalence-container .highcharts-xaxis-labels")).toBeVisible();
await expect(page.locator("#costsChartContainer rect").first()).toBeVisible();

// Test that one of the time series charts for the second analysis has different data from the first analysis.
const prevalence2DataStr = await page.locator("#prevalence-container").getAttribute("data-test");
const prevalence2Data = JSON.parse(prevalence2DataStr!);
const prevalenceTimeSeries2LastY = prevalence2Data.lastDataPoint[1];
expect(prevalenceTimeSeries2LastY).not.toEqual(prevalenceTimeSeries1LastY);

// Test that the second analysis' costs pie chart has different data from the first.
const costsPie2DataStr = await page.locator("#costsChartContainer").getAttribute("data-test");
const costsPie2Data = JSON.parse(costsPie2DataStr!);
expect(costsPie2Data).toHaveLength(12);
costsPie2Data.forEach((cost: any, index: number) => {
expect(cost.value).not.toEqual(costsPieData[index].value);
});

// Test that the user can navigate to previously-run analyses, including when the page is initially rendered server-side.
await page.goto(urlOfFirstAnalysis);
await page.waitForURL(urlOfFirstAnalysis);
Expand Down
1 change: 1 addition & 0 deletions types/dataTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type TimeSeriesDataPoint = [number, number];
Loading